154 lines
5.5 KiB
Python
154 lines
5.5 KiB
Python
# context_builder.py
|
|
import base64
|
|
import json
|
|
import logging
|
|
import re
|
|
|
|
import httpx
|
|
|
|
from agents.issue_triage.project_loader import load_project
|
|
from common.as_type import _as_dict, _as_list, _first, _extract_base64
|
|
from common.youtrack_mcp_client import IssueNotFound
|
|
from contracts.IssueContext import IssueContext
|
|
|
|
logger = logging.getLogger("context_builder")
|
|
logger.setLevel(logging.DEBUG)
|
|
|
|
IMAGE_MIME_PREFIX = "image/"
|
|
MARKDOWN_IMAGE_RE = re.compile(r"!\[[^]]*]\(([^)]+)\)")
|
|
|
|
class YouTrackContextBuilder:
|
|
"""Reads YouTrack issues via MCP tools."""
|
|
|
|
def __init__(self, mcp_client, http_client: httpx.AsyncClient):
|
|
# mcp_client exposes async methods like call_tool("get_issue", {...})
|
|
self.mcp = mcp_client
|
|
self.http = http_client
|
|
|
|
async def build(self, issue_id: str) -> IssueContext | None:
|
|
try:
|
|
issue_raw = await self.mcp.call_tool(
|
|
"get_issue",
|
|
{"issueId": issue_id, "briefOutput": False},
|
|
)
|
|
logger.debug("TYPE: %s", type(issue_raw)) # use %s placeholder
|
|
logger.debug("REPR: %s", repr(issue_raw)[:2000])
|
|
issue = _as_dict(issue_raw)
|
|
except IssueNotFound:
|
|
return None # caller decides what to do
|
|
|
|
images = await self._fetch_image_base64(issue)
|
|
|
|
# comments are optional — missing comments shouldn't kill the plan
|
|
try:
|
|
logger.debug(f"Lookup for comments")
|
|
comments_raw = await self.mcp.call_tool(
|
|
"get_issue_comments",
|
|
{"issueId": issue_id, "limit": 10},
|
|
)
|
|
logger.debug(f"Found {len(comments_raw)} comments")
|
|
|
|
logger.debug(json.dumps(comments_raw))
|
|
comments = _as_list(comments_raw)
|
|
except IssueNotFound:
|
|
comments = []
|
|
|
|
project_key = issue_id.split("-", 1)[0]
|
|
project = load_project(project_key)
|
|
|
|
return IssueContext(
|
|
issue_id=_first(issue, "idReadable", "id") or issue_id,
|
|
title=_first(issue, "summary", "title", "name", default=""),
|
|
body=_first(issue, "description", "body", default=""),
|
|
comments=[
|
|
{
|
|
"id": c.get("url"),
|
|
"author": c.get("author") or "unknown",
|
|
"body": c.get("text") or c.get("body") or "",
|
|
"created_at": str(c.get("created") or c.get("created_at") or ""),
|
|
}
|
|
for c in comments
|
|
],
|
|
labels=[
|
|
t["name"]
|
|
for t in issue.get("tags", [])
|
|
if isinstance(t, dict) and t.get("name")
|
|
],
|
|
repo=_first(
|
|
(_first(issue, "project", default={}) or {}),
|
|
"shortName", "id", "key"
|
|
),
|
|
metadata={
|
|
"custom_fields": _first(issue, "customFields", "fields", default=[]),
|
|
"url": _first(issue, "url", "selfUrl"),
|
|
"state": self._extract_field(issue, "State"),
|
|
},
|
|
images=images,
|
|
project=project,
|
|
acceptance_criteria=""
|
|
)
|
|
|
|
async def _fetch_image_base64(self, issue: dict) -> list[str]:
|
|
"""Download image attachments referenced by the issue."""
|
|
attachments = issue.get("attachments") or []
|
|
if not isinstance(attachments, list):
|
|
return []
|
|
|
|
referenced = self._extract_markdown_image_names(issue.get("description") or "")
|
|
|
|
images: list[str] = []
|
|
for att in attachments:
|
|
if not isinstance(att, dict):
|
|
continue
|
|
mime = str(att.get("mimeType", ""))
|
|
if not mime.startswith("image/"):
|
|
continue
|
|
name = att.get("name", "")
|
|
# Only fetch if it's referenced in the body, or fetch all if body has no refs
|
|
if referenced and name not in referenced:
|
|
continue
|
|
url = att.get("url")
|
|
if not url:
|
|
continue
|
|
try:
|
|
resp = await self.http.get(url)
|
|
resp.raise_for_status()
|
|
except httpx.HTTPError as e:
|
|
print(f"Failed to fetch attachment {name}: {e}", flush=True)
|
|
continue
|
|
images.append(base64.b64encode(resp.content).decode("ascii"))
|
|
|
|
return images
|
|
|
|
async def _download_attachment(self, issue_id: str, attachment_id: str) -> str | None:
|
|
try:
|
|
result = await self.mcp.call_tool(
|
|
"issue_attachment_download",
|
|
{
|
|
"issueId": issue_id,
|
|
"attachmentId": attachment_id,
|
|
"downloadToFile": False,
|
|
},
|
|
)
|
|
except Exception:
|
|
return None
|
|
# Server may return base64 directly, or a text block containing base64/URL
|
|
return _extract_base64(result)
|
|
|
|
@staticmethod
|
|
def _extract_markdown_image_names(body: str) -> set[str]:
|
|
return {m.group(1) for m in MARKDOWN_IMAGE_RE.finditer(body)}
|
|
|
|
@staticmethod
|
|
def _extract_field(issue: dict, name: str):
|
|
fields = issue.get("customFields") or issue.get("custom_fields") or []
|
|
for f in fields:
|
|
if not isinstance(f, dict):
|
|
continue # skip strings, numbers, None
|
|
if f.get("name") != name:
|
|
continue
|
|
value = f.get("value")
|
|
if isinstance(value, dict):
|
|
return value.get("name") or value.get("presentation")
|
|
return value
|
|
return None |