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

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

一条 Jenkins 流水线,从登录禅道、导出全量 Task 与 Bug,到生成可直接打分的月度开发考核汇总表,全程零手工。

本文把两个可复用的成果拆开讲:成果一:全量导出通道 ,成果二:考核指标统计引擎,最后给出整体设计思路与几个必须踩过的坑。


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

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

问题不在慢,在于口径:

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

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


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

2.1 运行视图

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

text 复制代码
=== 导出 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 里的筛选上下文来决定"导哪些行":

text 复制代码
① 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

python 复制代码
# 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 导出三步的代码骨架

python 复制代码
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 行。工时的口径校验是这一步的核心:

text 复制代码
整数合计 268.0  →  仅叶子合计 162.5  (已剔除 105.5)

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

3.2 最终考核表

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

开发人员 月开发人天 开发时间 总BUG数 有效负责BUG数 非常严重 严重 一般 月BUG率 月评分
开发 A 0.01 0.0 0 0 0 0 0 0.0 10
开发 B 1.57 11.0 1 1 0 0 1 0.64 4
开发 C 2.36 16.5 0 0 0 0 0 0.0 10
开发 D 3.79 26.5 0 0 0 0 0 0.0 10
开发 E 8.21 57.5 4 4 0 0 3 0.37 7
开发 F 1.86 13.0 0 0 0 0 0 0.0 10
开发 G 3.43 24.0 1 1 0 0 1 0.29 8
开发 H 2.57 18.0 1 1 0 0 1 0.39 7
开发 I 4.00 28.0 0 0 0 0 0 0.0 10
开发 J 0.01 0.0 3 3 0 0 2 0.0 10
开发 K 0.01 0.0 1 1 0 0 1 0.0 10

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 数)是两套考核逻辑,混在公式里就再也说不清是谁的责任:
python 复制代码
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 列,怎么重建任务树

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

python 复制代码
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,展示列做了兜底:

python 复制代码
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 / 名称 / 指派给 / 负责人 经常对不上,指派给 里甚至存的是账号而非中文名。所以脚本第一行永远是:

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

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

python 复制代码
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 但没结果"

一条命令跑完整条链:

bash 复制代码
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 占用,别急着改逻辑
11 Jenkins agent 环境不同 本地跑通、构建失败 首次在 agent 工作目录手跑一遍再上构建

七、整体思路

先把全貌放出来,再谈为什么这么设计。
#mermaid-svg-nnqWbBP2raUmgseg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-nnqWbBP2raUmgseg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-nnqWbBP2raUmgseg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-nnqWbBP2raUmgseg .error-icon{fill:#552222;}#mermaid-svg-nnqWbBP2raUmgseg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-nnqWbBP2raUmgseg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-nnqWbBP2raUmgseg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-nnqWbBP2raUmgseg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-nnqWbBP2raUmgseg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-nnqWbBP2raUmgseg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-nnqWbBP2raUmgseg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-nnqWbBP2raUmgseg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-nnqWbBP2raUmgseg .marker.cross{stroke:#333333;}#mermaid-svg-nnqWbBP2raUmgseg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-nnqWbBP2raUmgseg p{margin:0;}#mermaid-svg-nnqWbBP2raUmgseg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-nnqWbBP2raUmgseg .cluster-label text{fill:#333;}#mermaid-svg-nnqWbBP2raUmgseg .cluster-label span{color:#333;}#mermaid-svg-nnqWbBP2raUmgseg .cluster-label span p{background-color:transparent;}#mermaid-svg-nnqWbBP2raUmgseg .label text,#mermaid-svg-nnqWbBP2raUmgseg span{fill:#333;color:#333;}#mermaid-svg-nnqWbBP2raUmgseg .node rect,#mermaid-svg-nnqWbBP2raUmgseg .node circle,#mermaid-svg-nnqWbBP2raUmgseg .node ellipse,#mermaid-svg-nnqWbBP2raUmgseg .node polygon,#mermaid-svg-nnqWbBP2raUmgseg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-nnqWbBP2raUmgseg .rough-node .label text,#mermaid-svg-nnqWbBP2raUmgseg .node .label text,#mermaid-svg-nnqWbBP2raUmgseg .image-shape .label,#mermaid-svg-nnqWbBP2raUmgseg .icon-shape .label{text-anchor:middle;}#mermaid-svg-nnqWbBP2raUmgseg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-nnqWbBP2raUmgseg .rough-node .label,#mermaid-svg-nnqWbBP2raUmgseg .node .label,#mermaid-svg-nnqWbBP2raUmgseg .image-shape .label,#mermaid-svg-nnqWbBP2raUmgseg .icon-shape .label{text-align:center;}#mermaid-svg-nnqWbBP2raUmgseg .node.clickable{cursor:pointer;}#mermaid-svg-nnqWbBP2raUmgseg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-nnqWbBP2raUmgseg .arrowheadPath{fill:#333333;}#mermaid-svg-nnqWbBP2raUmgseg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-nnqWbBP2raUmgseg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-nnqWbBP2raUmgseg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nnqWbBP2raUmgseg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-nnqWbBP2raUmgseg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nnqWbBP2raUmgseg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-nnqWbBP2raUmgseg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-nnqWbBP2raUmgseg .cluster text{fill:#333;}#mermaid-svg-nnqWbBP2raUmgseg .cluster span{color:#333;}#mermaid-svg-nnqWbBP2raUmgseg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-nnqWbBP2raUmgseg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-nnqWbBP2raUmgseg rect.text{fill:none;stroke-width:0;}#mermaid-svg-nnqWbBP2raUmgseg .icon-shape,#mermaid-svg-nnqWbBP2raUmgseg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nnqWbBP2raUmgseg .icon-shape p,#mermaid-svg-nnqWbBP2raUmgseg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-nnqWbBP2raUmgseg .icon-shape .label rect,#mermaid-svg-nnqWbBP2raUmgseg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nnqWbBP2raUmgseg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-nnqWbBP2raUmgseg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-nnqWbBP2raUmgseg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 通过
通过
失败
失败
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 + 参数化构建)。文中人员姓名已脱敏。

相关推荐
特立独行的猫A37 分钟前
13MB 单文件、一条命令变服务:把仓颉写的 cjh 内嵌进你的应用,当它的 AI 大脑
java·后端·python
deepseek231 小时前
Agent任务中途断线,怎么避免重复执行?SQLite检查点实测
python·sqlite·任务调度·幂等性·ai agent
寂寞的船1 小时前
【Unity Bug挑战】第10期 SerializeReference 内存泄漏之谜
unity·c#·游戏引擎·bug
逐流人1 小时前
DevOps 入门实战(一):从 CI/CD 概念到 Jenkins 自动构建部署
运维·云原生·云计算·jenkins·devops
2401_868534782 小时前
【网络安全
网络·python
❁满城风絮*13 小时前
Claude Code 学习总结
python·agent·claude
维克兜率天15 小时前
【维克】复合型CTA:趋势+套利的双轮驱动
python·学习方法
伞伞悦读15 小时前
【第76期】两张3行表左连接变5行:主键、validate与未匹配排查完整教程
人工智能·python
用户83562907805116 小时前
使用 Python 在 Excel 中添加和自定义迷你图
后端·python