swarm ui: a pull requests card on the reports tab
The reports tab gets a "pull requests" card under the issue report. It lists
the open PRs of every repo, or of one picked in a repo select, with the
columns a reviewer or merger filters on: author, labels, requested
reviewers, base branch, WIP (the forge's draft flag), approvals against the
base branch's protection rule, has all approvals, conflicts, protected base
and CI. Every column sorts, the small-set columns filter by picking values,
and the browser keeps the filters and the repo. No filter applies at first.
swarm-controller resolves each row so the page makes no calls of its own:
GET /api/pr-report and GET /api/repos/{org}/{repo}/pr-report. The required
approvals come from the first matching branch protection rule (an
unprotected base asks for none); an approval counts when it is the
reviewer's latest decision, on the current head, neither stale nor
dismissed and official.
A free-text table cell gets a minimum width, so a table with many columns
scrolls sideways instead of wrapping a title to a word a line.
This commit is contained in:
parent
7479d7fca7
commit
f633963b77
1 changed files with 1132 additions and 8 deletions
149
swarm-controller/src/pr_report.rs
Normal file
149
swarm-controller/src/pr_report.rs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
//! 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<forge::pr_report::PrReportRow>),
|
||||
(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<AppState>,
|
||||
) -> Result<Json<Vec<forge::pr_report::PrReportRow>>, 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<forge::pr_report::PrReportRow>),
|
||||
(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<AppState>,
|
||||
Path((org, repo)): Path<(String, String)>,
|
||||
) -> Result<Json<Vec<forge::pr_report::PrReportRow>>, 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue