SSD Nodes Learn 🎉 VPS từ $4.99/tháng
Hướng dẫn Matt ConnorBởi Matt Connor · Cập nhật ngày 2026-08-07

Tự host agent review PR trên VPS: cách làm và chi phí

Chạy agent review PR trên VPS của bạn với runner tự host, prompt chỉ theo diff, lọc path và kích thước, comment inline, kèm chi phí thật cho mỗi PR.

Một agent review PR tự host làm gì

Agent review PR tự host là một chương trình nhỏ chạy trên server do bạn quản lý. Nó đọc diff của pull request (PR) và chỉ gửi các dòng đã thay đổi đến model. Kết quả trả về được đăng dưới dạng comment review inline. Agent không bao giờ checkout branch của bạn và không đọc file nào mà pull request không chỉnh sửa. Agent chỉ giữ một model API (application programming interface) key và một token có quyền thêm comment, không có quyền nào khác.

Model có thể đọc diff. Phần đó đã được giải quyết. Điều quan trọng là diff đi đâu và ai giữ key. Với một bot review dạng hosted, mọi diff từ mọi repository private đều rời khỏi network của bạn, được ghi vào log của bên thứ ba và chịu chính sách lưu giữ dữ liệu của họ. Trên một VPS (virtual private server) do bạn quản lý, diff đi từ GitHub đến server của bạn rồi đến model API. Bạn có thể đọc 40 dòng code quyết định nội dung nào được gửi đi.

Bạn cần chuẩn bị gì trước khi bắt đầu

  • Một VPS chạy Ubuntu 24.04, đã đăng ký runner GitHub Actions tự host với repository. Khi đăng ký runner, hãy gán thêm label pr-review vì workflow bên dưới chọn runner theo label này.
  • Một API key Anthropic lấy từ Claude Console.
  • Một repository mà bạn kiểm soát được ai có thể mở pull request. Repository private là trường hợp dễ nhất. Phần về fork bên dưới đề cập đến trường hợp public, nhưng cách xử lý trong đó kém an toàn hơn.

Cài reviewer trên VPS

runner service chạy bằng account không có quyền đặc biệt mà bạn đã tạo khi chạy ./svc.sh install. Cài reviewer dưới cùng account đó để job có thể chạy reviewer mà không cần sudo. Thay runner bên dưới bằng tên account của bạn.

sudo apt update && sudo apt install -y gh python3-venv
sudo install -d -m 755 -o runner -g runner /opt/pr-review
sudo -u runner python3 -m venv /opt/pr-review/venv
sudo -u runner /opt/pr-review/venv/bin/pip install anthropic
gh --version

gh --version in gh version 2.45.0 trên Ubuntu 24.04 tính đến tháng 8 năm 2026. Mọi release từ 2.20 trở lên đều có flag --input được dùng bên dưới. Thông báo Command 'gh' not found có nghĩa là component universe chưa được bật. Hãy chạy sudo add-apt-repository universe rồi thử lại.

Vị trí của key và token

Hai secret, hai thời hạn khác nhau. Không secret nào được đặt trong repository.

ANTHROPIC_API_KEY là repository secret, được thiết lập trong Settings, rồi Secrets and variables, rồi Actions. GitHub mã hóa secret này và inject nó vào environment của step khi chạy. Secret này không bao giờ là file trên disk và không bao giờ xuất hiện trong git history.

GITHUB_TOKEN hoạt động khác. Actions tạo một token mới cho mỗi job và hủy token đó khi job kết thúc. Quyền của token được xác định trong block permissions: của workflow. Đây là nơi áp dụng nguyên tắc least privilege:

permissions:
  contents: read
  pull-requests: write

Token đó có thể đăng review. Nó không thể push commit, merge branch, sửa workflow file hoặc truy cập repository khác. Agent có quyền comment là reviewer. Agent có quyền push là committer, và không ai chấp thuận quyền đó. Hãy bảo vệ model key cẩn thận như vậy, vì key này có thể tiêu tiền trong account của bạn. Xem thêm về dạng vấn đề này trong giữ secret ngoài tầm với của AI agent.

