Skip to content

Commit 9b73169

Browse files
authored
fix(sweep): converge the re-gate sweep over all open PRs via an internal marker (#1282)
The scheduled re-gate sweep ordered open PRs by staleness keyed on the PullRequestRecord's updatedAt, which toPullRequestRecordFromRow resolves as `payload.updated_at ?? row.updatedAt` — GitHub's value. A review normally freshens a PR because its comment/label WRITE bumps GitHub's updated_at, but when agent actions are suppressed (dry-run, or a paused/permission-blocked repo) that write never happens, so the 25 stalest PRs stay pinned at the head of the sort forever and the sweep re-selects the SAME 25 every tick, never covering the rest of the queue (observed: 5x recompute of "25 stale; 3 flagged" during the metagraphed dry-run). Stamp an internal last_regated_at marker on every PR the sweep re-gates — a plain D1 UPDATE (markPullRequestRegated), NOT routed through the agent-action chokepoint, so it advances even when GitHub writes are suppressed. Key the sweep's sort on last_regated_at (falling back to createdAt, then epoch) instead of GitHub's updated_at, so a just-regated PR sorts freshest and the next sweep picks the next-stalest: full coverage of all open PRs in ceil(open/max) sweeps. GitHub's updated_at is kept ONLY as the "don't race an in-flight webhook" freshness guard, never as the sort key. The marker is omitted from the upsertPullRequestFromGitHub SET clause so a later sync cannot clobber it (mirrors approved_head_sha / merge_blocked_sha).
1 parent b4f52d8 commit 9b73169

9 files changed

Lines changed: 241 additions & 40 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
-- Sweep convergence: let the scheduled re-gate sweep advance through ALL open PRs.
2+
--
3+
-- BEFORE: selectRegateCandidates() sorts open PRs by staleness keyed on the PullRequestRecord's `updatedAt`,
4+
-- which toPullRequestRecordFromRow resolves as `payload.updated_at ?? row.updatedAt` — i.e. GitHub's value. A
5+
-- review normally freshens a PR because its comment/label WRITE bumps GitHub's updated_at (and fires a sync
6+
-- webhook). But when agent actions are SUPPRESSED (dry-run, or a paused/permission-blocked repo) that write
7+
-- never happens, so the 25 stalest PRs stay pinned at the head of the sort forever and the sweep re-selects the
8+
-- SAME 25 every tick, never covering the rest of the queue (observed: 5x recompute of "25 stale; 3 flagged").
9+
--
10+
-- AFTER: the sweep stamps an INTERNAL last_regated_at marker on every PR it re-gates (a plain D1 UPDATE, not a
11+
-- GitHub write — so it advances even when GitHub writes are suppressed). selectRegateCandidates keys the sort on
12+
-- last_regated_at instead of GitHub's updated_at, so a just-regated PR sorts freshest and the next sweep picks
13+
-- the next-stalest — full coverage of all open PRs in ceil(open/SWEEP_MAX_PRS) sweeps, convergent regardless of
14+
-- suppression. The GitHub-updatedAt freshness window is kept ONLY as the "don't race an in-flight webhook" guard.
15+
--
16+
-- last_regated_at is gittensory-computed (sweep-written), keyed to the PR (not the head SHA), and OMITTED from
17+
-- the upsertPullRequestFromGitHub SET clause so a later GitHub sync cannot clobber it. Mirrors approved_head_sha
18+
-- (0053) / merge_blocked_sha (0052). Nullable / no default → backward-compatible (existing rows = NULL = never
19+
-- swept = maximally stale = picked first).
20+
--
21+
-- pull_requests IS a Drizzle table (src/db/schema.ts), so this column is added to the Drizzle schema too; this
22+
-- raw migration is the production DDL applied by `wrangler d1 migrations apply` (drizzle-kit is not the runtime
23+
-- migrator here).
24+
ALTER TABLE pull_requests ADD COLUMN last_regated_at TEXT;

src/db/repositories.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2563,6 +2563,20 @@ export async function markPullRequestApproved(env: Env, fullName: string, number
25632563
.where(and(eq(pullRequests.repoFullName, fullName), eq(pullRequests.number, number), eq(pullRequests.headSha, headSha)));
25642564
}
25652565

