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:
committed by
gsxdsm
parent
102893cba3
commit
73454fae5c
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
23
packages/dashboard/src/__tests__/routes-pr-merge.test.ts
Normal file
23
packages/dashboard/src/__tests__/routes-pr-merge.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user