use axum::{body::Bytes, extract::State, http::StatusCode, routing::post, Router};
use hmac::{Hmac, Mac};
use serde::Deserialize;
use sha2::Sha256;
use tracing::{info, warn};
use crate::adf_commands::AdfCommandParser;
use crate::persona::PersonaRegistry;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Deserialize)]
struct GiteaWebhookPayload {
action: String,
comment: GiteaComment,
issue: GiteaIssue,
repository: GiteaRepository,
}
#[derive(Debug, Deserialize)]
struct GiteaComment {
id: u64,
body: String,
user: GiteaUser,
created_at: String,
}
#[derive(Debug, Deserialize)]
struct GiteaUser {
login: String,
}
#[derive(Debug, Deserialize)]
struct GiteaIssue {
number: u64,
title: String,
state: String,
}
#[derive(Debug, Deserialize)]
struct GiteaRepository {
full_name: String,
}
pub enum WebhookDispatch {
SpawnAgent {
agent_name: String,
issue_number: u64,
comment_id: u64,
context: String,
},
SpawnPersona {
persona_name: String,
issue_number: u64,
comment_id: u64,
context: String,
},
CompoundReview {
issue_number: u64,
comment_id: u64,
},
}
impl WebhookDispatch {
pub fn comment_id(&self) -> u64 {
match self {
Self::SpawnAgent { comment_id, .. } => *comment_id,
Self::SpawnPersona { comment_id, .. } => *comment_id,
Self::CompoundReview { comment_id, .. } => *comment_id,
}
}
}
#[derive(Clone)]
pub struct WebhookState {
pub agent_names: Vec<String>,
pub persona_registry: std::sync::Arc<PersonaRegistry>,
pub dispatch_tx: tokio::sync::mpsc::Sender<WebhookDispatch>,
pub secret: Option<String>,
}
pub fn webhook_router(state: WebhookState) -> Router {
Router::new()
.route("/webhooks/gitea", post(handle_gitea_webhook))
.with_state(state)
}
async fn handle_gitea_webhook(
State(state): State<WebhookState>,
headers: axum::http::HeaderMap,
body: Bytes,
) -> StatusCode {
if let Some(ref secret) = state.secret {
let sig_header = headers
.get("X-Gitea-Signature")
.or_else(|| headers.get("X-Hub-Signature-256"));
match sig_header.and_then(|h| h.to_str().ok()) {
Some(sig) => {
if !verify_signature(secret, &body, sig) {
warn!("webhook signature verification failed");
return StatusCode::UNAUTHORIZED;
}
}
None => {
warn!("webhook secret configured but no signature header present");
return StatusCode::BAD_REQUEST;
}
}
}
let payload: GiteaWebhookPayload = match serde_json::from_slice(&body) {
Ok(p) => p,
Err(e) => {
warn!(error = %e, "failed to parse webhook payload");
return StatusCode::BAD_REQUEST;
}
};
info!(
repo = %payload.repository.full_name,
issue = payload.issue.number,
issue_title = %payload.issue.title,
issue_state = %payload.issue.state,
comment_id = payload.comment.id,
author = %payload.comment.user.login,
created_at = %payload.comment.created_at,
action = %payload.action,
"received webhook event"
);
if payload.action != "created" {
return StatusCode::OK;
}
let persona_names: Vec<String> = state
.persona_registry
.persona_names()
.into_iter()
.map(|s| s.to_string())
.collect();
let parser = AdfCommandParser::new(&state.agent_names, &persona_names);
let commands = parser.parse_commands(
&payload.comment.body,
payload.issue.number,
payload.comment.id,
);
if commands.is_empty() {
return StatusCode::OK;
}
let mut commands_dispatched: u32 = 0;
for cmd in commands {
let dispatch = match cmd {
crate::adf_commands::AdfCommand::SpawnAgent {
agent_name,
issue_number,
comment_id,
context,
} => WebhookDispatch::SpawnAgent {
agent_name,
issue_number,
comment_id,
context,
},
crate::adf_commands::AdfCommand::SpawnPersona {
persona_name,
issue_number,
comment_id,
context,
} => WebhookDispatch::SpawnPersona {
persona_name,
issue_number,
comment_id,
context,
},
crate::adf_commands::AdfCommand::CompoundReview {
issue_number,
comment_id,
} => WebhookDispatch::CompoundReview {
issue_number,
comment_id,
},
crate::adf_commands::AdfCommand::Unknown { raw } => {
warn!(raw = %raw, "unknown ADF command from webhook");
continue;
}
};
if let Err(e) = state.dispatch_tx.send(dispatch).await {
warn!(error = %e, "failed to send webhook dispatch to orchestrator");
return StatusCode::SERVICE_UNAVAILABLE;
}
commands_dispatched += 1;
}
info!(
repo = %payload.repository.full_name,
issue = payload.issue.number,
comment_id = payload.comment.id,
author = %payload.comment.user.login,
commands = commands_dispatched,
"webhook dispatch complete"
);
StatusCode::ACCEPTED
}
fn verify_signature(secret: &str, body: &[u8], signature: &str) -> bool {
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
mac.update(body);
let result = mac.finalize();
let expected = result.into_bytes();
let sig_bytes: Vec<u8> =
match hex::decode(signature.strip_prefix("sha256=").unwrap_or(signature)) {
Ok(b) => b,
Err(_) => return false,
};
expected.len() == sig_bytes.len() && expected.iter().zip(sig_bytes.iter()).all(|(a, b)| a == b)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_verify_signature_valid() {
let secret = "test-secret";
let body = b"hello world";
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
let result = mac.finalize();
let sig = hex::encode(result.into_bytes());
assert!(verify_signature(secret, body, &sig));
}
#[test]
fn test_verify_signature_invalid() {
assert!(!verify_signature("secret", b"body", "deadbeef"));
}
#[test]
fn test_verify_signature_with_prefix() {
let secret = "test-secret";
let body = b"hello world";
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
let result = mac.finalize();
let sig = format!("sha256={}", hex::encode(result.into_bytes()));
assert!(verify_signature(secret, body, &sig));
}
}