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

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
__pycache__/
*.pyc
.env

71
README.md Normal file
View File

@@ -0,0 +1,71 @@
# ci-ai — KI-Schicht für die Gitea-CI
Self-hosted Äquivalent zu **GitLab Duo** für Gitea + act_runner: automatische
PR-Reviews, PR-Summaries, KI-erklärte Security-Funde und CI-Failure-Analyse.
Läuft über den vorhandenen `gitea-runner` auf dem Pi.
## Bausteine
| Feature | Skript | Trigger |
|---|---|---|
| A — PR-Review + Summary | `scripts/ai_review.py` | `pull_request` |
| B — Security-Scan + KI-Erklärung (hart blockierend) | `security.yml` + `scripts/ai_explain.py` | `pull_request`, `push` |
| C — CI-Failure-Analyse | `if:failure()`-Job + `scripts/ai_explain.py` | Job-Fehler |
`scripts/llm.py` ist die **provider-agnostische** LLM-Schicht — heute Claude
(Anthropic), später der lokale Jetson (Ollama), umschaltbar per Env-Var.
## Aufbau
```
scripts/
llm.py Provider-Abstraktion (anthropic | ollama), stdlib-only
gitea_api.py PR-Diff holen + idempotente Marker-Kommentare
context.py Gitea-Actions-Event-Kontext (GITHUB_*-Vars)
ai_review.py Feature A
ai_explain.py Features B & C
workflows/ Beispiel-Workflows zum Kopieren in Ziel-Repos
```
Keine Abhängigkeiten — reine Python-Standardbibliothek, kein `pip install`.
## In ein Repo einbauen
1. Beispiel aus `workflows/` nach `.gitea/workflows/` im Ziel-Repo kopieren.
2. Im Repo (oder org-weit) die **Actions-Unit** aktivieren.
3. **Actions-Secret** `ANTHROPIC_API_KEY` setzen. Kommentiert wird per Auto-Token
(`${{ github.token }}`); reichen dessen Rechte nicht, ein `GITEA_BOT_TOKEN`
(PAT mit `write:issue`) als Secret hinterlegen.
4. Für „hart blockieren": in der Branch-Protection den `Security`-Check als
*required* markieren.
## Umstieg auf den Jetson (später)
Sobald das lokale Ollama auf dem Jetson läuft — nur die Workflow-Env ändern,
**kein Code-Change**:
```yaml
AI_PROVIDER: ollama
AI_MODEL: qwen-light
AI_BASE_URL: http://192.168.178.20:11434/v1
JETSON_API_KEY: ${{ secrets.JETSON_API_KEY }}
```
## Konfiguration (Env-Vars)
| Var | Default | Zweck |
|---|---|---|
| `AI_PROVIDER` | `anthropic` | `anthropic` oder `ollama` |
| `AI_MODEL` | `claude-3-5-haiku-latest` / `qwen-light` | Modell |
| `ANTHROPIC_API_KEY` | — | Pflicht bei provider=anthropic |
| `AI_BASE_URL` | `http://192.168.178.20:11434/v1` | Ollama-Endpoint |
| `JETSON_API_KEY` | — | Bearer für Ollama (optional) |
| `GITEA_API` | `https://git.eulernest.eu/api/v1` | Gitea-API-Basis |
| `GITEA_TOKEN` / `GITEA_BOT_TOKEN` | — | Token fürs Kommentieren |
## Constraints
- Runner hat **capacity 1** → Jobs laufen seriell.
- Docker-Executor abgesichert (kein privileged, keine Host-Mounts) → Trivy
`fs`/`config` + Gitleaks laufen; Scans *laufender* Container nicht (unnötig).
- Kosten (Anthropic, Haiku-Klasse, gedeckelter Diff): Bruchteile eines Cent/PR.

71
scripts/ai_explain.py Normal file
View File

