feat(FN-4762): complete Step 3 — PR merge routes and dashboard controls

Fusion-Task-Id: FN-4762
Fusion-Task-Lineage: 5c7ef3b8-fefc-4cc2-a703-634124d77cde
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 12:04:54 -07:00
committed by gsxdsm
parent 102893cba3
commit 73454fae5c
9 changed files with 443 additions and 9 deletions

View File

@@ -2219,6 +2219,11 @@ export interface PrRefreshResponse {
automationStatus?: string | null;
}
export interface PrMergeResponse {
prInfo: PrInfo;
alreadyMerged?: boolean;
}
export interface PrChecksResponse {
checks: PrCheckStatus[];
rollup: "success" | "pending" | "failure" | "unknown";
@@ -2349,6 +2354,25 @@ export function refreshPrStatus(id: string, projectId?: string): Promise<PrRefre
});
}
export function mergePr(id: string, method?: "merge" | "squash" | "rebase", projectId?: string): Promise<PrMergeResponse> {
return api<PrMergeResponse>(withProjectId(`/tasks/${id}/pr/merge`, projectId), {
method: "POST",
body: JSON.stringify(method ? { method } : {}),
});
}
export function setAutoMergeOnGreen(
id: string,
enabled: boolean,
strategy?: "merge" | "squash" | "rebase",
projectId?: string,
): Promise<{ prInfo: PrInfo }> {
return api<{ prInfo: PrInfo }>(withProjectId(`/tasks/${id}/pr/auto-merge`, projectId), {
method: "POST",
body: JSON.stringify({ enabled, strategy }),
});
}
/** Fetch all PR checks for a task */
export function fetchPrChecks(id: string, projectId?: string): Promise<PrChecksResponse> {
return api<PrChecksResponse>(withProjectId(`/tasks/${id}/pr/checks`, projectId));

View File

@@ -158,6 +158,32 @@
padding: var(--space-sm);
}
.pr-hint--success {
background: color-mix(in srgb, var(--color-success) 12%, transparent);
border: var(--btn-border-width) solid color-mix(in srgb, var(--color-success) 35%, transparent);
border-radius: var(--radius-md);
color: var(--color-success);
font-size: 0.8125rem;
padding: var(--space-sm);
}
.pr-merge-controls {
display: grid;
gap: var(--space-sm);
}
.pr-merge-error {
align-items: center;
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border: var(--btn-border-width) solid color-mix(in srgb, var(--color-error) 35%, transparent);
border-radius: var(--radius-md);
color: var(--color-error);
display: flex;
gap: var(--space-sm);
justify-content: space-between;
padding: var(--space-sm);
}
@media (max-width: 768px) {
.pr-panel-checks-rollup {
align-items: flex-start;

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { GitPullRequest, ExternalLink, RefreshCw, Plus, MessageSquare, CircleDot, XCircle, GitMerge } from "lucide-react";
import { getErrorMessage } from "@fusion/core";
import { fetchPrReviews, refreshPrStatus, type PrCheckStatus, type PrInfo, type PrRefreshResponse, type PrReviewsResponse } from "../api";
import { fetchPrReviews, mergePr, refreshPrStatus, setAutoMergeOnGreen, type DirectMergeCommitStrategy, type PrCheckStatus, type PrInfo, type PrRefreshResponse, type PrReviewsResponse } from "../api";
import { usePrChecksStream } from "../hooks/usePrChecksStream";
import { PrChecksList } from "./PrChecksList";
import type { ToastType } from "../hooks/useToast";
@@ -19,6 +19,7 @@ interface PrPanelProps {
prAuthAvailable: boolean;
onPrUpdated: (prInfo: PrInfo) => void;
onRequestCreatePr?: () => void;
directMergeCommitStrategy?: DirectMergeCommitStrategy;
addToast: (message: string, type?: ToastType) => void;
}
@@ -52,11 +53,20 @@ export function PrPanel({
prAuthAvailable,
onPrUpdated,
onRequestCreatePr,
directMergeCommitStrategy = "auto",
addToast,
}: PrPanelProps) {
const [isRefreshing, setIsRefreshing] = useState(false);
const [refreshState, setRefreshState] = useState<PrRefreshResponse | null>(null);
const [reviewsState, setReviewsState] = useState<PrReviewsResponse | null>(null);
const [isMerging, setIsMerging] = useState(false);
const [mergeStrategy, setMergeStrategy] = useState<"merge" | "squash" | "rebase">(
directMergeCommitStrategy === "always-rebase"
? "rebase"
: directMergeCommitStrategy === "always-squash"
? "squash"
: "squash",
);
useEffect(() => {
if (!prInfo) {
@@ -86,6 +96,31 @@ export function PrPanel({
}
}, [taskId, projectId, prInfo, onPrUpdated, addToast]);
const handleMerge = useCallback(async () => {
if (!prInfo) return;
setIsMerging(true);
try {
const result = await mergePr(taskId, mergeStrategy, projectId);
onPrUpdated(result.prInfo);
addToast("Pull request merged", "success");
} catch (err) {
addToast(getErrorMessage(err) || "Failed to merge pull request", "error");
} finally {
setIsMerging(false);
}
}, [addToast, mergeStrategy, onPrUpdated, prInfo, projectId, taskId]);
const handleAutoMergeToggle = useCallback(async (enabled: boolean) => {
if (!prInfo) return;
try {
const result = await setAutoMergeOnGreen(taskId, enabled, mergeStrategy, projectId);
onPrUpdated(result.prInfo);
addToast(enabled ? "Auto-merge enabled" : "Auto-merge disabled", "success");
} catch (err) {
addToast(getErrorMessage(err) || "Failed to update auto-merge", "error");
}
}, [addToast, mergeStrategy, onPrUpdated, prInfo, projectId, taskId]);
if (!prInfo) {
if (automationStatus === "creating-pr") {
return (
@@ -170,6 +205,9 @@ export function PrPanel({
initialRollup: checkSummary,
initialLastCheckedAt: prInfo.lastCheckedAt,
});
const mergeReady = (refreshState?.mergeReady ?? false) && prInfo.status === "open";
const blockingReasonsTitle = (refreshState?.blockingReasons ?? []).join("; ");
const showMergeControls = prInfo.status === "open" && (prInfo.draft ?? prInfo.isDraft) !== true;
return (
<div className="pr-section">
@@ -236,6 +274,47 @@ export function PrPanel({
))}
</div>
{showMergeControls ? (
<div className="pr-panel-section">
<div className="pr-panel-row-label">Merge</div>
<div className="pr-merge-controls">
<select className="select" value={mergeStrategy} onChange={(event) => setMergeStrategy(event.target.value as "merge" | "squash" | "rebase")}>
<option value="merge">merge</option>
<option value="squash">squash</option>
<option value="rebase">rebase</option>
</select>
<button
className="btn btn-primary btn-sm"
onClick={handleMerge}
disabled={!mergeReady || isMerging}
title={mergeReady ? "Merge pull request" : blockingReasonsTitle || "Refresh PR status to check merge readiness"}
>
Merge pull request
</button>
<label className="checkbox-label">
<input
type="checkbox"
checked={Boolean(prInfo.autoMergeOnGreen)}
onChange={(event) => {
void handleAutoMergeToggle(event.currentTarget.checked);
}}
/>
Auto-merge when green
</label>
</div>
{prInfo.lastMergeError ? (
<div className="pr-merge-error">
<span>{prInfo.lastMergeError}</span>
<button className="btn btn-sm" onClick={handleMerge} disabled={isMerging}>Retry</button>
</div>
) : null}
</div>
) : null}
{(prInfo.draft ?? prInfo.isDraft) === true && prInfo.status === "open" ? (
<div className="pr-hint pr-hint--warning">Ready for review required before merging.</div>
) : null}
{reviewDecision === "CHANGES_REQUESTED" && taskColumn === "todo" && (
<div className="pr-hint pr-hint--warning">Auto-moved to Todo reviewer feedback ready</div>
)}
@@ -249,7 +328,7 @@ export function PrPanel({
</div>
)}
{prInfo.status === "merged" && (
<div className="pr-hint pr-hint--info">This PR is merged. fn will finish local cleanup and move the task to Done.</div>
<div className="pr-hint pr-hint--success">Merged task moved to Done</div>
)}
<div className="pr-footer">

View File

@@ -3292,6 +3292,7 @@ export function TaskDetailContent({
taskColumn={task.column}
autoMerge={settings?.autoMerge ?? false}
isManualPrFlow={isManualPrFlow}
directMergeCommitStrategy={settings?.directMergeCommitStrategy}
prAuthAvailable={prAuthAvailable ?? false}
// TODO(FN-4758): wire create-PR modal trigger
onRequestCreatePr={undefined}

View File

@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { PrPanel } from "../PrPanel";
vi.mock("../../api", () => ({
refreshPrStatus: vi.fn(),
fetchPrChecks: vi.fn().mockResolvedValue({ checks: [], rollup: "unknown", lastCheckedAt: new Date().toISOString() }),
fetchPrReviews: vi.fn().mockResolvedValue({ snapshot: { decision: null, items: [] }, comments: [] }),
mergePr: vi.fn().mockResolvedValue({ prInfo: { url: "https://github.com/o/r/pull/1", number: 1, status: "merged", title: "t", headBranch: "h", baseBranch: "main", commentCount: 0 } }),
setAutoMergeOnGreen: vi.fn().mockResolvedValue({ prInfo: { url: "https://github.com/o/r/pull/1", number: 1, status: "open", title: "t", headBranch: "h", baseBranch: "main", commentCount: 0, autoMergeOnGreen: true } }),
}));
describe("PrPanel merge controls", () => {
it.each([
[{ status: "open", draft: false }, true],
[{ status: "open", draft: true }, false],
[{ status: "merged", draft: false }, false],
] as const)("shows merge controls matrix %#", (state, expected) => {
render(<PrPanel taskId="FN-1" prAuthAvailable onPrUpdated={() => {}} addToast={() => {}} prInfo={{ url: "https://github.com/o/r/pull/1", number: 1, title: "t", headBranch: "h", baseBranch: "main", commentCount: 0, ...state }} />);
expect(screen.queryByText("Merge pull request") !== null).toBe(expected);
});
it("shows merged banner", () => {
render(<PrPanel taskId="FN-1" prAuthAvailable onPrUpdated={() => {}} addToast={() => {}} prInfo={{ url: "https://github.com/o/r/pull/1", number: 1, status: "merged", title: "t", headBranch: "h", baseBranch: "main", commentCount: 0 }} />);
expect(screen.getByText("Merged — task moved to Done")).toBeInTheDocument();
});
it("shows error block and retry", () => {
render(<PrPanel taskId="FN-1" prAuthAvailable onPrUpdated={() => {}} addToast={() => {}} prInfo={{ url: "https://github.com/o/r/pull/1", number: 1, status: "open", title: "t", headBranch: "h", baseBranch: "main", commentCount: 0, lastMergeError: "boom" }} />);
expect(screen.getByText("boom")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
});
});

View File

@@ -6,9 +6,11 @@ vi.mock("../../api", () => ({
refreshPrStatus: vi.fn(),
fetchPrChecks: vi.fn(),
fetchPrReviews: vi.fn(),
mergePr: vi.fn(),
setAutoMergeOnGreen: vi.fn(),
}));
import { refreshPrStatus, fetchPrChecks, fetchPrReviews } from "../../api";
import { refreshPrStatus, fetchPrChecks, fetchPrReviews, mergePr, setAutoMergeOnGreen } from "../../api";
const mockAddToast = vi.fn();
const mockOnPrUpdated = vi.fn();
@@ -34,6 +36,8 @@ describe("PrPanel", () => {
lastCheckedAt: new Date().toISOString(),
});
(fetchPrReviews as ReturnType<typeof vi.fn>).mockResolvedValue({ snapshot: { decision: null, items: [] }, comments: [] });
(mergePr as ReturnType<typeof vi.fn>).mockResolvedValue({ prInfo: { ...mockPrInfo, status: "merged" } });
(setAutoMergeOnGreen as ReturnType<typeof vi.fn>).mockResolvedValue({ prInfo: { ...mockPrInfo, autoMergeOnGreen: true } });
});
it("renders create button and calls onRequestCreatePr", () => {

View File

@@ -0,0 +1,70 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore } from "@fusion/core";
import { refreshPrInBackground } from "../routes/register-git-github.js";
vi.mock("../github.js", async () => {
const actual = await vi.importActual<object>("../github.js");
class MockGitHubClient {
async getPrReviewSnapshot() {
return {
prInfo: { url: "https://github.com/o/r/pull/1", number: 1, status: "merged", title: "t", headBranch: "h", baseBranch: "main", commentCount: 0 },
decision: "APPROVED",
items: [],
};
}
async getPrMergeStatus() {
return {
prInfo: { url: "https://github.com/o/r/pull/1", number: 1, status: "merged", title: "t", headBranch: "h", baseBranch: "main", commentCount: 0 },
reviewDecision: "APPROVED",
checks: [],
mergeReady: true,
blockingReasons: [],
};
}
}
return { ...actual, GitHubClient: MockGitHubClient };
});
describe("pr merged refresh auto-done", () => {
let store: TaskStore;
let rootDir: string;
let globalDir: string;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "fn-4762-pr-merged-root-"));
globalDir = mkdtempSync(join(tmpdir(), "fn-4762-pr-merged-global-"));
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
rmSync(globalDir, { recursive: true, force: true });
});
it("moves in-review task to done and records audit", async () => {
const task = await store.createTask({ description: "pr merged" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updatePrInfo(task.id, {
url: "https://github.com/o/r/pull/1",
number: 1,
status: "open",
title: "t",
headBranch: "h",
baseBranch: "main",
commentCount: 0,
});
await refreshPrInBackground(store, task.id, (await store.getTask(task.id)).prInfo!);
const updated = await store.getTask(task.id);
expect(updated.column).toBe("done");
const events = store.getRunAuditEvents({ taskId: task.id, mutationType: "pr:merged-auto-done" });
expect(events.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { resolvePrMergeMethod } from "../routes/register-git-github.js";
describe("resolvePrMergeMethod", () => {
it("prefers explicit request method", () => {
expect(resolvePrMergeMethod({ directMergeCommitStrategy: "always-rebase" }, { autoMergeStrategy: "squash" }, "merge")).toBe("merge");
});
it("falls back to pr auto strategy", () => {
expect(resolvePrMergeMethod({ directMergeCommitStrategy: "always-rebase" }, { autoMergeStrategy: "squash" })).toBe("squash");
});
it("maps settings strategy", () => {
expect(resolvePrMergeMethod({ directMergeCommitStrategy: "always-rebase" }, null)).toBe("rebase");
expect(resolvePrMergeMethod({ directMergeCommitStrategy: "always-squash" }, null)).toBe("squash");
expect(resolvePrMergeMethod({ directMergeCommitStrategy: "auto" }, null)).toBe("squash");
});
it("hard-falls back to squash", () => {
expect(resolvePrMergeMethod(undefined, undefined)).toBe("squash");
});
});

View File

@@ -1098,6 +1098,81 @@ async function applyChangesRequestedTransition(
}
}
export function resolvePrMergeMethod(
settings: Pick<import("@fusion/core").Settings, "directMergeCommitStrategy"> | null | undefined,
prInfo: Pick<PrInfo, "autoMergeStrategy"> | null | undefined,
explicit?: "merge" | "squash" | "rebase",
): "merge" | "squash" | "rebase" {
if (explicit) return explicit;
if (prInfo?.autoMergeStrategy) return prInfo.autoMergeStrategy;
switch (settings?.directMergeCommitStrategy) {
case "always-rebase":
return "rebase";
case "always-squash":
return "squash";
case "auto":
default:
return "squash";
}
}
async function mergeTaskPr(
scopedStore: TaskStore,
task: Task,
token: string | undefined,
explicitMethod?: "merge" | "squash" | "rebase",
runIdPrefix = "pr-merge",
): Promise<PrInfo> {
if (!task.prInfo?.number) {
throw badRequest("Task has no associated PR number");
}
if (task.prInfo.status !== "open") {
throw badRequest(`PR is ${task.prInfo.status}`);
}
const badgeParsed = parseBadgeUrl(task.prInfo.url);
const repo = badgeParsed ?? getCurrentRepo(scopedStore.getRootDir());
if (!repo) {
throw badRequest("Could not determine GitHub repository");
}
const settings = await scopedStore.getSettings();
const method = resolvePrMergeMethod(settings, task.prInfo, explicitMethod);
const client = new GitHubClient(token);
try {
const mergedPrInfo = await client.mergePr({ owner: repo.owner, repo: repo.repo, number: task.prInfo.number, method });
const updated = {
...task.prInfo,
...mergedPrInfo,
autoMergeOnGreen: task.prInfo.autoMergeOnGreen,
autoMergeStrategy: task.prInfo.autoMergeStrategy,
lastMergeError: undefined,
lastMergeErrorAt: undefined,
draft: mergedPrInfo.draft ?? mergedPrInfo.isDraft,
} satisfies PrInfo;
await scopedStore.updatePrInfo(task.id, updated);
await scopedStore.applyPrMergedTransition(task.id, {
agentId: "dashboard",
runId: `${runIdPrefix}-${task.id}-${Date.now()}`,
});
return updated;
} catch (error) {
const message = getCommandErrorMessage(error) || "Failed to merge pull request";
await scopedStore.updatePrInfo(task.id, {
...task.prInfo,
lastMergeError: message,
lastMergeErrorAt: new Date().toISOString(),
});
const err = new ApiError(502, "Failed to merge pull request", {
code: "pr_merge_failed",
retryable: true,
error: message,
});
throw err;
}
}
export async function refreshPrInBackground(store: TaskStore, taskId: string, currentPrInfo: PrInfo, token?: string): Promise<void> {
try {
let owner: string;
@@ -1130,15 +1205,37 @@ export async function refreshPrInBackground(store: TaskStore, taskId: string, cu
const task = await store.getTask(taskId);
const reviewSnapshot = await client.getPrReviewSnapshot(owner, repo, currentPrInfo.number);
const mergeStatus = await client.getPrMergeStatus(owner, repo, currentPrInfo.number);
const prior = task.prInfo;
const prInfo = {
...reviewSnapshot.prInfo,
...prior,
...mergeStatus.prInfo,
autoMergeOnGreen: prior?.autoMergeOnGreen,
autoMergeStrategy: prior?.autoMergeStrategy,
lastMergeError: prior?.lastMergeError,
lastMergeErrorAt: prior?.lastMergeErrorAt,
draft: mergeStatus.prInfo.draft ?? mergeStatus.prInfo.isDraft,
lastCheckedAt: new Date().toISOString(),
lastReviewDecision: reviewSnapshot.decision,
};
} satisfies PrInfo;
await store.updatePrInfo(taskId, prInfo);
await syncPrReviewsToTask(store, task, reviewSnapshot);
await applyChangesRequestedTransition(store, task, reviewSnapshot, prInfo);
if (prInfo.status === "merged") {
await store.applyPrMergedTransition(taskId, {
agentId: "dashboard",
runId: `pr-refresh-${taskId}-${Date.now()}`,
});
return;
}
const lastMergeErrorAt = prior?.lastMergeErrorAt ? Date.parse(prior.lastMergeErrorAt) : Number.NaN;
const recentlyFailed = Number.isFinite(lastMergeErrorAt) && Date.now() - lastMergeErrorAt < 5 * 60 * 1000;
if (prior?.autoMergeOnGreen && mergeStatus.mergeReady && !recentlyFailed) {
await mergeTaskPr(store, task, token, undefined, "pr-refresh");
}
} catch {
// best-effort
}
@@ -3295,8 +3392,14 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
const reviewSnapshot = await client.getPrReviewSnapshot(owner, repo, task.prInfo.number);
const mergeStatus = await client.getPrMergeStatus(owner, repo, task.prInfo.number);
const prInfo = {
...reviewSnapshot.prInfo,
const prInfo: PrInfo = {
...task.prInfo,
...mergeStatus.prInfo,
autoMergeOnGreen: task.prInfo.autoMergeOnGreen,
autoMergeStrategy: task.prInfo.autoMergeStrategy,
lastMergeError: task.prInfo.lastMergeError,
lastMergeErrorAt: task.prInfo.lastMergeErrorAt,
draft: mergeStatus.prInfo.draft ?? mergeStatus.prInfo.isDraft,
lastCheckedAt: new Date().toISOString(),
lastReviewDecision: reviewSnapshot.decision,
};
@@ -3305,13 +3408,27 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
await syncPrReviewsToTask(scopedStore, task, reviewSnapshot);
await applyChangesRequestedTransition(scopedStore, task, reviewSnapshot, prInfo);
if (prInfo.status === "merged") {
await scopedStore.applyPrMergedTransition(task.id, {
agentId: "dashboard",
runId: `pr-refresh-${task.id}-${Date.now()}`,
});
} else {
const lastMergeErrorAt = prInfo.lastMergeErrorAt ? Date.parse(prInfo.lastMergeErrorAt) : Number.NaN;
const recentlyFailed = Number.isFinite(lastMergeErrorAt) && Date.now() - lastMergeErrorAt < 5 * 60 * 1000;
if (prInfo.autoMergeOnGreen && mergeStatus.mergeReady && !recentlyFailed) {
await mergeTaskPr(scopedStore, task, githubToken, undefined, "pr-refresh");
}
}
const refreshedTask = await scopedStore.getTask(task.id);
res.json({
prInfo,
prInfo: refreshedTask.prInfo ?? prInfo,
mergeReady: mergeStatus.mergeReady,
blockingReasons: mergeStatus.blockingReasons,
reviewDecision: reviewSnapshot.decision,
checks: mergeStatus.checks,
automationStatus: task.status ?? null,
automationStatus: refreshedTask.status ?? task.status ?? null,
});
} catch (err: unknown) {
if (err instanceof ApiError) {
@@ -3327,6 +3444,63 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
}
});
router.post("/tasks/:id/pr/merge", async (req, res) => {
try {
const method = req.body?.method;
if (method && !["merge", "squash", "rebase"].includes(method)) {
throw badRequest("Invalid merge method");
}
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
if (!task.prInfo?.number) {
throw notFound("Task has no associated PR");
}
if (task.prInfo.status === "merged") {
await scopedStore.applyPrMergedTransition(task.id, {
agentId: "dashboard",
runId: `pr-merge-${task.id}-${Date.now()}`,
});
return res.json({ prInfo: task.prInfo, alreadyMerged: true });
}
const prInfo = await mergeTaskPr(scopedStore, task, githubToken, method);
res.json({ prInfo });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to merge PR");
}
});
router.post("/tasks/:id/pr/auto-merge", async (req, res) => {
try {
const { enabled, strategy } = req.body ?? {};
if (typeof enabled !== "boolean") {
throw badRequest("enabled must be a boolean");
}
if (strategy && !["merge", "squash", "rebase"].includes(strategy)) {
throw badRequest("Invalid auto-merge strategy");
}
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
if (!task.prInfo) {
throw notFound("Task has no associated PR");
}
const prInfo: PrInfo = {
...task.prInfo,
autoMergeOnGreen: enabled,
autoMergeStrategy: strategy ?? task.prInfo.autoMergeStrategy,
lastMergeError: undefined,
lastMergeErrorAt: undefined,
};
await scopedStore.updatePrInfo(task.id, prInfo);
res.json({ prInfo });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err, "Failed to set PR auto-merge");
}
});
/**
* GET /api/tasks/:id/pr/reviews
* Fetch PR review snapshot and merged Fusion comment thread view.