from __future__ import annotations import asyncio import base64 import logging import httpx from yuxi.channel.extensions.jira.errors import MAX_RETRIES, JiraErrorHandler, classify_error from yuxi.channel.extensions.jira.format import markdown_to_adf from yuxi.channel.protocols import ( OutboundDeliveryCapabilities, OutboundDeliveryMode, OutboundPresentationCapabilities, ) logger = logging.getLogger(__name__) _error_handler = JiraErrorHandler() class JiraOutbound: delivery_mode = OutboundDeliveryMode.DIRECT chunker_mode = "length" text_chunk_limit: int = 32000 supports_polls = False extract_markdown_images = False presentation_capabilities = OutboundPresentationCapabilities() delivery_capabilities = OutboundDeliveryCapabilities() def __init__(self, config_adapter=None): self._config_adapter = config_adapter self._clients: dict[str, httpx.AsyncClient] = {} async def send_text( self, target_id: str, content: str, *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> dict | None: account = await self._resolve_account(account_id) if not account or not account.get("site_url"): logger.error("Jira send_text: account not configured") return None return await self._create_comment( account, target_id, content, visibility=account.get("comment_visibility", "internal"), source="forcepilot", ) async def send_media( self, target_id: str, media_url: str, media_type: str, *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> dict | None: account = await self._resolve_account(account_id) if not account or not account.get("site_url"): return None return await self._upload_attachment(account, target_id, media_url) async def edit_message( self, target_id: str, message_id: str, content: str, *, account_id: str | None = None, ) -> dict | None: account = await self._resolve_account(account_id) if not account or not account.get("site_url"): return None return await self._update_comment( account, target_id, message_id, content, visibility=account.get("comment_visibility", "internal"), ) async def delete_message( self, target_id: str, message_id: str, *, account_id: str | None = None, ) -> dict | None: account = await self._resolve_account(account_id) if not account or not account.get("site_url"): return None client = await self._get_client(account) url = f"{account['site_url']}/rest/api/3/issue/{target_id}/comment/{message_id}" try: resp = await client.delete(url) if resp.status_code == 204: return {"msg_id": message_id, "success": True} return {"msg_id": message_id, "success": False, "error": f"HTTP {resp.status_code}"} except Exception as e: logger.error("Jira delete_message error: %s", e) return {"msg_id": message_id, "success": False, "error": str(e)} async def _create_comment( self, account: dict, issue_key: str, content: str, *, visibility: str = "internal", source: str = "forcepilot", ) -> dict | None: import time client = await self._get_client(account) adf_body = markdown_to_adf(content) payload: dict = { "body": adf_body, "properties": [ {"key": "ai.agent", "value": {"generated_at": time.time(), "source": source}}, {"key": "sd.public.comment", "value": {"internal": visibility == "internal"}}, ], } for attempt in range(MAX_RETRIES): try: resp = await client.post( f"/rest/api/3/issue/{issue_key}/comment", json=payload, ) if resp.status_code in (200, 201): data = resp.json() return { "msg_id": str(data["id"]), "success": True, } severity, desc, retry_after = classify_error(resp.status_code, resp.json() if resp.content else None) if ( _error_handler.is_retryable(httpx.HTTPStatusError("", request=resp.request, response=resp)) and attempt < MAX_RETRIES - 1 ): delay = retry_after or min(2**attempt, 10) await asyncio.sleep(delay) continue return {"msg_id": None, "success": False, "error": desc} except Exception as e: logger.warning("Jira create_comment error (attempt %d): %s", attempt + 1, e) if attempt < MAX_RETRIES - 1: await asyncio.sleep(min(2**attempt, 10)) else: return {"msg_id": None, "success": False, "error": str(e)} return None async def _update_comment( self, account: dict, issue_key: str, comment_id: str, content: str, *, visibility: str = "internal", ) -> dict | None: client = await self._get_client(account) adf_body = markdown_to_adf(content) payload: dict = { "body": adf_body, "properties": [ {"key": "ai.agent", "value": {"active": True}}, {"key": "sd.public.comment", "value": {"internal": visibility == "internal"}}, ], } try: resp = await client.put( f"/rest/api/3/issue/{issue_key}/comment/{comment_id}", json=payload, ) if resp.status_code == 200: return {"msg_id": comment_id, "success": True} return {"msg_id": comment_id, "success": False, "error": f"HTTP {resp.status_code}"} except Exception as e: logger.error("Jira update_comment error: %s", e) return {"msg_id": comment_id, "success": False, "error": str(e)} async def _upload_attachment(self, account: dict, issue_key: str, media_url: str) -> dict | None: client = await self._get_client(account) async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as dl_client: resp = await dl_client.get(media_url) if resp.status_code != 200: return { "msg_id": None, "success": False, "error": f"download failed: HTTP {resp.status_code}", } file_content = resp.content content_type = resp.headers.get("content-type", "application/octet-stream") filename = media_url.rsplit("/", 1)[-1] or "attachment" url = f"/rest/api/3/issue/{issue_key}/attachments" try: headers = dict(client.headers) headers["X-Atlassian-Token"] = "no-check" resp = await client.post( url, headers=headers, files={"file": (filename, file_content, content_type)}, ) if resp.status_code in (200, 201): data = resp.json() attachment_id = str(data[0]["id"]) if isinstance(data, list) and data else "" return {"msg_id": attachment_id, "success": True} return {"msg_id": None, "success": False, "error": f"HTTP {resp.status_code}"} except Exception as e: logger.error("Jira upload_attachment error: %s", e) return {"msg_id": None, "success": False, "error": str(e)} def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]: limit = limit or self.text_chunk_limit if len(text) <= limit: return [text] chunks: list[str] = [] for i in range(0, len(text), limit): chunks.append(text[i : i + limit]) return chunks def sanitize_text(self, text: str, payload: object) -> str: return text[: self.text_chunk_limit] def resolve_target( self, to: str | None = None, *, config: dict | None = None, allow_from: list[str] | None = None, account_id: str | None = None, mode: str | None = None, ) -> tuple[bool, str]: if not to: return False, "target required" return True, to async def _get_client(self, account: dict) -> httpx.AsyncClient: account_key = account.get("account_id", "default") if account_key not in self._clients: credentials = f"{account['email']}:{account['api_token']}" auth = f"Basic {base64.b64encode(credentials.encode('utf-8')).decode('utf-8')}" self._clients[account_key] = httpx.AsyncClient( base_url=account["site_url"].rstrip("/"), headers={ "Authorization": auth, "Accept": "application/json", }, timeout=httpx.Timeout(30.0), ) return self._clients[account_key] async def _resolve_account(self, account_id: str | None) -> dict | None: if self._config_adapter: aid = account_id or self._config_adapter.default_account_id() return await self._config_adapter.resolve_account(aid) return None async def close(self): for client in self._clients.values(): await client.aclose() self._clients.clear()