用户登录

技术文章

外部链接将在新标签页打开。

Agent 提交治理:把 Octoverse 指标变成合并门禁

一句话回答

Octoverse 2026 的提交与推送新高只能说明 agent 在写代码,不能说明代码可合并;把宏观指标折算成 PR 级比率阈值,挂到分支保护的必需状态检查上,agent 提交才从「看得见」变成「拦得住」。

核心要点 TL;DR
  • Octoverse 是平台级聚合口径,门禁要的是仓库级 PR 级可复现指标,两者不能直接划等号。
  • 六个可门禁指标:提交粒度、变更率、自主推送次数、测试增量、回滚率、描述可核验性。
  • 门禁脚本必须自带超时、重试和降级开关,否则它自己就是全仓库合并的单点故障。

结论先行:Octoverse 2026 的提交与推送曲线不该只进周报。把它折算成 PR 级比率阈值,挂到分支保护(Branch Protection)的必需状态检查(Required Status Checks)上,agent 提交才从「看得见」变成「拦得住」。

GitHub Universe 2026 上,官方把 GitHub 定位成端到端的软件交付系统(GitHub Blog);同一周的 Octoverse 报告说提交和推送创下新高,驱动力来自开发者用 agent 写代码(Octoverse 2026)。两件事叠在一起,暴露了一个空档:平台在往上收,产出在往上走,中间那层「什么代码能合进主干」的规则,仍然要每个团队自己写。

我不打算复述 Octoverse 里的绝对数值。原因很直接——那些数字是平台级年度聚合,口径随报告调整,你拿它当自己仓库的阈值,第一周就会被误判打脸。真正能落地的是另一件事:把宏观指标翻译成微观比率,让它在每个 PR 上跑一遍,跑不过就不给合并按钮。

为什么 Octoverse 指标不能直接当门禁用?#

Octoverse 回答的是「平台上发生了什么」,门禁回答的是「这一个 PR 该不该合」。两个问题的时间尺度、统计单元和容错成本完全不同。前者错了顶多周报难看,后者错了要么放行 bug,要么堵死整条流水线。

对比表:宏观指标与门禁指标的口径差异

对比项Octoverse 类宏观指标合并门禁(Merge Gate)指标
统计单元全平台仓库与账号单个 PR、单条提交(Commit)
更新频率年度报告每次 push 事件触发
可比性跨年可比性弱,口径会调必须与仓库自身历史分位数(Percentile)比
失败代价结论偏差阻塞合并或放行缺陷
可执行动作无阻断、降级、转人工

Stack Overflow 上有一篇说得挺准:JSON schema 不是 AI 治理层,治理发生在真正拦住 agent 的那层代码里(Stack Overflow Blog)。门禁就是那层代码。你写一百页 agent 使用规范,不如一条 exit code 1 有用。

还有一个容易被忽略的点:agent 的推送(Push)行为强依赖远端环境。微软的工程博客做过一次对照实验,把 agent 评测环境的远端从 GitHub 换成本地裸仓,其余不变,agent 自主推送的频率低了 11 个百分点(Microsoft DevBlogs)。这意味着只采集 GitHub 侧事件的门禁,会系统性低估 agent 在本地反复试错的次数。

哪些指标可以进合并门禁?#

我的筛选标准只有三条:能从 Git 对象或 GitHub API 稳定算出来、有明确方向性(越大越糟或越小越糟)、失败时能对应一个具体动作。筛完剩下六个。

对比表:六个可门禁指标与起始阈值

指标计算方式起始阈值数据来源触发动作
提交粒度(Commit Size P50)PR 内单条提交变更行数的中位数≤ 120 行GET /repos/{o}/{r}/commits/{sha} 的 stats要求 squash 或拆分
变更率(Churn Ratio)(新增+删除) ÷ 净新增生产行≤ 2.5git diff --numstat打 needs-human 标签
自主推送次数两次人工评审事件之间的 push 数≤ 3PR timeline events冻结 30 分钟后重跑
测试增量(Test Delta)新增测试行 ÷ 新增生产行≥ 0.6按路径正则分类后统计直接阻断
回滚率(Revert Rate)近 30 天该目录被 revert 的次数≤ 1git log --grep="This reverts"转指定评审人
描述可核验性是否含 issue 号、复现命令、影响面三段三项全有PR body 正则阻断并回评论

