feat(pr): dashboard PR view, routes, node-state UI + user controls (U7)

Adds /api/pull-requests routes (list, detail, merge/approve/retry/close/
automerge — all re-fetch authoritative state before acting), a
PullRequestView rendering every entity state distinctly (creating/failed/
unverified/responding/await-review/conflict) with the action bar, live
auto-merge gate reason, and conflict CTA; TaskCard PR node-state badge +
link; and the R16 column-move-backward guard. User actions route through
the existing releaseHeldTaskByEvent primitives. Maps the new PR node kinds
in the workflow editor's kind resolver. 13 route tests + lazy-view guard
green; component test runs in CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-05 22:18:34 -07:00
parent 96f801c019
commit 8a6a67a305
14 changed files with 1478 additions and 6 deletions

View File

@@ -179,7 +179,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme
### Lazy-Loaded Heavy Views
These 19 views are lazy-loaded via `React.lazy()` with `<Suspense fallback={null}>`.
These 20 views are lazy-loaded via `React.lazy()` with `<Suspense fallback={null}>`.
Keep this AGENTS inventory in sync with App lazy imports and `packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts`.
- `AgentsView`
@@ -197,6 +197,7 @@ Keep this AGENTS inventory in sync with App lazy imports and `packages/dashboard
- `TodoView`
- `GoalsView`
- `StashRecoveryView`
- `PullRequestView`
- `SetupWizardModal`
- `PluginManager`
- `PiExtensionsManager`

View File

@@ -118,6 +118,7 @@ const DevServerView = lazy(() => import("./components/DevServerView").then((m) =
const _TodoView = lazy(() => import("./components/TodoView").then((m) => ({ default: m.TodoView })));
const GoalsView = lazy(() => import("./components/GoalsView").then((m) => ({ default: m.GoalsView })));
const StashRecoveryView = lazy(() => import("./components/StashRecoveryView").then((m) => ({ default: m.StashRecoveryView })));
const PullRequestView = lazy(() => import("./components/PullRequestView").then((m) => ({ default: m.PullRequestView })));
// Warm lazy chunks during browser idle so first navigation to each view is
// instant. Each chunk is ~10–80 kB; total prefetch finishes well under a
@@ -147,6 +148,7 @@ function prefetchLazyViews() {
void import("./components/TodoView");
void import("./components/GoalsView");
void import("./components/StashRecoveryView");
void import("./components/PullRequestView");
});
}
@@ -757,6 +759,11 @@ function AppInner() {
const [missionResumeSessionId, setMissionResumeSessionId] = useState<string | undefined>(undefined);
const [missionTargetId, setMissionTargetId] = useState<string | undefined>(undefined);
const [goalAnchorId, setGoalAnchorId] = useState<string | undefined>(undefined);
const [selectedPrId, setSelectedPrId] = useState<string | undefined>(() => {
if (typeof window === "undefined") return undefined;
const v = new URL(window.location.href).searchParams.get("pr");
return v ?? undefined;
});
const [milestoneSliceResumeSessionId, setMilestoneSliceResumeSessionId] = useState<string | undefined>(undefined);
useEffect(() => {
@@ -764,6 +771,11 @@ function AppInner() {
setGoalAnchorId(undefined);
}
}, [goalAnchorId, taskView]);
useEffect(() => {
if (taskView !== "pull-requests" && selectedPrId !== undefined) {
setSelectedPrId(undefined);
}
}, [selectedPrId, taskView]);
const [quickChatOpen, setQuickChatOpen] = useState(false);
const [authTokenRecoveryOpen, setAuthTokenRecoveryOpen] = useState(false);
const [dashboardHealth, setDashboardHealth] = useState<DashboardHealthResponse | null>(null);
@@ -1563,6 +1575,16 @@ function AppInner() {
);
}
if (taskView === "pull-requests") {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<PullRequestView pullRequestId={selectedPrId} projectId={currentProject?.id} />
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "insights") {
if (!settingsLoaded || !insightsEnabled) {
return null;

View File

@@ -18,6 +18,7 @@ const EXPECTED_DOCUMENTED_VIEWS = new Set([
"TodoView",
"GoalsView",
"StashRecoveryView",
"PullRequestView",
"SetupWizardModal",
"PluginManager",
"PiExtensionsManager",
@@ -40,6 +41,7 @@ const EXPECTED_APP_LEVEL_VIEWS = new Set([
"TodoView",
"GoalsView",
"StashRecoveryView",
"PullRequestView",
]);
function extractLazyLoadedSection(agentsDoc: string): string {
@@ -81,11 +83,11 @@ describe("AGENTS lazy-loaded views inventory", () => {
const section = extractLazyLoadedSection(agentsDoc);
const countMatch = section.match(/These\s+(\d+)\s+views\s+are lazy-loaded/);
expect(countMatch).toBeTruthy();
expect(Number(countMatch?.[1])).toBe(19);
expect(Number(countMatch?.[1])).toBe(20);
const documentedViews = extractBacktickedNamesFromBullets(section);
expect(new Set(documentedViews)).toEqual(EXPECTED_DOCUMENTED_VIEWS);
expect(documentedViews).toHaveLength(19);
expect(documentedViews).toHaveLength(20);
expect(section).toContain("`ResearchView`");
expect(section).toContain("`TodoView`");

View File

@@ -0,0 +1,160 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { PullRequestView, type PrDetail } from "../components/PullRequestView";
// Icons → simple stubs so assertions key on text/testids, not SVG internals.
vi.mock("lucide-react", () => {
const Stub = () => <span />;
return new Proxy({}, { get: () => Stub });
});
function makeSummary(over: Partial<PrDetail["summary"]> = {}): PrDetail["summary"] {
return {
mergeable: "clean",
reviewDecision: "APPROVED",
checksRollup: "success",
conflicting: false,
autoMerge: false,
autoMergeReason: "Ready to merge",
autoMergeReady: true,
actionable: true,
active: true,
pendingThreads: 0,
disagreedThreads: 0,
...over,
};
}
function makeDetail(over: Partial<PrDetail> = {}): PrDetail {
return {
id: "PR-1",
sourceType: "task",
sourceId: "FN-1",
repo: "owner/repo",
headBranch: "feature/x",
state: "open",
prNumber: 42,
prUrl: "https://example/pr/42",
mergeable: "clean",
checksRollup: "success",
reviewDecision: "APPROVED",
autoMerge: false,
unverified: false,
responseRounds: 0,
threads: [],
summary: makeSummary(over.summary),
...over,
};
}
describe("PullRequestView per-node-state rendering", () => {
it("creating → 'Creating PR…' placeholder", () => {
render(<PullRequestView detail={makeDetail({ state: "creating" })} />);
expect(screen.getByTestId("pr-view").dataset.state).toBe("creating");
expect(screen.getByTestId("pr-creating").textContent).toContain("Creating PR");
});
it("failed → failure reason + Retry PR creation action", async () => {
const onAction = vi.fn(async () => makeDetail({ state: "creating" }));
render(
<PullRequestView
detail={makeDetail({ state: "failed", failureReason: "gh auth missing" })}
onAction={onAction}
/>,
);
expect(screen.getByTestId("pr-failed").textContent).toContain("gh auth missing");
fireEvent.click(screen.getByTestId("pr-retry-create"));
await waitFor(() => expect(onAction).toHaveBeenCalledWith("retry-create", "PR-1", undefined));
});
it("unverified → 'Verifying with GitHub…', checks/threads hidden, merge disabled", () => {
render(
<PullRequestView
detail={makeDetail({ unverified: true, threads: [
{ prEntityId: "PR-1", threadId: "T1", headOid: "a", outcome: "pending", updatedAt: 0 },
] })}
/>,
);
expect(screen.getByTestId("pr-unverified").textContent).toContain("Verifying with GitHub");
expect(screen.queryByTestId("pr-threads")).toBeNull();
expect(screen.queryByTestId("pr-summary")).toBeNull();
expect((screen.getByTestId("pr-merge") as HTMLButtonElement).disabled).toBe(true);
});
it("responding → banner with N pending threads, respond/retry disabled, per-thread pending markers", () => {
render(
<PullRequestView
detail={makeDetail({
state: "responding",
summary: makeSummary({ pendingThreads: 3 }),
threads: [
{ prEntityId: "PR-1", threadId: "T1", headOid: "a", outcome: "pending", updatedAt: 0 },
],
})}
/>,
);
expect(screen.getByTestId("pr-responding").textContent).toContain("3 threads pending");
expect((screen.getByTestId("pr-retry") as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByTestId("pr-thread-pending")).toBeTruthy();
});
it("open/await-review → action bar (Approve/Retry/Merge/Close) + auto-merge gate reason", () => {
render(<PullRequestView detail={makeDetail({ summary: makeSummary({ autoMergeReason: "Waiting for approval" }) })} />);
expect(screen.getByTestId("pr-action-bar")).toBeTruthy();
expect(screen.getByTestId("pr-approve")).toBeTruthy();
expect(screen.getByTestId("pr-retry")).toBeTruthy();
expect(screen.getByTestId("pr-merge")).toBeTruthy();
expect(screen.getByTestId("pr-close")).toBeTruthy();
expect(screen.getByTestId("pr-automerge-gate").textContent).toBe("Waiting for approval");
});
it("conflict → Merge disabled + 'Resolve conflicts on GitHub' link", () => {
render(
<PullRequestView
detail={makeDetail({
mergeable: "conflicting",
summary: makeSummary({ conflicting: true, autoMergeReason: "Blocked: conflict" }),
})}
/>,
);
expect((screen.getByTestId("pr-merge") as HTMLButtonElement).disabled).toBe(true);
const link = screen.getByTestId("pr-conflict-link") as HTMLAnchorElement;
expect(link.textContent).toContain("Resolve conflicts on GitHub");
expect(link.href).toContain("/pr/42");
});
it("agent disagreements are visually distinguished from human-awaiting threads", () => {
render(
<PullRequestView
detail={makeDetail({
threads: [
{ prEntityId: "PR-1", threadId: "T1", headOid: "a", outcome: "disagreed", updatedAt: 0 },
{ prEntityId: "PR-1", threadId: "T2", headOid: "a", outcome: "pending", updatedAt: 0 },
],
})}
/>,
);
const disagreed = screen.getByTestId("pr-thread-disagreed");
expect(disagreed.dataset.agentDisagreement).toBe("true");
expect(disagreed.className).toContain("pr-thread--agent-disagreement");
const pending = screen.getByTestId("pr-thread-pending");
expect(pending.dataset.agentDisagreement).toBe("false");
});
it("merge uses a single confirm step, then fires the merge action", async () => {
const onAction = vi.fn(async () => makeDetail());
render(<PullRequestView detail={makeDetail()} onAction={onAction} />);
fireEvent.click(screen.getByTestId("pr-merge"));
// Single-confirm: a confirm control appears (no heavy modal).
const confirm = await screen.findByTestId("pr-merge-confirm");
fireEvent.click(confirm);
await waitFor(() => expect(onAction).toHaveBeenCalledWith("merge", "PR-1", undefined));
});
it("auto-merge toggle dispatches the automerge action with enabled flag", async () => {
const onAction = vi.fn(async () => makeDetail({ autoMerge: true }));
render(<PullRequestView detail={makeDetail({ autoMerge: false })} onAction={onAction} />);
fireEvent.click(screen.getByTestId("pr-automerge").querySelector("input")!);
await waitFor(() => expect(onAction).toHaveBeenCalledWith("automerge", "PR-1", { enabled: true }));
});
});

View File

@@ -0,0 +1,254 @@
.pr-view {
display: flex;
flex-direction: column;
gap: var(--space-md);
height: 100%;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: var(--space-lg);
color: var(--text);
}
.pr-view--loading,
.pr-view--error {
display: flex;
align-items: center;
gap: var(--space-xs);
color: var(--text-muted);
}
.pr-view--error {
color: var(--danger, #e5534b);
}
/* identity header */
.pr-identity {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
padding-bottom: var(--space-sm);
border-bottom: 1px solid var(--border);
}
.pr-identity-repo {
font-weight: 600;
}
.pr-identity-number {
color: var(--accent, var(--text));
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 2px;
}
.pr-identity-branch {
color: var(--text-muted);
font-family: var(--font-mono, monospace);
font-size: 0.85em;
}
.pr-identity-state {
margin-left: auto;
text-transform: uppercase;
font-size: 0.7em;
letter-spacing: 0.04em;
padding: 2px 6px;
border-radius: 4px;
background: var(--surface-2, rgba(127, 127, 127, 0.15));
color: var(--text-muted);
}
.pr-identity-state--failed {
background: rgba(229, 83, 75, 0.18);
color: var(--danger, #e5534b);
}
.pr-identity-state--merged {
background: rgba(130, 80, 223, 0.18);
}
/* placeholders / banners / notices */
.pr-placeholder,
.pr-notice,
.pr-banner,
.pr-error-reason {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
border-radius: 6px;
background: var(--surface-2, rgba(127, 127, 127, 0.1));
}
.pr-banner--responding {
background: rgba(54, 130, 220, 0.14);
}
.pr-notice--unverified {
background: rgba(220, 170, 54, 0.14);
}
.pr-error-reason {
background: rgba(229, 83, 75, 0.14);
color: var(--danger, #e5534b);
}
/* action bar */
.pr-action-bar {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
}
.pr-action {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface, transparent);
color: var(--text);
cursor: pointer;
font-size: 0.85em;
}
.pr-action:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pr-action--merge,
.pr-action--merge-confirm {
border-color: var(--accent, var(--border));
}
.pr-action--merge-confirm {
background: var(--accent, #2f81f7);
color: #fff;
}
.pr-action--close {
border-color: rgba(229, 83, 75, 0.4);
}
.pr-automerge-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
font-size: 0.82em;
color: var(--text-muted);
}
.pr-automerge-gate {
padding: 1px 6px;
border-radius: 4px;
background: var(--surface-2, rgba(127, 127, 127, 0.15));
}
.pr-conflict-link {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--danger, #e5534b);
text-decoration: none;
font-size: 0.85em;
}
/* merge-readiness summary */
.pr-summary {
display: flex;
gap: var(--space-md);
flex-wrap: wrap;
padding: var(--space-sm) 0;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.pr-summary-item {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.85em;
color: var(--text-muted);
}
.pr-icon-success {
color: var(--success, #3fb950);
}
.pr-icon-failure {
color: var(--danger, #e5534b);
}
.pr-icon-pending {
color: var(--warning, #d29922);
}
/* threads */
.pr-threads {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.pr-threads-empty {
color: var(--text-muted);
font-size: 0.85em;
}
.pr-thread {
border: 1px solid var(--border);
border-radius: 6px;
padding: var(--space-sm);
}
.pr-thread--agent-disagreement {
border-left: 3px solid var(--warning, #d29922);
background: rgba(210, 153, 34, 0.08);
}
.pr-thread--pending {
border-left: 3px solid var(--text-muted);
}
.pr-thread-head {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 0.82em;
}
.pr-thread-pending,
.pr-thread-disagreed,
.pr-thread-fixed {
display: inline-flex;
align-items: center;
gap: 4px;
}
.pr-thread-id {
color: var(--text-muted);
font-family: var(--font-mono, monospace);
font-size: 0.85em;
margin-left: auto;
}
.pr-thread-reply {
margin-top: 6px;
margin-left: var(--space-md);
padding: var(--space-xs) var(--space-sm);
border-left: 2px solid var(--border);
color: var(--text-muted);
font-size: 0.82em;
}
.pr-inline-error {
color: var(--danger, #e5534b);
font-size: 0.85em;
}

View File

@@ -0,0 +1,409 @@
import { useCallback, useEffect, useState } from "react";
import {
GitPullRequest,
GitMerge,
CheckCircle,
XCircle,
Clock,
AlertTriangle,
ExternalLink,
RotateCcw,
ThumbsUp,
MessageSquare,
} from "lucide-react";
import { api } from "../api";
import "./PullRequestView.css";
// Mirrors the route's serialized entity (register-pull-requests-routes.ts).
export type PrThread = {
prEntityId: string;
threadId: string;
headOid: string;
outcome: "fixed" | "disagreed" | "pending";
fixCommitSha?: string;
updatedAt: number;
};
export type PrSummary = {
mergeable: string;
reviewDecision: string | null;
checksRollup: string;
conflicting: boolean;
autoMerge: boolean;
autoMergeReason: string;
autoMergeReady: boolean;
actionable: boolean;
active: boolean;
pendingThreads: number;
disagreedThreads: number;
};
export type PrDetail = {
id: string;
sourceType: "task" | "branch-group";
sourceId: string;
repo: string;
headBranch: string;
baseBranch?: string;
state: "creating" | "open" | "responding" | "merged" | "closed" | "failed";
prNumber?: number;
prUrl?: string;
mergeable?: string;
checksRollup?: string;
reviewDecision?: string | null;
autoMerge: boolean;
unverified: boolean;
failureReason?: string;
responseRounds: number;
threads: PrThread[];
summary: PrSummary;
};
type ActionKind = "approve" | "merge" | "retry" | "close" | "automerge" | "retry-create";
export interface PullRequestViewProps {
/** When provided, render this detail directly (tests / parent-supplied data). */
detail?: PrDetail | null;
/** Entity id to self-fetch when `detail` is not provided. */
pullRequestId?: string;
projectId?: string;
/** Override the action dispatcher (tests). Defaults to the POST routes. */
onAction?: (kind: ActionKind, id: string, body?: Record<string, unknown>) => Promise<PrDetail>;
/** Override the fetcher (tests). */
loadPullRequest?: (id: string) => Promise<PrDetail>;
}
function defaultLoad(projectId?: string) {
return async (id: string): Promise<PrDetail> => {
const q = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const res = await api<{ pullRequest: PrDetail }>(`/pull-requests/${id}${q}`);
return res.pullRequest;
};
}
function defaultAction(projectId?: string) {
return async (kind: ActionKind, id: string, body?: Record<string, unknown>): Promise<PrDetail> => {
const path = kind === "automerge" ? "automerge" : kind;
const res = await api<{ pullRequest: PrDetail }>(`/pull-requests/${id}/${path}`, {
method: "POST",
body: JSON.stringify({ ...(body ?? {}), ...(projectId ? { projectId } : {}) }),
headers: { "content-type": "application/json" },
});
return res.pullRequest;
};
}
function ChecksIcon({ rollup }: { rollup: string }) {
if (rollup === "success") return <CheckCircle size={14} className="pr-icon-success" />;
if (rollup === "failure") return <XCircle size={14} className="pr-icon-failure" />;
if (rollup === "pending") return <Clock size={14} className="pr-icon-pending" />;
return <span className="pr-icon-none">—</span>;
}
export function PullRequestView(props: PullRequestViewProps) {
const { detail: detailProp, pullRequestId, projectId, onAction, loadPullRequest } = props;
const [detail, setDetail] = useState<PrDetail | null>(detailProp ?? null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<ActionKind | null>(null);
const [confirmingMerge, setConfirmingMerge] = useState(false);
const load = loadPullRequest ?? defaultLoad(projectId);
const dispatch = onAction ?? defaultAction(projectId);
const refresh = useCallback(async () => {
if (detailProp) {
setDetail(detailProp);
return;
}
if (!pullRequestId) return;
try {
setError(null);
setDetail(await load(pullRequestId));
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load PR");
}
}, [detailProp, pullRequestId, load]);
useEffect(() => {
void refresh();
}, [refresh]);
// Live updates: re-poll on the store-event / SSE channel the rest of the app
// uses. We listen for the lightweight "store-changed" window event the SSE
// bridge dispatches; each tick re-reads authoritative state from the route.
useEffect(() => {
if (detailProp || !pullRequestId) return;
const handler = () => void refresh();
window.addEventListener("fusion:store-changed", handler);
return () => window.removeEventListener("fusion:store-changed", handler);
}, [detailProp, pullRequestId, refresh]);
const runAction = useCallback(
async (kind: ActionKind, body?: Record<string, unknown>) => {
if (!detail) return;
try {
setBusy(kind);
setError(null);
const fresh = await dispatch(kind, detail.id, body);
setDetail(fresh);
} catch (err) {
setError(err instanceof Error ? err.message : `Action ${kind} failed`);
} finally {
setBusy(null);
setConfirmingMerge(false);
}
},
[detail, dispatch],
);
if (error && !detail) {
return (
<div className="pr-view pr-view--error" data-testid="pr-view-error">
<AlertTriangle size={16} /> {error}
</div>
);
}
if (!detail) {
return (
<div className="pr-view pr-view--loading" data-testid="pr-view-loading">
Loading PR…
</div>
);
}
const { state, summary } = detail;
// ── creating ───────────────────────────────────────────────────────────────
if (state === "creating") {
return (
<div className="pr-view" data-testid="pr-view" data-state="creating">
<PrIdentityHeader detail={detail} />
<div className="pr-placeholder" data-testid="pr-creating">
<Clock size={16} /> Creating PR…
</div>
</div>
);
}
// ── failed ───────────────────────────────────────────────────────────────
if (state === "failed") {
return (
<div className="pr-view" data-testid="pr-view" data-state="failed">
<PrIdentityHeader detail={detail} />
<div className="pr-error-reason" data-testid="pr-failed">
<AlertTriangle size={16} className="pr-icon-failure" />
<span>{detail.failureReason ?? "PR creation failed"}</span>
</div>
<div className="pr-action-bar">
<button
type="button"
className="pr-action pr-action--retry"
data-testid="pr-retry-create"
disabled={busy === "retry-create"}
onClick={() => void runAction("retry-create")}
>
<RotateCcw size={14} /> Retry PR creation
</button>
</div>
{error && <div className="pr-inline-error">{error}</div>}
</div>
);
}
// ── unverified ─────────────────────────────────────────────────────────────
if (detail.unverified) {
return (
<div className="pr-view" data-testid="pr-view" data-state="unverified">
<PrIdentityHeader detail={detail} />
<div className="pr-notice pr-notice--unverified" data-testid="pr-unverified">
<Clock size={16} /> Verifying with GitHub…
</div>
<div className="pr-action-bar">
<button
type="button"
className="pr-action"
data-testid="pr-merge"
disabled
title="Merge is disabled until GitHub verifies this PR"
>
<GitMerge size={14} /> Merge
</button>
</div>
{/* checks/threads hidden while unverified */}
</div>
);
}
const conflicting = summary.conflicting;
return (
<div className="pr-view" data-testid="pr-view" data-state={state}>
<PrIdentityHeader detail={detail} />
{/* responding banner */}
{state === "responding" && (
<div className="pr-banner pr-banner--responding" data-testid="pr-responding">
<MessageSquare size={16} /> Response run in progress — {summary.pendingThreads} threads
pending
</div>
)}
{/* ── action bar ──────────────────────────────────────────────────── */}
<div className="pr-action-bar" data-testid="pr-action-bar">
<button
type="button"
className="pr-action pr-action--approve"
data-testid="pr-approve"
disabled={state === "responding" || busy === "approve"}
onClick={() => void runAction("approve")}
>
<ThumbsUp size={14} /> Approve
</button>
<button
type="button"
className="pr-action pr-action--retry"
data-testid="pr-retry"
disabled={state === "responding" || busy === "retry"}
title={state === "responding" ? "A response run is already in progress" : undefined}
onClick={() => void runAction("retry")}
>
<RotateCcw size={14} /> Request retry
</button>
{!confirmingMerge ? (
<button
type="button"
className="pr-action pr-action--merge"
data-testid="pr-merge"
disabled={conflicting || state === "responding" || busy === "merge"}
title={conflicting ? "Resolve conflicts on GitHub before merging" : undefined}
onClick={() => setConfirmingMerge(true)}
>
<GitMerge size={14} /> Merge
</button>
) : (
<button
type="button"
className="pr-action pr-action--merge-confirm"
data-testid="pr-merge-confirm"
disabled={busy === "merge"}
onClick={() => void runAction("merge")}
>
<GitMerge size={14} /> Confirm merge
</button>
)}
<button
type="button"
className="pr-action pr-action--close"
data-testid="pr-close"
disabled={busy === "close"}
onClick={() => void runAction("close")}
>
<XCircle size={14} /> Close
</button>
<label className="pr-automerge-toggle" data-testid="pr-automerge">
<input
type="checkbox"
checked={detail.autoMerge}
disabled={busy === "automerge"}
onChange={(e) => void runAction("automerge", { enabled: e.target.checked })}
/>
<span>Auto-merge</span>
<span className="pr-automerge-gate" data-testid="pr-automerge-gate">
{summary.autoMergeReason}
</span>
</label>
</div>
{/* conflict link */}
{conflicting && detail.prUrl && (
<a
className="pr-conflict-link"
data-testid="pr-conflict-link"
href={detail.prUrl}
target="_blank"
rel="noopener noreferrer"
>
Resolve conflicts on GitHub <ExternalLink size={12} />
</a>
)}
{/* ── merge-readiness summary ─────────────────────────────────────── */}
<div className="pr-summary" data-testid="pr-summary">
<span className="pr-summary-item" data-testid="pr-summary-mergeable">
Mergeable: {summary.mergeable}
</span>
<span className="pr-summary-item" data-testid="pr-summary-review">
Review: {summary.reviewDecision ?? "none"}
</span>
<span className="pr-summary-item" data-testid="pr-summary-checks">
<ChecksIcon rollup={summary.checksRollup} /> {summary.checksRollup}
</span>
</div>
{/* ── threads (agent replies nested) ───────────────────────────────── */}
<div className="pr-threads" data-testid="pr-threads">
{detail.threads.length === 0 ? (
<div className="pr-threads-empty">No review threads.</div>
) : (
detail.threads.map((thread) => (
<div
key={`${thread.threadId}:${thread.headOid}`}
className={`pr-thread pr-thread--${thread.outcome} ${
thread.outcome === "disagreed" ? "pr-thread--agent-disagreement" : ""
}`}
data-testid={`pr-thread-${thread.outcome}`}
data-agent-disagreement={thread.outcome === "disagreed" ? "true" : "false"}
>
<div className="pr-thread-head">
{thread.outcome === "pending" && (
<span className="pr-thread-pending">
<Clock size={12} /> pending
</span>
)}
{thread.outcome === "disagreed" && (
<span className="pr-thread-disagreed">
<AlertTriangle size={12} /> agent disagreed
</span>
)}
{thread.outcome === "fixed" && (
<span className="pr-thread-fixed">
<CheckCircle size={12} /> fixed
</span>
)}
<span className="pr-thread-id">{thread.threadId}</span>
</div>
{thread.fixCommitSha && (
<div className="pr-thread-reply" data-testid="pr-thread-reply">
Agent reply — fix {thread.fixCommitSha.slice(0, 8)}
</div>
)}
</div>
))
)}
</div>
{error && <div className="pr-inline-error">{error}</div>}
</div>
);
}
function PrIdentityHeader({ detail }: { detail: PrDetail }) {
return (
<div className="pr-identity" data-testid="pr-identity">
<GitPullRequest size={16} />
<span className="pr-identity-repo">{detail.repo}</span>
{detail.prNumber != null ? (
detail.prUrl ? (
<a className="pr-identity-number" href={detail.prUrl} target="_blank" rel="noopener noreferrer">
#{detail.prNumber} <ExternalLink size={12} />
</a>
) : (
<span className="pr-identity-number">#{detail.prNumber}</span>
)
) : null}
<span className="pr-identity-branch">{detail.headBranch}</span>
<span className={`pr-identity-state pr-identity-state--${detail.state}`}>{detail.state}</span>
</div>
);
}

View File

@@ -187,6 +187,26 @@
text-transform: lowercase;
}
/* Unified PR entity node-state badge (R12). Clickable, links to the PR view. */
.card-pr-node-badge {
display: inline-flex;
align-items: center;
gap: 3px;
cursor: pointer;
background: color-mix(in srgb, var(--text-muted) 14%, transparent);
border-color: color-mix(in srgb, var(--text-muted) 30%, transparent);
color: var(--text-muted);
text-transform: none;
letter-spacing: 0;
}
/* DISTINCT error badge for the failed node-state (never the open-PR badge). */
.card-pr-node-badge--failed {
background: color-mix(in srgb, var(--color-danger, #e5534b) 18%, transparent);
border-color: color-mix(in srgb, var(--color-danger, #e5534b) 35%, transparent);
color: var(--color-danger, #e5534b);
}
.card-status-badge.stalled-review {
background: color-mix(in srgb, var(--color-warning) 18%, transparent);
color: var(--color-warning);

View File

@@ -2,7 +2,7 @@ import "./TaskCard.css";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { memo, useCallback, useState, useRef, useEffect, useMemo, type ReactElement } from "react";
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest } from "lucide-react";
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest, AlertTriangle } from "lucide-react";
import type { Task, TaskDetail, Column, ColumnId, PrInfo, IssueInfo, TaskPriority, GithubIssueAction } from "@fusion/core";
import {
DEFAULT_TASK_PRIORITY,
@@ -407,6 +407,12 @@ interface TaskCardProps {
/** Card-placed custom field definitions for this task's workflow (U13/KTD-14).
* Empty/undefined → no field badges render (card byte-identical to today). */
cardFieldDefs?: WorkflowFieldDefinition[];
/** Unified PR entity node-state for this task's work, surfaced on the card (R12).
* When present, the card shows a node-state badge linking to the PR view. The
* `failed` state renders a DISTINCT error badge (not the open-PR badge). */
prNode?: { id: string; state: "creating" | "open" | "responding" | "merged" | "closed" | "failed"; prNumber?: number };
/** Called when the PR node badge is clicked — opens the dedicated PR view (R12). */
onOpenPullRequest?: (prEntityId: string) => void;
}
function getTaskPrimaryPrInfo(task: Pick<Task, "prInfo" | "prInfos">): PrInfo | undefined {
@@ -540,6 +546,10 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previous.taskStuckTimeoutMs === next.taskStuckTimeoutMs &&
previous.prAuthAvailable === next.prAuthAvailable &&
previous.autoMergeEnabled === next.autoMergeEnabled &&
previous.onOpenPullRequest === next.onOpenPullRequest &&
previous.prNode?.id === next.prNode?.id &&
previous.prNode?.state === next.prNode?.state &&
previous.prNode?.prNumber === next.prNode?.prNumber &&
previous.cardFieldDefs === next.cardFieldDefs &&
(previous.cardFieldDefs == null && next.cardFieldDefs == null
? true
@@ -658,6 +668,8 @@ function TaskCardComponent({
prAuthAvailable,
autoMergeEnabled = false,
cardFieldDefs,
prNode,
onOpenPullRequest,
}: TaskCardProps) {
const { t } = useTranslation("app");
const columnLabel = useColumnLabel();
@@ -1875,6 +1887,41 @@ function TaskCardComponent({
) : null}
</>
)}
{prNode && (
prNode.state === "failed" ? (
<button
type="button"
className="card-status-badge card-pr-node-badge card-pr-node-badge--failed"
data-testid="pr-node-badge-failed"
title={t("tasks.prNodeFailedTitle", "PR creation failed — open the PR view")}
onClick={(e) => {
e.stopPropagation();
onOpenPullRequest?.(prNode.id);
}}
>
<AlertTriangle size={10} aria-hidden="true" />
<span>{t("tasks.prNodeFailed", "PR failed")}</span>
</button>
) : (
<button
type="button"
className={`card-status-badge card-pr-node-badge card-pr-node-badge--${prNode.state}`}
data-testid={`pr-node-badge-${prNode.state}`}
title={t("tasks.prNodeTitle", "PR {{state}} — open the PR view", { state: prNode.state })}
onClick={(e) => {
e.stopPropagation();
onOpenPullRequest?.(prNode.id);
}}
>
<GitPullRequest size={10} aria-hidden="true" />
<span>
{prNode.prNumber != null
? t("tasks.prNodeWithNumber", "PR #{{number}} · {{state}}", { number: prNode.prNumber, state: prNode.state })
: t("tasks.prNodeState", "PR · {{state}}", { state: prNode.state })}
</span>
</button>
)
)}
{isAgentCreated && (
<span
className="card-agent-created-badge"

View File

@@ -101,6 +101,12 @@ function isV2(ir: WorkflowIr): ir is WorkflowIrV2 {
function editorKind(node: WorkflowIr["nodes"][number]): WorkflowEditorNodeKind {
const seam = node.config?.seam;
if (seam === "merge") return "merge";
// PR node kinds (pr-create/pr-respond/pr-merge) are graph node kinds but have
// no dedicated editor palette renderer yet; map them to the closest existing
// editor shape so the workflow editor renders them as recognizable nodes.
// (Dedicated PR-node editor rendering is a follow-up, not part of this work.)
if (node.kind === "pr-merge") return "merge";
if (node.kind === "pr-create" || node.kind === "pr-respond") return "prompt";
return node.kind;
}

View File

@@ -5,7 +5,7 @@ import { getScopedItem, setScopedItem } from "../utils/projectStorage";
import { getPluginViewId, isPluginViewId, isPluginViewRegistered } from "../plugins/pluginViewRegistry";
export type ViewMode = "overview" | "project";
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "goalsView" | "skills" | "mailbox" | "insights" | "memory" | "reliability" | "secrets" | "devserver" | "dev-server" | "stash-recovery";
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "goalsView" | "skills" | "mailbox" | "insights" | "memory" | "reliability" | "secrets" | "devserver" | "dev-server" | "stash-recovery" | "pull-requests";
export type PluginTaskView = `plugin:${string}:${string}`;
export type TaskView = BuiltInTaskView | PluginTaskView;
@@ -30,6 +30,7 @@ const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [
"devserver",
"dev-server",
"stash-recovery",
"pull-requests",
];
function isBuiltInTaskView(value: string | null): value is BuiltInTaskView {

View File

@@ -0,0 +1,256 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import type { PrEntity, PrThreadState, Task, TaskStore } from "@fusion/core";
import {
createPullRequestsRouter,
isBackwardMoveBlockedByOpenPr,
PR_OPEN_BLOCKS_MOVE_BACK_MESSAGE,
} from "../routes/register-pull-requests-routes.js";
import { ApiError, sendErrorResponse } from "../api-error.js";
import { request as REQUEST } from "../test-request.js";
function attachErrorHandler(app: express.Express) {
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
if (err instanceof ApiError) {
sendErrorResponse(res, err.statusCode, err.message, { details: err.details });
return;
}
sendErrorResponse(res, 500, err instanceof Error ? err.message : "Internal server error");
});
}
function buildEntity(overrides: Partial<PrEntity> = {}): PrEntity {
return {
id: "PR-1",
sourceType: "task",
sourceId: "FN-1",
repo: "owner/repo",
headBranch: "feature/x",
state: "open",
prNumber: 42,
prUrl: "https://example/pr/42",
mergeable: "clean",
checksRollup: "success",
reviewDecision: "APPROVED",
autoMerge: false,
unverified: false,
responseRounds: 0,
createdAt: Date.now(),
updatedAt: Date.now(),
...overrides,
};
}
function createStore(entity: PrEntity, threads: PrThreadState[] = []) {
let current = { ...entity };
const store = {
getPrEntity: vi.fn((id: string) => (id === current.id ? current : null)),
listActivePrEntities: vi.fn(() => [current]),
listPrThreadStates: vi.fn(() => threads),
updatePrEntity: vi.fn((_id: string, patch: Partial<PrEntity>) => {
current = { ...current, ...patch } as PrEntity;
return current;
}),
getTask: vi.fn(async (id: string) => ({ id, column: "in-review" } as Task)),
} as unknown as TaskStore;
return { store, getCurrent: () => current, setCurrent: (e: PrEntity) => { current = e; } };
}
function mount(store: TaskStore, opts?: Parameters<typeof createPullRequestsRouter>[1]) {
const app = express();
app.use(express.json());
app.use("/api/pull-requests", createPullRequestsRouter(store, opts));
attachErrorHandler(app);
return app;
}
describe("pull request routes", () => {
let entity: PrEntity;
let threads: PrThreadState[];
beforeEach(() => {
entity = buildEntity();
threads = [
{ prEntityId: "PR-1", threadId: "T1", headOid: "abc", outcome: "pending", updatedAt: Date.now() },
{ prEntityId: "PR-1", threadId: "T2", headOid: "abc", outcome: "disagreed", updatedAt: Date.now() },
];
});
it("GET list returns entity with checks/threads/merge/conflict summary", async () => {
const { store } = createStore(entity, threads);
const app = mount(store);
const res = await REQUEST(app, "GET", "/api/pull-requests");
expect(res.status).toBe(200);
expect(res.body.pullRequests).toHaveLength(1);
const pr = res.body.pullRequests[0];
expect(pr.threads).toHaveLength(2);
expect(pr.summary.checksRollup).toBe("success");
expect(pr.summary.mergeable).toBe("clean");
expect(pr.summary.conflicting).toBe(false);
expect(pr.summary.pendingThreads).toBe(1);
expect(pr.summary.disagreedThreads).toBe(1);
});
it("GET list filters by repo and status", async () => {
const { store } = createStore(entity, threads);
const app = mount(store);
let res = await REQUEST(app, "GET", "/api/pull-requests?repo=other/repo");
expect(res.body.pullRequests).toHaveLength(0);
res = await REQUEST(app, "GET", "/api/pull-requests?status=closed");
expect(res.body.pullRequests).toHaveLength(0);
res = await REQUEST(app, "GET", "/api/pull-requests?status=open");
expect(res.body.pullRequests).toHaveLength(1);
});
it("GET :id reports conflicting summary and gate reason", async () => {
const conflict = buildEntity({ mergeable: "conflicting", autoMerge: true });
const { store } = createStore(conflict, threads);
const app = mount(store);
const res = await REQUEST(app, "GET", "/api/pull-requests/PR-1");
expect(res.status).toBe(200);
expect(res.body.pullRequest.summary.conflicting).toBe(true);
expect(res.body.pullRequest.summary.autoMergeReason).toBe("Blocked: conflict");
});
it("GET :id returns 404 for unknown PR", async () => {
const { store } = createStore(entity);
const app = mount(store);
const res = await REQUEST(app, "GET", "/api/pull-requests/PR-404");
expect(res.status).toBe(404);
});
it("merge re-fetches authoritative state before acting (not a stale client copy)", async () => {
const { store } = createStore(entity, threads);
const mergePr = vi.fn(async () => ({ released: true }));
const app = mount(store, { mergePr });
// Client sends a stale body claiming an old/wrong state — the route must ignore it.
const res = await REQUEST(
app,
"POST",
"/api/pull-requests/PR-1/merge",
JSON.stringify({ entity: { id: "PR-1", state: "creating", mergeable: "conflicting" } }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
// getPrEntity is the authoritative re-read; it must have been consulted.
expect((store.getPrEntity as unknown as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith("PR-1");
// The capability received the AUTHORITATIVE entity (clean/open), not the stale client copy.
expect(mergePr).toHaveBeenCalledTimes(1);
const arg = mergePr.mock.calls[0][0] as { entity: PrEntity };
expect(arg.entity.state).toBe("open");
expect(arg.entity.mergeable).toBe("clean");
});
it("merge is rejected (409) when the authoritative entity is conflicting", async () => {
const conflict = buildEntity({ mergeable: "conflicting" });
const { store } = createStore(conflict, threads);
const mergePr = vi.fn(async () => ({ released: true }));
const app = mount(store, { mergePr });
const res = await REQUEST(app, "POST", "/api/pull-requests/PR-1/merge", JSON.stringify({}), {
"content-type": "application/json",
});
expect(res.status).toBe(409);
expect(mergePr).not.toHaveBeenCalled();
});
it("approve/retry/close route to the injected engine capabilities", async () => {
const { store } = createStore(entity, threads);
const approvePr = vi.fn(async () => ({ released: true, action: "approve" }));
const retryPr = vi.fn(async () => ({ released: true, action: "retry" }));
const closePr = vi.fn(async () => ({ released: true, action: "close" }));
const app = mount(store, { approvePr, retryPr, closePr });
for (const [path, spy] of [["approve", approvePr], ["retry", retryPr], ["close", closePr]] as const) {
const res = await REQUEST(app, "POST", `/api/pull-requests/PR-1/${path}`, JSON.stringify({}), {
"content-type": "application/json",
});
expect(res.status).toBe(200);
expect(spy).toHaveBeenCalledTimes(1);
expect(res.body.pullRequest.id).toBe("PR-1");
}
});
it("retry-create only acts on failed entities and routes to retryCreate", async () => {
const failed = buildEntity({ state: "failed", failureReason: "auth" });
const { store } = createStore(failed);
const retryCreate = vi.fn(async () => ({ released: true }));
const app = mount(store, { retryCreate });
const res = await REQUEST(app, "POST", "/api/pull-requests/PR-1/retry-create", JSON.stringify({}), {
"content-type": "application/json",
});
expect(res.status).toBe(200);
expect(retryCreate).toHaveBeenCalledTimes(1);
// open entity → retry-create rejected (wrong state)
const { store: openStore } = createStore(buildEntity({ state: "open" }));
const retryCreate2 = vi.fn();
const openApp = mount(openStore, { retryCreate: retryCreate2 as unknown as () => Promise<Record<string, unknown>> });
const res2 = await REQUEST(openApp, "POST", "/api/pull-requests/PR-1/retry-create", JSON.stringify({}), {
"content-type": "application/json",
});
expect(res2.status).toBe(409);
expect(retryCreate2).not.toHaveBeenCalled();
});
it("action 400s when the capability is not wired", async () => {
const { store } = createStore(entity);
const app = mount(store, {}); // no approvePr
const res = await REQUEST(app, "POST", "/api/pull-requests/PR-1/approve", JSON.stringify({}), {
"content-type": "application/json",
});
expect(res.status).toBe(400);
});
it("automerge toggle persists the flip and returns the gate reason", async () => {
const { store, getCurrent } = createStore(buildEntity({ autoMerge: false }));
const app = mount(store);
const res = await REQUEST(app, "POST", "/api/pull-requests/PR-1/automerge", JSON.stringify({ enabled: true }), {
"content-type": "application/json",
});
expect(res.status).toBe(200);
expect(getCurrent().autoMerge).toBe(true);
expect(res.body.pullRequest.summary.autoMergeReason).toBe("Ready to merge");
});
});
describe("column move-backward guard (R16)", () => {
// COLUMNS order: triage(0) todo(1) in-progress(2) in-review(3) done(4).
it("blocks in-review (3) → in-progress (2) while an open PR exists, with guidance", () => {
expect(
isBackwardMoveBlockedByOpenPr({
fromIndex: 3,
toIndex: 2,
activePrEntity: buildEntity({ state: "open" }),
}),
).toBe(true);
expect(PR_OPEN_BLOCKS_MOVE_BACK_MESSAGE).toBe(
"This task has an open PR. Merge or close the PR before moving it back.",
);
});
it("allows the backward move once the PR is terminal (no active entity)", () => {
expect(
isBackwardMoveBlockedByOpenPr({ fromIndex: 3, toIndex: 2, activePrEntity: null }),
).toBe(false);
// A terminal entity should also not block (defensive: store excludes these).
expect(
isBackwardMoveBlockedByOpenPr({
fromIndex: 3,
toIndex: 2,
activePrEntity: buildEntity({ state: "closed" }),
}),
).toBe(false);
});
it("never blocks a forward move even with an open PR", () => {
expect(
isBackwardMoveBlockedByOpenPr({
fromIndex: 3,
toIndex: 4,
activePrEntity: buildEntity({ state: "open" }),
}),
).toBe(false);
});
});

View File

@@ -13,8 +13,10 @@ import { createDevServerRouter } from "../dev-server-routes.js";
import type { AiSessionStore } from "../ai-session-store.js";
import { createStashRecoveryRouter } from "./register-stash-recovery-routes.js";
import { createBranchGroupsRouter } from "./register-branch-groups-routes.js";
import { createPullRequestsRouter } from "./register-pull-requests-routes.js";
import { GitHubClient, closeGroupPullRequest, reconcileGroupPullRequest } from "../github.js";
import { reconcileBranchGroupPr } from "@fusion/engine";
import { reconcileBranchGroupPr, releaseHeldTaskByEvent } from "@fusion/engine";
import type { PrEntity } from "@fusion/core";
interface IntegratedRoutersOptions {
router: Router;
@@ -115,6 +117,29 @@ export function registerIntegratedRouters({
return store.getBranchGroup(group.id) ?? group;
},
}));
// Unified PR entity view + user-controlled actions (U7, R11/R12/R13). Each
// side-effecting action maps to a manual hold-release: the workflow's
// user-controlled release edges own the GitHub side effects, so the route just
// releases the entity's source task with an action-specific event tag. The
// route layer already re-reads authoritative entity state before invoking these
// callbacks (never a stale client copy). The engine primitive is imported
// statically (FN-3049 — no runtime `await import`).
const releaseForPr = async (entity: PrEntity, eventTag: string): Promise<Record<string, unknown>> => {
// task-sourced entities release the task directly; branch-group-sourced
// entities release the group's representative task (the sourceId is the task
// id the workflow placed on the await hold in both cases).
const result = await releaseHeldTaskByEvent(store, entity.sourceId, eventTag);
return { released: result.released, toColumn: result.toColumn, rejection: result.rejection };
};
router.use("/pull-requests", createPullRequestsRouter(store, {
approvePr: ({ entity }) => releaseForPr(entity, "pr-approve"),
mergePr: ({ entity }) => releaseForPr(entity, "pr-merge"),
retryPr: ({ entity }) => releaseForPr(entity, "pr-retry"),
closePr: ({ entity }) => releaseForPr(entity, "pr-close"),
retryCreate: ({ entity }) => releaseForPr(entity, "pr-retry-create"),
}));
}
export function registerIntegratedDevServerRouter({ router, store }: DevServerRouterOptions): void {

View File

@@ -0,0 +1,239 @@
import { Router, type Request } from "express";
import type { PrEntity, PrThreadState, TaskStore } from "@fusion/core";
import {
isPrEntityActive,
isPrEntityActionable,
isPrEntityAutoMergeReady,
} from "@fusion/core";
import { badRequest, notFound, ApiError } from "../api-error.js";
/**
* Injected engine capabilities for the user-controlled PR actions (U7, R13).
*
* Each action maps to a manual hold-release (the workflow's user-controlled
* release edges own the real GitHub side effects): approve/merge/retry/close all
* fire the same release authority the scheduler's hold-release sweep uses. The
* router never imports the engine directly (FN-3049) — capabilities arrive as
* option callbacks wired in register-integrated-routers.ts. When a capability is
* omitted the corresponding action 400s ("unavailable") rather than no-op'ing
* silently.
*
* All side-effecting callbacks receive the AUTHORITATIVE entity the route just
* re-read from the store — never a client-supplied copy. Acting on a stale
* client copy is the bug class documented in
* docs/solutions/logic-errors/queued-chat-message-flush-trusts-stale-isgenerating.md.
*/
export interface PullRequestsRouterOptions {
/** Release the PR's source task to advance toward merge (approve / force-merge). */
approvePr?: (input: { entity: PrEntity; projectId?: string }) => Promise<Record<string, unknown>>;
/** Merge the PR via the workflow's merge release (force-merge). */
mergePr?: (input: { entity: PrEntity; projectId?: string }) => Promise<Record<string, unknown>>;
/** Request another review-response round (rework release). */
retryPr?: (input: { entity: PrEntity; projectId?: string }) => Promise<Record<string, unknown>>;
/** Close the PR terminally and reconcile the entity. */
closePr?: (input: { entity: PrEntity; projectId?: string }) => Promise<Record<string, unknown>>;
/** Retry a failed PR creation (state === "failed", R4). */
retryCreate?: (input: { entity: PrEntity; projectId?: string }) => Promise<Record<string, unknown>>;
}
function parseProjectId(req: Request): string | undefined {
const value = req.query.projectId ?? req.body?.projectId;
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
/**
* The live auto-merge gate reason shown next to the toggle (R11). Mirrors the
* engine's auto-merge-ready predicate ordering so the UI never disagrees with
* what the gate will actually do.
*/
export function autoMergeGateReason(entity: PrEntity): string {
if (!entity.autoMerge) return "Auto-merge off";
if (entity.mergeable === "conflicting") return "Blocked: conflict";
if (entity.reviewDecision !== "APPROVED") return "Waiting for approval";
if (entity.checksRollup !== "success") return "Waiting for checks";
if (entity.mergeable !== "clean") return "Waiting for checks";
if (isPrEntityAutoMergeReady(entity)) return "Ready to merge";
return "Waiting for checks";
}
/** Whether the entity is in a hard conflict (Merge must be disabled, R11). */
export function isPrConflicting(entity: PrEntity): boolean {
return entity.mergeable === "conflicting";
}
/** Structured rejection message for the R16 column-move-backward block. */
export const PR_OPEN_BLOCKS_MOVE_BACK_MESSAGE =
"This task has an open PR. Merge or close the PR before moving it back.";
/**
* R16: should a column move be blocked because the task has an open PR?
*
* A "backward" move (lower column index) of a task that still has an ACTIVE
* (non-terminal) PR entity is rejected — the PR's lifecycle is workflow-owned and
* dragging the card back would orphan the open GitHub PR. Forward moves and moves
* of tasks whose PR is terminal (merged/closed/failed → no active entity) pass.
*
* Pure so the move route and tests consult one definition.
*/
export function isBackwardMoveBlockedByOpenPr(input: {
fromIndex: number;
toIndex: number;
activePrEntity: Pick<PrEntity, "state"> | null | undefined;
}): boolean {
const { fromIndex, toIndex, activePrEntity } = input;
if (fromIndex < 0 || toIndex < 0) return false;
if (toIndex >= fromIndex) return false; // not backward
return Boolean(activePrEntity && isPrEntityActive(activePrEntity));
}
/**
* Build the merge-readiness summary the view renders above the checks list.
* Pure derivation from authoritative entity state.
*/
export function buildPrSummary(entity: PrEntity, threads: PrThreadState[]) {
const pendingThreads = threads.filter((t) => t.outcome === "pending").length;
const disagreedThreads = threads.filter((t) => t.outcome === "disagreed").length;
return {
mergeable: entity.mergeable ?? "unknown",
reviewDecision: entity.reviewDecision ?? null,
checksRollup: entity.checksRollup ?? "none",
conflicting: isPrConflicting(entity),
autoMerge: entity.autoMerge,
autoMergeReason: autoMergeGateReason(entity),
autoMergeReady: isPrEntityAutoMergeReady(entity),
actionable: isPrEntityActionable(entity),
active: isPrEntityActive(entity),
pendingThreads,
disagreedThreads,
};
}
function serializePr(entity: PrEntity, threads: PrThreadState[]) {
return {
...entity,
threads,
summary: buildPrSummary(entity, threads),
};
}
export function createPullRequestsRouter(store: TaskStore, options?: PullRequestsRouterOptions): Router {
const router = Router();
// GET /api/pull-requests — list active entities, optional repo/status filter.
router.get("/", async (_req, res) => {
const repoRaw = _req.query.repo;
const statusRaw = _req.query.status;
const repo = typeof repoRaw === "string" && repoRaw.trim() ? repoRaw.trim() : undefined;
const status = typeof statusRaw === "string" && statusRaw.trim() ? statusRaw.trim() : undefined;
if (
status &&
!["creating", "open", "responding", "merged", "closed", "failed"].includes(status)
) {
throw badRequest(
"status must be one of: creating, open, responding, merged, closed, failed",
);
}
let entities = store.listActivePrEntities();
if (repo) entities = entities.filter((e) => e.repo === repo);
if (status) entities = entities.filter((e) => e.state === status);
const pullRequests = entities.map((entity) =>
serializePr(entity, store.listPrThreadStates(entity.id)),
);
res.json({ pullRequests });
});
// GET /api/pull-requests/:id — entity + thread states + checks/merge/conflict summary.
router.get("/:id", async (req, res) => {
const id = String(req.params.id ?? "").trim();
if (!id) throw badRequest("id is required");
const entity = store.getPrEntity(id);
if (!entity) throw notFound("PR entity not found");
res.json({ pullRequest: serializePr(entity, store.listPrThreadStates(id)) });
});
/**
* Shared action handler: re-read the AUTHORITATIVE entity (never trust a client
* copy), gate it, then dispatch to the injected capability. Returns the freshly
* re-read serialized entity so the client replaces its stale copy.
*/
function makeAction(
name: string,
capability: ((input: { entity: PrEntity; projectId?: string }) => Promise<Record<string, unknown>>) | undefined,
opts: { requireActive?: boolean; requireState?: PrEntity["state"]; rejectConflict?: boolean } = {},
) {
return async (req: Request, res: import("express").Response) => {
const id = String(req.params.id ?? "").trim();
if (!id) throw badRequest("id is required");
// Re-fetch authoritative state — the side effect must never gate on a
// stale client/SSE-delivered copy.
const entity = store.getPrEntity(id);
if (!entity) throw notFound("PR entity not found");
if (opts.requireState && entity.state !== opts.requireState) {
throw new ApiError(409, `PR is not in '${opts.requireState}' state`, {
code: "pr-wrong-state",
retryable: false,
});
}
if (opts.requireActive && !isPrEntityActive(entity)) {
throw new ApiError(409, "PR is already terminal (merged/closed/failed)", {
code: "pr-terminal",
retryable: false,
});
}
if (opts.rejectConflict && isPrConflicting(entity)) {
throw new ApiError(409, "Resolve conflicts on GitHub before merging", {
code: "pr-conflict",
retryable: false,
});
}
if (!capability) {
throw badRequest(`${name} is unavailable`);
}
const result = await capability({ entity, projectId: parseProjectId(req) });
// Re-read after the action so the response reflects authoritative state.
const fresh = store.getPrEntity(id) ?? entity;
res.json({
...result,
pullRequest: serializePr(fresh, store.listPrThreadStates(id)),
});
};
}
router.post("/:id/approve", makeAction("Approve", options?.approvePr, { requireActive: true }));
router.post(
"/:id/merge",
makeAction("Merge", options?.mergePr, { requireActive: true, rejectConflict: true }),
);
router.post("/:id/retry", makeAction("Retry", options?.retryPr, { requireActive: true }));
router.post("/:id/close", makeAction("Close", options?.closePr, { requireActive: true }));
router.post(
"/:id/retry-create",
makeAction("Retry PR creation", options?.retryCreate, { requireState: "failed" }),
);
// Toggle auto-merge. Re-reads authoritative state then persists the flip.
router.post("/:id/automerge", async (req, res) => {
const id = String(req.params.id ?? "").trim();
if (!id) throw badRequest("id is required");
const entity = store.getPrEntity(id);
if (!entity) throw notFound("PR entity not found");
if (!isPrEntityActive(entity)) {
throw new ApiError(409, "PR is already terminal (merged/closed/failed)", {
code: "pr-terminal",
retryable: false,
});
}
const enabled =
typeof req.body?.enabled === "boolean" ? req.body.enabled : !entity.autoMerge;
const updated = store.updatePrEntity(id, { autoMerge: enabled });
res.json({ pullRequest: serializePr(updated, store.listPrThreadStates(id)) });
});
return router;
}

View File

@@ -45,6 +45,7 @@ import { createTrackingIssueForTask } from "../github-tracking-hook.js";
import { parseGitHubBadgeUrl } from "./register-git-github.js";
import { planTaskWorktreePath, promoteHeldTask } from "@fusion/engine";
import { buildBoardWorkflowsPayload } from "./board-workflows.js";
import { isBackwardMoveBlockedByOpenPr, PR_OPEN_BLOCKS_MOVE_BACK_MESSAGE } from "./register-pull-requests-routes.js";
import type { RunAuditEventInput } from "@fusion/core";
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js";
@@ -1364,6 +1365,35 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
throw badRequest("preserveProgress must be a boolean");
}
// R16: block moving a PR-await task "backward" (e.g. in-review → in-progress)
// while it still has an open PR entity. The PR's lifecycle is workflow-owned;
// dragging the card back would orphan the open GitHub PR. The user must
// merge or close the PR first (a user-controlled release advances it
// forward; this guard only rejects backward drags). Once the entity is
// terminal (merged/closed/failed) the move is allowed.
const moveTarget = column as Column;
const guardTask = await scopedStore.getTask(req.params.id);
if (guardTask) {
const activePrEntity =
scopedStore.getActivePrEntityBySource?.("task", guardTask.id) ??
(guardTask.branchContext?.groupId
? scopedStore.getActivePrEntityBySource?.("branch-group", guardTask.branchContext.groupId)
: null);
if (
isBackwardMoveBlockedByOpenPr({
fromIndex: COLUMNS.indexOf(guardTask.column as Column),
toIndex: COLUMNS.indexOf(moveTarget),
activePrEntity,
})
) {
throw new ApiError(409, PR_OPEN_BLOCKS_MOVE_BACK_MESSAGE, {
code: "pr-open-blocks-move-back",
messageKey: "board.rejection.prOpenBlocksMoveBack",
retryable: false,
});
}
}
// When manually promoting to in-progress, supply an allocator so
// moveTask assigns a worktree path under its cross-task allocation
// lock. This mirrors scheduler dispatch semantics — without it, a