diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c8b8740 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.12"] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install + run: python -m pip install -e ".[dev]" + - name: Lint + run: ruff check . + - name: Unit and contract tests + run: python -m pytest -q + - name: Consumer install test (fresh venv) + env: + GITHUBBOT_CORE_CONSUMER_TEST: "1" + run: python -m pytest -q tests/test_consumer_install.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a86b6ab --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog / Änderungsprotokoll + +Alle wesentlichen Änderungen an diesem Projekt werden hier dokumentiert. +All notable changes to this project are documented here. +Format: [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), [Semantic Versioning](https://semver.org/). + +## [Unreleased] + +## [0.1.0] - 2026-10-11 + +### Hinzugefügt / Added + +- Installierbares Paket `githubbot-core` mit CLI `githubbot-core` und JSON-Ergebnishülle + `githubbot-core.result/v1` / installable package with CLI and JSON result envelope. +- Issue-Triage, Duplikaterkennung, PR-Zusammenfassung, Health-/Traffic-/Sterne-Berichte / + issue triage, duplicate detection, PR summary, health/traffic/star reports. +- Vorschau/Ausführen-Vertrag mit Rechteobergrenze, Belegen und Idempotenz / + preview/apply contract with permission ceiling, receipts and idempotency. +- Geschichtete Konfiguration, Obergrenzen nur einengbar / layered configuration, ceilings narrow-only. +- Plugin-SPI v1 (Entry-Point-Gruppe `githubbot_core.plugins`) / plugin SPI v1. +- Credential-Adapter (Umgebung, `gh auth token`) mit Schwärzung / credential adapter with redaction. +- Namensraumunabhängiges Exact-SHA-Review-Gate samt Workflow-Scaffold / + namespace-independent exact-SHA review gate with workflow scaffold. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..6ca3366 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,73 @@ +# Verhaltenskodex / Code of Conduct + +## Deutsch + +### Unser Versprechen + +Wir als Mitglieder, Beitragende und Projektverantwortliche verpflichten uns, +die Teilnahme an unserer Community zu einer belästigungsfreien Erfahrung für +alle zu machen, unabhängig von Alter, Körpergröße, sichtbarer oder +unsichtbarer Behinderung, ethnischer Zugehörigkeit, Geschlechtsmerkmalen, +Geschlechtsidentität und -ausdruck, Erfahrungsgrad, Bildung, +sozioökonomischem Status, Nationalität, persönlichem Aussehen, Herkunft, +Religion oder sexueller Identität und Orientierung. + +### Unsere Standards + +Beispiele für Verhalten, das zu einem positiven Umfeld beiträgt: +- Verwendung einladender und inklusiver Sprache +- Respektvoller Umgang mit unterschiedlichen Standpunkten und Erfahrungen +- Konstruktive Kritik höflich annehmen +- Fokus auf das Beste für die Community + +Inakzeptables Verhalten umfasst: +- Trolling, beleidigende Kommentare und persönliche oder politische Angriffe +- Öffentliche oder private Belästigung +- Veröffentlichung privater Informationen anderer ohne ausdrückliche Erlaubnis + +### Meldung und Durchsetzung + +Wenn Sie problematisches Verhalten beobachten, melden Sie es bitte über die im +jeweiligen Repository vorgesehenen GitHub-Kanäle oder nehmen Sie direkt über +GitHub Kontakt mit den Maintainern auf. Sensible Meldungen sollen nicht in +öffentlichen Issues gepostet werden. + +Alle Beschwerden werden geprüft und angemessen bearbeitet. + +--- + +## English + +### Our Pledge + +We as members, contributors, and project maintainers pledge to make +participation in our community a harassment-free experience for everyone, +regardless of age, body size, visible or invisible disability, ethnicity, sex +characteristics, gender identity and expression, level of experience, +education, socio-economic status, nationality, personal appearance, race, +religion, or sexual identity and orientation. + +### Our Standards + +Examples of behavior that contributes to a positive environment: +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community + +Unacceptable behavior includes: +- Trolling, insulting comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission + +### Reporting and Enforcement + +If you observe abusive behavior, please use the GitHub contact channels +provided by the repository or contact the maintainers directly through GitHub. +Sensitive reports should not be posted in public issues. + +All complaints will be reviewed and handled appropriately. + +--- + +*Basierend auf dem [Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/)* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3604e23 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,87 @@ +# Beitragsrichtlinie / Contributing Guide + +## Deutsch + +Vielen Dank für Ihr Interesse, zu diesem Projekt beizutragen! + +### Wie Sie beitragen koennen + +1. **Bug melden:** Erstellen Sie ein Issue mit dem Label `bug` +2. **Feature vorschlagen:** Erstellen Sie ein Issue mit dem Label `enhancement` +3. **Code beitragen:** Erstellen Sie einen Pull Request + +### Pull Requests + +1. Forken Sie das Repository +2. Erstellen Sie einen Feature-Branch: `git checkout -b feature/mein-feature` +3. Committen Sie Ihre Änderungen: `git commit -m "Beschreibung der Änderung"` +4. Pushen Sie den Branch: `git push origin feature/mein-feature` +5. Erstellen Sie einen Pull Request + +### Lizenz- und Rechtehinweise + +Falls dieses Projekt ein CLA, DCO oder eine andere Beitragsvereinbarung nutzt, +ist dies im Repository gesondert dokumentiert. Ohne ausdrückliche Zusatzregel +gelten Pull Requests unter der Lizenz des Projekts. + +### Code-Richtlinien + +- Python: PEP 8 Stil, nur Standardbibliothek zur Laufzeit +- Encoding: UTF-8 für alle Dateien +- Sprache: Code und Kommentare auf Deutsch oder Englisch +- Keine hardcoded Pfade oder API-Keys + +### Erste Schritte + +```bash +pip install -e ".[dev]" +python -m pytest +ruff check . +``` + +Schreibende Funktionen laufen ausschließlich über `githubbot_core.actions` (Vorschau/Ausführen, +Rechteobergrenze, Belege). Neue Schreibaktionen bitte dort ergänzen, nicht direkt per API. + +--- + +## English + +Thank you for your interest in contributing to this project! + +### How to Contribute + +1. **Report bugs:** Create an issue with the `bug` label +2. **Suggest features:** Create an issue with the `enhancement` label +3. **Contribute code:** Create a Pull Request + +### Pull Requests + +1. Fork the repository +2. Create a feature branch: `git checkout -b feature/my-feature` +3. Commit your changes: `git commit -m "Description of change"` +4. Push the branch: `git push origin feature/my-feature` +5. Create a Pull Request + +### Licensing and Contribution Terms + +If this project uses a CLA, DCO, or any other contribution agreement, it +should be documented separately in the repository. Unless stated otherwise, +pull requests are understood to be submitted under the project's license. + +### Code Guidelines + +- Python: PEP 8 style, standard library only at runtime +- Encoding: UTF-8 for all files +- Language: Code and comments in German or English +- No hardcoded paths or API keys + +### Getting Started + +```bash +pip install -e ".[dev]" +python -m pytest +ruff check . +``` + +Every write goes through `githubbot_core.actions` (preview/apply, permission +ceiling, receipts). Please add new write actions there, not as direct API calls. diff --git a/README.md b/README.md new file mode 100644 index 0000000..599c8f3 --- /dev/null +++ b/README.md @@ -0,0 +1,168 @@ +# githubbot-core + +**English** | [Deutsch](README_de.md) + +Neutral, installable core for GitHub repository maintenance: issue triage, duplicate detection, +pull-request summaries, read-only health/traffic/star reports, a preview/apply contract for every +write, a plugin SPI and signed exact-SHA review receipts. Pure Python standard library, no runtime +dependencies. + +> Status: alpha (0.1.0). The CLI, the JSON result envelope and SPI version 1 are the contract; +> everything else may still change. + +## Features + +- **Triage** – classifies open issues with configurable keyword criteria and plans labels and + auto comments (with an invisible marker so a comment is never posted twice). +- **Duplicates** – Jaccard similarity on word tokens, offline on a JSON file. +- **Pull-request summary** – size check and summary; project-specific policy checks come from + plugins only. +- **Reports** – Dependabot / Actions / issues / PRs / code- and secret-scanning health, traffic + (14 days) and star rankings. Read-only. +- **Preview / apply** – nothing is written unless you pass `--apply`. Every write is an `Action`, + checked against a permission ceiling, and leaves a receipt. With a receipt store, repeated runs + skip actions that were already applied. +- **Plugin SPI** – extensions propose actions or add pull-request checks through an entry point. + A missing extension is reported as "not available"; the core keeps working. +- **Credential adapter** – tokens come from `GH_TOKEN` / `GITHUB_TOKEN` or `gh auth token` at + runtime. Nothing is stored or bundled. +- **Review gate** – signed (OpenSSH), time-limited receipts bound to repository, PR number and the + exact head SHA, verified by a GitHub Actions workflow that never executes pull-request code. + +## Installation + +Requirements: Python 3.10+, the [GitHub CLI](https://cli.github.com/) (`gh auth login`) for +commands that talk to GitHub, OpenSSH `ssh-keygen` for review-gate signatures. + +```bash +pip install git+https://github.com/dev-bricks/githubbot-core.git +githubbot-core --version +githubbot-core doctor +``` + +From a checkout: `pip install .` (development: `pip install -e ".[dev]"`). + +## Usage + +```bash +# Read-only reports +githubbot-core health --repo owner/repo --repo owner/other +githubbot-core traffic --repo owner/repo +githubbot-core stars --repo owner/repo + +# Triage: preview (default) -> apply the very same plan +githubbot-core triage --repo owner/repo +githubbot-core --json triage --repo owner/repo # note plan_sha256 +githubbot-core triage --repo owner/repo --apply --expect-plan --receipts receipts.jsonl + +# Offline helpers (JSON file in gh or REST issue format) +githubbot-core classify --input issues.json +githubbot-core duplicates --input issues.json --threshold 0.8 + +# Pull-request summary (+ checks from installed plugins) +githubbot-core review-pr --repo owner/repo --pr 12 + +# Plugins +githubbot-core plugins +githubbot-core run [--plugin-arg ...] [--apply] +``` + +`python -m githubbot_core ...` works the same way. + +### JSON contract + +With `--json` every command prints one envelope: + +```json +{"schema": "githubbot-core.result/v1", "command": "triage", "ok": true, + "mode": "preview", "data": {"plan_sha256": "…", "actions": [], "writes": 0}, "errors": []} +``` + +`mode` is `read`, `preview` or `apply`. Exit codes: `0` ok, `1` runtime error (for example GitHub +not reachable or a write failed), `2` configuration or usage error, `3` plugin not available. + +The Python API is the package itself: `githubbot_core.triage.IssueTriage`, +`githubbot_core.actions.preview/apply`, `githubbot_core.health.RepoHealthCollector`, +`githubbot_core.plugins`, `githubbot_core.review_gate`, `githubbot_core.credentials`. + +## Configuration and permissions + +The packaged defaults (`githubbot_core/default_config.json`) are extended with `--config file.json` +layers, applied in order (for example a profile, then a host-local file). Ordinary keys are merged, +later layers win. Permission ceilings are different: a later layer may only **narrow** +`permissions.ceiling` and `permissions.api_write_prefixes`; an attempt to widen them is an error, +not a silent override. `permissions.enabled` must stay inside the ceiling. + +| Action kind | Default | Executes | +|---|---|---| +| `issue.add_labels` | enabled | `POST /repos/{repo}/issues/{n}/labels` | +| `issue.comment` | enabled | `POST /repos/{repo}/issues/{n}/comments` | +| `api.request` | disabled | any write method on an endpoint that starts with one of `permissions.api_write_prefixes` | + +```json +{"permissions": {"enabled": ["issue.add_labels", "issue.comment", "api.request"], + "api_write_prefixes": ["user/starred/"]}} +``` + +## Writing a plugin (SPI v1) + +Declare an entry point in the group `githubbot_core.plugins` that points to a zero-argument +factory: + +```toml +[project.entry-points."githubbot_core.plugins"] +my-extension = "my_extension:create" +``` + +```python +from githubbot_core.actions import Action +from githubbot_core.plugins import SPI_VERSION + +class MyExtension: + name = "my-extension" + spi_version = SPI_VERSION + + def describe(self): + return {"version": "1.0.0", "capabilities": ["plan"]} + + def plan(self, ctx): # ctx.config, ctx.transport (read-only), ctx.args + data, error = ctx.transport.get("repos/owner/repo") + return [Action("api.request", {"method": "PUT", "endpoint": "user/starred/owner/repo"}, + reason="example")] + + def pr_policy_checks(self, pr, files, diff): # optional + return [("no_large_binaries", True, "OK")] + +def create(): + return MyExtension() +``` + +Plugins never write themselves: the core previews or applies their plan under the configured +permissions. During planning they only get a read-only transport. + +## Review gate + +`githubbot-core gate-scaffold --dest [--namespace name]` previews, and with `--apply` +writes, `.github/workflows/review-gate.yml` plus the standalone verifier +`.github/review-gate/review_gate.py` into a target repository. Receipts are created, signed, +verified and dispatched with `githubbot-core review-gate create|sign|verify|dispatch`. Details, +trust boundaries and rollback: [docs/REVIEW_GATE.md](docs/REVIEW_GATE.md). + +## Development + +```bash +pip install -e ".[dev]" +python -m pytest # unit and contract tests +ruff check . +GITHUBBOT_CORE_CONSUMER_TEST=1 python -m pytest tests/test_consumer_install.py # fresh-venv install test +``` + +## Provenance + +Written by Lukas Geiger with AI assistance (code generation and review by large language models), +extracted from a private maintenance tool and rebuilt as a new tree without that tool's history. +No third-party code is included; see [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md). + +## License + +[MIT](LICENSE) diff --git a/README_de.md b/README_de.md new file mode 100644 index 0000000..ab7f822 --- /dev/null +++ b/README_de.md @@ -0,0 +1,171 @@ +# githubbot-core + +[English](README.md) | **Deutsch** + +Neutraler, installierbarer Kern für die Pflege von GitHub-Repositories: Issue-Triage, +Duplikaterkennung, Pull-Request-Zusammenfassungen, lesende Berichte zu Gesundheit, Traffic und +Sternen, ein Vorschau-/Ausführungsvertrag für jede Schreibaktion, eine Plugin-Schnittstelle (SPI) +und signierte Exact-SHA-Review-Belege. Reine Python-Standardbibliothek, keine Laufzeitabhängigkeiten. + +> Status: Alpha (0.1.0). Verbindlich sind CLI, JSON-Ergebnishülle und SPI-Version 1; alles andere +> kann sich noch ändern. + +## Funktionen + +- **Triage** – ordnet offene Issues über konfigurierbare Stichwortkriterien ein und plant Labels + und Auto-Kommentare (mit unsichtbarem Marker, damit nie doppelt kommentiert wird). +- **Duplikate** – Jaccard-Ähnlichkeit auf Wort-Tokens, offline auf einer JSON-Datei. +- **Pull-Request-Zusammenfassung** – Größenprüfung und Zusammenfassung; projektspezifische + Policy-Prüfungen kommen ausschließlich aus Plugins. +- **Berichte** – Dependabot, Actions, Issues, PRs, Code- und Secret-Scanning, Traffic (14 Tage) + und Sterne-Rangliste. Nur lesend. +- **Vorschau / Ausführen** – ohne `--apply` wird nichts geschrieben. Jede Schreibaktion ist eine + `Action`, wird gegen eine Rechteobergrenze geprüft und hinterlässt einen Beleg. Mit einem + Belegspeicher überspringen Wiederholungsläufe bereits ausgeführte Aktionen. +- **Plugin-SPI** – Erweiterungen schlagen Aktionen vor oder ergänzen PR-Prüfungen über einen + Entry Point. Eine fehlende Erweiterung wird als „nicht verfügbar“ gemeldet; der Kern läuft weiter. +- **Credential-Adapter** – Tokens kommen zur Laufzeit aus `GH_TOKEN` / `GITHUB_TOKEN` oder + `gh auth token`. Nichts wird gespeichert oder mitgeliefert. +- **Review-Gate** – signierte (OpenSSH), befristete Belege, gebunden an Repository, PR-Nummer und + den exakten Head-SHA; geprüft von einem GitHub-Actions-Workflow, der nie PR-Code ausführt. + +## Installation + +Voraussetzungen: Python 3.10+, die [GitHub CLI](https://cli.github.com/) (`gh auth login`) für +Befehle mit GitHub-Zugriff, OpenSSH `ssh-keygen` für Review-Gate-Signaturen. + +```bash +pip install git+https://github.com/dev-bricks/githubbot-core.git +githubbot-core --version +githubbot-core doctor +``` + +Aus einem Checkout: `pip install .` (Entwicklung: `pip install -e ".[dev]"`). + +## Verwendung + +```bash +# Lesende Berichte +githubbot-core health --repo owner/repo --repo owner/other +githubbot-core traffic --repo owner/repo +githubbot-core stars --repo owner/repo + +# Triage: Vorschau (Standard) -> genau diesen Plan ausführen +githubbot-core triage --repo owner/repo +githubbot-core --json triage --repo owner/repo # plan_sha256 notieren +githubbot-core triage --repo owner/repo --apply --expect-plan --receipts receipts.jsonl + +# Offline-Helfer (JSON-Datei im gh- oder REST-Issue-Format) +githubbot-core classify --input issues.json +githubbot-core duplicates --input issues.json --threshold 0.8 + +# Pull-Request-Zusammenfassung (+ Prüfungen installierter Plugins) +githubbot-core review-pr --repo owner/repo --pr 12 + +# Plugins +githubbot-core plugins +githubbot-core run [--plugin-arg ...] [--apply] +``` + +`python -m githubbot_core ...` funktioniert genauso. + +### JSON-Vertrag + +Mit `--json` gibt jeder Befehl genau eine Hülle aus: + +```json +{"schema": "githubbot-core.result/v1", "command": "triage", "ok": true, + "mode": "preview", "data": {"plan_sha256": "…", "actions": [], "writes": 0}, "errors": []} +``` + +`mode` ist `read`, `preview` oder `apply`. Exit-Codes: `0` ok, `1` Laufzeitfehler (z. B. GitHub +nicht erreichbar oder Schreibaktion fehlgeschlagen), `2` Konfigurations- oder Aufruffehler, +`3` Plugin nicht verfügbar. + +Die Python-API ist das Paket selbst: `githubbot_core.triage.IssueTriage`, +`githubbot_core.actions.preview/apply`, `githubbot_core.health.RepoHealthCollector`, +`githubbot_core.plugins`, `githubbot_core.review_gate`, `githubbot_core.credentials`. + +## Konfiguration und Rechte + +Die mitgelieferten Standardwerte (`githubbot_core/default_config.json`) werden durch +`--config datei.json`-Schichten in Reihenfolge ergänzt (etwa ein Profil, danach eine hostlokale +Datei). Gewöhnliche Schlüssel werden zusammengeführt, spätere Schichten gewinnen. +Rechteobergrenzen sind anders: Eine spätere Schicht darf `permissions.ceiling` und +`permissions.api_write_prefixes` nur **einengen**; ein Erweiterungsversuch ist ein Fehler, keine +stille Überschreibung. `permissions.enabled` muss innerhalb der Obergrenze bleiben. + +| Aktionsart | Standard | Ausführung | +|---|---|---| +| `issue.add_labels` | aktiv | `POST /repos/{repo}/issues/{n}/labels` | +| `issue.comment` | aktiv | `POST /repos/{repo}/issues/{n}/comments` | +| `api.request` | aus | beliebige Schreibmethode auf einem Endpunkt, der mit einem Eintrag aus `permissions.api_write_prefixes` beginnt | + +```json +{"permissions": {"enabled": ["issue.add_labels", "issue.comment", "api.request"], + "api_write_prefixes": ["user/starred/"]}} +``` + +## Ein Plugin schreiben (SPI v1) + +Einen Entry Point in der Gruppe `githubbot_core.plugins` deklarieren, der auf eine Factory ohne +Argumente zeigt: + +```toml +[project.entry-points."githubbot_core.plugins"] +my-extension = "my_extension:create" +``` + +```python +from githubbot_core.actions import Action +from githubbot_core.plugins import SPI_VERSION + +class MyExtension: + name = "my-extension" + spi_version = SPI_VERSION + + def describe(self): + return {"version": "1.0.0", "capabilities": ["plan"]} + + def plan(self, ctx): # ctx.config, ctx.transport (nur lesend), ctx.args + data, error = ctx.transport.get("repos/owner/repo") + return [Action("api.request", {"method": "PUT", "endpoint": "user/starred/owner/repo"}, + reason="example")] + + def pr_policy_checks(self, pr, files, diff): # optional + return [("no_large_binaries", True, "OK")] + +def create(): + return MyExtension() +``` + +Plugins schreiben nie selbst: Der Kern zeigt ihren Plan als Vorschau oder führt ihn unter den +konfigurierten Rechten aus. Beim Planen erhalten sie nur einen lesenden Transport. + +## Review-Gate + +`githubbot-core gate-scaffold --dest [--namespace name]` zeigt als Vorschau und schreibt +mit `--apply` die Dateien `.github/workflows/review-gate.yml` und den eigenständigen Prüfer +`.github/review-gate/review_gate.py` in ein Ziel-Repository. Belege werden mit +`githubbot-core review-gate create|sign|verify|dispatch` erzeugt, signiert, geprüft und übermittelt. +Details, Vertrauensgrenzen und Rollback: [docs/REVIEW_GATE.md](docs/REVIEW_GATE.md) (Englisch). + +## Entwicklung + +```bash +pip install -e ".[dev]" +python -m pytest # Unit- und Vertragstests +ruff check . +GITHUBBOT_CORE_CONSUMER_TEST=1 python -m pytest tests/test_consumer_install.py # Installationstest im frischen venv +``` + +## Herkunft + +Geschrieben von Lukas Geiger mit KI-Unterstützung (Codeerzeugung und Review durch große +Sprachmodelle), aus einem privaten Pflegewerkzeug herausgelöst und als neuer Baum ohne dessen +Historie aufgebaut. Es ist kein Fremdcode enthalten; siehe +[THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md). + +## Lizenz + +[MIT](LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..267430a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,67 @@ +# Sicherheitsrichtlinie / Security Policy + +## Deutsch + +### Sicherheitslücken melden + +1. **Kein öffentliches Issue eröffnen.** +2. **GitHub Private Vulnerability Reporting verwenden:** `Security` → `Advisories` → `New`. +3. Beschreibung, Reproduktionsschritte, betroffene Version und mögliche Auswirkungen angeben. + +Falls Private Vulnerability Reporting noch nicht aktiviert ist, kontaktieren Sie die Maintainer +über GitHub und veröffentlichen Sie keine Details in einem öffentlichen Issue. + +### Geltungsbereich + +- Schreibaktionen gegen GitHub (Vorschau/Ausführen, Rechteobergrenzen, Belege) +- Umgang mit Zugangsdaten (Token-Auflösung, Schwärzung) +- Review-Gate: Signaturprüfung, Exact-SHA-Bindung, Workflow mit `pull_request_target` +- Plugin-Schnittstelle (Laden, Rechte beim Planen) + +### Sicherheitsprinzipien + +- Ohne `--apply` wird nichts geschrieben; jede Schreibaktion wird gegen die konfigurierte + Rechteobergrenze geprüft, die spätere Konfigurationsschichten nur einengen können. +- Tokens werden zur Laufzeit gelesen (`GH_TOKEN`, `GITHUB_TOKEN`, `gh auth token`) und weder + gespeichert noch mitgeliefert. +- Der Review-Gate-Workflow führt ausschließlich den Prüfer vom Default-Branch aus und checkt + keinen PR-Code aus. + +### Reaktionszeit + +Dies ist ein kleines Projekt; Reaktionszeiten können variieren. Kritische Meldungen werden +priorisiert. Bitte lassen Sie vor einer Veröffentlichung angemessen Zeit. + +--- + +## English + +### Reporting a Vulnerability + +1. **Do not open a public issue.** +2. **Use GitHub Private Vulnerability Reporting:** `Security` → `Advisories` → `New`. +3. Include a description, reproduction steps, the affected version and the potential impact. + +If private vulnerability reporting is not enabled yet, contact the maintainers through GitHub and +do not publish details in a public issue. + +### Scope + +- Writes to GitHub (preview/apply, permission ceilings, receipts) +- Credential handling (token lookup, redaction) +- Review gate: signature verification, exact-SHA binding, `pull_request_target` workflow +- Plugin interface (loading, permissions during planning) + +### Security Principles + +- Nothing is written without `--apply`; every write is checked against the configured permission + ceiling, which later configuration layers can only narrow. +- Tokens are read at runtime (`GH_TOKEN`, `GITHUB_TOKEN`, `gh auth token`) and are neither stored + nor bundled. +- The review-gate workflow only runs the verifier from the default branch and never checks out + pull-request code. + +### Response Time + +This is a small project; response times may vary. Critical reports are prioritised. Please allow +reasonable time before public disclosure. diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md new file mode 100644 index 0000000..807423e --- /dev/null +++ b/THIRD_PARTY_LICENSES.md @@ -0,0 +1,16 @@ +# Third-party licenses / Drittanbieter-Lizenzen + +githubbot-core contains **no third-party code** and has **no runtime dependencies** (Python +standard library only). The following tools are used but **not bundled or redistributed**: + +| Component | Used for | License | Bundled | +|---|---|---|---| +| [GitHub CLI (`gh`)](https://github.com/cli/cli) | default transport and token lookup, invoked as an external program | MIT | no | +| [OpenSSH `ssh-keygen`](https://www.openssh.com/) | signing and verifying review-gate receipts, invoked as an external program | BSD-style | no | +| [actions/checkout](https://github.com/actions/checkout) | referenced (pinned by commit SHA) in the review-gate workflow template | MIT | no | +| [setuptools](https://github.com/pypa/setuptools) | build backend (build time only) | MIT | no | +| [pytest](https://github.com/pytest-dev/pytest) | tests (development only) | MIT | no | +| [Ruff](https://github.com/astral-sh/ruff) | linting (development only) | MIT | no | + +githubbot-core enthält **keinen Fremdcode** und hat **keine Laufzeitabhängigkeiten**. Die oben +genannten Werkzeuge werden genutzt, aber weder mitgeliefert noch weiterverteilt. diff --git a/docs/REVIEW_GATE.md b/docs/REVIEW_GATE.md new file mode 100644 index 0000000..2d4f5e1 --- /dev/null +++ b/docs/REVIEW_GATE.md @@ -0,0 +1,128 @@ +# Signed exact-SHA review gate + +The review gate replaces a "required approving review" that a single maintainer identity cannot +satisfy with a **required commit status**. The status turns green only for a signed, time-limited +receipt bound to repository, pull-request number and the exact head SHA. A new commit produces a +new SHA and can never inherit an old receipt. + +This document describes the mechanism. Applying it to a repository changes branch protection and +must be authorised by the repository owner; nothing in this package changes GitHub settings on its +own. + +## Names and namespace + +A deployment chooses one namespace (default `githubbot`, set with `--namespace`). Everything else +is derived from it: + +| Name | Value | +|---|---| +| status context | `/review-gate` | +| SSH signature namespace | `-review-gate` | +| `repository_dispatch` event type | `-review-gate` | +| principal for `model_review` | `-reviewer` | +| principal for `owner_override` | `-owner` | + +The receipt schema `githubbot-core.review-gate-receipt/v1` does not depend on the namespace. + +## Trust boundaries + +The privileged workflow runs on `pull_request_target`, but only executes the verifier checked out +from the **default branch**. It does not check out the pull-request branch or a fork, installs no +pull-request dependencies and runs no pull-request code. This boundary is covered by a regression +test (`tests/test_review_gate.py`). + +A receipt contains: + +- `kind`: `model_review` or `owner_override` +- repository, PR number and the exact 40-character head SHA +- `approve` or `reject`, finding counts and the checks that were run +- SHA-256 of the detailed review artifact +- issue and expiry time (at most 24 hours) +- a random nonce +- for an owner override additionally `decision_id` and `reason` + +The canonical JSON bytes are signed with OpenSSH (Ed25519 recommended). `model_review` accepts only +the reviewer principal, `owner_override` only the owner principal. A positive model review with a +critical or high finding is invalid. + +The required-check configuration pins the status context to the GitHub Actions app +(ID `15368`), so a personal token cannot satisfy the gate by posting a status with the same name. +Anyone with write access to the default branch, the Actions variables, branch-protection +administration or a private signing key remains a high-trust person outside this gate. + +## Key separation is mandatory + +The reviewer key must not be reachable by the process that authored the pull request. If author +token and reviewer private key live in the same automation context, the signature is cosmetic and +the gate is **not** effective. + +Use two separate keys; only the public keys go into an `allowed_signers` file: + +```text +-reviewer ssh-ed25519 AAAA... reviewer +-owner ssh-ed25519 AAAA... owner +``` + +Store its base64 content as the repository variable `REVIEW_GATE_ALLOWED_SIGNERS_B64`. Private +keys, local paths, tokens and receipt artifacts are never committed. + +## Installing the workflow + +```bash +githubbot-core gate-scaffold --dest path/to/checkout --namespace # preview +githubbot-core gate-scaffold --dest path/to/checkout --namespace --apply # write +``` + +This writes `.github/workflows/review-gate.yml` and the standalone, standard-library-only verifier +`.github/review-gate/review_gate.py`. Existing files are never overwritten. Because the gate does +not exist on the default branch before this change is merged, the bootstrap merge needs a one-off, +explicitly authorised decision by the repository owner. + +## Local flow + +All commands are read-only towards GitHub unless `--apply` is given. + +```bash +githubbot-core review-gate --namespace create --kind model_review --repo owner/repo --pr 6 \ + --head-sha --verdict approve --reviewer \ + --artifact review.md --check pytest:green --check ruff:clean --output receipt.json + +githubbot-core review-gate --namespace sign --receipt receipt.json --key --output receipt.sig + +# verify signature and live PR head, dispatch nothing +githubbot-core review-gate --namespace dispatch --receipt receipt.json --signature receipt.sig \ + --allowed-signers allowed_signers + +# after authorisation: trigger repository_dispatch +githubbot-core review-gate --namespace dispatch --receipt receipt.json --signature receipt.sig \ + --allowed-signers allowed_signers --apply +``` + +An owner override uses `--kind owner_override --verdict approve --decision-id ... --reason ...` and +is never presented as an independent model review. + +## Branch protection plan and rollback + +`githubbot-core review-gate protection-plan --current current.json --output-dir plan/` turns the +current branch-protection GET response into two immutable files: `rollback.json` (restorable PUT +body of the current state) and `desired.json` (pull request still required, zero required +approvals, the gate as required status pinned to the Actions app with `strict: true`, enforced for +admins, no force pushes or deletions, conversation resolution on). The tool refuses to overwrite an +existing plan. Apply `desired.json` only after explicit authorisation; on any problem, restore +`rollback.json` first and read the protection back before touching the workflow. + +## Acceptance checks + +- A valid reviewer receipt for the current SHA sets exactly one green status. +- A signed `reject` sets exactly one red status. +- Wrong principal, invalid signature, expired receipt, other repository, other PR number or an old + SHA never produce success. +- A new commit is back to pending. +- An explicitly signed owner override works and names decision and reason in the receipt. +- The workflow demonstrably never runs pull-request code with the privileged token. + +## References + +- [Events `pull_request_target` and `repository_dispatch`](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows) +- [Update branch protection](https://docs.github.com/en/rest/branches/branch-protection#update-branch-protection) +- [Create a commit status](https://docs.github.com/en/rest/commits/statuses#create-a-commit-status) diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..97b8bcc --- /dev/null +++ b/llms.txt @@ -0,0 +1,24 @@ +# githubbot-core + +> Neutral, installable GitHub maintenance core (Python standard library only): issue triage, +> duplicate detection, PR summaries, read-only health/traffic/star reports, a preview/apply +> contract for every write, a plugin SPI and signed exact-SHA review receipts. + +Languages: en, de + +## Entry points + +- CLI: `githubbot-core --help` (also `python -m githubbot_core`); add `--json` for the + `githubbot-core.result/v1` envelope. Writes need `--apply`; default is preview. +- Python API: `githubbot_core.triage`, `githubbot_core.actions`, `githubbot_core.health`, + `githubbot_core.traffic`, `githubbot_core.stars`, `githubbot_core.pr_review`, + `githubbot_core.plugins`, `githubbot_core.review_gate`, `githubbot_core.credentials`. +- Plugin SPI v1: entry-point group `githubbot_core.plugins`, factory returning an object with + `name`, `spi_version`, `describe()`, optional `plan(ctx)` and `pr_policy_checks(pr, files, diff)`. + +## Docs + +- [README](README.md) / [README (Deutsch)](README_de.md) +- [Review gate](docs/REVIEW_GATE.md) +- [Security policy](SECURITY.md) +- [Changelog](CHANGELOG.md) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9ddfb17 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,59 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "githubbot-core" +dynamic = ["version"] +description = "Neutral GitHub maintenance core: triage, reports, preview/apply actions, plugin SPI and exact-SHA review receipts" +authors = [{ name = "Lukas Geiger" }] +requires-python = ">=3.10" +license = "MIT" +license-files = ["LICENSE"] +readme = "README.md" +dependencies = [] +keywords = ["github", "automation", "triage", "pr-review", "duplicate-detection", "review-gate", "cli"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Bug Tracking", + "Topic :: Software Development :: Quality Assurance", +] + +[project.scripts] +githubbot-core = "githubbot_core.cli:main" + +[project.urls] +Repository = "https://github.com/dev-bricks/githubbot-core" +Issues = "https://github.com/dev-bricks/githubbot-core/issues" +Changelog = "https://github.com/dev-bricks/githubbot-core/blob/main/CHANGELOG.md" +Security = "https://github.com/dev-bricks/githubbot-core/blob/main/SECURITY.md" + +[project.optional-dependencies] +dev = ["pytest>=8.0.0", "ruff>=0.5.0"] + +[tool.setuptools.dynamic] +version = { attr = "githubbot_core.__version__" } + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +githubbot_core = ["default_config.json", "templates/*.yml"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +target-version = "py310" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "W"] +ignore = ["E402", "E501", "E741"] diff --git a/src/githubbot_core/__init__.py b/src/githubbot_core/__init__.py new file mode 100644 index 0000000..ddc8116 --- /dev/null +++ b/src/githubbot_core/__init__.py @@ -0,0 +1,8 @@ +"""githubbot-core: neutral, installable GitHub maintenance core. + +Read-only reports (health, traffic, stars), issue triage and duplicate +detection, pull-request summaries, a preview/apply contract for every write, +a plugin SPI and signed exact-SHA review receipts. Standard library only. +""" + +__version__ = "0.1.0" diff --git a/src/githubbot_core/__main__.py b/src/githubbot_core/__main__.py new file mode 100644 index 0000000..eb53e2f --- /dev/null +++ b/src/githubbot_core/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/src/githubbot_core/actions.py b/src/githubbot_core/actions.py new file mode 100644 index 0000000..c97027f --- /dev/null +++ b/src/githubbot_core/actions.py @@ -0,0 +1,141 @@ +"""Preview/apply contract for every write the core performs. + +Nothing in the core writes to GitHub directly. Features and plugins produce a +list of :class:`Action` objects (a plan). ``preview`` shows the plan and writes +nothing; ``apply`` executes it, but only actions whose kind is enabled by the +configuration and inside the permission ceiling. Each executed action leaves a +receipt; with a receipt store, an action that was already applied is skipped, +so a repeated run never repeats a write. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"} + + +@dataclass(frozen=True) +class Action: + kind: str + params: dict = field(default_factory=dict) + reason: str = "" + source: str = "core" + + @property + def action_id(self) -> str: + canonical = json.dumps({"kind": self.kind, "params": self.params}, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + def to_dict(self) -> dict: + return {"id": self.action_id, **asdict(self)} + + +def plan_digest(actions: list[Action]) -> str: + joined = "\n".join(action.action_id for action in actions) + return hashlib.sha256(joined.encode("utf-8")).hexdigest() + + +def denial_reason(action: Action, config: dict) -> Optional[str]: + """Return why ``action`` may not be applied, or ``None`` if it is permitted.""" + permissions = config.get("permissions") or {} + if action.kind not in (permissions.get("ceiling") or []): + return f"{action.kind} is outside the permission ceiling" + if action.kind not in (permissions.get("enabled") or []): + return f"{action.kind} is not enabled in permissions.enabled" + if action.kind not in EXECUTORS: + return f"{action.kind} has no executor" + if action.kind == "api.request": + method = str(action.params.get("method", "")).upper() + endpoint = str(action.params.get("endpoint", "")).lstrip("/") + prefixes = permissions.get("api_write_prefixes") or [] + if method not in WRITE_METHODS: + return f"api.request method {method or '?'} is not a write method" + if ".." in endpoint or not any(endpoint.startswith(p) for p in prefixes): + return f"api.request endpoint {endpoint!r} is not covered by permissions.api_write_prefixes" + return None + + +def preview(actions: list[Action], config: dict) -> dict: + items = [] + for action in actions: + reason = denial_reason(action, config) + items.append({**action.to_dict(), "permitted": reason is None, "denied_reason": reason}) + return {"mode": "preview", "plan_sha256": plan_digest(actions), "actions": items, "writes": 0} + + +def _exec_add_labels(transport, params: dict): + return transport.request( + "POST", f"repos/{params['repo']}/issues/{int(params['number'])}/labels", {"labels": list(params["labels"])} + ) + + +def _exec_comment(transport, params: dict): + return transport.request( + "POST", f"repos/{params['repo']}/issues/{int(params['number'])}/comments", {"body": str(params["body"])} + ) + + +def _exec_api_request(transport, params: dict): + return transport.request(str(params["method"]).upper(), str(params["endpoint"]).lstrip("/"), params.get("body")) + + +EXECUTORS = { + "issue.add_labels": _exec_add_labels, + "issue.comment": _exec_comment, + "api.request": _exec_api_request, +} + + +def _load_applied(store: Optional[Path]) -> set[str]: + if store is None or not store.is_file(): + return set() + applied = set() + for line in store.read_text(encoding="utf-8").splitlines(): + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + if record.get("status") == "applied": + applied.add(record.get("id")) + return applied + + +def apply( + actions: list[Action], + transport, + config: dict, + receipt_store: Optional[Path] = None, + expected_plan_sha256: Optional[str] = None, +) -> dict: + """Execute permitted actions. Every action gets exactly one receipt.""" + digest = plan_digest(actions) + if expected_plan_sha256 and expected_plan_sha256 != digest: + return {"mode": "apply", "plan_sha256": digest, "receipts": [], "writes": 0, + "error": "plan changed since preview (plan_sha256 mismatch); nothing applied"} + applied_before = _load_applied(receipt_store) + receipts: list[dict[str, Any]] = [] + writes = 0 + for action in actions: + record: dict[str, Any] = {"id": action.action_id, "kind": action.kind, "source": action.source, + "at": datetime.now(timezone.utc).isoformat(timespec="seconds")} + reason = denial_reason(action, config) + if reason: + record.update(status="denied", detail=reason) + elif action.action_id in applied_before: + record.update(status="skipped_duplicate", detail="already applied according to the receipt store") + else: + _, error = EXECUTORS[action.kind](transport, action.params) + writes += 1 + record.update(status="failed" if error else "applied", detail=error or "") + receipts.append(record) + if receipt_store is not None: + receipt_store.parent.mkdir(parents=True, exist_ok=True) + with receipt_store.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(record, ensure_ascii=False) + "\n") + return {"mode": "apply", "plan_sha256": digest, "receipts": receipts, "writes": writes} diff --git a/src/githubbot_core/cli.py b/src/githubbot_core/cli.py new file mode 100644 index 0000000..a2e1438 --- /dev/null +++ b/src/githubbot_core/cli.py @@ -0,0 +1,329 @@ +"""Command line interface ``githubbot-core``. + +Every command can print a JSON envelope (``--json``):: + + {"schema": "githubbot-core.result/v1", "command": ..., "ok": bool, + "mode": "read" | "preview" | "apply", "data": ..., "errors": [...]} + +Commands that could write default to ``preview``. Nothing is written without +``--apply``, and even then only actions permitted by the configuration. + +Exit codes: 0 ok, 1 runtime error (for example GitHub not reachable), +2 configuration or usage error, 3 plugin not available. +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import sys +from importlib import resources +from pathlib import Path +from typing import Any, Optional + +from . import __version__, actions, plugins +from .config import ConfigError, load_config +from .credentials import CredentialUnavailable, resolve_token +from .duplicates import DuplicateDetector +from .health import RepoHealthCollector, generate_health_report +from .pr_review import PRReviewer +from .review_gate import DEFAULT_NAMESPACE, ReceiptValidationError, gate_names +from .stars import StarsFetcher, generate_stars_report +from .traffic import TrafficFetcher, generate_traffic_report +from .transport import GhCliTransport +from .triage import GitHubIssue, IssueClassifier, IssueTriage + +RESULT_SCHEMA = "githubbot-core.result/v1" +EXIT_OK, EXIT_RUNTIME, EXIT_USAGE, EXIT_PLUGIN = 0, 1, 2, 3 + + +def _emit(args, command: str, ok: bool, mode: str, data: Any, errors: list[str], text: str = "") -> None: + if args.json: + envelope = {"schema": RESULT_SCHEMA, "command": command, "ok": ok, "mode": mode, + "data": data, "errors": errors} + print(json.dumps(envelope, ensure_ascii=False, indent=2, default=str)) + return + if text: + print(text) + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + + +def _read_issues(path: Path) -> list[GitHubIssue]: + raw = json.loads(path.read_text(encoding="utf-8")) + issues = [] + for item in raw: + if "user" in item or "created_at" in item: + issues.append(GitHubIssue.from_rest_json(item)) + else: + issues.append(GitHubIssue.from_gh_json(item)) + return issues + + +def _plan_output(args, command: str, plan: list, config: dict, transport, extra: Optional[dict] = None) -> int: + """Shared preview/apply step for every command that produces actions.""" + if args.apply: + result = actions.apply(plan, transport, config, + receipt_store=Path(args.receipts) if args.receipts else None, + expected_plan_sha256=args.expect_plan) + failed = [r for r in result["receipts"] if r["status"] == "failed"] + errors = ([result["error"]] if result.get("error") else []) + [f"{r['id'][:12]}: {r['detail']}" for r in failed] + data = {**(extra or {}), **result} + lines = [f"apply: {r['status']:<18} {r['kind']:<18} {r['detail']}" for r in result["receipts"]] + _emit(args, command, not errors, "apply", data, errors, "\n".join(lines) or "nothing to apply") + return EXIT_RUNTIME if errors else EXIT_OK + result = actions.preview(plan, config) + data = {**(extra or {}), **result} + lines = [f"preview: {'ok ' if a['permitted'] else 'DENIED'} {a['kind']:<18} {a['reason']}" + + (f" ({a['denied_reason']})" if a["denied_reason"] else "") for a in result["actions"]] + lines.append(f"plan_sha256 {result['plan_sha256']} -- nothing written; rerun with --apply to execute") + _emit(args, command, True, "preview", data, [], "\n".join(lines)) + return EXIT_OK + + +def cmd_info(args, config, transport) -> int: + data = {"package": "githubbot-core", "version": __version__, "result_schema": RESULT_SCHEMA, + "spi_version": plugins.SPI_VERSION, "plugin_group": plugins.ENTRY_POINT_GROUP, + "plugins": plugins.list_plugins()} + text = f"githubbot-core {__version__} (SPI v{plugins.SPI_VERSION}, {len(data['plugins'])} plugin(s))" + _emit(args, "info", True, "read", data, [], text) + return EXIT_OK + + +def cmd_doctor(args, config, transport) -> int: + try: + _, provider = resolve_token() + credential = {"available": True, "provider": provider} + except CredentialUnavailable as exc: + credential = {"available": False, "detail": str(exc)} + data = {"gh_cli": shutil.which("gh") is not None, "credential": credential, + "permissions": config.get("permissions", {}), "plugins": plugins.list_plugins()} + text = (f"gh CLI: {'found' if data['gh_cli'] else 'missing'}\n" + f"credential: {credential.get('provider') or 'not available'}\n" + f"enabled actions: {', '.join(data['permissions'].get('enabled', [])) or 'none'}") + _emit(args, "doctor", True, "read", data, [], text) + return EXIT_OK + + +def cmd_plugins(args, config, transport) -> int: + listing = plugins.list_plugins() + text = "\n".join(f"{p['name']}: {'available' if p['available'] else p['error']}" for p in listing) \ + or "no plugins installed" + _emit(args, "plugins", True, "read", listing, [], text) + return EXIT_OK + + +def cmd_classify(args, config, transport) -> int: + classifier = IssueClassifier(config) + results = [classifier.classify(issue) for issue in _read_issues(Path(args.input))] + data = [{"number": r.issue.number, "labels_to_add": r.labels_to_add, "comment_type": r.comment_type, + "escalate": r.escalate, "escalate_reason": r.escalate_reason} for r in results] + text = "\n".join(f"#{d['number']}: {', '.join(d['labels_to_add']) or '-'}" + + (" [escalate]" if d["escalate"] else "") for d in data) + _emit(args, "classify", True, "read", data, [], text) + return EXIT_OK + + +def cmd_duplicates(args, config, transport) -> int: + candidates = DuplicateDetector(threshold=args.threshold).find_duplicates(_read_issues(Path(args.input))) + data = [{"issue": c.issue_number, "original": c.original_number, "similarity": round(c.similarity, 4)} + for c in candidates] + _emit(args, "duplicates", True, "read", data, [], "\n".join(str(c) for c in candidates) or "no duplicates") + return EXIT_OK + + +def cmd_triage(args, config, transport) -> int: + triage = IssueTriage(config, args.repo, transport) + issues, error = triage.fetch_issues(limit=args.limit) + if error: + _emit(args, "triage", False, "read", None, [error]) + return EXIT_RUNTIME + results = triage.classify_all(issues) + plan = triage.plan(results) + extra = {"repo": args.repo, "issues": len(issues), + "escalations": [r.issue.number for r in triage.get_escalations(results)]} + if not args.json: + print(triage.generate_report(results)) + return _plan_output(args, "triage", plan, config, transport, extra) + + +def cmd_review_pr(args, config, transport) -> int: + reviewer = PRReviewer(config, args.repo, transport, policy_plugins=plugins.available_plugins()) + pr, error = reviewer.fetch(args.pr) + if error: + _emit(args, "review-pr", False, "read", None, [error]) + return EXIT_RUNTIME + result = reviewer.review(pr) + data = {"repo": args.repo, "pr": pr.number, "head_sha": pr.head_sha, "escalate": result.escalate, + "escalate_reason": result.escalate_reason, "summary": result.summary, + "checks": [{"name": n, "passed": p, "detail": d} for n, p, d in result.policy_check_results]} + _emit(args, "review-pr", True, "read", data, [], result.summary) + return EXIT_OK + + +def cmd_health(args, config, transport) -> int: + health_config = config.get("health", {}) + summaries = RepoHealthCollector(health_config, transport).fetch_all(args.repo) + errors = [e for s in summaries for e in s.errors] + report = generate_health_report( + summaries, + window_days=int(health_config.get("failed_runs_window_days", 14)), + max_dependabot_alerts_per_repo=int(health_config.get("max_dependabot_alerts_per_repo", 10)), + max_failed_runs_per_repo=int(health_config.get("max_failed_runs_per_repo", 5)), + ) + data = [{"repo": s.repo, "dependabot": s.dependabot_count, "failed_runs": s.failed_runs_count, + "open_issues": s.open_issues_count, "open_prs": s.open_pull_requests_count, + "code_scanning": s.code_scanning_count, "secret_scanning": s.secret_scanning_count, + "errors": s.errors} for s in summaries] + _emit(args, "health", not errors, "read", data, errors, report) + return EXIT_RUNTIME if errors else EXIT_OK + + +def cmd_stars(args, config, transport) -> int: + summaries = StarsFetcher(transport).fetch_all(args.repo) + errors = [f"{s.repo}: {s.error}" for s in summaries if s.error] + data = [{"repo": s.repo, "stars": s.stars, "error": s.error} for s in summaries] + _emit(args, "stars", not errors, "read", data, errors, generate_stars_report(summaries)) + return EXIT_RUNTIME if errors else EXIT_OK + + +def cmd_traffic(args, config, transport) -> int: + summaries = TrafficFetcher(transport).fetch_all(args.repo) + errors = [f"{s.repo}: {s.error}" for s in summaries if s.error] + data = [{"repo": s.repo, "views": s.views_total, "views_unique": s.views_unique, + "clones": s.clones_total, "clones_unique": s.clones_unique, "error": s.error} for s in summaries] + _emit(args, "traffic", not errors, "read", data, errors, generate_traffic_report(summaries)) + return EXIT_RUNTIME if errors else EXIT_OK + + +def cmd_run(args, config, transport) -> int: + plan = plugins.run_plan(args.plugin, config, transport, args.plugin_args) + return _plan_output(args, "run", plan, config, transport, {"plugin": args.plugin}) + + +def cmd_gate_scaffold(args, config, transport) -> int: + names = gate_names(args.namespace) + package = resources.files("githubbot_core") + workflow = package.joinpath("templates/review-gate.yml").read_text(encoding="utf-8") + files = { + ".github/workflows/review-gate.yml": workflow.replace("__GATE_NAMESPACE__", args.namespace), + ".github/review-gate/review_gate.py": package.joinpath("review_gate.py").read_text(encoding="utf-8"), + } + dest = Path(args.dest) + existing = [rel for rel in files if (dest / rel).exists()] + data = {"dest": str(dest), "namespace": args.namespace, "names": names, "files": sorted(files), + "existing": existing} + if not args.apply: + _emit(args, "gate-scaffold", True, "preview", data, [], + "would write:\n " + "\n ".join(sorted(files)) + "\nnothing written; rerun with --apply") + return EXIT_OK + if existing: + _emit(args, "gate-scaffold", False, "apply", data, [f"refusing to overwrite {', '.join(existing)}"]) + return EXIT_USAGE + for rel, content in files.items(): + target = dest / rel + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("x", encoding="utf-8", newline="\n") as handle: + handle.write(content) + _emit(args, "gate-scaffold", True, "apply", data, [], "wrote:\n " + "\n ".join(sorted(files))) + return EXIT_OK + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="githubbot-core", description="Neutral GitHub maintenance core.") + parser.add_argument("--config", action="append", default=[], help="extra config layer (JSON), repeatable") + parser.add_argument("--json", action="store_true", help="print the JSON result envelope") + parser.add_argument("--version", action="version", version=f"githubbot-core {__version__}") + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("info", help="version, contract and plugin overview").set_defaults(func=cmd_info) + sub.add_parser("doctor", help="check gh CLI, credentials and permissions").set_defaults(func=cmd_doctor) + sub.add_parser("plugins", help="list installed plugins").set_defaults(func=cmd_plugins) + + p = sub.add_parser("classify", help="classify issues from a JSON file (offline)") + p.add_argument("--input", required=True) + p.set_defaults(func=cmd_classify) + + p = sub.add_parser("duplicates", help="find duplicate issues in a JSON file (offline)") + p.add_argument("--input", required=True) + p.add_argument("--threshold", type=float, default=0.8) + p.set_defaults(func=cmd_duplicates) + + def add_apply_flags(p): + p.add_argument("--apply", action="store_true", help="execute the plan (default: preview only)") + p.add_argument("--receipts", help="JSON-lines receipt store; already applied actions are skipped") + p.add_argument("--expect-plan", help="refuse to apply unless the plan_sha256 matches this preview") + + p = sub.add_parser("triage", help="classify open issues and plan labels/comments") + p.add_argument("--repo", required=True) + p.add_argument("--limit", type=int, default=50) + add_apply_flags(p) + p.set_defaults(func=cmd_triage) + + p = sub.add_parser("review-pr", help="summarise a pull request and run plugin policy checks") + p.add_argument("--repo", required=True) + p.add_argument("--pr", required=True, type=int) + p.set_defaults(func=cmd_review_pr) + + for name, func in (("health", cmd_health), ("stars", cmd_stars), ("traffic", cmd_traffic)): + p = sub.add_parser(name, help=f"read-only {name} report") + p.add_argument("--repo", required=True, action="append", help="owner/name, repeatable") + p.set_defaults(func=func) + + p = sub.add_parser("run", help="preview or apply the action plan of a plugin") + p.add_argument("plugin") + p.add_argument("--plugin-arg", dest="plugin_args", action="append", default=[], + help="argument passed to the plugin, repeatable") + add_apply_flags(p) + p.set_defaults(func=cmd_run) + + p = sub.add_parser("gate-scaffold", help="copy the review-gate workflow and verifier into a repository") + p.add_argument("--dest", required=True, help="root of the target repository checkout") + p.add_argument("--namespace", default=DEFAULT_NAMESPACE) + p.add_argument("--apply", action="store_true") + p.set_defaults(func=cmd_gate_scaffold) + + sub.add_parser("review-gate", help="signed exact-SHA receipts (see: review-gate --help)", add_help=False) + return parser + + +def _subcommand_index(argv: list[str]) -> Optional[int]: + i = 0 + while i < len(argv): + if argv[i] == "--config": + i += 2 + elif argv[i].startswith("-"): + i += 1 + else: + return i + return None + + +def main(argv: Optional[list[str]] = None, transport=None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + index = _subcommand_index(argv) + if index is not None and argv[index] == "review-gate": + from . import review_gate + + return review_gate.main(argv[index + 1:]) + parser = build_parser() + args = parser.parse_args(argv) + if not hasattr(args, "json"): + args.json = False + try: + config = load_config(args.config) + return args.func(args, config, transport or GhCliTransport()) + except ConfigError as exc: + _emit(args, args.command, False, "read", None, [f"config: {exc}"]) + return EXIT_USAGE + except plugins.PluginNotAvailable as exc: + _emit(args, args.command, False, "read", {"error": "plugin_not_available"}, [str(exc)]) + return EXIT_PLUGIN + except (ReceiptValidationError, ValueError, OSError) as exc: + _emit(args, args.command, False, "read", None, [str(exc)]) + return EXIT_USAGE + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/githubbot_core/comments.py b/src/githubbot_core/comments.py new file mode 100644 index 0000000..58978fe --- /dev/null +++ b/src/githubbot_core/comments.py @@ -0,0 +1,133 @@ +""" +githubbot-core -- Auto-Kommentare +============================== +Verwaltet und rendert automatische Kommentare fuer GitHub-Issues und PRs. + +Kommentar-Typen: + - needs_info_bug : Bug ohne Reproduktionsschritte + - question_wiki_hint : Verweis auf Wiki/Docs bei Fragen + - duplicate_reference: Verweis auf Original bei Duplikaten + - security_private : Umleitung zu privatem Reporting bei Security-Issues + +Templates kommen aus dem Config-Schluessel ``auto_comments`` (Default oder +eigene Konfigurationsschicht). + +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Optional + +logger = logging.getLogger(__name__) + + +# ============================================================ +# DATA CLASSES +# ============================================================ + +@dataclass +class CommentTemplate: + """Ein einzelnes Kommentar-Template.""" + key: str + trigger: str + template: str + + def render(self, **kwargs) -> str: + """ + Rendert das Template mit den gegebenen Variablen. + + Unterstuetzte Variablen (je nach Template): + - {original_issue}: Nummer des Original-Issues (bei duplicate_reference) + - {wiki_url}: URL zum Wiki (bei question_wiki_hint) + - Weitere koennen jederzeit hinzugefuegt werden + """ + try: + return self.template.format(**kwargs) + except KeyError: + return self.template # Fallback: Template unveraendert zurueckgeben + + +# ============================================================ +# COMMENT MANAGER +# ============================================================ + +class AutoCommentManager: + """ + Verwaltet Kommentar-Templates und verhindert Doppel-Kommentare. + + Verwendet: + manager = AutoCommentManager(config) + comment = manager.get_comment("needs_info_bug") + if comment: + gh_client.add_comment(issue_number, comment) + """ + + def __init__(self, config: dict): + self.config = config + self.templates = self._load_templates(config.get("auto_comments", {})) + + def _load_templates(self, comments_config: dict) -> dict[str, CommentTemplate]: + """Ladet Templates aus Config.""" + templates = {} + for key, data in comments_config.items(): + templates[key] = CommentTemplate( + key=key, + trigger=data.get("trigger", key), + template=data.get("template", ""), + ) + return templates + + def get_comment(self, key: str, **kwargs) -> Optional[str]: + """ + Gibt gerenderten Kommentar-Text zurueck. + + Args: + key: Template-Key (z.B. "needs_info_bug") + **kwargs: Template-Variablen (z.B. original_issue=42) + + Returns: + Gerenderten Kommentar-Text oder None wenn Template nicht gefunden + """ + template = self.templates.get(key) + if not template: + return None + return template.render(**kwargs) + + def has_template(self, key: str) -> bool: + """Prueft ob ein Template fuer den gegebenen Key existiert.""" + return key in self.templates + + def list_templates(self) -> list[str]: + """Gibt alle verfuegbaren Template-Keys zurueck.""" + return list(self.templates.keys()) + + def check_already_commented( + self, + issue_number: int, + comment_type: str, + existing_comments: list[dict], + ) -> bool: + """ + Prueft ob der Bot bereits einen Kommentar dieses Typs hinterlassen hat. + + Verhindert Duplikat-Kommentare bei mehrmaligem Ausfuehren der Triage. + Bot-Kommentare werden anhand eines HTML-Markers erkannt + (z.B. ""). + """ + marker = f"" + for comment in existing_comments: + if marker in comment.get("body", ""): + return True + return False + + def add_marker(self, text: str, comment_type: str) -> str: + """ + Fuegt unsichtbaren Marker zum Kommentar hinzu. + + Damit kann check_already_commented() erkennen ob dieser Typ + bereits gepostet wurde. + """ + marker = f"\n" + return text + marker diff --git a/src/githubbot_core/config.py b/src/githubbot_core/config.py new file mode 100644 index 0000000..6fa1fb8 --- /dev/null +++ b/src/githubbot_core/config.py @@ -0,0 +1,99 @@ +"""Layered configuration: packaged default -> profile -> local host config. + +Ordinary keys are deep-merged, later layers win. Permission ceilings are +different: a later layer may only narrow them. A layer that tries to widen a +ceiling is rejected with :class:`ConfigError` instead of being applied +silently, and ``permissions.enabled`` must stay inside the final ceiling. +""" + +from __future__ import annotations + +import copy +import json +from importlib import resources +from pathlib import Path +from typing import Any, Iterable, Optional + +# Dotted keys whose value is a list that later layers may only narrow. +NARROW_ONLY = ("permissions.ceiling", "permissions.api_write_prefixes") + + +class ConfigError(ValueError): + """Invalid or unsafe configuration.""" + + +def default_config() -> dict: + text = resources.files("githubbot_core").joinpath("default_config.json").read_text(encoding="utf-8") + return json.loads(text) + + +def _get(data: dict, dotted: str) -> Any: + node: Any = data + for part in dotted.split("."): + if not isinstance(node, dict) or part not in node: + return None + node = node[part] + return node + + +def _set(data: dict, dotted: str, value: Any) -> None: + parts = dotted.split(".") + node = data + for part in parts[:-1]: + node = node.setdefault(part, {}) + node[parts[-1]] = value + + +def _deep_merge(base: dict, layer: dict) -> dict: + for key, value in layer.items(): + if isinstance(value, dict) and isinstance(base.get(key), dict): + _deep_merge(base[key], value) + else: + base[key] = copy.deepcopy(value) + return base + + +def merge_layers(layers: Iterable[tuple[str, dict]]) -> dict: + """Merge ``(name, data)`` layers in order. The first layer is the base.""" + merged: dict = {} + narrowed: dict[str, Optional[list]] = {key: None for key in NARROW_ONLY} + for name, layer in layers: + if not isinstance(layer, dict): + raise ConfigError(f"{name}: configuration must be a JSON object") + for key in NARROW_ONLY: + value = _get(layer, key) + if value is None: + continue + if not isinstance(value, list) or not all(isinstance(v, str) for v in value): + raise ConfigError(f"{name}: {key} must be a list of strings") + current = narrowed[key] + if current is not None: + widened = [v for v in value if v not in current] + if widened: + raise ConfigError(f"{name}: {key} may only be narrowed, not widened by {widened}") + narrowed[key] = list(value) + _deep_merge(merged, layer) + for key, value in narrowed.items(): + if value is not None: + _set(merged, key, value) + validate(merged) + return merged + + +def validate(config: dict) -> None: + ceiling = _get(config, "permissions.ceiling") or [] + enabled = _get(config, "permissions.enabled") or [] + outside = [kind for kind in enabled if kind not in ceiling] + if outside: + raise ConfigError(f"permissions.enabled exceeds permissions.ceiling: {outside}") + + +def load_config(paths: Iterable[Path] = ()) -> dict: + layers: list[tuple[str, dict]] = [("default", default_config())] + for path in paths: + path = Path(path) + try: + layers.append((str(path), json.loads(path.read_text(encoding="utf-8")))) + except (OSError, json.JSONDecodeError) as exc: + raise ConfigError(f"{path}: cannot read configuration ({exc})") from exc + return merge_layers(layers) diff --git a/src/githubbot_core/credentials.py b/src/githubbot_core/credentials.py new file mode 100644 index 0000000..d3ee47a --- /dev/null +++ b/src/githubbot_core/credentials.py @@ -0,0 +1,84 @@ +"""Credential adapter: tokens are looked up at runtime, never stored or bundled. + +Lookup order (first hit wins): environment variables ``GH_TOKEN`` and +``GITHUB_TOKEN``, then ``gh auth token``. A deployment can pass its own +provider chain, for example a secret manager adapter. Whatever is found is +only kept in memory for the current call and is redacted from any text that +leaves the process through :func:`redact`. +""" + +from __future__ import annotations + +import os +import subprocess +from typing import Callable, Iterable, Optional, Protocol + +ENV_NAMES = ("GH_TOKEN", "GITHUB_TOKEN") + + +class CredentialUnavailable(RuntimeError): + """No provider produced a token.""" + + +class TokenProvider(Protocol): + name: str + + def token(self) -> Optional[str]: ... + + +class EnvTokenProvider: + name = "env" + + def __init__(self, names: Iterable[str] = ENV_NAMES, environ: Optional[dict] = None): + self.names = tuple(names) + self.environ = os.environ if environ is None else environ + + def token(self) -> Optional[str]: + for name in self.names: + value = (self.environ.get(name) or "").strip() + if value: + return value + return None + + +class GhCliTokenProvider: + name = "gh-cli" + + def __init__(self, gh: str = "gh", runner: Optional[Callable] = None): + self.gh = gh + self._runner = runner + + def token(self) -> Optional[str]: + run = self._runner or subprocess.run + try: + result = run( + [self.gh, "auth", "token"], + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15, + ) + except (OSError, subprocess.TimeoutExpired): + return None + value = (result.stdout or "").strip() + return value if result.returncode == 0 and value else None + + +def default_providers() -> list[TokenProvider]: + return [EnvTokenProvider(), GhCliTokenProvider()] + + +def resolve_token(providers: Optional[Iterable[TokenProvider]] = None) -> tuple[str, str]: + """Return ``(token, provider_name)`` or raise :class:`CredentialUnavailable`.""" + chain = list(providers) if providers is not None else default_providers() + for provider in chain: + value = provider.token() + if value: + return value, provider.name + names = ", ".join(p.name for p in chain) or "none" + raise CredentialUnavailable(f"no GitHub token available (providers tried: {names})") + + +def redact(text: str, *secrets: Optional[str]) -> str: + """Replace every given secret in ``text`` with ``***``.""" + for secret in secrets: + if secret: + text = text.replace(secret, "***") + return text diff --git a/src/githubbot_core/default_config.json b/src/githubbot_core/default_config.json new file mode 100644 index 0000000..748c7b2 --- /dev/null +++ b/src/githubbot_core/default_config.json @@ -0,0 +1,90 @@ +{ + "_meta": { + "schema": "githubbot-core.config/v1", + "description": "Neutral defaults. Override with --config layers; permission ceilings can only be narrowed by later layers." + }, + "permissions": { + "ceiling": ["issue.add_labels", "issue.comment", "api.request"], + "enabled": ["issue.add_labels", "issue.comment"] + }, + "label_schema": { + "bug": {"color": "d73a4a", "description": "Something is not working", "auto_apply": true}, + "feature": {"color": "0075ca", "description": "New feature or enhancement", "auto_apply": true}, + "question": {"color": "0075ca", "description": "Question or request for help", "auto_apply": true}, + "security": {"color": "e4e669", "description": "Security relevant issue", "auto_apply": true, "escalate": true}, + "duplicate": {"color": "cfd3d7", "description": "Duplicate of an existing issue", "auto_apply": true, "threshold": 0.8}, + "needs-info": {"color": "e4e669", "description": "More information needed", "auto_apply": true}, + "documentation": {"color": "0075ca", "description": "Documentation", "auto_apply": true}, + "good-first-issue": {"color": "7057ff", "description": "Good for newcomers", "auto_apply": false, "requires_human": true} + }, + "triage_criteria": { + "security_public": { + "description": "Publicly reported security vulnerability", + "labels": ["security"], + "auto_comment": "security_private_reporting", + "detection": {"keywords": ["security", "vulnerability", "exploit", "injection", "xss", "rce", "cve", "sicherheit", "luecke"]} + }, + "bug_with_steps": { + "description": "Bug report with reproduction steps", + "labels": ["bug"], + "priority": "high", + "detection": {"keywords": ["error", "traceback", "exception", "crash", "fails", "broken", "not working", "fehler", "absturz"], "has_steps": true} + }, + "bug_without_details": { + "description": "Bug report without enough details", + "labels": ["bug", "needs-info"], + "auto_comment": "needs_info_bug", + "detection": {"keywords": ["error", "traceback", "exception", "crash", "fails", "not working", "fehler", "funktioniert nicht"]} + }, + "feature_request": { + "description": "Feature request", + "labels": ["feature"], + "detection": {"keywords": ["feature", "request", "enhancement", "add", "support", "implement", "would be nice", "vorschlag", "hinzufuegen"]} + }, + "question": { + "description": "Question or request for help", + "labels": ["question"], + "auto_comment": "question_docs_hint", + "detection": {"keywords": ["how", "what", "why", "help", "question", "wie", "was", "warum", "hilfe", "how to"]} + }, + "documentation": { + "description": "Documentation issue", + "labels": ["documentation"], + "detection": {"keywords": ["docs", "documentation", "readme", "typo", "spelling", "doku", "anleitung"]} + }, + "unclear": { + "description": "Unclear or uncategorised request", + "labels": ["needs-info"], + "escalate": true + } + }, + "auto_comments": { + "needs_info_bug": { + "trigger": "bug_without_details", + "template": "Thank you for the report! To help debug this, please provide:\n- Software version\n- Python version and OS\n- Steps to reproduce\n- Full error message/traceback\n\nThis will help us fix the issue faster!" + }, + "question_docs_hint": { + "trigger": "question", + "template": "Thanks for your question! Please check the README and any available documentation first.\n\nIf you don't find an answer there, feel free to continue the discussion here!" + }, + "security_private_reporting": { + "trigger": "security_public", + "template": "Thank you for your security report! **Please do not report security vulnerabilities publicly.**\n\nInstead, use [GitHub's private vulnerability reporting](../../security/advisories/new) to report this privately. This helps protect users while we work on a fix." + } + }, + "pr_review": { + "summary_threshold_lines": 200 + }, + "health": { + "failed_runs_window_days": 14, + "max_failed_runs_per_repo": 5, + "max_dependabot_alerts_per_repo": 10, + "max_open_issues_per_repo": 10, + "max_open_prs_per_repo": 10, + "max_code_scanning_alerts_per_repo": 20, + "max_secret_scanning_alerts_per_repo": 20, + "check_copilot_autofix": true, + "max_copilot_autofix_checks_per_repo": 10, + "check_metadata": true + } +} diff --git a/src/githubbot_core/duplicates.py b/src/githubbot_core/duplicates.py new file mode 100644 index 0000000..fe6eb7c --- /dev/null +++ b/src/githubbot_core/duplicates.py @@ -0,0 +1,209 @@ +""" +githubbot-core -- Duplikat-Erkennung +================================= +Erkennt aehnliche GitHub-Issues mit einem Text-Fingerprint-Verfahren. + +Methode: Jaccard-Aehnlichkeit auf Wort-Tokens (einfach, keine Dependencies). +Schwelle: >80% Aehnlichkeit = Duplikat-Kandidat. + +Fuer grosse Repos: Erweiterbar mit TF-IDF oder sentence-transformers. + +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + + +# ============================================================ +# HELPER +# ============================================================ + +def tokenize(text: str) -> set[str]: + """ + Normalisiert und tokenisiert Text fuer Aehnlichkeitsvergleich. + + - Lowercase + - Nur alphanumerische Tokens + - Stopwoerter entfernen (einfache Liste) + """ + STOPWORDS = { + "the", "a", "an", "is", "it", "in", "on", "at", "to", "for", + "of", "with", "and", "or", "not", "are", "was", "has", "have", + "i", "my", "me", "ich", "mein", "das", "die", "der", "und", + "oder", "nicht", "ein", "eine", "beim", "ist", "im", "von" + } + tokens = set(re.findall(r'[a-z0-9]+', text.lower())) + return tokens - STOPWORDS + + +def jaccard_similarity(set_a: set, set_b: set) -> float: + """Berechnet Jaccard-Aehnlichkeit zwischen zwei Token-Sets.""" + if not set_a and not set_b: + return 1.0 + if not set_a or not set_b: + return 0.0 + intersection = len(set_a & set_b) + union = len(set_a | set_b) + return intersection / union + + +# ============================================================ +# DATA CLASSES +# ============================================================ + +@dataclass +class DuplicateCandidate: + """Ein moegliches Duplikat-Paar.""" + issue_number: int + issue_title: str + original_number: int + original_title: str + similarity: float + + @property + def is_duplicate(self) -> bool: + return self.similarity >= 0.80 + + def __str__(self) -> str: + pct = int(self.similarity * 100) + return ( + f"#{self.issue_number} '{self.issue_title[:40]}' " + f"<-> #{self.original_number} '{self.original_title[:40]}' " + f"({pct}% Aehnlichkeit)" + ) + + +# ============================================================ +# DETECTOR +# ============================================================ + +class DuplicateDetector: + """ + Erkennt Duplikat-Issues via Jaccard-Aehnlichkeit auf Token-Level. + + Schwelle konfigurierbar (default: 0.80 = 80%). + + Verwendung: + detector = DuplicateDetector(threshold=0.80) + candidates = detector.find_duplicates(issues) + for c in candidates: + if c.is_duplicate: + print(c) + """ + + def __init__(self, threshold: float = 0.80): + self.threshold = threshold + + def fingerprint(self, issue_number: int, title: str, body: str) -> dict: + """ + Erstellt Text-Fingerprint eines Issues. + + Returns: + Dict mit issue_number, title, tokens (fuer Vergleich) + """ + # Titel hat hoehere Gewichtung (doppelt zaehlen) + title_tokens = tokenize(title) + body_tokens = tokenize(body[:500]) # Nur erste 500 Zeichen des Bodys + combined = title_tokens | title_tokens | body_tokens # Titel doppelt + + return { + "number": issue_number, + "title": title, + "tokens": combined, + } + + def find_duplicates(self, issues: list) -> list[DuplicateCandidate]: + """ + Sucht Duplikat-Kandidaten in einer Liste von Issues. + + Args: + issues: Liste von GitHubIssue-Objekten (oder Dicts mit number/title/body) + + Returns: + Liste von DuplicateCandidate (sortiert nach Aehnlichkeit absteigend) + + Komplexitaet: O(n^2) -- fuer grosse Repos (>1000 Issues) optimieren! + TODO: Fuer grosse Repos: LSH (Locality-Sensitive Hashing) nutzen + TODO: Oder: Nur neue Issues gegen bestehende pruefen (nicht alle gegen alle) + """ + fingerprints = [] + + for issue in issues: + if hasattr(issue, "number"): + # GitHubIssue-Objekt + fp = self.fingerprint(issue.number, issue.title, issue.body) + else: + # Dict-Interface + fp = self.fingerprint( + issue["number"], + issue.get("title", ""), + issue.get("body", "") + ) + fingerprints.append(fp) + + candidates = [] + + for i, fp_a in enumerate(fingerprints): + for j, fp_b in enumerate(fingerprints): + if j <= i: + continue # Jedes Paar nur einmal pruefen + + sim = jaccard_similarity(fp_a["tokens"], fp_b["tokens"]) + + if sim >= self.threshold: + candidate = DuplicateCandidate( + issue_number=fp_b["number"], + issue_title=fp_b["title"], + original_number=fp_a["number"], + original_title=fp_a["title"], + similarity=sim, + ) + candidates.append(candidate) + + # Sortiert nach Aehnlichkeit (hoechste zuerst) + return sorted(candidates, key=lambda c: c.similarity, reverse=True) + + def find_for_new_issue( + self, + new_issue_number: int, + new_title: str, + new_body: str, + existing_issues: list, + ) -> list[DuplicateCandidate]: + """ + Prueft ein einzelnes neues Issue gegen alle bestehenden. + + Effizienter als find_duplicates() wenn nur ein neues Issue geprueft werden soll. + + """ + fp_new = self.fingerprint(new_issue_number, new_title, new_body) + candidates = [] + + for issue in existing_issues: + if hasattr(issue, "number"): + if issue.number == new_issue_number: + continue + fp_existing = self.fingerprint(issue.number, issue.title, issue.body) + else: + if issue["number"] == new_issue_number: + continue + fp_existing = self.fingerprint( + issue["number"], issue.get("title", ""), issue.get("body", "") + ) + + sim = jaccard_similarity(fp_new["tokens"], fp_existing["tokens"]) + if sim >= self.threshold: + candidates.append(DuplicateCandidate( + issue_number=new_issue_number, + issue_title=new_title, + original_number=fp_existing["number"], + original_title=fp_existing["title"], + similarity=sim, + )) + + return sorted(candidates, key=lambda c: c.similarity, reverse=True) diff --git a/src/githubbot_core/health.py b/src/githubbot_core/health.py new file mode 100644 index 0000000..d88c0ab --- /dev/null +++ b/src/githubbot_core/health.py @@ -0,0 +1,746 @@ +""" +githubbot-core -- Repo-Health +======================== +Sammelt repo-uebergreifende Gesundheitsdaten: + +- offene Dependabot-Warnungen +- fehlgeschlagene GitHub-Actions-Laeufe im Zeitfenster +- offene Issues und Pull Requests +- offene Code-Scanning- und Secret-Scanning-Alerts +- optionale Copilot-Autofix-Signale zu Code-Scanning-Alerts +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from datetime import date, datetime, timedelta, timezone +from typing import Callable, Optional +from urllib.parse import quote + +from .transport import GhCliTransport + +logger = logging.getLogger(__name__) + + +def _parse_timestamp(value: str) -> Optional[datetime]: + """Parst GitHub-UTC-Timestamps.""" + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +@dataclass +class DependabotAlert: + """Zusammenfassung einer offenen Dependabot-Warnung.""" + + number: int + package_name: str = "" + ecosystem: str = "" + severity: str = "unknown" + summary: str = "" + manifest_path: str = "" + created_at: str = "" + html_url: str = "" + + +@dataclass +class WorkflowFailure: + """Zusammenfassung eines fehlgeschlagenen Actions-Laufs.""" + + run_id: int + workflow_name: str = "" + display_title: str = "" + branch: str = "" + event: str = "" + actor: str = "" + status: str = "" + conclusion: str = "" + created_at: str = "" + updated_at: str = "" + html_url: str = "" + + +@dataclass +class OpenIssue: + """Zusammenfassung eines offenen Issues.""" + + number: int + title: str = "" + author: str = "" + labels: list[str] = field(default_factory=list) + updated_at: str = "" + html_url: str = "" + + +@dataclass +class OpenPullRequest: + """Zusammenfassung eines offenen Pull Requests.""" + + number: int + title: str = "" + author: str = "" + labels: list[str] = field(default_factory=list) + updated_at: str = "" + html_url: str = "" + + +@dataclass +class CodeScanningAlert: + """Zusammenfassung eines offenen Code-Scanning-Alerts.""" + + number: int + rule_id: str = "" + rule_name: str = "" + severity: str = "" + tool_name: str = "" + path: str = "" + start_line: int = 0 + created_at: str = "" + html_url: str = "" + copilot_autofix_status: str = "" + copilot_autofix_url: str = "" + + def has_copilot_autofix(self) -> bool: + return bool(self.copilot_autofix_status or self.copilot_autofix_url) + + +@dataclass +class SecretScanningAlert: + """Zusammenfassung eines offenen Secret-Scanning-Alerts.""" + + number: int + secret_type: str = "" + created_at: str = "" + html_url: str = "" + + +@dataclass +class RepoHealthSummary: + """Health-Zusammenfassung fuer ein Repo.""" + + repo: str + dependabot_alerts: list[DependabotAlert] = field(default_factory=list) + failed_runs: list[WorkflowFailure] = field(default_factory=list) + open_issues: list[OpenIssue] = field(default_factory=list) + open_pull_requests: list[OpenPullRequest] = field(default_factory=list) + code_scanning_alerts: list[CodeScanningAlert] = field(default_factory=list) + secret_scanning_alerts: list[SecretScanningAlert] = field(default_factory=list) + resolved_failures: int = 0 # Fehllaeufe im Fenster, die seither wieder gruen sind (unterdrueckt) + closed_pr_failures: int = 0 # PR-Fehllaeufe ohne offenen PR (geschlossen/gemerged) + open_issues_total: int = 0 + open_pull_requests_total: int = 0 + code_scanning_total: int = 0 + secret_scanning_total: int = 0 + missing_description: bool = False + missing_topics: bool = False + is_locked: bool = False + lock_reason: str = "" + errors: list[str] = field(default_factory=list) + + @property + def dependabot_count(self) -> int: + return len(self.dependabot_alerts) + + @property + def failed_runs_count(self) -> int: + return len(self.failed_runs) + + @property + def open_issues_count(self) -> int: + return self.open_issues_total or len(self.open_issues) + + @property + def open_pull_requests_count(self) -> int: + return self.open_pull_requests_total or len(self.open_pull_requests) + + @property + def code_scanning_count(self) -> int: + return self.code_scanning_total or len(self.code_scanning_alerts) + + @property + def secret_scanning_count(self) -> int: + return self.secret_scanning_total or len(self.secret_scanning_alerts) + + @property + def copilot_autofix_count(self) -> int: + return sum(1 for alert in self.code_scanning_alerts if alert.has_copilot_autofix()) + + def has_findings(self) -> bool: + return bool( + self.dependabot_alerts + or self.failed_runs + or self.open_issues + or self.open_pull_requests + or self.code_scanning_alerts + or self.secret_scanning_alerts + or (self.missing_description and not self.is_locked) + or (self.missing_topics and not self.is_locked) + ) + + +class RepoHealthCollector: + """Laedt Repo-Health ueber den Transport (Standard: `gh api`). Nur lesend.""" + + def __init__( + self, + config: dict | None = None, + transport=None, + lock_check: Optional[Callable[[str], tuple[bool, str]]] = None, + ): + config = config or {} + self.transport = transport or GhCliTransport() + # Optionaler Adapter: (gesperrt?, Grund) je Repo -- z. B. aus einem lokalen Register. + self.lock_check = lock_check + self.window_days = int(config.get("failed_runs_window_days", 14)) + self.max_failed_runs_per_repo = int(config.get("max_failed_runs_per_repo", 5)) + self.max_dependabot_alerts_per_repo = int(config.get("max_dependabot_alerts_per_repo", 10)) + self.max_open_issues_per_repo = int(config.get("max_open_issues_per_repo", 10)) + self.max_open_prs_per_repo = int(config.get("max_open_prs_per_repo", 10)) + self.max_code_scanning_alerts_per_repo = int(config.get("max_code_scanning_alerts_per_repo", 20)) + self.max_secret_scanning_alerts_per_repo = int(config.get("max_secret_scanning_alerts_per_repo", 20)) + self.check_copilot_autofix = bool(config.get("check_copilot_autofix", True)) + self.max_copilot_autofix_checks_per_repo = int(config.get("max_copilot_autofix_checks_per_repo", 10)) + self.check_metadata = bool(config.get("check_metadata", True)) + + def fetch(self, repo: str) -> RepoHealthSummary: + """Laedt Dependabot- und Actions-Health fuer ein Repo.""" + summary = RepoHealthSummary(repo=repo) + + alerts, alert_error = self._fetch_dependabot_alerts(repo) + summary.dependabot_alerts = alerts + if alert_error: + summary.errors.append(alert_error) + + issues, issue_total, prs, pr_total, issue_error = self._fetch_open_issues_and_prs(repo) + summary.open_issues = issues + summary.open_issues_total = issue_total + summary.open_pull_requests = prs + summary.open_pull_requests_total = pr_total + if issue_error: + summary.errors.append(issue_error) + + code_alerts, code_total, code_error = self._fetch_code_scanning_alerts(repo) + summary.code_scanning_alerts = code_alerts + summary.code_scanning_total = code_total + if code_error: + summary.errors.append(code_error) + + secret_alerts, secret_total, secret_error = self._fetch_secret_scanning_alerts(repo) + summary.secret_scanning_alerts = secret_alerts + summary.secret_scanning_total = secret_total + if secret_error: + summary.errors.append(secret_error) + + failed_runs, resolved_count, closed_pr_count, runs_error = self._fetch_failed_runs(repo) + summary.failed_runs = failed_runs + summary.resolved_failures = resolved_count + summary.closed_pr_failures = closed_pr_count + if runs_error: + summary.errors.append(runs_error) + + if self.check_metadata: + desc, topics, metadata_error = self._get_repo_metadata(repo) + if metadata_error: + summary.errors.append(metadata_error) + else: + summary.missing_description = not bool(str(desc or "").strip()) + summary.missing_topics = len(topics or []) == 0 + + if self.lock_check is not None: + summary.is_locked, summary.lock_reason = self.lock_check(repo) + + if not summary.is_locked: + if summary.missing_description: + logger.warning("[METADATA] %s: Description fehlt!", repo) + if summary.missing_topics: + logger.warning("[METADATA] %s: Topics fehlen!", repo) + + return summary + + def _get_repo_metadata(self, repo: str) -> tuple[str, list[str], str | None]: + """Holt Description und Topics via API.""" + data, err = self._call(f"repos/{repo}") + if not err and isinstance(data, dict): + desc = data.get("description") or "" + topics = data.get("topics") or [] + return desc, topics, None + return "", [], err or f"Metadaten-Antwort für {repo} ist ungültig" + + def fetch_all(self, repos: list[str]) -> list[RepoHealthSummary]: + """Laedt Repo-Health fuer mehrere Repos.""" + return [self.fetch(repo) for repo in repos] + + def _utcnow(self) -> datetime: + return datetime.now(timezone.utc) + + def _fetch_dependabot_alerts(self, repo: str) -> tuple[list[DependabotAlert], Optional[str]]: + data, error = self._call_paginated( + f"repos/{repo}/dependabot/alerts?state=open&per_page=100" + ) + if error: + return [], f"{repo}: Dependabot-Warnungen konnten nicht geladen werden ({error})" + + alerts = [] + for item in (data or [])[: self.max_dependabot_alerts_per_repo]: + dependency = item.get("dependency") or {} + package = dependency.get("package") or {} + vulnerability = item.get("security_vulnerability") or {} + advisory = item.get("security_advisory") or {} + + alerts.append( + DependabotAlert( + number=item.get("number", 0), + package_name=package.get("name", ""), + ecosystem=package.get("ecosystem", ""), + severity=vulnerability.get("severity") + or advisory.get("severity") + or "unknown", + summary=advisory.get("summary", ""), + manifest_path=item.get("manifest_path") + or dependency.get("manifest_path", ""), + created_at=item.get("created_at", ""), + html_url=item.get("html_url", ""), + ) + ) + + return alerts, None + + def _fetch_open_issues_and_prs( + self, repo: str + ) -> tuple[list[OpenIssue], int, list[OpenPullRequest], int, Optional[str]]: + data, error = self._call_paginated(f"repos/{repo}/issues?state=open&per_page=100") + if error: + return [], 0, [], 0, f"{repo}: offene Issues/PRs konnten nicht geladen werden ({error})" + + issues: list[OpenIssue] = [] + pull_requests: list[OpenPullRequest] = [] + + for item in data or []: + labels = [label.get("name", "") for label in item.get("labels", []) if label.get("name")] + author = (item.get("user") or {}).get("login", "") + if item.get("pull_request"): + pull_requests.append( + OpenPullRequest( + number=item.get("number", 0), + title=item.get("title", ""), + author=author, + labels=labels, + updated_at=item.get("updated_at", ""), + html_url=item.get("html_url", ""), + ) + ) + else: + issues.append( + OpenIssue( + number=item.get("number", 0), + title=item.get("title", ""), + author=author, + labels=labels, + updated_at=item.get("updated_at", ""), + html_url=item.get("html_url", ""), + ) + ) + + return ( + issues[: self.max_open_issues_per_repo], + len(issues), + pull_requests[: self.max_open_prs_per_repo], + len(pull_requests), + None, + ) + + def _fetch_code_scanning_alerts( + self, repo: str + ) -> tuple[list[CodeScanningAlert], int, Optional[str]]: + data, error = self._call_paginated(f"repos/{repo}/code-scanning/alerts?state=open&per_page=100") + if error: + if self._is_optional_security_feature_unavailable(error): + return [], 0, None + return [], 0, f"{repo}: Code-Scanning-Alerts konnten nicht geladen werden ({error})" + + alerts: list[CodeScanningAlert] = [] + for item in data or []: + rule = item.get("rule") or {} + tool = item.get("tool") or {} + instance = item.get("most_recent_instance") or {} + location = instance.get("location") or {} + alert = CodeScanningAlert( + number=item.get("number", 0), + rule_id=rule.get("id", ""), + rule_name=rule.get("name", ""), + severity=rule.get("security_severity_level") or rule.get("severity") or "", + tool_name=tool.get("name", ""), + path=location.get("path", ""), + start_line=location.get("start_line") or 0, + created_at=item.get("created_at", ""), + html_url=item.get("html_url", ""), + ) + alerts.append(alert) + + visible_alerts = alerts[: self.max_code_scanning_alerts_per_repo] + if self.check_copilot_autofix and visible_alerts: + for alert in visible_alerts[: self.max_copilot_autofix_checks_per_repo]: + status, url = self._fetch_copilot_autofix(repo, alert.number) + alert.copilot_autofix_status = status + alert.copilot_autofix_url = url + + return visible_alerts, len(alerts), None + + def _fetch_secret_scanning_alerts( + self, repo: str + ) -> tuple[list[SecretScanningAlert], int, Optional[str]]: + data, error = self._call_paginated(f"repos/{repo}/secret-scanning/alerts?state=open&per_page=100") + if error: + if self._is_optional_security_feature_unavailable(error): + return [], 0, None + return [], 0, f"{repo}: Secret-Scanning-Alerts konnten nicht geladen werden ({error})" + + alerts = [ + SecretScanningAlert( + number=item.get("number", 0), + secret_type=item.get("secret_type", ""), + created_at=item.get("created_at", ""), + html_url=item.get("html_url", ""), + ) + for item in data or [] + ] + return alerts[: self.max_secret_scanning_alerts_per_repo], len(alerts), None + + def _fetch_copilot_autofix(self, repo: str, alert_number: int) -> tuple[str, str]: + data, error = self._call(f"repos/{repo}/code-scanning/alerts/{alert_number}/autofix") + if error or not isinstance(data, dict): + return "", "" + return data.get("status") or data.get("state") or "available", data.get("html_url", "") + + @staticmethod + def _is_optional_security_feature_unavailable(error: str) -> bool: + text = error.lower() + return any( + marker in text + for marker in ( + "not found", + "no analysis found", + "not enabled", + "disabled", + "advanced security must be enabled", + ) + ) + + # Conclusions, die als echte Bruchstelle zaehlen (cancelled = bewusst abgebrochen, kein Defekt) + FAILURE_CONCLUSIONS = ("failure", "timed_out", "startup_failure") + + def _fetch_failed_runs( + self, repo: str + ) -> tuple[list[WorkflowFailure], int, int, Optional[str]]: + """Liefert nur OFFENE Fehllaeufe (Workflow+Branch aktuell rot) + Anzahl seither behobener. + + „since-resolved": Ein Fehllauf wird unterdrueckt, wenn fuer denselben Workflow + (`workflow_id`) auf demselben Branch (`head_branch`) ein NEUERER abgeschlossener Lauf + existiert. Es zaehlt nur der juengste abgeschlossene Lauf je (Workflow, Branch): + ist dessen Conclusion ein Fehler -> offen; sonst (gruen) bzw. aeltere Laeufe -> behoben. + """ + # ALLE Laeufe (jede Conclusion) laden, API liefert newest-first nach created_at. + data, error = self._call(f"repos/{repo}/actions/runs?per_page=100") + if error: + return [], 0, 0, f"{repo}: fehlgeschlagene Actions-Läufe konnten nicht geladen werden ({error})" + + runs = (data or {}).get("workflow_runs", []) + cutoff = self._utcnow() - timedelta(days=self.window_days) + + def _key(item: dict) -> tuple: + return (item.get("workflow_id"), item.get("head_branch", "")) + + # Juengster ABGESCHLOSSENER Lauf je (Workflow, Branch) -- erste Sichtung = neuester (desc-Order) + latest_completed: dict[tuple, dict] = {} + for item in runs: + if item.get("status") != "completed": + continue + key = _key(item) + if key not in latest_completed: + latest_completed[key] = item + + latest_open_ids = { + item.get("id") + for item in latest_completed.values() + if item.get("conclusion") in self.FAILURE_CONCLUSIONS + } + + open_failures: list[WorkflowFailure] = [] + resolved_count = 0 + closed_pr_count = 0 + for item in runs: + if item.get("conclusion") not in self.FAILURE_CONCLUSIONS: + continue + created_at = _parse_timestamp(item.get("created_at", "")) + if created_at and created_at < cutoff: + continue + # Fehllauf im Fenster: offen nur, wenn er der juengste abgeschlossene Lauf seiner + # (Workflow, Branch)-Gruppe ist; sonst seither behoben/ueberholt -> unterdruecken. + if item.get("id") in latest_open_ids: + if item.get("event") in {"pull_request", "pull_request_target"} and not self._has_open_pull_request_for_run(repo, item): + closed_pr_count += 1 + continue + open_failures.append( + WorkflowFailure( + run_id=item.get("id", 0), + workflow_name=item.get("name", ""), + display_title=item.get("display_title", ""), + branch=item.get("head_branch", ""), + event=item.get("event", ""), + actor=(item.get("actor") or {}).get("login", ""), + status=item.get("status", ""), + conclusion=item.get("conclusion", ""), + created_at=item.get("created_at", ""), + updated_at=item.get("updated_at", ""), + html_url=item.get("html_url", ""), + ) + ) + else: + resolved_count += 1 + + open_failures = open_failures[: self.max_failed_runs_per_repo] + return open_failures, resolved_count, closed_pr_count, None + + def _has_open_pull_request_for_run(self, repo: str, run: dict) -> bool: + """Prueft, ob ein PR-Run noch zu einem offenen PR gehoert. + + GitHub markiert alte `pull_request_target`-Runs auch nach Merge weiter als + rot. Fuer Health-Zwecke sind sie keine offene Bruchstelle mehr. + Bei API-Unsicherheit wird konservativ nicht unterdrueckt. + """ + branch = run.get("head_branch", "") + if not branch: + return True + head_repo = ((run.get("head_repository") or {}).get("full_name") or repo) + head_owner = head_repo.split("/", 1)[0] + head = quote(f"{head_owner}:{branch}", safe="") + data, error = self._call(f"repos/{repo}/pulls?state=open&head={head}&per_page=1") + if error: + return True + return bool(data) + + def _call(self, endpoint: str) -> tuple[dict | list | None, Optional[str]]: + return self.transport.get(endpoint) + + def _call_paginated(self, endpoint: str) -> tuple[list, Optional[str]]: + return self.transport.get_paginated(endpoint) + + +def generate_health_report( + summaries: list[RepoHealthSummary], + *, + window_days: int, + max_dependabot_alerts_per_repo: int, + max_failed_runs_per_repo: int, + max_open_issues_per_repo: int = 10, + max_open_prs_per_repo: int = 10, + max_code_scanning_alerts_per_repo: int = 20, + max_secret_scanning_alerts_per_repo: int = 20, +) -> str: + """Erstellt einen Markdown-Report ueber Repo-Health.""" + today = date.today().strftime("%Y-%m-%d") + + with_dependabot = [s for s in summaries if s.dependabot_alerts] + with_failures = [s for s in summaries if s.failed_runs] + with_issues = [s for s in summaries if s.open_issues] + with_prs = [s for s in summaries if s.open_pull_requests] + with_code_scanning = [s for s in summaries if s.code_scanning_alerts] + with_secret_scanning = [s for s in summaries if s.secret_scanning_alerts] + with_copilot_autofix = [s for s in summaries if s.copilot_autofix_count] + with_missing_metadata = [ + s for s in summaries + if not s.is_locked and (s.missing_description or s.missing_topics) + ] + with_errors = [s for s in summaries if s.errors] + healthy = [s for s in summaries if not s.errors and not s.has_findings()] + + total_alerts = sum(s.dependabot_count for s in summaries) + total_failures = sum(s.failed_runs_count for s in summaries) + total_resolved = sum(s.resolved_failures for s in summaries) + total_closed_pr_failures = sum(s.closed_pr_failures for s in summaries) + total_issues = sum(s.open_issues_count for s in summaries) + total_prs = sum(s.open_pull_requests_count for s in summaries) + total_code_scanning = sum(s.code_scanning_count for s in summaries) + total_secret_scanning = sum(s.secret_scanning_count for s in summaries) + total_copilot_autofix = sum(s.copilot_autofix_count for s in summaries) + + lines = [ + f"## GitHub Health Report -- {today}", + f"**Repos geprüft:** {len(summaries)}", + f"**Actions-Fenster:** letzte {window_days} Tage", + f"**Dependabot-Warnungen:** {total_alerts} in {len(with_dependabot)} Repos", + f"**Offene fehlgeschlagene Actions-Läufe:** {total_failures} in {len(with_failures)} Repos " + f"(aktuell rot; {total_resolved} seither behobene Fehlläufe, " + f"{total_closed_pr_failures} geschlossene/gemergte PR-Fehlläufe unterdrückt)", + f"**Offene Issues:** {total_issues} in {len(with_issues)} Repos", + f"**Offene Pull Requests:** {total_prs} in {len(with_prs)} Repos", + f"**Offene Code-Scanning-Alerts:** {total_code_scanning} in {len(with_code_scanning)} Repos", + f"**Offene Secret-Scanning-Alerts:** {total_secret_scanning} in {len(with_secret_scanning)} Repos", + f"**Copilot-Autofix-Signale:** {total_copilot_autofix} in {len(with_copilot_autofix)} Repos", + f"**Repos mit fehlenden Metadaten:** {len(with_missing_metadata)} in {len(summaries)} Repos (ohne gelockte Repos)", + "", + f"*„Offen\" = der jüngste abgeschlossene Lauf je Workflow+Branch ist rot. Fehlläufe, die ein " + f"späterer Lauf wieder grün gemacht hat, werden nur gezählt, nicht gelistet. " + f"Pro Repo max. {max_dependabot_alerts_per_repo} Dependabot-Warnungen / " + f"{max_failed_runs_per_repo} offene Läufe / " + f"{max_open_issues_per_repo} Issues / {max_open_prs_per_repo} PRs / " + f"{max_code_scanning_alerts_per_repo} Code-Scanning-Alerts / " + f"{max_secret_scanning_alerts_per_repo} Secret-Scanning-Alerts gelistet.*", + "", + ] + + if with_dependabot: + lines.append("### Repos mit offenen Dependabot-Warnungen") + lines.append("") + for summary in sorted(with_dependabot, key=lambda item: (-item.dependabot_count, item.repo)): + lines.append(f"**{summary.repo}** -- {summary.dependabot_count} Warnung(en)") + for alert in summary.dependabot_alerts: + package = alert.package_name or "unbekanntes Paket" + ecosystem = f" ({alert.ecosystem})" if alert.ecosystem else "" + severity = alert.severity or "unknown" + text = alert.summary or "Keine Kurzbeschreibung vorhanden." + lines.append(f"- `{severity}` -- `{package}`{ecosystem}: {text}") + if alert.manifest_path: + lines.append(f" Manifest: `{alert.manifest_path}`") + if alert.html_url: + lines.append(f" Link: {alert.html_url}") + lines.append("") + + if with_failures: + lines.append("### Repos mit OFFENEN fehlgeschlagenen Actions-Läufen (aktuell rot)") + lines.append("") + for summary in sorted(with_failures, key=lambda item: (-item.failed_runs_count, item.repo)): + resolved_note = ( + f" (+{summary.resolved_failures} seither behoben)" + if summary.resolved_failures + else "" + ) + lines.append(f"**{summary.repo}** -- {summary.failed_runs_count} offen{resolved_note}") + for run in summary.failed_runs: + workflow_name = run.workflow_name or "Unbenannter Workflow" + display_title = run.display_title or workflow_name + created = run.created_at[:10] if run.created_at else "unbekannt" + details = [created, workflow_name] + if run.branch: + details.append(f"Branch `{run.branch}`") + if run.event: + details.append(f"Event `{run.event}`") + if run.actor: + details.append(f"Actor `{run.actor}`") + if run.conclusion: + details.append(f"Conclusion `{run.conclusion}`") + lines.append(f"- {' -- '.join(details)}") + lines.append(f" Titel: {display_title}") + if run.html_url: + lines.append(f" Link: {run.html_url}") + lines.append("") + + if with_issues: + lines.append("### Repos mit offenen Issues") + lines.append("") + for summary in sorted(with_issues, key=lambda item: (-item.open_issues_count, item.repo)): + shown = len(summary.open_issues) + suffix = f" (zeige {shown})" if shown < summary.open_issues_count else "" + lines.append(f"**{summary.repo}** -- {summary.open_issues_count} offen{suffix}") + for issue in summary.open_issues: + labels = f" -- Labels: `{', '.join(issue.labels)}`" if issue.labels else "" + author = f" -- Autor `{issue.author}`" if issue.author else "" + lines.append(f"- #{issue.number} -- {issue.title}{author}{labels}") + if issue.html_url: + lines.append(f" Link: {issue.html_url}") + lines.append("") + + if with_prs: + lines.append("### Repos mit offenen Pull Requests") + lines.append("") + for summary in sorted(with_prs, key=lambda item: (-item.open_pull_requests_count, item.repo)): + shown = len(summary.open_pull_requests) + suffix = f" (zeige {shown})" if shown < summary.open_pull_requests_count else "" + lines.append(f"**{summary.repo}** -- {summary.open_pull_requests_count} offen{suffix}") + for pr in summary.open_pull_requests: + labels = f" -- Labels: `{', '.join(pr.labels)}`" if pr.labels else "" + author = f" -- Autor `{pr.author}`" if pr.author else "" + lines.append(f"- #{pr.number} -- {pr.title}{author}{labels}") + if pr.html_url: + lines.append(f" Link: {pr.html_url}") + lines.append("") + + if with_code_scanning: + lines.append("### Repos mit offenen Code-Scanning-Alerts") + lines.append("") + for summary in sorted(with_code_scanning, key=lambda item: (-item.code_scanning_count, item.repo)): + shown = len(summary.code_scanning_alerts) + suffix = f" (zeige {shown})" if shown < summary.code_scanning_count else "" + lines.append(f"**{summary.repo}** -- {summary.code_scanning_count} offen{suffix}") + for alert in summary.code_scanning_alerts: + rule = alert.rule_id or alert.rule_name or "unbekannte Regel" + severity = alert.severity or "unknown" + location = f" -- `{alert.path}:{alert.start_line}`" if alert.path else "" + tool = f" -- Tool `{alert.tool_name}`" if alert.tool_name else "" + autofix = "" + if alert.has_copilot_autofix(): + status = alert.copilot_autofix_status or "available" + autofix = f" -- Copilot Autofix `{status}`" + lines.append(f"- #{alert.number} -- `{severity}` -- `{rule}`{tool}{location}{autofix}") + if alert.html_url: + lines.append(f" Link: {alert.html_url}") + lines.append("") + + if with_secret_scanning: + lines.append("### Repos mit offenen Secret-Scanning-Alerts") + lines.append("") + for summary in sorted(with_secret_scanning, key=lambda item: (-item.secret_scanning_count, item.repo)): + shown = len(summary.secret_scanning_alerts) + suffix = f" (zeige {shown})" if shown < summary.secret_scanning_count else "" + lines.append(f"**{summary.repo}** -- {summary.secret_scanning_count} offen{suffix}") + for alert in summary.secret_scanning_alerts: + secret_type = alert.secret_type or "unbekannter Secret-Typ" + lines.append(f"- #{alert.number} -- `{secret_type}`") + if alert.html_url: + lines.append(f" Link: {alert.html_url}") + lines.append("") + + if with_missing_metadata: + lines.append("### Repos mit fehlenden Metadaten (Description / Topics)") + lines.append("") + for summary in sorted(with_missing_metadata, key=lambda item: item.repo): + details = [] + if summary.missing_description: + details.append("Description fehlt") + if summary.missing_topics: + details.append("Topics fehlen") + lines.append(f"- **{summary.repo}**: {', '.join(details)}") + lines.append("") + + if healthy: + names = ", ".join(summary.repo for summary in sorted(healthy, key=lambda item: item.repo)) + lines.append(f"### Unauffällige Repos ({len(healthy)})") + lines.append(names) + lines.append("") + + if with_errors: + lines.append("### Fehler beim Abruf") + lines.append("") + for summary in sorted(with_errors, key=lambda item: item.repo): + for error in summary.errors: + lines.append(f"- {error}") + lines.append("") + + if ( + not with_dependabot + and not with_failures + and not with_issues + and not with_prs + and not with_code_scanning + and not with_secret_scanning + and not with_missing_metadata + and not with_errors + and not healthy + ): + lines.append("*Keine offenen Health-Befunde im gewählten Zeitfenster.*") + + return "\n".join(lines) diff --git a/src/githubbot_core/plugins.py b/src/githubbot_core/plugins.py new file mode 100644 index 0000000..f0c8030 --- /dev/null +++ b/src/githubbot_core/plugins.py @@ -0,0 +1,115 @@ +"""Plugin SPI (service provider interface), version 1. + +A plugin is any installed package that declares an entry point in the group +``githubbot_core.plugins``. The entry point names a zero-argument factory that +returns an object with: + +* ``name`` (str) and ``spi_version`` (int, must equal :data:`SPI_VERSION`) +* ``describe() -> dict``: at least ``version`` and ``capabilities`` +* optional ``plan(ctx: PluginContext) -> list[Action]``: propose writes. + Plugins never write themselves; the core previews or applies the plan under + its permission ceiling. During planning the plugin gets a read-only transport. +* optional ``pr_policy_checks(pr, files, diff) -> list[(name, passed, detail)]``: + extra pull-request checks added to ``review-pr``. + +A missing, unreadable or incompatible plugin is reported as "not available"; +the core keeps working without it. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from importlib import metadata +from typing import Any, Callable, Optional + +from .actions import Action +from .transport import ReadOnlyTransport + +SPI_VERSION = 1 +ENTRY_POINT_GROUP = "githubbot_core.plugins" + +_REGISTERED: dict[str, Callable[[], Any]] = {} + + +class PluginNotAvailable(LookupError): + """The requested plugin is not installed, not accessible or incompatible.""" + + +@dataclass +class PluginContext: + config: dict + transport: ReadOnlyTransport + args: list[str] = field(default_factory=list) + + +def register(name: str, factory: Callable[[], Any]) -> None: + """Register a plugin factory in-process (embedding applications, tests).""" + _REGISTERED[name] = factory + + +def unregister(name: str) -> None: + _REGISTERED.pop(name, None) + + +def _factories() -> dict[str, Callable[[], Any]]: + found: dict[str, Callable[[], Any]] = {} + for ep in metadata.entry_points(group=ENTRY_POINT_GROUP): + found[ep.name] = ep.load + found.update({name: (lambda f=f: f) for name, f in _REGISTERED.items()}) + return found + + +def _instantiate(name: str, loader: Callable[[], Any]) -> Any: + try: + factory = loader() + plugin = factory() + except Exception as exc: # import errors, missing private access, broken factories + raise PluginNotAvailable(f"plugin {name!r} is not available: {type(exc).__name__}: {exc}") from None + if getattr(plugin, "spi_version", None) != SPI_VERSION: + raise PluginNotAvailable( + f"plugin {name!r} is not available: SPI version {getattr(plugin, 'spi_version', None)!r}, " + f"core expects {SPI_VERSION}" + ) + return plugin + + +def load(name: str) -> Any: + loader = _factories().get(name) + if loader is None: + raise PluginNotAvailable(f"plugin {name!r} is not installed or not accessible") + return _instantiate(name, loader) + + +def list_plugins() -> list[dict]: + result = [] + for name, loader in sorted(_factories().items()): + try: + plugin = _instantiate(name, loader) + info = dict(plugin.describe()) + result.append({"name": name, "available": True, **info, + "hooks": [h for h in ("plan", "pr_policy_checks") if callable(getattr(plugin, h, None))]}) + except PluginNotAvailable as exc: + result.append({"name": name, "available": False, "error": str(exc)}) + return result + + +def available_plugins() -> list[Any]: + plugins = [] + for name, loader in sorted(_factories().items()): + try: + plugins.append(_instantiate(name, loader)) + except PluginNotAvailable: + continue + return plugins + + +def run_plan(name: str, config: dict, transport, args: Optional[list[str]] = None) -> list: + plugin = load(name) + plan = getattr(plugin, "plan", None) + if not callable(plan): + raise PluginNotAvailable(f"plugin {name!r} does not provide a plan hook") + actions = list(plan(PluginContext(config=config, transport=ReadOnlyTransport(transport), args=list(args or [])))) + for action in actions: + if not isinstance(action, Action): + raise TypeError(f"plugin {name!r} returned {type(action).__name__}, expected Action") + return [Action(a.kind, a.params, a.reason, source=name) for a in actions] diff --git a/src/githubbot_core/pr_review.py b/src/githubbot_core/pr_review.py new file mode 100644 index 0000000..d70f571 --- /dev/null +++ b/src/githubbot_core/pr_review.py @@ -0,0 +1,141 @@ +""" +githubbot-core -- Pull-Request-Zusammenfassung +============================================== +Fasst Pull Requests zusammen und markiert grosse Aenderungen zur manuellen +Pruefung. Projektspezifische Policy-Pruefungen kommen ausschliesslich ueber +Plugins (Hook ``pr_policy_checks``); der Kern bringt keine eigene Policy mit. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Iterable + +logger = logging.getLogger(__name__) + + +@dataclass +class PullRequest: + """Repraesentiert einen GitHub Pull Request.""" + number: int + title: str + body: str + author: str + labels: list[str] = field(default_factory=list) + additions: int = 0 + deletions: int = 0 + changed_files: int = 0 + created_at: str = "" + head_sha: str = "" + threshold_lines: int = 200 + + @property + def total_lines(self) -> int: + return self.additions + self.deletions + + @property + def is_large(self) -> bool: + return self.total_lines > self.threshold_lines + + @classmethod + def from_gh_json(cls, data: dict) -> "PullRequest": + """Erstellt PR aus `gh pr list --json` Output.""" + return cls( + number=data.get("number", 0), + title=data.get("title", ""), + body=data.get("body", "") or "", + author=data.get("author", {}).get("login", "") if data.get("author") else "", + labels=[l["name"] for l in data.get("labels", [])], + additions=data.get("additions", 0), + deletions=data.get("deletions", 0), + changed_files=data.get("changedFiles", 0), + created_at=data.get("createdAt", ""), + ) + + @classmethod + def from_rest_json(cls, data: dict) -> "PullRequest": + """Erstellt PR aus der REST-Antwort `GET /repos/{repo}/pulls/{n}`.""" + return cls( + number=data.get("number", 0), + title=data.get("title", ""), + body=data.get("body", "") or "", + author=(data.get("user") or {}).get("login", ""), + labels=[l.get("name", "") for l in data.get("labels", []) if isinstance(l, dict)], + additions=data.get("additions", 0), + deletions=data.get("deletions", 0), + changed_files=data.get("changed_files", 0), + created_at=data.get("created_at", ""), + head_sha=(data.get("head") or {}).get("sha", ""), + ) + + +@dataclass +class PRReviewResult: + """Ergebnis der PR-Pruefung.""" + pr: PullRequest + summary: str = "" + policy_check_results: list[tuple[str, bool, str]] = field(default_factory=list) + escalate: bool = False + escalate_reason: str = "" + issues_found: list[str] = field(default_factory=list) + + +class PRReviewer: + """Groessenpruefung + Zusammenfassung; Policy-Checks liefern Plugins.""" + + def __init__(self, config: dict, repo: str = "", transport=None, policy_plugins: Iterable = ()): + self.config = config + self.repo = repo + self.transport = transport + self.policy_plugins = [p for p in policy_plugins if callable(getattr(p, "pr_policy_checks", None))] + self.threshold_lines = int(config.get("pr_review", {}).get("summary_threshold_lines", 200)) + + def fetch(self, number: int) -> tuple[PullRequest | None, str | None]: + data, error = self.transport.get(f"repos/{self.repo}/pulls/{int(number)}") + if error or not isinstance(data, dict): + return None, error or "invalid pull request response" + pr = PullRequest.from_rest_json(data) + pr.threshold_lines = self.threshold_lines + return pr, None + + def review(self, pr: PullRequest) -> PRReviewResult: + pr.threshold_lines = self.threshold_lines + result = PRReviewResult(pr=pr) + if pr.is_large: + result.escalate = True + result.escalate_reason = f"Grosser PR: {pr.total_lines} Zeilen in {pr.changed_files} Dateien" + result.summary = self._generate_summary(pr) + + if self.policy_plugins: + files, diff = self._files_and_diff(pr.number) + for plugin in self.policy_plugins: + for name, passed, detail in plugin.pr_policy_checks(pr, files, diff): + check = (f"{plugin.name}:{name}", bool(passed), str(detail)) + result.policy_check_results.append(check) + if not passed: + result.issues_found.append(f"{check[0]}: {check[2]}") + result.escalate = True + return result + + def _files_and_diff(self, number: int) -> tuple[list[str], str]: + if self.transport is None or not self.repo: + return [], "" + items, error = self.transport.get_paginated(f"repos/{self.repo}/pulls/{number}/files?per_page=100") + files = [] if error else [item.get("filename", "") for item in items] + diff, diff_error = self.transport.get_text(f"repos/{self.repo}/pulls/{number}", "application/vnd.github.diff") + return files, "" if diff_error else diff[:80000] + + def _generate_summary(self, pr: PullRequest) -> str: + lines = [ + f"**PR #{pr.number}: {pr.title}**", + f"Autor: {pr.author} | +{pr.additions} -{pr.deletions} Zeilen / {pr.changed_files} Dateien", + ] + if pr.body: + preview = pr.body[:200].replace("\n", " ") + if len(pr.body) > 200: + preview += "..." + lines.append(f"Beschreibung: {preview}") + if pr.is_large: + lines.append(f"HINWEIS: Grosser PR (>{self.threshold_lines} Zeilen) — manuelle Review empfohlen") + return "\n".join(lines) diff --git a/src/githubbot_core/report_sections.py b/src/githubbot_core/report_sections.py new file mode 100644 index 0000000..d3c2ca1 --- /dev/null +++ b/src/githubbot_core/report_sections.py @@ -0,0 +1,117 @@ +""" +githubbot-core -- gemeinsame Berichtssektionen +========================================== +Bausteine, die Traffic- und Sterne-Bericht identisch brauchen: die Sektion +"Diese Module sind privat", die Archiviert-Sektion und der Zeilenumbruch fuer +lange Namenslisten -- je einmal in Markdown und im 80-Zeichen-ASCII-Stil. + +Warum getrennt vom Bericht selbst: Beide Berichte zeigen dieselbe Aussage -- +private Repos stehen nicht in den Zahlen, sondern hier. Lebte der Baustein +zweimal, driften die Formulierungen auseinander, und der Leser haelt einen +Unterschied fuer eine Aussage. + +Der einzige echte Unterschied ist die Begruendung, warum die privaten Module +nicht in den Zahlen stehen -- deshalb ist sie ein Parameter. + +""" + +from __future__ import annotations + +PRIVATE_HEADING_MD = "### Diese Module sind privat:" +PRIVATE_HEADING_ASCII = "PRIVATE MODULE (nicht in den Zahlen)" +ARCHIVED_HEADING_MD = "### Archiviert:" +ARCHIVED_HEADING_ASCII = "ARCHIVIERT" + +AUDIT_HINT = ( + "Die Auflistung dient dem Sichtbarkeits-Audit: so lässt sich prüfen, ob " + "wirklich die richtigen Module privat sind und welche veröffentlicht " + "werden sollten." +) + +# Warum die privaten Module in diesem Bericht nicht in den Zahlen stehen. +REASON_TRAFFIC = ( + "Private Repos liefern über die Traffic-API systematisch keine Werte " + "(0 Views/0 Clones) und stehen deshalb nicht in den Zahlen oben. " +) +REASON_STARS = ( + "Private Repos werden hier bewusst separat aufgeführt, unabhängig von " + "ihrer Sternenzahl. " +) + + +def group_repos_by_org(repo_full_names: list[str]) -> dict[str, list[str]]: + """ + Gruppiert 'org/repo'-Strings nach Organisation. + + Orgs UND Repo-Namen (ohne Org-Praefix) werden alphabetisch sortiert + zurueckgegeben -- fuer die Privat-/Archiviert-Sektionen der Berichte. + """ + groups: dict[str, list[str]] = {} + for full in repo_full_names: + org, sep, name = full.partition("/") + if not sep: + org, name = "", full + groups.setdefault(org, []).append(name) + return {org: sorted(names) for org, names in sorted(groups.items())} + + +def wrap_repo_names(names: list[str], prefix: str, width: int = 80, + indent: str = " ") -> list[str]: + """ + Bricht eine kommaseparierte Namensliste auf mehrere Zeilen um. + + Umgebrochene Zeilen enden auf einem Komma -- ohne es liest sich die + Fortsetzung wie eine neue Aufzaehlung statt wie dieselbe Liste. + Deshalb bleibt beim Anhaengen ein Zeichen Reserve: keine Zeile + ueberschreitet `width`, auch nicht mit dem angehaengten Komma. Ausnahme: + ein einzelner Name ist laenger als `width` -- dann steht er allein auf + seiner Zeile und darf ueberstehen. + + `prefix` steht am Anfang der ersten Zeile (z. B. " example-org (11): "). + """ + lines: list[str] = [] + current = prefix + line_has_item = False + for name in names: + piece = name if not line_has_item else f", {name}" + if line_has_item and len(current) + len(piece) > width - 1: + lines.append(current + ",") + current = indent + name + continue + current += piece + line_has_item = True + lines.append(current) + return lines + + +def markdown_private_section(private_repos: list[str] | None, reason: str) -> list[str]: + """Markdown-Sektion 'Diese Module sind privat'. Leere Liste = keine Sektion.""" + if not private_repos: + return [] + lines = [PRIVATE_HEADING_MD, ""] + for org, names in group_repos_by_org(private_repos).items(): + lines.append(f"- **{org}** ({len(names)}): {', '.join(names)}") + lines += ["", reason + AUDIT_HINT, ""] + return lines + + +def markdown_archived_section(archived_repos: list[str] | None) -> list[str]: + """Markdown-Sektion 'Archiviert'. Leere Liste = keine Sektion.""" + if not archived_repos: + return [] + lines = [ARCHIVED_HEADING_MD, ""] + for org, names in group_repos_by_org(archived_repos).items(): + lines.append(f"- **{org}** ({len(names)}): {', '.join(names)}") + lines.append("") + return lines + + +def ascii_group_block(heading: str, repo_full_names: list[str] | None, + width: int = 80) -> list[str]: + """Blockabschnitt (Privat/Archiviert) im ASCII-Stil der uebrigen Grafiken.""" + if not repo_full_names: + return [] + block = ["", f" {heading}", " " + "─" * (width - 2)] + for org, names in group_repos_by_org(repo_full_names).items(): + block.extend(wrap_repo_names(names, f" {org} ({len(names)}): ", width=width)) + return block diff --git a/src/githubbot_core/review_gate.py b/src/githubbot_core/review_gate.py new file mode 100644 index 0000000..53f0eed --- /dev/null +++ b/src/githubbot_core/review_gate.py @@ -0,0 +1,929 @@ +#!/usr/bin/env python3 +"""Signed, exact-SHA review receipts for GitHub branch protection. + +The privileged GitHub Actions workflow executes this file from the default +branch only. Pull-request content is treated as data and is never imported or +executed. A successful status requires an OpenSSH signature from an allowed +reviewer or owner principal and a receipt bound to the current PR head SHA. + +The receipt format is namespace independent. A deployment chooses one +namespace (default "githubbot"); it derives the signature namespace, the +commit-status context, the dispatch event type and the two signer principals. +This file uses only the Python standard library so that it can be copied +unchanged to .github/review-gate/review_gate.py of a target repository. +""" + +from __future__ import annotations + +import argparse +import base64 +import binascii +import hashlib +import json +import os +import re +import secrets +import shutil +import subprocess +import sys +import tempfile +import urllib.error +import urllib.request +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Mapping + + +RECEIPT_SCHEMA = "githubbot-core.review-gate-receipt/v1" +DEFAULT_NAMESPACE = "githubbot" +ACTIONS_APP_ID = 15368 +MAX_RECEIPT_BYTES = 32 * 1024 +MAX_SIGNATURE_BYTES = 8 * 1024 +MAX_DISPATCH_BYTES = 64 * 1024 +MAX_VALIDITY = timedelta(hours=24) + +_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +_SHA_RE = re.compile(r"^[0-9a-f]{40}$") +_HASH_RE = re.compile(r"^[0-9a-f]{64}$") +_NONCE_RE = re.compile(r"^[A-Za-z0-9_.:/-]{16,128}$") +_NAMESPACE_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,38}$") +_FINDING_KEYS = {"critical", "high", "medium", "low"} +_BASE_KEYS = { + "schema", + "kind", + "repository", + "pr_number", + "head_sha", + "verdict", + "reviewer", + "artifact_sha256", + "findings", + "checks", + "issued_at", + "valid_until", + "nonce", +} +_OPTIONAL_KEYS = {"decision_id", "reason"} + + +class ReceiptValidationError(ValueError): + """A review receipt, signature, or exact-SHA binding is invalid.""" + + +def gate_names(namespace: str = DEFAULT_NAMESPACE) -> dict[str, str]: + """Derive every deployment-specific name from one namespace.""" + if not isinstance(namespace, str) or not _NAMESPACE_RE.fullmatch(namespace): + raise ReceiptValidationError("namespace must be 1-39 lowercase letters, digits or hyphens") + return { + "signature": f"{namespace}-review-gate", + "status_context": f"{namespace}/review-gate", + "dispatch_event": f"{namespace}-review-gate", + "model_review": f"{namespace}-reviewer", + "owner_override": f"{namespace}-owner", + } + + +def _utc_text(value: datetime) -> str: + if value.tzinfo is None: + raise ReceiptValidationError("timestamps must be timezone-aware") + return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _parse_utc(value: object, field: str) -> datetime: + if not isinstance(value, str) or not value.endswith("Z"): + raise ReceiptValidationError(f"{field} must be an RFC3339 UTC timestamp ending in Z") + try: + parsed = datetime.fromisoformat(value[:-1] + "+00:00") + except ValueError as exc: + raise ReceiptValidationError(f"{field} is not a valid timestamp") from exc + if parsed.microsecond: + raise ReceiptValidationError(f"{field} must not contain fractional seconds") + return parsed.astimezone(timezone.utc) + + +def canonical_receipt_bytes(receipt: Mapping[str, object]) -> bytes: + """Return the one accepted byte representation for signing and dispatch.""" + return ( + json.dumps(receipt, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + "\n" + ).encode("utf-8") + + +def create_receipt(**values: object) -> dict[str, object]: + """Create a schema-tagged receipt while enforcing kind-specific fields.""" + receipt = {"schema": RECEIPT_SCHEMA, **values} + kind = receipt.get("kind") + if kind == "owner_override": + if not isinstance(receipt.get("decision_id"), str) or not receipt["decision_id"].strip(): + raise ReceiptValidationError("owner_override requires decision_id") + if not isinstance(receipt.get("reason"), str) or not receipt["reason"].strip(): + raise ReceiptValidationError("owner_override requires reason") + return receipt + + +def build_receipt( + *, + kind: str, + repository: str, + pr_number: int, + head_sha: str, + verdict: str, + reviewer: str, + artifact_path: Path, + findings: Mapping[str, int], + checks: list[str], + valid_minutes: int = 60, + decision_id: str | None = None, + reason: str | None = None, + now: datetime | None = None, +) -> dict[str, object]: + """Build a receipt from an immutable review artifact on disk.""" + issued = (now or datetime.now(timezone.utc)).astimezone(timezone.utc).replace(microsecond=0) + if valid_minutes < 1 or valid_minutes > int(MAX_VALIDITY.total_seconds() // 60): + raise ReceiptValidationError("valid_minutes must be between 1 and 1440") + artifact_hash = hashlib.sha256(artifact_path.read_bytes()).hexdigest() + values: dict[str, object] = { + "kind": kind, + "repository": repository, + "pr_number": pr_number, + "head_sha": head_sha, + "verdict": verdict, + "reviewer": reviewer, + "artifact_sha256": artifact_hash, + "findings": dict(findings), + "checks": checks, + "issued_at": _utc_text(issued), + "valid_until": _utc_text(issued + timedelta(minutes=valid_minutes)), + "nonce": secrets.token_urlsafe(24), + } + if decision_id is not None: + values["decision_id"] = decision_id + if reason is not None: + values["reason"] = reason + receipt = create_receipt(**values) + validate_receipt(receipt, now=issued) + return receipt + + +def validate_receipt( + receipt: Mapping[str, object], + *, + expected_repository: str | None = None, + expected_pr_number: int | None = None, + expected_head_sha: str | None = None, + now: datetime | None = None, +) -> dict[str, object]: + """Validate schema, expiry, verdict invariants, and optional PR binding.""" + if not isinstance(receipt, Mapping): + raise ReceiptValidationError("receipt must be a JSON object") + unknown = set(receipt) - (_BASE_KEYS | _OPTIONAL_KEYS) + missing = _BASE_KEYS - set(receipt) + if unknown: + raise ReceiptValidationError(f"unknown receipt fields: {', '.join(sorted(unknown))}") + if missing: + raise ReceiptValidationError(f"missing receipt fields: {', '.join(sorted(missing))}") + if receipt.get("schema") != RECEIPT_SCHEMA: + raise ReceiptValidationError("unsupported receipt schema") + + kind = receipt.get("kind") + if kind not in {"model_review", "owner_override"}: + raise ReceiptValidationError("kind must be model_review or owner_override") + repository = receipt.get("repository") + if not isinstance(repository, str) or not _REPO_RE.fullmatch(repository): + raise ReceiptValidationError("repository must be org/name") + pr_number = receipt.get("pr_number") + if isinstance(pr_number, bool) or not isinstance(pr_number, int) or pr_number < 1: + raise ReceiptValidationError("pr_number must be a positive integer") + head_sha = receipt.get("head_sha") + if not isinstance(head_sha, str) or not _SHA_RE.fullmatch(head_sha): + raise ReceiptValidationError("head_sha must be a lowercase 40-character Git SHA") + verdict = receipt.get("verdict") + if verdict not in {"approve", "reject"}: + raise ReceiptValidationError("verdict must be approve or reject") + reviewer = receipt.get("reviewer") + if not isinstance(reviewer, str) or not reviewer.strip() or len(reviewer) > 120: + raise ReceiptValidationError("reviewer must be a non-empty short string") + artifact_hash = receipt.get("artifact_sha256") + if not isinstance(artifact_hash, str) or not _HASH_RE.fullmatch(artifact_hash): + raise ReceiptValidationError("artifact_sha256 must be a lowercase SHA-256 hash") + + findings = receipt.get("findings") + if not isinstance(findings, Mapping) or set(findings) != _FINDING_KEYS: + raise ReceiptValidationError("findings must contain exactly critical, high, medium, and low") + for key, value in findings.items(): + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ReceiptValidationError(f"finding count {key} must be a non-negative integer") + + checks = receipt.get("checks") + if ( + not isinstance(checks, list) + or not checks + or any(not isinstance(item, str) or not item.strip() or len(item) > 200 for item in checks) + ): + raise ReceiptValidationError("checks must be a non-empty list of short strings") + nonce = receipt.get("nonce") + if not isinstance(nonce, str) or not _NONCE_RE.fullmatch(nonce): + raise ReceiptValidationError("nonce must contain 16-128 safe characters") + + issued = _parse_utc(receipt.get("issued_at"), "issued_at") + valid_until = _parse_utc(receipt.get("valid_until"), "valid_until") + if valid_until <= issued: + raise ReceiptValidationError("valid_until must be after issued_at") + if valid_until - issued > MAX_VALIDITY: + raise ReceiptValidationError("receipt validity must not exceed 24 hours") + if now is not None: + current = now.astimezone(timezone.utc) + if issued > current + timedelta(minutes=5): + raise ReceiptValidationError("receipt issued_at is too far in the future") + if valid_until < current: + raise ReceiptValidationError("receipt has expired") + + if kind == "model_review": + if "decision_id" in receipt: + raise ReceiptValidationError("model_review must not contain decision_id") + if verdict == "approve" and (findings["critical"] or findings["high"]): + raise ReceiptValidationError("approval cannot contain critical or high findings") + if verdict == "reject" and ( + not isinstance(receipt.get("reason"), str) or not str(receipt["reason"]).strip() + ): + raise ReceiptValidationError("rejected model_review requires reason") + else: + if verdict != "approve": + raise ReceiptValidationError("owner_override only supports approve") + if not isinstance(receipt.get("decision_id"), str) or not str(receipt["decision_id"]).strip(): + raise ReceiptValidationError("owner_override requires decision_id") + if not isinstance(receipt.get("reason"), str) or not str(receipt["reason"]).strip(): + raise ReceiptValidationError("owner_override requires reason") + + if expected_repository is not None and repository != expected_repository: + raise ReceiptValidationError("receipt repository does not match the workflow repository") + if expected_pr_number is not None and pr_number != expected_pr_number: + raise ReceiptValidationError("receipt PR number does not match the live pull request") + if expected_head_sha is not None and head_sha != expected_head_sha: + raise ReceiptValidationError("receipt head SHA does not match the live pull request") + return dict(receipt) + + +def validate_receipt_bytes( + data: bytes, + *, + expected_repository: str | None = None, + expected_pr_number: int | None = None, + expected_head_sha: str | None = None, + now: datetime | None = None, +) -> dict[str, object]: + if not data or len(data) > MAX_RECEIPT_BYTES: + raise ReceiptValidationError("receipt byte length is invalid") + try: + receipt = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ReceiptValidationError("receipt is not valid UTF-8 JSON") from exc + if not isinstance(receipt, dict): + raise ReceiptValidationError("receipt must be a JSON object") + if data != canonical_receipt_bytes(receipt): + raise ReceiptValidationError("receipt bytes are not canonical") + return validate_receipt( + receipt, + expected_repository=expected_repository, + expected_pr_number=expected_pr_number, + expected_head_sha=expected_head_sha, + now=now, + ) + + +def _decode_b64(value: object, *, field: str, limit: int) -> bytes: + if not isinstance(value, str) or not value: + raise ReceiptValidationError(f"{field} must be non-empty base64") + try: + decoded = base64.b64decode(value, validate=True) + except (ValueError, binascii.Error) as exc: + raise ReceiptValidationError(f"{field} is not valid base64") from exc + if not decoded or len(decoded) > limit: + raise ReceiptValidationError(f"{field} decoded length is invalid") + return decoded + + +def encode_dispatch_payload(receipt_bytes: bytes, signature_bytes: bytes) -> dict[str, str]: + if len(receipt_bytes) > MAX_RECEIPT_BYTES or len(signature_bytes) > MAX_SIGNATURE_BYTES: + raise ReceiptValidationError("dispatch artifact is too large") + return { + "receipt_b64": base64.b64encode(receipt_bytes).decode("ascii"), + "signature_b64": base64.b64encode(signature_bytes).decode("ascii"), + } + + +def decode_dispatch_payload(payload: Mapping[str, object]) -> tuple[bytes, bytes]: + return ( + _decode_b64(payload.get("receipt_b64"), field="receipt_b64", limit=MAX_RECEIPT_BYTES), + _decode_b64(payload.get("signature_b64"), field="signature_b64", limit=MAX_SIGNATURE_BYTES), + ) + + +def principal_for_kind(kind: object, namespace: str = DEFAULT_NAMESPACE) -> str: + if kind in ("model_review", "owner_override"): + return gate_names(namespace)[str(kind)] + raise ReceiptValidationError("cannot select signature principal for unknown receipt kind") + + +def verify_ssh_signature( + receipt_bytes: bytes, + signature_bytes: bytes, + allowed_signers_bytes: bytes, + *, + principal: str, + namespace: str = DEFAULT_NAMESPACE, +) -> None: + """Verify a detached OpenSSH signature without exposing a private key.""" + executable = shutil.which("ssh-keygen") + if executable is None: + raise ReceiptValidationError("ssh-keygen is unavailable for signature verification") + if not allowed_signers_bytes or len(allowed_signers_bytes) > 64 * 1024: + raise ReceiptValidationError("allowed signers content is missing or too large") + signature_namespace = gate_names(namespace)["signature"] + with tempfile.TemporaryDirectory(prefix="review-gate-") as temp_dir: + root = Path(temp_dir) + allowed = root / "allowed_signers" + signature = root / "receipt.sig" + allowed.write_bytes(allowed_signers_bytes) + signature.write_bytes(signature_bytes) + result = subprocess.run( + [ + executable, + "-Y", + "verify", + "-f", + str(allowed), + "-I", + principal, + "-n", + signature_namespace, + "-s", + str(signature), + ], + input=receipt_bytes, + capture_output=True, + timeout=20, + ) + if result.returncode != 0: + detail = result.stderr.decode("utf-8", errors="replace").strip() + raise ReceiptValidationError(f"SSH signature verification failed: {detail or 'invalid signature'}") + + +def sign_receipt( + receipt_path: Path, key_path: Path, output_path: Path, namespace: str = DEFAULT_NAMESPACE +) -> None: + """Sign canonical receipt bytes without leaving an adjacent implicit .sig file.""" + receipt_bytes = receipt_path.read_bytes() + validate_receipt_bytes(receipt_bytes, now=datetime.now(timezone.utc)) + executable = shutil.which("ssh-keygen") + if executable is None: + raise ReceiptValidationError("ssh-keygen is unavailable for receipt signing") + if not key_path.is_file(): + raise ReceiptValidationError("signing key does not exist") + if output_path.exists(): + raise ReceiptValidationError(f"refusing to overwrite existing signature: {output_path}") + signature_namespace = gate_names(namespace)["signature"] + with tempfile.TemporaryDirectory(prefix="review-gate-sign-") as temp_dir: + temp_receipt = Path(temp_dir) / "receipt.json" + temp_receipt.write_bytes(receipt_bytes) + result = subprocess.run( + [ + executable, + "-Y", + "sign", + "-f", + str(key_path), + "-n", + signature_namespace, + str(temp_receipt), + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=20, + ) + if result.returncode != 0: + raise ReceiptValidationError( + f"receipt signing failed: {result.stderr.strip() or result.stdout.strip()}" + ) + signature_path = Path(f"{temp_receipt}.sig") + if not signature_path.is_file(): + raise ReceiptValidationError("ssh-keygen did not produce a detached signature") + signature_bytes = signature_path.read_bytes() + if not signature_bytes or len(signature_bytes) > MAX_SIGNATURE_BYTES: + raise ReceiptValidationError("generated signature length is invalid") + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("xb") as handle: + handle.write(signature_bytes) + + +def _enabled(value: object, default: bool = False) -> bool: + if isinstance(value, Mapping): + return bool(value.get("enabled", default)) + if isinstance(value, bool): + return value + return default + + +def _names(items: object, field: str) -> list[str]: + if not isinstance(items, list): + return [] + result: list[str] = [] + for item in items: + if isinstance(item, Mapping) and isinstance(item.get(field), str): + result.append(str(item[field])) + elif isinstance(item, str): + result.append(item) + return result + + +def protection_put_payload(current: Mapping[str, object]) -> dict[str, object]: + """Convert a branch-protection GET response into a restorable PUT body.""" + status = current.get("required_status_checks") + status_payload: dict[str, object] | None = None + if isinstance(status, Mapping): + checks: list[dict[str, object]] = [] + raw_checks = status.get("checks") + if isinstance(raw_checks, list): + for check in raw_checks: + if isinstance(check, Mapping) and isinstance(check.get("context"), str): + raw_app_id = check.get("app_id") + app_id = raw_app_id if isinstance(raw_app_id, int) and not isinstance(raw_app_id, bool) else -1 + checks.append( + { + "context": str(check["context"]), + "app_id": app_id, + } + ) + if not checks: + for context in status.get("contexts", []) if isinstance(status.get("contexts"), list) else []: + if isinstance(context, str): + checks.append({"context": context, "app_id": -1}) + status_payload = {"strict": bool(status.get("strict", False)), "checks": checks} + + reviews = current.get("required_pull_request_reviews") + reviews_payload: dict[str, object] | None = None + if isinstance(reviews, Mapping): + reviews_payload = { + "dismiss_stale_reviews": bool(reviews.get("dismiss_stale_reviews", False)), + "require_code_owner_reviews": bool(reviews.get("require_code_owner_reviews", False)), + "required_approving_review_count": int(reviews.get("required_approving_review_count", 0)), + "require_last_push_approval": bool(reviews.get("require_last_push_approval", False)), + } + bypass = reviews.get("bypass_pull_request_allowances") + if isinstance(bypass, Mapping): + reviews_payload["bypass_pull_request_allowances"] = { + "users": _names(bypass.get("users"), "login"), + "teams": _names(bypass.get("teams"), "slug"), + "apps": _names(bypass.get("apps"), "slug"), + } + dismissal = reviews.get("dismissal_restrictions") + if isinstance(dismissal, Mapping): + reviews_payload["dismissal_restrictions"] = { + "users": _names(dismissal.get("users"), "login"), + "teams": _names(dismissal.get("teams"), "slug"), + "apps": _names(dismissal.get("apps"), "slug"), + } + + restrictions = current.get("restrictions") + restrictions_payload: dict[str, object] | None = None + if isinstance(restrictions, Mapping): + restrictions_payload = { + "users": _names(restrictions.get("users"), "login"), + "teams": _names(restrictions.get("teams"), "slug"), + "apps": _names(restrictions.get("apps"), "slug"), + } + + return { + "required_status_checks": status_payload, + "enforce_admins": _enabled(current.get("enforce_admins")), + "required_pull_request_reviews": reviews_payload, + "restrictions": restrictions_payload, + "required_linear_history": _enabled(current.get("required_linear_history")), + "allow_force_pushes": _enabled(current.get("allow_force_pushes")), + "allow_deletions": _enabled(current.get("allow_deletions")), + "block_creations": _enabled(current.get("block_creations")), + "required_conversation_resolution": _enabled(current.get("required_conversation_resolution")), + "lock_branch": _enabled(current.get("lock_branch")), + "allow_fork_syncing": _enabled(current.get("allow_fork_syncing")), + } + + +def build_protection_payloads( + current: Mapping[str, object], + *, + context: str | None = None, + app_id: int = ACTIONS_APP_ID, + namespace: str = DEFAULT_NAMESPACE, +) -> tuple[dict[str, object], dict[str, object]]: + """Return (rollback, desired) payloads without changing GitHub.""" + context = context or gate_names(namespace)["status_context"] + rollback = protection_put_payload(current) + desired = json.loads(json.dumps(rollback)) + desired["required_status_checks"] = { + "strict": True, + "checks": [{"context": context, "app_id": app_id}], + } + desired["required_pull_request_reviews"] = { + "dismiss_stale_reviews": False, + "require_code_owner_reviews": False, + "required_approving_review_count": 0, + "require_last_push_approval": False, + } + desired["enforce_admins"] = True + desired["allow_force_pushes"] = False + desired["allow_deletions"] = False + desired["required_conversation_resolution"] = True + return rollback, desired + + +def _resolve_token() -> str: + # Kept local on purpose: this file must run standalone inside a target + # repository. The package-level adapter is githubbot_core.credentials. + for name in ("GH_TOKEN", "GITHUB_TOKEN"): + if os.environ.get(name): + return str(os.environ[name]) + result = subprocess.run( + ["gh", "auth", "token"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=15, + ) + if result.returncode != 0 or not result.stdout.strip(): + raise ReceiptValidationError("no GitHub token available") + return result.stdout.strip() + + +def _github_api( + method: str, + path: str, + *, + token: str, + payload: Mapping[str, object] | None = None, +) -> object: + url = f"https://api.github.com/{path.lstrip('/')}" + body = None if payload is None else json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + url, + data=body, + method=method, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2026-03-10", + "User-Agent": "githubbot-core-review-gate/1", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + raw = response.read() + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace")[:500] + raise ReceiptValidationError(f"GitHub API {method} {path} failed: HTTP {exc.code}: {detail}") from exc + if not raw: + return None + return json.loads(raw.decode("utf-8")) + + +def _live_pr(repository: str, pr_number: int, *, token: str) -> dict[str, object]: + result = _github_api("GET", f"repos/{repository}/pulls/{pr_number}", token=token) + if not isinstance(result, dict): + raise ReceiptValidationError("GitHub pull request response is invalid") + return result + + +def _post_status( + repository: str, + sha: str, + state: str, + description: str, + *, + token: str, + target_url: str | None = None, + namespace: str = DEFAULT_NAMESPACE, +) -> None: + payload: dict[str, object] = { + "state": state, + "context": gate_names(namespace)["status_context"], + "description": description[:140], + } + if target_url: + payload["target_url"] = target_url + _github_api("POST", f"repos/{repository}/statuses/{sha}", token=token, payload=payload) + + +def _run_url() -> str | None: + server = os.environ.get("GITHUB_SERVER_URL") + repository = os.environ.get("GITHUB_REPOSITORY") + run_id = os.environ.get("GITHUB_RUN_ID") + if server and repository and run_id: + return f"{server}/{repository}/actions/runs/{run_id}" + return None + + +def workflow_pending( + repository: str, pr_number: int, head_sha: str, namespace: str = DEFAULT_NAMESPACE +) -> None: + token = _resolve_token() + live = _live_pr(repository, pr_number, token=token) + live_sha = ((live.get("head") or {}).get("sha") if isinstance(live.get("head"), Mapping) else None) + if live.get("state") != "open" or live_sha != head_sha: + raise ReceiptValidationError("pending status target is not the current open PR head") + _post_status( + repository, + head_sha, + "pending", + "Awaiting a signed exact-SHA reviewer or owner receipt.", + token=token, + target_url=_run_url(), + namespace=namespace, + ) + + +def workflow_dispatch( + repository: str, + payload: Mapping[str, object], + allowed_signers: bytes, + namespace: str = DEFAULT_NAMESPACE, +) -> None: + token = _resolve_token() + receipt_bytes, signature_bytes = decode_dispatch_payload(payload) + preliminary = validate_receipt_bytes(receipt_bytes, now=datetime.now(timezone.utc)) + pr_number = int(preliminary["pr_number"]) + live = _live_pr(repository, pr_number, token=token) + live_head = live.get("head") + live_sha = live_head.get("sha") if isinstance(live_head, Mapping) else None + if live.get("state") != "open" or not isinstance(live_sha, str): + raise ReceiptValidationError("receipt targets a pull request that is not open") + + try: + receipt = validate_receipt_bytes( + receipt_bytes, + expected_repository=repository, + expected_pr_number=pr_number, + expected_head_sha=live_sha, + now=datetime.now(timezone.utc), + ) + verify_ssh_signature( + receipt_bytes, + signature_bytes, + allowed_signers, + principal=principal_for_kind(receipt["kind"], namespace), + namespace=namespace, + ) + if receipt["verdict"] == "approve": + description = ( + "Signed model review approved the exact PR head." + if receipt["kind"] == "model_review" + else f"Signed owner override: {receipt['decision_id']}" + ) + _post_status( + repository, + live_sha, + "success", + description, + token=token, + target_url=_run_url(), + namespace=namespace, + ) + return + raise ReceiptValidationError("signed review verdict is reject") + except ReceiptValidationError as exc: + _post_status( + repository, + live_sha, + "failure", + f"Review receipt rejected: {exc}", + token=token, + target_url=_run_url(), + namespace=namespace, + ) + raise + + +def dispatch_receipt( + receipt_path: Path, + signature_path: Path, + allowed_signers_path: Path, + *, + apply: bool, + namespace: str = DEFAULT_NAMESPACE, +) -> dict[str, object]: + """Verify a local receipt against its live PR head and optionally dispatch it.""" + receipt_bytes = receipt_path.read_bytes() + signature_bytes = signature_path.read_bytes() + allowed_signers = allowed_signers_path.read_bytes() + now = datetime.now(timezone.utc) + receipt = validate_receipt_bytes(receipt_bytes, now=now) + verify_ssh_signature( + receipt_bytes, + signature_bytes, + allowed_signers, + principal=principal_for_kind(receipt["kind"], namespace), + namespace=namespace, + ) + repository = str(receipt["repository"]) + pr_number = int(receipt["pr_number"]) + token = _resolve_token() + live = _live_pr(repository, pr_number, token=token) + live_head = live.get("head") + live_sha = live_head.get("sha") if isinstance(live_head, Mapping) else None + if live.get("state") != "open" or not isinstance(live_sha, str): + raise ReceiptValidationError("receipt targets a pull request that is not open") + receipt = validate_receipt_bytes( + receipt_bytes, + expected_repository=repository, + expected_pr_number=pr_number, + expected_head_sha=live_sha, + now=now, + ) + client_payload = encode_dispatch_payload(receipt_bytes, signature_bytes) + api_payload: dict[str, object] = { + "event_type": gate_names(namespace)["dispatch_event"], + "client_payload": client_payload, + } + encoded_size = len(json.dumps(api_payload, separators=(",", ":")).encode("utf-8")) + if encoded_size > MAX_DISPATCH_BYTES: + raise ReceiptValidationError("repository_dispatch payload exceeds GitHub's 64 KiB limit") + if apply: + _github_api( + "POST", + f"repos/{repository}/dispatches", + token=token, + payload=api_payload, + ) + return { + "ok": True, + "applied": apply, + "repository": repository, + "pr_number": pr_number, + "head_sha": live_sha, + "verdict": receipt["verdict"], + "kind": receipt["kind"], + "payload_bytes": encoded_size, + } + + +def _json_env_payload() -> dict[str, object]: + return { + "receipt_b64": os.environ.get("REVIEW_GATE_RECEIPT_B64", ""), + "signature_b64": os.environ.get("REVIEW_GATE_SIGNATURE_B64", ""), + } + + +def _allowed_signers_from_env() -> bytes: + return _decode_b64( + os.environ.get("REVIEW_GATE_ALLOWED_SIGNERS_B64", ""), + field="REVIEW_GATE_ALLOWED_SIGNERS_B64", + limit=64 * 1024, + ) + + +def _write_json(path: Path, value: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("x", encoding="utf-8") as handle: + handle.write(json.dumps(value, indent=2, ensure_ascii=False) + "\n") + + +def _write_new_bytes(path: Path, value: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("xb") as handle: + handle.write(value) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Signed exact-SHA GitHub review gate") + parser.add_argument( + "--namespace", + default=os.environ.get("REVIEW_GATE_NAMESPACE") or DEFAULT_NAMESPACE, + help="deployment namespace (env REVIEW_GATE_NAMESPACE, default %(default)s)", + ) + sub = parser.add_subparsers(dest="command", required=True) + + create = sub.add_parser("create", help="Create a canonical receipt bound to a review artifact") + create.add_argument("--kind", required=True, choices=("model_review", "owner_override")) + create.add_argument("--repo", required=True) + create.add_argument("--pr", required=True, type=int) + create.add_argument("--head-sha", required=True) + create.add_argument("--verdict", required=True, choices=("approve", "reject")) + create.add_argument("--reviewer", required=True) + create.add_argument("--artifact", required=True, type=Path) + create.add_argument("--critical", type=int, default=0) + create.add_argument("--high", type=int, default=0) + create.add_argument("--medium", type=int, default=0) + create.add_argument("--low", type=int, default=0) + create.add_argument("--check", action="append", required=True) + create.add_argument("--valid-minutes", type=int, default=60) + create.add_argument("--decision-id") + create.add_argument("--reason") + create.add_argument("--output", required=True, type=Path) + + sign = sub.add_parser("sign", help="Sign a canonical receipt with an OpenSSH private key") + sign.add_argument("--receipt", required=True, type=Path) + sign.add_argument("--key", required=True, type=Path) + sign.add_argument("--output", required=True, type=Path) + + dispatch_local = sub.add_parser( + "dispatch", help="Verify a signed receipt against the live PR and optionally dispatch it" + ) + dispatch_local.add_argument("--receipt", required=True, type=Path) + dispatch_local.add_argument("--signature", required=True, type=Path) + dispatch_local.add_argument("--allowed-signers", required=True, type=Path) + dispatch_local.add_argument("--apply", action="store_true") + + pending = sub.add_parser("workflow-pending", help="Mark a verified live PR head pending") + pending.add_argument("--repo", required=True) + pending.add_argument("--pr", required=True, type=int) + pending.add_argument("--head-sha", required=True) + + dispatch = sub.add_parser("workflow-dispatch", help="Verify dispatch env and post exact-SHA status") + dispatch.add_argument("--repo", required=True) + + verify = sub.add_parser("verify", help="Verify a local signed receipt") + verify.add_argument("--receipt", required=True, type=Path) + verify.add_argument("--signature", required=True, type=Path) + verify.add_argument("--allowed-signers", required=True, type=Path) + + plan = sub.add_parser("protection-plan", help="Build desired and rollback branch-protection payloads") + plan.add_argument("--current", required=True, type=Path) + plan.add_argument("--output-dir", required=True, type=Path) + + args = parser.parse_args(argv) + try: + if args.command == "create": + receipt = build_receipt( + kind=args.kind, + repository=args.repo, + pr_number=args.pr, + head_sha=args.head_sha, + verdict=args.verdict, + reviewer=args.reviewer, + artifact_path=args.artifact, + findings={ + "critical": args.critical, + "high": args.high, + "medium": args.medium, + "low": args.low, + }, + checks=args.check, + valid_minutes=args.valid_minutes, + decision_id=args.decision_id, + reason=args.reason, + ) + _write_new_bytes(args.output, canonical_receipt_bytes(receipt)) + print(args.output) + elif args.command == "sign": + sign_receipt(args.receipt, args.key, args.output, namespace=args.namespace) + print(args.output) + elif args.command == "dispatch": + print( + json.dumps( + dispatch_receipt( + args.receipt, + args.signature, + args.allowed_signers, + apply=args.apply, + namespace=args.namespace, + ), + ensure_ascii=False, + ) + ) + elif args.command == "workflow-pending": + workflow_pending(args.repo, args.pr, args.head_sha, namespace=args.namespace) + elif args.command == "workflow-dispatch": + workflow_dispatch( + args.repo, _json_env_payload(), _allowed_signers_from_env(), namespace=args.namespace + ) + elif args.command == "verify": + receipt_bytes = args.receipt.read_bytes() + receipt = validate_receipt_bytes(receipt_bytes, now=datetime.now(timezone.utc)) + verify_ssh_signature( + receipt_bytes, + args.signature.read_bytes(), + args.allowed_signers.read_bytes(), + principal=principal_for_kind(receipt["kind"], args.namespace), + namespace=args.namespace, + ) + print(json.dumps({"ok": True, "receipt": receipt}, ensure_ascii=False)) + elif args.command == "protection-plan": + current = json.loads(args.current.read_text(encoding="utf-8")) + rollback, desired = build_protection_payloads(current, namespace=args.namespace) + rollback_path = args.output_dir / "rollback.json" + desired_path = args.output_dir / "desired.json" + if rollback_path.exists() or desired_path.exists(): + raise ReceiptValidationError("refusing to overwrite an existing protection plan") + _write_json(rollback_path, rollback) + _write_json(desired_path, desired) + print(args.output_dir) + return 0 + except (OSError, ReceiptValidationError, json.JSONDecodeError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/githubbot_core/stars.py b/src/githubbot_core/stars.py new file mode 100644 index 0000000..9e31b1d --- /dev/null +++ b/src/githubbot_core/stars.py @@ -0,0 +1,200 @@ +""" +githubbot-core -- Sterne-Rangliste +================================ +Ruft die Sternenzahl (stargazers_count) fuer Repos ab und erstellt eine +nach Sternen sortierte Rangliste ueber alle Repos aller konfigurierten Orgs. + +Endpunkt: + - /repos/{owner}/{repo} -- liefert u.a. "stargazers_count" + +Es wird nur der Zaehlerwert pro Repo gelesen (ein API-Call pro Repo), keine +Stargazer-Liste und keine Schreibaktion. + +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Optional + +from .transport import GhCliTransport + +logger = logging.getLogger(__name__) + +from .report_sections import ( # noqa: E402 + ARCHIVED_HEADING_ASCII, + REASON_STARS as PRIVATE_REASON, + PRIVATE_HEADING_ASCII, + ascii_group_block, + markdown_archived_section, + markdown_private_section, +) + + +@dataclass +class RepoStars: + """Sternenzahl eines Repos.""" + repo: str + stars: int = 0 + error: Optional[str] = None + + +class StarsFetcher: + """ + Ruft die Sternenzahl fuer GitHub-Repos ab. + + Nutzt den Transport-Adapter (Standard: `gh api`) -- keine Zusatzabhaengigkeiten. + """ + + def __init__(self, transport=None) -> None: + self.transport = transport or GhCliTransport() + self.last_error: Optional[str] = None + + def fetch(self, repo: str) -> RepoStars: + """ + Ruft die Sternenzahl fuer ein Repo ab. + + Args: + repo: "owner/reponame" (z.B. "octo-org/octo-repo") + + Returns: + RepoStars mit Sternenzahl oder Fehlermeldung + """ + data = self._call(f"repos/{repo}") + if data is None: + detail = self.last_error or "unbekannter Fehler" + return RepoStars(repo=repo, error=f"Kein Zugriff auf Repo-API: {detail}") + return RepoStars(repo=repo, stars=data.get("stargazers_count", 0)) + + def fetch_all(self, repos: list[str]) -> list[RepoStars]: + """Ruft die Sternenzahl fuer mehrere Repos ab.""" + results = [] + for repo in repos: + results.append(self.fetch(repo)) + return results + + def _call(self, endpoint: str) -> dict | list | None: + data, error = self.transport.get(endpoint) + self.last_error = error + return None if error else data + + +def generate_ascii_ranking( + summaries: list[RepoStars], + private_repos: list[str] | None = None, + archived_repos: list[str] | None = None, +) -> str: + """ + Erstellt eine ASCII-Rangliste aller Repos, absteigend nach Sternen. + + `summaries` enthaelt nur oeffentliche Repos (der Aufrufer filtert bereits). + `private_repos`/`archived_repos` ("org/repo"-Strings, optional) erscheinen + in eigenen Bloecken am Ende und fliessen NICHT in Summen/Raenge/Balken ein. + """ + from datetime import date + + today = date.today().strftime("%Y-%m-%d") + BAR_WIDTH = 40 + NAME_W = 38 + + def bar(val, max_val): + if max_val == 0: + return "·" * BAR_WIDTH + filled = val / max_val * BAR_WIDTH + full = int(filled) + half = 1 if (filled - full) >= 0.5 else 0 + return "█" * full + ("▌" if half else "") + "·" * (BAR_WIDTH - full - half) + + def fmt(name): + return (name[:NAME_W - 1] + "…") if len(name) > NAME_W else name.ljust(NAME_W) + + valid = [s for s in summaries if not s.error] + errors = [s for s in summaries if s.error] + ranked = sorted(valid, key=lambda s: s.stars, reverse=True) + max_stars = max((s.stars for s in ranked), default=1) + total_stars = sum(s.stars for s in ranked) + + W = 80 + lines = [ + "=" * W, + " GITHUB REPO STERNE-RANGLISTE", + f" Multi-Org | Stand: {today}", + "=" * W, + "", + f" {'Rang':>4} {'Repo':<{NAME_W}} {'Stars':>6} Bar", + f" {'─' * 4} {'─' * NAME_W} {'─' * 6} {'─' * BAR_WIDTH}", + ] + + for i, s in enumerate(ranked, 1): + lines.append(f" {i:>4} {fmt(s.repo)} {s.stars:>6} {bar(s.stars, max_stars)}") + + if errors: + names = ", ".join(s.repo for s in errors) + lines += ["", f" (kein Zugriff): {names}"] + + lines += ascii_group_block(PRIVATE_HEADING_ASCII, private_repos, W) + lines += ascii_group_block(ARCHIVED_HEADING_ASCII, archived_repos, W) + + gesamt_line = f" GESAMT | {len(ranked)} Repos | {total_stars} Stars" + if private_repos: + gesamt_line += f" | Privat: {len(private_repos)} (siehe oben)" + + lines += [ + "", + "=" * W, + gesamt_line, + "=" * W, + ] + + return "\n".join(lines) + + +def generate_stars_report( + summaries: list[RepoStars], + private_repos: list[str] | None = None, + archived_repos: list[str] | None = None, +) -> str: + """ + Erstellt einen Markdown-Report mit der Sterne-Rangliste. + + `summaries` enthaelt nur oeffentliche Repos (der Aufrufer filtert bereits). + `private_repos`/`archived_repos` ("org/repo"-Strings, optional) erscheinen + als eigene Sektionen am Ende und fliessen NICHT in die Zahlen ein. + """ + from datetime import date + today = date.today().strftime("%Y-%m-%d") + + valid = [s for s in summaries if not s.error] + errors = [s for s in summaries if s.error] + ranked = sorted(valid, key=lambda s: s.stars, reverse=True) + total_stars = sum(s.stars for s in ranked) + + if private_repos: + header_line = ( + f"**{len(ranked)} öffentliche Repos** in den Zahlen | " + f"**{total_stars} Stars gesamt** | **{len(private_repos)} privat** (siehe unten)" + ) + else: + header_line = f"**{len(ranked)} Repos** | **{total_stars} Stars gesamt**" + + lines = [ + f"## GitHub Sterne-Rangliste -- {today}", + header_line, + "", + "| Rang | Repo | Stars |", + "|------|------|------:|", + ] + + for i, s in enumerate(ranked, 1): + lines.append(f"| {i} | {s.repo} | {s.stars} |") + + if errors: + lines += ["", "### Fehler (kein Zugriff):"] + for s in errors: + lines.append(f"- {s.repo}: {s.error}") + + lines += markdown_private_section(private_repos, PRIVATE_REASON) + lines += markdown_archived_section(archived_repos) + + return "\n".join(lines) diff --git a/src/githubbot_core/templates/review-gate.yml b/src/githubbot_core/templates/review-gate.yml new file mode 100644 index 0000000..e500dd9 --- /dev/null +++ b/src/githubbot_core/templates/review-gate.yml @@ -0,0 +1,66 @@ +name: review gate + +# Exact-SHA review gate. The privileged jobs run the verifier from the default +# branch only; pull-request code is never checked out, installed or executed. +# Namespace: __GATE_NAMESPACE__ (fixed when this file was scaffolded). +# Repository variable REVIEW_GATE_ALLOWED_SIGNERS_B64: base64 of the OpenSSH +# allowed_signers file with the public keys of the reviewer and owner principals. + +on: + pull_request_target: + types: [opened, reopened, synchronize, ready_for_review] + repository_dispatch: + types: [__GATE_NAMESPACE__-review-gate] + +permissions: + contents: read + pull-requests: read + statuses: write + +concurrency: + group: review-gate-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: false + +jobs: + pending: + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-latest + steps: + - name: Check out trusted default-branch verifier + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Mark the exact pull-request head pending + env: + GH_TOKEN: ${{ github.token }} + REVIEW_GATE_NAMESPACE: __GATE_NAMESPACE__ + GATE_REPOSITORY: ${{ github.repository }} + GATE_PR_NUMBER: ${{ github.event.pull_request.number }} + GATE_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: >- + python .github/review-gate/review_gate.py workflow-pending + --repo "$GATE_REPOSITORY" + --pr "$GATE_PR_NUMBER" + --head-sha "$GATE_PR_HEAD_SHA" + + dispatch: + if: github.event_name == 'repository_dispatch' + runs-on: ubuntu-latest + steps: + - name: Check out trusted default-branch verifier + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Verify signed receipt and publish the exact-SHA gate status + env: + GH_TOKEN: ${{ github.token }} + REVIEW_GATE_NAMESPACE: __GATE_NAMESPACE__ + GATE_REPOSITORY: ${{ github.repository }} + REVIEW_GATE_RECEIPT_B64: ${{ github.event.client_payload.receipt_b64 }} + REVIEW_GATE_SIGNATURE_B64: ${{ github.event.client_payload.signature_b64 }} + REVIEW_GATE_ALLOWED_SIGNERS_B64: ${{ vars.REVIEW_GATE_ALLOWED_SIGNERS_B64 }} + run: >- + python .github/review-gate/review_gate.py workflow-dispatch + --repo "$GATE_REPOSITORY" diff --git a/src/githubbot_core/traffic.py b/src/githubbot_core/traffic.py new file mode 100644 index 0000000..41aed21 --- /dev/null +++ b/src/githubbot_core/traffic.py @@ -0,0 +1,437 @@ +""" +githubbot-core -- Traffic-Abruf +=========================== +Ruft GitHub Traffic-Daten fuer ein Repo ab (14-Tage-Fenster). + +Endpunkte: + - /traffic/views -- Seitenaufrufe (total + unique) + - /traffic/clones -- Klone (total + unique) + - /traffic/popular/referrers -- Top-Referrer (bis 10) + - /traffic/popular/paths -- Meistbesuchte Pfade (bis 10) + +Voraussetzung: `gh auth login` mit push-Rechten auf das Repo. + +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from typing import Optional + +from .transport import GhCliTransport + +logger = logging.getLogger(__name__) + +from .report_sections import ( # noqa: E402 + ARCHIVED_HEADING_ASCII, + REASON_TRAFFIC as PRIVATE_REASON, + PRIVATE_HEADING_ASCII, + ascii_group_block, + markdown_archived_section, + markdown_private_section, +) + + +@dataclass +class TrafficSummary: + """Zusammengefasste Traffic-Daten eines Repos (14-Tage-Fenster).""" + repo: str + views_total: int = 0 + views_unique: int = 0 + clones_total: int = 0 + clones_unique: int = 0 + top_referrers: list[dict] = field(default_factory=list) + top_paths: list[dict] = field(default_factory=list) + error: Optional[str] = None + + def is_empty(self) -> bool: + return self.views_total == 0 and self.clones_total == 0 + + def format(self) -> str: + if self.error: + return f" [TRAFFIC ERROR] {self.error}" + + lines = [ + f" Views (14d): {self.views_total} gesamt / {self.views_unique} unique", + f" Clones (14d): {self.clones_total} gesamt / {self.clones_unique} unique", + ] + + if self.top_referrers: + refs = ", ".join( + f"{r['referrer']} ({r['count']})" + for r in self.top_referrers[:3] + ) + lines.append(f" Referrer: {refs}") + + if self.top_paths: + paths = ", ".join( + f"{p['path']} ({p['count']})" + for p in self.top_paths[:3] + ) + lines.append(f" Top-Pfade: {paths}") + + return "\n".join(lines) + + +class TrafficFetcher: + """ + Ruft Traffic-Daten fuer GitHub-Repos ab. + + Nutzt den Transport-Adapter (Standard: `gh api`) -- keine Zusatzabhaengigkeiten. + Token braucht push-Rechte (eigene Repos: automatisch vorhanden). + """ + + def __init__(self, transport=None) -> None: + self.transport = transport or GhCliTransport() + self.last_error: Optional[str] = None + + def fetch(self, repo: str) -> TrafficSummary: + """ + Ruft alle Traffic-Endpunkte fuer ein Repo ab. + + Args: + repo: "owner/reponame" (z.B. "octo-org/octo-repo") + + Returns: + TrafficSummary mit allen verfuegbaren Daten + """ + summary = TrafficSummary(repo=repo) + + views = self._call(f"repos/{repo}/traffic/views") + if views is None: + detail = self.last_error or "unbekannter Fehler" + summary.error = f"Kein Zugriff auf Traffic-Views-API: {detail}" + return summary + summary.views_total = views.get("count", 0) + summary.views_unique = views.get("uniques", 0) + + clones = self._call(f"repos/{repo}/traffic/clones") + if clones is None: + detail = self.last_error or "unbekannter Fehler" + summary.error = f"Kein Zugriff auf Traffic-Clones-API: {detail}" + return summary + summary.clones_total = clones.get("count", 0) + summary.clones_unique = clones.get("uniques", 0) + + referrers = self._call(f"repos/{repo}/traffic/popular/referrers") + if referrers: + summary.top_referrers = referrers + + paths = self._call(f"repos/{repo}/traffic/popular/paths") + if paths: + summary.top_paths = paths + + return summary + + def fetch_all(self, repos: list[str]) -> list[TrafficSummary]: + """Ruft Traffic fuer mehrere Repos ab.""" + results = [] + for repo in repos: + results.append(self.fetch(repo)) + return results + + def _call(self, endpoint: str) -> dict | list | None: + data, error = self.transport.get(endpoint) + self.last_error = error + return None if error else data + + +# Balkenbreite und Namensspalte gelten fuer BEIDE Grafiken -- nur so sind ein +# Repo-Balken im Gesamtchart und derselbe Balken in der Org-Ansicht optisch +# vergleichbar. Deshalb liegen bar()/fmt() auf Modulebene statt lokal. +BAR_WIDTH = 40 +NAME_W = 30 + + +def bar(val: int, max_val: int) -> str: + """Balken fester Breite, halbe Stufe ab 0,5 -- identisch in allen Grafiken.""" + if max_val == 0: + return "·" * BAR_WIDTH + filled = val / max_val * BAR_WIDTH + full = int(filled) + half = 1 if (filled - full) >= 0.5 else 0 + return "█" * full + ("▌" if half else "") + "·" * (BAR_WIDTH - full - half) + + +def fmt(name: str) -> str: + """Repo-Name auf feste Spaltenbreite, zu lange Namen mit Auslassung.""" + return (name[:NAME_W - 1] + "…") if len(name) > NAME_W else name.ljust(NAME_W) + + +def generate_ascii_chart( + summaries: list[TrafficSummary], + private_repos: list[str] | None = None, + archived_repos: list[str] | None = None, +) -> str: + """ + Erstellt eine ASCII-Balkengrafik mit Clones und Views aller Repos. + + `summaries` enthaelt nur oeffentliche Repos (der Aufrufer filtert bereits). + `private_repos`/`archived_repos` ("org/repo"-Strings, optional) erscheinen + in eigenen Bloecken am Ende und fliessen NICHT in Summen/Raenge/Balken ein. + """ + from datetime import date + + today = date.today().strftime("%Y-%m-%d") + + valid = [s for s in summaries if not s.error] + max_clones = max((s.clones_total for s in valid), default=1) + max_views = max((s.views_total for s in valid), default=1) + + by_clones = sorted(valid, key=lambda s: s.clones_total, reverse=True) + by_views = sorted(valid, key=lambda s: s.views_total, reverse=True) + with_views = [s for s in by_views if s.views_total > 0] + no_views = [s for s in by_views if s.views_total == 0] + + total_clones = sum(s.clones_total for s in valid) + total_cu = sum(s.clones_unique for s in valid) + total_views = sum(s.views_total for s in valid) + total_vu = sum(s.views_unique for s in valid) + + W = 80 + lines = [ + "=" * W, + " GITHUB REPO TRAFFIC | letzte 14 Tage", + f" Multi-Org | Stand: {today}", + "=" * W, + "", + " CLONES (gesamt / unique)", + " " + "─" * (W - 2), + f" {'Repo':<{NAME_W}} {'Clones':>7} Bar", + f" {'─' * NAME_W} {'─' * 7} {'─' * BAR_WIDTH}", + ] + + for s in by_clones: + name = s.repo.split("/")[1] + val = f"{s.clones_total:>4} /{s.clones_unique:>3}u" + lines.append(f" {fmt(name)} {val} {bar(s.clones_total, max_clones)}") + + lines += [ + "", + " VIEWS (gesamt / unique)", + " " + "─" * (W - 2), + f" {'Repo':<{NAME_W}} {'Views':>7} Bar", + f" {'─' * NAME_W} {'─' * 7} {'─' * BAR_WIDTH}", + ] + + for s in with_views: + name = s.repo.split("/")[1] + val = f"{s.views_total:>4} /{s.views_unique:>3}u" + lines.append(f" {fmt(name)} {val} {bar(s.views_total, max_views)}") + + if no_views: + names = ", ".join(s.repo.split("/")[1] for s in no_views) + lines.append(f" {'(keine Views):':<{NAME_W}} {'':>7} {names}") + + lines += ascii_group_block(PRIVATE_HEADING_ASCII, private_repos, W) + lines += ascii_group_block(ARCHIVED_HEADING_ASCII, archived_repos, W) + + gesamt_line = ( + f" GESAMT | Clones: {total_clones} ({total_cu} unique) | " + f"Views: {total_views} ({total_vu} unique)" + ) + if private_repos: + gesamt_line += f" | Privat: {len(private_repos)} (siehe oben)" + + lines += [ + "", + "=" * W, + gesamt_line, + "=" * W, + ] + + return "\n".join(lines) + + +GROUP_FALLBACK = "Sonstiges" + + +def load_repo_groups(path) -> tuple[dict[str, str], str]: + """ + Laedt die Zuordnung Repo -> Bundle/Thema aus `config/repo_groups.json`. + + Rueckgabe: ({"org/repo": "Gruppenname"}, Fallback-Name). Fehlt die Datei + oder ist sie unlesbar, kommt eine leere Zuordnung zurueck -- die Grafik + entsteht dann vollstaendig, nur eben mit allen Repos unter "Sonstiges". + Der Bericht zaehlt diese Faelle in der Kopfzeile, damit eine veraltete + Zuordnung auffaellt statt still zu bleiben. + """ + from pathlib import Path + + try: + raw = json.loads(Path(path).read_text(encoding="utf-8")) + except Exception as exc: + logger.warning("Repo-Gruppen nicht geladen (%s): %s", path, exc) + return {}, GROUP_FALLBACK + + fallback = raw.get("fallback") or GROUP_FALLBACK + groups = {} + for repo, entry in (raw.get("groups") or {}).items(): + name = entry.get("group") if isinstance(entry, dict) else entry + if name: + groups[repo] = name + return groups, fallback + + +def generate_ascii_chart_by_group( + summaries: list[TrafficSummary], + groups: dict[str, str], + private_repos: list[str] | None = None, + archived_repos: list[str] | None = None, + fallback: str = GROUP_FALLBACK, +) -> str: + """ + Zweite Grafik: dieselben Zahlen, nach Organisation und darin nach Bundle + bzw. Thema gegliedert -- je ein Abschnitt fuer Views und fuer Clones. + + Innerhalb jeder Ebene wird absteigend nach der Kennzahl des Abschnitts + sortiert: Orgs nach ihrer Summe, Gruppen nach ihrer Summe, Repos nach + ihrem eigenen Wert. Balkenzeichen und Skalierung sind dieselben wie in + `generate_ascii_chart` (gemeinsames `bar()`), damit sich beide Dateien + nebeneinander lesen lassen. + + `groups` bildet "org/repo" auf einen Gruppennamen ab; was dort fehlt, + landet unter `fallback`. + """ + from datetime import date + + today = date.today().strftime("%Y-%m-%d") + W = 80 + + valid = [s for s in summaries if not s.error] + max_views = max((s.views_total for s in valid), default=1) + max_clones = max((s.clones_total for s in valid), default=1) + ungrouped = sum(1 for s in valid if s.repo not in groups) + + def split(summary): + org, _, name = summary.repo.partition("/") + return (org or "?"), (name or summary.repo) + + # org -> gruppe -> [summary] + tree: dict[str, dict[str, list[TrafficSummary]]] = {} + for s in valid: + org, _ = split(s) + tree.setdefault(org, {}).setdefault(groups.get(s.repo, fallback), []).append(s) + + def section(title, unit, value, unique, max_val): + out = [ + "", + f" {title} (gesamt / unique)", + " " + "─" * (W - 2), + ] + orgs = sorted( + tree.items(), + key=lambda kv: (-sum(value(s) for g in kv[1].values() for s in g), kv[0]), + ) + for org, by_group in orgs: + org_sum = sum(value(s) for g in by_group.values() for s in g) + org_uni = sum(unique(s) for g in by_group.values() for s in g) + out.append("") + out.append(f" {org} ({org_sum} {unit} / {org_uni} unique)") + ordered = sorted( + by_group.items(), + key=lambda kv: (-sum(value(s) for s in kv[1]), kv[0]), + ) + for group, members in ordered: + group_sum = sum(value(s) for s in members) + group_uni = sum(unique(s) for s in members) + out.append(f" {group}") + for s in sorted(members, key=lambda s: (-value(s), split(s)[1].lower())): + name = split(s)[1] + val = f"{value(s):>4} /{unique(s):>3}u" + out.append(f" {fmt(name)} {val} {bar(value(s), max_val)}") + out.append(f" {fmt('Summe ' + group)} {group_sum:>4} /{group_uni:>3}u") + out.append(f" {fmt('Summe ' + org):<{NAME_W + 2}} {org_sum:>4} /{org_uni:>3}u") + return out + + lines = [ + "=" * W, + " GITHUB REPO TRAFFIC NACH ORGANISATION | letzte 14 Tage", + f" Gruppierung: Bundle bzw. Thema | {fallback}: {ungrouped} | Stand: {today}", + "=" * W, + ] + lines += section("VIEWS", "Views", lambda s: s.views_total, lambda s: s.views_unique, max_views) + lines += section( + "CLONES (Downloads)", "Clones", + lambda s: s.clones_total, lambda s: s.clones_unique, max_clones, + ) + + lines += ascii_group_block(PRIVATE_HEADING_ASCII, private_repos, W) + lines += ascii_group_block(ARCHIVED_HEADING_ASCII, archived_repos, W) + + total_views = sum(s.views_total for s in valid) + total_vu = sum(s.views_unique for s in valid) + total_clones = sum(s.clones_total for s in valid) + total_cu = sum(s.clones_unique for s in valid) + gesamt_line = ( + f" GESAMT | Views: {total_views} ({total_vu} unique) | " + f"Clones: {total_clones} ({total_cu} unique)" + ) + if private_repos: + gesamt_line += f" | Privat: {len(private_repos)} (siehe oben)" + + lines += ["", "=" * W, gesamt_line, "=" * W] + return "\n".join(lines) + + +def generate_traffic_report( + summaries: list[TrafficSummary], + private_repos: list[str] | None = None, + archived_repos: list[str] | None = None, +) -> str: + """ + Erstellt einen Markdown-Report ueber alle Traffic-Daten. + + `summaries` enthaelt nur oeffentliche Repos (der Aufrufer filtert bereits). + `private_repos`/`archived_repos` ("org/repo"-Strings, optional) erscheinen + als eigene Sektionen am Ende und fliessen NICHT in die Zahlen ein. + """ + from datetime import date + today = date.today().strftime("%Y-%m-%d") + + if private_repos: + header_line = ( + f"**{len(summaries)} öffentliche Repos** in den Zahlen | " + f"**{len(private_repos)} privat** (siehe unten) | 14-Tage-Fenster" + ) + else: + header_line = f"**{len(summaries)} Repos** | 14-Tage-Fenster" + + lines = [ + f"## GitHub Traffic Report -- {today}", + header_line, + "", + ] + + # Sortiert nach Views (hoeher = weiter oben) + sorted_summaries = sorted(summaries, key=lambda s: s.views_total, reverse=True) + + active = [s for s in sorted_summaries if not s.is_empty() and not s.error] + inactive = [s for s in sorted_summaries if s.is_empty() and not s.error] + errors = [s for s in sorted_summaries if s.error] + + if active: + lines.append("### Aktive Repos (Traffic in den letzten 14 Tagen):") + lines.append("") + for s in active: + lines.append(f"**{s.repo}**") + lines.append(s.format()) + lines.append("") + + if inactive: + names = ", ".join(s.repo.split("/")[1] for s in inactive) + lines.append(f"### Keine Aktivität (14d): {names}") + lines.append("") + + if errors: + lines.append("### Fehler (kein Zugriff):") + for s in errors: + lines.append(f"- {s.repo}: {s.error}") + lines.append("") + + lines += markdown_private_section(private_repos, PRIVATE_REASON) + lines += markdown_archived_section(archived_repos) + + return "\n".join(lines) diff --git a/src/githubbot_core/transport.py b/src/githubbot_core/transport.py new file mode 100644 index 0000000..937d319 --- /dev/null +++ b/src/githubbot_core/transport.py @@ -0,0 +1,138 @@ +"""GitHub transport adapter. + +All network access of the core goes through one object with a small surface: +``get``, ``get_paginated``, ``get_text`` and ``request``. Every method returns +``(data, error)`` instead of raising, so callers can report partial failures. + +The default implementation shells out to the GitHub CLI (``gh api``). The CLI +owns authentication; the core never stores a token. Tests and plugins can pass +any object with the same methods (for example an in-memory fake). +""" + +from __future__ import annotations + +import json +import logging +import subprocess +from typing import Any, Callable, Optional, Protocol + +logger = logging.getLogger(__name__) + +API_VERSION = "2026-03-10" + + +class Transport(Protocol): + def get(self, endpoint: str) -> tuple[Any, Optional[str]]: ... + + def get_paginated(self, endpoint: str) -> tuple[list, Optional[str]]: ... + + def get_text(self, endpoint: str, accept: str) -> tuple[str, Optional[str]]: ... + + def request(self, method: str, endpoint: str, body: Optional[dict] = None) -> tuple[Any, Optional[str]]: ... + + +class GhCliTransport: + """``gh api`` based transport. Authentication stays with the GitHub CLI.""" + + def __init__(self, gh: str = "gh", timeout: int = 60, runner: Optional[Callable] = None): + self.gh = gh + self.timeout = timeout + self._runner = runner + + def _invoke(self, args: list[str], stdin: Optional[str] = None) -> tuple[str, Optional[str]]: + cmd = [self.gh, "api", "-H", f"X-GitHub-Api-Version: {API_VERSION}", *args] + run = self._runner or subprocess.run + try: + result = run( + cmd, + input=stdin, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=self.timeout, + ) + except subprocess.TimeoutExpired: + return "", "Timeout" + except Exception as exc: # the transport reports, it never raises (callers collect partial errors) + return "", f"{type(exc).__name__}: {exc}" + if result.returncode != 0: + detail = " ".join((result.stderr or result.stdout or "").split()) + return "", detail[:500] or f"gh api exit {result.returncode}" + return result.stdout or "", None + + @staticmethod + def _json(text: str) -> tuple[Any, Optional[str]]: + if not text.strip(): + return None, None + try: + return json.loads(text), None + except json.JSONDecodeError as exc: + return None, f"JSON error: {exc}" + + def get(self, endpoint: str) -> tuple[Any, Optional[str]]: + text, error = self._invoke(["-H", "Accept: application/vnd.github+json", endpoint]) + return (None, error) if error else self._json(text) + + def get_text(self, endpoint: str, accept: str) -> tuple[str, Optional[str]]: + return self._invoke(["-H", f"Accept: {accept}", endpoint]) + + def get_paginated(self, endpoint: str) -> tuple[list, Optional[str]]: + text, error = self._invoke(["--paginate", "-H", "Accept: application/vnd.github+json", endpoint]) + if error: + return [], error + return parse_concatenated_json(text) + + def request(self, method: str, endpoint: str, body: Optional[dict] = None) -> tuple[Any, Optional[str]]: + args = ["--method", method.upper(), "-H", "Accept: application/vnd.github+json", endpoint] + stdin = None + if body is not None: + args += ["--input", "-"] + stdin = json.dumps(body) + text, error = self._invoke(args, stdin=stdin) + return (None, error) if error else self._json(text) + + +def parse_concatenated_json(text: str) -> tuple[list, Optional[str]]: + """Parse the concatenated JSON documents that ``gh api --paginate`` prints.""" + text = (text or "").strip() + decoder = json.JSONDecoder() + items: list = [] + idx = 0 + try: + while idx < len(text): + while idx < len(text) and text[idx].isspace(): + idx += 1 + if idx >= len(text): + break + obj, idx = decoder.raw_decode(text, idx) + if isinstance(obj, list): + items.extend(obj) + elif isinstance(obj, dict) and "items" in obj: + items.extend(obj.get("items") or []) + else: + items.append(obj) + except json.JSONDecodeError as exc: + return [], f"JSON error: {exc}" + return items, None + + +class ReadOnlyTransport: + """Wraps a transport and refuses writes. Handed to plugins during planning.""" + + def __init__(self, inner: Transport): + self._inner = inner + + def get(self, endpoint: str): + return self._inner.get(endpoint) + + def get_paginated(self, endpoint: str): + return self._inner.get_paginated(endpoint) + + def get_text(self, endpoint: str, accept: str): + return self._inner.get_text(endpoint, accept) + + def request(self, method: str, endpoint: str, body: Optional[dict] = None): + if method.upper() != "GET": + return None, "read-only transport: writes are only possible through an applied action plan" + return self._inner.get(endpoint) diff --git a/src/githubbot_core/triage.py b/src/githubbot_core/triage.py new file mode 100644 index 0000000..b82cc6a --- /dev/null +++ b/src/githubbot_core/triage.py @@ -0,0 +1,237 @@ +""" +githubbot-core -- Issue-Triage +============================== +Klassifiziert GitHub-Issues nach konfigurierbaren Kriterien und plant +Label- und Kommentaraktionen. + +Ablauf: + 1. Offene Issues ueber den Transport laden (REST, Pull Requests ausgefiltert) + 2. Jedes Issue nach ``triage_criteria`` klassifizieren + 3. Labels und Auto-Kommentare als Aktionen planen (``actions.Action``) + 4. Vorschau ist der Standard; geschrieben wird nur ueber ``actions.apply`` +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from datetime import date +from typing import Optional + +from .actions import Action +from .comments import AutoCommentManager + +logger = logging.getLogger(__name__) + + +# ============================================================ +# DATA CLASSES +# ============================================================ + +@dataclass +class GitHubIssue: + """Repraesentiert ein GitHub Issue.""" + number: int + title: str + body: str + labels: list[str] = field(default_factory=list) + state: str = "open" + author: str = "" + created_at: str = "" + + @classmethod + def from_gh_json(cls, data: dict) -> "GitHubIssue": + """Erstellt Issue aus `gh issue list --json` Output.""" + return cls( + number=data.get("number", 0), + title=data.get("title", ""), + body=data.get("body", "") or "", + labels=[l["name"] for l in data.get("labels", [])], + state=data.get("state", "open"), + author=data.get("author", {}).get("login", "") if data.get("author") else "", + created_at=data.get("createdAt", ""), + ) + + @classmethod + def from_rest_json(cls, data: dict) -> "GitHubIssue": + """Erstellt Issue aus der REST-Antwort `GET /repos/{repo}/issues`.""" + return cls( + number=data.get("number", 0), + title=data.get("title", ""), + body=data.get("body", "") or "", + labels=[l.get("name", "") for l in data.get("labels", []) if isinstance(l, dict)], + state=data.get("state", "open"), + author=(data.get("user") or {}).get("login", ""), + created_at=data.get("created_at", ""), + ) + + +@dataclass +class TriageResult: + """Ergebnis der Triage fuer ein Issue.""" + issue: GitHubIssue + labels_to_add: list[str] = field(default_factory=list) + comment: Optional[str] = None + comment_type: Optional[str] = None + escalate: bool = False + escalate_reason: str = "" + + +# ============================================================ +# CLASSIFIER +# ============================================================ + +class IssueClassifier: + """Klassifiziert Issues nach den Triage-Kriterien aus der Config.""" + + PRIORITY_ORDER = [ + "security_public", "core_change", "bug_with_steps", + "bug_without_details", "feature_request", "question", + "documentation", "unclear", + ] + + def __init__(self, config: dict): + self.criteria = config.get("triage_criteria", {}) + self.comments = config.get("auto_comments", {}) + + def classify(self, issue: GitHubIssue) -> TriageResult: + """ + Klassifiziert ein Issue und gibt ein TriageResult zurueck. + + Reihenfolge (Prioritaet absteigend): + 1. Security (sofortige Aktion noetig) + 2. Core-Aenderung (eskalieren) + 3. Bug mit / ohne Steps + 4. Feature Request + 5. Frage + 6. Unklar + """ + result = TriageResult(issue=issue) + + text = f"{issue.title} {issue.body}".lower() + matched_criteria = [] + + for crit_name, crit in self.criteria.items(): + if self._matches(text, crit): + matched_criteria.append(crit_name) + + # Sortiere nach Prioritaet + matched_criteria.sort( + key=lambda c: self.PRIORITY_ORDER.index(c) if c in self.PRIORITY_ORDER else len(self.PRIORITY_ORDER) + ) + + if not matched_criteria: + crit = self.criteria.get("unclear", {}) + result.labels_to_add = [l for l in crit.get("labels", ["needs-info"]) if l not in issue.labels] + result.escalate = crit.get("escalate", False) + result.escalate_reason = "Keine Kategorie erkannt" + return result + + primary = matched_criteria[0] + crit = self.criteria[primary] + + # Labels aus Primary + sekundaere Labels zusammensammeln + all_labels = list(crit.get("labels", [])) + for secondary in matched_criteria[1:]: + for label in self.criteria[secondary].get("labels", []): + if label not in all_labels: + all_labels.append(label) + + result.labels_to_add = [l for l in all_labels if l not in issue.labels] + + if crit.get("escalate"): + result.escalate = True + result.escalate_reason = crit["description"] + + auto_comment_key = crit.get("auto_comment") + if auto_comment_key and auto_comment_key in self.comments: + result.comment = self.comments[auto_comment_key]["template"] + result.comment_type = auto_comment_key + + return result + + def _matches(self, text: str, criterion: dict) -> bool: + """Prueft ob Text die Detektions-Keywords des Kriteriums enthaelt.""" + detection = criterion.get("detection", {}) + keywords = detection.get("keywords", []) + if not keywords: + return False + return any(kw.lower() in text for kw in keywords) + + +# ============================================================ +# TRIAGE +# ============================================================ + +class IssueTriage: + """Laedt Issues, klassifiziert sie und plant Aktionen. Schreibt selbst nie.""" + + def __init__(self, config: dict, repo: str, transport): + if not repo or "/" not in repo: + raise ValueError("repo must be given as owner/name") + self.config = config + self.repo = repo + self.transport = transport + self.classifier = IssueClassifier(config) + self.comments = AutoCommentManager(config) + + def fetch_issues(self, limit: int = 50) -> tuple[list[GitHubIssue], Optional[str]]: + data, error = self.transport.get_paginated(f"repos/{self.repo}/issues?state=open&per_page=100") + if error: + return [], error + issues = [GitHubIssue.from_rest_json(item) for item in data if "pull_request" not in item] + return issues[:limit], None + + def classify_all(self, issues: list[GitHubIssue]) -> list[TriageResult]: + return [self.classifier.classify(issue) for issue in issues] + + def plan(self, results: list[TriageResult]) -> list[Action]: + """Uebersetzt Triage-Ergebnisse in Aktionen. Kommentare nur, wenn der Marker fehlt.""" + actions: list[Action] = [] + for result in results: + number = result.issue.number + if result.labels_to_add: + actions.append(Action("issue.add_labels", + {"repo": self.repo, "number": number, "labels": result.labels_to_add}, + reason=f"triage #{number}")) + if result.comment and result.comment_type: + existing, error = self.transport.get_paginated( + f"repos/{self.repo}/issues/{number}/comments?per_page=100") + if error: + logger.warning("#%d: Kommentare nicht lesbar, Kommentar wird nicht geplant: %s", number, error) + continue + if self.comments.check_already_commented(number, result.comment_type, existing): + continue + body = self.comments.add_marker(result.comment, result.comment_type) + actions.append(Action("issue.comment", {"repo": self.repo, "number": number, "body": body}, + reason=f"auto comment {result.comment_type} #{number}")) + return actions + + @staticmethod + def get_escalations(results: list[TriageResult]) -> list[TriageResult]: + return [r for r in results if r.escalate] + + def generate_report(self, results: list[TriageResult]) -> str: + today = date.today().strftime("%Y-%m-%d") + lines = [ + f"## Issue-Triage -- {today}", + f"**Repo:** {self.repo} | **Issues verarbeitet:** {len(results)}", + "", + ] + auto_done = [r for r in results if not r.escalate] + escalations = [r for r in results if r.escalate] + if auto_done: + lines.append("### Automatisch einordenbar:") + for r in auto_done: + labels = ", ".join(r.labels_to_add) if r.labels_to_add else "(keine neuen Labels)" + lines.append(f"- #{r.issue.number}: **{r.issue.title[:60]}** -> {labels}") + lines.append("") + if escalations: + lines.append("### Eskalation (Handlungsbedarf):") + for r in escalations: + lines.append(f"- #{r.issue.number}: **{r.issue.title[:60]}**") + lines.append(f" Grund: {r.escalate_reason}") + lines.append("") + if not results: + lines.append("*Keine neuen Issues zu verarbeiten.*") + return "\n".join(lines) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..502b990 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import pytest + + +class FakeTransport: + """In-memory transport: canned GET answers, every write is recorded.""" + + def __init__(self, gets: dict | None = None, texts: dict | None = None, fail_writes: bool = False): + self.gets = gets or {} + self.texts = texts or {} + self.fail_writes = fail_writes + self.writes: list[tuple[str, str, object]] = [] + + def get(self, endpoint): + if endpoint in self.gets: + return self.gets[endpoint], None + return None, f"404 {endpoint}" + + def get_paginated(self, endpoint): + data, error = self.get(endpoint) + return (data or []), error + + def get_text(self, endpoint, accept): + return self.texts.get(endpoint, ""), None + + def request(self, method, endpoint, body=None): + self.writes.append((method, endpoint, body)) + return (None, "HTTP 500") if self.fail_writes else ({"ok": True}, None) + + +@pytest.fixture +def fake_transport(): + return FakeTransport diff --git a/tests/test_auto_comments.py b/tests/test_auto_comments.py new file mode 100644 index 0000000..83574e6 --- /dev/null +++ b/tests/test_auto_comments.py @@ -0,0 +1,102 @@ +"""Tests fuer AutoCommentManager.""" +import unittest + + +from githubbot_core.comments import AutoCommentManager, CommentTemplate + + +class TestCommentTemplate(unittest.TestCase): + """Testet CommentTemplate.""" + + def test_render_with_variable(self): + tmpl = CommentTemplate(key="test", trigger="test", template="Issue #{original_issue} is duplicate") + result = tmpl.render(original_issue=42) + self.assertEqual(result, "Issue #42 is duplicate") + + def test_render_without_variable(self): + tmpl = CommentTemplate(key="test", trigger="test", template="Static message") + result = tmpl.render() + self.assertEqual(result, "Static message") + + def test_render_missing_variable_fallback(self): + tmpl = CommentTemplate(key="test", trigger="test", template="Issue #{original_issue}") + result = tmpl.render() # Missing variable + self.assertEqual(result, "Issue #{original_issue}") # Fallback + + +class TestAutoCommentManager(unittest.TestCase): + """Testet AutoCommentManager.""" + + def setUp(self): + self.config = { + "auto_comments": { + "needs_info_bug": { + "trigger": "bug_without_details", + "template": "Please provide: version, OS, steps to reproduce" + }, + "duplicate_reference": { + "trigger": "duplicate", + "template": "Duplicate of #{original_issue}. Closing." + }, + } + } + self.manager = AutoCommentManager(self.config) + + def test_get_comment(self): + result = self.manager.get_comment("needs_info_bug") + self.assertIn("provide", result.lower()) + + def test_get_comment_with_variable(self): + result = self.manager.get_comment("duplicate_reference", original_issue=42) + self.assertIn("#42", result) + + def test_get_nonexistent_template(self): + result = self.manager.get_comment("nonexistent_key") + self.assertIsNone(result) + + def test_has_template(self): + self.assertTrue(self.manager.has_template("needs_info_bug")) + self.assertFalse(self.manager.has_template("nonexistent")) + + def test_list_templates(self): + keys = self.manager.list_templates() + self.assertIn("needs_info_bug", keys) + self.assertIn("duplicate_reference", keys) + + def test_add_marker(self): + text = "Some comment" + marked = self.manager.add_marker(text, "needs_info_bug") + self.assertIn("", marked) + + def test_check_already_commented_true(self): + """Erkennt vorhandenen Bot-Kommentar.""" + comments = [ + {"body": "Some comment\n"} + ] + result = self.manager.check_already_commented(1, "needs_info_bug", comments) + self.assertTrue(result) + + def test_check_already_commented_false(self): + """Erkennt fehlenden Bot-Kommentar.""" + comments = [ + {"body": "Regular user comment without marker"} + ] + result = self.manager.check_already_commented(1, "needs_info_bug", comments) + self.assertFalse(result) + + def test_check_already_commented_empty(self): + """Leere Kommentarliste = nicht kommentiert.""" + result = self.manager.check_already_commented(1, "needs_info_bug", []) + self.assertFalse(result) + + def test_check_different_type(self): + """Anderer Kommentar-Typ wird nicht verwechselt.""" + comments = [ + {"body": "Comment\n"} + ] + result = self.manager.check_already_commented(1, "needs_info_bug", comments) + self.assertFalse(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_classifier.py b/tests/test_classifier.py new file mode 100644 index 0000000..14da870 --- /dev/null +++ b/tests/test_classifier.py @@ -0,0 +1,89 @@ +"""Tests fuer IssueClassifier.""" +import unittest + + +from githubbot_core.triage import IssueClassifier, GitHubIssue + + +class TestIssueClassifier(unittest.TestCase): + """Testet IssueClassifier mit verschiedenen Issue-Typen.""" + + def setUp(self): + """Config mit Standard-Kriterien laden.""" + from githubbot_core.config import default_config + self.config = default_config() + self.classifier = IssueClassifier(self.config) + + def _make_issue(self, title: str, body: str = "", labels: list = None) -> GitHubIssue: + return GitHubIssue( + number=1, title=title, body=body, + labels=labels or [], state="open", author="testuser" + ) + + def test_security_detection(self): + """Security-Keywords werden erkannt.""" + issue = self._make_issue("Found XSS vulnerability in login page") + result = self.classifier.classify(issue) + self.assertIn("security", result.labels_to_add) + + def test_bug_detection(self): + """Bug-Keywords werden erkannt.""" + issue = self._make_issue("Application crashes on startup", "I get a traceback error") + result = self.classifier.classify(issue) + self.assertIn("bug", result.labels_to_add) + + def test_feature_detection(self): + """Feature-Keywords werden erkannt.""" + issue = self._make_issue("Feature request: add dark mode support") + result = self.classifier.classify(issue) + self.assertIn("feature", result.labels_to_add) + + def test_question_detection(self): + """Frage-Keywords werden erkannt.""" + issue = self._make_issue("How to configure the database?") + result = self.classifier.classify(issue) + self.assertIn("question", result.labels_to_add) + + def test_unclear_fallback(self): + """Unklare Issues bekommen needs-info.""" + issue = self._make_issue("Hello world", "Just saying hi") + result = self.classifier.classify(issue) + self.assertIn("needs-info", result.labels_to_add) + + def test_security_priority_over_bug(self): + """Security hat hoehere Prioritaet als Bug.""" + issue = self._make_issue("Security vulnerability causes crash", "exploit error traceback") + result = self.classifier.classify(issue) + # Security sollte als erstes Label kommen + self.assertEqual(result.labels_to_add[0], "security") + + def test_existing_labels_not_duplicated(self): + """Bereits vorhandene Labels werden nicht erneut gesetzt.""" + issue = self._make_issue("Bug: crash on startup", labels=["bug"]) + result = self.classifier.classify(issue) + self.assertNotIn("bug", result.labels_to_add) + + def test_auto_comment_set(self): + """Auto-Kommentar wird bei passendem Kriterium gesetzt.""" + issue = self._make_issue("Not working", "error fails") + result = self.classifier.classify(issue) + # bug_without_details sollte needs_info_bug Kommentar setzen + if result.comment: + self.assertIn("provide", result.comment.lower()) + + def test_comment_type_set(self): + """comment_type wird zusammen mit comment gesetzt.""" + issue = self._make_issue("How do I install this?", "help question") + result = self.classifier.classify(issue) + if result.comment: + self.assertIsNotNone(result.comment_type) + + def test_escalation_for_unclear(self): + """Unklare Issues werden eskaliert.""" + issue = self._make_issue("xyz abc", "random text without keywords") + result = self.classifier.classify(issue) + self.assertTrue(result.escalate) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_consumer_install.py b/tests/test_consumer_install.py new file mode 100644 index 0000000..63babed --- /dev/null +++ b/tests/test_consumer_install.py @@ -0,0 +1,63 @@ +"""Consumer test: install the package into a fresh virtual environment and use the CLI. + +Slow (creates a venv and builds the wheel), therefore opt-in: + GITHUBBOT_CORE_CONSUMER_TEST=1 python -m pytest tests/test_consumer_install.py +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import venv +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + +pytestmark = pytest.mark.skipif( + os.environ.get("GITHUBBOT_CORE_CONSUMER_TEST") != "1", reason="set GITHUBBOT_CORE_CONSUMER_TEST=1" +) + + +def _run(cmd, cwd, **kwargs): + env = {**os.environ, "PYTHONIOENCODING": "utf-8"} + env.pop("PYTHONPATH", None) + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, encoding="utf-8", env=env, **kwargs) + + +def test_installed_package_cli_without_private_extension(tmp_path: Path) -> None: + env_dir = tmp_path / "venv" + venv.EnvBuilder(with_pip=True).create(env_dir) + bindir = env_dir / ("Scripts" if sys.platform == "win32" else "bin") + python = bindir / ("python.exe" if sys.platform == "win32" else "python") + install = _run([str(python), "-m", "pip", "install", "-q", str(ROOT)], cwd=tmp_path, timeout=600) + assert install.returncode == 0, install.stderr + + cli = bindir / ("githubbot-core.exe" if sys.platform == "win32" else "githubbot-core") + work = tmp_path / "work" + work.mkdir() + + info = _run([str(cli), "--json", "info"], cwd=work) + assert info.returncode == 0, info.stderr + envelope = json.loads(info.stdout) + assert envelope["ok"] is True and envelope["data"]["plugins"] == [] + + origin = _run([str(python), "-c", "import githubbot_core, sys; print(githubbot_core.__file__)"], cwd=work) + assert "site-packages" in origin.stdout, origin.stdout # not the source tree + + missing = _run([str(cli), "--json", "run", "private-extension"], cwd=work) + assert missing.returncode == 3 + assert json.loads(missing.stdout)["data"] == {"error": "plugin_not_available"} + + issues = work / "issues.json" + issues.write_text(json.dumps([{"number": 1, "title": "Found XSS vulnerability", "body": "", "labels": []}]), + encoding="utf-8") + classify = _run([str(cli), "--json", "classify", "--input", str(issues)], cwd=work) + assert classify.returncode == 0 and "security" in json.loads(classify.stdout)["data"][0]["labels_to_add"] + + before = sorted(p.name for p in work.iterdir()) + scaffold = _run([str(cli), "--json", "gate-scaffold", "--dest", str(work)], cwd=work) + assert scaffold.returncode == 0 and json.loads(scaffold.stdout)["mode"] == "preview" + assert sorted(p.name for p in work.iterdir()) == before # preview wrote nothing diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..33b012d --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,273 @@ +"""Contract tests: preview/apply, permission ceilings, config layering, plugin SPI, CLI.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from githubbot_core import actions, cli, plugins +from githubbot_core.actions import Action +from githubbot_core.config import ConfigError, default_config, merge_layers +from githubbot_core.credentials import CredentialUnavailable, EnvTokenProvider, redact, resolve_token +from githubbot_core.pr_review import PRReviewer, PullRequest +from githubbot_core.transport import ReadOnlyTransport + +REPO = "example-org/example-repo" +ISSUES = [ + {"number": 1, "title": "How do I configure the output?", "body": "", "labels": [], + "user": {"login": "someone"}, "created_at": "2026-01-01T00:00:00Z"}, + {"number": 2, "title": "Found XSS vulnerability", "body": "", "labels": [], + "user": {"login": "someone"}, "created_at": "2026-01-01T00:00:00Z"}, + {"number": 3, "title": "a pull request", "body": "", "labels": [], "pull_request": {}, + "user": {"login": "someone"}, "created_at": "2026-01-01T00:00:00Z"}, +] + + +def _triage_gets(): + return { + f"repos/{REPO}/issues?state=open&per_page=100": ISSUES, + f"repos/{REPO}/issues/1/comments?per_page=100": [], + f"repos/{REPO}/issues/2/comments?per_page=100": [{"body": "x\n"}], + } + + +# ---------------------------------------------------------------- preview/apply + +def test_preview_writes_nothing(fake_transport, capsys): + transport = fake_transport(_triage_gets()) + code = cli.main(["--json", "triage", "--repo", REPO], transport=transport) + out = json.loads(capsys.readouterr().out) + assert code == 0 + assert out["mode"] == "preview" and out["data"]["writes"] == 0 + assert transport.writes == [] + kinds = sorted(a["kind"] for a in out["data"]["actions"]) + # issue 1: question label + docs-hint comment; issue 2: labels only (comment marker already present); PR skipped + assert kinds == ["issue.add_labels", "issue.add_labels", "issue.comment"] + assert out["data"]["issues"] == 2 + + +def test_apply_writes_only_permitted_actions_and_is_idempotent(fake_transport, tmp_path, capsys): + transport = fake_transport(_triage_gets()) + store = tmp_path / "receipts.jsonl" + assert cli.main(["--json", "triage", "--repo", REPO, "--apply", "--receipts", str(store)], transport=transport) == 0 + first = json.loads(capsys.readouterr().out) + assert first["mode"] == "apply" + assert len(transport.writes) == 3 + assert {r["status"] for r in first["data"]["receipts"]} == {"applied"} + + assert cli.main(["--json", "triage", "--repo", REPO, "--apply", "--receipts", str(store)], transport=transport) == 0 + second = json.loads(capsys.readouterr().out) + assert len(transport.writes) == 3 # nothing repeated + assert {r["status"] for r in second["data"]["receipts"]} == {"skipped_duplicate"} + + +def test_apply_refuses_changed_plan(fake_transport): + transport = fake_transport() + result = actions.apply([Action("issue.comment", {"repo": REPO, "number": 1, "body": "x"})], transport, + default_config(), expected_plan_sha256="0" * 64) + assert result["writes"] == 0 and "mismatch" in result["error"] + assert transport.writes == [] + + +def test_disabled_action_is_denied_not_written(fake_transport): + transport = fake_transport() + plan = [Action("api.request", {"method": "PUT", "endpoint": "user/following/someone"})] + result = actions.apply(plan, transport, default_config()) + assert result["receipts"][0]["status"] == "denied" + assert transport.writes == [] + + +def test_api_request_needs_matching_prefix(fake_transport): + config = merge_layers([("default", default_config()), + ("profile", {"permissions": {"enabled": ["api.request"], + "api_write_prefixes": ["user/starred/"]}})]) + allowed = Action("api.request", {"method": "PUT", "endpoint": "user/starred/o/r"}) + other = Action("api.request", {"method": "PUT", "endpoint": "user/following/someone"}) + sneaky = Action("api.request", {"method": "PUT", "endpoint": "user/starred/../following/x"}) + read = Action("api.request", {"method": "GET", "endpoint": "user/starred/o/r"}) + assert actions.denial_reason(allowed, config) is None + assert "not covered" in actions.denial_reason(other, config) + assert "not covered" in actions.denial_reason(sneaky, config) + assert "not a write method" in actions.denial_reason(read, config) + + +def test_failed_write_is_reported(fake_transport, capsys): + transport = fake_transport(_triage_gets(), fail_writes=True) + assert cli.main(["--json", "triage", "--repo", REPO, "--apply"], transport=transport) == 1 + out = json.loads(capsys.readouterr().out) + assert out["ok"] is False and {r["status"] for r in out["data"]["receipts"]} == {"failed"} + + +# ---------------------------------------------------------------- config layering + +def test_later_layer_cannot_widen_ceiling(): + with pytest.raises(ConfigError, match="may only be narrowed"): + merge_layers([("default", default_config()), + ("profile", {"permissions": {"ceiling": ["issue.comment"], "enabled": ["issue.comment"]}}), + ("local", {"permissions": {"ceiling": ["issue.comment", "api.request"]}})]) + + +def test_enabled_must_stay_inside_ceiling(): + with pytest.raises(ConfigError, match="exceeds"): + merge_layers([("default", default_config()), + ("profile", {"permissions": {"ceiling": ["issue.comment"]}})]) + + +def test_api_prefixes_narrow_only(): + base = [("default", default_config()), ("profile", {"permissions": {"api_write_prefixes": ["user/starred/"]}})] + assert merge_layers(base)["permissions"]["api_write_prefixes"] == ["user/starred/"] + with pytest.raises(ConfigError): + merge_layers(base + [("local", {"permissions": {"api_write_prefixes": ["user/"]}})]) + + +def test_bad_config_file_exit_code(tmp_path, fake_transport): + bad = tmp_path / "bad.json" + bad.write_text("{not json", encoding="utf-8") + assert cli.main(["--config", str(bad), "info"], transport=fake_transport()) == cli.EXIT_USAGE + + +# ---------------------------------------------------------------- plugin SPI + +class _DemoPlugin: + name = "demo" + spi_version = plugins.SPI_VERSION + + def describe(self): + return {"version": "1.0", "capabilities": ["plan", "pr_policy"]} + + def plan(self, ctx): + assert isinstance(ctx.transport, ReadOnlyTransport) + assert ctx.transport.request("PUT", "user/starred/o/r")[1].startswith("read-only") + return [Action("api.request", {"method": "PUT", "endpoint": "user/starred/o/r"}, reason="demo")] + + def pr_policy_checks(self, pr, files, diff): + return [("no_todo", "TODO" not in diff, "diff contains TODO" if "TODO" in diff else "OK")] + + +@pytest.fixture +def demo_plugin(): + plugins.register("demo", _DemoPlugin) + yield + plugins.unregister("demo") + + +def test_missing_plugin_is_clean_not_available(fake_transport, capsys): + code = cli.main(["--json", "run", "not-installed-extension"], transport=fake_transport()) + out = json.loads(capsys.readouterr().out) + assert code == cli.EXIT_PLUGIN + assert out["ok"] is False and out["data"] == {"error": "plugin_not_available"} + assert "not installed or not accessible" in out["errors"][0] + + +def test_broken_or_incompatible_plugin_is_not_available(): + def broken(): + raise ImportError("private package not accessible") + + class Old: + name, spi_version = "old", 0 + + plugins.register("broken", broken) + plugins.register("old", Old) + try: + listing = {p["name"]: p for p in plugins.list_plugins()} + assert listing["broken"]["available"] is False and "ImportError" in listing["broken"]["error"] + assert listing["old"]["available"] is False and "SPI version" in listing["old"]["error"] + with pytest.raises(plugins.PluginNotAvailable): + plugins.load("broken") + finally: + plugins.unregister("broken") + plugins.unregister("old") + + +def test_plugin_plan_preview_and_denied_apply(demo_plugin, fake_transport, capsys): + transport = fake_transport() + assert cli.main(["--json", "run", "demo"], transport=transport) == 0 + out = json.loads(capsys.readouterr().out) + assert out["mode"] == "preview" and out["data"]["actions"][0]["source"] == "demo" + assert out["data"]["actions"][0]["permitted"] is False # api.request not enabled by default + assert cli.main(["--json", "run", "demo", "--apply"], transport=transport) == 0 + assert transport.writes == [] + + +def test_plugin_plan_apply_when_profile_allows(demo_plugin, fake_transport, tmp_path, capsys): + profile = tmp_path / "profile.json" + profile.write_text(json.dumps({"permissions": {"enabled": ["api.request"], + "api_write_prefixes": ["user/starred/"]}}), encoding="utf-8") + transport = fake_transport() + assert cli.main(["--config", str(profile), "--json", "run", "demo", "--apply"], transport=transport) == 0 + assert transport.writes == [("PUT", "user/starred/o/r", None)] + + +def test_pr_policy_checks_come_from_plugins(demo_plugin, fake_transport): + transport = fake_transport( + gets={f"repos/{REPO}/pulls/7/files?per_page=100": [{"filename": "a.py"}]}, + texts={f"repos/{REPO}/pulls/7": "+ TODO later"}, + ) + reviewer = PRReviewer(default_config(), REPO, transport, policy_plugins=plugins.available_plugins()) + result = reviewer.review(PullRequest(7, "t", "", "someone", additions=5)) + assert result.policy_check_results == [("demo:no_todo", False, "diff contains TODO")] + assert result.escalate is True + + +def test_pr_review_without_plugins_is_size_only(): + reviewer = PRReviewer(default_config()) + assert reviewer.review(PullRequest(1, "small", "", "a", additions=10)).escalate is False + big = reviewer.review(PullRequest(2, "big", "", "a", additions=150, deletions=100, changed_files=3)) + assert big.escalate is True and "250 Zeilen" in big.escalate_reason + + +# ---------------------------------------------------------------- credentials + +def test_credentials_resolve_from_env_and_redact(): + token, provider = resolve_token([EnvTokenProvider(environ={"GITHUB_TOKEN": "tok-123"})]) + assert (token, provider) == ("tok-123", "env") + assert redact("Bearer tok-123 used", token) == "Bearer *** used" + with pytest.raises(CredentialUnavailable): + resolve_token([EnvTokenProvider(environ={})]) + + +# ---------------------------------------------------------------- offline CLI + scaffold + +def test_offline_classify_and_duplicates(tmp_path, capsys, fake_transport): + data = tmp_path / "issues.json" + data.write_text(json.dumps([ + {"number": 1, "title": "Crash on save in editor", "body": "", "labels": []}, + {"number": 2, "title": "Crash on save in editor", "body": "", "labels": []}, + ]), encoding="utf-8") + assert cli.main(["--json", "duplicates", "--input", str(data)], transport=fake_transport()) == 0 + assert json.loads(capsys.readouterr().out)["data"][0]["original"] == 1 + assert cli.main(["--json", "classify", "--input", str(data)], transport=fake_transport()) == 0 + assert "bug" in json.loads(capsys.readouterr().out)["data"][0]["labels_to_add"] + + +def test_gate_scaffold_preview_then_apply(tmp_path, capsys, fake_transport): + dest = tmp_path / "target" + assert cli.main(["--json", "gate-scaffold", "--dest", str(dest), "--namespace", "acme"], transport=fake_transport()) == 0 + assert not dest.exists() # preview wrote nothing + capsys.readouterr() + assert cli.main(["gate-scaffold", "--dest", str(dest), "--namespace", "acme", "--apply"], + transport=fake_transport()) == 0 + workflow = (dest / ".github/workflows/review-gate.yml").read_text(encoding="utf-8") + assert "types: [acme-review-gate]" in workflow and "__GATE_NAMESPACE__" not in workflow + assert (dest / ".github/review-gate/review_gate.py").is_file() + assert cli.main(["gate-scaffold", "--dest", str(dest), "--apply"], transport=fake_transport()) == cli.EXIT_USAGE + + +def test_review_gate_subcommand_passthrough(capsys): + with pytest.raises(SystemExit) as exc: + cli.main(["review-gate", "--help"]) + assert exc.value.code == 0 + assert "--namespace" in capsys.readouterr().out + + +def test_info_json_envelope(capsys, fake_transport): + assert cli.main(["--json", "info"], transport=fake_transport()) == 0 + out = json.loads(capsys.readouterr().out) + assert out["schema"] == cli.RESULT_SCHEMA and out["data"]["spi_version"] == plugins.SPI_VERSION + + +def test_package_data_is_shipped(): + package = Path(cli.__file__).parent + assert (package / "default_config.json").is_file() + assert (package / "templates" / "review-gate.yml").is_file() diff --git a/tests/test_duplicates.py b/tests/test_duplicates.py new file mode 100644 index 0000000..213742e --- /dev/null +++ b/tests/test_duplicates.py @@ -0,0 +1,132 @@ +"""Tests fuer DuplicateDetector.""" +import unittest + + +from githubbot_core.duplicates import ( + DuplicateDetector, + tokenize, jaccard_similarity, +) + + +class TestTokenize(unittest.TestCase): + """Testet die Tokenisierung.""" + + def test_basic_tokenize(self): + tokens = tokenize("Hello World Test") + self.assertIn("hello", tokens) + self.assertIn("world", tokens) + self.assertIn("test", tokens) + + def test_stopwords_removed(self): + tokens = tokenize("the quick fox is not a dog") + self.assertNotIn("the", tokens) + self.assertNotIn("is", tokens) + self.assertNotIn("not", tokens) + self.assertNotIn("a", tokens) + + def test_special_chars_removed(self): + tokens = tokenize("error! crash? bug#123") + self.assertIn("error", tokens) + self.assertIn("crash", tokens) + self.assertIn("bug", tokens) + self.assertIn("123", tokens) + + def test_empty_string(self): + tokens = tokenize("") + self.assertEqual(len(tokens), 0) + + +class TestJaccardSimilarity(unittest.TestCase): + """Testet Jaccard-Aehnlichkeit.""" + + def test_identical_sets(self): + self.assertAlmostEqual(jaccard_similarity({"a", "b"}, {"a", "b"}), 1.0) + + def test_disjoint_sets(self): + self.assertAlmostEqual(jaccard_similarity({"a", "b"}, {"c", "d"}), 0.0) + + def test_partial_overlap(self): + sim = jaccard_similarity({"a", "b", "c"}, {"b", "c", "d"}) + self.assertAlmostEqual(sim, 2/4) # 0.5 + + def test_empty_sets(self): + self.assertAlmostEqual(jaccard_similarity(set(), set()), 1.0) + + def test_one_empty(self): + self.assertAlmostEqual(jaccard_similarity({"a"}, set()), 0.0) + + +class TestDuplicateDetector(unittest.TestCase): + """Testet DuplicateDetector.""" + + def setUp(self): + self.detector = DuplicateDetector(threshold=0.80) + + def test_identical_issues(self): + """Identische Issues werden als Duplikat erkannt.""" + issues = [ + {"number": 1, "title": "Application crashes on startup", "body": "Error traceback shown"}, + {"number": 2, "title": "Application crashes on startup", "body": "Error traceback shown"}, + ] + candidates = self.detector.find_duplicates(issues) + self.assertEqual(len(candidates), 1) + self.assertGreaterEqual(candidates[0].similarity, 0.80) + + def test_different_issues(self): + """Verschiedene Issues werden nicht als Duplikat erkannt.""" + issues = [ + {"number": 1, "title": "Add dark mode", "body": "Please implement dark mode"}, + {"number": 2, "title": "Fix login error", "body": "Cannot login with email"}, + ] + candidates = self.detector.find_duplicates(issues) + self.assertEqual(len(candidates), 0) + + def test_single_issue(self): + """Ein einzelnes Issue ergibt keine Duplikate.""" + issues = [{"number": 1, "title": "Bug report", "body": "Something broken"}] + candidates = self.detector.find_duplicates(issues) + self.assertEqual(len(candidates), 0) + + def test_empty_list(self): + """Leere Liste ergibt keine Duplikate.""" + candidates = self.detector.find_duplicates([]) + self.assertEqual(len(candidates), 0) + + def test_sorted_by_similarity(self): + """Ergebnisse sind nach Aehnlichkeit sortiert.""" + issues = [ + {"number": 1, "title": "crash error bug", "body": "crash error bug details"}, + {"number": 2, "title": "crash error bug", "body": "crash error bug details"}, + {"number": 3, "title": "crash error bug report", "body": "crash error different"}, + ] + candidates = self.detector.find_duplicates(issues) + if len(candidates) >= 2: + self.assertGreaterEqual(candidates[0].similarity, candidates[1].similarity) + + def test_find_for_new_issue(self): + """find_for_new_issue findet Duplikate fuer ein neues Issue.""" + existing = [ + {"number": 1, "title": "Application crashes on startup", "body": "Error shown"}, + {"number": 2, "title": "Feature request dark mode", "body": "Add dark mode"}, + ] + candidates = self.detector.find_for_new_issue( + 3, "Application crashes on startup", "Error shown", existing + ) + self.assertGreater(len(candidates), 0) + self.assertEqual(candidates[0].original_number, 1) + + def test_threshold_configurable(self): + """Schwellwert ist konfigurierbar.""" + strict = DuplicateDetector(threshold=0.95) + loose = DuplicateDetector(threshold=0.30) + issues = [ + {"number": 1, "title": "crash error report", "body": "details here"}, + {"number": 2, "title": "crash error found", "body": "different details"}, + ] + strict_results = strict.find_duplicates(issues) + loose_results = loose.find_duplicates(issues) + self.assertLessEqual(len(strict_results), len(loose_results)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_metadata.py b/tests/test_metadata.py new file mode 100644 index 0000000..a1a319a --- /dev/null +++ b/tests/test_metadata.py @@ -0,0 +1,47 @@ +"""Repository metadata: required root documents, license and packaging rules.""" +from __future__ import annotations + +import sys +from pathlib import Path + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover + tomllib = None + +ROOT = Path(__file__).resolve().parents[1] +REQUIRED = ["README.md", "README_de.md", "LICENSE", ".gitignore", "CHANGELOG.md", "SECURITY.md", + "CONTRIBUTING.md", "CODE_OF_CONDUCT.md", "THIRD_PARTY_LICENSES.md", "pyproject.toml"] + + +def test_required_root_documents_exist(): + missing = [name for name in REQUIRED if not (ROOT / name).is_file()] + assert missing == [] + + +def test_license_is_mit_and_referenced(): + assert (ROOT / "LICENSE").read_text(encoding="utf-8").startswith("MIT License") + for readme in ("README.md", "README_de.md"): + assert "[MIT](LICENSE)" in (ROOT / readme).read_text(encoding="utf-8") + + +def test_pyproject_uses_pep639_without_license_classifier(): + text = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + assert 'license = "MIT"' in text + assert "License ::" not in text + if tomllib: + data = tomllib.loads(text) + assert data["project"]["dependencies"] == [] + assert data["project"]["scripts"]["githubbot-core"] == "githubbot_core.cli:main" + + +def test_bilingual_policy_documents(): + for name in ("SECURITY.md", "CONTRIBUTING.md", "CODE_OF_CONDUCT.md"): + text = (ROOT / name).read_text(encoding="utf-8") + assert "## Deutsch" in text and "## English" in text, name + + +def test_security_policy_uses_private_reporting_not_email(): + text = (ROOT / "SECURITY.md").read_text(encoding="utf-8") + assert "Private Vulnerability Reporting" in text + assert "@" not in text diff --git a/tests/test_repo_health.py b/tests/test_repo_health.py new file mode 100644 index 0000000..6f3c7c1 --- /dev/null +++ b/tests/test_repo_health.py @@ -0,0 +1,274 @@ +"""Tests fuer Repo-Health.""" + +import json +import subprocess +import unittest +from datetime import datetime, timezone +from unittest.mock import patch + + +from githubbot_core.health import ( + CodeScanningAlert, + DependabotAlert, + OpenIssue, + RepoHealthCollector, + RepoHealthSummary, + WorkflowFailure, + generate_health_report, +) + + +class TestRepoHealthCollector(unittest.TestCase): + """Prueft den Health-Collector.""" + + @patch("githubbot_core.transport.subprocess.run") + def test_fetch_collects_dependabot_and_recent_failures(self, mock_run): + alerts_payload = [ + { + "number": 17, + "html_url": "https://github.com/org/repo/security/dependabot/17", + "created_at": "2026-06-18T10:00:00Z", + "manifest_path": "requirements.txt", + "dependency": { + "package": { + "name": "requests", + "ecosystem": "pip", + } + }, + "security_vulnerability": {"severity": "high"}, + "security_advisory": {"summary": "Requests vulnerability"}, + } + ] + runs_payload = { + "workflow_runs": [ + { + "id": 101, + "name": "CI", + "display_title": "Fix tests", + "head_branch": "main", + "event": "push", + "status": "completed", + "conclusion": "failure", + "created_at": "2026-06-18T09:00:00Z", + "updated_at": "2026-06-18T09:05:00Z", + "html_url": "https://github.com/org/repo/actions/runs/101", + "actor": {"login": "octocat"}, + }, + { + "id": 99, + "name": "CI", + "display_title": "Old failure", + "head_branch": "main", + "event": "push", + "status": "completed", + "conclusion": "failure", + "created_at": "2026-05-01T09:00:00Z", + "updated_at": "2026-05-01T09:05:00Z", + "html_url": "https://github.com/org/repo/actions/runs/99", + "actor": {"login": "octocat"}, + }, + ] + } + + def fake_run(cmd, **_kwargs): + endpoint = cmd[-1] + if "dependabot/alerts" in endpoint: + return subprocess.CompletedProcess(cmd, 0, json.dumps(alerts_payload), "") + if "/issues" in endpoint: + return subprocess.CompletedProcess(cmd, 0, json.dumps([]), "") + if "code-scanning/alerts" in endpoint: + return subprocess.CompletedProcess(cmd, 0, json.dumps([]), "") + if "secret-scanning/alerts" in endpoint: + return subprocess.CompletedProcess(cmd, 0, json.dumps([]), "") + if "actions/runs" in endpoint: + return subprocess.CompletedProcess(cmd, 0, json.dumps(runs_payload), "") + if endpoint == "repos/org/repo": + return subprocess.CompletedProcess( + cmd, + 0, + json.dumps({"description": "Test repo", "topics": ["test"]}), + "", + ) + raise AssertionError(f"Unerwarteter Endpoint: {endpoint}") + + mock_run.side_effect = fake_run + + collector = RepoHealthCollector( + { + "failed_runs_window_days": 14, + "max_failed_runs_per_repo": 5, + "max_dependabot_alerts_per_repo": 10, + } + ) + + with patch.object( + collector, + "_utcnow", + return_value=datetime(2026, 6, 19, tzinfo=timezone.utc), + ): + summary = collector.fetch("org/repo") + + self.assertEqual(summary.dependabot_count, 1) + self.assertEqual(summary.failed_runs_count, 1) + self.assertEqual(summary.dependabot_alerts[0].package_name, "requests") + self.assertEqual(summary.failed_runs[0].run_id, 101) + self.assertEqual(summary.failed_runs[0].actor, "octocat") + self.assertEqual(summary.errors, []) + + @patch("githubbot_core.transport.subprocess.run") + def test_fetch_records_endpoint_errors(self, mock_run): + def fake_run(cmd, **_kwargs): + endpoint = cmd[-1] + if "dependabot/alerts" in endpoint: + return subprocess.CompletedProcess(cmd, 1, "", "HTTP 403") + if "/issues" in endpoint: + return subprocess.CompletedProcess(cmd, 0, json.dumps([]), "") + if "code-scanning/alerts" in endpoint: + return subprocess.CompletedProcess(cmd, 0, json.dumps([]), "") + if "secret-scanning/alerts" in endpoint: + return subprocess.CompletedProcess(cmd, 0, json.dumps([]), "") + if "actions/runs" in endpoint: + return subprocess.CompletedProcess(cmd, 0, json.dumps({"workflow_runs": []}), "") + raise AssertionError(f"Unerwarteter Endpoint: {endpoint}") + + mock_run.side_effect = fake_run + + collector = RepoHealthCollector() + summary = collector.fetch("org/repo") + + self.assertEqual(summary.dependabot_count, 0) + self.assertEqual(summary.failed_runs_count, 0) + self.assertTrue(summary.errors) + self.assertIn("Dependabot-Warnungen", summary.errors[0]) + + +class TestGenerateHealthReport(unittest.TestCase): + """Prueft den Markdown-Report.""" + + def test_report_contains_findings_and_summary(self): + summary = RepoHealthSummary( + repo="org/repo", + dependabot_alerts=[ + DependabotAlert( + number=1, + package_name="requests", + ecosystem="pip", + severity="high", + summary="Requests vulnerability", + manifest_path="requirements.txt", + html_url="https://github.com/org/repo/security/dependabot/1", + ) + ], + failed_runs=[ + WorkflowFailure( + run_id=101, + workflow_name="CI", + display_title="Fix tests", + branch="main", + event="push", + actor="octocat", + conclusion="failure", + created_at="2026-06-18T09:00:00Z", + html_url="https://github.com/org/repo/actions/runs/101", + ) + ], + open_issues=[ + OpenIssue( + number=7, + title="Rename provider-specific API", + author="octocat", + labels=["documentation"], + html_url="https://github.com/org/repo/issues/7", + ) + ], + open_issues_total=1, + code_scanning_alerts=[ + CodeScanningAlert( + number=3, + rule_id="py/path-injection", + severity="high", + tool_name="CodeQL", + path="server.py", + start_line=42, + html_url="https://github.com/org/repo/security/code-scanning/3", + ) + ], + code_scanning_total=1, + ) + + report = generate_health_report( + [summary], + window_days=14, + max_dependabot_alerts_per_repo=10, + max_failed_runs_per_repo=5, + ) + + self.assertIn("Dependabot-Warnungen", report) + self.assertIn("fehlgeschlagenen Actions-Läufen", report) + self.assertIn("org/repo", report) + self.assertIn("requests", report) + self.assertIn("Fix tests", report) + self.assertIn("Rename provider-specific API", report) + self.assertIn("py/path-injection", report) + + def test_report_contains_missing_metadata_section(self): + summary_unlocked = RepoHealthSummary( + repo="org/unlocked-repo", + missing_description=True, + missing_topics=True, + is_locked=False, + ) + summary_locked = RepoHealthSummary( + repo="org/locked-repo", + missing_description=True, + missing_topics=True, + is_locked=True, + lock_reason="exclude_writes", + ) + report = generate_health_report( + [summary_unlocked, summary_locked], + window_days=14, + max_dependabot_alerts_per_repo=10, + max_failed_runs_per_repo=5, + ) + self.assertIn("Repos mit fehlenden Metadaten", report) + self.assertIn("- **org/unlocked-repo**", report) + self.assertNotIn("- **org/locked-repo**", report) + + def test_has_findings_ignores_locked_metadata(self): + summary_locked = RepoHealthSummary( + repo="org/locked-repo", + missing_description=True, + missing_topics=True, + is_locked=True, + ) + self.assertFalse(summary_locked.has_findings()) + + summary_unlocked = RepoHealthSummary( + repo="org/unlocked-repo", + missing_description=True, + is_locked=False, + ) + self.assertTrue(summary_unlocked.has_findings()) + + @patch("githubbot_core.transport.subprocess.run") + def test_metadata_api_error_is_unknown_not_missing(self, mock_run): + def fake_run(cmd, **_kwargs): + endpoint = cmd[-1] + if endpoint == "repos/org/repo": + return subprocess.CompletedProcess(cmd, 1, "", "API unavailable") + if "actions/runs" in endpoint: + return subprocess.CompletedProcess(cmd, 0, '{"workflow_runs": []}', "") + return subprocess.CompletedProcess(cmd, 0, "[]", "") + + mock_run.side_effect = fake_run + + summary = RepoHealthCollector().fetch("org/repo") + + self.assertFalse(summary.missing_description) + self.assertFalse(summary.missing_topics) + self.assertTrue(any("API unavailable" in error for error in summary.errors)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_report_sections.py b/tests/test_report_sections.py new file mode 100644 index 0000000..146e46b --- /dev/null +++ b/tests/test_report_sections.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +""" +Tests fuer core/report_sections.py -- die Bausteine, die Traffic- und +Sterne-Bericht gemeinsam nutzen. + +Die Helfer lagen bis 2026-08-02 doppelt in core/traffic.py und core/stars.py. +Sie liegen jetzt einmal hier, damit die Formulierungen der Privat-Sektion in +beiden Berichten nicht auseinanderdriften -- ein Textunterschied liest sich +sonst wie ein inhaltlicher. +""" + + +import pytest + + +from githubbot_core.report_sections import ( # noqa: E402 + ARCHIVED_HEADING_MD, + PRIVATE_HEADING_MD, + REASON_STARS, + REASON_TRAFFIC, + ascii_group_block, + group_repos_by_org, + markdown_archived_section, + markdown_private_section, + wrap_repo_names, +) + + +class TestGruppierung: + def test_sortiert_orgs_und_namen(self): + result = group_repos_by_org([ + "zeta/b", "alpha/z", "alpha/a", "zeta/a", + ]) + assert list(result) == ["alpha", "zeta"] + assert result["alpha"] == ["a", "z"] + assert result["zeta"] == ["a", "b"] + + def test_name_ohne_org_landet_unter_leerem_schluessel(self): + assert group_repos_by_org(["ohne-org"]) == {"": ["ohne-org"]} + + def test_leere_liste(self): + assert group_repos_by_org([]) == {} + + +class TestZeilenumbruch: + def test_kurze_liste_bleibt_einzeilig(self): + lines = wrap_repo_names(["a", "b"], " org (2): ", width=80) + assert lines == [" org (2): a, b"] + + def test_umgebrochene_zeile_endet_auf_komma(self): + """ + Ohne Komma am Zeilenende liest sich die Fortsetzung wie eine neue + Aufzaehlung -- genau das war im ersten Bericht vom 2026-08-02 zu sehen. + """ + names = [f"repo-nummer-{i:02d}" for i in range(10)] + lines = wrap_repo_names(names, " org (10): ", width=60) + assert len(lines) > 1, "Testdaten muessen einen Umbruch erzwingen" + for line in lines[:-1]: + assert line.endswith(","), f"Zeile ohne Trennkomma: {line!r}" + assert not lines[-1].endswith(","), "letzte Zeile braucht kein Komma" + + def test_haelt_die_breite_ein_inklusive_komma(self): + """Bei realistischen Breiten passt jede Zeile samt Trennkomma.""" + names = [f"ein-ziemlich-langer-repo-name-{i:02d}" for i in range(12)] + for width in (60, 80, 100): + for line in wrap_repo_names(names, " org (12): ", width=width): + assert len(line) <= width, f"{len(line)} > {width}: {line!r}" + + def test_breite_wird_nur_von_einzelelementen_gesprengt(self): + """ + Die eigentliche Invariante: eine zu breite Zeile darf hoechstens EIN + Element tragen. Passt schon `prefix` + ein Name nicht in die Breite, + ist die Ueberschreitung unvermeidbar -- aber sie darf sich nie + dadurch ergeben, dass ein zweites Element zu viel angehaengt wurde. + """ + names = [f"ein-ziemlich-langer-repo-name-{i:02d}" for i in range(12)] + for width in (30, 40, 50, 60, 80): + for line in wrap_repo_names(names, " org (12): ", width=width): + if len(line) > width: + assert line.rstrip(",").count(", ") == 0, ( + f"Zeile ueberschreitet {width} mit mehreren Elementen: {line!r}" + ) + + def test_alle_namen_bleiben_erhalten(self): + names = [f"repo{i}" for i in range(25)] + text = " ".join(wrap_repo_names(names, " org (25): ", width=50)) + for name in names: + assert name in text + + def test_ueberlanger_einzelname_steht_allein(self): + lang = "x" * 200 + lines = wrap_repo_names(["kurz", lang], " org (2): ", width=40) + assert any(lang in line for line in lines) + + def test_folgezeilen_sind_eingerueckt(self): + names = [f"repo-nummer-{i:02d}" for i in range(10)] + lines = wrap_repo_names(names, " org (10): ", width=50, indent=" ") + assert all(line.startswith(" ") for line in lines[1:]) + + +class TestMarkdownSektionen: + def test_leere_liste_erzeugt_keine_sektion(self): + assert markdown_private_section([], REASON_TRAFFIC) == [] + assert markdown_private_section(None, REASON_TRAFFIC) == [] + assert markdown_archived_section([]) == [] + assert markdown_archived_section(None) == [] + + def test_ueberschrift_ist_der_wortlaut_den_der_nutzer_sucht(self): + lines = markdown_private_section(["o/r"], REASON_TRAFFIC) + assert lines[0] == "### Diese Module sind privat:" + assert PRIVATE_HEADING_MD == lines[0] + + def test_gruppiert_und_zaehlt_je_org(self): + lines = markdown_private_section(["a/z", "a/y", "b/x"], REASON_TRAFFIC) + assert "- **a** (2): y, z" in lines + assert "- **b** (1): x" in lines + + def test_begruendung_unterscheidet_sich_je_bericht(self): + traffic = "\n".join(markdown_private_section(["o/r"], REASON_TRAFFIC)) + stars = "\n".join(markdown_private_section(["o/r"], REASON_STARS)) + assert "0 Views/0 Clones" in traffic + assert "Sternenzahl" in stars + # Der Audit-Zweck steht in beiden -- das ist die gemeinsame Aussage. + assert "Sichtbarkeits-Audit" in traffic and "Sichtbarkeits-Audit" in stars + + def test_archiviert_hat_eigene_ueberschrift(self): + assert markdown_archived_section(["o/alt"])[0] == ARCHIVED_HEADING_MD + + def test_umlaute_bleiben_echt(self): + text = "\n".join(markdown_private_section(["o/r"], REASON_TRAFFIC)) + assert "veröffentlicht" in text + assert "ue" not in "prüfen" + + +class TestAsciiBlock: + def test_leere_liste_erzeugt_keinen_block(self): + assert ascii_group_block("EGAL", []) == [] + assert ascii_group_block("EGAL", None) == [] + + def test_haelt_die_breite_ein(self): + repos = [f"games-org/ein-langer-spielname-{i:02d}" for i in range(15)] + for line in ascii_group_block("PRIVATE MODULE", repos, width=80): + assert len(line) <= 80, f"{len(line)}: {line!r}" + + def test_beginnt_mit_leerzeile_und_ueberschrift(self): + block = ascii_group_block("PRIVATE MODULE", ["o/r"]) + assert block[0] == "" + assert block[1] == " PRIVATE MODULE" + + @pytest.mark.parametrize("width", [40, 80, 120]) + def test_trennlinie_folgt_der_breite(self, width): + block = ascii_group_block("X", ["o/r"], width=width) + assert len(block[2]) == width diff --git a/tests/test_review_gate.py b/tests/test_review_gate.py new file mode 100644 index 0000000..4da1cd1 --- /dev/null +++ b/tests/test_review_gate.py @@ -0,0 +1,418 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import re +import shutil +import subprocess +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +from githubbot_core.review_gate import ( + ACTIONS_APP_ID, + RECEIPT_SCHEMA, + ReceiptValidationError, + build_receipt, + build_protection_payloads, + canonical_receipt_bytes, + create_receipt, + decode_dispatch_payload, + encode_dispatch_payload, + validate_receipt_bytes, + verify_ssh_signature, +) +from githubbot_core import review_gate + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _now() -> datetime: + return datetime(2026, 8, 31, 10, 0, tzinfo=timezone.utc) + + +def _receipt(**overrides: object) -> dict[str, object]: + values: dict[str, object] = { + "kind": "model_review", + "repository": "example-org/example-repo", + "pr_number": 6, + "head_sha": "2f9e8f6fe280e2449b2d6081ef55a5cfdee431ea", + "verdict": "approve", + "reviewer": "model-reviewer", + "artifact_sha256": "a" * 64, + "findings": {"critical": 0, "high": 0, "medium": 1, "low": 2}, + "checks": ["pytest:383", "ruff:clean", "codeql:green"], + "issued_at": "2026-08-31T09:55:00Z", + "valid_until": "2026-08-31T10:55:00Z", + "nonce": "review-nonce-0000000001", + } + values.update(overrides) + return create_receipt(**values) + + +def test_model_review_receipt_is_canonical_and_valid() -> None: + receipt = _receipt() + encoded = canonical_receipt_bytes(receipt) + + assert encoded.endswith(b"\n") + assert json.loads(encoded) == receipt + assert receipt["schema"] == RECEIPT_SCHEMA + assert validate_receipt_bytes( + encoded, + expected_repository="example-org/example-repo", + expected_pr_number=6, + expected_head_sha="2f9e8f6fe280e2449b2d6081ef55a5cfdee431ea", + now=_now(), + ) == receipt + + +def test_receipt_rejects_noncanonical_bytes_and_wrong_head() -> None: + receipt = _receipt() + pretty = json.dumps(receipt, indent=2).encode("utf-8") + + with pytest.raises(ReceiptValidationError, match="canonical"): + validate_receipt_bytes(pretty, now=_now()) + + with pytest.raises(ReceiptValidationError, match="head SHA"): + validate_receipt_bytes( + canonical_receipt_bytes(receipt), + expected_head_sha="b" * 40, + now=_now(), + ) + + +def test_approval_rejects_high_findings_and_expired_receipt() -> None: + receipt = _receipt(findings={"critical": 0, "high": 1, "medium": 0, "low": 0}) + with pytest.raises(ReceiptValidationError, match="high findings"): + validate_receipt_bytes(canonical_receipt_bytes(receipt), now=_now()) + + expired = _receipt(valid_until="2026-08-31T09:59:59Z") + with pytest.raises(ReceiptValidationError, match="expired"): + validate_receipt_bytes(canonical_receipt_bytes(expired), now=_now()) + + +def test_owner_override_requires_explicit_decision_and_reason() -> None: + with pytest.raises(ReceiptValidationError, match="decision_id"): + _receipt(kind="owner_override", reviewer="octocat", findings={"critical": 1, "high": 1, "medium": 0, "low": 0}) + + receipt = _receipt( + kind="owner_override", + reviewer="octocat", + findings={"critical": 1, "high": 1, "medium": 0, "low": 0}, + decision_id="DECISION-0001", + reason="Owner accepts the exact-SHA bootstrap risk for the pilot repository.", + ) + assert validate_receipt_bytes(canonical_receipt_bytes(receipt), now=_now()) == receipt + + +def test_dispatch_payload_round_trip() -> None: + receipt_bytes = canonical_receipt_bytes(_receipt()) + signature = b"-----BEGIN SSH SIGNATURE-----\nexample\n-----END SSH SIGNATURE-----\n" + + payload = encode_dispatch_payload(receipt_bytes, signature) + decoded_receipt, decoded_signature = decode_dispatch_payload(payload) + + assert base64.b64decode(payload["receipt_b64"]) == receipt_bytes + assert decoded_receipt == receipt_bytes + assert decoded_signature == signature + + +@pytest.mark.skipif(shutil.which("ssh-keygen") is None, reason="OpenSSH ssh-keygen is unavailable") +def test_real_ssh_signature_is_bound_to_principal_and_receipt(tmp_path: Path) -> None: + key = tmp_path / "reviewer" + subprocess.run( + ["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)], + check=True, + ) + receipt_path = tmp_path / "receipt.json" + receipt_path.write_bytes(canonical_receipt_bytes(_receipt())) + subprocess.run( + ["ssh-keygen", "-Y", "sign", "-f", str(key), "-n", "githubbot-review-gate", str(receipt_path)], + check=True, + ) + allowed = tmp_path / "allowed_signers" + public_key = key.with_suffix(".pub").read_text(encoding="utf-8").strip() + allowed.write_text(f"githubbot-reviewer {public_key}\n", encoding="utf-8") + + verify_ssh_signature( + receipt_path.read_bytes(), + Path(f"{receipt_path}.sig").read_bytes(), + allowed.read_bytes(), + principal="githubbot-reviewer", + ) + + with pytest.raises(ReceiptValidationError, match="signature"): + verify_ssh_signature( + receipt_path.read_bytes(), + Path(f"{receipt_path}.sig").read_bytes(), + allowed.read_bytes(), + principal="githubbot-owner", + ) + + +def test_protection_plan_replaces_self_review_with_actions_pinned_gate() -> None: + current = { + "required_status_checks": None, + "required_pull_request_reviews": { + "url": "https://api.github.test/reviews", + "dismiss_stale_reviews": True, + "require_code_owner_reviews": False, + "required_approving_review_count": 1, + "require_last_push_approval": False, + }, + "enforce_admins": {"enabled": False}, + "restrictions": None, + "required_conversation_resolution": {"enabled": True}, + "required_linear_history": {"enabled": False}, + "allow_force_pushes": {"enabled": False}, + "allow_deletions": {"enabled": False}, + "block_creations": {"enabled": False}, + "lock_branch": {"enabled": False}, + "allow_fork_syncing": {"enabled": False}, + } + + rollback, desired = build_protection_payloads(current) + + assert rollback["required_pull_request_reviews"]["required_approving_review_count"] == 1 + assert rollback["required_status_checks"] is None + assert desired["required_pull_request_reviews"] == { + "dismiss_stale_reviews": False, + "require_code_owner_reviews": False, + "required_approving_review_count": 0, + "require_last_push_approval": False, + } + assert desired["required_status_checks"] == { + "strict": True, + "checks": [{"context": "githubbot/review-gate", "app_id": ACTIONS_APP_ID}], + } + assert desired["enforce_admins"] is True + assert desired["allow_force_pushes"] is False + assert desired["allow_deletions"] is False + assert desired["required_conversation_resolution"] is True + + +def test_protection_rollback_accepts_an_unpinned_existing_status() -> None: + current = { + "required_status_checks": { + "strict": False, + "checks": [{"context": "ci/test", "app_id": None}], + }, + "required_pull_request_reviews": None, + "enforce_admins": {"enabled": False}, + "restrictions": None, + } + + rollback, _ = build_protection_payloads(current) + + assert rollback["required_status_checks"] == { + "strict": False, + "checks": [{"context": "ci/test", "app_id": -1}], + } + + +def test_workflow_never_executes_pull_request_code_with_privileged_token() -> None: + workflow = (REPO_ROOT / "src" / "githubbot_core" / "templates" / "review-gate.yml").read_text(encoding="utf-8") + + assert "pull_request_target:" in workflow + assert "repository_dispatch:" in workflow + assert "statuses: write" in workflow + assert "pull-requests: read" in workflow + assert "github.event.repository.default_branch" in workflow + assert "ref: ${{ github.event.pull_request.head.sha }}" not in workflow + assert "repository: ${{ github.event.pull_request.head.repo.full_name }}" not in workflow + assert "allow-unsafe-pr-checkout" not in workflow + assert "pip install" not in workflow + assert "npm " not in workflow + assert ".github/review-gate/review_gate.py workflow-pending" in workflow + assert ".github/review-gate/review_gate.py workflow-dispatch" in workflow + # B1 (Review 2026-09-03): both privileged jobs must hand the Actions token to the + # verifier, otherwise _resolve_token() fails before any status is posted. + assert workflow.count("GH_TOKEN: ${{ github.token }}") == 2 + # S4: the checkout action is pinned to a commit SHA, not a movable tag. + assert "actions/checkout@v4" not in workflow + assert re.search(r"uses: actions/checkout@[0-9a-f]{40}", workflow) + + +def test_build_receipt_hashes_the_review_artifact_and_cli_create_is_reproducible( + tmp_path: Path, +) -> None: + artifact = tmp_path / "review.md" + artifact.write_bytes(b"# Review\n\nNo blocking findings.\n") + receipt = build_receipt( + kind="model_review", + repository="example-org/example-repo", + pr_number=6, + head_sha="2f9e8f6fe280e2449b2d6081ef55a5cfdee431ea", + verdict="approve", + reviewer="model-reviewer", + artifact_path=artifact, + findings={"critical": 0, "high": 0, "medium": 0, "low": 0}, + checks=["pytest:green"], + now=_now(), + ) + + assert receipt["artifact_sha256"] == "8beae8e173a6f781110d12b7c2a5d4cc45932e6b7a548d06d2742cb3bc739744" + assert receipt["issued_at"] == "2026-08-31T10:00:00Z" + + +def test_reject_dispatch_posts_one_failure_status(monkeypatch: pytest.MonkeyPatch) -> None: + current = datetime.now(timezone.utc).replace(microsecond=0) + receipt = _receipt( + verdict="reject", + reason="A blocking governance issue remains.", + issued_at=(current - timedelta(minutes=1)).isoformat().replace("+00:00", "Z"), + valid_until=(current + timedelta(minutes=30)).isoformat().replace("+00:00", "Z"), + ) + payload = encode_dispatch_payload(canonical_receipt_bytes(receipt), b"signature") + statuses: list[tuple[str, str]] = [] + + monkeypatch.setattr(review_gate, "_resolve_token", lambda: "test-token") + monkeypatch.setattr( + review_gate, + "_live_pr", + lambda repository, pr_number, token: { + "state": "open", + "head": {"sha": receipt["head_sha"]}, + }, + ) + monkeypatch.setattr(review_gate, "verify_ssh_signature", lambda *args, **kwargs: None) + monkeypatch.setattr( + review_gate, + "_post_status", + lambda repository, sha, state, description, **kwargs: statuses.append((state, description)), + ) + + with pytest.raises(ReceiptValidationError, match="verdict is reject"): + review_gate.workflow_dispatch("example-org/example-repo", payload, b"allowed signer") + + assert len(statuses) == 1 + assert statuses[0][0] == "failure" + + +def test_cli_create_writes_a_canonical_receipt(tmp_path: Path) -> None: + artifact = tmp_path / "review.md" + artifact.write_text("review evidence\n", encoding="utf-8") + output = tmp_path / "receipt.json" + + result = review_gate.main( + [ + "create", + "--kind", + "model_review", + "--repo", + "example-org/example-repo", + "--pr", + "6", + "--head-sha", + "2f9e8f6fe280e2449b2d6081ef55a5cfdee431ea", + "--verdict", + "approve", + "--reviewer", + "model-reviewer", + "--artifact", + str(artifact), + "--check", + "pytest:green", + "--check", + "ruff:clean", + "--output", + str(output), + ] + ) + + assert result == 0 + receipt = validate_receipt_bytes(output.read_bytes(), now=datetime.now(timezone.utc)) + assert receipt["checks"] == ["pytest:green", "ruff:clean"] + assert receipt["artifact_sha256"] == hashlib.sha256(artifact.read_bytes()).hexdigest() + + +@pytest.mark.skipif(shutil.which("ssh-keygen") is None, reason="OpenSSH ssh-keygen is unavailable") +def test_cli_sign_writes_only_the_requested_signature(tmp_path: Path) -> None: + key = tmp_path / "reviewer" + subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)], check=True) + receipt = tmp_path / "receipt.json" + current = datetime.now(timezone.utc).replace(microsecond=0) + current_receipt = _receipt( + issued_at=(current - timedelta(minutes=1)).isoformat().replace("+00:00", "Z"), + valid_until=(current + timedelta(minutes=30)).isoformat().replace("+00:00", "Z"), + ) + receipt.write_bytes(canonical_receipt_bytes(current_receipt)) + signature = tmp_path / "signed" / "receipt.sig" + + assert review_gate.main( + ["sign", "--receipt", str(receipt), "--key", str(key), "--output", str(signature)] + ) == 0 + assert signature.is_file() + assert not Path(f"{receipt}.sig").exists() + + +def test_local_dispatch_dry_run_verifies_live_head_and_apply_posts_once( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + current = datetime.now(timezone.utc).replace(microsecond=0) + receipt_value = _receipt( + issued_at=(current - timedelta(minutes=1)).isoformat().replace("+00:00", "Z"), + valid_until=(current + timedelta(minutes=30)).isoformat().replace("+00:00", "Z"), + ) + receipt = tmp_path / "receipt.json" + receipt.write_bytes(canonical_receipt_bytes(receipt_value)) + signature = tmp_path / "receipt.sig" + signature.write_bytes(b"signature") + allowed = tmp_path / "allowed_signers" + allowed.write_bytes(b"allowed signer") + posts: list[tuple[str, str, object]] = [] + + monkeypatch.setattr(review_gate, "verify_ssh_signature", lambda *args, **kwargs: None) + monkeypatch.setattr(review_gate, "_resolve_token", lambda: "test-token") + monkeypatch.setattr( + review_gate, + "_live_pr", + lambda repository, pr_number, token: { + "state": "open", + "head": {"sha": receipt_value["head_sha"]}, + }, + ) + monkeypatch.setattr( + review_gate, + "_github_api", + lambda method, path, token, payload=None: posts.append((method, path, payload)), + ) + + summary = review_gate.dispatch_receipt(receipt, signature, allowed, apply=False) + assert summary["applied"] is False + assert posts == [] + + summary = review_gate.dispatch_receipt(receipt, signature, allowed, apply=True) + assert summary["applied"] is True + assert len(posts) == 1 + assert posts[0][0:2] == ("POST", "repos/example-org/example-repo/dispatches") + assert posts[0][2]["event_type"] == "githubbot-review-gate" + + +def test_protection_plan_refuses_to_overwrite_a_rollback(tmp_path: Path) -> None: + current = tmp_path / "current.json" + current.write_text( + json.dumps( + { + "required_status_checks": None, + "required_pull_request_reviews": None, + "enforce_admins": {"enabled": False}, + "restrictions": None, + } + ), + encoding="utf-8", + ) + output = tmp_path / "plan" + output.mkdir() + rollback = output / "rollback.json" + rollback.write_text("do not overwrite\n", encoding="utf-8") + + assert review_gate.main( + ["protection-plan", "--current", str(current), "--output-dir", str(output)] + ) == 2 + assert rollback.read_text(encoding="utf-8") == "do not overwrite\n" + assert not (output / "desired.json").exists() diff --git a/tests/test_stars.py b/tests/test_stars.py new file mode 100644 index 0000000..5dd43a3 --- /dev/null +++ b/tests/test_stars.py @@ -0,0 +1,301 @@ +"""Tests fuer die Sterne-Rangliste (core/stars.py).""" + +import json +import subprocess +import unittest +from unittest.mock import patch + + +from githubbot_core.stars import ( + RepoStars, + StarsFetcher, + generate_ascii_ranking, + generate_stars_report, +) +from githubbot_core.report_sections import group_repos_by_org, wrap_repo_names + + +class TestStarsFetcher(unittest.TestCase): + """Prueft den Stars-Fetcher.""" + + @patch("githubbot_core.transport.subprocess.run") + def test_fetch_reads_stargazers_count(self, mock_run): + mock_run.return_value = subprocess.CompletedProcess( + [], 0, json.dumps({"stargazers_count": 42}), "" + ) + + fetcher = StarsFetcher() + result = fetcher.fetch("org/repo") + + self.assertEqual(result.repo, "org/repo") + self.assertEqual(result.stars, 42) + self.assertIsNone(result.error) + + @patch("githubbot_core.transport.subprocess.run") + def test_fetch_records_error_on_api_failure(self, mock_run): + mock_run.return_value = subprocess.CompletedProcess([], 1, "", "HTTP 404") + + fetcher = StarsFetcher() + result = fetcher.fetch("org/missing-repo") + + self.assertEqual(result.stars, 0) + self.assertIsNotNone(result.error) + self.assertIn("HTTP 404", result.error) + + @patch("githubbot_core.transport.subprocess.run") + def test_fetch_records_json_error_detail(self, mock_run): + mock_run.return_value = subprocess.CompletedProcess([], 0, "not-json", "") + + result = StarsFetcher().fetch("org/broken-json") + + self.assertIsNotNone(result.error) + self.assertIn("JSON error", result.error) + + @patch("githubbot_core.transport.subprocess.run") + def test_fetch_all_preserves_order(self, mock_run): + payloads = { + "repos/org/a": {"stargazers_count": 5}, + "repos/org/b": {"stargazers_count": 10}, + } + + def fake_run(cmd, **_kwargs): + endpoint = cmd[-1] + return subprocess.CompletedProcess(cmd, 0, json.dumps(payloads[endpoint]), "") + + mock_run.side_effect = fake_run + + fetcher = StarsFetcher() + results = fetcher.fetch_all(["org/a", "org/b"]) + + self.assertEqual([r.repo for r in results], ["org/a", "org/b"]) + self.assertEqual([r.stars for r in results], [5, 10]) + + +class TestGenerateAsciiRanking(unittest.TestCase): + """Prueft die ASCII-Rangliste.""" + + def test_ranking_sorted_descending_by_stars(self): + summaries = [ + RepoStars(repo="org/low", stars=3), + RepoStars(repo="org/high", stars=99), + RepoStars(repo="org/mid", stars=20), + ] + + ranking = generate_ascii_ranking(summaries) + + pos_high = ranking.index("org/high") + pos_mid = ranking.index("org/mid") + pos_low = ranking.index("org/low") + self.assertLess(pos_high, pos_mid) + self.assertLess(pos_mid, pos_low) + self.assertIn("3 Repos", ranking) + self.assertIn("122 Stars", ranking) + + def test_ranking_lists_errors_separately(self): + summaries = [ + RepoStars(repo="org/ok", stars=1), + RepoStars(repo="org/broken", error="Kein Zugriff auf Repo-API"), + ] + + ranking = generate_ascii_ranking(summaries) + + self.assertIn("kein Zugriff", ranking) + self.assertIn("org/broken", ranking) + # Fehlerhafte Repos zaehlen nicht in die Rangliste (nur 1 gueltiges Repo). + self.assertIn("1 Repos", ranking) + + +class TestGenerateStarsReport(unittest.TestCase): + """Prueft den Markdown-Report.""" + + def test_report_contains_table_and_totals(self): + summaries = [ + RepoStars(repo="org/high", stars=99), + RepoStars(repo="org/low", stars=3), + ] + + report = generate_stars_report(summaries) + + self.assertIn("Sterne-Rangliste", report) + self.assertIn("org/high", report) + self.assertIn("org/low", report) + self.assertIn("102 Stars gesamt", report) + + def test_report_lists_errors(self): + summaries = [RepoStars(repo="org/broken", error="HTTP 404")] + + report = generate_stars_report(summaries) + + self.assertIn("Fehler (kein Zugriff)", report) + self.assertIn("org/broken", report) + self.assertIn("HTTP 404", report) + + +class TestBackwardCompatibilityWithPrivateArchived(unittest.TestCase): + """ + Ohne die neuen Parameter (private_repos/archived_repos) bleibt die Ausgabe + unveraendert -- Pflicht laut Auftrag (bestehende Ein-Argument-Aufrufe + duerfen sich nicht aendern). + """ + + def test_ascii_ranking_unchanged_without_new_params(self): + summaries = [RepoStars(repo="org/a", stars=5), RepoStars(repo="org/b", stars=1)] + + self.assertEqual( + generate_ascii_ranking(summaries), generate_ascii_ranking(summaries, None, None) + ) + out = generate_ascii_ranking(summaries) + self.assertNotIn("PRIVATE MODULE", out) + self.assertNotIn("ARCHIVIERT", out) + self.assertNotIn("Privat:", out) + + def test_stars_report_unchanged_without_new_params(self): + summaries = [RepoStars(repo="org/a", stars=5), RepoStars(repo="org/b", stars=1)] + + self.assertEqual( + generate_stars_report(summaries), generate_stars_report(summaries, None, None) + ) + out = generate_stars_report(summaries) + self.assertIn("**2 Repos** | **6 Stars gesamt**", out) + self.assertNotIn("Diese Module sind privat", out) + self.assertNotIn("Archiviert", out) + + def test_empty_lists_behave_like_none(self): + summaries = [RepoStars(repo="org/a", stars=5)] + + self.assertEqual( + generate_ascii_ranking(summaries, [], []), generate_ascii_ranking(summaries, None, None) + ) + self.assertEqual( + generate_stars_report(summaries, [], []), generate_stars_report(summaries, None, None) + ) + + +class TestPrivateReposExcludedFromStarsTotals(unittest.TestCase): + """Private Repos duerfen keine Summe/Rangliste beeinflussen.""" + + def test_ascii_totals_unaffected_by_private_repos(self): + summaries = [RepoStars(repo="org/a", stars=5), RepoStars(repo="org/b", stars=1)] + + without_private = generate_ascii_ranking(summaries) + with_private = generate_ascii_ranking( + summaries, + private_repos=["games-org/AlphaGame", "games-org/BetaGame"], + ) + + self.assertIn("GESAMT | 2 Repos | 6 Stars", without_private) + self.assertIn("GESAMT | 2 Repos | 6 Stars | Privat: 2 (siehe oben)", with_private) + + def test_markdown_header_reports_public_and_private_counts(self): + summaries = [RepoStars(repo="org/a", stars=5), RepoStars(repo="org/b", stars=1)] + + report = generate_stars_report(summaries, private_repos=["games-org/AlphaGame"]) + + self.assertIn( + "**2 öffentliche Repos** in den Zahlen | **6 Stars gesamt** | **1 privat** (siehe unten)", + report, + ) + + def test_private_repo_not_in_ranking_table(self): + summaries = [RepoStars(repo="org/a", stars=5)] + private = ["games-org/GhostRepo"] + + ascii_out = generate_ascii_ranking(summaries, private_repos=private) + table_lines = ascii_out.split("\n") + ranking_start = next(i for i, l in enumerate(table_lines) if l.strip().startswith("Rang")) + ranking_end = next(i for i, l in enumerate(table_lines) if "PRIVATE MODULE" in l) + self.assertFalse(any("GhostRepo" in l for l in table_lines[ranking_start:ranking_end])) + self.assertIn("GhostRepo", ascii_out) # aber im Privat-Block weiter unten + + md = generate_stars_report(summaries, private_repos=private) + table_row_lines = [l for l in md.split("\n") if l.startswith("| ")] + self.assertFalse(any("GhostRepo" in l for l in table_row_lines)) + self.assertIn("GhostRepo", md) + + +class TestStarsGroupingAndSorting(unittest.TestCase): + def test_group_repos_by_org_sorts_orgs_and_names(self): + repos = ["zeta-org/zebra", "zeta-org/apple", "alpha-org/banana", "alpha-org/apple"] + + groups = group_repos_by_org(repos) + + self.assertEqual(list(groups.keys()), ["alpha-org", "zeta-org"]) + self.assertEqual(groups["alpha-org"], ["apple", "banana"]) + self.assertEqual(groups["zeta-org"], ["apple", "zebra"]) + + def test_markdown_private_section_grouped_and_sorted(self): + summaries = [RepoStars(repo="org/a", stars=5)] + private = ["zeta-org/ZRepo", "alpha-org/BRepo", "alpha-org/ARepo"] + + report = generate_stars_report(summaries, private_repos=private) + + pos_alpha = report.index("**alpha-org**") + pos_zeta = report.index("**zeta-org**") + self.assertLess(pos_alpha, pos_zeta) + self.assertIn("**alpha-org** (2): ARepo, BRepo", report) + self.assertIn("**zeta-org** (1): ZRepo", report) + + +class TestStarsPrivateAndArchivedOverlap(unittest.TestCase): + def test_repo_private_and_archived_appears_in_both_sections_without_crash(self): + summaries = [RepoStars(repo="org/a", stars=5)] + shared = "example-org/dual-status-repo" + + md = generate_stars_report(summaries, private_repos=[shared], archived_repos=[shared]) + self.assertIn("### Diese Module sind privat:", md) + self.assertIn("### Archiviert:", md) + self.assertEqual(md.count("dual-status-repo"), 2) + + ascii_out = generate_ascii_ranking(summaries, private_repos=[shared], archived_repos=[shared]) + self.assertIn("PRIVATE MODULE", ascii_out) + self.assertIn("ARCHIVIERT", ascii_out) + self.assertEqual(ascii_out.count("dual-status-repo"), 2) + + +class TestStarsAsciiWidth(unittest.TestCase): + """ + Die 80-Zeichen-Breite gilt fuer die NEUEN Privat-/Archiviert-Bloecke der + ASCII-Rangliste (siehe Meldung: bestehende Tabellen-/Balkenspalten in + generate_ascii_ranking waren bereits vor dieser Aenderung teils >80 + Zeichen -- ausserhalb meines Schreibbereichs, nicht Teil dieser Pruefung). + """ + + def test_wrap_repo_names_never_exceeds_width(self): + names = [f"Repo-Mit-Langem-Namen-{i:02d}" for i in range(30)] + + lines = wrap_repo_names(names, prefix=" games-org (30): ", width=80) + + self.assertGreater(len(lines), 1) + for line in lines: + self.assertLessEqual(len(line), 80) + + def test_private_archived_blocks_within_width(self): + summaries = [RepoStars(repo="org/a", stars=5)] + private = [f"games-org/Repo-Mit-Ziemlich-Langem-Namen-{i:02d}" for i in range(15)] + archived = [f"tools-org/Noch-Ein-Langer-Repo-Name-{i:02d}" for i in range(15)] + + ascii_out = generate_ascii_ranking(summaries, private_repos=private, archived_repos=archived) + lines = ascii_out.split("\n") + + start = next(i for i, l in enumerate(lines) if "PRIVATE MODULE" in l) + end = next(i for i, l in enumerate(lines) if i > start and l.startswith("=" * 80)) + + for line in lines[start - 1:end]: + self.assertLessEqual(len(line), 80, msg=f"Zeile zu lang: {line!r}") + + def test_gesamt_line_stays_within_width_with_private_count(self): + summaries = [RepoStars(repo="org/a", stars=5)] + private = [ + "games-org/AlphaGame", + "games-org/BetaGame", + "example-org/internal-tool", + ] + + ascii_out = generate_ascii_ranking(summaries, private_repos=private) + gesamt_line = next(l for l in ascii_out.split("\n") if l.strip().startswith("GESAMT")) + + self.assertLessEqual(len(gesamt_line), 80) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_traffic_report.py b/tests/test_traffic_report.py new file mode 100644 index 0000000..8f8932b --- /dev/null +++ b/tests/test_traffic_report.py @@ -0,0 +1,197 @@ +""" +Tests fuer den Traffic-Report (core/traffic.py) -- inkl. der neuen +Privat-/Archiviert-Sektionen (Sichtbarkeits-Audit fuer private Repos). +""" + +import unittest + + +from githubbot_core.traffic import ( + TrafficSummary, + generate_ascii_chart, + generate_traffic_report, +) +from githubbot_core.report_sections import group_repos_by_org, wrap_repo_names + + +def _make_summaries(): + """Zwei oeffentliche Repos: eines aktiv, eines ohne Traffic (14d).""" + return [ + TrafficSummary( + repo="example-org/alpha", + views_total=50, views_unique=20, + clones_total=10, clones_unique=5, + ), + TrafficSummary( + repo="example-org/beta", + views_total=0, views_unique=0, + clones_total=0, clones_unique=0, + ), + ] + + +class TestBackwardCompatibility(unittest.TestCase): + """ + Ohne die neuen Parameter (private_repos/archived_repos) bleibt die + Ausgabe unveraendert -- Pflicht laut Auftrag (bestehende + Ein-Argument-Aufrufe duerfen sich nicht aendern). + """ + + def test_ascii_chart_unchanged_without_new_params(self): + summaries = _make_summaries() + + self.assertEqual( + generate_ascii_chart(summaries), generate_ascii_chart(summaries, None, None) + ) + out = generate_ascii_chart(summaries) + self.assertNotIn("PRIVATE MODULE", out) + self.assertNotIn("ARCHIVIERT", out) + self.assertNotIn("Privat:", out) + + def test_traffic_report_unchanged_without_new_params(self): + summaries = _make_summaries() + + self.assertEqual( + generate_traffic_report(summaries), generate_traffic_report(summaries, None, None) + ) + out = generate_traffic_report(summaries) + self.assertIn("**2 Repos** | 14-Tage-Fenster", out) + self.assertNotIn("Diese Module sind privat", out) + self.assertNotIn("Archiviert", out) + + def test_empty_lists_behave_like_none(self): + summaries = _make_summaries() + + self.assertEqual( + generate_ascii_chart(summaries, [], []), generate_ascii_chart(summaries, None, None) + ) + self.assertEqual( + generate_traffic_report(summaries, [], []), generate_traffic_report(summaries, None, None) + ) + + +class TestPrivateReposExcludedFromTotals(unittest.TestCase): + """Private Repos duerfen keine Summe/Balken/Aktivitaets-Liste beeinflussen.""" + + def test_ascii_totals_unaffected_by_private_repos(self): + summaries = _make_summaries() + + without_private = generate_ascii_chart(summaries) + with_private = generate_ascii_chart( + summaries, + private_repos=["games-org/AlphaGame", "games-org/BetaGame"], + ) + + self.assertIn( + "GESAMT | Clones: 10 (5 unique) | Views: 50 (20 unique)", without_private + ) + self.assertIn( + "GESAMT | Clones: 10 (5 unique) | Views: 50 (20 unique) | Privat: 2 (siehe oben)", + with_private, + ) + + def test_markdown_header_reports_public_and_private_counts(self): + summaries = _make_summaries() + + report = generate_traffic_report( + summaries, + private_repos=["games-org/AlphaGame", "games-org/BetaGame"], + ) + + self.assertIn( + "**2 öffentliche Repos** in den Zahlen | **2 privat** (siehe unten) | 14-Tage-Fenster", + report, + ) + # Die "Keine Aktivitaet"-Zeile zaehlt weiterhin nur das oeffentliche Repo. + self.assertIn("Keine Aktivität (14d): beta", report) + + def test_private_repo_not_in_no_views_or_inactive_lists(self): + summaries = _make_summaries() + private = ["games-org/GhostRepo"] + + ascii_out = generate_ascii_chart(summaries, private_repos=private) + no_views_line = next( + l for l in ascii_out.split("\n") if l.strip().startswith("(keine Views):") + ) + self.assertNotIn("GhostRepo", no_views_line) + self.assertIn("GhostRepo", ascii_out) # aber im Privat-Block weiter unten + + md = generate_traffic_report(summaries, private_repos=private) + inactive_line = next(l for l in md.split("\n") if l.startswith("### Keine Aktivität")) + self.assertNotIn("GhostRepo", inactive_line) + self.assertIn("GhostRepo", md) + + +class TestGroupingAndSorting(unittest.TestCase): + def test_group_repos_by_org_sorts_orgs_and_names(self): + repos = ["zeta-org/zebra", "zeta-org/apple", "alpha-org/banana", "alpha-org/apple"] + + groups = group_repos_by_org(repos) + + self.assertEqual(list(groups.keys()), ["alpha-org", "zeta-org"]) + self.assertEqual(groups["alpha-org"], ["apple", "banana"]) + self.assertEqual(groups["zeta-org"], ["apple", "zebra"]) + + def test_markdown_private_section_grouped_and_sorted(self): + summaries = _make_summaries() + private = ["zeta-org/ZRepo", "alpha-org/BRepo", "alpha-org/ARepo"] + + report = generate_traffic_report(summaries, private_repos=private) + + pos_alpha = report.index("**alpha-org**") + pos_zeta = report.index("**zeta-org**") + self.assertLess(pos_alpha, pos_zeta) + self.assertIn("**alpha-org** (2): ARepo, BRepo", report) + self.assertIn("**zeta-org** (1): ZRepo", report) + + +class TestPrivateAndArchivedOverlap(unittest.TestCase): + def test_repo_private_and_archived_appears_in_both_sections_without_crash(self): + summaries = _make_summaries() + shared = "example-org/dual-status-repo" + + md = generate_traffic_report(summaries, private_repos=[shared], archived_repos=[shared]) + self.assertIn("### Diese Module sind privat:", md) + self.assertIn("### Archiviert:", md) + self.assertEqual(md.count("dual-status-repo"), 2) + + ascii_out = generate_ascii_chart(summaries, private_repos=[shared], archived_repos=[shared]) + self.assertIn("PRIVATE MODULE", ascii_out) + self.assertIn("ARCHIVIERT", ascii_out) + self.assertEqual(ascii_out.count("dual-status-repo"), 2) + + +class TestAsciiWidth(unittest.TestCase): + """ + Die 80-Zeichen-Breite gilt fuer die NEUEN Privat-/Archiviert-Bloecke der + ASCII-Grafik (siehe Meldung: bestehende Tabellen-/Balkenspalten in + generate_ascii_chart waren bereits vor dieser Aenderung teils >80 Zeichen + -- ausserhalb meines Schreibbereichs, nicht Teil dieser Pruefung). + """ + + def test_wrap_repo_names_never_exceeds_width(self): + names = [f"Repo-Mit-Langem-Namen-{i:02d}" for i in range(30)] + + lines = wrap_repo_names(names, prefix=" games-org (30): ", width=80) + + self.assertGreater(len(lines), 1) # Umbruch muss tatsaechlich stattfinden + for line in lines: + self.assertLessEqual(len(line), 80) + + def test_private_archived_blocks_within_width(self): + summaries = _make_summaries() + private = [f"games-org/Repo-Mit-Ziemlich-Langem-Namen-{i:02d}" for i in range(15)] + archived = [f"tools-org/Noch-Ein-Langer-Repo-Name-{i:02d}" for i in range(15)] + + ascii_out = generate_ascii_chart(summaries, private_repos=private, archived_repos=archived) + lines = ascii_out.split("\n") + + start = next(i for i, l in enumerate(lines) if "PRIVATE MODULE" in l) + end = next(i for i, l in enumerate(lines) if i > start and l.startswith("=" * 80)) + + for line in lines[start - 1:end]: + self.assertLessEqual(len(line), 80, msg=f"Zeile zu lang: {line!r}") + + +if __name__ == "__main__": + unittest.main()