//! Forgejo webhook endpoints. //! //! - **`/webhook/config-pr`** — `pull_request` events on any `agent-configs/*` //! repo queue a [`hive_sh4re::approvals::ApprovalKind::MergeConfigPr`] approval row //! so the operator can review + approve the merge from the dashboard. //! //! There was a second endpoint here, `/webhook/knowledge`, which pulled the //! local `internal/knowledge` clone on push. It is gone along with the //! per-hive registration that fed it: a webhook has exactly one target URL, //! so every hive registering one against the shared repository was //! last-writer-wins. The swarm controller now holds the single registration //! and addresses an event to each hive over the queue, which //! [`crate::workers::knowledge`] documents. //! //! The endpoint is reached via the gateway (HTTPS, public domain URL) so //! Forgejo's SSRF guard does not block delivery. Each delivery is verified //! against the `X-Hub-Signature-256` HMAC header Forgejo attaches; the //! shared secret is auto-generated at startup and persisted to //! [`crate::paths::webhook_secret_file()`]. use axum::{ body::Bytes, extract::State, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; use serde::Deserialize; use super::AppState; // ── HMAC helper ─────────────────────────────────────────────────────────────── /// Verify the `X-Hub-Signature-256` header on an incoming Forgejo webhook. /// Returns `Err` (with a safe-to-log message) on mismatch, missing header, /// or when the HMAC secret is unavailable (load failure at startup). fn verify_hmac(state: &AppState, headers: &HeaderMap, body: &Bytes) -> Result<(), String> { let secret = state .webhook_secret .as_deref() .ok_or_else(|| "webhook HMAC secret unavailable; endpoint disabled".to_owned())?; let sig = headers .get("x-hub-signature-256") .and_then(|v| v.to_str().ok()) .unwrap_or(""); if sig.is_empty() { return Err("missing X-Hub-Signature-256 header".to_owned()); } crate::webhook_secret::verify_signature(secret, body, sig).map_err(|e| e.to_string()) } // ── config-PR webhook ────────────────────────────────────────────────────────── /// Minimal Forgejo `pull_request`-webhook payload. /// /// Forgejo fires this for actions: `opened`, `closed`, `reopened`, /// `synchronized`, `assigned`, `unassigned`, `label_updated`, /// `label_cleared`, `milestoned`, `demilestoned`, `review_requested`, /// `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`. /// We only act on `opened` and `synchronized` (Forgejo spells the /// push-update action past-tense, unlike GitHub's `synchronize`). #[derive(Deserialize)] pub(super) struct PrWebhookPayload { /// What triggered this event (`opened`, `closed`, `synchronized`, …). action: Option, /// PR index on the repo. number: Option, pull_request: Option, repository: Option, } #[derive(Deserialize)] struct PrWebhookPr { head: Option, } #[derive(Deserialize)] struct PrWebhookHead { sha: Option, } #[derive(Deserialize)] struct PrWebhookRepo { full_name: Option, } /// POST `/webhook/config-pr` — Forgejo `pull_request` webhook for /// `agent-configs/*` repos. /// /// On `opened` or `synchronized` for an `agent-configs/` PR: /// fetches the current PR head sha, queues a `MergeConfigPr` approval row, /// and emits the `ApprovalAdded` event so the dashboard card appears /// immediately. /// /// All other actions (closed, label changes, etc.) are silently ignored — /// the operator can deny a pending approval if the PR is later closed. /// /// Always returns HTTP 200 (even on queue failure) so Forgejo does not /// retry the delivery. Failures are logged at `warn` level. /// /// Expected Forgejo webhook configuration: /// - URL: `https:///webhook/config-pr` /// - Content type: `application/json` /// - Events: "Pull Request" only /// - Secret: auto-generated HMAC key (see [`crate::webhook_secret`]) /// - Organisation: `agent-configs` (org-level hook covers all config repos) /// /// hive-c0re registers this hook automatically at startup via /// [`crate::forge::ensure_config_pr_webhook`]. #[utoipa::path( post, path = "/webhook/config-pr", request_body( content = String, content_type = "application/json", description = "Forgejo pull_request-webhook payload, taken as raw \ bytes (not a typed extractor) so HMAC verification \ runs over the exact wire bytes before any JSON \ parsing" ), responses( (status = 200, description = "processed (approval queued or ignored)", body = String), (status = 400, description = "invalid JSON payload"), (status = 401, description = "bad or missing HMAC signature"), (status = 503, description = "HMAC secret unavailable at startup"), ), tag = "webhook" )] pub(super) async fn post_webhook_config_pr( State(state): State, headers: HeaderMap, body: Bytes, ) -> Response { if let Err(e) = verify_hmac(&state, &headers, &body) { tracing::warn!("webhook/config-pr: HMAC verification failed: {e}"); let status = if e.contains("unavailable") { StatusCode::SERVICE_UNAVAILABLE } else { StatusCode::UNAUTHORIZED }; return (status, e).into_response(); } let payload = match serde_json::from_slice::(&body) { Ok(p) => p, Err(e) => { tracing::warn!("webhook/config-pr: JSON parse error: {e}"); return (StatusCode::BAD_REQUEST, "invalid JSON").into_response(); } }; let action = payload.action.as_deref().unwrap_or(""); // Only act on a newly-opened PR or a new push to its branch. Forgejo's // webhook payload spells the push-update action `synchronized` (past // tense), NOT GitHub's `synchronize` — matching only the GitHub spelling // silently dropped every PR-update delivery (the whole reason updates were // "only found by poll"). Accept both so the handler is correct against // Forgejo and stays GitHub-compatible. if action != "opened" && action != "synchronize" && action != "synchronized" { tracing::debug!(action, "webhook/config-pr: ignoring action"); return (StatusCode::OK, "ignored").into_response(); } let full_name = payload .repository .as_ref() .and_then(|r| r.full_name.as_deref()) .unwrap_or(""); // Expect `agent-configs/`. let agent = match full_name.strip_prefix(&format!("{}/", crate::forge::CONFIG_ORG)) { Some(name) if !name.is_empty() && !name.contains('/') => name, _ => { tracing::debug!( full_name, "webhook/config-pr: ignoring non-config-repo event" ); return (StatusCode::OK, "ignored").into_response(); } }; let pr_number = match payload.number { Some(n) if n > 0 => n, _ => { tracing::warn!(full_name, "webhook/config-pr: missing or zero PR number"); return (StatusCode::OK, "ignored").into_response(); } }; // The payload already carries the head sha — use it as an early hint for // logging, but the canonical sha comes from `submit_merge_config_pr`'s // fresh forge API call so we don't trust a potentially-stale payload sha. let payload_sha = payload .pull_request .as_ref() .and_then(|pr| pr.head.as_ref()) .and_then(|h| h.sha.as_deref()) .unwrap_or(""); tracing::info!( %full_name, %agent, %pr_number, %payload_sha, %action, "webhook/config-pr: queuing MergeConfigPr approval" ); // Queue the approval. The description surfaces the action and PR number // on the dashboard card so the operator has context without opening the // forge PR. let description = format!("PR #{pr_number} on {full_name} ({action})"); if let Err(e) = crate::socket_server::submit_merge_config_pr( &state.coord, agent, pr_number, Some(&description), "forge", // submitter — identifies the webhook path in the audit trail ) .await { tracing::warn!( %agent, %pr_number, error = ?e, "webhook/config-pr: failed to queue MergeConfigPr approval" ); } (StatusCode::OK, "ok").into_response() }