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.
| 28bc555 | 1 | /** |
| 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 | ||
| 15 | export { | |
| 16 | DEFAULT_WINDOW_DAYS, | |
| 17 | VALID_WINDOWS, | |
| 18 | parseWindow, | |
| 19 | } from "./response-time"; | |
| 20 | ||
| 21 | import { DEFAULT_WINDOW_DAYS } from "./response-time"; | |
| 22 | ||
| 23 | /** Inclusive-below boundaries: a PR with lines === max lands in the NEXT class. */ | |
| 24 | export 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 | ||
| 37 | export type PrSizeClassKey = (typeof PR_SIZE_CLASSES)[number]["key"]; | |
| 38 | ||
| 39 | export const DEFAULT_TOP_N = 10; | |
| 40 | ||
| 41 | export 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 | ||
| 55 | export interface PrSizeStat extends PrSizeInput { | |
| 56 | linesChanged: number; | |
| 57 | sizeClass: PrSizeClassKey; | |
| 58 | } | |
| 59 | ||
| 60 | export 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 | ||
| 73 | export 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 | ||
| 81 | export 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 | ||
| 91 | function 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 | ||
| 97 | export 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 | ||
| 105 | function 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. */ | |
| 112 | function 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 | ||
| 119 | export 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 | ||
| 140 | function 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 | ||
| 152 | export 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 | ||
| 177 | export 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 | ||
| 193 | export 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 | ||
| 210 | export interface BuildPrSizeReportOptions { | |
| 211 | prs: readonly PrSizeInput[]; | |
| 212 | windowDays?: number; | |
| 213 | now?: number; | |
| 214 | topN?: number; | |
| 215 | } | |
| 216 | ||
| 217 | export 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 | ||
| 233 | export 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 | }; |