diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 914121d5..e2a868ae 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -139,33 +139,16 @@ fn agent_email(name: &str) -> String { /// 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 { - 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}"))?; - Ok(resp.status()) -} - -/// Like [`forge_http`] but also returns the response body, so callers -/// can log *why* Forgejo rejected a request (e.g. the validation -/// message on a 422). Body read is best-effort — a read error yields an -/// empty string rather than failing the whole call. -async fn forge_http_full( - method: reqwest::Method, - url: &str, - token: &str, - body: &str, ) -> Result<(StatusCode, String)> { let client = reqwest::Client::new(); let resp = client @@ -275,14 +258,14 @@ async fn ensure_user_email(name: &str) { 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() => { + Ok((status, _)) if status.is_success() => { 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 => { + Ok((status, _)) if status == reqwest::StatusCode::FORBIDDEN => { // Core token missing admin scope — see // `docs/forge.md::Token scopes` migration note. tracing::warn!( @@ -291,7 +274,7 @@ async fn ensure_user_email(name: &str) { Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes." ); } - Ok(status) => { + 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"), @@ -326,21 +309,21 @@ async fn ensure_repo_creation_disabled(name: &str) { 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() => { + Ok((status, _)) if status.is_success() => { 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 => { + Ok((status, _)) if status == reqwest::StatusCode::FORBIDDEN => { tracing::warn!( %name, %status, "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) => { + Ok((status, _)) => { tracing::warn!(%name, %status, "forge: PATCH max_repo_creation returned non-success"); } Err(e) => { @@ -468,7 +451,7 @@ async fn ensure_core_avatar(token: &str) -> Result<()> { 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?; + let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?; if !status.is_success() { anyhow::bail!("set core avatar: HTTP {status}"); } @@ -499,7 +482,7 @@ async fn ensure_config_org_avatar(token: &str) -> Result<()> { 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?; + let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?; if !status.is_success() { anyhow::bail!("set {CONFIG_ORG} avatar: HTTP {status}"); } @@ -552,7 +535,7 @@ fn classify_core_token_status(status: StatusCode) -> CoreTokenCheck { 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), + Ok((status, _)) => classify_core_token_status(status), Err(e) => { tracing::debug!( error = %e, @@ -614,7 +597,8 @@ fn repo_body_public(name: &str) -> String { /// 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?; + 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"); @@ -639,7 +623,7 @@ async fn ensure_org_repo_public(org: &str, name: &str, token: &str) -> Result<() /// (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?; + let (status, _) = forge_http(reqwest::Method::POST, url, token, body).await?; match status.as_u16() { 201 => { tracing::info!(%label, "forge: created repo"); @@ -915,7 +899,7 @@ pub async fn push_config(name: &str) -> Result<()> { 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?; + let (status, _) = forge_http(reqwest::Method::POST, &url, admin_token, &body).await?; match status.as_u16() { 201 => { tracing::info!(%name, "forge: created org"); @@ -950,7 +934,7 @@ async fn ensure_operators_team(token: &str) -> Result<()> { 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?; + let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?; match status.as_u16() { 201 => { tracing::info!("forge: created {OPERATORS_TEAM} team in {AGENTS_ORG}"); @@ -978,7 +962,7 @@ async fn add_collaborator( ) -> 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?; + 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"); @@ -1000,7 +984,7 @@ async fn apply_operator_branch_protection(repo: &str, token: &str) -> Result<()> 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?; + let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?; match status.as_u16() { 201 => { tracing::info!(%repo, "forge: applied operator branch protection"); @@ -1037,7 +1021,7 @@ async fn apply_config_repo_branch_protection(repo: &str, token: &str) -> Result< 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_full(reqwest::Method::POST, &url, token, &body).await?; + let (status, resp_body) = forge_http(reqwest::Method::POST, &url, token, &body).await?; if status.as_u16() == 201 { tracing::info!(%repo, "forge: applied config-repo branch protection"); return Ok(()); @@ -1051,7 +1035,7 @@ async fn apply_config_repo_branch_protection(repo: &str, token: &str) -> Result< // 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?; + 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(()) @@ -1447,7 +1431,7 @@ pub async fn mark_pr_merged(repo: &str, pr: u64, sha: &str) -> Result<(), ForgeM .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 (status, _) = forge_http(reqwest::Method::POST, &url, &token, &body) .await .context("POST pulls//merge (manually-merged)")?; if status.is_success() {