拖累斯头像
Jenkins + Python 全自动跑通「禅道任务/BUG 导出 → 月度开发考核表」封面图

Jenkins + Python 全自动跑通「禅道任务/BUG 导出 → 月度开发考核表」

Jenkins + Python 全自动跑通「禅道任务/BUG 导出 → 月度开发考核表」

一条 Jenkins 流水线,从登录禅道、导出全量 Task 与 Bug,到生成可直接打分的月度开发考核汇总表,全程零手工。
本文把两个可复用的成果拆开讲:成果一:全量导出通道,成果二:考核指标统计引擎,最后给出整体设计思路与几个必须踩过的坑。


一、背景:手工统计一个月,错在四个地方

做测试主管这几年,每月初都要交一份「开发人员 BUG 统计 + 评分」。手工版本的流程大概是:登录禅道 → 任务列表逐个项目点导出 → BUG 列表再导一遍 → 复制粘贴到 Excel → 按人手工求和。

问题不在慢,在于口径:

问题表现后果
主任务和子任务工时重复计一个父任务下挂 5 个子任务,父任务的"最初预计"本身是子任务的汇总,又被加了一次有人月工时统计到 60+ 人天,明显不可能
父任务没有 parent 字段禅道导出表里只有「任务名称」用 > 前缀表示层级,没有父任务 ID想用代码自动剔除父任务,无从下手
BUG 归属算错人「指派给」和「解决者」不是同一个人别人提的、别人修的 BUG 算到某个人头上
0 工时的展示兜底值当了分母工时为 0 时为了表格好看写成 0.01BUG 率被放大成 200、100 这种离谱数字

所以这件事的价值不是"省了半小时",而是把考核口径固化成代码,每月跑同一套规则,不再靠人肉对齐。


二、成果一:一条流水线,全量导出 Task / Bug

2.1 运行视图

Job 路径:Jenkins / 统计_月度开发bug / bug_count / #21,执行结果 Finished: SUCCESS,控制台输出如下:

=== 导出 Task (全量) ===
  访问全量任务列表页,重置筛选会话
  访问导出 iframe 页,初始化导出上下文
  POST 完整表单导出
  ✅ Task导出成功: task_638.xlsx (34.96 KB)

=== 导出 Bug (全量) ===
  访问全量 Bug 列表页,重置筛选会话
  访问导出 iframe 页,初始化导出上下文
  POST 完整表单导出
  ✅ Bug导出成功: bug_638.xlsx (149.79 KB)

=== 统计分析 ===
Task 行数: 79 | Bug 行数: 8
Task 树解析: 总行数 79,父任务 14 个,叶子任务 65 行
工时口径校验: 整数合计 268.0 → 仅叶子合计 162.5 (已剔除 105.5)
✅ 汇总结果已生成: result_638.xlsx

全部流程执行成功
Archiving artifacts
Finished: SUCCESS

关键点:每一步都打印进度。批处理脚本最怕的不是报错,是"没报错但结果是空的"。所以每个阶段都输出"我在做什么 + 拿到了什么",构建日志本身就是运行证据。

2.2 为什么不是"一个 POST 就导完"

这是第一个大坑。禅道类系统的导出接口,直接 POST 会返回一个 几 KB 的 xlsx 空模板,HTTP 200、响应头 Content-Type 正常、文件开头还是标准的 PK 字节(xlsx 就是 zip)。也就是说:看起来完全成功,实际一条数据都没有。

浏览器里能导出成功,是因为它走了三步,服务端靠 Session 里的筛选上下文来决定"导哪些行":

① GET 全量列表页      → 把当前筛选条件种进服务端 Session
② GET 导出 iframe 页   → 初始化导出上下文、拿到导出表单的字段注册
③ POST 同一个导出地址  → 带上完整的 exportFields[] 才真正吐数据

脚本必须原样复刻这三步,并且:

  • 列表页地址要区分对象:Task 用 project-task-{pid}-all.html,Bug 用 project-bug-{pid}.html;
  • exportFields[] 整段照抄抓包结果,一个字段都不能删——后端会校验字段列表与页面注册的是否一致,不一致同样静默返回空表;
  • 登录后还要手动补业务 Cookie(lastProject、preProjectID 设成项目 ID),否则导出接口会被拦。

2.3 登录:两层 md5

# 1) 取随机数(必须带 XHR 头,否则返回 HTML)
r = session.get(f"{BASE}/user-refreshRandom.html",
                headers={"X-Requested-With": "XMLHttpRequest"})
verify_rand = r.text.strip()

