diff --git a/.github/workflows/review-debt-guard.yml b/.github/workflows/review-debt-guard.yml index 5e3668b363..3df105ced0 100644 --- a/.github/workflows/review-debt-guard.yml +++ b/.github/workflows/review-debt-guard.yml @@ -52,43 +52,73 @@ 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 을 걸러낸다. «누가» 봤는지는 따지지 + # 않는다 — 팀에서 한 사람이 보면 그 PR 의 리뷰는 채워진 것이다. + # + # 승인과 변경요청은 무게가 다르다. + # APPROVED 머지 가능한 상태다. 그 뒤에 작성자가 손보는 것은 + # 작성자 몫이고, 다시 봐 줄 의무는 누구에게도 없다. + # CHANGES_REQUESTED 아직 끝난 판정이 아니다. 작성자가 지적을 반영하면 + # 공이 리뷰어에게 돌아온다. 재리뷰가 없으면 그 PR 은 + # 영영 머지되지 못한다. # - # 1. «누가» 봤는지는 따지지 않는다. PR 하나에 필요한 리뷰는 한 건이므로, - # 다른 사람이 이미 판정을 냈으면 내 몫은 끝난 것으로 본다. 요청을 - # 전원에게 거는 것은 «누구든 먼저 보라» 는 뜻이지 «둘 다 보라» 가 아니다. - # 2. 병합 커밋은 «새 코드» 로 치지 않는다. develop 전파만 쌓인 PR 이 - # 미리뷰로 잡히면, 이미 승인해 둔 스택 PR 이 통째로 빚이 된다. - # parents 가 둘 이상인 커밋을 빼고 마지막 실질 변경 시각을 잡는다. + # 그래서 빚은 두 종류다. (1) 아무도 판정을 내지 않은 PR, + # (2) 미해소 변경요청이 있는데 작성자가 이미 반영 커밋을 올린 PR. + # 변경요청 뒤로 새 커밋이 없으면 공은 아직 작성자에게 있으므로 빚이 아니다. + # 반영 여부는 병합 커밋(parents 2개 이상)을 뺀 실질 커밋으로만 센다. debt_file=$(mktemp) + reviews_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" + 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 - 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) + 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 <<< "$requested" + done <<< "$open_prs" if [ ! -s "$debt_file" ]; then - echo "미처리 리뷰 요청 없음 — 통과" + echo "응답을 기다리는 PR 없음 — 통과" exit 0 fi @@ -99,7 +129,7 @@ jobs: { echo "## 🚧 리뷰 적체 가드 — 이 PR 을 닫습니다" echo - echo "@$AUTHOR 님이 아직 처리하지 않은 리뷰 요청이 **${count}건** 있습니다." + echo "**아무도 리뷰하지 않은** PR 이 **${count}건** 남아 있습니다." echo echo "| PR | 열린 날 | 경과 | 제목 |" echo "|---|---|---|---|" @@ -110,9 +140,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 +155,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: