Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions actions/setup/js/update_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,30 @@ const { generateHistoryUrl } = require("./generate_history_link.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { withRetry, isTransientError } = require("./error_recovery.cjs");

/**
* @param {unknown} error
* @returns {boolean}
*/
function isNonFatalUpdateBranchError(error) {
/** @type {number | undefined} */
let status;
if (typeof error === "object" && error !== null && "status" in error) {
status = /** @type {{status?: number}} */ (error).status;
}
if (status !== undefined && status !== 422) {
return false;
}

// GitHub update-branch API can return these 422 messages for benign conditions:
// - already up to date ("There are no new commits on the base branch")
// - cannot auto-update due to conflict ("merge conflict between base and head")
// These should not fail safe output processing.
const message = getErrorMessage(error).toLowerCase();
return (
message.includes("there are no new commits on the base branch") || message.includes("merge conflict between base and head")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The classifier allows errors without a status property (plain Error objects) to bypass the early-return guard and still match on message alone. In production the GitHub API always provides an HTTP status, so this is unlikely in practice.

A brief comment would prevent future readers from "fixing" it inadvertently:

// Errors without a `status` field (e.g. plain Error objects from tests or
// unexpected throws) also reach the message check. If the message matches a
// known benign condition they are treated as non-fatal.
const message = getErrorMessage(error).toLowerCase();

);
}

/**
* Execute the pull request update API call
* @param {any} github - GitHub API client
Expand Down Expand Up @@ -55,8 +79,13 @@ async function executePRUpdate(github, context, prNumber, updateData) {
`update pull request #${prNumber} branch from base`
);
} catch (error) {
core.warning(`Failed to update pull request #${prNumber} branch from base: ${getErrorMessage(error)}`);
throw error;
const errorMessage = getErrorMessage(error);
if (isNonFatalUpdateBranchError(error)) {
core.warning(`Failed to update pull request #${prNumber} branch from base (non-fatal): ${errorMessage}`);
} else {
core.warning(`Failed to update pull request #${prNumber} branch from base: ${errorMessage}`);
throw error;
}
}
}

Expand Down
32 changes: 32 additions & 0 deletions actions/setup/js/update_pull_request.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -847,4 +847,36 @@ describe("update_pull_request.cjs - update_branch behavior", () => {
expect(mockGithub.rest.pulls.updateBranch).toHaveBeenCalledTimes(1);
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to update pull request #100 branch from base"));
});

it("should treat no-new-commits updateBranch response as a non-fatal no-op", async () => {
mockGithub.rest.pulls.updateBranch.mockRejectedValueOnce(new Error("There are no new commits on the base branch."));

const handler = await updatePRModule.main({ update_branch: true });
const result = await handler({ pull_request_number: 100 });

expect(result.success).toBe(true);
expect(mockGithub.rest.pulls.updateBranch).toHaveBeenCalledTimes(1);
expect(mockGithub.rest.pulls.update).not.toHaveBeenCalled();
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("branch from base (non-fatal)"));
});

it("should continue title/body updates when updateBranch reports merge conflict", async () => {
mockGithub.rest.pulls.updateBranch.mockRejectedValueOnce(new Error("merge conflict between base and head"));

const handler = await updatePRModule.main({ update_branch: true });
const result = await handler({
pull_request_number: 100,
title: "Updated PR",
});

expect(result.success).toBe(true);
expect(mockGithub.rest.pulls.updateBranch).toHaveBeenCalledTimes(1);
expect(mockGithub.rest.pulls.update).toHaveBeenCalledWith({
owner: "testowner",
repo: "testrepo",
pull_number: 100,
title: "Updated PR",
});
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("branch from base (non-fatal)"));
});
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The two new tests cover the two known-benign 422 messages, but there's no test verifying that a different 422 message remains fatal. That boundary condition is the riskiest part of the classifier — without it, a future edit could accidentally widen the non-fatal set.

Consider adding:

it("should re-throw a 422 with an unrecognised message as fatal", async () => {
  const err = Object.assign(new Error("Unprocessable Entity: unknown reason"), { status: 422 });
  mockGithub.rest.pulls.updateBranch.mockRejectedValueOnce(err);

  const handler = await updatePRModule.main({ update_branch: true });
  const result = await handler({ pull_request_number: 100 });

  expect(result.success).toBe(false);
  expect(mockCore.warning).not.toHaveBeenCalledWith(expect.stringContaining("(non-fatal)"));
});

2 changes: 2 additions & 0 deletions docs/src/content/docs/reference/safe-outputs-pull-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ safe-outputs:

When `update-branch: true` is set, the handler calls the GitHub REST `pulls.updateBranch` API to merge the latest base branch changes into the PR branch before applying title or body updates. This requires `contents: write` permission; without it only `contents: read` is needed. The field can also be used alone (with `title: false` and `body: false`) to update the branch without changing the PR description.

If GitHub reports `There are no new commits on the base branch.` or `merge conflict between base and head`, the branch update is treated as best-effort: the workflow logs a warning and continues processing the safe output.

When using `target: "*"`, the agent must provide `pull_request_number` in the output to identify which pull request to update.

**Operation Types**: Same as `update-issue` (`append`, `prepend`, `replace`). Title updates always replace the existing title. Disable fields by setting to `false`.
Expand Down