@rtorcato/repo-tooling 3.44.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 +2 -0
- package/dist/base/github-settings.js +13 -0
- package/dist/cli/commands/loop-reap.js +22 -16
- package/dist/cli/commands/loop-tick.js +349 -0
- package/dist/cli/index.js +9 -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,7 @@ 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. |
|
|
33
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. |
|
|
34
35
|
|
|
35
36
|
## Recommended workflows
|
|
@@ -124,6 +125,7 @@ A fixer may also **refuse** — the target file holds something the generator ca
|
|
|
124
125
|
- `src/cli/commands/loop-env.ts` — `loop env`: the skill's Pass 0 variables resolved in one call (#615)
|
|
125
126
|
- `src/cli/commands/loop-worktree.ts` — `loop worktree add`: Pass 4 worktree creation, dependency linking and the missing-link assertion (#616)
|
|
126
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)
|
|
127
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)
|
|
128
130
|
- `src/cli/generators/` — one file per concern (linting, testing, build, git, github-actions, security, misc)
|
|
129
131
|
- `tooling/` — every shipped preset, mirrored 1:1 with `package.json` `exports`
|
|
@@ -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
|
|
@@ -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
|
@@ -10,6 +10,7 @@ import { loopEnvCommand } from './commands/loop-env.js';
|
|
|
10
10
|
import { loopGuardCommand } from './commands/loop-guard.js';
|
|
11
11
|
import { loopCommentCommand, loopVerdictCommand } from './commands/loop-marker.js';
|
|
12
12
|
import { loopReapCommand } from './commands/loop-reap.js';
|
|
13
|
+
import { loopTickCommand } from './commands/loop-tick.js';
|
|
13
14
|
import { loopWorktreeAddCommand } from './commands/loop-worktree.js';
|
|
14
15
|
import { setupProject } from './commands/setup.js';
|
|
15
16
|
import { copyPreset, PRESETS } from './utils/copy-preset.js';
|
|
@@ -429,6 +430,14 @@ loop
|
|
|
429
430
|
.addHelpText('after', '\nPrints PASS, PASS-NOTES, CHANGES, or an empty line when none is posted.\n' +
|
|
430
431
|
'Exits 1 when the PR or its reviews cannot be read.\n')
|
|
431
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);
|
|
432
441
|
program.hook('preAction', async (_, actionCommand) => {
|
|
433
442
|
const name = actionCommand.name();
|
|
434
443
|
if (name === 'setup' || name === 'doctor' || name === 'fix') {
|
package/package.json
CHANGED