diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a70a45..9c76ecc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ Format: [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), [Semantic Vers ### Hinzugefügt / Added +- Getypte Schreibaktion `pr.review` für formale Reviews am exakten PR-Head mit Bot-Identitätsprüfung, + `publish_reviews`-Gate und unabhängigem Readback. Issue-Labels/-Kommentare binden MULTI-BOT- + Schreibvorgänge an das konfigurierte Konto und lesen Änderungen zurück. / + Added exact-head formal PR reviews with configured bot identity, a `publish_reviews` gate and + readback; MULTI-BOT issue writes now verify the configured account and read changes back. +- Selector und Rollen um Issue-Triage erweitert; Operator bearbeitet einzelne Kandidaten mit + frischer Auswahl weiter, bis der Umfang erledigt oder konkret blockiert ist. / + Selector and roles now include issue triage; operator continues across selected candidates with + refreshed state until the requested scope is complete or concretely blocked. - Getypte Schreibaktionen `pr.merge`, `pr.ready`, `actions.dispatch`, `actions.rerun`, `pr.create` und `git.push_branch` mit frischem Lesen, exakter Head-Bindung und Rücklesen; in der Standard-Obergrenze, aber nie standardmäßig aktiv. Jede braucht zusätzlich ihren @@ -26,6 +35,13 @@ Format: [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), [Semantic Vers - README (EN/DE) mit Banner (`assets/banner.png`, Quelle `assets/banner.svg`); Testkommentar neutral formuliert / README banner added; a test comment worded neutrally. +- `pr.review` liest nach dem Dublettencheck den PR-Zustand erneut und verweigert einen + zwischenzeitlich geänderten Head oder eine inzwischen als Draft markierte PR vor dem POST. Bestehende Reviews desselben Kontos + am selben Head, auch `DISMISSED`, verhindern ein erneutes POST. / After the duplicate + lookup, `pr.review` re-reads the PR and denies an observed head or draft change before + POST. Existing reviews by the same account for the same head, including `DISMISSED`, + prevent reposting. + ## [0.2.1] - 2026-10-11 ### Geändert / Changed diff --git a/README.md b/README.md index 8355d48..9aa2328 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ pull-request summaries, read-only health/traffic/star reports, a preview/apply c write, a plugin SPI and signed exact-SHA review receipts. Pure Python standard library, no runtime dependencies. -> Status: alpha (0.2.1). The CLI, the JSON result envelope and SPI version 1 are the contract; +> Status: alpha (0.2.2). The CLI, the JSON result envelope and SPI version 1 are the contract; > everything else may still change. ## Features @@ -110,6 +110,7 @@ not a silent override. `permissions.enabled` must stay inside the ceiling. | `issue.add_labels` | enabled | `POST /repos/{repo}/issues/{n}/labels` | | `issue.comment` | enabled | `POST /repos/{repo}/issues/{n}/comments` | | `api.request` | disabled | any write method on an endpoint that starts with one of `permissions.api_write_prefixes` | +| `pr.review` | disabled | verifies bot identity, open non-draft PR and exact head; `POST …/pulls/{n}/reviews` with `commit_id` and review event, then reads the formal review back; also requires `publish_reviews` and a configured enabled organization in `/config/orgs.json` (`multi_bot.root`, default: current directory); global and organization `exclude_writes` are enforced, and a missing or invalid policy denies the review | | `pr.merge` | disabled | re-reads the PR (open, exact `sha`, default base, not draft, `mergeable_state` clean, configured merge account), `PUT …/pulls/{n}/merge` with `sha`, reads `merged` back | | `pr.ready` | disabled | re-reads the draft at the exact `sha`, GraphQL `markPullRequestReadyForReview`, reads back | | `actions.dispatch` | disabled | `POST …/actions/workflows/{workflow}/dispatches` for an active workflow | @@ -117,9 +118,9 @@ not a silent override. `permissions.enabled` must stay inside the ceiling. | `pr.create` | disabled | `POST …/pulls` from a bot-prefixed branch onto the default branch | | `git.push_branch` | disabled | `git push origin :refs/heads/` without force, bot prefix only (`typed_actions.push_branch_prefix`, default `bot/`), never the default branch, verified with `ls-remote` | -The six typed actions sit in the default ceiling but are never enabled by default. Each one also -needs its `multi_bot` switch (`merge_enabled`, `policy.draft_*`, `policy.trigger_missing_ci`, -`policy.open_fix_prs`); a permission alone opens nothing. One action at a time: +The seven typed actions sit in the default ceiling but are never enabled by default. Each one also +needs its `multi_bot` switch (`merge_enabled`, `policy.publish_reviews`, `policy.draft_*`, +`policy.trigger_missing_ci`, `policy.open_fix_prs`); a permission alone opens nothing. One action at a time: ```bash githubbot-core --config instance.json act pr.merge --params '{"repo": "o/r", "number": 7, "sha": "<40-hex>"}' @@ -200,7 +201,7 @@ No third-party code is included; see [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICEN ## Optional MULTI-BOT role `python -m githubbot_core multi-bot` delegates mode/provider/model/effort selection to the -optional TASKPLAN launcher. The visible menu is **1 operator** (default), **2 bot-triage** (PR triage/reviews only), and **3 github-maintain** (fixes and maintenance). Operator checks fresh PR work before every unit, then fixes, then policies/gates/privacy and care. Initial policy preference and evidenced policy/care alternation are a documented heuristic; urgent policies precede routine care. Missing TASKPLAN produces an installation hint +optional TASKPLAN launcher. The visible menu is **1 operator** (default), **2 bot-triage** (PR and Issue triage, independent PR reviews), and **3 github-maintain** (fixes and maintenance). Operator handles fresh PRs, then Issues, then fixes, policies/gates/privacy and care. A selector call chooses one candidate; the agent continues through the requested scope using refreshed lists. Preview alone is not completion. Merges remain opt-in through `effective_enabled` and all exact-head review/CI/identity/branch-protection gates. Initial policy preference and evidenced policy/care alternation are a documented heuristic; urgent policies precede routine care. Missing TASKPLAN produces an installation hint and starts no provider. `--dry-run` enables TASKPLAN's no-probe dry run. Wheels and sdists ship `githubbot_core/prompts/MULTI-BOT.de.md`, neutral starters under @@ -220,6 +221,6 @@ service or executable automatic merge is installed. Named legacy flags `triage`, `review-merge`, and `aftercare` retain their original limited behavior and are absent from the new three-choice menu. Choosing a role never enables merges or widens permissions. -`githubbot-core --json multi-bot-select --input SNAPSHOT.json --mode operator` makes a deterministic read-only decision from an existing snapshot. It creates no queue, work item, persistent state, or agent. The snapshot contains `candidates`, optional `processed` and `lastMaintenance`. Candidates require `id`, an exact lowercase 40-character commit `revision`, `status` (`ready`, `blocked`, `done`), `category` (`prs`, `fixes`, `policies`, `care`), and optional boolean `urgent`. Processed records carry the same identity/category, blocked/done status and a nonempty `evidence` reference; the caller verifies the actual evidence. `lastMaintenance` points via `{id, revision}` to an evidenced successful policy/care completion. Selection and repeated empty checks never advance it. Unknown values are rejected. Exact processed heads are skipped; changed PR heads can preempt fixes and maintenance. Equal-priority candidates sort by ID and SHA. +`githubbot-core --json multi-bot-select --input SNAPSHOT.json --mode operator` makes a deterministic read-only decision from an existing snapshot. It creates no queue, work item, persistent state, or agent. The snapshot contains `candidates`, optional `processed` and `lastMaintenance`. Candidates require `id`, an exact lowercase 40-character commit `revision`, `status` (`ready`, `blocked`, `done`), `category` (`prs`, `issues`, `fixes`, `policies`, `care`), and optional boolean `urgent`. Operator prioritizes PRs, Issues, then fixes; `bot-triage` handles PRs and Issues, while `github-maintain` handles fixes and maintenance. Processed records carry the same identity/category, blocked/done status and a nonempty `evidence` reference; the caller verifies the actual evidence. `lastMaintenance` points via `{id, revision}` to an evidenced successful policy/care completion. Selection and repeated empty checks never advance it. Unknown values are rejected. Exact processed heads are skipped until new evidence; equal-priority candidates sort by ID and SHA. The neutral ASCII Windows starter is titled MULTI-BOT COMPANION, explains foreground operation and pauses after success, failure or STOP. Only explicit `--dry-run`, `TASKPLAN_STARTER_DRY_RUN=1` or `MULTIBOT_NO_PAUSE=1` skips the pause. It installs nothing and opens no extra window. diff --git a/README_de.md b/README_de.md index 947c7d9..49b2c7f 100644 --- a/README_de.md +++ b/README_de.md @@ -9,7 +9,7 @@ Duplikaterkennung, Pull-Request-Zusammenfassungen, lesende Berichte zu Gesundhei Sternen, ein Vorschau-/Ausführungsvertrag für jede Schreibaktion, eine Plugin-Schnittstelle (SPI) und signierte Exact-SHA-Review-Belege. Reine Python-Standardbibliothek, keine Laufzeitabhängigkeiten. -> Status: Alpha (0.2.1). Verbindlich sind CLI, JSON-Ergebnishülle und SPI-Version 1; alles andere +> Status: Alpha (0.2.2). Verbindlich sind CLI, JSON-Ergebnishülle und SPI-Version 1; alles andere > kann sich noch ändern. ## Funktionen @@ -112,6 +112,7 @@ stille Überschreibung. `permissions.enabled` muss innerhalb der Obergrenze blei | `issue.add_labels` | aktiv | `POST /repos/{repo}/issues/{n}/labels` | | `issue.comment` | aktiv | `POST /repos/{repo}/issues/{n}/comments` | | `api.request` | aus | beliebige Schreibmethode auf einem Endpunkt, der mit einem Eintrag aus `permissions.api_write_prefixes` beginnt | +| `pr.review` | aus | prüft Bot-Identität, offenen Nicht-Draft-PR und exakten Head; `POST …/pulls/{n}/reviews` mit `commit_id`/Review-Ereignis, danach unabhängiger Review-Readback; zusätzlich `publish_reviews` und eine konfigurierte aktivierte Organisation in `/config/orgs.json` (`multi_bot.root`, Standard: aktuelles Verzeichnis); globale und organisationsbezogene `exclude_writes` werden durchgesetzt, eine fehlende oder ungültige Policy verweigert das Review | | `pr.merge` | aus | liest den PR frisch (offen, exakter `sha`, Default-Base, kein Draft, `mergeable_state` clean, konfiguriertes Merge-Konto), `PUT …/pulls/{n}/merge` mit `sha`, liest `merged` zurück | | `pr.ready` | aus | liest den Draft am exakten `sha` frisch, GraphQL `markPullRequestReadyForReview`, liest zurück | | `actions.dispatch` | aus | `POST …/actions/workflows/{workflow}/dispatches` für einen aktiven Workflow | @@ -119,9 +120,9 @@ stille Überschreibung. `permissions.enabled` muss innerhalb der Obergrenze blei | `pr.create` | aus | `POST …/pulls` von einem Branch mit Bot-Präfix auf den Default-Branch | | `git.push_branch` | aus | `git push origin :refs/heads/` ohne Force, nur Bot-Präfix (`typed_actions.push_branch_prefix`, Standard `bot/`), nie der Default-Branch, Prüfung per `ls-remote` | -Die sechs getypten Aktionen stehen in der Standard-Obergrenze, sind aber nie standardmäßig aktiv. -Jede braucht zusätzlich ihren `multi_bot`-Schalter (`merge_enabled`, `policy.draft_*`, -`policy.trigger_missing_ci`, `policy.open_fix_prs`); eine Berechtigung allein öffnet nichts. +Die sieben getypten Aktionen stehen in der Standard-Obergrenze, sind aber nie standardmäßig aktiv. +Jede braucht zusätzlich ihren `multi_bot`-Schalter (`merge_enabled`, `policy.publish_reviews`, +`policy.draft_*`, `policy.trigger_missing_ci`, `policy.open_fix_prs`); eine Berechtigung allein öffnet nichts. Eine Aktion je Aufruf: ```bash @@ -203,7 +204,7 @@ Historie aufgebaut. Es ist kein Fremdcode enthalten; siehe ## MULTI-BOT als optionale Agentenrolle -`python -m githubbot_core multi-bot` startet die paketierte Rolle über TASKPLAN. Das sichtbare Menü enthält **1 operator** (Standard), **2 bot-triage** (nur PR-Triage/Reviews) und **3 github-maintain** (Fixes und Pflege). Operator prüft vor jeder neuen Einheit frisch PRs, dann konkrete Fixes, dann Policies/Gates/Privacy und Care. Die 3a/3b-Heuristik bevorzugt anfangs Policies und wechselt nach belegten Abschlüssen; dringende Policies gehen vor normaler Care. Das Startcenter verwendet denselben CLI-Aufruf. +`python -m githubbot_core multi-bot` startet die paketierte Rolle über TASKPLAN. Das sichtbare Menü enthält **1 operator** (Standard), **2 bot-triage** (PR- und Issue-Triage, unabhängige PR-Reviews) und **3 github-maintain** (Fixes und Pflege). Operator bearbeitet frische PRs, dann Issues, dann konkrete Fixes, danach Policies/Gates/Privacy und Care. Ein Selector-Durchlauf wählt einen Kandidaten; der Agent setzt den angeforderten Umfang mit frischen Listen fort. Vorschau allein gilt nicht als Abschluss. Merges sind im neutralen Paketstandard aus und nur über `effective_enabled` plus alle exakten Review-/CI-/Identitäts-/Branchschutz-Gates möglich. Die 3a/3b-Heuristik bevorzugt anfangs Policies und wechselt nach belegten Abschlüssen; dringende Policies gehen vor normaler Care. Das Startcenter verwendet denselben CLI-Aufruf. `--provider`, `--model`, `--effort` und `--mode` wählen ausdrücklich; die Provider-Ausführung bleibt in TASKPLAN. Fehlt dieses optionale Modul, erscheint ein Installationshinweis und kein Anbieter startet. `--dry-run` aktiviert TASKPLANs Trockenlauf ohne Sonde. @@ -233,6 +234,6 @@ bleiben eigenständige, getrennte Konsumenten. Die alten benannten Flags `triage`, `review-merge`, `aftercare` bleiben mit ihrem bisherigen begrenzten Umfang verfügbar, erscheinen aber nicht im neuen Dreiermenü. Eine Rollenwahl aktiviert keine Merges und erweitert keine Permission-Decke. -`githubbot-core --json multi-bot-select --input SNAPSHOT.json --mode operator` wählt deterministisch und ausschließlich lesend aus vorhandenen Kandidaten. Keine Queue, Auftragsanlage, Persistierung oder Agentenstarts. Der Snapshot enthält `candidates`, optional `processed` und `lastMaintenance`: Kandidaten brauchen `id`, exakte 40-stellige lowercase Commit-`revision`, `status` (`ready`, `blocked`, `done`), `category` (`prs`, `fixes`, `policies`, `care`) und optional `urgent` (bool). Ein `processed`-Eintrag bindet dieselben Felder an `blocked`/`done` und eine nichtleere `evidence`-Referenz; die tatsächliche Belegprüfung liegt beim Aufrufer. `lastMaintenance` ist ein `{id, revision}`-Verweis auf einen solchen erfolgreichen Policies-/Care-Abschluss. Auswahl und zwei leere Prüfungen verändern nichts. Unbekannte Werte werden abgewiesen. Neue PR-Heads kommen wieder vor Fixes/Pflege; erledigte oder blockierte gleiche Heads bleiben übersprungen, bis der zuständige Workflow neue Belege liefert. Gleichrangige Kandidaten werden nach ID und SHA sortiert. +`githubbot-core --json multi-bot-select --input SNAPSHOT.json --mode operator` wählt deterministisch und ausschließlich lesend aus vorhandenen Kandidaten. Keine Queue, Auftragsanlage, Persistierung oder Agentenstarts. Der Snapshot enthält `candidates`, optional `processed` und `lastMaintenance`: Kandidaten brauchen `id`, exakte 40-stellige lowercase Commit-`revision`, `status` (`ready`, `blocked`, `done`), `category` (`prs`, `issues`, `fixes`, `policies`, `care`) und optional `urgent` (bool). Operator priorisiert PRs, Issues und Fixes vor Pflege; `bot-triage` nimmt PRs und Issues, `github-maintain` Fixes und Pflege. Ein `processed`-Eintrag bindet dieselben Felder an `blocked`/`done` und eine nichtleere `evidence`-Referenz; die tatsächliche Belegprüfung liegt beim Aufrufer. `lastMaintenance` ist ein `{id, revision}`-Verweis auf einen solchen erfolgreichen Policies-/Care-Abschluss. Auswahl und zwei leere Prüfungen verändern nichts. Unbekannte Werte werden abgewiesen. Exakt erledigte/blockierte Heads bleiben übersprungen, bis neue Belege den Zustand ändern. Gleichrangige Kandidaten werden nach ID und SHA sortiert. Der öffentliche ASCII-Windows-Starter trägt den Titel MULTI-BOT COMPANION, zeigt den Vordergrundbetrieb und wartet nach Erfolg, Fehler oder STOP auf eine Taste. Ausnahmen: explizites `--dry-run`, `TASKPLAN_STARTER_DRY_RUN=1` oder `MULTIBOT_NO_PAUSE=1`. Er installiert nichts und öffnet kein zusätzliches Fenster. diff --git a/src/githubbot_core/__init__.py b/src/githubbot_core/__init__.py index a08acfd..7871001 100644 --- a/src/githubbot_core/__init__.py +++ b/src/githubbot_core/__init__.py @@ -5,4 +5,4 @@ a plugin SPI and signed exact-SHA review receipts. Standard library only. """ -__version__ = "0.2.1" +__version__ = "0.2.2" diff --git a/src/githubbot_core/actions.py b/src/githubbot_core/actions.py index 284dcd1..3615f25 100644 --- a/src/githubbot_core/actions.py +++ b/src/githubbot_core/actions.py @@ -18,6 +18,7 @@ from typing import Any, Optional from . import typed_actions +from .repo_scope import review_write_scope_denial WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"} @@ -57,6 +58,10 @@ def denial_reason(action: Action, config: dict) -> Optional[str]: ) if reason: return reason + if action.kind == "pr.review": + reason = review_write_scope_denial(action.params.get("repo"), config) + if reason: + return reason if action.kind == "api.request": method = str(action.params.get("method", "")).upper() endpoint = str(action.params.get("endpoint", "")).lstrip("/") @@ -77,15 +82,77 @@ def preview(actions: list[Action], config: dict) -> dict: def _exec_add_labels(transport, params: dict, config: dict): - return transport.request( + if "multi_bot" not in config: + return transport.request( + "POST", + f"repos/{params['repo']}/issues/{int(params['number'])}/labels", + {"labels": list(params["labels"])}, + ) + _, error = _require_multibot_identity(transport, config) + if error: + return None, error + _, error = transport.request( "POST", f"repos/{params['repo']}/issues/{int(params['number'])}/labels", {"labels": list(params["labels"])} ) + if error: + return None, error + issue, error = transport.get(f"repos/{params['repo']}/issues/{int(params['number'])}") + if error or not isinstance(issue, dict): + return None, f"labels submitted, but issue readback failed: {error or 'no issue data'}" + if issue.get("number") != int(params["number"]): + return None, "labels submitted, but issue readback returned a different issue" + labels = {str(label.get("name", "")) for label in issue.get("labels", []) if isinstance(label, dict)} + requested = {str(label) for label in params["labels"]} + if not requested <= labels: + return None, "labels submitted, but issue readback did not confirm every requested label" + return {"readback": {"number": int(params["number"]), "labels": sorted(requested)}}, None def _exec_comment(transport, params: dict, config: dict): - return transport.request( + if "multi_bot" not in config: + return transport.request( + "POST", + f"repos/{params['repo']}/issues/{int(params['number'])}/comments", + {"body": str(params["body"])}, + ) + _, error = _require_multibot_identity(transport, config) + if error: + return None, error + created, error = transport.request( "POST", f"repos/{params['repo']}/issues/{int(params['number'])}/comments", {"body": str(params["body"])} ) + if error or not isinstance(created, dict) or not str(created.get("id", "")).isdecimal(): + return None, error or "comment submitted, but create response has no numeric id" + comment_id = int(created["id"]) + comment, error = transport.get(f"repos/{params['repo']}/issues/comments/{comment_id}") + if error or not isinstance(comment, dict): + return None, f"comment submitted, but comment readback failed: {error or 'no comment data'}" + if comment.get("body") != str(params["body"]): + return None, "comment submitted, but readback body did not match" + if not str(comment.get("issue_url", "")).endswith(f"/issues/{int(params['number'])}"): + return None, "comment submitted, but readback issue did not match the requested issue" + expected_account = str((config.get("multi_bot") or {}).get("merge_account") or "").strip() + if expected_account and str((comment.get("user") or {}).get("login", "")).casefold() != expected_account.casefold(): + return None, "comment submitted, but readback author did not match the configured account" + return { + "readback": {"comment_id": comment_id, "number": int(params["number"]), "url": comment.get("html_url")} + }, None + + +def _require_multibot_identity(transport, config: dict): + """Bind MULTI-BOT issue writes to its configured account; leave generic triage unchanged.""" + if "multi_bot" not in config: + return None, None + settings = config.get("multi_bot") + if not isinstance(settings, dict): + return None, "multi_bot configuration must be an object" + expected = str(settings.get("merge_account") or "").strip() + if not expected: + return None, "multi_bot.merge_account is required for issue writes" + user, error = transport.get("user") + if error or not isinstance(user, dict) or str(user.get("login", "")).casefold() != expected.casefold(): + return None, f"identity does not match the configured MULTI-BOT account {expected!r}" + return user, None def _exec_api_request(transport, params: dict, config: dict): diff --git a/src/githubbot_core/cli.py b/src/githubbot_core/cli.py index df15d12..abc434a 100644 --- a/src/githubbot_core/cli.py +++ b/src/githubbot_core/cli.py @@ -429,8 +429,8 @@ def add_apply_flags(p): add_apply_flags(p) p.set_defaults(func=cmd_run) - p = sub.add_parser("act", help="preview or apply one typed write action (pr.merge, pr.ready, ...)") - p.add_argument("kind", help="action kind, for example pr.merge") + p = sub.add_parser("act", help="preview or apply one typed write action (pr.review, pr.merge, ...)") + p.add_argument("kind", help="action kind, for example pr.review") p.add_argument( "--params", required=True, help='JSON object, for example {"repo": "o/r", "number": 1, "sha": "..."}' ) diff --git a/src/githubbot_core/default_config.json b/src/githubbot_core/default_config.json index a354ca2..6344ae1 100644 --- a/src/githubbot_core/default_config.json +++ b/src/githubbot_core/default_config.json @@ -8,6 +8,7 @@ "issue.add_labels", "issue.comment", "api.request", + "pr.review", "pr.merge", "pr.ready", "actions.dispatch", diff --git a/src/githubbot_core/multibot_roles.json b/src/githubbot_core/multibot_roles.json index 1b5c245..8be2ed3 100644 --- a/src/githubbot_core/multibot_roles.json +++ b/src/githubbot_core/multibot_roles.json @@ -19,9 +19,9 @@ "name": "multi-bot-operator", "title": "MULTI-BOT operator", "kind": "agent", - "description": "Category 1 PR triage and independent reviews, then category 2 security/general fixes, then category 3a policies/gates/privacy and 3b care. Recheck category 1 before each new unit. Initial policy preference, then evidenced 3a/3b alternation; urgent policies precede routine care.", + "description": "End-to-end GitHub operator: PR triage/reviews, Issue triage, security/general fixes, policies/gates/privacy and care, in that priority order. Merges are allowed only when pr.merge is effective and every merge gate passes. Refresh PRs and Issues before each new unit; continue through the requested scope until no eligible work remains or a concrete blocker is recorded. Initial policy preference, then evidenced policy/care alternation; urgent policies precede routine care.", "persona_role": "github-maintainer", - "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in mode operator. Scope: Category 1 PR triage and independent reviews, then category 2 security/general fixes, then category 3a policies/gates/privacy and 3b care. Recheck category 1 before each new unit. Initial policy preference, then evidenced 3a/3b alternation; urgent policies precede routine care. Runtime root, merge account and authorization come from explicit instance configuration. Merge is disabled by default. Never expand the configured permission ceiling.", + "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in mode operator. Complete eligible work in this order: PR triage and independent reviews, Issue triage, security/general fixes, then policies/gates/privacy and care. Refresh PRs and Issues before every new unit; continue until the requested scope is complete, no eligible work remains, or a concrete blocker is recorded. A preview is not completion: apply only effective actions, then independently read back each mutation. Merges are allowed only when pr.merge is in effective_enabled, the instance merge switch is on, the configured account is verified, and every exact-head review/CI/branch-protection gate passes. Runtime root, account and authorization come from explicit instance configuration. Never expand the configured permission ceiling.", "skills": [], "animus_type": "cli", "modus": "usus", @@ -53,9 +53,9 @@ "name": "multi-bot-bot-triage", "title": "MULTI-BOT bot-triage", "kind": "agent", - "description": "Only category 1: PR triage and independent reviews. Do not enter security/general fixes or maintenance categories.", + "description": "Only PR triage, independent formal PR reviews, and Issue triage. Do not enter fixes, security/general remediation, maintenance, or merges.", "persona_role": "github-maintainer", - "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in mode bot-triage. Scope: Only category 1: PR triage and independent reviews. Do not enter security/general fixes or maintenance categories. Runtime root, merge account and authorization come from explicit instance configuration. Merge is disabled by default. Never expand the configured permission ceiling.", + "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in mode bot-triage. Triage PRs and Issues and publish an independent formal PR review only when pr.review is effective and publish_reviews is on. Use only enabled issue labels/comments for Issue triage. Do not implement fixes, enter maintenance, or merge PRs in this role even if another mode has merge permission. Runtime root, configured account and authorization come from the instance. Never expand the configured permission ceiling.", "skills": [], "animus_type": "cli", "modus": "casualis", @@ -87,9 +87,9 @@ "name": "multi-bot-github-maintain", "title": "MULTI-BOT github-maintain", "kind": "agent", - "description": "Only categories 2 and 3: security/general fixes, policies/gates/privacy and care. Recheck category 1 and notify the operator; do not take over category 1.", + "description": "Only security/general fixes, policies/gates/privacy and care. Refresh PR and Issue triage before each new unit and notify the operator; do not take over triage or reviews.", "persona_role": "github-maintainer", - "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in mode github-maintain. Scope: Only categories 2 and 3: security/general fixes, policies/gates/privacy and care. Recheck category 1 and notify the operator; do not take over category 1. Runtime root, merge account and authorization come from explicit instance configuration. Merge is disabled by default. Never expand the configured permission ceiling.", + "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in mode github-maintain. Work only on security/general fixes, policies/gates/privacy and care. Refresh PR and Issue triage before each new unit and notify the operator; do not take over PR/Issue triage, formal reviews, or merges in this role. Fix PRs require effective git.push_branch and pr.create actions plus open_fix_prs. Runtime root, account and authorization come from explicit instance configuration. Never expand the configured permission ceiling.", "skills": [], "animus_type": "cli", "modus": "usus", @@ -121,9 +121,9 @@ "name": "multi-bot-triage", "title": "MULTI-BOT triage", "kind": "agent", - "description": "Neutral GitHub maintenance role; instance capabilities and authorization remain explicit.", + "description": "Read-only snapshot and report for open PRs, Issues, CI failures and security alerts; no writes, fixes or merges.", "persona_role": "github-maintainer", - "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in mode triage. Runtime root, merge account and authorization come from explicit instance configuration. Merge is disabled by default. Never expand the configured permission ceiling.", + "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in legacy mode triage. Produce a bounded read-only snapshot/report of open PRs, Issues, CI failures and security alerts. Do not publish reviews or triage mutations, implement fixes, or merge in this mode. Runtime root, account and authorization come from explicit instance configuration. Never expand the configured permission ceiling.", "skills": [], "animus_type": "cli", "modus": "casualis", @@ -155,9 +155,9 @@ "name": "multi-bot-review-merge", "title": "MULTI-BOT review-merge", "kind": "agent", - "description": "Neutral GitHub maintenance role; instance capabilities and authorization remain explicit.", + "description": "Independent PR review and gated merge role. Merge only when pr.merge is effective, the configured account matches, and all exact-head gates pass.", "persona_role": "github-maintainer", - "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in mode review-merge. Runtime root, merge account and authorization come from explicit instance configuration. Merge is disabled by default. Never expand the configured permission ceiling.", + "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in legacy mode review-merge. Review only work independent from this session. Publish a formal review only through effective pr.review with publish_reviews enabled. Merge only when pr.merge appears in effective_enabled, merge_enabled is true, the configured account is verified, and every exact-head CI/review/base/cleanliness gate passes. Preview is not completion; independently read back each review or merge. Never expand the configured permission ceiling.", "skills": [], "animus_type": "cli", "modus": "usus", @@ -189,9 +189,9 @@ "name": "multi-bot-aftercare", "title": "MULTI-BOT aftercare", "kind": "agent", - "description": "Neutral GitHub maintenance role; instance capabilities and authorization remain explicit.", + "description": "Independent PR review and exact-gated merge work, followed by claimed repository care.", "persona_role": "github-maintainer", - "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in mode aftercare. Runtime root, merge account and authorization come from explicit instance configuration. Merge is disabled by default. Never expand the configured permission ceiling.", + "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in legacy mode aftercare. Finish eligible independent PR review and gated merge work first, then perform only claimed aftercare. A merge requires effective pr.merge, explicit merge_enabled, verified configured identity, and all exact-head gates. Do not interpret preview as completion; read back every mutation. Never expand the configured permission ceiling.", "skills": [], "animus_type": "cli", "modus": "usus", diff --git a/src/githubbot_core/multibot_select.py b/src/githubbot_core/multibot_select.py index cb3a39d..1d51d1b 100644 --- a/src/githubbot_core/multibot_select.py +++ b/src/githubbot_core/multibot_select.py @@ -5,7 +5,7 @@ import re MODES = ("operator", "bot-triage", "github-maintain") -CATEGORIES = ("prs", "fixes", "policies", "care") +CATEGORIES = ("prs", "issues", "fixes", "policies", "care") def _identity(row): @@ -77,13 +77,17 @@ def select_work(snapshot, *, mode="operator"): if row["status"] == "ready" and key not in known: ready.append(row) ready.sort(key=lambda row: _identity(row)) - categories = ("prs",) if mode == "bot-triage" else CATEGORIES if mode == "operator" else CATEGORIES[1:] + categories = ("prs", "issues") if mode == "bot-triage" else CATEGORIES if mode == "operator" else CATEGORIES[2:] selected, reason = None, "no_ready_candidate" - for category in ("prs", "fixes"): + for category in ("prs", "issues", "fixes"): if category in categories: selected = next((row for row in ready if row["category"] == category), None) if selected: - reason = "fresh_pr_priority" if category == "prs" else "fixes_before_maintenance" + reason = { + "prs": "fresh_pr_priority", + "issues": "fresh_issue_priority", + "fixes": "fixes_before_maintenance", + }[category] break if selected is None and "policies" in categories: selected = next((row for row in ready if row["category"] == "policies" and row.get("urgent", False)), None) @@ -109,6 +113,6 @@ def select_work(snapshot, *, mode="operator"): "candidate": dict(selected) if selected else None, "revision_key": "@".join(_identity(selected)) if selected else None, "reason": reason, - "next_check": "prs", + "next_check": "triage", "idle_cooldown_seconds": 60, } diff --git a/src/githubbot_core/prompts/MULTI-BOT.de.md b/src/githubbot_core/prompts/MULTI-BOT.de.md index 964c4b8..be5dd5f 100644 --- a/src/githubbot_core/prompts/MULTI-BOT.de.md +++ b/src/githubbot_core/prompts/MULTI-BOT.de.md @@ -18,7 +18,9 @@ Modus. Keine Unteragenten starten oder zusätzliche kostenpflichtige Dienste bes Betriebsregister/Logs im konfigurierten Betriebsroot; lokale Instanzregeln entscheiden über Spiegel-/Cloud- und Hostpfade. Ohne belegte Konfiguration keine Pfade erfinden. Nutze `run.py registry show`, `_tools/repo_radar.py` und die vorhandenen Werkzeuge. - Erfinde keine zweite Registry. `audit_open_prs.py` liefert Hinweise, keine Merge-Freigabe: + Erfinde keine zweite Registry. Wenn eine benötigte Core-Aktion wie `pr.review` im installierten + Laufvertrag fehlt, ist das eine konkrete Core-Fähigkeitslücke; verwende keinen Ersatzkommentar + als formalen Review und klassifiziere das nicht als Tokenfehler. `audit_open_prs.py` liefert Hinweise, keine Merge-Freigabe: seine Empfehlung kann bei Drafts oder unvollständiger CI zu optimistisch sein. 3. Vor Änderungen, Commit, Push und Merge den echten `lock_scan.py --check-dir --json` über einen aufgelösten Python-Interpreter ausführen: lokaler Klon (bei Worktrees @@ -31,9 +33,10 @@ Modus. Keine Unteragenten starten oder zusätzliche kostenpflichtige Dienste bes Bot-Schreibfeatures dort. Keine gestapelten PRs mergen; Drafts nur nach den Schaltern `draft_auto_ready_hours`/`draft_ready_when_mature` (Abschnitt „Einstellbare Regeln“). Keine Force-Pushes, Admin-/Bypass-Merges, Publikation, Tags, Releases oder Sichtbarkeitsänderungen. - Keine Dritt-Nachrichten, Einladungen, Follow-Aktionen oder Kommentare ohne explizite - Autorisierung; für Reviews und Triage an PRs/Issues der konfigurierten Owner ist das der - Schalter `publish_reviews`. + Keine Dritt-Nachrichten, Einladungen oder Follow-Aktionen. Triage-Labels und + Unterhaltungskommentare nur über die aktivierten `issue.add_labels`/`issue.comment`-Aktionen, + für den konfigurierten Bot-Account und innerhalb der freigegebenen Owner. Formale PR-Reviews + brauchen zusätzlich `publish_reviews` und die getypte Aktion `pr.review`. 5. Echte Nutzerentscheidungen mit dem vorhandenen `ticket_writer.py` als Ticket erfassen; nicht in alte `USER-DECIDE-NEEDED.md`-Wrapper schreiben. Fremde Arbeit nie mitcommitten. @@ -44,31 +47,37 @@ und **3 github-maintain**. Die benannten alten Flags triage, review-merge und af bleiben als begrenzte Legacy-Abläufe erhalten; sie erscheinen nicht im neuen Dreiermenü. Eine Rollenwahl ist keine neue Schreib-, Merge- oder Startberechtigung. -Kategorien: **1 prs** = PR-Triage und unabhängige Reviews; **2 fixes** = konkrete -Sicherheits- und allgemeine Fehlerbehebungen; **3a policies** = Policies, Gates und -Privacy; **3b care** = konservative Repository-/Org-Pflege. Nach jeder abgeschlossenen -Arbeit und vor jeder neuen Arbeitseinheit Kategorie 1 frisch lesend prüfen. Ein +Arbeitskategorien und Reihenfolge: **prs** = PR-Triage und unabhängige Reviews; **issues** = +Issue-Triage; **fixes** = konkrete Sicherheits- und allgemeine Fehlerbehebungen; **policies** = +Policies, Gates und Privacy; **care** = konservative Repository-/Org-Pflege. Operator-Reihenfolge: +`prs` → `issues` → `fixes` → `policies`/`care`. Vor jeder neuen Arbeitseinheit PRs und Issues +frisch lesend prüfen. Ein unbekannter oder fehlgeschlagener Readback ist HOLD und niemals eine erfundene leere Liste. ### operator — vollständige Reihenfolge -Nimm genau eine belegte geeignete Einheit: 1 vor 2 vor 3. Bei erstmals leerer Kategorie 1/2 -bevorzuge 3a, sofern vorhanden; danach wechsle 3a und 3b auf Basis tatsächlich belegter -erfolgreicher Abschlüsse ab. Dringende 3a-Arbeit steht vor normaler Care. Fehlt die bevorzugte -geeignete Kategorie, ist die andere erlaubt. Dies ist eine dokumentierte AB-Heuristik, -kein eigenmächtig festgelegtes 2:1-Verhältnis und kein Freibrief für zusätzliche Aufgaben. +Bearbeite alle freigegebenen geeigneten Einheiten in der Reihenfolge `prs` → `issues` → `fixes` +→ `policies`/`care`. Der Selector liefert jeweils nur den nächsten Kandidaten; nach dessen +Abschluss oder einmalig dokumentiertem Blocker den Zustand frisch einlesen und die nächste +Einheit wählen. Beende den Lauf nicht nach einer Vorschau, einer Vorprüfung oder einem einzelnen +Kandidaten, solange im angeforderten Umfang weitere unabhängige Arbeit bereitsteht. Bei erstmals +leeren `prs`/`issues`/`fixes` bevorzuge `policies`, sofern vorhanden; danach wechsle Policies und +Care auf Basis belegter erfolgreicher Abschlüsse ab. Dringende Policies stehen vor normaler Care. +Dies ist eine dokumentierte AB-Heuristik und kein Freibrief für zusätzliche Aufgaben. -### bot-triage — ausschließlich Kategorie 1 +### bot-triage — PR- und Issue-Triage -Bearbeite PR-Triage und unabhängige Reviews. Fixes und Pflege an operator/github-maintain -mit einem belegten Hinweis übergeben; keine Kategorien 2 oder 3 selbst übernehmen. -Merges bleiben standardmäßig aus; alle untenstehenden unabhängigen Head-/CI-/Kontogates gelten. +Bearbeite PR- und Issue-Triage. Für Issues nur die aktivierten `issue.add_labels`/`issue.comment` +Aktionen und den konfigurierten Bot-Account verwenden. Formale unabhängige PR-Reviews nur über +`pr.review`, wenn diese Aktion in `effective_enabled` steht und `publish_reviews` aktiv ist. +Keine Fixes, Pflege oder Merges in diesem Modus; andere Arbeit mit frischem Beleg an operator/ +github-maintain übergeben. -### github-maintain — ausschließlich Kategorien 2 und 3 +### github-maintain — Fixes und Pflege -Konkrete Sicherheits-/allgemeine Fixes zuerst, dann 3a/3b mit derselben AB-Heuristik. -Kategorie 1 vor neuen Einheiten frisch lesen und dem Operator melden; sie wird durch -diese Rolle nicht selbst übernommen. Care verwendet weiter vorhandene Radar-/Claimwerkzeuge, +Konkrete Sicherheits-/allgemeine Fixes zuerst, dann Policies/Care mit derselben AB-Heuristik. +PRs und Issues vor neuen Einheiten frisch lesen und dem Operator melden; Triage, Reviews und +Merges werden durch diese Rolle nicht selbst übernommen. Care verwendet weiter vorhandene Radar-/Claimwerkzeuge, Policies und Fixes eigene enge Scope-Locks. Keine neue Queue, Profile, DB oder Dienste starten. ### Evidenz, Leerlauf und Wiederaufnahme @@ -76,12 +85,16 @@ Policies und Fixes eigene enge Scope-Locks. Keine neue Queue, Profile, DB oder D `githubbot-core --json multi-bot-select --input SNAPSHOT.json --mode operator` ist eine reine deterministische Auswahl vorhandener Kandidaten, ohne Auftragsanlage, Persistierung oder Start. Snapshot-Vertrag: candidates mit id, exakt 40-stelliger -lowercase Commit-revision, category prs/fixes/policies/care, status ready/blocked/done und +lowercase Commit-revision, category prs/issues/fixes/policies/care, status ready/blocked/done und optional urgent (bool). processed enthält belegte blocked/done-Einträge derselben id/revision/category plus nichtleere evidence-Referenz. lastMaintenance verweist nur mit id/revision auf einen solchen erfolgreichen policies-/care-Abschluss. Der Helper prüft Form und exakte Bindung; die tatsächliche Belegprüfung übernimmt der aufrufende Workflow. -Eine Auswahl, ein Fehler, HOLD oder Leerlauf ist kein Abschluss und verändert keine Historie. +Eine Auswahl, Vorschau, ein Fehler, HOLD oder Leerlauf ist kein Abschluss und verändert keine Historie. +Eine echte abgeschlossene Einheit braucht einen Erfolgsbeleg und unabhängigen Readback jeder +Mutation: Issue-Labels/-Kommentare, PR-Review, Fix-PR und Merge jeweils passend zur Aktion. +Nach Abschluss oder Blocker Kandidatenlisten neu laden; einen unveränderten Blocker nicht in +jeder Runde erneut bearbeiten, aber andere freigegebene Kandidaten weiterbearbeiten. Bereits belegte blocked/done-Kandidaten am gleichen exakten Head nicht immer wieder als neue Prio-1-Arbeit zurückholen. Neue Heads werden erneut geprüft; eine belegte Auflösung @@ -94,7 +107,7 @@ Wenn keine geeignete Einheit bereitsteht: 60 Sekunden Leerlauf-Cooldown, keine h Schleife oder neuen Modellstarts. STOP auch während des Wartens regelmäßig lokal prüfen und bei Fund geordnet abbrechen. Kontingent vor jeder neuen Einheit und nach Wartephasen frisch prüfen; fehlende Telemetrie sperrt neue Starts. Zwei leere Prüfungen drehen den -3a/3b-Zeiger nicht weiter. Nach Warten Kategorie 1 erneut frisch prüfen. +3a/3b-Zeiger nicht weiter. Nach Warten PR- und Issue-Listen erneut frisch prüfen. ## Begrenzte Legacy-Abläufe @@ -137,8 +150,8 @@ keinen Draft und `mergeable_state = clean`, prüft das Merge-Konto, merged sha-g Admin-Bypass und liest das Ergebnis zurück. Kein `gh pr merge`, kein `--admin`/`--auto`. Wo Branch-Schutz zusätzliche Approvals verlangt, diesen normal erfüllen. -Merges sind standardmäßig deaktiviert. Nur die ausdrücklich autorisierte Instanzkonfiguration -darf sie aktivieren (`multi_bot.merge_enabled = true` in `config/orgs.json` oder einem +Im neutralen Paketstandard sind Merges deaktiviert. Nur die ausdrücklich autorisierte +Instanzkonfiguration darf sie aktivieren (`multi_bot.merge_enabled = true` in `config/orgs.json` oder einem expliziten lokalen Instanzprompt). Das Zweitmodell nutzt das dort konfigurierte Merge-Konto (`multi_bot.merge_account`, alternativ `MULTIBOT_MERGE_ACCOUNT`); ohne Kontonamen HOLD. Token ausschließlich über die konfigurierte Laufzeitumgebung als `GH_TOKEN` für den @@ -207,11 +220,14 @@ und ersetzen weder Merge-Opt-in noch Identitätsprüfung. ab; angestoßen wird immer über GitHub. Bleibt ein Job länger als eine Runde `queued` oder „Waiting for a runner“, ist das HOLD (Runner offline?) mit Ticket, nie grün. Lokale Läufe auf dem Runner-Host sind Prüfbelege für Reviews, aber keine Checks; keine Commit-Status setzen. -- `publish_reviews` (bool): erlaubt Reviews, Review-Kommentare, Triage-Kommentare und Labels an - PRs und Issues der konfigurierten Owner. Inhalt: exakter Head, tatsächliches Modell, - Prüfumfang (gelesen/nicht gelesen), Testergebnis mit Befehl, Befunde mit Datei und Zeile. - Ein Beitrag pro (PR, Head); keine Secrets, keine lokalen Pfade. Ohne diesen Schalter bleibt - das Ergebnis im Bericht. +- `publish_reviews` (bool): erlaubt formale PR-Reviews über die getypte Aktion `pr.review` an + PRs der konfigurierten Owner. Der Review bindet sich an den exakten Head und wird als + GitHub-Review mit `COMMENT`, `APPROVE` oder `REQUEST_CHANGES` gelesen zurück. Inhalt: SHA, + tatsächliches Modell, Prüfumfang (gelesen/nicht gelesen), echte Testergebnisse und Befunde + mit Datei/Zeile. Ein Review pro (PR, Head); keine Secrets oder lokalen Pfade. Ohne Schalter + bleibt das Ergebnis im Bericht. `issue.comment` und `issue.add_labels` sind getrennte + Triage-Aktionen und brauchen ihre eigene effektive Core-Berechtigung sowie die konfigurierte + Account-Identität; ein Issue-Kommentar ist kein formaler PR-Review. - `open_fix_prs` (bool): erlaubt Fix-Branches zu pushen (Core-Aktion `git.push_branch`: nur Bot-Präfix `typed_actions.push_branch_prefix`, nie Default-Branch, nie Force) und PRs zu öffnen (`pr.create`, Base immer der Default-Branch). Ohne diesen Schalter bleiben Fixes lokal @@ -227,9 +243,12 @@ Care-/Probe-Helfer benutzen den ausdrücklich gewählten `--root`, standardmäß Verzeichnis. Ihre Dateikonventionen (`config/orgs.json`, `config/repo_registry.json`, Hostlogs) müssen als Instanzdaten vorhanden sein; bei fehlendem Bestand sperren sie die Auswahl. Die Core-Konfiguration kommt über wiederholbares `--config PFAD`. `multi_bot` enthält -Root, optionalen Instanzprompt, Merge-Konto, Merge-Opt-in, `policy`-Schalter und optional trusted_automation. +Root, optionalen Instanzprompt, konfigurierten Bot-/Merge-Account, Merge-Opt-in, `policy`-Schalter und optional trusted_automation. Die berechnete Core-Permission-Decke bleibt bindend; ein Rollenprompt erweitert sie nicht. Ohne Merge-Aktionsfähigkeit/Permission keine Merge-Mutation, auch bei aktivem Merge-Opt-in. -Jede getypte Aktion (`pr.merge`, `pr.ready`, `actions.dispatch`, `actions.rerun`, `pr.create`, -`git.push_branch`) braucht Obergrenze, `permissions.enabled` UND ihren `multi_bot`-Schalter; -der Startauftrag nennt das Ergebnis als `effective_enabled`. Nur diese Liste gilt. +Jede getypte Aktion (`pr.review`, `pr.merge`, `pr.ready`, `actions.dispatch`, `actions.rerun`, +`pr.create`, `git.push_branch`) braucht Obergrenze, `permissions.enabled` UND ihren +`multi_bot`-Schalter; der Startauftrag nennt das Ergebnis als `effective_enabled`. Issue-Triage +braucht ebenfalls `issue.add_labels`/`issue.comment` in `effective_enabled`; in einer MULTI-BOT- +Instanz prüfen diese Aktionen vor dem Schreiben das konfigurierte Konto und lesen die Änderung +zurück. Nur die tatsächlich ausgegebenen effektiven Berechtigungen gelten. diff --git a/src/githubbot_core/repo_scope.py b/src/githubbot_core/repo_scope.py new file mode 100644 index 0000000..e15145f --- /dev/null +++ b/src/githubbot_core/repo_scope.py @@ -0,0 +1,170 @@ +"""Runtime repository-scope checks for GitHub write actions. + +The portable MULTI-BOT root owns ``config/orgs.json``. This module reads that +canonical policy for each review action; it deliberately keeps no copy of the +organization or repository allow/deny lists. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +from typing import Optional + +_OWNER_OR_REPO = re.compile(r"[A-Za-z0-9_.-]+") +_FULL_REPO = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+") + + +class _PolicyError(ValueError): + pass + + +def _unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise _PolicyError(f"duplicate JSON key {key!r}") + result[key] = value + return result + + +def _normalize_exclusion(value: object, field: str) -> str: + if not isinstance(value, str) or value != value.strip() or not value: + raise _PolicyError(f"{field} entries must be non-empty strings without surrounding whitespace") + if value in (".", ".."): + raise _PolicyError(f"{field} contains an invalid repository target") + if "/" in value: + if not _FULL_REPO.fullmatch(value): + raise _PolicyError(f"{field} entries must be repository names or owner/repository") + owner, name = value.split("/", maxsplit=1) + if owner in (".", "..") or name in (".", ".."): + raise _PolicyError(f"{field} contains an invalid repository target") + elif not _OWNER_OR_REPO.fullmatch(value): + raise _PolicyError(f"{field} entries must be repository names or owner/repository") + return value.casefold() + + +def _string_list(value: object, field: str) -> list[str]: + if not isinstance(value, list): + raise _PolicyError(f"{field} must be a list") + result = [] + for item in value: + if not isinstance(item, str) or not item or item != item.strip(): + raise _PolicyError(f"{field} must contain non-empty strings without surrounding whitespace") + result.append(item.casefold()) + return result + + +def _policy_path(config: dict) -> Path: + settings = config.get("multi_bot") + if not isinstance(settings, dict): + raise _PolicyError("multi_bot must be an object for a formal PR review") + + raw_root = settings.get("root") + if raw_root is None or raw_root == "": + root = Path.cwd() + elif isinstance(raw_root, (str, os.PathLike)): + rendered = os.fspath(raw_root) + if not isinstance(rendered, str) or not rendered or rendered != rendered.strip(): + raise _PolicyError("multi_bot.root must be a non-empty path") + root = Path(rendered).expanduser() + else: + raise _PolicyError("multi_bot.root must be a path") + + try: + root = root.resolve(strict=True) + except (OSError, RuntimeError, ValueError) as exc: + raise _PolicyError("configured MULTI-BOT root is unavailable") from exc + if not root.is_dir(): + raise _PolicyError("configured MULTI-BOT root is not a directory") + return root / "config" / "orgs.json" + + +def _load_policy(path: Path) -> tuple[dict[str, dict], list[str]]: + try: + raw = path.read_text(encoding="utf-8-sig") + except (OSError, UnicodeError) as exc: + raise _PolicyError("required config/orgs.json is unavailable or unreadable") from exc + try: + document = json.loads(raw, object_pairs_hook=_unique_object) + except (json.JSONDecodeError, _PolicyError, RecursionError) as exc: + raise _PolicyError("required config/orgs.json is invalid JSON or contains duplicate keys") from exc + if not isinstance(document, dict): + raise _PolicyError("config/orgs.json must contain an object") + + raw_global = document.get("global_exclude_writes") + if "global_exclude_writes" not in document: + raise _PolicyError("config/orgs.json must define global_exclude_writes") + global_exclusions = [ + _normalize_exclusion(item, "global_exclude_writes") + for item in _string_list(raw_global, "global_exclude_writes") + ] + + raw_organizations = document.get("organizations") + if not isinstance(raw_organizations, list) or not raw_organizations: + raise _PolicyError("config/orgs.json organizations must be a non-empty list") + + organizations: dict[str, dict] = {} + for index, row in enumerate(raw_organizations): + prefix = f"organizations[{index}]" + if not isinstance(row, dict): + raise _PolicyError(f"{prefix} must be an object") + name = row.get("name") + if not isinstance(name, str) or not _OWNER_OR_REPO.fullmatch(name) or name in (".", ".."): + raise _PolicyError(f"{prefix}.name must be a GitHub namespace") + key = name.casefold() + if key in organizations: + raise _PolicyError(f"config/orgs.json contains duplicate namespace {name!r}") + namespace_type = row.get("type") + if namespace_type not in ("organization", "personal"): + raise _PolicyError(f"{prefix}.type must be organization or personal") + if type(row.get("enabled")) is not bool: + raise _PolicyError(f"{prefix}.enabled must be a boolean") + exclusions = [] + if "exclude_writes" in row: + exclusions = [ + _normalize_exclusion(item, f"{prefix}.exclude_writes") + for item in _string_list(row["exclude_writes"], f"{prefix}.exclude_writes") + ] + organizations[key] = { + "name": name, + "type": namespace_type, + "enabled": row["enabled"], + "exclude_writes": exclusions, + } + return organizations, global_exclusions + + +def review_write_scope_denial(repo: object, config: dict) -> Optional[str]: + """Return a fail-closed denial for a PR review outside the canonical scope.""" + if not isinstance(config, dict): + return "review write scope denied: action config must be an object" + if not isinstance(repo, str) or not _FULL_REPO.fullmatch(repo): + return "review write scope requires repo in owner/name form" + + try: + policy_path = _policy_path(config) + organizations, global_exclusions = _load_policy(policy_path) + except _PolicyError as exc: + return f"review write scope denied: {exc}" + + owner, name = repo.split("/", maxsplit=1) + if owner in (".", "..") or name in (".", ".."): + return "review write scope requires a GitHub owner and repository name" + owner_key, name_key = owner.casefold(), name.casefold() + full_key = f"{owner_key}/{name_key}" + if name_key in global_exclusions or full_key in global_exclusions: + return f"review write scope denies globally excluded repository {repo!r}" + + namespace = organizations.get(owner_key) + if namespace is None: + return f"review write scope denies unknown namespace {owner!r}" + if namespace["type"] != "organization": + return f"review write scope denies non-organization namespace {owner!r}" + if not namespace["enabled"]: + return f"review write scope denies disabled organization {owner!r}" + if name_key in namespace["exclude_writes"] or full_key in namespace["exclude_writes"]: + return f"review write scope denies organization-excluded repository {repo!r}" + return None diff --git a/src/githubbot_core/typed_actions.py b/src/githubbot_core/typed_actions.py index 92fc00d..91db4d1 100644 --- a/src/githubbot_core/typed_actions.py +++ b/src/githubbot_core/typed_actions.py @@ -1,11 +1,11 @@ -"""Narrow, typed write actions for pull requests, CI runs and bot branches. +"""Narrow, typed write actions for reviews, pull requests, CI runs and bot branches. ``api.request`` can only be scoped by endpoint prefix, which is far too coarse for -merges (``repos//`` would allow every write in every repository). These -actions each do exactly one thing, re-read the target before writing, refuse when -the reviewed head moved, and read the result back. They are in the default ceiling -but never in the default ``enabled`` list, and every kind is additionally gated by -its ``multi_bot`` switch, so a permission alone opens nothing. +reviews and merges (``repos//`` would allow every write in every repository). +These actions each do exactly one thing, re-read the target before writing, refuse +when the reviewed head moved, and read the result back. They are in the default +ceiling but never in the default ``enabled`` list, and every kind is additionally +gated by its ``multi_bot`` switch, so a permission alone opens nothing. """ from __future__ import annotations @@ -14,14 +14,29 @@ import subprocess from typing import Any, Optional +from .repo_scope import review_write_scope_denial + SHA = re.compile(r"[0-9a-f]{40}") REPO = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+") BRANCH = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]{0,199}") WORKFLOW = re.compile(r"[A-Za-z0-9_.-]+\.ya?ml|[0-9]+") MERGE_METHODS = ("merge", "squash", "rebase") +REVIEW_STATES = { + "COMMENT": "COMMENTED", + "APPROVE": "APPROVED", + "REQUEST_CHANGES": "CHANGES_REQUESTED", +} DEFAULT_PUSH_PREFIX = "bot/" -KINDS = ("pr.merge", "pr.ready", "actions.dispatch", "actions.rerun", "pr.create", "git.push_branch") +KINDS = ( + "pr.review", + "pr.merge", + "pr.ready", + "actions.dispatch", + "actions.rerun", + "pr.create", + "git.push_branch", +) _git = subprocess.run # replaced in tests @@ -42,6 +57,7 @@ def switch_denial(kind: str, config: dict) -> Optional[str]: except ValueError as exc: return f"multi_bot.policy invalid: {exc}" allowed = { + "pr.review": policy["publish_reviews"], "pr.merge": policy["merge_enabled"], "pr.ready": policy["draft_auto_ready_hours"] is not None or policy["draft_ready_when_mature"], "actions.dispatch": policy["trigger_missing_ci"], @@ -63,11 +79,16 @@ def param_denial(kind: str, params: dict, config: dict) -> Optional[str]: return None if not REPO.fullmatch(str(params.get("repo", ""))): return "repo must be owner/name" - need_sha = kind in ("pr.merge", "pr.ready", "actions.rerun", "git.push_branch") + need_sha = kind in ("pr.review", "pr.merge", "pr.ready", "actions.rerun", "git.push_branch") if need_sha and not SHA.fullmatch(str(params.get("sha", ""))): return "sha must be the exact reviewed 40-character lowercase commit" - if kind in ("pr.merge", "pr.ready") and not str(params.get("number", "")).isdecimal(): + if kind in ("pr.review", "pr.merge", "pr.ready") and not str(params.get("number", "")).isdecimal(): return "number must be a pull request number" + if kind == "pr.review": + if params.get("event") not in REVIEW_STATES: + return f"event must be one of {list(REVIEW_STATES)}" + if not isinstance(params.get("body"), str) or not params["body"].strip(): + return "a non-empty review body is required" if kind == "pr.merge" and params.get("method", "merge") not in MERGE_METHODS: return f"method must be one of {list(MERGE_METHODS)}" if kind == "actions.dispatch": @@ -104,6 +125,22 @@ def _pull(transport, repo: str, number) -> tuple[dict, Optional[str]]: return data, None +def _reviewable_pull(transport, repo: str, number, sha: str, account: str) -> tuple[dict, Optional[str]]: + """Require the exact open, non-draft head and a non-self author.""" + pull, error = _pull(transport, repo, number) + if error: + return {}, error + if pull.get("state") != "open": + return {}, "pull request is not open" + if (pull.get("head") or {}).get("sha") != sha: + return {}, "head moved since review (sha mismatch); nothing written" + if pull.get("draft") is not False: + return {}, "draft pull request or draft state is not explicitly false; do not submit a formal review" + if str((pull.get("user") or {}).get("login") or "").casefold() == account.casefold(): + return {}, "the configured review account authored this pull request" + return pull, None + + def _reviewed_pull(transport, params: dict, *, draft: bool) -> tuple[dict, Optional[str]]: """Fresh read: open, unstacked, exact reviewed head, expected draft state.""" repo, sha = params["repo"], params["sha"] @@ -129,6 +166,77 @@ def _reviewed_pull(transport, params: dict, *, draft: bool) -> tuple[dict, Optio ) +def exec_review(transport, params: dict, config: dict): + """Submit one formal review for the exact current head and confirm it by readback.""" + scope_error = review_write_scope_denial(params.get("repo"), config) + if scope_error: + return None, scope_error + account = str((config.get("multi_bot") or {}).get("merge_account") or "") + if not account: + return None, "multi_bot.merge_account is required for published pull request reviews" + user, error = transport.get("user") + if error or str((user or {}).get("login", "")).casefold() != account.casefold(): + return None, f"identity is not the configured review account {account!r}" + + repo, number, sha = params["repo"], int(params["number"]), params["sha"] + pull, error = _reviewable_pull(transport, repo, number, sha, account) + if error: + return None, error + + reviews, error = transport.get_paginated(f"repos/{repo}/pulls/{number}/reviews") + if error or not isinstance(reviews, list): + return None, f"existing reviews unreadable: {error or 'no data'}" + if any( + item.get("commit_id") == sha and str((item.get("user") or {}).get("login", "")).casefold() == account.casefold() + for item in reviews + if isinstance(item, dict) + ): + return None, "this account already submitted a review for this exact head; read it back instead of duplicating" + + pull, error = _reviewable_pull(transport, repo, number, sha, account) + if error: + return None, error + + event = params["event"] + endpoint = f"repos/{repo}/pulls/{number}/reviews" + body = {"commit_id": sha, "event": event, "body": params["body"]} + scope_error = review_write_scope_denial(repo, config) + if scope_error: + return None, scope_error + created, error = transport.request("POST", endpoint, body) + if error or not isinstance(created, dict) or not str(created.get("id", "")).isdecimal(): + return None, error or "review not confirmed by create response" + review_id = int(created["id"]) + + review, error = transport.get(f"{endpoint}/{review_id}") + if error or not isinstance(review, dict): + return None, f"review not confirmed by readback: {error or 'no data'}" + if ( + review.get("commit_id") != sha + or review.get("state") != REVIEW_STATES[event] + or str((review.get("user") or {}).get("login", "")).casefold() != account.casefold() + or review.get("body") != params["body"] + ): + return None, "review readback does not match the requested account, head or event" + after, error = _pull(transport, repo, number) + if ( + error + or after.get("state") != "open" + or (after.get("head") or {}).get("sha") != sha + or after.get("draft") is not False + ): + return None, "review submitted, but pull request state, head or draft changed before final readback" + return { + "readback": { + "review_id": review_id, + "event": event, + "state": review["state"], + "commit_id": sha, + "url": review.get("html_url"), + } + }, None + + def exec_merge(transport, params: dict, config: dict): account = str((config.get("multi_bot") or {}).get("merge_account") or "") if account: @@ -275,6 +383,7 @@ def exec_push_branch(transport, params: dict, config: dict): EXECUTORS: dict[str, Any] = { + "pr.review": exec_review, "pr.merge": exec_merge, "pr.ready": exec_ready, "actions.dispatch": exec_dispatch, diff --git a/tests/test_review_write_scope.py b/tests/test_review_write_scope.py new file mode 100644 index 0000000..26b94dd --- /dev/null +++ b/tests/test_review_write_scope.py @@ -0,0 +1,371 @@ +"""Formal review writes obey the portable MULTI-BOT organization policy.""" + +from __future__ import annotations + +import json + +import pytest + +from githubbot_core import actions, typed_actions +from githubbot_core.actions import Action +from githubbot_core.config import default_config, merge_layers + +SHA = "a" * 40 +OTHER_SHA = "b" * 40 +BOT = "example-bot" + + +def orgs_document(): + return { + "organizations": [ + { + "name": "research-line", + "type": "organization", + "enabled": True, + "features": ["triage", "duplicates", "traffic", "stars", "health"], + "exclude_writes": ["fst-nash"], + }, + { + "name": "dev-bricks", + "type": "organization", + "enabled": True, + "features": ["pr-review"], + "exclude_writes": ["automizer-for-claude-desktop"], + }, + {"name": "file-bricks", "type": "organization", "enabled": True, "features": ["pr-review"]}, + { + "name": "lukisch", + "type": "personal", + "enabled": True, + "features": ["pr-review"], + }, + ], + "global_exclude_writes": [".github"], + } + + +def write_orgs(root, document=None): + path = root / "config" / "orgs.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(document if document is not None else orgs_document()), encoding="utf-8") + return path + + +def review_config(root, *, enabled=True, publish_reviews=True): + layer = { + "permissions": {"enabled": ["pr.review"] if enabled else []}, + "multi_bot": { + "root": str(root), + "merge_account": BOT, + "policy": {"publish_reviews": publish_reviews}, + }, + } + return merge_layers([("default", default_config()), ("instance", layer)]) + + +def review_action(repo): + return Action( + "pr.review", + {"repo": repo, "number": 7, "sha": SHA, "event": "COMMENT", "body": "Independent review receipt."}, + ) + + +class FakeTransport: + """Offline transport that records every call and never opens a socket.""" + + def __init__(self, *, identity=BOT, head_sha=SHA, reviews=None, after_reviews=None, after_post_pull=None): + self.identity = identity + self.head_sha = head_sha + self.calls = [] + self.reviews = [] if reviews is None else reviews + self.after_reviews = after_reviews + self.after_post_pull = after_post_pull + self.review_posted = False + self.repo = "dev-bricks/githubbot-core" + self.pull = { + "state": "open", + "draft": False, + "head": {"sha": head_sha}, + "user": {"login": "different-author"}, + } + self.review = { + "id": 23, + "commit_id": SHA, + "state": "COMMENTED", + "body": "Independent review receipt.", + "user": {"login": BOT}, + "html_url": "https://example.invalid/review/23", + } + + def get(self, endpoint): + self.calls.append(("GET", endpoint)) + if endpoint == "user": + return {"login": self.identity}, None + if endpoint.endswith("/pulls/7"): + if self.review_posted and self.after_post_pull is not None: + self.pull.update(self.after_post_pull) + self.after_post_pull = None + return self.pull, None + if endpoint.endswith("/pulls/7/reviews/23"): + return self.review, None + return None, f"unexpected fake GET {endpoint}" + + def get_paginated(self, endpoint): + self.calls.append(("GET_PAGINATED", endpoint)) + if self.after_reviews is not None: + self.pull.update(self.after_reviews) + return self.reviews, None + + def request(self, method, endpoint, body=None): + self.calls.append((method, endpoint, body)) + if method == "POST" and endpoint.endswith("/pulls/7/reviews"): + self.review_posted = True + return {"id": "23"}, None + return None, f"unexpected fake write {method} {endpoint}" + + +class PolicyChangingTransport(FakeTransport): + def __init__(self, root): + super().__init__() + self.root = root + + def get_paginated(self, endpoint): + self.calls.append(("GET_PAGINATED", endpoint)) + document = orgs_document() + document["organizations"][1]["exclude_writes"].append("dev-bricks/githubbot-core") + write_orgs(self.root, document) + return [], None + + +@pytest.mark.parametrize( + ("repo", "message"), + [ + ("RESEARCH-LINE/FST-NASH", "organization-excluded"), + ("DEV-BRICKS/.GITHUB", "globally excluded"), + ("dev-bricks/automizer-for-claude-desktop", "organization-excluded"), + ], +) +def test_configured_exclusions_block_preview_apply_direct_execution_and_replay(tmp_path, repo, message): + write_orgs(tmp_path) + config = review_config(tmp_path) + action = review_action(repo) + transport = FakeTransport() + + preview = actions.preview([action], config) + assert preview["actions"][0]["permitted"] is False + assert message in preview["actions"][0]["denied_reason"] + + receipt_store = tmp_path / "receipts.jsonl" + receipt_store.write_text(json.dumps({"id": action.action_id, "status": "applied"}) + "\n", encoding="utf-8") + applied = actions.apply([action], transport, config, receipt_store=receipt_store) + assert applied["receipts"][0]["status"] == "denied" + assert message in applied["receipts"][0]["detail"] + assert transport.calls == [] + + _, direct_error = typed_actions.exec_review(transport, action.params, config) + assert direct_error and message in direct_error + assert transport.calls == [] + + +def test_configured_org_without_legacy_pr_review_feature_can_submit_via_fake_transport(tmp_path): + write_orgs(tmp_path) + config = review_config(tmp_path) + action = review_action("research-line/unexcluded-control") + transport = FakeTransport(reviews=[{"commit_id": SHA, "state": "DISMISSED", "user": {"login": "different-bot"}}]) + + assert actions.preview([action], config)["actions"][0]["permitted"] is True + result = actions.apply([action], transport, config) + + assert result["receipts"][0]["status"] == "applied" + assert result["receipts"][0]["readback"]["commit_id"] == SHA + assert [call for call in transport.calls if call[0] == "POST"] == [ + ( + "POST", + "repos/research-line/unexcluded-control/pulls/7/reviews", + {"commit_id": SHA, "event": "COMMENT", "body": "Independent review receipt."}, + ) + ] + + +@pytest.mark.parametrize( + ("change", "detail"), + [ + ({"head": {"sha": OTHER_SHA}}, "head moved"), + ({"draft": True}, "draft pull request"), + ({"state": "closed"}, "not open"), + ], +) +def test_review_rechecks_current_pull_after_reviews_before_post(tmp_path, change, detail): + write_orgs(tmp_path) + config = review_config(tmp_path) + transport = FakeTransport(after_reviews=change) + + result = actions.apply([review_action("dev-bricks/githubbot-core")], transport, config) + + assert result["receipts"][0]["status"] == "failed" + assert detail in result["receipts"][0]["detail"] + pull_reads = [ + index for index, call in enumerate(transport.calls) if call[0] == "GET" and call[1].endswith("/pulls/7") + ] + reviews_read = next(index for index, call in enumerate(transport.calls) if call[0] == "GET_PAGINATED") + assert len(pull_reads) == 2 + assert pull_reads[0] < reviews_read < pull_reads[1] + assert not [call for call in transport.calls if call[0] == "POST"] + + +@pytest.mark.parametrize("state", ["DISMISSED", "COMMENTED", "APPROVED", "CHANGES_REQUESTED", "PENDING"]) +def test_any_existing_bot_review_for_same_head_prevents_repost(tmp_path, state): + write_orgs(tmp_path) + config = review_config(tmp_path) + transport = FakeTransport(reviews=[{"commit_id": SHA, "state": state, "user": {"login": BOT}}]) + + result = actions.apply([review_action("dev-bricks/githubbot-core")], transport, config) + + assert result["receipts"][0]["status"] == "failed" + assert "already submitted a review" in result["receipts"][0]["detail"] + assert not [call for call in transport.calls if call[0] == "POST"] + + +@pytest.mark.parametrize( + "change", + [ + {"draft": True}, + {"state": "closed"}, + {"head": {"sha": OTHER_SHA}}, + ], +) +def test_review_final_readback_requires_open_non_draft_exact_head(tmp_path, change): + write_orgs(tmp_path) + config = review_config(tmp_path) + transport = FakeTransport(after_post_pull=change) + + result = actions.apply([review_action("dev-bricks/githubbot-core")], transport, config) + + assert result["receipts"][0]["status"] == "failed" + assert "state, head or draft" in result["receipts"][0]["detail"] + assert len([call for call in transport.calls if call[0] == "POST"]) == 1 + + +def test_full_owner_repo_exclusion_is_enforced_and_absent_org_exclusions_are_valid(tmp_path): + document = orgs_document() + document["organizations"][1]["exclude_writes"].append("dev-bricks/githubbot-core") + write_orgs(tmp_path, document) + config = review_config(tmp_path) + + assert "organization-excluded" in actions.denial_reason(review_action("dev-bricks/githubbot-core"), config) + assert actions.denial_reason(review_action("file-bricks/unexcluded-control"), config) is None + + +def test_scope_is_reloaded_immediately_before_review_post(tmp_path): + write_orgs(tmp_path) + config = review_config(tmp_path) + transport = PolicyChangingTransport(tmp_path) + + result = actions.apply([review_action("dev-bricks/githubbot-core")], transport, config) + + assert result["receipts"][0]["status"] == "failed" + assert "organization-excluded" in result["receipts"][0]["detail"] + assert not [call for call in transport.calls if call[0] == "POST"] + + +@pytest.mark.parametrize( + ("repo", "mutate", "message"), + [ + ("unknown-org/repo", None, "unknown namespace"), + ("lukisch/repo", None, "non-organization"), + ("dev-bricks/repo", lambda doc: doc["organizations"][1].update(enabled=False), "disabled organization"), + ], +) +def test_noneligible_namespace_denies_without_transport_calls(tmp_path, repo, mutate, message): + document = orgs_document() + if mutate: + mutate(document) + write_orgs(tmp_path, document) + config = review_config(tmp_path) + transport = FakeTransport() + + denial = actions.denial_reason(review_action(repo), config) + + assert denial and message in denial + assert actions.apply([review_action(repo)], transport, config)["receipts"][0]["status"] == "denied" + assert transport.calls == [] + + +@pytest.mark.parametrize( + "document_text", + [ + None, + "{not-json", + json.dumps({"organizations": orgs_document()["organizations"]}), + json.dumps({"organizations": "dev-bricks", "global_exclude_writes": []}), + json.dumps({"organizations": [], "global_exclude_writes": []}), + json.dumps({"organizations": orgs_document()["organizations"], "global_exclude_writes": ".github"}), + ], +) +def test_missing_or_invalid_required_policy_fails_closed(tmp_path, document_text): + if document_text is not None: + path = tmp_path / "config" / "orgs.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(document_text, encoding="utf-8") + config = review_config(tmp_path) + transport = FakeTransport() + + denial = actions.denial_reason(review_action("dev-bricks/githubbot-core"), config) + + assert denial and "review write scope denied" in denial + assert ( + actions.apply([review_action("dev-bricks/githubbot-core")], transport, config)["receipts"][0]["status"] + == "denied" + ) + assert transport.calls == [] + + +def test_duplicate_json_keys_fail_closed(tmp_path): + path = tmp_path / "config" / "orgs.json" + path.parent.mkdir(parents=True) + path.write_text('{"global_exclude_writes": [], "global_exclude_writes": [".github"]}', encoding="utf-8") + config = review_config(tmp_path) + + denial = actions.denial_reason(review_action("dev-bricks/githubbot-core"), config) + + assert denial and "duplicate keys" in denial + + +def test_runtime_root_defaults_to_current_directory_when_not_configured(tmp_path, monkeypatch): + write_orgs(tmp_path) + monkeypatch.chdir(tmp_path) + config = review_config(tmp_path) + config["multi_bot"].pop("root") + + assert "organization-excluded" in actions.denial_reason(review_action("research-line/fst-nash"), config) + + +def test_permission_and_publish_switch_remain_independent_of_scope(tmp_path): + write_orgs(tmp_path) + action = review_action("dev-bricks/githubbot-core") + no_permission = review_config(tmp_path, enabled=False, publish_reviews=True) + switch_off = review_config(tmp_path, enabled=True, publish_reviews=False) + transport = FakeTransport() + + assert "not enabled" in actions.denial_reason(action, no_permission) + assert "switched off" in actions.denial_reason(action, switch_off) + assert transport.calls == [] + + # Publishing reviews does not turn on the distinct merge switch. + assert "switched off" in typed_actions.switch_denial("pr.merge", review_config(tmp_path)) + + +@pytest.mark.parametrize( + ("identity", "head_sha", "expected"), + [("someone-else", SHA, "identity"), (BOT, OTHER_SHA, "head moved")], +) +def test_identity_and_exact_head_guards_still_precede_review_post(tmp_path, identity, head_sha, expected): + write_orgs(tmp_path) + config = review_config(tmp_path) + transport = FakeTransport(identity=identity, head_sha=head_sha) + + result = actions.apply([review_action("dev-bricks/githubbot-core")], transport, config) + + assert result["receipts"][0]["status"] == "failed" + assert expected in result["receipts"][0]["detail"] + assert not [call for call in transport.calls if call[0] == "POST"]