From 39805e0c2fd651145ad6e3d4692dfaf909d53c7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 30 Aug 2026 12:22:30 +0900 Subject: [PATCH] =?UTF-8?q?ci:=20=EB=B3=80=EA=B2=BD=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EB=92=A4=20=EC=9E=91=EC=84=B1=EC=9E=90=20=EB=AC=B4=EC=A1=B0?= =?UTF-8?q?=EC=B9=98=20PR=20=EC=97=90=20awaiting-author=20=EB=9D=BC?= =?UTF-8?q?=EB=B2=A8=EC=9D=84=20=EB=B6=99=EC=9D=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review-debt-guard 는 설계상 리뷰어의 빚만 잡는다. 공이 작성자에게 있는 PR 은 «변경요청 미반영, 공은 작성자에게» 로 로그에 찍은 뒤 버리므로, 그 축은 어느 게이트에서도 신호가 나지 않았다. 없던 판정을 새로 만드는 것이 아니라 이미 내려지고 버려지는 판정을 라벨로 남긴다. 붙이기와 떼기를 한 번의 차이 계산으로 처리하는 리컨사일러로 넣는다. 경로를 둘로 나누면 떼는 쪽이 새고 스테일 라벨이 신호를 죽인다. 전용 워크플로를 세우는 대신 PR Label Reconcile 에 얹었다 — 트리거·concurrency·quota 처리가 이미 거기 있다. 판정 기준은 가드와 같아야 한다. 최신 결정 리뷰 하나만 보고, 병합 커밋과 빈 커밋을 반영에서 빼고, 커밋 작성자를 확인하며(계정 미연결분은 이메일 폴백), 커밋과 함께 작성자의 응답을 센다. 두 판정이 갈라지지 않도록 공유 술어를 awaiting-author-policy.test.mjs 가 잠근다. 8/30 dry-run 실측으로 6건이 잡힌다 — #440 은 49일, #767·#771 은 22일째다. --- .../scripts/awaiting-author-policy.test.mjs | 103 ++++ .github/scripts/label-awaiting-author-prs.mjs | 447 ++++++++++++++++++ .../label-awaiting-author-prs.test.mjs | 308 ++++++++++++ .github/workflows/conflict-label.yml | 9 +- 4 files changed, 866 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/awaiting-author-policy.test.mjs create mode 100644 .github/scripts/label-awaiting-author-prs.mjs create mode 100644 .github/scripts/label-awaiting-author-prs.test.mjs diff --git a/.github/scripts/awaiting-author-policy.test.mjs b/.github/scripts/awaiting-author-policy.test.mjs new file mode 100644 index 0000000000..80846b90fc --- /dev/null +++ b/.github/scripts/awaiting-author-policy.test.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { judgeAwaitingAuthor } from "./label-awaiting-author-prs.mjs"; + +const guard = await readFile(new URL("../workflows/review-debt-guard.yml", import.meta.url), "utf8"); +const reconcile = await readFile(new URL("../workflows/conflict-label.yml", import.meta.url), "utf8"); +const script = await readFile(new URL("./label-awaiting-author-prs.mjs", import.meta.url), "utf8"); + +test("가드는 작성자 무조치를 여전히 «빚 아님» 으로 흘려보낸다", () => { + // 이 라벨이 존재하는 이유 자체다. 가드가 작성자 무조치까지 빚으로 세도록 바뀌면 + // 같은 PR 이 두 경로에서 이중으로 처벌된다 — 리뷰어가 새 PR 을 못 열고, 작성자에게도 + // 라벨이 붙는다. 그때는 이 리컨사일러를 없애거나 기준을 다시 나눠야 한다. + assert.match(guard, /변경요청 미반영, 공은 작성자에게/); +}); + +test("두 판정은 «PR 전체의 최신 결정 리뷰» 라는 같은 축을 쓴다", () => { + // 한쪽만 리뷰어별로 묶으면, 승인으로 끝난 PR 에 더 오래된 변경요청 때문에 라벨이 + // 붙거나 그 반대가 된다. + assert.match(guard, /sort_by\(\.t\) \| last/); + assert.doesNotMatch(guard, /group_by\(\.u/); + assert.match(script, /export function latestDecision/); + assert.doesNotMatch(script, /groupBy/); +}); + +test("두 판정 모두 병합 커밋을 작성자 반영으로 세지 않는다", () => { + // 병합 커밋의 diff 는 base 에서 들어온 변경을 통째로 담아 반영분과 구별되지 않는다. + assert.match(guard, /\(\.parents \| length\) < 2/); + assert.match(script, /parents\?\.totalCount \?\? 0\) >= 2/); +}); + +test("두 판정 모두 빈 커밋을 반영으로 세지 않는다", () => { + // 리뷰어가 미는 +0/-0 CI 재트리거 커밋이 «작성자가 반영했다» 가 되면, 방치된 PR 의 + // 라벨이 조용히 떨어진다 (#1459). + assert.match(guard, /\(\.files \/\/ \[\]\) \| length/); + assert.match(guard, /\[ "\$changed" -gt 0 \] \|\| continue/); + assert.match(script, /changedFilesIfAvailable/); + assert.match(script, /changed === 0/); +}); + +test("두 판정 모두 커밋 작성자를 확인하고 이메일 폴백을 쓴다", () => { + // 누가 올렸는지를 보지 않으면 리뷰어 커밋이 작성자 반영으로 둔갑한다. 계정이 연결되지 + // 않은 커밋은 login 이 비어 같은 PR 의 작성자 이메일로만 가릴 수 있다. + assert.match(guard, /\(\.author\.login \/\/ ""\)/); + assert.match(guard, /email in own/); + assert.match(script, /ownEmails/); +}); + +test("두 판정 모두 커밋과 함께 작성자의 응답을 본다", () => { + // 커밋만 세면 base 를 merge 로 끌어와 반영한 PR 이 «무조치» 가 된다 (#1316 → #1450). + assert.match(guard, /issues\/\$pn\/comments/); + assert.match(guard, /\[ "\$\{fixed:-0\}" -gt 0 \] \|\| \[ "\$responses" -gt 0 \]/); + assert.match(script, /export function countAuthorResponses/); + assert.match(script, /fixes > 0 \|\| responses > 0/); +}); + +test("두 판정 모두 draft·봇·fork 를 대상에서 뺀다", () => { + assert.match(guard, /\[ "\$HEAD_REPO" != "\$REPO" \]/); + assert.match(guard, /select\(\.draft == false\)/); + assert.match(guard, /봇 PR — 건너뜀/); + + for (const [name, pullRequest] of [ + ["draft", { isDraft: true }], + ["bot", { author: { login: "dependabot[bot]" } }], + ["fork", { headRepository: { nameWithOwner: "someone/fork" } }], + ]) { + const verdict = judgeAwaitingAuthor( + { + isDraft: false, + author: { login: "author" }, + headRepository: { nameWithOwner: "Afternote/Afternote-FE" }, + reviews: { + nodes: [ + { state: "CHANGES_REQUESTED", submittedAt: "2026-08-29T00:00:00Z", author: { login: "r" } }, + ], + }, + commits: { nodes: [] }, + comments: { nodes: [] }, + ...pullRequest, + }, + { repository: "Afternote/Afternote-FE" }, + ); + assert.equal(verdict.awaiting, false, `${name} 은 대상이 아니어야 한다`); + } +}); + +test("라벨은 리컨사일러로만 관리된다 — 붙이는 경로와 떼는 경로가 갈라지지 않는다", () => { + // 이벤트로 붙이고 다른 이벤트로 떼면 떼는 쪽이 새고 스테일 라벨이 남는다. 한 번의 + // 차이 계산으로 양쪽을 처리하는 구조를 강제한다. + assert.match(script, /export function planAwaitingAuthorLabels/); + assert.match(script, /toUnlabel/); + + // 전용 워크플로를 새로 세우지 않는다. 트리거·concurrency·quota 처리가 이미 허브에 있다. + assert.match(reconcile, /label-awaiting-author-prs\.test\.mjs/); + assert.match(reconcile, /node \.github\/scripts\/label-awaiting-author-prs\.mjs/); + assert.match(reconcile, /awaiting-author-policy\.test\.mjs/); +}); + +test("리컨사일러 실패가 다른 리컨사일러를 가리지 않는다", () => { + // 허브는 실패를 모아 마지막에 exit 한다. `&&` 로 이으면 앞이 죽을 때 뒤가 아예 돌지 않는다. + assert.match(reconcile, /node \.github\/scripts\/label-awaiting-author-prs\.mjs \|\| failures=1/); +}); diff --git a/.github/scripts/label-awaiting-author-prs.mjs b/.github/scripts/label-awaiting-author-prs.mjs new file mode 100644 index 0000000000..8f3f0a78bc --- /dev/null +++ b/.github/scripts/label-awaiting-author-prs.mjs @@ -0,0 +1,447 @@ +#!/usr/bin/env node + +// 변경요청을 받은 뒤 작성자가 아무 조치도 하지 않은 PR 에 라벨을 붙인다 (#1552). +// +// `review-debt-guard.yml` 은 설계상 **리뷰어의 빚**만 잡는다 — "재요청이 없거나 최신 변경요청 +// 뒤 새 커밋이 없으면 빚이 아니다"(#1136). 공이 작성자에게 있는 PR 로 리뷰어를 벌하지 않는 +// 것은 맞지만, 그 결과 **작성자 무조치는 어느 게이트에서도 신호가 나지 않는다.** 가드는 이미 +// 이 상태를 식별하고 «변경요청 미반영, 공은 작성자에게» 로 로그에 찍은 뒤 버린다. 없던 판정을 +// 새로 만드는 것이 아니라 버려지는 판정을 라벨로 남긴다. +// +// 8/30 실측: 열린 non-draft 29건 중 최신 판정이 CHANGES_REQUESTED 인 9건이 9건 모두 작성자 +// 무조치였고, 그중 #440 은 49일 · #767·#771 은 22일째였다. 오래 열려 있을수록 develop 이 +// 전진해 충돌 비용이 커진다. +// +// 이벤트가 아니라 리컨사일러인 이유. 붙이는 경로와 떼는 경로를 따로 만들면 떼는 쪽이 새고 +// 스테일 라벨이 남아 신호가 죽는다. 여기서는 매 실행이 «지금 무조치인 PR 집합» 을 다시 계산해 +// 차이만 쓰므로 붙이기와 떼기가 갈라질 수 없다. 호출도 PR 수만큼 곱해지지 않는다 (#1465). +// +// 판정 기준은 `review-debt-guard.yml` 과 같아야 한다. 한쪽만 바뀌면 가드가 «작성자 몫» 이라고 +// 판단한 PR 에 라벨이 없거나, 반대로 리뷰어 몫인 PR 에 라벨이 붙는다. 두 판정이 공유하는 +// 술어는 `awaiting-author-policy.test.mjs` 가 잠근다. + +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +export const DEFAULT_LABEL = "awaiting-author"; + +const LABEL_COLOR = "FBCA04"; +const LABEL_DESCRIPTION = "변경요청 뒤 작성자 조치 없음 — 공은 작성자에게 있다"; +// 25건 × (리뷰·커밋·코멘트 100) 를 한 번에 물으면 GraphQL 이 시간 안에 못 돌려 504 를 낸다 +// (8/30 실측). 페이지를 잘게 끊고, 하위 컬렉션도 «판정 이후» 를 덮을 만큼만 가져온다. 셋 다 +// `last` 인 것은 필요한 쪽이 최근이기 때문이다 — 판정은 최신 결정 리뷰이고, 반영·응답은 그 +// 시각 이후만 센다. `first` 로 앞부분을 가져오면 정작 볼 구간이 잘린다. +const PULL_REQUEST_PAGE_SIZE = 10; + +const OPEN_PULL_REQUESTS_QUERY = ` +query($owner: String!, $name: String!, $cursor: String, $pageSize: Int!) { + repository(owner: $owner, name: $name) { + pullRequests(states: OPEN, first: $pageSize, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + number + title + isDraft + createdAt + author { login } + headRepository { nameWithOwner } + labels(first: 50) { nodes { name } } + reviews(last: 50) { + nodes { + state + submittedAt + author { login } + } + } + commits(last: 50) { + nodes { + commit { + committedDate + changedFilesIfAvailable + parents(first: 2) { totalCount } + author { email user { login } } + } + } + } + comments(last: 50) { + nodes { + createdAt + author { login } + } + } + } + } + } +}`; + +function sameLogin(a, b) { + return typeof a === "string" && typeof b === "string" && a.toLowerCase() === b.toLowerCase(); +} + +/** + * PR 전체에서 «최신 결정 리뷰» 를 고른다. + * + * 리뷰어별로 묶지 않는다. 팀에서 한 사람이 더 늦은 판정을 내렸다면 그것이 PR 의 현재 상태이고, + * 더 오래된 다른 리뷰어의 변경요청을 다시 살리지 않는다 — 가드의 `sort_by(.t) | last` 와 같다. + */ +export function latestDecision(reviews) { + const decisions = (reviews ?? []) + .filter((review) => review?.state === "APPROVED" || review?.state === "CHANGES_REQUESTED") + .filter((review) => typeof review.submittedAt === "string") + .sort((a, b) => (a.submittedAt < b.submittedAt ? -1 : a.submittedAt > b.submittedAt ? 1 : 0)); + + return decisions.length > 0 ? decisions[decisions.length - 1] : null; +} + +/** + * 판정 시각 이후 작성자가 올린 «실질» 커밋 수를 센다. + * + * 병합 커밋은 빼고(base 를 끌어온 변경이 반영분과 구별되지 않는다), 바뀐 파일이 0건인 커밋도 + * 뺀다. 누가 올렸는지를 보지 않으면 리뷰어가 미는 CI 재트리거 빈 커밋이 «작성자가 반영했다» 가 + * 되어 가짜 판정이 된다 — 8/28 에 그 한 건으로 PR 3건이 빚으로 잡혔다 (#1459). + * + * 계정이 연결되지 않은 커밋은 login 이 비어 가릴 수 없다. 같은 PR 에서 작성자 것으로 확인된 + * 커밋의 이메일을 폴백 신원으로 쓴다. + * + * 변경 파일 수를 알 수 없으면(`changedFilesIfAvailable` 가 null) 반영으로 센다. 조회 공백을 + * «빈 커밋» 으로 접으면 라벨이 없는 근거로 둔갑한다 — 놓치는 쪽이 잘못 붙이는 쪽보다 안전하다. + */ +export function countAuthorFixes({ commits, author, since }) { + 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) { + ownEmails.add(commit.author.email.toLowerCase()); + } + } + + let fixes = 0; + for (const commit of nodes) { + if ((commit.parents?.totalCount ?? 0) >= 2) { + continue; + } + if (!(typeof commit.committedDate === "string" && commit.committedDate > since)) { + continue; + } + + const login = commit.author?.user?.login; + const email = commit.author?.email?.toLowerCase(); + const isAuthor = login ? sameLogin(login, author) : Boolean(email && ownEmails.has(email)); + if (!isAuthor) { + continue; + } + + const changed = commit.changedFilesIfAvailable; + if (changed === 0) { + continue; + } + fixes += 1; + } + + return fixes; +} + +/** + * 판정 시각 이후 작성자가 남긴 응답 수를 센다. + * + * 커밋만 세면 base 를 merge 로 끌어와 반영한 PR 이 «무조치» 가 된다 — #1316 은 리뷰가 지목한 + * 파일이 실제로 바뀌고 응답 코멘트도 2건 달린 채 빚에서 빠져 있었다 (#1450). + * + * 라인 코멘트는 따로 조회하지 않는다. 작성자가 라인 코멘트를 남기면 그 코멘트를 담은 리뷰가 + * 함께 제출되므로 `reviews` 에서 잡힌다. + */ +export function countAuthorResponses({ comments, reviews, author, since }) { + const issueComments = (comments ?? []).filter( + (comment) => + sameLogin(comment?.author?.login, author) && + typeof comment?.createdAt === "string" && + comment.createdAt > since, + ).length; + + const authorReviews = (reviews ?? []).filter( + (review) => + sameLogin(review?.author?.login, author) && + typeof review?.submittedAt === "string" && + review.submittedAt > since, + ).length; + + return issueComments + authorReviews; +} + +/** + * PR 한 건이 «변경요청 뒤 작성자 무조치» 인지 판정한다. + * + * 제외 축은 가드와 같다. draft 는 리뷰를 받을 상태가 아니고, 봇 PR 은 사람이 반영할 주체가 + * 없으며, fork PR 은 팀 리뷰 적체와 무관하다. + */ +export function judgeAwaitingAuthor(pullRequest, { repository } = {}) { + const author = pullRequest?.author?.login ?? ""; + + if (pullRequest?.isDraft) { + return { awaiting: false, reason: "draft" }; + } + if (!author) { + return { awaiting: false, reason: "작성자 없음" }; + } + if (/\[bot\]$/i.test(author) || author === "dependabot" || author === "github-actions") { + return { awaiting: false, reason: "봇 PR" }; + } + + const headRepository = pullRequest?.headRepository?.nameWithOwner; + if (repository && headRepository && headRepository !== repository) { + return { awaiting: false, reason: `fork PR(${headRepository})` }; + } + + const decision = latestDecision(pullRequest?.reviews?.nodes); + if (!decision) { + return { awaiting: false, reason: "결정 리뷰 없음" }; + } + if (decision.state !== "CHANGES_REQUESTED") { + return { awaiting: false, reason: "최신 판정이 변경요청이 아님" }; + } + + const since = decision.submittedAt; + const fixes = countAuthorFixes({ + commits: pullRequest?.commits?.nodes, + author, + since, + }); + const responses = countAuthorResponses({ + comments: pullRequest?.comments?.nodes, + reviews: pullRequest?.reviews?.nodes, + author, + since, + }); + + if (fixes > 0 || responses > 0) { + return { + awaiting: false, + reason: `작성자 조치 있음(${fixes}커밋 · 응답 ${responses}건)`, + decidedAt: since, + }; + } + + return { + awaiting: true, + reason: "변경요청 뒤 작성자 조치 없음", + decidedAt: since, + reviewer: decision.author?.login ?? "", + }; +} + +/** + * 라벨을 붙일 PR 과 뗄 PR 을 한 번에 계산한다. + * + * 매 실행이 현재 집합을 다시 세우고 차이만 쓴다. 조치가 들어오면 다음 실행에서 라벨이 떨어지므로 + * 스테일 라벨이 남지 않는다. + */ +export function planAwaitingAuthorLabels({ pullRequests, repository, label = DEFAULT_LABEL }) { + const toLabel = []; + const toUnlabel = []; + const unchanged = []; + + for (const pullRequest of pullRequests ?? []) { + const labels = (pullRequest?.labels?.nodes ?? []).map((node) => node?.name); + const labeled = labels.includes(label); + const verdict = judgeAwaitingAuthor(pullRequest, { repository }); + const entry = { + number: pullRequest.number, + title: pullRequest.title, + author: pullRequest?.author?.login ?? "", + reason: verdict.reason, + decidedAt: verdict.decidedAt, + reviewer: verdict.reviewer, + }; + + if (verdict.awaiting && !labeled) { + toLabel.push(entry); + } else if (!verdict.awaiting && labeled) { + toUnlabel.push(entry); + } else { + unchanged.push({ ...entry, labeled }); + } + } + + return { toLabel, toUnlabel, unchanged }; +} + +export function renderSummary({ plan, dryRun, label = DEFAULT_LABEL }) { + const lines = [`## \`${label}\` 리컨사일`]; + if (dryRun) { + lines.push("", "> DRY_RUN — 라벨을 쓰지 않고 계획만 출력합니다."); + } + + lines.push("", `- 붙임 ${plan.toLabel.length}건 · 뗌 ${plan.toUnlabel.length}건`); + + if (plan.toLabel.length > 0) { + lines.push("", "| PR | 작성자 | 변경요청 | 리뷰어 |", "|---|---|---|---|"); + for (const entry of plan.toLabel) { + lines.push( + `| #${entry.number} | @${entry.author} | ${entry.decidedAt?.slice(0, 10) ?? "-"} | @${entry.reviewer || "-"} |`, + ); + } + } + + if (plan.toUnlabel.length > 0) { + lines.push("", "**뗀 PR**", ""); + for (const entry of plan.toUnlabel) { + lines.push(`- #${entry.number} — ${entry.reason}`); + } + } + + return lines.join("\n"); +} + +function createApi(token) { + return async function api(apiPath, { method = "GET", body, allowNotFound = false } = {}) { + const response = await fetch(`https://api.github.com${apiPath}`, { + method, + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "content-type": "application/json", + "x-github-api-version": "2022-11-28", + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + if (allowNotFound && response.status === 404) { + return null; + } + if (!response.ok) { + const detail = await response.text(); + throw new Error(`GitHub API ${method} ${apiPath} 실패: ${response.status} ${detail}`); + } + if (response.status === 204) { + return null; + } + return response.json(); + }; +} + +async function graphql(api, query, variables) { + const payload = await api("/graphql", { method: "POST", body: { query, variables } }); + if (payload?.errors?.length) { + throw new Error(`GraphQL 실패: ${JSON.stringify(payload.errors)}`); + } + return payload.data; +} + +export async function fetchOpenPullRequests(api, repository) { + const [owner, name] = repository.split("/"); + const pullRequests = []; + let cursor = null; + + for (;;) { + const data = await graphql(api, OPEN_PULL_REQUESTS_QUERY, { + owner, + name, + cursor, + pageSize: PULL_REQUEST_PAGE_SIZE, + }); + const page = data?.repository?.pullRequests; + if (!page) { + break; + } + pullRequests.push(...(page.nodes ?? []).filter(Boolean)); + if (!page.pageInfo?.hasNextPage) { + break; + } + cursor = page.pageInfo.endCursor; + } + + return pullRequests; +} + +export async function ensureLabelExists(api, repository, label = DEFAULT_LABEL) { + const existing = await api(`/repos/${repository}/labels/${encodeURIComponent(label)}`, { + allowNotFound: true, + }); + if (existing) { + return; + } + await api(`/repos/${repository}/labels`, { + method: "POST", + body: { name: label, color: LABEL_COLOR, description: LABEL_DESCRIPTION }, + }); +} + +export async function applyPlan(api, repository, plan, { dryRun = false, label = DEFAULT_LABEL, logger = console } = {}) { + const failures = []; + + for (const entry of plan.toLabel) { + if (dryRun) { + logger.log(`(dry-run) #${entry.number} ${label} 부착`); + continue; + } + try { + await api(`/repos/${repository}/issues/${entry.number}/labels`, { + method: "POST", + body: { labels: [label] }, + }); + logger.log(`#${entry.number} ${label} 부착 — ${entry.reason}`); + } catch (error) { + failures.push(`#${entry.number} 라벨 부착 실패: ${error.message}`); + } + } + + for (const entry of plan.toUnlabel) { + if (dryRun) { + logger.log(`(dry-run) #${entry.number} ${label} 제거`); + continue; + } + try { + await api( + `/repos/${repository}/issues/${entry.number}/labels/${encodeURIComponent(label)}`, + { method: "DELETE", allowNotFound: true }, + ); + logger.log(`#${entry.number} ${label} 제거 — ${entry.reason}`); + } catch (error) { + failures.push(`#${entry.number} 라벨 제거 실패: ${error.message}`); + } + } + + return failures; +} + +async function main() { + const token = process.env.GITHUB_TOKEN; + const repository = process.env.GITHUB_REPOSITORY; + if (!token || !repository) { + throw new Error("GITHUB_TOKEN·GITHUB_REPOSITORY 가 필요합니다."); + } + + const label = process.env.AWAITING_AUTHOR_LABEL ?? DEFAULT_LABEL; + const dryRun = process.env.DRY_RUN === "true"; + const api = createApi(token); + + const pullRequests = await fetchOpenPullRequests(api, repository); + const plan = planAwaitingAuthorLabels({ pullRequests, repository, label }); + + if (!dryRun && plan.toLabel.length > 0) { + await ensureLabelExists(api, repository, label); + } + + const failures = await applyPlan(api, repository, plan, { dryRun, label }); + const summary = renderSummary({ plan, dryRun, label }); + console.log(summary); + + if (process.env.GITHUB_STEP_SUMMARY) { + const { appendFile } = await import("node:fs/promises"); + await appendFile(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`); + } + + if (failures.length > 0) { + throw new Error(failures.join("\n")); + } +} + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; +if (import.meta.url === invokedPath) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/label-awaiting-author-prs.test.mjs b/.github/scripts/label-awaiting-author-prs.test.mjs new file mode 100644 index 0000000000..e0d6219fe4 --- /dev/null +++ b/.github/scripts/label-awaiting-author-prs.test.mjs @@ -0,0 +1,308 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEFAULT_LABEL, + applyPlan, + countAuthorFixes, + countAuthorResponses, + ensureLabelExists, + judgeAwaitingAuthor, + latestDecision, + planAwaitingAuthorLabels, + renderSummary, +} from "./label-awaiting-author-prs.mjs"; + +const REPO = "Afternote/Afternote-FE"; + +/** 테스트 출력이 CI 로그에서 실제 조작처럼 보이지 않도록 삼킨다. */ +const silent = { log() {} }; + +function fakeApi({ responses = {}, failOn = null } = {}) { + const calls = []; + const api = async (apiPath, options = {}) => { + calls.push({ apiPath, method: options.method ?? "GET", body: options.body }); + if (failOn && failOn(apiPath, options)) { + throw new Error("boom"); + } + for (const [pattern, value] of Object.entries(responses)) { + if (apiPath.startsWith(pattern)) { + return typeof value === "function" ? value(apiPath, options) : value; + } + } + return options.allowNotFound ? null : {}; + }; + api.calls = calls; + return api; +} + +function commit({ login = "author", email = "author@example.com", date = "2026-08-30T00:00:00Z", changed = 3, parents = 1 } = {}) { + return { + commit: { + committedDate: date, + changedFilesIfAvailable: changed, + parents: { totalCount: parents }, + author: { email, user: login === null ? null : { login } }, + }, + }; +} + +function pullRequest(overrides = {}) { + return { + number: 1, + title: "fix(core): 무언가", + isDraft: false, + createdAt: "2026-08-01T00:00:00Z", + author: { login: "author" }, + headRepository: { nameWithOwner: REPO }, + labels: { nodes: [] }, + reviews: { nodes: [] }, + commits: { nodes: [] }, + comments: { nodes: [] }, + ...overrides, + }; +} + +function changesRequested(at = "2026-08-29T00:00:00Z", reviewer = "reviewer") { + return { state: "CHANGES_REQUESTED", submittedAt: at, author: { login: reviewer } }; +} + +test("최신 결정 리뷰는 리뷰어별로 묶지 않고 PR 전체에서 하나만 고른다", () => { + // 팀에서 한 사람이 더 늦은 판정을 내리면 그것이 PR 의 현재 상태다. 더 오래된 다른 + // 리뷰어의 변경요청을 다시 살리면 승인된 PR 에까지 라벨이 붙는다. + const decision = latestDecision([ + { state: "CHANGES_REQUESTED", submittedAt: "2026-08-01T00:00:00Z", author: { login: "a" } }, + { state: "APPROVED", submittedAt: "2026-08-05T00:00:00Z", author: { login: "b" } }, + ]); + assert.equal(decision.state, "APPROVED"); + + // COMMENTED 는 판정이 아니다. + const onlyComments = latestDecision([ + { state: "COMMENTED", submittedAt: "2026-08-09T00:00:00Z", author: { login: "a" } }, + ]); + assert.equal(onlyComments, null); +}); + +test("변경요청 뒤 작성자가 아무것도 하지 않으면 대상이다", () => { + const verdict = judgeAwaitingAuthor( + pullRequest({ reviews: { nodes: [changesRequested()] } }), + { repository: REPO }, + ); + assert.equal(verdict.awaiting, true); + assert.equal(verdict.reviewer, "reviewer"); + assert.equal(verdict.decidedAt, "2026-08-29T00:00:00Z"); +}); + +test("최신 판정이 승인이면 대상이 아니다", () => { + // 승인 뒤 작성자가 손보는 것은 작성자 몫이지만, 그것은 «머지 가능한 상태» 이지 + // «지적을 방치한 상태» 가 아니다. + const verdict = judgeAwaitingAuthor( + pullRequest({ + reviews: { + nodes: [changesRequested("2026-08-01T00:00:00Z"), { state: "APPROVED", submittedAt: "2026-08-02T00:00:00Z", author: { login: "reviewer" } }], + }, + }), + { repository: REPO }, + ); + assert.equal(verdict.awaiting, false); +}); + +test("리뷰어가 미는 빈 CI 재트리거 커밋은 작성자 조치가 아니다", () => { + // 8/28 에 리뷰어가 건 +0/-0 커밋 하나로 PR 3건(#1379·#1365·#882)이 전부 가짜 빚이 + // 됐다 (#1459). 여기서 같은 실수를 하면 방치된 PR 의 라벨이 조용히 떨어진다. + const withReviewerEmptyCommit = pullRequest({ + reviews: { nodes: [changesRequested()] }, + commits: { nodes: [commit({ login: "reviewer", email: "reviewer@example.com", changed: 0 })] }, + }); + assert.equal(judgeAwaitingAuthor(withReviewerEmptyCommit, { repository: REPO }).awaiting, true); + + // 작성자가 올린 커밋이라도 바뀐 파일이 0건이면 반영이 아니다. + const ownEmptyCommit = pullRequest({ + reviews: { nodes: [changesRequested()] }, + commits: { nodes: [commit({ changed: 0 })] }, + }); + assert.equal(judgeAwaitingAuthor(ownEmptyCommit, { repository: REPO }).awaiting, true); +}); + +test("작성자의 실질 커밋이 하나라도 있으면 대상에서 빠진다", () => { + const verdict = judgeAwaitingAuthor( + pullRequest({ + reviews: { nodes: [changesRequested()] }, + commits: { nodes: [commit({ date: "2026-08-29T12:00:00Z" })] }, + }), + { repository: REPO }, + ); + assert.equal(verdict.awaiting, false); + assert.match(verdict.reason, /1커밋/); +}); + +test("판정 이전 커밋은 반영으로 세지 않는다", () => { + const verdict = judgeAwaitingAuthor( + pullRequest({ + reviews: { nodes: [changesRequested("2026-08-29T00:00:00Z")] }, + commits: { nodes: [commit({ date: "2026-08-28T00:00:00Z" })] }, + }), + { repository: REPO }, + ); + assert.equal(verdict.awaiting, true); +}); + +test("병합으로 반영하고 코멘트를 남긴 PR 은 대상이 아니다", () => { + // 비병합 커밋만 세면 base 를 끌어와 충돌을 풀며 반영한 PR 이 «무조치» 가 된다. + // #1316 은 리뷰가 지목한 파일이 실제로 바뀌고 응답도 2건 달린 채 빚에서 빠져 + // 있었다 (#1450). 커밋과 함께 작성자의 응답을 본다. + const verdict = judgeAwaitingAuthor( + pullRequest({ + reviews: { nodes: [changesRequested()] }, + commits: { nodes: [commit({ date: "2026-08-29T10:00:00Z", parents: 2 })] }, + comments: { nodes: [{ createdAt: "2026-08-29T11:00:00Z", author: { login: "author" } }] }, + }), + { repository: REPO }, + ); + assert.equal(verdict.awaiting, false); + assert.match(verdict.reason, /응답 1건/); +}); + +test("작성자가 제출한 리뷰도 응답으로 센다", () => { + // 라인 코멘트를 남기면 그것을 담은 리뷰가 함께 제출된다. 별도 조회 없이 잡힌다. + const responses = countAuthorResponses({ + comments: [], + reviews: [ + changesRequested(), + { state: "COMMENTED", submittedAt: "2026-08-29T05:00:00Z", author: { login: "author" } }, + ], + author: "author", + since: "2026-08-29T00:00:00Z", + }); + assert.equal(responses, 1); +}); + +test("변경 파일 수를 알 수 없는 커밋은 반영으로 센다", () => { + // 조회 공백을 «빈 커밋» 으로 접으면 라벨이 없는 근거로 둔갑한다. 놓치는 쪽이 + // 잘못 붙이는 쪽보다 안전하다. + const fixes = countAuthorFixes({ + commits: [commit({ date: "2026-08-29T10:00:00Z", changed: null })], + author: "author", + since: "2026-08-29T00:00:00Z", + }); + assert.equal(fixes, 1); +}); + +test("계정이 연결되지 않은 커밋은 같은 PR 의 작성자 이메일로 가린다", () => { + const fixes = countAuthorFixes({ + commits: [ + commit({ date: "2026-08-29T10:00:00Z" }), + commit({ login: null, email: "AUTHOR@example.com", date: "2026-08-29T11:00:00Z" }), + ], + author: "author", + since: "2026-08-29T00:00:00Z", + }); + assert.equal(fixes, 2); + + // 남의 이메일까지 주우면 리뷰어 커밋이 작성자 것이 된다. + const strangers = countAuthorFixes({ + commits: [commit({ login: null, email: "someone@example.com", date: "2026-08-29T11:00:00Z" })], + author: "author", + since: "2026-08-29T00:00:00Z", + }); + assert.equal(strangers, 0); +}); + +test("draft·봇·fork 는 판정 대상이 아니다", () => { + const draft = pullRequest({ isDraft: true, reviews: { nodes: [changesRequested()] } }); + assert.equal(judgeAwaitingAuthor(draft, { repository: REPO }).awaiting, false); + + const bot = pullRequest({ author: { login: "dependabot[bot]" }, reviews: { nodes: [changesRequested()] } }); + assert.equal(judgeAwaitingAuthor(bot, { repository: REPO }).awaiting, false); + + const fork = pullRequest({ + headRepository: { nameWithOwner: "someone/Afternote-FE" }, + reviews: { nodes: [changesRequested()] }, + }); + assert.equal(judgeAwaitingAuthor(fork, { repository: REPO }).awaiting, false); +}); + +test("계획은 붙일 것과 뗄 것을 한 번에 낸다", () => { + const plan = planAwaitingAuthorLabels({ + repository: REPO, + pullRequests: [ + // 무조치 · 라벨 없음 → 붙인다 + pullRequest({ number: 10, reviews: { nodes: [changesRequested()] } }), + // 조치함 · 라벨 있음 → 뗀다 + pullRequest({ + number: 11, + labels: { nodes: [{ name: DEFAULT_LABEL }] }, + reviews: { nodes: [changesRequested()] }, + commits: { nodes: [commit({ date: "2026-08-29T10:00:00Z" })] }, + }), + // 무조치 · 라벨 있음 → 그대로 + pullRequest({ + number: 12, + labels: { nodes: [{ name: DEFAULT_LABEL }] }, + reviews: { nodes: [changesRequested()] }, + }), + // 승인 · 라벨 없음 → 그대로 + pullRequest({ number: 13 }), + ], + }); + + assert.deepEqual(plan.toLabel.map((entry) => entry.number), [10]); + assert.deepEqual(plan.toUnlabel.map((entry) => entry.number), [11]); + assert.deepEqual(plan.unchanged.map((entry) => entry.number), [12, 13]); +}); + +test("적용은 실패한 PR 만 보고하고 나머지를 계속 처리한다", async () => { + const api = fakeApi({ failOn: (apiPath) => apiPath.includes("/issues/10/") }); + const plan = { + toLabel: [ + { number: 10, reason: "변경요청 뒤 작성자 조치 없음" }, + { number: 11, reason: "변경요청 뒤 작성자 조치 없음" }, + ], + toUnlabel: [{ number: 12, reason: "작성자 조치 있음(1커밋 · 응답 0건)" }], + }; + + const failures = await applyPlan(api, REPO, plan, { logger: silent }); + + assert.equal(failures.length, 1); + assert.match(failures[0], /#10/); + assert.ok(api.calls.some((call) => call.apiPath === `/repos/${REPO}/issues/11/labels` && call.method === "POST")); + assert.ok( + api.calls.some( + (call) => call.apiPath === `/repos/${REPO}/issues/12/labels/${DEFAULT_LABEL}` && call.method === "DELETE", + ), + ); +}); + +test("dry-run 은 아무것도 쓰지 않는다", async () => { + const api = fakeApi(); + const plan = { toLabel: [{ number: 10, reason: "r" }], toUnlabel: [{ number: 11, reason: "r" }] }; + + await applyPlan(api, REPO, plan, { dryRun: true, logger: silent }); + + assert.equal(api.calls.filter((call) => call.method !== "GET").length, 0); +}); + +test("이미 있는 라벨은 다시 만들지 않는다", async () => { + const existing = fakeApi({ responses: { [`/repos/${REPO}/labels/${DEFAULT_LABEL}`]: { name: DEFAULT_LABEL } } }); + await ensureLabelExists(existing, REPO); + assert.equal(existing.calls.filter((call) => call.method === "POST").length, 0); + + const missing = fakeApi({ responses: { [`/repos/${REPO}/labels/${DEFAULT_LABEL}`]: () => null } }); + await ensureLabelExists(missing, REPO); + assert.ok(missing.calls.some((call) => call.apiPath === `/repos/${REPO}/labels` && call.method === "POST")); +}); + +test("요약에는 붙인 PR 의 근거가 남는다", () => { + const summary = renderSummary({ + plan: { + toLabel: [{ number: 440, author: "koongmai", decidedAt: "2026-08-29T00:00:00Z", reviewer: "Sadturtleman" }], + toUnlabel: [{ number: 11, reason: "작성자 조치 있음(1커밋 · 응답 0건)" }], + }, + dryRun: false, + }); + + assert.match(summary, /#440/); + assert.match(summary, /@koongmai/); + assert.match(summary, /2026-08-29/); + assert.match(summary, /#11 — 작성자 조치 있음/); +}); diff --git a/.github/workflows/conflict-label.yml b/.github/workflows/conflict-label.yml index 48cb581fc6..51a47f8a8c 100644 --- a/.github/workflows/conflict-label.yml +++ b/.github/workflows/conflict-label.yml @@ -7,6 +7,10 @@ name: PR Label Reconcile # # 같은 자리에서 이슈 쪽 라벨도 맞춘다 (#1532) — base 가 기본 브랜치가 아닌 PR 은 이슈의 # Development 칸에 뜨지 않아, 이슈만 보면 아무도 손대지 않은 것으로 읽힌다. +# +# 변경요청 뒤 작성자가 아무 조치도 하지 않은 PR 에도 라벨을 붙인다 (#1552). `review-debt-guard` +# 는 리뷰어의 빚만 잡고 그 상태를 «공은 작성자에게» 로 흘려보내므로, 그쪽 축은 어디서도 신호가 +# 나지 않았다. 붙이기와 떼기를 한 번의 차이 계산으로 처리해 스테일 라벨이 남지 않게 한다. on: push: branches: [develop] @@ -55,7 +59,9 @@ jobs: node --test \ .github/scripts/label-conflicting-prs.test.mjs \ .github/scripts/label-android-test-plan.test.mjs \ - .github/scripts/label-issues-with-open-prs.test.mjs + .github/scripts/label-issues-with-open-prs.test.mjs \ + .github/scripts/label-awaiting-author-prs.test.mjs \ + .github/scripts/awaiting-author-policy.test.mjs - name: Reconcile pull request labels env: @@ -69,4 +75,5 @@ jobs: node .github/scripts/label-conflicting-prs.mjs || failures=1 node .github/scripts/label-android-test-prs.mjs || failures=1 node .github/scripts/label-issues-with-open-prs.mjs || failures=1 + node .github/scripts/label-awaiting-author-prs.mjs || failures=1 exit "$failures"