//! The swarm UI's PR report: every open pull request of the forge, or of one //! repo, with the attributes a reviewer or merger filters on already //! resolved. The logic is [`forge::Client::pr_report`]; this module is the //! HTTP surface over it, the same shape as [`crate::issue_report`]. The repo //! dropdown's source is `GET /api/pulls/repos`. use axum::{ Json, extract::{Path, State}, }; use crate::forge; use crate::issue_report::issue_report_error_status; use crate::routes::{AppState, error_problem}; /// The 503 detail when the forge did not answer within /// [`forge::FORGE_DEADLINE`]. const DEADLINE_DETAIL: &str = "the forge did not answer within the deadline"; /// Every open pull request across every repo that has one. #[utoipa::path( get, path = "/api/pr-report", responses( (status = 200, description = "every open pull request, newest number first within a repo", body = Vec), (status = 503, description = "no forge is configured on this host, the report could not be built, or the forge did not answer within two minutes (problem+json)", body = String), ), tag = "pulls" )] pub async fn get_pr_report_all( State(state): State, ) -> Result>, problem_details::ProblemDetails> { let Some(client) = state.forge.get() else { return Err(error_problem( axum::http::StatusCode::SERVICE_UNAVAILABLE, "no forge is configured on this host", )); }; let Some(built) = forge::within_forge_deadline("PR report", client.pr_report_all()).await else { return Err(error_problem( axum::http::StatusCode::SERVICE_UNAVAILABLE, DEADLINE_DETAIL, )); }; match built { Ok(rows) => Ok(Json(rows)), Err(e) => { let detail = format!("{e:#}"); tracing::warn!(error = %detail, "building the all-repos PR report failed"); Err(error_problem( axum::http::StatusCode::SERVICE_UNAVAILABLE, &detail, )) } } } /// The open pull requests of `{org}/{repo}`. A 404 from the forge for the /// repo itself is a 404 here; anything else stays 503, as for the issue /// report ([`issue_report_error_status`]). #[utoipa::path( get, path = "/api/repos/{org}/{repo}/pr-report", params( ("org" = String, Path, description = "repo owner (user or org)"), ("repo" = String, Path, description = "repo name"), ), responses( (status = 200, description = "every open pull request in this repo", body = Vec), (status = 404, description = "no such repo (problem+json)", body = String), (status = 503, description = "no forge is configured on this host, the report could not be built, or the forge did not answer within two minutes (problem+json)", body = String), ), tag = "pulls" )] pub async fn get_pr_report( State(state): State, Path((org, repo)): Path<(String, String)>, ) -> Result>, problem_details::ProblemDetails> { let Some(client) = state.forge.get() else { return Err(error_problem( axum::http::StatusCode::SERVICE_UNAVAILABLE, "no forge is configured on this host", )); }; let Some(built) = forge::within_forge_deadline("repo PR report", client.pr_report(&org, &repo)).await else { return Err(error_problem( axum::http::StatusCode::SERVICE_UNAVAILABLE, DEADLINE_DETAIL, )); }; match built { Ok(rows) => Ok(Json(rows)), Err(e) => { let status = issue_report_error_status(&e); if status == axum::http::StatusCode::NOT_FOUND { Err(error_problem(status, &format!("{org}/{repo} not found"))) } else { let detail = format!("{e:#}"); tracing::warn!(error = %detail, %org, %repo, "building PR report failed"); Err(error_problem(status, &detail)) } } } } #[cfg(test)] mod tests { use super::*; /// Each route against a forge that accepts the connection and never /// answers: 503 at the deadline. The paused clock jumps to the deadline /// once nothing else can run, so this takes no wall time. #[tokio::test(start_paused = true)] async fn every_route_is_503_when_the_forge_never_answers() { use axum::body::Body; use axum::http::{Request, StatusCode}; use tower::ServiceExt as _; 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(); for uri in ["/api/pr-report", "/api/repos/o/r/pr-report"] { let (state, _) = crate::routes::tests::state_with_roster(); assert!( state .forge .set(std::sync::Arc::new(forge::Client::for_test_forge( url.clone() ))) .is_ok() ); let start = tokio::time::Instant::now(); let req = Request::get(uri).body(Body::empty()).expect("a request"); let res = crate::startup::build_app(state) .oneshot(req) .await .expect("infallible"); assert_eq!(res.status(), StatusCode::SERVICE_UNAVAILABLE, "{uri}"); let bytes = axum::body::to_bytes(res.into_body(), usize::MAX) .await .unwrap(); let body = String::from_utf8_lossy(&bytes); assert!(body.contains(super::DEADLINE_DETAIL), "{uri}: {body}"); assert_eq!(start.elapsed(), forge::FORGE_DEADLINE, "{uri}"); } } }