delivery-friction-analyzer 0.4.0 → 0.6.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/README.md +3 -2
- package/docs/contracts/friction-report.md +3 -0
- package/docs/reference/repository-profile.md +5 -1
- package/package.json +1 -1
- package/release-log.md +16 -0
- package/src/cli/analyze-github.js +496 -59
- package/src/report/evidence-artifacts.js +22 -0
- package/src/report/friction-report.js +88 -1
package/README.md
CHANGED
|
@@ -50,7 +50,7 @@ For a guided first run in a local terminal, use the opt-in interactive flow:
|
|
|
50
50
|
npm run analyze:github -- --interactive
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
Interactive mode asks for the same run choices supported by flags, including repository, PR limit, profile path, output directory, dry-run mode, CSV exports, JSON completion output, and configured PR class exclusions. Scripted and CI usage should keep passing explicit flags; missing required flags without `--interactive` fail deterministically instead of waiting for input.
|
|
53
|
+
Interactive mode asks for the same run choices supported by flags, including repository, PR limit, profile path, output directory, dry-run mode, CSV exports, JSON completion output, and configured PR class exclusions. It can also create a missing repository profile path or write a generated profile copy with confirmed workflow context and release PR title rules. Scripted and CI usage should keep passing explicit flags; missing required flags without `--interactive` fail deterministically instead of waiting for input.
|
|
54
54
|
|
|
55
55
|
## Repository Profiles
|
|
56
56
|
|
|
@@ -61,7 +61,8 @@ Profiles can define:
|
|
|
61
61
|
- file categories such as code, tests, docs, generated files, infrastructure, or config;
|
|
62
62
|
- file roles such as core product code, release notes, fixtures, planning docs, or generated docs;
|
|
63
63
|
- functional surfaces such as runtime, test suite, release notes, or user docs;
|
|
64
|
-
- PR classes such as release, dependency, feature, or other repository-specific groups
|
|
64
|
+
- PR classes such as release, dependency, feature, or other repository-specific groups;
|
|
65
|
+
- workflow context such as merge method, release strategy, and branch strategy.
|
|
65
66
|
|
|
66
67
|
Use `fixtures/github/mcp-writing/profile.json` as a starting point, then save a copy for the repository you want to analyze. The full profile format is documented in `docs/reference/repository-profile.md`, and the schema lives at `schemas/repository-profile.schema.json`.
|
|
67
68
|
|
|
@@ -27,6 +27,7 @@ The command reads local `friction-metrics.v1` JSON and writes deterministic `fri
|
|
|
27
27
|
- `metricVersion`: source metrics contract version.
|
|
28
28
|
- `targetRepository`: analyzed repository identity; live analysis sample size is encoded as `targetRepository.analysisPullRequestLimit` from collection metadata.
|
|
29
29
|
- `analysisFilter`: optional metadata for explicit filters applied before metrics computation, including excluded PR classes and before/after PR counts.
|
|
30
|
+
- `configuredWorkflow`: optional user-configured workflow context from the repository profile. It is not observed GitHub evidence and does not change scoring, ranking, CSV exports, or PR class matching.
|
|
30
31
|
- `summary`: repository totals and top bottleneck identifiers.
|
|
31
32
|
- `coverage`: PR-open diff, workflow-run, and review-thread coverage counts plus caveats.
|
|
32
33
|
- `commentSources`: total and source-grouped review comments for Copilot, human, bot, scanner, author replies, and unknown sources.
|
|
@@ -55,6 +56,7 @@ The Markdown renderer presents the same report data for human review:
|
|
|
55
56
|
- a top-of-report focus snapshot that names focus areas, action categories, evidence reviewed, and confidence caveats before detailed bottlenecks;
|
|
56
57
|
- a compact recommendation-category snapshot before detailed bottlenecks, with the full category reference retained later in the report;
|
|
57
58
|
- a short "How To Read This Report" guide that distinguishes observed evidence, interpretation, recommendations, and caveats;
|
|
59
|
+
- a configured workflow context section only when repository profile workflow fields are present, labeled as user-configured profile context rather than observed GitHub evidence;
|
|
58
60
|
- evidence-quality and coverage tables before detailed recommendations;
|
|
59
61
|
- key findings that highlight top bottlenecks, strongest displayed signal, outlier caveats, PR class caveats, and coverage caveats;
|
|
60
62
|
- a PR class context table that shows analyzed PR counts, changed lines, sample share, and classification sources by class;
|
|
@@ -104,6 +106,7 @@ Full live analysis writes `methodology.md` as a hybrid artifact: stable explanat
|
|
|
104
106
|
|
|
105
107
|
- target repository and report/metric versions;
|
|
106
108
|
- profile path when available;
|
|
109
|
+
- configured workflow context when supplied by the repository profile, labeled as user-configured context rather than observed GitHub evidence;
|
|
107
110
|
- requested and collected PR counts;
|
|
108
111
|
- collection coverage status and API-family diagnostics;
|
|
109
112
|
- scoring, ranking, dominance, sensitivity, and limitation explanations;
|
|
@@ -54,9 +54,11 @@ If both matchers are present on one rule, both must match. If no rule matches, t
|
|
|
54
54
|
|
|
55
55
|
Class identifiers are validated as lower-kebab-case or lower_snake_case strings. Profile validation rejects duplicate PR class rule IDs, empty match objects, invalid class identifiers, and invalid title regexes.
|
|
56
56
|
|
|
57
|
+
Interactive setup can add a release PR class rule from a confirmed title convention using the current title-only matcher shape. Branch strategy answers stay in `workflow` context only; they do not create branch-based PR class matching.
|
|
58
|
+
|
|
57
59
|
## Workflow Context
|
|
58
60
|
|
|
59
|
-
`workflow` is optional user-configured context. It records repository workflow assumptions that
|
|
61
|
+
`workflow` is optional user-configured context. It records repository workflow assumptions that reports can surface as configured profile context, but the analyzer does not infer these values from GitHub and does not change scoring, rankings, collection, CSV exports, or PR class matching.
|
|
60
62
|
|
|
61
63
|
When provided, `workflow` must include at least one supported field.
|
|
62
64
|
|
|
@@ -79,3 +81,5 @@ Example:
|
|
|
79
81
|
```
|
|
80
82
|
|
|
81
83
|
Use stable identifiers exactly as shown above. Display labels such as "squash merges" or "release PRs" belong in CLI prompts or documentation, not in profile data.
|
|
84
|
+
|
|
85
|
+
When interactive setup writes profile changes, it preserves deterministic two-space JSON formatting in place. If an existing profile uses other formatting, setup writes a generated profile copy and prints that generated path in completion output instead of rewriting the original file.
|
package/package.json
CHANGED
package/release-log.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
### 2026-06-19 — Workflow Context Surfacing
|
|
6
|
+
|
|
7
|
+
- What changed: Friction reports and methodology now show configured repository workflow context from the profile when it is present.
|
|
8
|
+
- Why it matters: Maintainers can see merge, release, and branch assumptions beside report evidence without mistaking them for observed GitHub data or scoring inputs.
|
|
9
|
+
- Who is affected: Maintainers and contributors reviewing generated reports or authoring repository profiles with `workflow` context.
|
|
10
|
+
- Action needed: None.
|
|
11
|
+
- PR: https://github.com/hannasdev/delivery-friction-analyzer/pull/37
|
|
12
|
+
|
|
13
|
+
### 2026-06-18 — Workflow Profile Wizard
|
|
14
|
+
|
|
15
|
+
- What changed: Interactive setup can now create repository profiles or generated profile copies with confirmed workflow context and release PR title rules.
|
|
16
|
+
- Why it matters: Maintainers can capture reusable workflow assumptions during guided setup without hand-editing profile JSON or risking silent rewrites of existing profiles.
|
|
17
|
+
- Who is affected: Maintainers running `--interactive` for first-time repository setup or profile updates.
|
|
18
|
+
- Action needed: Review the generated or updated profile path printed in completion output before reusing it in automation.
|
|
19
|
+
- PR: https://github.com/hannasdev/delivery-friction-analyzer/pull/36
|
|
20
|
+
|
|
5
21
|
### 2026-06-17 — Workflow Profile Contract
|
|
6
22
|
|
|
7
23
|
- What changed: Repository profiles can now declare optional workflow context for merge method, release strategy, and branch strategy using validated stable identifiers.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { constants, realpathSync } from "node:fs";
|
|
3
|
-
import { access, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { access, lstat, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { createInterface } from "node:readline/promises";
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -17,7 +17,12 @@ import {
|
|
|
17
17
|
renderRepositoryFrictionMarkdown,
|
|
18
18
|
} from "../report/friction-report.js";
|
|
19
19
|
import { assertValidPrClassRules } from "../profile/pr-class.js";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
WORKFLOW_BRANCH_STRATEGIES,
|
|
22
|
+
WORKFLOW_PRIMARY_MERGE_METHODS,
|
|
23
|
+
WORKFLOW_RELEASE_STRATEGIES,
|
|
24
|
+
assertValidWorkflowContext,
|
|
25
|
+
} from "../profile/workflow.js";
|
|
21
26
|
|
|
22
27
|
const ALLOWED_OPTIONS = new Set([
|
|
23
28
|
"repo",
|
|
@@ -183,25 +188,6 @@ function validateExcludedPrClassesAreConfigured(excludedPrClasses = [], reposito
|
|
|
183
188
|
throw new Error(`exclude-pr-class must name configured PR class(es): ${unconfigured.join(", ")}.${available}`);
|
|
184
189
|
}
|
|
185
190
|
|
|
186
|
-
async function readProfile(profilePath) {
|
|
187
|
-
let profile;
|
|
188
|
-
try {
|
|
189
|
-
profile = JSON.parse(await readFile(profilePath, "utf8"));
|
|
190
|
-
} catch (error) {
|
|
191
|
-
if (error instanceof SyntaxError) {
|
|
192
|
-
throw new Error(`profile must be valid JSON: ${error.message}`);
|
|
193
|
-
}
|
|
194
|
-
throw new Error(`profile could not be read: ${error.message}`);
|
|
195
|
-
}
|
|
196
|
-
try {
|
|
197
|
-
assertValidPrClassRules(profile);
|
|
198
|
-
assertValidWorkflowContext(profile);
|
|
199
|
-
} catch (error) {
|
|
200
|
-
throw new Error(`profile is invalid: ${error.message}`);
|
|
201
|
-
}
|
|
202
|
-
return profile;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
191
|
function configuredPrClassList(repositoryProfile) {
|
|
206
192
|
return [...configuredPrClasses(repositoryProfile)].sort();
|
|
207
193
|
}
|
|
@@ -223,6 +209,9 @@ function formatInteractivePrompt(prompt) {
|
|
|
223
209
|
if (prompt.type === "multi-select" && prompt.choices?.length) {
|
|
224
210
|
return `${prompt.message} (${prompt.choices.join(",")})${suffix}: `;
|
|
225
211
|
}
|
|
212
|
+
if (prompt.type === "select" && prompt.choices?.length) {
|
|
213
|
+
return `${prompt.message} (${prompt.choices.join(",")})${suffix}: `;
|
|
214
|
+
}
|
|
226
215
|
return `${prompt.message}${suffix}: `;
|
|
227
216
|
}
|
|
228
217
|
|
|
@@ -286,22 +275,418 @@ function normalizeConfirmAnswer(raw, prompt) {
|
|
|
286
275
|
throw new Error("Answer yes or no.");
|
|
287
276
|
}
|
|
288
277
|
|
|
278
|
+
function normalizeChoiceAnswer(raw, prompt) {
|
|
279
|
+
const value = normalizeTextAnswer(raw, prompt);
|
|
280
|
+
if (prompt.choices?.includes(value)) return value;
|
|
281
|
+
throw new Error(`${prompt.id} must be one of: ${prompt.choices.join(", ")}`);
|
|
282
|
+
}
|
|
283
|
+
|
|
289
284
|
function normalizeMultiSelectAnswer(raw, prompt) {
|
|
290
285
|
const value = String(raw ?? "").trim();
|
|
291
286
|
if (!value) return prompt.defaultValue ?? [];
|
|
292
287
|
return normalizeExcludedPrClasses([value]);
|
|
293
288
|
}
|
|
294
289
|
|
|
290
|
+
function validateProfile(profile) {
|
|
291
|
+
assertValidPrClassRules(profile);
|
|
292
|
+
assertValidWorkflowContext(profile);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function parseProfileJson(text) {
|
|
296
|
+
try {
|
|
297
|
+
return JSON.parse(text);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (error instanceof SyntaxError) {
|
|
300
|
+
throw new Error(`profile must be valid JSON: ${error.message}`);
|
|
301
|
+
}
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function hasTrailingPathSeparator(profilePath) {
|
|
307
|
+
return /[/\\]$/.test(profilePath);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function inspectProfilePath(profilePath) {
|
|
311
|
+
if (hasTrailingPathSeparator(profilePath)) {
|
|
312
|
+
throw new Error("profile path must be a JSON file path, not a directory or special file.");
|
|
313
|
+
}
|
|
314
|
+
let profileLinkStat;
|
|
315
|
+
try {
|
|
316
|
+
profileLinkStat = await lstat(profilePath);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (error.code === "ENOENT") {
|
|
319
|
+
return { exists: false, profile: null, text: null, isSymbolicLink: false };
|
|
320
|
+
}
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
const isSymbolicLink = profileLinkStat.isSymbolicLink();
|
|
324
|
+
const profileStat = isSymbolicLink ? await stat(profilePath) : profileLinkStat;
|
|
325
|
+
if (!profileStat.isFile()) {
|
|
326
|
+
throw new Error("profile path must be a JSON file path, not a directory or special file.");
|
|
327
|
+
}
|
|
328
|
+
const text = await readFile(profilePath, "utf8");
|
|
329
|
+
const profile = parseProfileJson(text);
|
|
330
|
+
validateProfile(profile);
|
|
331
|
+
return { exists: true, profile, text, isSymbolicLink };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function readProfile(profilePath) {
|
|
335
|
+
let inspected;
|
|
336
|
+
try {
|
|
337
|
+
inspected = await inspectProfilePath(profilePath);
|
|
338
|
+
} catch (error) {
|
|
339
|
+
if (error.message?.startsWith("profile must be valid JSON")) {
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
if (error.message?.startsWith("invalid ")) {
|
|
343
|
+
throw new Error(`profile is invalid: ${error.message}`);
|
|
344
|
+
}
|
|
345
|
+
throw new Error(`profile could not be read: ${error.message}`);
|
|
346
|
+
}
|
|
347
|
+
if (!inspected.exists) {
|
|
348
|
+
throw new Error("profile could not be read: no such file or directory");
|
|
349
|
+
}
|
|
350
|
+
return inspected.profile;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function cloneJson(value) {
|
|
354
|
+
return JSON.parse(JSON.stringify(value));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function repositoryProfileFromSlug(repository) {
|
|
358
|
+
const match = REPOSITORY_SLUG.exec(repository);
|
|
359
|
+
if (!match) {
|
|
360
|
+
throw new Error("repo must use owner/name with GitHub-safe owner and name segments.");
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
schemaVersion: "repository-profile.v1",
|
|
364
|
+
repository: {
|
|
365
|
+
owner: match[1],
|
|
366
|
+
name: match[2],
|
|
367
|
+
},
|
|
368
|
+
rules: [],
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function formatProfile(profile) {
|
|
373
|
+
return `${JSON.stringify(profile, null, 2)}\n`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function generatedProfilePath(profilePath, index = 1) {
|
|
377
|
+
const suffix = index === 1 ? ".generated.json" : `.generated-${index}.json`;
|
|
378
|
+
return /\.json$/i.test(profilePath)
|
|
379
|
+
? profilePath.replace(/\.json$/i, suffix)
|
|
380
|
+
: `${profilePath}${suffix}`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function writeProfileFile(profilePath, profile) {
|
|
384
|
+
await mkdir(dirname(profilePath), { recursive: true });
|
|
385
|
+
await writeFile(profilePath, formatProfile(profile), { encoding: "utf8", flag: "wx" });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function shouldWriteGeneratedProfileAfterCreateFailure(error) {
|
|
389
|
+
return ["EEXIST", "EISDIR", "ELOOP"].includes(error.code);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function shouldRetryProfileReplaceAfterRenameFailure(error) {
|
|
393
|
+
return ["EEXIST", "EPERM"].includes(error.code);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function replaceProfileFile(profilePath, profile, originalText) {
|
|
397
|
+
const tempPath = `${profilePath}.${process.pid}.${Date.now()}.tmp`;
|
|
398
|
+
let shouldCleanupTemp = true;
|
|
399
|
+
try {
|
|
400
|
+
await writeFile(tempPath, formatProfile(profile), { encoding: "utf8", flag: "wx" });
|
|
401
|
+
try {
|
|
402
|
+
await rename(tempPath, profilePath);
|
|
403
|
+
shouldCleanupTemp = false;
|
|
404
|
+
return true;
|
|
405
|
+
} catch (error) {
|
|
406
|
+
if (!shouldRetryProfileReplaceAfterRenameFailure(error)) {
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
409
|
+
if (!await profileStillMatchesOriginal(profilePath, originalText)) {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
await rm(profilePath, { force: true });
|
|
413
|
+
await rename(tempPath, profilePath);
|
|
414
|
+
shouldCleanupTemp = false;
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
} finally {
|
|
418
|
+
if (shouldCleanupTemp) {
|
|
419
|
+
await rm(tempPath, { force: true });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function profileStillMatchesOriginal(profilePath, originalText) {
|
|
425
|
+
try {
|
|
426
|
+
const profileLinkStat = await lstat(profilePath);
|
|
427
|
+
if (profileLinkStat.isSymbolicLink() || !profileLinkStat.isFile()) return false;
|
|
428
|
+
return await readFile(profilePath, "utf8") === originalText;
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (error.code === "ENOENT") return false;
|
|
431
|
+
throw error;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function writeGeneratedProfileFile(profilePath, profile) {
|
|
436
|
+
for (let index = 1; ; index += 1) {
|
|
437
|
+
const writePath = generatedProfilePath(profilePath, index);
|
|
438
|
+
try {
|
|
439
|
+
await mkdir(dirname(writePath), { recursive: true });
|
|
440
|
+
await writeFile(writePath, formatProfile(profile), { encoding: "utf8", flag: "wx" });
|
|
441
|
+
return writePath;
|
|
442
|
+
} catch (error) {
|
|
443
|
+
if (error.code === "EEXIST") continue;
|
|
444
|
+
throw error;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function writeInteractiveProfile(profilePath, profile, {
|
|
450
|
+
exists,
|
|
451
|
+
originalProfile,
|
|
452
|
+
originalText,
|
|
453
|
+
originalIsSymbolicLink,
|
|
454
|
+
}) {
|
|
455
|
+
const canRewriteExisting = exists
|
|
456
|
+
&& !originalIsSymbolicLink
|
|
457
|
+
&& originalText === formatProfile(originalProfile);
|
|
458
|
+
if (exists && !canRewriteExisting) {
|
|
459
|
+
return writeGeneratedProfileFile(profilePath, profile);
|
|
460
|
+
}
|
|
461
|
+
if (!exists) {
|
|
462
|
+
try {
|
|
463
|
+
await writeProfileFile(profilePath, profile);
|
|
464
|
+
return profilePath;
|
|
465
|
+
} catch (error) {
|
|
466
|
+
if (shouldWriteGeneratedProfileAfterCreateFailure(error)) {
|
|
467
|
+
return writeGeneratedProfileFile(profilePath, profile);
|
|
468
|
+
}
|
|
469
|
+
throw error;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (!await profileStillMatchesOriginal(profilePath, originalText)) {
|
|
473
|
+
return writeGeneratedProfileFile(profilePath, profile);
|
|
474
|
+
}
|
|
475
|
+
if (!await replaceProfileFile(profilePath, profile, originalText)) {
|
|
476
|
+
return writeGeneratedProfileFile(profilePath, profile);
|
|
477
|
+
}
|
|
478
|
+
return profilePath;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function releaseClassRuleIndexes(profile) {
|
|
482
|
+
if (!Array.isArray(profile.prClasses)) return [];
|
|
483
|
+
return profile.prClasses
|
|
484
|
+
.map((rule, index) => rule.class === "release" ? index : -1)
|
|
485
|
+
.filter(index => index >= 0);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function updateableReleaseClassRuleIndex(profile) {
|
|
489
|
+
const releaseIndexes = releaseClassRuleIndexes(profile);
|
|
490
|
+
if (releaseIndexes.length !== 1) return -1;
|
|
491
|
+
const rule = profile.prClasses[releaseIndexes[0]];
|
|
492
|
+
const match = rule.match ?? {};
|
|
493
|
+
const hasTitleIncludes = typeof match.titleIncludes === "string" && match.titleIncludes.length > 0;
|
|
494
|
+
const hasTitleRegex = typeof match.titleRegex === "string" && match.titleRegex.length > 0;
|
|
495
|
+
return hasTitleIncludes && !hasTitleRegex ? releaseIndexes[0] : -1;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function defaultReleaseTitleIncludes(profile) {
|
|
499
|
+
const index = updateableReleaseClassRuleIndex(profile);
|
|
500
|
+
const existingIncludes = index >= 0 ? profile.prClasses[index]?.match?.titleIncludes : null;
|
|
501
|
+
return typeof existingIncludes === "string" && existingIncludes.length ? existingIncludes : "Release";
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function nextPrClassRuleId(profile, baseId) {
|
|
505
|
+
const existingIds = new Set((profile.prClasses ?? []).map(rule => rule.id));
|
|
506
|
+
if (!existingIds.has(baseId)) return baseId;
|
|
507
|
+
for (let index = 2; ; index += 1) {
|
|
508
|
+
const candidate = `${baseId}-${index}`;
|
|
509
|
+
if (!existingIds.has(candidate)) return candidate;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function releasePrClassRule(profile, titleIncludes, existingRule = null) {
|
|
514
|
+
return {
|
|
515
|
+
id: existingRule?.id ?? nextPrClassRuleId(profile, "release-title"),
|
|
516
|
+
class: "release",
|
|
517
|
+
match: { ...(existingRule?.match ?? {}), titleIncludes },
|
|
518
|
+
notes: existingRule?.notes ?? "Generated by interactive setup from the configured release PR title convention.",
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function promptWorkflowField(promptAdapter, output, { id, message, choices, defaultValue }) {
|
|
523
|
+
return askUntilValid(promptAdapter, {
|
|
524
|
+
id,
|
|
525
|
+
type: "select",
|
|
526
|
+
message,
|
|
527
|
+
choices,
|
|
528
|
+
defaultValue,
|
|
529
|
+
}, {
|
|
530
|
+
output,
|
|
531
|
+
normalize: normalizeChoiceAnswer,
|
|
532
|
+
validate() {},
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function promptWorkflowProfileUpdate(promptAdapter, output, profile, { isNewProfile }) {
|
|
537
|
+
const updated = cloneJson(profile);
|
|
538
|
+
updated.workflow = {
|
|
539
|
+
primaryMergeMethod: await promptWorkflowField(promptAdapter, output, {
|
|
540
|
+
id: "primaryMergeMethod",
|
|
541
|
+
message: "Primary merge method",
|
|
542
|
+
choices: WORKFLOW_PRIMARY_MERGE_METHODS,
|
|
543
|
+
defaultValue: updated.workflow?.primaryMergeMethod ?? "unknown",
|
|
544
|
+
}),
|
|
545
|
+
releaseStrategy: await promptWorkflowField(promptAdapter, output, {
|
|
546
|
+
id: "releaseStrategy",
|
|
547
|
+
message: "Release strategy",
|
|
548
|
+
choices: WORKFLOW_RELEASE_STRATEGIES,
|
|
549
|
+
defaultValue: updated.workflow?.releaseStrategy ?? "unknown",
|
|
550
|
+
}),
|
|
551
|
+
branchStrategy: await promptWorkflowField(promptAdapter, output, {
|
|
552
|
+
id: "branchStrategy",
|
|
553
|
+
message: "Branch strategy",
|
|
554
|
+
choices: WORKFLOW_BRANCH_STRATEGIES,
|
|
555
|
+
defaultValue: updated.workflow?.branchStrategy ?? "unknown",
|
|
556
|
+
}),
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
if (updated.workflow.releaseStrategy === "release_prs") {
|
|
560
|
+
const suggestedReleaseTitle = defaultReleaseTitleIncludes(updated);
|
|
561
|
+
const titleIncludes = await askUntilValid(promptAdapter, {
|
|
562
|
+
id: "releasePrTitleIncludes",
|
|
563
|
+
type: "text",
|
|
564
|
+
message: `Release PR title includes (blank to skip PR class rule; suggested: ${suggestedReleaseTitle})`,
|
|
565
|
+
}, {
|
|
566
|
+
output,
|
|
567
|
+
normalize: normalizeTextAnswer,
|
|
568
|
+
validate() {},
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
if (titleIncludes) {
|
|
572
|
+
updated.prClasses = Array.isArray(updated.prClasses) ? [...updated.prClasses] : [];
|
|
573
|
+
const updateableReleaseIndex = updateableReleaseClassRuleIndex(updated);
|
|
574
|
+
if (updateableReleaseIndex >= 0) {
|
|
575
|
+
const shouldUpdateReleaseRule = await askUntilValid(promptAdapter, {
|
|
576
|
+
id: "updateReleasePrClass",
|
|
577
|
+
type: "confirm",
|
|
578
|
+
message: "Update existing title-based release PR class rule",
|
|
579
|
+
defaultValue: false,
|
|
580
|
+
}, {
|
|
581
|
+
output,
|
|
582
|
+
normalize: normalizeConfirmAnswer,
|
|
583
|
+
validate() {},
|
|
584
|
+
});
|
|
585
|
+
if (shouldUpdateReleaseRule) {
|
|
586
|
+
updated.prClasses[updateableReleaseIndex] = releasePrClassRule(
|
|
587
|
+
updated,
|
|
588
|
+
titleIncludes,
|
|
589
|
+
updated.prClasses[updateableReleaseIndex],
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
} else {
|
|
593
|
+
const shouldAddReleaseRule = isNewProfile
|
|
594
|
+
? true
|
|
595
|
+
: await askUntilValid(promptAdapter, {
|
|
596
|
+
id: "addReleasePrClass",
|
|
597
|
+
type: "confirm",
|
|
598
|
+
message: "Add release PR class rule from title convention",
|
|
599
|
+
defaultValue: true,
|
|
600
|
+
}, {
|
|
601
|
+
output,
|
|
602
|
+
normalize: normalizeConfirmAnswer,
|
|
603
|
+
validate() {},
|
|
604
|
+
});
|
|
605
|
+
if (shouldAddReleaseRule) {
|
|
606
|
+
updated.prClasses.push(releasePrClassRule(updated, titleIncludes));
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
validateProfile(updated);
|
|
613
|
+
return updated;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async function maybeConfigureInteractiveProfile(promptAdapter, output, profileState, repository) {
|
|
617
|
+
const isNewProfile = !profileState.exists;
|
|
618
|
+
const originalProfile = profileState.profile;
|
|
619
|
+
let profile = isNewProfile ? repositoryProfileFromSlug(repository) : cloneJson(originalProfile);
|
|
620
|
+
|
|
621
|
+
if (isNewProfile) {
|
|
622
|
+
const shouldCreateProfile = await askUntilValid(promptAdapter, {
|
|
623
|
+
id: "createProfile",
|
|
624
|
+
type: "confirm",
|
|
625
|
+
message: "Create repository profile at this path",
|
|
626
|
+
defaultValue: true,
|
|
627
|
+
}, {
|
|
628
|
+
output,
|
|
629
|
+
normalize: normalizeConfirmAnswer,
|
|
630
|
+
validate() {},
|
|
631
|
+
});
|
|
632
|
+
if (!shouldCreateProfile) {
|
|
633
|
+
throw new Error("Repository profile is required.");
|
|
634
|
+
}
|
|
635
|
+
} else {
|
|
636
|
+
const shouldConfigureWorkflow = await askUntilValid(promptAdapter, {
|
|
637
|
+
id: "configureWorkflow",
|
|
638
|
+
type: "confirm",
|
|
639
|
+
message: "Configure repository workflow profile fields",
|
|
640
|
+
defaultValue: false,
|
|
641
|
+
}, {
|
|
642
|
+
output,
|
|
643
|
+
normalize: normalizeConfirmAnswer,
|
|
644
|
+
validate() {},
|
|
645
|
+
});
|
|
646
|
+
if (!shouldConfigureWorkflow) {
|
|
647
|
+
return {
|
|
648
|
+
profile,
|
|
649
|
+
profilePath: profileState.profilePath,
|
|
650
|
+
savedProfilePath: null,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
profile = await promptWorkflowProfileUpdate(promptAdapter, output, profile, { isNewProfile });
|
|
656
|
+
const savedProfilePath = await writeInteractiveProfile(profileState.profilePath, profile, {
|
|
657
|
+
exists: profileState.exists,
|
|
658
|
+
originalProfile,
|
|
659
|
+
originalText: profileState.text,
|
|
660
|
+
originalIsSymbolicLink: profileState.isSymbolicLink,
|
|
661
|
+
});
|
|
662
|
+
return {
|
|
663
|
+
profile,
|
|
664
|
+
profilePath: savedProfilePath,
|
|
665
|
+
savedProfilePath,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
295
669
|
async function promptProfilePath(promptAdapter, output, prompt) {
|
|
296
|
-
let
|
|
670
|
+
let profileState = null;
|
|
297
671
|
const profilePath = await askUntilValid(promptAdapter, prompt, {
|
|
298
672
|
output,
|
|
299
673
|
normalize: normalizeTextAnswer,
|
|
300
674
|
async validate(value) {
|
|
301
|
-
profile
|
|
675
|
+
if (!value) throw new Error("Repository profile path is required.");
|
|
676
|
+
try {
|
|
677
|
+
profileState = await inspectProfilePath(value);
|
|
678
|
+
} catch (error) {
|
|
679
|
+
if (error.message?.startsWith("profile must be valid JSON")) {
|
|
680
|
+
throw error;
|
|
681
|
+
}
|
|
682
|
+
if (error.message?.startsWith("invalid ")) {
|
|
683
|
+
throw new Error(`profile is invalid: ${error.message}`);
|
|
684
|
+
}
|
|
685
|
+
throw new Error(`profile could not be read: ${error.message}`);
|
|
686
|
+
}
|
|
302
687
|
},
|
|
303
688
|
});
|
|
304
|
-
return { profilePath,
|
|
689
|
+
return { profilePath, ...profileState };
|
|
305
690
|
}
|
|
306
691
|
|
|
307
692
|
function hasOwnOption(options, key) {
|
|
@@ -321,6 +706,7 @@ export async function collectInteractiveAnalyzeGithubOptions(options, {
|
|
|
321
706
|
input = process.stdin,
|
|
322
707
|
output = process.stderr,
|
|
323
708
|
isInteractiveTerminal = Boolean(input?.isTTY),
|
|
709
|
+
onSavedProfilePath = null,
|
|
324
710
|
} = {}) {
|
|
325
711
|
if (!isInteractiveTerminal) {
|
|
326
712
|
throw new Error("interactive mode requires a terminal. Re-run with --repo <owner/name> --limit <1-100> --profile <path> --out <directory>, or provide a TTY for prompts.");
|
|
@@ -331,6 +717,7 @@ export async function collectInteractiveAnalyzeGithubOptions(options, {
|
|
|
331
717
|
const resolved = { ...options };
|
|
332
718
|
delete resolved.interactivePromptDefaults;
|
|
333
719
|
let repositoryProfile = null;
|
|
720
|
+
let profileState = null;
|
|
334
721
|
|
|
335
722
|
try {
|
|
336
723
|
if (!resolved.repository) {
|
|
@@ -364,11 +751,34 @@ export async function collectInteractiveAnalyzeGithubOptions(options, {
|
|
|
364
751
|
type: "path",
|
|
365
752
|
message: "Repository profile path",
|
|
366
753
|
});
|
|
367
|
-
|
|
368
|
-
repositoryProfile = prompted.profile;
|
|
754
|
+
profileState = prompted;
|
|
369
755
|
} else {
|
|
370
|
-
|
|
756
|
+
try {
|
|
757
|
+
profileState = {
|
|
758
|
+
profilePath: resolved.profilePath,
|
|
759
|
+
...await inspectProfilePath(resolved.profilePath),
|
|
760
|
+
};
|
|
761
|
+
} catch (error) {
|
|
762
|
+
if (error.message?.startsWith("profile must be valid JSON")) {
|
|
763
|
+
throw error;
|
|
764
|
+
}
|
|
765
|
+
if (error.message?.startsWith("invalid ")) {
|
|
766
|
+
throw new Error(`profile is invalid: ${error.message}`);
|
|
767
|
+
}
|
|
768
|
+
throw new Error(`profile could not be read: ${error.message}`);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
resolved.profilePath = profileState.profilePath;
|
|
772
|
+
|
|
773
|
+
const profileUpdate = await maybeConfigureInteractiveProfile(adapter, output, profileState, resolved.repository);
|
|
774
|
+
resolved.profilePath = profileUpdate.profilePath;
|
|
775
|
+
if (profileUpdate.savedProfilePath) {
|
|
776
|
+
resolved.savedProfilePath = profileUpdate.savedProfilePath;
|
|
777
|
+
if (typeof onSavedProfilePath === "function") {
|
|
778
|
+
onSavedProfilePath(profileUpdate.savedProfilePath);
|
|
779
|
+
}
|
|
371
780
|
}
|
|
781
|
+
repositoryProfile = profileUpdate.profile;
|
|
372
782
|
|
|
373
783
|
if (!resolved.outDir) {
|
|
374
784
|
resolved.outDir = await askUntilValid(adapter, {
|
|
@@ -612,8 +1022,8 @@ function attachCollectionCoverage(report, sourceBundle) {
|
|
|
612
1022
|
};
|
|
613
1023
|
}
|
|
614
1024
|
|
|
615
|
-
function summarizeResult({ dryRun, outDir, paths, sourceBundle, metrics, report, requestedLimit, sampledLimit, csv, analysisFilter }) {
|
|
616
|
-
|
|
1025
|
+
function summarizeResult({ dryRun, outDir, paths, sourceBundle, metrics, report, requestedLimit, sampledLimit, csv, analysisFilter, savedProfilePath }) {
|
|
1026
|
+
const summary = {
|
|
617
1027
|
ok: true,
|
|
618
1028
|
dryRun,
|
|
619
1029
|
csvArtifactsEnabled: Boolean(csv),
|
|
@@ -628,6 +1038,10 @@ function summarizeResult({ dryRun, outDir, paths, sourceBundle, metrics, report,
|
|
|
628
1038
|
totals: metrics?.totals ?? null,
|
|
629
1039
|
topBottleneckIds: report?.summary?.topBottleneckIds ?? null,
|
|
630
1040
|
};
|
|
1041
|
+
if (savedProfilePath) {
|
|
1042
|
+
summary.savedProfilePath = savedProfilePath;
|
|
1043
|
+
}
|
|
1044
|
+
return summary;
|
|
631
1045
|
}
|
|
632
1046
|
|
|
633
1047
|
function applyPrClassFilter(normalized, excludedPrClasses = []) {
|
|
@@ -699,6 +1113,7 @@ export async function runAnalyzeGithub(options, {
|
|
|
699
1113
|
sampledLimit: collectionLimit,
|
|
700
1114
|
csv: false,
|
|
701
1115
|
analysisFilter: null,
|
|
1116
|
+
savedProfilePath: options.savedProfilePath,
|
|
702
1117
|
});
|
|
703
1118
|
}
|
|
704
1119
|
|
|
@@ -708,7 +1123,10 @@ export async function runAnalyzeGithub(options, {
|
|
|
708
1123
|
options.excludedPrClasses ?? [],
|
|
709
1124
|
);
|
|
710
1125
|
const metrics = computeRepositoryMetrics(normalized);
|
|
711
|
-
const report = attachCollectionCoverage(
|
|
1126
|
+
const report = attachCollectionCoverage(
|
|
1127
|
+
generateRepositoryFrictionReport(metrics, { workflowContext: repositoryProfile.workflow }),
|
|
1128
|
+
sourceBundle,
|
|
1129
|
+
);
|
|
712
1130
|
const markdown = `${renderRepositoryFrictionMarkdown(report)}${collectionCoverageMarkdown(sourceBundle)}`;
|
|
713
1131
|
const methodology = renderRepositoryFrictionMethodology({
|
|
714
1132
|
report,
|
|
@@ -747,6 +1165,7 @@ export async function runAnalyzeGithub(options, {
|
|
|
747
1165
|
sampledLimit: collectionLimit,
|
|
748
1166
|
csv: csvEnabled,
|
|
749
1167
|
analysisFilter: normalized.analysisFilter ?? null,
|
|
1168
|
+
savedProfilePath: options.savedProfilePath,
|
|
750
1169
|
});
|
|
751
1170
|
}
|
|
752
1171
|
|
|
@@ -816,6 +1235,10 @@ export function formatAnalyzeGithubCompletion(result) {
|
|
|
816
1235
|
);
|
|
817
1236
|
}
|
|
818
1237
|
|
|
1238
|
+
if (result.savedProfilePath) {
|
|
1239
|
+
lines.push(`Repository profile saved: ${result.savedProfilePath}.`);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
819
1242
|
lines.push(`Collection coverage: ${result.collectionCoverage?.status ?? "unknown"}.`);
|
|
820
1243
|
|
|
821
1244
|
const caveats = coverageCaveats(result.collectionCoverage);
|
|
@@ -847,36 +1270,50 @@ export async function runAnalyzeGithubCli(argv, {
|
|
|
847
1270
|
promptAdapter = null,
|
|
848
1271
|
isInteractiveTerminal = Boolean(stdin?.isTTY),
|
|
849
1272
|
} = {}) {
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
? await collectInteractiveAnalyzeGithubOptions({
|
|
858
|
-
...options,
|
|
859
|
-
interactivePromptDefaults: {
|
|
860
|
-
dryRun: !options.dryRun,
|
|
861
|
-
csv: options.csv !== false,
|
|
862
|
-
json: !options.json,
|
|
863
|
-
},
|
|
864
|
-
}, {
|
|
865
|
-
promptAdapter,
|
|
866
|
-
input: stdin,
|
|
867
|
-
output: stderr,
|
|
868
|
-
isInteractiveTerminal,
|
|
869
|
-
})
|
|
870
|
-
: options;
|
|
871
|
-
const runOptions = {
|
|
872
|
-
onProgress: message => writeProgress(message, stderr),
|
|
873
|
-
};
|
|
874
|
-
if (provider !== undefined) runOptions.provider = provider;
|
|
875
|
-
if (now !== undefined) runOptions.now = now;
|
|
1273
|
+
let savedProfilePath = null;
|
|
1274
|
+
try {
|
|
1275
|
+
const options = parseAnalyzeGithubArgs(argv);
|
|
1276
|
+
if (options.help) {
|
|
1277
|
+
stdout.write(USAGE);
|
|
1278
|
+
return null;
|
|
1279
|
+
}
|
|
876
1280
|
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
1281
|
+
const resolvedOptions = options.interactive
|
|
1282
|
+
? await collectInteractiveAnalyzeGithubOptions({
|
|
1283
|
+
...options,
|
|
1284
|
+
interactivePromptDefaults: {
|
|
1285
|
+
dryRun: !options.dryRun,
|
|
1286
|
+
csv: options.csv !== false,
|
|
1287
|
+
json: !options.json,
|
|
1288
|
+
},
|
|
1289
|
+
}, {
|
|
1290
|
+
promptAdapter,
|
|
1291
|
+
input: stdin,
|
|
1292
|
+
output: stderr,
|
|
1293
|
+
isInteractiveTerminal,
|
|
1294
|
+
onSavedProfilePath(path) {
|
|
1295
|
+
savedProfilePath = path;
|
|
1296
|
+
},
|
|
1297
|
+
})
|
|
1298
|
+
: options;
|
|
1299
|
+
if (resolvedOptions.savedProfilePath) {
|
|
1300
|
+
savedProfilePath = resolvedOptions.savedProfilePath;
|
|
1301
|
+
}
|
|
1302
|
+
const runOptions = {
|
|
1303
|
+
onProgress: message => writeProgress(message, stderr),
|
|
1304
|
+
};
|
|
1305
|
+
if (provider !== undefined) runOptions.provider = provider;
|
|
1306
|
+
if (now !== undefined) runOptions.now = now;
|
|
1307
|
+
|
|
1308
|
+
const result = await runAnalyzeGithub(resolvedOptions, runOptions);
|
|
1309
|
+
writeAnalyzeGithubCompletion(result, { json: resolvedOptions.json, stdout });
|
|
1310
|
+
return result;
|
|
1311
|
+
} catch (error) {
|
|
1312
|
+
if (savedProfilePath) {
|
|
1313
|
+
stderr.write(`Repository profile saved before failure: ${savedProfilePath}.\n`);
|
|
1314
|
+
}
|
|
1315
|
+
throw error;
|
|
1316
|
+
}
|
|
880
1317
|
}
|
|
881
1318
|
|
|
882
1319
|
async function main(argv) {
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONFIGURED_WORKFLOW_NOTE,
|
|
3
|
+
configuredWorkflowEntries,
|
|
4
|
+
hasConfiguredWorkflowContext,
|
|
5
|
+
} from "./friction-report.js";
|
|
6
|
+
|
|
1
7
|
const BOT_OR_SCANNER_SOURCES = new Set([
|
|
2
8
|
"copilot",
|
|
3
9
|
"github_actions_bot",
|
|
@@ -336,6 +342,21 @@ function formatSensitivitySummaries(report) {
|
|
|
336
342
|
}).join("\n");
|
|
337
343
|
}
|
|
338
344
|
|
|
345
|
+
function formatConfiguredWorkflowContext(report) {
|
|
346
|
+
const configuredWorkflow = report.configuredWorkflow;
|
|
347
|
+
if (!hasConfiguredWorkflowContext(configuredWorkflow)) return [];
|
|
348
|
+
|
|
349
|
+
return [
|
|
350
|
+
"## Configured Workflow Context",
|
|
351
|
+
"",
|
|
352
|
+
configuredWorkflow.note ?? CONFIGURED_WORKFLOW_NOTE,
|
|
353
|
+
"",
|
|
354
|
+
...configuredWorkflowEntries(configuredWorkflow)
|
|
355
|
+
.map(entry => `- ${entry.label}: ${entry.valueLabel}`),
|
|
356
|
+
"",
|
|
357
|
+
];
|
|
358
|
+
}
|
|
359
|
+
|
|
339
360
|
export function renderRepositoryFrictionMethodology({
|
|
340
361
|
report,
|
|
341
362
|
sourceBundle,
|
|
@@ -373,6 +394,7 @@ export function renderRepositoryFrictionMethodology({
|
|
|
373
394
|
"",
|
|
374
395
|
"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
396
|
"",
|
|
397
|
+
...formatConfiguredWorkflowContext(report),
|
|
376
398
|
"## Scores And Rankings",
|
|
377
399
|
"",
|
|
378
400
|
"The report ranks bottlenecks by transparent component metrics from `friction-metrics.v1`: review churn, change scope (the internal changed-file-spread signal: core files touched plus directories touched plus functional surfaces touched), 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.",
|
|
@@ -61,6 +61,28 @@ const RANKING_SIGNAL_LABELS = new Map([
|
|
|
61
61
|
["fixAmplification", "fix amplification"],
|
|
62
62
|
]);
|
|
63
63
|
|
|
64
|
+
const WORKFLOW_CONTEXT_FIELDS = [
|
|
65
|
+
["primaryMergeMethod", "Primary merge method"],
|
|
66
|
+
["releaseStrategy", "Release strategy"],
|
|
67
|
+
["branchStrategy", "Branch strategy"],
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const WORKFLOW_CONTEXT_VALUE_LABELS = new Map([
|
|
71
|
+
["merge_commit", "Merge commit"],
|
|
72
|
+
["squash_merge", "Squash merge"],
|
|
73
|
+
["rebase_merge", "Rebase merge"],
|
|
74
|
+
["release_prs", "Release PRs"],
|
|
75
|
+
["direct_tags", "Direct tags"],
|
|
76
|
+
["release_branches", "Release branches"],
|
|
77
|
+
["trunk_based", "Trunk-based"],
|
|
78
|
+
["main_plus_release_branches", "Main plus release branches"],
|
|
79
|
+
["long_lived_development_branches", "Long-lived development branches"],
|
|
80
|
+
["mixed", "Mixed"],
|
|
81
|
+
["unknown", "Unknown"],
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
export const CONFIGURED_WORKFLOW_NOTE = "Configured workflow context comes from the repository profile. It is user-configured context, not observed GitHub evidence, and it does not change scores, rankings, CSV exports, or PR class matching.";
|
|
85
|
+
|
|
64
86
|
const BOTTLENECK_DEFINITIONS = [
|
|
65
87
|
{
|
|
66
88
|
id: "review-churn",
|
|
@@ -563,6 +585,42 @@ function summarizeRecommendationCategories(bottlenecks) {
|
|
|
563
585
|
}));
|
|
564
586
|
}
|
|
565
587
|
|
|
588
|
+
export function normalizeConfiguredWorkflowContext(workflowContext) {
|
|
589
|
+
if (!workflowContext || typeof workflowContext !== "object" || Array.isArray(workflowContext)) {
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const configuredWorkflow = {
|
|
594
|
+
source: "repository_profile",
|
|
595
|
+
note: CONFIGURED_WORKFLOW_NOTE,
|
|
596
|
+
};
|
|
597
|
+
for (const [field] of WORKFLOW_CONTEXT_FIELDS) {
|
|
598
|
+
if (workflowContext[field] !== undefined) {
|
|
599
|
+
configuredWorkflow[field] = workflowContext[field];
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return WORKFLOW_CONTEXT_FIELDS.some(([field]) => configuredWorkflow[field] !== undefined)
|
|
604
|
+
? configuredWorkflow
|
|
605
|
+
: null;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export function configuredWorkflowEntries(configuredWorkflow) {
|
|
609
|
+
if (!configuredWorkflow || typeof configuredWorkflow !== "object") return [];
|
|
610
|
+
return WORKFLOW_CONTEXT_FIELDS
|
|
611
|
+
.filter(([field]) => configuredWorkflow[field] !== undefined)
|
|
612
|
+
.map(([field, label]) => ({
|
|
613
|
+
field,
|
|
614
|
+
label,
|
|
615
|
+
value: configuredWorkflow[field],
|
|
616
|
+
valueLabel: WORKFLOW_CONTEXT_VALUE_LABELS.get(configuredWorkflow[field]) ?? configuredWorkflow[field],
|
|
617
|
+
}));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export function hasConfiguredWorkflowContext(configuredWorkflow) {
|
|
621
|
+
return configuredWorkflowEntries(configuredWorkflow).length > 0;
|
|
622
|
+
}
|
|
623
|
+
|
|
566
624
|
function evidenceSignature(bottleneck) {
|
|
567
625
|
return (bottleneck.observedData ?? [])
|
|
568
626
|
.map(evidence => evidence.number)
|
|
@@ -712,16 +770,19 @@ function summarizeSensitivity(metricsSummary, baselineBottlenecks) {
|
|
|
712
770
|
};
|
|
713
771
|
}
|
|
714
772
|
|
|
715
|
-
export function generateRepositoryFrictionReport(metricsSummary) {
|
|
773
|
+
export function generateRepositoryFrictionReport(metricsSummary, options = {}) {
|
|
774
|
+
const { workflowContext } = options ?? {};
|
|
716
775
|
const prClasses = summarizePrClasses(metricsSummary);
|
|
717
776
|
const bottlenecksWithSharedSignalKeys = summarizeBottlenecks(metricsSummary, prClasses);
|
|
718
777
|
const sharedSignals = summarizeSharedSignals(bottlenecksWithSharedSignalKeys);
|
|
719
778
|
const bottlenecks = bottlenecksWithSharedSignalKeys.map(({ rankingKey, ...bottleneck }) => bottleneck);
|
|
779
|
+
const configuredWorkflow = normalizeConfiguredWorkflowContext(workflowContext);
|
|
720
780
|
return {
|
|
721
781
|
reportVersion: FRICTION_REPORT_VERSION,
|
|
722
782
|
metricVersion: metricsSummary.metricVersion,
|
|
723
783
|
targetRepository: metricsSummary.targetRepository,
|
|
724
784
|
...(metricsSummary.analysisFilter ? { analysisFilter: metricsSummary.analysisFilter } : {}),
|
|
785
|
+
...(configuredWorkflow ? { configuredWorkflow } : {}),
|
|
725
786
|
summary: {
|
|
726
787
|
pullRequests: metricsSummary.totals?.pullRequests ?? 0,
|
|
727
788
|
changedLines: metricsSummary.totals?.changedLines ?? 0,
|
|
@@ -1152,6 +1213,23 @@ function renderPrClassContext(prClasses) {
|
|
|
1152
1213
|
].join("\n");
|
|
1153
1214
|
}
|
|
1154
1215
|
|
|
1216
|
+
function renderConfiguredWorkflowContext(configuredWorkflow) {
|
|
1217
|
+
const entries = configuredWorkflowEntries(configuredWorkflow);
|
|
1218
|
+
if (!entries.length) return "";
|
|
1219
|
+
|
|
1220
|
+
return [
|
|
1221
|
+
"## Configured Workflow Context",
|
|
1222
|
+
"",
|
|
1223
|
+
configuredWorkflow.note ?? CONFIGURED_WORKFLOW_NOTE,
|
|
1224
|
+
"",
|
|
1225
|
+
renderMarkdownTable(
|
|
1226
|
+
["Field", "Configured value"],
|
|
1227
|
+
entries.map(entry => [entry.label, entry.valueLabel]),
|
|
1228
|
+
),
|
|
1229
|
+
"",
|
|
1230
|
+
].join("\n");
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1155
1233
|
function classDominanceCaveat(bottleneck) {
|
|
1156
1234
|
return bottleneck.classDominance?.status === "single_class_dominates"
|
|
1157
1235
|
? bottleneck.classDominance.note
|
|
@@ -1299,7 +1377,13 @@ export function renderRepositoryFrictionMarkdown(report) {
|
|
|
1299
1377
|
"- Interpretation is the analyzer's explanation of what the observed evidence suggests.",
|
|
1300
1378
|
"- Recommendation is a workflow intervention to consider; the report does not modify repositories.",
|
|
1301
1379
|
"- Confidence and caveats call out outliers, missing coverage, and evidence-quality limits before you act.",
|
|
1380
|
+
...(hasConfiguredWorkflowContext(report.configuredWorkflow)
|
|
1381
|
+
? ["- Configured workflow context, when shown, comes from the repository profile and is not observed GitHub evidence."]
|
|
1382
|
+
: []),
|
|
1302
1383
|
"",
|
|
1384
|
+
...(hasConfiguredWorkflowContext(report.configuredWorkflow)
|
|
1385
|
+
? [renderConfiguredWorkflowContext(report.configuredWorkflow)]
|
|
1386
|
+
: []),
|
|
1303
1387
|
"## Evidence Quality And Coverage",
|
|
1304
1388
|
"",
|
|
1305
1389
|
renderCoverageSummary(report.coverage),
|
|
@@ -1370,6 +1454,9 @@ export function renderRepositoryFrictionMarkdown(report) {
|
|
|
1370
1454
|
"- Bottlenecks are ranked by their strongest representative observed signal, with stable category order only used to break ties.",
|
|
1371
1455
|
"- Recommendations are inferred from transparent component evidence and representative PR examples; they are not automated changes.",
|
|
1372
1456
|
"- Missing or partial GitHub data remains visible in coverage tables rather than being inferred from unrelated fields.",
|
|
1457
|
+
...(hasConfiguredWorkflowContext(report.configuredWorkflow)
|
|
1458
|
+
? ["- Configured workflow context is user-configured repository-profile context; it does not change scoring, ranking, CSV exports, or PR class matching."]
|
|
1459
|
+
: []),
|
|
1373
1460
|
"- Sensitivity analysis, when present, excludes one dominant representative PR at a time to show robustness context without changing the baseline ranking.",
|
|
1374
1461
|
report.analysisFilter?.excludedPrClasses?.length
|
|
1375
1462
|
? "- PR class filtering was explicitly applied before metrics and ranking; PR class context still supports interpretation of the filtered sample."
|