0

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:

  1. 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.
    1. Có fingerprint ổn định, để cùng một issue không bị báo lại mỗi lần chạy.
    1. 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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí