Spaces:
Running
Running
| """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()) | |