---
date: 2026-05-11
tags: [ai-review, ci, workflow, dedup, idempotency]
---

# ai-review.yml 加回 synchronize + runner 用 PR diff hash 幂等短路

## 起因

PR #259 删 `synchronize` 是为了省"~47% PR ≥2 次 review"的浪费。代价没暴露在 dry-run 期——转正后立刻出现：

- 推 fix commit 不重跑（synchronize 不监听）
- merge develop 进 PR 不重跑
- 评论 `/ai-review` 用 default branch 的 workflow + code（半显语义），救不了"我刚推的 fix"
- workflow_dispatch 不写 `(pull_request)` 后缀的 commit status，过不了 required check
- 结果："fix 自己 required check 的 PR" 死锁

详见 `.learnings/2026-05-11-gitea-actions-trigger-checkout-status-semantics.md`。

## 修法

**两面同时改**：

### A. 触发面

`ai-review.yml` types 加回 `[synchronize, reopened]`。PR push / 关再开都正常触发。

### B. 计算面

`scripts/ops/ai-review-runner.sh` 加 PR diff hash 幂等短路：

```bash
CURRENT_DIFF_HASH=$(echo -n "$PR_DIFF" | md5sum | cut -d' ' -f1)
# ... 从上轮 state header 解出 PREV_DIFF_HASH ...
if [ "$CURRENT_DIFF_HASH" = "$PREV_DIFF_HASH" ] && [ pull_request 触发 ] && [ 无 FORCE ]; then
  echo "PR diff hash 未变化，跳过"
  exit 0
fi
```

state header JSON 多存一个 `last_diff_hash` 字段，跟 `last_head_sha` / `review_count` / `open_findings` 一起。

## 短路覆盖什么场景

- ✅ `git merge develop` 进 PR：diff 相对新 base 通常一致，跳过
- ✅ `git rebase` 整理历史：diff 不变，跳过
- ✅ 无内容 force-push（如修 commit message）：跳过
- ✅ Draft → ready_for_review 切换：跳过（diff 没变）
- ❌ 推真改动：diff 变 → 跑全量
- ❌ 手动 `/ai-review` 或 `workflow_dispatch`：人为意图，**绕过短路强制重跑**
- ❌ `AI_REVIEW_FORCE=1`：本地调试用，强制全量

## 为什么 hash 不基于 head SHA

`head_sha` 每次 push 都变（即使 merge 一个空 commit），用 SHA 做幂等键会漏掉 99% 该跳的场景。`diff_hash` 才是"PR 实质有没有改"的真信号。

## 短路是 `exit 0` 不是 `exit 1`

很重要：Gitea Actions 用脚本 exit code 决定 step success/failure。`exit 0` → step success → 写 `ai-review / ai-review (pull_request)` = success status → 满足 required check，PR 可 merge。

如果改成 `exit 1`（即"跳过 = 失败"），就会回到原来的死锁。

## 为什么不靠 `concurrency` group 收敛

GH Actions / Gitea Actions 的 `concurrency` 是把同 group 的旧 run 取消、跑最新的，控制不到"内容相同就别跑"。诉求不同。

## 关联

- PR #259（删 synchronize 的初衷）
- PR #320（转正暴露问题）
- `.learnings/2026-05-11-gitea-actions-trigger-checkout-status-semantics.md`（半显语义）
- `.learnings/2026-05-11-ai-review-runner-first-pr-no-prior-comment.md`（runner pipefail bug）
