@rtorcato/repo-tooling 3.43.0 → 3.45.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 +4 -0
- package/dist/base/github-settings.js +13 -0
- package/dist/cli/commands/loop-env.js +1 -1
- package/dist/cli/commands/loop-marker.js +169 -0
- package/dist/cli/commands/loop-reap.js +22 -16
- package/dist/cli/commands/loop-tick.js +349 -0
- package/dist/cli/index.js +26 -0
- package/package.json +1 -1
- package/skills/ai-issue-loop/SKILL.md +251 -996
- package/skills/ai-workflow/SKILL.md +7 -9
package/AGENTS.md
CHANGED
|
@@ -30,6 +30,8 @@ Every command supports `--json` and a non-interactive mode. Combine with `--yes`
|
|
|
30
30
|
| `loop env` | ✅ | ✅ | Resolve an `ai-issue-loop` tick's variables once: `root` (main checkout, correct from inside a worktree), `worktreeRoot`, `ownerRepo`, `agentUser` (empty unless an assignable collaborator), `humanUser` (empty for org repos), `me`. Without `--json`, prints `KEY='value'` lines for `eval`. Exit `1` when the checkout or its GitHub repo cannot be resolved. |
|
|
31
31
|
| `loop worktree add <ai-N-slug>` | ✅ | ✅ | Create an `ai-issue-loop` worktree at `<root>-worktrees/<slug>` on a new branch off `origin/main` (`--base`), symlink every `worktree.symlinkDirectories` entry from `.claude/settings.json`, and add `node_modules` to `.git/info/exclude`. `needsInstall: true` when no list is declared. Exit `1` when the worktree was not created or an entry is left unlinked — do not spawn an implementer. |
|
|
32
32
|
| `loop reap --json` | ✅ | ✅ | Report the `ai-issue-loop` Pass 2 stalls: a label that sat ≥45 minutes past its last application (per the issue timeline). Each entry is `{ kind, issue, pr, label, minutes, applications, action, worktree, reason }` — `kind` is `implementer` / `reviewer` / `fixer` / `orphan`, `action` is `block` / `drop-label` / `remove-worktree` (`block` once a claim has been applied ≥3 times). Read-only; exit `1` when a `gh` query failed. |
|
|
33
|
+
| `loop tick --json` | ✅ | ✅ | One `ai-issue-loop` tick's work list: runs `loop guard`, `loop cleanup` (and `guard --removed`) and `loop reap`, reads verdicts, merge states, required checks and the `ai-ready` queue, and returns `{ halt, idle, adopt, disarm, handoffs, sendBacks, stripMergeReady, cleaned, stalled, decay, verdicts, reviewsToSpawn, fixRounds, slots, pickups, summary, errors }`. Writes no GitHub state — the skill applies every label, comment and spawn. Exit `1`/`2` halts the tick. |
|
|
34
|
+
| `loop comment <pr> --body-file <path>` / `loop verdict <pr> --arm <code\|sec>` | ✅ | ✅ | The loop's hidden-marker protocols. `comment` upserts the one `<!-- ai-issue-loop:decision -->` comment (body from a file or `-` stdin, never argv). `verdict` prints the arm's `PASS` / `PASS-NOTES` / `CHANGES` for the PR's current head, or empty. Both only trust markers posted by `gh`'s own login. Exit `1` when GitHub cannot be read. |
|
|
33
35
|
|
|
34
36
|
## Recommended workflows
|
|
35
37
|
|
|
@@ -123,6 +125,8 @@ A fixer may also **refuse** — the target file holds something the generator ca
|
|
|
123
125
|
- `src/cli/commands/loop-env.ts` — `loop env`: the skill's Pass 0 variables resolved in one call (#615)
|
|
124
126
|
- `src/cli/commands/loop-worktree.ts` — `loop worktree add`: Pass 4 worktree creation, dependency linking and the missing-link assertion (#616)
|
|
125
127
|
- `src/cli/commands/loop-reap.ts` — `loop reap`: the skill's Pass 2 stalled-agent table as verdicts (#618)
|
|
128
|
+
- `src/cli/commands/loop-tick.ts` — `loop tick`: composes the other `loop` helpers into one tick's work list, so the skill keeps only judgement and spawns (#620)
|
|
129
|
+
- `src/cli/commands/loop-marker.ts` — `loop comment` / `loop verdict`: the decision-comment upsert and verdict read-back, with the author and head-commit gates (#619)
|
|
126
130
|
- `src/cli/generators/` — one file per concern (linting, testing, build, git, github-actions, security, misc)
|
|
127
131
|
- `tooling/` — every shipped preset, mirrored 1:1 with `package.json` `exports`
|
|
128
132
|
|
|
@@ -588,6 +588,19 @@ async function readEnvironments(gh, nwo) {
|
|
|
588
588
|
return 'skip';
|
|
589
589
|
}
|
|
590
590
|
}
|
|
591
|
+
/**
|
|
592
|
+
* The ai-issue-loop's unattended-merge probe (#620): true only when the job
|
|
593
|
+
* that publishes runs behind an environment carrying `required_reviewers`, so a
|
|
594
|
+
* human still stands between a merge and the registry. An environment no job
|
|
595
|
+
* references gates nothing, and every unreadable answer fails closed.
|
|
596
|
+
*/
|
|
597
|
+
export async function releaseGated(gh, nwo, dir) {
|
|
598
|
+
const publish = await findPublishJob(dir);
|
|
599
|
+
if (publish === null || publish === 'skip' || publish.environment === null)
|
|
600
|
+
return false;
|
|
601
|
+
const envs = await readEnvironments(gh, nwo);
|
|
602
|
+
return envs !== 'skip' && envs.get(publish.environment) === true;
|
|
603
|
+
}
|
|
591
604
|
/**
|
|
592
605
|
* The gap between merging the default branch and publishing to npm (#429). On
|
|
593
606
|
* the shipped semantic-release preset those are one event: nothing stands
|
|
@@ -3,7 +3,7 @@ import { LOGIN } from '../../base/agent-user.js';
|
|
|
3
3
|
import { realGitExec } from '../../base/git-identity.js';
|
|
4
4
|
import { realGhExec } from '../../base/github-settings.js';
|
|
5
5
|
import { configuredAgentUser, defaultWorktreeRoot } from './loop-guard.js';
|
|
6
|
-
async function ghOut(gh, args) {
|
|
6
|
+
export async function ghOut(gh, args) {
|
|
7
7
|
const r = await gh(args);
|
|
8
8
|
return r.ok ? r.stdout.trim() : '';
|
|
9
9
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
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
|
+
}
|
|
@@ -31,6 +31,24 @@ const REVIEW_CLAIMS = {
|
|
|
31
31
|
'ai-reviewing-code': 'ai-ok-code',
|
|
32
32
|
'ai-reviewing-sec': 'ai-ok-sec',
|
|
33
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
|
+
}
|
|
34
52
|
export async function runLoopReap(options = {}) {
|
|
35
53
|
const root = path.resolve(options.root ?? process.cwd());
|
|
36
54
|
const worktreeRoot = options.worktreeRoot
|
|
@@ -48,25 +66,13 @@ export async function runLoopReap(options = {}) {
|
|
|
48
66
|
}
|
|
49
67
|
return JSON.parse(r.stdout || '[]');
|
|
50
68
|
};
|
|
51
|
-
/** Every application of `label` on issue/PR `n`, oldest first. */
|
|
52
69
|
const applications = async (n, label) => {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
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()}`);
|
|
70
|
+
const times = await labelApplications(gh, n, label);
|
|
71
|
+
if (typeof times === 'string') {
|
|
72
|
+
errors.push(times);
|
|
62
73
|
return null;
|
|
63
74
|
}
|
|
64
|
-
return
|
|
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);
|
|
75
|
+
return times;
|
|
70
76
|
};
|
|
71
77
|
/** Age and count of `label` on `n`, or null when it is not stale yet (or unknown). */
|
|
72
78
|
const stale = async (n, label) => {
|
|
@@ -0,0 +1,349 @@
|
|
|
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
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -8,7 +8,9 @@ import { fixCommand } from './commands/fix.js';
|
|
|
8
8
|
import { loopCleanupCommand } from './commands/loop-cleanup.js';
|
|
9
9
|
import { loopEnvCommand } from './commands/loop-env.js';
|
|
10
10
|
import { loopGuardCommand } from './commands/loop-guard.js';
|
|
11
|
+
import { loopCommentCommand, loopVerdictCommand } from './commands/loop-marker.js';
|
|
11
12
|
import { loopReapCommand } from './commands/loop-reap.js';
|
|
13
|
+
import { loopTickCommand } from './commands/loop-tick.js';
|
|
12
14
|
import { loopWorktreeAddCommand } from './commands/loop-worktree.js';
|
|
13
15
|
import { setupProject } from './commands/setup.js';
|
|
14
16
|
import { copyPreset, PRESETS } from './utils/copy-preset.js';
|
|
@@ -412,6 +414,30 @@ loop
|
|
|
412
414
|
.addHelpText('after', '\nRead-only: prints a verdict per stall (block / drop-label / remove-worktree).\n' +
|
|
413
415
|
'Exits 1 when a gh query failed and the report is incomplete.\n')
|
|
414
416
|
.action(loopReapCommand);
|
|
417
|
+
loop
|
|
418
|
+
.command('comment <pr>')
|
|
419
|
+
.description("💬 Upsert the loop's one decision-marker comment on a PR")
|
|
420
|
+
.requiredOption('--body-file <path>', 'Comment text, without the marker (- for stdin)')
|
|
421
|
+
.option('-d, --dir <path>', 'Directory to resolve the GitHub repo from', process.cwd())
|
|
422
|
+
.option('--json', 'Emit machine-readable JSON output')
|
|
423
|
+
.action(loopCommentCommand);
|
|
424
|
+
loop
|
|
425
|
+
.command('verdict <pr>')
|
|
426
|
+
.description("⚖️ Read a reviewer arm's verdict marker for the PR's current head")
|
|
427
|
+
.requiredOption('--arm <arm>', 'Reviewer arm: code or sec')
|
|
428
|
+
.option('-d, --dir <path>', 'Directory to resolve the GitHub repo from', process.cwd())
|
|
429
|
+
.option('--json', 'Emit machine-readable JSON output')
|
|
430
|
+
.addHelpText('after', '\nPrints PASS, PASS-NOTES, CHANGES, or an empty line when none is posted.\n' +
|
|
431
|
+
'Exits 1 when the PR or its reviews cannot be read.\n')
|
|
432
|
+
.action(loopVerdictCommand);
|
|
433
|
+
loop
|
|
434
|
+
.command('tick')
|
|
435
|
+
.description("📋 Compute one tick's work list — guard, cleanup, reap, verdicts, pickups")
|
|
436
|
+
.option('--root <path>', 'Main checkout, or any worktree of it', process.cwd())
|
|
437
|
+
.option('--json', 'Emit machine-readable JSON output')
|
|
438
|
+
.addHelpText('after', '\nWrites no GitHub state: the caller applies every label, comment and spawn.\n' +
|
|
439
|
+
"Exit 1 or 2 halts the tick (loop guard's codes, or an unresolvable checkout).\n")
|
|
440
|
+
.action(loopTickCommand);
|
|
415
441
|
program.hook('preAction', async (_, actionCommand) => {
|
|
416
442
|
const name = actionCommand.name();
|
|
417
443
|
if (name === 'setup' || name === 'doctor' || name === 'fix') {
|
package/package.json
CHANGED