mandrel 2.42.0 → 2.44.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 +4 -1
- package/.agents/docs/workflows.md +1 -1
- package/.agents/schemas/agentrc.schema.json +20 -1
- package/.agents/scripts/lib/ITicketingProvider.js +27 -0
- package/.agents/scripts/lib/config-settings-schema.js +29 -1
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches-detect.js +187 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +37 -90
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +115 -19
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +10 -2
- package/.agents/scripts/lib/orchestration/plan-context.js +4 -0
- package/.agents/scripts/lib/orchestration/plan-run-labels/reap.js +327 -0
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +9 -1
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +159 -55
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +117 -36
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +81 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +12 -7
- package/.agents/scripts/providers/github/labels.js +88 -0
- package/.agents/scripts/providers/github.js +2 -0
- package/.agents/scripts/prune-plan-run-labels.js +218 -0
- package/.agents/workflows/memory-consolidate.md +18 -6
- package/docs/CHANGELOG.md +20 -0
- package/package.json +1 -1
|
@@ -45,6 +45,28 @@
|
|
|
45
45
|
* and that intent stands). It additionally reports a `degradations[]` array
|
|
46
46
|
* naming **which** surface could not run and **why**, so the review outcome can
|
|
47
47
|
* say "this gate did not run" instead of silently reading clean.
|
|
48
|
+
*
|
|
49
|
+
* ## The code surface never got the same treatment (Story #5193)
|
|
50
|
+
*
|
|
51
|
+
* Story #4839 gave the *markdown* surface a disk probe and left the code
|
|
52
|
+
* surface spawning `npx --no biome` and classifying the exit code. On npm 11.x
|
|
53
|
+
* that spawn exits **0 with empty output** when biome is absent, so
|
|
54
|
+
* `parseLintOutput` saw no failure, produced no degradation, and the surface
|
|
55
|
+
* contributed `errors: 0, warnings: 0` — a *silent false clean*, which is
|
|
56
|
+
* strictly worse than a degradation: a degraded surface announces itself, a
|
|
57
|
+
* falsely-clean one is trusted. Since nothing in the framework requires either
|
|
58
|
+
* runner, "absent" is the default state of a consumer checkout.
|
|
59
|
+
*
|
|
60
|
+
* Fix: runner resolution is a **precondition** for every surface, not an
|
|
61
|
+
* outcome inferred from an exit code. Both surfaces now resolve through
|
|
62
|
+
* {@link resolveRunner}, and an unresolved runner is never spawned.
|
|
63
|
+
*
|
|
64
|
+
* The same measurement invalidated the `NPX_UNRESOLVABLE` sentinel: current npm
|
|
65
|
+
* answers an unresolvable bin with `npm error code E404`, not `could not
|
|
66
|
+
* determine executable to run`, so every genuinely-unresolvable runner was
|
|
67
|
+
* being labelled `unparseable-output`. The sentinel now recognises both shapes.
|
|
68
|
+
* It still earns its keep after the disk probe: the probe only sees
|
|
69
|
+
* `node_modules/.bin`, so a runner resolvable some other way can still fail.
|
|
48
70
|
*/
|
|
49
71
|
|
|
50
72
|
import { spawnSync } from 'node:child_process';
|
|
@@ -54,8 +76,14 @@ import path from 'node:path';
|
|
|
54
76
|
/** Paths these extensions land on the biome (code) runner. */
|
|
55
77
|
const CODE_EXTENSIONS = /\.(js|mjs|cjs|jsx|ts|tsx|json|jsonc)$/i;
|
|
56
78
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
79
|
+
/**
|
|
80
|
+
* npx's output when the requested bin cannot be resolved. Two shapes: the
|
|
81
|
+
* legacy message, and the `E404` current npm answers with instead (measured
|
|
82
|
+
* 2026-09-07 on npm 11.13.0). Matching the E404 *code* rather than any
|
|
83
|
+
* `npm error` line keeps a genuine runner error out of this classification.
|
|
84
|
+
*/
|
|
85
|
+
const NPX_UNRESOLVABLE =
|
|
86
|
+
/could not determine executable to run|npm (?:error|ERR!)\s+code\s+E404/i;
|
|
59
87
|
|
|
60
88
|
/** Biome's exit-1 message when every supplied path is config-excluded. */
|
|
61
89
|
const BIOME_EMPTY_SCOPE = /No files were processed in the specified paths/i;
|
|
@@ -74,6 +102,15 @@ const MARKDOWN_RUNNERS = Object.freeze([
|
|
|
74
102
|
}),
|
|
75
103
|
]);
|
|
76
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Code runners in preference order. `@biomejs/biome` installs a bare `biome`
|
|
107
|
+
* bin, which is also the canonical name this surface reports itself under when
|
|
108
|
+
* nothing resolves.
|
|
109
|
+
*/
|
|
110
|
+
const CODE_RUNNERS = Object.freeze([
|
|
111
|
+
Object.freeze({ bin: 'biome', extraArgs: Object.freeze([]) }),
|
|
112
|
+
]);
|
|
113
|
+
|
|
77
114
|
/** Reason codes carried on a degradation record. */
|
|
78
115
|
const DEGRADATION_REASONS = Object.freeze({
|
|
79
116
|
RUNNER_NOT_INSTALLED: 'runner-not-installed',
|
|
@@ -103,24 +140,30 @@ function spawnLintRunner(bin, args, cwd) {
|
|
|
103
140
|
}
|
|
104
141
|
|
|
105
142
|
/**
|
|
106
|
-
* Pure-ish: pick the first
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
143
|
+
* Pure-ish: pick the first candidate whose bin is actually installed under
|
|
144
|
+
* `<cwd>/node_modules/.bin`. Returns `null` when none is — an honest "this
|
|
145
|
+
* surface has no runner" that the caller reports rather than silently folding
|
|
146
|
+
* into a generic parse failure.
|
|
110
147
|
*
|
|
111
148
|
* The disk probe (rather than "spawn and see") is what makes the failure
|
|
112
|
-
* *nameable
|
|
113
|
-
*
|
|
149
|
+
* *nameable*, and — since Story #5193 — what makes it *visible at all* on the
|
|
150
|
+
* code surface: `npx --no <missing-bin>` answers with a generic npm error at
|
|
151
|
+
* best and an empty exit 0 at worst, which is precisely how both defects hid.
|
|
152
|
+
*
|
|
153
|
+
* Probing `node_modules/.bin` only is a deliberate bound: a globally-installed
|
|
154
|
+
* runner reads as absent here, which degrades the gate honestly rather than
|
|
155
|
+
* trusting a spawn nobody resolved.
|
|
114
156
|
*
|
|
115
157
|
* Not exported: it is reachable — and asserted — through {@link runScopedLint},
|
|
116
158
|
* whose `existsFn` seam drives every resolution branch.
|
|
117
159
|
*
|
|
160
|
+
* @param {ReadonlyArray<{ bin: string, extraArgs: ReadonlyArray<string> }>} candidates
|
|
118
161
|
* @param {string} cwd
|
|
119
|
-
* @param {(p: string) => boolean}
|
|
162
|
+
* @param {(p: string) => boolean} existsFn Injected for testing.
|
|
120
163
|
* @returns {{ bin: string, extraArgs: ReadonlyArray<string> }|null}
|
|
121
164
|
*/
|
|
122
|
-
function
|
|
123
|
-
for (const candidate of
|
|
165
|
+
function resolveRunner(candidates, cwd, existsFn) {
|
|
166
|
+
for (const candidate of candidates) {
|
|
124
167
|
const base = path.join(cwd, 'node_modules', '.bin', candidate.bin);
|
|
125
168
|
if (existsFn(base)) return candidate;
|
|
126
169
|
if (
|
|
@@ -133,6 +176,49 @@ function resolveMarkdownRunner(cwd, existsFn = existsSync) {
|
|
|
133
176
|
return null;
|
|
134
177
|
}
|
|
135
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Pure: the summary a surface reports when it has no runner to spawn. Counts
|
|
181
|
+
* are zero *and* `executionFailed` is true, so the row can never be read as a
|
|
182
|
+
* clean result — the invariant Story #5193 restored.
|
|
183
|
+
*
|
|
184
|
+
* @returns {ReturnType<typeof parseLintOutput>}
|
|
185
|
+
*/
|
|
186
|
+
function unresolvedRunnerSummary() {
|
|
187
|
+
return {
|
|
188
|
+
errors: 0,
|
|
189
|
+
warnings: 0,
|
|
190
|
+
parsed: false,
|
|
191
|
+
executionFailed: true,
|
|
192
|
+
emptyScope: false,
|
|
193
|
+
reason: DEGRADATION_REASONS.RUNNER_NOT_INSTALLED,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Resolve one surface's runner and, only if it resolved, spawn and classify it.
|
|
199
|
+
* Shared by both surfaces so neither can drift back into spawn-and-see.
|
|
200
|
+
*
|
|
201
|
+
* @param {{
|
|
202
|
+
* label: string,
|
|
203
|
+
* candidates: ReadonlyArray<{ bin: string, extraArgs: ReadonlyArray<string> }>,
|
|
204
|
+
* buildArgs: (runner: { bin: string, extraArgs: ReadonlyArray<string> }) => string[],
|
|
205
|
+
* cwd: string,
|
|
206
|
+
* runnerFn: typeof spawnLintRunner,
|
|
207
|
+
* existsFn: (p: string) => boolean,
|
|
208
|
+
* }} args
|
|
209
|
+
* @returns {{ surface: string, summary: ReturnType<typeof parseLintOutput> }}
|
|
210
|
+
*/
|
|
211
|
+
function runSurface({ label, candidates, buildArgs, cwd, runnerFn, existsFn }) {
|
|
212
|
+
const runner = resolveRunner(candidates, cwd, existsFn);
|
|
213
|
+
if (runner === null) {
|
|
214
|
+
return { surface: label, summary: unresolvedRunnerSummary() };
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
surface: runner.bin,
|
|
218
|
+
summary: parseLintOutput(runnerFn(runner.bin, buildArgs(runner), cwd)),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
136
222
|
/**
|
|
137
223
|
* Pure: split changed paths into the file lists each lint runner consumes.
|
|
138
224
|
*
|
|
@@ -267,33 +353,28 @@ export function runScopedLint(
|
|
|
267
353
|
|
|
268
354
|
const surfaces = [];
|
|
269
355
|
if (code.length > 0) {
|
|
270
|
-
surfaces.push(
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
356
|
+
surfaces.push(
|
|
357
|
+
runSurface({
|
|
358
|
+
label: 'biome',
|
|
359
|
+
candidates: CODE_RUNNERS,
|
|
360
|
+
buildArgs: (runner) => ['lint', ...code, ...runner.extraArgs],
|
|
361
|
+
cwd,
|
|
362
|
+
runnerFn,
|
|
363
|
+
existsFn,
|
|
364
|
+
}),
|
|
365
|
+
);
|
|
274
366
|
}
|
|
275
367
|
if (md.length > 0) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
reason: DEGRADATION_REASONS.RUNNER_NOT_INSTALLED,
|
|
287
|
-
},
|
|
288
|
-
});
|
|
289
|
-
} else {
|
|
290
|
-
surfaces.push({
|
|
291
|
-
surface: runner.bin,
|
|
292
|
-
summary: parseLintOutput(
|
|
293
|
-
runnerFn(runner.bin, [...md, ...runner.extraArgs], cwd),
|
|
294
|
-
),
|
|
295
|
-
});
|
|
296
|
-
}
|
|
368
|
+
surfaces.push(
|
|
369
|
+
runSurface({
|
|
370
|
+
label: 'markdownlint',
|
|
371
|
+
candidates: MARKDOWN_RUNNERS,
|
|
372
|
+
buildArgs: (runner) => [...md, ...runner.extraArgs],
|
|
373
|
+
cwd,
|
|
374
|
+
runnerFn,
|
|
375
|
+
existsFn,
|
|
376
|
+
}),
|
|
377
|
+
);
|
|
297
378
|
}
|
|
298
379
|
|
|
299
380
|
return mergeSurfaceSummaries(surfaces);
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
executeFastForward as defaultExecuteFastForward,
|
|
45
45
|
planFastForward as defaultPlanFastForward,
|
|
46
46
|
} from '../../git-cleanup/phases/fast-forward.js';
|
|
47
|
+
import { reapPlanRunLabelsForStory as defaultReapPlanRunLabelsForStory } from '../../plan-run-labels/reap.js';
|
|
47
48
|
import { reassertStatusColumn as defaultReassertStatusColumn } from '../../reassert-status-column.js';
|
|
48
49
|
import { releaseStoryLease as defaultReleaseStoryLease } from '../../single-story-lease-guard.js';
|
|
49
50
|
import { captureStoryFollowUps as defaultCaptureStoryFollowUps } from '../../story-follow-ups.js';
|
|
@@ -290,6 +291,70 @@ async function stepLeaseRelease({
|
|
|
290
291
|
};
|
|
291
292
|
}
|
|
292
293
|
|
|
294
|
+
/**
|
|
295
|
+
* The whole-repository sweep an operator runs when the automatic reap could
|
|
296
|
+
* not finish its job. Named in the warning itself so the next step is in the
|
|
297
|
+
* message rather than in a runbook nobody opens mid-incident.
|
|
298
|
+
*/
|
|
299
|
+
const REAP_SWEEP_REMEDY = 'node .agents/scripts/prune-plan-run-labels.js';
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Reap the cohort labels the closing Story carried (Story #5189).
|
|
303
|
+
*
|
|
304
|
+
* This seam is chosen deliberately. It is the only one that fires for both
|
|
305
|
+
* single- and multi-Story runs: the multi-Story run epilogue is keyed on a
|
|
306
|
+
* synthesized ad-hoc id, never sees the cohort label, and reports
|
|
307
|
+
* `applicable: false` at N=1 — which is the planning default, so wiring the
|
|
308
|
+
* reap there would leave the common case unreaped forever.
|
|
309
|
+
*
|
|
310
|
+
* Best-effort in the strongest sense the tail offers: the outcome is
|
|
311
|
+
* deliberately NOT reported in the returned `tail` envelope. A per-step
|
|
312
|
+
* boolean is the right shape for a step whose failure degrades the *report of
|
|
313
|
+
* the land* — a missed follow-up, an unresynced status column. Label
|
|
314
|
+
* vocabulary hygiene is not that: nothing downstream reads a cohort label as
|
|
315
|
+
* an input, so a failed reap costs one stale label. Surfacing it in the
|
|
316
|
+
* envelope would make a close whose label read flaked terminate differently
|
|
317
|
+
* from one where no label was reapable, for no difference an operator can act
|
|
318
|
+
* on. The failure is named in a warning instead, and the whole-repository
|
|
319
|
+
* sweep (`node .agents/scripts/prune-plan-run-labels.js`) collects whatever
|
|
320
|
+
* the automatic path misses.
|
|
321
|
+
*
|
|
322
|
+
* Never reaps a zero-issue label: that shape is indistinguishable from a label
|
|
323
|
+
* an in-flight persist has just minted, so the opt-in stays off here.
|
|
324
|
+
*/
|
|
325
|
+
async function stepPlanRunLabelReap({
|
|
326
|
+
storyId,
|
|
327
|
+
provider,
|
|
328
|
+
progress,
|
|
329
|
+
reapPlanRunLabelsForStoryFn,
|
|
330
|
+
}) {
|
|
331
|
+
const warn = (message) =>
|
|
332
|
+
progress?.(
|
|
333
|
+
'POST-LAND',
|
|
334
|
+
`⚠️ plan-run label reap: ${message} — sweep the pile with ` +
|
|
335
|
+
`"${REAP_SWEEP_REMEDY}".`,
|
|
336
|
+
);
|
|
337
|
+
const outcome = await reapPlanRunLabelsForStoryFn({
|
|
338
|
+
storyId,
|
|
339
|
+
provider,
|
|
340
|
+
onWarn: warn,
|
|
341
|
+
});
|
|
342
|
+
const reaped = outcome?.deleted?.length ?? 0;
|
|
343
|
+
if (reaped > 0) {
|
|
344
|
+
progress?.(
|
|
345
|
+
'POST-LAND',
|
|
346
|
+
`🏷️ Reaped ${reaped} spent plan-run label(s): ` +
|
|
347
|
+
`${outcome.deleted.map((d) => d.label).join(', ')}.`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
ok: (outcome?.failed?.length ?? 0) === 0,
|
|
352
|
+
detail: outcome?.failed?.length
|
|
353
|
+
? outcome.failed.map((f) => f.label).join(', ')
|
|
354
|
+
: null,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
293
358
|
/**
|
|
294
359
|
* Run the whole post-land tail. Never throws.
|
|
295
360
|
*
|
|
@@ -330,6 +395,7 @@ async function stepLeaseRelease({
|
|
|
330
395
|
* @param {Function} [args.acquireLockWithWaitFn] Test seam.
|
|
331
396
|
* @param {Function} [args.purgeStoryTempArtifactsFn] Test seam.
|
|
332
397
|
* @param {Function} [args.releaseStoryLeaseFn] Test seam.
|
|
398
|
+
* @param {Function} [args.reapPlanRunLabelsForStoryFn] Test seam.
|
|
333
399
|
* @returns {Promise<{ followUps: boolean, statusResync: boolean, refCleanup: boolean, baseFastForward: boolean, tempPurge: boolean, leaseRelease: boolean, details: Record<string, string|null> }>}
|
|
334
400
|
*/
|
|
335
401
|
export async function runPostLandTail({
|
|
@@ -350,6 +416,7 @@ export async function runPostLandTail({
|
|
|
350
416
|
acquireLockWithWaitFn = defaultAcquireLockWithWait,
|
|
351
417
|
purgeStoryTempArtifactsFn = defaultPurgeStoryTempArtifacts,
|
|
352
418
|
releaseStoryLeaseFn = defaultReleaseStoryLease,
|
|
419
|
+
reapPlanRunLabelsForStoryFn = defaultReapPlanRunLabelsForStory,
|
|
353
420
|
}) {
|
|
354
421
|
progress?.('POST-LAND', `🧾 Running land tail for Story #${storyId}...`);
|
|
355
422
|
|
|
@@ -402,6 +469,20 @@ export async function runPostLandTail({
|
|
|
402
469
|
}),
|
|
403
470
|
{ name: 'status-column resync', progress },
|
|
404
471
|
);
|
|
472
|
+
// Story #5189 — the cohort label's end of life. Runs with the other
|
|
473
|
+
// GitHub-touching steps (outside the checkout lock) and contributes nothing
|
|
474
|
+
// to `tail`; see `stepPlanRunLabelReap` for why that omission is the point.
|
|
475
|
+
await step(
|
|
476
|
+
() =>
|
|
477
|
+
stepPlanRunLabelReap({
|
|
478
|
+
storyId,
|
|
479
|
+
provider,
|
|
480
|
+
progress,
|
|
481
|
+
reapPlanRunLabelsForStoryFn,
|
|
482
|
+
}),
|
|
483
|
+
{ name: 'plan-run label reap', progress },
|
|
484
|
+
);
|
|
485
|
+
|
|
405
486
|
// Local-checkout mutations: serialized behind a best-effort cross-process
|
|
406
487
|
// lock (Story #4622). Acquire once, run both steps, release in `finally`.
|
|
407
488
|
const lockCfg = config?.delivery?.postLandLock ?? {};
|
|
@@ -9,12 +9,17 @@
|
|
|
9
9
|
* explicitly, and always state them (as `none` when healthy) so an absent line
|
|
10
10
|
* can never be mistaken for a clean gate.
|
|
11
11
|
*
|
|
12
|
-
* A degraded gate is **reported, not blocking**:
|
|
13
|
-
* close
|
|
14
|
-
*
|
|
15
|
-
* would cost delivery without buying coverage. The rationale for that posture
|
|
16
|
-
* lives with the channel itself in
|
|
12
|
+
* A degraded gate is **reported, not blocking**: this review is a secondary
|
|
13
|
+
* read, and the close does not gate the merge on it. The rationale for that
|
|
14
|
+
* posture lives with the channel itself in
|
|
17
15
|
* [`review-providers/degraded-gates.js`](../../review-providers/degraded-gates.js).
|
|
16
|
+
*
|
|
17
|
+
* What this module must **not** do (Story #5193) is tell the operator that the
|
|
18
|
+
* canonical `npm run lint` close gate covered the surface instead. A stub
|
|
19
|
+
* `lint` script is a supported consumer shape, so that claim is unverifiable
|
|
20
|
+
* from here — and asserting it talks the operator out of the exact concern the
|
|
21
|
+
* degradation was raised to surface. State the posture; never vouch for
|
|
22
|
+
* coverage this module cannot see.
|
|
18
23
|
*/
|
|
19
24
|
|
|
20
25
|
import { summarizeDegradations } from '../../review-providers/degraded-gates.js';
|
|
@@ -58,8 +63,8 @@ export function formatReviewOutcomeLines({
|
|
|
58
63
|
if (summarizeDegradations(degradations) !== 'none') {
|
|
59
64
|
lines.push(
|
|
60
65
|
'⚠️ Review ran DEGRADED — the surface(s) above were not reviewed. The close ' +
|
|
61
|
-
'is not blocked
|
|
62
|
-
'
|
|
66
|
+
'is not blocked — a secondary review does not gate the merge — but ' +
|
|
67
|
+
'nothing here vouches for those surfaces.',
|
|
63
68
|
);
|
|
64
69
|
}
|
|
65
70
|
return lines;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { withTransientRetry } from './errors.js';
|
|
20
|
+
import { paginateRest } from './request-helpers.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Detect the "label already exists" signal across the surfaces `gh label
|
|
@@ -60,6 +61,28 @@ export function isLabelAlreadyExistsError(err) {
|
|
|
60
61
|
return false;
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Detect the "label does not exist" signal on the delete path. `gh api` exits
|
|
66
|
+
* non-zero on a 404 with `gh: Not Found (HTTP 404)` on stderr; the REST body
|
|
67
|
+
* carries `"message": "Not Found"`. Both are matched, and the numeric status
|
|
68
|
+
* is matched on its own so a transport that surfaces only `err.status` still
|
|
69
|
+
* classifies.
|
|
70
|
+
*
|
|
71
|
+
* Deliberately narrow: only a 404 counts. A 403 (scope) or a 422 must stay
|
|
72
|
+
* loud, because reading either as "already gone" would let a sweep report
|
|
73
|
+
* labels as reaped that are all still there.
|
|
74
|
+
*
|
|
75
|
+
* @param {unknown} err
|
|
76
|
+
* @returns {boolean}
|
|
77
|
+
*/
|
|
78
|
+
export function isLabelNotFoundError(err) {
|
|
79
|
+
if (!err) return false;
|
|
80
|
+
if (err.status === 404 || err.statusCode === 404) return true;
|
|
81
|
+
return /\bHTTP\s+404\b|\bnot found\b/i.test(
|
|
82
|
+
`${err.message ?? ''} ${err.stderr ?? ''}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
63
86
|
export class LabelGateway {
|
|
64
87
|
/**
|
|
65
88
|
* @param {{ gh: object, owner: string, repo: string }} deps
|
|
@@ -162,6 +185,71 @@ export class LabelGateway {
|
|
|
162
185
|
return missing;
|
|
163
186
|
}
|
|
164
187
|
|
|
188
|
+
/**
|
|
189
|
+
* List the repository's whole label vocabulary, paginated.
|
|
190
|
+
*
|
|
191
|
+
* Deliberately NOT modelled on `_reconcileLabelsPresence`'s
|
|
192
|
+
* `gh label list --limit 500`. That hard cap is fine for its own job —
|
|
193
|
+
* "are these 20 bootstrap labels present?" — and fatal for this one: a
|
|
194
|
+
* caller deciding which labels to delete from a truncated view would skip
|
|
195
|
+
* exactly the labels that sort after an accumulated pile, which is the
|
|
196
|
+
* failure Story #5189 exists to stop reproducing. `paginateRest` walks
|
|
197
|
+
* pages until a short one lands and throws (loudly) rather than truncating
|
|
198
|
+
* if the repository somehow exceeds its ceiling.
|
|
199
|
+
*
|
|
200
|
+
* Rows are projected to the fields a caller can rely on; a row without a
|
|
201
|
+
* usable `name` is dropped rather than passed on as a deletable target.
|
|
202
|
+
*
|
|
203
|
+
* @returns {Promise<Array<{ name: string, color: string|null, description: string|null }>>}
|
|
204
|
+
* @field-manifest /repos/{owner}/{repo}/labels: name, color, description
|
|
205
|
+
*/
|
|
206
|
+
async listLabels() {
|
|
207
|
+
const endpoint = `/repos/${this.owner}/${this.repo}/labels`;
|
|
208
|
+
const rows = await paginateRest(this._gh, endpoint, {
|
|
209
|
+
label: `listLabels ${this.owner}/${this.repo}`,
|
|
210
|
+
});
|
|
211
|
+
return (Array.isArray(rows) ? rows : [])
|
|
212
|
+
.filter((row) => typeof row?.name === 'string')
|
|
213
|
+
.map((row) => ({
|
|
214
|
+
name: row.name,
|
|
215
|
+
color: row.color ?? null,
|
|
216
|
+
description: row.description ?? null,
|
|
217
|
+
}));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Delete one label by name.
|
|
222
|
+
*
|
|
223
|
+
* Goes through the REST surface (`DELETE /repos/{owner}/{repo}/labels/{name}`)
|
|
224
|
+
* rather than `gh label delete`, so the "already gone" signal arrives as a
|
|
225
|
+
* structured 404 on the same facade every other read here uses instead of
|
|
226
|
+
* as CLI prose — and so this gateway needs no new verb on the `gh` facade.
|
|
227
|
+
*
|
|
228
|
+
* A missing label resolves as a successful no-op. That is not leniency: the
|
|
229
|
+
* sweep this port exists for is expected to run repeatedly and concurrently
|
|
230
|
+
* with the close-path reap, so "someone else already deleted it" is the
|
|
231
|
+
* normal case, not an error.
|
|
232
|
+
*
|
|
233
|
+
* @param {string} name
|
|
234
|
+
* @returns {Promise<{ deleted: boolean, reason: string|null }>}
|
|
235
|
+
*/
|
|
236
|
+
async deleteLabel(name) {
|
|
237
|
+
if (typeof name !== 'string' || name.trim().length === 0) {
|
|
238
|
+
throw new Error('deleteLabel: a non-empty label name is required');
|
|
239
|
+
}
|
|
240
|
+
const endpoint = `/repos/${this.owner}/${this.repo}/labels/${encodeURIComponent(name)}`;
|
|
241
|
+
try {
|
|
242
|
+
await withTransientRetry(() =>
|
|
243
|
+
this._gh.api({ method: 'DELETE', endpoint }),
|
|
244
|
+
);
|
|
245
|
+
return { deleted: true, reason: null };
|
|
246
|
+
} catch (err) {
|
|
247
|
+
if (isLabelNotFoundError(err))
|
|
248
|
+
return { deleted: false, reason: 'not-found' };
|
|
249
|
+
throw err;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
165
253
|
_normalizeLabelListResult(result) {
|
|
166
254
|
if (Array.isArray(result)) return result;
|
|
167
255
|
if (result && typeof result.stdout === 'string') {
|
|
@@ -133,6 +133,8 @@ const DELEGATIONS = [
|
|
|
133
133
|
['getBranchProtection', 'branchProtection.getBranchProtection'],
|
|
134
134
|
['setBranchProtection', 'branchProtection.setBranchProtection'],
|
|
135
135
|
['ensureLabels', 'labels.ensureLabels'],
|
|
136
|
+
['listLabels', 'labels.listLabels'],
|
|
137
|
+
['deleteLabel', 'labels.deleteLabel'],
|
|
136
138
|
['_reconcileLabelsPresence', 'labels._reconcileLabelsPresence'],
|
|
137
139
|
['getMergeMethods', 'mergeMethods.getMergeMethods'],
|
|
138
140
|
['setMergeMethods', 'mergeMethods.setMergeMethods'],
|