# 2) 密码 = md5(md5(明文) + verifyRand),单层 md5 会被直接拒绝
pwd = hashlib.md5((hashlib.md5(PASSWORD.encode()).hexdigest()
                   + verify_rand).encode()).hexdigest()

session.post(f"{BASE}/user-login.html", data={
    "account": USER, "password": pwd,
    "verifyRand": verify_rand, "keepLogin": 1,
})

2.4 导出三步的代码骨架

def export(session, list_url, export_url, fields, out_path):
    session.get(list_url)                      # ① 种筛选上下文
    session.get(export_url.replace("export", "export-iframe", 1))  # ② 初始化
    resp = session.post(export_url, data=[("exportFields[]", f) for f in fields])  # ③

    # 校验:xlsx 必须是 PK 开头;体积过小先怀疑空表
    if not resp.content.startswith(b"PK"):
        sys.exit("导出失败:响应不是 xlsx")
    if len(resp.content) < 8 * 1024:
        print("⚠ 文件偏小,可能是空表,请核对行数")

    with open(out_path, "wb") as f:
        f.write(resp.content)

PK 头能挡掉"返回登录页 HTML"这种情况,但挡不掉空模板——空模板也是合法 xlsx。所以真正的兜底是导出后立刻读一遍行数,把"Success 但 0 行"变成显式失败。


三、成果二:自动统计出考核汇总表

3.1 从 79 行到 65 个叶子任务

Task 导出 79 行,其中父任务 14 个,真正该计工时的叶子任务 65 行。工时的口径校验是这一步的核心:

整数合计 268.0  →  仅叶子合计 162.5  (已剔除 105.5)

268 是"整表 最初预计 直接求和",162.5 是"只算叶子任务"。差的 105.5 就是父子任务重复计入的部分。这个差值必须打印出来——只看到"数字变小了"并不能说明扣对了,看到"扣了 105.5、剩余 162.5"才知道扣的是什么。

3.2 最终考核表

result_638.xlsx 归档为 月度开发考核汇总.xlsx,表结构如下(姓名已脱敏):

开发人员月开发人天开发时间总BUG数有效负责BUG数非常严重严重一般月BUG率月评分
开发 A0.010.0000000.010
开发 B1.5711.0110010.644
开发 C2.3616.5000000.010
开发 D3.7926.5000000.010
开发 E8.2157.5440030.377
开发 F1.8613.0000000.010
开发 G3.4324.0110010.298
开发 H2.5718.0110010.397
开发 I4.0028.0000000.010
开发 J0.010.0330020.010
开发 K0.010.0110010.010

3.3 字段口径(这一栏比代码更重要)

字段来源口径说明
开发时间Task 表「最初预计」仅叶子任务求和,单位小时
月开发人天开发时间 / 7团队按 7 小时 = 1 人天折算(28.0 → 4.00 人天)
总BUG数Bug 表「指派给」归集见全量 BUG 数,用于发现"分配了但没处理"的情况
有效负责BUG数Bug 表「解决者」+ 解决方案过滤只算本人真正修掉的;重复/不予解决/设计如此不计
非常严重 / 严重 / 一般Bug 表「严重程度」三个等级分列,方便回溯
月BUG率加权 BUG 数 ÷ 真实人天权重:非常严重 2、严重 1.5、一般 1
月评分按月BUG率分档换算0 率 → 10 分,0.29 → 8 分,0.37~0.39 → 7 分,0.64 → 4 分,分档表作为配置常量维护

拿"开发 E"验算一遍:57.5 小时 = 8.21 人天,该行 4 个 BUG 里落进严重程度分列的是 3 个"一般"(另外 1 个严重程度为空/未分级,不进加权),加权和 = 3 × 1 = 3,3 / 8.21 = 0.365 → 0.37,与表里一致。

这一步验算顺带暴露了两个必须显式处理的点:

  • 严重程度可能为空。"总BUG数 4 = 非常严重 0 + 严重 0 + 一般 3"并不成立,差的那 1 个是空值。归集时要么归到"未分级"单列,要么按最低权重计入,但必须选一个并写进口径,不能默默丢掉——丢掉的话分子就悄悄变小,比率变好看。
  • 分子口径要能一键切换。加权(2 / 1.5 / 1)和简单计数(有效 BUG 数)是两套考核逻辑,混在公式里就再也说不清是谁的责任:
WEIGHT = {"非常严重": 2.0, "严重": 1.5, "一般": 1.0, "未分级": 1.0}

def weighted_bugs(counts: dict) -> float:
    """counts: {严重程度: 条数},未分级按最低权重计入"""
    return sum(WEIGHT.get(level, 1.0) * cnt for level, cnt in counts.items())

BUG_RATE_MODE = "weighted"   # 只改这一处:weighted / simple

