From 8891d4b90a3c07bf048a38c7c1dfafe609ecb701 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 16:03:03 -0700 Subject: [PATCH] FN-5950: add Create PR branch-push remediation Add in-app preflight remediation so Create PR can push task branches without leaving Fusion. - add a dashboard API route and client helper to push the task branch to origin and recompute PR preflight state - update the Create Pull Request modal, styles, and tests to surface push-branch remediation alongside AI conflict resolution - document the flow and add a published CLI changeset plus server coverage for the new push-branch endpoint Files changed: .changeset/fn-5950-pr-push-branch.md | 5 + docs/dashboard-guide.md | 2 +- packages/dashboard/app/api/legacy.ts | 19 +++ packages/dashboard/app/components/PrCreateModal.css | 4 +- packages/dashboard/app/components/PrCreateModal.tsx | 48 +++++- packages/dashboard/app/components/__tests__/PrCreateModal.test.tsx | 44 ++++++ packages/dashboard/src/__tests__/register-git-github.pr-push-branch.test.ts | 176 +++++++++++++++++++++ packages/dashboard/src/routes/register-git-github.ts | 68 ++++++++ 8 files changed, 362 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-5950 Fusion-Task-Lineage: 5a6c5c6a-8f99-44c0-8f54-f0a6ff537ed0 --- .changeset/fn-5950-pr-push-branch.md | 5 + docs/dashboard-guide.md | 2 +- packages/dashboard/app/api/legacy.ts | 19 ++ .../app/components/PrCreateModal.css | 4 +- .../app/components/PrCreateModal.tsx | 48 ++++- .../__tests__/PrCreateModal.test.tsx | 44 +++++ ...register-git-github.pr-push-branch.test.ts | 176 ++++++++++++++++++ .../src/routes/register-git-github.ts | 68 +++++++ 8 files changed, 362 insertions(+), 4 deletions(-) create mode 100644 .changeset/fn-5950-pr-push-branch.md create mode 100644 packages/dashboard/src/__tests__/register-git-github.pr-push-branch.test.ts diff --git a/.changeset/fn-5950-pr-push-branch.md b/.changeset/fn-5950-pr-push-branch.md new file mode 100644 index 0000000000..1f619f0f02 --- /dev/null +++ b/.changeset/fn-5950-pr-push-branch.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add an in-app Create PR remediation that pushes the task branch to `origin`, refreshes preflight status, and unblocks PR creation without leaving Fusion. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index a3335bb409..7f8cb05987 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -613,7 +613,7 @@ Inspect task definition, logs, review feedback, comments, documents, workflow ou - In shared task edit/create forms, GitHub Tracking appears at the bottom of **More options**, after **Workflow Steps**. - From this section you can explicitly enable/disable tracking and manage a per-task repo override (`owner/repo`). Clearing the override saves `null` and falls back to project/global defaults. - In `in-review`, pull-request controls/status (including stall badges) are in a dedicated **Pull Request** tab instead of the Definition tab. -- In the **Create Pull Request** modal, if preflight detects `conflictsWithBase`, the modal now offers **Resolve conflicts with AI**. Fusion uses an AI coding agent to resolve merge markers on the task branch, commits the result, pushes `fusion/` to `origin`, and refreshes preflight so normal PR creation can continue once conflicts are gone. +- The **Create Pull Request** modal now offers in-app remediation for every blocking preflight check. If `branchOnRemote` is false, use **Push branch to remote** and Fusion will publish `fusion/` to `origin` and refresh preflight. If `conflictsWithBase` is true, use **Resolve conflicts with AI** and Fusion will resolve merge markers on the task branch, commit the result, push the branch, and refresh preflight so normal PR creation can continue once all checks pass. - The **Review** tab is separate from **Comments**: Review shows actionable PR/reviewer feedback and same-task revision controls, while Comments remains the general collaboration thread. - **Request revision** in Review resumes work on the same task ID (no refinement task): `in-progress` tasks get steering injection, while `in-review` tasks are moved back to `in-progress` for the same branch/worktree revision pass. - Review supports a manual **Refresh** action in-place: PR mode pulls latest GitHub review state/decision, while direct mode rehydrates reviewer-agent feedback from persisted task data (no GitHub call). diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 85b3c32e3b..09e9b60a8a 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -2435,6 +2435,17 @@ export interface ResolvePrConflictsResponse { preflight: PrPreflightResponse; } +export interface PushPrBranchResult { + pushed: boolean; + head: string; + message: string; +} + +export interface PushPrBranchResponse { + result: PushPrBranchResult; + preflight: PrPreflightResponse; +} + export interface PrOptionsUser { login: string; name?: string; @@ -2483,6 +2494,14 @@ export function resolvePrConflicts(id: string, base?: string, projectId?: string }); } +/** Push the Create-PR task branch to origin and refresh preflight state */ +export function pushPrBranch(id: string, base?: string, projectId?: string): Promise { + return api(withProjectId(`/tasks/${id}/pr/push-branch`, projectId), { + method: "POST", + ...(base ? { body: JSON.stringify({ base }) } : {}), + }); +} + /** Fetch PR creation options (branches/reviewers/assignees/labels) for a task */ export function fetchPrOptions(id: string, projectId?: string): Promise { 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 081b2232e2..a15b107731 100644 --- a/packages/dashboard/app/components/PrCreateModal.css +++ b/packages/dashboard/app/components/PrCreateModal.css @@ -80,7 +80,7 @@ gap: var(--space-sm); } -.pr-create-modal__conflict-resolution { +.pr-create-modal__preflight-remediation { display: flex; align-items: center; justify-content: space-between; @@ -276,7 +276,7 @@ .pr-create-modal__grid-two, .pr-create-modal__commit-row, .pr-create-modal__file-row, - .pr-create-modal__conflict-resolution { + .pr-create-modal__preflight-remediation { display: flex; flex-direction: column; align-items: flex-start; diff --git a/packages/dashboard/app/components/PrCreateModal.tsx b/packages/dashboard/app/components/PrCreateModal.tsx index 3c1129edec..01a00d1c47 100644 --- a/packages/dashboard/app/components/PrCreateModal.tsx +++ b/packages/dashboard/app/components/PrCreateModal.tsx @@ -8,6 +8,7 @@ import { fetchPrOptions, fetchPrPreflight, generatePrMetadata, + pushPrBranch, resolvePrConflicts, type PrOptionsLabel, type PrOptionsResponse, @@ -139,6 +140,7 @@ export function PrCreateModal({ const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const [pushBranchError, setPushBranchError] = useState(null); const [resolveConflictError, setResolveConflictError] = useState(null); const [lastGhError, setLastGhError] = useState(null); const [aiTitle, setAiTitle] = useState(""); @@ -152,6 +154,7 @@ export function PrCreateModal({ const [preflight, setPreflight] = useState(null); const [baseBranch, setBaseBranch] = useState(""); const [draft, setDraft] = useState(false); + const [pushingBranch, setPushingBranch] = useState(false); const [resolvingConflicts, setResolvingConflicts] = useState(false); const [reviewers, setReviewers] = useState([]); const [assignees, setAssignees] = useState([]); @@ -163,6 +166,7 @@ export function PrCreateModal({ const requestId = ++requestSeqRef.current; setLoading(true); setError(null); + setPushBranchError(null); setResolveConflictError(null); try { const [metadata, preflightData, optionsData] = await Promise.all([ @@ -289,6 +293,7 @@ export function PrCreateModal({ const handleBaseChange = useCallback(async (nextBase: string) => { setBaseBranch(nextBase); + setPushBranchError(null); setResolveConflictError(null); try { const nextPreflight = await fetchPrPreflight(taskId, projectId, nextBase); @@ -298,6 +303,21 @@ export function PrCreateModal({ } }, [projectId, taskId]); + const handlePushBranch = useCallback(async () => { + if (!baseBranch || pushingBranch) return; + setPushingBranch(true); + setPushBranchError(null); + try { + const response = await pushPrBranch(taskId, baseBranch, projectId); + setPreflight(response.preflight); + addToast(response.result.message, "success"); + } catch (pushError) { + setPushBranchError(getErrorMessage(pushError)); + } finally { + setPushingBranch(false); + } + }, [addToast, baseBranch, projectId, pushingBranch, taskId]); + const handleResolveConflicts = useCallback(async () => { if (!baseBranch || resolvingConflicts) return; setResolvingConflicts(true); @@ -383,8 +403,25 @@ export function PrCreateModal({ + {!preflight?.branchOnRemote ? ( +
+
+

Push branch to remote

+

Fusion will push this task's branch to origin so the PR can be created.

+
+ +
+ ) : null} {preflight?.conflictsWithBase ? ( -
+

Resolve conflicts with AI

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

@@ -486,6 +523,15 @@ export function PrCreateModal({
+ {pushBranchError ? ( +
+

{pushBranchError}

+
+ +
+
+ ) : null} + {resolveConflictError ? (

{resolveConflictError}

diff --git a/packages/dashboard/app/components/__tests__/PrCreateModal.test.tsx b/packages/dashboard/app/components/__tests__/PrCreateModal.test.tsx index ab1b998c15..c62d36219b 100644 --- a/packages/dashboard/app/components/__tests__/PrCreateModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/PrCreateModal.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act } from "react"; import type { ComponentProps } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { PrCreateModal } from "../PrCreateModal"; @@ -9,6 +10,7 @@ const mocks = vi.hoisted(() => ({ fetchPrPreflight: vi.fn(), fetchPrOptions: vi.fn(), createPr: vi.fn(), + pushPrBranch: vi.fn(), resolvePrConflicts: vi.fn(), })); @@ -17,6 +19,7 @@ vi.mock("../../api", () => ({ fetchPrPreflight: mocks.fetchPrPreflight, fetchPrOptions: mocks.fetchPrOptions, createPr: mocks.createPr, + pushPrBranch: mocks.pushPrBranch, resolvePrConflicts: mocks.resolvePrConflicts, })); @@ -69,6 +72,7 @@ describe("PrCreateModal", () => { mocks.fetchPrPreflight.mockResolvedValue(preflight); mocks.fetchPrOptions.mockResolvedValue(options); mocks.createPr.mockResolvedValue({ number: 12, title: "AI title", url: "url", status: "open", headBranch: "h", baseBranch: "main", commentCount: 0 } as PrInfo); + mocks.pushPrBranch.mockResolvedValue({ result: { pushed: true, head: "fusion/fn-4756", message: "Pushed fusion/fn-4756 to origin." }, preflight }); mocks.resolvePrConflicts.mockResolvedValue({ result: { resolved: true, pushed: true, conflictedFiles: ["a.ts"], message: "resolved" }, preflight }); }); @@ -210,6 +214,46 @@ describe("PrCreateModal", () => { fireEvent.click(screen.getByRole("button", { name: /remove reviewer 1/i })); }); + it("renders push-branch affordance and enables submit after success", async () => { + mocks.fetchPrPreflight.mockResolvedValue({ ...preflight, branchOnRemote: false }); + mocks.pushPrBranch.mockResolvedValueOnce({ result: { pushed: true, head: "fusion/fn-4756", message: "Pushed fusion/fn-4756 to origin." }, preflight }); + const { addToast } = await renderModalLoaded(); + + expect(screen.getByRole("button", { name: "Create PR" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Push branch to remote" })); + + await waitFor(() => expect(mocks.pushPrBranch).toHaveBeenCalledWith("FN-4756", "main", undefined)); + await waitFor(() => expect(screen.getByRole("button", { name: "Create PR" })).toBeEnabled()); + expect(addToast).toHaveBeenCalledWith("Pushed fusion/fn-4756 to origin.", "success"); + }); + + it("surfaces push-branch failures", async () => { + mocks.fetchPrPreflight.mockResolvedValue({ ...preflight, branchOnRemote: false }); + mocks.pushPrBranch.mockRejectedValueOnce(new Error("unable to push branch")); + await renderModalLoaded(); + + fireEvent.click(screen.getByRole("button", { name: "Push branch to remote" })); + + expect(await screen.findByText("unable to push branch")).toBeInTheDocument(); + }); + + it("keeps the push-branch remediation usable on narrow mobile widths", async () => { + mocks.fetchPrPreflight.mockResolvedValue({ ...preflight, branchOnRemote: false }); + const originalInnerWidth = window.innerWidth; + + await act(async () => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: 375 }); + window.dispatchEvent(new Event("resize")); + }); + + await renderModalLoaded(); + + expect(screen.getByRole("button", { name: "Push branch to remote" })).toBeVisible(); + + Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth }); + window.dispatchEvent(new Event("resize")); + }); + it("renders AI conflict resolution affordance and enables submit after success", async () => { mocks.fetchPrPreflight.mockResolvedValue({ ...preflight, conflictsWithBase: true, branchOnRemote: false }); mocks.resolvePrConflicts.mockResolvedValueOnce({ result: { resolved: true, pushed: true, conflictedFiles: ["a.ts"], message: "resolved" }, preflight }); diff --git a/packages/dashboard/src/__tests__/register-git-github.pr-push-branch.test.ts b/packages/dashboard/src/__tests__/register-git-github.pr-push-branch.test.ts new file mode 100644 index 0000000000..e4881e5ece --- /dev/null +++ b/packages/dashboard/src/__tests__/register-git-github.pr-push-branch.test.ts @@ -0,0 +1,176 @@ +// @vitest-environment node + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as fusionCore from "@fusion/core"; +import type { Task, TaskStore } from "@fusion/core"; + +const { mockRunGitCommand } = vi.hoisted(() => ({ + mockRunGitCommand: vi.fn(), +})); + +vi.mock("../routes/resolve-diff-base.js", () => ({ + runGitCommand: mockRunGitCommand, +})); + +import { prRouteCommandRunner } from "../routes/register-git-github.js"; +import { createServer } from "../server.js"; +import { request as performRequest } from "../test-request.js"; + +function createTask(overrides: Partial = {}): Task { + 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/push-branch", () => { + beforeEach(() => { + vi.clearAllMocks(); + runQueue.length = 0; + tryRunQueue.length = 0; + vi.spyOn(fusionCore, "isGhAuthenticated").mockReturnValue(true); + vi.spyOn(prRouteCommandRunner, "run").mockImplementation(async () => { + const next = runQueue.shift(); + if (!next) throw new Error("Unexpected run command"); + if (next.ok) return next.value; + throw next.error; + }); + vi.spyOn(prRouteCommandRunner, "tryRun").mockImplementation(async () => { + const next = tryRunQueue.shift(); + if (!next) throw new Error("Unexpected tryRun command"); + return next; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("rejects non in-review tasks", async () => { + const app = createServer(createStore(createTask({ column: "todo", status: "todo" }))); + const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/push-branch", JSON.stringify({ base: "main" }), { "content-type": "application/json" }); + + expect(response.status).toBe(400); + expect(response.body.error).toContain("Task must be in 'in-review' column"); + expect(mockRunGitCommand).not.toHaveBeenCalled(); + }); + + it("pushes the branch, logs it, and returns recomputed preflight", async () => { + mockRunGitCommand + .mockResolvedValueOnce("deadbeef\n") + .mockResolvedValueOnce("2\n") + .mockResolvedValueOnce(""); + queueTryRunSuccess("deadbeef\n"); + queueTryRunSuccess("refs/heads/fusion/fn-001\n"); + queueRunSuccess("2\n"); + queueRunSuccess(""); + queueRunSuccess("abc123\tAdd feature\tDev\n"); + queueRunSuccess("3\t1\tsrc/a.ts\n"); + queueRunSuccess("M\tsrc/a.ts\n"); + + const store = createStore(createTask()); + const app = createServer(store); + const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/push-branch", JSON.stringify({ base: "main" }), { "content-type": "application/json" }); + + expect(response.status).toBe(200); + expect(mockRunGitCommand).toHaveBeenNthCalledWith(1, ["rev-parse", "--verify", "refs/heads/fusion/fn-001"], "/tmp/project", 10000); + expect(mockRunGitCommand).toHaveBeenNthCalledWith(2, ["rev-list", "--count", "main..fusion/fn-001"], "/tmp/project", 10000); + expect(mockRunGitCommand).toHaveBeenNthCalledWith(3, ["push", "-u", "origin", "fusion/fn-001"], "/tmp/project", 60000); + expect(response.body.result).toEqual({ + pushed: true, + head: "fusion/fn-001", + message: "Pushed fusion/fn-001 to origin.", + }); + expect(response.body.preflight.branchOnRemote).toBe(true); + expect(response.body.preflight.commitsPresent).toBe(true); + expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Pushed PR branch", "fusion/fn-001"); + }); + + it("returns a structured badRequest when the branch has no commits", async () => { + mockRunGitCommand + .mockResolvedValueOnce("deadbeef\n") + .mockResolvedValueOnce("0\n"); + + const app = createServer(createStore(createTask())); + const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/push-branch", JSON.stringify({ base: "main" }), { "content-type": "application/json" }); + + expect(response.status).toBe(400); + expect(response.body.error).toContain("Branch has no commits"); + expect(mockRunGitCommand).toHaveBeenCalledTimes(2); + }); + + it("maps git push failures to a structured API error", async () => { + mockRunGitCommand + .mockResolvedValueOnce("deadbeef\n") + .mockResolvedValueOnce("2\n") + .mockRejectedValueOnce(new Error("network unreachable")); + + const app = createServer(createStore(createTask())); + const response = await performRequest(app, "POST", "/api/tasks/FN-001/pr/push-branch", JSON.stringify({ base: "main" }), { "content-type": "application/json" }); + + expect(response.status).toBe(502); + expect(response.body.error).toContain("network unreachable"); + expect(response.body.details.githubError.code).toBe("unknown"); + }); +}); diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index d221ed00dd..d542a6081b 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -4690,6 +4690,74 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { } }); + /** + * POST /api/tasks/:id/pr/push-branch + * Push the task branch to origin and return refreshed preflight state. + */ + router.post("/tasks/:id/pr/push-branch", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const task = await scopedStore.getTask(req.params.id); + if (task.column !== "in-review") { + throw badRequest("Task must be in 'in-review' column to push PR branch"); + } + + if (req.body?.base !== undefined && typeof req.body.base !== "string") { + throw badRequest("base must be a string when provided"); + } + + const repoRoot = scopedStore.getRootDir(); + const requestedBase = typeof req.body?.base === "string" ? req.body.base.trim() : ""; + const defaultBaseBranch = requestedBase || await resolveDefaultPrBaseBranch(task, repoRoot); + const baseBranch = ensureSafeGitRef(defaultBaseBranch, "base branch"); + const head = ensureSafeGitRef(`fusion/${task.id.toLowerCase()}`, "head branch"); + const headRef = `refs/heads/${head}`; + const baseRef = await resolvePrBaseRef(repoRoot, baseBranch).catch(() => baseBranch); + + try { + await runGitCommand(["rev-parse", "--verify", headRef], repoRoot, 10_000); + } catch { + throw badRequest(`Branch ${head} does not exist locally. Commit changes before creating a PR.`); + } + + let commitCount = 0; + try { + const commitCountOutput = await runGitCommand(["rev-list", "--count", `${baseRef}..${head}`], repoRoot, 10_000); + commitCount = Number.parseInt(commitCountOutput, 10); + } catch { + throw badRequest(`Branch ${head} does not exist locally. Commit changes before creating a PR.`); + } + + if (!Number.isFinite(commitCount) || commitCount <= 0) { + throw badRequest("Branch has no commits. Push changes before creating PR."); + } + + await runGitCommand(["push", "-u", "origin", head], repoRoot, 60_000); + await scopedStore.logEntry(task.id, "Pushed PR branch", head); + + const preflight = await computePrPreflight(task, repoRoot, baseBranch); + res.json({ + result: { + pushed: true, + head, + message: `Pushed ${head} to origin.`, + }, + preflight, + }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + throw notFound(`Task ${req.params.id} not found`); + } + if ((err instanceof Error ? err.message : String(err)).includes("already exists")) { + throw conflict(err instanceof Error ? err.message : String(err)); + } + throw toPrApiError(err, "Failed to push PR branch"); + } + }); + /** * POST /api/tasks/:id/pr/resolve-conflicts * Resolve Create-PR merge conflicts on the task branch, push the branch,