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\")