#!/usr/bin/env python3 """Sincroniza tasks.md (Spec Kit) → issues Forgejo. Uso: python3 scripts/sync-tasks-to-forgejo-issues.py [--dry-run] [--open-only] Requer: FORGEJO_URL, FORGEJO_USER, FORGEJO_TOKEN (ou defaults CT130). Idempotente: reutiliza issues pelo título canónico [spec-NNN] Txxx. """ from __future__ import annotations import argparse import json import os import re import sys import time import urllib.error import urllib.request from dataclasses import dataclass from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] SPECS_DIR = REPO_ROOT / "specs" TASK_LINE = re.compile(r"^-\s+\[([ xX])\]\s+(.*)$") 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") @dataclass class TaskItem: spec_id: str spec_slug: str spec_title: str task_id: str text: str done: bool tasks_path: str @property def issue_title(self) -> str: tid = self.task_id or self.text[:60] return f"[spec-{self.spec_id}] {tid} — {self.text[:120]}" @property def issue_body(self) -> str: status = "done" if self.done else "open" return ( f"## Spec {self.spec_id} — {self.spec_title}\n\n" f"| Campo | Valor |\n|-------|-------|\n" f"| **Task** | `{self.task_id or '—'}` |\n" f"| **Estado tasks.md** | `{status}` |\n" f"| **Ficheiro** | `{self.tasks_path}` |\n" f"| **Portal** | https://spec.ligbox.com.br/specs/{self.spec_slug}/ |\n\n" f"### Descrição\n\n{self.text}\n\n" f"---\n_Sincronizado automaticamente por `scripts/sync-tasks-to-forgejo-issues.py`_" ) 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") token = f"{FORGEJO_USER}:{FORGEJO_TOKEN}" import base64 req.add_header("Authorization", "Basic " + base64.b64encode(token.encode()).decode()) try: with urllib.request.urlopen(req, timeout=60) 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[:500]}") from e def spec_meta(spec_dir: Path) -> tuple[str, str]: m = SPEC_DIR.match(spec_dir.name) spec_id = m.group(1) if m else spec_dir.name[:3] 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()[:8]: if line.startswith("# "): title = line.lstrip("# ").strip() break return spec_id, title def parse_tasks(spec_dir: Path) -> list[TaskItem]: tasks_file = spec_dir / "tasks.md" if not tasks_file.is_file(): return [] spec_id, spec_title = spec_meta(spec_dir) rel = str(tasks_file.relative_to(REPO_ROOT)) items: list[TaskItem] = [] for line in tasks_file.read_text(encoding="utf-8", errors="replace").splitlines(): m = TASK_LINE.match(line.strip()) if not m: continue done = m.group(1).lower() == "x" raw = m.group(2).strip() tid_m = re.match(r"^(T\d+)\b", raw) task_id = tid_m.group(1) if tid_m else "" items.append( TaskItem( spec_id=spec_id, spec_slug=spec_dir.name, spec_title=spec_title, task_id=task_id, text=raw, done=done, tasks_path=rel, ) ) return items def load_all_tasks(open_only: bool) -> list[TaskItem]: all_items: list[TaskItem] = [] for spec_dir in sorted(SPECS_DIR.iterdir()): if not spec_dir.is_dir(): continue for item in parse_tasks(spec_dir): if open_only and item.done: continue all_items.append(item) return all_items def fetch_existing_titles() -> dict[str, int]: titles: dict[str, int] = {} 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 titles[issue["title"]] = issue["number"] if len(batch) < 50: break page += 1 return titles def ensure_label(name: str, cache: dict[str, int]) -> int: if name in cache: return cache[name] labels = api("GET", f"/repos/{OWNER}/{REPO}/labels?limit=100") for lb in labels: cache[lb["name"]] = lb["id"] if lb["name"] == name: return lb["id"] created = api("POST", f"/repos/{OWNER}/{REPO}/labels", {"name": name, "color": "0052cc"}) cache[name] = created["id"] return created["id"] def apply_labels(issue_num: int, label_names: list[str], cache: dict[str, int], dry_run: bool) -> None: if dry_run: return ids = [ensure_label(n, cache) for n in label_names] api("POST", f"/repos/{OWNER}/{REPO}/issues/{issue_num}/labels", {"labels": ids}) def sync_issue(task: TaskItem, existing: dict[str, int], label_cache: dict[str, int], dry_run: bool) -> str: title = task.issue_title labels = [f"spec-{task.spec_id}", "done" if task.done else "open"] state = "closed" if task.done else "open" if title in existing: num = existing[title] if dry_run: return f"skip #{num} {title[:70]}" api( "PATCH", f"/repos/{OWNER}/{REPO}/issues/{num}", {"state": state, "body": task.issue_body}, ) apply_labels(num, labels, label_cache, dry_run) return f"update #{num}" if dry_run: return f"create {title[:70]}" issue = api( "POST", f"/repos/{OWNER}/{REPO}/issues", {"title": title, "body": task.issue_body}, ) num = issue["number"] apply_labels(num, labels, label_cache, dry_run) if task.done: api("PATCH", f"/repos/{OWNER}/{REPO}/issues/{num}", {"state": "closed"}) existing[title] = num return f"create #{num}" def update_repo_metadata(dry_run: bool) -> None: desc = ( "Monorepo Ligbox Ops — Desk VM122, Wizard VM112, Finance VM123, Spec Kit. " "Fonte de verdade: specs/ + issues sincronizadas de tasks.md. " "Portal: https://spec.ligbox.com.br" ) if dry_run: print("would update repo description") return api("PATCH", f"/repos/{OWNER}/{REPO}", {"description": desc, "has_issues": True}) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--dry-run", action="store_true") parser.add_argument("--open-only", action="store_true", help="Só tarefas [ ] abertas") parser.add_argument("--sleep", type=float, default=0.15, help="Pausa entre requests") args = parser.parse_args() tasks = load_all_tasks(args.open_only) print(f"==> Tasks encontradas: {len(tasks)} (open_only={args.open_only})") if not tasks: print("Nada a sincronizar.") return 0 update_repo_metadata(args.dry_run) existing = {} if args.dry_run else fetch_existing_titles() print(f"==> Issues existentes: {len(existing)}") created = updated = 0 label_cache: dict[str, int] = {} for i, task in enumerate(tasks, 1): result = sync_issue(task, existing, label_cache, args.dry_run) if result.startswith("create"): created += 1 elif result.startswith("update"): updated += 1 if i % 50 == 0: print(f" ... {i}/{len(tasks)}") if not args.dry_run: time.sleep(args.sleep) print(f"==> Concluído: create={created} update={updated} total={len(tasks)}") if not args.dry_run: open_n = api("GET", f"/repos/{OWNER}/{REPO}") print( f"==> Repo: https://git.spec.ligbox.com.br/{OWNER}/{REPO}/issues " f"(open={open_n.get('open_issues_count')})" ) return 0 if __name__ == "__main__": sys.exit(main())