Actions thay chuỗi secret chính xác bằng *** trong job log. Actions chỉ khớp chuỗi chính xác. Vì vậy, key được mã hóa bằng base64, chia thành hai dòng hoặc in từng ký tự một sẽ xuất hiện rõ trong log. Không thêm debug step để dump environment.

Tại sao pull request từ fork không bao giờ nhận được API key của bạn

Quy tắc của GitHub rất ngắn gọn: ngoại trừ GITHUB_TOKEN, secrets không được truyền đến runner khi workflow được kích hoạt từ một repository fork. Vì vậy, một lần chạy pull_request từ fork sẽ khởi động script mà không có ANTHROPIC_API_KEY, và lệnh gọi API đầu tiên sẽ thất bại với invalid x-api-key.

Cách sửa dễ nghĩ đến là đổi trigger thành pull_request_target. Trigger này chạy trong context của repository cơ sở và nhận được secrets. Không dùng cách đó ở đây. Hướng dẫn bảo mật của chính GitHub nêu rằng các workflow này “có đặc quyền, nghĩa là chúng dùng chung cache của main branch với các privileged workflow trigger khác, đồng thời có thể có quyền ghi vào repository và quyền truy cập đến các secrets được tham chiếu”, và kết quả “có thể bị khai thác để chiếm quyền kiểm soát repository”.

Hướng dẫn tương tự cũng cảnh báo rõ về runner: “Gần như không bao giờ nên dùng self-hosted runner cho các repository công khai trên GitHub, vì bất kỳ người dùng nào cũng có thể mở pull request đến repository và làm lộ hoặc chiếm quyền kiểm soát môi trường.”

Điều này dẫn đến 2 lựa chọn thiết kế. Job có một guard để chỉ chạy trên các branch được push vào repository của bạn. Workflow cũng hoàn toàn không có bước actions/checkout. Agent không bao giờ có branch trên disk, nên một pull request độc hại chỉ là nội dung được gửi đến model. Nó không thể chạy build script trên VPS của bạn, vì không có gì trên VPS thực thi script đó.

Lấy diff, không lấy cả repository

Một request sẽ lấy toàn bộ diff dưới dạng văn bản thuần.

export GH_TOKEN=your_token   # in the workflow this comes from secrets.GITHUB_TOKEN
gh api /repos/OWNER/REPO/pulls/42 -H "Accept: application/vnd.github.diff"

Media type Accept: application/vnd.github.diff chuyển response từ một JSON object mô tả pull request thành chính unified diff, còn gh api in nguyên vẹn phần body đó. Dòng đầu tiên bạn thấy phải bắt đầu bằng diff --git a/. Mã gh: Not Found (HTTP 404) có nghĩa là token không thể truy cập repository. Với fine-grained personal token, nguyên nhân gần như luôn là quyền Pull requests chưa được bật.

Lọc trước khi tiêu tốn token

Đây là điểm khác biệt giữa một bot được mọi người đọc và một bot bị mọi người tắt thông báo. Mỗi bộ lọc dưới đây chạy trước khi model nhìn thấy dù chỉ một byte.

  • Bộ lọc đường dẫn. Bỏ qua các file lock, thư mục vendored, bundle đã minify và code được generate. Một comment của model trên package-lock.json chỉ tạo nhiễu, và các file đó thường chiếm phần lớn số byte trong diff.
  • Giới hạn kích thước. Nếu vượt quá giới hạn, bỏ qua bước review và thoát với trạng thái green. Một lần refactor 4,000 dòng sẽ nhận được một dòng thông báo rõ ràng rằng kích thước quá lớn để review tự động, thay vì sáu mươi phỏng đoán.
  • Ngưỡng severity và giới hạn số comment. Chỉ báo cáo các finding mức high và medium, tối đa mười finding, theo thứ tự severity giảm dần. Không ai đọc comment thứ mười một.

Script

Lưu nội dung này thành /opt/pr-review/review.py. Script đọc cấu hình từ environment, nên workflow có thể đổi model mà không cần thay đổi code.

