- Extract http.rs: unified HTTP server with /api/timers and gitea webhook - Life loop: select! on interval tick + mpsc channel for force-fire - Predefined diary timer (cron 22:55 daily), auto-registered on startup - BufferOutput for system timers (chat_id=0), no TG message - state: ensure_timer(), get_timer() - context.md: add blog and Hugo docs for AI
366 lines
10 KiB
Rust
366 lines
10 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};
|
|
|
|
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 fn webhook_router(config: &GiteaConfig, bot_user: String) -> axum::Router<()> {
|
|
let gitea = GiteaClient::new(config);
|
|
let state = Arc::new(WebhookState { gitea, bot_user });
|
|
|
|
axum::Router::new()
|
|
.route("/webhook/gitea", post(handle_webhook))
|
|
.with_state(state)
|
|
}
|
|
|
|
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(())
|
|
}
|