mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
184 lines
6.9 KiB
Python
184 lines
6.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Snapshot a bounded subset of a GitHub repo into the engineering analytics fixture.
|
|
|
|
Fetches recent pull requests and workflow runs via the `gh` CLI (uses your
|
|
existing `gh auth` token) and writes them, trimmed to the fields the curated
|
|
warehouse views read, to JSON files next to this script:
|
|
|
|
- github_pull_requests.json
|
|
- github_workflow_runs.json
|
|
|
|
Load the result into a local Insights stack with:
|
|
|
|
python manage.py seed_engineering_analytics --team-id <id>
|
|
|
|
Usage:
|
|
python products/engineering_analytics/fixtures/fetch.py
|
|
python products/engineering_analytics/fixtures/fetch.py --repo Insights/insights --days 30 --max-prs 300
|
|
"""
|
|
|
|
import sys
|
|
import json
|
|
import argparse
|
|
import subprocess
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
FIXTURE_DIR = Path(__file__).parent
|
|
|
|
# Only the fields the curated query builders in backend/logic/views read.
|
|
PR_FIELDS = (
|
|
"id",
|
|
"number",
|
|
"title",
|
|
"state",
|
|
"draft",
|
|
"created_at",
|
|
"updated_at",
|
|
"merged_at",
|
|
"closed_at",
|
|
# Keyed on by the runs builder to attribute a default-branch push to the PR that landed it.
|
|
"merge_commit_sha",
|
|
)
|
|
RUN_FIELDS = (
|
|
"id",
|
|
"name",
|
|
"head_sha",
|
|
"head_branch",
|
|
"status",
|
|
"conclusion",
|
|
"created_at",
|
|
"run_started_at",
|
|
"updated_at",
|
|
"run_attempt",
|
|
)
|
|
|
|
|
|
def log(message: str) -> None:
|
|
print(message, file=sys.stderr) # noqa: T201 — CLI progress output
|
|
|
|
|
|
def write_records(path: Path, records: list[dict[str, Any]]) -> None:
|
|
lines = ",\n".join(json.dumps(record, separators=(",", ":")) for record in records)
|
|
path.write_text(f"[\n{lines}\n]\n")
|
|
|
|
|
|
def gh_api(path: str) -> Any:
|
|
result = subprocess.run(["gh", "api", path], capture_output=True, text=True, check=True)
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def trim_pr(pr: dict[str, Any]) -> dict[str, Any]:
|
|
trimmed: dict[str, Any] = {field: pr[field] for field in PR_FIELDS}
|
|
trimmed["user"] = {"login": pr["user"]["login"], "avatar_url": pr["user"]["avatar_url"]}
|
|
trimmed["head"] = {"sha": pr["head"]["sha"]}
|
|
trimmed["base"] = {"repo": {"full_name": pr["base"]["repo"]["full_name"]}}
|
|
trimmed["labels"] = [{"name": label["name"]} for label in pr["labels"]]
|
|
return trimmed
|
|
|
|
|
|
def trim_run(run: dict[str, Any]) -> dict[str, Any]:
|
|
trimmed: dict[str, Any] = {field: run[field] for field in RUN_FIELDS}
|
|
trimmed["repository"] = {"full_name": run["repository"]["full_name"], "id": run["repository"]["id"]}
|
|
# The PR-list push / re-run rollup attributes runs to a PR via this association. `base.repo.id`
|
|
# is load-bearing, not decoration: GitHub lists every PR in the fork network sharing the run's
|
|
# head SHA, and the curated builder keeps only the entries based in this repo.
|
|
trimmed["pull_requests"] = [
|
|
{"number": pr["number"], "base": {"repo": {"id": pr["base"]["repo"]["id"]}}}
|
|
for pr in (run.get("pull_requests") or [])
|
|
]
|
|
# A default-branch push has no association at all — its squash-merge subject is the only PR
|
|
# attribution it has, and the author fields back the ci_job_history view.
|
|
head_commit = run.get("head_commit") or {}
|
|
trimmed["head_commit"] = {
|
|
"message": head_commit.get("message", ""),
|
|
"author": {
|
|
"name": (head_commit.get("author") or {}).get("name", ""),
|
|
"email": (head_commit.get("author") or {}).get("email", ""),
|
|
},
|
|
}
|
|
return trimmed
|
|
|
|
|
|
def fetch_pull_requests(repo: str, cutoff: datetime, max_prs: int) -> list[dict[str, Any]]:
|
|
prs: list[dict[str, Any]] = []
|
|
for page in range(1, (max_prs // 100) + 2):
|
|
batch = gh_api(f"repos/{repo}/pulls?state=all&sort=updated&direction=desc&per_page=100&page={page}")
|
|
if not batch:
|
|
break
|
|
for pr in batch:
|
|
if datetime.fromisoformat(pr["updated_at"]) < cutoff:
|
|
return prs
|
|
prs.append(trim_pr(pr))
|
|
if len(prs) >= max_prs:
|
|
return prs
|
|
return prs
|
|
|
|
|
|
def fetch_window_runs(repo: str, cutoff: datetime, max_runs: int) -> list[dict[str, Any]]:
|
|
runs: list[dict[str, Any]] = []
|
|
created = cutoff.strftime("%Y-%m-%d")
|
|
for page in range(1, (max_runs // 100) + 2):
|
|
batch = gh_api(f"repos/{repo}/actions/runs?created=>{created}&per_page=100&page={page}")["workflow_runs"]
|
|
if not batch:
|
|
break
|
|
runs.extend(trim_run(run) for run in batch)
|
|
if len(runs) >= max_runs:
|
|
return runs[:max_runs]
|
|
return runs
|
|
|
|
|
|
def fetch_head_sha_runs(repo: str, prs: list[dict[str, Any]], max_lookups: int) -> list[dict[str, Any]]:
|
|
# The PR list joins CI status by head SHA, so open PRs need their head runs
|
|
# even when those fall outside the windowed fetch.
|
|
runs: list[dict[str, Any]] = []
|
|
open_shas = [pr["head"]["sha"] for pr in prs if pr["state"] == "open"][:max_lookups]
|
|
for index, sha in enumerate(open_shas, start=1):
|
|
batch = gh_api(f"repos/{repo}/actions/runs?head_sha={sha}&per_page=50")["workflow_runs"]
|
|
runs.extend(trim_run(run) for run in batch)
|
|
if index % 25 == 0:
|
|
log(f" head-sha runs: {index}/{len(open_shas)} PRs")
|
|
return runs
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--repo", default="Insights/insights", help="owner/name repo to snapshot (default: %(default)s)")
|
|
parser.add_argument("--days", type=int, default=30, help="recency window in days (default: %(default)s)")
|
|
parser.add_argument("--max-prs", type=int, default=300, help="cap on pull requests (default: %(default)s)")
|
|
parser.add_argument(
|
|
"--max-runs", type=int, default=1000, help="cap on windowed workflow runs (default: %(default)s)"
|
|
)
|
|
parser.add_argument(
|
|
"--max-sha-lookups",
|
|
type=int,
|
|
default=150,
|
|
help="cap on per-open-PR head-sha run lookups (default: %(default)s)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
cutoff = datetime.now(UTC) - timedelta(days=args.days)
|
|
|
|
log(f"Fetching PRs for {args.repo} updated since {cutoff:%Y-%m-%d} (max {args.max_prs})...")
|
|
prs = fetch_pull_requests(args.repo, cutoff, args.max_prs)
|
|
log(f" {len(prs)} PRs")
|
|
|
|
log(f"Fetching workflow runs created since {cutoff:%Y-%m-%d} (max {args.max_runs})...")
|
|
runs = fetch_window_runs(args.repo, cutoff, args.max_runs)
|
|
log(f" {len(runs)} windowed runs")
|
|
|
|
log("Fetching head-sha runs for open PRs...")
|
|
sha_runs = fetch_head_sha_runs(args.repo, prs, args.max_sha_lookups)
|
|
by_id = {run["id"]: run for run in [*runs, *sha_runs]}
|
|
log(f" {len(by_id)} runs after dedup")
|
|
|
|
# One record per line: compact but still diff-able on refresh.
|
|
write_records(FIXTURE_DIR / "github_pull_requests.json", prs)
|
|
write_records(FIXTURE_DIR / "github_workflow_runs.json", sorted(by_id.values(), key=lambda r: r["id"]))
|
|
log(f"Wrote {len(prs)} PRs and {len(by_id)} runs to {FIXTURE_DIR}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|