migrated all pawprint work
This commit is contained in:
0
artery/veins/jira/api/__init__.py
Normal file
0
artery/veins/jira/api/__init__.py
Normal file
299
artery/veins/jira/api/routes.py
Normal file
299
artery/veins/jira/api/routes.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
API routes for Jira vein.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import PlainTextResponse, StreamingResponse
|
||||
from typing import Optional, Union
|
||||
from io import BytesIO
|
||||
|
||||
from ..core.auth import get_jira_credentials, JiraCredentials
|
||||
from ..core.client import connect_jira, JiraClientError
|
||||
from ..core.config import settings
|
||||
from ..core.query import JQL, Queries
|
||||
from ..models.ticket import Ticket, TicketDetail, TicketList
|
||||
from ..models.formatter import format_ticket_list, format_ticket_detail
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _download_attachments(jira, ticket: TicketDetail) -> TicketDetail:
|
||||
"""Download attachment content and populate base64 field."""
|
||||
for att in ticket.attachments:
|
||||
try:
|
||||
response = jira._session.get(att.url)
|
||||
if response.status_code == 200:
|
||||
att.content_base64 = base64.b64encode(response.content).decode("utf-8")
|
||||
except Exception:
|
||||
pass # Skip failed downloads
|
||||
return ticket
|
||||
|
||||
|
||||
def _search(creds: JiraCredentials, jql: JQL, page: int, page_size: int) -> TicketList:
|
||||
jira = connect_jira(creds.email, creds.token)
|
||||
start = (page - 1) * page_size
|
||||
issues = jira.search_issues(jql.build(), startAt=start, maxResults=page_size)
|
||||
tickets = [Ticket.from_jira(i, settings.jira_url) for i in issues]
|
||||
return TicketList(tickets=tickets, total=issues.total, page=page, page_size=page_size)
|
||||
|
||||
|
||||
def _maybe_text(data: Union[TicketList, TicketDetail], text: bool):
|
||||
if not text:
|
||||
return data
|
||||
if isinstance(data, TicketList):
|
||||
return PlainTextResponse(format_ticket_list(data))
|
||||
return PlainTextResponse(format_ticket_detail(data))
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health(creds: JiraCredentials = Depends(get_jira_credentials)):
|
||||
try:
|
||||
jira = connect_jira(creds.email, creds.token)
|
||||
me = jira.myself()
|
||||
return {"status": "ok", "user": me["displayName"]}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@router.get("/mine")
|
||||
def my_tickets(
|
||||
creds: JiraCredentials = Depends(get_jira_credentials),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
project: Optional[str] = None,
|
||||
text: bool = False,
|
||||
):
|
||||
"""Get my assigned open tickets."""
|
||||
try:
|
||||
return _maybe_text(_search(creds, Queries.my_tickets(project), page, page_size), text)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@router.get("/backlog")
|
||||
def backlog(
|
||||
creds: JiraCredentials = Depends(get_jira_credentials),
|
||||
project: str = Query(...),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
text: bool = False,
|
||||
):
|
||||
"""Get backlog tickets for a project."""
|
||||
try:
|
||||
return _maybe_text(_search(creds, Queries.backlog(project), page, page_size), text)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@router.get("/sprint")
|
||||
def current_sprint(
|
||||
creds: JiraCredentials = Depends(get_jira_credentials),
|
||||
project: str = Query(...),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
text: bool = False,
|
||||
):
|
||||
"""Get current sprint tickets for a project."""
|
||||
try:
|
||||
return _maybe_text(_search(creds, Queries.current_sprint(project), page, page_size), text)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@router.get("/ticket/{key}")
|
||||
def get_ticket(
|
||||
key: str,
|
||||
creds: JiraCredentials = Depends(get_jira_credentials),
|
||||
text: bool = False,
|
||||
include_attachments: bool = False,
|
||||
include_children: bool = True,
|
||||
):
|
||||
"""Get ticket details with comments, attachments, and child work items."""
|
||||
try:
|
||||
jira = connect_jira(creds.email, creds.token)
|
||||
issue = jira.issue(key, expand="comments")
|
||||
ticket = TicketDetail.from_jira(issue, settings.jira_url)
|
||||
if include_attachments and ticket.attachments:
|
||||
ticket = _download_attachments(jira, ticket)
|
||||
|
||||
# Fetch child work items if requested and ticket has subtasks
|
||||
children = []
|
||||
if include_children and ticket.subtasks:
|
||||
# Fetch all children in one query
|
||||
child_jql = f"key in ({','.join(ticket.subtasks)})"
|
||||
child_issues = jira.search_issues(child_jql, maxResults=len(ticket.subtasks))
|
||||
children = [Ticket.from_jira(i, settings.jira_url) for i in child_issues]
|
||||
# Sort children by key
|
||||
children.sort(key=lambda t: t.key)
|
||||
|
||||
# Return as special format that includes children
|
||||
if text:
|
||||
from ..models.formatter import format_ticket_with_children
|
||||
return PlainTextResponse(format_ticket_with_children(ticket, children))
|
||||
|
||||
# For JSON, add children to response
|
||||
result = ticket.model_dump()
|
||||
result["children"] = [c.model_dump() for c in children]
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# Return the actual Jira error for debugging
|
||||
raise HTTPException(404, f"Error fetching {key}: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/search")
|
||||
def search(
|
||||
jql: str,
|
||||
creds: JiraCredentials = Depends(get_jira_credentials),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
text: bool = False,
|
||||
):
|
||||
"""Search with raw JQL."""
|
||||
try:
|
||||
return _maybe_text(_search(creds, JQL().raw(jql), page, page_size), text)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@router.post("/epic/{key}/process")
|
||||
def process_epic(
|
||||
key: str,
|
||||
creds: JiraCredentials = Depends(get_jira_credentials),
|
||||
):
|
||||
"""Process epic: fetch epic and all children, save to files."""
|
||||
import time
|
||||
import json
|
||||
from pathlib import Path
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
logger.info(f"EPIC endpoint called: key={key}, email={creds.email}")
|
||||
|
||||
def generate():
|
||||
try:
|
||||
logger.info(f"Starting EPIC process for {key}")
|
||||
jira = connect_jira(creds.email, creds.token)
|
||||
logger.info(f"Connected to Jira for {key}")
|
||||
|
||||
# Fetch epic
|
||||
yield json.dumps({"status": "fetching_epic", "completed": 0, "total": 0}) + "\n"
|
||||
logger.info(f"Sent fetching_epic status for {key}")
|
||||
time.sleep(0.5)
|
||||
|
||||
logger.info(f"Fetching issue {key}")
|
||||
epic_issue = jira.issue(key, expand="comments")
|
||||
logger.info(f"Got issue {key}")
|
||||
epic = TicketDetail.from_jira(epic_issue, settings.jira_url)
|
||||
logger.info(f"Parsed epic: {epic.key} with {len(epic.subtasks)} subtasks")
|
||||
|
||||
# Get children keys from subtasks
|
||||
if not epic.subtasks:
|
||||
yield json.dumps({"status": "no_children", "completed": 0, "total": 0}) + "\n"
|
||||
return
|
||||
|
||||
total = len(epic.subtasks)
|
||||
|
||||
# Create storage folder in larder
|
||||
larder_path = Path(__file__).parent.parent.parent.parent / "larder" / "jira_epics" / key
|
||||
larder_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Save epic
|
||||
epic_file = larder_path / f"{key}.json"
|
||||
with open(epic_file, "w") as f:
|
||||
json.dump(epic.model_dump(), f, indent=2, default=str)
|
||||
|
||||
yield json.dumps({"status": "processing", "completed": 0, "total": total}) + "\n"
|
||||
|
||||
# Fetch each child
|
||||
children = []
|
||||
for idx, child_key in enumerate(epic.subtasks, 1):
|
||||
time.sleep(0.8) # Human speed
|
||||
|
||||
try:
|
||||
child_issue = jira.issue(child_key, expand="comments")
|
||||
child = TicketDetail.from_jira(child_issue, settings.jira_url)
|
||||
|
||||
# Save child
|
||||
child_file = larder_path / f"{child_key}.json"
|
||||
with open(child_file, "w") as f:
|
||||
json.dump(child.model_dump(), f, indent=2, default=str)
|
||||
|
||||
# Collect children for text formatting
|
||||
children.append(Ticket.from_jira(child_issue, settings.jira_url))
|
||||
|
||||
yield json.dumps({"status": "processing", "completed": idx, "total": total}) + "\n"
|
||||
except Exception as e:
|
||||
import traceback
|
||||
yield json.dumps({
|
||||
"status": "error",
|
||||
"completed": idx,
|
||||
"total": total,
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"child_key": child_key,
|
||||
"traceback": traceback.format_exc()
|
||||
}) + "\n"
|
||||
|
||||
# Format as text for display
|
||||
from ..models.formatter import format_ticket_with_children
|
||||
formatted_text = format_ticket_with_children(epic, children)
|
||||
|
||||
yield json.dumps({
|
||||
"status": "complete",
|
||||
"completed": total,
|
||||
"total": total,
|
||||
"path": str(larder_path),
|
||||
"text": formatted_text
|
||||
}) + "\n"
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
yield json.dumps({
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"traceback": traceback.format_exc()
|
||||
}) + "\n"
|
||||
|
||||
return StreamingResponse(generate(), media_type="application/x-ndjson")
|
||||
|
||||
|
||||
@router.get("/epic/{key}/status")
|
||||
def get_epic_status(key: str):
|
||||
"""Check if epic has been processed and files exist."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
larder_path = Path(__file__).parent.parent.parent.parent / "larder" / "jira_epics" / key
|
||||
|
||||
if not larder_path.exists():
|
||||
return {"processed": False}
|
||||
|
||||
files = list(larder_path.glob("*.json"))
|
||||
return {
|
||||
"processed": True,
|
||||
"path": str(larder_path),
|
||||
"files": [f.name for f in files],
|
||||
"count": len(files)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/attachment/{attachment_id}")
|
||||
def get_attachment(
|
||||
attachment_id: str,
|
||||
creds: JiraCredentials = Depends(get_jira_credentials),
|
||||
):
|
||||
"""Stream attachment content directly from Jira."""
|
||||
jira = connect_jira(creds.email, creds.token)
|
||||
att_url = f"{settings.jira_url}/rest/api/2/attachment/content/{attachment_id}"
|
||||
response = jira._session.get(att_url, allow_redirects=True)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(404, f"Attachment not found: {attachment_id}")
|
||||
content_type = response.headers.get("Content-Type", "application/octet-stream")
|
||||
return StreamingResponse(
|
||||
BytesIO(response.content),
|
||||
media_type=content_type,
|
||||
)
|
||||
Reference in New Issue
Block a user