feat: test-generation + issue/PR summary via slash commands (/gen-tests, /summarize)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 07:09:11 +02:00
parent dc35579135
commit eb0437b0ed
7 changed files with 233 additions and 0 deletions

View File

@@ -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

View File

@@ -11,6 +11,11 @@ Läuft über den vorhandenen `gitea-runner` auf dem Pi.
| A — PR-Review + Summary | `scripts/ai_review.py` | `pull_request` | | 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` | | 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 | | 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 `scripts/llm.py` ist die **provider-agnostische** LLM-Schicht — heute Claude
(Anthropic), später der lokale Jetson (Ollama), umschaltbar per Env-Var. (Anthropic), später der lokale Jetson (Ollama), umschaltbar per Env-Var.
@@ -24,9 +29,12 @@ scripts/
context.py Gitea-Actions-Event-Kontext (GITHUB_*-Vars) context.py Gitea-Actions-Event-Kontext (GITHUB_*-Vars)
ai_review.py Feature A ai_review.py Feature A
ai_explain.py Features B & C ai_explain.py Features B & C
ai_tests.py Feature D (/gen-tests)
ai_summary.py Feature E (/summarize)
.gitea/workflows/ .gitea/workflows/
reusable-security.yml on: workflow_call — zentraler Security-Scan reusable-security.yml on: workflow_call — zentraler Security-Scan
reusable-ai-review.yml on: workflow_call — zentraler PR-Review reusable-ai-review.yml on: workflow_call — zentraler PR-Review
reusable-commands.yml on: workflow_call — Slash-Commands (/gen-tests, /summarize)
workflows/ workflows/
caller.example.yml der EINE 12-Zeiler fürs Ziel-Repo (empfohlen) caller.example.yml der EINE 12-Zeiler fürs Ziel-Repo (empfohlen)
*.example.yml standalone-Workflows (Alternative: kopieren) *.example.yml standalone-Workflows (Alternative: kopieren)

53
scripts/ai_summary.py Normal file
View File

@@ -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 = "<!-- ci-ai:summary -->"
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())

68
scripts/ai_tests.py Normal file
View File

@@ -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 = "<!-- ci-ai:tests -->"
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())

View File

@@ -41,3 +41,30 @@ def pull_request():
"base_ref": (pr.get("base") or {}).get("ref", ""), "base_ref": (pr.get("base") or {}).get("ref", ""),
"head_sha": (pr.get("head") or {}).get("sha", ""), "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")),
}

View File

@@ -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) 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 `<!-- ci-ai:`) werden übersprungen, damit das
Summary nicht die eigene Ausgabe mitliest.
"""
iss = _request("GET", f"/repos/{owner}/{repo}/issues/{index}")
parts = [f"# {iss.get('title', '')}\n\n{iss.get('body') or '(kein Text)'}"]
comments = _request("GET", f"/repos/{owner}/{repo}/issues/{index}/comments") or []
for c in comments:
body = c.get("body") or ""
if "<!-- ci-ai:" in body:
continue
author = (c.get("user") or {}).get("login", "?")
parts.append(f"--- @{author}:\n{body}")
return "\n\n".join(parts)
def upsert_comment(owner: str, repo: str, index: int, marker: str, body: str) -> int: 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. """Legt einen Kommentar an ODER editiert den vorhandenen mit gleichem Marker.

View File

@@ -4,6 +4,11 @@
# Holt die komplette Logik zentral aus user2595/ci-ai (reusable workflows) — keine # Holt die komplette Logik zentral aus user2595/ci-ai (reusable workflows) — keine
# Skripte/Workflows ins Repo kopieren. Update der Logik passiert einmal in ci-ai. # Skripte/Workflows ins Repo kopieren. Update der Logik passiert einmal in ci-ai.
# #
# Features:
# - security : Gitleaks + Trivy, hart blockierend (push/PR)
# - review : KI-PR-Review + Summary (PR)
# - commands : Slash-Commands im Kommentar — `/gen-tests`, `/summarize`
#
# Voraussetzungen (einmalig zentral eingerichtet): # Voraussetzungen (einmalig zentral eingerichtet):
# 1. Runner instanzweit registriert (Label `eulernest`) # 1. Runner instanzweit registriert (Label `eulernest`)
# 2. anonymer Clone für public Repos erlaubt # 2. anonymer Clone für public Repos erlaubt
@@ -17,9 +22,12 @@ on:
pull_request: pull_request:
push: push:
branches: [main, master] branches: [main, master]
issue_comment:
types: [created]
jobs: jobs:
security: security:
if: github.event_name != 'issue_comment'
uses: user2595/ci-ai/.gitea/workflows/reusable-security.yml@main uses: user2595/ci-ai/.gitea/workflows/reusable-security.yml@main
secrets: inherit secrets: inherit
@@ -27,3 +35,11 @@ jobs:
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
uses: user2595/ci-ai/.gitea/workflows/reusable-ai-review.yml@main uses: user2595/ci-ai/.gitea/workflows/reusable-ai-review.yml@main
secrets: inherit secrets: inherit
commands:
if: >-
github.event_name == 'issue_comment' &&
(startsWith(github.event.comment.body, '/gen-tests') ||
startsWith(github.event.comment.body, '/summarize'))
uses: user2595/ci-ai/.gitea/workflows/reusable-commands.yml@main
secrets: inherit