feat: milestones Forgejo por spec (47) #856

Merged
roger merged 1 commit from develop into main 2026-07-02 17:07:28 +00:00
3 changed files with 192 additions and 1 deletions

View file

@ -92,6 +92,7 @@ GitHub **não substitui** Forgejo; é réplica read-only ou bidirecional acordad
- [ ] 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`
- [ ] Se spec nova → `python3 scripts/sync-forgejo-milestones.py`
- [ ] Push/mirror GitHub
## 7. Falhas conhecidas a evitar

View file

@ -72,7 +72,15 @@ python3 scripts/sync-tasks-to-forgejo-issues.py
**Importante:** utilizadores têm de estar na org `ligbox` (team Owners) para ver repos no dashboard Forgejo.
Script: `scripts/setup-forgejo-org-access.sh`
**Homepage Forgejo:** `LANDING_PAGE = /ligbox` (não landing vazia).
**Homepage Forgejo:** `LANDING_PAGE = /ligbox/ligbox-ops-platform` + templates custom em `/opt/ligbox-spec-hub/forgejo/custom/templates/`:
| Rota global | Redireciona para |
|-------------|------------------|
| `/` | repo principal |
| `/issues` | `.../ligbox-ops-platform/issues` (852) |
| `/milestones` | `.../ligbox-ops-platform/milestones` (47 specs) |
**Milestones:** `python3 scripts/sync-forgejo-milestones.py` (após novas specs).
## Spec

View file

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