diff --git a/.github/scripts/check-author-debt.mjs b/.github/scripts/check-author-debt.mjs index 7976ae66e..954394d4d 100644 --- a/.github/scripts/check-author-debt.mjs +++ b/.github/scripts/check-author-debt.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node -// 새 PR 작성자가 변경요청을 받은 자기 PR 을 방치하고 있는지 live 상태로 검사한다. +// 새 PR 을 여는 사람이 변경요청을 받은 «자기가 담당하는» PR 을 방치하고 있는지 live 상태로 +// 검사한다. 담당은 어사인이고 어사인이 비면 작성자다(#1974) — AUTHOR 는 여는 사람의 로그인이지 +// «작성자 필터» 가 아니다. // 라벨은 스테일할 수 있으므로 입장 판정에 사용하지 않는다. 열린 PR 을 한 번 조회한 뒤 // label-awaiting-author-prs.mjs 의 SSOT 판정을 재사용하고, 워크플로가 댓글과 close 를 한 번만 // 처리할 수 있도록 결과만 TSV 로 넘긴다. diff --git a/.github/scripts/check-author-debt.test.mjs b/.github/scripts/check-author-debt.test.mjs index 6b6abf07c..12e062a64 100644 --- a/.github/scripts/check-author-debt.test.mjs +++ b/.github/scripts/check-author-debt.test.mjs @@ -39,10 +39,20 @@ function graphqlApi(pullRequests) { return async (apiPath, options = {}) => { assert.equal(apiPath, "/graphql"); assert.equal(options.method, "POST"); - assert.equal( - options.body.variables.searchQuery, - "repo:Afternote/Afternote-FE is:pr is:open author:author", + // 담당은 어사인이 정본이고 어사인이 비면 작성자라 두 축을 모두 검색한다(#1974). + const query = options.body.variables.searchQuery; + assert.ok( + query === "repo:Afternote/Afternote-FE is:pr is:open assignee:author" || + query === "repo:Afternote/Afternote-FE is:pr is:open author:author", + query, ); + if (query.endsWith("author:author")) { + return { + data: { + search: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + }, + }; + } return { data: { search: { diff --git a/.github/scripts/label-awaiting-author-prs.mjs b/.github/scripts/label-awaiting-author-prs.mjs index 9780092b5..05aaac278 100644 --- a/.github/scripts/label-awaiting-author-prs.mjs +++ b/.github/scripts/label-awaiting-author-prs.mjs @@ -47,6 +47,7 @@ query($owner: String!, $name: String!, $cursor: String, $pageSize: Int!) { isDraft createdAt author { login } + assignees(first: 10) { nodes { login } } headRepository { nameWithOwner } labels(first: 50) { nodes { name } } reviews(last: 50) { @@ -99,6 +100,7 @@ query($searchQuery: String!, $cursor: String, $pageSize: Int!) { isDraft createdAt author { login } + assignees(first: 10) { nodes { login } } headRepository { nameWithOwner } labels(first: 50) { nodes { name } } reviews(last: 50) { @@ -152,6 +154,33 @@ function sameLogin(a, b) { return typeof a === "string" && typeof b === "string" && a.toLowerCase() === b.toLowerCase(); } +function isOneOf(login, owners) { + return (owners ?? []).some((owner) => sameLogin(login, owner)); +} + +function toOwners(author) { + return Array.isArray(author) ? author : [author]; +} + +/** + * PR 의 담당자 로그인 목록. + * + * PR 의 공은 «올린 사람» 이 아니라 «어사인된 사람» 에게 있다(#1974 결정, #1980). 변경요청에 + * 응답할 의무도, 그 미응답이 막는 «새 PR 열기» 도 담당자의 몫이다. 어사인이 비어 있는 동안만 + * 작성자가 담당자다 — pr-assign-author 가 빈 어사인에 작성자를 걸므로 잠깐 비는 창에서도 + * 결과가 같다. + */ +export function responsibleLogins(pullRequest) { + const assignees = (pullRequest?.assignees?.nodes ?? []) + .map((node) => node?.login) + .filter((login) => typeof login === "string" && login !== ""); + if (assignees.length > 0) { + return assignees; + } + const author = pullRequest?.author?.login; + return typeof author === "string" && author !== "" ? [author] : []; +} + /** * PR 전체에서 «최신 결정 리뷰» 를 고른다. * @@ -205,11 +234,12 @@ export function isCoveredSinceDecision(connectionName, nodes, since) { * «빈 커밋» 으로 접으면 라벨이 없는 근거로 둔갑한다 — 놓치는 쪽이 잘못 붙이는 쪽보다 안전하다. */ export function countAuthorFixes({ commits, author, since }) { + const owners = toOwners(author); const nodes = (commits ?? []).map((node) => node?.commit).filter(Boolean); const ownEmails = new Set(); for (const commit of nodes) { - if (sameLogin(commit.author?.user?.login, author) && commit.author?.email) { + if (isOneOf(commit.author?.user?.login, owners) && commit.author?.email) { ownEmails.add(commit.author.email.toLowerCase()); } } @@ -225,7 +255,7 @@ export function countAuthorFixes({ commits, author, since }) { const login = commit.author?.user?.login; const email = commit.author?.email?.toLowerCase(); - const isAuthor = login ? sameLogin(login, author) : Boolean(email && ownEmails.has(email)); + const isAuthor = login ? isOneOf(login, owners) : Boolean(email && ownEmails.has(email)); if (!isAuthor) { continue; } @@ -251,16 +281,17 @@ export function countAuthorFixes({ commits, author, since }) { * `countAuthorBodyEdits` 에서 센다. */ export function countAuthorResponses({ comments, reviews, author, since }) { + const owners = toOwners(author); const issueComments = (comments ?? []).filter( (comment) => - sameLogin(comment?.author?.login, author) && + isOneOf(comment?.author?.login, owners) && typeof comment?.createdAt === "string" && comment.createdAt > since, ).length; const authorReviews = (reviews ?? []).filter( (review) => - sameLogin(review?.author?.login, author) && + isOneOf(review?.author?.login, owners) && typeof review?.submittedAt === "string" && review.submittedAt > since, ).length; @@ -277,9 +308,10 @@ export function countAuthorResponses({ comments, reviews, author, since }) { * 기록은 작성자 조치로 추정하지 않는다. */ export function countAuthorBodyEdits({ userContentEdits, author, since }) { + const owners = toOwners(author); return (userContentEdits ?? []).filter( (edit) => - sameLogin(edit?.editor?.login, author) && + isOneOf(edit?.editor?.login, owners) && typeof edit?.editedAt === "string" && edit.editedAt > since, ).length; @@ -293,11 +325,13 @@ export function countAuthorBodyEdits({ userContentEdits, author, since }) { */ export function judgeAwaitingAuthor(pullRequest, { repository } = {}) { const author = pullRequest?.author?.login ?? ""; + // 조치 주체는 담당자다. 어사인이 비어 있으면 작성자로 폴백한다. + const owners = responsibleLogins(pullRequest); if (pullRequest?.isDraft) { return { awaiting: false, reason: "draft" }; } - if (!author) { + if (!author || owners.length === 0) { return { awaiting: false, reason: "작성자 없음" }; } if (/\[bot\]$/i.test(author) || author === "dependabot" || author === "github-actions") { @@ -320,34 +354,36 @@ export function judgeAwaitingAuthor(pullRequest, { repository } = {}) { const since = decision.submittedAt; const fixes = countAuthorFixes({ commits: pullRequest?.commits?.nodes, - author, + author: owners, since, }); const responses = countAuthorResponses({ comments: pullRequest?.comments?.nodes, reviews: pullRequest?.reviews?.nodes, - author, + author: owners, since, }); const bodyEdits = countAuthorBodyEdits({ userContentEdits: pullRequest?.userContentEdits?.nodes, - author, + author: owners, since, }); if (fixes > 0 || responses > 0 || bodyEdits > 0) { return { awaiting: false, - reason: `작성자 조치 있음(${fixes}커밋 · 응답 ${responses}건 · 본문 편집 ${bodyEdits}건)`, + reason: `담당자 조치 있음(${fixes}커밋 · 응답 ${responses}건 · 본문 편집 ${bodyEdits}건)`, decidedAt: since, + owners, }; } return { awaiting: true, - reason: "변경요청 뒤 작성자 조치 없음", + reason: "변경요청 뒤 담당자 조치 없음", decidedAt: since, reviewer: decision.author?.login ?? "", + owners, }; } @@ -355,8 +391,9 @@ export function judgeAwaitingAuthor(pullRequest, { repository } = {}) { * 새 PR 작성자가 먼저 처리해야 할 다른 열린 PR 을 고른다. * * 열린 PR 목록은 호출자가 한 번 live 조회해 넘긴다. 현재 PR 은 이벤트 직후 그 목록에 이미 - * 포함되므로 번호로 명시적으로 제외한다. 작성자 login 은 GitHub 의 대소문자 비구분 규약에 - * 맞춰 비교하고, 실제 무조치 여부는 라벨이 아니라 `judgeAwaitingAuthor` 로 매번 다시 판정한다. + * 포함되므로 번호로 명시적으로 제외한다. «자기 PR» 은 작성자가 아니라 담당자(어사인, 비어 + * 있으면 작성자)로 가른다. login 은 GitHub 의 대소문자 비구분 규약에 맞춰 비교하고, 실제 + * 무조치 여부는 라벨이 아니라 `judgeAwaitingAuthor` 로 매번 다시 판정한다. */ export function findAuthorDebts({ pullRequests, repository, author, currentPullRequestNumber }) { if (!Array.isArray(pullRequests)) { @@ -376,7 +413,9 @@ export function findAuthorDebts({ pullRequests, repository, author, currentPullR if (Number(pullRequest?.number) === currentNumber) { continue; } - if (!sameLogin(pullRequest?.author?.login, author)) { + // 빚의 주인은 담당자다. 남이 올렸어도 내가 어사인된 PR 이면 내 빚이고, 내가 올렸어도 + // 남에게 어사인된 PR 이면 내 빚이 아니다. + if (!isOneOf(author, responsibleLogins(pullRequest))) { continue; } @@ -411,14 +450,17 @@ export function planAwaitingAuthorLabels({ pullRequests, repository, label = DEF for (const pullRequest of pullRequests ?? []) { const labels = (pullRequest?.labels?.nodes ?? []).map((node) => node?.name); const labeled = labels.includes(label); - const exempt = exemptAuthors.some((login) => sameLogin(login, pullRequest?.author?.login)); + // 면제는 담당자 기준이다. 담당자 전원이 면제 목록에 있을 때만 뺀다 — 면제자와 비면제자가 + // 함께 어사인된 PR 은 비면제자의 몫이 남아 있다. + const owners = responsibleLogins(pullRequest); + const exempt = owners.length > 0 && owners.every((login) => isOneOf(login, exemptAuthors)); const verdict = exempt ? { awaiting: false, reason: "리뷰 게이트 면제 작성자" } : judgeAwaitingAuthor(pullRequest, { repository }); const entry = { number: pullRequest.number, title: pullRequest.title, - author: pullRequest?.author?.login ?? "", + author: owners.join(","), reason: verdict.reason, decidedAt: verdict.decidedAt, reviewer: verdict.reviewer, @@ -445,7 +487,7 @@ export function renderSummary({ plan, dryRun, label = DEFAULT_LABEL }) { lines.push("", `- 붙임 ${plan.toLabel.length}건 · 뗌 ${plan.toUnlabel.length}건`); if (plan.toLabel.length > 0) { - lines.push("", "| PR | 작성자 | 변경요청 | 리뷰어 |", "|---|---|---|---|"); + lines.push("", "| PR | 담당자 | 변경요청 | 리뷰어 |", "|---|---|---|---|"); for (const entry of plan.toLabel) { lines.push( `| #${entry.number} | @${entry.author} | ${entry.decidedAt?.slice(0, 10) ?? "-"} | @${entry.reviewer || "-"} |`, @@ -539,10 +581,12 @@ export async function fetchOpenPullRequests(api, repository) { } /** - * 입장 게이트용으로 한 작성자의 열린 PR 만 조회한다. + * 입장 게이트용으로 한 사람이 담당하는 열린 PR 만 조회한다. * * 라벨 리컨사일러는 전체 열린 PR 이 필요하지만, 새 PR 입장마다 그 전체의 하위 리뷰·커밋· - * 코멘트를 다시 읽을 필요는 없다. GitHub search 로 작성자를 서버에서 먼저 제한한다. + * 코멘트를 다시 읽을 필요는 없다. GitHub search 로 서버에서 먼저 제한한다. 담당은 어사인이 + * 정본이고 어사인이 비면 작성자이므로 `assignee:` 와 `author:` 두 검색을 합친다. 남에게 + * 어사인된 자기 작성 PR 은 `findAuthorDebts` 가 담당자 기준으로 걸러낸다. */ export async function fetchOpenPullRequestsByAuthor(api, repository, author) { const [owner, name] = repository.split("/"); @@ -554,11 +598,14 @@ export async function fetchOpenPullRequestsByAuthor(api, repository, author) { } const pullRequests = []; + const seen = new Set(); + + for (const qualifier of ["assignee", "author"]) { let cursor = null; for (;;) { const data = await graphql(api, OPEN_PULL_REQUESTS_BY_AUTHOR_QUERY, { - searchQuery: `repo:${owner}/${name} is:pr is:open author:${author}`, + searchQuery: `repo:${owner}/${name} is:pr is:open ${qualifier}:${author}`, cursor, pageSize: PULL_REQUEST_PAGE_SIZE, }); @@ -574,6 +621,10 @@ export async function fetchOpenPullRequestsByAuthor(api, repository, author) { if (!pullRequest || !Number.isSafeInteger(pullRequest.number)) { throw new Error("GraphQL 작성자 PR 검색 결과가 불완전합니다."); } + if (seen.has(pullRequest.number)) { + continue; + } + seen.add(pullRequest.number); // `last: 50` 앞에 더 많은 항목이 있으면 최신 결정 시각 이후의 작성자 이메일·응답· // 본문 편집을 완전하게 판정할 수 없다. 라벨은 다음 리컨사일에서 복구할 수 있지만, @@ -615,6 +666,7 @@ export async function fetchOpenPullRequestsByAuthor(api, repository, author) { throw new Error("GraphQL 작성자 PR 다음 페이지 cursor 가 없습니다."); } } + } return pullRequests; } diff --git a/.github/scripts/label-awaiting-author-prs.test.mjs b/.github/scripts/label-awaiting-author-prs.test.mjs index a3fac0922..10b4e9b43 100644 --- a/.github/scripts/label-awaiting-author-prs.test.mjs +++ b/.github/scripts/label-awaiting-author-prs.test.mjs @@ -2,11 +2,11 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - DEFAULT_LABEL, applyPlan, countAuthorBodyEdits, countAuthorFixes, countAuthorResponses, + DEFAULT_LABEL, ensureLabelExists, fetchOpenPullRequests, fetchOpenPullRequestsByAuthor, @@ -16,6 +16,7 @@ import { parseReviewGateExemptAuthors, planAwaitingAuthorLabels, renderSummary, + responsibleLogins, } from "./label-awaiting-author-prs.mjs"; const REPO = "Afternote/Afternote-FE"; @@ -267,6 +268,21 @@ test("작성자 빚은 같은 작성자의 다른 열린 PR 중 현재 무조치 author: { login: "someone-else" }, reviews: { nodes: [changesRequested()] }, }), + // 남이 올렸어도 내게 어사인된 PR 은 내 빚이다(#1974). + pullRequest({ + number: 24, + title: "fix(home): 인계받은 PR", + author: { login: "someone-else" }, + assignees: { nodes: [{ login: "author" }] }, + createdAt: "2026-08-18T10:00:00Z", + reviews: { nodes: [changesRequested("2026-08-29T00:00:00Z", "reviewer-2")] }, + }), + // 내가 올렸어도 남에게 어사인된 PR 은 내 빚이 아니다. + pullRequest({ + number: 25, + assignees: { nodes: [{ login: "someone-else" }] }, + reviews: { nodes: [changesRequested()] }, + }), ], }); @@ -277,9 +293,82 @@ test("작성자 빚은 같은 작성자의 다른 열린 PR 중 현재 무조치 reviewer: "reviewer-1", title: "fix(core): 기존 지적 반영", }, + { + number: 24, + createdDate: "2026-08-18", + reviewer: "reviewer-2", + title: "fix(home): 인계받은 PR", + }, ]); }); +test("담당자는 어사인이고, 어사인이 비어 있을 때만 작성자다", () => { + assert.deepEqual(responsibleLogins(pullRequest()), ["author"]); + assert.deepEqual( + responsibleLogins(pullRequest({ assignees: { nodes: [{ login: "a" }, { login: "b" }] } })), + ["a", "b"], + ); + assert.deepEqual(responsibleLogins(pullRequest({ assignees: { nodes: [] } })), ["author"]); +}); + +test("변경요청 뒤 조치는 작성자가 아니라 담당자의 것만 센다", () => { + const base = { + assignees: { nodes: [{ login: "Assignee" }] }, + reviews: { nodes: [changesRequested()] }, + }; + // 작성자의 커밋·코멘트는 담당자가 따로 있으면 조치가 아니다. + const authorOnly = judgeAwaitingAuthor( + pullRequest({ + ...base, + commits: { nodes: [commit({ date: "2026-08-29T12:00:00Z", login: "author" })] }, + comments: { nodes: [{ createdAt: "2026-08-29T12:00:00Z", author: { login: "author" } }] }, + }), + { repository: REPO }, + ); + assert.equal(authorOnly.awaiting, true); + assert.deepEqual(authorOnly.owners, ["Assignee"]); + + // 담당자의 코멘트 하나면 풀린다. 대소문자는 가리지 않는다. + const assigneeAnswered = judgeAwaitingAuthor( + pullRequest({ + ...base, + comments: { nodes: [{ createdAt: "2026-08-29T12:00:00Z", author: { login: "assignee" } }] }, + }), + { repository: REPO }, + ); + assert.equal(assigneeAnswered.awaiting, false); +}); + +test("면제는 담당자 전원이 면제일 때만 적용된다", () => { + const plan = planAwaitingAuthorLabels({ + repository: REPO, + exemptAuthors: ["exempt"], + pullRequests: [ + // 면제 작성자가 올렸어도 비면제자에게 어사인됐으면 그 사람의 몫이다. + pullRequest({ + number: 40, + author: { login: "exempt" }, + assignees: { nodes: [{ login: "someone" }] }, + reviews: { nodes: [changesRequested()] }, + }), + // 비면제 작성자가 올렸어도 면제자에게 어사인됐으면 뺀다. + pullRequest({ + number: 41, + assignees: { nodes: [{ login: "EXEMPT" }] }, + reviews: { nodes: [changesRequested()] }, + }), + // 면제자와 비면제자가 함께 어사인되면 비면제자의 몫이 남는다. + pullRequest({ + number: 42, + assignees: { nodes: [{ login: "exempt" }, { login: "someone" }] }, + reviews: { nodes: [changesRequested()] }, + }), + ], + }); + assert.deepEqual(plan.toLabel.map((entry) => entry.number), [40, 42]); + assert.equal(plan.unchanged.find((entry) => entry.number === 41)?.reason, "리뷰 게이트 면제 작성자"); +}); + test("작성자 빚 판정은 현재 PR 번호가 없거나 잘못되면 통과시키지 않는다", () => { assert.throws( () => findAuthorDebts({ pullRequests: [], repository: REPO, author: "author" }), @@ -287,18 +376,27 @@ test("작성자 빚 판정은 현재 PR 번호가 없거나 잘못되면 통과 ); }); -test("입장 게이트 조회는 GraphQL search 에서 같은 작성자의 열린 PR 만 페이지 처리한다", async () => { - const cursors = []; +test("입장 게이트 조회는 GraphQL search 로 어사인·작성자 두 축의 열린 PR 을 페이지 처리하고 합친다", async () => { + const calls = []; const api = async (apiPath, options) => { assert.equal(apiPath, "/graphql"); assert.match(options.body.query, /search\(query: \$searchQuery/); - assert.equal( - options.body.variables.searchQuery, - "repo:Afternote/Afternote-FE is:pr is:open author:AuThOr", - ); - - const cursor = options.body.variables.cursor; - cursors.push(cursor); + const { searchQuery, cursor } = options.body.variables; + calls.push([searchQuery, cursor]); + const byAssignee = searchQuery.endsWith("assignee:AuThOr"); + if (!byAssignee) { + assert.equal(searchQuery, "repo:Afternote/Afternote-FE is:pr is:open author:AuThOr"); + // 작성자 검색은 어사인 검색과 겹치는 30 을 다시 내려 준다 — 한 번만 남아야 한다. + return { + data: { + search: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [pullRequest({ number: 30 }), pullRequest({ number: 32 })], + }, + }, + }; + } + assert.equal(searchQuery, "repo:Afternote/Afternote-FE is:pr is:open assignee:AuThOr"); return { data: { search: { @@ -314,8 +412,12 @@ test("입장 게이트 조회는 GraphQL search 에서 같은 작성자의 열 const pullRequests = await fetchOpenPullRequestsByAuthor(api, REPO, "AuThOr"); - assert.deepEqual(cursors, [null, "next"]); - assert.deepEqual(pullRequests.map((pullRequest) => pullRequest.number), [30, 31]); + assert.deepEqual(calls, [ + ["repo:Afternote/Afternote-FE is:pr is:open assignee:AuThOr", null], + ["repo:Afternote/Afternote-FE is:pr is:open assignee:AuThOr", "next"], + ["repo:Afternote/Afternote-FE is:pr is:open author:AuThOr", null], + ]); + assert.deepEqual(pullRequests.map((pullRequest) => pullRequest.number), [30, 31, 32]); }); test("입장 게이트 조회는 불완전한 search 결과를 빈 목록으로 접지 않는다", async () => { @@ -358,9 +460,12 @@ test("두 GraphQL 조회는 결정 리뷰 권한과 하위 connection 절단 여 await fetchOpenPullRequests(api, REPO); await fetchOpenPullRequestsByAuthor(api, REPO, "author"); - assert.equal(queries.length, 2); + // 전체 조회 1 + 입장 조회(assignee·author) 2 + assert.equal(queries.length, 3); for (const query of queries) { assert.match(query, /authorCanPushToRepository/); + // 담당자 판정에 어사인이 필요하다. + assert.match(query, /assignees\(first: 10\) \{ nodes \{ login \} \}/); for (const connection of ["reviews", "commits", "comments", "userContentEdits"]) { assert.match( query, diff --git a/.github/scripts/review-debt-policy.test.mjs b/.github/scripts/review-debt-policy.test.mjs index fe3b3912a..e7ca15779 100644 --- a/.github/scripts/review-debt-policy.test.mjs +++ b/.github/scripts/review-debt-policy.test.mjs @@ -40,9 +40,21 @@ test("the debt sweep still fails closed when open pull requests cannot be listed 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("a pull request is never counted as debt against its own responsible person", () => { + // «자기 PR» 은 작성자가 아니라 담당자(어사인, 비어 있으면 작성자) 기준이다(#1974). + // 작성자 로그인만 보면 남이 올려 내게 어사인된 PR 을 내가 리뷰해야 하는 것으로 세고, + // 내가 올려 남에게 어사인된 PR 을 내 리뷰 목록에서 뺀다. + assert.doesNotMatch(guard, /select\(\.user\.login != \\"\$AUTHOR\\"\)/); + assert.match(guard, /if \(\.assignees \| length\) > 0 then \[\.assignees\[\]\.login\] else \[\.user\.login\] end/); + assert.match(guard, /select\(\(\\\$owners \| index\(\\"\$author_lc\\"\)\) == null\)/); +}); + +test("the request-all job excludes the responsible person, not only the author", () => { + // GitHub 은 작성자만 리뷰어에서 자동으로 뺀다. 어사인된 담당자는 직접 빼야 한다. + assert.match(requestAll, /ASSIGNEES: \$\{\{ join\(github\.event\.pull_request\.assignees\.\*\.login, ' '\) \}\}/); + assert.match(requestAll, /owners_lc=\$\(printf '%s' "\$\{ASSIGNEES:-\$AUTHOR\}"/); + assert.match(requestAll, /case " \$owners_lc " in\n\s*\*" \$u_lc "\*\) continue ;;/); + assert.doesNotMatch(requestAll, /\[ "\$u" != "\$AUTHOR" \] \|\| continue/); }); test("the dead review_request_removed path is gone", () => { @@ -74,8 +86,10 @@ test("a reviewer's own commit is never counted as the author's fix", () => { // 된다. 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, /login in target/); + assert.match(guard, /-v owners="\$pr_owners"/); + // 계정이 연결되지 않은 커밋은 login 이 비어 가릴 수 없다. 같은 PR 에서 담당자 // 것으로 확인된 커밋의 이메일을 폴백 신원으로 쓴다. assert.match(guard, /\(\.commit\.author\.email \/\/ ""\)/); assert.match(guard, /email in own/); @@ -118,7 +132,11 @@ test("a fix delivered by a merge commit still counts as a response", () => { // 채로 빚에서 빠져 있었다. 커밋과 함께 작성자의 응답을 본다 (#1450). assert.match(guard, /issues\/\$pn\/comments/); assert.match(guard, /pulls\/\$pn\/comments/); - assert.match(guard, /select\(\.user\.login == \\"\$pr_author\\"\)/); + // 응답 주체는 담당자 집합이다. + assert.equal( + (guard.match(/select\(\\",\$pr_owners,\\" \| contains\(\\",\\" \+ \(\.user\.login \| ascii_downcase\) \+ \\",\\"\)\)/g) ?? []).length, + 2, + ); assert.match(guard, /\[ "\$\{fixed:-0\}" -gt 0 \] \|\| \[ "\$responses" -gt 0 \]/); }); @@ -138,10 +156,17 @@ test("rerequests are automated so silence cannot pass the guard", () => { assert.match(requestAll, /github\.event\.action == 'synchronize'/); assert.match(requestAll, /github\.event\.action == 'edited'/); assert.match(requestAll, /github\.event\.changes\.body != null/); + // 본문 편집은 담당자(어사인, 비어 있으면 작성자)의 것만 조치다(#1974). + assert.match( + requestAll, + /contains\(github\.event\.pull_request\.assignees\.\*\.login, github\.event\.sender\.login\)/, + ); assert.match( requestAll, - /github\.event\.sender\.login == github\.event\.pull_request\.user\.login/, + /github\.event\.pull_request\.assignees\[0\] == null &&\n\s*github\.event\.sender\.login == github\.event\.pull_request\.user\.login/, ); + // 담당자가 낸 변경요청은 되살리지 않는다. + assert.match(requestAll, /\*" \$blocked_lc "\*\) echo "변경요청을 낸 사람이 담당자 본인/); assert.match(requestAll, /github\.event\.pull_request\.state == 'open'/); assert.match(requestAll, /--add-reviewer "\$blocked"/); // 기존 전원 요청은 반영 커밋이나 본문 편집마다 다시 돌지 않는다. @@ -169,7 +194,8 @@ test("author body edits are durable review evidence and fail closed when truncat assert.match(guard, /editor\{login\}/); assert.match(guard, /\.editor\.login/); assert.match(guard, /\.editedAt > \$cutoff/); - assert.match(guard, /ascii_downcase\) == \(\$author \| ascii_downcase/); + assert.match(guard, /--arg owners ",\$pr_owners,"/); + assert.match(guard, /select\(\$owners \| contains\("," \+ \(\(\.editor\.login \/\/ ""\) \| ascii_downcase\) \+ ","\)\)/); assert.match(guard, /pageInfo\.hasPreviousPage != false/); assert.match(guard, /\[ "\$body_edits" -gt 0 \]/); }); diff --git a/.github/workflows/review-debt-guard.yml b/.github/workflows/review-debt-guard.yml index 051367e39..dce20c6a2 100644 --- a/.github/workflows/review-debt-guard.yml +++ b/.github/workflows/review-debt-guard.yml @@ -7,11 +7,15 @@ # 그 PR 을 닫는다. 브랜치는 남으므로 리뷰를 끝낸 뒤 되살릴 수 있다. # # 리뷰어 빚의 기준은 «나에게 걸린 리뷰 요청» 이 아니라 «아무도 응답하지 않은 열린 -# PR» 이다(#1148). 다만 한번 변경요청을 남긴 뒤에는 작성자가 그 리뷰어에게 명시적으로 +# PR» 이다(#1148). 다만 한번 변경요청을 남긴 뒤에는 담당자가 그 리뷰어에게 명시적으로 # 재요청해야만 공이 돌아온다. 수정 커밋만으로 리뷰 의무를 만들지는 않는다(#1136). -# 작성자 빚은 같은 작성자의 다른 열린 PR 이 최신 변경요청 뒤 실질 커밋·응답·작성자 +# 작성자 빚은 같은 사람이 담당하는 다른 열린 PR 이 최신 변경요청 뒤 실질 커밋·응답· # 본문 편집 없이 남은 상태다. 라벨 갱신 시점에 기대지 않고 새 PR 을 열 때 현재 상태를 # 다시 계산한다(#1679, #1700). +# +# PR 의 담당자는 «올린 사람» 이 아니라 «어사인된 사람» 이다(#1974 결정, #1980). 변경요청에 +# 응답하는 것도, «자기 PR» 을 리뷰어 빚에서 빼는 것도 어사인 기준이고, 어사인이 비어 있는 +# 동안만 작성자가 담당자다. 새 PR 을 여는 사람(AUTHOR)이 게이트를 받는 것은 그대로다. name: review-debt-guard on: @@ -119,11 +123,16 @@ jobs: # 누구에게 걸렸든 팀에서 한 사람이 보면 그 PR 의 리뷰는 끝난 것이다. # 그래서 요청 목록이 아니라 열린 PR 전체를 훑는다. # - # 자기 PR 은 자신이 리뷰할 수 없으므로 뺀다. draft 는 리뷰를 받을 상태가 - # 아니므로 뺀다(review-request-all 도 draft 에는 요청을 걸지 않는다). + # 자기 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\(.user.login)\t\([.requested_reviewers[].login | ascii_downcase] | join(\",\"))\t\(.title)\""); then + --jq ".[] | select(.draft == false) + | ((if (.assignees | length) > 0 then [.assignees[].login] else [.user.login] end) | map(ascii_downcase)) as \$owners + | select((\$owners | index(\"$author_lc\")) == null) + | \"\(.number)\t\(.created_at[0:10])\t\(\$owners | join(\",\"))\t\([.requested_reviewers[].login | ascii_downcase] | join(\",\"))\t\(.title)\""); then echo "::error::열린 PR 조회에 실패했다 — 근거 없이 PR 을 닫지 않는다. 재실행할 것" exit 1 fi @@ -167,7 +176,7 @@ jobs: # (parents 2개 이상)을 뺀 실질 커밋으로만 센다. debt_file=$(mktemp) reviews_file=$(mktemp) - while IFS=$'\t' read -r pn created pr_author requested_reviewers title; do + while IFS=$'\t' read -r pn created pr_owners requested_reviewers title; do [ -n "$pn" ] || continue if ! gh api "repos/$REPO/pulls/$pn/reviews" --paginate \ @@ -242,21 +251,26 @@ jobs: fi # 계정이 연결되지 않은 커밋은 .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_emails=$(awk -F'\t' -v owners="$pr_owners" ' + BEGIN { + n = split(owners, list, ",") + for (i = 1; i <= n; i++) if (list[i] != "") target[list[i]] = 1 + } + (tolower($2) in 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" ' + author_commits=$(awk -F'\t' -v owners="$pr_owners" -v cutoff="$blocked_at" -v emails="$author_emails" ' BEGIN { - target = tolower(author) + n = split(owners, list, ",") + for (i = 1; i <= n; i++) if (list[i] != "") target[list[i]] = 1 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 } + if (login != "") { if (login in target) print $1 } else if (email != "" && (email in own)) print $1 }' "$commits_file") @@ -281,8 +295,9 @@ jobs: fixed=$((fixed + 1)) done <<< "$author_commits" + # 응답 주체도 담당자 집합이다. 쉼표로 감싼 목록에 쉼표로 감싼 로그인이 들어 있는지 본다. if ! said=$(gh api "repos/$REPO/issues/$pn/comments?per_page=100" --paginate \ - --jq ".[] | select(.user.login == \"$pr_author\") + --jq ".[] | select(\",$pr_owners,\" | contains(\",\" + (.user.login | ascii_downcase) + \",\")) | select(.created_at > \"$blocked_at\") | .id" \ | wc -l | tr -d ' '); then echo "::error::PR #$pn 작성자 일반 응답 조회에 실패했다 — 근거 없이 PR 을 닫지 않는다" @@ -290,7 +305,7 @@ jobs: fi if ! answered=$(gh api "repos/$REPO/pulls/$pn/comments?per_page=100" --paginate \ - --jq ".[] | select(.user.login == \"$pr_author\") + --jq ".[] | select(\",$pr_owners,\" | contains(\",\" + (.user.login | ascii_downcase) + \",\")) | select(.created_at > \"$blocked_at\") | .id" \ | wc -l | tr -d ' '); then echo "::error::PR #$pn 작성자 리뷰 응답 조회에 실패했다 — 근거 없이 PR 을 닫지 않는다" @@ -299,7 +314,7 @@ jobs: # PR 본문은 커밋 없이도 CI 계약·설명을 고치는 변경 표면이다(#771). REST 의 # updated_at 은 댓글·라벨까지 섞이고 GraphQL lastEditedAt 은 마지막 편집자만 - # 남기므로, 영속 편집 이력에서 «최신 변경요청 뒤 작성자가 직접 고친 본문»만 센다. + # 남기므로, 영속 편집 이력에서 «최신 변경요청 뒤 담당자가 직접 고친 본문»만 센다. # 최근 50건보다 많은 편집 이력이 있으면 필요한 구간이 잘렸을 수 있으므로 # 근거 없이 작성자·리뷰어 어느 쪽으로도 공을 넘기지 않고 가드를 실패시킨다. # shellcheck disable=SC2016 # GraphQL 변수는 gh가 -F 값으로 바인딩한다. @@ -312,7 +327,7 @@ jobs: exit 1 fi if ! body_edits=$(jq -er \ - --arg author "$pr_author" \ + --arg owners ",$pr_owners," \ --arg cutoff "$blocked_at" ' .data.repository.pullRequest.userContentEdits as $edits | if ($edits.pageInfo.hasPreviousPage != false) then @@ -320,7 +335,7 @@ jobs: else [ $edits.nodes[] - | select(((.editor.login // "") | ascii_downcase) == ($author | ascii_downcase)) + | select($owners | contains("," + ((.editor.login // "") | ascii_downcase) + ",")) | select(.editedAt > $cutoff) ] | length end @@ -335,7 +350,7 @@ jobs: echo "#$pn — @$blocked_by 재리뷰 요청 뒤 변경요청 반영(${fixed}커밋 · 응답 ${responses}건 · 본문 편집 ${body_edits}건), 아직 미응답" printf '%s\t%s\t%s\n' "$pn" "$created" "$title" >> "$debt_file" else - echo "#$pn — 변경요청 미반영, 공은 작성자에게" + echo "#$pn — 변경요청 미반영, 공은 담당자에게" fi done <<< "$open_prs" @@ -378,10 +393,11 @@ jobs: done <<< "$author_debt" echo fi - echo "이 저장소는 팀 리뷰 응답 또는 자기 PR 의 변경요청 대응을 기다리는 PR 이 1건이라도 남아 있으면 새 PR 을 열 수 없습니다 (#1136, #1679)." + echo "이 저장소는 팀 리뷰 응답 또는 자기가 담당하는 PR 의 변경요청 대응을 기다리는 PR 이 1건이라도 남아 있으면 새 PR 을 열 수 없습니다 (#1136, #1679)." echo - echo "각 PR 에 필요한 리뷰는 **한 건**입니다 — 누구든 먼저 판정을 내면 그 PR 은 모두의 목록에서 빠집니다. 승인 뒤 변경을 다시 볼 의무는 없고, **변경요청도 작성자가 같은 리뷰어에게 재요청한 뒤 실질 커밋·응답·본문 편집을 남긴 경우에만** 다시 리뷰어의 몫이 됩니다." - echo "자기 PR 이 **최신 변경요청 뒤 실질 커밋·응답·작성자 본문 편집 없이** 남아 있으면 작성자의 몫입니다. 조치를 남기면 다음 검사에서 작성자 빚이 해소됩니다." + echo "PR 의 담당자는 올린 사람이 아니라 **어사인된 사람**입니다(어사인이 비어 있으면 작성자, #1974)." + echo "각 PR 에 필요한 리뷰는 **한 건**입니다 — 누구든 먼저 판정을 내면 그 PR 은 모두의 목록에서 빠집니다. 승인 뒤 변경을 다시 볼 의무는 없고, **변경요청도 담당자가 같은 리뷰어에게 재요청한 뒤 실질 커밋·응답·본문 편집을 남긴 경우에만** 다시 리뷰어의 몫이 됩니다." + echo "담당하는 PR 이 **최신 변경요청 뒤 실질 커밋·응답·본문 편집 없이** 남아 있으면 담당자의 몫입니다. 조치를 남기면 다음 검사에서 작성자 빚이 해소됩니다." echo echo "### 되살리는 법" echo diff --git a/.github/workflows/review-request-all.yml b/.github/workflows/review-request-all.yml index c0fb7d992..cbcba2ebe 100644 --- a/.github/workflows/review-request-all.yml +++ b/.github/workflows/review-request-all.yml @@ -1,8 +1,10 @@ # 리뷰어 자동 요청 (#1136) # -# 리뷰 요청을 손으로 걸다 보면 빠뜨린다. PR 이 열리면 작성자를 뺀 팀 전원에게 +# 리뷰 요청을 손으로 걸다 보면 빠뜨린다. PR 이 열리면 담당자를 뺀 팀 전원에게 # 자동으로 요청한다. GitHub 은 자기 PR 의 작성자를 리뷰어로 받지 않으므로 -# 목록에 전원을 적어도 작성자는 자동으로 빠진다. +# 목록에 전원을 적어도 작성자는 자동으로 빠진다. 담당자(어사인)는 GitHub 이 막지 +# 않으므로 여기서 직접 뺀다 — PR 의 공은 올린 사람이 아니라 어사인된 사람에게 +# 있다(#1974 결정, #1980). 어사인이 비어 있는 동안만 작성자가 담당자다. # # 이 워크플로는 review-debt-guard 와 한 세트다. 요청이 자동으로 걸려야 # "리뷰를 안 하면 새 PR 을 못 연다" 가 성립한다 — 요청을 손으로 걸던 때는 @@ -40,13 +42,16 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: 작성자를 뺀 전원에게 리뷰 요청 + - name: 담당자를 뺀 전원에게 리뷰 요청 env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} AUTHOR: ${{ github.event.pull_request.user.login }} ASSOCIATION: ${{ github.event.pull_request.author_association }} + # 이벤트 시점의 어사인. pr-assign-author 가 같은 이벤트에서 빈 어사인에 작성자를 + # 거는 중일 수 있으므로, 비어 있으면 작성자로 폴백한다. + ASSIGNEES: ${{ join(github.event.pull_request.assignees.*.login, ' ') }} # 리뷰 의무가 있는 팀원만 적는다. koongmai 는 남의 PR 을 리뷰할 의무가 없어 # 빠져 있다 (#1910). 자기 PR 에는 나머지 둘에게 그대로 요청이 걸리고, # 스스로 리뷰하는 것도 막히지 않는다. 빠진 것은 자동 요청뿐이다. @@ -69,26 +74,35 @@ jobs: current=$(gh api "repos/$REPO/pulls/$PR_NUMBER" \ --jq '[.requested_reviewers[].login] | join(" ")') + # 담당자는 리뷰어가 아니다. 어사인이 있으면 어사인, 없으면 작성자. 로그인은 + # 대소문자를 가리지 않으므로 낮춰 비교한다. + owners_lc=$(printf '%s' "${ASSIGNEES:-$AUTHOR}" | tr '[:upper:]' '[:lower:]') + current_lc=$(printf '%s' "$current" | tr '[:upper:]' '[:lower:]') + targets="" for u in $TEAM; do - [ "$u" != "$AUTHOR" ] || continue - case " $current " in - *" $u "*) continue ;; + u_lc=$(printf '%s' "$u" | tr '[:upper:]' '[:lower:]') + case " $owners_lc " in + *" $u_lc "*) continue ;; + esac + case " $current_lc " in + *" $u_lc "*) continue ;; esac targets="${targets:+$targets,}$u" done if [ -z "$targets" ]; then - echo "추가할 리뷰어 없음 (이미 요청됨 또는 작성자뿐)" + echo "추가할 리뷰어 없음 (이미 요청됨 또는 담당자뿐)" exit 0 fi echo "리뷰 요청: $targets" gh pr edit "$PR_NUMBER" --repo "$REPO" --add-reviewer "$targets" - # 변경요청 뒤 반영 커밋이 올라오거나 작성자가 PR 본문을 고치면 그 리뷰어에게 + # 변경요청 뒤 반영 커밋이 올라오거나 담당자가 PR 본문을 고치면 그 리뷰어에게 # 다시 요청을 건다(#1450, #1700). `edited` 전체를 받되 제목 수정·관리자 대리 수정은 - # 작성자 조치가 아니므로 body 변경과 sender=author 를 함께 요구한다. + # 담당자 조치가 아니므로 body 변경과 sender=담당자 를 함께 요구한다. 담당자는 + # 어사인이고, 어사인이 비어 있으면 작성자다(#1974). # # 판정 기준은 review-debt-guard 와 같아야 한다 — 여기서 거는 요청이 곧 그쪽의 # 빚 성립 조건이므로, 한쪽만 «PR 전체의 최신 판정» 을 보면 자동 재요청이 빚으로 @@ -102,7 +116,13 @@ jobs: ( github.event.action == 'edited' && github.event.changes.body != null && - github.event.sender.login == github.event.pull_request.user.login + ( + contains(github.event.pull_request.assignees.*.login, github.event.sender.login) || + ( + github.event.pull_request.assignees[0] == null && + github.event.sender.login == github.event.pull_request.user.login + ) + ) ) ) runs-on: ubuntu-latest @@ -114,6 +134,7 @@ jobs: REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} AUTHOR: ${{ github.event.pull_request.user.login }} + ASSIGNEES: ${{ join(github.event.pull_request.assignees.*.login, ' ') }} HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} ACTION: ${{ github.event.action }} EVENT_AT: ${{ github.event.pull_request.updated_at }} @@ -165,20 +186,21 @@ jobs: IFS=$'\t' read -r blocked blocked_at <<< "$blocked" # 자기 자신은 리뷰어가 될 수 없다(422). 작성자가 자기 PR 에 변경요청을 낼 - # 수는 없지만, 소유권이 옮겨진 PR 에서 그렇게 보일 수 있다. - if [ "$blocked" = "$AUTHOR" ]; then - echo "변경요청을 낸 사람이 작성자 본인 — 건너뜀" - exit 0 - fi + # 수는 없지만, 소유권이 옮겨진 PR 에서 그렇게 보일 수 있다. 담당자(어사인)가 + # 낸 변경요청도 자기 PR 에 대한 것이라 되살리지 않는다. + owners_lc=$(printf '%s' "${ASSIGNEES:-$AUTHOR}" | tr '[:upper:]' '[:lower:]') + blocked_lc=$(printf '%s' "$blocked" | tr '[:upper:]' '[:lower:]') + case " $owners_lc " in + *" $blocked_lc "*) echo "변경요청을 낸 사람이 담당자 본인(@$blocked) — 건너뜀"; exit 0 ;; + esac # 이미 걸려 있으면 다시 걸지 않는다. synchronize 는 force-push 마다 온다. current=$(gh api "repos/$REPO/pulls/$PR_NUMBER" \ --jq '[.requested_reviewers[].login] | join(" ")') - blocked_lc=$(printf '%s' "$blocked" | tr '[:upper:]' '[:lower:]') current_lc=$(printf '%s' "$current" | tr '[:upper:]' '[:lower:]') case " $current_lc " in *" $blocked_lc "*) echo "@$blocked 요청이 이미 걸려 있음 — 건너뜀"; exit 0 ;; esac - echo "변경요청($blocked_at) 뒤 작성자 조치($ACTION) — @$blocked 에게 재리뷰 요청" + echo "변경요청($blocked_at) 뒤 담당자 조치($ACTION) — @$blocked 에게 재리뷰 요청" gh pr edit "$PR_NUMBER" --repo "$REPO" --add-reviewer "$blocked" diff --git a/README.md b/README.md index 29777ad05..a085e9c5b 100644 --- a/README.md +++ b/README.md @@ -181,10 +181,11 @@ Kover는 임의의 절대 커버리지 목표를 강제하지 않는다. 정확 ## 코드 리뷰 -- Draft가 아닌 내부 팀원 PR이 열리거나 리뷰 가능 상태가 되면 [자동 요청 workflow](.github/workflows/review-request-all.yml)가 작성자를 제외한 리뷰 담당 팀원(`TEAM`)에게 리뷰를 요청한다. 요청 인원 수와 필수 승인 수는 같은 뜻이 아니다. +- PR의 담당자는 올린 사람이 아니라 **어사인된 사람**이다. 어사인이 비어 있으면 [자동 어사인 workflow](.github/workflows/pr-assign-author.yml)가 작성자를 걸고, 사람이 담당자를 바꾼 것은 되돌리지 않는다. 변경요청 대응·리뷰 요청 제외·아래 적체 가드의 «자기 PR» 판정은 모두 이 담당자 기준이다. +- Draft가 아닌 내부 팀원 PR이 열리거나 리뷰 가능 상태가 되면 [자동 요청 workflow](.github/workflows/review-request-all.yml)가 담당자를 제외한 리뷰 담당 팀원(`TEAM`)에게 리뷰를 요청한다. 요청 인원 수와 필수 승인 수는 같은 뜻이 아니다. - 현재 `develop`·`main` 머지에는 **승인 1건**이 필요하다. `main`은 모든 리뷰 스레드 해결도 필요하다. - 리뷰 결과는 GitHub의 `APPROVED`·`CHANGES_REQUESTED`·일반 코멘트로 표현한다. 별도 RCA prefix나 12시간 SLA는 두지 않는다. -- [리뷰 적체 가드](.github/workflows/review-debt-guard.yml)는 응답을 기다리는 다른 PR이 남았거나 자기 PR의 최신 변경요청 뒤 아무 조치도 하지 않은 팀원의 새 PR을 닫을 수 있다. 아래 면제 목록에 있는 작성자는 대상이 아니다. 각 PR은 팀원 한 명이 먼저 유효한 판정을 내리면 최초 미응답 목록에서 빠지고, 작성자가 실질 커밋이나 응답을 남기면 작성자 대기 목록에서 빠진다. -- `awaiting-author` 라벨은 리뷰 게이트 적용 대상 중 변경요청 뒤 작성자 무조치 상태인 PR을 보여 준다. 면제 작성자에게는 붙이지 않으며, 이미 붙은 라벨도 리컨사일러가 제거한다. 새 PR 가드는 라벨 갱신 시점에 의존하지 않고 같은 판정을 현재 열린 PR에 다시 적용한다. +- [리뷰 적체 가드](.github/workflows/review-debt-guard.yml)는 응답을 기다리는 다른 PR이 남았거나 자기가 담당하는 PR의 최신 변경요청 뒤 아무 조치도 하지 않은 팀원의 새 PR을 닫을 수 있다. 아래 면제 목록에 있는 작성자는 대상이 아니다. 각 PR은 팀원 한 명이 먼저 유효한 판정을 내리면 최초 미응답 목록에서 빠지고, 담당자가 실질 커밋이나 응답을 남기면 작성자 대기 목록에서 빠진다. +- `awaiting-author` 라벨은 리뷰 게이트 적용 대상 중 변경요청 뒤 담당자 무조치 상태인 PR을 보여 준다. 면제 작성자에게는 붙이지 않으며, 이미 붙은 라벨도 리컨사일러가 제거한다. 새 PR 가드는 라벨 갱신 시점에 의존하지 않고 같은 판정을 현재 열린 PR에 다시 적용한다. - 쓰기 권한이 있는 리뷰어별 최신 `APPROVED`·`CHANGES_REQUESTED` 가운데 PR 전체에서 가장 늦은 판정을 최종 판정으로 사용한다. 가장 늦은 판정이 승인이면 더 오래된 변경 요청은 자동 해제되고, 승인 뒤에 새 변경 요청이 오면 다시 차단된다. - 적체 가드에는 라벨 우회가 없다. 가드와 `awaiting-author` 라벨이 함께 쓰는 면제 목록은 [review-debt-guard.yml](.github/workflows/review-debt-guard.yml)의 `REVIEW_GATE_EXEMPT_AUTHORS`이며, 지금은 `koongmai`가 거기에 있다. 면제받은 사람은 리뷰 지적 반영 여부와 무관하게 새 PR을 열 수 있고 남의 PR을 리뷰할 의무가 없다. 그래서 자동 요청 대상에서도 빠지고, 그가 낸 변경요청은 다른 팀원의 빚으로 세지 않는다. 머지 게이트는 면제와 무관하다. `develop` 승인 1건과 필수 체크는 그대로 요구된다.