From b8a3927c438006ffc5c5c1030abec3a32bd71da3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Tue, 7 Jul 2026 09:11:15 +0200 Subject: [PATCH] refactor(hive-c0re): port forge module + knowledge hooks to forgejo-api --- hive-c0re/src/forge/mod.rs | 47 +-- hive-c0re/src/forge/pr_merge.rs | 68 +++- hive-c0re/src/forge/repos.rs | 617 ++++++++++++++++++----------- hive-c0re/src/forge/users.rs | 266 ++++++++----- hive-c0re/src/workers/knowledge.rs | 89 ++--- 5 files changed, 656 insertions(+), 431 deletions(-) diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index f5d70daa..05c7d356 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -18,8 +18,11 @@ pub use repos::{ }; pub use users::{core_token, ensure_user_for, provision_user_token}; +use std::sync::OnceLock; + use anyhow::{Context, Result}; -use reqwest::StatusCode; +use forgejo_api::{Auth, Forgejo}; +use url::Url; use repos::{ensure_mirrors, ensure_operators_team, ensure_org}; use users::{ @@ -105,34 +108,20 @@ async fn forge_admin(args: &[&str]) -> Result { Ok(stdout) } -/// Thin Forgejo REST helper. Sends `method` to `url` with a JSON body -/// and `Authorization: token `, returns the HTTP status code. -/// All Forgejo API calls that don't shell out to `forgejo admin` go -/// through here — one place for auth header, content-type, error -/// propagation, and the shared reqwest Client. -/// Returns the response status **and body**. The body lets callers log -/// *why* Forgejo rejected a request (e.g. the validation message on a -/// 422); status-only callers just bind `(status, _)`. Body read is -/// best-effort — a read error yields an empty string rather than -/// failing the whole call. -async fn forge_http( - method: reqwest::Method, - url: &str, - token: &str, - body: &str, -) -> Result<(StatusCode, String)> { - let client = reqwest::Client::new(); - let resp = client - .request(method, url) - .header("Authorization", format!("token {token}")) - .header("Content-Type", "application/json") - .body(body.to_owned()) - .send() - .await - .with_context(|| format!("forge HTTP request to {url}"))?; - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - Ok((status, text)) +/// Typed Forgejo API client for the local forge ([`FORGE_HTTP`]), +/// authenticated as `token`. All Forgejo API calls that don't shell +/// out to `forgejo admin` go through clients built here — one place +/// for the base URL and auth. Tokens differ per call site (core admin +/// token vs per-agent tokens), so the token is passed per call; the +/// base URL is parsed once. Failures surface as +/// `forgejo_api::ForgejoError`, whose Display carries the HTTP status +/// and the API's error message (e.g. the validation reason on a 422). +pub(crate) fn api(token: &str) -> Result { + static URL: OnceLock = OnceLock::new(); + let url = URL + .get_or_init(|| Url::parse(FORGE_HTTP).expect("FORGE_HTTP is a valid URL")) + .clone(); + Forgejo::new(Auth::Token(token), url).context("build forgejo api client") } /// Whether `ns` is a hive-managed Forgejo namespace that agent-initiated diff --git a/hive-c0re/src/forge/pr_merge.rs b/hive-c0re/src/forge/pr_merge.rs index 02133085..c82e83f6 100644 --- a/hive-c0re/src/forge/pr_merge.rs +++ b/hive-c0re/src/forge/pr_merge.rs @@ -4,10 +4,12 @@ //! boundary; moved verbatim from the `forge` module root. use anyhow::Context; +use forgejo_api::ForgejoError; +use forgejo_api::structs::{MergePullRequestOption, MergePullRequestOptionDo}; use crate::coordinator::Coordinator; -use super::{CONFIG_ORG, FORGE_HTTP, core_token, forge_http}; +use super::{CONFIG_ORG, api, core_token}; // --------------------------------------------------------------------------- // PR-based config-flow merge primitives (part of the @@ -242,35 +244,57 @@ pub async fn ff_push_to_main(repo: &str, sha: &str) -> Result<(), ForgeMergeErro } /// Mark PR `pr` as **manually merged** at `sha` (Forgejo -/// `POST …/pulls/{pr}/merge` with `Do=manually-merged`, `MergeCommitID=sha`). +/// `repo_merge_pull_request` with `Do=manually-merged`, `MergeCommitID=sha`). /// `ff_push_to_main` must have already set `main` to `sha` (Forgejo requires -/// the branch already be at the merge commit). On a non-2xx, re-reads the PR -/// head to distinguish drift (`HeadDrift`) from a generic failure (`Other`) — -/// best-effort, since the handler's pre-merge head re-read is the real gate. +/// the branch already be at the merge commit). On an API rejection, re-reads +/// the PR head to distinguish drift (`HeadDrift`) from a generic failure +/// (`Other`) — best-effort, since the handler's pre-merge head re-read is +/// the real gate. A transport failure skips the drift re-read (it couldn't +/// reach the forge either) and surfaces as `Other` directly. /// /// # Errors /// `HeadDrift` if the PR head no longer matches `sha`; `Other` otherwise. pub async fn mark_pr_merged(repo: &str, pr: u64, sha: &str) -> Result<(), ForgeMergeError> { let token = core_token() .ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?; - let url = format!("{FORGE_HTTP}/api/v1/repos/{repo}/pulls/{pr}/merge"); - let body = format!(r#"{{"Do":"manually-merged","MergeCommitID":"{sha}"}}"#); - let (status, _) = forge_http(reqwest::Method::POST, &url, &token, &body) + let (owner, name) = repo.split_once('/').ok_or_else(|| { + ForgeMergeError::Other(anyhow::anyhow!("forge repo `{repo}` is not owner/name")) + })?; + let index = i64::try_from(pr) + .map_err(|_| ForgeMergeError::Other(anyhow::anyhow!("PR index {pr} overflows i64")))?; + let body = MergePullRequestOption { + r#do: MergePullRequestOptionDo::ManuallyMerged, + merge_commit_id: Some(sha.to_owned()), + merge_message_field: None, + merge_title_field: None, + delete_branch_after_merge: None, + force_merge: None, + head_commit_id: None, + merge_when_checks_succeed: None, + }; + let client = api(&token).map_err(ForgeMergeError::Other)?; + match client + .repo_merge_pull_request(owner, name, index, body) .await - .context("POST pulls//merge (manually-merged)")?; - if status.is_success() { - return Ok(()); - } - // Best-effort drift detection: if the live head no longer matches `sha`, - // that's a head-drift race; otherwise surface as a hard failure. - match pr_head_sha(repo, pr).await { - Ok(actual) if actual != sha => Err(ForgeMergeError::HeadDrift { - expected: sha.to_string(), - actual, - }), - _ => Err(ForgeMergeError::Other(anyhow::anyhow!( - "mark PR #{pr} in {repo} manually-merged at {sha} failed: HTTP {status}" - ))), + { + Ok(()) => Ok(()), + Err(ForgejoError::ReqwestError(e)) => Err(ForgeMergeError::Other( + anyhow::Error::from(e).context("POST pulls//merge (manually-merged)"), + )), + Err(e) => { + // Best-effort drift detection: if the live head no longer matches + // `sha`, that's a head-drift race; otherwise surface as a hard + // failure. + match pr_head_sha(repo, pr).await { + Ok(actual) if actual != sha => Err(ForgeMergeError::HeadDrift { + expected: sha.to_string(), + actual, + }), + _ => Err(ForgeMergeError::Other(anyhow::Error::from(e).context( + format!("mark PR #{pr} in {repo} manually-merged at {sha}"), + ))), + } + } } } diff --git a/hive-c0re/src/forge/repos.rs b/hive-c0re/src/forge/repos.rs index d5fce8f5..215e4e25 100644 --- a/hive-c0re/src/forge/repos.rs +++ b/hive-c0re/src/forge/repos.rs @@ -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 `/` — purely for log + error +/// context. +fn created_or_exists(res: Result, label: &str) -> Result<()> { + match res { + Ok(_) => { + tracing::info!(%label, "forge: created repo"); + Ok(()) + } + Err(e) if is_already_exists(&e) => { + tracing::debug!(%label, "forge: repo already exists"); + Ok(()) + } + Err(e) => Err(e).with_context(|| format!("create repo {label}")), + } } /// Set an existing repo to public visibility. No-op if the repo is /// already public. Used for `internal/knowledge` which may have been /// created as private on an older deployment. async fn set_repo_public(owner: &str, repo: &str, token: &str) -> Result<()> { - let 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 `/` — 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/`). /// 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 { 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}")) diff --git a/hive-c0re/src/forge/users.rs b/hive-c0re/src/forge/users.rs index 38bc382b..4d6c20f2 100644 --- a/hive-c0re/src/forge/users.rs +++ b/hive-c0re/src/forge/users.rs @@ -1,15 +1,18 @@ //! Per-agent Forgejo user + access-token provisioning, account //! policy (email alignment, repo-creation lockdown), avatar uploads, -//! and the bootstrap `core` admin user + token lifecycle. Shared -//! HTTP / `forgejo admin` helpers live in the module root (`super`). +//! and the bootstrap `core` admin user + token lifecycle. The typed +//! API-client constructor + `forgejo admin` helpers live in the +//! module root (`super`). use std::path::Path; use anyhow::{Context, Result}; use base64::Engine; +use forgejo_api::structs::{EditUserOption, UpdateUserAvatarOption}; +use forgejo_api::{ApiErrorKind, ForgejoError}; use reqwest::StatusCode; -use super::{CONFIG_ORG, FORGE_HTTP, forge_admin, forge_http, is_present}; +use super::{CONFIG_ORG, api, forge_admin, is_present}; const TOKEN_NAME_PREFIX: &str = "hyperhive"; /// Where the host-side `core` admin token lives. Used by hive-c0re @@ -55,6 +58,49 @@ fn agent_email(name: &str) -> String { format!("{name}@hyperhive.local") } +/// `EditUserOption` with every field unset except the ones Forgejo's +/// validator effectively requires: `login_name` and `source_id = 0` +/// (local auth, the default for users hive-c0re creates). Omitting +/// `login_name` made Forgejo reset `use_custom_avatar` on every admin +/// edit — the same reason the old raw JSON bodies always carried both +/// fields. Callers set only the field(s) they mean to change on top. +fn sparse_edit_user_option(name: &str) -> EditUserOption { + EditUserOption { + active: None, + admin: None, + allow_create_organization: None, + allow_git_hook: None, + allow_import_local: None, + description: None, + email: None, + full_name: None, + hide_email: None, + location: None, + login_name: Some(name.to_owned()), + max_repo_creation: None, + must_change_password: None, + password: None, + prohibit_login: None, + pronouns: None, + restricted: None, + source_id: Some(0), + visibility: None, + website: None, + } +} + +/// Whether a Forgejo API error is a definitive 403. The typed client +/// maps a 403 response to `ApiErrorKind::Forbidden` when the endpoint +/// spec lists it, or to `UnexpectedStatusCode(403)` otherwise — check +/// both defensively. +fn is_forbidden(e: &ForgejoError) -> bool { + match e { + ForgejoError::ApiError(api) => matches!(api.error_kind(), ApiErrorKind::Forbidden), + ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::FORBIDDEN, + _ => false, + } +} + /// Ensure a forgejo user named `name` exists. Idempotent: forgejo /// returns a "user already exists" error which we treat as success. /// `admin` adds `--admin` (site admin) — used for the bootstrap @@ -129,11 +175,11 @@ async fn change_user_password(name: &str, password: &str) -> Result<()> { /// Forgejo's admin-user-edit endpoint from resetting `use_custom_avatar` /// on every `sync_agent` tick. Delete the marker to force re-alignment. /// -/// Uses the admin REST API (`PATCH /api/v1/admin/users/{name}`) rather -/// than `forgejo admin user edit` because the CLI dropped the `edit` -/// subcommand somewhere between forgejo 8 and current. Body includes -/// `login_name` (required by Forgejo's `EditUserOption` validator) and -/// `source_id = 0` (local auth, the default for users hive-c0re creates). +/// Uses the admin REST API (`admin_edit_user`, i.e. `PATCH +/// /api/v1/admin/users/{name}`) rather than `forgejo admin user edit` +/// because the CLI dropped the `edit` subcommand somewhere between +/// forgejo 8 and current. The edit body carries `login_name` + +/// `source_id = 0` via [`sparse_edit_user_option`]. pub(super) async fn ensure_user_email(name: &str) { let marker = crate::paths::forge_email_aligned_marker(name); if marker.exists() { @@ -144,31 +190,33 @@ pub(super) async fn ensure_user_email(name: &str) { return; }; let email = agent_email(name); - // `login_name` is required by Forgejo's EditUserOption validator. - // Omitting it caused Forgejo to reset use_custom_avatar on each call. - let body = format!(r#"{{"email":"{email}","login_name":"{name}","source_id":0}}"#); - let url = format!("{FORGE_HTTP}/api/v1/admin/users/{name}"); - match forge_http(reqwest::Method::PATCH, &url, &token, &body).await { - Ok((status, _)) if status.is_success() => { + let mut edit = sparse_edit_user_option(name); + edit.email = Some(email.clone()); + let client = match api(&token) { + Ok(c) => c, + Err(e) => { + tracing::warn!(%name, error = %e, "forge: PATCH user email: client build failed"); + return; + } + }; + match client.admin_edit_user(name, edit).await { + Ok(_) => { if let Some(parent) = marker.parent() { std::fs::create_dir_all(parent).ok(); } std::fs::write(&marker, "").ok(); tracing::info!(%name, %email, "forge: user email aligned"); } - Ok((status, _)) if status == reqwest::StatusCode::FORBIDDEN => { + Err(e) if is_forbidden(&e) => { // Core token missing admin scope — see // `docs/forge.md::Token scopes` migration note. tracing::warn!( - %name, %email, %status, + %name, %email, error = %e, "forge: PATCH user email forbidden — core token likely missing admin scope. \ Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes." ); } - Ok((status, _)) => { - tracing::warn!(%name, %email, %status, "forge: PATCH user email returned non-success"); - } - Err(e) => tracing::warn!(%name, error = %e, "forge: PATCH user email transport error"), + Err(e) => tracing::warn!(%name, %email, error = %e, "forge: PATCH user email failed"), } } @@ -183,11 +231,12 @@ pub(super) async fn ensure_user_email(name: &str) { /// so creation is refused while push / PR / clone stay intact. **Existing /// repos are untouched** — this only blocks *new* direct creation. /// -/// Marker-guarded like [`ensure_user_email`]: the PATCH runs once per -/// agent (delete the marker to re-apply). Body carries `login_name` + -/// `source_id` for the same reason `ensure_user_email` does — omitting -/// `login_name` makes Forgejo's `EditUserOption` validator reset -/// `use_custom_avatar`. Best-effort: failures warn, don't propagate. +/// Marker-guarded like [`ensure_user_email`]: the edit runs once per +/// agent (delete the marker to re-apply). The edit body carries +/// `login_name` + `source_id` for the same reason `ensure_user_email` +/// does — omitting `login_name` makes Forgejo's `EditUserOption` +/// validator reset `use_custom_avatar`. Best-effort: failures warn, +/// don't propagate. pub(super) async fn ensure_repo_creation_disabled(name: &str) { let marker = crate::paths::forge_repo_creation_disabled_marker(name); if marker.exists() { @@ -197,28 +246,32 @@ pub(super) async fn ensure_repo_creation_disabled(name: &str) { tracing::debug!(%name, "forge: skipping ensure_repo_creation_disabled — no core token yet"); return; }; - let body = format!(r#"{{"login_name":"{name}","source_id":0,"max_repo_creation":0}}"#); - let url = format!("{FORGE_HTTP}/api/v1/admin/users/{name}"); - match forge_http(reqwest::Method::PATCH, &url, &token, &body).await { - Ok((status, _)) if status.is_success() => { + let mut edit = sparse_edit_user_option(name); + edit.max_repo_creation = Some(0); + let client = match api(&token) { + Ok(c) => c, + Err(e) => { + tracing::warn!(%name, error = %e, "forge: PATCH max_repo_creation: client build failed"); + return; + } + }; + match client.admin_edit_user(name, edit).await { + Ok(_) => { if let Some(parent) = marker.parent() { std::fs::create_dir_all(parent).ok(); } std::fs::write(&marker, "").ok(); tracing::info!(%name, "forge: disabled direct repo creation (max_repo_creation=0)"); } - Ok((status, _)) if status == reqwest::StatusCode::FORBIDDEN => { + Err(e) if is_forbidden(&e) => { tracing::warn!( - %name, %status, + %name, error = %e, "forge: PATCH max_repo_creation forbidden — core token likely missing admin scope. \ Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes." ); } - Ok((status, _)) => { - tracing::warn!(%name, %status, "forge: PATCH max_repo_creation returned non-success"); - } Err(e) => { - tracing::warn!(%name, error = %e, "forge: PATCH max_repo_creation transport error"); + tracing::warn!(%name, error = %e, "forge: PATCH max_repo_creation failed"); } } } @@ -337,15 +390,17 @@ pub(super) async fn ensure_core_avatar(token: &str) -> Result<()> { let png_bytes = tokio::fs::read(&png_path) .await .with_context(|| format!("read core avatar PNG from {}", png_path.display()))?; - let body = format!( - r#"{{"image":"{}"}}"#, - base64::engine::general_purpose::STANDARD.encode(&png_bytes), - ); - let url = format!("{FORGE_HTTP}/api/v1/admin/users/core/avatar"); - let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?; - if !status.is_success() { - anyhow::bail!("set core avatar: HTTP {status}"); - } + // The raw-HTTP predecessor POSTed the admin endpoint + // `/admin/users/core/avatar`, which forgejo-api has no method for. + // `token` IS the core user's own token though, so updating "the + // current user's avatar" (`POST /user/avatar`) is behaviorally + // identical. + api(token)? + .user_update_avatar(UpdateUserAvatarOption { + image: Some(base64::engine::general_purpose::STANDARD.encode(&png_bytes)), + }) + .await + .context("set core avatar")?; if let Some(parent) = marker.parent() { std::fs::create_dir_all(parent).ok(); } @@ -356,9 +411,8 @@ pub(super) async fn ensure_core_avatar(token: &str) -> Result<()> { /// Set the `agent-configs` org's Forgejo avatar to the /// configs-stack glyph once. Sibling to `ensure_core_avatar`: -/// one-shot, marker-guarded, best-effort. Forgejo's per-org avatar -/// endpoint is `POST /api/v1/orgs/{org}/avatar` with a base64-PNG -/// JSON body — same shape as the admin user endpoint above. +/// one-shot, marker-guarded, best-effort. Uses `org_update_avatar` +/// (`POST /api/v1/orgs/{org}/avatar`, base64-PNG payload). pub(super) async fn ensure_config_org_avatar(token: &str) -> Result<()> { let marker = crate::paths::forge_config_org_avatar_marker(); if marker.exists() { @@ -368,15 +422,15 @@ pub(super) async fn ensure_config_org_avatar(token: &str) -> Result<()> { let png_bytes = tokio::fs::read(&png_path) .await .with_context(|| format!("read {CONFIG_ORG} avatar PNG from {}", png_path.display()))?; - let body = format!( - r#"{{"image":"{}"}}"#, - base64::engine::general_purpose::STANDARD.encode(&png_bytes), - ); - let url = format!("{FORGE_HTTP}/api/v1/orgs/{CONFIG_ORG}/avatar"); - let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?; - if !status.is_success() { - anyhow::bail!("set {CONFIG_ORG} avatar: HTTP {status}"); - } + api(token)? + .org_update_avatar( + CONFIG_ORG, + UpdateUserAvatarOption { + image: Some(base64::engine::general_purpose::STANDARD.encode(&png_bytes)), + }, + ) + .await + .with_context(|| format!("set {CONFIG_ORG} avatar"))?; if let Some(parent) = marker.parent() { std::fs::create_dir_all(parent).ok(); } @@ -407,32 +461,47 @@ enum CoreTokenCheck { Indeterminate, } -/// Map the HTTP status of the token-probe call to a [`CoreTokenCheck`]. -/// Pure so the decision logic is unit-testable without a live forge. -fn classify_core_token_status(status: StatusCode) -> CoreTokenCheck { - if status.is_success() { - CoreTokenCheck::Valid - } else if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN { - CoreTokenCheck::Invalid - } else { - CoreTokenCheck::Indeterminate +/// Map a failed token-probe call to a [`CoreTokenCheck`]. Only a +/// definitive auth rejection (401/403 — surfaced by the typed client +/// as `Unauthorized`/`Forbidden` API errors, or defensively as bare +/// `UnexpectedStatusCode`s) is `Invalid`; anything else (transport, +/// 5xx, unexpected shapes) is `Indeterminate`. Pure so the decision +/// logic is unit-testable without a live forge. +fn classify_core_token_error(e: &ForgejoError) -> CoreTokenCheck { + match e { + ForgejoError::ApiError(api) => match api.error_kind() { + ApiErrorKind::Unauthorized | ApiErrorKind::Forbidden => CoreTokenCheck::Invalid, + _ => CoreTokenCheck::Indeterminate, + }, + ForgejoError::UnexpectedStatusCode(s) + if *s == StatusCode::UNAUTHORIZED || *s == StatusCode::FORBIDDEN => + { + CoreTokenCheck::Invalid + } + _ => CoreTokenCheck::Indeterminate, } } /// Probe whether `token` is still accepted by the current forge with a -/// cheap authenticated `GET /api/v1/user` (covered by the core token's -/// `read:user` scope). See [`CoreTokenCheck`] for how the outcome is -/// interpreted. +/// cheap authenticated `user_get_current` (`GET /api/v1/user`, covered +/// by the core token's `read:user` scope). See [`CoreTokenCheck`] for +/// how the outcome is interpreted. async fn check_core_token(token: &str) -> CoreTokenCheck { - let url = format!("{FORGE_HTTP}/api/v1/user"); - match forge_http(reqwest::Method::GET, &url, token, "").await { - Ok((status, _)) => classify_core_token_status(status), + let Ok(client) = api(token) else { + return CoreTokenCheck::Indeterminate; + }; + match client.user_get_current().await { + Ok(_) => CoreTokenCheck::Valid, Err(e) => { - tracing::debug!( - error = %e, - "forge: core-token probe could not reach forge; treating as indeterminate" - ); - CoreTokenCheck::Indeterminate + let outcome = classify_core_token_error(&e); + if outcome == CoreTokenCheck::Indeterminate { + tracing::debug!( + error = %e, + "forge: core-token probe inconclusive (unreachable / unexpected response); \ + treating as indeterminate" + ); + } + outcome } } } @@ -484,37 +553,42 @@ pub fn core_token() -> Option { #[cfg(test)] mod tests { - use super::{CoreTokenCheck, classify_core_token_status}; + use super::{CoreTokenCheck, classify_core_token_error}; + use forgejo_api::{ApiError, ApiErrorKind, ForgejoError}; use reqwest::StatusCode; - #[test] - fn success_statuses_are_valid() { - assert_eq!( - classify_core_token_status(StatusCode::OK), - CoreTokenCheck::Valid - ); - assert_eq!( - classify_core_token_status(StatusCode::NO_CONTENT), - CoreTokenCheck::Valid - ); + fn api_err(kind: ApiErrorKind) -> ForgejoError { + ForgejoError::ApiError(ApiError { + message: None, + kind, + }) } #[test] - fn auth_rejection_statuses_are_invalid() { + fn auth_rejection_errors_are_invalid() { // The whole point: a stale token (forge rebuilt out from under it) // 401s, and 401/403 are the only outcomes that trigger a re-mint. assert_eq!( - classify_core_token_status(StatusCode::UNAUTHORIZED), + classify_core_token_error(&api_err(ApiErrorKind::Unauthorized)), CoreTokenCheck::Invalid ); assert_eq!( - classify_core_token_status(StatusCode::FORBIDDEN), + classify_core_token_error(&api_err(ApiErrorKind::Forbidden)), CoreTokenCheck::Invalid ); + // Defensive: the same statuses arriving as bare status codes + // (endpoint spec didn't list them) must classify identically. + for s in [StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN] { + assert_eq!( + classify_core_token_error(&ForgejoError::UnexpectedStatusCode(s)), + CoreTokenCheck::Invalid, + "status {s} should be invalid" + ); + } } #[test] - fn transient_and_unexpected_statuses_are_indeterminate() { + fn transient_and_unexpected_errors_are_indeterminate() { // Never re-mint on a transient — minting needs the forge too, and // churning tokens on a blip is worse than keeping the existing one. for s in [ @@ -525,10 +599,18 @@ mod tests { StatusCode::NOT_FOUND, ] { assert_eq!( - classify_core_token_status(s), + classify_core_token_error(&ForgejoError::UnexpectedStatusCode(s)), CoreTokenCheck::Indeterminate, "status {s} should be indeterminate" ); } + assert_eq!( + classify_core_token_error(&api_err(ApiErrorKind::NotFound { errors: None })), + CoreTokenCheck::Indeterminate + ); + assert_eq!( + classify_core_token_error(&api_err(ApiErrorKind::Generic)), + CoreTokenCheck::Indeterminate + ); } } diff --git a/hive-c0re/src/workers/knowledge.rs b/hive-c0re/src/workers/knowledge.rs index a99be93b..7eef8455 100644 --- a/hive-c0re/src/workers/knowledge.rs +++ b/hive-c0re/src/workers/knowledge.rs @@ -11,7 +11,10 @@ //! webhook is auto-created by [`ensure_webhook`] at startup. A //! periodic pull in `main.rs` provides a fallback cadence. +use std::collections::BTreeMap; + use anyhow::{Context, Result}; +use forgejo_api::structs::{CreateHookOption, CreateHookOptionConfig, CreateHookOptionType}; pub const ORG: &str = "internal"; pub const REPO: &str = "knowledge"; @@ -146,65 +149,49 @@ async fn seed_readme(core_token: &str) -> Result<()> { /// core token is absent (forge not yet provisioned). pub async fn ensure_webhook(core_token: &str, dashboard_port: u16) -> Result<()> { let target_url = format!("http://127.0.0.1:{dashboard_port}/webhook/knowledge"); - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - .context("build reqwest client for webhook setup")?; + let client = crate::forge::api(core_token)?; // List existing hooks — skip creation if ours is already there. - let list_url = format!( - "{}/api/v1/repos/{ORG}/{REPO}/hooks", - crate::forge::FORGE_HTTP - ); - let resp = client - .get(&list_url) - .header("Authorization", format!("token {core_token}")) - .send() - .await - .with_context(|| format!("GET {list_url}"))?; - if resp.status().is_success() { - let hooks: Vec = resp.json().await.unwrap_or_default(); - let already_exists = hooks.iter().any(|h| { - h.get("config") - .and_then(|c| c.get("url")) - .and_then(|u| u.as_str()) - == Some(&target_url) - }); - if already_exists { - tracing::debug!(%target_url, "knowledge: push webhook already configured"); - return Ok(()); + // Best-effort like the raw-HTTP predecessor: a listing failure + // falls through to the create attempt. + match client.repo_list_hooks(ORG, REPO).all().await { + Ok(hooks) => { + let already_exists = hooks.iter().any(|h| { + h.config + .as_ref() + .and_then(|c| c.get("url")) + .map(String::as_str) + == Some(target_url.as_str()) + }); + if already_exists { + tracing::debug!(%target_url, "knowledge: push webhook already configured"); + return Ok(()); + } + } + Err(e) => { + tracing::debug!(error = %e, "knowledge: listing hooks failed; attempting create"); } } // Create the webhook. - let create_url = format!( - "{}/api/v1/repos/{ORG}/{REPO}/hooks", - crate::forge::FORGE_HTTP - ); - let body = serde_json::json!({ - "type": "forgejo", - "config": { - "url": target_url, - "content_type": "json" + let hook = CreateHookOption { + active: Some(true), + authorization_header: None, + branch_filter: None, + config: CreateHookOptionConfig { + content_type: "json".to_owned(), + url: url::Url::parse(&target_url).context("parse webhook target url")?, + additional: BTreeMap::new(), }, - "events": ["push"], - "active": true - }); - let resp = client - .post(&create_url) - .header("Authorization", format!("token {core_token}")) - .json(&body) - .send() + events: Some(vec!["push".to_owned()]), + r#type: CreateHookOptionType::Forgejo, + }; + client + .repo_create_hook(ORG, REPO, hook) .await - .with_context(|| format!("POST {create_url}"))?; - let status = resp.status(); - if status.is_success() { - tracing::info!(%target_url, "knowledge: push webhook created"); - Ok(()) - } else { - let body = resp.text().await.unwrap_or_default(); - anyhow::bail!("create webhook for {ORG}/{REPO} failed ({status}): {body}") - } + .with_context(|| format!("create webhook for {ORG}/{REPO}"))?; + tracing::info!(%target_url, "knowledge: push webhook created"); + Ok(()) } /// Pull the latest changes in the local clone. Called from the webhook