diff --git a/src/automerge.ts b/src/automerge.ts index d67cff5..742650a 100644 --- a/src/automerge.ts +++ b/src/automerge.ts @@ -2,6 +2,7 @@ import type { CheckRun, CommitStatus, IssueEvent, MergeMethod, PullRequest, Repo export const AUTOMERGE_LABELS = new Set(["automerge", "tag: automerge"]); export const AUTOMERGE_GRACE_PERIOD_MS = 10_000; +export const MERGE_ATTEMPT_LIMIT = 3; export const MERGEABILITY_POLL_INTERVAL_MS = 1_000; export const MERGEABILITY_POLL_LIMIT = 10; @@ -67,6 +68,7 @@ export async function evaluatePullRequest( let pullRequest = await github.pullRequest(number); const labelObservedAt = now(); if (pullRequest.state !== "open" || pullRequest.draft || !hasAutomergeLabel(pullRequest)) return false; + let mergeAttempts = 0; let mergeabilityPolls = 0; for (;;) { @@ -112,7 +114,18 @@ export async function evaluatePullRequest( return false; } const merged = await github.merge(number, pullRequest.head.sha, method); - console.log(merged ? `Merged ${repository}#${number}` : `GitHub blocked merge of ${repository}#${number}`); - return merged; + if (merged) { + console.log(`Merged ${repository}#${number}`); + return true; + } + mergeAttempts += 1; + if (mergeAttempts >= MERGE_ATTEMPT_LIMIT) { + console.log(`GitHub blocked merge of ${repository}#${number}`); + return false; + } + console.log(`Retrying merge of ${repository}#${number}`); + await waitFor(MERGEABILITY_POLL_INTERVAL_MS); + pullRequest = await github.pullRequest(number); + if (pullRequest.state !== "open" || pullRequest.draft || !hasAutomergeLabel(pullRequest)) return false; } } diff --git a/tests/automerge.test.ts b/tests/automerge.test.ts index abfa34c..5450655 100644 --- a/tests/automerge.test.ts +++ b/tests/automerge.test.ts @@ -5,6 +5,7 @@ import { gracePeriodRemaining, hasAutomergeLabel, mergeMethod, + MERGE_ATTEMPT_LIMIT, MERGEABILITY_POLL_INTERVAL_MS, MERGEABILITY_POLL_LIMIT, type AutomergeGitHub, @@ -201,12 +202,45 @@ test("asks GitHub to merge after all actions finish", async () => { expect(github.merges).toEqual([{ number: 12, sha: "abc123", method: "squash" }]); }); -test("leaves required-condition enforcement to GitHub", async () => { +test("retries when another pull request merges first", async () => { + const github = new FakeGitHub(); + const mergeResults = [false, true]; + const waits: number[] = []; + let requests = 0; + github.pullRequest = async () => { + requests += 1; + return { ...github.pull, mergeable: requests === 2 ? null : true }; + }; + github.merge = async (number, sha, method) => { + github.merges.push({ number, sha, method }); + return mergeResults.shift() || false; + }; + + expect( + await evaluatePullRequest(github, 12, "owner/repository", { + wait: async (milliseconds) => { + waits.push(milliseconds); + }, + }), + ).toBe(true); + expect(waits).toEqual([MERGEABILITY_POLL_INTERVAL_MS, MERGEABILITY_POLL_INTERVAL_MS]); + expect(github.merges).toHaveLength(2); +}); + +test("stops retrying when GitHub continues to block the merge", async () => { const github = new FakeGitHub(); github.mergeResult = false; + const waits: number[] = []; - expect(await evaluatePullRequest(github, 12, "owner/repository")).toBe(false); - expect(github.merges).toHaveLength(1); + expect( + await evaluatePullRequest(github, 12, "owner/repository", { + wait: async (milliseconds) => { + waits.push(milliseconds); + }, + }), + ).toBe(false); + expect(waits).toEqual(Array(MERGE_ATTEMPT_LIMIT - 1).fill(MERGEABILITY_POLL_INTERVAL_MS)); + expect(github.merges).toHaveLength(MERGE_ATTEMPT_LIMIT); }); test.each([