When AI Reviewers Find More Bugs Than You Can Fix: Building a Triage Pipeline for AI Code Review Findings
Tuần này trên Hacker News có một tin khá đáng suy nghĩ: lịch phát hành Postgres 19 bị lùi lại vì các công cụ AI review tìm ra nhiều bug hơn số bug mà đội ngũ con người kịp xử lý. Nghe thì có vẻ là tin tốt, vì tìm ra bug sớm luôn rẻ hơn. Nhưng ai từng bật AI reviewer cho repo của team đều biết mặt trái của nó: sau khoảng một tuần, mỗi PR có thêm 30 comment, một nửa là nhắc lại chuyện cũ, một phần ba là false positive, và dev bắt đầu bấm "Resolve" hàng loạt mà không đọc. Lúc đó AI review không còn là công cụ hữu ích nữa mà thành tiếng ồn. Bài này chia sẻ cách mình dựng một triage pipeline đơn giản để biến đống finding hỗn loạn thành một danh sách ngắn mà team xử lý được.
Vấn đề thật sự: lượng finding, không phải độ chính xác
Phần lớn team nghĩ vấn đề nằm ở model: "model chưa đủ giỏi nên báo sai nhiều". Thực ra kể cả khi độ chính xác là 80%, một codebase 200k dòng vẫn có thể sinh ra hàng trăm finding hợp lệ ngay lần quét đầu tiên. Team 5 người không thể xử lý hết trong một sprint.
Nên câu hỏi đúng là: trong số các finding này, cái nào phải chặn merge, cái nào để vào backlog, cái nào bỏ qua? Đây là bài toán triage, và giới security đã giải nó từ lâu với SAST tool (Semgrep, CodeQL). Ta chỉ cần áp dụng lại cho AI reviewer.
Ba nguyên tắc mình dùng:
- Chỉ chặn finding mới, tức là finding nằm trên các dòng mà PR vừa thay đổi. Nợ kỹ thuật cũ thì vào baseline.
-
- Có fingerprint ổn định, để cùng một issue không bị báo lại mỗi lần chạy.
-
- Có ngân sách (budget): mỗi PR tối đa N comment, sắp xếp theo severity.
flowchart LR
A[AI Reviewer output] --> B[Normalize sang SARIF/JSON]
B --> C[Lọc theo diff của PR]
C --> D[Tính fingerprint]
D --> E{Có trong baseline?}
E -- Có --> F[Bỏ qua]
E -- Không --> G[Sắp xếp theo severity]
G --> H[Cắt theo budget]
H --> I[Comment lên PR / Fail CI]
```
## Bước 1: Chỉ giữ lại finding trên dòng đã thay đổi
Đây là bộ lọc có tác dụng nhất. Nhiều AI reviewer đọc cả file để có ngữ cảnh rồi báo luôn các vấn đề ở chỗ PR không hề đụng tới. Dev sẽ thấy khó chịu, và họ có lý: "tôi chỉ sửa 3 dòng, sao bắt tôi refactor cả file?"
Script bash dưới đây lấy danh sách các dòng được thêm hoặc sửa trong PR, dùng `git diff` với `--unified=0`:
```bash
#!/usr/bin/env bash
# changed_lines.sh - xuất ra file:line cho mọi dòng được thêm/sửa
set -euo pipefail
BASE="${1:-origin/main}"
git diff --unified=0 --no-color "$BASE"...HEAD | awk '
/^\+\+\+ b\// { file = substr($0, 7); next }
/^@@/ {
# @@ -a,b +c,d @@ -> lấy c và d
split($3, parts, ",")
start = substr(parts[1], 2)
count = (parts[2] == "") ? 1 : parts[2]
for (i = 0; i < count; i++) print file ":" start + i
}
' > changed_lines.txt
echo "Changed lines: $(wc -l < changed_lines.txt)"
```
Chạy trong GitHub Actions thì nhớ `fetch-depth: 0` ở `actions/checkout@v4`, nếu không `git diff` sẽ không tìm thấy base commit.
## Bước 2: Fingerprint và baseline
AI reviewer có tính không tất định (non-deterministic): cùng một đoạn code, lần này nó viết "possible null dereference", lần sau lại viết "variable may be None". Nếu dùng message làm key thì baseline chẳng có tác dụng gì.
Mình tính fingerprint từ **file + category + nội dung dòng code đã chuẩn hóa**, không dùng số dòng (vì số dòng thay đổi khi có người chèn code phía trên) và không dùng message. Python 3.11+, không cần thư viện ngoài:
```python
#!/usr/bin/env python3
# triage.py - lọc, dedupe và cắt finding theo budget
import hashlib, json, re, sys
from pathlib import Path
SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
BUDGET = 10 # tối đa 10 comment mỗi PR
BLOCKING = {"critical", "high"}
def normalize(code: str) -> str:
code = re.sub(r"#.*$|//.*$", "", code) # bỏ comment
return re.sub(r"\s+", " ", code).strip() # gộp whitespace
def fingerprint(f: dict) -> str:
line = Path(f["file"]).read_text(errors="ignore").splitlines()[f["line"] - 1]
raw = f"{f['file']}|{f['category']}|{normalize(line)}"
return hashlib.sha256(raw.encode()).hexdigest()[:16]
def main(findings_path, changed_path, baseline_path):
findings = json.loads(Path(findings_path).read_text())
changed = set(Path(changed_path).read_text().split())
baseline = set(json.loads(Path(baseline_path).read_text())) \
if Path(baseline_path).exists() else set()
seen, kept = set(), []
for f in findings:
if f"{f['file']}:{f['line']}" not in changed:
continue
fp = fingerprint(f)
if fp in baseline or fp in seen:
continue
seen.add(fp)
kept.append({**f, "fingerprint": fp})
kept.sort(key=lambda f: SEVERITY_RANK.get(f["severity"], 9))
shown, dropped = kept[:BUDGET], len(kept) - BUDGET
Path("triaged.json").write_text(json.dumps(shown, indent=2))
print(f"Total: {len(findings)} | sau triage: {len(kept)} | hiển thị: {len(shown)}")
if dropped > 0:
print(f"{dropped} finding mức thấp hơn bị ẩn do vượt budget")
blocking = [f for f in shown if f["severity"] in BLOCKING]
sys.exit(1 if blocking else 0)
if __name__ == "__main__":
main(*sys.argv[1:4])
```
Để tạo baseline lần đầu, chạy reviewer trên toàn bộ nhánh `main`, tính fingerprint cho tất cả finding rồi lưu vào `.ai-review-baseline.json`, commit file này vào repo. Từ đó trở đi, nợ cũ không làm phiền PR mới nữa. Mỗi sprint dành ra vài giờ để xử lý dần baseline, giống cách bạn trả nợ kỹ thuật.
## Bước 3: Ghép vào CI và đóng vòng phản hồi
```mermaid
sequenceDiagram
participant Dev
participant CI as GitHub Actions
participant AI as AI Reviewer
participant T as triage.py
Dev->>CI: Push PR
CI->>AI: Gửi diff + context
AI-->>CI: findings.json
CI->>T: findings + changed_lines + baseline
T-->>CI: triaged.json + exit code
CI-->>Dev: Tối đa 10 comment, fail nếu có high/critical
Dev->>CI: Đánh dấu false positive
```
Bước cuối cùng hay bị bỏ qua nhưng lại quan trọng nhất là **feedback loop**. Khi dev đánh dấu một finding là false positive (ví dụ comment `/ai-ignore` trên PR), fingerprint đó sẽ được thêm vào baseline. Dùng `gh` CLI (bản 2.40+) để đọc comment khá đơn giản:
```bash
gh api "repos/$REPO/pulls/$PR/comments" \
--jq '.[] | select(.body | contains("/ai-ignore")) | .in_reply_to_id' \
| while read -r id; do
gh api "repos/$REPO/pulls/comments/$id" --jq '.body' \
| grep -oE 'fp:[a-f0-9]{16}' | cut -d: -f2
done | sort -u > ignored.txt
```
Mẹo nhỏ: đặt `fp:<fingerprint>` ở cuối mỗi comment bot đăng lên, nhờ vậy việc trace ngược lại sẽ rất dễ.
Ngoài ra hãy theo dõi tỷ lệ false positive theo từng `category`. Nếu category `style` có 70% bị ignore thì tắt nó đi, dành budget comment cho `security` và `concurrency`, những loại bug mà con người hay bỏ sót nhất.
## Kết luận
Câu chuyện Postgres 19 cho thấy AI reviewer đã đủ giỏi để tạo ra *quá nhiều* finding, và điểm nghẽn bây giờ nằm ở năng lực xử lý của con người. Vài việc bạn có thể làm ngay tuần này:
- **Lọc theo diff trước tiên**: chỉ comment trên dòng mà PR thay đổi. Riêng bước này thường cắt được 50-70% tiếng ồn.
- **Tạo baseline** cho nhánh `main` và commit vào repo, để nợ cũ không chặn PR mới.
- **Fingerprint theo nội dung code**, không dùng số dòng hay message của model.
- **Đặt budget** khoảng 10 comment mỗi PR, sắp xếp theo severity, và chỉ fail CI với `high`/`critical`.
- **Đóng vòng phản hồi**: false positive phải quay ngược vào baseline, đồng thời đo tỷ lệ ignore theo category để tinh chỉnh.
AI review chỉ có giá trị khi team thực sự đọc nó. Một danh sách 5 finding đúng và được xử lý có ích hơn nhiều so với 50 finding bị bấm Resolve cho xong.
All rights reserved