@rtorcato/repo-tooling 3.13.2 → 3.15.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.md CHANGED
@@ -95,6 +95,8 @@ npx @rtorcato/repo-tooling fix claude-skills --yes --json
95
95
 
96
96
  Fixers marked `explicitOnly` are exempt from `fix` all *and* from `fix --yes` — they only run when named as the target. Today that is `claude-skills`, the one fixer whose blast radius is outside the repo.
97
97
 
98
+ A fixer may also **refuse** — the target file holds something the generator cannot reproduce, so overwriting would destroy it. Today that is `dependabot` against a config with repo-local `ignore:` rules (#422). A targeted `fix dependabot` then exits 1 with `error: dependabot-ignore-rules`; a bulk `fix --yes` records it `skipped` and carries on with the rest. Neither `--yes` nor `--json` overrides it — resolve the named rules by hand and re-run.
99
+
98
100
  ## Source-of-truth files in the repo
99
101
 
100
102
  - `src/cli/commands/setup.ts` — `ProjectConfig` interface and the setup orchestrator
@@ -3,6 +3,7 @@ import fs from 'fs-extra';
3
3
  import { BLOCK_START, hasStaleAgentBlock } from '../cli/generators/agent-rules.js';
4
4
  import { BADGE_START, hasPublicOnlyBadges } from '../cli/generators/badges.js';
5
5
  import { claudeSkillStatus, SHIPPED_SKILL } from '../cli/generators/claude-skills.js';
6
+ import { DEPENDABOT_CONFIG_PATHS, dependabotIgnoreRules } from '../cli/generators/security.js';
6
7
  import { detectNestedLanguages } from '../cli/utils/detect-language.js';
7
8
  /**
8
9
  * Root-only detection is the decision (#317), not an oversight — but it used to
@@ -235,7 +236,7 @@ export async function checkGitHubActions(dir, preset = null) {
235
236
  }
236
237
  }
237
238
  export async function checkDependabot(dir) {
238
- for (const candidate of ['.github/dependabot.yml', '.github/dependabot.yaml']) {
239
+ for (const candidate of DEPENDABOT_CONFIG_PATHS) {
239
240
  const candidatePath = path.join(dir, candidate);
240
241
  if (await fs.pathExists(candidatePath)) {
241
242
  // The canonical standard (apps/docs/docs/guides/dependabot-strategy.md) is
@@ -250,21 +251,39 @@ export async function checkDependabot(dir) {
250
251
  }
251
252
  }
252
253
  const automergePath = path.join(dir, '.github', 'workflows', 'dependabot-automerge.yml');
253
- if (!(await fs.pathExists(automergePath))) {
254
+ if (await fs.pathExists(automergePath)) {
255
+ // Pre-#423 workflows gated on the semver type alone, so production
256
+ // bumps of a published package merged with nobody in the loop. The
257
+ // canonical gate also requires the dev-minor group.
258
+ const automerge = await fs.readFile(automergePath, 'utf8');
259
+ if (!automerge.includes("dependency-group == 'dev-minor'")) {
260
+ deltas.push('auto-merge workflow merges production bumps (missing dev-minor group gate)');
261
+ }
262
+ }
263
+ else {
254
264
  deltas.push('missing dependabot-automerge workflow');
255
265
  }
266
+ // Repo-local `ignore:` rules aren't drift — they're deliberate, and the
267
+ // canonical config has no way to express them. Report them anyway so the
268
+ // reason `fix dependabot` refuses is visible before anyone runs it (#422).
269
+ const ignored = dependabotIgnoreRules(content);
270
+ const ignoreNote = ignored.length === 0
271
+ ? ''
272
+ : ` — ${ignored.length} local \`ignore:\` rule(s) the canonical config cannot express (${ignored.join(', ')}); \`fix dependabot\` refuses rather than drop them`;
256
273
  if (deltas.length > 0) {
257
274
  return {
258
275
  check: 'Dependabot',
259
276
  status: 'drift',
260
- detail: `${candidate} drifts from canonical (${deltas.join('; ')})`,
261
- hint: 'Run `npx @rtorcato/repo-tooling fix dependabot` to apply the canonical grouping + auto-merge workflow',
277
+ detail: `${candidate} drifts from canonical (${deltas.join('; ')})${ignoreNote}`,
278
+ hint: ignored.length > 0
279
+ ? 'Run `npx @rtorcato/repo-tooling fix dependabot --diff` — it will refuse until the `ignore:` block is dealt with by hand'
280
+ : 'Run `npx @rtorcato/repo-tooling fix dependabot` to apply the canonical grouping + auto-merge workflow',
262
281
  };
263
282
  }
264
283
  return {
265
284
  check: 'Dependabot',
266
285
  status: 'ok',
267
- detail: `${candidate} + auto-merge workflow`,
286
+ detail: `${candidate} + auto-merge workflow${ignoreNote}`,
268
287
  };
269
288
  }
270
289
  }
@@ -21,11 +21,13 @@ import { generateBrand } from '../cli/generators/brand.js';
21
21
  import { generateCommunityHealth } from '../cli/generators/community-health.js';
22
22
  import { generateCommitlintConfig } from '../cli/generators/git.js';
23
23
  import { generateCodeowners, generateEditorConfig } from '../cli/generators/misc.js';
24
- import { generateCodeQLWorkflow, generateDependabotConfig, generateRenovateConfig, } from '../cli/generators/security.js';
24
+ import { findDependabotIgnoreRules, generateCodeQLWorkflow, generateDependabotConfig, generateRenovateConfig, } from '../cli/generators/security.js';
25
+ import { classifyCopiedAssets } from '../cli/utils/copied-assets.js';
25
26
  import { copyPreset } from '../cli/utils/copy-preset.js';
26
27
  import { detectLanguage } from '../cli/utils/detect-language.js';
27
28
  import { resolveLanguageModule } from '../languages/registry.js';
28
29
  import { applyGithubSettings } from './github-settings.js';
30
+ import { applyLoopLabels } from './labels.js';
29
31
  import { closeCompletedMilestones } from './milestones.js';
30
32
  /**
31
33
  * A fixer giving up on something the user has to resolve — a wrong flag, not a
@@ -75,6 +77,24 @@ async function resolveInstallDir(explicit, assumeYes) {
75
77
  return trimmed ? path.resolve(trimmed) : null;
76
78
  }
77
79
  export const BASE_FIXERS = [
80
+ {
81
+ target: 'copied-assets',
82
+ description: 'Re-copy presets that are unchanged since they were copied but older than the version this package ships',
83
+ appliesTo: ['Copied assets'],
84
+ outputs: ['(the stale copied presets, re-copied in place)'],
85
+ canFixDrift: true,
86
+ async run({ targetDir }) {
87
+ // Only the `stale` ones: their content provably still matches what was
88
+ // copied, so overwriting loses nothing. `modified` assets are somebody's
89
+ // deliberate fork and stay a human decision (#428).
90
+ const stale = (await classifyCopiedAssets(targetDir)).filter((a) => a.state === 'stale');
91
+ const filesWritten = [];
92
+ for (const asset of stale) {
93
+ filesWritten.push((await copyPreset(asset.preset, targetDir)).target);
94
+ }
95
+ return { filesWritten };
96
+ },
97
+ },
78
98
  {
79
99
  target: 'editorconfig',
80
100
  description: 'Scaffold .editorconfig (UTF-8, LF, tab indent)',
@@ -112,6 +132,16 @@ export const BASE_FIXERS = [
112
132
  outputs: ['.github/dependabot.yml', '.github/workflows/dependabot-automerge.yml'],
113
133
  canFixDrift: true,
114
134
  async run({ targetDir }) {
135
+ // The template owns the whole file but emits no `ignore:` block, so
136
+ // regenerating deletes any repo-local ignore rule — silently, and with
137
+ // nothing in `doctor` to report the loss afterwards, because from the
138
+ // fixer's point of view the file then matches the standard exactly (#422).
139
+ // Refuse rather than warn: an unattended `fix --yes` would walk past a
140
+ // warning, and the rules are unrecoverable once written over.
141
+ const ignored = await findDependabotIgnoreRules(targetDir);
142
+ if (ignored) {
143
+ throw new FixerAbort('dependabot-ignore-rules', `refusing to overwrite ${ignored.file} — it has ${ignored.rules.length} \`ignore:\` rule(s) the canonical config does not reproduce: ${ignored.rules.join(', ')}`, `re-add the \`ignore:\` block after regenerating, or delete it from ${ignored.file} to accept the loss — then re-run \`fix dependabot\``);
144
+ }
115
145
  const { dependabotEcosystem } = await moduleFor(targetDir);
116
146
  return { filesWritten: await generateDependabotConfig(targetDir, dependabotEcosystem) };
117
147
  },
@@ -176,6 +206,19 @@ export const BASE_FIXERS = [
176
206
  return { filesWritten: await closeCompletedMilestones(targetDir) };
177
207
  },
178
208
  },
209
+ {
210
+ target: 'labels',
211
+ description: 'Repair ai-issue-loop label colours and descriptions on GitHub via `gh label edit` (mutates the remote repo, not files). No-ops on a repo that does not use the loop',
212
+ appliesTo: ['AI loop labels'],
213
+ outputs: ['GitHub labels (remote, via gh label edit)'],
214
+ // safe-add for the same reason github-settings is: it exempts this fixer
215
+ // from the `--diff` shadow-run, which executes run() for a mere preview.
216
+ riskLevel: 'safe-add',
217
+ canFixDrift: true,
218
+ async run({ targetDir }) {
219
+ return { filesWritten: await applyLoopLabels(targetDir) };
220
+ },
221
+ },
179
222
  {
180
223
  target: 'codeowners',
181
224
  description: 'Scaffold .github/CODEOWNERS with commented examples',
@@ -51,11 +51,15 @@ export const GITHUB_STANDARD = {
51
51
  requiredContexts: ['lint', 'typecheck', 'build', 'test'],
52
52
  };
53
53
  const CODE_SCANNING_CHECK = 'Code-scanning gate';
54
+ const RELEASE_GATE_CHECK = 'Release gate';
55
+ const RELEASE_ENV_CHECK = 'Release environment';
54
56
  const CHECK_NAMES = [
55
57
  'Branch protection',
56
58
  'Merge settings',
57
59
  'Workflow permissions',
58
60
  CODE_SCANNING_CHECK,
61
+ RELEASE_GATE_CHECK,
62
+ RELEASE_ENV_CHECK,
59
63
  ];
60
64
  // Recommended CodeQL alert thresholds — GitHub's UI defaults. This is the
61
65
  // override surface: bump them here for a stricter/looser fleet-wide baseline.
@@ -143,6 +147,7 @@ export async function checkGitHubSettings(dir, exec) {
143
147
  checkMergeSettings(info),
144
148
  await checkWorkflowPermissions(gh, info.nwo),
145
149
  await checkCodeScanningRuleset(gh, info.nwo, info.branch, dir),
150
+ ...(await checkReleaseGate(gh, info.nwo, dir)),
146
151
  ];
147
152
  }
148
153
  function probeFailureReason(probe) {
@@ -425,6 +430,270 @@ async function checkCodeScanningRuleset(gh, nwo, branch, dir) {
425
430
  : 'Run `npx @rtorcato/repo-tooling fix github-settings` to add a code_scanning branch ruleset that blocks merge on High+ CodeQL alerts',
426
431
  };
427
432
  }
433
+ // --- Release environment gate (#429) --------------------------------------
434
+ /** The environment name the standard reserves for the publish gate. */
435
+ const RELEASE_ENVIRONMENT = 'release';
436
+ /**
437
+ * What a job has to run for a merge to the default branch to reach a registry.
438
+ * `semantic-release` counts on its own — the shipped preset publishes with it.
439
+ */
440
+ const PUBLISH_COMMAND = /semantic-release|changesets\/action|(?:npm|pnpm|yarn)\s+publish/;
441
+ const GATE_HINT = 'Create a `release` environment with required reviewers (Settings → Environments) and add `environment: release` to the publishing job — a merge to the default branch then leaves the run `waiting` instead of publishing';
442
+ const ENV_HINT = 'Add `environment: release` to the publishing job, or delete the environment — whichever was meant. An environment nothing references still lists under Settings → Environments as though it gates something';
443
+ const unquote = (s) => s.replace(/^['"]|['"]$/g, '');
444
+ /**
445
+ * A workflow's `jobs:` blocks, keyed by job id.
446
+ *
447
+ * Hand-split rather than parsed: this package ships no YAML dependency, and the
448
+ * only question asked of the result is whether *one particular job* carries an
449
+ * `environment:` key. A whole-file grep would answer that wrong on any repo with
450
+ * a `github-pages` deploy job — which is the exact false negative this check
451
+ * exists to avoid. Jobs sit one indent level under `jobs:` and their keys one
452
+ * level below that, which holds for every workflow Actions accepts.
453
+ */
454
+ export function workflowJobs(yaml) {
455
+ const jobs = new Map();
456
+ const lines = yaml.split('\n');
457
+ const start = lines.findIndex((l) => /^jobs:\s*$/.test(l));
458
+ if (start === -1)
459
+ return jobs;
460
+ const indentOf = (l) => l.length - l.trimStart().length;
461
+ // The block runs until the next top-level key. A column-0 comment is not one —
462
+ // it ends nothing, so skipping it keeps a stray comment between `jobs:` and its
463
+ // first job from truncating the block and hiding every job below it.
464
+ let end = lines.length;
465
+ for (let i = start + 1; i < lines.length; i++) {
466
+ const l = lines[i] ?? '';
467
+ if (l.trim() !== '' && !l.trimStart().startsWith('#') && indentOf(l) === 0) {
468
+ end = i;
469
+ break;
470
+ }
471
+ }
472
+ const body = lines.slice(start + 1, end);
473
+ const first = body.find((l) => l.trim() !== '' && !l.trimStart().startsWith('#'));
474
+ if (first === undefined)
475
+ return jobs;
476
+ const jobIndent = indentOf(first);
477
+ let id = null;
478
+ let buf = [];
479
+ for (const line of body) {
480
+ const header = line.trim() !== '' && indentOf(line) === jobIndent
481
+ ? /^([\w.-]+):/.exec(line.trim())?.[1]
482
+ : undefined;
483
+ if (header) {
484
+ if (id)
485
+ jobs.set(id, buf.join('\n'));
486
+ id = header;
487
+ buf = [];
488
+ }
489
+ else if (id) {
490
+ buf.push(line);
491
+ }
492
+ }
493
+ if (id)
494
+ jobs.set(id, buf.join('\n'));
495
+ return jobs;
496
+ }
497
+ /**
498
+ * The environment a job runs in, in either form Actions accepts: the scalar
499
+ * `environment: release`, or a block whose `name:` names it. Null when the job
500
+ * declares none.
501
+ */
502
+ export function jobEnvironment(body) {
503
+ const inline = /^[ \t]*environment:[ \t]*(\S+)[ \t]*$/m.exec(body);
504
+ if (inline?.[1])
505
+ return unquote(inline[1]);
506
+ const at = body.search(/^[ \t]*environment:[ \t]*$/m);
507
+ if (at === -1)
508
+ return null;
509
+ // Step names are list items (`- name:`), so the first bare `name:` after the
510
+ // block opener is the environment's.
511
+ const name = /^[ \t]*name:[ \t]*(\S+)/m.exec(body.slice(at))?.[1];
512
+ return name ? unquote(name) : null;
513
+ }
514
+ /**
515
+ * A job body with whole-line comments dropped. Same reasoning as
516
+ * `hookHasUncommented` in base/checks.ts: a `#` line runs nothing, so matching
517
+ * it is a false positive. Observed on this repo's own ci.yml, where a `varcheck`
518
+ * job carrying `# npm publish uses OIDC trusted publishing` was reported as the
519
+ * publishing job. Comments are stripped rather than pattern-tested because
520
+ * `jobEnvironment` needs the same treatment — a commented-out `environment:`
521
+ * would otherwise read as a live gate, the exact inversion of this check.
522
+ */
523
+ function withoutComments(body) {
524
+ return body
525
+ .split('\n')
526
+ .filter((line) => !line.trimStart().startsWith('#'))
527
+ .join('\n');
528
+ }
529
+ /** `private: true` — nothing reaches a registry, so no gate is owed. */
530
+ async function isPrivatePackage(dir) {
531
+ try {
532
+ const pkg = await fs.readJson(path.join(dir, 'package.json'));
533
+ return pkg?.private === true;
534
+ }
535
+ catch {
536
+ return false;
537
+ }
538
+ }
539
+ /**
540
+ * The first workflow job that runs a publish command, or null if none does.
541
+ *
542
+ * `'skip'` when the directory exists but can't be read — a permission error or a
543
+ * broken symlink must not read as "nothing publishes", which would report the
544
+ * gate as `ok` on a repo whose workflows were never inspected. Same shape as
545
+ * `readEnvironments`: absent is an answer, unreadable is not.
546
+ */
547
+ async function findPublishJob(dir) {
548
+ const workflowsDir = path.join(dir, '.github', 'workflows');
549
+ if (!(await fs.pathExists(workflowsDir)))
550
+ return null;
551
+ try {
552
+ for (const f of (await fs.readdir(workflowsDir)).sort()) {
553
+ if (!/\.ya?ml$/.test(f))
554
+ continue;
555
+ const content = await fs.readFile(path.join(workflowsDir, f), 'utf-8');
556
+ if (!PUBLISH_COMMAND.test(content))
557
+ continue;
558
+ for (const [job, raw] of workflowJobs(content)) {
559
+ const body = withoutComments(raw);
560
+ if (PUBLISH_COMMAND.test(body)) {
561
+ return { file: f, job, environment: jobEnvironment(body) };
562
+ }
563
+ }
564
+ }
565
+ }
566
+ catch {
567
+ return 'skip';
568
+ }
569
+ return null;
570
+ }
571
+ async function readEnvironments(gh, nwo) {
572
+ const r = await gh(['api', `repos/${nwo}/environments`]);
573
+ // A repo with no environments can answer 404 — that's "none", not unreadable.
574
+ if (!r.ok)
575
+ return /404|not found/i.test(r.stderr) ? new Map() : 'skip';
576
+ try {
577
+ const parsed = JSON.parse(r.stdout);
578
+ const envs = new Map();
579
+ for (const e of parsed.environments ?? []) {
580
+ if (typeof e.name !== 'string')
581
+ continue;
582
+ envs.set(e.name, (e.protection_rules ?? []).some((p) => p.type === 'required_reviewers'));
583
+ }
584
+ return envs;
585
+ }
586
+ catch {
587
+ return 'skip';
588
+ }
589
+ }
590
+ /**
591
+ * The gap between merging the default branch and publishing to npm (#429). On
592
+ * the shipped semantic-release preset those are one event: nothing stands
593
+ * between a squash-merge and a new version on the registry.
594
+ *
595
+ * Two checks, because they fail differently and want different answers:
596
+ *
597
+ * - **Release gate** — the publishing job runs behind no environment at all, or
598
+ * behind one that isn't really a gate (absent, or with no required reviewers).
599
+ * - **Release environment** — the repo *has* a `release` environment and no job
600
+ * references it. That's the failure mode worth catching: an unreferenced
601
+ * environment gates nothing while reading as a gate in the GitHub UI.
602
+ *
603
+ * The two never both fire on one repo. "No `environment:` and no `release`
604
+ * environment" is the gate check's; "no `environment:` but a `release`
605
+ * environment exists" is the environment check's, and the gate check defers.
606
+ *
607
+ * A repo that publishes nothing is `ok` — not applicable, not drift. There is
608
+ * no fixer: creating the environment needs a `required_reviewers` list only a
609
+ * human can supply, so both hints describe the manual step.
610
+ */
611
+ async function checkReleaseGate(gh, nwo, dir) {
612
+ const publish = (await isPrivatePackage(dir)) ? null : await findPublishJob(dir);
613
+ if (publish === 'skip') {
614
+ const reason = 'could not read .github/workflows';
615
+ return [skip(RELEASE_GATE_CHECK, reason), skip(RELEASE_ENV_CHECK, reason)];
616
+ }
617
+ if (!publish) {
618
+ const detail = 'not applicable — no workflow job publishes to a registry';
619
+ return [
620
+ { check: RELEASE_GATE_CHECK, status: 'ok', detail },
621
+ { check: RELEASE_ENV_CHECK, status: 'ok', detail },
622
+ ];
623
+ }
624
+ const envs = await readEnvironments(gh, nwo);
625
+ if (envs === 'skip') {
626
+ const reason = 'could not read environments';
627
+ return [skip(RELEASE_GATE_CHECK, reason), skip(RELEASE_ENV_CHECK, reason)];
628
+ }
629
+ const named = publish.environment;
630
+ const where = `${publish.file} \`${publish.job}\``;
631
+ const hasRelease = envs.has(RELEASE_ENVIRONMENT);
632
+ let gate;
633
+ if (named === null) {
634
+ gate = hasRelease
635
+ ? {
636
+ check: RELEASE_GATE_CHECK,
637
+ status: 'ok',
638
+ detail: `${where} declares no environment — reported by the ${RELEASE_ENV_CHECK} check`,
639
+ }
640
+ : {
641
+ check: RELEASE_GATE_CHECK,
642
+ status: 'drift',
643
+ detail: `${where} publishes with no \`environment:\` and the repo has no \`${RELEASE_ENVIRONMENT}\` environment — anything that lands on the default branch publishes`,
644
+ hint: GATE_HINT,
645
+ };
646
+ }
647
+ else if (!envs.has(named)) {
648
+ gate = {
649
+ check: RELEASE_GATE_CHECK,
650
+ status: 'drift',
651
+ detail: `${where} names the \`${named}\` environment, which the repo does not have — Actions creates it unprotected on first run, so the publish is never held`,
652
+ hint: GATE_HINT,
653
+ };
654
+ }
655
+ else if (!envs.get(named)) {
656
+ gate = {
657
+ check: RELEASE_GATE_CHECK,
658
+ status: 'drift',
659
+ detail: `the \`${named}\` environment has no required_reviewers — ${where} passes straight through it`,
660
+ hint: GATE_HINT,
661
+ };
662
+ }
663
+ else {
664
+ gate = {
665
+ check: RELEASE_GATE_CHECK,
666
+ status: 'ok',
667
+ detail: `${where} runs behind the \`${named}\` environment (required reviewers)`,
668
+ };
669
+ }
670
+ let environment;
671
+ if (!hasRelease) {
672
+ environment = {
673
+ check: RELEASE_ENV_CHECK,
674
+ status: 'ok',
675
+ detail: `no \`${RELEASE_ENVIRONMENT}\` environment — nothing to reference`,
676
+ };
677
+ }
678
+ else if (named === RELEASE_ENVIRONMENT) {
679
+ environment = {
680
+ check: RELEASE_ENV_CHECK,
681
+ status: 'ok',
682
+ detail: `${where} references the \`${RELEASE_ENVIRONMENT}\` environment`,
683
+ };
684
+ }
685
+ else {
686
+ environment = {
687
+ check: RELEASE_ENV_CHECK,
688
+ status: 'drift',
689
+ detail: named
690
+ ? `the repo has a \`${RELEASE_ENVIRONMENT}\` environment but ${where} runs in \`${named}\` — \`${RELEASE_ENVIRONMENT}\` gates nothing while still reading as a gate in the GitHub UI`
691
+ : `the repo has a \`${RELEASE_ENVIRONMENT}\` environment but ${where} references no environment — it gates nothing while still reading as a gate in the GitHub UI`,
692
+ hint: ENV_HINT,
693
+ };
694
+ }
695
+ return [gate, environment];
696
+ }
428
697
  /** The branch-protection body PUT to the API — mirrors the doctor standard. */
429
698
  const PROTECTION_BODY = JSON.stringify({
430
699
  required_status_checks: { strict: false, contexts: GITHUB_STANDARD.requiredContexts },
@@ -0,0 +1,205 @@
1
+ import path from 'node:path';
2
+ import chalk from 'chalk';
3
+ import fs from 'fs-extra';
4
+ import { realGhExec } from './github-settings.js';
5
+ /**
6
+ * `ai-issue-loop` label hygiene (#446). Same class of GitHub-side drift as
7
+ * github-settings.ts and milestones.ts, on the same `gh` seam.
8
+ *
9
+ * The bug this exists for: the skill's bootstrap uses `gh label create`, which
10
+ * *errors as a no-op* when the label already exists. It can add a missing label
11
+ * but can never repair an existing one, so a hand-created `ai-ready` keeps
12
+ * whatever colour the web picker gave it forever. Measured across eight repos,
13
+ * six had `ai-ready` at `B60205` — byte-identical to `ai-blocked`, so "an agent
14
+ * should pick this up" and "an agent gave up" rendered the same. Repair here
15
+ * goes through `gh label edit`, which is the whole point of the fixer.
16
+ *
17
+ * This table is the single source of truth for the label set. The bootstrap
18
+ * block in skills/ai-issue-loop/SKILL.md is asserted against it in
19
+ * tests/base/labels.test.ts, so the two cannot drift apart.
20
+ */
21
+ const CHECK = 'AI loop labels';
22
+ export const LOOP_LABELS = [
23
+ {
24
+ name: 'holding',
25
+ color: '5319e7',
26
+ description: 'Gate/holding issue — human judgement, never auto-picked',
27
+ },
28
+ { name: 'ai-ready', color: '0e8a16', description: 'Eligible for an AI agent to implement' },
29
+ { name: 'ai-wip', color: 'fbca04', description: 'Claimed by an agent; worktree exists' },
30
+ { name: 'ai-blocked', color: 'b60205', description: 'Agent gave up; needs a human' },
31
+ { name: 'ai-review', color: '1d76db', description: 'PR awaiting agent review' },
32
+ { name: 'ai-reviewing-code', color: 'c5def5', description: 'code-reviewer claimed and running' },
33
+ { name: 'ai-reviewing-sec', color: 'c5def5', description: 'security-expert claimed and running' },
34
+ { name: 'ai-ok-code', color: '0e8a16', description: 'code-reviewer passed' },
35
+ { name: 'ai-ok-sec', color: '0e8a16', description: 'security-expert passed' },
36
+ { name: 'ai-changes', color: 'd93f0b', description: 'Reviewer requested changes' },
37
+ {
38
+ name: 'ai-notes',
39
+ color: 'fbca04',
40
+ description: 'Passed, but a reviewer left something to read before merging',
41
+ },
42
+ ];
43
+ /**
44
+ * How many of the set have to exist before this repo counts as running the
45
+ * loop. A repo with none has opted out, not drifted — creating eleven labels it
46
+ * will never use is the nag this threshold exists to prevent. One alone is the
47
+ * observed half-state (`cf-common` has only `ai-ready`, applied by hand), which
48
+ * is likewise not evidence the pipeline runs there.
49
+ */
50
+ const IN_USE_THRESHOLD = 2;
51
+ const skip = (reason) => ({
52
+ check: CHECK,
53
+ status: 'ok',
54
+ detail: `skipped — ${reason}`,
55
+ });
56
+ /**
57
+ * Case-insensitive, `#`-insensitive. The drift that started this was uppercase
58
+ * `B60205` against lowercase `b60205` — GitHub's colour picker writes uppercase,
59
+ * `gh` writes lowercase, and the two render identically. Comparing raw would
60
+ * report every hand-created label as drift forever and have the fixer PATCH a
61
+ * colour that was already correct.
62
+ */
63
+ const normalizeColor = (c) => c.trim().replace(/^#/, '').toLowerCase();
64
+ async function readLabels(gh) {
65
+ const r = await gh(['label', 'list', '--json', 'name,color,description', '--limit', '200']);
66
+ if (!r.ok)
67
+ return null;
68
+ try {
69
+ const parsed = JSON.parse(r.stdout);
70
+ if (!Array.isArray(parsed))
71
+ return null;
72
+ const byName = new Map();
73
+ for (const l of parsed) {
74
+ if (typeof l?.name !== 'string')
75
+ continue;
76
+ byName.set(l.name, {
77
+ name: l.name,
78
+ color: typeof l.color === 'string' ? l.color : '',
79
+ description: typeof l.description === 'string' ? l.description : '',
80
+ });
81
+ }
82
+ return byName;
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ export function classifyLabels(existing) {
89
+ const deltas = { present: [], missing: [], wrongColor: [], wrongDescription: [] };
90
+ for (const spec of LOOP_LABELS) {
91
+ const actual = existing.get(spec.name);
92
+ if (!actual) {
93
+ deltas.missing.push(spec);
94
+ continue;
95
+ }
96
+ deltas.present.push(spec);
97
+ if (normalizeColor(actual.color) !== normalizeColor(spec.color))
98
+ deltas.wrongColor.push({ spec, actual: normalizeColor(actual.color) });
99
+ if (actual.description.trim() !== spec.description)
100
+ deltas.wrongDescription.push(spec);
101
+ }
102
+ return deltas;
103
+ }
104
+ const names = (specs) => specs.map((s) => `\`${s.name}\``).join(', ');
105
+ export async function checkLoopLabels(dir, exec) {
106
+ // Cheap gate first: no .git → never spawn (keeps tmp-dir doctor runs offline).
107
+ if (!(await fs.pathExists(path.join(dir, '.git'))))
108
+ return skip('not a git repository');
109
+ const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
110
+ const existing = await readLabels(gh);
111
+ if (!existing)
112
+ return skip('could not read labels');
113
+ const { present, missing, wrongColor, wrongDescription } = classifyLabels(existing);
114
+ if (present.length < IN_USE_THRESHOLD)
115
+ return {
116
+ check: CHECK,
117
+ status: 'ok',
118
+ detail: 'not applicable — repo does not use the ai-issue-loop labels',
119
+ };
120
+ const deltas = [];
121
+ for (const { spec, actual } of wrongColor)
122
+ deltas.push(`\`${spec.name}\` is #${actual}, should be #${spec.color}`);
123
+ if (wrongDescription.length)
124
+ deltas.push(`wrong description: ${names(wrongDescription)}`);
125
+ if (missing.length)
126
+ deltas.push(`missing: ${names(missing)}`);
127
+ if (deltas.length)
128
+ return {
129
+ check: CHECK,
130
+ status: 'drift',
131
+ detail: deltas.join('; '),
132
+ hint: 'Run `npx @rtorcato/repo-tooling fix labels` to repair them with `gh label edit` — `gh label create` cannot change an existing label, which is how this drifted',
133
+ };
134
+ return {
135
+ check: CHECK,
136
+ status: 'ok',
137
+ detail: `${present.length} ai-issue-loop label(s) match spec`,
138
+ };
139
+ }
140
+ /**
141
+ * Repairs colour and description with `gh label edit`, and creates the labels
142
+ * the set is missing. Only on a repo already running the loop (the same
143
+ * `IN_USE_THRESHOLD` gate the check uses) — otherwise a plain `fix --yes` would
144
+ * push eleven labels into every repo it touches.
145
+ *
146
+ * Idempotent: an aligned repo is a no-op, and a label whose only difference is
147
+ * the hex case is not touched at all.
148
+ *
149
+ * Advisories go to `console.error`; stdout carries the `--json` payload (#357).
150
+ */
151
+ export async function applyLoopLabels(dir, exec) {
152
+ if (!(await fs.pathExists(path.join(dir, '.git')))) {
153
+ console.error(chalk.gray(' skipped — not a git repository'));
154
+ return [];
155
+ }
156
+ const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
157
+ const existing = await readLabels(gh);
158
+ if (!existing) {
159
+ console.error(chalk.gray(' skipped — could not read labels'));
160
+ return [];
161
+ }
162
+ const { present, missing, wrongColor, wrongDescription } = classifyLabels(existing);
163
+ if (present.length < IN_USE_THRESHOLD) {
164
+ console.error(chalk.gray(' skipped — repo does not use the ai-issue-loop labels'));
165
+ return [];
166
+ }
167
+ // One edit per drifted label, whichever field drifted — the API takes both.
168
+ const toEdit = new Set([...wrongColor.map((w) => w.spec), ...wrongDescription]);
169
+ const applied = [];
170
+ for (const spec of toEdit) {
171
+ // `edit`, not `create`: create errors as a no-op on an existing label, so a
172
+ // fixer built on it would silently repair nothing (#446).
173
+ const r = await gh([
174
+ 'label',
175
+ 'edit',
176
+ spec.name,
177
+ '--color',
178
+ spec.color,
179
+ '--description',
180
+ spec.description,
181
+ ]);
182
+ if (r.ok)
183
+ applied.push(`repaired label "${spec.name}"`);
184
+ else
185
+ console.error(chalk.yellow(` could not repair "${spec.name}": ${r.stderr.trim() || 'gh error'}`));
186
+ }
187
+ for (const spec of missing) {
188
+ const r = await gh([
189
+ 'label',
190
+ 'create',
191
+ spec.name,
192
+ '--color',
193
+ spec.color,
194
+ '--description',
195
+ spec.description,
196
+ ]);
197
+ if (r.ok)
198
+ applied.push(`created label "${spec.name}"`);
199
+ else
200
+ console.error(chalk.yellow(` could not create "${spec.name}": ${r.stderr.trim() || 'gh error'}`));
201
+ }
202
+ if (applied.length === 0)
203
+ console.error(chalk.gray(' labels already match spec'));
204
+ return applied;
205
+ }
@@ -13,8 +13,10 @@ import { SWIFT_GIT_HOOKS, runSwiftChecks } from '../../languages/swift/checks.js
13
13
  import { readSwiftPackage, renderSwiftWorkflow } from '../../languages/swift/ci.js';
14
14
  import { detectLanguage } from '../utils/detect-language.js';
15
15
  import { checkGitHubSettings } from '../../base/github-settings.js';
16
+ import { checkLoopLabels } from '../../base/labels.js';
16
17
  import { checkMilestones } from '../../base/milestones.js';
17
18
  import { checkGitIdentity } from '../../base/git-identity.js';
19
+ import { checkCopiedAssets } from '../utils/copied-assets.js';
18
20
  import { LOCKFILE_VERSION, readLockfile } from '../utils/lockfile.js';
19
21
  import { declinedInLock, getFixTargetForCheck } from './fix-targets.js';
20
22
  import { checkAiSetup, checkBrand, checkCodeowners, checkClaudeSkills, checkCodeQL, checkCommunityHealth, checkCoverageUpload, checkDependabot, checkEditorConfig, checkFile, checkGitHooks, checkGitHubActions, checkGitLabCI, checkNestedLanguages, checkPrePushHook, checkReadmeBadges, COMMITLINT_FILE_CHECK, } from '../../base/checks.js';
@@ -150,6 +152,9 @@ function demoteDeclined(results, lock) {
150
152
  async function runBaseChecks(dir, lock, opts) {
151
153
  const results = [];
152
154
  results.push(checkLockfile(lock));
155
+ // Any language can have copied presets (swiftlint, ruff, perlcriticrc), so
156
+ // this rides with the base suite rather than a module's (#428).
157
+ results.push(await checkCopiedAssets(dir));
153
158
  results.push(await checkNestedLanguages(dir, opts.language));
154
159
  results.push(await checkGitIdentity(dir));
155
160
  results.push(await checkEditorConfig(dir));
@@ -166,6 +171,8 @@ async function runBaseChecks(dir, lock, opts) {
166
171
  results.push(...(await checkGitHubSettings(dir)));
167
172
  // Milestone hygiene (#397) — same seam, same self-skip.
168
173
  results.push(await checkMilestones(dir));
174
+ // ai-issue-loop label colours/descriptions (#446) — same seam, same self-skip.
175
+ results.push(await checkLoopLabels(dir));
169
176
  results.push(await checkGitLabCI(dir));
170
177
  results.push(await checkCodeowners(dir));
171
178
  results.push(await checkCommunityHealth(dir));