check_unmerged_branches.sh 使用文档

功能:检查 GitLab 项目(支持多个项目 / 整个 Group)中长期未合并的分支,通过企业微信 Webhook 推送告警和汇总报告。


1. 执行流程

流程概述:

① 加载配置 → ② 构建项目列表(GROUP_ID 展开 + PROJECT_IDS 合并去重)
→ ③ 计算时间截止日期(cutoff)
→ ④ 逐项目循环:
     拉取全部分支(自动翻页)
     → 过滤:未合并(merged=false) + 分支名不匹配排除正则 + 最后提交日期 < cutoff
     → 命中 > 0:发送该项目的企业微信告警明细(每条间隔 MSG_INTERVAL 秒)
     → 获取失败:跳过该项目(计入 SKIPPED,不中断整体)
→ ⑤ 发送汇总报告(有告警→⚠️ / 无告警→✅,含检查数、告警数、跳过数)

每次运行至少收到 1 条企业微信消息(汇总报告),有命中时每个项目额外 1 条明细告警。


2. 环境依赖

依赖说明
bashmacOS / Linux 自带
curl调用 GitLab API 和企业微信 Webhook
jqJSON 解析(必装brew install jq / yum install jq
date自动兼容 macOS(BSD) 与 Linux(GNU) 两种语法,无需配置

网络要求:运行机器需能访问 GITLAB_URL 和企业微信 qyapi.weixin.qq.com


3. 快速开始

  1. 打开脚本,修改顶部 配置区(见下节)。

  2. 运行:

    bash check_unmerged_branches.sh
    
  3. 观察控制台输出与企业微信消息。企业微信响应为 {"errcode":0,"errmsg":"ok"} 即发送成功。


4. 配置项说明

⚠️ 安全提醒GITLAB_TOKEN 与 WECOM_WEBHOOK 以明文写在脚本内,请勿将含真实密钥的脚本提交到公共仓库或对外分享;在 CI 中建议改用环境变量注入。本文档中所有密钥均已脱敏。

4.1 GitLab 连接

变量示例说明
GITLAB_URLhttps://git.example.comGitLab 实例地址(不带尾部 /
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_DAYS14 或留空/0相对天数:只告警 N 天没有新提交的分支
  • 时间筛选两者同时填写时 BEFORE_DATE 优先;都留空 = 检查全部未合并分支。
  • 时间比较基于分支最后一次提交日期commit.created_at 取 YYYY-MM-DD 字符串比较),刚创建还在活跃开发的分支不会被误报。
  • 判断"未合并"的依据是 GitLab API 的 merged 字段(即未合并到项目默认分支)。

🚨 注意:排除正则是"非锚定"匹配(子串命中即排除)
例如排除规则含 main 时,feature/main-flowhotfix/maintain-x 这类名字中间包含 main 的分支也会被排除,不会告警。
若想精确排除,请自行加锚定写法,如 "^main$|^master$|^develop$|^reserve"

4.4 通知与限流

变量示例说明
WECOM_WEBHOOKhttps://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=****企业微信群机器人 Webhook 地址
MSG_INTERVAL3每个命中项目单独发一条消息,两条之间的休眠秒数(限流保护)

📌 企业微信机器人限制约 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_TOKENWECOM_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:某个明显很久没合并的分支没有告警?
按顺序检查三个过滤条件:

  1. 分支名是否子串命中了 EXCLUDE_BRANCHES(非锚定正则,见 4.3 节 🚨);
  2. 最后提交日期是否晚于 cutoff(OLDER_THAN_DAYS/BEFORE_DATE 未满足);
  3. 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. 已知限制

  1. PROJECT_IDS 仅支持数字 ID,不支持项目路径(如 group/name);
  2. 排除正则为非锚定子串匹配,需精确排除时自行加 ^...$
  3. 项目列表为空时的报错路径在 set -e 下会静默退出,无友好提示;
  4. 企业微信 markdown 单条 4096 字节上限,超大项目的分支明细可能发送失败;
  5. 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"

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