Skip to content

Commit aa9012a

Browse files
authored
Merge pull request #188 from netresearch/feat/pr-status-186-187
fix(pr-status): hold --watch on unsettled CI; detect the invisible signature gate
2 parents 08d44f7 + 0fd4e3f commit aa9012a

3 files changed

Lines changed: 157 additions & 10 deletions

File tree

skills/git-workflow/references/pull-request-workflow.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,22 @@ On `CLOSED / FIXED`, treat it as an ordinary already-fixed bot thread: reply wit
403403

404404
Observed 2026-07-30 on a `docker:S8544` finding: gate `OK`, 0 open issues, 0 failing checks, `mergeStateStatus: BLOCKED` on one stale thread — a merge that looked inexplicably stuck until the thread was read.
405405

406+
### `gh pr update-branch` re-writes the head UNSIGNED
407+
408+
Both forms (merge and `--rebase`) create the new commit server-side, signed by
409+
nobody. In a repo that requires signed commits — including a requirement
410+
living in **classic branch protection**, which neither the rulesets endpoint
411+
nor a non-admin protection query can see — the PR then sits at
412+
`mergeStateStatus: BLOCKED` with every visible gate green (observed on a PR
413+
that reported request-review for an hour while the real blocker was the
414+
signature). Rebase locally instead: signing is wired into git, so a plain
415+
`git rebase origin/main` (or `git commit --amend --no-edit` when only the
416+
signature is missing) re-signs, then push with `--force-with-lease`. Two
417+
traps in that push: a checkout created from `FETCH_HEAD` has no lease
418+
baseline and fails with `stale info` — pass the lease explicitly as
419+
`--force-with-lease=<branch>:<remote-sha>`; and that remote SHA must be
420+
**measured** (`git ls-remote origin <branch>`), never retyped from memory.
421+
406422
### Signature verification: the GitHub API is the source of truth, not your keyring
407423

408424
For "is this commit signed?" in a review, ask the API:

skills/git-workflow/scripts/pr-status.sh

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,38 @@ evaluate() {
408408
else "" end)
409409
+ " — the queue merges it once its own checks pass; enqueueing"
410410
+ " again only restarts them")}
411+
# A required-signatures gate can live in CLASSIC branch protection,
412+
# which neither the rulesets endpoint nor a non-admin protection
413+
# query can see: mergeState sits at BLOCKED while every visible gate
414+
# is green. When a branch commit is unsigned, that invisible gate is
415+
# the prime suspect — and it must outrank the advisory review
416+
# branches below, which otherwise mask it (observed: a
417+
# `gh pr update-branch --rebase` re-wrote the head UNSIGNED and the
418+
# PR reported request-review while the real blocker was the
419+
# signature; #187). Guarded on checks_settled and zero failures so
420+
# it only fires when nothing visible explains the BLOCKED.
421+
# reviewDecision is consulted first: BLOCKED caused by a required
422+
# human review (REVIEW_REQUIRED / CHANGES_REQUESTED) reaches this
423+
# spot too, and authors who simply do not sign commits would get a
424+
# history-rewriting rebase command for a review problem.
425+
elif (($s.unsigned|length) > 0
426+
and $s.mergeState == "BLOCKED"
427+
and ($s.reviewDecision != "REVIEW_REQUIRED")
428+
and ($s.reviewDecision != "CHANGES_REQUESTED")
429+
and $s.checks_settled
430+
and (($s.checks.failing|length) == 0)
431+
and $s.unresolved_threads == 0) then
432+
{action:"fix-signatures",
433+
why:("\($s.unsigned|length) commit(s) on this branch carry no valid signature — \($s.unsigned|join(", "))"
434+
+ " — and mergeState is BLOCKED with every visible gate green."
435+
+ " A signature requirement in classic branch protection is"
436+
+ " invisible to the rulesets endpoint and to non-admin"
437+
+ " queries — fix the signatures before chasing review state."
438+
+ " Note: gh pr update-branch re-writes the head UNSIGNED;"
439+
+ " rebase locally instead"),
440+
# No single quotes in here: the whole jq program lives in a
441+
# single-quoted shell string (same trap as the sibling cmd below).
442+
cmd:"git rebase --exec \"git commit --amend --no-edit -S\" $(git merge-base HEAD origin/\($s.base)) ; git push --force-with-lease"}
411443
# `|` binds looser than `and`, so the negation needs its own parens:
412444
# `a and b|not` parses as `(a and b)|not` and inverts the whole test.
413445
# has_copilot_review_on_head must be false too: the error row stays on
@@ -643,12 +675,20 @@ while :; do
643675
fi
644676
case "$act" in
645677
request-review)
678+
# request-review while CI has not settled is not actionable yet — the
679+
# ladder itself stamps "do not enqueue on this reading" onto the why.
680+
# Keep waiting: a failing check fires the branch above, and once the
681+
# checks settle this returns on the review state (#186). The quota
682+
# dead-end is exempt — waiting cannot clear it, return immediately.
683+
if [ "$(jq -r '.checks_settled' <<<"$s")" != "true" ] \
684+
&& [ "$(jq -r '.copilot_quota_hit // false' <<<"$s")" != "true" ]; then
685+
:
646686
# A request-review whose cause is an exhausted review bot is a standing
647687
# condition, not an event: waiting cannot clear a quota ceiling, so every
648688
# re-arm of --watch returns instantly with the same line. Saying so is
649689
# what stops an operator re-arming it three times before switching to
650690
# `gh pr checks --watch`, which watches something that does move.
651-
if [ "$(jq -r '.copilot_quota_hit // false' <<<"$s")" = "true" ]; then
691+
elif [ "$(jq -r '.copilot_quota_hit // false' <<<"$s")" = "true" ]; then
652692
echo "ACTIONABLE: request-review (UNSATISFIABLE — Copilot is OUT OF REVIEW QUOTA" \
653693
"for the month; this will NOT change until the monthly reset, on this or any" \
654694
"other PR. Do not re-arm this watch and do not re-request — review the diff" \
@@ -662,8 +702,12 @@ while :; do
662702
else
663703
echo "ACTIONABLE: request-review"
664704
fi
665-
emit "$s"; exit 0 ;;
666-
fix-ci|triage-ci|resolve-threads|rebase|resolve-conflicts|merge|blocked|none)
705+
# Unsettled-CI hold: emit nothing, fall through to the wait line.
706+
if [ "$(jq -r '.checks_settled' <<<"$s")" = "true" ] \
707+
|| [ "$(jq -r '.copilot_quota_hit // false' <<<"$s")" = "true" ]; then
708+
emit "$s"; exit 0
709+
fi ;;
710+
fix-ci|triage-ci|resolve-threads|rebase|resolve-conflicts|merge|blocked|none|fix-signatures)
667711
echo "ACTIONABLE: $act"
668712
emit "$s"; exit 0 ;;
669713
esac

