migrated all pawprint work
This commit is contained in:
0
artery/veins/jira/models/__init__.py
Normal file
0
artery/veins/jira/models/__init__.py
Normal file
182
artery/veins/jira/models/formatter.py
Normal file
182
artery/veins/jira/models/formatter.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Text formatters for LLM/human readable output.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .ticket import Attachment, Ticket, TicketDetail, TicketList
|
||||
|
||||
|
||||
def _fmt_size(size: int) -> str:
|
||||
"""Format bytes to human readable."""
|
||||
for unit in ["B", "KB", "MB", "GB"]:
|
||||
if size < 1024:
|
||||
return f"{size:.1f}{unit}" if unit != "B" else f"{size}{unit}"
|
||||
size /= 1024
|
||||
return f"{size:.1f}TB"
|
||||
|
||||
|
||||
def _fmt_dt(dt) -> str:
|
||||
if not dt:
|
||||
return "-"
|
||||
return dt.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def format_ticket(t: "Ticket") -> str:
|
||||
lines = [
|
||||
f"[{t.key}] {t.summary}",
|
||||
f" Project: {t.project} | Type: {t.issue_type} | Priority: {t.priority or '-'}",
|
||||
f" Status: {t.status} ({t.status_category or '-'})",
|
||||
f" Assignee: {t.assignee or '-'} | Reporter: {t.reporter or '-'}",
|
||||
f" Labels: {', '.join(t.labels) if t.labels else '-'}",
|
||||
f" Created: {_fmt_dt(t.created)} | Updated: {_fmt_dt(t.updated)}",
|
||||
f" URL: {t.url}",
|
||||
]
|
||||
if t.description:
|
||||
lines.append(f" Description: {t.description}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_ticket_detail(t: "TicketDetail") -> str:
|
||||
lines = [
|
||||
f"# {t.key}: {t.summary}",
|
||||
"",
|
||||
f"Project: {t.project}",
|
||||
f"Type: {t.issue_type}",
|
||||
f"Status: {t.status} ({t.status_category or '-'})",
|
||||
f"Priority: {t.priority or '-'}",
|
||||
f"Assignee: {t.assignee or '-'}",
|
||||
f"Reporter: {t.reporter or '-'}",
|
||||
f"Labels: {', '.join(t.labels) if t.labels else '-'}",
|
||||
f"Created: {_fmt_dt(t.created)}",
|
||||
f"Updated: {_fmt_dt(t.updated)}",
|
||||
f"Parent: {t.parent_key or '-'}",
|
||||
f"Subtasks: {', '.join(t.subtasks) if t.subtasks else '-'}",
|
||||
f"Linked issues: {', '.join(t.linked_issues) if t.linked_issues else '-'}",
|
||||
f"URL: {t.url}",
|
||||
"",
|
||||
"## Description",
|
||||
t.description or "(no description)",
|
||||
"",
|
||||
]
|
||||
|
||||
lines.append(f"## Comments ({len(t.comments)})")
|
||||
if t.comments:
|
||||
for c in t.comments:
|
||||
lines.append(f"### {c.get('author', 'Unknown')} ({c.get('created', '')[:16] if c.get('created') else '-'})")
|
||||
lines.append(c.get("body", ""))
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append("(no comments)")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"## Attachments ({len(t.attachments)})")
|
||||
if t.attachments:
|
||||
for a in t.attachments:
|
||||
has_content = "[downloaded]" if a.content_base64 else ""
|
||||
lines.append(f"- {a.filename} ({_fmt_size(a.size)}, {a.mimetype}) {has_content}")
|
||||
else:
|
||||
lines.append("(no attachments)")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_ticket_with_children(parent: "TicketDetail", children: list) -> str:
|
||||
"""Format a ticket with its children (subtasks/stories)."""
|
||||
lines = [
|
||||
f"# {parent.key}: {parent.summary}",
|
||||
"",
|
||||
f"Project: {parent.project}",
|
||||
f"Type: {parent.issue_type}",
|
||||
f"Status: {parent.status} ({parent.status_category or '-'})",
|
||||
f"Priority: {parent.priority or '-'}",
|
||||
f"Assignee: {parent.assignee or '-'}",
|
||||
f"Reporter: {parent.reporter or '-'}",
|
||||
f"Labels: {', '.join(parent.labels) if parent.labels else '-'}",
|
||||
f"Created: {_fmt_dt(parent.created)}",
|
||||
f"Updated: {_fmt_dt(parent.updated)}",
|
||||
f"URL: {parent.url}",
|
||||
"",
|
||||
"## Description",
|
||||
parent.description or "(no description)",
|
||||
"",
|
||||
]
|
||||
|
||||
# Add children section
|
||||
if children:
|
||||
child_type = "Sub-tasks" if parent.issue_type in ("Story", "Task") else "Stories"
|
||||
lines.append(f"## {child_type} ({len(children)})")
|
||||
lines.append("=" * 60)
|
||||
lines.append("")
|
||||
|
||||
for child in children:
|
||||
lines.append(f"[{child.key}] {child.summary}")
|
||||
lines.append(f" Type: {child.issue_type} | Status: {child.status} | Priority: {child.priority or '-'}")
|
||||
lines.append(f" Assignee: {child.assignee or '-'}")
|
||||
lines.append(f" URL: {child.url}")
|
||||
lines.append("")
|
||||
lines.append("-" * 60)
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"## Comments ({len(parent.comments)})")
|
||||
if parent.comments:
|
||||
for c in parent.comments:
|
||||
lines.append(f"### {c.get('author', 'Unknown')} ({c.get('created', '')[:16] if c.get('created') else '-'})")
|
||||
lines.append(c.get("body", ""))
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append("(no comments)")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"## Attachments ({len(parent.attachments)})")
|
||||
if parent.attachments:
|
||||
for a in parent.attachments:
|
||||
has_content = "[downloaded]" if a.content_base64 else ""
|
||||
lines.append(f"- {a.filename} ({_fmt_size(a.size)}, {a.mimetype}) {has_content}")
|
||||
else:
|
||||
lines.append("(no attachments)")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_ticket_list(tl: "TicketList") -> str:
|
||||
# Sort for text output: stories with subtasks, then bugs
|
||||
stories = []
|
||||
bugs = []
|
||||
subtasks = []
|
||||
|
||||
for t in tl.tickets:
|
||||
if t.parent_key:
|
||||
subtasks.append(t)
|
||||
elif t.issue_type in ("Story", "Epic", "Task"):
|
||||
stories.append(t)
|
||||
elif t.issue_type == "Bug":
|
||||
bugs.append(t)
|
||||
else:
|
||||
stories.append(t) # fallback
|
||||
|
||||
# Build sorted list: parent stories, then their subtasks, then bugs
|
||||
sorted_tickets = []
|
||||
for story in sorted(stories, key=lambda t: t.key):
|
||||
sorted_tickets.append(story)
|
||||
# Add subtasks for this story
|
||||
story_subtasks = [st for st in subtasks if st.parent_key == story.key]
|
||||
sorted_tickets.extend(sorted(story_subtasks, key=lambda t: t.key))
|
||||
|
||||
# Add bugs at the end
|
||||
sorted_tickets.extend(sorted(bugs, key=lambda t: t.key))
|
||||
|
||||
lines = [
|
||||
f"Total: {tl.total} | Page: {tl.page} | Page size: {tl.page_size}",
|
||||
f"Showing: {len(tl.tickets)} tickets",
|
||||
"=" * 60,
|
||||
"",
|
||||
]
|
||||
for i, t in enumerate(sorted_tickets):
|
||||
lines.append(format_ticket(t))
|
||||
if i < len(sorted_tickets) - 1:
|
||||
lines.append("")
|
||||
lines.append("-" * 60)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
135
artery/veins/jira/models/ticket.py
Normal file
135
artery/veins/jira/models/ticket.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Ticket models with self-parsing from Jira objects.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Attachment(BaseModel):
|
||||
id: str
|
||||
filename: str
|
||||
mimetype: str
|
||||
size: int # bytes
|
||||
url: str
|
||||
content_base64: Optional[str] = None # populated when include_attachments=true
|
||||
|
||||
@classmethod
|
||||
def from_jira(cls, att) -> "Attachment":
|
||||
return cls(
|
||||
id=att.id,
|
||||
filename=att.filename,
|
||||
mimetype=att.mimeType,
|
||||
size=att.size,
|
||||
url=att.content,
|
||||
)
|
||||
|
||||
|
||||
class Ticket(BaseModel):
|
||||
key: str
|
||||
summary: str
|
||||
description: Optional[str] = None
|
||||
status: str
|
||||
status_category: Optional[str] = None
|
||||
issue_type: str
|
||||
priority: Optional[str] = None
|
||||
project: str
|
||||
assignee: Optional[str] = None
|
||||
reporter: Optional[str] = None
|
||||
labels: List[str] = []
|
||||
created: Optional[datetime] = None
|
||||
updated: Optional[datetime] = None
|
||||
url: str
|
||||
parent_key: Optional[str] = None # For subtasks
|
||||
|
||||
@classmethod
|
||||
def from_jira(cls, issue, base_url: str) -> "Ticket":
|
||||
f = issue.fields
|
||||
status_cat = None
|
||||
if hasattr(f.status, "statusCategory"):
|
||||
status_cat = f.status.statusCategory.name
|
||||
|
||||
# Get parent key for subtasks
|
||||
parent = None
|
||||
if hasattr(f, "parent") and f.parent:
|
||||
parent = f.parent.key
|
||||
|
||||
return cls(
|
||||
key=issue.key,
|
||||
summary=f.summary or "",
|
||||
description=f.description,
|
||||
status=f.status.name,
|
||||
status_category=status_cat,
|
||||
issue_type=f.issuetype.name,
|
||||
priority=f.priority.name if f.priority else None,
|
||||
project=f.project.key,
|
||||
assignee=f.assignee.displayName if f.assignee else None,
|
||||
reporter=f.reporter.displayName if f.reporter else None,
|
||||
labels=f.labels or [],
|
||||
created=cls._parse_dt(f.created),
|
||||
updated=cls._parse_dt(f.updated),
|
||||
url=f"{base_url}/browse/{issue.key}",
|
||||
parent_key=parent,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_dt(val: Optional[str]) -> Optional[datetime]:
|
||||
if not val:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(val.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class TicketDetail(Ticket):
|
||||
comments: List[dict] = []
|
||||
linked_issues: List[str] = []
|
||||
subtasks: List[str] = []
|
||||
attachments: List[Attachment] = []
|
||||
|
||||
@classmethod
|
||||
def from_jira(cls, issue, base_url: str) -> "TicketDetail":
|
||||
base = Ticket.from_jira(issue, base_url)
|
||||
f = issue.fields
|
||||
|
||||
comments = []
|
||||
if hasattr(f, "comment") and f.comment:
|
||||
for c in f.comment.comments:
|
||||
comments.append({
|
||||
"author": c.author.displayName if hasattr(c, "author") else None,
|
||||
"body": c.body,
|
||||
"created": c.created,
|
||||
})
|
||||
|
||||
linked = []
|
||||
if hasattr(f, "issuelinks") and f.issuelinks:
|
||||
for link in f.issuelinks:
|
||||
if hasattr(link, "outwardIssue"):
|
||||
linked.append(link.outwardIssue.key)
|
||||
if hasattr(link, "inwardIssue"):
|
||||
linked.append(link.inwardIssue.key)
|
||||
|
||||
subtasks = []
|
||||
if hasattr(f, "subtasks") and f.subtasks:
|
||||
subtasks = [st.key for st in f.subtasks]
|
||||
|
||||
attachments = []
|
||||
if hasattr(f, "attachment") and f.attachment:
|
||||
attachments = [Attachment.from_jira(a) for a in f.attachment]
|
||||
|
||||
return cls(
|
||||
**base.model_dump(),
|
||||
comments=comments,
|
||||
linked_issues=linked,
|
||||
subtasks=subtasks,
|
||||
attachments=attachments,
|
||||
)
|
||||
|
||||
|
||||
class TicketList(BaseModel):
|
||||
tickets: List[Ticket]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
Reference in New Issue
Block a user