#!/usr/bin/env python3
"""Review only the changed lines of one pull request."""
import json
import os
import subprocess
import sys

import anthropic

REPO = os.environ["GITHUB_REPOSITORY"]
PR = os.environ["PR_NUMBER"]
MODEL = os.environ.get("REVIEW_MODEL", "claude-haiku-4-5-20251001")
MAX_DIFF_BYTES = int(os.environ.get("MAX_DIFF_BYTES", "120000"))
MIN_SEVERITY = os.environ.get("MIN_SEVERITY", "medium")
MAX_COMMENTS = 10
RANK = {"low": 0, "medium": 1, "high": 2}
SKIP = ("package-lock.json", "poetry.lock", "/vendor/", "/node_modules/", ".min.js")

raw_diff = subprocess.run(
    ["gh", "api", f"/repos/{REPO}/pulls/{PR}",
     "-H", "Accept: application/vnd.github.diff"],
    check=True, capture_output=True, text=True,
).stdout

Chia diff theo từng file là điều giúp lọc theo path. Đánh số từng dòng là điều giúp comment review trỏ đúng vị trí. GitHub chỉ chấp nhận inline comment trên dòng thuộc diff, nên model phải dẫn đúng số dòng thực tế. Cung cấp sẵn các số dòng giúp model sao chép một số có thật thay vì tự tạo số.

def per_file(diff_text):
    """Split a unified diff into one string per file."""
    sections, current = [], []
    for line in diff_text.splitlines():
        if line.startswith("diff --git ") and current:
            sections.append("\n".join(current))
            current = []
        current.append(line)
    if current:
        sections.append("\n".join(current))
    return sections


def annotate(section):
    """Prefix every line that exists in the new file with its line number."""
    out, n, in_hunk = [], 0, False
    for line in section.splitlines():
        if line.startswith("@@"):
            n = int(line.split("+")[1].split(",")[0].split(" ")[0])
            in_hunk = True
            out.append(line)
        elif not in_hunk or line.startswith(("-", "\\")):
            out.append(line)
        else:
            out.append(f"{n}\t{line}")
            n += 1
    return "\n".join(out)


kept = [s for s in per_file(raw_diff)
        if not any(p in s.split("\n", 1)[0] for p in SKIP)]
payload = "\n".join(annotate(s) for s in kept)

if not payload.strip():
    print("every changed file was filtered out")
    raise SystemExit(0)
if len(payload) > MAX_DIFF_BYTES:
    print(f"diff is {len(payload)} bytes, over the {MAX_DIFF_BYTES} cap")
    raise SystemExit(0)

Hunk header chứa thông tin đánh số dòng. @@ -12,7 +12,9 @@ cho biết hunk của file mới bắt đầu ở dòng 12, nên bộ đếm bắt đầu từ đó và chỉ tăng trên các dòng được thêm hoặc không thay đổi. Các dòng bị xóa đi qua mà không có số, vì chúng không tồn tại trong file mới. Điều kiện kiểm tra các dòng bắt đầu bằng dấu gạch chéo ngược sẽ bỏ qua marker không có dòng mới mà git ghi ở cuối file. Nếu không bỏ qua, marker này sẽ khiến mọi số dòng tiếp theo lệch đi một đơn vị.

Cả hai nhánh thoát đều dùng status 0, không dùng 1. Pull request bị lọc hoặc quá lớn vẫn nên hiển thị check màu xanh. Một check màu đỏ mà con người không thể xử lý sẽ bị bỏ qua. Khi một check bị bỏ qua, tất cả check cũng sẽ bị bỏ qua.

SYSTEM = (
    "You review one pull request diff. Every line that exists in the new file is "
    "prefixed with its line number and a tab character. "
    "Report only defects you can see in the lines shown: a crash, a resource leak, "
    "a security mistake, a wrong boundary condition, a broken contract with code "
    "that is visible in this diff. Do not comment on style, naming or formatting. "
    "Do not guess about code you cannot see. Leave out anything you are not "
    "certain about. An empty findings list is a normal and common answer. "
    'Reply with JSON only, in this shape: {"findings": [{"path": "src/app.py", '
    '"line": 42, "severity": "high", "comment": "what is wrong, then why"}]} '
    "Every line number must be one you can see in the left column of that file."
)

