Files
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

135 lines
3.2 KiB
Go

package tui
import (
"fmt"
"strings"
"gopkg.in/yaml.v3"
"git.unkin.net/unkin/forgebot/pkg/models"
)
type editableTask struct {
Status string `yaml:"status"`
Message string `yaml:"message"`
ErrorMessage string `yaml:"error_message"`
}
type newTask struct {
Command string `yaml:"command"`
Repository string `yaml:"repository"`
Ref string `yaml:"ref"`
Body string `yaml:"body"`
Author string `yaml:"author"`
Skill string `yaml:"skill"`
PoolRef string `yaml:"pool_ref"`
}
func marshalTaskForEdit(task *models.Task) []byte {
var b strings.Builder
fmt.Fprintf(&b, "# forgebot task %s\n", task.ID)
b.WriteString("# Editable: status, message, error_message\n\n")
et := editableTask{
Status: string(task.Status),
Message: task.Result,
ErrorMessage: task.ErrorMessage,
}
data, _ := yaml.Marshal(et)
b.Write(data)
b.WriteString("\n# -- Context (read-only) --\n")
fmt.Fprintf(&b, "# command: %s\n", task.Command)
fmt.Fprintf(&b, "# repository: %s\n", task.Repository)
fmt.Fprintf(&b, "# ref: %s\n", task.Ref)
fmt.Fprintf(&b, "# author: %s\n", task.Author)
if task.IssueNumber > 0 {
fmt.Fprintf(&b, "# issue: %d\n", task.IssueNumber)
}
if task.PRNumber > 0 {
fmt.Fprintf(&b, "# pr: %d\n", task.PRNumber)
}
fmt.Fprintf(&b, "# created: %s\n", task.CreatedAt.Format("2006-01-02T15:04:05Z"))
if task.Body != "" {
b.WriteString("#\n# body:\n")
for _, line := range strings.Split(task.Body, "\n") {
fmt.Fprintf(&b, "# %s\n", line)
}
}
if task.Result != "" {
b.WriteString("#\n# result:\n")
for _, line := range strings.Split(task.Result, "\n") {
fmt.Fprintf(&b, "# %s\n", line)
}
}
return []byte(b.String())
}
func unmarshalEditedTask(data []byte) (*editableTask, error) {
var et editableTask
if err := yaml.Unmarshal(data, &et); err != nil {
return nil, fmt.Errorf("parse edited task: %w", err)
}
return &et, nil
}
func diffEditableTask(original *models.Task, edited *editableTask) *models.UpdateTaskRequest {
req := &models.UpdateTaskRequest{}
changed := false
if edited.Status != string(original.Status) {
req.Status = models.TaskStatus(edited.Status)
changed = true
}
if edited.Message != original.Result {
req.Message = edited.Message
changed = true
}
if edited.ErrorMessage != original.ErrorMessage {
req.ErrorMessage = edited.ErrorMessage
changed = true
}
if !changed {
return nil
}
return req
}
func marshalNewTask() []byte {
var b strings.Builder
b.WriteString("# New forgebot task\n\n")
nt := newTask{
Command: "implement",
Repository: "",
Ref: "main",
Body: "",
Author: "",
Skill: "",
PoolRef: "",
}
data, _ := yaml.Marshal(nt)
b.Write(data)
return []byte(b.String())
}
func unmarshalNewTask(data []byte) (*models.CreateTaskRequest, error) {
var nt newTask
if err := yaml.Unmarshal(data, &nt); err != nil {
return nil, fmt.Errorf("parse new task: %w", err)
}
if nt.Command == "" || nt.Repository == "" {
return nil, fmt.Errorf("command and repository are required")
}
return &models.CreateTaskRequest{
Command: nt.Command,
Repository: nt.Repository,
Ref: nt.Ref,
Body: nt.Body,
Author: nt.Author,
Skill: nt.Skill,
PoolRef: nt.PoolRef,
}, nil
}