SSD Nodes Learn 🎉 VPS $4.99/월부터
가이드 Matt Connor작성자 Matt Connor · 업데이트됨 2026-08-07

VPS에서 AI PR 리뷰 에이전트 직접 호스팅하기

자체 VPS에서 AI PR 리뷰 에이전트를 운영하는 방법을 설명합니다. GitHub Actions 러너 설정부터 diff 범위 필터링, 인라인 댓글 작성, API 비용 최적화까지 보안을 유지하며 코드를 자동 검토하는 전체 과정을 단계별로 안내합니다.

자체 호스팅 PR 리뷰 에이전트의 역할

자체 호스팅 PR 리뷰 에이전트는 사용자가 소유한 서버에서 실행되는 작은 프로그램입니다. 이 프로그램은 풀 리퀘스트(PR)의 diff를 읽고 변경된 라인만 모델로 전송합니다. 모델이 반환한 결과는 인라인 리뷰 댓글로 게시됩니다. 이 에이전트는 브랜치를 체크아웃하지 않으며, 풀 리퀘스트에서 수정되지 않은 파일은 읽지 않습니다. 에이전트가 보유한 자격 증명은 모델 API(application programming interface) 키 하나와 댓글 작성 권한만 가진 토큰 하나가 전부입니다.

모델은 diff를 읽을 수 있습니다. 이 부분은 이미 해결된 문제입니다. 중요한 것은 diff가 어디로 전송되는지, 그리고 누가 키를 보유하는지입니다. 호스팅형 리뷰 봇을 사용하면 모든 비공개 저장소의 diff가 네트워크 외부로 유출되어 제3자의 로그에 기록되며, 해당 업체의 보관 정책에 따라 저장됩니다. 사용자가 소유한 VPS(virtual private server)를 사용하면 diff는 GitHub에서 사용자의 서버로, 다시 모델 API로 전달됩니다. 또한 무엇이 전송되는지 결정하는 40줄 정도의 코드를 직접 확인할 수 있습니다.

시작하기 전에 필요한 것

  • Ubuntu 24.04가 실행 중이며 자체 호스팅 GitHub Actions 러너가 이미 리포지토리에 등록된 VPS. 아래 워크플로우가 해당 레이블을 기준으로 선택하므로, 등록 시 pr-review 레이블을 추가로 부여하십시오.
  • Claude Console에서 발급받은 Anthropic API 키.
  • 풀 리퀘스트를 열 수 있는 사용자를 제어할 수 있는 리포지토리. 비공개 리포지토리가 가장 간단합니다. 아래 포크 섹션에서 공개 리포지토리의 경우를 다루지만, 해당 방식은 다소 불편할 수 있습니다.

VPS에 reviewer 설치하기

runner 서비스는 ./svc.sh install 실행 시 생성한 권한이 없는 계정으로 동작합니다. 작업이 sudo 없이 실행될 수 있도록 동일한 계정으로 reviewer를 설치하십시오. 아래의 runner을 본인의 계정명으로 바꾸십시오.

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는 2026년 8월 기준 Ubuntu 24.04에서 gh version 2.45.0를 출력합니다. 2.20 이후의 모든 릴리스는 아래에서 사용하는 --input 플래그를 포함합니다. Command 'gh' not found 메시지가 나타나면 universe 구성 요소가 활성화되지 않은 것이므로, sudo add-apt-repository universe을 실행한 뒤 다시 시도하십시오.

키와 토큰의 위치

두 가지 비밀 정보는 수명이 서로 다릅니다. 어느 것도 저장소에 포함해서는 안 됩니다.

ANTHROPIC_API_KEY는 저장소 비밀 정보로, Settings의 Secrets and variables, 그리고 Actions 항목에서 설정합니다. GitHub는 이를 암호화하여 런타임에 단계별 환경 변수로 주입합니다. 이는 디스크상의 파일로 존재하지 않으며 git 기록에도 남지 않습니다.

GITHUB_TOKEN의 작동 방식은 다릅니다. Actions는 각 작업마다 새로운 토큰을 생성하고 작업이 종료되면 이를 폐기합니다. 이 토큰의 권한 범위는 워크플로우 내의 permissions: 블록에서 설정하므로, 여기서 최소 권한 원칙이 실제로 구현됩니다.

permissions:
  contents: read
  pull-requests: write

