实现了 GitLab 全功能集成,包含 Webhook 处理、API 客户端、消息收发、会话管理、安全校验、限流器等完整模块,支持 Issues、Merge Requests、流水线等事件监听与交互。
233 lines
7.9 KiB
Python
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
|