Skip to content

Commit 68f65ff

Browse files
Jack Qclaude
andauthored
feat(pm): add --reconcile to ensure-pm-labels, aligning drifted label objects (#10193)
Claude-Session: https://claude.ai/code/session_019T1sSZbQTnLhrK9HhNdNiB Co-authored-by: Claude <noreply@anthropic.com>
1 parent 97d2a03 commit 68f65ff

2 files changed

Lines changed: 280 additions & 28 deletions

File tree

scripts/pm/check-label-desc-cap.mjs

Lines changed: 129 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,22 @@
6262
* dry-run mode of its own, which is the other half of why nothing measured this
6363
* before.
6464
*
65+
* ## Both ways a description reaches GitHub
66+
*
67+
* The vocabulary script has two modes: create-if-missing (default) and
68+
* `--reconcile`, which additionally sends colour and description to
69+
* `gh label edit`. `gh label edit` 422s over the cap exactly as `gh label create`
70+
* does, so the gate's charter — no description this script sends can 422 —
71+
* spans both. The self-test drives the script in BOTH modes and asserts that
72+
* every edit carries the same string as its create, which is what keeps the
73+
* single measured `-d` literal per label sufficient. Without those cases the
74+
* gate would still pass while covering only half of what the script sends.
75+
*
6576
* Missing file or empty read is RED, never a pass — a gate that cannot find its
6677
* input must fail, not skip.
6778
*/
6879

69-
import { execFileSync } from 'node:child_process';
80+
import { spawnSync } from 'node:child_process';
7081
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7182
import { tmpdir } from 'node:os';
7283
import { join } from 'node:path';
@@ -210,38 +221,72 @@ function readTarget() {
210221
}
211222

212223
/**
213-
* Run the real script with a fake `gh` on PATH and return every description it
214-
* was actually invoked with. The fake records one argument per line and closes
215-
* each invocation with a sentinel line; no argument in this file contains a
216-
* newline, and no control bytes are written.
224+
* Run the real script with a fake `gh` on PATH and return every label call it
225+
* actually made, plus the script's exit status. The fake records one argument
226+
* per line and closes each invocation with a sentinel line; no argument in this
227+
* file contains a newline, and no control bytes are written.
228+
*
229+
* `args` are passed through to the script — which is how the `--reconcile` mode
230+
* is measured. `failEdit` makes the fake `gh` reject `label edit`, so the
231+
* self-test can assert that a failed reconciliation is LOUD: unlike creation,
232+
* whose `|| true` is mandatory for rerunnability, a swallowed edit failure would
233+
* report success while leaving the drift live.
234+
*
235+
* The script is run via `spawnSync`, not `execFileSync`, because two of the
236+
* behaviours under test are non-zero exits (an unknown flag, a failed edit) and
237+
* a throwing runner cannot observe an exit status it converts into an exception.
217238
*/
218-
export function dryRunDescriptions(scriptPath) {
239+
export function dryRunCalls(scriptPath, args = [], { failEdit = false } = {}) {
219240
const dir = mkdtempSync(join(tmpdir(), 'pm-label-cap-'));
220241
try {
221242
const log = join(dir, 'calls.log');
222243
const fake = join(dir, 'gh');
223244
writeFileSync(
224245
fake,
225-
['#!/usr/bin/env bash', 'for a in "$@"; do printf "%s\\n" "$a" >> "$FAKE_GH_LOG"; done', 'printf "<<<END>>>\\n" >> "$FAKE_GH_LOG"', 'exit 0', ''].join('\n'),
246+
[
247+
'#!/usr/bin/env bash',
248+
'for a in "$@"; do printf "%s\\n" "$a" >> "$FAKE_GH_LOG"; done',
249+
'printf "<<<END>>>\\n" >> "$FAKE_GH_LOG"',
250+
'if [ -n "${FAKE_GH_FAIL_EDIT:-}" ] && [ "${2:-}" = "edit" ]; then echo "fake gh: refusing" >&2; exit 1; fi',
251+
'exit 0',
252+
'',
253+
].join('\n'),
226254
);
227255
chmodSync(fake, 0o755);
228256
writeFileSync(log, '');
229-
execFileSync('bash', [scriptPath], {
230-
env: { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}`, FAKE_GH_LOG: log },
231-
stdio: 'ignore',
232-
});
233-
const out = [];
257+
const env = { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}`, FAKE_GH_LOG: log };
258+
if (failEdit) env.FAKE_GH_FAIL_EDIT = '1';
259+
const proc = spawnSync('bash', [scriptPath, ...args], { env, stdio: 'ignore' });
260+
const calls = [];
234261
for (const call of readFileSync(log, 'utf8').split('<<<END>>>\n')) {
235-
const args = call.split('\n').filter((a) => a !== '');
236-
const i = args.indexOf('-d');
237-
if (args[0] === 'label' && args[1] === 'create' && i !== -1) out.push(args[i + 1]);
262+
const argv = call.split('\n').filter((a) => a !== '');
263+
if (argv[0] !== 'label') continue;
264+
const flag = (...names) => {
265+
const i = argv.findIndex((a) => names.includes(a));
266+
return i === -1 ? null : argv[i + 1];
267+
};
268+
calls.push({
269+
verb: argv[1],
270+
name: argv[2],
271+
repo: flag('-R', '--repo'),
272+
color: flag('-c', '--color'),
273+
description: flag('-d', '--description'),
274+
argv,
275+
});
238276
}
239-
return out;
277+
return { calls, status: proc.status };
240278
} finally {
241279
rmSync(dir, { recursive: true, force: true });
242280
}
243281
}
244282

283+
/** Every description the script passes to `gh label create`. See dryRunCalls. */
284+
export function dryRunDescriptions(scriptPath) {
285+
return dryRunCalls(scriptPath)
286+
.calls.filter((c) => c.verb === 'create' && c.description !== null)
287+
.map((c) => c.description);
288+
}
289+
245290
function run() {
246291
const source = readTarget();
247292
const descriptions = parseDescriptions(source);
@@ -357,6 +402,74 @@ function selfTest() {
357402
// The floor: a parser that matches nothing must not read as clean.
358403
t('an empty parse is below the floor', parseDescriptions('echo hi').length < MIN_DESCRIPTIONS, true);
359404

405+
// ── The --reconcile mode ──────────────────────────────────────────────────
406+
//
407+
// This gate's charter is "no description the script sends to GitHub can 422".
408+
// Reconcile adds a SECOND way to send one (`gh label edit`), so without the
409+
// cases below the gate's coverage would silently shrink to the create path
410+
// while still reading as a full pass — the parser-matches-nothing failure in
411+
// a new place. The cases assert the two properties that keep the cap honest:
412+
// every edit carries the same string as its create, and nothing else is sent.
413+
const script = join(REPO_ROOT, TARGET);
414+
// The verb is deliberately NOT part of the signature: these cases compare a
415+
// create against its edit, and equality is the whole assertion.
416+
const sig = (c) => JSON.stringify([c.name, c.repo, c.color, c.description]);
417+
const verbs = (calls, v) => calls.filter((c) => c.verb === v);
418+
419+
const plain = dryRunCalls(script);
420+
const reconciled = dryRunCalls(script, ['--reconcile']);
421+
422+
t('the default run issues no label edit at all', verbs(plain.calls, 'edit').length, 0);
423+
t('…and exits 0', plain.status, 0);
424+
t('reconcile exits 0 when every edit succeeds', reconciled.status, 0);
425+
t(
426+
'reconcile creates exactly what the default run creates',
427+
verbs(reconciled.calls, 'create').map(sig),
428+
verbs(plain.calls, 'create').map(sig),
429+
);
430+
t(
431+
'reconcile edits exactly the labels it creates — same name, repo, colour and description',
432+
verbs(reconciled.calls, 'edit').map(sig),
433+
verbs(reconciled.calls, 'create').map(sig),
434+
);
435+
t(
436+
'so every description reconcile SENDS is one this gate already measured',
437+
verbs(reconciled.calls, 'edit').filter((c) => !real.some((r) => templateMatches(r.raw, c.description))),
438+
[],
439+
);
440+
t(
441+
'…and none of them would 422',
442+
verbs(reconciled.calls, 'edit').filter((c) => charLength(c.description) > MAX_DESCRIPTION_CHARS),
443+
[],
444+
);
445+
t(
446+
'reconcile issues no verb other than create and edit — it never deletes',
447+
[...new Set(reconciled.calls.map((c) => c.verb))].filter((v) => v !== 'create' && v !== 'edit'),
448+
[],
449+
);
450+
t(
451+
'reconcile never renames: no --name/-n reaches gh',
452+
reconciled.calls.filter((c) => c.argv.includes('--name') || c.argv.includes('-n')).map((c) => c.name),
453+
[],
454+
);
455+
t(
456+
'reconcile is idempotent — a second run issues the identical call sequence',
457+
dryRunCalls(script, ['--reconcile']).calls.map(sig),
458+
reconciled.calls.map(sig),
459+
);
460+
461+
// A mistyped flag must not fall through to a create-only run that prints the
462+
// ordinary success line — the operator would read a reconciliation that never
463+
// happened as done.
464+
const mistyped = dryRunCalls(script, ['--reconsile']);
465+
t('an unknown flag exits non-zero', mistyped.status, 2);
466+
t('…and issues no gh calls at all', mistyped.calls.length, 0);
467+
468+
// The asymmetry with creation: a failed edit means the drift is still live.
469+
const brokenEdit = dryRunCalls(script, ['--reconcile'], { failEdit: true });
470+
t('a failing gh label edit makes the script exit non-zero', brokenEdit.status !== 0, true);
471+
t('…while the same failure in the default mode cannot arise (no edits)', verbs(dryRunCalls(script, [], { failEdit: true }).calls, 'edit').length, 0);
472+
360473
if (failed) {
361474
console.error(`\n❌ check-label-desc-cap --self-test: ${failed} case(s) failed`);
362475
process.exit(1);

0 commit comments

Comments
 (0)