//! The swarm-ui issue-report page's data source — repo listing plus the //! two shapes of the report itself (all-repos default, single-repo //! filtered). See [`crate::forge::Client::issue_report`] and //! [`crate::forge::Client::issue_report_all`] for how a row's `blocked` / //! `depended_on_by_count` fields are actually computed; this module is //! just the HTTP surface over that logic, split out into its own file — //! same shape [`crate::webhook`] already uses: business logic in //! `crate::forge::Client`, the axum handlers next to their own routes. use axum::{ Json, extract::{Path, State}, }; use crate::forge; use crate::routes::{AppState, StatusUnavailable, 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"; /// Repos with at least one open issue, as `owner/name` full names — the /// data source for swarm-ui's repo-filter dropdown. Not scoped to /// `forge::CONFIG_ORG` the way `routes/config_prs.rs`'s `get_config_prs` is: this /// report deliberately covers the whole forge instance, not one org, since /// the report itself is a general-purpose browsing tool rather than /// something agent-config-specific. #[utoipa::path( get, path = "/api/repos", responses( (status = 200, description = "repos with at least one open issue, as owner/name", body = Vec), (status = 503, description = "no forge is configured on this host, or the forge did not answer within two minutes", body = String), ), tag = "repos" )] pub async fn get_repos( State(state): State, ) -> Result>, StatusUnavailable> { let Some(client) = state.forge.get() else { return Err(StatusUnavailable( "no forge is configured on this host".to_owned(), )); }; let Some(listed) = forge::within_forge_deadline("issue repos", client.list_repos_with_open_issues()).await else { return Err(StatusUnavailable(DEADLINE_DETAIL.to_owned())); }; match listed { Ok(repos) => Ok(Json(repos)), Err(e) => { let detail = format!("{e:#}"); tracing::warn!(error = %detail, "listing repos failed"); Err(StatusUnavailable(detail)) } } } /// Every open issue across every repo with at least one — the default, /// no-repo-filter view. See [`get_issue_report`] for the single-repo /// counterpart (used once the dropdown's repo filter is set) and /// [`forge::Client::issue_report_all`] for how the fan-out works. #[utoipa::path( get, path = "/api/issue-report", responses( (status = 200, description = "every open issue across every repo with one, blocked/dependent-count pre-resolved", 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", body = String), ), tag = "repos" )] pub async fn get_issue_report_all( State(state): State, ) -> Result>, StatusUnavailable> { let Some(client) = state.forge.get() else { return Err(StatusUnavailable( "no forge is configured on this host".to_owned(), )); }; let Some(built) = forge::within_forge_deadline("issue report", client.issue_report_all()).await else { return Err(StatusUnavailable(DEADLINE_DETAIL.to_owned())); }; match built { Ok(rows) => Ok(Json(rows)), Err(e) => { let detail = format!("{e:#}"); tracing::warn!(error = %detail, "building the all-repos issue report failed"); Err(StatusUnavailable(detail)) } } } /// Every open issue in `{org}/{repo}` specifically — the filtered view once /// swarm-ui's repo dropdown (populated by [`get_repos`]) has a selection. /// See [`forge::Client::issue_report`] for how `blocked` / /// `depended_on_by_count` are computed and why this has to be a /// server-side pass rather than something swarm-ui resolves itself per row. /// /// Unlike [`get_repos`] / [`get_issue_report_all`], a failure here isn't /// automatically 503: `issue_report` calls `issue_list_issues(org, repo, /// ...)` first, and a Forgejo 404 there — a typo'd or deleted repo — means /// "this will never work", not "try again later". See /// [`issue_report_error_status`] for how that's told apart from a genuine /// forge failure, which does stay 503. #[utoipa::path( get, path = "/api/repos/{org}/{repo}/issue-report", params( ("org" = String, Path, description = "repo owner (user or org)"), ("repo" = String, Path, description = "repo name"), ), responses( (status = 200, description = "every open issue in this repo, blocked/dependent-count pre-resolved", 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", body = String), ), tag = "repos" )] pub async fn get_issue_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 issue report", client.issue_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 { tracing::info!(%org, %repo, "issue report requested for a repo that doesn't exist"); Err(error_problem(status, &format!("{org}/{repo} not found"))) } else { let detail = format!("{e:#}"); tracing::warn!(error = %detail, %org, %repo, "building issue report failed"); Err(error_problem(status, &detail)) } } } } /// The status [`get_issue_report`]'s forge error should answer with. /// /// `Client::issue_report` propagates through `anyhow`, so the concrete /// [`forgejo_api::ForgejoError`] survives underneath but is not the type in /// hand — downcasting back to it, same pattern `routes`'s /// `wanted_error_status` uses for `swarm_queue_client::Error`. A 404 there /// (`forge::is_repo_not_found`) means the repo itself doesn't exist; /// anything else — including a 404 on some *other* call this function /// doesn't cover, an outage, auth failure, etc. — stays 503, same as before /// this existed. pub(crate) fn issue_report_error_status(e: &anyhow::Error) -> axum::http::StatusCode { match e.downcast_ref::() { Some(fe) if forge::is_repo_not_found(fe) => axum::http::StatusCode::NOT_FOUND, _ => axum::http::StatusCode::SERVICE_UNAVAILABLE, } } #[cfg(test)] mod tests { use crate::forge; use forgejo_api::{ApiErrorKind, ForgejoError}; // ── a repo-not-found forge error maps to 404, everything else stays // 503 ─────────────────────────────────────────────────────────── /// `issue_report_error_status` as a pure function first: build the /// exact `ForgejoError` a 404 from `issue_list_issues` decodes to (see /// `forge::is_repo_not_found`'s doc comment for the trace through /// forgejo-api's generated client), wrap it the same way `?` inside /// `Client::issue_report`'s `.with_context(...)` does, and confirm it /// downcasts back to the status the route is meant to answer. #[test] fn repo_not_found_maps_to_404() { let e: anyhow::Error = ForgejoError::ApiError(ApiErrorKind::NotFound { errors: None }.into()).into(); let e = e.context("list open issues in some-org/some-repo"); assert_eq!( super::issue_report_error_status(&e), axum::http::StatusCode::NOT_FOUND ); } /// Belt-and-braces case: a 404 that arrives as `UnexpectedStatusCode` /// rather than the parsed `ApiErrorKind::NotFound` shape (the body /// didn't decode as forgejo's not-found JSON) still maps to 404 — see /// `forge::is_repo_not_found`'s dual check. #[test] fn unparsed_404_still_maps_to_404() { let e: anyhow::Error = ForgejoError::UnexpectedStatusCode(axum::http::StatusCode::NOT_FOUND).into(); assert_eq!( super::issue_report_error_status(&e), axum::http::StatusCode::NOT_FOUND ); } /// A *different* `ForgejoError` variant — a real forge failure, not /// "repo doesn't exist" — stays 503. Proves the match looks at which /// kind of forge error this is, not just "was `ForgejoError` involved /// at all". #[test] fn a_different_forge_error_stays_503() { let e: anyhow::Error = ForgejoError::ApiError(ApiErrorKind::Forbidden.into()).into(); assert_eq!( super::issue_report_error_status(&e), axum::http::StatusCode::SERVICE_UNAVAILABLE ); } /// Invert-proof for the 404 cases above: an `anyhow::Error` that does /// not wrap a `ForgejoError` at all (the shape a genuine non-forge /// failure takes) must NOT downcast to it, and must stay 503. Without /// this, an `issue_report_error_status` that answered 404 /// unconditionally would still pass the tests above. This is also the /// status quo this change preserves — before this change, *every* /// error on this route (including the 404 case above) answered 503 /// unconditionally; this test alone can't distinguish "old /// unconditional 503" from "new, correctly-scoped 503", the 404 tests /// above do that. #[test] fn a_non_forge_error_stays_503() { let e = anyhow::anyhow!("list open issues in some-org/some-repo"); assert_eq!( super::issue_report_error_status(&e), axum::http::StatusCode::SERVICE_UNAVAILABLE ); } /// 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 _; // Bound but never accepted: the handshake completes, no response comes. 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/repos", "/api/issue-report", "/api/repos/o/r/issue-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}"); } } }