Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# GitLab Bot configuration
BOT_GITLAB_USERNAME=
BOT_GITLAB_URL=
BOT_GITLAB_TOKEN=

# Feishu notifications are disabled by default.
FEISHU_ENABLED=false
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_CHAT_ID=
FEISHU_BOT_OPEN_ID=
FEISHU_REQUEST_TIMEOUT_SECONDS=10

# Durable notification recovery
MERGE_NOTIFICATION_MAX_ATTEMPTS=5
MERGE_NOTIFICATION_RETRY_BACKOFF_SECONDS=1
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ This is a GitLab(13.2+) bot that utilizes [webhooks](https://docs.gitlab.com/ee/
* Merge Request Events
* Wiki Page Events (Not yet)
* Tag Events (Not yet)
* Pipeline Events (Not yet)
* Pipeline Events
* Pipeline success/failure notifications
* Build Events (Not yet)

## Development
Expand Down Expand Up @@ -123,13 +124,36 @@ To enable the GitLab Bot to respond to events, you need to configure a Webhook i
- `Comments` (comment events)
- `Issues events` (issue events)
- `Merge request events` (merge request events)
- `Pipeline events` (Pipeline success/failure notifications)

5. **Save the Webhook**:
- Click on the `Add webhook` button to save your configuration.

6. **Test the Webhook (Optional)**:
- To confirm if the Webhook is set up correctly, locate the newly added Webhook in the Webhooks list and use the `Test` button to send a test request.

## Feishu Notifications

When enabled, the bot sends normalized MR approval, MR merge, and Pipeline success/failure notifications to one Feishu group while keeping the structured log channel. Feishu is disabled by default.

Before enabling it, create a Feishu custom app, grant permission to send group messages, add the app to the target group, and confirm the runtime can reach `https://open.feishu.cn` over HTTPS.

```shell
FEISHU_ENABLED=true
FEISHU_APP_ID=<Feishu app ID>
FEISHU_APP_SECRET=<Feishu app secret>
FEISHU_CHAT_ID=<target chat ID>
# Optional:
FEISHU_BOT_OPEN_ID=<open ID to mention>
FEISHU_REQUEST_TIMEOUT_SECONDS=10
```

`FEISHU_APP_SECRET` must be injected as a secret and never committed or printed. The MVP sends text messages to one `FEISHU_CHAT_ID`; cards, rich text, multiple groups, and Feishu callbacks are not supported.

MR merge and Pipeline Feishu deliveries are persisted with per-target idempotency and can be recovered or replayed after failures. Approval/unapproval keeps the existing direct-send failure behavior. To roll back, set `FEISHU_ENABLED=false`; existing log and GitLab processing remain available.

Recovery can be tuned with `MERGE_NOTIFICATION_MAX_ATTEMPTS` (default `5`) and `MERGE_NOTIFICATION_RETRY_BACKOFF_SECONDS` (default `1`). Automatic retries are bounded; failed records remain available for manual replay.

## Environment Variables

**`BOT_GITLAB_USERNAME` / `BOT_GITLAB_URL` / `BOT_GITLAB_TOKEN`**
Expand All @@ -148,6 +172,10 @@ Supports both Chinese (zh) and English (en) languages by default.

These variables specify the host and port on which the bot will run. By default, the bot will run on the IP address 0.0.0.0 and port number 9998.

**`FEISHU_ENABLED` / `FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_CHAT_ID` / `FEISHU_BOT_OPEN_ID` / `FEISHU_REQUEST_TIMEOUT_SECONDS`**

These variables configure the optional Feishu notification channel. `FEISHU_ENABLED` defaults to `false`; the other required values are checked when it is enabled. Keep the App Secret outside source control and logs. See [Feishu Notifications](#feishu-notifications) for permissions, rollback, and delivery recovery behavior.

**`BOT_GIT_EMAIL_DOMAIN`**

This configuration specifies the email domain that will be used for email addresses when making Git commits. For example:
Expand Down
32 changes: 30 additions & 2 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@
* 合并请求事件
* 维基页面事件(尚未支持)
* 标签事件(尚未支持)
* 管道事件(尚未支持)
* 管道事件
* Pipeline 成功/失败通知
* 构建事件(尚未支持)

## 本地开发
Expand Down Expand Up @@ -121,13 +122,36 @@ coolbeevip/gitlab-bot
- `评论`(评论事件)
- `问题事件`(问题事件)
- `合并请求事件`(合并请求事件)
- `管道事件`(Pipeline 成功/失败通知)

5. **保存 Webhook**:
- 点击 `添加 webhook` 按钮以保存您的配置。

6. **测试 Webhook(可选)**:
- 为确认 Webhook 是否设置正确,在 Webhooks 列表中找到新添加的 Webhook,并使用 `测试` 按钮发送测试请求。


## 飞书通知

启用后,Bot 会在保留结构化日志的同时,将 MR 审批、MR 合并和 Pipeline 成功/失败通知发送到一个飞书群。飞书默认关闭。

启用前请创建飞书自建应用,授予发送群消息权限,将应用加入目标群,并确认运行环境可以通过 HTTPS 访问 `https://open.feishu.cn`。

```shell
FEISHU_ENABLED=true
FEISHU_APP_ID=<飞书应用 App ID>
FEISHU_APP_SECRET=<飞书应用 Secret>
FEISHU_CHAT_ID=<目标群 Chat ID>
# 可选:
FEISHU_BOT_OPEN_ID=<需要被 @ 的用户 Open ID>
FEISHU_REQUEST_TIMEOUT_SECONDS=10
```

`FEISHU_APP_SECRET` 必须通过 Secret 注入,不能提交到仓库或打印到日志。MVP 只向一个 `FEISHU_CHAT_ID` 发送文本消息,不支持卡片、富文本、多群路由和飞书回调。

MR 合并和 Pipeline 的飞书投递会按目标持久化幂等状态,失败后支持恢复或补发;审批/取消审批沿用现有直接发送失败记录行为。回滚时设置 `FEISHU_ENABLED=false`,日志和 GitLab 处理仍保持可用。

可通过 `MERGE_NOTIFICATION_MAX_ATTEMPTS`(默认 `5`)和 `MERGE_NOTIFICATION_RETRY_BACKOFF_SECONDS`(默认 `1`)调整恢复策略。自动重试次数有限,失败记录仍可人工补发。

## 环境变量

**`BOT_GITLAB_USERNAME` / `BOT_GITLAB_URL` / `BOT_GITLAB_TOKEN`**
Expand All @@ -146,6 +170,10 @@ coolbeevip/gitlab-bot

这些变量指定机器人运行的主机和端口。默认情况下,机器人将在 IP 地址 0.0.0.0 和端口号 9998 上运行。

**`FEISHU_ENABLED` / `FEISHU_APP_ID` / `FEISHU_APP_SECRET` / `FEISHU_CHAT_ID` / `FEISHU_BOT_OPEN_ID` / `FEISHU_REQUEST_TIMEOUT_SECONDS`**

这些变量用于配置可选的飞书通知 Channel。`FEISHU_ENABLED` 默认是 `false`;启用后会校验其他必填配置。请将 App Secret 保存在 Secret 管理系统中,不要提交到仓库或写入日志。权限、回滚和投递恢复说明见[飞书通知](#飞书通知)。

**`BOT_GIT_EMAIL_DOMAIN`**

该配置指定在进行 Git 提交时使用的电子邮件域名。例如:
Expand Down
51 changes: 48 additions & 3 deletions gitlab_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,24 @@

from dotenv import load_dotenv

from src.channels.dispatcher import NotificationDispatcher
from src.channels.feishu import FeishuChannel
from src.channels.log import LogChannel
from src.config import (
bot_gitlab_token,
bot_gitlab_url,
bot_gitlab_username,
bot_host,
bot_port,
feishu_app_id,
feishu_app_secret,
feishu_bot_open_id,
feishu_chat_id,
feishu_enabled,
feishu_request_timeout_seconds,
merge_notification_db_path,
merge_notification_max_attempts,
merge_notification_retry_backoff_seconds,
merge_notification_sending_timeout_seconds,
)
from src.delivery.coordinator import NotificationDelivery
Expand All @@ -35,6 +45,7 @@
from src.hooks.merge_notification import MergeRequestNotificationHooks
from src.hooks.merge_request import MergeRequestHooks
from src.hooks.note import NoteHooks
from src.hooks.pipeline_notification import PipelineNotificationHooks
from src.logs import print_event

load_dotenv() # isort:skip
Expand Down Expand Up @@ -65,22 +76,51 @@ def _load_gitlab_bot():
issue_hooks = IssueHooks()
merge_request_hooks = MergeRequestHooks()
note_hooks = NoteHooks()
notification_channel = LogChannel()


def _build_notification_targets():
targets = {"log": LogChannel()}
if feishu_enabled:
targets["feishu"] = FeishuChannel.from_environment(
app_id=feishu_app_id,
app_secret=feishu_app_secret,
chat_id=feishu_chat_id,
bot_open_id=feishu_bot_open_id,
timeout_seconds=feishu_request_timeout_seconds,
)
return targets


notification_targets = _build_notification_targets()
notification_channel = NotificationDispatcher(notification_targets)
approval_notification_hooks = ApprovalNotificationHooks(notification_channel)
notification_delivery_store = NotificationDeliveryStore(
merge_notification_db_path,
sending_timeout_seconds=merge_notification_sending_timeout_seconds,
max_attempts=merge_notification_max_attempts,
retry_backoff_seconds=merge_notification_retry_backoff_seconds,
)
merge_notification_channel = DurableIdempotentChannel(notification_channel, notification_delivery_store)
durable_notification_targets = {
target: DurableIdempotentChannel(channel, notification_delivery_store, delivery_target=target)
for target, channel in notification_targets.items()
}
merge_notification_channel = NotificationDispatcher(durable_notification_targets)
merge_notification_delivery = NotificationDelivery(merge_notification_channel, notification_delivery_store)
pipeline_notification_delivery = NotificationDelivery(merge_notification_channel, notification_delivery_store)
merge_request_notification_hooks = MergeRequestNotificationHooks(
merge_notification_channel,
delivery=merge_notification_delivery,
)
pipeline_notification_hooks = PipelineNotificationHooks(
merge_notification_channel,
delivery=pipeline_notification_delivery,
)


async def recover_merge_notification_deliveries(_app):
await merge_request_notification_hooks.recover()
recovered_merge_notifications = await merge_request_notification_hooks.recover()
recovered_pipeline_notifications = await pipeline_notification_hooks.recover()
return recovered_merge_notifications + recovered_pipeline_notifications


bot.app.on_startup.append(recover_merge_notification_deliveries)
Expand Down Expand Up @@ -145,6 +185,11 @@ async def merge_request_merged_event(event, gl, *args, **kwargs):
await merge_request_notification_hooks.handle(event, gl, *args, **kwargs)


@bot.router.register("Pipeline Hook")
async def pipeline_event(event, gl, *args, **kwargs):
await pipeline_notification_hooks.handle(event, gl, *args, **kwargs)


@bot.router.register("Note Hook", noteable_type="MergeRequest")
async def note_merge_request_event(event, gl, *args, **kwargs):
if not ignore_event(event):
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ authors = [
]
requires-python = ">=3.9,<4.0"
dependencies = [
"aiohttp>=3.11,<4.0",
"gidgetlab[aiohttp]>=1.1.0,<2.0.0",
"setuptools<81",
"langchain==0.3.6",
Expand Down
10 changes: 9 additions & 1 deletion src/channels/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@
# limitations under the License.

from .base import Channel
from .dispatcher import NotificationDispatcher, NotificationDispatchError
from .feishu import FeishuChannel
from .log import LogChannel

__all__ = ["Channel", "LogChannel"]
__all__ = [
"Channel",
"FeishuChannel",
"LogChannel",
"NotificationDispatchError",
"NotificationDispatcher",
]
4 changes: 2 additions & 2 deletions src/channels/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@

from abc import ABC, abstractmethod

from ..notifications.model import MergeRequestNotification
from ..notifications.model import Notification


class Channel(ABC):
"""Asynchronous destination for normalized notifications."""

@abstractmethod
async def send(self, notification: MergeRequestNotification) -> None:
async def send(self, notification: Notification) -> None:
raise NotImplementedError
71 changes: 71 additions & 0 deletions src/channels/dispatcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Copyright 2026 Lei Zhang
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import asyncio
import json
import logging
from typing import Dict, Mapping, Optional

from ..notifications.model import Notification
from .base import Channel


class NotificationDispatchError(RuntimeError):
"""Raised after all notification targets have had a chance to process a notification."""

def __init__(self, errors: Mapping[str, BaseException]):
self.errors = dict(errors)
summary = ", ".join(f"{target}: {type(error).__name__}" for target, error in self.errors.items())
super().__init__(f"notification dispatch failed ({summary})")


class NotificationDispatcher(Channel):
"""Send one normalized notification to independent named targets."""

def __init__(self, channels: Mapping[str, Channel], logger: Optional[logging.Logger] = None):
if not channels:
raise ValueError("NotificationDispatcher requires at least one target")
self.channels = dict(channels)
self.logger = logger or logging.getLogger(__name__)

async def send(self, notification: Notification) -> None:
target_names = tuple(self.channels)
results = await asyncio.gather(
*(self.channels[target].send(notification) for target in target_names),
return_exceptions=True,
)
errors: Dict[str, BaseException] = {}
for target, result in zip(target_names, results):
if isinstance(result, asyncio.CancelledError):
raise result
if isinstance(result, BaseException):
errors[target] = result
self.logger.error(
json.dumps(
{
"event": "notification_channel_failed",
"target": target,
"notification_action": notification.action,
"error_type": type(result).__name__,
"error": str(result),
},
ensure_ascii=False,
sort_keys=True,
)
)
if errors:
raise NotificationDispatchError(errors)


__all__ = ["NotificationDispatchError", "NotificationDispatcher"]
Loading
Loading