mandrel 2.54.0 → 2.55.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/agents/story-worker.md +24 -23
- package/.agents/audit-checklists/accessibility.md +0 -3
- package/.agents/audit-checklists/mobile.md +0 -4
- package/.agents/docs/agentrc-reference.json +4 -2
- package/.agents/docs/configuration.md +2 -0
- package/.agents/schemas/agentrc.schema.json +15 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/audit-to-stories.js +158 -7
- package/.agents/scripts/check-audit-attribution.js +119 -62
- package/.agents/scripts/check-test-portability.js +512 -0
- package/.agents/scripts/coverage-capture.js +17 -10
- package/.agents/scripts/evidence-gate.js +31 -4
- package/.agents/scripts/generate-workflows-doc.js +65 -14
- package/.agents/scripts/git-cleanup.js +4 -0
- package/.agents/scripts/lib/ITicketingProvider.js +78 -0
- package/.agents/scripts/lib/audit-advisories.js +195 -0
- package/.agents/scripts/lib/audit-attribution.js +22 -0
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
- package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
- package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
- package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
- package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/close-validation/gates.js +113 -7
- package/.agents/scripts/lib/close-validation/process.js +7 -3
- package/.agents/scripts/lib/close-validation/runner.js +62 -11
- package/.agents/scripts/lib/config/ci.js +28 -9
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/config-settings-schema.js +19 -1
- package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
- package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
- package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
- package/.agents/scripts/lib/coverage-capture.js +77 -3
- package/.agents/scripts/lib/findings/route-finding.js +4 -2
- package/.agents/scripts/lib/full-suite-lock.js +232 -6
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/git/sync-from-base.js +130 -13
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
- package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
- package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
- package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
- package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
- package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
- package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
- package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
- package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
- package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
- package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
- package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
- package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
- package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
- package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
- package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
- package/.agents/scripts/lib/pinned-override-notes.js +41 -53
- package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
- package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
- package/.agents/scripts/lib/test-temp.js +167 -30
- package/.agents/scripts/lib/validation-evidence.js +37 -0
- package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
- package/.agents/scripts/merge-baseline.js +175 -21
- package/.agents/scripts/providers/github/errors.js +22 -1
- package/.agents/scripts/providers/github/issues.js +106 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
- package/.agents/scripts/providers/github.js +6 -0
- package/.agents/scripts/resolve-stories.js +44 -34
- package/.agents/scripts/single-story-close.js +5 -0
- package/.agents/scripts/stories-wave-tick.js +37 -13
- package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
- package/.agents/workflows/audit-accessibility.md +16 -31
- package/.agents/workflows/audit-mobile.md +20 -37
- package/.agents/workflows/git-cleanup.md +17 -3
- package/.agents/workflows/helpers/audit-lens-core.md +45 -0
- package/.agents/workflows/helpers/deliver-digest.md +7 -6
- package/.agents/workflows/helpers/deliver-reference.md +35 -14
- package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
- package/.agents/workflows/helpers/deliver-story.md +15 -12
- package/.agents/workflows/helpers/plan-reference.md +7 -0
- package/.agents/workflows/mandrel-plan.md +4 -7
- package/.agents/workflows/memory-consolidate.md +14 -9
- package/docs/CHANGELOG.md +27 -0
- package/lib/cli/registry.js +64 -21
- package/lib/cli/sync.js +27 -2
- package/package.json +7 -4
|
@@ -13,8 +13,18 @@
|
|
|
13
13
|
* On a merge conflict the Story is transitioned to `agent::blocked` via
|
|
14
14
|
* `handleSyncFailure` and the caller throws — the operator resolves in
|
|
15
15
|
* the worktree and re-runs.
|
|
16
|
+
*
|
|
17
|
+
* Story #5267: a sync that lands tracked content also spends the worker's
|
|
18
|
+
* pre-push credit, because that credit is keyed on the tree. The phase says
|
|
19
|
+
* so out loud — see `buildStampInvalidatedWarning` — instead of leaving
|
|
20
|
+
* close's second full suite looking like a bug. Story #5278 splits the claim
|
|
21
|
+
* in two: gate evidence is spent by any tracked path, the capture stamp only
|
|
22
|
+
* by one under `crap.targetDirs`.
|
|
16
23
|
*/
|
|
17
24
|
|
|
25
|
+
import { getQuality } from '../../../config/quality.js';
|
|
26
|
+
import { resolveConfig } from '../../../config-resolver.js';
|
|
27
|
+
import { filterFilesUnderTargets } from '../../../coverage-capture.js';
|
|
18
28
|
import { syncBranchFromBase } from '../../../git/sync-from-base.js';
|
|
19
29
|
import { Logger } from '../../../Logger.js';
|
|
20
30
|
import { AGENT_LABELS } from '../../../label-constants.js';
|
|
@@ -40,6 +50,7 @@ import {
|
|
|
40
50
|
* storyId: number,
|
|
41
51
|
* provider: object,
|
|
42
52
|
* injectedSync?: typeof syncBranchFromBase,
|
|
53
|
+
* resolveConfigImpl?: typeof resolveConfig,
|
|
43
54
|
* progress: (tag: string, msg: string) => void,
|
|
44
55
|
* }} args
|
|
45
56
|
*/
|
|
@@ -52,6 +63,7 @@ export async function runBaseSyncPhase({
|
|
|
52
63
|
storyId,
|
|
53
64
|
provider,
|
|
54
65
|
injectedSync,
|
|
66
|
+
resolveConfigImpl = resolveConfig,
|
|
55
67
|
progress,
|
|
56
68
|
}) {
|
|
57
69
|
const syncCwd = worktreePath ?? cwd;
|
|
@@ -87,6 +99,95 @@ export async function runBaseSyncPhase({
|
|
|
87
99
|
);
|
|
88
100
|
}
|
|
89
101
|
progress('SYNC', `✅ Synced from origin/${baseBranch} (${syncResult.kind}).`);
|
|
102
|
+
for (const line of buildStampInvalidatedWarning({
|
|
103
|
+
baseBranch,
|
|
104
|
+
result: syncResult,
|
|
105
|
+
targetDirs: resolveCrapTargetDirs(resolveConfigImpl, syncCwd),
|
|
106
|
+
})) {
|
|
107
|
+
progress('SYNC', line);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The CRAP scoring scope, or `[]` when it cannot be resolved. A `[]` makes
|
|
113
|
+
* the warning below fall back to naming the capture stamp unconditionally —
|
|
114
|
+
* the pre-#5278 wording — because an unresolvable scope is no evidence that
|
|
115
|
+
* the stamp survived.
|
|
116
|
+
*
|
|
117
|
+
* @param {typeof resolveConfig} resolveConfigImpl
|
|
118
|
+
* @param {string} cwd
|
|
119
|
+
* @returns {string[]}
|
|
120
|
+
*/
|
|
121
|
+
function resolveCrapTargetDirs(resolveConfigImpl, cwd) {
|
|
122
|
+
try {
|
|
123
|
+
return getQuality(resolveConfigImpl({ cwd }))?.crap?.targetDirs ?? [];
|
|
124
|
+
} catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* How many changed paths the warning names before it stops listing.
|
|
131
|
+
* Enough to recognise the change set; short enough that the warning still
|
|
132
|
+
* reads as a warning rather than as a diff.
|
|
133
|
+
*/
|
|
134
|
+
const WARNED_PATH_LIMIT = 12;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The loud "your credit is spent" warning, or `[]` when the sync changed
|
|
138
|
+
* nothing (Story #5267, narrowed by #5278).
|
|
139
|
+
*
|
|
140
|
+
* The worker banks two kinds of credit before the push: gate evidence keyed
|
|
141
|
+
* on the tree (lint, typecheck) and one full-suite capture stamp keyed on the
|
|
142
|
+
* content of `crap.targetDirs`. This sync runs after that, and the two are
|
|
143
|
+
* spent on different conditions — which is why #5267's single blanket
|
|
144
|
+
* sentence was wrong half the time:
|
|
145
|
+
*
|
|
146
|
+
* - **Gate evidence** is spent by any tracked path at all, because the tree
|
|
147
|
+
* hash it is keyed on moves with the first byte.
|
|
148
|
+
* - **The capture stamp** is spent only when a merged path lands under
|
|
149
|
+
* `crap.targetDirs`. A sync that brings in docs, workflows or CI config
|
|
150
|
+
* leaves it perfectly valid, and announcing it as spent taught operators
|
|
151
|
+
* to expect a second full suite that close was never going to run.
|
|
152
|
+
*
|
|
153
|
+
* Quiet by construction on the outcome that cannot spend either: a
|
|
154
|
+
* `noop-already-current` sync never touched the tree. A content-changing
|
|
155
|
+
* fast-forward DOES warn — it moves the tree exactly as a merge commit does,
|
|
156
|
+
* and staying quiet there would be a lie of omission.
|
|
157
|
+
*
|
|
158
|
+
* Pure. Module-private: the phase is the seam tests drive it through
|
|
159
|
+
* (`injectedSync` + a `progress` spy), so it needs no export of its own.
|
|
160
|
+
*
|
|
161
|
+
* @param {{ baseBranch: string, result: { kind?: string, changedPaths?: string[] }, targetDirs?: string[] }} args
|
|
162
|
+
* @returns {string[]} Progress lines, in order. Empty when nothing changed.
|
|
163
|
+
*/
|
|
164
|
+
function buildStampInvalidatedWarning({ baseBranch, result, targetDirs }) {
|
|
165
|
+
const changed = Array.isArray(result?.changedPaths)
|
|
166
|
+
? result.changedPaths
|
|
167
|
+
: [];
|
|
168
|
+
if (changed.length === 0) return [];
|
|
169
|
+
const dirs = Array.isArray(targetDirs) ? targetDirs : [];
|
|
170
|
+
// An unresolvable scope (`[]`) cannot prove the stamp survived, so it fails
|
|
171
|
+
// closed to the unconditional wording.
|
|
172
|
+
const scored =
|
|
173
|
+
dirs.length === 0 ? changed : filterFilesUnderTargets(changed, dirs);
|
|
174
|
+
const shown = changed.slice(0, WARNED_PATH_LIMIT);
|
|
175
|
+
const overflow = changed.length - shown.length;
|
|
176
|
+
return [
|
|
177
|
+
`⚠️ BASE MOVED: the ${result?.kind ?? 'sync'} from origin/${baseBranch} ` +
|
|
178
|
+
`brought ${changed.length} tracked path(s) into this branch, so the tree ` +
|
|
179
|
+
`hash the pre-push lint/typecheck evidence was keyed on has changed. ` +
|
|
180
|
+
`That evidence cannot be credited; those gates re-run below.`,
|
|
181
|
+
scored.length > 0
|
|
182
|
+
? `⚠️ The full-suite capture stamp is spent too: ${scored.length} of ` +
|
|
183
|
+
`those path(s) fall under the CRAP target dirs [${dirs.join(', ')}], ` +
|
|
184
|
+
`so the suite re-runs against the merged tree. This is expected, not a fault.`
|
|
185
|
+
: `⚠️ The full-suite capture stamp SURVIVES: no merged path falls under ` +
|
|
186
|
+
`the CRAP target dirs [${dirs.join(', ')}], so the coverage artifact still ` +
|
|
187
|
+
`describes this tree and the suite is not re-run.`,
|
|
188
|
+
...shown.map((f) => `⚠️ ${f}`),
|
|
189
|
+
...(overflow > 0 ? [`⚠️ …and ${overflow} more`] : []),
|
|
190
|
+
];
|
|
90
191
|
}
|
|
91
192
|
|
|
92
193
|
/**
|
|
@@ -105,6 +105,8 @@ import {
|
|
|
105
105
|
import { emitMergeUnlanded as defaultEmitMergeUnlanded } from '../../lifecycle/emit-merge-unlanded.js';
|
|
106
106
|
import { classifyMergeBlock as defaultClassifyMergeBlock } from '../../merge-block-class.js';
|
|
107
107
|
import {
|
|
108
|
+
ADVISORY_GATE_INCONCLUSIVE_CLASS,
|
|
109
|
+
ADVISORY_GATE_RED_CLASS,
|
|
108
110
|
DEFAULT_INTERVAL_SECONDS,
|
|
109
111
|
DEFAULT_MAX_BUDGET_SECONDS,
|
|
110
112
|
decideAdvisoryGateBlock,
|
|
@@ -113,6 +115,9 @@ import {
|
|
|
113
115
|
deriveRedHeadRuns,
|
|
114
116
|
deriveRequiredRunEvidence,
|
|
115
117
|
MERGE_WAIT_GH_TIMEOUT_MS,
|
|
118
|
+
parseWorkflowRunId,
|
|
119
|
+
readRunSummary,
|
|
120
|
+
resolveAdvisoryGateVerdict,
|
|
116
121
|
} from '../../merge-poll.js';
|
|
117
122
|
import { NEXT_COMMANDS } from '../../story-deliver-terminal.js';
|
|
118
123
|
import {
|
|
@@ -209,6 +214,26 @@ function withGhTimeout(promise, timeoutMs, label) {
|
|
|
209
214
|
return bounded;
|
|
210
215
|
}
|
|
211
216
|
|
|
217
|
+
/**
|
|
218
|
+
* One string field off a `gh pr view` payload, or `absent` when the API did
|
|
219
|
+
* not return one. Deduplicated out of the probe below (Story #5266): six
|
|
220
|
+
* identical `typeof x === 'string'` ternaries put that one function over the
|
|
221
|
+
* CRAP ratchet the moment a seventh field was needed.
|
|
222
|
+
*
|
|
223
|
+
* An empty string counts as absent — `gh` returns `""` for a field it cannot
|
|
224
|
+
* read, and every caller treats that exactly as "not there".
|
|
225
|
+
*
|
|
226
|
+
* @param {unknown} value
|
|
227
|
+
* @param {null|undefined} [absent] What to report when the field is missing.
|
|
228
|
+
* `null` for the three fields the poll loop compares against null; the
|
|
229
|
+
* `undefined` default for the ones whose absence must not shadow a
|
|
230
|
+
* downstream default.
|
|
231
|
+
* @returns {string|null|undefined}
|
|
232
|
+
*/
|
|
233
|
+
function readString(value, absent = undefined) {
|
|
234
|
+
return typeof value === 'string' && value ? value : absent;
|
|
235
|
+
}
|
|
236
|
+
|
|
212
237
|
/**
|
|
213
238
|
* One probe per poll iteration, carrying every field the loop and the
|
|
214
239
|
* terminal classifier need: merge state, the checks rollup, the merge-state
|
|
@@ -238,22 +263,19 @@ export async function readPrWaitProbe({
|
|
|
238
263
|
'mergeStateStatus',
|
|
239
264
|
'reviewDecision',
|
|
240
265
|
'statusCheckRollup',
|
|
266
|
+
// Story #5266 — the head the red advisory runs belong to, so their
|
|
267
|
+
// check-run output can be read back and classified.
|
|
268
|
+
'headRefOid',
|
|
241
269
|
]),
|
|
242
270
|
ghTimeoutMs,
|
|
243
271
|
`gh pr view ${prNumber}`,
|
|
244
272
|
);
|
|
245
273
|
return {
|
|
246
|
-
state:
|
|
247
|
-
mergedAt:
|
|
248
|
-
createdAt:
|
|
249
|
-
mergeStateStatus:
|
|
250
|
-
|
|
251
|
-
? view.mergeStateStatus
|
|
252
|
-
: undefined,
|
|
253
|
-
reviewDecision:
|
|
254
|
-
typeof view?.reviewDecision === 'string'
|
|
255
|
-
? view.reviewDecision
|
|
256
|
-
: undefined,
|
|
274
|
+
state: readString(view?.state, null),
|
|
275
|
+
mergedAt: readString(view?.mergedAt, null),
|
|
276
|
+
createdAt: readString(view?.createdAt, null),
|
|
277
|
+
mergeStateStatus: readString(view?.mergeStateStatus),
|
|
278
|
+
reviewDecision: readString(view?.reviewDecision),
|
|
257
279
|
checksStatus: deriveChecksStatus(view?.statusCheckRollup),
|
|
258
280
|
// Head-anchored per-run evidence (Story #4695): distinguishes a
|
|
259
281
|
// genuinely red required run from the superseded / still-pending noise
|
|
@@ -264,6 +286,7 @@ export async function readPrWaitProbe({
|
|
|
264
286
|
// verdict can name the offending job and match the allowlist. Same
|
|
265
287
|
// red-ness test as `requiredRunEvidence`, so the two cannot disagree.
|
|
266
288
|
redHeadRuns: deriveRedHeadRuns(view?.statusCheckRollup),
|
|
289
|
+
headSha: readString(view?.headRefOid),
|
|
267
290
|
};
|
|
268
291
|
} catch (err) {
|
|
269
292
|
return {
|
|
@@ -373,6 +396,83 @@ export function resolveBudgetAnchorMs({ createdAt, fallbackMs }) {
|
|
|
373
396
|
return Number.isFinite(parsed) ? parsed : fallbackMs;
|
|
374
397
|
}
|
|
375
398
|
|
|
399
|
+
/**
|
|
400
|
+
* The advisory-gate half of {@link unlandedRemedy} (Story #5279).
|
|
401
|
+
*
|
|
402
|
+
* Both advisory classes used to fall through to the generic remedy, which
|
|
403
|
+
* opens by telling the operator to resolve "branch protection, required
|
|
404
|
+
* checks, or a manual merge" — a diagnosis of a fault that provably does not
|
|
405
|
+
* exist here. An advisory gate blocks precisely BECAUSE GitHub reports the PR
|
|
406
|
+
* mergeable (`mergeStateStatus=UNSTABLE`) over a NON-required red check, so
|
|
407
|
+
* close refused to let native auto-merge land it. Saying so is the paragraph
|
|
408
|
+
* that stops the operator hunting a protection rule that is working fine.
|
|
409
|
+
*
|
|
410
|
+
* The two classes then part on what the evidence authorises. `inconclusive`
|
|
411
|
+
* is a job that failed WITHOUT FINISHING and reported no violation — nothing
|
|
412
|
+
* says the change is bad — so the proportionate act is a re-run, named here
|
|
413
|
+
* as `--rerun-advisory`; the permanent global exemption is deliberately
|
|
414
|
+
* mentioned last. `advisory-gate-red` reported a real violation, so the
|
|
415
|
+
* change is implicated and landing over it is a deliberate override.
|
|
416
|
+
*
|
|
417
|
+
* @param {{ storyId: number, blockClass: string }} args
|
|
418
|
+
* @returns {string}
|
|
419
|
+
*/
|
|
420
|
+
function advisoryGateRemedy({ storyId, blockClass }) {
|
|
421
|
+
const mergeable =
|
|
422
|
+
'GitHub reports this PR **mergeable regardless** ' +
|
|
423
|
+
'(`mergeStateStatus=UNSTABLE`) — the check that blocked is **advisory** ' +
|
|
424
|
+
'(non-required), so there is nothing wrong with branch protection or the ' +
|
|
425
|
+
'required checks. Close refused to let native auto-merge land the PR ' +
|
|
426
|
+
'over the failure, and disarmed it.';
|
|
427
|
+
const act =
|
|
428
|
+
blockClass === ADVISORY_GATE_INCONCLUSIVE_CLASS
|
|
429
|
+
? 'The job **failed without finishing** and reported no violation, so ' +
|
|
430
|
+
'nothing here says the change is bad. Re-run it — ' +
|
|
431
|
+
'`--rerun-advisory <n>` on this command, or ' +
|
|
432
|
+
'`delivery.ci.rerunAdvisory` — rather than granting the permanent ' +
|
|
433
|
+
'global exemption `delivery.ci.advisoryAllowlist` is. Merging by ' +
|
|
434
|
+
'hand also lands it.'
|
|
435
|
+
: 'The job reported a real violation, so the change **is** implicated. ' +
|
|
436
|
+
'Fix it and push a new head, merge by hand to land over it ' +
|
|
437
|
+
'deliberately, re-run the job (`--rerun-advisory <n>`, or ' +
|
|
438
|
+
'`delivery.ci.rerunAdvisory`) if you believe it flaked, or exempt ' +
|
|
439
|
+
'the job via `delivery.ci.advisoryAllowlist`.';
|
|
440
|
+
return (
|
|
441
|
+
`${mergeable}\n\n${act}\n\nThen resume the land:\n\n` +
|
|
442
|
+
`\`\`\`bash\n${NEXT_COMMANDS.resumeLand(storyId)}\n\`\`\``
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* The class-specific remediation paragraph of the unlanded friction comment.
|
|
448
|
+
* Split out of {@link formatUnlandedFriction} so each class's wording is one
|
|
449
|
+
* named branch rather than a nested ternary.
|
|
450
|
+
*
|
|
451
|
+
* @param {{ storyId: number, prNumber: number|null, blockClass: string }} args
|
|
452
|
+
* @returns {string}
|
|
453
|
+
*/
|
|
454
|
+
function unlandedRemedy({ storyId, prNumber, blockClass }) {
|
|
455
|
+
if (blockClass === 'checks-failed') {
|
|
456
|
+
return (
|
|
457
|
+
`A required check is **red**. Fix the failure and push a new commit on \`story-${storyId}\`; ` +
|
|
458
|
+
`the red disarms auto-merge, and only a green on a new head SHA re-arms it — ` +
|
|
459
|
+
`re-running the failed job is forbidden. Watch the checks with:\n\n` +
|
|
460
|
+
`\`\`\`bash\n${NEXT_COMMANDS.watchCi(storyId, prNumber)}\n\`\`\``
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
if (
|
|
464
|
+
blockClass === ADVISORY_GATE_INCONCLUSIVE_CLASS ||
|
|
465
|
+
blockClass === ADVISORY_GATE_RED_CLASS
|
|
466
|
+
) {
|
|
467
|
+
return advisoryGateRemedy({ storyId, blockClass });
|
|
468
|
+
}
|
|
469
|
+
return (
|
|
470
|
+
`Resolve the underlying condition (branch protection, required checks, ` +
|
|
471
|
+
`or a manual merge), then resume the land:\n\n` +
|
|
472
|
+
`\`\`\`bash\n${NEXT_COMMANDS.resumeLand(storyId)}\n\`\`\``
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
376
476
|
/**
|
|
377
477
|
* Format the `friction` comment body posted alongside the `agent::blocked`
|
|
378
478
|
* transition when a landing attempt gives up without a confirmed merge.
|
|
@@ -389,15 +489,7 @@ function formatUnlandedFriction({
|
|
|
389
489
|
Number.isInteger(prNumber) && prNumber > 0
|
|
390
490
|
? `PR #${prNumber}${prUrl ? ` (${prUrl})` : ''}`
|
|
391
491
|
: (prUrl ?? 'the PR');
|
|
392
|
-
const remedy =
|
|
393
|
-
blockClass === 'checks-failed'
|
|
394
|
-
? `A required check is **red**. Fix the failure and push a new commit on \`story-${storyId}\`; ` +
|
|
395
|
-
`the red disarms auto-merge, and only a green on a new head SHA re-arms it — ` +
|
|
396
|
-
`re-running the failed job is forbidden. Watch the checks with:\n\n` +
|
|
397
|
-
`\`\`\`bash\n${NEXT_COMMANDS.watchCi(storyId, prNumber)}\n\`\`\``
|
|
398
|
-
: `Resolve the underlying condition (branch protection, required checks, ` +
|
|
399
|
-
`or a manual merge), then resume the land:\n\n` +
|
|
400
|
-
`\`\`\`bash\n${NEXT_COMMANDS.resumeLand(storyId)}\n\`\`\``;
|
|
492
|
+
const remedy = unlandedRemedy({ storyId, prNumber, blockClass });
|
|
401
493
|
return (
|
|
402
494
|
`### close-and-land: merge did not land\n\n` +
|
|
403
495
|
`Story #${storyId}: the close polled ${prLabel} for merge confirmation and ` +
|
|
@@ -545,11 +637,179 @@ async function blockOnFlipFailed({
|
|
|
545
637
|
}
|
|
546
638
|
|
|
547
639
|
/**
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
640
|
+
* Story #5266 — the per-invocation advisory rerun allowance.
|
|
641
|
+
*
|
|
642
|
+
* `--rerun-advisory <n>` wins over `delivery.ci.rerunAdvisory` on exactly the
|
|
643
|
+
* precedence `--max-wait-seconds` already uses. Both default to **0**: a
|
|
644
|
+
* rerun spends CI minutes and mutates GitHub state, so close does neither
|
|
645
|
+
* unasked. A non-integer or negative override is not an instruction to guess
|
|
646
|
+
* — it degrades to the config value.
|
|
647
|
+
*
|
|
648
|
+
* @param {object|null} config Resolved config (or any `delivery.ci` bag).
|
|
649
|
+
* @param {number} [override] The `--rerun-advisory` value, when supplied.
|
|
650
|
+
* @returns {number} allowance ≥ 0
|
|
651
|
+
*/
|
|
652
|
+
export function resolveAdvisoryRerunAllowance(config, override) {
|
|
653
|
+
if (Number.isInteger(override) && override >= 0) return override;
|
|
654
|
+
return getCiDelivery(config).rerunAdvisory;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Identify ONE observation of a red run: the job, the workflow run behind it,
|
|
659
|
+
* and when that run finished. A rerun changes `completedAt` (and eventually
|
|
660
|
+
* the conclusion), so this is what lets the wait tell a re-run's verdict apart
|
|
661
|
+
* from the stale pre-rerun snapshot it will keep seeing for a poll or two.
|
|
662
|
+
*
|
|
663
|
+
* @param {{name?: string|null, runId?: number, completedAt?: string}} run
|
|
664
|
+
* @returns {string}
|
|
665
|
+
*/
|
|
666
|
+
function advisoryRunSignature(run) {
|
|
667
|
+
return `${run?.name ?? '(unnamed)'}@${run?.runId ?? 'no-run'}#${run?.completedAt ?? 'no-stamp'}`;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Story #5266 — read each red advisory run's own account of WHY it failed.
|
|
672
|
+
*
|
|
673
|
+
* `gh pr view --json statusCheckRollup` has a fixed projection that carries no
|
|
674
|
+
* output text for a CheckRun, so on the rollup alone every red advisory run
|
|
675
|
+
* looks identical — which is the defect. The check-runs API for the head SHA
|
|
676
|
+
* carries `output.title` / `output.summary`, and ONE call for the whole head
|
|
677
|
+
* is enough to classify every red run on it.
|
|
678
|
+
*
|
|
679
|
+
* Called only on the block path (never per poll), and **fails open**: any
|
|
680
|
+
* error, timeout, or missing head SHA returns the runs unchanged, which
|
|
681
|
+
* classifies them as `advisory-gate-red` — the pre-#5266 verdict.
|
|
682
|
+
*
|
|
683
|
+
* @returns {Promise<Array<object>>} the runs, enriched where output was found
|
|
684
|
+
*/
|
|
685
|
+
async function enrichRedRunsWithOutput({
|
|
686
|
+
runs,
|
|
687
|
+
headSha,
|
|
688
|
+
gh,
|
|
689
|
+
ghTimeoutMs,
|
|
690
|
+
progress,
|
|
691
|
+
}) {
|
|
692
|
+
if (!Array.isArray(runs) || runs.length === 0 || !headSha) return runs ?? [];
|
|
693
|
+
try {
|
|
694
|
+
const raw = await withGhTimeout(
|
|
695
|
+
(gh ?? defaultGh).api({
|
|
696
|
+
endpoint: `/repos/{owner}/{repo}/commits/${headSha}/check-runs?per_page=100`,
|
|
697
|
+
}),
|
|
698
|
+
ghTimeoutMs,
|
|
699
|
+
`gh api check-runs ${headSha}`,
|
|
700
|
+
);
|
|
701
|
+
const payload = JSON.parse(raw?.stdout ?? '{}');
|
|
702
|
+
const byName = new Map();
|
|
703
|
+
for (const checkRun of payload?.check_runs ?? []) {
|
|
704
|
+
if (typeof checkRun?.name === 'string' && checkRun.name) {
|
|
705
|
+
byName.set(checkRun.name, checkRun);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return runs.map((run) => {
|
|
709
|
+
const summary = readRunSummary(run.name ? byName.get(run.name) : null);
|
|
710
|
+
return summary ? { ...run, summary } : run;
|
|
711
|
+
});
|
|
712
|
+
} catch (err) {
|
|
713
|
+
progress?.(
|
|
714
|
+
'CONFIRM',
|
|
715
|
+
`⚠️ Could not read the advisory check output (${err?.message ?? err}) — ` +
|
|
716
|
+
'classifying from the rollup alone.',
|
|
717
|
+
);
|
|
718
|
+
return runs;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Story #5266 — re-run the failed advisory workflow run(s), once, within the
|
|
724
|
+
* caller's remaining allowance.
|
|
725
|
+
*
|
|
726
|
+
* Requests `rerun-failed-jobs` per distinct workflow run rather than per job:
|
|
727
|
+
* one advisory workflow commonly fans out, and re-running the whole failed set
|
|
728
|
+
* is both cheaper in API calls and what an operator means by "re-run it".
|
|
729
|
+
*
|
|
730
|
+
* @returns {Promise<boolean>} whether every rerun request succeeded. `false`
|
|
731
|
+
* (including "no run id to re-run") leaves the caller on the block path,
|
|
732
|
+
* because an allowance that cannot be spent must not silently suppress the
|
|
733
|
+
* gate.
|
|
552
734
|
*/
|
|
735
|
+
async function rerunAdvisoryRuns({ blockingRuns, gh, ghTimeoutMs, progress }) {
|
|
736
|
+
const runIds = [
|
|
737
|
+
...new Set(
|
|
738
|
+
blockingRuns
|
|
739
|
+
.map((run) => run?.runId ?? parseWorkflowRunId(run?.detailsUrl))
|
|
740
|
+
.filter((id) => Number.isInteger(id) && id > 0),
|
|
741
|
+
),
|
|
742
|
+
];
|
|
743
|
+
if (runIds.length === 0) {
|
|
744
|
+
progress?.(
|
|
745
|
+
'CONFIRM',
|
|
746
|
+
'⚠️ Advisory rerun requested but no workflow run id is readable on the ' +
|
|
747
|
+
'red run(s) — blocking instead.',
|
|
748
|
+
);
|
|
749
|
+
return false;
|
|
750
|
+
}
|
|
751
|
+
for (const runId of runIds) {
|
|
752
|
+
try {
|
|
753
|
+
await withGhTimeout(
|
|
754
|
+
(gh ?? defaultGh).api({
|
|
755
|
+
method: 'POST',
|
|
756
|
+
endpoint: `/repos/{owner}/{repo}/actions/runs/${runId}/rerun-failed-jobs`,
|
|
757
|
+
}),
|
|
758
|
+
ghTimeoutMs,
|
|
759
|
+
`gh api rerun-failed-jobs ${runId}`,
|
|
760
|
+
);
|
|
761
|
+
} catch (err) {
|
|
762
|
+
progress?.(
|
|
763
|
+
'CONFIRM',
|
|
764
|
+
`⚠️ Advisory rerun of workflow run ${runId} failed (${err?.message ?? err}) — blocking instead.`,
|
|
765
|
+
);
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
progress?.(
|
|
770
|
+
'CONFIRM',
|
|
771
|
+
`🔁 Re-ran ${runIds.length} failed advisory workflow run(s) [${runIds.join(', ')}] — ` +
|
|
772
|
+
'the merge wait keeps polling inside its existing budget.',
|
|
773
|
+
);
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Spend one unit of the rerun allowance, if there is one and the rerun takes.
|
|
779
|
+
* Records the observation signature of every run it re-ran, so the stale
|
|
780
|
+
* pre-rerun snapshot the next poll reads does not re-block on the same job.
|
|
781
|
+
*
|
|
782
|
+
* Deliberately does NOT disarm first, unlike the block path: the whole point
|
|
783
|
+
* of a rerun is that a green re-run lands the PR on its own. The cost is an
|
|
784
|
+
* armed window in which GitHub could land the PR over the still-red advisory
|
|
785
|
+
* if the required contexts go green before the re-run reports — which is
|
|
786
|
+
* exactly what opting in to `--rerun-advisory` buys and accepts. At the
|
|
787
|
+
* default 0 there is no such window.
|
|
788
|
+
*
|
|
789
|
+
* @returns {Promise<boolean>} `true` when the caller should keep polling.
|
|
790
|
+
*/
|
|
791
|
+
async function maybeRerunAdvisory({
|
|
792
|
+
rerunState,
|
|
793
|
+
blockingRuns,
|
|
794
|
+
gh,
|
|
795
|
+
ghTimeoutMs,
|
|
796
|
+
progress,
|
|
797
|
+
}) {
|
|
798
|
+
if (rerunState.remaining <= 0) return false;
|
|
799
|
+
const rerun = await rerunAdvisoryRuns({
|
|
800
|
+
blockingRuns,
|
|
801
|
+
gh,
|
|
802
|
+
ghTimeoutMs,
|
|
803
|
+
progress,
|
|
804
|
+
});
|
|
805
|
+
if (!rerun) return false;
|
|
806
|
+
rerunState.remaining -= 1;
|
|
807
|
+
for (const run of blockingRuns) {
|
|
808
|
+
rerunState.issued.add(advisoryRunSignature(run));
|
|
809
|
+
}
|
|
810
|
+
return true;
|
|
811
|
+
}
|
|
812
|
+
|
|
553
813
|
/**
|
|
554
814
|
* Story #5096 — resolve the advisory-gate terminal for one poll.
|
|
555
815
|
*
|
|
@@ -561,14 +821,22 @@ async function blockOnFlipFailed({
|
|
|
561
821
|
*
|
|
562
822
|
* Disarms BEFORE returning the terminal: an armed PR can merge out from under
|
|
563
823
|
* the block the caller is about to record.
|
|
824
|
+
*
|
|
825
|
+
* Story #5266 threads three more steps through the same single assignment:
|
|
826
|
+
* runs this invocation already re-ran (and has no fresh verdict for) are
|
|
827
|
+
* skipped rather than re-blocked; the survivors are enriched with their own
|
|
828
|
+
* check-run output so the class can be `advisory-gate-inconclusive`; and a
|
|
829
|
+
* remaining rerun allowance is spent before any block is recorded.
|
|
564
830
|
*/
|
|
565
831
|
async function resolveAdvisoryUnlanded({
|
|
566
832
|
unlanded,
|
|
567
833
|
probe,
|
|
568
834
|
blockOnAdvisoryFailure,
|
|
569
835
|
advisoryAllowlist,
|
|
836
|
+
rerunState,
|
|
570
837
|
prNumber,
|
|
571
838
|
gh,
|
|
839
|
+
ghTimeoutMs,
|
|
572
840
|
progress,
|
|
573
841
|
disarmAutoMergeFn,
|
|
574
842
|
elapsedSeconds,
|
|
@@ -580,16 +848,51 @@ async function resolveAdvisoryUnlanded({
|
|
|
580
848
|
advisoryAllowlist,
|
|
581
849
|
});
|
|
582
850
|
if (!advisory) return null;
|
|
583
|
-
|
|
851
|
+
// Every blocking run is one this invocation already re-ran and has not seen
|
|
852
|
+
// a fresh verdict for yet (Story #5266) — keep polling rather than blocking
|
|
853
|
+
// on the snapshot the rerun was meant to replace.
|
|
854
|
+
const pending = advisory.blockingRuns.filter(
|
|
855
|
+
(run) => !rerunState.issued.has(advisoryRunSignature(run)),
|
|
856
|
+
);
|
|
857
|
+
if (pending.length === 0) return null;
|
|
858
|
+
const blockingRuns = await enrichRedRunsWithOutput({
|
|
859
|
+
runs: pending,
|
|
860
|
+
headSha: probe?.headSha,
|
|
861
|
+
gh,
|
|
862
|
+
ghTimeoutMs,
|
|
863
|
+
progress,
|
|
864
|
+
});
|
|
865
|
+
if (
|
|
866
|
+
await maybeRerunAdvisory({
|
|
867
|
+
rerunState,
|
|
868
|
+
blockingRuns,
|
|
869
|
+
gh,
|
|
870
|
+
ghTimeoutMs,
|
|
871
|
+
progress,
|
|
872
|
+
})
|
|
873
|
+
) {
|
|
874
|
+
return null;
|
|
875
|
+
}
|
|
876
|
+
const verdict = resolveAdvisoryGateVerdict({
|
|
877
|
+
blockingRuns,
|
|
878
|
+
rerunAllowance: rerunState.allowance,
|
|
879
|
+
});
|
|
880
|
+
progress?.('CONFIRM', `🛑 PR #${prNumber}: ${verdict.reason}`);
|
|
584
881
|
await disarmAutoMergeFn({ prNumber, gh, progress });
|
|
585
882
|
return {
|
|
586
883
|
prProbe: probe,
|
|
587
884
|
budget: { exhausted: false, elapsedSeconds },
|
|
588
|
-
blockClassOverride:
|
|
589
|
-
reasonOverride:
|
|
885
|
+
blockClassOverride: verdict.blockClass,
|
|
886
|
+
reasonOverride: verdict.reason,
|
|
590
887
|
};
|
|
591
888
|
}
|
|
592
889
|
|
|
890
|
+
/**
|
|
891
|
+
* Classify the unlanded merge, emit `merge.unlanded`, post a `friction`
|
|
892
|
+
* comment, and transition the Story to `agent::blocked`. Every side effect
|
|
893
|
+
* is best-effort logged rather than thrown — the caller owns surfacing the
|
|
894
|
+
* non-zero exit once this returns.
|
|
895
|
+
*/
|
|
593
896
|
async function blockOnUnlanded({
|
|
594
897
|
storyId,
|
|
595
898
|
prNumber,
|
|
@@ -853,6 +1156,8 @@ async function onMergeObserved({
|
|
|
853
1156
|
* `--merge-watch-mode` override (Story #4949); wins over
|
|
854
1157
|
* `delivery.mergeWatch.mode`.
|
|
855
1158
|
* @param {(tag: string, msg: string) => void} [args.progress]
|
|
1159
|
+
* @param {number} [args.rerunAdvisory] `--rerun-advisory <n>` — the
|
|
1160
|
+
* per-invocation override of `delivery.ci.rerunAdvisory` (both default 0).
|
|
856
1161
|
* @param {object} [args.injectedGh]
|
|
857
1162
|
* @param {Function} [args.injectedNotify]
|
|
858
1163
|
* @param {Function} [args.confirmStoryMergedFn] Test seam — defaults to the
|
|
@@ -886,6 +1191,7 @@ export async function runConfirmMergePhase({
|
|
|
886
1191
|
config,
|
|
887
1192
|
maxWaitSeconds: maxWaitSecondsOverride,
|
|
888
1193
|
mergeWatchMode: mergeWatchModeOverride,
|
|
1194
|
+
rerunAdvisory: rerunAdvisoryOverride,
|
|
889
1195
|
progress,
|
|
890
1196
|
injectedGh,
|
|
891
1197
|
injectedNotify,
|
|
@@ -923,9 +1229,12 @@ export async function runConfirmMergePhase({
|
|
|
923
1229
|
// Story #5096 — the arm phase already refused over a red advisory gate;
|
|
924
1230
|
// carry its verdict through instead of letting the classifier read this
|
|
925
1231
|
// as a generic `arm-failure`.
|
|
1232
|
+
// Story #5266 — carry the arm phase's CLASS too: a pre-arm refusal over
|
|
1233
|
+
// a scan that never finished is `advisory-gate-inconclusive`, and
|
|
1234
|
+
// hard-coding the red class here would relabel it at the terminal.
|
|
926
1235
|
...(autoMergeReason === 'advisory-gate-red'
|
|
927
1236
|
? {
|
|
928
|
-
blockClassOverride: 'advisory-gate-red',
|
|
1237
|
+
blockClassOverride: advisoryGate?.blockClass ?? 'advisory-gate-red',
|
|
929
1238
|
reasonOverride: advisoryGate?.reason,
|
|
930
1239
|
}
|
|
931
1240
|
: {}),
|
|
@@ -945,6 +1254,18 @@ export async function runConfirmMergePhase({
|
|
|
945
1254
|
);
|
|
946
1255
|
// Story #5096 — the advisory-gate knobs, read once for the whole wait.
|
|
947
1256
|
const { blockOnAdvisoryFailure, advisoryAllowlist } = getCiDelivery(config);
|
|
1257
|
+
// Story #5266 — the rerun allowance and its ledger, spent across the whole
|
|
1258
|
+
// wait rather than per poll, so `n` bounds the CI minutes this invocation
|
|
1259
|
+
// can cost no matter how many times the gate is observed red.
|
|
1260
|
+
const rerunAllowance = resolveAdvisoryRerunAllowance(
|
|
1261
|
+
config,
|
|
1262
|
+
rerunAdvisoryOverride,
|
|
1263
|
+
);
|
|
1264
|
+
const rerunState = {
|
|
1265
|
+
allowance: rerunAllowance,
|
|
1266
|
+
remaining: rerunAllowance,
|
|
1267
|
+
issued: new Set(),
|
|
1268
|
+
};
|
|
948
1269
|
const intervalMs = intervalSeconds * 1000;
|
|
949
1270
|
const startedAtMs = nowMsFn();
|
|
950
1271
|
let anchorMs = startedAtMs;
|
|
@@ -1105,8 +1426,10 @@ export async function runConfirmMergePhase({
|
|
|
1105
1426
|
probe,
|
|
1106
1427
|
blockOnAdvisoryFailure,
|
|
1107
1428
|
advisoryAllowlist,
|
|
1429
|
+
rerunState,
|
|
1108
1430
|
prNumber,
|
|
1109
1431
|
gh: injectedGh,
|
|
1432
|
+
ghTimeoutMs,
|
|
1110
1433
|
progress,
|
|
1111
1434
|
disarmAutoMergeFn,
|
|
1112
1435
|
elapsedSeconds: Math.round(waitedMs / 1000),
|