mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 23:13:15 +00:00
269 lines
9.2 KiB
Python
269 lines
9.2 KiB
Python
|
|
"""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 `<PROJECT>-<number>` (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 `<PROJECT>-<number>` 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}
|