enhance: configurable fields, format, JQL for Jira connector

Match ServiceNow's configurability: custom JQL, field selection,
markdown/json format, and limit. Supports query string params:
  jira:PROJ?jql=status=open&fields=summary,status&format=json&limit=50
This commit is contained in:
Timothy Jaeryang Baek 2026-05-20 22:55:10 +04:00
parent ff37b83b14
commit f3ca110358
2 changed files with 88 additions and 16 deletions

View file

@ -96,7 +96,14 @@ def _resolve_connector(source: str, branch: str | None = None, path: str | None
if source.startswith("jira:"):
from oikb.connectors.jira import JiraConnector, parse_jira_source
parsed = parse_jira_source(source)
return JiraConnector(project_key=parsed["project_key"])
fields = parsed["fields"].split(",") if parsed.get("fields") else None
return JiraConnector(
project_key=parsed["project_key"],
jql=parsed.get("jql"),
fields=fields,
fmt=parsed.get("format", "markdown"),
limit=int(parsed["limit"]) if parsed.get("limit") else None,
)
if source.startswith("sharepoint:"):
from oikb.connectors.sharepoint import SharePointConnector, parse_sharepoint_source

View file

@ -1,13 +1,20 @@
"""Jira connector — sync issues from a Jira project to a Knowledge Base.
Auth via JIRA_URL, JIRA_USER, JIRA_TOKEN env vars.
Configurable:
- project_key: Jira project key
- jql: custom JQL query (overrides default project filter)
- fields: which fields to export (default: summary, description, status, issuetype)
- format: output format 'markdown' (default) or 'json'
- limit: max issues to fetch (default: unlimited)
"""
from __future__ import annotations
import hashlib
import json as json_mod
import os
import re
from typing import Any
import httpx
@ -21,6 +28,10 @@ class JiraConnector(BaseConnector):
def __init__(
self,
project_key: str,
jql: str | None = None,
fields: list[str] | None = None,
fmt: str = "markdown",
limit: int | None = None,
base_url: str | None = None,
user: str | None = None,
token: str | None = None,
@ -35,6 +46,10 @@ class JiraConnector(BaseConnector):
if not self._token:
raise ValueError("Jira API token required. Set JIRA_TOKEN env var.")
self._jql = jql or f"project={self.project_key}"
self._fields = fields or ["summary", "description", "status", "issuetype", "priority", "assignee"]
self._fmt = fmt
self._limit = limit
self._http = httpx.Client(
base_url=self._base_url,
auth=(self._user, self._token) if self._user else None,
@ -45,37 +60,78 @@ class JiraConnector(BaseConnector):
def build_manifest(self) -> list[ManifestEntry]:
entries: list[ManifestEntry] = []
api_fields = list(set(self._fields + ["summary", "updated"]))
start = 0
fetched = 0
while True:
max_results = 100
if self._limit:
max_results = min(100, self._limit - fetched)
if max_results <= 0:
break
resp = self._http.get(
"/rest/api/3/search",
params={"jql": f"project={self.project_key}", "startAt": start, "maxResults": 100,
"fields": "summary,description,status,updated,issuetype"},
params={"jql": self._jql, "startAt": start, "maxResults": max_results,
"fields": ",".join(api_fields)},
)
resp.raise_for_status()
data = resp.json()
for issue in data.get("issues", []):
issues = data.get("issues", [])
for issue in issues:
key = issue["key"]
fields = issue["fields"]
updated = fields.get("updated", "")
checksum = hashlib.sha256(f"{key}:{updated}".encode()).hexdigest()[:16]
text = self._format_issue(issue)
self._issue_cache[f"{key}.txt"] = text
entries.append(ManifestEntry(filename=f"{key}.txt", path="", checksum=checksum, size=len(text.encode())))
if start + len(data.get("issues", [])) >= data.get("total", 0):
ext = ".json" if self._fmt == "json" else ".md"
filename = f"{key}{ext}"
self._issue_cache[filename] = text
entries.append(ManifestEntry(filename=filename, path="", checksum=checksum, size=len(text.encode())))
fetched += len(issues)
if start + len(issues) >= data.get("total", 0):
break
start += len(data.get("issues", []))
start += len(issues)
entries.sort(key=lambda e: e.display_path)
return entries
def _resolve_field(self, fields: dict, field_name: str) -> str:
"""Extract display value from a Jira field."""
val = fields.get(field_name)
if val is None:
return ""
if isinstance(val, dict):
# ADF document (description)
if val.get("type") == "doc":
return self._adf_to_text(val)
# Object with name (status, issuetype, priority, assignee)
return val.get("name", val.get("displayName", val.get("value", str(val))))
if isinstance(val, list):
return ", ".join(str(v.get("name", v) if isinstance(v, dict) else v) for v in val)
return str(val)
def _format_issue(self, issue: dict) -> str:
fields = issue["fields"]
key = issue["key"]
if self._fmt == "json":
data = {"key": key}
for f in self._fields:
data[f] = self._resolve_field(fields, f)
return json_mod.dumps(data, indent=2, ensure_ascii=False)
# Default: markdown
summary = fields.get("summary", "")
status = fields.get("status", {}).get("name", "")
issue_type = fields.get("issuetype", {}).get("name", "")
desc = self._adf_to_text(fields.get("description"))
lines = [f"# [{key}] {summary}", f"Type: {issue_type}", f"Status: {status}", "", desc]
lines = [f"# [{key}] {summary}", ""]
for f in self._fields:
if f in ("summary", "description"):
continue
val = self._resolve_field(fields, f)
if val:
lines.append(f"**{f}**: {val}")
desc = self._resolve_field(fields, "description") if "description" in self._fields else ""
if desc:
lines.extend(["", desc])
return "\n".join(lines)
def _adf_to_text(self, doc: dict | None) -> str:
@ -106,7 +162,16 @@ class JiraConnector(BaseConnector):
def parse_jira_source(source: str) -> dict[str, str | None]:
key = source.removeprefix("jira:")
if not key:
"""Parse jira:<PROJECT_KEY> or jira:<PROJECT_KEY>?jql=...&fields=...&format=..."""
raw = source.removeprefix("jira:")
parts = raw.split("?", 1)
project_key = parts[0]
if not project_key:
raise ValueError("Invalid Jira source. Expected: jira:<PROJECT_KEY>")
return {"project_key": key}
result: dict[str, str | None] = {"project_key": project_key}
if len(parts) > 1:
for param in parts[1].split("&"):
if "=" in param:
k, v = param.split("=", 1)
result[k] = v
return result