diff --git a/swarm-controller/src/forge.rs b/swarm-controller/src/forge.rs index af87462a..c4cd5dc3 100644 --- a/swarm-controller/src/forge.rs +++ b/swarm-controller/src/forge.rs @@ -20,13 +20,15 @@ use forgejo_api::structs::{ AddCollaboratorOption, AddCollaboratorOptionPermission, ChangeFileOperation, ChangeFileOperationOperation, ChangeFilesOptions, CreateBranchProtectionOption, CreateHookOption, CreateHookOptionConfig, CreateHookOptionType, CreateRepoOption, - CreateUserOption, RepoGetContentsQuery, RepoListPullRequestsQuery, - RepoListPullRequestsQueryState, + CreateUserOption, IssueListIssuesQuery, IssueListIssuesQueryState, IssueListIssuesQueryType, + RepoGetContentsQuery, RepoListPullRequestsQuery, RepoListPullRequestsQueryState, + RepoSearchQuery, StateType, }; use forgejo_api::{ApiErrorKind, Auth, Forgejo, ForgejoError}; +use futures_util::{StreamExt as _, TryStreamExt as _}; use reqwest::StatusCode; use serde::Serialize; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap, HashSet}; use utoipa::ToSchema; use crate::webhook::DeliveryKind; @@ -41,6 +43,31 @@ pub struct ConfigPrStatus { pub html_url: Option, } +/// One row of [`Client::issue_report`] — an open issue plus the two facts +/// derived from its dependency graph that swarm-ui's issue-report page +/// sorts/filters on. See that function's doc comment for how both are +/// computed. +#[derive(Clone, Debug, PartialEq, Serialize, ToSchema)] +pub struct IssueReportRow { + /// `owner/name` — present on every row (not just the cross-repo + /// aggregate) so a caller can use the same row type for either + /// endpoint without a second shape to branch on. + pub repo: String, + pub number: i64, + pub title: String, + pub labels: Vec, + pub assignee: Option, + pub html_url: Option, + /// True if any of this issue's own dependencies is still open — same + /// semantics `hive-forge issue dependency list` already uses (a closed + /// blocker doesn't count). + pub blocked: bool, + /// How many OTHER issues in this same report list this issue as a + /// dependency. The reverse of `blocked`, for ranking "fix this one to + /// unblock the most other work" — mara's own framing for the field. + pub depended_on_by_count: u32, +} + /// The `operators` team, whitelisted for the merge gate on every repo /// this client protects — provisioned by `hive-c0re::forge::repos` /// already (`ensure_operators_team`), not re-provisioned here. If that @@ -549,6 +576,177 @@ impl Client { Ok(out) } + /// 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)"). + /// Filtered on `Repository::open_issues_count` from the search result + /// itself rather than a follow-up `issue_list_issues` call per repo — + /// forgejo's own repo summary already carries that count, so this + /// stays one request regardless of how many repos exist. + /// + /// One search page (forgejo's own default page size) — this binding's + /// `RepoSearchQuery` has no `page`/`limit` field to page through, unlike + /// the `(Headers, Vec)`-shaped list endpoints elsewhere in this file + /// that `.all()` fully drains. Fine for admin-facing tooling at today's + /// repo count; revisit if an instance's repo count ever outgrows one + /// page. + pub async fn list_repos_with_open_issues(&self) -> Result> { + let results = self + .api + .repo_search(RepoSearchQuery::default()) + .await + .context("search repos")?; + Ok(results + .data + .unwrap_or_default() + .into_iter() + .filter(|r| r.open_issues_count.unwrap_or(0) > 0) + .filter_map(|r| r.full_name) + .collect()) + } + + /// Every open issue in `owner/repo`, each with its dependency-derived + /// `blocked` / `depended_on_by_count` pre-resolved server-side — the + /// whole job behind `GET /api/repos/{org}/{repo}/issue-report`. Doing + /// this resolution client-side (one dependency lookup per issue, on + /// every page load) doesn't scale, which is the reason this endpoint + /// exists at all (iris's own framing on the design thread that led here). + /// + /// Forgejo has no bulk or reverse dependency query — the same + /// limitation `hive-forge issue dependency` hits — so this is still one + /// `issue_list_issue_dependencies` call per open issue, just run with + /// bounded concurrency (`CONCURRENCY`) rather than one-at-a-time or all + /// at once, so a repo with hundreds of open issues doesn't serialize on + /// round-trips or fire them all in one burst. + /// + /// `depended_on_by_count` is a reverse index built off that SAME + /// resolve pass, not a second query: every open issue's own dependency + /// list is already in hand to compute `blocked`, so counting how often + /// each open issue's number appears across all of them is pure + /// in-memory aggregation. A dependency on a closed issue, or one + /// outside this open-issue set, can never accrue a count here — it + /// isn't a row in the report either, so "how many rows depend on this + /// row" would have nothing to point at. + pub async fn issue_report(&self, owner: &str, repo: &str) -> Result> { + const CONCURRENCY: usize = 8; + + let issues = self + .api + .issue_list_issues( + owner, + repo, + IssueListIssuesQuery { + state: Some(IssueListIssuesQueryState::Open), + r#type: Some(IssueListIssuesQueryType::Issues), + ..Default::default() + }, + ) + .all() + .await + .with_context(|| format!("list open issues in {owner}/{repo}"))?; + + // Numbers collected into an owned `Vec` first, rather than handing + // `stream::iter` a lazy `filter_map` over borrowed `issues` + // directly — the latter shape trips a "closure implementation of + // `FnOnce` is not general enough" HRTB inference error where this + // function is used as an axum handler (the `routes!` macro checks + // `Handler` for arbitrary request lifetimes), even though the + // closure itself is unremarkable. + let numbers: Vec = issues.iter().filter_map(|i| i.number).collect(); + let deps: HashMap> = + futures_util::stream::iter(numbers) + .map(|number| async move { + let blockers = self + .api + .issue_list_issue_dependencies(owner, repo, number) + .await + .with_context(|| format!("list dependencies of {owner}/{repo}#{number}"))?; + Ok::<_, anyhow::Error>((number, blockers)) + }) + .buffer_unordered(CONCURRENCY) + .try_collect() + .await?; + + let open_numbers: HashSet = issues.iter().filter_map(|i| i.number).collect(); + let mut dependent_counts: HashMap = HashMap::new(); + for blockers in deps.values() { + for blocker in blockers { + if let Some(n) = blocker.number + && open_numbers.contains(&n) + { + *dependent_counts.entry(n).or_insert(0) += 1; + } + } + } + + let repo_full_name = format!("{owner}/{repo}"); + Ok(issues + .into_iter() + .filter_map(|issue| { + let number = issue.number?; + let blocked = deps + .get(&number) + .is_some_and(|b| b.iter().any(|d| d.state == Some(StateType::Open))); + Some(IssueReportRow { + repo: repo_full_name.clone(), + number, + title: issue.title.unwrap_or_default(), + labels: issue + .labels + .unwrap_or_default() + .into_iter() + .filter_map(|l| l.name) + .collect(), + assignee: issue.assignee.and_then(|a| a.login), + html_url: issue.html_url.map(|u| u.to_string()), + blocked, + depended_on_by_count: dependent_counts.get(&number).copied().unwrap_or(0), + }) + }) + .collect()) + } + + /// [`Self::issue_report`], fanned out over every repo + /// [`Self::list_repos_with_open_issues`] finds — the default, + /// no-repo-filter view (mara: "by default, i want it to not filter by + /// repo"). Each repo's report is resolved independently and + /// concurrently (same `CONCURRENCY` bound as the per-issue dependency + /// resolution inside `issue_report`, since this is the same + /// "N independent forge round-trips" shape one level up), then + /// flattened into one row set — `IssueReportRow::repo` is what lets a + /// caller tell which repo a given row came from. + /// + /// One repo's report failing does not fail the whole scan — logged and + /// skipped, same best-effort shape [`Self::list_open_config_prs`] uses: + /// a flaky repo should not blank out every other repo's rows. + pub async fn issue_report_all(&self) -> Result> { + const CONCURRENCY: usize = 4; + + let repos = self.list_repos_with_open_issues().await?; + let rows: Vec> = futures_util::stream::iter(repos) + .map(|full_name| async move { + let Some((owner, repo)) = full_name.split_once('/') else { + tracing::warn!(%full_name, "issue_report_all: repo full_name has no '/'"); + return Vec::new(); + }; + match self.issue_report(owner, repo).await { + Ok(rows) => rows, + Err(e) => { + tracing::warn!( + error = %format!("{e:#}"), + %full_name, + "issue_report_all: one repo's report failed, skipping it" + ); + Vec::new() + } + } + }) + .buffer_unordered(CONCURRENCY) + .collect() + .await; + Ok(rows.into_iter().flatten().collect()) + } + /// Register the swarm-wide hooks against this controller, so a real /// forge event reaches [`crate::webhook`] instead of the endpoint only /// being reachable by hand. diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 3a617fbf..75d33551 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -387,6 +387,7 @@ fn socket_path() -> PathBuf { (name = "jobq", description = "the swarm-level job graph"), (name = "agents", description = "creating agent identities at swarm level"), (name = "webhook", description = "swarm-wide forge webhook receipt"), + (name = "repos", description = "forge repo browsing (swarm-ui's issue-report page)"), ) )] struct ApiDoc; @@ -474,6 +475,13 @@ struct AppState { /// `Arc` rationale as `webhook_secret`: never mutated, so a /// clone per request is just a refcount bump. swarm_name: Option>, + /// The forge client itself, for the read-only repo/issue-report + /// routes (`GET /api/repos`, `GET /api/repos/{org}/{repo}/issue-report`) + /// — distinct from `config_prs`, which holds a *cache* built off this + /// same client rather than the client. `None` under the same + /// "no forge configured on this host" shape every other + /// forge-backed field here uses. + forge: Option>, } /// Env var the controller's NixOS module sets from @@ -1072,6 +1080,107 @@ 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 @@ -1188,6 +1297,20 @@ async fn connect_status_reader() -> Result>> { } } +/// Clones `forge_client` for [`AppState::forge`] before handing the +/// original off to [`register_swarm_webhooks`], which takes it by value — +/// split out of `main` purely to keep that function under clippy's +/// line-count lint (same rationale as [`connect_status_reader`]'s own doc +/// comment), no behavior change. +fn keep_forge_for_state( + forge_client: Option>, + webhook_secret: Option>, +) -> Option> { + let state_forge = forge_client.clone(); + register_swarm_webhooks(forge_client, webhook_secret); + state_forge +} + #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() @@ -1300,8 +1423,7 @@ async fn main() -> Result<()> { }; let config_prs = forge_client.clone().map(config_pr::spawn); - - register_swarm_webhooks(forge_client, webhook_secret.clone()); + let state_forge = keep_forge_for_state(forge_client, webhook_secret.clone()); let state = AppState { hives: Arc::new(load_hives()), @@ -1312,6 +1434,7 @@ async fn main() -> Result<()> { config_prs, swarm_name: load_swarm_name().map(Arc::from), auth, + forge: state_forge, }; let (router, api) = OpenApiRouter::::with_openapi(ApiDoc::openapi()) @@ -1326,6 +1449,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!(webhook::post_webhook_forge)) .split_for_parts(); // Just the JSON, not the UI — Swagger UI itself is nginx-hosted from @@ -1437,6 +1563,7 @@ mod tests { // job and never consults one. The roster read is the verb that // needs it, and it has its own test below. auth: None, + forge: None, }; (state, sched) }