Imported from pdw96/ai-production-risk-early-warning (
.claude/skills/pr-round/SKILL.md). Install upstream withnpx skills add pdw96/ai-production-risk-early-warning --skill pr-round. Copyright stays with the author.
PR 한 라운드를 읽는 법
리뷰 봇의 신호는 문서에 없다. OpenAI·CodeRabbit 쪽 동작이라 이 저장소가 실제로 밟아 본 것만 안다. 그래서 여기 적힌 것은 규칙이 아니라 관측이고, 모양이 바뀌면 다시 재야 한다.
말부터 정한다 — 이 문서에서 「신호」·「리뷰」·「코멘트」·「리액션」은 전부 「기다리는 그 봇 계정이 남긴 것」을 뜻한다. 다른 계정이 남긴 같은 모양은 신호가 아니다. 라운드를 열기 전에 어느
user.login을 기다리는지 먼저 정하고, 아래 어디서든 이 말들이 나오면 그 조건이 이미 걸려 있는 것으로 읽는다.
이 문단이 열거를 대신한다. 같은 조건을 자리마다 붙였다가 다섯 번 샜다 —
리액션 · 리뷰 · 무지적 코멘트 경로(4·5·7라운드), 재촉 억제(8라운드), skip review
제외(9라운드). 표 밖으로 올려도, 걸려야 할 자리를 세어 봐도 또 샜다. 자리를 세는
것이 실행 가능한 규칙이 아니었다 — 산문에서 「이 조건이 걸려야 할 곳」을 기계적으로
찾을 길이 없기 때문이다. 그래서 열거를 그만두고 말의 뜻으로 옮겼다. 빠뜨릴
자리가 생기지 않는 유일한 모양이다.
깨끗한 라운드의 신호는 셋이고, 매번 같은 것이 오지 않는다
코덱스가 지적 없이 끝냈다는 신호는 리뷰 제출 · 무지적 코멘트 · 👍 리액션 셋이며 어느 것이 올지는 PR 마다 다르다. 하나만 보면 놓친다.
다만 셋이 다 판정은 아니다. 아래 「판정은 커밋에 묶는다」가 가른다 — 리뷰
제출과 무지적 코멘트는 커밋을 들고 오고(앞은 commit_id 필드, 뒤는 본문),
👍 리액션만 신호에서 멈춘다.
| PR | 무지적 코멘트 | 👍 |
|---|---|---|
| #16 · #21 · #47 · #50 | 없음 | 있음 |
| #19 · #20 · #65 | 있음 | 있음 |
reviews 배열이 0건인 것은 「아무도 안 봤다」가 아니다. 지적이 없으면 코덱스는
reviews 를 비운 채 리액션으로만 남기기도 한다 — 머지된 PR 열하나 전부에 👍 가
있었고, 없는 것은 머지되지 않고 닫힌 #14 하나뿐이었다. 자료구조를 사건으로
착각하지 않는다.
거꾸로도 마찬가지다 — 차 있는 것이 「지적이 있다」는 뜻이 아니다. 위에서 봤듯이
리뷰 제출은 깨끗한 신호 셋 중 하나라, 지적이 없어도 reviews 가 찬다. 배열이
비었나 찼나로 세지 말고 그 봇 리뷰의 state · 본문 · 딸린 라인 지적을 연다.
실측 2026-09-14 · #65 — 지적이 있던 라운드는 COMMENTED + 라인 지적이 함께
왔고(1라운드 6건), 깨끗한 라운드는 라인 지적이 0이었다. 가르는 것은 배열의
길이가 아니라 딸린 지적의 수다.
리액션을 누가 남겼는지는 헤더 하나로 읽는다:
curl -s -H "Accept: application/vnd.github.squirrel-girl-preview+json" \
https://api.github.com/repos/OWNER/REPO/issues/N/reactions
리액션은 두 종류이고 같은 자리에서 교체된다. ready → 👀 eyes(「보고 있다」) → 👍 +1(「끝냈다」). 나중에 조회하면 👍 하나만 남는다. 👀 를 완료로 읽으면 지적이 있는데 깨끗하다고 센다 — 둘 다 판정이 아니라 신호라는 것은 아래에서 가른다.
👍 는 CI 를 보지 않는다. 한 번은 👍 가 CI 완료보다 2분 빨랐다. 코덱스는 코드를 읽지 파이프라인을 읽지 않는다 — 두 조건은 따로 확인한다.
판정은 커밋에 묶는다 — 시각으로는 묶이지 않는다
관제탑은 「푸시하면 리액션이 지워지므로 머지 시점에 👍 가 남아 있으면 판정한 커밋과 머지된 커밋이 같다」고 적었다. GitHub 은 이슈 본문 리액션을 푸시로 지우지 않는다. 그대로 믿으면 판정 뒤에 올린 커밋을 검토된 것으로 읽는다.
시각 비교로 대신할 수 없다. 이전 head 를 보는 도중에 새 커밋이 올라가면 완료 신호가 새 커밋보다 늦게 찍힌다. 늦게 찍혔다는 것이 그것을 봤다는 뜻이 아니다.
리뷰에는 커밋이 적혀 있고, 리액션에는 없다. 리뷰 본문 머리에 Reviewed commit: <sha> 가 찍히고, reviews 의 각 항목과 라인 지적에 commit_id 가 붙는다.
curl -s https://api.github.com/repos/OWNER/REPO/pulls/N/reviews | python3 -c \
"import json,sys; [print(r['user']['login'], r['state'], r['commit_id'][:10]) \
for r in json.load(sys.stdin)]"
판정으로 세는 것은 커밋을 들고 온 신호뿐이다.
판정에는 조건이 둘이고, 둘 다 모든 경로에 공통이다. 경로마다 다른 것은 조건이 아니라 SHA 가 어디 적혀 있는가뿐이다.
① 작성자 — 머리말의 정의 그대로다. 그 봇이 남긴 것만 신호다.
reviews도 코멘트도 리액션도 먼저user.login으로 거르고 나서 아래를 본다.② 커밋 — 들고 온 SHA 가 현재
head.sha와 같아야 한다. 이것도 모든 경로에 공통이다. 어디에 붙어 있는지만 경로마다 다르고, 아래 표가 그 자리를 든다.
| 무엇이 왔나 | SHA 가 어디 붙나 |
|---|---|
| 리뷰 · 라인 지적 | commit_id 필드 |
| 무지적 코멘트 | 본문의 Reviewed commit: <sha> — reviews 가 비어 있어도 판정이다 |
| 👍 리액션 | 어디에도 안 붙는다 — 신호이고 판정이 아니다. 「확인 불가」로 부른다 |
표를 「어디 붙나」만 남기게 정리하면서 「현재 head 와 같고」를 떨어뜨린 적이
있다(044547c, 8라운드에 지적). 그러면 작성자가 맞는 예전 판정이 새 head 를
통과시킨다 — 이 문서가 여덟 라운드에 걸쳐 세운 것이 그 한 줄이었다. 바로 그
커밋에서 「규칙을 고칠 때는 고친 자리만 보지 않는다」를 각주로 적고 있었다.
작성자를 왜 보는가. 이 저장소에는 봇이 둘이고 사람도 리뷰를 낸다 — 코덱스가 확인 불가능한 👍 만 남긴 사이에 CodeRabbit 이나 협업자가 같은 SHA 에 리뷰를 제출하면, 그것이 코덱스의 판정 자리에 앉는다.
둘 다 표 안에 넣지 않은 이유가 있다. 행마다 적으면 경로를 손댈 때 조건만 따라오지 않는다 — ①이 그렇게 다섯 번 샜고(머리말 각주), ②는 표를 정리하다 통째로 빠졌다.
조건은 표 밖에 둔다. 그리고 열거로 지킬 수 있는 것은 거기까지다 — ①처럼 문서 곳곳에서 되풀이되는 조건은 말의 뜻으로 올려야 새지 않는다(머리말).
코멘트 경로는 특히 약하다 — 본문 표식은 누구나 적을 수 있고, 인용만 해도
생긴다. 실제로 #65 7라운드 요청 코멘트가 지난 판정을 인용하느라 Reviewed commit: 을 그대로 적었다. 그 SHA 가 현재 head 였다면 저자가 올린 코멘트가 봇의
깨끗한 판정으로 집계됐을 것이다.
「무지적 코멘트에는 커밋이 안 붙는다」고 적었다가 다음 라운드에 반증됐다(실측
2026-09-14 · #65 6라운드). 리뷰 본문에 Reviewed commit: 이 찍히는 것은 이미
관측해 놓고, 같은 것이 코멘트 경로에도 있는지는 재지 않고 「없다」고 단정했다.
reviews 배열이 비어 있는 것을 「커밋을 안 들고 온다」로 읽은 것이다 — 자료구조를
사건으로 착각하지 않는다가 이 문서 위쪽에 이미 있다.
head 는 PR 에서 읽는다(GET /pulls/N → head.sha). git rev-parse HEAD 는
사람이나 다른 레인이 그 가지에 푸시해도 안 움직여, 내 작업 공간이 조용한 것을 PR 이
조용한 것으로 읽는다.
「확인 불가」는 실패가 아니라 아는 만큼 적는 것이다. 저자에게 그대로 알린다 — 「코덱스가 신호는 남겼지만 어느 커밋을 봤는지 확인할 수 없다」. 그 다음은 저자가 정한다.
다만 그 자리는 생각보다 좁다. 지적이 있으면 리뷰가 오고, 지적이 없으면 무지적
코멘트가 온다 — 둘 다 커밋을 들고 온다. 👍 만 오는 경우가 남지만, #65 의 깨끗한
라운드는 👍 와 무지적 코멘트가 1초 차로 함께 왔다. 「깨끗한 라운드가 전부 확인
불가로 떨어진다」는 걱정은 재 보니 기우였다.
없는 정보를 주변 사실로 복원하려 들지 않는다
리액션에 커밋을 붙여 보려고 조건을 쌓았다가 네 라운드에 걸쳐 구멍이 넷 나왔다 — 묵은 👍 가 통과했고, 남의 👍 가 통과했고, 로컬 HEAD 가 원격을 못 봤고, 이전 head 를 검토하던 실행이 늦게 끝나도 통과했다. 마지막 것이 핵심이다.
경로를 지우거나 더해도 그 경로의 결함은 따라온다. 「남의 것을 세지 않는다」는 처음에 리액션 쪽 조건이었다. 리액션 경로를 지우자 같은 결함이 리뷰 쪽에 남았고, 무지적 코멘트 경로를 더하자 거기에 또 났다 — 세 번이다. 그래서 그 조건을 표 밖의 공통 조건으로 올렸다. 규칙을 고칠 때는 고친 자리만 보지 않는다.
리액션은 커밋도 시작 시각도 들고 오지 않는다. 없는 정보는 스냅샷으로 복원되지 않고, 조건을 하나 더 붙일 때마다 다음 구멍이 생긴다.
그래서 조건을 늘리는 대신 판정의 정의를 좁혔다. 「깨끗한 PR 이 영원히 안 닫힌다」는 걱정은 자리를 잘못 짚은 것이다 — 닫는 것은 저자이고, 문서가 할 일은 모르는 것을 안다고 적지 않는 것이다.
실측 2026-09-14 · #65 라운드 넷 — reviews[].commit_id 가 매번 당시
head(02f8c68 · 917a7c2 · 96da03c · f968256)로 왔고 라인 지적도 같았다.
지적이 있는 라운드에서는 커밋이 붙은 신호가 항상 왔다.
봇 둘이 지적을 내는 모양이 다르다
코덱스는 라인 스레드, CodeRabbit 은 리뷰 본문 안 <details> 블록이다.
스레드만 훑는 절차는 CodeRabbit 을 통째로 놓친다 — #17 에서 실제로 2건을
놓쳤다.
「지적 0」이 「깨끗함」이 아닌 경우가 둘 있다
봇이 안 붙은 것과 봇이 붙었는데 지적이 없는 것은 화면에서 똑같이 초록이다. 안 붙는 이유가 하나가 아니므로 사유 문구를 읽는다.
| 사유 | 문구 | 어떻게 부르나 |
|---|---|---|
| PR 작성자가 봇 | “Review skipped — Bot user detected” · 코덱스는 “create a Codex account” | 사람이 ready 를 누르면 코덱스는 붙는다(#47) |
| 저장소 별이 열 개 미만 | “This repository does not receive automatic reviews because it has fewer than 10 stars.” | Trigger review 버튼 또는 @coderabbitai review |
본문이 skip review 인 코멘트는 라운드로 세지 않는다 — 세면 분모가 기운다.
머리말의 정의대로 그 봇이 남긴 것일 때만 그렇다. 누가 인용만 해도 같은 문자열이
생기므로, 남의 것을 빼면 붙었는데 안 붙은 것으로 오진한다.
자동 부착은 들쭉날쭉하다 — 재촉에도 규칙이 있다
ready 로 바꾼 뒤 붙기까지 6초 ~ 19분 무응답까지 봤다.
전환 후 5분 안에 아무 반응이 없으면 재촉할 때다. 다만 👀 가 이미 붙어 있으면 재촉하지 않는다 — 이미 보고 있는 것이고, 부르면 중복 리뷰가 된다.
여기 「👀」도 머리말의 정의대로 그 봇의 것이다. 협업자나 다른 봇이 남긴 👀 를 「보고 있다」로 읽으면, 그 봇이 시작도 안 했는데 저자에게 알리는 것을 막는다.
이 자리가 같은 조건이 샌 네 번째였다(8라운드에 지적) — 그때는 조건을 판정에만
걸어 두었다. 다섯 번째(skip review)가 나온 뒤 열거를 그만두고 머리말의 정의로
옮겼다.
리액션이 비어 있는 것도 무응답이 아니다. 판정이 나면 👀 가 교체되어 사라진다 —
19분 뒤 리액션이 0건이라 무응답으로 읽을 뻔했는데, 판정은 5분 만에 리뷰로 와
있었다(실측 2026-09-14 · #65 8라운드). 재촉을 알리기 전에 판정 경로 셋을 먼저
본다.
재촉은 에이전트가 하지 않는다. @codex review 를 남기는 것은 사람
계정이어야 하고, 봇 계정이 남기면 거절당한다. 그러니 에이전트가 할 일은
저자에게 「5분이 지났고 👀 도 없다」고 알리는 것까지다 — authority-line 이
리뷰 요청을 사람에게 남기는 이유가 이것이다. 에이전트가 직접 부르면 요청이
갔다고 믿는 채로 PR 이 검토 없이 앉아 있게 된다.
매 PR 에서 따로 확인하는 셋
작성자 · 리뷰어 존재 · merged_by. 셋이 따로인 이유는 각각 다른 것을 숨기기
때문이다 — 작성자는 봇이 안 붙는 사유를, 리뷰어 존재는 「지적 0」의 뜻을,
merged_by 는 사람이 눌렀는지 봇이 눌렀는지를 가른다. 마지막 것은 PR
화면에서 눈에 띄지 않는다.
가지는 조각마다 새로 뗀다
머지된 가지를 재사용하려면 리셋이 필요하고, 리셋은 force-push 를 부르고, force-push 는 분류기가 막는다(published history rewrite). 새 가지를 떼면 force 가 필요 없다. 취향이 아니라 이 환경에서 유일하게 통하는 길이다.
스택된 PR 은 아래가 들어가면 충돌할 수 있다
base 를 다른 PR 의 가지로 두면 그것이 머지된 뒤 dirty 가 될 수 있다 — 되는지는
변경이 실제로 겹치는가에 달렸고, 안 겹치면 멀쩡하다. 그러니 고치기 전에 묻는다.
curl -s https://api.github.com/repos/OWNER/REPO/pulls/N | python3 -c \
"import json,sys; print(json.load(sys.stdin)['mergeable_state'])"
dirty 일 때만 수리한다. 해소는 base 를 머지해서 푸는 것이지 rebase·force-push
가 아니고, 해소 커밋이 아래 PR 이 넣은 것을 지우지 않았는지 diff 로 확인해야
한다 — CI 초록은 그것을 못 잡는다(검사가 사라지면 그냥 덜 돈다).
멀쩡한데 수리하지 않는다. 수리 커밋은 head SHA 를 바꾸고, 그러면 위의 「판정은 커밋에 묶는다」가 리뷰를 한 번 더 요구한다. 필요 없는 라운드를 스스로 만드는 셈이다.
환경의 결함이 아니다. GitHub 은 base 가 전진해 충돌이 생겨도 웹훅을 쏘지 않는다.
실측 2026-09-09 ~ 2026-09-14 · PR #14~#26 · #47 · #50 · #65.
👀 의 시각만은 레인 실시간 관측이라 사후 재현이 안 된다. 봇의 동작이 바뀌면 이 문서가 아니라
다시 잰 값이 맞다.