Files
Gitea-Tools/manage_labels.py
sysadmin f32aaaa3b5 fix: paginate repository-label inventory for gitea_set_issue_labels (#627)
Replace single-page GET labels?limit=100 with api_get_all-backed
_repo_label_id_map so later-page labels (e.g. type:feature,
workflow-hardening) are not falsely rejected during full-set replacement.

Also add post-mutation verification, fix related MCP/CLI label inventory
call sites, and add multi-page regression tests for the #601 reconciler
failure mode.

Closes #627
2026-07-10 13:05:27 -04:00

186 lines
6.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""Create a label set on a Gitea repo and apply labels to issues.
Auth follows the project convention: credentials are pulled from the macOS
keychain via `git credential fill` (HTTPS), then sent as Basic auth.
Modes (default = create labels then apply the one-off MAPPING, preserving the
original behavior):
./manage_labels.py # create labels + apply MAPPING
./manage_labels.py --create-labels # idempotent label creation only
./manage_labels.py --apply-mapping # one-off MAPPING labeling only
./manage_labels.py --add-label 42 chore # add one label to one issue
./manage_labels.py --dry ... # print actions without writing
"""
import os
import sys
# Auto-execute using the project's local virtual environment Python
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
venv_python = os.path.join(PROJECT_ROOT, "venv", "bin", "python3")
if os.path.exists(venv_python) and sys.executable != venv_python:
os.execv(venv_python, [venv_python] + sys.argv)
from gitea_auth import get_auth_header, api_request, api_get_all, repo_api_url
import issue_workflow_labels
HOST = "gitea.dadeschools.net"
ORG = "Contractor"
REPO = "Timesheet"
# Mirror of the eAgenda label set (i18n omitted as irrelevant to Timesheet).
LABELS = [
{"name": "chore", "color": "5319e7", "description": ""},
{"name": "critical", "color": "b60205", "description": ""},
{"name": "epic", "color": "8250df", "description": ""},
{"name": "important", "color": "fbca04", "description": ""},
{"name": "nice-to-have", "color": "0e8a16", "description": ""},
*[
{"name": spec.name, "color": spec.color, "description": spec.description}
for spec in issue_workflow_labels.CANONICAL_LABEL_SPECS
],
]
# issue number -> label names to apply (one-off backfill)
MAPPING = {
23: ["chore"],
22: ["chore"],
21: ["chore"],
20: ["chore", "status:in-progress"],
19: ["chore"],
18: ["chore"],
17: ["important"],
14: ["nice-to-have"],
13: ["nice-to-have"],
12: ["important"],
11: ["important"],
8: ["nice-to-have"],
7: ["nice-to-have"],
3: ["important"],
2: ["important"],
1: ["nice-to-have"],
}
BASE_URL = repo_api_url(HOST, ORG, REPO)
USAGE = (
"usage: manage_labels.py [--dry] "
"[--create-labels | --apply-mapping | --add-label <issue> <label>]"
)
def api(method, path, auth, payload=None):
"""Thin wrapper around auth.api_request that prepends BASE_URL and
handles errors gracefully (returns None instead of raising)."""
url = f"{BASE_URL}{path}"
try:
return api_request(method, url, auth, payload)
except RuntimeError as e:
print(f" {e}", file=sys.stderr)
return None
def _labels_by_name(auth):
"""Return {label name: id} for the repo's existing labels (all pages, #627)."""
existing = api_get_all(f"{BASE_URL}/labels", auth) or []
name_to_id = {}
for lb in existing:
if not isinstance(lb, dict):
continue
name = lb.get("name")
lid = lb.get("id")
if name and lid is not None and name not in name_to_id:
name_to_id[name] = lid
return name_to_id
def create_labels(auth, dry=False):
"""Idempotently create the LABELS set; return the resulting name->id map."""
by_name = _labels_by_name(auth)
for spec in LABELS:
if spec["name"] in by_name:
print(f"label exists: {spec['name']}")
continue
if dry:
print(f"[dry] create label: {spec['name']} #{spec['color']}")
continue
created = api("POST", "/labels", auth, spec)
if created:
by_name[created["name"]] = created["id"]
print(f"created label: {created['name']} (id {created['id']})")
return by_name
def apply_mapping(auth, by_name=None, dry=False):
"""Apply the one-off MAPPING (PUT replaces each issue's label set)."""
if by_name is None:
by_name = _labels_by_name(auth)
for issue, names in sorted(MAPPING.items(), reverse=True):
ids = [by_name[n] for n in names if n in by_name]
missing = [n for n in names if n not in by_name]
if missing:
print(f" #{issue}: skipping unknown labels {missing}", file=sys.stderr)
if dry:
print(f"[dry] #{issue} <- {names}")
continue
# PUT replaces the issue's labels with exactly this set (idempotent).
res = api("PUT", f"/issues/{issue}/labels", auth, {"labels": ids})
if res is not None:
applied = [lb["name"] for lb in res]
print(f"#{issue} labeled: {applied}")
def add_label(auth, issue, label, dry=False):
"""Ad-hoc: ADD a single existing label to one issue (append, not replace)."""
by_name = _labels_by_name(auth)
if label not in by_name:
print(f" unknown label '{label}'; create it first (--create-labels)",
file=sys.stderr)
return False
if dry:
print(f"[dry] #{issue} += {label}")
return True
# POST appends to the issue's existing labels (does not replace).
res = api("POST", f"/issues/{issue}/labels", auth, {"labels": [by_name[label]]})
if res is not None:
print(f"#{issue} += {label}")
return True
return False
def main(argv=None):
argv = list(sys.argv[1:] if argv is None else argv)
dry = "--dry" in argv or "--dry-run" in argv
auth = get_auth_header(HOST)
if auth is None:
print("Could not get credentials from git credential fill",
file=sys.stderr)
sys.exit(1)
if "--create-labels" in argv:
create_labels(auth, dry=dry)
elif "--apply-mapping" in argv:
apply_mapping(auth, dry=dry)
elif "--add-label" in argv:
i = argv.index("--add-label")
if i + 2 >= len(argv):
print(USAGE, file=sys.stderr)
sys.exit(2)
try:
issue = int(argv[i + 1])
except ValueError:
print(f"--add-label: issue must be a number, got '{argv[i + 1]}'",
file=sys.stderr)
sys.exit(2)
add_label(auth, issue, argv[i + 2], dry=dry)
else:
# Default (backward compatible): create labels, then apply the mapping.
by_name = create_labels(auth, dry=dry)
apply_mapping(auth, by_name, dry=dry)
if __name__ == "__main__":
main()