From 40f3177e2a3e9501d42b674413cb8cfdde37ba49 Mon Sep 17 00:00:00 2001 From: Matt Schweitz Date: Fri, 25 Sep 2026 21:16:51 -0400 Subject: [PATCH 001/266] Add a shared Pipeline card model --- plugins/workstreams/inbox-rows.ts | 69 ++++++++++ plugins/workstreams/inbox.tsx | 76 +---------- plugins/workstreams/pipeline.test.ts | 142 +++++++++++++++++++++ plugins/workstreams/pipeline.ts | 180 +++++++++++++++++++++++++++ plugins/workstreams/pr-backlog.ts | 2 +- 5 files changed, 397 insertions(+), 72 deletions(-) create mode 100644 plugins/workstreams/inbox-rows.ts create mode 100644 plugins/workstreams/pipeline.test.ts create mode 100644 plugins/workstreams/pipeline.ts diff --git a/plugins/workstreams/inbox-rows.ts b/plugins/workstreams/inbox-rows.ts new file mode 100644 index 0000000..0b409a5 --- /dev/null +++ b/plugins/workstreams/inbox-rows.ts @@ -0,0 +1,69 @@ +// Shared, pure checkout rows for the legacy Board and Pipeline. +import { prHoldFor, type PrHold } from "./pr-holds.js"; +import type { Board, WireGroup, WireRun } from "./server.js"; +import { INBOX_SECTIONS, byInboxOrder, displayTitle, groupChildren, inboxSection, inboxVerb, stateAge, type InboxSection, type StateAge } from "./workstreams.js"; +import { primaryAction, type PrimaryAction } from "./actions.js"; +import { rowRun } from "./runs.js"; + +type Cluster = WireGroup["clusters"][number]; +type Unit = Cluster["units"][number]; + +/** One inbox row: a checkout and everything the row shows about it. */ +export type Row = { + key: string; + hold?: PrHold | null; + unit: Unit; + cluster: Cluster; + effortKey: string; + effort: string; + section: InboxSection; + verb: string | null; + age: StateAge; + repo: string; + title: string; + /** What the row's `a` key and action button do; null when there is nothing to do. */ + action: PrimaryAction | null; + /** The row's latest agent or direct run, while it is still worth reporting. */ + run: WireRun | null; +}; + +/** Every checkout on the board, as rows, grouped and ordered by section. */ +export function inboxRows(board: Board, now: number): Map { + const byParent = groupChildren(board.groups); + const rows: Row[] = []; + for (const group of board.groups) { + // Clusters live on the leaves; a group with children holds none itself. + if ((byParent.get(group.key) ?? []).length > 0) continue; + for (const cluster of group.clusters) { + for (const unit of cluster.units) { + const section = inboxSection(unit, now); + const verb = inboxVerb(unit, section); + rows.push({ + key: unit.path, + hold: unit.pr?.state === "OPEN" ? prHoldFor(unit.pr.url, board.prHolds) : null, + unit, + cluster, + effortKey: group.key, + effort: group.name, + section, + verb, + action: primaryAction(unit, section, verb), + age: stateAge(unit), + run: rowRun(board.runs, unit.path, now), + repo: unit.repo ?? unit.dirName, + title: unit.pr === null ? (unit.branch ?? unit.dirName) : displayTitle(unit.pr.title), + }); + } + } + } + const sections = new Map(INBOX_SECTIONS.map((section) => [section, []])); + for (const row of rows) sections.get(row.section)?.push(row); + const facts = (row: Row) => ({ + repo: row.repo, + prNumber: row.unit.pr?.number ?? null, + path: row.unit.path, + since: row.age.since, + }); + for (const list of sections.values()) list.sort((a, b) => byInboxOrder(facts(a), facts(b))); + return sections; +} diff --git a/plugins/workstreams/inbox.tsx b/plugins/workstreams/inbox.tsx index 2eb719f..19452d3 100644 --- a/plugins/workstreams/inbox.tsx +++ b/plugins/workstreams/inbox.tsx @@ -4,7 +4,6 @@ // Map, position here DOES follow status: this is a list you work through, and // the sections and in-section order come from the pure rules in // workstreams.ts (`inboxSection`, `byInboxOrder`), unit-tested there. -import { prHoldFor, type PrHold } from "./pr-holds"; import { PrHoldDialog, usePrHoldControls } from "./pr-hold-dialog"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; @@ -12,25 +11,19 @@ import * as PopoverPrimitive from "@radix-ui/react-popover"; import * as SelectPrimitive from "@radix-ui/react-select"; import { UrlLink, experimental_useSidebarThreads, useBbNavigate, useRpc } from "@get-bb/plugin-sdk/app"; import type { Board, Prefs, WireGroup, WireRun, rpcContract } from "./server"; +import { inboxRows, type Row } from "./inbox-rows"; import { INBOX_COLLAPSED, INBOX_SECTIONS, INBOX_SECTION_LABEL, ESCALATING, STALENESS, - byInboxOrder, - displayTitle, - groupChildren, - inboxSection, - inboxVerb, isTicketlessClone, outsideGrouping, matchesInboxQuery, - stateAge, threadPrompt, type InboxSection, type Staleness, - type StateAge, } from "./workstreams"; import { Button } from "@/components/ui/button"; import { @@ -45,7 +38,7 @@ import { Icon } from "@/components/ui/icon"; import { Tip } from "@/components/ui/tooltip"; import { POINTER_CURSORS, cn } from "@/lib/utils"; import { toast } from "sonner"; -import { primaryAction, type PrimaryAction } from "./actions"; +import type { PrimaryAction } from "./actions"; import { ActionDialogs, RowActionMenu, ThreadMessageDialog, type ActionRequest } from "./rowactions"; import { ThreadMenu } from "./threadmenu"; import { PrBacklog } from "./pr-backlog-view"; @@ -55,7 +48,7 @@ import { matchesApprovedFilter } from "./approval-filter"; import { EffortCoordinatorControl } from "./effort-coordinator-control"; import { backlogMatches, includeRemoteEfforts, prBacklog, remoteAttentionRows, remotePrsByEffort } from "./pr-backlog"; import { ArchivedThreadsButton } from "./archivedthreads"; -import { rowRun, runDetail, runLabel, stripCounts, type RunStatus, type StripCounts } from "./runs"; +import { runDetail, runLabel, stripCounts, type RunStatus, type StripCounts } from "./runs"; import { REVIEWER_MARK, reviewerInitials, reviewersLabel, reviewersOf, visibleReviewers, type Reviewer, type ReviewerState } from "./reviewers"; import { ageHint, primaryHint, rowAge, shortAge, shortVerb, titleHint } from "./rowlabels"; import { completedByEffort, heldByEffort, groupInboxRows, partitionCompletedRows, visibleCompletedRows, visibleInboxRows, type InboxGrouping, type RowGroup } from "./inbox-grouping"; @@ -64,67 +57,8 @@ import { attentionDetail, attentionLabel, hasBoardRows, workstreamAttention, typ import { usePortalScopeProps } from "./lib/portal-scope"; type Cluster = WireGroup["clusters"][number]; -type Unit = Cluster["units"][number]; -/** One inbox row: a checkout and everything the row shows about it. */ -export type Row = { - key: string; - hold?: PrHold | null; - unit: Unit; - cluster: Cluster; - effortKey: string; - effort: string; - section: InboxSection; - verb: string | null; - age: StateAge; - repo: string; - title: string; - /** What the row's `a` key and action button do; null when there is nothing to do. */ - action: PrimaryAction | null; - /** The row's latest agent or direct run, while it is still worth reporting. */ - run: WireRun | null; -}; - -/** Every checkout on the board, as rows, grouped and ordered by section. */ -export function inboxRows(board: Board, now: number): Map { - const byParent = groupChildren(board.groups); - const rows: Row[] = []; - for (const group of board.groups) { - // Clusters live on the leaves; a group with children holds none itself. - if ((byParent.get(group.key) ?? []).length > 0) continue; - for (const cluster of group.clusters) { - for (const unit of cluster.units) { - const section = inboxSection(unit, now); - const verb = inboxVerb(unit, section); - rows.push({ - key: unit.path, - hold: unit.pr?.state === "OPEN" ? prHoldFor(unit.pr.url, board.prHolds) : null, - unit, - cluster, - effortKey: group.key, - effort: group.name, - section, - verb, - action: primaryAction(unit, section, verb), - age: stateAge(unit), - run: rowRun(board.runs, unit.path, now), - repo: unit.repo ?? unit.dirName, - title: unit.pr === null ? (unit.branch ?? unit.dirName) : displayTitle(unit.pr.title), - }); - } - } - } - const sections = new Map(INBOX_SECTIONS.map((section) => [section, []])); - for (const row of rows) sections.get(row.section)?.push(row); - const facts = (row: Row) => ({ - repo: row.repo, - prNumber: row.unit.pr?.number ?? null, - path: row.unit.path, - since: row.age.since, - }); - for (const list of sections.values()) list.sort((a, b) => byInboxOrder(facts(a), facts(b))); - return sections; -} +export { inboxRows, type Row } from "./inbox-rows"; export function InboxBoard({ board, @@ -1609,7 +1543,7 @@ const DISPATCH_ACTION: Record["actio * (no BB project holds the checkout, say) is shown here, and the dialog stays * open with the prompt intact. */ -function StartThreadDialog({ row, onClose }: { row: Row | null; onClose: () => void }) { +export function StartThreadDialog({ row, onClose }: { row: Row | null; onClose: () => void }) { const rpc = useRpc(); const navigate = useBbNavigate(); const [prompt, setPrompt] = useState(""); diff --git a/plugins/workstreams/pipeline.test.ts b/plugins/workstreams/pipeline.test.ts new file mode 100644 index 0000000..9d536a4 --- /dev/null +++ b/plugins/workstreams/pipeline.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { prSchema, type Pr } from "./contract.js"; +import type { Row } from "./inbox-rows.js"; +import type { BacklogEntry } from "./pr-backlog.js"; +import { activityFor, blockerFor, pipelineBulkCards, pipelineCards, pipelineColumns, pipelineEfforts, primaryPipelineAction, stageFor } from "./pipeline.js"; +import type { Lifecycle } from "./workstreams.js"; + +const now = Date.parse("2026-09-25T00:00:00Z"); +function pr(number: number, patch: Partial = {}): Pr { + return prSchema.parse({ number, state: "OPEN", isDraft: false, reviewDecision: "APPROVED", checkConclusions: ["SUCCESS"], + url: `https://github.com/inkwell/catalog/pull/${number}`, title: `Improve catalog ${number}`, mergeable: "MERGEABLE", + baseRefName: "main", headRefName: `book-${number}`, latestReviewStates: ["APPROVED"], unresolvedReviewThreads: 0, + mergeStateStatus: "CLEAN", createdAt: "2026-09-01T00:00:00Z", ...patch }); +} +const entry = (value: Pr, extra: Partial = {}): BacklogEntry => ({ repo: "inkwell/catalog", pr: value, stale: false, ...extra }); +function local(value: Pr | null, patch: Partial = {}): Row { + return { key: `/work/catalog-${value?.number ?? "next"}`, repo: "inkwell/catalog", title: value?.title ?? "Next catalog feature", + effortKey: "ticket:INK-1", effort: "Catalog improvements", age: { since: now - 31 * 86_400_000, basis: "state" }, + unit: { path: `/work/catalog-${value?.number ?? "next"}`, pr: value, lifecycle: value?.isDraft ? "in-progress" : value?.state === "MERGED" ? "merged" : "up-next", stack: null, ticket: "INK-1" }, + cluster: { threads: [], units: [{ path: `/work/catalog-${value?.number ?? "next"}` }] }, section: "in-flight", verb: "In progress", action: null, run: null, ...patch } as Row; +} + +describe("pipeline position and gates", () => { + it("maps every lifecycle, while drafts stay in Build even with failed CI", () => { + const cases: [Lifecycle, string][] = [ + ["active", "build"], ["in-progress", "build"], ["up-next", "build"], + ["awaiting-review", "review"], ["awaiting-rereview", "review"], ["unverified", "review"], + ["blocked", "feedback"], ["awaiting-followup", "feedback"], ["approved-with-comments", "feedback"], ["approved-with-note", "feedback"], + ["awaiting-merge", "ready"], ["merged", "merged"], ["closed", "merged"], ["shipped", "released"], + ]; + for (const [lifecycle, stage] of cases) expect(stageFor(lifecycle, pr(1))).toBe(stage); + expect(stageFor("blocked", pr(1, { isDraft: true, checkConclusions: ["FAILURE"] }))).toBe("build"); + expect(stageFor("awaiting-merge", pr(1, { mergeStateStatus: "DIRTY" }))).toBe("feedback"); + expect(stageFor("awaiting-merge", pr(1, { mergeStateStatus: "BEHIND" }))).toBe("feedback"); + }); + + it("keeps a stack child in Ready but opens the parent before merging", () => { + const parent = pr(2, { headRefName: "foundation" }); + const child = pr(3, { baseRefName: "foundation" }); + const cards = pipelineCards([entry(child), entry(parent)], [], now); + const top = cards.find((card) => card.pr?.number === 3)!; + expect(top).toMatchObject({ stage: "ready", blocker: { label: "Behind #2" }, action: { kind: "open-parent", behind: 2 } }); + expect(pipelineColumns(cards).find((column) => column.stage === "ready")?.bulkCount).toBe(1); + }); + + it("holds keep their stage, hide agent activity, and leave bulk selection", () => { + const held = pr(4, { unresolvedReviewThreads: 2 }); + const key = held.url; + const cards = pipelineCards([entry(held)], [], now, { holds: { [key]: { reason: "Waiting for copy review", heldAt: now } } }); + expect(cards[0]).toMatchObject({ stage: "feedback", blocker: { label: "On hold" }, activity: { state: "none" }, action: { kind: "release" } }); + expect(pipelineColumns(cards).find((column) => column.stage === "feedback")?.bulkCount).toBe(0); + }); + + it("gives stale and pending PRs no merge action, and never nudges after follow-up", () => { + expect(pipelineCards([entry(pr(5), { stale: true })], [], now)[0]).toMatchObject({ stage: "review", action: null }); + const pending = pipelineCards([entry(pr(6, { checkConclusions: ["WAITING"] }))], [], now)[0]!; + expect(pending.blocker.label).toBe("Checks pending"); + expect(pending.action).toBeNull(); + const rereview = pipelineCards([entry(pr(7, { reviewDecision: "CHANGES_REQUESTED", reviewFollowupPosted: true, reviewRequests: ["reviewer"] }))], [], now)[0]!; + expect(rereview).toMatchObject({ stage: "review", blocker: { label: "Awaiting re-review" }, action: null }); + }); + + it("counts only approved, unheld Feedback PRs for Advance", () => { + const cards = pipelineCards([entry(pr(8, { unresolvedReviewThreads: 2 })), entry(pr(9, { reviewDecision: "CHANGES_REQUESTED" }))], [], now); + expect(cards.map((card) => card.action?.kind)).toEqual(["advance", "advance"]); + expect(pipelineColumns(cards).find((column) => column.stage === "feedback")?.bulkCount).toBe(1); + expect(pipelineBulkCards(cards, "feedback").map((card) => card.pr?.number)).toEqual([8]); + }); + + it("deduplicates inventory, cloned checkouts, and checkout-only PRs; retains effort keys", () => { + const shared = pr(10); + const checkoutOnly = pr(11, { isDraft: true }); + const cards = pipelineCards([entry(shared, { effortKey: "cohort:INK-1", effortName: "Catalog cohort" }), entry(shared)], + [local(shared), local(shared, { key: "/work/clone" }), local(checkoutOnly), local(checkoutOnly, { key: "/work/clone2" }), local(null)], now); + expect(cards).toHaveLength(3); + expect(cards.find((card) => card.pr?.number === 10)?.local).not.toBeNull(); + expect(cards.find((card) => card.pr?.number === 10)?.repo).toBe("inkwell/catalog"); + expect(cards.find((card) => card.pr?.number === 11)?.stage).toBe("build"); + expect(pipelineEfforts(cards).map((effort) => effort.key)).toEqual(["ticket:INK-1"]); + expect(pipelineCards([entry(pr(12), { effortKey: "cohort:INK-1", effortName: "Catalog cohort" })], [], now)[0]).toMatchObject({ effortKey: "cohort:INK-1" }); + }); + + it("keeps stale drafts and unverified local work in Build, and uses PR age only for PRs", () => { + const draft = pipelineCards([entry(pr(15, { isDraft: true, checkConclusions: ["FAILURE"] }), { stale: true })], [], now)[0]!; + expect(draft.stage).toBe("build"); + const noPr = local(null, { unit: { ...local(null).unit, lifecycle: "unverified", lastCommitAt: "2026-09-10T00:00:00Z" } }); + expect(pipelineCards([], [noPr], now)[0]).toMatchObject({ stage: "build", ageSince: Date.parse("2026-09-10T00:00:00Z") }); + expect(pipelineCards([entry(pr(16, { createdAt: null }))], [], now)[0]?.ageSince).toBeNull(); + }); + + it("uses current ready facts over an old failed Advance attempt", () => { + const current = pr(17); + const sources = { batches: [{ id: "batch", createdAt: now - 1000, cancelled: false, jobs: [{ id: "job", prUrl: current.url, + status: "needs-attention", detail: "Earlier attempt failed", threadId: "thread-1", updatedAt: now - 1000 }] }] } as unknown as Parameters[3]; + expect(pipelineCards([entry(current)], [], now, sources)[0]).toMatchObject({ stage: "ready", action: { kind: "merge" }, activity: { state: "none" } }); + }); + + it("keeps a usable repository when cached PR URL is malformed", () => { + const card = pipelineCards([entry(pr(23, { url: "bad-url" }))], [], now)[0]!; + expect(card.repo).toBe("inkwell/catalog"); + }); + + it("keeps release evidence from any merged checkout clone", () => { + const merged = pr(22, { state: "MERGED" }); + const plain = local(merged, { key: "/work/aaa-plain", unit: { ...local(merged).unit, lifecycle: "merged" } }); + const tagged = local(merged, { key: "/work/zzz-tagged", unit: { ...local(merged).unit, lifecycle: "shipped" } }); + expect(pipelineCards([], [plain, tagged], now)).toMatchObject([{ stage: "released", local: { key: "/work/zzz-tagged" } }]); + }); + + it("shows release-tagged and merged checkouts as separate columns", () => { + const merged = pr(13, { state: "MERGED" }); + const cards = pipelineCards([], [local(merged, { unit: { ...local(merged).unit, lifecycle: "merged" } }), + local(pr(14, { state: "MERGED" }), { unit: { ...local(pr(14)).unit, lifecycle: "shipped" } })], now); + expect(cards.map((card) => card.stage)).toEqual(["merged", "released"]); + }); +}); + +describe("card activity", () => { + it("uses the latest active advance job and routes stuck work to Fix", () => { + const source = { batches: [{ id: "batch", createdAt: now, cancelled: false, jobs: [{ id: "job", prUrl: pr(1).url, + status: "needs-attention", detail: "Agent needs direction", threadId: "thread-1", updatedAt: now }] }] } as unknown as Parameters[2]; + const activity = activityFor(pr(1).url, null, source); + expect(activity).toMatchObject({ state: "needs-you", source: "advance", threadId: "thread-1" }); + expect(primaryPipelineAction("feedback", blockerFor(pr(1), "feedback", null), activity, null, null)).toMatchObject({ kind: "fix" }); + }); + + it("prefers an active run over a later completed job", () => { + const url = pr(20).url; + const sources = { batches: [{ id: "batch", createdAt: now, cancelled: false, jobs: [{ id: "job", prUrl: url, + status: "ready", detail: "Old work complete", threadId: "thread-old", updatedAt: now }] }], + runs: [{ kind: "agent", path: "/work/catalog-20", prUrl: url, status: "running", startedAt: now - 1000, + finishedAt: null, result: null, error: null, action: "address-review", threadId: "thread-live" }] } as unknown as Parameters[2]; + expect(activityFor(url, "/work/catalog-20", sources)).toMatchObject({ state: "working", threadId: "thread-live" }); + }); + + it("uses a linked active author thread to show Build work and open it", () => { + const row = local(pr(2, { isDraft: true }), { cluster: { units: [{ path: "/work/catalog-2" }], threads: [{ id: "author-2", title: "Write catalog copy", active: true, tier: "started" }] } as Row["cluster"] }); + expect(pipelineCards([], [row], now)[0]).toMatchObject({ stage: "build", activity: { state: "working", threadId: "author-2" }, action: { kind: "open-thread" } }); + const merged = local(pr(21, { state: "MERGED" }), { cluster: row.cluster }); + expect(pipelineCards([], [merged], now)[0]).toMatchObject({ stage: "merged", activity: { state: "none" } }); + }); +}); diff --git a/plugins/workstreams/pipeline.ts b/plugins/workstreams/pipeline.ts new file mode 100644 index 0000000..724b7c9 --- /dev/null +++ b/plugins/workstreams/pipeline.ts @@ -0,0 +1,180 @@ +// Pure presentation model for the Pipeline. GitHub and checkout facts remain the source of truth. +import type { Pr } from "./contract.js"; +import type { Row } from "./inbox-rows.js"; +import type { AdvanceBatch, AdvanceJob } from "./bulk-advance.js"; +import type { DispatchState } from "./dispatch.js"; +import type { PrHold, PrHolds } from "./pr-holds.js"; +import { canonicalPrUrl } from "./pr-holds.js"; +import { prBacklog, type BacklogEntry, type BacklogRow } from "./pr-backlog.js"; +import type { WireRun } from "./server.js"; +import { isAdvanceEligible } from "./bulk-advance-selection.js"; +import { displayTitle, isTicketlessClone, prLifecycle, type Lifecycle } from "./workstreams.js"; + +export const PIPELINE_STAGES = ["build", "review", "feedback", "ready", "merged", "released"] as const; +export type PipelineStage = (typeof PIPELINE_STAGES)[number]; +export type PipelineBlocker = { label: string; tone: "bad" | "warn" | "wait" | "clear" }; +export type PipelineActivity = { state: "none" | "working" | "done" | "needs-you"; detail: string; threadId: string | null; source: "advance" | "dispatch" | "run" | null }; +export type PipelineAction = { kind: "merge" | "advance" | "fix" | "nudge" | "open-parent" | "open-thread" | "release"; label: string; behind?: number } | null; +export type PipelineCard = { + key: string; repo: string; title: string; pr: Pr | null; local: Row | null; backlog: BacklogRow | null; + effortKey: string | null; effortName: string | null; hold: PrHold | null; + stage: PipelineStage; blocker: PipelineBlocker; activity: PipelineActivity; action: PipelineAction; + ageSince: number | null; stale: boolean; +}; +export type PipelineSources = { holds?: PrHolds; batches?: readonly AdvanceBatch[]; dispatch?: DispatchState; runs?: readonly WireRun[] }; + +const BAD_CHECKS = new Set(["FAILURE", "ERROR"]); +const GREEN_CHECKS = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]); +const ACTIVE_JOBS = new Set(["queued", "launching", "running", "verifying"]); +const NONE: PipelineActivity = { state: "none", detail: "", threadId: null, source: null }; +const prKey = (url: string): string => canonicalPrUrl(url) ?? url.replace(/\/$/u, "").toLowerCase(); +const repoOf = (pr: Pr, fallback: string): string => { + const canonical = canonicalPrUrl(pr.url); + return canonical === null ? fallback : new URL(canonical).pathname.split("/").slice(1, 3).join("/"); +}; + +/** Lifecycle owns position. A draft and an active rebase stay in Build even with bad checks. */ +export function stageFor(lifecycle: Lifecycle, pr: Pr | null, behind: number | null = null): PipelineStage { + if (lifecycle === "shipped") return "released"; + if (lifecycle === "merged" || lifecycle === "closed") return "merged"; + if (pr === null || pr.isDraft || lifecycle === "active" || lifecycle === "in-progress" || lifecycle === "up-next") return "build"; + if (pr !== null && !pr.isDraft && (pr.mergeStateStatus === "DIRTY" || pr.mergeStateStatus === "BEHIND")) return "feedback"; + if (behind !== null && pr?.reviewDecision === "APPROVED" && lifecycle === "awaiting-merge") return "ready"; + if (lifecycle === "blocked" || lifecycle === "awaiting-followup" || lifecycle === "approved-with-comments" || lifecycle === "approved-with-note") return "feedback"; + if (lifecycle === "awaiting-merge") return "ready"; + return "review"; // Awaiting review, re-review, or facts that need verification. +} + +/** One visible gate, ordered by the repair that must happen first. */ +export function blockerFor(pr: Pr | null, stage: PipelineStage, hold: PrHold | null, behind: number | null = null, stale = false): PipelineBlocker { + if (hold !== null) return { label: "On hold", tone: "wait" }; + if (stage === "merged" || stage === "released") return { label: "Clear", tone: "clear" }; + if (pr === null || stage === "build") return { label: "In progress", tone: "wait" }; + if (stale) return { label: "Status unknown", tone: "wait" }; + if (pr.checkConclusions.some((check) => BAD_CHECKS.has(check))) return { label: "CI failing", tone: "bad" }; + if (pr.mergeStateStatus === "DIRTY") return { label: "Conflicts", tone: "bad" }; + if (pr.reviewDecision === "CHANGES_REQUESTED" && !pr.reviewFollowupPosted) return { label: "Changes requested", tone: "warn" }; + if (pr.unresolvedReviewThreads !== null && pr.unresolvedReviewThreads > 0) return { label: `${pr.unresolvedReviewThreads} open threads`, tone: "warn" }; + if (pr.reviewDecision === "APPROVED" && pr.approvalHasBody && !pr.approvalNoteFollowedUp) return { label: "Review note", tone: "warn" }; + if (behind !== null) return { label: `Behind #${behind}`, tone: "wait" }; + if (pr.reviewDecision === "CHANGES_REQUESTED" && pr.reviewFollowupPosted) return { label: "Awaiting re-review", tone: "wait" }; + if (pr.reviewDecision === "APPROVED" && !pr.checkConclusions.every((check) => GREEN_CHECKS.has(check))) return { label: "Checks pending", tone: "wait" }; + if (pr.mergeStateStatus === "BEHIND") return { label: "Branch behind", tone: "wait" }; + if (pr.mergeStateStatus === "BLOCKED") return { label: "Rules block", tone: "wait" }; + if (pr.mergeStateStatus === "UNKNOWN" || pr.unresolvedReviewThreads === null) return { label: "Status unknown", tone: "wait" }; + if (pr.reviewDecision !== "APPROVED" && pr.reviewRequests.length === 0) return { label: "No reviewer", tone: "wait" }; + if (pr.reviewDecision !== "APPROVED") return { label: "Awaiting review", tone: "wait" }; + return { label: "Clear", tone: "clear" }; +} + +/** Newest matching activity wins across Advance, automatic dispatch, and row actions. */ +export function activityFor(prUrl: string | null, path: string | null, sources: PipelineSources): PipelineActivity { + const candidates: { at: number; activity: PipelineActivity }[] = []; + if (prUrl !== null) { + for (const batch of sources.batches ?? []) for (const job of batch.jobs) { + if (prKey(job.prUrl) !== prKey(prUrl) || job.hiddenFromProgress || job.status === "cancelled" || job.status === "merged" || job.status === "closed") continue; + const state = ACTIVE_JOBS.has(job.status) ? "working" : job.status === "needs-attention" ? "needs-you" : "done"; + candidates.push({ at: job.updatedAt, activity: { state, detail: job.detail, threadId: job.threadId, source: "advance" } }); + } + for (const attempt of sources.dispatch?.attempts ?? []) { + if (prKey(attempt.prUrl) !== prKey(prUrl)) continue; + const state = ["launching", "running", "verifying"].includes(attempt.status) ? "working" : ["needs-you", "failed"].includes(attempt.status) ? "needs-you" : "done"; + candidates.push({ at: attempt.startedAt, activity: { state, detail: attempt.detail, threadId: attempt.threadId, source: "dispatch" } }); + } + } + for (const run of sources.runs ?? []) { + if (run.kind !== "agent" || (prUrl === null || run.prUrl === null ? run.path !== path : prKey(run.prUrl) !== prKey(prUrl))) continue; + const state = run.status === "running" ? "working" : run.status === "needs-you" || run.status === "failed" ? "needs-you" : "done"; + candidates.push({ at: run.finishedAt ?? run.startedAt, activity: { state, detail: run.result ?? run.error ?? run.action, threadId: run.threadId, source: "run" } }); + } + return candidates.sort((a, b) => Number(b.activity.state === "working") - Number(a.activity.state === "working") || b.at - a.at)[0]?.activity ?? NONE; +} + +export function primaryPipelineAction(stage: PipelineStage, blocker: PipelineBlocker, activity: PipelineActivity, hold: PrHold | null, behind: number | null): PipelineAction { + if (hold !== null) return { kind: "release", label: "Release" }; + if (stage === "merged" || stage === "released") return null; + if (activity.state === "needs-you") return { kind: "fix", label: "Fix" }; + if (activity.state === "working") return stage === "build" && activity.threadId !== null ? { kind: "open-thread", label: "Open thread" } : null; + if (stage === "build") return activity.threadId !== null ? { kind: "open-thread", label: "Open thread" } : null; + if (behind !== null) return { kind: "open-parent", label: "Open parent", behind }; + if (stage === "ready" && blocker.label === "Clear") return { kind: "merge", label: "Merge" }; + if (stage === "feedback" && blocker.label !== "Status unknown") return { kind: "advance", label: "Advance" }; + if (stage === "review" && (blocker.label === "Awaiting review" || blocker.label === "No reviewer")) return { kind: "nudge", label: "Nudge" }; + return null; +} + +function ageOf(pr: Pr | null, local: Row | null): number | null { + const source = pr === null ? local?.unit.lastCommitAt : pr.createdAt; + const parsed = source ? Date.parse(source) : NaN; + return Number.isFinite(parsed) ? parsed : null; +} + +/** One card per PR, with remote inventory facts preferred; checkout-only work also appears. */ +export function pipelineCards(entries: readonly BacklogEntry[], locals: readonly Row[], now: number, sources: PipelineSources = {}): PipelineCard[] { + const backlog = prBacklog(entries, locals, now, sources.holds); + const covered = new Set(backlog.map((row) => prKey(row.pr.url))); + const cards: PipelineCard[] = []; + const add = (local: Row | null, remote: BacklogRow | null): void => { + const pr = remote?.pr ?? local?.unit.pr ?? null; + const stale = remote?.stale ?? false; + const lifecycle = stale ? "unverified" : remote?.lifecycle ?? local?.unit.lifecycle ?? (pr === null ? "up-next" : prLifecycle(pr)); + const behind = remote?.parent?.pr.number ?? local?.unit.stack?.blockedBelow ?? null; + const hold = remote?.hold ?? local?.hold ?? null; + const stage = stale && pr !== null && !pr.isDraft ? "review" : stageFor(lifecycle, pr, behind); + const blocker = blockerFor(pr, stage, hold, behind, stale); + let activity = hold === null ? activityFor(pr?.url ?? null, local?.key ?? null, sources) : NONE; + if (hold === null && stage === "build" && activity.state === "none" && local?.cluster.units.length === 1 && local.cluster.threads.some((thread) => thread.active)) { + const thread = local.cluster.threads.find((item) => item.active)!; + activity = { state: "working", detail: thread.title, threadId: thread.id, source: "run" }; + } + if (stage === "ready" && blocker.label === "Clear" && activity.state === "needs-you") activity = NONE; + const action = primaryPipelineAction(stage, blocker, activity, hold, behind); + if (pr !== null) covered.add(prKey(pr.url)); + cards.push({ key: pr === null ? local!.key : prKey(pr.url), repo: pr === null ? local!.repo : repoOf(pr, remote?.repo ?? local!.repo), + title: pr === null ? local!.title : displayTitle(pr.title), pr, local, backlog: remote, + effortKey: local?.effortKey ?? remote?.effortKey ?? null, effortName: local?.effort ?? remote?.effortName ?? null, + hold, stage, blocker, activity, action, ageSince: ageOf(pr, local), stale }); + }; + for (const row of backlog) add(row.local, row); + for (const local of [...locals].sort((a, b) => Number(b.unit.pr?.state === "MERGED" && b.unit.lifecycle === "shipped") - Number(a.unit.pr?.state === "MERGED" && a.unit.lifecycle === "shipped") || + Number(b.unit.rebasing === true) - Number(a.unit.rebasing === true) || a.key.localeCompare(b.key))) { + if (local.unit.pr !== null && covered.has(prKey(local.unit.pr.url))) continue; + if (isTicketlessClone(local.unit)) continue; + if (local.unit.pr !== null && local.unit.pr.state !== "OPEN" && local.unit.pr.state !== "MERGED") continue; + add(local, null); + } + return cards.sort(byPipelineOrder); +} + +const severity = (card: PipelineCard): number => card.blocker.tone === "bad" ? 0 : card.blocker.tone === "warn" ? 1 : card.blocker.tone === "wait" ? 2 : 3; +export function byPipelineOrder(a: PipelineCard, b: PipelineCard): number { + return PIPELINE_STAGES.indexOf(a.stage) - PIPELINE_STAGES.indexOf(b.stage) || Number(a.hold !== null) - Number(b.hold !== null) || + severity(a) - severity(b) || (a.ageSince ?? Number.MAX_SAFE_INTEGER) - (b.ageSince ?? Number.MAX_SAFE_INTEGER) || a.key.localeCompare(b.key); +} + +export type PipelineColumn = { stage: PipelineStage; cards: PipelineCard[]; bulk: "nudge" | "advance" | "merge" | null; bulkCount: number }; +/** The exact cards represented by a column bulk button, in the same sort order. */ +export function pipelineBulkCards(cards: readonly PipelineCard[], stage: PipelineStage): PipelineCard[] { + const bulk = stage === "review" ? "nudge" : stage === "feedback" ? "advance" : stage === "ready" ? "merge" : null; + if (bulk === null) return []; + return cards.filter((card) => card.stage === stage && card.hold === null && !card.stale && + (bulk === "advance" ? card.pr !== null && !card.pr.isDraft && isAdvanceEligible(card.pr) && card.activity.state !== "working" : card.action?.kind === bulk)).sort(byPipelineOrder); +} +export function pipelineColumns(cards: readonly PipelineCard[]): PipelineColumn[] { + return PIPELINE_STAGES.map((stage) => { + const members = cards.filter((card) => card.stage === stage).sort(byPipelineOrder); + const bulk = stage === "review" ? "nudge" : stage === "feedback" ? "advance" : stage === "ready" ? "merge" : null; + return { stage, cards: members, bulk, bulkCount: pipelineBulkCards(members, stage).length }; + }); +} + +export type PipelineEffort = { key: string | null; name: string; cards: PipelineCard[] }; +export function pipelineEfforts(cards: readonly PipelineCard[]): PipelineEffort[] { + const groups = new Map(); + for (const card of cards) { + const effort = groups.get(card.effortKey) ?? { key: card.effortKey, name: card.effortName ?? "One-offs", cards: [] }; + effort.cards.push(card); + groups.set(card.effortKey, effort); + } + return [...groups.values()].sort((a, b) => a.key === null ? 1 : b.key === null ? -1 : a.name.localeCompare(b.name)); +} diff --git a/plugins/workstreams/pr-backlog.ts b/plugins/workstreams/pr-backlog.ts index 4f36cd7..3449d3d 100644 --- a/plugins/workstreams/pr-backlog.ts +++ b/plugins/workstreams/pr-backlog.ts @@ -1,6 +1,6 @@ import { prHoldFor, type PrHold, type PrHolds } from "./pr-holds.js"; import type { Pr } from "./contract.js"; -import type { Row } from "./inbox.js"; +import type { Row } from "./inbox-rows.js"; import type { AttentionRow } from "./workstream-attention.js"; import type { RowGroup } from "./inbox-grouping.js"; import { primaryAction, type PrimaryAction } from "./actions.js"; From 5e825dc1f446f0dd1170fc0a60c0fbda21c2d98e Mon Sep 17 00:00:00 2001 From: Matt Schweitz Date: Fri, 25 Sep 2026 21:18:51 -0400 Subject: [PATCH 002/266] Unify Pipeline agent planning and repair --- plugins/workstreams/bulk-advance-preview.ts | 7 + .../workstreams/pipeline-agent-sheet.test.ts | 25 + plugins/workstreams/pipeline-agent-sheet.tsx | 639 ++++++++++++++++++ 3 files changed, 671 insertions(+) create mode 100644 plugins/workstreams/pipeline-agent-sheet.test.ts create mode 100644 plugins/workstreams/pipeline-agent-sheet.tsx diff --git a/plugins/workstreams/bulk-advance-preview.ts b/plugins/workstreams/bulk-advance-preview.ts index 6b714f1..972fe7d 100644 --- a/plugins/workstreams/bulk-advance-preview.ts +++ b/plugins/workstreams/bulk-advance-preview.ts @@ -1,5 +1,12 @@ +import type { AdvancePreview } from "./bulk-advance"; + type PlannedWork = { repo: string; eligible: boolean; needsPreparation: boolean; needsFeedback?: boolean }; +/** Work that needs renewed review if an advance preview token expires. */ +export function advanceScope(plan: AdvancePreview): string { + return JSON.stringify(plan.jobs.map((job) => [job.prUrl, job.eligible, job.needsFeedback, job.needsPreparation, job.workspace, job.headOid, job.baseRefName, job.headRefName])); +} + export function advancePreviewAction(job: PlannedWork): string { if (!job.eligible) return "Skip"; if (job.needsFeedback && job.needsPreparation) return "Address feedback + prepare + verify"; diff --git a/plugins/workstreams/pipeline-agent-sheet.test.ts b/plugins/workstreams/pipeline-agent-sheet.test.ts new file mode 100644 index 0000000..97e1043 --- /dev/null +++ b/plugins/workstreams/pipeline-agent-sheet.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import type { AdvancePreview } from "./bulk-advance"; +import { advanceScope } from "./bulk-advance-preview"; + +const plan: AdvancePreview = { + token: "first", expiresAt: 1, + jobs: [{ prUrl: "https://github.com/inkwell/editor/pull/42", repo: "inkwell/editor", number: 42, + title: "Prepare editor release", headOid: "a".repeat(40), baseRefName: "main", headRefName: "release", + needsPreparation: false, needsFeedback: false, eligible: true, detail: "Ready to verify", workspace: "existing" }], +}; + +describe("advance token renewal", () => { + it("keeps the same approval when only the token and expiry change", () => { + expect(advanceScope({ ...plan, token: "second", expiresAt: 500 })).toBe(advanceScope(plan)); + }); + + it("requires review if a read-only verification gains write work", () => { + expect(advanceScope({ ...plan, jobs: [{ ...plan.jobs[0]!, needsFeedback: true }] })).not.toBe(advanceScope(plan)); + }); + + it("requires review if the checked commit or workspace changes", () => { + expect(advanceScope({ ...plan, jobs: [{ ...plan.jobs[0]!, headOid: "b".repeat(40) }] })).not.toBe(advanceScope(plan)); + expect(advanceScope({ ...plan, jobs: [{ ...plan.jobs[0]!, workspace: "create" }] })).not.toBe(advanceScope(plan)); + }); +}); diff --git a/plugins/workstreams/pipeline-agent-sheet.tsx b/plugins/workstreams/pipeline-agent-sheet.tsx new file mode 100644 index 0000000..67e689a --- /dev/null +++ b/plugins/workstreams/pipeline-agent-sheet.tsx @@ -0,0 +1,639 @@ +import { useEffect, useRef, useState } from "react"; +import { UrlLink, useBbNavigate, useRpc } from "@get-bb/plugin-sdk/app"; +import type { rpcContract } from "./server"; +import type { Row } from "./inbox"; +import type { AgentAction, ThreadMode } from "./actions"; +import { AGENT_LABEL, actionPrompt } from "./actions"; +import type { AdvancePreview, AdvanceRepairPlan } from "./bulk-advance"; +import { advanceScope, advancePreviewSummary } from "./bulk-advance-preview"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; + +export type PipelineAgentRequest = + | { kind: "agent"; action: AgentAction; row: Row } + | { kind: "advance"; prUrls: string[] } + | { kind: "repair"; batchId: string; jobId: string }; + +type AgentPlan = Extract< + Awaited>["call"]>>, + { recommendation: unknown; capabilities: unknown } +>; +type Plan = + | { kind: "agent"; value: AgentPlan } + | { kind: "advance"; value: AdvancePreview } + | { kind: "repair"; value: AdvanceRepairPlan }; + +const MODE_LABEL: Record = { + continue: "Continue worker", + subthread: "Linked subthread", + new: "New thread", +}; +const failure = (cause: unknown): string => + cause instanceof Error ? cause.message : String(cause); +const writes = (job: AdvancePreview["jobs"][number]) => + job.eligible && (job.needsFeedback || job.needsPreparation); +const readOnly = (plan: AdvancePreview) => + plan.jobs.some((job) => job.eligible) && !plan.jobs.some(writes); + +function shortPlan( + feedback: boolean, + preparation: boolean, + eligible = true, +): string { + if (!eligible) return "Skip this PR"; + if (feedback && preparation) return "Fix, prepare, verify"; + if (feedback) return "Fix and verify"; + if (preparation) return "Prepare and verify"; + return "Verify merge readiness"; +} + +export function PipelineAgentSheet({ + request, + onClose, + onStarted, + onOpenThread, +}: { + request: PipelineAgentRequest | null; + onClose: () => void; + onStarted?: () => void; + onOpenThread?: (threadId: string) => void; +}) { + const rpc = useRpc(); + const navigate = useBbNavigate(); + const [plan, setPlan] = useState(null); + const [mode, setMode] = useState("new"); + const [threadId, setThreadId] = useState(null); + const [instruction, setInstruction] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [started, setStarted] = useState(null); + const generation = useRef(0); + const launch = useRef(false); + const requestKey = + request === null + ? null + : request.kind === "agent" + ? JSON.stringify([request.kind, request.action, request.row.unit.path]) + : request.kind === "advance" + ? JSON.stringify([request.kind, request.prUrls]) + : JSON.stringify([request.kind, request.batchId, request.jobId]); + + useEffect(() => { + const sequence = ++generation.current; + setPlan(null); + setError(null); + setNotice(null); + setStarted(null); + setBusy(false); + setMode("new"); + setThreadId(null); + setInstruction(""); + launch.current = false; + if (request === null) return; + const load = async () => { + try { + let next: Plan; + if (request.kind === "agent") { + const value = await rpc.call("agent_plan", { + path: request.row.unit.path, + action: request.action, + }); + if (!value.ok) throw new Error(value.error); + next = { kind: "agent", value }; + } else if (request.kind === "advance") { + next = { + kind: "advance", + value: await rpc.call("advance_preview", { + prUrls: request.prUrls, + }), + }; + } else { + next = { + kind: "repair", + value: await rpc.call("advance_repair_plan", { + batchId: request.batchId, + jobId: request.jobId, + }), + }; + } + if (generation.current !== sequence) return; + setPlan(next); + if (next.kind === "agent" || next.kind === "repair") { + setMode(next.value.recommendation.mode); + setThreadId(next.value.recommendation.threadId); + if (next.kind === "agent" && request.kind === "agent") + setInstruction( + actionPrompt(request.action, { + repo: request.row.repo, + prNumber: request.row.unit.pr?.number ?? null, + title: request.row.unit.pr ? request.row.title : null, + branch: request.row.unit.branch, + path: request.row.unit.path, + }), + ); + } + if (next.kind === "advance" && readOnly(next.value)) { + launch.current = true; + const fresh = + next.value.expiresAt > Date.now() + ? next.value + : await rpc.call("advance_preview", { + prUrls: request.kind === "advance" ? request.prUrls : [], + }); + if (generation.current !== sequence) return; + setPlan({ kind: "advance", value: fresh }); + if (!readOnly(fresh)) { + launch.current = false; + setNotice( + "The plan changed and may push code. Review it before starting.", + ); + return; + } + await rpc.call("advance_start", { token: fresh.token }); + if (generation.current !== sequence) return; + toast.success("Readiness check started"); + onStarted?.(); + onClose(); + } + } catch (cause) { + if (generation.current === sequence) setError(failure(cause)); + } finally { + if (generation.current === sequence) launch.current = false; + } + }; + void load(); + return () => { + generation.current++; + }; + }, [requestKey]); + + const active = plan?.kind === request?.kind ? plan : null; + const selected = + active?.kind === "agent" || active?.kind === "repair" ? active.value : null; + const candidates = selected?.candidates ?? []; + const availableModes: ThreadMode[] = + active?.kind === "repair" + ? active.value.modes + : active?.kind === "agent" + ? [ + "new", + ...(active.value.capabilities.subthread && + candidates.some((candidate) => candidate.canSpawnChild) + ? ["subthread" as const] + : []), + ] + : []; + const eligibleCandidates = candidates.filter((candidate) => + mode === "continue" + ? "canContinue" in candidate && candidate.canContinue + : candidate.canSpawnChild, + ); + const canStart = + active !== null && + (active.kind === "advance" + ? active.value.jobs.some((job) => job.eligible) + : availableModes.includes(mode) && + (mode === "new" || + eligibleCandidates.some((candidate) => candidate.id === threadId)) && + (active.kind === "repair" || instruction.trim() !== "")); + + const start = async () => { + if (!request || !active || !canStart || busy || launch.current) return; + setBusy(true); + setError(null); + setNotice(null); + try { + if (active.kind === "advance" && request.kind === "advance") { + let fresh = active.value; + if (fresh.expiresAt <= Date.now()) { + fresh = await rpc.call("advance_preview", { prUrls: request.prUrls }); + setPlan({ kind: "advance", value: fresh }); + if (advanceScope(fresh) !== advanceScope(active.value)) { + setNotice( + "The plan changed. Review the updated PR work before starting.", + ); + return; + } + if (!fresh.jobs.some((job) => job.eligible)) { + setNotice("No PRs are eligible in the updated plan."); + return; + } + } + await rpc.call("advance_start", { token: fresh.token }); + toast.success("Advance started"); + onStarted?.(); + onClose(); + } else if (active.kind === "agent" && request.kind === "agent") { + const result = await rpc.call("agent_run", { + path: request.row.unit.path, + action: request.action, + mode, + threadId: mode === "new" ? null : threadId, + prompt: instruction, + }); + if (!result.ok) throw new Error(result.error); + onStarted?.(); + onClose(); + navigate.toThread(result.threadId); + } else if (active.kind === "repair" && request.kind === "repair") { + let fresh = active.value; + if (fresh.expiresAt <= Date.now()) { + fresh = await rpc.call("advance_repair_plan", { + batchId: request.batchId, + jobId: request.jobId, + }); + setPlan({ kind: "repair", value: fresh }); + if ( + !fresh.modes.includes(mode) || + (mode !== "new" && + !fresh.candidates.some( + (candidate) => + candidate.id === threadId && + (mode === "continue" + ? candidate.canContinue + : candidate.canSpawnChild), + )) + ) { + setMode(fresh.recommendation.mode); + setThreadId(fresh.recommendation.threadId); + setNotice( + "The available worker changed. Review where this repair runs.", + ); + return; + } + } + const result = await rpc.call("advance_repair_run", { + token: fresh.token, + mode, + threadId: mode === "new" ? null : threadId, + instruction: instruction.trim(), + }); + setStarted(result.threadId); + onStarted?.(); + } + } catch (cause) { + setError(failure(cause)); + } finally { + setBusy(false); + } + }; + + const title = + request?.kind === "agent" + ? AGENT_LABEL[request.action] + : request?.kind === "repair" + ? "Fix this PR" + : "Advance approved PRs"; + const description = + request?.kind === "agent" + ? "An agent handles the selected action and reports the result. It does not merge the PR." + : request?.kind === "repair" + ? "An agent repairs the remaining blocker and checks readiness. It does not merge the PR." + : "Agents prepare branches and address feedback where needed, then check readiness. They do not merge PRs."; + const rows = + active?.kind === "advance" + ? active.value.jobs.map((job) => ({ + key: job.prUrl, + url: job.prUrl, + label: `${job.repo} #${job.number}`, + title: job.title, + plan: shortPlan( + job.needsFeedback, + job.needsPreparation, + job.eligible, + ), + workspace: + job.workspace === "existing" + ? "Matched checkout" + : job.workspace === "create" + ? "New checkout" + : "Unavailable", + pushes: writes(job), + detail: job.detail, + eligible: job.eligible, + })) + : active?.kind === "repair" + ? [ + { + key: active.value.job.id, + url: active.value.job.prUrl, + label: `${active.value.job.repo} #${active.value.job.number}`, + title: active.value.job.title, + plan: "Repair and verify", + workspace: + mode === "continue" + ? "Previous checkout" + : active.value.fresh.workspace === "create" + ? "New checkout" + : "Matched checkout", + pushes: true, + detail: active.value.job.detail, + eligible: true, + }, + ] + : request?.kind === "agent" + ? [ + { + key: request.row.key, + url: request.row.unit.pr?.url, + label: `${request.row.repo}${request.row.unit.pr ? ` #${request.row.unit.pr.number}` : ""}`, + title: request.row.title, + plan: + request.action === "investigate-ci" + ? "Investigate CI failure" + : request.action === "resolve-conflicts" + ? "Resolve merge conflicts" + : "Address review feedback", + workspace: request.row.unit.path, + pushes: request.action !== "investigate-ci", + detail: request.row.title, + eligible: true, + }, + ] + : []; + const advanceSummary = + active?.kind === "advance" + ? advancePreviewSummary(active.value.jobs) + : null; + + return ( + { + if (!open && !busy && !launch.current) onClose(); + }} + > + + + {started ? "Repair started" : title} + + {started ? "The worker is tracked on this PR." : description} + + + {started ? ( +
+ +
+ ) : ( + <> + {!active && !error ? ( +

+ Reading the current plan… +

+ ) : null} + {rows.length > 0 ? ( +
+ + + + + + + + + + + {rows.map((row) => ( + + + + + + + ))} + +
+ PR + + Plan + + Workspace + + Effect +
+ {row.url ? ( + + {row.label} + + ) : ( + row.label + )} + + {row.title} + + + {row.plan} + {!row.eligible ? ( + + {row.detail} + + ) : null} + + {row.workspace} + + + {row.pushes ? "Pushes" : "Read-only"} + +
+
+ ) : null} + {active?.kind === "advance" && + active.value.jobs.some((job) => !job.eligible) ? ( +

+ Ineligible PRs are skipped. +

+ ) : null} + {selected ? ( +
+

Where to run

+

+ {selected.recommendation.reason} +

+
+ {availableModes.map((option) => ( + + ))} +
+ {mode !== "new" ? ( + + ) : null} +
+ ) : active?.kind === "advance" && active.value.jobs.some(writes) ? ( +

+ Work runs in separate checkout workers, one per repository. +

+ ) : null} + {active?.kind === "advance" && advanceSummary ? ( +
+ + Instructions · fixed for this batch + +
+ {advanceSummary.hasFeedback ? ( +

+ Read review feedback, address remaining changes, test, and + reply with evidence. +

+ ) : null} + {advanceSummary.hasPreparation ? ( +

+ Integrate the current base branch, resolve conflicts, + test, and push changes. +

+ ) : null} +

+ Check approval, unresolved feedback, checks, and + mergeability against the final commit. Do not merge. +

+

+ These batch instructions are fixed by the advance service. +

+
+
+ ) : null} + {active?.kind === "agent" || active?.kind === "repair" ? ( +
+ + Instructions + +