Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

stale-issues.tsBlame176 lines · 1 contributor
d6b4e67Claude1/**
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 */
15export const STALE_PERIODS = ["30d", "60d", "90d", "180d"] as const;
16export type StalePeriod = (typeof STALE_PERIODS)[number];
17export const DEFAULT_STALE_PERIOD: StalePeriod = "60d";
18
19export 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
32export 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
38export 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
48export 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
58export interface StaleBuckets {
59 "30-60": StaleIssue[];
60 "60-90": StaleIssue[];
61 "90-180": StaleIssue[];
62 "180+": StaleIssue[];
63}
64
65export interface StaleReport {
66 period: StalePeriod;
67 thresholdDays: number;
68 now: string;
69 total: number;
70 issues: StaleIssue[];
71 buckets: StaleBuckets;
72}
73
74function 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
86function 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 */
95export 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 */
131export 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
149export 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
166export const __internal = {
167 STALE_PERIODS,
168 DEFAULT_STALE_PERIOD,
169 periodDays,
170 parsePeriod,
171 filterStale,
172 bucketByStaleness,
173 buildStaleReport,
174 toDate,
175 daysBetween,
176};