noc/src/gitea.rs
Fam Zheng dbd729ecb8 add Gitea Bot interface: webhook server, API tool, Caddy ingress
- Add src/gitea.rs: axum webhook server on :9800, handles @mention in
  issues and PRs, spawns claude -p for review, posts result as comment
- Add call_gitea_api tool: LLM can directly call Gitea REST API with
  pre-configured admin token (noc_bot identity)
- Add Caddy to Docker image as ingress layer (subdomain/path routing)
- Config: add gitea section with token_file support for auto-provisioned token
- Update suite.md: VPS-first deployment, SubAgent architecture, Caddy role
2026-04-10 21:09:15 +01:00

380 lines
11 KiB
Rust

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<String> {
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<serde_json::Value> {
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<String>,
#[serde(default)]
comment: Option<Comment>,
#[serde(default)]
issue: Option<Issue>,
#[serde(default)]
pull_request: Option<PullRequest>,
repository: Option<Repository>,
}
#[derive(serde::Deserialize, Debug)]
struct Comment {
body: Option<String>,
user: Option<User>,
}
#[derive(serde::Deserialize, Debug, Clone)]
struct Issue {
number: u64,
title: Option<String>,
body: Option<String>,
#[serde(default)]
pull_request: Option<serde_json::Value>,
}
#[derive(serde::Deserialize, Debug, Clone)]
struct PullRequest {
number: u64,
title: Option<String>,
body: Option<String>,
}
#[derive(serde::Deserialize, Debug)]
struct Repository {
full_name: Option<String>,
}
#[derive(serde::Deserialize, Debug)]
struct User {
login: Option<String>,
}
// ── 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<Arc<WebhookState>>,
Json(payload): Json<WebhookPayload>,
) -> 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<Issue>,
pr: Option<PullRequest>,
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(())
}