diff --git a/lib/plugins/__tests__/github-approve-on-green.test.ts b/lib/plugins/__tests__/github-approve-on-green.test.ts index 65887ac7..c390e10c 100644 --- a/lib/plugins/__tests__/github-approve-on-green.test.ts +++ b/lib/plugins/__tests__/github-approve-on-green.test.ts @@ -94,6 +94,7 @@ interface Captured { approvePosts: Array>; commentPosts: Array>; mergePuts: Array>; + updateBranchPuts: Array>; } function ciCompletionPayload(): Record { @@ -105,7 +106,7 @@ function ciCompletionPayload(): Record { } function stubFetch(opts: StubOpts): Captured { - const captured: Captured = { approvePosts: [], commentPosts: [], mergePuts: [] }; + const captured: Captured = { approvePosts: [], commentPosts: [], mergePuts: [], updateBranchPuts: [] }; const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); @@ -360,11 +361,13 @@ interface SweepPr { mergeStatus?: number; /** ISO created_at for the pulls listing (default: undefined — reads as "too young" for rescue). */ createdAt?: string; + /** mergeable_state for a single-PR GET (default "blocked"). */ + mergeableState?: string; } /** Stub GitHub REST for a reconciliation sweep over one repo's open PRs. */ function stubSweepFetch(prs: SweepPr[]): Captured { - const captured: Captured = { approvePosts: [], commentPosts: [], mergePuts: [] }; + const captured: Captured = { approvePosts: [], commentPosts: [], mergePuts: [], updateBranchPuts: [] }; const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); const byNum = new Map(prs.map((p) => [p.number, p])); @@ -405,6 +408,16 @@ function stubSweepFetch(prs: SweepPr[]): Captured { captured.approvePosts.push(JSON.parse(String(init?.body ?? "{}"))); return json({ id: 999 }); } + // update-behind (fifth stall mode): PUT /pulls/{n}/update-branch + if (url.match(/\/pulls\/\d+\/update-branch$/) && method === "PUT") { + captured.updateBranchPuts.push({ url, ...JSON.parse(String(init?.body ?? "{}")) }); + return json({ message: "Updating pull request branch." }, 202); + } + // single-PR GET (mergeable_state probe on a 405 refusal) + if (url.match(/\/pulls\/\d+$/) && method === "GET") { + const p = byNumFromUrl(url); + return json({ number: p?.number, mergeable_state: p?.mergeableState ?? "blocked" }); + } // merge-completion backstop (ws-5sc): PUT /pulls/{n}/merge if (url.match(/\/pulls\/\d+\/merge$/) && method === "PUT") { const p = byNumFromUrl(url); @@ -584,6 +597,16 @@ describe("_reconcileApproveOnGreen — level-triggered backstop (#879)", () => { expect(dispatched.length).toBe(0); }); + test("405 on a BEHIND branch → sweep updates the branch (expected head SHA) and drops the watch", async () => { + const behind = { ...approvedGreenPr(913, "bhd0913", "main"), mergeStatus: 405, mergeableState: "behind" }; + const { captured, plugin } = await runSweepWithPlugin([behind], { seedWatch: { key: `${OWNER}/${REPO}#913@bhd0913`, ageMs: 10 * 60_000 } }); + expect(captured.mergePuts.length).toBe(1); // merge attempted first + expect(captured.updateBranchPuts.length).toBe(1); + expect(captured.updateBranchPuts[0]!.expected_head_sha).toBe("bhd0913"); + const watch = (plugin as unknown as { mergeCompletionFirstSeen: Map }).mergeCompletionFirstSeen; + expect(watch.size).toBe(0); // head will change — stale watch dropped + }); + test("covers a reviewed-but-UNREGISTERED repo (the approve-on-green gap fix)", async () => { // Registry is empty (repo not tagged / not in EXPLICIT), but Quinn reviewed // it this process → the sweep must still cover it via reviewedRepoCoords. diff --git a/lib/plugins/github.ts b/lib/plugins/github.ts index 73aa750f..e01854a2 100644 --- a/lib/plugins/github.ts +++ b/lib/plugins/github.ts @@ -1613,9 +1613,47 @@ export class GitHubPlugin implements Plugin { if (!res.ok) { const body = await res.text(); // 409 = head moved since we started watching — the watch is stale, - // drop it (a fresh approve cycle re-evaluates the new SHA). Any other - // refusal (405 not-mergeable, 403) keeps the watch for the next sweep. - if (res.status === 409) this.mergeCompletionFirstSeen.delete(key); + // drop it (a fresh approve cycle re-evaluates the new SHA). + if (res.status === 409) { + this.mergeCompletionFirstSeen.delete(key); + log.warn(`merge-completion (${source}): PUT merge → 409 for ${key} (head moved): ${body.slice(0, 200)}`); + return false; + } + // 405 on a BEHIND branch = strict protection refusing an out-of-date + // head — the fifth stall mode: two PRs in flight, the first merge + // moves main under the second, and neither native auto-merge nor + // anything else updates the branch. Update it ourselves: the + // synchronize event runs CI + re-review on the fresh head and the + // normal promote→merge cycle converges. expected_head_sha guards the + // same push race as the merge call. Any other refusal keeps the + // watch for the next sweep. + if (res.status === 405) { + const detail = (await this._ghGet(getToken, owner, repo, `/repos/${owner}/${repo}/pulls/${number}`)) as + | { mergeable_state?: string } + | null; + if (detail?.mergeable_state === "behind") { + const upd = await withCircuitBreaker("github-api", () => + fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${number}/update-branch`, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + Accept: "application/vnd.github+json", + "User-Agent": "protoWorkstacean/1.0", + "X-GitHub-Api-Version": "2022-11-28", + }, + body: JSON.stringify({ expected_head_sha: headSha }), + }), + ); + this.mergeCompletionFirstSeen.delete(key); // head will change either way + if (upd.ok) { + log.info(`merge-completion (${source}): ${key} is behind main — branch updated, cycle re-runs on the fresh head`); + } else { + log.warn(`merge-completion (${source}): update-branch → ${upd.status} for ${key}: ${(await upd.text()).slice(0, 200)}`); + } + return false; + } + } log.warn(`merge-completion (${source}): PUT merge → ${res.status} for ${key}: ${body.slice(0, 200)}`); return false; }