阈值那一列是起始值,不是真理。校准方法是:拉近 90 天已合并且 30 天内没被 revert 的 PR,算每个指标的 P50 和 P90,阈值取 P90 再乘 0.9。第一次上线建议只开「测试增量」和「描述可核验性」两项,误判率最低,团队抵触最小。

变更率这一项我要多说一句。agent 生成的代码经常出现「写了又删」的痕迹——先加 200 行,再在同一个 PR 里删掉 150 行换成另一种写法。这种 PR 的净 diff 很干净,但 churn 比能到 6 以上。它不是 bug,是信号:说明 agent 在收敛过程中没有人类介入判断。把它拦下来看两眼,通常能省掉后面两次返工。

怎么把门禁接进 GitHub Actions?

分三步:验来源、算指标、挂必需检查。第一步常被跳过,但它决定了后面所有数据可不可信——你得先确认这次提交是哪个 runner、哪个出口 IP 发出来的。

可执行脚本:curl 校验 runner 出口 IP 归属

# 运行环境:本地 bash 5.x / curl 8.x,或 GitHub Actions ubuntu-24.04 runner
# 示例仅用于逻辑验证,生产环境需补充日志、监控与降级
set -uo pipefail

BODY=$(mktemp)
CODE=$(curl -sS \
  --max-time 5 \
  --retry 2 \
  --retry-delay 1 \
  --retry-connrefused \
  -H "Accept: application/json" \
  -o "$BODY" \
  -w '%{http_code}' \
  "https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8") || {
    echo "curl transport error, gate degrade to warn"; exit 0
  }

if [ "$CODE" != "200" ]; then
  echo "upstream http=$CODE, gate degrade to warn"; exit 0
fi

# 边界保护:响应体不是合法 JSON 时不能直接 grep,先过一遍解析器
if ! python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$BODY" 2>/dev/null; then
  echo "non-json body, gate degrade to warn"; exit 0
fi

cat "$BODY"

参数逐个说:--max-time 5 是整次请求的总超时,含连接和读;--retry 2 配 --retry-delay 1 处理瞬时抖动;--retry-connrefused 让连接被拒也进重试,不加重试的话 DNS 刚生效那几秒会直接失败;-w '%{http_code}' 把状态码单独打出来,避免把 5xx 的错误页当数据解析。apikey 是查询密钥,ip 是待查地址,8.8.8.8 只是演示值,实际要填 runner 的出口地址。

返回体形如(字段名以 GET 示例接口 文档和一次手工调用的实际响应为准,我这里标「待验证」,不替你猜字段):

{
  "code": 200,
  "data": {
    "ip": "8.8.8.8",
    "country": "United States",
    "isp": "Google LLC"
  }
}

拿到归属之后,对照你自己的 ASN 白名单判断。命中就继续,不命中不要直接 fail——先记一条 unexpected-egress 事件,累积三次再拦。新加的 self-hosted runner 第一次上线经常忘记加白名单,直接拦会误伤。

第二步是真正的门禁脚本。下面这段跑在 PR 上下文里,读 GitHub REST API,算指标,用退出码(Exit Code)表达结论。

可执行脚本:agent 提交门禁判定(Python 3.12)

# -*- coding: utf-8 -*-
# 运行环境:Python 3.12+,requests 2.32+,urllib3 2.x
# 示例仅用于逻辑验证,生产环境需补充日志、监控与降级
import os, re, sys, json, time, statistics
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

API = "https://api.github.com"
TOKEN = os.environ["GITHUB_TOKEN"]
REPO = os.environ["GITHUB_REPOSITORY"]
PR = int(os.environ["PR_NUMBER"])

