fix: require explicit scope before gr pr edit/review/merge touch multiple repos#773
Conversation
…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
|
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. One non-blocking ask before merge: add the dedicated loud-fail test for |
|
SENTINEL REVIEWER-2 at The implementation would have stopped my exact 11:36:01Z incident. I replayed the original unscoped shape, One blocking test gap remains: none of the committed tests exercise that placement. All four new tests call 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:
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 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
|
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
Also added a One shared-infra note: all three new command-level tests, plus the existing merge tests, now go through a 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 ( |
|
SENTINEL REVIEWER-2 at No remaining findings. Round two closes all four requested mechanism locks at the real command boundaries:
The Mutation audit: I removed only the guard call-site from Verification:
This would have stopped the exact 11:36:01Z incident. The scope-safety arc is ready to merge to |
Summary
Fixes the root cause behind two real incidents this sprint:
gr pr editandgr pr review comment, run without an explicit--repo, silently touched PRs in unrelated repos.gr pr editmeant 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'suserContentEditshistory.gr pr review commentmeant 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_editandrun_pr_reviewhad no--repoparameter 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 mergeandgr pr createdo have--repo(filter_repos()incore/repo.rs, already shared between them) — but confirmed the same unscoped-by-default behavior when--repois omitted:filter_reposdefaults to matching every repo (.unwrap_or(true)). Verified directly:gr pr mergewithout--reporeportedRepositories checked: <N>across the whole gripspace, exactly the same shape of risk, just with an escape hatch thatedit/reviewlacked entirely.The shared defect: a checked-out branch is a much weaker signal of intent than the diff/status-driven scoping the rest of
gruses (gr commit/gr pushonly 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 existingfilter_repos/validate_repo_filters_known): a small, pure, reusable guard. When more than one repo would be touched and the caller passed neither--reponor--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,--allto 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 sharedfilter_repos()(matchingcreate.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--alland the guard call right afterprs_to_mergeis 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/--allalready 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/--allguidance in the message, both explicit-scope escape hatches work) plus the fulltest_pr_merge.rsintegration 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 --waittiming 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-41fresh rather than stacking onsprint-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 confirmedsprint-41is the team's actual current sprint viarecall's own most-recent sprint branch andconfig'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:linegrep, 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