From ddeb3142e77d3722d94ed9651c19db5fb05133b3 Mon Sep 17 00:00:00 2001 From: Tarik Moussa Date: Fri, 12 Jun 2026 22:23:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(bot):=20interactive=20Gitea=20webhook=20bo?= =?UTF-8?q?t=20(FastAPI)=20=E2=80=94=20@mention=20chat=20+=20auto=20PR=20r?= =?UTF-8?q?eview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bot/Dockerfile | 12 +++++ bot/app.py | 113 +++++++++++++++++++++++++++++++++++++++++++ scripts/gitea_api.py | 11 +++++ 3 files changed, 136 insertions(+) create mode 100644 bot/Dockerfile create mode 100644 bot/app.py diff --git a/bot/Dockerfile b/bot/Dockerfile new file mode 100644 index 0000000..e5de38c --- /dev/null +++ b/bot/Dockerfile @@ -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"] diff --git a/bot/app.py b/bot/app.py new file mode 100644 index 0000000..14132b0 --- /dev/null +++ b/bot/app.py @@ -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 `@` → 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} diff --git a/scripts/gitea_api.py b/scripts/gitea_api.py index 4daad63..57e989c 100644 --- a/scripts/gitea_api.py +++ b/scripts/gitea_api.py @@ -86,3 +86,14 @@ def upsert_comment(owner: str, repo: str, index: int, marker: str, body: str) -> data={"body": full}, ) 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"]