"""Jira Cloud REST API utilities. Mirrors ``agent/utils/linear.py`` but talks to Jira Cloud REST v3 with a single service-account (Basic auth over ``email:api_token``). Issue/comment bodies are Atlassian Document Format (ADF), so read paths convert ADF -> markdown and write paths convert markdown -> ADF via ``agent/utils/adf.py``. """ from __future__ import annotations import base64 import logging import os import re from typing import Any from urllib.parse import quote import httpx from agent.utils.langsmith import get_langsmith_trace_url from .adf import adf_to_markdown, markdown_to_adf from .http import DEFAULT_HTTP_TIMEOUT logger = logging.getLogger(__name__) # Jira issue keys are `-` (e.g. PROJ-123). Untrusted webhook # input is interpolated into REST paths, so keys are validated against this and # path segments are percent-encoded to prevent traversal / query injection. _ISSUE_KEY_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*-\d+$") def is_valid_issue_key(issue_key: str) -> bool: """Whether ``issue_key`` matches the Jira `-` format.""" return bool(issue_key) and bool(_ISSUE_KEY_RE.match(issue_key)) def _seg(value: str) -> str: """Percent-encode a single untrusted URL path segment (no '/' passthrough).""" return quote(value, safe="") JIRA_BASE_URL = os.environ.get("JIRA_BASE_URL", "").rstrip("/") # https://seahaven.atlassian.net JIRA_EMAIL = os.environ.get("JIRA_SERVICE_EMAIL", "") JIRA_API_TOKEN = os.environ.get("JIRA_API_TOKEN", "") _ISSUE_FIELDS = ( "summary,description,status,assignee,reporter,priority,labels," "project,issuetype,created,updated,comment" ) def _headers() -> dict[str, str]: token = base64.b64encode(f"{JIRA_EMAIL}:{JIRA_API_TOKEN}".encode()).decode() return { "Authorization": f"Basic {token}", "Content-Type": "application/json", "Accept": "application/json", } async def _request( method: str, path: str, *, json: dict[str, Any] | None = None, params: dict[str, Any] | None = None, ) -> dict[str, Any]: """Execute a REST request against the Jira Cloud v3 API.""" if not (JIRA_BASE_URL and JIRA_EMAIL and JIRA_API_TOKEN): return {"error": "JIRA_BASE_URL / JIRA_SERVICE_EMAIL / JIRA_API_TOKEN are not set"} async with httpx.AsyncClient(timeout=DEFAULT_HTTP_TIMEOUT) as client: try: response = await client.request( method, f"{JIRA_BASE_URL}/rest/api/3{path}", headers=_headers(), json=json, params=params, ) response.raise_for_status() return response.json() if response.content else {} except Exception as e: # noqa: BLE001 return {"error": str(e)} def _issue_url(issue_key: str) -> str: return f"{JIRA_BASE_URL}/browse/{_seg(issue_key)}" if JIRA_BASE_URL else "" def _normalize_issue(raw: dict[str, Any]) -> dict[str, Any]: """Flatten a raw Jira issue into an agent-friendly dict with markdown bodies.""" fields = raw.get("fields", {}) or {} project = fields.get("project") or {} status = fields.get("status") or {} assignee = fields.get("assignee") or {} priority = fields.get("priority") or {} issue_type = fields.get("issuetype") or {} return { "key": raw.get("key", ""), "id": raw.get("id", ""), "title": fields.get("summary", ""), "description": adf_to_markdown(fields.get("description")), "status": status.get("name", ""), "assignee": { "name": assignee.get("displayName"), "email": assignee.get("emailAddress"), "account_id": assignee.get("accountId"), } if assignee else None, "priority": priority.get("name", ""), "labels": fields.get("labels", []), "project_key": project.get("key", ""), "project_name": project.get("name", ""), "issue_type": issue_type.get("name", ""), "created": fields.get("created", ""), "updated": fields.get("updated", ""), "url": _issue_url(raw.get("key", "")), } def _normalize_comment(raw: dict[str, Any]) -> dict[str, Any]: author = raw.get("author") or {} return { "id": raw.get("id", ""), "body": adf_to_markdown(raw.get("body")), "created": raw.get("created", ""), "updated": raw.get("updated", ""), "author": { "name": author.get("displayName"), "email": author.get("emailAddress"), "account_id": author.get("accountId"), }, } async def comment_on_issue(issue_key: str, comment_body: str) -> bool: """Add a comment (markdown) to a Jira issue. Returns True on success.""" result = await _request( "POST", f"/issue/{_seg(issue_key)}/comment", json={"body": markdown_to_adf(comment_body)}, ) return bool(result.get("id")) and "error" not in result async def post_jira_trace_comment(issue_key: str, thread_id: str) -> None: """Post a trace URL comment on a Jira issue.""" trace_url = get_langsmith_trace_url(thread_id) body = f"On it! [View trace]({trace_url})" if trace_url else "On it!" await comment_on_issue(issue_key, body) async def get_user_email(account_id: str) -> str | None: """Look up a Jira user's email by accountId (webhooks only carry accountId).""" result = await _request("GET", "/user", params={"accountId": account_id}) if "error" in result: return None return result.get("emailAddress") async def get_issue(issue_key: str) -> dict[str, Any]: """Get a Jira issue by its key (e.g. PROJ-123).""" result = await _request("GET", f"/issue/{_seg(issue_key)}", params={"fields": _ISSUE_FIELDS}) if "error" in result: return result return {"issue": _normalize_issue(result)} async def get_issue_comments(issue_key: str) -> dict[str, Any]: """Get comments for a Jira issue (newest ordering as returned by Jira).""" result = await _request("GET", f"/issue/{_seg(issue_key)}/comment") if "error" in result: return result comments = result.get("comments", []) return {"comments": [_normalize_comment(c) for c in comments]} async def get_comment(issue_key: str, comment_id: str) -> dict[str, Any]: """Fetch a single comment by id — the authoritative record for a webhook. Webhook payloads are unsigned, so the triggering comment's real author and body must be read from Jira server-side (matched by comment_id) rather than trusted from the payload. """ result = await _request("GET", f"/issue/{_seg(issue_key)}/comment/{_seg(comment_id)}") if "error" in result: return result return {"comment": _normalize_comment(result)} async def create_issue( project_key: str, summary: str, description: str | None = None, issue_type: str = "Task", assignee_account_id: str | None = None, priority: str | None = None, labels: list[str] | None = None, ) -> dict[str, Any]: """Create a new Jira issue.""" fields: dict[str, Any] = { "project": {"key": project_key}, "summary": summary, "issuetype": {"name": issue_type}, } if description is not None: fields["description"] = markdown_to_adf(description) if assignee_account_id is not None: fields["assignee"] = {"accountId": assignee_account_id} if priority is not None: fields["priority"] = {"name": priority} if labels is not None: fields["labels"] = labels result = await _request("POST", "/issue", json={"fields": fields}) if "error" in result: return result key = result.get("key", "") return { "success": bool(key), "issue": {"key": key, "id": result.get("id", ""), "url": _issue_url(key)}, } async def update_issue( issue_key: str, summary: str | None = None, description: str | None = None, assignee_account_id: str | None = None, priority: str | None = None, labels: list[str] | None = None, ) -> dict[str, Any]: """Update an existing Jira issue.""" fields: dict[str, Any] = {} if summary is not None: fields["summary"] = summary if description is not None: fields["description"] = markdown_to_adf(description) if assignee_account_id is not None: fields["assignee"] = {"accountId": assignee_account_id} if priority is not None: fields["priority"] = {"name": priority} if labels is not None: fields["labels"] = labels if not fields: return {"error": "No fields to update"} # A 204 (empty body) is success; _request returns {} in that case. result = await _request("PUT", f"/issue/{_seg(issue_key)}", json={"fields": fields}) if "error" in result: return result return {"success": True, "issue": {"key": issue_key, "url": _issue_url(issue_key)}} async def list_projects() -> dict[str, Any]: """List projects visible to the service account.""" result = await _request("GET", "/project/search") if "error" in result: return result projects = [ {"key": p.get("key", ""), "name": p.get("name", ""), "id": p.get("id", "")} for p in result.get("values", []) ] return {"projects": projects}