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>
410 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|