@erclx/canon 4.20.1 → 4.21.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/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/index.md +1 -0
- package/docs/agents/pr-reads.md +73 -0
- package/docs/operating-model.md +7 -0
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/commands/pr.ts +368 -4
- package/src/pr/checks.ts +83 -0
- package/src/pr/head.ts +108 -0
- package/src/targets/pulls.ts +7 -1
|
@@ -27,10 +27,10 @@ For inline review comments, read them via `gh api` on the PR's review comments.
|
|
|
27
27
|
Also read the CI check status so the fixes cover failing checks, not only review comments:
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
|
-
|
|
30
|
+
canon pr checks <number> --json
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
Treat
|
|
33
|
+
Read the verdict off the record's `state` rather than off the exit. Treat `failing` as a finding to resolve alongside the review comments. A `pending` covers a tip whose runs have yet to conclude and a tip carrying no run at all, which the record separates on `matched`, and neither is a green to continue on. Fall back to `gh pr checks <number>` when no record comes back at all, which is a target whose CLI predates the verb.
|
|
34
34
|
|
|
35
35
|
## Step 2: address each finding
|
|
36
36
|
|
|
@@ -161,10 +161,10 @@ gh pr comment <number> --body-file .canon/tmp/address-review/reply-<number>.md
|
|
|
161
161
|
|
|
162
162
|
## Step 7: confirm resolution
|
|
163
163
|
|
|
164
|
-
After the follow-up push, watch CI on the PR. Poll
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
clear terminal state:
|
|
164
|
+
After the follow-up push, watch CI on the PR. Poll
|
|
165
|
+
`canon pr checks <number> --json` until the record's `state` leaves `pending`,
|
|
166
|
+
then read it. When every finding is addressed and the state is `passing`, post
|
|
167
|
+
one closing comment so the thread has a clear terminal state:
|
|
168
168
|
|
|
169
169
|
```bash
|
|
170
170
|
gh pr comment <number> --body "✅ All review findings addressed, CI green."
|
|
@@ -72,6 +72,8 @@ The report is also where the count in `## Parallelism` is legible. That threshol
|
|
|
72
72
|
|
|
73
73
|
The count reads low, and it errs in the direction that breaks the trigger. A review's `commit.oid` is stamped with the head at submission rather than with the commit the reviewer read, so an author pushing between the diff read and the post leaves the pass recorded against a commit it never saw, and `SEEN` then fires on a head still awaiting its first look at that delta. Measured on `#1299` on 2026-08-31, where a pass written against `5653721` landed stamped `a5ceb40` and the delta it skipped was a real fix. So a `SEEN` on a head you do not recognize is worth one `gh pr view --json reviews` before it is believed, and a wave past three is likelier to trip the fallback late than early.
|
|
74
74
|
|
|
75
|
+
The other side of that comparison used to lag as well, which made the two errors compound rather than cancel. `gh pr view --json headRefOid` answers from the pull request object and that object trails the branch ref by up to a minute after a push, reporting nothing about the trail, so a pass stamped ahead of the commit it read was compared against a head stamped behind the commit that exists. Two sessions were wrong off that field on 2026-09-01: a reviewing session posted a finding calling a pushed commit unpushed, and a worker fired a green claim it retracted on its own thread minutes later. The head now comes from `canon pr head`, which resolves the tip through `git ls-remote` and reports which commit each source names, and the object's head stays behind it as the fallback for a target whose CLI predates the verb. The review stamp is unchanged, so the paragraph above still holds on its own half.
|
|
76
|
+
|
|
75
77
|
## The watch beside it
|
|
76
78
|
|
|
77
79
|
`${CLAUDE_SKILL_DIR}/scripts/watch.sh` is a long-running loop rather than a scheduled prompt. It reads the open pull request list and the session roster together every sixty seconds and prints one line per new pull request, per worker whose status changed, and per worker that dropped out of the roster. Start it in the background and read what it emits. It writes nothing.
|
|
@@ -187,7 +187,25 @@ snapshot() {
|
|
|
187
187
|
continue
|
|
188
188
|
fi
|
|
189
189
|
|
|
190
|
-
head
|
|
190
|
+
# The tip is the authority for the head, not the pull request object. That
|
|
191
|
+
# object lags the ref by up to a minute after a push, so MOVED fired late
|
|
192
|
+
# and the merge-tree read below judged a commit the branch had already left
|
|
193
|
+
# behind. Read on the record's own field rather than on the exit, since an
|
|
194
|
+
# operator shell profile can wrap canon in a function that flattens it.
|
|
195
|
+
# The trailing assignment is load-bearing under `set -e` and `set -o
|
|
196
|
+
# pipefail` at the top of this file. Every refusal exits 1, and a refusal
|
|
197
|
+
# here is ordinary rather than exceptional, so an unguarded pipeline would
|
|
198
|
+
# end the whole poll on the first pull request whose branch was deleted.
|
|
199
|
+
head=$(canon pr head "$n" --json 2>/dev/null | jq -r '.tip // empty') || head=""
|
|
200
|
+
|
|
201
|
+
# The object's head is the fallback rather than the source. This script
|
|
202
|
+
# ships with the plugin and the verb ships with the CLI, so a target on an
|
|
203
|
+
# older binary reaches no `pr head` at all, and answering there with the
|
|
204
|
+
# lagging head is the behavior this poll already had.
|
|
205
|
+
if [ -z "$head" ]; then
|
|
206
|
+
head=$(jq -r '.headRefOid // empty' <<<"$payload")
|
|
207
|
+
fi
|
|
208
|
+
|
|
191
209
|
if [ -z "$head" ]; then
|
|
192
210
|
carry_forward "$n" "returned no head"
|
|
193
211
|
continue
|
|
@@ -29,7 +29,7 @@ reader scanning the thread finds the current verdict where the last one sat.
|
|
|
29
29
|
|
|
30
30
|
## Step 1: resolve the PR and read context
|
|
31
31
|
|
|
32
|
-
Resolve the PR: `gh pr view --json number,headRefName,headRefOid,title,body` for the current branch, or use a PR number the user names.
|
|
32
|
+
Resolve the PR: `gh pr view --json number,headRefName,headRefOid,title,body` for the current branch, or use a PR number the user names. Take `<headRefOid>` from `canon pr head <number> --json`, off that record's `tip`, and fall back to the `headRefOid` field above when no record comes back, which is a target whose CLI predates the verb. The first seven characters are `<short-sha>`, which names the body file in Step 4.
|
|
33
33
|
|
|
34
34
|
Read these in parallel from the project root, skipping any that do not exist:
|
|
35
35
|
|
|
@@ -30,7 +30,7 @@ Run `git diff --cached --name-only 2>/dev/null` to check for staged files. If ou
|
|
|
30
30
|
5. Invoke `canon:git-stage` to group staged changes and commit by concern
|
|
31
31
|
6. Invoke `canon:git-branch` to rename branch to match conventional format
|
|
32
32
|
7. Invoke `canon:git-pr` to push branch and open pull request
|
|
33
|
-
8. After the PR opens, watch CI. Poll `gh pr checks <number>`
|
|
33
|
+
8. After the PR opens, watch CI. Poll `canon pr checks <number> --json` until the record's `state` leaves `pending`, branching on that field rather than on the exit, and fall back to `gh pr checks <number>` when no record comes back at all, which is a target whose CLI predates the verb. On `passing`, continue. On `failing`, stop the sequence and report the failing check with its URL. Do not auto-fix. This step may output on failure, the one exception to the no-text-between-steps rule.
|
|
34
34
|
9. If step 1 wrote or updated at least one memory file, invoke `canon:claude-memory-review` scoped to those entries to propose fixes while session context is fresh. If the pen got nothing, skip this step.
|
|
35
35
|
|
|
36
36
|
A caller wrapping this sequence may act between step 7 and step 8, which is the one gap the order leaves open, since the pull request exists there and nothing has read its checks yet. `claude-autoship` marks the pull request draft in it. Nothing else may go there, and a caller that needs a step anywhere else in the sequence is asking for a change to this body rather than for a place to stand.
|
package/docs/agents/commands.md
CHANGED
|
@@ -71,6 +71,8 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
|
|
|
71
71
|
| `canon labels scan` | Fail a pull request whose title or body carries a phase label, a label a code span quotes, a gitignored record path, or a session link (`--event`, `--json`) |
|
|
72
72
|
| `canon autoship classify` | Decide whether a changed set needs the review pass, naming the file and the test that decided it (`--json`) |
|
|
73
73
|
| `canon pr key-changes` | Compare the files a pull request body's Key Changes names against its own diff, in both directions (`--body`, `--base`, `--json`) |
|
|
74
|
+
| `canon pr head` | Compare the head a pull request object reports against the branch tip the remote carries, naming both shas (`--root`, `--json`) |
|
|
75
|
+
| `canon pr checks` | Report the check runs belonging to the branch tip, reading pending for a tip carrying none rather than clean (`--root`, `--json`) |
|
|
74
76
|
| `canon repo metadata propose` | Compare a description, homepage, and topic set computed from the README and `package.json` against what the remote carries, writing nothing (`--root`, `--json`) |
|
|
75
77
|
| `canon repo metadata apply` | Write an explicitly supplied description, homepage, or topic set to the remote through `gh repo edit` (`--description`, `--homepage`, `--topics`, `--root`, `--json`) |
|
|
76
78
|
| `canon census [path]` | Report tracked file count, a breakdown by extension, and a line total that skips whatever reads as binary (`--json`) |
|
|
@@ -132,6 +134,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
|
|
|
132
134
|
| `labels` | `audit` |
|
|
133
135
|
| `migrate` | `rename`, `records` |
|
|
134
136
|
| `autoship` | `classify` |
|
|
137
|
+
| `pr` | `key-changes`, `head`, `checks` |
|
|
135
138
|
| `audits` | `run`, `list` |
|
|
136
139
|
| `gate` | `run` |
|
|
137
140
|
|
package/docs/agents/index.md
CHANGED
|
@@ -28,6 +28,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
28
28
|
- [Markdown audit](markdown-audit.md): Running the audit over any markdown path, where its bans and checkpoints are read from, what each check reports, and why the ban half gates while the structural half reports
|
|
29
29
|
- [Output shape](output-shape.md): Two framed shapes every command renders into, how JSON and --names modes keep stdout clean, and the exit discipline that lets piped output drain
|
|
30
30
|
- [Overview](overview.md): What this folder covers, the invocation rules every command inherits, and where domain behavior is documented instead
|
|
31
|
+
- [Head-sensitive pull request reads](pr-reads.md): Resolving a branch tip from the remote rather than from the pull request object, reading check runs keyed on that tip, why an empty run list is not a pass, the refusal reasons each verb names, and what the remote read costs
|
|
31
32
|
- [Records](records.md): The two roots a record folder resolves at, validating the session records and the standards corpus, the per-kind checks, the refusal reasons, migrating a record a frontmatter change orphaned, reading each folder's size and growth, backing the folders to a private remote, and which root each kind defaults to
|
|
32
33
|
- [Restated instructions](restated.md): Counting the instructions the always-loaded file and every path-scoped rule share with the seed, the shipped skill bodies, and each other, how a match is decided, the three classes, which surface a later edit starts from, and why the sweep reports rather than gates
|
|
33
34
|
- [Review classification](review-classification.md): Deciding whether a changed set needs the review pass, the two tests it runs, why an empty set refuses rather than skipping, and the written fallback a target on an older binary falls back to
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Head-sensitive pull request reads
|
|
3
|
+
description: Resolving a branch tip from the remote rather than from the pull request object, reading check runs keyed on that tip, why an empty run list is not a pass, the refusal reasons each verb names, and what the remote read costs
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Head-sensitive pull request reads
|
|
7
|
+
|
|
8
|
+
`canon pr head` and `canon pr checks` answer about a commit. Both resolve the branch tip from the remote with `git ls-remote` and report what they found for that sha, rather than taking the pull request object's word for which commit the branch is on.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
canon pr head
|
|
12
|
+
canon pr head 1341 --json
|
|
13
|
+
canon pr checks
|
|
14
|
+
canon pr checks 1341 --json
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## The pull request object is not the authority for a head
|
|
18
|
+
|
|
19
|
+
`gh pr view --json headRefOid` reads a field on the pull request object. That object trails the branch ref by up to a minute after a push and carries nothing saying how far behind it is, so a session that keys a read on it is describing whichever commit GitHub last folded into the object.
|
|
20
|
+
|
|
21
|
+
Two failures on 2026-09-01 came off that one field. A reviewing session posted a finding calling a pushed commit unpushed. A worker fired a green claim and retracted it on its own thread minutes later. Neither session did anything wrong with the value it was handed. The value was stale and said so nowhere.
|
|
22
|
+
|
|
23
|
+
The remote ref carries no such lag. `git ls-remote --heads origin <branch>` asks the remote itself rather than a tracking ref, which is only as current as the last fetch, and the push worth catching is the one this process never saw.
|
|
24
|
+
|
|
25
|
+
The argument is stated here once. The shipped skill bodies call the verb rather than repeating it, because a sentence telling a session to compare two shas is a sentence a session can decide it already followed.
|
|
26
|
+
|
|
27
|
+
## What each verb answers
|
|
28
|
+
|
|
29
|
+
`canon pr head` reports `fresh` when the object and the remote name the same commit and `stale` when they disagree, with both shas on the record. The tip is the authority and the object's head is the claim being checked against it.
|
|
30
|
+
|
|
31
|
+
`canon pr checks` reads `repos/{owner}/{repo}/commits/<tip>/check-runs` and collapses it to `passing`, `failing`, or `pending`. A failure outranks a run still going, since a run in flight cannot clear a job that already failed.
|
|
32
|
+
|
|
33
|
+
`gh pr checks` cannot be made to answer this question at all, which is the argument for the move rather than a preference between two working commands. Its `--json` field set is `bucket, completedAt, description, event, link, name, startedAt, state, workflow`, with no sha among them, so a caller cannot even learn which commit its answer describes.
|
|
34
|
+
|
|
35
|
+
## An empty run list is not a pass
|
|
36
|
+
|
|
37
|
+
Keying the query on a sha is necessary and not sufficient. The endpoint answered with `total_count` 2 and an empty row list during a measured window on 2026-09-02, so a reader that finds no run for the tip and reports `passing` reproduces the false green behind a better query.
|
|
38
|
+
|
|
39
|
+
Both empty cases report `pending` instead: a tip carrying no run yet, and a listing whose rows all belong to some other commit. The record separates them, since `matched` counts the runs belonging to the tip and `foreign` counts the rest, and a caller that wants to tell "not started" from "still going" reads those two numbers rather than the state alone.
|
|
40
|
+
|
|
41
|
+
`matched` counts runs and not distinct checks, which is where it parts company with the list `gh pr checks` prints. A workflow that fires twice on one commit lands two runs under one name, so a pull request whose body was edited after the push reads 3 against that command's 2. Measured on 2026-09-02 against a commit whose phase-label workflow ran on the push and again on a later body edit. Read a `matched` above the check count as that, rather than as the verb disagreeing with the command.
|
|
42
|
+
|
|
43
|
+
A listing carrying even one foreign run reports `pending` whatever the matching half says. A set that describes another commit says nothing about this one, so answering off the rows that happen to match would put a verdict on a set already known to be incomplete.
|
|
44
|
+
|
|
45
|
+
A count above the rows returned reports `pending` for the same reason. The query asks for 100 rows against the endpoint's default of 30, and a commit carrying more than that comes back short, so the verb reports the tip as unread rather than collapsing the page it happens to hold. `reported` against `matched` and `foreign` is where a reader sees which of the three shapes produced the answer.
|
|
46
|
+
|
|
47
|
+
## Reading the answer
|
|
48
|
+
|
|
49
|
+
Branch on the record rather than on the exit. Both verbs exit 0 whenever they resolved a tip, whatever the verdict, and 1 only when they refused. An operator shell profile can wrap `canon` in a function whose status comes from a trailing command, which flattens every non-zero exit to 0, so the exit is not a channel either verb puts a verdict on. `gh pr checks` reserving exit 8 for pending is the pattern being replaced rather than one to copy.
|
|
50
|
+
|
|
51
|
+
Each refusal names a different repair:
|
|
52
|
+
|
|
53
|
+
| Reason | What it means |
|
|
54
|
+
| ------------------ | ------------------------------------------------------------------------------------------ |
|
|
55
|
+
| `gh-missing` | `gh` is not on the path, so no pull request resolved |
|
|
56
|
+
| `gh-failed` | `gh` could not answer for this branch, so name a number instead |
|
|
57
|
+
| `no-branch` | The pull request carries no head branch name, so no ref could be read |
|
|
58
|
+
| `unresolvable-ref` | The remote read failed, which is not the same answer as an absent branch |
|
|
59
|
+
| `no-remote-branch` | The remote carries no branch by that name, so there is no tip to compare against |
|
|
60
|
+
| `no-object-head` | The pull request object reported no head, so `head` has nothing to compare the tip against |
|
|
61
|
+
| `runs-unreadable` | The check runs for the tip could not be read, which is unread rather than none |
|
|
62
|
+
|
|
63
|
+
A failed remote read and a branch the remote does not carry stay apart rather than collapsing into one reason. Reading the first as the second would report a network refusal as a deleted branch, and the repairs have nothing in common.
|
|
64
|
+
|
|
65
|
+
## What it costs
|
|
66
|
+
|
|
67
|
+
One remote round trip per call, sampled here at 0.41s, 0.55s, and 0.67s on 2026-09-01 and 2026-09-02, against 0.001s for the local ref read. The spread is the network rather than the command, so read it as roughly half a second and not as a figure to compare a later reading against. Every caller pointed at these verbs already spends a `gh` round trip on the same line, so the added cost lands on a path that was never local to begin with.
|
|
68
|
+
|
|
69
|
+
One head-sensitive read is left on the object deliberately. `canon targets pulls` reads `statusCheckRollup` for every open pull request across every target, where resolving a tip per row would cost one remote read per pull request across a dozen projects, against a surface that reports a listing rather than gating a push.
|
|
70
|
+
|
|
71
|
+
The orchestrator poll spends that same read per open pull request and repeats it on a three-minute timer, which makes it the heaviest caller here rather than an exception to the paragraph above. Six open pull requests is around 120 remote reads an hour. What separates the two cases is what each reading decides rather than what it costs. The poll's head fires the review trigger, so a stale one sends a pass at a commit nobody read or withholds one that is owed. The listing decides nothing, so the same spend buys a fresher column in a report and no correctness at all.
|
|
72
|
+
|
|
73
|
+
`canon pr key-changes` also stays on the object, for a different reason. It reads the body, the file list, and `headRefOid` in one call on purpose, so the three describe one commit. That is a consistency requirement rather than a freshness one, and keying its head elsewhere would break it.
|
package/docs/operating-model.md
CHANGED
|
@@ -108,6 +108,13 @@ Findings travel on the PR. `claude-pr-review` posts them there.
|
|
|
108
108
|
threads, and pushes a follow-up. `claude-pr-review` then runs again, reading only
|
|
109
109
|
what the follow-up added.
|
|
110
110
|
|
|
111
|
+
Both halves of that loop key on a commit rather than on the pull request object,
|
|
112
|
+
which trails the branch ref by up to a minute after a push and says nothing
|
|
113
|
+
about the trail. `canon pr head` resolves the tip the review pass scopes its
|
|
114
|
+
delta against, and `canon pr checks` reports the runs belonging to that tip, so a
|
|
115
|
+
follow-up push cannot be read as green off the predecessor's completed run. See
|
|
116
|
+
[Head-sensitive pull request reads](agents/pr-reads.md).
|
|
117
|
+
|
|
111
118
|
What the session channel carries is the handback instruction and the worker's
|
|
112
119
|
reply to it, which is a notification layer over a record that stays on the PR. A
|
|
113
120
|
reply that changes an outcome, such as a worker naming the plan question that
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -88,7 +88,7 @@ function showHelp(): void {
|
|
|
88
88
|
`${GREY}│${NC} deps [cmd] ${GREY}# Read the resolved dependency set for advisories (audit)${NC}`,
|
|
89
89
|
`${GREY}│${NC} labels [cmd] ${GREY}# Read a changed set against the pull request label map (audit)${NC}`,
|
|
90
90
|
`${GREY}│${NC} autoship [cmd] ${GREY}# Decide whether a changed set needs the review pass (classify)${NC}`,
|
|
91
|
-
`${GREY}│${NC} pr [cmd] ${GREY}# Read a pull request
|
|
91
|
+
`${GREY}│${NC} pr [cmd] ${GREY}# Read a pull request against its own diff and its branch tip (key-changes, head, checks)${NC}`,
|
|
92
92
|
`${GREY}│${NC} repo [cmd] ${GREY}# This repository's own remote metadata (metadata propose, apply)${NC}`,
|
|
93
93
|
`${GREY}│${NC} census [path] ${GREY}# Report tracked file count, extension breakdown, and line totals${NC}`,
|
|
94
94
|
`${GREY}│${NC} audits [cmd] ${GREY}# Run every health check as one set (run, list)${NC}`,
|
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/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
|
+
}
|
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
|
+
}
|
package/src/targets/pulls.ts
CHANGED
|
@@ -87,7 +87,13 @@ interface RawPull {
|
|
|
87
87
|
readonly statusCheckRollup?: readonly RawCheck[]
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
|
|
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
|