@@ -0,0 +1,71 @@
"""Features B & C — Klartext-Erklärung von Scan-Funden / CI-Fehlern.
Aufruf:
python ai_explain.py <kind> <textdatei>
kind = "security" -> erklärt Trivy/Gitleaks-Funde (GitLab-Duo-Parität)
kind = "ci-failure" -> erklärt ein fehlgeschlagenes Job-Log
Postet das Ergebnis als idempotenten PR-Kommentar (eigener Marker je kind).
Ohne PR-Kontext (z.B. push ohne PR) wird nur ins Job-Log geschrieben.
"""
import sys
import context
import gitea_api
import llm
KINDS = {
"security": {
"marker": "<!-- ci-ai:security -->",
"heading": "## 🛡️ Security-Scan — Erklärung der Funde",
"system": (
"Du bist ein Security-Engineer. Erkläre die folgenden Scanner-Funde "
"(Trivy/Gitleaks) auf Deutsch in Klartext: was wurde gefunden, warum "
"ist es ein Risiko, und wie behebt man es konkret. Priorisiere nach "
"Schweregrad. Keine Funde erfinden."
),
"intro": "Der Security-Scan ist fehlgeschlagen. Hier die Funde im Klartext:",
},
"ci-failure": {
"marker": "<!-- ci-ai:ci-failure -->",
"heading": "## 🔧 CI-Fehler — Analyse",
"system": (
"Du bist ein erfahrener CI/CD-Engineer. Analysiere das folgende Log "
"eines fehlgeschlagenen CI-Jobs auf Deutsch: nenne die wahrscheinliche "
"Ursache und einen konkreten Fix. Knapp und konkret."
),
"intro": "Ein CI-Job ist fehlgeschlagen. Wahrscheinliche Ursache:",
},
}
LOG_CAP = 16000 # Zeichen aus dem Log (Ende ist meist am aussagekräftigsten)
def main() -> int:
if len(sys.argv) != 3 or sys.argv[1] not in KINDS:
print("Usage: ai_explain.py <security|ci-failure> <textdatei>", file=sys.stderr)
return 2
kind = KINDS[sys.argv[1]]
with open(sys.argv[2], encoding="utf-8", errors="replace") as fh:
text = fh.read()
if len(text) > LOG_CAP:
text = "[... Anfang gekürzt ...]\n" + text[-LOG_CAP:]
answer = llm.complete(kind["system"], f"{kind['intro']}\n\n```\n{text}\n```", max_tokens=1200)
body = f"{kind['heading']}\n\n{answer}"
owner, repo = context.repo_slug()
pr = context.pull_request()
if pr and pr["index"]:
cid = gitea_api.upsert_comment(owner, repo, pr["index"], kind["marker"], body)
print(f"[ai_explain] Kommentar gepostet/aktualisiert (id={cid}).")
else:
print("[ai_explain] Kein PR-Kontext — Ausgabe nur im Log:\n")
print(body)
return 0
if __name__ == "__main__":
sys.exit(main())

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())

43
scripts/context.py Normal file
View File

@@ -0,0 +1,43 @@
"""Liest den Gitea-Actions-Event-Kontext (GitHub-kompatible Env-Vars).
Gitea spiegelt die GITHUB_*-Variablen: GITHUB_REPOSITORY (owner/repo),
GITHUB_EVENT_PATH (JSON des auslösenden Events), GITHUB_EVENT_NAME.
"""
import json
import os
def repo_slug():
"""(owner, repo) aus GITHUB_REPOSITORY."""
full = os.environ.get("GITHUB_REPOSITORY", "")
if "/" not in full:
raise SystemExit("[context] GITHUB_REPOSITORY fehlt oder ist ungültig.")
owner, repo = full.split("/", 1)
return owner, repo
def event():
"""Geparstes Event-JSON (oder {} wenn nicht vorhanden)."""
path = os.environ.get("GITHUB_EVENT_PATH", "")
if path and os.path.exists(path):
with open(path, encoding="utf-8") as fh:
return json.load(fh)
return {}
def pull_request():
"""PR-Infos aus dem Event oder None, wenn das Event kein PR ist.
Liefert dict: index, title, body, author, base_ref, head_sha.
"""
pr = event().get("pull_request")
if not pr:
return None
return {
"index": pr.get("number"),
"title": pr.get("title", ""),
"body": pr.get("body", "") or "",
"author": (pr.get("user") or {}).get("login", ""),
"base_ref": (pr.get("base") or {}).get("ref", ""),
"head_sha": (pr.get("head") or {}).get("sha", ""),
}