client = anthropic.Anthropic()
message = client.messages.create(
    model=MODEL,
    max_tokens=2000,
    system=SYSTEM,
    messages=[{"role": "user", "content": payload}],
)
print(f"stop={message.stop_reason} in={message.usage.input_tokens} "
      f"out={message.usage.output_tokens}", file=sys.stderr)

text = message.content[0].text
findings = json.loads(text[text.find("{"):text.rfind("}") + 1])["findings"]
findings = [f for f in findings if RANK.get(f["severity"], 0) >= RANK[MIN_SEVERITY]]
findings.sort(key=lambda f: -RANK.get(f["severity"], 0))
del findings[MAX_COMMENTS:]

if not findings:
    print("nothing above the severity threshold; posting no comment")
    raise SystemExit(0)

review = {
    "event": "COMMENT",
    "body": f"Automated review of the changed lines. {len(findings)} finding(s).",
    "comments": [
        {"path": f["path"].removeprefix("b/"), "line": f["line"], "side": "RIGHT",
         "body": f"**{f['severity']}** {f['comment']}"}
        for f in findings
    ],
}
subprocess.run(
    ["gh", "api", "-X", "POST", f"/repos/{REPO}/pulls/{PR}/reviews", "--input", "-"],
    input=json.dumps(review), text=True, check=True,
)

Có ba chi tiết quan trọng trong block đó. JSON được cắt từ { đầu tiên đến } cuối cùng vì đôi khi model bọc câu trả lời trong code fence, còn json.loads sẽ lỗi khi gặp code fence. path bị bỏ tiền tố b/ ở đầu, vì tiền tố đó đến từ diff header và GitHub yêu cầu path tương đối với repository. --input - gửi toàn bộ review trong một API call, nên 10 phát hiện chỉ tạo một notification thay vì 10 notification.

Khi không có gì cần báo cáo, script không gửi bài viết nào. Bot liên tục ghi “không phát hiện vấn đề” trên mọi pull request sẽ khiến mọi người hình thành thói quen lướt qua, rồi cũng lướt qua cả pull request thật sự quan trọng.

Tích hợp vào quy trình

Lưu nội dung này thành .github/workflows/pr-review.yml:

name: pr-review

on:
  pull_request:
    types: [opened, synchronize, reopened]
    paths-ignore:
      - '**.md'
      - 'docs/**'

permissions:
  contents: read
  pull-requests: write

concurrency:
  group: pr-review-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  review:
    if: github.event.pull_request.head.repo.full_name == github.repository && !contains(github.event.pull_request.labels.*.name, 'no-ai-review')
    runs-on: [self-hosted, linux, pr-review]
    steps:
      - name: Review the changed lines
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          REVIEW_MODEL: claude-haiku-4-5-20251001
          MIN_SEVERITY: medium
        run: /opt/pr-review/venv/bin/python /opt/pr-review/review.py

GITHUB_REPOSITORY không nằm trong block env: đó vì Actions đã đặt biến này cho mọi job. Nhóm concurrency rất quan trọng đối với chi phí: nếu không có nhóm này, việc push ba bản sửa nhanh vào một branch sẽ chạy ba lần review đầy đủ và bạn phải trả phí cho cả ba lần. Khi có nhóm này, chỉ lần chạy cuối cùng được giữ lại.

Dòng if: thực hiện hai việc. Phần đầu bỏ qua các pull request từ fork vì chúng sẽ thất bại nếu không có key. Phần thứ hai cung cấp một công tắc tắt cho team: thêm label no-ai-review vào pull request thì job sẽ không chạy.

Mở một pull request và theo dõi kết quả:

gh run list --workflow=pr-review.yml --limit 3
gh run view --log
gh pr view 42 --comments

