Files
2026-04-05 00:43:23 +05:30

347 lines
12 KiB
Python

"""
Jira REST API v3 client — async, using httpx.
All methods return plain dicts (no Jira SDK objects).
Authentication: Basic auth with email + API token (Jira Cloud standard).
Docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/
"""
import base64
import logging
from typing import Optional
import httpx
from backend.config import (
JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN, ENABLE_JIRA
)
logger = logging.getLogger("thirdeye.integrations.jira")
# ─── Auth ────────────────────────────────────────────────────────────────────
def _auth_header() -> dict:
"""Build the Basic auth header from email + API token."""
raw = f"{JIRA_EMAIL}:{JIRA_API_TOKEN}"
encoded = base64.b64encode(raw.encode()).decode()
return {
"Authorization": f"Basic {encoded}",
"Accept": "application/json",
"Content-Type": "application/json",
}
def _base_url() -> str:
return f"{JIRA_BASE_URL}/rest/api/3"
def is_configured() -> bool:
"""Return True if all required Jira config is set."""
return bool(JIRA_BASE_URL and JIRA_EMAIL and JIRA_API_TOKEN and ENABLE_JIRA)
# ─── Core HTTP helpers ───────────────────────────────────────────────────────
async def _get(path: str, params: dict = None) -> dict:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(
f"{_base_url()}{path}",
headers=_auth_header(),
params=params or {},
)
resp.raise_for_status()
return resp.json()
async def _post(path: str, body: dict) -> dict:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
f"{_base_url()}{path}",
headers=_auth_header(),
json=body,
)
resp.raise_for_status()
return resp.json()
async def _put(path: str, body: dict) -> dict:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.put(
f"{_base_url()}{path}",
headers=_auth_header(),
json=body,
)
resp.raise_for_status()
# PUT /issue returns 204 No Content on success
if resp.status_code == 204:
return {"ok": True}
return resp.json()
# ─── Public API ──────────────────────────────────────────────────────────────
async def test_connection() -> dict:
"""
Verify credentials work by calling /myself.
Returns {"ok": True, "displayName": "...", "email": "..."} or {"ok": False, "error": "..."}
"""
try:
data = await _get("/myself")
return {
"ok": True,
"display_name": data.get("displayName", "Unknown"),
"email": data.get("emailAddress", "Unknown"),
"account_id": data.get("accountId", ""),
}
except httpx.HTTPStatusError as e:
return {"ok": False, "error": f"HTTP {e.response.status_code}: {e.response.text[:200]}"}
except Exception as e:
return {"ok": False, "error": str(e)}
async def list_projects() -> list[dict]:
"""
List all accessible Jira projects.
Returns list of {"key": "ENG", "name": "Engineering", "id": "10001"}
"""
data = await _get("/project/search", params={"maxResults": 50})
return [
{
"key": p["key"],
"name": p["name"],
"id": p["id"],
"type": p.get("projectTypeKey", "software"),
}
for p in data.get("values", [])
]
async def list_issue_types(project_key: str) -> list[dict]:
"""
List issue types available for a specific project.
Returns list of {"id": "10001", "name": "Bug", "subtask": False}
"""
data = await _get(f"/project/{project_key}")
issue_types = data.get("issueTypes", [])
return [
{
"id": it["id"],
"name": it["name"],
"subtask": it.get("subtask", False),
}
for it in issue_types
if not it.get("subtask", False) # Exclude subtask types
]
async def get_issue(issue_key: str) -> dict:
"""
Get a single issue by key (e.g. "ENG-42").
Returns simplified issue dict.
"""
data = await _get(f"/issue/{issue_key}")
fields = data.get("fields", {})
return {
"key": data["key"],
"id": data["id"],
"summary": fields.get("summary", ""),
"status": fields.get("status", {}).get("name", "Unknown"),
"priority": fields.get("priority", {}).get("name", "Medium"),
"assignee": (fields.get("assignee") or {}).get("displayName", "Unassigned"),
"issue_type": fields.get("issuetype", {}).get("name", "Task"),
"url": f"{JIRA_BASE_URL}/browse/{data['key']}",
"created": fields.get("created", ""),
"updated": fields.get("updated", ""),
}
async def create_issue(
project_key: str,
summary: str,
description: str,
issue_type: str = "Task",
priority: str = "Medium",
labels: list[str] = None,
assignee_account_id: str = None,
) -> dict:
"""
Create a new Jira issue.
Args:
project_key: Project key (e.g. "ENG")
summary: Issue title (max ~250 chars)
description: Full description in Atlassian Document Format (ADF)
issue_type: "Task", "Bug", "Story", "Epic"
priority: "Highest", "High", "Medium", "Low", "Lowest"
labels: List of label strings (no spaces allowed in labels)
assignee_account_id: Jira account ID to assign to (optional)
Returns:
{"key": "ENG-42", "id": "10042", "url": "https://..."}
"""
fields: dict = {
"project": {"key": project_key},
"summary": summary[:255], # Jira hard limit
"description": _text_to_adf(description),
"issuetype": {"name": issue_type},
"priority": {"name": priority},
}
if labels:
# Jira labels cannot have spaces — replace with hyphens
fields["labels"] = [l.replace(" ", "-") for l in labels]
if assignee_account_id:
fields["assignee"] = {"accountId": assignee_account_id}
body = {"fields": fields}
try:
data = await _post("/issue", body)
issue_key = data["key"]
return {
"ok": True,
"key": issue_key,
"id": data["id"],
"url": f"{JIRA_BASE_URL}/browse/{issue_key}",
}
except httpx.HTTPStatusError as e:
error_body = {}
try:
error_body = e.response.json()
except Exception:
pass
errors = error_body.get("errors", {})
messages = error_body.get("errorMessages", [])
return {
"ok": False,
"error": f"HTTP {e.response.status_code}",
"details": errors or messages or e.response.text[:300],
}
except Exception as e:
return {"ok": False, "error": str(e)}
async def search_issues(jql: str, max_results: int = 10) -> list[dict]:
"""
Search issues using JQL (Jira Query Language).
Example JQL: 'project = ENG AND labels = thirdeye AND status != Done'
Returns list of simplified issue dicts.
"""
data = await _get("/search/jql", params={
"jql": jql,
"maxResults": max_results,
"fields": "summary,status,priority,assignee,issuetype,labels,created",
})
results = []
for issue in data.get("issues", []):
fields = issue.get("fields", {})
results.append({
"key": issue["key"],
"summary": fields.get("summary", ""),
"status": fields.get("status", {}).get("name", "Unknown"),
"priority": fields.get("priority", {}).get("name", "Medium"),
"assignee": (fields.get("assignee") or {}).get("displayName", "Unassigned"),
"issue_type": fields.get("issuetype", {}).get("name", "Task"),
"labels": fields.get("labels", []),
"url": f"{JIRA_BASE_URL}/browse/{issue['key']}",
})
return results
async def search_users(query: str, max_results: int = 10) -> list[dict]:
"""
Search Jira users by display name or email fragment.
Returns list of {"account_id", "display_name", "email", "active"}.
"""
try:
data = await _get("/user/search", params={"query": query, "maxResults": max_results})
return [
{
"account_id": u.get("accountId", ""),
"display_name": u.get("displayName", ""),
"email": u.get("emailAddress", ""),
"active": u.get("active", True),
}
for u in data
if u.get("active", True)
]
except Exception as e:
logger.warning(f"User search failed for '{query}': {e}")
return []
async def assign_issue(issue_key: str, account_id: str) -> dict:
"""
Assign a Jira issue to a user by their Jira account ID.
Returns {"ok": True} on success or {"ok": False, "error": "..."}.
"""
try:
await _put(f"/issue/{issue_key}/assignee", {"accountId": account_id})
return {"ok": True}
except httpx.HTTPStatusError as e:
return {"ok": False, "error": f"HTTP {e.response.status_code}: {e.response.text[:200]}"}
except Exception as e:
return {"ok": False, "error": str(e)}
async def add_comment(issue_key: str, comment: str) -> dict:
"""Add a plain-text comment to an existing issue."""
try:
data = await _post(f"/issue/{issue_key}/comment", {
"body": _text_to_adf(comment)
})
return {"ok": True, "id": data.get("id")}
except Exception as e:
return {"ok": False, "error": str(e)}
# ─── ADF helper ──────────────────────────────────────────────────────────────
def _text_to_adf(text: str) -> dict:
"""
Convert plain text to Atlassian Document Format (ADF).
Jira Cloud requires ADF for description/comment fields (not plain strings).
Splits on double newlines to create separate paragraphs.
"""
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
if not paragraphs:
paragraphs = [text.strip() or "(no description)"]
content = []
for para in paragraphs:
# Handle bullet lines within a paragraph (lines starting with - or *)
lines = para.split("\n")
bullet_items = [l.lstrip("-* ").strip() for l in lines if l.strip().startswith(("-", "*", ""))]
non_bullets = [l for l in lines if not l.strip().startswith(("-", "*", ""))]
if non_bullets:
content.append({
"type": "paragraph",
"content": [{"type": "text", "text": " ".join(non_bullets)}],
})
if bullet_items:
content.append({
"type": "bulletList",
"content": [
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [{"type": "text", "text": item}],
}
],
}
for item in bullet_items
],
})
return {
"type": "doc",
"version": 1,
"content": content or [
{"type": "paragraph", "content": [{"type": "text", "text": "(no description)"}]}
],
}