资讯动态

用Shell脚本与GitLab API实现团队研发效能自动化统计报表

发布时间:2026/10/22 6:13:19 来源:尧图企业网站定制
接手团队研发效能度量这件事最早是在一次周会上被临时推上去的。领导只给了一句话下周一之前要看到上个月每个成员的提交量、MR数量、Issue处理情况最好有图表。当时团队用的是自建GitLab数据都在里面可报表却没人认真做过。我用一个周末的时间用Shell脚本直连GitLab API跑出了第一版按月维度的用户周期性数据统计分析结果并且自动输出成HTML格式的报告。这套工具之后一直在用支撑了将近一年的周报和月报期间的坑踩了不少也一个一个填平了。这篇文章把整个实现过程、拆解思路和替换方案都梳理出来希望对做研发管理、团队效能或者运维统计的同学有参考价值。1. 项目定位与整体设计思路1.1 这个工具到底在解决什么问题先说说我面对的业务背景。公司内部自建的GitLab是个很典型的中型实例几十个仓库、上百个活跃账号业务代码、运维脚本、内部工具全都往里放。月底和每周一看板需要一份成员活跃数据做绩效参考以前都是从GitLab网页后台手动导出点来点去又慢又容易漏。更麻烦的是GitLab网页端只能看到单项目维度的近期动态跨项目汇总用户在整个周期内到底干了多少活基本要靠人肉拼。所以这个工具要解决的第一个问题是跨项目、跨用户的周期聚合。我把它定义为周期性数据统计分析工具核心目标是输入一个时间范围输出一份多维度的个人贡献报告覆盖提交数、变更行数、MR创建与合并数、Issue创建与关闭数、活跃天数等关键指标。第二个问题是自动化。人工统计的最大问题不是不会算而是每周重复劳动。这个工具用Shell脚本把所有统计逻辑固化下来配合cron定时执行到点自动跑、自动出HTML报告再也不需要人盯着复制粘贴。第三个问题是可视化。产出HTML格式而不是CSV或纯文本是为了让没有命令行习惯的管理者也能直接打开看。HTML报告可以在浏览器里看也可以扔到内网静态目录里共享比Excel截图体面得多也比自建BI系统轻量得多。1.2 为什么用Shell REST API而不是自建一套BI系统技术选型时有人建议用Python Pandas还有人提议干脆上一套开源BI一套图表比这个漂亮。我都认真考虑过最后仍然选了Shell curl jq这套看起来有点原始的组合原因很现实。第一环境依赖少。GitLab服务器和统计执行机之间不需要额外装什么运行时只要curl和jq就能干活。Shell脚本天然就是Linux环境的一部分部署成本几乎为零放到任何一台内网机器上都能跑。第二维护门槛低。团队里任何一位会点Linux命令的同学都能快速看懂脚本逻辑出了问题能直接上手改。如果上一套BI光配置数据源、同步用户映射关系、调权限就是一连串工作量对一个小工具来说得不偿失。第三执行路径清晰。统计工具的本质是定时抓取数据、清洗聚合、渲染展示Shell脚本恰好每一步都有成熟的命令级方案。curl负责请求jq负责解析awk做统计heredoc写HTML流水线式的处理非常自然。当然了纯Shell方案也有天花板比如复杂的多表关联和机器学习型分析就不适合用Shell硬写。但对我们这种几十个仓库、上百个账号的规模Shell绰绰有余。1.3 整体数据流与模块划分整个工具我拆成了四层以后维护起来思路很清晰。第一层是配置层维护一个config.env文件里面包含GitLab地址、Token、周期参数、目标用户列表等。所有脚本只从配置文件读参数不硬编码任何环境相关的信息换环境或者换周期的时候不用动主逻辑。第二层是采集层封装一组GitLab API调用脚本按需拉取用户、项目、提交、MR、Issue、事件六个维度的数据并把分页循环藏在这层后面。采集层的输出是原始JSON暂存文件下一步要用。第三层是统计层用jq和awk把原始JSON聚合成用户 指标 周期的行式结构输出统一的CSV中间文件。这个中间层非常重要它把复杂统计逻辑从展示逻辑中剥离出去之后换HTML模板、加指标都只动一层。第四层是展示层读取CSV生成HTML报告包括汇总表、明细表、条形图、活跃度等级。展示层也不关心数据源给它CSV它就能渲染。分层设计的好处是可以分头调试。某次如果发现MR数不对只需要单独跑统计层命令重算一份CSV不用重新采一次数排查问题的路径一下子就短了。2. 前置准备与关键配置2.1 创建最小权限TokenGitLab接入API需要Token这一步大家都会但我踩过一个权限坑多说两句。创建Token时只给read_api权限就足够了不需要给api的全权限。read_api能覆盖绝大多数统计接口比如读用户列表、读项目列表、读提交、读MR、读Issue而api权限可能触发写操作万一Token泄漏风险大得多。路径在GitLab的User Settings - Access Tokens里创建一个Personal Access Token接下列scoperead_apiread_userToken生成之后马上复制保存GitLab只显示一次。我习惯把它放进执行机上的config.env里并把配置文件权限设为600避免同机其他用户直接看到。2.2 依赖工具jq与curl的版本要求整个工具运行的基础是curl和jq这两样在主流Linux发行版上基本都有但版本有讲究。curl要7.55以上才比较省心低于这个版本对参数支持会有差异。jq至少1.6老版本在处理\u字符串、数字精度、大JSON时会有各种小毛病。我在某台老机器上就遇到过jq 1.5把长数字切成科学计数法的问题后来统一升级到jq 1.6才消停。检查版本curl -V | head -1 jq --version如果版本太老用包管理器更新一下。Debian系是apt install jqRedHat系是yum install jq都是几条命令的事。2.3 目录结构与配置文件我的目录长这样/opt/gitlab-stats/ ├── bin/ │ ├── collect.sh │ ├── stats.sh │ └── report.sh ├── config/ │ └── config.env ├── data/ │ ├── raw/ │ └── csv/ ├── logs/ │ └── gitlab-stats.log ├── reports/ │ └── index.html └── lib/ └── common.shbin下放三份主脚本lib放公共函数库data保存原始JSON和中间CSVlogs存运行日志reports是最终HTML输出目录。config.env的内容大概是GITLAB_URLhttps://gitlab.example.com GITLAB_TOKENxxxxxxxx STATS_PERIODmonth STATS_MONTH_OFFSET1 USER_LIST_FILE/opt/gitlab-stats/config/users.txt OUT_DIR/opt/gitlab-stats/reports LOG_FILE/opt/gitlab-stats/logs/gitlab-stats.logusers.txt是可选的不填就自动拉全量活跃用户。我一般会手工维护一份参与统计的成员名单排除掉一些服务账号和离职账号这样报告更有针对性。3. 数据采集层四类接口与分页处理经验3.1 用户列表与项目列表接口先说说我实际用得最多的几类接口。获取用户列表curl -s --header PRIVATE-TOKEN: ${GITLAB_TOKEN} \ ${GITLAB_URL}/api/v4/users?activetrueper_page100page1这个接口返回的是全量符合条件用户字段里有id、username、name、state、last_activity_on。我关注的其实只有id和username因为后续所有统计都以username作为关联键。获取项目列表curl -s --header PRIVATE-TOKEN: ${GITLAB_TOKEN} \ ${GITLAB_URL}/api/v4/projects?membershiptrueper_page100page1membershiptrue只返回当前Token所属用户有权限的项目用来限定统计范围。给管理员Token时如果没有这一步会拉出整个实例下所有项目把无关项目也统计进来数字失真。3.2 提交、MR、Issue与事件接口获取提交记录是统计的核心接口。按项目逐个查询用author参数过滤用户用since与until限定时间窗curl -s --header PRIVATE-TOKEN: ${GITLAB_TOKEN} \ ${GITLAB_URL}/api/v4/projects/${project_id}/repository/commits?author${username}since${since}until${until}per_page100page1这个接口返回每个commit的id、title、created_at、author_name我在统计提交次数时直接数返回数组长度就行。MR和Issue的接口路径类似# 用户创建的MR ${GITLAB_URL}/api/v4/projects/${project_id}/merge_requests?author_username${username}created_after${since}created_before${until} # 用户关联的MR含reviewer视角 ${GITLAB_URL}/api/v4/projects/${project_id}/merge_requests?scopeallcreated_after${since}created_before${until} # 用户创建的Issue ${GITLAB_URL}/api/v4/projects/${project_id}/issues?author_username${username}created_after${since}created_before${until} # 用户事件动态push、comment、close等 ${GITLAB_URL}/api/v4/users/${user_id}/events?after${since_date}before${until_date}events接口是用户维度的活动日志它能把评论、合并、关闭这类非提交动作也捕捉进来是评估活跃度的好抓手。不过它有两个坑一是只按日期过滤细到小时的时间其实是在本地时区做了偏移二是before参数是开区间传统计结束日当天是拿不到那天的数据的必须把before设为结束日期的下一天。3.3 分页、限速与时区边界GitLab的REST API默认每页最多100条把per_page设为100减少请求次数。可一旦某位同学提交特别勤快一个周期内commit超过100条你就必须在循环里翻页翻页逻辑如果写错统计数据就悄悄变少。我封装了一个公共翻页函数放在lib/common.sh里fetch_paginated() { local base_url$1 local page1 while :; do local resp resp$(curl -s --header PRIVATE-TOKEN: ${GITLAB_TOKEN} \ ${base_url}per_page100page${page}) echo ${resp} local item_count item_count$(echo ${resp} | jq length) if [[ ${item_count} -lt 100 ]]; then break fi page$((page 1)) done }这个函数的跳出条件不严谨当本周期返回正好100的整数倍时会多请求一页空数组但空数组不会污染统计对jq length求和仍然是0所以影响不大。这是我在进度要求下做的一个优化取舍可接受。限速也要注意。GitLab对每个Token有API调用频率限制免费版和部分自建配置默认是每分钟60次。我们统计期间要请求的URL数量大概等于项目数乘以用户数再乘以接口种类如果仓库多、人多且分页多很容易把限速撞上。一旦429脚本就必须sleep一阵子再重试否则整个任务会失败一半。时区边界是个容易忽视的点。GitLab服务端记录的created_at大多是UTC时间而统计周期如果按本地时间定义直接传参采集会把边界附近几条记录算重或者算漏。我的处理方法简单粗暴在Shell里用date命令先算好本地时区的时间字符串再追加偏移参数转成UTC传给API。实测下来只要周期边界按开始日的2024-05-01T00:00:00Z这种全UTC写法传统计结果就是稳定的。3.4 jq与awk的分工采集返回的是嵌套JSON我需要把它拍平成行式数据。jq是首选因为它处理JSON结构能力强语法也直观。比如从提交数组里提取user和countecho ${commits_json} | jq -r .[] | [.author_name, .created_at] | tsv批量累加时我反而更习惯用awk因为纯计数、累加、比较这种动作awk的数组和字符串处理比jq的reduce写法读起来更自然。awk -F \t {commit_count[$1] 1; if ($2 ! ) active_days[$1] 1} END { for (u in commit_count) print u, commit_count[u] }基本原则是列裁剪用jq行聚合用awk。这个分工在Shell场景下效率高维护起来也一眼能看懂。4. 统计口径与指标定义4.1 周期粒度怎么定周、月还是双周周期粒度主要看使用场景。我们这边周报用周粒度月报用月粒度季度绩效参考时我就把月报数据再Excel汇总一下没有单独做季度任务。工具里通过STATS_PERIOD参数控制periodweek时脚本自动计算上周一0点到下周一0点periodmonth时计算上个月1号0点到下个月1号0点。if [[ ${STATS_PERIOD} month ]]; then since$(date -d -1 month -$(date %d -d today) day 1 day %Y-%m-%dT00:00:00Z) until$(date %Y-%m-%dT00:00:00Z) elif [[ ${STATS_PERIOD} week ]]; then since$(date -d -1 week 1 day %Y-%m-%dT00:00:00Z) until$(date %Y-%m-%dT00:00:00Z) fi这里的关键点是把until设成当前周期的开始点即统计结束日期的0点确保和下一周期无缝衔接不重叠也不留缝。4.2 六类核心指标的统计逻辑我最终列了六类报表指标每一类在报告中都有独立表格呈现。第一类是提交次数直接从commits接口返回值数组长度得到代表周期内push代码的次数。第二类是变更行数这个GitLab API不直接给我采用的是用一个额外接口获取每个commit的统计GET /projects/:id/repository/commits/:sha/diff。遍历diff的diff字段利用awk统计以开头和以-开头的行数再汇总。这个接口一个提交请求一次请求量极大所以要加限速保护和断点续跑。第三类是MR创建数按author_username过滤新建MR统计周期内由该用户发起的合并请求数量。第四类是MR合并数GitLab的MR接口不能按合并人过滤网上有人用statemerged updated_after来近似但updated会被review流程改变导致偏差。我最后改用MR关联事件来统计合并动作即使用events接口把target_typeMilestone/Note等类型里的MR merged动作算进去。这样准确但脚本复杂度略高。第五类是Issue创建与关闭数创建用issues接口按author_username统计关闭数我用events里issue close事件来统计能真实反映谁操作关闭了Issue。第六类是活跃天数统计周期内该用户至少有一次有效事件push、comment、close、merge等的独立去重日期天数。这个指标最能反映持续投入度因为一个人可以一天提交100个commit也可以每天都只提交1个commit两者工作节奏完全不同。4.3 活跃度分级A/B/C三档怎么划分报告里我给每个用户落了一个活跃度等级方便管理层瞄一眼就完成排序。划分规则是在采集汇总之后统一算的不依赖单一指标走的是加权评分。score$(( commit_count * 2 mr_created * 5 issues_closed * 3 active_days * 3 ))这个权重配比是我根据团队实际情况调的。做得特别重的代码提交是基础分MR和Issue更能体现协作价值所以权重更高。总分区间拉到100分制后按百分位分成三档前20%为A中间60%为B后20%为C。分级本身不是目的它主要是用来在HTML报告里用颜色标出重点对象。A档绿色高亮B档正常颜色C档橙色提示管理层可以在十秒内完成对团队成员投入状态的初步判断。5. Shell脚本主体实现5.1 脚本骨架与函数划分主流程我拆成三份脚本collect.sh负责调用API采集原始数据stats.sh做统计report.sh渲染HTML。每份脚本都引用lib/common.sh加载公共函数。collect.sh的执行骨架长这样#!/usr/bin/env bash set -euo pipefail source $(dirname $0)/../lib/common.sh load_config # 1. 计算周期 calculate_period # 2. 拉取用户和项目列表 fetch_users ${raw_dir}/users.json fetch_projects ${raw_dir}/projects.json # 3. 循环每个项目和用户拉取提交和MR while read -r project_id; do while read -r username; do fetch_commits ${project_id} ${username} ${raw_dir}/commits_${project_id}_${username}.json fetch_mrs ${project_id} ${username} ${raw_dir}/mrs_${project_id}_${username}.json done ${user_list} done ${project_list}这里粗看没问题实际跑起来会有性能问题。项目多用户多的环境下串行循环会让整个采集过程持续几小时。我的优化是引入简单的并行度控制用xargs -P参数把用户循环拆成3路并发。并发数不是越大越好因为GitLab限速和机器网络连接数都有上限3到5路在多数场景是甜点区。5.2 提交统计函数详解commit统计是整个项目的核心我拆成两个函数一个是纯拉取一个是解析。拉取函数fetch_commits() { local project_id$1 local username$2 fetch_paginated ${GITLAB_URL}/api/v4/projects/${project_id}/repository/commits?author${username}since${since}until${until} }注意gitlab commit API的until参数是闭区间会把边界当天0点那一条也算进去。我为了避免重复在calculate_period阶段把until统一加了一天并追加了时区偏移这样每次统计周期互相独立。解析函数parse_commits() { local username$1 local raw_file$2 jq -r .[] | select(.author_name ! null) | [.author_name, .created_at, .id] | tsv ${raw_file} \ | awk -v user${username} -F \t { if ($1 user) { commits[user] sub(/T.*/, , $2) days[$2] 1 } } END { n 0 for (d in days) n printf %s\t%d\t%d\t%s\n, user, commits[user], n, target } }我在脚本里故意把活跃天数计算放在commit解析阶段一起算虽然严格意义上活跃天数应该综合所有事件但commit日期去重已经能覆盖九成场景其他动作的活跃在后续事件统计里再叠加去重。5.3 MR、Issue与事件统计MR统计我按两个方向分别处理创建数直接用MR接口author过滤合并数用events接口的动作事件去重。parse_mr_created() { jq -r .[] | [.author.username, .created_at] | tsv $1 \ | awk -F \t {mr_created[$1]} END {for (u in mr_created) print u, mr_created[u]} } parse_merge_events() { # events来源GET /api/v4/users/:id/events # action_name为merged的目标是MergeRequest统计触发人 jq -r .[] | select(.action_name merged) | [.author.username, .target_type] | tsv $1 \ | awk -F \t $2 MergeRequest {mr_merged[$1]} END {for (u in mr_merged) print u, mr_merged[u]} }Issue的创建数目和关闭数也可以类比封装。关闭动作在events里表现为action_name为closedtarget_type为Issue。这一层统计完再调用merge_by_user函数把六份中间结果合并成一张宽表键统一为username。5.4 数据聚合与CSV中间层最终统计层输出一个统一格式的CSV列定义如下username, commit_count, changed_lines, mr_created, mr_merged, issue_created, issue_closed, active_days, score, level合并逻辑我用awk维护一个全量用户字典每读一行统计结果就往字典里累加最后一次性输出。这里必须注意awk的字符串字段拼接要用制表符或逗号不能混用空格否则后面report.sh按逗号解析会错位。CSV中间层让我在调试HTML样式时受益巨大。我不需要每次调样式都重新跑一遍采集和统计直接把之前的CSV保留一份report.sh对着同一份CSV反复渲染即可。这也是我把整个工具拆成分层脚本后的最大红利。6. HTML报告生成不借助前端框架的轻量可视化6.1 报告模板与CSS设计HTML报告输出是整套工具最出效果的部分。因为内网环境不方便加载CDN所以我不引入任何外部JS和CSS框架完全用原生HTML 内联样式生成报告这样可以保证在任何浏览器里直接打开都有稳定的版式。模板的核心结构是!DOCTYPE html html head meta charsetutf-8 title用户周期数据统计报告/title style body { font-family: PingFang SC, Microsoft YaHei, sans-serif; margin: 24px; background: #f5f6fa; } h1 { font-size: 24px; color: #2c3e50; } h2 { font-size: 18px; color: #34495e; margin-top: 28px; border-left: 4px solid #2980b9; padding-left: 10px; } table { width: 100%; border-collapse: collapse; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.08); } th { background: #2c3e50; color: #fff; padding: 8px 6px; font-size: 13px; } td { padding: 6px 6px; font-size: 13px; border-bottom: 1px solid #ecf0f1; text-align: center; } tr:hover { background: #f8f9fa; } .level-A { color: #27ae60; font-weight: bold; } .level-C { color: #e67e22; font-weight: bold; } .bar-wrap { background: #ecf0f1; border-radius: 4px; height: 18px; width: 100%; } .bar { background: #3498db; color: #fff; text-align: right; padding-right: 6px; height: 18px; border-radius: 4px; line-height: 18px; font-size: 12px; } /style /head body因为脚本是周期重复生成HTML模板内容全部由Shell用heredoc写入每次渲染时动态替换占位符。CSS里我特意把表格hover高亮做了鼠标扫过不同成员行时能快速定位目标行。6.2 用div宽度画轻量柱状图不引入ECharts、Chart.js又想有直观对比我采用的方式是用div的宽度比例画条形图。统计报告里最核心的两张图提交次数对比和活跃天数对比都用这个方案。做法是先把max值和min值算出来然后把每个用户的数值映射为百分比宽度最后在HTML里生成这串标签# 在awk输出阶段对每个用户算好条形宽度百分比 aws -F , { if ($2 max) max $2 user[$1] $2 } END { for (u in user) { width user[u] / max * 100 printf div classbar-wrapdiv classbar style\width:%.1f%%\%s/div/div\n, width, user[u] } }这种CSS条形图最大的优势是无依赖、实现简单、打开速度快在月度报告这种频繁分发场景里非常稳定。缺点是它是静态的不能点击查看明细但配合下面的明细表已经能满足绝大部分管理需求。6.3 表格排序与高亮异常除了图报告的主体是明细表。我直接罗列出所有成员的统计行并按score字段降序排列。排序逻辑写在统计层脚本里避免在HTML里调JS。排序实现就是在输出HTML前先用sort命令对CSV按score倒序重排sort -t, -k9,9nr -o ${csv_sorted} ${csv_file}排好序以后awk逐行读出来判断level字段加对应的class名生成行标签。A级用户用绿色字体C级用户用橙色提示。C级用户除了颜色我还额外在行尾加了一个attention标记列值为需关注。HTML渲染结束后脚本会顺手把当前报告软链到reports/current.html同时保留带日期的历史版本方便翻旧账。7. 自动化部署、日志与问题排查7.1 cron定时表达式与时区坑整套工具最后一步是交给cron跑。我设置的周报任务如下0 22 * * 0 /opt/gitlab-stats/bin/run_all.sh week /opt/gitlab-stats/logs/cron.log 21星期天晚上10点自动跑上周数据周一早上团队就能看到报告。月报则额外挂一条0 1 1 * * /opt/gitlab-stats/bin/run_all.sh month /opt/gitlab-stats/logs/cron.log 21cron最容易踩的坑是环境变量。cron执行时的PATH里通常没有/usr/local/bin如果jq装在这个目录下脚本一跑就报command not found。所以我每份主脚本开头都会显式source /etc/profile保证环境变量完整。第二个坑是时区。如果执行机的时区是UTC那么cron在UTC的22点执行本地看起来是凌晨2点统计日期边界会和业务理解错位。我在执行机上强制设定了Asia/Shanghai时区并且所有脚本里的date计算都基于本地时间。7.2 日志与执行状态追踪日志我坚持一次跑一条主日志每次调用写子日志的方式。run_all.sh会先写一行开始时间、周期、执行参数然后各阶段脚本再往同一个日志文件追加阶段结果。echo [$(date %Y-%m-%d %H:%M:%S)] START collect.sh ${period} ${LOG_FILE} if bash bin/collect.sh ${period}; then echo [$(date %Y-%m-%d %H:%M:%S)] collect done ${LOG_FILE} else echo [$(date %Y-%m-%d %H:%M:%S)] collect FAILED ${LOG_FILE} fi日志文件我会交给logrotate每周滚动保留52周防止机器磁盘被几十MB的日志塞满。调试期日志可以设DEBUG_MODE1会额外打印每次API请求URL和HTTP状态码。7.3 常见问题速查表现象可能原因处理办法所有用户提交数为0Token没有该项目的read权限检查Token scope确认成员权限重新创建Token部分项目数据缺失项目列表接口取不到对应仓库检查membershiptrue参数确认Token用户拥有项目权限报告页打开空白HTML转义错误或heredoc引号冲突用curl或wget下载报告检查HTML结构重点看引号配对统计数字比GitLab网页少分页没翻全或until边界设置不对在collect.sh加临时调试打印每页返回数量429限速报错API调用太频繁调低并发路数增强sleep_backoff重试机制时区错位一天执行机时区与预期不符检查date -R调整系统时区或修改脚本日期计算最后放一个我在实际使用中遇到的最典型问题MR合并数统计偏差。这是因为events接口的merged动作和MR接口的stateupdated_after两种口径天然不同我一开始图省事用后者结果每次都有几位同学的数据对不上。后来彻底改用events接口后偏差才消失。这个坑提醒我统计工具的口径一致性比实现简便更重要。8. 最后几点个人体会工具跑了将近一年我的体会是轻量自动化统计的核心价值不在于报表多漂亮而在于把一套口径稳定地固化下来每周无差别地执行。数据的稳定性让我后面做团队分析、环比对比都有了可信的基线。如果你也准备做类似的统计工具我建议先花30分钟把指标口径写清楚再谈代码口径定了Shell脚本就是一层窗户纸。若后续要扩展我计划把这份CSV再接一个简单的看板程序把接口数据接入内网BI再把自动化通知从纯文件改成邮件附带链接。不过不管怎么扩展底层的Shell采集和统计层都还能继续用这算是我当时选Shell方案时没想到的意外红利。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