Ppdd.bot
中EN

让 CI 读 PR 描述:验证、风险、回滚缺一不可

用一个小脚本拦下缺段和只写占位符的描述,并说明为什么推送前要先在本地跑一遍。

9 月 17 日,我在公司仓库里加了一个 CI 检查。它不跑测试,也不看代码,只读 PR 描述:没有「验证」「风险」「回滚」这三段,就标红。

同一天的提交说明里,最后一行是这么写的:

暂未进 required checks(Actions 账单停跑)

也就是说,这个检查上线那天,CI 正停着,它在 GitHub 上根本跑不起来。

为什么要读 PR 描述

agent 提 PR 很勤快,描述也写得很长,改了哪些文件、加了哪些函数,一条条列得清清楚楚。可我审 PR 的时候,最想知道的是另外三件事:你是怎么确认它能用的,它可能弄坏什么,弄坏了怎么撤回来。

这三件事,「变更」那一节里一件都没有。不写,我就得自己去翻代码、自己去跑一遍,那 PR 描述写那么长,对我来说没省下什么。

规则文件里当然可以写「PR 描述必须有验证、风险、回滚三段」,可规则靠的是 agent 记得,忘一次就漏一次;每段只填一个「无」,字面上也算照做了。这种事交给脚本最合适:它不会忘,也不会看在描述很长的份上放过去。

脚本只管两件事

一是三段都得在。标题里出现「验证」「测试」「Validation」中的任意一个就算验证段,风险和回滚同理;一个标题写成「风险与回滚」,两段都算。

二是不能只写占位符。「无」「N/A」「TODO」「同上」这种,删掉标点以后如果整段只剩它们,就算没写。模板里那些 <!-- 写怎么验证的 --> 的填写提示也不算内容,不然 agent 原样留着模板,检查也能过。

写不出内容的时候怎么办?我在规则文件里写的是:别删小节,把「为什么不适用」写进去;没做真机验证,就在验证段里如实写没做。

比如这个站的一个 PR,改的全是文案,风险段只写了一句:

低。只改文字,删了一个可选导语

这句话很短,但它是一个判断,读的人看完知道风险在哪、有多大。「无」就不是判断。

推送前,先在本地跑一遍

回到开头那件事。CI 停跑,后来查清楚了不是欠费,是那个月的免费额度用完了,而 Actions 的预算设的是 0 美元,额度一用完所有任务直接被拦。报错写的是付款失败,看着像欠费,其实一分钱都没欠。

10 月初额度重置,CI 恢复了。10 月 12 日,额度又用完了,又停了几天,直到我把预算从 0 调到 20 美元。

所以那条规则从第一天起就是这么写的:提 PR 前,本地先跑一遍。CI 停着的那段时间,本地这一遍是唯一一道闸。到现在这个检查也还没设成合并前的必过项,红了照样合得进去。本地这一遍,仍然是第一道闸。

就算 CI 一直好好的,本地先跑也划算。agent 在执行 gh pr create 之前跑一下,马上就知道缺了什么,当场补上;推上去再等 CI 变红,就是一来一回好几分钟。

那次提交里还有两个细节值得一说。PR 描述是谁都能写的文本,在 GitHub Actions 里别把它直接拼进 shell 命令,要通过环境变量传进脚本,不然有人在描述里写一段命令就能注入。另外,这种以后要设成必过项的检查,别用 workflow 级的 paths 过滤,改动没命中过滤条件的 PR,检查不会运行,就会一直挂着等待,永远合不进去。

可以直接抄的版本

脚本只用标准库,放进仓库的 scripts/pr_body_lint.py:

#!/usr/bin/env python3
"""PR 描述必须有 验证 / 风险 / 回滚 三段,且不能只写占位符。"""
import os
import re
import sys

SECTIONS = {
    "验证": ("validation", "验证", "测试"),
    "风险": ("risk", "风险"),
    "回滚": ("rollback", "回滚", "回退"),
}
PLACEHOLDERS = {"n/a", "na", "none", "todo", "tbd", "无", "暂无", "略", "同上", "待补"}
HEADING = re.compile(r"^#{1,6}\s+(.+?)\s*$")


def split_sections(body):
    body = re.sub(r"<!--.*?-->", "", body, flags=re.S)  # 模板里的填写提示不算内容
    sections, in_fence = [], False
    for line in body.splitlines():
        if line.lstrip().startswith("```"):
            in_fence = not in_fence
        match = None if in_fence else HEADING.match(line)
        if match:
            sections.append((match.group(1).lower(), []))
        elif sections:
            sections[-1][1].append(line)
    return sections


def is_placeholder(lines):
    words = [re.sub(r"[\s\-*>`.。::]+", "", line).lower() for line in lines]
    return all(word in PLACEHOLDERS for word in words if word)


def main():
    if len(sys.argv) > 1:
        body = open(sys.argv[1], encoding="utf-8").read()
    else:
        body = os.environ.get("PR_BODY", "")
    sections = split_sections(body)
    errors = []
    for name, aliases in SECTIONS.items():
        found = [lines for title, lines in sections if any(a in title for a in aliases)]
        if not found:
            errors.append(f"缺少「{name}」一节")
        elif all(is_placeholder(lines) for lines in found):
            errors.append(f"「{name}」只写了占位符;不适用就写清为什么不适用")
    for error in errors:
        print(error)
    return 1 if errors else 0


if __name__ == "__main__":
    sys.exit(main())

CI 里这样接,描述走环境变量,编辑描述也会重新检查:

name: pr-body
on:
  pull_request:
    types: [opened, edited, synchronize, reopened]
jobs:
  pr-body:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: python3 scripts/pr_body_lint.py
        env:
          PR_BODY: ${{ github.event.pull_request.body }}

再往规则文件里加一句,让 agent 在本地先跑:

- PR 描述必须有「验证」「风险」「回滚」三段;写不出内容就写清为什么不适用,
  没做的验证如实写没做
- 提 PR 前先跑 `python3 scripts/pr_body_lint.py body.md`,通过了再 `gh pr create`

脚本能管的只是格式。验证段里写了「测试全过」,它分不出是真跑过还是随口一说,这得靠别的规矩,比如收尾汇报里要求测试失败就贴输出。

那个上线当天跑不起来的检查,现在 GitHub 上的 PR 都会跑,只是红了还拦不住合并。现在真正能拦在合并前面的,还是本地那一遍。