FN-5949: add AI PR conflict resolution to Create PR flow

Add dashboard support for resolving task-branch PR conflicts with AI before PR creation.

- add a dashboard PR conflict resolver that merges the selected base into the task branch, invokes an AI merge session on conflicted files, verifies markers are removed, then commits and pushes the updated branch
- expose a POST /tasks/:id/pr/resolve-conflicts route plus client API/export wiring, docs updates, and a changeset for the published CLI package
- update the Create PR modal UI, styling, and tests to surface preflight conflicts, trigger AI resolution, and refresh preflight state after success
- add API coverage for successful and unresolved conflict-resolution paths and include the new route test in the dashboard API quality shard

Files changed:
 .changeset/fn-5949-pr-conflict-resolution.md       |   5 +
 docs/dashboard-guide.md                            |   1 +
 docs/task-management.md                            |   1 +
 packages/dashboard/README.md                       |   1 +
 packages/dashboard/app/api/legacy.ts               |  20 ++
 .../dashboard/app/components/PrCreateModal.css     |  32 ++-
 .../dashboard/app/components/PrCreateModal.tsx     |  46 ++++
 .../components/__tests__/PrCreateModal.test.tsx    |  33 +++
 ...egister-git-github.pr-resolve-conflicts.test.ts | 186 +++++++++++++++
 packages/dashboard/src/index.ts                    |   5 +
 packages/dashboard/src/pr-conflict-resolver.ts     | 258 +++++++++++++++++++++
 .../dashboard/src/routes/register-git-github.ts    | 225 ++++++++++++------
 packages/dashboard/vitest.config.ts                |   2 +-
 13 files changed, 739 insertions(+), 76 deletions(-)

Fusion-Task-Id: FN-5949

Fusion-Task-Lineage: fea35fbf-6254-415c-83cc-0bc24abc911e
This commit is contained in:
gsxdsm
2026-06-03 15:11:07 -07:00
parent de3273eba3
commit 8aed4da764
13 changed files with 739 additions and 76 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add AI-assisted conflict resolution to the dashboard Create PR flow so users can resolve task-branch merge conflicts against the selected base branch, push the updated branch, and continue PR creation without leaving Fusion.

View File

@@ -613,6 +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 **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).

View File

@@ -688,6 +688,7 @@ Manual/non-auto-merge behavior:
- `Finish & Close` (PR already merged) - `Finish & Close` (PR already merged)
- Manual PR creation first checks for an existing PR on that branch and links it when found. - Manual PR creation first checks for an existing PR on that branch and links it when found.
- If no PR exists, Fusion pushes the task branch to `origin` before creating the PR. - If no PR exists, Fusion pushes the task branch to `origin` before creating the PR.
- In the dashboard Create-PR modal, if preflight detects merge conflicts with the selected base branch, you can choose **Resolve conflicts with AI**. Fusion resolves the task branch in-place, commits the result, pushes the updated branch to `origin`, and then lets you retry PR creation.
- When buffered actionable PR feedback exists on a PR that is already merged/closed and the task leaves `in-review`, Fusion creates a dependency-linked follow-up task in `triage` so feedback is not stranded. - When buffered actionable PR feedback exists on a PR that is already merged/closed and the task leaves `in-review`, Fusion creates a dependency-linked follow-up task in `triage` so feedback is not stranded.
## GitHub Tracking Issues ## GitHub Tracking Issues

View File

@@ -734,6 +734,7 @@ The dashboard server exposes a REST API at `/api`:
- `POST /api/github/issues/import` - Import issue (`{ owner, repo, issueNumber }`) - `POST /api/github/issues/import` - Import issue (`{ owner, repo, issueNumber }`)
- `POST /api/github/webhooks` - GitHub App webhook endpoint for badge updates (see GitHub App Setup below) - `POST /api/github/webhooks` - GitHub App webhook endpoint for badge updates (see GitHub App Setup below)
- `POST /api/tasks/:id/pr/create` - Create PR - `POST /api/tasks/:id/pr/create` - Create PR
- `POST /api/tasks/:id/pr/resolve-conflicts` - Resolve Create-PR merge conflicts with AI and push the task branch
- `GET /api/tasks/:id/pr/status` - Get PR status (5-min staleness, auto background refresh) - `GET /api/tasks/:id/pr/status` - Get PR status (5-min staleness, auto background refresh)
- `POST /api/tasks/:id/pr/refresh` - Force refresh PR status - `POST /api/tasks/:id/pr/refresh` - Force refresh PR status
- `GET /api/tasks/:id/issue/status` - Get cached issue status (5-min staleness, auto background refresh) - `GET /api/tasks/:id/issue/status` - Get cached issue status (5-min staleness, auto background refresh)

View File

