@rtorcato/repo-tooling 3.45.0 → 4.0.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 +3 -25
- package/README.md +13 -16
- package/dist/base/checks.js +0 -132
- package/dist/base/fixers.js +0 -123
- package/dist/base/github-settings.js +0 -13
- package/dist/cli/commands/doctor.js +3 -23
- package/dist/cli/commands/fix-targets.js +4 -7
- package/dist/cli/commands/fix.js +1 -9
- package/dist/cli/index.js +14 -102
- package/dist/cli/utils/lockfile.js +5 -7
- package/package.json +1 -1
- package/dist/base/agent-user.js +0 -68
- package/dist/base/ai-loop-identity.js +0 -80
- package/dist/base/labels.js +0 -220
- package/dist/cli/commands/loop-cleanup.js +0 -100
- package/dist/cli/commands/loop-env.js +0 -76
- package/dist/cli/commands/loop-guard.js +0 -232
- package/dist/cli/commands/loop-marker.js +0 -169
- package/dist/cli/commands/loop-reap.js +0 -202
- package/dist/cli/commands/loop-tick.js +0 -349
- package/dist/cli/commands/loop-worktree.js +0 -132
- package/dist/cli/generators/claude-skills.js +0 -240
- package/skills/ai-issue/SKILL.md +0 -74
- package/skills/ai-issue-loop/SKILL.md +0 -597
- package/skills/ai-loop-status/SKILL.md +0 -122
- package/skills/ai-workflow/SKILL.md +0 -335
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import chalk from 'chalk';
|
|
3
|
-
import { realGitExec } from '../../base/git-identity.js';
|
|
4
|
-
import { realGhExec } from '../../base/github-settings.js';
|
|
5
|
-
import { defaultWorktreeRoot, findLive } from './loop-guard.js';
|
|
6
|
-
/** How far back to look for the squash subject. */
|
|
7
|
-
const SQUASH_WINDOW = 200;
|
|
8
|
-
async function findPr(gh, heads) {
|
|
9
|
-
for (const head of heads) {
|
|
10
|
-
const r = await gh(['pr', 'list', '--head', head, '--state', 'all', '--json', 'number,state']);
|
|
11
|
-
if (!r.ok)
|
|
12
|
-
continue;
|
|
13
|
-
const [pr] = JSON.parse(r.stdout || '[]');
|
|
14
|
-
if (pr)
|
|
15
|
-
return pr;
|
|
16
|
-
}
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
19
|
-
export async function runLoopCleanup(options = {}) {
|
|
20
|
-
const root = path.resolve(options.root ?? process.cwd());
|
|
21
|
-
const worktreeRoot = options.worktreeRoot
|
|
22
|
-
? path.resolve(options.worktreeRoot)
|
|
23
|
-
: defaultWorktreeRoot(root);
|
|
24
|
-
// Fetch and `worktree remove` outlast the 5s probe timeout on a real repo.
|
|
25
|
-
const git = options.git ?? ((args) => realGitExec(args, root, 120_000));
|
|
26
|
-
const gh = options.gh ?? ((args, stdin) => realGhExec(args, stdin, root));
|
|
27
|
-
const dirs = await findLive([worktreeRoot, path.join(root, '.claude', 'worktrees')]);
|
|
28
|
-
let subjects = null;
|
|
29
|
-
const worktrees = [];
|
|
30
|
-
for (const dir of dirs) {
|
|
31
|
-
const slug = path.basename(dir);
|
|
32
|
-
const issue = Number(slug.match(/^ai-(\d+)-/)?.[1]) || null;
|
|
33
|
-
// `worktree-` is the prefix EnterWorktree once gave branches — legacy.
|
|
34
|
-
const heads = [slug, `worktree-${slug}`];
|
|
35
|
-
const branch = (await git(['branch', '--list', ...heads, '--format=%(refname:short)']))
|
|
36
|
-
?.split('\n')[0]
|
|
37
|
-
?.trim() || null;
|
|
38
|
-
const pr = await findPr(gh, heads);
|
|
39
|
-
const entry = {
|
|
40
|
-
path: dir,
|
|
41
|
-
issue,
|
|
42
|
-
branch,
|
|
43
|
-
pr: pr?.number ?? null,
|
|
44
|
-
prState: pr?.state ?? null,
|
|
45
|
-
action: 'kept',
|
|
46
|
-
reason: '',
|
|
47
|
-
};
|
|
48
|
-
worktrees.push(entry);
|
|
49
|
-
if (!pr || pr.state === 'OPEN') {
|
|
50
|
-
entry.reason = pr ? 'PR still open' : 'no PR';
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
if (pr.state === 'MERGED') {
|
|
54
|
-
if (subjects === null) {
|
|
55
|
-
// Best-effort: a failed fetch leaves origin/main stale, which can
|
|
56
|
-
// only keep a worktree, never remove one wrongly.
|
|
57
|
-
await git(['fetch', '--prune', 'origin']);
|
|
58
|
-
const log = await git(['log', 'origin/main', `-n${SQUASH_WINDOW}`, '--format=%s']);
|
|
59
|
-
subjects = log ? log.split('\n') : [];
|
|
60
|
-
}
|
|
61
|
-
if (!subjects.some((s) => s.trimEnd().endsWith(`(#${pr.number})`))) {
|
|
62
|
-
entry.reason = `squash for #${pr.number} not on origin/main`;
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
if ((await git(['worktree', 'remove', '--force', dir])) === null) {
|
|
67
|
-
entry.action = 'remove-failed';
|
|
68
|
-
entry.reason = 'git worktree remove failed';
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
if (branch)
|
|
72
|
-
await git(['branch', '-D', branch]);
|
|
73
|
-
entry.action = 'removed';
|
|
74
|
-
entry.reason = pr.state === 'MERGED' ? `#${pr.number} landed on main` : `#${pr.number} closed`;
|
|
75
|
-
}
|
|
76
|
-
return {
|
|
77
|
-
root,
|
|
78
|
-
worktreeRoot,
|
|
79
|
-
removed: worktrees.some((w) => w.action === 'removed'),
|
|
80
|
-
worktrees,
|
|
81
|
-
exitCode: worktrees.some((w) => w.action === 'remove-failed') ? 1 : 0,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
export async function loopCleanupCommand(options) {
|
|
85
|
-
const result = await runLoopCleanup(options);
|
|
86
|
-
if (options.json) {
|
|
87
|
-
console.log(JSON.stringify(result, null, 2));
|
|
88
|
-
}
|
|
89
|
-
else {
|
|
90
|
-
console.log();
|
|
91
|
-
if (result.worktrees.length === 0)
|
|
92
|
-
console.log(chalk.gray(' no ai-* worktrees'));
|
|
93
|
-
for (const w of result.worktrees) {
|
|
94
|
-
const line = `${w.action.padEnd(13)} ${path.basename(w.path)} — ${w.reason}`;
|
|
95
|
-
console.log(` ${w.action === 'remove-failed' ? chalk.yellow(line) : chalk.gray(line)}`);
|
|
96
|
-
}
|
|
97
|
-
console.log();
|
|
98
|
-
}
|
|
99
|
-
process.exitCode = result.exitCode;
|
|
100
|
-
}
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import { LOGIN } from '../../base/agent-user.js';
|
|
3
|
-
import { realGitExec } from '../../base/git-identity.js';
|
|
4
|
-
import { realGhExec } from '../../base/github-settings.js';
|
|
5
|
-
import { configuredAgentUser, defaultWorktreeRoot } from './loop-guard.js';
|
|
6
|
-
export async function ghOut(gh, args) {
|
|
7
|
-
const r = await gh(args);
|
|
8
|
-
return r.ok ? r.stdout.trim() : '';
|
|
9
|
-
}
|
|
10
|
-
export async function resolveLoopEnv(options = {}) {
|
|
11
|
-
const dir = path.resolve(options.dir ?? process.cwd());
|
|
12
|
-
const git = options.git ?? ((args) => realGitExec(args, dir));
|
|
13
|
-
const gh = options.gh ?? ((args, stdin) => realGhExec(args, stdin, dir));
|
|
14
|
-
const env = options.env ?? process.env;
|
|
15
|
-
const warnings = [];
|
|
16
|
-
// Absolute, so the common dir is `<main checkout>/.git` from anywhere.
|
|
17
|
-
const common = (await git(['rev-parse', '--path-format=absolute', '--git-common-dir']))?.trim();
|
|
18
|
-
const root = common ? path.resolve(common, '..') : '';
|
|
19
|
-
if (!root)
|
|
20
|
-
warnings.push('not a git repository');
|
|
21
|
-
const worktreeRoot = root ? defaultWorktreeRoot(root) : '';
|
|
22
|
-
const ownerRepo = await ghOut(gh, [
|
|
23
|
-
'repo',
|
|
24
|
-
'view',
|
|
25
|
-
'--json',
|
|
26
|
-
'nameWithOwner',
|
|
27
|
-
'--jq',
|
|
28
|
-
'.nameWithOwner',
|
|
29
|
-
]);
|
|
30
|
-
if (!ownerRepo)
|
|
31
|
-
warnings.push('could not resolve the GitHub repo from the working directory');
|
|
32
|
-
let agentUser = env.AI_LOOP_AGENT?.trim() || (root ? await configuredAgentUser(root) : '') || '';
|
|
33
|
-
// LOGIN is the injection boundary — the login goes into an API path.
|
|
34
|
-
if (agentUser &&
|
|
35
|
-
(!ownerRepo ||
|
|
36
|
-
!LOGIN.test(agentUser) ||
|
|
37
|
-
!(await gh(['api', `repos/${ownerRepo}/assignees/${agentUser}`, '--silent'])).ok)) {
|
|
38
|
-
warnings.push(`agentUser '${agentUser}' is not an assignable collaborator — assigning nothing`);
|
|
39
|
-
agentUser = '';
|
|
40
|
-
}
|
|
41
|
-
const humanUser = ownerRepo
|
|
42
|
-
? await ghOut(gh, [
|
|
43
|
-
'api',
|
|
44
|
-
`repos/${ownerRepo}`,
|
|
45
|
-
'--jq',
|
|
46
|
-
'if .owner.type == "User" then .owner.login else "" end',
|
|
47
|
-
])
|
|
48
|
-
: '';
|
|
49
|
-
const me = await ghOut(gh, ['api', 'user', '--jq', '.login']);
|
|
50
|
-
return { root, worktreeRoot, ownerRepo, agentUser, humanUser, me, warnings };
|
|
51
|
-
}
|
|
52
|
-
const VARS = [
|
|
53
|
-
['ROOT', 'root'],
|
|
54
|
-
['WT_ROOT', 'worktreeRoot'],
|
|
55
|
-
['OWNER_REPO', 'ownerRepo'],
|
|
56
|
-
['AGENT_USER', 'agentUser'],
|
|
57
|
-
['HUMAN_USER', 'humanUser'],
|
|
58
|
-
['ME', 'me'],
|
|
59
|
-
];
|
|
60
|
-
/** `KEY='value'` lines, single-quoted so `eval "$(… loop env)"` is safe. */
|
|
61
|
-
export function toShell(env) {
|
|
62
|
-
return VARS.map(([name, key]) => `${name}='${env[key].replaceAll("'", `'\\''`)}'`).join('\n');
|
|
63
|
-
}
|
|
64
|
-
export async function loopEnvCommand(options) {
|
|
65
|
-
const env = await resolveLoopEnv(options);
|
|
66
|
-
if (options.json) {
|
|
67
|
-
console.log(JSON.stringify(env, null, 2));
|
|
68
|
-
}
|
|
69
|
-
else {
|
|
70
|
-
for (const w of env.warnings)
|
|
71
|
-
console.error(`⚠ ${w}`);
|
|
72
|
-
console.log(toShell(env));
|
|
73
|
-
}
|
|
74
|
-
// Nothing downstream works without both — the skill bails on either.
|
|
75
|
-
process.exitCode = env.root && env.ownerRepo ? 0 : 1;
|
|
76
|
-
}
|
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import chalk from 'chalk';
|
|
4
|
-
import fs from 'fs-extra';
|
|
5
|
-
import { realGitExec } from '../../base/git-identity.js';
|
|
6
|
-
import { realGhExec } from '../../base/github-settings.js';
|
|
7
|
-
/**
|
|
8
|
-
* The invariant table from the skill, verified on git 2.55.0:
|
|
9
|
-
*
|
|
10
|
-
* | repo state | `--is-inside-work-tree` | `.git` |
|
|
11
|
-
* |---|---|---|
|
|
12
|
-
* | healthy checkout | `true`, exit 0 | directory |
|
|
13
|
-
* | **wrongly bare** | `false`, **exit 0** | directory |
|
|
14
|
-
* | genuinely bare | `false`, exit 0 | absent |
|
|
15
|
-
* | linked worktree | `true`, exit 0 | file |
|
|
16
|
-
*
|
|
17
|
-
* Two things it encodes. **Stdout, not the exit code** — `rev-parse
|
|
18
|
-
* --is-inside-work-tree` exits `0` either way and only *prints* the answer, so
|
|
19
|
-
* an exit-code probe is dead code (`insideWorkTree` is `null` only when git
|
|
20
|
-
* itself failed, i.e. there is no repo here). And **`.git` must be a directory
|
|
21
|
-
* before repairing** — a genuinely bare repo prints `false` too, and nothing
|
|
22
|
-
* else separates the two. This ships to consumers' machines, where "repairing"
|
|
23
|
-
* someone's real bare clone is the damage rather than the fix.
|
|
24
|
-
*/
|
|
25
|
-
export function classifyRoot(insideWorkTree, gitEntry) {
|
|
26
|
-
if (insideWorkTree === null)
|
|
27
|
-
return 'not-a-repo';
|
|
28
|
-
if (insideWorkTree.trim() === 'true')
|
|
29
|
-
return 'work-tree';
|
|
30
|
-
if (gitEntry === 'directory')
|
|
31
|
-
return 'wrongly-bare';
|
|
32
|
-
// A linked worktree's `.git` is a file; its main checkout is where a repair
|
|
33
|
-
// belongs, and that is not the path we were handed.
|
|
34
|
-
if (gitEntry === 'file')
|
|
35
|
-
return 'linked-worktree';
|
|
36
|
-
return 'genuinely-bare';
|
|
37
|
-
}
|
|
38
|
-
const LOCKFILE = '.repo-tooling.json';
|
|
39
|
-
/**
|
|
40
|
-
* `rules.aiLoop.agentUser`, with the flat pre-v4 fallback — the same pair the
|
|
41
|
-
* skill's `jq` reads. Read raw rather than through `readLockfile`, whose parser
|
|
42
|
-
* requires a `record.config`: a hand-written rules-only lockfile is exactly the
|
|
43
|
-
* file this has to see.
|
|
44
|
-
*/
|
|
45
|
-
export async function configuredAgentUser(root) {
|
|
46
|
-
const raw = await fs.readJson(path.join(root, LOCKFILE)).catch(() => null);
|
|
47
|
-
const user = raw?.rules?.aiLoop?.agentUser ?? raw?.aiLoop?.agentUser;
|
|
48
|
-
return typeof user === 'string' && user.trim() !== '' ? user.trim() : undefined;
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* Declared intent that is not met is a misconfiguration, not a degraded mode —
|
|
52
|
-
* so this halts, unlike the assignability check in `base/agent-user.ts`, which
|
|
53
|
-
* warns and carries on. That check can never catch this: `agentUser` is
|
|
54
|
-
* assignable regardless of who is calling.
|
|
55
|
-
*/
|
|
56
|
-
export async function checkAgentIdentity(configured, gh) {
|
|
57
|
-
if (!configured) {
|
|
58
|
-
return {
|
|
59
|
-
verdict: 'not-configured',
|
|
60
|
-
message: `no aiLoop.agentUser in ${LOCKFILE} — identity check skipped`,
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
const r = await gh(['api', 'user', '--jq', '.login']);
|
|
64
|
-
const effective = r.ok ? r.stdout.trim() : '';
|
|
65
|
-
// GitHub logins are case-insensitive, so a case difference is one account.
|
|
66
|
-
if (effective !== '' && effective.toLowerCase() === configured.toLowerCase()) {
|
|
67
|
-
return {
|
|
68
|
-
verdict: 'match',
|
|
69
|
-
message: `gh is authenticated as ${effective} — the configured agent account`,
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
return {
|
|
73
|
-
verdict: 'mismatch',
|
|
74
|
-
message: `⚠ agentUser is ${configured} but gh authenticates as ${effective || '(gh could not say — unauthenticated or missing)'} — the tick would commit, push and review as the wrong account. Run \`npx @rtorcato/repo-tooling fix ai-loop-identity\` in this checkout, then relaunch the Claude session`,
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* `--frozen-lockfile` forbids re-resolution, so neither `pnpm-lock.yaml` nor a
|
|
79
|
-
* `pnpm-workspace.yaml` carve-out moves as a side effect of a cleanup.
|
|
80
|
-
* `--config.confirmModulesPurge=false` gets past
|
|
81
|
-
* `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY` — which is why a bare `pnpm
|
|
82
|
-
* install` cannot repair this.
|
|
83
|
-
*/
|
|
84
|
-
export const REBUILD_ARGS = [
|
|
85
|
-
'install',
|
|
86
|
-
'--frozen-lockfile',
|
|
87
|
-
'--config.confirmModulesPurge=false',
|
|
88
|
-
];
|
|
89
|
-
const realInstall = (cwd) => new Promise((resolve) => {
|
|
90
|
-
// stdout stays clean for the `--json` payload; pnpm's diagnostics are
|
|
91
|
-
// still visible on stderr.
|
|
92
|
-
const child = spawn('pnpm', [...REBUILD_ARGS], {
|
|
93
|
-
cwd,
|
|
94
|
-
stdio: ['ignore', 'ignore', 'inherit'],
|
|
95
|
-
});
|
|
96
|
-
child.on('close', (code) => resolve(code === 0));
|
|
97
|
-
child.on('error', () => resolve(false));
|
|
98
|
-
});
|
|
99
|
-
async function gitEntryKind(root) {
|
|
100
|
-
// lstat, not stat: the file/directory distinction is the whole discriminator.
|
|
101
|
-
try {
|
|
102
|
-
const stat = await fs.lstat(path.join(root, '.git'));
|
|
103
|
-
return stat.isDirectory() ? 'directory' : 'file';
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
106
|
-
return 'absent';
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
/** `ai-*` directories one level down, in either place worktrees are kept. */
|
|
110
|
-
export async function findLive(dirs) {
|
|
111
|
-
const live = [];
|
|
112
|
-
for (const dir of dirs) {
|
|
113
|
-
// A missing worktree root is the normal case, not an error.
|
|
114
|
-
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
115
|
-
for (const entry of entries) {
|
|
116
|
-
if (entry.isDirectory() && entry.name.startsWith('ai-'))
|
|
117
|
-
live.push(path.join(dir, entry.name));
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
return live;
|
|
121
|
-
}
|
|
122
|
-
/** The sibling layout the skill uses: `<parent>/<name>-worktrees`. */
|
|
123
|
-
export function defaultWorktreeRoot(root) {
|
|
124
|
-
return path.join(path.dirname(root), `${path.basename(root)}-worktrees`);
|
|
125
|
-
}
|
|
126
|
-
const stamp = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
127
|
-
const UNUSABLE = {
|
|
128
|
-
'genuinely-bare': 'main checkout is a genuinely bare repository (.git absent) — refusing to flip core.bare on a real bare clone',
|
|
129
|
-
'linked-worktree': '--root points at a linked worktree (.git is a file), not the main checkout — repair belongs on the main checkout',
|
|
130
|
-
'not-a-repo': 'not a git repository',
|
|
131
|
-
};
|
|
132
|
-
/**
|
|
133
|
-
* One tick's worth of guarding, as data. The command wrapper prints it and
|
|
134
|
-
* sets the exit code; tests call this directly.
|
|
135
|
-
*/
|
|
136
|
-
export async function runLoopGuard(options = {}) {
|
|
137
|
-
const root = path.resolve(options.root ?? process.cwd());
|
|
138
|
-
const worktreeRoot = options.worktreeRoot
|
|
139
|
-
? path.resolve(options.worktreeRoot)
|
|
140
|
-
: defaultWorktreeRoot(root);
|
|
141
|
-
const git = options.git ?? ((args) => realGitExec(args, root));
|
|
142
|
-
const gh = options.gh ?? ((args, stdin) => realGhExec(args, stdin, root));
|
|
143
|
-
const install = options.install ?? realInstall;
|
|
144
|
-
const messages = [];
|
|
145
|
-
const state = classifyRoot(await git(['rev-parse', '--is-inside-work-tree']), await gitEntryKind(root));
|
|
146
|
-
let bare = 'healthy';
|
|
147
|
-
let exitCode = 0;
|
|
148
|
-
if (state === 'wrongly-bare') {
|
|
149
|
-
messages.push(`⚠ main checkout bare at ${stamp()} — repairing`);
|
|
150
|
-
// Writes $ROOT/.git/config, which a restrictive sandbox refuses with
|
|
151
|
-
// `error: could not lock config file .git/config`. Aborting beats
|
|
152
|
-
// reporting a healthy repo while it stays broken.
|
|
153
|
-
if ((await git(['config', 'core.bare', 'false'])) === null) {
|
|
154
|
-
bare = 'repair-failed';
|
|
155
|
-
exitCode = 1;
|
|
156
|
-
messages.push('⚠ repair FAILED — main checkout still bare');
|
|
157
|
-
}
|
|
158
|
-
else {
|
|
159
|
-
bare = 'repaired';
|
|
160
|
-
messages.push('main checkout repaired — core.bare is false');
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
else if (state !== 'work-tree') {
|
|
164
|
-
bare = 'unrepairable';
|
|
165
|
-
exitCode = 2;
|
|
166
|
-
messages.push(`⚠ ${UNUSABLE[state]}`);
|
|
167
|
-
}
|
|
168
|
-
else {
|
|
169
|
-
messages.push('main checkout is a work tree');
|
|
170
|
-
}
|
|
171
|
-
const { verdict: identity, message: identityMessage } = await checkAgentIdentity(await configuredAgentUser(root), gh);
|
|
172
|
-
messages.push(identityMessage);
|
|
173
|
-
// A failed repair (1) is the more specific verdict, so it keeps the code.
|
|
174
|
-
if (identity === 'mismatch' && exitCode === 0)
|
|
175
|
-
exitCode = 2;
|
|
176
|
-
const live = await findLive([worktreeRoot, path.join(root, '.claude', 'worktrees')]);
|
|
177
|
-
const rebuild = await decideRebuild({ root, removed: options.removed === true, exitCode, live });
|
|
178
|
-
let outcome = rebuild;
|
|
179
|
-
if (rebuild === 'deferred') {
|
|
180
|
-
messages.push(`rebuild deferred — ${live.length} worktree(s) still live`);
|
|
181
|
-
}
|
|
182
|
-
else if (rebuild === 'rebuilt') {
|
|
183
|
-
messages.push('rebuilding the main checkout’s node_modules');
|
|
184
|
-
if (!(await install(root))) {
|
|
185
|
-
outcome = 'rebuild-failed';
|
|
186
|
-
// Not fatal: a broken .bin cannot corrupt a commit the way a bare
|
|
187
|
-
// checkout does. Loud, though — a silent skip is the breakage this
|
|
188
|
-
// guard exists to end.
|
|
189
|
-
messages.push('⚠ node_modules rebuild FAILED — main checkout may be unbuildable');
|
|
190
|
-
}
|
|
191
|
-
else {
|
|
192
|
-
messages.push('node_modules rebuilt');
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
return { root, worktreeRoot, state, bare, identity, rebuild: outcome, live, exitCode, messages };
|
|
196
|
-
}
|
|
197
|
-
/**
|
|
198
|
-
* The three load-bearing conditions, kept separate from the run so a test can
|
|
199
|
-
* assert them without a pnpm install:
|
|
200
|
-
*
|
|
201
|
-
* - **`removed`** — set by every removal path, merged-PR cleanup *and* stall
|
|
202
|
-
* reaping. A reaped worktree needs this most: its agent died mid-command.
|
|
203
|
-
* - **`pnpm-lock.yaml`** — non-pnpm repos skip the whole thing.
|
|
204
|
-
* - **no live worktrees** — the rebuild *purges* the shared modules dir, which
|
|
205
|
-
* would be yanked out from under any agent still running in a surviving
|
|
206
|
-
* worktree. Deferring costs a broken main checkout until the last worktree
|
|
207
|
-
* clears; not deferring costs a live implementer run.
|
|
208
|
-
*/
|
|
209
|
-
export async function decideRebuild(input) {
|
|
210
|
-
if (!input.removed)
|
|
211
|
-
return 'not-requested';
|
|
212
|
-
// Nothing runs against a root we are about to halt the tick over.
|
|
213
|
-
if (input.exitCode !== 0)
|
|
214
|
-
return 'skipped-root-unusable';
|
|
215
|
-
if (!(await fs.pathExists(path.join(input.root, 'pnpm-lock.yaml'))))
|
|
216
|
-
return 'skipped-no-lockfile';
|
|
217
|
-
return input.live.length > 0 ? 'deferred' : 'rebuilt';
|
|
218
|
-
}
|
|
219
|
-
export async function loopGuardCommand(options) {
|
|
220
|
-
const result = await runLoopGuard(options);
|
|
221
|
-
if (options.json) {
|
|
222
|
-
console.log(JSON.stringify(result, null, 2));
|
|
223
|
-
}
|
|
224
|
-
else {
|
|
225
|
-
console.log();
|
|
226
|
-
for (const line of result.messages) {
|
|
227
|
-
console.log(` ${line.startsWith('⚠') ? chalk.yellow(line) : chalk.gray(line)}`);
|
|
228
|
-
}
|
|
229
|
-
console.log();
|
|
230
|
-
}
|
|
231
|
-
process.exitCode = result.exitCode;
|
|
232
|
-
}
|
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import fs from 'fs-extra';
|
|
3
|
-
import { realGhExec } from '../../base/github-settings.js';
|
|
4
|
-
import { ghOut } from './loop-env.js';
|
|
5
|
-
/**
|
|
6
|
-
* `repo-tooling loop comment` and `loop verdict` — the ai-issue-loop skill's
|
|
7
|
-
* two hidden-marker protocols, moved out of prose-with-jq (#619):
|
|
8
|
-
*
|
|
9
|
-
* - **comment** upserts the one `<!-- ai-issue-loop:decision -->` comment on a
|
|
10
|
-
* PR (Pass 1), so a PR left over a weekend gets one edited comment, not one
|
|
11
|
-
* per tick.
|
|
12
|
-
* - **verdict** reads a reviewer arm's `<!-- ai-issue-loop:verdict:… -->`
|
|
13
|
-
* marker back off the PR's reviews (Pass 3), so a reviewer that posted and
|
|
14
|
-
* died before labelling is adopted rather than re-spawned.
|
|
15
|
-
*
|
|
16
|
-
* Both gate on the loop's own login — anyone can comment on or review a public
|
|
17
|
-
* PR, and a stranger's marker would otherwise own the slot or override a
|
|
18
|
-
* genuine `CHANGES`. Login, not `author_association`, which wobbles with repo
|
|
19
|
-
* ownership. Bodies travel to gh as JSON on stdin, never through a shell.
|
|
20
|
-
*/
|
|
21
|
-
export const DECISION_MARKER = '<!-- ai-issue-loop:decision -->';
|
|
22
|
-
async function resolveTarget(dir, gh) {
|
|
23
|
-
const cwd = path.resolve(dir ?? process.cwd());
|
|
24
|
-
const exec = gh ?? ((args, stdin) => realGhExec(args, stdin, cwd));
|
|
25
|
-
const ownerRepo = await ghOut(exec, [
|
|
26
|
-
'repo',
|
|
27
|
-
'view',
|
|
28
|
-
'--json',
|
|
29
|
-
'nameWithOwner',
|
|
30
|
-
'--jq',
|
|
31
|
-
'.nameWithOwner',
|
|
32
|
-
]);
|
|
33
|
-
if (!ownerRepo)
|
|
34
|
-
return 'could not resolve the GitHub repo from the working directory';
|
|
35
|
-
const me = await ghOut(exec, ['api', 'user', '--jq', '.login']);
|
|
36
|
-
if (!me)
|
|
37
|
-
return 'could not resolve the gh login';
|
|
38
|
-
return { ownerRepo, me, gh: exec };
|
|
39
|
-
}
|
|
40
|
-
/** The PR number goes into an API path — digits only. */
|
|
41
|
-
function prNumber(pr) {
|
|
42
|
-
const s = String(pr);
|
|
43
|
-
return /^[1-9]\d*$/.test(s) ? Number(s) : null;
|
|
44
|
-
}
|
|
45
|
-
/** `--paginate --slurp` yields one array per page; `--jq` would run per page. */
|
|
46
|
-
async function slurp(gh, route) {
|
|
47
|
-
const r = await gh(['api', route, '--paginate', '--slurp']);
|
|
48
|
-
if (!r.ok)
|
|
49
|
-
return null;
|
|
50
|
-
try {
|
|
51
|
-
return JSON.parse(r.stdout).flat();
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
export async function runLoopComment(pr, options) {
|
|
58
|
-
const n = prNumber(pr);
|
|
59
|
-
const fail = (error) => ({
|
|
60
|
-
pr: n,
|
|
61
|
-
action: 'failed',
|
|
62
|
-
commentId: null,
|
|
63
|
-
error,
|
|
64
|
-
});
|
|
65
|
-
if (n === null)
|
|
66
|
-
return fail(`not a PR number: ${pr}`);
|
|
67
|
-
const text = options.text.trim();
|
|
68
|
-
if (!text)
|
|
69
|
-
return fail('empty comment body');
|
|
70
|
-
const target = await resolveTarget(options.dir, options.gh);
|
|
71
|
-
if (typeof target === 'string')
|
|
72
|
-
return fail(target);
|
|
73
|
-
const { ownerRepo, me, gh } = target;
|
|
74
|
-
const comments = await slurp(gh, `repos/${ownerRepo}/issues/${n}/comments`);
|
|
75
|
-
// A failed read must not fall through to "create" — that is the duplicate.
|
|
76
|
-
if (comments === null)
|
|
77
|
-
return fail('could not list PR comments');
|
|
78
|
-
const existing = comments.find((c) => c.user?.login === me && (c.body ?? '').startsWith(DECISION_MARKER));
|
|
79
|
-
const input = JSON.stringify({ body: `${DECISION_MARKER}\n${text}` });
|
|
80
|
-
const r = existing
|
|
81
|
-
? await gh(['api', '-X', 'PATCH', `repos/${ownerRepo}/issues/comments/${existing.id}`, '--input', '-'], input)
|
|
82
|
-
: await gh(['api', `repos/${ownerRepo}/issues/${n}/comments`, '--input', '-'], input);
|
|
83
|
-
if (!r.ok)
|
|
84
|
-
return fail(r.stderr.trim() || 'gh api failed');
|
|
85
|
-
let id = existing?.id ?? null;
|
|
86
|
-
try {
|
|
87
|
-
id = JSON.parse(r.stdout).id ?? id;
|
|
88
|
-
}
|
|
89
|
-
catch { }
|
|
90
|
-
return { pr: n, action: existing ? 'updated' : 'created', commentId: id };
|
|
91
|
-
}
|
|
92
|
-
export async function runLoopVerdict(pr, options) {
|
|
93
|
-
const n = prNumber(pr);
|
|
94
|
-
const { arm } = options;
|
|
95
|
-
const fail = (error) => ({
|
|
96
|
-
pr: n,
|
|
97
|
-
arm,
|
|
98
|
-
head: null,
|
|
99
|
-
verdict: null,
|
|
100
|
-
error,
|
|
101
|
-
});
|
|
102
|
-
if (n === null)
|
|
103
|
-
return fail(`not a PR number: ${pr}`);
|
|
104
|
-
if (arm !== 'code' && arm !== 'sec')
|
|
105
|
-
return fail(`--arm must be code or sec, got ${arm}`);
|
|
106
|
-
const target = await resolveTarget(options.dir, options.gh);
|
|
107
|
-
if (typeof target === 'string')
|
|
108
|
-
return fail(target);
|
|
109
|
-
const { ownerRepo, me, gh } = target;
|
|
110
|
-
const head = await ghOut(gh, [
|
|
111
|
-
'pr',
|
|
112
|
-
'view',
|
|
113
|
-
String(n),
|
|
114
|
-
'-R',
|
|
115
|
-
ownerRepo,
|
|
116
|
-
'--json',
|
|
117
|
-
'headRefOid',
|
|
118
|
-
'--jq',
|
|
119
|
-
'.headRefOid',
|
|
120
|
-
]);
|
|
121
|
-
if (!head)
|
|
122
|
-
return fail('could not resolve the PR head commit');
|
|
123
|
-
// `issues/<N>/comments` would never see these — `gh pr review --comment`
|
|
124
|
-
// creates a review.
|
|
125
|
-
const reviews = await slurp(gh, `repos/${ownerRepo}/pulls/${n}/reviews`);
|
|
126
|
-
if (reviews === null)
|
|
127
|
-
return { ...fail('could not list PR reviews'), head };
|
|
128
|
-
const marker = new RegExp(`<!-- ai-issue-loop:verdict:${arm}:(PASS-NOTES|PASS|CHANGES) -->`);
|
|
129
|
-
let verdict = null;
|
|
130
|
-
for (const r of reviews) {
|
|
131
|
-
// The head gate: a verdict expires with the diff it read.
|
|
132
|
-
if (r.user?.login !== me || r.commit_id !== head)
|
|
133
|
-
continue;
|
|
134
|
-
const m = (r.body ?? '').match(marker);
|
|
135
|
-
if (m)
|
|
136
|
-
verdict = m[1];
|
|
137
|
-
}
|
|
138
|
-
return { pr: n, arm, head, verdict };
|
|
139
|
-
}
|
|
140
|
-
export async function loopCommentCommand(pr, options) {
|
|
141
|
-
let text;
|
|
142
|
-
try {
|
|
143
|
-
text = fs.readFileSync(options.bodyFile === '-' ? 0 : options.bodyFile, 'utf8');
|
|
144
|
-
}
|
|
145
|
-
catch (err) {
|
|
146
|
-
console.error(`✖ cannot read ${options.bodyFile}: ${err.message}`);
|
|
147
|
-
process.exitCode = 1;
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
const result = await runLoopComment(pr, { dir: options.dir, text });
|
|
151
|
-
if (options.json)
|
|
152
|
-
console.log(JSON.stringify(result, null, 2));
|
|
153
|
-
else if (result.error)
|
|
154
|
-
console.error(`✖ ${result.error}`);
|
|
155
|
-
else
|
|
156
|
-
console.log(`${result.action} comment ${result.commentId} on #${result.pr}`);
|
|
157
|
-
process.exitCode = result.action === 'failed' ? 1 : 0;
|
|
158
|
-
}
|
|
159
|
-
export async function loopVerdictCommand(pr, options) {
|
|
160
|
-
const result = await runLoopVerdict(pr, options);
|
|
161
|
-
if (options.json)
|
|
162
|
-
console.log(JSON.stringify(result, null, 2));
|
|
163
|
-
else if (result.error)
|
|
164
|
-
console.error(`✖ ${result.error}`);
|
|
165
|
-
// Empty line for "none", so `VERDICT=$(… loop verdict …)` needs no null check.
|
|
166
|
-
else
|
|
167
|
-
console.log(result.verdict ?? '');
|
|
168
|
-
process.exitCode = result.error ? 1 : 0;
|
|
169
|
-
}
|