mandrel 2.55.0 → 2.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/docs/agentrc-reference.json +4 -0
- package/.agents/docs/configuration.md +3 -0
- package/.agents/rules/ci-remediation.md +39 -21
- package/.agents/schemas/agentrc.schema.json +19 -0
- package/.agents/scripts/audit-to-stories.js +222 -75
- package/.agents/scripts/file-ci-gap.js +306 -0
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +40 -52
- package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
- package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
- package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +1 -1
- package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
- package/.agents/scripts/lib/config-settings-schema.js +33 -0
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
- package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
- package/.agents/scripts/lib/findings/route-finding.js +38 -0
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/github/framework-repo.js +148 -2
- package/.agents/scripts/lib/label-constants.js +6 -1
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
- package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +15 -2
- package/.agents/scripts/lib/orchestration/run-epilogue.js +4 -4
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
- package/.agents/scripts/pr-watch-with-update.js +3 -2
- package/.agents/workflows/audit-to-stories.md +63 -27
- package/.agents/workflows/helpers/deliver-story-reference.md +19 -4
- package/.agents/workflows/helpers/plan-reference.md +23 -0
- package/.agents/workflows/mandrel-plan.md +6 -6
- package/docs/CHANGELOG.md +10 -0
- package/package.json +1 -1
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ci-gap-intake.js — the mechanism behind `rules/ci-remediation.md` Option 2.
|
|
3
|
+
*
|
|
4
|
+
* ## What was here before: nothing
|
|
5
|
+
*
|
|
6
|
+
* The rule told a delivering agent to "file a `meta::framework-gap` issue
|
|
7
|
+
* carrying the run link and the failure signature". That sentence was the
|
|
8
|
+
* whole implementation. The agent hand-ran `gh issue create` in whatever
|
|
9
|
+
* repository it happened to be standing in, which produced two failures every
|
|
10
|
+
* time:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Unactionable.** A hand-typed issue has no `## Spec`, no
|
|
13
|
+
* `acceptance[]` / `verify[]` and no `agent::*` label, so
|
|
14
|
+
* `/mandrel-deliver` cannot take it. `/mandrel-plan` Phase 0 re-surfaces
|
|
15
|
+
* it as a recurring-defect row forever and nothing ever acts on it.
|
|
16
|
+
* 2. **Mis-routed.** It lands in the consumer's repo no matter who owns the
|
|
17
|
+
* fault — including when the root cause is a shared base config or the
|
|
18
|
+
* runner host, which no amount of work in the consumer repo can fix.
|
|
19
|
+
*
|
|
20
|
+
* ## Two phases, never one
|
|
21
|
+
*
|
|
22
|
+
* Delivery files an **intake issue**; planning graduates it. This module
|
|
23
|
+
* never invokes `/mandrel-plan`, never blocks on it, and never authors a
|
|
24
|
+
* Story body. It runs mid-delivery while the Story is heading to
|
|
25
|
+
* `agent::blocked` with nobody at the keyboard, so a synchronous planning
|
|
26
|
+
* call would couple the CI path to an interactive workflow. Instead the
|
|
27
|
+
* intake issue is written in a shape Phase 0 can recognise (the
|
|
28
|
+
* {@link CI_GAP_INTAKE_MARKER}) and `/mandrel-plan <id>` rewrites it into a
|
|
29
|
+
* Story on the next planning pass.
|
|
30
|
+
*
|
|
31
|
+
* ## Dedup is the point, not a nicety
|
|
32
|
+
*
|
|
33
|
+
* Runner-host contention is a repeat offender: the same signature reds an
|
|
34
|
+
* unrelated PR weekly. The Nth occurrence must land on the existing ticket —
|
|
35
|
+
* an occurrence count is the evidence that turns "a flake" into "a defect
|
|
36
|
+
* worth someone's afternoon". Routing goes through `findings/route-finding.js`,
|
|
37
|
+
* the same fingerprint dedup `audit-to-stories` and `qa-explore` share, so
|
|
38
|
+
* there is one dedup implementation in the repo rather than three.
|
|
39
|
+
*
|
|
40
|
+
* Pure orchestration: **no network I/O lives here.** Every GitHub side effect
|
|
41
|
+
* flows through injected ports (`searchIssues`, `createIssue`, `updateIssue`),
|
|
42
|
+
* so the unit tests run with no network and the CLI wires them to the
|
|
43
|
+
* provider.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import {
|
|
47
|
+
fingerprintFinding,
|
|
48
|
+
fingerprintFooter,
|
|
49
|
+
routeFinding,
|
|
50
|
+
} from '../findings/route-finding.js';
|
|
51
|
+
import { OWNERSHIP_BUCKETS, routeOwnership } from '../github/framework-repo.js';
|
|
52
|
+
import { META_LABELS } from '../label-constants.js';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Body marker identifying a CI-gap intake issue. `/mandrel-plan` Phase 0
|
|
56
|
+
* keys graduation off it, so it is a contract: bump the version suffix only
|
|
57
|
+
* alongside a reader that understands both.
|
|
58
|
+
*/
|
|
59
|
+
export const CI_GAP_INTAKE_MARKER = '<!-- ci-gap-intake: v1 -->';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The verdicts that route to Option 2 in `rules/ci-remediation.md`. Closed by
|
|
63
|
+
* construction: this is the rule's own set, not a second taxonomy invented
|
|
64
|
+
* here. Each becomes a `friction::<verdict>` label, which is what
|
|
65
|
+
* `prior-feedback-fetcher.js` already counts into `recurringDefectClasses[]`.
|
|
66
|
+
*/
|
|
67
|
+
export const INTAKE_VERDICTS = Object.freeze([
|
|
68
|
+
'pre-existing',
|
|
69
|
+
'capacity',
|
|
70
|
+
'unreproducible-tier',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The Option-1 verdict. Named rather than merely absent so refusing it can
|
|
75
|
+
* say *why*: a defect in the diff under review is fixed at source on the
|
|
76
|
+
* branch, and filing an intake issue for it would launder a real defect into
|
|
77
|
+
* someone else's backlog.
|
|
78
|
+
*/
|
|
79
|
+
export const REFUSED_VERDICT = 'defect-in-diff';
|
|
80
|
+
|
|
81
|
+
/** Ownership bucket → the `meta::*` label that marks who owns the work. */
|
|
82
|
+
const BUCKET_META_LABEL = Object.freeze({
|
|
83
|
+
consumer: META_LABELS.CONSUMER_IMPROVEMENT,
|
|
84
|
+
framework: META_LABELS.FRAMEWORK_GAP,
|
|
85
|
+
platform: META_LABELS.PLATFORM_GAP,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Validate a verdict against the rule's Option-2 set.
|
|
90
|
+
*
|
|
91
|
+
* Throws rather than defaulting: a mis-verdicted filing routes real work to
|
|
92
|
+
* the wrong owner, and the caller can always fix the flag.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} verdict
|
|
95
|
+
* @returns {string} the validated verdict
|
|
96
|
+
*/
|
|
97
|
+
function assertIntakeVerdict(verdict) {
|
|
98
|
+
if (verdict === REFUSED_VERDICT) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`verdict "${REFUSED_VERDICT}" routes to Option 1 (fix at source on the branch), not to an intake filing — see .agents/rules/ci-remediation.md`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (!INTAKE_VERDICTS.includes(verdict)) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`unknown verdict "${verdict}" — expected one of ${INTAKE_VERDICTS.join(', ')}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return verdict;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Validate an ownership bucket against the closed set.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} bucket
|
|
115
|
+
* @returns {string} the validated bucket
|
|
116
|
+
*/
|
|
117
|
+
function assertOwnershipBucket(bucket) {
|
|
118
|
+
if (!OWNERSHIP_BUCKETS.includes(bucket)) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`unknown ownership bucket "${bucket}" — expected one of ${OWNERSHIP_BUCKETS.join(', ')}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return bucket;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The failure signature: the first distinctive line of the captured
|
|
128
|
+
* failed-job log. Mirrors `ci-rerun-guard.js`'s reading of the same digest
|
|
129
|
+
* field — this is the line a human recognises a repeat occurrence by.
|
|
130
|
+
*
|
|
131
|
+
* @param {object} digest
|
|
132
|
+
* @returns {string}
|
|
133
|
+
*/
|
|
134
|
+
function failureSignature(digest) {
|
|
135
|
+
const line = String(digest?.logTail ?? '')
|
|
136
|
+
.split('\n')
|
|
137
|
+
.map((l) => l.trim())
|
|
138
|
+
.find((l) => l.length > 0);
|
|
139
|
+
return line ?? '(no failed-log output captured)';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Normalise a signature line into fingerprint-stable text.
|
|
144
|
+
*
|
|
145
|
+
* Run-specific noise — timestamps, ports, run ids, hex object ids, absolute
|
|
146
|
+
* temp paths — makes two occurrences of the SAME defect fingerprint
|
|
147
|
+
* differently, which is precisely the dedup failure this module exists to
|
|
148
|
+
* avoid. Stripping it is what lets the Nth occurrence find the first.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} line
|
|
151
|
+
* @returns {string}
|
|
152
|
+
*/
|
|
153
|
+
function normaliseSignature(line) {
|
|
154
|
+
return String(line ?? '')
|
|
155
|
+
.replace(/\b\d{4}-\d{2}-\d{2}[T ][\d:.]+Z?\b/g, '<ts>')
|
|
156
|
+
.replace(/\b[0-9a-f]{7,40}\b/gi, '<sha>')
|
|
157
|
+
.replace(/:\d{2,5}\b/g, ':<port>')
|
|
158
|
+
.replace(/\b\d+(\.\d+)?(ms|s)\b/g, '<duration>')
|
|
159
|
+
.replace(/\b\d+\b/g, '<n>')
|
|
160
|
+
.replace(/\s+/g, ' ')
|
|
161
|
+
.trim()
|
|
162
|
+
.slice(0, 200);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Build the canonical finding the dedup layer fingerprints.
|
|
167
|
+
*
|
|
168
|
+
* `area` folds in the verdict so the same signature reached under two
|
|
169
|
+
* different verdicts stays two findings — the verdict is who owns the fix,
|
|
170
|
+
* and merging those would merge two different pieces of work.
|
|
171
|
+
*
|
|
172
|
+
* @param {object} opts
|
|
173
|
+
* @param {object} opts.digest — the CI failure digest.
|
|
174
|
+
* @param {string} opts.verdict
|
|
175
|
+
* @param {string} opts.bucket
|
|
176
|
+
* @returns {object} canonical finding for `route-finding.js`
|
|
177
|
+
*/
|
|
178
|
+
function buildIntakeFinding({ digest, verdict, bucket }) {
|
|
179
|
+
return {
|
|
180
|
+
title: normaliseSignature(failureSignature(digest)),
|
|
181
|
+
area: `ci:${verdict}`,
|
|
182
|
+
primaryFile: String(digest?.failingCheck ?? 'unknown-check'),
|
|
183
|
+
severity: 'high',
|
|
184
|
+
labels: [BUCKET_META_LABEL[bucket], `friction::${verdict}`],
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Render one `## Occurrences` row.
|
|
190
|
+
*
|
|
191
|
+
* @param {object} occurrence
|
|
192
|
+
* @returns {string}
|
|
193
|
+
*/
|
|
194
|
+
function renderOccurrenceRow({ at, runUrl, headSha, prNumber }) {
|
|
195
|
+
const cells = [
|
|
196
|
+
at ?? new Date().toISOString(),
|
|
197
|
+
runUrl ? `[run](${runUrl})` : '_(no run link)_',
|
|
198
|
+
headSha ? `\`${String(headSha).slice(0, 12)}\`` : '_(unresolved)_',
|
|
199
|
+
prNumber ? `#${prNumber}` : '_(n/a)_',
|
|
200
|
+
];
|
|
201
|
+
return `| ${cells.join(' | ')} |`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Header of the occurrences table, matched verbatim when appending a row. */
|
|
205
|
+
const OCCURRENCES_HEADER = [
|
|
206
|
+
'## Occurrences',
|
|
207
|
+
'',
|
|
208
|
+
'| When | Run | Head SHA | PR |',
|
|
209
|
+
'| --- | --- | --- | --- |',
|
|
210
|
+
].join('\n');
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Render the `## Routing` block — the half of this module that exists purely
|
|
214
|
+
* so a mis-route can never again be silent. It states the bucket, the
|
|
215
|
+
* destination, and, when there is no destination, the exact config key that
|
|
216
|
+
* would give it one.
|
|
217
|
+
*
|
|
218
|
+
* @param {object} routing
|
|
219
|
+
* @returns {string}
|
|
220
|
+
*/
|
|
221
|
+
function renderRouting(routing) {
|
|
222
|
+
const lines = ['## Routing', '', `- **Owner:** \`${routing.bucket}\``];
|
|
223
|
+
if (!routing.routable) {
|
|
224
|
+
lines.push(
|
|
225
|
+
`- **Status:** \`unroutable\` — \`${routing.missingKey}\` is unset in \`.agentrc.json\`, so this was filed in the repository that surfaced it instead of the repository that owns it.`,
|
|
226
|
+
'- **To fix the routing:** set that key and re-file, or move this issue by hand.',
|
|
227
|
+
);
|
|
228
|
+
} else if (routing.deferredFrom) {
|
|
229
|
+
lines.push(
|
|
230
|
+
`- **Status:** \`deferred to ${routing.deferredFrom}\` — the write to that repository was refused, so this was filed locally.`,
|
|
231
|
+
`- **Refusal:** \`${routing.deferralReason}\``,
|
|
232
|
+
'- **To fix the routing:** grant a token with write scope on the target repository and re-file, or move this issue by hand.',
|
|
233
|
+
);
|
|
234
|
+
} else {
|
|
235
|
+
lines.push(
|
|
236
|
+
`- **Status:** \`routed\` — filed in \`${routing.routedRepo.owner}/${routing.routedRepo.repo}\`.`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return lines.join('\n');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Render the intake issue body.
|
|
244
|
+
*
|
|
245
|
+
* @param {object} opts
|
|
246
|
+
* @returns {string}
|
|
247
|
+
*/
|
|
248
|
+
function renderIntakeBody({
|
|
249
|
+
digest,
|
|
250
|
+
verdict,
|
|
251
|
+
evidence,
|
|
252
|
+
routing,
|
|
253
|
+
fingerprint,
|
|
254
|
+
occurrence,
|
|
255
|
+
}) {
|
|
256
|
+
const runRef =
|
|
257
|
+
digest?.runUrl ??
|
|
258
|
+
(digest?.runId ? `run id ${digest.runId}` : '_(run link unresolved)_');
|
|
259
|
+
return [
|
|
260
|
+
CI_GAP_INTAKE_MARKER,
|
|
261
|
+
'',
|
|
262
|
+
'> Intake issue filed by the CI-remediation path. It is **not** an',
|
|
263
|
+
"> executable Story: run `/mandrel-plan <this issue's number>` to graduate",
|
|
264
|
+
'> it into one.',
|
|
265
|
+
'',
|
|
266
|
+
'## Signature',
|
|
267
|
+
'',
|
|
268
|
+
`- **Failing check:** \`${digest?.failingCheck ?? 'unknown'}\``,
|
|
269
|
+
`- **Run:** ${runRef}`,
|
|
270
|
+
`- **Classification:** ${digest?.classification ?? 'unknown'}`,
|
|
271
|
+
'',
|
|
272
|
+
'```text',
|
|
273
|
+
failureSignature(digest),
|
|
274
|
+
'```',
|
|
275
|
+
'',
|
|
276
|
+
'## Verdict',
|
|
277
|
+
'',
|
|
278
|
+
`- **Verdict:** \`${verdict}\` (see \`.agents/rules/ci-remediation.md\`)`,
|
|
279
|
+
`- **Proof reading:** ${evidence?.trim() ? evidence.trim() : '_(none supplied — this verdict is unproven and should be re-triaged)_'}`,
|
|
280
|
+
'',
|
|
281
|
+
renderRouting(routing),
|
|
282
|
+
'',
|
|
283
|
+
OCCURRENCES_HEADER,
|
|
284
|
+
renderOccurrenceRow(occurrence),
|
|
285
|
+
'',
|
|
286
|
+
fingerprintFooter([fingerprint]),
|
|
287
|
+
].join('\n');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Append one occurrence row to an existing intake body.
|
|
292
|
+
*
|
|
293
|
+
* Appends to the existing table when the body carries one, and otherwise
|
|
294
|
+
* grafts a fresh table onto the end — an intake issue an operator has
|
|
295
|
+
* hand-edited must still accumulate occurrences rather than silently losing
|
|
296
|
+
* them.
|
|
297
|
+
*
|
|
298
|
+
* @param {string} body — the existing issue body.
|
|
299
|
+
* @param {object} occurrence
|
|
300
|
+
* @returns {string}
|
|
301
|
+
*/
|
|
302
|
+
function appendOccurrence(body, occurrence) {
|
|
303
|
+
const row = renderOccurrenceRow(occurrence);
|
|
304
|
+
const existing = String(body ?? '');
|
|
305
|
+
if (!existing.includes('## Occurrences')) {
|
|
306
|
+
return [existing.trimEnd(), '', OCCURRENCES_HEADER, row].join('\n');
|
|
307
|
+
}
|
|
308
|
+
const lines = existing.split('\n');
|
|
309
|
+
// The table runs to the first blank line after the header; inserting there
|
|
310
|
+
// (rather than appending to the body) keeps any footer below it intact.
|
|
311
|
+
const headerIdx = lines.findIndex((l) => l.trim() === '## Occurrences');
|
|
312
|
+
let insertAt = lines.length;
|
|
313
|
+
for (let i = headerIdx + 1; i < lines.length; i += 1) {
|
|
314
|
+
const isRow = lines[i].trimStart().startsWith('|');
|
|
315
|
+
if (isRow) insertAt = i + 1;
|
|
316
|
+
else if (insertAt !== lines.length) break;
|
|
317
|
+
}
|
|
318
|
+
lines.splice(insertAt, 0, row);
|
|
319
|
+
return lines.join('\n');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Compose the intake issue title. Verdict-prefixed so a tracker list reads as
|
|
324
|
+
* a triage queue rather than a wall of stack traces.
|
|
325
|
+
*
|
|
326
|
+
* @param {object} opts
|
|
327
|
+
* @returns {string}
|
|
328
|
+
*/
|
|
329
|
+
function buildIntakeTitle({ digest, verdict }) {
|
|
330
|
+
const signature = failureSignature(digest).slice(0, 110);
|
|
331
|
+
return `CI gap (${verdict}): \`${digest?.failingCheck ?? 'unknown check'}\` — ${signature}`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Append this occurrence to the intake issue already tracking the signature.
|
|
336
|
+
*
|
|
337
|
+
* The Nth occurrence is the evidence that turns "a flake" into a defect worth
|
|
338
|
+
* someone's afternoon, so it lands on the existing ticket rather than minting
|
|
339
|
+
* a second one.
|
|
340
|
+
*
|
|
341
|
+
* @param {object} opts
|
|
342
|
+
* @returns {Promise<object>} the intake result envelope.
|
|
343
|
+
*/
|
|
344
|
+
async function recordRecurrence({
|
|
345
|
+
matchedIssue,
|
|
346
|
+
occurrence,
|
|
347
|
+
routing,
|
|
348
|
+
labels,
|
|
349
|
+
title,
|
|
350
|
+
fingerprint,
|
|
351
|
+
ports,
|
|
352
|
+
errors,
|
|
353
|
+
}) {
|
|
354
|
+
const updatedBody = appendOccurrence(matchedIssue.body, occurrence);
|
|
355
|
+
const res = await ports.updateIssue({
|
|
356
|
+
owner: routing.routedRepo.owner,
|
|
357
|
+
repo: routing.routedRepo.repo,
|
|
358
|
+
number: matchedIssue.number,
|
|
359
|
+
body: updatedBody,
|
|
360
|
+
});
|
|
361
|
+
if (res?.error) errors.push(`issue update failed: ${res.error}`);
|
|
362
|
+
return {
|
|
363
|
+
decision: 'update-existing',
|
|
364
|
+
issue: {
|
|
365
|
+
number: matchedIssue.number,
|
|
366
|
+
url: res?.url ?? matchedIssue.url ?? null,
|
|
367
|
+
},
|
|
368
|
+
routing,
|
|
369
|
+
labels,
|
|
370
|
+
title,
|
|
371
|
+
body: updatedBody,
|
|
372
|
+
fingerprint,
|
|
373
|
+
dryRun: false,
|
|
374
|
+
errors,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Create the intake issue in the routed repository, degrading to a local
|
|
380
|
+
* filing when that write is refused.
|
|
381
|
+
*
|
|
382
|
+
* A cross-repo write needs a token scoped to the target repo, which an
|
|
383
|
+
* operator may simply not have granted. The refusal becomes a local filing
|
|
384
|
+
* that names the repo the work belongs in — never a silent drop, and never a
|
|
385
|
+
* pretend-success. `routing` is mutated so the caller's envelope and the
|
|
386
|
+
* re-rendered body agree on where this landed.
|
|
387
|
+
*
|
|
388
|
+
* @param {object} opts
|
|
389
|
+
* @param {Function} opts.render — re-render the body for a mutated routing.
|
|
390
|
+
* @returns {Promise<{ created: object, body: string }>}
|
|
391
|
+
*/
|
|
392
|
+
async function createWithDegrade({
|
|
393
|
+
routing,
|
|
394
|
+
currentRepo,
|
|
395
|
+
title,
|
|
396
|
+
body,
|
|
397
|
+
labels,
|
|
398
|
+
ports,
|
|
399
|
+
logger,
|
|
400
|
+
render,
|
|
401
|
+
}) {
|
|
402
|
+
const created = await ports.createIssue({
|
|
403
|
+
owner: routing.routedRepo.owner,
|
|
404
|
+
repo: routing.routedRepo.repo,
|
|
405
|
+
title,
|
|
406
|
+
body,
|
|
407
|
+
labels,
|
|
408
|
+
});
|
|
409
|
+
if (!created?.error || !routing.crossRepo) return { created, body };
|
|
410
|
+
|
|
411
|
+
const target = `${routing.routedRepo.owner}/${routing.routedRepo.repo}`;
|
|
412
|
+
logger?.warn?.(
|
|
413
|
+
`[ci-gap-intake] cross-repo write to ${target} refused (${created.error}) — filing in ${currentRepo.owner}/${currentRepo.repo} and recording the deferral.`,
|
|
414
|
+
);
|
|
415
|
+
routing.deferredFrom = target;
|
|
416
|
+
routing.deferralReason = created.error;
|
|
417
|
+
routing.routedRepo = currentRepo;
|
|
418
|
+
routing.crossRepo = false;
|
|
419
|
+
const localBody = render(routing);
|
|
420
|
+
const retried = await ports.createIssue({
|
|
421
|
+
owner: currentRepo.owner,
|
|
422
|
+
repo: currentRepo.repo,
|
|
423
|
+
title,
|
|
424
|
+
body: localBody,
|
|
425
|
+
labels,
|
|
426
|
+
});
|
|
427
|
+
return { created: retried, body: localBody };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* File (or update) the intake issue for one CI-gap verdict.
|
|
432
|
+
*
|
|
433
|
+
* Never throws for an I/O reason — every port failure lands in `errors[]` so
|
|
434
|
+
* a filing fault cannot itself fail the delivery that is already blocking.
|
|
435
|
+
* An invalid verdict or bucket DOES throw: that is operator input, caught
|
|
436
|
+
* before any side effect, and silently proceeding would file the wrong thing.
|
|
437
|
+
*
|
|
438
|
+
* @param {object} opts
|
|
439
|
+
* @param {object} opts.digest — CI failure digest (from `ci-rerun-guard.js`).
|
|
440
|
+
* @param {string} opts.verdict — one of {@link INTAKE_VERDICTS}.
|
|
441
|
+
* @param {string} opts.bucket — ownership bucket (`consumer|framework|platform`).
|
|
442
|
+
* @param {string} [opts.evidence] — the verdict's proof reading.
|
|
443
|
+
* @param {object} opts.repos — resolved ownership buckets.
|
|
444
|
+
* @param {{owner: string, repo: string}} opts.currentRepo
|
|
445
|
+
* @param {number|null} [opts.prNumber]
|
|
446
|
+
* @param {boolean} [opts.dryRun] — compose everything, write nothing.
|
|
447
|
+
* @param {object} opts.ports — `{ searchIssues, createIssue, updateIssue }`.
|
|
448
|
+
* @param {object} [opts.logger]
|
|
449
|
+
* @param {string} [opts.now] — ISO timestamp seam for deterministic tests.
|
|
450
|
+
* @returns {Promise<object>} the intake result envelope.
|
|
451
|
+
*/
|
|
452
|
+
export async function fileCiGapIntake({
|
|
453
|
+
digest,
|
|
454
|
+
verdict,
|
|
455
|
+
bucket,
|
|
456
|
+
evidence = '',
|
|
457
|
+
repos,
|
|
458
|
+
currentRepo,
|
|
459
|
+
prNumber = null,
|
|
460
|
+
dryRun = false,
|
|
461
|
+
ports = {},
|
|
462
|
+
logger,
|
|
463
|
+
now,
|
|
464
|
+
}) {
|
|
465
|
+
assertIntakeVerdict(verdict);
|
|
466
|
+
assertOwnershipBucket(bucket);
|
|
467
|
+
|
|
468
|
+
const errors = [];
|
|
469
|
+
const occurrence = {
|
|
470
|
+
at: now ?? new Date().toISOString(),
|
|
471
|
+
runUrl: digest?.runUrl ?? null,
|
|
472
|
+
headSha: digest?.headSha ?? null,
|
|
473
|
+
prNumber,
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const routed = routeOwnership({ bucket, repos, currentRepo });
|
|
477
|
+
// An unroutable bucket files where the run is standing and says so in the
|
|
478
|
+
// body — the one thing it must never do is look routed.
|
|
479
|
+
const routing = {
|
|
480
|
+
bucket,
|
|
481
|
+
routable: routed.routable,
|
|
482
|
+
missingKey: routed.missingKey,
|
|
483
|
+
crossRepo: routed.crossRepo,
|
|
484
|
+
routedRepo: routed.routable ? routed.routedRepo : currentRepo,
|
|
485
|
+
deferredFrom: null,
|
|
486
|
+
deferralReason: null,
|
|
487
|
+
};
|
|
488
|
+
if (!routed.routable) {
|
|
489
|
+
logger?.warn?.(
|
|
490
|
+
`[ci-gap-intake] ${bucket} bucket is unroutable: ${routed.missingKey} is unset — filing in ${currentRepo.owner}/${currentRepo.repo} and saying so in the body.`,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const finding = buildIntakeFinding({ digest, verdict, bucket });
|
|
495
|
+
const labels = finding.labels;
|
|
496
|
+
const title = buildIntakeTitle({ digest, verdict });
|
|
497
|
+
|
|
498
|
+
let decision = 'new';
|
|
499
|
+
let matchedIssue = null;
|
|
500
|
+
let fingerprint = fingerprintFinding(finding).full;
|
|
501
|
+
try {
|
|
502
|
+
const routedDecision = await routeFinding(finding, {
|
|
503
|
+
searchIssues: ports.searchIssues,
|
|
504
|
+
});
|
|
505
|
+
decision = routedDecision.decision;
|
|
506
|
+
matchedIssue = routedDecision.matchedIssue;
|
|
507
|
+
fingerprint = routedDecision.fingerprint;
|
|
508
|
+
} catch (err) {
|
|
509
|
+
// A degraded lookup must not mint a duplicate on a repeat offender, so
|
|
510
|
+
// the filing stops here rather than guessing `new`.
|
|
511
|
+
errors.push(`dedup lookup failed: ${err?.message ?? err}`);
|
|
512
|
+
return {
|
|
513
|
+
decision: 'lookup-failed',
|
|
514
|
+
issue: null,
|
|
515
|
+
routing,
|
|
516
|
+
labels,
|
|
517
|
+
title,
|
|
518
|
+
body: null,
|
|
519
|
+
fingerprint,
|
|
520
|
+
dryRun,
|
|
521
|
+
errors,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const body = renderIntakeBody({
|
|
526
|
+
digest,
|
|
527
|
+
verdict,
|
|
528
|
+
evidence,
|
|
529
|
+
routing,
|
|
530
|
+
fingerprint,
|
|
531
|
+
occurrence,
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// An open match is an occurrence of a defect already tracked; only a `new`
|
|
535
|
+
// or a regression of something closed long ago earns a fresh ticket.
|
|
536
|
+
const isRecurrence =
|
|
537
|
+
(decision === 'update-existing' || decision === 'duplicate') &&
|
|
538
|
+
matchedIssue?.number;
|
|
539
|
+
|
|
540
|
+
if (dryRun) {
|
|
541
|
+
return {
|
|
542
|
+
decision,
|
|
543
|
+
issue: matchedIssue ? { number: matchedIssue.number } : null,
|
|
544
|
+
routing,
|
|
545
|
+
labels,
|
|
546
|
+
title,
|
|
547
|
+
body: isRecurrence
|
|
548
|
+
? appendOccurrence(matchedIssue.body, occurrence)
|
|
549
|
+
: body,
|
|
550
|
+
fingerprint,
|
|
551
|
+
dryRun: true,
|
|
552
|
+
errors,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (isRecurrence) {
|
|
557
|
+
return recordRecurrence({
|
|
558
|
+
matchedIssue,
|
|
559
|
+
occurrence,
|
|
560
|
+
routing,
|
|
561
|
+
labels,
|
|
562
|
+
title,
|
|
563
|
+
fingerprint,
|
|
564
|
+
ports,
|
|
565
|
+
errors,
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const { created, body: finalBody } = await createWithDegrade({
|
|
570
|
+
routing,
|
|
571
|
+
currentRepo,
|
|
572
|
+
title,
|
|
573
|
+
body,
|
|
574
|
+
labels,
|
|
575
|
+
ports,
|
|
576
|
+
logger,
|
|
577
|
+
render: (r) =>
|
|
578
|
+
renderIntakeBody({
|
|
579
|
+
digest,
|
|
580
|
+
verdict,
|
|
581
|
+
evidence,
|
|
582
|
+
routing: r,
|
|
583
|
+
fingerprint,
|
|
584
|
+
occurrence,
|
|
585
|
+
}),
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
if (created?.error) {
|
|
589
|
+
errors.push(`issue create failed: ${created.error}`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return {
|
|
593
|
+
decision: created?.error ? 'create-failed' : 'new',
|
|
594
|
+
issue: created?.error
|
|
595
|
+
? null
|
|
596
|
+
: { number: created?.number ?? null, url: created?.url ?? null },
|
|
597
|
+
routing,
|
|
598
|
+
labels,
|
|
599
|
+
title,
|
|
600
|
+
body: finalBody,
|
|
601
|
+
fingerprint,
|
|
602
|
+
dryRun: false,
|
|
603
|
+
errors,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
* **Head SHA is the discriminator.** A green on a *different* head SHA is a
|
|
21
21
|
* fix at source — legal, and it retires the digest. A green on the *same*
|
|
22
22
|
* head SHA is a re-run of a failed job, which the rule forbids: the
|
|
23
|
-
* delivery hard-stops at `agent::blocked` and a
|
|
24
|
-
* (carrying the run link and failure signature the digest
|
|
25
|
-
* required before it can proceed.
|
|
23
|
+
* delivery hard-stops at `agent::blocked` and a CI-gap intake filing
|
|
24
|
+
* (`file-ci-gap.js`, carrying the run link and failure signature the digest
|
|
25
|
+
* already holds) is required before it can proceed.
|
|
26
26
|
*
|
|
27
27
|
* **Fail closed on an unverifiable green.** A digest whose head SHA is
|
|
28
28
|
* missing, or a current head SHA that `gh` could not resolve, leaves no
|
|
@@ -136,7 +136,7 @@ function ghRunLogTail({ runId, cwd, spawnFn, maxLines = 40 }) {
|
|
|
136
136
|
* Resolve the failing check's run identity — the GitHub Actions run id AND
|
|
137
137
|
* the run URL. Best-effort via `gh pr checks --json name,link`: the `link`
|
|
138
138
|
* field carries the run URL whose trailing path segment is the run id. The
|
|
139
|
-
* URL matters as much as the id, because the
|
|
139
|
+
* URL matters as much as the id, because the intake issue a
|
|
140
140
|
* rerun violation demands must carry a run **link**.
|
|
141
141
|
*
|
|
142
142
|
* @returns {{ runId: string|null, url: string|null }}
|
|
@@ -441,7 +441,8 @@ export function classifyGreenVerdict({ digest, headSha }) {
|
|
|
441
441
|
}
|
|
442
442
|
|
|
443
443
|
/**
|
|
444
|
-
* The failure signature
|
|
444
|
+
* The failure signature an intake filing must carry (see `file-ci-gap.js`):
|
|
445
|
+
* the first
|
|
445
446
|
* distinctive line of the captured failed-job log.
|
|
446
447
|
*/
|
|
447
448
|
function failureSignature(digest) {
|
|
@@ -480,9 +481,13 @@ export function formatRerunViolation({ digest, headSha, prNumber, reason }) {
|
|
|
480
481
|
'',
|
|
481
482
|
'1. Fix the root cause on `story-<id>` and push a new commit — the head SHA',
|
|
482
483
|
' moving is what clears the block.',
|
|
483
|
-
'2.
|
|
484
|
-
'
|
|
485
|
-
'
|
|
484
|
+
'2. When the root cause is outside this delivery, file the routed intake',
|
|
485
|
+
' issue — `node .agents/scripts/file-ci-gap.js --story <id> --verdict',
|
|
486
|
+
' <pre-existing|capacity|unreproducible-tier> --owner',
|
|
487
|
+
' <consumer|framework|platform> --evidence "<proof reading>"` — then',
|
|
488
|
+
' resume. It carries the run link and signature above, routes the filing',
|
|
489
|
+
' to whoever owns the fault, and updates the existing ticket when this',
|
|
490
|
+
' signature has been seen before.',
|
|
486
491
|
].join('\n');
|
|
487
492
|
}
|
|
488
493
|
|