delivery-friction-analyzer 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/docs/reference/repository-profile.md +5 -1
- package/package.json +1 -1
- package/release-log.md +8 -0
- package/src/cli/analyze-github.js +492 -58
package/README.md
CHANGED
|
@@ -50,7 +50,7 @@ For a guided first run in a local terminal, use the opt-in interactive flow:
|
|
|
50
50
|
npm run analyze:github -- --interactive
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
Interactive mode asks for the same run choices supported by flags, including repository, PR limit, profile path, output directory, dry-run mode, CSV exports, JSON completion output, and configured PR class exclusions. Scripted and CI usage should keep passing explicit flags; missing required flags without `--interactive` fail deterministically instead of waiting for input.
|
|
53
|
+
Interactive mode asks for the same run choices supported by flags, including repository, PR limit, profile path, output directory, dry-run mode, CSV exports, JSON completion output, and configured PR class exclusions. It can also create a missing repository profile path or write a generated profile copy with confirmed workflow context and release PR title rules. Scripted and CI usage should keep passing explicit flags; missing required flags without `--interactive` fail deterministically instead of waiting for input.
|
|
54
54
|
|
|
55
55
|
## Repository Profiles
|
|
56
56
|
|
|
@@ -61,7 +61,8 @@ Profiles can define:
|
|
|
61
61
|
- file categories such as code, tests, docs, generated files, infrastructure, or config;
|
|
62
62
|
- file roles such as core product code, release notes, fixtures, planning docs, or generated docs;
|
|
63
63
|
- functional surfaces such as runtime, test suite, release notes, or user docs;
|
|
64
|
-
- PR classes such as release, dependency, feature, or other repository-specific groups
|
|
64
|
+
- PR classes such as release, dependency, feature, or other repository-specific groups;
|
|
65
|
+
- workflow context such as merge method, release strategy, and branch strategy.
|
|
65
66
|
|
|
66
67
|
Use `fixtures/github/mcp-writing/profile.json` as a starting point, then save a copy for the repository you want to analyze. The full profile format is documented in `docs/reference/repository-profile.md`, and the schema lives at `schemas/repository-profile.schema.json`.
|
|
67
68
|
|
|
@@ -54,9 +54,11 @@ If both matchers are present on one rule, both must match. If no rule matches, t
|
|
|
54
54
|
|
|
55
55
|
Class identifiers are validated as lower-kebab-case or lower_snake_case strings. Profile validation rejects duplicate PR class rule IDs, empty match objects, invalid class identifiers, and invalid title regexes.
|
|
56
56
|
|
|
57
|
+
Interactive setup can add a release PR class rule from a confirmed title convention using the current title-only matcher shape. Branch strategy answers stay in `workflow` context only; they do not create branch-based PR class matching.
|
|
58
|
+
|
|
57
59
|
## Workflow Context
|
|
58
60
|
|
|
59
|
-
`workflow` is optional user-configured context. It records repository workflow assumptions that later setup and report milestones can rely on, but
|
|
61
|
+
`workflow` is optional user-configured context. It records repository workflow assumptions that later setup and report milestones can rely on, but the analyzer does not infer these values from GitHub and does not change scoring, rankings, collection, PR class matching, or report wording.
|
|
60
62
|
|
|
61
63
|
When provided, `workflow` must include at least one supported field.
|
|
62
64
|
|
|
@@ -79,3 +81,5 @@ Example:
|
|
|
79
81
|
```
|
|
80
82
|
|
|
81
83
|
Use stable identifiers exactly as shown above. Display labels such as "squash merges" or "release PRs" belong in CLI prompts or documentation, not in profile data.
|
|
84
|
+
|
|
85
|
+
When interactive setup writes profile changes, it preserves deterministic two-space JSON formatting in place. If an existing profile uses other formatting, setup writes a generated profile copy and prints that generated path in completion output instead of rewriting the original file.
|
package/package.json
CHANGED
package/release-log.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
### 2026-06-18 — Workflow Profile Wizard
|
|
6
|
+
|
|
7
|
+
- What changed: Interactive setup can now create repository profiles or generated profile copies with confirmed workflow context and release PR title rules.
|
|
8
|
+
- Why it matters: Maintainers can capture reusable workflow assumptions during guided setup without hand-editing profile JSON or risking silent rewrites of existing profiles.
|
|
9
|
+
- Who is affected: Maintainers running `--interactive` for first-time repository setup or profile updates.
|
|
10
|
+
- Action needed: Review the generated or updated profile path printed in completion output before reusing it in automation.
|
|
11
|
+
- PR: https://github.com/hannasdev/delivery-friction-analyzer/pull/36
|
|
12
|
+
|
|
5
13
|
### 2026-06-17 — Workflow Profile Contract
|
|
6
14
|
|
|
7
15
|
- What changed: Repository profiles can now declare optional workflow context for merge method, release strategy, and branch strategy using validated stable identifiers.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { constants, realpathSync } from "node:fs";
|
|
3
|
-
import { access, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { access, lstat, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { createInterface } from "node:readline/promises";
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -17,7 +17,12 @@ import {
|
|
|
17
17
|
renderRepositoryFrictionMarkdown,
|
|
18
18
|
} from "../report/friction-report.js";
|
|
19
19
|
import { assertValidPrClassRules } from "../profile/pr-class.js";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
WORKFLOW_BRANCH_STRATEGIES,
|
|
22
|
+
WORKFLOW_PRIMARY_MERGE_METHODS,
|
|
23
|
+
WORKFLOW_RELEASE_STRATEGIES,
|
|
24
|
+
assertValidWorkflowContext,
|
|
25
|
+
} from "../profile/workflow.js";
|
|
21
26
|
|
|
22
27
|
const ALLOWED_OPTIONS = new Set([
|
|
23
28
|
"repo",
|
|
@@ -183,25 +188,6 @@ function validateExcludedPrClassesAreConfigured(excludedPrClasses = [], reposito
|
|
|
183
188
|
throw new Error(`exclude-pr-class must name configured PR class(es): ${unconfigured.join(", ")}.${available}`);
|
|
184
189
|
}
|
|
185
190
|
|
|
186
|
-
async function readProfile(profilePath) {
|
|
187
|
-
let profile;
|
|
188
|
-
try {
|
|
189
|
-
profile = JSON.parse(await readFile(profilePath, "utf8"));
|
|
190
|
-
} catch (error) {
|
|
191
|
-
if (error instanceof SyntaxError) {
|
|
192
|
-
throw new Error(`profile must be valid JSON: ${error.message}`);
|
|
193
|
-
}
|
|
194
|
-
throw new Error(`profile could not be read: ${error.message}`);
|
|
195
|
-
}
|
|
196
|
-
try {
|
|
197
|
-
assertValidPrClassRules(profile);
|
|
198
|
-
assertValidWorkflowContext(profile);
|
|
199
|
-
} catch (error) {
|
|
200
|
-
throw new Error(`profile is invalid: ${error.message}`);
|
|
201
|
-
}
|
|
202
|
-
return profile;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
191
|
function configuredPrClassList(repositoryProfile) {
|
|
206
192
|
return [...configuredPrClasses(repositoryProfile)].sort();
|
|
207
193
|
}
|
|
@@ -223,6 +209,9 @@ function formatInteractivePrompt(prompt) {
|
|
|
223
209
|
if (prompt.type === "multi-select" && prompt.choices?.length) {
|
|
224
210
|
return `${prompt.message} (${prompt.choices.join(",")})${suffix}: `;
|
|
225
211
|
}
|
|
212
|
+
if (prompt.type === "select" && prompt.choices?.length) {
|
|
213
|
+
return `${prompt.message} (${prompt.choices.join(",")})${suffix}: `;
|
|
214
|
+
}
|
|
226
215
|
return `${prompt.message}${suffix}: `;
|
|
227
216
|
}
|
|
228
217
|
|
|
@@ -286,22 +275,418 @@ function normalizeConfirmAnswer(raw, prompt) {
|
|
|
286
275
|
throw new Error("Answer yes or no.");
|
|
287
276
|
}
|
|
288
277
|
|
|
278
|
+
function normalizeChoiceAnswer(raw, prompt) {
|
|
279
|
+
const value = normalizeTextAnswer(raw, prompt);
|
|
280
|
+
if (prompt.choices?.includes(value)) return value;
|
|
281
|
+
throw new Error(`${prompt.id} must be one of: ${prompt.choices.join(", ")}`);
|
|
282
|
+
}
|
|
283
|
+
|
|
289
284
|
function normalizeMultiSelectAnswer(raw, prompt) {
|
|
290
285
|
const value = String(raw ?? "").trim();
|
|
291
286
|
if (!value) return prompt.defaultValue ?? [];
|
|
292
287
|
return normalizeExcludedPrClasses([value]);
|
|
293
288
|
}
|
|
294
289
|
|
|
290
|
+
function validateProfile(profile) {
|
|
291
|
+
assertValidPrClassRules(profile);
|
|
292
|
+
assertValidWorkflowContext(profile);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function parseProfileJson(text) {
|
|
296
|
+
try {
|
|
297
|
+
return JSON.parse(text);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (error instanceof SyntaxError) {
|
|
300
|
+
throw new Error(`profile must be valid JSON: ${error.message}`);
|
|
301
|
+
}
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function hasTrailingPathSeparator(profilePath) {
|
|
307
|
+
return /[/\\]$/.test(profilePath);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function inspectProfilePath(profilePath) {
|
|
311
|
+
if (hasTrailingPathSeparator(profilePath)) {
|
|
312
|
+
throw new Error("profile path must be a JSON file path, not a directory or special file.");
|
|
313
|
+
}
|
|
314
|
+
let profileLinkStat;
|
|
315
|
+
try {
|
|
316
|
+
profileLinkStat = await lstat(profilePath);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (error.code === "ENOENT") {
|
|
319
|
+
return { exists: false, profile: null, text: null, isSymbolicLink: false };
|
|
320
|
+
}
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
const isSymbolicLink = profileLinkStat.isSymbolicLink();
|
|
324
|
+
const profileStat = isSymbolicLink ? await stat(profilePath) : profileLinkStat;
|
|
325
|
+
if (!profileStat.isFile()) {
|
|
326
|
+
throw new Error("profile path must be a JSON file path, not a directory or special file.");
|
|
327
|
+
}
|
|
328
|
+
const text = await readFile(profilePath, "utf8");
|
|
329
|
+
const profile = parseProfileJson(text);
|
|
330
|
+
validateProfile(profile);
|
|
331
|
+
return { exists: true, profile, text, isSymbolicLink };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function readProfile(profilePath) {
|
|
335
|
+
let inspected;
|
|
336
|
+
try {
|
|
337
|
+
inspected = await inspectProfilePath(profilePath);
|
|
338
|
+
} catch (error) {
|
|
339
|
+
if (error.message?.startsWith("profile must be valid JSON")) {
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
if (error.message?.startsWith("invalid ")) {
|
|
343
|
+
throw new Error(`profile is invalid: ${error.message}`);
|
|
344
|
+
}
|
|
345
|
+
throw new Error(`profile could not be read: ${error.message}`);
|
|
346
|
+
}
|
|
347
|
+
if (!inspected.exists) {
|
|
348
|
+
throw new Error("profile could not be read: no such file or directory");
|
|
349
|
+
}
|
|
350
|
+
return inspected.profile;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function cloneJson(value) {
|
|
354
|
+
return JSON.parse(JSON.stringify(value));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function repositoryProfileFromSlug(repository) {
|
|
358
|
+
const match = REPOSITORY_SLUG.exec(repository);
|
|
359
|
+
if (!match) {
|
|
360
|
+
throw new Error("repo must use owner/name with GitHub-safe owner and name segments.");
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
schemaVersion: "repository-profile.v1",
|
|
364
|
+
repository: {
|
|
365
|
+
owner: match[1],
|
|
366
|
+
name: match[2],
|
|
367
|
+
},
|
|
368
|
+
rules: [],
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function formatProfile(profile) {
|
|
373
|
+
return `${JSON.stringify(profile, null, 2)}\n`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function generatedProfilePath(profilePath, index = 1) {
|
|
377
|
+
const suffix = index === 1 ? ".generated.json" : `.generated-${index}.json`;
|
|
378
|
+
return /\.json$/i.test(profilePath)
|
|
379
|
+
? profilePath.replace(/\.json$/i, suffix)
|
|
380
|
+
: `${profilePath}${suffix}`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function writeProfileFile(profilePath, profile) {
|
|
384
|
+
await mkdir(dirname(profilePath), { recursive: true });
|
|
385
|
+
await writeFile(profilePath, formatProfile(profile), { encoding: "utf8", flag: "wx" });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function shouldWriteGeneratedProfileAfterCreateFailure(error) {
|
|
389
|
+
return ["EEXIST", "EISDIR", "ELOOP"].includes(error.code);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function shouldRetryProfileReplaceAfterRenameFailure(error) {
|
|
393
|
+
return ["EEXIST", "EPERM"].includes(error.code);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function replaceProfileFile(profilePath, profile, originalText) {
|
|
397
|
+
const tempPath = `${profilePath}.${process.pid}.${Date.now()}.tmp`;
|
|
398
|
+
let shouldCleanupTemp = true;
|
|
399
|
+
try {
|
|
400
|
+
await writeFile(tempPath, formatProfile(profile), { encoding: "utf8", flag: "wx" });
|
|
401
|
+
try {
|
|
402
|
+
await rename(tempPath, profilePath);
|
|
403
|
+
shouldCleanupTemp = false;
|
|
404
|
+
return true;
|
|
405
|
+
} catch (error) {
|
|
406
|
+
if (!shouldRetryProfileReplaceAfterRenameFailure(error)) {
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
409
|
+
if (!await profileStillMatchesOriginal(profilePath, originalText)) {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
await rm(profilePath, { force: true });
|
|
413
|
+
await rename(tempPath, profilePath);
|
|
414
|
+
shouldCleanupTemp = false;
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
} finally {
|
|
418
|
+
if (shouldCleanupTemp) {
|
|
419
|
+
await rm(tempPath, { force: true });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function profileStillMatchesOriginal(profilePath, originalText) {
|
|
425
|
+
try {
|
|
426
|
+
const profileLinkStat = await lstat(profilePath);
|
|
427
|
+
if (profileLinkStat.isSymbolicLink() || !profileLinkStat.isFile()) return false;
|
|
428
|
+
return await readFile(profilePath, "utf8") === originalText;
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (error.code === "ENOENT") return false;
|
|
431
|
+
throw error;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function writeGeneratedProfileFile(profilePath, profile) {
|
|
436
|
+
for (let index = 1; ; index += 1) {
|
|
437
|
+
const writePath = generatedProfilePath(profilePath, index);
|
|
438
|
+
try {
|
|
439
|
+
await mkdir(dirname(writePath), { recursive: true });
|
|
440
|
+
await writeFile(writePath, formatProfile(profile), { encoding: "utf8", flag: "wx" });
|
|
441
|
+
return writePath;
|
|
442
|
+
} catch (error) {
|
|
443
|
+
if (error.code === "EEXIST") continue;
|
|
444
|
+
throw error;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function writeInteractiveProfile(profilePath, profile, {
|
|
450
|
+
exists,
|
|
451
|
+
originalProfile,
|
|
452
|
+
originalText,
|
|
453
|
+
originalIsSymbolicLink,
|
|
454
|
+
}) {
|
|
455
|
+
const canRewriteExisting = exists
|
|
456
|
+
&& !originalIsSymbolicLink
|
|
457
|
+
&& originalText === formatProfile(originalProfile);
|
|
458
|
+
if (exists && !canRewriteExisting) {
|
|
459
|
+
return writeGeneratedProfileFile(profilePath, profile);
|
|
460
|
+
}
|
|
461
|
+
if (!exists) {
|
|
462
|
+
try {
|
|
463
|
+
await writeProfileFile(profilePath, profile);
|
|
464
|
+
return profilePath;
|
|
465
|
+
} catch (error) {
|
|
466
|
+
if (shouldWriteGeneratedProfileAfterCreateFailure(error)) {
|
|
467
|
+
return writeGeneratedProfileFile(profilePath, profile);
|
|
468
|
+
}
|
|
469
|
+
throw error;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (!await profileStillMatchesOriginal(profilePath, originalText)) {
|
|
473
|
+
return writeGeneratedProfileFile(profilePath, profile);
|
|
474
|
+
}
|
|
475
|
+
if (!await replaceProfileFile(profilePath, profile, originalText)) {
|
|
476
|
+
return writeGeneratedProfileFile(profilePath, profile);
|
|
477
|
+
}
|
|
478
|
+
return profilePath;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function releaseClassRuleIndexes(profile) {
|
|
482
|
+
if (!Array.isArray(profile.prClasses)) return [];
|
|
483
|
+
return profile.prClasses
|
|
484
|
+
.map((rule, index) => rule.class === "release" ? index : -1)
|
|
485
|
+
.filter(index => index >= 0);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function updateableReleaseClassRuleIndex(profile) {
|
|
489
|
+
const releaseIndexes = releaseClassRuleIndexes(profile);
|
|
490
|
+
if (releaseIndexes.length !== 1) return -1;
|
|
491
|
+
const rule = profile.prClasses[releaseIndexes[0]];
|
|
492
|
+
const match = rule.match ?? {};
|
|
493
|
+
const hasTitleIncludes = typeof match.titleIncludes === "string" && match.titleIncludes.length > 0;
|
|
494
|
+
const hasTitleRegex = typeof match.titleRegex === "string" && match.titleRegex.length > 0;
|
|
495
|
+
return hasTitleIncludes && !hasTitleRegex ? releaseIndexes[0] : -1;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function defaultReleaseTitleIncludes(profile) {
|
|
499
|
+
const index = updateableReleaseClassRuleIndex(profile);
|
|
500
|
+
const existingIncludes = index >= 0 ? profile.prClasses[index]?.match?.titleIncludes : null;
|
|
501
|
+
return typeof existingIncludes === "string" && existingIncludes.length ? existingIncludes : "Release";
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function nextPrClassRuleId(profile, baseId) {
|
|
505
|
+
const existingIds = new Set((profile.prClasses ?? []).map(rule => rule.id));
|
|
506
|
+
if (!existingIds.has(baseId)) return baseId;
|
|
507
|
+
for (let index = 2; ; index += 1) {
|
|
508
|
+
const candidate = `${baseId}-${index}`;
|
|
509
|
+
if (!existingIds.has(candidate)) return candidate;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function releasePrClassRule(profile, titleIncludes, existingRule = null) {
|
|
514
|
+
return {
|
|
515
|
+
id: existingRule?.id ?? nextPrClassRuleId(profile, "release-title"),
|
|
516
|
+
class: "release",
|
|
517
|
+
match: { ...(existingRule?.match ?? {}), titleIncludes },
|
|
518
|
+
notes: existingRule?.notes ?? "Generated by interactive setup from the configured release PR title convention.",
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function promptWorkflowField(promptAdapter, output, { id, message, choices, defaultValue }) {
|
|
523
|
+
return askUntilValid(promptAdapter, {
|
|
524
|
+
id,
|
|
525
|
+
type: "select",
|
|
526
|
+
message,
|
|
527
|
+
choices,
|
|
528
|
+
defaultValue,
|
|
529
|
+
}, {
|
|
530
|
+
output,
|
|
531
|
+
normalize: normalizeChoiceAnswer,
|
|
532
|
+
validate() {},
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function promptWorkflowProfileUpdate(promptAdapter, output, profile, { isNewProfile }) {
|
|
537
|
+
const updated = cloneJson(profile);
|
|
538
|
+
updated.workflow = {
|
|
539
|
+
primaryMergeMethod: await promptWorkflowField(promptAdapter, output, {
|
|
540
|
+
id: "primaryMergeMethod",
|
|
541
|
+
message: "Primary merge method",
|
|
542
|
+
choices: WORKFLOW_PRIMARY_MERGE_METHODS,
|
|
543
|
+
defaultValue: updated.workflow?.primaryMergeMethod ?? "unknown",
|
|
544
|
+
}),
|
|
545
|
+
releaseStrategy: await promptWorkflowField(promptAdapter, output, {
|
|
546
|
+
id: "releaseStrategy",
|
|
547
|
+
message: "Release strategy",
|
|
548
|
+
choices: WORKFLOW_RELEASE_STRATEGIES,
|
|
549
|
+
defaultValue: updated.workflow?.releaseStrategy ?? "unknown",
|
|
550
|
+
}),
|
|
551
|
+
branchStrategy: await promptWorkflowField(promptAdapter, output, {
|
|
552
|
+
id: "branchStrategy",
|
|
553
|
+
message: "Branch strategy",
|
|
554
|
+
choices: WORKFLOW_BRANCH_STRATEGIES,
|
|
555
|
+
defaultValue: updated.workflow?.branchStrategy ?? "unknown",
|
|
556
|
+
}),
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
if (updated.workflow.releaseStrategy === "release_prs") {
|
|
560
|
+
const suggestedReleaseTitle = defaultReleaseTitleIncludes(updated);
|
|
561
|
+
const titleIncludes = await askUntilValid(promptAdapter, {
|
|
562
|
+
id: "releasePrTitleIncludes",
|
|
563
|
+
type: "text",
|
|
564
|
+
message: `Release PR title includes (blank to skip PR class rule; suggested: ${suggestedReleaseTitle})`,
|
|
565
|
+
}, {
|
|
566
|
+
output,
|
|
567
|
+
normalize: normalizeTextAnswer,
|
|
568
|
+
validate() {},
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
if (titleIncludes) {
|
|
572
|
+
updated.prClasses = Array.isArray(updated.prClasses) ? [...updated.prClasses] : [];
|
|
573
|
+
const updateableReleaseIndex = updateableReleaseClassRuleIndex(updated);
|
|
574
|
+
if (updateableReleaseIndex >= 0) {
|
|
575
|
+
const shouldUpdateReleaseRule = await askUntilValid(promptAdapter, {
|
|
576
|
+
id: "updateReleasePrClass",
|
|
577
|
+
type: "confirm",
|
|
578
|
+
message: "Update existing title-based release PR class rule",
|
|
579
|
+
defaultValue: false,
|
|
580
|
+
}, {
|
|
581
|
+
output,
|
|
582
|
+
normalize: normalizeConfirmAnswer,
|
|
583
|
+
validate() {},
|
|
584
|
+
});
|
|
585
|
+
if (shouldUpdateReleaseRule) {
|
|
586
|
+
updated.prClasses[updateableReleaseIndex] = releasePrClassRule(
|
|
587
|
+
updated,
|
|
588
|
+
titleIncludes,
|
|
589
|
+
updated.prClasses[updateableReleaseIndex],
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
} else {
|
|
593
|
+
const shouldAddReleaseRule = isNewProfile
|
|
594
|
+
? true
|
|
595
|
+
: await askUntilValid(promptAdapter, {
|
|
596
|
+
id: "addReleasePrClass",
|
|
597
|
+
type: "confirm",
|
|
598
|
+
message: "Add release PR class rule from title convention",
|
|
599
|
+
defaultValue: true,
|
|
600
|
+
}, {
|
|
601
|
+
output,
|
|
602
|
+
normalize: normalizeConfirmAnswer,
|
|
603
|
+
validate() {},
|
|
604
|
+
});
|
|
605
|
+
if (shouldAddReleaseRule) {
|
|
606
|
+
updated.prClasses.push(releasePrClassRule(updated, titleIncludes));
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
validateProfile(updated);
|
|
613
|
+
return updated;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async function maybeConfigureInteractiveProfile(promptAdapter, output, profileState, repository) {
|
|
617
|
+
const isNewProfile = !profileState.exists;
|
|
618
|
+
const originalProfile = profileState.profile;
|
|
619
|
+
let profile = isNewProfile ? repositoryProfileFromSlug(repository) : cloneJson(originalProfile);
|
|
620
|
+
|
|
621
|
+
if (isNewProfile) {
|
|
622
|
+
const shouldCreateProfile = await askUntilValid(promptAdapter, {
|
|
623
|
+
id: "createProfile",
|
|
624
|
+
type: "confirm",
|
|
625
|
+
message: "Create repository profile at this path",
|
|
626
|
+
defaultValue: true,
|
|
627
|
+
}, {
|
|
628
|
+
output,
|
|
629
|
+
normalize: normalizeConfirmAnswer,
|
|
630
|
+
validate() {},
|
|
631
|
+
});
|
|
632
|
+
if (!shouldCreateProfile) {
|
|
633
|
+
throw new Error("Repository profile is required.");
|
|
634
|
+
}
|
|
635
|
+
} else {
|
|
636
|
+
const shouldConfigureWorkflow = await askUntilValid(promptAdapter, {
|
|
637
|
+
id: "configureWorkflow",
|
|
638
|
+
type: "confirm",
|
|
639
|
+
message: "Configure repository workflow profile fields",
|
|
640
|
+
defaultValue: false,
|
|
641
|
+
}, {
|
|
642
|
+
output,
|
|
643
|
+
normalize: normalizeConfirmAnswer,
|
|
644
|
+
validate() {},
|
|
645
|
+
});
|
|
646
|
+
if (!shouldConfigureWorkflow) {
|
|
647
|
+
return {
|
|
648
|
+
profile,
|
|
649
|
+
profilePath: profileState.profilePath,
|
|
650
|
+
savedProfilePath: null,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
profile = await promptWorkflowProfileUpdate(promptAdapter, output, profile, { isNewProfile });
|
|
656
|
+
const savedProfilePath = await writeInteractiveProfile(profileState.profilePath, profile, {
|
|
657
|
+
exists: profileState.exists,
|
|
658
|
+
originalProfile,
|
|
659
|
+
originalText: profileState.text,
|
|
660
|
+
originalIsSymbolicLink: profileState.isSymbolicLink,
|
|
661
|
+
});
|
|
662
|
+
return {
|
|
663
|
+
profile,
|
|
664
|
+
profilePath: savedProfilePath,
|
|
665
|
+
savedProfilePath,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
295
669
|
async function promptProfilePath(promptAdapter, output, prompt) {
|
|
296
|
-
let
|
|
670
|
+
let profileState = null;
|
|
297
671
|
const profilePath = await askUntilValid(promptAdapter, prompt, {
|
|
298
672
|
output,
|
|
299
673
|
normalize: normalizeTextAnswer,
|
|
300
674
|
async validate(value) {
|
|
301
|
-
profile
|
|
675
|
+
if (!value) throw new Error("Repository profile path is required.");
|
|
676
|
+
try {
|
|
677
|
+
profileState = await inspectProfilePath(value);
|
|
678
|
+
} catch (error) {
|
|
679
|
+
if (error.message?.startsWith("profile must be valid JSON")) {
|
|
680
|
+
throw error;
|
|
681
|
+
}
|
|
682
|
+
if (error.message?.startsWith("invalid ")) {
|
|
683
|
+
throw new Error(`profile is invalid: ${error.message}`);
|
|
684
|
+
}
|
|
685
|
+
throw new Error(`profile could not be read: ${error.message}`);
|
|
686
|
+
}
|
|
302
687
|
},
|
|
303
688
|
});
|
|
304
|
-
return { profilePath,
|
|
689
|
+
return { profilePath, ...profileState };
|
|
305
690
|
}
|
|
306
691
|
|
|
307
692
|
function hasOwnOption(options, key) {
|
|
@@ -321,6 +706,7 @@ export async function collectInteractiveAnalyzeGithubOptions(options, {
|
|
|
321
706
|
input = process.stdin,
|
|
322
707
|
output = process.stderr,
|
|
323
708
|
isInteractiveTerminal = Boolean(input?.isTTY),
|
|
709
|
+
onSavedProfilePath = null,
|
|
324
710
|
} = {}) {
|
|
325
711
|
if (!isInteractiveTerminal) {
|
|
326
712
|
throw new Error("interactive mode requires a terminal. Re-run with --repo <owner/name> --limit <1-100> --profile <path> --out <directory>, or provide a TTY for prompts.");
|
|
@@ -331,6 +717,7 @@ export async function collectInteractiveAnalyzeGithubOptions(options, {
|
|
|
331
717
|
const resolved = { ...options };
|
|
332
718
|
delete resolved.interactivePromptDefaults;
|
|
333
719
|
let repositoryProfile = null;
|
|
720
|
+
let profileState = null;
|
|
334
721
|
|
|
335
722
|
try {
|
|
336
723
|
if (!resolved.repository) {
|
|
@@ -364,11 +751,34 @@ export async function collectInteractiveAnalyzeGithubOptions(options, {
|
|
|
364
751
|
type: "path",
|
|
365
752
|
message: "Repository profile path",
|
|
366
753
|
});
|
|
367
|
-
|
|
368
|
-
repositoryProfile = prompted.profile;
|
|
754
|
+
profileState = prompted;
|
|
369
755
|
} else {
|
|
370
|
-
|
|
756
|
+
try {
|
|
757
|
+
profileState = {
|
|
758
|
+
profilePath: resolved.profilePath,
|
|
759
|
+
...await inspectProfilePath(resolved.profilePath),
|
|
760
|
+
};
|
|
761
|
+
} catch (error) {
|
|
762
|
+
if (error.message?.startsWith("profile must be valid JSON")) {
|
|
763
|
+
throw error;
|
|
764
|
+
}
|
|
765
|
+
if (error.message?.startsWith("invalid ")) {
|
|
766
|
+
throw new Error(`profile is invalid: ${error.message}`);
|
|
767
|
+
}
|
|
768
|
+
throw new Error(`profile could not be read: ${error.message}`);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
resolved.profilePath = profileState.profilePath;
|
|
772
|
+
|
|
773
|
+
const profileUpdate = await maybeConfigureInteractiveProfile(adapter, output, profileState, resolved.repository);
|
|
774
|
+
resolved.profilePath = profileUpdate.profilePath;
|
|
775
|
+
if (profileUpdate.savedProfilePath) {
|
|
776
|
+
resolved.savedProfilePath = profileUpdate.savedProfilePath;
|
|
777
|
+
if (typeof onSavedProfilePath === "function") {
|
|
778
|
+
onSavedProfilePath(profileUpdate.savedProfilePath);
|
|
779
|
+
}
|
|
371
780
|
}
|
|
781
|
+
repositoryProfile = profileUpdate.profile;
|
|
372
782
|
|
|
373
783
|
if (!resolved.outDir) {
|
|
374
784
|
resolved.outDir = await askUntilValid(adapter, {
|
|
@@ -612,8 +1022,8 @@ function attachCollectionCoverage(report, sourceBundle) {
|
|
|
612
1022
|
};
|
|
613
1023
|
}
|
|
614
1024
|
|
|
615
|
-
function summarizeResult({ dryRun, outDir, paths, sourceBundle, metrics, report, requestedLimit, sampledLimit, csv, analysisFilter }) {
|
|
616
|
-
|
|
1025
|
+
function summarizeResult({ dryRun, outDir, paths, sourceBundle, metrics, report, requestedLimit, sampledLimit, csv, analysisFilter, savedProfilePath }) {
|
|
1026
|
+
const summary = {
|
|
617
1027
|
ok: true,
|
|
618
1028
|
dryRun,
|
|
619
1029
|
csvArtifactsEnabled: Boolean(csv),
|
|
@@ -628,6 +1038,10 @@ function summarizeResult({ dryRun, outDir, paths, sourceBundle, metrics, report,
|
|
|
628
1038
|
totals: metrics?.totals ?? null,
|
|
629
1039
|
topBottleneckIds: report?.summary?.topBottleneckIds ?? null,
|
|
630
1040
|
};
|
|
1041
|
+
if (savedProfilePath) {
|
|
1042
|
+
summary.savedProfilePath = savedProfilePath;
|
|
1043
|
+
}
|
|
1044
|
+
return summary;
|
|
631
1045
|
}
|
|
632
1046
|
|
|
633
1047
|
function applyPrClassFilter(normalized, excludedPrClasses = []) {
|
|
@@ -699,6 +1113,7 @@ export async function runAnalyzeGithub(options, {
|
|
|
699
1113
|
sampledLimit: collectionLimit,
|
|
700
1114
|
csv: false,
|
|
701
1115
|
analysisFilter: null,
|
|
1116
|
+
savedProfilePath: options.savedProfilePath,
|
|
702
1117
|
});
|
|
703
1118
|
}
|
|
704
1119
|
|
|
@@ -747,6 +1162,7 @@ export async function runAnalyzeGithub(options, {
|
|
|
747
1162
|
sampledLimit: collectionLimit,
|
|
748
1163
|
csv: csvEnabled,
|
|
749
1164
|
analysisFilter: normalized.analysisFilter ?? null,
|
|
1165
|
+
savedProfilePath: options.savedProfilePath,
|
|
750
1166
|
});
|
|
751
1167
|
}
|
|
752
1168
|
|
|
@@ -816,6 +1232,10 @@ export function formatAnalyzeGithubCompletion(result) {
|
|
|
816
1232
|
);
|
|
817
1233
|
}
|
|
818
1234
|
|
|
1235
|
+
if (result.savedProfilePath) {
|
|
1236
|
+
lines.push(`Repository profile saved: ${result.savedProfilePath}.`);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
819
1239
|
lines.push(`Collection coverage: ${result.collectionCoverage?.status ?? "unknown"}.`);
|
|
820
1240
|
|
|
821
1241
|
const caveats = coverageCaveats(result.collectionCoverage);
|
|
@@ -847,36 +1267,50 @@ export async function runAnalyzeGithubCli(argv, {
|
|
|
847
1267
|
promptAdapter = null,
|
|
848
1268
|
isInteractiveTerminal = Boolean(stdin?.isTTY),
|
|
849
1269
|
} = {}) {
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
? await collectInteractiveAnalyzeGithubOptions({
|
|
858
|
-
...options,
|
|
859
|
-
interactivePromptDefaults: {
|
|
860
|
-
dryRun: !options.dryRun,
|
|
861
|
-
csv: options.csv !== false,
|
|
862
|
-
json: !options.json,
|
|
863
|
-
},
|
|
864
|
-
}, {
|
|
865
|
-
promptAdapter,
|
|
866
|
-
input: stdin,
|
|
867
|
-
output: stderr,
|
|
868
|
-
isInteractiveTerminal,
|
|
869
|
-
})
|
|
870
|
-
: options;
|
|
871
|
-
const runOptions = {
|
|
872
|
-
onProgress: message => writeProgress(message, stderr),
|
|
873
|
-
};
|
|
874
|
-
if (provider !== undefined) runOptions.provider = provider;
|
|
875
|
-
if (now !== undefined) runOptions.now = now;
|
|
1270
|
+
let savedProfilePath = null;
|
|
1271
|
+
try {
|
|
1272
|
+
const options = parseAnalyzeGithubArgs(argv);
|
|
1273
|
+
if (options.help) {
|
|
1274
|
+
stdout.write(USAGE);
|
|
1275
|
+
return null;
|
|
1276
|
+
}
|
|
876
1277
|
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
1278
|
+
const resolvedOptions = options.interactive
|
|
1279
|
+
? await collectInteractiveAnalyzeGithubOptions({
|
|
1280
|
+
...options,
|
|
1281
|
+
interactivePromptDefaults: {
|
|
1282
|
+
dryRun: !options.dryRun,
|
|
1283
|
+
csv: options.csv !== false,
|
|
1284
|
+
json: !options.json,
|
|
1285
|
+
},
|
|
1286
|
+
}, {
|
|
1287
|
+
promptAdapter,
|
|
1288
|
+
input: stdin,
|
|
1289
|
+
output: stderr,
|
|
1290
|
+
isInteractiveTerminal,
|
|
1291
|
+
onSavedProfilePath(path) {
|
|
1292
|
+
savedProfilePath = path;
|
|
1293
|
+
},
|
|
1294
|
+
})
|
|
1295
|
+
: options;
|
|
1296
|
+
if (resolvedOptions.savedProfilePath) {
|
|
1297
|
+
savedProfilePath = resolvedOptions.savedProfilePath;
|
|
1298
|
+
}
|
|
1299
|
+
const runOptions = {
|
|
1300
|
+
onProgress: message => writeProgress(message, stderr),
|
|
1301
|
+
};
|
|
1302
|
+
if (provider !== undefined) runOptions.provider = provider;
|
|
1303
|
+
if (now !== undefined) runOptions.now = now;
|
|
1304
|
+
|
|
1305
|
+
const result = await runAnalyzeGithub(resolvedOptions, runOptions);
|
|
1306
|
+
writeAnalyzeGithubCompletion(result, { json: resolvedOptions.json, stdout });
|
|
1307
|
+
return result;
|
|
1308
|
+
} catch (error) {
|
|
1309
|
+
if (savedProfilePath) {
|
|
1310
|
+
stderr.write(`Repository profile saved before failure: ${savedProfilePath}.\n`);
|
|
1311
|
+
}
|
|
1312
|
+
throw error;
|
|
1313
|
+
}
|
|
880
1314
|
}
|
|
881
1315
|
|
|
882
1316
|
async function main(argv) {
|