From 752e2d37d6a8cde36616fb839ee3c0dca3680934 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 23 Jun 2026 08:59:07 -0500 Subject: [PATCH] feat(board): live PR status badge on web cards + daemon refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI kanban already renders a live PR badge (state + checks + diff) and refreshes PR info on a tick, but the web/desktop board only showed a bare PR chip and never surfaced state or CI. This brings the web to parity and keeps the cached PR state fresh daemon-side so the badge updates without a restart. - web API: taskJSON / BoardEntry now carry a `pr` object (state, check_state, mergeable, additions, deletions) decoded from the cached PRInfoJSON. - web UI (Board.tsx): PRBadge renders state-colored icon (open/draft/merged/ closed), a CI check mark (passing/failing/pending, conflicts as failing), and diff stats; falls back to a bare chip for legacy rows without cached state. - db: UpdateTaskPRInfo records a board-change event only when the PR JSON actually changes, so the SSE feed re-pushes the board live without spamming. - executor: refreshActivePRInfo refreshes cached PR state for processing/blocked tasks with a branch on a 90s tick (batch list, rate-limit gated), carrying forward a known CI rollup so checks aren't wiped. Render cache: hashTaskCard already hashes every PR field the TUI badge reads, so the existing signature covers it — no stale-card regression. Tests: web PR payload (present/omitted, board snapshot, string mappings), db event-on-change/no-op, all touched Go packages + tsc green. Co-Authored-By: Claude Opus 4.8 --- desktop/src/api/types.ts | 15 +++ desktop/src/components/Board.tsx | 104 ++++++++++++++++-- internal/db/tasks.go | 15 +++ internal/db/tasks_test.go | 56 ++++++++++ internal/executor/executor.go | 91 +++++++++++++++- internal/web/board.go | 14 +-- internal/web/handlers.go | 120 ++++++++++++++++----- internal/web/pr_badge_test.go | 179 +++++++++++++++++++++++++++++++ 8 files changed, 546 insertions(+), 48 deletions(-) create mode 100644 internal/web/pr_badge_test.go diff --git a/desktop/src/api/types.ts b/desktop/src/api/types.ts index 112b0964..c70a7bda 100644 --- a/desktop/src/api/types.ts +++ b/desktop/src/api/types.ts @@ -29,6 +29,7 @@ export interface Task { shell_pane_id?: string; pr_url: string; pr_number?: number; + pr?: PRStatus; summary?: string; created_at: string; updated_at: string; @@ -36,6 +37,20 @@ export interface Task { completed_at?: string; } +export type PRState = "open" | "draft" | "merged" | "closed"; +export type PRCheckState = "passing" | "failing" | "pending" | ""; + +// Live PR badge payload mirrored from the cached github.PRInfo on the server. +export interface PRStatus { + number: number; + url: string; + state: PRState; + check_state: PRCheckState; + mergeable: string; + additions: number; + deletions: number; +} + export interface LogLine { id: number; line_type: string; diff --git a/desktop/src/components/Board.tsx b/desktop/src/components/Board.tsx index 13ca9387..e1d0a468 100644 --- a/desktop/src/components/Board.tsx +++ b/desktop/src/components/Board.tsx @@ -1,8 +1,8 @@ import { memo, useEffect, useRef, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; -import { GitPullRequest, Pin } from "lucide-react"; +import { Check, Clock, GitMerge, GitPullRequest, GitPullRequestClosed, Pin, X } from "lucide-react"; import { api } from "../api/client"; -import type { LogLine, Task } from "../api/types"; +import type { LogLine, PRStatus, Task } from "../api/types"; import { ageHint, type Column } from "../lib/board"; import { store, useAppSelector } from "../store"; import { checkEnvironment, inTauri } from "../tauri"; @@ -35,6 +35,91 @@ const COLUMN_DOT: Record = { done: "bg-status-done", }; +/** Per-state visual treatment for the PR badge (icon + border/text color), + * mirroring the TUI's PRStatusBadge palette. */ +const PR_STATE_STYLE: Record< + PRStatus["state"], + { Icon: typeof GitPullRequest; className: string; label: string } +> = { + open: { + Icon: GitPullRequest, + className: "border-emerald-400/40 text-emerald-600 dark:text-emerald-300", + label: "open", + }, + draft: { + Icon: GitPullRequest, + className: "border-muted-foreground/40 text-muted-foreground", + label: "draft", + }, + merged: { + Icon: GitMerge, + className: "border-purple-400/40 text-purple-600 dark:text-purple-300", + label: "merged", + }, + closed: { + Icon: GitPullRequestClosed, + className: "border-red-400/40 text-red-600 dark:text-red-300", + label: "closed", + }, +}; + +/** CI check rollup indicator appended to the badge. Conflicting merges read as a + * failure (parity with the TUI, where conflicts outrank check status). */ +function CheckMark({ pr }: { pr: PRStatus }) { + if (pr.state === "merged" || pr.state === "closed") return null; + if (pr.mergeable === "CONFLICTING") { + return ; + } + switch (pr.check_state) { + case "passing": + return ; + case "failing": + return ; + case "pending": + return ; + default: + return null; + } +} + +/** Live PR badge: state, CI checks, and diff size. Falls back to a bare PR chip + * for legacy rows that have a URL but no cached PR state yet. */ +function PRBadge({ task }: { task: Task }) { + const pr = task.pr; + if (!pr) { + if (!task.pr_url) return null; + return ( + + + {task.pr_number ? `#${task.pr_number}` : "PR"} + + ); + } + const style = PR_STATE_STYLE[pr.state] ?? PR_STATE_STYLE.open; + const { Icon } = style; + const title = `PR #${pr.number} — ${style.label}${pr.check_state ? ` · checks ${pr.check_state}` : ""}`; + return ( + + + {pr.number ? `#${pr.number}` : "PR"} + + {(pr.additions > 0 || pr.deletions > 0) && ( + + {pr.additions > 0 && +{pr.additions}} + {pr.deletions > 0 && −{pr.deletions}} + + )} + + ); +} + function useSpinner(active: boolean): string { const [frame, setFrame] = useState(0); useEffect(() => { @@ -67,6 +152,11 @@ function cardPropsEqual(prev: CardProps, next: CardProps): boolean { a.pinned === b.pinned && a.pr_url === b.pr_url && a.pr_number === b.pr_number && + a.pr?.state === b.pr?.state && + a.pr?.check_state === b.pr?.check_state && + a.pr?.mergeable === b.pr?.mergeable && + a.pr?.additions === b.pr?.additions && + a.pr?.deletions === b.pr?.deletions && a.executor === b.executor && a.project === b.project && a.updated_at === b.updated_at @@ -146,15 +236,7 @@ const CardSlot = memo(function CardSlot({ task, selected, projectColor, latest } needs input )} - {task.pr_url && ( - - - {task.pr_number ? `#${task.pr_number}` : "PR"} - - )} + {task.executor && task.executor !== "claude" && ( {task.executor} diff --git a/internal/db/tasks.go b/internal/db/tasks.go index 4a89e06a..d9495268 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -710,7 +710,18 @@ func (db *DB) UpdateTask(t *Task) error { // UpdateTaskPRInfo updates only the PR-related fields for a task. // This is used to persist PR state from GitHub API responses without touching other fields. +// +// When the cached PR JSON actually changes, it records a board-change event so the +// HTTP API's SSE stream re-pushes the board — this is how a live PR badge reaches +// the web/desktop without a restart. We compare against the stored value first so +// idle refreshes (state unchanged) don't spam the change feed every tick. func (db *DB) UpdateTaskPRInfo(taskID int64, prURL string, prNumber int, prInfoJSON string) error { + var prevJSON string + // Best-effort read of the prior value to detect real changes. A scan error + // (e.g. task gone) leaves prevJSON empty, so we fall through and emit — the + // UPDATE below will no-op on a missing row anyway. + _ = db.QueryRow(`SELECT pr_info_json FROM tasks WHERE id = ?`, taskID).Scan(&prevJSON) + _, err := db.Exec(` UPDATE tasks SET pr_url = ?, pr_number = ?, pr_info_json = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? @@ -718,6 +729,10 @@ func (db *DB) UpdateTaskPRInfo(taskID int64, prURL string, prNumber int, prInfoJ if err != nil { return fmt.Errorf("update task pr info: %w", err) } + + if prevJSON != prInfoJSON { + db.recordEvent("task.updated", taskID, "pr status") + } return nil } diff --git a/internal/db/tasks_test.go b/internal/db/tasks_test.go index 1b562cf7..3ab00f53 100644 --- a/internal/db/tasks_test.go +++ b/internal/db/tasks_test.go @@ -1718,6 +1718,62 @@ func TestUpdateTaskPRInfo(t *testing.T) { } } +// UpdateTaskPRInfo must emit a board-change event when the PR JSON actually +// changes (so the web SSE re-pushes), and must stay quiet on a no-op update so +// idle refreshes don't spam the change feed. +func TestUpdateTaskPRInfoEmitsEventOnChange(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + db, err := Open(dbPath) + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + defer db.Close() + + task := &Task{Title: "PR events", Status: StatusProcessing, Type: TypeCode, Project: "personal"} + if err := db.CreateTask(task); err != nil { + t.Fatalf("failed to create task: %v", err) + } + + prEvents := func() int { + var n int + if err := db.QueryRow(`SELECT COUNT(*) FROM event_log WHERE task_id = ? AND message = 'pr status'`, task.ID).Scan(&n); err != nil { + t.Fatalf("count events: %v", err) + } + return n + } + + if got := prEvents(); got != 0 { + t.Fatalf("expected 0 pr events initially, got %d", got) + } + + openJSON := `{"number":1,"url":"u","state":"OPEN"}` + if err := db.UpdateTaskPRInfo(task.ID, "u", 1, openJSON); err != nil { + t.Fatalf("update: %v", err) + } + if got := prEvents(); got != 1 { + t.Fatalf("expected 1 pr event after first change, got %d", got) + } + + // Same payload again — no new event. + if err := db.UpdateTaskPRInfo(task.ID, "u", 1, openJSON); err != nil { + t.Fatalf("update: %v", err) + } + if got := prEvents(); got != 1 { + t.Fatalf("expected no new event on no-op update, got %d", got) + } + + // Changed payload — one more event. + mergedJSON := `{"number":1,"url":"u","state":"MERGED"}` + if err := db.UpdateTaskPRInfo(task.ID, "u", 1, mergedJSON); err != nil { + t.Fatalf("update: %v", err) + } + if got := prEvents(); got != 2 { + t.Fatalf("expected 2 pr events after state change, got %d", got) + } +} + func TestTaskDangerousModeInListTasks(t *testing.T) { // Create temporary database tmpDir := t.TempDir() diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 5e33d4d8..8c5be991 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -849,10 +849,11 @@ func (e *Executor) worker(ctx context.Context) { // Check for stale worktrees to archive every 10 minutes (300 ticks) tickCount := 0 const suspendCheckInterval = 30 - const doneCleanupInterval = 150 // 5 minutes at 2 second ticks - const staleWorktreeInterval = 300 // 10 minutes at 2 second ticks - const authCheckInterval = 15 // 30 seconds at 2 second ticks - const reviewReconcileInterval = 30 // 60 seconds at 2 second ticks + const doneCleanupInterval = 150 // 5 minutes at 2 second ticks + const staleWorktreeInterval = 300 // 10 minutes at 2 second ticks + const authCheckInterval = 15 // 30 seconds at 2 second ticks + const reviewReconcileInterval = 30 // 60 seconds at 2 second ticks + const prDisplayRefreshInterval = 45 // 90 seconds at 2 second ticks for { select { @@ -885,6 +886,12 @@ func (e *Executor) worker(ctx context.Context) { e.reconcileReviewTasks() } + // Periodically refresh cached PR state for actively-watched tasks so + // the board's live PR badge stays current without a TUI open. + if tickCount%prDisplayRefreshInterval == 0 { + e.refreshActivePRInfo() + } + // Periodically cleanup Claude processes for inactive done tasks if tickCount%doneCleanupInterval == 0 { e.cleanupInactiveDoneTasks() @@ -980,6 +987,82 @@ func (e *Executor) reconcileReviewTasks() { } } +// refreshActivePRInfo keeps the cached PR badge fresh for tasks the human is +// actively watching — those processing or blocked with a branch — so the board +// (TUI and web) shows live PR state without waiting for completion. It runs +// daemon-side so the web/desktop stays current even when no TUI is open. +// +// It uses the batch open-PR listing (one rate-limited API call per repo), which +// carries PR state but not the CI rollup. To avoid wiping a CheckState a detail +// fetch previously learned, the prior CheckState is carried forward while the PR +// is still open. Merged/closed promotion stays the job of reconcileReviewTasks. +func (e *Executor) refreshActivePRInfo() { + if e.prCache == nil { + return + } + + // Collect actively-watched tasks across the processing and blocked columns. + var candidates []*db.Task + for _, status := range []string{db.StatusProcessing, db.StatusBlocked} { + tasks, err := e.db.ListTasks(db.ListTasksOptions{Status: status, Limit: 200}) + if err != nil { + continue + } + candidates = append(candidates, tasks...) + } + + // Group by project so each repo's open PRs are fetched once. + byProject := make(map[string][]*db.Task) + for _, task := range candidates { + if task.BranchName == "" { + continue + } + // Skip terminal PRs — their state won't change and reconcile owns promotion. + if cached := github.UnmarshalPRInfo(task.PRInfoJSON); cached != nil { + if cached.State == github.PRStateMerged || cached.State == github.PRStateClosed { + continue + } + } + byProject[task.Project] = append(byProject[task.Project], task) + } + + for project, ptasks := range byProject { + projectDir := e.getProjectDir(project) + if projectDir == "" { + continue + } + + // One rate-limited API call lists the repo's OPEN PRs. nil means gh is + // unavailable or the rate-limit guard tripped — keep cached state. + openPRs := github.FetchAllPRsForRepo(projectDir) + if openPRs == nil { + continue + } + e.prCache.UpdateCacheForRepo(projectDir, openPRs) + + for _, task := range ptasks { + info := openPRs[task.BranchName] + if info == nil { + // Absent from the open set → merged/closed; reconcileReviewTasks + // handles the terminal transition for blocked review tasks. + continue + } + + // The batch path doesn't fetch the CI rollup; carry forward the last + // known CheckState for this same PR so the badge doesn't lose its checks. + merged := *info + if merged.CheckState == github.CheckStateNone { + if prev := github.UnmarshalPRInfo(task.PRInfoJSON); prev != nil && prev.Number == merged.Number { + merged.CheckState = prev.CheckState + } + } + if err := e.db.UpdateTaskPRInfo(task.ID, merged.URL, merged.Number, github.MarshalPRInfo(&merged)); err != nil { + e.logger.Warn("refreshActivePRInfo: failed to persist PR info", "task", task.ID, "error", err) + } + } + } +} + // suspendIdleBlockedTasks finds blocked tasks that have been idle and suspends their Claude processes. func (e *Executor) suspendIdleBlockedTasks() { tasks, err := e.db.ListTasks(db.ListTasksOptions{Status: db.StatusBlocked, Limit: 100}) diff --git a/internal/web/board.go b/internal/web/board.go index 6312fe56..09779091 100644 --- a/internal/web/board.go +++ b/internal/web/board.go @@ -25,12 +25,13 @@ type BoardColumn struct { // BoardEntry is a single task card in the board. type BoardEntry struct { - ID int64 `json:"id"` - Title string `json:"title"` - Project string `json:"project"` - Type string `json:"type"` - Pinned bool `json:"pinned"` - AgeHint string `json:"age_hint"` + ID int64 `json:"id"` + Title string `json:"title"` + Project string `json:"project"` + Type string `json:"type"` + Pinned bool `json:"pinned"` + AgeHint string `json:"age_hint"` + PR *prStatusJSON `json:"pr,omitempty"` } // BuildBoardSnapshot groups tasks into kanban columns. @@ -79,6 +80,7 @@ func BuildBoardSnapshot(tasks []*db.Task, limit int) BoardSnapshot { Type: task.Type, Pinned: task.Pinned, AgeHint: boardAgeHint(task), + PR: toPRStatusJSON(task.PRInfoJSON), } column.Tasks = append(column.Tasks, entry) } diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 3d211e2c..83584ffe 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -7,6 +7,7 @@ import ( "time" "github.com/bborn/workflow/internal/db" + "github.com/bborn/workflow/internal/github" ) // --- JSON helpers --- @@ -1027,33 +1028,48 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { // --- JSON conversion helpers --- type taskJSON struct { - ID int64 `json:"id"` - Title string `json:"title"` - Body string `json:"body"` - Status string `json:"status"` - Type string `json:"type"` - Project string `json:"project"` - Executor string `json:"executor"` - Pinned bool `json:"pinned"` - Tags string `json:"tags"` - PermissionMode string `json:"permission_mode"` - BranchName string `json:"branch_name"` - Port int `json:"port,omitempty"` - WorktreePath string `json:"worktree_path,omitempty"` - HasExecutor bool `json:"has_executor"` - EffortLevel string `json:"effort_level,omitempty"` - SourceBranch string `json:"source_branch,omitempty"` - DaemonSession string `json:"daemon_session,omitempty"` - TmuxWindowID string `json:"tmux_window_id,omitempty"` - ClaudePaneID string `json:"claude_pane_id,omitempty"` - ShellPaneID string `json:"shell_pane_id,omitempty"` - PRURL string `json:"pr_url"` - PRNumber int `json:"pr_number,omitempty"` - Summary string `json:"summary,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - StartedAt string `json:"started_at,omitempty"` - CompletedAt string `json:"completed_at,omitempty"` + ID int64 `json:"id"` + Title string `json:"title"` + Body string `json:"body"` + Status string `json:"status"` + Type string `json:"type"` + Project string `json:"project"` + Executor string `json:"executor"` + Pinned bool `json:"pinned"` + Tags string `json:"tags"` + PermissionMode string `json:"permission_mode"` + BranchName string `json:"branch_name"` + Port int `json:"port,omitempty"` + WorktreePath string `json:"worktree_path,omitempty"` + HasExecutor bool `json:"has_executor"` + EffortLevel string `json:"effort_level,omitempty"` + SourceBranch string `json:"source_branch,omitempty"` + DaemonSession string `json:"daemon_session,omitempty"` + TmuxWindowID string `json:"tmux_window_id,omitempty"` + ClaudePaneID string `json:"claude_pane_id,omitempty"` + ShellPaneID string `json:"shell_pane_id,omitempty"` + PRURL string `json:"pr_url"` + PRNumber int `json:"pr_number,omitempty"` + PR *prStatusJSON `json:"pr,omitempty"` + Summary string `json:"summary,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + StartedAt string `json:"started_at,omitempty"` + CompletedAt string `json:"completed_at,omitempty"` +} + +// prStatusJSON is the live PR badge payload surfaced on board cards: the PR's +// state plus its CI rollup and diff size. It mirrors the cached github.PRInfo +// persisted in tasks.pr_info_json, lower-cased for the web client. CheckState is +// "" when no checks are known (the batch refresh path doesn't fetch them). +type prStatusJSON struct { + Number int `json:"number"` + URL string `json:"url"` + State string `json:"state"` // open | draft | merged | closed + CheckState string `json:"check_state"` // passing | failing | pending | "" + Mergeable string `json:"mergeable"` // MERGEABLE | CONFLICTING | UNKNOWN + Additions int `json:"additions"` + Deletions int `json:"deletions"` } type logJSON struct { @@ -1087,6 +1103,7 @@ func toTaskJSON(t *db.Task) *taskJSON { ShellPaneID: t.ShellPaneID, PRURL: t.PRURL, PRNumber: t.PRNumber, + PR: toPRStatusJSON(t.PRInfoJSON), Summary: t.Summary, CreatedAt: apiTime(t.CreatedAt.Time), UpdatedAt: apiTime(t.UpdatedAt.Time), @@ -1100,6 +1117,55 @@ func toTaskJSON(t *db.Task) *taskJSON { return tj } +// toPRStatusJSON decodes the cached github.PRInfo JSON persisted on a task into +// the web badge payload. Returns nil when there's no associated PR so the field +// is omitted entirely. +func toPRStatusJSON(prInfoJSON string) *prStatusJSON { + info := github.UnmarshalPRInfo(prInfoJSON) + if info == nil { + return nil + } + return &prStatusJSON{ + Number: info.Number, + URL: info.URL, + State: prStateString(info.State), + CheckState: checkStateString(info.CheckState), + Mergeable: info.Mergeable, + Additions: info.Additions, + Deletions: info.Deletions, + } +} + +// prStateString maps a github.PRState to the lower-case token the web client uses. +func prStateString(s github.PRState) string { + switch s { + case github.PRStateMerged: + return "merged" + case github.PRStateClosed: + return "closed" + case github.PRStateDraft: + return "draft" + case github.PRStateOpen: + return "open" + default: + return "" + } +} + +// checkStateString maps a github.CheckState to a web token; "" means no checks known. +func checkStateString(s github.CheckState) string { + switch s { + case github.CheckStatePassing: + return "passing" + case github.CheckStateFailing: + return "failing" + case github.CheckStatePending: + return "pending" + default: + return "" + } +} + func toTaskJSONSlice(tasks []*db.Task) []*taskJSON { result := make([]*taskJSON, len(tasks)) for i, t := range tasks { diff --git a/internal/web/pr_badge_test.go b/internal/web/pr_badge_test.go new file mode 100644 index 00000000..e4a8ffbe --- /dev/null +++ b/internal/web/pr_badge_test.go @@ -0,0 +1,179 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/bborn/workflow/internal/db" + "github.com/bborn/workflow/internal/github" +) + +func TestToPRStatusJSON(t *testing.T) { + if got := toPRStatusJSON(""); got != nil { + t.Errorf("empty JSON should yield nil, got %+v", got) + } + if got := toPRStatusJSON("not json"); got != nil { + t.Errorf("invalid JSON should yield nil, got %+v", got) + } + + info := &github.PRInfo{ + Number: 42, + URL: "https://github.com/test/repo/pull/42", + State: github.PRStateOpen, + CheckState: github.CheckStatePassing, + Mergeable: "MERGEABLE", + Additions: 120, + Deletions: 34, + } + got := toPRStatusJSON(github.MarshalPRInfo(info)) + if got == nil { + t.Fatal("expected non-nil PR status") + } + if got.Number != 42 || got.URL != info.URL { + t.Errorf("number/url mismatch: %+v", got) + } + if got.State != "open" { + t.Errorf("state = %q, want open", got.State) + } + if got.CheckState != "passing" { + t.Errorf("check_state = %q, want passing", got.CheckState) + } + if got.Mergeable != "MERGEABLE" { + t.Errorf("mergeable = %q, want MERGEABLE", got.Mergeable) + } + if got.Additions != 120 || got.Deletions != 34 { + t.Errorf("diff stats = +%d -%d, want +120 -34", got.Additions, got.Deletions) + } +} + +func TestPRStateAndCheckStateStrings(t *testing.T) { + stateCases := map[github.PRState]string{ + github.PRStateOpen: "open", + github.PRStateDraft: "draft", + github.PRStateMerged: "merged", + github.PRStateClosed: "closed", + github.PRState(""): "", + } + for in, want := range stateCases { + if got := prStateString(in); got != want { + t.Errorf("prStateString(%q) = %q, want %q", in, got, want) + } + } + + checkCases := map[github.CheckState]string{ + github.CheckStatePassing: "passing", + github.CheckStateFailing: "failing", + github.CheckStatePending: "pending", + github.CheckStateNone: "", + } + for in, want := range checkCases { + if got := checkStateString(in); got != want { + t.Errorf("checkStateString(%q) = %q, want %q", in, got, want) + } + } +} + +// The /api/tasks payload must carry the live PR badge so the web board can render +// state + checks without a separate request. +func TestTaskJSON_IncludesPRStatus(t *testing.T) { + srv, database, _ := setupServer(t) + + task := &db.Task{Title: "PR task", Status: db.StatusBlocked, Project: "personal"} + if err := database.CreateTask(task); err != nil { + t.Fatalf("create task: %v", err) + } + info := &github.PRInfo{ + Number: 7, + URL: "https://github.com/test/repo/pull/7", + State: github.PRStateDraft, + CheckState: github.CheckStatePending, + Mergeable: "UNKNOWN", + Additions: 5, + Deletions: 2, + } + if err := database.UpdateTaskPRInfo(task.ID, info.URL, info.Number, github.MarshalPRInfo(info)); err != nil { + t.Fatalf("update pr info: %v", err) + } + + req := httptest.NewRequest("GET", "/api/tasks", nil) + w := httptest.NewRecorder() + srv.handleListTasks(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + + var tasks []*taskJSON + if err := json.NewDecoder(w.Body).Decode(&tasks); err != nil { + t.Fatalf("decode: %v", err) + } + if len(tasks) != 1 { + t.Fatalf("expected 1 task, got %d", len(tasks)) + } + pr := tasks[0].PR + if pr == nil { + t.Fatal("expected pr payload, got nil") + } + if pr.State != "draft" || pr.CheckState != "pending" || pr.Number != 7 { + t.Errorf("unexpected pr payload: %+v", pr) + } +} + +// A task with no PR must omit the pr field entirely (omitempty). +func TestTaskJSON_OmitsPRWhenAbsent(t *testing.T) { + srv, database, _ := setupServer(t) + task := &db.Task{Title: "No PR", Status: db.StatusBacklog, Project: "personal"} + if err := database.CreateTask(task); err != nil { + t.Fatalf("create task: %v", err) + } + + req := httptest.NewRequest("GET", "/api/tasks", nil) + w := httptest.NewRecorder() + srv.handleListTasks(w, req) + + var raw []map[string]any + if err := json.NewDecoder(w.Body).Decode(&raw); err != nil { + t.Fatalf("decode: %v", err) + } + if len(raw) != 1 { + t.Fatalf("expected 1 task, got %d", len(raw)) + } + if _, present := raw[0]["pr"]; present { + t.Error("pr field should be omitted when no PR is associated") + } +} + +// BuildBoardSnapshot entries should also carry the PR badge for the board API. +func TestBuildBoardSnapshot_IncludesPR(t *testing.T) { + database := setupTestDB(t) + task := &db.Task{Title: "Board PR", Status: db.StatusBlocked, Project: "personal"} + if err := database.CreateTask(task); err != nil { + t.Fatalf("create task: %v", err) + } + info := &github.PRInfo{Number: 9, URL: "u", State: github.PRStateMerged} + if err := database.UpdateTaskPRInfo(task.ID, info.URL, info.Number, github.MarshalPRInfo(info)); err != nil { + t.Fatalf("update pr info: %v", err) + } + + tasks, err := database.ListTasks(db.ListTasksOptions{IncludeClosed: true, Limit: 100}) + if err != nil { + t.Fatalf("list tasks: %v", err) + } + snap := BuildBoardSnapshot(tasks, 50) + + var found *prStatusJSON + for _, col := range snap.Columns { + for _, entry := range col.Tasks { + if entry.ID == task.ID { + found = entry.PR + } + } + } + if found == nil { + t.Fatal("expected PR badge on board entry") + } + if found.State != "merged" { + t.Errorf("state = %q, want merged", found.State) + } +}