# 阈值:用仓库近 90 天已合并 PR 的 P90 校准后替换,这里是起始值
TH = {
    "commit_p50_lines": 120,
    "churn_ratio": 2.5,
    "autonomous_push": 3,
    "test_delta": 0.6,
    "max_files": 800,      # 文件数超限直接跳过细算,避免打爆 API
    "max_commits": 30,     # 只对最近 30 条提交算中位数
}

TEST_RE = re.compile(r"(^|/)(tests?|__tests__)/|(_test\.|\.test\.|_spec\.)")

def build_session():
    s = requests.Session()
    retry = Retry(
        total=3, backoff_factor=0.5, status_forcelist=(429, 500, 502, 503, 504),
        allowed_methods=frozenset(["GET"]), raise_on_status=False,
    )
    s.mount("https://", HTTPAdapter(max_retries=retry, pool_maxsize=8))
    s.headers.update({
        "Authorization": f"Bearer {TOKEN}",
        "Accept": "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
    })
    return s

S = build_session()

def api_get(path, **params):
    # 连接 3.05s / 读取 15s:拆开是为了区分网络不可达和上游慢
    r = S.get(f"{API}{path}", params=params, timeout=(3.05, 15))
    if r.status_code == 403 and r.headers.get("X-RateLimit-Remaining") == "0":
        raise RuntimeError(f"rate limited, reset={r.headers.get('X-RateLimit-Reset')}")
    if r.status_code >= 400:
        raise RuntimeError(f"GET {path} -> {r.status_code}")
    return r.json()

def compute():
    files = api_get(f"/repos/{REPO}/pulls/{PR}/files", per_page=100)
    if len(files) >= TH["max_files"]:
        return {"skipped": True, "reason": "too_many_files", "files": len(files)}

    add_prod = add_test = del_all = add_all = 0
    for f in files:
        a, d = f.get("additions", 0), f.get("deletions", 0)
        add_all += a; del_all += d
        if TEST_RE.search(f["filename"]):
            add_test += a
        else:
            add_prod += a

    commits = api_get(f"/repos/{REPO}/pulls/{PR}/commits", per_page=TH["max_commits"])
    sizes = []
    for c in commits[:TH["max_commits"]]:
        try:
            detail = api_get(f"/repos/{REPO}/commits/{c['sha']}")
            st = detail.get("stats") or {}
            sizes.append(st.get("additions", 0) + st.get("deletions", 0))
        except RuntimeError:
            continue  # 单条取不到不影响整体判定

    events = api_get(f"/repos/{REPO}/issues/{PR}/events", per_page=100)
    pushes = [e for e in events if e.get("event") == "committed"]
    reviewed = [i for i, e in enumerate(events) if e.get("event") == "reviewed"]
    window = pushes if not reviewed else pushes[-len(pushes):]
    autonomous = max(0, len(window) - len(reviewed))

    pr = api_get(f"/repos/{REPO}/pulls/{PR}")
    body = pr.get("body") or ""
    has_issue = bool(re.search(r"#\d+", body))
    has_repro = bool(re.search(r"```|复现|repro", body, re.I))
    has_impact = bool(re.search(r"影响|impact|blast", body, re.I))

    return {
        "skipped": False,
        "commit_p50_lines": statistics.median(sizes) if sizes else 0,
        "churn_ratio": round((add_all + del_all) / max(add_prod, 1), 2),
        "autonomous_push": autonomous,
        "test_delta": round(add_test / max(add_prod, 1), 2),
        "desc_ok": all([has_issue, has_repro, has_impact]),
        "files": len(files),
    }

def main():
    try:
        m = compute()
    except Exception as e:
        # 门禁自己出错不能阻塞全仓合并,降级为 warn
        print(f"::warning::gate degraded: {e}")
        json.dump({"degraded": True, "error": str(e)}, open("/tmp/gate.json", "w"))
        return 0

    json.dump(m, open("/tmp/gate.json", "w"), ensure_ascii=False)
    print(json.dumps(m, ensure_ascii=False))

    if m.get("skipped"):
        print("::warning::gate skipped, manual review required")
        return 0

    fails = []
    if m["commit_p50_lines"] > TH["commit_p50_lines"]:
        fails.append(f"commit_p50={m['commit_p50_lines']}")
    if m["churn_ratio"] > TH["churn_ratio"]:
        fails.append(f"churn={m['churn_ratio']}")
    if m["autonomous_push"] > TH["autonomous_push"]:
        fails.append(f"autonomous_push={m['autonomous_push']}")
    if m["test_delta"] 

