Script idempotente sync-tasks-to-forgejo-issues.py; docs CT130 com URLs directas org/repo/issues e nota sobre tab organizations sem login. Co-authored-by: Cursor <cursoragent@cursor.com>
259 lines
8.4 KiB
Python
Executable file
259 lines
8.4 KiB
Python
Executable file
#!/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())
|