diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index e2bb8756..914121d5 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -157,6 +157,30 @@ async fn forge_http( 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 + .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)) +} + /// Ensure a forgejo user named `name` exists. Idempotent: forgejo /// returns a "user already exists" error which we treat as success. /// `admin` adds `--admin` (site admin) — used for the bootstrap @@ -1013,19 +1037,30 @@ 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 = forge_http(reqwest::Method::POST, &url, token, &body).await?; - match status.as_u16() { - 201 => { - tracing::info!(%repo, "forge: applied config-repo branch protection"); - Ok(()) - } - 200 | 409 | 422 => { - tracing::debug!(%repo, "forge: config-repo branch protection already present"); - Ok(()) - } - other => { - anyhow::bail!("POST {CONFIG_ORG}/{repo}/branch_protections returned HTTP {other}") - } + let (status, resp_body) = forge_http_full(reqwest::Method::POST, &url, token, &body).await?; + if status.as_u16() == 201 { + tracing::info!(%repo, "forge: applied config-repo branch protection"); + return Ok(()); + } + // Non-201 is ambiguous: it can mean "rule already exists" (idempotent + // success) OR a silent rejection — e.g. a 422 where Forgejo refused + // the request and created NO rule. The old code treated 200/409/422 + // all as success, so a rejected POST left the repo unprotected with + // no error (the reported case: a new agent's config repo had no + // `main` rule and nothing was logged). Don't trust the status code: + // verify the `main` rule actually exists, and on failure surface the + // POST's response body so the real reason is in the journal. + let main_url = format!("{url}/main"); + let check = forge_http(reqwest::Method::GET, &main_url, token, "").await?; + if check.as_u16() == 200 { + tracing::debug!(%repo, %status, "forge: config-repo branch protection already present"); + Ok(()) + } else { + anyhow::bail!( + "branch protection for {CONFIG_ORG}/{repo} not applied: POST -> HTTP {status} \ + (body: {body}); GET main -> HTTP {check}, no `main` rule present", + body = resp_body.trim(), + ) } }