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:
gsxdsm
2026-06-03 16:03:03 -07:00
parent 12b418a524
commit 8891d4b90a
8 changed files with 362 additions and 4 deletions

View 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.

View File

@@ -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).

View File

@@ -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));

View File

@@ -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;

View File

@@ -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&apos;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>

View File

@@ -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 });

View File

@@ -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");
});
});

View File

@@ -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,