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

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

branch-age.tsBlame363 lines · 1 contributor
c02a55bClaude1/**
2 * Block J27 — Branch staleness / age report. Pure rollup helpers.
3 *
4 * Given a set of branches with tip-commit metadata + ahead/behind counts
5 * vs the default branch, produces a per-branch view with age classification,
6 * bucket counts, and summary statistics. IO-free so the route can
7 * orchestrate git subprocess calls and feed the pre-fetched rows here.
8 */
9
10export const DAY_MS = 24 * 60 * 60 * 1000;
11
12/** Canonical thresholds (in days) exposed to the UI select. */
13export const VALID_THRESHOLDS = [0, 30, 60, 90, 180] as const;
14export type Threshold = (typeof VALID_THRESHOLDS)[number];
15
16export const DEFAULT_THRESHOLD: Threshold = 0;
17
18export type BranchSort =
19 | "age-desc"
20 | "age-asc"
21 | "name"
22 | "ahead-desc"
23 | "behind-desc";
24
25export const VALID_SORTS: readonly BranchSort[] = [
26 "age-desc",
27 "age-asc",
28 "name",
29 "ahead-desc",
30 "behind-desc",
31];
32
33export const DEFAULT_SORT: BranchSort = "age-desc";
34
35/** Raw input row: tip commit + ahead/behind relative to default. */
36export interface BranchInputRow {
37 name: string;
38 tipSha: string;
39 tipDate: Date | string | null;
40 tipAuthor: string | null;
41 tipMessage: string | null;
42 /** Commits on this branch not on default. */
43 ahead: number;
44 /** Commits on default not on this branch. */
45 behind: number;
46 /** True when this branch is the repo default. */
47 isDefault: boolean;
48}
49
50export type BranchAgeCategory = "fresh" | "aging" | "stale" | "abandoned";
51
52export interface BranchReportRow {
53 name: string;
54 tipSha: string;
55 tipDate: Date | null;
56 tipAuthor: string | null;
57 tipMessage: string | null;
58 ahead: number;
59 behind: number;
60 isDefault: boolean;
61 daysOld: number | null;
62 category: BranchAgeCategory;
63 merged: boolean;
64}
65
66export interface BranchBuckets {
67 fresh: number;
68 aging: number;
69 stale: number;
70 abandoned: number;
71}
72
73export interface BranchSummary {
74 total: number;
75 /** Excludes the default branch from the count. */
76 nonDefault: number;
77 merged: number;
78 unmerged: number;
79 withoutTip: number;
80 oldestName: string | null;
81 oldestDaysOld: number | null;
82 averageAgeDays: number | null;
83 medianAgeDays: number | null;
84}
85
86export interface BranchReport {
87 now: number;
88 threshold: Threshold;
89 sort: BranchSort;
90 defaultBranch: string | null;
91 rows: BranchReportRow[];
92 filtered: BranchReportRow[];
93 buckets: BranchBuckets;
94 summary: BranchSummary;
95}
96
97/** Accept `number | string | null | undefined` on the query; coerce to allow-listed Threshold. */
98export function parseThreshold(raw: unknown): Threshold {
99 if (raw === undefined || raw === null) return DEFAULT_THRESHOLD;
100 const s = String(raw).trim();
101 if (s === "") return DEFAULT_THRESHOLD;
102 const n = Number.parseInt(s, 10);
103 if (!Number.isFinite(n)) return DEFAULT_THRESHOLD;
104 if ((VALID_THRESHOLDS as readonly number[]).includes(n)) return n as Threshold;
105 return DEFAULT_THRESHOLD;
106}
107
108export function parseSort(raw: unknown): BranchSort {
109 if (typeof raw !== "string") return DEFAULT_SORT;
110 const t = raw.trim() as BranchSort;
111 return VALID_SORTS.includes(t) ? t : DEFAULT_SORT;
112}
113
114function toDate(v: Date | string | null | undefined): Date | null {
115 if (v === null || v === undefined) return null;
116 if (v instanceof Date) return Number.isNaN(v.getTime()) ? null : v;
117 if (typeof v !== "string") return null;
118 const d = new Date(v);
119 return Number.isNaN(d.getTime()) ? null : d;
120}
121
122/** How old in whole days. `null` when `tipDate` is missing/unparseable. */
123export function computeDaysOld(
124 tipDate: Date | string | null | undefined,
125 now: number
126): number | null {
127 const d = toDate(tipDate);
128 if (!d) return null;
129 const ms = now - d.getTime();
130 if (ms < 0) return 0;
131 return Math.floor(ms / DAY_MS);
132}
133
134/**
135 * Four categories:
136 * <30 days → fresh
137 * 30–59 → aging
138 * 60–89 → stale
139 * ≥90 → abandoned
140 *
141 * Branches with no tipDate → `abandoned` (we can't verify they're alive).
142 */
143export function classifyBranchAge(
144 daysOld: number | null
145): BranchAgeCategory {
146 if (daysOld === null) return "abandoned";
147 if (daysOld < 30) return "fresh";
148 if (daysOld < 60) return "aging";
149 if (daysOld < 90) return "stale";
150 return "abandoned";
151}
152
153export function computeBranchRow(
154 input: BranchInputRow,
155 now: number
156): BranchReportRow {
157 const daysOld = computeDaysOld(input.tipDate, now);
158 return {
159 name: input.name,
160 tipSha: input.tipSha,
161 tipDate: toDate(input.tipDate),
162 tipAuthor: input.tipAuthor,
163 tipMessage: input.tipMessage,
164 ahead: Math.max(0, input.ahead | 0),
165 behind: Math.max(0, input.behind | 0),
166 isDefault: input.isDefault,
167 daysOld,
168 category: classifyBranchAge(daysOld),
169 // "merged" means no commits ahead of default AND not the default itself.
170 merged: !input.isDefault && (input.ahead | 0) === 0,
171 };
172}
173
174export function bucketBranches(rows: readonly BranchReportRow[]): BranchBuckets {
175 const out: BranchBuckets = { fresh: 0, aging: 0, stale: 0, abandoned: 0 };
176 for (const r of rows) {
177 if (r.isDefault) continue; // default branch never enters the buckets
178 out[r.category]++;
179 }
180 return out;
181}
182
183export function filterByThreshold(
184 rows: readonly BranchReportRow[],
185 threshold: Threshold
186): BranchReportRow[] {
187 if (threshold === 0) return rows.slice();
188 return rows.filter(
189 (r) => !r.isDefault && r.daysOld !== null && r.daysOld >= threshold
190 );
191}
192
193function percentile(sorted: readonly number[], p: number): number | null {
194 if (sorted.length === 0) return null;
195 if (sorted.length === 1) return sorted[0]!;
196 const rank = (p / 100) * (sorted.length - 1);
197 const lo = Math.floor(rank);
198 const hi = Math.ceil(rank);
199 if (lo === hi) return sorted[lo]!;
200 const w = rank - lo;
201 return sorted[lo]! + (sorted[hi]! - sorted[lo]!) * w;
202}
203
204export function summariseBranches(
205 rows: readonly BranchReportRow[]
206): BranchSummary {
207 const nonDefault = rows.filter((r) => !r.isDefault);
208 const withAge = nonDefault
209 .filter((r): r is BranchReportRow & { daysOld: number } => r.daysOld !== null);
210 const sortedAges = withAge.map((r) => r.daysOld).sort((a, b) => a - b);
211 const sum = sortedAges.reduce((a, b) => a + b, 0);
212 const avg = sortedAges.length > 0 ? Math.round(sum / sortedAges.length) : null;
213 const med = percentile(sortedAges, 50);
214 let oldestName: string | null = null;
215 let oldestDays: number | null = null;
216 for (const r of withAge) {
217 if (oldestDays === null || r.daysOld > oldestDays) {
218 oldestDays = r.daysOld;
219 oldestName = r.name;
220 }
221 }
222 return {
223 total: rows.length,
224 nonDefault: nonDefault.length,
225 merged: nonDefault.filter((r) => r.merged).length,
226 unmerged: nonDefault.filter((r) => !r.merged).length,
227 withoutTip: nonDefault.filter((r) => r.daysOld === null).length,
228 oldestName,
229 oldestDaysOld: oldestDays,
230 averageAgeDays: avg,
231 medianAgeDays: med === null ? null : Math.round(med),
232 };
233}
234
235function cmpString(a: string, b: string): number {
236 if (a < b) return -1;
237 if (a > b) return 1;
238 return 0;
239}
240
241export function sortBranchRows(
242 rows: readonly BranchReportRow[],
243 sort: BranchSort
244): BranchReportRow[] {
245 const out = rows.slice();
246 switch (sort) {
247 case "name":
248 out.sort((a, b) => cmpString(a.name, b.name));
249 break;
250 case "age-asc":
251 out.sort((a, b) => {
252 // null daysOld sinks to the bottom
253 const av = a.daysOld ?? Number.POSITIVE_INFINITY;
254 const bv = b.daysOld ?? Number.POSITIVE_INFINITY;
255 if (av !== bv) return av - bv;
256 return cmpString(a.name, b.name);
257 });
258 break;
259 case "age-desc":
260 out.sort((a, b) => {
261 const av = a.daysOld ?? -1;
262 const bv = b.daysOld ?? -1;
263 if (av !== bv) return bv - av;
264 return cmpString(a.name, b.name);
265 });
266 break;
267 case "ahead-desc":
268 out.sort((a, b) => {
269 if (a.ahead !== b.ahead) return b.ahead - a.ahead;
270 return cmpString(a.name, b.name);
271 });
272 break;
273 case "behind-desc":
274 out.sort((a, b) => {
275 if (a.behind !== b.behind) return b.behind - a.behind;
276 return cmpString(a.name, b.name);
277 });
278 break;
279 }
280 return out;
281}
282
283export interface BuildReportOptions {
284 branches: readonly BranchInputRow[];
285 defaultBranch: string | null;
286 threshold?: Threshold;
287 sort?: BranchSort;
288 now?: number;
289}
290
291/** One-shot report builder used by the route. */
292export function buildBranchReport(opts: BuildReportOptions): BranchReport {
293 const now = opts.now ?? Date.now();
294 const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
295 const sort = opts.sort ?? DEFAULT_SORT;
296 const rows = opts.branches.map((b) => computeBranchRow(b, now));
297 const sorted = sortBranchRows(rows, sort);
298 const filtered = filterByThreshold(sorted, threshold);
299 return {
300 now,
301 threshold,
302 sort,
303 defaultBranch: opts.defaultBranch,
304 rows: sorted,
305 filtered,
306 buckets: bucketBranches(rows),
307 summary: summariseBranches(rows),
308 };
309}
310
311/** Short human label for category pills. */
312export function categoryLabel(c: BranchAgeCategory): string {
313 switch (c) {
314 case "fresh":
315 return "Fresh";
316 case "aging":
317 return "Aging";
318 case "stale":
319 return "Stale";
320 case "abandoned":
321 return "Abandoned";
322 }
323}
324
325export function thresholdLabel(t: Threshold): string {
326 return t === 0 ? "All branches" : `≥ ${t} days old`;
327}
328
329export function sortLabel(s: BranchSort): string {
330 switch (s) {
331 case "age-desc":
332 return "Oldest first";
333 case "age-asc":
334 return "Newest first";
335 case "name":
336 return "Name A–Z";
337 case "ahead-desc":
338 return "Most ahead";
339 case "behind-desc":
340 return "Most behind";
341 }
342}
343
344export const __internal = {
345 DAY_MS,
346 VALID_THRESHOLDS,
347 VALID_SORTS,
348 DEFAULT_THRESHOLD,
349 DEFAULT_SORT,
350 parseThreshold,
351 parseSort,
352 computeDaysOld,
353 classifyBranchAge,
354 computeBranchRow,
355 bucketBranches,
356 filterByThreshold,
357 summariseBranches,
358 sortBranchRows,
359 buildBranchReport,
360 categoryLabel,
361 thresholdLabel,
362 sortLabel,
363};