TL;DR

  • Encode checklist sáu mục Part 1 thành sáu CI assertion, map tier blast radius Part 2 thành auto-label, thêm behavioral diff gate cho PR T0/T1. Tổng cộng ba job CI và một PR bot.
  • Vòng review chạy được lúc bạn vào họp, nghỉ phép, hoặc ngủ. Queue PR AI vẫn dồn vào, nhưng đã được label, gate, và spot-check trước khi bạn quay lại bàn phím.
  • Bot KHÔNG làm thay bạn việc đánh giá taste, architectural intent, hay business fit. Phần đó vẫn cần con người, và bài này nói rõ ranh giới.

📊 Result Proof Sau khi setup xong trên repo medium (khoảng 80k LOC, 5 dev, trung bình 4 PR AI / ngày): 8h sáng thứ Hai, 7 PR AI mới qua đêm đã được label tier, 4 PR pass tự động sẵn sàng taste-review, 2 PR T1 fail behavioral-gate (chờ test mới), 1 PR fail hallucinated-import scan (auto-reject). Tổng thời gian reviewer cần ngồi xuống: 22 phút thay vì 2 tiếng.

Phần 2 đã dạy bạn triage queue PR AI bằng blast radius, dồn read budget vào tier cao, spot-check tier thấp bằng behavioral diff. Quy trình đó giữ tốc độ khi bạn còn ngồi ở bàn phím. Nhưng đúng cái lúc bạn vào họp hai tiếng hoặc nghỉ phép, queue PR AI lại phình ra và những kiểm tra này vẫn cần phải chạy mà không có bạn. Bài này encode checklist Part 1 và triage Part 2 thành ba job CI cộng một PR bot, để vòng review chạy đúng kể cả khi bạn đang ngủ. Bạn sẽ kết thúc bài với YAML workflow paste-and-adapt được, script Python tính behavioral coverage delta, template Markdown cho bot comment, và một danh sách rõ ràng những việc CI không làm thay bạn được.

Prerequisites:

  • Đã đọc Part 1, Checklist review một PR AIPart 2, Triage theo blast radius. Bài này không re-explain checklist hay blast radius tier.
  • Có quyền sửa CI config cho repo team. Ví dụ trong bài dùng GitHub Actions YAML; logic tương đương dễ port sang GitLab CI hoặc Buildkite.
  • Có quyền tạo GitHub App hoặc bot account đủ scope pull_requests: writechecks: write.

Phần mở: queue PR AI lúc bạn đang họp hay nghỉ phép, Part 2 chừa lại đúng câu hỏi này

Bạn đẩy nguyên checklist Part 1 và triage Part 2 vào ba job CI cộng một PR bot, theo thứ tự: label tier, assert sáu failure mode, gate behavioral coverage delta, bot post spot-check comment. Mỗi job đối ứng một phần của logic bạn đã chạy bằng tay trong hai part trước.

Pipeline ở pipe-level trông như sau:

