@rtorcato/repo-tooling 3.45.0 → 4.1.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,349 +0,0 @@
1
- import path from 'node:path';
2
- import chalk from 'chalk';
3
- import { realGitExec } from '../../base/git-identity.js';
4
- import { realGhExec, releaseGated } from '../../base/github-settings.js';
5
- import { runLoopCleanup } from './loop-cleanup.js';
6
- import { resolveLoopEnv } from './loop-env.js';
7
- import { runLoopGuard } from './loop-guard.js';
8
- import { runLoopVerdict } from './loop-marker.js';
9
- import { labelApplications, MAX_APPLICATIONS, runLoopReap } from './loop-reap.js';
10
- /**
11
- * `repo-tooling loop tick` — one ai-issue-loop tick's mechanics as one work
12
- * list (#620). It composes `loop env`, `loop guard`, `loop cleanup`,
13
- * `loop reap` and `loop verdict` with the Pass 1 merge-state and CI reads, the
14
- * Pass 0 PR-adoption query, the Pass 2 `ai-suggested` decay and the Pass 4
15
- * eligibility query, and says what to do. The skill applies it: every label,
16
- * assignee, comment, merge and agent spawn stays the agent's call.
17
- *
18
- * It writes no GitHub state. Its only local writes are the ones the helpers it
19
- * composes already make — `loop guard`'s bare repair and gated `node_modules`
20
- * rebuild, and `loop cleanup`'s removal of worktrees whose PR landed or closed.
21
- *
22
- * Exit non-zero only to halt the tick: `loop guard`'s own code (`1`/`2`), or `1`
23
- * when the checkout or its GitHub repo cannot be resolved. A failed gh read is
24
- * not a halt — the lists it feeds are left empty, it lands in `errors`, and the
25
- * summary leads with `⚠error`.
26
- */
27
- /** Issues in flight at once. */
28
- export const MAX_IN_FLIGHT = 6;
29
- /** `ai-suggested` issues untouched this long are closed. */
30
- export const DECAY_DAYS = 30;
31
- const TRUSTED = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
32
- /** Merge states that name something to fix; any other non-CLEAN state waits. */
33
- const SEND_BACK_STATES = new Set(['BEHIND', 'DIRTY', 'BLOCKED']);
34
- const ARMS = ['code', 'sec'];
35
- const issueOf = (head) => Number(head.match(/^(?:worktree-)?ai-(\d+)-/)?.[1]) || null;
36
- function empty(env) {
37
- return {
38
- env,
39
- halt: null,
40
- idle: false,
41
- releaseGated: false,
42
- adopt: [],
43
- disarm: [],
44
- handoffs: [],
45
- sendBacks: [],
46
- stripMergeReady: [],
47
- dependabotCiRed: [],
48
- dependabotChanges: [],
49
- cleaned: [],
50
- stalled: [],
51
- decay: [],
52
- verdicts: [],
53
- reviewsToSpawn: [],
54
- fixRounds: [],
55
- slots: 0,
56
- pickups: [],
57
- rebuild: 'not-requested',
58
- summary: '',
59
- errors: [],
60
- exitCode: 0,
61
- };
62
- }
63
- export async function runLoopTick(options = {}) {
64
- const dir = path.resolve(options.root ?? process.cwd());
65
- const env = await resolveLoopEnv({ dir, git: options.git, gh: options.gh, env: options.env });
66
- const result = empty(env);
67
- if (!env.root || !env.ownerRepo) {
68
- result.halt = env.warnings.join('; ') || 'could not resolve the checkout';
69
- result.exitCode = 1;
70
- return result;
71
- }
72
- const { root, ownerRepo } = env;
73
- const git = options.git ?? ((args) => realGitExec(args, root, 120_000));
74
- const gh = options.gh ?? ((args, stdin) => realGhExec(args, stdin, root));
75
- const now = (options.now ?? new Date()).getTime();
76
- const errors = result.errors;
77
- const seams = { root, git: options.git, gh: options.gh };
78
- const guard = await runLoopGuard({ ...seams, install: options.install });
79
- if (guard.exitCode !== 0) {
80
- result.halt = guard.messages.filter((m) => m.startsWith('⚠')).join('; ') || 'loop guard failed';
81
- result.exitCode = guard.exitCode;
82
- return result;
83
- }
84
- // Best-effort: Pass 4 branches worktrees off origin/main.
85
- await git(['fetch', '--prune', 'origin']);
86
- const cleanup = await runLoopCleanup(seams);
87
- result.cleaned = cleanup.worktrees.filter((w) => w.action === 'removed');
88
- for (const w of cleanup.worktrees.filter((w) => w.action === 'remove-failed'))
89
- errors.push(`could not remove ${w.path}`);
90
- let live = guard.live;
91
- if (cleanup.removed) {
92
- const again = await runLoopGuard({ ...seams, install: options.install, removed: true });
93
- if (again.exitCode !== 0) {
94
- result.halt =
95
- again.messages.filter((m) => m.startsWith('⚠')).join('; ') || 'loop guard failed';
96
- result.exitCode = again.exitCode;
97
- return result;
98
- }
99
- result.rebuild = again.rebuild;
100
- live = again.live;
101
- }
102
- const reap = await runLoopReap({ root, gh: options.gh, now: options.now });
103
- result.stalled = reap.stalled;
104
- errors.push(...reap.errors);
105
- const json = async (args) => {
106
- const r = await gh(args);
107
- if (r.ok) {
108
- try {
109
- return JSON.parse(r.stdout || 'null');
110
- }
111
- catch { }
112
- }
113
- errors.push(`gh ${args.slice(0, 3).join(' ')} failed: ${r.stderr.trim()}`);
114
- return null;
115
- };
116
- const prs = await json([
117
- 'pr',
118
- 'list',
119
- '--state',
120
- 'open',
121
- '--limit',
122
- '100',
123
- '--json',
124
- 'number,headRefName,labels,autoMergeRequest,author,body,statusCheckRollup',
125
- ]);
126
- const wip = await json([
127
- 'issue',
128
- 'list',
129
- '--label',
130
- 'ai-wip',
131
- '--state',
132
- 'open',
133
- '--limit',
134
- '100',
135
- '--json',
136
- 'number',
137
- ]);
138
- result.releaseGated = await releaseGated(gh, ownerRepo, root);
139
- for (const pr of prs ?? []) {
140
- const labels = new Set(pr.labels.map((l) => l.name));
141
- const has = (l) => labels.has(l);
142
- const issue = issueOf(pr.headRefName);
143
- if (pr.headRefName.startsWith('dependabot/')) {
144
- const red = (pr.statusCheckRollup ?? []).some((c) => c.conclusion === 'FAILURE');
145
- if (pr.autoMergeRequest && red)
146
- result.dependabotCiRed.push(pr.number);
147
- if (has('ai-changes'))
148
- result.dependabotChanges.push(pr.number);
149
- continue;
150
- }
151
- const loopPr = has('merge-ready') || [...labels].some((l) => l.startsWith('ai-'));
152
- if (!loopPr) {
153
- // The 🤖 header is the discriminator: every agent is the owner's login.
154
- const mine = pr.author?.login.toLowerCase() === env.me.toLowerCase();
155
- if (env.me && mine && (pr.body ?? '').startsWith('🤖 '))
156
- result.adopt.push(pr.number);
157
- continue;
158
- }
159
- const passed = (has('ai-ok-code') && has('ai-ok-sec')) || has('merge-ready');
160
- if (pr.autoMergeRequest && !passed)
161
- result.disarm.push(pr.number);
162
- const claimed = has('ai-reviewing-code') || has('ai-reviewing-sec');
163
- // CI red is a send-back, except mid-review or when one is already out.
164
- if (!claimed && !has('ai-changes')) {
165
- const r = await gh([
166
- 'pr',
167
- 'checks',
168
- String(pr.number),
169
- '--required',
170
- '--json',
171
- 'name,state,link',
172
- ]);
173
- // Exits non-zero whenever a check fails or is pending; stdout is still the answer.
174
- let checks = [];
175
- try {
176
- checks = JSON.parse(r.stdout || '[]');
177
- }
178
- catch { }
179
- const failing = checks
180
- .filter((c) => c.state === 'FAILURE')
181
- .map(({ name, link }) => ({ name, link }));
182
- if (failing.length > 0) {
183
- result.sendBacks.push({ pr: pr.number, issue, reason: 'ci-red', failing });
184
- continue;
185
- }
186
- }
187
- if (has('ai-changes')) {
188
- if (has('merge-ready'))
189
- result.stripMergeReady.push(pr.number);
190
- }
191
- else if (passed) {
192
- const view = await json([
193
- 'pr',
194
- 'view',
195
- String(pr.number),
196
- '--json',
197
- 'mergeStateStatus',
198
- ]);
199
- const s = view?.mergeStateStatus ?? 'UNKNOWN';
200
- if (s === 'CLEAN') {
201
- const notes = has('ai-notes');
202
- result.handoffs.push({
203
- pr: pr.number,
204
- issue,
205
- notes,
206
- autoMerge: result.releaseGated && !notes,
207
- });
208
- }
209
- else if (SEND_BACK_STATES.has(s)) {
210
- result.sendBacks.push({
211
- pr: pr.number,
212
- issue,
213
- reason: s,
214
- failing: [],
215
- });
216
- }
217
- else if (s !== 'UNKNOWN' && has('merge-ready')) {
218
- result.stripMergeReady.push(pr.number);
219
- }
220
- continue;
221
- }
222
- if (has('ai-review') && !has('ai-changes')) {
223
- const spawn = [];
224
- let changes = false;
225
- for (const arm of ARMS) {
226
- if (has(`ai-ok-${arm}`) || has(`ai-reviewing-${arm}`))
227
- continue;
228
- const v = await runLoopVerdict(pr.number, { dir: root, arm, gh: options.gh });
229
- if (v.error)
230
- errors.push(`verdict ${arm} on #${pr.number}: ${v.error}`);
231
- else if (v.verdict) {
232
- result.verdicts.push({ pr: pr.number, arm, verdict: v.verdict });
233
- changes ||= v.verdict === 'CHANGES';
234
- }
235
- else
236
- spawn.push(arm);
237
- }
238
- // A CHANGES about to be applied sends the PR back; a review now is wasted.
239
- if (!changes)
240
- for (const arm of spawn)
241
- result.reviewsToSpawn.push({ pr: pr.number, issue, arm });
242
- }
243
- if (has('ai-changes') && !has('ai-fixing')) {
244
- const times = await labelApplications(gh, pr.number, 'ai-changes');
245
- if (typeof times === 'string') {
246
- errors.push(times);
247
- continue;
248
- }
249
- const slug = pr.headRefName.replace(/^worktree-/, '');
250
- const worktree = live.find((d) => path.basename(d) === slug) ?? null;
251
- const atCap = times.length >= MAX_APPLICATIONS;
252
- result.fixRounds.push({
253
- pr: pr.number,
254
- issue,
255
- worktree,
256
- applications: times.length,
257
- action: atCap || !worktree ? 'block' : 'spawn',
258
- reason: atCap
259
- ? `ai-changes applied ${times.length}× — round cap reached`
260
- : worktree
261
- ? `round ${times.length}`
262
- : 'no worktree to fix in',
263
- });
264
- }
265
- }
266
- const suggested = await json([
267
- 'issue',
268
- 'list',
269
- '--label',
270
- 'ai-suggested',
271
- '--state',
272
- 'open',
273
- '--limit',
274
- '100',
275
- '--json',
276
- 'number,updatedAt,labels',
277
- ]);
278
- const cutoff = now - DECAY_DAYS * 86_400_000;
279
- result.decay = (suggested ?? [])
280
- // A promoted suggestion keeps its label; closing a queued one is unrecoverable.
281
- .filter((i) => !i.labels.some((l) => ['ai-ready', 'ai-wip', 'holding'].includes(l.name)))
282
- .filter((i) => Date.parse(i.updatedAt) < cutoff)
283
- .map((i) => i.number);
284
- // ponytail: first 100 ai-ready issues; paginate if a queue ever outgrows that.
285
- const queue = await json([
286
- 'api',
287
- `repos/${ownerRepo}/issues?labels=ai-ready&state=open&per_page=100`,
288
- ]);
289
- result.pickups = (queue ?? [])
290
- .filter((i) => !i.pull_request)
291
- .filter((i) => !i.labels.some((l) => ['ai-wip', 'ai-blocked', 'holding'].includes(l.name)))
292
- // The label is the hard gate; association is the backstop.
293
- .filter((i) => TRUSTED.has(i.author_association))
294
- .map(({ number, title, body }) => ({ number, title, body: body ?? '' }));
295
- // Slots count what is still in flight once this tick's cleanup and reaping land.
296
- const freed = new Set([
297
- ...result.cleaned.map((w) => w.issue),
298
- ...result.stalled.filter((s) => s.kind === 'implementer').map((s) => s.issue),
299
- ]);
300
- const inFlight = (wip ?? []).filter((i) => !freed.has(i.number)).length;
301
- result.slots = wip ? Math.max(0, MAX_IN_FLIGHT - inFlight) : 0;
302
- const loopPrs = (prs ?? []).filter((p) => !p.headRefName.startsWith('dependabot/') &&
303
- p.labels.some((l) => l.name === 'merge-ready' || l.name.startsWith('ai-')));
304
- result.idle =
305
- errors.length === 0 &&
306
- loopPrs.length === 0 &&
307
- live.length === 0 &&
308
- [result.adopt, result.pickups, result.stalled, result.decay, result.dependabotCiRed].every((l) => l.length === 0);
309
- result.summary = summarize(result, inFlight, loopPrs.length);
310
- return result;
311
- }
312
- /** `⚠` segments first, so a truncated phone banner still leads with the stall. */
313
- export function summarize(r, inFlight, loopPrs) {
314
- if (r.idle)
315
- return 'idle';
316
- const blocked = r.stalled.filter((s) => s.action === 'block').length +
317
- r.fixRounds.filter((f) => f.action === 'block').length;
318
- const ciRed = r.sendBacks.filter((s) => s.reason === 'ci-red').length + r.dependabotCiRed.length;
319
- const wip = inFlight + Math.min(r.slots, r.pickups.length);
320
- const ready = r.handoffs.length;
321
- const segments = [
322
- [r.errors.length > 0, '⚠error'],
323
- [blocked, `⚠${blocked}blocked`],
324
- [ciRed, `⚠${ciRed}ci-red`],
325
- [r.rebuild === 'deferred' || r.rebuild === 'rebuild-failed', '⚠rebuild'],
326
- [wip, `${wip}wip`],
327
- [loopPrs - ready, `${loopPrs - ready}rev`],
328
- [ready, `${ready}ready`],
329
- ];
330
- return (segments
331
- .filter(([n]) => n)
332
- .map(([, s]) => s)
333
- .join('·') || 'idle');
334
- }
335
- export async function loopTickCommand(options) {
336
- const result = await runLoopTick(options);
337
- if (options.json) {
338
- console.log(JSON.stringify(result, null, 2));
339
- }
340
- else if (result.halt) {
341
- console.error(chalk.red(`✖ halt: ${result.halt}`));
342
- }
343
- else {
344
- console.log(result.summary);
345
- for (const e of result.errors)
346
- console.log(` ${chalk.yellow(e)}`);
347
- }
348
- process.exitCode = result.exitCode;
349
- }
@@ -1,132 +0,0 @@
1
- import path from 'node:path';
2
- import chalk from 'chalk';
3
- import fs from 'fs-extra';
4
- import { realGitExec } from '../../base/git-identity.js';
5
- import { defaultWorktreeRoot } from './loop-guard.js';
6
- /**
7
- * `repo-tooling loop worktree add` — the ai-issue-loop skill's Pass 4
8
- * worktree creation, moved into code (#616): `git worktree add`, linking every
9
- * `worktree.symlinkDirectories` entry, the `.git/info/exclude` line, and the
10
- * assertion that nothing was left unlinked.
11
- *
12
- * The prose version iterated `for d in $DIRS`, which zsh does not word-split,
13
- * so it silently linked nothing (#585). An array has no such failure mode.
14
- *
15
- * Exit `0` means the worktree is ready for an implementer. Exit `1` means do
16
- * not spawn one — either the worktree was not created, or an entry that exists
17
- * in the main checkout has no link in the worktree. `needsInstall` (exit `0`)
18
- * means the repo declares no symlink list, so nothing was linked and a real
19
- * install in the worktree is safe.
20
- */
21
- /** `ai-<issue>-<kebab words>` — it becomes a branch name and a path segment. */
22
- export const SLUG = /^ai-\d+-[a-z0-9]+(?:-[a-z0-9]+)*$/;
23
- /**
24
- * Relative and inside the checkout, or dropped: the list comes from a
25
- * committed file, and `../x` would otherwise link outside both trees.
26
- */
27
- function safeEntry(entry) {
28
- if (typeof entry !== 'string' || entry.trim() === '')
29
- return false;
30
- const n = path.normalize(entry);
31
- return !path.isAbsolute(n) && n !== '..' && !n.startsWith(`..${path.sep}`);
32
- }
33
- export async function symlinkDirectories(root) {
34
- const settings = await fs.readJson(path.join(root, '.claude', 'settings.json')).catch(() => null);
35
- const dirs = settings?.worktree?.symlinkDirectories;
36
- return Array.isArray(dirs) ? dirs.filter(safeEntry) : [];
37
- }
38
- /**
39
- * `node_modules/` with a trailing slash does not match a symlink, so a
40
- * `git add -A` would commit every link. No slash matches at any depth. The
41
- * exclude file is shared by every worktree and never committed.
42
- */
43
- async function ensureExcluded(root) {
44
- const file = path.join(root, '.git', 'info', 'exclude');
45
- const current = await fs.readFile(file, 'utf8').catch(() => '');
46
- if (current.split('\n').includes('node_modules'))
47
- return false;
48
- await fs.outputFile(file, `${current}${current && !current.endsWith('\n') ? '\n' : ''}node_modules\n`);
49
- return true;
50
- }
51
- async function isSymlink(p) {
52
- return fs
53
- .lstat(p)
54
- .then((s) => s.isSymbolicLink())
55
- .catch(() => false);
56
- }
57
- export async function runLoopWorktreeAdd(slug, options = {}) {
58
- const root = path.resolve(options.root ?? process.cwd());
59
- const worktreeRoot = options.worktreeRoot
60
- ? path.resolve(options.worktreeRoot)
61
- : defaultWorktreeRoot(root);
62
- const worktree = path.join(worktreeRoot, slug);
63
- // A checkout of a real repo takes longer than realGitExec's 5s default.
64
- const git = options.git ?? ((args) => realGitExec(args, root, 120_000));
65
- const result = {
66
- root,
67
- worktree,
68
- branch: slug,
69
- linked: [],
70
- missing: [],
71
- needsInstall: false,
72
- exitCode: 1,
73
- messages: [],
74
- };
75
- if (!SLUG.test(slug)) {
76
- result.messages.push(`⚠ '${slug}' is not ai-<issue>-<kebab-slug>`);
77
- return result;
78
- }
79
- const base = options.base ?? 'origin/main';
80
- // Goes to git as an argument; a leading dash would be read as an option.
81
- if (base.startsWith('-')) {
82
- result.messages.push(`⚠ '${base}' is not a ref`);
83
- return result;
84
- }
85
- await fs.ensureDir(worktreeRoot);
86
- if ((await git(['worktree', 'add', worktree, '-b', slug, base])) === null) {
87
- result.messages.push(`⚠ git worktree add failed — does branch ${slug} or ${worktree} already exist?`);
88
- return result;
89
- }
90
- result.messages.push(`created ${worktree} on ${slug}`);
91
- // Before linking, so the links are never momentarily untracked-and-addable.
92
- if (await ensureExcluded(root))
93
- result.messages.push('added node_modules to .git/info/exclude');
94
- const dirs = await symlinkDirectories(root);
95
- if (dirs.length === 0) {
96
- result.needsInstall = true;
97
- result.messages.push('no worktree.symlinkDirectories — run a real install in the worktree (`fix ai` restores the fast path)');
98
- }
99
- for (const d of dirs) {
100
- const source = path.join(root, d);
101
- // An entry pointing at nothing links nothing, and is not missing.
102
- if (!(await fs.pathExists(source)))
103
- continue;
104
- const target = path.join(worktree, d);
105
- await fs.ensureDir(path.dirname(target));
106
- await fs.symlink(source, target).catch(() => { });
107
- if (await isSymlink(target))
108
- result.linked.push(d);
109
- else
110
- result.missing.push(d);
111
- }
112
- if (result.missing.length > 0) {
113
- result.messages.push(`⚠ FATAL: no symlink for ${result.missing.join(', ')} — do not spawn an implementer`);
114
- return result;
115
- }
116
- if (result.linked.length > 0)
117
- result.messages.push(`linked ${result.linked.join(', ')}`);
118
- result.exitCode = 0;
119
- return result;
120
- }
121
- export async function loopWorktreeAddCommand(slug, options) {
122
- const result = await runLoopWorktreeAdd(slug, options);
123
- if (options.json) {
124
- console.log(JSON.stringify(result, null, 2));
125
- }
126
- else {
127
- for (const line of result.messages) {
128
- console.log(` ${line.startsWith('⚠') ? chalk.yellow(line) : chalk.gray(line)}`);
129
- }
130
- }
131
- process.exitCode = result.exitCode;
132
- }