""" 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)"}]} ], }