이 토큰은 리뷰를 게시할 수 있습니다. 커밋을 푸시하거나, 브랜치를 병합하거나, 워크플로우 파일을 수정하거나, 다른 저장소에 접근할 수는 없습니다. 댓글을 달 수 있는 에이전트는 리뷰어일 뿐입니다. 푸시 권한이 있는 에이전트는 커미터이며, 아무도 그러한 권한을 부여하기로 합의한 적이 없습니다. 모델 키는 계정의 비용을 발생시키므로 동일하게 주의해서 다루어야 합니다. 이러한 문제 유형에 대한 자세한 내용은 AI 에이전트가 비밀 정보에 접근하지 못하도록 관리하기에서 확인할 수 있습니다.

Actions는 작업 로그에서 정확히 일치하는 비밀 문자열을 ***로 대체합니다. 이는 정확히 일치하는 문자열에 대해서만 작동하므로, base64로 인코딩하거나, 두 줄로 나누거나, 한 글자씩 출력하는 키는 그대로 노출됩니다. 환경 변수를 덤프하는 디버그 단계를 추가하지 마십시오.

포크된 저장소에서 보낸 풀 리퀘스트에 API 키가 포함되지 않는 이유

GitHub의 규칙은 간단합니다. GITHUB_TOKEN을 제외하고, 포크된 저장소에서 워크플로우가 트리거될 때는 시크릿이 러너(runner)로 전달되지 않습니다. 따라서 포크에서 실행되는 pull_requestANTHROPIC_API_KEY 없이 스크립트를 시작하며, 첫 번째 API 호출은 invalid x-api-key 오류와 함께 실패합니다.

이를 해결하기 위해 트리거를 pull_request_target로 변경하고 싶은 유혹이 들 수 있습니다. 이 방식은 기본 저장소의 컨텍스트에서 실행되므로 시크릿을 전달받을 수 있기 때문입니다. 하지만 여기서는 그렇게 하지 마십시오. GitHub의 보안 지침에 따르면, 이러한 워크플로우는 "권한이 부여된 상태이며, 이는 다른 권한 있는 워크플로우 트리거와 메인 브랜치의 캐시를 공유하고, 저장소 쓰기 권한 및 참조된 시크릿에 접근할 수 있음을 의미한다"고 명시되어 있습니다. 또한 그 결과로 "저장소를 탈취하는 데 악용될 수 있다"고 경고합니다.

같은 지침은 러너에 대해서도 단호하게 말합니다. "GitHub의 공개 저장소에는 자체 호스팅 러너(self-hosted runner)를 절대 사용해서는 안 됩니다. 어떤 사용자든 저장소에 풀 리퀘스트를 열어 환경을 침해할 수 있기 때문입니다."

이러한 이유로 두 가지 설계 원칙을 따릅니다. 첫째, 작업(job)에 가드(guard)를 설정하여 본인의 저장소로 푸시된 브랜치에서만 실행되도록 합니다. 둘째, 워크플로우에 actions/checkout 단계를 아예 포함하지 않습니다. 에이전트는 디스크에 해당 브랜치를 저장하지 않으므로, 악의적인 풀 리퀘스트는 모델로 전송되는 텍스트일 뿐입니다. VPS에서 아무것도 실행되지 않으므로, 공격자가 VPS에서 빌드 스크립트를 실행할 방법은 없습니다.

저장소 전체가 아닌 diff만 가져오기

단일 요청으로 전체 diff를 일반 텍스트로 가져올 수 있습니다.

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"

Accept: application/vnd.github.diff 미디어 타입을 사용하면 풀 리퀘스트를 설명하는 JSON 객체 대신 unified diff 본문을 직접 응답받을 수 있으며, gh api은 해당 본문을 변경 없이 출력합니다. 출력의 첫 번째 줄은 diff --git a/로 시작해야 합니다. gh: Not Found (HTTP 404)가 표시된다면 토큰이 저장소에 접근할 수 없다는 의미이며, 세분화된 개인 토큰(fine-grained personal token)의 경우 거의 항상 Pull requests 권한이 누락되었기 때문입니다.

토큰을 소비하기 전 필터링

