delivery-friction-analyzer 0.11.0 → 0.12.1
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
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
129
|
-
isValidationTarget:
|
|
130
|
-
excludedPrClasses: normalizeExcludedPrClasses(options
|
|
131
|
-
csv:
|
|
132
|
-
json:
|
|
133
|
-
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
|
|
1400
|
-
if (
|
|
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: !
|
|
1410
|
-
csv:
|
|
1411
|
-
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
|
}
|