几个设计取舍值得展开。提交粒度要逐条请求 /commits/{sha} 才有行数,30 条就是 30 次调用,所以设了 max_commits=30 上限;超过就只算前 30 条,宁可指标偏保守也不打爆速率限制。文件数超 800 直接跳过细算转人工——这是防止一个自动生成的迁移 PR 把整个 job 拖到超时。

异常处理分两层:Retry 处理 429/5xx 的瞬时故障,compute() 整体的 try/except 处理逻辑异常。后者返回 0 而不是 1,因为门禁脚本自己崩了不代表代码有问题,把它变成阻塞项是最常见的翻车方式。

第三步,把脚本挂成必需检查。

规范模板:GitHub Actions workflow(.github/workflows/agent-gate.yml)

name: agent-merge-gate
on:
  pull_request:
    types: [opened, synchronize, reopened, ready_for_review]
    branches: [main]
permissions:
  contents: read
  pull-requests: read
  checks: write
concurrency:
  group: gate-${{ github.event.pull_request.number }}
  cancel-in-progress: true
jobs:
  gate:
    if: github.event.pull_request.draft == false
    runs-on: ubuntu-24.04
    timeout-minutes: 8
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install --no-cache-dir "requests==2.32.3"
      - name: run gate
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: python3 .github/scripts/agent_gate.py
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: gate-report
          path: /tmp/gate.json

concurrency 那三行是关键:同一个 PR 连续 push 时,旧 job 立刻取消,不然 agent 一次提十个 commit 会排队跑十遍门禁,白烧 runner 分钟数。`timeout-minutes: 8` 是兜底,防止某个分页卡死后挂满 6 小时默认上限。

然后在分支保护里把它设成必需。用 gh CLI 比点网页可复现:

# 运行环境:本地已 gh auth login 的 shell,gh 2.60+
cat > /tmp/prot.json 

strict=true 的含义是分支必须基于最新 main 才能合,配合门禁能挡住「门禁通过后 main 又前进了」的过期绿灯。新版 API 另有 checks 数组形式,带 app_id 更精确,contexts 的兼容性更好——先跑通再考虑迁移。

监控与告警清单#

门禁上线之后,你自己就成了被监控对象。这张表是我踩过坑之后定下来的。

清单:门禁自身的可观测指标

指标建议阈值告警动作
门禁 job P95 耗时> 90 秒通知平台组;> 180 秒自动切 warn-only
脚本执行失败率> 2% / 小时熔断(Circuit Breaker),全量降级为提示
人工推翻比例> 8% / 周回看阈值,重算 P90 基线
GitHub API 限流命中> 5 次 / 小时切 GraphQL 批量查询或加 token 池
必需检查可用率< 99.5% / 天页面告警 + 值班介入
跳过细算的 PR 占比> 15% / 周说明 max_files 阈值定低了,重新评估

「人工推翻比例」这个指标最值钱。它直接反映阈值定得对不对,而且只有人工推翻才统计得到——所以门禁被绕过时必须留痕,在 PR 里留一条 comment 说明原因。团队把这类复盘沉淀下来,比看板上多一个仪表盘有用。我们内部把这类规则文档和踩坑记录归档在 API 文章中心,新人接手时先读这个,比读代码快。

风险清单#

风险清单:门禁落地的五个真实坑

风险影响规避方式
阈值过严开发者绕道直推 main 或关掉检查发布前灰度两周,保留 CODEOWNERS 双人评审通道
门禁脚本自身是单点全仓库无法合并超时自动 pass、失败率熔断、提供 gate-override 手动标签
指标被 agent 逆向优化数字好看,质量不变每月抽 20 个 PR 人工复核,指标只做拦截不进绩效
只采集 GitHub 侧事件漏掉本地裸仓里的自主 push评测环境固定远端,同时采集 runner 本地日志
密钥写进 workflowtoken 泄露后门禁形同虚设用 OIDC 换短时凭证,禁用长期 PAT(个人访问令牌)

