@rtorcato/repo-tooling 3.19.2 → 3.21.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/README.md +3 -3
- package/dist/base/fixers.js +21 -1
- package/dist/base/github-settings.js +132 -6
- package/dist/cli/generators/claude-skills.js +1 -9
- package/dist/cli/generators/skills-install.js +20 -3
- package/dist/cli/utils/shell.js +13 -0
- package/package.json +1 -1
- package/skills/ai-issue-loop/SKILL.md +142 -6
package/README.md
CHANGED
|
@@ -234,8 +234,8 @@ MIT — see [LICENSE](LICENSE).
|
|
|
234
234
|
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
235
|
|
|
236
236
|
```bash
|
|
237
|
-
npx skills add https://github.com/rtorcato/repo-tooling --skill ai-issue-loop
|
|
238
|
-
npx skills add https://github.com/rtorcato/repo-tooling --skill npm-publish
|
|
239
|
-
npx skills add https://github.com/rtorcato/repo-tooling --skill repo-tooling
|
|
237
|
+
npx skills add https://github.com/rtorcato/repo-tooling --skill 'ai-issue-loop'
|
|
238
|
+
npx skills add https://github.com/rtorcato/repo-tooling --skill 'npm-publish'
|
|
239
|
+
npx skills add https://github.com/rtorcato/repo-tooling --skill 'repo-tooling'
|
|
240
240
|
```
|
|
241
241
|
<!-- js-tooling:skills:end -->
|
package/dist/base/fixers.js
CHANGED
|
@@ -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
|
/**
|
|
@@ -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',
|
|
@@ -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
|
+
}
|
|
@@ -9,6 +9,7 @@ import os from 'node:os';
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import fs from 'fs-extra';
|
|
11
11
|
import { getPackageRoot } from '../utils/copy-preset.js';
|
|
12
|
+
import { shellQuote } from '../utils/shell.js';
|
|
12
13
|
/** Skills this package owns the content of and keeps up to date. */
|
|
13
14
|
export const SHIPPED_SKILL = 'ai-issue-loop';
|
|
14
15
|
/**
|
|
@@ -129,15 +130,6 @@ export async function readShippedSkill(name = SHIPPED_SKILL) {
|
|
|
129
130
|
const pkg = await fs.readJson(path.join(root, 'package.json'));
|
|
130
131
|
return { content, version: String(pkg.version), file };
|
|
131
132
|
}
|
|
132
|
-
/**
|
|
133
|
-
* POSIX single-quote escaping — the only form correct for arbitrary bytes.
|
|
134
|
-
* Inside single quotes every character is literal, so `'` is the sole one
|
|
135
|
-
* needing care: end the quote, escape it, start a new one. Double quotes are
|
|
136
|
-
* not a substitute; `$(...)`, backticks and `\` all still expand inside them.
|
|
137
|
-
*/
|
|
138
|
-
function shellQuote(value) {
|
|
139
|
-
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
140
|
-
}
|
|
141
133
|
/**
|
|
142
134
|
* The command a human runs to see what their fork changed, before deciding
|
|
143
135
|
* whether to take the shipped copy. One builder, because `doctor` and `fix`
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// repo (its skills dir + package.json `repository`) so it works for any consumer.
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import fs from 'fs-extra';
|
|
6
|
+
import { shellQuote } from '../utils/shell.js';
|
|
6
7
|
import { upsertBlock } from './agent-rules.js';
|
|
7
8
|
import { parseRepository } from './badges.js';
|
|
8
9
|
export const SKILLS_DOCS_START = '<!-- js-tooling:skills:start -->';
|
|
@@ -25,12 +26,18 @@ export async function findSkills(targetDir) {
|
|
|
25
26
|
/**
|
|
26
27
|
* Build the README section body (inner content, no delimiters) listing one
|
|
27
28
|
* `npx skills add` command per skill. Returns '' when there are no skills.
|
|
29
|
+
*
|
|
30
|
+
* The skill name is a directory basename — `findSkills` takes whatever is under
|
|
31
|
+
* `skills/`, and nothing constrains it — so it is quoted (#498). `owner`/`repo`
|
|
32
|
+
* are not: `parseRepository` already matches them against `[\w.-]+`. Unlike the
|
|
33
|
+
* doctor hint of #493 this line is committed to a README and rendered on GitHub
|
|
34
|
+
* and npm, where it gets pasted by people who did not create the directory.
|
|
28
35
|
*/
|
|
29
36
|
export function buildSkillsInstallBody(owner, repo, skills) {
|
|
30
37
|
if (skills.length === 0)
|
|
31
38
|
return '';
|
|
32
39
|
const commands = skills
|
|
33
|
-
.map((s) => `npx skills add https://github.com/${owner}/${repo} --skill ${s}`)
|
|
40
|
+
.map((s) => `npx skills add https://github.com/${owner}/${repo} --skill ${shellQuote(s)}`)
|
|
34
41
|
.join('\n');
|
|
35
42
|
const plural = skills.length > 1 ? 's' : '';
|
|
36
43
|
return [
|
|
@@ -38,11 +45,21 @@ export function buildSkillsInstallBody(owner, repo, skills) {
|
|
|
38
45
|
'',
|
|
39
46
|
`Any agent that supports the [\`skills\`](https://www.npmjs.com/package/skills) CLI can install this repo's skill${plural} straight from GitHub — no clone, no package install:`,
|
|
40
47
|
'',
|
|
41
|
-
|
|
48
|
+
`${fenceFor(commands)}bash`,
|
|
42
49
|
commands,
|
|
43
|
-
|
|
50
|
+
fenceFor(commands),
|
|
44
51
|
].join('\n');
|
|
45
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* A fence longer than any backtick run in the content, per CommonMark. Quoting
|
|
55
|
+
* cannot help here: a name may contain backticks *and* a newline, which puts
|
|
56
|
+
* arbitrary text at the start of a line inside the block and would otherwise
|
|
57
|
+
* close it early, mangling the rendered README.
|
|
58
|
+
*/
|
|
59
|
+
function fenceFor(content) {
|
|
60
|
+
const longestRun = Math.max(0, ...[...content.matchAll(/`+/g)].map((m) => m[0].length));
|
|
61
|
+
return '`'.repeat(Math.max(3, longestRun + 1));
|
|
62
|
+
}
|
|
46
63
|
/**
|
|
47
64
|
* Add or refresh a "Install the skill" block in README.md. No-op (returns null)
|
|
48
65
|
* when the repo ships no skills or package.json has no GitHub `repository` to
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POSIX single-quote escaping — the only form correct for arbitrary bytes.
|
|
3
|
+
* Inside single quotes every character is literal, so `'` is the sole one
|
|
4
|
+
* needing care: end the quote, escape it, start a new one. Double quotes are
|
|
5
|
+
* not a substitute; `$(...)`, backticks and `\` all still expand inside them.
|
|
6
|
+
*
|
|
7
|
+
* One escaper for every generator that emits a copy-pasteable command line
|
|
8
|
+
* (#498) — two independently-built ones drift, which is the argument #492 made
|
|
9
|
+
* for a single `skillDiffCommand`.
|
|
10
|
+
*/
|
|
11
|
+
export function shellQuote(value) {
|
|
12
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -200,6 +200,44 @@ pass.** A session can be pinned to a worktree, so the orchestrator can find itse
|
|
|
200
200
|
inside one it did not choose. `--git-common-dir` resolves to the main checkout's
|
|
201
201
|
`.git` from anywhere, including a worktree, so `ROOT` is correct either way.
|
|
202
202
|
|
|
203
|
+
**Check the main checkout is not bare before anything else uses `ROOT`.** It has gone
|
|
204
|
+
`core.bare = true` on its own, repeatedly — four times in one session, some occurrences
|
|
205
|
+
immediately after a `worktree remove` and some with nothing removed at all. The trigger
|
|
206
|
+
is unidentified, so this is detection and repair only:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
if [ "$(git -C "$ROOT" rev-parse --is-inside-work-tree 2>/dev/null)" != true ] && [ -d "$ROOT/.git" ]; then
|
|
210
|
+
echo "⚠ main checkout bare at $(date -u +%FT%TZ) — repairing"
|
|
211
|
+
git -C "$ROOT" config core.bare false || {
|
|
212
|
+
echo "⚠ repair FAILED — main checkout still bare"; exit 1; }
|
|
213
|
+
fi
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**It corrupts commits — this is not a cosmetic error message.** A bare main checkout
|
|
217
|
+
wipes a worktree's index while every file sits untouched on disk, and the next commit
|
|
218
|
+
faithfully records the whole repository as deleted. PR #500 died that way: a diff of
|
|
219
|
+
`0 additions, 67703 deletions` across 359 files, not one of which had moved.
|
|
220
|
+
|
|
221
|
+
**Test stdout, not the exit code.** `rev-parse --is-inside-work-tree` exits `0` either
|
|
222
|
+
way and only *prints* the answer, so an exit-code probe is dead code. Verified on git
|
|
223
|
+
2.55.0:
|
|
224
|
+
|
|
225
|
+
| repo state | `--is-inside-work-tree` | `.git` |
|
|
226
|
+
|---|---|---|
|
|
227
|
+
| healthy checkout | `true`, exit 0 | directory |
|
|
228
|
+
| **wrongly bare** | `false`, **exit 0** | directory |
|
|
229
|
+
| genuinely bare | `false`, exit 0 | absent |
|
|
230
|
+
| linked worktree | `true`, exit 0 | file |
|
|
231
|
+
|
|
232
|
+
**`.git` must be a directory before repairing.** A genuinely bare repo prints `false`
|
|
233
|
+
too, and nothing else separates the two — this skill ships to users' `~/.claude/skills/`,
|
|
234
|
+
where "repairing" someone's real bare clone is the damage rather than the fix. The same
|
|
235
|
+
check skips a linked worktree, whose `.git` is a file.
|
|
236
|
+
|
|
237
|
+
**Fail loudly.** The repair writes `$ROOT/.git/config`, which a restrictive sandbox
|
|
238
|
+
refuses with `error: could not lock config file .git/config: Operation not permitted` —
|
|
239
|
+
observed. Aborting beats reporting a healthy repo while it stays broken.
|
|
240
|
+
|
|
203
241
|
Never use a relative path like `ai-*`. From inside a worktree it matches nothing, and
|
|
204
242
|
the failure is **silent**: Pass 2 concludes there is nothing to clean, every worktree
|
|
205
243
|
survives, `ai-wip` is never cleared, and slots leak until the loop reports `idle`
|
|
@@ -473,6 +511,23 @@ and say in the comment that you re-queued it, that you deviated, and why. Re-add
|
|
|
473
511
|
issue out of the queue silently, which is the worse failure. `ai-blocked` means *a
|
|
474
512
|
human must look*; do not spend it on a claim you already understand.
|
|
475
513
|
|
|
514
|
+
**Then re-check `core.bare`** — the same probe as Pass 0, against the same `ROOT`:
|
|
515
|
+
|
|
516
|
+
```bash
|
|
517
|
+
if [ "$(git -C "$ROOT" rev-parse --is-inside-work-tree 2>/dev/null)" != true ] && [ -d "$ROOT/.git" ]; then
|
|
518
|
+
echo "⚠ main checkout bare at $(date -u +%FT%TZ) — repairing"
|
|
519
|
+
git -C "$ROOT" config core.bare false || {
|
|
520
|
+
echo "⚠ repair FAILED — main checkout still bare"; exit 1; }
|
|
521
|
+
fi
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
This is the last pass that *removes* worktrees, not the tick's last touch on the main
|
|
525
|
+
checkout — Pass 4 still runs `git -C "$ROOT" worktree add` against it. That is exactly
|
|
526
|
+
why the re-check belongs here: it catches a flip after this pass's removals and before
|
|
527
|
+
Pass 4 branches every new worktree off a broken `ROOT`. Keep the timestamp in both log
|
|
528
|
+
lines; which pass emitted one, and when, is the only instrumentation likely to pin the
|
|
529
|
+
trigger down.
|
|
530
|
+
|
|
476
531
|
### Pass 3 — review
|
|
477
532
|
|
|
478
533
|
**PRs labelled `ai-review`.** For each, spawn *in background* only the reviewers
|
|
@@ -482,6 +537,66 @@ whose pass-label is missing — `code-reviewer` if no `ai-ok-code`,
|
|
|
482
537
|
carries `ai-reviewing-sec`. Both can run concurrently; launch them in a single
|
|
483
538
|
message.
|
|
484
539
|
|
|
540
|
+
**Before spawning either, check whether it already posted.** A missing verdict
|
|
541
|
+
label does not mean the review is missing: on #497 both reviewers posted
|
|
542
|
+
complete reviews and then went idle, labelling nothing. Every review comment
|
|
543
|
+
carries a hidden verdict marker, so read that back instead of re-spawning over a
|
|
544
|
+
review that already exists — `<ARM>` is `code` or `sec`:
|
|
545
|
+
|
|
546
|
+
```bash
|
|
547
|
+
VERDICT=$(gh api "repos/$OWNER_REPO/pulls/<N>/reviews" --paginate --slurp \
|
|
548
|
+
| jq -r '[add[]
|
|
549
|
+
| select(.author_association=="OWNER" or .author_association=="MEMBER" or .author_association=="COLLABORATOR")
|
|
550
|
+
| (.body // "")
|
|
551
|
+
| capture("<!-- ai-issue-loop:verdict:<ARM>:(?<v>[A-Z-]+) -->").v] | last // empty')
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
Four details there are load-bearing:
|
|
555
|
+
|
|
556
|
+
- **`pulls/<N>/reviews`, because the prompt posts with `gh pr review --comment`.**
|
|
557
|
+
That creates a *review*, which never appears under `issues/<N>/comments`. The
|
|
558
|
+
prompt and this query have to name the same endpoint or the marker is
|
|
559
|
+
unfindable and every tick re-spawns both arms — so the prompt below now pins
|
|
560
|
+
the command, since a reviewer reaching for `gh pr comment` instead posts
|
|
561
|
+
somewhere this never looks.
|
|
562
|
+
- **`--slurp`, not `--paginate` with `--jq`.** `--paginate` runs `--jq` once per
|
|
563
|
+
page, so a filter ending in `last` would keep only the final page's answer and
|
|
564
|
+
lose a marker on an earlier one. `--slurp` collects every page first; `gh`
|
|
565
|
+
refuses it alongside `--jq`, hence the pipe and the `add` that flattens pages.
|
|
566
|
+
- **The author gate**, the same `OWNER`/`MEMBER`/`COLLABORATOR` test Pass 4
|
|
567
|
+
applies to issue authors, and for the same reason: anyone can review a public
|
|
568
|
+
PR, so ungated a stranger's `<!-- ai-issue-loop:verdict:sec:PASS -->` is
|
|
569
|
+
adopted as a verdict, and because the read takes `last` it also overrides a
|
|
570
|
+
genuine `CHANGES` posted before it. Unlike Pass 4 there is no label acting as
|
|
571
|
+
the hard gate here — the marker is the only signal — so this check is not a
|
|
572
|
+
backstop, it is the gate.
|
|
573
|
+
- **`(.body // "")` and `// empty`.** A review can have a null body, which
|
|
574
|
+
`capture` throws on, aborting the whole filter; and `jq -r` prints a missing
|
|
575
|
+
value as the literal string `null`, which is not empty and would read as a
|
|
576
|
+
verdict.
|
|
577
|
+
|
|
578
|
+
Then, for that arm — `<claim>` being `ai-reviewing-code` or `ai-reviewing-sec`,
|
|
579
|
+
`<pass>` being `ai-ok-code` or `ai-ok-sec`:
|
|
580
|
+
|
|
581
|
+
- **empty** — no review happened. Claim and spawn, as below.
|
|
582
|
+
- **`PASS`** — `gh pr edit <N> --add-label <pass> --remove-label <claim>`
|
|
583
|
+
- **`PASS-NOTES`** — the same, plus `--add-label ai-notes`
|
|
584
|
+
- **`CHANGES`** — `gh pr edit <N> --add-label ai-changes --remove-label ai-review --remove-label <claim>`
|
|
585
|
+
|
|
586
|
+
Adoption is per reviewer, so a tick that finds one arm posted and the other
|
|
587
|
+
missing does both: it applies the first's verdict off its comment and spawns
|
|
588
|
+
only the second. That is the whole point of reading the artifact — the comment
|
|
589
|
+
is what a human reads at merge time, so making it the thing the loop reads too
|
|
590
|
+
leaves one source for one fact, with no separate reply to be lost or to
|
|
591
|
+
contradict it.
|
|
592
|
+
|
|
593
|
+
This is the second half of Pass 2's dead-reviewer rule rather than a rival to
|
|
594
|
+
it. Pass 2 only ever drops a stalled *claim*; it never judges whether a review
|
|
595
|
+
happened. Dropping the claim is what makes an arm eligible here, and this lookup
|
|
596
|
+
is what then decides between adopting and re-spawning. An agent that died before
|
|
597
|
+
posting leaves no marker and so re-spawns, which is what that rule always
|
|
598
|
+
intended; one that died after posting is now recovered instead of duplicated.
|
|
599
|
+
|
|
485
600
|
**Claim first, then spawn** — the same shape Pass 4 uses before picking up an
|
|
486
601
|
issue. Apply the label immediately before the spawn, not after:
|
|
487
602
|
|
|
@@ -523,10 +638,25 @@ Reviewer prompt template:
|
|
|
523
638
|
> PR:
|
|
524
639
|
> `gh pr review <N> --comment --body "..."`
|
|
525
640
|
>
|
|
526
|
-
>
|
|
527
|
-
>
|
|
641
|
+
> **That exact command, not `gh pr comment`.** The two write to different
|
|
642
|
+
> endpoints, and Pass 3 reads your verdict back from the reviews one; a body
|
|
643
|
+
> posted the other way is invisible to it and gets you re-spawned.
|
|
644
|
+
>
|
|
645
|
+
> The body **must** begin with a hidden verdict marker, then the header line,
|
|
646
|
+
> then a blank line — you authenticate as the repo owner, so without the header
|
|
647
|
+
> the review reads as a human's:
|
|
648
|
+
>
|
|
649
|
+
> ```markdown
|
|
650
|
+
> <!-- ai-issue-loop:verdict:<code|sec>:<PASS|PASS-NOTES|CHANGES> -->
|
|
651
|
+
> 🤖 *Automated review — \`<your agent type>\` via ai-issue-loop.*
|
|
652
|
+
> ```
|
|
528
653
|
>
|
|
529
|
-
>
|
|
654
|
+
> `code` for `code-reviewer`, `sec` for `security-expert` — the same arm as your
|
|
655
|
+
> labels. The verdict is `CHANGES` if you are about to apply `ai-changes`,
|
|
656
|
+
> `PASS-NOTES` if a pass plus `ai-notes`, `PASS` for a pass alone; it must agree
|
|
657
|
+
> with the labels you apply below. The marker renders as nothing, and it is what
|
|
658
|
+
> lets a later tick read your verdict back off this comment if your run dies
|
|
659
|
+
> between posting and labelling — so post it even when the answer is `Nothing.`
|
|
530
660
|
>
|
|
531
661
|
> The body **must end** with this section, as its last thing:
|
|
532
662
|
>
|
|
@@ -603,8 +733,10 @@ Reviewer prompt template:
|
|
|
603
733
|
> a Dependabot PR it suppresses auto-merge outright. Use `ai-changes` only when
|
|
604
734
|
> you can name a concrete change an agent could make.
|
|
605
735
|
>
|
|
606
|
-
> Say nothing else and
|
|
607
|
-
>
|
|
736
|
+
> Say nothing else, and **do not restate your verdict in your reply** — the
|
|
737
|
+
> marker in the posted comment is the only place it is read from, so a reply that
|
|
738
|
+
> disagreed with it would be a second source for one fact. One line back to the
|
|
739
|
+
> orchestrator is plenty; the comment body is capped separately, above.
|
|
608
740
|
|
|
609
741
|
**Dependabot PRs use a different prompt** — the one above would burn the tick on a
|
|
610
742
|
lockfile. `js-common` #148 bumps 20 packages and its *entire* diff is
|
|
@@ -671,7 +803,11 @@ package/from/to table survives because it sits at the top; classify from that.
|
|
|
671
803
|
>
|
|
672
804
|
> State in your comment which rule fired, name the packages that tripped it, and say
|
|
673
805
|
> whether the body was truncated so the reader knows what you could and couldn't see.
|
|
674
|
-
> Same `🤖 *Automated review — …*`
|
|
806
|
+
> Same `<!-- ai-issue-loop:verdict:… -->` marker and `🤖 *Automated review — …*`
|
|
807
|
+
> header line opening the body — `PASS-NOTES` when you apply `ai-notes`, `PASS`
|
|
808
|
+
> otherwise, never `CHANGES` on this arm — and posted the same way, with
|
|
809
|
+
> `gh pr review <N> --comment` rather than `gh pr comment`, or Pass 3 cannot read
|
|
810
|
+
> the marker back. Same closing `### Before merging`
|
|
675
811
|
> section, same ≤600-character cap and no-negative-findings rule on the body, and same
|
|
676
812
|
> one-verdict-label rule as above — **including clearing your
|
|
677
813
|
> `<ai-reviewing-code|ai-reviewing-sec>` claim label in the same `gh pr edit`**.
|