Skip to content

Commit 4a45df4

Browse files
feat: capture detailed git stderr in PR comments and default branch to fern/openapi-sync
Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
1 parent 98821fd commit 4a45df4

4 files changed

Lines changed: 210 additions & 80 deletions

File tree

action.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ inputs:
99
required: true
1010
branch:
1111
description: 'Branch name to create or update'
12-
required: true
12+
required: false
13+
default: 'fern/openapi-sync'
1314
sources:
1415
description: 'JSON or YAML array of mappings (from source to destination) (only used when update_from_source is false)'
1516
required: false
@@ -26,4 +27,4 @@ runs:
2627
main: 'dist/index.js'
2728
branding:
2829
icon: 'refresh-cw'
29-
color: 'green'
30+
color: 'green'

dist/index.js

Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -39323,7 +39323,7 @@ const minimatch_1 = __nccwpck_require__(4501);
3932339323
async function run() {
3932439324
try {
3932539325
const token = core.getInput("token") || process.env.GITHUB_TOKEN;
39326-
const branch = core.getInput("branch", { required: true });
39326+
const branch = core.getInput("branch") || "fern/openapi-sync";
3932739327
const autoMerge = core.getBooleanInput("auto_merge");
3932839328
const updateFromSource = core.getBooleanInput("update_from_source");
3932939329
if (!token) {
@@ -39649,31 +39649,41 @@ async function pushWithFallback(branchName, owner, repo, octokit) {
3964939649
// Try pull --rebase then push
3965039650
let rebaseErrorMsg = null;
3965139651
let abortErrorMsg = null;
39652-
try {
39653-
await exec.exec("git", ["pull", "--rebase", "origin", branchName], {
39654-
silent: false,
39655-
});
39656-
await exec.exec("git", ["push", "--verbose", "origin", branchName], { silent: false });
39657-
core.info(`Successfully pushed to '${branchName}' after rebasing on remote changes.`);
39658-
return true;
39659-
}
39660-
catch (rebaseError) {
39661-
rebaseErrorMsg =
39662-
rebaseError instanceof Error
39663-
? rebaseError.message
39664-
: "Unknown error";
39665-
core.info(`Rebase failed (likely due to merge conflicts): ${rebaseErrorMsg}. Aborting rebase.`);
39666-
// Abort the rebase so the working tree is clean
39667-
try {
39668-
await exec.exec("git", ["rebase", "--abort"], { silent: true });
39669-
}
39670-
catch (abortError) {
39671-
abortErrorMsg =
39672-
abortError instanceof Error
39673-
? abortError.message
39674-
: "Unknown error";
39675-
core.info(`rebase --abort failed: ${abortErrorMsg}. The working tree may be in an unexpected state.`);
39652+
const rebaseResult = await exec.getExecOutput("git", ["pull", "--rebase", "origin", branchName], { ignoreReturnCode: true });
39653+
if (rebaseResult.exitCode === 0) {
39654+
const pushResult = await exec.getExecOutput("git", ["push", "--verbose", "origin", branchName], { ignoreReturnCode: true });
39655+
if (pushResult.exitCode === 0) {
39656+
core.info(`Successfully pushed to '${branchName}' after rebasing on remote changes.`);
39657+
return true;
3967639658
}
39659+
// Push after rebase failed
39660+
rebaseErrorMsg = [
39661+
pushResult.stderr.trim(),
39662+
pushResult.stdout.trim(),
39663+
]
39664+
.filter(Boolean)
39665+
.join("\n") || `git push failed with exit code ${pushResult.exitCode}`;
39666+
}
39667+
else {
39668+
// Rebase itself failed (merge conflicts)
39669+
rebaseErrorMsg = [
39670+
rebaseResult.stderr.trim(),
39671+
rebaseResult.stdout.trim(),
39672+
]
39673+
.filter(Boolean)
39674+
.join("\n") || `git pull --rebase failed with exit code ${rebaseResult.exitCode}`;
39675+
}
39676+
core.info(`Rebase failed (likely due to merge conflicts). Aborting rebase.`);
39677+
// Abort the rebase so the working tree is clean
39678+
const abortResult = await exec.getExecOutput("git", ["rebase", "--abort"], { ignoreReturnCode: true });
39679+
if (abortResult.exitCode !== 0) {
39680+
abortErrorMsg = [
39681+
abortResult.stderr.trim(),
39682+
abortResult.stdout.trim(),
39683+
]
39684+
.filter(Boolean)
39685+
.join("\n") || `rebase --abort failed with exit code ${abortResult.exitCode}`;
39686+
core.info(`rebase --abort failed: ${abortErrorMsg}. The working tree may be in an unexpected state.`);
3967739687
}
3967839688
// Last resort: leave a comment on the existing PR
3967939689
const existingPRNumber = await prExists(owner, repo, branchName, octokit);

src/sync.test.ts

Lines changed: 119 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ const state = {
1111
_cmd: string,
1212
_args?: string[],
1313
): Promise<number> => 0,
14-
getExecOutputImpl: async (): Promise<{
14+
getExecOutputImpl: async (
15+
_cmd: string,
16+
_args?: string[],
17+
): Promise<{
1518
stdout: string;
1619
stderr: string;
1720
exitCode: number;
@@ -64,7 +67,16 @@ vi.mock("@actions/exec", () => ({
6467
return state.execImpl(cmd, args);
6568
},
6669
),
67-
getExecOutput: vi.fn(async () => state.getExecOutputImpl()),
70+
getExecOutput: vi.fn(
71+
async (
72+
cmd: string,
73+
args?: string[],
74+
opts?: unknown,
75+
) => {
76+
state.execCalls.push([cmd, args, opts]);
77+
return state.getExecOutputImpl(cmd, args);
78+
},
79+
),
6880
}));
6981

7082
vi.mock("@actions/io", () => ({
@@ -105,7 +117,10 @@ function setupMocks({
105117

106118
// Setup exec implementations
107119
state.execImpl = async (_cmd: string, _args?: string[]) => 0;
108-
state.getExecOutputImpl = async () => ({
120+
state.getExecOutputImpl = async (
121+
_cmd: string,
122+
_args?: string[],
123+
) => ({
109124
stdout: hasChanges ? "M openapi/openapi.json\n" : "",
110125
stderr: "",
111126
exitCode: 0,
@@ -251,6 +266,7 @@ describe("updateFromSourceSpec", () => {
251266
it("should rebase and retry push when regular push fails", async () => {
252267
setupMocks({ hasChanges: true, existingPRNumber: null });
253268
let pushAttempt = 0;
269+
// First push via exec throws (regular push)
254270
state.execImpl = async (
255271
cmd: string,
256272
args?: string[],
@@ -267,18 +283,24 @@ describe("updateFromSourceSpec", () => {
267283
}
268284
return 0;
269285
};
270-
await importAndRun();
271-
272-
// Should have attempted push twice (initial + after rebase)
273-
const pushCalls = state.execCalls.filter(
274-
([cmd, args]) =>
286+
// Rebase + second push via getExecOutput succeed
287+
state.getExecOutputImpl = async (
288+
cmd: string,
289+
args?: string[],
290+
) => {
291+
// git status --porcelain returns changes
292+
if (
275293
cmd === "git" &&
276294
Array.isArray(args) &&
277-
args.includes("push"),
278-
);
279-
expect(pushCalls.length).toBe(2);
295+
args.includes("--porcelain")
296+
) {
297+
return { stdout: "M openapi/openapi.json\n", stderr: "", exitCode: 0 };
298+
}
299+
return { stdout: "", stderr: "", exitCode: 0 };
300+
};
301+
await importAndRun();
280302

281-
// Should have done a pull --rebase between pushes
303+
// Should have done a pull --rebase
282304
const rebasePull = state.execCalls.find(
283305
([cmd, args]) =>
284306
cmd === "git" &&
@@ -294,20 +316,47 @@ describe("updateFromSourceSpec", () => {
294316

295317
it("should comment on PR when push and rebase both fail", async () => {
296318
setupMocks({ hasChanges: true, existingPRNumber: 42 });
319+
// First push via exec throws
297320
state.execImpl = async (
298321
cmd: string,
299322
args?: string[],
300323
): Promise<number> => {
301324
if (
302325
cmd === "git" &&
303326
Array.isArray(args) &&
304-
(args.includes("push") ||
305-
(args.includes("pull") && args.includes("--rebase")))
327+
args.includes("push")
306328
) {
307-
throw new Error("merge conflict");
329+
throw new Error("rejected (non-fast-forward)");
308330
}
309331
return 0;
310332
};
333+
// Rebase via getExecOutput fails with detailed error
334+
state.getExecOutputImpl = async (
335+
cmd: string,
336+
args?: string[],
337+
) => {
338+
if (
339+
cmd === "git" &&
340+
Array.isArray(args) &&
341+
args.includes("--porcelain")
342+
) {
343+
return { stdout: "M openapi/openapi.json\n", stderr: "", exitCode: 0 };
344+
}
345+
if (
346+
cmd === "git" &&
347+
Array.isArray(args) &&
348+
args.includes("pull") &&
349+
args.includes("--rebase")
350+
) {
351+
return {
352+
stdout: "CONFLICT (content): Merge conflict in fern/openapi/openapi.json",
353+
stderr: "error: could not apply abc1234... Update API",
354+
exitCode: 1,
355+
};
356+
}
357+
// rebase --abort succeeds
358+
return { stdout: "", stderr: "", exitCode: 0 };
359+
};
311360
await importAndRun();
312361

313362
// Should NOT create a new PR
@@ -322,12 +371,13 @@ describe("updateFromSourceSpec", () => {
322371
}),
323372
);
324373

325-
// Comment body should mention sync failed and include error details
374+
// Comment body should mention sync failed and include detailed error
326375
const commentCall = mockIssuesCreateComment.mock.calls[0][0];
327376
expect(commentCall.body).toContain("Sync failed");
328377
expect(commentCall.body).toContain("merge conflicts");
329378
expect(commentCall.body).toContain("Rebase error:");
330-
expect(commentCall.body).toContain("merge conflict");
379+
expect(commentCall.body).toContain("CONFLICT (content)");
380+
expect(commentCall.body).toContain("could not apply");
331381

332382
// Action should still fail (not silently succeed)
333383
expect(state.setFailedCalls).toEqual(
@@ -339,20 +389,42 @@ describe("updateFromSourceSpec", () => {
339389

340390
it("should call setFailed when push fails and no existing PR to comment on", async () => {
341391
setupMocks({ hasChanges: true, existingPRNumber: null });
392+
// First push via exec throws
342393
state.execImpl = async (
343394
cmd: string,
344395
args?: string[],
345396
): Promise<number> => {
346397
if (
347398
cmd === "git" &&
348399
Array.isArray(args) &&
349-
(args.includes("push") ||
350-
(args.includes("pull") && args.includes("--rebase")))
400+
args.includes("push")
351401
) {
352-
throw new Error("merge conflict");
402+
throw new Error("rejected (non-fast-forward)");
353403
}
354404
return 0;
355405
};
406+
// Rebase via getExecOutput also fails
407+
state.getExecOutputImpl = async (
408+
cmd: string,
409+
args?: string[],
410+
) => {
411+
if (
412+
cmd === "git" &&
413+
Array.isArray(args) &&
414+
args.includes("--porcelain")
415+
) {
416+
return { stdout: "M openapi/openapi.json\n", stderr: "", exitCode: 0 };
417+
}
418+
if (
419+
cmd === "git" &&
420+
Array.isArray(args) &&
421+
args.includes("pull") &&
422+
args.includes("--rebase")
423+
) {
424+
return { stdout: "", stderr: "merge conflict", exitCode: 1 };
425+
}
426+
return { stdout: "", stderr: "", exitCode: 0 };
427+
};
356428
await importAndRun();
357429

358430
expect(mockPullsCreate).not.toHaveBeenCalled();
@@ -366,27 +438,49 @@ describe("updateFromSourceSpec", () => {
366438

367439
it("should include rebase abort error in PR comment when abort fails", async () => {
368440
setupMocks({ hasChanges: true, existingPRNumber: 42 });
441+
// First push via exec throws
369442
state.execImpl = async (
370443
cmd: string,
371444
args?: string[],
372445
): Promise<number> => {
373446
if (
374447
cmd === "git" &&
375448
Array.isArray(args) &&
376-
(args.includes("push") ||
377-
(args.includes("pull") && args.includes("--rebase")))
449+
args.includes("push")
378450
) {
379-
throw new Error("merge conflict");
451+
throw new Error("rejected (non-fast-forward)");
452+
}
453+
return 0;
454+
};
455+
// Rebase fails AND abort fails via getExecOutput
456+
state.getExecOutputImpl = async (
457+
cmd: string,
458+
args?: string[],
459+
) => {
460+
if (
461+
cmd === "git" &&
462+
Array.isArray(args) &&
463+
args.includes("--porcelain")
464+
) {
465+
return { stdout: "M openapi/openapi.json\n", stderr: "", exitCode: 0 };
466+
}
467+
if (
468+
cmd === "git" &&
469+
Array.isArray(args) &&
470+
args.includes("pull") &&
471+
args.includes("--rebase")
472+
) {
473+
return { stdout: "", stderr: "merge conflict", exitCode: 1 };
380474
}
381475
if (
382476
cmd === "git" &&
383477
Array.isArray(args) &&
384478
args.includes("rebase") &&
385479
args.includes("--abort")
386480
) {
387-
throw new Error("no rebase in progress");
481+
return { stdout: "", stderr: "no rebase in progress", exitCode: 1 };
388482
}
389-
return 0;
483+
return { stdout: "", stderr: "", exitCode: 0 };
390484
};
391485
await importAndRun();
392486

0 commit comments

Comments
 (0)