//! The data the review try-out's dropdowns list: the repos that have an open //! pull request, and a repo's open pull requests. Read-only views of the //! forge, over the controller's own forge token. The try-out page still takes //! a repo or a PR typed by hand, since the run also works on a PR that is //! merged or closed. use axum::Json; use axum::extract::rejection::QueryRejection; use axum::extract::{Query, State}; use axum::http::StatusCode; use problem_details::ProblemDetails; use serde::Deserialize; use utoipa::IntoParams; use crate::forge::{self, OpenPull}; use crate::issue_report::issue_report_error_status; use crate::review_classifier::split_repo; use crate::{AppState, error_problem}; /// Whose open pull requests to list. #[derive(Debug, Deserialize, IntoParams)] pub(crate) struct PullsQuery { /// `owner/name`. repo: String, } /// Repos with at least one open pull request, as `owner/name`. #[utoipa::path( get, path = "/api/pulls/repos", responses( (status = 200, description = "repos with at least one open pull request, as owner/name", body = Vec), (status = 503, description = "this host has no forge token yet; or the forge failed or \ did not answer within two minutes (problem+json)", body = String), ), tag = "pulls" )] pub(crate) async fn get_pull_repos( State(state): State, ) -> Result>, ProblemDetails> { let Some(client) = state.forge.get() else { return Err(no_forge()); }; let repos = within_deadline("pull request repos", client.list_repos_with_open_pulls()).await?; Ok(Json(repos.map_err(|e| unavailable(&e))?)) } /// A repo's open pull requests, newest first. #[utoipa::path( get, path = "/api/pulls", params(PullsQuery), responses( (status = 200, description = "the repo's open pull requests, highest number first", body = Vec), (status = 400, description = "no `repo`, or a repo that is not owner/name with segments \ of `[A-Za-z0-9._-]` other than `.` and `..` (problem+json)", body = String), (status = 404, description = "the forge knows no such repo (problem+json)", body = String), (status = 503, description = "this host has no forge token yet; or the forge failed or \ did not answer within two minutes (problem+json)", body = String), ), tag = "pulls" )] pub(crate) async fn get_pulls( State(state): State, query: Result, QueryRejection>, ) -> Result>, ProblemDetails> { let Query(query) = query.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.body_text()))?; let (owner, name) = split_repo(&query.repo).map_err(|d| error_problem(StatusCode::BAD_REQUEST, &d))?; let Some(client) = state.forge.get() else { return Err(no_forge()); }; let pulls = within_deadline("open pull requests", client.list_open_pulls(owner, name)) .await? .map_err(|e| { if issue_report_error_status(&e) == StatusCode::NOT_FOUND { error_problem(StatusCode::NOT_FOUND, &format!("{owner}/{name} not found")) } else { unavailable(&e) } })?; Ok(Json(pulls)) } fn no_forge() -> ProblemDetails { error_problem( StatusCode::SERVICE_UNAVAILABLE, "no forge access on this host yet", ) } /// `fut`'s answer, or a 503 if the forge did not answer by the deadline. async fn within_deadline( what: &str, fut: impl std::future::Future, ) -> Result { forge::within_forge_deadline(what, fut) .await .ok_or_else(|| { error_problem( StatusCode::SERVICE_UNAVAILABLE, "the forge did not answer within the deadline", ) }) } fn unavailable(e: &anyhow::Error) -> ProblemDetails { let detail = format!("{e:#}"); tracing::warn!(error = %detail, "listing pull requests failed"); error_problem(StatusCode::SERVICE_UNAVAILABLE, &detail) } #[cfg(test)] mod tests { use axum::body::Body; use axum::http::{Request, StatusCode}; use tower::ServiceExt as _; async fn get(uri: &str) -> (StatusCode, serde_json::Value) { let (state, _) = crate::tests::state_with_roster(); let req = Request::get(uri).body(Body::empty()).expect("a request"); let res = crate::build_app(state) .oneshot(req) .await .expect("infallible"); let status = res.status(); let bytes = axum::body::to_bytes(res.into_body(), usize::MAX) .await .expect("a body"); (status, serde_json::from_slice(&bytes).unwrap_or_default()) } #[tokio::test] async fn a_bad_or_missing_repo_is_400_before_the_forge_is_asked() { for (query, says) in [ ("", "repo"), ("?repo=o", "owner/name"), ("?repo=o/..", "owner/name"), ("?repo=a/b/c", "owner/name"), ] { let (status, problem) = get(&format!("/api/pulls{query}")).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{query}: {problem}"); let detail = problem["detail"].as_str().expect("a problem+json detail"); assert!(detail.contains(says), "{query}: {detail}"); } } #[tokio::test] async fn both_lists_without_forge_access_on_this_host_are_503() { for uri in ["/api/pulls?repo=o/r", "/api/pulls/repos"] { let (status, problem) = get(uri).await; assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{uri}: {problem}"); assert_eq!(problem["detail"], "no forge access on this host yet"); } } }