`forge_git_url` spliced `core:<token>@` between scheme and authority, and that URL is a process argument. `/proc/<pid>/cmdline` is mode 0444 — world-readable — so the core admin token, which provisions every agent's forge account, was published to any local user for the lifetime of each git child. Seven call sites built such a URL. The credential now travels in the environment instead: `git_command_authed` sets `http.extraHeader` via `GIT_CONFIG_*`, which git reads exactly like a config file, and `/proc/<pid>/environ` is 0400 — owner-only. Same credential, materially smaller audience. The remote is a plain `http://forge/<org>/<repo>.git`, and `forge_git_url` no longer takes a token, so the old shape cannot be rebuilt by accident. `knowledge`'s clone was the one place a credentialed URL was stored as a named remote — git persists the clone URL into `.git/config`, so the token sat on disk and every later `pull` authenticated from there. That is the case `forge::repos::push_config` documents as forbidden ("the tokenised URL ... deliberately never stored as a named remote"). `pull` now rewrites `origin` to the plain URL first, which also scrubs the persisted token from existing deployments, and authenticates from the environment when a token is available. The repo is public, so the pull still works without one. Three call sites also stopped spawning `Command::new("git")` directly, so they honour the `HYPERHIVE_GIT` path the NixOS module bakes in and the `kill_on_drop` every other git spawn gets. The two URL-shape tests now assert the *absence* of a credential, and a new one decodes the header back to `core:<token>` — without that, a malformed header would leave every forge operation silently anonymous with the other assertions still green.
1054 lines
44 KiB
Rust
1054 lines
44 KiB
Rust
//! 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 `<owner>/<name>` — purely for log + error
|
|
/// context.
|
|
fn created_or_exists(res: Result<Repository, ForgejoError>, 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/<agent>`).
|
|
/// 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<Option<crate::warnings::WarningGuard>> = 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:<token>` or just
|
|
// any-username:<token>; 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/<name>` 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<Mutex<BTreeSet<String>>> = OnceLock::new();
|
|
static GUARD: OnceLock<Mutex<Option<WarningGuard>>> = 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::<Vec<_>>().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/<name>` 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/<id>`, …) 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 <url> <refspec>` 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<std::process::Output> {
|
|
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 `<owner>/<repo>` 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<Mirror> = 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 <owner>/<repo>; 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<String> = 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/<name>` 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/<name>` `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<String> {
|
|
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"));
|
|
}
|
|
}
|