496e796cdd
Split the one-shot label backfill into reusable, mode-selected operations while preserving the original default behavior: - --create-labels : idempotent label creation only (create_labels()). - --apply-mapping : one-off MAPPING labeling only (apply_mapping(); PUT replaces each issue's set). - --add-label <issue> <label> : ad-hoc single-issue labeling (add_label(); POST appends the label, does not replace; refuses an undefined label). - default (no mode) : create labels then apply MAPPING — identical to the prior behavior. --dry (and --dry-run) still print without writing. Extracted create_labels / apply_mapping / add_label / _labels_by_name helpers; LABELS, MAPPING, and the api() wrapper are unchanged. No auth/network behavior change; MAPPING remains the same one-off backfill data. Tests: extend tests/test_manage_labels.py with a TestModes suite — create-only (no PUT), apply-only (no label creation), add-label appends (POST, not PUT), unknown-label no-op, dry no-op, non-numeric issue exits. Existing default/dry/ mapping/constant tests unchanged and still pass. py_compile clean; full suite 319 passed / 0 failures; git diff --check clean; no secrets. Closes #6. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
175 lines
6.0 KiB
Python
Executable File
175 lines
6.0 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, repo_api_url
|
|
|
|
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": "status:in-progress", "color": "fefe2e",
|
|
"description": "Issue is being worked on"},
|
|
]
|
|
|
|
# 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."""
|
|
existing = api("GET", "/labels?limit=100", auth) or []
|
|
return {lb["name"]: lb["id"] for lb in existing}
|
|
|
|
|
|
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()
|