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:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
71
README.md
Normal file
71
README.md
Normal 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
71
scripts/ai_explain.py
Normal 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
101
scripts/ai_review.py
Normal 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
|
||||||
|
2–4 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
43
scripts/context.py
Normal 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
70
scripts/gitea_api.py
Normal 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
92
scripts/llm.py
Normal 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()
|
||||||
40
workflows/ai-review.example.yml
Normal file
40
workflows/ai-review.example.yml
Normal 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 }}
|
||||||
41
workflows/ci-failure.example.yml
Normal file
41
workflows/ci-failure.example.yml
Normal 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.
|
||||||
60
workflows/security.example.yml
Normal file
60
workflows/security.example.yml
Normal 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
|
||||||
Reference in New Issue
Block a user