diff --git a/.cursor/rules/spec-driver-sync.mdc b/.cursor/rules/spec-driver-sync.mdc index 4930275..635ece6 100644 --- a/.cursor/rules/spec-driver-sync.mdc +++ b/.cursor/rules/spec-driver-sync.mdc @@ -81,6 +81,7 @@ GitHub **não substitui** Forgejo; é réplica read-only ou bidirecional acordad - [ ] `refresh-spec-driver.sh` executado no CT130 - [ ] Spec visível em https://spec.ligbox.com.br/specs/ - [ ] Commit + push Forgejo +- [ ] Se `tasks.md` mudou → `python3 scripts/sync-tasks-to-forgejo-issues.py` - [ ] Push/mirror GitHub ## 7. Falhas conhecidas a evitar diff --git a/docs/vms/CT130.md b/docs/vms/CT130.md index f51c85d..a9d903a 100644 --- a/docs/vms/CT130.md +++ b/docs/vms/CT130.md @@ -18,6 +18,9 @@ | https://spec.ligbox.com.br/specs/ | Browser Spec Kit | | https://spec.ligbox.com.br/vault/ | Browser Obsidian | | https://git.spec.ligbox.com.br | Forgejo Git | +| https://git.spec.ligbox.com.br/ligbox | **Org Ligbox** (repos) | +| https://git.spec.ligbox.com.br/ligbox/ligbox-ops-platform | **Repo principal** | +| https://git.spec.ligbox.com.br/ligbox/ligbox-ops-platform/issues | **Issues** (sync `tasks.md`) | ## Stack local @@ -32,10 +35,19 @@ ## Repos Forgejo (org `ligbox`) -| Repo | Conteúdo | -|------|----------| -| `ligbox-ops-platform` | Monorepo — **todas** VMs (deploy/ por VM) | -| `obsidian-vault` | Notas, LAPTOP, anais | +| Repo | Conteúdo | Issues | +|------|----------|--------| +| [`ligbox-ops-platform`](https://git.spec.ligbox.com.br/ligbox/ligbox-ops-platform) | Monorepo — **todas** VMs | [852 issues](https://git.spec.ligbox.com.br/ligbox/ligbox-ops-platform/issues) (sync `tasks.md`) | +| [`obsidian-vault`](https://git.spec.ligbox.com.br/ligbox/obsidian-vault) | Notas, LAPTOP, anais | — | + +**Navegação Forgejo:** a homepage `/?repo-search-tab=organizations` sem login mostra landing vazia. +Usar directamente: **https://git.spec.ligbox.com.br/ligbox** ou **Explore → Repositories**. + +**Sincronizar issues** (após alterar `tasks.md`): + +```bash +python3 scripts/sync-tasks-to-forgejo-issues.py +``` ## Utilizadores diff --git a/scripts/sync-tasks-to-forgejo-issues.py b/scripts/sync-tasks-to-forgejo-issues.py new file mode 100755 index 0000000..8657df9 --- /dev/null +++ b/scripts/sync-tasks-to-forgejo-issues.py @@ -0,0 +1,259 @@ +#!/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())