@erclx/canon 4.20.1 → 4.22.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/README.md +4 -4
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-address-review/SKILL.md +6 -6
- package/claude/skills/claude-orchestrate/references/orchestrator-poll.md +2 -0
- package/claude/skills/claude-orchestrate/scripts/poll.sh +19 -1
- package/claude/skills/claude-pr-review/SKILL.md +1 -1
- package/claude/skills/git-ship/SKILL.md +1 -1
- package/docs/agents/commands.md +3 -0
- package/docs/agents/docs.md +11 -1
- package/docs/agents/index.md +1 -0
- package/docs/agents/pr-reads.md +73 -0
- package/docs/index.md +1 -7
- package/docs/target-projects.md +2 -2
- package/docs/{ai-workflow.md → workflow/ai-workflow.md} +6 -6
- package/docs/workflow/index.md +18 -0
- package/docs/{operating-model.md → workflow/operating-model.md} +12 -5
- package/docs/{visual-design-workflow.md → workflow/visual-design-workflow.md} +17 -17
- package/docs/{zshrc-aliases.md → workflow/zshrc-aliases.md} +3 -1
- package/package.json +1 -1
- package/scripts/docs/list.sh +21 -2
- package/src/cli.ts +1 -1
- package/src/commands/pr.ts +368 -4
- package/src/docs/read.ts +88 -25
- package/src/pr/checks.ts +83 -0
- package/src/pr/head.ts +108 -0
- package/src/targets/pulls.ts +7 -1
package/src/commands/pr.ts
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
compareKeyChanges,
|
|
16
16
|
treeRoots,
|
|
17
17
|
} from '@/pr/bijection'
|
|
18
|
+
import { type CheckRunListing, collapseChecks } from '@/pr/checks'
|
|
19
|
+
import { type HeadRefusal, resolveHead, resolveTip } from '@/pr/head'
|
|
18
20
|
import { KEY_CHANGES } from '@/pr/paths'
|
|
19
21
|
import { intro, logInfo, logStep, logWarn, outro, plural } from '@/ui'
|
|
20
22
|
|
|
@@ -41,6 +43,32 @@ interface KeyChangesOptions {
|
|
|
41
43
|
readonly json?: boolean
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
interface ReadOptions {
|
|
47
|
+
readonly root?: string
|
|
48
|
+
readonly json?: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Why a head-sensitive read produced no answer about a commit. */
|
|
52
|
+
type PullRefusal = 'gh-missing' | 'gh-failed' | 'no-branch' | 'runs-unreadable'
|
|
53
|
+
|
|
54
|
+
/** What a reader does about each way the two sha-keyed verbs produced nothing. */
|
|
55
|
+
const PULL_REFUSALS: Record<PullRefusal | HeadRefusal, string> = {
|
|
56
|
+
'gh-missing':
|
|
57
|
+
'gh is not on the path, so no pull request could be resolved. Name the branch through a checkout that carries one.',
|
|
58
|
+
'gh-failed':
|
|
59
|
+
'gh could not answer for this branch. Name the pull request number instead.',
|
|
60
|
+
'no-branch':
|
|
61
|
+
'The pull request carries no head branch name, so no ref could be read for it.',
|
|
62
|
+
'unresolvable-ref':
|
|
63
|
+
'git could not read the remote, so the branch tip is unknown. That is a failed read rather than an absent branch, so nothing is reported about the head.',
|
|
64
|
+
'no-remote-branch':
|
|
65
|
+
'The remote carries no branch by that name. It was deleted or never pushed, so there is no tip to compare against.',
|
|
66
|
+
'no-object-head':
|
|
67
|
+
'The pull request object reported no head commit, so there is nothing to compare the tip against.',
|
|
68
|
+
'runs-unreadable':
|
|
69
|
+
'The check runs for this commit could not be read. An empty answer here would report a commit as having no check rather than as unread, so nothing is reported.',
|
|
70
|
+
}
|
|
71
|
+
|
|
44
72
|
/** Why the read produced no comparison, ahead of the ones the compare owns. */
|
|
45
73
|
type SourceRefusal =
|
|
46
74
|
| 'gh-missing'
|
|
@@ -139,6 +167,75 @@ export function register(program: Command): void {
|
|
|
139
167
|
.action(async (number: string | undefined, opts: KeyChangesOptions) => {
|
|
140
168
|
process.exitCode = await runKeyChanges(number, opts)
|
|
141
169
|
})
|
|
170
|
+
|
|
171
|
+
pr.command('head')
|
|
172
|
+
.description(
|
|
173
|
+
"Compare a pull request's reported head against the branch tip",
|
|
174
|
+
)
|
|
175
|
+
.argument('[number]', 'Pull request to read, defaulting to this branch')
|
|
176
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
177
|
+
.option('--root <path>', 'Repository to read, defaulting to the cwd')
|
|
178
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
179
|
+
.addHelpText(
|
|
180
|
+
'after',
|
|
181
|
+
[
|
|
182
|
+
'',
|
|
183
|
+
'The pull request object lags the branch ref by up to a minute after a',
|
|
184
|
+
'push and reports nothing about the lag, so a session reading',
|
|
185
|
+
'`headRefOid` alone calls a pushed commit unpushed. This resolves the',
|
|
186
|
+
'tip from the remote with `git ls-remote` and reports which commit each',
|
|
187
|
+
'source names.',
|
|
188
|
+
'',
|
|
189
|
+
'Read the verdict off the record rather than off the exit. A stale head',
|
|
190
|
+
'exits 0, because an operator shell profile can wrap this binary in a',
|
|
191
|
+
'function whose status comes from a trailing command.',
|
|
192
|
+
'',
|
|
193
|
+
'Exit codes:',
|
|
194
|
+
' 0 the tip resolved, whether the object agreed with it or not',
|
|
195
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
196
|
+
'',
|
|
197
|
+
'Examples:',
|
|
198
|
+
' canon pr head',
|
|
199
|
+
' canon pr head 1341 --json',
|
|
200
|
+
'',
|
|
201
|
+
].join('\n'),
|
|
202
|
+
)
|
|
203
|
+
.action(async (number: string | undefined, opts: ReadOptions) => {
|
|
204
|
+
process.exitCode = await runHead(number, opts)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
pr.command('checks')
|
|
208
|
+
.description('Report the check runs belonging to the branch tip')
|
|
209
|
+
.argument('[number]', 'Pull request to read, defaulting to this branch')
|
|
210
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
211
|
+
.option('--root <path>', 'Repository to read, defaulting to the cwd')
|
|
212
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
213
|
+
.addHelpText(
|
|
214
|
+
'after',
|
|
215
|
+
[
|
|
216
|
+
'',
|
|
217
|
+
'`gh pr checks` cannot be made sha-aware at all: its field set carries no',
|
|
218
|
+
'sha, so a caller cannot learn which commit its answer describes. This',
|
|
219
|
+
'resolves the tip from the remote and reads the check runs keyed on it.',
|
|
220
|
+
'',
|
|
221
|
+
'Pending is reported for a tip carrying no run yet as well as for one',
|
|
222
|
+
'still going, since the endpoint has answered with a non-zero count and',
|
|
223
|
+
'an empty row list, and reading that as passing is the false green the',
|
|
224
|
+
'sha key alone does not close.',
|
|
225
|
+
'',
|
|
226
|
+
'Exit codes:',
|
|
227
|
+
' 0 the runs for the tip were read, whatever they say',
|
|
228
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
229
|
+
'',
|
|
230
|
+
'Examples:',
|
|
231
|
+
' canon pr checks',
|
|
232
|
+
' canon pr checks 1341 --json',
|
|
233
|
+
'',
|
|
234
|
+
].join('\n'),
|
|
235
|
+
)
|
|
236
|
+
.action(async (number: string | undefined, opts: ReadOptions) => {
|
|
237
|
+
process.exitCode = await runChecks(number, opts)
|
|
238
|
+
})
|
|
142
239
|
}
|
|
143
240
|
|
|
144
241
|
interface PullRequestRead {
|
|
@@ -400,20 +497,287 @@ async function runKeyChanges(
|
|
|
400
497
|
return report.unmet.length === 0 ? 0 : 2
|
|
401
498
|
}
|
|
402
499
|
|
|
500
|
+
/** The head branch and reported head of the pull request a caller named. */
|
|
501
|
+
interface PullIdentity {
|
|
502
|
+
readonly number: number | undefined
|
|
503
|
+
readonly branch: string
|
|
504
|
+
readonly head?: string
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
type IdentityRead =
|
|
508
|
+
| { readonly kind: 'read'; readonly identity: PullIdentity }
|
|
509
|
+
| { readonly kind: 'refused'; readonly reason: PullRefusal }
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Runs one `gh` invocation and hands back its stdout, or null when it failed.
|
|
513
|
+
*
|
|
514
|
+
* See src/worktrees/reclaim.ts for why gh needs the stripped environment: it
|
|
515
|
+
* resolves its repository through the same variables git does and they beat
|
|
516
|
+
* `cwd`, so a run from inside a hook would answer for another repository.
|
|
517
|
+
*/
|
|
518
|
+
async function gh(
|
|
519
|
+
cwd: string,
|
|
520
|
+
args: readonly string[],
|
|
521
|
+
): Promise<string | null> {
|
|
522
|
+
try {
|
|
523
|
+
const result = await execa('gh', [...args], {
|
|
524
|
+
cwd,
|
|
525
|
+
timeout: GH_TIMEOUT_MS,
|
|
526
|
+
env: gitEnv(),
|
|
527
|
+
extendEnv: false,
|
|
528
|
+
})
|
|
529
|
+
return result.stdout
|
|
530
|
+
} catch {
|
|
531
|
+
return null
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Reads the head branch and the head the pull request object reports.
|
|
537
|
+
*
|
|
538
|
+
* Both come from one call, so the branch a ref is read for and the head that
|
|
539
|
+
* ref is compared against describe the same object.
|
|
540
|
+
*/
|
|
541
|
+
async function readIdentity(
|
|
542
|
+
cwd: string,
|
|
543
|
+
number: string | undefined,
|
|
544
|
+
): Promise<IdentityRead> {
|
|
545
|
+
if (Bun.which('gh') === null) {
|
|
546
|
+
return { kind: 'refused', reason: 'gh-missing' }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const args = ['pr', 'view']
|
|
550
|
+
if (number !== undefined) args.push(number)
|
|
551
|
+
args.push('--json', 'number,headRefName,headRefOid')
|
|
552
|
+
|
|
553
|
+
const stdout = await gh(cwd, args)
|
|
554
|
+
if (stdout === null) return { kind: 'refused', reason: 'gh-failed' }
|
|
555
|
+
|
|
556
|
+
let row: { number?: number; headRefName?: string; headRefOid?: string }
|
|
557
|
+
try {
|
|
558
|
+
row = JSON.parse(stdout)
|
|
559
|
+
} catch {
|
|
560
|
+
return { kind: 'refused', reason: 'gh-failed' }
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (row.headRefName === undefined || row.headRefName === '') {
|
|
564
|
+
return { kind: 'refused', reason: 'no-branch' }
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return {
|
|
568
|
+
kind: 'read',
|
|
569
|
+
identity: {
|
|
570
|
+
number: row.number,
|
|
571
|
+
branch: row.headRefName,
|
|
572
|
+
...(row.headRefOid !== undefined && { head: row.headRefOid }),
|
|
573
|
+
},
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Reads the branch tip from the remote rather than from a tracking ref.
|
|
579
|
+
*
|
|
580
|
+
* A tracking ref is only as current as the last fetch, and the push this is
|
|
581
|
+
* meant to catch is one this process never saw.
|
|
582
|
+
*/
|
|
583
|
+
function refReader(root: string) {
|
|
584
|
+
return async (branch: string): Promise<string | null> => {
|
|
585
|
+
const result = await $`git -C ${root} ls-remote --heads origin ${branch}`
|
|
586
|
+
.env(gitEnv())
|
|
587
|
+
.quiet()
|
|
588
|
+
.nothrow()
|
|
589
|
+
return result.exitCode === 0 ? result.text() : null
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function runHead(
|
|
594
|
+
number: string | undefined,
|
|
595
|
+
opts: ReadOptions,
|
|
596
|
+
): Promise<number> {
|
|
597
|
+
const root = resolve(opts.root ?? process.cwd())
|
|
598
|
+
const emitJson = opts.json ?? false
|
|
599
|
+
|
|
600
|
+
intro('canon pr head')
|
|
601
|
+
|
|
602
|
+
const read = await readIdentity(root, number)
|
|
603
|
+
if (read.kind === 'refused') {
|
|
604
|
+
return refuseWith(read.reason, PULL_REFUSALS[read.reason], emitJson, root)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const { identity } = read
|
|
608
|
+
const reading = await resolveHead(
|
|
609
|
+
identity.branch,
|
|
610
|
+
identity.head,
|
|
611
|
+
refReader(root),
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
if (reading.kind === 'refused') {
|
|
615
|
+
return refuseWith(
|
|
616
|
+
reading.reason,
|
|
617
|
+
PULL_REFUSALS[reading.reason],
|
|
618
|
+
emitJson,
|
|
619
|
+
root,
|
|
620
|
+
)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
logStep('Scope')
|
|
624
|
+
logInfo(
|
|
625
|
+
`${identity.number === undefined ? 'the pull request on' : `#${identity.number} on`} ${reading.branch}`,
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
logStep(reading.state === 'fresh' ? 'Fresh' : 'Stale')
|
|
629
|
+
if (reading.state === 'fresh') {
|
|
630
|
+
logInfo(
|
|
631
|
+
`the object and the remote both name ${reading.tip.slice(0, 8)}, so a read keyed on either describes the same commit`,
|
|
632
|
+
)
|
|
633
|
+
} else {
|
|
634
|
+
logWarn(
|
|
635
|
+
`the remote carries ${reading.tip.slice(0, 8)} and the pull request object still reports ${reading.object.slice(0, 8)}. Key every head-sensitive read on the tip.`,
|
|
636
|
+
)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
outro()
|
|
640
|
+
|
|
641
|
+
if (emitJson) {
|
|
642
|
+
process.stdout.write(
|
|
643
|
+
`${JSON.stringify({
|
|
644
|
+
root,
|
|
645
|
+
...(identity.number !== undefined && { number: identity.number }),
|
|
646
|
+
branch: reading.branch,
|
|
647
|
+
state: reading.state,
|
|
648
|
+
tip: reading.tip,
|
|
649
|
+
object: reading.object,
|
|
650
|
+
})}\n`,
|
|
651
|
+
)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return 0
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function runChecks(
|
|
658
|
+
number: string | undefined,
|
|
659
|
+
opts: ReadOptions,
|
|
660
|
+
): Promise<number> {
|
|
661
|
+
const root = resolve(opts.root ?? process.cwd())
|
|
662
|
+
const emitJson = opts.json ?? false
|
|
663
|
+
|
|
664
|
+
intro('canon pr checks')
|
|
665
|
+
|
|
666
|
+
const read = await readIdentity(root, number)
|
|
667
|
+
if (read.kind === 'refused') {
|
|
668
|
+
return refuseWith(read.reason, PULL_REFUSALS[read.reason], emitJson, root)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const { identity } = read
|
|
672
|
+
const resolved = await resolveTip(identity.branch, refReader(root))
|
|
673
|
+
if (resolved.kind === 'refused') {
|
|
674
|
+
return refuseWith(
|
|
675
|
+
resolved.reason,
|
|
676
|
+
PULL_REFUSALS[resolved.reason],
|
|
677
|
+
emitJson,
|
|
678
|
+
root,
|
|
679
|
+
)
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// The page size is raised rather than paged through. `collapseChecks` reads
|
|
683
|
+
// a count above the rows it was handed as pending, so a commit past the
|
|
684
|
+
// ceiling reports unread rather than clean, and 100 is the endpoint's own
|
|
685
|
+
// maximum against a default of 30.
|
|
686
|
+
const listed = await gh(root, [
|
|
687
|
+
'api',
|
|
688
|
+
`repos/{owner}/{repo}/commits/${resolved.tip}/check-runs?per_page=100`,
|
|
689
|
+
])
|
|
690
|
+
if (listed === null) {
|
|
691
|
+
return refuseWith(
|
|
692
|
+
'runs-unreadable',
|
|
693
|
+
PULL_REFUSALS['runs-unreadable'],
|
|
694
|
+
emitJson,
|
|
695
|
+
root,
|
|
696
|
+
)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
let listing: CheckRunListing
|
|
700
|
+
try {
|
|
701
|
+
listing = JSON.parse(listed)
|
|
702
|
+
} catch {
|
|
703
|
+
return refuseWith(
|
|
704
|
+
'runs-unreadable',
|
|
705
|
+
PULL_REFUSALS['runs-unreadable'],
|
|
706
|
+
emitJson,
|
|
707
|
+
root,
|
|
708
|
+
)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const reading = collapseChecks(resolved.tip, listing)
|
|
712
|
+
|
|
713
|
+
logStep('Scope')
|
|
714
|
+
logInfo(
|
|
715
|
+
`${identity.branch} at ${reading.tip.slice(0, 8)}, ${plural(reading.matched, 'run')} belonging to it`,
|
|
716
|
+
)
|
|
717
|
+
// Named rather than folded into the count above, since a listing carrying a
|
|
718
|
+
// run for another commit is what makes the verdict pending on its own.
|
|
719
|
+
if (reading.foreign > 0) {
|
|
720
|
+
logWarn(
|
|
721
|
+
`${plural(reading.foreign, 'further run')} belonging to another commit, so this listing does not describe the tip alone.`,
|
|
722
|
+
)
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
logStep(
|
|
726
|
+
reading.state === 'passing'
|
|
727
|
+
? 'Passing'
|
|
728
|
+
: reading.state === 'failing'
|
|
729
|
+
? 'Failing'
|
|
730
|
+
: 'Pending',
|
|
731
|
+
)
|
|
732
|
+
if (reading.state === 'passing') {
|
|
733
|
+
logInfo('every run on the tip completed and none failed')
|
|
734
|
+
} else if (reading.state === 'failing') {
|
|
735
|
+
logWarn('a run on the tip failed, which no run still going can clear')
|
|
736
|
+
} else if (reading.matched === 0) {
|
|
737
|
+
logInfo(
|
|
738
|
+
`no run belongs to the tip yet${reading.reported > 0 ? `, against a reported count of ${reading.reported}` : ''}. That is unread rather than clean.`,
|
|
739
|
+
)
|
|
740
|
+
} else {
|
|
741
|
+
logInfo('a run on the tip has yet to conclude')
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
outro()
|
|
745
|
+
|
|
746
|
+
if (emitJson) {
|
|
747
|
+
process.stdout.write(
|
|
748
|
+
`${JSON.stringify({
|
|
749
|
+
root,
|
|
750
|
+
...(identity.number !== undefined && { number: identity.number }),
|
|
751
|
+
branch: identity.branch,
|
|
752
|
+
...reading,
|
|
753
|
+
})}\n`,
|
|
754
|
+
)
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return 0
|
|
758
|
+
}
|
|
759
|
+
|
|
403
760
|
/**
|
|
404
761
|
* Frames a refusal on stderr in both modes and puts the record on stdout alone,
|
|
405
762
|
* so an operator reading the terminal sees the reason rather than a command
|
|
406
763
|
* that appeared to do nothing.
|
|
407
764
|
*/
|
|
408
765
|
function refuse(reason: Refusal, emitJson: boolean, root: string): number {
|
|
766
|
+
return refuseWith(reason, REFUSALS[reason], emitJson, root)
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function refuseWith(
|
|
770
|
+
reason: string,
|
|
771
|
+
message: string,
|
|
772
|
+
emitJson: boolean,
|
|
773
|
+
root: string,
|
|
774
|
+
): number {
|
|
409
775
|
logStep('Refused')
|
|
410
|
-
logWarn(
|
|
776
|
+
logWarn(message)
|
|
411
777
|
outro()
|
|
412
778
|
|
|
413
779
|
if (emitJson) {
|
|
414
|
-
process.stdout.write(
|
|
415
|
-
`${JSON.stringify({ root, reason, message: REFUSALS[reason] })}\n`,
|
|
416
|
-
)
|
|
780
|
+
process.stdout.write(`${JSON.stringify({ root, reason, message })}\n`)
|
|
417
781
|
}
|
|
418
782
|
return 1
|
|
419
783
|
}
|
package/src/docs/read.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
-
import { basename,
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
3
|
import { stripFrontmatter } from '@/frontmatter'
|
|
4
4
|
|
|
5
5
|
const INDEX_TOPIC = 'index'
|
|
@@ -20,6 +20,11 @@ export interface ResolvedTopic {
|
|
|
20
20
|
* A domain too large for one file splits into `<domain>/` with a generated
|
|
21
21
|
* `index.md`, so a topic names either a sibling file or such a folder. Both
|
|
22
22
|
* spellings resolve to the same name a caller types.
|
|
23
|
+
*
|
|
24
|
+
* A sub-area file one level down resolves by its bare name too, but only after
|
|
25
|
+
* every root has been tried for both spellings above. Reading the folder last
|
|
26
|
+
* is what keeps the widening additive: no name that resolved before this
|
|
27
|
+
* resolves anywhere else now.
|
|
23
28
|
*/
|
|
24
29
|
export function resolveTopic(
|
|
25
30
|
root: string,
|
|
@@ -37,39 +42,97 @@ export function resolveTopic(
|
|
|
37
42
|
}
|
|
38
43
|
}
|
|
39
44
|
|
|
40
|
-
|
|
45
|
+
const leaves = findLeaves(root, topic)
|
|
46
|
+
if (leaves.length !== 1) return undefined
|
|
47
|
+
|
|
48
|
+
const rel = leaves[0] as string
|
|
49
|
+
return { path: join(root, rel), rel }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Names every sub-area file called `<topic>.md`, across both roots. A name
|
|
54
|
+
* carried by more than one folder resolves to none of them, since answering a
|
|
55
|
+
* bare `overview` with whichever folder sorts first is a confident wrong answer
|
|
56
|
+
* where the miss is a listing of what the caller could have typed.
|
|
57
|
+
*/
|
|
58
|
+
function findLeaves(root: string, topic: string): string[] {
|
|
59
|
+
const matches: string[] = []
|
|
60
|
+
|
|
61
|
+
for (const dir of ROOTS) {
|
|
62
|
+
for (const folder of catalogedFolders(join(root, dir))) {
|
|
63
|
+
const rel = join(dir, folder, `${topic}.md`)
|
|
64
|
+
if (existsSync(join(root, rel))) matches.push(rel)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return matches
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The split folders of one root, which is every sub-folder carrying an index.
|
|
73
|
+
* A folder without one is absent from both verbs, so its files stay out of
|
|
74
|
+
* reach with it rather than becoming nameable through a catalog nobody wrote.
|
|
75
|
+
*/
|
|
76
|
+
function catalogedFolders(dir: string): string[] {
|
|
77
|
+
if (!existsSync(dir)) return []
|
|
78
|
+
|
|
79
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
80
|
+
.filter((entry) => entry.isDirectory())
|
|
81
|
+
.map((entry) => entry.name)
|
|
82
|
+
.filter((name) => existsSync(join(dir, name, `${INDEX_TOPIC}.md`)))
|
|
83
|
+
.sort()
|
|
41
84
|
}
|
|
42
85
|
|
|
43
86
|
/**
|
|
44
87
|
* Names every topic a `get` could resolve, listed per root in the order the
|
|
45
88
|
* roots are searched. Shown when a topic misses, so it doubles as the answer to
|
|
46
|
-
* what the caller should have typed
|
|
89
|
+
* what the caller should have typed, which is why it carries exactly what
|
|
90
|
+
* `resolveTopic` answers for and nothing else.
|
|
47
91
|
*/
|
|
48
92
|
export function listTopics(root: string): string[] {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
for (const dir of ROOTS) {
|
|
52
|
-
const cwd = join(root, dir)
|
|
53
|
-
if (!existsSync(cwd)) continue
|
|
93
|
+
const named = ROOTS.map((dir) => collectNamed(root, dir))
|
|
94
|
+
const leaves = ROOTS.map((dir) => collectLeaves(root, dir))
|
|
54
95
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
.filter((name) => name !== INDEX_TOPIC)
|
|
60
|
-
|
|
61
|
-
const folders = [
|
|
62
|
-
...new Bun.Glob(`*/${INDEX_TOPIC}.md`).scanSync({
|
|
63
|
-
cwd,
|
|
64
|
-
onlyFiles: true,
|
|
65
|
-
dot: true,
|
|
66
|
-
}),
|
|
67
|
-
].map((name) => dirname(name))
|
|
68
|
-
|
|
69
|
-
topics.push(...[...files, ...folders].sort())
|
|
96
|
+
const taken = new Set(named.flat())
|
|
97
|
+
const counts = new Map<string, number>()
|
|
98
|
+
for (const name of leaves.flat()) {
|
|
99
|
+
counts.set(name, (counts.get(name) ?? 0) + 1)
|
|
70
100
|
}
|
|
71
101
|
|
|
72
|
-
|
|
102
|
+
const reachable = (name: string): boolean =>
|
|
103
|
+
!taken.has(name) && counts.get(name) === 1
|
|
104
|
+
|
|
105
|
+
return ROOTS.flatMap((_, position) =>
|
|
106
|
+
[
|
|
107
|
+
...(named[position] as string[]),
|
|
108
|
+
...(leaves[position] as string[]).filter(reachable),
|
|
109
|
+
].sort(),
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The sibling files and split folders of one root, which shadow every leaf. */
|
|
114
|
+
function collectNamed(root: string, dir: string): string[] {
|
|
115
|
+
const cwd = join(root, dir)
|
|
116
|
+
if (!existsSync(cwd)) return []
|
|
117
|
+
|
|
118
|
+
return [...markdownNames(cwd), ...catalogedFolders(cwd)]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** One entry per occurrence, so a name carried twice is counted twice. */
|
|
122
|
+
function collectLeaves(root: string, dir: string): string[] {
|
|
123
|
+
const cwd = join(root, dir)
|
|
124
|
+
|
|
125
|
+
return catalogedFolders(cwd).flatMap((folder) =>
|
|
126
|
+
markdownNames(join(cwd, folder)),
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function markdownNames(dir: string): string[] {
|
|
131
|
+
return [
|
|
132
|
+
...new Bun.Glob('*.md').scanSync({ cwd: dir, onlyFiles: true, dot: true }),
|
|
133
|
+
]
|
|
134
|
+
.map((name) => basename(name, '.md'))
|
|
135
|
+
.filter((name) => name !== INDEX_TOPIC)
|
|
73
136
|
}
|
|
74
137
|
|
|
75
138
|
export function readTopic(topic: ResolvedTopic): string {
|
package/src/pr/checks.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { type ChecksState, type RawCheck, rollup } from '@/targets/pulls'
|
|
2
|
+
|
|
3
|
+
/** One row of `repos/{owner}/{repo}/commits/<sha>/check-runs`. */
|
|
4
|
+
export interface RawCheckRun {
|
|
5
|
+
readonly head_sha?: string
|
|
6
|
+
readonly status?: string
|
|
7
|
+
readonly conclusion?: string | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** What that endpoint returns, count and rows apart. */
|
|
11
|
+
export interface CheckRunListing {
|
|
12
|
+
readonly total_count?: number
|
|
13
|
+
readonly check_runs?: readonly RawCheckRun[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ChecksReading {
|
|
17
|
+
readonly state: ChecksState
|
|
18
|
+
/** The commit the answer describes. */
|
|
19
|
+
readonly tip: string
|
|
20
|
+
/** Runs the listing carried for the tip. */
|
|
21
|
+
readonly matched: number
|
|
22
|
+
/** Runs the listing carried for some other commit. */
|
|
23
|
+
readonly foreign: number
|
|
24
|
+
/** What the endpoint said it holds, which can exceed the rows it returned. */
|
|
25
|
+
readonly reported: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Puts a check-run row into the shape `rollup` reads.
|
|
30
|
+
*
|
|
31
|
+
* REST spells its statuses lowercase and `rollup` reads the GraphQL enum the
|
|
32
|
+
* pull request listing returns, so the vocabulary is converted at this boundary
|
|
33
|
+
* rather than teaching that function a second one. A null conclusion becomes an
|
|
34
|
+
* absent one, since `rollup` reads absence as a run that has not concluded.
|
|
35
|
+
*/
|
|
36
|
+
function adapt(run: RawCheckRun): RawCheck {
|
|
37
|
+
return {
|
|
38
|
+
...(run.status !== undefined && { status: run.status.toUpperCase() }),
|
|
39
|
+
...(run.conclusion !== undefined &&
|
|
40
|
+
run.conclusion !== null && {
|
|
41
|
+
conclusion: run.conclusion.toUpperCase(),
|
|
42
|
+
}),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Collapses a check-run listing to one word about one commit.
|
|
48
|
+
*
|
|
49
|
+
* Keying the query on a sha is necessary and not sufficient. The endpoint
|
|
50
|
+
* returned `total_count` 2 with an empty row list during a measured window on
|
|
51
|
+
* 2026-09-02, so a reader that finds no run for the tip and reports `passing`
|
|
52
|
+
* reproduces the false green behind a better query. Both that case and a
|
|
53
|
+
* listing carrying a run for another commit report `pending` instead.
|
|
54
|
+
*
|
|
55
|
+
* The foreign test runs ahead of the collapse rather than after it. A listing
|
|
56
|
+
* that describes another commit says nothing about this one, so answering off
|
|
57
|
+
* the matching half would report a verdict on a set known to be incomplete.
|
|
58
|
+
*/
|
|
59
|
+
export function collapseChecks(
|
|
60
|
+
tip: string,
|
|
61
|
+
listing: CheckRunListing,
|
|
62
|
+
): ChecksReading {
|
|
63
|
+
const runs = listing.check_runs ?? []
|
|
64
|
+
const matched = runs.filter((run) => run.head_sha === tip)
|
|
65
|
+
const reading = {
|
|
66
|
+
tip,
|
|
67
|
+
matched: matched.length,
|
|
68
|
+
foreign: runs.length - matched.length,
|
|
69
|
+
reported: listing.total_count ?? runs.length,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (reading.foreign > 0) return { ...reading, state: 'pending' }
|
|
73
|
+
|
|
74
|
+
// A count above the rows returned is a listing this reader holds only part
|
|
75
|
+
// of, whether the endpoint paged or answered inconsistently. Collapsing the
|
|
76
|
+
// part in hand would report `passing` off a set a failing run can still be
|
|
77
|
+
// sitting outside of, which is the false green keyed on a sha alone.
|
|
78
|
+
if (reading.reported > runs.length) return { ...reading, state: 'pending' }
|
|
79
|
+
|
|
80
|
+
if (matched.length === 0) return { ...reading, state: 'pending' }
|
|
81
|
+
|
|
82
|
+
return { ...reading, state: rollup(matched.map(adapt)) ?? 'pending' }
|
|
83
|
+
}
|