dev-loops 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/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/agents/refiner.md +1 -0
- package/.claude/skills/dev-loop/SKILL.md +16 -6
- package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/.claude/skills/docs/artifact-authority-contract.md +86 -31
- package/.claude/skills/docs/local-planning-flow.md +63 -0
- package/.claude/skills/docs/local-planning-worked-example.md +139 -0
- package/.claude/skills/docs/merge-preconditions.md +35 -0
- package/.claude/skills/docs/plan-file-contract.md +37 -0
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/.claude/skills/docs/spike-mode-contract.md +237 -0
- package/CHANGELOG.md +43 -0
- package/README.md +1 -0
- package/agents/refiner.agent.md +1 -0
- package/package.json +5 -3
- package/scripts/github/capture-review-threads.mjs +20 -2
- package/scripts/github/probe-copilot-review.mjs +69 -3
- package/scripts/github/upsert-checkpoint-verdict.mjs +18 -3
- package/scripts/lib/jq-output.mjs +297 -0
- package/scripts/loop/check-retro-tooling.mjs +246 -0
- package/scripts/loop/copilot-pr-handoff.mjs +21 -3
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -2
- package/scripts/loop/docs-grill-contract.mjs +70 -0
- package/scripts/loop/info.mjs +21 -2
- package/scripts/loop/pr-runner-coordination.mjs +20 -4
- package/scripts/loop/resolve-dev-loop-startup.mjs +176 -5
- package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
- package/scripts/loop/run-watch-cycle.mjs +77 -3
- package/scripts/loop/slides-story-review-contract.mjs +123 -0
- package/scripts/pages/build-site.mjs +136 -0
- package/scripts/projects/add-queue-item.mjs +12 -2
- package/scripts/projects/list-queue-items.mjs +12 -2
- package/scripts/projects/move-queue-item.mjs +12 -2
- package/scripts/refine/_refine-helpers.mjs +20 -0
- package/scripts/refine/exit-spike.mjs +186 -0
- package/scripts/refine/promote-plan.mjs +387 -0
- package/scripts/refine/refine-plan-file.mjs +165 -0
- package/scripts/refine/validate-plan-file.mjs +64 -0
- package/scripts/refine/validate-spike-file.mjs +87 -0
- package/skills/dev-loop/SKILL.md +11 -1
- package/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/skills/docs/artifact-authority-contract.md +86 -31
- package/skills/docs/local-planning-flow.md +63 -0
- package/skills/docs/local-planning-worked-example.md +139 -0
- package/skills/docs/merge-preconditions.md +35 -0
- package/skills/docs/plan-file-contract.md +37 -0
- package/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/skills/docs/spike-mode-contract.md +237 -0
|
@@ -23,18 +23,37 @@ import { detectRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
|
23
23
|
import { isCopilotLogin } from "@dev-loops/core/github/copilot-helpers";
|
|
24
24
|
import { loadDevLoopConfig, resolveWorkflowConfig } from "@dev-loops/core/config";
|
|
25
25
|
import { createPiAdapter } from "@dev-loops/core/harness";
|
|
26
|
+
import { validatePlanFile } from "../refine/validate-plan-file.mjs";
|
|
27
|
+
import {
|
|
28
|
+
validateSpikeExplorationSections,
|
|
29
|
+
SPIKE_FILE_EXIT_MARKER_SECTION,
|
|
30
|
+
} from "../refine/validate-spike-file.mjs";
|
|
31
|
+
import { extractSection } from "../refine/_refine-helpers.mjs";
|
|
32
|
+
import {
|
|
33
|
+
evaluatePlanFileIntakeState,
|
|
34
|
+
PLAN_FILE_REFINEMENT_SECTIONS,
|
|
35
|
+
} from "@dev-loops/core/loop/plan-file-intake-contract";
|
|
36
|
+
import { evaluateSpikeIntakeState } from "@dev-loops/core/loop/spike-intake-contract";
|
|
26
37
|
import { parseArgs } from "node:util";
|
|
27
38
|
const USAGE = `Usage:
|
|
28
39
|
resolve-dev-loop-startup.mjs --issue <number>
|
|
29
40
|
resolve-dev-loop-startup.mjs --pr <number>
|
|
30
41
|
resolve-dev-loop-startup.mjs --input <path>
|
|
42
|
+
resolve-dev-loop-startup.mjs --plan-file <path>
|
|
43
|
+
resolve-dev-loop-startup.mjs --spike <path>
|
|
31
44
|
Resolve the authoritative public dev-loop startup/resume bundle.
|
|
32
45
|
Auto-resolves state from GitHub API, git remote, and settings when
|
|
33
46
|
--issue or --pr is used. Use --input for non-standard states.
|
|
47
|
+
Use --plan-file to start local planning from a phase-doc-format plan
|
|
48
|
+
(read-only: no tracker mutation, no issue/PR number).
|
|
49
|
+
Use --spike to start a time-boxed exploratory loop from a local spike
|
|
50
|
+
artifact (read-only: no tracker mutation, no issue/PR number).
|
|
34
51
|
Required (exactly one):
|
|
35
52
|
--issue <n> Target an issue by number (auto-resolves all state)
|
|
36
53
|
--pr <n> Target a PR by number (auto-resolves all state)
|
|
37
54
|
--input <path> Path to a JSON file with canonical-state payload
|
|
55
|
+
--plan-file <path> Path to a phase-doc-format plan to start locally
|
|
56
|
+
--spike <path> Path to a spike artifact to start a spike loop locally
|
|
38
57
|
Exit codes:
|
|
39
58
|
0 Success
|
|
40
59
|
1 Argument error, runtime failure, or async-start contract rejection`.trim();
|
|
@@ -102,6 +121,8 @@ export function parseResolveDevLoopStartupCliArgs(argv) {
|
|
|
102
121
|
inputPath: undefined,
|
|
103
122
|
issue: undefined,
|
|
104
123
|
pr: undefined,
|
|
124
|
+
planFile: undefined,
|
|
125
|
+
spike: undefined,
|
|
105
126
|
};
|
|
106
127
|
const { tokens } = parseArgs({
|
|
107
128
|
args: [...argv],
|
|
@@ -110,6 +131,8 @@ export function parseResolveDevLoopStartupCliArgs(argv) {
|
|
|
110
131
|
input: { type: "string" },
|
|
111
132
|
issue: { type: "string" },
|
|
112
133
|
pr: { type: "string" },
|
|
134
|
+
"plan-file": { type: "string" },
|
|
135
|
+
spike: { type: "string" },
|
|
113
136
|
},
|
|
114
137
|
allowPositionals: true,
|
|
115
138
|
strict: false,
|
|
@@ -138,14 +161,22 @@ export function parseResolveDevLoopStartupCliArgs(argv) {
|
|
|
138
161
|
options.pr = parsePositiveInteger(requireTokenValue(token, parseError), "--pr", parseError);
|
|
139
162
|
continue;
|
|
140
163
|
}
|
|
164
|
+
if (token.name === "plan-file") {
|
|
165
|
+
options.planFile = requireTokenValue(token, parseError);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (token.name === "spike") {
|
|
169
|
+
options.spike = requireTokenValue(token, parseError);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
141
172
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
142
173
|
}
|
|
143
|
-
const modeCount = [options.inputPath, options.issue, options.pr].filter(v => v !== undefined).length;
|
|
174
|
+
const modeCount = [options.inputPath, options.issue, options.pr, options.planFile, options.spike].filter(v => v !== undefined).length;
|
|
144
175
|
if (modeCount > 1) {
|
|
145
|
-
throw parseError("--issue, --pr, and --
|
|
176
|
+
throw parseError("--issue, --pr, --input, --plan-file, and --spike are mutually exclusive; provide exactly one");
|
|
146
177
|
}
|
|
147
178
|
if (modeCount === 0) {
|
|
148
|
-
throw parseError("--input <path>, --issue <n>, or --
|
|
179
|
+
throw parseError("--input <path>, --issue <n>, --pr <n>, --plan-file <path>, or --spike <path> is required");
|
|
149
180
|
}
|
|
150
181
|
return options;
|
|
151
182
|
}
|
|
@@ -368,6 +399,118 @@ export function buildAutoResolvedInput({ issue, pr, cwd, targetPreference, input
|
|
|
368
399
|
},
|
|
369
400
|
};
|
|
370
401
|
}
|
|
402
|
+
/**
|
|
403
|
+
* Read + validate a `--plan-file` path and build a local_phase startup input.
|
|
404
|
+
*
|
|
405
|
+
* Read-only: no tracker mutation, no GitHub calls, no issue/PR number. A
|
|
406
|
+
* missing/unreadable file, or one failing the base-section validator, throws so
|
|
407
|
+
* the CLI fails closed (exit 1, no readiness bundle). The plan-file path is
|
|
408
|
+
* carried as the target `phase` and is exempt from the worktree-isolation guard
|
|
409
|
+
* because there is no issue to key a worktree on before promotion.
|
|
410
|
+
*
|
|
411
|
+
* @returns {object} startup input with a `planFileIntakeState` field threaded onto output
|
|
412
|
+
*/
|
|
413
|
+
export function buildPlanFileInput({ planFilePath }) {
|
|
414
|
+
const resolvedPath = path.resolve(planFilePath);
|
|
415
|
+
let markdownText;
|
|
416
|
+
try {
|
|
417
|
+
markdownText = readFileSync(resolvedPath, "utf8");
|
|
418
|
+
} catch (err) {
|
|
419
|
+
throw new Error(`Plan file is missing or unreadable: ${resolvedPath} (${err instanceof Error ? err.message : String(err)})`);
|
|
420
|
+
}
|
|
421
|
+
const validation = validatePlanFile(markdownText);
|
|
422
|
+
if (!validation.ok) {
|
|
423
|
+
const codes = validation.errors.map(e => e.code).join(", ");
|
|
424
|
+
throw new Error(`Plan file failed validation (${resolvedPath}): ${codes}`);
|
|
425
|
+
}
|
|
426
|
+
// Refined-vs-needs-refinement: refinement adds Acceptance criteria + Definition
|
|
427
|
+
// of done on top of the base authoring sections. Detect each via extractSection
|
|
428
|
+
// (non-null trimmed body == present and non-empty), then classify with the pure
|
|
429
|
+
// intake evaluator.
|
|
430
|
+
const [acHeading, dodHeading] = PLAN_FILE_REFINEMENT_SECTIONS;
|
|
431
|
+
const hasAcceptanceCriteria = extractSection(markdownText, acHeading) ? true : false;
|
|
432
|
+
const hasDefinitionOfDone = extractSection(markdownText, dodHeading) ? true : false;
|
|
433
|
+
const { state: planFileIntakeState } = evaluatePlanFileIntakeState({
|
|
434
|
+
baseSectionsValid: true,
|
|
435
|
+
hasAcceptanceCriteria,
|
|
436
|
+
hasDefinitionOfDone,
|
|
437
|
+
});
|
|
438
|
+
return {
|
|
439
|
+
intent: "start_issue_locally",
|
|
440
|
+
mode: "bounded_handoff",
|
|
441
|
+
targetPreference: "prefer_local",
|
|
442
|
+
artifactState: "not_applicable",
|
|
443
|
+
issueLinkageResolution: "not_applicable",
|
|
444
|
+
issueReadiness: "not_applicable",
|
|
445
|
+
issueAssignmentState: "not_applicable",
|
|
446
|
+
loopState: "implementation_pending",
|
|
447
|
+
planFileIntakeState,
|
|
448
|
+
planFileExempt: true,
|
|
449
|
+
currentState: {
|
|
450
|
+
target: { kind: "local_phase", issue: null, pr: null, linkedPr: null, branch: null, phase: resolvedPath },
|
|
451
|
+
ownership: "local",
|
|
452
|
+
nextActor: "local",
|
|
453
|
+
status: "active",
|
|
454
|
+
authorization: "authorized",
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Read + validate a `--spike` path and build a local_phase startup input for a
|
|
460
|
+
* time-boxed exploratory loop.
|
|
461
|
+
*
|
|
462
|
+
* Read-only: no tracker mutation, no GitHub calls, no issue/PR number — a spike
|
|
463
|
+
* is startable from a local question with no GitHub issue and no production-gate
|
|
464
|
+
* ceremony at entry. A missing/unreadable file, or one missing the exploration
|
|
465
|
+
* scaffold (Question/Approach/Findings), throws so the CLI fails closed (exit 1,
|
|
466
|
+
* no readiness bundle). The Recommendation section is filled in during the spike;
|
|
467
|
+
* its presence flips the intake state to spike_ready_for_exit (the seam phase 2's
|
|
468
|
+
* discard/graduate exits consume). The spike path is carried as the target
|
|
469
|
+
* `phase` and is exempt from the worktree-isolation guard because there is no
|
|
470
|
+
* issue to key a worktree on.
|
|
471
|
+
*
|
|
472
|
+
* @returns {object} startup input with a `spikeIntakeState` field threaded onto output
|
|
473
|
+
*/
|
|
474
|
+
export function buildSpikeInput({ spikeFilePath }) {
|
|
475
|
+
const resolvedPath = path.resolve(spikeFilePath);
|
|
476
|
+
let markdownText;
|
|
477
|
+
try {
|
|
478
|
+
markdownText = readFileSync(resolvedPath, "utf8");
|
|
479
|
+
} catch (err) {
|
|
480
|
+
throw new Error(`Spike file is missing or unreadable: ${resolvedPath} (${err instanceof Error ? err.message : String(err)})`);
|
|
481
|
+
}
|
|
482
|
+
// Entry gate: the exploration scaffold must be present and non-empty. A
|
|
483
|
+
// malformed spike artifact fails closed before any intake classification.
|
|
484
|
+
const validation = validateSpikeExplorationSections(markdownText);
|
|
485
|
+
if (!validation.ok) {
|
|
486
|
+
const codes = validation.errors.map(e => e.code).join(", ");
|
|
487
|
+
throw new Error(`Spike file failed validation (${resolvedPath}): ${codes}`);
|
|
488
|
+
}
|
|
489
|
+
const hasRecommendation = extractSection(markdownText, SPIKE_FILE_EXIT_MARKER_SECTION) ? true : false;
|
|
490
|
+
const { state: spikeIntakeState } = evaluateSpikeIntakeState({
|
|
491
|
+
baseSectionsValid: true,
|
|
492
|
+
hasRecommendation,
|
|
493
|
+
});
|
|
494
|
+
return {
|
|
495
|
+
intent: "start_issue_locally",
|
|
496
|
+
mode: "bounded_handoff",
|
|
497
|
+
targetPreference: "prefer_local",
|
|
498
|
+
artifactState: "not_applicable",
|
|
499
|
+
issueLinkageResolution: "not_applicable",
|
|
500
|
+
issueReadiness: "not_applicable",
|
|
501
|
+
issueAssignmentState: "not_applicable",
|
|
502
|
+
loopState: "implementation_pending",
|
|
503
|
+
spikeIntakeState,
|
|
504
|
+
planFileExempt: true,
|
|
505
|
+
currentState: {
|
|
506
|
+
target: { kind: "local_phase", issue: null, pr: null, linkedPr: null, branch: null, phase: resolvedPath },
|
|
507
|
+
ownership: "local",
|
|
508
|
+
nextActor: "local",
|
|
509
|
+
status: "active",
|
|
510
|
+
authorization: "authorized",
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
}
|
|
371
514
|
export function summarizeCanonicalState(bundle) {
|
|
372
515
|
return {
|
|
373
516
|
target: bundle.canonicalState?.target ?? null,
|
|
@@ -390,6 +533,16 @@ export function summarizeCanonicalState(bundle) {
|
|
|
390
533
|
export function buildResolveDevLoopStartupResult(input, { adapter = createPiAdapter(), env, cwd, asyncStartMode = "required" } = {}) {
|
|
391
534
|
const effectiveEnv = env ?? adapter.getEnv();
|
|
392
535
|
const effectiveCwd = cwd ?? adapter.getCwd();
|
|
536
|
+
// Normalize a non-object input (e.g. `--input null`, which parses to a legal
|
|
537
|
+
// JSON null) to {} so the destructure below cannot throw before routing can
|
|
538
|
+
// fail closed with a structured reconcile bundle.
|
|
539
|
+
if (input === null || typeof input !== "object") input = {};
|
|
540
|
+
// Plan-file intake carries two resolver-only fields that the pure routing
|
|
541
|
+
// evaluator does not model. Strip them before evaluation and re-apply them to
|
|
542
|
+
// the result; `planFileExempt` waives the worktree-isolation guard because a
|
|
543
|
+
// pre-promotion plan has no issue to key a worktree on.
|
|
544
|
+
const { planFileExempt = false, planFileIntakeState = null, spikeIntakeState = null, ...routingInput } = input;
|
|
545
|
+
input = routingInput;
|
|
393
546
|
try {
|
|
394
547
|
const checkpointText = readFileSync(
|
|
395
548
|
path.join(effectiveCwd, ".pi", "dev-loop-retrospective-checkpoint.json"),
|
|
@@ -440,6 +593,7 @@ export function buildResolveDevLoopStartupResult(input, { adapter = createPiAdap
|
|
|
440
593
|
const DEVLOOPS_WORKTREE_BYPASS_VAR = "DEVLOOPS_WORKTREE_BYPASS";
|
|
441
594
|
if (
|
|
442
595
|
strategyKey === "local_implementation" &&
|
|
596
|
+
!planFileExempt &&
|
|
443
597
|
(effectiveEnv[DEVLOOPS_WORKTREE_BYPASS_VAR] ?? "").trim() !== "1"
|
|
444
598
|
) {
|
|
445
599
|
try {
|
|
@@ -496,6 +650,8 @@ export function buildResolveDevLoopStartupResult(input, { adapter = createPiAdap
|
|
|
496
650
|
requiredReads: STRATEGY_REQUIRED_READS[strategyKey],
|
|
497
651
|
nextAction: bundle.nextAction,
|
|
498
652
|
canonicalStateSummary: summarizeCanonicalState(bundle),
|
|
653
|
+
...(planFileIntakeState !== null ? { planFileIntakeState } : {}),
|
|
654
|
+
...(spikeIntakeState !== null ? { spikeIntakeState } : {}),
|
|
499
655
|
bundle,
|
|
500
656
|
};
|
|
501
657
|
}
|
|
@@ -521,9 +677,24 @@ export async function runCli(argv = process.argv.slice(2), { stdout = process.st
|
|
|
521
677
|
? normalizeConfigInputSource(devLoopConfig?.inputSource?.default)
|
|
522
678
|
: "tracker";
|
|
523
679
|
let input;
|
|
524
|
-
if (options.
|
|
680
|
+
if (options.spike !== undefined) {
|
|
681
|
+
input = buildSpikeInput({ spikeFilePath: options.spike });
|
|
682
|
+
} else if (options.planFile !== undefined) {
|
|
683
|
+
input = buildPlanFileInput({ planFilePath: options.planFile });
|
|
684
|
+
} else if (options.inputPath !== undefined) {
|
|
525
685
|
const text = await readFile(path.resolve(options.inputPath), "utf8");
|
|
526
|
-
|
|
686
|
+
const parsed = parseJsonText(text);
|
|
687
|
+
// `--input` is untrusted external JSON. Strip the resolver-only intake fields
|
|
688
|
+
// so it cannot inject them — `planFileExempt` would otherwise waive the
|
|
689
|
+
// worktree-isolation guard for a normal local_implementation, and the intake
|
|
690
|
+
// state is owned by the internal plan-file path (buildPlanFileInput), not the
|
|
691
|
+
// caller.
|
|
692
|
+
if (parsed && typeof parsed === "object") {
|
|
693
|
+
delete parsed.planFileExempt;
|
|
694
|
+
delete parsed.planFileIntakeState;
|
|
695
|
+
delete parsed.spikeIntakeState;
|
|
696
|
+
}
|
|
697
|
+
input = parsed;
|
|
527
698
|
} else if (options.issue !== undefined) {
|
|
528
699
|
input = buildAutoResolvedInput({
|
|
529
700
|
issue: options.issue,
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
|
|
7
|
+
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
8
|
+
import { requireTokenValue } from "../_cli-primitives.mjs";
|
|
9
|
+
|
|
10
|
+
const USAGE = `Usage: resolve-pr-conflicts.mjs [--base <branch>] [--repo-root <dir>] [--no-verify] [--push] [--json]
|
|
11
|
+
|
|
12
|
+
Deterministic, conservative auto-resolve for a PR branch that is behind/CONFLICTING
|
|
13
|
+
with its base. Merges \`origin/<base>\` into the current branch and resolves ONLY the
|
|
14
|
+
safe additive case: a CHANGELOG.md conflict where both sides only ADD list/section
|
|
15
|
+
entries (keep BOTH sides, in order). ANY other conflicted path — or a non-additive
|
|
16
|
+
CHANGELOG edit — FAILS CLOSED, naming the conflicted paths. Never guesses.
|
|
17
|
+
|
|
18
|
+
After a clean merge (or an auto-resolved additive CHANGELOG) it runs \`npm run test:docs\`
|
|
19
|
+
(unless --no-verify) and, with --push, pushes the branch.
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--base <branch> Base branch to merge from (default: derived from \`gh pr view\`
|
|
23
|
+
base, falling back to "main").
|
|
24
|
+
--repo-root <dir> Repo working tree to operate in (default: cwd).
|
|
25
|
+
--no-verify Skip \`npm run test:docs\` after resolving.
|
|
26
|
+
--push Push the resolved branch to origin after verify.
|
|
27
|
+
--json Emit machine-readable JSON on stdout.
|
|
28
|
+
|
|
29
|
+
Output (stdout, JSON with --json):
|
|
30
|
+
{ "ok": true, "action": "clean_merge"|"resolved",
|
|
31
|
+
"base": "main", "resolvedFiles": ["CHANGELOG.md"], "pushed": false,
|
|
32
|
+
"verified": true }
|
|
33
|
+
("clean_merge" covers an already-up-to-date branch; "resolved" is the
|
|
34
|
+
auto-resolved additive-CHANGELOG case. "verified" is true when post-resolve
|
|
35
|
+
verification ran; it is absent with --no-verify.)
|
|
36
|
+
Error output:
|
|
37
|
+
{ "ok": false, "error": "...", "conflictFiles": ["..."] }
|
|
38
|
+
|
|
39
|
+
Exit codes:
|
|
40
|
+
0 Clean merge (incl. already up to date) or auto-resolved CHANGELOG
|
|
41
|
+
1 Argument error, git failure, or UNRESOLVABLE conflict (fail closed)`.trim();
|
|
42
|
+
|
|
43
|
+
const parseError = buildParseError(USAGE);
|
|
44
|
+
|
|
45
|
+
const SAFE_RESOLVABLE_PATH = "CHANGELOG.md";
|
|
46
|
+
|
|
47
|
+
// Identity for merge/resolve commits so auto-resolve works on a clean runner
|
|
48
|
+
// (CI / consumer) where no ambient git user is configured. Per-command via -c
|
|
49
|
+
// so we never mutate the repo's config.
|
|
50
|
+
const BOT_IDENTITY = [
|
|
51
|
+
"-c", "user.name=dev-loops[bot]",
|
|
52
|
+
"-c", "user.email=dev-loops[bot]@users.noreply.github.com",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
export function parseResolvePrConflictsCliArgs(argv) {
|
|
56
|
+
const options = {
|
|
57
|
+
help: false,
|
|
58
|
+
base: null,
|
|
59
|
+
repoRoot: process.cwd(),
|
|
60
|
+
verify: true,
|
|
61
|
+
push: false,
|
|
62
|
+
json: false,
|
|
63
|
+
};
|
|
64
|
+
const { tokens } = parseArgs({
|
|
65
|
+
args: [...argv],
|
|
66
|
+
options: {
|
|
67
|
+
help: { type: "boolean", short: "h" },
|
|
68
|
+
base: { type: "string" },
|
|
69
|
+
"repo-root": { type: "string" },
|
|
70
|
+
"no-verify": { type: "boolean" },
|
|
71
|
+
push: { type: "boolean" },
|
|
72
|
+
json: { type: "boolean" },
|
|
73
|
+
},
|
|
74
|
+
allowPositionals: true,
|
|
75
|
+
strict: false,
|
|
76
|
+
tokens: true,
|
|
77
|
+
});
|
|
78
|
+
for (const token of tokens) {
|
|
79
|
+
if (token.kind === "positional") throw parseError(`Unknown argument: ${token.value}`);
|
|
80
|
+
if (token.kind !== "option") continue;
|
|
81
|
+
switch (token.name) {
|
|
82
|
+
case "help":
|
|
83
|
+
options.help = true;
|
|
84
|
+
return options;
|
|
85
|
+
case "base": {
|
|
86
|
+
const value = requireTokenValue(token, parseError, { flagPattern: /^-/u }).trim();
|
|
87
|
+
options.base = value.length > 0 ? value : null;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
case "repo-root": {
|
|
91
|
+
const value = requireTokenValue(token, parseError, { flagPattern: /^-/u }).trim();
|
|
92
|
+
options.repoRoot = value.length > 0 ? value : process.cwd();
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
case "no-verify":
|
|
96
|
+
options.verify = false;
|
|
97
|
+
break;
|
|
98
|
+
case "push":
|
|
99
|
+
options.push = true;
|
|
100
|
+
break;
|
|
101
|
+
case "json":
|
|
102
|
+
options.json = true;
|
|
103
|
+
break;
|
|
104
|
+
default:
|
|
105
|
+
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return options;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function run(command, args, { cwd, env = process.env } = {}) {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
114
|
+
let stdout = "";
|
|
115
|
+
let stderr = "";
|
|
116
|
+
child.stdout.on("data", (chunk) => { stdout += String(chunk); });
|
|
117
|
+
child.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
|
118
|
+
child.on("error", reject);
|
|
119
|
+
child.on("close", (code) => { resolve({ code, stdout, stderr }); });
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function listUnmergedFiles({ cwd, env }) {
|
|
124
|
+
const result = await run("git", ["diff", "--name-only", "--diff-filter=U"], { cwd, env });
|
|
125
|
+
if (result.code !== 0) {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
return result.stdout.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Decide whether a single conflicted file is the safe additive CHANGELOG case
|
|
133
|
+
* and, if so, produce the merged content (keep BOTH sides, in order).
|
|
134
|
+
*
|
|
135
|
+
* Requires diff3-style conflict markers so each hunk carries its merge BASE:
|
|
136
|
+
*
|
|
137
|
+
* <<<<<<< ours
|
|
138
|
+
* ...ours...
|
|
139
|
+
* ||||||| base
|
|
140
|
+
* ...base...
|
|
141
|
+
* =======
|
|
142
|
+
* ...theirs...
|
|
143
|
+
* >>>>>>> theirs
|
|
144
|
+
*
|
|
145
|
+
* STRUCTURAL additivity (not lexical): a hunk is safe to keep-both ONLY when its
|
|
146
|
+
* BASE section is EMPTY — a true insertion on both sides at a spot where base had
|
|
147
|
+
* nothing. A non-empty base means a shared line was MODIFIED or DELETED on at
|
|
148
|
+
* least one side → fail closed (a lexical "looks like a list item" check cannot
|
|
149
|
+
* tell an add from a modify, which silently duplicates/resurrects entries).
|
|
150
|
+
*
|
|
151
|
+
* Keep-both order: ours block then theirs block, preserving each side's order.
|
|
152
|
+
*/
|
|
153
|
+
export function resolveAdditiveChangelog(content) {
|
|
154
|
+
const lines = content.split("\n");
|
|
155
|
+
const out = [];
|
|
156
|
+
let i = 0;
|
|
157
|
+
let resolvedAnyHunk = false;
|
|
158
|
+
|
|
159
|
+
while (i < lines.length) {
|
|
160
|
+
const line = lines[i];
|
|
161
|
+
if (!line.startsWith("<<<<<<<")) {
|
|
162
|
+
out.push(line);
|
|
163
|
+
i += 1;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
// Conflict hunk: <<<<<<< ours ... ||||||| base ... ======= ... >>>>>>> theirs
|
|
167
|
+
const ours = [];
|
|
168
|
+
const base = [];
|
|
169
|
+
const theirs = [];
|
|
170
|
+
i += 1; // skip <<<<<<<
|
|
171
|
+
while (i < lines.length && !lines[i].startsWith("|||||||") && !lines[i].startsWith("=======")) {
|
|
172
|
+
ours.push(lines[i]);
|
|
173
|
+
i += 1;
|
|
174
|
+
}
|
|
175
|
+
if (i >= lines.length) {
|
|
176
|
+
return { safe: false, reason: "malformed conflict markers (no ======= separator)" };
|
|
177
|
+
}
|
|
178
|
+
if (!lines[i].startsWith("|||||||")) {
|
|
179
|
+
// No base section ⇒ not diff3 style; we cannot tell add from modify.
|
|
180
|
+
return { safe: false, reason: "missing diff3 base section (||||||| marker)" };
|
|
181
|
+
}
|
|
182
|
+
i += 1; // skip |||||||
|
|
183
|
+
while (i < lines.length && !lines[i].startsWith("=======")) {
|
|
184
|
+
base.push(lines[i]);
|
|
185
|
+
i += 1;
|
|
186
|
+
}
|
|
187
|
+
if (i >= lines.length) {
|
|
188
|
+
return { safe: false, reason: "malformed conflict markers (no ======= separator)" };
|
|
189
|
+
}
|
|
190
|
+
i += 1; // skip =======
|
|
191
|
+
while (i < lines.length && !lines[i].startsWith(">>>>>>>")) {
|
|
192
|
+
theirs.push(lines[i]);
|
|
193
|
+
i += 1;
|
|
194
|
+
}
|
|
195
|
+
if (i >= lines.length) {
|
|
196
|
+
return { safe: false, reason: "malformed conflict markers (no >>>>>>> terminator)" };
|
|
197
|
+
}
|
|
198
|
+
i += 1; // skip >>>>>>>
|
|
199
|
+
|
|
200
|
+
// Additive ⇔ base had no line here (both sides inserted). Any non-empty base
|
|
201
|
+
// section is a modify/delete of a shared line → fail closed.
|
|
202
|
+
if (base.some((b) => b.trim().length > 0)) {
|
|
203
|
+
return { safe: false, reason: "CHANGELOG conflict modifies or deletes a shared line (non-empty merge base)" };
|
|
204
|
+
}
|
|
205
|
+
// Keep both sides, in order.
|
|
206
|
+
out.push(...ours, ...theirs);
|
|
207
|
+
resolvedAnyHunk = true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!resolvedAnyHunk) {
|
|
211
|
+
return { safe: false, reason: "no conflict hunks found in CHANGELOG.md" };
|
|
212
|
+
}
|
|
213
|
+
return { safe: true, content: out.join("\n") };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function abortMerge({ cwd, env }) {
|
|
217
|
+
await run("git", ["merge", "--abort"], { cwd, env });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function resolvePrConflicts(options, { env = process.env } = {}) {
|
|
221
|
+
const cwd = options.repoRoot;
|
|
222
|
+
|
|
223
|
+
// Resolve base branch: explicit flag, else `gh pr view`, else "main".
|
|
224
|
+
let base = options.base;
|
|
225
|
+
if (!base) {
|
|
226
|
+
const view = await run("gh", ["pr", "view", "--json", "baseRefName"], { cwd, env });
|
|
227
|
+
if (view.code === 0) {
|
|
228
|
+
try {
|
|
229
|
+
const parsed = JSON.parse(view.stdout);
|
|
230
|
+
if (typeof parsed?.baseRefName === "string" && parsed.baseRefName.trim().length > 0) {
|
|
231
|
+
base = parsed.baseRefName.trim();
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
// fall through to default
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (!base) {
|
|
238
|
+
base = "main";
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const fetch = await run("git", ["fetch", "origin", base], { cwd, env });
|
|
243
|
+
if (fetch.code !== 0) {
|
|
244
|
+
throw new Error(`git fetch origin ${base} failed: ${fetch.stderr.trim() || `exit ${fetch.code}`}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// diff3 conflict style so conflict hunks carry the merge BASE — the additive
|
|
248
|
+
// CHANGELOG resolver needs it to tell a true insertion from a modify/delete.
|
|
249
|
+
const merge = await run("git", [...BOT_IDENTITY, "-c", "merge.conflictStyle=diff3", "merge", "--no-edit", `origin/${base}`], { cwd, env });
|
|
250
|
+
if (merge.code === 0) {
|
|
251
|
+
const result = { ok: true, action: "clean_merge", base, resolvedFiles: [], pushed: false };
|
|
252
|
+
await afterResolve(result, options, { cwd, env });
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Merge failed — inspect conflicts.
|
|
257
|
+
const conflictFiles = await listUnmergedFiles({ cwd, env });
|
|
258
|
+
if (conflictFiles.length === 0) {
|
|
259
|
+
await abortMerge({ cwd, env });
|
|
260
|
+
throw new Error(`git merge origin/${base} failed without resolvable conflicts: ${merge.stderr.trim() || `exit ${merge.code}`}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Fail closed on ANY conflicted path other than the single safe CHANGELOG case.
|
|
264
|
+
const nonSafe = conflictFiles.filter((file) => file !== SAFE_RESOLVABLE_PATH);
|
|
265
|
+
if (nonSafe.length > 0) {
|
|
266
|
+
await abortMerge({ cwd, env });
|
|
267
|
+
const err = new Error(
|
|
268
|
+
`Unresolvable merge conflict: only additive ${SAFE_RESOLVABLE_PATH} conflicts are auto-resolved. `
|
|
269
|
+
+ `Conflicting paths: ${conflictFiles.join(", ")}. Resolve manually.`,
|
|
270
|
+
);
|
|
271
|
+
err.conflictFiles = conflictFiles;
|
|
272
|
+
throw err;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Sole conflict is CHANGELOG.md — attempt the safe additive resolution.
|
|
276
|
+
const changelogPath = path.join(cwd, SAFE_RESOLVABLE_PATH);
|
|
277
|
+
const content = await readFile(changelogPath, "utf8");
|
|
278
|
+
const resolution = resolveAdditiveChangelog(content);
|
|
279
|
+
if (!resolution.safe) {
|
|
280
|
+
await abortMerge({ cwd, env });
|
|
281
|
+
const err = new Error(
|
|
282
|
+
`Unresolvable ${SAFE_RESOLVABLE_PATH} conflict (${resolution.reason}); not purely additive, so it fails closed. `
|
|
283
|
+
+ `Conflicting paths: ${SAFE_RESOLVABLE_PATH}. Resolve manually.`,
|
|
284
|
+
);
|
|
285
|
+
err.conflictFiles = [SAFE_RESOLVABLE_PATH];
|
|
286
|
+
throw err;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
await writeFile(changelogPath, resolution.content, "utf8");
|
|
290
|
+
const add = await run("git", ["add", SAFE_RESOLVABLE_PATH], { cwd, env });
|
|
291
|
+
if (add.code !== 0) {
|
|
292
|
+
await abortMerge({ cwd, env });
|
|
293
|
+
throw new Error(`git add ${SAFE_RESOLVABLE_PATH} failed: ${add.stderr.trim() || `exit ${add.code}`}`);
|
|
294
|
+
}
|
|
295
|
+
const commit = await run("git", [...BOT_IDENTITY, "commit", "--no-edit"], { cwd, env });
|
|
296
|
+
if (commit.code !== 0) {
|
|
297
|
+
await abortMerge({ cwd, env });
|
|
298
|
+
throw new Error(`git commit (merge) failed: ${commit.stderr.trim() || `exit ${commit.code}`}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const result = { ok: true, action: "resolved", base, resolvedFiles: [SAFE_RESOLVABLE_PATH], pushed: false };
|
|
302
|
+
await afterResolve(result, options, { cwd, env });
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function afterResolve(result, options, { cwd, env }) {
|
|
307
|
+
if (options.verify) {
|
|
308
|
+
const verify = await run("npm", ["run", "test:docs"], { cwd, env });
|
|
309
|
+
if (verify.code !== 0) {
|
|
310
|
+
const err = new Error(`Post-resolve verification (npm run test:docs) failed: ${verify.stderr.trim() || verify.stdout.trim() || `exit ${verify.code}`}`);
|
|
311
|
+
err.verifyFailed = true;
|
|
312
|
+
throw err;
|
|
313
|
+
}
|
|
314
|
+
result.verified = true;
|
|
315
|
+
}
|
|
316
|
+
if (options.push) {
|
|
317
|
+
const push = await run("git", ["push"], { cwd, env });
|
|
318
|
+
if (push.code !== 0) {
|
|
319
|
+
throw new Error(`git push failed: ${push.stderr.trim() || `exit ${push.code}`}`);
|
|
320
|
+
}
|
|
321
|
+
result.pushed = true;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env } = {}) {
|
|
326
|
+
let options;
|
|
327
|
+
try {
|
|
328
|
+
options = parseResolvePrConflictsCliArgs(argv);
|
|
329
|
+
} catch (error) {
|
|
330
|
+
stderr.write(`${formatCliError(error)}\n`);
|
|
331
|
+
return 1;
|
|
332
|
+
}
|
|
333
|
+
if (options.help) {
|
|
334
|
+
stdout.write(`${USAGE}\n`);
|
|
335
|
+
return 0;
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
const result = await resolvePrConflicts(options, { env });
|
|
339
|
+
if (options.json) {
|
|
340
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
341
|
+
} else {
|
|
342
|
+
stdout.write(`${result.action} (base ${result.base}${result.resolvedFiles.length ? `, resolved ${result.resolvedFiles.join(", ")}` : ""}${result.pushed ? ", pushed" : ""})\n`);
|
|
343
|
+
}
|
|
344
|
+
return 0;
|
|
345
|
+
} catch (error) {
|
|
346
|
+
const payload = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
347
|
+
if (Array.isArray(error?.conflictFiles)) {
|
|
348
|
+
payload.conflictFiles = error.conflictFiles;
|
|
349
|
+
}
|
|
350
|
+
stderr.write(`${JSON.stringify(payload)}\n`);
|
|
351
|
+
return 1;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
356
|
+
runCli(process.argv.slice(2)).then((code) => { process.exitCode = code; });
|
|
357
|
+
}
|