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
|
|
@ -18,8 +18,11 @@ pub use repos::{
|
||||||
};
|
};
|
||||||
pub use users::{core_token, ensure_user_for, provision_user_token};
|
pub use users::{core_token, ensure_user_for, provision_user_token};
|
||||||
|
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
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 repos::{ensure_mirrors, ensure_operators_team, ensure_org};
|
||||||
use users::{
|
use users::{
|
||||||
|
|
@ -105,34 +108,20 @@ async fn forge_admin(args: &[&str]) -> Result<String> {
|
||||||
Ok(stdout)
|
Ok(stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Thin Forgejo REST helper. Sends `method` to `url` with a JSON body
|
/// Typed Forgejo API client for the local forge ([`FORGE_HTTP`]),
|
||||||
/// and `Authorization: token <token>`, returns the HTTP status code.
|
/// authenticated as `token`. All Forgejo API calls that don't shell
|
||||||
/// All Forgejo API calls that don't shell out to `forgejo admin` go
|
/// out to `forgejo admin` go through clients built here — one place
|
||||||
/// through here — one place for auth header, content-type, error
|
/// for the base URL and auth. Tokens differ per call site (core admin
|
||||||
/// propagation, and the shared reqwest Client.
|
/// token vs per-agent tokens), so the token is passed per call; the
|
||||||
/// Returns the response status **and body**. The body lets callers log
|
/// base URL is parsed once. Failures surface as
|
||||||
/// *why* Forgejo rejected a request (e.g. the validation message on a
|
/// `forgejo_api::ForgejoError`, whose Display carries the HTTP status
|
||||||
/// 422); status-only callers just bind `(status, _)`. Body read is
|
/// and the API's error message (e.g. the validation reason on a 422).
|
||||||
/// best-effort — a read error yields an empty string rather than
|
pub(crate) fn api(token: &str) -> Result<Forgejo> {
|
||||||
/// failing the whole call.
|
static URL: OnceLock<Url> = OnceLock::new();
|
||||||
async fn forge_http(
|
let url = URL
|
||||||
method: reqwest::Method,
|
.get_or_init(|| Url::parse(FORGE_HTTP).expect("FORGE_HTTP is a valid URL"))
|
||||||
url: &str,
|
.clone();
|
||||||
token: &str,
|
Forgejo::new(Auth::Token(token), url).context("build forgejo api client")
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `ns` is a hive-managed Forgejo namespace that agent-initiated
|
/// Whether `ns` is a hive-managed Forgejo namespace that agent-initiated
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@
|
||||||
//! boundary; moved verbatim from the `forge` module root.
|
//! boundary; moved verbatim from the `forge` module root.
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
use forgejo_api::ForgejoError;
|
||||||
|
use forgejo_api::structs::{MergePullRequestOption, MergePullRequestOptionDo};
|
||||||
|
|
||||||
use crate::coordinator::Coordinator;
|
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
|
// 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
|
/// 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
|
/// `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
|
/// the branch already be at the merge commit). On an API rejection, re-reads
|
||||||
/// head to distinguish drift (`HeadDrift`) from a generic failure (`Other`) —
|
/// the PR head to distinguish drift (`HeadDrift`) from a generic failure
|
||||||
/// best-effort, since the handler's pre-merge head re-read is the real gate.
|
/// (`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
|
/// # Errors
|
||||||
/// `HeadDrift` if the PR head no longer matches `sha`; `Other` otherwise.
|
/// `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> {
|
pub async fn mark_pr_merged(repo: &str, pr: u64, sha: &str) -> Result<(), ForgeMergeError> {
|
||||||
let token = core_token()
|
let token = core_token()
|
||||||
.ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?;
|
.ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?;
|
||||||
let url = format!("{FORGE_HTTP}/api/v1/repos/{repo}/pulls/{pr}/merge");
|
let (owner, name) = repo.split_once('/').ok_or_else(|| {
|
||||||
let body = format!(r#"{{"Do":"manually-merged","MergeCommitID":"{sha}"}}"#);
|
ForgeMergeError::Other(anyhow::anyhow!("forge repo `{repo}` is not owner/name"))
|
||||||
let (status, _) = forge_http(reqwest::Method::POST, &url, &token, &body)
|
})?;
|
||||||
|
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
|
.await
|
||||||
.context("POST pulls/<pr>/merge (manually-merged)")?;
|
{
|
||||||
if status.is_success() {
|
Ok(()) => Ok(()),
|
||||||
return Ok(());
|
Err(ForgejoError::ReqwestError(e)) => Err(ForgeMergeError::Other(
|
||||||
}
|
anyhow::Error::from(e).context("POST pulls/<pr>/merge (manually-merged)"),
|
||||||
// Best-effort drift detection: if the live head no longer matches `sha`,
|
)),
|
||||||
// that's a head-drift race; otherwise surface as a hard failure.
|
Err(e) => {
|
||||||
match pr_head_sha(repo, pr).await {
|
// Best-effort drift detection: if the live head no longer matches
|
||||||
Ok(actual) if actual != sha => Err(ForgeMergeError::HeadDrift {
|
// `sha`, that's a head-drift race; otherwise surface as a hard
|
||||||
expected: sha.to_string(),
|
// failure.
|
||||||
actual,
|
match pr_head_sha(repo, pr).await {
|
||||||
}),
|
Ok(actual) if actual != sha => Err(ForgeMergeError::HeadDrift {
|
||||||
_ => Err(ForgeMergeError::Other(anyhow::anyhow!(
|
expected: sha.to_string(),
|
||||||
"mark PR #{pr} in {repo} manually-merged at {sha} failed: HTTP {status}"
|
actual,
|
||||||
))),
|
}),
|
||||||
|
_ => Err(ForgeMergeError::Other(anyhow::Error::from(e).context(
|
||||||
|
format!("mark PR #{pr} in {repo} manually-merged at {sha}"),
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,99 +1,178 @@
|
||||||
//! Repo + org plumbing on the local Forgejo: org / repo creation,
|
//! Repo + org plumbing on the local Forgejo: org / repo creation,
|
||||||
//! the meta + shared-docs + knowledge repos, per-agent config-repo
|
//! the meta + shared-docs + knowledge repos, per-agent config-repo
|
||||||
//! mirroring (`push_config` / `push_meta`), collaborator grants,
|
//! mirroring (`push_config` / `push_meta`), collaborator grants,
|
||||||
//! pull-mirrors, and branch-protection rules. Shared HTTP helpers +
|
//! pull-mirrors, and branch-protection rules. The typed API-client
|
||||||
//! org-name constants live in the module root (`super`).
|
//! constructor + org-name constants live in the module root (`super`).
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
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 tokio::process::Command;
|
||||||
|
|
||||||
use crate::coordinator::Coordinator;
|
use crate::coordinator::Coordinator;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
AGENTS_ORG, CONFIG_ORG, FORGE_HTTP, KNOWLEDGE_REPO, OPERATORS_TEAM, SHARED_DOCS_REPO,
|
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`.
|
/// Creation options for an empty repo defaulting to `main`.
|
||||||
fn repo_body(name: &str) -> String {
|
fn repo_option(name: &str, private: bool) -> CreateRepoOption {
|
||||||
format!(r#"{{"name":"{name}","auto_init":false,"private":true,"default_branch":"main"}}"#)
|
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`.
|
/// `EditRepoOption` with every field unset — repo edits only ever
|
||||||
fn repo_body_public(name: &str) -> String {
|
/// change the one field the caller sets on top (Forgejo leaves `None`
|
||||||
format!(r#"{{"name":"{name}","auto_init":false,"private":false,"default_branch":"main"}}"#)
|
/// 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
|
/// Set an existing repo to public visibility. No-op if the repo is
|
||||||
/// already public. Used for `internal/knowledge` which may have been
|
/// already public. Used for `internal/knowledge` which may have been
|
||||||
/// created as private on an older deployment.
|
/// created as private on an older deployment.
|
||||||
async fn set_repo_public(owner: &str, repo: &str, token: &str) -> Result<()> {
|
async fn set_repo_public(owner: &str, repo: &str, token: &str) -> Result<()> {
|
||||||
let url = format!("{FORGE_HTTP}/api/v1/repos/{owner}/{repo}");
|
let mut edit = sparse_edit_repo_option();
|
||||||
let (status, _) =
|
edit.private = Some(false);
|
||||||
forge_http(reqwest::Method::PATCH, &url, token, r#"{"private":false}"#).await?;
|
api(token)?
|
||||||
match status.as_u16() {
|
.repo_edit(owner, repo, edit)
|
||||||
200 => {
|
.await
|
||||||
tracing::debug!(%owner, %repo, "forge: repo set to public");
|
.with_context(|| format!("edit {owner}/{repo} (set public)"))?;
|
||||||
Ok(())
|
tracing::debug!(%owner, %repo, "forge: repo set to public");
|
||||||
}
|
Ok(())
|
||||||
other => anyhow::bail!("PATCH {owner}/{repo} (set public) returned HTTP {other}"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create `name` inside org `org` as a public repo. Idempotent.
|
/// Create `name` inside org `org` as a public repo. Idempotent.
|
||||||
async fn ensure_org_repo_public(org: &str, name: &str, token: &str) -> Result<()> {
|
async fn ensure_org_repo_public(org: &str, name: &str, token: &str) -> Result<()> {
|
||||||
create_repo(
|
let res = api(token)?
|
||||||
&format!("{FORGE_HTTP}/api/v1/orgs/{org}/repos"),
|
.create_org_repo(org, repo_option(name, false))
|
||||||
&repo_body_public(name),
|
.await;
|
||||||
token,
|
created_or_exists(res, &format!("{org}/{name}"))
|
||||||
&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}"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a repo in the token-owner's own namespace. `token` belongs
|
/// 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
|
/// to the user we want the repo owned by (we use `core`'s token for
|
||||||
/// `core/meta`). Idempotent.
|
/// `core/meta`). Idempotent.
|
||||||
pub async fn ensure_repo(name: &str, token: &str) -> Result<()> {
|
pub async fn ensure_repo(name: &str, token: &str) -> Result<()> {
|
||||||
create_repo(
|
let res = api(token)?
|
||||||
&format!("{FORGE_HTTP}/api/v1/user/repos"),
|
.create_current_user_repo(repo_option(name, true))
|
||||||
&repo_body(name),
|
.await;
|
||||||
token,
|
created_or_exists(res, &format!("core/{name}"))
|
||||||
&format!("core/{name}"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create `name` inside org `org` (used for `agent-configs/<agent>`).
|
/// Create `name` inside org `org` (used for `agent-configs/<agent>`).
|
||||||
/// Idempotent.
|
/// Idempotent.
|
||||||
async fn ensure_org_repo(org: &str, name: &str, token: &str) -> Result<()> {
|
async fn ensure_org_repo(org: &str, name: &str, token: &str) -> Result<()> {
|
||||||
create_repo(
|
let res = api(token)?
|
||||||
&format!("{FORGE_HTTP}/api/v1/orgs/{org}/repos"),
|
.create_org_repo(org, repo_option(name, true))
|
||||||
&repo_body(name),
|
.await;
|
||||||
token,
|
created_or_exists(res, &format!("{org}/{name}"))
|
||||||
&format!("{org}/{name}"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push `dir` (the meta repo) to `core/meta` on the local forge.
|
/// 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?;
|
ensure_org_repo(CONFIG_ORG, name, &token).await?;
|
||||||
// Agent = write collaborator: it can push config-PR branches + open PRs,
|
// Agent = write collaborator: it can push config-PR branches + open PRs,
|
||||||
// but the branch protection below keeps it off `main` directly.
|
// 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).
|
// Protect `main` core-only, fast-forward-only (no auto force-push).
|
||||||
apply_config_repo_branch_protection(name, &token).await
|
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`.
|
/// Grant agent `name` read-only collaborator access to `internal/docs`.
|
||||||
/// Idempotent: HTTP 204 (already a collaborator) is treated as success.
|
/// Idempotent: re-adding an existing collaborator succeeds (Forgejo
|
||||||
/// Mirrors `meta_read_access` so agents can clone the shared docs repo
|
/// answers 204 either way). Mirrors `meta_read_access` so agents can
|
||||||
/// without authentication hassle.
|
/// clone the shared docs repo without authentication hassle.
|
||||||
pub async fn shared_docs_access(name: &str, core_token: &str) -> Result<()> {
|
pub async fn shared_docs_access(name: &str, core_token: &str) -> Result<()> {
|
||||||
let url =
|
add_collaborator(
|
||||||
format!("{FORGE_HTTP}/api/v1/repos/{SHARED_ORG}/{SHARED_DOCS_REPO}/collaborators/{name}");
|
SHARED_ORG,
|
||||||
let body = r#"{"permission":"read"}"#;
|
SHARED_DOCS_REPO,
|
||||||
let out = Command::new("curl")
|
name,
|
||||||
.args([
|
AddCollaboratorOptionPermission::Read,
|
||||||
"-sS",
|
core_token,
|
||||||
"-o",
|
)
|
||||||
"/dev/null",
|
.await?;
|
||||||
"-w",
|
tracing::info!(%name, "forge: granted shared-docs read access");
|
||||||
"%{http_code}",
|
Ok(())
|
||||||
"-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}"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure the `internal/knowledge` repo exists and is public.
|
/// 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
|
/// Grant agent `name` read-only collaborator access to `core/meta` on
|
||||||
/// the forge so the agent can clone/fetch the meta flake. Idempotent:
|
/// 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<()> {
|
pub async fn meta_read_access(name: &str, core_token: &str) -> Result<()> {
|
||||||
let url = format!("{FORGE_HTTP}/api/v1/repos/core/meta/collaborators/{name}");
|
add_collaborator(
|
||||||
let body = r#"{"permission":"read"}"#;
|
"core",
|
||||||
let out = Command::new("curl")
|
"meta",
|
||||||
.args([
|
name,
|
||||||
"-sS",
|
AddCollaboratorOptionPermission::Read,
|
||||||
"-o",
|
core_token,
|
||||||
"/dev/null",
|
)
|
||||||
"-w",
|
.await?;
|
||||||
"%{http_code}",
|
tracing::info!(%name, "forge: granted meta read access");
|
||||||
"-X",
|
Ok(())
|
||||||
"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 `http://localhost:3000/core/meta.git` as the `meta` remote in
|
/// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST `/api/v1/orgs` to create an org named `name`. Idempotent:
|
/// Create an org named `name` (`org_create`). Idempotent: HTTP 422
|
||||||
/// HTTP 422 ("user already exists") is treated as success.
|
/// ("user already exists") / 409 is treated as success.
|
||||||
pub(super) async fn ensure_org(name: &str, admin_token: &str) -> Result<()> {
|
pub(super) async fn ensure_org(name: &str, admin_token: &str) -> Result<()> {
|
||||||
let body = format!(r#"{{"username":"{name}"}}"#);
|
let org = CreateOrgOption {
|
||||||
let url = format!("{FORGE_HTTP}/api/v1/orgs");
|
description: None,
|
||||||
let (status, _) = forge_http(reqwest::Method::POST, &url, admin_token, &body).await?;
|
email: None,
|
||||||
match status.as_u16() {
|
full_name: None,
|
||||||
201 => {
|
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");
|
tracing::info!(%name, "forge: created org");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
422 | 409 => {
|
Err(e) if is_already_exists(&e) => {
|
||||||
tracing::debug!(%name, "forge: org already exists");
|
tracing::debug!(%name, "forge: org already exists");
|
||||||
Ok(())
|
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
|
/// Idempotent: if the repo already exists this function patches its
|
||||||
/// `mirror_interval` to ensure it matches (covers mirrors that were
|
/// `mirror_interval` to ensure it matches (covers mirrors that were
|
||||||
/// created before the interval was introduced). A 409 on the migrate
|
/// 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(
|
async fn ensure_mirror_repo(
|
||||||
upstream: &str,
|
upstream: &str,
|
||||||
owner: &str,
|
owner: &str,
|
||||||
repo: &str,
|
repo: &str,
|
||||||
admin_token: &str,
|
admin_token: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let repo_url = format!("{FORGE_HTTP}/api/v1/repos/{owner}/{repo}");
|
let client = api(admin_token)?;
|
||||||
let (status, _) = forge_http(reqwest::Method::GET, &repo_url, admin_token, "").await?;
|
if client.repo_get(owner, repo).await.is_ok() {
|
||||||
if status.is_success() {
|
|
||||||
// Mirror already present. Patch interval so mirrors seeded before
|
// Mirror already present. Patch interval so mirrors seeded before
|
||||||
// this field was introduced (or with a different value) converge.
|
// this field was introduced (or with a different value) converge.
|
||||||
let patch_body = serde_json::json!({ "mirror_interval": MIRROR_INTERVAL }).to_string();
|
let mut edit = sparse_edit_repo_option();
|
||||||
let (patch_status, patch_text) =
|
edit.mirror_interval = Some(MIRROR_INTERVAL.to_owned());
|
||||||
forge_http(reqwest::Method::PATCH, &repo_url, admin_token, &patch_body).await?;
|
match client.repo_edit(owner, repo, edit).await {
|
||||||
if patch_status.is_success() {
|
Ok(_) => {
|
||||||
tracing::debug!(%owner, %repo, interval = MIRROR_INTERVAL, "forge: pull-mirror interval updated");
|
tracing::debug!(%owner, %repo, interval = MIRROR_INTERVAL, "forge: pull-mirror interval updated");
|
||||||
} else {
|
}
|
||||||
tracing::warn!(
|
Err(e) => {
|
||||||
%owner, %repo, status = %patch_status, body = %patch_text,
|
tracing::warn!(
|
||||||
"forge: failed to set mirror_interval on existing pull-mirror"
|
%owner, %repo, error = %e,
|
||||||
);
|
"forge: failed to set mirror_interval on existing pull-mirror"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// serde_json::json! → the upstream URL is escaped safely (no string
|
let opts = MigrateRepoOptions {
|
||||||
// interpolation into the JSON body).
|
auth_password: None,
|
||||||
let body = serde_json::json!({
|
auth_token: None,
|
||||||
"clone_addr": upstream,
|
auth_username: None,
|
||||||
"repo_owner": owner,
|
clone_addr: upstream.to_owned(),
|
||||||
"repo_name": repo,
|
description: None,
|
||||||
"mirror": true,
|
issues: None,
|
||||||
|
labels: None,
|
||||||
|
lfs: None,
|
||||||
|
lfs_endpoint: None,
|
||||||
|
milestones: None,
|
||||||
|
mirror: Some(true),
|
||||||
// Periodic refresh instead of on-access sync — keeps CI isolated
|
// Periodic refresh instead of on-access sync — keeps CI isolated
|
||||||
// from external DNS failures at clone time.
|
// from external DNS failures at clone time.
|
||||||
"interval": MIRROR_INTERVAL,
|
mirror_interval: Some(MIRROR_INTERVAL.to_owned()),
|
||||||
"service": "git",
|
private: Some(false),
|
||||||
"private": false,
|
pull_requests: None,
|
||||||
})
|
releases: None,
|
||||||
.to_string();
|
repo_name: repo.to_owned(),
|
||||||
let url = format!("{FORGE_HTTP}/api/v1/repos/migrate");
|
repo_owner: Some(owner.to_owned()),
|
||||||
let (status, text) = forge_http(reqwest::Method::POST, &url, admin_token, &body).await?;
|
service: Some(MigrateRepoOptionsService::Git),
|
||||||
match status.as_u16() {
|
uid: None,
|
||||||
201 => {
|
wiki: None,
|
||||||
|
};
|
||||||
|
match client.repo_migrate(opts).await {
|
||||||
|
Ok(_) => {
|
||||||
tracing::info!(%owner, %repo, %upstream, interval = MIRROR_INTERVAL, "forge: created pull-mirror");
|
tracing::info!(%owner, %repo, %upstream, interval = MIRROR_INTERVAL, "forge: created pull-mirror");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
// 409 = a race created it between our GET check and here (the GET
|
// 409 = a race created it between our existence check and here (the
|
||||||
// is the real idempotency guard). NOT 422: for the migrate endpoint
|
// check is the real idempotency guard). NOT 422: for the migrate
|
||||||
// 422 is a validation error (bad clone_addr / service), so it must
|
// endpoint 422 is a validation error (bad clone_addr / service), so
|
||||||
// surface via the bail arm, not be swallowed as "already exists".
|
// it must surface via the error arm, not be swallowed as "already
|
||||||
409 => {
|
// exists" — hence `is_conflict`, not `is_already_exists`.
|
||||||
|
Err(e) if is_conflict(&e) => {
|
||||||
tracing::debug!(%owner, %repo, "forge: pull-mirror already exists (race)");
|
tracing::debug!(%owner, %repo, "forge: pull-mirror already exists (race)");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
other => {
|
Err(e) => Err(e).with_context(|| format!("migrate pull-mirror {owner}/{repo}")),
|
||||||
anyhow::bail!("POST /api/v1/repos/migrate {owner}/{repo} returned HTTP {other}: {text}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -488,48 +548,101 @@ async fn ensure_mirror_repo(
|
||||||
/// repos unprotected — operator-merged config PRs then bypassed the deploy
|
/// repos unprotected — operator-merged config PRs then bypassed the deploy
|
||||||
/// pipeline and silently didn't apply.
|
/// pipeline and silently didn't apply.
|
||||||
pub(super) async fn ensure_operators_team(org: &str, token: &str) -> Result<()> {
|
pub(super) async fn ensure_operators_team(org: &str, token: &str) -> Result<()> {
|
||||||
let url = format!("{FORGE_HTTP}/api/v1/orgs/{org}/teams");
|
let team = CreateTeamOption {
|
||||||
let body = format!(
|
can_create_org_repo: Some(false),
|
||||||
r#"{{"name":"{OPERATORS_TEAM}","description":"hyperhive operators — merge gate for agent repos","permission":"write","includes_all_repositories":true,"can_create_org_repo":false}}"#
|
description: Some("hyperhive operators — merge gate for agent repos".to_owned()),
|
||||||
);
|
includes_all_repositories: Some(true),
|
||||||
let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
name: OPERATORS_TEAM.to_owned(),
|
||||||
match status.as_u16() {
|
permission: Some(CreateTeamOptionPermission::Write),
|
||||||
201 => {
|
units: None,
|
||||||
|
units_map: None,
|
||||||
|
};
|
||||||
|
match api(token)?.org_create_team(org, team).await {
|
||||||
|
Ok(_) => {
|
||||||
tracing::info!(%org, "forge: created {OPERATORS_TEAM} team");
|
tracing::info!(%org, "forge: created {OPERATORS_TEAM} team");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
409 | 422 => {
|
Err(e) if is_already_exists(&e) => {
|
||||||
tracing::debug!(%org, "forge: {OPERATORS_TEAM} team already exists");
|
tracing::debug!(%org, "forge: {OPERATORS_TEAM} team already exists");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
other => {
|
Err(e) => Err(e).with_context(|| format!("create team {org}/{OPERATORS_TEAM}")),
|
||||||
anyhow::bail!("POST /orgs/{org}/teams ({OPERATORS_TEAM}) returned HTTP {other}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add `user` as a collaborator on `owner/repo` at `permission`
|
/// Add `user` as a collaborator on `owner/repo` at `permission`.
|
||||||
/// (`read` / `write` / `admin`). Idempotent: 201 (added) and 204 (already a
|
/// Idempotent: adding an existing collaborator just updates its
|
||||||
/// collaborator / permission updated) both count as success.
|
/// permission (Forgejo answers 204 either way; a 201 from older
|
||||||
|
/// versions is tolerated defensively).
|
||||||
async fn add_collaborator(
|
async fn add_collaborator(
|
||||||
owner: &str,
|
owner: &str,
|
||||||
repo: &str,
|
repo: &str,
|
||||||
user: &str,
|
user: &str,
|
||||||
permission: &str,
|
permission: AddCollaboratorOptionPermission,
|
||||||
token: &str,
|
token: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let url = format!("{FORGE_HTTP}/api/v1/repos/{owner}/{repo}/collaborators/{user}");
|
let res = api(token)?
|
||||||
let body = format!(r#"{{"permission":"{permission}"}}"#);
|
.repo_add_collaborator(
|
||||||
let (status, _) = forge_http(reqwest::Method::PUT, &url, token, &body).await?;
|
owner,
|
||||||
match status.as_u16() {
|
repo,
|
||||||
201 | 204 => {
|
user,
|
||||||
tracing::debug!(%owner, %repo, %user, %permission, "forge: collaborator set");
|
AddCollaboratorOption {
|
||||||
Ok(())
|
permission: Some(permission),
|
||||||
}
|
},
|
||||||
other => {
|
)
|
||||||
anyhow::bail!("PUT {owner}/{repo}/collaborators/{user} returned HTTP {other}")
|
.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
|
/// 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
|
/// agent, not in the team) cannot merge its own PR. Idempotent: an existing
|
||||||
/// rule for the branch (200/409/422) is treated as success.
|
/// rule for the branch (200/409/422) is treated as success.
|
||||||
async fn apply_operator_branch_protection(repo: &str, token: &str) -> Result<()> {
|
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 mut rule = main_branch_protection_option();
|
||||||
let body = format!(
|
rule.enable_merge_whitelist = Some(true);
|
||||||
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}}"#
|
rule.merge_whitelist_teams = Some(vec![OPERATORS_TEAM.to_owned()]);
|
||||||
);
|
rule.enable_approvals_whitelist = Some(true);
|
||||||
let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
rule.approvals_whitelist_teams = Some(vec![OPERATORS_TEAM.to_owned()]);
|
||||||
match status.as_u16() {
|
rule.required_approvals = Some(1);
|
||||||
201 => {
|
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");
|
tracing::info!(%repo, "forge: applied operator branch protection");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
200 | 409 | 422 => {
|
Err(e) if is_protection_already_present(&e) => {
|
||||||
tracing::debug!(%repo, "forge: branch protection already present");
|
tracing::debug!(%repo, "forge: branch protection already present");
|
||||||
Ok(())
|
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.
|
/// (`run_merge_config_pr`). The agent can never push `main` directly.
|
||||||
/// - **operator-team approval is required** to merge, and the author (not in
|
/// - **operator-team approval is required** to merge, and the author (not in
|
||||||
/// the team) cannot self-approve.
|
/// 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
|
/// 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
|
/// 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
|
/// 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.
|
/// mirror runs best-effort until the agent-opened PR-merge flow retires it.
|
||||||
/// (Auto force-push is intentionally not allowed: per operator directive a
|
/// (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.
|
/// Idempotent: an existing rule for the branch (200/409/422) is success.
|
||||||
async fn apply_config_repo_branch_protection(repo: &str, token: &str) -> Result<()> {
|
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 client = api(token)?;
|
||||||
let body = format!(
|
let mut rule = main_branch_protection_option();
|
||||||
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}}"#
|
rule.enable_push_whitelist = Some(true);
|
||||||
);
|
rule.push_whitelist_usernames = Some(vec!["core".to_owned()]);
|
||||||
let (status, resp_body) = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
rule.enable_merge_whitelist = Some(true);
|
||||||
if status.as_u16() == 201 {
|
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");
|
tracing::info!(%repo, "forge: applied config-repo branch protection");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
};
|
||||||
// Non-201 is ambiguous: it can mean "rule already exists" (idempotent
|
// A create failure is ambiguous: it can mean "rule already exists"
|
||||||
// success) OR a silent rejection — e.g. a 422 where Forgejo refused
|
// (idempotent success) OR a silent rejection — e.g. a 422 where Forgejo
|
||||||
// the request and created NO rule. The old code treated 200/409/422
|
// refused the request and created NO rule. The old code treated
|
||||||
// all as success, so a rejected POST left the repo unprotected with
|
// 200/409/422 all as success, so a rejected POST left the repo
|
||||||
// no error (the reported case: a new agent's config repo had no
|
// unprotected with no error (the reported case: a new agent's config
|
||||||
// `main` rule and nothing was logged). Don't trust the status code:
|
// repo had no `main` rule and nothing was logged). Don't trust the
|
||||||
// verify the `main` rule actually exists, and on failure surface the
|
// status: verify the `main` rule actually exists, and on failure
|
||||||
// POST's response body so the real reason is in the journal.
|
// surface the create error (its Display carries Forgejo's validation
|
||||||
let main_url = format!("{url}/main");
|
// message) so the real reason is in the journal.
|
||||||
let (check, _) = forge_http(reqwest::Method::GET, &main_url, token, "").await?;
|
match client
|
||||||
if check.as_u16() == 200 {
|
.repo_get_branch_protection(CONFIG_ORG, repo, "main")
|
||||||
tracing::debug!(%repo, %status, "forge: config-repo branch protection already present");
|
.await
|
||||||
Ok(())
|
{
|
||||||
} else {
|
Ok(_) => {
|
||||||
anyhow::bail!(
|
tracing::debug!(
|
||||||
"branch protection for {CONFIG_ORG}/{repo} not applied: POST -> HTTP {status} \
|
%repo, create_error = %create_err,
|
||||||
(body: {body}); GET main -> HTTP {check}, no `main` rule present",
|
"forge: config-repo branch protection already present"
|
||||||
body = resp_body.trim(),
|
);
|
||||||
)
|
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.
|
/// create repos directly (`max_repo_creation = 0`). Idempotent.
|
||||||
pub async fn create_agent_repo(agent: &str, repo: &str, core_token: &str) -> Result<String> {
|
pub async fn create_agent_repo(agent: &str, repo: &str, core_token: &str) -> Result<String> {
|
||||||
ensure_org_repo(AGENTS_ORG, repo, core_token).await?;
|
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?;
|
apply_operator_branch_protection(repo, core_token).await?;
|
||||||
tracing::info!(%agent, %repo, "forge: created agent repo in {AGENTS_ORG} with operator merge gate");
|
tracing::info!(%agent, %repo, "forge: created agent repo in {AGENTS_ORG} with operator merge gate");
|
||||||
Ok(format!("{AGENTS_ORG}/{repo}"))
|
Ok(format!("{AGENTS_ORG}/{repo}"))
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
//! Per-agent Forgejo user + access-token provisioning, account
|
//! Per-agent Forgejo user + access-token provisioning, account
|
||||||
//! policy (email alignment, repo-creation lockdown), avatar uploads,
|
//! policy (email alignment, repo-creation lockdown), avatar uploads,
|
||||||
//! and the bootstrap `core` admin user + token lifecycle. Shared
|
//! and the bootstrap `core` admin user + token lifecycle. The typed
|
||||||
//! HTTP / `forgejo admin` helpers live in the module root (`super`).
|
//! API-client constructor + `forgejo admin` helpers live in the
|
||||||
|
//! module root (`super`).
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
|
use forgejo_api::structs::{EditUserOption, UpdateUserAvatarOption};
|
||||||
|
use forgejo_api::{ApiErrorKind, ForgejoError};
|
||||||
use reqwest::StatusCode;
|
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";
|
const TOKEN_NAME_PREFIX: &str = "hyperhive";
|
||||||
/// Where the host-side `core` admin token lives. Used by hive-c0re
|
/// 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")
|
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
|
/// Ensure a forgejo user named `name` exists. Idempotent: forgejo
|
||||||
/// returns a "user already exists" error which we treat as success.
|
/// returns a "user already exists" error which we treat as success.
|
||||||
/// `admin` adds `--admin` (site admin) — used for the bootstrap
|
/// `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`
|
/// Forgejo's admin-user-edit endpoint from resetting `use_custom_avatar`
|
||||||
/// on every `sync_agent` tick. Delete the marker to force re-alignment.
|
/// on every `sync_agent` tick. Delete the marker to force re-alignment.
|
||||||
///
|
///
|
||||||
/// Uses the admin REST API (`PATCH /api/v1/admin/users/{name}`) rather
|
/// Uses the admin REST API (`admin_edit_user`, i.e. `PATCH
|
||||||
/// than `forgejo admin user edit` because the CLI dropped the `edit`
|
/// /api/v1/admin/users/{name}`) rather than `forgejo admin user edit`
|
||||||
/// subcommand somewhere between forgejo 8 and current. Body includes
|
/// because the CLI dropped the `edit` subcommand somewhere between
|
||||||
/// `login_name` (required by Forgejo's `EditUserOption` validator) and
|
/// forgejo 8 and current. The edit body carries `login_name` +
|
||||||
/// `source_id = 0` (local auth, the default for users hive-c0re creates).
|
/// `source_id = 0` via [`sparse_edit_user_option`].
|
||||||
pub(super) async fn ensure_user_email(name: &str) {
|
pub(super) async fn ensure_user_email(name: &str) {
|
||||||
let marker = crate::paths::forge_email_aligned_marker(name);
|
let marker = crate::paths::forge_email_aligned_marker(name);
|
||||||
if marker.exists() {
|
if marker.exists() {
|
||||||
|
|
@ -144,31 +190,33 @@ pub(super) async fn ensure_user_email(name: &str) {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let email = agent_email(name);
|
let email = agent_email(name);
|
||||||
// `login_name` is required by Forgejo's EditUserOption validator.
|
let mut edit = sparse_edit_user_option(name);
|
||||||
// Omitting it caused Forgejo to reset use_custom_avatar on each call.
|
edit.email = Some(email.clone());
|
||||||
let body = format!(r#"{{"email":"{email}","login_name":"{name}","source_id":0}}"#);
|
let client = match api(&token) {
|
||||||
let url = format!("{FORGE_HTTP}/api/v1/admin/users/{name}");
|
Ok(c) => c,
|
||||||
match forge_http(reqwest::Method::PATCH, &url, &token, &body).await {
|
Err(e) => {
|
||||||
Ok((status, _)) if status.is_success() => {
|
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() {
|
if let Some(parent) = marker.parent() {
|
||||||
std::fs::create_dir_all(parent).ok();
|
std::fs::create_dir_all(parent).ok();
|
||||||
}
|
}
|
||||||
std::fs::write(&marker, "").ok();
|
std::fs::write(&marker, "").ok();
|
||||||
tracing::info!(%name, %email, "forge: user email aligned");
|
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
|
// Core token missing admin scope — see
|
||||||
// `docs/forge.md::Token scopes` migration note.
|
// `docs/forge.md::Token scopes` migration note.
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
%name, %email, %status,
|
%name, %email, error = %e,
|
||||||
"forge: PATCH user email forbidden — core token likely missing admin scope. \
|
"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."
|
Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok((status, _)) => {
|
Err(e) => tracing::warn!(%name, %email, error = %e, "forge: PATCH user email failed"),
|
||||||
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"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,11 +231,12 @@ pub(super) async fn ensure_user_email(name: &str) {
|
||||||
/// so creation is refused while push / PR / clone stay intact. **Existing
|
/// so creation is refused while push / PR / clone stay intact. **Existing
|
||||||
/// repos are untouched** — this only blocks *new* direct creation.
|
/// repos are untouched** — this only blocks *new* direct creation.
|
||||||
///
|
///
|
||||||
/// Marker-guarded like [`ensure_user_email`]: the PATCH runs once per
|
/// Marker-guarded like [`ensure_user_email`]: the edit runs once per
|
||||||
/// agent (delete the marker to re-apply). Body carries `login_name` +
|
/// agent (delete the marker to re-apply). The edit body carries
|
||||||
/// `source_id` for the same reason `ensure_user_email` does — omitting
|
/// `login_name` + `source_id` for the same reason `ensure_user_email`
|
||||||
/// `login_name` makes Forgejo's `EditUserOption` validator reset
|
/// does — omitting `login_name` makes Forgejo's `EditUserOption`
|
||||||
/// `use_custom_avatar`. Best-effort: failures warn, don't propagate.
|
/// validator reset `use_custom_avatar`. Best-effort: failures warn,
|
||||||
|
/// don't propagate.
|
||||||
pub(super) async fn ensure_repo_creation_disabled(name: &str) {
|
pub(super) async fn ensure_repo_creation_disabled(name: &str) {
|
||||||
let marker = crate::paths::forge_repo_creation_disabled_marker(name);
|
let marker = crate::paths::forge_repo_creation_disabled_marker(name);
|
||||||
if marker.exists() {
|
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");
|
tracing::debug!(%name, "forge: skipping ensure_repo_creation_disabled — no core token yet");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let body = format!(r#"{{"login_name":"{name}","source_id":0,"max_repo_creation":0}}"#);
|
let mut edit = sparse_edit_user_option(name);
|
||||||
let url = format!("{FORGE_HTTP}/api/v1/admin/users/{name}");
|
edit.max_repo_creation = Some(0);
|
||||||
match forge_http(reqwest::Method::PATCH, &url, &token, &body).await {
|
let client = match api(&token) {
|
||||||
Ok((status, _)) if status.is_success() => {
|
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() {
|
if let Some(parent) = marker.parent() {
|
||||||
std::fs::create_dir_all(parent).ok();
|
std::fs::create_dir_all(parent).ok();
|
||||||
}
|
}
|
||||||
std::fs::write(&marker, "").ok();
|
std::fs::write(&marker, "").ok();
|
||||||
tracing::info!(%name, "forge: disabled direct repo creation (max_repo_creation=0)");
|
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!(
|
tracing::warn!(
|
||||||
%name, %status,
|
%name, error = %e,
|
||||||
"forge: PATCH max_repo_creation forbidden — core token likely missing admin scope. \
|
"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."
|
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) => {
|
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)
|
let png_bytes = tokio::fs::read(&png_path)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("read core avatar PNG from {}", png_path.display()))?;
|
.with_context(|| format!("read core avatar PNG from {}", png_path.display()))?;
|
||||||
let body = format!(
|
// The raw-HTTP predecessor POSTed the admin endpoint
|
||||||
r#"{{"image":"{}"}}"#,
|
// `/admin/users/core/avatar`, which forgejo-api has no method for.
|
||||||
base64::engine::general_purpose::STANDARD.encode(&png_bytes),
|
// `token` IS the core user's own token though, so updating "the
|
||||||
);
|
// current user's avatar" (`POST /user/avatar`) is behaviorally
|
||||||
let url = format!("{FORGE_HTTP}/api/v1/admin/users/core/avatar");
|
// identical.
|
||||||
let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
api(token)?
|
||||||
if !status.is_success() {
|
.user_update_avatar(UpdateUserAvatarOption {
|
||||||
anyhow::bail!("set core avatar: HTTP {status}");
|
image: Some(base64::engine::general_purpose::STANDARD.encode(&png_bytes)),
|
||||||
}
|
})
|
||||||
|
.await
|
||||||
|
.context("set core avatar")?;
|
||||||
if let Some(parent) = marker.parent() {
|
if let Some(parent) = marker.parent() {
|
||||||
std::fs::create_dir_all(parent).ok();
|
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
|
/// Set the `agent-configs` org's Forgejo avatar to the
|
||||||
/// configs-stack glyph once. Sibling to `ensure_core_avatar`:
|
/// configs-stack glyph once. Sibling to `ensure_core_avatar`:
|
||||||
/// one-shot, marker-guarded, best-effort. Forgejo's per-org avatar
|
/// one-shot, marker-guarded, best-effort. Uses `org_update_avatar`
|
||||||
/// endpoint is `POST /api/v1/orgs/{org}/avatar` with a base64-PNG
|
/// (`POST /api/v1/orgs/{org}/avatar`, base64-PNG payload).
|
||||||
/// JSON body — same shape as the admin user endpoint above.
|
|
||||||
pub(super) async fn ensure_config_org_avatar(token: &str) -> Result<()> {
|
pub(super) async fn ensure_config_org_avatar(token: &str) -> Result<()> {
|
||||||
let marker = crate::paths::forge_config_org_avatar_marker();
|
let marker = crate::paths::forge_config_org_avatar_marker();
|
||||||
if marker.exists() {
|
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)
|
let png_bytes = tokio::fs::read(&png_path)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("read {CONFIG_ORG} avatar PNG from {}", png_path.display()))?;
|
.with_context(|| format!("read {CONFIG_ORG} avatar PNG from {}", png_path.display()))?;
|
||||||
let body = format!(
|
api(token)?
|
||||||
r#"{{"image":"{}"}}"#,
|
.org_update_avatar(
|
||||||
base64::engine::general_purpose::STANDARD.encode(&png_bytes),
|
CONFIG_ORG,
|
||||||
);
|
UpdateUserAvatarOption {
|
||||||
let url = format!("{FORGE_HTTP}/api/v1/orgs/{CONFIG_ORG}/avatar");
|
image: Some(base64::engine::general_purpose::STANDARD.encode(&png_bytes)),
|
||||||
let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
},
|
||||||
if !status.is_success() {
|
)
|
||||||
anyhow::bail!("set {CONFIG_ORG} avatar: HTTP {status}");
|
.await
|
||||||
}
|
.with_context(|| format!("set {CONFIG_ORG} avatar"))?;
|
||||||
if let Some(parent) = marker.parent() {
|
if let Some(parent) = marker.parent() {
|
||||||
std::fs::create_dir_all(parent).ok();
|
std::fs::create_dir_all(parent).ok();
|
||||||
}
|
}
|
||||||
|
|
@ -407,32 +461,47 @@ enum CoreTokenCheck {
|
||||||
Indeterminate,
|
Indeterminate,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map the HTTP status of the token-probe call to a [`CoreTokenCheck`].
|
/// Map a failed token-probe call to a [`CoreTokenCheck`]. Only a
|
||||||
/// Pure so the decision logic is unit-testable without a live forge.
|
/// definitive auth rejection (401/403 — surfaced by the typed client
|
||||||
fn classify_core_token_status(status: StatusCode) -> CoreTokenCheck {
|
/// as `Unauthorized`/`Forbidden` API errors, or defensively as bare
|
||||||
if status.is_success() {
|
/// `UnexpectedStatusCode`s) is `Invalid`; anything else (transport,
|
||||||
CoreTokenCheck::Valid
|
/// 5xx, unexpected shapes) is `Indeterminate`. Pure so the decision
|
||||||
} else if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
|
/// logic is unit-testable without a live forge.
|
||||||
CoreTokenCheck::Invalid
|
fn classify_core_token_error(e: &ForgejoError) -> CoreTokenCheck {
|
||||||
} else {
|
match e {
|
||||||
CoreTokenCheck::Indeterminate
|
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
|
/// Probe whether `token` is still accepted by the current forge with a
|
||||||
/// cheap authenticated `GET /api/v1/user` (covered by the core token's
|
/// cheap authenticated `user_get_current` (`GET /api/v1/user`, covered
|
||||||
/// `read:user` scope). See [`CoreTokenCheck`] for how the outcome is
|
/// by the core token's `read:user` scope). See [`CoreTokenCheck`] for
|
||||||
/// interpreted.
|
/// how the outcome is interpreted.
|
||||||
async fn check_core_token(token: &str) -> CoreTokenCheck {
|
async fn check_core_token(token: &str) -> CoreTokenCheck {
|
||||||
let url = format!("{FORGE_HTTP}/api/v1/user");
|
let Ok(client) = api(token) else {
|
||||||
match forge_http(reqwest::Method::GET, &url, token, "").await {
|
return CoreTokenCheck::Indeterminate;
|
||||||
Ok((status, _)) => classify_core_token_status(status),
|
};
|
||||||
|
match client.user_get_current().await {
|
||||||
|
Ok(_) => CoreTokenCheck::Valid,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::debug!(
|
let outcome = classify_core_token_error(&e);
|
||||||
error = %e,
|
if outcome == CoreTokenCheck::Indeterminate {
|
||||||
"forge: core-token probe could not reach forge; treating as indeterminate"
|
tracing::debug!(
|
||||||
);
|
error = %e,
|
||||||
CoreTokenCheck::Indeterminate
|
"forge: core-token probe inconclusive (unreachable / unexpected response); \
|
||||||
|
treating as indeterminate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
outcome
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -484,37 +553,42 @@ pub fn core_token() -> Option<String> {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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;
|
use reqwest::StatusCode;
|
||||||
|
|
||||||
#[test]
|
fn api_err(kind: ApiErrorKind) -> ForgejoError {
|
||||||
fn success_statuses_are_valid() {
|
ForgejoError::ApiError(ApiError {
|
||||||
assert_eq!(
|
message: None,
|
||||||
classify_core_token_status(StatusCode::OK),
|
kind,
|
||||||
CoreTokenCheck::Valid
|
})
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
classify_core_token_status(StatusCode::NO_CONTENT),
|
|
||||||
CoreTokenCheck::Valid
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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)
|
// 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.
|
// 401s, and 401/403 are the only outcomes that trigger a re-mint.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify_core_token_status(StatusCode::UNAUTHORIZED),
|
classify_core_token_error(&api_err(ApiErrorKind::Unauthorized)),
|
||||||
CoreTokenCheck::Invalid
|
CoreTokenCheck::Invalid
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify_core_token_status(StatusCode::FORBIDDEN),
|
classify_core_token_error(&api_err(ApiErrorKind::Forbidden)),
|
||||||
CoreTokenCheck::Invalid
|
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]
|
#[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
|
// Never re-mint on a transient — minting needs the forge too, and
|
||||||
// churning tokens on a blip is worse than keeping the existing one.
|
// churning tokens on a blip is worse than keeping the existing one.
|
||||||
for s in [
|
for s in [
|
||||||
|
|
@ -525,10 +599,18 @@ mod tests {
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
] {
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify_core_token_status(s),
|
classify_core_token_error(&ForgejoError::UnexpectedStatusCode(s)),
|
||||||
CoreTokenCheck::Indeterminate,
|
CoreTokenCheck::Indeterminate,
|
||||||
"status {s} should be 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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@
|
||||||
//! webhook is auto-created by [`ensure_webhook`] at startup. A
|
//! webhook is auto-created by [`ensure_webhook`] at startup. A
|
||||||
//! periodic pull in `main.rs` provides a fallback cadence.
|
//! periodic pull in `main.rs` provides a fallback cadence.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use forgejo_api::structs::{CreateHookOption, CreateHookOptionConfig, CreateHookOptionType};
|
||||||
|
|
||||||
pub const ORG: &str = "internal";
|
pub const ORG: &str = "internal";
|
||||||
pub const REPO: &str = "knowledge";
|
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).
|
/// core token is absent (forge not yet provisioned).
|
||||||
pub async fn ensure_webhook(core_token: &str, dashboard_port: u16) -> Result<()> {
|
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 target_url = format!("http://127.0.0.1:{dashboard_port}/webhook/knowledge");
|
||||||
let client = reqwest::Client::builder()
|
let client = crate::forge::api(core_token)?;
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
|
||||||
.build()
|
|
||||||
.context("build reqwest client for webhook setup")?;
|
|
||||||
|
|
||||||
// List existing hooks — skip creation if ours is already there.
|
// List existing hooks — skip creation if ours is already there.
|
||||||
let list_url = format!(
|
// Best-effort like the raw-HTTP predecessor: a listing failure
|
||||||
"{}/api/v1/repos/{ORG}/{REPO}/hooks",
|
// falls through to the create attempt.
|
||||||
crate::forge::FORGE_HTTP
|
match client.repo_list_hooks(ORG, REPO).all().await {
|
||||||
);
|
Ok(hooks) => {
|
||||||
let resp = client
|
let already_exists = hooks.iter().any(|h| {
|
||||||
.get(&list_url)
|
h.config
|
||||||
.header("Authorization", format!("token {core_token}"))
|
.as_ref()
|
||||||
.send()
|
.and_then(|c| c.get("url"))
|
||||||
.await
|
.map(String::as_str)
|
||||||
.with_context(|| format!("GET {list_url}"))?;
|
== Some(target_url.as_str())
|
||||||
if resp.status().is_success() {
|
});
|
||||||
let hooks: Vec<serde_json::Value> = resp.json().await.unwrap_or_default();
|
if already_exists {
|
||||||
let already_exists = hooks.iter().any(|h| {
|
tracing::debug!(%target_url, "knowledge: push webhook already configured");
|
||||||
h.get("config")
|
return Ok(());
|
||||||
.and_then(|c| c.get("url"))
|
}
|
||||||
.and_then(|u| u.as_str())
|
}
|
||||||
== Some(&target_url)
|
Err(e) => {
|
||||||
});
|
tracing::debug!(error = %e, "knowledge: listing hooks failed; attempting create");
|
||||||
if already_exists {
|
|
||||||
tracing::debug!(%target_url, "knowledge: push webhook already configured");
|
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the webhook.
|
// Create the webhook.
|
||||||
let create_url = format!(
|
let hook = CreateHookOption {
|
||||||
"{}/api/v1/repos/{ORG}/{REPO}/hooks",
|
active: Some(true),
|
||||||
crate::forge::FORGE_HTTP
|
authorization_header: None,
|
||||||
);
|
branch_filter: None,
|
||||||
let body = serde_json::json!({
|
config: CreateHookOptionConfig {
|
||||||
"type": "forgejo",
|
content_type: "json".to_owned(),
|
||||||
"config": {
|
url: url::Url::parse(&target_url).context("parse webhook target url")?,
|
||||||
"url": target_url,
|
additional: BTreeMap::new(),
|
||||||
"content_type": "json"
|
|
||||||
},
|
},
|
||||||
"events": ["push"],
|
events: Some(vec!["push".to_owned()]),
|
||||||
"active": true
|
r#type: CreateHookOptionType::Forgejo,
|
||||||
});
|
};
|
||||||
let resp = client
|
client
|
||||||
.post(&create_url)
|
.repo_create_hook(ORG, REPO, hook)
|
||||||
.header("Authorization", format!("token {core_token}"))
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("POST {create_url}"))?;
|
.with_context(|| format!("create webhook for {ORG}/{REPO}"))?;
|
||||||
let status = resp.status();
|
tracing::info!(%target_url, "knowledge: push webhook created");
|
||||||
if status.is_success() {
|
Ok(())
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pull the latest changes in the local clone. Called from the webhook
|
/// Pull the latest changes in the local clone. Called from the webhook
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue