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-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.

stale-issues.test.tsBlame278 lines · 1 contributor
d6b4e67Claude1/**
2 * Block J20 — Stale issue detector. Pure helpers + route smokes.
3 */
4
5import { describe, it, expect } from "bun:test";
6import app from "../app";
7import {
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
19function 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
24const NOW = new Date("2026-04-15T12:00:00Z");
25
26const 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
40describe("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
70describe("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
147describe("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
201describe("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
244describe("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
256describe("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});