diff --git a/swarm-controller/src/issue_report.rs b/swarm-controller/src/issue_report.rs new file mode 100644 index 00000000..119769ef --- /dev/null +++ b/swarm-controller/src/issue_report.rs @@ -0,0 +1,120 @@ +//! 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 per +//! mara's review call on the PR that introduced it (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 super::{AppState, StatusUnavailable}; +use crate::forge; + +/// Repos with at least one open issue, as `owner/name` full names — the +/// data source for swarm-ui's repo-filter dropdown (mara: "repo filter +/// should be dropdown (list only repos with open issues)"). Not scoped to +/// [`forge::CONFIG_ORG`] the way `main.rs`'s `get_config_prs` is: mara's +/// own framing for this issue-report page was "repos cannot be fixed for +/// a hyperhive feature", so this deliberately covers the whole forge +/// instance, not one org. +#[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", body = String), + ), + tag = "repos" +)] +pub async fn get_repos( + State(state): State, +) -> Result>, StatusUnavailable> { + let Some(client) = state.forge.as_ref() else { + return Err(StatusUnavailable( + "no forge is configured on this host".to_owned(), + )); + }; + match client.list_repos_with_open_issues().await { + 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 (mara: "by default, i want it to not filter by +/// repo"). 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, or the report could not be built", body = String), + ), + tag = "repos" +)] +pub async fn get_issue_report_all( + State(state): State, +) -> Result>, StatusUnavailable> { + let Some(client) = state.forge.as_ref() else { + return Err(StatusUnavailable( + "no forge is configured on this host".to_owned(), + )); + }; + match client.issue_report_all().await { + 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. +#[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 = 503, description = "no forge is configured on this host, or the report could not be built", body = String), + ), + tag = "repos" +)] +pub async fn get_issue_report( + State(state): State, + Path((org, repo)): Path<(String, String)>, +) -> Result>, StatusUnavailable> { + let Some(client) = state.forge.as_ref() else { + return Err(StatusUnavailable( + "no forge is configured on this host".to_owned(), + )); + }; + match client.issue_report(&org, &repo).await { + Ok(rows) => Ok(Json(rows)), + Err(e) => { + let detail = format!("{e:#}"); + tracing::warn!(error = %detail, %org, %repo, "building issue report failed"); + Err(StatusUnavailable(detail)) + } + } +} diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 75d33551..7e38020a 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -43,6 +43,7 @@ use utoipa_axum::{router::OpenApiRouter, routes}; mod auth; mod config_pr; mod forge; +mod issue_report; mod otel_http_client; mod status; mod vcs_metrics; @@ -1080,107 +1081,6 @@ async fn get_config_prs( Ok(Json(cache.snapshot())) } -/// Repos with at least one open issue, as `owner/name` full names — the -/// data source for swarm-ui's repo-filter dropdown (mara: "repo filter -/// should be dropdown (list only repos with open issues)"). Not scoped to -/// [`forge::CONFIG_ORG`] the way [`get_config_prs`] is: mara's own framing -/// for this issue-report page was "repos cannot be fixed for a hyperhive -/// feature", so this deliberately covers the whole forge instance, not -/// one org. -#[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", body = String), - ), - tag = "repos" -)] -async fn get_repos(State(state): State) -> Result>, StatusUnavailable> { - let Some(client) = state.forge.as_ref() else { - return Err(StatusUnavailable( - "no forge is configured on this host".to_owned(), - )); - }; - match client.list_repos_with_open_issues().await { - 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 (mara: "by default, i want it to not filter by -/// repo"). 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, or the report could not be built", body = String), - ), - tag = "repos" -)] -async fn get_issue_report_all( - State(state): State, -) -> Result>, StatusUnavailable> { - let Some(client) = state.forge.as_ref() else { - return Err(StatusUnavailable( - "no forge is configured on this host".to_owned(), - )); - }; - match client.issue_report_all().await { - 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. -#[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 = 503, description = "no forge is configured on this host, or the report could not be built", body = String), - ), - tag = "repos" -)] -async fn get_issue_report( - State(state): State, - Path((org, repo)): Path<(String, String)>, -) -> Result>, StatusUnavailable> { - let Some(client) = state.forge.as_ref() else { - return Err(StatusUnavailable( - "no forge is configured on this host".to_owned(), - )); - }; - match client.issue_report(&org, &repo).await { - Ok(rows) => Ok(Json(rows)), - Err(e) => { - let detail = format!("{e:#}"); - tracing::warn!(error = %detail, %org, %repo, "building issue report failed"); - Err(StatusUnavailable(detail)) - } - } -} - /// The swarm's own public base URL, as the forge must address it. /// /// Set by `swarm-controller.nix` **only when this host actually serves the @@ -1449,9 +1349,9 @@ async fn main() -> Result<()> { .routes(routes!(get_config_prs)) .routes(routes!(create_agent)) .routes(routes!(get_agents)) - .routes(routes!(get_repos)) - .routes(routes!(get_issue_report_all)) - .routes(routes!(get_issue_report)) + .routes(routes!(issue_report::get_repos)) + .routes(routes!(issue_report::get_issue_report_all)) + .routes(routes!(issue_report::get_issue_report)) .routes(routes!(webhook::post_webhook_forge)) .split_for_parts(); // Just the JSON, not the UI — Swagger UI itself is nginx-hosted from