"""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 `@` → 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}