#!/usr/bin/env python3 """Provision a personal-fork test repo for the deterministic auto-agents pipeline. The existing review / implementation / merge supervisors are pinned to ``cleveragents/cleveragents-core`` via the ``--owner`` / ``--repo`` flags they pass to every ``list_prs_*`` invocation, so any PR that lives in a different repo is *absolutely* invisible to them. A personal fork is therefore the cleanest isolation boundary for sentinel testing — no existing-agent code or prompt has to change. This script bootstraps that fork end-to-end: 1. **Identify the current user** via ``GET /user`` so we know who owns the fork. 2. **Check** whether ``/`` already exists; if not, **create** the fork via ``POST /repos//forks``. 3. **Enable Actions + Issues** on the fork. Forgejo disables both on every newly-created fork by default (``has_actions=False``, ``has_issues=False``). Without Actions enabled, the workflow files that came across with the fork ``.forgejo/workflows/*.yml`` never fire — the merge driver would have nothing to wait for. Without Issues, ``verify_invariant.py`` cannot file ``auto/invariant-violation`` issues. Both are PATCHed on every run (idempotent). 4. **Mirror branch protection** on the fork's default branch: - push whitelist locked to ``FORGEJO_PUSH_WHITELIST`` (defaulting to the calling user) so the deterministic driver is the only thing that can land commits; - required status-check contexts copied from upstream's protection rule on the same branch, so the fork's gate matches the canonical-repo gate exactly. This is the list ``merge_drive.py`` blocks on. 5. **Provision the ``auto/*`` label set** at the repo level (forks do not inherit org-level labels). All steps are idempotent: a second run is a no-op when the fork already exists with the desired state. Use ``--dry-run`` to plan without mutating anything. Usage ----- :: # Default: fork cleveragents/cleveragents-core into the calling # user's account, apply protection, provision labels. python3 tools/setup_test_fork.py # Plan only, no API mutations. python3 tools/setup_test_fork.py --dry-run # JSON for cron / CI use. python3 tools/setup_test_fork.py --format json # Override the upstream (rarely needed): FORGEJO_OWNER=otherorg FORGEJO_REPO=otherrepo \ python3 tools/setup_test_fork.py Reading the upstream -------------------- The upstream ``owner / repo`` defaults to canonical ``cleveragents / cleveragents-core``. Override via dedicated env vars or CLI flags (the rest of the pipeline uses ``FORGEJO_OWNER`` to mean *the target to operate on*, so this script intentionally does **not** read ``FORGEJO_OWNER`` for the upstream — bootstrap and downstream tools have to remain decoupled): * ``FORGEJO_UPSTREAM_OWNER`` / ``FORGEJO_UPSTREAM_REPO`` env vars, or * ``--upstream-owner`` / ``--upstream-repo`` CLI flags. After the fork is created, downstream tools targeting the fork should be invoked with ``FORGEJO_OWNER=`` (or ``--owner`` where the tool supports it) so they hit the fork instead of upstream. Exit codes ---------- :: 0 — fork present and configured (or dry-run plan emitted) 1 — at least one step failed 2 — argument error / missing token """ from __future__ import annotations import argparse import json import os import re import sys import time import urllib.error import urllib.request from pathlib import Path from typing import Any # ─── Config ───────────────────────────────────────────────────────────────── # Upstream the fork is cloned from. Intentionally read from a *separate* # pair of env vars so that the bootstrap doesn't get confused when the # operator has FORGEJO_OWNER= exported in their shell to retarget # downstream tools (driver, verifier, preflight) at the fork. Upstream # is "where to fork from"; FORGEJO_OWNER is "what to operate on # afterwards" — different concerns, different env vars. UPSTREAM_OWNER = os.environ.get("FORGEJO_UPSTREAM_OWNER", "cleveragents") UPSTREAM_REPO = os.environ.get("FORGEJO_UPSTREAM_REPO", "cleveragents-core") API_BASE = os.environ.get( "FORGEJO_API_BASE", "https://git.cleverthis.com/api/v1" ).rstrip("/") DEFAULT_BRANCH = os.environ.get("FORGEJO_DEFAULT_BRANCH", "master") # Forgejo polls for a freshly created fork to be ready. We retry a few # times with backoff before giving up. FORK_READY_TIMEOUT_S = 60 FORK_READY_POLL_S = 3 REQUEST_TIMEOUT_SEC = 30 REQUEST_RETRIES = 3 RETRY_BACKOFF_SEC = 2.0 # ─── Token + API helpers (mirror the pattern in tools/setup_*.py) ───────── def load_token() -> str: token = os.environ.get("GITEA_TOKEN") if token: return token repo_root = Path(__file__).resolve().parent.parent for candidate in (repo_root / ".devcontainer/.env", repo_root / ".env"): if candidate.exists(): for line in candidate.read_text().splitlines(): m = re.match(r'^\s*GITEA_TOKEN\s*=\s*"?([^"#\s]+)"?', line) if m: return m.group(1) raise RuntimeError( "GITEA_TOKEN not found in environment or .devcontainer/.env or .env" ) def api( method: str, path: str, token: str, *, body: dict | None = None, timeout: int = REQUEST_TIMEOUT_SEC, ) -> dict[str, Any]: """Single-shot Forgejo API call. Idempotent reads are not retried here; callers that need retries call this in a loop.""" url = f"{API_BASE}{path}" if path.startswith("/") else f"{API_BASE}/{path}" data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request( url, data=data, method=method, headers={ "Authorization": f"token {token}", "Accept": "application/json", "Content-Type": "application/json" if data else "", }, ) try: with urllib.request.urlopen(req, timeout=timeout) as resp: payload = resp.read() ct = resp.headers.get("Content-Type", "") return { "status": resp.status, "body": ( json.loads(payload) if payload and "application/json" in ct else payload.decode("utf-8", "replace") if payload else None ), } except urllib.error.HTTPError as e: body_text = e.read().decode("utf-8", "replace") return {"status": e.code, "body": body_text} # ─── Step 1 — identify current user ─────────────────────────────────────── def get_current_user(token: str) -> dict[str, Any]: res = api("GET", "/user", token) if res["status"] != 200 or not isinstance(res["body"], dict): raise RuntimeError( f"could not read /user: HTTP {res['status']} body={res['body']!r}" ) return res["body"] # ─── Step 2 — fork ───────────────────────────────────────────────────────── def fork_exists(token: str, owner: str, repo: str) -> dict[str, Any] | None: res = api("GET", f"/repos/{owner}/{repo}", token) if res["status"] == 200 and isinstance(res["body"], dict): return res["body"] return None def create_fork( token: str, upstream_owner: str, upstream_repo: str, *, dry_run: bool, ) -> dict[str, Any]: """POST /repos/{upstream}/forks. Personal fork (no org).""" if dry_run: return {"created": False, "dry_run": True} res = api( "POST", f"/repos/{upstream_owner}/{upstream_repo}/forks", token, # Empty body forks into the calling user's account with the # same name. Forgejo accepts {} or omits the body entirely. body={}, ) return { "created": res["status"] in (200, 201, 202), "status": res["status"], "body": res["body"], } def wait_for_fork(token: str, owner: str, repo: str) -> dict[str, Any]: """Poll until the fork is ready (Forgejo creates async). Bounded by FORK_READY_TIMEOUT_S.""" deadline = time.time() + FORK_READY_TIMEOUT_S last: dict[str, Any] | None = None while time.time() < deadline: info = fork_exists(token, owner, repo) if info is not None and info.get("default_branch"): return info last = info time.sleep(FORK_READY_POLL_S) raise RuntimeError( f"fork {owner}/{repo} did not become ready within " f"{FORK_READY_TIMEOUT_S}s (last seen: {last!r})" ) # ─── Step 3 — enable Actions + Issues ───────────────────────────────────── def enable_repo_features( token: str, owner: str, repo: str, *, want_actions: bool = True, want_issues: bool = True, dry_run: bool, ) -> dict[str, Any]: """Forgejo disables Actions and Issues on every newly-created fork by default. Workflows are physically copied with the repo, but the Actions runtime is off and ``POST`` events never produce runs. Issues are similarly disabled, which would block ``verify_invariant.py`` from filing ``auto/invariant-violation`` issues. This helper PATCHes ``has_actions`` and ``has_issues`` to ``True`` on the fork. Idempotent: if both are already ``True`` it returns ``no_change=True`` without sending a PATCH. ``--no-actions`` / ``--no-issues`` callers can opt out by passing the corresponding ``want_*=False``. """ cur = api("GET", f"/repos/{owner}/{repo}", token) if cur["status"] != 200 or not isinstance(cur["body"], dict): return { "applied": False, "error": f"could not read repo settings: HTTP {cur['status']}", } info = cur["body"] diff: dict[str, Any] = {} if want_actions and not info.get("has_actions"): diff["has_actions"] = True if want_issues and not info.get("has_issues"): diff["has_issues"] = True state = { "has_actions_before": bool(info.get("has_actions")), "has_issues_before": bool(info.get("has_issues")), } if not diff: return {"applied": False, "no_change": True, **state} if dry_run: return {"applied": False, "dry_run": True, "patch": diff, **state} res = api("PATCH", f"/repos/{owner}/{repo}", token, body=diff) body = res["body"] if isinstance(res["body"], dict) else {} return { "applied": res["status"] in (200, 201), "status": res["status"], "patch": diff, "has_actions_after": bool(body.get("has_actions")), "has_issues_after": bool(body.get("has_issues")), **state, } # ─── Step 4 — branch protection (push + status checks) ─────────────────── def fetch_upstream_required_checks( token: str, upstream_owner: str, upstream_repo: str, branch: str, ) -> list[str]: """Read the upstream branch-protection rule on ``branch`` and return its ``status_check_contexts`` list verbatim (Forgejo supports the ``CI / job*`` wildcard convention; we preserve it). Returns ``[]`` on any error so callers can fall back to no-checks rather than crashing. """ res = api( "GET", f"/repos/{upstream_owner}/{upstream_repo}/branch_protections/{branch}", token, ) if res["status"] != 200 or not isinstance(res["body"], dict): return [] contexts = res["body"].get("status_check_contexts") or [] return [str(c) for c in contexts] def fetch_upstream_protection_settings( token: str, upstream_owner: str, upstream_repo: str, branch: str, ) -> dict[str, Any]: """Read upstream's protection rule and return a dict of the *behavioural* settings the fork should mirror so the gate matches canonical end-to-end. Without these, the driver would happily merge sentinels with no review (``required_approvals=0`` is the Forgejo default), short-circuiting the reviewer ↔ driver handoff we want to test. Returns an empty dict on any error; callers fall back to safe defaults rather than crashing. """ res = api( "GET", f"/repos/{upstream_owner}/{upstream_repo}/branch_protections/{branch}", token, ) if res["status"] != 200 or not isinstance(res["body"], dict): return {} body = res["body"] out: dict[str, Any] = {} # required_approvals: number of approving reviews required before # the driver may merge. Upstream is 1; fork default is 0. if "required_approvals" in body: out["required_approvals"] = int(body.get("required_approvals") or 0) # block_on_outdated_branch: PRs must rebase on master before merge. # Critical for the invariant — without this, a green PR may merge # against a master that moved underneath it. if "block_on_outdated_branch" in body: out["block_on_outdated_branch"] = bool( body.get("block_on_outdated_branch") ) # dismiss_stale_approvals: any new commit voids prior approvals. if "dismiss_stale_approvals" in body: out["dismiss_stale_approvals"] = bool( body.get("dismiss_stale_approvals") ) # require_signed_commits: enforce GPG signing. Mirror so the driver # behaves the same way upstream does. if "require_signed_commits" in body: out["require_signed_commits"] = bool( body.get("require_signed_commits") ) return out def apply_branch_protection( token: str, owner: str, repo: str, branch: str, allow_users: list[str], required_checks: list[str], upstream_settings: dict[str, Any] | None = None, *, dry_run: bool, ) -> dict[str, Any]: """Apply protection on the fork: push whitelist locked to ``allow_users``, plus the same required-CI-check contexts upstream enforces (``required_checks``, typically read from upstream via :func:`fetch_upstream_required_checks`), plus the behavioural settings from ``upstream_settings`` (typically ``required_approvals``, ``block_on_outdated_branch``, ``dismiss_stale_approvals``, ``require_signed_commits`` — read via :func:`fetch_upstream_protection_settings`). When ``required_checks`` is empty, status checks are left optional — useful for test scenarios that deliberately want a no-CI gate. The fork's protection rule mirrors upstream's gate so the deterministic merge driver (``merge_drive.py``) sees the same set of required contexts AND the same approval / outdated-branch / signing behaviour in both environments. """ cur = api("GET", f"/repos/{owner}/{repo}/branch_protections/{branch}", token) desired: dict[str, Any] = { "branch_name": branch, "enable_push": True, "enable_push_whitelist": True, "push_whitelist_usernames": sorted(allow_users), # Default values; overridden below by upstream_settings if # caller supplied them. We keep these as static defaults so a # caller that passes upstream_settings=None still gets a # functional protection rule. "dismiss_stale_approvals": False, "required_approvals": 0, "block_on_outdated_branch": False, "require_signed_commits": False, "enable_status_check": bool(required_checks), "status_check_contexts": list(required_checks), } if upstream_settings: for k, v in upstream_settings.items(): desired[k] = v if cur["status"] == 200 and isinstance(cur["body"], dict): diff: dict[str, Any] = {} for k, v in desired.items(): if k == "branch_name": continue cur_v = cur["body"].get(k) # Compare lists order-insensitively; everything else by value. if isinstance(v, list) and isinstance(cur_v, list): if sorted(cur_v) != sorted(v): diff[k] = v elif cur_v != v: diff[k] = v if not diff: return {"applied": False, "no_change": True} if dry_run: return {"applied": False, "dry_run": True, "patch": diff} res = api( "PATCH", f"/repos/{owner}/{repo}/branch_protections/{branch}", token, body=diff, ) return { "applied": res["status"] in (200, 201, 204), "status": res["status"], "patch": diff, } # No protection rule exists yet — POST to create one. if dry_run: return {"applied": False, "dry_run": True, "create": desired} res = api( "POST", f"/repos/{owner}/{repo}/branch_protections", token, body=desired, ) return { "applied": res["status"] in (200, 201), "status": res["status"], "create": desired, } # ─── Step 4 — labels (delegated to setup_auto_labels.py) ────────────────── def provision_labels( token: str, owner: str, repo: str, *, dry_run: bool, ) -> dict[str, Any]: """Provision the canonical ``auto/*`` label set at the repo level. Forks do not inherit org-level labels, so the fork needs a copy at the repo scope. We import ``setup_auto_labels.py`` and call its helpers directly with the fork's owner/repo (rather than spawning a subprocess) so error handling stays inside one process. """ import importlib.util here = Path(__file__).resolve().parent spec = importlib.util.spec_from_file_location( "setup_auto_labels", here / "setup_auto_labels.py" ) if spec is None or spec.loader is None: raise RuntimeError("could not import setup_auto_labels.py") mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) # Retarget the module-level constants at the fork. Env vars were # resolved at module-import time so this in-process override is the # cleanest way to point label provisioning at /. mod.REPO_OWNER = owner mod.REPO_NAME = repo existing = mod.fetch_existing_labels(token, "repo") diff = mod.diff_labels(mod.LABELS, existing) if dry_run: return { "scope": "repo", "to_create": [lbl["name"] for lbl in diff["to_create"]], "to_update": [u["name"] for u in diff["to_update"]], "unchanged": [u["name"] for u in diff["unchanged"]], "dry_run": True, } results = mod.apply_diff(diff, token, "repo", dry_run=False) return { "scope": "repo", "created": [c["name"] for c in results.get("created", [])], "updated": [u["name"] for u in results.get("updated", [])], "errors": results.get("errors", []), "dry_run": False, } # ─── CLI ──────────────────────────────────────────────────────────────────── def run( token: str, *, dry_run: bool, enable_actions: bool = True, enable_issues: bool = True, mirror_required_checks: bool = True, ) -> dict[str, Any]: user = get_current_user(token) me = user["login"] fork_owner = me fork_repo = UPSTREAM_REPO summary: dict[str, Any] = { "upstream": f"{UPSTREAM_OWNER}/{UPSTREAM_REPO}", "fork": f"{fork_owner}/{fork_repo}", "dry_run": dry_run, "steps": {}, } # Step 2 — fork existing = fork_exists(token, fork_owner, fork_repo) if existing is not None: summary["steps"]["fork"] = { "exists": True, "html_url": existing.get("html_url"), "default_branch": existing.get("default_branch"), } else: cf = create_fork(token, UPSTREAM_OWNER, UPSTREAM_REPO, dry_run=dry_run) summary["steps"]["fork"] = {"exists": False, "create": cf} if not dry_run and cf.get("created"): ready = wait_for_fork(token, fork_owner, fork_repo) summary["steps"]["fork"]["ready"] = { "html_url": ready.get("html_url"), "default_branch": ready.get("default_branch"), } elif not dry_run: return summary # creation failed; downstream steps would 404 fork_already_exists = ( summary["steps"].get("fork", {}).get("exists") is True ) fork_is_live = fork_already_exists or (not dry_run) # Step 3 — enable Actions + Issues (only meaningful once the fork is live) if fork_is_live: summary["steps"]["repo_features"] = enable_repo_features( token, fork_owner, fork_repo, want_actions=enable_actions, want_issues=enable_issues, dry_run=dry_run, ) else: # Dry-run on a not-yet-existing fork — project the desired state. summary["steps"]["repo_features"] = { "applied": False, "dry_run": True, "note": "fork not yet created; would PATCH on real run", "patch": { **({"has_actions": True} if enable_actions else {}), **({"has_issues": True} if enable_issues else {}), }, } # Step 4 — branch protection (push whitelist + mirrored required checks) push_allow = [ u.strip() for u in os.environ.get("FORGEJO_PUSH_WHITELIST", me).split(",") if u.strip() ] required_checks: list[str] = [] upstream_settings: dict[str, Any] = {} if mirror_required_checks: required_checks = fetch_upstream_required_checks( token, UPSTREAM_OWNER, UPSTREAM_REPO, DEFAULT_BRANCH, ) upstream_settings = fetch_upstream_protection_settings( token, UPSTREAM_OWNER, UPSTREAM_REPO, DEFAULT_BRANCH, ) summary["steps"]["required_checks_source"] = { "from_upstream": mirror_required_checks, "contexts": required_checks, "upstream_settings": upstream_settings, } summary["steps"]["branch_protection"] = apply_branch_protection( token, fork_owner, fork_repo, DEFAULT_BRANCH, push_allow, required_checks, upstream_settings, dry_run=dry_run, ) # Step 5 — labels if dry_run and not fork_already_exists: # Can't list labels on a not-yet-created fork; project the full # desired set as "to_create" so the operator sees what the real # run will provision. import importlib.util as _iu here = Path(__file__).resolve().parent spec = _iu.spec_from_file_location( "setup_auto_labels", here / "setup_auto_labels.py" ) if spec and spec.loader: sal = _iu.module_from_spec(spec) spec.loader.exec_module(sal) summary["steps"]["labels"] = { "scope": "repo", "to_create": [lbl["name"] for lbl in sal.LABELS], "to_update": [], "unchanged": [], "dry_run": True, "note": "fork not yet created; projected from desired set", } else: summary["steps"]["labels"] = {"error": "could not import setup_auto_labels"} else: try: summary["steps"]["labels"] = provision_labels( token, fork_owner, fork_repo, dry_run=dry_run, ) except Exception as e: summary["steps"]["labels"] = {"error": str(e)} return summary def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description="Bootstrap a personal-fork test repo for the auto-agents pipeline." ) parser.add_argument( "--dry-run", action="store_true", help="Plan only; mutate nothing. Forgejo is contacted only for reads.", ) parser.add_argument( "--format", choices=("text", "json"), default="text", help="Output format (default: text).", ) parser.add_argument( "--no-actions", action="store_true", help="Skip enabling has_actions on the fork. Use only when you " "intentionally want CI runs not to fire (e.g. a smoke test " "of a no-CI scenario).", ) parser.add_argument( "--no-issues", action="store_true", help="Skip enabling has_issues on the fork. verify_invariant.py " "will be unable to file auto/invariant-violation issues if " "this is set.", ) parser.add_argument( "--no-mirror-checks", action="store_true", help="Skip copying upstream's required status_check_contexts to " "the fork's branch protection. The fork's gate will accept " "any commit that passes the push whitelist (CI optional).", ) parser.add_argument( "--upstream-owner", default=None, help="Override the upstream owner (default: env " "FORGEJO_UPSTREAM_OWNER or 'cleveragents').", ) parser.add_argument( "--upstream-repo", default=None, help="Override the upstream repo (default: env " "FORGEJO_UPSTREAM_REPO or 'cleveragents-core').", ) args = parser.parse_args(argv) # CLI flags win over env vars; mutate module globals so run() and # the helpers all see the same effective upstream. global UPSTREAM_OWNER, UPSTREAM_REPO if args.upstream_owner: UPSTREAM_OWNER = args.upstream_owner if args.upstream_repo: UPSTREAM_REPO = args.upstream_repo try: token = load_token() except RuntimeError as e: print(f"ERROR: {e}", file=sys.stderr) return 2 try: summary = run( token, dry_run=args.dry_run, enable_actions=not args.no_actions, enable_issues=not args.no_issues, mirror_required_checks=not args.no_mirror_checks, ) except RuntimeError as e: print(f"ERROR: {e}", file=sys.stderr) return 1 if args.format == "json": print(json.dumps(summary, indent=2, sort_keys=True)) else: prefix = "(DRY RUN) " if args.dry_run else "" print(f"# {prefix}setup_test_fork") print(f" upstream: {summary['upstream']}") print(f" fork : {summary['fork']}") s = summary["steps"] if "fork" in s: f = s["fork"] print( f" fork.exists: {f.get('exists')}" + ( f" url={f.get('html_url')}" if f.get("html_url") else ( f" create_status={(f.get('create') or {}).get('status')}" if "create" in f else "" ) ) ) if "repo_features" in s: rf = s["repo_features"] if rf.get("error"): print(f" repo_features: ERROR {rf['error']}") elif rf.get("no_change"): print( f" repo_features: no change needed " f"(has_actions={rf.get('has_actions_before')}, " f"has_issues={rf.get('has_issues_before')})" ) elif rf.get("dry_run"): print(f" repo_features (dry-run, patch): {rf.get('patch')}") else: print( f" repo_features: applied={rf.get('applied')} " f"status={rf.get('status')} " f"has_actions: {rf.get('has_actions_before')}→" f"{rf.get('has_actions_after')} " f"has_issues: {rf.get('has_issues_before')}→" f"{rf.get('has_issues_after')}" ) if "required_checks_source" in s: rcs = s["required_checks_source"] ctx = rcs.get("contexts") or [] us = rcs.get("upstream_settings") or {} origin = "from upstream" if rcs.get("from_upstream") else "skipped" print( f" required_checks ({origin}): {len(ctx)} contexts" + (f" → {ctx}" if ctx else "") ) if us: print(f" upstream_settings: {us}") if "branch_protection" in s: bp = s["branch_protection"] if bp.get("no_change"): print(f" branch_protection: no change needed") elif bp.get("dry_run"): op = "patch" if "patch" in bp else "create" print( f" branch_protection (dry-run, {op}): " f"{bp.get('patch') or bp.get('create')}" ) else: print(f" branch_protection: applied={bp.get('applied')} status={bp.get('status')}") if "labels" in s: lbl = s["labels"] if "error" in lbl: print(f" labels: ERROR {lbl['error']}") elif lbl.get("dry_run"): print( f" labels (dry-run): " f"to_create={lbl.get('to_create', [])} " f"to_update={lbl.get('to_update', [])} " f"unchanged={len(lbl.get('unchanged', []))}" ) else: print( f" labels: created={lbl.get('created', [])} " f"updated={lbl.get('updated', [])} " f"errors={len(lbl.get('errors', []))}" ) # Exit non-zero if any step reported an explicit failure status. bp = summary["steps"].get("branch_protection") or {} fk = summary["steps"].get("fork") or {} rf = summary["steps"].get("repo_features") or {} if not args.dry_run: if "create" in fk and not (fk["create"] or {}).get("created"): return 1 if ( "status" in bp and bp.get("status") not in (None, 200, 201, 204) and not bp.get("no_change") ): return 1 if rf.get("error") or ( "status" in rf and rf.get("status") not in (None, 200, 201) and not rf.get("no_change") ): return 1 return 0 if __name__ == "__main__": raise SystemExit(main())