Skip to content

feat(zsh): gate external-contributor PRs in ghsq/ghrb/ghrp - #348

Merged
laurigates merged 1 commit into
mainfrom
feat/zsh-external-pr-merge-guard
Jul 30, 2026
Merged

feat(zsh): gate external-contributor PRs in ghsq/ghrb/ghrp#348
laurigates merged 1 commit into
mainfrom
feat/zsh-external-pr-merge-guard

Conversation

@laurigates

Copy link
Copy Markdown
Owner

Why

ghsq / ghrb are for bulk-merging my own and bots' PRs, and the muscle memory is ghsq^a (select all) → a (all remaining). A drive-by PR from a stranger riding that reflex is merged code nobody read — and a PR that adds a workflow, a hook, or a shell script executes on merge.

laurigates/claude-plugins is public and just received one (#2231, one of a 284-repo spray with zero merges anywhere). It added a CI workflow calling a third-party action on a floating @v0 tag.

Three layers, none of which slow the normal path down

Layer Behaviour
Picker External PRs are hidden by default, so ^a cannot even select one. A stderr banner reports the count and lists them (hiding a contribution must not mean ghosting it). -x / --external opts them in, rendered white-on-red.
Merge gate Typing merge is required — not a keypress. First prints author, author_association, diff size, and specifically which touched paths EXECUTE (CI workflow, shell, .claude/, build entry point).
a=all Cannot reach an external PR: the gate lives in _gh_poll_merge, which a still routes through. The ghsq 123 arg form bypasses the picker entirely, so it's gated separately at that call site.

ghrp inherits the merge gate for free (shared batch engine) and gains an author column — a stranger's PR carrying an autorelease: pending label in an org repo is exactly the case worth seeing.

The subtle part

A bot author renders three different ways depending on which gh subcommand produced it:

Source login is_bot type
gh pr list / gh pr view app/laurigates-release-please true
gh search prs laurigates-release-please[bot] false Bot

Keying on is_bot alone silently classifies every App bot in search results as a stranger — which would make ghrp demand a typed word on every release PR and train me to stop reading the prompt. _gh_trust accepts all four spellings.

Fails closed throughout: if the author can't be read, or my own login can't be resolved, the merge is skipped, not performed.

Verification

Live, against the real repo:

$ ghsq                      # default
  ⚠ 1 external PR(s) hidden  — ghsq -x to include
      #2231  joshua-trustabl  Add Trustabl security scanning to CI
  → picker lists only #2237 (mine)

$ ghsq -x                   # opted in; #2231 present, marked ⚠

$ _gh_trust_gate laurigates/claude-plugins 2231   # answered "n"
  ╔═ ⚠ EXTERNAL CONTRIBUTOR ═
  ║ laurigates/claude-plugins#2231  Add Trustabl security scanning to CI
  ║ author   joshua-trustabl   (FIRST_TIME_CONTRIBUTOR)
  ║ size    1 file(s)  +17 -0
  ║  ⚠ EXECUTABLE PATHS TOUCHED
  ║     .github/workflows/trustabl.yml  ← CI executes this
  ╠═ not covered by 'a'=all — review it before answering
  ╚═ type merge to merge, anything else skips
  → rc=1, not merged

$ _gh_trust_gate laurigates/claude-plugins 2237   # my own PR
  rc=0, no output

Plus a 39-assertion harness over the classifier and the gate (both bot renderings, flag-value-not-selector, y/empty/MERGE answers, unreadable-author and unresolvable-own-login fail-closed paths). chezmoi cat ~/.zshrc | zsh -n clean; template renders.

Related

🤖 Generated with Claude Code

https://claude.ai/code/session_01VXmv48FuSrSLh9CKha7d3R

The merge wrappers exist for bulk-merging my own and bots' PRs, and the muscle
memory is `ghsq` → ^a (select all) → a (all remaining). A drive-by PR from a
stranger riding that reflex merges code nobody read — and a PR that adds a
workflow, a hook, or a shell script executes on merge. laurigates/claude-plugins
is public and just received one (#2231, one of a 284-repo spray).

Three layers, none of which slow down the normal path:

- The picker HIDES external PRs by default, so ^a cannot even select one. A
  stderr banner reports the count and lists them, so hiding a contribution
  never means ghosting it; `ghsq -x` / `ghrb -x` opts them in, rendered
  white-on-red.
- Merging one requires typing `merge` — not a keypress. The prompt first prints
  the author, their author_association, the diff size, and specifically which
  touched paths EXECUTE (CI workflow, shell, .claude config, build entry point).
- `a`=all-remaining cannot reach it: the gate lives in _gh_poll_merge, which
  `a` still routes through. The `ghsq 123` arg form bypasses the picker
  entirely, so it is gated separately at that call site.

ghrp inherits the merge gate for free (shared batch engine) and gains an author
column — a stranger's PR carrying an `autorelease: pending` label in an org repo
is exactly the case worth seeing.

The bit that took longest to get right: a bot author renders three different
ways depending on which gh subcommand produced it.

  gh pr list    →  login "app/<name>",   is_bot true,  no type
  gh search prs →  login "<name>[bot]",  is_bot FALSE, type "Bot"

Keying on is_bot alone silently classifies every App bot in search results as a
stranger — which would make ghrp demand a typed word on every release PR and
train me to stop reading the prompt. _gh_trust accepts all four spellings.

Fails closed throughout: if the author cannot be read, or my own login cannot be
resolved, the merge is skipped rather than performed. Cannot-verify is not safe.

The agent-side counterpart is laurigates/claude-plugins#2240 (a PreToolUse hook
denying the same merges from an agent), and the policy is documented in the
portfolio rules (repos-claude-config .claude/rules/external-contributor-prs.md)
rather than in the always-loaded global set, which is already near its budget.
@laurigates laurigates self-assigned this Jul 30, 2026
@laurigates
laurigates merged commit ac0aa5f into main Jul 30, 2026
8 checks passed
@laurigates
laurigates deleted the feat/zsh-external-pr-merge-guard branch July 30, 2026 13:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant