@erclx/aitk 3.43.2 → 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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/{toolkit-cli → aitk-cli}/REQUIREMENT.md +4 -4
- package/claude/skills/{toolkit-cli → aitk-cli}/SKILL.md +1 -1
- package/claude/skills/{toolkit-feedback → aitk-feedback-file}/REQUIREMENT.md +3 -3
- package/claude/skills/{toolkit-feedback → aitk-feedback-file}/SKILL.md +2 -2
- package/claude/skills/{toolkit-triage → aitk-feedback-triage}/REQUIREMENT.md +3 -3
- package/claude/skills/{toolkit-triage → aitk-feedback-triage}/SKILL.md +3 -3
- package/claude/skills/{toolkit-operator → aitk-operator}/REQUIREMENT.md +3 -3
- package/claude/skills/{toolkit-operator → aitk-operator}/SKILL.md +3 -3
- package/claude/skills/aitk-rollout/REQUIREMENT.md +59 -0
- package/claude/skills/aitk-rollout/SKILL.md +147 -0
- package/claude/skills/{claude-screencast → aitk-screencast}/REQUIREMENT.md +3 -3
- package/claude/skills/{claude-screencast → aitk-screencast}/SKILL.md +2 -2
- package/claude/skills/{claude-slides-draft → aitk-slides-draft}/REQUIREMENT.md +3 -3
- package/claude/skills/{claude-slides-draft → aitk-slides-draft}/SKILL.md +1 -1
- package/claude/skills/{cli-script → bash-cli-script}/REQUIREMENT.md +2 -2
- package/claude/skills/{cli-script → bash-cli-script}/SKILL.md +2 -2
- package/claude/skills/bash-script/REQUIREMENT.md +2 -2
- package/claude/skills/bash-script/SKILL.md +2 -2
- package/claude/skills/ci-workflow/REQUIREMENT.md +1 -1
- package/claude/skills/claude-memory-review/SKILL.md +2 -2
- package/claude/skills/claude-memory-review/references/receipt-format.md +1 -1
- package/claude/skills/claude-seed-sync/REQUIREMENT.md +1 -1
- package/claude/skills/claude-seed-sync/SKILL.md +1 -1
- package/claude/skills/git-issue/REQUIREMENT.md +2 -2
- package/claude/skills/git-issue/SKILL.md +1 -1
- package/claude/skills/{restate → restate-plainly}/REQUIREMENT.md +2 -2
- package/claude/skills/{restate → restate-plainly}/SKILL.md +2 -2
- package/claude/skills/setup-init/REQUIREMENT.md +1 -1
- package/claude/skills/setup-init/SKILL.md +1 -1
- package/claude/skills/write-human/REQUIREMENT.md +1 -1
- package/claude/skills/write-human/SKILL.md +1 -1
- package/docs/agents/demo.md +1 -1
- package/docs/agents/index.md +1 -0
- package/docs/agents/overview.md +2 -2
- package/docs/agents/scripting.md +1 -1
- package/docs/agents/sessions.md +11 -5
- package/docs/agents/targets.md +83 -0
- package/docs/ai-workflow.md +17 -16
- package/docs/target-projects.md +1 -1
- package/governance/rules/lang/120-bash.md +1 -1
- package/package.json +1 -1
- package/scripts/core/regen-tooling-paths.sh +1 -1
- package/scripts/core/verify.sh +1 -1
- package/src/claude/cases/authoring.ts +2 -2
- package/src/claude/cases/claude-workflow.ts +2 -2
- package/src/claude/cases/setup.ts +14 -6
- package/src/cli.ts +3 -0
- package/src/commands/demo.ts +1 -1
- package/src/commands/sessions.ts +24 -8
- package/src/commands/targets.ts +319 -0
- package/src/demo/beats.ts +1 -1
- package/src/sessions/claim.ts +7 -0
- package/src/sync/stamp.ts +9 -0
- package/src/targets/pulls.ts +250 -0
- package/src/targets/registry.ts +161 -0
- package/src/targets/resolve.ts +145 -0
- package/src/targets/sweep.ts +246 -0
- package/standards/issue.md +1 -1
- /package/claude/skills/{cli-script → bash-cli-script}/references/template.md +0 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import type { Command } from 'commander'
|
|
2
|
+
import { readPullsAcross, type TargetPulls } from '@/targets/pulls'
|
|
3
|
+
import {
|
|
4
|
+
type KnownTarget,
|
|
5
|
+
type ResolvedTargets,
|
|
6
|
+
resolveTargets,
|
|
7
|
+
} from '@/targets/resolve'
|
|
8
|
+
import { DEFAULT_DEPTH, type SweepBound } from '@/targets/sweep'
|
|
9
|
+
import {
|
|
10
|
+
intro,
|
|
11
|
+
logInfo,
|
|
12
|
+
logStep,
|
|
13
|
+
logWarn,
|
|
14
|
+
outro,
|
|
15
|
+
pipeOutput,
|
|
16
|
+
plural,
|
|
17
|
+
} from '@/ui'
|
|
18
|
+
|
|
19
|
+
interface ListOptions {
|
|
20
|
+
readonly json?: boolean
|
|
21
|
+
readonly sweep?: string[]
|
|
22
|
+
readonly depth?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface PullsOptions extends ListOptions {}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Reads the depth a sweep is bounded by, or null when the value is not one.
|
|
29
|
+
*
|
|
30
|
+
* `Number('abc')` is `NaN` and every `level >= NaN` test is false, so an
|
|
31
|
+
* unchecked value walks to the bottom of whatever root it was given while
|
|
32
|
+
* the bound reports a depth of `NaN`. Both halves of the guarantee go at once,
|
|
33
|
+
* which is why this refuses rather than falling back to the default.
|
|
34
|
+
*/
|
|
35
|
+
function readDepth(value: string | undefined): number | null {
|
|
36
|
+
const depth = Number(value ?? DEFAULT_DEPTH)
|
|
37
|
+
|
|
38
|
+
return Number.isInteger(depth) && depth >= 0 ? depth : null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const REFUSALS: Record<string, string> = {
|
|
42
|
+
'not-a-directory': 'the path is not a directory, so nothing was read there',
|
|
43
|
+
'gh-unavailable': 'gh is not on the path, so no pull request could be read',
|
|
44
|
+
'list-failed':
|
|
45
|
+
'the open pull request list could not be read, so this is not a target with no work',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function register(program: Command): void {
|
|
49
|
+
const targets = program
|
|
50
|
+
.command('targets')
|
|
51
|
+
.description('Report the projects this toolkit has installed into')
|
|
52
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
53
|
+
|
|
54
|
+
targets
|
|
55
|
+
.command('list')
|
|
56
|
+
.description('Report every known target with where the answer came from')
|
|
57
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
58
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
59
|
+
.option(
|
|
60
|
+
'--sweep <path...>',
|
|
61
|
+
'Also walk these roots for targets the record never held',
|
|
62
|
+
)
|
|
63
|
+
.option(
|
|
64
|
+
'--depth <n>',
|
|
65
|
+
'How deep below each swept root to walk',
|
|
66
|
+
String(DEFAULT_DEPTH),
|
|
67
|
+
)
|
|
68
|
+
.addHelpText('after', LIST_HELP)
|
|
69
|
+
.action(async (opts: ListOptions) => {
|
|
70
|
+
process.exitCode = await runList(opts)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
targets
|
|
74
|
+
.command('pulls')
|
|
75
|
+
.description(
|
|
76
|
+
'Report the open pull request, checks, and review heading per target',
|
|
77
|
+
)
|
|
78
|
+
.argument('[path...]', 'Targets to read, defaulting to every known one')
|
|
79
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
80
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
81
|
+
.option('--sweep <path...>', 'Also walk these roots when no path is given')
|
|
82
|
+
.option(
|
|
83
|
+
'--depth <n>',
|
|
84
|
+
'How deep below each swept root to walk',
|
|
85
|
+
String(DEFAULT_DEPTH),
|
|
86
|
+
)
|
|
87
|
+
.addHelpText('after', PULLS_HELP)
|
|
88
|
+
.action(async (paths: string[], opts: PullsOptions) => {
|
|
89
|
+
process.exitCode = await runPulls(paths, opts)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const LIST_HELP = [
|
|
94
|
+
'',
|
|
95
|
+
'Sources:',
|
|
96
|
+
' Every sync that stamps a target records it in a machine-level index, so',
|
|
97
|
+
' a project installed since that shipped is known without being named.',
|
|
98
|
+
' --sweep walks the roots given for anything installed before it, and a',
|
|
99
|
+
' target found in two clones is reported once with both paths.',
|
|
100
|
+
'',
|
|
101
|
+
'Exit codes:',
|
|
102
|
+
' 0 the population was read',
|
|
103
|
+
' 1 refused, with the reason on stderr',
|
|
104
|
+
'',
|
|
105
|
+
'An absent index is reported as unknown rather than as no targets. Nothing',
|
|
106
|
+
'was read in that case, so a count of zero would be a confident wrong answer.',
|
|
107
|
+
'',
|
|
108
|
+
'The JSON carries a "bound" object whenever a sweep ran, naming the roots',
|
|
109
|
+
'walked, the depth, the folders the walk stopped at, the roots it could not',
|
|
110
|
+
'read, and the directory symlinks the walk did not follow. A sweep cannot',
|
|
111
|
+
'see another machine or a clone under a path nobody named, so read the bound',
|
|
112
|
+
'before treating the count as the population.',
|
|
113
|
+
'',
|
|
114
|
+
'An exit code says nothing about a call made from a session, since a shell',
|
|
115
|
+
'profile may wrap the binary in a function taking its status from a later',
|
|
116
|
+
'command. Read the record rather than the exit when a skill consumes this.',
|
|
117
|
+
'',
|
|
118
|
+
'Examples:',
|
|
119
|
+
' aitk targets list',
|
|
120
|
+
' aitk targets list --json',
|
|
121
|
+
' aitk targets list --sweep ~/repos --json',
|
|
122
|
+
'',
|
|
123
|
+
].join('\n')
|
|
124
|
+
|
|
125
|
+
const PULLS_HELP = [
|
|
126
|
+
'',
|
|
127
|
+
'Reads, per target, every open pull request with its checks and the heading',
|
|
128
|
+
'its newest review pass carries. Naming paths reads those and looks up',
|
|
129
|
+
'nothing; naming none reads every target `aitk targets list` reports.',
|
|
130
|
+
'',
|
|
131
|
+
'Exit codes:',
|
|
132
|
+
' 0 at least one target was read',
|
|
133
|
+
' 1 refused, or every target refused',
|
|
134
|
+
'',
|
|
135
|
+
'A target that could not be read carries a "reason" rather than an empty',
|
|
136
|
+
'pull list, since reading a failed query as no open work is what reports a',
|
|
137
|
+
'target as done having read nothing.',
|
|
138
|
+
'',
|
|
139
|
+
'"checks" is null when GitHub reported no check at all, which is not the',
|
|
140
|
+
'same answer as passing. "review" is "open" while the newest pass carries',
|
|
141
|
+
'## Review, "closed" once one carries ## Review closed, and null when no',
|
|
142
|
+
'pass has landed. "reviewReadable" is false when that query failed, which',
|
|
143
|
+
'leaves "review" covering nothing.',
|
|
144
|
+
'',
|
|
145
|
+
'Examples:',
|
|
146
|
+
' aitk targets pulls',
|
|
147
|
+
' aitk targets pulls ../caret ../stackr --json',
|
|
148
|
+
'',
|
|
149
|
+
].join('\n')
|
|
150
|
+
|
|
151
|
+
async function runList(opts: ListOptions): Promise<number> {
|
|
152
|
+
intro('aitk targets list')
|
|
153
|
+
|
|
154
|
+
const depth = readDepth(opts.depth)
|
|
155
|
+
|
|
156
|
+
if (depth === null) return refuseDepth(opts)
|
|
157
|
+
|
|
158
|
+
const resolved = await resolveTargets({ sweep: opts.sweep, depth })
|
|
159
|
+
|
|
160
|
+
const unknown = reportTargets(resolved)
|
|
161
|
+
if (resolved.bound) reportBound(resolved.bound)
|
|
162
|
+
outro()
|
|
163
|
+
|
|
164
|
+
if (opts.json) {
|
|
165
|
+
process.stdout.write(
|
|
166
|
+
`${JSON.stringify({
|
|
167
|
+
registry: resolved.registry?.path ?? null,
|
|
168
|
+
known: resolved.registry?.kind !== 'absent',
|
|
169
|
+
targets: resolved.targets,
|
|
170
|
+
bound: resolved.bound,
|
|
171
|
+
})}\n`,
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return unknown ? 1 : 0
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function runPulls(paths: string[], opts: PullsOptions): Promise<number> {
|
|
179
|
+
intro('aitk targets pulls')
|
|
180
|
+
|
|
181
|
+
const depth = readDepth(opts.depth)
|
|
182
|
+
|
|
183
|
+
if (depth === null) return refuseDepth(opts)
|
|
184
|
+
|
|
185
|
+
const resolved = await resolveTargets({ paths, sweep: opts.sweep, depth })
|
|
186
|
+
|
|
187
|
+
if (resolved.targets.length === 0) {
|
|
188
|
+
logStep('Refused')
|
|
189
|
+
logWarn(
|
|
190
|
+
resolved.registry?.kind === 'absent'
|
|
191
|
+
? `No target index at ${resolved.registry.path}, and no path was given. Name the targets, or sweep for them with --sweep.`
|
|
192
|
+
: 'No target resolved, so nothing was read.',
|
|
193
|
+
)
|
|
194
|
+
outro()
|
|
195
|
+
|
|
196
|
+
if (opts.json) {
|
|
197
|
+
process.stdout.write(
|
|
198
|
+
`${JSON.stringify({ reason: 'no-targets', targets: [] })}\n`,
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return 1
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// One clone per project. Two checkouts sharing an origin answer the same
|
|
206
|
+
// query, so reading both spends the rate limit to print one answer twice.
|
|
207
|
+
const reports = await readPullsAcross(
|
|
208
|
+
resolved.targets.map((target) => target.paths[0] ?? ''),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
reportPulls(reports)
|
|
212
|
+
outro()
|
|
213
|
+
|
|
214
|
+
if (opts.json) {
|
|
215
|
+
process.stdout.write(`${JSON.stringify({ targets: reports })}\n`)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return reports.every((report) => report.kind === 'refused') ? 1 : 0
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function refuseDepth(opts: ListOptions): number {
|
|
222
|
+
logStep('Refused')
|
|
223
|
+
logWarn(
|
|
224
|
+
`--depth takes a whole number of levels and was given ${opts.depth}. A value that is not one leaves the walk with no cap at all.`,
|
|
225
|
+
)
|
|
226
|
+
outro()
|
|
227
|
+
|
|
228
|
+
if (opts.json) {
|
|
229
|
+
process.stdout.write(
|
|
230
|
+
`${JSON.stringify({ reason: 'bad-depth', depth: opts.depth, targets: [] })}\n`,
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return 1
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Returns whether the population is unknown, which is the one refusal this read has. */
|
|
238
|
+
function reportTargets(resolved: ResolvedTargets): boolean {
|
|
239
|
+
logStep('Targets')
|
|
240
|
+
|
|
241
|
+
if (resolved.registry?.kind === 'absent' && resolved.bound === null) {
|
|
242
|
+
logWarn(
|
|
243
|
+
`No target index at ${resolved.registry.path}. Nothing was read, so this is not a machine with no targets. Sweep for them with --sweep, or run a sync in a target to record it.`,
|
|
244
|
+
)
|
|
245
|
+
return true
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (resolved.targets.length === 0) {
|
|
249
|
+
logInfo('No target found.')
|
|
250
|
+
return false
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
logInfo(plural(resolved.targets.length, 'target'))
|
|
254
|
+
pipeOutput(resolved.targets.map(describe).join('\n'))
|
|
255
|
+
return false
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function describe(target: KnownTarget): string {
|
|
259
|
+
const flags = [target.source, ...(target.legacy ? ['legacy stamp'] : [])]
|
|
260
|
+
const clones =
|
|
261
|
+
target.paths.length > 1 ? `\n ${target.paths.slice(1).join('\n ')}` : ''
|
|
262
|
+
|
|
263
|
+
return `${target.paths[0]} ${flags.join(', ')}${clones}`
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function reportBound(bound: SweepBound): void {
|
|
267
|
+
logStep('Bound')
|
|
268
|
+
logInfo(
|
|
269
|
+
`Walked ${plural(bound.roots.length, 'root')} to depth ${bound.depth} on this machine alone.`,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
if (bound.truncated.length > 0) {
|
|
273
|
+
logWarn(
|
|
274
|
+
`${plural(bound.truncated.length, 'folder')} hit the depth cap, so a target below one is unseen.`,
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (bound.unreadable.length > 0) {
|
|
279
|
+
logWarn(
|
|
280
|
+
`${plural(bound.unreadable.length, 'root')} could not be read: ${bound.unreadable.join(', ')}`,
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (bound.symlinks.length > 0) {
|
|
285
|
+
const verb = bound.symlinks.length === 1 ? 'was' : 'were'
|
|
286
|
+
logWarn(
|
|
287
|
+
`${plural(bound.symlinks.length, 'symlink')} to a directory ${verb} not followed: ${bound.symlinks.join(', ')}`,
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function reportPulls(reports: readonly TargetPulls[]): void {
|
|
293
|
+
logStep('Pull requests')
|
|
294
|
+
|
|
295
|
+
for (const report of reports) {
|
|
296
|
+
if (report.kind === 'refused') {
|
|
297
|
+
logWarn(`${report.path}: ${REFUSALS[report.reason] ?? report.reason}`)
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (report.pulls.length === 0) {
|
|
302
|
+
logInfo(`${report.path}: nothing open.`)
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
logInfo(`${report.path}`)
|
|
307
|
+
pipeOutput(
|
|
308
|
+
report.pulls
|
|
309
|
+
.map((pull) => {
|
|
310
|
+
const checks = pull.checks ?? 'no checks'
|
|
311
|
+
const review = pull.reviewReadable
|
|
312
|
+
? (pull.review ?? 'no pass')
|
|
313
|
+
: 'review unreadable'
|
|
314
|
+
return ` #${pull.number} ${checks} ${review} ${pull.url}`
|
|
315
|
+
})
|
|
316
|
+
.join('\n'),
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
}
|
package/src/demo/beats.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Reads the human-facing draft `
|
|
2
|
+
* Reads the human-facing draft `aitk-screencast` writes. Nothing here knows
|
|
3
3
|
* about a browser: the draft is prose aimed at a person, and turning it into
|
|
4
4
|
* something executable is `@/demo/compile`'s job.
|
|
5
5
|
*/
|
package/src/sessions/claim.ts
CHANGED
|
@@ -24,6 +24,13 @@ export interface ClaimReport {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export interface ClaimOptions {
|
|
27
|
+
/**
|
|
28
|
+
* The repository the claim is answered about, not merely where the caller
|
|
29
|
+
* stands. Every reading below is taken against it, so handing another
|
|
30
|
+
* project's path asks about that project, which is what lets a dispatcher in
|
|
31
|
+
* one repository see a branch held in another. Defaults to the caller's own
|
|
32
|
+
* directory, so a call omitting it answers exactly as it always has.
|
|
33
|
+
*/
|
|
27
34
|
readonly cwd?: string
|
|
28
35
|
readonly resolve?: () => Promise<SessionReport>
|
|
29
36
|
readonly listWorktrees?: (cwd: string) => Promise<readonly WorktreeEntry[]>
|
package/src/sync/stamp.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
3
3
|
import { mkdir, writeFile } from 'node:fs/promises'
|
|
4
4
|
import { dirname, join, sep } from 'node:path'
|
|
5
5
|
import { execa } from 'execa'
|
|
6
|
+
import { recordTarget } from '@/targets/registry'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Domains the stamp can record. Governance attributes file by file through the
|
|
@@ -209,6 +210,14 @@ async function putDomain(
|
|
|
209
210
|
const path = stampPath(target)
|
|
210
211
|
await mkdir(dirname(path), { recursive: true })
|
|
211
212
|
await writeFile(path, `${JSON.stringify(stamp, null, 2)}\n`)
|
|
213
|
+
|
|
214
|
+
// Every install and sync that stamps a target passes through here, which is
|
|
215
|
+
// what makes this the one place the machine-level index can be kept without
|
|
216
|
+
// each command remembering to. Its outcome is dropped rather than reported:
|
|
217
|
+
// the stamp just written is the authoritative record of this install, the
|
|
218
|
+
// index is a cache over every such stamp, and a state folder nobody can
|
|
219
|
+
// write is not a reason to fail a sync that already landed its files.
|
|
220
|
+
recordTarget(target, now)
|
|
212
221
|
}
|
|
213
222
|
|
|
214
223
|
const commitCache = new Map<string, Promise<string | undefined>>()
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
import { gitEnv } from '@/git-env'
|
|
3
|
+
import { isDirectory } from '@/target'
|
|
4
|
+
|
|
5
|
+
const GH_TIMEOUT_MS = 30_000
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The two headings a review pass posts under.
|
|
9
|
+
*
|
|
10
|
+
* Owned by `claude-pr-review`, which states the full set once, and pinned here
|
|
11
|
+
* the way `claude-orchestrate/scripts/poll.sh` pins them. All three surfaces
|
|
12
|
+
* ship separately, so a heading added in that skill goes stale here with
|
|
13
|
+
* nothing comparing the copies.
|
|
14
|
+
*/
|
|
15
|
+
const REVIEW_OPEN = '## Review'
|
|
16
|
+
const REVIEW_CLOSED = '## Review closed'
|
|
17
|
+
|
|
18
|
+
/** Why a target produced no reading, so an unreachable one never reads as having no work. */
|
|
19
|
+
export type TargetRefusal = 'not-a-directory' | 'gh-unavailable' | 'list-failed'
|
|
20
|
+
|
|
21
|
+
export type ChecksState = 'passing' | 'failing' | 'pending'
|
|
22
|
+
|
|
23
|
+
/** Whether the newest review pass left work owed. */
|
|
24
|
+
export type ReviewState = 'open' | 'closed'
|
|
25
|
+
|
|
26
|
+
export interface PullState {
|
|
27
|
+
readonly number: number
|
|
28
|
+
readonly title: string
|
|
29
|
+
readonly url: string
|
|
30
|
+
readonly head: string
|
|
31
|
+
/** Null when GitHub reported no check at all, which is not the same answer as passing. */
|
|
32
|
+
readonly checks: ChecksState | null
|
|
33
|
+
/** Null when no pass carrying a review heading has landed on the thread. */
|
|
34
|
+
readonly review: ReviewState | null
|
|
35
|
+
/** False when the review read failed, leaving `review` covering nothing. */
|
|
36
|
+
readonly reviewReadable: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type TargetPulls =
|
|
40
|
+
| {
|
|
41
|
+
readonly kind: 'refused'
|
|
42
|
+
readonly path: string
|
|
43
|
+
readonly reason: TargetRefusal
|
|
44
|
+
}
|
|
45
|
+
| {
|
|
46
|
+
readonly kind: 'read'
|
|
47
|
+
readonly path: string
|
|
48
|
+
readonly pulls: readonly PullState[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Runs one `gh` invocation in a target and hands back its stdout, or null when it failed. */
|
|
52
|
+
export type GhRunner = (
|
|
53
|
+
cwd: string,
|
|
54
|
+
args: readonly string[],
|
|
55
|
+
) => Promise<string | null>
|
|
56
|
+
|
|
57
|
+
export interface PullsOptions {
|
|
58
|
+
readonly run?: GhRunner
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `gh` resolves its repository through the same environment variables git does
|
|
63
|
+
* and they beat `cwd`, so a run from inside a hook would read whichever
|
|
64
|
+
* repository that hook's environment names rather than the target handed here.
|
|
65
|
+
*/
|
|
66
|
+
const runGh: GhRunner = async (cwd, args) => {
|
|
67
|
+
if (Bun.which('gh') === null) return null
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const result = await execa('gh', [...args], {
|
|
71
|
+
cwd,
|
|
72
|
+
timeout: GH_TIMEOUT_MS,
|
|
73
|
+
env: gitEnv(),
|
|
74
|
+
extendEnv: false,
|
|
75
|
+
})
|
|
76
|
+
return result.stdout
|
|
77
|
+
} catch {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface RawPull {
|
|
83
|
+
readonly number?: number
|
|
84
|
+
readonly title?: string
|
|
85
|
+
readonly url?: string
|
|
86
|
+
readonly headRefOid?: string
|
|
87
|
+
readonly statusCheckRollup?: readonly RawCheck[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface RawCheck {
|
|
91
|
+
readonly status?: string
|
|
92
|
+
readonly conclusion?: string
|
|
93
|
+
readonly state?: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface RawReview {
|
|
97
|
+
readonly body?: string
|
|
98
|
+
readonly submittedAt?: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const FAILED = new Set([
|
|
102
|
+
'FAILURE',
|
|
103
|
+
'TIMED_OUT',
|
|
104
|
+
'CANCELLED',
|
|
105
|
+
'ACTION_REQUIRED',
|
|
106
|
+
'STARTUP_FAILURE',
|
|
107
|
+
'ERROR',
|
|
108
|
+
])
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Collapses every check on a head into one word.
|
|
112
|
+
*
|
|
113
|
+
* A failure outranks a pending one, because a run still going cannot clear a
|
|
114
|
+
* job that already failed and reporting the head as pending would invite a
|
|
115
|
+
* wait for an answer that has arrived.
|
|
116
|
+
*/
|
|
117
|
+
export function rollup(checks: readonly RawCheck[]): ChecksState | null {
|
|
118
|
+
if (checks.length === 0) return null
|
|
119
|
+
|
|
120
|
+
const verdicts = checks.map((check) => check.conclusion ?? check.state ?? '')
|
|
121
|
+
|
|
122
|
+
if (verdicts.some((verdict) => FAILED.has(verdict))) return 'failing'
|
|
123
|
+
|
|
124
|
+
const running = checks.some(
|
|
125
|
+
(check) =>
|
|
126
|
+
(check.status !== undefined && check.status !== 'COMPLETED') ||
|
|
127
|
+
check.state === 'PENDING' ||
|
|
128
|
+
(check.conclusion === undefined && check.state === undefined),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return running ? 'pending' : 'passing'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Reads the heading of the newest pass carrying one, matching on the first line
|
|
136
|
+
* alone the way `poll.sh` does.
|
|
137
|
+
*
|
|
138
|
+
* The reviews arrive oldest first, so the last match is the current state of
|
|
139
|
+
* the thread. A pass carrying neither heading is somebody reviewing by hand and
|
|
140
|
+
* says nothing about whether the loop owes work.
|
|
141
|
+
*/
|
|
142
|
+
export function latestReview(
|
|
143
|
+
reviews: readonly RawReview[],
|
|
144
|
+
): ReviewState | null {
|
|
145
|
+
let state: ReviewState | null = null
|
|
146
|
+
|
|
147
|
+
for (const review of reviews) {
|
|
148
|
+
const first = (review.body ?? '').split('\n')[0]?.replace(/\r$/, '')
|
|
149
|
+
if (first === REVIEW_OPEN) state = 'open'
|
|
150
|
+
else if (first === REVIEW_CLOSED) state = 'closed'
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return state
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parse<T>(text: string | null): T | null {
|
|
157
|
+
if (text === null) return null
|
|
158
|
+
try {
|
|
159
|
+
return JSON.parse(text) as T
|
|
160
|
+
} catch {
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Reports the open pull requests in one target with their checks and the
|
|
167
|
+
* heading their newest review pass carries.
|
|
168
|
+
*
|
|
169
|
+
* A list that failed and a target with no open pull request are separated
|
|
170
|
+
* rather than collapsed, since reading the first as the second reports a target
|
|
171
|
+
* as done when nothing was read at all. That is the failure mode the shell loop
|
|
172
|
+
* this replaces had no way to surface.
|
|
173
|
+
*/
|
|
174
|
+
export async function readTargetPulls(
|
|
175
|
+
path: string,
|
|
176
|
+
opts: PullsOptions = {},
|
|
177
|
+
): Promise<TargetPulls> {
|
|
178
|
+
const run = opts.run ?? runGh
|
|
179
|
+
|
|
180
|
+
if (!isDirectory(path))
|
|
181
|
+
return { kind: 'refused', path, reason: 'not-a-directory' }
|
|
182
|
+
|
|
183
|
+
if (opts.run === undefined && Bun.which('gh') === null) {
|
|
184
|
+
return { kind: 'refused', path, reason: 'gh-unavailable' }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const listed = parse<readonly RawPull[]>(
|
|
188
|
+
await run(path, [
|
|
189
|
+
'pr',
|
|
190
|
+
'list',
|
|
191
|
+
'--state',
|
|
192
|
+
'open',
|
|
193
|
+
'--json',
|
|
194
|
+
'number,title,url,headRefOid,statusCheckRollup',
|
|
195
|
+
]),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if (listed === null) return { kind: 'refused', path, reason: 'list-failed' }
|
|
199
|
+
|
|
200
|
+
const pulls = await Promise.all(
|
|
201
|
+
listed
|
|
202
|
+
.filter(
|
|
203
|
+
(raw): raw is RawPull & { number: number } =>
|
|
204
|
+
typeof raw.number === 'number',
|
|
205
|
+
)
|
|
206
|
+
.map(async (raw) => {
|
|
207
|
+
// One query per pull request, so a review read that failed surfaces on
|
|
208
|
+
// the thread it failed for rather than emptying the whole target.
|
|
209
|
+
const reviews = parse<{ reviews?: readonly RawReview[] }>(
|
|
210
|
+
await run(path, [
|
|
211
|
+
'pr',
|
|
212
|
+
'view',
|
|
213
|
+
String(raw.number),
|
|
214
|
+
'--json',
|
|
215
|
+
'reviews',
|
|
216
|
+
]),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
number: raw.number,
|
|
221
|
+
title: raw.title ?? '',
|
|
222
|
+
url: raw.url ?? '',
|
|
223
|
+
head: raw.headRefOid ?? '',
|
|
224
|
+
checks: rollup(raw.statusCheckRollup ?? []),
|
|
225
|
+
review: reviews === null ? null : latestReview(reviews.reviews ?? []),
|
|
226
|
+
reviewReadable: reviews !== null,
|
|
227
|
+
}
|
|
228
|
+
}),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
return { kind: 'read', path, pulls }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Reads every target, one at a time.
|
|
236
|
+
*
|
|
237
|
+
* The reads are serial rather than batched because each one spends a GitHub API
|
|
238
|
+
* quota shared across all of them, and a wave running over a dozen targets that
|
|
239
|
+
* fired them together would meet the secondary rate limit rather than an answer.
|
|
240
|
+
*/
|
|
241
|
+
export async function readPullsAcross(
|
|
242
|
+
paths: readonly string[],
|
|
243
|
+
opts: PullsOptions = {},
|
|
244
|
+
): Promise<readonly TargetPulls[]> {
|
|
245
|
+
const reports: TargetPulls[] = []
|
|
246
|
+
|
|
247
|
+
for (const path of paths) reports.push(await readTargetPulls(path, opts))
|
|
248
|
+
|
|
249
|
+
return reports
|
|
250
|
+
}
|