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

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

pr-size.tsBlame246 lines · 1 contributor
28bc555Claude1/**
2 * Block J32 — PR size distribution metric.
3 *
4 * Pure rollup of "how big are our PRs?". Given a set of `{additions,
5 * deletions, files}` records (optionally inside a time window), classifies
6 * each PR into five well-known size classes (XS/S/M/L/XL), computes
7 * p50/p90/mean/largest/smallest over the window, and emits a bucket
8 * histogram + the N largest open PRs.
9 *
10 * Size thresholds follow the common "≤10 / ≤50 / ≤250 / ≤1000 / >1000"
11 * heuristic. A PR's size is `additions + deletions` (binaries contribute
12 * 0 lines — the numstat parser treats `-` as zero).
13 */
14
15export {
16 DEFAULT_WINDOW_DAYS,
17 VALID_WINDOWS,
18 parseWindow,
19} from "./response-time";
20
21import { DEFAULT_WINDOW_DAYS } from "./response-time";
22
23/** Inclusive-below boundaries: a PR with lines === max lands in the NEXT class. */
24export const PR_SIZE_CLASSES = [
25 { key: "xs", label: "XS", max: 10, description: "≤ 10 lines" },
26 { key: "s", label: "S", max: 50, description: "11 – 50 lines" },
27 { key: "m", label: "M", max: 250, description: "51 – 250 lines" },
28 { key: "l", label: "L", max: 1000, description: "251 – 1000 lines" },
29 {
30 key: "xl",
31 label: "XL",
32 max: Number.POSITIVE_INFINITY,
33 description: "> 1000 lines",
34 },
35] as const;
36
37export type PrSizeClassKey = (typeof PR_SIZE_CLASSES)[number]["key"];
38
39export const DEFAULT_TOP_N = 10;
40
41export interface PrSizeInput {
42 id: string;
43 number: number;
44 title: string;
45 state: string; // open, closed, merged
46 isDraft?: boolean;
47 createdAt: Date | string;
48 mergedAt?: Date | string | null;
49 closedAt?: Date | string | null;
50 additions: number;
51 deletions: number;
52 files: number;
53}
54
55export interface PrSizeStat extends PrSizeInput {
56 linesChanged: number;
57 sizeClass: PrSizeClassKey;
58}
59
60export interface PrSizeSummary {
61 total: number;
62 merged: number;
63 open: number;
64 medianLines: number;
65 meanLines: number;
66 p90Lines: number;
67 largestLines: number;
68 smallestLines: number;
69 /** % of PRs classified `xs` or `s`, rounded to one decimal. */
70 smallPrRatio: number;
71}
72
73export interface PrSizeBucket {
74 key: PrSizeClassKey;
75 label: string;
76 description: string;
77 count: number;
78 bytes: number; // total lines changed in this bucket
79}
80
81export interface PrSizeReport {
82 windowDays: number;
83 now: number;
84 perPr: PrSizeStat[];
85 summary: PrSizeSummary;
86 buckets: PrSizeBucket[];
87 /** `N` largest PRs in the window, descending. */
88 largest: PrSizeStat[];
89}
90
91function toTime(value: Date | string | null | undefined): number | null {
92 if (!value) return null;
93 const t = value instanceof Date ? value.getTime() : Date.parse(value);
94 return Number.isFinite(t) ? t : null;
95}
96
97export function classifyPrSize(linesChanged: number): PrSizeClassKey {
98 if (!Number.isFinite(linesChanged) || linesChanged < 0) return "xs";
99 for (const c of PR_SIZE_CLASSES) {
100 if (linesChanged <= c.max) return c.key;
101 }
102 return "xl";
103}
104
105function safeLines(additions: number, deletions: number): number {
106 const a = Number.isFinite(additions) && additions > 0 ? additions : 0;
107 const d = Number.isFinite(deletions) && deletions > 0 ? deletions : 0;
108 return a + d;
109}
110
111/** Window anchor: mergedAt for merged PRs, createdAt otherwise. */
112function anchorTime(pr: PrSizeInput): number | null {
113 if (pr.state === "merged" && pr.mergedAt) {
114 return toTime(pr.mergedAt);
115 }
116 return toTime(pr.createdAt);
117}
118
119export function computePrSizeStats(
120 prs: readonly PrSizeInput[],
121 windowDays: number,
122 now: number = Date.now()
123): PrSizeStat[] {
124 const cutoff = windowDays > 0 ? now - windowDays * 24 * 60 * 60 * 1000 : null;
125 const out: PrSizeStat[] = [];
126 for (const pr of prs) {
127 const anchor = anchorTime(pr);
128 if (anchor === null) continue;
129 if (cutoff !== null && anchor < cutoff) continue;
130 const linesChanged = safeLines(pr.additions, pr.deletions);
131 out.push({
132 ...pr,
133 linesChanged,
134 sizeClass: classifyPrSize(linesChanged),
135 });
136 }
137 return out;
138}
139
140function percentile(sortedAsc: readonly number[], p: number): number {
141 const n = sortedAsc.length;
142 if (n === 0) return 0;
143 if (n === 1) return sortedAsc[0]!;
144 const rank = (p / 100) * (n - 1);
145 const lo = Math.floor(rank);
146 const hi = Math.ceil(rank);
147 if (lo === hi) return sortedAsc[lo]!;
148 const frac = rank - lo;
149 return Math.round(sortedAsc[lo]! + frac * (sortedAsc[hi]! - sortedAsc[lo]!));
150}
151
152export function summarisePrSizes(stats: readonly PrSizeStat[]): PrSizeSummary {
153 const sizes = stats.map((s) => s.linesChanged).sort((a, b) => a - b);
154 const n = sizes.length;
155 const total = sizes.reduce((acc, v) => acc + v, 0);
156 const merged = stats.filter((s) => s.state === "merged").length;
157 const open = stats.filter(
158 (s) => s.state === "open" && !s.isDraft
159 ).length;
160 const smallCount = stats.filter(
161 (s) => s.sizeClass === "xs" || s.sizeClass === "s"
162 ).length;
163
164 return {
165 total: n,
166 merged,
167 open,
168 medianLines: n === 0 ? 0 : percentile(sizes, 50),
169 meanLines: n === 0 ? 0 : Math.round(total / n),
170 p90Lines: n === 0 ? 0 : percentile(sizes, 90),
171 largestLines: n === 0 ? 0 : sizes[n - 1]!,
172 smallestLines: n === 0 ? 0 : sizes[0]!,
173 smallPrRatio: n === 0 ? 0 : Math.round((smallCount / n) * 1000) / 10,
174 };
175}
176
177export function bucketPrSizes(stats: readonly PrSizeStat[]): PrSizeBucket[] {
178 const out: PrSizeBucket[] = PR_SIZE_CLASSES.map((c) => ({
179 key: c.key,
180 label: c.label,
181 description: c.description,
182 count: 0,
183 bytes: 0,
184 }));
185 for (const s of stats) {
186 const b = out.find((x) => x.key === s.sizeClass)!;
187 b.count++;
188 b.bytes += s.linesChanged;
189 }
190 return out;
191}
192
193export function topLargestPrs(
194 stats: readonly PrSizeStat[],
195 limit: number = DEFAULT_TOP_N
196): PrSizeStat[] {
197 const n =
198 Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : DEFAULT_TOP_N;
199 return stats
200 .slice()
201 .sort((a, b) => {
202 if (a.linesChanged !== b.linesChanged) {
203 return b.linesChanged - a.linesChanged;
204 }
205 return b.number - a.number;
206 })
207 .slice(0, n);
208}
209
210export interface BuildPrSizeReportOptions {
211 prs: readonly PrSizeInput[];
212 windowDays?: number;
213 now?: number;
214 topN?: number;
215}
216
217export function buildPrSizeReport(
218 opts: BuildPrSizeReportOptions
219): PrSizeReport {
220 const now = opts.now ?? Date.now();
221 const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS;
222 const perPr = computePrSizeStats(opts.prs, windowDays, now);
223 return {
224 windowDays,
225 now,
226 perPr,
227 summary: summarisePrSizes(perPr),
228 buckets: bucketPrSizes(perPr),
229 largest: topLargestPrs(perPr, opts.topN ?? DEFAULT_TOP_N),
230 };
231}
232
233export const __internal = {
234 PR_SIZE_CLASSES,
235 DEFAULT_TOP_N,
236 classifyPrSize,
237 computePrSizeStats,
238 summarisePrSizes,
239 bucketPrSizes,
240 topLargestPrs,
241 buildPrSizeReport,
242 toTime,
243 anchorTime,
244 percentile,
245 safeLines,
246};