diff --git a/.github/scripts/awaiting-author-policy.test.mjs b/.github/scripts/awaiting-author-policy.test.mjs deleted file mode 100644 index 1e4b7103e..000000000 --- a/.github/scripts/awaiting-author-policy.test.mjs +++ /dev/null @@ -1,176 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -import { - judgeAwaitingAuthor, - parseReviewGateExemptAuthors, - planAwaitingAuthorLabels, -} 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"); -const authorDebtCheck = await readFile(new URL("./check-author-debt.mjs", import.meta.url), "utf8"); -const exemptAuthors = parseReviewGateExemptAuthors(guard); - -test("가드는 작성자 무조치를 공통 판정 CLI 로 검사한다", () => { - // 라벨은 주기 리컨사일 결과라 입장 시점에 스테일할 수 있다. 가드는 live PR 목록을 읽는 - // CLI 를 호출하고, CLI 는 라벨 리컨사일러와 같은 judgeAwaitingAuthor 를 재사용해야 한다. - assert.match(guard, /node \.github\/scripts\/check-author-debt\.mjs/); - assert.match(guard, /AUTHOR_DEBT_FILE="\$author_debt_file"/); - assert.match(authorDebtCheck, /findAuthorDebts/); - assert.match(script, /export function findAuthorDebts/); -}); - -test("라벨 판정은 입장 가드에 선언된 작성자 면제 목록을 직접 읽는다", () => { - assert.match(guard, /^\s*REVIEW_GATE_EXEMPT_AUTHORS:/m); - assert.match(guard, /\$REVIEW_GATE_EXEMPT_AUTHORS/); - assert.match(script, /new URL\("\.\.\/workflows\/review-debt-guard\.yml", import\.meta\.url\)/); - assert.doesNotMatch(script, /review-gate-policy\.json/); - const mainAt = script.indexOf("async function main()"); - assert.ok(mainAt > 0); - const main = script.slice(mainAt); - assert.match(main, /parseReviewGateExemptAuthors\(/); - assert.match(main, /planAwaitingAuthorLabels\(\{[^}]*\bexemptAuthors\b/); -}); - -test("workflow 파일이 없는 가드 checkout에서도 작성자 빚 판정을 import할 수 있다", async () => { - // data URL 에는 상대 workflow 경로가 없다. 모듈을 불러오는 순간 목록을 읽으면 - // scripts 만 checkout 하는 오래된 가드와 마찬가지로 import가 실패한다. - const module = await import(`data:text/javascript;base64,${Buffer.from(script).toString("base64")}`); - - assert.equal(typeof module.findAuthorDebts, "function"); - assert.equal(typeof module.judgeAwaitingAuthor, "function"); -}); - -test("가드의 면제 목록에 있는 작성자는 라벨 계획에서도 빠진다", () => { - const pullRequests = exemptAuthors.map((login, index) => ({ - number: index + 1, - isDraft: false, - author: { login: login.toUpperCase() }, - labels: { nodes: [{ name: "awaiting-author" }] }, - reviews: { - nodes: [{ - state: "CHANGES_REQUESTED", - submittedAt: "2026-08-29T00:00:00Z", - authorCanPushToRepository: true, - author: { login: "reviewer" }, - }], - }, - })); - const plan = planAwaitingAuthorLabels({ pullRequests, exemptAuthors }); - - assert.deepEqual(plan.toLabel, []); - assert.deepEqual(plan.toUnlabel.map((entry) => entry.number), pullRequests.map(({ number }) => number)); - assert.ok(plan.toUnlabel.every(({ reason }) => reason === "리뷰 게이트 면제 작성자")); -}); - -test("리뷰어 빚과 작성자 빚은 한 번의 안내와 종료로 합친다", () => { - // 두 경로가 각각 댓글·close 를 수행하면 같은 opened 이벤트에서 중복 코멘트와 경합이 - // 생긴다. 두 TSV 를 모두 만든 뒤 한 번만 최종 결정을 내려야 한다. - assert.match(guard, /\[ ! -s "\$debt_file" \] && \[ ! -s "\$author_debt_file" \]/); - assert.equal((guard.match(/gh pr close/g) ?? []).length, 1); - assert.equal((guard.match(/> \/tmp\/debt-comment\.md/g) ?? []).length, 1); -}); - -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("두 판정 모두 변경요청 뒤 작성자의 PR 본문 편집을 조치로 본다", () => { - // CI Test Plan 처럼 PR 본문 자체가 리뷰 대상인 지적은 새 커밋 없이 해결된다. 일반 - // updatedAt 이 아니라 편집자와 편집 시각이 남는 userContentEdits 를 사용해야 한다. - assert.match(guard, /userContentEdits/); - assert.match(script, /userContentEdits\(last: 50\)/); - assert.match(script, /export function countAuthorBodyEdits/); - assert.match(script, /bodyEdits > 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", - authorCanPushToRepository: true, - 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/check-author-debt.mjs b/.github/scripts/check-author-debt.mjs deleted file mode 100644 index 954394d4d..000000000 --- a/.github/scripts/check-author-debt.mjs +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env node - -// 새 PR 을 여는 사람이 변경요청을 받은 «자기가 담당하는» PR 을 방치하고 있는지 live 상태로 -// 검사한다. 담당은 어사인이고 어사인이 비면 작성자다(#1974) — AUTHOR 는 여는 사람의 로그인이지 -// «작성자 필터» 가 아니다. -// 라벨은 스테일할 수 있으므로 입장 판정에 사용하지 않는다. 열린 PR 을 한 번 조회한 뒤 -// label-awaiting-author-prs.mjs 의 SSOT 판정을 재사용하고, 워크플로가 댓글과 close 를 한 번만 -// 처리할 수 있도록 결과만 TSV 로 넘긴다. - -import { writeFile } from "node:fs/promises"; -import path from "node:path"; -import process from "node:process"; -import { pathToFileURL } from "node:url"; - -import { - createApi, - fetchOpenPullRequestsByAuthor, - findAuthorDebts, -} from "./label-awaiting-author-prs.mjs"; - -function requiredEnv(env, name) { - const value = env[name]; - if (typeof value !== "string" || value.trim() === "") { - throw new Error(`${name} 가 필요합니다.`); - } - return value; -} - -function safeTsvField(value) { - return String(value ?? "").replace(/[\u0000-\u001f\u007f]+/g, " ").trim(); -} - -export function renderAuthorDebtTsv(debts) { - if (!Array.isArray(debts)) { - throw new TypeError("debts 배열이 필요합니다."); - } - - const lines = debts.map((debt) => - [debt.number, debt.createdDate, debt.reviewer, debt.title] - .map(safeTsvField) - .join("\t"), - ); - return lines.length > 0 ? `${lines.join("\n")}\n` : ""; -} - -export async function runAuthorDebtCheck({ env = process.env, api, logger = console } = {}) { - const token = requiredEnv(env, "GITHUB_TOKEN"); - const repository = requiredEnv(env, "GITHUB_REPOSITORY"); - const author = requiredEnv(env, "AUTHOR"); - const currentPullRequestNumber = requiredEnv(env, "CURRENT_PULL_REQUEST_NUMBER"); - const outputFile = requiredEnv(env, "AUTHOR_DEBT_FILE"); - - const pullRequests = await fetchOpenPullRequestsByAuthor( - api ?? createApi(token), - repository, - author, - ); - const debts = findAuthorDebts({ - pullRequests, - repository, - author, - currentPullRequestNumber, - }); - - // 빚이 없어도 파일을 새로 써서 이전 실행의 결과가 남지 않게 한다. - await writeFile(outputFile, renderAuthorDebtTsv(debts), "utf8"); - logger.log(`작성자 미조치 PR ${debts.length}건`); - return debts; -} - -const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; -if (import.meta.url === invokedPath) { - runAuthorDebtCheck().catch((error) => { - console.error(error.message); - process.exitCode = 1; - }); -} diff --git a/.github/scripts/check-author-debt.test.mjs b/.github/scripts/check-author-debt.test.mjs deleted file mode 100644 index 12e062a64..000000000 --- a/.github/scripts/check-author-debt.test.mjs +++ /dev/null @@ -1,146 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import test from "node:test"; - -import { renderAuthorDebtTsv, runAuthorDebtCheck } from "./check-author-debt.mjs"; - -const REPO = "Afternote/Afternote-FE"; -const silent = { log() {} }; - -function changesRequested() { - return { - state: "CHANGES_REQUESTED", - submittedAt: "2026-08-29T00:00:00Z", - authorCanPushToRepository: true, - author: { login: "reviewer" }, - }; -} - -function pullRequest(overrides = {}) { - return { - number: 10, - title: "fix(core): review debt", - isDraft: false, - createdAt: "2026-08-01T00:00:00Z", - author: { login: "author" }, - headRepository: { nameWithOwner: REPO }, - labels: { nodes: [] }, - reviews: { pageInfo: { hasPreviousPage: false }, nodes: [changesRequested()] }, - commits: { pageInfo: { hasPreviousPage: false }, nodes: [] }, - comments: { pageInfo: { hasPreviousPage: false }, nodes: [] }, - userContentEdits: { pageInfo: { hasPreviousPage: false }, nodes: [] }, - ...overrides, - }; -} - -function graphqlApi(pullRequests) { - return async (apiPath, options = {}) => { - assert.equal(apiPath, "/graphql"); - assert.equal(options.method, "POST"); - // 담당은 어사인이 정본이고 어사인이 비면 작성자라 두 축을 모두 검색한다(#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: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: pullRequests, - }, - }, - }; - }; -} - -function environment(outputFile, overrides = {}) { - return { - GITHUB_TOKEN: "test-token", - GITHUB_REPOSITORY: REPO, - AUTHOR: "author", - CURRENT_PULL_REQUEST_NUMBER: "99", - AUTHOR_DEBT_FILE: outputFile, - ...overrides, - }; -} - -test("CLI 는 live GraphQL 결과를 판정해 안전한 4열 TSV 를 쓴다", async (t) => { - const root = await mkdtemp(path.join(os.tmpdir(), "author-debt-")); - t.after(() => rm(root, { recursive: true, force: true })); - const outputFile = path.join(root, "debt.tsv"); - - const debts = await runAuthorDebtCheck({ - env: environment(outputFile), - api: graphqlApi([ - pullRequest({ title: "fix(core): tab\tand\nnewline" }), - pullRequest({ number: 99, title: "현재 PR" }), - ]), - logger: silent, - }); - - assert.equal(debts.length, 1); - assert.equal( - await readFile(outputFile, "utf8"), - "10\t2026-08-01\treviewer\tfix(core): tab and newline\n", - ); -}); - -test("CLI 는 빚이 없어도 빈 결과 파일을 새로 쓴다", async (t) => { - const root = await mkdtemp(path.join(os.tmpdir(), "author-debt-empty-")); - t.after(() => rm(root, { recursive: true, force: true })); - const outputFile = path.join(root, "debt.tsv"); - - await runAuthorDebtCheck({ - env: environment(outputFile), - api: graphqlApi([]), - logger: silent, - }); - - assert.equal(await readFile(outputFile, "utf8"), ""); -}); - -test("CLI 는 API 실패와 불완전한 GraphQL 응답을 빚 없음으로 통과시키지 않는다", async (t) => { - const root = await mkdtemp(path.join(os.tmpdir(), "author-debt-fail-")); - t.after(() => rm(root, { recursive: true, force: true })); - const outputFile = path.join(root, "debt.tsv"); - - await assert.rejects( - runAuthorDebtCheck({ - env: environment(outputFile), - api: async () => { - throw new Error("API unavailable"); - }, - logger: silent, - }), - /API unavailable/, - ); - - await assert.rejects( - runAuthorDebtCheck({ - env: environment(outputFile), - api: async () => ({ data: { search: null } }), - logger: silent, - }), - /작성자의 열린 PR 검색 결과가 없습니다/, - ); -}); - -test("TSV 렌더러는 행과 열을 깨는 제어 문자를 제거한다", () => { - assert.equal( - renderAuthorDebtTsv([ - { number: 7, createdDate: "2026-08-31", reviewer: "r\t1", title: "a\rb\nc" }, - ]), - "7\t2026-08-31\tr 1\ta b c\n", - ); -}); diff --git a/.github/scripts/ensure-api-quota.mjs b/.github/scripts/ensure-api-quota.mjs index 7cbec400e..bb2157518 100644 --- a/.github/scripts/ensure-api-quota.mjs +++ b/.github/scripts/ensure-api-quota.mjs @@ -4,8 +4,7 @@ // 구분한다 (#1465). // // 이 저장소의 게이트들은 잡마다 `gh api` 를 여러 번 부른다 — merge-order-guard 는 closing -// 이슈마다 blocked_by 를, review-debt-guard 는 열린 PR 전수를, impact 분류는 PR 메타를 -// 조회한다. PR 수 × 잡 수 × 잡당 호출수가 곱해져 installation 한도를 넘기면, 저장소 전체의 +// 이슈마다 blocked_by 를, impact 분류는 PR 메타를 조회한다. PR 수 × 잡 수 × 잡당 호출수가 곱해져 installation 한도를 넘기면, 저장소 전체의 // 게이트가 코드와 무관하게 빨갛게 된다. 그때 재실행은 남은 quota 를 더 태울 뿐이다. // // 명령: @@ -18,8 +17,8 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import process from "node:process"; -// 게이트 한 번이 쓰는 호출 수의 상한을 여유 있게 잡은 값. merge-order-guard 8 · review-debt-guard 6 -// (열린 PR 전수를 도는 루프 포함) 를 감안한다. +// 게이트 한 번이 쓰는 호출 수의 상한을 여유 있게 잡은 값. merge-order-guard 8 을 +// 감안한다. const DEFAULT_MIN_REMAINING = 200; // job timeout(대개 5분) 안에서 기다릴 수 있는 상한. 리셋이 이보다 멀면 기다리지 않고 실패한다. const DEFAULT_MAX_WAIT_SECONDS = 240; diff --git a/.github/scripts/ensure-api-quota.test.mjs b/.github/scripts/ensure-api-quota.test.mjs index 8d112266c..4e5cf578c 100644 --- a/.github/scripts/ensure-api-quota.test.mjs +++ b/.github/scripts/ensure-api-quota.test.mjs @@ -102,7 +102,7 @@ test("요약에 남은 호출과 리셋까지 남은 시간이 드러난다", () }); // 스크립트가 default branch 사본으로 도는 게이트. 판정 자체를 하는 자리라 PR 사본을 믿지 않는다. -const TRUSTED_COPY_GATES = ["merge-order-guard.yml", "review-debt-guard.yml"]; +const TRUSTED_COPY_GATES = ["merge-order-guard.yml"]; // PR 사본으로 도는 게이트. 이미 PR 사본의 분류·검증 스크립트를 실행하는 자리라 새 신뢰 경계가 // 열리지 않고, quota 스텝은 관측·대기라 무력화해도 통과시킬 판정이 없다. const PULL_REQUEST_COPY_GATES = ["repository-quality.yml", "codeql.yml"]; @@ -128,16 +128,6 @@ test("게이트 워크플로가 quota 확인과 실패 분류를 모두 건다", } }); -test("GraphQL 을 사용하는 리뷰 적체 가드는 core 와 graphql 을 모두 예산·분류한다", async () => { - const source = await workflow("review-debt-guard.yml"); - for (const resource of ["core", "graphql"]) { - assert.match( - source, - new RegExp(`ensure-api-quota\\.mjs ensure --resource ${resource} --max-wait 115`), - ); - assert.match(source, new RegExp(`ensure-api-quota\\.mjs classify --resource ${resource}`)); - } -}); test("default branch 사본으로 도는 게이트만 부트스트랩 예외를 갖는다", async () => { for (const name of TRUSTED_COPY_GATES) { diff --git a/.github/scripts/find-viewed-auto-merge-targets.mjs b/.github/scripts/find-viewed-auto-merge-targets.mjs index 0d6bbfe86..7087f07f6 100644 --- a/.github/scripts/find-viewed-auto-merge-targets.mjs +++ b/.github/scripts/find-viewed-auto-merge-targets.mjs @@ -1,7 +1,7 @@ // Viewed 기반 자동 머지 대상 판정 (#2248) // -// 지정한 한 사람(VIEWED_BY)이 PR 의 모든 파일을 Viewed 로 체크했고 PR 이 승인 상태면 «Merge when ready» -// 를 예약할 대상으로 고른다. 실제 머지는 GitHub 가 required check·승인 수·merge queue 를 통과시킨 뒤에만 +// 지정한 한 사람(VIEWED_BY)이 PR 의 모든 파일을 Viewed 로 체크했고 PR 에 승인 대기·변경 요청이 없으면 +// «Merge when ready» 를 예약할 대상으로 고른다. 실제 머지는 GitHub 가 required check·승인 수·merge queue 를 통과시킨 뒤에만 // 한다. // // viewerViewedState 는 토큰 주인 본인의 체크만 돌려준다. 그래서 VIEWED_BY 본인의 토큰(VIEWED_TOKEN)으로 @@ -17,6 +17,8 @@ import { appendFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; +const BLOCKING_REVIEW_DECISIONS = new Set(["CHANGES_REQUESTED", "REVIEW_REQUIRED"]); + const PULL_REQUEST_FIELDS = ` id number isDraft headRefOid reviewDecision isInMergeQueue changedFiles author { login } @@ -47,8 +49,10 @@ export function decideViewedAutoMerge(pullRequest, files) { if (pullRequest.autoMergeRequest || pullRequest.isInMergeQueue) { return { enable: false, reason: "이미 자동 머지 예약 또는 merge queue 에 있다" }; } - if (pullRequest.reviewDecision !== "APPROVED") { - return { enable: false, reason: `reviewDecision 이 ${pullRequest.reviewDecision ?? "없음"}` }; + // 승인 룰셋이 승인 0건을 요구하면 GitHub 는 reviewDecision 을 비워(null) 돌려준다. 막는 것은 + // 아직 승인이 필요하거나 변경 요청이 남은 상태뿐이다. + if (BLOCKING_REVIEW_DECISIONS.has(pullRequest.reviewDecision)) { + return { enable: false, reason: `reviewDecision 이 ${pullRequest.reviewDecision}` }; } // 파일 목록을 다 못 읽었으면 «다 봤다» 고 단정하지 않는다. if (files.length === 0 || files.length !== pullRequest.changedFiles) { @@ -58,7 +62,7 @@ export function decideViewedAutoMerge(pullRequest, files) { if (unviewed.length > 0) { return { enable: false, reason: `Viewed 안 된 파일 ${unviewed.length}/${files.length}` }; } - return { enable: true, reason: `모든 파일 Viewed·승인 — 자동 머지 예약` }; + return { enable: true, reason: `모든 파일 Viewed — 자동 머지 예약` }; } /** diff --git a/.github/scripts/find-viewed-auto-merge-targets.test.mjs b/.github/scripts/find-viewed-auto-merge-targets.test.mjs index 6e8144dad..ac2f0d749 100644 --- a/.github/scripts/find-viewed-auto-merge-targets.test.mjs +++ b/.github/scripts/find-viewed-auto-merge-targets.test.mjs @@ -35,13 +35,18 @@ test("하나라도 Viewed 가 아니면 예약하지 않는다 — 체크 뒤 } }); -test("승인 상태가 아니면 Viewed 를 다 해도 예약하지 않는다", () => { - for (const reviewDecision of ["CHANGES_REQUESTED", "REVIEW_REQUIRED", null]) { +test("승인 대기·변경 요청이면 Viewed 를 다 해도 예약하지 않는다", () => { + for (const reviewDecision of ["CHANGES_REQUESTED", "REVIEW_REQUIRED"]) { const decision = decideViewedAutoMerge(pullRequest({ reviewDecision }), [viewed("a.kt"), viewed("b.kt")]); assert.equal(decision.enable, false, String(reviewDecision)); } }); +test("승인을 요구하지 않는 룰셋(reviewDecision null)이면 Viewed 만으로 예약한다", () => { + const decision = decideViewedAutoMerge(pullRequest({ reviewDecision: null }), [viewed("a.kt"), viewed("b.kt")]); + assert.equal(decision.enable, true); +}); + test("파일 목록을 다 못 읽었으면 다 봤다고 단정하지 않는다", () => { assert.equal(decideViewedAutoMerge(pullRequest({ changedFiles: 3 }), [viewed("a.kt"), viewed("b.kt")]).enable, false); assert.equal(decideViewedAutoMerge(pullRequest({ changedFiles: 0 }), []).enable, false); diff --git a/.github/scripts/label-awaiting-author-prs.mjs b/.github/scripts/label-awaiting-author-prs.mjs deleted file mode 100644 index 05aaac278..000000000 --- a/.github/scripts/label-awaiting-author-prs.mjs +++ /dev/null @@ -1,787 +0,0 @@ -#!/usr/bin/env node - -// 변경요청을 받은 뒤 작성자가 아무 조치도 하지 않은 PR 에 라벨을 붙인다 (#1552). -// -// `review-debt-guard.yml` 은 리뷰어의 빚과 작성자의 빚을 서로 다른 입장 조건으로 다룬다. -// 리뷰어 빚 판정에서는 "재요청이 없거나 최신 변경요청 뒤 작성자 조치가 없으면 리뷰어 빚이 -// 아니다"(#1136)를 유지한다. 대신 `check-author-debt.mjs` 가 이 파일의 같은 판정을 재사용해, -// 공이 작성자에게 남은 자기 PR 이 있으면 새 PR 을 닫는다. 라벨 리컨사일러는 현재 상태를 열린 -// PR 목록에서 바로 볼 수 있게 만드는 관찰 가능성 경로이고, check CLI 는 새 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 { readFile } from "node:fs/promises"; -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 } - assignees(first: 10) { nodes { login } } - headRepository { nameWithOwner } - labels(first: 50) { nodes { name } } - reviews(last: 50) { - pageInfo { hasPreviousPage } - nodes { - state - submittedAt - authorCanPushToRepository - author { login } - } - } - commits(last: 50) { - pageInfo { hasPreviousPage } - nodes { - commit { - committedDate - changedFilesIfAvailable - parents(first: 2) { totalCount } - author { email user { login } } - } - } - } - comments(last: 50) { - pageInfo { hasPreviousPage } - nodes { - createdAt - author { login } - } - } - userContentEdits(last: 50) { - pageInfo { hasPreviousPage } - nodes { - editedAt - editor { login } - } - } - } - } - } -}`; - -const OPEN_PULL_REQUESTS_BY_AUTHOR_QUERY = ` -query($searchQuery: String!, $cursor: String, $pageSize: Int!) { - search(query: $searchQuery, type: ISSUE, first: $pageSize, after: $cursor) { - pageInfo { hasNextPage endCursor } - nodes { - ... on PullRequest { - number - title - isDraft - createdAt - author { login } - assignees(first: 10) { nodes { login } } - headRepository { nameWithOwner } - labels(first: 50) { nodes { name } } - reviews(last: 50) { - pageInfo { hasPreviousPage } - nodes { - state - submittedAt - authorCanPushToRepository - author { login } - } - } - commits(last: 50) { - pageInfo { hasPreviousPage } - nodes { - commit { - committedDate - changedFilesIfAvailable - parents(first: 2) { totalCount } - author { email user { login } } - } - } - } - comments(last: 50) { - pageInfo { hasPreviousPage } - nodes { - createdAt - author { login } - } - } - userContentEdits(last: 50) { - pageInfo { hasPreviousPage } - nodes { - editedAt - editor { login } - } - } - } - } - } -}`; - -// 판정 함수가 각 connection 에서 읽는 시각 필드. reviews 는 판정 기준 시각 자체를 구하는 -// 근거라 여기 넣지 않는다. -const ACTIVITY_TIMESTAMP_BY_CONNECTION = Object.freeze({ - commits: (node) => node?.commit?.committedDate, - comments: (node) => node?.createdAt, - userContentEdits: (node) => node?.editedAt, -}); - -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 전체에서 «최신 결정 리뷰» 를 고른다. - * - * 쓰기 권한이 있는 팀원의 판정만 인정하고 리뷰어별로 묶지 않는다. 외부인의 변경요청으로 팀원의 - * 새 PR 을 막거나, 외부인의 승인으로 팀 변경요청을 지울 수 없다. 팀에서 한 사람이 더 늦은 판정을 - * 내렸다면 그것이 PR 의 현재 상태이고, 더 오래된 다른 팀원의 변경요청을 다시 살리지 않는다. - */ -export function latestDecision(reviews) { - const decisions = (reviews ?? []) - .filter((review) => review?.state === "APPROVED" || review?.state === "CHANGES_REQUESTED") - .filter((review) => review?.authorCanPushToRepository === true) - .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; -} - -/** - * `last: 50` 이 판정에 필요한 범위를 덮는지 본다. - * - * 판정은 최신 결정 시각 이후 항목만 세므로, 가져온 것 중 가장 오래된 항목이 그 시각보다 앞서면 - * 그 이후는 전부 들어와 있다. `reviews` 는 그 시각 자체를 구하는 근거라 여기서 제외한다. - */ -export function isCoveredSinceDecision(connectionName, nodes, since) { - const readTimestamp = ACTIVITY_TIMESTAMP_BY_CONNECTION[connectionName]; - if (!readTimestamp) { - return false; - } - // 최신 결정이 없거나 변경요청이 아니면 judgeAwaitingAuthor 가 이 connection 을 읽지 않는다. - if (since === null || since === undefined) { - return true; - } - if (!Array.isArray(nodes) || nodes.length === 0) { - return false; - } - const oldest = readTimestamp(nodes[0]); - return typeof oldest === "string" && oldest <= since; -} - -/** - * 판정 시각 이후 작성자가 올린 «실질» 커밋 수를 센다. - * - * 병합 커밋은 빼고(base 를 끌어온 변경이 반영분과 구별되지 않는다), 바뀐 파일이 0건인 커밋도 - * 뺀다. 누가 올렸는지를 보지 않으면 리뷰어가 미는 CI 재트리거 빈 커밋이 «작성자가 반영했다» 가 - * 되어 가짜 판정이 된다 — 8/28 에 그 한 건으로 PR 3건이 빚으로 잡혔다 (#1459). - * - * 계정이 연결되지 않은 커밋은 login 이 비어 가릴 수 없다. 같은 PR 에서 작성자 것으로 확인된 - * 커밋의 이메일을 폴백 신원으로 쓴다. - * - * 변경 파일 수를 알 수 없으면(`changedFilesIfAvailable` 가 null) 반영으로 센다. 조회 공백을 - * «빈 커밋» 으로 접으면 라벨이 없는 근거로 둔갑한다 — 놓치는 쪽이 잘못 붙이는 쪽보다 안전하다. - */ -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 (isOneOf(commit.author?.user?.login, owners) && 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 ? isOneOf(login, owners) : 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` 에서 잡힌다. PR 본문 편집은 별도 이력이므로 아래 - * `countAuthorBodyEdits` 에서 센다. - */ -export function countAuthorResponses({ comments, reviews, author, since }) { - const owners = toOwners(author); - const issueComments = (comments ?? []).filter( - (comment) => - isOneOf(comment?.author?.login, owners) && - typeof comment?.createdAt === "string" && - comment.createdAt > since, - ).length; - - const authorReviews = (reviews ?? []).filter( - (review) => - isOneOf(review?.author?.login, owners) && - typeof review?.submittedAt === "string" && - review.submittedAt > since, - ).length; - - return issueComments + authorReviews; -} - -/** - * 판정 시각 이후 PR 작성자가 직접 고친 본문 편집 수를 센다. - * - * PR 본문의 CI Test Plan 같이 커밋 밖에 있는 리뷰 지적은 본문 편집만으로 반영될 수 있다. - * `updatedAt` 은 라벨·리뷰어 등 다른 메타데이터 변경도 섞이므로 쓰지 않고, GitHub 가 남기는 - * `userContentEdits` 의 실제 편집자와 시각만 본다. 리뷰어의 대리 편집이나 편집자 정보가 사라진 - * 기록은 작성자 조치로 추정하지 않는다. - */ -export function countAuthorBodyEdits({ userContentEdits, author, since }) { - const owners = toOwners(author); - return (userContentEdits ?? []).filter( - (edit) => - isOneOf(edit?.editor?.login, owners) && - typeof edit?.editedAt === "string" && - edit.editedAt > since, - ).length; -} - -/** - * PR 한 건이 «변경요청 뒤 작성자 무조치» 인지 판정한다. - * - * 제외 축은 가드와 같다. draft 는 리뷰를 받을 상태가 아니고, 봇 PR 은 사람이 반영할 주체가 - * 없으며, fork PR 은 팀 리뷰 적체와 무관하다. - */ -export function judgeAwaitingAuthor(pullRequest, { repository } = {}) { - const author = pullRequest?.author?.login ?? ""; - // 조치 주체는 담당자다. 어사인이 비어 있으면 작성자로 폴백한다. - const owners = responsibleLogins(pullRequest); - - if (pullRequest?.isDraft) { - return { awaiting: false, reason: "draft" }; - } - if (!author || owners.length === 0) { - 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: owners, - since, - }); - const responses = countAuthorResponses({ - comments: pullRequest?.comments?.nodes, - reviews: pullRequest?.reviews?.nodes, - author: owners, - since, - }); - const bodyEdits = countAuthorBodyEdits({ - userContentEdits: pullRequest?.userContentEdits?.nodes, - author: owners, - since, - }); - - if (fixes > 0 || responses > 0 || bodyEdits > 0) { - return { - awaiting: false, - reason: `담당자 조치 있음(${fixes}커밋 · 응답 ${responses}건 · 본문 편집 ${bodyEdits}건)`, - decidedAt: since, - owners, - }; - } - - return { - awaiting: true, - reason: "변경요청 뒤 담당자 조치 없음", - decidedAt: since, - reviewer: decision.author?.login ?? "", - owners, - }; -} - -/** - * 새 PR 작성자가 먼저 처리해야 할 다른 열린 PR 을 고른다. - * - * 열린 PR 목록은 호출자가 한 번 live 조회해 넘긴다. 현재 PR 은 이벤트 직후 그 목록에 이미 - * 포함되므로 번호로 명시적으로 제외한다. «자기 PR» 은 작성자가 아니라 담당자(어사인, 비어 - * 있으면 작성자)로 가른다. login 은 GitHub 의 대소문자 비구분 규약에 맞춰 비교하고, 실제 - * 무조치 여부는 라벨이 아니라 `judgeAwaitingAuthor` 로 매번 다시 판정한다. - */ -export function findAuthorDebts({ pullRequests, repository, author, currentPullRequestNumber }) { - if (!Array.isArray(pullRequests)) { - throw new TypeError("pullRequests 배열이 필요합니다."); - } - if (typeof author !== "string" || author.trim() === "") { - throw new TypeError("author 가 필요합니다."); - } - - const currentNumber = Number(currentPullRequestNumber); - if (!Number.isSafeInteger(currentNumber) || currentNumber <= 0) { - throw new TypeError("currentPullRequestNumber 는 양의 정수여야 합니다."); - } - - const debts = []; - for (const pullRequest of pullRequests) { - if (Number(pullRequest?.number) === currentNumber) { - continue; - } - // 빚의 주인은 담당자다. 남이 올렸어도 내가 어사인된 PR 이면 내 빚이고, 내가 올렸어도 - // 남에게 어사인된 PR 이면 내 빚이 아니다. - if (!isOneOf(author, responsibleLogins(pullRequest))) { - continue; - } - - const verdict = judgeAwaitingAuthor(pullRequest, { repository }); - if (!verdict.awaiting) { - continue; - } - - debts.push({ - number: pullRequest.number, - createdDate: - typeof pullRequest.createdAt === "string" ? pullRequest.createdAt.slice(0, 10) : "", - reviewer: verdict.reviewer ?? "", - title: pullRequest.title ?? "", - }); - } - - return debts; -} - -/** - * 라벨을 붙일 PR 과 뗄 PR 을 한 번에 계산한다. - * - * 매 실행이 현재 집합을 다시 세우고 차이만 쓴다. 조치가 들어오면 다음 실행에서 라벨이 떨어지므로 - * 스테일 라벨이 남지 않는다. - */ -export function planAwaitingAuthorLabels({ pullRequests, repository, label = DEFAULT_LABEL, exemptAuthors = [] }) { - 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); - // 면제는 담당자 기준이다. 담당자 전원이 면제 목록에 있을 때만 뺀다 — 면제자와 비면제자가 - // 함께 어사인된 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: owners.join(","), - 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"); -} - -export function createApi(token, { fetchImpl = globalThis.fetch } = {}) { - if (typeof fetchImpl !== "function") { - throw new TypeError("fetch 구현이 필요합니다."); - } - - return async function api(apiPath, { method = "GET", body, allowNotFound = false } = {}) { - const response = await fetchImpl(`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("/"); - if (!owner || !name || repository.split("/").length !== 2) { - throw new TypeError("repository 는 owner/name 형식이어야 합니다."); - } - - 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) { - throw new Error("GraphQL 응답에 열린 PR 목록이 없습니다."); - } - if (!Array.isArray(page.nodes)) { - throw new Error("GraphQL 열린 PR nodes 가 배열이 아닙니다."); - } - pullRequests.push(...(page.nodes ?? []).filter(Boolean)); - if (!page.pageInfo?.hasNextPage) { - break; - } - cursor = page.pageInfo.endCursor; - if (!cursor) { - throw new Error("GraphQL 다음 페이지 cursor 가 없습니다."); - } - } - - return pullRequests; -} - -/** - * 입장 게이트용으로 한 사람이 담당하는 열린 PR 만 조회한다. - * - * 라벨 리컨사일러는 전체 열린 PR 이 필요하지만, 새 PR 입장마다 그 전체의 하위 리뷰·커밋· - * 코멘트를 다시 읽을 필요는 없다. GitHub search 로 서버에서 먼저 제한한다. 담당은 어사인이 - * 정본이고 어사인이 비면 작성자이므로 `assignee:` 와 `author:` 두 검색을 합친다. 남에게 - * 어사인된 자기 작성 PR 은 `findAuthorDebts` 가 담당자 기준으로 걸러낸다. - */ -export async function fetchOpenPullRequestsByAuthor(api, repository, author) { - const [owner, name] = repository.split("/"); - if (!owner || !name || repository.split("/").length !== 2) { - throw new TypeError("repository 는 owner/name 형식이어야 합니다."); - } - if (typeof author !== "string" || author.trim() === "") { - throw new TypeError("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 ${qualifier}:${author}`, - cursor, - pageSize: PULL_REQUEST_PAGE_SIZE, - }); - const page = data?.search; - if (!page) { - throw new Error("GraphQL 응답에 작성자의 열린 PR 검색 결과가 없습니다."); - } - if (!Array.isArray(page.nodes)) { - throw new Error("GraphQL 작성자 PR nodes 가 배열이 아닙니다."); - } - - for (const pullRequest of page.nodes) { - if (!pullRequest || !Number.isSafeInteger(pullRequest.number)) { - throw new Error("GraphQL 작성자 PR 검색 결과가 불완전합니다."); - } - if (seen.has(pullRequest.number)) { - continue; - } - seen.add(pullRequest.number); - - // `last: 50` 앞에 더 많은 항목이 있으면 최신 결정 시각 이후의 작성자 이메일·응답· - // 본문 편집을 완전하게 판정할 수 없다. 라벨은 다음 리컨사일에서 복구할 수 있지만, - // 입장 가드가 불완전한 근거로 PR 을 닫는 것은 되돌리기 비용이 있으므로 판정 자체를 - // 중단한다. - // - // 다만 완전성의 기준은 «전체 이력» 이 아니라 «판정이 실제로 읽는 범위» 다. 세 판정 - // 함수는 모두 최신 결정 시각(since) 이후 항목만 세고, judgeAwaitingAuthor 는 최신 - // 결정이 없거나 변경요청이 아니면 그 셋을 아예 부르지 않는다. `last: 50` 은 최신 - // 50건이므로 가져온 것 중 가장 오래된 항목이 since 보다 앞서면 since 이후는 전부 - // 확보한 것이고 판정은 완전하다 — 커밋이 몇백 건이든 상관없다. 이 구분이 없어서 - // 커밋 403건짜리 릴리스 PR(develop → main) 하나가 그 작성자의 새 PR 을 전부 막았다 - // (#1787). reviews 는 since 를 구하는 근거라 시각으로 대체 판정할 수 없어 그대로 둔다. - const decision = latestDecision(pullRequest.reviews?.nodes); - const since = decision?.state === "CHANGES_REQUESTED" ? decision.submittedAt : null; - for (const connectionName of ["reviews", "commits", "comments", "userContentEdits"]) { - const connection = pullRequest[connectionName]; - if (!connection || !Array.isArray(connection.nodes)) { - throw new Error(`GraphQL 작성자 PR ${connectionName} 응답이 불완전합니다.`); - } - if (connection.pageInfo?.hasPreviousPage === false) { - continue; - } - if (isCoveredSinceDecision(connectionName, connection.nodes, since)) { - continue; - } - throw new Error( - `GraphQL 작성자 PR ${connectionName} 최근 50건이 완전하지 않습니다.`, - ); - } - pullRequests.push(pullRequest); - } - - if (!page.pageInfo?.hasNextPage) { - break; - } - cursor = page.pageInfo.endCursor; - if (!cursor) { - throw new Error("GraphQL 작성자 PR 다음 페이지 cursor 가 없습니다."); - } - } - } - - 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; -} - -/** 가드의 한 줄 환경변수 선언을 읽는다. 형식이 달라지면 빈 면제 목록으로 숨기지 않는다. */ -export function parseReviewGateExemptAuthors(workflow) { - const lines = workflow.split(/\r?\n/); - const declarations = lines.flatMap((line, index) => { - const match = /^([ \t]+)REVIEW_GATE_EXEMPT_AUTHORS:[ \t]*(.*)$/.exec(line); - return match ? [{ index, indent: match[1].length, value: match[2].trim() }] : []; - }); - if (declarations.length !== 1) { - throw new Error("REVIEW_GATE_EXEMPT_AUTHORS 선언이 정확히 한 개 필요합니다."); - } - const { index, indent, value } = declarations[0]; - const nextLine = lines.slice(index + 1).find((line) => line.trim() && !line.trimStart().startsWith("#")); - const continued = nextLine && /^[ \t]*/.exec(nextLine)[0].length > indent; - if (continued || !/^[a-zA-Z0-9-]+(?:[ \t]+[a-zA-Z0-9-]+)*$/.test(value)) { - throw new Error("REVIEW_GATE_EXEMPT_AUTHORS는 인용·주석 없는 한 줄의 공백 구분 로그인 목록이어야 합니다."); - } - return value.split(/\s+/).map((login) => login.toLowerCase()); -} - -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); - - // 전체 checkout을 쓰는 라벨 CLI에서만 읽는다. 가드의 scripts-only checkout에서도 - // 상태 판정 함수를 import할 수 있어야 한다. 면제 목록의 정본은 기존 가드 선언이다. - const exemptAuthors = parseReviewGateExemptAuthors( - await readFile(new URL("../workflows/review-debt-guard.yml", import.meta.url), "utf8"), - ); - const pullRequests = await fetchOpenPullRequests(api, repository); - const plan = planAwaitingAuthorLabels({ pullRequests, repository, label, exemptAuthors }); - - 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 deleted file mode 100644 index 10b4e9b43..000000000 --- a/.github/scripts/label-awaiting-author-prs.test.mjs +++ /dev/null @@ -1,939 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - applyPlan, - countAuthorBodyEdits, - countAuthorFixes, - countAuthorResponses, - DEFAULT_LABEL, - ensureLabelExists, - fetchOpenPullRequests, - fetchOpenPullRequestsByAuthor, - findAuthorDebts, - judgeAwaitingAuthor, - latestDecision, - parseReviewGateExemptAuthors, - planAwaitingAuthorLabels, - renderSummary, - responsibleLogins, -} from "./label-awaiting-author-prs.mjs"; - -const REPO = "Afternote/Afternote-FE"; - -/** 테스트 출력이 CI 로그에서 실제 조작처럼 보이지 않도록 삼킨다. */ -const silent = { log() {} }; - -test("게이트 면제 목록은 공백을 나누고 로그인 대소문자를 정규화한다", () => { - const workflow = [ - "jobs:", - " check:", - " env:", - " REPO: Afternote/Afternote-FE", - " REVIEW_GATE_EXEMPT_AUTHORS: KoOnGmAi Example-User ", - " run: echo check", - ].join("\r\n"); - - assert.deepEqual(parseReviewGateExemptAuthors(workflow), ["koongmai", "example-user"]); -}); - -test("게이트 면제 목록이 없거나 두 번 선언되면 빈 목록으로 넘기지 않는다", () => { - for (const workflow of [ - "env:\n REPO: Afternote/Afternote-FE\n", - "# REVIEW_GATE_EXEMPT_AUTHORS: koongmai\n", - "env:\n REVIEW_GATE_EXEMPT_AUTHORS: koongmai\n REVIEW_GATE_EXEMPT_AUTHORS: other\n", - ]) { - assert.throws(() => parseReviewGateExemptAuthors(workflow), undefined, workflow); - } -}); - -test("게이트 면제 목록은 따옴표·주석·배열·여러 줄과 잘못된 로그인 형식을 거부한다", () => { - for (const value of [ - "", - '"koongmai"', - "'koongmai'", - "koongmai # comment", - "[koongmai]", - "|\n koongmai", - ">\n koongmai", - "\n koongmai", - "koongmai\n other", - "koongmai,other", - "invalid_login", - ]) { - const workflow = `env:\n REVIEW_GATE_EXEMPT_AUTHORS: ${value}\n`; - assert.throws(() => parseReviewGateExemptAuthors(workflow), undefined, value); - } -}); - -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: { pageInfo: { hasPreviousPage: false }, nodes: [] }, - commits: { pageInfo: { hasPreviousPage: false }, nodes: [] }, - comments: { pageInfo: { hasPreviousPage: false }, nodes: [] }, - userContentEdits: { pageInfo: { hasPreviousPage: false }, nodes: [] }, - ...overrides, - }; -} - -function changesRequested( - at = "2026-08-29T00:00:00Z", - reviewer = "reviewer", - authorCanPushToRepository = true, -) { - return { - state: "CHANGES_REQUESTED", - submittedAt: at, - authorCanPushToRepository, - author: { login: reviewer }, - }; -} - -test("최신 결정 리뷰는 리뷰어별로 묶지 않고 PR 전체에서 하나만 고른다", () => { - // 팀에서 한 사람이 더 늦은 판정을 내리면 그것이 PR 의 현재 상태다. 더 오래된 다른 - // 리뷰어의 변경요청을 다시 살리면 승인된 PR 에까지 라벨이 붙는다. - const decision = latestDecision([ - { - state: "CHANGES_REQUESTED", - submittedAt: "2026-08-01T00:00:00Z", - authorCanPushToRepository: true, - author: { login: "a" }, - }, - { - state: "APPROVED", - submittedAt: "2026-08-05T00:00:00Z", - authorCanPushToRepository: true, - 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("리뷰 게이트 면제 작성자는 대소문자와 무관하게 무조치 라벨 대상에서 빠진다", () => { - for (const login of ["koongmai", "KoOnGmAi"]) { - const plan = planAwaitingAuthorLabels({ - repository: REPO, - exemptAuthors: ["koongmai"], - pullRequests: [pullRequest({ - author: { login }, - reviews: { nodes: [changesRequested()] }, - })], - }); - - assert.deepEqual(plan.toLabel, [], login); - assert.equal(plan.unchanged[0].reason, "리뷰 게이트 면제 작성자"); - } -}); - -test("면제 계정과 일부만 같은 작성자는 여전히 무조치 라벨 대상이다", () => { - const plan = planAwaitingAuthorLabels({ - repository: REPO, - exemptAuthors: ["koongmai"], - pullRequests: [pullRequest({ - author: { login: "koongmai-other" }, - reviews: { nodes: [changesRequested()] }, - })], - }); - - assert.deepEqual(plan.toLabel.map((entry) => entry.number), [1]); -}); - -test("변경요청 리뷰어가 면제 계정이어도 다른 작성자의 무조치 라벨은 유지한다", () => { - const plan = planAwaitingAuthorLabels({ - repository: REPO, - exemptAuthors: ["koongmai"], - pullRequests: [pullRequest({ - reviews: { nodes: [changesRequested("2026-08-29T00:00:00Z", "koongmai")] }, - })], - }); - - assert.deepEqual(plan.toLabel.map((entry) => entry.number), [1]); - assert.equal(plan.toLabel[0].reviewer, "koongmai"); -}); - -test("쓰기 권한이 없는 외부인의 변경요청은 작성자 빚을 만들지 못한다", () => { - const verdict = judgeAwaitingAuthor( - pullRequest({ - reviews: { - nodes: [changesRequested("2026-08-29T00:00:00Z", "outsider", false)], - }, - }), - { repository: REPO }, - ); - - assert.equal(verdict.awaiting, false); - assert.equal(verdict.reason, "결정 리뷰 없음"); -}); - -test("쓰기 권한이 없는 외부인의 승인은 팀 변경요청을 지우지 못한다", () => { - const verdict = judgeAwaitingAuthor( - pullRequest({ - reviews: { - nodes: [ - changesRequested("2026-08-28T00:00:00Z", "team-reviewer"), - { - state: "APPROVED", - submittedAt: "2026-08-29T00:00:00Z", - authorCanPushToRepository: false, - author: { login: "outsider" }, - }, - ], - }, - }), - { repository: REPO }, - ); - - assert.equal(verdict.awaiting, true); - assert.equal(verdict.reviewer, "team-reviewer"); - assert.equal(verdict.decidedAt, "2026-08-28T00:00:00Z"); -}); - -test("작성자 빚은 같은 작성자의 다른 열린 PR 중 현재 무조치인 것만 고른다", () => { - const debts = findAuthorDebts({ - repository: REPO, - author: "AuThOr", - currentPullRequestNumber: "20", - pullRequests: [ - // 현재 PR 자체는 아직 변경요청 상태여도 명시적으로 제외한다. - pullRequest({ number: 20, reviews: { nodes: [changesRequested()] } }), - // login 대소문자가 달라도 같은 작성자다. - pullRequest({ - number: 21, - title: "fix(core): 기존 지적 반영", - author: { login: "AUTHOR" }, - createdAt: "2026-08-17T10:00:00Z", - reviews: { nodes: [changesRequested("2026-08-29T00:00:00Z", "reviewer-1")] }, - }), - // 작성자가 이미 조치한 PR 은 입장 빚이 아니다. - pullRequest({ - number: 22, - reviews: { nodes: [changesRequested()] }, - commits: { nodes: [commit({ date: "2026-08-29T12:00:00Z" })] }, - }), - // 다른 작성자의 무조치 PR 은 이 작성자를 막지 않는다. - pullRequest({ - number: 23, - 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()] }, - }), - ], - }); - - assert.deepEqual(debts, [ - { - number: 21, - createdDate: "2026-08-17", - 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" }), - /양의 정수/, - ); -}); - -test("입장 게이트 조회는 GraphQL search 로 어사인·작성자 두 축의 열린 PR 을 페이지 처리하고 합친다", async () => { - const calls = []; - const api = async (apiPath, options) => { - assert.equal(apiPath, "/graphql"); - assert.match(options.body.query, /search\(query: \$searchQuery/); - 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: { - pageInfo: - cursor === null - ? { hasNextPage: true, endCursor: "next" } - : { hasNextPage: false, endCursor: null }, - nodes: [pullRequest({ number: cursor === null ? 30 : 31 })], - }, - }, - }; - }; - - const pullRequests = await fetchOpenPullRequestsByAuthor(api, REPO, "AuThOr"); - - 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 () => { - await assert.rejects( - fetchOpenPullRequestsByAuthor( - async () => ({ data: { search: { pageInfo: { hasNextPage: false }, nodes: [{}] } } }), - REPO, - "author", - ), - /검색 결과가 불완전합니다/, - ); -}); - -test("두 GraphQL 조회는 결정 리뷰 권한과 하위 connection 절단 여부를 함께 요청한다", async () => { - const queries = []; - const api = async (_apiPath, options) => { - queries.push(options.body.query); - if (options.body.variables.searchQuery) { - return { - data: { - search: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: [], - }, - }, - }; - } - return { - data: { - repository: { - pullRequests: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: [], - }, - }, - }, - }; - }; - - await fetchOpenPullRequests(api, REPO); - await fetchOpenPullRequestsByAuthor(api, REPO, "author"); - - // 전체 조회 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, - new RegExp(`${connection}\\(last: 50\\) \\{\\s+pageInfo \\{ hasPreviousPage \\}`), - ); - } - } -}); - -test("입장 게이트 조회는 잘린·누락된 하위 connection으로 판정하지 않는다", async () => { - const cases = [ - [ - "reviews", - { pageInfo: { hasPreviousPage: true }, nodes: [changesRequested()] }, - /reviews 최근 50건이 완전하지 않습니다/, - ], - ["commits", { pageInfo: {}, nodes: [] }, /commits 최근 50건이 완전하지 않습니다/], - ["comments", { pageInfo: { hasPreviousPage: false } }, /comments 응답이 불완전합니다/], - [ - "userContentEdits", - { pageInfo: { hasPreviousPage: true }, nodes: [] }, - /userContentEdits 최근 50건이 완전하지 않습니다/, - ], - ]; - - for (const [connection, value, expected] of cases) { - // 최신 판정이 변경요청이어야 판정이 이 connection 들을 실제로 읽는다. 그 상태에서만 - // 「잘린 데이터로 판정하지 않는다」 가 의미를 갖는다. - const candidate = pullRequest({ - number: 40, - reviews: { pageInfo: { hasPreviousPage: false }, nodes: [changesRequested()] }, - }); - candidate[connection] = value; - - await assert.rejects( - fetchOpenPullRequestsByAuthor( - async () => ({ - data: { - search: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: [candidate], - }, - }, - }), - REPO, - "author", - ), - expected, - ); - } -}); - -test("판정이 읽지 않는 connection 은 잘려 있어도 입장을 막지 않는다", async () => { - // 릴리스 PR(develop → main)은 커밋이 수백 건이라 commits(last: 50) 이 항상 잘린다. - // 그런데 리뷰 판정이 없으면 judgeAwaitingAuthor 는 커밋을 읽지도 않는다. 이 구분이 없어 - // 커밋 403건짜리 PR 하나가 그 작성자의 새 PR 을 전부 막았다 (#1787). - const releasePullRequest = pullRequest({ - number: 1778, - reviews: { pageInfo: { hasPreviousPage: false }, nodes: [] }, - commits: { pageInfo: { hasPreviousPage: true }, nodes: [] }, - }); - - const fetched = await fetchOpenPullRequestsByAuthor( - async () => ({ - data: { - search: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: [releasePullRequest], - }, - }, - }), - REPO, - "author", - ); - - assert.equal(fetched.length, 1); - assert.equal(fetched[0].number, 1778); -}); - -test("판정 시각 이후가 최근 50건 안에 다 들어오면 잘려 있어도 완전하다", async () => { - // last: 50 은 최신 50건이다. 가져온 것 중 가장 오래된 커밋이 판정 시각보다 앞서면 - // 그 이후 커밋은 전부 확보한 것이므로 판정에 공백이 없다. - const candidate = pullRequest({ - number: 41, - reviews: { - pageInfo: { hasPreviousPage: false }, - nodes: [changesRequested("2026-08-29T00:00:00Z")], - }, - commits: { - pageInfo: { hasPreviousPage: true }, - nodes: [ - { commit: { committedDate: "2026-08-28T00:00:00Z" } }, - { commit: { committedDate: "2026-08-30T00:00:00Z" } }, - ], - }, - }); - - const fetched = await fetchOpenPullRequestsByAuthor( - async () => ({ - data: { - search: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: [candidate], - }, - }, - }), - REPO, - "author", - ); - - assert.equal(fetched.length, 1); -}); - -test("판정 시각 이후가 최근 50건을 넘치면 여전히 중단한다", async () => { - // 가장 오래된 커밋조차 판정 시각 뒤라면 그 사이에 못 본 커밋이 있을 수 있다. - // 이때는 불완전한 근거로 PR 을 닫지 않도록 판정을 멈춘다. - const candidate = pullRequest({ - number: 42, - reviews: { - pageInfo: { hasPreviousPage: false }, - nodes: [changesRequested("2026-08-29T00:00:00Z")], - }, - commits: { - pageInfo: { hasPreviousPage: true }, - nodes: [ - { commit: { committedDate: "2026-08-30T00:00:00Z" } }, - { commit: { committedDate: "2026-08-31T00:00:00Z" } }, - ], - }, - }); - - await assert.rejects( - fetchOpenPullRequestsByAuthor( - async () => ({ - data: { - search: { - pageInfo: { hasNextPage: false, endCursor: null }, - nodes: [candidate], - }, - }, - }), - REPO, - "author", - ), - /commits 최근 50건이 완전하지 않습니다/, - ); -}); - -test("최신 판정이 승인이면 대상이 아니다", () => { - // 승인 뒤 작성자가 손보는 것은 작성자 몫이지만, 그것은 «머지 가능한 상태» 이지 - // «지적을 방치한 상태» 가 아니다. - const verdict = judgeAwaitingAuthor( - pullRequest({ - reviews: { - nodes: [ - changesRequested("2026-08-01T00:00:00Z"), - { - state: "APPROVED", - submittedAt: "2026-08-02T00:00:00Z", - authorCanPushToRepository: true, - author: { login: "reviewer" }, - }, - ], - }, - userContentEdits: { - nodes: [ - { - editedAt: "2026-08-03T00:00:00Z", - editor: { login: "author" }, - }, - ], - }, - }), - { repository: REPO }, - ); - assert.equal(verdict.awaiting, false); - assert.equal(verdict.reason, "최신 판정이 변경요청이 아님"); -}); - -test("변경요청 뒤 작성자가 PR 본문을 고치면 조치로 센다", () => { - const verdict = judgeAwaitingAuthor( - pullRequest({ - reviews: { nodes: [changesRequested("2026-08-29T00:00:00Z")] }, - userContentEdits: { - nodes: [ - { - editedAt: "2026-08-29T00:00:01Z", - editor: { login: "AUTHOR" }, - }, - ], - }, - }), - { repository: REPO }, - ); - - assert.equal(verdict.awaiting, false); - assert.match(verdict.reason, /본문 편집 1건/); -}); - -test("판정 전·다른 사람·편집자 없는 본문 편집은 작성자 조치가 아니다", () => { - const edits = [ - { editedAt: "2026-08-28T23:59:59Z", editor: { login: "author" } }, - { editedAt: "2026-08-29T00:00:00Z", editor: { login: "author" } }, - { editedAt: "2026-08-29T00:00:01Z", editor: { login: "reviewer" } }, - { editedAt: "2026-08-29T00:00:02Z", editor: null }, - ]; - - assert.equal( - countAuthorBodyEdits({ - userContentEdits: edits, - author: "author", - since: "2026-08-29T00:00:00Z", - }), - 0, - ); - - const verdict = judgeAwaitingAuthor( - pullRequest({ - reviews: { nodes: [changesRequested("2026-08-29T00:00:00Z")] }, - userContentEdits: { nodes: edits }, - }), - { repository: REPO }, - ); - assert.equal(verdict.awaiting, true); -}); - -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("면제 작성자의 기존 라벨을 제거하고 다음 리컨사일에서도 다시 붙이지 않는다", () => { - const pullRequests = [ - pullRequest({ - number: 40, - author: { login: "KoOnGmAi" }, - labels: { nodes: [{ name: DEFAULT_LABEL }] }, - reviews: { nodes: [changesRequested()] }, - }), - pullRequest({ - number: 41, - author: { login: "koongmai" }, - reviews: { nodes: [changesRequested()] }, - }), - pullRequest({ - number: 42, - labels: { nodes: [{ name: DEFAULT_LABEL }] }, - reviews: { nodes: [changesRequested()] }, - }), - pullRequest({ number: 43, reviews: { nodes: [changesRequested()] } }), - ]; - - const plan = planAwaitingAuthorLabels({ repository: REPO, pullRequests, exemptAuthors: ["koongmai"] }); - - assert.deepEqual(plan.toUnlabel.map((entry) => entry.number), [40]); - assert.equal(plan.toUnlabel[0].reason, "리뷰 게이트 면제 작성자"); - assert.deepEqual(plan.toLabel.map((entry) => entry.number), [43]); - assert.deepEqual(plan.unchanged.map((entry) => entry.number), [41, 42]); - - // 첫 계획을 적용한 다음에도 면제 작성자의 무조치 상태는 그대로 남아 있다. - pullRequests[0].labels.nodes = []; - pullRequests[3].labels.nodes = [{ name: DEFAULT_LABEL }]; - const nextPlan = planAwaitingAuthorLabels({ repository: REPO, pullRequests, exemptAuthors: ["koongmai"] }); - - assert.deepEqual(nextPlan.toLabel, []); - assert.deepEqual(nextPlan.toUnlabel, []); - assert.deepEqual( - nextPlan.unchanged.map(({ number, labeled }) => ({ number, labeled })), - [ - { number: 40, labeled: false }, - { number: 41, labeled: false }, - { number: 42, labeled: true }, - { number: 43, labeled: true }, - ], - ); -}); - -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/scripts/latest-review-decision-policy.test.mjs b/.github/scripts/latest-review-decision-policy.test.mjs deleted file mode 100644 index 646187f25..000000000 --- a/.github/scripts/latest-review-decision-policy.test.mjs +++ /dev/null @@ -1,49 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -const signal = await readFile( - new URL("../workflows/latest-review-decision-event.yml", import.meta.url), - "utf8", -); -const reconcile = await readFile( - new URL("../workflows/latest-review-decision-reconcile.yml", import.meta.url), - "utf8", -); -const readme = await readFile(new URL("../../README.md", import.meta.url), "utf8"); - -test("the review event workflow is an unprivileged signal only", () => { - assert.match(signal, /^\s{2}pull_request_review:\n\s{4}types: \[submitted\]/m); - assert.match(signal, /^permissions: \{\}$/m); - assert.doesNotMatch(signal, /actions\/checkout@/); - assert.doesNotMatch(signal, /GITHUB_TOKEN|GH_TOKEN|gh api|pull-requests:\s*write/); -}); - -test("the trusted workflow reconciles from the default branch with minimal write access", () => { - assert.match(reconcile, /^\s{2}workflow_run:/m); - assert.match(reconcile, /workflows: \[Latest Review Decision Event\]/); - assert.match(reconcile, /^\s{2}workflow_dispatch:/m); - assert.match(reconcile, /^permissions: \{\}$/m); - assert.match(reconcile, /^\s{6}contents: read$/m); - assert.match(reconcile, /^\s{6}pull-requests: write$/m); - assert.match(reconcile, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/); - assert.match(reconcile, /persist-credentials: false/); - assert.match(reconcile, /reconcile-latest-review-decision\.mjs/); - assert.doesNotMatch(reconcile, /github\.event\.pull_request\.head|refs\/pull/); -}); - -test("automatic reconciliation is serialized and historical PRs can be swept manually", () => { - assert.match(reconcile, /cancel-in-progress: false/); - assert.match(reconcile, /inputs\.pull_number/); - assert.match(reconcile, /github\.event\.workflow_run\.pull_requests\[0\]\.number/); - assert.match(reconcile, /github\.event\.workflow_run\.conclusion == 'success'/); - assert.match(reconcile, /github\.event\.workflow_run\.event == 'pull_request_review'/); -}); - -test("the README documents the latest write-access review as the final decision", () => { - assert.match( - readme, - /쓰기 권한이 있는 리뷰어별 최신 `APPROVED`·`CHANGES_REQUESTED` 가운데 PR 전체에서 가장 늦은 판정을 최종 판정으로 사용한다/, - ); - assert.doesNotMatch(readme, /\/review-response/); -}); diff --git a/.github/scripts/reconcile-latest-review-decision.mjs b/.github/scripts/reconcile-latest-review-decision.mjs deleted file mode 100644 index 32c076fde..000000000 --- a/.github/scripts/reconcile-latest-review-decision.mjs +++ /dev/null @@ -1,308 +0,0 @@ -import { pathToFileURL } from "node:url"; - -export const DECISIVE_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED"]); - -const WRITE_PERMISSIONS = new Set(["admin", "maintain", "write"]); -const NON_WRITE_PERMISSIONS = new Set(["triage", "read", "none"]); - -function requiredString(value, name) { - if (typeof value !== "string" || value.trim() === "") { - throw new Error(`${name} 값이 없습니다.`); - } - return value.trim(); -} - -function requiredPositiveInteger(value, name) { - const number = typeof value === "number" ? value : Number(value); - if (!Number.isSafeInteger(number) || number <= 0) { - throw new Error(`${name} 값이 양의 정수가 아닙니다: ${value}`); - } - return number; -} - -function normalizeReviewState(value) { - return requiredString(value, "review.state").toUpperCase(); -} - -function normalizeRepository(repository) { - const value = requiredString(repository, "repository"); - if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value)) { - throw new Error(`repository 형식이 owner/name 이 아닙니다: ${value}`); - } - return value; -} - -function normalizeDecisiveReview(review) { - const state = normalizeReviewState(review?.state); - if (!DECISIVE_REVIEW_STATES.has(state)) { - return null; - } - - const id = requiredPositiveInteger(review?.id, "review.id"); - const reviewer = requiredString( - review?.user?.login ?? review?.reviewer, - "review.user.login", - ).toLowerCase(); - const submittedAt = requiredString( - review?.submitted_at ?? review?.submittedAt, - "review.submitted_at", - ); - const submittedTimestamp = Date.parse(submittedAt); - if (!Number.isFinite(submittedTimestamp)) { - throw new Error(`review.submitted_at 값이 올바른 시각이 아닙니다: ${submittedAt}`); - } - - return { id, reviewer, state, submittedAt, submittedTimestamp }; -} - -function compareDecisiveReviews(left, right) { - return left.submittedTimestamp - right.submittedTimestamp || left.id - right.id; -} - -export function classifyRepositoryPermission(permission) { - const value = requiredString(permission, "repository permission").toLowerCase(); - if (WRITE_PERMISSIONS.has(value)) { - return "write"; - } - if (NON_WRITE_PERMISSIONS.has(value)) { - return "read"; - } - throw new Error(`알 수 없는 repository permission 입니다: ${permission}`); -} - -export function selectLatestDecisiveReviewsByReviewer(reviews) { - if (!Array.isArray(reviews)) { - throw new Error("리뷰 목록 응답이 배열이 아닙니다."); - } - - const latestByReviewer = new Map(); - for (const rawReview of reviews) { - const review = normalizeDecisiveReview(rawReview); - if (!review) { - continue; - } - const previous = latestByReviewer.get(review.reviewer); - if (!previous || compareDecisiveReviews(previous, review) <= 0) { - latestByReviewer.set(review.reviewer, review); - } - } - - return [...latestByReviewer.values()].sort(compareDecisiveReviews); -} - -export function buildDismissalPlan(reviews, permissionByReviewer) { - if (!(permissionByReviewer instanceof Map)) { - throw new Error("permissionByReviewer 는 Map 이어야 합니다."); - } - - const latestByReviewer = selectLatestDecisiveReviewsByReviewer(reviews); - const writeReviews = latestByReviewer.filter((review) => { - if (!permissionByReviewer.has(review.reviewer)) { - throw new Error(`@${review.reviewer}의 repository permission 이 없습니다.`); - } - return classifyRepositoryPermission(permissionByReviewer.get(review.reviewer)) === "write"; - }); - - if (writeReviews.length === 0) { - return { status: "no-write-review", latestReview: null, blockingReviews: [] }; - } - - const latestReview = writeReviews.at(-1); - if (latestReview.state !== "APPROVED") { - return { status: "latest-changes-requested", latestReview, blockingReviews: [] }; - } - - const blockingReviews = writeReviews.filter((review) => review.state === "CHANGES_REQUESTED"); - if (blockingReviews.length === 0) { - return { status: "already-approved", latestReview, blockingReviews: [] }; - } - - return { status: "dismiss", latestReview, blockingReviews }; -} - -export class GitHubClient { - constructor({ token, apiUrl = "https://api.github.com", fetchImpl = globalThis.fetch }) { - this.token = requiredString(token, "GITHUB_TOKEN"); - this.apiUrl = requiredString(apiUrl, "GITHUB_API_URL").replace(/\/+$/, ""); - if (typeof fetchImpl !== "function") { - throw new Error("fetch 구현이 없습니다."); - } - this.fetchImpl = fetchImpl; - } - - async request(path, { method = "GET", body } = {}) { - if (typeof path !== "string" || !path.startsWith("/")) { - throw new Error(`GitHub API path가 올바르지 않습니다: ${path}`); - } - - const response = await this.fetchImpl(`${this.apiUrl}${path}`, { - method, - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${this.token}`, - "Content-Type": "application/json", - "X-GitHub-Api-Version": "2022-11-28", - }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - const responseText = await response.text(); - let data = null; - if (responseText !== "") { - try { - data = JSON.parse(responseText); - } catch { - throw new Error(`GitHub API가 JSON이 아닌 응답을 반환했습니다: ${response.status}`); - } - } - if (!response.ok) { - const detail = typeof data?.message === "string" ? data.message : responseText.slice(0, 500); - throw new Error(`GitHub API ${method} ${path} 실패: HTTP ${response.status} ${detail}`); - } - return data; - } - - async paginate(path) { - const items = []; - for (let page = 1; ; page += 1) { - const separator = path.includes("?") ? "&" : "?"; - const response = await this.request(`${path}${separator}per_page=100&page=${page}`); - if (!Array.isArray(response)) { - throw new Error(`GitHub API pagination 응답이 배열이 아닙니다: ${path}`); - } - items.push(...response); - if (response.length < 100) { - return items; - } - } - } -} - -export async function planPullRequest(client, repository, pullNumber) { - const normalizedRepository = normalizeRepository(repository); - const normalizedPullNumber = requiredPositiveInteger(pullNumber, "pull number"); - const reviews = await client.paginate( - `/repos/${normalizedRepository}/pulls/${normalizedPullNumber}/reviews`, - ); - const latestByReviewer = selectLatestDecisiveReviewsByReviewer(reviews); - const permissionByReviewer = new Map(); - - for (const review of latestByReviewer) { - const response = await client.request( - `/repos/${normalizedRepository}/collaborators/${encodeURIComponent(review.reviewer)}/permission`, - ); - permissionByReviewer.set(review.reviewer, response?.permission); - } - - return { - pullNumber: normalizedPullNumber, - ...buildDismissalPlan(reviews, permissionByReviewer), - }; -} - -export async function applyDismissalPlan(client, repository, plan) { - const normalizedRepository = normalizeRepository(repository); - if (plan?.status !== "dismiss") { - return []; - } - - const pullNumber = requiredPositiveInteger(plan.pullNumber, "pull number"); - const approval = normalizeDecisiveReview(plan.latestReview); - if (!approval || approval.state !== "APPROVED") { - throw new Error(`#${pullNumber} dismissal plan의 최종 승인이 올바르지 않습니다.`); - } - - const dismissed = []; - for (const rawReview of plan.blockingReviews) { - const blockingReview = normalizeDecisiveReview(rawReview); - if (!blockingReview || blockingReview.state !== "CHANGES_REQUESTED") { - throw new Error(`#${pullNumber} dismissal plan에 변경요청이 아닌 리뷰가 있습니다.`); - } - if (compareDecisiveReviews(blockingReview, approval) >= 0) { - throw new Error(`#${pullNumber} 최종 승인보다 늦은 변경요청은 dismiss할 수 없습니다.`); - } - - const message = [ - `@${approval.reviewer}의 더 늦은 승인(review #${approval.id})을 최종 판정으로 사용합니다.`, - "저장소 정책은 PR 전체에서 가장 최근의 APPROVED/CHANGES_REQUESTED 판정을 우선합니다.", - ].join(" "); - const response = await client.request( - `/repos/${normalizedRepository}/pulls/${pullNumber}/reviews/${blockingReview.id}/dismissals`, - { method: "PUT", body: { event: "DISMISS", message } }, - ); - if (normalizeReviewState(response?.state) !== "DISMISSED") { - throw new Error(`#${pullNumber} review #${blockingReview.id} dismiss 응답이 DISMISSED가 아닙니다.`); - } - dismissed.push(blockingReview); - } - return dismissed; -} - -export async function reconcileLatestReviewDecisions({ client, repository, pullNumbers, logger = console }) { - if (!Array.isArray(pullNumbers)) { - throw new Error("pullNumbers 는 배열이어야 합니다."); - } - const normalizedPullNumbers = [...new Set( - pullNumbers.map((pullNumber) => requiredPositiveInteger(pullNumber, "pull number")), - )]; - - // 모든 PR의 조회와 권한 판정을 먼저 끝낸다. 한 PR이라도 근거 조회가 실패하면 - // 다른 PR의 blocking review까지 일부만 dismiss하지 않는다. - const plans = []; - for (const pullNumber of normalizedPullNumbers) { - plans.push(await planPullRequest(client, repository, pullNumber)); - } - - const results = []; - for (const plan of plans) { - const dismissed = await applyDismissalPlan(client, repository, plan); - if (dismissed.length === 0) { - logger.log(`#${plan.pullNumber}: ${plan.status}`); - } else { - logger.log( - `#${plan.pullNumber}: @${plan.latestReview.reviewer} 승인 뒤 변경요청 ${dismissed.length}건 dismiss`, - ); - } - results.push({ plan, dismissed }); - } - return results; -} - -export function parseTargetPullNumber(value) { - if (value === undefined || value === null || String(value).trim() === "") { - return null; - } - return requiredPositiveInteger(String(value).trim(), "TARGET_PULL_NUMBER"); -} - -async function main() { - const repository = normalizeRepository(process.env.GITHUB_REPOSITORY); - const client = new GitHubClient({ - token: process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN, - apiUrl: process.env.GITHUB_API_URL ?? "https://api.github.com", - }); - const targetPullNumber = parseTargetPullNumber(process.env.TARGET_PULL_NUMBER); - let pullNumbers; - - if (targetPullNumber === null) { - const openPullRequests = await client.paginate(`/repos/${repository}/pulls?state=open`); - pullNumbers = openPullRequests.map((pullRequest) => - requiredPositiveInteger(pullRequest?.number, "pull_request.number")); - } else { - pullNumbers = [targetPullNumber]; - } - - console.log(`Latest review decision reconcile: ${pullNumbers.length} open pull request(s)`); - await reconcileLatestReviewDecisions({ client, repository, pullNumbers }); -} - -const isDirectExecution = process.argv[1] - && import.meta.url === pathToFileURL(process.argv[1]).href; - -if (isDirectExecution) { - try { - await main(); - } catch (error) { - console.error(`::error::${error.message}`); - process.exitCode = 1; - } -} diff --git a/.github/scripts/reconcile-latest-review-decision.test.mjs b/.github/scripts/reconcile-latest-review-decision.test.mjs deleted file mode 100644 index acc6303bc..000000000 --- a/.github/scripts/reconcile-latest-review-decision.test.mjs +++ /dev/null @@ -1,275 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - applyDismissalPlan, - buildDismissalPlan, - classifyRepositoryPermission, - parseTargetPullNumber, - reconcileLatestReviewDecisions, - selectLatestDecisiveReviewsByReviewer, -} from "./reconcile-latest-review-decision.mjs"; - -function review({ - id, - reviewer, - state, - submittedAt, -}) { - return { - id, - state, - submitted_at: submittedAt, - user: { login: reviewer }, - }; -} - -const changesByAlice = review({ - id: 10, - reviewer: "Alice", - state: "CHANGES_REQUESTED", - submittedAt: "2026-08-26T01:00:00Z", -}); -const approvalByBob = review({ - id: 20, - reviewer: "Bob", - state: "APPROVED", - submittedAt: "2026-08-26T02:00:00Z", -}); - -test("latest decisive review is selected separately for each reviewer", () => { - const reviews = [ - changesByAlice, - review({ - id: 11, - reviewer: "alice", - state: "COMMENTED", - submittedAt: "2026-08-26T02:30:00Z", - }), - review({ - id: 12, - reviewer: "ALICE", - state: "APPROVED", - submittedAt: "2026-08-26T03:00:00Z", - }), - approvalByBob, - ]; - - assert.deepEqual( - selectLatestDecisiveReviewsByReviewer(reviews).map(({ id, reviewer, state }) => ({ - id, - reviewer, - state, - })), - [ - { id: 20, reviewer: "bob", state: "APPROVED" }, - { id: 12, reviewer: "alice", state: "APPROVED" }, - ], - ); -}); - -test("a later write-access approval dismisses another reviewer's blocking request", () => { - const plan = buildDismissalPlan( - [changesByAlice, approvalByBob], - new Map([["alice", "write"], ["bob", "maintain"]]), - ); - - assert.equal(plan.status, "dismiss"); - assert.equal(plan.latestReview.id, 20); - assert.deepEqual(plan.blockingReviews.map(({ id }) => id), [10]); -}); - -test("one later approval dismisses every older write-access blocking request", async () => { - const changesByCarol = review({ - id: 15, - reviewer: "Carol", - state: "CHANGES_REQUESTED", - submittedAt: "2026-08-26T01:30:00Z", - }); - const plan = { - pullNumber: 914, - ...buildDismissalPlan( - [changesByAlice, changesByCarol, approvalByBob], - new Map([["alice", "write"], ["bob", "maintain"], ["carol", "admin"]]), - ), - }; - const calls = []; - const client = { - async request(path) { - calls.push(path); - return { state: "DISMISSED" }; - }, - }; - - const dismissed = await applyDismissalPlan(client, "Afternote/Afternote-FE", plan); - - assert.deepEqual(dismissed.map(({ id }) => id), [10, 15]); - assert.deepEqual(calls, [ - "/repos/Afternote/Afternote-FE/pulls/914/reviews/10/dismissals", - "/repos/Afternote/Afternote-FE/pulls/914/reviews/15/dismissals", - ]); -}); - -test("a later change request still blocks after an earlier approval", () => { - const plan = buildDismissalPlan( - [approvalByBob, review({ - id: 30, - reviewer: "Alice", - state: "CHANGES_REQUESTED", - submittedAt: "2026-08-26T03:00:00Z", - })], - new Map([["alice", "admin"], ["bob", "write"]]), - ); - - assert.equal(plan.status, "latest-changes-requested"); - assert.deepEqual(plan.blockingReviews, []); -}); - -test("a reviewer's own later approval supersedes their earlier change request", () => { - const plan = buildDismissalPlan( - [ - changesByAlice, - review({ - id: 15, - reviewer: "Alice", - state: "APPROVED", - submittedAt: "2026-08-26T01:30:00Z", - }), - review({ - id: 12, - reviewer: "Carol", - state: "CHANGES_REQUESTED", - submittedAt: "2026-08-26T01:15:00Z", - }), - ], - new Map([["alice", "write"], ["carol", "write"]]), - ); - - assert.equal(plan.status, "dismiss"); - assert.deepEqual(plan.blockingReviews.map(({ id }) => id), [12]); -}); - -test("read-only approval never overrides a write-access change request", () => { - const plan = buildDismissalPlan( - [changesByAlice, approvalByBob], - new Map([["alice", "write"], ["bob", "read"]]), - ); - - assert.equal(plan.status, "latest-changes-requested"); - assert.equal(plan.latestReview.id, 10); -}); - -test("same-second reviews use the larger review id as the later decision", () => { - const submittedAt = "2026-08-26T01:00:00Z"; - const plan = buildDismissalPlan( - [ - review({ id: 100, reviewer: "Alice", state: "CHANGES_REQUESTED", submittedAt }), - review({ id: 101, reviewer: "Bob", state: "APPROVED", submittedAt }), - ], - new Map([["alice", "write"], ["bob", "write"]]), - ); - - assert.equal(plan.status, "dismiss"); - assert.equal(plan.latestReview.id, 101); -}); - -test("unknown or missing permissions fail closed", () => { - assert.equal(classifyRepositoryPermission("admin"), "write"); - assert.equal(classifyRepositoryPermission("triage"), "read"); - assert.throws(() => classifyRepositoryPermission("mystery"), /알 수 없는/); - assert.throws( - () => buildDismissalPlan([approvalByBob], new Map()), - /repository permission 이 없습니다/, - ); -}); - -test("only valid optional target pull numbers are accepted", () => { - assert.equal(parseTargetPullNumber(undefined), null); - assert.equal(parseTargetPullNumber(""), null); - assert.equal(parseTargetPullNumber(" 914 "), 914); - assert.throws(() => parseTargetPullNumber("0"), /양의 정수/); - assert.throws(() => parseTargetPullNumber("abc"), /양의 정수/); -}); - -test("dismissal calls the protected review endpoint and verifies the response", async () => { - const calls = []; - const client = { - async request(path, options) { - calls.push({ path, options }); - return { state: "DISMISSED" }; - }, - }; - const plan = { - pullNumber: 914, - status: "dismiss", - latestReview: { - id: 20, - reviewer: "bob", - state: "APPROVED", - submittedAt: "2026-08-26T02:00:00Z", - submittedTimestamp: Date.parse("2026-08-26T02:00:00Z"), - }, - blockingReviews: [{ - id: 10, - reviewer: "alice", - state: "CHANGES_REQUESTED", - submittedAt: "2026-08-26T01:00:00Z", - submittedTimestamp: Date.parse("2026-08-26T01:00:00Z"), - }], - }; - - const dismissed = await applyDismissalPlan(client, "Afternote/Afternote-FE", plan); - - assert.deepEqual(dismissed.map(({ id }) => id), [10]); - assert.equal( - calls[0].path, - "/repos/Afternote/Afternote-FE/pulls/914/reviews/10/dismissals", - ); - assert.equal(calls[0].options.method, "PUT"); - assert.equal(calls[0].options.body.event, "DISMISS"); - assert.match(calls[0].options.body.message, /@bob/); - assert.match(calls[0].options.body.message, /review #20/); -}); - -test("an unexpected dismiss response fails instead of reporting success", async () => { - const client = { async request() { return { state: "CHANGES_REQUESTED" }; } }; - await assert.rejects( - applyDismissalPlan(client, "Afternote/Afternote-FE", { - pullNumber: 914, - status: "dismiss", - latestReview: approvalByBob, - blockingReviews: [changesByAlice], - }), - /DISMISSED가 아닙니다/, - ); -}); - -test("all pull requests are read and authorized before the first dismissal", async () => { - const writes = []; - const client = { - async paginate(path) { - if (path.includes("/pulls/1/reviews")) { - return [changesByAlice, approvalByBob]; - } - throw new Error("second PR review lookup failed"); - }, - async request(path, options = {}) { - if (options.method === "PUT") { - writes.push(path); - return { state: "DISMISSED" }; - } - return { permission: "write" }; - }, - }; - - await assert.rejects( - reconcileLatestReviewDecisions({ - client, - repository: "Afternote/Afternote-FE", - pullNumbers: [1, 2], - logger: { log() {} }, - }), - /second PR review lookup failed/, - ); - assert.deepEqual(writes, []); -}); diff --git a/.github/scripts/review-debt-policy.test.mjs b/.github/scripts/review-debt-policy.test.mjs deleted file mode 100644 index 1c507ddb5..000000000 --- a/.github/scripts/review-debt-policy.test.mjs +++ /dev/null @@ -1,249 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -const guard = await readFile(new URL("../workflows/review-debt-guard.yml", import.meta.url), "utf8"); -const requestAll = await readFile(new URL("../workflows/review-request-all.yml", import.meta.url), "utf8"); - -test("team membership is judged by real permission, not author_association", () => { - // 웹훅 페이로드의 author_association 은 조직 멤버십이 비공개면 MEMBER 대신 - // CONTRIBUTOR 를 내린다. 이 조직은 공개 멤버가 0명이라, 레포에 직접 초대된 한 - // 계정만 COLLABORATOR 로 찍혀 검사를 받고 나머지 팀원은 전원 건너뛰어졌다. - // 가드가 한 사람만 막는 상태였다 (8/25 #1152·#1154 통과 / #1112 만 닫힘). - // 주석은 이력이라 남기고, 실제로 값을 끌어오는 자리만 금지한다. - assert.doesNotMatch(guard, /^\s*[A-Z_]+:\s*\$\{\{[^\n]*author_association/m); - assert.doesNotMatch(guard, /\$ASSOCIATION/); - assert.match(guard, /collaborators\/\$AUTHOR\/permission/); -}); - -test("only write-capable accounts are subject to the guard", () => { - // read 권한만 있는 계정은 리뷰로 판정을 낼 수 없으니 빚을 질 수도 없다. - assert.match(guard, /^\s*admin\|maintain\|write\)/m); - // 빈 값·예상 밖 값까지 «권한 없음» 으로 접으면 조회 실패가 통과로 둔갑한다. - assert.match(guard, /^\s*read\|none\)[^\n]*exit 0/m); - assert.match(guard, /^\s*\*\) echo "::error::권한 값을 알 수 없다[^\n]*exit 1/m); -}); - -test("a failed permission lookup fails the run instead of passing the pull request", () => { - // 조회 실패를 «권한 없음» 으로 접으면 가드 전체가 조용히 무력해진다. - assert.match(guard, /if ! perm=\$\(gh api[^\n]*\n\s*echo "::error::[^\n]*\n\s*exit 1/); -}); - -test("forks are excluded by head repository, not by contributor status", () => { - // fork PR 은 토큰이 read-only 라 닫지도 못한다. 그것이 원래 걸러내려던 것이고, - // author_association 은 그 판정에 쓸 수 없는 축이었다. - assert.match(guard, /\[ "\$HEAD_REPO" != "\$REPO" \]/); -}); - -test("the debt sweep still fails closed when open pull requests cannot be listed", () => { - // 조회 실패를 «빚 없음» 으로 오인하면 새 PR 이 전부 통과한다. - 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 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", () => { - // 빚 기준은 여전히 열린 PR 전체다. 변경요청 뒤 명시적 재요청 여부를 확인하더라도 - // 요청 제거 자체를 별도 감사하는 job 은 가드 런 19건 동안 발동한 적이 없었다. - // 주석은 이력이라 남기고, 실제로 트리거를 걸고 job 을 세우는 자리만 금지한다. - assert.doesNotMatch(guard, /^\s*types:[^\n]*review_request_removed/m); - assert.doesNotMatch(guard, /^\s{2}bypass-audit:/m); -}); - -test("changes requested becomes debt only after explicit rerequest and a fix", () => { - // 수정 커밋만으로 리뷰 의무를 만들지 않는다. 변경요청을 낸 리뷰어가 현재 요청 - // 목록에 다시 들어온 경우에만 반영 여부를 보고 빚으로 센다. - assert.match(guard, /CHANGES_REQUESTED/); - assert.match(guard, /requested_reviewers\[\]\.login/); - assert.match(guard, /blocked_by/); - assert.match(guard, /명시적 재리뷰 요청 없음/); - assert.match(guard, /sort_by\(\.t\) \| last/); - assert.doesNotMatch(guard, /group_by\(\.u/); - assert.match(guard, /\(\.parents \| length\) < 2/); - // 반영 판정의 기준 시각은 여전히 최신 변경요청 시각이다. 집계가 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 in target/); - assert.match(guard, /-v owners="\$pr_owners"/); - // 계정이 연결되지 않은 커밋은 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/); - // 조회 실패나 깨진 응답을 반영·미반영 어느 쪽으로도 접지 않는다. 근거가 완전하지 - // 않으면 가드를 실패시켜 사람의 정상 PR 을 닫는 오탐을 막는다. - assert.doesNotMatch(guard, /changed=1/); - assert.match(guard, /변경 파일 조회에 실패했다/); - assert.match(guard, /변경 파일 수가 올바르지 않다/); -}); - -test("review evidence API failures stop the guard before it can close a pull request", () => { - // 리뷰·커밋·댓글을 못 읽은 상태는 «응답 없음» 이 아니다. 각 근거 조회가 실패하면 - // 즉시 종료하고, `|| true` 나 stderr 폐기로 빈 결과를 만들어서는 안 된다. - for (const message of [ - "리뷰 조회에 실패했다", - "커밋 조회에 실패했다", - "변경 파일 조회에 실패했다", - "작성자 일반 응답 조회에 실패했다", - "작성자 리뷰 응답 조회에 실패했다", - "본문 편집 이력 조회에 실패했다", - "본문 편집 이력이 불완전하다", - ]) { - assert.match(guard, new RegExp(message)); - } - assert.doesNotMatch(guard, /gh api[^\n]*(?:\n[^\n]*){0,4}\|\| true/); - assert.doesNotMatch(guard, /gh api[^\n]*2>\/dev\/null/); -}); - -test("a fix delivered by a merge commit still counts as a response", () => { - // 비병합 커밋만 세면 base 를 끌어와 충돌을 풀며 반영한 PR 이 «미반영» 이 된다. - // #1316 은 리뷰가 지목한 파일이 실제로 바뀌고 작성자 응답 코멘트도 2건 달린 - // 채로 빚에서 빠져 있었다. 커밋과 함께 작성자의 응답을 본다 (#1450). - assert.match(guard, /issues\/\$pn\/comments/); - assert.match(guard, /pulls\/\$pn\/comments/); - // 담당자·시각 판정은 review-debt-query.test.mjs에서 실제 jq로 검증한다. - assert.match(guard, /\[ "\$\{fixed:-0\}" -gt 0 \] \|\| \[ "\$responses" -gt 0 \]/); -}); - -test("counts are taken per item so paginated pull requests do not break the comparison", () => { - // --paginate 는 페이지마다 jq 를 돌린다. 페이지 단위로 length 를 뽑으면 커밋이 - // 100건을 넘는 PR 에서 숫자가 여러 줄로 나와 -gt 비교가 죽는다. - assert.doesNotMatch(guard, /\| length" 2>\/dev\/null/); - assert.match(guard, /\| wc -l \| tr -d ' '/); -}); - -test("rerequests are automated so silence cannot pass the guard", () => { - // 가드는 변경요청을 낸 리뷰어에게 요청이 다시 걸린 경우에만 빚으로 센다. 그 - // 되살리기를 작성자 손에 맡기면 아무도 걸지 않아 가드가 통째로 무력해진다 — - // 8/29 실측에서 반영까지 끝난 7건이 전원 «빚 아님» 이었다 (#1450). - assert.match(requestAll, /^\s*types: \[opened, ready_for_review, reopened, synchronize, edited\]/m); - assert.match(requestAll, /^\s{2}rerequest:/m); - 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\.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"/); - // 기존 전원 요청은 반영 커밋이나 본문 편집마다 다시 돌지 않는다. - assert.match(requestAll, /github\.event\.action != 'synchronize'/); - assert.match(requestAll, /github\.event\.action != 'edited'/); -}); - -test("rerequest review lookup fails closed because body edits are one-shot events", () => { - assert.match(requestAll, /리뷰 조회에 실패했다 — 재리뷰 요청을 누락시키지 않도록 재실행할 것/); - assert.match(requestAll, /최신 리뷰 판정에 실패했다 — 재리뷰 요청을 누락시키지 않도록 재실행할 것/); - assert.doesNotMatch(requestAll, /gh api[^\n]*(?:\n[^\n]*){0,4}2>\/dev\/null/); - assert.doesNotMatch(requestAll, /gh api[^\n]*(?:\n[^\n]*){0,4}\|\| true/); -}); - -test("an edited event cannot be attributed to a later change request", () => { - assert.match(requestAll, /EVENT_AT: \$\{\{ github\.event\.pull_request\.updated_at \}\}/); - assert.match(requestAll, /\$action != "edited" or \.t < \$event_at/); - assert.match(requestAll, /본문 편집 이벤트 시각이 없다/); -}); - -test("author body edits are durable review evidence and fail closed when truncated", () => { - // updated_at 은 댓글·라벨까지 섞이고 lastEditedAt 은 마지막 편집자만 남긴다. 영속 - // userContentEdits 에서 최신 변경요청 뒤 PR 작성자의 편집만 세어야 한다. - assert.match(guard, /userContentEdits\(last:50\)/); - assert.match(guard, /editor\{login\}/); - assert.match(guard, /\.editor\.login/); - assert.match(guard, /\.editedAt > \$cutoff/); - assert.match(guard, /--arg owners ",\$pr_owners,"/); - assert.match(guard, /pageInfo\.hasPreviousPage != false/); - assert.match(guard, /\[ "\$body_edits" -gt 0 \]/); -}); - -test("the rerequest job and the guard judge by the same latest decision", () => { - // 한쪽만 «PR 전체의 최신 판정» 을 보면 자동 재요청이 빚으로 이어지지 않거나, - // 이미 승인된 PR 에 요청을 되살린다. - for (const wf of [guard, requestAll]) { - assert.match(wf, /sort_by\(\.t\) \| last/); - assert.match(wf, /CHANGES_REQUESTED/); - } - // 봇·fork 는 토큰이 read-only 라 요청을 걸 수 없다. - assert.match(requestAll, /\[ "\$HEAD_REPO" != "\$REPO" \]/); -}); - -function spaceSeparatedEnv(workflow, name) { - const match = new RegExp(`^\\s*${name}: ([^\\n#]+)$`, "m").exec(workflow); - assert.ok(match, `${name} 환경변수를 찾지 못했다`); - return match[1].trim().split(/\s+/).filter(Boolean).map((login) => login.toLowerCase()); -} - -test("the gate exemption is judged before any debt is counted", () => { - // 면제를 빚 계산 뒤에 두면, 면제받은 사람의 PR 때문에 조회가 실패했을 때 가드가 그를 - // 대신해 죽는다. 봇·fork·권한 판정과 같은 자리, 열린 PR 을 훑기 전에 둔다 (#1910). - const exemptAt = guard.indexOf("리뷰 게이트 면제 작성자"); - const sweepAt = guard.indexOf("if ! open_prs=$(gh api"); - assert.ok(exemptAt > 0, "면제 경로가 없다"); - assert.ok(sweepAt > exemptAt, "면제가 빚 계산 뒤에 있다"); - assert.match(guard, /리뷰 게이트 면제 작성자\(\$AUTHOR\)[^\n]*exit 0/); -}); - -test("exempt logins are matched case-insensitively", () => { - // GitHub 로그인은 대소문자를 가리지 않는다. 그대로 비교하면 웹훅이 Koongmai 를 내리는 - // 순간 면제가 조용히 풀리고, 그 사람의 PR 이 닫힌다. - assert.match(guard, /author_lc=\$\(printf '%s' "\$AUTHOR" \| tr '\[:upper:\]' '\[:lower:\]'\)/); - assert.match(guard, /exempt_lc=\$\(printf '%s' "\$REVIEW_GATE_EXEMPT_AUTHORS" \| tr '\[:upper:\]' '\[:lower:\]'\)/); -}); - -test("a change request from an exempt reviewer is not charged to the rest of the team", () => { - // 면제된 사람은 리뷰할 의무가 없다. 그가 자발적으로 낸 변경요청 뒤 침묵하면 그 침묵의 - // 대가를 나머지 팀원이 "새 PR 을 못 연다" 로 치른다. 그래도 그 PR 이 그냥 머지되지는 - // 않는다. 승인 1건은 required-approval 룰셋이 계속 요구한다. - assert.match(guard, /case " \$exempt_lc " in\n\s*\*" \$blocked_by "\*\)/); - assert.match(guard, /면제 리뷰어 @\$blocked_by/); -}); - -test("nobody is exempt from the gate while still on the automatic review roster", () => { - // 리뷰 의무가 없는 사람에게 요청만 계속 걸면, 그 요청은 아무도 응답하지 않는 알림으로 - // 쌓이고 두 워크플로가 서로 다른 팀 명단을 갖게 된다. - const exempt = spaceSeparatedEnv(guard, "REVIEW_GATE_EXEMPT_AUTHORS"); - const team = spaceSeparatedEnv(requestAll, "TEAM"); - for (const login of exempt) { - assert.ok(!team.includes(login), `${login} 이 면제이면서 자동 요청 대상이다`); - } - assert.ok(team.length > 0, "자동 요청 대상이 비었다"); -}); diff --git a/.github/scripts/review-debt-query.test.mjs b/.github/scripts/review-debt-query.test.mjs deleted file mode 100644 index 9751d19ab..000000000 --- a/.github/scripts/review-debt-query.test.mjs +++ /dev/null @@ -1,62 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import { spawnSync } from "node:child_process"; -import test from "node:test"; - -const guard = await readFile(new URL("../workflows/review-debt-guard.yml", import.meta.url), "utf8"); -const cutoff = "2026-09-10T00:00:00Z"; -const owners = "koongmai,1hyok"; - -function runQuery(query, data, args = []) { - return spawnSync("jq", ["-r", ...args, query], { input: JSON.stringify(data), encoding: "utf8" }); -} - -for (const endpoint of ["issues", "pulls"]) { - test(`${endpoint} comments retain author context and count only later owner responses`, () => { - const start = guard.indexOf(`repos/$REPO/${endpoint}/$pn/comments?per_page=100`); - assert.ok(start >= 0); - const raw = guard.slice(start).match(/--jq "([\s\S]*?)" \\\n/)[1]; - const query = raw.replaceAll("$pr_owners", owners).replaceAll("$blocked_at", cutoff) - .replaceAll('\\"', '"').replaceAll('\\$', '$'); - const comments = [ - { id: 1, user: { login: "KoongMai" }, created_at: "2026-09-11T00:00:00Z" }, - { id: 2, user: { login: "1hyok" }, created_at: "2026-09-12T00:00:00Z" }, - { id: 3, user: { login: "koongmai" }, created_at: cutoff }, - { id: 4, user: { login: "koong" }, created_at: "2026-09-11T00:00:00Z" }, - { id: 5, user: { login: "other" }, created_at: "2026-09-11T00:00:00Z" }, - ]; - // gh --paginate executes its query separately on each page. - const pages = [comments.slice(0, 1), comments.slice(1), []]; - const output = pages.map(page => { - const result = runQuery(query, page); - assert.equal(result.status, 0, result.stderr); - return result.stdout; - }).join(""); - assert.equal(output, "1\n2\n"); - }); -} - -const bodyQuery = guard.match(/--arg cutoff "\$blocked_at" '([\s\S]*?)' <<< "\$edits_json"/)[1]; -function edits(nodes, hasPreviousPage = false) { - return { data: { repository: { pullRequest: { userContentEdits: { nodes, pageInfo: { hasPreviousPage } } } } } }; -} -const args = ["--arg", "owners", `,${owners},`, "--arg", "cutoff", cutoff]; - -test("body edit evidence keeps editor context, cutoff, and exact owner membership", () => { - const result = runQuery(bodyQuery, edits([ - { editor: { login: "KOONGMAI" }, editedAt: "2026-09-11T00:00:00Z" }, - { editor: { login: "1hyok" }, editedAt: "2026-09-12T00:00:00Z" }, - { editor: { login: "koongmai" }, editedAt: cutoff }, - { editor: { login: "koong" }, editedAt: "2026-09-11T00:00:00Z" }, - { editor: null, editedAt: "2026-09-11T00:00:00Z" }, - ]), args); - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stdout, "2\n"); - assert.equal(runQuery(bodyQuery, edits([]), args).stdout, "0\n"); -}); - -test("truncated body edit history still fails closed", () => { - const result = runQuery(bodyQuery, edits([], true), args); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /최근 50건보다 많은 본문 편집 이력/); -}); diff --git a/.github/scripts/validate-pr-issue-link.mjs b/.github/scripts/validate-pr-issue-link.mjs index abd00791e..3319f1c68 100644 --- a/.github/scripts/validate-pr-issue-link.mjs +++ b/.github/scripts/validate-pr-issue-link.mjs @@ -75,7 +75,6 @@ function issueAssigneeLogins(issue) { } // 봇은 Issue 담당자로 지정할 수 없으므로 담당자 대조에서 면제한다. -// review-debt-guard·review-request-all 의 봇 면제 규약과 같은 경계다. function isBotAuthor(user, login) { return user?.type === "Bot" || login.endsWith("[bot]"); } diff --git a/.github/workflows/conflict-label.yml b/.github/workflows/conflict-label.yml index 5b0cb8cea..b99565c38 100644 --- a/.github/workflows/conflict-label.yml +++ b/.github/workflows/conflict-label.yml @@ -8,10 +8,6 @@ name: PR Label Reconcile # 같은 자리에서 이슈 쪽 라벨도 맞춘다 (#1532) — base 가 기본 브랜치가 아닌 PR 은 이슈의 # Development 칸에 뜨지 않아, 이슈만 보면 아무도 손대지 않은 것으로 읽힌다. # -# 변경요청 뒤 작성자가 아무 조치도 하지 않은 PR 에도 라벨을 붙인다 (#1552). `review-debt-guard` -# 는 리뷰어의 빚만 잡고 그 상태를 «공은 작성자에게» 로 흘려보내므로, 그쪽 축은 어디서도 신호가 -# 나지 않았다. 붙이기와 떼기를 한 번의 차이 계산으로 처리해 스테일 라벨이 남지 않게 한다. -# # 열린 PR 끼리의 «형제 충돌» 도 같은 리컨사일러가 잡는다 (#1750). `conflict` 라벨은 base 와의 충돌만 # 보므로, 두 PR 이 같은 파일을 서로 다르게 고친 사실은 한쪽이 머지된 뒤에야 드러났다. merge-tree 에 # 전체 히스토리가 필요해 별도 job 으로 두고 checkout 을 fetch-depth 0 으로 받는다. @@ -63,9 +59,7 @@ 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-awaiting-author-prs.test.mjs \ - .github/scripts/awaiting-author-policy.test.mjs + .github/scripts/label-issues-with-open-prs.test.mjs - name: Reconcile pull request labels env: @@ -79,7 +73,6 @@ 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" sibling-conflicts: diff --git a/.github/workflows/latest-review-decision-event.yml b/.github/workflows/latest-review-decision-event.yml deleted file mode 100644 index ee0263767..000000000 --- a/.github/workflows/latest-review-decision-event.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Latest Review Decision Event - -# pull_request_review는 fork PR의 read-only 토큰으로도 발생한다. 여기서는 PR 코드나 API를 -# 건드리지 않고 완료 신호만 만든다. 실제 쓰기는 trusted default-branch workflow가 맡는다. -on: - pull_request_review: - types: [submitted] - -permissions: {} - -jobs: - signal: - name: Signal latest review decision reconciliation - runs-on: ubuntu-latest - timeout-minutes: 1 - steps: - - name: Record review decision event - run: echo 'A pull request review decision was submitted.' diff --git a/.github/workflows/latest-review-decision-reconcile.yml b/.github/workflows/latest-review-decision-reconcile.yml deleted file mode 100644 index 270dfc1ec..000000000 --- a/.github/workflows/latest-review-decision-reconcile.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Latest Review Decision Reconcile - -on: - workflow_run: - workflows: [Latest Review Decision Event] - types: [completed] - workflow_dispatch: - inputs: - pull_number: - description: Optional pull request number; empty reconciles every open PR - required: false - type: string - -concurrency: - group: latest-review-decision-${{ github.repository }} - cancel-in-progress: false - -permissions: {} - -jobs: - reconcile: - name: Reconcile latest review decisions - if: >- - github.event_name == 'workflow_dispatch' || - (github.event.workflow_run.event == 'pull_request_review' && - github.event.workflow_run.conclusion == 'success') - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: read - pull-requests: write - steps: - - name: Clone trusted default branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.repository.default_branch }} - persist-credentials: false - - - name: Reconcile latest review decisions - env: - GITHUB_API_URL: ${{ github.api_url }} - GITHUB_REPOSITORY: ${{ github.repository }} - GITHUB_TOKEN: ${{ github.token }} - TARGET_PULL_NUMBER: >- - ${{ inputs.pull_number || github.event.workflow_run.pull_requests[0].number }} - run: node .github/scripts/reconcile-latest-review-decision.mjs diff --git a/.github/workflows/pr-assign-author.yml b/.github/workflows/pr-assign-author.yml index 9e8f5c72e..b72aa9ff5 100644 --- a/.github/workflows/pr-assign-author.yml +++ b/.github/workflows/pr-assign-author.yml @@ -5,7 +5,7 @@ # 어사인이 비어 있는 PR 에만 작성자를 건다. 사람이 담당자를 바꾼 것(작성자를 떼고 # 다른 사람을 건 것)은 그대로 둔다 — 강제하는 것은 «비어 있지 않음» 이지 «작성자» 가 # 아니다. 전부 떼어 비게 되면 다시 작성자를 건다. -# review-request-all 과 같은 조건(봇·외부 기여자 제외)으로 돈다. +# 봇·외부 기여자 PR 은 건너뛴다. name: pr-assign-author on: diff --git a/.github/workflows/review-debt-guard.yml b/.github/workflows/review-debt-guard.yml deleted file mode 100644 index f64c37a3c..000000000 --- a/.github/workflows/review-debt-guard.yml +++ /dev/null @@ -1,428 +0,0 @@ -# 리뷰 적체 가드 (#1136) -# -# 리뷰 요청을 처리하지 않거나 자기 PR 의 변경요청에 응답하지 않은 사람이 새 PR 을 -# 계속 여는 것을 막는다. -# GitHub 네이티브에는 PR 생성을 막는 수단이 없어(ruleset·branch protection 은 -# push·merge 만 통제) 열린 직후 조회해 미처리 리뷰 요청이 1건이라도 있으면 -# 그 PR 을 닫는다. 브랜치는 남으므로 리뷰를 끝낸 뒤 되살릴 수 있다. -# -# 리뷰어 빚의 기준은 «나에게 걸린 리뷰 요청» 이 아니라 «아무도 응답하지 않은 열린 -# PR» 이다(#1148). 다만 한번 변경요청을 남긴 뒤에는 담당자가 그 리뷰어에게 명시적으로 -# 재요청해야만 공이 돌아온다. 수정 커밋만으로 리뷰 의무를 만들지는 않는다(#1136). -# 작성자 빚은 같은 사람이 담당하는 다른 열린 PR 이 최신 변경요청 뒤 실질 커밋·응답· -# 본문 편집 없이 남은 상태다. 라벨 갱신 시점에 기대지 않고 새 PR 을 열 때 현재 상태를 -# 다시 계산한다(#1679, #1700). -# -# PR 의 담당자는 «올린 사람» 이 아니라 «어사인된 사람» 이다(#1974 결정, #1980). 변경요청에 -# 응답하는 것도, «자기 PR» 을 리뷰어 빚에서 빼는 것도 어사인 기준이고, 어사인이 비어 있는 -# 동안만 작성자가 담당자다. 새 PR 을 여는 사람(AUTHOR)이 게이트를 받는 것은 그대로다. -name: review-debt-guard - -on: - pull_request: - types: [opened, reopened, ready_for_review] - -permissions: - contents: read - pull-requests: write - -jobs: - debt-check: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - # 게이트 스크립트는 **default branch 사본**으로 돌린다. PR 사본을 체크아웃하면 PR 이 - # 자기를 검사하는 가드를 고쳐 통과시킬 수 있다. 필요한 건 .github/scripts 뿐이라 - # sparse-checkout 으로 좁힌다. - - name: Clone gate scripts from the default branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.repository.default_branch }} - persist-credentials: false - sparse-checkout: .github/scripts - - # 한도가 바닥인 채로 들어가면 게이트가 코드와 무관하게 빨개진다. 남은 quota 를 요약에 - # 남기고, 부족하면 리셋까지 기다린다 — 즉시 실패시키면 사람이 재실행을 걸고 그 재실행이 - # 남은 quota 를 더 태운다 (#1465). - - name: Ensure GitHub API quota - env: - GH_TOKEN: ${{ github.token }} - run: | - # default branch 사본에 이 스크립트가 아직 없는 동안(= 스크립트를 심는 이 PR 자신)에는 - # 건너뛴다. 머지 뒤에는 항상 존재하므로 이 경로는 부트스트랩 한 번에만 쓰인다. - if [ ! -f .github/scripts/ensure-api-quota.mjs ]; then - echo "::notice::quota 스크립트가 default branch 에 아직 없습니다 — 건너뜁니다" - exit 0 - fi - # 두 자원을 순차 확인하므로 각각의 대기 상한을 절반 이하로 잡아 job 의 5분 - # timeout 안에 판정 로그까지 남긴다(내부 +5초 경계 여유를 포함해 최대 240초). - node .github/scripts/ensure-api-quota.mjs ensure --resource core --max-wait 115 - node .github/scripts/ensure-api-quota.mjs ensure --resource graphql --max-wait 115 - - - name: 리뷰·작성자 응답 빚 검사 - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - AUTHOR: ${{ github.event.pull_request.user.login }} - HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} - # PR 제한·awaiting-author 라벨의 공통 면제 목록 (#1910, #1934). - # 라벨러도 이 선언을 읽는다. 인용·주석 없는 한 줄에서 공백으로 구분한다. - REVIEW_GATE_EXEMPT_AUTHORS: koongmai - run: | - # 조회 실패를 "빚 없음" 으로 오인해 통과시키지 않는다. 다만 판정 근거를 - # 얻지 못한 상태로 남의 PR 을 닫지도 않는다 — 아래에서 명시적으로 나눈다. - set -euo pipefail - - # 봇 PR 은 대상이 아니다. - case "$AUTHOR" in - *'[bot]'|dependabot|github-actions) echo "봇 PR — 건너뜀"; exit 0 ;; - esac - - # fork PR 은 대상이 아니다 — 토큰이 read-only 라 닫지도 못하고, 팀 리뷰 - # 적체와도 무관하다. 원래 걸러내려던 것이 이것이므로 head 저장소로 가른다. - if [ "$HEAD_REPO" != "$REPO" ]; then - echo "fork PR($HEAD_REPO) — 건너뜀" - exit 0 - fi - - # 팀원 여부는 author_association 으로 가리지 않는다. 웹훅 페이로드는 조직 - # 멤버십이 비공개면 MEMBER 대신 CONTRIBUTOR 를 내리는데 이 조직은 공개 - # 멤버가 0명이다. 그래서 레포에 직접 초대된 한 계정만 COLLABORATOR 로 찍혀 - # 검사를 받고, 나머지 팀원은 전원 «외부 기여자» 로 건너뛰어졌다 — 8/25 에 - # #1152·#1154 가 그렇게 통과했고 12분 뒤 같은 빚으로 #1112 만 닫혔다. - # REST 로 조회하면 MEMBER 로 보여 눈으로는 잡히지 않는다. 실권한을 묻는다. - if ! perm=$(gh api "repos/$REPO/collaborators/$AUTHOR/permission" --jq '.permission'); then - echo "::error::권한 조회에 실패했다 — 근거 없이 통과시키지 않는다. 재실행할 것" - exit 1 - fi - - # 빈 값·예상 밖 값을 «권한 없음» 으로 접으면 조회 실패가 통과로 둔갑한다. - case "$perm" in - admin|maintain|write) ;; - read|none) echo "쓰기 권한 없음($perm) — 건너뜀"; exit 0 ;; - *) echo "::error::권한 값을 알 수 없다($perm) — 재실행할 것"; exit 1 ;; - esac - - # 이 가드 자체를 면제받은 작성자 (#1910). 팀이 koongmai 에게는 PR 을 여는 시점의 - # 제약을 두지 않고 머지 시점 제약만 남기기로 했다. 리뷰 지적을 반영했는지와 무관하게 - # 새 PR 을 열 수 있고, 남의 PR 을 리뷰할 의무도 없어 review-request-all 의 TEAM 에서도 - # 빠져 있다. 머지 게이트는 그대로다. develop 은 여전히 승인 1건과 필수 체크를 요구하고, - # 그 요건은 이 가드가 아니라 required-approval·required-checks 룰셋이 건다. - # - # 면제는 "빚 계산" 앞에 둔다. 뒤에 두면 면제받은 사람의 PR 때문에 조회가 실패했을 때 - # 가드가 그를 대신해 죽는다. GitHub 로그인은 대소문자를 가리지 않으므로 낮춰 비교한다. - author_lc=$(printf '%s' "$AUTHOR" | tr '[:upper:]' '[:lower:]') - exempt_lc=$(printf '%s' "$REVIEW_GATE_EXEMPT_AUTHORS" | tr '[:upper:]' '[:lower:]') - case " $exempt_lc " in - *" $author_lc "*) echo "리뷰 게이트 면제 작성자($AUTHOR) — 건너뜀"; exit 0 ;; - esac - - # 빚의 기준은 «나에게 요청이 걸렸는가» 가 아니라 «아무도 응하지 않은 PR 이 - # 남아 있는가» 다. 리뷰 요청은 알림 수단이지 담당 배정이 아니다 — - # 누구에게 걸렸든 팀에서 한 사람이 보면 그 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) - | ((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 - repo_owner=${REPO%%/*} - repo_name=${REPO#*/} - - # 같은 작성자의 다른 PR 에 공이 남아 있는지도 실시간으로 다시 계산한다. 기존 - # `awaiting-author` 라벨만 보면 리컨사일 주기와 이 실행이 엇갈릴 때 방금 붙거나 - # 떨어진 상태를 놓친다. 라벨 리컨사일러와 같은 judgeAwaitingAuthor 술어를 쓰되, - # 현재 막 열려 판정 리뷰가 없는 PR 자신은 번호로 명시적으로 제외한다. - author_debt_file=$(mktemp) - if [ -f .github/scripts/check-author-debt.mjs ]; then - GITHUB_TOKEN="$GH_TOKEN" \ - GITHUB_REPOSITORY="$REPO" \ - AUTHOR="$AUTHOR" \ - CURRENT_PULL_REQUEST_NUMBER="$PR_NUMBER" \ - AUTHOR_DEBT_FILE="$author_debt_file" \ - node .github/scripts/check-author-debt.mjs - else - # default branch 에 스크립트를 심는 이 PR 자신에게는 아직 사본이 없다. 머지 - # 뒤에는 항상 존재하므로 부트스트랩 한 번만 작성자 빚 검사를 건너뛴다. - echo "::notice::작성자 빚 스크립트가 default branch 에 아직 없습니다 — 건너뜁니다" - : > "$author_debt_file" - fi - - # 남은 것에서 «리뷰어의 공이 아닌» PR 을 걸러낸다. «누가» 봤는지는 따지지 - # 않는다 — 팀에서 한 사람이 더 늦은 판정을 내리면 그 판정이 PR 의 현재 - # 상태다. 더 오래된 다른 리뷰어의 변경요청을 다시 빚으로 살리지 않는다. - # - # 승인과 변경요청은 무게가 다르다. - # APPROVED 머지 가능한 상태다. 그 뒤에 작성자가 손보는 것은 - # 작성자 몫이고, 다시 봐 줄 의무는 누구에게도 없다. - # CHANGES_REQUESTED 아직 끝난 판정이 아니다. 다만 수정 커밋만으로 리뷰 - # 의무가 생기지는 않는다. 작성자가 같은 리뷰어에게 명시적으로 - # 재요청해야 공이 돌아온다. - # - # 그래서 빚은 두 종류다. (1) 아무도 판정을 내리지 않은 PR, - # (2) PR 전체의 최신 판정이 변경요청이고, 그 리뷰어에게 재요청이 걸렸으며, - # 그 판정 뒤 작성자가 반영 커밋·응답·PR 본문 편집을 남긴 PR. 재요청이 없거나 - # 최신 변경요청 뒤 작성자 조치가 없으면 빚이 아니다. 반영 커밋은 병합 커밋 - # (parents 2개 이상)을 뺀 실질 커밋으로만 센다. - debt_file=$(mktemp) - reviews_file=$(mktemp) - 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 \ - --jq '.[] | select(.state == "APPROVED" or .state == "CHANGES_REQUESTED") - | {u: .user.login, s: .state, t: .submitted_at}' \ - > "$reviews_file"; then - echo "::error::PR #$pn 리뷰 조회에 실패했다 — 근거 없이 PR 을 닫지 않는다" - exit 1 - fi - - if [ ! -s "$reviews_file" ]; then - echo "#$pn — 아무도 판정하지 않음" - printf '%s\t%s\t%s\n' "$pn" "$created" "$title" >> "$debt_file" - continue - fi - - # PR 전체의 최신 판정만 본다. 그 판정이 CHANGES_REQUESTED 이면서 현재 같은 - # 리뷰어에게 요청이 다시 걸렸다면, 리뷰 제출 시 제거된 요청을 작성자가 - # 최신 판정 뒤 명시적으로 되살린 것이다. 이후 다른 리뷰어가 더 늦게 판정한 - # 경우에는 과거 리뷰어의 남은 요청으로 공을 되돌리지 않는다. - if ! blocked=$(jq -rs --arg active ",$requested_reviewers," ' - sort_by(.t) | last - | .u |= ascii_downcase - | select(.s == "CHANGES_REQUESTED") - | select(.u as $reviewer - | $active | contains("," + $reviewer + ",")) - | [.u, .t] | @tsv' \ - "$reviews_file"); then - echo "::error::PR #$pn 최신 리뷰 판정에 실패했다 — 근거 없이 PR 을 닫지 않는다" - exit 1 - fi - - if [ -z "$blocked" ]; then - echo "#$pn — 결정 리뷰 뒤 명시적 재리뷰 요청 없음, 빚 아님" - continue - fi - IFS=$'\t' read -r blocked_by blocked_at <<< "$blocked" - - # 면제 리뷰어가 낸 변경요청은 팀의 빚으로 돌아오지 않는다 (#1910). 면제된 사람은 - # 리뷰할 의무가 없으므로, 그가 자발적으로 낸 변경요청 뒤 침묵하면 그 침묵의 대가를 - # 나머지 팀원이 "새 PR 을 못 연다" 로 치르게 된다. 여기서 세지 않아도 그 PR 이 - # 그냥 머지되지는 않는다. 승인 1건이 머지 게이트에 그대로 남아 있다. - # jq 의 최신 판정은 이미 소문자로 내려온다. - case " $exempt_lc " in - *" $blocked_by "*) - echo "#$pn — 면제 리뷰어 @$blocked_by 의 변경요청, 팀 빚 아님" - continue - ;; - esac - - # 반영을 비병합 커밋으로만 세면 병합으로 반영한 PR 을 놓친다. base 를 - # 끌어와 충돌을 풀며 지적을 반영하면 그 뒤 커밋이 전부 parents 2개라 - # «미반영» 이 된다 — #1316 은 리뷰가 지목한 파일이 실제로 바뀌고 작성자 - # 응답 코멘트까지 2건 달린 채로 8/29 까지 빚에서 빠져 있었다(#1450). - # 병합 커밋의 first-parent diff 는 base 에서 들어온 변경을 통째로 담아 - # 반영분과 구별되지 않으므로, 커밋 대신 «작성자가 응답했는가» 를 함께 본다. - # - # 페이지마다 집계하면 100건이 넘는 PR 에서 숫자가 여러 줄로 나와 비교가 - # 깨진다. 건별로 뽑아 한 번에 센다. - # - # 세는 것은 «작성자가 올린 실질 변경» 뿐이다. 누가 올렸는지·무엇이 바뀌었는지를 - # 안 보면 리뷰어가 미는 CI 재트리거 빈 커밋까지 «작성자가 반영했다» 로 세어, - # 재트리거한 리뷰어가 그 대가로 자기 PR 을 못 열게 된다 — 8/28 에 리뷰어가 건 - # +0/-0 커밋 하나로 koongmai PR 3건(#1379·#1365·#882)이 전부 가짜 빚이 됐다(#1459). - commits_file=$(mktemp) - if ! 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' > "$commits_file"; then - echo "::error::PR #$pn 커밋 조회에 실패했다 — 근거 없이 PR 을 닫지 않는다" - exit 1 - fi - - # 계정이 연결되지 않은 커밋은 .author 가 비어 login 으로 가릴 수 없다. 같은 PR 에서 - # 담당자 것으로 확인된 커밋의 이메일을 모아 폴백 신원으로 쓴다. 담당자는 여럿일 수 - # 있으므로 집합으로 들고 다닌다. - 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 owners="$pr_owners" -v cutoff="$blocked_at" -v emails="$author_emails" ' - BEGIN { - 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 in 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 - if ! changed=$(gh api "repos/$REPO/commits/$sha" --jq '(.files // []) | length'); then - echo "::error::PR #$pn 커밋 $sha 변경 파일 조회에 실패했다 — 근거 없이 PR 을 닫지 않는다" - exit 1 - fi - case "$changed" in - ''|*[!0-9]*) - echo "::error::PR #$pn 커밋 $sha 변경 파일 수가 올바르지 않다($changed)" - exit 1 - ;; - esac - [ "$changed" -gt 0 ] || continue - fixed=$((fixed + 1)) - done <<< "$author_commits" - - # 응답 주체도 담당자 집합이다. 쉼표로 감싼 목록에 쉼표로 감싼 로그인이 들어 있는지 본다. - if ! said=$(gh api "repos/$REPO/issues/$pn/comments?per_page=100" --paginate \ - --jq ".[] | select((.user.login | ascii_downcase) as \$login | \",$pr_owners,\" | contains(\",\" + \$login + \",\")) - | select(.created_at > \"$blocked_at\") | .id" \ - | wc -l | tr -d ' '); then - echo "::error::PR #$pn 작성자 일반 응답 조회에 실패했다 — 근거 없이 PR 을 닫지 않는다" - exit 1 - fi - - if ! answered=$(gh api "repos/$REPO/pulls/$pn/comments?per_page=100" --paginate \ - --jq ".[] | select((.user.login | ascii_downcase) as \$login | \",$pr_owners,\" | contains(\",\" + \$login + \",\")) - | select(.created_at > \"$blocked_at\") | .id" \ - | wc -l | tr -d ' '); then - echo "::error::PR #$pn 작성자 리뷰 응답 조회에 실패했다 — 근거 없이 PR 을 닫지 않는다" - exit 1 - fi - - # PR 본문은 커밋 없이도 CI 계약·설명을 고치는 변경 표면이다(#771). REST 의 - # updated_at 은 댓글·라벨까지 섞이고 GraphQL lastEditedAt 은 마지막 편집자만 - # 남기므로, 영속 편집 이력에서 «최신 변경요청 뒤 담당자가 직접 고친 본문»만 센다. - # 최근 50건보다 많은 편집 이력이 있으면 필요한 구간이 잘렸을 수 있으므로 - # 근거 없이 작성자·리뷰어 어느 쪽으로도 공을 넘기지 않고 가드를 실패시킨다. - # shellcheck disable=SC2016 # GraphQL 변수는 gh가 -F 값으로 바인딩한다. - if ! edits_json=$(gh api graphql \ - -F owner="$repo_owner" \ - -F name="$repo_name" \ - -F number="$pn" \ - -f query='query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){userContentEdits(last:50){pageInfo{hasPreviousPage} nodes{editedAt editor{login}}}}}}'); then - echo "::error::PR #$pn 본문 편집 이력 조회에 실패했다 — 근거 없이 PR 을 닫지 않는다" - exit 1 - fi - if ! body_edits=$(jq -er \ - --arg owners ",$pr_owners," \ - --arg cutoff "$blocked_at" ' - .data.repository.pullRequest.userContentEdits as $edits - | if ($edits.pageInfo.hasPreviousPage != false) then - error("최근 50건보다 많은 본문 편집 이력") - else - [ - $edits.nodes[] - | select(((.editor.login // "") | ascii_downcase) as $login | $owners | contains("," + $login + ",")) - | select(.editedAt > $cutoff) - ] | length - end - ' <<< "$edits_json"); then - echo "::error::PR #$pn 본문 편집 이력이 불완전하다 — 근거 없이 PR 을 닫지 않는다" - exit 1 - fi - - responses=$(( ${said:-0} + ${answered:-0} )) - - if [ "${fixed:-0}" -gt 0 ] || [ "$responses" -gt 0 ] || [ "$body_edits" -gt 0 ]; then - echo "#$pn — @$blocked_by 재리뷰 요청 뒤 변경요청 반영(${fixed}커밋 · 응답 ${responses}건 · 본문 편집 ${body_edits}건), 아직 미응답" - printf '%s\t%s\t%s\n' "$pn" "$created" "$title" >> "$debt_file" - else - echo "#$pn — 변경요청 미반영, 공은 담당자에게" - fi - done <<< "$open_prs" - - if [ ! -s "$debt_file" ] && [ ! -s "$author_debt_file" ]; then - echo "리뷰·작성자 응답을 기다리는 PR 없음 — 통과" - exit 0 - fi - - debt=$(cat "$debt_file") - author_debt=$(cat "$author_debt_file") - review_count=$(wc -l < "$debt_file" | tr -d ' ') - author_count=$(wc -l < "$author_debt_file" | tr -d ' ') - count=$(( review_count + author_count )) - today=$(date -u +%s) - - { - echo "## 🚧 리뷰 적체 가드 — 이 PR 을 닫습니다" - echo - if [ "$review_count" -gt 0 ]; then - echo "### 팀 리뷰 응답 대기 · ${review_count}건" - echo - echo "| PR | 열린 날 | 경과 | 제목 |" - echo "|---|---|---|---|" - while IFS=$'\t' read -r n created title; do - [ -n "$n" ] || continue - opened=$(date -u -d "$created" +%s 2>/dev/null || echo "$today") - days=$(( (today - opened) / 86400 )) - echo "| #$n | $created | ${days}일 | $title |" - done <<< "$debt" - echo - fi - if [ "$author_count" -gt 0 ]; then - echo "### 작성자 응답 대기 · ${author_count}건" - echo - echo "| PR | 열린 날 | 변경요청자 | 제목 |" - echo "|---|---|---|---|" - while IFS=$'\t' read -r n created reviewer title; do - [ -n "$n" ] || continue - echo "| #$n | $created | @$reviewer | $title |" - done <<< "$author_debt" - echo - fi - echo "이 저장소는 팀 리뷰 응답 또는 자기가 담당하는 PR 의 변경요청 대응을 기다리는 PR 이 1건이라도 남아 있으면 새 PR 을 열 수 없습니다 (#1136, #1679)." - echo - echo "PR 의 담당자는 올린 사람이 아니라 **어사인된 사람**입니다(어사인이 비어 있으면 작성자, #1974)." - echo "각 PR 에 필요한 리뷰는 **한 건**입니다 — 누구든 먼저 판정을 내면 그 PR 은 모두의 목록에서 빠집니다. 승인 뒤 변경을 다시 볼 의무는 없고, **변경요청도 담당자가 같은 리뷰어에게 재요청한 뒤 실질 커밋·응답·본문 편집을 남긴 경우에만** 다시 리뷰어의 몫이 됩니다." - echo "담당하는 PR 이 **최신 변경요청 뒤 실질 커밋·응답·본문 편집 없이** 남아 있으면 담당자의 몫입니다. 조치를 남기면 다음 검사에서 작성자 빚이 해소됩니다." - echo - echo "### 되살리는 법" - echo - echo "위 PR 에 필요한 리뷰나 작성자 조치를 끝낸 뒤 이 PR 을 다시 여세요. **브랜치는 지워지지 않았고 커밋도 그대로입니다.**" - echo - echo '```bash' - echo "gh pr reopen $PR_NUMBER" - echo '```' - } > /tmp/debt-comment.md - - echo "리뷰·작성자 미응답 ${count}건 — PR #$PR_NUMBER 를 닫는다" - gh pr close "$PR_NUMBER" --repo "$REPO" --comment "$(cat /tmp/debt-comment.md)" - - # 실패가 «가드가 사실을 확인하고 거절함» 인지 «한도 소진» 인지 가른다. 구분이 없으면 - # 빨간 체크를 보고도 원인을 로그까지 들어가야 안다 (#1465). - - name: Classify a failed gate as rate limit exhaustion - if: failure() - run: | - # default branch 사본에 이 스크립트가 아직 없는 동안(= 스크립트를 심는 이 PR 자신)에는 - # 건너뛴다. 머지 뒤에는 항상 존재하므로 이 경로는 부트스트랩 한 번에만 쓰인다. - if [ ! -f .github/scripts/ensure-api-quota.mjs ]; then - echo "::notice::quota 스크립트가 default branch 에 아직 없습니다 — 건너뜁니다" - exit 0 - fi - node .github/scripts/ensure-api-quota.mjs classify --resource core - node .github/scripts/ensure-api-quota.mjs classify --resource graphql - env: - GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/review-request-all.yml b/.github/workflows/review-request-all.yml deleted file mode 100644 index cbcba2ebe..000000000 --- a/.github/workflows/review-request-all.yml +++ /dev/null @@ -1,206 +0,0 @@ -# 리뷰어 자동 요청 (#1136) -# -# 리뷰 요청을 손으로 걸다 보면 빠뜨린다. PR 이 열리면 담당자를 뺀 팀 전원에게 -# 자동으로 요청한다. GitHub 은 자기 PR 의 작성자를 리뷰어로 받지 않으므로 -# 목록에 전원을 적어도 작성자는 자동으로 빠진다. 담당자(어사인)는 GitHub 이 막지 -# 않으므로 여기서 직접 뺀다 — PR 의 공은 올린 사람이 아니라 어사인된 사람에게 -# 있다(#1974 결정, #1980). 어사인이 비어 있는 동안만 작성자가 담당자다. -# -# 이 워크플로는 review-debt-guard 와 한 세트다. 요청이 자동으로 걸려야 -# "리뷰를 안 하면 새 PR 을 못 연다" 가 성립한다 — 요청을 손으로 걸던 때는 -# 아예 걸지 않는 것으로 가드를 통과할 수 있었다. -# -# 같은 구멍이 «변경요청 뒤» 에 그대로 남아 있었다(#1450). GitHub 은 변경요청 -# 리뷰를 제출하는 순간 그 리뷰어의 요청을 떼어 내는데, 가드는 그 리뷰어에게 -# 요청이 다시 걸린 경우에만 빚으로 센다. 되살리는 일이 작성자 손에 맡겨져 있어 -# 8/29 실측에서 반영까지 끝난 PR 7건이 전원 «빚 아님» 이었고, 그중 하나는 -# dependabot 이 작성자라 되살릴 주체 자체가 없었다. 반영 커밋이 올라오거나 -# 작성자가 PR 본문을 고치면 재요청도 자동으로 건다. -# -# 옛 Review Assign(.github/workflows/PRassign.yml)을 대체한다. 그쪽은 develop -# 에서 파일만 지워졌을 뿐 워크플로 등록이 살아 있어, 파일이 남은 옛 브랜치에서 -# 갈라진 PR 에는 지금도 구버전이 실행된다. 이 워크플로를 들이면서 함께 disable -# 해야 중복 요청이 나지 않는다. -name: review-request-all - -on: - pull_request: - types: [opened, ready_for_review, reopened, synchronize, edited] - -permissions: - pull-requests: write - -jobs: - request: - # draft 에는 걸지 않는다. 리뷰할 수 없는 PR 로 남의 미처리 건수를 늘리면 - # review-debt-guard 가 그 사람의 새 PR 을 닫아 버린다. - if: >- - github.event.pull_request.state == 'open' && - github.event.pull_request.draft == false && - github.event.action != 'synchronize' && - github.event.action != 'edited' - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - 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 에는 나머지 둘에게 그대로 요청이 걸리고, - # 스스로 리뷰하는 것도 막히지 않는다. 빠진 것은 자동 요청뿐이다. - TEAM: 1hyok Sadturtleman - run: | - set -euo pipefail - - case "$AUTHOR" in - *'[bot]'|dependabot|github-actions) echo "봇 PR — 건너뜀"; exit 0 ;; - esac - - # 외부 기여자 PR 은 토큰이 read-only 라 요청을 걸 수 없다. - case "$ASSOCIATION" in - OWNER|MEMBER|COLLABORATOR) ;; - *) echo "외부 기여자($ASSOCIATION) — 건너뜀"; exit 0 ;; - esac - - # 이미 걸려 있는 요청은 다시 걸지 않는다. reopened·ready_for_review 가 - # 여러 번 오갈 때 같은 사람에게 요청이 반복되는 것을 막는다. - 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_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 "추가할 리뷰어 없음 (이미 요청됨 또는 담당자뿐)" - exit 0 - fi - - echo "리뷰 요청: $targets" - gh pr edit "$PR_NUMBER" --repo "$REPO" --add-reviewer "$targets" - - # 변경요청 뒤 반영 커밋이 올라오거나 담당자가 PR 본문을 고치면 그 리뷰어에게 - # 다시 요청을 건다(#1450, #1700). `edited` 전체를 받되 제목 수정·관리자 대리 수정은 - # 담당자 조치가 아니므로 body 변경과 sender=담당자 를 함께 요구한다. 담당자는 - # 어사인이고, 어사인이 비어 있으면 작성자다(#1974). - # - # 판정 기준은 review-debt-guard 와 같아야 한다 — 여기서 거는 요청이 곧 그쪽의 - # 빚 성립 조건이므로, 한쪽만 «PR 전체의 최신 판정» 을 보면 자동 재요청이 빚으로 - # 이어지지 않거나 이미 승인된 PR 을 되살린다. - rerequest: - if: >- - github.event.pull_request.state == 'open' && - github.event.pull_request.draft == false && - ( - github.event.action == 'synchronize' || - ( - github.event.action == 'edited' && - github.event.changes.body != null && - ( - 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 - timeout-minutes: 5 - steps: - - name: 변경요청 리뷰어에게 재요청 - env: - GH_TOKEN: ${{ github.token }} - 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 }} - run: | - set -euo pipefail - - # 봇 PR 은 건너뛴다. dependabot 이 연 PR 에서는 GITHUB_TOKEN 이 read-only 라 - # 요청을 걸지 못한다 — 시도하면 403 으로 죽을 뿐이다. 봇 PR 의 변경요청이 - # 빚이 되지 못하는 구멍은 이 워크플로로는 못 메운다(#1450 에 남겨 둔다). - case "$AUTHOR" in - *'[bot]'|dependabot|github-actions) echo "봇 PR — 건너뜀"; exit 0 ;; - esac - - # fork PR 도 토큰이 read-only 다. 팀원 여부를 author_association 으로 가르지 - # 않는 이유는 review-debt-guard 주석에 있다 — 비공개 멤버가 CONTRIBUTOR 로 - # 내려와 팀원 대부분이 조용히 빠진다. 실제 제약인 head 저장소로 가른다. - if [ "$HEAD_REPO" != "$REPO" ]; then - echo "fork PR($HEAD_REPO) — 토큰이 read-only 라 건너뜀" - exit 0 - fi - if [ "$ACTION" = "edited" ] && [ -z "$EVENT_AT" ]; then - echo "::error::본문 편집 이벤트 시각이 없다 — 과거 편집을 최신 변경요청에 귀속하지 않도록 재실행할 것" - exit 1 - fi - - # PR 전체의 최신 판정만 본다. 승인 뒤 올라온 커밋은 재요청 대상이 아니다. - # --paginate 는 페이지마다 jq 를 돌리므로 정렬은 모아서 한 번에 한다. - if ! decisions=$(gh api "repos/$REPO/pulls/$PR_NUMBER/reviews" --paginate \ - --jq '.[] | select(.state == "APPROVED" or .state == "CHANGES_REQUESTED") - | {u: .user.login, s: .state, t: .submitted_at}'); then - echo "::error::리뷰 조회에 실패했다 — 재리뷰 요청을 누락시키지 않도록 재실행할 것" - exit 1 - fi - if ! blocked=$(printf '%s\n' "$decisions" \ - | jq -rs --arg action "$ACTION" --arg event_at "$EVENT_AT" ' - sort_by(.t) | last // empty - | select(.s == "CHANGES_REQUESTED") - | select($action != "edited" or .t < $event_at) - | [.u, .t] | @tsv - '); then - echo "::error::최신 리뷰 판정에 실패했다 — 재리뷰 요청을 누락시키지 않도록 재실행할 것" - exit 1 - fi - - if [ -z "$blocked" ]; then - echo "최신 판정이 변경요청이 아님 — 재요청할 대상 없음" - exit 0 - fi - IFS=$'\t' read -r blocked blocked_at <<< "$blocked" - - # 자기 자신은 리뷰어가 될 수 없다(422). 작성자가 자기 PR 에 변경요청을 낼 - # 수는 없지만, 소유권이 옮겨진 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(" ")') - current_lc=$(printf '%s' "$current" | tr '[:upper:]' '[:lower:]') - case " $current_lc " in - *" $blocked_lc "*) echo "@$blocked 요청이 이미 걸려 있음 — 건너뜀"; exit 0 ;; - esac - - echo "변경요청($blocked_at) 뒤 담당자 조치($ACTION) — @$blocked 에게 재리뷰 요청" - gh pr edit "$PR_NUMBER" --repo "$REPO" --add-reviewer "$blocked" diff --git a/.github/workflows/viewed-auto-merge.yml b/.github/workflows/viewed-auto-merge.yml index 29ef82f6f..0910b46a6 100644 --- a/.github/workflows/viewed-auto-merge.yml +++ b/.github/workflows/viewed-auto-merge.yml @@ -2,17 +2,16 @@ # # 적용 범위는 VIEWED_BY 본인이 작성한 PR 이다(#2270). 팀원·봇 PR 은 예약도 해제도 하지 않는다. # -# VIEWED_BY 가 PR 의 모든 파일을 Viewed 로 체크했고 PR 이 승인 상태면 «Merge when ready» 를 대신 건다. -# 머지는 GitHub 가 승인 수·required check·merge queue 를 모두 통과시킨 뒤에만 하므로 CI 가 빨갛거나 -# 변경요청이 남으면 머지되지 않는다. +# VIEWED_BY 가 PR 의 모든 파일을 Viewed 로 체크했고 PR 에 승인 대기·변경 요청이 없으면 «Merge when ready» 를 +# 대신 건다. 승인 룰셋이 승인 0건이면 reviewDecision 이 비어 있어도 대상이다. 머지는 GitHub 가 룰셋·required +# check·merge queue 를 모두 통과시킨 뒤에만 하므로 CI 가 빨가면 머지되지 않는다. # # 반대 방향도 매 실행이 맞춘다(#2252). 예약됐거나 merge queue 에 있는 VIEWED_BY 본인 PR 중 VIEWED_BY 기준 Viewed 가 다 차지 # 않은 것은 예약을 끄고 큐에서 뺀다. 새 push 로 바뀐 파일은 DISMISSED 가 되고 룰셋은 push 로 승인을 지우지 # 않으므로, 이 단계가 없으면 아무도 다시 보지 않은 커밋이 머지된다. 누가 건 예약인지는 따지지 않는다. 예약을 # 멈추려면 파일 하나의 Viewed 를 풀면 되고, 다시 전부 Viewed 가 되면 다음 실행이 다시 건다. # -# Viewed 체크는 webhook 이벤트가 없어 주기 실행으로 훑는다. 승인 직후에도 리컨사일 완료 신호로 한 번 -# 돈다. viewerViewedState 는 토큰 주인 본인 것만 보이므로 VIEWED_BY 의 읽기 전용 토큰을 VIEWED_TOKEN +# Viewed 체크는 webhook 이벤트가 없어 주기 실행으로 훑는다. viewerViewedState 는 토큰 주인 본인 것만 보이므로 VIEWED_BY 의 읽기 전용 토큰을 VIEWED_TOKEN # 시크릿으로 둔다. 시크릿이 없으면 아무것도 하지 않는다. # # 예약 쓰기는 MERGE_QUEUE_TOKEN(1hyok 의 fine-grained PAT)이 한다. GITHUB_TOKEN 으로 예약하거나 큐에 넣으면 GitHub 이 @@ -23,9 +22,6 @@ name: Viewed Auto Merge on: schedule: - cron: '*/10 * * * *' - workflow_run: - workflows: [Latest Review Decision Reconcile] - types: [completed] workflow_dispatch: concurrency: diff --git a/README.md b/README.md index d308fb7f6..19453f492 100644 --- a/README.md +++ b/README.md @@ -183,14 +183,8 @@ Kover는 임의의 절대 커버리지 목표를 강제하지 않는다. 정확 정기·기본 브랜치 수동 검증은 PR critical path 밖에서 minSdk API 26과 targetSdk API 36 경계 smoke도 실행한다. 문서 링크는 PR에서 로컬 경로·heading anchor를 검사하고 외부 URL은 주간 실행으로 분리하며, release AAB/R8 preflight는 `develop`에서 주 2회 조기 검증한다. -## 코드 리뷰 - -- PR의 담당자는 올린 사람이 아니라 **어사인된 사람**이다. 어사인이 비어 있으면 [자동 어사인 workflow](.github/workflows/pr-assign-author.yml)가 작성자를 걸고, 사람이 담당자를 바꾼 것은 되돌리지 않는다. 변경요청 대응·리뷰 요청 제외·아래 적체 가드의 «자기 PR» 판정은 모두 이 담당자 기준이다. -- Draft가 아닌 내부 팀원 PR이 열리거나 리뷰 가능 상태가 되면 [자동 요청 workflow](.github/workflows/review-request-all.yml)가 담당자를 제외한 리뷰 담당 팀원(`TEAM`)에게 리뷰를 요청한다. 요청 인원 수와 필수 승인 수는 같은 뜻이 아니다. -- 현재 `develop`·`main` 머지에는 **승인 1건**이 필요하다. `main`은 모든 리뷰 스레드 해결도 필요하다. -- `develop` 대상 PR은 승인 상태에서 1hyok가 모든 파일을 Viewed로 체크하면 [Viewed 자동 머지 workflow](.github/workflows/viewed-auto-merge.yml)가 10분 안팎으로 자동 머지(Merge when ready)를 예약한다. Viewed 체크에는 이벤트가 없어 주기 실행으로 훑고, 본인 체크만 조회되므로 1hyok의 읽기 전용 토큰을 `VIEWED_TOKEN` 시크릿으로 둔다. 필수 검사가 통과해야 merge queue로 들어가며, 예약을 멈추려면 파일 하나의 Viewed를 풀고 PR 화면에서 예약을 끈다. -- 리뷰 결과는 GitHub의 `APPROVED`·`CHANGES_REQUESTED`·일반 코멘트로 표현한다. 별도 RCA prefix나 12시간 SLA는 두지 않는다. -- [리뷰 적체 가드](.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건과 필수 체크는 그대로 요구된다. +## 머지 + +- PR의 담당자는 올린 사람이 아니라 **어사인된 사람**이다. 어사인이 비어 있으면 [자동 어사인 workflow](.github/workflows/pr-assign-author.yml)가 작성자를 건다. 사람이 담당자를 바꾼 것은 되돌리지 않는다. +- `develop`·`main` 머지에 리뷰 승인은 필요 없다. PR 경유, 필수 검사, `develop`의 merge queue는 그대로 요구된다. +- `develop` 대상 PR은 1hyok가 모든 파일을 Viewed로 체크하면 [Viewed 자동 머지 workflow](.github/workflows/viewed-auto-merge.yml)가 10분 안팎으로 자동 머지(Merge when ready)를 예약한다. 변경요청이 남은 PR은 예약하지 않는다. Viewed 체크에는 이벤트가 없어 주기 실행으로 훑고, 본인 체크만 조회되므로 1hyok의 읽기 전용 토큰을 `VIEWED_TOKEN` 시크릿으로 둔다. 필수 검사가 통과해야 merge queue로 들어가며, 예약을 멈추려면 파일 하나의 Viewed를 풀고 PR 화면에서 예약을 끈다.