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
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user