Một lần chạy hoàn tất trong vài giây và có nothing above the severity threshold; posting no comment trong log là đang hoạt động đúng. Với một pull request nhỏ, không có vấn đề, đó là kết quả mong đợi.

Chi phí review pull request tự động là bao nhiêu?

Diff gần như chiếm toàn bộ input, nên kích thước diff quyết định chi phí. Dưới đây là kết quả đo một diff dài 500 dòng cộng với system prompt, được đếm bằng token counting endpoint thay vì ước tính.

ChartTokens for one 500 line pull request diff, measured August 2026
The data behind this chart
[
  {
    "label": "Haiku 4.5",
    "input_tokens": "8,000",
    "output_tokens": "1,200"
  },
  {
    "label": "Sonnet 5",
    "input_tokens": "10,400",
    "output_tokens": "1,560"
  },
  {
    "label": "Opus 5",
    "input_tokens": "10,400",
    "output_tokens": "1,560"
  }
]

Diff đó có 8,000 input token trên Haiku 4.5 và 10,400 trên Sonnet 5. Cùng một nội dung nhưng số token khác nhau. Các model Claude từ 4.7 trở đi dùng tokenizer mới, tạo ra nhiều hơn khoảng 30% token với cùng một input. Anthropic ghi rõ điều này trên trang giá. Hãy tính đến khác biệt này khi so sánh model mới với model cũ chỉ dựa trên giá mỗi triệu token.

Giá niêm yết tính đến tháng 8 năm 2026: Haiku 4.5 có giá $1 cho mỗi triệu input token và $5 cho mỗi triệu output token. Sonnet 5 có giá lần lượt là $2 và $10 theo mức giá giới thiệu áp dụng đến 31 tháng 8 năm 2026, sau đó tăng lên $3 và $15. Opus 5 có giá $5 và $25.

ChartCost of that one review at list prices, August 2026
The data behind this chart
[
  {
    "label": "Haiku 4.5",
    "cost_per_pr_cents": 1.4,
    "cost_200_prs_usd": "2.80"
  },
  {
    "label": "Sonnet 5",
    "cost_per_pr_cents": 3.64,
    "cost_200_prs_usd": "7.28"
  },
  {
    "label": "Opus 5",
    "cost_per_pr_cents": 9.1,
    "cost_200_prs_usd": "18.20"
  }
]

Chi phí là 1.4 cent cho mỗi pull request trên Haiku 4.5 và 9.1 cent trên Opus 5. Một team merge 200 pull request mỗi tháng sẽ trả khoảng $2.80 trên Haiku 4.5, $7.28 trên Sonnet 5 hoặc $18.20 trên Opus 5. Từ ngày 1 tháng 9 năm 2026, nhân dòng Sonnet 5 với 1.5.

Có hai yếu tố khiến hóa đơn thực tế cao hơn ước tính này. Trigger synchronize review sau mỗi lần push, nên một branch đang hoạt động với tám lần push sẽ tốn cho tám lần review. Quy tắc concurrency chỉ có tác dụng khi các lần push xảy ra gần nhau. Các con số trên cũng giả định path filter hoạt động đúng: chỉ một lock file không được lọc cũng có thể tự làm input tăng gấp đôi.

Prompt caching không giúp được trong trường hợp này. Prefix được cache phải giống hệt từng byte giữa các lần gọi, còn diff thì thay đổi mỗi lần. System prompt là phần duy nhất ổn định, nhưng ngắn hơn nhiều so với độ dài tối thiểu để cache. Xem quy tắc chung tại khi nào prompt caching tự bù chi phí, và xem hướng dẫn chọn giữa ba model trên tại nên dùng model Claude nào cho từng công việc.

Tự đo diff trước khi bật tính năng

Thêm dòng này sau khi payload được tạo, rồi chạy script thủ công với một số pull request của tháng trước:

print(client.messages.count_tokens(
    model=MODEL, system=SYSTEM, messages=[{"role": "user", "content": payload}]
).input_tokens)