考核数据一旦不能手算复现,被质疑时你就输了。所以公式必须能从原始导出表一步步推出来——这也是下面"中间量打印"存在的理由。


四、三个必须写对的技术细节

4.1 没有 parent 列,怎么重建任务树

禅道的层级只体现在「任务名称」的 > 前缀和行序上:> 子任务、>> 孙任务。用栈按原始行序扫一遍即可:

def count_leading_gt(name):
    """'>> 孙任务' → 2,'子任务' → 0;先 lstrip 去掉缩进,再数开头连续 '>'"""
    s = str(name).lstrip()
    n = 0
    for ch in s:
        if ch == ">":
            n += 1
        else:
            break
    return n

df["_level"] = df[名称列].apply(count_leading_gt)

stack, child_map = [], {}
for _, row in df.iterrows():              # 必须保持导出原始行序,禁止先排序
    tid, lvl = row[编号列], row["_level"]
    while stack and stack[-1][0] >= lvl:  # 弹到比自己层级低的位置
        stack.pop()
    if stack:
        child_map.setdefault(stack[-1][1], []).append(tid)
    stack.append((lvl, tid))

df_leaf = df[~df[编号列].isin(child_map.keys())]   # 只剩叶子

两个反直觉的点:

  1. 不能按"任务类型 = 子任务"过滤。很多导出没有这个字段,或者取值对不上,一过滤就是 0 行。> 前缀 + 行序才是稳定判据。
  2. 不能只剔除"直接父任务"。中间层任务自己也挂着下级,它的工时同样要剔除。所以判据是"是否有任何子节点",不是"层级是否大于 0"。

4.2 展示兜底值绝对不能当分母

这是最阴的一个坑。为了让 0 工时的人在表里不显示刺眼的 0,展示列做了兜底:

def calc_man_day(hours):            # 展示列:0 显示成 0.01
    v = round(float(hours) / 7, 2)
    return 0.01 if v == 0 else v

def calc_bug_rate(hours, bugs):     # 计算列:分母用真实值,绝不兜底
    real_day = float(hours) / 7
    if real_day <= 0:
        return 0.0                  # 0 投入不参与比率,显式返回 0
    return round(bugs / real_day, 2)

如果比率公式写成 bugs / calc_man_day(hours),开发 J 的 3 个 BUG 会被 0.01 放大成 200,开发 K 的 1 个放大成 100。表面看像"合并逻辑错了",实际是兜底值污染了分母。改成真实人天后异常消失,其他人的数值完全不变(57.5 → 8.21 → 0.37 依旧),据此可以确认只影响 0 工时分支。

表里开发 J、K 那两行 0.01 人天 / 0.0 工时 / 0.0 率 / 10 分 就是修复后的样子——展示上仍是 0.01,计算上却是 0。

4.3 列名不要猜

禅道导出表头是中文,且不同版本不统一:编号 / 任务名称 / 由谁完成 / 最初预计 与 id / 名称 / 指派给 / 负责人 经常对不上,指派给 里甚至存的是账号而非中文名。所以脚本第一行永远是:

print(df.columns.tolist())      # 先看清真实列名,再做统计

按英文字段名取列会静默 KeyError 或全 0;按中文名硬编码则在升级后崩。建议做成"候选列名映射 + 命中告警":

COLS = {"dev": ["由谁完成", "指派给", "负责人"], "hours": ["最初预计", "预计工时"]}

def pick(df, key):
    for c in COLS[key]:
        if c in df.columns:
            return c
    sys.exit(f"列缺失:{key},实际列={df.columns.tolist()}")

五、Jenkins 侧怎么落地

原来这套脚本是带 GUI 的桌面程序,上 Jenkins 有四个改动:

改造点做法
去 GUI命令行参数 --project-id 638,或读环境变量 PROJECT_ID
防并发覆盖所有产物文件名带项目号:task_638.xlsx、bug_638.xlsx、result_638.xlsx
产物交付归档表达式填 月度开发考核汇总.xlsx(先把 result 复制成语义化名字)
失败可见任何异常 sys.exit(1),让构建变红而不是"Success 但没结果"

一条命令跑完整条链:

python export_and_report.py --project-id 638 \
        --task-out task_638.xlsx --bug-out bug_638.xlsx --result result_638.xlsx

流水线本身用参数化构建(项目 ID、月份),每月初点一次「Build with Parameters」。


六、踩坑清单(建议收藏)

