FN-5950: add Create PR branch-push remediation
Add in-app preflight remediation so Create PR can push task branches without leaving Fusion. - add a dashboard API route and client helper to push the task branch to origin and recompute PR preflight state - update the Create Pull Request modal, styles, and tests to surface push-branch remediation alongside AI conflict resolution - document the flow and add a published CLI changeset plus server coverage for the new push-branch endpoint Files changed: .changeset/fn-5950-pr-push-branch.md | 5 + docs/dashboard-guide.md | 2 +- packages/dashboard/app/api/legacy.ts | 19 +++ packages/dashboard/app/components/PrCreateModal.css | 4 +- packages/dashboard/app/components/PrCreateModal.tsx | 48 +++++- packages/dashboard/app/components/__tests__/PrCreateModal.test.tsx | 44 ++++++ packages/dashboard/src/__tests__/register-git-github.pr-push-branch.test.ts | 176 +++++++++++++++++++++ packages/dashboard/src/routes/register-git-github.ts | 68 ++++++++ 8 files changed, 362 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-5950 Fusion-Task-Lineage: 5a6c5c6a-8f99-44c0-8f54-f0a6ff537ed0
This commit is contained in:
5
.changeset/fn-5950-pr-push-branch.md
Normal file
5
.changeset/fn-5950-pr-push-branch.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add an in-app Create PR remediation that pushes the task branch to `origin`, refreshes preflight status, and unblocks PR creation without leaving Fusion.
|
||||
@@ -613,7 +613,7 @@ Inspect task definition, logs, review feedback, comments, documents, workflow ou
|
||||
- In shared task edit/create forms, GitHub Tracking appears at the bottom of **More options**, after **Workflow Steps**.
|
||||
- From this section you can explicitly enable/disable tracking and manage a per-task repo override (`owner/repo`). Clearing the override saves `null` and falls back to project/global defaults.
|
||||
- In `in-review`, pull-request controls/status (including stall badges) are in a dedicated **Pull Request** tab instead of the Definition tab.
|
||||
- In the **Create Pull Request** modal, if preflight detects `conflictsWithBase`, the modal now offers **Resolve conflicts with AI**. Fusion uses an AI coding agent to resolve merge markers on the task branch, commits the result, pushes `fusion/<task-id-lower>` to `origin`, and refreshes preflight so normal PR creation can continue once conflicts are gone.
|
||||
- The **Create Pull Request** modal now offers in-app remediation for every blocking preflight check. If `branchOnRemote` is false, use **Push branch to remote** and Fusion will publish `fusion/<task-id-lower>` to `origin` and refresh preflight. If `conflictsWithBase` is true, use **Resolve conflicts with AI** and Fusion will resolve merge markers on the task branch, commit the result, push the branch, and refresh preflight so normal PR creation can continue once all checks pass.
|
||||
- The **Review** tab is separate from **Comments**: Review shows actionable PR/reviewer feedback and same-task revision controls, while Comments remains the general collaboration thread.
|
||||
- **Request revision** in Review resumes work on the same task ID (no refinement task): `in-progress` tasks get steering injection, while `in-review` tasks are moved back to `in-progress` for the same branch/worktree revision pass.
|
||||
- Review supports a manual **Refresh** action in-place: PR mode pulls latest GitHub review state/decision, while direct mode rehydrates reviewer-agent feedback from persisted task data (no GitHub call).
|
||||
|
||||
@@ -2435,6 +2435,17 @@ export interface ResolvePrConflictsResponse {
|
||||
preflight: PrPreflightResponse;
|
||||
}
|
||||
|
||||
export interface PushPrBranchResult {
|
||||
pushed: boolean;
|
||||
head: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PushPrBranchResponse {
|
||||
result: PushPrBranchResult;
|
||||
preflight: PrPreflightResponse;
|
||||
}
|
||||
|
||||
export interface PrOptionsUser {
|
||||
login: string;
|
||||
name?: string;
|
||||
@@ -2483,6 +2494,14 @@ export function resolvePrConflicts(id: string, base?: string, projectId?: string
|
||||
});
|
||||
}
|
||||
|
||||
/** Push the Create-PR task branch to origin and refresh preflight state */
|
||||
export function pushPrBranch(id: string, base?: string, projectId?: string): Promise<PushPrBranchResponse> {
|
||||
return api<PushPrBranchResponse>(withProjectId(`/tasks/${id}/pr/push-branch`, projectId), {
|
||||
method: "POST",
|
||||
...(base ? { body: JSON.stringify({ base }) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch PR creation options (branches/reviewers/assignees/labels) for a task */
|
||||
export function fetchPrOptions(id: string, projectId?: string): Promise<PrOptionsResponse> {
|
||||
return api<PrOptionsResponse>(withProjectId(`/tasks/${id}/pr/options`, projectId));
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.pr-create-modal__conflict-resolution {
|
||||
.pr-create-modal__preflight-remediation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -276,7 +276,7 @@
|
||||
.pr-create-modal__grid-two,
|
||||
.pr-create-modal__commit-row,
|
||||
.pr-create-modal__file-row,
|
||||
.pr-create-modal__conflict-resolution {
|
||||
.pr-create-modal__preflight-remediation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
fetchPrOptions,
|
||||
fetchPrPreflight,
|
||||
generatePrMetadata,
|
||||
pushPrBranch,
|
||||
resolvePrConflicts,
|
||||
type PrOptionsLabel,
|
||||
type PrOptionsResponse,
|
||||
@@ -139,6 +140,7 @@ export function PrCreateModal({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pushBranchError, setPushBranchError] = useState<string | null>(null);
|
||||
const [resolveConflictError, setResolveConflictError] = useState<string | null>(null);
|
||||
const [lastGhError, setLastGhError] = useState<ModalGhError | null>(null);
|
||||
const [aiTitle, setAiTitle] = useState("");
|
||||
@@ -152,6 +154,7 @@ export function PrCreateModal({
|
||||
const [preflight, setPreflight] = useState<PrPreflightResponse | null>(null);
|
||||
const [baseBranch, setBaseBranch] = useState("");
|
||||
const [draft, setDraft] = useState(false);
|
||||
const [pushingBranch, setPushingBranch] = useState(false);
|
||||
const [resolvingConflicts, setResolvingConflicts] = useState(false);
|
||||
const [reviewers, setReviewers] = useState<PrOptionsUser[]>([]);
|
||||
const [assignees, setAssignees] = useState<PrOptionsUser[]>([]);
|
||||
@@ -163,6 +166,7 @@ export function PrCreateModal({
|
||||
const requestId = ++requestSeqRef.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setPushBranchError(null);
|
||||
setResolveConflictError(null);
|
||||
try {
|
||||
const [metadata, preflightData, optionsData] = await Promise.all([
|
||||
@@ -289,6 +293,7 @@ export function PrCreateModal({
|
||||
|
||||
const handleBaseChange = useCallback(async (nextBase: string) => {
|
||||
setBaseBranch(nextBase);
|
||||
setPushBranchError(null);
|
||||
setResolveConflictError(null);
|
||||
try {
|
||||
const nextPreflight = await fetchPrPreflight(taskId, projectId, nextBase);
|
||||
@@ -298,6 +303,21 @@ export function PrCreateModal({
|
||||
}
|
||||
}, [projectId, taskId]);
|
||||
|
||||
const handlePushBranch = useCallback(async () => {
|
||||
if (!baseBranch || pushingBranch) return;
|
||||
setPushingBranch(true);
|
||||
setPushBranchError(null);
|
||||
try {
|
||||
const response = await pushPrBranch(taskId, baseBranch, projectId);
|
||||
setPreflight(response.preflight);
|
||||
addToast(response.result.message, "success");
|
||||
} catch (pushError) {
|
||||
setPushBranchError(getErrorMessage(pushError));
|
||||
} finally {
|
||||
setPushingBranch(false);
|
||||
}
|
||||
}, [addToast, baseBranch, projectId, pushingBranch, taskId]);
|
||||
|
||||
const handleResolveConflicts = useCallback(async () => {
|
||||
if (!baseBranch || resolvingConflicts) return;
|
||||
setResolvingConflicts(true);
|
||||
@@ -383,8 +403,25 @@ export function PrCreateModal({
|
||||
<button type="button" className="btn btn-sm" onClick={() => void handleBaseChange(baseBranch)}>
|
||||
{t("pr.rerunPreflight", "Re-run preflight")}
|
||||
</button>
|
||||
{!preflight?.branchOnRemote ? (
|
||||
<div className="card pr-create-modal__preflight-remediation">
|
||||
<div className="pr-create-modal__conflict-copy">
|
||||
<p className="pr-create-modal__conflict-title">Push branch to remote</p>
|
||||
<p className="pr-create-modal__conflict-message">Fusion will push this task's branch to origin so the PR can be created.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => void handlePushBranch()}
|
||||
disabled={pushingBranch || loading}
|
||||
>
|
||||
{pushingBranch ? <RefreshCw size={14} className="spin" /> : null}
|
||||
Push branch to remote
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{preflight?.conflictsWithBase ? (
|
||||
<div className="card pr-create-modal__conflict-resolution">
|
||||
<div className="card pr-create-modal__preflight-remediation">
|
||||
<div className="pr-create-modal__conflict-copy">
|
||||
<p className="pr-create-modal__conflict-title">Resolve conflicts with AI</p>
|
||||
<p className="pr-create-modal__conflict-message">Fusion will use AI to resolve conflicts on this branch and push it.</p>
|
||||
@@ -486,6 +523,15 @@ export function PrCreateModal({
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{pushBranchError ? (
|
||||
<div className="form-error pr-error" role="alert">
|
||||
<p>{pushBranchError}</p>
|
||||
<div className="pr-error__actions">
|
||||
<button type="button" className="btn btn-sm pr-error__dismiss" onClick={() => setPushBranchError(null)} aria-label="Dismiss push branch error">×</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{resolveConflictError ? (
|
||||
<div className="form-error pr-error" role="alert">
|
||||
<p>{resolveConflictError}</p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PrCreateModal } from "../PrCreateModal";
|
||||
@@ -9,6 +10,7 @@ const mocks = vi.hoisted(() => ({
|
||||
fetchPrPreflight: vi.fn(),
|
||||
fetchPrOptions: vi.fn(),
|
||||
createPr: vi.fn(),
|
||||
pushPrBranch: vi.fn(),
|
||||
resolvePrConflicts: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -17,6 +19,7 @@ vi.mock("../../api", () => ({
|
||||
fetchPrPreflight: mocks.fetchPrPreflight,
|
||||
fetchPrOptions: mocks.fetchPrOptions,
|
||||
createPr: mocks.createPr,
|
||||
pushPrBranch: mocks.pushPrBranch,
|
||||
resolvePrConflicts: mocks.resolvePrConflicts,
|
||||
}));
|
||||
|
||||
@@ -69,6 +72,7 @@ describe("PrCreateModal", () => {
|
||||
mocks.fetchPrPreflight.mockResolvedValue(preflight);
|
||||
mocks.fetchPrOptions.mockResolvedValue(options);
|
||||
mocks.createPr.mockResolvedValue({ number: 12, title: "AI title", url: "url", status: "open", headBranch: "h", baseBranch: "main", commentCount: 0 } as PrInfo);
|
||||
mocks.pushPrBranch.mockResolvedValue({ result: { pushed: true, head: "fusion/fn-4756", message: "Pushed fusion/fn-4756 to origin." }, preflight });
|
||||
mocks.resolvePrConflicts.mockResolvedValue({ result: { resolved: true, pushed: true, conflictedFiles: ["a.ts"], message: "resolved" }, preflight });
|
||||
});
|
||||
|
||||
@@ -210,6 +214,46 @@ describe("PrCreateModal", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /remove reviewer 1/i }));
|
||||
});
|
||||
|
||||
it("renders push-branch affordance and enables submit after success", async () => {
|
||||
mocks.fetchPrPreflight.mockResolvedValue({ ...preflight, branchOnRemote: false });
|
||||
mocks.pushPrBranch.mockResolvedValueOnce({ result: { pushed: true, head: "fusion/fn-4756", message: "Pushed fusion/fn-4756 to origin." }, preflight });
|
||||
const { addToast } = await renderModalLoaded();
|
||||
|
||||
expect(screen.getByRole("button", { name: "Create PR" })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Push branch to remote" }));
|
||||
|
||||
await waitFor(() => expect(mocks.pushPrBranch).toHaveBeenCalledWith("FN-4756", "main", undefined));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled());
|
||||
expect(addToast).toHaveBeenCalledWith("Pushed fusion/fn-4756 to origin.", "success");
|
||||
});
|
||||
|
||||
it("surfaces push-branch failures", async () => {
|
||||
mocks.fetchPrPreflight.mockResolvedValue({ ...preflight, branchOnRemote: false });
|
||||
mocks.pushPrBranch.mockRejectedValueOnce(new Error("unable to push branch"));
|
||||
await renderModalLoaded();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Push branch to remote" }));
|
||||
|
||||
expect(await screen.findByText("unable to push branch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the push-branch remediation usable on narrow mobile widths", async () => {
|
||||
mocks.fetchPrPreflight.mockResolvedValue({ ...preflight, branchOnRemote: false });
|
||||
const originalInnerWidth = window.innerWidth;
|
||||
|
||||
await act(async () => {
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 375 });
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
|
||||
await renderModalLoaded();
|
||||
|
||||
expect(screen.getByRole("button", { name: "Push branch to remote" })).toBeVisible();
|
||||
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
|
||||
it("renders AI conflict resolution affordance and enables submit after success", async () => {
|
||||
mocks.fetchPrPreflight.mockResolvedValue({ ...preflight, conflictsWithBase: true, branchOnRemote: false });
|
||||
mocks.resolvePrConflicts.mockResolvedValueOnce({ result: { resolved: true, pushed: true, conflictedFiles: ["a.ts"], message: "resolved" }, preflight });
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as fusionCore from "@fusion/core";
|
||||
import type { Task, TaskStore } from "@fusion/core";
|
||||
|
||||
const { mockRunGitCommand } = vi.hoisted(() => ({
|
||||
mockRunGitCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../routes/resolve-diff-base.js", () => ({
|
||||
runGitCommand: mockRunGitCommand,
|
||||
}));
|
||||
|
||||
import { prRouteCommandRunner } from "../routes/register-git-github.js";
|
||||
import { createServer } from "../server.js";
|
||||
import { request as performRequest } from "../test-request.js";
|
||||
|
||||
function createTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-001",
|
||||
title: "Task",
|
||||
description: "desc",
|
||||
column: "in-review",
|
||||
status: "in-review",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
prInfo: {
|
||||
url: "https://github.com/owner/repo/pull/1",
|
||||
number: 1,
|
||||
status: "open",
|
||||
title: "PR",
|
||||
headBranch: "fusion/fn-001",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
},
|
||||
comments: [],
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function createStore(task: Task): TaskStore {
|
||||
return {
|
||||
getTask: vi.fn().mockResolvedValue(task),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({ defaultProvider: "mock", defaultModelId: "scripted" }),
|
||||
updateSettings: vi.fn(),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
addSteeringComment: vi.fn(),
|
||||
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||||
updatePrInfoByNumber: vi.fn().mockResolvedValue(undefined),
|
||||
addPrInfo: vi.fn().mockResolvedValue(undefined),
|
||||
removePrInfoByNumber: vi.fn().mockResolvedValue(undefined),
|
||||
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
|
||||
getRootDir: vi.fn().mockReturnValue("/tmp/project"),
|
||||
getFusionDir: vi.fn().mockReturnValue("/tmp/project/.fusion"),
|
||||
getDatabase: vi.fn().mockReturnValue({
|
||||
exec: vi.fn(),
|
||||
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
|
||||
}),
|
||||
getMissionStore: vi.fn().mockReturnValue({ listMissions: vi.fn().mockReturnValue([]) }),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
type TryRunResult = Awaited<ReturnType<typeof prRouteCommandRunner.tryRun>>;
|
||||
const runQueue: Array<{ ok: true; value: string } | { ok: false; error: Error }> = [];
|
||||
const tryRunQueue: TryRunResult[] = [];
|
||||
|
||||
function queueRunSuccess(value = "") {
|
||||
runQueue.push({ ok: true, value });
|
||||
}
|
||||
|
||||
function queueTryRunSuccess(value = "") {
|
||||
tryRunQueue.push({ ok: true, stdout: value });
|
||||
}
|
||||
|
||||
describe("POST /pr/push-branch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
runQueue.length = 0;
|
||||
tryRunQueue.length = 0;
|
||||
vi.spyOn(fusionCore, "isGhAuthenticated").mockReturnValue(true);
|
||||
vi.spyOn(prRouteCommandRunner, "run").mockImplementation(async () => {
|
||||
const next = runQueue.shift();
|
||||
if (!next) throw new Error("Unexpected run command");
|
||||
if (next.ok) return next.value;
|
||||
throw next.error;
|
||||
});
|
||||
vi.spyOn(prRouteCommandRunner, "tryRun").mockImplementation(async () => {
|
||||
const next = tryRunQueue.shift();
|
||||
if (!next) throw new Error("Unexpected tryRun command");
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("rejects non in-review tasks", async () => {
|
||||
const app = createServer(createStore(createTask({ column: "todo", status: "todo" })));
|
||||
const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/push-branch", JSON.stringify({ base: "main" }), { "content-type": "application/json" });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain("Task must be in 'in-review' column");
|
||||
expect(mockRunGitCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pushes the branch, logs it, and returns recomputed preflight", async () => {
|
||||
mockRunGitCommand
|
||||
.mockResolvedValueOnce("deadbeef\n")
|
||||
.mockResolvedValueOnce("2\n")
|
||||
.mockResolvedValueOnce("");
|
||||
queueTryRunSuccess("deadbeef\n");
|
||||
queueTryRunSuccess("refs/heads/fusion/fn-001\n");
|
||||
queueRunSuccess("2\n");
|
||||
queueRunSuccess("");
|
||||
queueRunSuccess("abc123\tAdd feature\tDev\n");
|
||||
queueRunSuccess("3\t1\tsrc/a.ts\n");
|
||||
queueRunSuccess("M\tsrc/a.ts\n");
|
||||
|
||||
const store = createStore(createTask());
|
||||
const app = createServer(store);
|
||||
const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/push-branch", JSON.stringify({ base: "main" }), { "content-type": "application/json" });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockRunGitCommand).toHaveBeenNthCalledWith(1, ["rev-parse", "--verify", "refs/heads/fusion/fn-001"], "/tmp/project", 10000);
|
||||
expect(mockRunGitCommand).toHaveBeenNthCalledWith(2, ["rev-list", "--count", "main..fusion/fn-001"], "/tmp/project", 10000);
|
||||
expect(mockRunGitCommand).toHaveBeenNthCalledWith(3, ["push", "-u", "origin", "fusion/fn-001"], "/tmp/project", 60000);
|
||||
expect(response.body.result).toEqual({
|
||||
pushed: true,
|
||||
head: "fusion/fn-001",
|
||||
message: "Pushed fusion/fn-001 to origin.",
|
||||
});
|
||||
expect(response.body.preflight.branchOnRemote).toBe(true);
|
||||
expect(response.body.preflight.commitsPresent).toBe(true);
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Pushed PR branch", "fusion/fn-001");
|
||||
});
|
||||
|
||||
it("returns a structured badRequest when the branch has no commits", async () => {
|
||||
mockRunGitCommand
|
||||
.mockResolvedValueOnce("deadbeef\n")
|
||||
.mockResolvedValueOnce("0\n");
|
||||
|
||||
const app = createServer(createStore(createTask()));
|
||||
const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/push-branch", JSON.stringify({ base: "main" }), { "content-type": "application/json" });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain("Branch has no commits");
|
||||
expect(mockRunGitCommand).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("maps git push failures to a structured API error", async () => {
|
||||
mockRunGitCommand
|
||||
.mockResolvedValueOnce("deadbeef\n")
|
||||
.mockResolvedValueOnce("2\n")
|
||||
.mockRejectedValueOnce(new Error("network unreachable"));
|
||||
|
||||
const app = createServer(createStore(createTask()));
|
||||
const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/push-branch", JSON.stringify({ base: "main" }), { "content-type": "application/json" });
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(response.body.error).toContain("network unreachable");
|
||||
expect(response.body.details.githubError.code).toBe("unknown");
|
||||
});
|
||||
});
|
||||
@@ -4690,6 +4690,74 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tasks/:id/pr/push-branch
|
||||
* Push the task branch to origin and return refreshed preflight state.
|
||||
*/
|
||||
router.post("/tasks/:id/pr/push-branch", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const task = await scopedStore.getTask(req.params.id);
|
||||
if (task.column !== "in-review") {
|
||||
throw badRequest("Task must be in 'in-review' column to push PR branch");
|
||||
}
|
||||
|
||||
if (req.body?.base !== undefined && typeof req.body.base !== "string") {
|
||||
throw badRequest("base must be a string when provided");
|
||||
}
|
||||
|
||||
const repoRoot = scopedStore.getRootDir();
|
||||
const requestedBase = typeof req.body?.base === "string" ? req.body.base.trim() : "";
|
||||
const defaultBaseBranch = requestedBase || await resolveDefaultPrBaseBranch(task, repoRoot);
|
||||
const baseBranch = ensureSafeGitRef(defaultBaseBranch, "base branch");
|
||||
const head = ensureSafeGitRef(`fusion/${task.id.toLowerCase()}`, "head branch");
|
||||
const headRef = `refs/heads/${head}`;
|
||||
const baseRef = await resolvePrBaseRef(repoRoot, baseBranch).catch(() => baseBranch);
|
||||
|
||||
try {
|
||||
await runGitCommand(["rev-parse", "--verify", headRef], repoRoot, 10_000);
|
||||
} catch {
|
||||
throw badRequest(`Branch ${head} does not exist locally. Commit changes before creating a PR.`);
|
||||
}
|
||||
|
||||
let commitCount = 0;
|
||||
try {
|
||||
const commitCountOutput = await runGitCommand(["rev-list", "--count", `${baseRef}..${head}`], repoRoot, 10_000);
|
||||
commitCount = Number.parseInt(commitCountOutput, 10);
|
||||
} catch {
|
||||
throw badRequest(`Branch ${head} does not exist locally. Commit changes before creating a PR.`);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(commitCount) || commitCount <= 0) {
|
||||
throw badRequest("Branch has no commits. Push changes before creating PR.");
|
||||
}
|
||||
|
||||
await runGitCommand(["push", "-u", "origin", head], repoRoot, 60_000);
|
||||
await scopedStore.logEntry(task.id, "Pushed PR branch", head);
|
||||
|
||||
const preflight = await computePrPreflight(task, repoRoot, baseBranch);
|
||||
res.json({
|
||||
result: {
|
||||
pushed: true,
|
||||
head,
|
||||
message: `Pushed ${head} to origin.`,
|
||||
},
|
||||
preflight,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
throw notFound(`Task ${req.params.id} not found`);
|
||||
}
|
||||
if ((err instanceof Error ? err.message : String(err)).includes("already exists")) {
|
||||
throw conflict(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
throw toPrApiError(err, "Failed to push PR branch");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tasks/:id/pr/resolve-conflicts
|
||||
* Resolve Create-PR merge conflicts on the task branch, push the branch,
|
||||
|
||||
Reference in New Issue
Block a user