feat(pr-update): change reviewers on an existing PR, without discarding approvals - #58
Conversation
…ng approvals Closes #57. Reviewers could only be set at creation time, which is the wrong way round: PRs get opened, and then someone works out who should look at them. Adds `--reviewer` and `--remove-reviewer` to pr-update, mirrored as `add_reviewers` / `remove_reviewers` on bb_ops.pr_update and the MCP tool. Add and remove, not replace Bitbucket has no add-a-reviewer endpoint. The PR PUT REPLACES the whole `reviewers` array, so sending just the person being added silently unassigns everyone else, and the request still returns 200. Both options therefore read the PR first and send the full resulting list. Adding someone already on the PR is a no-op rather than a duplicate, so both are idempotent. The issue floated a `--set-reviewers` for wholesale replace. This ships add/remove instead and deliberately omits replace: replace is precisely the operation that silently discards other people's approvals, and add/remove composes to the same result with every change stated explicitly. `bb pr` already lists reviewers, so the current set is readable before changing it. The read-modify-write has a race: a reviewer added by someone else between the GET and the PUT is lost. Bitbucket exposes no ETag or if-match on this endpoint, so the window cannot be closed here; it is narrow and the operation is trivially repeatable. Noted in both implementations rather than left for the next reader to discover. Approvals are not discarded silently Removing a reviewer who has ALREADY APPROVED discards that approval, and re-adding them does not bring it back. That is unrecoverable from the CLI, so it is refused unless `--drop-approvals` (Python: `drop_approvals=True`) is passed, matching the repo-wide rule that a destructive action is never a default. Removing a reviewer who has not approved needs no opt-in. The guard reads `participants[]`, not `reviewers[]`: approval state exists only on the former, and a guard that looks for it on the latter finds nothing and lets every removal through. A test pins that distinction against a fixture whose reviewers carry no approval field, as the real API returns. Readback `bb pr` now prints each reviewer's UUID next to the name, and pr-update echoes the resulting list after a reviewer change. Display names are not unique in a workspace, so a name alone does not confirm the right person was assigned. bb members: making an ambiguous pick visible The issue noted two members sharing a display name and suggested marking inactive accounts. Probing the live workspace showed that would not have helped: the two accounts share display name AND nickname, and BOTH are active, so only the UUID tells them apart. `bb members` now says so explicitly when a display-name/nickname pair is shared, which is the condition that actually makes picking by name a coin flip. Deactivated accounts are marked `(inactive)` as well, since they can still be assigned as a reviewer and are dead weight on a PR. The status rides in on the same request via `fields=+values.user.account_status`, so it costs no extra call and no per-member lookup. Tests 26 new pytest cases (646 total) and two bash harnesses at 36 and 42 assertions. Every reviewer assertion reads the actual PUT body, because the silent-unassign bug returns 200; the approval cases assert no PUT was issued at all, because a guard that fires after the write is worthless. Mutation-verified: the naive send-only-the-added-reviewer implementation fails 11 assertions, removing the approval guard fails 7, sourcing approval from `reviewers` instead of `participants` fails 7, moving the guard after the write fails 7, making remove a no-op fails 5, and running the read-modify-write on a title-only update fails 2. Also corrects the README's MCP tool count, stale at 42 since members_list landed in v1.10.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @daniel-pittman's task in 2m 40s —— View job Code ReviewReviewed the full diff against ✅ What's right
🟡 MEDIUM — bare (unbraced) UUID on
|
…clause MEDIUM, confirmed and fixed. `_require_reviewer_uuid` deliberately accepts a bare (unbraced) UUID because people strip the braces when copying, but it stored the value raw. Every value it is compared against comes from the API braced: `.reviewers[].uuid` and `.participants[].user.uuid`. So a bare UUID matched nothing. Reproduced before fixing. `--remove-reviewer <bare>` left the reviewer in place and exited 0 with no message, and `--reviewer <bare>` appended a duplicate of someone already assigned. Accepting both forms is only safe if they converge, so both now canonicalise to the braced form the API uses. Bash canonicalises in `_require_reviewer_uuid`, which sets REVIEWER_UUID in the CALLER's scope rather than echoing it. `exit 1` inside a `$( )` substitution kills only the subshell, so an echo-based helper would let a rejected value through as an empty string; this is the same reason resolve_repo assigns instead of printing. Python canonicalises in `_normalise_reviewer_uuids`, and pr_create now uses that shared helper instead of its own inline loop, so create and update validate and canonicalise identically. Values that are not uuid-shaped pass through untouched: this layer does not validate format (the bash `_require_*` sibling does), and wrapping an unrecognised string in braces would corrupt it. This changes what v1.10.0's `pr-create --reviewer` sends for a bare UUID: braced rather than verbatim. Braced is the form the API returns, so it is strictly closer to the contract. The v1.10.0 assertion that pinned pass-through is updated to pin canonicalisation. Five new cases cover the gap the review named: bare removal, bare dedup on add, and non-uuid pass-through in Python; bare removal and bare add in bash. Mutation-verified both ways: storing the raw value again fails 2 bash assertions in each harness and 2 in pytest. LOW: agents/bitbucket.md still said the PUT preserves "source/destination branches and reviewers" immediately before explaining that reviewers are the exception that gets replaced. Dropped the contradicting clause. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @daniel-pittman's task in 2m 22s —— View job Code ReviewRe-reviewed the full diff against ✅ Prior findings resolved
🟢 LOW — Python
|
…tion
LOW, confirmed. `_UUID_RE` used `\{?…\}?`, which also matches a
half-braced `{aaaa…` (leading brace, no closer). The matched-pair strip
then declines to fire, and the value is re-wrapped into `{{aaaa…}` —
mangling input the docstring promises to return untouched.
The regex now requires balanced braces: fully braced or fully bare. A
half-brace no longer matches and is returned verbatim, which is what the
docstring says and what the caller typed. Bash already rejected the same
input locally via `_require_reviewer_uuid`'s matched-pair strip, so this
closes the asymmetry the review named.
Fails closed either way (the API rejects both forms), so this is contract
hygiene rather than a behavior fix. Two parametrized cases cover both
half-brace orientations; mutation-verified by restoring the loose regex.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Semgrep (OSS): no findingsClean scan. Posted before the Claude review. |
CI Tests✅ Tests passed: the pytest suite across Python 3.10/3.11/3.12 (and bash syntax checks) is green on Posted before the Claude review so the test outcome is folded into the review. |
|
Claude finished @daniel-pittman's task in 1m 16s —— View job Code ReviewRe-reviewed the full diff against ✅ Prior finding resolved
Nothing new foundI re-checked the security-sensitive core once more since the canonicalisation now sits upstream of the guards:
Test coveragePer the CI Tests comment, the pytest suite is green across Python 3.10/3.11/3.12 and the bash syntax/32-floor checks on The fix commit changes source behavior and ships with tests — no gap:
All three findings from the earlier passes are now resolved, each with a test and mutation evidence. The reviewer-change feature is correct on both surfaces — participants-sourced approval guard, guard-before-PUT, no replace-by-default, and brace canonicalisation upstream of the guards so input form can't slip past them. Solid work. |
Review Gate✅ Gate: PASS — Review reported no HIGH/CRITICAL findings. |
…clause MEDIUM, confirmed and fixed. `_require_reviewer_uuid` deliberately accepts a bare (unbraced) UUID because people strip the braces when copying, but it stored the value raw. Every value it is compared against comes from the API braced: `.reviewers[].uuid` and `.participants[].user.uuid`. So a bare UUID matched nothing. Reproduced before fixing. `--remove-reviewer <bare>` left the reviewer in place and exited 0 with no message, and `--reviewer <bare>` appended a duplicate of someone already assigned. Accepting both forms is only safe if they converge, so both now canonicalise to the braced form the API uses. Bash canonicalises in `_require_reviewer_uuid`, which sets REVIEWER_UUID in the CALLER's scope rather than echoing it. `exit 1` inside a `$( )` substitution kills only the subshell, so an echo-based helper would let a rejected value through as an empty string; this is the same reason resolve_repo assigns instead of printing. Python canonicalises in `_normalise_reviewer_uuids`, and pr_create now uses that shared helper instead of its own inline loop, so create and update validate and canonicalise identically. Values that are not uuid-shaped pass through untouched: this layer does not validate format (the bash `_require_*` sibling does), and wrapping an unrecognised string in braces would corrupt it. This changes what v1.10.0's `pr-create --reviewer` sends for a bare UUID: braced rather than verbatim. Braced is the form the API returns, so it is strictly closer to the contract. The v1.10.0 assertion that pinned pass-through is updated to pin canonicalisation. Five new cases cover the gap the review named: bare removal, bare dedup on add, and non-uuid pass-through in Python; bare removal and bare add in bash. Mutation-verified both ways: storing the raw value again fails 2 bash assertions in each harness and 2 in pytest. LOW: agents/bitbucket.md still said the PUT preserves "source/destination branches and reviewers" immediately before explaining that reviewers are the exception that gets replaced. Dropped the contradicting clause. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #57.
Reviewers could only be set at creation time. As the issue puts it, that is the wrong way round: PRs get opened, and then someone works out who should look at them.
Mirrored as
add_reviewers/remove_reviewersonbb_ops.pr_updateand the MCPpr_updatetool, whose docstring said reviewers were preserved and now explains that they are the exception to the field merge.Decision 1: add/remove, not replace
You flagged this as worth deciding explicitly, so here is the reasoning.
Bitbucket has no add-a-reviewer endpoint. The PR PUT replaces the whole
reviewersarray, so sending just the person being added silently unassigns everyone else — and the request still returns 200, so the bug is invisible without inspecting the body. Both flags therefore read the PR first and send the full resulting list. Adding someone already on the PR is a no-op, not a duplicate, so both are idempotent.The issue floated
--set-reviewersfor wholesale replace. This ships add/remove and deliberately omits replace. Replace is precisely the operation that silently discards other people's approvals, and add/remove composes to the same result with every change stated explicitly.bb pralready lists reviewers, so the current set is readable before changing it. If you want replace anyway, say so and it's a small follow-up — but I'd rather not ship the footgun by default.The race is real and unclosable here. A reviewer added by someone else between the GET and the PUT is lost. Bitbucket exposes no ETag or if-match on this endpoint, so it cannot be fixed in the client; the window is narrow and the operation is trivially repeatable. It's noted in both implementations rather than left for the next reader to find.
Decision 2: approvals are refused, not warned
Removing a reviewer who has already approved discards that approval, and re-adding them does not bring it back. That is unrecoverable from the CLI, so it is refused rather than warned about, unless you pass
--drop-approvals(Python:drop_approvals=True). This matches the repo-wide rule that a destructive action is never a default. Removing a reviewer who has not approved needs no opt-in.One subtlety worth a reviewer's eye: the guard reads
participants[], notreviewers[]. Approval state exists only on the former —.reviewers[]carries noapprovedfield at all — so a guard that looks for it onreviewersfinds nothing and lets every removal through. That is the silent-approval-loss bug in disguise. A test pins the distinction against a fixture whose reviewers carry no approval field, matching what the real API returns.Readback
bb prnow prints each reviewer's UUID beside the name, andpr-updateechoes the resulting list after a reviewer change. Display names are not unique in a workspace (see below), so a name alone doesn't confirm the right person was assigned.Your
bb membersnote: the suggested fix wouldn't have workedYou saw two entries with the same display name and suggested marking inactive members. I probed the live workspace before implementing that, and it would not have helped:
active(all 18 members are).account_idanduuid.So the ambiguity isn't an active/inactive distinction.
bb membersnow says so explicitly:That fires on your real workspace today. Deactivated accounts are also marked
(inactive), since they can still be assigned and are dead weight on a PR — but that's a separate, generic improvement, not the fix for what you hit. The status rides in on the same request viafields=+values.user.account_status, so it costs no extra call and no per-member lookup.Tests
26 new pytest cases (646 total), plus two bash harnesses at 36 and 42 assertions.
Every reviewer assertion reads the actual PUT body, because the silent-unassign bug returns 200 and is invisible from the status code. The approval cases assert no PUT was issued at all, because a guard that fires after the write is worthless.
Mutation-verified rather than trusted for being green:
reviewersinstead ofparticipants(inactive)marker invertedThree pre-existing MCP tests asserted the exact kwargs dict
pr_updateforwards; the call shape changed by design, so they were updated to the new full dict rather than loosened to a subset check.Also run: all five bash harnesses (11 + 29 + 37 + 42 + 36), 646 pytest,
py_compile, and a bash 3.2 parse.Incidentally corrects the README's MCP tool count, stale at 42 since
members_listlanded in v1.10.0.🤖 Generated with Claude Code