이 섹션은 사용자가 읽는 봇과 차단하는 봇을 가르는 기준입니다. 아래의 각 필터는 모델이 단 1바이트라도 처리하기 전에 실행됩니다.

  • 경로 필터. 잠금 파일(lock files), 벤더 디렉터리(vendored directories), 축소된 번들(minified bundles) 및 생성된 코드를 제외합니다. package-lock.json에 대한 모델의 코멘트는 순수한 노이즈이며, 이러한 파일들은 대개 diff에서 가장 많은 바이트를 차지합니다.
  • 크기 제한. 제한을 초과하면 검토를 건너뛰고 정상 종료합니다. 4,000줄 규모의 리팩토링에 대해 60개의 추측성 코멘트를 남기는 대신, 너무 커서 자동 검토가 불가능하다는 정직한 메시지 한 줄을 남깁니다.
  • 심각도 임계값 및 코멘트 제한. 높음 및 중간 수준의 발견 사항을 심각도가 높은 순서대로 최대 10개까지만 보고합니다. 11번째 코멘트부터는 아무도 읽지 않습니다.

스크립트

이 내용을 /opt/pr-review/review.py로 저장합니다. 스크립트는 환경 변수에서 설정을 읽어오므로, 코드 변경 없이도 워크플로에서 모델을 교체할 수 있습니다.

#!/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

파일별로 diff를 분할해야 경로 필터링이 가능합니다. 각 줄에 번호를 매겨야 리뷰 코멘트를 정확한 위치에 남길 수 있습니다. GitHub는 diff에 포함된 줄에만 인라인 코멘트를 허용하므로, 모델은 실제 줄 번호를 인용해야 합니다. 번호를 직접 전달하면 모델이 번호를 지어내는 대신 기존 번호를 복사할 수 있습니다.

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 헤더에 번호 정보가 포함됩니다. @@ -12,7 +12,9 @@는 새 파일의 hunk가 12번째 줄부터 시작함을 나타내며, 카운터는 여기서 시작하여 추가된 줄과 변경되지 않은 줄에서만 증가합니다. 삭제된 줄은 새 파일에 존재하지 않으므로 번호를 매기지 않고 건너뜁니다. 백슬래시로 시작하는 줄에 대한 가드(guard)는 git이 파일 끝에 기록하는 줄 바꿈 없음(no-newline) 마커를 건너뜁니다. 이 마커를 처리하지 않으면 이후의 모든 번호가 1씩 밀리게 됩니다.

두 종료 지점 모두 1이 아닌 상태 코드 0을 사용합니다. 필터링되었거나 크기가 초과된 풀 리퀘스트라도 녹색 체크 표시가 나타나야 합니다. 사람이 조치할 수 없는 빨간색 체크 표시는 무시되기 마련이며, 한 번 무시하기 시작하면 모든 체크 표시가 무시됩니다.

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,
)

해당 블록에서 세 가지 세부 사항이 핵심적인 역할을 합니다. 모델이 답변을 코드 블록으로 감싸는 경우가 있는데, json.loads은 이 코드 블록을 처리하지 못하므로 JSON을 첫 번째 {과 마지막 } 사이에서 잘라냅니다. path는 앞부분의 b/을 제거합니다. 해당 접두사는 diff 헤더에서 오지만, GitHub는 저장소 기준 상대 경로를 요구하기 때문입니다. 마지막으로 --input -은 전체 리뷰를 하나의 API 호출로 전송하므로, 10개의 발견 사항이 10개의 알림이 아닌 하나의 알림으로 전달됩니다.

보고할 내용이 없으면 스크립트는 아무것도 게시하지 않습니다. 모든 풀 리퀘스트에 "발견된 문제 없음"이라고 작성하는 봇은 사람들이 내용을 훑어보고 넘기게 만들며, 결국 중요한 알림까지 무시하게 됩니다.

워크플로우에 연결하기

이 내용을 .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은 Actions가 모든 작업에 대해 이미 설정하므로 해당 env: 블록에 포함되지 않습니다. concurrency 그룹은 비용과 관련이 있습니다. 이 설정이 없으면 브랜치에 3번의 빠른 수정 사항을 푸시할 때 3번의 전체 검토가 실행되어 모두 비용이 청구되지만, 설정이 있으면 마지막 작업만 유지됩니다.

if: 라인은 두 가지 역할을 합니다. 앞부분은 포크된 저장소에서 오는 풀 리퀘스트를 건너뜁니다. 어차피 키가 없으면 실패할 작업입니다. 뒷부분은 팀을 위한 중단 스위치를 제공합니다. 풀 리퀘스트에 no-ai-review 라벨을 추가하면 해당 작업은 실행되지 않습니다.

풀 리퀘스트를 열고 어떤 일이 일어나는지 확인합니다:

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

