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:
43
.gitea/workflows/reusable-commands.yml
Normal file
43
.gitea/workflows/reusable-commands.yml
Normal 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
|
||||||
@@ -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
53
scripts/ai_summary.py
Normal 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
68
scripts/ai_tests.py
Normal 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())
|
||||||
@@ -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")),
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user