From 8aed4da764a8d8882d567dac2c9b46862249e499 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 15:11:07 -0700 Subject: [PATCH] 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 --- .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 ++ .../app/components/PrCreateModal.css | 32 ++- .../app/components/PrCreateModal.tsx | 46 ++++ .../__tests__/PrCreateModal.test.tsx | 33 +++ ...er-git-github.pr-resolve-conflicts.test.ts | 186 +++++++++++++ packages/dashboard/src/index.ts | 5 + .../dashboard/src/pr-conflict-resolver.ts | 258 ++++++++++++++++++ .../src/routes/register-git-github.ts | 225 ++++++++++----- packages/dashboard/vitest.config.ts | 2 +- 13 files changed, 739 insertions(+), 76 deletions(-) create mode 100644 .changeset/fn-5949-pr-conflict-resolution.md create mode 100644 packages/dashboard/src/__tests__/register-git-github.pr-resolve-conflicts.test.ts create mode 100644 packages/dashboard/src/pr-conflict-resolver.ts diff --git a/.changeset/fn-5949-pr-conflict-resolution.md b/.changeset/fn-5949-pr-conflict-resolution.md new file mode 100644 index 0000000000..2500c1c0c6 --- /dev/null +++ b/.changeset/fn-5949-pr-conflict-resolution.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index df790bd67c..bac1ef0fb4 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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**. - 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/` 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. - **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). diff --git a/docs/task-management.md b/docs/task-management.md index d459f5c983..fd81282534 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -688,6 +688,7 @@ Manual/non-auto-merge behavior: - `Finish & Close` (PR already merged) - 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. +- 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. ## GitHub Tracking Issues diff --git a/packages/dashboard/README.md b/packages/dashboard/README.md index 912b24277c..bb6b7e5431 100644 --- a/packages/dashboard/README.md +++ b/packages/dashboard/README.md @@ -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/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/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) - `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) diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 0a987a4900..98024d5e48 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -2416,6 +2416,18 @@ export interface PrPreflightResponse { changedFiles: PrPreflightChangedFile[]; } +export interface ResolvePrConflictsResult { + resolved: boolean; + pushed: boolean; + conflictedFiles: string[]; + message: string; +} + +export interface ResolvePrConflictsResponse { + result: ResolvePrConflictsResult; + preflight: PrPreflightResponse; +} + export interface PrOptionsUser { login: string; name?: string; @@ -2456,6 +2468,14 @@ export function fetchPrPreflight(id: string, projectId?: string, base?: string): return api(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 { + return api(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 */ export function fetchPrOptions(id: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/pr/options`, projectId)); diff --git a/packages/dashboard/app/components/PrCreateModal.css b/packages/dashboard/app/components/PrCreateModal.css index 17ff18c877..081b2232e2 100644 --- a/packages/dashboard/app/components/PrCreateModal.css +++ b/packages/dashboard/app/components/PrCreateModal.css @@ -80,6 +80,35 @@ 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 { font-size: 0.75rem; text-transform: uppercase; @@ -246,7 +275,8 @@ .pr-create-modal__title-row, .pr-create-modal__grid-two, .pr-create-modal__commit-row, - .pr-create-modal__file-row { + .pr-create-modal__file-row, + .pr-create-modal__conflict-resolution { display: flex; flex-direction: column; align-items: flex-start; diff --git a/packages/dashboard/app/components/PrCreateModal.tsx b/packages/dashboard/app/components/PrCreateModal.tsx index 02275ddfac..8ab1ec7679 100644 --- a/packages/dashboard/app/components/PrCreateModal.tsx +++ b/packages/dashboard/app/components/PrCreateModal.tsx @@ -7,6 +7,7 @@ import { fetchPrOptions, fetchPrPreflight, generatePrMetadata, + resolvePrConflicts, type PrOptionsLabel, type PrOptionsResponse, type PrOptionsUser, @@ -135,6 +136,7 @@ export function PrCreateModal({ const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const [resolveConflictError, setResolveConflictError] = useState(null); const [lastGhError, setLastGhError] = useState(null); const [aiTitle, setAiTitle] = useState(""); const [aiBody, setAiBody] = useState(""); @@ -147,6 +149,7 @@ export function PrCreateModal({ const [preflight, setPreflight] = useState(null); const [baseBranch, setBaseBranch] = useState(""); const [draft, setDraft] = useState(false); + const [resolvingConflicts, setResolvingConflicts] = useState(false); const [reviewers, setReviewers] = useState([]); const [assignees, setAssignees] = useState([]); const [labels, setLabels] = useState([]); @@ -157,6 +160,7 @@ export function PrCreateModal({ const requestId = ++requestSeqRef.current; setLoading(true); setError(null); + setResolveConflictError(null); try { const [metadata, preflightData, optionsData] = await Promise.all([ generatePrMetadata(taskId, projectId), @@ -282,6 +286,7 @@ export function PrCreateModal({ const handleBaseChange = useCallback(async (nextBase: string) => { setBaseBranch(nextBase); + setResolveConflictError(null); try { const nextPreflight = await fetchPrPreflight(taskId, projectId, nextBase); setPreflight(nextPreflight); @@ -290,6 +295,21 @@ export function PrCreateModal({ } }, [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(() => ({ title: title.trim(), body: body.trim(), @@ -360,6 +380,23 @@ export function PrCreateModal({ + {preflight?.conflictsWithBase ? ( +
+
+

Resolve conflicts with AI

+

Fusion will use AI to resolve conflicts on this branch and push it.

+
+ +
+ ) : null}
@@ -446,6 +483,15 @@ export function PrCreateModal({ + {resolveConflictError ? ( +
+

{resolveConflictError}

+
+ +
+
+ ) : null} + {error && (

{error}

diff --git a/packages/dashboard/app/components/__tests__/PrCreateModal.test.tsx b/packages/dashboard/app/components/__tests__/PrCreateModal.test.tsx index 50c94a0b39..ab1b998c15 100644 --- a/packages/dashboard/app/components/__tests__/PrCreateModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/PrCreateModal.test.tsx @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ fetchPrPreflight: vi.fn(), fetchPrOptions: vi.fn(), createPr: vi.fn(), + resolvePrConflicts: vi.fn(), })); vi.mock("../../api", () => ({ @@ -16,6 +17,7 @@ vi.mock("../../api", () => ({ fetchPrPreflight: mocks.fetchPrPreflight, fetchPrOptions: mocks.fetchPrOptions, 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 }; @@ -67,6 +69,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.resolvePrConflicts.mockResolvedValue({ result: { resolved: true, pushed: true, conflictedFiles: ["a.ts"], message: "resolved" }, preflight }); }); it("renders nothing when closed", () => { @@ -207,11 +210,39 @@ describe("PrCreateModal", () => { 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 () => { 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 waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled()); fireEvent.click(screen.getByRole("button", { name: "Create PR" })); + await waitFor(() => expect(mocks.createPr).toHaveBeenCalledTimes(1)); expect(await screen.findByText("bad")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Retry" })); await waitFor(() => expect(mocks.createPr).toHaveBeenCalledTimes(2)); @@ -232,7 +263,9 @@ describe("PrCreateModal", () => { }); mocks.createPr.mockRejectedValueOnce(err); await renderModalLoaded(); + await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled()); 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); }); diff --git a/packages/dashboard/src/__tests__/register-git-github.pr-resolve-conflicts.test.ts b/packages/dashboard/src/__tests__/register-git-github.pr-resolve-conflicts.test.ts new file mode 100644 index 0000000000..c4ca3028cb --- /dev/null +++ b/packages/dashboard/src/__tests__/register-git-github.pr-resolve-conflicts.test.ts @@ -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 { + 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>; +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); + }); +}); diff --git a/packages/dashboard/src/index.ts b/packages/dashboard/src/index.ts index d1741a4210..3917d6582e 100644 --- a/packages/dashboard/src/index.ts +++ b/packages/dashboard/src/index.ts @@ -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 { 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 { + resolvePrConflicts, + type ResolvePrConflictsInput, + type ResolvePrConflictsResult, +} from "./pr-conflict-resolver.js"; export { maybeCreateTrackingIssue, type MaybeCreateTrackingIssueDeps } from "./github-tracking.js"; export { buildIssueSearchQueries, diff --git a/packages/dashboard/src/pr-conflict-resolver.ts b/packages/dashboard/src/pr-conflict-resolver.ts new file mode 100644 index 0000000000..230b80d0b2 --- /dev/null +++ b/packages/dashboard/src/pr-conflict-resolver.ts @@ -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 { + try { + await access(path); + return true; + } catch { + return false; + } +} + +async function resolveUsableWorktree(candidatePath: string | undefined, branchName: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } + } + } +} diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index 1613d971bc..d221ed00dd 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -46,6 +46,7 @@ import { GitHubSourceIssueCloseService } from "../github-source-issue-close.js"; import { githubRateLimiter } from "../github-poll.js"; import * as projectStoreResolver from "../project-store-resolver.js"; import { generatePrMetadata } from "../pr-metadata-generator.js"; +import { resolvePrConflicts } from "../pr-conflict-resolver.js"; import { classifyWebhookEvent, getGitHubAppConfig, @@ -295,6 +296,17 @@ function parsePreflightCommits(output: string): Array<{ sha: string; subject: st .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<{ path: string; additions: number; @@ -339,6 +351,73 @@ function parsePreflightChangedFiles(numstatOutput: string, nameStatusOutput: str return results; } +async function computePrPreflight(task: Task, repoRoot: string, requestedBase?: string): Promise { + 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(output: string): T[] { return output .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 * 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 repoRoot = scopedStore.getRootDir(); const requestedBase = typeof req.query.base === "string" ? req.query.base.trim() : ""; - const defaultBaseBranch = 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); + res.json(await computePrPreflight(task, repoRoot, requestedBase)); } catch (err: unknown) { if (err instanceof ApiError) { throw err; diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index 302be7480b..bf5e608710 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -210,7 +210,7 @@ const qualityAppSettingsOnlyTests = ["app/components/__tests__/SettingsModal.tes const qualityApiTests = [ // Critical HTTP/server behavior: auth, task/project/settings mutation, // 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/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",