diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 1619071cd..e76ea1c0f 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -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 { + return api(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 { return api(withProjectId(`/tasks/${id}/pr/checks`, projectId)); diff --git a/packages/dashboard/app/components/PrPanel.css b/packages/dashboard/app/components/PrPanel.css index 2b0f279af..f91494e3f 100644 --- a/packages/dashboard/app/components/PrPanel.css +++ b/packages/dashboard/app/components/PrPanel.css @@ -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; diff --git a/packages/dashboard/app/components/PrPanel.tsx b/packages/dashboard/app/components/PrPanel.tsx index 7c8539f44..487e2e12a 100644 --- a/packages/dashboard/app/components/PrPanel.tsx +++ b/packages/dashboard/app/components/PrPanel.tsx @@ -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(null); const [reviewsState, setReviewsState] = useState(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 (
@@ -236,6 +274,47 @@ export function PrPanel({ ))}
+ {showMergeControls ? ( +
+
Merge
+
+ + + +
+ {prInfo.lastMergeError ? ( +
+ {prInfo.lastMergeError} + +
+ ) : null} +
+ ) : null} + + {(prInfo.draft ?? prInfo.isDraft) === true && prInfo.status === "open" ? ( +
Ready for review required before merging.
+ ) : null} + {reviewDecision === "CHANGES_REQUESTED" && taskColumn === "todo" && (
Auto-moved to Todo — reviewer feedback ready
)} @@ -249,7 +328,7 @@ export function PrPanel({ )} {prInfo.status === "merged" && ( -
This PR is merged. fn will finish local cleanup and move the task to Done.
+
Merged — task moved to Done
)}
diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index b72679368..8ec7ab1c0 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -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} diff --git a/packages/dashboard/app/components/__tests__/PrPanel.merge.test.tsx b/packages/dashboard/app/components/__tests__/PrPanel.merge.test.tsx new file mode 100644 index 000000000..5a4916b83 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/PrPanel.merge.test.tsx @@ -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( {}} 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( {}} 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( {}} 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" })); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/PrPanel.test.tsx b/packages/dashboard/app/components/__tests__/PrPanel.test.tsx index cc7961d37..0af673a21 100644 --- a/packages/dashboard/app/components/__tests__/PrPanel.test.tsx +++ b/packages/dashboard/app/components/__tests__/PrPanel.test.tsx @@ -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).mockResolvedValue({ snapshot: { decision: null, items: [] }, comments: [] }); + (mergePr as ReturnType).mockResolvedValue({ prInfo: { ...mockPrInfo, status: "merged" } }); + (setAutoMergeOnGreen as ReturnType).mockResolvedValue({ prInfo: { ...mockPrInfo, autoMergeOnGreen: true } }); }); it("renders create button and calls onRequestCreatePr", () => { diff --git a/packages/dashboard/src/__tests__/pr-merged-auto-done.integration.test.ts b/packages/dashboard/src/__tests__/pr-merged-auto-done.integration.test.ts new file mode 100644 index 000000000..696d72e27 --- /dev/null +++ b/packages/dashboard/src/__tests__/pr-merged-auto-done.integration.test.ts @@ -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("../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); + }); +}); diff --git a/packages/dashboard/src/__tests__/routes-pr-merge.test.ts b/packages/dashboard/src/__tests__/routes-pr-merge.test.ts new file mode 100644 index 000000000..dc9b27073 --- /dev/null +++ b/packages/dashboard/src/__tests__/routes-pr-merge.test.ts @@ -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"); + }); +}); diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index 59dace33d..90f1e5c7d 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -1098,6 +1098,81 @@ async function applyChangesRequestedTransition( } } +export function resolvePrMergeMethod( + settings: Pick | null | undefined, + prInfo: Pick | 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 { + 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 { 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.