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
20 changed files with 1231 additions and 64 deletions
|
|
@ -55,6 +55,7 @@ pub mod legacy_tokens;
|
|||
pub mod objects;
|
||||
pub mod operator_profiles;
|
||||
pub mod pull_diff;
|
||||
pub mod review_request;
|
||||
pub mod runner_token;
|
||||
pub mod site_admin;
|
||||
|
||||
|
|
@ -1760,12 +1761,12 @@ fn hook_registration(kind: DeliveryKind) -> (&'static [&'static str], HookScope<
|
|||
// classifier's account access can request its review.
|
||||
DeliveryKind::ReviewRequest => (&["pull_request_review_request"], HookScope::Instance),
|
||||
// Instance-wide, for the notification router, which reads the assign
|
||||
// events. They are named: Forgejo's web form shows assignment as a
|
||||
// box of its own, so a hook edited there can drop it while keeping
|
||||
// `issues` or `pull_request`.
|
||||
// events and a push to a PR's branch. They are named: Forgejo's web
|
||||
// form shows each as a box of its own, so a hook edited there can drop
|
||||
// one while keeping `issues` or `pull_request`.
|
||||
DeliveryKind::Issues => (&["issues", "issue_assign"], HookScope::Instance),
|
||||
DeliveryKind::PullRequest => (
|
||||
&["pull_request", "pull_request_assign"],
|
||||
&["pull_request", "pull_request_assign", "pull_request_sync"],
|
||||
HookScope::Instance,
|
||||
),
|
||||
}
|
||||
|
|
@ -2111,7 +2112,7 @@ mod tests {
|
|||
assert_eq!(events(DeliveryKind::Issues), ["issues", "issue_assign"]);
|
||||
assert_eq!(
|
||||
events(DeliveryKind::PullRequest),
|
||||
["pull_request", "pull_request_assign"]
|
||||
["pull_request", "pull_request_assign", "pull_request_sync"]
|
||||
);
|
||||
// The events a stored flow's source may name are the ones the hooks
|
||||
// deliver to the router.
|
||||
|
|
|
|||
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:#}");
|
||||
}
|
||||
}
|
||||
|
|
@ -55,6 +55,7 @@ mod otel_http_client;
|
|||
mod pulls;
|
||||
mod queue_identity;
|
||||
mod read_policy;
|
||||
mod request_reviewer;
|
||||
mod review_classifier;
|
||||
mod routes;
|
||||
mod schedules;
|
||||
|
|
@ -239,9 +240,11 @@ async fn main() -> Result<()> {
|
|||
serve(listener, state).await
|
||||
}
|
||||
|
||||
/// Start taking the queued review requests, then serve `state` on `listener`.
|
||||
/// Start taking the queued review requests and answering the notification
|
||||
/// router's, then serve `state` on `listener`.
|
||||
async fn serve(listener: tokio::net::UnixListener, state: AppState) -> Result<()> {
|
||||
review_classifier::review::spawn_queue(&state);
|
||||
request_reviewer::spawn(&state);
|
||||
axum::serve(listener, build_app(state))
|
||||
.await
|
||||
.context("serving swarm-controller")
|
||||
|
|
|
|||
|
|
@ -294,7 +294,8 @@ mod tests {
|
|||
let Json(view) = get_flows(State(state.clone())).await.expect("get");
|
||||
assert_eq!(view.revision, 0);
|
||||
assert!(view.disabled_builtins.is_empty() && view.flows.is_empty());
|
||||
assert_eq!(view.builtins.len(), 3);
|
||||
assert_eq!(view.builtins.len(), 4);
|
||||
assert!(view.builtins.iter().any(|f| f.id == "head-classifier"));
|
||||
let json = serde_json::to_value(&view).expect("serializes");
|
||||
assert_eq!(json["schema"]["limits"]["max_nodes"], 64);
|
||||
assert_eq!(json["schema"]["node_kinds"][0]["kind"], "source");
|
||||
|
|
@ -321,7 +322,7 @@ mod tests {
|
|||
.expect("read")
|
||||
.expect("stored");
|
||||
let doc = Document::decode(&bytes).expect("the router reads it");
|
||||
assert_eq!(doc.active().0.len(), 3, "2 built-ins and the custom flow");
|
||||
assert_eq!(doc.active().0.len(), 4, "3 built-ins and the custom flow");
|
||||
|
||||
// A refused save changes nothing: an orphan node, and a built-in that does not exist.
|
||||
let mut orphan = custom("orphan");
|
||||
|
|
|
|||
388
swarm-controller/src/request_reviewer.rs
Normal file
388
swarm-controller/src/request_reviewer.rs
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
//! Requesting a review for the notification router, which holds no forge
|
||||
//! token.
|
||||
//!
|
||||
//! The router sends a [`RequestReviewer`] on
|
||||
//! `swarm_wire::router_sink::REQUEST_REVIEWER_SUBJECT` and [`spawn`]'s
|
||||
//! responder answers with an [`Outcome`] on the reply subject, having acted
|
||||
//! under the controller's token. A reviewer whose review of the PR's head
|
||||
//! already stands is skipped: requesting it again makes the forge dismiss it,
|
||||
//! approval included. What stands is [`hive_types::review`], the rule
|
||||
//! `hive-forge pr assign-reviewer` applies too.
|
||||
//!
|
||||
//! The request may be repeated (the router's event comes back after a failed
|
||||
//! step): a reviewer with a request pending is a no-op for the forge, and one
|
||||
//! who has reviewed since stands and is skipped.
|
||||
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt as _;
|
||||
use hive_types::review::{ReviewInfo, standing};
|
||||
use swarm_wire::router_sink::{Outcome, REQUEST_REVIEWER_SUBJECT, RequestReviewer, WORK_DEADLINE};
|
||||
|
||||
use crate::forge;
|
||||
use crate::jobq::Late;
|
||||
use crate::review_classifier::split_repo;
|
||||
use crate::routes::AppState;
|
||||
|
||||
/// How long to wait before subscribing again.
|
||||
const RETRY: Duration = Duration::from_secs(5);
|
||||
|
||||
/// The forge as the responder uses it: [`forge::Client`] in production, a fake
|
||||
/// in the tests.
|
||||
pub(crate) trait ReviewForge {
|
||||
fn reviews(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
number: i64,
|
||||
) -> impl Future<Output = anyhow::Result<Vec<ReviewInfo>>> + Send;
|
||||
|
||||
fn request(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
number: i64,
|
||||
reviewer: &str,
|
||||
) -> impl Future<Output = anyhow::Result<()>> + Send;
|
||||
}
|
||||
|
||||
impl ReviewForge for forge::Client {
|
||||
async fn reviews(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
number: i64,
|
||||
) -> anyhow::Result<Vec<ReviewInfo>> {
|
||||
self.pull_reviews(owner, repo, number).await
|
||||
}
|
||||
|
||||
async fn request(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
number: i64,
|
||||
reviewer: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
self.request_review(owner, repo, number, reviewer).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Answer the router's review requests for as long as the controller runs.
|
||||
pub(crate) fn spawn(state: &AppState) {
|
||||
let Some(client) = state.status.as_ref().map(|s| s.queue_client()) else {
|
||||
tracing::warn!("no swarm queue is configured on this host; review requests go unanswered");
|
||||
return;
|
||||
};
|
||||
let forge = Arc::clone(&state.forge);
|
||||
tokio::spawn(async move { follow(&client, &forge).await });
|
||||
}
|
||||
|
||||
/// Subscribe to the requests and answer each, and subscribe again if the
|
||||
/// subscription ends.
|
||||
async fn follow<F: ReviewForge>(client: &async_nats::Client, forge: &Late<Arc<F>>) {
|
||||
loop {
|
||||
match client.subscribe(REQUEST_REVIEWER_SUBJECT).await {
|
||||
Ok(mut requests) => {
|
||||
tracing::info!(
|
||||
subject = REQUEST_REVIEWER_SUBJECT,
|
||||
"request reviewer: answering requests"
|
||||
);
|
||||
while let Some(msg) = requests.next().await {
|
||||
let Some(reply) = msg.reply.clone() else {
|
||||
continue;
|
||||
};
|
||||
let outcome = answer(forge.get().map(Arc::as_ref), &msg.payload).await;
|
||||
match &outcome {
|
||||
Outcome::Error(why) => {
|
||||
tracing::warn!(%why, "request reviewer: not requested");
|
||||
}
|
||||
other => {
|
||||
tracing::info!(outcome = %other.encode(), "request reviewer: answered");
|
||||
}
|
||||
}
|
||||
if let Err(e) = client.publish(reply, outcome.encode().into()).await {
|
||||
tracing::warn!(error = %e, "request reviewer: answering failed");
|
||||
}
|
||||
}
|
||||
tracing::warn!("request reviewer: subscription ended");
|
||||
}
|
||||
Err(e) => tracing::warn!(error = %e, "request reviewer: subscribe failed"),
|
||||
}
|
||||
tokio::time::sleep(RETRY).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// What to answer to one request `payload`, `forge` being `None` while the
|
||||
/// controller has no forge access.
|
||||
async fn answer<F: ReviewForge>(forge: Option<&F>, payload: &[u8]) -> Outcome {
|
||||
let request: RequestReviewer = match serde_json::from_slice(payload) {
|
||||
Ok(request) => request,
|
||||
Err(e) => return Outcome::Error(format!("the request does not decode: {e}")),
|
||||
};
|
||||
let Some(forge) = forge else {
|
||||
return Outcome::Error("this controller has no forge access yet".to_owned());
|
||||
};
|
||||
match tokio::time::timeout(WORK_DEADLINE, act(forge, &request)).await {
|
||||
Ok(outcome) => outcome,
|
||||
Err(_) => Outcome::Error(format!(
|
||||
"the forge did not answer within {}s",
|
||||
WORK_DEADLINE.as_secs()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn act<F: ReviewForge>(forge: &F, request: &RequestReviewer) -> Outcome {
|
||||
let RequestReviewer {
|
||||
repo,
|
||||
number,
|
||||
reviewer,
|
||||
} = request;
|
||||
let (owner, name) = match split_repo(repo) {
|
||||
Ok(split) => split,
|
||||
Err(why) => return Outcome::Error(why),
|
||||
};
|
||||
let Ok(number_i64) = i64::try_from(*number) else {
|
||||
return Outcome::Error(format!("PR number {number} is out of range"));
|
||||
};
|
||||
if reviewer.trim().is_empty() {
|
||||
return Outcome::Error("no reviewer named".to_owned());
|
||||
}
|
||||
let reviews = match forge.reviews(owner, name, number_i64).await {
|
||||
Ok(reviews) => reviews,
|
||||
Err(e) => return Outcome::Error(format!("{e:#}")),
|
||||
};
|
||||
if let Some(review) = standing(&reviews, reviewer) {
|
||||
return Outcome::Skipped(format!(
|
||||
"{reviewer} already has a standing {} review on {repo}#{number}",
|
||||
review.state
|
||||
));
|
||||
}
|
||||
match forge.request(owner, name, number_i64, reviewer).await {
|
||||
Ok(()) => Outcome::Requested,
|
||||
Err(e) => Outcome::Error(format!("{e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fake {
|
||||
reviews: Vec<ReviewInfo>,
|
||||
reviews_fail: bool,
|
||||
request_fail: bool,
|
||||
asked: Mutex<Vec<(String, i64, String)>>,
|
||||
}
|
||||
|
||||
impl ReviewForge for Fake {
|
||||
async fn reviews(&self, _: &str, _: &str, _: i64) -> anyhow::Result<Vec<ReviewInfo>> {
|
||||
if self.reviews_fail {
|
||||
anyhow::bail!("the forge is down");
|
||||
}
|
||||
Ok(self.reviews.clone())
|
||||
}
|
||||
|
||||
async fn request(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
number: i64,
|
||||
reviewer: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
if self.request_fail {
|
||||
anyhow::bail!("422: the reviewer is the PR's author");
|
||||
}
|
||||
self.asked.lock().unwrap().push((
|
||||
format!("{owner}/{repo}"),
|
||||
number,
|
||||
reviewer.to_owned(),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn review(login: &str, verdict: &str, stale: bool, dismissed: bool) -> ReviewInfo {
|
||||
ReviewInfo {
|
||||
login: login.to_owned(),
|
||||
state: verdict.to_owned(),
|
||||
stale,
|
||||
dismissed,
|
||||
}
|
||||
}
|
||||
|
||||
fn payload(repo: &str, number: u64, reviewer: &str) -> Vec<u8> {
|
||||
serde_json::to_vec(&RequestReviewer {
|
||||
repo: repo.to_owned(),
|
||||
number,
|
||||
reviewer: reviewer.to_owned(),
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn asked(fake: &Fake) -> Vec<(String, i64, String)> {
|
||||
fake.asked.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_reviewer_with_no_review_is_requested() {
|
||||
let fake = Fake::default();
|
||||
let got = answer(Some(&fake), &payload("o/r", 7, "review-classifier")).await;
|
||||
assert_eq!(got, Outcome::Requested);
|
||||
assert_eq!(
|
||||
asked(&fake),
|
||||
[("o/r".to_owned(), 7, "review-classifier".to_owned())]
|
||||
);
|
||||
}
|
||||
|
||||
/// A review that still stands is skipped, because requesting from its
|
||||
/// reviewer would make the forge dismiss it.
|
||||
#[tokio::test]
|
||||
async fn a_standing_review_is_skipped_and_a_stale_or_dismissed_one_is_requested_again() {
|
||||
let reviewer = "review-classifier";
|
||||
let standing = Fake {
|
||||
reviews: vec![review(reviewer, "APPROVED", false, false)],
|
||||
..Fake::default()
|
||||
};
|
||||
let got = answer(Some(&standing), &payload("o/r", 7, reviewer)).await;
|
||||
assert!(
|
||||
matches!(&got, Outcome::Skipped(why) if why.contains("APPROVED") && why.contains("o/r#7")),
|
||||
"{got:?}"
|
||||
);
|
||||
assert!(asked(&standing).is_empty(), "nothing was requested");
|
||||
|
||||
for (stale, dismissed) in [(true, false), (false, true)] {
|
||||
let fake = Fake {
|
||||
reviews: vec![review(reviewer, "APPROVED", stale, dismissed)],
|
||||
..Fake::default()
|
||||
};
|
||||
let got = answer(Some(&fake), &payload("o/r", 7, reviewer)).await;
|
||||
assert_eq!(
|
||||
got,
|
||||
Outcome::Requested,
|
||||
"stale {stale}, dismissed {dismissed}"
|
||||
);
|
||||
assert_eq!(asked(&fake).len(), 1);
|
||||
}
|
||||
|
||||
// Another reviewer's standing review is no reason to skip this one.
|
||||
let other = Fake {
|
||||
reviews: vec![review("argus", "APPROVED", false, false)],
|
||||
..Fake::default()
|
||||
};
|
||||
let got = answer(Some(&other), &payload("o/r", 7, reviewer)).await;
|
||||
assert_eq!(got, Outcome::Requested);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_forge_failure_is_an_error_and_a_failed_read_requests_nothing() {
|
||||
let down = Fake {
|
||||
reviews_fail: true,
|
||||
..Fake::default()
|
||||
};
|
||||
let got = answer(Some(&down), &payload("o/r", 7, "review-classifier")).await;
|
||||
assert!(
|
||||
matches!(&got, Outcome::Error(e) if e.contains("the forge is down")),
|
||||
"{got:?}"
|
||||
);
|
||||
assert!(asked(&down).is_empty());
|
||||
|
||||
let refused = Fake {
|
||||
request_fail: true,
|
||||
..Fake::default()
|
||||
};
|
||||
let got = answer(Some(&refused), &payload("o/r", 7, "review-classifier")).await;
|
||||
assert!(
|
||||
matches!(&got, Outcome::Error(e) if e.contains("422")),
|
||||
"{got:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_request_that_is_malformed_or_has_no_forge_is_an_error() {
|
||||
let fake = Fake::default();
|
||||
for bad in [
|
||||
b"not json".to_vec(),
|
||||
payload("no-slash", 7, "review-classifier"),
|
||||
payload("o/r", 7, " "),
|
||||
payload("o/r", u64::MAX, "review-classifier"),
|
||||
] {
|
||||
let got = answer(Some(&fake), &bad).await;
|
||||
assert!(
|
||||
matches!(got, Outcome::Error(_)),
|
||||
"{:?}",
|
||||
String::from_utf8_lossy(&bad)
|
||||
);
|
||||
}
|
||||
assert!(asked(&fake).is_empty());
|
||||
let none: Option<&Fake> = None;
|
||||
let got = answer(none, &payload("o/r", 7, "review-classifier")).await;
|
||||
assert!(
|
||||
matches!(&got, Outcome::Error(e) if e.contains("no forge")),
|
||||
"{got:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The built-in flow's reviewer is the account the controller makes.
|
||||
#[test]
|
||||
fn the_built_in_flow_requests_the_classifiers_account() {
|
||||
assert_eq!(
|
||||
swarm_wire::notify_flows::CLASSIFIER_ACCOUNT,
|
||||
forge::classifier_account::LOGIN
|
||||
);
|
||||
}
|
||||
|
||||
/// Against a real server: a request sent the way the router sends it comes
|
||||
/// back with the outcome, and a subscriber before the controller is up
|
||||
/// leaves the sender with no responders.
|
||||
#[tokio::test]
|
||||
async fn a_request_over_the_queue_is_answered_with_its_outcome() {
|
||||
let nats = crate::test_nats::TestNats::start();
|
||||
let router = nats.client().await;
|
||||
let ask = |body: Vec<u8>| {
|
||||
let router = router.clone();
|
||||
async move {
|
||||
let sent = async_nats::Request::new()
|
||||
.payload(body.into())
|
||||
.timeout(Some(Duration::from_secs(5)));
|
||||
router.send_request(REQUEST_REVIEWER_SUBJECT, sent).await
|
||||
}
|
||||
};
|
||||
assert!(
|
||||
ask(payload("o/r", 7, "review-classifier")).await.is_err(),
|
||||
"no controller listening"
|
||||
);
|
||||
|
||||
let fake = Arc::new(Fake {
|
||||
reviews: vec![review("argus", "APPROVED", false, false)],
|
||||
..Fake::default()
|
||||
});
|
||||
let late: Late<Arc<Fake>> = Arc::default();
|
||||
late.set(Arc::clone(&fake)).ok().unwrap();
|
||||
let controller = nats.client().await;
|
||||
tokio::spawn(async move { follow(&controller, &late).await });
|
||||
// The subscription is processed once a request gets through.
|
||||
let mut reply = None;
|
||||
for _ in 0..50 {
|
||||
if let Ok(msg) = ask(payload("o/r", 7, "review-classifier")).await {
|
||||
reply = Some(msg);
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
let reply = reply.expect("the controller answers");
|
||||
assert_eq!(
|
||||
Outcome::decode(&String::from_utf8_lossy(&reply.payload)),
|
||||
Outcome::Requested
|
||||
);
|
||||
let skipped = ask(payload("o/r", 7, "argus")).await.unwrap();
|
||||
assert!(matches!(
|
||||
Outcome::decode(&String::from_utf8_lossy(&skipped.payload)),
|
||||
Outcome::Skipped(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,11 @@
|
|||
//! ([`LOGIN`]) runs the repo's rules against the PR and posts the result as
|
||||
//! that account's review, on the commit the run read.
|
||||
//!
|
||||
//! Only a review request starts a run; a push does not. A fresh request
|
||||
//! retires the account's earlier review: the forge clears the `official` flag
|
||||
//! on a reviewer's previous reviews when they are requested again, and only
|
||||
//! official reviews count toward approvals or block a merge.
|
||||
//! Only a review request starts a run. A push makes none itself: the
|
||||
//! notification router's `head-classifier` flow requests the review. A fresh
|
||||
//! request retires the account's earlier review: the forge clears the
|
||||
//! `official` flag on a reviewer's previous reviews when they are requested
|
||||
//! again, and only official reviews count toward approvals or block a merge.
|
||||
//!
|
||||
//! A request for a head whose newest recorded review approved or requested
|
||||
//! changes runs nothing: the account posts that verdict again, saying so. A
|
||||
|
|
|
|||
Loading…
Reference in a new issue