70
scripts/gitea_api.py Normal file
View File

@@ -0,0 +1,70 @@
"""Schlanke Gitea-API-Helfer (stdlib only).
- PR-Diff holen
- Kommentare IDEMPOTENT posten: ein versteckter HTML-Marker identifiziert den
Bot-Kommentar; existiert er schon, wird er editiert statt neu angelegt
(kein Zuspammen bei jedem Push).
Env-Vars:
GITEA_API Basis-URL der API, Default https://git.eulernest.eu/api/v1
GITEA_TOKEN Token fürs Kommentieren (Actions-Auto-Token ODER PAT).
Fallback: GITEA_BOT_TOKEN.
"""
import json
import os
import urllib.error
import urllib.request
_BASE = os.environ.get("GITEA_API", "https://git.eulernest.eu/api/v1").rstrip("/")
_TOKEN = os.environ.get("GITEA_TOKEN") or os.environ.get("GITEA_BOT_TOKEN", "")
def _request(method: str, path: str, *, data=None, raw: bool = False):
url = f"{_BASE}{path}"
headers = {}
if _TOKEN:
headers["Authorization"] = f"token {_TOKEN}"
body = None
if data is not None:
body = json.dumps(data).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
content = resp.read().decode("utf-8", "replace")
if raw:
return content
return json.loads(content) if content.strip() else None
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
raise SystemExit(f"[gitea] {method} {path} -> HTTP {exc.code}: {detail[:400]}")
except urllib.error.URLError as exc:
raise SystemExit(f"[gitea] {method} {path} fehlgeschlagen: {exc.reason}")
def get_pr_diff(owner: str, repo: str, index: int) -> str:
"""Unified Diff eines PRs (Gitea unterstützt den .diff-Suffix)."""
return _request("GET", f"/repos/{owner}/{repo}/pulls/{index}.diff", raw=True)
def upsert_comment(owner: str, repo: str, index: int, marker: str, body: str) -> int:
"""Legt einen Kommentar an ODER editiert den vorhandenen mit gleichem Marker.
PR-Kommentare sind in Gitea Issue-Kommentare (gleicher Endpunkt).
"""
full = f"{body}\n\n{marker}"
existing = _request("GET", f"/repos/{owner}/{repo}/issues/{index}/comments") or []
for comment in existing:
if marker in (comment.get("body") or ""):
_request(
"PATCH",
f"/repos/{owner}/{repo}/issues/comments/{comment['id']}",
data={"body": full},
)
return comment["id"]
created = _request(
"POST",
f"/repos/{owner}/{repo}/issues/{index}/comments",
data={"body": full},
)
return created["id"]

92
scripts/llm.py Normal file
View File

