Files
fusion/packages/dashboard/app/components/PullRequestView.tsx
gsxdsm 8a6a67a305 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>
2026-06-05 22:18:34 -07:00

410 lines
14 KiB
TypeScript

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>
);
}