//! Swarm-controller's own forgejo client. Self-contained — deliberately //! not sharing code with `hive-c0re::forge` across the crate boundary //! (forcing that split now, over a handful of idempotent CRUD-ish //! calls, is premature plumbing; factor out a shared crate later if the //! duplication actually starts to hurt). //! //! Reads its identity from the credentials `swarm-controller.nix`'s //! `forgeEnv` wires in (`SWARM_CONTROLLER_FORGE_URL` + //! `SWARM_CONTROLLER_FORGE_TOKEN_FILE`, the latter pointing at a //! systemd `LoadCredential`-delivered file) — see that module and //! `hive-forge/default.nix`'s `forgejo-swarm-controller-account` / //! `hive-forge-swarm-controller-token` units for how the token itself //! gets minted and delivered. Absent env (forge not configured on this //! host) means [`Client::from_env`] returns `None` — every caller //! treats that as "no forge access here" and continues, the same //! graceful-absence shape the queue coordinates already use. use anyhow::{Context, Result}; use forgejo_api::structs::{ AddCollaboratorOption, AddCollaboratorOptionPermission, ChangeFileOperation, ChangeFileOperationOperation, ChangeFilesOptions, CreateBranchProtectionOption, CreateHookOption, CreateHookOptionConfig, CreateHookOptionType, CreateRepoOption, CreateUserOption, EditUserOption, 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::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap, HashSet}; use utoipa::ToSchema; use crate::webhook::DeliveryKind; pub mod agent_token; pub mod legacy_tokens; pub mod objects; pub mod site_admin; /// An agent's open config-PR, as [`Client::list_open_config_prs`] reports it /// and `GET /api/agents/{name}/config-pr` serves it. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)] pub struct ConfigPrStatus { pub pr_number: u64, /// Absent only if Forgejo itself omitted the field — every real PR has /// one; not worth failing the whole scan over. 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, /// How many distinct OTHER issues this issue unblocks, following the /// same reverse-dependency edges transitively (a depends on b depends /// on c: fixing c eventually frees both a and b, so c's count includes /// both). A superset of `depended_on_by_count`, which only counts the /// direct edge. See [`Client::issue_report`] for how it's walked. pub transitively_blocks_count: u32, } /// The `operators` team, whitelisted for the merge gate on every repo /// this client protects. Provisioned by this daemon's own periodic pass /// ([`objects`]), and checked again by [`Client::create_repo`] before it /// applies a rule naming the team. const OPERATORS_TEAM: &str = "operators"; /// The org owning per-agent config repos — where this daemon creates them, /// seeds them, and where the `pull_request` hook lives. Same value as /// `hive-c0re::forge::CONFIG_ORG` — one forge, one org. /// /// ⚠️ Duplicated across the crate boundary (this crate deliberately does not /// depend on `hive-c0re`), so nothing makes the two fail together. The /// failure mode if they drift is quiet: the hook is created on an org that /// exists, forgejo reports it healthy, and it simply never fires. /// /// ⚠️ **Not `agents`.** That org exists and is a different thing: it is the /// namespace repos an *agent asks for* land in, per /// `hive-c0re::forge::AGENTS_ORG`. Agent **config** repos have always lived /// here, which is where `hive-c0re` reconciles, merges and mirrors them — a /// config repo created in `agents` is invisible to every one of those paths, /// and nothing errors, because both orgs exist and both accept a repo. /// /// The merge gate survives the distinction: [`objects`] provisions the /// `operators` team in **both** orgs precisely so branch protection can be /// applied in either. const CONFIG_ORG: &str = "agent-configs"; /// The hive-wide knowledge repo, where the `push` hook lives. Same values as /// `hive-c0re::workers::knowledge::{ORG, REPO}`. const KNOWLEDGE_ORG: &str = "internal"; const KNOWLEDGE_REPO: &str = "knowledge"; /// This daemon's own forge account — same constant as /// `swarm-controller.nix`'s `swarmControllerForgeUser` (`hive-forge/default.nix`). /// Needed in the branch-protection push whitelist (see /// [`Client::apply_operator_branch_protection`]): being a site admin does /// not exempt a caller from a protected branch's push check on the /// content-edit API path (`repo_change_files`, what [`Client::seed_agent_config`] /// uses) the way it does for a real `git push` — confirmed live against a /// re-test where the account was already promoted to admin and the seed /// commit still failed `ErrUserCannotCommit`. Whitelisting the account by /// name is the same shape a git-push-based seed gets for free from the /// admin bypass, without adding a `git` shell-out to this daemon. const SWARM_CONTROLLER_FORGE_USER: &str = "swarm-controller"; /// The env vars `swarm-controller.nix`'s `forgeEnv` sets. Named here /// once so [`Client::from_env`] and the nix module can't drift silently /// — a rename on one side without the other fails loudly (env var /// absent) rather than silently losing forge access. const URL_ENV: &str = "SWARM_CONTROLLER_FORGE_URL"; const TOKEN_FILE_ENV: &str = "SWARM_CONTROLLER_FORGE_TOKEN_FILE"; /// Typed forgejo client, built once at startup from env. pub struct Client { api: Forgejo, /// The forge's base URL, kept so a client authenticated as someone /// else can be built against the same forge (see /// [`agent_token`]'s read-back). url: url::Url, } impl Client { /// Build from env. `Ok(None)` (not an error) when neither env var is /// set — forge isn't configured on this host, which is a normal, /// expected deployment shape (see the module doc comment), not a /// failure. `Err` only for a genuine misconfiguration: one of the /// two env vars present without the other, the token file unreadable, /// or the URL/client failing to build — those ARE failures, because /// they mean forge was *meant* to be configured here and isn't /// usable, which should be loud rather than silently downgrading to /// "no forge access." pub fn from_env() -> Result> { let url = std::env::var(URL_ENV).ok(); let token_file = std::env::var(TOKEN_FILE_ENV).ok(); let (url, token_file) = match (url, token_file) { (None, None) => return Ok(None), (Some(url), Some(token_file)) => (url, token_file), (Some(_), None) => { anyhow::bail!("{URL_ENV} is set but {TOKEN_FILE_ENV} is not — partial forge config") } (None, Some(_)) => { anyhow::bail!("{TOKEN_FILE_ENV} is set but {URL_ENV} is not — partial forge config") } }; let token = std::fs::read_to_string(&token_file) .with_context(|| format!("read forge token from {token_file}"))?; let token = token.trim(); if token.is_empty() { anyhow::bail!("forge token file {token_file} is empty"); } let parsed_url = url::Url::parse(&url).with_context(|| format!("parse {URL_ENV} ({url}) as a URL"))?; let api = Forgejo::new(Auth::Token(token), parsed_url.clone()).context("build forgejo client")?; Ok(Some(Self { api, url: parsed_url, })) } /// Creation options for an empty repo defaulting to `main`. fn repo_option(name: &str, private: bool) -> CreateRepoOption { CreateRepoOption { auto_init: Some(false), default_branch: Some("main".to_owned()), description: None, gitignores: None, issue_labels: None, license: None, name: name.to_owned(), object_format_name: None, private: Some(private), readme: None, template: None, trust_model: None, } } /// Create `name` inside [`CONFIG_ORG`]. Idempotent: an existing repo /// (409, or a 422 confirmed by a follow-up `repo_get`) is folded into /// success. A 422 that is *not* an existing repo — forgejo also uses /// it for other repo-create validation failures — fails with /// forgejo's own message rather than being folded silently (see /// [`folds_into_success`]). async fn ensure_org_repo(&self, name: &str) -> Result<()> { match self .api .create_org_repo(CONFIG_ORG, Self::repo_option(name, true)) .await { Ok(_) => { tracing::info!(%name, "swarm forge: created repo in {CONFIG_ORG}"); Ok(()) } Err(e) => { let existing = is_ambiguous_validation_failure(&e) && self.api.repo_get(CONFIG_ORG, name).await.is_ok(); if folds_into_success(&e, existing) { tracing::debug!(%name, "swarm forge: repo already exists"); Ok(()) } else { tracing::error!(%name, error = %e, "swarm forge: forge refused to create repo"); Err(e).with_context(|| format!("create repo {CONFIG_ORG}/{name}")) } } } } /// Add `user` as a collaborator on `CONFIG_ORG/repo` at `permission`. /// Idempotent: re-adding an existing collaborator just updates its /// permission (forgejo answers 204 either way; a 201 from older /// server versions is tolerated defensively). async fn add_collaborator( &self, repo: &str, user: &str, permission: AddCollaboratorOptionPermission, ) -> Result<()> { let res = self .api .repo_add_collaborator( CONFIG_ORG, repo, user, AddCollaboratorOption { permission: Some(permission), }, ) .await; match res { Ok(()) => {} Err(ForgejoError::UnexpectedStatusCode(s)) if s == StatusCode::CREATED => {} Err(e) => { return Err(e) .with_context(|| format!("add collaborator {user} to {CONFIG_ORG}/{repo}")); } } tracing::debug!(%repo, %user, ?permission, "swarm forge: collaborator set"); Ok(()) } /// Apply the operator merge-gate branch protection to `repo`'s /// default branch — only [`OPERATORS_TEAM`] members can merge, and /// an approving review from that team is required, so the agent (a /// write-level collaborator, not in the team) cannot merge its own /// PR. A create failure is ambiguous (already-exists vs. a silent /// reject that created no rule), so on any error this GETs the /// `main` rule and only treats it as success if the rule is /// actually present — a fail-open merge gate is a security bug, /// not a shrug. /// /// Also push-whitelists [`SWARM_CONTROLLER_FORGE_USER`] alone — /// [`Client::seed_agent_config`]'s initial commit is a direct push to /// this same protected `main`, and being a site admin does not /// exempt the content-edit API path from a protected branch's push /// check the way a real `git push` is exempted (confirmed live: an /// already-admin-promoted account still hit `ErrUserCannotCommit` /// here). `hive-c0re`'s equivalent seed avoids the whole question by /// seeding through an actual `git push`, which the admin bypass does /// cover — this daemon has no `git` shell-out, so an explicit /// whitelist entry is the narrower fix. The agent (a write /// collaborator, not on this whitelist) still cannot push directly, /// so this doesn't loosen the "can't merge your own PR" guarantee. /// /// Needs [`OPERATORS_TEAM`] to exist in [`CONFIG_ORG`], which /// [`Client::create_repo`] ensures first — if it still doesn't, this /// fails loudly rather than silently leaving the repo unprotected. async fn apply_operator_branch_protection(&self, repo: &str) -> Result<()> { let rule = CreateBranchProtectionOption { apply_to_admins: None, approvals_whitelist_teams: Some(vec![OPERATORS_TEAM.to_owned()]), approvals_whitelist_username: None, block_on_official_review_requests: Some(true), block_on_outdated_branch: None, block_on_rejected_reviews: None, branch_name: Some("main".to_owned()), dismiss_stale_approvals: None, enable_approvals_whitelist: Some(true), enable_merge_whitelist: Some(true), enable_push: Some(true), enable_push_whitelist: Some(true), enable_status_check: None, ignore_stale_approvals: None, merge_whitelist_teams: Some(vec![OPERATORS_TEAM.to_owned()]), merge_whitelist_usernames: None, protected_file_patterns: None, push_whitelist_deploy_keys: None, push_whitelist_teams: None, push_whitelist_usernames: Some(vec![SWARM_CONTROLLER_FORGE_USER.to_owned()]), require_signed_commits: None, required_approvals: Some(1), rule_name: None, status_check_contexts: None, unprotected_file_patterns: None, }; let Err(create_err) = self .api .repo_create_branch_protection(CONFIG_ORG, repo, rule) .await else { tracing::info!(%repo, "swarm forge: applied operator branch protection"); return Ok(()); }; match self .api .repo_get_branch_protection(CONFIG_ORG, repo, "main") .await { Ok(_) => { tracing::debug!( %repo, create_error = %create_err, "swarm forge: operator branch protection already present" ); Ok(()) } Err(check_err) => anyhow::bail!( "branch protection for {CONFIG_ORG}/{repo} not applied: create failed \ ({create_err}); GET main rule failed ({check_err}), no `main` rule present" ), } } /// Create `repo` in [`CONFIG_ORG`] and apply the operator merge gate to /// its default branch — the whole job of the `CreateRepo` node. /// Branch protection is folded in here rather than a separate node: it /// has no independent retry value apart from the repo existing (there /// is nothing useful to retry "branch protection on a repo that /// doesn't exist yet"), unlike collaborator-add and config-seed, which /// are genuinely separate operations against an already-existing repo. /// Idempotent — safe to call again. /// /// Ensures the org and its [`OPERATORS_TEAM`] first: the periodic /// [`objects`] pass does too, but an agent can be created before that /// pass has succeeded once, and the rule below names the team. pub async fn create_repo(&self, repo: &str) -> Result { self.ensure_merge_gate_prerequisites().await?; self.ensure_org_repo(repo).await?; self.apply_operator_branch_protection(repo).await?; tracing::info!(%repo, "swarm forge: created repo in {CONFIG_ORG} with operator merge gate"); Ok(format!("{CONFIG_ORG}/{repo}")) } /// Add `agent` as a **write** collaborator on `repo` (can push + open /// PRs, cannot bypass branch protection) — the whole job of the /// `AddRepoMember` node. Idempotent. pub async fn add_repo_member(&self, repo: &str, agent: &str) -> Result<()> { self.add_collaborator(repo, agent, AddCollaboratorOptionPermission::Write) .await } /// Ensure `agent` exists as a Forgejo user account that cannot create /// repos — the whole job of the `CreateForgeUser` node, and the fix for the "user does not /// exist" failure `AddRepoMember` hit before this node existed: adding /// a nonexistent user as a collaborator is a Forgejo validation error, /// not an idempotent no-op, so something has to create the account /// first. Mirrors `hive-c0re::forge::users::ensure_user_exists`'s /// intent (an agent's Forgejo identity is provisioned once, up front, /// authenticates by token thereafter, and its password is never read) /// but not its mechanism: that function shells out to the local /// `forgejo admin` CLI, which assumes co-location with the forge host. /// This daemon has no such assumption — like every other call in this /// file, it only ever talks to the forge over HTTP — so this goes through /// `admin_create_user` instead. The password is a throwaway: 32 random /// bytes, generated once, never persisted or read again (see /// [`crate::webhook::generate_hex_secret`], reused here for the same /// reason a webhook secret and this password are both "32 random bytes /// nothing reads back"). /// /// Idempotent, same as [`Self::ensure_org_repo`]: 409 folds to success /// unconditionally, 422 only after a follow-up `user_get` confirms the /// user (see [`folds_into_success`] — not every 422 means it exists). /// /// Then locks the account out of creating repos /// ([`Self::disable_repo_creation`]), whether it was just created or /// already existed. Nothing else does that for a swarm-created agent, so /// a failure fails the node. Does not align the account's email the way /// `hive-c0re`'s `ensure_user_email` does — cosmetic, not a guard. pub async fn ensure_agent_user(&self, agent: &str) -> Result<()> { self.create_agent_user(agent).await?; self.disable_repo_creation(agent).await } /// Set `max_repo_creation = 0` on `agent`'s account. Agents create repos /// through the hive, never with their own token — a write-scoped token /// can otherwise create and own a repo and self-merge in it, bypassing /// the operator-only-merge policy. Existing repos are untouched; push, /// PRs and clone keep working. /// /// The PATCH itself is unconditional rather than verify-then-patch: /// Forgejo's API never returns `max_repo_creation` (it is a field of /// `EditUserOption` only, not of `User`), so there is no current value /// to compare against, and setting 0 on an account already at 0 is a /// no-op. But `create_agent_user`'s idempotent fold only proves *some* /// account named `agent` exists — a 409 folds with no read at all, and /// a 422's follow-up `user_get` only confirms the username, not that /// swarm-controller created it — so before that PATCH this reads the /// account once and checks the one thing that *is* comparable: the /// email. A match against either [`agent_email`] (what /// `create_agent_user` itself sets) or [`legacy_agent_email`] (what /// `hive-c0re::forge::users::ensure_user_exists` used before its own /// `ensure_user_email` alignment pass runs) counts as this agent's /// account — the legacy value has to be accepted too, not just the /// aligned one, because `ensure_user_email` only runs on the forge-host /// hive and only once it has a core token and has ticked at least once, /// so a real, existing legacy agent can still carry the old email when /// this node reads it mid-rollout. A mismatch against both means the /// name collided with an account this node didn't create, so it fails /// the node rather than sending the PATCH (or silently skipping) to an /// account nobody verified is agent-controlled. async fn disable_repo_creation(&self, agent: &str) -> Result<()> { let user = self .api .user_get(agent) .await .with_context(|| format!("read forge user {agent} before locking it down"))?; let expected = agent_email(agent); let legacy = legacy_agent_email(agent); if !matches!(user.email.as_deref(), Some(e) if e == expected || e == legacy) { anyhow::bail!( "forge account `{agent}` exists but is not this agent's (email {:?}, expected \ `{expected}` or the legacy `{legacy}`); refusing to lock it down", user.email ); } self.api .admin_edit_user(agent, repo_creation_lockdown(agent)) .await .with_context(|| { format!("disable repo creation for forge user {agent} (max_repo_creation = 0)") })?; tracing::info!(%agent, "swarm forge: disabled repo creation (max_repo_creation = 0)"); Ok(()) } /// Create `agent`'s account. Idempotent: an existing user (409/422) is /// folded into success, same as [`Self::ensure_org_repo`]. async fn create_agent_user(&self, agent: &str) -> Result<()> { let password = crate::webhook::generate_hex_secret() .context("generating a throwaway password for the agent's forge account")?; let res = self .api .admin_create_user(CreateUserOption { created_at: None, email: agent_email(agent), full_name: None, login_name: None, must_change_password: Some(false), password: Some(password), restricted: None, send_notify: None, source_id: None, username: agent.to_owned(), visibility: None, }) .await; match res { Ok(_) => { tracing::info!(%agent, "swarm forge: created agent forge user"); Ok(()) } Err(e) => { let existing = is_ambiguous_validation_failure(&e) && self.api.user_get(agent).await.is_ok(); if folds_into_success(&e, existing) { tracing::debug!(%agent, "swarm forge: agent forge user already exists"); Ok(()) } else { tracing::error!(%agent, error = %e, "swarm forge: forge refused to create agent user"); Err(e).with_context(|| format!("create forge user {agent}")) } } } } /// Seed `repo` with the two files every agent config repo needs: /// `agent.nix` (the agent's own module) and `flake.nix` (the /// boilerplate that lets the meta flake import this repo as a flake /// input) — committed here in one atomic `repo_change_files` call /// instead of a local `git commit` (this process has no working tree to /// commit from — it only ever talks to the forge over HTTP). The whole /// job of the `InitAgentConfigRepo` node. /// /// **This is the agent's config, not a copy of it.** A hive told to /// deploy the agent clones this repo /// (`hive-c0re::forge::clone_config_into_proposed`) rather than writing /// the same files again locally; the byte-identical template in /// `hive-c0re::lifecycle::setup::seed_template` is only reached when no /// repo exists here to clone. /// /// Idempotent by construction rather than by catching a conflict: an /// unconditional `repo_change_files` against an already-seeded repo /// would overwrite `agent.nix` with the seed content again, clobbering /// any edits merged since — so this checks for `agent.nix`'s presence /// first and treats it as "already seeded, nothing to do" rather than /// re-committing over it. pub async fn seed_agent_config(&self, repo: &str, agent: &str) -> Result<()> { if self.file_exists(repo, "agent.nix").await? { tracing::debug!(%repo, "swarm forge: config already seeded"); return Ok(()); } let files = vec![ ChangeFileOperation { content: Some(base64_encode(&initial_agent_nix(agent))), from_path: None, operation: ChangeFileOperationOperation::Create, path: "agent.nix".to_owned(), sha: None, }, ChangeFileOperation { content: Some(base64_encode(initial_flake_nix())), from_path: None, operation: ChangeFileOperationOperation::Create, path: "flake.nix".to_owned(), sha: None, }, ]; self.api .repo_change_files( CONFIG_ORG, repo, ChangeFilesOptions { author: None, branch: None, committer: None, dates: None, files, force_overwrite_new_branch: None, message: Some("swarm-controller: seed agent config".to_owned()), new_branch: None, signoff: None, }, ) .await .with_context(|| format!("seed config files in {CONFIG_ORG}/{repo}"))?; tracing::info!(%repo, %agent, "swarm forge: seeded agent.nix + flake.nix"); Ok(()) } /// Whether `path` exists on `repo`'s default branch — the idempotency /// check [`Self::seed_agent_config`] uses instead of catching a /// create-conflict, since `repo_change_files` has no /// already-exists-is-fine status the way `create_org_repo` does. /// /// `create_repo` makes `repo` with `auto_init: false` (see /// [`Self::repo_option`]) — no commits, no `main` ref, nothing a /// contents lookup can resolve. Forgejo doesn't 404 that the way it /// does a missing path on a real ref: it returns `200` with a bare /// JSON `[]`, which fails to deserialize into the single-object /// `ContentsResponse` the crate expects for a file path ("invalid /// length 0, expected struct `ContentsResponse` with 16 elements" — /// serde reading the 0-element array as a positional struct). So a /// freshly created, not-yet-seeded repo trips this as an opaque /// deserialize error, not the `NOT_FOUND` branch below — confirmed /// against a live re-test of the swarm agent-create flow tracked on /// the forge. Short-circuit on `empty` first: an empty repo /// obviously has no `agent.nix`, and skipping /// the contents call for it sidesteps the malformed-response shape /// entirely rather than trying to special-case parsing it. async fn file_exists(&self, repo: &str, path: &str) -> Result { let is_empty = self .api .repo_get(CONFIG_ORG, repo) .await .with_context(|| format!("get repo {CONFIG_ORG}/{repo}"))? .empty .unwrap_or(false); if is_empty { return Ok(false); } match self .api .repo_get_contents(CONFIG_ORG, repo, path, RepoGetContentsQuery::default()) .await { Ok(_) => Ok(true), Err(ForgejoError::UnexpectedStatusCode(s)) if s == StatusCode::NOT_FOUND => Ok(false), Err(e) => Err(e).with_context(|| format!("check for {path} in {CONFIG_ORG}/{repo}")), } } /// Every agent in [`CONFIG_ORG`] with an open config PR, keyed by agent /// name. Mirrors `hive-c0re::forge::config_pr_poll::poll_open_config_prs`'s /// scan shape (list repos in the org, list open PRs per repo) but returns /// data instead of side-effecting an approval queue — this daemon has no /// approval system of its own; it exists so `GET /// /api/agents/{name}/config-pr` has something to answer from, /// independent of any one hive being up. /// /// A single repo's list failing does not fail the whole scan — logged and /// skipped, so one flaky repo can't blank out every other agent's status. pub async fn list_open_config_prs( &self, ) -> Result> { let repos = self .api .org_list_repos(CONFIG_ORG) .all() .await .with_context(|| format!("list repos in {CONFIG_ORG}"))?; let mut out = std::collections::HashMap::new(); for repo in repos { let Some(agent) = repo.name.as_deref() else { continue; }; let query = RepoListPullRequestsQuery { state: Some(RepoListPullRequestsQueryState::Open), sort: None, milestone: None, labels: None, poster: None, base: None, head: None, }; let prs = match self .api .repo_list_pull_requests(CONFIG_ORG, agent, query) .all() .await { Ok(prs) => prs, Err(e) => { tracing::debug!(%agent, error = %e, "swarm forge: listing config PRs failed, skipping repo"); continue; } }; // Only the first open PR matters for the panel — a config repo // is meant to carry at most one live proposal at a time (the // same assumption `hive-c0re`'s poller and the `MergeConfigPr` // approval flow both make). if let Some(pr) = prs.into_iter().next() { let Some(pr_number) = pr.number.and_then(|n| u64::try_from(n).ok()) else { continue; }; out.insert( agent.to_owned(), ConfigPrStatus { pr_number, html_url: pr.html_url.map(|u| u.to_string()), }, ); } } Ok(out) } /// Pages a search-shaped result (`data: Option>`, no total-count /// header) to exhaustion via `fetch`, which must issue one request per /// `page` (1-based) at `page_size` and return its `data`. `repo_search`'s /// `SearchResults` is this shape, not the `(Headers, Vec)` shape the /// `.all()` helper on `Request` requires, so it can't use that helper — /// this is its manual equivalent. Stops at the first page shorter than /// `page_size` (forgejo's own last-page signal); a page exactly /// `page_size` long always triggers one more fetch to confirm there /// isn't a further page. Errors instead of returning a partial result if /// `max_pages` is reached without a short page, so a misbehaving or /// unexpectedly huge instance fails loudly rather than looping forever /// or silently truncating again. async fn page_search_results( page_size: u32, max_pages: u32, mut fetch: F, ) -> Result> where F: FnMut(u32) -> Fut, Fut: std::future::Future>>>, { let mut out = Vec::new(); for page in 1..=max_pages { let rows = fetch(page).await?.unwrap_or_default(); let got = rows.len(); out.extend(rows); if got < page_size as usize { return Ok(out); } } anyhow::bail!( "search paging hit the {max_pages}-page bound (page size {page_size}) without a \ short page — the forge may hold more results than this scan can safely enumerate" ) } /// 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 per page /// regardless of how many repos exist. pub async fn list_repos_with_open_issues(&self) -> Result> { const PAGE_SIZE: u32 = 50; // 40 pages at 50/page is 2000 repos — comfortably past any repo // count this instance has today; see `page_search_results` for why // hitting it is an error rather than a truncated result. const MAX_PAGES: u32 = 40; let repos = Self::page_search_results(PAGE_SIZE, MAX_PAGES, |page| async move { Ok(self .api .repo_search(RepoSearchQuery::default()) .page(page) .page_size(PAGE_SIZE) .await .context("search repos")? .data) }) .await?; Ok(repos .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. /// /// `transitively_blocks_count` walks the same `reverse_adj` (blocker /// number → issues naming it as a dependency) from each issue — see /// [`transitive_reach`] for how the walk itself is made /// diamond/cycle-safe. `depended_on_by_count` is `reverse_adj[n].len()`; /// this is everything reachable from `n`, not just the direct edge. 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 reverse_adj: HashMap> = HashMap::new(); for (&number, blockers) in &deps { for blocker in blockers { if let Some(n) = blocker.number && open_numbers.contains(&n) { reverse_adj.entry(n).or_default().push(number); } } } 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: u32::try_from( reverse_adj.get(&number).map_or(0, Vec::len), ) .unwrap_or(u32::MAX), transitively_blocks_count: u32::try_from(transitive_reach( number, &reverse_adj, )) .unwrap_or(u32::MAX), }) }) .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. /// /// **These are registered ALONGSIDE the per-hive hooks, not instead of /// them.** Every hive keeps receiving and acting on its own deliveries /// exactly as today; the controller receives a copy and (for now) logs /// it. Taking the hive-side registration away is a later step, and it /// has to be later: fan-out swarm→hive does not exist yet, so a hook /// moved now would point at a receiver that forwards nowhere — silent on /// both sides, indistinguishable from no activity. /// /// ⛔ **No stale-hook deletion arm, unlike the two per-hive registrars /// this otherwise mirrors.** Their arm deletes hooks matching their own /// path with a foreign base; copying it here would delete the hives' /// live hooks, which are not stale — they are the path still in /// production. The controller only ever adds its own. /// /// Idempotent: an existing hook with the same `target_url` is left /// alone, so this is safe on every boot. /// /// # Errors /// /// Returns the first failure. A listing failure is not fatal — it falls /// through to the create attempt, which is idempotent server-side by way /// of the already-exists fold, the same best-effort shape `hive-c0re` /// uses. pub async fn ensure_swarm_webhooks(&self, public_base: &str, secret: &str) -> Result<()> { for kind in DeliveryKind::ALL { let target_url = kind.target_url(public_base); let (event, scope) = match kind { DeliveryKind::Knowledge => ( "push", HookScope::Repo { org: KNOWLEDGE_ORG, repo: KNOWLEDGE_REPO, }, ), DeliveryKind::ConfigPr => ("pull_request", HookScope::Org { org: CONFIG_ORG }), // Instance-wide: commit/push activity is not scoped to one // org (unlike the two above) — it needs observing across // every repo the forge hosts, per the "no metric exists for // commits or pushes" tracker issue. Forgejo's // "global (system) webhook" (`admin_create_hook`) is exactly // this — the only scope in this API that fires for a repo // in an org created after this hook was registered, with no // per-org registration to keep in sync as orgs come and go. DeliveryKind::VcsActivity => ("push", HookScope::Instance), }; self.ensure_hook(&scope, &target_url, event, secret) .await .with_context(|| format!("register swarm webhook {target_url}"))?; } Ok(()) } /// One hook, idempotently. Split out of [`Self::ensure_swarm_webhooks`] /// so the org-scoped and repo-scoped calls do not each grow their own /// copy of the list-then-create logic. async fn ensure_hook( &self, scope: &HookScope<'_>, target_url: &str, event: &str, secret: &str, ) -> Result<()> { // ⚠️ Both non-matching arms log at a level the journal keeps, because // this idempotency check fails *silently*: when the list step does // not recognise a hook that is already there, the create below runs // again, and that stays harmless only while the forge rejects the // duplicate. A forge that accepts it leaves one extra hook per // process start, each delivering on every event — and the create // arm's line reads exactly like a first, correct registration. // // The two ways to reach a create have different causes, so they are // separated: `listed=0` means the create is not landing in the set // the list reads (a bucket or permission problem — see // [`HookScope::extra_create_config`], which is where the instance // scope's own version of that went wrong), a non-zero count with no // match means the recorded url is not the one compared. match scope.list_hook_urls(&self.api).await { Ok(urls) => { if urls.iter().any(|u| u == target_url) { tracing::debug!(%target_url, "swarm forge: webhook already registered"); return Ok(()); } tracing::info!( %target_url, listed = urls.len(), "swarm forge: no listed hook matches; registering" ); } Err(e) => { // Best-effort, same as the per-hive registrars: a forge that // cannot be listed may still accept a create, and a // duplicate create is folded into success below. `warn!` // because that fold is an assumption about the forge, not a // guarantee — see the note above. tracing::warn!(error = %e, %target_url, "swarm forge: listing hooks failed; registering blind"); } } let mut additional = BTreeMap::new(); additional.insert("secret".to_owned(), secret.to_owned()); for (key, value) in scope.extra_create_config() { additional.insert((*key).to_owned(), (*value).to_owned()); } let hook = CreateHookOption { active: Some(true), authorization_header: None, branch_filter: None, config: CreateHookOptionConfig { content_type: "json".to_owned(), url: url::Url::parse(target_url) .with_context(|| format!("parse webhook target url {target_url}"))?, additional, }, events: Some(vec![event.to_owned()]), r#type: CreateHookOptionType::Forgejo, }; match scope.create_hook(&self.api, hook).await { Ok(()) => { tracing::info!(%target_url, %event, "swarm forge: webhook registered"); Ok(()) } Err(e) if is_already_exists(&e) => { tracing::debug!(%target_url, "swarm forge: webhook already present"); Ok(()) } Err(e) => Err(e.into()), } } } /// Every distinct node reachable from `start` by following `adj` edges /// (`adj[n]` = the issues that directly depend on `n`), not counting /// `start` itself. Plain DFS with a `visited` set doubling as the /// cycle guard — see [`Client::issue_report`]'s doc comment for why a /// cycle has to be tolerated rather than assumed impossible. fn transitive_reach(start: i64, adj: &HashMap>) -> usize { // `start` goes into `visited` up front, pre-empting the one case a // plain "insert on visit" walk gets wrong: a cycle that loops back to // `start` would otherwise re-insert it and count it as its own // descendant. Pre-seeding makes that re-visit a no-op instead, so the // `- 1` below only ever backs out the seed, never a real node. let mut visited: HashSet = HashSet::from([start]); let mut stack: Vec = adj.get(&start).cloned().unwrap_or_default(); while let Some(node) = stack.pop() { if visited.insert(node) && let Some(next) = adj.get(&node) { stack.extend(next); } } visited.len() - 1 } /// Where a hook lives. The knowledge hook is repo-scoped and the config-PR /// hook is org-scoped, mirroring exactly where the per-hive registrars put /// theirs — a hook on the wrong scope would never fire, and forgejo would /// report that as a perfectly healthy hook with no deliveries. enum HookScope<'a> { Repo { org: &'a str, repo: &'a str, }, Org { org: &'a str, }, /// Forgejo's "global (system) webhook" — fires for every repo in the /// instance, in every org, present or future. The `admin_*` API /// namespace; needs the same `write:admin` scope /// `Client::ensure_agent_user` already requires on this token, **and** /// the `is_system_webhook` config key from /// [`Self::extra_create_config`] — that endpoint's default is the /// *other* kind of admin hook. Instance, } impl HookScope<'_> { /// Config-map keys the create call needs beyond the shared ones. /// /// Instance scope carries `is_system_webhook`, and it is load-bearing /// twice over. `POST /admin/hooks` reads that key **out of the config /// map** and defaults it to `false`, which makes a forgejo *default* /// webhook — a template copied into repos created later, not a live /// hook — while `GET /admin/hooks` returns only webhooks with the flag /// set. Omitting it therefore breaks both halves at once: the hook is /// not the instance-wide one this scope exists for, and /// [`Self::list_hook_urls`] can never see it, so every process start /// creates another one — twelve in a day, `listed=0` each time, /// before this key was sent. fn extra_create_config(&self) -> &'static [(&'static str, &'static str)] { match self { Self::Repo { .. } | Self::Org { .. } => &[], Self::Instance => &[("is_system_webhook", "true")], } } /// The `url` config value of every hook currently on this scope. async fn list_hook_urls(&self, api: &Forgejo) -> Result, ForgejoError> { let hooks = match self { Self::Repo { org, repo } => api.repo_list_hooks(org, repo).all().await?, Self::Org { org } => api.org_list_hooks(org).send().await?, Self::Instance => api.admin_list_hooks().await?, }; Ok(hooks .iter() .filter_map(|h| h.config.as_ref()?.get("url").cloned()) .collect()) } async fn create_hook(&self, api: &Forgejo, hook: CreateHookOption) -> Result<(), ForgejoError> { match self { Self::Repo { org, repo } => api.repo_create_hook(org, repo, hook).await.map(drop), Self::Org { org } => api.org_create_hook(org, hook).await.map(drop), Self::Instance => api.admin_create_hook(hook).await.map(drop), } } } /// Base64-encode `content` for a forgejo content-API call — every file /// write on this API takes base64, never raw bytes. fn base64_encode(content: &str) -> String { use base64::Engine as _; base64::engine::general_purpose::STANDARD.encode(content) } /// Per-agent NixOS module seed — same content /// `hive-c0re::lifecycle::setup::initial_agent_nix` writes at the per-hive /// level (this process has no access to that function across the crate /// boundary, and it's three lines — not worth a shared crate for). /// /// Deliberately does **not** record which hive the agent belongs to, even /// though `POST /api/agents` now takes one: an agent's config should carry /// no reference to the hive it runs on. The hive is an address the swarm /// routes on, not a property of the agent — a /// config that named its own hive would be a second place stating where /// the agent lives, free to disagree with the queue that actually /// delivers to it. fn initial_agent_nix(name: &str) -> String { format!( "{{ config, pkgs, lib, ... }}:\n{{\n # Per-agent overrides for {name}. This is a regular NixOS module\n # — add packages, services, modules, imports as needed.\n #\n # imports = [ ./extra-module.nix ];\n # environment.systemPackages = with pkgs; [ ];\n}}\n", ) } /// Module-only flake seed, byte-identical to /// `hive-c0re::lifecycle::setup::initial_flake_nix` — see that function's /// doc comment for why the shape looks the way it does (`flakeInputs` /// forwarding, meta-flake consumption). Not shared across the crate /// boundary for the same reason [`initial_agent_nix`] isn't. fn initial_flake_nix() -> &'static str { "{\n description = \"hyperhive agent\";\n inputs = { };\n outputs =\n { self, ... }@inputs:\n {\n nixosModules.default = {\n imports = [ ./agent.nix ];\n _module.args.flakeInputs = builtins.removeAttrs inputs [ \"self\" ];\n };\n };\n}\n" } /// Canonical email for an agent's Forgejo account, set by /// [`Client::create_agent_user`] at creation and checked by /// [`Client::disable_repo_creation`] before it sends the account's PATCH. Same /// format string as `hive-c0re::forge::users::agent_email` — not called /// across the crate boundary (see this module's doc comment), but both /// creators (and `hive-c0re`'s `ensure_user_email`, which aligns older /// accounts to it) converge on this exact value, so it doubles as the /// marker that distinguishes an agent's own account from an unrelated one /// that happens to share its username. fn agent_email(agent: &str) -> String { format!("{agent}@hyperhive.local") } /// The pre-alignment email `hive-c0re::forge::users::ensure_user_exists` /// gave an agent account before that crate's own `ensure_user_email` /// existed — see that function's doc comment ("Existing agents were /// created with `{name}@hive.local`; this corrects that"). Accepted by /// [`Client::disable_repo_creation`]'s guard alongside [`agent_email`] /// because `ensure_user_email`'s alignment PATCH only runs on the /// forge-host hive, and only after it has a core token and has ticked at /// least once, so a real legacy agent can still carry this value when this /// node reads it. fn legacy_agent_email(agent: &str) -> String { format!("{agent}@hive.local") } /// The `admin_edit_user` body that sets `max_repo_creation = 0` on `agent` /// and nothing else. Same shape as `hive-c0re::forge::users`' /// `sparse_edit_user_option`: `login_name` + `source_id = 0` (local auth, /// which is what [`Client::create_agent_user`] creates) ride along because /// Forgejo resets `use_custom_avatar` on an admin edit that omits /// `login_name`; every other field is left unset so nothing else is touched. fn repo_creation_lockdown(agent: &str) -> EditUserOption { EditUserOption { active: None, admin: None, allow_create_organization: None, allow_git_hook: None, allow_import_local: None, description: None, email: None, full_name: None, hide_email: None, location: None, login_name: Some(agent.to_owned()), max_repo_creation: Some(0), must_change_password: None, password: None, prohibit_login: None, pronouns: None, restricted: None, source_id: Some(0), visibility: None, website: None, } } /// Whether a create-style call failed because the object already /// exists. Forgejo signals this as HTTP 409 (conflict) or 422 /// (validation) — match both defensively, same as /// `hive-c0re::forge::repos::is_already_exists`. fn is_already_exists(e: &ForgejoError) -> bool { match e { ForgejoError::ApiError(api) => match api.error_kind() { ApiErrorKind::ValidationFailed => true, ApiErrorKind::Other(s) => *s == StatusCode::CONFLICT, _ => false, }, ForgejoError::UnexpectedStatusCode(s) => { *s == StatusCode::CONFLICT || *s == StatusCode::UNPROCESSABLE_ENTITY } _ => false, } } /// Whether a create-call failure is forgejo's unambiguous "already /// exists" signal — HTTP 409. Unlike a 422 (see /// [`is_ambiguous_validation_failure`]), nothing else produces a 409 on /// these create calls, so folding it into success needs no follow-up /// confirmation. fn is_confirmed_conflict(e: &ForgejoError) -> bool { match e { ForgejoError::ApiError(api) => { matches!(api.error_kind(), ApiErrorKind::Other(s) if *s == StatusCode::CONFLICT) } ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::CONFLICT, _ => false, } } /// Whether a create-call failure is forgejo's 422 "validation failed" — /// ambiguous on its own. Forgejo folds several distinct causes into this /// one status; an admin user create alone answers 422 for /// `ErrUserAlreadyExist`, `ErrEmailAlreadyUsed`, `ErrNameReserved`, /// `ErrNameCharsNotAllowed`, `ErrEmailInvalid` and /// `ErrNamePatternNotAllowed` — only the first of those means the object /// exists. A 422 alone is never enough to fold into success; the caller /// must confirm with a follow-up read first (see [`folds_into_success`]). fn is_ambiguous_validation_failure(e: &ForgejoError) -> bool { match e { ForgejoError::ApiError(api) => matches!(api.error_kind(), ApiErrorKind::ValidationFailed), ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::UNPROCESSABLE_ENTITY, _ => false, } } /// Whether a create-call failure `e` should be folded into success, given /// whether a follow-up read found the object already present. /// `existing` is only consulted for the ambiguous 422 case — a 409 is /// trusted on its own, and any other error always fails. fn folds_into_success(e: &ForgejoError, existing: bool) -> bool { is_confirmed_conflict(e) || (is_ambiguous_validation_failure(e) && existing) } /// Whether a forge call failed because the named repo itself doesn't exist — /// forgejo answers a plain HTTP 404 for `owner/repo` not found, which the /// generated client surfaces as `ApiErrorKind::NotFound`. Checked /// defensively against `ApiErrorKind::Other`/`UnexpectedStatusCode` too, /// same dual-check shape [`is_already_exists`] uses for 409/422 — belt and /// braces against a response whose body didn't parse as the expected shape. /// Used by [`crate::issue_report::get_issue_report`] to tell "no such repo" /// (404, retrying won't help) apart from every other forge failure (503, /// might clear up) — see that module for why the distinction matters. pub(crate) fn is_repo_not_found(e: &ForgejoError) -> bool { match e { ForgejoError::ApiError(api) => match api.error_kind() { ApiErrorKind::NotFound { .. } => true, ApiErrorKind::Other(s) => *s == StatusCode::NOT_FOUND, _ => false, }, ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::NOT_FOUND, _ => false, } } #[cfg(test)] fn validation_failed_error() -> ForgejoError { ForgejoError::ApiError(ApiErrorKind::ValidationFailed.into()) } #[cfg(test)] fn conflict_error() -> ForgejoError { ForgejoError::ApiError(ApiErrorKind::Other(StatusCode::CONFLICT).into()) } #[cfg(test)] mod tests { use super::*; // Both scenarios live in ONE test, not two, deliberately: `cargo // test` runs tests in parallel by default, and two tests mutating // the same process-global env vars would race each other. A single // test's body is one thread, so sequential set/clear within it is // race-free without needing a cross-test lock. #[test] fn from_env_reads_both_vars_or_neither() { // SAFETY: test-only mutation of env vars this module itself // owns (`URL_ENV`/`TOKEN_FILE_ENV`), scoped to this one test // function so there's no cross-test race (see comment above). unsafe { std::env::remove_var(URL_ENV); std::env::remove_var(TOKEN_FILE_ENV); } assert!(Client::from_env().unwrap().is_none()); unsafe { std::env::set_var(URL_ENV, "https://forge.example.invalid"); } // Not `.unwrap_err()`: that needs `Client: Debug` for its panic // message, and the wrapped `forgejo_api::Forgejo` doesn't derive // it. Match directly instead of adding a `Debug` impl nothing // else needs. match Client::from_env() { Err(e) => assert!(e.to_string().contains(TOKEN_FILE_ENV)), Ok(_) => panic!("expected an error for a partial forge config"), } unsafe { std::env::remove_var(URL_ENV); } } #[tokio::test] async fn page_search_results_pages_past_a_full_first_page() { #[derive(Clone)] struct FakeRepo { full_name: String, open_issues: u32, } // Page 1 is exactly PAGE_SIZE long, which must not be mistaken for // the last page — a repo living on page 2 still has to turn up. const PAGE_SIZE: u32 = 2; let pages = vec![ vec![ FakeRepo { full_name: "org/a".to_owned(), open_issues: 0, }, FakeRepo { full_name: "org/b".to_owned(), open_issues: 0, }, ], vec![FakeRepo { full_name: "org/c".to_owned(), open_issues: 3, }], ]; let repos = Client::page_search_results(PAGE_SIZE, 10, |page| { let pages = pages.clone(); async move { Ok(pages.get(page as usize - 1).cloned()) } }) .await .unwrap(); let with_open_issues: Vec<_> = repos .into_iter() .filter(|r| r.open_issues > 0) .map(|r| r.full_name) .collect(); assert_eq!(with_open_issues, vec!["org/c".to_owned()]); } #[tokio::test] async fn page_search_results_errors_instead_of_looping_forever() { // A server that never returns a short page (misbehaving, or an // instance that genuinely outgrew `max_pages`) must fail loudly // rather than paging forever or handing back a partial result. const PAGE_SIZE: u32 = 2; let result = Client::page_search_results( PAGE_SIZE, 3, |_page| async move { Ok(Some(vec![1u32, 2])) }, ) .await; assert!(result.is_err()); } #[test] fn transitive_reach_walks_a_chain_not_just_the_direct_edge() { // 3 depends on 2 depends on 1: reverse_adj is "who depends on me", // so 1 -> [2], 2 -> [3]. Fixing 1 eventually frees both 2 and 3. let adj = HashMap::from([(1, vec![2]), (2, vec![3])]); assert_eq!(transitive_reach(1, &adj), 2); assert_eq!(transitive_reach(2, &adj), 1); assert_eq!(transitive_reach(3, &adj), 0); } #[test] fn transitive_reach_counts_a_diamond_descendant_once() { // 1 -> [2, 3], both 2 and 3 -> [4]: 4 is reachable via two paths // but must only be counted once. let adj = HashMap::from([(1, vec![2, 3]), (2, vec![4]), (3, vec![4])]); assert_eq!(transitive_reach(1, &adj), 3); } #[test] fn transitive_reach_terminates_on_a_cycle() { // 1 -> [2] -> [1]: nothing forge-side prevents a dependency cycle, // so the walk has to survive one instead of looping forever. let adj = HashMap::from([(1, vec![2]), (2, vec![1])]); assert_eq!(transitive_reach(1, &adj), 1); } // The three cases from the `forge-422-fold` issue: a create call's // 422 with the object confirmed absent must fail (case 1), the same // 422 with the object confirmed present must fold to success (case // 2), and a 409 must fold to success without needing a follow-up // read at all (case 3). #[test] fn folds_into_success_case1_422_absent_fails() { assert!(!folds_into_success(&validation_failed_error(), false)); } #[test] fn folds_into_success_case2_422_present_is_done() { assert!(folds_into_success(&validation_failed_error(), true)); } #[test] fn folds_into_success_case3_conflict_is_done() { // `existing` is never consulted for a 409 — pass `false` to prove // that, not just `true`. assert!(folds_into_success(&conflict_error(), false)); } // Invert-proof: the old `is_already_exists` folded every 422 into // success regardless of whether the object existed — exactly the // defect `folds_into_success` fixes. Demonstrate the old logic // getting case 1 wrong before trusting the new one. #[test] fn old_is_already_exists_wrongly_returns_true_for_an_absent_object() { let e = validation_failed_error(); assert!(is_already_exists(&e), "old logic: false positive on 422"); assert!( !folds_into_success(&e, false), "new logic: correctly refuses to fold a 422 with no confirmed existence" ); } /// What [`stub_forge`] saw: method, path and JSON body of each request. type Seen = std::sync::Arc>>; /// A stand-in forge on a loopback port — never the real one — answering /// the admin user create (`POST`) with `create`, the pre-lockdown /// `user_get` (`GET`) with `get`, and the admin user edit (`PATCH`) with /// `edit` (status, JSON body each), and recording every request. async fn stub_forge( create: (u16, &'static str), get: (u16, &'static str), edit: (u16, &'static str), ) -> (Client, Seen) { use axum::http::{Method, StatusCode, Uri, header::CONTENT_TYPE}; let seen = Seen::default(); let recorded = seen.clone(); let app = axum::Router::new().fallback( move |method: Method, uri: Uri, body: axum::body::Bytes| { let recorded = recorded.clone(); async move { let json = serde_json::from_slice(&body).unwrap_or_default(); recorded.lock().unwrap().push(( method.to_string(), uri.path().to_owned(), json, )); let (status, body) = match method { Method::POST => create, Method::GET => get, Method::PATCH => edit, _ => (404, r#"{"message":"not stubbed"}"#), }; let status = StatusCode::from_u16(status).unwrap(); (status, [(CONTENT_TYPE, "application/json")], body) } }, ); 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(); tokio::spawn(async move { axum::serve(listener, app).await }); let api = Forgejo::new(Auth::Token("stub-token"), url.clone()).unwrap(); (Client { api, url }, seen) } /// The `PATCH` [`Client::ensure_agent_user`] sent, if any. fn lockdown_patch(seen: &Seen, agent: &str) -> Option { let path = format!("/api/v1/admin/users/{agent}"); seen.lock() .unwrap() .iter() .find(|(method, p, _)| method == "PATCH" && *p == path) .map(|(_, _, body)| body.clone()) } /// The email [`stub_forge`]'s `user_get` stub should answer with for an /// account this agent actually owns — same value /// [`Client::create_agent_user`] itself sets. fn own_account_get(agent: &str) -> (u16, &'static str) { // Leaked rather than borrowed: `stub_forge`'s `get` param needs // `&'static str` (it moves into a `'static` axum handler), and this // helper builds the body per-agent instead of hardcoding "alice". ( 200, Box::leak(format!(r#"{{"email":"{agent}@hyperhive.local"}}"#).into_boxed_str()), ) } /// A freshly created agent account is locked out of repo creation by /// this node itself — not left for a hive-level pass to catch later — /// and the edit sets nothing beyond `max_repo_creation` plus the /// `login_name` / `source_id` pair Forgejo needs to leave the rest alone. #[tokio::test] async fn a_created_agent_user_gets_repo_creation_disabled() { let (client, seen) = stub_forge((201, "{}"), own_account_get("alice"), (200, "{}")).await; client.ensure_agent_user("alice").await.unwrap(); let body = lockdown_patch(&seen, "alice").expect("no lockdown PATCH sent"); assert_eq!(body["max_repo_creation"], 0); let set: Vec<&str> = body .as_object() .unwrap() .iter() .filter(|(_, v)| !v.is_null()) .map(|(k, _)| k.as_str()) .collect(); assert_eq!(set, ["login_name", "max_repo_creation", "source_id"]); assert_eq!(body["login_name"], "alice"); assert_eq!(body["source_id"], 0); } /// An account that already existed is locked down too: the node is /// re-run, and an account created before this lockdown existed must /// not keep repo creation just because the create step folded to /// success. 409 rather than 422 so the case doesn't hinge on how a 422 /// is disambiguated. #[tokio::test] async fn an_existing_agent_user_still_gets_repo_creation_disabled() { let (client, seen) = stub_forge( (409, r#"{"message":"user already exists"}"#), own_account_get("alice"), (200, "{}"), ) .await; client.ensure_agent_user("alice").await.unwrap(); let body = lockdown_patch(&seen, "alice").expect("no lockdown PATCH sent"); assert_eq!(body["max_repo_creation"], 0); } /// A refused lockdown fails the node with Forgejo's own message, rather /// than warning and reporting the account as provisioned — that is /// `hive-c0re`'s `ensure_repo_creation_disabled` shape, which leaves an /// agent able to create and self-merge with nothing marked failed. #[tokio::test] async fn a_refused_lockdown_fails_the_node() { let (client, _seen) = stub_forge( (201, "{}"), own_account_get("alice"), (403, r#"{"message":"token does not have required scope"}"#), ) .await; let err = client.ensure_agent_user("alice").await.unwrap_err(); let msg = format!("{err:#}"); assert!(msg.contains("max_repo_creation"), "{msg}"); assert!(msg.contains("token does not have required scope"), "{msg}"); } /// The guard this PR adds: a name collision with a pre-existing /// non-agent account (or any account whose email isn't the /// `{agent}@hyperhive.local` marker) must fail the node rather than /// PATCH `max_repo_creation`/`source_id` onto an account nobody /// verified this node created. 409 create-fold, same as /// `an_existing_agent_user_still_gets_repo_creation_disabled` — the /// fold itself is untouched by this PR; the fix lives in the read right /// before the PATCH. #[tokio::test] async fn a_name_collision_with_a_non_agent_account_fails_without_patching() { let (client, seen) = stub_forge( (409, r#"{"message":"user already exists"}"#), (200, r#"{"email":"someone@example.com"}"#), (200, "{}"), ) .await; let err = client.ensure_agent_user("alice").await.unwrap_err(); let msg = format!("{err:#}"); assert!(msg.contains("alice"), "{msg}"); assert!( msg.contains("not this agent's"), "must name the collision, got: {msg}" ); assert!( lockdown_patch(&seen, "alice").is_none(), "a collision must never reach the PATCH" ); } /// A legacy account `hive-c0re::forge::users::ensure_user_exists` (or /// its `ensure_user_email` alignment pass) created carries the same /// `{agent}@hyperhive.local` marker, plus the `login_name`/`source_id` /// fields its own `sparse_edit_user_option` sets — this node must still /// lock such an account down, not just ones it created itself. #[tokio::test] async fn a_legacy_hive_created_agent_account_still_gets_locked_down() { let (client, seen) = stub_forge( (409, r#"{"message":"user already exists"}"#), ( 200, r#"{"id":42,"login":"alice","login_name":"alice","email":"alice@hyperhive.local","source_id":0}"#, ), (200, "{}"), ) .await; client.ensure_agent_user("alice").await.unwrap(); let body = lockdown_patch(&seen, "alice").expect("no lockdown PATCH sent"); assert_eq!(body["max_repo_creation"], 0); } /// argus's round-2 🟡: `hive-c0re`'s `ensure_user_email` alignment pass /// (the thing that rewrites a legacy `{name}@hive.local` account to /// `{name}@hyperhive.local`) only runs on the forge-host hive, and only /// once it has a core token and has ticked — so a real, pre-existing /// agent can still be sitting on the pre-alignment `@hive.local` email /// when this node reads it. The guard must accept that value too, not /// just the aligned one, or a real agent would fail with "not this /// agent's" during the rollout window. #[tokio::test] async fn a_pre_alignment_legacy_email_still_gets_locked_down() { let (client, seen) = stub_forge( (409, r#"{"message":"user already exists"}"#), (200, r#"{"email":"alice@hive.local"}"#), (200, "{}"), ) .await; client.ensure_agent_user("alice").await.unwrap(); let body = lockdown_patch(&seen, "alice").expect("no lockdown PATCH sent"); assert_eq!(body["max_repo_creation"], 0); } /// Every `PATCH` body the stub saw. fn patches(seen: &Seen) -> Vec { seen.lock() .unwrap() .iter() .filter(|(method, _, _)| method == "PATCH") .map(|(_, _, body)| body.clone()) .collect() } /// Only `admin` goes out: a `source_id` would reset an SSO-made /// account's login type, and the other fields would overwrite the user's /// own settings. #[tokio::test] async fn make_site_admin_sets_admin_and_nothing_else() { let (client, seen) = stub_forge( (404, "{}"), (200, r#"{"login":"mara","is_admin":false}"#), (200, "{}"), ) .await; let plan = client.make_site_admin("mara").await.unwrap(); assert_eq!(plan, site_admin::Plan::Promote); let bodies = patches(&seen); assert_eq!(bodies.len(), 1, "{bodies:?}"); let set: Vec<(&String, &serde_json::Value)> = bodies[0] .as_object() .unwrap() .iter() .filter(|(_, v)| !v.is_null()) .collect(); assert_eq!(set, [(&"admin".to_owned(), &serde_json::Value::Bool(true))]); } #[tokio::test] async fn make_site_admin_leaves_an_admin_alone() { let (client, seen) = stub_forge( (404, "{}"), (200, r#"{"login":"mara","is_admin":true}"#), (200, "{}"), ) .await; let plan = client.make_site_admin("mara").await.unwrap(); assert_eq!(plan, site_admin::Plan::AlreadyAdmin); assert!(patches(&seen).is_empty()); } /// A user who has not logged in yet is reported, not created: nothing is /// sent but the read. #[tokio::test] async fn make_site_admin_does_not_create_a_missing_user() { let (client, seen) = stub_forge( (201, "{}"), (404, r#"{"message":"user does not exist"}"#), (200, "{}"), ) .await; let plan = client.make_site_admin("mara").await.unwrap(); assert_eq!(plan, site_admin::Plan::NoSuchUser); let methods: Vec = seen .lock() .unwrap() .iter() .map(|(m, _, _)| m.clone()) .collect(); assert_eq!(methods, ["GET"]); } }