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.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.tsBlame671 lines · 1 contributor
534f04aClaude1/**
2 * Block M5 — Stale PR/issue sweeper.
3 *
4 * Two-stage gate: the autopilot poke-then-close pipeline.
5 *
6 * Stage 1 (poke):
7 * - Pull-requests: open, non-draft, `updated_at` older than 7 days, AND no
8 * `pr_comments` row carrying `STALE_PR_POKE_MARKER` posted in the last
9 * 7 days → post a polite poke comment + `notify()` the author with
10 * `kind: "pr_stale"` + audit `pr.stale_poked`.
11 * - Issues: open, `updated_at` older than 30 days, AND no
12 * `issue_comments` row carrying `STALE_ISSUE_POKE_MARKER` in the last
13 * 30 days → post a polite poke comment + `notify()` the author with
14 * `kind: "issue_stale"` + audit `issue.stale_poked`.
15 *
16 * Stage 2 (close):
17 * - Pull-requests: a poke older than 14 days with no human reply since
18 * → AUTO-CLOSE the PR, post the final close marker comment, audit
19 * `pr.stale_closed`. Skipped when `repositories.auto_close_stale_prs=false`.
20 * - Issues: a poke older than 60 days with no human reply since
21 * → AUTO-CLOSE the issue, post the final close marker comment, audit
22 * `issue.stale_closed`. Skipped when `repositories.auto_close_stale_issues=false`.
23 *
24 * Idempotency is provided by the HTML-comment markers — a re-run of the
25 * same tick will never re-poke or double-close.
26 *
27 * Every DB call is dependency-injected so the test suite can exercise the
28 * loop without touching Neon. The default orchestrators wire real Drizzle
29 * helpers; the test suite supplies fakes.
30 *
31 * Nothing here throws. Every per-PR / per-issue branch is wrapped in
32 * try/catch so one bad row never wedges the tick.
33 */
34
35import { and, desc, eq, lt, sql } from "drizzle-orm";
36import { db } from "../db";
37import {
38 issueComments,
39 issues,
40 prComments,
41 pullRequests,
42 repositories,
43} from "../db/schema";
44import { audit, notify } from "./notify";
a7460bfClaude45import { getBotUserIdOrFallback } from "./bot-user";
534f04aClaude46
47// ---------------------------------------------------------------------------
48// Marker constants — stable HTML comments. Versioned so a v2 contract can
49// re-sweep old rows by minting a fresh marker string.
50// ---------------------------------------------------------------------------
51
52export const STALE_PR_POKE_MARKER = "<!-- gluecron:stale-poke:v1 -->";
53export const STALE_PR_CLOSE_MARKER = "<!-- gluecron:stale-close:v1 -->";
54export const STALE_ISSUE_POKE_MARKER =
55 "<!-- gluecron:stale-issue-poke:v1 -->";
56export const STALE_ISSUE_CLOSE_MARKER =
57 "<!-- gluecron:stale-issue-close:v1 -->";
58
59// ---------------------------------------------------------------------------
60// Time windows
61// ---------------------------------------------------------------------------
62
63const MS_PER_DAY = 24 * 60 * 60 * 1000;
64
65/** Poke threshold for pull requests — "no activity for N days." */
66export const STALE_PR_POKE_DAYS = 7;
67/** Close threshold after the stage-1 poke for pull requests. */
68export const STALE_PR_CLOSE_DAYS = 14;
69/** Poke threshold for issues. */
70export const STALE_ISSUE_POKE_DAYS = 30;
71/** Close threshold after the stage-1 poke for issues. */
72export const STALE_ISSUE_CLOSE_DAYS = 60;
73
74/** Per-tick cap on number of pokes/closes — spec says 30. */
75export const STALE_DEFAULT_CAP = 30;
76
77// ---------------------------------------------------------------------------
78// Comment bodies
79// ---------------------------------------------------------------------------
80
81const PR_POKE_BODY = `${STALE_PR_POKE_MARKER}
82## This PR has gone quiet
83No activity for 7+ days. Is this still in progress?
84- **Author**: keep working / mark draft / close.
85- **Reviewers**: review or unassign yourself.
86- **Maintainers**: close if no longer relevant.`;
87
88const PR_CLOSE_BODY = `${STALE_PR_CLOSE_MARKER}
89Closed as stale — please reopen if still relevant.`;
90
91const ISSUE_POKE_BODY = `${STALE_ISSUE_POKE_MARKER}
92## This issue has gone quiet
93Still relevant? No activity for 30+ days. Close if not, or comment to keep open.`;
94
95const ISSUE_CLOSE_BODY = `${STALE_ISSUE_CLOSE_MARKER}
96Closed as stale — please reopen if still relevant.`;
97
98// ---------------------------------------------------------------------------
99// Pure helpers — exported so tests can hit them directly.
100// ---------------------------------------------------------------------------
101
102/**
103 * True iff the PR is past its poke threshold AND we haven't poked it
104 * within the same window. `hasPokeWithin` is the result of "is there a
105 * comment carrying STALE_PR_POKE_MARKER newer than `now - 7d`?".
106 */
107export function shouldPokePr(
108 pr: { updatedAt: Date; hasPokeWithin: boolean },
109 now: Date
110): boolean {
111 if (pr.hasPokeWithin) return false;
112 const ageMs = now.getTime() - new Date(pr.updatedAt).getTime();
113 return ageMs >= STALE_PR_POKE_DAYS * MS_PER_DAY;
114}
115
116/**
117 * True iff the most recent poke is older than the close threshold and
118 * nothing human-shaped has happened since. The caller supplies
119 * `lastPokedAt` (most-recent poke comment's createdAt); a null means "no
120 * poke posted yet" → cannot close.
121 */
122export function shouldClosePr(
123 pr: { lastPokedAt: Date | null },
124 now: Date
125): boolean {
126 if (!pr.lastPokedAt) return false;
127 const ageMs = now.getTime() - new Date(pr.lastPokedAt).getTime();
128 return ageMs >= STALE_PR_CLOSE_DAYS * MS_PER_DAY;
129}
130
131/** Issue-flavoured mirror of `shouldPokePr`. */
132export function shouldPokeIssue(
133 i: { updatedAt: Date; hasPokeWithin: boolean },
134 now: Date
135): boolean {
136 if (i.hasPokeWithin) return false;
137 const ageMs = now.getTime() - new Date(i.updatedAt).getTime();
138 return ageMs >= STALE_ISSUE_POKE_DAYS * MS_PER_DAY;
139}
140
141/** Issue-flavoured mirror of `shouldClosePr`. */
142export function shouldCloseIssue(
143 i: { lastPokedAt: Date | null },
144 now: Date
145): boolean {
146 if (!i.lastPokedAt) return false;
147 const ageMs = now.getTime() - new Date(i.lastPokedAt).getTime();
148 return ageMs >= STALE_ISSUE_CLOSE_DAYS * MS_PER_DAY;
149}
150
151// ---------------------------------------------------------------------------
152// Candidate shapes — what the orchestrators receive from the finder.
153// ---------------------------------------------------------------------------
154
155export interface StalePrCandidate {
156 prId: string;
157 prNumber: number;
158 repositoryId: string;
159 ownerUsername: string | null;
160 repoName: string;
161 authorUserId: string;
162 updatedAt: Date;
163 /** Whether a poke comment exists within the past poke-window. */
164 hasPokeWithin: boolean;
165 /** Most recent poke comment's createdAt (any age), or null. */
166 lastPokedAt: Date | null;
167 /** Whether the repo currently has `auto_close_stale_prs=true`. */
168 autoCloseEnabled: boolean;
169}
170
171export interface StaleIssueCandidate {
172 issueId: string;
173 issueNumber: number;
174 repositoryId: string;
175 ownerUsername: string | null;
176 repoName: string;
177 authorUserId: string;
178 updatedAt: Date;
179 hasPokeWithin: boolean;
180 lastPokedAt: Date | null;
181 autoCloseEnabled: boolean;
182}
183
184// ---------------------------------------------------------------------------
185// DI shapes — what tests can swap.
186// ---------------------------------------------------------------------------
187
188export interface StaleSweepDeps {
189 /** Wall-clock override. */
190 now?: Date;
191 /** Per-tick poke/close cap. */
192 cap?: number;
193 /** Inject a candidate-finder for the PR sweep. */
194 findPrCandidates?: (
195 now: Date,
196 cap: number
197 ) => Promise<StalePrCandidate[]>;
198 /** Inject the poke-side-effect. */
199 pokePr?: (cand: StalePrCandidate) => Promise<void>;
200 /** Inject the close-side-effect. */
201 closePr?: (cand: StalePrCandidate) => Promise<void>;
202}
203
204export interface StaleIssueSweepDeps {
205 now?: Date;
206 cap?: number;
207 findIssueCandidates?: (
208 now: Date,
209 cap: number
210 ) => Promise<StaleIssueCandidate[]>;
211 pokeIssue?: (cand: StaleIssueCandidate) => Promise<void>;
212 closeIssue?: (cand: StaleIssueCandidate) => Promise<void>;
213}
214
215export interface StaleSweepSummary {
216 poked: number;
217 closed: number;
218}
219
220// ---------------------------------------------------------------------------
221// Default DB-backed finders + side-effects.
222// ---------------------------------------------------------------------------
223
224/**
225 * Default PR candidate-finder. Selects open non-draft PRs whose
226 * `updatedAt` is older than the close threshold (the wider window — so
227 * we surface BOTH stage-1 pokes AND stage-2 closes in one pass), then
228 * joins to repo to fetch the `auto_close_stale_prs` flag and owner
229 * username, and joins to the latest poke comment via a correlated
230 * sub-select. Caller-side `shouldPokePr`/`shouldClosePr` decide stages.
231 *
232 * Archived repos are excluded — autopilot never touches them.
233 */
234async function defaultFindStalePrCandidates(
235 now: Date,
236 cap: number
237): Promise<StalePrCandidate[]> {
238 // We look back to the poke threshold (7 days) so that stage-1 candidates
239 // surface. Stage-2 close candidates have a poke comment, so they appear
240 // here too as long as the PR's `updatedAt` was set when the poke posted.
241 const cutoff = new Date(now.getTime() - STALE_PR_POKE_DAYS * MS_PER_DAY);
242 const pokeFreshCutoff = new Date(
243 now.getTime() - STALE_PR_POKE_DAYS * MS_PER_DAY
244 );
245 try {
246 const rows = await db
247 .select({
248 prId: pullRequests.id,
249 prNumber: pullRequests.number,
250 repositoryId: pullRequests.repositoryId,
251 authorUserId: pullRequests.authorId,
252 updatedAt: pullRequests.updatedAt,
253 autoCloseEnabled: repositories.autoCloseStalePrs,
254 repoName: repositories.name,
255 ownerUsername: sql<string | null>`(SELECT username FROM users WHERE users.id = ${repositories.ownerId})`,
256 lastPokedAt: sql<Date | null>`(
257 SELECT MAX(${prComments.createdAt})
258 FROM ${prComments}
259 WHERE ${prComments.pullRequestId} = ${pullRequests.id}
260 AND ${prComments.body} LIKE ${"%" + STALE_PR_POKE_MARKER + "%"}
261 )`,
262 })
263 .from(pullRequests)
264 .innerJoin(
265 repositories,
266 eq(repositories.id, pullRequests.repositoryId)
267 )
268 .where(
269 and(
270 eq(pullRequests.state, "open"),
271 eq(pullRequests.isDraft, false),
272 eq(repositories.isArchived, false),
273 lt(pullRequests.updatedAt, cutoff)
274 )
275 )
276 .orderBy(desc(pullRequests.updatedAt))
277 .limit(cap * 4); // Over-fetch slightly; loop caps actual side-effects.
278
279 return rows.map((r) => {
280 const lastPokedAt = r.lastPokedAt ? new Date(r.lastPokedAt) : null;
281 const hasPokeWithin =
282 !!lastPokedAt && lastPokedAt >= pokeFreshCutoff;
283 return {
284 prId: r.prId,
285 prNumber: r.prNumber,
286 repositoryId: r.repositoryId,
287 ownerUsername: r.ownerUsername ?? null,
288 repoName: r.repoName,
289 authorUserId: r.authorUserId,
290 updatedAt: new Date(r.updatedAt),
291 hasPokeWithin,
292 lastPokedAt,
293 autoCloseEnabled: !!r.autoCloseEnabled,
294 };
295 });
296 } catch (err) {
297 console.error("[autopilot] stale-pr-sweep: candidate query failed:", err);
298 return [];
299 }
300}
301
302/** Default poke side-effect: comment + notify + audit. */
303async function defaultPokePr(cand: StalePrCandidate): Promise<void> {
a7460bfClaude304 // 1. Post the marker comment as the bot user (falls back to PR author if
305 // the bot row has not been seeded yet).
306 const commentAuthorId = await getBotUserIdOrFallback(cand.authorUserId);
534f04aClaude307 try {
308 await db.insert(prComments).values({
309 pullRequestId: cand.prId,
a7460bfClaude310 authorId: commentAuthorId,
534f04aClaude311 body: PR_POKE_BODY,
312 isAiReview: false,
313 });
314 } catch (err) {
315 console.error(
316 `[autopilot] stale-pr-sweep: poke comment failed for pr=${cand.prId}:`,
317 err
318 );
319 // Don't fall through — without the marker we'd re-poke next tick.
320 return;
321 }
322 // 2. Notify the author. Best-effort; failures swallowed by notify().
323 const url =
324 cand.ownerUsername && cand.repoName
325 ? `/${cand.ownerUsername}/${cand.repoName}/pull/${cand.prNumber}`
326 : undefined;
327 await notify(cand.authorUserId, {
328 kind: "pr_stale",
329 title: `PR #${cand.prNumber} has gone quiet`,
330 body: "No activity for 7+ days. Reply to keep it open, or close it.",
331 url,
332 repositoryId: cand.repositoryId,
333 });
334 // 3. Audit row.
335 await audit({
336 repositoryId: cand.repositoryId,
337 action: "pr.stale_poked",
338 targetType: "pull_request",
339 targetId: cand.prId,
340 metadata: { prNumber: cand.prNumber },
341 });
342}
343
344/** Default close side-effect: comment + state→closed + audit. */
345async function defaultClosePr(cand: StalePrCandidate): Promise<void> {
a7460bfClaude346 // 1. Post the final close comment as the bot user.
347 const commentAuthorId = await getBotUserIdOrFallback(cand.authorUserId);
534f04aClaude348 try {
349 await db.insert(prComments).values({
350 pullRequestId: cand.prId,
a7460bfClaude351 authorId: commentAuthorId,
534f04aClaude352 body: PR_CLOSE_BODY,
353 isAiReview: false,
354 });
355 } catch (err) {
356 console.error(
357 `[autopilot] stale-pr-sweep: close comment failed for pr=${cand.prId}:`,
358 err
359 );
360 }
361 // 2. Flip state→closed.
362 try {
363 await db
364 .update(pullRequests)
365 .set({
366 state: "closed",
367 closedAt: new Date(),
368 updatedAt: new Date(),
369 })
370 .where(eq(pullRequests.id, cand.prId));
371 } catch (err) {
372 console.error(
373 `[autopilot] stale-pr-sweep: close update failed for pr=${cand.prId}:`,
374 err
375 );
376 return;
377 }
378 // 3. Audit row.
379 await audit({
380 repositoryId: cand.repositoryId,
381 action: "pr.stale_closed",
382 targetType: "pull_request",
383 targetId: cand.prId,
384 metadata: { prNumber: cand.prNumber },
385 });
386}
387
388/** Issue-flavoured default candidate finder. */
389async function defaultFindStaleIssueCandidates(
390 now: Date,
391 cap: number
392): Promise<StaleIssueCandidate[]> {
393 const cutoff = new Date(now.getTime() - STALE_ISSUE_POKE_DAYS * MS_PER_DAY);
394 const pokeFreshCutoff = new Date(
395 now.getTime() - STALE_ISSUE_POKE_DAYS * MS_PER_DAY
396 );
397 try {
398 const rows = await db
399 .select({
400 issueId: issues.id,
401 issueNumber: issues.number,
402 repositoryId: issues.repositoryId,
403 authorUserId: issues.authorId,
404 updatedAt: issues.updatedAt,
405 autoCloseEnabled: repositories.autoCloseStaleIssues,
406 repoName: repositories.name,
407 ownerUsername: sql<string | null>`(SELECT username FROM users WHERE users.id = ${repositories.ownerId})`,
408 lastPokedAt: sql<Date | null>`(
409 SELECT MAX(${issueComments.createdAt})
410 FROM ${issueComments}
411 WHERE ${issueComments.issueId} = ${issues.id}
412 AND ${issueComments.body} LIKE ${"%" + STALE_ISSUE_POKE_MARKER + "%"}
413 )`,
414 })
415 .from(issues)
416 .innerJoin(repositories, eq(repositories.id, issues.repositoryId))
417 .where(
418 and(
419 eq(issues.state, "open"),
420 eq(repositories.isArchived, false),
421 lt(issues.updatedAt, cutoff)
422 )
423 )
424 .orderBy(desc(issues.updatedAt))
425 .limit(cap * 4);
426
427 return rows.map((r) => {
428 const lastPokedAt = r.lastPokedAt ? new Date(r.lastPokedAt) : null;
429 const hasPokeWithin =
430 !!lastPokedAt && lastPokedAt >= pokeFreshCutoff;
431 return {
432 issueId: r.issueId,
433 issueNumber: r.issueNumber,
434 repositoryId: r.repositoryId,
435 ownerUsername: r.ownerUsername ?? null,
436 repoName: r.repoName,
437 authorUserId: r.authorUserId,
438 updatedAt: new Date(r.updatedAt),
439 hasPokeWithin,
440 lastPokedAt,
441 autoCloseEnabled: !!r.autoCloseEnabled,
442 };
443 });
444 } catch (err) {
445 console.error(
446 "[autopilot] stale-issue-sweep: candidate query failed:",
447 err
448 );
449 return [];
450 }
451}
452
453/** Default issue poke. */
454async function defaultPokeIssue(cand: StaleIssueCandidate): Promise<void> {
a7460bfClaude455 const commentAuthorId = await getBotUserIdOrFallback(cand.authorUserId);
534f04aClaude456 try {
457 await db.insert(issueComments).values({
458 issueId: cand.issueId,
a7460bfClaude459 authorId: commentAuthorId,
534f04aClaude460 body: ISSUE_POKE_BODY,
461 });
462 } catch (err) {
463 console.error(
464 `[autopilot] stale-issue-sweep: poke comment failed for issue=${cand.issueId}:`,
465 err
466 );
467 return;
468 }
469 const url =
470 cand.ownerUsername && cand.repoName
471 ? `/${cand.ownerUsername}/${cand.repoName}/issues/${cand.issueNumber}`
472 : undefined;
473 await notify(cand.authorUserId, {
474 kind: "issue_stale",
475 title: `Issue #${cand.issueNumber} has gone quiet`,
476 body: "No activity for 30+ days. Reply to keep it open, or close it.",
477 url,
478 repositoryId: cand.repositoryId,
479 });
480 await audit({
481 repositoryId: cand.repositoryId,
482 action: "issue.stale_poked",
483 targetType: "issue",
484 targetId: cand.issueId,
485 metadata: { issueNumber: cand.issueNumber },
486 });
487}
488
489/** Default issue close. */
490async function defaultCloseIssue(cand: StaleIssueCandidate): Promise<void> {
a7460bfClaude491 const commentAuthorId = await getBotUserIdOrFallback(cand.authorUserId);
534f04aClaude492 try {
493 await db.insert(issueComments).values({
494 issueId: cand.issueId,
a7460bfClaude495 authorId: commentAuthorId,
534f04aClaude496 body: ISSUE_CLOSE_BODY,
497 });
498 } catch (err) {
499 console.error(
500 `[autopilot] stale-issue-sweep: close comment failed for issue=${cand.issueId}:`,
501 err
502 );
503 }
504 try {
505 await db
506 .update(issues)
507 .set({
508 state: "closed",
509 closedAt: new Date(),
510 updatedAt: new Date(),
511 })
512 .where(eq(issues.id, cand.issueId));
513 } catch (err) {
514 console.error(
515 `[autopilot] stale-issue-sweep: close update failed for issue=${cand.issueId}:`,
516 err
517 );
518 return;
519 }
520 await audit({
521 repositoryId: cand.repositoryId,
522 action: "issue.stale_closed",
523 targetType: "issue",
524 targetId: cand.issueId,
525 metadata: { issueNumber: cand.issueNumber },
526 });
527}
528
529// ---------------------------------------------------------------------------
530// Orchestrators — what the autopilot calls.
531// ---------------------------------------------------------------------------
532
533/**
534 * One iteration of the PR stale sweeper. Returns counts suitable for the
535 * autopilot tick log. Never throws.
536 *
537 * Decision matrix per candidate:
538 * 1. `hasPokeWithin` → skip (idempotency — already poked within window).
539 * 2. No poke ever AND old enough → POKE (stage 1).
540 * 3. Poke exists older than close threshold AND repo opted in → CLOSE.
541 * 4. Otherwise → skip.
542 *
543 * Per-tick cap is enforced across BOTH poke and close phases combined —
544 * i.e. we never side-effect more than `cap` PRs in a single tick.
545 */
546export async function runStalePrSweepOnce(
547 deps: StaleSweepDeps = {}
548): Promise<StaleSweepSummary> {
549 const now = deps.now ?? new Date();
550 const cap = deps.cap ?? STALE_DEFAULT_CAP;
551 const findCandidates =
552 deps.findPrCandidates ?? defaultFindStalePrCandidates;
553 const pokePr = deps.pokePr ?? defaultPokePr;
554 const closePr = deps.closePr ?? defaultClosePr;
555
556 let candidates: StalePrCandidate[] = [];
557 try {
558 candidates = await findCandidates(now, cap);
559 } catch (err) {
560 console.error("[autopilot] stale-pr-sweep: findCandidates threw:", err);
561 return { poked: 0, closed: 0 };
562 }
563
564 let poked = 0;
565 let closed = 0;
566 let acted = 0;
567
568 for (const cand of candidates) {
569 if (acted >= cap) break;
570 try {
571 // Stage 2 takes priority: if the existing poke is older than the
572 // close threshold AND the repo opted in, we close. We must NOT
573 // re-poke a PR that already has a poke — that would spam.
574 if (shouldClosePr(cand, now)) {
575 if (!cand.autoCloseEnabled) {
576 // Repo opted out of stage-2; don't close, don't re-poke.
577 continue;
578 }
579 await closePr(cand);
580 closed += 1;
581 acted += 1;
582 continue;
583 }
584 // Stage 1: poke if we haven't already inside the window.
585 if (shouldPokePr(cand, now)) {
586 await pokePr(cand);
587 poked += 1;
588 acted += 1;
589 }
590 } catch (err) {
591 console.error(
592 `[autopilot] stale-pr-sweep: per-PR failure for pr=${cand.prId}:`,
593 err
594 );
595 }
596 }
597
598 console.log(
599 `[autopilot] stale-pr-sweep: poked=${poked} closed=${closed}`
600 );
601 return { poked, closed };
602}
603
604/** Issue-flavoured mirror of `runStalePrSweepOnce`. */
605export async function runStaleIssueSweepOnce(
606 deps: StaleIssueSweepDeps = {}
607): Promise<StaleSweepSummary> {
608 const now = deps.now ?? new Date();
609 const cap = deps.cap ?? STALE_DEFAULT_CAP;
610 const findCandidates =
611 deps.findIssueCandidates ?? defaultFindStaleIssueCandidates;
612 const pokeIssue = deps.pokeIssue ?? defaultPokeIssue;
613 const closeIssue = deps.closeIssue ?? defaultCloseIssue;
614
615 let candidates: StaleIssueCandidate[] = [];
616 try {
617 candidates = await findCandidates(now, cap);
618 } catch (err) {
619 console.error(
620 "[autopilot] stale-issue-sweep: findCandidates threw:",
621 err
622 );
623 return { poked: 0, closed: 0 };
624 }
625
626 let poked = 0;
627 let closed = 0;
628 let acted = 0;
629
630 for (const cand of candidates) {
631 if (acted >= cap) break;
632 try {
633 if (shouldCloseIssue(cand, now)) {
634 if (!cand.autoCloseEnabled) continue;
635 await closeIssue(cand);
636 closed += 1;
637 acted += 1;
638 continue;
639 }
640 if (shouldPokeIssue(cand, now)) {
641 await pokeIssue(cand);
642 poked += 1;
643 acted += 1;
644 }
645 } catch (err) {
646 console.error(
647 `[autopilot] stale-issue-sweep: per-issue failure for issue=${cand.issueId}:`,
648 err
649 );
650 }
651 }
652
653 console.log(
654 `[autopilot] stale-issue-sweep: poked=${poked} closed=${closed}`
655 );
656 return { poked, closed };
657}
658
659/** Exposed for tests / debugging only. */
660export const __test = {
661 PR_POKE_BODY,
662 PR_CLOSE_BODY,
663 ISSUE_POKE_BODY,
664 ISSUE_CLOSE_BODY,
665 defaultFindStalePrCandidates,
666 defaultFindStaleIssueCandidates,
667 defaultPokePr,
668 defaultClosePr,
669 defaultPokeIssue,
670 defaultCloseIssue,
671};