traceRAG-backend / scripts /ingest_github.py
Karush2807
TraceRAG backend + demo graphs
db284b9
Raw
History Blame Contribute Delete
7.26 kB
"""Pull closed GitHub PRs into the TraceRAG graph.
python scripts/ingest_github.py --repo langchain-ai/langchain
python scripts/ingest_github.py --repo owner/repo --limit 100 --reset
Set GITHUB_TOKEN in .env to raise the rate limit from 60/hr to 5000/hr.
"""
from __future__ import annotations
import argparse
import logging
import os
import re
import sys
from pathlib import Path
import requests
from tqdm import tqdm
_SCRIPTS = Path(__file__).resolve().parent
sys.path.insert(0, str(_SCRIPTS.parent))
sys.path.insert(0, str(_SCRIPTS))
from tracerag import config # noqa: E402
from tracerag.db import TraceDB # noqa: E402
from tracerag.extract import EntityExtractor # noqa: E402
from tracerag.curation import CurationEngine, IngestStats # noqa: E402
from ingest import ingest_text # noqa: E402
logger = logging.getLogger("tracerag.github")
GITHUB_API = "https://api.github.com"
_PER_PAGE = 100 # GitHub's max page size
_MD_IMAGE = re.compile(r"!\[[^\]]*\]\([^)]*\)")
_HTML_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
_WS = re.compile(r"\s+")
def clean_pr_body(body: str | None) -> str:
"""Strip markdown images and HTML comments; collapse whitespace."""
if not body:
return ""
t = _MD_IMAGE.sub(" ", body)
t = _HTML_COMMENT.sub(" ", t)
return _WS.sub(" ", t).strip()
def assemble_text(pr: dict) -> tuple[str, str]:
"""Build (doc_id, clean text blob) the existing pipeline can parse."""
number = pr.get("number")
title = (pr.get("title") or "").strip()
author = (pr.get("user") or {}).get("login") or "unknown"
# rstrip trailing periods/spaces so we don't emit "...client.."
body = clean_pr_body(pr.get("body")).rstrip(". ")
text = f"PR #{number} merged by {author}. Title: {title}. Description: {body}."
return f"pr-{number}", text
def fetch_merged_prs(repo: str, limit: int, token: str | None) -> list[dict]:
"""Page through closed PRs, keeping only merged ones (merged_at != null), until limit."""
headers = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
if token:
headers["Authorization"] = f"Bearer {token}"
merged: list[dict] = []
page = 1
while len(merged) < limit:
resp = requests.get(
f"{GITHUB_API}/repos/{repo}/pulls",
headers=headers,
params={"state": "closed", "per_page": _PER_PAGE, "page": page},
timeout=30,
)
if resp.status_code != 200:
raise RuntimeError(
f"GitHub API {resp.status_code} for {repo}: {resp.text[:200]}"
)
batch = resp.json()
if not batch:
break
merged.extend(pr for pr in batch if pr.get("merged_at"))
page += 1
return merged[:limit]
def repo_db_path(repo: str, graphs_dir: Path) -> Path:
"""Per-repo .lbug file, e.g. pallets/flask -> graphs/pallets__flask.lbug."""
slug = re.sub(r"[^a-z0-9_]+", "-", repo.lower().replace("/", "__"))
return graphs_dir / f"{slug}.lbug"
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
p = argparse.ArgumentParser(description="Ingest closed GitHub PRs into TraceRAG.")
p.add_argument("--repo", nargs="+", required=True,
help='One or more repos, e.g. "pallets/flask psf/requests".')
p.add_argument("--limit", type=int, default=50,
help="Max MERGED PRs to ingest per repo (protects rate limits).")
p.add_argument("--db", type=Path, default=None,
help="Override output file (single-repo only; otherwise one "
"per-repo file is created under --graphs-dir).")
p.add_argument("--graphs-dir", type=Path, default=None,
help="Directory for per-repo .lbug files (default backend/graphs).")
p.add_argument("--reset", action="store_true",
help="Delete each target .lbug (+ sidecars) before ingesting.")
p.add_argument("-v", "--verbose", action="store_true")
return p.parse_args(argv)
def ingest_repo(
repo: str, db_path: Path, limit: int, reset: bool,
token: str | None, extractor: EntityExtractor,
) -> None:
"""Fetch + ingest one repo's merged PRs into its own .lbug file."""
db_path.parent.mkdir(parents=True, exist_ok=True)
if reset:
for p in sorted(db_path.parent.glob(db_path.name + "*")):
logger.info("[%s] reset: removing %s", repo, p.name)
p.unlink()
logger.info("[%s] fetching up to %d merged PRs", repo, limit)
prs = fetch_merged_prs(repo, limit, token)
logger.info("[%s] fetched %d merged PRs -> %s", repo, len(prs), db_path.name)
db = TraceDB(db_path)
db.init_schema()
engine = CurationEngine(db)
totals, skipped = IngestStats(), 0
try:
for pr in tqdm(prs, total=len(prs), desc=f"{repo}", unit="pr"):
doc_id, text = assemble_text(pr)
if not text.strip():
skipped += 1
continue
totals.merge(
ingest_text(engine, extractor, doc_id, text, source=pr.get("html_url"))
)
db.build_vector_index()
logger.info(
"[%s] done. %d PRs (%d skipped) | %d entities | "
"created=%d fast=%d deep_yes=%d deep_no=%d llm=%d | "
"rel=%d mentions=%d | nodes_in_db=%d",
repo, totals.docs, skipped, totals.entities, totals.created,
totals.fast_merged, totals.deep_merged_yes, totals.deep_merged_no,
totals.ollama_calls, totals.relates_edges, totals.mentions_edges,
db.count_nodes(),
)
finally:
db.close()
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s %(message)s",
)
for noisy in ("httpx", "httpcore", "openai", "urllib3", "sentence_transformers"):
logging.getLogger(noisy).setLevel(logging.WARNING)
token = os.getenv("GITHUB_TOKEN")
if not token:
logger.warning("GITHUB_TOKEN not set — unauthenticated requests are capped "
"at 60/hr. Add it to .env to raise the limit.")
repo_root = config.PROJECT_ROOT.parent
graphs_dir = args.graphs_dir or (config.PROJECT_ROOT / "graphs")
if not graphs_dir.is_absolute():
graphs_dir = repo_root / graphs_dir
if args.db and len(args.repo) > 1:
logger.warning("--db is ignored with multiple repos; using per-repo files.")
extractor = EntityExtractor()
for repo in args.repo:
if args.db and len(args.repo) == 1:
db_path = args.db if args.db.is_absolute() else (repo_root / args.db)
else:
db_path = repo_db_path(repo, graphs_dir)
try:
ingest_repo(repo, db_path, args.limit, args.reset, token, extractor)
except Exception as exc: # noqa: BLE001
logger.error("[%s] FAILED: %s", repo, exc)
logger.info("Batch complete. Graphs in %s", graphs_dir)
return 0
if __name__ == "__main__":
raise SystemExit(main())