第三条我要强调。任何指标一旦和合并速度、绩效挂钩,agent 就会朝指标最省力的方向收敛:commit 拆得碎碎的、测试行数灌满、描述套模板。这不是 agent 坏,是指标设计的锅。门禁是过滤器,不是记分牌。

顺便说一句,这套思路和 Meta 工程团队公开的 JiTTesting 做法是同一个方向——在代码落地前用 LLM 现场生成测试、再由人做最终判断(Meta Engineering)。区别在于他们拦截的是缺陷,我们拦截的是提交行为本身。OpenAI 在 Model Spec 里把「忠于人类意图」列为核心价值之一(OpenAI),落到工程上,忠于意图的第一步就是让人类有机会在合并前看到差异。

总结#

Octoverse 2026 的曲线说明 agent 已经在大规模写代码,但「写得快」和「合得进」是两回事。门禁的本质是把不可执行的宏观叙事,翻译成一条能返回 0 或 1 的判定。六个指标、两段脚本、一份 workflow,加起来不到 300 行,但它把 agent 治理从文档层拉到了代码层。

别指望一次调准。第一版阈值一定会误判,把它当成需要持续校准的线上服务,而不是一次性配置。

适用范围

  • 有多人协作、主干受保护、PR 流程已经跑起来的仓库;
  • 已经在用 coding agent 产出代码,且能识别哪些提交来自 agent(标签、分支前缀、bot 账号任一种);
  • 愿意投入每周 1 到 2 小时做阈值校准和误判复盘的团队。

不适用范围

  • 个人项目或单人仓库:门禁的维护成本高于收益,用 pre-commit hook 就够;
  • 日均 PR 少于 3 个的仓库:样本量不足,P90 基线没有统计意义,阈值只能靠拍脑袋;
  • 以实验性、探索性代码为主的分支:变更率天然高,门禁会持续误伤;
  • 尚未接入任何 CI 的仓库:先把测试跑起来,再谈门禁。

最后一句话给准备动手的人:先只上门禁里的「描述可核验性」一项,跑两周看误判率。这一项不依赖任何历史基线,零成本,但它会立刻让 agent 生成的 PR 描述从一段漂亮散文变成可核验的三段式。后面五个指标,等你有了基线再加。

本站实测数据(近 24 小时)#

以下数据来自 API.PLUS 线上调用日志(统计时间 2026-10-30 15:58,口径:仅统计已上线接口,不含扫描与无效路径),可作为容量规划与稳定性判断的现实参考。

常见问题(FAQ)

Octoverse 的提交和推送数字能直接当合并门禁阈值吗?
不能。Octoverse 是平台级年度聚合口径,含大量非 agent 提交,且逐年调整统计范围。门禁阈值必须来自你自己仓库近 90 天已合并 PR 的 P50/P90 分位数,否则阈值会随平台口径漂移。
门禁脚本挂了会不会导致整个仓库无法合并?
会,这是最常见的翻车点。必需状态检查一旦设置,脚本超时或报错就等于全量阻塞。正确做法是给门禁设 8 分钟超时、失败率超 2% 自动切 warn-only、保留 CODEOWNERS 人工通道。
agent 会不会针对门禁指标做逆向优化?
会。指标一旦与绩效或合并速度挂钩,agent 就会拆小 commit、灌测试行数、把描述写满模板。缓解办法是每月抽 20 个 PR 人工复核,指标只用于拦截不用于考核。

参考来源

  1. Octoverse: Commits, pushes, reach new highs in 2026 as developers build with agents
  2. Can a local git remote replace GitHub in your agent eval?
  3. A JSON schema Is not an AI governance layer
  4. How we detect frontier model misalignment

以上为本文写作时的参考资料,引用观点以原文为准。

作者:沐小悠

本文约 31 分钟读完 · 关键词:Agent 提交治理、合并门禁、GitHub Actions、Octoverse 2026、分支保护

更多文章 GET 示例接口

「」标签下的其他文章