@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 +2 -0
- package/dist/base/checks.js +24 -5
- package/dist/base/fixers.js +44 -1
- package/dist/base/github-settings.js +269 -0
- package/dist/base/labels.js +205 -0
- package/dist/cli/commands/doctor.js +7 -0
- package/dist/cli/commands/fix-targets.js +2 -0
- package/dist/cli/commands/fix.js +35 -9
- package/dist/cli/generators/security.js +90 -9
- package/dist/cli/utils/copied-assets.js +79 -0
- package/dist/cli/utils/copy-preset.js +23 -0
- package/dist/cli/utils/lockfile.js +26 -3
- package/package.json +2 -1
- package/skills/ai-issue-loop/SKILL.md +71 -28
- package/tooling/docusaurus/docs-helpers.mjs +109 -0
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
|
package/dist/base/checks.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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:
|
|
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
|
}
|
package/dist/base/fixers.js
CHANGED
|
@@ -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));
|