mirror of
https://github.com/arkorty/B.Tech-Project-III.git
synced 2026-04-19 20:51:49 +00:00
init
This commit is contained in:
346
thirdeye/backend/integrations/jira_client.py
Normal file
346
thirdeye/backend/integrations/jira_client.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""
|
||||
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)"}]}
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user