tests/test_pr_status_errored_review.sh

Lines changed: 94 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,22 @@ cat "$STUB_DIR/graphql.json"
5656
STUB
5757
chmod +x "$STUB_DIR/gh"
5858
python3 - "$STUB_DIR/graphql.json" "$@" <<'PY'
59-
import sys, json
59+
import sys, json, os
6060
out, bodies = sys.argv[1], sys.argv[2:]
6161
head = "deadbeefcafe"
62+
# Env overrides so the signature/settled ladder branches are testable:
63+
# MERGE_STATE (default CLEAN), SIGNED (default 1),
64+
# PENDING_CHECK=1 adds an IN_PROGRESS check run (checks_settled -> false).
65+
merge_state = os.environ.get("MERGE_STATE", "CLEAN")
66+
signed = os.environ.get("SIGNED", "1") == "1"
67+
review_decision = os.environ.get("REVIEW_DECISION") or None
68+
checks = [{"__typename": "CheckRun", "name": "CI", "conclusion": "SUCCESS",
69+
"status": "COMPLETED", "detailsUrl": "u",
70+
"startedAt": "2026-01-01T00:00:00Z"}]
71+
if os.environ.get("PENDING_CHECK", "0") == "1":
72+
checks.append({"__typename": "CheckRun", "name": "slow", "conclusion": None,
73+
"status": "IN_PROGRESS", "detailsUrl": "u",
74+
"startedAt": "2026-01-01T00:00:00Z"})
6275
reviews = [{"author": {"login": "copilot-pull-request-reviewer"},
6376
"state": "COMMENTED", "commit": {"oid": head}, "body": b}
6477
for b in bodies]
@@ -67,20 +80,17 @@ json.dump({"data": {"repository": {
6780
"mergeCommitAllowed": True, "rebaseMergeAllowed": False, "squashMergeAllowed": False,
6881
"pullRequest": {
6982
"number": 1, "title": "t", "state": "OPEN", "isDraft": False,
70-
"mergeable": "MERGEABLE", "mergeStateStatus": "CLEAN", "reviewDecision": None,
83+
"mergeable": "MERGEABLE", "mergeStateStatus": merge_state, "reviewDecision": review_decision,
7184
"author": {"login": "someone"},
7285
"baseRefName": "main", "headRefName": "f", "headRefOid": head,
7386
"isCrossRepository": False,
7487
"reviews": {"nodes": reviews},
7588
"reviewRequests": {"nodes": []},
7689
"reviewThreads": {"nodes": []},
7790
"commits": {"nodes": [{"commit": {"oid": head, "statusCheckRollup": {
78-
"state": "SUCCESS", "contexts": {"nodes": [
79-
{"__typename": "CheckRun", "name": "CI", "conclusion": "SUCCESS",
80-
"status": "COMPLETED", "detailsUrl": "u",
81-
"startedAt": "2026-01-01T00:00:00Z"}]}}}}]},
91+
"state": "SUCCESS", "contexts": {"nodes": checks}}}}]},
8292
"allCommits": {"nodes": [{"commit": {"oid": head,
83-
"signature": {"isValid": True}}}]},
93+
"signature": {"isValid": signed}}}]},
8494
}}}}, open(out, "w"))
8595
PY
8696
}
@@ -430,6 +440,83 @@ make_stub_reviews \
430440
"a-human|CHANGES_REQUESTED|found something else"
431441
check "reviews_on_head[a-human]" "APPROVED+CHANGES_REQUESTED" "$(run_flag '"reviews_on_head"."a-human"')"
432442

