Skip to content

Commit 256055b

Browse files
feat: add push fallback with rebase retry and PR comment on conflict
Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
1 parent 5f76e07 commit 256055b

3 files changed

Lines changed: 251 additions & 8 deletions

File tree

dist/index.js

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39368,7 +39368,10 @@ async function updateFromSourceSpec(token, branch, autoMerge) {
3936839368
await exec.exec("git", ["add", "."], { silent: true });
3936939369
await exec.exec("git", ["commit", "-m", "Update API specifications with fern api update"], { silent: true });
3937039370
core.info(`Pushing changes to branch: ${branch}`);
39371-
await exec.exec("git", ["push", "--verbose", "origin", branch], { silent: false });
39371+
const pushSucceeded = await pushWithFallback(branch, owner, repo, octokit);
39372+
if (!pushSucceeded) {
39373+
return;
39374+
}
3937239375
if (!autoMerge) {
3937339376
const existingPRNumber = await prExists(owner, repo, branch, octokit);
3937439377
if (existingPRNumber) {
@@ -39633,6 +39636,54 @@ async function pushChanges(branchName, options) {
3963339636
throw new Error(`Failed to push changes to the repository: ${error instanceof Error ? error.message : "Unknown error"}`);
3963439637
}
3963539638
}
39639+
// Push with fallback: try regular push, then rebase, then force push, then comment on PR
39640+
async function pushWithFallback(branchName, owner, repo, octokit) {
39641+
// Try regular push first
39642+
try {
39643+
await exec.exec("git", ["push", "--verbose", "origin", branchName], { silent: false });
39644+
return true;
39645+
}
39646+
catch {
39647+
core.info(`Regular push to '${branchName}' failed. Attempting to rebase on remote branch.`);
39648+
}
39649+
// Try pull --rebase then push
39650+
try {
39651+
await exec.exec("git", ["pull", "--rebase", "origin", branchName], {
39652+
silent: false,
39653+
});
39654+
await exec.exec("git", ["push", "--verbose", "origin", branchName], { silent: false });
39655+
core.info(`Successfully pushed to '${branchName}' after rebasing on remote changes.`);
39656+
return true;
39657+
}
39658+
catch {
39659+
core.info(`Rebase failed (likely due to merge conflicts). Aborting rebase.`);
39660+
// Abort the rebase so the working tree is clean
39661+
try {
39662+
await exec.exec("git", ["rebase", "--abort"], { silent: true });
39663+
}
39664+
catch {
39665+
// rebase --abort can fail if there's no rebase in progress, ignore
39666+
}
39667+
}
39668+
// Last resort: leave a comment on the existing PR
39669+
const existingPRNumber = await prExists(owner, repo, branchName, octokit);
39670+
if (existingPRNumber) {
39671+
core.info(`Could not push to '${branchName}' due to conflicts. Leaving a comment on PR #${existingPRNumber}.`);
39672+
await octokit.rest.issues.createComment({
39673+
owner,
39674+
repo,
39675+
issue_number: existingPRNumber,
39676+
body: `⚠️ **Sync failed**: The latest \`fern api update\` detected changes, but they could not be pushed to this branch due to merge conflicts.\n\n` +
39677+
`**To resolve**, either:\n` +
39678+
`- Merge or close this PR so the next run creates a fresh one, or\n` +
39679+
`- Manually rebase this branch on \`${github.context.ref.replace("refs/heads/", "")}\` and re-run the workflow.`,
39680+
});
39681+
}
39682+
else {
39683+
core.setFailed(`Failed to push changes to '${branchName}' and no existing PR was found to comment on.`);
39684+
}
39685+
return false;
39686+
}
3963639687
// Check if a PR exists for a branch
3963739688
async function prExists(owner, repo, branchName, octokit) {
3963839689
const prs = await octokit.rest.pulls.list({

src/sync.test.ts

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ const state = {
77
execCalls: [] as [string, string[] | undefined, unknown][], // [cmd, args, opts]
88
getInputImpl: (_name: string): string => "",
99
getBooleanInputImpl: (_name: string): boolean => false,
10-
execImpl: async (): Promise<number> => 0,
10+
execImpl: async (
11+
_cmd: string,
12+
_args?: string[],
13+
): Promise<number> => 0,
1114
getExecOutputImpl: async (): Promise<{
1215
stdout: string;
1316
stderr: string;
@@ -25,6 +28,7 @@ let mockPullsList: ReturnType<typeof vi.fn>;
2528
let mockPullsCreate: ReturnType<typeof vi.fn>;
2629
let mockPullsUpdate: ReturnType<typeof vi.fn>;
2730
let mockGitGetRef: ReturnType<typeof vi.fn>;
31+
let mockIssuesCreateComment: ReturnType<typeof vi.fn>;
2832

2933
// Factory mocks that delegate to shared state
3034
vi.mock("@actions/core", () => ({
@@ -56,7 +60,7 @@ vi.mock("@actions/exec", () => ({
5660
opts?: unknown,
5761
): Promise<number> => {
5862
state.execCalls.push([cmd, args, opts]);
59-
return state.execImpl();
63+
return state.execImpl(cmd, args);
6064
},
6165
),
6266
getExecOutput: vi.fn(async () => state.getExecOutputImpl()),
@@ -99,7 +103,7 @@ function setupMocks({
99103
};
100104

101105
// Setup exec implementations
102-
state.execImpl = async () => 0;
106+
state.execImpl = async (_cmd: string, _args?: string[]) => 0;
103107
state.getExecOutputImpl = async () => ({
104108
stdout: hasChanges ? "M openapi/openapi.json\n" : "",
105109
stderr: "",
@@ -114,6 +118,7 @@ function setupMocks({
114118
data: { html_url: "https://github.com/test-owner/test-repo/pull/1" },
115119
});
116120
mockPullsUpdate = vi.fn().mockResolvedValue({});
121+
mockIssuesCreateComment = vi.fn().mockResolvedValue({});
117122
mockGitGetRef = branchExists
118123
? vi.fn().mockResolvedValue({})
119124
: vi.fn().mockRejectedValue(new Error("Not found"));
@@ -128,6 +133,9 @@ function setupMocks({
128133
git: {
129134
getRef: mockGitGetRef,
130135
},
136+
issues: {
137+
createComment: mockIssuesCreateComment,
138+
},
131139
},
132140
};
133141
}
@@ -240,6 +248,113 @@ describe("updateFromSourceSpec", () => {
240248
expect(pushCall![1]).toContain("origin");
241249
expect(pushCall![1]).toContain("update-api");
242250
});
251+
252+
it("should rebase and retry push when regular push fails", async () => {
253+
setupMocks({ hasChanges: true, existingPRNumber: null });
254+
let pushAttempt = 0;
255+
state.execImpl = async (
256+
cmd: string,
257+
args?: string[],
258+
): Promise<number> => {
259+
if (
260+
cmd === "git" &&
261+
Array.isArray(args) &&
262+
args.includes("push")
263+
) {
264+
pushAttempt++;
265+
if (pushAttempt === 1) {
266+
throw new Error("rejected (non-fast-forward)");
267+
}
268+
}
269+
return 0;
270+
};
271+
await importAndRun();
272+
273+
// Should have attempted push twice (initial + after rebase)
274+
const pushCalls = state.execCalls.filter(
275+
([cmd, args]) =>
276+
cmd === "git" &&
277+
Array.isArray(args) &&
278+
args.includes("push"),
279+
);
280+
expect(pushCalls.length).toBe(2);
281+
282+
// Should have done a pull --rebase between pushes
283+
const rebasePull = state.execCalls.find(
284+
([cmd, args]) =>
285+
cmd === "git" &&
286+
Array.isArray(args) &&
287+
args.includes("pull") &&
288+
args.includes("--rebase"),
289+
);
290+
expect(rebasePull).toBeDefined();
291+
292+
// PR should still be created after successful retry
293+
expect(mockPullsCreate).toHaveBeenCalledTimes(1);
294+
});
295+
296+
it("should comment on PR when push and rebase both fail", async () => {
297+
setupMocks({ hasChanges: true, existingPRNumber: 42 });
298+
state.execImpl = async (
299+
cmd: string,
300+
args?: string[],
301+
): Promise<number> => {
302+
if (
303+
cmd === "git" &&
304+
Array.isArray(args) &&
305+
(args.includes("push") ||
306+
(args.includes("pull") && args.includes("--rebase")))
307+
) {
308+
throw new Error("merge conflict");
309+
}
310+
return 0;
311+
};
312+
await importAndRun();
313+
314+
// Should NOT create a new PR
315+
expect(mockPullsCreate).not.toHaveBeenCalled();
316+
317+
// Should leave a comment on the existing PR
318+
expect(mockIssuesCreateComment).toHaveBeenCalledWith(
319+
expect.objectContaining({
320+
owner: "test-owner",
321+
repo: "test-repo",
322+
issue_number: 42,
323+
}),
324+
);
325+
326+
// Comment body should mention sync failed
327+
const commentCall = mockIssuesCreateComment.mock.calls[0][0];
328+
expect(commentCall.body).toContain("Sync failed");
329+
expect(commentCall.body).toContain("merge conflicts");
330+
});
331+
332+
it("should call setFailed when push fails and no existing PR to comment on", async () => {
333+
setupMocks({ hasChanges: true, existingPRNumber: null });
334+
state.execImpl = async (
335+
cmd: string,
336+
args?: string[],
337+
): Promise<number> => {
338+
if (
339+
cmd === "git" &&
340+
Array.isArray(args) &&
341+
(args.includes("push") ||
342+
(args.includes("pull") && args.includes("--rebase")))
343+
) {
344+
throw new Error("merge conflict");
345+
}
346+
return 0;
347+
};
348+
await importAndRun();
349+
350+
expect(mockPullsCreate).not.toHaveBeenCalled();
351+
expect(mockIssuesCreateComment).not.toHaveBeenCalled();
352+
expect(state.setFailedCalls).toEqual(
353+
expect.arrayContaining([
354+
expect.stringContaining("Failed to push changes"),
355+
]),
356+
);
357+
});
243358
});
244359

245360
describe("when auto_merge is true", () => {

src/sync.ts

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,17 @@ async function updateFromSourceSpec(
101101

102102
core.info(`Pushing changes to branch: ${branch}`);
103103

104-
await exec.exec(
105-
"git",
106-
["push", "--verbose", "origin", branch],
107-
{ silent: false },
104+
const pushSucceeded = await pushWithFallback(
105+
branch,
106+
owner,
107+
repo,
108+
octokit,
108109
);
109110

111+
if (!pushSucceeded) {
112+
return;
113+
}
114+
110115
if (!autoMerge) {
111116
const existingPRNumber = await prExists(
112117
owner,
@@ -496,6 +501,78 @@ async function pushChanges(
496501
}
497502
}
498503

504+
// Push with fallback: try regular push, then rebase, then force push, then comment on PR
505+
async function pushWithFallback(
506+
branchName: string,
507+
owner: string,
508+
repo: string,
509+
octokit: any,
510+
): Promise<boolean> {
511+
// Try regular push first
512+
try {
513+
await exec.exec(
514+
"git",
515+
["push", "--verbose", "origin", branchName],
516+
{ silent: false },
517+
);
518+
return true;
519+
} catch {
520+
core.info(
521+
`Regular push to '${branchName}' failed. Attempting to rebase on remote branch.`,
522+
);
523+
}
524+
525+
// Try pull --rebase then push
526+
try {
527+
await exec.exec("git", ["pull", "--rebase", "origin", branchName], {
528+
silent: false,
529+
});
530+
await exec.exec(
531+
"git",
532+
["push", "--verbose", "origin", branchName],
533+
{ silent: false },
534+
);
535+
core.info(
536+
`Successfully pushed to '${branchName}' after rebasing on remote changes.`,
537+
);
538+
return true;
539+
} catch {
540+
core.info(
541+
`Rebase failed (likely due to merge conflicts). Aborting rebase.`,
542+
);
543+
// Abort the rebase so the working tree is clean
544+
try {
545+
await exec.exec("git", ["rebase", "--abort"], { silent: true });
546+
} catch {
547+
// rebase --abort can fail if there's no rebase in progress, ignore
548+
}
549+
}
550+
551+
// Last resort: leave a comment on the existing PR
552+
const existingPRNumber = await prExists(owner, repo, branchName, octokit);
553+
if (existingPRNumber) {
554+
core.info(
555+
`Could not push to '${branchName}' due to conflicts. Leaving a comment on PR #${existingPRNumber}.`,
556+
);
557+
await octokit.rest.issues.createComment({
558+
owner,
559+
repo,
560+
issue_number: existingPRNumber,
561+
body:
562+
`⚠️ **Sync failed**: The latest \`fern api update\` detected changes, but they could not be pushed to this branch due to merge conflicts.\n\n` +
563+
`**To resolve**, either:\n` +
564+
`- Merge or close this PR so the next run creates a fresh one, or\n` +
565+
`- Manually rebase this branch on \`${github.context.ref.replace("refs/heads/", "")}\` and re-run the workflow.`,
566+
});
567+
} else {
568+
core.setFailed(
569+
`Failed to push changes to '${branchName}' and no existing PR was found to comment on.`,
570+
);
571+
}
572+
573+
return false;
574+
}
575+
499576
// Check if a PR exists for a branch
500577
async function prExists(
501578
owner: string,

0 commit comments

Comments
 (0)