diff --git a/swarm-controller/src/forge.rs b/swarm-controller/src/forge.rs index d69e3180..af87462a 100644 --- a/swarm-controller/src/forge.rs +++ b/swarm-controller/src/forge.rs @@ -20,15 +20,13 @@ use forgejo_api::structs::{ AddCollaboratorOption, AddCollaboratorOptionPermission, ChangeFileOperation, ChangeFileOperationOperation, ChangeFilesOptions, CreateBranchProtectionOption, CreateHookOption, CreateHookOptionConfig, CreateHookOptionType, CreateRepoOption, - CreateUserOption, IssueListIssuesQuery, IssueListIssuesQueryState, IssueListIssuesQueryType, - RepoGetContentsQuery, RepoListPullRequestsQuery, RepoListPullRequestsQueryState, - RepoSearchQuery, StateType, + CreateUserOption, RepoGetContentsQuery, RepoListPullRequestsQuery, + RepoListPullRequestsQueryState, }; use forgejo_api::{ApiErrorKind, Auth, Forgejo, ForgejoError}; -use futures_util::{StreamExt as _, TryStreamExt as _}; use reqwest::StatusCode; use serde::Serialize; -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::BTreeMap; use utoipa::ToSchema; use crate::webhook::DeliveryKind; @@ -43,34 +41,6 @@ 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, - /// All assignees, not just one — forgejo's `Issue::assignee` is a - /// legacy single-value field; `assignee`**s** (plural) is the real - /// multi-assignee list, and this repo's issues do use more than one. - pub assignees: Vec, - 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`. Ranks "fix this one to - /// unblock the most other work" highest. - 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 @@ -579,182 +549,6 @@ 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, which lists - /// only repos that currently have open issues rather than every repo - /// the forge hosts. 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 rather than swarm-ui resolving it itself per row. - /// - /// 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(), - assignees: issue - .assignees - .unwrap_or_default() - .into_iter() - .filter_map(|a| a.login) - .collect(), - 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. 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/issue_report.rs b/swarm-controller/src/issue_report.rs deleted file mode 100644 index 12309b6f..00000000 --- a/swarm-controller/src/issue_report.rs +++ /dev/null @@ -1,117 +0,0 @@ -//! 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 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. Not scoped to -/// `forge::CONFIG_ORG` the way `main.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", 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. 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 7e38020a..3a617fbf 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -43,7 +43,6 @@ 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; @@ -388,7 +387,6 @@ 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; @@ -476,13 +474,6 @@ 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 @@ -1197,20 +1188,6 @@ 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() @@ -1323,7 +1300,8 @@ async fn main() -> Result<()> { }; let config_prs = forge_client.clone().map(config_pr::spawn); - let state_forge = keep_forge_for_state(forge_client, webhook_secret.clone()); + + register_swarm_webhooks(forge_client, webhook_secret.clone()); let state = AppState { hives: Arc::new(load_hives()), @@ -1334,7 +1312,6 @@ 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()) @@ -1349,9 +1326,6 @@ async fn main() -> Result<()> { .routes(routes!(get_config_prs)) .routes(routes!(create_agent)) .routes(routes!(get_agents)) - .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 @@ -1463,7 +1437,6 @@ 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) }