from __future__ import annotations import asyncio import logging from yuxi.channel.extensions.gitlab.api import AsyncGitLabClient from yuxi.channel.extensions.gitlab.errors import MAX_RETRIES logger = logging.getLogger(__name__) class GitLabOutbound: delivery_mode = "direct" chunker_mode = "length" text_chunk_limit: int = 65000 supports_polls = False extract_markdown_images = False presentation_capabilities = None delivery_capabilities = None def __init__(self, config_adapter=None): self._config_adapter = config_adapter 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, self._config_adapter) if not account or not account.get("is_configured"): logger.error("GitLab send_text: account not configured") return None client = self._build_client(account) chunks = self.chunker(content, self.text_chunk_limit) result = None project_id = int(account.get("project_id", 0)) noteable_type = "Issue" noteable_iid = 0 if thread_id and isinstance(thread_id, dict): noteable_type = thread_id.get("noteable_type", "Issue") noteable_iid = int(thread_id.get("noteable_iid", 0)) for i, chunk in enumerate(chunks): prefix = "" suffix = "" if len(chunks) > 1: prefix = f"*(Block {i + 1}/{len(chunks)})*\n\n" if i < len(chunks) - 1: suffix = "\n\n---\n*(续)*" else: suffix = "\n\n---\n*(续完)*" body = f"{prefix}{chunk}{suffix}" for attempt in range(MAX_RETRIES): try: if noteable_type == "MergeRequest": note = await client.create_mr_note(project_id, noteable_iid, body) else: note = await client.create_issue_note(project_id, noteable_iid, body) result = {"note_id": note.get("id"), "success": True} break except Exception as e: logger.warning("GitLab outbound attempt %d failed: %s", attempt + 1, e) if attempt < MAX_RETRIES - 1: await asyncio.sleep(min(2**attempt, 10)) else: result = {"note_id": None, "success": False, "error": str(e)} return result 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, self._config_adapter) if not account: return None client = self._build_client(account) project_id = int(account.get("project_id", 0)) noteable_type = "Issue" noteable_iid = 0 if thread_id and isinstance(thread_id, dict): noteable_type = thread_id.get("noteable_type", "Issue") noteable_iid = int(thread_id.get("noteable_iid", 0)) try: upload_result = await client.upload_file(project_id, media_url) markdown_ref = upload_result.get("markdown", f"[附件]({media_url})") if noteable_type == "MergeRequest": note = await client.create_mr_note(project_id, noteable_iid, markdown_ref) else: note = await client.create_issue_note(project_id, noteable_iid, markdown_ref) return {"note_id": note.get("id"), "success": True} except Exception as e: logger.error("GitLab media send failed: %s", e) return {"note_id": None, "success": False, "error": str(e)} async def send_reaction( self, target_id: str, message_id: str, emoji: str, account_id: str | None = None, ) -> dict | None: account = await self._resolve_account(account_id, self._config_adapter) if not account: return None client = self._build_client(account) project_id = int(account.get("project_id", 0)) noteable_type = "Issue" noteable_iid = 0 note_id = int(message_id) if message_id.isdigit() else 0 try: result = await client.add_reaction( project_id, noteable_type, noteable_iid, note_id, emoji, ) return {"success": True, "reaction": result} except Exception as e: logger.warning("GitLab send_reaction failed: %s", e) return {"success": False, "error": str(e)} async def create_diff_review( self, account: dict, client: AsyncGitLabClient, project_id: int, mr_iid: int, review_comments: list[dict], ) -> list[dict]: results = [] for comment in review_comments: try: result = await client.create_diff_note( project_id=project_id, mr_iid=mr_iid, body=comment.get("body", ""), file_path=comment.get("file_path", ""), new_line=comment.get("new_line", 0), old_line=comment.get("old_line"), base_sha=comment.get("base_sha", ""), head_sha=comment.get("head_sha", ""), ) results.append({"success": True, "note_id": result.get("id")}) except Exception as e: logger.error("GitLab diff review failed: %s", e) results.append({"success": False, "error": str(e)}) return results def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]: if len(text) <= limit: return [text] chunks: list[str] = [] in_fenced_block = False current = "" for line in text.split("\n"): stripped = line.strip() if stripped.startswith("```"): in_fenced_block = not in_fenced_block candidate = current + line + "\n" if len(candidate) > limit and not in_fenced_block: chunks.append(current.rstrip()) current = line + "\n" else: current = candidate if current.strip(): chunks.append(current.rstrip()) return chunks if chunks else [text] def sanitize_text(self, text: str, payload: object) -> str: return text.replace("\x00", "") def should_skip_plain_text_sanitization(self, payload: object) -> bool: return False 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 @staticmethod def _build_client(account: dict) -> AsyncGitLabClient: return AsyncGitLabClient( base_url=account.get("base_url", "https://gitlab.com"), private_token=account.get("private_token", ""), ssl_verify=account.get("ssl_verify", True), ) @staticmethod async def _resolve_account(account_id: str | None, config_adapter=None) -> dict | None: from yuxi.channel.extensions.gitlab.config import GitLabConfigAdapter adapter = config_adapter if config_adapter else GitLabConfigAdapter() aid = account_id or "default" account = await adapter.resolve_account(aid) account["is_configured"] = adapter.is_configured(account) return account