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.
- package/LICENSE +21 -0
- package/README.md +91 -0
- package/docs/contracts/friction-metrics.md +52 -0
- package/docs/contracts/friction-report.md +131 -0
- package/docs/contracts/normalized-entities.md +39 -0
- package/docs/contracts/target-repository.md +24 -0
- package/docs/reference/github-access-coverage.md +25 -0
- package/docs/reference/github-data-inventory.md +55 -0
- package/docs/reference/release-automation.md +65 -0
- package/docs/reference/repository-profile.md +55 -0
- package/fixtures/github/mcp-writing/profile.json +73 -0
- package/package.json +48 -0
- package/release-log.md +106 -0
- package/schemas/normalized-entities.schema.json +342 -0
- package/schemas/repository-profile.schema.json +92 -0
- package/schemas/target-repository.schema.json +40 -0
- package/src/cli/analyze-github.js +597 -0
- package/src/collect/coverage.js +82 -0
- package/src/collect/gh-provider.js +279 -0
- package/src/collect/github-source-bundle.js +455 -0
- package/src/contracts/target-repository.js +75 -0
- package/src/github/comment-source.js +57 -0
- package/src/metrics/friction.js +414 -0
- package/src/normalize/github-fixture.js +168 -0
- package/src/profile/file-role.js +76 -0
- package/src/profile/pr-class.js +107 -0
- package/src/report/evidence-artifacts.js +403 -0
- package/src/report/friction-report.js +1301 -0
- package/src/report/generate-report.js +85 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export const PR_CLASS_FALLBACK = Object.freeze({
|
|
2
|
+
class: "unknown",
|
|
3
|
+
classificationSource: "fallback_rule",
|
|
4
|
+
ruleId: null,
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
const PR_CLASS_IDENTIFIER_PATTERN = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/;
|
|
8
|
+
|
|
9
|
+
function ruleLabel(rule, index) {
|
|
10
|
+
return typeof rule?.id === "string" && rule.id.length ? rule.id : `index ${index}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function validatePrClassRules(profile = {}) {
|
|
14
|
+
const errors = [];
|
|
15
|
+
const seenRuleIds = new Set();
|
|
16
|
+
|
|
17
|
+
if (!profile || typeof profile !== "object" || Array.isArray(profile)) {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!Object.prototype.hasOwnProperty.call(profile, "prClasses")) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const rules = profile.prClasses;
|
|
26
|
+
|
|
27
|
+
if (!Array.isArray(rules)) {
|
|
28
|
+
return ["prClasses must be an array when provided"];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const [index, rule] of rules.entries()) {
|
|
32
|
+
const label = ruleLabel(rule, index);
|
|
33
|
+
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
|
|
34
|
+
errors.push(`prClasses[${index}] must be an object`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (typeof rule.id !== "string" || rule.id.length === 0) {
|
|
39
|
+
errors.push(`prClasses[${index}].id must be a non-empty string`);
|
|
40
|
+
} else if (seenRuleIds.has(rule.id)) {
|
|
41
|
+
errors.push(`prClasses rule id "${rule.id}" is duplicated`);
|
|
42
|
+
} else {
|
|
43
|
+
seenRuleIds.add(rule.id);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (typeof rule.class !== "string" || !PR_CLASS_IDENTIFIER_PATTERN.test(rule.class)) {
|
|
47
|
+
errors.push(`prClasses rule "${label}" class must be lower-kebab-case or lower_snake_case`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const match = rule.match;
|
|
51
|
+
if (!match || typeof match !== "object" || Array.isArray(match)) {
|
|
52
|
+
errors.push(`prClasses rule "${label}" match must be an object`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const hasTitleIncludes = typeof match.titleIncludes === "string" && match.titleIncludes.length > 0;
|
|
57
|
+
const hasTitleRegex = typeof match.titleRegex === "string" && match.titleRegex.length > 0;
|
|
58
|
+
if (!hasTitleIncludes && !hasTitleRegex) {
|
|
59
|
+
errors.push(`prClasses rule "${label}" match must include titleIncludes or titleRegex`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (hasTitleRegex) {
|
|
63
|
+
try {
|
|
64
|
+
new RegExp(match.titleRegex);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
errors.push(`prClasses rule "${label}" titleRegex is invalid: ${error.message}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return errors;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function assertValidPrClassRules(profile = {}) {
|
|
75
|
+
const errors = validatePrClassRules(profile);
|
|
76
|
+
if (errors.length > 0) {
|
|
77
|
+
throw new Error(`invalid PR class profile rules: ${errors.join("; ")}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function ruleMatches(title, match = {}) {
|
|
82
|
+
if (match.titleIncludes && !title.includes(match.titleIncludes)) return false;
|
|
83
|
+
if (match.titleRegex) {
|
|
84
|
+
try {
|
|
85
|
+
if (!new RegExp(match.titleRegex).test(title)) return false;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return Boolean(match.titleIncludes || match.titleRegex);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function classifyPullRequest(pr, profile = {}) {
|
|
94
|
+
const title = String(pr?.title ?? "");
|
|
95
|
+
const rules = Array.isArray(profile?.prClasses) ? profile.prClasses : [];
|
|
96
|
+
for (const rule of rules) {
|
|
97
|
+
if (ruleMatches(title, rule.match)) {
|
|
98
|
+
return {
|
|
99
|
+
class: rule.class,
|
|
100
|
+
classificationSource: "repository_profile",
|
|
101
|
+
ruleId: rule.id,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { ...PR_CLASS_FALLBACK };
|
|
107
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
const BOT_OR_SCANNER_SOURCES = new Set([
|
|
2
|
+
"copilot",
|
|
3
|
+
"github_actions_bot",
|
|
4
|
+
"dependency_bot",
|
|
5
|
+
"code_scanning",
|
|
6
|
+
"unknown_bot",
|
|
7
|
+
]);
|
|
8
|
+
const HUMAN_OR_AUTHOR_SOURCES = new Set(["human_reviewer", "author_reply"]);
|
|
9
|
+
|
|
10
|
+
const SCORE_COLUMNS = [
|
|
11
|
+
["review_churn_score", "reviewChurn"],
|
|
12
|
+
["changed_file_spread_score", "changedFileSpread"],
|
|
13
|
+
["validation_gap_score", "validationGap"],
|
|
14
|
+
["planning_gap_score", "planningGap"],
|
|
15
|
+
["review_surprise_score", "reviewSurprise"],
|
|
16
|
+
["fix_amplification_score", "fixAmplification"],
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function csvValue(value) {
|
|
20
|
+
if (value === null || value === undefined) return "";
|
|
21
|
+
const rawValue = String(value);
|
|
22
|
+
const stringValue = typeof value === "string" && (/^[=+\-@\t\r\n]/.test(rawValue) || /^\s*[=+\-@]/u.test(rawValue))
|
|
23
|
+
? `'${rawValue}`
|
|
24
|
+
: rawValue;
|
|
25
|
+
return /[",\n\r]/.test(stringValue) ? `"${stringValue.replace(/"/g, '""')}"` : stringValue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function renderCsv(headers, rows) {
|
|
29
|
+
return `${[
|
|
30
|
+
headers.map(csvValue).join(","),
|
|
31
|
+
...rows.map(row => headers.map(header => csvValue(row[header])).join(",")),
|
|
32
|
+
].join("\n")}\n`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function analysisFilterCsvMetadata(analysisFilter) {
|
|
36
|
+
if (!analysisFilter?.excludedPrClasses?.length) return { headers: [], row: {} };
|
|
37
|
+
return {
|
|
38
|
+
headers: [
|
|
39
|
+
"analysis_filter_excluded_pr_classes",
|
|
40
|
+
"analysis_filter_original_pull_requests",
|
|
41
|
+
"analysis_filter_filtered_pull_requests",
|
|
42
|
+
],
|
|
43
|
+
row: {
|
|
44
|
+
analysis_filter_excluded_pr_classes: analysisFilter.excludedPrClasses.join(";"),
|
|
45
|
+
analysis_filter_original_pull_requests: analysisFilter.originalPullRequests,
|
|
46
|
+
analysis_filter_filtered_pull_requests: analysisFilter.filteredPullRequests,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderEvidenceCsv(headers, rows, analysisFilter) {
|
|
52
|
+
const metadata = analysisFilterCsvMetadata(analysisFilter);
|
|
53
|
+
if (!metadata.headers.length) return renderCsv(headers, rows);
|
|
54
|
+
return renderCsv(
|
|
55
|
+
[...headers, ...metadata.headers],
|
|
56
|
+
rows.map(row => ({ ...row, ...metadata.row })),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function rankingValueMaps(metricsSummary) {
|
|
61
|
+
return Object.fromEntries(
|
|
62
|
+
SCORE_COLUMNS.map(([, rankingKey]) => [
|
|
63
|
+
rankingKey,
|
|
64
|
+
new Map((metricsSummary.rankings?.[rankingKey] ?? []).map(entry => [entry.number, entry.value])),
|
|
65
|
+
]),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sortedPullRequests(metricsSummary) {
|
|
70
|
+
return [...(metricsSummary.pullRequests ?? [])].sort((left, right) => left.number - right.number);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function formatCommentSources(entries) {
|
|
74
|
+
return (entries ?? []).map(entry => `${entry.name}=${entry.value}`).join("; ");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatSourceLabels(evidence) {
|
|
78
|
+
return [
|
|
79
|
+
`workflow=${evidence.validationEvidence?.workflowRunSource ?? "unavailable"}`,
|
|
80
|
+
`review=${evidence.reviewEvidence?.reviewThreadSource ?? "unavailable"}`,
|
|
81
|
+
].join("; ");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function hasObservedWorkflowRuns(workflowRuns = {}) {
|
|
85
|
+
return (workflowRuns.coverage ?? workflowRuns.workflowRunCoverage) === "observed";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function hasObservedReviewThreads(reviewThreads = {}) {
|
|
89
|
+
const source = reviewThreads.source ?? reviewThreads.reviewThreadSource ?? "unavailable";
|
|
90
|
+
return String(source).startsWith("graphql");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function hasObservedReviewDecision(reviewDecision = {}) {
|
|
94
|
+
return (reviewDecision.source ?? "unavailable") !== "unavailable"
|
|
95
|
+
&& (reviewDecision.state ?? "unavailable") !== "unavailable";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function unavailableUnlessObserved(value, observed) {
|
|
99
|
+
return observed ? value : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function prMetricsCsv(metricsSummary, analysisFilter) {
|
|
103
|
+
const scoreMaps = rankingValueMaps(metricsSummary);
|
|
104
|
+
const headers = [
|
|
105
|
+
"pr_number",
|
|
106
|
+
"title",
|
|
107
|
+
"url",
|
|
108
|
+
"pr_class",
|
|
109
|
+
"pr_classification_source",
|
|
110
|
+
"pr_class_rule_id",
|
|
111
|
+
"changed_lines",
|
|
112
|
+
"non_generated_changed_lines",
|
|
113
|
+
"review_comments",
|
|
114
|
+
"review_threads",
|
|
115
|
+
"review_decision",
|
|
116
|
+
"human_reviewer_count",
|
|
117
|
+
"human_approved",
|
|
118
|
+
"human_changes_requested",
|
|
119
|
+
"failed_checks",
|
|
120
|
+
"failed_workflow_runs",
|
|
121
|
+
"cancelled_workflow_runs",
|
|
122
|
+
"post_review_commits",
|
|
123
|
+
"review_thread_source",
|
|
124
|
+
"workflow_run_source",
|
|
125
|
+
"workflow_run_coverage",
|
|
126
|
+
...SCORE_COLUMNS.map(([header]) => header),
|
|
127
|
+
];
|
|
128
|
+
const rows = sortedPullRequests(metricsSummary).map(pr => {
|
|
129
|
+
const observedReviewThreads = hasObservedReviewThreads(pr.review?.threads);
|
|
130
|
+
const observedReviewDecision = hasObservedReviewDecision(pr.review?.decision);
|
|
131
|
+
const observedWorkflowRuns = hasObservedWorkflowRuns(pr.ci?.workflowRuns);
|
|
132
|
+
const row = {
|
|
133
|
+
pr_number: pr.number,
|
|
134
|
+
title: pr.title,
|
|
135
|
+
url: pr.url,
|
|
136
|
+
pr_class: pr.prClass?.class ?? "unknown",
|
|
137
|
+
pr_classification_source: pr.prClass?.classificationSource ?? "fallback_rule",
|
|
138
|
+
pr_class_rule_id: pr.prClass?.ruleId ?? null,
|
|
139
|
+
changed_lines: pr.diffAtMerge?.changedLines,
|
|
140
|
+
non_generated_changed_lines: pr.files?.nonGeneratedChangedLines,
|
|
141
|
+
review_comments: pr.review?.comments?.totalCount,
|
|
142
|
+
review_threads: unavailableUnlessObserved(pr.review?.threads?.totalCount, observedReviewThreads),
|
|
143
|
+
review_decision: pr.review?.decision?.state ?? "unavailable",
|
|
144
|
+
human_reviewer_count: unavailableUnlessObserved(pr.review?.decision?.humanReviewerCount ?? 0, observedReviewDecision),
|
|
145
|
+
human_approved: unavailableUnlessObserved(pr.review?.decision?.humanApproved ?? false, observedReviewDecision),
|
|
146
|
+
human_changes_requested: unavailableUnlessObserved(pr.review?.decision?.humanChangesRequested ?? false, observedReviewDecision),
|
|
147
|
+
failed_checks: pr.ci?.checkRuns?.failedCount,
|
|
148
|
+
failed_workflow_runs: unavailableUnlessObserved(pr.ci?.workflowRuns?.failedCount, observedWorkflowRuns),
|
|
149
|
+
cancelled_workflow_runs: unavailableUnlessObserved(pr.ci?.workflowRuns?.cancelledCount, observedWorkflowRuns),
|
|
150
|
+
post_review_commits: pr.iteration?.commitsAfterFirstReview,
|
|
151
|
+
review_thread_source: pr.review?.threads?.source ?? "unavailable",
|
|
152
|
+
workflow_run_source: pr.ci?.workflowRuns?.source ?? "unavailable",
|
|
153
|
+
workflow_run_coverage: pr.ci?.workflowRuns?.coverage ?? "unavailable",
|
|
154
|
+
};
|
|
155
|
+
for (const [header, rankingKey] of SCORE_COLUMNS) {
|
|
156
|
+
row[header] = scoreMaps[rankingKey].get(pr.number);
|
|
157
|
+
}
|
|
158
|
+
return row;
|
|
159
|
+
});
|
|
160
|
+
return renderEvidenceCsv(headers, rows, analysisFilter);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function bottleneckExamplesCsv(report, analysisFilter) {
|
|
164
|
+
const headers = [
|
|
165
|
+
"bottleneck_id",
|
|
166
|
+
"bottleneck_title",
|
|
167
|
+
"recommendation_category",
|
|
168
|
+
"pr_number",
|
|
169
|
+
"title",
|
|
170
|
+
"url",
|
|
171
|
+
"score",
|
|
172
|
+
"changed_lines",
|
|
173
|
+
"failed_checks",
|
|
174
|
+
"failed_workflow_runs",
|
|
175
|
+
"cancelled_workflow_runs",
|
|
176
|
+
"review_threads",
|
|
177
|
+
"resolved_threads",
|
|
178
|
+
"outdated_threads",
|
|
179
|
+
"comment_sources",
|
|
180
|
+
"workflow_run_source",
|
|
181
|
+
"workflow_run_coverage",
|
|
182
|
+
"review_thread_source",
|
|
183
|
+
"dominance_status",
|
|
184
|
+
"dominant_pr_number",
|
|
185
|
+
"evidence_source_labels",
|
|
186
|
+
];
|
|
187
|
+
const rows = [];
|
|
188
|
+
for (const bottleneck of report.bottlenecks ?? []) {
|
|
189
|
+
for (const evidence of bottleneck.observedData ?? []) {
|
|
190
|
+
const observedWorkflowRuns = hasObservedWorkflowRuns(evidence.validationEvidence);
|
|
191
|
+
const observedReviewThreads = hasObservedReviewThreads(evidence.reviewEvidence);
|
|
192
|
+
rows.push({
|
|
193
|
+
bottleneck_id: bottleneck.id,
|
|
194
|
+
bottleneck_title: bottleneck.title,
|
|
195
|
+
recommendation_category: bottleneck.suggestedAction?.category,
|
|
196
|
+
pr_number: evidence.number,
|
|
197
|
+
title: evidence.title,
|
|
198
|
+
url: evidence.url,
|
|
199
|
+
score: evidence.value,
|
|
200
|
+
changed_lines: evidence.changedLines,
|
|
201
|
+
failed_checks: evidence.validationEvidence?.failedCheckRuns,
|
|
202
|
+
failed_workflow_runs: unavailableUnlessObserved(evidence.validationEvidence?.failedWorkflowRuns, observedWorkflowRuns),
|
|
203
|
+
cancelled_workflow_runs: unavailableUnlessObserved(evidence.validationEvidence?.cancelledWorkflowRuns, observedWorkflowRuns),
|
|
204
|
+
review_threads: unavailableUnlessObserved(evidence.reviewEvidence?.reviewThreads, observedReviewThreads),
|
|
205
|
+
resolved_threads: unavailableUnlessObserved(evidence.reviewEvidence?.resolvedThreads, observedReviewThreads),
|
|
206
|
+
outdated_threads: unavailableUnlessObserved(evidence.reviewEvidence?.outdatedThreads, observedReviewThreads),
|
|
207
|
+
comment_sources: formatCommentSources(evidence.reviewEvidence?.commentSources),
|
|
208
|
+
workflow_run_source: evidence.validationEvidence?.workflowRunSource ?? "unavailable",
|
|
209
|
+
workflow_run_coverage: evidence.validationEvidence?.workflowRunCoverage ?? "unavailable",
|
|
210
|
+
review_thread_source: evidence.reviewEvidence?.reviewThreadSource ?? "unavailable",
|
|
211
|
+
dominance_status: bottleneck.dominance?.status,
|
|
212
|
+
dominant_pr_number: bottleneck.dominance?.topPrNumber,
|
|
213
|
+
evidence_source_labels: formatSourceLabels(evidence),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return renderEvidenceCsv(headers, rows, analysisFilter);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function commentSourcesCsv(report, analysisFilter) {
|
|
221
|
+
const totalComments = Number(report.commentSources?.totalComments ?? 0);
|
|
222
|
+
const headers = [
|
|
223
|
+
"source_name",
|
|
224
|
+
"total_comments",
|
|
225
|
+
"is_bot_or_scanner",
|
|
226
|
+
"is_human_or_author",
|
|
227
|
+
"share_of_all_comments",
|
|
228
|
+
];
|
|
229
|
+
const rows = (report.commentSources?.bySource ?? []).map(entry => ({
|
|
230
|
+
source_name: entry.name,
|
|
231
|
+
total_comments: entry.value,
|
|
232
|
+
is_bot_or_scanner: BOT_OR_SCANNER_SOURCES.has(entry.name),
|
|
233
|
+
is_human_or_author: HUMAN_OR_AUTHOR_SOURCES.has(entry.name),
|
|
234
|
+
share_of_all_comments: totalComments > 0 ? Math.round((entry.value / totalComments) * 10000) / 10000 : 0,
|
|
235
|
+
}));
|
|
236
|
+
return renderEvidenceCsv(headers, rows, analysisFilter);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function collectionCoverageCsv(collectionCoverage, analysisFilter) {
|
|
240
|
+
const headers = [
|
|
241
|
+
"api_family",
|
|
242
|
+
"status",
|
|
243
|
+
"attempts",
|
|
244
|
+
"source",
|
|
245
|
+
"diagnostics",
|
|
246
|
+
"downstream_impact",
|
|
247
|
+
];
|
|
248
|
+
const rows = [...(collectionCoverage?.apiFamilies ?? [])]
|
|
249
|
+
.sort((left, right) => String(left.family).localeCompare(String(right.family)))
|
|
250
|
+
.map(family => ({
|
|
251
|
+
api_family: family.family,
|
|
252
|
+
status: family.status,
|
|
253
|
+
attempts: family.attempts ?? 1,
|
|
254
|
+
source: family.source,
|
|
255
|
+
diagnostics: (family.diagnostics ?? []).join(" | "),
|
|
256
|
+
downstream_impact: family.downstreamImpact,
|
|
257
|
+
}));
|
|
258
|
+
return renderEvidenceCsv(headers, rows, analysisFilter);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function generateEvidenceCsvArtifacts({ metricsSummary, report, collectionCoverage }) {
|
|
262
|
+
const analysisFilter = report?.analysisFilter ?? metricsSummary?.analysisFilter;
|
|
263
|
+
return {
|
|
264
|
+
prMetricsCsv: prMetricsCsv(metricsSummary, analysisFilter),
|
|
265
|
+
bottleneckExamplesCsv: bottleneckExamplesCsv(report, analysisFilter),
|
|
266
|
+
commentSourcesCsv: commentSourcesCsv(report, analysisFilter),
|
|
267
|
+
collectionCoverageCsv: collectionCoverageCsv(collectionCoverage, analysisFilter),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function repositoryLabel(report) {
|
|
272
|
+
return report.targetRepository
|
|
273
|
+
? `${report.targetRepository.owner}/${report.targetRepository.name}`
|
|
274
|
+
: "unknown repository";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function formatCoverageFamilies(collectionCoverage) {
|
|
278
|
+
const families = collectionCoverage?.apiFamilies ?? [];
|
|
279
|
+
if (!families.length) return "- No collection coverage families were recorded.";
|
|
280
|
+
return families
|
|
281
|
+
.map(family => {
|
|
282
|
+
const diagnosticsText = (family.diagnostics ?? []).join(" | ").replace(/\.+$/u, "");
|
|
283
|
+
const impactText = String(family.downstreamImpact ?? "").replace(/\.+$/u, "");
|
|
284
|
+
const diagnostics = (family.diagnostics ?? []).length
|
|
285
|
+
? ` Diagnostics: ${diagnosticsText}.`
|
|
286
|
+
: "";
|
|
287
|
+
const impact = family.downstreamImpact ? ` Impact: ${impactText}.` : "";
|
|
288
|
+
return `- ${family.family}: ${family.status}; attempts=${family.attempts ?? 1}; source=${family.source ?? "unavailable"}.${diagnostics}${impact}`;
|
|
289
|
+
})
|
|
290
|
+
.join("\n");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function formatArtifactList(artifactFileNames, csvEnabled) {
|
|
294
|
+
const entries = [
|
|
295
|
+
["Markdown report", artifactFileNames.reportMarkdown],
|
|
296
|
+
["JSON report", artifactFileNames.reportJson],
|
|
297
|
+
["Methodology", artifactFileNames.methodology],
|
|
298
|
+
["Source bundle", artifactFileNames.sourceBundle],
|
|
299
|
+
["Normalized data", artifactFileNames.normalized],
|
|
300
|
+
["Metrics summary", artifactFileNames.metricsSummary],
|
|
301
|
+
];
|
|
302
|
+
if (csvEnabled) {
|
|
303
|
+
entries.push(
|
|
304
|
+
["PR metrics CSV", artifactFileNames.prMetricsCsv],
|
|
305
|
+
["Bottleneck examples CSV", artifactFileNames.bottleneckExamplesCsv],
|
|
306
|
+
["Comment sources CSV", artifactFileNames.commentSourcesCsv],
|
|
307
|
+
["Collection coverage CSV", artifactFileNames.collectionCoverageCsv],
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
return entries.map(([label, fileName]) => `- ${label}: \`${fileName}\``).join("\n");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function formatAnalysisFilter(report) {
|
|
314
|
+
const analysisFilter = report.analysisFilter;
|
|
315
|
+
if (!analysisFilter?.excludedPrClasses?.length) {
|
|
316
|
+
return "No PR class filter was applied; downstream artifacts use the full collected sample.";
|
|
317
|
+
}
|
|
318
|
+
return [
|
|
319
|
+
`Excluded PR class(es): ${analysisFilter.excludedPrClasses.join(", ")}.`,
|
|
320
|
+
`Filtered sample: ${analysisFilter.filteredPullRequests} of ${analysisFilter.originalPullRequests} collected pull request(s).`,
|
|
321
|
+
"`source-bundle.json` preserves the full collected sample; normalized, metrics, report, methodology, and CSV artifacts use the filtered sample.",
|
|
322
|
+
].join(" ");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function formatSensitivitySummaries(report) {
|
|
326
|
+
const summaries = report.sensitivity?.summaries ?? [];
|
|
327
|
+
if (!summaries.length) return "- No displayed bottleneck examples were dominated by one PR.";
|
|
328
|
+
return summaries.map(summary => {
|
|
329
|
+
const affected = summary.affectedBottlenecks.map(bottleneck => bottleneck.title).join(", ") || "none";
|
|
330
|
+
return [
|
|
331
|
+
`- PR #${summary.excludedPr.number} (${summary.excludedPr.title ?? "unknown title"}) dominated: ${affected}.`,
|
|
332
|
+
` Baseline top bottlenecks: ${(summary.baselineTopBottleneckIds ?? []).join(", ") || "none"}.`,
|
|
333
|
+
` Top bottlenecks without that PR: ${(summary.topBottleneckIdsWithoutPr ?? []).join(", ") || "none"}.`,
|
|
334
|
+
` Interpretation: ${summary.interpretation}`,
|
|
335
|
+
].join("\n");
|
|
336
|
+
}).join("\n");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function renderRepositoryFrictionMethodology({
|
|
340
|
+
report,
|
|
341
|
+
sourceBundle,
|
|
342
|
+
profilePath,
|
|
343
|
+
artifactFileNames,
|
|
344
|
+
csvEnabled,
|
|
345
|
+
}) {
|
|
346
|
+
const selection = sourceBundle?.selection ?? {};
|
|
347
|
+
const collectionCoverage = sourceBundle?.coverage ?? report.collectionCoverage;
|
|
348
|
+
|
|
349
|
+
return `${[
|
|
350
|
+
`# Methodology: ${repositoryLabel(report)}`,
|
|
351
|
+
"",
|
|
352
|
+
`Report version: ${report.reportVersion}`,
|
|
353
|
+
`Metric version: ${report.metricVersion}`,
|
|
354
|
+
`Repository: ${repositoryLabel(report)}`,
|
|
355
|
+
`Profile path: ${profilePath ?? "not recorded"}`,
|
|
356
|
+
`Requested pull requests: ${selection.requestedLimit ?? "unknown"}`,
|
|
357
|
+
`Collected pull requests: ${selection.collectedCount ?? report.summary?.pullRequests ?? "unknown"}`,
|
|
358
|
+
`Collection coverage: ${collectionCoverage?.status ?? "unknown"}`,
|
|
359
|
+
`Analysis filter: ${formatAnalysisFilter(report)}`,
|
|
360
|
+
"",
|
|
361
|
+
"## What This Analysis Uses",
|
|
362
|
+
"",
|
|
363
|
+
"The analyzer collects merged GitHub pull requests, normalizes repository-specific fields through the supplied profile, computes transparent component metrics, and renders a repository-level report. It does not inspect local working trees, mutate repositories, rank people, or apply recommendations automatically.",
|
|
364
|
+
"",
|
|
365
|
+
"## Pull Request Selection",
|
|
366
|
+
"",
|
|
367
|
+
"Pull requests are selected by the collection step before report rendering. The default live path samples the latest merged pull requests up to the requested limit. Coverage gaps are preserved as explicit unavailable or partial values instead of being inferred from unrelated fields.",
|
|
368
|
+
report.analysisFilter?.excludedPrClasses?.length
|
|
369
|
+
? "PR class filtering is applied after collection and normalization, before metrics computation, so downstream totals, rankings, reports, methodology, and CSVs describe the filtered sample while the source bundle remains auditable."
|
|
370
|
+
: "No PR class filtering was applied for this run.",
|
|
371
|
+
"",
|
|
372
|
+
"## Profile Classification",
|
|
373
|
+
"",
|
|
374
|
+
"The repository profile maps file paths to categories, roles, and functional surfaces. Those classifications drive non-generated changed-line counts, support-surface summaries, planning-document signals, and low-signal weighting.",
|
|
375
|
+
"",
|
|
376
|
+
"## Scores And Rankings",
|
|
377
|
+
"",
|
|
378
|
+
"The report ranks bottlenecks by transparent component metrics from `friction-metrics.v1`: review churn, changed-file spread, validation gap, planning gap, review surprise, and fix amplification. These are not an opaque composite score, and they are not individual contributor or reviewer rankings.",
|
|
379
|
+
"",
|
|
380
|
+
"## Coverage And Limitations",
|
|
381
|
+
"",
|
|
382
|
+
formatCoverageFamilies(collectionCoverage),
|
|
383
|
+
"",
|
|
384
|
+
"Missing PR-open diffs, workflow runs, or review-thread data remain visible in coverage tables. Recommendations should be checked against repository context before maintainers turn them into process changes.",
|
|
385
|
+
"",
|
|
386
|
+
"## Dominance And Sensitivity",
|
|
387
|
+
"",
|
|
388
|
+
"When displayed bottleneck examples are dominated by one PR, the report recomputes the displayed top bottlenecks after excluding that PR from the report-layer sample. This is robustness context only; it does not replace the baseline ranking or imply the PR should be ignored.",
|
|
389
|
+
"",
|
|
390
|
+
formatSensitivitySummaries(report),
|
|
391
|
+
"",
|
|
392
|
+
"## Generated Artifacts",
|
|
393
|
+
"",
|
|
394
|
+
formatArtifactList(artifactFileNames, csvEnabled),
|
|
395
|
+
"",
|
|
396
|
+
"## Artifact Sensitivity",
|
|
397
|
+
"",
|
|
398
|
+
csvEnabled
|
|
399
|
+
? "Generated artifacts may include repository names, PR URLs, titles, file paths, comment-source counts, and coverage diagnostics. CSV files are curated for spreadsheet inspection but should still be treated as local/private unless intentionally shared."
|
|
400
|
+
: "Generated artifacts may include repository names, PR URLs, titles, file paths, comment metadata, and coverage diagnostics. CSV export generation was disabled for this run.",
|
|
401
|
+
"",
|
|
402
|
+
].join("\n").trimEnd()}\n`;
|
|
403
|
+
}
|