delivery-friction-analyzer 0.11.0 → 0.12.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.
@@ -4,6 +4,8 @@ Repository profiles map paths to file categories, file roles, and functional sur
4
4
 
5
5
  Schema: `schemas/repository-profile.schema.json`.
6
6
 
7
+ Repository profiles own repository semantics. Keep file rules, PR class rules, workflow context, branch or release strategy, and contributor-source declarations here. Optional [run presets](run-presets.md) only store reusable run settings such as the target repository, profile path, sample size, output directory, dry-run mode, CSV preference, JSON completion preference, validation-target mode, and requested PR class exclusions. Explicit CLI flags override preset values.
8
+
7
9
  ## Categories
8
10
 
9
11
  - `code`
@@ -0,0 +1,91 @@
1
+ # Run Presets
2
+
3
+ Run presets are optional local JSON files for reusing CLI run settings. They are intended for rerunning the same analysis without re-answering interactive prompts.
4
+
5
+ Repository meaning stays in repository profiles. Put file rules, PR class rules, workflow context, branch or release strategy, and contributor-source declarations in a repository profile. A run preset may only point at a profile and store run inputs or preferences such as the target repository, sample size, output directory, dry-run mode, CSV preference, JSON completion preference, validation-target mode, and requested PR class exclusions.
6
+
7
+ ## Save A Preset
8
+
9
+ Interactive setup asks whether to save a local run preset near the end of the prompt flow. If you answer yes, you choose the preset path explicitly. The CLI does not invent a global or cloud-synced preset location.
10
+
11
+ Saving a preset may overwrite an existing regular file at that path, but the path must not be a directory, symbolic link, or other special file.
12
+
13
+ You can also save a preset from flags:
14
+
15
+ ```sh
16
+ npm run analyze:github -- \
17
+ --repo example/example-repo \
18
+ --limit 30 \
19
+ --profile profiles/example-repo.json \
20
+ --out reports/example-repo \
21
+ --save-preset .delivery-friction-analyzer/example-repo.run-preset.json
22
+ ```
23
+
24
+ When a preset is written, the completion output includes:
25
+
26
+ ```text
27
+ Run preset saved: .delivery-friction-analyzer/example-repo.run-preset.json.
28
+ ```
29
+
30
+ With `--json`, the same path is emitted as `savedRunPresetPath` in the machine-readable completion receipt.
31
+
32
+ ## Rerun From A Preset
33
+
34
+ Use `--preset <path>` to load saved settings without prompts:
35
+
36
+ ```sh
37
+ npm run analyze:github -- --preset .delivery-friction-analyzer/example-repo.run-preset.json
38
+ ```
39
+
40
+ Explicit CLI flags override preset values. This makes one-off reruns predictable:
41
+
42
+ ```sh
43
+ npm run analyze:github -- \
44
+ --preset .delivery-friction-analyzer/example-repo.run-preset.json \
45
+ --limit 10 \
46
+ --no-csv
47
+ ```
48
+
49
+ In that command, the preset still supplies values such as `--repo`, `--profile`, and `--out`, while `--limit 10` and `--no-csv` win over the saved sample size and CSV preference.
50
+
51
+ Boolean preset values can be overridden in either direction:
52
+
53
+ - `--dry-run` or `--no-dry-run`
54
+ - `--validation-target` or `--no-validation-target`
55
+ - `--csv` or `--no-csv`
56
+ - `--json` or `--no-json`
57
+
58
+ If both forms are provided in one command, the later flag wins. For example, `--preset local.json --dry-run --no-dry-run` runs a full analysis, while `--preset local.json --no-csv --csv` writes CSV evidence files.
59
+
60
+ ## Format
61
+
62
+ Preset files use `analyze-github-run-preset.v1`:
63
+
64
+ ```json
65
+ {
66
+ "schemaVersion": "analyze-github-run-preset.v1",
67
+ "run": {
68
+ "repository": "example/example-repo",
69
+ "limit": 30,
70
+ "profilePath": "profiles/example-repo.json",
71
+ "outDir": "reports/example-repo",
72
+ "dryRun": false,
73
+ "isValidationTarget": false,
74
+ "csv": true,
75
+ "json": false,
76
+ "excludedPrClasses": []
77
+ }
78
+ }
79
+ ```
80
+
81
+ The CLI only reads and writes the allowlisted `run` keys shown above. Presets must not contain GitHub tokens, secrets, raw source bundles, normalized data, metrics, reports, methodology text, CSV contents, contributor file contents, or repository profile rules.
82
+
83
+ ## Cleanup
84
+
85
+ Preset files are local user-owned files. Delete a preset when it no longer matches how you want to run the analyzer:
86
+
87
+ ```sh
88
+ rm .delivery-friction-analyzer/example-repo.run-preset.json
89
+ ```
90
+
91
+ Deleting a preset does not delete generated reports or repository profiles. If a preset points at a generated profile, review and clean up that profile separately.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "delivery-friction-analyzer",
3
- "version": "0.11.0",
3
+ "version": "0.12.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-20 — Reusable Run Presets
6
+
7
+ - What changed: GitHub analysis can now load local run settings from `--preset` and save reusable settings with `--save-preset` or the interactive setup flow, with explicit CLI flags taking precedence.
8
+ - Why it matters: Maintainers can rerun an interactive setup non-interactively without moving repository semantics out of repository profiles.
9
+ - Who is affected: Maintainers using `--interactive` or repeated local analysis commands.
10
+ - Action needed: Optional; save a local preset for repeated runs and delete stale preset files when they no longer match the desired analysis settings.
11
+ - PR: #48
12
+
5
13
  ### 2026-06-20 — Contributor Source Configuration
