//! Forgejo webhook endpoints. //! //! - **`/webhook/knowledge`** — push events on `internal/knowledge` trigger a //! `git pull` on the local clone so agents see up-to-date docs. //! - **`/webhook/config-pr`** — `pull_request` events on any `agent-configs/*` //! repo queue a [`hive_sh4re::ApprovalKind::MergeConfigPr`] approval row //! so the operator can review + approve the merge from the dashboard. //! //! Both endpoints are 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()) } // ── knowledge webhook ────────────────────────────────────────────────────────── /// Minimal Forgejo push-webhook payload — only the fields we care about. #[derive(Deserialize)] pub(super) struct PushWebhookPayload { #[serde(rename = "ref")] git_ref: Option, repository: Option, } #[derive(Deserialize)] pub(super) struct PushWebhookRepo { full_name: Option, } /// POST `/webhook/knowledge` — Forgejo push webhook for /// `internal/knowledge`. Runs `git pull` on the local clone so /// agents see up-to-date documents on their next turn. /// /// Expected Forgejo webhook configuration: /// - URL: `https:///webhook/knowledge` /// - Content type: `application/json` /// - Event: "Push" (fires on merge commits to main as well) /// - Secret: auto-generated HMAC key (see [`crate::webhook_secret`]) /// /// The gateway routes `/webhook/` → hive-c0re; the HMAC secret protects /// the endpoint from unauthenticated callers. pub(super) async fn post_webhook_knowledge( State(state): State, headers: HeaderMap, body: Bytes, ) -> Response { if let Err(e) = verify_hmac(&state, &headers, &body) { tracing::warn!("webhook/knowledge: 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/knowledge: JSON parse error: {e}"); return (StatusCode::BAD_REQUEST, "invalid JSON").into_response(); } }; let expected_repo = format!("{}/{}", crate::knowledge::ORG, crate::knowledge::REPO); let full_name = payload .repository .as_ref() .and_then(|r| r.full_name.as_deref()) .unwrap_or(""); if full_name != expected_repo { tracing::debug!( full_name, "webhook/knowledge: ignoring push from unexpected repo" ); return (StatusCode::OK, "ignored").into_response(); } let git_ref = payload.git_ref.as_deref().unwrap_or(""); if git_ref != "refs/heads/main" { tracing::debug!(git_ref, "webhook/knowledge: ignoring non-main push"); return (StatusCode::OK, "ignored").into_response(); } tracing::info!("webhook/knowledge: pull triggered by push to {expected_repo}"); tokio::spawn(async { if let Err(e) = crate::knowledge::pull().await { tracing::warn!(error = ?e, "webhook/knowledge: pull failed"); } }); (StatusCode::OK, "ok").into_response() } // ── 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`]. 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() }