ci-ai: KI-Schicht für die Gitea-CI (PR-Review, Security-Erklärung, CI-Failure-Analyse)

Provider-agnostische LLM-Anbindung (Anthropic jetzt, Jetson/Ollama später per
Env-Var umschaltbar). Stdlib-only Skripte + Beispiel-Workflows. GitLab-Duo-Parität
für self-hosted Gitea + act_runner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 15:38:53 +02:00
commit daf89ff8b9
10 changed files with 592 additions and 0 deletions

101
scripts/ai_review.py Normal file
View File

@@ -0,0 +1,101 @@
"""Feature A — KI-PR-Review + Summary.
Holt den PR-Diff über die Gitea-API, lässt ihn vom konfigurierten LLM
zusammenfassen + reviewen und postet das Ergebnis als EINEN idempotenten
Kommentar (Marker -> editiert sich bei jedem Push, statt zu spammen).
Erwartete Env-Vars: siehe llm.py + gitea_api.py, plus die Actions-Kontext-
Variablen (GITHUB_REPOSITORY, GITHUB_EVENT_PATH).
"""
import sys
import context
import gitea_api
import llm
MARKER = "<!-- ci-ai:review -->"
DIFF_CAP = 40000 # Zeichen — darüber wird der Diff gekürzt (Token-/Kostenschutz)
# Dateien, die für ein Review wenig Sinn ergeben (Lockfiles, Binär, generiert).
SKIP_TOKENS = (
".lock", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
".min.js", ".min.css", ".map",
".png", ".jpg", ".jpeg", ".gif", ".ico", ".pdf", ".woff", ".woff2", ".ttf",
"/vendor/", "/node_modules/", "/dist/", "/build/",
)
SYSTEM = (
"Du bist ein erfahrener, pragmatischer Code-Reviewer. Antworte auf Deutsch, "
"knapp und konkret. Erfinde nichts, was nicht im Diff steht."
)
PROMPT = """\
Reviewe den folgenden Pull-Request.
PR-Titel: {title}
PR-Beschreibung: {body}
Gib genau diese zwei Abschnitte aus (Markdown):
### Zusammenfassung
24 Sätze: Was ändert dieser PR und warum.
### Review
Stichpunkte zu: möglichen Bugs, Sicherheits-/Edge-Cases, fehlenden Tests,
Stil/Lesbarkeit. Nur echte, im Diff belegbare Punkte — wenn alles gut aussieht,
sag das ehrlich. Markiere ernste Probleme mit ⚠️.
--- DIFF ---
{diff}
"""
def filter_diff(diff: str) -> str:
"""Entfernt uninteressante Dateien (per `diff --git`-Sektion)."""
if not diff:
return ""
sections = diff.split("diff --git ")
kept = []
for sec in sections[1:]: # sections[0] ist der Vorspann (leer)
header = sec.split("\n", 1)[0].lower()
if any(tok in header for tok in SKIP_TOKENS):
continue
kept.append("diff --git " + sec)
return "".join(kept).strip()
def main() -> int:
owner, repo = context.repo_slug()
pr = context.pull_request()
if not pr or not pr["index"]:
print("[ai_review] Kein Pull-Request im Event — übersprungen.")
return 0
if pr["author"].lower().endswith("-bot") or pr["author"].lower() == "ci-ai":
print("[ai_review] PR vom Bot selbst — übersprungen.")
return 0
diff = filter_diff(gitea_api.get_pr_diff(owner, repo, pr["index"]))
if not diff:
print("[ai_review] Leerer/gefilterter Diff — nichts zu reviewen.")
return 0
truncated = False
if len(diff) > DIFF_CAP:
diff = diff[:DIFF_CAP]
truncated = True
answer = llm.complete(
SYSTEM,
PROMPT.format(title=pr["title"], body=pr["body"][:1000], diff=diff),
max_tokens=1800,
)
note = "\n\n_⚠ Diff war groß und wurde gekürzt — Review nur über den Anfang._" if truncated else ""
body = f"## 🤖 KI-Review\n\n{answer}{note}"
cid = gitea_api.upsert_comment(owner, repo, pr["index"], MARKER, body)
print(f"[ai_review] Kommentar gepostet/aktualisiert (id={cid}).")
return 0
if __name__ == "__main__":
sys.exit(main())