Files
forgebot/internal/tui/detail.go
T
unkinben 8f48dd838b Add TUI kanban board, review workflow, and new task statuses
Replace task statuses (pending/running/succeeded/failed/cancelled) with
a kanban workflow: todo → in_progress → in_review → done/wontdo.

When a non-review agent task completes, the API auto-creates a child
review task and moves the parent to in_review. Only humans can move
tasks from in_review to done/wontdo via the TUI.

New components:
- cmd/tui: bubbletea kanban board with $EDITOR integration
- POST /api/v1/tasks/{id}/complete: agent completion callback
- Operator --api-url flag for completion callbacks
- ProviderQueue sets tasks to in_progress on pickup
- AgentTask reconciler calls /complete on job finish
2026-06-12 22:47:40 +10:00

118 lines
2.5 KiB
Go

package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/viewport"
"github.com/charmbracelet/lipgloss"
"git.unkin.net/unkin/forgebot/pkg/models"
)
type detailView struct {
task *models.Task
viewport viewport.Model
ready bool
}
func newDetailView() detailView {
return detailView{}
}
func (d *detailView) setTask(task *models.Task, width, height int) {
d.task = task
d.viewport = viewport.New(width, height-2)
d.viewport.SetContent(d.renderContent(width))
d.ready = true
}
func (d *detailView) renderContent(width int) string {
t := d.task
if t == nil {
return ""
}
var b strings.Builder
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(statusColors[t.Status])
b.WriteString(titleStyle.Render(fmt.Sprintf("Task: %s", t.ID)))
b.WriteString("\n\n")
row := func(label, value string) {
b.WriteString(detailLabelStyle.Render(label))
b.WriteString(detailValueStyle.Render(value))
b.WriteString("\n")
}
row("Status:", string(t.Status))
row("Command:", t.Command)
row("Repository:", t.Repository)
row("Ref:", t.Ref)
row("Author:", t.Author)
if t.PoolRef != "" {
row("Pool:", t.PoolRef)
}
if t.IssueNumber > 0 {
row("Issue:", fmt.Sprintf("#%d", t.IssueNumber))
}
if t.PRNumber > 0 {
row("PR:", fmt.Sprintf("#%d", t.PRNumber))
}
if t.Skill != "" {
row("Skill:", t.Skill)
}
if t.JobName != "" {
row("Job:", t.JobName)
}
if t.ParentTaskID != "" {
row("Parent:", t.ParentTaskID)
}
b.WriteString("\n")
row("Created:", t.CreatedAt.Format("2006-01-02 15:04:05"))
if t.StartedAt != nil {
row("Started:", t.StartedAt.Format("2006-01-02 15:04:05"))
}
if t.CompletedAt != nil {
row("Completed:", t.CompletedAt.Format("2006-01-02 15:04:05"))
}
if t.Body != "" {
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Bold(true).Render("Body:"))
b.WriteString("\n")
b.WriteString(t.Body)
b.WriteString("\n")
}
if t.Result != "" {
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Bold(true).Render("Result:"))
b.WriteString("\n")
b.WriteString(t.Result)
b.WriteString("\n")
}
if t.ErrorMessage != "" {
b.WriteString("\n")
b.WriteString(errStyle.Bold(true).Render("Error:"))
b.WriteString("\n")
b.WriteString(errStyle.Render(t.ErrorMessage))
b.WriteString("\n")
}
return b.String()
}
func (d *detailView) view() string {
if !d.ready {
return ""
}
header := lipgloss.NewStyle().Bold(true).Render("Task Detail") +
" " + helpStyle.Render("esc=back e=edit j/k=scroll")
return header + "\n" + d.viewport.View()
}