Skip to content
This repository was archived by the owner on Jul 20, 2026. It is now read-only.
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
27 changes: 25 additions & 2 deletions lib/plugins/__tests__/github-approve-on-green.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ interface Captured {
approvePosts: Array<Record<string, unknown>>;
commentPosts: Array<Record<string, unknown>>;
mergePuts: Array<Record<string, unknown>>;
updateBranchPuts: Array<Record<string, unknown>>;
}

function ciCompletionPayload(): Record<string, unknown> {
Expand All @@ -105,7 +106,7 @@ function ciCompletionPayload(): Record<string, unknown> {
}

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" } });

Expand Down Expand Up @@ -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]));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<string, number> }).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.
Expand Down
44 changes: 41 additions & 3 deletions lib/plugins/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading