From 5e18e7fe4e168d7d272c34216aa6899f1f40355b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Tue, 25 Aug 2026 21:11:59 +0900 Subject: [PATCH 1/3] =?UTF-8?q?ci(review-debt-guard):=20=EB=B9=9A=EC=9D=98?= =?UTF-8?q?=20=EA=B8=B0=EC=A4=80=EC=9D=84=20=C2=AB=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EB=B0=9B=EC=9D=80=20=EA=B2=83=C2=BB=20=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=C2=AB=EC=95=84=EB=AC=B4=EB=8F=84=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=95=88=20=ED=95=9C=20PR=C2=BB=20=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 요청은 알림 수단이지 담당 배정이 아니다. 누구에게 걸렸든 팀에서 한 사람이 리뷰하면 그 PR 은 끝난 것이고, 반대로 아무도 응답하지 않은 PR 이 남아 있으면 요청을 받았든 아니든 리뷰를 미룬 것이다. 그래서 판정 대상을 review-requested 검색이 아니라 열린 PR 전체로 바꾼다. 자기 PR 은 자신이 리뷰할 수 없으므로 빼고, draft 는 리뷰받을 상태가 아니므로 뺀다. 실측(로컬 드라이런, close 직전까지): koongmai 22 Sadturtleman 20 1hyok 2 열린 non-draft PR 48건 중 아무도 응답하지 않은 것이 22건이고, 그중 20건이 1hyok 의 PR 이라 사람마다 빚이 다르게 잡힌다. --- .github/workflows/review-debt-guard.yml | 38 +++++++++++++------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/.github/workflows/review-debt-guard.yml b/.github/workflows/review-debt-guard.yml index 5e3668b363..3cfee13d04 100644 --- a/.github/workflows/review-debt-guard.yml +++ b/.github/workflows/review-debt-guard.yml @@ -52,22 +52,24 @@ jobs: *,review-debt-exempt,*) echo "review-debt-exempt 라벨 — 건너뜀"; exit 0 ;; esac - # 작성자에게 걸린 리뷰 요청. 자기가 만든 PR 은 자신이 리뷰어가 될 수 - # 없으므로 이 검색에 잡히지 않는다. --paginate 없이는 30건에서 잘린다. - if ! requested=$(gh api -X GET search/issues \ - -f q="is:open is:pr repo:$REPO review-requested:$AUTHOR" --paginate \ - --jq '.items[] | "\(.number)\t\(.created_at[0:10])\t\(.title)"'); then - echo "::error::리뷰 요청 조회에 실패했다 — 근거 없이 PR 을 닫지 않는다. 재실행할 것" + # 빚의 기준은 «나에게 요청이 걸렸는가» 가 아니라 «아무도 응하지 않은 PR 이 + # 남아 있는가» 다. 리뷰 요청은 알림 수단이지 담당 배정이 아니다 — + # 누구에게 걸렸든 팀에서 한 사람이 보면 그 PR 의 리뷰는 끝난 것이다. + # 그래서 요청 목록이 아니라 열린 PR 전체를 훑는다. + # + # 자기 PR 은 자신이 리뷰할 수 없으므로 뺀다. draft 는 리뷰를 받을 상태가 + # 아니므로 뺀다(review-request-all 도 draft 에는 요청을 걸지 않는다). + if ! open_prs=$(gh api "repos/$REPO/pulls?state=open&per_page=100" --paginate \ + --jq ".[] | select(.draft == false) | select(.user.login != \"$AUTHOR\") | \"\(.number)\t\(.created_at[0:10])\t\(.title)\""); then + echo "::error::열린 PR 조회에 실패했다 — 근거 없이 PR 을 닫지 않는다. 재실행할 것" exit 1 fi - # 요청이 걸렸다고 다 빚은 아니다. 두 가지를 걸러낸다. + # 남은 것에서 «이미 응답받은» PR 을 걸러낸다. # - # 1. «누가» 봤는지는 따지지 않는다. PR 하나에 필요한 리뷰는 한 건이므로, - # 다른 사람이 이미 판정을 냈으면 내 몫은 끝난 것으로 본다. 요청을 - # 전원에게 거는 것은 «누구든 먼저 보라» 는 뜻이지 «둘 다 보라» 가 아니다. + # 1. «누가» 봤는지는 따지지 않는다. 다른 사람이 이미 판정을 냈으면 끝이다. # 2. 병합 커밋은 «새 코드» 로 치지 않는다. develop 전파만 쌓인 PR 이 - # 미리뷰로 잡히면, 이미 승인해 둔 스택 PR 이 통째로 빚이 된다. + # 미응답으로 잡히면, 이미 승인해 둔 스택 PR 이 통째로 빚이 된다. # parents 가 둘 이상인 커밋을 빼고 마지막 실질 변경 시각을 잡는다. debt_file=$(mktemp) while IFS=$'\t' read -r pn created title; do @@ -83,12 +85,12 @@ jobs: if [ "${judged:-0}" -eq 0 ]; then printf '%s\t%s\t%s\n' "$pn" "$created" "$title" >> "$debt_file" else - echo "#$pn — 최신 코드에 이미 판정이 있음(${judged}건), 빚 아님" + echo "#$pn — 이미 응답됨(판정 ${judged}건), 빚 아님" fi - done <<< "$requested" + done <<< "$open_prs" if [ ! -s "$debt_file" ]; then - echo "미처리 리뷰 요청 없음 — 통과" + echo "응답을 기다리는 PR 없음 — 통과" exit 0 fi @@ -99,7 +101,7 @@ jobs: { echo "## 🚧 리뷰 적체 가드 — 이 PR 을 닫습니다" echo - echo "@$AUTHOR 님이 아직 처리하지 않은 리뷰 요청이 **${count}건** 있습니다." + echo "@$AUTHOR 님이 아직 리뷰하지 않았고 **다른 누구도 응답하지 않은** PR 이 **${count}건** 있습니다." echo echo "| PR | 열린 날 | 경과 | 제목 |" echo "|---|---|---|---|" @@ -110,9 +112,9 @@ jobs: echo "| #$n | $created | ${days}일 | $title |" done <<< "$debt" echo - echo "이 저장소는 미처리 리뷰 요청이 1건이라도 있으면 새 PR 을 열 수 없습니다 (#1136)." + echo "이 저장소는 아무도 응답하지 않은 PR 이 1건이라도 남아 있으면 새 PR 을 열 수 없습니다 (#1136)." echo - echo "각 PR 에 필요한 리뷰는 **한 건**입니다 — 다른 사람이 먼저 판정을 내면 그 PR 은 목록에서 빠집니다." + echo "각 PR 에 필요한 리뷰는 **한 건**입니다 — 팀에서 누구든 먼저 판정을 내면 그 PR 은 모두의 목록에서 빠집니다. 리뷰 요청이 나에게 걸렸는지는 무관합니다." echo echo "### 되살리는 법" echo @@ -125,7 +127,7 @@ jobs: echo "리뷰보다 이 PR 이 급하면 \`review-debt-exempt\` 라벨을 붙이고 다시 열면 통과합니다." } > /tmp/debt-comment.md - echo "미처리 ${count}건 — PR #$PR_NUMBER 를 닫는다" + echo "미응답 ${count}건 — PR #$PR_NUMBER 를 닫는다" gh pr close "$PR_NUMBER" --repo "$REPO" --comment "$(cat /tmp/debt-comment.md)" bypass-audit: From bae71e8854fced17914e855832e4890e8af4b7a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Tue, 25 Aug 2026 21:18:00 +0900 Subject: [PATCH 2/3] =?UTF-8?q?ci(review-debt-guard):=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=EC=9D=B4=20=ED=95=9C=20=EB=B2=88=20=EB=82=98=EC=99=94=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=EA=B7=B8=20PR=20=EC=9D=80=20=EB=81=9D=EC=9D=B4?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 승인 뒤에 작성자가 손보는 것은 작성자 몫이고, 그 변경을 다시 봐 줄 의무는 누구에게도 없다. 리뷰가 다시 필요하면 작성자가 요청을 걸면 된다. 그래서 «마지막 실질 변경 이후» 라는 시점 조건을 걷어낸다. 판정 (APPROVED·CHANGES_REQUESTED)이 한 번이라도 나온 PR 은 빚에서 제외한다. 커밋 시각을 볼 일이 없어져 parents 조회가 통째로 빠지고, PR 당 API 가 2회에서 1회로 준다. 가드 실행도 그만큼 짧아진다. 실측(로컬 드라이런, close 직전까지): koongmai 4 Sadturtleman 4 1hyok 0 (통과) 열린 non-draft PR 48건 중 판정이 한 번도 없는 것은 #1133 #1134 #1138 #1139 넷뿐이고 모두 1hyok 의 PR 이라, 두 사람의 빚이 각각 4건으로 잡힌다. --- .github/workflows/review-debt-guard.yml | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/.github/workflows/review-debt-guard.yml b/.github/workflows/review-debt-guard.yml index 3cfee13d04..d7e63c42e3 100644 --- a/.github/workflows/review-debt-guard.yml +++ b/.github/workflows/review-debt-guard.yml @@ -67,25 +67,21 @@ jobs: # 남은 것에서 «이미 응답받은» PR 을 걸러낸다. # - # 1. «누가» 봤는지는 따지지 않는다. 다른 사람이 이미 판정을 냈으면 끝이다. - # 2. 병합 커밋은 «새 코드» 로 치지 않는다. develop 전파만 쌓인 PR 이 - # 미응답으로 잡히면, 이미 승인해 둔 스택 PR 이 통째로 빚이 된다. - # parents 가 둘 이상인 커밋을 빼고 마지막 실질 변경 시각을 잡는다. + # «누가», «언제» 봤는지는 따지지 않는다. 판정(APPROVED·CHANGES_REQUESTED)이 + # 한 번이라도 나왔으면 그 PR 에 대한 리뷰 의무는 끝난 것이다. 승인 뒤에 + # 작성자가 손보는 것은 작성자 몫이고, 그 변경을 다시 봐 줄 의무는 누구에게도 + # 없다. 리뷰가 필요하면 작성자가 요청을 다시 걸면 된다. debt_file=$(mktemp) while IFS=$'\t' read -r pn created title; do [ -n "$pn" ] || continue - changed_at=$(gh api "repos/$REPO/pulls/$pn/commits?per_page=100" --paginate \ - --jq '[.[] | select((.parents | length) < 2)] | last | .commit.committer.date' 2>/dev/null || true) - [ -n "$changed_at" ] && [ "$changed_at" != "null" ] || changed_at="1970-01-01T00:00:00Z" - judged=$(gh api "repos/$REPO/pulls/$pn/reviews" --paginate \ - --jq "[.[] | select(.submitted_at > \"$changed_at\") | select(.state == \"APPROVED\" or .state == \"CHANGES_REQUESTED\")] | length" 2>/dev/null || true) + --jq '[.[] | select(.state == "APPROVED" or .state == "CHANGES_REQUESTED")] | length' 2>/dev/null || true) if [ "${judged:-0}" -eq 0 ]; then printf '%s\t%s\t%s\n' "$pn" "$created" "$title" >> "$debt_file" else - echo "#$pn — 이미 응답됨(판정 ${judged}건), 빚 아님" + echo "#$pn — 이미 판정 ${judged}건, 빚 아님" fi done <<< "$open_prs" @@ -101,7 +97,7 @@ jobs: { echo "## 🚧 리뷰 적체 가드 — 이 PR 을 닫습니다" echo - echo "@$AUTHOR 님이 아직 리뷰하지 않았고 **다른 누구도 응답하지 않은** PR 이 **${count}건** 있습니다." + echo "**아무도 리뷰하지 않은** PR 이 **${count}건** 남아 있습니다." echo echo "| PR | 열린 날 | 경과 | 제목 |" echo "|---|---|---|---|" @@ -114,7 +110,7 @@ jobs: echo echo "이 저장소는 아무도 응답하지 않은 PR 이 1건이라도 남아 있으면 새 PR 을 열 수 없습니다 (#1136)." echo - echo "각 PR 에 필요한 리뷰는 **한 건**입니다 — 팀에서 누구든 먼저 판정을 내면 그 PR 은 모두의 목록에서 빠집니다. 리뷰 요청이 나에게 걸렸는지는 무관합니다." + echo "각 PR 에 필요한 리뷰는 **한 건**입니다 — 누구든 한 번 판정을 내면 그 PR 은 모두의 목록에서 빠집니다. 승인 뒤에 올라온 변경을 다시 봐 줄 의무는 없습니다." echo echo "### 되살리는 법" echo From e6f427bd3190aa936d226c10f7f6aa2ddc99c4b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Tue, 25 Aug 2026 21:23:43 +0900 Subject: [PATCH 3/3] =?UTF-8?q?ci(review-debt-guard):=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EC=9D=80=20=EB=B0=98=EC=98=81=EB=90=9C=20?= =?UTF-8?q?=EC=88=9C=EA=B0=84=20=EB=8B=A4=EC=8B=9C=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=EC=96=B4=EC=9D=98=20=EB=AA=AB=EC=9D=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 승인과 변경요청의 무게가 다르다. APPROVED 머지 가능한 상태다. 그 뒤 작성자가 손보는 것은 작성자 몫이고 다시 봐 줄 의무는 누구에게도 없다. CHANGES_REQUESTED 끝난 판정이 아니다. 작성자가 지적을 반영하면 공이 리뷰어에게 돌아온다. 재리뷰가 없으면 그 PR 은 영영 머지되지 못한다. 직전 커밋은 둘을 «판정 1건» 으로 묶어, 변경요청을 받고 반영까지 끝낸 #821 이 빚에서 빠졌다. 그 상태로는 아무도 재리뷰할 의무가 없어 PR 이 갇힌다. 그래서 빚을 두 종류로 나눈다. 1. 아무도 판정을 내지 않은 PR 2. 미해소 변경요청이 있는데 작성자가 이미 반영 커밋을 올린 PR 변경요청 뒤로 새 커밋이 없으면 공은 아직 작성자에게 있으므로 빚이 아니다. 반영 여부는 병합 커밋(parents 2개 이상)을 뺀 실질 커밋으로만 센다. 실측(로컬 드라이런, close 직전까지): koongmai 11 Sadturtleman 11 1hyok 0 (통과) #1133 아무도 판정하지 않음 #966 승인됨, 빚 아님 #821 변경요청 반영(4커밋) 뒤 재리뷰 없음 #958 변경요청 미반영, 공은 작성자에게 --- .github/workflows/review-debt-guard.yml | 52 ++++++++++++++++++++----- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/.github/workflows/review-debt-guard.yml b/.github/workflows/review-debt-guard.yml index d7e63c42e3..3df105ced0 100644 --- a/.github/workflows/review-debt-guard.yml +++ b/.github/workflows/review-debt-guard.yml @@ -65,23 +65,55 @@ jobs: exit 1 fi - # 남은 것에서 «이미 응답받은» PR 을 걸러낸다. + # 남은 것에서 «리뷰어의 공이 아닌» PR 을 걸러낸다. «누가» 봤는지는 따지지 + # 않는다 — 팀에서 한 사람이 보면 그 PR 의 리뷰는 채워진 것이다. # - # «누가», «언제» 봤는지는 따지지 않는다. 판정(APPROVED·CHANGES_REQUESTED)이 - # 한 번이라도 나왔으면 그 PR 에 대한 리뷰 의무는 끝난 것이다. 승인 뒤에 - # 작성자가 손보는 것은 작성자 몫이고, 그 변경을 다시 봐 줄 의무는 누구에게도 - # 없다. 리뷰가 필요하면 작성자가 요청을 다시 걸면 된다. + # 승인과 변경요청은 무게가 다르다. + # APPROVED 머지 가능한 상태다. 그 뒤에 작성자가 손보는 것은 + # 작성자 몫이고, 다시 봐 줄 의무는 누구에게도 없다. + # CHANGES_REQUESTED 아직 끝난 판정이 아니다. 작성자가 지적을 반영하면 + # 공이 리뷰어에게 돌아온다. 재리뷰가 없으면 그 PR 은 + # 영영 머지되지 못한다. + # + # 그래서 빚은 두 종류다. (1) 아무도 판정을 내지 않은 PR, + # (2) 미해소 변경요청이 있는데 작성자가 이미 반영 커밋을 올린 PR. + # 변경요청 뒤로 새 커밋이 없으면 공은 아직 작성자에게 있으므로 빚이 아니다. + # 반영 여부는 병합 커밋(parents 2개 이상)을 뺀 실질 커밋으로만 센다. debt_file=$(mktemp) + reviews_file=$(mktemp) while IFS=$'\t' read -r pn created title; do [ -n "$pn" ] || continue - judged=$(gh api "repos/$REPO/pulls/$pn/reviews" --paginate \ - --jq '[.[] | select(.state == "APPROVED" or .state == "CHANGES_REQUESTED")] | length' 2>/dev/null || true) + gh api "repos/$REPO/pulls/$pn/reviews" --paginate \ + --jq '.[] | select(.state == "APPROVED" or .state == "CHANGES_REQUESTED") + | {u: .user.login, s: .state, t: .submitted_at}' \ + > "$reviews_file" 2>/dev/null || true + + if [ ! -s "$reviews_file" ]; then + echo "#$pn — 아무도 판정하지 않음" + printf '%s\t%s\t%s\n' "$pn" "$created" "$title" >> "$debt_file" + continue + fi + + # 리뷰어별 최신 판정이 CHANGES_REQUESTED 인 것 중 가장 늦은 시각. + blocked_at=$(jq -rs 'group_by(.u) | map(sort_by(.t) | last) + | map(select(.s == "CHANGES_REQUESTED")) + | sort_by(.t) | last | .t // empty' "$reviews_file" 2>/dev/null || true) + + if [ -z "$blocked_at" ]; then + echo "#$pn — 승인됨, 빚 아님" + continue + fi + + fixed=$(gh api "repos/$REPO/pulls/$pn/commits?per_page=100" --paginate \ + --jq "[.[] | select((.parents | length) < 2) + | select(.commit.committer.date > \"$blocked_at\")] | length" 2>/dev/null || true) - if [ "${judged:-0}" -eq 0 ]; then + if [ "${fixed:-0}" -gt 0 ]; then + echo "#$pn — 변경요청 반영(${fixed}커밋) 뒤 재리뷰 없음" printf '%s\t%s\t%s\n' "$pn" "$created" "$title" >> "$debt_file" else - echo "#$pn — 이미 판정 ${judged}건, 빚 아님" + echo "#$pn — 변경요청 미반영, 공은 작성자에게" fi done <<< "$open_prs" @@ -110,7 +142,7 @@ jobs: echo echo "이 저장소는 아무도 응답하지 않은 PR 이 1건이라도 남아 있으면 새 PR 을 열 수 없습니다 (#1136)." echo - echo "각 PR 에 필요한 리뷰는 **한 건**입니다 — 누구든 한 번 판정을 내면 그 PR 은 모두의 목록에서 빠집니다. 승인 뒤에 올라온 변경을 다시 봐 줄 의무는 없습니다." + echo "각 PR 에 필요한 리뷰는 **한 건**입니다 — 누구든 먼저 판정을 내면 그 PR 은 모두의 목록에서 빠집니다. 승인 뒤에 올라온 변경을 다시 봐 줄 의무는 없지만, **변경요청은 작성자가 반영한 시점에 다시 리뷰어의 몫**이 됩니다." echo echo "### 되살리는 법" echo