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**.
|
- 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.
|
- 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 `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.
|
- 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.
|
- **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).
|
- 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;
|
preflight: PrPreflightResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PushPrBranchResult {
|
||||||
|
pushed: boolean;
|
||||||
|
head: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushPrBranchResponse {
|
||||||
|
result: PushPrBranchResult;
|
||||||
|
preflight: PrPreflightResponse;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PrOptionsUser {
|
export interface PrOptionsUser {
|
||||||
login: string;
|
login: string;
|
||||||
name?: 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 */
|
/** Fetch PR creation options (branches/reviewers/assignees/labels) for a task */
|
||||||
export function fetchPrOptions(id: string, projectId?: string): Promise<PrOptionsResponse> {
|
export function fetchPrOptions(id: string, projectId?: string): Promise<PrOptionsResponse> {
|
||||||
return api<PrOptionsResponse>(withProjectId(`/tasks/${id}/pr/options`, projectId));
|
return api<PrOptionsResponse>(withProjectId(`/tasks/${id}/pr/options`, projectId));
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pr-create-modal__conflict-resolution {
|
.pr-create-modal__preflight-remediation {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -276,7 +276,7 @@
|
|||||||
.pr-create-modal__grid-two,
|
.pr-create-modal__grid-two,
|
||||||
.pr-create-modal__commit-row,
|
.pr-create-modal__commit-row,
|
||||||
.pr-create-modal__file-row,
|
.pr-create-modal__file-row,
|
||||||
.pr-create-modal__conflict-resolution {
|
.pr-create-modal__preflight-remediation {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
fetchPrOptions,
|
fetchPrOptions,
|
||||||
fetchPrPreflight,
|
fetchPrPreflight,
|
||||||
generatePrMetadata,
|
generatePrMetadata,
|
||||||
|
pushPrBranch,
|
||||||
resolvePrConflicts,
|
resolvePrConflicts,
|
||||||
type PrOptionsLabel,
|
type PrOptionsLabel,
|
||||||
type PrOptionsResponse,
|
type PrOptionsResponse,
|
||||||
@@ -139,6 +140,7 @@ export function PrCreateModal({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pushBranchError, setPushBranchError] = useState<string | null>(null);
|
||||||
const [resolveConflictError, setResolveConflictError] = useState<string | null>(null);
|
const [resolveConflictError, setResolveConflictError] = useState<string | null>(null);
|
||||||
const [lastGhError, setLastGhError] = useState<ModalGhError | null>(null);
|
const [lastGhError, setLastGhError] = useState<ModalGhError | null>(null);
|
||||||
const [aiTitle, setAiTitle] = useState("");
|
const [aiTitle, setAiTitle] = useState("");
|
||||||
@@ -152,6 +154,7 @@ export function PrCreateModal({
|
|||||||
const [preflight, setPreflight] = useState<PrPreflightResponse | null>(null);
|
const [preflight, setPreflight] = useState<PrPreflightResponse | null>(null);
|
||||||
const [baseBranch, setBaseBranch] = useState("");
|
const [baseBranch, setBaseBranch] = useState("");
|
||||||
const [draft, setDraft] = useState(false);
|
const [draft, setDraft] = useState(false);
|
||||||
|
const [pushingBranch, setPushingBranch] = useState(false);
|
||||||
const [resolvingConflicts, setResolvingConflicts] = useState(false);
|
const [resolvingConflicts, setResolvingConflicts] = useState(false);
|
||||||
const [reviewers, setReviewers] = useState<PrOptionsUser[]>([]);
|
const [reviewers, setReviewers] = useState<PrOptionsUser[]>([]);
|
||||||
const [assignees, setAssignees] = useState<PrOptionsUser[]>([]);
|
const [assignees, setAssignees] = useState<PrOptionsUser[]>([]);
|
||||||
@@ -163,6 +166,7 @@ export function PrCreateModal({
|
|||||||
const requestId = ++requestSeqRef.current;
|
const requestId = ++requestSeqRef.current;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setPushBranchError(null);
|
||||||
setResolveConflictError(null);
|
setResolveConflictError(null);
|
||||||
try {
|
try {
|
||||||
const [metadata, preflightData, optionsData] = await Promise.all([
|
const [metadata, preflightData, optionsData] = await Promise.all([
|
||||||
@@ -289,6 +293,7 @@ export function PrCreateModal({
|
|||||||
|
|
||||||
const handleBaseChange = useCallback(async (nextBase: string) => {
|
const handleBaseChange = useCallback(async (nextBase: string) => {
|
||||||
setBaseBranch(nextBase);
|
setBaseBranch(nextBase);
|
||||||
|
setPushBranchError(null);
|
||||||
setResolveConflictError(null);
|
setResolveConflictError(null);
|
||||||
try {
|
try {
|
||||||
const nextPreflight = await fetchPrPreflight(taskId, projectId, nextBase);
|
const nextPreflight = await fetchPrPreflight(taskId, projectId, nextBase);
|
||||||
@@ -298,6 +303,21 @@ export function PrCreateModal({
|
|||||||
}
|
}
|
||||||
}, [projectId, taskId]);
|
}, [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 () => {
|
const handleResolveConflicts = useCallback(async () => {
|
||||||
if (!baseBranch || resolvingConflicts) return;
|
if (!baseBranch || resolvingConflicts) return;
|
||||||
setResolvingConflicts(true);
|
setResolvingConflicts(true);
|
||||||
@@ -383,8 +403,25 @@ export function PrCreateModal({
|
|||||||
<button type="button" className="btn btn-sm" onClick={() => void handleBaseChange(baseBranch)}>
|
<button type="button" className="btn btn-sm" onClick={() => void handleBaseChange(baseBranch)}>
|
||||||
{t("pr.rerunPreflight", "Re-run preflight")}
|
{t("pr.rerunPreflight", "Re-run preflight")}
|
||||||
</button>
|
</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 ? (
|
{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">
|
<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-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>
|
<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>
|
</div>
|
||||||
</details>
|
</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 ? (
|
{resolveConflictError ? (
|
||||||
<div className="form-error pr-error" role="alert">
|
<div className="form-error pr-error" role="alert">
|
||||||
<p>{resolveConflictError}</p>
|
<p>{resolveConflictError}</p>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { act } from "react";
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { PrCreateModal } from "../PrCreateModal";
|
import { PrCreateModal } from "../PrCreateModal";
|
||||||
@@ -9,6 +10,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
fetchPrPreflight: vi.fn(),
|
fetchPrPreflight: vi.fn(),
|
||||||
fetchPrOptions: vi.fn(),
|
fetchPrOptions: vi.fn(),
|
||||||
createPr: vi.fn(),
|
createPr: vi.fn(),
|
||||||
|
pushPrBranch: vi.fn(),
|
||||||
resolvePrConflicts: vi.fn(),
|
resolvePrConflicts: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ vi.mock("../../api", () => ({
|
|||||||
fetchPrPreflight: mocks.fetchPrPreflight,
|
fetchPrPreflight: mocks.fetchPrPreflight,
|
||||||
fetchPrOptions: mocks.fetchPrOptions,
|
fetchPrOptions: mocks.fetchPrOptions,
|
||||||
createPr: mocks.createPr,
|
createPr: mocks.createPr,
|
||||||
|
pushPrBranch: mocks.pushPrBranch,
|
||||||
resolvePrConflicts: mocks.resolvePrConflicts,
|
resolvePrConflicts: mocks.resolvePrConflicts,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -69,6 +72,7 @@ describe("PrCreateModal", () => {
|
|||||||
mocks.fetchPrPreflight.mockResolvedValue(preflight);
|
mocks.fetchPrPreflight.mockResolvedValue(preflight);
|
||||||
mocks.fetchPrOptions.mockResolvedValue(options);
|
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.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 });
|
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 }));
|
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 () => {
|
it("renders AI conflict resolution affordance and enables submit after success", async () => {
|
||||||
mocks.fetchPrPreflight.mockResolvedValue({ ...preflight, conflictsWithBase: true, branchOnRemote: false });
|
mocks.fetchPrPreflight.mockResolvedValue({ ...preflight, conflictsWithBase: true, branchOnRemote: false });
|
||||||
mocks.resolvePrConflicts.mockResolvedValueOnce({ result: { resolved: true, pushed: true, conflictedFiles: ["a.ts"], message: "resolved" }, preflight });
|
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
|
* POST /api/tasks/:id/pr/resolve-conflicts
|
||||||
* Resolve Create-PR merge conflicts on the task branch, push the branch,
|
* Resolve Create-PR merge conflicts on the task branch, push the branch,
|
||||||
|
|||||||
Reference in New Issue
Block a user