migrated all pawprint work

This commit is contained in:
buenosairesam
2025-12-31 08:34:18 -03:00
parent fc63e9010c
commit 680969ca42
63 changed files with 4687 additions and 5 deletions

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