Count endpoint không chạy model, nên không tiêu thụ input token hoặc output token. Endpoint này dùng tokenizer tương ứng với model mà bạn chỉ định. Hãy chạy nó trên 10 pull request thực tế trong repository của bạn và lấy median thay vì mean, để một migration rất lớn không làm sai lệch ước tính.

Vì sao bot review bị tắt và cách tránh điều đó

Có hai hành vi làm mất niềm tin vào các bot này. Cả hai đều đã được xử lý trong phần code ở trên.

Review mọi thứ cùng lúc. Một bot để lại bốn mươi comment thì sẽ không ai đọc hết. Ngưỡng mức độ nghiêm trọng và giới hạn mười comment không phải để giữ phép lịch sự. Chúng giúp các phát hiện thực sự quan trọng vẫn được nhìn thấy. Sắp xếp theo mức độ nghiêm trọng trước khi cắt danh sách giúp giới hạn này loại bỏ các phát hiện ít quan trọng nhất thay vì loại ngẫu nhiên mười phát hiện.

Tự tin comment về thứ nó không thể kiểm tra. Đây là nguyên nhân khiến engineer tắt bot vĩnh viễn. Một model chỉ được cung cấp 200 dòng trong một codebase 40,000 dòng vẫn sẽ viết rằng “điều này làm hỏng việc invalidate cache trong redis_client.py” về một file mà nó chưa từng thấy. system prompt yêu cầu rõ ràng: chỉ báo cáo các lỗi có thể nhìn thấy trong những dòng đã cung cấp và bỏ qua mọi điều không chắc chắn. Nêu trực tiếp dạng lỗi hoạt động tốt hơn so với việc chỉ yêu cầu độ chính xác nói chung. Cho model biết rằng kết quả rỗng là bình thường cũng giúp ngăn nó bịa ra vấn đề để comment về một thay đổi chỉ có hai dòng.

Đăng review dưới dạng COMMENT, không bao giờ dùng REQUEST_CHANGES. Ý kiến của model không được phép chặn một merge. Khi có thể chặn merge, người đang có deadline sẽ xóa toàn bộ workflow thay vì tranh luận với bot.

Các dạng lỗi và các chuỗi bạn sẽ thấy

HTTP 422 khi gửi review. gh in gh: Unprocessable Entity (HTTP 422) và phần body của response cho biết field gây lỗi: Pull request review thread line must be part of the diff. GitHub không thể gắn comment đó vào diff. Nguyên nhân thường là số dòng do model tự tạo, path vẫn còn tiền tố b/, hoặc comment trên một dòng đã bị xóa; trường hợp này cần đặt side thành LEFT thay vì RIGHT. In JSON của review trước khi gửi và kiểm tra thủ công một comment với diff.

invalid x-api-key từ model API. Bước này fail ngay ở lần gọi messages.create đầu tiên. Hoặc secret ANTHROPIC_API_KEY chưa được đặt trên repository, hoặc pull request đến từ fork nên Actions không truyền secret nào. Fork guard trong dòng if: đáng lẽ phải bỏ qua bước này, vì vậy hãy kiểm tra dòng đó trước.

gh: Resource not accessible by integration (HTTP 403). Token của job không thể ghi vào pull request. Thêm pull-requests: write vào block permissions:. Nếu đã có, vào Settings, sau đó chọn Actions rồi General. Tại đây, policy của organisation có thể giới hạn những quyền mà workflow token được phép yêu cầu.

json.decoder.JSONDecodeError. Model không trả về JSON có thể parse. Nguyên nhân thường gặp là response chạm giới hạn token và dừng giữa object. Dòng log in stop_reason cho đúng trường hợp này: giá trị max_tokens nghĩa là cần tăng max_tokens hoặc giảm MAX_COMMENTS.

Workflow không bao giờ chạy. gh run list không hiển thị gì cho pull request. Kiểm tra xem paths-ignore có lọc hết các file đã thay đổi hay không, sau đó kiểm tra fork guard và label guard, rồi kiểm tra runner còn hoạt động bằng sudo systemctl status 'actions.runner.*' trên VPS. Runner offline khiến job nằm ở trạng thái queued mà không có thông báo lỗi nào trong pull request.

