diff --git a/.github/scripts/review-debt-policy.test.mjs b/.github/scripts/review-debt-policy.test.mjs new file mode 100644 index 0000000000..850c5d0034 --- /dev/null +++ b/.github/scripts/review-debt-policy.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const guard = await readFile(new URL("../workflows/review-debt-guard.yml", import.meta.url), "utf8"); + +test("team membership is judged by real permission, not author_association", () => { + // 웹훅 페이로드의 author_association 은 조직 멤버십이 비공개면 MEMBER 대신 + // CONTRIBUTOR 를 내린다. 이 조직은 공개 멤버가 0명이라, 레포에 직접 초대된 한 + // 계정만 COLLABORATOR 로 찍혀 검사를 받고 나머지 팀원은 전원 건너뛰어졌다. + // 가드가 한 사람만 막는 상태였다 (8/25 #1152·#1154 통과 / #1112 만 닫힘). + // 주석은 이력이라 남기고, 실제로 값을 끌어오는 자리만 금지한다. + assert.doesNotMatch(guard, /^\s*[A-Z_]+:\s*\$\{\{[^\n]*author_association/m); + assert.doesNotMatch(guard, /\$ASSOCIATION/); + assert.match(guard, /collaborators\/\$AUTHOR\/permission/); +}); + +test("only write-capable accounts are subject to the guard", () => { + // read 권한만 있는 계정은 리뷰로 판정을 낼 수 없으니 빚을 질 수도 없다. + assert.match(guard, /^\s*admin\|maintain\|write\)/m); + // 빈 값·예상 밖 값까지 «권한 없음» 으로 접으면 조회 실패가 통과로 둔갑한다. + assert.match(guard, /^\s*read\|none\)[^\n]*exit 0/m); + assert.match(guard, /^\s*\*\) echo "::error::권한 값을 알 수 없다[^\n]*exit 1/m); +}); + +test("a failed permission lookup fails the run instead of passing the pull request", () => { + // 조회 실패를 «권한 없음» 으로 접으면 가드 전체가 조용히 무력해진다. + assert.match(guard, /if ! perm=\$\(gh api[^\n]*\n\s*echo "::error::[^\n]*\n\s*exit 1/); +}); + +test("forks are excluded by head repository, not by contributor status", () => { + // fork PR 은 토큰이 read-only 라 닫지도 못한다. 그것이 원래 걸러내려던 것이고, + // author_association 은 그 판정에 쓸 수 없는 축이었다. + assert.match(guard, /\[ "\$HEAD_REPO" != "\$REPO" \]/); +}); + +test("the debt sweep still fails closed when open pull requests cannot be listed", () => { + // 조회 실패를 «빚 없음» 으로 오인하면 새 PR 이 전부 통과한다. + assert.match(guard, /if ! open_prs=\$\(gh api[^\n]*\n(?:[^\n]*\n)*?\s*exit 1/); +}); + +test("a pull request is never counted as debt against its own author", () => { + // 자기 PR 은 자신이 리뷰할 수 없다. + assert.match(guard, /select\(\.user\.login != \\"\$AUTHOR\\"\)/); +}); + +test("the dead review_request_removed path is gone", () => { + // 빚 기준이 «아무도 응답하지 않은 열린 PR» 로 바뀌면서(#1148) 가드는 리뷰 요청 + // 목록을 보지 않는다. 요청을 스스로 떼던 우회가 사라졌으므로, 그것을 기록하던 + // bypass-audit job 도 걷었다 — 가드 런 19건 전부 debt-check 만 실행했고 이 + // job 은 한 번도 발동하지 않았다. 되살리려면 우회가 되살아났는지부터 볼 것. + // 주석은 이력이라 남기고, 실제로 트리거를 걸고 job 을 세우는 자리만 금지한다. + assert.doesNotMatch(guard, /^\s*types:[^\n]*review_request_removed/m); + assert.doesNotMatch(guard, /^\s{2}bypass-audit:/m); +}); + +test("changes requested becomes debt again once the author has pushed a fix", () => { + // 변경요청은 끝난 판정이 아니다. 반영 뒤 재리뷰가 없으면 그 PR 은 영영 머지되지 + // 못한다. 병합 커밋은 반영이 아니므로 parents 2개 이상은 빼고 센다. + assert.match(guard, /CHANGES_REQUESTED/); + assert.match(guard, /\(\.parents \| length\) < 2/); + assert.match(guard, /select\(\.commit\.committer\.date > \\"\$blocked_at\\"\)/); +}); diff --git a/.github/workflows/review-debt-guard.yml b/.github/workflows/review-debt-guard.yml index 3df105ced0..ce28fdc3e3 100644 --- a/.github/workflows/review-debt-guard.yml +++ b/.github/workflows/review-debt-guard.yml @@ -5,21 +5,21 @@ # push·merge 만 통제) 열린 직후 조회해 미처리 리뷰 요청이 1건이라도 있으면 # 그 PR 을 닫는다. 브랜치는 남으므로 리뷰를 끝낸 뒤 되살릴 수 있다. # -# 우회가 설계에 남아 있다 — #796 에서 자동 reviewer 요청을 없애 리뷰 요청은 -# 전부 수동이고, 리뷰어가 자기에게 걸린 요청을 스스로 떼면 이 가드를 통과한다. -# 막지는 못하므로 review_request_removed 를 함께 받아 기록만 남긴다. +# 빚의 기준은 «나에게 걸린 리뷰 요청» 이 아니라 «아무도 응답하지 않은 열린 PR» +# 이다(#1148). 요청 목록을 아예 보지 않으므로, 리뷰어가 자기 요청을 스스로 떼던 +# 옛 우회로는 더 이상 통하지 않는다. 그것을 기록하던 bypass-audit job 은 발동 +# 실적 0건인 채로 죽어 있었으므로 함께 걷었다. name: review-debt-guard on: pull_request: - types: [opened, reopened, ready_for_review, review_request_removed] + types: [opened, reopened, ready_for_review] permissions: pull-requests: write jobs: debt-check: - if: github.event.action != 'review_request_removed' runs-on: ubuntu-latest steps: - name: 미처리 리뷰 요청 검사 @@ -28,7 +28,7 @@ jobs: REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} AUTHOR: ${{ github.event.pull_request.user.login }} - ASSOCIATION: ${{ github.event.pull_request.author_association }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} run: | # 조회 실패를 "빚 없음" 으로 오인해 통과시키지 않는다. 다만 판정 근거를 @@ -40,11 +40,29 @@ jobs: *'[bot]'|dependabot|github-actions) echo "봇 PR — 건너뜀"; exit 0 ;; esac - # 외부 기여자는 대상이 아니다. fork PR 은 토큰이 read-only 라 - # 닫지도 못하고, 팀 리뷰 적체와도 무관하다. - case "$ASSOCIATION" in - OWNER|MEMBER|COLLABORATOR) ;; - *) echo "외부 기여자($ASSOCIATION) — 건너뜀"; exit 0 ;; + # fork PR 은 대상이 아니다 — 토큰이 read-only 라 닫지도 못하고, 팀 리뷰 + # 적체와도 무관하다. 원래 걸러내려던 것이 이것이므로 head 저장소로 가른다. + if [ "$HEAD_REPO" != "$REPO" ]; then + echo "fork PR($HEAD_REPO) — 건너뜀" + exit 0 + fi + + # 팀원 여부는 author_association 으로 가리지 않는다. 웹훅 페이로드는 조직 + # 멤버십이 비공개면 MEMBER 대신 CONTRIBUTOR 를 내리는데 이 조직은 공개 + # 멤버가 0명이다. 그래서 레포에 직접 초대된 한 계정만 COLLABORATOR 로 찍혀 + # 검사를 받고, 나머지 팀원은 전원 «외부 기여자» 로 건너뛰어졌다 — 8/25 에 + # #1152·#1154 가 그렇게 통과했고 12분 뒤 같은 빚으로 #1112 만 닫혔다. + # REST 로 조회하면 MEMBER 로 보여 눈으로는 잡히지 않는다. 실권한을 묻는다. + if ! perm=$(gh api "repos/$REPO/collaborators/$AUTHOR/permission" --jq '.permission'); then + echo "::error::권한 조회에 실패했다 — 근거 없이 통과시키지 않는다. 재실행할 것" + exit 1 + fi + + # 빈 값·예상 밖 값을 «권한 없음» 으로 접으면 조회 실패가 통과로 둔갑한다. + case "$perm" in + admin|maintain|write) ;; + read|none) echo "쓰기 권한 없음($perm) — 건너뜀"; exit 0 ;; + *) echo "::error::권한 값을 알 수 없다($perm) — 재실행할 것"; exit 1 ;; esac # 긴급 우회 라벨. @@ -158,38 +176,3 @@ jobs: echo "미응답 ${count}건 — PR #$PR_NUMBER 를 닫는다" gh pr close "$PR_NUMBER" --repo "$REPO" --comment "$(cat /tmp/debt-comment.md)" - bypass-audit: - # 리뷰를 남기지 않은 채 요청이 제거되면 이 가드는 그냥 통과한다. - # 막을 수단이 없으니 드러나게만 한다. - if: github.event.action == 'review_request_removed' - runs-on: ubuntu-latest - steps: - - name: 리뷰 없는 요청 제거 기록 - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - REMOVED: ${{ github.event.requested_reviewer.login }} - SENDER: ${{ github.event.sender.login }} - run: | - set -euo pipefail - - # 팀 리뷰 요청 제거만 본다. - [ -n "$REMOVED" ] || { echo "리뷰어 정보 없음 — 건너뜀"; exit 0; } - - reviewed=$(gh api "repos/$REPO/pulls/$PR_NUMBER/reviews" \ - --jq "[.[] | select(.user.login == \"$REMOVED\")] | length") - - if [ "$reviewed" -gt 0 ]; then - echo "$REMOVED 는 이미 리뷰를 남겼다 — 정상 제거" - exit 0 - fi - - if [ "$SENDER" = "$REMOVED" ]; then - note="리뷰어 본인이 직접 뗐습니다 — 리뷰 적체 가드(#1136)를 통과하게 됩니다." - else - note="@$SENDER 님이 제거했습니다." - fi - - gh pr comment "$PR_NUMBER" --repo "$REPO" --body \ - "🔍 **리뷰 없이 요청이 제거됨** — @$REMOVED 님은 이 PR 에 리뷰를 남기지 않은 상태입니다. $note"