"""Export group issues from git.lab to analysis/data/gitlab_issues.json. Metadata only -- titles, labels, dates, state -- never descriptions or comment bodies. The token is read from the path the management repo's CLAUDE.md sanctions (~/.config/gitlab-lab/token) and is never printed, logged or written anywhere. All issues of the group are exported, open and closed alike: the group is small enough that a full export beats an arbitrary "recently closed" cutoff, and a full export is trivially reproducible. Issues of projects the human placed out of scope are exported too and tagged in_scope=false -- they are evidence about where work lives. Skipped without failing the run when the token file is missing or git.lab is unreachable; run_all.sh then proceeds without the ticket dimension. """ import json import sys import urllib.error import urllib.request from pathlib import Path from common import ( COMPONENTS, DATA_DIR, GITLAB_HOST, GROUP, OUT_OF_SCOPE_PROJECTS, ) TOKEN_FILE = Path.home() / ".config" / "gitlab-lab" / "token" IN_SCOPE = {slug for slug, _ in COMPONENTS} | {"management"} # Exactly the fields the analysis needs. Anything else -- above all # `description` -- is dropped before it reaches disk. FIELDS = ["id", "iid", "project_id", "title", "state", "labels", "created_at", "updated_at", "closed_at", "due_date"] def api(token, path): """Yield every page of a GitLab list endpoint.""" url = f"{GITLAB_HOST}/api/v4/{path}" while url: req = urllib.request.Request(url, headers={"PRIVATE-TOKEN": token}) with urllib.request.urlopen(req, timeout=60) as resp: yield from json.load(resp) url = None for link in resp.headers.get("Link", "").split(","): if 'rel="next"' in link: url = link.split(";")[0].strip().strip("<>") def slim(issue, projects): out = {k: issue.get(k) for k in FIELDS} out["labels"] = sorted(out["labels"] or []) out["project"] = projects.get(issue["project_id"], str(issue["project_id"])) out["in_scope"] = out["project"] in IN_SCOPE milestone = issue.get("milestone") out["milestone"] = milestone["title"] if milestone else None assignee = issue.get("assignee") out["assignee"] = assignee["username"] if assignee else None return out def main(): if not TOKEN_FILE.exists(): print(f" gitlab_issues.json: SKIPPED, no token at {TOKEN_FILE}") return 0 token = TOKEN_FILE.read_text().strip() try: projects = { p["id"]: p["path"] for p in api(token, f"groups/{GROUP}/projects?per_page=100&archived=false") } issues = list(api(token, f"groups/{GROUP}/issues?per_page=100&state=all&scope=all")) except (urllib.error.URLError, TimeoutError) as exc: print(f" gitlab_issues.json: SKIPPED, git.lab unreachable ({exc.reason})") return 0 payload = { "source": f"{GITLAB_HOST}/api/v4/groups/{GROUP}/issues?state=all", "note": "metadata only; descriptions and comments deliberately not exported", "projects": { path: {"id": pid, "in_scope": path in IN_SCOPE, "declared_out_of_scope": path in OUT_OF_SCOPE_PROJECTS} for pid, path in sorted(projects.items(), key=lambda kv: kv[1]) }, "issues": sorted((slim(i, projects) for i in issues), key=lambda i: i["id"]), } out = DATA_DIR / "gitlab_issues.json" DATA_DIR.mkdir(parents=True, exist_ok=True) out.write_text(json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True) + "\n", encoding="utf-8") print(f" gitlab_issues.json: {len(payload['issues'])} issues, " f"{len(projects)} projects") return 0 if __name__ == "__main__": sys.exit(main())