refactor(hive-c0re): port forge module + knowledge hooks to forgejo-api
This commit is contained in:
parent
4468e86e2d
commit
b8a3927c43
5 changed files with 651 additions and 426 deletions
|
|
@ -1,99 +1,178 @@
|
|||
//! 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. Shared HTTP helpers +
|
||||
//! org-name constants live in the module root (`super`).
|
||||
//! 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, 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, FORGE_HTTP, KNOWLEDGE_REPO, OPERATORS_TEAM, SHARED_DOCS_REPO,
|
||||
SHARED_ORG, core_token, forge_http, is_present,
|
||||
SHARED_ORG, api, core_token, is_present,
|
||||
};
|
||||
|
||||
/// JSON body for a private, empty repo defaulting to `main`.
|
||||
fn repo_body(name: &str) -> String {
|
||||
format!(r#"{{"name":"{name}","auto_init":false,"private":true,"default_branch":"main"}}"#)
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON body for a public, empty repo defaulting to `main`.
|
||||
fn repo_body_public(name: &str) -> String {
|
||||
format!(r#"{{"name":"{name}","auto_init":false,"private":false,"default_branch":"main"}}"#)
|
||||
/// `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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 url = format!("{FORGE_HTTP}/api/v1/repos/{owner}/{repo}");
|
||||
let (status, _) =
|
||||
forge_http(reqwest::Method::PATCH, &url, token, r#"{"private":false}"#).await?;
|
||||
match status.as_u16() {
|
||||
200 => {
|
||||
tracing::debug!(%owner, %repo, "forge: repo set to public");
|
||||
Ok(())
|
||||
}
|
||||
other => anyhow::bail!("PATCH {owner}/{repo} (set public) returned HTTP {other}"),
|
||||
}
|
||||
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<()> {
|
||||
create_repo(
|
||||
&format!("{FORGE_HTTP}/api/v1/orgs/{org}/repos"),
|
||||
&repo_body_public(name),
|
||||
token,
|
||||
&format!("{org}/{name}"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// POST a repo-creation request to `url` and fold "already exists"
|
||||
/// (HTTP 409 / 422) into success. `label` is `<owner>/<name>` — purely
|
||||
/// for log + error context.
|
||||
async fn create_repo(url: &str, body: &str, token: &str, label: &str) -> Result<()> {
|
||||
let (status, _) = forge_http(reqwest::Method::POST, url, token, body).await?;
|
||||
match status.as_u16() {
|
||||
201 => {
|
||||
tracing::info!(%label, "forge: created repo");
|
||||
Ok(())
|
||||
}
|
||||
409 | 422 => {
|
||||
tracing::debug!(%label, "forge: repo already exists");
|
||||
Ok(())
|
||||
}
|
||||
other => anyhow::bail!("POST {url} ({label}) returned HTTP {other}"),
|
||||
}
|
||||
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<()> {
|
||||
create_repo(
|
||||
&format!("{FORGE_HTTP}/api/v1/user/repos"),
|
||||
&repo_body(name),
|
||||
token,
|
||||
&format!("core/{name}"),
|
||||
)
|
||||
.await
|
||||
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<()> {
|
||||
create_repo(
|
||||
&format!("{FORGE_HTTP}/api/v1/orgs/{org}/repos"),
|
||||
&repo_body(name),
|
||||
token,
|
||||
&format!("{org}/{name}"),
|
||||
)
|
||||
.await
|
||||
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.
|
||||
|
|
@ -141,7 +220,14 @@ pub async fn ensure_config_repo(name: &str) -> Result<()> {
|
|||
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, "write", &token).await?;
|
||||
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
|
||||
}
|
||||
|
|
@ -154,43 +240,20 @@ pub async fn ensure_shared_docs_repo(core_token: &str) -> Result<()> {
|
|||
}
|
||||
|
||||
/// Grant agent `name` read-only collaborator access to `internal/docs`.
|
||||
/// Idempotent: HTTP 204 (already a collaborator) is treated as success.
|
||||
/// Mirrors `meta_read_access` so agents can clone the shared docs repo
|
||||
/// without authentication hassle.
|
||||
/// 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<()> {
|
||||
let url =
|
||||
format!("{FORGE_HTTP}/api/v1/repos/{SHARED_ORG}/{SHARED_DOCS_REPO}/collaborators/{name}");
|
||||
let body = r#"{"permission":"read"}"#;
|
||||
let out = Command::new("curl")
|
||||
.args([
|
||||
"-sS",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
"-X",
|
||||
"PUT",
|
||||
"-H",
|
||||
"Content-Type: application/json",
|
||||
"-H",
|
||||
&format!("Authorization: token {core_token}"),
|
||||
"-d",
|
||||
body,
|
||||
&url,
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("invoke curl PUT internal/docs/collaborators")?;
|
||||
let code = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
||||
match code.as_str() {
|
||||
"204" => {
|
||||
tracing::info!(%name, "forge: granted shared-docs read access");
|
||||
Ok(())
|
||||
}
|
||||
other => anyhow::bail!(
|
||||
"PUT {SHARED_ORG}/{SHARED_DOCS_REPO}/collaborators/{name} returned HTTP {other}"
|
||||
),
|
||||
}
|
||||
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.
|
||||
|
|
@ -208,38 +271,19 @@ pub async fn ensure_knowledge_repo(core_token: &str) -> Result<()> {
|
|||
|
||||
/// Grant agent `name` read-only collaborator access to `core/meta` on
|
||||
/// the forge so the agent can clone/fetch the meta flake. Idempotent:
|
||||
/// HTTP 204 (already a collaborator) is treated as success.
|
||||
/// re-adding an existing collaborator succeeds (Forgejo answers 204
|
||||
/// either way).
|
||||
pub async fn meta_read_access(name: &str, core_token: &str) -> Result<()> {
|
||||
let url = format!("{FORGE_HTTP}/api/v1/repos/core/meta/collaborators/{name}");
|
||||
let body = r#"{"permission":"read"}"#;
|
||||
let out = Command::new("curl")
|
||||
.args([
|
||||
"-sS",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
"-X",
|
||||
"PUT",
|
||||
"-H",
|
||||
"Content-Type: application/json",
|
||||
"-H",
|
||||
&format!("Authorization: token {core_token}"),
|
||||
"-d",
|
||||
body,
|
||||
&url,
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("invoke curl PUT core/meta/collaborators")?;
|
||||
let code = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
||||
match code.as_str() {
|
||||
"204" => {
|
||||
tracing::info!(%name, "forge: granted meta read access");
|
||||
Ok(())
|
||||
}
|
||||
other => anyhow::bail!("PUT core/meta/collaborators/{name} returned HTTP {other}"),
|
||||
}
|
||||
add_collaborator(
|
||||
"core",
|
||||
"meta",
|
||||
name,
|
||||
AddCollaboratorOptionPermission::Read,
|
||||
core_token,
|
||||
)
|
||||
.await?;
|
||||
tracing::info!(%name, "forge: granted meta read access");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add `http://localhost:3000/core/meta.git` as the `meta` remote in
|
||||
|
|
@ -339,22 +383,29 @@ pub async fn push_config(name: &str) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// POST `/api/v1/orgs` to create an org named `name`. Idempotent:
|
||||
/// HTTP 422 ("user already exists") is treated as success.
|
||||
/// 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 body = format!(r#"{{"username":"{name}"}}"#);
|
||||
let url = format!("{FORGE_HTTP}/api/v1/orgs");
|
||||
let (status, _) = forge_http(reqwest::Method::POST, &url, admin_token, &body).await?;
|
||||
match status.as_u16() {
|
||||
201 => {
|
||||
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(())
|
||||
}
|
||||
422 | 409 => {
|
||||
Err(e) if is_already_exists(&e) => {
|
||||
tracing::debug!(%name, "forge: org already exists");
|
||||
Ok(())
|
||||
}
|
||||
other => anyhow::bail!("POST /api/v1/orgs name={name} returned HTTP {other}"),
|
||||
Err(e) => Err(e).with_context(|| format!("create org {name}")),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -414,63 +465,72 @@ const MIRROR_INTERVAL: &str = "8h0m0s";
|
|||
/// 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
|
||||
/// POST (a race between the GET check and the POST) is also success.
|
||||
/// 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 repo_url = format!("{FORGE_HTTP}/api/v1/repos/{owner}/{repo}");
|
||||
let (status, _) = forge_http(reqwest::Method::GET, &repo_url, admin_token, "").await?;
|
||||
if status.is_success() {
|
||||
let client = api(admin_token)?;
|
||||
if client.repo_get(owner, repo).await.is_ok() {
|
||||
// Mirror already present. Patch interval so mirrors seeded before
|
||||
// this field was introduced (or with a different value) converge.
|
||||
let patch_body = serde_json::json!({ "mirror_interval": MIRROR_INTERVAL }).to_string();
|
||||
let (patch_status, patch_text) =
|
||||
forge_http(reqwest::Method::PATCH, &repo_url, admin_token, &patch_body).await?;
|
||||
if patch_status.is_success() {
|
||||
tracing::debug!(%owner, %repo, interval = MIRROR_INTERVAL, "forge: pull-mirror interval updated");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
%owner, %repo, status = %patch_status, body = %patch_text,
|
||||
"forge: failed to set mirror_interval on existing pull-mirror"
|
||||
);
|
||||
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(());
|
||||
}
|
||||
// serde_json::json! → the upstream URL is escaped safely (no string
|
||||
// interpolation into the JSON body).
|
||||
let body = serde_json::json!({
|
||||
"clone_addr": upstream,
|
||||
"repo_owner": owner,
|
||||
"repo_name": repo,
|
||||
"mirror": true,
|
||||
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.
|
||||
"interval": MIRROR_INTERVAL,
|
||||
"service": "git",
|
||||
"private": false,
|
||||
})
|
||||
.to_string();
|
||||
let url = format!("{FORGE_HTTP}/api/v1/repos/migrate");
|
||||
let (status, text) = forge_http(reqwest::Method::POST, &url, admin_token, &body).await?;
|
||||
match status.as_u16() {
|
||||
201 => {
|
||||
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 GET check and here (the GET
|
||||
// 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 bail arm, not be swallowed as "already exists".
|
||||
409 => {
|
||||
// 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(())
|
||||
}
|
||||
other => {
|
||||
anyhow::bail!("POST /api/v1/repos/migrate {owner}/{repo} returned HTTP {other}: {text}")
|
||||
}
|
||||
Err(e) => Err(e).with_context(|| format!("migrate pull-mirror {owner}/{repo}")),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -488,48 +548,101 @@ async fn ensure_mirror_repo(
|
|||
/// repos unprotected — operator-merged config PRs then bypassed the deploy
|
||||
/// pipeline and silently didn't apply.
|
||||
pub(super) async fn ensure_operators_team(org: &str, token: &str) -> Result<()> {
|
||||
let url = format!("{FORGE_HTTP}/api/v1/orgs/{org}/teams");
|
||||
let body = format!(
|
||||
r#"{{"name":"{OPERATORS_TEAM}","description":"hyperhive operators — merge gate for agent repos","permission":"write","includes_all_repositories":true,"can_create_org_repo":false}}"#
|
||||
);
|
||||
let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
||||
match status.as_u16() {
|
||||
201 => {
|
||||
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: None,
|
||||
units_map: None,
|
||||
};
|
||||
match api(token)?.org_create_team(org, team).await {
|
||||
Ok(_) => {
|
||||
tracing::info!(%org, "forge: created {OPERATORS_TEAM} team");
|
||||
Ok(())
|
||||
}
|
||||
409 | 422 => {
|
||||
Err(e) if is_already_exists(&e) => {
|
||||
tracing::debug!(%org, "forge: {OPERATORS_TEAM} team already exists");
|
||||
Ok(())
|
||||
}
|
||||
other => {
|
||||
anyhow::bail!("POST /orgs/{org}/teams ({OPERATORS_TEAM}) returned HTTP {other}")
|
||||
}
|
||||
Err(e) => Err(e).with_context(|| format!("create team {org}/{OPERATORS_TEAM}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add `user` as a collaborator on `owner/repo` at `permission`
|
||||
/// (`read` / `write` / `admin`). Idempotent: 201 (added) and 204 (already a
|
||||
/// collaborator / permission updated) both count as success.
|
||||
/// 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: &str,
|
||||
permission: AddCollaboratorOptionPermission,
|
||||
token: &str,
|
||||
) -> Result<()> {
|
||||
let url = format!("{FORGE_HTTP}/api/v1/repos/{owner}/{repo}/collaborators/{user}");
|
||||
let body = format!(r#"{{"permission":"{permission}"}}"#);
|
||||
let (status, _) = forge_http(reqwest::Method::PUT, &url, token, &body).await?;
|
||||
match status.as_u16() {
|
||||
201 | 204 => {
|
||||
tracing::debug!(%owner, %repo, %user, %permission, "forge: collaborator set");
|
||||
Ok(())
|
||||
}
|
||||
other => {
|
||||
anyhow::bail!("PUT {owner}/{repo}/collaborators/{user} returned HTTP {other}")
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a branch-protection create failed because a rule for the
|
||||
/// branch already exists: 409/422 ([`is_already_exists`]) or the 200
|
||||
/// Forgejo answers instead of 201 for a duplicate rule (unlisted in
|
||||
/// the endpoint spec, so it surfaces as `UnexpectedStatusCode(200)`).
|
||||
fn is_protection_already_present(e: &ForgejoError) -> bool {
|
||||
is_already_exists(e)
|
||||
|| matches!(e, ForgejoError::UnexpectedStatusCode(s) if *s == StatusCode::OK)
|
||||
}
|
||||
|
||||
/// Apply the operator merge-gate branch protection to `repo`'s default
|
||||
|
|
@ -538,21 +651,26 @@ async fn add_collaborator(
|
|||
/// agent, not in the team) cannot merge its own PR. Idempotent: an existing
|
||||
/// rule for the branch (200/409/422) is treated as success.
|
||||
async fn apply_operator_branch_protection(repo: &str, token: &str) -> Result<()> {
|
||||
let url = format!("{FORGE_HTTP}/api/v1/repos/{AGENTS_ORG}/{repo}/branch_protections");
|
||||
let body = format!(
|
||||
r#"{{"branch_name":"main","enable_merge_whitelist":true,"merge_whitelist_teams":["{OPERATORS_TEAM}"],"enable_approvals_whitelist":true,"approvals_whitelist_teams":["{OPERATORS_TEAM}"],"required_approvals":1,"block_on_official_review_requests":true}}"#
|
||||
);
|
||||
let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
||||
match status.as_u16() {
|
||||
201 => {
|
||||
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);
|
||||
match api(token)?
|
||||
.repo_create_branch_protection(AGENTS_ORG, repo, rule)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
tracing::info!(%repo, "forge: applied operator branch protection");
|
||||
Ok(())
|
||||
}
|
||||
200 | 409 | 422 => {
|
||||
Err(e) if is_protection_already_present(&e) => {
|
||||
tracing::debug!(%repo, "forge: branch protection already present");
|
||||
Ok(())
|
||||
}
|
||||
other => anyhow::bail!("POST {AGENTS_ORG}/{repo}/branch_protections returned HTTP {other}"),
|
||||
Err(e) => Err(e).with_context(|| format!("create branch protection {AGENTS_ORG}/{repo}")),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -564,45 +682,63 @@ async fn apply_operator_branch_protection(repo: &str, token: &str) -> Result<()>
|
|||
/// (`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.
|
||||
/// - **`enable_force_push` is `false`** — `main` only ever advances by
|
||||
/// - **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 now rejects those non-ff updates — that
|
||||
/// 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.)
|
||||
/// 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 url = format!("{FORGE_HTTP}/api/v1/repos/{CONFIG_ORG}/{repo}/branch_protections");
|
||||
let body = format!(
|
||||
r#"{{"branch_name":"main","enable_push_whitelist":true,"push_whitelist_usernames":["core"],"enable_merge_whitelist":true,"merge_whitelist_usernames":["core"],"enable_approvals_whitelist":true,"approvals_whitelist_teams":["{OPERATORS_TEAM}"],"required_approvals":1,"block_on_official_review_requests":true,"allow_manual_merge":true,"enable_force_push":false}}"#
|
||||
);
|
||||
let (status, resp_body) = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
||||
if status.as_u16() == 201 {
|
||||
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(());
|
||||
}
|
||||
// Non-201 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 code:
|
||||
// verify the `main` rule actually exists, and on failure surface the
|
||||
// POST's response body so the real reason is in the journal.
|
||||
let main_url = format!("{url}/main");
|
||||
let (check, _) = forge_http(reqwest::Method::GET, &main_url, token, "").await?;
|
||||
if check.as_u16() == 200 {
|
||||
tracing::debug!(%repo, %status, "forge: config-repo branch protection already present");
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"branch protection for {CONFIG_ORG}/{repo} not applied: POST -> HTTP {status} \
|
||||
(body: {body}); GET main -> HTTP {check}, no `main` rule present",
|
||||
body = resp_body.trim(),
|
||||
)
|
||||
};
|
||||
// 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"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -614,7 +750,14 @@ async fn apply_config_repo_branch_protection(repo: &str, token: &str) -> Result<
|
|||
/// 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, "write", 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}"))
|
||||
|
|
|
|||
Loading…
Reference in a new issue