Files
management/analysis/scripts/fetch_gitlab_issues.py
T
Thore Cimbal b8b853163d analysis: add deterministic inventory scripts and raw data
run_all.sh reproduces every file under analysis/data/ from zero: it
clones the in-scope components if missing, exports group issue metadata
from git.lab, and regenerates the inventories. Reruns are diff-clean --
no wall-clock time enters an output; 'days since' is measured against
the management repo's HEAD date.

The management repo is inventoried at main, not at the analysis branch,
so this analysis does not observe its own commits. inv_repo.py aborts
the run if anything outside analysis/ was modified.
2026-08-09 12:00:00 +00:00

100 lines
3.8 KiB
Python

"""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())