//! `pr assign-reviewer [--remove]` — add or remove a //! requested reviewer on a PR. Unlike `pr assign-committer` (assignees, //! mutate + PATCH the issue) Forgejo has a dedicated endpoint pair for review //! requests: `POST /repos/{repo}/pulls/{index}/requested_reviewers` //! adds, `DELETE` (same path + body) removes. //! //! Requesting a review from a user who is already *pending* (requested but //! hasn't reviewed yet) is a Forgejo-side no-op. Requesting from a user who //! has already **submitted** a review is a different story: Forgejo doesn't //! no-op that case, it dismisses the existing review (including an //! `APPROVED` one), because the reviewer is no longer in the pending-request //! list once they've reviewed. A caller re-requesting on the strength of //! "already requested = no-op" (the old, wrong claim this module made) //! silently destroys a standing approval — this is how a coverage sweep that //! re-requests reviews indiscriminately can dismiss a ready PR's approval //! with no new commit in sight. So this verb checks for an existing //! non-superseded review from `user` first and skips the request instead of //! blindly posting it. use anyhow::Result; use clap::Args as ClapArgs; use forgejo_api::structs::PullReviewRequestOptions; use crate::client::{Client, index}; #[derive(ClapArgs)] pub struct Args { /// PR number. number: u64, /// User login to request a review from (or withdraw with `--remove`). user: String, /// Withdraw the review request instead of adding it. #[arg(long)] remove: bool, } pub fn run(client: &Client, args: Args) -> Result<()> { let (owner, name) = client.owner_repo()?; let idx = index(args.number)?; let body = PullReviewRequestOptions { reviewers: Some(vec![args.user.clone()]), team_reviewers: None, }; if args.remove { client .api() .repo_delete_pull_review_requests(owner, name, idx, body) .send()?; // The forge answers success even when there was no pending request // to remove (the reviewer already submitted a review), so read the // list back instead of claiming a withdrawal that did not happen. let pull = client .api() .repo_get_pull_request(owner, name, idx) .send()?; let still_listed = pull .requested_reviewers .as_deref() .unwrap_or_default() .iter() .any(|u| u.login.as_deref() == Some(args.user.as_str())); if still_listed { anyhow::bail!( "{} is still listed as a requested reviewer on #{} after the withdraw call \ (the forge keeps a reviewer who already reviewed in that list)", args.user, args.number ); } println!( "review request withdrawn: {} on #{}", args.user, args.number ); return Ok(()); } // Guard the add path only — withdrawing a request is always safe. // Re-requesting from someone who already reviewed dismisses that review // instead of no-op'ing (see the module doc-comment above). let reviews = super::latest_reviews(client, client.repo()?, args.number)?; if let Some(existing) = hive_types::review::standing(&reviews, &args.user) { println!( "not re-requesting: {} already has a standing {} review on #{} (re-requesting would dismiss it)", args.user, existing.state, args.number ); return Ok(()); } client .api() .repo_create_pull_review_requests(owner, name, idx, body) .send()?; println!("review requested: {} on #{}", args.user, args.number); Ok(()) }