2566+
/** Sweep convergence: stamp the timestamp the scheduled re-gate sweep just recomputed this PR. A plain D1 UPDATE
2567+
* — NOT routed through the agent-action-executor chokepoint (#1258) — so it advances even when GitHub writes are
2568+
* suppressed (dry-run / paused). selectRegateCandidates orders the sweep by last_regated_at, so a just-regated PR
2569+
* sorts freshest and the next sweep picks the next-stalest → the sweep converges over all open PRs. Keyed to the
2570+
* PR (not the head SHA): a re-gate stamps the PR regardless of which commit is live. */
2571+
export async function markPullRequestRegated(env: Env, fullName: string, number: number): Promise<void> {
2572+
const db = getDb(env.DB);
2573+
const now = nowIso();
2574+
await db
2575+
.update(pullRequests)
2576+
.set({ lastRegatedAt: now, updatedAt: now })
2577+
.where(and(eq(pullRequests.repoFullName, fullName), eq(pullRequests.number, number)));
2578+
}
2579+
25662580
export async function getIssue(env: Env, fullName: string, number: number): Promise<IssueRecord | null> {
25672581
const db = getDb(env.DB);
25682582
const [row] = await db.select().from(issues).where(and(eq(issues.repoFullName, fullName), eq(issues.number, number))).limit(1);
@@ -4010,6 +4024,8 @@ function toPullRequestRecordFromRow(row: typeof pullRequests.$inferSelect): Pull
40104024
mergeBlockedSha: row.mergeBlockedSha,
40114025
mergeBlockedReason: row.mergeBlockedReason,
40124026
approvedHeadSha: row.approvedHeadSha,
4027+
// Read straight from the row, NEVER the GitHub payload — this is a gittensory-internal sweep marker.
4028+
lastRegatedAt: row.lastRegatedAt,
40134029
};
40144030
}
40154031

src/db/schema.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,11 @@ export const pullRequests = sqliteTable(
288288
// new commit makes the bot re-approve the new code. gittensory-computed (executor-written), omitted from
289289
// the GitHub-sync SET clause so a later sync cannot clobber it. (Mirrors merge_blocked_sha.)
290290
approvedHeadSha: text("approved_head_sha"),
291+
// Sweep convergence: the timestamp the scheduled re-gate sweep last recomputed this PR. selectRegateCandidates
292+
// orders the sweep by THIS marker (not GitHub's updated_at) so it advances through all open PRs even when the
293+
// review WRITE that would bump updated_at is suppressed (dry-run / paused). gittensory-computed (sweep-written),
294+
// omitted from the GitHub-sync SET clause so a later sync cannot clobber it. (Mirrors approved_head_sha.)
295+
lastRegatedAt: text("last_regated_at"),
291296
createdAt: text("created_at").notNull().$defaultFn(() => nowIso()),
292297
updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()),
293298
},

