850 lines
34 KiB
Rust
850 lines
34 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 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 `<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}"))
|
|
}
|
|
|
|
/// 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.
|
|
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/<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).
|
|
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/<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 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/<id>`, `deployed/<id>`, …) 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/<id>` 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 `<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(())
|
|
}
|
|
// 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/<name>` 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<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}"))
|
|
}
|