@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.
@@ -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(REFUSALS[reason])
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, dirname, join } from 'node:path'
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
- return undefined
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 topics: string[] = []
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
- const files = [
56
- ...new Bun.Glob('*.md').scanSync({ cwd, onlyFiles: true, dot: true }),
57
- ]
58
- .map((name) => basename(name, '.md'))
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
- return topics
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 {
@@ -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
+ }