feat(forgejo): 47 milestones por spec + sync issues
Script sync-forgejo-milestones.py; redirect /milestones no Forgejo; docs CT130 e spec-driver-sync actualizados. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
4f7f169776
commit
6c94a77277
3 changed files with 192 additions and 1 deletions
|
|
@ -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/
|
- [ ] Spec visível em https://spec.ligbox.com.br/specs/
|
||||||
- [ ] Commit + push Forgejo
|
- [ ] Commit + push Forgejo
|
||||||
- [ ] Se `tasks.md` mudou → `python3 scripts/sync-tasks-to-forgejo-issues.py`
|
- [ ] Se `tasks.md` mudou → `python3 scripts/sync-tasks-to-forgejo-issues.py`
|
||||||
|
- [ ] Se spec nova → `python3 scripts/sync-forgejo-milestones.py`
|
||||||
- [ ] Push/mirror GitHub
|
- [ ] Push/mirror GitHub
|
||||||
|
|
||||||
## 7. Falhas conhecidas a evitar
|
## 7. Falhas conhecidas a evitar
|
||||||
|
|
|
||||||
|
|
@ -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.
|
**Importante:** utilizadores têm de estar na org `ligbox` (team Owners) para ver repos no dashboard Forgejo.
|
||||||
Script: `scripts/setup-forgejo-org-access.sh`
|
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
|
## Spec
|
||||||
|
|
||||||
|
|
|
||||||
182
scripts/sync-forgejo-milestones.py
Executable file
182
scripts/sync-forgejo-milestones.py
Executable 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())
|
||||||
Loading…
Reference in a new issue