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
10 changes: 10 additions & 0 deletions src/services/merge-failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ function isBaseBranchMovedMessage(message: string): boolean {
return /base branch was modified/i.test(message);
}

/** True for the transient "Merge already in progress" 405 (GITTENSORY-1K) — another merge request for the
* SAME PR (a manual click, a concurrent duplicate job) is already being processed by GitHub. Not a policy
* rejection: the in-flight merge either lands (making this retry a no-op once the PR is no longer open) or
* fails (making a retry the right move), so it resolves the same way isBaseBranchMovedMessage's TOCTOU race
* does — re-attempt rather than hold. */
function isMergeAlreadyInProgressMessage(message: string): boolean {
return /merge already in progress/i.test(message);
}

function isConvergenceForbiddenMessage(message: string): boolean {
return /resource not accessible by integration|secondary rate limit|api rate limit|abuse detection/i.test(message);
}
Expand All @@ -55,6 +64,7 @@ export function classifyMergeFailure(error: unknown): { terminal: boolean; reaso
// A 405 "Base branch was modified" is a benign TOCTOU race, not a policy rejection — retry against the new base
// (the executor caps retries at MERGE_RETRY_CAP before escalating to the same terminal hold).
if (status === 405 && isBaseBranchMovedMessage(message)) return { terminal: false, reason: `base branch moved during merge — retrying: ${message}` };
if (status === 405 && isMergeAlreadyInProgressMessage(message)) return { terminal: false, reason: `a merge for this PR was already in progress — retrying: ${message}` };
if (status === 405) return { terminal: true, reason: `merge not allowed (405 — repo merge policy forbids an automated merge): ${message}` };
if (status === 409) return { terminal: true, reason: `merge conflict / required check absent (409): ${message}` };
if (isMergeConflictMessage(message)) return { terminal: true, reason: `branch conflicts with base — contributor must rebase: ${message}` };
Expand Down
6 changes: 6 additions & 0 deletions test/unit/merge-failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ describe("classifyMergeFailure", () => {
expect(result.reason).toMatch(/base branch moved/i);
});

it("REGRESSION (#5003, GITTENSORY-1K): retries the transient 405 'Merge already in progress' race instead of holding it", () => {
const result = classifyMergeFailure(httpError(405, "Merge already in progress"));
expect(result.terminal).toBe(false);
expect(result.reason).toMatch(/already in progress/i);
});

it("still treats a policy 405 (required reviews/checks) as terminal", () => {
const result = classifyMergeFailure(httpError(405, "At least 1 approving review is required by reviewers with write access."));
expect(result.terminal).toBe(true);
Expand Down