refactor(#2051): fold body-return into forge_http instead of a near-copy

Per review: rather than adding forge_http_full (a near-duplicate of
forge_http), change forge_http itself to return (StatusCode, String).
Status-only callers bind (status, _); the branch-protection verify path
uses the body to log the real Forgejo rejection reason. Updates all call
sites accordingly.
This commit is contained in:
atlas 2026-06-27 14:09:22 +02:00 committed by mara
commit 3fedc102cc

View file

@ -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<StatusCode> {
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 `<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?;
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/<pr>/merge (manually-merged)")?;
if status.is_success() {