PR opens
→ Job 1: label-tier (encode Part 2 blast radius)
→ Job 2: six-asserts (encode Part 1 checklist sáu mục)
→ Job 3: behavioral-gate (encode Part 1 failure mode #1 ở scale luồng)
→ Bot: spot-check report (render output + checklist con người)

Job 1 chạy đầu vì mọi job sau đều branch theo tier. Job 2 và Job 3 chạy parallel khi có tier rồi. Bot chạy cuối, đợi cả ba job xong, render một comment duy nhất.

Điểm mấu chốt: Behavioral diff (Part 1 đã định nghĩa) là cách spot-check khi bạn không có read budget. CI làm đúng cái đó: nó đọc behavior, không đọc style.

Làm sao auto-label blast radius tier cho PR AI khi vừa opened?

Job 1 dùng path regex cộng author signature để gán label tier:T0, tier:T1, tier:T2, hoặc tier:T3 cho PR khi opened. Required reviewers set theo tier qua CODEOWNERS. Đây là dây thần kinh: nếu Job 1 sai tier, Job 3 và bot sẽ nói chuyện sai đối tượng.

Why: mọi job sau branch theo tier. T0/T1 đi qua gate behavioral coverage delta, T2/T3 chỉ qua six-assert và bot comment. Đặt tier ở Job 1 để các job sau không cần re-compute.

Code (.github/workflows/label-tier.yml):

name: label-tier
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
label:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- id: tier
run: python3 scripts/label_tier.py >> "$GITHUB_OUTPUT"
env:
BASE_REF: ${{ github.event.pull_request.base.sha }}
HEAD_REF: ${{ github.event.pull_request.head.sha }}
- uses: actions/github-script@v7
with:
script: |
const tier = "${{ steps.tier.outputs.tier }}";
const ai = "${{ steps.tier.outputs.ai_authored }}" === "true";
const labels = [`tier:${tier}`];
if (ai) labels.push("ai-authored");
await github.rest.issues.addLabels({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.issue.number, labels });

Script scripts/label_tier.py (logic core):

# scripts/label_tier.py: map changed paths + commit trailers to a tier label
import os, re, subprocess
TIER_RULES = [
("T0", [r"^infra/", r"^security/", r"^auth/", r"^db/migrations/"]),
("T1", [r"^core/", r"^domain/", r"^api/"]),
("T2", [r"^features/"]),
("T3", [r"^docs/", r"^tests/", r"\.md$"]),
]
AI_TRAILERS = re.compile(
r"Co-Authored-By:\s*(Claude|Cursor|Codex|Copilot)", re.IGNORECASE)
def changed_paths(base, head):
out = subprocess.check_output(
["git", "diff", "--name-only", base, head], text=True)
return [p for p in out.splitlines() if p.strip()]
def highest_tier(paths):
for tier, patterns in TIER_RULES: # rules ordered T0 to T3
if any(re.search(pat, p) for pat in patterns for p in paths):
return tier
return "T3" # default: docs-tier when nothing matched
def is_ai_authored(base, head):
msgs = subprocess.check_output(
["git", "log", "--format=%B", f"{base}..{head}"], text=True)
return bool(AI_TRAILERS.search(msgs))
if __name__ == "__main__":
base, head = os.environ["BASE_REF"], os.environ["HEAD_REF"]
paths = changed_paths(base, head)
print(f"tier={highest_tier(paths)}")
print(f"ai_authored={'true' if is_ai_authored(base, head) else 'false'}")

Verify: mở một PR test có file core/billing/charge.py, expect label tier:T1ai-authored xuất hiện trong khoảng 30s. Bảng path → tier (T0 thắng nếu PR đụng nhiều tier, đó là chủ ý vì blast radius là biên cao nhất, không phải trung bình):

Path patternTier
infra/, security/, auth/, db/migrations/T0
core/, domain/, api/T1
features/T2
docs/, tests/, *.mdT3

Điểm mấu chốt: Path heuristic không bao giờ 100% đúng. Luôn cho maintainer override label thủ công. Bot tôn trọng label hiện tại nếu đã có người sửa.

Trade-off: path regex rẻ và dễ debug nhưng giả định codebase tổ chức theo domain folder. Nếu repo chia theo feature flat, đổi rules sang CODEOWNERS-based hoặc weight bằng git diff --stat. Đừng làm semantic-aware bằng AI ở đây, latency cao và false positive bóp chết throughput Job 1.

Sáu failure-mode check của Part 1 encode thế nào thành CI assertion?

Job 2 là matrix gồm sáu assertion độc lập, mỗi cái đối ứng một mục trong checklist Part 1 (xem Part 1, Checklist review một PR AI cho định nghĩa từng failure mode). Bốn blocking, hai soft-warn. Chạy parallel để giữ wall-clock thấp.

Why: encode được pattern nào ra CI thì để CI bắt; cái nào không encode được thì bot post comment để bạn check tay.

Code (.github/workflows/six-asserts.yml, rút gọn):

name: six-asserts
on: { pull_request: { types: [opened, synchronize, reopened, labeled] } }
jobs:
assert:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
check:
- { name: fake-test, blocking: true }
- { name: schema-diff, blocking: true }
- { name: scope-vs-desc, blocking: false }
- { name: hallucinated-import, blocking: true }
- { name: side-effect-taint, blocking: true }
- { name: commit-message, blocking: false }
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: bash scripts/check-${{ matrix.check.name }}.sh
continue-on-error: ${{ matrix.check.blocking == false }}

Sáu sub-check:

1. check-fake-test.sh, fake / over-mocked test. Grep AST cho test có assert mock.call_count == N mà không có assertion về return value hoặc state thực. Fail khi tỉ lệ mock-only / total > 0.4. Verify: PR thêm test chỉ có assert mock.foo.called, expect đỏ.

2. check-schema-diff.sh, schema-validation diff. So OpenAPI / JSON Schema / Prisma schema giữa base và head. Flag nếu schema đổi nhưng không có migration file kèm theo. Verify: PR sửa prisma/schema.prisma không kèm db/migrations/*.sql, expect đỏ.

3. check-scope-vs-desc.sh, scope vs description drift (soft). Extract scope keyword từ PR description, so với danh sách path PR đụng. Warn khi drift > 20%. Soft vì false positive cao khi description ngắn. Verify: PR description nói “fix typo” nhưng diff đụng auth/, expect comment cảnh báo, job vẫn pass.

4. check-hallucinated-import.sh, hallucinated import scan. Extract mọi import mới thêm, validate chúng tồn tại trong package.json, pyproject.toml, hoặc go.mod. Verify: PR thêm from sklearn_xgboost import Booster, expect đỏ.

5. check-side-effect-taint.sh, side-effect taint check. Diff đụng *.config.*, env file, migration, cron, hoặc DI container? Nếu có và PR không mang label infra-change, fail. Verify: PR sửa docker-compose.yml không có label infra-change, expect đỏ.

6. check-commit-message.sh, commit-message sanity (soft). Regex ^(feat|fix|chore|docs|refactor|test|perf|build|ci)(\(.+\))?: .{10,}. Warn khi không match. Verify: commit fix stuff, expect comment cảnh báo.

Failure mode trong checklist Part 1Assertion trong Job 2Blocking?
Test giả / over-mockfake-test
API dùng sai nhưng nhìn hợp lýschema-diff
Scope nở ngoài mô tảscope-vs-descKhông
Import / symbol bịahallucinated-import
Side-effect ẩnside-effect-taint
Commit message lừacommit-messageKhông

Điểm mấu chốt: Sáu assertion này không thay thế review thủ công. Chúng chỉ bắt cái máy bắt được. Mọi cái còn lại (taste, intent, fit) bot sẽ liệt kê dưới dạng checkbox cho bạn ở section sau.

Trade-off: assertion 3 và 6 đáng lẽ blocking, nhưng false positive rate cao đến mức reviewer sẽ override liên tục và gate mất uy tín. Soft-warn giữ signal mà không tạo noise, cùng logic Danger.js đã chọn nhiều năm trước.

Khi nào nên chặn merge PR T0/T1 vì thiếu behavioral test coverage delta?

Job 3 chỉ chạy khi PR có label tier:T0 hoặc tier:T1. Nó tính behavioral coverage delta (số test name mới cộng contract test mới hoặc thay đổi giữa base và head). Nếu delta bằng 0 mà src/ đã đổi, fail required check behavioral-gate. Đây là encode failure mode #1 (“test giả”) ở scale luồng: thay vì assert format test, assert sự tồn tại của test mới cho behavior mới.

Why: AI hay viết PR đổi behavior nhưng không thêm test cho behavior đó. Line coverage có thể tăng vì test cũ chạy qua code path mới tình cờ; behavioral coverage delta yêu cầu test name mới hoặc contract test mới, chứng minh một hành vi mới đã được verify.

Code (.github/workflows/behavioral-gate.yml):

name: behavioral-gate
on: { pull_request: { types: [labeled, synchronize] } }
jobs:
gate:
if: contains(github.event.pull_request.labels.*.name, 'tier:T0') ||
contains(github.event.pull_request.labels.*.name, 'tier:T1')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: python3 scripts/behavioral_delta.py
env:
BASE_REF: ${{ github.event.pull_request.base.sha }}
HEAD_REF: ${{ github.event.pull_request.head.sha }}

Script scripts/behavioral_delta.py:

# scripts/behavioral_delta.py: fail if T0/T1 PR changes src/ but adds no new test name
import os, re, subprocess, sys
TEST_NAME = re.compile(
r"^\+\s*(def|it|test)\s+(test_\w+|['\"][^'\"]+['\"])", re.M)
def diff(base, head, pathspec):
return subprocess.check_output(
["git", "diff", base, head, "--", pathspec], text=True)
def test_names(diff_text):
return set(m.group(2) for m in TEST_NAME.finditer(diff_text))
if __name__ == "__main__":
base, head = os.environ["BASE_REF"], os.environ["HEAD_REF"]
src_diff = diff(base, head, "src/")
new_tests = test_names(diff(base, head, "tests/"))
if src_diff.strip() and not new_tests:
print("FAIL: src/ changed but no new test name detected.")
sys.exit(1)
print(f"OK: {len(new_tests)} new test name(s): {sorted(new_tests)[:5]}")

Expected output khi pass:

OK: 2 new test name(s): ['test_charge_retries_on_5xx', 'test_charge_rejects_negative_amount']

Verify: tạo PR T1 chỉ sửa src/billing/charge.py không sửa tests/, expect behavioral-gate đỏ. Thêm test mới def test_charge_retries_on_5xx():, expect xanh trong lần CI tiếp theo.

Điểm mấu chốt: Behavioral coverage delta khác line coverage. Line coverage tăng khi test mock thêm; behavioral coverage delta yêu cầu test name mới, chứng minh một hành vi mới đã được test, không phải code path cũ được chạm thêm lần nữa.

Trade-off: gate này strict. Refactor thuần sẽ fail vì src/ đổi mà không có test mới. Cách thoái lui: label refactor-only skip gate. Đừng auto-detect refactor, vì AI giả refactor để né gate là failure mode bạn không muốn tạo lỗ hổng.

PR bot post spot-check comment thế nào và bạn vẫn phải tick gì bằng tay?

Bot là một workflow ngắn chạy sau khi Job 1, 2, 3 xong. Nó đọc output các job, render một single PR review comment chứa: tier đã gán, bảng sáu assertion với pass/fail, và checklist spot-check con người phải làm. Comment idempotent: update thay vì tạo mới ở mỗi push.

Why: reviewer mở PR ra phải thấy ngay “cái máy đã làm được gì, cái mình còn phải làm gì”. Không có comment này, bạn vẫn phải mở từng tab Actions để đọc, mất 80% giá trị tự động hoá.

Code (.github/workflows/pr-bot.yml):

name: pr-bot
on:
workflow_run:
workflows: [label-tier, six-asserts, behavioral-gate]
types: [completed]
jobs:
comment:
runs-on: ubuntu-latest
permissions: { pull-requests: write }
steps:
- uses: actions/github-script@v7
with:
script: |
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.issue.number });
const marker = "<!-- pr-bot:spot-check -->";
const existing = comments.find(c => c.body.includes(marker));
const body = require('./scripts/render_spot_check.js')();
const params = {
owner: context.repo.owner, repo: context.repo.repo,
body: marker + "\n" + body };
if (existing) {
await github.rest.issues.updateComment(
{ ...params, comment_id: existing.id });
} else {
await github.rest.issues.createComment(
{ ...params, issue_number: context.issue.number });
}

Template Markdown bot post (nội dung động được render từ output các job):

## Spot-check report
**Tier:** T1 · **AI-authored:** yes
### Machine asserts
| Check | Result |
| --- | --- |
| fake-test | pass |
| schema-diff | pass |
| scope-vs-desc | warn (drift 28%) |
| hallucinated-import | pass |
| side-effect-taint | pass |
| commit-message | pass |
| behavioral-gate | pass (2 new tests) |
### Human spot-check (please tick)
- [ ] Taste review: tên hàm / signature / shape API có đúng với codebase?
- [ ] Architectural intent: layering giữ đúng (domain không gọi infra trực tiếp)?
- [ ] Business intent: feature đúng user story đã agree, không phải cái LLM tưởng?
- [ ] Domain logic: edge case nào test hiện tại không cover mà bạn biết là quan trọng?

Verify: push một commit lên PR test, expect comment xuất hiện sau khi cả ba job xong. Push commit tiếp, expect cùng một comment được update (kiểm comment ID không đổi), không có comment thứ hai.

Điểm mấu chốt: Bot dùng actions/github-script là đủ, không cần dựng dịch vụ riêng. Danger.js, Reviewdog là lựa chọn khác; nhưng nếu team chưa dùng cái nào, github-script trong cùng repo dễ maintain hơn.

Trade-off: template Markdown nhúng trong script là pragmatic nhưng sẽ ngứa khi muốn i18n hoặc theme. Khi đó tách render_spot_check.js thành module thuần, test bằng snapshot. Đừng over-engineer ở vòng đầu.

Wrap: tự động hóa review AI pull request bằng CI và PR bot làm được gì, KHÔNG làm được gì

Bức tranh thành công, cụ thể. 8h sáng thứ Hai, bạn mở email. Queue PR AI có 7 PR mới qua đêm: 4 đã pass tự động sẵn sàng taste-review (5 phút mỗi cái để tick checkbox bot post sẵn), 2 fail behavioral-gate (bạn comment yêu cầu thêm test, không cần đọc diff), 1 fail hallucinated-import (bot auto-reject, đóng PR). Tổng thời gian bạn ngồi xuống: khoảng 22 phút. Trước pipeline này con số đó là 2 tiếng.

Đây là điều CI KHÔNG làm được, bạn cần nói thẳng với team:

  • Taste API. Hai cách đặt tên function đều “đúng” về syntax; cách nào fit codebase là judgment call. CI không có context đó.
  • Architectural intent fit. PR có thể pass mọi assertion nhưng vẫn vi phạm layering (domain gọi infra trực tiếp vì AI thấy nhanh hơn). Bạn phải tự đọc.
  • Business intent. Feature có đúng user story đã agree không, hay LLM tự “improve” theo cảm hứng? CI không có user story.
  • Domain logic edge case. Behavioral coverage delta chỉ kiểm “có test mới không”, không kiểm “test có đủ không”.
  • Câu hỏi ‘có nên build cái này không’. AI giả định request là valid; con người phải hỏi lại.
CI làm đượcCon người vẫn phải làm
Label tier theo pathQuyết định tier đúng khi path heuristic sai
Bắt fake test, hallucinated import, side-effect taintĐánh giá test có cover edge case quan trọng không
Gate behavioral coverage deltaĐánh giá domain logic và business intent
Post checklist spot-checkTick checklist, đây là chữ ký taste của bạn
Block PR thiếu test trên T0/T1Quyết định feature có nên tồn tại không

Điểm mấu chốt: AI hiện chưa judge được taste, architectural intent, hay business fit. Đó là dòng phân cách rõ giữa cái encode được vào CI và cái không. Bot không thay bạn; nó dọn việc giúp bạn về đúng cái việc chỉ bạn làm được.

Bài này khép lại phần kỹ thuật của series. Nếu bạn nhảy thẳng vào Part 3:

FAQ

Q: Làm sao phát hiện PR do AI viết trong CI?

A: Kiểm Co-Authored-By: trailer cho Claude, Cursor, Codex, hoặc Copilot; hoặc magic marker trong PR description (mỗi agent có format khác nhau). Kết hợp cả hai vì một số agent không emit trailer trừ khi cấu hình rõ.

Q: Behavioral diff gate khác line coverage gate thế nào?

A: Line coverage có thể tăng vì test mock thêm hoặc code path cũ được chạm tình cờ. Behavioral coverage delta yêu cầu test name mới hoặc contract test mới, chứng minh có một hành vi mới đã được mô tả và verify, không phải code path cũ vô tình được cover thêm.

Q: Bot có nên auto-reject PR fail assertion không, hay chỉ comment?

A: Phân ngạch. Assertion 1, 2, 4, 5 (fake test, schema diff, hallucinated import, side-effect taint) là blocking, fail required check. Assertion 3, 6 (scope drift, commit message) là soft, chỉ comment. Behavioral-gate cũng blocking, nhưng chỉ cho T0/T1.

Q: Setup ba job và bot này mất bao lâu cho một repo bình thường?

A: 1–2 ngày làm full-time cho repo medium nếu đã có GitHub Actions sẵn. Ngày đầu setup Job 1 và Job 2 (sáu assertion). Ngày hai làm Job 3 và bot. Ngày ba là calibration: chạy trên 10 PR thật, chỉnh threshold.

Q: Còn cần review thủ công nữa không sau khi setup xong?

A: Có. Bot không judge taste, architectural intent, hay business fit; nó dọn 60-70% việc của bạn để bạn về đúng cái không tự động hoá được. Nếu team coi bot là “review xong rồi”, đó là failure mode #2 trong checklist senior-dev, bạn vừa downgrade chính mình.