CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.
| c02a55b | 1 | /** |
| 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 | ||
| 10 | export const DAY_MS = 24 * 60 * 60 * 1000; | |
| 11 | ||
| 12 | /** Canonical thresholds (in days) exposed to the UI select. */ | |
| 13 | export const VALID_THRESHOLDS = [0, 30, 60, 90, 180] as const; | |
| 14 | export type Threshold = (typeof VALID_THRESHOLDS)[number]; | |
| 15 | ||
| 16 | export const DEFAULT_THRESHOLD: Threshold = 0; | |
| 17 | ||
| 18 | export type BranchSort = | |
| 19 | | "age-desc" | |
| 20 | | "age-asc" | |
| 21 | | "name" | |
| 22 | | "ahead-desc" | |
| 23 | | "behind-desc"; | |
| 24 | ||
| 25 | export const VALID_SORTS: readonly BranchSort[] = [ | |
| 26 | "age-desc", | |
| 27 | "age-asc", | |
| 28 | "name", | |
| 29 | "ahead-desc", | |
| 30 | "behind-desc", | |
| 31 | ]; | |
| 32 | ||
| 33 | export const DEFAULT_SORT: BranchSort = "age-desc"; | |
| 34 | ||
| 35 | /** Raw input row: tip commit + ahead/behind relative to default. */ | |
| 36 | export 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 | ||
| 50 | export type BranchAgeCategory = "fresh" | "aging" | "stale" | "abandoned"; | |
| 51 | ||
| 52 | export 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 | ||
| 66 | export interface BranchBuckets { | |
| 67 | fresh: number; | |
| 68 | aging: number; | |
| 69 | stale: number; | |
| 70 | abandoned: number; | |
| 71 | } | |
| 72 | ||
| 73 | export 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 | ||
| 86 | export 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. */ | |
| 98 | export 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 | ||
| 108 | export 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 | ||
| 114 | function 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. */ | |
| 123 | export 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 | */ | |
| 143 | export 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 | ||
| 153 | export 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 | ||
| 174 | export 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 | ||
| 183 | export 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 | ||
| 193 | function 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 | ||
| 204 | export 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 | ||
| 235 | function cmpString(a: string, b: string): number { | |
| 236 | if (a < b) return -1; | |
| 237 | if (a > b) return 1; | |
| 238 | return 0; | |
| 239 | } | |
| 240 | ||
| 241 | export 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 | ||
| 283 | export 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. */ | |
| 292 | export 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. */ | |
| 312 | export 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 | ||
| 325 | export function thresholdLabel(t: Threshold): string { | |
| 326 | return t === 0 ? "All branches" : `≥ ${t} days old`; | |
| 327 | } | |
| 328 | ||
| 329 | export 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 | ||
| 344 | export 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 | }; |