Files
cleveragents-core/tools/setup_auto_labels.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

600 lines
22 KiB
Python
Executable File

#!/usr/bin/env python3
"""Provision the ``auto/*`` label set on the cleveragents org.
Implements the ``setup_labels`` todo from the auto-agents pipeline plan
(rollout step 3). Idempotent: running twice in succession produces zero
mutations on the second run.
Per the project convention captured in
``.opencode/skills/auto-agents-system/SKILL.md`` ("Labels: NEVER create.
Org-level only.") this script defaults to the **org** label scope, which
inherits into every repo under the org. The pre-existing agent permission
rules deny `*api/v1/orgs/*/labels*` and `*api/v1/repos/*/labels*` to all
auto-agents, so this script is intended to be run once by an operator
before 0B ships.
Label set
---------
Mirrors the table in
``.cursor/plans/improve_auto-agents_pipeline_a31d3945.plan.md`` (rollout
step 3). Colour groupings:
- blue — informational / transient (currently being processed)
- yellow — waiting on system (cooldown markers)
- orange — needs attention
- red — blocking / violation
- grey — historical / postmortem
Usage
-----
::
# Dry-run: print the diff without mutating
python3 tools/setup_auto_labels.py --dry-run
# Apply (default)
python3 tools/setup_auto_labels.py
# Provision at the repo level instead (uncommon)
python3 tools/setup_auto_labels.py --scope repo
# JSON output
python3 tools/setup_auto_labels.py --format json
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
REPO_OWNER = os.environ.get("FORGEJO_OWNER", "cleveragents")
REPO_NAME = os.environ.get("FORGEJO_REPO", "cleveragents-core")
ORG_NAME = os.environ.get("FORGEJO_ORG", "cleveragents")
API_BASE = os.environ.get(
"FORGEJO_API_BASE", "https://git.cleverthis.com/api/v1"
).rstrip("/")
REQUEST_TIMEOUT_SEC = 30
def labels_path(scope: str) -> str:
"""Return the API path for listing/creating labels in the given scope."""
if scope == "org":
return f"/orgs/{ORG_NAME}/labels"
if scope == "repo":
return f"/repos/{REPO_OWNER}/{REPO_NAME}/labels"
raise ValueError(f"unknown scope: {scope}")
def label_id_path(scope: str, label_id: int) -> str:
if scope == "org":
return f"/orgs/{ORG_NAME}/labels/{label_id}"
if scope == "repo":
return f"/repos/{REPO_OWNER}/{REPO_NAME}/labels/{label_id}"
raise ValueError(f"unknown scope: {scope}")
# Labels keyed by name. Each carries a colour (without leading #) and a
# free-text description rendered in the Forgejo UI.
LABELS: list[dict[str, str]] = [
# ── blue: informational / transient ───────────────────────────────────
{
"name": "auto/claimed-merge",
"color": "0e8a16", # green-ish (informational, accent on action)
"description": "Currently being processed by the merge driver.",
},
{
"name": "auto/claimed-implementer",
"color": "1d76db",
"description": "Currently being processed by an implementer worker.",
},
{
"name": "auto/claimed-reviewer",
"color": "5319e7",
"description": "Currently being processed by a reviewer worker.",
},
# In-cycle tier escalation labels (2026-05-12). The implementer
# dispatcher's escalation loop mutates these as it walks Tier 0
# → 1 (→ 2 when the Tier 2 flag is flipped) so an operator can
# see which tier the worker is currently running for a given PR.
# See ``docs/development/implementer-in-cycle-escalation-plan.md``.
#
# 2026-05-16 promotion to control state (run-15 fix): the labels
# are now strict-walk seed state across cycles, not
# observability-only. The dispatcher's
# ``_read_start_tier_from_labels`` reads the highest tier label
# and sets ``start_tier = min(labeled + 1, max_tier)``. Cycles
# ending in SUCCESS clear the labels (PR is done); cycles ending
# in ESCALATE / END_CYCLE / EXHAUSTED keep the label so the next
# dispatcher cycle deterministically walks one tier higher
# instead of re-asking the estimator (which previously re-picked
# the failed tier on PR #30 across six cycles).
#
# 2026-05-16 tier-min addition: the ``auto/last-attempt-tier-min``
# entry below covers tier -1 (the cheapest slot, only ever
# picked by the estimator). Earlier escalation cycles at tier-min
# left no label because the suffix didn't match any provisioned
# name — the dispatcher's apply silently failed and the next
# cycle treated the PR as fresh. Without this label the
# cross-cycle determinism breaks for the most-likely
# estimator-error case (re-picking a too-cheap tier).
{
"name": "auto/last-attempt-tier-min",
"color": "1d76db",
"description": (
"In-cycle escalation: most recent attempt ran at the "
"Tier -1 slot (`tier-min`). Slot's model defined in "
".opencode/models/tiers.yaml. Suffix is ``-min`` (not "
"``--1``) so the Forgejo UI reads naturally."
),
},
{
"name": "auto/last-attempt-tier-0",
"color": "1d76db",
"description": (
"In-cycle escalation: most recent attempt ran at the "
"Tier 0 slot (`tier-0`). Slot's model defined in "
".opencode/models/tiers.yaml."
),
},
{
"name": "auto/last-attempt-tier-1",
"color": "1d76db",
"description": (
"In-cycle escalation: most recent attempt ran at the "
"Tier 1 slot (`tier-1`). Slot's model defined in "
".opencode/models/tiers.yaml."
),
},
{
"name": "auto/last-attempt-tier-2",
"color": "1d76db",
"description": (
"In-cycle escalation: most recent attempt ran at the "
"Tier 2 slot (`tier-2`). Slot's model defined in "
".opencode/models/tiers.yaml. Gated behind "
"IMPLEMENTER_ESCALATION_TIER2_ENABLED."
),
},
# Merge-readiness gate (2026-05-16): explicit positive signal that
# the reviewer worker has APPROVED a PR (and no subsequent
# REQUEST_CHANGES has landed). The merge driver's pick_candidates
# requires this label to be present — without it the driver would
# claim + rebase + CI-wait on PRs the reviewer has just flagged as
# not ready, wasting cycles. The reviewer worker's submission path
# (``_review_finalize``) mutates this label on every successful
# review submission:
# - submit_review event=APPROVED -> add this label
# - submit_review event=REQUEST_CHANGES -> remove this label
# - submit_review event=COMMENT (advisory only) -> no change
# An operator who wants to force a merge attempt on a PR the
# reviewer hasn't approved (e.g. emergency hotfix) can add the
# label manually; the merge driver respects whoever set it.
{
"name": "auto/ready-to-merge",
"color": "0e8a16",
"description": (
"Reviewer has APPROVED this PR and no later REQUEST_CHANGES "
"is outstanding. The merge driver requires this label to "
"even consider a PR for merging. Set by the reviewer "
"worker on APPROVE; cleared on REQUEST_CHANGES."
),
},
# ── yellow: waiting on system / historical cooldown ──────────────────
{
"name": "auto/ci-timeout",
"color": "fbca04",
"description": (
"Most recent merge cycle hit CI timeout. Driver excludes this PR "
"while last merge_cycle row is < 30 min old; label persists "
"thereafter as visible history."
),
},
{
"name": "auto/restart-throttled",
"color": "fbca04",
"description": (
"Train repeatedly lost master-tempo races. Driver excludes "
"via merge_cycle until cooldown elapses; label persists as "
"visible history."
),
},
# ── orange: needs attention ──────────────────────────────────────────
{
"name": "auto/needs-implementer",
"color": "d93f0b",
"description": "Failing CI needs implementer attention.",
},
{
"name": "auto/needs-conflict-resolution",
"color": "d93f0b",
"description": "Rebase conflict needs LLM conflict-resolver.",
},
{
"name": "auto/blocked-by-deps",
"color": "d93f0b",
"description": (
"PR blocked by an open issue dependency. Operator must close "
"the dep (or remove the dependency link) before the merge "
"driver can act. Auto-cleared by merge_drive when no open "
"deps remain."
),
},
{
"name": "auto/stale-inactivity",
"color": "d93f0b",
"description": (
"No implementer activity for N days. Flagged for human review. "
"Auto-cleared on next push to head branch."
),
},
{
"name": "auto/needs-human-triage",
"color": "d93f0b",
"description": (
"Iteration cap exceeded — N consecutive dispatcher cycles "
"with no progress (head_sha + comment count unchanged). "
"All dispatchers skip PRs with this label; remove "
"manually after investigation to re-enable automated work."
),
},
{
"name": "auto/revert",
"color": "d93f0b",
"description": (
"Revert PR backing out an invariant violation. Fast-tracked "
"through the merge driver."
),
},
# ── red: blocking / violation ────────────────────────────────────────
{
"name": "auto/unstable",
"color": "b60205",
"description": (
"Repeatedly fails on current master (>= 3 ci-fail-on-rebased-sha "
"releases in 12 h). Excluded from driver until human triage."
),
},
{
"name": "auto/invariant-violation",
"color": "b60205",
"description": (
"Detected master commit violating the strict merge invariant. "
"Tracked as an issue (not a PR label); kept here for label "
"completeness."
),
},
{
"name": "auto/driver-down",
"color": "b60205",
"description": (
"Merge driver heartbeat stale; pipeline halted. Closed "
"automatically on next clean tick."
),
},
# ── grey: postmortem / historical ────────────────────────────────────
{
"name": "auto/postmortem",
"color": "555555",
"description": "Documenting a driver incident or rollback.",
},
# ── grey: test infrastructure ───────────────────────────────────────
{
"name": "auto/sentinel",
"color": "5a5a5a",
"description": (
"Sentinel PR duplicated from upstream into a personal fork "
"by tools/duplicate_prs_to_fork.py for pipeline testing. "
"Lives only in the fork; the canonical pipeline never sees it."
),
},
]
# ─── Token + HTTP ──────────────────────────────────────────────────────────
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: Any | None = None) -> dict[str, Any]:
url = f"{API_BASE}{path}" if path.startswith("/") else path
data = json.dumps(body).encode() if body is not None else None
headers = {"Authorization": f"token {token}"}
if data is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_SEC) as resp:
payload = resp.read()
return {
"status": resp.status,
"body": json.loads(payload) if payload else None,
}
except urllib.error.HTTPError as e:
return {
"status": e.code,
"body": json.loads(e.read())
if e.headers.get("Content-Type", "").startswith("application/json")
else None,
"error": e.read().decode("utf-8", "replace") if False else None,
}
# ─── Sync logic ────────────────────────────────────────────────────────────
def fetch_existing_labels(token: str, scope: str) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
page = 1
base = labels_path(scope)
while True:
res = api("GET", f"{base}?limit=50&page={page}", token)
if res["status"] != 200:
raise RuntimeError(
f"could not list labels at {scope} scope (HTTP {res['status']}): "
f"{res.get('body')}"
)
chunk = res["body"] or []
out.extend(chunk)
if len(chunk) < 50:
break
page += 1
return out
def diff_labels(
desired: list[dict[str, str]], existing: list[dict[str, Any]]
) -> dict[str, list[dict[str, Any]]]:
"""Return {to_create, to_update, unchanged} for the desired label set."""
by_name = {lbl["name"]: lbl for lbl in existing}
to_create: list[dict[str, Any]] = []
to_update: list[dict[str, Any]] = []
unchanged: list[dict[str, Any]] = []
for want in desired:
have = by_name.get(want["name"])
if not have:
to_create.append(want)
continue
wants_color = want["color"].lstrip("#").lower()
has_color = (have.get("color") or "").lstrip("#").lower()
wants_desc = want.get("description", "") or ""
has_desc = have.get("description", "") or ""
if wants_color != has_color or wants_desc != has_desc:
to_update.append(
{
"id": have["id"],
"name": want["name"],
"want": want,
"have": have,
}
)
else:
unchanged.append({"name": want["name"]})
return {
"to_create": to_create,
"to_update": to_update,
"unchanged": unchanged,
}
def apply_diff(
diff: dict[str, list[dict[str, Any]]],
token: str,
scope: str,
dry_run: bool,
) -> dict[str, list[dict[str, Any]]]:
results: dict[str, list[dict[str, Any]]] = {
"created": [],
"updated": [],
"errors": [],
}
create_path = labels_path(scope)
# The Forgejo label endpoints accept the colour with or without a leading
# `#`; we send without it for consistency with org-labels.md.
#
# Description length: Forgejo silently 500s when description > 255 chars
# (no 4xx, no body — live-verified 2026-05-17 against
# https://git.cleverthis.com on the auto/needs-human-triage create).
# Guard locally so the script surfaces a useful error rather than a
# bare ``status=500 body={"message":""}``.
for lbl in diff["to_create"]:
desc = lbl.get("description", "") or ""
if len(desc) > 255:
results["errors"].append(
{
"op": "create",
"name": lbl["name"],
"status": 0,
"body": (
f"description is {len(desc)} chars; Forgejo's silent "
"limit is 255. Shorten the description in "
"tools/setup_auto_labels.py before retrying."
),
}
)
continue
if dry_run:
results["created"].append({"name": lbl["name"], "dry_run": True})
continue
res = api(
"POST",
create_path,
token,
body={
"name": lbl["name"],
"color": lbl["color"].lstrip("#"),
"description": desc,
},
)
if res["status"] in (200, 201):
results["created"].append(
{"name": lbl["name"], "id": (res["body"] or {}).get("id")}
)
else:
results["errors"].append(
{
"op": "create",
"name": lbl["name"],
"status": res["status"],
"body": res.get("body"),
}
)
for upd in diff["to_update"]:
desc = upd["want"].get("description", "") or ""
if len(desc) > 255:
results["errors"].append(
{
"op": "update",
"name": upd["name"],
"status": 0,
"body": (
f"description is {len(desc)} chars; Forgejo's silent "
"limit is 255. Shorten the description in "
"tools/setup_auto_labels.py before retrying."
),
}
)
continue
if dry_run:
results["updated"].append({"name": upd["name"], "dry_run": True})
continue
res = api(
"PATCH",
label_id_path(scope, upd["id"]),
token,
body={
"name": upd["want"]["name"],
"color": upd["want"]["color"].lstrip("#"),
"description": desc,
},
)
if res["status"] == 200:
results["updated"].append({"name": upd["name"], "id": upd["id"]})
else:
results["errors"].append(
{
"op": "update",
"name": upd["name"],
"status": res["status"],
"body": res.get("body"),
}
)
return results
# ─── Main ──────────────────────────────────────────────────────────────────
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Provision the auto/* label set on cleveragents-core."
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print the planned diff without mutating any labels.",
)
parser.add_argument(
"--scope",
choices=("org", "repo"),
default="org",
help=(
"Label scope (default: org). The cleveragents convention is "
"org-level labels which inherit into every repo."
),
)
parser.add_argument(
"--format",
choices=("text", "json"),
default="text",
help="Output format (default: text).",
)
args = parser.parse_args(argv)
try:
token = load_token()
except RuntimeError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 2
existing = fetch_existing_labels(token, args.scope)
# Only consider auto/* labels in the existing set when computing the diff;
# other org labels are managed by other processes.
existing_auto = [lbl for lbl in existing if lbl.get("name", "").startswith("auto/")]
diff = diff_labels(LABELS, existing_auto)
results = apply_diff(diff, token, args.scope, dry_run=args.dry_run)
summary = {
"scope": args.scope,
"scope_target": (
ORG_NAME if args.scope == "org" else f"{REPO_OWNER}/{REPO_NAME}"
),
"desired_count": len(LABELS),
"existing_total": len(existing),
"existing_auto_count": len(existing_auto),
"to_create": [lbl["name"] for lbl in diff["to_create"]],
"to_update": [lbl["name"] for lbl in diff["to_update"]],
"unchanged": [lbl["name"] for lbl in diff["unchanged"]],
"results": results,
"dry_run": args.dry_run,
}
if args.format == "json":
print(json.dumps(summary, indent=2, sort_keys=True))
else:
print(
f"# auto/* label provisioning "
f"{'(DRY RUN) ' if args.dry_run else ''}"
f"{args.scope}: {summary['scope_target']}"
)
print(
f" desired: {summary['desired_count']} labels, "
f"existing total: {summary['existing_total']}, "
f"existing auto/*: {summary['existing_auto_count']}"
)
print(
f" to create: {len(diff['to_create'])}: "
f"{', '.join(summary['to_create']) or '-'}"
)
print(
f" to update: {len(diff['to_update'])}: "
f"{', '.join(summary['to_update']) or '-'}"
)
print(
f" unchanged: {len(diff['unchanged'])}: "
f"{', '.join(summary['unchanged']) or '-'}"
)
if results["errors"]:
print(f" ERRORS: {len(results['errors'])}")
for err in results["errors"]:
print(f" - {err}")
return 1
return 0 if not results["errors"] else 1
if __name__ == "__main__":
raise SystemExit(main())