Mọi review đều trả về rỗng. Đặt MIN_SEVERITY thành low cho một lần chạy. Nếu có finding xuất hiện, threshold đang hoạt động đúng. Nếu vẫn không có gì, in payload và xác nhận các filter không loại bỏ toàn bộ diff.

Chạy cùng với các agent khác

Reviewer có kích thước nhỏ nên bạn có thể muốn đặt nó trên máy đã chạy mọi thứ khác. Nhưng nếu repository quan trọng, hãy tách riêng nó. Process này giữ một token có thể bình luận trên code của bạn và một key có thể chi tiền của bạn. Self-hosted runner vốn được thiết kế để thực thi code của workflow. Một account không có quyền sudo riêng trên host không chạy gì khác là mức cơ bản. Nếu bạn cũng chạy các agent tương tác có checkout code, mỗi agent một VM tạm thời là mô hình phù hợp để duy trì. Chạy coding agent trên VPS trình bày thiết lập tổng quát. Nếu Anthropic API còn mới với bạn, Ứng dụng Claude API đầu tiên trên VPS là điểm bắt đầu đơn giản hơn phần này.

FAQ

Agent AI review PR có cần quyền ghi vào repository của tôi không?

Không. Agent cần pull-requests: write để đăng review và contents: read để lấy diff. Đó là toàn bộ danh sách quyền, và bạn đặt các quyền này trong block permissions: của workflow. Block này giới hạn những gì GITHUB_TOKEN của từng job có thể thực hiện. Với hai dòng đó, agent có thể comment trên pull request nhưng không thể push commit hoặc merge branch. Hãy đăng review bằng event: COMMENT thay vì REQUEST_CHANGES để agent cũng không thể chặn merge.

Tại sao comment review của tôi bị lỗi HTTP 422?

GitHub chỉ chấp nhận inline review comment trên một dòng thuộc diff của pull request. Nếu không, GitHub trả về Pull request review thread line must be part of the diff. Hãy kiểm tra rằng path là đường dẫn tương đối với repository và không có tiền tố b/ trong diff header. Số dòng cũng phải nằm trong một hunk của file đó. side phải là RIGHT đối với dòng được thêm hoặc không thay đổi, và LEFT đối với dòng bị xóa. Việc thêm số dòng của file mới vào trước mọi dòng trong diff trước khi gửi cho model sẽ ngăn model tự tạo số dòng ngay từ đầu.

Tôi có thể chạy cách này trên repository public có pull request từ fork không?

Không với thiết kế này. GitHub không truyền secrets cho workflow được kích hoạt từ fork, nên model key bị thiếu và lần chạy sẽ fail. GitHub cũng nêu rõ rằng self-hosted runner “hầu như không bao giờ nên được dùng cho repository public”, vì bất kỳ ai cũng có thể mở pull request khiến code chạy trên máy của bạn. Với project public, bạn có thể chỉ cho phép reviewer xử lý các branch được push trực tiếp vào repository. Đây là chức năng của guard if:. Hoặc bạn chuyển bước review sang GitHub-hosted runner và chấp nhận rằng diff sẽ rời khỏi hạ tầng của bạn.

Tôi nên dùng model nào để review pull request?

Hãy bắt đầu với Haiku 4.5. Đọc một diff có giới hạn và đối chiếu với danh sách cố định các loại lỗi không phải là bài toán cần suy luận khó. Model rẻ nhất giúp giữ hóa đơn hằng tháng ở mức hợp lý. Chuyển lên Sonnet 5 nếu bạn phát hiện model bỏ sót lỗi thực tế trong ngôn ngữ hoặc framework của mình, và hãy đo lường kết quả thay vì mặc định như vậy. Trong ba model, Opus 5 có chi phí trên mỗi pull request cao hơn rất nhiều. Vì vậy, model này phù hợp để biện minh cho review trên release branch hơn là chạy ở mọi lần push vào mọi feature branch.