Two read-only routes feed them: GET /api/pulls/repos lists the repos with an open pull request, and GET /api/pulls?repo=owner/name lists a repo's open pull requests, highest number first. The page offers the lists through the browser's own suggestion list and keeps both fields free text, since a run also works on a merged or closed PR.
156 lines
5.6 KiB
Rust
156 lines
5.6 KiB
Rust
//! 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<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_pull_repos(
|
|
State(state): State<AppState>,
|
|
) -> Result<Json<Vec<String>>, 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<OpenPull>),
|
|
(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<AppState>,
|
|
query: Result<Query<PullsQuery>, QueryRejection>,
|
|
) -> Result<Json<Vec<OpenPull>>, 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<T>(
|
|
what: &str,
|
|
fut: impl std::future::Future<Output = T>,
|
|
) -> Result<T, ProblemDetails> {
|
|
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");
|
|
}
|
|
}
|
|
}
|