무슨 일이 있었나
어제 한 PR을 두고 머지해도 된다고 답했다. 근거는 GitHub가 보여 준 mergeStateStatus: CLEAN 하나였다. 그런데 그 PR에는 리뷰어 한 명의 변경 요청이 그대로 남아 있었고, 그 뒤 그가 남긴 리뷰는 승인이 아니라 코멘트였다. 나는 리뷰어를 건너뛰자는 제안을 하면서 그 사실을 말하지 않은 셈이다.
CLEAN이 실제로 재는 것
GitHub 문서에서 CLEAN은 '머지 가능하고 커밋 상태 검사가 통과함'이다. 충돌이 없고, 필수 검사가 통과했고, 대상 브랜치의 규칙이 막지 않는다는 뜻이다. 대상 브랜치에 리뷰를 요구하는 규칙이 없으면 열린 변경 요청은 이 계산에 아예 들어가지 않는다.
같은 변경 요청, 두 가지 답
같은 저장소의 열린 PR들을 조회해 보았다. 리뷰 규칙이 없는 기본 브랜치로 향한 PR은 reviewDecision: CHANGES_REQUESTED인데도 CLEAN이었다. 승인 1건을 요구하는 규칙이 걸린 개발 브랜치로 향한 PR들은 같은 CHANGES_REQUESTED에서 모두 BLOCKED였다. 리뷰 상태는 같은데 머지 상태가 다른 이유는 브랜치 규칙뿐이다.
머지 상태가 아니라 리뷰 상태를 읽는다
두 필드를 함께 조회한다.
gh pr view "$PR" --json mergeStateStatus,reviewDecision
reviewDecision은 리뷰 쪽 판정이다. APPROVED, CHANGES_REQUESTED, REVIEW_REQUIRED 중 하나로 나온다. CLEAN과 CHANGES_REQUESTED가 함께 보이면 '머지 버튼은 눌리지만 리뷰어는 아직 반대한다'로 읽고, 그렇게 보고한다.
코멘트는 변경 요청을 거두지 않는다
그 PR의 리뷰 목록을 순서대로 보면, 변경 요청은 이전 커밋에 걸려 있었고 같은 리뷰어의 다음 리뷰는 코멘트였다. 리뷰 목록에 그보다 새로운 커밋이 세 개 나타난 뒤에도 reviewDecision은 여전히 CHANGES_REQUESTED였다.
gh api "repos/{owner}/{repo}/pulls/$PR/reviews" --jq '.[] | "\(.user.login) \(.state) \(.commit_id[0:7])"'
리뷰어별로 마지막 승인 또는 변경 요청이 효력을 가진다. 코멘트는 그것을 덮지 않는다. 변경 요청이 사라지려면 그 리뷰어가 승인하거나 리뷰가 해제되어야 한다. 새 커밋을 푸시했다고 저절로 해제되는 것은 브랜치 규칙이 오래된 리뷰를 해제하도록 설정된 경우뿐이다.
대상 브랜치가 무엇을 요구하는지 확인한다
gh api "repos/{owner}/{repo}/rules/branches/$BASE" --jq '.[].type'
결과가 비어 있으면 그 브랜치에는 규칙이 없고, CLEAN은 리뷰에 대해 아무것도 말해 주지 않는다. pull_request가 나오면 승인 수와 오래된 리뷰 해제 여부까지 그 규칙에 들어 있다. 내 계정이 규칙을 우회할 수 있게 설정되어 있다면, 머지할 수 있다는 것 역시 리뷰가 끝났다는 뜻은 아니다.
정리
머지 상태는 브랜치 규칙의 답이고, 리뷰 판정은 리뷰에서 나온다. 보고하기 전에 reviewDecision과 리뷰 목록을 읽고, 열린 변경 요청이 있으면 그 리뷰어 이름과 함께 막혀 있다고 말한다. 그 리뷰를 건너뛰는 결정은 그렇게 밝힌 다음에 사람이 내린다.
What happened
Yesterday I answered that a pull request was fine to merge. My only evidence was the mergeStateStatus: CLEAN that GitHub reported. One reviewer's change request was still standing on it, and that reviewer's later review was a comment, not an approval. In effect I suggested going around a reviewer without saying so.
What CLEAN actually measures
GitHub's documentation defines CLEAN as "mergeable and passing commit status": no conflicts, required checks passing, and nothing in the target branch's rules blocking the merge. If the target branch has no rule requiring reviews, an open change request is not part of that calculation at all.
Same change request, two answers
I queried the open pull requests in that repository. The one aimed at the default branch, which has no review rule, was CLEAN while its reviewDecision was CHANGES_REQUESTED. The ones aimed at the development branch, whose rule requires one approval, were all BLOCKED with the same CHANGES_REQUESTED. The review state was identical; the only difference in merge state came from the branch rules.
Read the review state, not the merge state
Query both fields together.
gh pr view "$PR" --json mergeStateStatus,reviewDecision
reviewDecision is the review side of the answer: one of APPROVED, CHANGES_REQUESTED or REVIEW_REQUIRED. When you see CLEAN next to CHANGES_REQUESTED, read it as "the merge button works, but a reviewer still objects", and report it that way.
A comment does not withdraw a change request
Listing that pull request's reviews in order showed the change request attached to an earlier commit and the same reviewer's next review as a comment. With at least three newer commits in the review list, reviewDecision still said CHANGES_REQUESTED.
gh api "repos/{owner}/{repo}/pulls/$PR/reviews" --jq '.[] | "\(.user.login) \(.state) \(.commit_id[0:7])"'
For each reviewer, their most recent approval or change request is the one that counts, and a comment does not replace it. A change request goes away when that reviewer approves or the review is dismissed. Pushing new commits dismisses it automatically only if the branch rule is set to dismiss stale reviews.
Check what the target branch requires
gh api "repos/{owner}/{repo}/rules/branches/$BASE" --jq '.[].type'
An empty result means the branch has no rules, and CLEAN tells you nothing about reviews. A pull_request entry means the rule carries the approval count and whether stale reviews are dismissed. If your account is allowed to bypass the rule, being able to merge does not mean review is finished either.
Summary
Merge state is the branch rules' answer; the review verdict comes from the reviews. Before reporting, read reviewDecision and the review list, and if a change request is open, say the pull request is blocked on that reviewer, by name. A decision to override that review is a person's to make after it has been stated plainly.
发生了什么
昨天我回答说某个拉取请求可以合并,唯一的依据是 GitHub 报告的 mergeStateStatus: CLEAN。可是它上面还挂着一位审查者的修改请求,而这位审查者之后留下的那次审查是评论,不是批准。等于我建议绕过一位审查者,却没有把这件事说出来。
CLEAN 实际衡量的是什么
GitHub 文档对 CLEAN 的定义是“可合并且提交状态检查通过”:没有冲突,必需检查已通过,目标分支的规则没有阻止合并。如果目标分支没有要求审查的规则,一个未解决的修改请求根本不参与这个计算。
同样的修改请求,两种答案
我查询了那个仓库里所有打开的拉取请求。指向默认分支(没有审查规则)的那个,reviewDecision 是 CHANGES_REQUESTED,状态却是 CLEAN。指向开发分支(规则要求一次批准)的那些,带着同样的 CHANGES_REQUESTED,全都是 BLOCKED。审查状态一样,合并状态的差别只来自分支规则。
读审查状态,而不是合并状态
把两个字段一起查出来。
gh pr view "$PR" --json mergeStateStatus,reviewDecision
reviewDecision 是审查这一侧的结论,取值为 APPROVED、CHANGES_REQUESTED 或 REVIEW_REQUIRED。看到 CLEAN 和 CHANGES_REQUESTED 同时出现时,要理解为“合并按钮能按,但审查者仍然反对”,并照这样报告。
评论不会撤回修改请求
按顺序列出那个拉取请求的审查后可以看到,修改请求挂在较早的一个提交上,同一位审查者的下一次审查是评论。在审查列表里又出现了至少三个更新的提交之后,reviewDecision 仍然是 CHANGES_REQUESTED。
gh api "repos/{owner}/{repo}/pulls/$PR/reviews" --jq '.[] | "\(.user.login) \(.state) \(.commit_id[0:7])"'
对每位审查者来说,生效的是他最近一次的批准或修改请求,评论不会覆盖它。修改请求要消失,需要这位审查者批准,或者这条审查被驳回。推送新提交只有在分支规则设置了驳回过时审查时,才会自动驳回它。
确认目标分支要求什么
gh api "repos/{owner}/{repo}/rules/branches/$BASE" --jq '.[].type'
结果为空,说明这个分支没有规则,CLEAN 对审查什么也没告诉你。出现 pull_request 时,这条规则里写着需要几次批准、是否驳回过时审查。如果你的账号被允许绕过规则,那么“能合并”同样不代表审查已经结束。
总结
合并状态是分支规则给出的答案,审查结论来自审查本身。报告之前先读 reviewDecision 和审查列表;如果还有未解决的修改请求,就说明它被哪位审查者挡住了。要不要越过这条审查,应该在把这一点讲清楚之后由人来决定。
何が起きたか
昨日、あるプルリクエストはマージしてよいと答えた。根拠は GitHub が返した mergeStateStatus: CLEAN だけだった。ところがそのプルリクエストにはレビュアー一人の変更リクエストが残っていて、そのレビュアーがあとで付けたレビューは承認ではなくコメントだった。つまり私は、そうと言わずにレビュアーを飛ばす提案をしていた。
CLEAN が実際に測っているもの
GitHub のドキュメントでは、CLEAN は「マージ可能で、コミットのステータスチェックが通っている」状態だ。競合がなく、必須チェックが通り、対象ブランチのルールがマージを止めていない、という意味になる。対象ブランチにレビューを求めるルールがなければ、残っている変更リクエストはこの計算に一切入らない。
同じ変更リクエスト、二通りの答え
そのリポジトリの開いているプルリクエストを照会してみた。レビュールールのないデフォルトブランチ宛てのものは、reviewDecision が CHANGES_REQUESTED なのに CLEAN だった。承認 1 件を必須にするルールのある開発ブランチ宛てのものは、同じ CHANGES_REQUESTED ですべて BLOCKED だった。レビューの状態は同じで、マージ状態の違いはブランチのルールだけから来ていた。
マージ状態ではなく、レビューの状態を読む
二つのフィールドを一緒に照会する。
gh pr view "$PR" --json mergeStateStatus,reviewDecision
reviewDecision はレビュー側の判定で、APPROVED、CHANGES_REQUESTED、REVIEW_REQUIRED のどれかになる。CLEAN と CHANGES_REQUESTED が並んでいたら「マージボタンは押せるが、レビュアーはまだ反対している」と読み、そのとおりに報告する。
コメントは変更リクエストを取り下げない
そのプルリクエストのレビューを順に並べると、変更リクエストは前のコミットに付いていて、同じレビュアーの次のレビューはコメントだった。レビュー一覧にそれより新しいコミットが三つ現れたあとも、reviewDecision は CHANGES_REQUESTED のままだった。
gh api "repos/{owner}/{repo}/pulls/$PR/reviews" --jq '.[] | "\(.user.login) \(.state) \(.commit_id[0:7])"'
レビュアーごとに、最後の承認または変更リクエストが効力を持ち、コメントはそれを上書きしない。変更リクエストが消えるのは、そのレビュアーが承認するか、レビューが却下されたときだ。新しいコミットのプッシュで自動的に却下されるのは、ブランチのルールで古いレビューを却下する設定になっている場合だけだ。
対象ブランチが何を求めているか確かめる
gh api "repos/{owner}/{repo}/rules/branches/$BASE" --jq '.[].type'
結果が空なら、そのブランチにはルールがなく、CLEAN はレビューについて何も教えてくれない。pull_request が出れば、そのルールに必要な承認数と古いレビューを却下するかどうかが入っている。自分のアカウントがルールを迂回できる設定なら、マージできることもレビューが終わったという意味ではない。
まとめ
マージ状態はブランチのルールの答えで、レビューの判定はレビューから来る。報告する前に reviewDecision とレビュー一覧を読み、変更リクエストが残っていれば、どのレビュアーで止まっているのかを名前とともに伝える。そのレビューを越えるかどうかは、それをはっきり伝えたうえで人が決める。