gitlab合并检查通知工具
check_unmerged_branches.sh 使用文档
功能:检查 GitLab 项目(支持多个项目 / 整个 Group)中长期未合并的分支,通过企业微信 Webhook 推送告警和汇总报告。
1. 执行流程

流程概述:
① 加载配置 → ② 构建项目列表(GROUP_ID 展开 + PROJECT_IDS 合并去重)
→ ③ 计算时间截止日期(cutoff)
→ ④ 逐项目循环:
拉取全部分支(自动翻页)
→ 过滤:未合并(merged=false) + 分支名不匹配排除正则 + 最后提交日期 < cutoff
→ 命中 > 0:发送该项目的企业微信告警明细(每条间隔 MSG_INTERVAL 秒)
→ 获取失败:跳过该项目(计入 SKIPPED,不中断整体)
→ ⑤ 发送汇总报告(有告警→⚠️ / 无告警→✅,含检查数、告警数、跳过数)
每次运行至少收到 1 条企业微信消息(汇总报告),有命中时每个项目额外 1 条明细告警。
2. 环境依赖
| 依赖 | 说明 |
|---|---|
bash | macOS / Linux 自带 |
curl | 调用 GitLab API 和企业微信 Webhook |
jq | JSON 解析(必装:brew install jq / yum install jq) |
date | 自动兼容 macOS(BSD) 与 Linux(GNU) 两种语法,无需配置 |
网络要求:运行机器需能访问 GITLAB_URL 和企业微信 qyapi.weixin.qq.com。
3. 快速开始
-
打开脚本,修改顶部 配置区(见下节)。
-
运行:
bash check_unmerged_branches.sh -
观察控制台输出与企业微信消息。企业微信响应为
{"errcode":0,"errmsg":"ok"}即发送成功。
4. 配置项说明
⚠️ 安全提醒:
GITLAB_TOKEN与WECOM_WEBHOOK以明文写在脚本内,请勿将含真实密钥的脚本提交到公共仓库或对外分享;在 CI 中建议改用环境变量注入。本文档中所有密钥均已脱敏。
4.1 GitLab 连接
| 变量 | 示例 | 说明 |
|---|---|---|
GITLAB_URL | https://git.example.com | GitLab 实例地址(不带尾部 /) |
GITLAB_TOKEN | **** | Personal Access Token,需 api 权限 |
📌 该 GitLab 实例的网关会拦截
PRIVATE-TOKEN请求头(返回 401),脚本已改为
private_token=URL 参数方式认证,无需再改。
4.2 检查范围(二选一或同时配置)
| 变量 | 示例 | 说明 |
|---|---|---|
PROJECT_IDS | "8318 8324 8325" | 空格分隔的数字项目 ID 列表 |
GROUP_ID | "10035" | Group ID,自动展开该 Group(含子 Group)下所有项目 |
- 两者同时填写时,结果合并去重。
- 项目 ID / Group ID 获取方式:GitLab 项目或 Group 首页 → Settings → General,或访问
API_URL/api/v4/groups?search=名称查询。
🚨 常见错误:
PROJECT_IDS填了组名(如auto-edge-match)→ 脚本静默退出,什么都不发!
PROJECT_IDS只接受数字 ID。非数字内容会被过滤掉,当最终项目列表为空时,
脚本因set -e+grep -c的非零退出码而直接退出(exit 1),不会打印任何错误提示。
若想按组检查,请把数字 Group ID 填到GROUP_ID,而不是把组名填进PROJECT_IDS。
4.3 分支过滤
| 变量 | 示例 | 说明 |
|---|---|---|
EXCLUDE_BRANCHES | "main|master|develop|reserve" | 排除分支名正则,匹配到的分支不告警 |
BEFORE_DATE | "2026-08-01" 或留空 | 绝对日期:只告警最后提交早于该日期的分支 |
OLDER_THAN_DAYS | 14 或留空/0 | 相对天数:只告警 N 天没有新提交的分支 |
- 时间筛选两者同时填写时
BEFORE_DATE优先;都留空 = 检查全部未合并分支。 - 时间比较基于分支最后一次提交日期(
commit.created_at取YYYY-MM-DD字符串比较),刚创建还在活跃开发的分支不会被误报。 - 判断"未合并"的依据是 GitLab API 的
merged字段(即未合并到项目默认分支)。
🚨 注意:排除正则是"非锚定"匹配(子串命中即排除)。
例如排除规则含main时,feature/main-flow、hotfix/maintain-x这类名字中间包含 main 的分支也会被排除,不会告警。
若想精确排除,请自行加锚定写法,如"^main$|^master$|^develop$|^reserve"。
4.4 通知与限流
| 变量 | 示例 | 说明 |
|---|---|---|
WECOM_WEBHOOK | https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=**** | 企业微信群机器人 Webhook 地址 |
MSG_INTERVAL | 3 | 每个命中项目单独发一条消息,两条之间的休眠秒数(限流保护) |
📌 企业微信机器人限制约 20 条/分钟。告警项目很多时请调大
MSG_INTERVAL(如 5),
否则可能触发限流导致消息丢失。📌 企业微信 markdown 消息单条内容上限 4096 字节。若单个项目未合并分支过多,
消息可能发送失败(响应中的errcode非 0),需缩短明细或分拆发送。
5. 输出说明
5.1 控制台输出示例
==========================================
GitLab 未合并分支检查
时间: 2026-09-07 10:00:00
==========================================
正在获取 Group(10035) 下的项目列表...
Group 内项目: 8318 8324 8325
待检查项目数: 3
项目列表: 8318 8324 8325
时间筛选: 仅检查最后提交早于 2026-08-24 的分支
[1/3] 检查项目 8318 ...
项目: group/demo-service
分支总数: 25 | 命中告警: 2
企业微信响应: {"errcode":0,"errmsg":"ok"}
[2/3] 检查项目 8324 ...
项目: group/another-service
分支总数: 10 | 命中告警: 0
==========================================
检查完成: 共 3 个项目,1 个项目触发告警
==========================================
发送汇总报告到企业微信...
汇总报告企业微信响应: {"errcode":0,"errmsg":"ok"}
5.2 企业微信消息
| 消息 | 触发条件 | 内容 |
|---|---|---|
| ⚠️ 未合并分支告警(每项目一条) | 该项目命中 ≥1 个分支 | 项目全路径、未合并分支数、筛选条件、每个分支的名称/最后提交日期/提交者/提交消息(前60字符) |
| 汇总报告(每次运行一条) | 总是发送 | 有告警 → ⚠️「存在告警」;无告警 → ✅「全部正常」;含检查项目数、告警项目数、失败跳过数 |
6. 定时运行
6.1 cron(macOS / Linux 通用)
crontab -e
# 每周一上午 10:00 检查一次
0 10 * * 1 /bin/bash "/Users/huan2.liu/ai_生成工具-文档/ai-生成-项目/check_unmerged_branches.sh" >> /tmp/check_unmerged.log 2>&1
6.2 GitLab CI Schedule
check-unmerged:
stage: .pre
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
script:
- bash scripts/check_unmerged_branches.sh
配合 GitLab → CI/CD → Schedules 设置周期。CI 环境中建议将 GITLAB_TOKEN、WECOM_WEBHOOK 配置为 Masked CI/CD Variables 并改造脚本从环境变量读取,避免密钥入库。
7. 退出码
| 退出码 | 场景 |
|---|---|
0 | 正常完成(无论是否有告警) |
1 | 缺少 GITLAB_TOKEN 或 WECOM_WEBHOOK 配置(有错误提示) |
1 | 获取 Group 项目列表失败(有错误提示) |
1 | 项目列表为空(配置了 PROJECT_IDS/GROUP_ID 但过滤后无有效数字 ID)——⚠️ 因 set -e 机制,此场景可能静默退出无提示,见 4.2 节 |
单个项目 API 失败不会中断整体:跳过该项目并计入汇总报告的「检查失败(跳过)」数。
8. 常见问题(FAQ)
Q1:改了 PROJECT_IDS 后运行"没效果"——无输出、无消息?
最常见原因是把组名/分支名等非数字内容填进了 PROJECT_IDS(见 4.2 节 🚨)。非数字 ID 会被过滤,列表为空时脚本静默退出(exit 1)。
排查:bash -x check_unmerged_branches.sh 2>&1 | tail -20 看执行到哪一步退出;修复:改回数字项目 ID,或用 GROUP_ID 按组检查。
Q2:报 401 Unauthorized?
Token 过期或权限不足。重新生成带 api scope 的 Token。注意该实例必须用 private_token= URL 参数方式(脚本已内置),用 PRIVATE-TOKEN 请求头会被网关拦截。
Q3:某个明显很久没合并的分支没有告警?
按顺序检查三个过滤条件:
- 分支名是否子串命中了
EXCLUDE_BRANCHES(非锚定正则,见 4.3 节 🚨); - 最后提交日期是否晚于 cutoff(
OLDER_THAN_DAYS/BEFORE_DATE未满足); - GitLab 上该分支的
merged是否为 false(已合并到默认分支的不会出现)。
Q4:企业微信响应 errcode 非 0?
93000:机器人被移出群聊或 key 失效 → 重新创建机器人;- 频率限制:调大
MSG_INTERVAL; - 内容超 4096 字节:减少单条消息中的分支明细。
Q5:jq: command not found?
安装 jq:macOS brew install jq,CentOS yum install -y jq,Ubuntu apt install -y jq。
Q6:想先试运行、不真的发消息?
临时把 WECOM_WEBHOOK 换成无效地址会因 set -e 影响汇总逻辑,更稳妥的方式是注释掉 send_wecom_msg内的 curl 行(改为 echo "$payload"),只看控制台输出。
9. 变更记录
| 提交 | 内容 |
|---|---|
e691418 | 初始版本:单项目未合并分支检查 + 企业微信通知 |
a5c751e | 修复:网关拦截 PRIVATE-TOKEN 头 → 改用 private_token URL 参数;颜色语法兼容 sh/bash |
a9aba50 | 新增时间筛选:BEFORE_DATE / OLDER_THAN_DAYS,只告警滞留老分支 |
1ac28a3 | 支持多项目 PROJECT_IDS 与 GROUP_ID 整组模式(合并去重、单项目失败跳过) |
| 当前版本 | 新增汇总报告:运行结束统一发送 ✅/⚠️ 汇总(含跳过项目数) |
10. 已知限制
PROJECT_IDS仅支持数字 ID,不支持项目路径(如group/name);- 排除正则为非锚定子串匹配,需精确排除时自行加
^...$; - 项目列表为空时的报错路径在
set -e下会静默退出,无友好提示; - 企业微信 markdown 单条 4096 字节上限,超大项目的分支明细可能发送失败;
- Token 明文存储于脚本,需注意文件权限与分发范围。
工具本体:
#!/bin/bash
# scripts/check_unmerged_branches.sh
# 功能: 检查 GitLab 项目(支持多个项目/整个Group)中未合并的分支,通过企业微信 Webhook 通知
# 用法: 直接运行,或放入 cron / GitLab CI schedule
# bash check_unmerged_branches.sh
set -e
# ==================== 配置区 ====================
GITLAB_URL="https://git.xxx.com"
GITLAB_TOKEN="xxxx"
# ---- 检查范围(二选一或同时配置)----
# PROJECT_IDS: 空格分隔的项目ID列表,逐个检查,如 "8318 8324 8325"
# GROUP_ID: 指定 Group 后,该 Group(含子 Group)下的所有项目都会加入检查列表
# 两者同时填写时,结果合并去重
PROJECT_IDS=
GROUP_ID="10035"
# 每个项目命中告警时单独发一条企业微信消息;多条消息间休眠秒数(限流保护,约20条/分钟)
MSG_INTERVAL=3
# 排除的分支名(正则,匹配到的分支不告警)
EXCLUDE_BRANCHES="main|master|develop|reserve"
# 企业微信 Webhook
WECOM_WEBHOOK="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxx"
# ---- 时间筛选 ----
# 只对「最后提交早于指定时间」的未合并分支进行判断和通知(即只告警滞留的老分支,
# 刚创建还在开发中的分支不会被告警)
# BEFORE_DATE: 绝对日期,格式 YYYY-MM-DD,如 "2026-08-01"(留空 = 不启用)
# OLDER_THAN_DAYS: 相对天数,如 7 表示只通知 7 天没有新提交的分支(留空或 0 = 不启用)
# 两者同时填写时 BEFORE_DATE 优先
BEFORE_DATE=""
OLDER_THAN_DAYS=14
# ==================== 以下无需修改 ====================
COLOR_INFO="info"
COLOR_COMMENT="comment"
COLOR_WARNING="warning"
# 颜色(用 $'...' 语法,兼容 sh/bash)
RED=$'\033[0;31m'
GREEN=$'\033[0;32m'
YELLOW=$'\033[0;33m'
NC=$'\033[0m'
# ---- API 函数 ----
# 获取项目所有分支(自动翻页)
# 注意: 该 GitLab 实例的网关会拦截 PRIVATE-TOKEN 请求头(401),
# 必须用 private_token= URL 参数方式认证
fetch_branches() {
local project_id=$1
local page=1
local all_branches="[]"
while true; do
response=$(curl -s \
"${GITLAB_URL}/api/v4/projects/${project_id}/repository/branches?per_page=100&page=${page}&private_token=${GITLAB_TOKEN}")
# API 返回错误对象时报错
if echo "$response" | jq -e 'type == "object" and has("message")' >/dev/null 2>&1; then
echo "${RED}API 错误: $(echo "$response" | jq -r '.message')${NC}" >&2
return 1
fi
count=$(echo "$response" | jq 'length')
if [ "$count" = "0" ] || [ -z "$count" ]; then
break
fi
all_branches=$(echo "$all_branches" | jq -c ". + $response")
page=$((page + 1))
done
echo "$all_branches"
}
# 获取项目全路径名
fetch_project_name() {
local project_id=$1
curl -s \
"${GITLAB_URL}/api/v4/projects/${project_id}?private_token=${GITLAB_TOKEN}" | jq -r '.path_with_namespace'
}
# 获取 Group 下所有项目(含子Group,自动翻页)
# 输出: 每行一个项目ID
fetch_group_projects() {
local group_id=$1
local page=1
while true; do
local response
response=$(curl -s \
"${GITLAB_URL}/api/v4/groups/${group_id}/projects?per_page=100&page=${page}&include_subgroups=true&private_token=${GITLAB_TOKEN}")
if echo "$response" | jq -e 'type == "object" and has("message")' >/dev/null 2>&1; then
echo "${RED}Group API 错误: $(echo "$response" | jq -r '.message')${NC}" >&2
return 1
fi
local count
count=$(echo "$response" | jq 'length')
if [ "$count" = "0" ] || [ -z "$count" ]; then
break
fi
echo "$response" | jq -r '.[].id'
page=$((page + 1))
done
}
# 计算截止日期(YYYY-MM-DD): 最后提交早于该日期的分支才会被告警
compute_cutoff() {
if [ -n "$BEFORE_DATE" ]; then
echo "$BEFORE_DATE"
elif [ -n "$OLDER_THAN_DAYS" ] && [ "$OLDER_THAN_DAYS" != "0" ]; then
if date -v-1d '+%Y-%m-%d' >/dev/null 2>&1; then
date -v-${OLDER_THAN_DAYS}d '+%Y-%m-%d' # macOS / BSD
else
date -d "${OLDER_THAN_DAYS} days ago" '+%Y-%m-%d' # Linux / GNU
fi
else
echo ""
fi
}
# 发送企业微信 markdown 消息
send_wecom_msg() {
local content=$1
local payload
payload=$(jq -n --arg content "$content" '{
msgtype: "markdown",
markdown: { content: $content }
}')
curl -s -X POST "$WECOM_WEBHOOK" \
-H 'Content-Type: application/json' \
-d "$payload"
}
# 发送检查汇总报告(全部项目检查完成后调用)
# 参数: $1=已检查项目数 $2=触发告警项目数 $3=检查失败(跳过)项目数
# 规则: 存在告警项目 -> 发送警告信息; 无告警项目 -> 发送正常成功信息
send_summary_report() {
local checked=$1
local notified=$2
local skipped=${3:-0}
local content
local skipped_line=""
if [ "$skipped" -gt 0 ] 2>/dev/null; then
skipped_line="
**检查失败(跳过):** <font color=\"$COLOR_WARNING\">$skipped</font>"
fi
if [ "$notified" -gt 0 ] 2>/dev/null; then
content="## <font color=\"$COLOR_WARNING\">⚠️ 未合并分支检查报告 - 存在告警</font>
**检查项目数:** <font color=\"$COLOR_INFO\">$checked</font>
**触发告警项目数:** <font color=\"$COLOR_WARNING\">$notified</font>${skipped_line}
**筛选条件:** $FILTER_DESC
> 存在 <font color=\"$COLOR_WARNING\">$notified</font> 个项目有超期未合并分支,请尽快处理(明细见上方各项目告警消息)。
---
<font color=\"$COLOR_COMMENT\">检查时间: $(date '+%Y-%m-%d %H:%M:%S')</font>"
else
content="## <font color=\"$COLOR_INFO\">✅ 未合并分支检查报告 - 全部正常</font>
**检查项目数:** <font color=\"$COLOR_INFO\">$checked</font>
**触发告警项目数:** <font color=\"$COLOR_INFO\">0</font>${skipped_line}
**筛选条件:** $FILTER_DESC
> 所有项目均未发现超期未合并分支,检查通过。
---
<font color=\"$COLOR_COMMENT\">检查时间: $(date '+%Y-%m-%d %H:%M:%S')</font>"
fi
echo ""
echo "发送汇总报告到企业微信..."
local resp
resp=$(send_wecom_msg "$content")
echo "汇总报告企业微信响应: $resp"
}
# ---- 主逻辑 ----
echo "${GREEN}==========================================${NC}"
echo "${GREEN}GitLab 未合并分支检查${NC}"
echo "${GREEN}时间: $(date '+%Y-%m-%d %H:%M:%S')${NC}"
echo "${GREEN}==========================================${NC}"
# 检查必要变量
if [ -z "$GITLAB_TOKEN" ] || [ -z "$WECOM_WEBHOOK" ]; then
echo "${RED}错误: 缺少 GITLAB_TOKEN 或 WECOM_WEBHOOK 配置${NC}"
exit 1
fi
# ---- 构建待检查项目列表 ----
ALL_PROJECT_IDS="$PROJECT_IDS"
if [ -n "$GROUP_ID" ]; then
echo "正在获取 Group($GROUP_ID) 下的项目列表..."
GROUP_PROJECT_IDS=$(fetch_group_projects "$GROUP_ID" | tr '\n' ' ')
if [ $? -ne 0 ] && [ -z "$GROUP_PROJECT_IDS" ]; then
echo "${RED}错误: 获取 Group 项目列表失败${NC}"
exit 1
fi
echo "Group 内项目: $GROUP_PROJECT_IDS"
ALL_PROJECT_IDS="$ALL_PROJECT_IDS $GROUP_PROJECT_IDS"
fi
# 清理去重,得到最终项目列表(仅保留数字)
PROJECT_LIST=$(echo "$ALL_PROJECT_IDS" | tr ' ' '\n' | grep -E '^[0-9]+$' | sort -un)
PROJECT_COUNT=$(echo "$PROJECT_LIST" | grep -c .)
if [ "$PROJECT_COUNT" = "0" ]; then
echo "${RED}错误: 未配置任何项目,请填写 PROJECT_IDS 或 GROUP_ID${NC}"
exit 1
fi
echo "待检查项目数: $PROJECT_COUNT"
echo "项目列表: $(echo $PROJECT_LIST | tr '\n' ' ')"
CUTOFF=$(compute_cutoff)
if [ -n "$CUTOFF" ]; then
FILTER_DESC="最后提交早于 $CUTOFF 的未合并分支"
echo "时间筛选: 仅检查最后提交早于 $CUTOFF 的分支"
else
FILTER_DESC="全部未合并分支"
echo "时间筛选: 未启用"
fi
# ---- 逐项目检查 ----
NOTIFIED_COUNT=0 # 触发告警的项目数
CHECKED_COUNT=0 # 已检查的项目数
SKIPPED_COUNT=0 # 检查失败(跳过)的项目数
for PID in $PROJECT_LIST; do
CHECKED_COUNT=$((CHECKED_COUNT + 1))
echo ""
echo "[$CHECKED_COUNT/$PROJECT_COUNT] 检查项目 $PID ..."
PROJECT_NAME=$(fetch_project_name "$PID")
# 单项目获取失败时跳过,不中断整体
if ! BRANCHES=$(fetch_branches "$PID"); then
echo "${RED} 跳过: 获取分支失败${NC}"
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
continue
fi
TOTAL_COUNT=$(echo "$BRANCHES" | jq 'length')
# 过滤未合并分支(排除指定分支 + 时间筛选)
UNMERGED=$(echo "$BRANCHES" | jq -r \
--arg exclude "$EXCLUDE_BRANCHES" \
--arg cutoff "$CUTOFF" \
'.[]
| select(.merged == false)
| select(.name as $name | $name | test($exclude) | not)
| select($cutoff == "" or .commit.created_at[0:10] < $cutoff)
| "**\(.name)**\n> 最后提交: \(.commit.created_at[0:10])\n> 提交者: \(.commit.author_name)\n> 消息: \(.commit.message | split("\n")[0] | .[0:60])\n"')
UNMERGED_COUNT=$(echo "$BRANCHES" | jq -r \
--arg exclude "$EXCLUDE_BRANCHES" \
--arg cutoff "$CUTOFF" \
'[.[] | select(.merged == false) | select(.name as $name | $name | test($exclude) | not) | select($cutoff == "" or .commit.created_at[0:10] < $cutoff)] | length')
echo " 项目: $PROJECT_NAME"
echo " 分支总数: $TOTAL_COUNT | 命中告警: $UNMERGED_COUNT"
if [ -n "$UNMERGED" ] && [ "$UNMERGED_COUNT" != "0" ]; then
CONTENT="## <font color=\"$COLOR_WARNING\">⚠️ 未合并分支告警</font>
**项目:** <font color=\"$COLOR_INFO\">$PROJECT_NAME</font>
**未合并分支数:** <font color=\"$COLOR_WARNING\">$UNMERGED_COUNT</font>
**筛选条件:** $FILTER_DESC
---
$UNMERGED
---
<font color=\"$COLOR_COMMENT\">检查时间: $(date '+%Y-%m-%d %H:%M:%S')</font>"
RESP=$(send_wecom_msg "$CONTENT")
echo " 企业微信响应: $RESP"
NOTIFIED_COUNT=$((NOTIFIED_COUNT + 1))
# 限流保护: 多条消息之间短暂休眠
sleep "$MSG_INTERVAL"
fi
done
echo ""
echo "${GREEN}==========================================${NC}"
echo "${GREEN}检查完成: 共 $CHECKED_COUNT 个项目,$NOTIFIED_COUNT 个项目触发告警${NC}"
echo "${GREEN}==========================================${NC}"
# 全部检查完成后,发送汇总报告到 Webhook
send_summary_report "$CHECKED_COUNT" "$NOTIFIED_COUNT" "$SKIPPED_COUNT"
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐

所有评论(0)