#!/usr/bin/env python3 """Cria milestones Forgejo por spec e associa issues (label spec-NNN). Uso: python3 scripts/sync-forgejo-milestones.py [--dry-run] Requer credenciais Forgejo (roger:805353 default CT130). """ from __future__ import annotations import argparse import json import os import re import sys import time import urllib.error import urllib.request from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] SPECS_DIR = REPO_ROOT / "specs" SPEC_DIR = re.compile(r"^(\d{3})-") FORGEJO_URL = os.environ.get("FORGEJO_URL", "http://10.10.10.130:3000").rstrip("/") FORGEJO_USER = os.environ.get("FORGEJO_USER", "roger") FORGEJO_TOKEN = os.environ.get("FORGEJO_TOKEN", "805353") OWNER = os.environ.get("FORGEJO_OWNER", "ligbox") REPO = os.environ.get("FORGEJO_REPO", "ligbox-ops-platform") def api(method: str, path: str, data: dict | None = None) -> dict | list: url = f"{FORGEJO_URL}/api/v1{path}" body = json.dumps(data).encode() if data is not None else None req = urllib.request.Request(url, data=body, method=method) req.add_header("Content-Type", "application/json") import base64 req.add_header("Authorization", "Basic " + base64.b64encode(f"{FORGEJO_USER}:{FORGEJO_TOKEN}".encode()).decode()) try: with urllib.request.urlopen(req, timeout=120) as resp: raw = resp.read().decode() return json.loads(raw) if raw else {} except urllib.error.HTTPError as e: err = e.read().decode() raise RuntimeError(f"{method} {path} → HTTP {e.code}: {err[:400]}") from e def spec_title(spec_dir: Path) -> tuple[str, str]: spec_id = SPEC_DIR.match(spec_dir.name).group(1) title = spec_dir.name spec_md = spec_dir / "spec.md" if spec_md.is_file(): for line in spec_md.read_text(encoding="utf-8", errors="replace").splitlines()[:6]: if line.startswith("# "): title = line.lstrip("# ").strip() break return spec_id, title def load_specs() -> list[tuple[str, str, str]]: items: list[tuple[str, str, str]] = [] for spec_dir in sorted(SPECS_DIR.iterdir()): if not spec_dir.is_dir(): continue m = SPEC_DIR.match(spec_dir.name) if not m: continue spec_id, title = spec_title(spec_dir) ms_title = f"Spec {spec_id} — {title[:80]}" desc = ( f"**Spec:** `{spec_dir.name}`\n\n" f"Portal: https://spec.ligbox.com.br/specs/{spec_dir.name}/\n\n" f"Issues com label `spec-{spec_id}`." ) items.append((spec_id, ms_title, desc)) return items def fetch_milestones() -> dict[str, int]: by_title: dict[str, int] = {} page = 1 while True: batch = api("GET", f"/repos/{OWNER}/{REPO}/milestones?state=all&limit=50&page={page}") if not batch: break for ms in batch: by_title[ms["title"]] = ms["id"] if len(batch) < 50: break page += 1 return by_title def ensure_milestone(spec_id: str, title: str, desc: str, cache: dict[str, int], dry_run: bool) -> int: if title in cache: return cache[title] if dry_run: cache[title] = -1 return -1 ms = api("POST", f"/repos/{OWNER}/{REPO}/milestones", {"title": title, "description": desc, "state": "open"}) cache[title] = ms["id"] return ms["id"] def fetch_all_issues() -> list[dict]: issues: list[dict] = [] page = 1 while True: batch = api("GET", f"/repos/{OWNER}/{REPO}/issues?state=all&limit=50&page={page}") if not batch: break for issue in batch: if issue.get("pull_request"): continue issues.append(issue) if len(batch) < 50: break page += 1 return issues def spec_id_from_issue(issue: dict) -> str | None: for lb in issue.get("labels", []): name = lb.get("name", "") if name.startswith("spec-"): return name.replace("spec-", "", 1) m = re.search(r"\[spec-(\d{3})\]", issue.get("title", "")) return m.group(1) if m else None def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--dry-run", action="store_true") parser.add_argument("--sleep", type=float, default=0.05) args = parser.parse_args() specs = load_specs() print(f"==> Specs: {len(specs)}") ms_cache = {} if args.dry_run else fetch_milestones() spec_to_ms: dict[str, int] = {} for spec_id, title, desc in specs: mid = ensure_milestone(spec_id, title, desc, ms_cache, args.dry_run) spec_to_ms[spec_id] = mid if not args.dry_run: time.sleep(args.sleep) print(f"==> Milestones: {len(spec_to_ms)}") issues = fetch_all_issues() if not args.dry_run else [] print(f"==> Issues a associar: {len(issues)}") linked = skipped = 0 for issue in issues: sid = spec_id_from_issue(issue) if not sid or sid not in spec_to_ms: skipped += 1 continue mid = spec_to_ms[sid] if issue.get("milestone") and issue["milestone"].get("id") == mid: skipped += 1 continue if args.dry_run: linked += 1 continue api("PATCH", f"/repos/{OWNER}/{REPO}/issues/{issue['number']}", {"milestone": mid}) linked += 1 if linked % 100 == 0: print(f" ... {linked} associadas") time.sleep(args.sleep) print(f"==> Concluído: milestones={len(spec_to_ms)} issues_linked={linked} skipped={skipped}") if not args.dry_run: open_ms = api("GET", f"/repos/{OWNER}/{REPO}/milestones?state=open&limit=1") print(f"==> URL: https://git.spec.ligbox.com.br/{OWNER}/{REPO}/milestones") return 0 if __name__ == "__main__": sys.exit(main())