@rtorcato/repo-tooling 3.20.0 → 3.21.1
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/README.md +11 -6
- package/dist/base/checks.js +37 -23
- package/dist/base/fixers.js +48 -24
- package/dist/base/github-settings.js +132 -6
- package/dist/cli/generators/claude-skills.js +6 -1
- package/package.json +1 -1
- package/skills/ai-issue/SKILL.md +74 -0
- package/skills/ai-issue-loop/SKILL.md +289 -46
- package/skills/ai-loop-status/SKILL.md +115 -0
- package/skills/ai-workflow/SKILL.md +293 -0
package/README.md
CHANGED
|
@@ -180,20 +180,22 @@ ln -sf ../../node_modules/@rtorcato/repo-tooling/tooling/claude/repo-tooling.md
|
|
|
180
180
|
### Use with Claude Code (plugin)
|
|
181
181
|
|
|
182
182
|
This repo is also a self-hosted Claude Code marketplace. Install the plugin to
|
|
183
|
-
get
|
|
184
|
-
`npm-publish` (never hand-cut a release)
|
|
185
|
-
issue → PR pipeline)
|
|
183
|
+
get six skills — `repo-tooling` (adopt/audit the presets via the CLI),
|
|
184
|
+
`npm-publish` (never hand-cut a release), `ai-issue-loop` (the label-driven
|
|
185
|
+
issue → PR pipeline), `ai-workflow` (burst the `ai-ready` queue in parallel
|
|
186
|
+
worktrees), `ai-issue` (file agent-executable issues) and `ai-loop-status`
|
|
187
|
+
(read-only pipeline status) — in any session:
|
|
186
188
|
|
|
187
189
|
```
|
|
188
190
|
/plugin marketplace add rtorcato/repo-tooling
|
|
189
191
|
/plugin install repo-tooling@repo-tooling
|
|
190
192
|
```
|
|
191
193
|
|
|
192
|
-
`ai
|
|
193
|
-
machine shares one copy:
|
|
194
|
+
The four `ai-*` skills also install on their own, user-globally, so every repo
|
|
195
|
+
on the machine shares one copy:
|
|
194
196
|
|
|
195
197
|
```bash
|
|
196
|
-
npx @rtorcato/repo-tooling fix claude-skills # → ~/.claude/skills/ai-issue-loop/SKILL.md
|
|
198
|
+
npx @rtorcato/repo-tooling fix claude-skills # → ~/.claude/skills/{ai-issue-loop,ai-workflow,ai-issue,ai-loop-status}/SKILL.md
|
|
197
199
|
```
|
|
198
200
|
|
|
199
201
|
It writes outside the repo, so it is opt-in: a bare `fix` skips it. See the
|
|
@@ -234,7 +236,10 @@ MIT — see [LICENSE](LICENSE).
|
|
|
234
236
|
Any agent that supports the [`skills`](https://www.npmjs.com/package/skills) CLI can install this repo's skills straight from GitHub — no clone, no package install:
|
|
235
237
|
|
|
236
238
|
```bash
|
|
239
|
+
npx skills add https://github.com/rtorcato/repo-tooling --skill 'ai-issue'
|
|
237
240
|
npx skills add https://github.com/rtorcato/repo-tooling --skill 'ai-issue-loop'
|
|
241
|
+
npx skills add https://github.com/rtorcato/repo-tooling --skill 'ai-loop-status'
|
|
242
|
+
npx skills add https://github.com/rtorcato/repo-tooling --skill 'ai-workflow'
|
|
238
243
|
npx skills add https://github.com/rtorcato/repo-tooling --skill 'npm-publish'
|
|
239
244
|
npx skills add https://github.com/rtorcato/repo-tooling --skill 'repo-tooling'
|
|
240
245
|
```
|
package/dist/base/checks.js
CHANGED
|
@@ -2,7 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
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
|
-
import { claudeSkillStatus,
|
|
5
|
+
import { claudeSkillStatus, SHIPPED_SKILLS, skillDiffCommand, } from '../cli/generators/claude-skills.js';
|
|
6
6
|
import { DEPENDABOT_CONFIG_PATHS, dependabotIgnoreRules } from '../cli/generators/security.js';
|
|
7
7
|
import { detectNestedLanguages } from '../cli/utils/detect-language.js';
|
|
8
8
|
/**
|
|
@@ -477,48 +477,62 @@ export async function checkAiSetup(dir) {
|
|
|
477
477
|
*/
|
|
478
478
|
export async function checkClaudeSkills(skillsDir) {
|
|
479
479
|
const check = 'Claude skills';
|
|
480
|
-
const hint = `Run \`npx @rtorcato/repo-tooling fix claude-skills\` to install the ${
|
|
481
|
-
const
|
|
482
|
-
|
|
480
|
+
const hint = `Run \`npx @rtorcato/repo-tooling fix claude-skills\` to install the ${SHIPPED_SKILLS.join(', ')} skills (writes outside the repo; opt-in, so \`fix\` alone skips it)`;
|
|
481
|
+
const statuses = [];
|
|
482
|
+
for (const name of SHIPPED_SKILLS)
|
|
483
|
+
statuses.push([name, await claudeSkillStatus(name, skillsDir)]);
|
|
484
|
+
if (statuses.every(([, s]) => s.file === null)) {
|
|
483
485
|
return {
|
|
484
486
|
check,
|
|
485
487
|
status: 'optional-missing',
|
|
486
|
-
detail:
|
|
487
|
-
? `${SHIPPED_SKILL} skill is not installed`
|
|
488
|
-
: `no ~/.claude/skills — the ${SHIPPED_SKILL} skill is not installed`,
|
|
488
|
+
detail: `no ~/.claude/skills — the ${SHIPPED_SKILLS.join(', ')} skills are not installed`,
|
|
489
489
|
hint,
|
|
490
490
|
};
|
|
491
491
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
492
|
+
const missing = statuses.filter(([, s]) => !s.installed).map(([name]) => name);
|
|
493
|
+
const behind = statuses.filter(([, s]) => s.installed && s.needsInstall);
|
|
494
|
+
if (missing.length > 0 || behind.length > 0) {
|
|
495
|
+
const parts = [
|
|
496
|
+
missing.length > 0 ? `not installed: ${missing.join(', ')}` : null,
|
|
497
|
+
...behind.map(([name, s]) => `${name} is at ${s.installedVersion ?? 'an unstamped version'}; this package ships ${s.shippedVersion}`),
|
|
498
|
+
].filter((p) => p !== null);
|
|
499
|
+
return { check, status: 'optional-missing', detail: parts.join('; '), hint };
|
|
499
500
|
}
|
|
500
501
|
// A local fork is `ok` for the same reason a modified copied asset is (#448):
|
|
501
502
|
// it is somebody's deliberate work, so it is named once and never nagged as
|
|
502
503
|
// fixable — pointing at a `fix` that would refuse is worse than saying nothing.
|
|
503
504
|
// `realFile` is set whenever `contentState` is — both mean something is
|
|
504
505
|
// installed. The extra test is TypeScript's, not a real condition.
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
506
|
+
const forks = statuses.filter(([, s]) => s.realFile && s.contentState && s.contentState !== 'pristine');
|
|
507
|
+
if (forks.length > 0) {
|
|
508
|
+
const detail = forks
|
|
509
|
+
.map(([name, s]) => {
|
|
510
|
+
const why = s.contentState === 'modified'
|
|
511
|
+
? `has local changes since ${s.installedVersion}`
|
|
512
|
+
: 'carries no content record, so a fork cannot be told from a stale copy';
|
|
513
|
+
return `${name} skill at ${s.file} ${why}; this package ships ${s.shippedVersion} and will not overwrite it`;
|
|
514
|
+
})
|
|
515
|
+
.join('; ');
|
|
516
|
+
// Name both paths: "diff it against the shipped copy" left the reader
|
|
517
|
+
// with nothing to diff, in the one case they most want to look (#484).
|
|
518
|
+
const diffs = forks
|
|
519
|
+
.map(([, s]) => s.realFile
|
|
520
|
+
? `\`${skillDiffCommand({ realFile: s.realFile, shippedFile: s.shippedFile })}\``
|
|
521
|
+
: null)
|
|
522
|
+
.filter((d) => d !== null)
|
|
523
|
+
.join(', ');
|
|
509
524
|
return {
|
|
510
525
|
check,
|
|
511
526
|
status: 'ok',
|
|
512
|
-
detail
|
|
513
|
-
|
|
514
|
-
// with nothing to diff, in the one case they most want to look (#484).
|
|
515
|
-
hint: `Diff it against the shipped copy — \`${skillDiffCommand({ realFile: status.realFile, shippedFile: status.shippedFile })}\` — then run \`npx @rtorcato/repo-tooling fix claude-skills --force-skills\` to take the shipped version`,
|
|
527
|
+
detail,
|
|
528
|
+
hint: `Diff against the shipped copy — ${diffs} — then run \`npx @rtorcato/repo-tooling fix claude-skills --force-skills\` to take the shipped version`,
|
|
516
529
|
};
|
|
517
530
|
}
|
|
531
|
+
const versions = new Set(statuses.map(([, s]) => s.installedVersion));
|
|
518
532
|
return {
|
|
519
533
|
check,
|
|
520
534
|
status: 'ok',
|
|
521
|
-
detail: `${
|
|
535
|
+
detail: `${SHIPPED_SKILLS.length} skills installed at ${[...versions].join(', ')}`,
|
|
522
536
|
};
|
|
523
537
|
}
|
|
524
538
|
/**
|
package/dist/base/fixers.js
CHANGED
|
@@ -16,7 +16,7 @@ import path from 'node:path';
|
|
|
16
16
|
import chalk from 'chalk';
|
|
17
17
|
import inquirer from 'inquirer';
|
|
18
18
|
import { installAgentRules, installAiSetup } from '../cli/generators/agent-rules.js';
|
|
19
|
-
import { installClaudeSkill, resolveSkillsDir,
|
|
19
|
+
import { installClaudeSkill, resolveSkillsDir, SHIPPED_SKILLS, skillDiffCommand, } from '../cli/generators/claude-skills.js';
|
|
20
20
|
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';
|
|
@@ -26,7 +26,7 @@ import { classifyCopiedAssets } from '../cli/utils/copied-assets.js';
|
|
|
26
26
|
import { copyPreset } from '../cli/utils/copy-preset.js';
|
|
27
27
|
import { detectLanguage } from '../cli/utils/detect-language.js';
|
|
28
28
|
import { resolveLanguageModule } from '../languages/registry.js';
|
|
29
|
-
import { applyGithubSettings } from './github-settings.js';
|
|
29
|
+
import { applyGithubSettings, applyReleaseEnvironment, RELEASE_ENV_CHECK, RELEASE_GATE_CHECK, } from './github-settings.js';
|
|
30
30
|
import { applyLoopLabels } from './labels.js';
|
|
31
31
|
import { closeCompletedMilestones } from './milestones.js';
|
|
32
32
|
/**
|
|
@@ -87,7 +87,7 @@ function describeSkillFork(result) {
|
|
|
87
87
|
? `its content has diverged from the ${result.installedVersion} release it was installed from`
|
|
88
88
|
: 'it carries no content record, so a local fork and a stale copy are indistinguishable';
|
|
89
89
|
return [
|
|
90
|
-
`skipped — ${
|
|
90
|
+
`skipped — ${result.name} was not overwritten with ${result.shippedVersion}: ${why}`,
|
|
91
91
|
` ${target}`,
|
|
92
92
|
` compare: ${skillDiffCommand(result)}`,
|
|
93
93
|
' overwrite anyway: fix claude-skills --force-skills',
|
|
@@ -210,6 +210,26 @@ export const BASE_FIXERS = [
|
|
|
210
210
|
return { filesWritten: await applyGithubSettings(targetDir) };
|
|
211
211
|
},
|
|
212
212
|
},
|
|
213
|
+
{
|
|
214
|
+
target: 'release-environment',
|
|
215
|
+
description: 'Create the `release` environment (authenticated user as required reviewer) and wire `environment: release` into the publishing job — the gate between merging and publishing (#429)',
|
|
216
|
+
appliesTo: [RELEASE_GATE_CHECK, RELEASE_ENV_CHECK],
|
|
217
|
+
outputs: [
|
|
218
|
+
'GitHub `release` environment (remote, via gh api)',
|
|
219
|
+
'.github/workflows/<the publishing workflow>',
|
|
220
|
+
],
|
|
221
|
+
// safe-add for the same shadow-run reason as github-settings, and
|
|
222
|
+
// explicitOnly because arming the gate changes what a merge *does*: the
|
|
223
|
+
// next release run sits `waiting` for an approval instead of publishing.
|
|
224
|
+
// That is the point, but it must be a chosen rollout per repo, not a side
|
|
225
|
+
// effect of a bare `fix`.
|
|
226
|
+
riskLevel: 'safe-add',
|
|
227
|
+
explicitOnly: true,
|
|
228
|
+
canFixDrift: true,
|
|
229
|
+
async run({ targetDir }) {
|
|
230
|
+
return { filesWritten: await applyReleaseEnvironment(targetDir) };
|
|
231
|
+
},
|
|
232
|
+
},
|
|
213
233
|
{
|
|
214
234
|
target: 'milestones',
|
|
215
235
|
description: 'Close 100%-complete open milestones on GitHub via gh api (mutates the remote repo, not files). Never deletes or creates one',
|
|
@@ -328,9 +348,9 @@ export const BASE_FIXERS = [
|
|
|
328
348
|
},
|
|
329
349
|
{
|
|
330
350
|
target: 'claude-skills',
|
|
331
|
-
description: `Install the ${
|
|
351
|
+
description: `Install the ${SHIPPED_SKILLS.join(', ')} Claude Code skills into the user-level skills dir (~/.claude/skills, or --skills-dir). Writes outside the repo`,
|
|
332
352
|
appliesTo: ['Claude skills'],
|
|
333
|
-
outputs:
|
|
353
|
+
outputs: SHIPPED_SKILLS.map((name) => `~/.claude/skills/${name}/SKILL.md`),
|
|
334
354
|
// safe-add is load-bearing for the same reason it is on github-settings:
|
|
335
355
|
// it exempts this fixer from the `--diff` shadow-run, which copies the repo
|
|
336
356
|
// to tmp and *executes* run() — here that would write to the real home dir
|
|
@@ -342,26 +362,30 @@ export const BASE_FIXERS = [
|
|
|
342
362
|
const dir = await resolveInstallDir(skillsDir, assumeYes);
|
|
343
363
|
if (!dir)
|
|
344
364
|
return { filesWritten: [] };
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
365
|
+
const filesWritten = [];
|
|
366
|
+
for (const name of SHIPPED_SKILLS) {
|
|
367
|
+
const result = await installClaudeSkill(dir, name, { force: forceSkills });
|
|
368
|
+
if (result.status === 'declined-downgrade') {
|
|
369
|
+
console.error(chalk.yellow(` skipped — ${result.file} is at ${result.installedVersion}, newer than the ${result.shippedVersion} this package ships`));
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (result.status === 'declined-fork') {
|
|
373
|
+
// Name `realFile`: through a stow symlink the overwrite would land in a
|
|
374
|
+
// *second* repo's working tree, and that is the path to look at (#480).
|
|
375
|
+
for (const line of describeSkillFork(result))
|
|
376
|
+
console.error(chalk.yellow(` ${line}`));
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (result.status === 'up-to-date')
|
|
380
|
+
continue;
|
|
381
|
+
// Report the resolved real path when the skill is a stow symlink: the bytes
|
|
382
|
+
// landed in a dotfiles checkout, and that is where the user has to commit them.
|
|
383
|
+
if (result.viaSymlink) {
|
|
384
|
+
console.error(chalk.dim(` wrote through a symlink — commit ${result.realFile}`));
|
|
385
|
+
}
|
|
386
|
+
filesWritten.push(result.realFile);
|
|
363
387
|
}
|
|
364
|
-
return { filesWritten
|
|
388
|
+
return { filesWritten };
|
|
365
389
|
},
|
|
366
390
|
},
|
|
367
391
|
{
|
|
@@ -51,8 +51,8 @@ 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
|
+
export const RELEASE_GATE_CHECK = 'Release gate';
|
|
55
|
+
export const RELEASE_ENV_CHECK = 'Release environment';
|
|
56
56
|
const CHECK_NAMES = [
|
|
57
57
|
'Branch protection',
|
|
58
58
|
'Merge settings',
|
|
@@ -438,7 +438,7 @@ const RELEASE_ENVIRONMENT = 'release';
|
|
|
438
438
|
* `semantic-release` counts on its own — the shipped preset publishes with it.
|
|
439
439
|
*/
|
|
440
440
|
const PUBLISH_COMMAND = /semantic-release|changesets\/action|(?:npm|pnpm|yarn)\s+publish/;
|
|
441
|
-
const GATE_HINT = '
|
|
441
|
+
const GATE_HINT = 'Run `npx @rtorcato/repo-tooling fix release-environment` to create the `release` environment with required reviewers (you) and add `environment: release` to the publishing job — a merge to the default branch then leaves the run `waiting` instead of publishing';
|
|
442
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
443
|
const unquote = (s) => s.replace(/^['"]|['"]$/g, '');
|
|
444
444
|
/**
|
|
@@ -604,9 +604,9 @@ async function readEnvironments(gh, nwo) {
|
|
|
604
604
|
* environment" is the gate check's; "no `environment:` but a `release`
|
|
605
605
|
* environment exists" is the environment check's, and the gate check defers.
|
|
606
606
|
*
|
|
607
|
-
* A repo that publishes nothing is `ok` — not applicable, not drift.
|
|
608
|
-
*
|
|
609
|
-
*
|
|
607
|
+
* A repo that publishes nothing is `ok` — not applicable, not drift. The fixer
|
|
608
|
+
* is `fix release-environment` (`applyReleaseEnvironment` below), which uses
|
|
609
|
+
* the authenticated user as the required reviewer.
|
|
610
610
|
*/
|
|
611
611
|
async function checkReleaseGate(gh, nwo, dir) {
|
|
612
612
|
const publish = (await isPrivatePackage(dir)) ? null : await findPublishJob(dir);
|
|
@@ -836,3 +836,129 @@ export async function applyGithubSettings(dir, exec) {
|
|
|
836
836
|
console.error(chalk.gray(' already configured — nothing to apply'));
|
|
837
837
|
return applied;
|
|
838
838
|
}
|
|
839
|
+
// --- Release environment scaffolding (#429) --------------------------------
|
|
840
|
+
/**
|
|
841
|
+
* Insert `environment: <env>` as the first key of job `jobId`. Line-based, same
|
|
842
|
+
* reasoning as `workflowJobs`: this package ships no YAML dependency, and jobs
|
|
843
|
+
* sit one indent level under `jobs:` with their keys one level below. Returns
|
|
844
|
+
* null when the job cannot be found. The caller only reaches this when
|
|
845
|
+
* `jobEnvironment()` was null, so it never doubles an existing key.
|
|
846
|
+
*/
|
|
847
|
+
export function addJobEnvironment(yaml, jobId, env) {
|
|
848
|
+
const lines = yaml.split('\n');
|
|
849
|
+
const start = lines.findIndex((l) => /^jobs:\s*$/.test(l));
|
|
850
|
+
if (start === -1)
|
|
851
|
+
return null;
|
|
852
|
+
const indentOf = (l) => l.length - l.trimStart().length;
|
|
853
|
+
// The job headers' indent — from the first real line after `jobs:`, exactly
|
|
854
|
+
// as workflowJobs derives it, so the two agree on what counts as a header.
|
|
855
|
+
let jobIndent = -1;
|
|
856
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
857
|
+
const line = lines[i] ?? '';
|
|
858
|
+
if (line.trim() === '' || line.trimStart().startsWith('#'))
|
|
859
|
+
continue;
|
|
860
|
+
if (indentOf(line) === 0)
|
|
861
|
+
return null;
|
|
862
|
+
jobIndent = indentOf(line);
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
if (jobIndent === -1)
|
|
866
|
+
return null;
|
|
867
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
868
|
+
const line = lines[i] ?? '';
|
|
869
|
+
if (line.trim() === '' || line.trimStart().startsWith('#'))
|
|
870
|
+
continue;
|
|
871
|
+
if (indentOf(line) === 0)
|
|
872
|
+
return null; // left the jobs block
|
|
873
|
+
if (indentOf(line) !== jobIndent || !line.trim().startsWith(`${jobId}:`))
|
|
874
|
+
continue;
|
|
875
|
+
// Key indent = the job's first real line, so the insert matches whatever
|
|
876
|
+
// indentation the file already uses.
|
|
877
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
878
|
+
const next = lines[j] ?? '';
|
|
879
|
+
if (next.trim() === '' || next.trimStart().startsWith('#'))
|
|
880
|
+
continue;
|
|
881
|
+
lines.splice(j, 0, `${' '.repeat(indentOf(next))}environment: ${env}`);
|
|
882
|
+
return lines.join('\n');
|
|
883
|
+
}
|
|
884
|
+
return null;
|
|
885
|
+
}
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Scaffold the release environment gate — the fixer half of #429 (the checks
|
|
890
|
+
* shipped with #449). Two writes, each skipped when already in place:
|
|
891
|
+
*
|
|
892
|
+
* 1. `PUT /repos/{nwo}/environments/release` with the **authenticated user** as
|
|
893
|
+
* the required reviewer. On a solo-maintained repo they are the only human
|
|
894
|
+
* there is; add or swap reviewers afterwards under Settings → Environments.
|
|
895
|
+
* An environment that already carries a `required_reviewers` rule is never
|
|
896
|
+
* touched — whoever set it up made a richer decision than this default.
|
|
897
|
+
* 2. `environment: release` on the publishing job, so the merge leaves the run
|
|
898
|
+
* `waiting` instead of publishing.
|
|
899
|
+
*
|
|
900
|
+
* A job already behind some *other* environment is a warning, not a rename —
|
|
901
|
+
* this fixer scaffolds the standard, it does not migrate a custom setup.
|
|
902
|
+
*/
|
|
903
|
+
export async function applyReleaseEnvironment(dir, exec) {
|
|
904
|
+
if (!(await fs.pathExists(path.join(dir, '.git')))) {
|
|
905
|
+
console.error(chalk.gray(' skipped — not a git repository'));
|
|
906
|
+
return [];
|
|
907
|
+
}
|
|
908
|
+
const gh = exec ?? ((args, stdin) => realGhExec(args, stdin, dir));
|
|
909
|
+
const probe = await probeRepo(gh);
|
|
910
|
+
if ('skip' in probe) {
|
|
911
|
+
console.error(chalk.gray(` skipped — ${probe.skip}`));
|
|
912
|
+
return [];
|
|
913
|
+
}
|
|
914
|
+
const nwo = probe.info.nwo;
|
|
915
|
+
const publish = (await isPrivatePackage(dir)) ? null : await findPublishJob(dir);
|
|
916
|
+
if (publish === 'skip') {
|
|
917
|
+
console.error(chalk.yellow(' skipped — could not read .github/workflows'));
|
|
918
|
+
return [];
|
|
919
|
+
}
|
|
920
|
+
if (!publish) {
|
|
921
|
+
console.error(chalk.gray(' skipped — no workflow job publishes to a registry'));
|
|
922
|
+
return [];
|
|
923
|
+
}
|
|
924
|
+
if (publish.environment !== null && publish.environment !== RELEASE_ENVIRONMENT) {
|
|
925
|
+
console.error(chalk.yellow(` skipped — ${publish.file} \`${publish.job}\` already runs behind \`${publish.environment}\`; not renaming it`));
|
|
926
|
+
return [];
|
|
927
|
+
}
|
|
928
|
+
const envs = await readEnvironments(gh, nwo);
|
|
929
|
+
if (envs === 'skip') {
|
|
930
|
+
console.error(chalk.yellow(' skipped — could not read environments'));
|
|
931
|
+
return [];
|
|
932
|
+
}
|
|
933
|
+
const applied = [];
|
|
934
|
+
if (envs.get(RELEASE_ENVIRONMENT) !== true) {
|
|
935
|
+
const user = await gh(['api', 'user', '--jq', '.id']);
|
|
936
|
+
const id = user.ok ? Number.parseInt(user.stdout.trim(), 10) : Number.NaN;
|
|
937
|
+
if (Number.isNaN(id)) {
|
|
938
|
+
console.error(chalk.yellow(` could not resolve the authenticated user for required_reviewers: ${user.stderr.trim() || 'gh error'}`));
|
|
939
|
+
return applied;
|
|
940
|
+
}
|
|
941
|
+
const label = `\`${RELEASE_ENVIRONMENT}\` environment with the authenticated user as required reviewer`;
|
|
942
|
+
const r = await gh(['api', '-X', 'PUT', `repos/${nwo}/environments/${RELEASE_ENVIRONMENT}`, '--input', '-'], JSON.stringify({ reviewers: [{ type: 'User', id }] }));
|
|
943
|
+
if (r.ok)
|
|
944
|
+
applied.push(label);
|
|
945
|
+
else {
|
|
946
|
+
console.error(chalk.yellow(` could not apply ${label}: ${r.stderr.trim() || 'gh error'}`));
|
|
947
|
+
return applied;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
if (publish.environment === null) {
|
|
951
|
+
const file = path.join(dir, '.github', 'workflows', publish.file);
|
|
952
|
+
const updated = addJobEnvironment(await fs.readFile(file, 'utf-8'), publish.job, RELEASE_ENVIRONMENT);
|
|
953
|
+
if (updated === null) {
|
|
954
|
+
console.error(chalk.yellow(` could not add \`environment:\` to ${publish.file} \`${publish.job}\` — add it by hand`));
|
|
955
|
+
}
|
|
956
|
+
else {
|
|
957
|
+
await fs.writeFile(file, updated);
|
|
958
|
+
applied.push(`environment: ${RELEASE_ENVIRONMENT} on ${publish.file} \`${publish.job}\``);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
if (applied.length === 0)
|
|
962
|
+
console.error(chalk.gray(' already configured — nothing to apply'));
|
|
963
|
+
return applied;
|
|
964
|
+
}
|
|
@@ -10,7 +10,12 @@ import path from 'node:path';
|
|
|
10
10
|
import fs from 'fs-extra';
|
|
11
11
|
import { getPackageRoot } from '../utils/copy-preset.js';
|
|
12
12
|
import { shellQuote } from '../utils/shell.js';
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Skills this package owns the content of and keeps up to date. The loop first —
|
|
15
|
+
* it is the pipeline; the other three are its drivers (burst, on-ramp, status).
|
|
16
|
+
*/
|
|
17
|
+
export const SHIPPED_SKILLS = ['ai-issue-loop', 'ai-workflow', 'ai-issue', 'ai-loop-status'];
|
|
18
|
+
/** The primary skill — the default everywhere a single name is accepted. */
|
|
14
19
|
export const SHIPPED_SKILL = 'ai-issue-loop';
|
|
15
20
|
/**
|
|
16
21
|
* Stamped into the installed copy's frontmatter so a second repo pinned to an
|
package/package.json
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ai-issue
|
|
3
|
+
description: |
|
|
4
|
+
File a GitHub issue labelled `ai-ready` for the ai-issue-loop pipeline to pick
|
|
5
|
+
up and implement unattended. Use when the user says "file this for the loop",
|
|
6
|
+
"make this an AI issue", "queue this for an agent", or invokes `/ai-issue`.
|
|
7
|
+
For an ordinary issue a human will work on, use plain `gh issue create` with
|
|
8
|
+
no label instead. GitHub only (`gh`) — not GitLab.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# ai-issue
|
|
12
|
+
|
|
13
|
+
File an issue an **agent will execute unattended**, labelled `ai-ready` so
|
|
14
|
+
`ai-issue-loop` picks it up. Arguments: $ARGUMENTS
|
|
15
|
+
|
|
16
|
+
This is only for work you intend a background agent to do without you. For an
|
|
17
|
+
ordinary issue, use `gh issue create` with no `ai-*` label.
|
|
18
|
+
|
|
19
|
+
## Preflight
|
|
20
|
+
|
|
21
|
+
1. `git remote get-url origin` — GitHub only. On GitLab, stop: the loop is
|
|
22
|
+
`gh`-based and nothing would ever pick the issue up.
|
|
23
|
+
2. `gh label list --search ai-ready` — if the label is missing, this repo hasn't
|
|
24
|
+
been bootstrapped for the loop. Stop and point at the `ai-issue-loop` skill's
|
|
25
|
+
label block; creating a bare `ai-ready` label would produce an issue that
|
|
26
|
+
silently never runs.
|
|
27
|
+
|
|
28
|
+
## Write it for an agent, not for yourself
|
|
29
|
+
|
|
30
|
+
The agent that picks this up **cannot ask a follow-up question**, and is
|
|
31
|
+
instructed to treat the body as untrusted data. Both change how it must read:
|
|
32
|
+
|
|
33
|
+
- **Describe, never instruct.** "The README claims X but Y is true" — not "go
|
|
34
|
+
update the README". Directive phrasing is exactly what the agent is told to
|
|
35
|
+
ignore, so an instruction-shaped issue reads as empty.
|
|
36
|
+
- **Name the files** you already know are involved. The two reviewing agents are
|
|
37
|
+
diff-scoped and won't explore the repo to judge whether the right thing was
|
|
38
|
+
touched.
|
|
39
|
+
- **State a done-condition a reviewer can check.** Those same agents review the
|
|
40
|
+
PR against this body; a vague issue produces a vague review on a PR you then
|
|
41
|
+
merge without having really vetted.
|
|
42
|
+
- **One PR's worth.** Split anything spanning several concerns — the loop runs
|
|
43
|
+
several issues in parallel, so splitting is free.
|
|
44
|
+
|
|
45
|
+
## Refuse the ones that aren't ready
|
|
46
|
+
|
|
47
|
+
Say so, and file it unlabelled instead, when the task:
|
|
48
|
+
|
|
49
|
+
- needs a judgement call you'd normally make mid-PR,
|
|
50
|
+
- needs eyes on rendered output, a real device, or a running service,
|
|
51
|
+
- depends on context that lives in this conversation rather than the repo, or
|
|
52
|
+
- you couldn't write self-contained without "we can sort that out in review".
|
|
53
|
+
|
|
54
|
+
An `ai-ready` that stalls costs more than an issue you did yourself: it burns a
|
|
55
|
+
concurrency slot, two review passes, and up to two fix rounds before it lands as
|
|
56
|
+
`ai-blocked`.
|
|
57
|
+
|
|
58
|
+
## Create
|
|
59
|
+
|
|
60
|
+
Draft title and body from `$ARGUMENTS` plus the conversation. The body opens
|
|
61
|
+
with a line saying an agent wrote it — everything you post appears under the
|
|
62
|
+
owner's own account:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
gh issue create --label ai-ready --title "TITLE" --body "$(cat <<'EOF'
|
|
66
|
+
🤖 *Filed by Claude (AI agent) on the owner's behalf.*
|
|
67
|
+
|
|
68
|
+
BODY
|
|
69
|
+
EOF
|
|
70
|
+
)"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Print the URL. Note that nothing happens until a tick runs — `/ai-issue-loop`
|
|
74
|
+
manually, or a recurring schedule if one is active.
|