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