CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

feat(pr-ledger): sweep open PRs whose head already reached their base #5623

MergedXSccantynz wants to mergefix/stale-merged-pr-sweepmainopened 20h ago3/3 tasks
4 changed files+238−33
Modifiedsrc/__tests__/pr-merge-detect.test.ts+30−1View fileUnifiedSplit
1111import { mkdtempSync, rmSync } from "fs";
1212import { tmpdir } from "os";
1313import { join } from "path";
14import { detectMergedPrsOnPush, __test } from "../lib/pr-merge-detect";
14import {
15 detectMergedPrsOnPush,
16 sweepAlreadyMergedPrs,
17 __test,
18} from "../lib/pr-merge-detect";
1519
1620const { isAncestor } = __test;
1721
131135 ).toBe(0);
132136 });
133137});
138
139describe("sweepAlreadyMergedPrs — the endpoint-merge blind spot", () => {
140 // The sweep exists because endpoint merges advance the base ref without a
141 // push, so the push-path detector never sees sibling PRs whose heads just
142 // became reachable (#5607 sat open 2 days that way). The git decision is
143 // pinned above via isAncestor; here we pin the guard rails: the sweep
144 // never throws and reports 0 when there is nothing to examine.
145
146 test("scoped to a repository with no open PRs → 0 flips, no throw", async () => {
147 expect(
148 await sweepAlreadyMergedPrs({
149 repositoryId: "00000000-0000-0000-0000-000000000000",
150 })
151 ).toBe(0);
152 });
153
154 test("maxPrs below 1 is clamped rather than exploding the query", async () => {
155 expect(
156 await sweepAlreadyMergedPrs({
157 repositoryId: "00000000-0000-0000-0000-000000000000",
158 maxPrs: 0,
159 })
160 ).toBe(0);
161 });
162});
Modifiedsrc/lib/autopilot.ts+19−0View fileUnifiedSplit
459459 }
460460 },
461461 },
462 {
463 name: "merged-pr-detect-sweep",
464 run: async () => {
465 // Flip open PRs whose head is already reachable from their base tip.
466 // Covers what the push-path detector can't see: server-side endpoint
467 // merges advance the base ref without a push, so sibling PRs whose
468 // content just landed stay "open" (the #5607 rot, 2026-09-02).
469 try {
470 const { sweepAlreadyMergedPrs } = await import("./pr-merge-detect");
471 const flipped = await sweepAlreadyMergedPrs();
472 console.log(
473 `[autopilot] merged-pr-detect-sweep: flipped=${flipped}`
474 );
475 } catch (err) {
476 console.error("[autopilot] merged-pr-detect-sweep: threw:", err);
477 throw err;
478 }
479 },
480 },
462481 {
463482 name: "stale-issue-sweep",
464483 run: async () => {
Modifiedsrc/lib/pr-merge-detect.ts+180−32View fileUnifiedSplit
2020
2121import { and, eq } from "drizzle-orm";
2222import { db } from "../db";
23import { pullRequests } from "../db/schema";
23import { pullRequests, repositories, users } from "../db/schema";
2424import { getRepoPath, resolveRef, gitExecTimeoutMs } from "../git/repository";
2525import { logActivity } from "./notify";
2626
5858 }
5959}
6060
61/**
62 * Guarded state flip shared by the push-path detector and the sweep.
63 * Re-asserts state="open" in the WHERE so a concurrent merge (endpoint,
64 * another push ref, or an overlapping sweep tick) can never double-flip.
65 * Returns true only when THIS call performed the flip.
66 */
67async function flipPrToMerged(opts: {
68 repositoryId: string;
69 prId: string;
70 prNumber: number;
71 baseBranch: string;
72 headBranch: string;
73 headSha: string;
74 baseSha: string;
75 via: "push-detection" | "sweep-detection";
76 pusherUserId?: string | null;
77}): Promise<boolean> {
78 const now = new Date();
79 const updated = await db
80 .update(pullRequests)
81 .set({
82 state: "merged",
83 mergedAt: now,
84 mergedBy: opts.pusherUserId || null,
85 updatedAt: now,
86 })
87 .where(
88 and(eq(pullRequests.id, opts.prId), eq(pullRequests.state, "open"))
89 )
90 .returning({ id: pullRequests.id });
91 if (updated.length === 0) return false;
92 void logActivity({
93 repositoryId: opts.repositoryId,
94 userId: opts.pusherUserId || null,
95 action: "pr_merged",
96 targetType: "pull_request",
97 targetId: opts.prId,
98 metadata: {
99 number: opts.prNumber,
100 via: opts.via,
101 baseBranch: opts.baseBranch,
102 headBranch: opts.headBranch,
103 headSha: opts.headSha,
104 baseSha: opts.baseSha,
105 },
106 });
107 return true;
108}
109
61110/**
62111 * Flip open PRs to "merged" when a push lands their head commits in their
63112 * base branch. Returns the number of PRs flipped (0 on any resolution
115164 if (!(await isAncestor(repoPath, headTip, ref.newSha))) continue;
116165
117166 try {
118 const now = new Date();
119 const updated = await db
120 .update(pullRequests)
121 .set({
122 state: "merged",
123 mergedAt: now,
124 mergedBy: opts.pusherUserId || null,
125 updatedAt: now,
126 })
127 // Re-assert state="open" so a concurrent merge (endpoint or another
128 // push ref) can't double-flip the same PR.
129 .where(
130 and(eq(pullRequests.id, pr.id), eq(pullRequests.state, "open"))
131 )
132 .returning({ id: pullRequests.id });
133 if (updated.length === 0) continue;
167 const didFlip = await flipPrToMerged({
168 repositoryId,
169 prId: pr.id,
170 prNumber: pr.number,
171 baseBranch,
172 headBranch: pr.headBranch,
173 headSha: headTip,
174 baseSha: ref.newSha,
175 via: "push-detection",
176 pusherUserId: opts.pusherUserId,
177 });
178 if (!didFlip) continue;
134179 flipped++;
135180 console.log(
136181 `[pr-merge-detect] ${owner}/${repo}#${pr.number}: head ${pr.headBranch} (${headTip.slice(0, 7)}) reached ${baseBranch} — marked merged`
137182 );
138 void logActivity({
139 repositoryId,
140 userId: opts.pusherUserId || null,
141 action: "pr_merged",
142 targetType: "pull_request",
143 targetId: pr.id,
144 metadata: {
145 number: pr.number,
146 via: "push-detection",
147 baseBranch,
148 headBranch: pr.headBranch,
149 headSha: headTip,
150 baseSha: ref.newSha,
151 },
152 });
153183 } catch {
154184 /* one PR failing must not stop the rest */
155185 }
159189 return flipped;
160190}
161191
192/** Hard cap on PRs examined per sweep tick (runaway protection). */
193const SWEEP_MAX_PRS_PER_TICK = 200;
194
195/**
196 * Sweep counterpart of `detectMergedPrsOnPush` — catches the case the
197 * push-path detector structurally cannot see.
198 *
199 * The push detector only fires inside the post-receive hook, but a
200 * server-side endpoint merge (pr-merge-gated.ts) advances the base branch
201 * ref WITHOUT a push, so post-receive never runs and any SIBLING open PR
202 * whose head just became reachable from the new base tip stays "open"
203 * forever. Founding case: #5607 sat open for 2 days (2026-08-31 →
204 * 2026-09-02) with its head an ancestor of main the whole time — "merge"
205 * on it then returned the existing main tip, pure ledger bookkeeping.
206 *
207 * Runs two ways:
208 * - every autopilot tick, unscoped, capped at SWEEP_MAX_PRS_PER_TICK;
209 * - fire-and-forget after each successful endpoint merge, scoped to that
210 * repository, so siblings flip immediately instead of within 5 min.
211 *
212 * Same safety posture as the push path: reachability only (never message
213 * parsing), a deleted head branch is left untouched, the flip re-asserts
214 * state="open", and no failure ever throws to the caller.
215 */
216export async function sweepAlreadyMergedPrs(
217 opts: { repositoryId?: string; maxPrs?: number } = {}
218): Promise<number> {
219 const cap = Math.max(1, opts.maxPrs ?? SWEEP_MAX_PRS_PER_TICK);
220
221 let rows: Array<{
222 prId: string;
223 prNumber: number;
224 baseBranch: string;
225 headBranch: string;
226 repositoryId: string;
227 repoName: string;
228 ownerName: string;
229 }> = [];
230 try {
231 rows = await db
232 .select({
233 prId: pullRequests.id,
234 prNumber: pullRequests.number,
235 baseBranch: pullRequests.baseBranch,
236 headBranch: pullRequests.headBranch,
237 repositoryId: repositories.id,
238 repoName: repositories.name,
239 ownerName: users.username,
240 })
241 .from(pullRequests)
242 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
243 .innerJoin(users, eq(repositories.ownerId, users.id))
244 .where(
245 opts.repositoryId
246 ? and(
247 eq(pullRequests.state, "open"),
248 eq(pullRequests.repositoryId, opts.repositoryId)
249 )
250 : eq(pullRequests.state, "open")
251 )
252 .limit(cap);
253 } catch {
254 return 0;
255 }
256
257 let flipped = 0;
258 // Base tips resolved once per (repo, branch) — most rows share a base.
259 const baseTipCache = new Map<string, string | null>();
260
261 for (const row of rows) {
262 try {
263 if (row.headBranch === row.baseBranch) continue;
264
265 const baseKey = `${row.repositoryId}:${row.baseBranch}`;
266 let baseTip = baseTipCache.get(baseKey);
267 if (baseTip === undefined) {
268 baseTip = await resolveRef(
269 row.ownerName,
270 row.repoName,
271 `refs/heads/${row.baseBranch}`
272 ).catch(() => null);
273 baseTipCache.set(baseKey, baseTip);
274 }
275 if (!baseTip) continue;
276
277 const headTip = await resolveRef(
278 row.ownerName,
279 row.repoName,
280 `refs/heads/${row.headBranch}`
281 ).catch(() => null);
282 if (!headTip) continue;
283
284 const repoPath = getRepoPath(row.ownerName, row.repoName);
285 if (!(await isAncestor(repoPath, headTip, baseTip))) continue;
286
287 const didFlip = await flipPrToMerged({
288 repositoryId: row.repositoryId,
289 prId: row.prId,
290 prNumber: row.prNumber,
291 baseBranch: row.baseBranch,
292 headBranch: row.headBranch,
293 headSha: headTip,
294 baseSha: baseTip,
295 via: "sweep-detection",
296 });
297 if (!didFlip) continue;
298 flipped++;
299 console.log(
300 `[pr-merge-detect] sweep: ${row.ownerName}/${row.repoName}#${row.prNumber}: head ${row.headBranch} (${headTip.slice(0, 7)}) already in ${row.baseBranch} — marked merged`
301 );
302 } catch {
303 /* one PR failing must not stop the rest */
304 }
305 }
306
307 return flipped;
308}
309
162310/** Test-only access to internals. */
163311export const __test = { isAncestor };
Modifiedsrc/lib/pr-merge-gated.ts+9−0View fileUnifiedSplit
448448 metadata: { source, sha: headSha },
449449 });
450450 void fireWebhooks(repoId, "pr", { action: "merged", number: pr.number });
451 // This merge advanced the base ref WITHOUT a push, so post-receive's
452 // detectMergedPrsOnPush never runs — sweep this repo now so any sibling
453 // open PR whose head just became reachable flips immediately instead of
454 // rotting until the next autopilot tick (or forever, pre-2026-09-02).
455 void import("./pr-merge-detect")
456 .then((m) => m.sweepAlreadyMergedPrs({ repositoryId: repoId }))
457 .catch((err) =>
458 console.warn("[pr-merge-detect] post-merge sweep failed:", err)
459 );
451460 void import("./push-workflow-sync").then((m) =>
452461 m.enqueuePushWorkflowsForBranchAdvance({
453462 owner,
454463
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts