//! Repo + org plumbing on the local Forgejo: org / repo creation, //! the meta + shared-docs + knowledge repos, per-agent config-repo //! mirroring (`push_config` / `push_meta`), collaborator grants, //! pull-mirrors, and branch-protection rules. The typed API-client //! constructor + org-name constants live in the module root (`super`). use std::path::Path; use std::sync::Mutex; use anyhow::{Context, Result}; use forgejo_api::structs::{ AddCollaboratorOption, AddCollaboratorOptionPermission, CreateBranchProtectionOption, CreateOrgOption, CreateRepoOption, CreateTeamOption, CreateTeamOptionPermission, EditBranchProtectionOption, EditRepoOption, EditTeamOption, EditTeamOptionPermission, MigrateRepoOptions, MigrateRepoOptionsService, Repository, }; use forgejo_api::{ApiErrorKind, ForgejoError}; use reqwest::StatusCode; use crate::coordinator::Coordinator; use super::{ AGENTS_ORG, CONFIG_ORG, KNOWLEDGE_REPO, OPERATORS_TEAM, SHARED_DOCS_REPO, SHARED_ORG, api, core_auth_header, core_token, forge_git_url, forge_http_base, is_present, }; /// 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, } } /// `EditRepoOption` with every field unset — repo edits only ever /// change the one field the caller sets on top (Forgejo leaves `None` /// fields untouched). fn sparse_edit_repo_option() -> EditRepoOption { EditRepoOption { allow_fast_forward_only_merge: None, allow_manual_merge: None, allow_merge_commits: None, allow_rebase: None, allow_rebase_explicit: None, allow_rebase_update: None, allow_squash_merge: None, archived: None, autodetect_manual_merge: None, default_allow_maintainer_edit: None, default_branch: None, default_delete_branch_after_merge: None, default_merge_style: None, default_update_style: None, description: None, enable_prune: None, external_tracker: None, external_wiki: None, globally_editable_wiki: None, has_actions: None, has_issues: None, has_packages: None, has_projects: None, has_pull_requests: None, has_releases: None, has_wiki: None, ignore_whitespace_conflicts: None, internal_tracker: None, mirror_interval: None, name: None, private: None, template: None, website: None, wiki_branch: None, } } /// Whether a create-style call failed because the object already /// exists. Forgejo signals this as HTTP 409 (conflict) or 422 /// (validation). The typed client surfaces those as /// `ApiErrorKind::Other(409)` / `ValidationFailed` when the endpoint /// spec lists the status, or as a bare `UnexpectedStatusCode` /// otherwise — match all shapes defensively. 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 an error is specifically an HTTP 409 conflict (and NOT a /// 422): the migrate endpoint's 422 is a validation error (bad /// `clone_addr` / service) and must surface, so it can't share /// [`is_already_exists`]'s 422 tolerance. fn is_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 rendered error message names the "already exists" case. The /// discriminator that tells a *benign* already-exists 422 apart from a /// *real* validation 422 (invalid units, etc.). Case-insensitive. fn message_says_already_exists(rendered: &str) -> bool { rendered.to_lowercase().contains("already exists") } /// Whether `e` is Forgejo saying the resource already exists — matching /// the 409 conflict shape *and* the 422 shape this Forgejo build actually /// returns for a duplicate team: `validation failed: team already exists`. /// A 409-only [`is_conflict`] check misses that 422, so the caller fires /// a spurious "provisioning failed" warning every boot and skips its /// settings reconcile. This still surfaces *other* 422s (bad request /// body) as real failures — only a 422 whose message names the /// already-exists case is folded in. fn is_already_exists_lenient(e: &ForgejoError) -> bool { if is_conflict(e) { return true; } let is_unprocessable = match e { ForgejoError::ApiError(api) => matches!(api.error_kind(), ApiErrorKind::ValidationFailed), ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::UNPROCESSABLE_ENTITY, _ => false, }; is_unprocessable && message_says_already_exists(&e.to_string()) } /// Whether an error is Forgejo saying 404 — the resource is absent, /// as opposed to a transport / auth / server failure. fn is_not_found(e: &ForgejoError) -> bool { match e { ForgejoError::ApiError(api) => { matches!(api.error_kind(), ApiErrorKind::NotFound { .. }) } ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::NOT_FOUND, _ => false, } } /// Fold a repo-creation result's "already exists" (409 / 422) into /// success. `label` is `/` — purely for log + error /// context. fn created_or_exists(res: Result, label: &str) -> Result<()> { match res { Ok(_) => { tracing::info!(%label, "forge: created repo"); Ok(()) } Err(e) if is_already_exists(&e) => { tracing::debug!(%label, "forge: repo already exists"); Ok(()) } Err(e) => Err(e).with_context(|| format!("create repo {label}")), } } /// Set an existing repo to public visibility. No-op if the repo is /// already public. Used for `internal/knowledge` which may have been /// created as private on an older deployment. async fn set_repo_public(owner: &str, repo: &str, token: &str) -> Result<()> { let mut edit = sparse_edit_repo_option(); edit.private = Some(false); api(token)? .repo_edit(owner, repo, edit) .await .with_context(|| format!("edit {owner}/{repo} (set public)"))?; tracing::debug!(%owner, %repo, "forge: repo set to public"); Ok(()) } /// Create `name` inside org `org` as a public repo. Idempotent. async fn ensure_org_repo_public(org: &str, name: &str, token: &str) -> Result<()> { let res = api(token)? .create_org_repo(org, repo_option(name, false)) .await; created_or_exists(res, &format!("{org}/{name}")) } /// Create a repo in the token-owner's own namespace. `token` belongs /// to the user we want the repo owned by (we use `core`'s token for /// `core/meta`). Idempotent. pub async fn ensure_repo(name: &str, token: &str) -> Result<()> { let res = api(token)? .create_current_user_repo(repo_option(name, true)) .await; created_or_exists(res, &format!("core/{name}")) } /// Create `name` inside org `org` (used for `agent-configs/`). /// Idempotent. async fn ensure_org_repo(org: &str, name: &str, token: &str) -> Result<()> { let res = api(token)? .create_org_repo(org, repo_option(name, true)) .await; created_or_exists(res, &format!("{org}/{name}")) } /// Dashboard-warning guard for the meta non-fast-forward condition. /// Held while `core/meta` remote is ahead of our local mirror; cleared /// automatically when the next push succeeds. static META_NON_FF_GUARD: Mutex> = Mutex::new(None); /// Push `dir` (the meta repo) to `core/meta` on the local forge. /// Best-effort: returns Err which callers log + ignore. No-op when /// the core token isn't present yet (forge container not provisioned). pub async fn push_meta(dir: &Path) -> Result<()> { let Some(token) = core_token() else { return Ok(()); }; // Token-in-URL push. Forgejo accepts `oauth2:` or just // any-username:; using `core` matches the owner so the // remote name is self-describing. // // No --force: `meta.rs` uses regular `git commit` (append-only), // so the push is always fast-forward in normal operation. Per // operator directive, hive-c0re must not force-push anywhere. If // the remote is somehow ahead (e.g. split-brain or manual push) we // raise a dashboard warning and leave the remote intact rather than // silently erasing its history — consistent with `push_config`'s // non-fast-forward handling. let url = forge_git_url("core/meta"); let out = crate::lifecycle::git_command_authed(&core_auth_header(&token)) .current_dir(dir) .args(["push", &url, "HEAD:main"]) .output() .await .context("invoke git push core/meta")?; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr); if stderr.contains("non-fast-forward") || stderr.contains("fetch first") { let msg = "forge/core/meta push rejected (non-fast-forward): \ remote is ahead of local; leaving remote history intact"; tracing::warn!("{msg}"); // Raise a persistent dashboard banner; cleared on next successful push. if let Ok(mut g) = META_NON_FF_GUARD.lock() { *g = Some(crate::warnings::set_warning( "forge-meta-non-ff", "warn", msg, )); } return Ok(()); } anyhow::bail!( "git push core/meta failed ({}): {}", out.status, stderr.trim() ); } // Successful push: clear any outstanding non-ff warning. if let Ok(mut g) = META_NON_FF_GUARD.lock() { *g = None; } tracing::info!("forge: pushed meta to core/meta"); Ok(()) } /// Ensure the `agent-configs/` repo exists so the first /// `push_config` doesn't 404, and wire it as the agent-editable PR surface: /// the agent is a **write** collaborator (can push feature branches + /// open config PRs) and `main` is branch-protected core-only (only hive-c0re's /// merge handler lands on it; operator approval required). No-op when the forge /// isn't running or the core token isn't minted yet. Safe to call on every /// spawn and on every startup (all steps idempotent). pub async fn ensure_config_repo(name: &str) -> Result<()> { if !is_present().await { return Ok(()); } let Some(token) = core_token() else { return Ok(()); }; ensure_org_repo(CONFIG_ORG, name, &token).await?; // Agent = write collaborator: it can push config-PR branches + open PRs, // but the branch protection below keeps it off `main` directly. add_collaborator( CONFIG_ORG, name, name, AddCollaboratorOptionPermission::Write, &token, ) .await?; // Protect `main` core-only, fast-forward-only (no auto force-push). A // failure here is security-relevant (an unprotected config repo lets an // operator-merged config PR bypass the deploy pipeline), so it's tracked // on the dashboard banner in addition to the journal warning callers // already log — see `record_branch_protection_result`. let result = apply_config_repo_branch_protection(name, &token).await; record_branch_protection_result(name, result.is_ok()); result } /// Dashboard-banner tracker for [`apply_config_repo_branch_protection`] /// failures. The registry ([`crate::warnings::set_warning`]) only takes /// `&'static str` kinds, so a dynamic per-agent key isn't possible — instead /// this keeps one static `crit` warning (`"branch_protection_missing"`) whose /// message lists every agent currently failing to protect, and clears it /// once the set is empty. Called on every `ensure_config_repo` pass (startup /// sweep + per-rebuild), so a fixed agent drops out of the message on its /// next successful sweep without requiring a restart. fn record_branch_protection_result(name: &str, ok: bool) { use std::collections::BTreeSet; use std::sync::{OnceLock, PoisonError}; use crate::warnings::{WarningGuard, set_warning}; static FAILING: OnceLock>> = OnceLock::new(); static GUARD: OnceLock>> = OnceLock::new(); let mut failing = FAILING .get_or_init(|| Mutex::new(BTreeSet::new())) .lock() .unwrap_or_else(PoisonError::into_inner); if ok { failing.remove(name); } else { failing.insert(name.to_owned()); } let mut guard = GUARD .get_or_init(|| Mutex::new(None)) .lock() .unwrap_or_else(PoisonError::into_inner); if failing.is_empty() { *guard = None; return; } let names = failing.iter().cloned().collect::>().join(", "); let message = format!( "config-repo branch protection not applied for: {names} — \ operator-merged config PRs for these agents could bypass the deploy pipeline" ); match guard.as_ref() { Some(g) => g.update("crit", message), None => *guard = Some(set_warning("branch_protection_missing", "crit", message)), } } /// Ensure the `internal/docs` repo exists. Called once at startup /// after `ensure_org(SHARED_ORG)`. Idempotent — `ensure_org_repo` /// treats 409 as success. pub async fn ensure_shared_docs_repo(core_token: &str) -> Result<()> { ensure_org_repo(SHARED_ORG, SHARED_DOCS_REPO, core_token).await } /// Grant agent `name` read-only collaborator access to `internal/docs`. /// Idempotent: re-adding an existing collaborator succeeds (Forgejo /// answers 204 either way). Mirrors `meta_read_access` so agents can /// clone the shared docs repo without authentication hassle. pub async fn shared_docs_access(name: &str, core_token: &str) -> Result<()> { add_collaborator( SHARED_ORG, SHARED_DOCS_REPO, name, AddCollaboratorOptionPermission::Read, core_token, ) .await?; tracing::info!(%name, "forge: granted shared-docs read access"); Ok(()) } /// Ensure the `internal/knowledge` repo exists and is public. /// Called once at startup after `ensure_org(SHARED_ORG)`. Idempotent. /// /// The repo is created as public so any agent with a forge account can /// fork it and open PRs to contribute. Existing deployments that ended /// up with a private repo are patched to public on the next hive-c0re /// startup via `set_repo_public`. pub async fn ensure_knowledge_repo(core_token: &str) -> Result<()> { ensure_org_repo_public(SHARED_ORG, KNOWLEDGE_REPO, core_token).await?; // Ensure public even if the repo already existed as private (older deployment). set_repo_public(SHARED_ORG, KNOWLEDGE_REPO, core_token).await } /// Grant agent `name` read-only collaborator access to `core/meta` on /// the forge so the agent can clone/fetch the meta flake. Idempotent: /// re-adding an existing collaborator succeeds (Forgejo answers 204 /// either way). pub async fn meta_read_access(name: &str, core_token: &str) -> Result<()> { add_collaborator( "core", "meta", name, AddCollaboratorOptionPermission::Read, core_token, ) .await?; tracing::info!(%name, "forge: granted meta read access"); Ok(()) } /// Add the forge `core/meta.git` URL as the `meta` remote in the /// agent's proposed config repo so the agent (and the manager) can /// fetch the meta flake from the forge. Idempotent: no-op when the /// remote already points at the right URL, or when the proposed repo /// does not exist yet. No-op when the forge is not running. pub async fn ensure_meta_remote(name: &str) -> Result<()> { if !is_present().await { return Ok(()); } // A malformed name has no proposed config repo (repos are only created // under a validated Ident), so there's nothing to wire — no-op. let Ok(agent) = hive_types::Ident::parse(name) else { return Ok(()); }; let proposed_dir = Coordinator::agent_proposed_dir(&agent); if !proposed_dir.join(".git").exists() { return Ok(()); } let want = format!("{}/core/meta.git", forge_http_base()); let existing = crate::lifecycle::git_command() .current_dir(&proposed_dir) .args(["remote", "get-url", "meta"]) .output() .await .context("git remote get-url meta")?; if existing.status.success() { let current = String::from_utf8_lossy(&existing.stdout).trim().to_owned(); if current == want { return Ok(()); } crate::lifecycle::git(&proposed_dir, &["remote", "set-url", "meta", &want]).await } else { crate::lifecycle::git(&proposed_dir, &["remote", "add", "meta", &want]).await } } /// Mirror agent `name`'s applied config repo — every status tag /// (`proposal` / `approved` / `building` / `deployed` / `failed` / /// `denied`) plus a best-effort `main` — to `agent-configs/` on /// the local forge. Best-effort: returns Err which callers log + ignore. /// No-op when the forge isn't seeded or the applied repo doesn't exist. /// /// Call this after every hive-c0re mutation of an applied repo's refs /// so the forge copy always reflects what core actually did. /// /// **Two separate pushes, not one.** The status tags are id-suffixed /// (`deployed/`, …) and add-only, so they must always land. `main`, /// by contrast, is core-only branch-protected and authoritatively /// advanced by `pr_merge`'s ff-only merge API — so a mirror push of an /// already-established `main` is routinely rejected (protected-branch, /// or non-fast-forward after a rolled-back deploy). Git's pre-receive /// hook is all-or-nothing: bundling both refspecs in one push means that /// `main` reject declines the whole push, dropping the tags too. /// So we push the tags on their own first, then attempt `main` /// separately and swallow the expected reject. The `main` push still /// matters for the initial seed of a fresh (empty) config repo, where it /// creates `main`. Operator-driven divergence fix: the `hivectl forge /// reconcile-config` command. /// /// The tokenised URL is passed straight to `git push` and deliberately /// never stored as a named remote: the applied repo is bind-mounted /// READ-ONLY into the manager container (`/applied`), so a token in /// `.git/config` would leak core's admin credential to an agent. pub async fn push_config(name: &str) -> Result<()> { let Some(token) = core_token() else { return Ok(()); }; let dir = crate::paths::applied_dir(name); if !dir.join(".git").exists() { return Ok(()); } let url = forge_git_url(&format!("{CONFIG_ORG}/{name}")); let auth = core_auth_header(&token); // Tags first, in their own push, so they land regardless of main's fate. let out = run_config_push(&dir, &url, &auth, "refs/tags/*:refs/tags/*").await?; if !out.status.success() { anyhow::bail!( "git push tags {CONFIG_ORG}/{name} failed ({}): {}", out.status, String::from_utf8_lossy(&out.stderr).trim() ); } // Then main on its own — a protected-branch / non-ff reject of an // established main is expected (pr_merge owns main); only the initial // empty-repo seed actually advances it here. let out = run_config_push(&dir, &url, &auth, "refs/heads/main:refs/heads/main").await?; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr); if stderr.contains("non-fast-forward") || stderr.contains("protected branch") || stderr.contains("pre-receive hook declined") { tracing::info!( %name, "forge: mirror push of main skipped — forge main is protected / \ owned by the config-PR ff-merge (expected); tags mirrored" ); return Ok(()); } anyhow::bail!( "git push main {CONFIG_ORG}/{name} failed ({}): {}", out.status, stderr.trim() ); } tracing::info!(%name, "forge: mirrored applied config to agent-configs"); Ok(()) } /// Run a single `git push ` in the applied repo `dir` and /// return the raw output for the caller to classify. Split out so /// [`push_config`] can push tags and `main` as independent pushes. async fn run_config_push( dir: &std::path::Path, url: &str, auth: &str, refspec: &str, ) -> Result { crate::lifecycle::git_command_authed(auth) .current_dir(dir) .args(["push", url, refspec]) .output() .await .context("invoke git push agent-configs") } /// Create an org named `name` (`org_create`). Idempotent: HTTP 422 /// ("user already exists") / 409 is treated as success. pub(super) async fn ensure_org(name: &str, admin_token: &str) -> Result<()> { let org = CreateOrgOption { description: None, email: None, full_name: None, location: None, repo_admin_change_team_access: None, username: name.to_owned(), visibility: None, website: None, }; match api(admin_token)?.org_create(org).await { Ok(_) => { tracing::info!(%name, "forge: created org"); Ok(()) } Err(e) if is_already_exists(&e) => { tracing::debug!(%name, "forge: org already exists"); Ok(()) } Err(e) => Err(e).with_context(|| format!("create org {name}")), } } /// One operator-declared pull-mirror, forwarded from the nix /// `services.hyperhive.forge.mirrors` option as JSON in /// `HYPERHIVE_FORGE_MIRRORS`. #[derive(serde::Deserialize)] struct Mirror { /// Upstream clone URL to mirror from (e.g. `https://github.com/actions/checkout`). upstream: String, /// Local `/` the mirror is created at. dest: String, } /// Ensure each `HYPERHIVE_FORGE_MIRRORS` entry exists as a real Forgejo /// pull-mirror. The env carries the JSON-encoded nix `forge.mirrors` list /// (plus the CI-auto `actions/checkout` entry). Absent/empty env = no-op. /// Per-mirror failures warn and continue — never abort the startup sweep. pub(super) async fn ensure_mirrors(admin_token: &str) { let raw = match std::env::var("HYPERHIVE_FORGE_MIRRORS") { Ok(s) if !s.trim().is_empty() => s, _ => return, }; let mirrors: Vec = match serde_json::from_str(&raw) { Ok(m) => m, Err(e) => { tracing::warn!(error = ?e, "forge: HYPERHIVE_FORGE_MIRRORS is not valid JSON; skipping mirror seed"); return; } }; for m in mirrors { let Some((owner, repo)) = m.dest.split_once('/') else { tracing::warn!(dest = %m.dest, "forge: mirror dest is not /; skipping"); continue; }; // Create the dest org first (idempotent); the mirror can't land // without its owner existing. if let Err(e) = ensure_org(owner, admin_token).await { tracing::warn!(%owner, error = ?e, "forge: ensure_org for mirror failed"); continue; } if let Err(e) = ensure_mirror_repo(&m.upstream, owner, repo, admin_token).await { tracing::warn!(dest = %m.dest, error = ?e, "forge: ensure_mirror_repo failed"); } } } /// Periodic sync interval for pull-mirrors. Forgejo syncs mirrors /// on-access by default, which re-introduces external DNS latency on /// every `git clone` (the hive-ci runner shares the host netns and is /// therefore affected by host resolver blips). A fixed periodic interval /// isolates CI from transient DNS failures — a stale mirror is /// acceptable; a broken clone because of a momentary DNS blip is not. const MIRROR_INTERVAL: &str = "8h0m0s"; /// Create `owner/repo` as a pull-mirror of `upstream` via the migrate API. /// Idempotent: if the repo already exists this function patches its /// `mirror_interval` to ensure it matches (covers mirrors that were /// created before the interval was introduced). A 409 on the migrate /// call (a race between the existence check and the migrate) is also /// success. async fn ensure_mirror_repo( upstream: &str, owner: &str, repo: &str, admin_token: &str, ) -> Result<()> { let client = api(admin_token)?; match client.repo_get(owner, repo).await { Ok(_) => { // Mirror already present. Patch interval so mirrors seeded before // this field was introduced (or with a different value) converge. let mut edit = sparse_edit_repo_option(); edit.mirror_interval = Some(MIRROR_INTERVAL.to_owned()); match client.repo_edit(owner, repo, edit).await { Ok(_) => { tracing::debug!(%owner, %repo, interval = MIRROR_INTERVAL, "forge: pull-mirror interval updated"); } Err(e) => { tracing::warn!( %owner, %repo, error = %e, "forge: failed to set mirror_interval on existing pull-mirror" ); } } return Ok(()); } // Absent — fall through to migrate. Err(e) if is_not_found(&e) => {} // Anything else (transport, auth, 5xx) leaves the repo's existence // unknown: migrating anyway would fold a 409 into success and skip // the interval patch this pass. Surface it instead. Err(e) => { return Err(e).with_context(|| format!("get pull-mirror {owner}/{repo}")); } } let opts = MigrateRepoOptions { auth_password: None, auth_token: None, auth_username: None, clone_addr: upstream.to_owned(), description: None, issues: None, labels: None, lfs: None, lfs_endpoint: None, milestones: None, mirror: Some(true), // Periodic refresh instead of on-access sync — keeps CI isolated // from external DNS failures at clone time. mirror_interval: Some(MIRROR_INTERVAL.to_owned()), private: Some(false), pull_requests: None, releases: None, repo_name: repo.to_owned(), repo_owner: Some(owner.to_owned()), service: Some(MigrateRepoOptionsService::Git), uid: None, wiki: None, }; match client.repo_migrate(opts).await { Ok(_) => { tracing::info!(%owner, %repo, %upstream, interval = MIRROR_INTERVAL, "forge: created pull-mirror"); Ok(()) } // 409 = a race created it between our existence check and here (the // check is the real idempotency guard). NOT 422: for the migrate // endpoint 422 is a validation error (bad clone_addr / service), so // it must surface via the error arm, not be swallowed as "already // exists" — hence `is_conflict`, not `is_already_exists`. Err(e) if is_conflict(&e) => { tracing::debug!(%owner, %repo, "forge: pull-mirror already exists (race)"); Ok(()) } Err(e) => Err(e).with_context(|| format!("migrate pull-mirror {owner}/{repo}")), } } /// Provision the [`OPERATORS_TEAM`] inside `org` as an **empty** team. /// Branch protection on that org's repos references it as the /// merge/approval whitelist; the operator adds herself as a member via the /// forge UI / hivectl. `includes_all_repositories` so the gate applies to /// every repo in the org; `write` is enough to approve + merge. hive-c0re /// never manages membership. Idempotent (409 = already exists). /// /// Must run for BOTH [`AGENTS_ORG`] and [`CONFIG_ORG`]: Gitea teams are /// org-scoped, so a config-repo branch-protection rule referencing /// `operators` needs the team to exist in `agent-configs` too. Missing it /// there 422'd every `apply_config_repo_branch_protection`, leaving config /// repos unprotected — operator-merged config PRs then bypassed the deploy /// pipeline and silently didn't apply. /// /// Uses `is_conflict` (409 only) — NOT `is_already_exists` (which also /// folds 422 into "already exists"). A 422 from `org_create_team` is a /// real validation error (bad request shape, missing units, etc.) that /// must surface so it can be fixed; the previous 422-swallowing hid the /// true cause and left the team silently uncreated every boot. /// Repo-unit access flags for the `operators` team. /// Explicit list so Forgejo doesn't reject a null/absent `units` field; /// a `write`-permission team needs at least `repo.code` + `repo.pulls` /// to review and merge PRs. const OPERATORS_TEAM_UNITS: &[&str] = &[ "repo.code", "repo.issues", "repo.pulls", "repo.releases", "repo.wiki", "repo.projects", "repo.packages", ]; pub(super) async fn ensure_operators_team(org: &str, token: &str) -> Result<()> { let units_vec: Vec = OPERATORS_TEAM_UNITS.iter().map(|&s| s.to_owned()).collect(); let team = CreateTeamOption { can_create_org_repo: Some(false), description: Some("hyperhive operators — merge gate for agent repos".to_owned()), includes_all_repositories: Some(true), name: OPERATORS_TEAM.to_owned(), permission: Some(CreateTeamOptionPermission::Write), units: Some(units_vec.clone()), units_map: None, }; let client = api(token)?; match client.org_create_team(org, team).await { Ok(_) => { tracing::info!(%org, "forge: created {OPERATORS_TEAM} team"); Ok(()) } // Team already exists — reconcile settings to desired state so a // team created with an older/wrong shape self-heals on next boot. // Forgejo signals the duplicate as a 409 conflict OR (this build) a // 422 `validation failed: team already exists`; both mean the same // thing, so fold both in via `is_already_exists_lenient` — a 409-only // check missed the 422 and warned every boot. List teams to find the // id (required by org_edit_team), then unconditionally PATCH to the // desired settings. Members are a separate endpoint; untouched here. Err(e) if is_already_exists_lenient(&e) => { let (_headers, teams) = client .org_list_teams(org) .await .with_context(|| format!("list teams for {org}"))?; let team_id = teams .into_iter() .find(|t| t.name.as_deref() == Some(OPERATORS_TEAM)) .and_then(|t| t.id) .with_context(|| { format!("{OPERATORS_TEAM} team not found in {org} after 409 conflict") })?; let edit = EditTeamOption { can_create_org_repo: Some(false), description: Some("hyperhive operators — merge gate for agent repos".to_owned()), includes_all_repositories: Some(true), name: OPERATORS_TEAM.to_owned(), permission: Some(EditTeamOptionPermission::Write), units: Some(units_vec), units_map: None, }; client .org_edit_team(team_id, edit) .await .with_context(|| format!("reconcile {org}/{OPERATORS_TEAM} team settings"))?; tracing::debug!(%org, "forge: reconciled {OPERATORS_TEAM} team settings"); Ok(()) } // Any OTHER error (a 422 that is NOT already-exists, or a transport // / auth failure): surface it — don't mask a real failure. A // non-already-exists 422 means the request body is invalid (e.g. // Forgejo rejected the units list); it repeats every boot until fixed. Err(e) => Err(e).with_context(|| format!("create team {org}/{OPERATORS_TEAM}")), } } /// Add `user` as a collaborator on `owner/repo` at `permission`. /// Idempotent: adding an existing collaborator just updates its /// permission (Forgejo answers 204 either way; a 201 from older /// versions is tolerated defensively). async fn add_collaborator( owner: &str, repo: &str, user: &str, permission: AddCollaboratorOptionPermission, token: &str, ) -> Result<()> { let res = api(token)? .repo_add_collaborator( owner, 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 {owner}/{repo}")); } } tracing::debug!(%owner, %repo, %user, ?permission, "forge: collaborator set"); Ok(()) } /// `CreateBranchProtectionOption` protecting `main` with every other /// field unset — Forgejo treats `None` fields as their defaults, same /// as the sparse JSON bodies the raw-HTTP predecessor sent. Callers /// set the whitelist/approval fields they need on top. fn main_branch_protection_option() -> CreateBranchProtectionOption { CreateBranchProtectionOption { apply_to_admins: None, approvals_whitelist_teams: None, approvals_whitelist_username: None, block_on_official_review_requests: None, block_on_outdated_branch: None, block_on_rejected_reviews: None, branch_name: Some("main".to_owned()), dismiss_stale_approvals: None, enable_approvals_whitelist: None, enable_merge_whitelist: None, enable_push: None, enable_push_whitelist: None, enable_status_check: None, ignore_stale_approvals: None, merge_whitelist_teams: None, merge_whitelist_usernames: None, protected_file_patterns: None, push_whitelist_deploy_keys: None, push_whitelist_teams: None, push_whitelist_usernames: None, require_signed_commits: None, required_approvals: None, rule_name: None, status_check_contexts: None, unprotected_file_patterns: None, } } /// 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 author (a write-level /// agent, not in the team) cannot merge its own PR. /// /// Idempotent, but **verify-don't-trust**: a create failure is ambiguous — /// "rule already exists" (success) OR a silent rejection that created NO rule /// (e.g. a 422 where `OPERATORS_TEAM` doesn't exist in `AGENTS_ORG`). The old /// code folded 200/409/422 into `Ok` and left the repo unprotected with no /// error — a fail-open merge gate. So on any create error, GET the `main` rule /// and only treat it as success if the rule is actually present (the exact fix /// already applied to [`apply_config_repo_branch_protection`]). async fn apply_operator_branch_protection(repo: &str, token: &str) -> Result<()> { let client = api(token)?; let mut rule = main_branch_protection_option(); rule.enable_merge_whitelist = Some(true); rule.merge_whitelist_teams = Some(vec![OPERATORS_TEAM.to_owned()]); rule.enable_approvals_whitelist = Some(true); rule.approvals_whitelist_teams = Some(vec![OPERATORS_TEAM.to_owned()]); rule.required_approvals = Some(1); rule.block_on_official_review_requests = Some(true); let Err(create_err) = client .repo_create_branch_protection(AGENTS_ORG, repo, rule) .await else { tracing::info!(%repo, "forge: applied operator branch protection"); return Ok(()); }; match client .repo_get_branch_protection(AGENTS_ORG, repo, "main") .await { Ok(_) => { tracing::debug!( %repo, create_error = %create_err, "forge: operator branch protection already present" ); Ok(()) } Err(check_err) => anyhow::bail!( "branch protection for {AGENTS_ORG}/{repo} not applied: create failed \ ({create_err}); GET main rule failed ({check_err}), no `main` rule present" ), } } /// Apply branch protection to an `agent-configs/` repo's `main` so it /// can serve as the agent-editable, PR-merge config surface: /// - **`main` is never directly pushable** — no push is enabled on the /// protected branch, so neither the agent (a write collaborator) nor /// hive-c0re can `git push` it. It only advances via the config-PR merge /// node (`actions::run_deploy_apply`), which fast-forward-*merges* the reviewed /// head through the forge merge API (`Do=fast-forward-only`, /// `head_commit_id` pinned to the reviewed sha). /// - **merge is whitelisted to `core`** — only hive-c0re can merge a config PR; /// the agent can push feature branches + open PRs but can't land them. /// - **the operator's dashboard approval is the gate** — approval happens on /// the `MergeConfigPr` card and hive-c0re only merges an approved PR. There's /// deliberately no Forgejo `required_approvals` review requirement: the flow /// never does an in-forge review, so requiring one would only dead-block the /// `core` merge. The dashboard approval + the `core`-only merge whitelist are /// the real gate. /// - **fast-forward-only** — `main` only ever advances by fast-forward; a raced /// non-ff `main` is refused by the merge API rather than force-moved. /// /// Idempotent: an existing rule for the branch (200/409/422) is success. async fn apply_config_repo_branch_protection(repo: &str, token: &str) -> Result<()> { let client = api(token)?; let mut rule = main_branch_protection_option(); // Only `core` may merge (through the config-PR merge API); no push is // enabled at all, so `main` can only advance via that merge. Push + // approval defaults are off in `main_branch_protection_option`, so a fresh // rule needs nothing but the merge whitelist. (`config_repo_protection_edit` // must clear the old push/approval fields explicitly, since a PATCH leaves // unset fields untouched.) rule.enable_merge_whitelist = Some(true); rule.merge_whitelist_usernames = Some(vec!["core".to_owned()]); let Err(create_err) = client .repo_create_branch_protection(CONFIG_ORG, repo, rule) .await else { tracing::info!(%repo, "forge: applied config-repo branch protection"); return Ok(()); }; // Create failed. This is ambiguous — "rule already exists" (the common // idempotent case) OR a silent rejection that created no rule. Either way a // create never *updates* an existing rule, and repos protected under an // older shape (the push-based / approval-gated rules) carry stale settings. // So converge the existing rule with a PATCH that explicitly clears them: // it fixes those stale repos on the next boot's `ensure_config_repo` pass, // is a harmless no-op when the rule is already correct, and still fails // loudly when no rule can be established (you can't edit a rule that isn't // there) — so it can't leave a repo silently unprotected. match client .repo_edit_branch_protection(CONFIG_ORG, repo, "main", config_repo_protection_edit()) .await { Ok(_) => { // Debug, not info: this PATCH runs on every `ensure_config_repo` // boot pass for every existing repo (create → 409 → converge), so // it's almost always a no-op re-assertion — info-logging it would // be N lines of noise per boot on a many-agent hive. tracing::debug!( %repo, create_error = %create_err, "forge: converged existing config-repo branch protection" ); Ok(()) } Err(edit_err) => anyhow::bail!( "branch protection for {CONFIG_ORG}/{repo} not applied: create failed \ ({create_err}); edit of existing `main` rule failed ({edit_err})" ), } } /// The `EditBranchProtectionOption` that converges an existing /// `agent-configs/` `main` rule to the current desired shape: merge /// whitelisted to `core`, **no direct push at all**, and **no in-forge approval /// requirement** (the dashboard approval + `core`-only merge whitelist are the /// gate). The push/approval fields are set to their explicit off-values, not /// left `None`, so a repo carrying an older push-based or approval-gated rule is /// actually *converged* rather than merely re-asserted — a PATCH leaves unset /// fields untouched. Every field unrelated to this policy stays `None`. fn config_repo_protection_edit() -> EditBranchProtectionOption { EditBranchProtectionOption { apply_to_admins: None, approvals_whitelist_teams: None, approvals_whitelist_username: None, block_on_official_review_requests: Some(false), block_on_outdated_branch: None, block_on_rejected_reviews: None, dismiss_stale_approvals: None, enable_approvals_whitelist: Some(false), enable_merge_whitelist: Some(true), enable_push: Some(false), enable_push_whitelist: Some(false), enable_status_check: None, ignore_stale_approvals: None, merge_whitelist_teams: None, merge_whitelist_usernames: Some(vec!["core".to_owned()]), protected_file_patterns: None, push_whitelist_deploy_keys: None, push_whitelist_teams: None, push_whitelist_usernames: Some(Vec::new()), require_signed_commits: None, required_approvals: Some(0), status_check_contexts: None, unprotected_file_patterns: None, } } /// Create a repo for `agent` in the c0re-owned [`AGENTS_ORG`] and wire the /// perms: the org owns it (perms stay c0re-managed), the agent is added /// as a **write** collaborator (not owner — can push + open PRs but can't /// bypass branch protection), and the default branch gets the operator /// merge gate. This is the sanctioned create path now that agents can't /// create repos directly (`max_repo_creation = 0`). Idempotent. pub async fn create_agent_repo(agent: &str, repo: &str, core_token: &str) -> Result { ensure_org_repo(AGENTS_ORG, repo, core_token).await?; add_collaborator( AGENTS_ORG, repo, agent, AddCollaboratorOptionPermission::Write, core_token, ) .await?; apply_operator_branch_protection(repo, core_token).await?; tracing::info!(%agent, %repo, "forge: created agent repo in {AGENTS_ORG} with operator merge gate"); Ok(format!("{AGENTS_ORG}/{repo}")) } #[cfg(test)] mod tests { use super::message_says_already_exists; #[test] fn already_exists_message_is_recognised() { // The exact 422 body this Forgejo build returns for a duplicate team. assert!(message_says_already_exists( "validation failed: team already exists [org_id: 10, name: operators]" )); // Case-insensitive. assert!(message_says_already_exists("Repository Already Exists")); } #[test] fn real_validation_error_is_not_treated_as_already_exists() { // A genuine bad-request 422 must still surface, not be folded in. assert!(!message_says_already_exists( "validation failed: units must not be empty" )); assert!(!message_says_already_exists("not found")); } }