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
This commit is contained in:
379
src/gitea.rs
Normal file
379
src/gitea.rs
Normal file
@@ -0,0 +1,379 @@
|
||||
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(())
|
||||
}
|
||||
Reference in New Issue
Block a user