#!/usr/bin/env python3 """Synchronize issue labels to a Gitea repository.""" from __future__ import annotations import argparse import os import pathlib import sys from typing import Any from gitea_common import ( GiteaClient, GiteaError, infer_target, load_dotenv, load_json, repo_path, repo_root, require_token, ) DEFAULT_LABELS_FILE = pathlib.Path(__file__).resolve().parents[1] / "docs" / "gitea-labels.json" def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path.cwd(), help="repository root or child path") parser.add_argument("--remote", default="origin", help="git remote to use for target inference") parser.add_argument("--labels-file", type=pathlib.Path, default=DEFAULT_LABELS_FILE) parser.add_argument("--env-file", type=pathlib.Path, help="dotenv file to read before using GITEA_* values") parser.add_argument("--apply", action="store_true", help="create or update labels in Gitea") args = parser.parse_args() try: root = repo_root(args.root) load_dotenv(args.env_file or root / ".env") target = infer_target(root, args.remote) labels = _load_labels(args.labels_file) token = require_token() if args.apply else os.environ.get("GITEA_TOKEN") print(f"Target: {target.display}") print(f"Labels file: {args.labels_file}") if not args.apply and not token: print("Dry run without GITEA_TOKEN: API comparison skipped.") for label in labels: print(f"would ensure {label['name']} ({label['color']})") return 0 client = GiteaClient(target, token) existing = _labels_by_name(client, target.owner, target.repo) creates: list[dict[str, Any]] = [] updates: list[tuple[dict[str, Any], dict[str, Any]]] = [] for label in labels: current = existing.get(label["name"]) if current is None: creates.append(label) continue patch = _diff_label(current, label) if patch: updates.append((current, patch)) if not creates and not updates: print("No label changes needed.") return 0 for label in creates: print(f"{'create' if args.apply else 'would create'} {label['name']}") if args.apply: client.request_json( "POST", repo_path(target.owner, target.repo, "/labels"), body=label, ) for current, patch in updates: print(f"{'update' if args.apply else 'would update'} {current['name']}: {', '.join(sorted(patch))}") if args.apply: client.request_json( "PATCH", repo_path(target.owner, target.repo, f"/labels/{current['id']}"), body=patch, ) return 0 except GiteaError as exc: print(f"error: {exc}", file=sys.stderr) return 1 def _load_labels(path: pathlib.Path) -> list[dict[str, Any]]: payload = load_json(path) if not isinstance(payload, list): raise GiteaError(f"{path} must contain a JSON list") labels: list[dict[str, Any]] = [] seen: set[str] = set() for index, item in enumerate(payload, start=1): if not isinstance(item, dict): raise GiteaError(f"label #{index} must be an object") label = { "name": str(item.get("name", "")).strip(), "color": str(item.get("color", "")).strip().lstrip("#").lower(), "description": str(item.get("description", "")).strip(), "exclusive": bool(item.get("exclusive", False)), } if not label["name"]: raise GiteaError(f"label #{index} is missing name") if label["name"] in seen: raise GiteaError(f"duplicate label name: {label['name']}") if not _is_hex_color(label["color"]): raise GiteaError(f"{label['name']} has invalid color {label['color']!r}") seen.add(label["name"]) labels.append(label) return labels def _labels_by_name(client: GiteaClient, owner: str, repo: str) -> dict[str, dict[str, Any]]: labels = client.paginate(repo_path(owner, repo, "/labels")) return {str(label.get("name")): label for label in labels} def _diff_label(current: dict[str, Any], desired: dict[str, Any]) -> dict[str, Any]: patch: dict[str, Any] = {} if _normalize_color(current.get("color")) != desired["color"]: patch["color"] = desired["color"] if str(current.get("description") or "").strip() != desired["description"]: patch["description"] = desired["description"] if bool(current.get("exclusive", False)) != desired["exclusive"]: patch["exclusive"] = desired["exclusive"] return patch def _normalize_color(value: Any) -> str: return str(value or "").strip().lstrip("#").lower() def _is_hex_color(value: str) -> bool: if len(value) != 6: return False return all(char in "0123456789abcdef" for char in value) if __name__ == "__main__": raise SystemExit(main())