Agent 提交治理:把 Octoverse 指标变成合并门禁
Octoverse 2026 的提交与推送新高只能说明 agent 在写代码,不能说明代码可合并;把宏观指标折算成 PR 级比率阈值,挂到分支保护的必需状态检查上,agent 提交才从「看得见」变成「拦得住」。
- 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.5 | git diff --numstat | 打 needs-human 标签 |
| 自主推送次数 | 两次人工评审事件之间的 push 数 | ≤ 3 | PR timeline events | 冻结 30 分钟后重跑 |
| 测试增量(Test Delta) | 新增测试行 ÷ 新增生产行 | ≥ 0.6 | 按路径正则分类后统计 | 直接阻断 |
| 回滚率(Revert Rate) | 近 30 天该目录被 revert 的次数 | ≤ 1 | git 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 本地日志 |
| 密钥写进 workflow | token 泄露后门禁形同虚设 | 用 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,口径:仅统计已上线接口,不含扫描与无效路径),可作为容量规划与稳定性判断的现实参考。
- 接口调用总数:138 次;成功返回 136 次,鉴权/参数校验拦截 2 次
- 最活跃接口:随机动漫壁纸(92 次)、Menhera 动漫图片(41 次)、IP 地址查询(4 次)
- 近 7 天累计调用:894 次
常见问题(FAQ)
Octoverse 的提交和推送数字能直接当合并门禁阈值吗?
门禁脚本挂了会不会导致整个仓库无法合并?
agent 会不会针对门禁指标做逆向优化?
参考来源
- Octoverse: Commits, pushes, reach new highs in 2026 as developers build with agents
- Can a local git remote replace GitHub in your agent eval?
- A JSON schema Is not an AI governance layer
- How we detect frontier model misalignment
以上为本文写作时的参考资料,引用观点以原文为准。