//! A PR's reviews, and asking for one more, under the controller's token. //! //! The router's `request_reviewer` sink reaches the forge through here //! ([`crate::request_reviewer`]). Which reviews still stand is //! [`hive_types::review`], the rule `hive-forge pr assign-reviewer` applies //! too. use anyhow::{Context, Result}; use forgejo_api::structs::PullReviewRequestOptions; use hive_types::review::{Listed, ReviewInfo, latest_reviews}; use super::Client; /// One review listing takes this many pages at most, 50 reviews a page. const MAX_PAGES: u32 = 10; const PAGE_SIZE: u32 = 50; impl Client { /// The latest verdict of each reviewer of PR `number` of `owner/repo`, /// judged against the PR's head as it is now. /// /// # Errors /// A forge failure, with the [`forgejo_api::ForgejoError`] underneath. pub async fn pull_reviews( &self, owner: &str, repo: &str, number: i64, ) -> Result> { let pull = self .api .call(|api| api.repo_get_pull_request(owner, repo, number).into_future()) .await .with_context(|| format!("read PR {number} of {owner}/{repo}"))?; let head = pull.head.and_then(|h| h.sha); let mut reviews = Vec::new(); for page in 1..=MAX_PAGES { let (_, batch) = self .api .call(|api| { api.repo_list_pull_reviews(owner, repo, number) .page(page) .page_size(PAGE_SIZE) .into_future() }) .await .with_context(|| format!("list the reviews of PR {number} of {owner}/{repo}"))?; let short = batch.len() < PAGE_SIZE as usize; reviews.extend(batch); if short { break; } } Ok(latest_reviews( reviews.iter().map(|r| Listed { login: r.user.as_ref().and_then(|u| u.login.as_deref()), state: r.state.as_deref(), commit_id: r.commit_id.as_deref(), stale: r.stale, dismissed: r.dismissed, }), head.as_deref(), )) } /// Ask `reviewer` to review PR `number` of `owner/repo`. From a reviewer /// whose review stands, the forge dismisses it: check /// [`hive_types::review::standing`] first. /// /// # Errors /// The forge refused: no such reviewer, the PR's author, a reviewer with /// no access to the repo. pub async fn request_review( &self, owner: &str, repo: &str, number: i64, reviewer: &str, ) -> Result<()> { let body = PullReviewRequestOptions { reviewers: Some(vec![reviewer.to_owned()]), team_reviewers: None, }; self.api .call(|api| { api.repo_create_pull_review_requests(owner, repo, number, body.clone()) .into_future() }) .await .with_context(|| { format!("request {reviewer}'s review of PR {number} of {owner}/{repo}") })?; Ok(()) } } #[cfg(test)] mod tests { use std::sync::{Arc, Mutex}; use axum::http::{Method, StatusCode, Uri}; use super::*; /// A stand-in forge holding PR 7 of `o/r` at head `new`, with `reviews` /// listed, answering the request post with `post`. Records each request as /// method, path and body. async fn forge( reviews: &'static str, post: u16, ) -> (Client, Arc>>) { let seen = Arc::new(Mutex::new(Vec::new())); let recorded = Arc::clone(&seen); let app = axum::Router::new().fallback( move |method: Method, uri: Uri, body: axum::body::Bytes| { let recorded = Arc::clone(&recorded); async move { recorded.lock().unwrap().push(( method.to_string(), uri.path().to_owned(), String::from_utf8_lossy(&body).into_owned(), )); match (method.as_str(), uri.path()) { ("GET", "/api/v1/repos/o/r/pulls/7") => ( StatusCode::OK, r#"{"number":7,"head":{"sha":"new"},"requested_reviewers":[]}"# .to_owned(), ), ("GET", "/api/v1/repos/o/r/pulls/7/reviews") => { (StatusCode::OK, reviews.to_owned()) } ("POST", "/api/v1/repos/o/r/pulls/7/requested_reviewers") => ( StatusCode::from_u16(post).unwrap(), if post == 201 { "[]".to_owned() } else { r#"{"message":"refused"}"#.to_owned() }, ), _ => (StatusCode::NOT_FOUND, r#"{"message":"no"}"#.to_owned()), } } }, ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = url::Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap(); tokio::spawn(async move { axum::serve(listener, app).await }); (Client::for_test_forge(url), seen) } const REVIEWS: &str = r#"[ {"id":1,"user":{"login":"review-classifier"},"state":"APPROVED","commit_id":"old","stale":false,"dismissed":false}, {"id":2,"user":{"login":"argus"},"state":"APPROVED","commit_id":"new","stale":false,"dismissed":false} ]"#; /// The head is read from the PR, so a review of an older commit is /// superseded though the forge still reports `stale: false`. #[tokio::test] async fn reviews_are_judged_against_the_head_read_now() { let (client, _) = forge(REVIEWS, 201).await; let reviews = client.pull_reviews("o", "r", 7).await.unwrap(); let standing = |login| hive_types::review::standing(&reviews, login).is_some(); assert!( !standing("review-classifier"), "reviewed `old`, head is `new`" ); assert!(standing("argus"), "control: reviewed the head"); } #[tokio::test] async fn a_request_posts_the_reviewer_to_the_pull_requests_reviewer_list() { let (client, seen) = forge("[]", 201).await; client .request_review("o", "r", 7, "review-classifier") .await .unwrap(); let seen = seen.lock().unwrap(); let (method, path, body) = seen.last().unwrap(); assert_eq!(method, "POST"); assert_eq!(path, "/api/v1/repos/o/r/pulls/7/requested_reviewers"); let body: serde_json::Value = serde_json::from_str(body).unwrap(); assert_eq!(body["reviewers"], serde_json::json!(["review-classifier"])); } #[tokio::test] async fn a_refused_request_is_an_error_naming_the_reviewer() { let (client, _) = forge("[]", 422).await; let err = client .request_review("o", "r", 7, "review-classifier") .await .unwrap_err(); assert!(format!("{err:#}").contains("review-classifier"), "{err:#}"); } }