로그에 nothing above the severity threshold; posting no comment이 표시되며 몇 초 만에 완료되는 실행은 정상적으로 작동하는 것입니다. 작고 깔끔한 풀 리퀘스트에서는 이것이 예상되는 결과입니다.

자동화된 풀 리퀘스트 리뷰 비용은 얼마입니까?

diff는 입력값의 대부분을 차지하므로, diff의 크기가 가격을 결정합니다. 아래는 500줄의 diff와 시스템 프롬프트를 포함한 측정값이며, 추정치가 아닌 토큰 계수 엔드포인트를 사용하여 계산했습니다.

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는 Haiku 4.5 모델에서 8,000개의 입력 토큰, Sonnet 5 모델에서 10,400개의 입력 토큰을 사용했습니다. 동일한 텍스트라도 모델에 따라 토큰 수가 다릅니다. 4.7 버전 이후의 Claude 모델은 더 새로운 토크나이저를 사용하며, 이는 Anthropic의 가격 페이지에 명시된 대로 동일한 입력에 대해 약 30% 더 많은 토큰을 생성합니다. 백만 토큰당 가격만으로 모델을 비교할 때는 항상 이 점을 고려하십시오.

2026년 8월 기준 정가는 다음과 같습니다. Haiku 4.5는 입력 토큰 백만 개당 $1, 출력 토큰 백만 개당 $5입니다. Sonnet 5는 2026년 8월 31일까지 적용되는 도입가로 각각 $2와 $10이며, 이후에는 $3와 $15로 변경됩니다. Opus 5는 각각 $5와 $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"
  }
]

이는 Haiku 4.5 모델에서 풀 리퀘스트당 1.4 센트, Opus 5 모델에서 9.1 센트의 비용이 발생함을 의미합니다. 한 달에 200개의 풀 리퀘스트를 병합하는 팀의 경우 Haiku 4.5 사용 시 약 $2.80, Sonnet 5 사용 시 $7.28, Opus 5 사용 시 $18.20가 소요됩니다. 2026년 9월 1일부터는 Sonnet 5의 비용에 1.5를 곱하십시오.

실제 청구 금액이 이 추정치를 상회하게 만드는 두 가지 요인이 있습니다. synchronize는 푸시가 발생할 때마다 리뷰를 트리거하므로, 8번의 푸시가 발생한 활성 브랜치는 8번의 리뷰 비용이 발생하며, 동시성 규칙은 푸시가 짧은 간격으로 발생할 때만 비용 절감 효과가 있습니다. 또한 이 수치는 경로 필터가 정상 작동함을 가정합니다. 필터링되지 않은 lock 파일 하나만으로도 입력 토큰이 두 배로 늘어날 수 있습니다.

프롬프트 캐싱은 이 경우 도움이 되지 않습니다. 캐시된 접두사는 호출 간에 바이트 단위로 동일해야 하지만, diff는 매번 달라지기 때문입니다. 시스템 프롬프트만이 유일하게 고정된 부분이지만, 이는 최소 캐시 가능 길이보다 훨씬 작습니다. 일반적인 규칙은 프롬프트 캐싱이 비용 효율적인 경우를 참조하고, 위 세 모델 중 선택하는 방법은 작업별 적합한 Claude 모델 선택을 확인하십시오.

기능을 활성화하기 전에 직접 diff를 측정하십시오

payload가 빌드된 후 아래 줄을 추가하고, 지난달의 풀 리퀘스트 몇 개를 대상으로 스크립트를 직접 실행해 보십시오:

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

계수 엔드포인트는 모델을 실행하지 않으므로 입력 또는 출력 토큰을 소비하지 않으며, 지정한 모델에 해당하는 토크나이저를 사용합니다. 실제 저장소의 풀 리퀘스트 10개를 대상으로 실행한 뒤 평균이 아닌 중앙값을 취하십시오. 그래야 대규모 마이그레이션 작업 하나가 추정치를 왜곡하는 것을 방지할 수 있습니다.

리뷰 봇이 차단되는 이유와 방지 방법

두 가지 행동이 봇에 대한 신뢰를 떨어뜨리며, 위 코드에는 두 가지 모두에 대한 해결책이 포함되어 있습니다.

모든 내용을 한꺼번에 리뷰하는 경우. 40개의 댓글을 남기는 봇은 그중 하나도 읽히지 않습니다. 심각도 임계값과 10개 댓글 제한은 예의 차원이 아니라, 실제 중요한 발견 사항을 눈에 띄게 유지하기 위한 장치입니다. 자르기 전에 심각도순으로 정렬하면, 무작위로 10개를 남기는 대신 가장 중요도가 낮은 발견 사항부터 제외할 수 있습니다.

