//! 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 anyhow::{Context, Result}; use forgejo_api::structs::{ AddCollaboratorOption, AddCollaboratorOptionPermission, CreateBranchProtectionOption, CreateOrgOption, CreateRepoOption, CreateTeamOption, CreateTeamOptionPermission, EditRepoOption, EditTeamOption, EditTeamOptionPermission, MigrateRepoOptions, MigrateRepoOptionsService, Repository, }; use forgejo_api::{ApiErrorKind, ForgejoError}; use reqwest::StatusCode; use tokio::process::Command; use crate::coordinator::Coordinator; use super::{ AGENTS_ORG, CONFIG_ORG, KNOWLEDGE_REPO, OPERATORS_TEAM, SHARED_DOCS_REPO, SHARED_ORG, api, 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 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}")) } /// 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. let url = forge_git_url(&token, "core/meta"); let out = Command::new("git") .current_dir(dir) .args(["push", "--force", &url, "HEAD:main"]) .output() .await .context("invoke git push core/meta")?; if !out.status.success() { anyhow::bail!( "git push core/meta failed ({}): {}", out.status, String::from_utf8_lossy(&out.stderr).trim() ); } 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). apply_config_repo_branch_protection(name, &token).await } /// 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(()); } let proposed_dir = Coordinator::agent_proposed_dir(name); 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 — `main` plus every tag /// (`proposal` / `approved` / `building` / `deployed` / `failed` / /// `denied`) — 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 yet. /// /// Call this after every hive-c0re mutation of an applied repo's refs /// so the forge copy always reflects what core actually did. /// /// Never force-pushes. The status tags are id-suffixed /// (`proposal/`, `deployed/`, …) and therefore add-only, and /// `main` is published history — after a failed deploy rolls the LOCAL /// applied `main` back to last-good, the forge `main` may legitimately /// be ahead (e.g. an operator-merged config PR whose rebuild failed). /// Rewinding it would erase that merged commit from the forge, which /// is exactly the incident this guards against: the local repo tracks /// "what last built", the forge tracks "what was approved", and the /// `failed/` tag records the divergence. A non-fast-forward /// rejection of `main` is therefore expected + logged at info; the /// tags in the same push still land (git pushes refspecs /// independently). Any other failure is a real error. /// /// 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 = Coordinator::agent_applied_dir(name); if !dir.join(".git").exists() { return Ok(()); } let url = forge_git_url(&token, &format!("{CONFIG_ORG}/{name}")); let out = crate::lifecycle::git_command() .current_dir(&dir) .args([ "push", &url, "refs/heads/main:refs/heads/main", "refs/tags/*:refs/tags/*", ]) .output() .await .context("invoke git push agent-configs")?; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr); if stderr.contains("non-fast-forward") { tracing::info!( %name, "forge: mirror push of main rejected (non-fast-forward) — forge main is \ ahead of local applied main (rolled-back deploy); leaving forge history intact" ); return Ok(()); } anyhow::bail!( "git push {CONFIG_ORG}/{name} failed ({}): {}", out.status, stderr.trim() ); } tracing::info!(%name, "forge: mirrored applied config to agent-configs"); Ok(()) } /// 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(()) } // 409: team already exists — reconcile settings to desired state so // a team created with an older/wrong shape self-heals on next boot. // List teams to find the id (required by org_edit_team), then // unconditionally PATCH to the desired settings. Members are a // separate endpoint; this never touches membership. Err(e) if is_conflict(&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(()) } // 422 and any other error: surface it — don't mask a real failure // as "already exists". A 422 here typically means the request body // is invalid (e.g. Forgejo rejected the units list or another // field); it will repeat 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: /// - **push + merge whitelists are `core`-only** — the agent (a write /// collaborator) can push feature branches and open config PRs, but only /// hive-c0re lands on `main`, via its verify-and-ff-push merge handler /// (`run_merge_config_pr`). The agent can never push `main` directly. /// - **operator-team approval is required** to merge, and the author (not in /// the team) cannot self-approve. /// - **force-pushing `main` stays impossible** — `main` only ever advances by /// fast-forward. The merge handler's `ff_push_to_main` is already a /// non-force push, so it lands fine. The legacy `push_config` mirror DOES /// force-push (it re-points status tags and rewinds `main` on a failed-build /// rollback), so the protection rejects those non-ff updates — that /// mirror runs best-effort until the agent-opened PR-merge flow retires it. /// (Auto force-push is intentionally not allowed: per operator directive a /// silent force-push is a bug, not a feature. The raw-HTTP predecessor /// sent `"enable_force_push":false` + `"allow_manual_merge":true` in this /// body; neither is a `CreateBranchProtectionOption` field, so Forgejo /// ignored both keys — dropping them changes nothing: force-push /// protection defaults to off, and `allow_manual_merge` is a *repo* /// setting, not a branch-protection one.) /// /// 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(); rule.enable_push_whitelist = Some(true); rule.push_whitelist_usernames = Some(vec!["core".to_owned()]); rule.enable_merge_whitelist = Some(true); rule.merge_whitelist_usernames = Some(vec!["core".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(CONFIG_ORG, repo, rule) .await else { tracing::info!(%repo, "forge: applied config-repo branch protection"); return Ok(()); }; // A create failure is ambiguous: it can mean "rule already exists" // (idempotent success) OR a silent rejection — e.g. a 422 where Forgejo // refused the request and created NO rule. The old code treated // 200/409/422 all as success, so a rejected POST left the repo // unprotected with no error (the reported case: a new agent's config // repo had no `main` rule and nothing was logged). Don't trust the // status: verify the `main` rule actually exists, and on failure // surface the create error (its Display carries Forgejo's validation // message) so the real reason is in the journal. match client .repo_get_branch_protection(CONFIG_ORG, repo, "main") .await { Ok(_) => { tracing::debug!( %repo, create_error = %create_err, "forge: config-repo 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 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}")) }