From 13399525781652efd0645840ca5f0f215d17f804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 30 Aug 2026 02:48:42 +0900 Subject: [PATCH] =?UTF-8?q?fix(ci):=20=EB=A6=AC=EB=B7=B0=20=EC=A0=81?= =?UTF-8?q?=EC=B2=B4=20=EA=B0=80=EB=93=9C=EA=B0=80=20=EC=84=B8=EB=8A=94=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20=EC=BB=A4=EB=B0=8B=EC=9D=84=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=EC=9E=90=EC=9D=98=20=EC=8B=A4=EC=A7=88=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=9C=BC=EB=A1=9C=20=EC=A2=81=ED=9E=8C=EB=8B=A4=20(#1?= =?UTF-8?q?459)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 가드는 최신 변경요청 이후의 비병합 커밋 개수만으로 «작성자가 반영했다» 를 판정했다. 누가 올렸는지도, 무엇이 바뀌었는지도 조건에 없었다. 그래서 리뷰어가 CI 를 다시 트리거하려고 미는 +0/-0 빈 커밋이 작성자의 반영으로 집계됐다. 재트리거는 이 저장소의 일상적 조치라, 그렇게 한 리뷰어가 그 대가로 자기 PR 을 못 열게 된다 — 8/28 에 건 빈 커밋 하나로 koongmai PR 3건이 전부 빚이 됐다. 집계에 두 조건을 세운다. - 작성자 커밋만 센다. .author.login 으로 가리고, 계정이 연결되지 않은 커밋은 같은 PR 에서 작성자 것으로 확인된 커밋의 이메일을 폴백 신원으로 쓴다. - 빈 커밋을 뺀다. pulls/{n}/commits 에는 파일 정보가 없어 위에서 좁힌 후보에 한해 commits/{sha} 를 조회한다. 조회가 실패하면 반영으로 센다 — 빚을 씌우는 오탐보다 놓치는 쪽이 안전하고, 놓친 빚은 다음 PR 에서 다시 잡힌다. 실측(cutoff 를 8/28 00:00Z 로 놓고 재현): #882 는 2 → 0 으로 가짜 빚이 사라지고, #1379·#1365 는 2 → 1 로 작성자의 실질 커밋만 남는다. 미탐은 생기지 않는다. 회귀는 review-debt-policy.test.mjs 에 리뷰어 커밋·빈 커밋 두 케이스로 고정했다. 두 단언 모두 develop 판 워크플로에 대고 먼저 실패함을 확인했다. --- .github/scripts/review-debt-policy.test.mjs | 27 +++++++++++- .github/workflows/review-debt-guard.yml | 48 +++++++++++++++++++-- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/.github/scripts/review-debt-policy.test.mjs b/.github/scripts/review-debt-policy.test.mjs index a6240bc5e8..ce0de8bdf1 100644 --- a/.github/scripts/review-debt-policy.test.mjs +++ b/.github/scripts/review-debt-policy.test.mjs @@ -63,7 +63,32 @@ test("changes requested becomes debt only after explicit rerequest and a fix", ( assert.match(guard, /sort_by\(\.t\) \| last/); assert.doesNotMatch(guard, /group_by\(\.u/); assert.match(guard, /\(\.parents \| length\) < 2/); - assert.match(guard, /select\(\.commit\.committer\.date > \\"\$blocked_at\\"\)/); + // 반영 판정의 기준 시각은 여전히 최신 변경요청 시각이다. 집계가 jq 에서 awk 로 + // 옮겨 갔을 뿐, 그 이전 커밋을 반영으로 세지 않는다. + assert.match(guard, /-v cutoff="\$blocked_at"/); + assert.match(guard, /\$4 > cutoff/); +}); + +test("a reviewer's own commit is never counted as the author's fix", () => { + // 누가 올렸는지를 안 보면 리뷰어가 미는 CI 재트리거 커밋이 «작성자가 반영했다» 가 + // 된다. 8/28 에 리뷰어가 건 +0/-0 커밋 하나로 koongmai PR 3건(#1379·#1365·#882)이 + // 전부 가짜 빚이 됐고, 재트리거한 리뷰어가 그 대가로 자기 PR 을 못 열었다 (#1459). + assert.match(guard, /\(\.author\.login \/\/ ""\)/); + assert.match(guard, /login == target/); + // 계정이 연결되지 않은 커밋은 login 이 비어 가릴 수 없다. 같은 PR 에서 작성자 + // 것으로 확인된 커밋의 이메일을 폴백 신원으로 쓴다. + assert.match(guard, /\(\.commit\.author\.email \/\/ ""\)/); + assert.match(guard, /email in own/); +}); + +test("an empty commit is not a fix", () => { + // pulls/{n}/commits 응답에는 파일 정보가 없다. 작성자 커밋으로 좁힌 후보에 한해 + // commits/{sha} 를 조회해 바뀐 파일이 0건이면 버린다. + assert.match(guard, /repos\/\$REPO\/commits\/\$sha/); + assert.match(guard, /\(\.files \/\/ \[\]\) \| length/); + assert.match(guard, /\[ "\$changed" -gt 0 \] \|\| continue/); + // 조회 실패를 «빈 커밋» 으로 접으면 조회 장애가 빚을 통째로 지운다. 반영으로 센다. + assert.match(guard, /\'\'\|\*\[!0-9\]\*\) changed=1/); }); test("a fix delivered by a merge commit still counts as a response", () => { diff --git a/.github/workflows/review-debt-guard.yml b/.github/workflows/review-debt-guard.yml index a92a6289fa..dec1631a56 100644 --- a/.github/workflows/review-debt-guard.yml +++ b/.github/workflows/review-debt-guard.yml @@ -144,10 +144,50 @@ jobs: # # 페이지마다 집계하면 100건이 넘는 PR 에서 숫자가 여러 줄로 나와 비교가 # 깨진다. 건별로 뽑아 한 번에 센다. - fixed=$( { gh api "repos/$REPO/pulls/$pn/commits?per_page=100" --paginate \ - --jq ".[] | select((.parents | length) < 2) - | select(.commit.committer.date > \"$blocked_at\") | .sha" 2>/dev/null || true; } \ - | wc -l | tr -d ' ') + # + # 세는 것은 «작성자가 올린 실질 변경» 뿐이다. 누가 올렸는지·무엇이 바뀌었는지를 + # 안 보면 리뷰어가 미는 CI 재트리거 빈 커밋까지 «작성자가 반영했다» 로 세어, + # 재트리거한 리뷰어가 그 대가로 자기 PR 을 못 열게 된다 — 8/28 에 리뷰어가 건 + # +0/-0 커밋 하나로 koongmai PR 3건(#1379·#1365·#882)이 전부 가짜 빚이 됐다(#1459). + commits_file=$(mktemp) + { gh api "repos/$REPO/pulls/$pn/commits?per_page=100" --paginate \ + --jq '.[] | select((.parents | length) < 2) + | [.sha, (.author.login // ""), (.commit.author.email // ""), .commit.committer.date] + | @tsv' 2>/dev/null || true; } > "$commits_file" + + # 계정이 연결되지 않은 커밋은 .author 가 비어 login 으로 가릴 수 없다. 같은 PR 에서 + # 작성자 것으로 확인된 커밋의 이메일을 모아 폴백 신원으로 쓴다. + author_emails=$(awk -F'\t' -v author="$pr_author" ' + BEGIN { target = tolower(author) } + tolower($2) == target && $3 != "" { print tolower($3) }' "$commits_file" | sort -u) + + author_commits=$(awk -F'\t' -v author="$pr_author" -v cutoff="$blocked_at" -v emails="$author_emails" ' + BEGIN { + target = tolower(author) + count = split(emails, list, "\n") + for (i = 1; i <= count; i++) if (list[i] != "") own[list[i]] = 1 + } + $4 > cutoff { + login = tolower($2) + email = tolower($3) + if (login != "") { if (login == target) print $1 } + else if (email != "" && (email in own)) print $1 + }' "$commits_file") + + # 빈 커밋은 반영이 아니다. pulls/{n}/commits 응답에는 파일 정보가 없어 후보에 + # 한해 commits/{sha} 를 더 조회한다 — 후보는 대개 한 자릿수라 비용이 작다. + # 조회가 실패하면 반영으로 센다. 빚을 씌우는 오탐보다 놓치는 쪽이 안전하고, + # 놓친 빚은 다음 PR 에서 다시 잡힌다. + fixed=0 + while IFS= read -r sha; do + [ -n "$sha" ] || continue + changed=$( { gh api "repos/$REPO/commits/$sha" --jq '(.files // []) | length' 2>/dev/null || true; } | tail -1) + case "$changed" in + ''|*[!0-9]*) changed=1 ;; + esac + [ "$changed" -gt 0 ] || continue + fixed=$((fixed + 1)) + done <<< "$author_commits" said=$( { gh api "repos/$REPO/issues/$pn/comments?per_page=100" --paginate \ --jq ".[] | select(.user.login == \"$pr_author\")