delivery-friction-analyzer 0.3.0 → 0.5.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 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
 
@@ -53,3 +53,33 @@ If both matchers are present on one rule, both must match. If no rule matches, t
53
53
  ```
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
+
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
+
59
+ ## Workflow Context
60
+
61
+ `workflow` is optional user-configured context. It records repository workflow assumptions that later setup and report milestones can rely on, but the analyzer does not infer these values from GitHub and does not change scoring, rankings, collection, PR class matching, or report wording.
62
+
63
+ When provided, `workflow` must include at least one supported field.
64
+
65
+ Supported fields:
66
+
67
+ - `primaryMergeMethod`: `merge_commit`, `squash_merge`, `rebase_merge`, `mixed`, or `unknown`.
68
+ - `releaseStrategy`: `release_prs`, `direct_tags`, `release_branches`, `mixed`, or `unknown`.
69
+ - `branchStrategy`: `trunk_based`, `main_plus_release_branches`, `long_lived_development_branches`, `mixed`, or `unknown`.
70
+
71
+ Example:
72
+
73
+ ```json
74
+ {
75
+ "workflow": {
76
+ "primaryMergeMethod": "squash_merge",
77
+ "releaseStrategy": "release_prs",
78
+ "branchStrategy": "main_plus_release_branches"
79
+ }
80
+ }
81
+ ```
82
+
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "delivery-friction-analyzer",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Local GitHub pull request analytics for delivery friction reports.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/release-log.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ### 2026-06-18 — Workflow Profile Wizard
6
+
7
+ - What changed: Interactive setup can now create repository profiles or generated profile copies with confirmed workflow context and release PR title rules.
8
+ - Why it matters: Maintainers can capture reusable workflow assumptions during guided setup without hand-editing profile JSON or risking silent rewrites of existing profiles.
9
+ - Who is affected: Maintainers running `--interactive` for first-time repository setup or profile updates.
10
+ - Action needed: Review the generated or updated profile path printed in completion output before reusing it in automation.
11
+ - PR: https://github.com/hannasdev/delivery-friction-analyzer/pull/36
12
+
13
+ ### 2026-06-17 — Workflow Profile Contract
14
+
15
+ - What changed: Repository profiles can now declare optional workflow context for merge method, release strategy, and branch strategy using validated stable identifiers.
16
+ - Why it matters: Future interactive setup and report milestones can rely on a documented profile contract without inferring workflow assumptions from GitHub history or changing scoring.
17
+ - Who is affected: Maintainers authoring or validating repository profiles.
18
+ - Action needed: Add `workflow` context only when you want to record repository workflow assumptions; existing profiles remain valid without it.
19
+ - PR: https://github.com/hannasdev/delivery-friction-analyzer/pull/35
20
+
5
21
  ### 2026-06-17 — Opt-In Interactive CLI Setup
6
22
 
7
23
  - What changed: GitHub analysis now supports `--interactive` to prompt for existing run options such as repository, PR limit, profile path, output directory, dry-run mode, CSV exports, JSON completion output, and configured PR class exclusions.
@@ -87,6 +87,22 @@
87
87
  "notes": { "type": "string" }
88
88
  }
89
89
  }
90
+ },
91
+ "workflow": {
92
+ "type": "object",
93
+ "additionalProperties": false,
94
+ "properties": {
95
+ "primaryMergeMethod": {
96
+ "enum": ["merge_commit", "squash_merge", "rebase_merge", "mixed", "unknown"]
97
+ },
98
+ "releaseStrategy": {
99
+ "enum": ["release_prs", "direct_tags", "release_branches", "mixed", "unknown"]
100
+ },
101
+ "branchStrategy": {
102
+ "enum": ["trunk_based", "main_plus_release_branches", "long_lived_development_branches", "mixed", "unknown"]
103
+ }
104
+ },
105
+ "minProperties": 1
90
106
  }
91
107
  }
92
108
  }
@@ -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,6 +17,12 @@ import {
17
17
  renderRepositoryFrictionMarkdown,
18
18
  } from "../report/friction-report.js";
19
19
  import { assertValidPrClassRules } from "../profile/pr-class.js";
20
+ import {
21
+ WORKFLOW_BRANCH_STRATEGIES,
22
+ WORKFLOW_PRIMARY_MERGE_METHODS,
23
+ WORKFLOW_RELEASE_STRATEGIES,
24
+ assertValidWorkflowContext,
25
+ } from "../profile/workflow.js";
20
26
 
21
27
  const ALLOWED_OPTIONS = new Set([
22
28
  "repo",
@@ -182,24 +188,6 @@ function validateExcludedPrClassesAreConfigured(excludedPrClasses = [], reposito
182
188
  throw new Error(`exclude-pr-class must name configured PR class(es): ${unconfigured.join(", ")}.${available}`);
183
189
  }
184
190
 
185
- async function readProfile(profilePath) {
186
- let profile;
187
- try {
188
- profile = JSON.parse(await readFile(profilePath, "utf8"));
189
- } catch (error) {
190
- if (error instanceof SyntaxError) {
191
- throw new Error(`profile must be valid JSON: ${error.message}`);
192
- }
193
- throw new Error(`profile could not be read: ${error.message}`);
194
- }
195
- try {
196
- assertValidPrClassRules(profile);
197
- } catch (error) {
198
- throw new Error(`profile is invalid: ${error.message}`);
199
- }
200
- return profile;
201
- }
202
-
203
191
  function configuredPrClassList(repositoryProfile) {
204
192
  return [...configuredPrClasses(repositoryProfile)].sort();
205
193
  }
@@ -221,6 +209,9 @@ function formatInteractivePrompt(prompt) {
221
209
  if (prompt.type === "multi-select" && prompt.choices?.length) {
222
210
  return `${prompt.message} (${prompt.choices.join(",")})${suffix}: `;
223
211
  }
212
+ if (prompt.type === "select" && prompt.choices?.length) {
213
+ return `${prompt.message} (${prompt.choices.join(",")})${suffix}: `;
214
+ }
224
215
  return `${prompt.message}${suffix}: `;
225
216
  }
226
217
 
@@ -284,22 +275,418 @@ function normalizeConfirmAnswer(raw, prompt) {
284
275
  throw new Error("Answer yes or no.");
285
276
  }
286
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
+
287
284
  function normalizeMultiSelectAnswer(raw, prompt) {
288
285
  const value = String(raw ?? "").trim();
289
286
  if (!value) return prompt.defaultValue ?? [];
290
287
  return normalizeExcludedPrClasses([value]);
291
288
  }
292
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
+
293
669
  async function promptProfilePath(promptAdapter, output, prompt) {
294
- let profile = null;
670
+ let profileState = null;
295
671
  const profilePath = await askUntilValid(promptAdapter, prompt, {
296
672
  output,
297
673
  normalize: normalizeTextAnswer,
298
674
  async validate(value) {
299
- profile = await readProfile(value);
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
+ }
300
687
  },
301
688
  });
302
- return { profilePath, profile };
689
+ return { profilePath, ...profileState };
303
690
  }
304
691
 
305
692
  function hasOwnOption(options, key) {
@@ -319,6 +706,7 @@ export async function collectInteractiveAnalyzeGithubOptions(options, {
319
706
  input = process.stdin,
320
707
  output = process.stderr,
321
708
  isInteractiveTerminal = Boolean(input?.isTTY),
709
+ onSavedProfilePath = null,
322
710
  } = {}) {
323
711
  if (!isInteractiveTerminal) {
324
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.");
@@ -329,6 +717,7 @@ export async function collectInteractiveAnalyzeGithubOptions(options, {
329
717
  const resolved = { ...options };
330
718
  delete resolved.interactivePromptDefaults;
331
719
  let repositoryProfile = null;
720
+ let profileState = null;
332
721
 
333
722
  try {
334
723
  if (!resolved.repository) {
@@ -362,11 +751,34 @@ export async function collectInteractiveAnalyzeGithubOptions(options, {
362
751
  type: "path",
363
752
  message: "Repository profile path",
364
753
  });
365
- resolved.profilePath = prompted.profilePath;
366
- repositoryProfile = prompted.profile;
754
+ profileState = prompted;
367
755
  } else {
368
- repositoryProfile = await readProfile(resolved.profilePath);
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
+ }
369
780
  }
781
+ repositoryProfile = profileUpdate.profile;
370
782
 
371
783
  if (!resolved.outDir) {
372
784
  resolved.outDir = await askUntilValid(adapter, {
@@ -610,8 +1022,8 @@ function attachCollectionCoverage(report, sourceBundle) {
610
1022
  };
611
1023
  }
612
1024
 
613
- function summarizeResult({ dryRun, outDir, paths, sourceBundle, metrics, report, requestedLimit, sampledLimit, csv, analysisFilter }) {
614
- return {
1025
+ function summarizeResult({ dryRun, outDir, paths, sourceBundle, metrics, report, requestedLimit, sampledLimit, csv, analysisFilter, savedProfilePath }) {
1026
+ const summary = {
615
1027
  ok: true,
616
1028
  dryRun,
617
1029
  csvArtifactsEnabled: Boolean(csv),
@@ -626,6 +1038,10 @@ function summarizeResult({ dryRun, outDir, paths, sourceBundle, metrics, report,
626
1038
  totals: metrics?.totals ?? null,
627
1039
  topBottleneckIds: report?.summary?.topBottleneckIds ?? null,
628
1040
  };
1041
+ if (savedProfilePath) {
1042
+ summary.savedProfilePath = savedProfilePath;
1043
+ }
1044
+ return summary;
629
1045
  }
630
1046
 
631
1047
  function applyPrClassFilter(normalized, excludedPrClasses = []) {
@@ -697,6 +1113,7 @@ export async function runAnalyzeGithub(options, {
697
1113
  sampledLimit: collectionLimit,
698
1114
  csv: false,
699
1115
  analysisFilter: null,
1116
+ savedProfilePath: options.savedProfilePath,
700
1117
  });
701
1118
  }
702
1119
 
@@ -745,6 +1162,7 @@ export async function runAnalyzeGithub(options, {
745
1162
  sampledLimit: collectionLimit,
746
1163
  csv: csvEnabled,
747
1164
  analysisFilter: normalized.analysisFilter ?? null,
1165
+ savedProfilePath: options.savedProfilePath,
748
1166
  });
749
1167
  }
750
1168
 
@@ -814,6 +1232,10 @@ export function formatAnalyzeGithubCompletion(result) {
814
1232
  );
815
1233
  }
816
1234
 
1235
+ if (result.savedProfilePath) {
1236
+ lines.push(`Repository profile saved: ${result.savedProfilePath}.`);
1237
+ }
1238
+
817
1239
  lines.push(`Collection coverage: ${result.collectionCoverage?.status ?? "unknown"}.`);
818
1240
 
819
1241
  const caveats = coverageCaveats(result.collectionCoverage);
@@ -845,36 +1267,50 @@ export async function runAnalyzeGithubCli(argv, {
845
1267
  promptAdapter = null,
846
1268
  isInteractiveTerminal = Boolean(stdin?.isTTY),
847
1269
  } = {}) {
848
- const options = parseAnalyzeGithubArgs(argv);
849
- if (options.help) {
850
- stdout.write(USAGE);
851
- return null;
852
- }
853
-
854
- const resolvedOptions = options.interactive
855
- ? await collectInteractiveAnalyzeGithubOptions({
856
- ...options,
857
- interactivePromptDefaults: {
858
- dryRun: !options.dryRun,
859
- csv: options.csv !== false,
860
- json: !options.json,
861
- },
862
- }, {
863
- promptAdapter,
864
- input: stdin,
865
- output: stderr,
866
- isInteractiveTerminal,
867
- })
868
- : options;
869
- const runOptions = {
870
- onProgress: message => writeProgress(message, stderr),
871
- };
872
- if (provider !== undefined) runOptions.provider = provider;
873
- if (now !== undefined) runOptions.now = now;
1270
+ let savedProfilePath = null;
1271
+ try {
1272
+ const options = parseAnalyzeGithubArgs(argv);
1273
+ if (options.help) {
1274
+ stdout.write(USAGE);
1275
+ return null;
1276
+ }
874
1277
 
875
- const result = await runAnalyzeGithub(resolvedOptions, runOptions);
876
- writeAnalyzeGithubCompletion(result, { json: resolvedOptions.json, stdout });
877
- return result;
1278
+ const resolvedOptions = options.interactive
1279
+ ? await collectInteractiveAnalyzeGithubOptions({
1280
+ ...options,
1281
+ interactivePromptDefaults: {
1282
+ dryRun: !options.dryRun,
1283
+ csv: options.csv !== false,
1284
+ json: !options.json,
1285
+ },
1286
+ }, {
1287
+ promptAdapter,
1288
+ input: stdin,
1289
+ output: stderr,
1290
+ isInteractiveTerminal,
1291
+ onSavedProfilePath(path) {
1292
+ savedProfilePath = path;
1293
+ },
1294
+ })
1295
+ : options;
1296
+ if (resolvedOptions.savedProfilePath) {
1297
+ savedProfilePath = resolvedOptions.savedProfilePath;
1298
+ }
1299
+ const runOptions = {
1300
+ onProgress: message => writeProgress(message, stderr),
1301
+ };
1302
+ if (provider !== undefined) runOptions.provider = provider;
1303
+ if (now !== undefined) runOptions.now = now;
1304
+
1305
+ const result = await runAnalyzeGithub(resolvedOptions, runOptions);
1306
+ writeAnalyzeGithubCompletion(result, { json: resolvedOptions.json, stdout });
1307
+ return result;
1308
+ } catch (error) {
1309
+ if (savedProfilePath) {
1310
+ stderr.write(`Repository profile saved before failure: ${savedProfilePath}.\n`);
1311
+ }
1312
+ throw error;
1313
+ }
878
1314
  }
879
1315
 
880
1316
  async function main(argv) {
@@ -1,6 +1,7 @@
1
1
  import { classifyCommentSource, groupByCommentSource } from "../github/comment-source.js";
2
2
  import { classifyFilePath } from "../profile/file-role.js";
3
3
  import { assertValidPrClassRules, classifyPullRequest } from "../profile/pr-class.js";
4
+ import { assertValidWorkflowContext } from "../profile/workflow.js";
4
5
 
5
6
  function minDate(values) {
6
7
  return values.filter(Boolean).sort()[0] ?? null;
@@ -112,6 +113,7 @@ function normalizeCommit(commit) {
112
113
  export function normalizeFixtureBundle(bundle, { repositoryProfile } = {}) {
113
114
  const profile = repositoryProfile ?? {};
114
115
  assertValidPrClassRules(profile);
116
+ assertValidWorkflowContext(profile);
115
117
 
116
118
  const pullRequests = (bundle.pullRequests ?? []).map(pr => {
117
119
  const reviewDates = (pr.reviews ?? []).map(review => review.submittedAt);
@@ -0,0 +1,75 @@
1
+ export const WORKFLOW_PRIMARY_MERGE_METHODS = Object.freeze([
2
+ "merge_commit",
3
+ "squash_merge",
4
+ "rebase_merge",
5
+ "mixed",
6
+ "unknown",
7
+ ]);
8
+
9
+ export const WORKFLOW_RELEASE_STRATEGIES = Object.freeze([
10
+ "release_prs",
11
+ "direct_tags",
12
+ "release_branches",
13
+ "mixed",
14
+ "unknown",
15
+ ]);
16
+
17
+ export const WORKFLOW_BRANCH_STRATEGIES = Object.freeze([
18
+ "trunk_based",
19
+ "main_plus_release_branches",
20
+ "long_lived_development_branches",
21
+ "mixed",
22
+ "unknown",
23
+ ]);
24
+
25
+ const WORKFLOW_FIELDS = Object.freeze({
26
+ primaryMergeMethod: WORKFLOW_PRIMARY_MERGE_METHODS,
27
+ releaseStrategy: WORKFLOW_RELEASE_STRATEGIES,
28
+ branchStrategy: WORKFLOW_BRANCH_STRATEGIES,
29
+ });
30
+
31
+ function allowedValues(values) {
32
+ return values.join(", ");
33
+ }
34
+
35
+ export function validateWorkflowContext(profile = {}) {
36
+ const errors = [];
37
+ if (!profile || typeof profile !== "object" || Array.isArray(profile)) {
38
+ return errors;
39
+ }
40
+
41
+ if (!Object.prototype.hasOwnProperty.call(profile, "workflow")) {
42
+ return errors;
43
+ }
44
+
45
+ const workflow = profile.workflow;
46
+ if (!workflow || typeof workflow !== "object" || Array.isArray(workflow)) {
47
+ return ["workflow must be an object when provided"];
48
+ }
49
+ if (Object.keys(workflow).length === 0) {
50
+ return ["workflow must include at least one field when provided"];
51
+ }
52
+
53
+ for (const key of Object.keys(workflow)) {
54
+ if (!Object.prototype.hasOwnProperty.call(WORKFLOW_FIELDS, key)) {
55
+ errors.push(`workflow.${key} is not supported`);
56
+ }
57
+ }
58
+
59
+ for (const [field, values] of Object.entries(WORKFLOW_FIELDS)) {
60
+ const value = workflow[field];
61
+ if (value === undefined) continue;
62
+ if (!values.includes(value)) {
63
+ errors.push(`workflow.${field} must be one of: ${allowedValues(values)}`);
64
+ }
65
+ }
66
+
67
+ return errors;
68
+ }
69
+
70
+ export function assertValidWorkflowContext(profile = {}) {
71
+ const errors = validateWorkflowContext(profile);
72
+ if (errors.length > 0) {
73
+ throw new Error(`invalid workflow profile context: ${errors.join("; ")}`);
74
+ }
75
+ }