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:
@@ -100,6 +100,26 @@ func (h *TasksHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *TasksHandler) Complete(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var req models.CompleteTaskRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
task, err := h.db.CompleteTask(r.Context(), id, req)
|
||||
if err != nil {
|
||||
slog.Error("failed to complete task", "error", err, "id", id)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(task)
|
||||
}
|
||||
|
||||
func (h *TasksHandler) PostComment(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ func (s *Server) routes() chi.Router {
|
||||
r.Post("/tasks", tasksH.Create)
|
||||
r.Get("/tasks/{id}", tasksH.Get)
|
||||
r.Patch("/tasks/{id}", tasksH.UpdateStatus)
|
||||
r.Post("/tasks/{id}/complete", tasksH.Complete)
|
||||
r.Post("/tasks/{id}/comment", tasksH.PostComment)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
@@ -13,11 +16,14 @@ import (
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
|
||||
forgebotv1alpha1 "git.unkin.net/unkin/forgebot/api/v1alpha1"
|
||||
"git.unkin.net/unkin/forgebot/pkg/models"
|
||||
)
|
||||
|
||||
type AgentTaskReconciler struct {
|
||||
client.Client
|
||||
Scheme *runtime.Scheme
|
||||
Scheme *runtime.Scheme
|
||||
APIURL string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// +kubebuilder:rbac:groups=forgebot.unkin.net,resources=agenttasks,verbs=get;list;watch;create;update;patch;delete
|
||||
@@ -91,6 +97,7 @@ func (r *AgentTaskReconciler) handleRunning(ctx context.Context, task *forgebotv
|
||||
if err := r.Status().Update(ctx, task); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
r.completeAPITask(ctx, task, models.CompleteTaskRequest{})
|
||||
logger.Info("task succeeded", "task", task.Name)
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
@@ -103,6 +110,7 @@ func (r *AgentTaskReconciler) handleRunning(ctx context.Context, task *forgebotv
|
||||
if err := r.Status().Update(ctx, task); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
r.completeAPITask(ctx, task, models.CompleteTaskRequest{ErrorMessage: "job failed"})
|
||||
logger.Info("task failed", "task", task.Name)
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
@@ -178,6 +186,30 @@ func (r *AgentTaskReconciler) buildJob(task *forgebotv1alpha1.AgentTask, pool *f
|
||||
}
|
||||
}
|
||||
|
||||
func (r *AgentTaskReconciler) completeAPITask(ctx context.Context, task *forgebotv1alpha1.AgentTask, req models.CompleteTaskRequest) {
|
||||
if r.APIURL == "" {
|
||||
return
|
||||
}
|
||||
apiTaskID := task.Annotations["forgebot.unkin.net/api-task-id"]
|
||||
if apiTaskID == "" {
|
||||
return
|
||||
}
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
body, _ := json.Marshal(req)
|
||||
httpReq, _ := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
r.APIURL+"/api/v1/tasks/"+apiTaskID+"/complete", bytes.NewReader(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
httpClient := r.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
if _, err := httpClient.Do(httpReq); err != nil {
|
||||
logger.Error(err, "failed to complete API task", "apiTaskID", apiTaskID)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *AgentTaskReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
For(&forgebotv1alpha1.AgentTask{}).
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -47,7 +48,7 @@ func (r *ProviderQueueReconciler) Reconcile(ctx context.Context, req ctrl.Reques
|
||||
httpClient = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
|
||||
resp, err := httpClient.Get(queue.Spec.Endpoint + "/tasks?status=pending")
|
||||
resp, err := httpClient.Get(queue.Spec.Endpoint + "/tasks?status=todo")
|
||||
if err != nil {
|
||||
now := metav1.Now()
|
||||
queue.Status.LastPoll = &now
|
||||
@@ -95,6 +96,9 @@ func (r *ProviderQueueReconciler) Reconcile(ctx context.Context, req ctrl.Reques
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: fmt.Sprintf("task-%s", task.ID[:8]),
|
||||
Namespace: req.Namespace,
|
||||
Annotations: map[string]string{
|
||||
"forgebot.unkin.net/api-task-id": task.ID,
|
||||
},
|
||||
},
|
||||
Spec: forgebotv1alpha1.AgentTaskSpec{
|
||||
PoolRef: binding.Spec.AgentPoolRef,
|
||||
@@ -119,6 +123,14 @@ func (r *ProviderQueueReconciler) Reconcile(ctx context.Context, req ctrl.Reques
|
||||
continue
|
||||
}
|
||||
|
||||
patchURL := queue.Spec.Endpoint + "/tasks/" + task.ID
|
||||
patchBody := fmt.Sprintf(`{"status":"in_progress","jobName":"%s"}`, agentTask.Name)
|
||||
patchReq, _ := http.NewRequestWithContext(ctx, http.MethodPatch, patchURL, strings.NewReader(patchBody))
|
||||
patchReq.Header.Set("Content-Type", "application/json")
|
||||
if _, err := httpClient.Do(patchReq); err != nil {
|
||||
logger.Error(err, "failed to update task status", "task", task.ID)
|
||||
}
|
||||
|
||||
queue.Status.TasksCreated++
|
||||
logger.Info("created AgentTask", "task", agentTask.Name, "command", task.Command)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ import (
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
)
|
||||
|
||||
func SetupAll(mgr ctrl.Manager) error {
|
||||
type SetupOptions struct {
|
||||
APIURL string
|
||||
}
|
||||
|
||||
func SetupAll(mgr ctrl.Manager, opts SetupOptions) error {
|
||||
if err := (&AgentPoolReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
Scheme: mgr.GetScheme(),
|
||||
@@ -15,6 +19,7 @@ func SetupAll(mgr ctrl.Manager) error {
|
||||
if err := (&AgentTaskReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
Scheme: mgr.GetScheme(),
|
||||
APIURL: opts.APIURL,
|
||||
}).SetupWithManager(mgr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ func (db *DB) migrate() error {
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
author TEXT NOT NULL,
|
||||
extra_tools TEXT[] NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
status TEXT NOT NULL DEFAULT 'todo',
|
||||
pool_ref TEXT NOT NULL DEFAULT '',
|
||||
job_name TEXT NOT NULL DEFAULT '',
|
||||
result TEXT NOT NULL DEFAULT '',
|
||||
@@ -30,6 +30,12 @@ func (db *DB) migrate() error {
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_repository ON tasks(repository);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id);
|
||||
|
||||
-- migrate legacy statuses
|
||||
UPDATE tasks SET status = 'todo' WHERE status IN ('pending', 'failed');
|
||||
UPDATE tasks SET status = 'in_progress' WHERE status = 'running';
|
||||
UPDATE tasks SET status = 'done' WHERE status = 'succeeded';
|
||||
UPDATE tasks SET status = 'wontdo' WHERE status = 'cancelled';
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -24,7 +25,7 @@ func (db *DB) CreateTask(ctx context.Context, req models.CreateTaskRequest) (*mo
|
||||
ExtraTools: req.ExtraTools,
|
||||
ParentTaskID: req.ParentTaskID,
|
||||
PoolRef: req.PoolRef,
|
||||
Status: models.StatusPending,
|
||||
Status: models.StatusTodo,
|
||||
}
|
||||
if task.ExtraTools == nil {
|
||||
task.ExtraTools = []string{}
|
||||
@@ -79,7 +80,7 @@ func (db *DB) GetTask(ctx context.Context, id string) (*models.Task, error) {
|
||||
}
|
||||
|
||||
func (db *DB) ListPendingTasks(ctx context.Context) ([]models.Task, error) {
|
||||
return db.listTasksByStatus(ctx, string(models.StatusPending))
|
||||
return db.listTasksByStatus(ctx, string(models.StatusTodo))
|
||||
}
|
||||
|
||||
func (db *DB) listTasksByStatus(ctx context.Context, status string) ([]models.Task, error) {
|
||||
@@ -130,13 +131,13 @@ func (db *DB) ListTasks(ctx context.Context, status string, repository string) (
|
||||
}
|
||||
|
||||
func (db *DB) UpdateTaskStatus(ctx context.Context, id string, req models.UpdateTaskRequest) error {
|
||||
if req.Status == models.StatusRunning {
|
||||
if req.Status == models.StatusInProgress {
|
||||
_, err := db.Pool.Exec(ctx, `
|
||||
UPDATE tasks SET status = $2, job_name = COALESCE(NULLIF($3, ''), job_name), started_at = NOW()
|
||||
WHERE id = $1`, id, req.Status, req.JobName)
|
||||
return err
|
||||
}
|
||||
if req.Status == models.StatusSucceeded || req.Status == models.StatusFailed {
|
||||
if req.Status == models.StatusDone || req.Status == models.StatusWontdo {
|
||||
_, err := db.Pool.Exec(ctx, `
|
||||
UPDATE tasks SET status = $2, result = COALESCE(NULLIF($3, ''), result),
|
||||
error_message = COALESCE(NULLIF($4, ''), error_message), completed_at = NOW()
|
||||
@@ -147,6 +148,70 @@ func (db *DB) UpdateTaskStatus(ctx context.Context, id string, req models.Update
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) CompleteTask(ctx context.Context, id string, req models.CompleteTaskRequest) (*models.Task, error) {
|
||||
task, err := db.GetTask(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.ErrorMessage != "" {
|
||||
_, err := db.Pool.Exec(ctx, `
|
||||
UPDATE tasks SET status = 'todo', error_message = $2, completed_at = NOW()
|
||||
WHERE id = $1`, id, req.ErrorMessage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task.Status = models.StatusTodo
|
||||
task.ErrorMessage = req.ErrorMessage
|
||||
return task, nil
|
||||
}
|
||||
|
||||
if req.Result != "" {
|
||||
_, err := db.Pool.Exec(ctx, `
|
||||
UPDATE tasks SET result = $2 WHERE id = $1`, id, req.Result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task.Result = req.Result
|
||||
}
|
||||
|
||||
if task.Command != "review" {
|
||||
_, err := db.Pool.Exec(ctx, `
|
||||
UPDATE tasks SET status = 'in_review', completed_at = NOW()
|
||||
WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task.Status = models.StatusInReview
|
||||
|
||||
reviewTask := models.CreateTaskRequest{
|
||||
Command: "review",
|
||||
Repository: task.Repository,
|
||||
Ref: task.Ref,
|
||||
IssueNumber: task.IssueNumber,
|
||||
PRNumber: task.PRNumber,
|
||||
Body: task.Body,
|
||||
Author: task.Author,
|
||||
ParentTaskID: task.ID,
|
||||
PoolRef: task.PoolRef,
|
||||
}
|
||||
if _, err := db.CreateTask(ctx, reviewTask); err != nil {
|
||||
return nil, fmt.Errorf("create review task: %w", err)
|
||||
}
|
||||
|
||||
return task, nil
|
||||
}
|
||||
|
||||
_, err = db.Pool.Exec(ctx, `
|
||||
UPDATE tasks SET status = 'in_review', completed_at = NOW()
|
||||
WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task.Status = models.StatusInReview
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func scanTasks(rows pgx.Rows) ([]models.Task, error) {
|
||||
var tasks []models.Task
|
||||
for rows.Next() {
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"git.unkin.net/unkin/forgebot/pkg/models"
|
||||
)
|
||||
|
||||
type viewMode int
|
||||
|
||||
const (
|
||||
viewBoard viewMode = iota
|
||||
viewDetail
|
||||
viewFilter
|
||||
)
|
||||
|
||||
type tasksLoadedMsg struct {
|
||||
tasks []models.Task
|
||||
err error
|
||||
}
|
||||
|
||||
type taskUpdatedMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type tickMsg time.Time
|
||||
|
||||
type App struct {
|
||||
client *Client
|
||||
board board
|
||||
detail detailView
|
||||
mode viewMode
|
||||
width int
|
||||
height int
|
||||
err error
|
||||
help help.Model
|
||||
showHelp bool
|
||||
filter textinput.Model
|
||||
filterRepo string
|
||||
}
|
||||
|
||||
func NewApp(apiURL string) App {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "owner/repo"
|
||||
ti.CharLimit = 100
|
||||
|
||||
return App{
|
||||
client: NewClient(apiURL),
|
||||
board: newBoard(),
|
||||
detail: newDetailView(),
|
||||
help: help.New(),
|
||||
filter: ti,
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) Init() tea.Cmd {
|
||||
return tea.Batch(fetchTasks(a.client, a.filterRepo), tickCmd())
|
||||
}
|
||||
|
||||
func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
a.width = msg.Width
|
||||
a.height = msg.Height
|
||||
a.help.Width = msg.Width
|
||||
return a, nil
|
||||
|
||||
case tasksLoadedMsg:
|
||||
if msg.err != nil {
|
||||
a.err = msg.err
|
||||
} else {
|
||||
a.err = nil
|
||||
a.board.loadTasks(msg.tasks)
|
||||
}
|
||||
return a, nil
|
||||
|
||||
case taskUpdatedMsg:
|
||||
if msg.err != nil {
|
||||
a.err = msg.err
|
||||
}
|
||||
return a, fetchTasks(a.client, a.filterRepo)
|
||||
|
||||
case editorFinishedMsg:
|
||||
if msg.err != nil {
|
||||
a.err = msg.err
|
||||
}
|
||||
return a, fetchTasks(a.client, a.filterRepo)
|
||||
|
||||
case tickMsg:
|
||||
return a, tea.Batch(fetchTasks(a.client, a.filterRepo), tickCmd())
|
||||
|
||||
case tea.KeyMsg:
|
||||
if a.mode == viewFilter {
|
||||
return a.updateFilter(msg)
|
||||
}
|
||||
if a.mode == viewDetail {
|
||||
return a.updateDetail(msg)
|
||||
}
|
||||
return a.updateBoard(msg)
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a App) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, keys.Quit):
|
||||
return a, tea.Quit
|
||||
|
||||
case key.Matches(msg, keys.Left):
|
||||
a.board.moveLeft()
|
||||
|
||||
case key.Matches(msg, keys.Right):
|
||||
a.board.moveRight()
|
||||
|
||||
case key.Matches(msg, keys.Up):
|
||||
a.board.moveUp()
|
||||
|
||||
case key.Matches(msg, keys.Down):
|
||||
a.board.moveDown()
|
||||
|
||||
case key.Matches(msg, keys.Enter):
|
||||
if t := a.board.selectedTask(); t != nil {
|
||||
a.mode = viewDetail
|
||||
a.detail.setTask(t, a.width, a.height)
|
||||
}
|
||||
|
||||
case key.Matches(msg, keys.Edit):
|
||||
if t := a.board.selectedTask(); t != nil {
|
||||
return a, editTaskCmd(t, a.client)
|
||||
}
|
||||
|
||||
case key.Matches(msg, keys.New):
|
||||
return a, newTaskEditorCmd(a.client)
|
||||
|
||||
case key.Matches(msg, keys.Done):
|
||||
if t := a.board.selectedTask(); t != nil && t.Status == models.StatusInReview {
|
||||
return a, updateTaskStatus(a.client, t.ID, models.StatusDone)
|
||||
}
|
||||
|
||||
case key.Matches(msg, keys.Wontdo):
|
||||
if t := a.board.selectedTask(); t != nil && t.Status == models.StatusInReview {
|
||||
return a, updateTaskStatus(a.client, t.ID, models.StatusWontdo)
|
||||
}
|
||||
|
||||
case key.Matches(msg, keys.Refresh):
|
||||
return a, fetchTasks(a.client, a.filterRepo)
|
||||
|
||||
case key.Matches(msg, keys.Filter):
|
||||
a.mode = viewFilter
|
||||
a.filter.SetValue(a.filterRepo)
|
||||
a.filter.Focus()
|
||||
return a, nil
|
||||
|
||||
case key.Matches(msg, keys.Help):
|
||||
a.showHelp = !a.showHelp
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a App) updateDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, keys.Back), key.Matches(msg, keys.Quit):
|
||||
a.mode = viewBoard
|
||||
return a, nil
|
||||
|
||||
case key.Matches(msg, keys.Edit):
|
||||
if a.detail.task != nil {
|
||||
return a, editTaskCmd(a.detail.task, a.client)
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
a.detail.viewport, cmd = a.detail.viewport.Update(msg)
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
func (a App) updateFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
a.filterRepo = a.filter.Value()
|
||||
a.mode = viewBoard
|
||||
a.filter.Blur()
|
||||
return a, fetchTasks(a.client, a.filterRepo)
|
||||
case "esc":
|
||||
a.mode = viewBoard
|
||||
a.filter.Blur()
|
||||
return a, nil
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
a.filter, cmd = a.filter.Update(msg)
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
func (a App) View() string {
|
||||
if a.width == 0 {
|
||||
return "Loading..."
|
||||
}
|
||||
|
||||
var content string
|
||||
switch a.mode {
|
||||
case viewDetail:
|
||||
content = a.detail.view()
|
||||
case viewFilter:
|
||||
content = a.board.view(a.width, a.height-4)
|
||||
content += "\n" + lipgloss.NewStyle().Bold(true).Render("Filter repo: ") + a.filter.View()
|
||||
default:
|
||||
content = a.board.view(a.width, a.height-3)
|
||||
}
|
||||
|
||||
var statusLine string
|
||||
if a.err != nil {
|
||||
errText := a.err.Error()
|
||||
if len(errText) > a.width-2 {
|
||||
errText = errText[:a.width-5] + "..."
|
||||
}
|
||||
statusLine = errStyle.Render(errText)
|
||||
} else if a.filterRepo != "" {
|
||||
statusLine = dimStyle.Render(fmt.Sprintf("filter: %s", a.filterRepo))
|
||||
}
|
||||
|
||||
var helpView string
|
||||
if a.showHelp && a.mode == viewBoard {
|
||||
helpView = a.help.View(keys)
|
||||
} else if a.mode == viewBoard {
|
||||
helpView = helpStyle.Render("? help q quit")
|
||||
}
|
||||
|
||||
parts := []string{content}
|
||||
if statusLine != "" {
|
||||
parts = append(parts, statusLine)
|
||||
}
|
||||
if helpView != "" {
|
||||
parts = append(parts, helpView)
|
||||
}
|
||||
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func fetchTasks(client *Client, repo string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
tasks, err := client.ListTasks(context.Background(), "", repo)
|
||||
return tasksLoadedMsg{tasks: tasks, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func updateTaskStatus(client *Client, id string, status models.TaskStatus) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := client.UpdateTask(context.Background(), id, models.UpdateTaskRequest{Status: status})
|
||||
return taskUpdatedMsg{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func tickCmd() tea.Cmd {
|
||||
return tea.Tick(5*time.Second, func(t time.Time) tea.Msg {
|
||||
return tickMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"git.unkin.net/unkin/forgebot/pkg/models"
|
||||
)
|
||||
|
||||
var columnOrder = []columnDef{
|
||||
{status: models.StatusTodo, title: "Todo", extra: ""},
|
||||
{status: models.StatusInProgress, title: "In Progress", extra: ""},
|
||||
{status: models.StatusInReview, title: "In Review", extra: ""},
|
||||
{status: models.StatusDone, title: "Done", extra: string(models.StatusWontdo)},
|
||||
}
|
||||
|
||||
type columnDef struct {
|
||||
status models.TaskStatus
|
||||
title string
|
||||
extra string
|
||||
}
|
||||
|
||||
type column struct {
|
||||
def columnDef
|
||||
tasks []models.Task
|
||||
cursor int
|
||||
offset int
|
||||
}
|
||||
|
||||
type board struct {
|
||||
columns [4]column
|
||||
activeCol int
|
||||
}
|
||||
|
||||
func newBoard() board {
|
||||
var b board
|
||||
for i, def := range columnOrder {
|
||||
b.columns[i] = column{def: def}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *board) loadTasks(tasks []models.Task) {
|
||||
for i := range b.columns {
|
||||
b.columns[i].tasks = nil
|
||||
}
|
||||
for _, t := range tasks {
|
||||
for i := range b.columns {
|
||||
col := &b.columns[i]
|
||||
if t.Status == col.def.status || string(t.Status) == col.def.extra {
|
||||
col.tasks = append(col.tasks, t)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range b.columns {
|
||||
col := &b.columns[i]
|
||||
if col.cursor >= len(col.tasks) {
|
||||
col.cursor = max(0, len(col.tasks)-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *board) selectedTask() *models.Task {
|
||||
col := &b.columns[b.activeCol]
|
||||
if len(col.tasks) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &col.tasks[col.cursor]
|
||||
}
|
||||
|
||||
func (b *board) moveLeft() {
|
||||
if b.activeCol > 0 {
|
||||
b.activeCol--
|
||||
}
|
||||
}
|
||||
|
||||
func (b *board) moveRight() {
|
||||
if b.activeCol < len(b.columns)-1 {
|
||||
b.activeCol++
|
||||
}
|
||||
}
|
||||
|
||||
func (b *board) moveUp() {
|
||||
col := &b.columns[b.activeCol]
|
||||
if col.cursor > 0 {
|
||||
col.cursor--
|
||||
}
|
||||
}
|
||||
|
||||
func (b *board) moveDown() {
|
||||
col := &b.columns[b.activeCol]
|
||||
if col.cursor < len(col.tasks)-1 {
|
||||
col.cursor++
|
||||
}
|
||||
}
|
||||
|
||||
func (b *board) view(width, height int) string {
|
||||
colWidth := width / 4
|
||||
if colWidth < 20 {
|
||||
colWidth = 20
|
||||
}
|
||||
|
||||
cardHeight := height - 4
|
||||
if cardHeight < 1 {
|
||||
cardHeight = 1
|
||||
}
|
||||
|
||||
var cols []string
|
||||
for i := range b.columns {
|
||||
cols = append(cols, b.renderColumn(i, colWidth, cardHeight))
|
||||
}
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, cols...)
|
||||
}
|
||||
|
||||
func (b *board) renderColumn(idx, width, maxHeight int) string {
|
||||
col := &b.columns[idx]
|
||||
color := statusColors[col.def.status]
|
||||
active := idx == b.activeCol
|
||||
|
||||
titleStyle := columnTitleStyle.
|
||||
Width(width).
|
||||
Align(lipgloss.Center).
|
||||
Foreground(color)
|
||||
if active {
|
||||
titleStyle = titleStyle.Underline(true)
|
||||
}
|
||||
|
||||
title := titleStyle.Render(fmt.Sprintf("%s (%d)", col.def.title, len(col.tasks)))
|
||||
|
||||
if col.offset > col.cursor {
|
||||
col.offset = col.cursor
|
||||
}
|
||||
|
||||
var cards []string
|
||||
usedHeight := 0
|
||||
visibleStart := col.offset
|
||||
for j := visibleStart; j < len(col.tasks); j++ {
|
||||
selected := active && j == col.cursor
|
||||
card := renderCard(col.tasks[j], width, selected)
|
||||
cardLines := strings.Count(card, "\n") + 1
|
||||
if usedHeight+cardLines > maxHeight && len(cards) > 0 {
|
||||
break
|
||||
}
|
||||
cards = append(cards, card)
|
||||
usedHeight += cardLines
|
||||
}
|
||||
|
||||
if col.cursor >= visibleStart+len(cards) && len(col.tasks) > 0 {
|
||||
col.offset = col.cursor
|
||||
return b.renderColumn(idx, width, maxHeight)
|
||||
}
|
||||
|
||||
content := strings.Join(cards, "\n")
|
||||
if len(col.tasks) == 0 {
|
||||
content = dimStyle.Width(width).Align(lipgloss.Center).Render("empty")
|
||||
}
|
||||
|
||||
scrollInfo := ""
|
||||
if col.offset > 0 {
|
||||
scrollInfo = dimStyle.Render(fmt.Sprintf("↑ %d more", col.offset))
|
||||
}
|
||||
remaining := len(col.tasks) - visibleStart - len(cards)
|
||||
if remaining > 0 {
|
||||
if scrollInfo != "" {
|
||||
scrollInfo += " "
|
||||
}
|
||||
scrollInfo += dimStyle.Render(fmt.Sprintf("↓ %d more", remaining))
|
||||
}
|
||||
|
||||
parts := []string{title}
|
||||
if scrollInfo != "" {
|
||||
parts = append(parts, scrollInfo)
|
||||
}
|
||||
parts = append(parts, content)
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"git.unkin.net/unkin/forgebot/pkg/models"
|
||||
)
|
||||
|
||||
func renderCard(task models.Task, width int, selected bool) string {
|
||||
color := statusColors[task.Status]
|
||||
|
||||
style := cardStyle.Width(width - 4)
|
||||
if selected {
|
||||
style = cardSelectedStyle.Width(width - 4).BorderForeground(color)
|
||||
}
|
||||
|
||||
cmd := lipgloss.NewStyle().Bold(true).Render(task.Command)
|
||||
|
||||
repo := task.Repository
|
||||
maxRepo := width - 6
|
||||
if maxRepo > 0 && len(repo) > maxRepo {
|
||||
repo = repo[:maxRepo-1] + "…"
|
||||
}
|
||||
repo = dimStyle.Render(repo)
|
||||
|
||||
var ref string
|
||||
if task.IssueNumber > 0 {
|
||||
ref = fmt.Sprintf("#%d", task.IssueNumber)
|
||||
} else if task.PRNumber > 0 {
|
||||
ref = fmt.Sprintf("PR#%d", task.PRNumber)
|
||||
}
|
||||
if task.Ref != "" {
|
||||
if ref != "" {
|
||||
ref += " " + task.Ref
|
||||
} else {
|
||||
ref = task.Ref
|
||||
}
|
||||
}
|
||||
ref = dimStyle.Render(ref)
|
||||
|
||||
elapsed := relativeTime(task.CreatedAt)
|
||||
if task.Status == models.StatusInProgress && task.StartedAt != nil {
|
||||
elapsed = relativeTime(*task.StartedAt)
|
||||
}
|
||||
if (task.Status == models.StatusDone || task.Status == models.StatusWontdo) && task.CompletedAt != nil {
|
||||
elapsed = relativeTime(*task.CompletedAt)
|
||||
}
|
||||
|
||||
bottomLine := elapsed
|
||||
if task.Author != "" {
|
||||
pad := width - 6 - len(elapsed) - len(task.Author)
|
||||
if pad < 1 {
|
||||
pad = 1
|
||||
}
|
||||
bottomLine = elapsed + strings.Repeat(" ", pad) + task.Author
|
||||
}
|
||||
bottomLine = dimStyle.Render(bottomLine)
|
||||
|
||||
content := lipgloss.JoinVertical(lipgloss.Left, cmd, repo, ref, bottomLine)
|
||||
|
||||
if task.Status == models.StatusWontdo {
|
||||
content = dimStyle.Render(content)
|
||||
}
|
||||
if task.ErrorMessage != "" && task.Status == models.StatusTodo {
|
||||
errHint := task.ErrorMessage
|
||||
if len(errHint) > width-6 {
|
||||
errHint = errHint[:width-7] + "…"
|
||||
}
|
||||
content += "\n" + errStyle.Render(errHint)
|
||||
}
|
||||
|
||||
return style.Render(content)
|
||||
}
|
||||
|
||||
func relativeTime(t time.Time) string {
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||
default:
|
||||
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/forgebot/pkg/models"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) ListTasks(ctx context.Context, status, repository string) ([]models.Task, error) {
|
||||
u := c.baseURL + "/api/v1/tasks"
|
||||
params := url.Values{}
|
||||
if status != "" {
|
||||
params.Set("status", status)
|
||||
}
|
||||
if repository != "" {
|
||||
params.Set("repository", repository)
|
||||
}
|
||||
if len(params) > 0 {
|
||||
u += "?" + params.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("GET %s: %d %s", u, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tasks []models.Task
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tasks); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetTask(ctx context.Context, id string) (*models.Task, error) {
|
||||
u := c.baseURL + "/api/v1/tasks/" + id
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("GET %s: %d %s", u, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var task models.Task
|
||||
if err := json.NewDecoder(resp.Body).Decode(&task); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateTask(ctx context.Context, req models.CreateTaskRequest) (*models.Task, error) {
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u := c.baseURL + "/api/v1/tasks"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("POST %s: %d %s", u, resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var task models.Task
|
||||
if err := json.NewDecoder(resp.Body).Decode(&task); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
func (c *Client) UpdateTask(ctx context.Context, id string, req models.UpdateTaskRequest) error {
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u := c.baseURL + "/api/v1/tasks/" + id
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPatch, u, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("PATCH %s: %d %s", u, resp.StatusCode, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"git.unkin.net/unkin/forgebot/pkg/models"
|
||||
)
|
||||
|
||||
type editorFinishedMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func editTaskCmd(task *models.Task, client *Client) tea.Cmd {
|
||||
data := marshalTaskForEdit(task)
|
||||
tmpFile, err := os.CreateTemp("", "forgebot-task-*.yaml")
|
||||
if err != nil {
|
||||
return func() tea.Msg { return editorFinishedMsg{err: err} }
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
if _, err := tmpFile.Write(data); err != nil {
|
||||
tmpFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
return func() tea.Msg { return editorFinishedMsg{err: err} }
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
editor := resolveEditor()
|
||||
c := exec.Command(editor, tmpPath)
|
||||
|
||||
return tea.ExecProcess(c, func(err error) tea.Msg {
|
||||
defer os.Remove(tmpPath)
|
||||
if err != nil {
|
||||
return editorFinishedMsg{err: err}
|
||||
}
|
||||
|
||||
edited, err := os.ReadFile(tmpPath)
|
||||
if err != nil {
|
||||
return editorFinishedMsg{err: err}
|
||||
}
|
||||
|
||||
et, err := unmarshalEditedTask(edited)
|
||||
if err != nil {
|
||||
return editorFinishedMsg{err: err}
|
||||
}
|
||||
|
||||
diff := diffEditableTask(task, et)
|
||||
if diff == nil {
|
||||
return editorFinishedMsg{}
|
||||
}
|
||||
|
||||
err = client.UpdateTask(context.Background(), task.ID, *diff)
|
||||
return editorFinishedMsg{err: err}
|
||||
})
|
||||
}
|
||||
|
||||
func newTaskEditorCmd(client *Client) tea.Cmd {
|
||||
data := marshalNewTask()
|
||||
tmpFile, err := os.CreateTemp("", "forgebot-new-*.yaml")
|
||||
if err != nil {
|
||||
return func() tea.Msg { return editorFinishedMsg{err: err} }
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
if _, err := tmpFile.Write(data); err != nil {
|
||||
tmpFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
return func() tea.Msg { return editorFinishedMsg{err: err} }
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
editor := resolveEditor()
|
||||
c := exec.Command(editor, tmpPath)
|
||||
|
||||
return tea.ExecProcess(c, func(err error) tea.Msg {
|
||||
defer os.Remove(tmpPath)
|
||||
if err != nil {
|
||||
return editorFinishedMsg{err: err}
|
||||
}
|
||||
|
||||
edited, err := os.ReadFile(tmpPath)
|
||||
if err != nil {
|
||||
return editorFinishedMsg{err: err}
|
||||
}
|
||||
|
||||
req, err := unmarshalNewTask(edited)
|
||||
if err != nil {
|
||||
return editorFinishedMsg{err: err}
|
||||
}
|
||||
|
||||
_, err = client.CreateTask(context.Background(), *req)
|
||||
return editorFinishedMsg{err: err}
|
||||
})
|
||||
}
|
||||
|
||||
func resolveEditor() string {
|
||||
if e := os.Getenv("EDITOR"); e != "" {
|
||||
return e
|
||||
}
|
||||
if e := os.Getenv("VISUAL"); e != "" {
|
||||
return e
|
||||
}
|
||||
return "vi"
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package tui
|
||||
|
||||
import "github.com/charmbracelet/bubbles/key"
|
||||
|
||||
type keyMap struct {
|
||||
Left key.Binding
|
||||
Right key.Binding
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Enter key.Binding
|
||||
Edit key.Binding
|
||||
New key.Binding
|
||||
Done key.Binding
|
||||
Wontdo key.Binding
|
||||
Refresh key.Binding
|
||||
Filter key.Binding
|
||||
Help key.Binding
|
||||
Quit key.Binding
|
||||
Back key.Binding
|
||||
}
|
||||
|
||||
var keys = keyMap{
|
||||
Left: key.NewBinding(
|
||||
key.WithKeys("h", "left"),
|
||||
key.WithHelp("h/←", "prev column"),
|
||||
),
|
||||
Right: key.NewBinding(
|
||||
key.WithKeys("l", "right"),
|
||||
key.WithHelp("l/→", "next column"),
|
||||
),
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("k", "up"),
|
||||
key.WithHelp("k/↑", "up"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("j", "down"),
|
||||
key.WithHelp("j/↓", "down"),
|
||||
),
|
||||
Enter: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "detail"),
|
||||
),
|
||||
Edit: key.NewBinding(
|
||||
key.WithKeys("e"),
|
||||
key.WithHelp("e", "edit"),
|
||||
),
|
||||
New: key.NewBinding(
|
||||
key.WithKeys("n"),
|
||||
key.WithHelp("n", "new task"),
|
||||
),
|
||||
Done: key.NewBinding(
|
||||
key.WithKeys("d"),
|
||||
key.WithHelp("d", "mark done"),
|
||||
),
|
||||
Wontdo: key.NewBinding(
|
||||
key.WithKeys("w"),
|
||||
key.WithHelp("w", "mark wontdo"),
|
||||
),
|
||||
Refresh: key.NewBinding(
|
||||
key.WithKeys("r"),
|
||||
key.WithHelp("r", "refresh"),
|
||||
),
|
||||
Filter: key.NewBinding(
|
||||
key.WithKeys("/"),
|
||||
key.WithHelp("/", "filter repo"),
|
||||
),
|
||||
Help: key.NewBinding(
|
||||
key.WithKeys("?"),
|
||||
key.WithHelp("?", "help"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("q", "ctrl+c"),
|
||||
key.WithHelp("q", "quit"),
|
||||
),
|
||||
Back: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "back"),
|
||||
),
|
||||
}
|
||||
|
||||
func (k keyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{k.Left, k.Right, k.Up, k.Down, k.Enter, k.Edit, k.New, k.Done, k.Quit, k.Help}
|
||||
}
|
||||
|
||||
func (k keyMap) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{
|
||||
{k.Left, k.Right, k.Up, k.Down},
|
||||
{k.Enter, k.Edit, k.New, k.Refresh},
|
||||
{k.Done, k.Wontdo, k.Filter},
|
||||
{k.Quit, k.Back, k.Help},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"git.unkin.net/unkin/forgebot/pkg/models"
|
||||
)
|
||||
|
||||
var statusColors = map[models.TaskStatus]lipgloss.Color{
|
||||
models.StatusTodo: lipgloss.Color("3"),
|
||||
models.StatusInProgress: lipgloss.Color("4"),
|
||||
models.StatusInReview: lipgloss.Color("5"),
|
||||
models.StatusDone: lipgloss.Color("2"),
|
||||
models.StatusWontdo: lipgloss.Color("8"),
|
||||
}
|
||||
|
||||
var (
|
||||
columnTitleStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Padding(0, 1)
|
||||
|
||||
cardStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
Padding(0, 1)
|
||||
|
||||
cardSelectedStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.ThickBorder()).
|
||||
Padding(0, 1)
|
||||
|
||||
detailLabelStyle = lipgloss.NewStyle().Bold(true).Width(14)
|
||||
detailValueStyle = lipgloss.NewStyle()
|
||||
|
||||
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
||||
errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
|
||||
|
||||
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
||||
)
|
||||
@@ -0,0 +1,134 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user