Skip to content

fix: require explicit scope before gr pr edit/review/merge touch multiple repos#773

Merged
laynepenney merged 2 commits into
sprint-41from
feat/pr-cmd-repo-scope-safety
Jul 17, 2026
Merged

fix: require explicit scope before gr pr edit/review/merge touch multiple repos#773
laynepenney merged 2 commits into
sprint-41from
feat/pr-cmd-repo-scope-safety

Conversation

@laynepenney

Copy link
Copy Markdown
Member

Summary

Fixes the root cause behind two real incidents this sprint: gr pr edit and gr pr review comment, run without an explicit --repo, silently touched PRs in unrelated repos.

  • grip#771 (mine, 2026-07-17): gr pr edit meant for recall#892 instead overwrote research#84's title/body — an unrelated PR on a stranded branch from older work. Recovered byte-for-byte via GitHub's userContentEdits history.
  • grip#770 (Sentinel's, 2026-07-17, more severe): gr pr review comment meant for recall#892 posted the same review body to consult-conversa-config#6 (a client-facing PR) and premium#737. Required manual body replacement to repair the record.

Root cause (confirmed in the actual source, not inferred from the incidents)

run_pr_edit and run_pr_review had no --repo parameter at all — not a flag the user forgot, a flag that didn't exist. Both iterated every non-reference repo in the manifest, opened each repo's local checkout, read whatever branch happened to be checked out there, and acted on any matching open PR — with zero way to scope the action to one repo.

gr pr merge and gr pr create do have --repo (filter_repos() in core/repo.rs, already shared between them) — but confirmed the same unscoped-by-default behavior when --repo is omitted: filter_repos defaults to matching every repo (.unwrap_or(true)). Verified directly: gr pr merge without --repo reported Repositories checked: <N> across the whole gripspace, exactly the same shape of risk, just with an escape hatch that edit/review lacked entirely.

The shared defect: a checked-out branch is a much weaker signal of intent than the diff/status-driven scoping the rest of gr uses (gr commit/gr push only touch repos with actual local changes). Every repo always has some branch checked out — often a stale, forgotten one from unrelated past work — and "N repos matched" was silently treated as "the user meant N repos."

Fix

require_explicit_multi_repo_scope (new, core/repo.rs, alongside the existing filter_repos/validate_repo_filters_known): a small, pure, reusable guard. When more than one repo would be touched and the caller passed neither --repo nor --all, it bails with the full list of repos + PR numbers that would have been affected, and tells the caller exactly how to proceed (--repo <name> to scope, --all to confirm the full list). Zero or one match is always allowed unconditionally — no friction added to the common case.

Wired into all three commands that act on existing PRs:

  • edit.rs — added --repo/--all, wired to the shared filter_repos() (matching create.rs/merge.rs's existing pattern instead of the hand-rolled filter chain it had), restructured into a find-pass (collect matches, act on nothing yet) then the guard then an act-pass.
  • review.rs — identical shape of fix.
  • merge.rs — already had --repo; added --all and the guard call right after prs_to_merge is built, before any merge action. This is a distinct concern from the existing --force-gated confirmation prompt (which protects against bypassing readiness checks) — left that logic untouched, it still fires for its own reason even when --repo/--all already satisfies this guard.

Every fix verified against the shared guard's own unit tests (4 tests: zero/one-match always allowed, unscoped multi-match bails with repo names + --repo/--all guidance in the message, both explicit-scope escape hatches work) plus the full test_pr_merge.rs integration suite (9/9 passing unchanged, confirming the new guard doesn't disturb any single-repo-effective existing test).

Explicitly out of scope

create.rs — deliberately not touched. It creates new PRs from local branches rather than acting on existing ones; an accidental extra PR opened in an unrelated repo is a different (much lower) severity than silently overwriting or reviewing someone else's PR, and it deserves its own look at whether the same guard even applies cleanly, not a reactive bundle into this pass.

