Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/scripts/review-debt-policy.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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("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\\"\)/);
});
30 changes: 24 additions & 6 deletions .github/workflows/review-debt-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
# 조회 실패를 "빚 없음" 으로 오인해 통과시키지 않는다. 다만 판정 근거를
Expand All @@ -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

# 긴급 우회 라벨.
Expand Down
Loading