diff --git a/.gitea/workflows/reusable-commands.yml b/.gitea/workflows/reusable-commands.yml new file mode 100644 index 0000000..5be4a50 --- /dev/null +++ b/.gitea/workflows/reusable-commands.yml @@ -0,0 +1,43 @@ +# GLOBALE, wiederverwendbare Slash-Commands (Feature D + E). +# Wird vom Caller bei einem issue_comment ausgelöst: +# /gen-tests → KI-Test-Vorschläge für den PR +# /summarize → KI-Zusammenfassung des Issue-/PR-Threads +# +# Aufruf (siehe workflows/caller.example.yml): +# commands: +# if: github.event_name == 'issue_comment' && ... +# uses: user2595/ci-ai/.gitea/workflows/reusable-commands.yml@main +# secrets: inherit +name: ci-ai · commands (reusable) + +on: + workflow_call: + secrets: + ANTHROPIC_API_KEY: + required: false + +jobs: + command: + runs-on: eulernest + container: + image: python:3.12-slim + options: "--memory=512m --memory-swap=768m" + 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 + steps: + - name: Tooling + ci-ai (anon) + run: | + apt-get update -qq && apt-get install -y -qq --no-install-recommends git ca-certificates + git clone --depth 1 https://git.eulernest.eu/user2595/ci-ai.git /opt/ci-ai + + - name: /gen-tests + if: startsWith(github.event.comment.body, '/gen-tests') + run: python /opt/ci-ai/scripts/ai_tests.py + + - name: /summarize + if: startsWith(github.event.comment.body, '/summarize') + run: python /opt/ci-ai/scripts/ai_summary.py diff --git a/README.md b/README.md index 07d9552..412cfb6 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,11 @@ Läuft über den vorhandenen `gitea-runner` auf dem Pi. | 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 | +| D — Test-Generierung (Vorschlag) | `scripts/ai_tests.py` | Kommentar `/gen-tests` (PR) | +| E — Issue-/PR-Zusammenfassung | `scripts/ai_summary.py` | Kommentar `/summarize` | + +**Slash-Commands** (D + E): in einem Issue/PR `/gen-tests` bzw. `/summarize` kommentieren +→ der `commands`-Job (siehe `caller.example.yml`) postet das Ergebnis als Kommentar. `scripts/llm.py` ist die **provider-agnostische** LLM-Schicht — heute Claude (Anthropic), später der lokale Jetson (Ollama), umschaltbar per Env-Var. @@ -24,9 +29,12 @@ scripts/ context.py Gitea-Actions-Event-Kontext (GITHUB_*-Vars) ai_review.py Feature A ai_explain.py Features B & C + ai_tests.py Feature D (/gen-tests) + ai_summary.py Feature E (/summarize) .gitea/workflows/ reusable-security.yml on: workflow_call — zentraler Security-Scan reusable-ai-review.yml on: workflow_call — zentraler PR-Review + reusable-commands.yml on: workflow_call — Slash-Commands (/gen-tests, /summarize) workflows/ caller.example.yml der EINE 12-Zeiler fürs Ziel-Repo (empfohlen) *.example.yml standalone-Workflows (Alternative: kopieren) diff --git a/scripts/ai_summary.py b/scripts/ai_summary.py new file mode 100644 index 0000000..953636a --- /dev/null +++ b/scripts/ai_summary.py @@ -0,0 +1,53 @@ +"""Feature E — KI-Zusammenfassung langer Issue-/PR-Diskussionen. + +Per Slash-Command `/summarize` an einem Issue oder PR (issue_comment). Holt +Titel/Body + alle menschlichen Kommentare und fasst Diskussion, Stand und offene +Punkte zusammen — als idempotenten Kommentar. +""" +import sys + +import context +import gitea_api +import llm + +MARKER = "" +THREAD_CAP = 24000 + +SYSTEM = ( + "Du fasst Issue-/PR-Diskussionen prägnant auf Deutsch zusammen: worum es geht, " + "welche Entscheidungen/Argumente fielen und was offen ist. Keine Erfindungen — " + "nur was im Thread steht." +) + +PROMPT = """\ +Fasse die folgende Diskussion zusammen. Genau diese drei Abschnitte (Markdown): + +### Worum geht's +### Stand & Entscheidungen +### Offene Punkte / nächste Schritte + +--- THREAD --- +{thread} +""" + + +def main() -> int: + owner, repo = context.repo_slug() + iss = context.issue() + index = iss["index"] if iss else (context.pull_request() or {}).get("index") + if not index: + print("[ai_summary] Kein Issue/PR-Kontext. Übersprungen.") + return 0 + ok, why = llm.configured() + if not ok: + print(f"[ai_summary] KI inaktiv ({why}) — übersprungen, kein Fehler.") + return 0 + thread = gitea_api.get_issue_thread(owner, repo, index)[:THREAD_CAP] + answer = llm.complete(SYSTEM, PROMPT.format(thread=thread), max_tokens=1500) + gitea_api.upsert_comment(owner, repo, index, MARKER, f"## 📝 KI-Zusammenfassung\n\n{answer}") + print("[ai_summary] Kommentar gepostet/aktualisiert.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ai_tests.py b/scripts/ai_tests.py new file mode 100644 index 0000000..457a7ac --- /dev/null +++ b/scripts/ai_tests.py @@ -0,0 +1,68 @@ +"""Feature D — KI-Test-Generierung (Vorschlag als PR-Kommentar). + +Per Slash-Command `/gen-tests` an einem PR (issue_comment) ODER aus einem +pull_request-Event. Holt den Diff, lässt das LLM passende Unit-Tests vorschlagen +und postet sie als idempotenten Kommentar. Bewusst NUR Vorschlag — kein +Auto-Commit (der Mensch entscheidet, was übernommen wird). +""" +import sys + +import context +import gitea_api +import llm + +MARKER = "" +DIFF_CAP = 30000 + +SYSTEM = ( + "Du bist ein erfahrener Test-Engineer. Schlage sinnvolle, lauffähige Unit-Tests " + "für die geänderten Stellen vor. Erkenne Sprache und Test-Framework aus dem Diff. " + "Antworte auf Deutsch, Test-Code in passenden Code-Blöcken. Keine Tests für reine " + "Trivialitäten; erfinde keine nicht vorhandenen APIs." +) + +PROMPT = """\ +Generiere Unit-Tests für die Änderungen in diesem Pull-Request. + +PR-Titel: {title} + +Pro relevanter Datei/Funktion: ein kurzer Hinweis + der Test-Code (richtige Sprache, +lauffähig, Framework benennen). Wenn nichts sinnvoll testbar ist, sag das ehrlich. + +--- DIFF --- +{diff} +""" + + +def _pr_index(): + pr = context.pull_request() + if pr and pr.get("index"): + return pr["index"], pr.get("title", "") + iss = context.issue() + if iss and iss.get("is_pr"): + return iss["index"], iss.get("title", "") + return None, "" + + +def main() -> int: + owner, repo = context.repo_slug() + index, title = _pr_index() + if not index: + print("[ai_tests] Kein PR-Kontext — Test-Generierung läuft nur auf PRs. Übersprungen.") + return 0 + ok, why = llm.configured() + if not ok: + print(f"[ai_tests] KI inaktiv ({why}) — übersprungen, kein Fehler.") + return 0 + diff = gitea_api.get_pr_diff(owner, repo, index) + if not diff.strip(): + print("[ai_tests] Leerer Diff — nichts zu testen.") + return 0 + answer = llm.complete(SYSTEM, PROMPT.format(title=title, diff=diff[:DIFF_CAP]), max_tokens=2000) + gitea_api.upsert_comment(owner, repo, index, MARKER, f"## 🧪 KI-Test-Vorschläge\n\n{answer}") + print("[ai_tests] Kommentar gepostet/aktualisiert.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/context.py b/scripts/context.py index bccbd67..aaf5939 100644 --- a/scripts/context.py +++ b/scripts/context.py @@ -41,3 +41,30 @@ def pull_request(): "base_ref": (pr.get("base") or {}).get("ref", ""), "head_sha": (pr.get("head") or {}).get("sha", ""), } + + +def event_name(): + """Name des auslösenden Events (push, pull_request, issue_comment, …).""" + return os.environ.get("GITHUB_EVENT_NAME", "") + + +def comment_body(): + """Body des auslösenden Kommentars (issue_comment-Event) oder ''.""" + return (event().get("comment") or {}).get("body") or "" + + +def issue(): + """Issue/PR-Infos aus einem issue_comment-Event oder None. + + Liefert dict: index, title, body, author, is_pr. + """ + iss = event().get("issue") + if not iss: + return None + return { + "index": iss.get("number"), + "title": iss.get("title", ""), + "body": iss.get("body", "") or "", + "author": (iss.get("user") or {}).get("login", ""), + "is_pr": bool(iss.get("pull_request")), + } diff --git a/scripts/gitea_api.py b/scripts/gitea_api.py index 03873fd..4daad63 100644 --- a/scripts/gitea_api.py +++ b/scripts/gitea_api.py @@ -47,6 +47,24 @@ def get_pr_diff(owner: str, repo: str, index: int) -> str: return _request("GET", f"/repos/{owner}/{repo}/pulls/{index}.diff", raw=True) +def get_issue_thread(owner: str, repo: str, index: int) -> str: + """Titel + Body + alle MENSCHLICHEN Kommentare eines Issues/PRs als Text. + + Eigene Bot-Kommentare (Marker `