mandrel 2.53.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 +241 -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 +119 -6
- 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 +70 -6
- 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 +40 -16
- 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 +8 -1
- package/.agents/workflows/mandrel-plan.md +4 -7
- package/.agents/workflows/memory-consolidate.md +14 -9
- package/docs/CHANGELOG.md +34 -0
- package/lib/cli/registry.js +64 -21
- package/lib/cli/sync.js +27 -2
- package/package.json +7 -4
|
@@ -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),
|
|
@@ -36,6 +36,23 @@ function resolveFlag(paramValue, parsedValue) {
|
|
|
36
36
|
return paramValue ?? parsedValue;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* An integer flag at or above `min`, or `undefined` — the shape BOTH numeric
|
|
41
|
+
* close flags share (Story #5266 deduplicated them): absence is preserved so
|
|
42
|
+
* the reader downstream applies its own config default, and a junk value is
|
|
43
|
+
* treated as absent rather than coerced into a bound nobody asked for.
|
|
44
|
+
*
|
|
45
|
+
* `min` is what separates them: `--max-wait-seconds 0` is a typo (a
|
|
46
|
+
* zero-second wait), while `--rerun-advisory 0` is the meaningful default.
|
|
47
|
+
*
|
|
48
|
+
* @param {unknown} value
|
|
49
|
+
* @param {number} min
|
|
50
|
+
* @returns {number|undefined}
|
|
51
|
+
*/
|
|
52
|
+
function intAtLeast(value, min) {
|
|
53
|
+
return Number.isInteger(value) && value >= min ? value : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
39
56
|
/**
|
|
40
57
|
* Resolve whether close lands the PR in-process (`waitForMerge`).
|
|
41
58
|
*
|
|
@@ -139,8 +156,8 @@ function assertNoRetiredFlags(argv) {
|
|
|
139
156
|
* (`waitForMergeExplicit` / `noWaitForMerge`) for the runner to resolve once
|
|
140
157
|
* the config and the arm outcome exist.
|
|
141
158
|
*
|
|
142
|
-
* @param {{ storyIdParam, cwdParam, skipValidationParam, skipSyncParam, noAutoMergeParam, waitForMergeParam, noWaitForMergeParam, maxWaitSecondsParam, mergeWatchModeParam, overrideReviewBlockParam }} raw
|
|
143
|
-
* @returns {{ storyId, cwd, skipValidation, skipSync, noAutoMerge, waitForMergeExplicit, noWaitForMerge, maxWaitSeconds, mergeWatchMode, overrideReviewBlock }}
|
|
159
|
+
* @param {{ storyIdParam, cwdParam, skipValidationParam, skipSyncParam, noAutoMergeParam, waitForMergeParam, noWaitForMergeParam, maxWaitSecondsParam, mergeWatchModeParam, rerunAdvisoryParam, overrideReviewBlockParam }} raw
|
|
160
|
+
* @returns {{ storyId, cwd, skipValidation, skipSync, noAutoMerge, waitForMergeExplicit, noWaitForMerge, maxWaitSeconds, mergeWatchMode, rerunAdvisory, overrideReviewBlock }}
|
|
144
161
|
*/
|
|
145
162
|
export function parseCloseOptions({
|
|
146
163
|
storyIdParam,
|
|
@@ -152,6 +169,7 @@ export function parseCloseOptions({
|
|
|
152
169
|
noWaitForMergeParam,
|
|
153
170
|
maxWaitSecondsParam,
|
|
154
171
|
mergeWatchModeParam,
|
|
172
|
+
rerunAdvisoryParam,
|
|
155
173
|
overrideReviewBlockParam,
|
|
156
174
|
}) {
|
|
157
175
|
// An injecting caller (`storyIdParam` supplied) is not reading argv at all,
|
|
@@ -178,6 +196,7 @@ export function parseCloseOptions({
|
|
|
178
196
|
maxWaitSecondsParam,
|
|
179
197
|
parsed.maxWaitSeconds,
|
|
180
198
|
);
|
|
199
|
+
const rerunAdvisory = resolveFlag(rerunAdvisoryParam, parsed.rerunAdvisory);
|
|
181
200
|
return {
|
|
182
201
|
storyId: resolveFlag(storyIdParam, parsed.storyId),
|
|
183
202
|
cwd: path.resolve(cwdParam ?? parsed.cwd ?? PROJECT_ROOT),
|
|
@@ -185,10 +204,7 @@ export function parseCloseOptions({
|
|
|
185
204
|
// `delivery.mergeWatch.maxWaitSeconds`. A per-run override exists so a
|
|
186
205
|
// headless caller with no host tool-invocation ceiling can keep
|
|
187
206
|
// single-block semantics without editing the consumer's config.
|
|
188
|
-
maxWaitSeconds:
|
|
189
|
-
Number.isInteger(maxWaitSeconds) && maxWaitSeconds > 0
|
|
190
|
-
? maxWaitSeconds
|
|
191
|
-
: undefined,
|
|
207
|
+
maxWaitSeconds: intAtLeast(maxWaitSeconds, 1),
|
|
192
208
|
// `undefined` when unsupplied — the merge wait then reads
|
|
193
209
|
// `delivery.mergeWatch.mode`. The two merge-watch flags stay composable and
|
|
194
210
|
// mode-agnostic: `--merge-watch-mode async` picks the posture, and an
|
|
@@ -196,6 +212,11 @@ export function parseCloseOptions({
|
|
|
196
212
|
mergeWatchMode: parseMergeWatchMode(
|
|
197
213
|
resolveFlag(mergeWatchModeParam, parsed.mergeWatchMode),
|
|
198
214
|
),
|
|
215
|
+
// Story #5266 — `undefined` when unsupplied, so the merge wait reads
|
|
216
|
+
// `delivery.ci.rerunAdvisory` (default 0). An explicit 0 is preserved as
|
|
217
|
+
// an explicit 0: it means "spend nothing", which is also the default, but
|
|
218
|
+
// an operator who typed it must not have it read as absent.
|
|
219
|
+
rerunAdvisory: intAtLeast(rerunAdvisory, 0),
|
|
199
220
|
skipValidation: !!resolveFlag(skipValidationParam, parsed.skipValidation),
|
|
200
221
|
skipSync: !!resolveFlag(skipSyncParam, parsed.skipSync),
|
|
201
222
|
noAutoMerge: !!resolveFlag(noAutoMergeParam, parsed.noAutoMerge),
|