확인할 수 없는 내용에 대해 확신을 가지고 댓글을 다는 경우. 이는 엔지니어가 봇을 영구적으로 비활성화하게 만드는 주된 원인입니다. 40,000줄의 코드베이스 중 200줄만 본 모델도 본 적 없는 파일에 대해 "이것이 redis_client.py의 캐시 무효화를 깨뜨린다"와 같은 의견을 작성합니다. 시스템 프롬프트는 이를 명확한 언어로 제어합니다. 즉, 표시된 코드 줄에서 확인 가능한 결함만 보고하고, 확신할 수 없는 내용은 제외하도록 지시합니다. 실패 사례를 직접적으로 명시하는 것이 일반적인 정확도를 요구하는 것보다 효과적이며, 빈 결과가 나오는 것이 정상임을 모델에게 알리면 2줄짜리 변경 사항에 대해 억지로 의견을 지어내는 일을 방지할 수 있습니다.

리뷰는 REQUEST_CHANGES이 아닌 COMMENT로 게시해야 합니다. 모델의 의견이 머지를 차단해서는 안 됩니다. 만약 모델이 머지를 차단할 수 있게 되는 순간, 마감 기한에 쫓기는 개발자는 봇과 실랑이를 벌이기보다 워크플로우 전체를 삭제해 버릴 것입니다.

실패 유형 및 확인 가능한 메시지

리뷰 게시 시 HTTP 422 오류 발생. ghgh: Unprocessable Entity (HTTP 422)를 출력하고 응답 본문에 필드 이름이 Pull request review thread line must be part of the diff으로 표시됩니다. GitHub는 해당 코멘트를 고정할 수 없습니다. 일반적인 원인은 모델이 생성한 잘못된 줄 번호, 여전히 b/ 접두사가 붙어 있는 path, 또는 삭제된 줄에 대한 코멘트입니다. 삭제된 줄의 경우 sideRIGHT 대신 LEFT으로 설정해야 합니다. 게시 전 리뷰 JSON을 출력하여 diff와 비교해 코멘트 하나를 수동으로 확인하십시오.

모델 API에서 invalid x-api-key 발생. 첫 번째 messages.create 호출에서 단계가 실패합니다. 저장소에 ANTHROPIC_API_KEY 시크릿이 설정되지 않았거나, 포크(fork)에서 생성된 풀 리퀘스트라 Actions에 시크릿이 전혀 전달되지 않은 경우입니다. if: 라인의 포크 가드가 이를 건너뛰었어야 하므로 해당 라인을 먼저 확인하십시오.

gh: Resource not accessible by integration (HTTP 403). 작업 토큰이 풀 리퀘스트에 쓰기 권한을 갖지 못했습니다. permissions: 블록에 pull-requests: write을 추가하십시오. 이미 추가되어 있다면 Settings, Actions, General 순으로 이동하여 조직 정책이 워크플로우 토큰의 요청 권한을 제한하고 있는지 확인하십시오.

json.decoder.JSONDecodeError. 모델이 파싱 가능한 JSON을 반환하지 않았습니다. 흔한 원인은 응답이 토큰 제한에 도달하여 객체 중간에 끊긴 경우입니다. 로그 라인에 stop_reason이 출력된다면 정확히 이 문제입니다. max_tokens 값은 max_tokens를 높이거나 MAX_COMMENTS을 낮춰야 함을 의미합니다.

워크플로우가 전혀 실행되지 않음. gh run list에 풀 리퀘스트 관련 내용이 표시되지 않습니다. paths-ignore가 변경된 모든 파일을 필터링하지 않았는지 확인하고, 포크 가드와 라벨 가드를 점검하십시오. 그 후 VPS에서 sudo systemctl status 'actions.runner.*'을 통해 러너가 활성화되어 있는지 확인하십시오. 러너가 오프라인 상태이면 풀 리퀘스트 어디에도 오류 메시지 없이 작업이 대기열에 남게 됩니다.

모든 리뷰가 빈 상태로 반환됨. 한 번의 실행 동안 MIN_SEVERITYlow로 설정하십시오. 결과가 나타난다면 임계값이 정상적으로 작동하는 것입니다. 아무것도 나타나지 않는다면 payload를 출력하여 필터가 전체 diff를 제거하지 않았는지 확인하십시오.

