Blame · Line-by-line history
stale-issues.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.
| d6b4e67 | 1 | /** |
| 2 | * Block J20 — Stale issue detector. | |
| 3 | * | |
| 4 | * Pure filtering/bucketing helper. Given a list of open issues and a | |
| 5 | * threshold in days, it surfaces issues whose `updatedAt` is older than | |
| 6 | * the threshold (i.e. "no activity in N days"). Route in | |
| 7 | * `src/routes/stale-issues.tsx` does the DB + rendering; this module is | |
| 8 | * IO-free so it can be exhaustively unit-tested. | |
| 9 | * | |
| 10 | * GitHub's "stale bot" uses a two-stage marking → closing flow — we only | |
| 11 | * implement the *detection* half (non-destructive) because automatic | |
| 12 | * closing demands per-repo opt-in + dry-run tooling we don't want to | |
| 13 | * ship silently. | |
| 14 | */ | |
| 15 | export const STALE_PERIODS = ["30d", "60d", "90d", "180d"] as const; | |
| 16 | export type StalePeriod = (typeof STALE_PERIODS)[number]; | |
| 17 | export const DEFAULT_STALE_PERIOD: StalePeriod = "60d"; | |
| 18 | ||
| 19 | export function periodDays(p: StalePeriod): number { | |
| 20 | switch (p) { | |
| 21 | case "30d": | |
| 22 | return 30; | |
| 23 | case "60d": | |
| 24 | return 60; | |
| 25 | case "90d": | |
| 26 | return 90; | |
| 27 | case "180d": | |
| 28 | return 180; | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | export function parsePeriod(raw: unknown): StalePeriod { | |
| 33 | if (typeof raw !== "string") return DEFAULT_STALE_PERIOD; | |
| 34 | const match = (STALE_PERIODS as readonly string[]).includes(raw); | |
| 35 | return match ? (raw as StalePeriod) : DEFAULT_STALE_PERIOD; | |
| 36 | } | |
| 37 | ||
| 38 | export interface StaleInputIssue { | |
| 39 | number: number; | |
| 40 | title: string; | |
| 41 | state: string; | |
| 42 | authorName: string; | |
| 43 | createdAt: Date | string; | |
| 44 | updatedAt: Date | string; | |
| 45 | commentCount?: number; | |
| 46 | } | |
| 47 | ||
| 48 | export interface StaleIssue { | |
| 49 | number: number; | |
| 50 | title: string; | |
| 51 | authorName: string; | |
| 52 | createdAt: string; | |
| 53 | updatedAt: string; | |
| 54 | daysSinceUpdate: number; | |
| 55 | commentCount: number; | |
| 56 | } | |
| 57 | ||
| 58 | export interface StaleBuckets { | |
| 59 | "30-60": StaleIssue[]; | |
| 60 | "60-90": StaleIssue[]; | |
| 61 | "90-180": StaleIssue[]; | |
| 62 | "180+": StaleIssue[]; | |
| 63 | } | |
| 64 | ||
| 65 | export interface StaleReport { | |
| 66 | period: StalePeriod; | |
| 67 | thresholdDays: number; | |
| 68 | now: string; | |
| 69 | total: number; | |
| 70 | issues: StaleIssue[]; | |
| 71 | buckets: StaleBuckets; | |
| 72 | } | |
| 73 | ||
| 74 | function toDate(v: Date | string | null | undefined): Date | null { | |
| 75 | if (v instanceof Date) { | |
| 76 | const t = v.getTime(); | |
| 77 | return Number.isFinite(t) ? v : null; | |
| 78 | } | |
| 79 | if (typeof v === "string" && v) { | |
| 80 | const t = Date.parse(v); | |
| 81 | if (Number.isFinite(t)) return new Date(t); | |
| 82 | } | |
| 83 | return null; | |
| 84 | } | |
| 85 | ||
| 86 | function daysBetween(a: Date, b: Date): number { | |
| 87 | const ms = Math.abs(a.getTime() - b.getTime()); | |
| 88 | return Math.floor(ms / (24 * 60 * 60 * 1000)); | |
| 89 | } | |
| 90 | ||
| 91 | /** | |
| 92 | * Select only *open* issues whose most recent activity is older than | |
| 93 | * `thresholdDays` relative to `now`. Output is sorted oldest-first. | |
| 94 | */ | |
| 95 | export function filterStale( | |
| 96 | issues: StaleInputIssue[], | |
| 97 | now: Date, | |
| 98 | thresholdDays: number | |
| 99 | ): StaleIssue[] { | |
| 100 | const out: StaleIssue[] = []; | |
| 101 | for (const i of issues) { | |
| 102 | if (i.state !== "open") continue; | |
| 103 | const updated = toDate(i.updatedAt); | |
| 104 | const created = toDate(i.createdAt); | |
| 105 | if (!updated) continue; | |
| 106 | const days = daysBetween(now, updated); | |
| 107 | if (days < thresholdDays) continue; | |
| 108 | out.push({ | |
| 109 | number: i.number, | |
| 110 | title: i.title, | |
| 111 | authorName: i.authorName, | |
| 112 | createdAt: (created ?? updated).toISOString(), | |
| 113 | updatedAt: updated.toISOString(), | |
| 114 | daysSinceUpdate: days, | |
| 115 | commentCount: i.commentCount ?? 0, | |
| 116 | }); | |
| 117 | } | |
| 118 | // Oldest activity first (highest daysSinceUpdate first). | |
| 119 | out.sort((a, b) => { | |
| 120 | if (a.daysSinceUpdate !== b.daysSinceUpdate) | |
| 121 | return b.daysSinceUpdate - a.daysSinceUpdate; | |
| 122 | return a.number - b.number; | |
| 123 | }); | |
| 124 | return out; | |
| 125 | } | |
| 126 | ||
| 127 | /** | |
| 128 | * Put already-staleness-filtered issues into age buckets so the UI can | |
| 129 | * surface a quick "how bad is it" breakdown. | |
| 130 | */ | |
| 131 | export function bucketByStaleness(issues: StaleIssue[]): StaleBuckets { | |
| 132 | const out: StaleBuckets = { | |
| 133 | "30-60": [], | |
| 134 | "60-90": [], | |
| 135 | "90-180": [], | |
| 136 | "180+": [], | |
| 137 | }; | |
| 138 | for (const i of issues) { | |
| 139 | const d = i.daysSinceUpdate; | |
| 140 | if (d >= 180) out["180+"].push(i); | |
| 141 | else if (d >= 90) out["90-180"].push(i); | |
| 142 | else if (d >= 60) out["60-90"].push(i); | |
| 143 | else if (d >= 30) out["30-60"].push(i); | |
| 144 | // Issues with d < 30 are dropped — they aren't "stale" by any bucket. | |
| 145 | } | |
| 146 | return out; | |
| 147 | } | |
| 148 | ||
| 149 | export function buildStaleReport(opts: { | |
| 150 | period: StalePeriod; | |
| 151 | now: Date; | |
| 152 | issues: StaleInputIssue[]; | |
| 153 | }): StaleReport { | |
| 154 | const threshold = periodDays(opts.period); | |
| 155 | const filtered = filterStale(opts.issues, opts.now, threshold); | |
| 156 | return { | |
| 157 | period: opts.period, | |
| 158 | thresholdDays: threshold, | |
| 159 | now: opts.now.toISOString(), | |
| 160 | total: filtered.length, | |
| 161 | issues: filtered, | |
| 162 | buckets: bucketByStaleness(filtered), | |
| 163 | }; | |
| 164 | } | |
| 165 | ||
| 166 | export const __internal = { | |
| 167 | STALE_PERIODS, | |
| 168 | DEFAULT_STALE_PERIOD, | |
| 169 | periodDays, | |
| 170 | parsePeriod, | |
| 171 | filterStale, | |
| 172 | bucketByStaleness, | |
| 173 | buildStaleReport, | |
| 174 | toDate, | |
| 175 | daysBetween, | |
| 176 | }; |