request the review classifier's review when a PR's head moves
The router gets a request_reviewer node and a pull_request_sync source; the built-in head-classifier flow asks swarm-controller, over $SWARM.router.sink.request-reviewer, to request review-classifier on every push, unless its review of the head already stands. The controller subscribes to pull_request_sync on its pull-request hook and answers the router; nats-auth grants the router the publish and the controller the subscribe.
This commit is contained in:
parent
463c5cbfcc
commit
0b1669a364
1 changed files with 1231 additions and 64 deletions
194
swarm-controller/src/forge/review_request.rs
Normal file
194
swarm-controller/src/forge/review_request.rs
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
//! 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<Vec<ReviewInfo>> {
|
||||
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<Mutex<Vec<(String, String, String)>>>) {
|
||||
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:#}");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue