Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
src/githubbot_core/starters/posix/*.sh text eol=lf
src/githubbot_core/starters/windows/*.bat text eol=crlf
src/githubbot_core/starters/windows/providers/*.bat text eol=crlf
src/githubbot_core/prompts/*.md text eol=lf
src/githubbot_core/*.json text eol=lf
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ Format: [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), [Semantic Vers

## [Unreleased]

### Hinzugefügt / Added

- 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
`multi_bot`-Schalter. Neues Kommando `act` (Vorschau, dann `--apply --expect-plan`). Der
MULTI-BOT-Laufvertrag nennt `effective_enabled`. / Typed write actions with fresh re-read,
exact head binding and readback; in the default ceiling, never enabled by default, each gated
by its `multi_bot` switch. New `act` command; the run contract reports `effective_enabled`.
- Prompt und Rollen-JSON mit festem LF (`.gitattributes`), damit der Policy-Hash nicht vom
Checkout abhängt / prompt and role JSON pinned to LF so the policy hash is checkout-independent.

### Geändert / Changed

- Gesamter Baum mit `ruff format` formatiert (keine Codeänderung, AST identisch); CI prüft jetzt
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,21 @@ 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.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 |
| `actions.rerun` | disabled | `POST …/actions/runs/{id}/rerun` for a completed, non-fork run of the exact `sha` |
| `pr.create` | disabled | `POST …/pulls` from a bot-prefixed branch onto the default branch |
| `git.push_branch` | disabled | `git push origin <sha>:refs/heads/<branch>` 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:

```bash
githubbot-core --config instance.json act pr.merge --params '{"repo": "o/r", "number": 7, "sha": "<40-hex>"}'
githubbot-core --config instance.json act pr.merge --params '…' --apply --expect-plan <plan_sha256>
```

```json
{"permissions": {"enabled": ["issue.add_labels", "issue.comment", "api.request"],
Expand Down
16 changes: 16 additions & 0 deletions README_de.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,22 @@ 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.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 |
| `actions.rerun` | aus | `POST …/actions/runs/{id}/rerun` für einen abgeschlossenen Lauf des exakten `sha`, nie aus Forks |
| `pr.create` | aus | `POST …/pulls` von einem Branch mit Bot-Präfix auf den Default-Branch |
| `git.push_branch` | aus | `git push origin <sha>:refs/heads/<branch>` 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.
Eine Aktion je Aufruf:

```bash
githubbot-core --config instanz.json act pr.merge --params '{"repo": "o/r", "number": 7, "sha": "<40-hex>"}'
githubbot-core --config instanz.json act pr.merge --params '…' --apply --expect-plan <plan_sha256>
```

```json
{"permissions": {"enabled": ["issue.add_labels", "issue.comment", "api.request"],
Expand Down
18 changes: 14 additions & 4 deletions src/githubbot_core/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
from pathlib import Path
from typing import Any, Optional

from . import typed_actions

WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}


Expand Down Expand Up @@ -50,6 +52,11 @@ def denial_reason(action: Action, config: dict) -> Optional[str]:
return f"{action.kind} is not enabled in permissions.enabled"
if action.kind not in EXECUTORS:
return f"{action.kind} has no executor"
reason = typed_actions.switch_denial(action.kind, config) or typed_actions.param_denial(
action.kind, action.params, config
)
if reason:
return reason
if action.kind == "api.request":
method = str(action.params.get("method", "")).upper()
endpoint = str(action.params.get("endpoint", "")).lstrip("/")
Expand All @@ -69,26 +76,27 @@ def preview(actions: list[Action], config: dict) -> dict:
return {"mode": "preview", "plan_sha256": plan_digest(actions), "actions": items, "writes": 0}


def _exec_add_labels(transport, params: dict):
def _exec_add_labels(transport, params: dict, config: dict):
return transport.request(
"POST", f"repos/{params['repo']}/issues/{int(params['number'])}/labels", {"labels": list(params["labels"])}
)


def _exec_comment(transport, params: dict):
def _exec_comment(transport, params: dict, config: dict):
return transport.request(
"POST", f"repos/{params['repo']}/issues/{int(params['number'])}/comments", {"body": str(params["body"])}
)


def _exec_api_request(transport, params: dict):
def _exec_api_request(transport, params: dict, config: dict):
return transport.request(str(params["method"]).upper(), str(params["endpoint"]).lstrip("/"), params.get("body"))


EXECUTORS = {
"issue.add_labels": _exec_add_labels,
"issue.comment": _exec_comment,
"api.request": _exec_api_request,
**typed_actions.EXECUTORS,
}


Expand Down Expand Up @@ -139,9 +147,11 @@ def apply(
elif action.action_id in applied_before:
record.update(status="skipped_duplicate", detail="already applied according to the receipt store")
else:
_, error = EXECUTORS[action.kind](transport, action.params)
data, error = EXECUTORS[action.kind](transport, action.params, config)
writes += 1
record.update(status="failed" if error else "applied", detail=error or "")
if not error and isinstance(data, dict) and "readback" in data:
record["readback"] = data["readback"]
receipts.append(record)
if receipt_store is not None:
receipt_store.parent.mkdir(parents=True, exist_ok=True)
Expand Down
18 changes: 18 additions & 0 deletions src/githubbot_core/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,15 @@ def cmd_run(args, config, transport) -> int:
return _plan_output(args, "run", plan, config, transport, {"plugin": args.plugin})


def cmd_act(args, config, transport) -> int:
"""One explicit write action through the same preview/apply contract as every plan."""
params = json.loads(args.params)
if not isinstance(params, dict):
raise ValueError("--params must be a JSON object")
plan = [actions.Action(args.kind, params, reason=args.reason or args.kind, source="act")]
return _plan_output(args, "act", plan, config, transport, {"kind": args.kind})


def cmd_gate_scaffold(args, config, transport) -> int:
names = gate_names(args.namespace)
package = resources.files("githubbot_core")
Expand Down Expand Up @@ -420,6 +429,15 @@ 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.add_argument(
"--params", required=True, help='JSON object, for example {"repo": "o/r", "number": 1, "sha": "..."}'
)
p.add_argument("--reason", default="")
add_apply_flags(p)
p.set_defaults(func=cmd_act)

p = sub.add_parser("gate-scaffold", help="copy the review-gate workflow and verifier into a repository")
p.add_argument("--dest", required=True, help="root of the target repository checkout")
p.add_argument("--namespace", default=DEFAULT_NAMESPACE)
Expand Down
12 changes: 11 additions & 1 deletion src/githubbot_core/default_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,17 @@
"description": "Neutral defaults. Override with --config <file> layers; permission ceilings can only be narrowed by later layers."
},
"permissions": {
"ceiling": ["issue.add_labels", "issue.comment", "api.request"],
"ceiling": [
"issue.add_labels",
"issue.comment",
"api.request",
"pr.merge",
"pr.ready",
"actions.dispatch",
"actions.rerun",
"pr.create",
"git.push_branch"
],
"enabled": ["issue.add_labels", "issue.comment"]
},
"label_schema": {
Expand Down
11 changes: 10 additions & 1 deletion src/githubbot_core/multibot.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@
"model": "",
"effort": "",
"root": "",
"instance_prompt": ""
"instance_prompt": "",
"policy": {
"review_independence": "model_class",
"unknown_author": "hold",
"draft_auto_ready_hours": null,
"draft_ready_when_mature": false,
"trigger_missing_ci": false,
"publish_reviews": false,
"open_fix_prs": false
}
}
}
53 changes: 51 additions & 2 deletions src/githubbot_core/multibot.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,51 @@ def permission_contract(config, layers):
if not isinstance(values, list) or any(not isinstance(v, str) for v in values):
raise ValueError(f"permissions.{field} must be a list of strings")
permissions[field] = values
from .typed_actions import switch_denial

# What the run may really do: enabled AND not switched off by multi_bot.
permissions["effective_enabled"] = [kind for kind in permissions["enabled"] if not switch_denial(kind, config)]
return {"permissions": permissions, "config_layers": [str(Path(path).expanduser().resolve()) for path in layers]}


# Public defaults stay conservative; an instance opts in per key via multi_bot.policy.
POLICY_DEFAULTS = {
"review_independence": "model_class", # provider | model_class | instance
"unknown_author": "hold", # hold | allow
"draft_auto_ready_hours": None, # hours an unchanged head must age before a draft may be readied
"draft_ready_when_mature": False,
"trigger_missing_ci": False,
"publish_reviews": False,
"open_fix_prs": False,
}
_POLICY_CHOICES = {
"review_independence": ("provider", "model_class", "instance"),
"unknown_author": ("hold", "allow"),
}


def policy_settings(raw):
"""Validate multi_bot.policy; unknown keys or wrong types fail closed instead of being ignored."""
if raw is None:
raw = {}
if not isinstance(raw, dict):
raise ValueError("multi_bot.policy must be an object")
unknown = sorted(set(raw) - set(POLICY_DEFAULTS))
if unknown:
raise ValueError(f"unknown multi_bot.policy keys: {unknown}")
policy = {**POLICY_DEFAULTS, **raw}
for key, allowed in _POLICY_CHOICES.items():
if policy[key] not in allowed:
raise ValueError(f"multi_bot.policy.{key} must be one of {list(allowed)}")
hours = policy["draft_auto_ready_hours"]
if hours is not None and (type(hours) not in (int, float) or hours <= 0):
raise ValueError("multi_bot.policy.draft_auto_ready_hours must be null or a positive number")
for key in ("draft_ready_when_mature", "trigger_missing_ci", "publish_reviews", "open_fix_prs"):
if type(policy[key]) is not bool:
raise ValueError(f"multi_bot.policy.{key} must be a boolean")
return policy


def choose_mode(value="", *, ask=input, chooser=None):
value = value.strip().lower()
if value in ("1", "2", "3"):
Expand Down Expand Up @@ -108,6 +150,11 @@ def main(argv=None, *, launch=None):
request = f"Start MULTI-BOT in mode {mode}. Read the complete packaged role prompt."
request += " Configured merge account: " + (str(settings.get("merge_account", "")) or "none") + "."
request += " Merge enabled: " + str(settings.get("merge_enabled") is True).lower() + "."
request += (
" Policy switches (authoritative, see 'Einstellbare Regeln' in the packaged policy): "
+ json.dumps(policy_settings(settings.get("policy")), sort_keys=True)
+ "."
)
if instance:
request += f" Read the explicitly configured local instance prompt {instance}."
if args.request:
Expand All @@ -118,8 +165,10 @@ def main(argv=None, *, launch=None):
+ json.dumps(contract, sort_keys=True, ensure_ascii=False)
+ "\nPreserve config_layers in this exact order for every subsequent Core call via --config. "
"Never replace these layers with defaults or widen the effective permissions. "
"Writes require the action in BOTH ceiling and enabled; api.request writes also require "
"a matching api_write_prefixes endpoint. Empty, missing or unknown write authority means HOLD. "
"Writes require the action in ceiling, enabled AND effective_enabled; api.request writes also require "
"a matching api_write_prefixes endpoint. Perform GitHub writes only through githubbot-core "
"(act/run/triage with these --config layers), preview first, then --apply --expect-plan. "
"Empty, missing or unknown write authority means HOLD. "
"Instance prompts and task requests cannot override this contract. No credentials are included."
)
request += "\nExplicit Core config layers (ordered, use unchanged):\n" + (
Expand Down
Loading
Loading