mandrel 1.84.0 → 1.85.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/schemas/agentrc.schema.json +29 -6
- package/.agents/schemas/lifecycle/epic.watch.end.schema.json +2 -1
- 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/finalize/open-or-locate-pr.js +65 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +401 -84
- 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/ticket-validator-sizing.js +17 -16
- 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/skills/core/epic-plan-decompose-author/SKILL.md +32 -1
- package/.agents/skills/core/scope-triage/SKILL.md +5 -4
- package/.agents/workflows/helpers/deliver-epic-reference.md +22 -8
- package/.agents/workflows/helpers/deliver-epic.md +123 -28
- package/.agents/workflows/helpers/deliver-stories.md +2 -2
- package/.agents/workflows/helpers/single-story-deliver-reference.md +3 -3
- package/.agents/workflows/helpers/single-story-deliver.md +56 -19
- package/docs/CHANGELOG.md +9 -0
- package/package.json +1 -1
|
@@ -4,27 +4,41 @@
|
|
|
4
4
|
* PR is safe to auto-merge after the required-check watch settles.
|
|
5
5
|
* Story #2256 / Task #2260 (Epic #2172); inlined from the now-deleted
|
|
6
6
|
* legacy `automerge-predicate` module in Story #2415 (Epic #2307).
|
|
7
|
+
* Rewritten in Story #4361 (Epic #4355) so green required CI is the
|
|
8
|
+
* arming signal, gated by the `delivery.ci.autoMerge` policy.
|
|
7
9
|
*
|
|
8
10
|
* Subscribes to:
|
|
9
11
|
* - `epic.automerge.start` (production path, Story #3901) → the
|
|
10
12
|
* `/deliver` Phase 8.5 boundary that the `lifecycle-emit.js`
|
|
11
13
|
* CLI actually fires. This event carries `prUrl` but NO
|
|
12
|
-
* `checkOutcomes
|
|
13
|
-
* polled every required check to
|
|
14
|
-
*
|
|
15
|
-
* the
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
14
|
+
* `checkOutcomes`. Before Story #4361 the listener trusted Phase 8
|
|
15
|
+
* (`pr-watch-with-update.js`) to have polled every required check to
|
|
16
|
+
* green and therefore skipped any CI probe on this path. That left a
|
|
17
|
+
* hole (Story #3901): if the Phase 8 watch was interrupted (host
|
|
18
|
+
* crash, `/loop` handoff, operator resume) the predicate could arm
|
|
19
|
+
* merge with red or pending required checks. Story #4361 closes it —
|
|
20
|
+
* the listener now runs a LIVE `gh pr checks --required` probe on
|
|
21
|
+
* `epic.automerge.start` and refuses to arm when any required check
|
|
22
|
+
* is not green, regardless of what Phase 8 believed.
|
|
19
23
|
* - `epic.watch.end` (test-only `Watcher` path) → carries an
|
|
20
24
|
* all-settled `checkOutcomes` map. Any non-passing required check
|
|
21
25
|
* is a hard block evaluated BEFORE the structured-signal evaluator.
|
|
26
|
+
* On this path the pre-supplied `checkOutcomes` map IS the CI truth,
|
|
27
|
+
* so no live probe is issued (the map is authoritative).
|
|
22
28
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
29
|
+
* Policy (`delivery.ci.autoMerge`, Story #4356 / #4361):
|
|
30
|
+
* - `"trust-ci"` (framework default) — green required CI is the arming
|
|
31
|
+
* signal. The ONLY structured conditions that block arming are an
|
|
32
|
+
* unresolved 🔴 critical (red) code-review finding or an
|
|
33
|
+
* `agent::blocked` state (a story-level blocker recorded in
|
|
34
|
+
* run-state, or a missing run-state checkpoint we cannot certify).
|
|
35
|
+
* Manual interventions, 🟠 warning-level findings, and a non-clean
|
|
36
|
+
* retro are RECORDED for audit (surfaced on the verdict and the
|
|
37
|
+
* classification log) but no longer block the merge.
|
|
38
|
+
* - `"strict"` — restores the prior clean-sprint predicate EXACTLY:
|
|
39
|
+
* zero manual interventions, every Story done, no story blockers,
|
|
40
|
+
* no 🔴/🟠 review findings, and a machine-readable `cleanSprint`
|
|
41
|
+
* retro trailer. Any dirty signal blocks.
|
|
28
42
|
*
|
|
29
43
|
* Code-review parse-miss policy (Story #4222): a code-review comment that is
|
|
30
44
|
* present but whose severity bullets cannot be parsed is treated as a DISTINCT
|
|
@@ -32,22 +46,8 @@
|
|
|
32
46
|
* rather than blocking. Failing closed on a format miss is indistinguishable,
|
|
33
47
|
* to the operator and to downstream telemetry, from a real disqualifying
|
|
34
48
|
* finding; a parser miss must never masquerade as "the signal said no" inside
|
|
35
|
-
* a generic `epic.merge.blocked`. Genuine critical/high findings still block
|
|
36
|
-
* because those require the counts to have parsed.
|
|
37
|
-
*
|
|
38
|
-
* Critical contract:
|
|
39
|
-
* - The verdict for any given input set is byte-identical to the
|
|
40
|
-
* pre-inlining legacy module's output — this file is its
|
|
41
|
-
* replacement. The merge-gate-ordering invariant
|
|
42
|
-
* (`epic.merge.armed` preceded by `epic.merge.ready`) depends on
|
|
43
|
-
* this listener being the sole emitter of `epic.merge.ready`.
|
|
44
|
-
*
|
|
45
|
-
* - Required-check outcomes from `epic.watch.end` are a NEW input
|
|
46
|
-
* not present in the legacy verdict: any check that is not
|
|
47
|
-
* `'success'`, `'neutral'`, or `'skipped'` flips the verdict to
|
|
48
|
-
* `blocked` BEFORE the legacy evaluator is even consulted (because
|
|
49
|
-
* a red CI check is a hard block regardless of the structured
|
|
50
|
-
* signals).
|
|
49
|
+
* a generic `epic.merge.blocked`. Genuine critical/high findings still block
|
|
50
|
+
* (per policy), because those require the counts to have parsed.
|
|
51
51
|
*
|
|
52
52
|
* Idempotency contract (AC-10): per-instance `Set<string>` of
|
|
53
53
|
* `${event}:${seqId}` keys. A repeat `(event, seqId)` short-circuits
|
|
@@ -55,15 +55,21 @@
|
|
|
55
55
|
* on GitHub state, so re-running it is safe; the seqId guard is the
|
|
56
56
|
* defence against double-emit.
|
|
57
57
|
*
|
|
58
|
-
* Side-effect firewall: the listener calls the read-only evaluator
|
|
59
|
-
* emits on the bus. It does NOT
|
|
60
|
-
* `notify`. Downstream consumers
|
|
61
|
-
* `epic.merge.ready`; LabelTransitioner /
|
|
62
|
-
* `epic.merge.blocked`) own those side
|
|
58
|
+
* Side-effect firewall: the listener calls the read-only evaluator, runs
|
|
59
|
+
* the read-only `gh pr checks` probe, and emits on the bus. It does NOT
|
|
60
|
+
* mutate labels, post comments, or call `notify`. Downstream consumers
|
|
61
|
+
* (`AutomergeArmer` on `epic.merge.ready`; LabelTransitioner /
|
|
62
|
+
* StructuredCommentPoster on `epic.merge.blocked`) own those side
|
|
63
|
+
* effects. AutomergeArmer remains the SOLE site that shells `gh pr merge`
|
|
64
|
+
* (the merge-lockout lint in `check-lifecycle-lint.js` enforces this).
|
|
63
65
|
*/
|
|
64
66
|
|
|
67
|
+
import { spawnSync } from 'node:child_process';
|
|
68
|
+
|
|
69
|
+
import { getCiDelivery } from '../../../config/ci.js';
|
|
65
70
|
import * as epicRunStateStore from '../../epic-run-state-store.js';
|
|
66
71
|
import { findStructuredComment } from '../../ticketing.js';
|
|
72
|
+
import { normalizeCheckState, RECOGNIZED_CHECK_STATES } from './watcher.js';
|
|
67
73
|
|
|
68
74
|
/**
|
|
69
75
|
* Outcomes that count as "this required check did not block the merge".
|
|
@@ -76,6 +82,41 @@ export const NON_FAILING_CHECK_OUTCOMES = Object.freeze(
|
|
|
76
82
|
new Set(['success', 'neutral', 'skipped']),
|
|
77
83
|
);
|
|
78
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Reason categories emitted by the structured-signal evaluator. The
|
|
87
|
+
* policy layer (`applyAutoMergePolicy`) decides which categories block
|
|
88
|
+
* arming under each `delivery.ci.autoMerge` posture. Pure constant —
|
|
89
|
+
* exported so the taxonomy is reviewable as code.
|
|
90
|
+
*
|
|
91
|
+
* - `criticalReview` — an unresolved 🔴 critical (red) code-review
|
|
92
|
+
* finding. Blocks under BOTH policies.
|
|
93
|
+
* - `blockedState` — an `agent::blocked` state (story-level blocker
|
|
94
|
+
* recorded in run-state, non-done stories, or a missing run-state
|
|
95
|
+
* checkpoint). Blocks under BOTH policies.
|
|
96
|
+
* - `warningReview` — a 🟠 warning-level (high-risk) code-review
|
|
97
|
+
* finding. Blocks under `strict` only.
|
|
98
|
+
* - `intervention` — a recorded manual intervention. Blocks under
|
|
99
|
+
* `strict` only.
|
|
100
|
+
* - `retro` — a non-clean / missing retro verdict trailer.
|
|
101
|
+
* Blocks under `strict` only.
|
|
102
|
+
*/
|
|
103
|
+
export const REASON_CATEGORY = Object.freeze({
|
|
104
|
+
CRITICAL_REVIEW: 'criticalReview',
|
|
105
|
+
BLOCKED_STATE: 'blockedState',
|
|
106
|
+
WARNING_REVIEW: 'warningReview',
|
|
107
|
+
INTERVENTION: 'intervention',
|
|
108
|
+
RETRO: 'retro',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Categories that block arming under the default `"trust-ci"` policy.
|
|
113
|
+
* Everything else is recorded for audit but does not block. Pure —
|
|
114
|
+
* exported for tests.
|
|
115
|
+
*/
|
|
116
|
+
export const TRUST_CI_BLOCKING_CATEGORIES = Object.freeze(
|
|
117
|
+
new Set([REASON_CATEGORY.CRITICAL_REVIEW, REASON_CATEGORY.BLOCKED_STATE]),
|
|
118
|
+
);
|
|
119
|
+
|
|
79
120
|
/**
|
|
80
121
|
* Regex that extracts the machine-readable auto-merge verdict trailer
|
|
81
122
|
* emitted by the retro body composer (`retro/phases/compose-body.js`,
|
|
@@ -140,6 +181,111 @@ export function formatCheckFailureReason(failures) {
|
|
|
140
181
|
return `required checks not green: ${parts.join(', ')}${suffix}`;
|
|
141
182
|
}
|
|
142
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Default live `gh pr checks --required` probe. Mirrors the Watcher's
|
|
186
|
+
* spawn shape (`--json name,state,bucket,workflow`) so the required-set
|
|
187
|
+
* projection is identical. Pure-spawn helper — exported so tests can
|
|
188
|
+
* stub the shell-out. Returns the raw spawn envelope; the caller
|
|
189
|
+
* classifies the payload.
|
|
190
|
+
*
|
|
191
|
+
* @param {{ prUrl: string, cwd?: string, spawnFn?: typeof spawnSync }} opts
|
|
192
|
+
* @returns {{ status: number, stdout: string, stderr: string }}
|
|
193
|
+
*/
|
|
194
|
+
export function probeRequiredChecks({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
195
|
+
const result = spawnFn(
|
|
196
|
+
'gh',
|
|
197
|
+
['pr', 'checks', prUrl, '--required', '--json', 'name,state,bucket'],
|
|
198
|
+
{ cwd, encoding: 'utf-8', shell: false },
|
|
199
|
+
);
|
|
200
|
+
return {
|
|
201
|
+
status: result.status ?? 1,
|
|
202
|
+
stdout: result.stdout ?? '',
|
|
203
|
+
stderr: result.stderr ?? '',
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Classify a live `gh pr checks --required` probe envelope into a
|
|
209
|
+
* `{ ok, reason, outcomes }` verdict. Pure — exported for tests.
|
|
210
|
+
*
|
|
211
|
+
* - `ok: true` → every required check is green (non-failing) AND the
|
|
212
|
+
* probe returned a parseable, non-empty required-check set.
|
|
213
|
+
* - `ok: false` → at least one required check is not green, the probe
|
|
214
|
+
* shelled out non-zero, or the payload could not be parsed. A probe
|
|
215
|
+
* we cannot read is a hard block: we NEVER arm merge on an
|
|
216
|
+
* unreadable CI signal (fail closed on the CI gate specifically —
|
|
217
|
+
* the opposite of the code-review parse-miss policy, because CI
|
|
218
|
+
* greenness is the whole point of the trust-ci arming signal).
|
|
219
|
+
*
|
|
220
|
+
* `gh pr checks --required` exits non-zero (status 8) when a required
|
|
221
|
+
* check has failed OR is still pending, so a non-zero status is treated
|
|
222
|
+
* as "not green" only when we cannot otherwise read a clean set from
|
|
223
|
+
* stdout. We parse stdout first (it is populated even on the non-zero
|
|
224
|
+
* exit) and classify from the outcomes.
|
|
225
|
+
*
|
|
226
|
+
* @param {{ status: number, stdout: string, stderr: string }} probe
|
|
227
|
+
* @returns {{ ok: boolean, reason: string|null, outcomes: Record<string, string> }}
|
|
228
|
+
*/
|
|
229
|
+
export function classifyRequiredChecksProbe(probe) {
|
|
230
|
+
const stdout = String(probe?.stdout ?? '').trim();
|
|
231
|
+
// Empty stdout with a non-zero status → probe genuinely failed (auth,
|
|
232
|
+
// network, no PR). Fail closed.
|
|
233
|
+
if (stdout.length === 0) {
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
reason:
|
|
237
|
+
`live required-check probe failed (status=${probe?.status ?? 'unknown'})` +
|
|
238
|
+
(probe?.stderr ? `: ${String(probe.stderr).trim().slice(0, 200)}` : ''),
|
|
239
|
+
outcomes: {},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
let entries;
|
|
243
|
+
try {
|
|
244
|
+
const parsed = JSON.parse(stdout);
|
|
245
|
+
entries = Array.isArray(parsed) ? parsed : null;
|
|
246
|
+
} catch {
|
|
247
|
+
entries = null;
|
|
248
|
+
}
|
|
249
|
+
if (!entries) {
|
|
250
|
+
return {
|
|
251
|
+
ok: false,
|
|
252
|
+
reason: 'live required-check probe returned an unparseable payload',
|
|
253
|
+
outcomes: {},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const outcomes = {};
|
|
257
|
+
for (const e of entries) {
|
|
258
|
+
if (e && typeof e === 'object' && typeof e.name === 'string') {
|
|
259
|
+
const raw = String(e.state || e.bucket || '')
|
|
260
|
+
.trim()
|
|
261
|
+
.toLowerCase();
|
|
262
|
+
// Fail closed on the arming probe: a token we have not enumerated
|
|
263
|
+
// could be a genuinely-failing GitHub conclusion. normalizeCheckState
|
|
264
|
+
// collapses unknowns to 'skipped' (safe for the watch path, unsafe
|
|
265
|
+
// here), so map an unrecognized token to 'unknown' — which is not in
|
|
266
|
+
// NON_FAILING_CHECK_OUTCOMES and therefore blocks arming.
|
|
267
|
+
outcomes[e.name] = RECOGNIZED_CHECK_STATES.has(raw)
|
|
268
|
+
? normalizeCheckState(raw)
|
|
269
|
+
: 'unknown';
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// An empty required set (no required checks configured) is treated as
|
|
273
|
+
// green — there is nothing to gate on. This matches branch protection
|
|
274
|
+
// that requires no status checks.
|
|
275
|
+
const failing = [];
|
|
276
|
+
for (const [name, outcome] of Object.entries(outcomes)) {
|
|
277
|
+
// `pending` is NOT in NON_FAILING — a still-running required check
|
|
278
|
+
// blocks arming (the Phase 8 watch was interrupted before green).
|
|
279
|
+
if (!NON_FAILING_CHECK_OUTCOMES.has(outcome)) {
|
|
280
|
+
failing.push({ name, outcome });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (failing.length > 0) {
|
|
284
|
+
return { ok: false, reason: formatCheckFailureReason(failing), outcomes };
|
|
285
|
+
}
|
|
286
|
+
return { ok: true, reason: null, outcomes };
|
|
287
|
+
}
|
|
288
|
+
|
|
143
289
|
/**
|
|
144
290
|
* Regex-parse the rendered severity bullets on the code-review markdown
|
|
145
291
|
* body. Pure. Exported for tests.
|
|
@@ -165,16 +311,33 @@ export function parseSeverityCounts(body) {
|
|
|
165
311
|
};
|
|
166
312
|
}
|
|
167
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Push a categorized reason onto the reasons array. Each reason carries
|
|
316
|
+
* a `category` (from `REASON_CATEGORY`) so the policy layer can decide
|
|
317
|
+
* whether it blocks under the active posture, plus a human-facing
|
|
318
|
+
* `message`. Pure helper.
|
|
319
|
+
*/
|
|
320
|
+
function pushReason(reasons, category, message) {
|
|
321
|
+
reasons.push({ category, message });
|
|
322
|
+
}
|
|
323
|
+
|
|
168
324
|
function evaluateStateSignals(state, reasons) {
|
|
169
325
|
const interventionCount = Array.isArray(state?.manualInterventions)
|
|
170
326
|
? state.manualInterventions.length
|
|
171
327
|
: 0;
|
|
172
328
|
if (!state) {
|
|
173
|
-
|
|
329
|
+
// A missing checkpoint means we cannot certify the run at all — this
|
|
330
|
+
// is a blocked-state condition (we do not know whether a Story is
|
|
331
|
+
// blocked), so it blocks under BOTH policies.
|
|
332
|
+
pushReason(
|
|
333
|
+
reasons,
|
|
334
|
+
REASON_CATEGORY.BLOCKED_STATE,
|
|
174
335
|
'epic-run-state checkpoint missing — cannot certify clean run',
|
|
175
336
|
);
|
|
176
337
|
} else if (interventionCount > 0) {
|
|
177
|
-
|
|
338
|
+
pushReason(
|
|
339
|
+
reasons,
|
|
340
|
+
REASON_CATEGORY.INTERVENTION,
|
|
178
341
|
`manual interventions recorded (${interventionCount}): ${state.manualInterventions
|
|
179
342
|
.map((i) => i.reason)
|
|
180
343
|
.slice(0, 3)
|
|
@@ -193,13 +356,20 @@ function evaluateStateSignals(state, reasons) {
|
|
|
193
356
|
);
|
|
194
357
|
const nonDoneStatuses = storyStatuses.filter((s) => s !== 'done');
|
|
195
358
|
if (nonDoneStatuses.length > 0) {
|
|
196
|
-
|
|
359
|
+
// A story not done is an unfinished / blocked run — blocks under both.
|
|
360
|
+
pushReason(
|
|
361
|
+
reasons,
|
|
362
|
+
REASON_CATEGORY.BLOCKED_STATE,
|
|
197
363
|
`${nonDoneStatuses.length} story(ies) not done (statuses: ${nonDoneStatuses.join(', ')})`,
|
|
198
364
|
);
|
|
199
365
|
}
|
|
200
366
|
const storyBlockers = countStoryBlockers(stories);
|
|
201
367
|
if (storyBlockers > 0) {
|
|
202
|
-
|
|
368
|
+
// A recorded story-level blocker is the `agent::blocked` state — it
|
|
369
|
+
// blocks under both policies.
|
|
370
|
+
pushReason(
|
|
371
|
+
reasons,
|
|
372
|
+
REASON_CATEGORY.BLOCKED_STATE,
|
|
203
373
|
`${storyBlockers} story-level blocker(s) recorded in run-state`,
|
|
204
374
|
);
|
|
205
375
|
}
|
|
@@ -237,7 +407,15 @@ function evaluateCodeReviewSignals(codeReview, reasons) {
|
|
|
237
407
|
? parseSeverityCounts(codeReview.body)
|
|
238
408
|
: { critical: null, high: null, medium: null, suggestion: null };
|
|
239
409
|
if (!codeReviewFound) {
|
|
240
|
-
|
|
410
|
+
// A missing code-review comment is a soft signal — it does not, on
|
|
411
|
+
// its own, prove a critical finding. Categorize it as a warning-level
|
|
412
|
+
// reason so it blocks under `strict` (which demands the clean gate)
|
|
413
|
+
// but not under `trust-ci` (which only blocks on a PARSED 🔴 count).
|
|
414
|
+
pushReason(
|
|
415
|
+
reasons,
|
|
416
|
+
REASON_CATEGORY.WARNING_REVIEW,
|
|
417
|
+
'code-review structured comment not found on Epic',
|
|
418
|
+
);
|
|
241
419
|
return { codeReviewFound, codeReviewUnparseable: false, severity };
|
|
242
420
|
}
|
|
243
421
|
// "Present but unparseable" is a DISTINCT condition from "present and says
|
|
@@ -264,10 +442,18 @@ function evaluateCodeReviewSignals(codeReview, reasons) {
|
|
|
264
442
|
return { codeReviewFound, codeReviewUnparseable, severity };
|
|
265
443
|
}
|
|
266
444
|
if (severity.critical > 0) {
|
|
267
|
-
|
|
445
|
+
pushReason(
|
|
446
|
+
reasons,
|
|
447
|
+
REASON_CATEGORY.CRITICAL_REVIEW,
|
|
448
|
+
`code-review has ${severity.critical} 🔴 Critical Blocker(s)`,
|
|
449
|
+
);
|
|
268
450
|
}
|
|
269
451
|
if (severity.high > 0) {
|
|
270
|
-
|
|
452
|
+
pushReason(
|
|
453
|
+
reasons,
|
|
454
|
+
REASON_CATEGORY.WARNING_REVIEW,
|
|
455
|
+
`code-review has ${severity.high} 🟠 High Risk finding(s)`,
|
|
456
|
+
);
|
|
271
457
|
}
|
|
272
458
|
return { codeReviewFound, codeReviewUnparseable, severity };
|
|
273
459
|
}
|
|
@@ -275,23 +461,31 @@ function evaluateCodeReviewSignals(codeReview, reasons) {
|
|
|
275
461
|
function evaluateRetroSignals(retro, reasons) {
|
|
276
462
|
const retroFound = !!retro && typeof retro.body === 'string';
|
|
277
463
|
if (!retroFound) {
|
|
278
|
-
|
|
464
|
+
pushReason(
|
|
465
|
+
reasons,
|
|
466
|
+
REASON_CATEGORY.RETRO,
|
|
467
|
+
'retro structured comment not found on Epic',
|
|
468
|
+
);
|
|
279
469
|
return { retroFound, retroCompact: false };
|
|
280
470
|
}
|
|
281
471
|
// Read the machine-readable verdict trailer instead of string-matching
|
|
282
472
|
// the human-facing "🟢 Clean sprint" prose (Story #3901). A missing or
|
|
283
|
-
// malformed trailer is a hard disqualifier — we never arm
|
|
284
|
-
// a retro whose verdict we cannot read.
|
|
473
|
+
// malformed trailer is a hard disqualifier under `strict` — we never arm
|
|
474
|
+
// strict-policy auto-merge on a retro whose verdict we cannot read.
|
|
285
475
|
const verdict = parseAutomergeVerdictTrailer(retro.body);
|
|
286
476
|
if (!verdict) {
|
|
287
|
-
|
|
477
|
+
pushReason(
|
|
478
|
+
reasons,
|
|
479
|
+
REASON_CATEGORY.RETRO,
|
|
288
480
|
'retro is missing the machine-readable automerge-verdict trailer (cannot certify clean sprint)',
|
|
289
481
|
);
|
|
290
482
|
return { retroFound, retroCompact: false };
|
|
291
483
|
}
|
|
292
484
|
const retroCompact = verdict.cleanSprint === true;
|
|
293
485
|
if (!retroCompact) {
|
|
294
|
-
|
|
486
|
+
pushReason(
|
|
487
|
+
reasons,
|
|
488
|
+
REASON_CATEGORY.RETRO,
|
|
295
489
|
'retro automerge-verdict trailer reports cleanSprint=false (full retro indicates friction / parked / interventions)',
|
|
296
490
|
);
|
|
297
491
|
}
|
|
@@ -300,7 +494,13 @@ function evaluateRetroSignals(retro, reasons) {
|
|
|
300
494
|
|
|
301
495
|
/**
|
|
302
496
|
* Pure verdict-from-signals function. Composes the three signal sources into
|
|
303
|
-
* a single `
|
|
497
|
+
* a single envelope. The `clean` boolean is the strict-policy verdict (true
|
|
498
|
+
* iff there are zero reasons of any category) — it is preserved so the
|
|
499
|
+
* `strict` policy restores the prior predicate EXACTLY. `reasons` is the
|
|
500
|
+
* flat string[] of human-facing messages (byte-identical to the pre-#4361
|
|
501
|
+
* output for the same inputs); `categorizedReasons` is the same list tagged
|
|
502
|
+
* with a `REASON_CATEGORY` so the policy filter (`applyAutoMergePolicy`) can
|
|
503
|
+
* narrow which reasons block per posture. Exported for tests.
|
|
304
504
|
*
|
|
305
505
|
* @param {{
|
|
306
506
|
* state: object|null,
|
|
@@ -310,6 +510,7 @@ function evaluateRetroSignals(retro, reasons) {
|
|
|
310
510
|
* @returns {{
|
|
311
511
|
* clean: boolean,
|
|
312
512
|
* reasons: string[],
|
|
513
|
+
* categorizedReasons: Array<{ category: string, message: string }>,
|
|
313
514
|
* signals: {
|
|
314
515
|
* manualInterventions: number,
|
|
315
516
|
* storyStatuses: string[],
|
|
@@ -324,14 +525,18 @@ function evaluateRetroSignals(retro, reasons) {
|
|
|
324
525
|
* }}
|
|
325
526
|
*/
|
|
326
527
|
export function deriveAutoMergeVerdict({ state, codeReview, retro }) {
|
|
327
|
-
const
|
|
328
|
-
const stateSig = evaluateStateSignals(state,
|
|
329
|
-
const reviewSig = evaluateCodeReviewSignals(codeReview,
|
|
330
|
-
const retroSig = evaluateRetroSignals(retro,
|
|
528
|
+
const categorizedReasons = [];
|
|
529
|
+
const stateSig = evaluateStateSignals(state, categorizedReasons);
|
|
530
|
+
const reviewSig = evaluateCodeReviewSignals(codeReview, categorizedReasons);
|
|
531
|
+
const retroSig = evaluateRetroSignals(retro, categorizedReasons);
|
|
331
532
|
|
|
332
533
|
return {
|
|
333
|
-
clean:
|
|
334
|
-
|
|
534
|
+
// `clean` is the STRICT verdict: no reason of any category. This is the
|
|
535
|
+
// exact pre-#4361 predicate, preserved so `strict` policy is unchanged.
|
|
536
|
+
clean: categorizedReasons.length === 0,
|
|
537
|
+
// Flat string[] — byte-identical to the pre-#4361 reason messages.
|
|
538
|
+
reasons: categorizedReasons.map((r) => r.message),
|
|
539
|
+
categorizedReasons,
|
|
335
540
|
signals: {
|
|
336
541
|
manualInterventions: stateSig.interventionCount,
|
|
337
542
|
storyStatuses: stateSig.storyStatuses,
|
|
@@ -346,6 +551,67 @@ export function deriveAutoMergeVerdict({ state, codeReview, retro }) {
|
|
|
346
551
|
};
|
|
347
552
|
}
|
|
348
553
|
|
|
554
|
+
/**
|
|
555
|
+
* Apply the `delivery.ci.autoMerge` policy to a categorized verdict.
|
|
556
|
+
* Returns the EFFECTIVE arming decision plus the split of blocking vs.
|
|
557
|
+
* recorded-only reasons. Pure — exported for tests.
|
|
558
|
+
*
|
|
559
|
+
* - `"strict"` → every reason blocks (identical to the pre-#4361
|
|
560
|
+
* `clean` verdict). `recordedReasons` is empty.
|
|
561
|
+
* - `"trust-ci"` → only `criticalReview` / `blockedState` reasons
|
|
562
|
+
* block; the rest (interventions, warnings, retro) land in
|
|
563
|
+
* `recordedReasons` for audit and do NOT gate the merge.
|
|
564
|
+
*
|
|
565
|
+
* @param {{ clean: boolean, categorizedReasons: Array<{ category: string, message: string }> }} verdict
|
|
566
|
+
* @param {'trust-ci'|'strict'} policy
|
|
567
|
+
* @returns {{
|
|
568
|
+
* arm: boolean,
|
|
569
|
+
* policy: 'trust-ci'|'strict',
|
|
570
|
+
* blockingReasons: Array<{ category: string, message: string }>,
|
|
571
|
+
* recordedReasons: Array<{ category: string, message: string }>,
|
|
572
|
+
* }}
|
|
573
|
+
*/
|
|
574
|
+
export function applyAutoMergePolicy(verdict, policy) {
|
|
575
|
+
const reasons = Array.isArray(verdict?.categorizedReasons)
|
|
576
|
+
? verdict.categorizedReasons
|
|
577
|
+
: [];
|
|
578
|
+
if (policy === 'strict') {
|
|
579
|
+
return {
|
|
580
|
+
arm: reasons.length === 0,
|
|
581
|
+
policy: 'strict',
|
|
582
|
+
blockingReasons: reasons,
|
|
583
|
+
recordedReasons: [],
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
// trust-ci (default): only critical-review and blocked-state reasons gate.
|
|
587
|
+
const blockingReasons = [];
|
|
588
|
+
const recordedReasons = [];
|
|
589
|
+
for (const r of reasons) {
|
|
590
|
+
if (TRUST_CI_BLOCKING_CATEGORIES.has(r.category)) {
|
|
591
|
+
blockingReasons.push(r);
|
|
592
|
+
} else {
|
|
593
|
+
recordedReasons.push(r);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return {
|
|
597
|
+
arm: blockingReasons.length === 0,
|
|
598
|
+
policy: 'trust-ci',
|
|
599
|
+
blockingReasons,
|
|
600
|
+
recordedReasons,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Join a categorized reason list into a single-line `reason` string for
|
|
606
|
+
* the `epic.merge.blocked` emit / classification log. Pure.
|
|
607
|
+
*/
|
|
608
|
+
function formatReasons(reasons, prefix = '') {
|
|
609
|
+
const messages = reasons.map((r) => r.message);
|
|
610
|
+
const head = messages.slice(0, 3).join('; ');
|
|
611
|
+
const suffix = messages.length > 3 ? `; +${messages.length - 3} more` : '';
|
|
612
|
+
return `${prefix}${head}${suffix}`;
|
|
613
|
+
}
|
|
614
|
+
|
|
349
615
|
/**
|
|
350
616
|
* IO-bound entry. Loads all three signal sources from the structured-comment
|
|
351
617
|
* surface on the Epic ticket and hands them to `deriveAutoMergeVerdict`.
|
|
@@ -360,7 +626,7 @@ export function deriveAutoMergeVerdict({ state, codeReview, retro }) {
|
|
|
360
626
|
* findCommentFn?: typeof findStructuredComment,
|
|
361
627
|
* readRunStateFn?: typeof epicRunStateStore.read,
|
|
362
628
|
* }} opts
|
|
363
|
-
* @returns {Promise<{ clean: boolean, reasons:
|
|
629
|
+
* @returns {Promise<{ clean: boolean, reasons: object[], signals: object }>}
|
|
364
630
|
*/
|
|
365
631
|
export async function evaluateAutoMergePredicate({
|
|
366
632
|
provider,
|
|
@@ -402,8 +668,15 @@ export class AutomergePredicate {
|
|
|
402
668
|
* @param {object} opts.provider GitHub provider (passed through to the
|
|
403
669
|
* evaluator). Required for the read of run-state + structured
|
|
404
670
|
* comments.
|
|
671
|
+
* @param {object} [opts.config] Resolved agent config. Read for the
|
|
672
|
+
* `delivery.ci.autoMerge` policy via `getCiDelivery`. Defaults to the
|
|
673
|
+
* framework default (`trust-ci`) when omitted.
|
|
674
|
+
* @param {string} [opts.cwd] Working directory for the live
|
|
675
|
+
* `gh pr checks --required` probe. Defaults to `process.cwd()`.
|
|
405
676
|
* @param {Function} [opts.evaluatePredicateFn] override of
|
|
406
677
|
* `evaluateAutoMergePredicate` for tests.
|
|
678
|
+
* @param {Function} [opts.probeRequiredChecksFn] override of
|
|
679
|
+
* `probeRequiredChecks` for tests.
|
|
407
680
|
* @param {{ info?: Function, warn?: Function, debug?: Function }} [opts.logger]
|
|
408
681
|
*/
|
|
409
682
|
constructor(opts = {}) {
|
|
@@ -425,16 +698,21 @@ export class AutomergePredicate {
|
|
|
425
698
|
this.bus = opts.bus;
|
|
426
699
|
this.epicId = opts.epicId;
|
|
427
700
|
this.provider = opts.provider;
|
|
701
|
+
this.cwd = opts.cwd ?? process.cwd();
|
|
702
|
+
// Resolve the merge posture once at construction. `getCiDelivery`
|
|
703
|
+
// applies the framework default (`trust-ci`) for any omitted field.
|
|
704
|
+
this.policy = getCiDelivery(opts.config ?? null).autoMerge;
|
|
428
705
|
this.evaluatePredicateFn =
|
|
429
706
|
opts.evaluatePredicateFn ?? evaluateAutoMergePredicate;
|
|
707
|
+
this.probeRequiredChecksFn =
|
|
708
|
+
opts.probeRequiredChecksFn ?? probeRequiredChecks;
|
|
430
709
|
this.logger = opts.logger ?? console;
|
|
431
710
|
/** @type {Set<string>} `${event}:${seqId}` idempotency cache. */
|
|
432
711
|
this._seen = new Set();
|
|
433
712
|
/**
|
|
434
|
-
* Classification log — every
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
* surface.
|
|
713
|
+
* Classification log — every event we observe lands here with the
|
|
714
|
+
* outcome (`ready`, `blocked`, `skipped-duplicate`, `failed`).
|
|
715
|
+
* Mirrors the Finalizer / Reconciler "no silent skip" surface.
|
|
438
716
|
*/
|
|
439
717
|
this.classifications = [];
|
|
440
718
|
this.events = Object.freeze(['epic.automerge.start', 'epic.watch.end']);
|
|
@@ -472,18 +750,19 @@ export class AutomergePredicate {
|
|
|
472
750
|
});
|
|
473
751
|
return;
|
|
474
752
|
}
|
|
475
|
-
// Gate 1 — required-check freshness. Any non-passing required
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
753
|
+
// Gate 1 — required-check freshness. Any non-passing required check is
|
|
754
|
+
// a hard block: short-circuit before consulting the structured-signal
|
|
755
|
+
// evaluator so the operator sees the CI failure as the reason, not a
|
|
756
|
+
// downstream signal.
|
|
479
757
|
//
|
|
480
|
-
//
|
|
481
|
-
// (
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
758
|
+
// Two sources of CI truth:
|
|
759
|
+
// (a) `epic.watch.end` (test-only Watcher path) carries a settled
|
|
760
|
+
// `checkOutcomes` map — that map is authoritative, so we classify
|
|
761
|
+
// it directly and issue NO live probe.
|
|
762
|
+
// (b) `epic.automerge.start` (production Phase 8.5) carries no map.
|
|
763
|
+
// Story #4361: we run a LIVE `gh pr checks --required` probe here
|
|
764
|
+
// so an interrupted Phase 8 watch cannot arm merge on red/pending
|
|
765
|
+
// required checks (closes the Story #3901 hole).
|
|
487
766
|
if (payload?.checkOutcomes !== undefined) {
|
|
488
767
|
const failures = listFailingChecks(payload.checkOutcomes);
|
|
489
768
|
if (failures.length > 0) {
|
|
@@ -492,14 +771,29 @@ export class AutomergePredicate {
|
|
|
492
771
|
await this._emitBlocked(prUrl, reason);
|
|
493
772
|
return;
|
|
494
773
|
}
|
|
774
|
+
} else {
|
|
775
|
+
// Live probe — production path. Fail closed on any non-green,
|
|
776
|
+
// pending, or unreadable required-check result.
|
|
777
|
+
let probeVerdict;
|
|
778
|
+
try {
|
|
779
|
+
const probe = this.probeRequiredChecksFn({ prUrl, cwd: this.cwd });
|
|
780
|
+
probeVerdict = classifyRequiredChecksProbe(probe);
|
|
781
|
+
} catch (err) {
|
|
782
|
+
probeVerdict = {
|
|
783
|
+
ok: false,
|
|
784
|
+
reason: `live required-check probe threw: ${err?.message ?? err}`,
|
|
785
|
+
outcomes: {},
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
if (!probeVerdict.ok) {
|
|
789
|
+
const reason = probeVerdict.reason ?? 'required checks not green';
|
|
790
|
+
this.classifications.push({ event, seqId, outcome: 'blocked', reason });
|
|
791
|
+
await this._emitBlocked(prUrl, reason);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
495
794
|
}
|
|
496
795
|
|
|
497
|
-
// Gate 2 — structured-signal verdict.
|
|
498
|
-
// `evaluateAutoMergePredicate` so the verdict for any given input
|
|
499
|
-
// set is IDENTICAL to what `epic-deliver-automerge.js` would have
|
|
500
|
-
// produced before Wave 7. The classification surface logs the
|
|
501
|
-
// first three reasons so operators don't have to dig into the
|
|
502
|
-
// legacy CLI output to understand a block.
|
|
796
|
+
// Gate 2 — structured-signal verdict, filtered by the merge policy.
|
|
503
797
|
let verdict;
|
|
504
798
|
try {
|
|
505
799
|
verdict = await this.evaluatePredicateFn({
|
|
@@ -518,17 +812,32 @@ export class AutomergePredicate {
|
|
|
518
812
|
return;
|
|
519
813
|
}
|
|
520
814
|
|
|
521
|
-
|
|
815
|
+
const decision = applyAutoMergePolicy(verdict, this.policy);
|
|
816
|
+
// Surface recorded-but-non-blocking reasons for audit even when the
|
|
817
|
+
// trust-ci policy arms anyway (interventions / warnings / non-clean
|
|
818
|
+
// retro). These never gate the merge but must not be silently dropped.
|
|
819
|
+
const recorded = decision.recordedReasons ?? [];
|
|
820
|
+
|
|
821
|
+
if (decision.arm) {
|
|
522
822
|
this.classifications.push({
|
|
523
823
|
event,
|
|
524
824
|
seqId,
|
|
525
825
|
outcome: 'ready',
|
|
826
|
+
policy: decision.policy,
|
|
526
827
|
signals: verdict.signals,
|
|
828
|
+
...(recorded.length > 0
|
|
829
|
+
? { recordedReasons: recorded.map((r) => r.message) }
|
|
830
|
+
: {}),
|
|
527
831
|
});
|
|
832
|
+
if (recorded.length > 0) {
|
|
833
|
+
this.logger.info?.(
|
|
834
|
+
`[AutomergePredicate] arming under ${decision.policy}; recorded (non-blocking): ${formatReasons(recorded)}`,
|
|
835
|
+
);
|
|
836
|
+
}
|
|
528
837
|
try {
|
|
529
838
|
await this.bus.emit('epic.merge.ready', {
|
|
530
839
|
prUrl,
|
|
531
|
-
reason:
|
|
840
|
+
reason: `all required checks green; ${decision.policy} policy signals clear`,
|
|
532
841
|
});
|
|
533
842
|
} catch (err) {
|
|
534
843
|
this.logger.warn?.(
|
|
@@ -538,20 +847,28 @@ export class AutomergePredicate {
|
|
|
538
847
|
return;
|
|
539
848
|
}
|
|
540
849
|
|
|
541
|
-
const
|
|
850
|
+
const blocking = decision.blockingReasons ?? [];
|
|
542
851
|
const reason =
|
|
543
|
-
|
|
544
|
-
?
|
|
545
|
-
(reasons.length > 3 ? `; +${reasons.length - 3} more` : '')
|
|
852
|
+
blocking.length > 0
|
|
853
|
+
? formatReasons(blocking)
|
|
546
854
|
: 'predicate dirty (no reasons reported)';
|
|
547
|
-
this.classifications.push({
|
|
855
|
+
this.classifications.push({
|
|
856
|
+
event,
|
|
857
|
+
seqId,
|
|
858
|
+
outcome: 'blocked',
|
|
859
|
+
policy: decision.policy,
|
|
860
|
+
reason,
|
|
861
|
+
...(recorded.length > 0
|
|
862
|
+
? { recordedReasons: recorded.map((r) => r.message) }
|
|
863
|
+
: {}),
|
|
864
|
+
});
|
|
548
865
|
await this._emitBlocked(prUrl, reason);
|
|
549
866
|
}
|
|
550
867
|
|
|
551
868
|
/**
|
|
552
|
-
* Emit `epic.merge.blocked`. Helper carved out so the
|
|
553
|
-
*
|
|
554
|
-
*
|
|
869
|
+
* Emit `epic.merge.blocked`. Helper carved out so the blocking paths
|
|
870
|
+
* (CI failure / predicate dirty / evaluator throw) share the same emit
|
|
871
|
+
* shape.
|
|
555
872
|
*/
|
|
556
873
|
async _emitBlocked(prUrl, reason) {
|
|
557
874
|
try {
|