fix: guard PR retries for missing task branches
This commit is contained in:
5
.changeset/fix-missing-task-branch-pr-retry.md
Normal file
5
.changeset/fix-missing-task-branch-pr-retry.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Guard PR creation retries against missing task branches and park no-delta branches with an actionable task error.
|
||||||
@@ -220,6 +220,181 @@ describe("processPullRequestMergeTask", () => {
|
|||||||
expect(github.createPr).not.toHaveBeenCalled();
|
expect(github.createPr).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fails before push when the task branch is missing locally and remotely", async () => {
|
||||||
|
const task: MockTask = {
|
||||||
|
id: "FN-9010",
|
||||||
|
title: "test",
|
||||||
|
description: "desc",
|
||||||
|
column: "in-review",
|
||||||
|
};
|
||||||
|
const branch = getTaskBranchName(task.id);
|
||||||
|
const store = makeStore(task);
|
||||||
|
|
||||||
|
const commands: string[] = [];
|
||||||
|
execMock.mockImplementation((cmd: string) => {
|
||||||
|
commands.push(cmd);
|
||||||
|
if (cmd.startsWith("git show-ref")) {
|
||||||
|
const err = new Error("not found") as Error & { code?: number };
|
||||||
|
err.code = 1;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (cmd.startsWith("git ls-remote")) {
|
||||||
|
const err = new Error("not found") as Error & { code?: number };
|
||||||
|
err.code = 2;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const github = {
|
||||||
|
findPrForBranch: vi.fn(async () => null),
|
||||||
|
createPr: vi.fn(),
|
||||||
|
getPrMergeStatus: vi.fn(),
|
||||||
|
mergePr: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined),
|
||||||
|
).rejects.toThrow(`Cannot create PR for missing task branch "${branch}"`);
|
||||||
|
|
||||||
|
expect(commands.some((cmd) => cmd.startsWith("git push"))).toBe(false);
|
||||||
|
expect(github.createPr).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rethrows unexpected remote lookup failures instead of treating them as missing branches", async () => {
|
||||||
|
const task: MockTask = {
|
||||||
|
id: "FN-9013",
|
||||||
|
title: "test",
|
||||||
|
description: "desc",
|
||||||
|
column: "in-review",
|
||||||
|
};
|
||||||
|
const store = makeStore(task);
|
||||||
|
|
||||||
|
const commands: string[] = [];
|
||||||
|
execMock.mockImplementation((cmd: string) => {
|
||||||
|
commands.push(cmd);
|
||||||
|
if (cmd.startsWith("git show-ref")) {
|
||||||
|
const err = new Error("not found") as Error & { code?: number };
|
||||||
|
err.code = 1;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (cmd.startsWith("git ls-remote")) {
|
||||||
|
const err = new Error("fatal: unable to access remote") as Error & { code?: number };
|
||||||
|
err.code = 128;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const github = {
|
||||||
|
findPrForBranch: vi.fn(async () => null),
|
||||||
|
createPr: vi.fn(),
|
||||||
|
getPrMergeStatus: vi.fn(),
|
||||||
|
mergePr: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined),
|
||||||
|
).rejects.toThrow("fatal: unable to access remote");
|
||||||
|
|
||||||
|
expect(commands.some((cmd) => cmd.startsWith("git push"))).toBe(false);
|
||||||
|
expect(github.createPr).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips push when the local branch is gone but the remote task branch exists", async () => {
|
||||||
|
const task: MockTask = {
|
||||||
|
id: "FN-9011",
|
||||||
|
title: "test",
|
||||||
|
description: "desc",
|
||||||
|
column: "in-review",
|
||||||
|
};
|
||||||
|
const branch = getTaskBranchName(task.id);
|
||||||
|
const store = makeStore(task);
|
||||||
|
|
||||||
|
const commands: string[] = [];
|
||||||
|
execMock.mockImplementation((cmd: string) => {
|
||||||
|
commands.push(cmd);
|
||||||
|
if (cmd.startsWith("git show-ref")) {
|
||||||
|
const err = new Error("not found") as Error & { code?: number };
|
||||||
|
err.code = 1;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const github = {
|
||||||
|
findPrForBranch: vi.fn(async () => null),
|
||||||
|
createPr: vi.fn(async () => ({
|
||||||
|
number: 43,
|
||||||
|
url: "https://github.com/x/y/pull/43",
|
||||||
|
status: "open" as const,
|
||||||
|
headBranch: branch,
|
||||||
|
baseBranch: "main",
|
||||||
|
})),
|
||||||
|
getPrMergeStatus: vi.fn(async () => ({
|
||||||
|
prInfo: { number: 43, status: "open" as const, url: "https://github.com/x/y/pull/43" },
|
||||||
|
reviewDecision: null,
|
||||||
|
checks: [],
|
||||||
|
mergeReady: false,
|
||||||
|
blockingReasons: [],
|
||||||
|
})),
|
||||||
|
mergePr: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await processPullRequestMergeTask(
|
||||||
|
store as never,
|
||||||
|
"/repo",
|
||||||
|
task.id,
|
||||||
|
github as never,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("waiting");
|
||||||
|
expect(commands.some((cmd) => cmd.startsWith("git ls-remote"))).toBe(true);
|
||||||
|
expect(commands.some((cmd) => cmd.startsWith("git push"))).toBe(false);
|
||||||
|
expect(github.createPr).toHaveBeenCalledWith(expect.objectContaining({ head: branch }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parks no-delta branches instead of retrying into branch push failures", async () => {
|
||||||
|
const task: MockTask = {
|
||||||
|
id: "FN-9012",
|
||||||
|
title: "test",
|
||||||
|
description: "desc",
|
||||||
|
column: "in-review",
|
||||||
|
};
|
||||||
|
const branch = getTaskBranchName(task.id);
|
||||||
|
const store = makeStore(task);
|
||||||
|
execMock.mockImplementation(() => "");
|
||||||
|
|
||||||
|
const github = {
|
||||||
|
findPrForBranch: vi.fn(async () => null),
|
||||||
|
createPr: vi.fn(async () => {
|
||||||
|
throw new Error(`GraphQL: No commits between main and ${branch} (createPullRequest)`);
|
||||||
|
}),
|
||||||
|
getPrMergeStatus: vi.fn(),
|
||||||
|
mergePr: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await processPullRequestMergeTask(
|
||||||
|
store as never,
|
||||||
|
"/repo",
|
||||||
|
task.id,
|
||||||
|
github as never,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("skipped");
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith(task.id, {
|
||||||
|
status: "failed",
|
||||||
|
error: `No pull request created for ${branch}: the branch has no commits relative to the base branch.`,
|
||||||
|
});
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
|
task.id,
|
||||||
|
`No pull request created for ${branch}: the branch has no commits relative to the base branch.`,
|
||||||
|
expect.stringContaining("No commits between"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("finalizes task cleanup when PR is already merged on status refresh", async () => {
|
it("finalizes task cleanup when PR is already merged on status refresh", async () => {
|
||||||
const task: MockTask = {
|
const task: MockTask = {
|
||||||
id: "FN-9004",
|
id: "FN-9004",
|
||||||
|
|||||||
@@ -58,7 +58,48 @@ export function getTaskBranchName(taskId: string): string {
|
|||||||
* fast-forwards thereafter. Required because the GitHub PR-create flow
|
* fast-forwards thereafter. Required because the GitHub PR-create flow
|
||||||
* does not implicitly publish the local branch.
|
* does not implicitly publish the local branch.
|
||||||
*/
|
*/
|
||||||
|
function commandExitCode(err: unknown): number | undefined {
|
||||||
|
if (typeof err === "object" && err !== null && "code" in err) {
|
||||||
|
const code = (err as { code?: unknown }).code;
|
||||||
|
return typeof code === "number" ? code : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gitCommandSucceeds(cwd: string, command: string, missingExitCode: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await execAsync(command, { cwd, timeout: 30_000 });
|
||||||
|
return true;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (commandExitCode(err) === missingExitCode) return false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function pushTaskBranchToOrigin(cwd: string, branch: string): Promise<void> {
|
async function pushTaskBranchToOrigin(cwd: string, branch: string): Promise<void> {
|
||||||
|
const localRef = `refs/heads/${branch}`;
|
||||||
|
const localBranchExists = await gitCommandSucceeds(
|
||||||
|
cwd,
|
||||||
|
`git show-ref --verify --quiet "${localRef}"`,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!localBranchExists) {
|
||||||
|
const remoteBranchExists = await gitCommandSucceeds(
|
||||||
|
cwd,
|
||||||
|
`git ls-remote --exit-code --heads origin "${branch}"`,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (remoteBranchExists) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Cannot create PR for missing task branch "${branch}": no local ref "${localRef}" and no origin branch "${branch}". Re-run the task or recreate the branch before retrying PR creation.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await execAsync(`git push -u origin "${branch}"`, {
|
await execAsync(`git push -u origin "${branch}"`, {
|
||||||
cwd,
|
cwd,
|
||||||
@@ -199,11 +240,22 @@ export async function processPullRequestMergeTask(
|
|||||||
// branch, so we push it here right before creating the PR.
|
// branch, so we push it here right before creating the PR.
|
||||||
await pushTaskBranchToOrigin(cwd, branch);
|
await pushTaskBranchToOrigin(cwd, branch);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
prInfo = existingPr ?? await github.createPr({
|
prInfo = existingPr ?? await github.createPr({
|
||||||
title: buildPullRequestTitle(task),
|
title: buildPullRequestTitle(task),
|
||||||
body: buildPullRequestBody(task),
|
body: buildPullRequestBody(task),
|
||||||
head: branch,
|
head: branch,
|
||||||
});
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (message.includes("No commits between")) {
|
||||||
|
const error = `No pull request created for ${branch}: the branch has no commits relative to the base branch.`;
|
||||||
|
await store.updateTask(task.id, { status: "failed", error });
|
||||||
|
await store.logEntry(task.id, error, message);
|
||||||
|
return "skipped";
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
await store.updatePrInfo(task.id, prInfo);
|
await store.updatePrInfo(task.id, prInfo);
|
||||||
await store.logEntry(
|
await store.logEntry(
|
||||||
|
|||||||
Reference in New Issue
Block a user