@@ -0,0 +1,92 @@
"""Provider-agnostischer LLM-Client für die KI-CI.
Bewusst NUR stdlib (urllib) — so braucht der CI-Job kein `pip install` und
läuft in jedem schlanken Python-Image sofort.
Umschalten zwischen Backends über Env-Vars:
AI_PROVIDER=anthropic -> api.anthropic.com/v1/messages (Claude, jetzt)
AI_PROVIDER=ollama -> <AI_BASE_URL>/chat/completions (Jetson, später)
Weitere Env-Vars:
AI_MODEL Modellname (Default je nach Provider)
ANTHROPIC_API_KEY (für anthropic)
AI_BASE_URL OpenAI-kompatible Basis-URL (für ollama),
Default http://192.168.178.20:11434/v1
JETSON_API_KEY Bearer-Token (für ollama, optional)
Der Wechsel auf den Jetson ist damit ein reiner Env-Change in der Workflow-
Datei — KEINE Code-Änderung.
"""
import json
import os
import urllib.error
import urllib.request
def complete(system: str, user: str, *, max_tokens: int = 1500) -> str:
"""Schickt (system, user) ans konfigurierte Backend, gibt reinen Text zurück."""
provider = os.environ.get("AI_PROVIDER", "anthropic").lower()
if provider == "anthropic":
model = os.environ.get("AI_MODEL", "claude-3-5-haiku-latest")
return _anthropic(system, user, model, max_tokens)
if provider == "ollama":
model = os.environ.get("AI_MODEL", "qwen-light")
return _ollama_openai(system, user, model, max_tokens)
raise SystemExit(f"[llm] Unbekannter AI_PROVIDER: {provider!r}")
def _post(url: str, headers: dict, payload: dict) -> dict:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=180) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", "replace")
raise SystemExit(f"[llm] HTTP {exc.code} von {url}: {body[:600]}")
except urllib.error.URLError as exc:
raise SystemExit(f"[llm] Verbindung zu {url} fehlgeschlagen: {exc.reason}")
def _anthropic(system: str, user: str, model: str, max_tokens: int) -> str:
try:
key = os.environ["ANTHROPIC_API_KEY"]
except KeyError:
raise SystemExit("[llm] ANTHROPIC_API_KEY ist nicht gesetzt.")
resp = _post(
"https://api.anthropic.com/v1/messages",
{
"x-api-key": key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
{
"model": model,
"max_tokens": max_tokens,
"system": system,
"messages": [{"role": "user", "content": user}],
},
)
parts = [b.get("text", "") for b in resp.get("content", []) if b.get("type") == "text"]
return "".join(parts).strip()
def _ollama_openai(system: str, user: str, model: str, max_tokens: int) -> str:
base = os.environ.get("AI_BASE_URL", "http://192.168.178.20:11434/v1").rstrip("/")
headers = {"content-type": "application/json"}
key = os.environ.get("JETSON_API_KEY", "")
if key:
headers["Authorization"] = f"Bearer {key}"
resp = _post(
f"{base}/chat/completions",
headers,
{
"model": model,
"max_tokens": max_tokens,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
},
)
return resp["choices"][0]["message"]["content"].strip()

View File

@@ -0,0 +1,40 @@
# Feature A — KI-PR-Review + Summary.
# In ein Ziel-Repo kopieren nach: .gitea/workflows/ai-review.yml
#
# Nutzt KEINE JS-Actions (kein node nötig): schlankes python-Image, ci-ai wird
# manuell geklont. GITHUB_REPOSITORY / GITHUB_EVENT_PATH setzt Gitea automatisch.
name: AI PR Review
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
review:
runs-on: eulernest
container: python:3.12-slim
steps:
- name: Tooling (git)
run: apt-get update -qq && apt-get install -y -qq --no-install-recommends git ca-certificates
- name: ci-ai-Skripte holen
run: git clone --depth 1 https://git.eulernest.eu/user2595/ci-ai.git /opt/ci-ai
- name: KI-Review
env:
AI_PROVIDER: anthropic
AI_MODEL: claude-3-5-haiku-latest
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
# Kommentieren: Actions-Auto-Token. Reicht dessen Schreibrecht nicht,
# stattdessen GITEA_BOT_TOKEN (PAT) als Repo-/Org-Secret setzen.
GITEA_TOKEN: ${{ github.token }}
GITEA_API: https://git.eulernest.eu/api/v1
run: python /opt/ci-ai/scripts/ai_review.py
# ─────────────────────────────────────────────────────────────────────────────
# Späterer Umstieg auf den Jetson (lokales Ollama) — NUR diese env-Werte ändern,
# kein Code-Change:
# AI_PROVIDER: ollama
# AI_MODEL: qwen-light
# AI_BASE_URL: http://192.168.178.20:11434/v1
# JETSON_API_KEY: ${{ secrets.JETSON_API_KEY }}

View File

@@ -0,0 +1,41 @@
# Feature C — KI-Analyse fehlgeschlagener CI-Jobs.
# KEIN eigenständiger Workflow, sondern ein MUSTER: diesen if:failure()-Job ans
# Ende eines echten Build/Test-Workflows hängen (needs: auf die CI-Jobs setzen).
# Er nimmt das geteilte Job-Log und lässt die KI die Ursache erklären.
#
# Beispiel-Einbettung in einen vorhandenen Workflow:
#
# jobs:
# build:
# runs-on: eulernest
# container: ghcr.io/catthehacker/ubuntu:act-latest
# steps:
# - uses: actions/checkout@v4
# - name: Build
# run: |
# set -o pipefail
# make 2>&1 | tee /tmp/ci.log # <-- Log mitschneiden
#
# explain-on-failure:
# needs: [build]
# if: failure()
# runs-on: eulernest
# container: python:3.12-slim
# steps:
# - name: Tooling
# run: apt-get update -qq && apt-get install -y -qq --no-install-recommends git ca-certificates
# - name: KI-Analyse
# env:
# AI_PROVIDER: anthropic
# AI_MODEL: claude-3-5-haiku-latest
# ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
# GITEA_TOKEN: ${{ github.token }}
# GITEA_API: https://git.eulernest.eu/api/v1
# run: |
# git clone --depth 1 https://git.eulernest.eu/user2595/ci-ai.git /opt/ci-ai
# python /opt/ci-ai/scripts/ai_explain.py ci-failure /tmp/ci.log
#
# Hinweis: /tmp wird zwischen Jobs NICHT automatisch geteilt. Für echtes Cross-
# Job-Log-Sharing das Log als actions/upload-artifact hochladen und im Folge-Job
# wieder herunterladen — ODER (einfacher) den explain-Schritt als if:failure()
# direkt in DENSELBEN Job hängen, wie in security.example.yml gezeigt.

View File

@@ -0,0 +1,60 @@
# Feature B (+C) — Security-Scan, HART blockierend, mit KI-Klartext-Erklärung.
# In ein Ziel-Repo kopieren nach: .gitea/workflows/security.yml
#
# Ablauf: scannen (ohne sofort abzubrechen) -> Gate (failt hart bei Funden) ->
# bei Fehler erklärt die KI die Funde im PR. So failt der Check verlässlich UND
# der Erklär-Schritt sieht beide Reports.
name: Security
on:
pull_request:
push:
branches: [main, master]
jobs:
scan:
runs-on: eulernest
container: ghcr.io/catthehacker/ubuntu:act-latest
steps:
- name: Checkout (volle Historie für Gitleaks)
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Gitleaks (Secret-Scan)
id: gitleaks
continue-on-error: true
run: |
curl -sSL https://github.com/gitleaks/gitleaks/releases/latest/download/gitleaks_linux_x64.tar.gz \
| tar -xz -C /usr/local/bin gitleaks
gitleaks detect --source . --report-format json --report-path /tmp/gitleaks.json --no-banner \
| tee /tmp/gitleaks.txt; echo "code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
- name: Trivy (fs + IaC, HIGH/CRITICAL)
id: trivy
continue-on-error: true
run: |
curl -sSL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/bin
trivy fs --scanners vuln,secret,misconfig --severity HIGH,CRITICAL \
--exit-code 1 --no-progress . | tee /tmp/trivy.txt; echo "code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
- name: Gate — hart blockieren bei Funden
run: |
cat /tmp/gitleaks.txt /tmp/trivy.txt > /tmp/scan.log 2>/dev/null || true
if [ "${{ steps.gitleaks.outputs.code }}" != "0" ] || [ "${{ steps.trivy.outputs.code }}" != "0" ]; then
echo "Security-Funde — Check schlägt fehl (hart blockierend)."; exit 1
fi
echo "Keine HIGH/CRITICAL-Funde."
- name: KI erklärt die Funde (nur bei Fehler)
if: failure()
env:
AI_PROVIDER: anthropic
AI_MODEL: claude-3-5-haiku-latest
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITEA_TOKEN: ${{ github.token }}
GITEA_API: https://git.eulernest.eu/api/v1
run: |
git clone --depth 1 https://git.eulernest.eu/user2595/ci-ai.git /opt/ci-ai
python3 /opt/ci-ai/scripts/ai_explain.py security /tmp/scan.log