0bc734c020
Applies `ruff format` to the accumulated formatting debt on this branch. Formatting-only — no behavioral changes. Required for CI/lint's format gate (`nox -s format -- --check`), which the branch was failing on 288 tracked files that drifted from ruff's canonical style. In-progress WIP files are intentionally excluded so this commit stays a clean formatting-only diff. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
846 lines
30 KiB
Python
846 lines
30 KiB
Python
#!/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 ``<me>/<upstream-repo>`` already exists; if not,
|
|
**create** the fork via ``POST /repos/<upstream>/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=<me>`` (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=<me> 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 <fork-owner>/<repo>.
|
|
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())
|