delivery-friction-analyzer 0.2.3 → 0.3.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
@@ -44,6 +44,14 @@ npx delivery-friction-analyzer \
44
44
 
45
45
  Open `reports/mcp-writing/friction-report.md` first. It is the main human-readable report. Use the JSON and CSV files when you want to audit a finding, compare PRs, or build follow-up analysis.
46
46
 
47
+ For a guided first run in a local terminal, use the opt-in interactive flow:
48
+
49
+ ```sh
50
+ npm run analyze:github -- --interactive
51
+ ```
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.
54
+
47
55
  ## Repository Profiles
48
56
 
49
57
  Every run needs a repository profile. Profiles keep repository-specific assumptions out of the analyzer code by describing how paths and pull request titles should be classified.
@@ -84,6 +92,8 @@ Use `--exclude-pr-class <class>` to remove a configured PR class from downstream
84
92
 
85
93
  Use `--json` when automation needs the full machine-readable completion receipt on stdout.
86
94
 
95
+ Use `--interactive` only in a terminal when you want prompts. When combined with `--json`, prompts and progress stay off stdout so the final completion receipt remains parseable JSON.
96
+
87
97
  ## How To Read A Report
88
98
 
89
99
  Start with `friction-report.md`. If a bottleneck looks surprising, inspect `methodology.md`, the CSV exports, `friction-report.json`, and `source-bundle.json`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "delivery-friction-analyzer",
3
- "version": "0.2.3",
3
+ "version": "0.3.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,14 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ### 2026-06-17 — Opt-In Interactive CLI Setup
6
+
7
+ - 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.
8
+ - Why it matters: First-time maintainers can complete a guided local analysis without memorizing every required flag, while scripts and CI keep deterministic flag-based behavior.
9
+ - Who is affected: Maintainers and contributors running local GitHub analysis from a terminal.
10
+ - Action needed: Use `--interactive` for guided local setup; keep explicit flags for automation.
11
+ - PR: https://github.com/hannasdev/delivery-friction-analyzer/pull/34
12
+
5
13
  ### 2026-06-15 — Review Decision Author Detection
6
14
 
7
15
  - What changed: Review decision evidence now recognizes human approvals from live `gh pr view` review events that include only an author login.
@@ -2,6 +2,7 @@
2
2
  import { constants, realpathSync } from "node:fs";
