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,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://delivery-friction-analyzer.local/schemas/target-repository.schema.json",
|
|
4
|
+
"title": "TargetRepositoryInput",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": [
|
|
8
|
+
"owner",
|
|
9
|
+
"name",
|
|
10
|
+
"defaultBranch",
|
|
11
|
+
"visibility",
|
|
12
|
+
"analysisPullRequestLimit"
|
|
13
|
+
],
|
|
14
|
+
"properties": {
|
|
15
|
+
"owner": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"pattern": "^[A-Za-z0-9_.-]+$"
|
|
18
|
+
},
|
|
19
|
+
"name": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"pattern": "^[A-Za-z0-9_.-]+$"
|
|
22
|
+
},
|
|
23
|
+
"defaultBranch": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"pattern": "^[A-Za-z0-9._/-]+$"
|
|
26
|
+
},
|
|
27
|
+
"visibility": {
|
|
28
|
+
"enum": ["public", "private", "unknown"]
|
|
29
|
+
},
|
|
30
|
+
"analysisPullRequestLimit": {
|
|
31
|
+
"type": "integer",
|
|
32
|
+
"minimum": 1,
|
|
33
|
+
"maximum": 100
|
|
34
|
+
},
|
|
35
|
+
"isValidationTarget": {
|
|
36
|
+
"type": "boolean",
|
|
37
|
+
"default": false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { constants, realpathSync } from "node:fs";
|
|
3
|
+
import { access, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
import { collectGitHubSourceBundle } from "../collect/github-source-bundle.js";
|
|
7
|
+
import { createGhCliProvider } from "../collect/gh-provider.js";
|
|
8
|
+
import { computeRepositoryMetrics } from "../metrics/friction.js";
|
|
9
|
+
import { normalizeFixtureBundle } from "../normalize/github-fixture.js";
|
|
10
|
+
import {
|
|
11
|
+
generateEvidenceCsvArtifacts,
|
|
12
|
+
renderRepositoryFrictionMethodology,
|
|
13
|
+
} from "../report/evidence-artifacts.js";
|
|
14
|
+
import {
|
|
15
|
+
generateRepositoryFrictionReport,
|
|
16
|
+
renderRepositoryFrictionMarkdown,
|
|
17
|
+
} from "../report/friction-report.js";
|
|
18
|
+
|
|
19
|
+
const ALLOWED_OPTIONS = new Set([
|
|
20
|
+
"repo",
|
|
21
|
+
"limit",
|
|
22
|
+
"profile",
|
|
23
|
+
"out",
|
|
24
|
+
"dry-run",
|
|
25
|
+
"metadata-only",
|
|
26
|
+
"validation-target",
|
|
27
|
+
"no-csv",
|
|
28
|
+
"exclude-pr-class",
|
|
29
|
+
"json",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const REPOSITORY_SLUG = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/;
|
|
33
|
+
|
|
34
|
+
export const ANALYZE_GITHUB_ARTIFACTS = Object.freeze({
|
|
35
|
+
sourceBundle: "source-bundle.json",
|
|
36
|
+
normalized: "normalized.json",
|
|
37
|
+
metricsSummary: "metrics-summary.json",
|
|
38
|
+
reportJson: "friction-report.json",
|
|
39
|
+
reportMarkdown: "friction-report.md",
|
|
40
|
+
methodology: "methodology.md",
|
|
41
|
+
prMetricsCsv: "pr-metrics.csv",
|
|
42
|
+
bottleneckExamplesCsv: "bottleneck-examples.csv",
|
|
43
|
+
commentSourcesCsv: "comment-sources.csv",
|
|
44
|
+
collectionCoverageCsv: "collection-coverage.csv",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const CSV_ARTIFACT_KEYS = new Set([
|
|
48
|
+
"prMetricsCsv",
|
|
49
|
+
"bottleneckExamplesCsv",
|
|
50
|
+
"commentSourcesCsv",
|
|
51
|
+
"collectionCoverageCsv",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
export const USAGE = `Usage:
|
|
55
|
+
delivery-friction-analyzer --repo <owner/name> --limit <1-100> --profile <path> --out <directory>
|
|
56
|
+
delivery-friction-analyzer --repo <owner/name> --limit <1-100> --profile <path> --out <directory> --dry-run
|
|
57
|
+
|
|
58
|
+
Options:
|
|
59
|
+
--repo <owner/name> Target GitHub repository to analyze.
|
|
60
|
+
--limit <1-100> Latest merged pull request count.
|
|
61
|
+
--profile <path> Repository profile JSON used for file role classification.
|
|
62
|
+
--out <directory> Output directory for generated artifacts.
|
|
63
|
+
--dry-run Validate inputs and sample GitHub coverage without writing artifacts.
|
|
64
|
+
--metadata-only Alias for --dry-run.
|
|
65
|
+
--validation-target Mark the target repository as a validation target in output metadata.
|
|
66
|
+
--exclude-pr-class <cls> Exclude a PR class from normalized, metrics, report, methodology, and CSV artifacts. Repeat or comma-separate values.
|
|
67
|
+
--no-csv Suppress curated CSV evidence exports.
|
|
68
|
+
--json Print the machine-readable completion receipt to stdout.
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
export function parseAnalyzeGithubArgs(argv) {
|
|
72
|
+
const options = {};
|
|
73
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
74
|
+
const arg = argv[index];
|
|
75
|
+
if (arg === "--help" || arg === "-h") {
|
|
76
|
+
return { help: true };
|
|
77
|
+
}
|
|
78
|
+
if (!arg.startsWith("--")) {
|
|
79
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const key = arg.slice(2);
|
|
83
|
+
if (!ALLOWED_OPTIONS.has(key)) {
|
|
84
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (
|
|
88
|
+
key === "dry-run"
|
|
89
|
+
|| key === "metadata-only"
|
|
90
|
+
|| key === "validation-target"
|
|
91
|
+
|| key === "no-csv"
|
|
92
|
+
|| key === "json"
|
|
93
|
+
) {
|
|
94
|
+
options[key] = true;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const value = argv[index + 1];
|
|
99
|
+
if (!value || value.startsWith("--")) {
|
|
100
|
+
throw new Error(`Missing value for ${arg}`);
|
|
101
|
+
}
|
|
102
|
+
if (key === "exclude-pr-class") {
|
|
103
|
+
options[key] = [...(options[key] ?? []), value];
|
|
104
|
+
} else {
|
|
105
|
+
options[key] = value;
|
|
106
|
+
}
|
|
107
|
+
index += 1;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
repository: options.repo,
|
|
112
|
+
limit: options.limit === undefined ? undefined : Number(options.limit),
|
|
113
|
+
profilePath: options.profile,
|
|
114
|
+
outDir: options.out,
|
|
115
|
+
dryRun: Boolean(options["dry-run"] || options["metadata-only"]),
|
|
116
|
+
isValidationTarget: Boolean(options["validation-target"]),
|
|
117
|
+
excludedPrClasses: normalizeExcludedPrClasses(options["exclude-pr-class"] ?? []),
|
|
118
|
+
csv: !options["no-csv"],
|
|
119
|
+
json: Boolean(options.json),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function normalizeExcludedPrClasses(values) {
|
|
124
|
+
return [...new Set(values
|
|
125
|
+
.flatMap(value => String(value).split(","))
|
|
126
|
+
.map(value => value.trim())
|
|
127
|
+
.filter(Boolean))];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function requireOptions(options) {
|
|
131
|
+
const missing = [];
|
|
132
|
+
if (!options.repository) missing.push("--repo");
|
|
133
|
+
if (options.limit === undefined) missing.push("--limit");
|
|
134
|
+
if (!options.profilePath) missing.push("--profile");
|
|
135
|
+
if (!options.outDir) missing.push("--out");
|
|
136
|
+
if (missing.length > 0) {
|
|
137
|
+
throw new Error(`Missing required option(s): ${missing.join(", ")}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function validateRepositorySlug(repository) {
|
|
142
|
+
if (typeof repository !== "string" || !REPOSITORY_SLUG.test(repository)) {
|
|
143
|
+
throw new Error("repo must use owner/name with GitHub-safe owner and name segments.");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function validateLimit(limit) {
|
|
148
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
149
|
+
throw new Error("limit must be an integer between 1 and 100.");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function validateExcludedPrClasses(excludedPrClasses = []) {
|
|
154
|
+
for (const prClass of excludedPrClasses) {
|
|
155
|
+
if (!/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/.test(prClass)) {
|
|
156
|
+
throw new Error(`exclude-pr-class must be a lowercase PR class identifier using letters, digits, "-" or "_" separators: ${prClass}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function configuredPrClasses(repositoryProfile) {
|
|
162
|
+
return new Set((repositoryProfile.prClasses ?? []).map(rule => rule.class));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function validateExcludedPrClassesAreConfigured(excludedPrClasses = [], repositoryProfile) {
|
|
166
|
+
if (!excludedPrClasses.length) return;
|
|
167
|
+
|
|
168
|
+
const configured = configuredPrClasses(repositoryProfile);
|
|
169
|
+
const unconfigured = excludedPrClasses.filter(prClass => !configured.has(prClass));
|
|
170
|
+
if (!unconfigured.length) return;
|
|
171
|
+
|
|
172
|
+
const available = configured.size
|
|
173
|
+
? ` Configured PR class(es): ${[...configured].sort().join(", ")}.`
|
|
174
|
+
: " The repository profile does not configure any PR classes.";
|
|
175
|
+
throw new Error(`exclude-pr-class must name configured PR class(es): ${unconfigured.join(", ")}.${available}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function readProfile(profilePath) {
|
|
179
|
+
try {
|
|
180
|
+
return JSON.parse(await readFile(profilePath, "utf8"));
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error instanceof SyntaxError) {
|
|
183
|
+
throw new Error(`profile must be valid JSON: ${error.message}`);
|
|
184
|
+
}
|
|
185
|
+
throw new Error(`profile could not be read: ${error.message}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function validateOutputDirectory(outDir) {
|
|
190
|
+
const resolvedOutDir = resolve(outDir);
|
|
191
|
+
try {
|
|
192
|
+
const outStat = await stat(resolvedOutDir);
|
|
193
|
+
if (!outStat.isDirectory()) {
|
|
194
|
+
throw new Error("out must be a directory path, not a file.");
|
|
195
|
+
}
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (error.code !== "ENOENT") {
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
await mkdir(resolvedOutDir, { recursive: true });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const probePath = join(resolvedOutDir, `.analyze-github-write-test-${process.pid}-${Date.now()}`);
|
|
204
|
+
try {
|
|
205
|
+
await writeFile(probePath, "ok\n", "utf8");
|
|
206
|
+
} catch (error) {
|
|
207
|
+
throw new Error(`out must be a writable directory path: ${resolvedOutDir}`);
|
|
208
|
+
} finally {
|
|
209
|
+
await rm(probePath, { force: true });
|
|
210
|
+
}
|
|
211
|
+
return resolvedOutDir;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function artifactPaths(outDir, { includeCsv = true } = {}) {
|
|
215
|
+
return Object.fromEntries(
|
|
216
|
+
Object.entries(ANALYZE_GITHUB_ARTIFACTS)
|
|
217
|
+
.filter(([key]) => includeCsv || !CSV_ARTIFACT_KEYS.has(key))
|
|
218
|
+
.map(([key, fileName]) => [key, join(outDir, fileName)]),
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function artifactTransactionDirectory(outDir, label) {
|
|
223
|
+
return join(outDir, `.analyze-github-${label}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function assertWritableArtifactTargets(paths) {
|
|
227
|
+
for (const path of Object.values(paths)) {
|
|
228
|
+
try {
|
|
229
|
+
const pathStat = await stat(path);
|
|
230
|
+
if (!pathStat.isFile()) {
|
|
231
|
+
throw new Error(`artifact path must be a writable file path, not a directory or special file: ${path}`);
|
|
232
|
+
}
|
|
233
|
+
await access(path, constants.W_OK);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (error.code !== "ENOENT") {
|
|
236
|
+
if (error.code === "EACCES") {
|
|
237
|
+
throw new Error(`artifact path must be writable: ${path}`);
|
|
238
|
+
}
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function writeJson(path, value) {
|
|
246
|
+
await mkdir(dirname(path), { recursive: true });
|
|
247
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function writeText(path, value) {
|
|
251
|
+
await mkdir(dirname(path), { recursive: true });
|
|
252
|
+
await writeFile(path, value, "utf8");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function writeAnalysisArtifacts(outDir, paths, artifacts, { disabledPaths = {} } = {}) {
|
|
256
|
+
const finalPaths = { ...paths, ...disabledPaths };
|
|
257
|
+
await assertWritableArtifactTargets(finalPaths);
|
|
258
|
+
|
|
259
|
+
const stagingDir = artifactTransactionDirectory(outDir, "staging");
|
|
260
|
+
const backupDir = artifactTransactionDirectory(outDir, "backup");
|
|
261
|
+
const stagingPaths = Object.fromEntries(
|
|
262
|
+
Object.keys(paths).map(key => [key, join(stagingDir, ANALYZE_GITHUB_ARTIFACTS[key])]),
|
|
263
|
+
);
|
|
264
|
+
const backupPaths = Object.fromEntries(
|
|
265
|
+
Object.keys(finalPaths).map(key => [key, join(backupDir, ANALYZE_GITHUB_ARTIFACTS[key])]),
|
|
266
|
+
);
|
|
267
|
+
const backedUp = [];
|
|
268
|
+
const promoted = [];
|
|
269
|
+
|
|
270
|
+
await mkdir(stagingDir, { recursive: false });
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
const stagingResults = await Promise.allSettled([
|
|
274
|
+
writeJson(stagingPaths.sourceBundle, artifacts.sourceBundle),
|
|
275
|
+
writeJson(stagingPaths.normalized, artifacts.normalized),
|
|
276
|
+
writeJson(stagingPaths.metricsSummary, artifacts.metricsSummary),
|
|
277
|
+
writeJson(stagingPaths.reportJson, artifacts.reportJson),
|
|
278
|
+
writeText(stagingPaths.reportMarkdown, artifacts.reportMarkdown),
|
|
279
|
+
writeText(stagingPaths.methodology, artifacts.methodology),
|
|
280
|
+
...Object.entries(artifacts.csv ?? {}).map(([key, value]) => writeText(stagingPaths[key], value)),
|
|
281
|
+
]);
|
|
282
|
+
const rejectedStagingWrite = stagingResults.find(result => result.status === "rejected");
|
|
283
|
+
if (rejectedStagingWrite) {
|
|
284
|
+
throw rejectedStagingWrite.reason;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
await assertWritableArtifactTargets(finalPaths);
|
|
288
|
+
await mkdir(backupDir, { recursive: false });
|
|
289
|
+
|
|
290
|
+
for (const [key, finalPath] of Object.entries(finalPaths)) {
|
|
291
|
+
try {
|
|
292
|
+
await rename(finalPath, backupPaths[key]);
|
|
293
|
+
backedUp.push(key);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (error.code !== "ENOENT") {
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
for (const [key, stagingPath] of Object.entries(stagingPaths)) {
|
|
302
|
+
await rename(stagingPath, paths[key]);
|
|
303
|
+
promoted.push(key);
|
|
304
|
+
}
|
|
305
|
+
} catch (error) {
|
|
306
|
+
await Promise.allSettled(promoted.map(key => rm(paths[key], { force: true })));
|
|
307
|
+
await Promise.allSettled(backedUp.map(key => rename(backupPaths[key], finalPaths[key])));
|
|
308
|
+
throw error;
|
|
309
|
+
} finally {
|
|
310
|
+
await Promise.allSettled([
|
|
311
|
+
rm(stagingDir, { recursive: true, force: true }),
|
|
312
|
+
rm(backupDir, { recursive: true, force: true }),
|
|
313
|
+
]);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function collectionCoverageMarkdown(sourceBundle) {
|
|
318
|
+
const lines = [
|
|
319
|
+
"",
|
|
320
|
+
"## Collection Coverage",
|
|
321
|
+
"",
|
|
322
|
+
`Overall collection coverage: ${sourceBundle.coverage.status}`,
|
|
323
|
+
"",
|
|
324
|
+
"API families:",
|
|
325
|
+
];
|
|
326
|
+
|
|
327
|
+
for (const family of sourceBundle.coverage.apiFamilies ?? []) {
|
|
328
|
+
const diagnostics = (family.diagnostics ?? []).length ? `; diagnostics: ${family.diagnostics.join(" | ")}` : "";
|
|
329
|
+
const impact = family.downstreamImpact ? `; impact: ${family.downstreamImpact}` : "";
|
|
330
|
+
lines.push(`- ${family.family}: ${family.status} (${family.attempts ?? 1} attempt(s))${diagnostics}${impact}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
lines.push("");
|
|
334
|
+
return lines.join("\n");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function attachCollectionCoverage(report, sourceBundle) {
|
|
338
|
+
return {
|
|
339
|
+
...report,
|
|
340
|
+
collectionCoverage: sourceBundle.coverage,
|
|
341
|
+
artifactSensitivity: "Generated artifacts may include repository names, PR URLs, titles, file paths, comment metadata, curated CSV evidence, and coverage diagnostics. Treat them as local/private unless intentionally shared.",
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function summarizeResult({ dryRun, outDir, paths, sourceBundle, metrics, report, requestedLimit, sampledLimit, csv, analysisFilter }) {
|
|
346
|
+
return {
|
|
347
|
+
ok: true,
|
|
348
|
+
dryRun,
|
|
349
|
+
csvArtifactsEnabled: Boolean(csv),
|
|
350
|
+
analysisFilter: analysisFilter ?? null,
|
|
351
|
+
requestedLimit,
|
|
352
|
+
sampledLimit,
|
|
353
|
+
outputDirectory: outDir,
|
|
354
|
+
artifactPaths: dryRun ? null : paths,
|
|
355
|
+
targetRepository: sourceBundle.targetRepository,
|
|
356
|
+
selection: sourceBundle.selection,
|
|
357
|
+
collectionCoverage: sourceBundle.coverage,
|
|
358
|
+
totals: metrics?.totals ?? null,
|
|
359
|
+
topBottleneckIds: report?.summary?.topBottleneckIds ?? null,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function applyPrClassFilter(normalized, excludedPrClasses = []) {
|
|
364
|
+
if (!excludedPrClasses.length) return normalized;
|
|
365
|
+
const excluded = new Set(excludedPrClasses);
|
|
366
|
+
const originalPullRequests = normalized.pullRequests ?? [];
|
|
367
|
+
if (!originalPullRequests.length) {
|
|
368
|
+
throw new Error("exclude-pr-class cannot filter because no merged pull requests were collected.");
|
|
369
|
+
}
|
|
370
|
+
const filteredPullRequests = originalPullRequests.filter(pr => !excluded.has(pr.prClass?.class ?? "unknown"));
|
|
371
|
+
if (!filteredPullRequests.length) {
|
|
372
|
+
throw new Error(`exclude-pr-class removed all ${originalPullRequests.length} collected pull request(s); choose a less restrictive filter.`);
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
...normalized,
|
|
376
|
+
analysisFilter: {
|
|
377
|
+
excludedPrClasses,
|
|
378
|
+
originalPullRequests: originalPullRequests.length,
|
|
379
|
+
filteredPullRequests: filteredPullRequests.length,
|
|
380
|
+
},
|
|
381
|
+
pullRequests: filteredPullRequests,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export async function runAnalyzeGithub(options, {
|
|
386
|
+
provider = createGhCliProvider(),
|
|
387
|
+
now = () => new Date().toISOString(),
|
|
388
|
+
onProgress = null,
|
|
389
|
+
} = {}) {
|
|
390
|
+
requireOptions(options);
|
|
391
|
+
validateRepositorySlug(options.repository);
|
|
392
|
+
validateLimit(options.limit);
|
|
393
|
+
validateExcludedPrClasses(options.excludedPrClasses);
|
|
394
|
+
const csvEnabled = options.csv !== false;
|
|
395
|
+
|
|
396
|
+
onProgress?.("Validating profile and output directory.");
|
|
397
|
+
const [repositoryProfile, outDir] = await Promise.all([
|
|
398
|
+
readProfile(options.profilePath),
|
|
399
|
+
validateOutputDirectory(options.outDir),
|
|
400
|
+
]);
|
|
401
|
+
validateExcludedPrClassesAreConfigured(options.excludedPrClasses ?? [], repositoryProfile);
|
|
402
|
+
const generatedPaths = artifactPaths(outDir, { includeCsv: csvEnabled });
|
|
403
|
+
const disabledPaths = csvEnabled ? {} : Object.fromEntries(
|
|
404
|
+
Object.entries(artifactPaths(outDir, { includeCsv: true })).filter(([key]) => CSV_ARTIFACT_KEYS.has(key)),
|
|
405
|
+
);
|
|
406
|
+
if (!options.dryRun) {
|
|
407
|
+
await assertWritableArtifactTargets({ ...generatedPaths, ...disabledPaths });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const collectionLimit = options.dryRun ? Math.min(options.limit, 1) : options.limit;
|
|
411
|
+
onProgress?.(options.dryRun
|
|
412
|
+
? "Sampling GitHub coverage without writing report artifacts."
|
|
413
|
+
: `Collecting latest ${options.limit} merged pull request(s) from ${options.repository}.`);
|
|
414
|
+
const sourceBundle = await collectGitHubSourceBundle({
|
|
415
|
+
repository: options.repository,
|
|
416
|
+
limit: collectionLimit,
|
|
417
|
+
provider,
|
|
418
|
+
collectedAt: now(),
|
|
419
|
+
isValidationTarget: options.isValidationTarget,
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
if (options.dryRun) {
|
|
423
|
+
return summarizeResult({
|
|
424
|
+
dryRun: true,
|
|
425
|
+
outDir,
|
|
426
|
+
paths: generatedPaths,
|
|
427
|
+
sourceBundle,
|
|
428
|
+
requestedLimit: options.limit,
|
|
429
|
+
sampledLimit: collectionLimit,
|
|
430
|
+
csv: false,
|
|
431
|
+
analysisFilter: null,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
onProgress?.("Normalizing source bundle and computing metrics.");
|
|
436
|
+
const normalized = applyPrClassFilter(
|
|
437
|
+
normalizeFixtureBundle(sourceBundle, { repositoryProfile }),
|
|
438
|
+
options.excludedPrClasses ?? [],
|
|
439
|
+
);
|
|
440
|
+
const metrics = computeRepositoryMetrics(normalized);
|
|
441
|
+
const report = attachCollectionCoverage(generateRepositoryFrictionReport(metrics), sourceBundle);
|
|
442
|
+
const markdown = `${renderRepositoryFrictionMarkdown(report)}${collectionCoverageMarkdown(sourceBundle)}`;
|
|
443
|
+
const methodology = renderRepositoryFrictionMethodology({
|
|
444
|
+
report,
|
|
445
|
+
sourceBundle,
|
|
446
|
+
profilePath: options.profilePath,
|
|
447
|
+
artifactFileNames: ANALYZE_GITHUB_ARTIFACTS,
|
|
448
|
+
csvEnabled,
|
|
449
|
+
});
|
|
450
|
+
const csv = csvEnabled
|
|
451
|
+
? generateEvidenceCsvArtifacts({
|
|
452
|
+
metricsSummary: metrics,
|
|
453
|
+
report,
|
|
454
|
+
collectionCoverage: sourceBundle.coverage,
|
|
455
|
+
})
|
|
456
|
+
: {};
|
|
457
|
+
|
|
458
|
+
onProgress?.("Writing local artifacts.");
|
|
459
|
+
await writeAnalysisArtifacts(outDir, generatedPaths, {
|
|
460
|
+
sourceBundle,
|
|
461
|
+
normalized,
|
|
462
|
+
metricsSummary: metrics,
|
|
463
|
+
reportJson: report,
|
|
464
|
+
reportMarkdown: markdown,
|
|
465
|
+
methodology,
|
|
466
|
+
csv,
|
|
467
|
+
}, { disabledPaths });
|
|
468
|
+
|
|
469
|
+
return summarizeResult({
|
|
470
|
+
dryRun: false,
|
|
471
|
+
outDir,
|
|
472
|
+
paths: generatedPaths,
|
|
473
|
+
sourceBundle,
|
|
474
|
+
metrics,
|
|
475
|
+
report,
|
|
476
|
+
requestedLimit: options.limit,
|
|
477
|
+
sampledLimit: collectionLimit,
|
|
478
|
+
csv: csvEnabled,
|
|
479
|
+
analysisFilter: normalized.analysisFilter ?? null,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function writeProgress(message) {
|
|
484
|
+
process.stderr.write(`${message}\n`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function coverageLine(family) {
|
|
488
|
+
const diagnostics = (family.diagnostics ?? []).filter(Boolean);
|
|
489
|
+
const details = [
|
|
490
|
+
`status=${family.status}`,
|
|
491
|
+
`attempts=${family.attempts ?? 1}`,
|
|
492
|
+
family.source ? `source=${family.source}` : null,
|
|
493
|
+
family.downstreamImpact ? `impact=${family.downstreamImpact}` : null,
|
|
494
|
+
diagnostics.length ? `diagnostics=${diagnostics.join(" | ")}` : null,
|
|
495
|
+
].filter(Boolean);
|
|
496
|
+
return `- ${family.family}: ${details.join("; ")}`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function coverageCaveats(coverage) {
|
|
500
|
+
return (coverage?.apiFamilies ?? []).filter(family => {
|
|
501
|
+
const diagnostics = (family.diagnostics ?? []).filter(Boolean);
|
|
502
|
+
return family.status !== "available" || diagnostics.length > 0;
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export function formatAnalyzeGithubCompletion(result) {
|
|
507
|
+
const target = result.targetRepository?.owner && result.targetRepository?.name
|
|
508
|
+
? `${result.targetRepository.owner}/${result.targetRepository.name}`
|
|
509
|
+
: "unknown repository";
|
|
510
|
+
const lines = [];
|
|
511
|
+
|
|
512
|
+
if (result.dryRun) {
|
|
513
|
+
lines.push(
|
|
514
|
+
`Dry run complete for ${target}.`,
|
|
515
|
+
`Sampled pull requests: ${result.sampledLimit} of ${result.requestedLimit} requested.`,
|
|
516
|
+
"Artifacts: not written.",
|
|
517
|
+
);
|
|
518
|
+
} else {
|
|
519
|
+
const paths = result.artifactPaths ?? {};
|
|
520
|
+
lines.push(
|
|
521
|
+
`Markdown report: ${paths.reportMarkdown}`,
|
|
522
|
+
`Analysis complete for ${target}.`,
|
|
523
|
+
`Methodology: ${paths.methodology}`,
|
|
524
|
+
`JSON report: ${paths.reportJson}`,
|
|
525
|
+
`Metrics summary: ${paths.metricsSummary}`,
|
|
526
|
+
`Source bundle: ${paths.sourceBundle}`,
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
if (result.csvArtifactsEnabled) {
|
|
530
|
+
lines.push(
|
|
531
|
+
"CSV evidence:",
|
|
532
|
+
`- PR metrics: ${paths.prMetricsCsv}`,
|
|
533
|
+
`- Bottleneck examples: ${paths.bottleneckExamplesCsv}`,
|
|
534
|
+
`- Comment sources: ${paths.commentSourcesCsv}`,
|
|
535
|
+
`- Collection coverage: ${paths.collectionCoverageCsv}`,
|
|
536
|
+
);
|
|
537
|
+
} else {
|
|
538
|
+
lines.push("CSV evidence: disabled by --no-csv.");
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (result.analysisFilter?.excludedPrClasses?.length) {
|
|
543
|
+
lines.push(
|
|
544
|
+
`Analysis filter: excluded PR class(es): ${result.analysisFilter.excludedPrClasses.join(", ")}.`,
|
|
545
|
+
`Filtered sample: ${result.analysisFilter.filteredPullRequests} of ${result.analysisFilter.originalPullRequests} collected pull request(s).`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
lines.push(`Collection coverage: ${result.collectionCoverage?.status ?? "unknown"}.`);
|
|
550
|
+
|
|
551
|
+
const caveats = coverageCaveats(result.collectionCoverage);
|
|
552
|
+
if (caveats.length > 0) {
|
|
553
|
+
lines.push("Coverage caveats:", ...caveats.map(coverageLine));
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (!result.dryRun && Array.isArray(result.topBottleneckIds) && result.topBottleneckIds.length > 0) {
|
|
557
|
+
lines.push(`Top bottlenecks: ${result.topBottleneckIds.join(", ")}.`);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return `${lines.join("\n")}\n`;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function writeAnalyzeGithubCompletion(result, { json = false, stdout = process.stdout } = {}) {
|
|
564
|
+
if (json) {
|
|
565
|
+
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
stdout.write(formatAnalyzeGithubCompletion(result));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async function main(argv) {
|
|
572
|
+
const options = parseAnalyzeGithubArgs(argv);
|
|
573
|
+
if (options.help) {
|
|
574
|
+
process.stdout.write(USAGE);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const result = await runAnalyzeGithub(options, { onProgress: writeProgress });
|
|
579
|
+
writeAnalyzeGithubCompletion(result, { json: options.json });
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function isCliEntrypoint(entryPath) {
|
|
583
|
+
if (!entryPath) return false;
|
|
584
|
+
|
|
585
|
+
try {
|
|
586
|
+
return realpathSync(entryPath) === realpathSync(fileURLToPath(import.meta.url));
|
|
587
|
+
} catch {
|
|
588
|
+
return import.meta.url === pathToFileURL(entryPath).href;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (isCliEntrypoint(process.argv[1])) {
|
|
593
|
+
main(process.argv.slice(2)).catch(error => {
|
|
594
|
+
process.stderr.write(`${error.message}\n\n${USAGE}`);
|
|
595
|
+
process.exitCode = 1;
|
|
596
|
+
});
|
|
597
|
+
}
|