Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

stale-sweep.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

stale-sweep.test.tsBlame537 lines · 1 contributor
534f04aClaude1/**
2 * Block M5 — Stale PR/issue sweeper tests.
3 *
4 * Covers the contract from the M5 spec:
5 * - shouldPokePr: true when >7d stale + no recent poke, false otherwise
6 * - shouldClosePr: true when poke is older than 14d
7 * - runStalePrSweepOnce happy path: pokes the right PRs
8 * - Idempotency: re-running with the same fixtures + a now-stamped
9 * `hasPokeWithin=true` flag results in zero side-effects
10 * - Stage-2 close fires when poke is older than 14d
11 * - `auto_close_stale_prs=false` skips the close phase
12 * - Per-tick cap is respected
13 * - findCandidates throwing returns a clean zero summary
14 * - Mirror tests for issues (30d/60d windows + auto_close_stale_issues)
15 *
16 * All DB-touching surfaces are dependency-injected so this file never
17 * hits Neon. Following the K3/L1 DI test pattern, NO `mock.module()` is
18 * used here — keeps the file pollution-free.
19 */
20
21import { describe, it, expect } from "bun:test";
22import {
23 STALE_PR_POKE_MARKER,
24 STALE_PR_CLOSE_MARKER,
25 STALE_ISSUE_POKE_MARKER,
26 STALE_ISSUE_CLOSE_MARKER,
27 STALE_PR_POKE_DAYS,
28 STALE_PR_CLOSE_DAYS,
29 STALE_ISSUE_POKE_DAYS,
30 STALE_ISSUE_CLOSE_DAYS,
31 shouldPokePr,
32 shouldClosePr,
33 shouldPokeIssue,
34 shouldCloseIssue,
35 runStalePrSweepOnce,
36 runStaleIssueSweepOnce,
37 type StalePrCandidate,
38 type StaleIssueCandidate,
39} from "../lib/stale-sweep";
40
41// ---------------------------------------------------------------------------
42// Fixtures
43// ---------------------------------------------------------------------------
44
45const NOW = new Date("2026-05-13T12:00:00Z");
46const MS_PER_DAY = 24 * 60 * 60 * 1000;
47const daysAgo = (n: number) => new Date(NOW.getTime() - n * MS_PER_DAY);
48
49function makePrCand(overrides: Partial<StalePrCandidate> = {}): StalePrCandidate {
50 return {
51 prId: "pr-1",
52 prNumber: 42,
53 repositoryId: "repo-1",
54 ownerUsername: "alice",
55 repoName: "demo",
56 authorUserId: "user-1",
57 updatedAt: daysAgo(10), // 10 days stale → past 7-day poke threshold
58 hasPokeWithin: false,
59 lastPokedAt: null,
60 autoCloseEnabled: true,
61 ...overrides,
62 };
63}
64
65function makeIssueCand(
66 overrides: Partial<StaleIssueCandidate> = {}
67): StaleIssueCandidate {
68 return {
69 issueId: "issue-1",
70 issueNumber: 7,
71 repositoryId: "repo-1",
72 ownerUsername: "alice",
73 repoName: "demo",
74 authorUserId: "user-1",
75 updatedAt: daysAgo(45), // 45 days → past 30-day issue poke threshold
76 hasPokeWithin: false,
77 lastPokedAt: null,
78 autoCloseEnabled: true,
79 ...overrides,
80 };
81}
82
83// ---------------------------------------------------------------------------
84// Constants — sanity-check the spec values are wired
85// ---------------------------------------------------------------------------
86
87describe("stale-sweep — constants", () => {
88 it("uses the documented poke + close thresholds", () => {
89 expect(STALE_PR_POKE_DAYS).toBe(7);
90 expect(STALE_PR_CLOSE_DAYS).toBe(14);
91 expect(STALE_ISSUE_POKE_DAYS).toBe(30);
92 expect(STALE_ISSUE_CLOSE_DAYS).toBe(60);
93 });
94
95 it("exposes the four versioned markers verbatim", () => {
96 expect(STALE_PR_POKE_MARKER).toBe("<!-- gluecron:stale-poke:v1 -->");
97 expect(STALE_PR_CLOSE_MARKER).toBe("<!-- gluecron:stale-close:v1 -->");
98 expect(STALE_ISSUE_POKE_MARKER).toBe(
99 "<!-- gluecron:stale-issue-poke:v1 -->"
100 );
101 expect(STALE_ISSUE_CLOSE_MARKER).toBe(
102 "<!-- gluecron:stale-issue-close:v1 -->"
103 );
104 });
105});
106
107// ---------------------------------------------------------------------------
108// Pure helpers — shouldPokePr / shouldClosePr
109// ---------------------------------------------------------------------------
110
111describe("shouldPokePr", () => {
112 it("returns true when PR is older than 7 days and no recent poke exists", () => {
113 expect(
114 shouldPokePr({ updatedAt: daysAgo(10), hasPokeWithin: false }, NOW)
115 ).toBe(true);
116 });
117
118 it("returns false when PR is younger than 7 days", () => {
119 expect(
120 shouldPokePr({ updatedAt: daysAgo(3), hasPokeWithin: false }, NOW)
121 ).toBe(false);
122 });
123
124 it("returns false when a poke already exists within the 7-day window", () => {
125 expect(
126 shouldPokePr({ updatedAt: daysAgo(10), hasPokeWithin: true }, NOW)
127 ).toBe(false);
128 });
129
130 it("returns true exactly at the 7-day boundary", () => {
131 expect(
132 shouldPokePr({ updatedAt: daysAgo(7), hasPokeWithin: false }, NOW)
133 ).toBe(true);
134 });
135});
136
137describe("shouldClosePr", () => {
138 it("returns true when the last poke is older than 14 days", () => {
139 expect(shouldClosePr({ lastPokedAt: daysAgo(15) }, NOW)).toBe(true);
140 });
141
142 it("returns false when the last poke is younger than 14 days", () => {
143 expect(shouldClosePr({ lastPokedAt: daysAgo(13) }, NOW)).toBe(false);
144 });
145
146 it("returns false when no poke has ever been posted", () => {
147 expect(shouldClosePr({ lastPokedAt: null }, NOW)).toBe(false);
148 });
149
150 it("returns true at exactly the 14-day boundary", () => {
151 expect(shouldClosePr({ lastPokedAt: daysAgo(14) }, NOW)).toBe(true);
152 });
153});
154
155describe("shouldPokeIssue + shouldCloseIssue", () => {
156 it("issue poke fires at 30+ days", () => {
157 expect(
158 shouldPokeIssue({ updatedAt: daysAgo(31), hasPokeWithin: false }, NOW)
159 ).toBe(true);
160 expect(
161 shouldPokeIssue({ updatedAt: daysAgo(29), hasPokeWithin: false }, NOW)
162 ).toBe(false);
163 });
164
165 it("issue close fires at 60+ days post-poke", () => {
166 expect(shouldCloseIssue({ lastPokedAt: daysAgo(61) }, NOW)).toBe(true);
167 expect(shouldCloseIssue({ lastPokedAt: daysAgo(59) }, NOW)).toBe(false);
168 });
169});
170
171// ---------------------------------------------------------------------------
172// runStalePrSweepOnce — happy path
173// ---------------------------------------------------------------------------
174
175describe("runStalePrSweepOnce — happy path", () => {
176 it("pokes every candidate that's past 7 days with no recent poke (3 in → 3 pokes)", async () => {
177 const candidates = [
178 makePrCand({ prId: "a" }),
179 makePrCand({ prId: "b" }),
180 makePrCand({ prId: "c" }),
181 ];
182 const poked: string[] = [];
183 const closed: string[] = [];
184 const summary = await runStalePrSweepOnce({
185 now: NOW,
186 findPrCandidates: async () => candidates,
187 pokePr: async (cand) => {
188 poked.push(cand.prId);
189 },
190 closePr: async (cand) => {
191 closed.push(cand.prId);
192 },
193 });
194 expect(poked).toEqual(["a", "b", "c"]);
195 expect(closed).toEqual([]);
196 expect(summary).toEqual({ poked: 3, closed: 0 });
197 });
198
199 it("skips PRs that are not stale (updated_at too recent)", async () => {
200 const candidates = [
201 makePrCand({ prId: "fresh", updatedAt: daysAgo(2) }),
202 makePrCand({ prId: "stale" }),
203 ];
204 const poked: string[] = [];
205 const summary = await runStalePrSweepOnce({
206 now: NOW,
207 findPrCandidates: async () => candidates,
208 pokePr: async (cand) => {
209 poked.push(cand.prId);
210 },
211 closePr: async () => {},
212 });
213 expect(poked).toEqual(["stale"]);
214 expect(summary).toEqual({ poked: 1, closed: 0 });
215 });
216});
217
218// ---------------------------------------------------------------------------
219// Idempotency — re-running with `hasPokeWithin=true` doesn't re-poke
220// ---------------------------------------------------------------------------
221
222describe("runStalePrSweepOnce — idempotency", () => {
223 it("does NOT re-poke a PR that already has a poke comment within the window", async () => {
224 // Simulate "we poked this 2 days ago" → finder reports hasPokeWithin=true
225 // and lastPokedAt 2d ago (so NOT yet close-eligible).
226 const already = makePrCand({
227 prId: "already-poked",
228 hasPokeWithin: true,
229 lastPokedAt: daysAgo(2),
230 });
231 let pokes = 0;
232 let closes = 0;
233 const summary = await runStalePrSweepOnce({
234 now: NOW,
235 findPrCandidates: async () => [already],
236 pokePr: async () => {
237 pokes += 1;
238 },
239 closePr: async () => {
240 closes += 1;
241 },
242 });
243 expect(pokes).toBe(0);
244 expect(closes).toBe(0);
245 expect(summary).toEqual({ poked: 0, closed: 0 });
246 });
247
248 it("re-running the same tick twice never double-pokes (state-machine round-trip)", async () => {
249 // First tick: cand has no poke → gets one. Second tick: cand has poke
250 // within window → skipped.
251 let firstTickPokes = 0;
252 let secondTickPokes = 0;
253
254 const initial = makePrCand({ prId: "X" });
255 const first = await runStalePrSweepOnce({
256 now: NOW,
257 findPrCandidates: async () => [initial],
258 pokePr: async () => {
259 firstTickPokes += 1;
260 },
261 closePr: async () => {},
262 });
263 expect(first).toEqual({ poked: 1, closed: 0 });
264 expect(firstTickPokes).toBe(1);
265
266 // Second tick: same PR but hasPokeWithin is now true.
267 const followup = makePrCand({
268 prId: "X",
269 hasPokeWithin: true,
270 lastPokedAt: NOW,
271 });
272 const second = await runStalePrSweepOnce({
273 now: NOW,
274 findPrCandidates: async () => [followup],
275 pokePr: async () => {
276 secondTickPokes += 1;
277 },
278 closePr: async () => {},
279 });
280 expect(second).toEqual({ poked: 0, closed: 0 });
281 expect(secondTickPokes).toBe(0);
282 });
283});
284
285// ---------------------------------------------------------------------------
286// Stage-2 close
287// ---------------------------------------------------------------------------
288
289describe("runStalePrSweepOnce — stage-2 close", () => {
290 it("auto-closes a PR whose poke is older than 14d when auto_close_stale_prs=true", async () => {
291 const ripe = makePrCand({
292 prId: "ripe",
293 lastPokedAt: daysAgo(15),
294 hasPokeWithin: false, // > 7d ago → not within window
295 autoCloseEnabled: true,
296 });
297 const closed: string[] = [];
298 const summary = await runStalePrSweepOnce({
299 now: NOW,
300 findPrCandidates: async () => [ripe],
301 pokePr: async () => {},
302 closePr: async (cand) => {
303 closed.push(cand.prId);
304 },
305 });
306 expect(closed).toEqual(["ripe"]);
307 expect(summary).toEqual({ poked: 0, closed: 1 });
308 });
309
310 it("SKIPS the close phase when auto_close_stale_prs=false (no close, no re-poke)", async () => {
311 const opted_out = makePrCand({
312 prId: "opt-out",
313 lastPokedAt: daysAgo(15),
314 hasPokeWithin: false,
315 autoCloseEnabled: false,
316 });
317 let pokes = 0;
318 let closes = 0;
319 const summary = await runStalePrSweepOnce({
320 now: NOW,
321 findPrCandidates: async () => [opted_out],
322 pokePr: async () => {
323 pokes += 1;
324 },
325 closePr: async () => {
326 closes += 1;
327 },
328 });
329 expect(pokes).toBe(0); // critical: must not re-poke either
330 expect(closes).toBe(0);
331 expect(summary).toEqual({ poked: 0, closed: 0 });
332 });
333
334 it("does NOT close until 14d have passed since the poke", async () => {
335 const tooSoon = makePrCand({
336 prId: "too-soon",
337 lastPokedAt: daysAgo(10),
338 hasPokeWithin: false,
339 autoCloseEnabled: true,
340 });
341 let closes = 0;
342 const summary = await runStalePrSweepOnce({
343 now: NOW,
344 findPrCandidates: async () => [tooSoon],
345 pokePr: async () => {},
346 closePr: async () => {
347 closes += 1;
348 },
349 });
350 expect(closes).toBe(0);
351 expect(summary.closed).toBe(0);
352 });
353});
354
355// ---------------------------------------------------------------------------
356// Per-tick cap + error isolation
357// ---------------------------------------------------------------------------
358
359describe("runStalePrSweepOnce — cap + isolation", () => {
360 it("respects an explicit cap argument across BOTH poke and close phases", async () => {
361 // 5 pokes + 5 closes available, cap=3 → first 3 actions only.
362 const cands = [
363 makePrCand({ prId: "p1" }),
364 makePrCand({ prId: "p2" }),
365 makePrCand({
366 prId: "c1",
367 lastPokedAt: daysAgo(20),
368 autoCloseEnabled: true,
369 }),
370 makePrCand({ prId: "p3" }),
371 makePrCand({
372 prId: "c2",
373 lastPokedAt: daysAgo(20),
374 autoCloseEnabled: true,
375 }),
376 ];
377 const acted: string[] = [];
378 const summary = await runStalePrSweepOnce({
379 now: NOW,
380 cap: 3,
381 findPrCandidates: async () => cands,
382 pokePr: async (c) => {
383 acted.push(`poke:${c.prId}`);
384 },
385 closePr: async (c) => {
386 acted.push(`close:${c.prId}`);
387 },
388 });
389 expect(acted.length).toBe(3);
390 expect(summary.poked + summary.closed).toBe(3);
391 });
392
393 it("isolates per-PR failures — a throwing pokePr doesn't stop later PRs", async () => {
394 const cands = [
395 makePrCand({ prId: "first" }),
396 makePrCand({ prId: "second" }),
397 ];
398 const ids: string[] = [];
399 const summary = await runStalePrSweepOnce({
400 now: NOW,
401 findPrCandidates: async () => cands,
402 pokePr: async (c) => {
403 if (c.prId === "first") throw new Error("kaboom");
404 ids.push(c.prId);
405 },
406 closePr: async () => {},
407 });
408 expect(ids).toEqual(["second"]);
409 expect(summary.poked).toBe(1);
410 });
411
412 it("returns a zero summary when findCandidates throws (never propagates)", async () => {
413 const summary = await runStalePrSweepOnce({
414 now: NOW,
415 findPrCandidates: async () => {
416 throw new Error("db down");
417 },
418 pokePr: async () => {},
419 closePr: async () => {},
420 });
421 expect(summary).toEqual({ poked: 0, closed: 0 });
422 });
423});
424
425// ---------------------------------------------------------------------------
426// Mirror tests for issues
427// ---------------------------------------------------------------------------
428
429describe("runStaleIssueSweepOnce — pokes + closes", () => {
430 it("pokes issues stale 30+ days with no recent poke", async () => {
431 const cands = [makeIssueCand({ issueId: "i1" })];
432 const poked: string[] = [];
433 const summary = await runStaleIssueSweepOnce({
434 now: NOW,
435 findIssueCandidates: async () => cands,
436 pokeIssue: async (c) => {
437 poked.push(c.issueId);
438 },
439 closeIssue: async () => {},
440 });
441 expect(poked).toEqual(["i1"]);
442 expect(summary).toEqual({ poked: 1, closed: 0 });
443 });
444
445 it("does NOT re-poke an issue with hasPokeWithin=true (idempotent)", async () => {
446 const cands = [
447 makeIssueCand({
448 issueId: "i-already",
449 hasPokeWithin: true,
450 lastPokedAt: daysAgo(2),
451 }),
452 ];
453 let pokes = 0;
454 const summary = await runStaleIssueSweepOnce({
455 now: NOW,
456 findIssueCandidates: async () => cands,
457 pokeIssue: async () => {
458 pokes += 1;
459 },
460 closeIssue: async () => {},
461 });
462 expect(pokes).toBe(0);
463 expect(summary).toEqual({ poked: 0, closed: 0 });
464 });
465
466 it("auto-closes an issue whose poke is older than 60d when auto_close_stale_issues=true", async () => {
467 const ripe = makeIssueCand({
468 issueId: "ripe",
469 lastPokedAt: daysAgo(61),
470 hasPokeWithin: false,
471 autoCloseEnabled: true,
472 });
473 const closed: string[] = [];
474 const summary = await runStaleIssueSweepOnce({
475 now: NOW,
476 findIssueCandidates: async () => [ripe],
477 pokeIssue: async () => {},
478 closeIssue: async (c) => {
479 closed.push(c.issueId);
480 },
481 });
482 expect(closed).toEqual(["ripe"]);
483 expect(summary).toEqual({ poked: 0, closed: 1 });
484 });
485
486 it("SKIPS issue close phase when auto_close_stale_issues=false", async () => {
487 const opted = makeIssueCand({
488 lastPokedAt: daysAgo(61),
489 autoCloseEnabled: false,
490 });
491 let closes = 0;
492 let pokes = 0;
493 const summary = await runStaleIssueSweepOnce({
494 now: NOW,
495 findIssueCandidates: async () => [opted],
496 pokeIssue: async () => {
497 pokes += 1;
498 },
499 closeIssue: async () => {
500 closes += 1;
501 },
502 });
503 expect(pokes).toBe(0);
504 expect(closes).toBe(0);
505 expect(summary).toEqual({ poked: 0, closed: 0 });
506 });
507
508 it("does NOT close an issue until 60d have passed since the poke", async () => {
509 const tooSoon = makeIssueCand({
510 lastPokedAt: daysAgo(45),
511 autoCloseEnabled: true,
512 });
513 let closes = 0;
514 const summary = await runStaleIssueSweepOnce({
515 now: NOW,
516 findIssueCandidates: async () => [tooSoon],
517 pokeIssue: async () => {},
518 closeIssue: async () => {
519 closes += 1;
520 },
521 });
522 expect(closes).toBe(0);
523 expect(summary.closed).toBe(0);
524 });
525
526 it("returns zero summary when findCandidates throws", async () => {
527 const summary = await runStaleIssueSweepOnce({
528 now: NOW,
529 findIssueCandidates: async () => {
530 throw new Error("db down");
531 },
532 pokeIssue: async () => {},
533 closeIssue: async () => {},
534 });
535 expect(summary).toEqual({ poked: 0, closed: 0 });
536 });
537});