use std::sync::Arc;
use axum::{
Json,
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use tracing::{debug, info, warn};
use crate::{
integrations::github::{RunMode, webhook::verify_webhook_signature},
pipeline::{DiffSource, ReviewDeps, ReviewInput, classify_review_request, run_review},
service::handlers::AppState,
};
#[derive(Debug, Deserialize)]
pub struct PullRequestEvent {
pub action: String,
pub pull_request: PrInfo,
pub repository: RepoInfo,
#[serde(default)]
pub requested_reviewer: Option<ReviewerInfo>,
}
#[derive(Debug, Deserialize)]
pub struct PrInfo {
pub number: u64,
pub user: UserInfo,
#[serde(default)]
pub head: Option<HeadInfo>,
}
#[derive(Debug, Deserialize)]
pub struct UserInfo {
pub login: String,
}
#[derive(Debug, Deserialize, Default)]
pub struct HeadInfo {
pub sha: String,
}
#[derive(Debug, Deserialize)]
pub struct RepoInfo {
pub name: String,
pub owner: UserInfo,
}
#[derive(Debug, Deserialize)]
pub struct ReviewerInfo {
pub login: String,
}
#[derive(Debug, Serialize)]
struct WebhookAck {
status: &'static str,
pr: u64,
}
pub async fn handle_github_webhook(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
let secret = &state.config.github_webhook_secret;
if secret.is_empty() {
warn!("GITHUB_WEBHOOK_SECRET is not set — rejecting all webhook requests");
return (StatusCode::UNAUTHORIZED, "Webhook secret not configured").into_response();
}
let signature = headers
.get("x-hub-signature-256")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !verify_webhook_signature(secret, &body, signature) {
warn!("webhook HMAC verification failed");
return (StatusCode::UNAUTHORIZED, "Invalid signature").into_response();
}
let event_type = headers
.get("x-github-event")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if event_type != "pull_request" {
debug!(event_type, "ignoring non pull_request webhook event");
return (StatusCode::OK, "ignored").into_response();
}
let event: PullRequestEvent = match serde_json::from_slice(&body) {
Ok(e) => e,
Err(e) => {
warn!("failed to parse pull_request webhook payload: {e}");
return (StatusCode::BAD_REQUEST, "invalid JSON payload").into_response();
}
};
if event.action != "review_requested" {
debug!(action = %event.action, pr = event.pull_request.number, "pull_request event ignored (not review_requested)");
return (StatusCode::OK, "ignored").into_response();
}
let pr_number = event.pull_request.number;
let owner = event.repository.owner.login.clone();
let repo = event.repository.name.clone();
let head_sha = event
.pull_request
.head
.as_ref()
.map(|h| h.sha.clone())
.unwrap_or_default();
let requested_login = event.requested_reviewer.as_ref().map(|r| r.login.as_str());
let trigger = classify_review_request(&state.config, requested_login);
info!(
pr = pr_number,
owner = %owner,
repo = %repo,
reviewer = ?requested_login,
trigger = ?trigger,
"webhook dispatching review for review_requested"
);
let pr_guard = match state
.in_flight_registry
.try_acquire_pr(&owner, &repo, pr_number)
{
Some(g) => g,
None => {
debug!(
pr = pr_number,
"a review for this PR is already in flight — dropping duplicate delivery"
);
return (StatusCode::OK, "already in flight").into_response();
}
};
let state_clone = state.clone();
tokio::spawn(async move {
let _pr_guard = pr_guard;
let _sha_guard = if head_sha.is_empty() {
None
} else {
match state_clone
.in_flight_registry
.try_acquire_sha(&owner, &repo, pr_number, &head_sha)
{
Some(g) => Some(g),
None => {
debug!(
pr = pr_number,
head_sha = %head_sha,
"a review for this head SHA is already in flight — skipping"
);
return;
}
}
};
state_clone
.in_flight
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let deps = ReviewDeps {
llm: Arc::clone(&state_clone.llm),
verifier: state_clone.verifier.clone(),
search: Arc::clone(&state_clone.search),
analyze: state_clone.analyze.clone(),
dedup: state_clone.dedup.clone(),
};
let reviewer_model = state_clone.config.role_models.reviewer.model.clone();
let input = ReviewInput {
diff_source: DiffSource::Github {
owner: owner.clone(),
repo: repo.clone(),
pr: pr_number,
token: String::new(), },
reviewer_model,
write_log: true, print_result: false,
trigger,
run_mode: RunMode::Serve,
allow_posting: true,
};
let result = run_review(&state_clone.config, input, deps).await;
state_clone
.in_flight
.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
if let Some(ref err) = result.error {
warn!(pr = pr_number, error = %err, "webhook pipeline completed with error");
if let Ok(mut guard) = state_clone.last_error.lock() {
*guard = Some(err.clone());
}
} else {
info!(
pr = pr_number,
verdict = %result.verdict,
posted = result.posted,
findings = result.findings.len(),
"webhook pipeline complete"
);
}
});
(
StatusCode::ACCEPTED,
Json(WebhookAck {
status: "accepted",
pr: pr_number,
}),
)
.into_response()
}
#[cfg(test)]
#[path = "webhook_tests.rs"]
mod tests;