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.
| 534f04a | 1 | /** |
| 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 | ||
| 35 | import { and, desc, eq, lt, sql } from "drizzle-orm"; | |
| 36 | import { db } from "../db"; | |
| 37 | import { | |
| 38 | issueComments, | |
| 39 | issues, | |
| 40 | prComments, | |
| 41 | pullRequests, | |
| 42 | repositories, | |
| 43 | } from "../db/schema"; | |
| 44 | import { 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 | ||
| 51 | export const STALE_PR_POKE_MARKER = "<!-- gluecron:stale-poke:v1 -->"; | |
| 52 | export const STALE_PR_CLOSE_MARKER = "<!-- gluecron:stale-close:v1 -->"; | |
| 53 | export const STALE_ISSUE_POKE_MARKER = | |
| 54 | "<!-- gluecron:stale-issue-poke:v1 -->"; | |
| 55 | export const STALE_ISSUE_CLOSE_MARKER = | |
| 56 | "<!-- gluecron:stale-issue-close:v1 -->"; | |
| 57 | ||
| 58 | // --------------------------------------------------------------------------- | |
| 59 | // Time windows | |
| 60 | // --------------------------------------------------------------------------- | |
| 61 | ||
| 62 | const MS_PER_DAY = 24 * 60 * 60 * 1000; | |
| 63 | ||
| 64 | /** Poke threshold for pull requests — "no activity for N days." */ | |
| 65 | export const STALE_PR_POKE_DAYS = 7; | |
| 66 | /** Close threshold after the stage-1 poke for pull requests. */ | |
| 67 | export const STALE_PR_CLOSE_DAYS = 14; | |
| 68 | /** Poke threshold for issues. */ | |
| 69 | export const STALE_ISSUE_POKE_DAYS = 30; | |
| 70 | /** Close threshold after the stage-1 poke for issues. */ | |
| 71 | export const STALE_ISSUE_CLOSE_DAYS = 60; | |
| 72 | ||
| 73 | /** Per-tick cap on number of pokes/closes — spec says 30. */ | |
| 74 | export const STALE_DEFAULT_CAP = 30; | |
| 75 | ||
| 76 | // --------------------------------------------------------------------------- | |
| 77 | // Comment bodies | |
| 78 | // --------------------------------------------------------------------------- | |
| 79 | ||
| 80 | const PR_POKE_BODY = `${STALE_PR_POKE_MARKER} | |
| 81 | ## This PR has gone quiet | |
| 82 | No 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 | ||
| 87 | const PR_CLOSE_BODY = `${STALE_PR_CLOSE_MARKER} | |
| 88 | Closed as stale — please reopen if still relevant.`; | |
| 89 | ||
| 90 | const ISSUE_POKE_BODY = `${STALE_ISSUE_POKE_MARKER} | |
| 91 | ## This issue has gone quiet | |
| 92 | Still relevant? No activity for 30+ days. Close if not, or comment to keep open.`; | |
| 93 | ||
| 94 | const ISSUE_CLOSE_BODY = `${STALE_ISSUE_CLOSE_MARKER} | |
| 95 | Closed 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 | */ | |
| 106 | export 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 | */ | |
| 121 | export 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`. */ | |
| 131 | export 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`. */ | |
| 141 | export 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 | ||
| 154 | export 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 | ||
| 170 | export 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 | ||
| 187 | export 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 | ||
| 203 | export 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 | ||
| 214 | export 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 | */ | |
| 233 | async 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. */ | |
| 302 | async 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. */ | |
| 342 | async 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. */ | |
| 385 | async 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. */ | |
| 450 | async 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. */ | |
| 485 | async 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 | */ | |
| 540 | export 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`. */ | |
| 599 | export 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. */ | |
| 654 | export 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 | }; |