|
2 | 2 | name: merge |
3 | 3 | description: >- |
4 | 4 | Land a pull request safely: check `main` has not moved since the branch was cut, rebase onto it |
5 | | - and force-push under a pinned lease if it has, read the CI result rather than trusting a tick, |
6 | | - then rebase-merge and clean up the branch, the primary clone and the worktree. Use when the user |
7 | | - says "merge it", "land this", "merge the PR", "ship it", "mergea", or "haz merge". Never merges |
8 | | - without being told to. Treats the local `HEAD..origin/main` count as the authority on whether |
9 | | - `main` moved, pins the force-with-lease to a recorded sha because the bare form silently |
10 | | - clobbers, and confirms from PR state because `gh`'s own exit code lies here. |
| 5 | + and force-push under a pinned lease if it has, read the CI result rather than trusting a tick — |
| 6 | + or the absence of one — then rebase-merge and clean up the branch, the primary clone and the |
| 7 | + worktree. Use when the user says "merge it", "land this", "merge the PR", "ship it", "mergea", or |
| 8 | + "haz merge". Never merges without being told to. Treats the local `HEAD..origin/main` count as the |
| 9 | + authority on whether `main` moved, pins the force-with-lease to a recorded sha because the bare |
| 10 | + form silently clobbers, never lets an empty check rollup or an empty `conclusion` count as a pass, |
| 11 | + and confirms from PR state because `gh`'s own exit code lies here. |
11 | 12 | --- |
12 | 13 |
|
13 | 14 | # Merge — land it on a `main` that has not moved under you |
@@ -85,26 +86,94 @@ Reproduced: a colleague pushed to the branch, bare lease correctly rejected with |
85 | 86 |
|
86 | 87 | Rejection means someone else moved the branch. Stop and look; do not escalate to `--force`. |
87 | 88 |
|
88 | | -## 5. Read the checks, don't trust the tick |
| 89 | +## 5. Read the checks — and don't trust the *absence* of a tick either |
| 90 | + |
| 91 | +Every mistake available here has one shape: **an absence of signal read as a positive answer.** A |
| 92 | +check that cannot tell "no CI exists" from "CI has not started yet" is not a gate. `[]`, `""` and a |
| 93 | +missing key all mean *ask again*, never *pass*. |
| 94 | + |
| 95 | +### 5a. The rollup is the authority |
89 | 96 |
|
90 | 97 | ```bash |
91 | | -gh pr checks <n> |
| 98 | +gh pr view <n> --json statusCheckRollup --jq ' |
| 99 | +[ .statusCheckRollup[]? | { |
| 100 | + name: (.name // .context), |
| 101 | + done: (if .__typename == "CheckRun" then .status == "COMPLETED" |
| 102 | + else (.state != null and (.state | IN("PENDING","EXPECTED") | not)) end), |
| 103 | + bad: (if .__typename == "CheckRun" |
| 104 | + then ((.conclusion // "") | IN("FAILURE","TIMED_OUT","CANCELLED","ACTION_REQUIRED","STARTUP_FAILURE","STALE")) |
| 105 | + else ((.state // "") | IN("FAILURE","ERROR")) end) } ] |
| 106 | +| {total: length, pending: [.[]|select(.done|not)|.name], failed: [.[]|select(.bad)|.name]}' |
92 | 107 | ``` |
93 | 108 |
|
94 | | -Exit `0` all passed, `1` a failure exists, `8` pending (documented, unobserved here). |
| 109 | +Merge only on **`total > 0` and `pending == []` and `failed == []`**. `total == 0` is never a pass |
| 110 | +— it goes to 5b. Requiring `total > 0` is what stops "0 checks, therefore 0 pending, therefore |
| 111 | +done" from satisfying a wait loop. |
| 112 | + |
| 113 | +Why each clause is written that way — the shorthands that look equivalent are not: |
| 114 | + |
| 115 | +- **The two row shapes have disjoint keys**, so a missing key reads as `null` and null-sniffing |
| 116 | + becomes a type test by accident. `gh`'s own export (`api/export_pr.go`) emits, for a **CheckRun**: |
| 117 | + `__typename, name, workflowName, status, conclusion, startedAt, completedAt, detailsUrl` — and no |
| 118 | + `state` at all; for a **StatusContext** (legacy commit status, e.g. an external CI provider): |
| 119 | + `__typename, context, state, targetUrl, startedAt` — no `status`, no `conclusion`, and the name |
| 120 | + lives in `context`. Branch on `__typename`, which is always present. |
| 121 | +- **`.conclusion // .state` does not fall through.** jq's `//` falls through on `null` and `false` |
| 122 | + only; an in-progress check run has `conclusion: ""`. Observed on ps-mutuus/mutuus-changelog#52: |
| 123 | + `{"name":"Site typecheck + build","status":"IN_PROGRESS","conclusion":""}` collapses into an |
| 124 | + empty-string bucket that is not `FAILURE` and so reads as fine. Gate on `status` first; look at |
| 125 | + `conclusion` only once `status == "COMPLETED"`. |
| 126 | +- **`.state != null` means "this is a legacy status context", not "it finished".** A pending one is |
| 127 | + `state: "PENDING"`. `StatusState` is `EXPECTED ERROR FAILURE PENDING SUCCESS` — only the last |
| 128 | + three are terminal, so `PENDING`/`EXPECTED` must count as pending. |
| 129 | +- **`COMPLETED` is the only terminal check-run status.** `CheckStatusState` is |
| 130 | + `REQUESTED QUEUED IN_PROGRESS COMPLETED WAITING PENDING`; five of the six are still running. |
| 131 | +- **The failing conclusions are more than `FAILURE`.** `CheckConclusionState` failures are |
| 132 | + `FAILURE TIMED_OUT CANCELLED ACTION_REQUIRED STARTUP_FAILURE STALE`; `SUCCESS`, `NEUTRAL` and |
| 133 | + `SKIPPED` pass. |
| 134 | +- An unrecognised `__typename` falls into the `else` branch with no `state` and lands in `pending`. |
| 135 | + That is deliberate: unknown fails closed. |
| 136 | + |
| 137 | +### 5b. An empty rollup means re-query, not "no CI" |
| 138 | + |
| 139 | +`[]` is also what GitHub returns for a minute or two after a force-push, while it re-associates |
| 140 | +workflow runs with the new head. Observed on #52 immediately after the step-4 push: the rollup was |
| 141 | +`[]` and `gh pr checks 52` printed `no checks reported on the 'claude/gh-path-helper' branch`, |
| 142 | +while a `PR checks` run was already queued for that exact head sha. Concluding "no CI" there merges |
| 143 | +with CI unrun. |
| 144 | + |
| 145 | +Prove a run exists before concluding one does not: |
95 | 146 |
|
96 | | -**Exit `1` also means "no checks are configured"** — it prints `no checks reported on the '<b>' |
97 | | -branch` to stderr with empty stdout. Branching on exit `1` alone will read a repo with no CI as a |
98 | | -repo with failing CI. Disambiguate: |
| 147 | +```bash |
| 148 | +HEAD=$(git rev-parse HEAD) |
| 149 | +gh run list --branch <branch> --limit 20 --json headSha,status,conclusion,workflowName \ |
| 150 | + --jq "[.[]|select(.headSha==\"$HEAD\")]" |
| 151 | +``` |
| 152 | + |
| 153 | +Non-empty → CI exists; wait, whatever the rollup says. Empty **and** an empty rollup is still not |
| 154 | +proof: `gh run list` sees GitHub Actions only, so an external provider that posts commit statuses |
| 155 | +appears in neither until it registers. Re-query over a settle window (~60s) before calling it "no |
| 156 | +CI", and say in the final report that no-CI was concluded from an absence. |
| 157 | + |
| 158 | +### 5c. The wait loop |
99 | 159 |
|
100 | 160 | ```bash |
101 | | -gh pr view <n> --json statusCheckRollup \ |
102 | | - --jq '[.statusCheckRollup[]?|.conclusion // .state]|group_by(.)|map({v:.[0],n:length})' |
| 161 | +for _ in $(seq 1 60); do |
| 162 | + R=$(gh pr view <n> --json statusCheckRollup --jq '<the 5a filter>') |
| 163 | + T=$(jq -r .total <<<"$R"); P=$(jq -r '.pending|length' <<<"$R"); F=$(jq -r '.failed|length' <<<"$R") |
| 164 | + [ "$F" -gt 0 ] && { echo "failed: $(jq -c .failed <<<"$R")"; break; } |
| 165 | + [ "$T" -gt 0 ] && [ "$P" -eq 0 ] && { echo "all passed"; break; } |
| 166 | + sleep 15 |
| 167 | +done |
103 | 168 | ``` |
104 | 169 |
|
105 | | -`[]` means no CI gate exists — proceed. Otherwise pass = no `FAILURE` and nothing outside |
106 | | -`COMPLETED`. `.conclusion // .state` is needed because check runs and legacy status contexts expose |
107 | | -different fields. |
| 170 | +`T > 0` guards the exit. Without it the loop terminates instantly on the post-force-push `[]`, |
| 171 | +which is exactly how a hand-written poll loop passed a PR whose checks had not started. |
| 172 | + |
| 173 | +`gh pr checks <n>` is a hint, not the gate: exit `0` all passed, `1` **either** a failure exists |
| 174 | +**or** no checks are configured (the two are indistinguishable from the exit code), `8` pending |
| 175 | +(documented, unobserved here). Use the rollup to decide; use `gh pr checks` only for a readable |
| 176 | +dump when reporting. |
108 | 177 |
|
109 | 178 | ## 6. Merge |
110 | 179 |
|
@@ -144,3 +213,8 @@ handed back, a check that was pending, a branch left in place. |
144 | 213 | the base. Expect `405 Merge cannot be performed` or `409 head did not match`; re-run from step 2. |
145 | 214 | - `gh pr checks` exit `8` is documented but was not observed here. Treat any non-`0`, non-`1` exit |
146 | 215 | as pending and wait rather than merging. |
| 216 | +- The `StatusContext` half of the step-5a filter is derived, not observed: the field list comes from |
| 217 | + `gh`'s `api/export_pr.go` and the terminal/failing values from the `StatusState` GraphQL enum, but |
| 218 | + no live legacy status context has been seen through it. Only the `CheckRun` half is observed |
| 219 | + (ps-mutuus/mutuus-changelog#52, and a 37-row live rollup on cli/cli#14334). If a repo's CI posts |
| 220 | + commit statuses rather than check runs, read the raw rows once before trusting the summary. |
0 commit comments