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>
114 lines
4.4 KiB
Python
114 lines
4.4 KiB
Python
"""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}
|