CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
stale-issues.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.
| d6b4e67 | 1 | /** |
| 2 | * Block J20 — Stale issue detector. Pure helpers + route smokes. | |
| 3 | */ | |
| 4 | ||
| 5 | import { describe, it, expect } from "bun:test"; | |
| 6 | import app from "../app"; | |
| 7 | import { | |
| 8 | STALE_PERIODS, | |
| 9 | DEFAULT_STALE_PERIOD, | |
| 10 | periodDays, | |
| 11 | parsePeriod, | |
| 12 | filterStale, | |
| 13 | bucketByStaleness, | |
| 14 | buildStaleReport, | |
| 15 | __internal, | |
| 16 | type StaleInputIssue, | |
| 17 | } from "../lib/stale-issues"; | |
| 18 | ||
| 19 | function daysAgo(n: number): Date { | |
| 20 | const now = new Date("2026-04-15T12:00:00Z"); | |
| 21 | return new Date(now.getTime() - n * 24 * 60 * 60 * 1000); | |
| 22 | } | |
| 23 | ||
| 24 | const NOW = new Date("2026-04-15T12:00:00Z"); | |
| 25 | ||
| 26 | const issueAt = ( | |
| 27 | number: number, | |
| 28 | daysSince: number, | |
| 29 | state = "open", | |
| 30 | authorName = "alice" | |
| 31 | ): StaleInputIssue => ({ | |
| 32 | number, | |
| 33 | title: `Issue ${number}`, | |
| 34 | state, | |
| 35 | authorName, | |
| 36 | createdAt: daysAgo(daysSince + 30), | |
| 37 | updatedAt: daysAgo(daysSince), | |
| 38 | }); | |
| 39 | ||
| 40 | describe("stale-issues — constants & parse", () => { | |
| 41 | it("exports the canonical period list", () => { | |
| 42 | expect(STALE_PERIODS).toEqual(["30d", "60d", "90d", "180d"]); | |
| 43 | expect(DEFAULT_STALE_PERIOD).toBe("60d"); | |
| 44 | }); | |
| 45 | ||
| 46 | it("periodDays returns day counts", () => { | |
| 47 | expect(periodDays("30d")).toBe(30); | |
| 48 | expect(periodDays("60d")).toBe(60); | |
| 49 | expect(periodDays("90d")).toBe(90); | |
| 50 | expect(periodDays("180d")).toBe(180); | |
| 51 | }); | |
| 52 | ||
| 53 | it("parsePeriod accepts valid values", () => { | |
| 54 | expect(parsePeriod("30d")).toBe("30d"); | |
| 55 | expect(parsePeriod("60d")).toBe("60d"); | |
| 56 | expect(parsePeriod("90d")).toBe("90d"); | |
| 57 | expect(parsePeriod("180d")).toBe("180d"); | |
| 58 | }); | |
| 59 | ||
| 60 | it("parsePeriod falls back to default on garbage", () => { | |
| 61 | expect(parsePeriod("")).toBe(DEFAULT_STALE_PERIOD); | |
| 62 | expect(parsePeriod("ever")).toBe(DEFAULT_STALE_PERIOD); | |
| 63 | expect(parsePeriod(null)).toBe(DEFAULT_STALE_PERIOD); | |
| 64 | expect(parsePeriod(undefined)).toBe(DEFAULT_STALE_PERIOD); | |
| 65 | expect(parsePeriod(123)).toBe(DEFAULT_STALE_PERIOD); | |
| 66 | expect(parsePeriod("1y")).toBe(DEFAULT_STALE_PERIOD); | |
| 67 | }); | |
| 68 | }); | |
| 69 | ||
| 70 | describe("stale-issues — filterStale", () => { | |
| 71 | it("keeps only open issues beyond the threshold", () => { | |
| 72 | const input = [ | |
| 73 | issueAt(1, 10), // too fresh | |
| 74 | issueAt(2, 45), | |
| 75 | issueAt(3, 100), | |
| 76 | issueAt(4, 200, "closed"), // closed, dropped | |
| 77 | ]; | |
| 78 | const out = filterStale(input, NOW, 30); | |
| 79 | expect(out.map((i) => i.number)).toEqual([3, 2]); | |
| 80 | }); | |
| 81 | ||
| 82 | it("is inclusive at the threshold boundary", () => { | |
| 83 | const out = filterStale([issueAt(1, 30)], NOW, 30); | |
| 84 | expect(out).toHaveLength(1); | |
| 85 | }); | |
| 86 | ||
| 87 | it("sorts oldest-first then by number", () => { | |
| 88 | const out = filterStale( | |
| 89 | [issueAt(3, 40), issueAt(1, 40), issueAt(2, 100)], | |
| 90 | NOW, | |
| 91 | 30 | |
| 92 | ); | |
| 93 | expect(out.map((i) => i.number)).toEqual([2, 1, 3]); | |
| 94 | }); | |
| 95 | ||
| 96 | it("gracefully skips issues with unparseable updatedAt", () => { | |
| 97 | const bad: StaleInputIssue = { | |
| 98 | number: 9, | |
| 99 | title: "broken", | |
| 100 | state: "open", | |
| 101 | authorName: "x", | |
| 102 | createdAt: "2026-01-01", | |
| 103 | updatedAt: "not-a-date", | |
| 104 | }; | |
| 105 | const out = filterStale([bad, issueAt(1, 40)], NOW, 30); | |
| 106 | expect(out.map((i) => i.number)).toEqual([1]); | |
| 107 | }); | |
| 108 | ||
| 109 | it("accepts ISO strings for updatedAt + createdAt", () => { | |
| 110 | const iso: StaleInputIssue = { | |
| 111 | number: 1, | |
| 112 | title: "iso", | |
| 113 | state: "open", | |
| 114 | authorName: "x", | |
| 115 | createdAt: "2026-01-01T00:00:00Z", | |
| 116 | updatedAt: "2026-02-01T00:00:00Z", | |
| 117 | }; | |
| 118 | const out = filterStale([iso], NOW, 30); | |
| 119 | expect(out).toHaveLength(1); | |
| 120 | expect(out[0].daysSinceUpdate).toBeGreaterThan(60); | |
| 121 | }); | |
| 122 | ||
| 123 | it("carries commentCount through when provided", () => { | |
| 124 | const withComments: StaleInputIssue = { | |
| 125 | ...issueAt(5, 50), | |
| 126 | commentCount: 7, | |
| 127 | }; | |
| 128 | const out = filterStale([withComments], NOW, 30); | |
| 129 | expect(out[0].commentCount).toBe(7); | |
| 130 | }); | |
| 131 | ||
| 132 | it("defaults commentCount to 0 when missing", () => { | |
| 133 | const out = filterStale([issueAt(1, 50)], NOW, 30); | |
| 134 | expect(out[0].commentCount).toBe(0); | |
| 135 | }); | |
| 136 | ||
| 137 | it("ignores non-open states (draft, in_progress, etc)", () => { | |
| 138 | const odd: StaleInputIssue = { | |
| 139 | ...issueAt(1, 100), | |
| 140 | state: "archived", | |
| 141 | }; | |
| 142 | const out = filterStale([odd], NOW, 30); | |
| 143 | expect(out).toHaveLength(0); | |
| 144 | }); | |
| 145 | }); | |
| 146 | ||
| 147 | describe("stale-issues — bucketByStaleness", () => { | |
| 148 | it("splits issues into the right buckets", () => { | |
| 149 | const staleOnly = filterStale( | |
| 150 | [ | |
| 151 | issueAt(1, 35), // 30-60 | |
| 152 | issueAt(2, 70), // 60-90 | |
| 153 | issueAt(3, 120), // 90-180 | |
| 154 | issueAt(4, 365), // 180+ | |
| 155 | ], | |
| 156 | NOW, | |
| 157 | 30 | |
| 158 | ); | |
| 159 | const b = bucketByStaleness(staleOnly); | |
| 160 | expect(b["30-60"].map((i) => i.number)).toEqual([1]); | |
| 161 | expect(b["60-90"].map((i) => i.number)).toEqual([2]); | |
| 162 | expect(b["90-180"].map((i) => i.number)).toEqual([3]); | |
| 163 | expect(b["180+"].map((i) => i.number)).toEqual([4]); | |
| 164 | }); | |
| 165 | ||
| 166 | it("drops issues with daysSinceUpdate < 30", () => { | |
| 167 | const out = bucketByStaleness([ | |
| 168 | { | |
| 169 | number: 1, | |
| 170 | title: "recent", | |
| 171 | authorName: "a", | |
| 172 | createdAt: new Date().toISOString(), | |
| 173 | updatedAt: new Date().toISOString(), | |
| 174 | daysSinceUpdate: 10, | |
| 175 | commentCount: 0, | |
| 176 | }, | |
| 177 | ]); | |
| 178 | expect(out["30-60"]).toHaveLength(0); | |
| 179 | expect(out["60-90"]).toHaveLength(0); | |
| 180 | expect(out["90-180"]).toHaveLength(0); | |
| 181 | expect(out["180+"]).toHaveLength(0); | |
| 182 | }); | |
| 183 | ||
| 184 | it("is inclusive at bucket boundaries (60, 90, 180)", () => { | |
| 185 | const mk = (d: number) => ({ | |
| 186 | number: d, | |
| 187 | title: `t${d}`, | |
| 188 | authorName: "a", | |
| 189 | createdAt: new Date().toISOString(), | |
| 190 | updatedAt: new Date().toISOString(), | |
| 191 | daysSinceUpdate: d, | |
| 192 | commentCount: 0, | |
| 193 | }); | |
| 194 | const b = bucketByStaleness([mk(60), mk(90), mk(180)]); | |
| 195 | expect(b["60-90"].map((i) => i.number)).toEqual([60]); | |
| 196 | expect(b["90-180"].map((i) => i.number)).toEqual([90]); | |
| 197 | expect(b["180+"].map((i) => i.number)).toEqual([180]); | |
| 198 | }); | |
| 199 | }); | |
| 200 | ||
| 201 | describe("stale-issues — buildStaleReport", () => { | |
| 202 | it("assembles period + threshold + buckets + total", () => { | |
| 203 | const report = buildStaleReport({ | |
| 204 | period: "30d", | |
| 205 | now: NOW, | |
| 206 | issues: [ | |
| 207 | issueAt(1, 10), | |
| 208 | issueAt(2, 40), | |
| 209 | issueAt(3, 100), | |
| 210 | issueAt(4, 200), | |
| 211 | ], | |
| 212 | }); | |
| 213 | expect(report.period).toBe("30d"); | |
| 214 | expect(report.thresholdDays).toBe(30); | |
| 215 | expect(report.total).toBe(3); | |
| 216 | expect(report.issues.map((i) => i.number)).toEqual([4, 3, 2]); | |
| 217 | expect(report.buckets["30-60"]).toHaveLength(1); | |
| 218 | expect(report.buckets["90-180"]).toHaveLength(1); | |
| 219 | expect(report.buckets["180+"]).toHaveLength(1); | |
| 220 | }); | |
| 221 | ||
| 222 | it("respects the chosen period for the threshold cutoff", () => { | |
| 223 | // With a 90d threshold, only issues older than 90 days qualify. | |
| 224 | const report = buildStaleReport({ | |
| 225 | period: "90d", | |
| 226 | now: NOW, | |
| 227 | issues: [issueAt(1, 40), issueAt(2, 100), issueAt(3, 200)], | |
| 228 | }); | |
| 229 | expect(report.total).toBe(2); | |
| 230 | expect(report.issues.map((i) => i.number)).toEqual([3, 2]); | |
| 231 | }); | |
| 232 | ||
| 233 | it("emits an ISO `now` for display", () => { | |
| 234 | const report = buildStaleReport({ | |
| 235 | period: "60d", | |
| 236 | now: NOW, | |
| 237 | issues: [], | |
| 238 | }); | |
| 239 | expect(report.now).toBe("2026-04-15T12:00:00.000Z"); | |
| 240 | expect(report.total).toBe(0); | |
| 241 | }); | |
| 242 | }); | |
| 243 | ||
| 244 | describe("stale-issues — __internal parity", () => { | |
| 245 | it("re-exports all helpers", () => { | |
| 246 | expect(__internal.STALE_PERIODS).toBe(STALE_PERIODS); | |
| 247 | expect(__internal.DEFAULT_STALE_PERIOD).toBe(DEFAULT_STALE_PERIOD); | |
| 248 | expect(__internal.periodDays).toBe(periodDays); | |
| 249 | expect(__internal.parsePeriod).toBe(parsePeriod); | |
| 250 | expect(__internal.filterStale).toBe(filterStale); | |
| 251 | expect(__internal.bucketByStaleness).toBe(bucketByStaleness); | |
| 252 | expect(__internal.buildStaleReport).toBe(buildStaleReport); | |
| 253 | }); | |
| 254 | }); | |
| 255 | ||
| 256 | describe("stale-issues — routes", () => { | |
| 257 | it("GET /:o/:r/issues/stale returns 200 + page chrome for public view", async () => { | |
| 258 | const res = await app.request("/alice/nope/issues/stale"); | |
| 259 | expect(res.status).toBe(404); | |
| 260 | }); | |
| 261 | ||
| 262 | it("unknown period falls back to default without 500", async () => { | |
| 263 | const res = await app.request( | |
| 264 | "/alice/nope/issues/stale?period=9999x" | |
| 265 | ); | |
| 266 | // Repo doesn't exist so we get the 404 path — important: no 500. | |
| 267 | expect(res.status).toBe(404); | |
| 268 | }); | |
| 269 | ||
| 270 | it("accepts each valid period without 500", async () => { | |
| 271 | for (const p of STALE_PERIODS) { | |
| 272 | const res = await app.request( | |
| 273 | `/alice/nope/issues/stale?period=${p}` | |
| 274 | ); | |
| 275 | expect([200, 404]).toContain(res.status); | |
| 276 | } | |
| 277 | }); | |
| 278 | }); |