150 lines
5.2 KiB
Python
150 lines
5.2 KiB
Python
#!/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())
|