Files
cleveragents-core/tools/setup_test_fork.py
T
drew 0bc734c020 style: ruff format the controller-state-machine branch (288 files)
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>
2026-05-20 00:09:17 -04:00

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())