use std::sync::Arc; use anyhow::Result; use axum::extract::State as AxumState; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::routing::post; use axum::Json; use tracing::{error, info, warn}; use crate::config::GiteaConfig; // ── Gitea API client ─────────────────────────────────────────────── #[derive(Clone)] pub struct GiteaClient { pub base_url: String, pub token: String, http: reqwest::Client, } impl GiteaClient { pub fn new(config: &GiteaConfig) -> Self { Self { base_url: config.url.trim_end_matches('/').to_string(), token: config.token.clone(), http: reqwest::Client::new(), } } pub async fn post_comment( &self, owner: &str, repo: &str, issue_nr: u64, body: &str, ) -> Result<()> { let url = format!( "{}/api/v1/repos/{owner}/{repo}/issues/{issue_nr}/comments", self.base_url ); let resp = self .http .post(&url) .header("Authorization", format!("token {}", self.token)) .json(&serde_json::json!({ "body": body })) .send() .await?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); anyhow::bail!("gitea comment failed: {status} {text}"); } Ok(()) } pub async fn get_pr_diff( &self, owner: &str, repo: &str, pr_nr: u64, ) -> Result { let url = format!( "{}/api/v1/repos/{owner}/{repo}/pulls/{pr_nr}.diff", self.base_url ); let resp = self .http .get(&url) .header("Authorization", format!("token {}", self.token)) .send() .await?; if !resp.status().is_success() { let status = resp.status(); anyhow::bail!("gitea get diff failed: {status}"); } Ok(resp.text().await?) } pub async fn get_issue( &self, owner: &str, repo: &str, issue_nr: u64, ) -> Result { let url = format!( "{}/api/v1/repos/{owner}/{repo}/issues/{issue_nr}", self.base_url ); let resp = self .http .get(&url) .header("Authorization", format!("token {}", self.token)) .send() .await?; Ok(resp.json().await?) } } // ── Webhook types ────────────────────────────────────────────────── #[derive(serde::Deserialize, Debug)] struct WebhookPayload { action: Option, #[serde(default)] comment: Option, #[serde(default)] issue: Option, #[serde(default)] pull_request: Option, repository: Option, } #[derive(serde::Deserialize, Debug)] struct Comment { body: Option, user: Option, } #[derive(serde::Deserialize, Debug, Clone)] struct Issue { number: u64, title: Option, body: Option, #[serde(default)] pull_request: Option, } #[derive(serde::Deserialize, Debug, Clone)] struct PullRequest { number: u64, title: Option, body: Option, } #[derive(serde::Deserialize, Debug)] struct Repository { full_name: Option, } #[derive(serde::Deserialize, Debug)] struct User { login: Option, } // ── Webhook server ───────────────────────────────────────────────── #[derive(Clone)] pub struct WebhookState { pub gitea: GiteaClient, pub bot_user: String, } pub async fn start_webhook_server(config: &GiteaConfig, bot_user: String) { let gitea = GiteaClient::new(config); let state = Arc::new(WebhookState { gitea, bot_user, }); let app = axum::Router::new() .route("/webhook/gitea", post(handle_webhook)) .with_state(state); let addr = format!("0.0.0.0:{}", config.webhook_port); info!("gitea webhook server listening on {addr}"); let listener = tokio::net::TcpListener::bind(&addr) .await .unwrap_or_else(|e| panic!("bind {addr}: {e}")); if let Err(e) = axum::serve(listener, app).await { error!("webhook server error: {e}"); } } async fn handle_webhook( AxumState(state): AxumState>, Json(payload): Json, ) -> impl IntoResponse { let action = payload.action.as_deref().unwrap_or(""); let repo_full = payload .repository .as_ref() .and_then(|r| r.full_name.as_deref()) .unwrap_or("unknown"); info!(repo = repo_full, action, "webhook received"); // We care about: // 1. issue_comment with @bot mention (works for both issues and PRs) // 2. issue opened with @bot mention if action == "created" || action == "opened" { let mention = format!("@{}", state.bot_user); // Check comment body for mention if let Some(comment) = &payload.comment { let body = comment.body.as_deref().unwrap_or(""); let commenter = comment .user .as_ref() .and_then(|u| u.login.as_deref()) .unwrap_or(""); // Don't respond to our own comments if commenter == state.bot_user { return StatusCode::OK; } if body.contains(&mention) { let state = state.clone(); let repo = repo_full.to_string(); let issue = payload.issue.clone(); let pr = payload.pull_request.clone(); let body = body.to_string(); tokio::spawn(async move { if let Err(e) = handle_mention(&state, &repo, issue, pr, &body).await { error!(repo, "handle mention: {e:#}"); } }); } } // Check issue/PR body for mention on open else if action == "opened" { let body = payload .issue .as_ref() .and_then(|i| i.body.as_deref()) .or(payload.pull_request.as_ref().and_then(|p| p.body.as_deref())) .unwrap_or(""); if body.contains(&mention) { let state = state.clone(); let repo = repo_full.to_string(); let issue = payload.issue.clone(); let pr = payload.pull_request.clone(); let body = body.to_string(); tokio::spawn(async move { if let Err(e) = handle_mention(&state, &repo, issue, pr, &body).await { error!(repo, "handle mention: {e:#}"); } }); } } } StatusCode::OK } async fn handle_mention( state: &WebhookState, repo_full: &str, issue: Option, pr: Option, comment_body: &str, ) -> Result<()> { let parts: Vec<&str> = repo_full.splitn(2, '/').collect(); if parts.len() != 2 { anyhow::bail!("bad repo name: {repo_full}"); } let (owner, repo) = (parts[0], parts[1]); // Strip the @mention to get the actual request let mention = format!("@{}", state.bot_user); let request = comment_body .replace(&mention, "") .trim() .to_string(); // Determine issue/PR number let issue_nr = issue .as_ref() .map(|i| i.number) .or(pr.as_ref().map(|p| p.number)) .unwrap_or(0); if issue_nr == 0 { return Ok(()); } let is_pr = pr.is_some() || issue .as_ref() .map(|i| i.pull_request.is_some()) .unwrap_or(false); let title = issue .as_ref() .and_then(|i| i.title.as_deref()) .or(pr.as_ref().and_then(|p| p.title.as_deref())) .unwrap_or(""); info!( repo = repo_full, issue_nr, is_pr, "handling mention: {request}" ); // Build prompt for claude -p let prompt = if is_pr { let diff = state.gitea.get_pr_diff(owner, repo, issue_nr).await?; // Truncate very large diffs let diff_truncated = if diff.len() > 50_000 { format!("{}...\n\n(diff truncated, {} bytes total)", &diff[..50_000], diff.len()) } else { diff }; if request.is_empty() { format!( "Review this pull request.\n\n\ PR #{issue_nr}: {title}\n\ Repo: {repo_full}\n\n\ Diff:\n```\n{diff_truncated}\n```\n\n\ Give a concise code review. Point out bugs, issues, and suggestions. \ Be direct and specific. Use markdown." ) } else { format!( "PR #{issue_nr}: {title}\nRepo: {repo_full}\n\n\ Diff:\n```\n{diff_truncated}\n```\n\n\ User request: {request}" ) } } else { // Issue let issue_data = state.gitea.get_issue(owner, repo, issue_nr).await?; let issue_body = issue_data["body"].as_str().unwrap_or(""); if request.is_empty() { format!( "Analyze this issue and suggest how to address it.\n\n\ Issue #{issue_nr}: {title}\n\ Repo: {repo_full}\n\n\ {issue_body}" ) } else { format!( "Issue #{issue_nr}: {title}\n\ Repo: {repo_full}\n\n\ {issue_body}\n\n\ User request: {request}" ) } }; // Run claude -p let output = tokio::process::Command::new("claude") .args(["-p", &prompt]) .output() .await; let response = match output { Ok(out) => { let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); if out.status.success() && !stdout.is_empty() { stdout.to_string() } else if !stderr.is_empty() { format!("Error running review:\n```\n{stderr}\n```") } else { "(no output)".to_string() } } Err(e) => format!("Failed to run claude: {e}"), }; // Post result as comment state .gitea .post_comment(owner, repo, issue_nr, &response) .await?; info!(repo = repo_full, issue_nr, "posted review comment"); Ok(()) }