@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,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,202 +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
- /** Every application of `label` on issue/PR `n`, oldest first — or why it could not be read. */
35
- export async function labelApplications(gh, n, label) {
36
- const r = await gh([
37
- 'api',
38
- `repos/{owner}/{repo}/issues/${n}/timeline`,
39
- '--paginate',
40
- '--jq',
41
- '.[] | select(.event=="labeled") | [.label.name, .created_at] | @tsv',
42
- ]);
43
- if (!r.ok)
44
- return `timeline for #${n} failed: ${r.stderr.trim()}`;
45
- return r.stdout
46
- .split('\n')
47
- .map((line) => line.split('\t'))
48
- .filter(([name]) => name === label)
49
- .map(([, at]) => Date.parse(at))
50
- .sort((a, b) => a - b);
51
- }
52
- export async function runLoopReap(options = {}) {
53
- const root = path.resolve(options.root ?? process.cwd());
54
- const worktreeRoot = options.worktreeRoot
55
- ? path.resolve(options.worktreeRoot)
56
- : defaultWorktreeRoot(root);
57
- const gh = options.gh ?? ((args, stdin) => realGhExec(args, stdin, root));
58
- const now = (options.now ?? new Date()).getTime();
59
- const errors = [];
60
- const stalled = [];
61
- const list = async (args) => {
62
- const r = await gh(args);
63
- if (!r.ok) {
64
- errors.push(`gh ${args.slice(0, 2).join(' ')} failed: ${r.stderr.trim()}`);
65
- return null;
66
- }
67
- return JSON.parse(r.stdout || '[]');
68
- };
69
- const applications = async (n, label) => {
70
- const times = await labelApplications(gh, n, label);
71
- if (typeof times === 'string') {
72
- errors.push(times);
73
- return null;
74
- }
75
- return times;
76
- };
77
- /** Age and count of `label` on `n`, or null when it is not stale yet (or unknown). */
78
- const stale = async (n, label) => {
79
- const times = await applications(n, label);
80
- const last = times?.at(-1);
81
- if (last === undefined)
82
- return null;
83
- const minutes = Math.floor((now - last) / 60_000);
84
- return minutes >= STALE_MINUTES ? { minutes, applications: times?.length ?? 0 } : null;
85
- };
86
- const wip = await list([
87
- 'issue',
88
- 'list',
89
- '--label',
90
- 'ai-wip',
91
- '--state',
92
- 'open',
93
- '--limit',
94
- '100',
95
- '--json',
96
- 'number',
97
- ]);
98
- const prs = await list([
99
- 'pr',
100
- 'list',
101
- '--state',
102
- 'all',
103
- '--limit',
104
- String(PR_WINDOW),
105
- '--json',
106
- 'number,state,headRefName,labels',
107
- ]);
108
- const dirs = await findLive([worktreeRoot, path.join(root, '.claude', 'worktrees')]);
109
- const worktreeOf = (issue) => dirs.find((d) => issueOf(path.basename(d)) === issue) ?? null;
110
- // Without both lists a "no PR" or "not ai-wip" verdict would be a guess.
111
- if (wip && prs) {
112
- const wipSet = new Set(wip.map((i) => i.number));
113
- const prIssues = (state) => new Set(prs.filter((p) => !state || p.state === state).map((p) => issueOf(p.headRefName)));
114
- const anyPr = prIssues();
115
- const openPr = prIssues('OPEN');
116
- for (const issue of wipSet) {
117
- if (anyPr.has(issue))
118
- continue;
119
- const s = await stale(issue, 'ai-wip');
120
- if (!s)
121
- continue;
122
- stalled.push({
123
- kind: 'implementer',
124
- issue,
125
- pr: null,
126
- label: 'ai-wip',
127
- ...s,
128
- action: 'block',
129
- worktree: worktreeOf(issue),
130
- reason: `ai-wip for ${s.minutes}min and no PR`,
131
- });
132
- }
133
- for (const pr of prs.filter((p) => p.state === 'OPEN')) {
134
- const labels = new Set(pr.labels.map((l) => l.name));
135
- const issue = issueOf(pr.headRefName);
136
- const claims = Object.entries(REVIEW_CLAIMS)
137
- .filter(([claim, ok]) => labels.has(claim) && !labels.has(ok) && !labels.has('ai-changes'))
138
- .map(([claim]) => ({ kind: 'reviewer', claim }));
139
- if (labels.has('ai-fixing') && labels.has('ai-changes'))
140
- claims.push({ kind: 'fixer', claim: 'ai-fixing' });
141
- for (const { kind, claim } of claims) {
142
- const s = await stale(pr.number, claim);
143
- if (!s)
144
- continue;
145
- const block = s.applications >= MAX_APPLICATIONS;
146
- stalled.push({
147
- kind,
148
- issue,
149
- pr: pr.number,
150
- label: claim,
151
- ...s,
152
- action: block ? 'block' : 'drop-label',
153
- worktree: null,
154
- reason: `${claim} for ${s.minutes}min, applied ${s.applications}×`,
155
- });
156
- }
157
- }
158
- for (const dir of dirs) {
159
- const issue = issueOf(path.basename(dir));
160
- if (issue === null || wipSet.has(issue) || openPr.has(issue))
161
- continue;
162
- stalled.push({
163
- kind: 'orphan',
164
- issue,
165
- pr: null,
166
- label: null,
167
- minutes: null,
168
- applications: null,
169
- action: 'remove-worktree',
170
- worktree: dir,
171
- reason: 'issue not ai-wip and no open PR',
172
- });
173
- }
174
- }
175
- return {
176
- root,
177
- worktreeRoot,
178
- staleMinutes: STALE_MINUTES,
179
- stalled,
180
- errors,
181
- exitCode: errors.length > 0 ? 1 : 0,
182
- };
183
- }
184
- export async function loopReapCommand(options) {
185
- const result = await runLoopReap(options);
186
- if (options.json) {
187
- console.log(JSON.stringify(result, null, 2));
188
- }
189
- else {
190
- console.log();
191
- if (result.stalled.length === 0)
192
- console.log(chalk.gray(' nothing stalled'));
193
- for (const s of result.stalled) {
194
- const target = s.pr ? `PR #${s.pr}` : `#${s.issue}`;
195
- console.log(` ${chalk.yellow(s.action.padEnd(15))} ${target} (${s.kind}) — ${s.reason}`);
196
- }
197
- for (const e of result.errors)
198
- console.log(` ${chalk.red(e)}`);
199
- console.log();
200
- }
201
- process.exitCode = result.exitCode;
202
- }