@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/src/pr/head.ts ADDED
@@ -0,0 +1,108 @@
1
+ /** Whether the pull request object's head still names the branch tip. */
2
+ export type HeadState = 'fresh' | 'stale'
3
+
4
+ /** Why the tip could not be resolved from the remote. */
5
+ export type TipRefusal = 'unresolvable-ref' | 'no-remote-branch'
6
+
7
+ /** Why the comparison produced no reading, adding the object's own half. */
8
+ export type HeadRefusal = TipRefusal | 'no-object-head'
9
+
10
+ export type TipReading =
11
+ | { readonly kind: 'read'; readonly tip: string }
12
+ | { readonly kind: 'refused'; readonly reason: TipRefusal }
13
+
14
+ export type HeadReading =
15
+ | {
16
+ readonly kind: 'read'
17
+ readonly state: HeadState
18
+ readonly branch: string
19
+ readonly tip: string
20
+ readonly object: string
21
+ }
22
+ | {
23
+ readonly kind: 'refused'
24
+ readonly reason: HeadRefusal
25
+ readonly branch: string
26
+ }
27
+
28
+ /**
29
+ * Hands back the stdout of `git ls-remote --heads origin <branch>`, or null
30
+ * when the command failed.
31
+ *
32
+ * Injected rather than called here, so the disagreement this module exists to
33
+ * report is a fixture rather than a live push nobody can stage in a test.
34
+ */
35
+ export type RefReader = (branch: string) => Promise<string | null>
36
+
37
+ /**
38
+ * The sha `git ls-remote` reported for exactly this branch.
39
+ *
40
+ * Matched on the whole ref rather than on a suffix, because the command takes
41
+ * its argument as a pattern: `--heads origin x` also returns `refs/heads/feat/x`,
42
+ * and reading the first line back would answer about another branch entirely.
43
+ */
44
+ function findRef(stdout: string, branch: string): string | undefined {
45
+ for (const line of stdout.split('\n')) {
46
+ const [sha, ref] = line.trim().split(/\s+/)
47
+ if (sha !== undefined && ref === `refs/heads/${branch}`) return sha
48
+ }
49
+ return undefined
50
+ }
51
+
52
+ /**
53
+ * Resolves a branch tip from the remote itself.
54
+ *
55
+ * The remote rather than the remote-tracking ref, since a tracking ref is only
56
+ * as current as the last fetch and the whole point here is answering about a
57
+ * push this process never saw. It costs a round trip, measured at 0.410s
58
+ * against 0.001s for the local read.
59
+ *
60
+ * A read that failed and a branch the remote does not carry are separated
61
+ * rather than collapsed. Reading the first as the second would report a
62
+ * network refusal as a deleted branch, which is a different repair.
63
+ */
64
+ export async function resolveTip(
65
+ branch: string,
66
+ read: RefReader,
67
+ ): Promise<TipReading> {
68
+ const stdout = await read(branch)
69
+ if (stdout === null) return { kind: 'refused', reason: 'unresolvable-ref' }
70
+
71
+ const tip = findRef(stdout, branch)
72
+ if (tip === undefined) return { kind: 'refused', reason: 'no-remote-branch' }
73
+
74
+ return { kind: 'read', tip }
75
+ }
76
+
77
+ /**
78
+ * Compares the head a pull request object reports against the branch tip.
79
+ *
80
+ * `gh pr view --json headRefOid` answers from the pull request object, which
81
+ * lags the branch ref by up to a minute after a push and reports nothing about
82
+ * the lag. A session that trusts it calls a pushed commit unpushed and fires a
83
+ * green claim against a commit CI never saw, both of which happened on
84
+ * 2026-09-01. The tip is the authority and the object's head is the claim being
85
+ * checked against it.
86
+ */
87
+ export async function resolveHead(
88
+ branch: string,
89
+ objectHead: string | undefined,
90
+ read: RefReader,
91
+ ): Promise<HeadReading> {
92
+ const resolved = await resolveTip(branch, read)
93
+ if (resolved.kind === 'refused') {
94
+ return { kind: 'refused', reason: resolved.reason, branch }
95
+ }
96
+
97
+ if (objectHead === undefined || objectHead === '') {
98
+ return { kind: 'refused', reason: 'no-object-head', branch }
99
+ }
100
+
101
+ return {
102
+ kind: 'read',
103
+ state: resolved.tip === objectHead ? 'fresh' : 'stale',
104
+ branch,
105
+ tip: resolved.tip,
106
+ object: objectHead,
107
+ }
108
+ }
@@ -87,7 +87,13 @@ interface RawPull {
87
87
  readonly statusCheckRollup?: readonly RawCheck[]
88
88
  }
89
89
 
90
- interface RawCheck {
90
+ /**
91
+ * One check as the pull request listing spells it, in the GraphQL enum.
92
+ *
93
+ * Exported because `src/pr/checks.ts` adapts the REST check-run row onto it
94
+ * rather than writing a second collapse beside `rollup`.
95
+ */
96
+ export interface RawCheck {
91
97
  readonly status?: string
92
98
  readonly conclusion?: string
93
99
  readonly state?: string