Release v0.1.6

This commit is contained in:
2026-07-07 16:00:38 +02:00
parent a2053518d1
commit 150b720f12
149 changed files with 14311 additions and 8005 deletions

View File

@@ -0,0 +1,149 @@
#!/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())