mandrel 1.84.0 → 1.86.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/.agents/docs/agentrc-reference.json +8 -2
- package/.agents/docs/configuration.md +7 -2
- package/.agents/instructions.md +4 -0
- package/.agents/rules/ci-remediation.md +131 -0
- package/.agents/rules/git-conventions.md +33 -0
- package/.agents/schemas/agentrc.schema.json +29 -6
- package/.agents/schemas/lifecycle/epic.watch.end.schema.json +2 -1
- package/.agents/scripts/boot-sweep.js +183 -0
- package/.agents/scripts/epic-deliver-prepare.js +55 -0
- package/.agents/scripts/git-pr-quality-gate.js +7 -5
- package/.agents/scripts/lib/config/ci.js +24 -3
- package/.agents/scripts/lib/config/explain.js +11 -3
- package/.agents/scripts/lib/config/github.js +11 -7
- package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
- package/.agents/scripts/lib/config-settings-schema.js +6 -6
- package/.agents/scripts/lib/orchestration/epic-cleanup.js +289 -1
- package/.agents/scripts/lib/orchestration/finalize/open-or-locate-pr.js +65 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +83 -30
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +401 -84
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/branch-cleaner.js +8 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +48 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +6 -1
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +172 -58
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +19 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -0
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/gate-failure.js +54 -6
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +35 -4
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +17 -16
- package/.agents/scripts/lib/single-story-sweep/protection-ctx.js +75 -0
- package/.agents/scripts/lib/single-story-sweep.js +181 -54
- package/.agents/scripts/lib/templates/decomposer-prompts.js +17 -3
- package/.agents/scripts/pr-watch-with-update.js +324 -37
- package/.agents/scripts/run-verify.js +18 -3
- package/.agents/scripts/single-story-confirm-merge.js +1 -1
- package/.agents/scripts/single-story-init.js +7 -51
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +32 -1
- package/.agents/skills/core/scope-triage/SKILL.md +5 -4
- package/.agents/workflows/git-deliver.md +36 -0
- package/.agents/workflows/helpers/deliver-epic-reference.md +41 -21
- package/.agents/workflows/helpers/deliver-epic.md +148 -28
- package/.agents/workflows/helpers/deliver-stories.md +18 -5
- package/.agents/workflows/helpers/single-story-deliver-reference.md +3 -3
- package/.agents/workflows/helpers/single-story-deliver.md +56 -19
- package/.agents/workflows/plan.md +32 -4
- package/docs/CHANGELOG.md +21 -0
- package/package.json +1 -1
|
@@ -126,10 +126,8 @@ const KEY_MEANINGS = Object.freeze({
|
|
|
126
126
|
'How many recent commits the snapshot summarizes.',
|
|
127
127
|
'planning.riskHeuristics':
|
|
128
128
|
'Phrases that flag a Story as high-risk for HITL escalation.',
|
|
129
|
-
'planning.taskSizing.maxAcceptance':
|
|
130
|
-
'Hard ceiling on acceptance criteria per Story.',
|
|
131
129
|
'planning.taskSizing.softAcceptanceCount':
|
|
132
|
-
'Acceptance-criteria count above which a Story is flagged as large.',
|
|
130
|
+
'Acceptance-criteria count above which a Story is flagged as large (advisory-only — there is no hard acceptance ceiling).',
|
|
133
131
|
'planning.taskSizing.softFiles':
|
|
134
132
|
'Touched-file count above which a Story is flagged as large.',
|
|
135
133
|
'planning.taskSizing.hardFiles':
|
|
@@ -148,6 +146,16 @@ const KEY_MEANINGS = Object.freeze({
|
|
|
148
146
|
'Time-to-live for the Epic lease before a stale claim is reclaimable.',
|
|
149
147
|
'delivery.ci.skipForStoryPushes':
|
|
150
148
|
'Whether Story-branch pushes carry a [skip ci] trailer.',
|
|
149
|
+
'delivery.ci.earlyPr':
|
|
150
|
+
'Whether /deliver opens the Epic PR early so CI warms during later waves.',
|
|
151
|
+
'delivery.ci.watch.pollIntervalMs':
|
|
152
|
+
'Poll cadence (ms) for the merge/CI watch loop.',
|
|
153
|
+
'delivery.ci.watch.maxPolls':
|
|
154
|
+
'Maximum number of poll probes before the CI watch gives up.',
|
|
155
|
+
'delivery.ci.watch.maxResumes':
|
|
156
|
+
'Maximum times the CI watch may resume after a transient stall.',
|
|
157
|
+
'delivery.ci.autoMerge':
|
|
158
|
+
'Merge posture: trust-ci merges on green checks; strict also requires a clean review gate.',
|
|
151
159
|
'delivery.preflight.maxStories':
|
|
152
160
|
'Pre-dispatch ceiling on estimated Story count (no cap when unset).',
|
|
153
161
|
'delivery.preflight.maxWaves':
|
|
@@ -13,23 +13,27 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* Default required-check suite.
|
|
17
|
-
* test
|
|
18
|
-
* `github.branchProtection.requiredChecks` in `.agentrc.json`.
|
|
16
|
+
* Default required-check suite. Mirrors the live CI required-check set
|
|
17
|
+
* (`lint` + `test` + `baselines`); consumers override via
|
|
18
|
+
* `github.branchProtection.requiredChecks` in `.agentrc.json`. Kept in sync
|
|
19
|
+
* with `DEFAULT_CHECKS` in `git-pr-quality-gate.js` and the CI job names in
|
|
20
|
+
* `.github/workflows/ci.yml` — the retired `format:check` folded into `lint`
|
|
21
|
+
* (Story #1829) and `lifecycle-doc-drift` collapsed into `lint`/`docs:check`
|
|
22
|
+
* (Epic #1943), so neither belongs in the default set.
|
|
19
23
|
*/
|
|
20
24
|
export const DEFAULT_REQUIRED_CHECKS = Object.freeze([
|
|
21
25
|
Object.freeze({
|
|
22
26
|
name: 'lint',
|
|
23
27
|
cmd: Object.freeze(['npm', 'run', 'lint']),
|
|
24
28
|
}),
|
|
25
|
-
Object.freeze({
|
|
26
|
-
name: 'format:check',
|
|
27
|
-
cmd: Object.freeze(['npm', 'run', 'format:check']),
|
|
28
|
-
}),
|
|
29
29
|
Object.freeze({
|
|
30
30
|
name: 'test',
|
|
31
31
|
cmd: Object.freeze(['npm', 'test']),
|
|
32
32
|
}),
|
|
33
|
+
Object.freeze({
|
|
34
|
+
name: 'baselines',
|
|
35
|
+
cmd: Object.freeze(['node', '.agents/scripts/check-baselines.js']),
|
|
36
|
+
}),
|
|
33
37
|
]);
|
|
34
38
|
|
|
35
39
|
export const BRANCH_PROTECTION_DEFAULTS = Object.freeze({
|
|
@@ -226,10 +226,31 @@ const EPIC_AUDIT_SCHEMA = {
|
|
|
226
226
|
// commit subjects so intermediate pushes do not stampede the CI fleet.
|
|
227
227
|
// The Epic-branch merge commit produced by story-close.js's merge
|
|
228
228
|
// runner never carries the marker, regardless of this flag.
|
|
229
|
+
//
|
|
230
|
+
// Story #4356 (Epic #4355) — CI-aware delivery namespace. `earlyPr` gates
|
|
231
|
+
// whether /deliver opens the Epic PR early (before every Story merges) so CI
|
|
232
|
+
// starts warming while later waves run; defaults to `true` via getCiDelivery.
|
|
233
|
+
// `watch.*` tunes the merge/CI watch poll loop (poll cadence, poll cap, and
|
|
234
|
+
// how many times the watcher may resume after a transient stall).
|
|
235
|
+
// `autoMerge` selects the merge posture: `"trust-ci"` (default) merges once
|
|
236
|
+
// required checks pass, `"strict"` additionally requires a clean review gate.
|
|
237
|
+
const CI_WATCH_SCHEMA = {
|
|
238
|
+
type: 'object',
|
|
239
|
+
properties: {
|
|
240
|
+
pollIntervalMs: { type: 'integer', minimum: 1 },
|
|
241
|
+
maxPolls: { type: 'integer', minimum: 1 },
|
|
242
|
+
maxResumes: { type: 'integer', minimum: 0 },
|
|
243
|
+
},
|
|
244
|
+
additionalProperties: false,
|
|
245
|
+
};
|
|
246
|
+
|
|
229
247
|
const CI_DELIVERY_SCHEMA = {
|
|
230
248
|
type: 'object',
|
|
231
249
|
properties: {
|
|
232
250
|
skipForStoryPushes: { type: 'boolean' },
|
|
251
|
+
earlyPr: { type: 'boolean' },
|
|
252
|
+
watch: CI_WATCH_SCHEMA,
|
|
253
|
+
autoMerge: { type: 'string', enum: ['trust-ci', 'strict'] },
|
|
233
254
|
},
|
|
234
255
|
additionalProperties: false,
|
|
235
256
|
};
|
|
@@ -250,18 +250,18 @@ const CODEBASE_SNAPSHOT_SCHEMA = {
|
|
|
250
250
|
* `planning.taskSizing` — Story-sizing thresholds consumed by
|
|
251
251
|
* `ticket-validator-sizing.js`. Operator overrides shallow-merge with
|
|
252
252
|
* `DEFAULT_TASK_SIZING` defaults (softFiles 15, hardFiles 30,
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
253
|
+
* softAcceptanceCount 10 — the uniform relaxed profile from Story #3874).
|
|
254
|
+
* Story #3760 collapsed the per-profile matrix and the parallel
|
|
255
|
+
* `testSurface` axis into a flat set of knobs; the `sizingProfile` enum was
|
|
256
|
+
* replaced by an optional body-level `wide` declaration that lifts the
|
|
257
|
+
* `hardFiles` rejection. The hard `maxAcceptance` ceiling was removed after
|
|
258
|
+
* the Epic #4355 decomposition experiment — acceptance mass is advisory-only.
|
|
258
259
|
*/
|
|
259
260
|
const TASK_SIZING_SCHEMA = {
|
|
260
261
|
type: 'object',
|
|
261
262
|
properties: {
|
|
262
263
|
softFiles: { type: 'integer', minimum: 1 },
|
|
263
264
|
hardFiles: { type: 'integer', minimum: 1 },
|
|
264
|
-
maxAcceptance: { type: 'integer', minimum: 1 },
|
|
265
265
|
softAcceptanceCount: { type: 'integer', minimum: 1 },
|
|
266
266
|
// Under-size (merge-candidate) thresholds (Story #4312). A Story whose
|
|
267
267
|
// footprint is at or below BOTH ceilings and that carries a `depends_on`
|
|
@@ -29,8 +29,12 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { spawnSync } from 'node:child_process';
|
|
32
|
-
|
|
33
32
|
import { parseWorktreePorcelain } from '../worktree/inspector.js';
|
|
33
|
+
import {
|
|
34
|
+
executeFastForward,
|
|
35
|
+
planFastForward,
|
|
36
|
+
} from './git-cleanup/phases/fast-forward.js';
|
|
37
|
+
import { makeFfProbes } from './git-cleanup/phases/git-probes-ff.js';
|
|
34
38
|
|
|
35
39
|
const WT_SCRATCH_BRANCH = 'wt-branch';
|
|
36
40
|
|
|
@@ -328,6 +332,51 @@ export function deleteWtBranchIfPresent(opts) {
|
|
|
328
332
|
return { deleted: false, present: true, stderr };
|
|
329
333
|
}
|
|
330
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Fast-forward the base branch to its remote after a confirmed merge, so a
|
|
337
|
+
* post-`/deliver` local checkout converges to `origin/<baseBranch>` without a
|
|
338
|
+
* manual `git pull`. Reuses `planFastForward` + `executeFastForward` from the
|
|
339
|
+
* git-cleanup phase library (the single source of the FF state machine),
|
|
340
|
+
* feeding them probes bound to the injected `gitSpawn`.
|
|
341
|
+
*
|
|
342
|
+
* @param {{
|
|
343
|
+
* cwd: string,
|
|
344
|
+
* baseBranch?: string,
|
|
345
|
+
* remoteName?: string,
|
|
346
|
+
* gitSpawn: (cwd: string, ...args: string[]) => { status: number, stdout: string, stderr: string },
|
|
347
|
+
* logger?: { info?: Function, warn?: Function },
|
|
348
|
+
* }} opts
|
|
349
|
+
* @returns {{ ok: boolean, applied: boolean, skipped: boolean, reason?: string, behind?: number, stderr?: string }}
|
|
350
|
+
*/
|
|
351
|
+
export function fastForwardBaseBranch(opts) {
|
|
352
|
+
const {
|
|
353
|
+
cwd,
|
|
354
|
+
baseBranch = 'main',
|
|
355
|
+
remoteName = 'origin',
|
|
356
|
+
gitSpawn,
|
|
357
|
+
logger,
|
|
358
|
+
} = opts;
|
|
359
|
+
const probe = makeFfProbes(gitSpawn);
|
|
360
|
+
const plan = planFastForward({
|
|
361
|
+
cwd,
|
|
362
|
+
baseBranch,
|
|
363
|
+
remoteName,
|
|
364
|
+
isCleanFn: probe.isClean,
|
|
365
|
+
currentBranchFn: probe.currentBranch,
|
|
366
|
+
fetchFn: probe.fetch,
|
|
367
|
+
canFastForwardFn: probe.canFastForward,
|
|
368
|
+
});
|
|
369
|
+
return executeFastForward({
|
|
370
|
+
cwd,
|
|
371
|
+
baseBranch,
|
|
372
|
+
remoteName,
|
|
373
|
+
plan,
|
|
374
|
+
checkoutFn: probe.checkout,
|
|
375
|
+
mergeFn: probe.merge,
|
|
376
|
+
...(logger ? { logger } : {}),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
331
380
|
/**
|
|
332
381
|
* Reap every branch owned by the Epic. Best-effort — failures aggregate into
|
|
333
382
|
* the result rather than throwing.
|
|
@@ -350,6 +399,7 @@ export function deleteWtBranchIfPresent(opts) {
|
|
|
350
399
|
* switched: { switched: boolean, from: string|null, to: string|null, stderr?: string } | null,
|
|
351
400
|
* pruned: { pruned: string[], stderr?: string } | null,
|
|
352
401
|
* wtBranch: { deleted: boolean, present: boolean, reason?: string, stderr?: string } | null,
|
|
402
|
+
* fastForward: { ok: boolean, applied: boolean, skipped: boolean, reason?: string, behind?: number, stderr?: string } | null,
|
|
353
403
|
* epicBranchKept: boolean,
|
|
354
404
|
* ok: boolean,
|
|
355
405
|
* }}
|
|
@@ -375,6 +425,7 @@ export function reapEpicBranches(opts) {
|
|
|
375
425
|
switched: null,
|
|
376
426
|
pruned: null,
|
|
377
427
|
wtBranch: null,
|
|
428
|
+
fastForward: null,
|
|
378
429
|
epicBranchKept: false,
|
|
379
430
|
ok: true,
|
|
380
431
|
};
|
|
@@ -463,6 +514,26 @@ export function reapEpicBranches(opts) {
|
|
|
463
514
|
logger?.info?.(`[epic-cleanup] deleted stale ${WT_SCRATCH_BRANCH} ref`);
|
|
464
515
|
}
|
|
465
516
|
|
|
517
|
+
// After a confirmed merge (epic branch NOT kept), fast-forward the base
|
|
518
|
+
// branch so the local checkout converges to `origin/<baseBranch>` with no
|
|
519
|
+
// manual `git pull`. When the epic branch is kept (open PR), the merge is
|
|
520
|
+
// not confirmed, so the FF is skipped with an explicit reason rather than
|
|
521
|
+
// moving `main` under an in-flight PR.
|
|
522
|
+
const fastForward = epicHasOpenPr
|
|
523
|
+
? { ok: true, applied: false, skipped: true, reason: 'epic-branch-kept' }
|
|
524
|
+
: fastForwardBaseBranch({
|
|
525
|
+
cwd,
|
|
526
|
+
baseBranch,
|
|
527
|
+
remoteName: remote,
|
|
528
|
+
gitSpawn,
|
|
529
|
+
logger,
|
|
530
|
+
});
|
|
531
|
+
if (fastForward.applied) {
|
|
532
|
+
logger?.info?.(
|
|
533
|
+
`[epic-cleanup] fast-forwarded ${baseBranch} by ${fastForward.behind} commit(s) to ${remote}/${baseBranch}`,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
466
537
|
const failures = reaped.filter((r) => !r.branchDeleted);
|
|
467
538
|
return {
|
|
468
539
|
epicId: state?.epicId ?? null,
|
|
@@ -471,7 +542,224 @@ export function reapEpicBranches(opts) {
|
|
|
471
542
|
switched,
|
|
472
543
|
pruned,
|
|
473
544
|
wtBranch,
|
|
545
|
+
fastForward,
|
|
474
546
|
epicBranchKept: epicHasOpenPr,
|
|
475
547
|
ok: failures.length === 0,
|
|
476
548
|
};
|
|
477
549
|
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Does a local branch head ref exist? Thin `git rev-parse --verify` wrapper
|
|
553
|
+
* exported for the resume-detect path (and its tests).
|
|
554
|
+
*
|
|
555
|
+
* @param {{ cwd: string, gitSpawn: Function, branch: string }} opts
|
|
556
|
+
* @returns {boolean}
|
|
557
|
+
*/
|
|
558
|
+
export function localRefExists({ cwd, gitSpawn, branch }) {
|
|
559
|
+
const res = gitSpawn(
|
|
560
|
+
cwd,
|
|
561
|
+
'rev-parse',
|
|
562
|
+
'--verify',
|
|
563
|
+
'--quiet',
|
|
564
|
+
`refs/heads/${branch}`,
|
|
565
|
+
);
|
|
566
|
+
return res.status === 0;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Probe whether the Epic branch's PR is MERGED and, if so, its URL (needed
|
|
571
|
+
* for the `epic.merge.armed` payload). Fails CLOSED to
|
|
572
|
+
* `{ merged: false, prUrl: null }` on any probe error — an indeterminate
|
|
573
|
+
* probe must never auto-arm a destructive reap.
|
|
574
|
+
*
|
|
575
|
+
* @param {{
|
|
576
|
+
* epicBranch: string,
|
|
577
|
+
* cwd: string,
|
|
578
|
+
* spawnFn?: typeof spawnSync,
|
|
579
|
+
* logger?: { warn?: Function },
|
|
580
|
+
* }} opts
|
|
581
|
+
* @returns {{ merged: boolean, prUrl: string|null }}
|
|
582
|
+
*/
|
|
583
|
+
export function epicPrMergeState(opts) {
|
|
584
|
+
const { epicBranch, cwd, spawnFn = spawnSync, logger } = opts;
|
|
585
|
+
if (typeof epicBranch !== 'string' || epicBranch.length === 0) {
|
|
586
|
+
return { merged: false, prUrl: null };
|
|
587
|
+
}
|
|
588
|
+
let result;
|
|
589
|
+
try {
|
|
590
|
+
result = spawnFn(
|
|
591
|
+
'gh',
|
|
592
|
+
[
|
|
593
|
+
'pr',
|
|
594
|
+
'list',
|
|
595
|
+
'--head',
|
|
596
|
+
epicBranch,
|
|
597
|
+
'--state',
|
|
598
|
+
'merged',
|
|
599
|
+
'--json',
|
|
600
|
+
'number,url,mergedAt',
|
|
601
|
+
'--limit',
|
|
602
|
+
'1',
|
|
603
|
+
],
|
|
604
|
+
{ cwd, encoding: 'utf-8', shell: false },
|
|
605
|
+
);
|
|
606
|
+
} catch (err) {
|
|
607
|
+
logger?.warn?.(
|
|
608
|
+
`[epic-cleanup] merged-PR probe threw for ${epicBranch} (treating as unmerged): ${err?.message ?? err}`,
|
|
609
|
+
);
|
|
610
|
+
return { merged: false, prUrl: null };
|
|
611
|
+
}
|
|
612
|
+
if (!result || result.status !== 0) {
|
|
613
|
+
logger?.warn?.(
|
|
614
|
+
`[epic-cleanup] merged-PR probe failed for ${epicBranch} (status=${result?.status}): ${(result?.stderr ?? '').trim()}`,
|
|
615
|
+
);
|
|
616
|
+
return { merged: false, prUrl: null };
|
|
617
|
+
}
|
|
618
|
+
let parsed;
|
|
619
|
+
try {
|
|
620
|
+
parsed = JSON.parse(String(result.stdout ?? '').trim() || '[]');
|
|
621
|
+
} catch {
|
|
622
|
+
return { merged: false, prUrl: null };
|
|
623
|
+
}
|
|
624
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
625
|
+
return { merged: false, prUrl: null };
|
|
626
|
+
}
|
|
627
|
+
const row = parsed[0];
|
|
628
|
+
const prUrl =
|
|
629
|
+
typeof row?.url === 'string' && row.url.length > 0 ? row.url : null;
|
|
630
|
+
// A merged-state row with no usable URL cannot arm (the schema requires a
|
|
631
|
+
// `prUrl`), so treat it as not-armable.
|
|
632
|
+
return { merged: prUrl !== null, prUrl };
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Detect a merged-but-uncleaned Epic: the PR merged but one or more local
|
|
637
|
+
* `epic/<id>` / `story-<id>` refs still linger. This is the signal `/deliver`
|
|
638
|
+
* idempotent resume uses to auto-fire `epic.merge.armed` so Phase 9 reaps
|
|
639
|
+
* without a manual command. Pure given its injected ports.
|
|
640
|
+
*
|
|
641
|
+
* @param {{
|
|
642
|
+
* state: object|null,
|
|
643
|
+
* cwd: string,
|
|
644
|
+
* gitSpawn: Function,
|
|
645
|
+
* spawnFn?: Function,
|
|
646
|
+
* prMergeStateFn?: typeof epicPrMergeState,
|
|
647
|
+
* logger?: { warn?: Function, info?: Function },
|
|
648
|
+
* }} opts
|
|
649
|
+
* @returns {{
|
|
650
|
+
* epicId: number|null,
|
|
651
|
+
* epicBranch: string|null,
|
|
652
|
+
* presentRefs: string[],
|
|
653
|
+
* localRefsPresent: boolean,
|
|
654
|
+
* merged: boolean,
|
|
655
|
+
* prUrl: string|null,
|
|
656
|
+
* shouldArm: boolean,
|
|
657
|
+
* reason: string,
|
|
658
|
+
* }}
|
|
659
|
+
*/
|
|
660
|
+
export function detectMergedUncleanedEpic(opts) {
|
|
661
|
+
const {
|
|
662
|
+
state,
|
|
663
|
+
cwd,
|
|
664
|
+
gitSpawn,
|
|
665
|
+
spawnFn,
|
|
666
|
+
prMergeStateFn = epicPrMergeState,
|
|
667
|
+
logger,
|
|
668
|
+
} = opts;
|
|
669
|
+
const { epicBranch, storyBranches } = listEpicBranchesFromState(state);
|
|
670
|
+
if (!epicBranch) {
|
|
671
|
+
return {
|
|
672
|
+
epicId: null,
|
|
673
|
+
epicBranch: null,
|
|
674
|
+
presentRefs: [],
|
|
675
|
+
localRefsPresent: false,
|
|
676
|
+
merged: false,
|
|
677
|
+
prUrl: null,
|
|
678
|
+
shouldArm: false,
|
|
679
|
+
reason: 'no-state',
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
const presentRefs = [epicBranch, ...storyBranches].filter((branch) =>
|
|
683
|
+
localRefExists({ cwd, gitSpawn, branch }),
|
|
684
|
+
);
|
|
685
|
+
if (presentRefs.length === 0) {
|
|
686
|
+
// Already clean — nothing to arm. This is the idempotent no-op that
|
|
687
|
+
// makes re-running `/deliver` on an already-reaped Epic safe.
|
|
688
|
+
return {
|
|
689
|
+
epicId: state.epicId,
|
|
690
|
+
epicBranch,
|
|
691
|
+
presentRefs,
|
|
692
|
+
localRefsPresent: false,
|
|
693
|
+
merged: false,
|
|
694
|
+
prUrl: null,
|
|
695
|
+
shouldArm: false,
|
|
696
|
+
reason: 'no-local-refs',
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
const { merged, prUrl } = prMergeStateFn({
|
|
700
|
+
epicBranch,
|
|
701
|
+
cwd,
|
|
702
|
+
spawnFn,
|
|
703
|
+
logger,
|
|
704
|
+
});
|
|
705
|
+
const shouldArm = merged && prUrl !== null;
|
|
706
|
+
return {
|
|
707
|
+
epicId: state.epicId,
|
|
708
|
+
epicBranch,
|
|
709
|
+
presentRefs,
|
|
710
|
+
localRefsPresent: true,
|
|
711
|
+
merged,
|
|
712
|
+
prUrl,
|
|
713
|
+
shouldArm,
|
|
714
|
+
reason: shouldArm ? 'merged-uncleaned' : 'not-merged',
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* `/deliver` idempotent-resume auto-arm: detect a merged-but-uncleaned Epic
|
|
720
|
+
* and, when found, fire `epic.merge.armed` on the injected lifecycle `bus` so
|
|
721
|
+
* the Cleaner → BranchCleaner chain reaps Phase 9 without an operator command.
|
|
722
|
+
* A no-op (and never throws on a clean/unmerged Epic) so re-running resume is
|
|
723
|
+
* safe.
|
|
724
|
+
*
|
|
725
|
+
* @param {{
|
|
726
|
+
* state: object|null,
|
|
727
|
+
* cwd: string,
|
|
728
|
+
* gitSpawn: Function,
|
|
729
|
+
* spawnFn?: Function,
|
|
730
|
+
* bus: { emit: (event: string, payload: object) => Promise<unknown> },
|
|
731
|
+
* detectFn?: typeof detectMergedUncleanedEpic,
|
|
732
|
+
* logger?: { warn?: Function, info?: Function },
|
|
733
|
+
* }} opts
|
|
734
|
+
* @returns {Promise<{ armed: boolean, reason: string, prUrl: string|null, detection: object }>}
|
|
735
|
+
*/
|
|
736
|
+
export async function armCleanupIfMerged(opts) {
|
|
737
|
+
const { state, cwd, gitSpawn, spawnFn, bus, detectFn, logger } = opts;
|
|
738
|
+
if (!bus || typeof bus.emit !== 'function') {
|
|
739
|
+
throw new TypeError('armCleanupIfMerged requires a bus exposing emit()');
|
|
740
|
+
}
|
|
741
|
+
const detect = detectFn ?? detectMergedUncleanedEpic;
|
|
742
|
+
const detection = detect({ state, cwd, gitSpawn, spawnFn, logger });
|
|
743
|
+
if (!detection.shouldArm) {
|
|
744
|
+
return {
|
|
745
|
+
armed: false,
|
|
746
|
+
reason: detection.reason,
|
|
747
|
+
prUrl: detection.prUrl,
|
|
748
|
+
detection,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
const payload = { prUrl: detection.prUrl };
|
|
752
|
+
if (Number.isInteger(detection.epicId) && detection.epicId > 0) {
|
|
753
|
+
payload.epicId = detection.epicId;
|
|
754
|
+
}
|
|
755
|
+
await bus.emit('epic.merge.armed', payload);
|
|
756
|
+
logger?.info?.(
|
|
757
|
+
`[epic-cleanup] resume auto-arm: fired epic.merge.armed for ${detection.epicBranch} (${detection.prUrl})`,
|
|
758
|
+
);
|
|
759
|
+
return {
|
|
760
|
+
armed: true,
|
|
761
|
+
reason: 'merged-uncleaned',
|
|
762
|
+
prUrl: detection.prUrl,
|
|
763
|
+
detection,
|
|
764
|
+
};
|
|
765
|
+
}
|
|
@@ -27,6 +27,20 @@
|
|
|
27
27
|
* does not attempt to create a duplicate PR. This is the AC-10
|
|
28
28
|
* idempotency contract the Finalizer relies on for cross-process
|
|
29
29
|
* re-runs of `/deliver`.
|
|
30
|
+
*
|
|
31
|
+
* Early-PR draft mode — Story #4359 (Epic #4355).
|
|
32
|
+
*
|
|
33
|
+
* When `delivery.ci.earlyPr` is on (the default), `/deliver` opens the
|
|
34
|
+
* Epic PR as a **draft** at wave 1 (`openOrLocatePr({ draft: true })`)
|
|
35
|
+
* so every subsequent per-wave push runs CI attributed to its own wave.
|
|
36
|
+
* The `draft` flag only appends `--draft` to the `gh pr create` shell —
|
|
37
|
+
* the probe/locate path is unchanged, so a re-run on the same head
|
|
38
|
+
* branch still short-circuits without opening a duplicate. Phase 7 then
|
|
39
|
+
* flips that existing draft to ready-for-review via `markPrReady` rather
|
|
40
|
+
* than creating the PR. When `earlyPr` is off, no draft is opened at
|
|
41
|
+
* wave 1 and Phase 7 opens the PR at close time exactly as before
|
|
42
|
+
* (`openOrLocatePr` with no `draft`). The title/body contract
|
|
43
|
+
* (`feat: Epic #<id>` / `Closes #<id>`) is identical in both modes.
|
|
30
44
|
*/
|
|
31
45
|
|
|
32
46
|
import { spawnSync } from 'node:child_process';
|
|
@@ -119,6 +133,12 @@ export function parsePrViewResult(stdout) {
|
|
|
119
133
|
* or changelog entry).
|
|
120
134
|
* @param {string} [args.body] — explicit PR body override; defaults to
|
|
121
135
|
* `Closes #<epicId>`.
|
|
136
|
+
* @param {boolean} [args.draft] — when `true`, open the new PR as a draft
|
|
137
|
+
* (`gh pr create --draft`). Story #4359: the early-PR path opens the
|
|
138
|
+
* Epic PR as a draft at wave 1 so per-wave pushes warm CI; Phase 7 later
|
|
139
|
+
* flips it ready via `markPrReady`. Only affects the create path — the
|
|
140
|
+
* locate short-circuit never inspects draft state, so a re-run stays
|
|
141
|
+
* idempotent. Default `false`.
|
|
122
142
|
* @param {string} [args.cwd] — working directory for the gh shells.
|
|
123
143
|
* Default `process.cwd()`.
|
|
124
144
|
* @param {Function} [args.ghSpawn] — override the gh invocation for
|
|
@@ -131,6 +151,7 @@ export async function openOrLocatePr({
|
|
|
131
151
|
baseBranch = 'main',
|
|
132
152
|
title,
|
|
133
153
|
body,
|
|
154
|
+
draft = false,
|
|
134
155
|
cwd = process.cwd(),
|
|
135
156
|
ghSpawn = defaultGhSpawn,
|
|
136
157
|
} = {}) {
|
|
@@ -206,6 +227,11 @@ export async function openOrLocatePr({
|
|
|
206
227
|
finalTitle,
|
|
207
228
|
'--body',
|
|
208
229
|
finalBody,
|
|
230
|
+
// Story #4359: open as a draft when the early-PR path requests it.
|
|
231
|
+
// The flag is elided entirely when `draft` is false so the
|
|
232
|
+
// close-time (earlyPr=false) create path is byte-identical to the
|
|
233
|
+
// pre-Story behaviour.
|
|
234
|
+
...(draft ? ['--draft'] : []),
|
|
209
235
|
],
|
|
210
236
|
cwd,
|
|
211
237
|
});
|
|
@@ -239,3 +265,42 @@ export async function openOrLocatePr({
|
|
|
239
265
|
}
|
|
240
266
|
return { prNumber: parsed.number, url: parsed.url, created: true };
|
|
241
267
|
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Mark an existing draft PR as ready-for-review — Story #4359 (Epic
|
|
271
|
+
* #4355). This is the Phase-7 counterpart to the wave-1 draft open: when
|
|
272
|
+
* `delivery.ci.earlyPr` is on, the Epic PR already exists as a draft, so
|
|
273
|
+
* finalize flips it ready rather than creating a PR.
|
|
274
|
+
*
|
|
275
|
+
* Idempotent: `gh pr ready` on an already-ready PR is a no-op that exits
|
|
276
|
+
* 0, so re-running finalize (or replaying after a crash) is safe. When the
|
|
277
|
+
* PR reference does not resolve, `gh` exits non-zero and this helper
|
|
278
|
+
* throws with the stderr detail.
|
|
279
|
+
*
|
|
280
|
+
* @param {object} args
|
|
281
|
+
* @param {string} args.pr — a PR reference `gh pr ready` accepts: the
|
|
282
|
+
* numeric id, the branch name, or the html URL. `openOrLocatePr`
|
|
283
|
+
* returns both `prNumber` and `url`; either is a valid input.
|
|
284
|
+
* @param {string} [args.cwd] — working directory for the gh shell.
|
|
285
|
+
* Default `process.cwd()`.
|
|
286
|
+
* @param {Function} [args.ghSpawn] — override the gh invocation for
|
|
287
|
+
* tests. Same shape as `defaultGhSpawn`.
|
|
288
|
+
* @returns {Promise<{ pr: string, ready: true }>}
|
|
289
|
+
*/
|
|
290
|
+
export async function markPrReady({
|
|
291
|
+
pr,
|
|
292
|
+
cwd = process.cwd(),
|
|
293
|
+
ghSpawn = defaultGhSpawn,
|
|
294
|
+
} = {}) {
|
|
295
|
+
const ref = pr == null ? '' : String(pr).trim();
|
|
296
|
+
if (ref.length === 0) {
|
|
297
|
+
throw new TypeError('markPrReady: pr must be a non-empty reference');
|
|
298
|
+
}
|
|
299
|
+
const ready = ghSpawn({ args: ['pr', 'ready', ref], cwd });
|
|
300
|
+
if (ready.status !== 0) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`markPrReady: gh pr ready failed (status=${ready.status}): ${ready.stderr.trim()}`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
return { pr: ref, ready: true };
|
|
306
|
+
}
|
|
@@ -34,54 +34,107 @@ export function isWorktreeLockFailure(stderr) {
|
|
|
34
34
|
|
|
35
35
|
/* node:coverage ignore next */
|
|
36
36
|
export function isWorkingTreeClean(cwd) {
|
|
37
|
-
|
|
38
|
-
if (res.status !== 0) return false;
|
|
39
|
-
return res.stdout.trim() === '';
|
|
37
|
+
return defaultFfProbes.isClean(cwd);
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
/* node:coverage ignore next */
|
|
43
41
|
export function fetchRef(cwd, remoteName, ref) {
|
|
44
|
-
|
|
45
|
-
if (res.status !== 0) return { ok: false, stderr: res.stderr };
|
|
46
|
-
return { ok: true };
|
|
42
|
+
return defaultFfProbes.fetch(cwd, remoteName, ref);
|
|
47
43
|
}
|
|
48
44
|
|
|
49
45
|
/* node:coverage ignore next */
|
|
50
46
|
export function canFastForward(cwd, baseBranch, remoteName) {
|
|
51
|
-
|
|
52
|
-
const ahead = gitSpawn(
|
|
53
|
-
cwd,
|
|
54
|
-
'rev-list',
|
|
55
|
-
'--left-right',
|
|
56
|
-
'--count',
|
|
57
|
-
`${baseBranch}...${ref}`,
|
|
58
|
-
);
|
|
59
|
-
if (ahead.status !== 0) {
|
|
60
|
-
return { ok: false, behind: 0, reason: 'rev-list-failed' };
|
|
61
|
-
}
|
|
62
|
-
const parts = ahead.stdout.trim().split(/\s+/);
|
|
63
|
-
const localAhead = Number(parts[0]) || 0;
|
|
64
|
-
const remoteAhead = Number(parts[1]) || 0;
|
|
65
|
-
if (localAhead > 0) {
|
|
66
|
-
return { ok: false, behind: remoteAhead, reason: 'not-fast-forward' };
|
|
67
|
-
}
|
|
68
|
-
return { ok: true, behind: remoteAhead };
|
|
47
|
+
return defaultFfProbes.canFastForward(cwd, baseBranch, remoteName);
|
|
69
48
|
}
|
|
70
49
|
|
|
71
50
|
/* node:coverage ignore next */
|
|
72
51
|
export function checkoutBranch(cwd, branch) {
|
|
73
|
-
|
|
74
|
-
if (res.status !== 0) return { ok: false, stderr: res.stderr };
|
|
75
|
-
return { ok: true };
|
|
52
|
+
return defaultFfProbes.checkout(cwd, branch);
|
|
76
53
|
}
|
|
77
54
|
|
|
78
55
|
/* node:coverage ignore next */
|
|
79
56
|
export function mergeFastForward(cwd, ref) {
|
|
80
|
-
|
|
81
|
-
if (res.status !== 0) return { ok: false, stderr: res.stderr };
|
|
82
|
-
return { ok: true };
|
|
57
|
+
return defaultFfProbes.merge(cwd, ref);
|
|
83
58
|
}
|
|
84
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Build the fast-forward probe bundle bound to a `gitSpawn`.
|
|
62
|
+
*
|
|
63
|
+
* This is the **single implementation** of the FF/base-sync git wrappers.
|
|
64
|
+
* The standalone exports above delegate to a default instance bound to the
|
|
65
|
+
* shared `gitSpawn`; callers that need to inject their own spawn for testing
|
|
66
|
+
* (e.g. the epic-cleanup runner) call this factory directly instead of
|
|
67
|
+
* hand-rolling a parallel copy (framework-gap #4379). The bundle also carries
|
|
68
|
+
* `currentBranch` so an injecting caller gets the whole FF surface from one
|
|
69
|
+
* place.
|
|
70
|
+
*
|
|
71
|
+
* @param {(cwd: string, ...args: string[]) => { status: number, stdout: string, stderr: string }} [spawn]
|
|
72
|
+
* @returns {{
|
|
73
|
+
* isClean: (cwd: string) => boolean,
|
|
74
|
+
* currentBranch: (cwd: string) => string|null,
|
|
75
|
+
* fetch: (cwd: string, remoteName: string, ref: string) => { ok: boolean, stderr?: string },
|
|
76
|
+
* canFastForward: (cwd: string, baseBranch: string, remoteName: string) => { ok: boolean, behind: number, reason?: string },
|
|
77
|
+
* checkout: (cwd: string, branch: string) => { ok: boolean, stderr?: string },
|
|
78
|
+
* merge: (cwd: string, ref: string) => { ok: boolean, stderr?: string },
|
|
79
|
+
* }}
|
|
80
|
+
*/
|
|
81
|
+
export function makeFfProbes(spawn = gitSpawn) {
|
|
82
|
+
return {
|
|
83
|
+
isClean: (cwd) => {
|
|
84
|
+
const res = spawn(cwd, 'status', '--porcelain');
|
|
85
|
+
return res.status === 0 && String(res.stdout ?? '').trim() === '';
|
|
86
|
+
},
|
|
87
|
+
currentBranch: (cwd) => {
|
|
88
|
+
const res = spawn(cwd, 'symbolic-ref', '--quiet', '--short', 'HEAD');
|
|
89
|
+
return res.status !== 0 ? null : String(res.stdout ?? '').trim() || null;
|
|
90
|
+
},
|
|
91
|
+
fetch: (cwd, remoteName, ref) => {
|
|
92
|
+
const res = spawn(cwd, 'fetch', '--quiet', remoteName, ref);
|
|
93
|
+
return res.status === 0
|
|
94
|
+
? { ok: true }
|
|
95
|
+
: { ok: false, stderr: res.stderr };
|
|
96
|
+
},
|
|
97
|
+
canFastForward: (cwd, baseBranch, remoteName) => {
|
|
98
|
+
const ref = `${remoteName}/${baseBranch}`;
|
|
99
|
+
const ahead = spawn(
|
|
100
|
+
cwd,
|
|
101
|
+
'rev-list',
|
|
102
|
+
'--left-right',
|
|
103
|
+
'--count',
|
|
104
|
+
`${baseBranch}...${ref}`,
|
|
105
|
+
);
|
|
106
|
+
if (ahead.status !== 0) {
|
|
107
|
+
return { ok: false, behind: 0, reason: 'rev-list-failed' };
|
|
108
|
+
}
|
|
109
|
+
const parts = String(ahead.stdout ?? '')
|
|
110
|
+
.trim()
|
|
111
|
+
.split(/\s+/);
|
|
112
|
+
const localAhead = Number(parts[0]) || 0;
|
|
113
|
+
const remoteAhead = Number(parts[1]) || 0;
|
|
114
|
+
if (localAhead > 0) {
|
|
115
|
+
return { ok: false, behind: remoteAhead, reason: 'not-fast-forward' };
|
|
116
|
+
}
|
|
117
|
+
return { ok: true, behind: remoteAhead };
|
|
118
|
+
},
|
|
119
|
+
checkout: (cwd, branch) => {
|
|
120
|
+
const res = spawn(cwd, 'checkout', branch);
|
|
121
|
+
return res.status === 0
|
|
122
|
+
? { ok: true }
|
|
123
|
+
: { ok: false, stderr: res.stderr };
|
|
124
|
+
},
|
|
125
|
+
merge: (cwd, ref) => {
|
|
126
|
+
const res = spawn(cwd, 'merge', '--ff-only', ref);
|
|
127
|
+
return res.status === 0
|
|
128
|
+
? { ok: true }
|
|
129
|
+
: { ok: false, stderr: res.stderr };
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Default instance bound to the shared gitSpawn; the standalone wrappers
|
|
135
|
+
// above delegate to it so there is exactly one FF-probe implementation.
|
|
136
|
+
const defaultFfProbes = makeFfProbes(gitSpawn);
|
|
137
|
+
|
|
85
138
|
/* node:coverage ignore next */
|
|
86
139
|
export function removeWorktree(worktreePath, cwd) {
|
|
87
140
|
const plain = gitSpawn(cwd, 'worktree', 'remove', worktreePath);
|