traceRAG-backend / scripts /ingest_jira_api.py
Karush2807
TraceRAG backend + demo graphs
db284b9
Raw
History Blame Contribute Delete
7.61 kB
"""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())