Spaces:
Running
Running
| """Ingest real Jira tickets straight from a public Jira REST API. | |
| Pulls issues for one project (default: Apache Kafka on issues.apache.org), | |
| maps each to the same text shape the CSV pipeline uses, and runs them through | |
| the existing extraction -> curation -> graph pipeline. | |
| # see what would be ingested, no DB / no GLiNER: | |
| python scripts/ingest_jira_api.py --project KAFKA --limit 20 --dry-run | |
| # build a fresh graph the dropdown will auto-discover: | |
| python scripts/ingest_jira_api.py --project KAFKA --limit 150 \ | |
| --db graphs/apache__kafka.lbug --reset | |
| Any public Jira works via --base, e.g. Apache SPARK / LUCENE / FLINK, or | |
| Hyperledger, etc. Requires no auth for public instances. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import logging | |
| import sys | |
| import urllib.parse | |
| import urllib.request | |
| from pathlib import Path | |
| _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 | |
| from ingest_kaggle_jira import clean_jira_text # noqa: E402 (reuse markup stripper) | |
| logger = logging.getLogger("tracerag.jira_api") | |
| DEFAULT_BASE = "https://issues.apache.org/jira" | |
| _FIELDS = "key,summary,issuetype,status,priority,assignee,reporter,components,labels,description" | |
| _PAGE = 100 # Jira caps maxResults at 100 for most instances | |
| def _name(obj: dict | None, key: str = "name") -> str: | |
| """Safely pull a display string out of a nested Jira field object.""" | |
| if not isinstance(obj, dict): | |
| return "" | |
| return str(obj.get(key) or "").strip() | |
| def assemble_text(issue: dict) -> tuple[str, str]: | |
| """Build (doc_id, clean text blob) from one Jira REST issue record.""" | |
| f = issue.get("fields") or {} | |
| key = str(issue.get("key") or "").strip() | |
| parts: list[str] = [] | |
| summary = str(f.get("summary") or "").strip() | |
| if key or summary: | |
| parts.append(f"Ticket {key}: {summary}.".strip()) | |
| for label, val in ( | |
| ("Type", _name(f.get("issuetype"))), | |
| ("Status", _name(f.get("status"))), | |
| ("Priority", _name(f.get("priority"))), | |
| ): | |
| if val: | |
| parts.append(f"{label}: {val}.") | |
| assignee = _name(f.get("assignee"), "displayName") | |
| if assignee: | |
| parts.append(f"Assigned to {assignee}.") | |
| reporter = _name(f.get("reporter"), "displayName") | |
| if reporter: | |
| parts.append(f"Reported by {reporter}.") | |
| components = ", ".join( | |
| c.get("name", "") for c in (f.get("components") or []) if c.get("name") | |
| ) | |
| if components: | |
| parts.append(f"Component: {components}.") | |
| labels = ", ".join(l for l in (f.get("labels") or []) if l) | |
| if labels: | |
| parts.append(f"Labels: {labels}.") | |
| desc = clean_jira_text(str(f.get("description") or "")) | |
| if desc: | |
| parts.append(f"Description: {desc}") | |
| return key, " ".join(parts) | |
| def fetch_issues(base: str, jql: str, limit: int) -> list[dict]: | |
| """Page through the Jira search API until `limit` issues are collected.""" | |
| out: list[dict] = [] | |
| start = 0 | |
| while len(out) < limit: | |
| page_size = min(_PAGE, limit - len(out)) | |
| params = urllib.parse.urlencode( | |
| {"jql": jql, "startAt": start, "maxResults": page_size, "fields": _FIELDS} | |
| ) | |
| url = f"{base.rstrip('/')}/rest/api/2/search?{params}" | |
| req = urllib.request.Request(url, headers={"Accept": "application/json"}) | |
| logger.info("GET %s (have %d/%d)", url.split("?")[0], len(out), limit) | |
| with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 (trusted host) | |
| data = json.loads(resp.read().decode("utf-8")) | |
| issues = data.get("issues") or [] | |
| if not issues: | |
| break | |
| out.extend(issues) | |
| total = data.get("total", 0) | |
| start += len(issues) | |
| if start >= total: | |
| break | |
| return out[:limit] | |
| def parse_args(argv: list[str] | None = None) -> argparse.Namespace: | |
| p = argparse.ArgumentParser(description="Ingest Jira tickets from a public REST API.") | |
| p.add_argument("--project", default="KAFKA", help="Jira project key, e.g. KAFKA, SPARK.") | |
| p.add_argument("--base", default=DEFAULT_BASE, help="Jira base URL.") | |
| p.add_argument("--jql", default=None, | |
| help="Override the JQL (default: project=<PROJECT> ORDER BY created DESC).") | |
| p.add_argument("--limit", type=int, default=150, help="Number of issues to ingest.") | |
| p.add_argument("--db", type=Path, default=config.DB_PATH) | |
| p.add_argument("--reset", action="store_true", | |
| help="Delete the existing .lbug (+ sidecars) before ingesting.") | |
| p.add_argument("--dry-run", action="store_true", | |
| help="Fetch + assemble + print samples; no GLiNER, no DB writes.") | |
| p.add_argument("-v", "--verbose", action="store_true") | |
| return p.parse_args(argv) | |
| 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", "sentence_transformers", "urllib3"): | |
| logging.getLogger(noisy).setLevel(logging.WARNING) | |
| jql = args.jql or f"project={args.project} ORDER BY created DESC" | |
| logger.info("Fetching up to %d issues | %s | %s", args.limit, args.base, jql) | |
| issues = fetch_issues(args.base, jql, args.limit) | |
| logger.info("Fetched %d issues", len(issues)) | |
| if args.dry_run: | |
| shown = 0 | |
| empty = 0 | |
| for issue in issues: | |
| doc_id, text = assemble_text(issue) | |
| if not text.strip(): | |
| empty += 1 | |
| continue | |
| if shown < 5: | |
| logger.info("--- %s ---\n%s", doc_id, text[:400]) | |
| shown += 1 | |
| logger.info("DRY RUN: %d ingestable, %d empty, of %d fetched", | |
| len(issues) - empty, empty, len(issues)) | |
| return 0 | |
| if args.reset: | |
| for p in sorted(args.db.parent.glob(args.db.name + "*")): | |
| logger.info("Reset: removing %s", p) | |
| p.unlink() | |
| extractor = EntityExtractor() | |
| db = TraceDB(args.db) | |
| db.init_schema() | |
| engine = CurationEngine(db) | |
| totals = IngestStats() | |
| skipped = 0 | |
| try: | |
| for i, issue in enumerate(issues): | |
| doc_id, text = assemble_text(issue) | |
| if not text.strip(): | |
| skipped += 1 | |
| continue | |
| totals.merge(ingest_text(engine, extractor, doc_id or f"issue-{i}", text)) | |
| if (i + 1) % 25 == 0: | |
| logger.info(" ingested %d/%d", i + 1, len(issues)) | |
| db.build_vector_index() | |
| logger.info( | |
| "Done. %d issues ingested (%d skipped) | %d entities | " | |
| "created=%d fast=%d deep_yes=%d deep_no=%d llm=%d | " | |
| "rel=%d mentions=%d | nodes_in_db=%d", | |
| 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() | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |