name: AI Review # 对抗式 PR 审查:先一遍找出候选缺陷,再对每一条派一个反驳者去证伪它,只有 # 活下来的才会发到 PR 上。这个不对称是刻意的——AI 审查员死于误报远早于死于漏 # 报,所以反驳阶段默认站在"这是误报"一边,"什么都没发现"是可接受的结果。 # # 引擎不在本仓库,在 vertai-tech/ai-review-engine。本文件只负责把三样东西摆到 # 位再调它:被审查的代码、本仓库的审查侧重、引擎本身。 # # 接入一个仓库需要(各一次): # 1. 引擎仓库的 templates/ai-review.yml -> 本仓库 .gitea/workflows/ai-review.yml # 2. 引擎仓库的 templates/ai-review.md -> 本仓库 .gitea/ai-review.md,按本仓库 # 的技术栈改写 # 3. 下面这些组织级机密 / 变量(组织设置 → Actions)配好之后,新仓库就不用再 # 单独配任何东西: # secret REVIEW_API_KEY 模型网关的 key # secret REVIEW_ENGINE_TOKEN 只读 PAT,用于拉私有的引擎仓库 # variable REVIEW_BASE_URL 任意 OpenAI 格式的根地址 # variable REVIEW_MODEL 支持 function calling 的模型 # # 自动修复默认是关的。要开,在**本仓库**的 设置 → Actions → 变量 里加一个(这两条 # 都不要配到组织级,那样等于给所有仓库都开了),并在组织级配一次 # REVIEW_FIX_MODEL: # REVIEW_AUTOFIX=1 改代码、提交、推回 PR 分支 # REVIEW_FIX_SUGGEST_ONLY=1 只把改法写进评论,一个字节都不落盘 # 两个都配的时候,不动仓库的那个赢。fork 来的 PR 推不回去,apply 会自动降级成 # 只给改法。 # # 默认只修一轮:修完推一个 commit,那次推送起的下一轮只审不改。要"一直修到没 # 意见",把仓库变量 REVIEW_FIX_ROUNDS 调大——它数的是这个分支已经被自动修过几 # 轮,轮次写在提交消息里。 # # 改法本身是两步定出来的:一个模型决定这条该不该由机器改、以及改完长什么样,另一 # 个模型把它落成逐字命中的替换。想让这两步用不同的模型,就再配一个组织级的 # REVIEW_FIX_PLAN_MODEL——判断那步值得用贵的,抄写那步不值得。不配也照样分两步, # 只是两步都用 REVIEW_FIX_MODEL。 # # 成本由模型决定而不是由这个设计决定:同一个 PR 在便宜模型上是几分钱,在旗舰 # 模型上是几美元。换模型前先看一眼那家的每 token 价格。 # 换了模型记得同步组织变量 REVIEW_PRICES,缺单价的那轮评论不报金额。 # # 只发 COMMENT 类型的 review,从不 approve,也从不 request changes。所有发现集中 # 在一条评论里按 1、2、3 编号,不再逐条钉成行内评论。 on: pull_request: # 没有 ready_for_review:Gitea Actions 不实现这个活动类型(它支持的是 # opened / edited / closed / reopened / assigned / unassigned / # synchronize / labeled / unlabeled / milestoned / demilestoned / # review_requested / review_request_removed)。写上去只会让人以为有这个 # 覆盖,实际没有。 # # 留下的缺口:草稿 PR 会被下面的 draft == false 挡掉,所以"取消草稿但不再 # 推代码"不会触发任何东西。再推一次,或者用下面的 workflow_dispatch 手动 # 指定 PR 号——那条路就是为这个场景留的。 # # 用 edited 能覆盖取消草稿,但它同时会在每次改标题、改描述时触发,而每一 # 次都是一整轮 find + refute 的钱。为一个"再推一次就解决"的场景不值。 types: [opened, synchronize, reopened] workflow_dispatch: inputs: pr_number: description: "PR number to review" required: true type: string # 连着推两次的 PR 只该被审一次——取消在飞的那轮,而不是花钱买一份关于旧代码 # 的报告。 concurrency: group: ai-review-${{ github.event.pull_request.number || inputs.pr_number }} cancel-in-progress: true jobs: review: # 草稿按定义就是没写完的东西;手动 dispatch 是明确的请求,所以绕过这条。 # vars 那两个条件意味着本文件在网关配好之前合入也是安全的——job 直接跳过。 if: >- (github.event_name == 'workflow_dispatch' || github.event.pull_request.draft == false) && vars.REVIEW_BASE_URL != '' && vars.REVIEW_MODEL != '' runs-on: ubuntu-latest # 不设的话是 Gitea 默认的 6 小时,而下面的 continue-on-error 帮不上忙——它 # 兜的是失败,不是挂起——于是一次卡在不返回的 API 调用上的审查会一直占着 # runner,PR 的 check 一下午都是 pending。fantap-server 真踩过这个。 # # 60 分钟是照实测定的:一次阶段一实测 710 秒、29k 输出 token(其中 28.7k # 是推理),阶段二还要在这之上再加最多八条反驳,开了自动修复的话阶段三再加 # 最多八条的规划加执行。阶段三从每条一次调用变成每条两次(执行没对上文件还 # 会重试)之后,原来的 45 分钟只在八条全中时才不够,但不够的表现是卡在超时 # 上而不是干净报错,所以往上留一档。上面 concurrency 的 cancel-in-progress # 负责拦住堆积。 timeout-minutes: 60 permissions: # write 只有 REVIEW_AUTOFIX 那个模式才用得上——它要把改动推回 PR 分支。 # 没开自动修复、或者只开了 REVIEW_FIX_SUGGEST_ONLY 的仓库,把这一行改回 # read 就是了:只给改法的模式一个字节都不落盘,不需要写权限。留着 write # 也没事,引擎只在 apply 模式下才提交任何东西。 contents: write pull-requests: write steps: # 一、被审查的代码。反驳阶段要读 PR head 上的整份文件,所以检出 head, # 不是 merge commit。 - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha || format('refs/pull/{0}/head', inputs.pr_number) }} # 二、本仓库的审查侧重(.gitea/ai-review.md),取默认分支的那一份,不取 # PR 分支的——否则一个 PR 可以先改掉审查重点,再让改过的重点来审自己。 # || 'main' 是给 Gitea 兜底:它的 workflow_dispatch 载荷不总是带 # github.event.repository。 - uses: actions/checkout@v4 with: ref: ${{ github.event.repository.default_branch || 'main' }} sparse-checkout: .gitea path: .review-profile # 三、引擎。跟着 v1 这个移动标签走:引擎修了 bug 移一次标签,所有接入的 # 仓库同时生效。某个仓库被新版引擎坑到时,把这里单独钉到具体 commit 就能 # 脱身,不用回滚全局。 # # 引擎仓库是私有的,Gitea 的 GITHUB_TOKEN 只对本仓库有效,拉不动它,所以 # 要一个组织级的只读 PAT。continue-on-error 在这里是必要的:PAT 没配或者 # 过期时,这一步失败不该把别人的 PR 弄红——下面那步会说明情况然后干净退出。 - uses: actions/checkout@v4 continue-on-error: true with: repository: vertai-tech/ai-review-engine ref: v1 path: .review-engine token: ${{ secrets.REVIEW_ENGINE_TOKEN }} - name: Adversarial review # 设计上就是建议性的:引擎自己的错误已经会干净退出,这里兜的是剩下的 # (进程被杀、脚本没看见的 curl 失败)。审查机器人不该让人的 PR 变红。 continue-on-error: true # 比 job 的预算小一档,这样超时是记在这一步上,而不是整个 job 被砍—— # 后者在界面上看不出是谁超的时。引擎内部 curl 的 max-time 比这再小一档 # (1200s),所以正常顺序是那一层带着响应体和日志先报错,而不是这一层 # 无声地杀掉进程。 timeout-minutes: 55 env: REVIEW_API_KEY: ${{ secrets.REVIEW_API_KEY }} REVIEW_BASE_URL: ${{ vars.REVIEW_BASE_URL }} REVIEW_MODEL: ${{ vars.REVIEW_MODEL }} # 可选,组织级变量。网关不在响应里报金额时(sub2api 就不报),评论按这张 # 单价表算金额;不配就只报 token。格式见引擎 README「每轮花了多少」。 REVIEW_PRICES: ${{ vars.REVIEW_PRICES }} # 上游报过载 / 限流时每次重试前等几秒。不配就是 10,30,60,写 0 关掉。 REVIEW_BUSY_RETRY_WAITS: ${{ vars.REVIEW_BUSY_RETRY_WAITS }} # 评论里行号链接用的站点地址,如 https://git.sciark.cn。runner 在容器里访问 # Gitea 时,API 返回的 html_url 和 GITHUB_SERVER_URL 都可能是容器内地址, # 不配这个链接就打不开。组织级配一次。 REVIEW_PUBLIC_URL: ${{ vars.REVIEW_PUBLIC_URL }} # PR 号和仓库名经 env 进脚本,不直接拼进 run:手动 dispatch 的 pr_number # 是任意字符串,表达式在 shell 解析之前就被展开,带引号就能注入命令。 PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} REVIEW_REPO: ${{ github.repository }} # 引擎的 Gitea REST helper 读这个。GITHUB_API_URL 由 act_runner 导出, # 已经指向 /api/v1。 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 自动修复。REVIEW_AUTOFIX 没配或不是 1 的话,下面几个都没有意义。 REVIEW_AUTOFIX: ${{ vars.REVIEW_AUTOFIX }} REVIEW_FIX_SUGGEST_ONLY: ${{ vars.REVIEW_FIX_SUGGEST_ONLY }} REVIEW_FIX_MODEL: ${{ vars.REVIEW_FIX_MODEL }} # 一个分支最多自动修复几轮。不配就是 1:修一次,下一轮只审,跟从前 # 一样。配大了才是"一直修到没意见",代价是往作者分支上推更多 commit、 # 一个 PR 烧更多轮的钱。硬上限在引擎里。 REVIEW_FIX_ROUNDS: ${{ vars.REVIEW_FIX_ROUNDS }} # 规划用哪个模型。这不是开关——不配也照样先规划再执行,只是两步同一个 # 模型。配了才是"贵的判断、便宜的抄写"那种分工。 REVIEW_FIX_PLAN_MODEL: ${{ vars.REVIEW_FIX_PLAN_MODEL }} # 只有当写修复的模型不在审查那个网关上时,才需要这两个。 REVIEW_FIX_BASE_URL: ${{ vars.REVIEW_FIX_BASE_URL }} REVIEW_FIX_API_KEY: ${{ secrets.REVIEW_FIX_API_KEY }} # 同上,只有当规划的模型连修复那个网关都不在时才需要。 REVIEW_FIX_PLAN_BASE_URL: ${{ vars.REVIEW_FIX_PLAN_BASE_URL }} REVIEW_FIX_PLAN_API_KEY: ${{ secrets.REVIEW_FIX_PLAN_API_KEY }} # 规划的思考强度。不配就是模型默认;写 high 之类的词按 reasoning_effort # 发,写 JSON 对象就原样并进请求。各家写法见引擎 README。 REVIEW_FIX_PLAN_THINKING: ${{ vars.REVIEW_FIX_PLAN_THINKING }} # 对照用的第二个执行模型。只在直接改的模式下跑,改动不落盘,只和 # REVIEW_FIX_MODEL 的改动一起贴进评论,方便比较哪个模型抄得好。 REVIEW_FIX_MODEL2: ${{ vars.REVIEW_FIX_MODEL2 }} REVIEW_FIX_MODEL2_BASE_URL: ${{ vars.REVIEW_FIX_MODEL2_BASE_URL }} REVIEW_FIX_MODEL2_API_KEY: ${{ secrets.REVIEW_FIX_MODEL2_API_KEY }} # 只有当上面那个自动 token 推不动分支时,才需要这个写权限 PAT。 REVIEW_FIX_TOKEN: ${{ secrets.REVIEW_FIX_TOKEN }} run: | ENGINE=.review-engine/scripts/ai_review.py if [ ! -f "$ENGINE" ]; then # 拉引擎那步失败了。说清楚原因,否则这里的沉默会被读成"没发现问题"。 echo "::warning::引擎没拉下来(多半是组织级 secret REVIEW_ENGINE_TOKEN 没配或已过期),本轮未审查。" exit 0 fi case "$PR_NUMBER" in ''|*[!0-9]*) echo "::warning::PR 号必须是数字,收到的是:$PR_NUMBER。本轮未审查。" exit 0 ;; esac python3 "$ENGINE" --repo "$REVIEW_REPO" --pr "$PR_NUMBER"