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-lead-time.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-lead-time.tsBlame240 lines · 1 contributor
8c5346cClaude1/**
2 * Block J29 — Pull request lead-time metric.
3 *
4 * Lead time = `mergedAt - createdAt` for merged PRs. For still-open PRs, we
5 * report the "in-flight" age from now. Open-but-unmerged PRs are excluded
6 * from the summary percentiles; they roll into a separate counter so the
7 * KPIs aren't biased downward by ancient stale drafts.
8 *
9 * Reuses `parseWindow`, `VALID_WINDOWS`, `formatDuration` from the Block J25
10 * response-time helpers to stay DRY + consistent.
11 */
12
13import {
14 parseWindow as parseWindowJ25,
15 VALID_WINDOWS as VALID_WINDOWS_J25,
16 formatDuration as formatDurationJ25,
17 DEFAULT_WINDOW_DAYS as DEFAULT_WINDOW_DAYS_J25,
18} from "./response-time";
19
20export const DEFAULT_WINDOW_DAYS = DEFAULT_WINDOW_DAYS_J25;
21export const VALID_WINDOWS = VALID_WINDOWS_J25;
22export const parseWindow = parseWindowJ25;
23export const formatDuration = formatDurationJ25;
24
25const HOUR = 60 * 60 * 1000;
26const DAY = 24 * HOUR;
27
28export interface PrLeadTimeInput {
29 id: string;
30 number: number;
31 title: string;
32 state: string; // "open" | "closed" | "merged" (+ anything else)
33 isDraft?: boolean;
34 createdAt: Date | string;
35 mergedAt?: Date | string | null;
36}
37
38export interface PrLeadTimeStat {
39 id: string;
40 number: number;
41 title: string;
42 state: string;
43 isDraft: boolean;
44 createdAt: number;
45 mergedAt: number | null;
46 leadMs: number | null; // null for unmerged
47 inFlightMs: number | null; // null for merged/closed
48}
49
50export interface PrLeadTimeSummary {
51 total: number;
52 merged: number;
53 openNonDraft: number;
54 openDraft: number;
55 closedUnmerged: number;
56 medianMs: number | null;
57 meanMs: number | null;
58 p90Ms: number | null;
59 fastestMs: number | null;
60 slowestMs: number | null;
61}
62
63export interface PrLeadTimeBuckets {
64 /** Merged ≤ 1 hour. */
65 within1h: number;
66 /** > 1h and ≤ 24h. */
67 within1d: number;
68 /** > 24h and ≤ 7d. */
69 within1w: number;
70 /** > 7d. */
71 over1w: number;
72}
73
74export interface PrLeadTimeReport {
75 windowDays: number;
76 now: number;
77 perPr: PrLeadTimeStat[];
78 summary: PrLeadTimeSummary;
79 buckets: PrLeadTimeBuckets;
80 /** Oldest still-open (non-draft) PRs — id list, for the in-flight table. */
81 oldestOpenIds: string[];
82}
83
84function toTime(v: Date | string | null | undefined): number | null {
85 if (v === null || v === undefined) return null;
86 if (v instanceof Date) {
87 const t = v.getTime();
88 return Number.isNaN(t) ? null : t;
89 }
90 if (typeof v === "string") {
91 const t = new Date(v).getTime();
92 return Number.isNaN(t) ? null : t;
93 }
94 return null;
95}
96
97export function computeLeadTime(
98 input: { createdAt: Date | string; mergedAt?: Date | string | null }
99): number | null {
100 const created = toTime(input.createdAt);
101 const merged = toTime(input.mergedAt);
102 if (created === null || merged === null) return null;
103 return Math.max(0, merged - created);
104}
105
106export function computePrStats(
107 prs: readonly PrLeadTimeInput[],
108 windowDays: number,
109 now: number
110): PrLeadTimeStat[] {
111 const cutoff =
112 windowDays > 0 ? now - windowDays * DAY : Number.NEGATIVE_INFINITY;
113 const out: PrLeadTimeStat[] = [];
114 for (const pr of prs) {
115 const created = toTime(pr.createdAt);
116 if (created === null) continue; // unparseable → drop
117 const merged = toTime(pr.mergedAt ?? null);
118 // Window filter: anchor on mergedAt for merged PRs, createdAt for the rest.
119 const anchor = merged ?? created;
120 if (anchor < cutoff) continue;
121 const leadMs =
122 merged !== null ? Math.max(0, merged - created) : null;
123 const isMerged = merged !== null;
124 const inFlightMs =
125 !isMerged && pr.state === "open" ? Math.max(0, now - created) : null;
126 out.push({
127 id: pr.id,
128 number: pr.number,
129 title: pr.title,
130 state: pr.state,
131 isDraft: !!pr.isDraft,
132 createdAt: created,
133 mergedAt: merged,
134 leadMs,
135 inFlightMs,
136 });
137 }
138 return out;
139}
140
141function percentile(sorted: readonly number[], p: number): number | null {
142 if (sorted.length === 0) return null;
143 if (sorted.length === 1) return sorted[0]!;
144 const rank = (p / 100) * (sorted.length - 1);
145 const lo = Math.floor(rank);
146 const hi = Math.ceil(rank);
147 if (lo === hi) return sorted[lo]!;
148 const w = rank - lo;
149 return sorted[lo]! + (sorted[hi]! - sorted[lo]!) * w;
150}
151
152export function summariseLeadTimes(
153 stats: readonly PrLeadTimeStat[]
154): PrLeadTimeSummary {
155 const merged = stats.filter(
156 (s): s is PrLeadTimeStat & { leadMs: number } => s.leadMs !== null
157 );
158 const leadMs = merged.map((s) => s.leadMs).sort((a, b) => a - b);
159 const openNonDraft = stats.filter(
160 (s) => s.leadMs === null && s.state === "open" && !s.isDraft
161 ).length;
162 const openDraft = stats.filter(
163 (s) => s.leadMs === null && s.state === "open" && s.isDraft
164 ).length;
165 const closedUnmerged = stats.filter(
166 (s) => s.leadMs === null && s.state !== "open"
167 ).length;
168 const sum = leadMs.reduce((a, b) => a + b, 0);
169 const mean = leadMs.length > 0 ? Math.round(sum / leadMs.length) : null;
170 const med = percentile(leadMs, 50);
171 const p90 = percentile(leadMs, 90);
172 return {
173 total: stats.length,
174 merged: merged.length,
175 openNonDraft,
176 openDraft,
177 closedUnmerged,
178 medianMs: med === null ? null : Math.round(med),
179 meanMs: mean,
180 p90Ms: p90 === null ? null : Math.round(p90),
181 fastestMs: leadMs.length > 0 ? leadMs[0]! : null,
182 slowestMs: leadMs.length > 0 ? leadMs[leadMs.length - 1]! : null,
183 };
184}
185
186export function bucketLeadTimes(
187 stats: readonly PrLeadTimeStat[]
188): PrLeadTimeBuckets {
189 const out: PrLeadTimeBuckets = {
190 within1h: 0,
191 within1d: 0,
192 within1w: 0,
193 over1w: 0,
194 };
195 for (const s of stats) {
196 if (s.leadMs === null) continue;
197 if (s.leadMs <= HOUR) out.within1h++;
198 else if (s.leadMs <= DAY) out.within1d++;
199 else if (s.leadMs <= 7 * DAY) out.within1w++;
200 else out.over1w++;
201 }
202 return out;
203}
204
205export interface BuildReportOptions {
206 prs: readonly PrLeadTimeInput[];
207 windowDays: number;
208 now?: number;
209}
210
211export function buildLeadTimeReport(
212 opts: BuildReportOptions
213): PrLeadTimeReport {
214 const now = opts.now ?? Date.now();
215 const perPr = computePrStats(opts.prs, opts.windowDays, now);
216 const open = perPr
217 .filter((s) => s.leadMs === null && s.state === "open" && !s.isDraft)
218 .sort((a, b) => a.createdAt - b.createdAt);
219 return {
220 windowDays: opts.windowDays,
221 now,
222 perPr,
223 summary: summariseLeadTimes(perPr),
224 buckets: bucketLeadTimes(perPr),
225 oldestOpenIds: open.map((s) => s.id),
226 };
227}
228
229export const __internal = {
230 DEFAULT_WINDOW_DAYS,
231 VALID_WINDOWS,
232 parseWindow,
233 formatDuration,
234 computeLeadTime,
235 computePrStats,
236 summariseLeadTimes,
237 bucketLeadTimes,
238 buildLeadTimeReport,
239 toTime,
240};