mandrel 1.89.0 → 1.90.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/configuration.md +1 -0
- package/.agents/schemas/agentrc.schema.json +4 -0
- package/.agents/schemas/lifecycle/epic.blocked.schema.json +1 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/scripts/coverage-capture.js +17 -0
- package/.agents/scripts/lib/close-validation/gates.js +64 -24
- package/.agents/scripts/lib/config/ci.js +12 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/npm-scripts.js +55 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +10 -5
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +179 -5
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +98 -10
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +7 -1
- package/.agents/scripts/lib/orchestration/merge-block-class.js +32 -4
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -1
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +8 -1
- package/docs/CHANGELOG.md +7 -0
- package/package.json +1 -1
|
@@ -300,6 +300,7 @@ top-level keys are validation errors.
|
|
|
300
300
|
| `ci.watch.maxPolls` | No | `integer` | — | — |
|
|
301
301
|
| `ci.watch.maxResumes` | No | `integer` | — | — |
|
|
302
302
|
| `ci.autoMerge` | No | `"trust-ci"` \| `"strict"` | — | Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate. |
|
|
303
|
+
| `ci.requireChecks` | No | `boolean` | — | Story #4472. Fail-closed-without-checks policy. When true, the AutomergePredicate refuses to arm merge in a repo that reports zero required checks ('no checks reported'), treating the absent CI gate as a hard block. Defaults to false so a checks-less repo with green close-validation gates lands headlessly instead of parking on the operator-merges path. |
|
|
303
304
|
| `preflight` | No | `object` | — | Story #2899 (Epic #2880, F13). Thresholds consumed by `.agents/scripts/epic-deliver-preflight.js`. When any value is exceeded the preflight envelope flags a breach and /deliver Phase 1 surfaces it via agent::blocked. |
|
|
304
305
|
| `preflight.maxStories` | No | `integer` | — | — |
|
|
305
306
|
| `preflight.maxWaves` | No | `integer` | — | — |
|
|
@@ -1456,6 +1456,10 @@
|
|
|
1456
1456
|
"type": "string",
|
|
1457
1457
|
"enum": ["trust-ci", "strict"],
|
|
1458
1458
|
"description": "Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate."
|
|
1459
|
+
},
|
|
1460
|
+
"requireChecks": {
|
|
1461
|
+
"type": "boolean",
|
|
1462
|
+
"description": "Story #4472. Fail-closed-without-checks policy. When true, the AutomergePredicate refuses to arm merge in a repo that reports zero required checks ('no checks reported'), treating the absent CI gate as a hard block. Defaults to false so a checks-less repo with green close-validation gates lands headlessly instead of parking on the operator-merges path."
|
|
1459
1463
|
}
|
|
1460
1464
|
},
|
|
1461
1465
|
"additionalProperties": false
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://github.com/dsj1984/mandrel/blob/main/.agents/schemas/lifecycle/epic.blocked.schema.json",
|
|
4
4
|
"title": "epic.blocked",
|
|
5
|
-
"description": "Emitted by AcceptanceReconciler (on a failed acceptance reconciliation)
|
|
5
|
+
"description": "Emitted by AcceptanceReconciler (on a failed acceptance reconciliation), MergeWatcher (on a merge-watch timeout), and — in headless runs (Story #4472) — AutomergePredicate (on a predicate refusal that would otherwise silently park) and AutomergeArmer (on a genuine arm failure) when the Epic transitions to agent::blocked. Subscribed by NotifyDispatcher, which fans the blocker out to the curated webhook channel. The reason field carries either a typed marker (timeout:<event>, merge-predicate:refused, merge-arm:failed, waiver, …) or a free-form summary; sourceStoryId scopes the blocker to a child Story when applicable.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"required": ["reason"],
|
|
8
8
|
"properties": {
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from './lib/coverage-capture.js';
|
|
34
34
|
|
|
35
35
|
import { Logger } from './lib/Logger.js';
|
|
36
|
+
import { hasNpmScript, readPackageScripts } from './lib/npm-scripts.js';
|
|
36
37
|
|
|
37
38
|
function parseArgs(argv) {
|
|
38
39
|
const out = {
|
|
@@ -59,6 +60,22 @@ function main() {
|
|
|
59
60
|
return 0;
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
// Story #4473 — detect the "missing npm script" misconfiguration
|
|
64
|
+
// distinctly. `close-validation/gates.js` already declines to register
|
|
65
|
+
// this gate when `test:coverage` is absent, so reaching here without the
|
|
66
|
+
// script means a direct/pre-push invocation in a consumer that never
|
|
67
|
+
// defined it. Surface a one-line, fix-naming diagnostic instead of
|
|
68
|
+
// spawning `npm run test:coverage` only to propagate npm's opaque
|
|
69
|
+
// "Missing script" exit code.
|
|
70
|
+
if (!hasNpmScript(readPackageScripts(args.cwd), 'test:coverage')) {
|
|
71
|
+
Logger.error(
|
|
72
|
+
'[coverage-capture] ✖ No "test:coverage" script in package.json. ' +
|
|
73
|
+
'Add one (e.g. "test:coverage": "node --test --experimental-test-coverage") ' +
|
|
74
|
+
'or disable the CRAP gate via delivery.quality.gates.crap.enabled=false.',
|
|
75
|
+
);
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
62
79
|
if (args.skipWhenNoCrapFiles) {
|
|
63
80
|
let changed;
|
|
64
81
|
try {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* runner (`INDEPENDENT_GATE_NAMES` / `partitionGates`).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { hasNpmScript, readPackageScripts } from '../npm-scripts.js';
|
|
9
10
|
import {
|
|
10
11
|
buildFormatHint,
|
|
11
12
|
FORMAT_CHECK_FALLBACK,
|
|
@@ -87,17 +88,30 @@ function isCrapGateEnabled(config) {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
|
|
91
|
+
* The gates run in the Story worktree, whose `package.json` is the committed
|
|
92
|
+
* one the consumer ships — the presence of a `test:coverage` script is a
|
|
93
|
+
* committed fact, so probing at the gate cwd is authoritative. See
|
|
94
|
+
* `lib/npm-scripts.js` for the shared reader.
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Conditionally produce the standalone `test` gate entry.
|
|
95
99
|
*
|
|
96
|
-
*
|
|
100
|
+
* The plain `test` gate is the canonical test runner UNLESS the
|
|
101
|
+
* coverage-capture gate is taking that role — which happens only when the
|
|
102
|
+
* CRAP gate is enabled (Story #1798) AND the consumer actually ships a
|
|
103
|
+
* `test:coverage` script for coverage-capture to run (#4473). When CRAP is
|
|
104
|
+
* enabled but `test:coverage` is absent, coverage-capture is dropped from
|
|
105
|
+
* the gate list, so the `test` gate MUST come back — otherwise the consumer
|
|
106
|
+
* has NO working test gate at all. Splitting this out keeps
|
|
107
|
+
* `buildDefaultGates` flat for the CRAP-cyclomatic gate.
|
|
108
|
+
*
|
|
109
|
+
* @param {boolean} coverageCaptureActive - Whether the coverage-capture gate
|
|
110
|
+
* is registered as the test runner for this build.
|
|
97
111
|
* @returns {Gate[]}
|
|
98
112
|
*/
|
|
99
|
-
function buildTestGateEntry(
|
|
100
|
-
if (
|
|
113
|
+
function buildTestGateEntry(coverageCaptureActive) {
|
|
114
|
+
if (coverageCaptureActive) return [];
|
|
101
115
|
return [{ name: 'test', cmd: 'npm', args: ['test'] }];
|
|
102
116
|
}
|
|
103
117
|
|
|
@@ -105,10 +119,13 @@ function buildTestGateEntry(config) {
|
|
|
105
119
|
* Build the canonical close-validation gate list.
|
|
106
120
|
*
|
|
107
121
|
* Ordering (cheapest fast-fail first): typecheck → lint → [test] →
|
|
108
|
-
* format → coverage-capture → check-baselines. The standalone `test`
|
|
109
|
-
* gate is dropped when
|
|
110
|
-
*
|
|
111
|
-
*
|
|
122
|
+
* format → [coverage-capture] → check-baselines. The standalone `test`
|
|
123
|
+
* gate is dropped when coverage-capture is the active test runner — i.e.
|
|
124
|
+
* `crap.enabled === true` (Story #1798) AND a `test:coverage` script
|
|
125
|
+
* exists (Story #4473) — because coverage-capture then carries
|
|
126
|
+
* test-failure signalling under c8. When CRAP is on but `test:coverage` is
|
|
127
|
+
* absent, coverage-capture is dropped and the `test` gate is restored so
|
|
128
|
+
* there is always a working test gate.
|
|
112
129
|
*
|
|
113
130
|
* `typecheck` is mandatory; consumers may customise the command via
|
|
114
131
|
* `project.commands.typecheck` (default `npm run typecheck`).
|
|
@@ -127,15 +144,34 @@ function buildTestGateEntry(config) {
|
|
|
127
144
|
* re-discovered inherited main-vs-epic drift in untouched files as phantom
|
|
128
145
|
* regressions and worked around it by hand-setting `BASELINE_REF`.
|
|
129
146
|
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
147
|
+
* Story #4473 — the coverage-capture gate spawns `npm run test:coverage`,
|
|
148
|
+
* so it is registered ONLY when the consumer actually ships that script.
|
|
149
|
+
* When CRAP is enabled but `test:coverage` is absent, coverage-capture is
|
|
150
|
+
* dropped and the plain `test` gate is restored (see `buildTestGateEntry`),
|
|
151
|
+
* so a consumer without a coverage script gets a working degraded test gate
|
|
152
|
+
* instead of a deterministic close failure with no test gate at all. The
|
|
153
|
+
* probe reads `package.json` at `cwd` (the gate execution directory).
|
|
154
|
+
*
|
|
155
|
+
* @param {{ config?: object, epicBranch?: string, cwd?: string, packageScripts?: Record<string, string> }} [opts]
|
|
156
|
+
* `config` is the canonical resolved config (`{ project, delivery, ... }`);
|
|
157
|
+
* gate commands resolve from `project.commands` and the CRAP toggle from
|
|
133
158
|
* `delivery.quality.gates.crap.enabled`. `epicBranch` is the close run's
|
|
134
159
|
* integration branch (`epic/<id>` for Epic-attached Stories, the base
|
|
135
|
-
* branch for standalone Stories).
|
|
160
|
+
* branch for standalone Stories). `cwd` is where the `package.json`
|
|
161
|
+
* coverage-script probe reads from (defaults to `process.cwd()`);
|
|
162
|
+
* `packageScripts` injects the scripts map directly (tests) and short-
|
|
163
|
+
* circuits the disk read.
|
|
136
164
|
* @returns {Gate[]}
|
|
137
165
|
*/
|
|
138
|
-
export function buildDefaultGates({
|
|
166
|
+
export function buildDefaultGates({
|
|
167
|
+
config,
|
|
168
|
+
epicBranch,
|
|
169
|
+
cwd,
|
|
170
|
+
packageScripts,
|
|
171
|
+
} = {}) {
|
|
172
|
+
const scripts = packageScripts ?? readPackageScripts(cwd);
|
|
173
|
+
const coverageCaptureActive =
|
|
174
|
+
isCrapGateEnabled(config) && hasNpmScript(scripts, 'test:coverage');
|
|
139
175
|
const typecheckCmdString = resolveTypecheckCommand(config);
|
|
140
176
|
const [typecheckCmd, ...typecheckArgs] = typecheckCmdString
|
|
141
177
|
.split(/\s+/)
|
|
@@ -158,7 +194,7 @@ export function buildDefaultGates({ config, epicBranch } = {}) {
|
|
|
158
194
|
hint: TYPECHECK_HINT,
|
|
159
195
|
},
|
|
160
196
|
{ name: 'lint', cmd: 'npm', args: ['run', 'lint'] },
|
|
161
|
-
...buildTestGateEntry(
|
|
197
|
+
...buildTestGateEntry(coverageCaptureActive),
|
|
162
198
|
{
|
|
163
199
|
// Gate name kept generic ("format") so the close-orchestrator log line
|
|
164
200
|
// and the per-gate phase-timer key don't shift when a repo swaps biome
|
|
@@ -172,12 +208,16 @@ export function buildDefaultGates({ config, epicBranch } = {}) {
|
|
|
172
208
|
? { changedFileScope: formatChangedFileScope }
|
|
173
209
|
: {}),
|
|
174
210
|
},
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
211
|
+
...(coverageCaptureActive
|
|
212
|
+
? [
|
|
213
|
+
{
|
|
214
|
+
name: 'coverage-capture',
|
|
215
|
+
cmd: 'node',
|
|
216
|
+
args: ['.agents/scripts/coverage-capture.js'],
|
|
217
|
+
hint: 'Coverage capture failed — `npm run test:coverage` exited non-zero. Fix failing tests or coverage-threshold breaches, then re-run close.',
|
|
218
|
+
},
|
|
219
|
+
]
|
|
220
|
+
: []),
|
|
181
221
|
{
|
|
182
222
|
// Story #2210 — unified `check-baselines` gate is the only path for
|
|
183
223
|
// per-kind regression enforcement. The legacy per-kind in-process
|
|
@@ -12,12 +12,19 @@
|
|
|
12
12
|
* waves run; `watch` tunes the merge/CI watch poll loop; and `autoMerge`
|
|
13
13
|
* (default `"trust-ci"`) selects the merge posture — `"trust-ci"` merges once
|
|
14
14
|
* required checks pass, `"strict"` additionally requires a clean review gate.
|
|
15
|
+
*
|
|
16
|
+
* Story #4472 adds `requireChecks` (default `false`): when `true` the
|
|
17
|
+
* AutomergePredicate treats a checks-less repo ("no checks reported") as a
|
|
18
|
+
* hard block rather than green, so a consumer that wants fail-closed-without-
|
|
19
|
+
* checks as policy opts into it explicitly instead of the framework blocking
|
|
20
|
+
* implicitly.
|
|
15
21
|
*/
|
|
16
22
|
|
|
17
23
|
export const CI_DELIVERY_DEFAULTS = Object.freeze({
|
|
18
24
|
skipForStoryPushes: true,
|
|
19
25
|
earlyPr: true,
|
|
20
26
|
autoMerge: 'trust-ci',
|
|
27
|
+
requireChecks: false,
|
|
21
28
|
});
|
|
22
29
|
|
|
23
30
|
/**
|
|
@@ -28,7 +35,7 @@ export const CI_DELIVERY_DEFAULTS = Object.freeze({
|
|
|
28
35
|
* defaults; only the scalar knobs carry framework defaults here.
|
|
29
36
|
*
|
|
30
37
|
* @param {object | null | undefined} config
|
|
31
|
-
* @returns {{ skipForStoryPushes: boolean, earlyPr: boolean, autoMerge: 'trust-ci' | 'strict', watch: object | undefined }}
|
|
38
|
+
* @returns {{ skipForStoryPushes: boolean, earlyPr: boolean, autoMerge: 'trust-ci' | 'strict', requireChecks: boolean, watch: object | undefined }}
|
|
32
39
|
*/
|
|
33
40
|
export function getCiDelivery(config) {
|
|
34
41
|
const ci = config?.delivery?.ci ?? config?.ci ?? config ?? {};
|
|
@@ -45,6 +52,10 @@ export function getCiDelivery(config) {
|
|
|
45
52
|
ci.autoMerge === 'trust-ci' || ci.autoMerge === 'strict'
|
|
46
53
|
? ci.autoMerge
|
|
47
54
|
: CI_DELIVERY_DEFAULTS.autoMerge,
|
|
55
|
+
requireChecks:
|
|
56
|
+
typeof ci.requireChecks === 'boolean'
|
|
57
|
+
? ci.requireChecks
|
|
58
|
+
: CI_DELIVERY_DEFAULTS.requireChecks,
|
|
48
59
|
watch:
|
|
49
60
|
ci.watch && typeof ci.watch === 'object' ? { ...ci.watch } : undefined,
|
|
50
61
|
};
|
|
@@ -264,6 +264,13 @@ const CI_DELIVERY_SCHEMA = {
|
|
|
264
264
|
earlyPr: { type: 'boolean' },
|
|
265
265
|
watch: CI_WATCH_SCHEMA,
|
|
266
266
|
autoMerge: { type: 'string', enum: ['trust-ci', 'strict'] },
|
|
267
|
+
// Story #4472 — fail-closed-without-checks policy. When `true`, the
|
|
268
|
+
// AutomergePredicate refuses to arm merge in a repo that reports zero
|
|
269
|
+
// required checks ("no checks reported"), treating the absence of a CI
|
|
270
|
+
// gate as a hard block instead of green. Defaults to `false` so a
|
|
271
|
+
// checks-less repo with green close-validation gates lands headlessly
|
|
272
|
+
// rather than parking on the operator-merges path.
|
|
273
|
+
requireChecks: { type: 'boolean' },
|
|
267
274
|
},
|
|
268
275
|
additionalProperties: false,
|
|
269
276
|
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm-scripts.js — shared `package.json` scripts probe (Story #4473).
|
|
3
|
+
*
|
|
4
|
+
* A single, dependency-free reader used wherever the framework must decide
|
|
5
|
+
* whether a consumer actually ships a given npm script before spawning
|
|
6
|
+
* `npm run <name>`. Two call sites depend on it:
|
|
7
|
+
* - `close-validation/gates.js` — only registers the coverage-capture gate
|
|
8
|
+
* when a `test:coverage` script exists (otherwise a consumer without it
|
|
9
|
+
* turns the gate into a guaranteed first-try close failure).
|
|
10
|
+
* - `coverage-capture.js` — fails fast with a one-line, fix-naming
|
|
11
|
+
* diagnostic instead of surfacing npm's opaque "Missing script" exit when
|
|
12
|
+
* invoked without the script.
|
|
13
|
+
*
|
|
14
|
+
* The reader is deliberately forgiving: any failure (missing file,
|
|
15
|
+
* unreadable, unparseable, or no `scripts` object) resolves to an empty map
|
|
16
|
+
* so callers treat "cannot prove the script exists" as "absent" without
|
|
17
|
+
* throwing.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Read the `scripts` map from the `package.json` at `cwd`.
|
|
25
|
+
*
|
|
26
|
+
* @param {string|undefined|null} cwd - Directory containing `package.json`.
|
|
27
|
+
* Defaults to `process.cwd()`.
|
|
28
|
+
* @returns {Record<string, string>} The scripts map, or `{}` on any failure.
|
|
29
|
+
*/
|
|
30
|
+
export function readPackageScripts(cwd) {
|
|
31
|
+
try {
|
|
32
|
+
const pkgPath = path.join(cwd || process.cwd(), 'package.json');
|
|
33
|
+
if (!existsSync(pkgPath)) return {};
|
|
34
|
+
const parsed = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
35
|
+
return parsed && typeof parsed.scripts === 'object' && parsed.scripts
|
|
36
|
+
? parsed.scripts
|
|
37
|
+
: {};
|
|
38
|
+
} catch {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Does the consumer define a runnable npm script by this name? A script is
|
|
45
|
+
* "runnable" when it is a present, non-empty string.
|
|
46
|
+
*
|
|
47
|
+
* @param {Record<string, string>} scripts - A scripts map (from
|
|
48
|
+
* `readPackageScripts`).
|
|
49
|
+
* @param {string} name - The script name to check (e.g. `test:coverage`).
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
export function hasNpmScript(scripts, name) {
|
|
53
|
+
const s = scripts?.[name];
|
|
54
|
+
return typeof s === 'string' && s.trim().length > 0;
|
|
55
|
+
}
|
|
@@ -48,9 +48,13 @@
|
|
|
48
48
|
*
|
|
49
49
|
* The schema declares `additionalProperties: false`, so this emitter's
|
|
50
50
|
* signature is deliberately narrow: only the schema-allowed fields are
|
|
51
|
-
* accepted. `blockClass` MUST be
|
|
52
|
-
* `merge-block-class.js` —
|
|
53
|
-
*
|
|
51
|
+
* accepted. `blockClass` MUST be a valid `merge.unlanded` attribution from
|
|
52
|
+
* `merge-block-class.js` (`MERGE_UNLANDED_BLOCK_CLASSES` — the four
|
|
53
|
+
* `classifyMergeBlock` outputs plus the directly-emitted `predicate-refused`,
|
|
54
|
+
* Story #4472). For a post-arm poll-exhaustion block, pass the classifier's
|
|
55
|
+
* verdict straight through (`classifyMergeBlock(...)` returns
|
|
56
|
+
* `{ blockClass, reason }`); the predicate/armer refusal paths pass
|
|
57
|
+
* `predicate-refused` / a classified arm failure directly.
|
|
54
58
|
*/
|
|
55
59
|
|
|
56
60
|
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
@@ -98,8 +102,9 @@ function getValidator() {
|
|
|
98
102
|
* @param {number} opts.ticketId epicId when `scope === 'epic'`,
|
|
99
103
|
* storyId when `scope === 'story'`.
|
|
100
104
|
* @param {number} opts.prNumber The PR number that did not land.
|
|
101
|
-
* @param {string} opts.blockClass
|
|
102
|
-
* `
|
|
105
|
+
* @param {string} opts.blockClass A valid `merge.unlanded` attribution
|
|
106
|
+
* (`MERGE_UNLANDED_BLOCK_CLASSES` in
|
|
107
|
+
* `merge-block-class.js`).
|
|
103
108
|
* @param {string} opts.reason Free-form diagnosis detail — pass
|
|
104
109
|
* the classifier's `reason`.
|
|
105
110
|
* @param {number} opts.elapsedSeconds Elapsed watch/poll time when the
|
|
@@ -54,7 +54,10 @@
|
|
|
54
54
|
|
|
55
55
|
import { spawnSync } from 'node:child_process';
|
|
56
56
|
|
|
57
|
+
import { parsePrNumberFromUrl } from '../../../github-url.js';
|
|
57
58
|
import { resolveAutoMergeArmCwd } from '../../auto-merge-cwd.js';
|
|
59
|
+
import { classifyMergeBlock } from '../../merge-block-class.js';
|
|
60
|
+
import { emitMergeUnlanded } from '../emit-merge-unlanded.js';
|
|
58
61
|
|
|
59
62
|
/**
|
|
60
63
|
* Default `gh pr view --json autoMergeRequest` probe. Pure-spawn helper
|
|
@@ -109,6 +112,64 @@ export function ghPrMergeAuto({
|
|
|
109
112
|
};
|
|
110
113
|
}
|
|
111
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Story #4472 — direct (non-`--auto`) squash-merge fallback.
|
|
117
|
+
*
|
|
118
|
+
* GitHub's native auto-merge (`gh pr merge --auto`) can only be QUEUED on a
|
|
119
|
+
* repository that has the "Allow auto-merge" setting enabled — which in
|
|
120
|
+
* practice requires branch protection. A repo with zero required checks and
|
|
121
|
+
* no branch protection (every mandrel-bench sandbox, many real consumer
|
|
122
|
+
* repos) rejects the `--auto` arm outright with `Auto merge is not allowed
|
|
123
|
+
* for this repository`. The AutomergePredicate has already cleared the merge
|
|
124
|
+
* (green/absent required checks + a clean structured-signal verdict) by the
|
|
125
|
+
* time the armer runs, so the safe, must-land-satisfying fallback is a
|
|
126
|
+
* direct immediate squash-merge — the epic path's observed de-facto manual
|
|
127
|
+
* fallback, made legal and kept inside the sole authorized `gh pr merge`
|
|
128
|
+
* call site.
|
|
129
|
+
*
|
|
130
|
+
* Same `--squash --delete-branch` shape and same `resolveArmCwd` re-point as
|
|
131
|
+
* `ghPrMergeAuto` (so the trailing local `--delete-branch` housekeeping runs
|
|
132
|
+
* from the primary worktree, not a head-branch worktree). Omitting `--auto`
|
|
133
|
+
* makes `gh` merge synchronously.
|
|
134
|
+
*/
|
|
135
|
+
export function ghPrMergeDirect({
|
|
136
|
+
prUrl,
|
|
137
|
+
cwd,
|
|
138
|
+
spawnFn = spawnSync,
|
|
139
|
+
resolveArmCwd = resolveAutoMergeArmCwd,
|
|
140
|
+
}) {
|
|
141
|
+
const armCwd = resolveArmCwd(cwd);
|
|
142
|
+
const result = spawnFn(
|
|
143
|
+
'gh',
|
|
144
|
+
['pr', 'merge', prUrl, '--squash', '--delete-branch'],
|
|
145
|
+
{ cwd: armCwd, encoding: 'utf-8', shell: false },
|
|
146
|
+
);
|
|
147
|
+
return {
|
|
148
|
+
status: result.status ?? 1,
|
|
149
|
+
stdout: result.stdout ?? '',
|
|
150
|
+
stderr: result.stderr ?? '',
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Pure: does this `gh pr merge --auto` stderr indicate that native
|
|
156
|
+
* auto-merge is unavailable on the repository (as opposed to a genuine arm
|
|
157
|
+
* failure — auth, a merge conflict, an already-merged race)? Only this
|
|
158
|
+
* specific class of failure is safe to retry as a direct merge; everything
|
|
159
|
+
* else must surface as a real failure. Matched case-insensitively.
|
|
160
|
+
*
|
|
161
|
+
* Exported so the marker set is reviewable and testable in isolation.
|
|
162
|
+
*/
|
|
163
|
+
export function isAutoMergeUnavailable(stderr) {
|
|
164
|
+
const text = String(stderr ?? '').toLowerCase();
|
|
165
|
+
return (
|
|
166
|
+
text.includes('auto merge is not allowed') ||
|
|
167
|
+
text.includes('auto-merge is not allowed') ||
|
|
168
|
+
text.includes('enablepullrequestautomerge') ||
|
|
169
|
+
(text.includes('auto') && text.includes('not enabled'))
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
112
173
|
/**
|
|
113
174
|
* Pure: parse `gh pr view --json autoMergeRequest,mergeCommit` output.
|
|
114
175
|
* `autoMergeRequest` is `null` when auto-merge is NOT armed; a non-null
|
|
@@ -172,9 +233,17 @@ export class AutomergeArmer {
|
|
|
172
233
|
/**
|
|
173
234
|
* @param {object} opts
|
|
174
235
|
* @param {object} opts.bus
|
|
236
|
+
* @param {number} [opts.epicId] Epic id — required for the headless
|
|
237
|
+
* `merge.unlanded` attribution on a genuine arm failure (Story #4472).
|
|
238
|
+
* @param {boolean} [opts.headless] When true (a `/deliver --yes` run), a
|
|
239
|
+
* genuine (non-fallback) arm failure escalates to an explicit
|
|
240
|
+
* `merge.unlanded` + `epic.blocked` terminal instead of returning
|
|
241
|
+
* silently (Story #4472). Defaults to `false` (attended).
|
|
175
242
|
* @param {string} [opts.cwd]
|
|
176
243
|
* @param {Function} [opts.ghPrViewAutoMergeFn] override for tests.
|
|
177
244
|
* @param {Function} [opts.ghPrMergeAutoFn] override for tests.
|
|
245
|
+
* @param {Function} [opts.ghPrMergeDirectFn] override for tests.
|
|
246
|
+
* @param {Function} [opts.emitMergeUnlandedFn] override for tests.
|
|
178
247
|
* @param {{ info?: Function, warn?: Function, debug?: Function }} [opts.logger]
|
|
179
248
|
*/
|
|
180
249
|
constructor(opts = {}) {
|
|
@@ -186,9 +255,13 @@ export class AutomergeArmer {
|
|
|
186
255
|
throw new TypeError('AutomergeArmer requires a bus with on() and emit()');
|
|
187
256
|
}
|
|
188
257
|
this.bus = opts.bus;
|
|
258
|
+
this.epicId = Number.isInteger(opts.epicId) ? opts.epicId : null;
|
|
259
|
+
this.headless = opts.headless === true;
|
|
189
260
|
this.cwd = opts.cwd ?? process.cwd();
|
|
190
261
|
this.ghPrViewAutoMergeFn = opts.ghPrViewAutoMergeFn ?? ghPrViewAutoMerge;
|
|
191
262
|
this.ghPrMergeAutoFn = opts.ghPrMergeAutoFn ?? ghPrMergeAuto;
|
|
263
|
+
this.ghPrMergeDirectFn = opts.ghPrMergeDirectFn ?? ghPrMergeDirect;
|
|
264
|
+
this.emitMergeUnlandedFn = opts.emitMergeUnlandedFn ?? emitMergeUnlanded;
|
|
192
265
|
this.logger = opts.logger ?? console;
|
|
193
266
|
/** @type {Set<string>} `${event}:${seqId}` idempotency cache. */
|
|
194
267
|
this._seen = new Set();
|
|
@@ -293,16 +366,26 @@ export class AutomergeArmer {
|
|
|
293
366
|
await this._emitArmed(prUrl);
|
|
294
367
|
return;
|
|
295
368
|
}
|
|
296
|
-
|
|
369
|
+
|
|
370
|
+
// Story #4472 — native auto-merge is unavailable on this repository
|
|
371
|
+
// (no branch protection / "Allow auto-merge" disabled). The predicate
|
|
372
|
+
// already cleared the merge, so fall back to a direct immediate
|
|
373
|
+
// squash-merge instead of stranding a landable PR on the
|
|
374
|
+
// operator-merges path.
|
|
375
|
+
if (isAutoMergeUnavailable(arm.stderr)) {
|
|
376
|
+
const armed = await this._tryDirectMerge({ event, seqId, prUrl, arm });
|
|
377
|
+
if (armed) return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Genuine arm failure (auth, conflict, an unresolved direct-merge
|
|
381
|
+
// fallback, …). Classify + escalate.
|
|
382
|
+
await this._emitArmFailure({
|
|
297
383
|
event,
|
|
298
384
|
seqId,
|
|
299
|
-
|
|
385
|
+
prUrl,
|
|
300
386
|
reason: `arm-failed:status=${arm.status}`,
|
|
301
387
|
ghStderr: arm.stderr,
|
|
302
388
|
});
|
|
303
|
-
this.logger.warn?.(
|
|
304
|
-
`[AutomergeArmer] gh pr merge --auto failed (status=${arm.status}): ${arm.stderr}`,
|
|
305
|
-
);
|
|
306
389
|
return;
|
|
307
390
|
}
|
|
308
391
|
|
|
@@ -320,6 +403,97 @@ export class AutomergeArmer {
|
|
|
320
403
|
}
|
|
321
404
|
}
|
|
322
405
|
|
|
406
|
+
/**
|
|
407
|
+
* Story #4472 — direct-merge fallback when native auto-merge is
|
|
408
|
+
* unavailable. Runs an immediate `gh pr merge --squash --delete-branch`
|
|
409
|
+
* (no `--auto`) then re-probes; on a confirmed merge (or the same
|
|
410
|
+
* post-merge `--delete-branch` housekeeping grumble the `--auto` path
|
|
411
|
+
* already tolerates) it emits `epic.merge.armed` so the
|
|
412
|
+
* MergeWatcher → Cleaner → LabelTransitioner chain engages and confirms
|
|
413
|
+
* the merge on its first poll.
|
|
414
|
+
*
|
|
415
|
+
* @returns {Promise<boolean>} `true` when the fallback landed the PR (an
|
|
416
|
+
* `epic.merge.armed` was emitted); `false` when the direct merge did
|
|
417
|
+
* not land, so the caller escalates the original arm failure.
|
|
418
|
+
*/
|
|
419
|
+
async _tryDirectMerge({ event, seqId, prUrl, arm }) {
|
|
420
|
+
this.logger.info?.(
|
|
421
|
+
`[AutomergeArmer] native auto-merge unavailable (${arm.stderr?.trim?.() ?? arm.stderr}); falling back to a direct squash-merge on ${prUrl}.`,
|
|
422
|
+
);
|
|
423
|
+
const direct = this.ghPrMergeDirectFn({ prUrl, cwd: this.cwd });
|
|
424
|
+
const recheck = this.ghPrViewAutoMergeFn({ prUrl, cwd: this.cwd });
|
|
425
|
+
const merged =
|
|
426
|
+
recheck.status === 0 &&
|
|
427
|
+
(parsePrMerged(recheck.stdout) || parseAutoMergeArmed(recheck.stdout));
|
|
428
|
+
if (direct.status === 0 || merged) {
|
|
429
|
+
this.classifications.push({
|
|
430
|
+
event,
|
|
431
|
+
seqId,
|
|
432
|
+
outcome: 'armed',
|
|
433
|
+
prUrl,
|
|
434
|
+
note: `direct-merge fallback (native auto-merge unavailable); direct exit ${direct.status}${direct.status !== 0 ? ` but re-probe shows merged/armed (housekeeping stderr: ${direct.stderr})` : ''}`,
|
|
435
|
+
});
|
|
436
|
+
await this._emitArmed(prUrl);
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
this.logger.warn?.(
|
|
440
|
+
`[AutomergeArmer] direct-merge fallback failed (status=${direct.status}): ${direct.stderr}`,
|
|
441
|
+
);
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Classify + (in headless) escalate a genuine arm failure. The `--auto`
|
|
447
|
+
* path historically returned silently here; a `/deliver --yes` run has no
|
|
448
|
+
* operator to notice, so we mirror the MergeWatcher's terminal:
|
|
449
|
+
* `merge.unlanded` ledger attribution + an explicit `epic.blocked`
|
|
450
|
+
* transition. Attended runs keep the classify-and-return behaviour.
|
|
451
|
+
*/
|
|
452
|
+
async _emitArmFailure({ event, seqId, prUrl, reason, ghStderr }) {
|
|
453
|
+
this.classifications.push({
|
|
454
|
+
event,
|
|
455
|
+
seqId,
|
|
456
|
+
outcome: 'failed',
|
|
457
|
+
reason,
|
|
458
|
+
ghStderr,
|
|
459
|
+
});
|
|
460
|
+
this.logger.warn?.(
|
|
461
|
+
`[AutomergeArmer] gh pr merge --auto failed (${reason}): ${ghStderr}`,
|
|
462
|
+
);
|
|
463
|
+
if (!this.headless) return;
|
|
464
|
+
const classification = classifyMergeBlock({
|
|
465
|
+
armResult: { armed: false, reason: ghStderr },
|
|
466
|
+
});
|
|
467
|
+
const prNumber = parsePrNumberFromUrl(prUrl);
|
|
468
|
+
if (
|
|
469
|
+
Number.isInteger(this.epicId) &&
|
|
470
|
+
Number.isInteger(prNumber) &&
|
|
471
|
+
prNumber > 0
|
|
472
|
+
) {
|
|
473
|
+
try {
|
|
474
|
+
this.emitMergeUnlandedFn({
|
|
475
|
+
scope: 'epic',
|
|
476
|
+
ticketId: this.epicId,
|
|
477
|
+
prNumber,
|
|
478
|
+
blockClass: classification.blockClass,
|
|
479
|
+
reason: classification.reason,
|
|
480
|
+
elapsedSeconds: 0,
|
|
481
|
+
});
|
|
482
|
+
} catch (err) {
|
|
483
|
+
this.logger.warn?.(
|
|
484
|
+
`[AutomergeArmer] emitMergeUnlanded failed (swallowed): ${err?.message ?? err}`,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
await this.bus.emit('epic.blocked', { reason: `merge-arm:failed` });
|
|
490
|
+
} catch (err) {
|
|
491
|
+
this.logger.warn?.(
|
|
492
|
+
`[AutomergeArmer] epic.blocked emit on arm failure failed (swallowed): ${err?.message ?? err}`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
323
497
|
reset() {
|
|
324
498
|
this._seen.clear();
|
|
325
499
|
this.classifications = [];
|
|
@@ -68,8 +68,10 @@ import { spawnSync } from 'node:child_process';
|
|
|
68
68
|
|
|
69
69
|
import { hasSurvivingCritical } from '../../../audit-suite/findings.js';
|
|
70
70
|
import { getCiDelivery } from '../../../config/ci.js';
|
|
71
|
+
import { parsePrNumberFromUrl } from '../../../github-url.js';
|
|
71
72
|
import * as epicRunStateStore from '../../epic-run-state-store.js';
|
|
72
73
|
import { findStructuredComment } from '../../ticketing.js';
|
|
74
|
+
import { emitMergeUnlanded } from '../emit-merge-unlanded.js';
|
|
73
75
|
import { normalizeCheckState, RECOGNIZED_CHECK_STATES } from './watcher.js';
|
|
74
76
|
|
|
75
77
|
/**
|
|
@@ -224,19 +226,51 @@ export function probeRequiredChecks({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
|
224
226
|
* stdout. We parse stdout first (it is populated even on the non-zero
|
|
225
227
|
* exit) and classify from the outcomes.
|
|
226
228
|
*
|
|
229
|
+
* Story #4472 — checks-less repos. In a repo with zero required checks
|
|
230
|
+
* (no branch protection, or protection that requires no status checks),
|
|
231
|
+
* `gh pr checks --required` writes NOTHING to stdout and reports
|
|
232
|
+
* `no checks reported on the <branch> branch` to stderr with a non-zero
|
|
233
|
+
* exit. That is the SAME empty-parsed-set condition the outcomes loop
|
|
234
|
+
* below already treats as green — there is simply nothing to gate on — so
|
|
235
|
+
* we must not conflate it with a genuine probe failure (auth, network, no
|
|
236
|
+
* PR). We detect the `no checks reported` stderr signature and return
|
|
237
|
+
* green, UNLESS the consumer opted into `delivery.ci.requireChecks`, in
|
|
238
|
+
* which case the absent CI gate is a deliberate hard block.
|
|
239
|
+
*
|
|
227
240
|
* @param {{ status: number, stdout: string, stderr: string }} probe
|
|
241
|
+
* @param {{ requireChecks?: boolean }} [opts] When `requireChecks` is
|
|
242
|
+
* true, a checks-less repo fails closed instead of arming.
|
|
228
243
|
* @returns {{ ok: boolean, reason: string|null, outcomes: Record<string, string> }}
|
|
229
244
|
*/
|
|
230
|
-
export function classifyRequiredChecksProbe(
|
|
245
|
+
export function classifyRequiredChecksProbe(
|
|
246
|
+
probe,
|
|
247
|
+
{ requireChecks = false } = {},
|
|
248
|
+
) {
|
|
231
249
|
const stdout = String(probe?.stdout ?? '').trim();
|
|
232
|
-
|
|
233
|
-
//
|
|
250
|
+
const stderr = String(probe?.stderr ?? '').trim();
|
|
251
|
+
// Empty stdout: either a checks-less repo (green, nothing to gate on) or
|
|
252
|
+
// a genuine probe failure. The `no checks reported` stderr signature
|
|
253
|
+
// distinguishes them.
|
|
234
254
|
if (stdout.length === 0) {
|
|
255
|
+
const noChecksReported = /no checks reported/i.test(stderr);
|
|
256
|
+
if (noChecksReported && !requireChecks) {
|
|
257
|
+
// Zero required checks configured — matches the empty-parsed-set
|
|
258
|
+
// "treated as green" branch below. Nothing to gate on.
|
|
259
|
+
return { ok: true, reason: null, outcomes: {} };
|
|
260
|
+
}
|
|
261
|
+
if (noChecksReported && requireChecks) {
|
|
262
|
+
return {
|
|
263
|
+
ok: false,
|
|
264
|
+
reason:
|
|
265
|
+
'no required checks reported and delivery.ci.requireChecks is set — failing closed per policy',
|
|
266
|
+
outcomes: {},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
235
269
|
return {
|
|
236
270
|
ok: false,
|
|
237
271
|
reason:
|
|
238
272
|
`live required-check probe failed (status=${probe?.status ?? 'unknown'})` +
|
|
239
|
-
(
|
|
273
|
+
(stderr ? `: ${stderr.slice(0, 200)}` : ''),
|
|
240
274
|
outcomes: {},
|
|
241
275
|
};
|
|
242
276
|
}
|
|
@@ -678,8 +712,13 @@ export class AutomergePredicate {
|
|
|
678
712
|
* evaluator). Required for the read of run-state + structured
|
|
679
713
|
* comments.
|
|
680
714
|
* @param {object} [opts.config] Resolved agent config. Read for the
|
|
681
|
-
* `delivery.ci.autoMerge` policy
|
|
682
|
-
*
|
|
715
|
+
* `delivery.ci.autoMerge` policy and the `delivery.ci.requireChecks`
|
|
716
|
+
* fail-closed-without-checks policy via `getCiDelivery`. Defaults to the
|
|
717
|
+
* framework defaults (`trust-ci` / `requireChecks: false`) when omitted.
|
|
718
|
+
* @param {boolean} [opts.headless] When true (a `/deliver --yes` run), a
|
|
719
|
+
* predicate refusal escalates to an explicit `merge.unlanded` +
|
|
720
|
+
* `epic.blocked` terminal instead of silently parking on the
|
|
721
|
+
* operator-merges path (Story #4472). Defaults to `false` (attended).
|
|
683
722
|
* @param {string} [opts.cwd] Working directory for the live
|
|
684
723
|
* `gh pr checks --required` probe. Defaults to `process.cwd()`.
|
|
685
724
|
* @param {Function} [opts.evaluatePredicateFn] override of
|
|
@@ -708,13 +747,20 @@ export class AutomergePredicate {
|
|
|
708
747
|
this.epicId = opts.epicId;
|
|
709
748
|
this.provider = opts.provider;
|
|
710
749
|
this.cwd = opts.cwd ?? process.cwd();
|
|
711
|
-
// Resolve the merge posture once at construction.
|
|
712
|
-
// applies the framework
|
|
713
|
-
|
|
750
|
+
// Resolve the merge posture + fail-closed policy once at construction.
|
|
751
|
+
// `getCiDelivery` applies the framework defaults (`trust-ci` /
|
|
752
|
+
// `requireChecks: false`) for any omitted field.
|
|
753
|
+
const ci = getCiDelivery(opts.config ?? null);
|
|
754
|
+
this.policy = ci.autoMerge;
|
|
755
|
+
this.requireChecks = ci.requireChecks;
|
|
756
|
+
this.headless = opts.headless === true;
|
|
714
757
|
this.evaluatePredicateFn =
|
|
715
758
|
opts.evaluatePredicateFn ?? evaluateAutoMergePredicate;
|
|
716
759
|
this.probeRequiredChecksFn =
|
|
717
760
|
opts.probeRequiredChecksFn ?? probeRequiredChecks;
|
|
761
|
+
// Injected for tests so the headless terminal escalation can be
|
|
762
|
+
// observed without touching disk.
|
|
763
|
+
this.emitMergeUnlandedFn = opts.emitMergeUnlandedFn ?? emitMergeUnlanded;
|
|
718
764
|
this.logger = opts.logger ?? console;
|
|
719
765
|
/** @type {Set<string>} `${event}:${seqId}` idempotency cache. */
|
|
720
766
|
this._seen = new Set();
|
|
@@ -786,7 +832,9 @@ export class AutomergePredicate {
|
|
|
786
832
|
let probeVerdict;
|
|
787
833
|
try {
|
|
788
834
|
const probe = this.probeRequiredChecksFn({ prUrl, cwd: this.cwd });
|
|
789
|
-
probeVerdict = classifyRequiredChecksProbe(probe
|
|
835
|
+
probeVerdict = classifyRequiredChecksProbe(probe, {
|
|
836
|
+
requireChecks: this.requireChecks,
|
|
837
|
+
});
|
|
790
838
|
} catch (err) {
|
|
791
839
|
probeVerdict = {
|
|
792
840
|
ok: false,
|
|
@@ -878,6 +926,17 @@ export class AutomergePredicate {
|
|
|
878
926
|
* Emit `epic.merge.blocked`. Helper carved out so the blocking paths
|
|
879
927
|
* (CI failure / predicate dirty / evaluator throw) share the same emit
|
|
880
928
|
* shape.
|
|
929
|
+
*
|
|
930
|
+
* Story #4472 — must-land coverage of predicate refusal. In a headless
|
|
931
|
+
* (`/deliver --yes`) run there is no operator to act on a bare
|
|
932
|
+
* `epic.merge.blocked` (nothing in the listener chain consumes it), so
|
|
933
|
+
* the run would silently park on the operator-merges path. When
|
|
934
|
+
* `this.headless`, we additionally attribute the refusal to the
|
|
935
|
+
* lifecycle ledger via `merge.unlanded` (blockClass `predicate-refused`)
|
|
936
|
+
* and drive the explicit `epic.blocked` terminal — the same
|
|
937
|
+
* escalation the MergeWatcher performs on post-arm budget exhaustion —
|
|
938
|
+
* so the Epic transitions to `agent::blocked` with an operator-visible
|
|
939
|
+
* reason instead of stalling.
|
|
881
940
|
*/
|
|
882
941
|
async _emitBlocked(prUrl, reason) {
|
|
883
942
|
try {
|
|
@@ -887,6 +946,35 @@ export class AutomergePredicate {
|
|
|
887
946
|
`[AutomergePredicate] epic.merge.blocked emit failed (swallowed): ${err?.message ?? err}`,
|
|
888
947
|
);
|
|
889
948
|
}
|
|
949
|
+
if (!this.headless) return;
|
|
950
|
+
// Ledger attribution — best-effort; a failed append must NOT mask the
|
|
951
|
+
// epic.blocked transition below.
|
|
952
|
+
try {
|
|
953
|
+
const prNumber = parsePrNumberFromUrl(prUrl);
|
|
954
|
+
if (Number.isInteger(prNumber) && prNumber > 0) {
|
|
955
|
+
this.emitMergeUnlandedFn({
|
|
956
|
+
scope: 'epic',
|
|
957
|
+
ticketId: this.epicId,
|
|
958
|
+
prNumber,
|
|
959
|
+
blockClass: 'predicate-refused',
|
|
960
|
+
reason,
|
|
961
|
+
elapsedSeconds: 0,
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
} catch (err) {
|
|
965
|
+
this.logger.warn?.(
|
|
966
|
+
`[AutomergePredicate] emitMergeUnlanded failed (swallowed): ${err?.message ?? err}`,
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
try {
|
|
970
|
+
await this.bus.emit('epic.blocked', {
|
|
971
|
+
reason: `merge-predicate:refused`,
|
|
972
|
+
});
|
|
973
|
+
} catch (err) {
|
|
974
|
+
this.logger.warn?.(
|
|
975
|
+
`[AutomergePredicate] epic.blocked emit on predicate refusal failed (swallowed): ${err?.message ?? err}`,
|
|
976
|
+
);
|
|
977
|
+
}
|
|
890
978
|
}
|
|
891
979
|
|
|
892
980
|
reset() {
|
|
@@ -226,10 +226,15 @@ export async function buildDefaultListenerChain(opts = {}) {
|
|
|
226
226
|
order.push('Finalizer');
|
|
227
227
|
|
|
228
228
|
// 4. AutomergeArmer — arms `gh pr merge --auto --squash --delete-branch`
|
|
229
|
-
// on epic.merge.ready.
|
|
229
|
+
// on epic.merge.ready. Story #4472: `headless` gates the direct-merge
|
|
230
|
+
// fallback's terminal escalation (a genuine arm failure emits
|
|
231
|
+
// `merge.unlanded` + `epic.blocked` in a `--yes` run instead of
|
|
232
|
+
// returning silently); `epicId` scopes the `merge.unlanded` ledger row.
|
|
230
233
|
const automergeArmer = new AutomergeArmer({
|
|
231
234
|
bus,
|
|
235
|
+
epicId,
|
|
232
236
|
cwd: repoRoot,
|
|
237
|
+
headless,
|
|
233
238
|
logger,
|
|
234
239
|
});
|
|
235
240
|
automergeArmer.register();
|
|
@@ -250,6 +255,7 @@ export async function buildDefaultListenerChain(opts = {}) {
|
|
|
250
255
|
provider,
|
|
251
256
|
config,
|
|
252
257
|
cwd: repoRoot,
|
|
258
|
+
headless,
|
|
253
259
|
logger,
|
|
254
260
|
});
|
|
255
261
|
automergePredicate.register();
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* one of four classes from the SAME decision logic, instead of each path
|
|
11
11
|
* inventing its own ad hoc diagnosis.
|
|
12
12
|
*
|
|
13
|
-
* Block classes (Epic #4425 Goal):
|
|
13
|
+
* Block classes (Epic #4425 Goal; `predicate-refused` added by #4472):
|
|
14
14
|
* - `checks-pending-timeout` The watch/poll budget was
|
|
15
15
|
* exhausted while required checks
|
|
16
16
|
* were still pending/running — not
|
|
@@ -33,6 +33,18 @@
|
|
|
33
33
|
* a transient GraphQL/API error, an
|
|
34
34
|
* ambiguous probe result, or a
|
|
35
35
|
* genuinely novel condition.
|
|
36
|
+
* - `predicate-refused` The AutomergePredicate refused to
|
|
37
|
+
* arm merge BEFORE any arm attempt —
|
|
38
|
+
* a red/pending required check, an
|
|
39
|
+
* unreadable check probe, a dirty
|
|
40
|
+
* structured-signal verdict, or a
|
|
41
|
+
* `delivery.ci.requireChecks` policy
|
|
42
|
+
* block on a checks-less repo (#4472).
|
|
43
|
+
* The must-land contract previously
|
|
44
|
+
* only covered post-arm poll
|
|
45
|
+
* exhaustion, so a predicate refusal
|
|
46
|
+
* in headless mode silently parked;
|
|
47
|
+
* this class makes it attributable.
|
|
36
48
|
*
|
|
37
49
|
* Pure function, no I/O: callers pass in the already-observed
|
|
38
50
|
* arm-result / PR-probe / budget signals (from `AutomergeArmer`,
|
|
@@ -55,12 +67,28 @@ export const BLOCK_CLASSES = Object.freeze([
|
|
|
55
67
|
'api-race-other',
|
|
56
68
|
]);
|
|
57
69
|
|
|
58
|
-
|
|
70
|
+
/**
|
|
71
|
+
* The full set of block-class values a `merge.unlanded` record may carry.
|
|
72
|
+
* This is the classifier's four outputs PLUS `predicate-refused` (#4472),
|
|
73
|
+
* which is emitted DIRECTLY by the AutomergePredicate / AutomergeArmer for a
|
|
74
|
+
* headless refusal that never reached the poll-exhaustion classifier — so it
|
|
75
|
+
* is a valid attribution value even though `classifyMergeBlock` never
|
|
76
|
+
* produces it. `isValidBlockClass` (and the `merge.unlanded` schema enum)
|
|
77
|
+
* validate against this broader set; the classifier's own reachability
|
|
78
|
+
* invariant stays scoped to `BLOCK_CLASSES`.
|
|
79
|
+
*/
|
|
80
|
+
export const MERGE_UNLANDED_BLOCK_CLASSES = Object.freeze([
|
|
81
|
+
...BLOCK_CLASSES,
|
|
82
|
+
'predicate-refused',
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
const BLOCK_CLASS_SET = new Set(MERGE_UNLANDED_BLOCK_CLASSES);
|
|
59
86
|
|
|
60
87
|
/**
|
|
61
88
|
* @param {string} value
|
|
62
|
-
* @returns {boolean} `true` iff `value` is
|
|
63
|
-
* block
|
|
89
|
+
* @returns {boolean} `true` iff `value` is a valid `merge.unlanded`
|
|
90
|
+
* block-class attribution (the four classifier outputs plus the directly-
|
|
91
|
+
* emitted `predicate-refused`).
|
|
64
92
|
*/
|
|
65
93
|
export function isValidBlockClass(value) {
|
|
66
94
|
return BLOCK_CLASS_SET.has(value);
|
|
@@ -125,7 +125,11 @@ export async function runCloseValidationPhase({
|
|
|
125
125
|
const validation = await runCloseValidation({
|
|
126
126
|
cwd,
|
|
127
127
|
worktreePath,
|
|
128
|
-
gates: buildDefaultGates({
|
|
128
|
+
gates: buildDefaultGates({
|
|
129
|
+
config,
|
|
130
|
+
epicBranch: baseBranch,
|
|
131
|
+
cwd: worktreePath || cwd,
|
|
132
|
+
}),
|
|
129
133
|
log: (m) => Logger.info(m),
|
|
130
134
|
storyId,
|
|
131
135
|
// Story #4250 — standalone storyId-anchored evidence keyspace. No
|
|
@@ -133,7 +133,14 @@ export async function runPreMergeGates({
|
|
|
133
133
|
// `buildDefaultGates` reads the canonical resolved config directly:
|
|
134
134
|
// gate commands resolve from `project.commands` and the CRAP toggle
|
|
135
135
|
// from `delivery.quality.gates.crap.enabled`.
|
|
136
|
-
|
|
136
|
+
// Probe the coverage script from the gate execution directory (the Story
|
|
137
|
+
// worktree when present) so coverage-capture is only registered when the
|
|
138
|
+
// consumer ships `test:coverage` (#4473).
|
|
139
|
+
const gates = buildDefaultGates({
|
|
140
|
+
config,
|
|
141
|
+
epicBranch,
|
|
142
|
+
cwd: worktreePath || cwd,
|
|
143
|
+
});
|
|
137
144
|
const gateCount = Array.isArray(gates) ? gates.length : 0;
|
|
138
145
|
// Story #2250 — emit `close-validate.start` only when both an epicId
|
|
139
146
|
// and a storyId are present; the schema requires both, and unit
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.90.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.89.0...mandrel-v1.90.0) (2026-07-12)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
* **delivery:** land headless PRs in checks-less repos + degrade coverage gate without test:coverage ([#4480](https://github.com/dsj1984/mandrel/issues/4480)) ([9836f1e](https://github.com/dsj1984/mandrel/commit/9836f1ec9beb4b2798bd72e869214e5705296522)), closes [#4472](https://github.com/dsj1984/mandrel/issues/4472) [#4473](https://github.com/dsj1984/mandrel/issues/4473)
|
|
11
|
+
|
|
5
12
|
## [1.89.0](https://github.com/dsj1984/mandrel/compare/mandrel-v1.88.0...mandrel-v1.89.0) (2026-07-11)
|
|
6
13
|
|
|
7
14
|
|
package/package.json
CHANGED