mirror of
https://github.com/arkorty/B.Tech-Project-III.git
synced 2026-04-19 20:51:49 +00:00
347 lines
12 KiB
Python
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)"}]}
|
|
],
|
|
}
|
|
|