delivery-friction-analyzer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,414 @@
1
+ import { PR_CLASS_FALLBACK } from "../profile/pr-class.js";
2
+
3
+ export const FRICTION_METRICS_VERSION = "friction-metrics.v1";
4
+ export const FRICTION_METRIC_CONSTANTS = Object.freeze({
5
+ lowSignalRoleWeight: 0.25,
6
+ smallDiffWideSpread: Object.freeze({
7
+ maxCoreChangedLines: 600,
8
+ minCoreFiles: 3,
9
+ }),
10
+ });
11
+
12
+ const COMMENT_SOURCES = [
13
+ "copilot",
14
+ "human_reviewer",
15
+ "author_reply",
16
+ "github_actions_bot",
17
+ "dependency_bot",
18
+ "code_scanning",
19
+ "unknown_bot",
20
+ "unknown",
21
+ ];
22
+
23
+ const LOW_SIGNAL_ROLES = new Set([
24
+ "generated_docs",
25
+ "release_notes",
26
+ "planning_docs",
27
+ "marketing_site",
28
+ "fixtures",
29
+ "generated_or_vendored",
30
+ ]);
31
+
32
+ const CORE_ROLES = new Set(["core_product_code", "product_ui"]);
33
+ const SUCCESS_CONCLUSIONS = new Set(["success", "successful", "passed", "neutral", "skipped"]);
34
+ const FAILURE_CONCLUSIONS = new Set([
35
+ "failure",
36
+ "failed",
37
+ "timed_failure",
38
+ "startup_failure",
39
+ "action_required",
40
+ "timed_out",
41
+ "stale",
42
+ "error",
43
+ ]);
44
+ const CHECK_FAILURE_CONCLUSIONS = new Set([
45
+ ...FAILURE_CONCLUSIONS,
46
+ "cancelled",
47
+ "canceled",
48
+ ]);
49
+
50
+ function linesOf(file = {}) {
51
+ return Number(file.additions ?? 0) + Number(file.deletions ?? 0);
52
+ }
53
+
54
+ function safeCount(value) {
55
+ return Number.isFinite(value) ? value : 0;
56
+ }
57
+
58
+ function round(value, digits = 4) {
59
+ if (!Number.isFinite(value)) return null;
60
+ const factor = 10 ** digits;
61
+ return Math.round(value * factor) / factor;
62
+ }
63
+
64
+ function density(count, denominator) {
65
+ return denominator > 0 ? round((count / denominator) * 100) : 0;
66
+ }
67
+
68
+ function directoryOf(path = "") {
69
+ const parts = String(path).split("/");
70
+ return parts.length > 1 ? parts.slice(0, -1).join("/") : ".";
71
+ }
72
+
73
+ function sumBy(items, keyFn, valueFn = () => 1) {
74
+ const totals = {};
75
+ for (const item of items) {
76
+ const key = keyFn(item);
77
+ totals[key] = (totals[key] ?? 0) + valueFn(item);
78
+ }
79
+ return Object.fromEntries(Object.entries(totals).sort(([left], [right]) => left.localeCompare(right)));
80
+ }
81
+
82
+ function summarizeFiles(files = []) {
83
+ const nonGeneratedFiles = files.filter(file => !file.generated);
84
+ const coreFiles = nonGeneratedFiles.filter(file => CORE_ROLES.has(file.role));
85
+ const lowSignalFiles = files.filter(file => file.generated || LOW_SIGNAL_ROLES.has(file.role));
86
+ const directories = new Set(nonGeneratedFiles.map(file => directoryOf(file.path)));
87
+ const functionalSurfaces = new Set(nonGeneratedFiles.map(file => file.functionalSurface ?? "unknown"));
88
+ const nonGeneratedLines = nonGeneratedFiles.reduce((total, file) => total + linesOf(file), 0);
89
+ const coreLines = coreFiles.reduce((total, file) => total + linesOf(file), 0);
90
+ const weightedChangedLines = files.reduce((total, file) => {
91
+ if (file.generated || file.role === "generated_or_vendored") return total;
92
+ const weight = LOW_SIGNAL_ROLES.has(file.role) ? FRICTION_METRIC_CONSTANTS.lowSignalRoleWeight : 1;
93
+ return total + (linesOf(file) * weight);
94
+ }, 0);
95
+ const { maxCoreChangedLines, minCoreFiles } = FRICTION_METRIC_CONSTANTS.smallDiffWideSpread;
96
+
97
+ return {
98
+ allFiles: files.length,
99
+ nonGeneratedFiles: nonGeneratedFiles.length,
100
+ coreFiles: coreFiles.length,
101
+ lowSignalFiles: lowSignalFiles.length,
102
+ directories: directories.size,
103
+ functionalSurfaces: functionalSurfaces.size,
104
+ changedLines: files.reduce((total, file) => total + linesOf(file), 0),
105
+ nonGeneratedChangedLines: nonGeneratedLines,
106
+ coreChangedLines: coreLines,
107
+ weightedChangedLines: round(weightedChangedLines, 2),
108
+ linesPerNonGeneratedFile: nonGeneratedFiles.length ? round(nonGeneratedLines / nonGeneratedFiles.length, 2) : 0,
109
+ smallDiffWideSpread: coreLines > 0 && coreLines <= maxCoreChangedLines && coreFiles.length >= minCoreFiles,
110
+ byCategory: sumBy(files, file => file.category, linesOf),
111
+ byRole: sumBy(files, file => file.role, linesOf),
112
+ byFunctionalSurface: sumBy(files, file => file.functionalSurface ?? "unknown", linesOf),
113
+ classificationSources: sumBy(files, file => file.classificationSource ?? "unknown"),
114
+ formulaInputs: {
115
+ lowSignalRoleWeight: FRICTION_METRIC_CONSTANTS.lowSignalRoleWeight,
116
+ lowSignalRoles: [...LOW_SIGNAL_ROLES].sort(),
117
+ coreRoles: [...CORE_ROLES].sort(),
118
+ smallDiffWideSpread: {
119
+ maxCoreChangedLines,
120
+ minCoreFiles,
121
+ },
122
+ },
123
+ };
124
+ }
125
+
126
+ function summarizeComments(pr, changedLines, changedFiles) {
127
+ const bySource = {};
128
+ for (const source of COMMENT_SOURCES) {
129
+ bySource[source] = pr.reviewComments?.bySource?.[source] ?? 0;
130
+ }
131
+
132
+ return {
133
+ totalCount: pr.reviewComments?.totalCount ?? Object.values(bySource).reduce((sum, count) => sum + count, 0),
134
+ bySource,
135
+ densityPer100ChangedLines: Object.fromEntries(
136
+ COMMENT_SOURCES.map(source => [source, density(bySource[source], changedLines)]),
137
+ ),
138
+ densityPerChangedFile: Object.fromEntries(
139
+ COMMENT_SOURCES.map(source => [source, changedFiles > 0 ? round(bySource[source] / changedFiles) : 0]),
140
+ ),
141
+ };
142
+ }
143
+
144
+ function summarizeChecks(pr) {
145
+ const byConclusion = sumBy(pr.checkRuns ?? [], check => String(check.conclusion ?? "unknown").toLowerCase());
146
+ const failedCheckRuns = (pr.checkRuns ?? []).filter(check => {
147
+ const conclusion = String(check.conclusion ?? "").toLowerCase();
148
+ return CHECK_FAILURE_CONCLUSIONS.has(conclusion);
149
+ }).length;
150
+ const successfulCheckRuns = (pr.checkRuns ?? []).filter(check => {
151
+ const conclusion = String(check.conclusion ?? "").toLowerCase();
152
+ return SUCCESS_CONCLUSIONS.has(conclusion);
153
+ }).length;
154
+
155
+ const workflowConclusions = pr.workflowRuns?.conclusions ?? {};
156
+ const failedWorkflowRuns = Object.entries(workflowConclusions)
157
+ .filter(([conclusion]) => FAILURE_CONCLUSIONS.has(String(conclusion).toLowerCase()))
158
+ .reduce((sum, [, count]) => sum + count, 0);
159
+ const cancelledWorkflowRuns = safeCount(workflowConclusions.cancelled) + safeCount(workflowConclusions.canceled);
160
+ const workflowRunCount = pr.workflowRuns?.totalCount ?? null;
161
+
162
+ return {
163
+ checkRuns: {
164
+ totalCount: (pr.checkRuns ?? []).length,
165
+ successfulCount: successfulCheckRuns,
166
+ failedCount: failedCheckRuns,
167
+ byConclusion,
168
+ },
169
+ workflowRuns: {
170
+ source: pr.workflowRuns?.source ?? "unavailable",
171
+ totalCount: workflowRunCount,
172
+ conclusions: workflowConclusions,
173
+ failedCount: failedWorkflowRuns,
174
+ cancelledCount: cancelledWorkflowRuns,
175
+ coverage: Number.isInteger(workflowRunCount) ? "observed" : "unavailable",
176
+ },
177
+ };
178
+ }
179
+
180
+ function hoursBetween(start, end) {
181
+ if (!start || !end) return null;
182
+ const startMs = Date.parse(start);
183
+ const endMs = Date.parse(end);
184
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) return null;
185
+ return round((endMs - startMs) / 36e5, 2);
186
+ }
187
+
188
+ function summarizeLifecycle(pr) {
189
+ return {
190
+ timeToFirstReviewHours: hoursBetween(pr.lifecycle?.createdAt, pr.lifecycle?.firstReviewAt),
191
+ timeToMergeHours: hoursBetween(pr.lifecycle?.createdAt, pr.lifecycle?.mergedAt),
192
+ reviewWindowHours: hoursBetween(pr.lifecycle?.firstReviewAt, pr.lifecycle?.lastReviewAt),
193
+ };
194
+ }
195
+
196
+ function summarizeIteration(pr) {
197
+ const firstReviewAt = pr.lifecycle?.firstReviewAt ? Date.parse(pr.lifecycle.firstReviewAt) : null;
198
+ const reviews = pr.reviews ?? [];
199
+ const commitsAfterFirstReview = Number.isFinite(firstReviewAt)
200
+ ? (pr.commits ?? []).filter(commit => {
201
+ const authoredAt = Date.parse(commit.authoredDate);
202
+ return Number.isFinite(authoredAt) && authoredAt > firstReviewAt;
203
+ }).length
204
+ : reviews.length === 0 ? 0 : null;
205
+
206
+ return {
207
+ commitCount: (pr.commits ?? []).length,
208
+ commitsAfterFirstReview,
209
+ reviewAttempts: reviews.length,
210
+ failedReviewAttempts: reviews.filter(review => review.failedAttempt).length,
211
+ };
212
+ }
213
+
214
+ function summarizeDiffGrowth(pr, diffAtMerge = pr.diffAtMerge ?? {}) {
215
+ const openDiff = pr.prOpenDiff ?? {};
216
+ const canCompare = ["direct", "reconstructed"].includes(openDiff.source)
217
+ && Number.isInteger(openDiff.additions)
218
+ && Number.isInteger(openDiff.deletions)
219
+ && Number.isInteger(openDiff.changedFiles);
220
+
221
+ if (!canCompare) {
222
+ return {
223
+ status: "unavailable",
224
+ source: openDiff.source ?? "unavailable",
225
+ confidence: openDiff.confidence ?? "unavailable",
226
+ changedLineGrowthRatio: null,
227
+ changedFileGrowthRatio: null,
228
+ };
229
+ }
230
+
231
+ const openLines = openDiff.additions + openDiff.deletions;
232
+ const mergeLines = Number(diffAtMerge.additions ?? 0) + Number(diffAtMerge.deletions ?? 0);
233
+
234
+ return {
235
+ status: "computed",
236
+ source: openDiff.source,
237
+ confidence: openDiff.confidence,
238
+ changedLineGrowthRatio: openLines > 0 ? round(mergeLines / openLines) : null,
239
+ changedFileGrowthRatio: openDiff.changedFiles > 0 ? round((diffAtMerge.changedFiles ?? 0) / openDiff.changedFiles) : null,
240
+ };
241
+ }
242
+
243
+ function componentMetric(value, inputs) {
244
+ return {
245
+ formulaVersion: FRICTION_METRICS_VERSION,
246
+ value,
247
+ inputs,
248
+ };
249
+ }
250
+
251
+ function summarizeComponents({ pr, comments, files, checks, iteration, diffGrowth, changedLines, reviewThreads }) {
252
+ const validationFailures = checks.checkRuns.failedCount + checks.workflowRuns.failedCount + checks.workflowRuns.cancelledCount;
253
+ const postReviewCommits = iteration.commitsAfterFirstReview;
254
+ const planningLines = (pr.files ?? []).filter(file => file.role === "planning_docs").reduce((sum, file) => sum + linesOf(file), 0);
255
+ const nonCoreSurfaces = Math.max(files.functionalSurfaces - 1, 0);
256
+ const reviewThreadCount = reviewThreads.totalCount ?? 0;
257
+
258
+ return {
259
+ commentSourceDensity: componentMetric(comments.totalCount, {
260
+ totalComments: comments.totalCount,
261
+ changedLines,
262
+ bySourcePer100ChangedLines: comments.densityPer100ChangedLines,
263
+ }),
264
+ functionalSurfaceDensity: componentMetric(files.functionalSurfaces, {
265
+ functionalSurfaces: files.functionalSurfaces,
266
+ nonGeneratedChangedLines: files.nonGeneratedChangedLines,
267
+ linesBySurface: files.byFunctionalSurface,
268
+ }),
269
+ iterationDrag: componentMetric(postReviewCommits === null ? null : postReviewCommits + reviewThreadCount + iteration.failedReviewAttempts, {
270
+ commitsAfterFirstReview: iteration.commitsAfterFirstReview,
271
+ reviewThreads: reviewThreadCount,
272
+ failedReviewAttempts: iteration.failedReviewAttempts,
273
+ }),
274
+ diffGrowthRatio: componentMetric(diffGrowth.changedLineGrowthRatio, diffGrowth),
275
+ changedFileSpread: componentMetric(files.coreFiles + files.directories + files.functionalSurfaces, {
276
+ coreFiles: files.coreFiles,
277
+ directories: files.directories,
278
+ functionalSurfaces: files.functionalSurfaces,
279
+ }),
280
+ validationGapScore: componentMetric(validationFailures, {
281
+ failedCheckRuns: checks.checkRuns.failedCount,
282
+ failedWorkflowRuns: checks.workflowRuns.failedCount,
283
+ cancelledWorkflowRuns: checks.workflowRuns.cancelledCount,
284
+ workflowCoverage: checks.workflowRuns.coverage,
285
+ }),
286
+ planningGapScore: componentMetric(planningLines > 0 ? 1 : 0, {
287
+ planningChangedLines: planningLines,
288
+ source: "repository_profile",
289
+ }),
290
+ reviewSurpriseScore: componentMetric(nonCoreSurfaces, {
291
+ functionalSurfaces: files.functionalSurfaces,
292
+ method: "deterministic_surface_spread_without_title_nlp",
293
+ }),
294
+ fixAmplification: componentMetric(postReviewCommits, {
295
+ commitsAfterFirstReview: iteration.commitsAfterFirstReview,
296
+ diffGrowthStatus: diffGrowth.status,
297
+ changedLineGrowthRatio: diffGrowth.changedLineGrowthRatio,
298
+ }),
299
+ };
300
+ }
301
+
302
+ export function computePullRequestMetrics(pr) {
303
+ const diffAtMerge = pr.diffAtMerge ?? { additions: 0, deletions: 0, changedFiles: 0 };
304
+ const reviewThreads = pr.reviewThreads ?? {
305
+ source: "unavailable",
306
+ totalCount: 0,
307
+ resolvedCount: 0,
308
+ outdatedCount: 0,
309
+ };
310
+ const files = summarizeFiles(pr.files ?? []);
311
+ const changedLines = Number(diffAtMerge.additions ?? 0) + Number(diffAtMerge.deletions ?? 0);
312
+ const comments = summarizeComments(pr, changedLines, diffAtMerge.changedFiles ?? 0);
313
+ const checks = summarizeChecks(pr);
314
+ const lifecycle = summarizeLifecycle(pr);
315
+ const iteration = summarizeIteration(pr);
316
+ const diffGrowth = summarizeDiffGrowth(pr, diffAtMerge);
317
+ const components = summarizeComponents({ pr, comments, files, checks, iteration, diffGrowth, changedLines, reviewThreads });
318
+
319
+ return {
320
+ metricVersion: FRICTION_METRICS_VERSION,
321
+ number: pr.number,
322
+ title: pr.title,
323
+ url: pr.url,
324
+ state: pr.state,
325
+ prClass: pr.prClass ?? { ...PR_CLASS_FALLBACK },
326
+ coverage: {
327
+ prOpenDiff: {
328
+ source: pr.prOpenDiff?.source ?? "unavailable",
329
+ confidence: pr.prOpenDiff?.confidence ?? "unavailable",
330
+ status: diffGrowth.status,
331
+ },
332
+ reviewThreads: { source: reviewThreads.source ?? "unavailable" },
333
+ workflowRuns: {
334
+ source: pr.workflowRuns?.source ?? "unavailable",
335
+ status: checks.workflowRuns.coverage,
336
+ },
337
+ },
338
+ diffAtMerge: {
339
+ ...diffAtMerge,
340
+ changedLines,
341
+ },
342
+ files,
343
+ review: {
344
+ comments,
345
+ threads: reviewThreads,
346
+ decision: pr.reviewDecision ?? {
347
+ state: "unavailable",
348
+ humanApproved: false,
349
+ humanChangesRequested: false,
350
+ humanReviewerCount: 0,
351
+ source: "unavailable",
352
+ },
353
+ },
354
+ ci: checks,
355
+ lifecycle,
356
+ iteration,
357
+ components,
358
+ };
359
+ }
360
+
361
+ function rankBy(pullRequests, metricName) {
362
+ return [...pullRequests]
363
+ .sort((left, right) => {
364
+ const leftValue = left.components[metricName]?.value;
365
+ const rightValue = right.components[metricName]?.value;
366
+ if (leftValue === null && rightValue !== null) return 1;
367
+ if (rightValue === null && leftValue !== null) return -1;
368
+ const delta = (rightValue ?? 0) - (leftValue ?? 0);
369
+ return delta || left.number - right.number;
370
+ })
371
+ .map(pr => ({
372
+ number: pr.number,
373
+ title: pr.title,
374
+ value: pr.components[metricName]?.value === undefined ? 0 : pr.components[metricName].value,
375
+ }));
376
+ }
377
+
378
+ export function computeRepositoryMetrics(normalizedBundle) {
379
+ const pullRequests = (normalizedBundle.pullRequests ?? []).map(computePullRequestMetrics);
380
+ const totals = pullRequests.reduce((summary, pr) => {
381
+ summary.pullRequests += 1;
382
+ summary.changedLines += pr.diffAtMerge.changedLines;
383
+ summary.nonGeneratedChangedLines += pr.files.nonGeneratedChangedLines;
384
+ summary.reviewComments += pr.review.comments.totalCount;
385
+ summary.reviewThreads += pr.review.threads.totalCount;
386
+ summary.failedChecks += pr.ci.checkRuns.failedCount;
387
+ summary.cancelledWorkflowRuns += pr.ci.workflowRuns.cancelledCount;
388
+ return summary;
389
+ }, {
390
+ pullRequests: 0,
391
+ changedLines: 0,
392
+ nonGeneratedChangedLines: 0,
393
+ reviewComments: 0,
394
+ reviewThreads: 0,
395
+ failedChecks: 0,
396
+ cancelledWorkflowRuns: 0,
397
+ });
398
+
399
+ return {
400
+ metricVersion: FRICTION_METRICS_VERSION,
401
+ targetRepository: normalizedBundle.targetRepository,
402
+ ...(normalizedBundle.analysisFilter ? { analysisFilter: normalizedBundle.analysisFilter } : {}),
403
+ totals,
404
+ rankings: {
405
+ reviewChurn: rankBy(pullRequests, "iterationDrag"),
406
+ changedFileSpread: rankBy(pullRequests, "changedFileSpread"),
407
+ validationGap: rankBy(pullRequests, "validationGapScore"),
408
+ planningGap: rankBy(pullRequests, "planningGapScore"),
409
+ reviewSurprise: rankBy(pullRequests, "reviewSurpriseScore"),
410
+ fixAmplification: rankBy(pullRequests, "fixAmplification"),
411
+ },
412
+ pullRequests,
413
+ };
414
+ }
@@ -0,0 +1,168 @@
1
+ import { classifyCommentSource, groupByCommentSource } from "../github/comment-source.js";
2
+ import { classifyFilePath } from "../profile/file-role.js";
3
+ import { assertValidPrClassRules, classifyPullRequest } from "../profile/pr-class.js";
4
+
5
+ function minDate(values) {
6
+ return values.filter(Boolean).sort()[0] ?? null;
7
+ }
8
+
9
+ function maxDate(values) {
10
+ return values.filter(Boolean).sort().at(-1) ?? null;
11
+ }
12
+
13
+ function flattenThreadComments(reviewThreads = {}) {
14
+ return (reviewThreads.nodes ?? []).flatMap(thread => (
15
+ (thread.comments ?? []).map(comment => ({
16
+ ...comment,
17
+ threadId: thread.id,
18
+ path: comment.path ?? thread.path,
19
+ isResolved: thread.isResolved,
20
+ isOutdated: thread.isOutdated,
21
+ }))
22
+ ));
23
+ }
24
+
25
+ function normalizeReview(review, { pullRequestAuthorLogin } = {}) {
26
+ const author = review.author ?? {};
27
+ return {
28
+ id: review.id,
29
+ submittedAt: review.submittedAt,
30
+ state: review.state,
31
+ commitOid: review.commitOid ?? review.commit?.oid ?? null,
32
+ source: classifyCommentSource(author, { pullRequestAuthorLogin }),
33
+ generatedCommentCount: review.generatedCommentCount ?? null,
34
+ failedAttempt: Boolean(review.failedAttempt),
35
+ };
36
+ }
37
+
38
+ function reviewAuthorKey(review) {
39
+ const author = review.author ?? {};
40
+ return author.login ?? author.id ?? author.node_id ?? author.nodeId ?? review.id ?? null;
41
+ }
42
+
43
+ function reviewState(review) {
44
+ return String(review.state ?? "").toLowerCase();
45
+ }
46
+
47
+ function submittedAtMs(review) {
48
+ const submittedAt = Date.parse(review.submittedAt);
49
+ return Number.isFinite(submittedAt) ? submittedAt : null;
50
+ }
51
+
52
+ function reviewDecisionState(humanReviews) {
53
+ const reviewStates = new Set(humanReviews.map(reviewState));
54
+ const terminalReviews = humanReviews.filter(review => ["approved", "changes_requested"].includes(reviewState(review)));
55
+ const allTerminalReviewsHaveTimestamps = terminalReviews.length > 0
56
+ && terminalReviews.every(review => submittedAtMs(review) !== null);
57
+
58
+ if (allTerminalReviewsHaveTimestamps) {
59
+ const latestTerminalReview = [...terminalReviews].sort((left, right) => submittedAtMs(right) - submittedAtMs(left))[0];
60
+ return reviewState(latestTerminalReview);
61
+ }
62
+ if (reviewStates.has("changes_requested")) return "changes_requested";
63
+ if (reviewStates.has("approved")) return "approved";
64
+ if (humanReviews.length > 0) return "commented";
65
+ return "none";
66
+ }
67
+
68
+ function summarizeReviewDecision(pr) {
69
+ if (!Array.isArray(pr.reviews)) {
70
+ return {
71
+ state: "unavailable",
72
+ humanApproved: false,
73
+ humanChangesRequested: false,
74
+ humanReviewerCount: 0,
75
+ source: "unavailable",
76
+ };
77
+ }
78
+
79
+ const humanReviews = pr.reviews.filter(review => (
80
+ classifyCommentSource(review.author, { pullRequestAuthorLogin: pr.author?.login }) === "human_reviewer"
81
+ ));
82
+ const humanReviewerKeys = new Set(humanReviews.map(reviewAuthorKey).filter(Boolean));
83
+ const states = new Set(humanReviews.map(reviewState));
84
+
85
+ return {
86
+ state: reviewDecisionState(humanReviews),
87
+ humanApproved: states.has("approved"),
88
+ humanChangesRequested: states.has("changes_requested"),
89
+ humanReviewerCount: humanReviewerKeys.size,
90
+ source: "reviews",
91
+ };
92
+ }
93
+
94
+ function normalizeCommit(commit) {
95
+ return {
96
+ oid: commit.oid,
97
+ authoredDate: commit.authoredDate ?? null,
98
+ committedDate: commit.committedDate ?? null,
99
+ messageHeadline: commit.messageHeadline ?? null,
100
+ };
101
+ }
102
+
103
+ export function normalizeFixtureBundle(bundle, { repositoryProfile } = {}) {
104
+ const profile = repositoryProfile ?? {};
105
+ assertValidPrClassRules(profile);
106
+
107
+ const pullRequests = (bundle.pullRequests ?? []).map(pr => {
108
+ const reviewDates = (pr.reviews ?? []).map(review => review.submittedAt);
109
+ const threadComments = flattenThreadComments(pr.reviewThreads);
110
+ return {
111
+ number: pr.number,
112
+ title: pr.title,
113
+ url: pr.url,
114
+ state: pr.state,
115
+ authorLogin: pr.author?.login ?? null,
116
+ prClass: classifyPullRequest(pr, profile),
117
+ lifecycle: {
118
+ createdAt: pr.createdAt,
119
+ mergedAt: pr.mergedAt ?? null,
120
+ firstCommitAt: minDate((pr.commits ?? []).map(commit => commit.authoredDate)),
121
+ firstReviewAt: minDate(reviewDates),
122
+ lastReviewAt: maxDate(reviewDates),
123
+ },
124
+ commits: (pr.commits ?? []).map(normalizeCommit),
125
+ diffAtMerge: {
126
+ additions: pr.additions,
127
+ deletions: pr.deletions,
128
+ changedFiles: pr.changedFiles,
129
+ },
130
+ prOpenDiff: pr.prOpenDiff ?? { source: "unavailable", confidence: "unavailable" },
131
+ files: (pr.files ?? []).map(file => ({
132
+ ...classifyFilePath(file.path, profile),
133
+ additions: file.additions,
134
+ deletions: file.deletions,
135
+ changeType: file.changeType,
136
+ })),
137
+ reviews: (pr.reviews ?? []).map(review => normalizeReview(review, { pullRequestAuthorLogin: pr.author?.login })),
138
+ reviewDecision: summarizeReviewDecision(pr),
139
+ reviewThreads: {
140
+ source: pr.reviewThreads?.source ?? "unavailable",
141
+ totalCount: pr.reviewThreads?.totalCount ?? 0,
142
+ resolvedCount: (pr.reviewThreads?.nodes ?? []).filter(thread => thread.isResolved).length,
143
+ outdatedCount: (pr.reviewThreads?.nodes ?? []).filter(thread => thread.isOutdated).length,
144
+ },
145
+ reviewComments: {
146
+ totalCount: threadComments.length,
147
+ bySource: groupByCommentSource(threadComments, { pullRequestAuthorLogin: pr.author?.login }),
148
+ },
149
+ checkRuns: (pr.statusCheckRollup ?? []).map(check => ({
150
+ source: check.__typename === "StatusContext" ? "status_context" : "check_run",
151
+ name: check.name ?? check.context ?? null,
152
+ workflowName: check.workflowName ?? null,
153
+ status: check.status ?? check.state ?? null,
154
+ conclusion: check.conclusion ?? check.state ?? null,
155
+ startedAt: check.startedAt ?? null,
156
+ completedAt: check.completedAt ?? null,
157
+ })),
158
+ workflowRuns: pr.workflowRuns ?? { source: "unavailable", totalCount: null, conclusions: {} },
159
+ };
160
+ });
161
+
162
+ return {
163
+ schemaVersion: "normalized-fixture.v1",
164
+ targetRepository: bundle.targetRepository,
165
+ languageDistribution: bundle.languageDistribution,
166
+ pullRequests,
167
+ };
168
+ }
@@ -0,0 +1,76 @@
1
+ export const FILE_CATEGORIES = Object.freeze([
2
+ "code",
3
+ "tests",
4
+ "docs",
5
+ "config",
6
+ "generated",
7
+ "infrastructure",
8
+ "unknown",
9
+ ]);
10
+
11
+ export const FILE_ROLES = Object.freeze([
12
+ "core_product_code",
13
+ "product_ui",
14
+ "tests",
15
+ "generated_docs",
16
+ "release_notes",
17
+ "planning_docs",
18
+ "marketing_site",
19
+ "config",
20
+ "infrastructure",
21
+ "fixtures",
22
+ "generated_or_vendored",
23
+ "unknown",
24
+ ]);
25
+
26
+ function ruleMatches(path, match = {}) {
27
+ if (match.exact && path !== match.exact) return false;
28
+ if (match.prefix && !path.startsWith(match.prefix)) return false;
29
+ if (match.suffix && !path.endsWith(match.suffix)) return false;
30
+ if (match.includes && !path.includes(match.includes)) return false;
31
+ if (match.regex) {
32
+ try {
33
+ if (!new RegExp(match.regex).test(path)) return false;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+ return Boolean(match.exact || match.prefix || match.suffix || match.includes || match.regex);
39
+ }
40
+
41
+ function inferCategory(path) {
42
+ if (/(^|\/)(test|tests|__tests__)\//.test(path) || /\.(test|spec)\.[cm]?[jt]s$/.test(path)) return "tests";
43
+ if (/\.(md|mdx|txt|adoc)$/.test(path)) return "docs";
44
+ if (/(^|\/)(Dockerfile|docker-compose\.ya?ml)$/.test(path) || path.startsWith(".github/")) return "infrastructure";
45
+ if (/\.(json|ya?ml|toml|ini|env)$/.test(path)) return "config";
46
+ if (/\.(js|mjs|cjs|ts|tsx|jsx|py|go|rs|java|rb|php|cs|swift|kt)$/.test(path)) return "code";
47
+ return "unknown";
48
+ }
49
+
50
+ export function classifyFilePath(path, profile = {}) {
51
+ const normalizedPath = String(path ?? "");
52
+ for (const rule of profile.rules ?? []) {
53
+ if (ruleMatches(normalizedPath, rule.match)) {
54
+ return {
55
+ path: normalizedPath,
56
+ category: rule.category,
57
+ role: rule.role,
58
+ functionalSurface: rule.functionalSurface ?? rule.role,
59
+ generated: Boolean(rule.generated),
60
+ classificationSource: "repository_profile",
61
+ ruleId: rule.id,
62
+ };
63
+ }
64
+ }
65
+
66
+ const category = inferCategory(normalizedPath);
67
+ return {
68
+ path: normalizedPath,
69
+ category,
70
+ role: category === "tests" ? "tests" : "unknown",
71
+ functionalSurface: "unknown",
72
+ generated: false,
73
+ classificationSource: "fallback_rule",
74
+ ruleId: null,
75
+ };
76
+ }