delivery-friction-analyzer 0.2.2 → 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 +96 -47
- package/package.json +1 -1
- package/release-log.md +8 -0
- package/src/cli/analyze-github.js +311 -9
package/README.md
CHANGED
|
@@ -1,38 +1,30 @@
|
|
|
1
1
|
# Delivery Friction Analyzer
|
|
2
2
|
|
|
3
|
-
Delivery Friction Analyzer is a
|
|
3
|
+
Delivery Friction Analyzer is a local CLI for GitHub pull request analytics. It samples merged PRs from a repository and writes delivery-friction reports that show where work slowed down: review loops, CI churn, scope spread, validation gaps, planning signals, and repeated corrective work.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Use it when you want to answer questions like:
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
`hannasdev/mcp-writing` remains the first validation target and fixture source, not product-specific scope.
|
|
7
|
+
- Where do PRs require the most corrective loops?
|
|
8
|
+
- Which feedback patterns repeat across PRs?
|
|
9
|
+
- Which files, surfaces, or PR classes create the most back-and-forth?
|
|
10
|
+
- Which issues look preventable with better local checks, repo-specific AI instructions, skills, hooks, or smaller delivery slices?
|
|
12
11
|
|
|
13
|
-
The
|
|
12
|
+
The analyzer runs locally with your GitHub credentials. Generated artifacts preserve source evidence, coverage caveats, and interpretation limits so reports can be inspected before they are shared.
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
- classify files and PRs through repository profiles;
|
|
17
|
-
- generate Markdown, JSON, methodology, and CSV artifacts;
|
|
18
|
-
- explain review, validation, scope, planning, PR-size, and PR-class friction with traceable evidence;
|
|
19
|
-
- support explicit follow-up filtering when maintainers want to inspect a configured PR population separately.
|
|
14
|
+
## Requirements
|
|
20
15
|
|
|
21
|
-
|
|
16
|
+
- Node.js 20 or newer.
|
|
17
|
+
- GitHub CLI (`gh`) installed and authenticated with access to the target repository.
|
|
18
|
+
- A repository profile JSON for the repository you want to analyze.
|
|
22
19
|
|
|
23
|
-
-
|
|
24
|
-
- Which feedback patterns repeat across PRs?
|
|
25
|
-
- Which issues are preventable with better local checks, repo-specific AI instructions, skills, hooks, or smaller delivery slices?
|
|
26
|
-
- Which changes create the largest gap between the PR opened state and the merged state?
|
|
27
|
-
- Which changed files are part of the repository's configured product surface versus tests, docs, generated artifacts, release notes, marketing surfaces, or other support surfaces?
|
|
20
|
+
For public repositories, ordinary read access is usually enough. Private repositories need a `gh` token with enough read access for the requested API families. With a classic PAT, that usually means the `repo` scope. With a fine-grained token or GitHub App, grant read permissions for repository metadata and contents, pull requests, Actions, and checks where available. Missing or partial API coverage is recorded in the generated methodology and coverage artifacts instead of being treated as complete data.
|
|
28
21
|
|
|
29
|
-
|
|
22
|
+
## Quickstart
|
|
30
23
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
Run the live analyzer with local `gh` credentials:
|
|
24
|
+
From this repository, install dependencies and run the analyzer against the sample validation target:
|
|
34
25
|
|
|
35
26
|
```sh
|
|
27
|
+
npm install
|
|
36
28
|
npm run analyze:github -- \
|
|
37
29
|
--repo hannasdev/mcp-writing \
|
|
38
30
|
--limit 30 \
|
|
@@ -40,7 +32,7 @@ npm run analyze:github -- \
|
|
|
40
32
|
--out reports/mcp-writing
|
|
41
33
|
```
|
|
42
34
|
|
|
43
|
-
|
|
35
|
+
From another project or script, run the published CLI with `npx`:
|
|
44
36
|
|
|
45
37
|
```sh
|
|
46
38
|
npx delivery-friction-analyzer \
|
|
@@ -50,45 +42,102 @@ npx delivery-friction-analyzer \
|
|
|
50
42
|
--out reports/mcp-writing
|
|
51
43
|
```
|
|
52
44
|
|
|
53
|
-
|
|
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.
|
|
54
46
|
|
|
55
|
-
|
|
47
|
+
For a guided first run in a local terminal, use the opt-in interactive flow:
|
|
56
48
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
- `friction-report.json`
|
|
61
|
-
- `friction-report.md`
|
|
62
|
-
- `methodology.md`
|
|
63
|
-
- `pr-metrics.csv`
|
|
64
|
-
- `bottleneck-examples.csv`
|
|
65
|
-
- `comment-sources.csv`
|
|
66
|
-
- `collection-coverage.csv`
|
|
49
|
+
```sh
|
|
50
|
+
npm run analyze:github -- --interactive
|
|
51
|
+
```
|
|
67
52
|
|
|
68
|
-
|
|
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.
|
|
69
54
|
|
|
70
|
-
|
|
55
|
+
## Repository Profiles
|
|
71
56
|
|
|
72
|
-
|
|
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.
|
|
73
58
|
|
|
74
|
-
|
|
59
|
+
Profiles can define:
|
|
75
60
|
|
|
76
|
-
|
|
61
|
+
- file categories such as code, tests, docs, generated files, infrastructure, or config;
|
|
62
|
+
- file roles such as core product code, release notes, fixtures, planning docs, or generated docs;
|
|
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.
|
|
77
65
|
|
|
78
|
-
|
|
66
|
+
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`.
|
|
79
67
|
|
|
80
|
-
|
|
68
|
+
## Outputs
|
|
81
69
|
|
|
82
|
-
|
|
70
|
+
A successful run writes a report bundle to the output directory:
|
|
71
|
+
|
|
72
|
+
- `friction-report.md`: the main report to read first.
|
|
73
|
+
- `methodology.md`: data coverage, caveats, and interpretation notes.
|
|
74
|
+
- `friction-report.json`: machine-readable report data.
|
|
75
|
+
- `metrics-summary.json`: computed metrics used by the report.
|
|
76
|
+
- `normalized.json`: normalized repository, PR, file, review, and validation entities.
|
|
77
|
+
- `source-bundle.json`: collected source data for auditability.
|
|
78
|
+
- `pr-metrics.csv`: per-PR metrics for spreadsheet review.
|
|
79
|
+
- `bottleneck-examples.csv`: representative bottleneck examples.
|
|
80
|
+
- `comment-sources.csv`: review-comment source breakdowns.
|
|
81
|
+
- `collection-coverage.csv`: API coverage diagnostics.
|
|
82
|
+
|
|
83
|
+
Each ranked bottleneck example includes source references, workflow-run conclusions, review-thread source information, comment-source breakdowns, and a dominance note when one PR contributes most of the displayed signal.
|
|
84
|
+
|
|
85
|
+
## Common Options
|
|
86
|
+
|
|
87
|
+
Use `--dry-run` or `--metadata-only` to validate repository access, profile JSON, output directory writability, and sampled API coverage without writing full report artifacts.
|
|
88
|
+
|
|
89
|
+
Use `--no-csv` when you want the Markdown, JSON, source, normalized, metrics, and methodology artifacts without spreadsheet-friendly CSV exports.
|
|
90
|
+
|
|
91
|
+
Use `--exclude-pr-class <class>` to remove a configured PR class from downstream normalized, metrics, report, methodology, and CSV artifacts. `source-bundle.json` still preserves the full collected sample for auditability.
|
|
92
|
+
|
|
93
|
+
Use `--json` when automation needs the full machine-readable completion receipt on stdout.
|
|
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.
|
|
83
96
|
|
|
84
|
-
|
|
97
|
+
## How To Read A Report
|
|
98
|
+
|
|
99
|
+
Start with `friction-report.md`. If a bottleneck looks surprising, inspect `methodology.md`, the CSV exports, `friction-report.json`, and `source-bundle.json`.
|
|
100
|
+
|
|
101
|
+
Ranked bottlenecks are ordered by their strongest displayed representative score, not by an opaque composite priority score. PR size columns show final or current additions, deletions, changed files, and changed lines so maintainers can compare size against review, validation, and planning signals.
|
|
102
|
+
|
|
103
|
+
Generated artifacts may contain repository names, PR URLs, PR titles, file paths, comment metadata, curated CSV evidence, and coverage diagnostics. Treat source bundles, normalized data, metrics summaries, reports, methodology, and CSV exports as local or private unless you intentionally review and share them.
|
|
104
|
+
|
|
105
|
+
## Interpretation Limits
|
|
106
|
+
|
|
107
|
+
Known MVP limits:
|
|
85
108
|
|
|
86
109
|
- PR-open diff growth is unavailable unless an open-time snapshot or reconstruction exists; the local historical collector does not infer it from merge-time diff data.
|
|
87
110
|
- Workflow runs are collected from branch-based pull-request Actions history, which can be unavailable or partial for deleted, renamed, reused, or inaccessible branches.
|
|
88
111
|
- Review-thread counts depend on GraphQL review-thread coverage; unavailable thread access is reported instead of silently treated as zero review churn.
|
|
89
|
-
- A single PR or PR class, such as release, dependency, bot-driven, or unusually broad feature work, can dominate validation or review findings. Treat PR and class dominance notes as prompts to inspect the raw evidence before generalizing
|
|
112
|
+
- A single PR or PR class, such as release, dependency, bot-driven, or unusually broad feature work, can dominate validation or review findings. Treat PR and class dominance notes as prompts to inspect the raw evidence before generalizing.
|
|
113
|
+
|
|
114
|
+
More detail on GitHub API coverage is documented in `docs/reference/github-access-coverage.md`.
|
|
115
|
+
|
|
116
|
+
## Optional Narrative Drafting
|
|
117
|
+
|
|
118
|
+
The generated artifacts are enough context for an optional local workflow where a separate model drafts a narrative report. Use `friction-report.json` as the structured source of truth, `friction-report.md` as the human-readable source of truth, and the curated CSV exports only as supporting evidence when the draft needs per-PR detail.
|
|
119
|
+
|
|
120
|
+
When using a model this way, keep the deterministic artifacts authoritative: preserve coverage, outlier, PR-class, and analysis-filter caveats; distinguish observed evidence from inferred diagnosis and suggested action; do not invent missing data; and do not rank individuals. Review any generated prose against the Markdown, JSON, and CSV evidence before sharing it.
|
|
121
|
+
|
|
122
|
+
No separate model-ready context artifact is required for this workflow. Reconsider a new artifact only if a concrete consumer needs a smaller single-file context, machine-readable prompt packaging, or fields that cannot be represented clearly by `friction-report.json` plus curated CSV evidence.
|
|
123
|
+
|
|
124
|
+
## Current Direction
|
|
125
|
+
|
|
126
|
+
Delivery Friction Analyzer is currently a local, GitHub-connected analyzer that produces repository-level friction reports from live pull request data. It is repo-source-agnostic: repository-specific assumptions live in profiles.
|
|
127
|
+
|
|
128
|
+
The current product wedge is a maintainer workflow:
|
|
129
|
+
|
|
130
|
+
- collect the latest merged PR sample from a target repository;
|
|
131
|
+
- classify files and PRs through repository profiles;
|
|
132
|
+
- generate Markdown, JSON, methodology, and CSV artifacts;
|
|
133
|
+
- explain review, validation, scope, planning, PR-size, and PR-class friction with traceable evidence;
|
|
134
|
+
- support explicit follow-up filtering when maintainers want to inspect a configured PR population separately.
|
|
135
|
+
|
|
136
|
+
The product should eventually combine GitHub delivery friction with token and model usage, but GitHub-only analytics remain the active validation surface.
|
|
137
|
+
|
|
138
|
+
`hannasdev/mcp-writing` remains the first validation target and fixture source, not product-specific scope.
|
|
90
139
|
|
|
91
|
-
|
|
140
|
+
## Development Notes
|
|
92
141
|
|
|
93
142
|
The existing metrics-summary-only report command remains available for fixture and advanced workflows:
|
|
94
143
|
|
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
575
|
-
return;
|
|
850
|
+
stdout.write(USAGE);
|
|
851
|
+
return null;
|
|
576
852
|
}
|
|
577
853
|
|
|
578
|
-
const
|
|
579
|
-
|
|
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) {
|