Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/forge.rs
atlas 7d217f8267 remove the create_repo agent tool
mara ruled on #4849 (c88934): "remove create_repo tool". The tool ran in
hive-c0re with the hive's core token, so it only ever worked for agents
on the hive that runs the forge.

Removed:

- the create_repo MCP tool and CreateRepoArgs (hive-agent-mcp)
- wire variants Request::CreateRepo and Response::RepoCreated
  (hive-core-agent-sock)
- hive-c0re's handle_create_repo, its valid_repo_name check and the
  dispatch arm
- forge::create_agent_repo and apply_operator_branch_protection, which
  had no other caller, plus AGENTS_ORG and OPERATORS_TEAM, whose only
  users they were
- the tool's docs (docs/tools/forge.md repo management, docs/turn-loop/
  mcp.md, the conventions tool-group table) and the doc comments that
  named it (hive-sock-client's response timeout, ensure_repo_creation_
  disabled, the security doc's merge-gate bullet)

ToolGroup::Forge is kept with no tools, the same way b88a5b24 kept
Lifecycle, so existing meta/capabilities.json grants still parse.

Forge state is untouched: existing agents/* repos keep their collaborators
and operators-team branch protection. The swarm-controller's own
create_repo (config-org repos) is a different path and is unchanged.

Closes #4849
2026-10-01 09:04:17 +02:00

1737 lines
77 KiB
Rust

//! 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<String>,
}
/// 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<String>,
/// 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<String>,
pub html_url: Option<String>,
/// 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<Option<Self>> {
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<String> {
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<bool> {
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<std::collections::HashMap<String, ConfigPrStatus>> {
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<Vec<T>>`, 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<T>)` 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<T, F, Fut>(
page_size: u32,
max_pages: u32,
mut fetch: F,
) -> Result<Vec<T>>
where
F: FnMut(u32) -> Fut,
Fut: std::future::Future<Output = Result<Option<Vec<T>>>>,
{
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<Vec<String>> {
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<Vec<IssueReportRow>> {
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<i64> = issues.iter().filter_map(|i| i.number).collect();
let deps: HashMap<i64, Vec<forgejo_api::structs::Issue>> =
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<i64> = issues.iter().filter_map(|i| i.number).collect();
let mut reverse_adj: HashMap<i64, Vec<i64>> = 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<Vec<IssueReportRow>> {
const CONCURRENCY: usize = 4;
let repos = self.list_repos_with_open_issues().await?;
let rows: Vec<Vec<IssueReportRow>> = 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<i64, Vec<i64>>) -> 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<i64> = HashSet::from([start]);
let mut stack: Vec<i64> = 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<Vec<String>, 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<std::sync::Mutex<Vec<(String, String, serde_json::Value)>>>;
/// 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<serde_json::Value> {
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<serde_json::Value> {
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<String> = seen
.lock()
.unwrap()
.iter()
.map(|(m, _, _)| m.clone())
.collect();
assert_eq!(methods, ["GET"]);
}
}