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", ""), }, )