VPS에서 AI PR 리뷰 에이전트 직접 구축하기
자체 서버에서 AI PR 리뷰어를 운영하는 방법을 소개합니다. diff 범위 설정, 파일 필터링, 인라인 댓글 작성 기능을 포함하며 데이터 유출 없이 안전하게 코드 리뷰를 자동화하는 과정을 단계별로 설명합니다.
자체 호스팅 PR 리뷰 에이전트의 역할
자체 호스팅 PR 검토 에이전트는 사용자가 소유한 서버에서 실행하는 작은 프로그램이다. 이 프로그램은 pull request(PR)의 diff를 읽고 변경된 줄만 모델에 전송한다. 모델의 응답은 인라인 검토 댓글로 게시된다. 이 프로그램은 브랜치를 checkout하지 않으며, pull request에서 변경하지 않은 파일을 읽지도 않는다. 프로그램이 보유하는 자격 증명은 모델 API(application programming interface) 키 1개와 댓글 작성만 가능하고 다른 작업은 할 수 없는 토큰 1개뿐이다. 이는 에이전트를 좁게 정의한 것이다. 자율적으로 동작하는 시스템이라기보다 앞단에 필터를 둔 프롬프트에 가깝다. 이 에이전트를 만들기 전에 전체 구조를 먼저 파악하려면 개념부터 직접 작성하는 루프까지의 단계적 경로에서 그 기반 내용을 다룬다.
모델은 diff를 읽을 수 있습니다. 그 부분은 이미 해결되었습니다. 중요한 것은 diff가 어디로 가는지, 그리고 누가 키를 보유하는지입니다. 호스팅형 리뷰 봇을 사용하면 모든 비공개 저장소의 diff가 네트워크를 벗어나 제3자의 로그에 저장되며, 해당 업체의 보관 정책을 따르게 됩니다. 사용자가 소유한 VPS(가상 사설 서버)에서는 diff가 GitHub에서 사용자의 서버로, 다시 모델 API로 전달됩니다. 또한 무엇을 전송할지 결정하는 40줄 정도의 코드를 직접 읽고 확인할 수 있습니다.
시작하기 전에 필요한 것
- Ubuntu 24.04가 실행 중인 VPS와 해당 리포지토리에 이미 등록된 자체 호스팅 GitHub Actions runner가 필요합니다. 아래 워크플로우가 해당 레이블을 기준으로 선택하므로, 등록 시
pr-review레이블을 추가로 지정하십시오. - Claude Console에서 발급받은 Anthropic API 키가 필요합니다.
- 풀 리퀘스트를 생성할 수 있는 사용자를 제어할 수 있는 리포지토리가 필요합니다. 비공개 리포지토리가 가장 간단한 경우입니다. 아래 포크(fork) 섹션에서 공개 리포지토리의 경우를 다루지만, 해당 방식은 다소 번거로울 수 있습니다.
VPS에 리뷰어 설치하기
러너 서비스는 ./svc.sh install 실행 시 생성한 권한이 없는 계정으로 실행됩니다. 작업이 sudo 없이 실행될 수 있도록 동일한 계정으로 리뷰어를 설치하십시오. 아래의 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 --versiongh --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_request는 ANTHROPIC_API_KEY 없이 스크립트를 시작하며, 첫 번째 API 호출은 invalid x-api-key 오류와 함께 실패합니다.
이를 해결하기 위해 트리거를 pull_request_target로 변경하고 싶은 유혹이 들 수 있습니다. 이 방식은 베이스 저장소의 컨텍스트에서 실행되므로 시크릿에 접근할 수 있기 때문입니다. 하지만 여기서는 그렇게 하지 마십시오. GitHub의 보안 지침에 따르면 이러한 워크플로우는 "권한이 부여된 상태이며, 이는 다른 권한 있는 워크플로우 트리거와 메인 브랜치의 캐시를 공유하고, 저장소 쓰기 권한 및 참조된 시크릿에 접근할 수 있음을 의미한다"고 명시되어 있으며, 그 결과가 "저장소를 탈취하는 데 악용될 수 있다"고 경고합니다.
같은 지침은 러너에 대해서도 단호하게 말합니다. "GitHub의 공개 저장소에는 자체 호스팅 러너(self-hosted runner)를 거의 사용하지 말아야 합니다. 누구나 저장소에 풀 리퀘스트를 열어 환경을 침해할 수 있기 때문입니다."
이러한 이유로 두 가지 설계 원칙이 도출됩니다. 첫째, 작업(job)에 가드(guard)를 설정하여 자신의 저장소로 푸시된 브랜치에서만 실행되도록 합니다. 둘째, 워크플로우에는 actions/checkout 단계가 전혀 없습니다. 에이전트는 디스크에 해당 브랜치를 저장하지 않으므로, 악의적인 풀 리퀘스트는 모델로 전송되는 텍스트일 뿐입니다. VPS에서 빌드 스크립트를 실행할 수 없는데, VPS의 그 무엇도 해당 스크립트를 실행하지 않기 때문입니다. 하지만 텍스트가 곧 무해함을 의미하지는 않습니다. 낯선 사람이 작성한 diff는 모델에 입력되는 신뢰할 수 없는 데이터이며, 이는 에이전트에게 웹 검색 권한을 부여할 때 마주하는 것과 동일한 신뢰 경계입니다. 여기서 이 데이터를 격리하는 유일한 장치는 이 에이전트가 오직 댓글을 작성하는 것 외에는 아무것도 할 수 없다는 점입니다.
저장소 전체가 아닌 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 객체에서 통합 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번 줄에서 시작함을 나타내며, 카운터는 여기서 시작하여 추가되거나 변경되지 않은 줄에서만 증가합니다. 삭제된 줄은 새 파일에 존재하지 않으므로 번호를 매기지 않고 건너뜁니다. 백슬래시로 시작하는 줄에 대한 가드는 git이 파일 끝에 작성하는 no-newline 마커를 건너뛰게 합니다. 이 마커를 처리하지 않으면 이후의 모든 번호가 1씩 밀리게 됩니다.
두 종료 지점 모두 1이 아닌 상태 코드 0을 사용합니다. 필터링되었거나 크기가 초과된 pull request라도 녹색 체크 표시가 나타나야 합니다. 사람이 조치할 수 없는 빨간색 체크는 무시되기 마련이며, 한 번 무시된 체크는 이후 모든 체크가 무시되는 결과를 낳습니다.
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개의 알림이 아닌 하나의 알림으로 도착합니다.
보고할 내용이 없으면 스크립트는 아무것도 게시하지 않습니다. 모든 pull request에 "발견된 문제 없음"이라고 작성하는 봇은 사용자가 내용을 대충 훑어보게 만들며, 결국 중요한 알림마저 무시하게 됩니다.
워크플로우에 연결하기
이 내용을 .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.pyGITHUB_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와 시스템 프롬프트를 포함한 측정 결과이며, 추정치가 아닌 토큰 계수 엔드포인트를 사용하여 계산했습니다.
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입니다.
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줄짜리 변경 사항에 대해 억지로 할 말을 지어내는 것을 막을 수 있습니다.
리뷰는 COMMENT로 게시해야 하며, 절대 REQUEST_CHANGES로 게시해서는 안 됩니다. 모델의 의견이 머지를 차단해서는 안 되며, 만약 차단이 가능한 순간 마감 기한에 쫓기는 누군가는 봇과 실랑이를 벌이는 대신 워크플로우 전체를 삭제해 버릴 것입니다.
실패 유형 및 확인 가능한 메시지
리뷰 게시 시 HTTP 422 오류 발생. gh는 gh: Unprocessable Entity (HTTP 422)를 출력하며 응답 본문은 필드명을 명시합니다: Pull request review thread line must be part of the diff. GitHub는 해당 코멘트를 고정할 수 없습니다. 일반적인 원인은 모델이 임의로 생성한 줄 번호, 여전히 b/ 접두사가 붙어 있는 path, 또는 삭제된 줄에 대한 코멘트입니다. 삭제된 줄의 경우 side를 RIGHT 대신 LEFT으로 설정해야 합니다. 게시 전 리뷰 JSON을 출력하여 코멘트 하나를 diff와 직접 대조해 보십시오.
모델 API에서 invalid x-api-key 발생. 첫 번째 messages.create 호출 단계에서 실패합니다. 저장소에 ANTHROPIC_API_KEY 시크릿이 설정되지 않았거나, 포크(fork)에서 발생한 풀 리퀘스트여서 Actions에 시크릿이 전혀 전달되지 않은 경우입니다. if: 라인의 포크 가드(fork guard)가 이를 건너뛰었어야 하므로 해당 라인을 먼저 확인하십시오.
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.*'을 통해 러너(runner)가 활성화되어 있는지 확인하십시오. 오프라인 상태인 러너는 풀 리퀘스트에 아무런 오류 메시지 없이 작업을 대기열에만 남겨둡니다.
모든 리뷰가 빈 상태로 반환됨. 1회 실행 동안 MIN_SEVERITY을 low로 설정하십시오. 결과가 나타난다면 임계값(threshold)이 정상 작동하는 것입니다. 아무것도 나타나지 않는다면 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를 보내기 전에 모든 줄 앞에 새 파일의 줄 번호를 붙이는 것이 모델이 임의로 번호를 생성하는 것을 방지하는 방법입니다.
포크에서 발생한 풀 리퀘스트가 있는 공개 저장소에서 이 기능을 실행할 수 있나요?
현재 설계로는 불가능합니다. GitHub는 포크에서 트리거된 워크플로에 시크릿을 전달하지 않으므로 모델 키가 누락되어 실행이 실패합니다. 또한 GitHub는 자가 호스팅 러너를 "공개 저장소에 사용해서는 안 된다"고 명시하고 있는데, 이는 누구나 풀 리퀘스트를 열어 사용자의 머신에서 코드를 실행하게 만들 수 있기 때문입니다. 공개 프로젝트의 경우, 리뷰어의 범위를 저장소 자체로 푸시된 브랜치로 제한하거나(if: 가드가 수행하는 작업), 리뷰 단계를 GitHub 호스팅 러너로 옮기고 diff가 자체 인프라를 벗어나는 것을 감수해야 합니다.
풀 리퀘스트 리뷰에는 어떤 모델을 사용해야 하나요?
Haiku 4.5로 시작하십시오. 고정된 결함 유형 목록을 기준으로 제한된 diff를 읽는 것은 어려운 추론 문제가 아니며, 가장 저렴한 모델을 사용하면 월간 비용을 논란의 여지가 없는 수준으로 유지할 수 있습니다. 사용 중인 언어나 프레임워크에서 실제 버그를 놓치는 경우가 발견되면 Sonnet 5로 업그레이드하되, 추측하지 말고 측정하십시오. Opus 5는 세 모델 중 풀 리퀘스트당 비용이 월등히 높으므로, 모든 기능 브랜치의 모든 푸시보다는 릴리스 브랜치에서 사용하는 것이 정당화하기 쉽습니다.