#坑现象正解
1只 POST 导出地址几 KB 空表,PK 头正常,无报错先 GET 列表页种 Session,再 GET iframe,最后 POST
2精简 exportFields[]同样静默返回空表整段照抄抓包
3密码单层 md5登录直接失败md5(md5(pwd) + verifyRand)
4不补业务 Cookie导出接口 403 / 返回登录页补 lastProject、preProjectID
5按英文字段名取列KeyError 或统计全 0先打印 df.columns,做候选映射
6按"任务类型"过滤父任务叶子任务数为 0用 > 前缀 + 行序重建树
7只剔直接父任务工时仍偏高按"是否有任何子节点"判断
8先排序再重建树父子关系错乱严格用导出原始行序
9兜底值当分母BUG 率 200、100展示列与计算分母分离
10读 xlsx 报 File is not a zip file本地能读、服务器报错先 zipfile.is_zipfile 区分真损坏 / 被 WPS 占用,别急着改逻辑
11Jenkins agent 环境不同本地跑通、构建失败首次在 agent 工作目录手跑一遍再上构建

七、整体思路

先把全貌放出来,再谈为什么这么设计。

通过

通过

失败

失败

Jenkins 参数化构建
PROJECT_ID / 月份

登录禅道
verifyRand + 双层 md5

GET task 列表页
种筛选上下文

GET bug 列表页
种筛选上下文

GET iframe + POST 导出
task_638.xlsx

GET iframe + POST 导出
bug_638.xlsx

校验
PK 头 + 行数 > 0

校验
PK 头 + 行数 > 0

任务树重建
79 行 → 65 叶子

BUG 归集
解决者 + 严重程度

工时口径校验
268.0 → 162.5

按人合并
人天 = 工时/7

比率计算
加权BUG / 真实人天

分档评分

result_638.xlsx
归档 月度开发考核汇总.xlsx

sys.exit(1)
构建标红

整个设计其实就是四层,任何一层错了,结果都不可信:

第一层:拿到真数据。 这一层的核心不是"会调用接口",而是"能判断接口给的是不是真数据"。所以脚本里所有"看起来成功"的地方都加了一道硬校验:PK 头挡掉 HTML 登录页,体积阈值 + 行数挡掉空模板。批处理的静默失败比报错危险十倍,因为它会被当成结果直接拿去打分。

第二层:把口径写死。 工时只算叶子任务、BUG 只算自己修的、人天按 7 小时折算——这些规则平时都在人脑子里,一旦换个人统计就漂。写进代码的意义是可复算:任何人拿到 task_638.xlsx 和 bug_638.xlsx,按文档里的公式能算出同样的 0.37 和 7 分。考核数据必须可复算,这是这套东西能拿去用的前提。

第三层:把中间量暴露出来。 日志里那几行"79 行 / 14 个父任务 / 65 个叶子 / 268.0 → 162.5(剔除 105.5)“是刻意打印的,不是调试残留。它们是审计线索——当有人质疑"我这个月明明不止 57.5 小时”,你可以直接指到"你的父任务工时被剔除了"这一步,而不是重新跑一遍 Excel 猜。

第四层:把交付物固定。 文件名带项目号防覆盖,归档名字带业务语义(月度开发考核汇总.xlsx),失败用 exit(1) 标红。这样下游(发邮件、贴报表、做趋势)都只需要认一个路径。

如果要把这套思路搬到别的系统(Jira、TAPD、自研平台),顺序不要变,但取舍会变:

  1. 先抓包确认"导出是否依赖服务端上下文"。不依赖就一个 POST 搞定;依赖就必须复刻列表页 → iframe → POST 三步,这是最容易翻车的地方。
  2. 先打印表头和行数,再写任何统计代码。列名和层级表示方式(> 前缀、缩进、parent 字段)决定后面全部逻辑。
  3. 先跑一次"整表合计 vs 口径合计"的对比,差值解释清楚再往下做。差值说不清的统计,不要交付。
  4. 凡是有兜底、有默认值的地方,问一句"这个值会不会被当分母"。这一问能省掉一次返工。
  5. 最后才接 Jenkins。本地命令行能稳定产出、日志能自证,再谈调度和归档;否则你会在 CI 里排查一个其实本地就有的问题。

最后再来看看jenkins的效果图:
各版本对应人员考核表

月度合并考核表

这是开发人员的哈,测试人员思路相同,抓取bug数据,完全可以一键自动化考核。只有有考核逻辑,都不是问题,框架搭出来就行。

一句话总结:自动化的难点从来不是"跑起来",而是"跑出来的数敢不敢签字"。 上面所有的校验、差值打印、口径开关,都是为了让那张 11 行的表可以被质疑、被复算、被追溯。


环境:Python 3 / requests / pandas / openpyxl,Jenkins 2.x(Pipeline + 参数化构建)。文中人员姓名已脱敏。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/u011004306/article/details/170185841

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--