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