3
3
  import { access, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { dirname, join, resolve } from "node:path";
5
+ import { createInterface } from "node:readline/promises";
5
6
  import { fileURLToPath, pathToFileURL } from "node:url";
6
7
  import { collectGitHubSourceBundle } from "../collect/github-source-bundle.js";
7
8
  import { createGhCliProvider } from "../collect/gh-provider.js";
@@ -15,6 +16,7 @@ import {
15
16
  generateRepositoryFrictionReport,
16
17
  renderRepositoryFrictionMarkdown,
17
18
  } from "../report/friction-report.js";
19
+ import { assertValidPrClassRules } from "../profile/pr-class.js";
18
20
 
19
21
  const ALLOWED_OPTIONS = new Set([
20
22
  "repo",
@@ -27,6 +29,7 @@ const ALLOWED_OPTIONS = new Set([
27
29
  "no-csv",
28
30
  "exclude-pr-class",
29
31
  "json",
32
+ "interactive",
30
33
  ]);
31
34
 
32
35
  const REPOSITORY_SLUG = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/;
@@ -66,6 +69,7 @@ Options:
66
69
  --exclude-pr-class <cls> Exclude a PR class from normalized, metrics, report, methodology, and CSV artifacts. Repeat or comma-separate values.
67
70
  --no-csv Suppress curated CSV evidence exports.
68
71
  --json Print the machine-readable completion receipt to stdout.
72
+ --interactive Prompt for missing run options in a terminal.
69
73
  `;
70
74
 
71
75
  export function parseAnalyzeGithubArgs(argv) {
@@ -90,6 +94,7 @@ export function parseAnalyzeGithubArgs(argv) {
90
94
  || key === "validation-target"
91
95
  || key === "no-csv"
92
96
  || key === "json"
97
+ || key === "interactive"
93
98
  ) {
94
99
  options[key] = true;
95
100
  continue;
@@ -117,6 +122,7 @@ export function parseAnalyzeGithubArgs(argv) {
117
122
  excludedPrClasses: normalizeExcludedPrClasses(options["exclude-pr-class"] ?? []),
118
123
  csv: !options["no-csv"],
119
124
  json: Boolean(options.json),
125
+ interactive: Boolean(options.interactive),
120
126
  };
121
127
  }
122
128
 
@@ -159,7 +165,8 @@ function validateExcludedPrClasses(excludedPrClasses = []) {
159
165
  }
160
166
 
161
167
  function configuredPrClasses(repositoryProfile) {
162
- return new Set((repositoryProfile.prClasses ?? []).map(rule => rule.class));
168
+ const rules = Array.isArray(repositoryProfile?.prClasses) ? repositoryProfile.prClasses : [];
169
+ return new Set(rules.map(rule => rule.class));
163
170
  }
164
171
 
165
172
  function validateExcludedPrClassesAreConfigured(excludedPrClasses = [], repositoryProfile) {
@@ -176,14 +183,275 @@ function validateExcludedPrClassesAreConfigured(excludedPrClasses = [], reposito
176
183
  }
177
184
 
178
185
  async function readProfile(profilePath) {
186
+ let profile;
179
187
  try {
180
- return JSON.parse(await readFile(profilePath, "utf8"));
188
+ profile = JSON.parse(await readFile(profilePath, "utf8"));
181
189
  } catch (error) {
182
190
  if (error instanceof SyntaxError) {
183
191
  throw new Error(`profile must be valid JSON: ${error.message}`);
184
192
  }
185
193
  throw new Error(`profile could not be read: ${error.message}`);
186
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
+ function configuredPrClassList(repositoryProfile) {
204
+ return [...configuredPrClasses(repositoryProfile)].sort();
205
+ }
206
+
207
+ function defaultOutDirForRepository(repository) {
208
+ const [, name] = String(repository ?? "").split("/");
209
+ return join("reports", name || "analysis");
210
+ }
211
+
212
+ function formatInteractivePrompt(prompt) {
213
+ const suffix = prompt.defaultValue === undefined
214
+ ? ""
215
+ : Array.isArray(prompt.defaultValue)
216
+ ? (prompt.defaultValue.length ? ` [${prompt.defaultValue.join(",")}]` : "")
217
+ : ` [${prompt.defaultValue}]`;
218
+ if (prompt.type === "confirm") {
219
+ return `${prompt.message}${prompt.defaultValue ? " [Y/n]" : " [y/N]"} `;
220
+ }
221
+ if (prompt.type === "multi-select" && prompt.choices?.length) {
222
+ return `${prompt.message} (${prompt.choices.join(",")})${suffix}: `;
223
+ }
224
+ return `${prompt.message}${suffix}: `;
225
+ }
226
+
227
+ function createTerminalPromptAdapter({ input, output }) {
228
+ const readline = createInterface({ input, output, terminal: true });
229
+ return {
230
+ async ask(prompt) {
231
+ return readline.question(formatInteractivePrompt(prompt));
232
+ },
233
+ writeError(message) {
234
+ output.write(`${message}\n`);
235
+ },
236
+ close() {
237
+ readline.close();
238
+ },
239
+ };
240
+ }
241
+
242
+ async function callPromptAdapter(promptAdapter, prompt) {
243
+ if (typeof promptAdapter === "function") {
244
+ return promptAdapter(prompt);
245
+ }
246
+ return promptAdapter.ask(prompt);
247
+ }
248
+
249
+ async function askUntilValid(promptAdapter, prompt, { normalize, validate, output }) {
250
+ for (;;) {
251
+ const raw = await callPromptAdapter(promptAdapter, prompt);
252
+ try {
253
+ const value = normalize(raw, prompt);
254
+ await validate(value);
255
+ return value;
256
+ } catch (error) {
257
+ const message = error?.message ?? String(error);
258
+ if (typeof promptAdapter.writeError === "function") {
259
+ promptAdapter.writeError(message);
260
+ } else if (output?.write) {
261
+ output.write(`${message}\n`);
262
+ }
263
+ }
264
+ }
265
+ }
266
+
267
+ function normalizeTextAnswer(raw, prompt) {
268
+ const value = String(raw ?? "").trim();
269
+ if (value) return value;
270
+ if (prompt.defaultValue !== undefined) return prompt.defaultValue;
271
+ return value;
272
+ }
273
+
274
+ function normalizeIntegerAnswer(raw, prompt) {
275
+ const value = normalizeTextAnswer(raw, prompt);
276
+ return Number(value);
277
+ }
278
+
279
+ function normalizeConfirmAnswer(raw, prompt) {
280
+ const value = String(raw ?? "").trim().toLowerCase();
281
+ if (!value && prompt.defaultValue !== undefined) return Boolean(prompt.defaultValue);
282
+ if (["y", "yes", "true", "1"].includes(value)) return true;
283
+ if (["n", "no", "false", "0"].includes(value)) return false;
284
+ throw new Error("Answer yes or no.");
285
+ }
286
+
287
+ function normalizeMultiSelectAnswer(raw, prompt) {
288
+ const value = String(raw ?? "").trim();
289
+ if (!value) return prompt.defaultValue ?? [];
290
+ return normalizeExcludedPrClasses([value]);
291
+ }
292
+
293
+ async function promptProfilePath(promptAdapter, output, prompt) {
294
+ let profile = null;
295
+ const profilePath = await askUntilValid(promptAdapter, prompt, {
296
+ output,
297
+ normalize: normalizeTextAnswer,
298
+ async validate(value) {
299
+ profile = await readProfile(value);
300
+ },
301
+ });
302
+ return { profilePath, profile };
303
+ }
304
+
305
+ function hasOwnOption(options, key) {
306
+ return Object.prototype.hasOwnProperty.call(options, key);
307
+ }
308
+
309
+ function shouldPromptInteractiveOption(options, key) {
310
+ const promptDefaults = options.interactivePromptDefaults ?? {};
311
+ if (hasOwnOption(promptDefaults, key)) {
312
+ return Boolean(promptDefaults[key]);
313
+ }
314
+ return !hasOwnOption(options, key);
315
+ }
316
+
317
+ export async function collectInteractiveAnalyzeGithubOptions(options, {
318
+ promptAdapter = null,
319
+ input = process.stdin,
320
+ output = process.stderr,
321
+ isInteractiveTerminal = Boolean(input?.isTTY),
322
+ } = {}) {
323
+ if (!isInteractiveTerminal) {
324
+ 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.");
325
+ }
326
+
327
+ const adapter = promptAdapter ?? createTerminalPromptAdapter({ input, output });
328
+ const ownsAdapter = !promptAdapter;
329
+ const resolved = { ...options };
330
+ delete resolved.interactivePromptDefaults;
331
+ let repositoryProfile = null;
332
+
333
+ try {
334
+ if (!resolved.repository) {
335
+ resolved.repository = await askUntilValid(adapter, {
336
+ id: "repository",
337
+ type: "text",
338
+ message: "Target GitHub repository",
339
+ }, {
340
+ output,
341
+ normalize: normalizeTextAnswer,
342
+ validate: validateRepositorySlug,
343
+ });
344
+ }
345
+
346
+ if (resolved.limit === undefined) {
347
+ resolved.limit = await askUntilValid(adapter, {
348
+ id: "limit",
349
+ type: "integer",
350
+ message: "Latest merged pull request count",
351
+ defaultValue: 30,
352
+ }, {
353
+ output,
354
+ normalize: normalizeIntegerAnswer,
355
+ validate: validateLimit,
356
+ });
357
+ }
358
+
359
+ if (!resolved.profilePath) {
360
+ const prompted = await promptProfilePath(adapter, output, {
361
+ id: "profilePath",
362
+ type: "path",
363
+ message: "Repository profile path",
364
+ });
365
+ resolved.profilePath = prompted.profilePath;
366
+ repositoryProfile = prompted.profile;
367
+ } else {
368
+ repositoryProfile = await readProfile(resolved.profilePath);
369
+ }
370
+
371
+ if (!resolved.outDir) {
372
+ resolved.outDir = await askUntilValid(adapter, {
373
+ id: "outDir",
374
+ type: "path",
375
+ message: "Output directory",
376
+ defaultValue: defaultOutDirForRepository(resolved.repository),
377
+ }, {
378
+ output,
379
+ normalize: normalizeTextAnswer,
380
+ async validate(value) {
381
+ if (!value) throw new Error("Output directory is required.");
382
+ await validateOutputDirectory(value);
383
+ },
384
+ });
385
+ }
386
+
387
+ if (shouldPromptInteractiveOption(options, "dryRun")) {
388
+ resolved.dryRun = await askUntilValid(adapter, {
389
+ id: "dryRun",
390
+ type: "confirm",
391
+ message: "Run metadata-only dry run",
392
+ defaultValue: false,
393
+ }, {
394
+ output,
395
+ normalize: normalizeConfirmAnswer,
396
+ validate() {},
397
+ });
398
+ }
399
+ if (resolved.dryRun === undefined) resolved.dryRun = false;
400
+
401
+ if (!resolved.dryRun && shouldPromptInteractiveOption(options, "csv")) {
402
+ resolved.csv = await askUntilValid(adapter, {
403
+ id: "csv",
404
+ type: "confirm",
405
+ message: "Write CSV evidence files",
406
+ defaultValue: true,
407
+ }, {
408
+ output,
409
+ normalize: normalizeConfirmAnswer,
410
+ validate() {},
411
+ });
412
+ }
413
+ if (resolved.csv === undefined) resolved.csv = true;
414
+
415
+ if (shouldPromptInteractiveOption(options, "json")) {
416
+ resolved.json = await askUntilValid(adapter, {
417
+ id: "json",
418
+ type: "confirm",
419
+ message: "Print completion as JSON",
420
+ defaultValue: false,
421
+ }, {
422
+ output,
423
+ normalize: normalizeConfirmAnswer,
424
+ validate() {},
425
+ });
426
+ }
427
+ if (resolved.json === undefined) resolved.json = false;
428
+
429
+ if (!resolved.excludedPrClasses?.length) {
430
+ const availablePrClasses = configuredPrClassList(repositoryProfile);
431
+ if (availablePrClasses.length) {
432
+ resolved.excludedPrClasses = await askUntilValid(adapter, {
433
+ id: "excludedPrClasses",
434
+ type: "multi-select",
435
+ message: "Exclude PR classes (comma-separated, blank for none)",
436
+ choices: availablePrClasses,
437
+ defaultValue: [],
438
+ }, {
439
+ output,
440
+ normalize: normalizeMultiSelectAnswer,
441
+ validate(value) {
442
+ validateExcludedPrClasses(value);
443
+ validateExcludedPrClassesAreConfigured(value, repositoryProfile);
444
+ },
445
+ });
446
+ }
447
+ }
448
+
449
+ return resolved;
450
+ } finally {
451
+ if (ownsAdapter && typeof adapter.close === "function") {
452
+ adapter.close();
453
+ }
454
+ }
187
455
  }
188
456
 
189
457
  async function validateOutputDirectory(outDir) {
@@ -480,8 +748,8 @@ export async function runAnalyzeGithub(options, {
480
748
  });
481
749
  }
482
750
 
483
- function writeProgress(message) {
484
- process.stderr.write(`${message}\n`);
751
+ function writeProgress(message, stderr = process.stderr) {
752
+ stderr.write(`${message}\n`);
485
753
  }
486
754
 
487
755
  function coverageLine(family) {
@@ -568,15 +836,49 @@ export function writeAnalyzeGithubCompletion(result, { json = false, stdout = pr
568
836
  stdout.write(formatAnalyzeGithubCompletion(result));
569
837
  }
570
838
 
571
- async function main(argv) {
839
+ export async function runAnalyzeGithubCli(argv, {
840
+ provider,
841
+ now,
842
+ stdin = process.stdin,
843
+ stdout = process.stdout,
844
+ stderr = process.stderr,
845
+ promptAdapter = null,
846
+ isInteractiveTerminal = Boolean(stdin?.isTTY),
847
+ } = {}) {
572
848
  const options = parseAnalyzeGithubArgs(argv);
573
849
  if (options.help) {
574
- process.stdout.write(USAGE);
575
- return;
850
+ stdout.write(USAGE);
851
+ return null;
576
852
  }
577
853
 
578
- const result = await runAnalyzeGithub(options, { onProgress: writeProgress });
579
- writeAnalyzeGithubCompletion(result, { json: options.json });
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;
874
+
875
+ const result = await runAnalyzeGithub(resolvedOptions, runOptions);
876
+ writeAnalyzeGithubCompletion(result, { json: resolvedOptions.json, stdout });
877
+ return result;
878
+ }
879
+
880
+ async function main(argv) {
881
+ await runAnalyzeGithubCli(argv);
580
882
  }
581
883
 
582
884
  function isCliEntrypoint(entryPath) {