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:
2026-06-12 22:23:00 +02:00
parent 2ae14a51e4
commit ddeb3142e7
3 changed files with 136 additions and 0 deletions

12
bot/Dockerfile Normal file
View 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
View 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}

View File

@@ -86,3 +86,14 @@ def upsert_comment(owner: str, repo: str, index: int, marker: str, body: str) ->
data={"body": full}, data={"body": full},
) )
return created["id"] return created["id"]
def post_comment(owner: str, repo: str, index: int, body: str) -> int:
"""Legt einen NEUEN Kommentar an (nicht idempotent — für Chat-Antworten,
wo jede @mention ihre eigene Antwort bekommen soll)."""
created = _request(
"POST",
f"/repos/{owner}/{repo}/issues/{index}/comments",
data={"body": body},
)
return created["id"]