다른 에이전트와 함께 실행하기

리뷰어는 크기가 작기 때문에 이미 다른 모든 것을 실행 중인 서버에 함께 배치하고 싶은 유혹이 생길 수 있습니다. 하지만 저장소가 중요하다면 분리된 상태를 유지하십시오. 이 프로세스는 코드에 댓글을 달 수 있는 토큰과 비용을 지출할 수 있는 키를 보유하며, 자체 호스팅 러너는 설계상 워크플로우 코드가 실행되는 공간입니다. sudo 권한이 없는 전용 비권한 계정을 사용하고, 다른 아무것도 실행하지 않는 호스트에서 운영하는 것이 기본 원칙입니다. 코드를 체크아웃하는 대화형 에이전트를 함께 실행한다면 에이전트당 일회용 VM을 사용하는 패턴이 가장 안전하며, VPS에서 코딩 에이전트 실행하기를 통해 일반적인 설정을 확인할 수 있습니다. Anthropic API가 처음이라면, 이 작업보다 규모가 작은 VPS에서 첫 Claude API 앱 만들기부터 시작하는 것이 좋습니다.

FAQ

AI PR 리뷰 에이전트가 내 저장소에 대한 쓰기 권한이 필요한가요?

아니요. 리뷰를 게시하기 위한 pull-requests: write와 diff를 가져오기 위한 contents: read만 있으면 됩니다. 이것이 전체 권한 목록이며, 워크플로우의 permissions: 블록에서 설정하여 작업별 GITHUB_TOKEN가 수행할 수 있는 범위를 제한합니다. 이 두 줄만 있으면 에이전트는 풀 리퀘스트에 댓글을 달 수 있지만, 커밋을 푸시하거나 브랜치를 병합할 수는 없습니다. REQUEST_CHANGES 대신 event: COMMENT를 사용하여 리뷰를 게시하면 병합을 차단하는 권한도 갖지 않게 됩니다.

왜 리뷰 댓글이 HTTP 422 오류로 실패하나요?

GitHub는 풀 리퀘스트 diff에 포함된 라인에 대해서만 인라인 리뷰 댓글을 허용하며, 그렇지 않은 경우 Pull request review thread line must be part of the diff를 반환합니다. path가 diff 헤더의 b/ 접두사를 제외한 저장소 상대 경로인지, 그리고 해당 라인 번호가 파일의 hunk 내에 존재하는지 확인하십시오. side는 추가되거나 변경되지 않은 라인의 경우 RIGHT이어야 하며, 삭제된 라인의 경우 LEFT이어야 합니다. 모델에 diff를 보내기 전에 모든 라인 앞에 새 파일의 라인 번호를 붙이면 모델이 임의로 번호를 생성하는 문제를 방지할 수 있습니다.

포크(fork)에서 발생한 풀 리퀘스트가 있는 공개 저장소에서도 실행할 수 있나요?

현재 설계로는 불가능합니다. GitHub는 포크에서 트리거된 워크플로우에 시크릿을 전달하지 않으므로 모델 키가 누락되어 실행이 실패합니다. 또한 GitHub는 누구나 코드를 실행하게 만드는 풀 리퀘스트를 열 수 있기 때문에 자체 호스팅 러너를 "공개 저장소에 사용하는 것은 거의 권장하지 않는다"고 명시합니다. 공개 프로젝트의 경우, 리뷰어를 저장소 자체로 푸시된 브랜치로 제한하거나(if: 가드가 수행하는 작업), 리뷰 단계를 GitHub 호스팅 러너로 옮기고 diff가 내부 인프라를 벗어나는 것을 허용해야 합니다.

풀 리퀘스트 리뷰에는 어떤 모델을 사용해야 하나요?

Haiku 4.5로 시작하십시오. 고정된 결함 유형 목록에 대해 제한된 diff를 읽는 것은 어려운 추론 문제가 아니며, 가장 저렴한 모델을 사용해야 월간 비용을 합리적인 수준으로 유지할 수 있습니다. 사용 중인 언어나 프레임워크에서 실제 버그를 놓치는 경우가 발견되면 Sonnet 5로 상향 조정하되, 추측보다는 측정 결과를 바탕으로 결정하십시오. Opus 5는 세 모델 중 풀 리퀘스트당 비용이 월등히 높으므로, 모든 기능 브랜치의 푸시마다 사용하기보다는 릴리스 브랜치에서 사용하는 것이 비용 정당성을 확보하기 쉽습니다.