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
|
@@ -71,7 +71,11 @@
|
|
|
71
71
|
* (`emit-merge-unlanded.js`).
|
|
72
72
|
*/
|
|
73
73
|
|
|
74
|
-
import {
|
|
74
|
+
import {
|
|
75
|
+
ADVISORY_GATE_INCONCLUSIVE_CLASS,
|
|
76
|
+
ADVISORY_GATE_RED_CLASS,
|
|
77
|
+
requiredCheckFailedBlocksMerge,
|
|
78
|
+
} from './merge-poll.js';
|
|
75
79
|
|
|
76
80
|
/**
|
|
77
81
|
* Every class `classifyMergeBlock` can return. Order is the evaluation
|
|
@@ -102,7 +106,14 @@ export const BLOCK_CLASSES = Object.freeze([
|
|
|
102
106
|
* (`mergeStateStatus: UNSTABLE`), which native auto-merge would land straight
|
|
103
107
|
* past. It is emitted directly by the arm and merge-wait phases — the
|
|
104
108
|
* classifier cannot produce it, because by construction GitHub is NOT blocking
|
|
105
|
-
* the merge, which is the entire problem it names.
|
|
109
|
+
* the merge, which is the entire problem it names. Story #5266 added
|
|
110
|
+
* `advisory-gate-inconclusive` beside it, emitted directly by the same two
|
|
111
|
+
* phases and under the same discipline: the SAME observation (a red advisory
|
|
112
|
+
* run on an `UNSTABLE` PR) whose run never FINISHED — a scan or navigation
|
|
113
|
+
* timeout reporting no violation. It blocks exactly as `advisory-gate-red`
|
|
114
|
+
* does; it exists because the two authorise different remedies, and reporting
|
|
115
|
+
* a timed-out scan as a found violation pushes the operator toward a permanent
|
|
116
|
+
* allowlist exemption for a transient failure. It is deliberately NOT in
|
|
106
117
|
* `BLOCK_CLASSES`, whose reachability invariant covers only what
|
|
107
118
|
* `classifyMergeBlock` returns. (The Epic-era listeners that used to
|
|
108
119
|
* emit it, AutomergePredicate and AutomergeArmer, are gone; the value stays
|
|
@@ -114,7 +125,11 @@ export const BLOCK_CLASSES = Object.freeze([
|
|
|
114
125
|
export const MERGE_UNLANDED_BLOCK_CLASSES = Object.freeze([
|
|
115
126
|
...BLOCK_CLASSES,
|
|
116
127
|
'predicate-refused',
|
|
117
|
-
|
|
128
|
+
// Sourced from the constants the advisory gate itself decides with
|
|
129
|
+
// (Story #5266), so the attribution vocabulary cannot drift from the
|
|
130
|
+
// verdict that emits it.
|
|
131
|
+
ADVISORY_GATE_RED_CLASS,
|
|
132
|
+
ADVISORY_GATE_INCONCLUSIVE_CLASS,
|
|
118
133
|
]);
|
|
119
134
|
|
|
120
135
|
const BLOCK_CLASS_SET = new Set(MERGE_UNLANDED_BLOCK_CLASSES);
|
|
@@ -113,6 +113,50 @@ export function deriveChecksStatus(statusCheckRollup) {
|
|
|
113
113
|
* @param {Array<{status?: string, conclusion?: string, state?: string}>} statusCheckRollup
|
|
114
114
|
* @returns {{ requiredRunFailed: boolean, requiredRunInFlight: boolean } | null}
|
|
115
115
|
*/
|
|
116
|
+
/**
|
|
117
|
+
* Pure: the uppercase conclusion of a check that GENUINELY concluded red, or
|
|
118
|
+
* `null` when it did not.
|
|
119
|
+
*
|
|
120
|
+
* Red means `FAILURE` / `ERROR` only — never `CANCELLED` / `TIMED_OUT` /
|
|
121
|
+
* `SKIPPED`, which are the superseded-push and sibling-invalidated runs a bare
|
|
122
|
+
* rollup read miscounts (the #4695 / #4710 trap). A CheckRun carries the
|
|
123
|
+
* verdict on `conclusion`; a legacy StatusContext carries it on `state`, so
|
|
124
|
+
* both are read and the one that is red is the one returned.
|
|
125
|
+
*
|
|
126
|
+
* Extracted (Story #5266) because {@link deriveRequiredRunEvidence} and
|
|
127
|
+
* {@link deriveRedHeadRuns} were carrying byte-identical copies of this test:
|
|
128
|
+
* two places that must agree about what "red" means, and nothing making them.
|
|
129
|
+
*
|
|
130
|
+
* @param {{ conclusion?: string, state?: string }} [check]
|
|
131
|
+
* @returns {string|null}
|
|
132
|
+
*/
|
|
133
|
+
function redConclusionOf(check) {
|
|
134
|
+
const conclusion = String(check?.conclusion ?? '').toUpperCase();
|
|
135
|
+
if (conclusion === 'FAILURE' || conclusion === 'ERROR') return conclusion;
|
|
136
|
+
const state = String(check?.state ?? '').toUpperCase();
|
|
137
|
+
if (state === 'FAILURE' || state === 'ERROR') return state;
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Pure: a check's display name — the CheckRun's `name`, falling back to a
|
|
143
|
+
* legacy StatusContext's `context`, and `null` when the projection carries
|
|
144
|
+
* neither.
|
|
145
|
+
*
|
|
146
|
+
* A run with no readable name can never match an allowlist entry, so it always
|
|
147
|
+
* blocks. That is the conservative direction for a gate whose whole purpose is
|
|
148
|
+
* to stop a silent landing.
|
|
149
|
+
*
|
|
150
|
+
* @param {{ name?: string, context?: string }} [check]
|
|
151
|
+
* @returns {string|null}
|
|
152
|
+
*/
|
|
153
|
+
function readRunName(check) {
|
|
154
|
+
for (const value of [check?.name, check?.context]) {
|
|
155
|
+
if (typeof value === 'string' && value) return value;
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
116
160
|
export function deriveRequiredRunEvidence(statusCheckRollup) {
|
|
117
161
|
if (!Array.isArray(statusCheckRollup) || statusCheckRollup.length === 0) {
|
|
118
162
|
return null;
|
|
@@ -120,7 +164,6 @@ export function deriveRequiredRunEvidence(statusCheckRollup) {
|
|
|
120
164
|
let requiredRunFailed = false;
|
|
121
165
|
let requiredRunInFlight = false;
|
|
122
166
|
for (const check of statusCheckRollup) {
|
|
123
|
-
const conclusion = String(check?.conclusion ?? '').toUpperCase();
|
|
124
167
|
const status = String(check?.status ?? '').toUpperCase();
|
|
125
168
|
const state = String(check?.state ?? '').toUpperCase();
|
|
126
169
|
// In flight: a CheckRun not yet COMPLETED, or a legacy StatusContext still
|
|
@@ -131,14 +174,7 @@ export function deriveRequiredRunEvidence(statusCheckRollup) {
|
|
|
131
174
|
} else if (state === 'PENDING' || state === 'EXPECTED') {
|
|
132
175
|
requiredRunInFlight = true;
|
|
133
176
|
}
|
|
134
|
-
|
|
135
|
-
// the superseded / sibling-invalidated noise a bare rollup miscounts.
|
|
136
|
-
if (
|
|
137
|
-
conclusion === 'FAILURE' ||
|
|
138
|
-
conclusion === 'ERROR' ||
|
|
139
|
-
state === 'FAILURE' ||
|
|
140
|
-
state === 'ERROR'
|
|
141
|
-
) {
|
|
177
|
+
if (redConclusionOf(check)) {
|
|
142
178
|
requiredRunFailed = true;
|
|
143
179
|
}
|
|
144
180
|
}
|
|
@@ -256,6 +292,47 @@ export function requiredCheckFailedBlocksMerge(prProbe) {
|
|
|
256
292
|
*/
|
|
257
293
|
const MERGE_ADVISORY_STATE = 'UNSTABLE';
|
|
258
294
|
|
|
295
|
+
/**
|
|
296
|
+
* The fields a check projection can use to say, in its own words, WHY it went
|
|
297
|
+
* red. A legacy StatusContext carries `description`; a GitHub Actions CheckRun
|
|
298
|
+
* carries none of them in `gh pr view`'s fixed `statusCheckRollup` projection,
|
|
299
|
+
* so the merge wait enriches the run with the check-run API's
|
|
300
|
+
* `output.title` / `output.summary` before classifying (Story #5266). Both
|
|
301
|
+
* shapes are read here so the projection has ONE text extractor.
|
|
302
|
+
*
|
|
303
|
+
* @param {object} [check] A rollup entry, or an enriched check-run record.
|
|
304
|
+
* @returns {string|undefined} The joined text, or `undefined` when the record
|
|
305
|
+
* carries none — which is itself the signal that the run cannot be
|
|
306
|
+
* classified beyond "red".
|
|
307
|
+
*/
|
|
308
|
+
export function readRunSummary(check) {
|
|
309
|
+
const parts = [];
|
|
310
|
+
for (const field of ['description', 'title', 'summary', 'text']) {
|
|
311
|
+
for (const value of [check?.[field], check?.output?.[field]]) {
|
|
312
|
+
if (typeof value === 'string' && value.trim()) parts.push(value.trim());
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return parts.length > 0 ? parts.join(' — ') : undefined;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Pure: the workflow run id behind a check run's `detailsUrl`
|
|
320
|
+
* (`.../actions/runs/<runId>/job/<jobId>`), or `null` when the URL is absent
|
|
321
|
+
* or shaped otherwise (a legacy StatusContext's `targetUrl`, a third-party
|
|
322
|
+
* app's own page). A run with no id can never be re-run, which is why the
|
|
323
|
+
* rerun path treats `null` as "nothing to re-run" rather than an error.
|
|
324
|
+
*
|
|
325
|
+
* @param {string} [detailsUrl]
|
|
326
|
+
* @returns {number|null}
|
|
327
|
+
*/
|
|
328
|
+
export function parseWorkflowRunId(detailsUrl) {
|
|
329
|
+
if (typeof detailsUrl !== 'string') return null;
|
|
330
|
+
const match = /\/actions\/runs\/(\d+)/.exec(detailsUrl);
|
|
331
|
+
if (!match) return null;
|
|
332
|
+
const id = Number.parseInt(match[1], 10);
|
|
333
|
+
return Number.isInteger(id) && id > 0 ? id : null;
|
|
334
|
+
}
|
|
335
|
+
|
|
259
336
|
/**
|
|
260
337
|
* Pure: project the HEAD-ANCHORED runs that genuinely concluded red, naming
|
|
261
338
|
* each one (Story #5096).
|
|
@@ -273,28 +350,36 @@ const MERGE_ADVISORY_STATE = 'UNSTABLE';
|
|
|
273
350
|
* conservative direction for a gate whose whole purpose is to stop a silent
|
|
274
351
|
* landing.
|
|
275
352
|
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
353
|
+
* **Widened by Story #5266** from `{name, conclusion}` to carry what a red run
|
|
354
|
+
* is classified and acted on by: `summary` (the run's own account of why it
|
|
355
|
+
* failed — see {@link readRunSummary}), `runId` (the workflow run behind it,
|
|
356
|
+
* so a rerun can be requested), and `completedAt` (the observation stamp that
|
|
357
|
+
* tells a re-run's verdict apart from the stale pre-rerun one). Every added
|
|
358
|
+
* field is OMITTED when the projection carries no value for it, so a run the
|
|
359
|
+
* rollup describes as thinly as before still projects to exactly the old two
|
|
360
|
+
* keys — and a thin run classifies as a violation, i.e. the pre-#5266 verdict.
|
|
361
|
+
*
|
|
362
|
+
* @param {Array<{name?: string, context?: string, status?: string, conclusion?: string, state?: string, detailsUrl?: string, completedAt?: string, description?: string, output?: object}>} statusCheckRollup
|
|
363
|
+
* @returns {Array<{ name: string|null, conclusion: string, summary?: string, runId?: number, completedAt?: string }>}
|
|
278
364
|
*/
|
|
279
365
|
export function deriveRedHeadRuns(statusCheckRollup) {
|
|
280
366
|
if (!Array.isArray(statusCheckRollup)) return [];
|
|
281
367
|
const red = [];
|
|
282
368
|
for (const check of statusCheckRollup) {
|
|
283
|
-
const conclusion =
|
|
284
|
-
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
red.push({ name, conclusion: conclusion || state });
|
|
369
|
+
const conclusion = redConclusionOf(check);
|
|
370
|
+
if (!conclusion) continue;
|
|
371
|
+
const summary = readRunSummary(check);
|
|
372
|
+
const runId = parseWorkflowRunId(check?.detailsUrl);
|
|
373
|
+
const completedAt = check?.completedAt;
|
|
374
|
+
red.push({
|
|
375
|
+
name: readRunName(check),
|
|
376
|
+
conclusion,
|
|
377
|
+
...(summary ? { summary } : {}),
|
|
378
|
+
...(runId ? { runId } : {}),
|
|
379
|
+
...(typeof completedAt === 'string' && completedAt
|
|
380
|
+
? { completedAt }
|
|
381
|
+
: {}),
|
|
382
|
+
});
|
|
298
383
|
}
|
|
299
384
|
return red;
|
|
300
385
|
}
|
|
@@ -353,6 +438,130 @@ export function advisoryCheckFailedBlocksArm(prProbe, allowlist = []) {
|
|
|
353
438
|
return selectBlockingRedRuns(prProbe?.redHeadRuns, allowlist).length > 0;
|
|
354
439
|
}
|
|
355
440
|
|
|
441
|
+
/**
|
|
442
|
+
* The two advisory-gate block classes (Story #5266). Both BLOCK — the gate's
|
|
443
|
+
* verdict on whether to land is unchanged — but they authorise different acts,
|
|
444
|
+
* which is the whole reason they are two:
|
|
445
|
+
*
|
|
446
|
+
* - `advisory-gate-red` A red advisory run that REPORTED a
|
|
447
|
+
* violation. The change is implicated;
|
|
448
|
+
* landing over it is a deliberate override.
|
|
449
|
+
* - `advisory-gate-inconclusive` A red advisory run that never finished — a
|
|
450
|
+
* scan or navigation timeout that reported
|
|
451
|
+
* no violation at all. Nothing here says the
|
|
452
|
+
* change is bad, so the proportionate remedy
|
|
453
|
+
* is to re-run the job, not to grant the
|
|
454
|
+
* permanent global exemption
|
|
455
|
+
* `advisoryAllowlist` is.
|
|
456
|
+
*/
|
|
457
|
+
export const ADVISORY_GATE_RED_CLASS = 'advisory-gate-red';
|
|
458
|
+
export const ADVISORY_GATE_INCONCLUSIVE_CLASS = 'advisory-gate-inconclusive';
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Text signatures of a run that FAILED WITHOUT FINISHING. Deliberately narrow:
|
|
462
|
+
* the observed shape (Story #5266) is a `Navigation timeout of NNNN ms
|
|
463
|
+
* exceeded` line with an empty violation set, and anything this list does not
|
|
464
|
+
* recognise keeps the pre-#5266 `advisory-gate-red` verdict — the conservative
|
|
465
|
+
* direction, since misreading a real violation as a timeout would offer the
|
|
466
|
+
* operator a rerun for a finding that will come back every time.
|
|
467
|
+
*/
|
|
468
|
+
const INCONCLUSIVE_MARKERS = Object.freeze([
|
|
469
|
+
/navigation timeout/i,
|
|
470
|
+
/timeout of \d+\s*m?s exceeded/i,
|
|
471
|
+
/\btimed out\b/i,
|
|
472
|
+
/\betimedout\b/i,
|
|
473
|
+
/\bdid not (?:finish|complete)\b/i,
|
|
474
|
+
/\b(?:scan|crawl|audit) (?:incomplete|aborted|interrupted)\b/i,
|
|
475
|
+
]);
|
|
476
|
+
|
|
477
|
+
/** A counted finding — `0 violations` is explicitly NOT one. */
|
|
478
|
+
const VIOLATION_COUNT =
|
|
479
|
+
/\b(\d+)\s+(?:violation|error|issue|failure|problem|finding)s?\b/i;
|
|
480
|
+
/** An uncounted finding — enough on its own, because it names a verdict. */
|
|
481
|
+
const VIOLATION_WORD = /\bviolations?\b|\bfailed assertion/i;
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Pure: does this red run's own text report a VIOLATION (as opposed to saying
|
|
485
|
+
* nothing, or saying it never got that far)?
|
|
486
|
+
*
|
|
487
|
+
* A counted phrase wins over the bare word so `0 violations found` — a scan
|
|
488
|
+
* that completed cleanly and then died — is not read as a finding.
|
|
489
|
+
*
|
|
490
|
+
* Takes the text, not the run: its one caller has already established the
|
|
491
|
+
* run says something, so a second empty-text guard here would be a branch no
|
|
492
|
+
* input can reach.
|
|
493
|
+
*
|
|
494
|
+
* @param {string} text A non-empty run summary.
|
|
495
|
+
* @returns {boolean}
|
|
496
|
+
*/
|
|
497
|
+
function reportsViolations(text) {
|
|
498
|
+
const counted = VIOLATION_COUNT.exec(text);
|
|
499
|
+
if (counted) return Number.parseInt(counted[1], 10) > 0;
|
|
500
|
+
return VIOLATION_WORD.test(text);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Pure: classify ONE red advisory run as a genuine violation or an
|
|
505
|
+
* unfinished job (Story #5266).
|
|
506
|
+
*
|
|
507
|
+
* A run whose projection carries no text at all classifies as `violation`:
|
|
508
|
+
* absence of evidence is not evidence the job timed out, and `violation` is
|
|
509
|
+
* the verdict every red advisory run already got before this Story.
|
|
510
|
+
*
|
|
511
|
+
* @param {{ summary?: string }} [run]
|
|
512
|
+
* @returns {'violation'|'inconclusive'}
|
|
513
|
+
*/
|
|
514
|
+
function classifyAdvisoryRedRun(run) {
|
|
515
|
+
const text = typeof run?.summary === 'string' ? run.summary : '';
|
|
516
|
+
if (!text || reportsViolations(text)) return 'violation';
|
|
517
|
+
return INCONCLUSIVE_MARKERS.some((marker) => marker.test(text))
|
|
518
|
+
? 'inconclusive'
|
|
519
|
+
: 'violation';
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Pure: the block class for a whole set of blocking runs.
|
|
524
|
+
*
|
|
525
|
+
* `advisory-gate-inconclusive` requires EVERY blocking run to be inconclusive.
|
|
526
|
+
* One genuine violation beside a timeout is still a genuine violation, and the
|
|
527
|
+
* operator must not be offered a rerun as the remedy for it.
|
|
528
|
+
*
|
|
529
|
+
* Module-private: {@link resolveAdvisoryGateVerdict} is the one door, so a
|
|
530
|
+
* caller cannot take the class without the reason that matches it — and, being
|
|
531
|
+
* the one door, it is also what normalises `blockingRuns` to an array, so
|
|
532
|
+
* neither this nor {@link formatAdvisoryGateReason} re-guards the shape.
|
|
533
|
+
*
|
|
534
|
+
* @param {Array<{ summary?: string }>} runs
|
|
535
|
+
* @returns {string} one of the two advisory classes above
|
|
536
|
+
*/
|
|
537
|
+
function deriveAdvisoryGateClass(runs) {
|
|
538
|
+
if (runs.length === 0) return ADVISORY_GATE_RED_CLASS;
|
|
539
|
+
return runs.every((run) => classifyAdvisoryRedRun(run) === 'inconclusive')
|
|
540
|
+
? ADVISORY_GATE_INCONCLUSIVE_CLASS
|
|
541
|
+
: ADVISORY_GATE_RED_CLASS;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Pure: the advisory gate's whole verdict — class AND the reason text that
|
|
546
|
+
* matches it (Story #5266). One function so a caller can never pair an
|
|
547
|
+
* inconclusive class with the violation wording.
|
|
548
|
+
*
|
|
549
|
+
* @param {{ blockingRuns?: Array<object>, rerunAllowance?: number }} [args]
|
|
550
|
+
* @returns {{ blockClass: string, blockingRuns: Array<object>, reason: string }}
|
|
551
|
+
*/
|
|
552
|
+
export function resolveAdvisoryGateVerdict({
|
|
553
|
+
blockingRuns,
|
|
554
|
+
rerunAllowance = 0,
|
|
555
|
+
} = {}) {
|
|
556
|
+
const runs = Array.isArray(blockingRuns) ? blockingRuns : [];
|
|
557
|
+
const blockClass = deriveAdvisoryGateClass(runs);
|
|
558
|
+
return {
|
|
559
|
+
blockClass,
|
|
560
|
+
blockingRuns: runs,
|
|
561
|
+
reason: formatAdvisoryGateReason(runs, { blockClass, rerunAllowance }),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
356
565
|
/**
|
|
357
566
|
* Pure: the merge wait's advisory-gate decision, the sibling of
|
|
358
567
|
* {@link decideMergeWaitFailFast} (Story #5096).
|
|
@@ -366,13 +575,19 @@ export function advisoryCheckFailedBlocksArm(prProbe, allowlist = []) {
|
|
|
366
575
|
* Returns `null` when the wait should keep polling — the knob is off, the PR
|
|
367
576
|
* is not in the advisory-red state, or every red run is allowlisted.
|
|
368
577
|
*
|
|
578
|
+
* The verdict it returns is provisional on the text the ROLLUP carried
|
|
579
|
+
* (Story #5266): the caller may enrich the blocking runs with the check-run
|
|
580
|
+
* API's output and re-resolve via {@link resolveAdvisoryGateVerdict} before
|
|
581
|
+
* recording the block.
|
|
582
|
+
*
|
|
369
583
|
* @param {object} args
|
|
370
|
-
* @returns {{ blockingRuns: Array<object>, reason: string } | null}
|
|
584
|
+
* @returns {{ blockingRuns: Array<object>, reason: string, blockClass: string } | null}
|
|
371
585
|
*/
|
|
372
586
|
export function decideAdvisoryGateBlock({
|
|
373
587
|
probe,
|
|
374
588
|
blockOnAdvisoryFailure,
|
|
375
589
|
advisoryAllowlist,
|
|
590
|
+
rerunAllowance = 0,
|
|
376
591
|
}) {
|
|
377
592
|
if (!blockOnAdvisoryFailure) return null;
|
|
378
593
|
if (!advisoryCheckFailedBlocksArm(probe, advisoryAllowlist)) return null;
|
|
@@ -380,30 +595,59 @@ export function decideAdvisoryGateBlock({
|
|
|
380
595
|
probe?.redHeadRuns,
|
|
381
596
|
advisoryAllowlist,
|
|
382
597
|
);
|
|
383
|
-
return { blockingRuns,
|
|
598
|
+
return resolveAdvisoryGateVerdict({ blockingRuns, rerunAllowance });
|
|
384
599
|
}
|
|
385
600
|
|
|
386
601
|
/**
|
|
387
602
|
* Format the one-line reason a `merge.unlanded` record and the operator-facing
|
|
388
|
-
* block carry for an
|
|
389
|
-
*
|
|
603
|
+
* block carry for an advisory-gate verdict, naming each offending job and its
|
|
604
|
+
* conclusion.
|
|
605
|
+
*
|
|
606
|
+
* Story #5266 splits the wording by class: an unfinished job is reported as
|
|
607
|
+
* one, because telling an operator a timed-out scan "concluded red" invites
|
|
608
|
+
* them to grant a permanent `advisoryAllowlist` exemption for a transient
|
|
609
|
+
* failure. Both wordings name the same three remedies — rerun, hand-merge,
|
|
610
|
+
* allowlist — in the order proportionate to the class.
|
|
390
611
|
*
|
|
391
|
-
*
|
|
612
|
+
* Module-private for the same reason {@link deriveAdvisoryGateClass} is: the
|
|
613
|
+
* class and the wording must travel together.
|
|
614
|
+
*
|
|
615
|
+
* @param {Array<{ name: string|null, conclusion: string }>} runs
|
|
616
|
+
* @param {{ blockClass: string, rerunAllowance: number }} options
|
|
392
617
|
* @returns {string}
|
|
393
618
|
*/
|
|
394
|
-
|
|
395
|
-
const named =
|
|
396
|
-
|
|
397
|
-
(
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
619
|
+
function formatAdvisoryGateReason(runs, { blockClass, rerunAllowance }) {
|
|
620
|
+
const named =
|
|
621
|
+
runs
|
|
622
|
+
.map(
|
|
623
|
+
(run) =>
|
|
624
|
+
`${run?.name ?? '(unnamed run)'} → ${run?.conclusion ?? 'FAILURE'}`,
|
|
625
|
+
)
|
|
626
|
+
.join(', ') || '(none named)';
|
|
627
|
+
const spent =
|
|
628
|
+
rerunAllowance > 0
|
|
629
|
+
? `The rerun allowance (${rerunAllowance}) is already spent on this head. `
|
|
630
|
+
: '';
|
|
631
|
+
if (blockClass === ADVISORY_GATE_INCONCLUSIVE_CLASS) {
|
|
632
|
+
return (
|
|
633
|
+
'A non-required (advisory) check FAILED WITHOUT FINISHING on the PR ' +
|
|
634
|
+
'head — it reported no violation, so nothing here says the change is ' +
|
|
635
|
+
'bad — and GitHub reports the PR mergeable anyway ' +
|
|
636
|
+
'(mergeStateStatus=UNSTABLE), so native auto-merge would land it over ' +
|
|
637
|
+
`the failure. Unfinished advisory job(s): ${named}. ` +
|
|
638
|
+
`${spent}Re-run the job (--rerun-advisory <n>, or ` +
|
|
639
|
+
'delivery.ci.rerunAdvisory), merge by hand to land over it ' +
|
|
640
|
+
'deliberately, or exempt the job via delivery.ci.advisoryAllowlist.'
|
|
641
|
+
);
|
|
642
|
+
}
|
|
401
643
|
return (
|
|
402
644
|
'A non-required (advisory) check concluded red on the PR head, and GitHub ' +
|
|
403
645
|
'reports the PR mergeable anyway (mergeStateStatus=UNSTABLE) — native ' +
|
|
404
646
|
'auto-merge would land it over the failure. Red advisory job(s): ' +
|
|
405
|
-
`${named
|
|
406
|
-
'
|
|
647
|
+
`${named}. ${spent}Merge by hand to land over it ` +
|
|
648
|
+
'deliberately, re-run the job (--rerun-advisory <n>, or ' +
|
|
649
|
+
'delivery.ci.rerunAdvisory), or exempt the job via ' +
|
|
650
|
+
'delivery.ci.advisoryAllowlist.'
|
|
407
651
|
);
|
|
408
652
|
}
|
|
409
653
|
|
|
@@ -145,6 +145,7 @@ export async function adoptContainerEpic({
|
|
|
145
145
|
provider,
|
|
146
146
|
epicNumber: target.id,
|
|
147
147
|
childIds,
|
|
148
|
+
created: all,
|
|
148
149
|
});
|
|
149
150
|
|
|
150
151
|
Logger.info(
|
|
@@ -164,6 +165,24 @@ export async function adoptContainerEpic({
|
|
|
164
165
|
/**
|
|
165
166
|
* Write the appended checklist back to the Epic body.
|
|
166
167
|
*
|
|
168
|
+
* **The body is re-read `fresh` immediately before the append.** `target.body`
|
|
169
|
+
* was captured by `resolveAdoptionTarget` before the first Story was created,
|
|
170
|
+
* which on a cohort of any size is many seconds and several writes ago. An
|
|
171
|
+
* append computed against that snapshot and PATCHed wholesale silently drops
|
|
172
|
+
* every checklist row another writer added in between — a concurrent persist
|
|
173
|
+
* run adopting the same Epic, or an operator ticking a child off by hand. The
|
|
174
|
+
* body is a full-document write, so a stale base is not a merge conflict; it
|
|
175
|
+
* is a silent revert.
|
|
176
|
+
*
|
|
177
|
+
* This **narrows** the read-then-PATCH window; it does not close it. Nothing
|
|
178
|
+
* here is atomic, and GitHub's issue API offers no compare-and-swap, so a
|
|
179
|
+
* write landing between this read and this PATCH is still lost. Narrowing it
|
|
180
|
+
* from "the whole create phase" to "one round-trip" is the available fix; a
|
|
181
|
+
* real one needs an API that does not exist.
|
|
182
|
+
*
|
|
183
|
+
* The PATCH is skipped when the append changes nothing, so re-running an
|
|
184
|
+
* adoption that already landed writes nothing at all.
|
|
185
|
+
*
|
|
167
186
|
* Non-fatal: the Stories are already live, and the native sub-issue edges
|
|
168
187
|
* written next are the other half of the linkage. Losing the checklist costs
|
|
169
188
|
* the body-only fallback path, not the grouping.
|
|
@@ -179,8 +198,9 @@ async function appendChecklist({ provider, target, childIds }) {
|
|
|
179
198
|
);
|
|
180
199
|
return;
|
|
181
200
|
}
|
|
182
|
-
const
|
|
183
|
-
|
|
201
|
+
const base = await readFreshEpicBody({ provider, target });
|
|
202
|
+
const next = appendEpicChildIds(base, childIds);
|
|
203
|
+
if (next === base) return;
|
|
184
204
|
try {
|
|
185
205
|
await provider.updateTicket(target.id, { body: next });
|
|
186
206
|
} catch (err) {
|
|
@@ -190,3 +210,30 @@ async function appendChecklist({ provider, target, childIds }) {
|
|
|
190
210
|
);
|
|
191
211
|
}
|
|
192
212
|
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Re-read the Epic's body, bypassing any provider cache.
|
|
216
|
+
*
|
|
217
|
+
* `{ fresh: true }` is the whole point: the adoption path already read this
|
|
218
|
+
* issue once, so a cached read would hand back the very snapshot this function
|
|
219
|
+
* exists to replace. Falls back to the snapshot when the re-read fails or the
|
|
220
|
+
* provider has no `getTicket` — a stale base still appends the run's own
|
|
221
|
+
* children, which beats not linking them.
|
|
222
|
+
*
|
|
223
|
+
* @param {{ provider: object, target: { id: number, body: string } }} opts
|
|
224
|
+
* @returns {Promise<string>}
|
|
225
|
+
*/
|
|
226
|
+
async function readFreshEpicBody({ provider, target }) {
|
|
227
|
+
if (typeof provider?.getTicket !== 'function') return target.body;
|
|
228
|
+
try {
|
|
229
|
+
const fresh = await provider.getTicket(target.id, { fresh: true });
|
|
230
|
+
if (typeof fresh?.body === 'string') return fresh.body;
|
|
231
|
+
} catch (err) {
|
|
232
|
+
Logger.warn(
|
|
233
|
+
`[plan-persist] could not re-read Epic #${target.id} before appending its ` +
|
|
234
|
+
`checklist (${err?.message ?? err}); appending to the body read earlier. ` +
|
|
235
|
+
'A child linked by another writer since then may be dropped.',
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return target.body;
|
|
239
|
+
}
|
|
@@ -123,10 +123,10 @@ async function ensureEpicLabel({ provider }) {
|
|
|
123
123
|
* @returns {Promise<{ id: number, url?: string }|null>}
|
|
124
124
|
*/
|
|
125
125
|
async function findExistingEpic({ provider, fingerprint }) {
|
|
126
|
-
if (typeof provider?.
|
|
126
|
+
if (typeof provider?.listTicketsByLabel !== 'function') return null;
|
|
127
127
|
try {
|
|
128
128
|
const marker = epicFingerprintMarker(fingerprint);
|
|
129
|
-
const found = await provider.
|
|
129
|
+
const found = await provider.listTicketsByLabel({
|
|
130
130
|
state: 'open',
|
|
131
131
|
labels: TYPE_LABELS.EPIC,
|
|
132
132
|
});
|
|
@@ -134,9 +134,12 @@ async function findExistingEpic({ provider, fingerprint }) {
|
|
|
134
134
|
String(issue?.body ?? '').includes(marker),
|
|
135
135
|
);
|
|
136
136
|
if (!hit) return null;
|
|
137
|
-
|
|
137
|
+
// The declared ticket shape: `id` is the issue number. The `number`-then-
|
|
138
|
+
// `id` fallback this replaced would have adopted the resumed container by
|
|
139
|
+
// database id — a number that exists, resolves to nothing, fails no guard.
|
|
140
|
+
const id = Number(hit.id);
|
|
138
141
|
if (!Number.isInteger(id) || id <= 0) return null;
|
|
139
|
-
return { id, url: hit.
|
|
142
|
+
return { id, url: hit.url ?? undefined };
|
|
140
143
|
} catch (err) {
|
|
141
144
|
Logger.warn(
|
|
142
145
|
`[plan-persist] Epic resume lookup failed (${err.message}); creating a new container.`,
|
|
@@ -145,6 +148,26 @@ async function findExistingEpic({ provider, fingerprint }) {
|
|
|
145
148
|
}
|
|
146
149
|
}
|
|
147
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Index a cohort's already-known database ids by issue number.
|
|
153
|
+
*
|
|
154
|
+
* A resumed run's adopted Stories carry no `internalId` — they were found by
|
|
155
|
+
* listing, not created — so they are simply absent from the map and fall
|
|
156
|
+
* through to the lookup. Absence means "not known here", never "has none".
|
|
157
|
+
*
|
|
158
|
+
* @param {Array<{ id?: number, internalId?: number }>|undefined} created
|
|
159
|
+
* @returns {Map<number, number>}
|
|
160
|
+
*/
|
|
161
|
+
function internalIdsFrom(created) {
|
|
162
|
+
const map = new Map();
|
|
163
|
+
for (const story of Array.isArray(created) ? created : []) {
|
|
164
|
+
if (Number.isInteger(story?.id) && typeof story?.internalId === 'number') {
|
|
165
|
+
map.set(story.id, story.internalId);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return map;
|
|
169
|
+
}
|
|
170
|
+
|
|
148
171
|
/**
|
|
149
172
|
* Link the created Stories under the Epic as native sub-issue edges.
|
|
150
173
|
*
|
|
@@ -156,10 +179,18 @@ async function findExistingEpic({ provider, fingerprint }) {
|
|
|
156
179
|
* children exactly the way creation does — one mirroring rule, not two that
|
|
157
180
|
* drift.
|
|
158
181
|
*
|
|
159
|
-
*
|
|
182
|
+
* `created` is optional and carries the cohort's `createIssue` responses, so
|
|
183
|
+
* the linker can skip the id lookup for every child this run made itself.
|
|
184
|
+
*
|
|
185
|
+
* @param {{ provider: object, epicNumber: number, childIds: number[], created?: Array<{ id: number, internalId?: number }> }} opts
|
|
160
186
|
* @returns {Promise<{ added: number, skipped: number, failed: number }|null>}
|
|
161
187
|
*/
|
|
162
|
-
export async function mirrorSubIssueEdges({
|
|
188
|
+
export async function mirrorSubIssueEdges({
|
|
189
|
+
provider,
|
|
190
|
+
epicNumber,
|
|
191
|
+
childIds,
|
|
192
|
+
created = [],
|
|
193
|
+
}) {
|
|
163
194
|
if (
|
|
164
195
|
typeof provider?.getDependencyWriteContext !== 'function' ||
|
|
165
196
|
typeof provider?.getTicket !== 'function'
|
|
@@ -176,6 +207,7 @@ export async function mirrorSubIssueEdges({ provider, epicNumber, childIds }) {
|
|
|
176
207
|
const summary = await linkStoriesToEpic({
|
|
177
208
|
epicNumber,
|
|
178
209
|
childIssueNumbers: childIds,
|
|
210
|
+
knownInternalIds: internalIdsFrom(created),
|
|
179
211
|
getTicket: (issueNumber) => provider.getTicket(issueNumber),
|
|
180
212
|
owner,
|
|
181
213
|
repo,
|
|
@@ -285,6 +317,7 @@ export async function createContainerEpic({
|
|
|
285
317
|
provider,
|
|
286
318
|
epicNumber: existing.id,
|
|
287
319
|
childIds,
|
|
320
|
+
created,
|
|
288
321
|
});
|
|
289
322
|
return {
|
|
290
323
|
id: existing.id,
|
|
@@ -303,11 +336,14 @@ export async function createContainerEpic({
|
|
|
303
336
|
labels: [TYPE_LABELS.EPIC],
|
|
304
337
|
});
|
|
305
338
|
|
|
306
|
-
|
|
339
|
+
// `createIssue` declares both `id` and `number` and sets them to the same
|
|
340
|
+
// issue number; reading one of them is the whole contract.
|
|
341
|
+
const epicNumber = result.id;
|
|
307
342
|
const edges = await mirrorSubIssueEdges({
|
|
308
343
|
provider,
|
|
309
344
|
epicNumber,
|
|
310
345
|
childIds,
|
|
346
|
+
created,
|
|
311
347
|
});
|
|
312
348
|
|
|
313
349
|
Logger.info(
|