ForcePilot/backend/package/yuxi/channel/extensions/gitlab/outbound.py
Kris 4a4e256955 feat(gitlab): 新增完整 GitLab 渠道插件支持
实现了 GitLab 全功能集成,包含 Webhook 处理、API 客户端、消息收发、会话管理、安全校验、限流器等完整模块,支持 Issues、Merge Requests、流水线等事件监听与交互。
2026-05-21 10:49:01 +08:00

233 lines
7.9 KiB
Python

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