mandrel 2.43.0 → 2.45.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/scripts/lib/ITicketingProvider.js +27 -0
- 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 +129 -19
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +10 -2
- package/.agents/scripts/lib/orchestration/plan-run-labels/reap.js +327 -0
- 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/docs/CHANGELOG.md +20 -0
- package/package.json +1 -1
|
@@ -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'],
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// .agents/scripts/prune-plan-run-labels.js — Story #5189.
|
|
4
|
+
//
|
|
5
|
+
// Sweep the repository's `plan-run::<id>` cohort labels and delete the ones
|
|
6
|
+
// that are provably spent.
|
|
7
|
+
//
|
|
8
|
+
// The close tail reaps incrementally — one Story's own labels, as it lands —
|
|
9
|
+
// which keeps a healthy repository flat but does nothing about a pile that
|
|
10
|
+
// already exists, and nothing about a cohort whose last Story was closed by
|
|
11
|
+
// hand rather than by a close. This is the surface that burns an accumulated
|
|
12
|
+
// pile down: it reads the whole label vocabulary through the paginating
|
|
13
|
+
// listing port, so what it can see is bounded by the repository rather than by
|
|
14
|
+
// an API page.
|
|
15
|
+
//
|
|
16
|
+
// The decision is not made here — `lib/orchestration/plan-run-labels/reap.js`
|
|
17
|
+
// owns it, so this sweep and the close-path reap cannot come to different
|
|
18
|
+
// conclusions about when a label is spent. In short: reapable means the label
|
|
19
|
+
// carries at least one issue and every one of them is closed. A label carrying
|
|
20
|
+
// zero issues is NOT reapable by default, because that shape is exactly what
|
|
21
|
+
// an in-flight `plan-persist` looks like between minting its label and
|
|
22
|
+
// creating its Stories; `--include-unreferenced` is the explicit opt-in for an
|
|
23
|
+
// operator who knows no persist is running.
|
|
24
|
+
//
|
|
25
|
+
// Exit codes:
|
|
26
|
+
// 0 nothing reapable (or, without `--check`, the reap was performed)
|
|
27
|
+
// 1 `--check` found at least one label that would be reaped
|
|
28
|
+
// 2 the sweep could not run
|
|
29
|
+
|
|
30
|
+
// Fail-fast if the framework's runtime deps are not installed — must be the
|
|
31
|
+
// first import so the check runs before any third-party-importing sibling
|
|
32
|
+
// module is evaluated (Story #3432).
|
|
33
|
+
import './lib/runtime-deps/ensure-installed.js';
|
|
34
|
+
import { runAsCli } from './lib/cli-utils.js';
|
|
35
|
+
import { resolveConfig } from './lib/config-resolver.js';
|
|
36
|
+
import { Logger } from './lib/Logger.js';
|
|
37
|
+
import {
|
|
38
|
+
REAP_REASONS,
|
|
39
|
+
sweepCohortLabels,
|
|
40
|
+
} from './lib/orchestration/plan-run-labels/reap.js';
|
|
41
|
+
import { createProvider } from './lib/provider-factory.js';
|
|
42
|
+
|
|
43
|
+
const EXIT_CLEAN = 0;
|
|
44
|
+
const EXIT_WOULD_REAP = 1;
|
|
45
|
+
const EXIT_CANNOT_RUN = 2;
|
|
46
|
+
|
|
47
|
+
const HELP = {
|
|
48
|
+
invocation:
|
|
49
|
+
'node .agents/scripts/prune-plan-run-labels.js [--check] [--json] [--include-unreferenced] [--cwd <dir>]',
|
|
50
|
+
summary:
|
|
51
|
+
'Delete the plan-run:: cohort labels whose Stories are all closed. Never touches any other label axis, and never changes how a cohort label is minted.',
|
|
52
|
+
flags: [
|
|
53
|
+
['--check', 'Report what would be reaped, delete nothing, exit 1 if any.'],
|
|
54
|
+
['--json', 'Emit the report as JSON instead of text.'],
|
|
55
|
+
[
|
|
56
|
+
'--include-unreferenced',
|
|
57
|
+
'Also reap cohort labels carrying zero issues. Off by default: that shape is indistinguishable from a label an in-flight plan-persist just minted.',
|
|
58
|
+
],
|
|
59
|
+
['--cwd <dir>', 'Repository root to sweep. Default: process.cwd().'],
|
|
60
|
+
],
|
|
61
|
+
notes: [
|
|
62
|
+
'Reapable = the label carries at least one issue AND every issue carrying it\nis closed. One open issue anywhere in the cohort keeps the label.',
|
|
63
|
+
'Exit codes:\n 0 clean, or reaped\n 1 --check found reapable labels\n 2 the sweep could not run',
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse argv into an options bag. An unknown flag is an error, never a silent
|
|
69
|
+
* no-op — a typo'd `--dry-run` must not read as "delete the labels".
|
|
70
|
+
*
|
|
71
|
+
* @param {string[]} argv
|
|
72
|
+
* @returns {{ check: boolean, json: boolean, includeUnreferenced: boolean, cwd: string }}
|
|
73
|
+
*/
|
|
74
|
+
export function parseArgs(argv = []) {
|
|
75
|
+
const out = {
|
|
76
|
+
check: false,
|
|
77
|
+
json: false,
|
|
78
|
+
includeUnreferenced: false,
|
|
79
|
+
cwd: null,
|
|
80
|
+
};
|
|
81
|
+
let i = 0;
|
|
82
|
+
while (i < argv.length) {
|
|
83
|
+
const arg = argv[i];
|
|
84
|
+
const value = argv[i + 1];
|
|
85
|
+
i += 1;
|
|
86
|
+
if (arg === '--check') out.check = true;
|
|
87
|
+
else if (arg === '--json') out.json = true;
|
|
88
|
+
else if (arg === '--include-unreferenced') out.includeUnreferenced = true;
|
|
89
|
+
else if (arg === '--cwd') {
|
|
90
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
91
|
+
throw new Error('--cwd requires a directory');
|
|
92
|
+
}
|
|
93
|
+
out.cwd = value;
|
|
94
|
+
i += 1;
|
|
95
|
+
} else throw new Error(`unknown flag "${arg}" (try --help)`);
|
|
96
|
+
}
|
|
97
|
+
out.cwd = out.cwd ?? process.cwd();
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One human-readable line per cohort label, naming the reason it was kept or
|
|
103
|
+
* reaped. The reason is printed for every label, not only the reapable ones —
|
|
104
|
+
* an operator auditing a pile of 235 needs to see why the other 234 stayed.
|
|
105
|
+
*
|
|
106
|
+
* @param {object} decision
|
|
107
|
+
* @param {boolean} check
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
function renderDecision(decision, check) {
|
|
111
|
+
if (!decision.reapable) {
|
|
112
|
+
const suffix =
|
|
113
|
+
decision.reason === REAP_REASONS.OPEN_STORIES
|
|
114
|
+
? ` (open: ${decision.openIssues.join(', ') || 'unknown'})`
|
|
115
|
+
: '';
|
|
116
|
+
return ` · keep ${decision.label} — ${decision.reason}${suffix}`;
|
|
117
|
+
}
|
|
118
|
+
const verb = check ? 'would reap' : 'reaped';
|
|
119
|
+
return ` · ${verb} ${decision.label} — ${decision.reason} (${decision.issueCount} closed issue(s))`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Render the whole report as text.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} report
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
export function formatReport(report) {
|
|
129
|
+
const verb = report.check ? 'would reap' : 'reaped';
|
|
130
|
+
const count = report.check ? report.reapable.length : report.deleted.length;
|
|
131
|
+
const lines = [
|
|
132
|
+
`[prune-plan-run-labels] ${verb} ${count} of ${report.evaluated} cohort ` +
|
|
133
|
+
`label(s) (${report.totalLabels} label(s) in the repository)`,
|
|
134
|
+
...report.decisions.map((d) => renderDecision(d, report.check)),
|
|
135
|
+
];
|
|
136
|
+
for (const failure of report.failed) {
|
|
137
|
+
lines.push(` ! failed ${failure.label} — ${failure.detail}`);
|
|
138
|
+
}
|
|
139
|
+
if (report.check && report.reapable.length > 0) {
|
|
140
|
+
lines.push(
|
|
141
|
+
'',
|
|
142
|
+
'Run without --check to delete them:',
|
|
143
|
+
' node .agents/scripts/prune-plan-run-labels.js',
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return lines.join('\n');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The whole sweep — argv in, exit code out, report written through `writeFn`.
|
|
151
|
+
*
|
|
152
|
+
* Exported with its provider/config/output surfaces as default-parameter
|
|
153
|
+
* seams (`rules/test-seams.md`) so a test drives the *real* argv parsing,
|
|
154
|
+
* report rendering and exit-code decision against a stub provider. Without
|
|
155
|
+
* that, the only testable thing here would be a re-implementation of the
|
|
156
|
+
* decision, which is precisely the copy that drifts.
|
|
157
|
+
*
|
|
158
|
+
* @param {string[]} argv
|
|
159
|
+
* @param {{
|
|
160
|
+
* createProviderFn?: Function,
|
|
161
|
+
* resolveConfigFn?: Function,
|
|
162
|
+
* writeFn?: (text: string) => void,
|
|
163
|
+
* warnFn?: (message: string) => void,
|
|
164
|
+
* }} [seams]
|
|
165
|
+
* @returns {Promise<number>} the process exit code.
|
|
166
|
+
*/
|
|
167
|
+
export async function runSweep(
|
|
168
|
+
argv,
|
|
169
|
+
{
|
|
170
|
+
createProviderFn = createProvider,
|
|
171
|
+
resolveConfigFn = resolveConfig,
|
|
172
|
+
writeFn = (text) => process.stdout.write(text),
|
|
173
|
+
warnFn = (message) => Logger.warn(`[prune-plan-run-labels] ${message}`),
|
|
174
|
+
} = {},
|
|
175
|
+
) {
|
|
176
|
+
let opts;
|
|
177
|
+
let report;
|
|
178
|
+
try {
|
|
179
|
+
opts = parseArgs(argv);
|
|
180
|
+
report = await sweepCohortLabels({
|
|
181
|
+
provider: createProviderFn(resolveConfigFn({ cwd: opts.cwd })),
|
|
182
|
+
includeUnreferenced: opts.includeUnreferenced,
|
|
183
|
+
check: opts.check,
|
|
184
|
+
onWarn: warnFn,
|
|
185
|
+
});
|
|
186
|
+
} catch (err) {
|
|
187
|
+
const message = err?.message ?? String(err);
|
|
188
|
+
writeFn(
|
|
189
|
+
`${JSON.stringify({ schemaVersion: '1', error: message }, null, 2)}\n`,
|
|
190
|
+
);
|
|
191
|
+
return EXIT_CANNOT_RUN;
|
|
192
|
+
}
|
|
193
|
+
writeFn(
|
|
194
|
+
opts.json
|
|
195
|
+
? `${JSON.stringify(report, null, 2)}\n`
|
|
196
|
+
: `${formatReport(report)}\n`,
|
|
197
|
+
);
|
|
198
|
+
return report.check && report.reapable.length > 0
|
|
199
|
+
? EXIT_WOULD_REAP
|
|
200
|
+
: EXIT_CLEAN;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* CLI entry point. Returns its exit code rather than calling `process.exit()`,
|
|
205
|
+
* so `runAsCli`'s `propagateExitCode` path settles it through `flushStdio` and
|
|
206
|
+
* an unbounded report is not truncated at a pipe boundary (Story #4783).
|
|
207
|
+
*
|
|
208
|
+
* @returns {Promise<number>}
|
|
209
|
+
*/
|
|
210
|
+
async function main() {
|
|
211
|
+
return runSweep(process.argv.slice(2));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
runAsCli(import.meta.url, main, {
|
|
215
|
+
source: 'prune-plan-run-labels',
|
|
216
|
+
usage: HELP,
|
|
217
|
+
propagateExitCode: true,
|
|
218
|
+
});
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,26 @@ All notable changes to this project will be documented in this file.
|
|
|
15
15
|
-->
|
|
16
16
|
<!-- markdownlint-disable-file MD004 MD012 MD037 -->
|
|
17
17
|
|
|
18
|
+
## [2.45.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.44.0...mandrel-v2.45.0) (2026-09-07)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
* **git-cleanup:** drop the symbolic HEAD ref under its short spelling ([#5197](https://github.com/dsj1984/mandrel/issues/5197)) ([98802f6](https://github.com/dsj1984/mandrel/commit/98802f6ee49b699560ab7362815e054751f3024b))
|
|
24
|
+
|
|
25
|
+
## [2.44.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.43.0...mandrel-v2.44.0) (2026-09-07)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
* reap a plan-run cohort label once every Story carrying it is closed ([#5189](https://github.com/dsj1984/mandrel/issues/5189)) ([#5194](https://github.com/dsj1984/mandrel/issues/5194)) ([43ecacb](https://github.com/dsj1984/mandrel/commit/43ecacb7e2fa8469813174623307149cfe59a60a))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
* **git-cleanup:** record an outcome for every remote-only branch, including the no-PR ones ([#5188](https://github.com/dsj1984/mandrel/issues/5188)) ([#5190](https://github.com/dsj1984/mandrel/issues/5190)) ([8016137](https://github.com/dsj1984/mandrel/commit/8016137e0c5e3b06fb608674cbb68a8fbf212ca8))
|
|
36
|
+
* **review:** disk-probe the scoped-lint code runner so an absent biome degrades by name (refs [#5193](https://github.com/dsj1984/mandrel/issues/5193)) ([#5195](https://github.com/dsj1984/mandrel/issues/5195)) ([cc4bb41](https://github.com/dsj1984/mandrel/commit/cc4bb41a5877366f7e4e488d6d522fcfa3c2f8cd))
|
|
37
|
+
|
|
18
38
|
## [2.43.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.42.0...mandrel-v2.43.0) (2026-09-07)
|
|
19
39
|
|
|
20
40
|
|
package/package.json
CHANGED