src/queue/processors.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
markInstallationDeleted,
4040
markRepositoriesRemovedFromInstallation,
4141
persistAdvisory,
42+
markPullRequestRegated,
4243
recordAgentCommandFeedback,
4344
recordAuditEvent,
4445
recordGateBlockOutcome,
@@ -582,6 +583,13 @@ async function sweepRepoRegate(env: Env, repoFullName: string | undefined): Prom
582583
console.error(JSON.stringify({ level: "warn", event: "sweep_rereview_failed", deliveryId: `regate-sweep:${repoFullName}#${pr.number}`, repository: repoFullName, pullNumber: pr.number, error: errorMessage(error) }));
583584
});
584585
}
586+
// Stamp the internal re-gate marker so the next sweep advances to the next-stalest PRs. This is a plain D1
587+
// write (not a GitHub action), so it converges even when agent actions are suppressed — the dry-run/pause
588+
// non-convergence fix. (#audit-sweep-converge) Degrade quietly on a D1 error: the PR is simply re-selected
589+
// next sweep (the prior behaviour), never lost.
590+
await markPullRequestRegated(env, repoFullName, pr.number).catch((error) => {
591+
console.error(JSON.stringify({ level: "warn", event: "sweep_mark_regated_failed", repository: repoFullName, pullNumber: pr.number, error: errorMessage(error) }));
592+
});
585593
}
586594
await recordAuditEvent(env, {
587595
eventType: "agent.sweep.regate",

src/settings/agent-sweep.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,16 @@ export const SWEEP_MAX_PRS = 25;
1717
export const SWEEP_FRESHNESS_MS = 2 * 60 * 1000;
1818

1919
/**
20-
* Select the open PRs a single repo sweep should recompute: drop drafts and anything updated within
21-
* `freshnessWindowMs` of `now` (recently active → already gated), then take the `max` STALEST by `updatedAt`
22-
* ascending (a missing `updatedAt` sorts oldest — it has gone longest without a recorded refresh). Pure and
23-
* deterministic: same inputs → same ordered batch, which is what makes the sweep idempotent.
20+
* Select the open PRs a single repo sweep should recompute: drop drafts and anything a webhook touched within
21+
* `freshnessWindowMs` of `now` (don't race an in-flight review), then take the `max` PRs the sweep has gone
22+
* longest WITHOUT re-gating — ordered by `lastRegatedAt` ascending, NOT GitHub's `updatedAt`.
23+
*
24+
* Why two different timestamps (#audit-sweep-converge): the review WRITE that bumps GitHub's `updatedAt` is
25+
* SUPPRESSED under dry-run / pause, so ordering the sweep by `updatedAt` pins the stalest PRs forever and it
26+
* never advances. The sweep instead stamps its own `lastRegatedAt` marker on every pass (a D1 write, never
27+
* suppressed), so a just-regated PR sorts freshest and the next pass covers the next-stalest — full coverage of
28+
* all open PRs in ceil(open/max) sweeps. GitHub's `updatedAt` is used ONLY for the freshness skip (a PR a
29+
* webhook is actively gating), never for the sort. Pure + deterministic: same inputs → same ordered batch.
2430
*/
2531
export function selectRegateCandidates(input: {
2632
pulls: PullRequestRecord[];
@@ -32,17 +38,27 @@ export function selectRegateCandidates(input: {
3238
const max = input.max ?? SWEEP_MAX_PRS;
3339
const nowMs = Date.parse(input.now);
3440
const freshCutoff = Number.isFinite(nowMs) ? nowMs - freshnessWindowMs : Number.NaN;
35-
const staleness = (pr: PullRequestRecord): number => {
41+
// Don't-race-webhook guard: a PR whose GitHub `updatedAt` is within the window was almost certainly just gated
42+
// by its webhook. A missing/unparseable timestamp = not recently touched = eligible (epoch).
43+
const webhookFreshness = (pr: PullRequestRecord): number => {
3644
const updated = pr.updatedAt ? Date.parse(pr.updatedAt) : Number.NaN;
37-
// A missing/unparseable timestamp is treated as maximally stale (epoch) so it is never starved.
3845
return Number.isFinite(updated) ? updated : 0;
3946
};
47+
// Progress key: when the SWEEP last re-gated this PR. Falls back to createdAt, then epoch, so a never-regated
48+
// PR sorts maximally stale and is picked first; ties broken by PR number. This is the convergence key — it
49+
// advances on every sweep regardless of whether GitHub writes are suppressed.
50+
const regateProgress = (pr: PullRequestRecord): number => {
51+
const regated = pr.lastRegatedAt ? Date.parse(pr.lastRegatedAt) : Number.NaN;
52+
if (Number.isFinite(regated)) return regated;
53+
const created = pr.createdAt ? Date.parse(pr.createdAt) : Number.NaN;
54+
return Number.isFinite(created) ? created : 0;
55+
};
4056
return input.pulls
4157
.filter((pr) => pr.state === "open" && !pr.isDraft)
4258
.filter((pr) => {
4359
if (!Number.isFinite(freshCutoff)) return true;
44-
return staleness(pr) <= freshCutoff;
60+
return webhookFreshness(pr) <= freshCutoff;
4561
})
46-
.sort((a, b) => staleness(a) - staleness(b) || a.number - b.number)
62+
.sort((a, b) => regateProgress(a) - regateProgress(b) || a.number - b.number)
4763
.slice(0, Math.max(0, max));
4864
}

src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,11 @@ export type PullRequestRecord = {
422422
* disposition while approvedHeadSha === headSha (this commit is already approved by the bot); a new commit
423423
* clears the match so the bot may re-approve the new code. Mirrors mergeBlockedSha. */
424424
approvedHeadSha?: string | null | undefined;
425+
/** Sweep convergence: the timestamp the scheduled re-gate sweep last recomputed this PR. selectRegateCandidates
426+
* orders by this marker (not GitHub's updatedAt) so the sweep advances through all open PRs even when the
427+
* review write that would bump updatedAt is suppressed (dry-run / paused). Sweep-written; read straight from
428+
* the row (never the GitHub payload). */
429+
lastRegatedAt?: string | null | undefined;
425430
};
426431

427432
export type IssueRecord = {

test/unit/agent-sweep.test.ts

Lines changed: 96 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -18,55 +18,119 @@ function pr(overrides: Partial<PullRequestRecord> & { number: number }): PullReq
1818
}
1919

2020
describe("selectRegateCandidates (#777 re-gate sweep selection)", () => {
21-
it("drops PRs updated within the freshness window (recently gated by their webhook)", () => {
22-
const pulls = [pr({ number: 1, updatedAt: minutesAgo(1) }), pr({ number: 2, updatedAt: minutesAgo(120) })];
23-
const picked = selectRegateCandidates({ pulls, now: NOW });
24-
expect(picked.map((p) => p.number)).toEqual([2]); // #1 updated 1m ago is inside the 2-min freshness window
25-
});
21+
describe("don't-race-webhook freshness guard (GitHub updatedAt)", () => {
22+
it("drops PRs whose GitHub updatedAt is within the freshness window (a webhook is gating them)", () => {
23+
const pulls = [pr({ number: 1, updatedAt: minutesAgo(1) }), pr({ number: 2, updatedAt: minutesAgo(120) })];
24+
const picked = selectRegateCandidates({ pulls, now: NOW });
25+
expect(picked.map((p) => p.number)).toEqual([2]); // #1 updated 1m ago is inside the 2-min window
26+
});
2627

27-
it("orders the stalest first and bounds to max (rate-aware)", () => {
28-
const pulls = [
29-
pr({ number: 1, updatedAt: minutesAgo(120) }),
30-
pr({ number: 2, updatedAt: minutesAgo(600) }),
31-
pr({ number: 3, updatedAt: minutesAgo(300) }),
32-
];
33-
const picked = selectRegateCandidates({ pulls, now: NOW, max: 2 });
34-
expect(picked.map((p) => p.number)).toEqual([2, 3]); // stalest (600m), then 300m; 120m dropped by cap
28+
it("treats a missing updatedAt as NOT recently touched (eligible, never starved by the freshness guard)", () => {
29+
const pulls = [pr({ number: 1, updatedAt: minutesAgo(1) }), pr({ number: 2 })];
30+
const picked = selectRegateCandidates({ pulls, now: NOW });
31+
expect(picked.map((p) => p.number)).toEqual([2]); // #1 fresh → dropped; #2 has no updatedAt → eligible
32+
});
33+
34+
it("keeps a PR whose lastRegatedAt is old but whose updatedAt is fresh OUT (the guard wins over the sort key)", () => {
35+
const pulls = [pr({ number: 1, updatedAt: minutesAgo(1), lastRegatedAt: minutesAgo(999) })];
36+
const picked = selectRegateCandidates({ pulls, now: NOW });
37+
expect(picked.map((p) => p.number)).toEqual([]); // stalest by re-gate, but a webhook just touched it → skip
38+
});
39+
40+
it("live case: when updatedAt and lastRegatedAt move together, the PR is eligible once outside the window (not double-excluded)", () => {
41+
const pulls = [pr({ number: 1, updatedAt: minutesAgo(120), lastRegatedAt: minutesAgo(120) })];
42+
const picked = selectRegateCandidates({ pulls, now: NOW });
43+
expect(picked.map((p) => p.number)).toEqual([1]); // both old → freshness allows it, re-gate orders it
44+
});
45+
46+
it("keeps every open non-draft PR when `now` is unparseable (no freshness cutoff possible)", () => {
47+
const pulls = [pr({ number: 1, createdAt: minutesAgo(5) }), pr({ number: 2, createdAt: minutesAgo(600) }), pr({ number: 3, isDraft: true })];
48+
const picked = selectRegateCandidates({ pulls, now: "not-a-date", freshnessWindowMs: 30 * 60 * 1000 });
49+
expect(picked.map((p) => p.number)).toEqual([2, 1]); // drafts still excluded; both non-draft kept, stalest-created first
50+
});
3551
});
3652

37-
it("treats a missing updatedAt as maximally stale and never starves it", () => {
38-
const pulls = [pr({ number: 1, updatedAt: minutesAgo(120) }), pr({ number: 2 })];
39-
const picked = selectRegateCandidates({ pulls, now: NOW });
40-
expect(picked.map((p) => p.number)).toEqual([2, 1]); // no-timestamp PR sorts oldest
53+
describe("convergence sort key (lastRegatedAt, NOT GitHub updatedAt)", () => {
54+
it("INVARIANT arm (i): orders by lastRegatedAt ascending when present — the staler RE-GATE sorts first", () => {
55+
// #1 was re-gated recently but created long ago; #2 was re-gated long ago but created recently. The re-gate
56+
// marker — not createdAt — drives the order, so #2 (stalest re-gate) comes first.
57+
const pulls = [
58+
pr({ number: 1, lastRegatedAt: minutesAgo(10), createdAt: minutesAgo(1000) }),
59+
pr({ number: 2, lastRegatedAt: minutesAgo(100), createdAt: minutesAgo(1) }),
60+
];
61+
const picked = selectRegateCandidates({ pulls, now: NOW });
62+
expect(picked.map((p) => p.number)).toEqual([2, 1]);
63+
});
64+
65+
it("INVARIANT arm (ii): falls back to createdAt when lastRegatedAt is absent — oldest-created sorts first", () => {
66+
const pulls = [pr({ number: 1, createdAt: minutesAgo(10) }), pr({ number: 2, createdAt: minutesAgo(600) })];
67+
const picked = selectRegateCandidates({ pulls, now: NOW });
68+
expect(picked.map((p) => p.number)).toEqual([2, 1]); // no lastRegatedAt on either → createdAt orders them
69+
});
70+
71+
it("INVARIANT arm (iii): falls back to the epoch when both lastRegatedAt and createdAt are absent — tie broken by PR number", () => {
72+
const pulls = [pr({ number: 9 }), pr({ number: 4 }), pr({ number: 7 })];
73+
const picked = selectRegateCandidates({ pulls, now: NOW });
74+
expect(picked.map((p) => p.number)).toEqual([4, 7, 9]); // all epoch → deterministic number order
75+
});
76+
77+
it("a never-regated PR (lastRegatedAt absent) outranks a just-regated one — the property that makes the sweep converge", () => {
78+
const pulls = [
79+
pr({ number: 1, lastRegatedAt: minutesAgo(1), createdAt: minutesAgo(1000) }), // just re-gated → freshest
80+
pr({ number: 2, createdAt: minutesAgo(50) }), // never re-gated → its createdAt (50m) is staler than #1's re-gate (1m)
81+
];
82+
const picked = selectRegateCandidates({ pulls, now: NOW });
83+
expect(picked.map((p) => p.number)).toEqual([2, 1]);
84+
});
85+
86+
it("bounds the batch to max (rate-aware) after ordering by re-gate staleness", () => {
87+
const pulls = [
88+
pr({ number: 1, lastRegatedAt: minutesAgo(120) }),
89+
pr({ number: 2, lastRegatedAt: minutesAgo(600) }),
90+
pr({ number: 3, lastRegatedAt: minutesAgo(300) }),
91+
];
92+
const picked = selectRegateCandidates({ pulls, now: NOW, max: 2 });
93+
expect(picked.map((p) => p.number)).toEqual([2, 3]); // stalest re-gate (600m), then 300m; 120m dropped by cap
94+
});
4195
});
4296

4397
it("excludes drafts and non-open PRs", () => {
4498
const pulls = [
45-
pr({ number: 1, updatedAt: minutesAgo(120), isDraft: true }),
46-
pr({ number: 2, updatedAt: minutesAgo(120), state: "closed" }),
47-
pr({ number: 3, updatedAt: minutesAgo(120) }),
99+
pr({ number: 1, createdAt: minutesAgo(120), isDraft: true }),
100+
pr({ number: 2, createdAt: minutesAgo(120), state: "closed" }),
101+
pr({ number: 3, createdAt: minutesAgo(120) }),
48102
];
49103
const picked = selectRegateCandidates({ pulls, now: NOW });
50104
expect(picked.map((p) => p.number)).toEqual([3]);
51105
});
52106

53-
it("is deterministic: equal staleness breaks ties by PR number", () => {
54-
const ts = minutesAgo(200);
55-
const pulls = [pr({ number: 9, updatedAt: ts }), pr({ number: 4, updatedAt: ts }), pr({ number: 7, updatedAt: ts })];
56-
const picked = selectRegateCandidates({ pulls, now: NOW });
57-
expect(picked.map((p) => p.number)).toEqual([4, 7, 9]);
58-
});
59-
60-
it("keeps every open non-draft PR when `now` is unparseable (no freshness cutoff possible)", () => {
61-
const pulls = [pr({ number: 1, updatedAt: minutesAgo(5) }), pr({ number: 2, updatedAt: minutesAgo(600) }), pr({ number: 3, isDraft: true })];
62-
const picked = selectRegateCandidates({ pulls, now: "not-a-date", freshnessWindowMs: 30 * 60 * 1000 });
63-
expect(picked.map((p) => p.number)).toEqual([2, 1]); // drafts still excluded; both non-draft kept, stalest first
107+
it("REGRESSION (convergence): ceil(50/25)=2 sweeps with all GitHub writes suppressed cover ALL 50 open PRs, none re-selected before the rest are stamped", () => {
108+
// Simulate the dry-run / paused world: a re-gate stamps lastRegatedAt (a D1 write, never suppressed) but the
109+
// GitHub updatedAt is frozen. Without the fix the same 25 stalest would recur every sweep forever; with it,
110+
// two sweeps of 25 (the cap) cover all 50 distinct PRs exactly once — full coverage in ceil(open/max) sweeps.
111+
const pulls = Array.from({ length: 50 }, (_, i) => pr({ number: i + 1, createdAt: minutesAgo(1000 - i), updatedAt: minutesAgo(1000) }));
112+
const stampedAt = new Map<number, string>();
113+
const covered = new Set<number>();
114+
let sweepNow = nowMs;
115+
for (let sweep = 0; sweep < 2; sweep++) {
116+
sweepNow += 5 * 60 * 1000; // each sweep runs ~5 min later (outside the freshness window)
117+
const now = new Date(sweepNow).toISOString();
118+
const view = pulls.map((p) => ({ ...p, lastRegatedAt: stampedAt.get(p.number) ?? p.lastRegatedAt }));
119+
const picked = selectRegateCandidates({ pulls: view, now });
120+
expect(picked.length).toBe(SWEEP_MAX_PRS); // each sweep fills the cap until the queue is drained
121+
for (const p of picked) {
122+
expect(covered.has(p.number)).toBe(false); // never re-selected before all are stamped
123+
covered.add(p.number);
124+
stampedAt.set(p.number, now); // the sweep stamps lastRegatedAt = now
125+
}
126+
}
127+
expect(covered.size).toBe(50); // full coverage of every open PR
64128
});
65129

66130
it("defaults: freshness window is two minutes and the cap is 25", () => {
67131
expect(SWEEP_FRESHNESS_MS).toBe(2 * 60 * 1000);
68132
expect(SWEEP_MAX_PRS).toBe(25);
69-
const pulls = Array.from({ length: 40 }, (_, i) => pr({ number: i + 1, updatedAt: minutesAgo(120 + i) }));
133+
const pulls = Array.from({ length: 40 }, (_, i) => pr({ number: i + 1, createdAt: minutesAgo(120 + i) }));
70134
expect(selectRegateCandidates({ pulls, now: NOW })).toHaveLength(25);
71135
});
72136
});

0 commit comments

Comments
 (0)