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:
@@ -11,6 +11,40 @@ pub struct Config {
|
||||
pub backend: BackendConfig,
|
||||
#[serde(default)]
|
||||
pub whisper_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub gitea: Option<GiteaConfig>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct GiteaConfig {
|
||||
pub url: String,
|
||||
/// Direct token or read from token_file at startup
|
||||
#[serde(default)]
|
||||
pub token: String,
|
||||
#[serde(default)]
|
||||
pub token_file: Option<String>,
|
||||
#[serde(default = "default_webhook_port")]
|
||||
pub webhook_port: u16,
|
||||
#[serde(default)]
|
||||
pub webhook_secret: Option<String>,
|
||||
}
|
||||
|
||||
impl GiteaConfig {
|
||||
/// Resolve token: if token_file is set and token is empty, read from file
|
||||
pub fn resolve_token(&mut self) {
|
||||
if self.token.is_empty() {
|
||||
if let Some(path) = &self.token_file {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(t) => self.token = t.trim().to_string(),
|
||||
Err(e) => tracing::error!("failed to read gitea token_file {path}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_webhook_port() -> u16 {
|
||||
9800
|
||||
}
|
||||
|
||||
fn default_name() -> String {
|
||||
|
||||
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(())
|
||||
}
|
||||
22
src/main.rs
22
src/main.rs
@@ -1,9 +1,10 @@
|
||||
mod config;
|
||||
mod state;
|
||||
mod tools;
|
||||
mod stream;
|
||||
mod display;
|
||||
mod gitea;
|
||||
mod life;
|
||||
mod state;
|
||||
mod stream;
|
||||
mod tools;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -71,8 +72,11 @@ async fn main() {
|
||||
let config_path = std::env::var("NOC_CONFIG").unwrap_or_else(|_| "config.yaml".into());
|
||||
let raw = std::fs::read_to_string(&config_path)
|
||||
.unwrap_or_else(|e| panic!("read {config_path}: {e}"));
|
||||
let config: Config =
|
||||
let mut config: Config =
|
||||
serde_yaml::from_str(&raw).unwrap_or_else(|e| panic!("parse config: {e}"));
|
||||
if let Some(ref mut gitea) = config.gitea {
|
||||
gitea.resolve_token();
|
||||
}
|
||||
|
||||
let state_path = std::env::var("NOC_STATE")
|
||||
.map(PathBuf::from)
|
||||
@@ -93,6 +97,16 @@ async fn main() {
|
||||
// start life loop
|
||||
tokio::spawn(life::life_loop(bot.clone(), state.clone(), config.clone()));
|
||||
|
||||
// start gitea webhook server
|
||||
if let Some(gitea_config) = &config.gitea {
|
||||
let gc = gitea_config.clone();
|
||||
// Use the gitea admin username as the bot user for @mention detection
|
||||
let bot_user = std::env::var("GITEA_ADMIN_USER").unwrap_or_else(|_| "noc".into());
|
||||
tokio::spawn(async move {
|
||||
gitea::start_webhook_server(&gc, bot_user).await;
|
||||
});
|
||||
}
|
||||
|
||||
Dispatcher::builder(bot, handler)
|
||||
.dependencies(dptree::deps![state, config, bot_username])
|
||||
.default_handler(|_| async {})
|
||||
|
||||
56
src/tools.rs
56
src/tools.rs
@@ -200,6 +200,22 @@ pub fn discover_tools() -> serde_json::Value {
|
||||
}
|
||||
}
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "call_gitea_api",
|
||||
"description": "调用 Gitea REST API。以 noc_bot 身份操作,拥有 admin 权限。可管理 repo、issue、PR、comment、webhook 等一切。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"method": {"type": "string", "description": "HTTP method: GET, POST, PATCH, PUT, DELETE"},
|
||||
"path": {"type": "string", "description": "API path after /api/v1/, e.g. repos/noc/myrepo/issues"},
|
||||
"body": {"type": "object", "description": "Optional JSON body for POST/PATCH/PUT"}
|
||||
},
|
||||
"required": ["method", "path"]
|
||||
}
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
// discover script tools
|
||||
@@ -350,6 +366,46 @@ pub async fn execute_tool(
|
||||
Err(e) => format!("Error: {e}"),
|
||||
}
|
||||
}
|
||||
"call_gitea_api" => {
|
||||
let method = args["method"].as_str().unwrap_or("GET").to_uppercase();
|
||||
let path = args["path"].as_str().unwrap_or("").trim_start_matches('/');
|
||||
let body = args.get("body");
|
||||
|
||||
let gitea_config = match &config.gitea {
|
||||
Some(c) => c,
|
||||
None => return "Gitea not configured".to_string(),
|
||||
};
|
||||
|
||||
let url = format!("{}/api/v1/{}", gitea_config.url.trim_end_matches('/'), path);
|
||||
let client = reqwest::Client::new();
|
||||
let mut req = match method.as_str() {
|
||||
"GET" => client.get(&url),
|
||||
"POST" => client.post(&url),
|
||||
"PATCH" => client.patch(&url),
|
||||
"PUT" => client.put(&url),
|
||||
"DELETE" => client.delete(&url),
|
||||
_ => return format!("Unsupported method: {method}"),
|
||||
};
|
||||
req = req.header("Authorization", format!("token {}", gitea_config.token));
|
||||
if let Some(b) = body {
|
||||
req = req.json(b);
|
||||
}
|
||||
|
||||
match req.send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
// Truncate large responses
|
||||
let text = if text.len() > 4000 {
|
||||
format!("{}...(truncated)", &text[..4000])
|
||||
} else {
|
||||
text
|
||||
};
|
||||
format!("HTTP {status}\n{text}")
|
||||
}
|
||||
Err(e) => format!("Request failed: {e:#}"),
|
||||
}
|
||||
}
|
||||
"gen_voice" => {
|
||||
let text = args["text"].as_str().unwrap_or("");
|
||||
if text.is_empty() {
|
||||
|
||||
Reference in New Issue
Block a user