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
+

+
**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
+
+
[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 @@
+
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}")