443+
444+
# --- Signature gate (#187): BLOCKED + unsigned + all green must name the ---
445+
# --- signature, even when a quota-errored Copilot row sits on the head ---
446+
echo "case S1: BLOCKED + unsigned + quota error row — fix-signatures outranks request-review"
447+
MERGE_STATE=BLOCKED SIGNED=0 make_stub "$ERR_QUOTA"
448+
check "next.action" "fix-signatures" "$(run_next)"
449+
if status | jq -e '.next.why | test("classic branch protection")' >/dev/null; then
450+
echo " ok why names the invisible classic protection"
451+
else
452+
echo " FAIL why does not name classic branch protection"
453+
fail=1
454+
fi
455+
456+
echo "case S2: BLOCKED + signed — the signature gate must stay quiet"
457+
MERGE_STATE=BLOCKED SIGNED=1 make_stub "$ERR_QUOTA"
458+
check "next.action" "request-review" "$(run_next)"
459+
460+
echo "case S3: CLEAN + unsigned — no signature rule visible, merge stays open"
461+
MERGE_STATE=CLEAN SIGNED=0 make_stub "$REAL_REVIEW"
462+
check "next.action" "merge" "$(run_next)"
463+
464+
echo "case S4: BLOCKED + unsigned + REVIEW_REQUIRED — review cause outranks the signature guess"
465+
MERGE_STATE=BLOCKED SIGNED=0 REVIEW_DECISION=REVIEW_REQUIRED make_stub "$ERR_QUOTA"
466+
if [ "$(run_next)" = "fix-signatures" ]; then
467+
echo " FAIL fired fix-signatures although a required review explains the BLOCKED"
468+
fail=1
469+
else
470+
echo " ok signature branch stays quiet when reviewDecision explains BLOCKED"
471+
fi
472+
473+
# --- Watch settle-gate (#186): request-review with unsettled CI must WAIT ---
474+
watch() { PATH="$STUB_DIR:$PATH" bash "$SCRIPT" -R o/r 1 --watch --interval 1 --max-wait 2; }
475+
echo "case W1: watch + pending check + no review — holds until timeout, no ACTIONABLE"
476+
PENDING_CHECK=1 make_stub
477+
out=$(watch || true)
478+
case "$out" in
479+
*"ACTIONABLE: request-review"*)
480+
echo " FAIL watch emitted request-review while CI was unsettled"; fail=1 ;;
481+
*TIMEOUT*)
482+
echo " ok watch held (timeout) instead of firing early" ;;
483+
*) echo " FAIL unexpected watch output: $(printf '%s' "$out" | head -2)"; fail=1 ;;
484+
esac
485+
486+
echo "case W2: watch + settled + no review — returns immediately"
487+
make_stub
488+
out=$(watch || true)
489+
case "$out" in
490+
*"ACTIONABLE: request-review"*) echo " ok immediate return on settled CI" ;;
491+
*) echo " FAIL expected immediate ACTIONABLE: request-review"; fail=1 ;;
492+
esac
493+
494+
echo "case W3: watch + pending check + QUOTA error — quota exemption still returns immediately"
495+
PENDING_CHECK=1 make_stub "$ERR_QUOTA"
496+
# Assert the discriminating signal: the exemption path's unique ACTIONABLE
497+
# prefix AND no TIMEOUT. The bare quota phrase also appears in every
498+
# "waiting:" line's why-text, so matching it passes vacuously when the
499+
# exemption is broken and the watch holds to timeout (found by mutation d).
500+
out=$(watch || true)
501+
case "$out" in
502+
*"ACTIONABLE: request-review (UNSATISFIABLE"*)
503+
case "$out" in
504+
*TIMEOUT*) echo " FAIL exemption fired only at timeout"; fail=1 ;;
505+
*) echo " ok quota dead-end returns immediately despite unsettled CI" ;;
506+
esac ;;
507+
*) echo " FAIL quota exemption did not fire (no UNSATISFIABLE ACTIONABLE line)"; fail=1 ;;
508+
esac
509+
510+
echo "case W4: watch + BLOCKED + unsigned + settled — fix-signatures is an actionable event"
511+
MERGE_STATE=BLOCKED SIGNED=0 make_stub "$REAL_REVIEW"
512+
out=$(watch || true)
513+
case "$out" in
514+
*"ACTIONABLE: fix-signatures"*) echo " ok watch returns immediately on the signature gate" ;;
515+
*TIMEOUT*) echo " FAIL watch held to timeout on its own headline scenario"; fail=1 ;;
516+
*) echo " FAIL unexpected watch output"; fail=1 ;;
517+
esac
518+
519+
433520
if [ "$fail" -eq 0 ]; then
434521
echo "all pass"
435522
else

0 commit comments

Comments
 (0)