use std::sync::Arc;
use anyhow::Result;
use axum::{
body::Bytes,
extract::{Query, State},
http::StatusCode,
response::Json,
};
use serde::Deserialize;
use crate::core::TrustySearchClient;
use crate::service::events::{AnalyzerAppState, ApiError};
#[derive(Deserialize)]
pub struct ReviewQueryParams {
pub index_id: Option<String>,
}
pub async fn review_diff_handler(
State(state): State<Arc<AnalyzerAppState>>,
Query(params): Query<ReviewQueryParams>,
body: Bytes,
) -> Result<Json<crate::core::ReviewReport>, ApiError> {
let index_id = params
.index_id
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| ApiError::bad_request("missing required 'index_id' query parameter"))?;
let diff = std::str::from_utf8(&body)
.map_err(|e| ApiError::bad_request(format!("diff body is not valid UTF-8: {e}")))?;
let report = crate::core::analyze_diff_with_client(diff, &state.search, index_id)
.await
.map_err(|e| match e {
crate::core::ReviewError::MalformedHunkHeader(_) => {
ApiError::bad_request(format!("invalid diff: {e}"))
}
crate::core::ReviewError::Search(_) => ApiError::bad_gateway(format!("{e}")),
})?;
Ok(Json(report))
}
pub async fn review_github_pr_handler(
State(state): State<Arc<AnalyzerAppState>>,
Json(req): Json<crate::core::GithubPrRequest>,
) -> Result<Json<crate::core::ReviewReport>, ApiError> {
let token = std::env::var("GITHUB_TOKEN").map_err(|_| {
ApiError::bad_request("GITHUB_TOKEN environment variable is not set on the daemon")
})?;
let client = reqwest::ClientBuilder::new()
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.expect("reqwest ClientBuilder is infallible with valid config");
let diff = crate::core::fetch_pr_diff(&client, &req.owner, &req.repo, req.pr, &token)
.await
.map_err(|e| ApiError::bad_gateway(format!("fetch PR diff: {e}")))?;
let report = crate::core::analyze_diff_with_client(&diff, &state.search, &req.index_id)
.await
.map_err(|e| match e {
crate::core::ReviewError::MalformedHunkHeader(_) => {
ApiError::bad_request(format!("invalid diff: {e}"))
}
crate::core::ReviewError::Search(_) => ApiError::bad_gateway(format!("{e}")),
})?;
if req.post_comment {
let markdown = crate::core::format_review_as_markdown(&report);
crate::core::post_pr_comment(&client, &req.owner, &req.repo, req.pr, &markdown, &token)
.await
.map_err(|e| ApiError::bad_gateway(format!("post PR comment: {e}")))?;
}
Ok(Json(report))
}
pub async fn github_webhook_handler(
State(state): State<Arc<AnalyzerAppState>>,
headers: axum::http::HeaderMap,
body: Bytes,
) -> Result<StatusCode, ApiError> {
let secret = state
.webhook_secret
.clone()
.or_else(|| std::env::var("GITHUB_WEBHOOK_SECRET").ok())
.filter(|s| !s.is_empty());
match secret {
Some(secret) => {
let sig = headers
.get("X-Hub-Signature-256")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !crate::core::verify_webhook_signature(&secret, &body, sig) {
return Err(ApiError {
status: StatusCode::UNAUTHORIZED,
message: "X-Hub-Signature-256 verification failed".to_string(),
});
}
}
None => {
tracing::warn!(
"no webhook secret configured — skipping webhook signature verification"
);
}
}
let event = headers
.get("X-GitHub-Event")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if event != "pull_request" {
return Ok(StatusCode::ACCEPTED);
}
let payload: serde_json::Value = serde_json::from_slice(&body)
.map_err(|e| ApiError::bad_request(format!("webhook body is not valid JSON: {e}")))?;
let action = payload.get("action").and_then(|v| v.as_str()).unwrap_or("");
if !matches!(action, "opened" | "synchronize" | "reopened") {
return Ok(StatusCode::ACCEPTED);
}
let pr = payload
.get("pull_request")
.and_then(|p| p.get("number"))
.and_then(|n| n.as_u64());
let owner = payload
.get("repository")
.and_then(|r| r.get("owner"))
.and_then(|o| o.get("login"))
.and_then(|l| l.as_str())
.map(str::to_owned);
let repo = payload
.get("repository")
.and_then(|r| r.get("name"))
.and_then(|n| n.as_str())
.map(str::to_owned);
let head_sha = payload
.get("pull_request")
.and_then(|p| p.get("head"))
.and_then(|h| h.get("sha"))
.and_then(|s| s.as_str())
.unwrap_or("unknown")
.to_string();
let (Some(pr), Some(owner), Some(repo)) = (pr, owner, repo) else {
return Err(ApiError::bad_request(
"webhook payload missing pull_request.number or repository owner/name",
));
};
let search = state.search.clone();
tokio::spawn(async move {
if let Err(e) = process_pr_webhook(search, &owner, &repo, pr, &head_sha).await {
tracing::warn!("github webhook PR {owner}/{repo}#{pr} processing failed: {e:#}");
}
});
Ok(StatusCode::ACCEPTED)
}
async fn process_pr_webhook(
search: TrustySearchClient,
owner: &str,
repo: &str,
pr: u64,
head_sha: &str,
) -> Result<()> {
let token = std::env::var("GITHUB_TOKEN")
.map_err(|_| anyhow::anyhow!("GITHUB_TOKEN not set; cannot process webhook PR"))?;
tracing::info!("processing webhook PR {owner}/{repo}#{pr} (head {head_sha})");
let client = reqwest::ClientBuilder::new()
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.expect("reqwest ClientBuilder is infallible with valid config");
let diff = crate::core::fetch_pr_diff(&client, owner, repo, pr, &token).await?;
let report = crate::core::analyze_diff_with_client(&diff, &search, repo).await?;
let markdown = crate::core::format_review_as_markdown(&report);
crate::core::post_pr_comment(&client, owner, repo, pr, &markdown, &token).await?;
tracing::info!("posted webhook review comment to {owner}/{repo}#{pr}");
Ok(())
}