@@ -2416,6 +2416,18 @@ export interface PrPreflightResponse {
changedFiles: PrPreflightChangedFile[]; changedFiles: PrPreflightChangedFile[];
} }
export interface ResolvePrConflictsResult {
resolved: boolean;
pushed: boolean;
conflictedFiles: string[];
message: string;
}
export interface ResolvePrConflictsResponse {
result: ResolvePrConflictsResult;
preflight: PrPreflightResponse;
}
export interface PrOptionsUser { export interface PrOptionsUser {
login: string; login: string;
name?: string; name?: string;
@@ -2456,6 +2468,14 @@ export function fetchPrPreflight(id: string, projectId?: string, base?: string):
return api<PrPreflightResponse>(withProjectId(`/tasks/${id}/pr/preflight${baseParam}`, projectId)); return api<PrPreflightResponse>(withProjectId(`/tasks/${id}/pr/preflight${baseParam}`, projectId));
} }
/** Ask Fusion to resolve Create-PR merge conflicts for a task branch */
export function resolvePrConflicts(id: string, base?: string, projectId?: string): Promise<ResolvePrConflictsResponse> {
return api<ResolvePrConflictsResponse>(withProjectId(`/tasks/${id}/pr/resolve-conflicts`, 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));

View File

@@ -80,6 +80,35 @@
gap: var(--space-sm); gap: var(--space-sm);
} }
.pr-create-modal__conflict-resolution {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-md);
border: var(--btn-border-width) solid color-mix(in srgb, var(--color-warning) 35%, transparent);
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
}
.pr-create-modal__conflict-copy {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.pr-create-modal__conflict-title,
.pr-create-modal__conflict-message {
margin: 0;
}
.pr-create-modal__conflict-title {
font-weight: 600;
}
.pr-create-modal__conflict-message {
color: var(--text-muted);
}
.pr-create-modal__label { .pr-create-modal__label {
font-size: 0.75rem; font-size: 0.75rem;
text-transform: uppercase; text-transform: uppercase;
@@ -246,7 +275,8 @@
.pr-create-modal__title-row, .pr-create-modal__title-row,
.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 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;

View File

@@ -7,6 +7,7 @@ import {
fetchPrOptions, fetchPrOptions,
fetchPrPreflight, fetchPrPreflight,
generatePrMetadata, generatePrMetadata,
resolvePrConflicts,
type PrOptionsLabel, type PrOptionsLabel,
type PrOptionsResponse, type PrOptionsResponse,
type PrOptionsUser, type PrOptionsUser,
@@ -135,6 +136,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 [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("");
const [aiBody, setAiBody] = useState(""); const [aiBody, setAiBody] = useState("");
@@ -147,6 +149,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 [resolvingConflicts, setResolvingConflicts] = useState(false);
const [reviewers, setReviewers] = useState<PrOptionsUser[]>([]); const [reviewers, setReviewers] = useState<PrOptionsUser[]>([]);
const [assignees, setAssignees] = useState<PrOptionsUser[]>([]); const [assignees, setAssignees] = useState<PrOptionsUser[]>([]);
const [labels, setLabels] = useState<PrOptionsLabel[]>([]); const [labels, setLabels] = useState<PrOptionsLabel[]>([]);
@@ -157,6 +160,7 @@ export function PrCreateModal({
const requestId = ++requestSeqRef.current; const requestId = ++requestSeqRef.current;
setLoading(true); setLoading(true);
setError(null); setError(null);
setResolveConflictError(null);
try { try {
const [metadata, preflightData, optionsData] = await Promise.all([ const [metadata, preflightData, optionsData] = await Promise.all([
generatePrMetadata(taskId, projectId), generatePrMetadata(taskId, projectId),
@@ -282,6 +286,7 @@ export function PrCreateModal({
const handleBaseChange = useCallback(async (nextBase: string) => { const handleBaseChange = useCallback(async (nextBase: string) => {
setBaseBranch(nextBase); setBaseBranch(nextBase);
setResolveConflictError(null);
try { try {
const nextPreflight = await fetchPrPreflight(taskId, projectId, nextBase); const nextPreflight = await fetchPrPreflight(taskId, projectId, nextBase);
setPreflight(nextPreflight); setPreflight(nextPreflight);
@@ -290,6 +295,21 @@ export function PrCreateModal({
} }
}, [projectId, taskId]); }, [projectId, taskId]);
const handleResolveConflicts = useCallback(async () => {
if (!baseBranch || resolvingConflicts) return;
setResolvingConflicts(true);
setResolveConflictError(null);
try {
const response = await resolvePrConflicts(taskId, baseBranch, projectId);
setPreflight(response.preflight);
addToast("Resolved PR conflicts and pushed branch", "success");
} catch (resolveError) {
setResolveConflictError(getErrorMessage(resolveError));
} finally {
setResolvingConflicts(false);
}
}, [addToast, baseBranch, projectId, resolvingConflicts, taskId]);
const payload = useMemo(() => ({ const payload = useMemo(() => ({
title: title.trim(), title: title.trim(),
body: body.trim(), body: body.trim(),
@@ -360,6 +380,23 @@ 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)}>
Re-run preflight Re-run preflight
</button> </button>
{preflight?.conflictsWithBase ? (
<div className="card pr-create-modal__conflict-resolution">
<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>
</div>
<button
type="button"
className="btn btn-sm"
onClick={() => void handleResolveConflicts()}
disabled={resolvingConflicts || loading}
>
{resolvingConflicts ? <RefreshCw size={14} className="spin" /> : null}
Resolve conflicts with AI
</button>
</div>
) : null}
</section> </section>
<section className="pr-create-modal__section"> <section className="pr-create-modal__section">
@@ -446,6 +483,15 @@ export function PrCreateModal({
</div> </div>
</details> </details>
{resolveConflictError ? (
<div className="form-error pr-error" role="alert">
<p>{resolveConflictError}</p>
<div className="pr-error__actions">
<button type="button" className="btn btn-sm pr-error__dismiss" onClick={() => setResolveConflictError(null)} aria-label="Dismiss conflict resolution error">×</button>
</div>
</div>
) : null}
{error && ( {error && (
<div className="form-error pr-error" role="alert"> <div className="form-error pr-error" role="alert">
<p>{error}</p> <p>{error}</p>

View File

@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
fetchPrPreflight: vi.fn(), fetchPrPreflight: vi.fn(),
fetchPrOptions: vi.fn(), fetchPrOptions: vi.fn(),
createPr: vi.fn(), createPr: vi.fn(),
resolvePrConflicts: vi.fn(),
})); }));
vi.mock("../../api", () => ({ vi.mock("../../api", () => ({
@@ -16,6 +17,7 @@ vi.mock("../../api", () => ({
fetchPrPreflight: mocks.fetchPrPreflight, fetchPrPreflight: mocks.fetchPrPreflight,
fetchPrOptions: mocks.fetchPrOptions, fetchPrOptions: mocks.fetchPrOptions,
createPr: mocks.createPr, createPr: mocks.createPr,
resolvePrConflicts: mocks.resolvePrConflicts,
})); }));
const metadata = { title: "AI title", body: "## Summary\n\n## Changes\n\n## Testing\n\n## Linked Task\n", templateUsed: true }; const metadata = { title: "AI title", body: "## Summary\n\n## Changes\n\n## Testing\n\n## Linked Task\n", templateUsed: true };
@@ -67,6 +69,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.resolvePrConflicts.mockResolvedValue({ result: { resolved: true, pushed: true, conflictedFiles: ["a.ts"], message: "resolved" }, preflight });
}); });
it("renders nothing when closed", () => { it("renders nothing when closed", () => {
@@ -207,11 +210,39 @@ describe("PrCreateModal", () => {
fireEvent.click(screen.getByRole("button", { name: /remove reviewer 1/i })); fireEvent.click(screen.getByRole("button", { name: /remove reviewer 1/i }));
}); });
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 });
const { addToast } = await renderModalLoaded();
const submitButton = screen.getByRole("button", { name: "Create PR" });
expect(submitButton).toBeDisabled();
const resolveButton = await screen.findByRole("button", { name: "Resolve conflicts with AI" });
fireEvent.click(resolveButton);
await waitFor(() => expect(mocks.resolvePrConflicts).toHaveBeenCalledWith("FN-4756", "main", undefined));
await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled());
expect(addToast).toHaveBeenCalledWith("Resolved PR conflicts and pushed branch", "success");
});
it("surfaces conflict resolution failures", async () => {
mocks.fetchPrPreflight.mockResolvedValue({ ...preflight, conflictsWithBase: true });
mocks.resolvePrConflicts.mockRejectedValueOnce(new Error("unable to resolve"));
await renderModalLoaded();
fireEvent.click(await screen.findByRole("button", { name: "Resolve conflicts with AI" }));
expect(await screen.findByText("unable to resolve")).toBeInTheDocument();
});
it("shows submit error and retries with same payload", async () => { it("shows submit error and retries with same payload", async () => {
mocks.createPr.mockRejectedValueOnce(new Error("bad")).mockResolvedValueOnce({ number: 22, title: "ok", url: "u", status: "open", headBranch: "h", baseBranch: "main", commentCount: 0 } as PrInfo); mocks.createPr.mockRejectedValueOnce(new Error("bad")).mockResolvedValueOnce({ number: 22, title: "ok", url: "u", status: "open", headBranch: "h", baseBranch: "main", commentCount: 0 } as PrInfo);
await renderModalLoaded(); await renderModalLoaded();
await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "Create PR" })); fireEvent.click(screen.getByRole("button", { name: "Create PR" }));
await waitFor(() => expect(mocks.createPr).toHaveBeenCalledTimes(1));
expect(await screen.findByText("bad")).toBeInTheDocument(); expect(await screen.findByText("bad")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Retry" })); fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => expect(mocks.createPr).toHaveBeenCalledTimes(2)); await waitFor(() => expect(mocks.createPr).toHaveBeenCalledTimes(2));
@@ -232,7 +263,9 @@ describe("PrCreateModal", () => {
}); });
mocks.createPr.mockRejectedValueOnce(err); mocks.createPr.mockRejectedValueOnce(err);
await renderModalLoaded(); await renderModalLoaded();
await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "Create PR" })); fireEvent.click(screen.getByRole("button", { name: "Create PR" }));
await waitFor(() => expect(mocks.createPr).toHaveBeenCalledTimes(1));
expect((await screen.findAllByText(/gh auth login/i)).length).toBeGreaterThan(0); expect((await screen.findAllByText(/gh auth login/i)).length).toBeGreaterThan(0);
}); });

View File

@@ -0,0 +1,186 @@
// @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 { mockResolvePrConflicts } = vi.hoisted(() => ({
mockResolvePrConflicts: vi.fn(),
}));
vi.mock("../pr-conflict-resolver.js", () => ({
resolvePrConflicts: mockResolvePrConflicts,
}));
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/resolve-conflicts", () => {
const originalRepoEnv = process.env.GITHUB_REPOSITORY;
beforeEach(() => {
vi.clearAllMocks();
runQueue.length = 0;
tryRunQueue.length = 0;
process.env.GITHUB_REPOSITORY = "owner/repo";
vi.spyOn(fusionCore, "getCurrentRepo").mockReturnValue({ owner: "owner", repo: "repo" });
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();
if (originalRepoEnv === undefined) {
delete process.env.GITHUB_REPOSITORY;
} else {
process.env.GITHUB_REPOSITORY = originalRepoEnv;
}
});
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/resolve-conflicts", 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(mockResolvePrConflicts).not.toHaveBeenCalled();
});
it("returns updated preflight after successful resolution and logs the push path", async () => {
queueTryRunSuccess("main"); // resolvePrBaseRef local base check
queueTryRunSuccess("main"); // computePrPreflight base check
queueTryRunSuccess("refs/heads/fusion/fn-001\n"); // remote branch exists
queueRunSuccess("2\n"); // git rev-list --count
queueRunSuccess(""); // git merge-tree --write-tree --name-only
queueRunSuccess("abc123\tResolve conflicts\tDev\n"); // git log
queueRunSuccess("3\t1\tsrc/a.ts\n"); // git diff --numstat
queueRunSuccess("M\tsrc/a.ts\n"); // git diff --name-status
mockResolvePrConflicts.mockResolvedValue({
resolved: true,
pushed: true,
conflictedFiles: ["src/a.ts"],
message: "Resolved conflicts and pushed branch.",
});
const store = createStore(createTask());
const app = createServer(store);
const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/resolve-conflicts", JSON.stringify({ base: "main" }), { "content-type": "application/json" });
expect(response.status).toBe(200);
expect(mockResolvePrConflicts).toHaveBeenCalledWith(expect.objectContaining({
taskId: "FN-001",
baseRef: "main",
rootDir: "/tmp/project",
}));
expect(response.body.result).toMatchObject({ resolved: true, pushed: true });
expect(response.body.preflight.conflictsWithBase).toBe(false);
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "AI resolved PR conflicts", expect.stringContaining("fusion/fn-001"));
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Pushed branch after PR conflict resolution", "fusion/fn-001");
expect(tryRunQueue).toHaveLength(0);
expect(runQueue).toHaveLength(0);
});
it("returns a structured retryable error when markers remain unresolved", async () => {
queueTryRunSuccess("main"); // resolvePrBaseRef local base check
mockResolvePrConflicts.mockResolvedValue({
resolved: false,
pushed: false,
conflictedFiles: ["src/conflicted.ts"],
message: "AI conflict resolution left unresolved markers in 1 file(s).",
});
const app = createServer(createStore(createTask()));
const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/resolve-conflicts", JSON.stringify({ base: "main" }), { "content-type": "application/json" });
expect(response.status).toBe(409);
expect(response.body.error).toContain("unresolved markers");
expect(response.body.details).toMatchObject({
code: "conflict-resolution-failed",
retryable: true,
unresolvedFiles: ["src/conflicted.ts"],
head: "fusion/fn-001",
base: "main",
});
expect(tryRunQueue).toHaveLength(0);
expect(runQueue).toHaveLength(0);
});
});

View File

@@ -13,6 +13,11 @@ export {
export { createSkillsAdapter, getProjectSettingsPath, type SkillsAdapter, type DiscoveredSkill, type CatalogEntry, type CatalogFetchResult, type ToggleSkillResult, type UpstreamError, type UpstreamErrorCode, type SkillContent, type SkillFileEntry } from "./skills-adapter.js"; export { createSkillsAdapter, getProjectSettingsPath, type SkillsAdapter, type DiscoveredSkill, type CatalogEntry, type CatalogFetchResult, type ToggleSkillResult, type UpstreamError, type UpstreamErrorCode, type SkillContent, type SkillFileEntry } from "./skills-adapter.js";
export { GitHubClient, isPrMergeReady, type GitHubClientOptions, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type FindPrParams, type CreateIssueParams, type CreatedIssue } from "./github.js"; export { GitHubClient, isPrMergeReady, type GitHubClientOptions, type PrMergeStatus, type PrCheckStatus, type ReviewDecision, type MergePrParams, type FindPrParams, type CreateIssueParams, type CreatedIssue } from "./github.js";
export { generatePrMetadata, type GeneratedPrMetadata } from "./pr-metadata-generator.js"; export { generatePrMetadata, type GeneratedPrMetadata } from "./pr-metadata-generator.js";
export {
resolvePrConflicts,
type ResolvePrConflictsInput,
type ResolvePrConflictsResult,
} from "./pr-conflict-resolver.js";
export { maybeCreateTrackingIssue, type MaybeCreateTrackingIssueDeps } from "./github-tracking.js"; export { maybeCreateTrackingIssue, type MaybeCreateTrackingIssueDeps } from "./github-tracking.js";
export { export {
buildIssueSearchQueries, buildIssueSearchQueries,

View File

@@ -0,0 +1,258 @@
import { access, mkdir, readFile, rm } from "node:fs/promises";
import { join, resolve } from "node:path";
import type { Settings, TaskStore } from "@fusion/core";
import { createResolvedAgentSession } from "@fusion/engine";
import { runGitCommand } from "./routes/resolve-diff-base.js";
const GIT_TIMEOUT_MS = 60_000;
const SESSION_PROMPT = [
"You are resolving merge conflicts for a Fusion task branch before GitHub PR creation.",
"Edit only the conflicted files in this worktree.",
"Remove every conflict marker (`<<<<<<<`, `=======`, `>>>>>>>`) and produce a coherent merged result.",
"Preserve the task branch intent while integrating the selected base branch changes.",
"Do NOT run git commands, do NOT create commits, and do NOT push.",
"When you finish, every conflicted file must be saved without conflict markers.",
].join("\n");
export interface ResolvePrConflictsInput {
taskId: string;
baseRef: string;
rootDir: string;
store: TaskStore;
settings: Settings;
}
export interface ResolvePrConflictsResult {
resolved: boolean;
pushed: boolean;
conflictedFiles: string[];
message: string;
}
function getHeadBranch(taskId: string): string {
return `fusion/${taskId.toLowerCase()}`;
}
function getDefaultSessionModel(settings: Settings): { provider: string | undefined; modelId: string | undefined } {
if (settings.defaultProviderOverride && settings.defaultModelIdOverride) {
return {
provider: settings.defaultProviderOverride,
modelId: settings.defaultModelIdOverride,
};
}
return {
provider: settings.defaultProvider,
modelId: settings.defaultModelId,
};
}
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
async function resolveUsableWorktree(candidatePath: string | undefined, branchName: string): Promise<string | null> {
if (!candidatePath) {
return null;
}
const absolutePath = resolve(candidatePath);
if (!await pathExists(absolutePath)) {
return null;
}
try {
const currentBranch = (await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], absolutePath, GIT_TIMEOUT_MS)).trim();
if (currentBranch === branchName) {
return absolutePath;
}
} catch {
return null;
}
return null;
}
async function listConflictedFiles(cwd: string): Promise<string[]> {
const output = await runGitCommand(["diff", "--name-only", "--diff-filter=U"], cwd, GIT_TIMEOUT_MS).catch(() => "");
return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
}
async function findFilesWithConflictMarkers(rootDir: string, files: string[]): Promise<string[]> {
const conflicted: string[] = [];
for (const file of files) {
try {
const contents = await readFile(join(rootDir, file), "utf8");
if (/^<<<<<<< /m.test(contents) || /^=======$/m.test(contents) || /^>>>>>>> /m.test(contents)) {
conflicted.push(file);
}
} catch {
// Best-effort verification.
}
}
return conflicted;
}
async function abortMerge(cwd: string): Promise<void> {
try {
await runGitCommand(["merge", "--abort"], cwd, GIT_TIMEOUT_MS);
return;
} catch {
// fall through
}
try {
await runGitCommand(["reset", "--merge"], cwd, GIT_TIMEOUT_MS);
} catch {
// best-effort cleanup
}
}
async function runResolutionAgent(params: {
cwd: string;
taskId: string;
conflictedFiles: string[];
settings: Settings;
}): Promise<void> {
const { cwd, taskId, conflictedFiles, settings } = params;
const sessionModel = getDefaultSessionModel(settings);
const { session } = await createResolvedAgentSession({
cwd,
systemPrompt: SESSION_PROMPT,
tools: "coding",
sessionPurpose: "merger",
defaultProvider: sessionModel.provider,
defaultModelId: sessionModel.modelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
settings,
});
try {
await session.prompt([
`Resolve Create-PR merge conflicts for task ${taskId}.`,
"",
"Conflicted files:",
...conflictedFiles.map((file) => `- ${file}`),
"",
"Instructions:",
"1. Read each conflicted file in the current worktree.",
"2. Edit only the listed files to remove all conflict markers.",
"3. Keep the branch in a coherent post-merge state.",
"4. Do not run git commands, do not commit, and do not push.",
].join("\n"));
} finally {
try {
session.dispose();
} catch {
// ignore dispose failures
}
}
}
export async function resolvePrConflicts(input: ResolvePrConflictsInput): Promise<ResolvePrConflictsResult> {
const { taskId, baseRef, rootDir, store } = input;
const task = await store.getTask(taskId);
const branchName = getHeadBranch(taskId);
const reusableWorktree = await resolveUsableWorktree(task.worktree, branchName);
const tempWorktreePath = join(rootDir, ".fusion", "worktrees", `conflict-${taskId.toLowerCase()}`);
const cwd = reusableWorktree ?? tempWorktreePath;
const createdTemporaryWorktree = !reusableWorktree;
if (createdTemporaryWorktree) {
await mkdir(join(rootDir, ".fusion", "worktrees"), { recursive: true });
await rm(tempWorktreePath, { recursive: true, force: true });
await runGitCommand(["worktree", "add", "--force", tempWorktreePath, branchName], rootDir, GIT_TIMEOUT_MS);
}
try {
try {
await runGitCommand(["checkout", branchName], cwd, GIT_TIMEOUT_MS);
await runGitCommand(["merge", "--no-commit", "--no-ff", baseRef], cwd, GIT_TIMEOUT_MS);
} catch (error) {
const conflictedFiles = await listConflictedFiles(cwd);
if (conflictedFiles.length === 0) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to merge ${baseRef} into ${branchName}: ${message}`);
}
await store.logEntry(taskId, "Started AI PR conflict resolution", `${conflictedFiles.length} conflicted file(s)`);
try {
await runResolutionAgent({
cwd,
taskId,
conflictedFiles,
settings: input.settings,
});
const unresolvedFiles = await findFilesWithConflictMarkers(cwd, conflictedFiles);
if (unresolvedFiles.length > 0) {
await abortMerge(cwd);
await store.logEntry(
taskId,
"AI PR conflict resolution left unresolved markers",
`Merge aborted. Worktree may still contain partial AI edits for manual review: ${unresolvedFiles.join(", ")}`,
);
return {
resolved: false,
pushed: false,
conflictedFiles: unresolvedFiles,
message: `AI conflict resolution left unresolved markers in ${unresolvedFiles.length} file(s).`,
};
}
await store.logEntry(taskId, "AI PR conflict resolution completed", `${conflictedFiles.length} conflicted file(s) resolved`);
await runGitCommand(["add", "-A"], cwd, GIT_TIMEOUT_MS);
await runGitCommand([
"commit",
"-m",
`fix(FN-5949): resolve PR conflicts for ${taskId}`,
"-m",
`Fusion-Task-Id: ${taskId}`,
], cwd, GIT_TIMEOUT_MS);
await runGitCommand(["push", "-u", "origin", branchName], cwd, GIT_TIMEOUT_MS);
await store.logEntry(taskId, "Pushed PR branch after AI conflict resolution", branchName);
return {
resolved: true,
pushed: true,
conflictedFiles,
message: `Resolved conflicts with ${baseRef} and pushed ${branchName}.`,
};
} catch (resolutionError) {
await abortMerge(cwd);
throw resolutionError;
}
}
await runGitCommand(["add", "-A"], cwd, GIT_TIMEOUT_MS);
await runGitCommand([
"commit",
"-m",
`fix(FN-5949): merge ${baseRef} into ${taskId}`,
"-m",
`Fusion-Task-Id: ${taskId}`,
], cwd, GIT_TIMEOUT_MS);
await runGitCommand(["push", "-u", "origin", branchName], cwd, GIT_TIMEOUT_MS);
await store.logEntry(taskId, "Pushed PR branch after conflict-free merge", branchName);
return {
resolved: true,
pushed: true,
conflictedFiles: [],
message: `Merged ${baseRef} into ${branchName} and pushed the branch.`,
};
} finally {
if (createdTemporaryWorktree) {
try {
await runGitCommand(["worktree", "remove", "--force", tempWorktreePath], rootDir, GIT_TIMEOUT_MS);
} catch {
await rm(tempWorktreePath, { recursive: true, force: true }).catch(() => undefined);
}
}
}
}

View File

@@ -46,6 +46,7 @@ import { GitHubSourceIssueCloseService } from "../github-source-issue-close.js";
import { githubRateLimiter } from "../github-poll.js"; import { githubRateLimiter } from "../github-poll.js";
import * as projectStoreResolver from "../project-store-resolver.js"; import * as projectStoreResolver from "../project-store-resolver.js";
import { generatePrMetadata } from "../pr-metadata-generator.js"; import { generatePrMetadata } from "../pr-metadata-generator.js";
import { resolvePrConflicts } from "../pr-conflict-resolver.js";
import { import {
classifyWebhookEvent, classifyWebhookEvent,
getGitHubAppConfig, getGitHubAppConfig,
@@ -295,6 +296,17 @@ function parsePreflightCommits(output: string): Array<{ sha: string; subject: st
.slice(0, 50); .slice(0, 50);
} }
interface PrPreflightResponse {
branchOnRemote: boolean;
commitsPresent: boolean;
conflictsWithBase: boolean;
ghAuthOk: boolean;
defaultBaseBranch: string;
head: string;
commits: Array<{ sha: string; subject: string; author: string }>;
changedFiles: Array<{ path: string; additions: number; deletions: number; status: "added" | "modified" | "deleted" | "renamed" }>;
}
function parsePreflightChangedFiles(numstatOutput: string, nameStatusOutput: string): Array<{ function parsePreflightChangedFiles(numstatOutput: string, nameStatusOutput: string): Array<{
path: string; path: string;
additions: number; additions: number;
@@ -339,6 +351,73 @@ function parsePreflightChangedFiles(numstatOutput: string, nameStatusOutput: str
return results; return results;
} }
async function computePrPreflight(task: Task, repoRoot: string, requestedBase?: string): Promise<PrPreflightResponse> {
const defaultBaseBranch = requestedBase?.trim()
? ensureSafeGitRef(requestedBase, "base branch")
: await resolveDefaultPrBaseBranch(task, repoRoot);
const head = `fusion/${task.id.toLowerCase()}`;
const safeHead = ensureSafeGitRef(head, "head branch");
const response: PrPreflightResponse = {
branchOnRemote: false,
commitsPresent: false,
conflictsWithBase: false,
ghAuthOk: isGhAuthenticated(),
defaultBaseBranch,
head,
commits: [],
changedFiles: [],
};
const baseRef = await resolvePrBaseRef(repoRoot, defaultBaseBranch).catch(() => defaultBaseBranch);
const remoteBranchCheck = await prRouteCommandRunner.tryRun(
`git ls-remote --exit-code --heads origin ${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
);
if (remoteBranchCheck.ok) {
response.branchOnRemote = true;
} else if (remoteBranchCheck.code !== 2) {
response.branchOnRemote = false;
}
const commitCountOutput = await prRouteCommandRunner.run(
`git rev-list --count ${shellQuote(baseRef)}..${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => "0");
response.commitsPresent = Number.parseInt(commitCountOutput, 10) > 0;
const mergeTreeOutput = await prRouteCommandRunner.run(
`git merge-tree --write-tree --name-only ${shellQuote(baseRef)} ${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => "");
response.conflictsWithBase = mergeTreeOutput.trim().length > 0;
const [commitLogOutput, numstatOutput, nameStatusOutput] = await Promise.all([
prRouteCommandRunner.run(
`git log --no-merges ${shellQuote(baseRef)}..${shellQuote(safeHead)} --format=%H%x09%s%x09%an`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => ""),
prRouteCommandRunner.run(
`git diff --numstat ${shellQuote(baseRef)}..${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => ""),
prRouteCommandRunner.run(
`git diff --name-status ${shellQuote(baseRef)}..${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => ""),
]);
response.commits = parsePreflightCommits(commitLogOutput);
response.changedFiles = parsePreflightChangedFiles(numstatOutput, nameStatusOutput);
return response;
}
function parseGhJsonLines<T>(output: string): T[] { function parseGhJsonLines<T>(output: string): T[] {
return output return output
.split(/\r?\n/) .split(/\r?\n/)
@@ -4611,6 +4690,77 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
} }
}); });
/**
* POST /api/tasks/:id/pr/resolve-conflicts
* Resolve Create-PR merge conflicts on the task branch, push the branch,
* and return refreshed preflight state.
*/
router.post("/tasks/:id/pr/resolve-conflicts", 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 resolve PR conflicts");
}
if (req.body?.base !== undefined && typeof req.body.base !== "string") {
throw badRequest("base must be a string when provided");
}
const repoRoot = scopedStore.getRootDir();
const envRepo = process.env.GITHUB_REPOSITORY?.trim();
const repoInfo = envRepo
? (() => {
const [owner = "", repo = ""] = envRepo.split("/");
return owner && repo ? { owner, repo } : null;
})()
: getCurrentRepo(repoRoot);
if (!repoInfo) {
throw badRequest("Could not determine GitHub repository. Set GITHUB_REPOSITORY env var or configure git remote.");
}
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 baseRef = await resolvePrBaseRef(repoRoot, baseBranch).catch(() => baseBranch);
const result = await resolvePrConflicts({
taskId: task.id,
baseRef,
rootDir: repoRoot,
store: scopedStore,
settings: await scopedStore.getSettings(),
});
if (!result.resolved) {
throw conflict(result.message, {
code: "conflict-resolution-failed",
retryable: true,
unresolvedFiles: result.conflictedFiles,
head,
base: baseBranch,
});
}
await scopedStore.logEntry(task.id, "AI resolved PR conflicts", `${head} against ${baseRef} in ${repoInfo.owner}/${repoInfo.repo}`);
if (result.pushed) {
await scopedStore.logEntry(task.id, "Pushed branch after PR conflict resolution", head);
}
const preflight = await computePrPreflight(task, repoRoot, baseBranch);
res.json({ result, 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`);
}
throw toPrApiError(err, "Failed to resolve PR conflicts");
}
});
/** /**
* POST /api/tasks/:id/pr/generate-metadata * POST /api/tasks/:id/pr/generate-metadata
* Generate AI PR title/body metadata for the Create PR dialog. * Generate AI PR title/body metadata for the Create PR dialog.
@@ -4648,80 +4798,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
const task = await scopedStore.getTask(req.params.id); const task = await scopedStore.getTask(req.params.id);
const repoRoot = scopedStore.getRootDir(); const repoRoot = scopedStore.getRootDir();
const requestedBase = typeof req.query.base === "string" ? req.query.base.trim() : ""; const requestedBase = typeof req.query.base === "string" ? req.query.base.trim() : "";
const defaultBaseBranch = requestedBase res.json(await computePrPreflight(task, repoRoot, requestedBase));
? ensureSafeGitRef(requestedBase, "base branch")
: await resolveDefaultPrBaseBranch(task, repoRoot);
const head = `fusion/${task.id.toLowerCase()}`;
const safeHead = ensureSafeGitRef(head, "head branch");
const response: {
branchOnRemote: boolean;
commitsPresent: boolean;
conflictsWithBase: boolean;
ghAuthOk: boolean;
defaultBaseBranch: string;
head: string;
commits: Array<{ sha: string; subject: string; author: string }>;
changedFiles: Array<{ path: string; additions: number; deletions: number; status: "added" | "modified" | "deleted" | "renamed" }>;
} = {
branchOnRemote: false,
commitsPresent: false,
conflictsWithBase: false,
ghAuthOk: isGhAuthenticated(),
defaultBaseBranch,
head,
commits: [],
changedFiles: [],
};
const baseRef = await resolvePrBaseRef(repoRoot, defaultBaseBranch).catch(() => defaultBaseBranch);
const remoteBranchCheck = await prRouteCommandRunner.tryRun(
`git ls-remote --exit-code --heads origin ${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
);
if (remoteBranchCheck.ok) {
response.branchOnRemote = true;
} else if (remoteBranchCheck.code !== 2) {
response.branchOnRemote = false;
}
const commitCountOutput = await prRouteCommandRunner.run(
`git rev-list --count ${shellQuote(baseRef)}..${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => "0");
response.commitsPresent = Number.parseInt(commitCountOutput, 10) > 0;
const mergeTreeOutput = await prRouteCommandRunner.run(
`git merge-tree --write-tree --name-only ${shellQuote(baseRef)} ${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => "");
response.conflictsWithBase = mergeTreeOutput.trim().length > 0;
const [commitLogOutput, numstatOutput, nameStatusOutput] = await Promise.all([
prRouteCommandRunner.run(
`git log --no-merges ${shellQuote(baseRef)}..${shellQuote(safeHead)} --format=%H%x09%s%x09%an`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => ""),
prRouteCommandRunner.run(
`git diff --numstat ${shellQuote(baseRef)}..${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => ""),
prRouteCommandRunner.run(
`git diff --name-status ${shellQuote(baseRef)}..${shellQuote(safeHead)}`,
repoRoot,
PR_PREFLIGHT_TIMEOUT_MS,
).catch(() => ""),
]);
response.commits = parsePreflightCommits(commitLogOutput);
response.changedFiles = parsePreflightChangedFiles(numstatOutput, nameStatusOutput);
res.json(response);
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
throw err; throw err;

View File

@@ -210,7 +210,7 @@ const qualityAppSettingsOnlyTests = ["app/components/__tests__/SettingsModal.tes
const qualityApiTests = [ const qualityApiTests = [
// Critical HTTP/server behavior: auth, task/project/settings mutation, // Critical HTTP/server behavior: auth, task/project/settings mutation,
// git/GitHub, agents, nodes, chat/files, realtime, and isolation guards. // git/GitHub, agents, nodes, chat/files, realtime, and isolation guards.
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,pr-routes-auto-merge,pr-routes.contract,project-routes,project-store-resolver,register-git-github.pr-options-preflight-metadata,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-branch-groups,routes-git,routes-github,routes-merge-advance-push-origin,routes-nodes,routes-nodes-sync-contract,routes-planning,routes-secrets-sync,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-deterministic-dedup,routes-tasks-duplicate-check,routes-tasks-explicit-duplicate-marker,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket,recover-branch-binding-route}.test.ts", "src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,pr-routes-auto-merge,pr-routes.contract,project-routes,project-store-resolver,register-git-github.pr-options-preflight-metadata,register-git-github.pr-resolve-conflicts,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-branch-groups,routes-git,routes-github,routes-merge-advance-push-origin,routes-nodes,routes-nodes-sync-contract,routes-planning,routes-secrets-sync,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-deterministic-dedup,routes-tasks-duplicate-check,routes-tasks-explicit-duplicate-marker,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket,recover-branch-binding-route}.test.ts",
"src/__tests__/dashboard-test-config-guard.test.ts", "src/__tests__/dashboard-test-config-guard.test.ts",
"src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes,register-diagnostics-routes,stash-recovery-routes}.test.ts", "src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes,register-diagnostics-routes,stash-recovery-routes}.test.ts",
"scripts/__tests__/run-vitest-with-heap.test.ts", "scripts/__tests__/run-vitest-with-heap.test.ts",