diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8b8740..c1e269f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,9 @@ jobs: - name: Install run: python -m pip install -e ".[dev]" - name: Lint - run: ruff check . + run: | + ruff check . + ruff format --check . - name: Unit and contract tests run: python -m pytest -q - name: Consumer install test (fresh venv) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec775c..2f6d138 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Format: [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), [Semantic Vers ## [Unreleased] +### Geändert / Changed + +- Gesamter Baum mit `ruff format` formatiert (keine Codeänderung, AST identisch); CI prüft jetzt + zusätzlich `ruff format --check` / whole tree formatted with `ruff format` (no code change, AST + identical); CI now also runs `ruff format --check`. +- README (EN/DE) mit Banner (`assets/banner.png`, Quelle `assets/banner.svg`); Testkommentar + neutral formuliert / README banner added; a test comment worded neutrally. + ## [0.2.1] - 2026-10-11 ### Geändert / Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3604e23..16169f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,6 +37,7 @@ gelten Pull Requests unter der Lizenz des Projekts. pip install -e ".[dev]" python -m pytest ruff check . +ruff format --check . ``` Schreibende Funktionen laufen ausschließlich über `githubbot_core.actions` (Vorschau/Ausführen, @@ -81,6 +82,7 @@ pull requests are understood to be submitted under the project's license. pip install -e ".[dev]" python -m pytest ruff check . +ruff format --check . ``` Every write goes through `githubbot_core.actions` (preview/apply, permission diff --git a/README.md b/README.md index bb72d60..7fda44c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # githubbot-core +

githubbot-core banner

+ **English** | [Deutsch](README_de.md) Neutral, installable core for GitHub repository maintenance: issue triage, duplicate detection, @@ -128,6 +130,7 @@ my-extension = "my_extension:create" from githubbot_core.actions import Action from githubbot_core.plugins import SPI_VERSION + class MyExtension: name = "my-extension" spi_version = SPI_VERSION @@ -135,14 +138,14 @@ class MyExtension: def describe(self): return {"version": "1.0.0", "capabilities": ["plan"]} - def plan(self, ctx): # ctx.config, ctx.transport (read-only), ctx.args + 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")] + return [Action("api.request", {"method": "PUT", "endpoint": "user/starred/owner/repo"}, reason="example")] - def pr_policy_checks(self, pr, files, diff): # optional + def pr_policy_checks(self, pr, files, diff): # optional return [("no_large_binaries", True, "OK")] + def create(): return MyExtension() ``` @@ -164,6 +167,7 @@ trust boundaries and rollback: [docs/REVIEW_GATE.md](docs/REVIEW_GATE.md). pip install -e ".[dev]" python -m pytest # unit and contract tests ruff check . +ruff format --check . GITHUBBOT_CORE_CONSUMER_TEST=1 python -m pytest tests/test_consumer_install.py # fresh-venv install test ``` diff --git a/README_de.md b/README_de.md index 8af0115..c8ec86e 100644 --- a/README_de.md +++ b/README_de.md @@ -1,5 +1,7 @@ # githubbot-core +

githubbot-core Banner

+ [English](README.md) | **Deutsch** Neutraler, installierbarer Kern für die Pflege von GitHub-Repositories: Issue-Triage, @@ -130,6 +132,7 @@ my-extension = "my_extension:create" from githubbot_core.actions import Action from githubbot_core.plugins import SPI_VERSION + class MyExtension: name = "my-extension" spi_version = SPI_VERSION @@ -137,14 +140,14 @@ class MyExtension: def describe(self): return {"version": "1.0.0", "capabilities": ["plan"]} - def plan(self, ctx): # ctx.config, ctx.transport (nur lesend), ctx.args + 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")] + return [Action("api.request", {"method": "PUT", "endpoint": "user/starred/owner/repo"}, reason="example")] - def pr_policy_checks(self, pr, files, diff): # optional + def pr_policy_checks(self, pr, files, diff): # optional return [("no_large_binaries", True, "OK")] + def create(): return MyExtension() ``` @@ -166,6 +169,7 @@ Details, Vertrauensgrenzen und Rollback: [docs/REVIEW_GATE.md](docs/REVIEW_GATE. pip install -e ".[dev]" python -m pytest # Unit- und Vertragstests ruff check . +ruff format --check . GITHUBBOT_CORE_CONSUMER_TEST=1 python -m pytest tests/test_consumer_install.py # Installationstest im frischen venv ``` diff --git a/assets/banner.png b/assets/banner.png new file mode 100644 index 0000000..a1c206f Binary files /dev/null and b/assets/banner.png differ diff --git a/assets/banner.svg b/assets/banner.svg new file mode 100644 index 0000000..2b81f19 --- /dev/null +++ b/assets/banner.svg @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + githubbot-core + Neutral GitHub maintenance core + + + + triage + + reports + + receipts + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PREVIEW + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + APPLY + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/githubbot_core/actions.py b/src/githubbot_core/actions.py index c97027f..90eb644 100644 --- a/src/githubbot_core/actions.py +++ b/src/githubbot_core/actions.py @@ -116,14 +116,23 @@ def apply( """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"} + 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")} + 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) diff --git a/src/githubbot_core/cli.py b/src/githubbot_core/cli.py index 699f2f0..d8ebf3b 100644 --- a/src/githubbot_core/cli.py +++ b/src/githubbot_core/cli.py @@ -41,8 +41,7 @@ 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} + 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: @@ -65,9 +64,13 @@ def _read_issues(path: Path) -> list[GitHubIssue]: 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) + 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} @@ -76,17 +79,25 @@ def _plan_output(args, command: str, plan: list, config: dict, transport, extra: 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 = [ + 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()} + 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 @@ -98,19 +109,27 @@ def cmd_doctor(args, config, transport) -> int: 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'}") + 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) \ + 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 @@ -127,10 +146,15 @@ def template_sha256(schema: str, template_id: str, blueprint: dict) -> str: def cmd_system_agent(args, config, transport) -> int: template = json.loads(resources.files("githubbot_core").joinpath("system_agent.json").read_text(encoding="utf-8")) schema = template["_meta"]["schema"] - data = {"schema": schema, "id": template["id"], "version": __version__, - "sha256": template_sha256(schema, template["id"], template["blueprint"]), - "capability": {"package": "githubbot-core", "version": __version__, "spi_version": plugins.SPI_VERSION}, - "blueprint": template["blueprint"], "permissions": config.get("permissions", {})} + data = { + "schema": schema, + "id": template["id"], + "version": __version__, + "sha256": template_sha256(schema, template["id"], template["blueprint"]), + "capability": {"package": "githubbot-core", "version": __version__, "spi_version": plugins.SPI_VERSION}, + "blueprint": template["blueprint"], + "permissions": config.get("permissions", {}), + } text = f"system agent {data['id']} {data['version']} sha256 {data['sha256']}" _emit(args, "system-agent", True, "read", data, [], text) return EXIT_OK @@ -139,18 +163,30 @@ def cmd_system_agent(args, config, transport) -> int: 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) + 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] + 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 @@ -163,8 +199,11 @@ def cmd_triage(args, config, transport) -> int: 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)]} + 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) @@ -177,9 +216,15 @@ def cmd_review_pr(args, config, transport) -> int: _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]} + 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 @@ -194,10 +239,19 @@ def cmd_health(args, config, transport) -> int: 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] + 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 @@ -213,8 +267,17 @@ def cmd_stars(args, config, transport) -> int: 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] + 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 @@ -234,11 +297,23 @@ def cmd_gate_scaffold(args, config, transport) -> int: } 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} + 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") + _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)}"]) @@ -263,7 +338,8 @@ def build_parser() -> argparse.ArgumentParser: 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) sub.add_parser("system-agent", help="versioned system agent template of this package").set_defaults( - func=cmd_system_agent) + func=cmd_system_agent + ) p = sub.add_parser("classify", help="classify issues from a JSON file (offline)") p.add_argument("--input", required=True) @@ -297,8 +373,13 @@ def add_apply_flags(p): 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") + 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) @@ -330,7 +411,7 @@ def main(argv: Optional[list[str]] = None, transport=None) -> int: if index is not None and argv[index] == "review-gate": from . import review_gate - return review_gate.main(argv[index + 1:]) + return review_gate.main(argv[index + 1 :]) parser = build_parser() args = parser.parse_args(argv) if not hasattr(args, "json"): diff --git a/src/githubbot_core/comments.py b/src/githubbot_core/comments.py index 58978fe..cd0c7c0 100644 --- a/src/githubbot_core/comments.py +++ b/src/githubbot_core/comments.py @@ -27,9 +27,11 @@ # DATA CLASSES # ============================================================ + @dataclass class CommentTemplate: """Ein einzelnes Kommentar-Template.""" + key: str trigger: str template: str @@ -53,6 +55,7 @@ def render(self, **kwargs) -> str: # COMMENT MANAGER # ============================================================ + class AutoCommentManager: """ Verwaltet Kommentar-Templates und verhindert Doppel-Kommentare. diff --git a/src/githubbot_core/credentials.py b/src/githubbot_core/credentials.py index d3ee47a..6ab7f1d 100644 --- a/src/githubbot_core/credentials.py +++ b/src/githubbot_core/credentials.py @@ -53,7 +53,11 @@ def token(self) -> Optional[str]: try: result = run( [self.gh, "auth", "token"], - capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=15, ) except (OSError, subprocess.TimeoutExpired): return None diff --git a/src/githubbot_core/duplicates.py b/src/githubbot_core/duplicates.py index fe6eb7c..53ea80b 100644 --- a/src/githubbot_core/duplicates.py +++ b/src/githubbot_core/duplicates.py @@ -23,6 +23,7 @@ # HELPER # ============================================================ + def tokenize(text: str) -> set[str]: """ Normalisiert und tokenisiert Text fuer Aehnlichkeitsvergleich. @@ -32,12 +33,44 @@ def tokenize(text: str) -> set[str]: - 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" + "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())) + tokens = set(re.findall(r"[a-z0-9]+", text.lower())) return tokens - STOPWORDS @@ -56,9 +89,11 @@ def jaccard_similarity(set_a: set, set_b: set) -> float: # DATA CLASSES # ============================================================ + @dataclass class DuplicateCandidate: """Ein moegliches Duplikat-Paar.""" + issue_number: int issue_title: str original_number: int @@ -82,6 +117,7 @@ def __str__(self) -> str: # DETECTOR # ============================================================ + class DuplicateDetector: """ Erkennt Duplikat-Issues via Jaccard-Aehnlichkeit auf Token-Level. @@ -139,11 +175,7 @@ def find_duplicates(self, issues: list) -> list[DuplicateCandidate]: fp = self.fingerprint(issue.number, issue.title, issue.body) else: # Dict-Interface - fp = self.fingerprint( - issue["number"], - issue.get("title", ""), - issue.get("body", "") - ) + fp = self.fingerprint(issue["number"], issue.get("title", ""), issue.get("body", "")) fingerprints.append(fp) candidates = [] @@ -192,18 +224,18 @@ def find_for_new_issue( else: if issue["number"] == new_issue_number: continue - fp_existing = self.fingerprint( - issue["number"], issue.get("title", ""), issue.get("body", "") - ) + 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, - )) + 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 index d88c0ab..2a7e1d4 100644 --- a/src/githubbot_core/health.py +++ b/src/githubbot_core/health.py @@ -278,9 +278,7 @@ 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" - ) + 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})" @@ -296,12 +294,9 @@ def _fetch_dependabot_alerts(self, repo: str) -> tuple[list[DependabotAlert], Op number=item.get("number", 0), package_name=package.get("name", ""), ecosystem=package.get("ecosystem", ""), - severity=vulnerability.get("severity") - or advisory.get("severity") - or "unknown", + 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", ""), + manifest_path=item.get("manifest_path") or dependency.get("manifest_path", ""), created_at=item.get("created_at", ""), html_url=item.get("html_url", ""), ) @@ -353,9 +348,7 @@ def _fetch_open_issues_and_prs( None, ) - def _fetch_code_scanning_alerts( - self, repo: str - ) -> tuple[list[CodeScanningAlert], int, Optional[str]]: + 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): @@ -390,9 +383,7 @@ def _fetch_code_scanning_alerts( return visible_alerts, len(alerts), None - def _fetch_secret_scanning_alerts( - self, repo: str - ) -> tuple[list[SecretScanningAlert], int, Optional[str]]: + 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): @@ -433,9 +424,7 @@ def _is_optional_security_feature_unavailable(error: str) -> bool: # 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]]: + 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 @@ -464,9 +453,7 @@ def _key(item: dict) -> tuple: latest_completed[key] = item latest_open_ids = { - item.get("id") - for item in latest_completed.values() - if item.get("conclusion") in self.FAILURE_CONCLUSIONS + item.get("id") for item in latest_completed.values() if item.get("conclusion") in self.FAILURE_CONCLUSIONS } open_failures: list[WorkflowFailure] = [] @@ -481,7 +468,10 @@ def _key(item: dict) -> tuple: # 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): + 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( @@ -515,7 +505,7 @@ def _has_open_pull_request_for_run(self, repo: str, run: dict) -> bool: branch = run.get("head_branch", "") if not branch: return True - head_repo = ((run.get("head_repository") or {}).get("full_name") or repo) + 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") @@ -551,10 +541,7 @@ def generate_health_report( 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_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()] @@ -583,7 +570,7 @@ def generate_health_report( 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'*„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 / " @@ -614,11 +601,7 @@ def generate_health_report( 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 "" - ) + 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" diff --git a/src/githubbot_core/plugins.py b/src/githubbot_core/plugins.py index f0c8030..cc1d9b8 100644 --- a/src/githubbot_core/plugins.py +++ b/src/githubbot_core/plugins.py @@ -86,8 +86,14 @@ def list_plugins() -> list[dict]: 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))]}) + 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 diff --git a/src/githubbot_core/pr_review.py b/src/githubbot_core/pr_review.py index d70f571..ca9de4f 100644 --- a/src/githubbot_core/pr_review.py +++ b/src/githubbot_core/pr_review.py @@ -18,6 +18,7 @@ @dataclass class PullRequest: """Repraesentiert einen GitHub Pull Request.""" + number: int title: str body: str @@ -73,6 +74,7 @@ def from_rest_json(cls, data: dict) -> "PullRequest": @dataclass class PRReviewResult: """Ergebnis der PR-Pruefung.""" + pr: PullRequest summary: str = "" policy_check_results: list[tuple[str, bool, str]] = field(default_factory=list) diff --git a/src/githubbot_core/report_sections.py b/src/githubbot_core/report_sections.py index d3c2ca1..3a9dbdd 100644 --- a/src/githubbot_core/report_sections.py +++ b/src/githubbot_core/report_sections.py @@ -33,10 +33,7 @@ "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. " -) +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]]: @@ -55,8 +52,7 @@ def group_repos_by_org(repo_full_names: list[str]) -> dict[str, list[str]]: 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]: +def wrap_repo_names(names: list[str], prefix: str, width: int = 80, indent: str = " ") -> list[str]: """ Bricht eine kommaseparierte Namensliste auf mehrere Zeilen um. @@ -106,8 +102,7 @@ def markdown_archived_section(archived_repos: list[str] | None) -> list[str]: return lines -def ascii_group_block(heading: str, repo_full_names: list[str] | None, - width: int = 80) -> list[str]: +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 [] diff --git a/src/githubbot_core/review_gate.py b/src/githubbot_core/review_gate.py index 53f0eed..5a512de 100644 --- a/src/githubbot_core/review_gate.py +++ b/src/githubbot_core/review_gate.py @@ -103,10 +103,7 @@ def _parse_utc(value: object, field: str) -> datetime: 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") + return (json.dumps(receipt, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n").encode("utf-8") def create_receipt(**values: object) -> dict[str, object]: @@ -243,9 +240,7 @@ def validate_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() - ): + 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": @@ -369,9 +364,7 @@ def verify_ssh_signature( 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: +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)) @@ -404,9 +397,7 @@ def sign_receipt( timeout=20, ) if result.returncode != 0: - raise ReceiptValidationError( - f"receipt signing failed: {result.stderr.strip() or result.stdout.strip()}" - ) + 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") @@ -625,12 +616,10 @@ def _run_url() -> str | None: return None -def workflow_pending( - repository: str, pr_number: int, head_sha: str, namespace: str = DEFAULT_NAMESPACE -) -> 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) + 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( @@ -895,9 +884,7 @@ def main(argv: list[str] | None = None) -> int: 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 - ) + 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)) diff --git a/src/githubbot_core/stars.py b/src/githubbot_core/stars.py index 9e31b1d..733de91 100644 --- a/src/githubbot_core/stars.py +++ b/src/githubbot_core/stars.py @@ -35,6 +35,7 @@ @dataclass class RepoStars: """Sternenzahl eines Repos.""" + repo: str stars: int = 0 error: Optional[str] = None @@ -107,7 +108,7 @@ def bar(val, max_val): 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) + 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] @@ -163,6 +164,7 @@ def generate_stars_report( 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] diff --git a/src/githubbot_core/traffic.py b/src/githubbot_core/traffic.py index 41aed21..e2d3705 100644 --- a/src/githubbot_core/traffic.py +++ b/src/githubbot_core/traffic.py @@ -37,6 +37,7 @@ @dataclass class TrafficSummary: """Zusammengefasste Traffic-Daten eines Repos (14-Tage-Fenster).""" + repo: str views_total: int = 0 views_unique: int = 0 @@ -59,17 +60,11 @@ def format(self) -> str: ] if self.top_referrers: - refs = ", ".join( - f"{r['referrer']} ({r['count']})" - for r in self.top_referrers[:3] - ) + 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] - ) + paths = ", ".join(f"{p['path']} ({p['count']})" for p in self.top_paths[:3]) lines.append(f" Top-Pfade: {paths}") return "\n".join(lines) @@ -157,7 +152,7 @@ def bar(val: int, max_val: int) -> str: 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) + return (name[: NAME_W - 1] + "…") if len(name) > NAME_W else name.ljust(NAME_W) def generate_ascii_chart( @@ -178,17 +173,17 @@ def generate_ascii_chart( 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) + 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] + 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) + 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 = [ @@ -205,7 +200,7 @@ def generate_ascii_chart( for s in by_clones: name = s.repo.split("/")[1] - val = f"{s.clones_total:>4} /{s.clones_unique:>3}u" + val = f"{s.clones_total:>4} /{s.clones_unique:>3}u" lines.append(f" {fmt(name)} {val} {bar(s.clones_total, max_clones)}") lines += [ @@ -218,7 +213,7 @@ def generate_ascii_chart( for s in with_views: name = s.repo.split("/")[1] - val = f"{s.views_total:>4} /{s.views_unique:>3}u" + 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: @@ -229,8 +224,7 @@ def generate_ascii_chart( 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)" + f" GESAMT | Clones: {total_clones} ({total_cu} unique) | Views: {total_views} ({total_vu} unique)" ) if private_repos: gesamt_line += f" | Privat: {len(private_repos)} (siehe oben)" @@ -354,8 +348,11 @@ def section(title, unit, value, unique, max_val): ] 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, + "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) @@ -366,8 +363,7 @@ def section(title, unit, value, unique, max_val): 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)" + f" GESAMT | Views: {total_views} ({total_vu} unique) | Clones: {total_clones} ({total_cu} unique)" ) if private_repos: gesamt_line += f" | Privat: {len(private_repos)} (siehe oben)" @@ -389,6 +385,7 @@ def generate_traffic_report( 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: diff --git a/src/githubbot_core/triage.py b/src/githubbot_core/triage.py index b82cc6a..bcd165d 100644 --- a/src/githubbot_core/triage.py +++ b/src/githubbot_core/triage.py @@ -28,9 +28,11 @@ # DATA CLASSES # ============================================================ + @dataclass class GitHubIssue: """Repraesentiert ein GitHub Issue.""" + number: int title: str body: str @@ -69,6 +71,7 @@ def from_rest_json(cls, data: dict) -> "GitHubIssue": @dataclass class TriageResult: """Ergebnis der Triage fuer ein Issue.""" + issue: GitHubIssue labels_to_add: list[str] = field(default_factory=list) comment: Optional[str] = None @@ -81,13 +84,19 @@ class TriageResult: # 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", + "security_public", + "core_change", + "bug_with_steps", + "bug_without_details", + "feature_request", + "question", + "documentation", + "unclear", ] def __init__(self, config: dict): @@ -163,6 +172,7 @@ def _matches(self, text: str, criterion: dict) -> bool: # TRIAGE # ============================================================ + class IssueTriage: """Laedt Issues, klassifiziert sie und plant Aktionen. Schreibt selbst nie.""" @@ -191,20 +201,30 @@ def plan(self, results: list[TriageResult]) -> 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}")) + 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") + 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}")) + actions.append( + Action( + "issue.comment", + {"repo": self.repo, "number": number, "body": body}, + reason=f"auto comment {result.comment_type} #{number}", + ) + ) return actions @staticmethod diff --git a/tests/test_auto_comments.py b/tests/test_auto_comments.py index 83574e6..3ee8df4 100644 --- a/tests/test_auto_comments.py +++ b/tests/test_auto_comments.py @@ -1,4 +1,5 @@ """Tests fuer AutoCommentManager.""" + import unittest @@ -32,12 +33,9 @@ def setUp(self): "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." + "template": "Please provide: version, OS, steps to reproduce", }, + "duplicate_reference": {"trigger": "duplicate", "template": "Duplicate of #{original_issue}. Closing."}, } } self.manager = AutoCommentManager(self.config) @@ -70,17 +68,13 @@ def test_add_marker(self): def test_check_already_commented_true(self): """Erkennt vorhandenen Bot-Kommentar.""" - comments = [ - {"body": "Some comment\n"} - ] + 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"} - ] + comments = [{"body": "Regular user comment without marker"}] result = self.manager.check_already_commented(1, "needs_info_bug", comments) self.assertFalse(result) @@ -91,9 +85,7 @@ def test_check_already_commented_empty(self): def test_check_different_type(self): """Anderer Kommentar-Typ wird nicht verwechselt.""" - comments = [ - {"body": "Comment\n"} - ] + comments = [{"body": "Comment\n"}] result = self.manager.check_already_commented(1, "needs_info_bug", comments) self.assertFalse(result) diff --git a/tests/test_classifier.py b/tests/test_classifier.py index 14da870..00a8e99 100644 --- a/tests/test_classifier.py +++ b/tests/test_classifier.py @@ -1,4 +1,5 @@ """Tests fuer IssueClassifier.""" + import unittest @@ -11,14 +12,12 @@ class TestIssueClassifier(unittest.TestCase): 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" - ) + return GitHubIssue(number=1, title=title, body=body, labels=labels or [], state="open", author="testuser") def test_security_detection(self): """Security-Keywords werden erkannt.""" diff --git a/tests/test_consumer_install.py b/tests/test_consumer_install.py index 46b6506..9e9d53a 100644 --- a/tests/test_consumer_install.py +++ b/tests/test_consumer_install.py @@ -3,6 +3,7 @@ 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 @@ -56,8 +57,9 @@ def test_installed_package_cli_without_private_extension(tmp_path: Path) -> None 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") + 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"] diff --git a/tests/test_contract.py b/tests/test_contract.py index 33b012d..af96071 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -1,4 +1,5 @@ """Contract tests: preview/apply, permission ceilings, config layering, plugin SPI, CLI.""" + from __future__ import annotations import json @@ -15,12 +16,31 @@ 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"}, + { + "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", + }, ] @@ -34,6 +54,7 @@ def _triage_gets(): # ---------------------------------------------------------------- preview/apply + def test_preview_writes_nothing(fake_transport, capsys): transport = fake_transport(_triage_gets()) code = cli.main(["--json", "triage", "--repo", REPO], transport=transport) @@ -64,8 +85,12 @@ def test_apply_writes_only_permitted_actions_and_is_idempotent(fake_transport, t 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) + 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 == [] @@ -79,9 +104,12 @@ def test_disabled_action_is_denied_not_written(fake_transport): 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/"]}})]) + 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"}) @@ -101,17 +129,21 @@ def test_failed_write_is_reported(fake_transport, capsys): # ---------------------------------------------------------------- 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"]}})]) + 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"]}})]) + merge_layers([("default", default_config()), ("profile", {"permissions": {"ceiling": ["issue.comment"]}})]) def test_api_prefixes_narrow_only(): @@ -129,6 +161,7 @@ def test_bad_config_file_exit_code(tmp_path, fake_transport): # ---------------------------------------------------------------- plugin SPI + class _DemoPlugin: name = "demo" spi_version = plugins.SPI_VERSION @@ -192,8 +225,10 @@ def test_plugin_plan_preview_and_denied_apply(demo_plugin, fake_transport, capsy 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") + 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)] @@ -219,6 +254,7 @@ def test_pr_review_without_plugins_is_size_only(): # ---------------------------------------------------------------- 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") @@ -229,12 +265,18 @@ def test_credentials_resolve_from_env_and_redact(): # ---------------------------------------------------------------- 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") + 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 @@ -243,11 +285,16 @@ def test_offline_classify_and_duplicates(tmp_path, capsys, fake_transport): 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 ( + 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 + 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() diff --git a/tests/test_duplicates.py b/tests/test_duplicates.py index 213742e..db1ee34 100644 --- a/tests/test_duplicates.py +++ b/tests/test_duplicates.py @@ -1,10 +1,12 @@ """Tests fuer DuplicateDetector.""" + import unittest from githubbot_core.duplicates import ( DuplicateDetector, - tokenize, jaccard_similarity, + tokenize, + jaccard_similarity, ) @@ -47,7 +49,7 @@ def test_disjoint_sets(self): def test_partial_overlap(self): sim = jaccard_similarity({"a", "b", "c"}, {"b", "c", "d"}) - self.assertAlmostEqual(sim, 2/4) # 0.5 + self.assertAlmostEqual(sim, 2 / 4) # 0.5 def test_empty_sets(self): self.assertAlmostEqual(jaccard_similarity(set(), set()), 1.0) @@ -109,9 +111,7 @@ def test_find_for_new_issue(self): {"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 - ) + 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) diff --git a/tests/test_metadata.py b/tests/test_metadata.py index a1a319a..b99a34b 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -1,4 +1,5 @@ """Repository metadata: required root documents, license and packaging rules.""" + from __future__ import annotations import sys @@ -10,8 +11,18 @@ 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"] +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(): diff --git a/tests/test_report_sections.py b/tests/test_report_sections.py index 146e46b..19b095f 100644 --- a/tests/test_report_sections.py +++ b/tests/test_report_sections.py @@ -9,7 +9,6 @@ sonst wie ein inhaltlicher. """ - import pytest @@ -28,9 +27,14 @@ class TestGruppierung: def test_sortiert_orgs_und_namen(self): - result = group_repos_by_org([ - "zeta/b", "alpha/z", "alpha/a", "zeta/a", - ]) + 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"] diff --git a/tests/test_review_gate.py b/tests/test_review_gate.py index 4da1cd1..4409148 100644 --- a/tests/test_review_gate.py +++ b/tests/test_review_gate.py @@ -60,13 +60,16 @@ def test_model_review_receipt_is_canonical_and_valid() -> None: 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 + 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: @@ -342,9 +345,7 @@ def test_cli_sign_writes_only_the_requested_signature(tmp_path: Path) -> None: 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 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() @@ -411,8 +412,6 @@ def test_protection_plan_refuses_to_overwrite_a_rollback(tmp_path: Path) -> None 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 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 index 5dd43a3..ea87036 100644 --- a/tests/test_stars.py +++ b/tests/test_stars.py @@ -20,9 +20,7 @@ class TestStarsFetcher(unittest.TestCase): @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}), "" - ) + mock_run.return_value = subprocess.CompletedProcess([], 0, json.dumps({"stargazers_count": 42}), "") fetcher = StarsFetcher() result = fetcher.fetch("org/repo") @@ -141,9 +139,7 @@ class TestBackwardCompatibilityWithPrivateArchived(unittest.TestCase): 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) - ) + 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) @@ -152,9 +148,7 @@ def test_ascii_ranking_unchanged_without_new_params(self): 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) - ) + 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) @@ -163,12 +157,8 @@ def test_stars_report_unchanged_without_new_params(self): 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) - ) + 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): @@ -280,7 +270,7 @@ def test_private_archived_blocks_within_width(self): 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]: + 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): diff --git a/tests/test_system_agent.py b/tests/test_system_agent.py index 5a94c6f..b460879 100644 --- a/tests/test_system_agent.py +++ b/tests/test_system_agent.py @@ -1,4 +1,5 @@ """System agent template: versioned, hashed, shaped like the agent-studio blueprint payload.""" + from __future__ import annotations import hashlib @@ -18,8 +19,9 @@ def _recompute(d: dict) -> str: material = {"schema": d["schema"], "id": d["id"], "blueprint": d["blueprint"]} - return hashlib.sha256(json.dumps(material, sort_keys=True, separators=(",", ":"), - ensure_ascii=False).encode("utf-8")).hexdigest() + return hashlib.sha256( + json.dumps(material, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + ).hexdigest() def _system_agent(capsys, *extra: str) -> dict: @@ -35,9 +37,10 @@ def test_template_identity_is_version_plus_canonical_hash(capsys): assert data["schema"] == "githubbot-core.system-agent/v1" assert data["id"] == "githubbot" and data["version"] == __version__ assert data["sha256"] == _recompute(data) # consumer view: printed output alone suffices - assert data["sha256"] == _recompute({"schema": template["_meta"]["schema"], "id": template["id"], - "blueprint": template["blueprint"]}) - assert data["sha256"].startswith("2e72c000") # pinned: v0.2.0 blueprint content, BACH contract + assert data["sha256"] == _recompute( + {"schema": template["_meta"]["schema"], "id": template["id"], "blueprint": template["blueprint"]} + ) + assert data["sha256"].startswith("2e72c000") # pinned: v0.2.0 blueprint content, consumer contract assert data["capability"] == {"package": "githubbot-core", "version": __version__, "spi_version": 1} diff --git a/tests/test_traffic_report.py b/tests/test_traffic_report.py index 8f8932b..5833e41 100644 --- a/tests/test_traffic_report.py +++ b/tests/test_traffic_report.py @@ -19,13 +19,17 @@ def _make_summaries(): return [ TrafficSummary( repo="example-org/alpha", - views_total=50, views_unique=20, - clones_total=10, clones_unique=5, + 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, + views_total=0, + views_unique=0, + clones_total=0, + clones_unique=0, ), ] @@ -40,9 +44,7 @@ class TestBackwardCompatibility(unittest.TestCase): def test_ascii_chart_unchanged_without_new_params(self): summaries = _make_summaries() - self.assertEqual( - generate_ascii_chart(summaries), generate_ascii_chart(summaries, None, None) - ) + 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) @@ -51,9 +53,7 @@ def test_ascii_chart_unchanged_without_new_params(self): def test_traffic_report_unchanged_without_new_params(self): summaries = _make_summaries() - self.assertEqual( - generate_traffic_report(summaries), generate_traffic_report(summaries, None, None) - ) + 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) @@ -62,12 +62,8 @@ def test_traffic_report_unchanged_without_new_params(self): 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) - ) + 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): @@ -82,9 +78,7 @@ def test_ascii_totals_unaffected_by_private_repos(self): 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)", without_private) self.assertIn( "GESAMT | Clones: 10 (5 unique) | Views: 50 (20 unique) | Privat: 2 (siehe oben)", with_private, @@ -110,9 +104,7 @@ def test_private_repo_not_in_no_views_or_inactive_lists(self): 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):") - ) + 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 @@ -189,7 +181,7 @@ def test_private_archived_blocks_within_width(self): 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]: + for line in lines[start - 1 : end]: self.assertLessEqual(len(line), 80, msg=f"Zeile zu lang: {line!r}")