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

628 lines
23 KiB
Python

from __future__ import annotations
import logging
from datetime import datetime
from yuxi.channel.message.models import (
GroupContext,
MessageType,
PeerInfo,
UnifiedMessage,
)
from yuxi.channel.routing.models import PeerKind
logger = logging.getLogger(__name__)
class GitLabMonitor:
def parse_webhook_to_unified(
self,
payload: dict,
account_id: str,
event_type: str,
) -> UnifiedMessage | None:
object_kind = payload.get("object_kind", "")
if object_kind == "note":
return self._parse_note_event(payload, account_id, event_type)
elif object_kind == "issue":
return self._parse_issue_event(payload, account_id)
elif object_kind == "merge_request":
return self._parse_mr_event(payload, account_id)
elif object_kind == "pipeline":
return self._parse_pipeline_event(payload, account_id)
elif object_kind == "deployment":
return self._parse_deployment_event(payload, account_id)
elif object_kind == "build":
return self._parse_job_event(payload, account_id)
elif object_kind == "work_item":
return self._parse_work_item_event(payload, account_id)
elif event_type == "Push Hook":
return self._parse_push_event(payload, account_id)
elif event_type == "Tag Push Hook":
return self._parse_tag_event(payload, account_id)
elif event_type == "Release Hook":
return self._parse_release_event(payload, account_id)
else:
logger.debug("GitLab monitor: unhandled object_kind=%s event_type=%s", object_kind, event_type)
return None
def _parse_note_event(self, payload: dict, account_id: str, event_type: str) -> UnifiedMessage | None:
attrs = payload.get("object_attributes", {})
user = payload.get("user", {})
project = payload.get("project", {})
noteable_type = attrs.get("noteable_type", "Issue")
note_id = attrs.get("id", 0)
body = attrs.get("note", "")
note_url = attrs.get("url", "")
action = attrs.get("action", "create")
noteable = (
payload.get("issue")
if noteable_type == "Issue"
else payload.get("merge_request", {})
if noteable_type == "MergeRequest"
else payload.get("commit")
if noteable_type == "Commit"
else payload.get("snippet", {})
if noteable_type == "Snippet"
else {}
)
noteable_iid = noteable.get("iid", 0) if noteable else 0
noteable_title = noteable.get("title", "") if noteable else ""
sender = PeerInfo(
kind=PeerKind.DIRECT,
id=str(user.get("id", "")),
display_name=user.get("name", user.get("username", "")),
is_bot=False,
is_self=False,
)
project_id = project.get("id", 0)
project_path = project.get("path_with_namespace", "")
group = GroupContext(
id=f"{project_id}:{noteable_type}:{noteable_iid}",
kind=f"gitlab_{noteable_type.lower()}",
name=f"{project_path}#{noteable_iid}{noteable_title}",
)
timestamp = None
created_at = attrs.get("created_at")
if created_at:
try:
timestamp = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
except ValueError:
pass
if action == "update":
return UnifiedMessage(
msg_id=f"gitlab:note:{note_id}:update",
channel_type="gitlab",
account_id=account_id,
content=f"\u270f\ufe0f 编辑了评论 (ID: {note_id})",
sender=sender,
group=group,
message_type=MessageType.EVENT,
timestamp=timestamp,
raw_payload=payload,
metadata={
"project_id": project_id,
"project_path": project_path,
"noteable_type": noteable_type,
"noteable_iid": noteable_iid,
"note_id": note_id,
"action": "update",
"body": body[:500] if body else "",
},
)
is_diff_note = attrs.get("position") is not None
return UnifiedMessage(
msg_id=f"gitlab:note:{note_id}",
channel_type="gitlab",
account_id=account_id,
content=body,
sender=sender,
group=group,
message_type=MessageType.TEXT,
timestamp=timestamp,
raw_payload=payload,
metadata={
"project_id": project_id,
"project_path": project_path,
"noteable_type": noteable_type,
"noteable_iid": noteable_iid,
"note_id": note_id,
"note_url": note_url,
"is_diff_note": is_diff_note,
"discussion_id": attrs.get("discussion_id", ""),
"system": attrs.get("system", False),
},
)
def _parse_issue_event(self, payload: dict, account_id: str) -> UnifiedMessage | None:
attrs = payload.get("object_attributes", {})
user = payload.get("user", {})
project = payload.get("project", {})
action = attrs.get("action", "")
issue_iid = attrs.get("iid", 0)
title = attrs.get("title", "")
description = attrs.get("description", "")
state = attrs.get("state", "")
action_text_map = {
"open": f"\U0001f4dd 创建了 Issue: **{title}**",
"close": f"\u2705 关闭了 Issue: **{title}**",
"reopen": f"\U0001f504 重新打开了 Issue: **{title}**",
"update": f"\u270f\ufe0f 更新了 Issue: **{title}**",
}
text = action_text_map.get(action, f"Issue 事件: {action} — **{title}**")
if description:
text += f"\n\n{description[:500]}"
sender = PeerInfo(
kind=PeerKind.DIRECT,
id=str(user.get("id", "")),
display_name=user.get("name", user.get("username", "")),
is_bot=False,
is_self=False,
)
project_id = project.get("id", 0)
project_path = project.get("path_with_namespace", "")
group = GroupContext(
id=f"{project_id}:Issue:{issue_iid}",
kind="gitlab_issue",
name=f"{project_path}#{issue_iid}{title}",
)
return UnifiedMessage(
msg_id=f"gitlab:issue:{attrs.get('id', 0)}:{action}",
channel_type="gitlab",
account_id=account_id,
content=text,
sender=sender,
group=group,
message_type=MessageType.EVENT,
raw_payload=payload,
metadata={
"project_id": project_id,
"project_path": project_path,
"issue_iid": issue_iid,
"action": action,
"state": state,
"labels": attrs.get("labels", []),
"url": attrs.get("url", ""),
},
)
def _parse_mr_event(self, payload: dict, account_id: str) -> UnifiedMessage | None:
attrs = payload.get("object_attributes", {})
user = payload.get("user", {})
project = payload.get("project", {})
action = attrs.get("action", "")
mr_iid = attrs.get("iid", 0)
title = attrs.get("title", "")
source_branch = attrs.get("source_branch", "")
target_branch = attrs.get("target_branch", "")
action_text_map = {
"open": f"\U0001f500 创建了 MR: **{title}**\n`{source_branch}` → `{target_branch}`",
"merge": f"\U0001f389 合并了 MR: **{title}**",
"close": f"\u274c 关闭了 MR: **{title}**",
"reopen": f"\U0001f504 重新打开了 MR: **{title}**",
"update": f"\u270f\ufe0f 更新了 MR: **{title}**",
"approved": f"\U0001f44d 所有审批已通过 MR: **{title}**",
"approval": f"\u2705 审批 MR: **{title}**",
"unapproved": f"\U0001f44e 取消审批了 MR: **{title}**",
}
text = action_text_map.get(action, f"MR 事件: {action} — **{title}**")
sender = PeerInfo(
kind=PeerKind.DIRECT,
id=str(user.get("id", "")),
display_name=user.get("name", user.get("username", "")),
is_bot=False,
is_self=False,
)
project_id = project.get("id", 0)
project_path = project.get("path_with_namespace", "")
group = GroupContext(
id=f"{project_id}:MergeRequest:{mr_iid}",
kind="gitlab_mr",
name=f"{project_path}!{mr_iid}{title}",
)
return UnifiedMessage(
msg_id=f"gitlab:mr:{attrs.get('id', 0)}:{action}",
channel_type="gitlab",
account_id=account_id,
content=text,
sender=sender,
group=group,
message_type=MessageType.EVENT,
raw_payload=payload,
metadata={
"project_id": project_id,
"project_path": project_path,
"mr_iid": mr_iid,
"action": action,
"source_branch": source_branch,
"target_branch": target_branch,
"url": attrs.get("url", ""),
},
)
def _parse_pipeline_event(self, payload: dict, account_id: str) -> UnifiedMessage | None:
attrs = payload.get("object_attributes", {})
user = payload.get("user", {})
project = payload.get("project", {})
commit = payload.get("commit", {})
status = attrs.get("status", "")
pipeline_id = attrs.get("iid", 0)
ref = attrs.get("ref", "")
duration = attrs.get("duration", 0)
status_emoji = {
"success": "\u2705",
"failed": "\u274c",
"canceled": "\u23f9\ufe0f",
"skipped": "\u23ed\ufe0f",
"running": "\U0001f504",
"pending": "\u23f3",
"manual": "\u270b",
}
emoji = status_emoji.get(status, "\u2139\ufe0f")
commit_msg = commit.get("message", "")
commit_msg_short = commit_msg.split("\n")[0][:100] if commit_msg else ""
if status == "manual":
text = (
f"{emoji} Pipeline **#{pipeline_id}** 等待手动触发 on `{ref}`\n"
f"Commit: {commit.get('id', '')[:8]}{commit_msg_short}\n"
f"\u2705 需要手动审批才能继续执行"
)
else:
text = (
f"{emoji} Pipeline **#{pipeline_id}** {status} on `{ref}`\n"
f"Commit: {commit.get('id', '')[:8]}{commit_msg_short}\n"
f"Duration: {duration}s"
)
failed_jobs = []
for build in payload.get("builds", []):
if build.get("status") == "failed":
failed_jobs.append(f" - `{build.get('name')}` ({build.get('stage')})")
if failed_jobs:
text += "\n\n\u274c Failed Jobs:\n" + "\n".join(failed_jobs[:10])
sender = PeerInfo(
kind=PeerKind.DIRECT,
id=str(user.get("id", "")),
display_name=user.get("name", user.get("username", "CI")),
is_bot=False,
is_self=False,
)
project_id = project.get("id", 0)
project_path = project.get("path_with_namespace", "")
group = GroupContext(
id=f"{project_id}:Pipeline:{ref}",
kind="gitlab_pipeline",
name=f"{project_path} Pipeline on `{ref}`",
)
return UnifiedMessage(
msg_id=f"gitlab:pipeline:{attrs.get('id', 0)}:{status}",
channel_type="gitlab",
account_id=account_id,
content=text,
sender=sender,
group=group,
message_type=MessageType.EVENT,
raw_payload=payload,
metadata={
"project_id": project_id,
"project_path": project_path,
"pipeline_id": pipeline_id,
"status": status,
"ref": ref,
"commit_sha": commit.get("id", ""),
"duration": duration,
"url": attrs.get("url", ""),
},
)
def _parse_deployment_event(self, payload: dict, account_id: str) -> UnifiedMessage | None:
status = payload.get("status", "")
environment = payload.get("environment", "")
env_url = payload.get("environment_external_url", "")
project = payload.get("project", {})
commit = payload.get("commit", {})
short_sha = payload.get("short_sha", commit.get("id", "")[:8])
text = (
f"\U0001f680 Deployment **{status}** to `{environment}`\n"
f"Commit: {short_sha}{commit.get('message', '').split(chr(10))[0][:100]}"
)
if env_url:
text += f"\nURL: {env_url}"
sender = PeerInfo(
kind=PeerKind.DIRECT,
id=str(payload.get("user", {}).get("id", "")),
display_name=payload.get("user", {}).get("name", "Deployment"),
is_bot=False,
is_self=False,
)
return UnifiedMessage(
msg_id=f"gitlab:deployment:{payload.get('deployment_id', 0)}",
channel_type="gitlab",
account_id=account_id,
content=text,
sender=sender,
message_type=MessageType.EVENT,
raw_payload=payload,
metadata={
"project_id": project.get("id", 0),
"environment": environment,
"status": status,
"short_sha": short_sha,
},
)
def _parse_job_event(self, payload: dict, account_id: str) -> UnifiedMessage | None:
build_status = payload.get("build_status", "")
build_name = payload.get("build_name", "")
build_stage = payload.get("build_stage", "")
pipeline_id = payload.get("pipeline_id", 0)
failure_reason = payload.get("build_failure_reason", "")
if build_status not in ("failed", "success"):
return None
status_emoji = "\u274c" if build_status == "failed" else "\u2705"
text = f"{status_emoji} Job `{build_name}` ({build_stage}) **{build_status}**\nPipeline: #{pipeline_id}"
if failure_reason:
text += f"\nReason: {failure_reason}"
project = payload.get("project", {})
sender = PeerInfo(
kind=PeerKind.DIRECT,
id=str(payload.get("user", {}).get("id", "")),
display_name=payload.get("user", {}).get("name", "Job"),
is_bot=False,
is_self=False,
)
return UnifiedMessage(
msg_id=f"gitlab:job:{payload.get('build_id', 0)}:{build_status}",
channel_type="gitlab",
account_id=account_id,
content=text,
sender=sender,
message_type=MessageType.EVENT,
raw_payload=payload,
metadata={
"project_id": project.get("id", 0),
"build_name": build_name,
"build_stage": build_stage,
"build_status": build_status,
"pipeline_id": pipeline_id,
},
)
def _parse_push_event(self, payload: dict, account_id: str) -> UnifiedMessage | None:
project = payload.get("project", {})
ref = payload.get("ref", "")
branch = ref.replace("refs/heads/", "") if ref.startswith("refs/heads/") else ref
commits = payload.get("commits", [])
total_commits = payload.get("total_commits_count", len(commits))
before = payload.get("before", "")
after = payload.get("after", "")
if before == "0000000000000000000000000000000000000000":
action_text = f"\U0001f4cc 新建分支 `{branch}`"
elif after == "0000000000000000000000000000000000000000":
action_text = f"\U0001f5d1\ufe0f 删除分支 `{branch}`"
else:
action_text = f"\U0001f4e4 Push {total_commits} commit(s) to `{branch}`"
text = action_text
commit_summaries = []
for c in commits[:5]:
msg = c.get("message", "").split("\n")[0][:80]
author = c.get("author", {}).get("name", "unknown")
sha = c.get("id", "")[:8]
commit_summaries.append(f" `{sha}` {msg}{author}")
if commit_summaries:
text += "\n" + "\n".join(commit_summaries)
if total_commits > 5:
text += f"\n ... and {total_commits - 5} more"
sender = PeerInfo(
kind=PeerKind.DIRECT,
id=str(payload.get("user_id", "")),
display_name=payload.get("user_username", payload.get("user_name", "Push")),
is_bot=False,
is_self=False,
)
project_id = project.get("id", 0)
project_path = project.get("path_with_namespace", "")
group = GroupContext(
id=f"{project_id}:Push:{branch}",
kind="gitlab_push",
name=f"{project_path} — `{branch}`",
)
return UnifiedMessage(
msg_id=f"gitlab:push:{after[:16]}:{branch}",
channel_type="gitlab",
account_id=account_id,
content=text,
sender=sender,
group=group,
message_type=MessageType.EVENT,
raw_payload=payload,
metadata={
"project_id": project_id,
"project_path": project_path,
"branch": branch,
"total_commits": total_commits,
"before": before,
"after": after,
},
)
def _parse_tag_event(self, payload: dict, account_id: str) -> UnifiedMessage | None:
project = payload.get("project", {})
ref = payload.get("ref", "")
tag_name = ref.replace("refs/tags/", "") if ref.startswith("refs/tags/") else ref
before = payload.get("before", "")
after = payload.get("after", "")
if before == "0000000000000000000000000000000000000000":
text = f"\U0001f3f7\ufe0f 创建标签: `{tag_name}`"
elif after == "0000000000000000000000000000000000000000":
text = f"\U0001f5d1\ufe0f 删除标签: `{tag_name}`"
else:
text = f"\U0001f3f7\ufe0f 标签更新: `{tag_name}`"
sender = PeerInfo(
kind=PeerKind.DIRECT,
id=str(payload.get("user_id", "")),
display_name=payload.get("user_username", payload.get("user_name", "Tag")),
is_bot=False,
is_self=False,
)
return UnifiedMessage(
msg_id=f"gitlab:tag:{tag_name}",
channel_type="gitlab",
account_id=account_id,
content=text,
sender=sender,
message_type=MessageType.EVENT,
raw_payload=payload,
metadata={
"project_id": project.get("id", 0),
"project_path": project.get("path_with_namespace", ""),
"tag": tag_name,
"before": before,
"after": after,
},
)
def _parse_release_event(self, payload: dict, account_id: str) -> UnifiedMessage | None:
project = payload.get("project", {})
tag = payload.get("tag", "")
name = payload.get("name", tag)
action = payload.get("action", "create")
description = payload.get("description", "")
action_map = {
"create": f"\U0001f680 发布: **{name}** (`{tag}`)",
"update": f"\u270f\ufe0f 更新发布: **{name}** (`{tag}`)",
"delete": f"\U0001f5d1\ufe0f 删除发布: **{name}** (`{tag}`)",
}
text = action_map.get(action, f"Release {action}: **{name}** (`{tag}`)")
if description:
text += f"\n\n{description[:500]}"
return UnifiedMessage(
msg_id=f"gitlab:release:{tag}:{action}",
channel_type="gitlab",
account_id=account_id,
content=text,
sender=PeerInfo(kind=PeerKind.DIRECT, id="0", display_name="Release", is_bot=False, is_self=False),
message_type=MessageType.EVENT,
raw_payload=payload,
metadata={
"project_id": project.get("id", 0),
"project_path": project.get("path_with_namespace", ""),
"tag": tag,
"name": name,
"action": action,
"url": payload.get("url", ""),
},
)
def _parse_work_item_event(self, payload: dict, account_id: str) -> UnifiedMessage | None:
attrs = payload.get("object_attributes", {})
user = payload.get("user", {})
project = payload.get("project", {})
work_item_type = attrs.get("work_item_type", payload.get("object_kind", "work_item"))
action = attrs.get("action", "")
iid = attrs.get("iid", 0)
title = attrs.get("title", "")
type_label_map = {
"Issue": "#Issue",
"Task": "\u2611\ufe0f Task",
"Incident": "\U0001f6a8 Incident",
"Epic": "\U0001f4d6 Epic",
"OKR": "\U0001f3af OKR",
}
type_label = type_label_map.get(work_item_type, work_item_type)
action_map = {
"open": f"\U0001f4dd {type_label}: **{title}**",
"close": f"\u2705 {type_label}: **{title}**",
"reopen": f"\U0001f504 {type_label}: **{title}**",
"update": f"\u270f\ufe0f {type_label}: **{title}**",
}
text = action_map.get(action, f"{type_label} 事件: {action} — **{title}**")
sender = PeerInfo(
kind=PeerKind.DIRECT,
id=str(user.get("id", "")),
display_name=user.get("name", user.get("username", "")),
is_bot=False,
is_self=False,
)
project_id = project.get("id", 0)
project_path = project.get("path_with_namespace", "")
return UnifiedMessage(
msg_id=f"gitlab:work_item:{attrs.get('id', 0)}:{action}",
channel_type="gitlab",
account_id=account_id,
content=text,
sender=sender,
group=GroupContext(
id=f"{project_id}:WorkItem:{iid}",
kind=f"gitlab_work_item_{work_item_type.lower()}",
name=f"{project_path}{title}",
),
message_type=MessageType.EVENT,
raw_payload=payload,
metadata={
"project_id": project_id,
"project_path": project_path,
"work_item_type": work_item_type,
"iid": iid,
"action": action,
"url": attrs.get("url", ""),
},
)