feat(bot): interactive Gitea webhook bot (FastAPI) — @mention chat + auto PR review
A small persistent service (bot/app.py) that reuses the ci-ai modules (llm.py->Jetson, gitea_api.py, ai_review prompt). Handles issue_comment @ci-ai mentions as threaded chat and pull_request events as idempotent reviews — answering in seconds via webhook, with NO runner/clone/hairpin. Runs on the Pi next to Gitea (internal http://gitea:3000 API). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
12
bot/Dockerfile
Normal file
12
bot/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
# ci-ai-bot — build from the repo ROOT so it can pull in scripts/:
|
||||
# docker build -f bot/Dockerfile -t ci-ai-bot .
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
RUN pip install --no-cache-dir "fastapi>=0.115,<0.116" "uvicorn[standard]>=0.32,<0.33"
|
||||
COPY scripts/ /app/scripts/
|
||||
COPY bot/app.py /app/app.py
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD python -c "import urllib.request;urllib.request.urlopen('http://localhost:8080/healthz')" || exit 1
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
113
bot/app.py
Normal file
113
bot/app.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""ci-ai-bot — schlanker Gitea-Webhook-Dienst (interaktiver KI-Bot).
|
||||
|
||||
Empfängt Gitea-Webhooks und reagiert sofort (Antwort in Sekunden, nicht über die
|
||||
CI-Pipeline). Wiederverwendet die ci-ai-Module:
|
||||
- scripts/llm.py → LLM-Backend (Jetson-Ollama, provider-agnostisch)
|
||||
- scripts/gitea_api.py → Diff/Thread holen, Kommentare posten
|
||||
- scripts/ai_review.py → derselbe Review-Prompt wie die CI-Variante
|
||||
|
||||
Events:
|
||||
- issue_comment (created) mit `@<BOT_NAME>` → Chat/Q&A im Thread-Kontext (neue Antwort)
|
||||
- pull_request (opened/synchronized/reopened) → automatisches PR-Review (idempotent)
|
||||
|
||||
Läuft auf dem Pi im web_network neben Gitea: Gitea-API intern über http://gitea:3000,
|
||||
LLM über die Jetson-LAN-IP. Kein Runner, kein Clone, kein Hairpin.
|
||||
|
||||
Env: BOT_NAME, WEBHOOK_SECRET, GITEA_TOKEN, GITEA_API, AI_PROVIDER, AI_MODEL, AI_BASE_URL.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from fastapi import BackgroundTasks, FastAPI, Request, Response
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "scripts"))
|
||||
import ai_review # noqa: E402 (reuse SYSTEM/PROMPT/filter_diff/MARKER/DIFF_CAP)
|
||||
import gitea_api # noqa: E402
|
||||
import llm # noqa: E402
|
||||
|
||||
BOT_NAME = os.environ.get("BOT_NAME", "ci-ai")
|
||||
SECRET = os.environ.get("WEBHOOK_SECRET", "")
|
||||
THREAD_CAP = 18000
|
||||
DIFF_CAP = 18000
|
||||
|
||||
CHAT_SYSTEM = (
|
||||
"Du bist ein hilfreicher KI-Assistent in Gitea-Pull-Requests/Issues. Antworte auf "
|
||||
"Deutsch, knapp und konkret, auf Basis des Threads und (falls PR) des Diffs. "
|
||||
"Erfinde nichts, was nicht im Kontext steht."
|
||||
)
|
||||
|
||||
app = FastAPI(title="ci-ai-bot")
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
return {"status": "ok", "bot": BOT_NAME}
|
||||
|
||||
|
||||
def _verify(body: bytes, signature: str) -> bool:
|
||||
if not SECRET:
|
||||
return True
|
||||
mac = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(mac, signature or "")
|
||||
|
||||
|
||||
def handle_comment(event: dict) -> None:
|
||||
"""@mention → eine Chat-Antwort (neuer Kommentar, mit Thread-/Diff-Kontext)."""
|
||||
comment = (event.get("comment") or {}).get("body") or ""
|
||||
if f"@{BOT_NAME}" not in comment:
|
||||
return
|
||||
owner, name = event["repository"]["full_name"].split("/", 1)
|
||||
issue = event["issue"]
|
||||
index = issue["number"]
|
||||
question = comment.split(f"@{BOT_NAME}", 1)[1].strip() or "Fasse den Stand zusammen."
|
||||
|
||||
thread = gitea_api.get_issue_thread(owner, name, index)[:THREAD_CAP]
|
||||
prompt = f"Frage an dich: {question}\n\n--- Thread ---\n{thread}"
|
||||
if issue.get("pull_request"):
|
||||
try:
|
||||
diff = ai_review.filter_diff(gitea_api.get_pr_diff(owner, name, index))[:DIFF_CAP]
|
||||
if diff:
|
||||
prompt += f"\n\n--- Diff ---\n{diff}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
answer = llm.complete(CHAT_SYSTEM, prompt, max_tokens=1200)
|
||||
gitea_api.post_comment(owner, name, index, f"🤖 {answer}")
|
||||
print(f"[bot] chat reply on {owner}/{name}#{index}", flush=True)
|
||||
|
||||
|
||||
def handle_pr(event: dict) -> None:
|
||||
"""PR opened/synchronized → idempotentes KI-Review (gleicher Prompt wie CI)."""
|
||||
if event.get("action") not in ("opened", "synchronized", "reopened"):
|
||||
return
|
||||
owner, name = event["repository"]["full_name"].split("/", 1)
|
||||
pr = event["pull_request"]
|
||||
index = pr["number"]
|
||||
diff = ai_review.filter_diff(gitea_api.get_pr_diff(owner, name, index))[: ai_review.DIFF_CAP]
|
||||
if not diff:
|
||||
return
|
||||
answer = llm.complete(
|
||||
ai_review.SYSTEM,
|
||||
ai_review.PROMPT.format(title=pr.get("title", ""), body=(pr.get("body") or "")[:1000], diff=diff),
|
||||
max_tokens=1800,
|
||||
)
|
||||
gitea_api.upsert_comment(owner, name, index, ai_review.MARKER, f"## 🤖 KI-Review\n\n{answer}")
|
||||
print(f"[bot] review on {owner}/{name}#{index}", flush=True)
|
||||
|
||||
|
||||
@app.post("/webhook")
|
||||
async def webhook(request: Request, background: BackgroundTasks):
|
||||
body = await request.body()
|
||||
if not _verify(body, request.headers.get("X-Gitea-Signature", "")):
|
||||
return Response(status_code=401, content="bad signature")
|
||||
event = json.loads(body or b"{}")
|
||||
etype = request.headers.get("X-Gitea-Event", "")
|
||||
# Antworte SOFORT mit 200; die LLM-Arbeit läuft im Hintergrund (Gitea-Timeout-Schutz).
|
||||
if etype == "issue_comment" and event.get("action") == "created":
|
||||
background.add_task(handle_comment, event)
|
||||
elif etype == "pull_request":
|
||||
background.add_task(handle_pr, event)
|
||||
return {"ok": True, "event": etype}
|
||||
Reference in New Issue
Block a user