6
14
 
7
15
  - What changed: Repository profiles can now configure `.all-contributorsrc` as a structured contributor source, and analysis records contributor-source coverage while using sanitized hints only for aggregate comment-source classification.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { constants, realpathSync } from "node:fs";
3
- import { access, lstat, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { access, lstat, mkdir, open, 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";
@@ -26,18 +26,71 @@ import {
26
26
  } from "../profile/workflow.js";
27
27
  import { assertValidContributorSource } from "../profile/contributor-source.js";
28
28
 
29
+ const RUN_PRESET_SCHEMA_VERSION = "analyze-github-run-preset.v1";
30
+
29
31
  const ALLOWED_OPTIONS = new Set([
30
32
  "repo",
31
33
  "limit",
32
34
  "profile",
33
35
  "out",
34
36
  "dry-run",
37
+ "no-dry-run",
35
38
  "metadata-only",
36
39
  "validation-target",
40
+ "no-validation-target",
41
+ "csv",
37
42
  "no-csv",
38
43
  "exclude-pr-class",
39
44
  "json",
45
+ "no-json",
40
46
  "interactive",
47
+ "preset",
48
+ "save-preset",
49
+ ]);
50
+
51
+ const BOOLEAN_OPTIONS = new Set([
52
+ "dry-run",
53
+ "no-dry-run",
54
+ "metadata-only",
55
+ "validation-target",
56
+ "no-validation-target",
57
+ "csv",
58
+ "no-csv",
59
+ "json",
60
+ "no-json",
61
+ "interactive",
62
+ ]);
63
+
64
+ const CLI_OPTION_KEYS = Object.freeze({
65
+ repo: "repository",
66
+ limit: "limit",
67
+ profile: "profilePath",
68
+ out: "outDir",
69
+ "dry-run": "dryRun",
70
+ "no-dry-run": "dryRun",
71
+ "metadata-only": "dryRun",
72
+ "validation-target": "isValidationTarget",
73
+ "no-validation-target": "isValidationTarget",
74
+ csv: "csv",
75
+ "no-csv": "csv",
76
+ "exclude-pr-class": "excludedPrClasses",
77
+ json: "json",
78
+ "no-json": "json",
79
+ interactive: "interactive",
80
+ preset: "presetPath",
81
+ "save-preset": "savePresetPath",
82
+ });
83
+
84
+ const RUN_PRESET_OPTION_KEYS = Object.freeze([
85
+ "repository",
86
+ "limit",
87
+ "profilePath",
88
+ "outDir",
89
+ "dryRun",
90
+ "isValidationTarget",
91
+ "csv",
92
+ "json",
93
+ "excludedPrClasses",
41
94
  ]);
42
95
 
43
96
  const REPOSITORY_SLUG = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/;
@@ -72,16 +125,58 @@ Options:
72
125
  --profile <path> Repository profile JSON used for file role classification.
73
126
  --out <directory> Output directory for generated artifacts.
74
127
  --dry-run Validate inputs and sample GitHub coverage without writing artifacts.
128
+ --no-dry-run Disable dry-run mode when a preset enabled it.
75
129
  --metadata-only Alias for --dry-run.
76
130
  --validation-target Mark the target repository as a validation target in output metadata.
131
+ --no-validation-target Disable validation-target mode when a preset enabled it.
77
132
  --exclude-pr-class <cls> Exclude a PR class from normalized, metrics, report, methodology, and CSV artifacts. Repeat or comma-separate values.
133
+ --csv Enable curated CSV evidence exports when a preset disabled them.
78
134
  --no-csv Suppress curated CSV evidence exports.
79
135
  --json Print the machine-readable completion receipt to stdout.
136
+ --no-json Disable JSON completion output when a preset enabled it.
80
137
  --interactive Prompt for missing run options in a terminal.
138
+ --preset <path> Load local run settings from a saved preset. Explicit CLI flags override preset values.
139
+ --save-preset <path> Save local run settings for non-interactive reruns.
81
140
  `;
82
141
 
142
+ function attachOptionSource(options, property, value) {
143
+ Object.defineProperty(options, property, {
144
+ value,
145
+ enumerable: false,
146
+ configurable: true,
147
+ });
148
+ return options;
149
+ }
150
+
151
+ function optionSourceSet(options, property) {
152
+ return options?.[property] instanceof Set ? options[property] : new Set();
153
+ }
154
+
155
+ function explicitCliOptionKeys(argv) {
156
+ const keys = new Set();
157
+ for (let index = 0; index < argv.length; index += 1) {
158
+ const arg = argv[index];
159
+ if (arg === "--help" || arg === "-h") continue;
160
+ if (!arg.startsWith("--")) continue;
161
+ const key = arg.slice(2);
162
+ const optionKey = CLI_OPTION_KEYS[key];
163
+ if (optionKey) keys.add(optionKey);
164
+ if (!BOOLEAN_OPTIONS.has(key)) {
165
+ index += 1;
166
+ }
167
+ }
168
+ return keys;
169
+ }
170
+
83
171
  export function parseAnalyzeGithubArgs(argv) {
84
- const options = {};
172
+ const options = {
173
+ dryRun: false,
174
+ isValidationTarget: false,
175
+ csv: true,
176
+ json: false,
177
+ interactive: false,
178
+ };
179
+ const explicitOptions = explicitCliOptionKeys(argv);
85
180
  for (let index = 0; index < argv.length; index += 1) {
86
181
  const arg = argv[index];
87
182
  if (arg === "--help" || arg === "-h") {
@@ -96,15 +191,16 @@ export function parseAnalyzeGithubArgs(argv) {
96
191
  throw new Error(`Unknown option: ${arg}`);
97
192
  }
98
193
 
99
- if (
100
- key === "dry-run"
101
- || key === "metadata-only"
102
- || key === "validation-target"
103
- || key === "no-csv"
104
- || key === "json"
105
- || key === "interactive"
106
- ) {
107
- options[key] = true;
194
+ if (BOOLEAN_OPTIONS.has(key)) {
195
+ if (key === "dry-run" || key === "metadata-only") options.dryRun = true;
196
+ if (key === "no-dry-run") options.dryRun = false;
197
+ if (key === "validation-target") options.isValidationTarget = true;
198
+ if (key === "no-validation-target") options.isValidationTarget = false;
199
+ if (key === "csv") options.csv = true;
200
+ if (key === "no-csv") options.csv = false;
201
+ if (key === "json") options.json = true;
202
+ if (key === "no-json") options.json = false;
203
+ if (key === "interactive") options.interactive = true;
108
204
  continue;
109
205
  }
110
206
 
@@ -113,25 +209,29 @@ export function parseAnalyzeGithubArgs(argv) {
113
209
  throw new Error(`Missing value for ${arg}`);
114
210
  }
115
211
  if (key === "exclude-pr-class") {
116
- options[key] = [...(options[key] ?? []), value];
212
+ options.excludedPrClasses = [...(options.excludedPrClasses ?? []), value];
117
213
  } else {
118
214
  options[key] = value;
119
215
  }
120
216
  index += 1;
121
217
  }
122
218
 
123
- return {
219
+ const parsed = {
124
220
  repository: options.repo,
125
221
  limit: options.limit === undefined ? undefined : Number(options.limit),
126
222
  profilePath: options.profile,
127
223
  outDir: options.out,
128
- dryRun: Boolean(options["dry-run"] || options["metadata-only"]),
129
- isValidationTarget: Boolean(options["validation-target"]),
130
- excludedPrClasses: normalizeExcludedPrClasses(options["exclude-pr-class"] ?? []),
131
- csv: !options["no-csv"],
132
- json: Boolean(options.json),
133
- interactive: Boolean(options.interactive),
224
+ dryRun: options.dryRun,
225
+ isValidationTarget: options.isValidationTarget,
226
+ excludedPrClasses: normalizeExcludedPrClasses(options.excludedPrClasses ?? []),
227
+ csv: options.csv,
228
+ json: options.json,
229
+ interactive: options.interactive,
134
230
  };
231
+ if (options.preset !== undefined) parsed.presetPath = options.preset;
232
+ if (options["save-preset"] !== undefined) parsed.savePresetPath = options["save-preset"];
233
+
234
+ return attachOptionSource(parsed, "explicitCliOptions", explicitOptions);
135
235
  }
136
236
 
137
237
  function normalizeExcludedPrClasses(values) {
@@ -141,6 +241,164 @@ function normalizeExcludedPrClasses(values) {
141
241
  .filter(Boolean))];
142
242
  }
143
243
 
244
+ function assertPlainObject(value, label) {
245
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
246
+ throw new Error(`${label} must be an object.`);
247
+ }
248
+ }
249
+
250
+ function normalizePresetRunOptions(runOptions) {
251
+ assertPlainObject(runOptions, "preset run");
252
+ const unknown = Object.keys(runOptions).filter(key => !RUN_PRESET_OPTION_KEYS.includes(key));
253
+ if (unknown.length) {
254
+ throw new Error(`preset run contains unsupported key(s): ${unknown.join(", ")}`);
255
+ }
256
+
257
+ const normalized = {};
258
+ for (const key of RUN_PRESET_OPTION_KEYS) {
259
+ if (!Object.prototype.hasOwnProperty.call(runOptions, key)) continue;
260
+ const value = runOptions[key];
261
+ if (key === "limit") {
262
+ if (typeof value !== "number") throw new Error("preset run.limit must be a number.");
263
+ normalized.limit = value;
264
+ } else if (key === "dryRun" || key === "isValidationTarget" || key === "csv" || key === "json") {
265
+ if (typeof value !== "boolean") throw new Error(`preset run.${key} must be a boolean.`);
266
+ normalized[key] = value;
267
+ } else if (key === "excludedPrClasses") {
268
+ if (!Array.isArray(value)) throw new Error("preset run.excludedPrClasses must be an array.");
269
+ if (!value.every(item => typeof item === "string")) {
270
+ throw new Error("preset run.excludedPrClasses must contain only strings.");
271
+ }
272
+ normalized.excludedPrClasses = normalizeExcludedPrClasses(value);
273
+ } else {
274
+ if (typeof value !== "string" || !value.trim()) {
275
+ throw new Error(`preset run.${key} must be a non-empty string.`);
276
+ }
277
+ normalized[key] = value.trim();
278
+ }
279
+ }
280
+
281
+ if (normalized.repository !== undefined) validateRepositorySlug(normalized.repository);
282
+ if (normalized.limit !== undefined) validateLimit(normalized.limit);
283
+ validateExcludedPrClasses(normalized.excludedPrClasses ?? []);
284
+ return normalized;
285
+ }
286
+
287
+ function parseRunPresetJson(text) {
288
+ let preset;
289
+ try {
290
+ preset = JSON.parse(text);
291
+ } catch (error) {
292
+ if (error instanceof SyntaxError) {
293
+ throw new Error(`preset must be valid JSON: ${error.message}`);
294
+ }
295
+ throw error;
296
+ }
297
+ assertPlainObject(preset, "preset");
298
+ if (preset.schemaVersion !== RUN_PRESET_SCHEMA_VERSION) {
299
+ throw new Error(`preset schemaVersion must be ${RUN_PRESET_SCHEMA_VERSION}.`);
300
+ }
301
+ const allowedTopLevel = new Set(["schemaVersion", "run"]);
302
+ const unknown = Object.keys(preset).filter(key => !allowedTopLevel.has(key));
303
+ if (unknown.length) {
304
+ throw new Error(`preset contains unsupported key(s): ${unknown.join(", ")}`);
305
+ }
306
+ return normalizePresetRunOptions(preset.run);
307
+ }
308
+
309
+ async function readRunPreset(presetPath) {
310
+ try {
311
+ const presetStat = await stat(presetPath);
312
+ if (!presetStat.isFile()) {
313
+ throw new Error("preset path must be a JSON file path, not a directory or special file.");
314
+ }
315
+ return parseRunPresetJson(await readFile(presetPath, "utf8"));
316
+ } catch (error) {
317
+ if (error.message?.startsWith("preset ")) throw error;
318
+ if (error.code === "ENOENT") {
319
+ throw new Error("preset could not be read: no such file or directory");
320
+ }
321
+ throw new Error(`preset could not be read: ${error.message}`);
322
+ }
323
+ }
324
+
325
+ async function mergeRunPresetOptions(options) {
326
+ if (!options.presetPath) return options;
327
+
328
+ const presetOptions = await readRunPreset(options.presetPath);
329
+ const explicitOptions = optionSourceSet(options, "explicitCliOptions");
330
+ const merged = { ...options };
331
+ const presetOptionKeys = new Set();
332
+ for (const key of RUN_PRESET_OPTION_KEYS) {
333
+ if (!Object.prototype.hasOwnProperty.call(presetOptions, key)) continue;
334
+ if (!explicitOptions.has(key)) {
335
+ merged[key] = presetOptions[key];
336
+ }
337
+ presetOptionKeys.add(key);
338
+ }
339
+ attachOptionSource(merged, "explicitCliOptions", explicitOptions);
340
+ attachOptionSource(merged, "presetOptionKeys", presetOptionKeys);
341
+ return merged;
342
+ }
343
+
344
+ function presetRunOptionsFromAnalyzeOptions(options) {
345
+ requireOptions(options);
346
+ validateRepositorySlug(options.repository);
347
+ validateLimit(options.limit);
348
+ validateExcludedPrClasses(options.excludedPrClasses ?? []);
349
+ return {
350
+ repository: options.repository,
351
+ limit: options.limit,
352
+ profilePath: options.profilePath,
353
+ outDir: options.outDir,
354
+ dryRun: Boolean(options.dryRun),
355
+ isValidationTarget: Boolean(options.isValidationTarget),
356
+ csv: options.csv !== false,
357
+ json: Boolean(options.json),
358
+ excludedPrClasses: [...(options.excludedPrClasses ?? [])],
359
+ };
360
+ }
361
+
362
+ function formatRunPreset(options) {
363
+ return `${JSON.stringify({
364
+ schemaVersion: RUN_PRESET_SCHEMA_VERSION,
365
+ run: presetRunOptionsFromAnalyzeOptions(options),
366
+ }, null, 2)}\n`;
367
+ }
368
+
369
+ async function writeRunPresetFile(presetPath, options) {
370
+ if (hasTrailingPathSeparator(presetPath)) {
371
+ throw new Error("preset path must be a JSON file path, not a directory or special file.");
372
+ }
373
+ await mkdir(dirname(presetPath), { recursive: true });
374
+ try {
375
+ const presetLinkStat = await lstat(presetPath);
376
+ if (!presetLinkStat.isFile()) {
377
+ throw new Error("preset path must be a JSON file path, not a directory or special file.");
378
+ }
379
+ } catch (error) {
380
+ if (error.code !== "ENOENT") throw error;
381
+ }
382
+
383
+ let file;
384
+ try {
385
+ file = await open(
386
+ presetPath,
387
+ constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW,
388
+ 0o666,
389
+ );
390
+ await file.writeFile(formatRunPreset(options), "utf8");
391
+ } catch (error) {
392
+ if (error.code === "ELOOP" || error.code === "EISDIR") {
393
+ throw new Error("preset path must be a JSON file path, not a directory or special file.");
394
+ }
395
+ throw error;
396
+ } finally {
397
+ await file?.close();
398
+ }
399
+ return presetPath;
400
+ }
401
+
144
402
  function requireOptions(options) {
145
403
  const missing = [];
146
404
  if (!options.repository) missing.push("--repo");
@@ -818,6 +1076,7 @@ export async function collectInteractiveAnalyzeGithubOptions(options, {
818
1076
  output = process.stderr,
819
1077
  isInteractiveTerminal = Boolean(input?.isTTY),
820
1078
  onSavedProfilePath = null,
1079
+ promptForRunPreset = false,
821
1080
  } = {}) {
822
1081
  if (!isInteractiveTerminal) {
823
1082
  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.");
@@ -970,6 +1229,36 @@ export async function collectInteractiveAnalyzeGithubOptions(options, {
970
1229
  }
971
1230
  }
972
1231
 
1232
+ if (promptForRunPreset && !resolved.savePresetPath) {
1233
+ const shouldSavePreset = await askUntilValid(adapter, {
1234
+ id: "saveRunPreset",
1235
+ type: "confirm",
1236
+ message: "Save local run preset for non-interactive reruns",
1237
+ defaultValue: false,
1238
+ }, {
1239
+ output,
1240
+ normalize: normalizeConfirmAnswer,
1241
+ validate() {},
1242
+ });
1243
+ if (shouldSavePreset) {
1244
+ resolved.savePresetPath = await askUntilValid(adapter, {
1245
+ id: "runPresetPath",
1246
+ type: "path",
1247
+ message: "Run preset path",
1248
+ }, {
1249
+ output,
1250
+ normalize: normalizeTextAnswer,
1251
+ async validate(value) {
1252
+ if (!value) throw new Error("Run preset path is required.");
1253
+ if (hasTrailingPathSeparator(value)) {
1254
+ throw new Error("preset path must be a JSON file path, not a directory or special file.");
1255
+ }
1256
+ await mkdir(dirname(value), { recursive: true });
1257
+ },
1258
+ });
1259
+ }
1260
+ }
1261
+
973
1262
  return resolved;
974
1263
  } finally {
975
1264
  if (ownsAdapter && typeof adapter.close === "function") {
@@ -1134,7 +1423,7 @@ function attachCollectionCoverage(report, sourceBundle) {
1134
1423
  };
1135
1424
  }
1136
1425
 
1137
- function summarizeResult({ dryRun, outDir, paths, sourceBundle, metrics, report, requestedLimit, sampledLimit, csv, analysisFilter, savedProfilePath, prClassRulesWritten }) {
1426
+ function summarizeResult({ dryRun, outDir, paths, sourceBundle, metrics, report, requestedLimit, sampledLimit, csv, analysisFilter, savedProfilePath, savedRunPresetPath, prClassRulesWritten }) {
1138
1427
  const summary = {
1139
1428
  ok: true,
1140
1429
  dryRun,
@@ -1153,6 +1442,9 @@ function summarizeResult({ dryRun, outDir, paths, sourceBundle, metrics, report,
1153
1442
  if (savedProfilePath) {
1154
1443
  summary.savedProfilePath = savedProfilePath;
1155
1444
  }
1445
+ if (savedRunPresetPath) {
1446
+ summary.savedRunPresetPath = savedRunPresetPath;
1447
+ }
1156
1448
  if (prClassRulesWritten) {
1157
1449
  summary.prClassRulesWritten = true;
1158
1450
  }
@@ -1230,6 +1522,7 @@ export async function runAnalyzeGithub(options, {
1230
1522
  csv: false,
1231
1523
  analysisFilter: null,
1232
1524
  savedProfilePath: options.savedProfilePath,
1525
+ savedRunPresetPath: options.savedRunPresetPath,
1233
1526
  prClassRulesWritten: options.prClassRulesWritten,
1234
1527
  });
1235
1528
  }
@@ -1286,6 +1579,7 @@ export async function runAnalyzeGithub(options, {
1286
1579
  csv: csvEnabled,
1287
1580
  analysisFilter: normalized.analysisFilter ?? null,
1288
1581
  savedProfilePath: options.savedProfilePath,
1582
+ savedRunPresetPath: options.savedRunPresetPath,
1289
1583
  prClassRulesWritten: options.prClassRulesWritten,
1290
1584
  });
1291
1585
  }
@@ -1359,6 +1653,9 @@ export function formatAnalyzeGithubCompletion(result) {
1359
1653
  if (result.savedProfilePath) {
1360
1654
  lines.push(`Repository profile saved: ${result.savedProfilePath}.`);
1361
1655
  }
1656
+ if (result.savedRunPresetPath) {
1657
+ lines.push(`Run preset saved: ${result.savedRunPresetPath}.`);
1658
+ }
1362
1659
  if (result.prClassRulesWritten) {
1363
1660
  lines.push("PR class rules written: Conventional Commit preset or release title rule.");
1364
1661
  }
@@ -1395,26 +1692,33 @@ export async function runAnalyzeGithubCli(argv, {
1395
1692
  isInteractiveTerminal = Boolean(stdin?.isTTY),
1396
1693
  } = {}) {
1397
1694
  let savedProfilePath = null;
1695
+ let savedRunPresetPath = null;
1398
1696
  try {
1399
- const options = parseAnalyzeGithubArgs(argv);
1400
- if (options.help) {
1697
+ const parsedOptions = parseAnalyzeGithubArgs(argv);
1698
+ if (parsedOptions.help) {
1401
1699
  stdout.write(USAGE);
1402
1700
  return null;
1403
1701
  }
1702
+ const options = await mergeRunPresetOptions(parsedOptions);
1404
1703
 
1704
+ const providedOptionKeys = new Set([
1705
+ ...optionSourceSet(options, "explicitCliOptions"),
1706
+ ...optionSourceSet(options, "presetOptionKeys"),
1707
+ ]);
1405
1708
  const resolvedOptions = options.interactive
1406
1709
  ? await collectInteractiveAnalyzeGithubOptions({
1407
1710
  ...options,
1408
1711
  interactivePromptDefaults: {
1409
- dryRun: !options.dryRun,
1410
- csv: options.csv !== false,
1411
- json: !options.json,
1712
+ dryRun: !providedOptionKeys.has("dryRun"),
1713
+ csv: !providedOptionKeys.has("csv"),
1714
+ json: !providedOptionKeys.has("json"),
1412
1715
  },
1413
1716
  }, {
1414
1717
  promptAdapter,
1415
1718
  input: stdin,
1416
1719
  output: stderr,
1417
1720
  isInteractiveTerminal,
1721
+ promptForRunPreset: true,
1418
1722
  onSavedProfilePath(path) {
1419
1723
  savedProfilePath = path;
1420
1724
  },
@@ -1423,6 +1727,10 @@ export async function runAnalyzeGithubCli(argv, {
1423
1727
  if (resolvedOptions.savedProfilePath) {
1424
1728
  savedProfilePath = resolvedOptions.savedProfilePath;
1425
1729
  }
1730
+ if (resolvedOptions.savePresetPath) {
1731
+ savedRunPresetPath = await writeRunPresetFile(resolvedOptions.savePresetPath, resolvedOptions);
1732
+ resolvedOptions.savedRunPresetPath = savedRunPresetPath;
1733
+ }
1426
1734
  const runOptions = {
1427
1735
  onProgress: message => writeProgress(message, stderr),
1428
1736
  };
@@ -1436,6 +1744,9 @@ export async function runAnalyzeGithubCli(argv, {
1436
1744
  if (savedProfilePath) {
1437
1745
  stderr.write(`Repository profile saved before failure: ${savedProfilePath}.\n`);
1438
1746
  }
1747
+ if (savedRunPresetPath) {
1748
+ stderr.write(`Run preset saved before failure: ${savedRunPresetPath}.\n`);
1749
+ }
1439
1750
  throw error;
1440
1751
  }
1441
1752
  }