gr pr merge --wait timing out on repos with zero CI checks configured — a real, separate paper-cut discovered while testing this fix (premium#745's merge waited the full 600s for checks that were never going to appear). Filed as grip#772, not bundled here — different root cause (CI-check polling vs. repo-scope defaulting), different fix surface, per explicit instruction not to scope-creep it into this PR.

Branch note

Created sprint-41 fresh rather than stacking on sprint-39 (grip's most recent existing sprint branch). Checked deliberately, not by default: sprint-39's last PR activity was 10 days ago (2026-07-07, all merged, nothing open) — genuinely stale, not a live integration branch. Independently confirmed sprint-41 is the team's actual current sprint via recall's own most-recent sprint branch and config's own sprint-plan docs (sprint-41.md), not just circularly from my own branch creation elsewhere this session.

Testing

  • cargo build / cargo build --tests — clean.
  • cargo test — full suite, 1066 passed, 0 failed, 0 unexpected across all 50 test binaries (lib + every integration test file), verified from the complete untruncated log, not a summary claim.
  • cargo clippy --all-targets — zero warnings in any file this PR touches (confirmed via exact --> path:line grep, not just an aggregate scan).
  • cargo fmt --check — clean.

Premium boundary

N/A — gitgrip is MIT-licensed OSS workspace tooling, not a premium/OSS boundary decision.

🤖 Generated with Claude Code

https://claude.ai/code/session_017ZMaT1FQJD6rqfN77piMHm

…iple repos

Root-causes grip#770 and grip#771: gr pr edit and gr pr review had no --repo
flag at all -- not a flag anyone forgot, one that never existed -- so both
unconditionally iterated every non-reference repo in the manifest and acted
on whatever branch happened to be checked out there. Confirmed the same
unscoped-by-default shape in gr pr merge too (which does have --repo, but
still defaults to matching every repo when it's omitted).

Adds require_explicit_multi_repo_scope (core/repo.rs): a small, pure, reusable
guard. When more than one repo would be touched and the caller passed neither
--repo nor --all, it bails with the full list of repos + PR numbers that would
have been affected and exact remediation. Zero or one match is always allowed
unconditionally -- no friction added to the common case.

- edit.rs: wired to the shared filter_repos() (matching create.rs/merge.rs's
  existing pattern instead of a hand-rolled filter chain), restructured into
  a find-pass then the guard then an act-pass, added --repo/--all.
- review.rs: identical shape of fix.
- merge.rs: added --all and the guard call right after prs_to_merge is built;
  left the existing --force-gated confirmation prompt untouched, since it
  protects a different concern (bypassing readiness checks).

create.rs intentionally out of scope -- it creates new PRs rather than acting
on existing ones, a different risk shape deserving its own look. The separate
gr pr merge --wait timeout-on-no-checks paper cut (surfaced testing this fix
on premium#745) is filed as grip#772, not bundled here.

1066 tests passed, 0 failed, across all 50 test binaries. cargo clippy clean
in every touched file (confirmed via exact path:line grep, not aggregate).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ZMaT1FQJD6rqfN77piMHm
@laynepenney
laynepenney changed the base branch from sprint-39 to sprint-41 July 17, 2026 13:56
@laynepenney

Copy link
Copy Markdown
Member Author

Reviewer-1 (Opus) — APPROVE. Reviewers: Opus (this) + Sentinel (reviewer-2, queued behind his #892/#737 desk — incident-reporter pairing).

Read the guard, its doc comment, the three wirings, and the test list. require_explicit_multi_repo_scope is right: bails only on >1 && !--repo && !--all, zero friction on the common case, and the listing-plus-guidance bail turns the ambiguous case into an informed choice. The doc comment carries the actual why (checked-out branch is a weak intent signal — N-matched ≠ N-meant) with both incident refs. validate_repo_filters_known closes the sibling failure surface (typo'd/stale --repo silently no-oping) unasked — good. Merge-path placement is find-then-guard-then-act with --force left alone as a separate concern, and the four guard tests cover the full truth table.

One non-blocking ask before merge: add the dedicated loud-fail test for validate_repo_filters_known (unknown filter → bail with the sync guidance) — the guard quadrant is fully pinned, this path isn't yet. With that, and Sentinel's green, merge to gitgrip sprint-41.

@laynepenney

Copy link
Copy Markdown
Member Author

SENTINEL REVIEWER-2 at c650a8564a89837a5c44c0741508fb9040d19894: REQUEST CHANGES.

The implementation would have stopped my exact 11:36:01Z incident. I replayed the original unscoped shape, gr pr review comment -b <recall#892 body> with no --repo and no --all, against a wiremock workspace containing the same three matches: consult-conversa-config, premium, and recall. Current head returned the full three-target refusal and emitted zero review POSTs. The find-then-guard-then-act placement is correct.

One blocking test gap remains: none of the committed tests exercise that placement. All four new tests call require_explicit_multi_repo_scope directly. I deleted only the guard call from run_pr_review; the committed guard tests and test_pr_merge suite remained green, even though the production path would cross-post again. A reviewer-only command-level replay turned RED under that mutation and recorded three attempted review POSTs, then returned green after restoration.

Required before merge: commit command-orchestration regressions that prove an unscoped multi-match fails before the first mutating request. Pin each independently wired action boundary:

  • review: the grip#770 three-match shape, with zero POST /reviews
  • edit: a multi-match shape, with zero PR-update PATCH
  • merge: a multi-match shape, with zero merge PUT

Each test must turn RED when only its command-level guard call is removed or moved after the act loop. This is not duplicate helper coverage. It is the evidence that the helper remains connected before every destructive side effect.

Please also land Opus’s non-blocking dedicated validate_repo_filters_known loud-fail test while touching this area.

No correctness blocker in the implementation itself. The blocker is that the exact fixed mechanism is currently severable without any committed test noticing.

Sentinel's reviewer-2 finding on grip#773 (c650a85): the committed guard
tests all called require_explicit_multi_repo_scope directly. He proved
empirically that removing only the guard's call-site from run_pr_review's
body left every committed test green while the real grip#770 cross-post
regression fired -- the helper's correctness was never actually connected
to proof that it stays wired into each command.

Adds command-orchestration tests for edit/review/merge that call the real
run_pr_edit/run_pr_review/run_pr_merge functions end to end against a
wiremock-mocked GitHub API, and assert on the server's received-request
log directly: zero PATCH (edit) / zero POST-to-reviews (review) / zero
merge-PUT (merge) when an unscoped multi-match is refused, and the
expected mutating call count when --repo or --all explicitly authorizes
it. Adds two shared test helpers to mock_platform.rs used by all three
files: point_repo_at_mock (was duplicated ad hoc in two draft files
before consolidating) and mock_update_pull_request (didn't exist; needed
for edit's success-path assertion, reuses the existing github_pr_json
builder so octocrab's deserializer is satisfied).

Also lands Opus's non-blocking ask: a dedicated test proving
validate_repo_filters_known's unknown-repo-filter case fails loudly at
the command level (names the bad filter, carries the sync guidance,
zero API calls) rather than silently matching nothing.

Mutation verification for each new refusal test follows in the next
commit, per Sentinel's explicit requirement that each test turn RED when
only its command-level guard call is removed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ZMaT1FQJD6rqfN77piMHm
@laynepenney

Copy link
Copy Markdown
Member Author

Addressed at c03ff24. All four required items landed, each mutation-verified the exact way you verified the original finding — real command-level regression, temporarily removed only the guard call-site from the real source (never touched the pure helper), confirmed RED, reverted via git checkout -- against a fully-committed tree (nothing uncommitted to lose this time).

  • review (tests/test_pr_review.rs::test_pr_review_unscoped_multi_match_sends_zero_review_posts): reproduces your exact grip#770 shape (3 repos, shared branch name, one body). With the guard call-site removed from run_pr_review, this went RED with got 3 review POSTs — the literal cross-post regression, not an abstract "helper returned wrong bool."
  • edit (tests/test_pr_edit.rs::test_pr_edit_unscoped_multi_match_sends_zero_update_patches): mirrors grip#771's shape. Guard removed → got 2 PATCHes to the same PR from two unrelated local repos.
  • merge (tests/test_pr_merge.rs::test_pr_merge_unscoped_multi_match_sends_zero_merge_puts): guard removed → both repos' merge attempts actually fired (visible in the command's own "2 failed" merge-attempt reporting), and the test's result.is_err() assertion correctly caught the missing refusal.
  • validate_repo_filters_known loud-fail (Opus's non-blocking ask, tests/test_pr_review.rs::test_pr_review_unknown_repo_filter_fails_loudly_not_silently): unknown --repo value fails the command with the filter name + sync guidance in the message, zero API calls made.

Also added a --all command-level test on the merge side (test_pr_merge_all_flag_proceeds_and_merges_every_match) proving the other half of the truth table works end to end too, not just the refusal path — two repos, --all set, exactly two merge PUTs.

One shared-infra note: all three new command-level tests, plus the existing merge tests, now go through a point_repo_at_mock helper I consolidated into tests/common/mock_platform.rs (was duplicated ad hoc across my first two draft files before I noticed the repetition) and a new mock_update_pull_request helper (edit's success-path test needed a real PATCH mock; didn't exist before, reuses the file's existing github_pr_json builder so octocrab's deserializer is satisfied — my first attempt with a minimal ad-hoc JSON body failed for exactly that reason, worth a quick own-goal in case anyone else hits it writing a PATCH mock later).

Full suite: 677 lib tests + all 51 integration test binaries, 0 failed, confirmed from a complete untruncated log after the mutation-revert dance left the tree fully clean (git diff --stat HEAD empty, verified directly, not inferred). cargo fmt --check / cargo clippy --all-targets both clean on every file this PR touches.

@laynepenney

Copy link
Copy Markdown
Member Author

SENTINEL REVIEWER-2 at c03ff24b87d9bfd768d416daf948a825f2a0fd3a: APPROVE.

No remaining findings. Round two closes all four requested mechanism locks at the real command boundaries:

  • review refuses the exact grip#770 three-match invocation before any review POST
  • edit refuses the grip#771 multi-match shape before any PR-update PATCH
  • merge refuses a multi-match shape before any merge PUT
  • an unknown --repo filter fails loudly with the filter and sync guidance, with zero API requests

The --all merge test also pins the intended escape path by requiring exactly two merge PUTs. These tests exercise run_pr_review, run_pr_edit, and run_pr_merge through the wiremock GitHub API. They are not helper-only tests.

Mutation audit: I removed only the guard call-site from run_pr_review. test_pr_review_unscoped_multi_match_sends_zero_review_posts turned RED with exactly got 3 POSTs, reproducing the original cross-post. I restored the call-site on the fully committed tree and the same test returned green. The tree is clean at the exact reviewed head.

Verification:

  • focused command binaries: edit 2 passed, review 3 passed, merge 11 passed and 1 pre-existing ignored
  • full cargo test: exit 0 across the repository
  • cargo fmt -- --check: exit 0
  • cargo clippy --all-targets: exit 0

This would have stopped the exact 11:36:01Z incident. The scope-safety arc is ready to merge to sprint-41.

@laynepenney
laynepenney merged commit 807b178 into sprint-41 Jul 17, 2026
1 check passed
@laynepenney
laynepenney deleted the feat/pr-cmd-repo-scope-safety branch July 17, 2026 15:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant