@rtorcato/repo-tooling 3.44.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.
@@ -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
- }
@@ -1,196 +0,0 @@
1
- import path from 'node:path';
2
- import chalk from 'chalk';
3
- import { realGhExec } from '../../base/github-settings.js';
4
- import { defaultWorktreeRoot, findLive } from './loop-guard.js';
5
- /**
6
- * `repo-tooling loop reap` — the ai-issue-loop skill's Pass 2 stalled-agent
7
- * table, moved out of prose (#618). Nothing can time out an agent, so a label
8
- * that sat `STALE_MINUTES` past its last application (per the timeline) marks
9
- * a dead one:
10
- *
11
- * - **implementer** — issue `ai-wip`, no PR for `ai-<N>-*` → `block`.
12
- * - **reviewer** — PR `ai-reviewing-code|sec` with no matching `ai-ok-*` and
13
- * no `ai-changes` → `drop-label` that claim, or `block` at ≥3 applications.
14
- * - **fixer** — PR `ai-fixing` still `ai-changes` → `drop-label`, or `block`.
15
- * - **orphan** — `ai-<N>-*` worktree whose issue is not `ai-wip` and has no
16
- * open PR → `remove-worktree`.
17
- *
18
- * Read-only: it reports verdicts, the skill applies them — the label, assign
19
- * and comment of an `ai-blocked`, and the call on whether a stall was benign.
20
- * Exit `1` when a gh query failed, so the report is incomplete.
21
- */
22
- /** Three ticks. Generous: a live agent must never be reaped out from under itself. */
23
- export const STALE_MINUTES = 45;
24
- /** A claim applied this many times is not one more spawn away from working. */
25
- export const MAX_APPLICATIONS = 3;
26
- // ponytail: newest 200 PRs; an ai-wip issue's PR older than that reads as "no PR".
27
- const PR_WINDOW = 200;
28
- /** `ai-<N>-slug`, or the legacy `worktree-ai-<N>-slug`. */
29
- const issueOf = (name) => Number(name.match(/^(?:worktree-)?ai-(\d+)-/)?.[1]) || null;
30
- const REVIEW_CLAIMS = {
31
- 'ai-reviewing-code': 'ai-ok-code',
32
- 'ai-reviewing-sec': 'ai-ok-sec',
33
- };
34
- export async function runLoopReap(options = {}) {
35
- const root = path.resolve(options.root ?? process.cwd());
36
- const worktreeRoot = options.worktreeRoot
37
- ? path.resolve(options.worktreeRoot)
38
- : defaultWorktreeRoot(root);
39
- const gh = options.gh ?? ((args, stdin) => realGhExec(args, stdin, root));
40
- const now = (options.now ?? new Date()).getTime();
41
- const errors = [];
42
- const stalled = [];
43
- const list = async (args) => {
44
- const r = await gh(args);
45
- if (!r.ok) {
46
- errors.push(`gh ${args.slice(0, 2).join(' ')} failed: ${r.stderr.trim()}`);
47
- return null;
48
- }
49
- return JSON.parse(r.stdout || '[]');
50
- };
51
- /** Every application of `label` on issue/PR `n`, oldest first. */
52
- const applications = async (n, label) => {
53
- const r = await gh([
54
- 'api',
55
- `repos/{owner}/{repo}/issues/${n}/timeline`,
56
- '--paginate',
57
- '--jq',
58
- '.[] | select(.event=="labeled") | [.label.name, .created_at] | @tsv',
59
- ]);
60
- if (!r.ok) {
61
- errors.push(`timeline for #${n} failed: ${r.stderr.trim()}`);
62
- return null;
63
- }
64
- return r.stdout
65
- .split('\n')
66
- .map((line) => line.split('\t'))
67
- .filter(([name]) => name === label)
68
- .map(([, at]) => Date.parse(at))
69
- .sort((a, b) => a - b);
70
- };
71
- /** Age and count of `label` on `n`, or null when it is not stale yet (or unknown). */
72
- const stale = async (n, label) => {
73
- const times = await applications(n, label);
74
- const last = times?.at(-1);
75
- if (last === undefined)
76
- return null;
77
- const minutes = Math.floor((now - last) / 60_000);
78
- return minutes >= STALE_MINUTES ? { minutes, applications: times?.length ?? 0 } : null;
79
- };
80
- const wip = await list([
81
- 'issue',
82
- 'list',
83
- '--label',
84
- 'ai-wip',
85
- '--state',
86
- 'open',
87
- '--limit',
88
- '100',
89
- '--json',
90
- 'number',
91
- ]);
92
- const prs = await list([
93
- 'pr',
94
- 'list',
95
- '--state',
96
- 'all',
97
- '--limit',
98
- String(PR_WINDOW),
99
- '--json',
100
- 'number,state,headRefName,labels',
101
- ]);
102
- const dirs = await findLive([worktreeRoot, path.join(root, '.claude', 'worktrees')]);
103
- const worktreeOf = (issue) => dirs.find((d) => issueOf(path.basename(d)) === issue) ?? null;
104
- // Without both lists a "no PR" or "not ai-wip" verdict would be a guess.
105
- if (wip && prs) {
106
- const wipSet = new Set(wip.map((i) => i.number));
107
- const prIssues = (state) => new Set(prs.filter((p) => !state || p.state === state).map((p) => issueOf(p.headRefName)));
108
- const anyPr = prIssues();
109
- const openPr = prIssues('OPEN');
110
- for (const issue of wipSet) {
111
- if (anyPr.has(issue))
112
- continue;
113
- const s = await stale(issue, 'ai-wip');
114
- if (!s)
115
- continue;
116
- stalled.push({
117
- kind: 'implementer',
118
- issue,
119
- pr: null,
120
- label: 'ai-wip',
121
- ...s,
122
- action: 'block',
123
- worktree: worktreeOf(issue),
124
- reason: `ai-wip for ${s.minutes}min and no PR`,
125
- });
126
- }
127
- for (const pr of prs.filter((p) => p.state === 'OPEN')) {
128
- const labels = new Set(pr.labels.map((l) => l.name));
129
- const issue = issueOf(pr.headRefName);
130
- const claims = Object.entries(REVIEW_CLAIMS)
131
- .filter(([claim, ok]) => labels.has(claim) && !labels.has(ok) && !labels.has('ai-changes'))
132
- .map(([claim]) => ({ kind: 'reviewer', claim }));
133
- if (labels.has('ai-fixing') && labels.has('ai-changes'))
134
- claims.push({ kind: 'fixer', claim: 'ai-fixing' });
135
- for (const { kind, claim } of claims) {
136
- const s = await stale(pr.number, claim);
137
- if (!s)
138
- continue;
139
- const block = s.applications >= MAX_APPLICATIONS;
140
- stalled.push({
141
- kind,
142
- issue,
143
- pr: pr.number,
144
- label: claim,
145
- ...s,
146
- action: block ? 'block' : 'drop-label',
147
- worktree: null,
148
- reason: `${claim} for ${s.minutes}min, applied ${s.applications}×`,
149
- });
150
- }
151
- }
152
- for (const dir of dirs) {
153
- const issue = issueOf(path.basename(dir));
154
- if (issue === null || wipSet.has(issue) || openPr.has(issue))
155
- continue;
156
- stalled.push({
157
- kind: 'orphan',
158
- issue,
159
- pr: null,
160
- label: null,
161
- minutes: null,
162
- applications: null,
163
- action: 'remove-worktree',
164
- worktree: dir,
165
- reason: 'issue not ai-wip and no open PR',
166
- });
167
- }
168
- }
169
- return {
170
- root,
171
- worktreeRoot,
172
- staleMinutes: STALE_MINUTES,
173
- stalled,
174
- errors,
175
- exitCode: errors.length > 0 ? 1 : 0,
176
- };
177
- }
178
- export async function loopReapCommand(options) {
179
- const result = await runLoopReap(options);
180
- if (options.json) {
181
- console.log(JSON.stringify(result, null, 2));
182
- }
183
- else {
184
- console.log();
185
- if (result.stalled.length === 0)
186
- console.log(chalk.gray(' nothing stalled'));
187
- for (const s of result.stalled) {
188
- const target = s.pr ? `PR #${s.pr}` : `#${s.issue}`;
189
- console.log(` ${chalk.yellow(s.action.padEnd(15))} ${target} (${s.kind}) — ${s.reason}`);
190
- }
191
- for (const e of result.errors)
192
- console.log(` ${chalk.red(e)}`);
193
- console.log();
194
- }
195
- process.exitCode = result.exitCode;
196
- }