Files
agents/agents/issue_triage/context_builder.py
T

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