""" 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, )