diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 0037c93efb..0000000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,2 +0,0 @@ -# Workflow definitions and their policy scripts are security-sensitive. -/.github/ @1hyok diff --git a/.github/scripts/awaiting-author-policy.test.mjs b/.github/scripts/awaiting-author-policy.test.mjs deleted file mode 100644 index 1e4b7103e2..0000000000 --- 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 954394d4da..0000000000 --- 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 12e062a644..0000000000 --- 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/dependabot-pr-gate.mjs b/.github/scripts/dependabot-pr-gate.mjs index 58b0e7a7b0..8fd2e24667 100644 --- a/.github/scripts/dependabot-pr-gate.mjs +++ b/.github/scripts/dependabot-pr-gate.mjs @@ -2,12 +2,22 @@ // // 봇은 이슈를 만들지 못하고 본문에 CI Test Plan 도 쓰지 않아, 열리는 순간 // Repository Quality 와 기기 잡이 구조적으로 실패한다(#1397·#1699·#2106). 사람이 매번 -// 손으로 하던 세 가지 — 대표 이슈 신설, 제목 끝 `(#N)`, 본문의 `Refs #N` + CI Test Plan — -// 를 여기서 한다. mode 는 사람이 정하지 않는다. `ci-test-plan.mjs` 가 변경 파일로 판정한 +// 손으로 하던 세 가지 — 대표 이슈 신설, 제목 끝 `(#N)`, 본문의 `Closes #N` + CI Test Plan — +// 를 여기서 한다. 손으로 할 때는 `Refs` 로 걸고 머지 뒤 이슈를 손으로 닫았는데(#1397·#2111), 게이트가 +// PR 마다 이슈를 새로 만들므로 `Closes` 로 걸어 머지가 이슈를 닫게 한다(#2274). mode 는 사람이 정하지 않는다. `ci-test-plan.mjs` 가 변경 파일로 판정한 // 결과를 그대로 쓴다. 게이트 면제는 하지 않는다 — 봇 PR 도 워크플로 파일을 건드린다. // -// 순수 함수(needsGate·buildPlan·buildIssue·applyGate)와 GitHub 호출(main)을 갈라 두어 -// 정책은 dependabot-pr-gate.test.mjs 가 잠근다. +// 게이트는 GITHUB_TOKEN 으로 제목·본문을 고치므로 그 `edited` 는 다른 워크플로를 깨우지 못한다. +// 같은 순간 열린 PR Validation·Managed Device 는 opened 시점 페이로드를 읽어 red 로 남았다(#2212). +// 게이트가 검증을 다시 부르는 길은 막혀 있다. 같은 sha 에 dispatch 로 붙인 초록은 PR 롤업이 옛 +// `pull_request` 실패를 덮지 못하고(#1825), 재실행은 옛 페이로드를 그대로 쓰는 데다 기기 레인의 +// 1회차 인프라 복구를 잃으며, App 토큰은 이 브리지에 금지된 시크릿이 필요하다. 그래서 방향을 +// 뒤집었다. 검증이 `await` 명령으로 게이트가 채울 때까지 live PR 을 기다렸다가 그 값으로 판정한다. +// +// 순수 함수(needsGate·awaitsGate·buildPlan·buildIssue·applyGate)·대기(awaitGate)와 GitHub 호출 +// (fillGate·awaitMain)을 갈라 두어 정책은 dependabot-pr-gate.test.mjs 가 잠근다. + +import { readFile, writeFile } from "node:fs/promises"; import { inspectAndroidTestImpact } from "./ci-test-plan.mjs"; @@ -53,6 +63,59 @@ export function buildPlan(changedPaths) { }; } +/** `await` 가 live PR 을 다시 조회하는 간격. 게이트는 job 시작 뒤 수 초 안에 끝난다(#2138 실측 7초). */ +export const AWAIT_POLL_INTERVAL_MS = 5_000; + +/** + * 검증이 아직 기다려야 하는 PR 인가. dependabot 이 연 열린 PR 인데 제목에 대표 이슈 번호가 없을 때만이다. + * 마커가 아니라 제목으로 가른다. 게이트가 경계 경로에서 멈춰 사람이 손으로 채운 PR 에는 마커가 없는데, + * 그 PR 까지 edited 마다 제한 시간을 꽉 채워 기다리게 하지 않는다. 게이트는 제목과 본문을 한 번의 + * PATCH 로 바꾸므로 제목에 번호가 있으면 본문도 채워져 있다. + */ +export function awaitsGate(pullRequest) { + return ( + pullRequest?.user?.login === DEPENDABOT_LOGIN && + pullRequest?.state === "open" && + !TITLE_ISSUE_PATTERN.test(String(pullRequest?.title ?? "")) + ); +} + +/** + * 게이트가 제목·본문을 채울 때까지 live PR 을 다시 읽는다(#2212). `initial`(이벤트 페이로드의 PR)이 이미 + * 판정할 수 있는 상태면 API 를 부르지 않고 그대로 돌려준다. 게이트가 채운 뒤의 edited·synchronize 는 + * 종전처럼 페이로드만으로 끝난다. 조회가 실패하면 제한 시간 안에서 다시 시도한다. 한 번도 읽지 못한 채 + * 시간이 다하면 마지막 오류를 던지고, 읽은 적이 있으면 마지막 live 상태를 돌려준다. 판정은 호출한 + * 검증의 몫이라, 게이트가 실패했으면 검증이 평소 오류 문구로 실패한다. + */ +export async function awaitGate({ loadPullRequest, initial, timeoutMs, intervalMs = AWAIT_POLL_INTERVAL_MS }) { + if (initial && !awaitsGate(initial)) { + return { pullRequest: initial, polls: 0, timedOut: false }; + } + const deadline = Date.now() + timeoutMs; + let latest; + let lastError; + for (let polls = 1; ; polls += 1) { + try { + const pullRequest = await loadPullRequest(); + if (!awaitsGate(pullRequest)) { + return { pullRequest, polls, timedOut: false }; + } + latest = pullRequest; + lastError = undefined; + } catch (error) { + lastError = error; + } + const remaining = deadline - Date.now(); + if (remaining <= 0) { + if (latest === undefined) { + throw lastError; + } + return { pullRequest: latest, polls, timedOut: true, lastError }; + } + await new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, remaining))); + } +} + /** 대표 이슈. `.github/ISSUE_TEMPLATE/issue.yml` 양식이라 reconcile-issue-metadata 가 어사인·라벨을 맞춘다. */ export function buildIssue({ title, prNumber, changedPaths, plan }) { const summary = String(title ?? "").replace(/^chore\(deps\):\s*/i, "").trim() || `PR #${prNumber}`; @@ -79,7 +142,7 @@ export function buildIssue({ title, prNumber, changedPaths, plan }) { "", `dependabot 이 올린 PR #${prNumber}(${summary})의 대표 이슈다. dependabot-pr-gate 워크플로가 만들었다(#2112).`, "", - "Repository Quality 가 모든 PR 에 제목 대표 이슈 번호와 본문 Refs 를 요구하는데 봇은 이슈를 만들지 못한다. 이 이슈로 연결해 수용한다.", + "Repository Quality 가 모든 PR 에 제목 대표 이슈 번호와 본문 연결을 요구하는데 봇은 이슈를 만들지 못한다. 이 이슈로 연결해 수용하고, PR 본문의 Closes 로 머지 때 함께 닫힌다.", "", `변경 파일 ${files.length}개:`, fileLines, @@ -97,8 +160,12 @@ export function buildIssue({ title, prNumber, changedPaths, plan }) { }; } -/** 제목 끝에 `(#N)`, 본문 맨 위에 `Refs #N` 과 CI Test Plan 을 붙인다. 봇 본문은 그대로 아래 둔다. */ -export function applyGate({ title, body, issueNumber, plan }) { +/** + * 제목 끝에 `(#N)`, 본문 맨 위에 `Closes #N` 과 CI Test Plan 을 붙인다. 봇 본문은 그대로 아래 둔다. + * 게이트가 만든 이슈는 이 PR 하나의 대표 이슈라 머지가 닫게 한다(#2274). 제목에 사람이 이미 붙인 + * 번호를 재사용할 때(`reusedIssue`)는 여러 봇 PR 이 공유하는 이슈일 수 있어 `Refs` 로 둔다(#1748). + */ +export function applyGate({ title, body, issueNumber, plan, reusedIssue = false }) { const baseTitle = String(title ?? "").replace(TITLE_ISSUE_PATTERN, "").trim(); const planJson = JSON.stringify( { androidTest: { mode: plan.mode, reason: plan.reason, tests: [] } }, @@ -108,7 +175,7 @@ export function applyGate({ title, body, issueNumber, plan }) { const original = String(body ?? "").replace(GATE_MARKER, "").trim(); const nextBody = [ GATE_MARKER, - `Refs #${issueNumber}`, + `${reusedIssue ? "Refs" : "Closes"} #${issueNumber}`, "", "## CI Test Plan", "", @@ -153,7 +220,7 @@ async function listChangedPaths(apiUrl, repository, prNumber, token) { return paths; } -async function main() { +async function fillGate() { const token = process.env.GITHUB_TOKEN; const repository = process.env.GITHUB_REPOSITORY; const prNumber = Number(process.env.PR_NUMBER); @@ -180,9 +247,11 @@ async function main() { const changedPaths = await listChangedPaths(apiUrl, repository, prNumber, token); const plan = buildPlan(changedPaths); - // 제목에 이슈 번호가 이미 있으면(사람이 붙였거나 앞선 실행이 본문 갱신 전에 끊겼거나) 재사용한다. + // 제목에 이슈 번호가 이미 있으면 사람이 붙인 것이다(게이트는 제목·본문을 한 번의 PATCH 로 바꾼다). 그 번호를 + // 재사용하고, 여러 봇 PR 이 공유하는 이슈일 수 있어 Refs 로 건다(#1748). let issueNumber = Number(TITLE_ISSUE_PATTERN.exec(pullRequest.title ?? "")?.[1]); - if (!issueNumber) { + const reusedIssue = Boolean(issueNumber); + if (!reusedIssue) { const issue = buildIssue({ title: pullRequest.title, prNumber, changedPaths, plan }); const created = await github(`${apiUrl}/repos/${repository}/issues`, token, { method: "POST", @@ -192,16 +261,61 @@ async function main() { console.log(`대표 이슈 #${issueNumber} 생성`); } - const next = applyGate({ title: pullRequest.title, body: pullRequest.body, issueNumber, plan }); + const next = applyGate({ title: pullRequest.title, body: pullRequest.body, issueNumber, plan, reusedIssue }); await github(`${apiUrl}/repos/${repository}/pulls/${prNumber}`, token, { method: "PATCH", body: JSON.stringify(next), }); - console.log(`#${prNumber} 제목·본문 갱신: Refs #${issueNumber}, androidTest mode=${plan.mode}`); + console.log(`#${prNumber} 제목·본문 갱신: ${reusedIssue ? "Refs" : "Closes"} #${issueNumber}, androidTest mode=${plan.mode}`); +} + +/** + * `await <출력 경로>`: dependabot PR 의 `pull_request` 검증이 부른다. 게이트를 기다린 뒤 PR JSON 을 출력 + * 경로에 쓴다. 이벤트 페이로드(GITHUB_EVENT_PATH)의 PR 이 이미 판정할 수 있는 상태면 그것을 그대로 쓴다. + * 제한 시간을 넘겨도 실패하지 않고 경고만 남긴다. 판정은 뒤따르는 검증이 한다. + */ +async function awaitMain(outputPath) { + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + const repository = process.env.GITHUB_REPOSITORY; + const prNumber = Number(process.env.PR_NUMBER); + const timeoutSeconds = Number(process.env.AWAIT_TIMEOUT_SECONDS); + const apiUrl = process.env.GITHUB_API_URL ?? "https://api.github.com"; + if (!outputPath || !token || !repository || !Number.isInteger(prNumber) || prNumber <= 0) { + throw new Error("await <출력 경로> 와 GITHUB_TOKEN(또는 GH_TOKEN), GITHUB_REPOSITORY, PR_NUMBER 가 필요합니다"); + } + if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 0) { + throw new Error(`AWAIT_TIMEOUT_SECONDS 는 0 이상의 정수여야 합니다: ${process.env.AWAIT_TIMEOUT_SECONDS}`); + } + + const eventPullRequest = process.env.GITHUB_EVENT_PATH + ? JSON.parse(await readFile(process.env.GITHUB_EVENT_PATH, "utf8")).pull_request + : undefined; + const { pullRequest, polls, timedOut, lastError } = await awaitGate({ + loadPullRequest: () => github(`${apiUrl}/repos/${repository}/pulls/${prNumber}`, token), + initial: eventPullRequest?.number === prNumber ? eventPullRequest : undefined, + timeoutMs: timeoutSeconds * 1000, + }); + await writeFile(outputPath, JSON.stringify(pullRequest)); + if (timedOut) { + console.log( + `::warning::dependabot-pr-gate 가 ${timeoutSeconds}초 안에 #${prNumber} 의 제목·본문을 채우지 않았습니다. ` + + "그 워크플로 실행을 확인하세요. 지금 live 상태로 판정합니다." + + (lastError ? ` 마지막 조회 오류: ${lastError.message}` : ""), + ); + } else if (polls > 1) { + console.log(`#${prNumber} 게이트가 채운 제목·본문을 ${polls}번째 조회에서 확인`); + } } if (import.meta.url === `file://${process.argv[1]}`) { - main().catch((error) => { + const [command, ...rest] = process.argv.slice(2); + const run = + command === undefined + ? fillGate + : command === "await" + ? () => awaitMain(rest[0]) + : () => Promise.reject(new Error(`알 수 없는 명령: ${command}. 인자 없이(채우기) 또는 await 만 받습니다`)); + run().catch((error) => { console.error(`::error::${error.message}`); process.exitCode = 1; }); diff --git a/.github/scripts/dependabot-pr-gate.test.mjs b/.github/scripts/dependabot-pr-gate.test.mjs index 1a1aa9c31b..604e0a5937 100644 --- a/.github/scripts/dependabot-pr-gate.test.mjs +++ b/.github/scripts/dependabot-pr-gate.test.mjs @@ -1,6 +1,12 @@ import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { inspectCiTestPlan } from "./ci-test-plan.mjs"; import { @@ -9,10 +15,16 @@ import { ISSUE_ASSIGNEES, ISSUE_LABELS, applyGate, + awaitGate, + awaitsGate, buildIssue, buildPlan, needsGate, } from "./dependabot-pr-gate.mjs"; +import { validatePullRequestIssueLink } from "./validate-pr-issue-link.mjs"; + +const GATE_SCRIPT = fileURLToPath(new URL("./dependabot-pr-gate.mjs", import.meta.url)); +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const BOT_TITLE = "chore(deps): bump the github-actions-updates group with 3 updates"; const BOT_BODY = "Bumps the github-actions-updates group with 3 updates: actions/setup-java ..."; @@ -63,13 +75,13 @@ test("대표 이슈는 issue.yml 양식이고 platform maintenance 로 1hyok 에 assert.match(issue.body, /### 참고/); }); -test("제목은 (#N) 으로 정확히 한 번 끝나고 본문은 Refs 와 유효한 CI Test Plan 으로 시작한다", () => { +test("제목은 (#N) 으로 정확히 한 번 끝나고 본문은 Closes 와 유효한 CI Test Plan 으로 시작한다", () => { const plan = buildPlan([".github/workflows/android-managed-device.yml"]); const next = applyGate({ title: BOT_TITLE, body: BOT_BODY, issueNumber: 2111, plan }); assert.equal(next.title, `${BOT_TITLE} (#2111)`); assert.equal((next.title.match(/\(#\d+\)/g) ?? []).length, 1); - assert.ok(next.body.startsWith(`${GATE_MARKER}\nRefs #2111\n`)); + assert.ok(next.body.startsWith(`${GATE_MARKER}\nCloses #2111\n`), "게이트가 만든 이슈는 머지가 닫는다 (#2274)"); assert.ok(next.body.endsWith(BOT_BODY), "봇 본문은 그대로 아래에 남는다"); assert.equal(needsGate(next), false, "한 번 적용한 결과는 다시 대상이 아니다"); @@ -78,12 +90,34 @@ test("제목은 (#N) 으로 정확히 한 번 끝나고 본문은 Refs 와 유 assert.equal(inspected.plan?.androidTest?.mode ?? inspected.androidTest?.mode, "full"); }); +test("게이트가 채운 봇 PR 은 Repository Quality 의 대표 이슈 연결 검증을 통과한다", async () => { + const plan = buildPlan([".github/workflows/codeql.yml"]); + for (const reusedIssue of [false, true]) { + const next = applyGate({ title: BOT_TITLE, body: BOT_BODY, issueNumber: 2111, plan, reusedIssue }); + const result = await validatePullRequestIssueLink({ + pullRequest: { number: 2106, ...next, user: { login: DEPENDABOT_LOGIN, type: "Bot" }, labels: [] }, + repository: "Afternote/Afternote-FE", + loadIssue: async (number) => ({ number, state: "open", assignees: [{ login: "1hyok" }] }), + }); + assert.deepEqual(result, { issues: [2111], rejected: [] }, `reusedIssue=${reusedIssue}`); + } +}); + +test("제목에 사람이 붙인 번호를 재사용하면 공유 이슈일 수 있어 Refs 로 건다 (#1748)", () => { + const plan = buildPlan([".github/workflows/codeql.yml"]); + const next = applyGate({ title: `${BOT_TITLE} (#1397)`, body: BOT_BODY, issueNumber: 1397, plan, reusedIssue: true }); + + assert.equal(next.title, `${BOT_TITLE} (#1397)`); + assert.ok(next.body.startsWith(`${GATE_MARKER}\nRefs #1397\n`)); + assert.doesNotMatch(next.body, /Closes #1397/); +}); + test("이미 (#N) 이 붙은 제목에 다시 적용해도 번호가 겹치지 않는다", () => { const plan = buildPlan([".github/workflows/codeql.yml"]); const once = applyGate({ title: BOT_TITLE, body: BOT_BODY, issueNumber: 2111, plan }); const twice = applyGate({ title: once.title, body: once.body, issueNumber: 2111, plan }); assert.equal(twice.title, once.title); - assert.equal((twice.body.match(/Refs #2111/g) ?? []).length, 2, "본문 재적용은 앞선 머리말을 봇 본문으로 취급한다"); + assert.equal((twice.body.match(/Closes #2111/g) ?? []).length, 2, "본문 재적용은 앞선 머리말을 봇 본문으로 취급한다"); assert.equal((twice.body.match(new RegExp(GATE_MARKER.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"), "g")) ?? []).length, 1); }); @@ -100,5 +134,264 @@ test("워크플로는 봇 PR 에만 돌고 PR 코드를 체크아웃하지 않 assert.match(source, /^permissions: \{\}/m); assert.match(source, /issues: write/); assert.match(source, /pull-requests: write/); - assert.match(source, /run: node \.github\/scripts\/dependabot-pr-gate\.mjs/); + assert.match(source, /run: node \.github\/scripts\/dependabot-pr-gate\.mjs$/m, "인자 없이 불러야 채우기다"); +}); + +const FILLED = { + user: { login: DEPENDABOT_LOGIN }, + state: "open", + title: `${BOT_TITLE} (#2139)`, + body: `${GATE_MARKER}\nCloses #2139\n\n${BOT_BODY}`, + head: { sha: "a".repeat(40) }, + changed_files: 3, +}; +const UNFILLED = { ...FILLED, title: BOT_TITLE, body: BOT_BODY }; + +test("Closes 로 바꾸기 전 게이트가 Refs 로 채운 PR 도 처리된 PR 이다 (#2274)", () => { + const legacy = { ...FILLED, body: `${GATE_MARKER}\nRefs #2139\n\n${BOT_BODY}` }; + assert.equal(needsGate(legacy), false, "열린 옛 봇 PR 을 다시 고치지 않는다"); + assert.equal(awaitsGate(legacy), false, "검증도 옛 본문을 기다리지 않는다"); +}); + +function sequence(...pullRequests) { + let calls = 0; + return { + load: async () => pullRequests[Math.min(calls++, pullRequests.length - 1)], + calls: () => calls, + }; +} + +test("await 는 게이트가 채운 뒤의 live PR 을 돌려준다 (#2212)", async () => { + const live = sequence(UNFILLED, UNFILLED, FILLED); + const result = await awaitGate({ loadPullRequest: live.load, timeoutMs: 5_000, intervalMs: 5 }); + + assert.equal(result.timedOut, false); + assert.equal(result.polls, 3); + assert.equal(result.pullRequest, FILLED); + assert.equal(needsGate(result.pullRequest), false); +}); + +test("await 는 기다릴 이유가 없으면 첫 조회로 끝난다", async () => { + const handFilled = { ...UNFILLED, title: `${BOT_TITLE} (#2139)`, body: "Refs #2139\n\n## CI Test Plan\n..." }; + assert.equal(needsGate(handFilled), true, "마커가 없어 게이트는 다시 채우려 한다"); + assert.equal(awaitsGate(handFilled), false, "검증은 사람이 채운 PR 을 기다리지 않는다"); + for (const pullRequest of [ + FILLED, + handFilled, + { ...UNFILLED, user: { login: "1hyok" } }, + { ...UNFILLED, state: "closed" }, + ]) { + const live = sequence(pullRequest); + const result = await awaitGate({ loadPullRequest: live.load, timeoutMs: 5_000, intervalMs: 5 }); + assert.equal(live.calls(), 1, JSON.stringify(pullRequest.user)); + assert.equal(result.timedOut, false); + assert.equal(result.pullRequest, pullRequest); + } +}); + +test("await 는 페이로드가 이미 판정할 수 있으면 API 를 부르지 않는다", async () => { + const live = sequence(UNFILLED); + const ready = await awaitGate({ loadPullRequest: live.load, initial: FILLED, timeoutMs: 5_000, intervalMs: 5 }); + assert.equal(live.calls(), 0, "게이트가 채운 뒤의 edited·synchronize 는 종전처럼 페이로드로 끝난다"); + assert.equal(ready.pullRequest, FILLED); + assert.equal(ready.polls, 0); + + const stale = sequence(UNFILLED, FILLED); + const waited = await awaitGate({ loadPullRequest: stale.load, initial: UNFILLED, timeoutMs: 5_000, intervalMs: 5 }); + assert.equal(stale.calls(), 2, "opened 페이로드는 옛 값이라 live 를 기다린다"); + assert.equal(waited.pullRequest, FILLED); +}); + +test("await 는 조회 오류를 제한 시간 안에서 다시 시도하고, 한 번도 못 읽으면 오류를 던진다", async () => { + let calls = 0; + const flaky = async () => { + calls += 1; + if (calls === 1) throw new Error("GET pulls -> 502"); + return calls === 2 ? UNFILLED : FILLED; + }; + const recovered = await awaitGate({ loadPullRequest: flaky, timeoutMs: 5_000, intervalMs: 5 }); + assert.equal(recovered.timedOut, false); + assert.equal(recovered.pullRequest, FILLED); + assert.equal(calls, 3); + + const down = async () => { + throw new Error("GET pulls -> 403 rate limit"); + }; + await assert.rejects(awaitGate({ loadPullRequest: down, timeoutMs: 30, intervalMs: 5 }), /403 rate limit/); + + let readOnce = 0; + const thenDown = async () => { + readOnce += 1; + if (readOnce === 1) return UNFILLED; + throw new Error("GET pulls -> 502"); + }; + const partial = await awaitGate({ loadPullRequest: thenDown, timeoutMs: 30, intervalMs: 5 }); + assert.equal(partial.timedOut, true); + assert.equal(partial.pullRequest, UNFILLED, "읽은 적이 있으면 마지막 live 상태를 넘긴다"); + assert.match(partial.lastError.message, /502/); +}); + +test("await 는 제한 시간이 지나면 기다림을 끝내고 마지막 live 상태를 넘긴다", async () => { + const live = sequence(UNFILLED); + const started = Date.now(); + const result = await awaitGate({ loadPullRequest: live.load, timeoutMs: 60, intervalMs: 10 }); + + assert.equal(result.timedOut, true); + assert.equal(result.pullRequest, UNFILLED); + assert.ok(live.calls() >= 2, "제한 시간 안에서는 다시 조회한다"); + assert.ok(Date.now() - started < 1_000, "무기한 대기하지 않는다"); + + const once = sequence(UNFILLED); + const immediate = await awaitGate({ loadPullRequest: once.load, timeoutMs: 0, intervalMs: 10 }); + assert.equal(immediate.timedOut, true); + assert.equal(once.calls(), 1); +}); + +async function runGateCli(args, { env = {}, pullRequest } = {}) { + const requests = []; + const server = createServer((request, response) => { + requests.push(`${request.method} ${request.url}`); + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify(pullRequest ?? {})); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address(); + try { + const result = await promisify(execFile)(process.execPath, [GATE_SCRIPT, ...args], { + env: { + PATH: process.env.PATH, + GITHUB_API_URL: `http://127.0.0.1:${port}`, + GITHUB_REPOSITORY: "Afternote/Afternote-FE", + ...env, + }, + }).then( + ({ stdout, stderr }) => ({ code: 0, stdout, stderr }), + (error) => ({ code: error.code, stdout: error.stdout, stderr: error.stderr }), + ); + return { ...result, requests }; + } finally { + server.close(); + } +} + +test("await 명령은 live PR JSON 을 파일에 쓰고, 제한 시간을 넘겨도 판정을 검증에 넘긴다", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "dependabot-pr-gate-")); + try { + const filledPath = path.join(directory, "filled.json"); + const filled = await runGateCli(["await", filledPath], { + env: { GH_TOKEN: "t", PR_NUMBER: "2138", AWAIT_TIMEOUT_SECONDS: "90" }, + pullRequest: FILLED, + }); + assert.equal(filled.code, 0, filled.stderr); + assert.deepEqual(filled.requests, ["GET /repos/Afternote/Afternote-FE/pulls/2138"]); + assert.deepEqual(JSON.parse(await readFile(filledPath, "utf8")), FILLED); + + const unfilledPath = path.join(directory, "unfilled.json"); + const unfilled = await runGateCli(["await", unfilledPath], { + env: { GH_TOKEN: "t", PR_NUMBER: "2138", AWAIT_TIMEOUT_SECONDS: "0" }, + pullRequest: UNFILLED, + }); + assert.equal(unfilled.code, 0, "시간 초과는 실패가 아니다. 검증이 평소 오류 문구로 판정한다"); + assert.match(unfilled.stdout, /::warning::dependabot-pr-gate 가 0초 안에 #2138/); + assert.deepEqual(JSON.parse(await readFile(unfilledPath, "utf8")), UNFILLED); + + const eventPath = path.join(directory, "event.json"); + await writeFile(eventPath, JSON.stringify({ action: "synchronize", pull_request: { ...FILLED, number: 2138 } })); + const payloadPath = path.join(directory, "payload.json"); + const fromPayload = await runGateCli(["await", payloadPath], { + env: { GH_TOKEN: "t", PR_NUMBER: "2138", AWAIT_TIMEOUT_SECONDS: "90", GITHUB_EVENT_PATH: eventPath }, + pullRequest: UNFILLED, + }); + assert.equal(fromPayload.code, 0, fromPayload.stderr); + assert.deepEqual(fromPayload.requests, [], "채워진 페이로드면 API 를 부르지 않는다"); + assert.deepEqual(JSON.parse(await readFile(payloadPath, "utf8")), { ...FILLED, number: 2138 }); + + const otherPath = path.join(directory, "other.json"); + const otherPullRequest = await runGateCli(["await", otherPath], { + env: { GH_TOKEN: "t", PR_NUMBER: "2140", AWAIT_TIMEOUT_SECONDS: "0", GITHUB_EVENT_PATH: eventPath }, + pullRequest: FILLED, + }); + assert.equal(otherPullRequest.code, 0, otherPullRequest.stderr); + assert.deepEqual(otherPullRequest.requests, ["GET /repos/Afternote/Afternote-FE/pulls/2140"], "다른 PR 의 페이로드는 쓰지 않는다"); + + const missing = await runGateCli(["await", path.join(directory, "x.json")], { + env: { GH_TOKEN: "t", PR_NUMBER: "2138" }, + }); + assert.equal(missing.code, 1); + assert.match(missing.stderr, /AWAIT_TIMEOUT_SECONDS/); + assert.deepEqual(missing.requests, [], "입력이 틀리면 API 를 부르지 않는다"); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("인자 없이 부르면 채우기다: live PR 을 읽고 봇 PR 이 아니면 아무것도 쓰지 않는다", async () => { + const result = await runGateCli([], { + env: { GITHUB_TOKEN: "t", PR_NUMBER: "2138" }, + pullRequest: { user: { login: "1hyok" }, state: "open", title: "x", body: "" }, + }); + assert.equal(result.code, 0, result.stderr); + assert.deepEqual(result.requests, ["GET /repos/Afternote/Afternote-FE/pulls/2138"]); + assert.match(result.stdout, /작성자가 1hyok 이라 건너뜀/); +}); + +test("모르는 명령은 채우기로 떨어지지 않는다", async () => { + const result = await runGateCli(["wait"], { env: { GITHUB_TOKEN: "t", PR_NUMBER: "2138" } }); + assert.equal(result.code, 1); + assert.match(result.stderr, /알 수 없는 명령: wait/); + assert.deepEqual(result.requests, [], "이슈 생성·PR 편집 요청이 나가지 않는다"); +}); + +test("게이트는 검증을 다시 부르지 않고, 두 검증이 dependabot PR 에서 게이트를 기다린다 (#2212)", async () => { + const readWorkflow = (name) => readFile(new URL(`../workflows/${name}`, import.meta.url), "utf8"); + const gate = await readWorkflow("dependabot-pr-gate.yml"); + const repositoryQuality = await readWorkflow("repository-quality.yml"); + const managedDevice = await readWorkflow("android-managed-device.yml"); + const login = escapeRegExp(DEPENDABOT_LOGIN); + + // 같은 sha 의 dispatch 는 PR 롤업의 옛 실패를 못 덮고(#1825), 재실행은 옛 페이로드를 쓴다. + assert.doesNotMatch(gate, /^\s+actions:/m, "게이트에 actions 권한을 주지 않는다"); + + // Repository Quality: pull_request 이면서 봇이 연 PR 만 live PR 을 기다렸다가 그 값으로 판정한다. + assert.match(repositoryQuality, /PR_AUTHOR: \$\{\{ github\.event\.pull_request\.user\.login \}\}/); + assert.match( + repositoryQuality, + new RegExp( + `elif \\[ "\\$GITHUB_EVENT_NAME" = "pull_request" \\] && \\[ "\\$PR_AUTHOR" = "${login}" \\]; then\\n` + + `(?:\\s+#.*\\n)*` + + `\\s+AWAIT_TIMEOUT_SECONDS=(\\d+) node \\.github/scripts/dependabot-pr-gate\\.mjs await "\\$pull_request_file"\\n` + + `\\s+CHANGED_FILES=\\$\\(jq -r '\\.changed_files' "\\$pull_request_file"\\)`, + ), + ); + const awaitSeconds = Number(repositoryQuality.match(/AWAIT_TIMEOUT_SECONDS=(\d+) node/)[1]); + const quotaSeconds = Number(repositoryQuality.match(/ensure-api-quota\.mjs ensure --max-wait (\d+)/)[1]); + const jobMinutes = Number(repositoryQuality.match(/name: Repository Quality\n\s+runs-on: .+\n\s+timeout-minutes: (\d+)/)[1]); + assert.ok( + awaitSeconds + quotaSeconds + 30 <= jobMinutes * 60, + `게이트 대기 ${awaitSeconds}s + quota 대기 ${quotaSeconds}s 가 job timeout ${jobMinutes}분을 넘기면 판정 전에 취소된다`, + ); + + // Managed Device: 판정 재료는 신뢰 정책 사본으로 기다린 live PR 이다. PR 사본 스크립트를 부르지 않는다. + assert.match( + managedDevice, + /install -m 0644 \\\n\s+\.github\/scripts\/dependabot-pr-gate\.mjs \\\n\s+"\$policy_dir\/dependabot-pr-gate\.mjs"\n\s+dependabot_gate=trusted/, + ); + // 신뢰 사본에 대기 명령이 있는지를 이 문자열로 가른다. 스크립트가 선언 모양을 바꾸면 기기 레인 + // 대기가 조용히 꺼지므로, grep 문자열이 스크립트에 실제로 있는지 대조한다. + const probe = managedDevice.match(/grep -q '([^']+)' \.github\/scripts\/dependabot-pr-gate\.mjs/)?.[1]; + assert.ok(probe, "스테이징 판별 grep 이 없다"); + assert.ok((await readFile(GATE_SCRIPT, "utf8")).includes(probe), `dependabot-pr-gate.mjs 에 '${probe}' 가 없다`); + assert.equal((managedDevice.match(/dependabot_gate=unavailable/g) ?? []).length, 2, "도입 PR·bootstrap 은 페이로드를 읽는다"); + assert.match(managedDevice, /EVENT_PR_AUTHOR: \$\{\{ github\.event\.pull_request\.user\.login \}\}/); + assert.match(managedDevice, /DEPENDABOT_GATE: \$\{\{ steps\.policy\.outputs\.dependabot_gate \}\}/); + assert.match( + managedDevice, + new RegExp(`if \\[\\[ "\\$EVENT_PR_AUTHOR" == "${login}" && "\\$DEPENDABOT_GATE" == "trusted" \\]\\]; then`), + ); + assert.match( + managedDevice, + /AWAIT_TIMEOUT_SECONDS=\d+ \\\n\s+node "\$RUNNER_TEMP\/android-test-policy\/dependabot-pr-gate\.mjs" await "\$pull_request_file"/, + ); + assert.match(managedDevice, /if \[\[ "\$live_head_sha" != "\$EVENT_PR_SHA" \]\]; then/); + assert.doesNotMatch(managedDevice, /node \.github\/scripts\/dependabot-pr-gate\.mjs/); }); diff --git a/.github/scripts/ensure-api-quota.mjs b/.github/scripts/ensure-api-quota.mjs index 7cbec400e9..bb21575184 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 8d112266c1..4e5cf578cc 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 new file mode 100644 index 0000000000..55b30a00f4 --- /dev/null +++ b/.github/scripts/find-viewed-auto-merge-targets.mjs @@ -0,0 +1,159 @@ +// Viewed 기반 자동 머지 대상 판정 (#2248) +// +// 지정한 한 사람(VIEWED_BY)이 PR 의 모든 파일을 Viewed 로 체크하면 «Merge when ready» 를 예약할 대상으로 +// 고른다. 리뷰 상태는 보지 않는다(#2277, 리뷰 폐지). 실제 머지는 GitHub 가 required check·승인 수·merge queue 를 통과시킨 뒤에만 +// 한다. +// +// viewerViewedState 는 토큰 주인 본인의 체크만 돌려준다. 그래서 VIEWED_BY 본인의 토큰(VIEWED_TOKEN)으로 +// 읽고, 토큰 주인이 VIEWED_BY 와 다르면 판정하지 않는다. 쓰기(예약)는 워크플로의 GITHUB_TOKEN 이 한다. +// 체크한 뒤 바뀐 파일은 GitHub 가 DISMISSED 로 되돌리므로 새 커밋은 다시 봐야 대상이 된다. +// +// 반대 방향(예약 해제·큐에서 빼기)은 하지 않는다(#2252 폐지). Viewed 가 덜 찬 PR 도 사람이 직접 예약하거나 큐에 넣으면 +// 그대로 머지된다. Viewed 는 자동 예약의 조건일 뿐 머지를 막는 장치가 아니다. +// +// 이 규칙은 VIEWED_BY 개인의 운영 규칙이라 VIEWED_BY 본인이 작성한 PR 에만 적용한다(#2270). 팀원·봇 PR 은 +// 예약하지 않는다. +import { appendFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const PULL_REQUEST_FIELDS = ` + number isDraft headRefOid isInMergeQueue changedFiles + author { login } + autoMergeRequest { enabledAt } + files(first: 100) { pageInfo { hasNextPage endCursor } nodes { path viewerViewedState } } +`; + +/** + * 예약을 판정할 PR 인지 가른다. VIEWED_BY 본인이 작성한 PR 만 대상이다(대소문자 무시). + * 작성자를 알 수 없는 PR(탈퇴 계정 등)은 대상이 아니다. + * + * @param {object} pullRequest GraphQL PullRequest 노드 + * @param {string} viewedBy Viewed 를 판정하는 사람의 로그인 + * @returns {boolean} + */ +export function isAuthoredByViewer(pullRequest, viewedBy) { + const login = pullRequest.author?.login; + return typeof login === "string" && login.toLowerCase() === viewedBy.toLowerCase(); +} + +/** + * @param {object} pullRequest GraphQL PullRequest 노드 + * @param {Array<{ path: string, viewerViewedState: string }>} files PR 의 전체 변경 파일 + * @returns {{ enable: boolean, reason: string }} + */ +export function decideViewedAutoMerge(pullRequest, files) { + if (pullRequest.isDraft) return { enable: false, reason: "Draft PR" }; + if (pullRequest.autoMergeRequest || pullRequest.isInMergeQueue) { + return { enable: false, reason: "이미 자동 머지 예약 또는 merge queue 에 있다" }; + } + // 파일 목록을 다 못 읽었으면 «다 봤다» 고 단정하지 않는다. + if (files.length === 0 || files.length !== pullRequest.changedFiles) { + return { enable: false, reason: `파일 ${files.length}/${pullRequest.changedFiles} 만 읽었다` }; + } + const unviewed = files.filter((file) => file.viewerViewedState !== "VIEWED"); + if (unviewed.length > 0) { + return { enable: false, reason: `Viewed 안 된 파일 ${unviewed.length}/${files.length}` }; + } + return { enable: true, reason: `모든 파일 Viewed — 자동 머지 예약` }; +} + +async function graphql(token, apiUrl, query, variables) { + const response = await fetch(`${apiUrl}/graphql`, { + method: "POST", + headers: { authorization: `bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ query, variables }), + }); + const payload = await response.json(); + if (!response.ok || payload.errors) { + throw new Error(`GraphQL 실패 (${response.status}): ${JSON.stringify(payload.errors ?? payload)}`); + } + return payload.data; +} + +async function readAllFiles(request, owner, name, pullRequest) { + const files = [...pullRequest.files.nodes]; + let { hasNextPage, endCursor } = pullRequest.files.pageInfo; + while (hasNextPage) { + const data = await request( + `query($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + files(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { path viewerViewedState } } + } + } + }`, + { owner, name, number: pullRequest.number, after: endCursor }, + ); + const page = data.repository.pullRequest.files; + files.push(...page.nodes); + ({ hasNextPage, endCursor } = page.pageInfo); + } + return files; +} + +/** + * 열린 PR 마다 예약을 판정한다. VIEWED_BY 본인 PR 이 아니면 파일도 읽지 않고 건너뛴다. + * + * @returns {Promise<{ targets: Array }>} + */ +export async function collectViewedAutoMergeActions(request, owner, name, viewedBy, pullRequests) { + const targets = []; + for (const pullRequest of pullRequests) { + if (!isAuthoredByViewer(pullRequest, viewedBy)) { + console.log(`#${pullRequest.number}: 작성자 ${pullRequest.author?.login ?? "없음"} — ${viewedBy} 의 PR 이 아니라 건드리지 않는다`); + continue; + } + const files = await readAllFiles(request, owner, name, pullRequest); + const decision = decideViewedAutoMerge(pullRequest, files); + console.log(`#${pullRequest.number}: ${decision.reason}`); + if (decision.enable) targets.push({ number: pullRequest.number, headSha: pullRequest.headRefOid }); + } + return { targets }; +} + +async function main() { + const token = process.env.VIEWED_TOKEN; + const viewedBy = process.env.VIEWED_BY; + const baseBranch = process.env.BASE_BRANCH; + const [owner, name] = (process.env.GITHUB_REPOSITORY ?? "").split("/"); + if (!token || !viewedBy || !baseBranch || !owner || !name) { + throw new Error("VIEWED_TOKEN·VIEWED_BY·BASE_BRANCH·GITHUB_REPOSITORY 가 필요합니다."); + } + const request = (query, variables) => + graphql(token, process.env.GITHUB_API_URL ?? "https://api.github.com", query, variables); + + const data = await request( + `query($owner: String!, $name: String!, $base: String!) { + viewer { login } + repository(owner: $owner, name: $name) { + pullRequests(states: OPEN, baseRefName: $base, first: 50) { nodes { ${PULL_REQUEST_FIELDS} } } + } + }`, + { owner, name, base: baseBranch }, + ); + if (data.viewer.login.toLowerCase() !== viewedBy.toLowerCase()) { + throw new Error(`VIEWED_TOKEN 주인이 ${data.viewer.login} 입니다 — ${viewedBy} 의 토큰이어야 합니다.`); + } + + const { targets } = await collectViewedAutoMergeActions( + request, owner, name, viewedBy, data.repository.pullRequests.nodes, + ); + if (process.env.GITHUB_OUTPUT) { + appendFileSync( + process.env.GITHUB_OUTPUT, + `targets=${JSON.stringify(targets)}\n`, + ); + } +} + +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/find-viewed-auto-merge-targets.test.mjs b/.github/scripts/find-viewed-auto-merge-targets.test.mjs new file mode 100644 index 0000000000..19ce8a0302 --- /dev/null +++ b/.github/scripts/find-viewed-auto-merge-targets.test.mjs @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { + collectViewedAutoMergeActions, + decideViewedAutoMerge, + isAuthoredByViewer, +} from "./find-viewed-auto-merge-targets.mjs"; + +const viewed = (path) => ({ path, viewerViewedState: "VIEWED" }); + +function pullRequest(overrides = {}) { + return { + number: 1, + isDraft: false, + headRefOid: "0123456789abcdef0123456789abcdef01234567", + isInMergeQueue: false, + autoMergeRequest: null, + changedFiles: 2, + ...overrides, + }; +} + +test("모든 파일이 Viewed 이고 승인 상태면 예약한다", () => { + assert.equal(decideViewedAutoMerge(pullRequest(), [viewed("a.kt"), viewed("b.kt")]).enable, true); +}); + +test("하나라도 Viewed 가 아니면 예약하지 않는다 — 체크 뒤 바뀐 파일(DISMISSED) 포함", () => { + for (const state of ["UNVIEWED", "DISMISSED"]) { + const files = [viewed("a.kt"), { path: "b.kt", viewerViewedState: state }]; + assert.equal(decideViewedAutoMerge(pullRequest(), files).enable, false, state); + } +}); + +test("리뷰 상태와 무관하게 Viewed 만으로 예약한다", () => { + for (const reviewDecision of [null, "APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"]) { + const decision = decideViewedAutoMerge(pullRequest({ reviewDecision }), [viewed("a.kt"), viewed("b.kt")]); + assert.equal(decision.enable, true, String(reviewDecision)); + } +}); + +test("파일 목록을 다 못 읽었으면 다 봤다고 단정하지 않는다", () => { + assert.equal(decideViewedAutoMerge(pullRequest({ changedFiles: 3 }), [viewed("a.kt"), viewed("b.kt")]).enable, false); + assert.equal(decideViewedAutoMerge(pullRequest({ changedFiles: 0 }), []).enable, false); +}); + +test("Draft·이미 예약·큐에 있는 PR 은 건드리지 않는다", () => { + const files = [viewed("a.kt"), viewed("b.kt")]; + assert.equal(decideViewedAutoMerge(pullRequest({ isDraft: true }), files).enable, false); + assert.equal(decideViewedAutoMerge(pullRequest({ autoMergeRequest: { enabledAt: "2026-10-04T00:00:00Z" } }), files).enable, false); + assert.equal(decideViewedAutoMerge(pullRequest({ isInMergeQueue: true }), files).enable, false); +}); + +const scheduled = { autoMergeRequest: { enabledAt: "2026-10-04T00:00:00Z" } }; + +test("VIEWED_BY 본인이 작성한 PR 만 대상이다 — 대소문자 무시, 작성자 없음은 제외", () => { + assert.equal(isAuthoredByViewer({ author: { login: "1hyok" } }, "1hyok"), true); + assert.equal(isAuthoredByViewer({ author: { login: "1Hyok" } }, "1hyok"), true); + for (const author of [{ login: "koongmai" }, { login: "Sadturtleman" }, { login: "dependabot" }, null, undefined]) { + assert.equal(isAuthoredByViewer({ author }, "1hyok"), false, JSON.stringify(author)); + } +}); + +// 파일이 한 페이지에 다 들어 있어 추가 조회가 없다. 작성자가 아니면 파일을 읽지도 않는다. +function listedPullRequest(number, login, overrides = {}, states = ["VIEWED", "VIEWED"]) { + const nodes = states.map((viewerViewedState, index) => ({ path: `f${index}.kt`, viewerViewedState })); + return pullRequest({ + number, + id: `PR_${number}`, + author: login === null ? null : { login }, + changedFiles: nodes.length, + files: { pageInfo: { hasNextPage: false, endCursor: null }, nodes }, + ...overrides, + }); +} + +const noRequest = async () => { + throw new Error("파일 추가 조회는 없어야 한다"); +}; + +test("팀원·봇 PR 은 Viewed 가 다 차도 예약하지 않는다", async () => { + const pullRequests = [ + listedPullRequest(2262, "dependabot", { isInMergeQueue: true }, ["UNVIEWED", "UNVIEWED"]), + listedPullRequest(2300, "Sadturtleman", { isInMergeQueue: true, changedFiles: 0 }, []), + listedPullRequest(2301, null), + listedPullRequest(2302, "koongmai"), + ]; + const actions = await collectViewedAutoMergeActions(noRequest, "Afternote", "Afternote-FE", "1hyok", pullRequests); + assert.deepEqual(actions, { targets: [] }); +}); + +test("본인 PR 은 Viewed 가 다 차면 예약하고, 덜 찬 채 예약·큐에 있어도 빼지 않는다", async () => { + const pullRequests = [ + listedPullRequest(10, "1hyok", { isInMergeQueue: true }, ["UNVIEWED", "UNVIEWED"]), + listedPullRequest(11, "1Hyok", scheduled, ["VIEWED", "DISMISSED"]), + listedPullRequest(12, "1hyok"), + listedPullRequest(13, "dependabot", { isInMergeQueue: true }, ["UNVIEWED", "UNVIEWED"]), + ]; + const actions = await collectViewedAutoMergeActions(noRequest, "Afternote", "Afternote-FE", "1hyok", pullRequests); + assert.deepEqual(actions, { targets: [{ number: 12, headSha: pullRequest().headRefOid }] }); +}); + +test("GraphQL 조회가 작성자를 가져온다", async () => { + const script = await readFile(new URL("./find-viewed-auto-merge-targets.mjs", import.meta.url), "utf8"); + const fields = script.slice(script.indexOf("const PULL_REQUEST_FIELDS"), script.indexOf("`;", script.indexOf("const PULL_REQUEST_FIELDS"))); + assert.match(fields, /author \{ login \}/); +}); + +test("워크플로는 예약을 끄거나 큐에서 빼지 않는다 — Viewed 는 머지를 막지 않는다", async () => { + const workflow = await readFile(new URL("../workflows/viewed-auto-merge.yml", import.meta.url), "utf8"); + assert.doesNotMatch(workflow, /--disable-auto/); + assert.doesNotMatch(workflow, /dequeuePullRequest/); + assert.doesNotMatch(workflow, /cancels/); + assert.doesNotMatch(workflow, /pull-requests: write|contents: write/); +}); + +test("워크플로는 본인 토큰으로 읽고 사람 토큰으로 승인한 HEAD 만 예약한다", async () => { + const workflow = await readFile(new URL("../workflows/viewed-auto-merge.yml", import.meta.url), "utf8"); + assert.match(workflow, /^permissions: \{\}$/m); + assert.match(workflow, /VIEWED_TOKEN: \$\{\{ secrets\.VIEWED_TOKEN \}\}/); + assert.match(workflow, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/); + assert.match(workflow, /persist-credentials: false/); + // GITHUB_TOKEN 으로 예약·투입하면 merge_group CI 가 뜨지 않아 큐가 60분 막힌다(1005 #2208). + const enableStep = workflow.slice(workflow.indexOf("- name: Enable auto-merge")); + assert.match(enableStep, /GH_TOKEN: \$\{\{ secrets\.MERGE_QUEUE_TOKEN \}\}/); + assert.doesNotMatch(enableStep, /GH_TOKEN: \$\{\{ github\.token \}\}/); + assert.match(enableStep, /if \[ -z "\$GH_TOKEN" \]; then[\s\S]*?exit 0/); + assert.match(workflow, /--match-head-commit "\$head_sha"/); + // 우회 머지 금지. merge queue 와 required check 를 그대로 탄다. + assert.doesNotMatch(workflow, /--admin/); + // PR 코드를 체크아웃하지 않는다. + assert.doesNotMatch(workflow, /github\.event\.pull_request\.head|refs\/pull/); +}); diff --git a/.github/scripts/handle-merge-queue-dequeue.mjs b/.github/scripts/handle-merge-queue-dequeue.mjs index 1ff578ce31..c05e6be0ad 100644 --- a/.github/scripts/handle-merge-queue-dequeue.mjs +++ b/.github/scripts/handle-merge-queue-dequeue.mjs @@ -10,9 +10,27 @@ // 한 번만 재투입한다. 0902 에 감시 루프가 90초마다 맹목 재투입해 30분을 헛돈 사고(#1638·#1639)가 // «한 번까지» 규칙의 근거다. 횟수는 이 스크립트가 남기는 마커 코멘트로 센다. // +// 예외는 증명된 인프라 실패다(#2250). GMD 분류기(classify-android-managed-device-failure.mjs)가 테스트가 돌기 전에 +// 죽었다고 판정하면 그 run 에 재시도 마커 아티팩트를 남긴다. 실패 job 이 전부 그런 job 이면 PR 코드가 실행되기 +// 전에 끝난 것이므로 조용한 방출과 같이 같은 head 당 한 번 재투입한다. 머지 큐는 실패한 필수 체크로 바로 방출하므로 +// job 재실행(android-managed-device-retry.yml)으로는 복구되지 않고 재투입만이 수단이다. +// // 판정 순서는 «실패 job 이 있는가» 가 먼저다. payload 의 reason 문자열은 GitHub 이 열거값을 문서화하지 // 않아 정본으로 삼지 않는다 — 실패 job 은 데이터로 확인하고, reason 은 «사람이 뺐다·이미 머지됐다» 를 // 거르는 데만 쓴다. 그 밖의 reason 은 전부 조용한 방출로 본다. +// +// 재투입 수단은 PR 이 GitHub 네이티브 스택의 일원인지로 갈린다(#2177). 스택 PR 은 GraphQL enqueuePullRequest 가 +// UNPROCESSABLE("part of a stack and must be enqueued using the asynchronous merge REST API")로 거부되므로 +// (0919 #2053, 0926 #2123 실측) REST merge-async 에 merge_action=merge_queue 로 넣는다. 판정표는 같다. +// merge-async 는 202 pending 으로 받으므로 결과(enqueued·merged·failed)를 폴링해 확인하고, failed·미확정은 job 을 +// red 로 남긴다. 스택 위쪽 PR 은 자동 재투입하지 않는다 — merge-async 가 downstack 의 열린 PR 까지 함께 넣어 +// 아래 PR 의 판정(실패 job 이면 재투입 안 함)을 덮기 때문이다. 위쪽 PR 에는 마커 없는 안내 코멘트만 남긴다. +// +// 재투입은 이 job 이 직접 하지 않고 merge-queue-requeue.yml 에 넘긴다(#2254). GITHUB_TOKEN 으로 큐에 넣으면 GitHub 이 +// 그 이벤트로 워크플로를 띄우지 않아 merge_group CI 가 하나도 돌지 않고, 큐 맨 앞을 60분(check timeout) 붙잡은 뒤 +// CI_TIMEOUT 으로 다시 빠지면서 뒤의 PR 들까지 다시 검증하게 만든다(1005 #2208 실측: 봇이 넣은 그룹만 체크 0개). +// 투입에는 사람 토큰(MERGE_QUEUE_TOKEN)이 필요한데 이 워크플로는 pull_request_target 브리지라 시크릿을 쓰지 않는다. +// workflow_dispatch 는 GITHUB_TOKEN 으로 보내도 실행되는 예외라서, 판정과 마커는 여기서 끝내고 투입만 dispatch 로 넘긴다. import { readFile } from "node:fs/promises"; import path from "node:path"; @@ -24,6 +42,12 @@ export const REQUEUE_MARKER_PREFIX = "`; } @@ -67,22 +111,32 @@ export function countRequeuesForHead(comments, headSha) { /** * 판정표. - * 1. 실패 job 이 있다 → comment-failure (재투입 없음) + * 1. 실패 job 이 있고 그중 하나라도 인프라로 증명되지 않았다 → comment-failure (재투입 없음) * 2. reason 이 MANUAL·ALREADY_MERGED·MERGE → none - * 3. 같은 head 에서 이미 재투입했다 → comment-give-up - * 4. 그 밖 → requeue + * 3. 실패 job 이 전부 증명된 인프라 실패다 → 같은 head 첫 번째면 requeue, 이미 재투입했으면 comment-failure + * 4. 같은 head 에서 이미 재투입했다 → comment-give-up + * 5. 그 밖 → requeue */ export function decide({ reason, failedJobs, requeueCount }) { - if (failedJobs.length > 0) return "comment-failure"; + if (failedJobs.length > 0 && !onlyInfrastructureFailures(failedJobs)) return "comment-failure"; if (NO_ACTION_REASONS.has(String(reason ?? "").toUpperCase())) return "none"; + if (failedJobs.length > 0) return requeueCount >= 1 ? "comment-failure" : "requeue"; if (requeueCount >= 1) return "comment-give-up"; return "requeue"; } const shortSha = (sha) => sha.slice(0, 7); -export function renderFailureComment({ reason, headSha, failedJobs, repository, number }) { - const lines = failedJobs.map((job) => `- [${job.runName} / ${job.jobName}](${job.jobUrl}) — ${job.conclusion}`); +/** + * 스택 PR 은 gh pr merge 가 "Auto-merge is not supported for stacked pull requests" 로 거부하므로 gh stack merge 를 안내한다. + * gh stack 은 --repo 플래그가 없어(gh 2.85 실측) 저장소 체크아웃 안에서 번호로 부른다. + */ +export function manualEnqueueCommand({ repository, number, stack = false }) { + return stack ? `gh stack merge ${number} --yes` : `gh pr merge ${number} --repo ${repository}`; +} + +export function renderFailureComment({ reason, headSha, failedJobs, repository, number, stack = false }) { + const lines = failedJobs.map((job) => `- [${job.runName} / ${job.jobName}](${job.jobUrl}) — ${job.conclusion}${job.infrastructure ? " (인프라 실패로 분류됨)" : ""}`); return [ "### merge queue 방출 — merge group CI 실패", "", @@ -91,26 +145,41 @@ export function renderFailureComment({ reason, headSha, failedJobs, repository, "실패한 job:", ...lines, "", - `고친 뒤 \`gh pr merge ${number} --repo ${repository}\` 로 다시 투입한다.`, + `고친 뒤 \`${manualEnqueueCommand({ repository, number, stack })}\` 로 다시 투입한다.`, ].join("\n"); } -export function renderRequeueComment({ reason, headSha }) { +export function renderRequeueComment({ reason, headSha, failedJobs = [] }) { + const why = failedJobs.length > 0 + ? "merge group 실패 job 이 전부 테스트 전 인프라 실패로 분류됨 → 한 번 재투입했다." + : "merge group 실패 job 없음 → 조용한 방출로 보고 한 번 재투입했다."; return [ requeueMarker(headSha), "### merge queue 방출 — 재투입했다", "", - `사유 \`${reason}\` · head \`${shortSha(headSha)}\` · merge group 실패 job 없음 → 조용한 방출로 보고 한 번 재투입했다.`, + `사유 \`${reason}\` · head \`${shortSha(headSha)}\` · ${why}`, + ...failedJobs.map((job) => `- [${job.runName} / ${job.jobName}](${job.jobUrl}) — ${job.conclusion}`), "같은 head 에서 또 방출되면 재투입하지 않고 여기에 남긴다.", ].join("\n"); } -export function renderGiveUpComment({ reason, headSha }) { +export function renderGiveUpComment({ reason, headSha, repository, number, stack = false }) { return [ "### merge queue 방출 — 같은 head 두 번째, 재투입하지 않는다", "", `사유 \`${reason}\` · head \`${shortSha(headSha)}\`. 실패 job 은 없다.`, "`gh run list --event merge_group` 으로 merge group run 을 확인하고 수동으로 투입한다.", + `확인한 뒤 \`${manualEnqueueCommand({ repository, number, stack })}\` 로 다시 투입한다.`, + ].join("\n"); +} + +export function renderStackUpperComment({ reason, headSha, repository, number }) { + return [ + "### merge queue 방출: 스택 위쪽 PR", + "", + `사유 \`${reason}\` · head \`${shortSha(headSha)}\`. 스택 위쪽 PR 이라 자동 재투입하지 않았다.`, + "이 PR 을 재투입하면 아래의 열린 PR 까지 큐에 다시 넣어 아래 PR 의 판정을 덮는다.", + `스택 밑단부터 확인한 뒤 \`${manualEnqueueCommand({ repository, number, stack: true })}\` 로 다시 투입한다.`, ].join("\n"); } @@ -160,6 +229,7 @@ export async function fetchLivePullRequest(api, repository, number) { headRefOid baseRefName mergeQueueEntry { state position } + stack { number baseRefName } timelineItems(last: 5, itemTypes: [ADDED_TO_MERGE_QUEUE_EVENT]) { nodes { ... on AddedToMergeQueueEvent { createdAt } } } @@ -171,6 +241,16 @@ export async function fetchLivePullRequest(api, repository, number) { return data.repository.pullRequest; } +/** GitHub 네이티브 스택의 일원인가. 비스택 PR 은 stack 이 null 로 온다(0926 #2176, #2171 실측). */ +export function isStackMember(pullRequest) { + return pullRequest?.stack != null; +} + +/** 스택의 기준 브랜치를 base 로 삼는 PR 만 밑단이다(2026-09-27 #2177). */ +export function isStackBottom(pullRequest) { + return isStackMember(pullRequest) && pullRequest.baseRefName === pullRequest.stack.baseRefName; +} + /** 마지막으로 큐에 들어간 시각 — 그 뒤의 merge_group run 만 이번 방출의 근거다. */ export function lastEnqueuedAt(pullRequest) { const stamps = (pullRequest.timelineItems?.nodes ?? []).map((node) => node?.createdAt).filter(Boolean); @@ -185,11 +265,19 @@ export async function fetchQueueFailedJobs(api, repository, { baseRef, number, s .filter((run) => !since || typeof run.created_at !== "string" || run.created_at >= since) .slice(0, QUEUE_RUNS_TO_INSPECT); const jobsByRunId = new Map(); + const artifactsByRunId = new Map(); for (const run of runs) { const jobs = await api(`/repos/${repository}/actions/runs/${run.id}/jobs?per_page=100`); jobsByRunId.set(run.id, jobs.jobs ?? []); + // 마커를 남길 수 있는 run 만 아티팩트를 본다 — 실패한 GMD job 이 없으면 볼 필요가 없다. + const hasFailedManagedDeviceJob = (jobs.jobs ?? []).some((job) => + FAILED_JOB_CONCLUSIONS.has(job.conclusion) && [...MANAGED_DEVICE_RETRY_JOBS.values()].includes(job.name)); + if (hasFailedManagedDeviceJob) { + const artifacts = await api(`/repos/${repository}/actions/runs/${run.id}/artifacts?per_page=100`); + artifactsByRunId.set(run.id, artifacts.artifacts ?? []); + } } - return collectFailedJobs({ runs, jobsByRunId, baseRef, number, since }); + return collectFailedJobs({ runs, jobsByRunId, artifactsByRunId, baseRef, number, since }); } export async function fetchComments(api, repository, number) { @@ -219,39 +307,167 @@ export async function enqueue(api, pullRequestId) { return data.enqueuePullRequest.mergeQueueEntry; } -export async function handleDequeue({ api, repository, number, reason, dryRun = false, logger = console }) { +/** + * 스택 PR 의 큐 투입. GraphQL enqueuePullRequest 는 스택 PR 을 거부하므로 REST 비동기 머지에 + * merge_action=merge_queue 를 준다. 요청한 PR 과 downstack 의 열린 PR 을 모두 함께 넣는다(2026-09-27 #2177). + * 새 요청은 202 pending 으로 받아 details.uuid 를 GET 폴링해 enqueued·merged·failed 를 확인한다. + * 이미 큐에 있거나 머지됐으면 200 enqueued·merged, 같은 PR 의 다른 비동기 요청이 pending 이면 409 다. + * sha 는 판정한 head 로 고정한다. head 가 어긋나면 요청이 거부되거나 배경에서 취소돼 failed 로 끝난다. + * failed 는 예외로 올리고, uuid 가 없거나 폴링 상한까지 미확정이면 live 큐 상태를 다시 확인하게 한다. + */ +export async function enqueueStacked(api, repository, number, headSha, { sleep = defaultSleep, pollIntervalMs = 5000, maxPolls = 24 } = {}) { + const apiPath = `/repos/${repository}/pulls/${number}/merge-async`; + let result = await api(apiPath, { + method: "PUT", + body: { merge_action: "merge_queue", sha: headSha }, + }); + const uuid = result?.details?.uuid; + for (let polls = 0; ; polls += 1) { + if (result?.status === "enqueued" || result?.status === "merged") return result; + if (result?.status === "failed") { + throw new Error(`merge-async 실패: ${JSON.stringify(result)}`); + } + if (result?.status !== "pending" || !uuid || polls >= maxPolls) { + return { ...result, unresolved: true }; + } + await sleep(pollIntervalMs); + result = await api(`${apiPath}/${uuid}`); + } +} + +export const REQUEUE_WORKFLOW_FILE = "merge-queue-requeue.yml"; + +/** 재투입 워크플로를 띄운다. 입력의 head 로 고정해 그사이 새 커밋이 올라오면 그쪽이 투입하지 않는다. */ +export async function dispatchRequeue(api, repository, { number, headSha, ref }) { + await api(`/repos/${repository}/actions/workflows/${REQUEUE_WORKFLOW_FILE}/dispatches`, { + method: "POST", + body: { ref, inputs: { pull_request_number: String(number), head_sha: headSha } }, + }); +} + +/** 큐 투입 자체. 스택 PR 은 merge-async, 나머지는 GraphQL 이다(#2177). [api] 의 토큰 주인이 투입 주체가 된다. */ +export async function performRequeue({ api, repository, number, live, logger = console, sleep = defaultSleep }) { + const headSha = live.headRefOid; + if (isStackMember(live)) { + const enqueueResult = await enqueueStacked(api, repository, number, headSha, { sleep }); + if (enqueueResult.unresolved) { + const refreshed = await fetchLivePullRequest(api, repository, number); + if (!refreshed?.mergeQueueEntry) { + throw new Error(`merge-async 결과 미확정: ${JSON.stringify(enqueueResult)} 큐에도 없다`); + } + logger.log(`#${number} 스택 ${live.stack?.number ?? "?"} 재투입(merge-async) → live 재조회로 큐 진입 확인(${refreshed.mergeQueueEntry.state})`); + } else { + logger.log(`#${number} 스택 ${live.stack?.number ?? "?"} 재투입(merge-async) → ${enqueueResult?.status ?? "?"} ${enqueueResult?.details?.message ?? ""}`.trimEnd()); + } + return "merge-async"; + } + const entry = await enqueue(api, live.id); + logger.log(`#${number} 재투입 → ${entry?.state ?? "?"} position=${entry?.position ?? "?"}`); + return "graphql"; +} + +/** + * merge-queue-requeue.yml 쪽. dispatch 를 받은 뒤 live 상태를 다시 보고, 판정한 head 그대로 열려 있고 큐 밖일 때만 넣는다. + * 판정·마커는 방출 처리 job 이 이미 끝냈으므로 여기서는 코멘트를 쓰지 않는다. + */ +export async function requeueDispatched({ api, repository, number, headSha, logger = console, sleep = defaultSleep }) { + const live = await fetchLivePullRequest(api, repository, number); + if (!live) throw new Error(`#${number} 를 찾을 수 없습니다`); + let skip = null; + if (live.state !== "OPEN") skip = live.state; + else if (live.mergeQueueEntry) skip = "already-queued"; + else if (live.headRefOid !== headSha) skip = "head-changed"; + else if (isStackMember(live) && !isStackBottom(live)) skip = "stack-upper"; + if (skip) { + logger.log(`#${number} 재투입 생략(${skip}) — 판정 head ${shortSha(headSha)}, 지금 head ${shortSha(live.headRefOid ?? "")}`); + return { action: "none", why: skip }; + } + const enqueueMethod = await performRequeue({ api, repository, number, live, logger, sleep }); + return { action: "requeue", enqueueMethod }; +} + +export function renderMissingQueueTokenComment({ headSha, repository, number, stack = false }) { + return [ + "### merge queue 방출: 재투입하지 못했다", + "", + `head \`${shortSha(headSha)}\`. \`MERGE_QUEUE_TOKEN\` 시크릿이 없다.`, + "GITHUB_TOKEN 으로 넣으면 merge_group CI 가 돌지 않아 60분 뒤 CI_TIMEOUT 으로 다시 빠지므로 넣지 않았다.", + `\`${manualEnqueueCommand({ repository, number, stack })}\` 로 직접 투입한다.`, + ].join("\n"); +} + +export async function handleDequeue({ + api, + repository, + number, + reason, + dryRun = false, + logger = console, + sleep = defaultSleep, + requeue = null, +}) { const live = await fetchLivePullRequest(api, repository, number); if (!live) throw new Error(`#${number} 를 찾을 수 없습니다`); if (live.state !== "OPEN") { logger.log(`#${number} 는 ${live.state} — 아무것도 하지 않는다`); - return { action: "none", why: live.state }; + return { action: "none", why: live.state, stack: live.stack?.number ?? null }; } if (live.mergeQueueEntry) { logger.log(`#${number} 는 이미 큐에 있다(${live.mergeQueueEntry.state}) — 아무것도 하지 않는다`); - return { action: "none", why: "already-queued" }; + return { action: "none", why: "already-queued", stack: live.stack?.number ?? null }; } const headSha = live.headRefOid; + const stack = isStackMember(live); const since = lastEnqueuedAt(live); const failedJobs = await fetchQueueFailedJobs(api, repository, { baseRef: live.baseRefName, number, since }); const comments = await fetchComments(api, repository, number); const requeueCount = countRequeuesForHead(comments, headSha); - const action = decide({ reason, failedJobs, requeueCount }); - logger.log(`#${number} reason=${reason} head=${shortSha(headSha)} 마지막투입=${since ?? "?"} 실패job=${failedJobs.length} 재투입횟수=${requeueCount} → ${action}`); + let action = decide({ reason, failedJobs, requeueCount }); + // 위쪽 PR 의 재투입이 아래 PR 의 실패 판정을 덮지 않게 밑단에서만 자동 재투입한다(2026-09-27 #2177). + if (action === "requeue" && stack && !isStackBottom(live)) action = "comment-stack-upper"; + logger.log(`#${number} reason=${reason} head=${shortSha(headSha)} 스택=${stack ? live.stack?.number ?? "?" : "없음"} 마지막투입=${since ?? "?"} 실패job=${failedJobs.length} 재투입횟수=${requeueCount} → ${action}`); - if (dryRun) return { action, dryRun: true, failedJobs, requeueCount }; + const result = { action, failedJobs, requeueCount, stack: live.stack?.number ?? null }; + if (dryRun) return { ...result, dryRun: true }; if (action === "comment-failure") { - await postComment(api, repository, number, renderFailureComment({ reason, headSha, failedJobs, repository, number })); + await postComment(api, repository, number, renderFailureComment({ reason, headSha, failedJobs, repository, number, stack })); } else if (action === "comment-give-up") { - await postComment(api, repository, number, renderGiveUpComment({ reason, headSha })); + await postComment(api, repository, number, renderGiveUpComment({ reason, headSha, repository, number, stack })); + } else if (action === "comment-stack-upper") { + await postComment(api, repository, number, renderStackUpperComment({ reason, headSha, repository, number })); } else if (action === "requeue") { // 마커를 먼저 남긴다 — 재투입 뒤 코멘트가 실패하면 다음 방출에서 두 번째 재투입이 나간다. - await postComment(api, repository, number, renderRequeueComment({ reason, headSha })); - const entry = await enqueue(api, live.id); - logger.log(`#${number} 재투입 → ${entry?.state ?? "?"} position=${entry?.position ?? "?"}`); + await postComment(api, repository, number, renderRequeueComment({ reason, headSha, failedJobs })); + result.enqueueMethod = requeue + ? await requeue({ number, headSha, live }) + : await performRequeue({ api, repository, number, live, logger, sleep }); + } + return result; +} + +function requiredEnv(name) { + const value = process.env[name]; + if (!value) throw new Error(`${name} 가 필요합니다.`); + return value; +} + +async function requeueMain(repository) { + const commentApi = createApi(requiredEnv("GITHUB_TOKEN")); + const number = Number(process.env.PULL_REQUEST_NUMBER); + const headSha = requiredEnv("HEAD_SHA"); + if (!Number.isInteger(number) || number <= 0) { + throw new Error("PULL_REQUEST_NUMBER 가 양의 정수여야 합니다."); + } + const queueToken = process.env.MERGE_QUEUE_TOKEN; + if (!queueToken) { + const live = await fetchLivePullRequest(commentApi, repository, number); + await postComment(commentApi, repository, number, renderMissingQueueTokenComment({ headSha, repository, number, stack: isStackMember(live) })); + throw new Error("MERGE_QUEUE_TOKEN 시크릿이 없어 재투입하지 못했다."); } - return { action, failedJobs, requeueCount }; + const result = await requeueDispatched({ api: createApi(queueToken), repository, number, headSha }); + console.log(`merge-queue-requeue: #${number} head=${shortSha(headSha)} → ${result.action}${result.why ? ` (${result.why})` : ""}${result.enqueueMethod ? ` (${result.enqueueMethod})` : ""}`); } async function main() { @@ -260,6 +476,10 @@ async function main() { if (!token || !repository) { throw new Error("GITHUB_TOKEN·GITHUB_REPOSITORY 가 필요합니다."); } + if (process.env.MODE === "requeue") { + await requeueMain(repository); + return; + } let number = Number(process.env.PULL_REQUEST_NUMBER); let reason = process.env.DEQUEUE_REASON; if ((!Number.isInteger(number) || number <= 0 || !reason) && process.env.GITHUB_EVENT_PATH) { @@ -273,8 +493,13 @@ async function main() { reason = reason || "UNKNOWN"; const api = createApi(token); - const result = await handleDequeue({ api, repository, number, reason, dryRun: process.env.DRY_RUN === "true" }); - const summary = `merge-queue-dequeue: #${number} reason=${reason} → ${result.action}${result.dryRun ? " (dry-run)" : ""}`; + const ref = requiredEnv("DEFAULT_BRANCH"); + const requeue = async ({ headSha }) => { + await dispatchRequeue(api, repository, { number, headSha, ref }); + return "dispatch"; + }; + const result = await handleDequeue({ api, repository, number, reason, dryRun: process.env.DRY_RUN === "true", requeue }); + const summary = `merge-queue-dequeue: #${number} reason=${reason} → ${result.action}${result.dryRun ? " (dry-run)" : ""}${result.stack !== null ? ` (스택 ${result.stack}, ${result.enqueueMethod ?? "판정만"})` : ""}`; console.log(summary); if (process.env.GITHUB_STEP_SUMMARY) { const { appendFile } = await import("node:fs/promises"); diff --git a/.github/scripts/handle-merge-queue-dequeue.test.mjs b/.github/scripts/handle-merge-queue-dequeue.test.mjs index f09f58d9a2..f75dabb935 100644 --- a/.github/scripts/handle-merge-queue-dequeue.test.mjs +++ b/.github/scripts/handle-merge-queue-dequeue.test.mjs @@ -7,10 +7,20 @@ import { collectFailedJobs, countRequeuesForHead, decide, + dispatchRequeue, + provenInfrastructureJobNames, + enqueueStacked, handleDequeue, + renderMissingQueueTokenComment, + requeueDispatched, + REQUEUE_WORKFLOW_FILE, + isStackBottom, + isStackMember, lastEnqueuedAt, + manualEnqueueCommand, queueBranchPrefix, renderFailureComment, + renderGiveUpComment, renderRequeueComment, requeueMarker, } from "./handle-merge-queue-dequeue.mjs"; @@ -18,6 +28,7 @@ import { const REPO = "Afternote/Afternote-FE"; const HEAD = "0123456789abcdef0123456789abcdef01234567"; const silent = { log() {} }; +const immediateSleep = async () => {}; // ---- 판정표 (#1892) ---- @@ -41,6 +52,24 @@ test("실패 job 없는 방출은 같은 head 에서 한 번만 재투입하고, assert.equal(decide({ reason: "ROLL_BACK", failedJobs: [], requeueCount: 3 }), "comment-give-up"); }); +test("실패 job 이 전부 증명된 인프라 실패면 같은 head 에서 한 번 재투입하고, 두 번째는 링크만 남긴다", () => { + const infra = [{ jobName: "Pixel 2 API 30 androidTest", conclusion: "failure", infrastructure: true }]; + assert.equal(decide({ reason: "CI_FAILURE", failedJobs: infra, requeueCount: 0 }), "requeue"); + assert.equal(decide({ reason: "CI_FAILURE", failedJobs: infra, requeueCount: 1 }), "comment-failure"); + assert.equal(decide({ reason: "MANUAL", failedJobs: infra, requeueCount: 0 }), "none"); + const mixed = [...infra, { jobName: "Run Unit Tests", conclusion: "failure", infrastructure: false }]; + assert.equal(decide({ reason: "CI_FAILURE", failedJobs: mixed, requeueCount: 0 }), "comment-failure"); +}); + +test("재시도 마커는 같은 run·attempt 이름만 인정한다 — 다른 run·만료·모르는 device 는 증명이 아니다", () => { + const run = { id: 37178469871, run_attempt: 1 }; + assert.deepEqual([...provenInfrastructureJobNames(run, [{ name: "android-managed-device-retry-api30-37178469871-1" }])], ["Pixel 2 API 30 androidTest"]); + assert.equal(provenInfrastructureJobNames(run, [{ name: "android-managed-device-retry-api30-37178469871-2" }]).size, 0); + assert.equal(provenInfrastructureJobNames(run, [{ name: "android-managed-device-retry-api30-1-1" }]).size, 0); + assert.equal(provenInfrastructureJobNames(run, [{ name: "android-managed-device-retry-api30-37178469871-1", expired: true }]).size, 0); + assert.equal(provenInfrastructureJobNames(run, [{ name: "android-managed-device-retry-api26-37178469871-1" }]).size, 0); +}); + // ---- 실패 job 수집 ---- test("이 PR 의 큐 브랜치 run 에서 failure·timed_out job 만 모은다 — 다른 PR 의 run, cancelled job 은 뺀다", () => { @@ -109,10 +138,77 @@ test("실패 코멘트는 job 링크와 재투입 명령을 담고, 재투입했 assert.ok(!body.includes(requeueMarker(HEAD))); }); +test("스택 PR 의 실패 코멘트는 gh pr merge 가 아니라 gh stack merge 를 안내한다 — gh pr merge 는 스택 PR 을 거부한다", () => { + assert.equal(manualEnqueueCommand({ repository: REPO, number: 2123 }), "gh pr merge 2123 --repo Afternote/Afternote-FE"); + assert.equal(manualEnqueueCommand({ repository: REPO, number: 2123, stack: true }), "gh stack merge 2123 --yes", "gh stack 에는 --repo 가 없다"); + const body = renderFailureComment({ + reason: "CI_FAILURE", + headSha: HEAD, + repository: REPO, + number: 2123, + stack: true, + failedJobs: [{ runName: "Unit Test", jobName: "Run Unit Tests", jobUrl: "https://x/jobs/11", conclusion: "failure" }], + }); + assert.match(body, /gh stack merge 2123 --yes/); + assert.ok(!body.includes("gh pr merge")); +}); + +test("재투입 중단 코멘트는 스택 여부에 맞는 수동 명령을 덧붙인다 — 기존 확인 안내를 보존한다", () => { + for (const [stack, number, command] of [ + [true, 2123, "gh stack merge 2123 --yes"], + [false, 1509, "gh pr merge 1509 --repo Afternote/Afternote-FE"], + ]) { + const body = renderGiveUpComment({ reason: "MERGE_CONFLICT", headSha: HEAD, repository: REPO, number, stack }); + assert.match(body, /같은 head 두 번째, 재투입하지 않는다/); + assert.ok(body.includes("`gh run list --event merge_group` 으로 merge group run 을 확인하고 수동으로 투입한다.")); + assert.ok(body.split("\n").at(-1).includes(command)); + assert.ok(!body.includes(requeueMarker(HEAD))); + } +}); + +// ---- 스택 판정 (2026-09-27 #2177) ---- + +test("stack 이 null·없음이면 비스택, 객체면 스택이다", () => { + assert.equal(isStackMember({ stack: null }), false); + assert.equal(isStackMember({}), false); + assert.equal(isStackMember({ stack: { number: 2125 } }), true); +}); + +test("스택 기준 브랜치와 PR base 가 같을 때만 밑단이다 — 위쪽 PR 은 아래 PR 의 판정을 덮을 수 있다", () => { + assert.equal(isStackBottom({ stack: null, baseRefName: "develop" }), false); + assert.equal(isStackBottom({}), false); + assert.equal(isStackBottom({ baseRefName: "develop", stack: { number: 2125, baseRefName: "develop" } }), true); + assert.equal(isStackBottom({ baseRefName: "refactor/1429-setting-repositories", stack: { number: 2125, baseRefName: "develop" } }), false); +}); + // ---- handleDequeue 흐름: live 상태가 정본, 쓰기 순서 ---- -function fakeApi({ live, runs = [], jobs = {}, comments = [] }) { +function fakeApi({ + live, + liveAfter = live, + runs = [], + jobs = {}, + artifacts = {}, + comments = [], + mergeAsync = { + status: "pending", + details: { + message: "Merge request enqueued.", + uuid: "u-1", + merge_method: "default", + merge_action: "merge_queue", + expected_head_sha: HEAD, + }, + }, + mergeAsyncPolls = [{ status: "enqueued", details: { message: "Pull request added to merge queue." } }], +}) { const calls = []; + let liveReads = 0; + let pollReads = 0; + const respond = (response) => { + if (response instanceof Error) throw response; + return response; + }; const api = async (apiPath, options = {}) => { calls.push({ apiPath, method: options.method ?? "GET", body: options.body }); if (apiPath === "/graphql") { @@ -120,38 +216,64 @@ function fakeApi({ live, runs = [], jobs = {}, comments = [] }) { if (query.includes("enqueuePullRequest")) { return { data: { enqueuePullRequest: { mergeQueueEntry: { state: "QUEUED", position: 1 } } } }; } - return { data: { repository: { pullRequest: live } } }; + return { data: { repository: { pullRequest: respond(liveReads++ === 0 ? live : liveAfter) } } }; + } + if (/\/pulls\/\d+\/merge-async$/.test(apiPath) && options.method === "PUT") return respond(mergeAsync); + if (/\/pulls\/\d+\/merge-async\/u-1$/.test(apiPath) && (options.method ?? "GET") === "GET") { + return respond(mergeAsyncPolls[Math.min(pollReads++, mergeAsyncPolls.length - 1)]); } if (apiPath.startsWith(`/repos/${REPO}/actions/runs?`)) return { workflow_runs: runs }; + const artifactsMatch = /\/actions\/runs\/(\d+)\/artifacts/.exec(apiPath); + if (artifactsMatch) return { artifacts: artifacts[artifactsMatch[1]] ?? [] }; const jobsMatch = /\/actions\/runs\/(\d+)\/jobs/.exec(apiPath); if (jobsMatch) return { jobs: jobs[jobsMatch[1]] ?? [] }; if (apiPath.includes("/comments") && (options.method ?? "GET") === "GET") return comments; if (apiPath.includes("/comments") && options.method === "POST") return { id: 1 }; + if (apiPath.endsWith("/dispatches") && options.method === "POST") return null; throw new Error(`unexpected ${apiPath}`); }; api.calls = calls; return api; } +const enqueueMutations = (api) => api.calls.filter((call) => call.body?.query?.includes("enqueuePullRequest")); +const mergeAsyncCalls = (api) => api.calls.filter((call) => call.apiPath.endsWith("/merge-async")); +const mergeAsyncGets = (api) => api.calls.filter((call) => call.method === "GET" && call.apiPath.endsWith("/merge-async/u-1")); +const liveQueries = (api) => api.calls.filter((call) => call.apiPath === "/graphql" && !call.body.query.includes("enqueuePullRequest")); +const commentPosts = (api) => api.calls.filter((call) => call.method === "POST" && call.apiPath.endsWith("/comments")); + const openLive = { id: "PR_1", state: "OPEN", headRefOid: HEAD, baseRefName: "develop", mergeQueueEntry: null, + stack: null, timelineItems: { nodes: [{ createdAt: "2026-09-04T08:00:00Z" }] }, }; +// 0926 #2123: base 는 develop 이지만 위에 #2124 가 얹혀 스택 2125 의 밑단이다. +const stackedLive = { ...openLive, stack: { number: 2125, baseRefName: "develop" } }; +const stackUpperLive = { ...stackedLive, baseRefName: "refactor/1429-setting-repositories" }; + test("MERGED·CLOSED 거나 이미 큐에 있으면 payload 와 무관하게 아무것도 쓰지 않는다", async () => { for (const live of [ { ...openLive, state: "MERGED" }, { ...openLive, state: "CLOSED" }, { ...openLive, mergeQueueEntry: { state: "QUEUED", position: 2 } }, + { ...stackedLive, state: "MERGED" }, + { ...stackedLive, state: "CLOSED" }, + { ...stackedLive, mergeQueueEntry: { state: "QUEUED", position: 2 } }, + { ...stackUpperLive, state: "MERGED" }, + { ...stackUpperLive, state: "CLOSED" }, + { ...stackUpperLive, mergeQueueEntry: { state: "QUEUED", position: 2 } }, ]) { const api = fakeApi({ live }); const result = await handleDequeue({ api, repository: REPO, number: 1509, reason: "UNKNOWN_REMOVAL_REASON", logger: silent }); assert.equal(result.action, "none"); - assert.equal(api.calls.filter((call) => call.method === "POST" && call.apiPath !== "/graphql").length, 0); + assert.equal(result.stack, live.stack?.number ?? null); + assert.ok(!("enqueueMethod" in result)); + assert.equal(api.calls.filter((call) => call.method !== "GET" && call.apiPath !== "/graphql").length, 0); assert.ok(!api.calls.some((call) => call.body?.query?.includes("enqueuePullRequest"))); } }); @@ -160,11 +282,253 @@ test("조용한 방출: 마커 코멘트를 먼저 남기고 그 다음 enqueueP const api = fakeApi({ live: openLive }); const result = await handleDequeue({ api, repository: REPO, number: 1509, reason: "UNKNOWN_REMOVAL_REASON", logger: silent }); assert.equal(result.action, "requeue"); - const writes = api.calls.filter((call) => call.method === "POST" && (call.apiPath !== "/graphql" || call.body.query.includes("enqueuePullRequest"))); + assert.equal(result.stack, null); + assert.equal(result.enqueueMethod, "graphql"); + const writes = api.calls.filter((call) => call.method !== "GET" && (call.apiPath !== "/graphql" || call.body.query.includes("enqueuePullRequest"))); assert.equal(writes.length, 2); assert.match(writes[0].apiPath, /\/issues\/1509\/comments$/); assert.ok(writes[0].body.body.startsWith(requeueMarker(HEAD))); assert.equal(writes[1].apiPath, "/graphql"); + assert.equal(mergeAsyncCalls(api).length, 0, "비스택 PR 은 종전대로 GraphQL 로만 넣는다"); +}); + +// ---- 스택 PR 의 재투입은 REST merge-async (2026-09-27 #2177) ---- + +test("live 조회는 stack 필드를 함께 읽는다 — 재투입 수단을 고르는 근거다", async () => { + const api = fakeApi({ live: openLive }); + await handleDequeue({ api, repository: REPO, number: 1509, reason: "UNKNOWN_REMOVAL_REASON", dryRun: true, logger: silent }); + const liveQuery = api.calls.find((call) => call.apiPath === "/graphql").body.query; + assert.match(liveQuery, /stack\s*\{\s*number\s+baseRefName\s*\}/); +}); + +test("스택 밑단은 마커 뒤 merge-async 로 넣고 GET enqueued 를 확인한다 — enqueuePullRequest 는 스택 PR 을 거부한다", async () => { + const api = fakeApi({ live: stackedLive }); + const result = await handleDequeue({ api, repository: REPO, number: 2123, reason: "MERGE_CONFLICT", logger: silent, sleep: immediateSleep }); + assert.equal(result.action, "requeue"); + assert.equal(result.stack, 2125); + assert.equal(result.enqueueMethod, "merge-async"); + assert.equal(enqueueMutations(api).length, 0, "GraphQL enqueuePullRequest 는 스택 PR 을 UNPROCESSABLE 로 거부한다"); + const writes = api.calls.filter((call) => call.method !== "GET" && call.apiPath !== "/graphql"); + assert.equal(writes.length, 2); + assert.match(writes[0].apiPath, /\/issues\/2123\/comments$/); + assert.ok(writes[0].body.body.startsWith(requeueMarker(HEAD)), "마커가 먼저다 — 재투입 뒤 코멘트가 실패하면 두 번째 재투입이 나간다"); + assert.deepEqual( + { apiPath: writes[1].apiPath, method: writes[1].method, body: writes[1].body }, + { apiPath: `/repos/${REPO}/pulls/2123/merge-async`, method: "PUT", body: { merge_action: "merge_queue", sha: HEAD } }, + ); + assert.equal(mergeAsyncGets(api).length, 1); + assert.equal(mergeAsyncGets(api)[0].apiPath, `/repos/${REPO}/pulls/2123/merge-async/u-1`); + assert.ok(api.calls.indexOf(writes[1]) < api.calls.indexOf(mergeAsyncGets(api)[0])); + assert.equal(liveQueries(api).length, 1, "최종 결과가 확인되면 live 재조회는 필요 없다"); +}); + +test("스택 위쪽은 마커 없는 안내만 남긴다 — 아래 PR 의 실패 판정을 우회하는 자동 재투입을 막는다", async () => { + const api = fakeApi({ live: stackUpperLive }); + const result = await handleDequeue({ api, repository: REPO, number: 2124, reason: "MERGE_CONFLICT", logger: silent, sleep: immediateSleep }); + assert.equal(result.action, "comment-stack-upper"); + assert.equal(result.stack, 2125); + assert.ok(!("enqueueMethod" in result)); + assert.equal(mergeAsyncCalls(api).length, 0); + assert.equal(mergeAsyncGets(api).length, 0); + assert.equal(enqueueMutations(api).length, 0); + assert.equal(commentPosts(api).length, 1); + const body = commentPosts(api)[0].body.body; + assert.ok(!body.includes("\nCloses #12", + user: { login: "dependabot[bot]", type: "Bot" }, + }), + repository: "Afternote/Afternote-FE", + loadIssue, + }); + assert.deepEqual(closing, { issues: [12], rejected: [] }); + const result = await validatePullRequestIssueLink({ pullRequest: pullRequest({ title: "chore(deps): bump upload-artifact (#12)", diff --git a/.github/scripts/qa-distribution-decision.mjs b/.github/scripts/qa-distribution-decision.mjs index 88f41ea71e..bf6f466bc9 100644 --- a/.github/scripts/qa-distribution-decision.mjs +++ b/.github/scripts/qa-distribution-decision.mjs @@ -137,9 +137,24 @@ export async function collectQaDistributionContext({ repository, headSha, api = } git(["cat-file", "-e", `${headSha}^{commit}`]); const root = `repos/${repository}`; - const runs = pages(await api(`${root}/actions/workflows/release-distribution.yml/runs?status=success&branch=main&event=push&per_page=100`, true), "workflow_runs"); - const latest = sortDistributionRuns(runs.filter((run) => run.conclusion === "success" && run.head_branch === "main" && run.event === "push"))[0]; + const runsEndpoint = `${root}/actions/workflows/release-distribution.yml/runs?branch=main&event=push&per_page=100`; + const successfulRuns = async (endpoint) => sortDistributionRuns(pages(await api(endpoint, true), "workflow_runs") + .filter((run) => run.conclusion === "success" && run.head_branch === "main" && run.event === "push")); + // status=success 필터 응답은 최근 실행이 통째로 빠진 채 올 때가 있다(#2269 머지 뒤 판정이 8/14 배포를 기준으로 잡음). 결론은 직접 거른다. + let latest = (await successfulRuns(runsEndpoint))[0]; if (!latest) throw new CollectionError("No successful main Firebase distribution baseline"); + // 목록에서 빠진 더 새 배포가 있는지 main 의 뒤 커밋마다 head_sha 로 다시 묻는다. main tip 이 곧 배포 커밋이면 호출이 없다. + const newerMainCommits = SHA.test(latest.head_sha ?? "") + ? git(["rev-list", "--first-parent", `${latest.head_sha}..refs/remotes/origin/main`]).split("\n").filter(Boolean) + : []; + for (const sha of newerMainCommits) { + if (!SHA.test(sha)) throw new CollectionError("Invalid commit in main history"); + const newer = (await successfulRuns(`${runsEndpoint}&head_sha=${sha}`))[0]; + if (newer) { + latest = newer; + break; + } + } const associated = pages(await api(`${root}/commits/${latest.head_sha}/pulls?per_page=100`, true)); const sourceSha = sourceShaForDistributionRun(latest, associated); if (!SHA.test(sourceSha ?? "")) throw new CollectionError("Successful distribution has no develop release PR source SHA"); diff --git a/.github/scripts/qa-distribution-decision.test.mjs b/.github/scripts/qa-distribution-decision.test.mjs index c63f8b80ac..bea4cd109c 100644 --- a/.github/scripts/qa-distribution-decision.test.mjs +++ b/.github/scripts/qa-distribution-decision.test.mjs @@ -72,6 +72,7 @@ function collectorFixture(count = 1, netPaths = [APP]) { return [pulls.slice(0, 50), pulls.slice(50)]; } function git(args) { + if (args[0] === "rev-list" && args.at(-1).endsWith("..refs/remotes/origin/main")) return ""; if (args[0] === "rev-list") return [...commits].reverse().concat(sourceSha).join("\n"); if (args.includes("--raw")) { const index = commits.indexOf(args.at(-2)); @@ -94,6 +95,33 @@ test("maps a successful main deployment to its develop source and collects over assert.equal(decideQaDistribution(collected, NOW).decision, "deploy"); }); +test("a run list that omits newer main deployments still resolves the newest baseline", async () => { + const fixture = collectorFixture(); + const [staleMain, failedMain, deployedMain] = ["d", "e", "f"].map((c) => c.repeat(40)); + const staleSource = "9".repeat(40); + const run = (id, sha, conclusion) => ({ id, head_sha: sha, head_branch: "main", event: "push", conclusion, updated_at: `2026-09-0${id}T00:00:00Z`, html_url: `https://example.com/${id}` }); + const api = fixture.api; + fixture.api = async (endpoint, paginate) => { + if (endpoint.includes("/actions/")) { + fixture.calls.push(endpoint); + if (endpoint.endsWith(`head_sha=${failedMain}`)) return [{ workflow_runs: [run(3, failedMain, "failure")] }]; + if (endpoint.endsWith(`head_sha=${deployedMain}`)) return [{ workflow_runs: [run(2, deployedMain, "success")] }]; + return [{ workflow_runs: [run(1, staleMain, "success")] }]; + } + if (endpoint.includes(`/commits/${staleMain}/`)) return [[{ merged_at: "2026-09-01T00:00:00Z", base: { ref: "main" }, head: { ref: "develop", sha: staleSource } }]]; + return api(endpoint, paginate); + }; + const git = fixture.git; + fixture.git = (args) => { + if (args[0] === "rev-list" && args.at(-1) === `${staleMain}..refs/remotes/origin/main`) return [failedMain, deployedMain].join("\n"); + return git(args); + }; + const collected = await collectQaDistributionContext(fixture); + assert.equal(collected.baseline.runId, 2); + assert.equal(collected.baseline.sourceSha, sourceSha); + assert.ok(fixture.calls.every((endpoint) => !endpoint.includes("status="))); +}); + test("a fully reverted runtime batch has no remaining APK change", async () => { const fixture = collectorFixture(75, []); const collected = await collectQaDistributionContext(fixture); diff --git a/.github/scripts/reconcile-latest-review-decision.mjs b/.github/scripts/reconcile-latest-review-decision.mjs deleted file mode 100644 index 32c076fdeb..0000000000 --- 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 acc6303bc6..0000000000 --- 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/resolve-pr-impact.mjs b/.github/scripts/resolve-pr-impact.mjs index e60dc1a82a..9e0aebe15b 100644 --- a/.github/scripts/resolve-pr-impact.mjs +++ b/.github/scripts/resolve-pr-impact.mjs @@ -25,6 +25,7 @@ const IMPACT_POLICY_PATHS = new Set([ ".github/workflows/unit-test.yml", ".github/workflows/screenshot.yml", ".github/workflows/codeql.yml", + ".github/workflows/dependency-review.yml", // 기대 실패 목록(xfail)은 검증 정책 그 자체다 — 목록·메커니즘 변경은 모든 lane 으로 // fail-closed 해, 잘못 지운 항목이 그 자리에서 red 를 만들게 한다. ".github/ci-expected-failures.json", @@ -35,6 +36,50 @@ const IMPACT_POLICY_PATHS = new Set([ // 아키텍처 가드 전용 순수 JVM 모듈. src/main 이 없어 프로덕션 소스 분기를 탈 수 없고, // kover 도 안 붙어 coverageModules 로도 안 잡힌다 — 소스셋 판정에서 따로 건져야 한다. const KONSIST_PROJECT_PATH = ":konsist"; +// Dependency Review 를 깨우는 의존성 그래프 입력. 예전엔 dependency-review.yml 의 paths +// 필터였는데, 그러면 소스만 고친 PR 에 check 가 생기지 않아 required 로 걸 수 없었다 (#2258). +// 같은 glob 문법을 그대로 두고 job 안 분류로 옮긴다. 목록은 supply-chain-policy.test.mjs 가 고정한다. +export const DEPENDENCY_GRAPH_INPUT_PATTERNS = Object.freeze([ + "**/*.gradle", + "**/*.gradle.kts", + "**/*.lockfile", + "**/build-logic/**", + "**/buildSrc/**", + "**/gradle.properties", + "**/gradlew", + "**/gradlew.bat", + "gradle/**", + ".github/workflows/dependency-review.yml", + ".github/workflows/dependency-submission.yml", + ".github/workflows/dependency-submission-trusted.yml", + ".github/workflows/dependency-submission-upload.yml", +]); + +function globToRegExp(pattern) { + let source = ""; + for (let index = 0; index < pattern.length; ) { + if (pattern.startsWith("**/", index)) { + source += "(?:.*/)?"; + index += 3; + } else if (pattern.startsWith("**", index)) { + source += ".*"; + index += 2; + } else if (pattern[index] === "*") { + source += "[^/]*"; + index += 1; + } else { + source += pattern[index].replace(/[.+?^${}()|[\]\\]/g, "\\$&"); + index += 1; + } + } + return new RegExp(`^${source}$`); +} + +const DEPENDENCY_GRAPH_INPUT_MATCHERS = DEPENDENCY_GRAPH_INPUT_PATTERNS.map(globToRegExp); + +export function isDependencyGraphInput(filePath) { + return DEPENDENCY_GRAPH_INPUT_MATCHERS.some((matcher) => matcher.test(filePath)); +} function normalizePath(value) { return String(value ?? "") @@ -172,6 +217,7 @@ export function resolvePrImpact(changedFiles, modules, dependencies) { let runNodeTests = false; let codeqlActions = false; let codeqlJavaKotlin = false; + let dependencyReview = false; let forceFull = false; let repositoryQualityFixtures = false; @@ -214,6 +260,9 @@ export function resolvePrImpact(changedFiles, modules, dependencies) { if (filePath.startsWith(".github/workflows/") || filePath.startsWith(".github/actions/")) { codeqlActions = true; } + if (isDependencyGraphInput(filePath)) { + dependencyReview = true; + } if (filePath === ".editorconfig") { globalKtlintChange = true; } @@ -286,6 +335,10 @@ export function resolvePrImpact(changedFiles, modules, dependencies) { runKonsist = true; compileAndroidTest = true; } + if (forceFull) { + // 분류기 자신이나 처음 보는 경로가 바뀌면 의존성 입력이 없다고 단정하지 않는다. + dependencyReview = true; + } const productionAffected = reverseDependencyClosure(productionSeeds, reverseDependencies); if (productionAffected.size > 0) { @@ -353,6 +406,7 @@ export function resolvePrImpact(changedFiles, modules, dependencies) { screenshotTasks, codeqlActions, codeqlJavaKotlin, + dependencyReview, repositoryQualityFull: forceFull, repositoryQualityFixtures, }; @@ -430,6 +484,7 @@ export function githubOutputLines(impact) { screenshot_tasks: impact.screenshotTasks.join(" "), codeql_actions: impact.codeqlActions, codeql_java_kotlin: impact.codeqlJavaKotlin, + dependency_review_required: impact.dependencyReview, repository_quality_full: impact.repositoryQualityFull, repository_quality_fixtures: impact.repositoryQualityFixtures, }; diff --git a/.github/scripts/resolve-pr-impact.test.mjs b/.github/scripts/resolve-pr-impact.test.mjs index eacafa372d..96c98d6c77 100644 --- a/.github/scripts/resolve-pr-impact.test.mjs +++ b/.github/scripts/resolve-pr-impact.test.mjs @@ -219,3 +219,28 @@ test("GitHub outputs preserve empty scopes and booleans", () => { assert.match(output, /^unit_test_tasks=$/m); assert.match(output, /^codeql_actions=false$/m); }); + +test("dependency review runs only for dependency graph inputs and fails closed", () => { + // #2258: Dependency Review 는 required 라 매 PR 에 check 가 생긴다. 검사 자체는 의존성 + // 입력이 바뀐 PR 에서만 하고, 분류가 확신할 수 없는 변경은 검사 쪽으로 닫는다. + const sourceOnly = resolvePrImpact(["feature/home/presentation/src/main/kotlin/Home.kt"], modules, dependencies); + assert.equal(sourceOnly.dependencyReview, false); + assert.match(githubOutputLines(sourceOnly).join("\n"), /^dependency_review_required=false$/m); + + for (const changed of [ + ["gradle/libs.versions.toml"], + ["feature/home/presentation/build.gradle.kts"], + ["build-logic/convention/src/main/kotlin/Convention.kt"], + [".github/workflows/dependency-submission.yml"], + [".github/workflows/dependency-review.yml"], + [".github/workflows/pr-validation.yml"], + [".github/scripts/resolve-pr-impact.mjs"], + ["unexpected/runtime-config.toml"], + ]) { + const impact = resolvePrImpact(changed, modules, dependencies); + assert.equal(impact.dependencyReview, true, changed[0]); + assert.match(githubOutputLines(impact).join("\n"), /^dependency_review_required=true$/m); + } + + assert.equal(resolvePrImpact(["README.md", "docs/qa/plan.md"], modules, dependencies).dependencyReview, false); +}); diff --git a/.github/scripts/review-debt-policy.test.mjs b/.github/scripts/review-debt-policy.test.mjs deleted file mode 100644 index 1c507ddb5c..0000000000 --- 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 9751d19ab5..0000000000 --- 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/supply-chain-policy.test.mjs b/.github/scripts/supply-chain-policy.test.mjs index 787af146b9..0ca50431f7 100644 --- a/.github/scripts/supply-chain-policy.test.mjs +++ b/.github/scripts/supply-chain-policy.test.mjs @@ -5,7 +5,11 @@ import path from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; -import { inspectModules } from './resolve-pr-impact.mjs'; +import { + DEPENDENCY_GRAPH_INPUT_PATTERNS, + inspectModules, + isDependencyGraphInput, +} from './resolve-pr-impact.mjs'; const workflowDirectory = new URL('../workflows/', import.meta.url); const repoRoot = path.resolve(fileURLToPath(new URL('../..', import.meta.url))); @@ -122,18 +126,6 @@ function checkoutStepBlocks(source) { return blocks; } -function eventPathFilters(source, eventName) { - const match = new RegExp(`^ ${eventName}:\\n paths:\\n((?: - '[^']+'\\n)+)`, 'm').exec( - source, - ); - assert.ok(match, `${eventName} must declare a paths filter`); - return match[1].trim().split('\n').map((line) => { - const path = /^- '([^']+)'$/.exec(line.trim()); - assert.ok(path, `invalid dependency path filter: ${line}`); - return path[1]; - }); -} - function workflowName(source) { const match = /^name:\s*(.+)$/m.exec(source); assert.ok(match, 'workflow must declare a top-level name'); @@ -488,7 +480,7 @@ test('manual dependency baseline is hard-wired to the checked-out main SHA', asy assert.doesNotMatch(manualJob, /inputs\./); }); -test('all pull request heads produce a baseline while dependency review keeps its path filter', async () => { +test('all pull request heads produce a baseline while dependency review classifies inputs in the job', async () => { const submission = await readFile( new URL('../workflows/dependency-submission.yml', import.meta.url), 'utf8', @@ -504,7 +496,12 @@ test('all pull request heads produce a baseline while dependency review keeps it assert.doesNotMatch(submission, /^\s+(?:paths(?:-ignore)?|branches(?:-ignore)?|types):/m); assert.doesNotMatch(submission, /^\s+if:/m); assert.doesNotMatch(submission, /pull_request_target:|workflow_dispatch:/); - assert.deepEqual(eventPathFilters(review, 'pull_request'), dependencyPathFilters); + // #2258: paths 필터는 소스만 고친 PR 에 check 를 만들지 않아 required 로 걸 수 없다. + // 같은 경로 목록을 repository-quality 의 분류로 옮기고, review 는 PR Validation 이 매번 부른다. + assert.match(review, /^on:\n workflow_call:\n/m); + assert.doesNotMatch(review, /^\s+(?:paths(?:-ignore)?|branches(?:-ignore)?):/m); + assert.doesNotMatch(review, /^ (?:pull_request|merge_group|push):/m); + assert.deepEqual([...DEPENDENCY_GRAPH_INPUT_PATTERNS], dependencyPathFilters); assert.doesNotMatch(submission, /Detect dependency graph input changes/); assert.doesNotMatch(review, /Detect dependency graph input changes/); }); @@ -607,6 +604,44 @@ test('only the release pull request is exempt from the path-filter boundary', as assert.doesNotMatch(exemption[1], /exit\s+1/); }); +test('dependency graph input classification matches the former paths filter semantics', () => { + for (const filePath of [ + 'build.gradle.kts', + 'settings.gradle.kts', + 'app/build.gradle.kts', + 'legacy/build.gradle', + 'gradle.properties', + 'feature/home/gradle.properties', + 'gradlew', + 'gradlew.bat', + 'gradle/libs.versions.toml', + 'gradle/wrapper/gradle-wrapper.properties', + 'build-logic/convention/src/main/kotlin/AndroidLibrary.kt', + 'tools/buildSrc/src/main/kotlin/Plugin.kt', + 'app/dependencies.lockfile', + '.github/workflows/dependency-review.yml', + '.github/workflows/dependency-submission.yml', + '.github/workflows/dependency-submission-trusted.yml', + '.github/workflows/dependency-submission-upload.yml', + ]) { + assert.equal(isDependencyGraphInput(filePath), true, filePath); + } + for (const filePath of [ + 'README.md', + 'docs/gradle.md', + 'app/src/main/kotlin/MainActivity.kt', + 'app/src/main/res/values/strings.xml', + 'feature/gradle/Screen.kt', + 'build-logic', + 'gradle', + 'gradlew.sh', + 'app/build.gradle.kts.orig', + '.github/workflows/pr-validation.yml', + ]) { + assert.equal(isDependencyGraphInput(filePath), false, filePath); + } +}); + test('dependency review blocks high severity changes without enforcing a license allowlist', async () => { const source = await readFile(new URL('../workflows/dependency-review.yml', import.meta.url), 'utf8'); diff --git a/.github/scripts/validate-pr-issue-link.mjs b/.github/scripts/validate-pr-issue-link.mjs index 80c3c573ba..3319f1c681 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]"); } @@ -123,7 +122,9 @@ export async function validatePullRequestIssueLink({ pullRequest, repository, lo // 대표 Issue 는 이 PR 이 끝내는 Issue 다. Refs 로 걸 수 있게 두면 두 가지 도피로가 열린다 — // 열린 blocked_by 가 있는 Issue 를 merge-order-guard 밖에서 머지하고 Issue 는 손으로 닫는 것, // Issue 의 일부만 하고 남는 몫을 본문 산문에만 남기는 것 (#1748). 일부만 한다면 그 몫을 새 Issue 로 - // 분리해 대표 Issue 로 삼는다. 봇 PR 은 사람이 나중에 링크를 붙이는 구조라 종전대로 Refs 를 허용한다. + // 분리해 대표 Issue 로 삼는다. 봇 PR 은 closing 키워드를 면제한다(금지가 아니다). 사람이 나중에 붙이는 + // 링크는 여러 봇 PR 이 공유하는 Issue 일 수 있어 Refs 를 허용하고, dependabot-pr-gate 가 PR 마다 만든 + // Issue 는 Closes 로 건다(#2274). const botAuthor = isBotAuthor(pullRequest.user, author); if (!botAuthor) { const closingReferences = extractClosingIssueNumbers(pullRequest.body, repository); diff --git a/.github/workflows/android-managed-device.yml b/.github/workflows/android-managed-device.yml index 7646189a31..5cfd3b6cfe 100644 --- a/.github/workflows/android-managed-device.yml +++ b/.github/workflows/android-managed-device.yml @@ -77,7 +77,7 @@ jobs: java-version: "21" - name: Set up Gradle and validate wrapper - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-provider: basic cache-read-only: true @@ -298,8 +298,21 @@ jobs: # tested revision의 권한 없는 classifier를 신뢰 경계 밖에서 실행하지 않는다. classifier=unavailable fi - printf 'renderer=%s\nclassifier=%s\nexpected_failures=%s\nsource=trusted\n' \ - "$renderer" "$classifier" "$expected_failures" >> "$GITHUB_OUTPUT" + # dependabot PR 은 게이트가 CI Test Plan 을 채울 때까지 이 신뢰 사본으로 기다린다(#2212). + # 파일 존재가 아니라 await 명령의 존재로 가른다. 이 파일은 #2112 부터 default branch 에 + # 있어서, 존재만 보면 도입 PR 이 명령 없는 옛 사본을 부른다. + if [[ -f .github/scripts/dependabot-pr-gate.mjs ]] && + grep -q 'export async function awaitGate' .github/scripts/dependabot-pr-gate.mjs; then + install -m 0644 \ + .github/scripts/dependabot-pr-gate.mjs \ + "$policy_dir/dependabot-pr-gate.mjs" + dependabot_gate=trusted + else + # 도입 PR 은 사람이 연다. 기다릴 게이트가 없으니 종전처럼 이벤트 페이로드를 읽는다. + dependabot_gate=unavailable + fi + printf 'renderer=%s\nclassifier=%s\nexpected_failures=%s\ndependabot_gate=%s\nsource=trusted\n' \ + "$renderer" "$classifier" "$expected_failures" "$dependabot_gate" >> "$GITHUB_OUTPUT" elif [[ ! -e .github/scripts/ci-test-plan.mjs && ! -e .github/scripts/resolve-android-test-plan.mjs && ! -e .github/scripts/resolve-pr-impact.mjs && @@ -312,6 +325,7 @@ jobs: renderer=bootstrap \ classifier=unavailable \ expected_failures=unavailable \ + dependabot_gate=unavailable \ source=bootstrap >> "$GITHUB_OUTPUT" else echo "::error::Android test policy가 부분 설치된 상태입니다" @@ -322,10 +336,12 @@ jobs: id: target env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + DEPENDABOT_GATE: ${{ steps.policy.outputs.dependabot_gate }} DISPATCH_REF_NAME: ${{ github.ref_name }} DISPATCH_PR_NUMBER: ${{ inputs.pull_request_number || 0 }} DEVICE: ${{ matrix.device }} EVENT_NAME: ${{ github.event_name }} + EVENT_PR_AUTHOR: ${{ github.event.pull_request.user.login }} EVENT_PR_BRANCH: ${{ github.event.pull_request.head.ref }} EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} EVENT_PR_SHA: ${{ github.event.pull_request.head.sha }} @@ -344,7 +360,22 @@ jobs: target_pr="$EVENT_PR_NUMBER" target_branch="$EVENT_PR_BRANCH" target_sha="$EVENT_PR_SHA" - jq '.pull_request' "$GITHUB_EVENT_PATH" > "$pull_request_file" + if [[ "$EVENT_PR_AUTHOR" == "dependabot[bot]" && "$DEPENDABOT_GATE" == "trusted" ]]; then + # dependabot-pr-gate 가 같은 순간 GITHUB_TOKEN 으로 CI Test Plan 을 채운다. 그 edited 는 + # 이 워크플로를 다시 깨우지 못하고 페이로드는 opened 시점 값이다(#2212). 페이로드 제목에 + # 대표 이슈 번호가 이미 있으면 API 없이 그대로 쓰고, 없으면 게이트가 채울 때까지 live PR 을 + # 기다린다. 재실행으로 풀면 실제 테스트가 attempt 2 에서 돌아 1회차 인프라 복구를 잃는다. + # lane timeout 이 15분 이상이라 3분까지 기다린다. + PR_NUMBER="$EVENT_PR_NUMBER" AWAIT_TIMEOUT_SECONDS=180 \ + node "$RUNNER_TEMP/android-test-policy/dependabot-pr-gate.mjs" await "$pull_request_file" + live_head_sha="$(jq -r '.head.sha' "$pull_request_file")" + if [[ "$live_head_sha" != "$EVENT_PR_SHA" ]]; then + echo "::error::기다리는 사이 PR head 가 $EVENT_PR_SHA 에서 $live_head_sha 로 움직였습니다. 새 head 의 run 이 판정합니다" + exit 1 + fi + else + jq '.pull_request' "$GITHUB_EVENT_PATH" > "$pull_request_file" + fi elif [[ "$EVENT_NAME" == "workflow_dispatch" && "$DISPATCH_PR_NUMBER" != "0" ]]; then if ! [[ "$DISPATCH_PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then echo "::error::유효한 pull_request_number 가 필요합니다" @@ -556,7 +587,7 @@ jobs: - name: Set up Gradle and validate wrapper if: steps.target.outputs.run_lane == 'true' - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-provider: basic cache-read-only: true @@ -616,8 +647,11 @@ jobs: local test_arguments=() jvm_arguments=() # #1824 merge-group API34: cold APK packaging exhausted the 2Gi heap # in ApkFlinger. The 15Gi runner still had 7Gi available, so reserve - # 4Gi for this lane only and preserve the existing UTF-8 JVM flag. - if [[ "$DEVICE_TASK" == "pixel2Api34DebugAndroidTest" ]]; then + # 4Gi and preserve the existing UTF-8 JVM flag. + # #2250 merge-group API30 (#1837 큐): 같은 ApkFlinger OOM 이 :app:packageDebug 에서 + # 시스템 이미지 설치와 겹쳐 났다. 전체 androidTest 레인도 같은 4Gi 를 쓴다. + if [[ "$DEVICE_TASK" == "pixel2Api34DebugAndroidTest" || + "$DEVICE_TASK" == "pixel2Api30DebugAndroidTest" ]]; then jvm_arguments+=("-Dorg.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8") fi if [[ -n "$selectors" ]]; then diff --git a/.github/workflows/api-contract-smoke.yml b/.github/workflows/api-contract-smoke.yml index e72e94d48f..fa343bae0a 100644 --- a/.github/workflows/api-contract-smoke.yml +++ b/.github/workflows/api-contract-smoke.yml @@ -59,7 +59,7 @@ jobs: java-version: '21' - name: Set up Gradle and validate wrapper - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-provider: basic cache-read-only: true diff --git a/.github/workflows/baseline-profile.yml b/.github/workflows/baseline-profile.yml index 4135680397..c14100494e 100644 --- a/.github/workflows/baseline-profile.yml +++ b/.github/workflows/baseline-profile.yml @@ -47,7 +47,7 @@ jobs: java-version: '21' - name: Set up Gradle and validate wrapper - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-provider: basic cache-read-only: true diff --git a/.github/workflows/build-cache-warm.yml b/.github/workflows/build-cache-warm.yml index cc8655fa60..2094bbf7ad 100644 --- a/.github/workflows/build-cache-warm.yml +++ b/.github/workflows/build-cache-warm.yml @@ -63,7 +63,7 @@ jobs: # «default branch 에서만 write» 인데 이 저장소의 default branch 는 main 이라, develop 에서 # 쓰려면 명시해야 한다. 캐시 대상에는 caches/build-cache-1 이 포함된다. - name: Set up Gradle and validate wrapper - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-provider: basic cache-read-only: false diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f527e90441..fabfb0defe 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -136,14 +136,14 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: languages: actions build-mode: none - name: Analyze and upload CodeQL results id: analyze - uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: category: /language:actions @@ -173,14 +173,14 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: languages: javascript-typescript build-mode: none - name: Analyze and upload CodeQL results id: analyze - uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: category: /language:javascript-typescript @@ -220,7 +220,7 @@ jobs: # 읽기 전용이다 — push 에서도 쓰지 않는다. 이 job 은 --build-cache 를 쓸 수 없어 # build-cache-warm.yml 이 develop 에 채운 캐시 키를 빈 엔트리로 선점해서는 안 된다. - name: Set up Gradle and validate wrapper - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-provider: basic cache-read-only: true @@ -230,10 +230,12 @@ jobs: uses: ./.github/actions/setup-ci-config - name: Initialize CodeQL - uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: languages: java-kotlin build-mode: manual + # Kotlin 2.4.20 support starts with the CodeQL 2.27.1 bundle. + tools: https://github.com/github/codeql-action/releases/download/codeql-bundle-v2.27.1/codeql-bundle.tar.gz # Java/Kotlin extraction observes compiler invocations, so a clean manual build is the # contract. Domain 모듈은 전부 순수 JVM 이라 classes 로 컴파일한다 (#1151). android 규약을 @@ -256,7 +258,7 @@ jobs: - name: Analyze and upload CodeQL results id: analyze - uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: category: /language:java-kotlin diff --git a/.github/workflows/conflict-label.yml b/.github/workflows/conflict-label.yml index 5b0cb8cea3..b99565c38b 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/dependabot-pr-gate.yml b/.github/workflows/dependabot-pr-gate.yml index cd5e362f72..9e29ba67f8 100644 --- a/.github/workflows/dependabot-pr-gate.yml +++ b/.github/workflows/dependabot-pr-gate.yml @@ -6,6 +6,11 @@ # pull_request_target 인 이유: dependabot 이 연 pull_request 이벤트의 토큰은 읽기 전용이라 # 이슈 생성·PR 편집을 못 한다. default branch 의 워크플로 정의만 실행되고, PR 코드는 # 체크아웃하지도 실행하지도 않는다. default branch 의 정책 스크립트와 GitHub API 응답만 쓴다. +# +# 이 편집이 낸 edited 는 GITHUB_TOKEN 이라 PR Validation·Managed Device 를 다시 깨우지 못한다. +# 여기서 그 둘을 dispatch·재실행하지 않고 actions 권한도 두지 않는다. 같은 sha 의 dispatch 초록은 +# PR 롤업이 옛 실패를 덮지 못하고(#1825), 재실행은 옛 페이로드를 쓴다. 대신 두 검증이 dependabot +# PR 에서만 이 게이트가 채울 때까지 live PR 을 기다린다(#2212, dependabot-pr-gate.mjs await). name: dependabot-pr-gate on: diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml index 93c8a01dca..5ab84d9c07 100644 --- a/.github/workflows/dependency-audit.yml +++ b/.github/workflows/dependency-audit.yml @@ -41,7 +41,7 @@ jobs: java-version: '21' - name: Set up Gradle and validate wrapper - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-provider: basic validate-wrappers: true diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index d230020ef0..5c314dd8b7 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -1,25 +1,26 @@ name: Dependency Review +# PR 에서는 pr-validation.yml 이 매번 부른다 (#2258). 예전에는 의존성 파일 paths 필터로 +# 직접 pull_request 를 들었는데, 그러면 소스만 고친 PR 에 check 가 생기지 않아 ruleset 의 +# required context 로 걸 수 없었다. 이제 repository-quality 가 같은 경로 목록으로 변경을 +# 분류하고, 의존성 입력이 없으면 아래 job 이 검사 스텝만 건너뛴 채 성공한다. 분류가 +# 실패하면 호출자가 run_review 를 true 로 넘겨 검사 쪽으로 닫힌다. +# +# 검사는 pull_request 이벤트에서만 할 수 있다. dependency-submission.yml 이 PR 스냅샷을 +# pull_request 에서만 만들기 때문이다. +# - merge group 은 건너뛴다. 큐에 들어온 PR 은 각 head 에서 이 required check 를 이미 +# 통과했고, 큐 SHA 는 그 head 들과 develop 의 병합이다. +# - bridge dispatch(GITHUB_TOKEN 커밋 뒤 PR Validation 재호출)는 의존성 입력이 바뀐 PR 이면 +# 실패로 닫는다. 건너뛰면 이전 head 의 실패를 검사 없는 초록이 덮는다. PR 브랜치에 새로 +# push 하면 pull_request 실행이 스냅샷과 함께 다시 검사한다. on: - pull_request: - paths: - - '**/*.gradle' - - '**/*.gradle.kts' - - '**/*.lockfile' - - '**/build-logic/**' - - '**/buildSrc/**' - - '**/gradle.properties' - - '**/gradlew' - - '**/gradlew.bat' - - 'gradle/**' - - '.github/workflows/dependency-review.yml' - - '.github/workflows/dependency-submission.yml' - - '.github/workflows/dependency-submission-trusted.yml' - - '.github/workflows/dependency-submission-upload.yml' - -concurrency: - group: dependency-review-${{ github.event.pull_request.number }} - cancel-in-progress: true + workflow_call: + inputs: + run_review: + description: "의존성 그래프 입력이 바뀌어 검사가 필요한지 여부" + required: false + default: true + type: boolean permissions: contents: read @@ -30,11 +31,28 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: + - name: Skip unchanged dependency graph inputs + if: ${{ !inputs.run_review }} + run: echo "::notice::의존성 그래프 입력이 바뀌지 않아 Dependency Review 를 건너뜁니다." + + - name: Skip merge group revalidation + if: ${{ inputs.run_review && github.event_name == 'merge_group' }} + run: echo "::notice::큐에 들어온 PR head 가 이미 Dependency Review 를 통과했으므로 merge group 에서는 건너뜁니다." + + - name: Reject dependency changes without a pull request snapshot + if: ${{ inputs.run_review && github.event_name != 'pull_request' && github.event_name != 'merge_group' }} + env: + EVENT_NAME: ${{ github.event_name }} + run: | + echo "::error::$EVENT_NAME 실행에는 비교할 PR 의존성 스냅샷이 없습니다. 의존성 입력이 바뀐 PR 은 브랜치에 새로 push 해 pull_request 실행으로 검사하세요." + exit 1 + # PR snapshot 은 두 workflow 를 거쳐 API 에 올라온다 (fork PR 을 checkout 하지 않기 위한 분리다). # 1. Generate PR Dependency Graph — PR context 에서 artifact 만 만든다. # 2. Submit PR Dependency Graph — trusted workflow_run 에서 artifact 를 제출한다. # 이 job 은 1번과 동시에 출발하므로 제출이 끝날 때까지 snapshot warning 을 재시도한다. - name: Fail on newly introduced high-severity vulnerabilities + if: ${{ inputs.run_review && github.event_name == 'pull_request' }} uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 with: fail-on-scopes: runtime, development, unknown diff --git a/.github/workflows/firebase-wif-canary.yml b/.github/workflows/firebase-wif-canary.yml index 61238d6ab2..074357100e 100644 --- a/.github/workflows/firebase-wif-canary.yml +++ b/.github/workflows/firebase-wif-canary.yml @@ -91,7 +91,7 @@ jobs: java-version: '21' - name: Set up Gradle and validate wrapper - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-provider: basic cache-read-only: false diff --git a/.github/workflows/latest-review-decision-event.yml b/.github/workflows/latest-review-decision-event.yml deleted file mode 100644 index ee0263767e..0000000000 --- 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 270dfc1ece..0000000000 --- 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/lint.yml b/.github/workflows/lint.yml index 4383e66ae4..839de9b2e3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -62,7 +62,7 @@ jobs: # 캐싱 소유자는 setup-gradle 하나 — setup-java 의 cache: gradle 은 정책 테스트 # (supply-chain-policy.test.mjs)가 금지한다. wrapper 검증도 여기서 fail-closed. - name: Set up Gradle and validate wrapper - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-provider: basic cache-read-only: true @@ -108,7 +108,7 @@ jobs: java-version: '21' - name: Set up Gradle and validate wrapper - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-provider: basic cache-read-only: true diff --git a/.github/workflows/merge-queue-dequeue.yml b/.github/workflows/merge-queue-dequeue.yml index 1ecb7f86bb..22de0e50c6 100644 --- a/.github/workflows/merge-queue-dequeue.yml +++ b/.github/workflows/merge-queue-dequeue.yml @@ -7,6 +7,9 @@ # 판정은 handle-merge-queue-dequeue.mjs 가 한다 — 이 PR 의 큐 브랜치 merge_group run 에 실패 job 이 # 있으면 링크를 코멘트로 남기고, 없으면(조용한 방출) 같은 head 당 한 번만 재투입하고, # MANUAL·ALREADY_MERGED·MERGE 는 아무것도 하지 않는다. +# +# 재투입은 여기서 하지 않고 merge-queue-requeue.yml 을 dispatch 한다. GITHUB_TOKEN 으로 넣은 큐 항목은 +# merge_group CI 를 띄우지 못하고, 이 브리지는 시크릿을 쓰지 않으므로 사람 토큰을 가진 쪽으로 넘긴다. name: merge-queue-dequeue on: @@ -24,8 +27,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 permissions: - actions: read - contents: write + # 재투입 워크플로 dispatch 에 쓴다. + actions: write + contents: read issues: write pull-requests: write steps: @@ -44,4 +48,5 @@ jobs: GITHUB_REPOSITORY: ${{ github.repository }} PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} DEQUEUE_REASON: ${{ github.event.reason }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: node .github/scripts/handle-merge-queue-dequeue.mjs diff --git a/.github/workflows/merge-queue-requeue.yml b/.github/workflows/merge-queue-requeue.yml new file mode 100644 index 0000000000..6b5f99988d --- /dev/null +++ b/.github/workflows/merge-queue-requeue.yml @@ -0,0 +1,53 @@ +# merge queue 재투입 (merge-queue-dequeue.yml 이 dispatch) +# +# GITHUB_TOKEN 으로 큐에 넣으면 GitHub 이 그 이벤트로 워크플로를 띄우지 않아 merge_group CI 가 하나도 돌지 않는다. +# 큐는 check timeout(60분)까지 기다렸다가 CI_TIMEOUT 으로 빼고, 그 뒤에 쌓인 PR 들도 다시 검증한다. +# 그래서 투입은 사람 토큰(MERGE_QUEUE_TOKEN, 1hyok 의 fine-grained PAT)으로 한다. 시크릿이 없으면 넣지 않고 +# 직접 투입하라는 코멘트를 남긴다. +# +# 방출 판정과 마커 코멘트는 방출 처리 job 이 끝냈다. 여기서는 live 상태를 다시 보고 판정한 head 그대로 열려 있고 +# 큐 밖일 때만 넣는다. default branch 의 스크립트만 실행하고 PR 코드는 체크아웃하지 않는다. +name: merge-queue-requeue + +on: + workflow_dispatch: + inputs: + pull_request_number: + description: 재투입할 PR 번호 + required: true + type: string + head_sha: + description: 방출 판정 때의 head SHA. 그사이 새 커밋이 올라왔으면 넣지 않는다. + required: true + type: string + +permissions: {} + +jobs: + requeue: + concurrency: + group: merge-queue-requeue-${{ github.repository_id }}-${{ inputs.pull_request_number }} + cancel-in-progress: false + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + issues: write + pull-requests: write + steps: + - name: Clone requeue policy 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 + + - name: 사람 토큰으로 재투입 + env: + MODE: requeue + GITHUB_TOKEN: ${{ github.token }} + MERGE_QUEUE_TOKEN: ${{ secrets.MERGE_QUEUE_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }} + HEAD_SHA: ${{ inputs.head_sha }} + run: node .github/scripts/handle-merge-queue-dequeue.mjs diff --git a/.github/workflows/pr-assign-author.yml b/.github/workflows/pr-assign-author.yml index 9e8f5c72e5..b72aa9ff57 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/pr-validation.yml b/.github/workflows/pr-validation.yml index e33f356dc2..2f7b175516 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -1,7 +1,7 @@ name: PR Validation # PR 검증의 유일한 진입점이다. repository quality 가 변경 파일을 완전하게 분류한 뒤 -# static analysis · unit test · screenshot 을 reusable workflow 로 한 번씩 부른다. +# static analysis · unit test · screenshot · dependency review 를 reusable workflow 로 한 번씩 부른다. # 각 job check 를 ruleset 에서 직접 required 로 요구하므로 문서 전용 PR 도 같은 이름의 # check 를 만들되, 무거운 job 내부는 즉시 건너뛴다. 모든 검증 뒤 집계용 runner 는 없다. # required context 이름은 pr-gate-policy.test.mjs 에 고정해 workflow 이름 변경과 ruleset @@ -87,3 +87,11 @@ jobs: run_validation: ${{ needs.repository-quality.result != 'success' || needs.repository-quality.outputs.screenshot_required != 'false' }} gradle_tasks: ${{ needs.repository-quality.result == 'success' && needs.repository-quality.outputs.screenshot_tasks || ':core:ui:validateScreenshotTest :feature:home:presentation:validateScreenshotTest :feature:receiver:presentation:validateScreenshotTest :feature:onboarding:presentation:validateScreenshotTest :feature:afternote:presentation:validateScreenshotTest :feature:mindrecord:presentation:validateScreenshotTest :feature:setting:presentation:validateScreenshotTest :feature:timeletter:presentation:validateScreenshotTest' }} screenshot_modules: ${{ needs.repository-quality.result == 'success' && needs.repository-quality.outputs.screenshot_modules || ':core:ui :feature:home:presentation :feature:receiver:presentation :feature:onboarding:presentation :feature:afternote:presentation :feature:mindrecord:presentation :feature:setting:presentation :feature:timeletter:presentation' }} + + dependency-review: + name: Dependency Review + needs: repository-quality + if: ${{ !cancelled() }} + uses: ./.github/workflows/dependency-review.yml + with: + run_review: ${{ needs.repository-quality.result != 'success' || needs.repository-quality.outputs.dependency_review_required != 'false' }} diff --git a/.github/workflows/release-aab-preflight.yml b/.github/workflows/release-aab-preflight.yml index cbaf5ff396..d6b83fb31f 100644 --- a/.github/workflows/release-aab-preflight.yml +++ b/.github/workflows/release-aab-preflight.yml @@ -43,7 +43,7 @@ jobs: java-version: "21" - name: Set up Gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-provider: basic cache-read-only: true diff --git a/.github/workflows/release-distribution.yml b/.github/workflows/release-distribution.yml index e051378929..959438b44d 100644 --- a/.github/workflows/release-distribution.yml +++ b/.github/workflows/release-distribution.yml @@ -75,7 +75,7 @@ jobs: java-version: '21' - name: Set up Gradle and validate wrapper - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-provider: basic cache-read-only: false diff --git a/.github/workflows/release-play-internal.yml b/.github/workflows/release-play-internal.yml index b54248a1b1..a64d5a5a8e 100644 --- a/.github/workflows/release-play-internal.yml +++ b/.github/workflows/release-play-internal.yml @@ -90,7 +90,7 @@ jobs: java-version: '21' - name: Set up Gradle and validate wrapper - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-provider: basic cache-read-only: true diff --git a/.github/workflows/repository-quality.yml b/.github/workflows/repository-quality.yml index 2c173a018d..7b92da1793 100644 --- a/.github/workflows/repository-quality.yml +++ b/.github/workflows/repository-quality.yml @@ -54,6 +54,9 @@ on: codeql_java_kotlin: description: "Java/Kotlin CodeQL 분석이 필요한지 여부" value: ${{ jobs.repository-quality.outputs.codeql_java_kotlin }} + dependency_review_required: + description: "의존성 그래프 입력이 바뀌어 Dependency Review 가 필요한지 여부" + value: ${{ jobs.repository-quality.outputs.dependency_review_required }} push: branches: [develop, main] @@ -87,6 +90,7 @@ jobs: screenshot_tasks: ${{ steps.classify-documentation-changes.outputs.screenshot_tasks }} codeql_actions: ${{ steps.classify-documentation-changes.outputs.codeql_actions }} codeql_java_kotlin: ${{ steps.classify-documentation-changes.outputs.codeql_java_kotlin }} + dependency_review_required: ${{ steps.classify-documentation-changes.outputs.dependency_review_required }} steps: - name: Clone repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -109,7 +113,8 @@ jobs: run: node .github/scripts/ensure-api-quota.mjs ensure --max-wait 60 # GitHub 의 server-side paths 평가는 유한한 diff 앞부분만 보므로, 그 경계를 넘는 PR 을 - # 그대로 허용하면 뒤쪽 dependency 파일이 graph/review workflow 를 우회할 수 있다. + # 그대로 허용하면 뒤쪽 파일이 paths 필터를 쓰는 workflow 를 우회할 수 있다. Dependency + # Review 는 #2258 부터 아래 전체 파일 분류로 판정하지만 다른 paths 필터 workflow 가 남아 있다. # 공식 문서의 상충하는 300/3,000 한도 중 보수적인 300을 fail-closed 경계로 쓴다. - name: Guard server-side path filter boundary id: changed-files @@ -118,6 +123,7 @@ jobs: CHANGED_FILES: ${{ github.event.pull_request.changed_files }} GH_TOKEN: ${{ github.token }} MAX_PATH_FILTER_FILES: 300 + PR_AUTHOR: ${{ github.event.pull_request.user.login }} PR_NUMBER: ${{ inputs.pull_request_number }} run: | set -euo pipefail @@ -138,6 +144,14 @@ jobs: fi CHANGED_FILES=$(jq -r '.changed_files' <<< "$pull_request") printf '%s' "$pull_request" > "$pull_request_file" + elif [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$PR_AUTHOR" = "dependabot[bot]" ]; then + # dependabot-pr-gate 가 같은 순간 GITHUB_TOKEN 으로 제목 (#N)·Closes·CI Test Plan 을 채운다. + # 그 edited 는 이 워크플로를 다시 깨우지 못하고 페이로드는 opened 시점 값이다(#2212). + # 페이로드 제목에 대표 이슈 번호가 이미 있으면 API 없이 그대로 쓰고, 없으면 게이트가 채울 + # 때까지 live PR 을 기다렸다가 그 값으로 판정한다. quota 대기 60초와 합쳐도 job timeout + # 3분 안에 들도록 90초에서 끊는다. + AWAIT_TIMEOUT_SECONDS=90 node .github/scripts/dependabot-pr-gate.mjs await "$pull_request_file" + CHANGED_FILES=$(jq -r '.changed_files' "$pull_request_file") elif [ -z "$CHANGED_FILES" ]; then pull_request=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") CHANGED_FILES=$(jq -r '.changed_files' <<< "$pull_request") diff --git a/.github/workflows/review-debt-guard.yml b/.github/workflows/review-debt-guard.yml deleted file mode 100644 index f64c37a3c8..0000000000 --- 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 cbcba2ebe2..0000000000 --- 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/screenshot-baseline-generate.yml b/.github/workflows/screenshot-baseline-generate.yml index 36cfd5f7cd..3ba4977c77 100644 --- a/.github/workflows/screenshot-baseline-generate.yml +++ b/.github/workflows/screenshot-baseline-generate.yml @@ -72,7 +72,7 @@ jobs: uses: ./.github/actions/setup-ci-config - name: Set up Gradle and validate wrapper - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-provider: basic cache-read-only: true diff --git a/.github/workflows/screenshot.yml b/.github/workflows/screenshot.yml index 5465785242..5c283c4fa6 100644 --- a/.github/workflows/screenshot.yml +++ b/.github/workflows/screenshot.yml @@ -71,7 +71,7 @@ jobs: # setup-gradle 의 공개 MIT cache provider 로 host 의 Gradle home 을 복원한 뒤 # 컨테이너 /root/.gradle 로 마운트한다. PR 은 cache poisoning 방지를 위해 읽기 전용이다. - name: Set up Gradle and validate wrapper - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-provider: basic cache-read-only: true diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index cb1079109e..be742ad230 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -127,7 +127,7 @@ jobs: - name: Set up Gradle and validate wrapper if: inputs.gradle_tasks != '' - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-provider: basic cache-read-only: true diff --git a/.github/workflows/viewed-auto-merge.yml b/.github/workflows/viewed-auto-merge.yml new file mode 100644 index 0000000000..df32ac8c89 --- /dev/null +++ b/.github/workflows/viewed-auto-merge.yml @@ -0,0 +1,82 @@ +# Viewed 기반 자동 머지 예약 (#2248) +# +# 적용 범위는 VIEWED_BY 본인이 작성한 PR 이다(#2270). 팀원·봇 PR 은 예약하지 않는다. +# +# VIEWED_BY 가 PR 의 모든 파일을 Viewed 로 체크하면 «Merge when ready» 를 대신 건다. 리뷰 상태는 보지 않는다. +# 머지는 GitHub 가 룰셋·required check·merge queue 를 모두 통과시킨 뒤에만 하므로 CI 가 빨가면 머지되지 않는다. +# +# 반대 방향(예약 해제·큐에서 빼기)은 하지 않는다(#2252 폐지). Viewed 가 덜 찬 PR 도 사람이 직접 예약하거나 큐에 넣으면 +# 그대로 머지된다. Viewed 는 자동 예약의 조건일 뿐 머지를 막는 장치가 아니다. +# +# Viewed 체크는 webhook 이벤트가 없어 주기 실행으로 훑는다. viewerViewedState 는 토큰 주인 본인 것만 보이므로 VIEWED_BY 의 읽기 전용 토큰을 VIEWED_TOKEN +# 시크릿으로 둔다. 시크릿이 없으면 아무것도 하지 않는다. +# +# 예약 쓰기는 MERGE_QUEUE_TOKEN(1hyok 의 fine-grained PAT)이 한다. GITHUB_TOKEN 으로 예약하거나 큐에 넣으면 GitHub 이 +# 그 이벤트로 워크플로를 띄우지 않아 merge_group CI 가 하나도 돌지 않는다. 큐는 check timeout(60분)까지 맨 앞을 붙잡았다가 +# CI_TIMEOUT 으로 빼고, 뒤에 쌓인 PR 들도 다시 검증한다(1005 #2208 실측). 시크릿이 없으면 예약하지 않는다. +name: Viewed Auto Merge + +on: + schedule: + - cron: '*/10 * * * *' + workflow_dispatch: + +concurrency: + group: viewed-auto-merge-${{ github.repository }} + cancel-in-progress: false + +permissions: {} + +jobs: + enable: + name: Enable auto-merge for fully viewed PRs + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + # 기본 브랜치의 스크립트만 체크아웃한다. 예약 쓰기는 MERGE_QUEUE_TOKEN 이 한다. + contents: read + steps: + - name: Clone script 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 + + - name: Find PRs to enable auto-merge + id: find + env: + VIEWED_TOKEN: ${{ secrets.VIEWED_TOKEN }} + VIEWED_BY: 1hyok + BASE_BRANCH: ${{ github.event.repository.default_branch }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_API_URL: ${{ github.api_url }} + run: | + if [ -z "$VIEWED_TOKEN" ]; then + echo "::notice::VIEWED_TOKEN 시크릿이 없어 건너뜁니다" + echo 'targets=[]' >> "$GITHUB_OUTPUT" + exit 0 + fi + node .github/scripts/find-viewed-auto-merge-targets.mjs + + - name: Enable auto-merge + if: steps.find.outputs.targets != '[]' + env: + GH_TOKEN: ${{ secrets.MERGE_QUEUE_TOKEN }} + GH_REPO: ${{ github.repository }} + TARGETS: ${{ steps.find.outputs.targets }} + run: | + if [ -z "$GH_TOKEN" ]; then + echo "::warning::MERGE_QUEUE_TOKEN 시크릿이 없어 예약하지 않습니다. GITHUB_TOKEN 으로 넣으면 merge_group CI 가 돌지 않습니다." + exit 0 + fi + # merge queue 대상이면 전략 없이 부른다. check 가 남았으면 auto-merge, 끝났으면 큐 투입이다. + # 판정 뒤 새 커밋이 올라왔으면 --match-head-commit 이 거부한다. 스택 PR 처럼 GitHub 가 거부하는 + # 경우는 사람이 버튼을 누르면 되므로 경고로 남긴다. + jq -c '.[]' <<< "$TARGETS" | while read -r target; do + number=$(jq -r '.number' <<< "$target") + head_sha=$(jq -r '.headSha' <<< "$target") + if ! gh pr merge "$number" --match-head-commit "$head_sha"; then + echo "::warning::#$number 자동 머지 예약 실패 — PR 화면에서 Merge when ready 를 직접 누르세요" + fi + done diff --git a/README.md b/README.md index b7d2e69a98..9bb88e9762 100644 --- a/README.md +++ b/README.md @@ -183,13 +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`은 모든 리뷰 스레드 해결도 필요하다. -- 리뷰 결과는 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)를 예약한다. Viewed 체크에는 이벤트가 없어 주기 실행으로 훑고, 본인 체크만 조회되므로 1hyok의 읽기 전용 토큰을 `VIEWED_TOKEN` 시크릿으로 둔다. 필수 검사가 통과해야 merge queue로 들어간다. 이 workflow는 예약을 걸기만 하고 풀거나 큐에서 빼지 않으므로, Viewed가 덜 찬 PR도 직접 예약하거나 큐에 넣으면 그대로 머지된다. 예약을 멈추려면 PR 화면에서 끈다. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 38b91e10e1..3592761784 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -50,6 +50,11 @@ android { execution = "ANDROIDX_TEST_ORCHESTRATOR" // Robolectric 이 병합된 매니페스트·리소스를 읽어야 NavHost 를 실제 컴포지션으로 띄울 수 있다 (#1601). unitTests.isIncludeAndroidResources = true + unitTests.all { + // 여러 SDK의 앱 리소스를 읽은 뒤에도 후속 테스트의 샌드박스를 생성할 수 있어야 한다. + // 기본 512 MB에서는 API 33 알림 권한 테스트의 리소스 로딩이 OOM으로 실패한다 (#2200). + it.maxHeapSize = "1g" + } managedDevices { localDevices { create("pixel2Api26") { @@ -228,7 +233,6 @@ baselineProfile { dependencies { implementation(libs.coil.compose) - implementation(libs.androidx.hilt.navigation.compose) implementation(libs.core.splashscreen) // App Startup — 기동 초기화는 app 매니페스트에 등록한 Initializer 로 실행한다. @@ -309,10 +313,12 @@ dependencies { androidTestImplementation(testFixtures(projects.core.ui)) androidTestImplementation(projects.feature.afternote.domain) androidTestImplementation(testFixtures(projects.feature.afternote.domain)) + androidTestImplementation(testFixtures(projects.feature.afternote.presentation)) androidTestImplementation(projects.feature.mindrecord.domain) androidTestImplementation(testFixtures(projects.feature.mindrecord.domain)) androidTestImplementation(projects.feature.receiver.domain) androidTestImplementation(testFixtures(projects.feature.receiver.domain)) + androidTestImplementation(testFixtures(projects.feature.setting.data)) androidTestImplementation(projects.feature.setting.data) androidTestImplementation(projects.feature.setting.domain) androidTestImplementation(testFixtures(projects.feature.setting.domain)) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt index 3d20af2511..eba20e2b69 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt @@ -25,8 +25,7 @@ import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.author.ListItem import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository -import com.afternote.feature.afternote.presentation.home.AfternoteHomeEntry -import com.afternote.feature.afternote.presentation.home.AfternoteHomeViewModel +import com.afternote.feature.afternote.presentation.testing.AfternoteHomeScreenFixture import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -66,17 +65,16 @@ class AfternoteAuthorExtendedAndroidTest { listFlows[null] = Pager(PagingConfig(pageSize = 20)) { pagingSource }.flow listFlows[AfternoteType.SOCIAL_NETWORK] = flowOf(PagingData.empty()) - val viewModel = AfternoteHomeViewModel(repository, FakeErrorReporter()) + val homeFixture = AfternoteHomeScreenFixture(repository, FakeErrorReporter()) val detailRoutes = mutableListOf() val addRoutes = mutableListOf() composeRule.setContent { AfternoteTheme { - AfternoteHomeEntry( + homeFixture.Content( navigateToDetail = detailRoutes::add, navigateToAdd = addRoutes::add, onSettingClick = {}, - viewModel = viewModel, ) } } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt index 20b113c583..1902fc2cbe 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteHomeRefreshBannerAndroidTest.kt @@ -20,8 +20,7 @@ import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.author.ListItem import com.afternote.feature.afternote.domain.testing.FakeAfternoteRepository -import com.afternote.feature.afternote.presentation.home.AfternoteHomeEntry -import com.afternote.feature.afternote.presentation.home.AfternoteHomeViewModel +import com.afternote.feature.afternote.presentation.testing.AfternoteHomeScreenFixture import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -78,15 +77,14 @@ class AfternoteHomeRefreshBannerAndroidTest { }.flow } } - val viewModel = AfternoteHomeViewModel(repository, FakeErrorReporter()) + val homeFixture = AfternoteHomeScreenFixture(repository, FakeErrorReporter()) composeRule.setContent { AfternoteTheme { - AfternoteHomeEntry( + homeFixture.Content( navigateToDetail = {}, navigateToAdd = {}, onSettingClick = {}, - viewModel = viewModel, ) } } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt index 5865b76162..6c0a2dc446 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt @@ -45,9 +45,7 @@ import com.afternote.core.ui.Route import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock -import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailRoute -import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailViewModel -import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteRoute +import com.afternote.feature.afternote.presentation.testing.ReceivedAfternoteDetailScreenFixture import com.afternote.feature.home.presentation.HomeTabActions import com.afternote.feature.home.presentation.receiver.ReceiverHomeEvent import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository @@ -502,42 +500,30 @@ class ReceiverRuntimeCompletionAndroidTest { actions = homeActions } AfternoteTheme { - NavHost( - navController = currentAppState.navController, - startDestination = Route.Home, - ) { - composable { Text("home route") } - composable { Text("afternote route") } - composable { Text("mind record route") } - composable { Text("time letter route") } - composable { Text("memory space route") } - composable { Text("setting route") } - } + Text("home route") } } composeRule.onNodeWithText("home route").assertIsDisplayed() val homeActions = checkNotNull(actions) composeRule.runOnIdle { homeActions.onNextStepClick() } - composeRule.onNodeWithText("afternote route").assertIsDisplayed() + assertEquals(Route.Afternote, composeRule.runOnIdle { checkNotNull(appState).backStack.last() }) // 카테고리 카드는 시안에 없어 사라졌지만(#700) 주간 이미지·카운트는 여전히 // Route.MindRecord 로 간다 — 살아 있는 진입점이라 가드를 그쪽으로 옮긴다 (리뷰 지적). composeRule.runOnIdle { homeActions.onWeeklyImageClick() } - composeRule.onNodeWithText("mind record route").assertIsDisplayed() - composeRule.runOnIdle { checkNotNull(appState).navController.popBackStack() } + assertEquals(Route.MindRecord, composeRule.runOnIdle { checkNotNull(appState).backStack.last() }) composeRule.runOnIdle { homeActions.onWeeklyCountClick() } - composeRule.onNodeWithText("mind record route").assertIsDisplayed() - composeRule.runOnIdle { checkNotNull(appState).navController.popBackStack() } + assertEquals(Route.MindRecord, composeRule.runOnIdle { checkNotNull(appState).backStack.last() }) // 2026-08-09 확정된 타임레터 NEXT STEP 카드의 목적지 (#700). composeRule.runOnIdle { homeActions.onTimeLetterNextStepClick() } - composeRule.onNodeWithText("time letter route").assertIsDisplayed() + assertEquals(Route.TimeLetter, composeRule.runOnIdle { checkNotNull(appState).backStack.last() }) - composeRule.runOnIdle { checkNotNull(appState).navController.popBackStack() } composeRule.runOnIdle { homeActions.onMemoriesSectionClick() } - composeRule.onNodeWithText("memory space route").assertIsDisplayed() + assertEquals(Route.MindRecord, composeRule.runOnIdle { checkNotNull(appState).backStack.last() }) + assertEquals(Route.MemorySpace, composeRule.runOnIdle { checkNotNull(appState).pendingMindRecordDestination }) composeRule.runOnIdle { homeActions.onSettingClick() } - composeRule.onNodeWithText("setting route").assertIsDisplayed() + assertEquals(Route.Setting(), composeRule.runOnIdle { checkNotNull(appState).backStack.last() }) } @Test @@ -560,20 +546,19 @@ class ReceiverRuntimeCompletionAndroidTest { ), ), ) - val viewModel = - ReceivedAfternoteDetailViewModel( - route = ReceivedAfternoteRoute.DetailRoute(afternoteId = 202L), + val detailFixture = + ReceivedAfternoteDetailScreenFixture( + afternoteId = 202L, receiverRepository = repository, errorReporter = FakeErrorReporter(), ) composeRule.setContent { AfternoteTheme { - ReceivedAfternoteDetailRoute( + detailFixture.Content( onNavigateBack = {}, onNavigateToFullList = {}, onNavigateToPlaylist = {}, - viewModel = viewModel, ) } } @@ -610,9 +595,9 @@ class ReceiverRuntimeCompletionAndroidTest { ), ), ) - val viewModel = - ReceivedAfternoteDetailViewModel( - route = ReceivedAfternoteRoute.DetailRoute(afternoteId = 303L), + val detailFixture = + ReceivedAfternoteDetailScreenFixture( + afternoteId = 303L, receiverRepository = repository, errorReporter = FakeErrorReporter(), ) @@ -620,11 +605,10 @@ class ReceiverRuntimeCompletionAndroidTest { composeRule.setContent { AfternoteTheme { - ReceivedAfternoteDetailRoute( + detailFixture.Content( onNavigateBack = {}, onNavigateToFullList = {}, onNavigateToPlaylist = playlistRoutes::add, - viewModel = viewModel, ) } } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordHomeRequestCountAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordHomeRequestCountAndroidTest.kt index c329232448..d59b9cd5e3 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordHomeRequestCountAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordHomeRequestCountAndroidTest.kt @@ -14,7 +14,7 @@ import androidx.navigation.compose.ComposeNavigator import androidx.navigation.testing.TestNavHostController import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.navigation.AppNavigation -import com.afternote.afternote_fe.navigation.AppState +import com.afternote.afternote_fe.navigation.rememberAfternoteAppState import com.afternote.afternote_fe.test.FailureArtifactRule import com.afternote.core.ui.Route import com.afternote.core.ui.theme.AfternoteTheme @@ -91,7 +91,8 @@ class MindRecordHomeRequestCountAndroidTest { AfternoteTheme { AppNavigation( startDestination = Route.MindRecord, - appState = AppState(navController), + appState = rememberAfternoteAppState(Route.MindRecord), + mindRecordNavController = navController, ) } } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/PasskeyRegistrationAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/PasskeyRegistrationAndroidTest.kt new file mode 100644 index 0000000000..71abe41179 --- /dev/null +++ b/app/src/androidTest/java/com/afternote/afternote_fe/PasskeyRegistrationAndroidTest.kt @@ -0,0 +1,132 @@ +package com.afternote.afternote_fe + +import android.os.Build +import androidx.activity.compose.setContent +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.afternote.afternote_fe.navigation.AppNavigation +import com.afternote.afternote_fe.navigation.rememberAfternoteAppState +import com.afternote.core.ui.Route +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.data.PasskeyTestScenario +import com.afternote.feature.setting.domain.Passkey +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import jakarta.inject.Inject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeFalse +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class PasskeyRegistrationAndroidTest { + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeRule = createAndroidComposeRule() + + @Inject + lateinit var scenario: PasskeyTestScenario + + @Before + fun setUp() { + hiltRule.inject() + composeRule.activityRule.scenario.onActivity { activity -> + activity.setContent { + AfternoteTheme { + AppNavigation(startDestination = Route.Setting(), appState = rememberAfternoteAppState()) + } + } + } + } + + @Test + fun passkeyList_queriesServerWithoutLocalFlagAndRefreshesOnReturn() { + scenario.passkeys = listOf(Passkey(7L, "Server passkey", "2026-09-06T10:00:00")) + openPasskeys() + waitForText("Server passkey") + composeRule.onNodeWithText("Server passkey").assertIsDisplayed() + + composeRule.runOnIdle { composeRule.activity.onBackPressedDispatcher.onBackPressed() } + scenario.passkeys = listOf(Passkey(8L, "New server passkey", "2026-09-06T11:00:00")) + openPasskeys() + waitForText("New server passkey") + composeRule.onNodeWithText("Server passkey").assertDoesNotExist() + assertTrue(scenario.listCalls.get() >= 2) + } + + @Test + fun passkeyList_failureShowsRetryAndNeverPretendsThereAreNoPasskeys() { + scenario.listFails = true + openPasskeys() + waitForText("패스키 목록을 불러올 수 없습니다.") + composeRule.onNodeWithText("패스키 등록").assertDoesNotExist() + + scenario.listFails = false + composeRule.onNodeWithText("다시 시도").performClick() + waitForText("패스키 등록") + composeRule.onNodeWithText("패스키 등록").assertIsDisplayed() + assertEquals(2, scenario.listCalls.get()) + } + + @Test + fun passkeyRegistration_withoutCredentialProviderShowsFailureAndDoesNotComplete() { + // CI의 AOSP lane에서 실제 Credential Manager 실패를 검증한다. 계정이 있는 Play 기기는 대상이 아니다. + val packageManager = composeRule.activity.packageManager + val hasPlayServices = + runCatching { packageManager.getPackageInfo("com.google.android.gms", 0) }.isSuccess + assumeFalse(hasPlayServices) + assumeFalse(Build.VERSION.SDK_INT < 28) + + openPasskeyPassword() + listOf("1", "2", "3", "4").forEach { composeRule.onNodeWithText(it).performClick() } + waitForText("패스키 등록에 실패했습니다. 다시 시도해 주세요.") + composeRule.onNodeWithText("패스키 등록에 실패했습니다. 다시 시도해 주세요.").assertIsDisplayed() + assertEquals(1, scenario.optionsCalls.get()) + assertTrue(scenario.registeredCredentials.isEmpty()) + composeRule.onNodeWithText("패스키 생성이 완료되었습니다").assertDoesNotExist() + } + + @Test + fun passkeyList_existingEntryRefreshesAfterRegistrationScreenReturns() { + scenario.passkeys = emptyList() + openPasskeys() + waitForText("패스키 등록") + composeRule.onNodeWithText("패스키 등록").performClick() + waitForText("비밀번호로 인증하기") + scenario.passkeys = listOf(Passkey(9L, "Registered on return", "2026-09-08T10:00:00")) + composeRule.runOnIdle { composeRule.activity.onBackPressedDispatcher.onBackPressed() } + waitForText("Registered on return") + assertEquals(2, scenario.listCalls.get()) + } + + private fun openPasskeys() { + waitForText("패스키 관리") + composeRule.onNodeWithText("패스키 관리").performScrollTo().performClick() + } + + private fun openPasskeyPassword() { + openPasskeys() + waitForText("패스키 등록") + composeRule.onNodeWithText("패스키 등록").performClick() + waitForText("비밀번호로 인증하기") + composeRule.onNodeWithText("비밀번호로 인증하기").performClick() + waitForText("비밀번호를 입력해 주세요.") + } + + private fun waitForText(text: String) { + composeRule.waitUntil(timeoutMillis = 15_000) { + composeRule.onAllNodes(hasText(text)).fetchSemanticsNodes().isNotEmpty() + } + } +} diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/ProfileSaveBackAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/ProfileSaveBackAndroidTest.kt new file mode 100644 index 0000000000..6f06072638 --- /dev/null +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ProfileSaveBackAndroidTest.kt @@ -0,0 +1,120 @@ +package com.afternote.afternote_fe + +import androidx.activity.compose.setContent +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextReplacement +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.afternote.core.domain.repository.MyProfileRepository +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.model.user.User +import com.afternote.core.ui.navigation.FeatureNavigationCallbacks +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.presentation.navigation.SettingExternalActions +import com.afternote.feature.setting.presentation.navigation.SettingNavHost +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import javax.inject.Inject + +/** #1438: 실제 SettingNavHost의 entry 이탈이 보류 중 저장을 취소하고 새 entry를 만드는지 검증한다. */ +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class ProfileSaveBackAndroidTest { + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeRule = createAndroidComposeRule() + + @Inject + lateinit var profileRepository: MyProfileRepository + + @Before + fun setUp() { + hiltRule.inject() + } + + @Test + fun systemBackDuringPendingSaveCancelsRequestAndReentryLoadsServerProfile() { + // 기존 app Hilt fixture가 제공하는 동일 저장소의 응답만 보류한다. 프로덕션 nav/VM은 그대로다. + val repository = profileRepository as FakeUserRepository + val saved = User("서버 이름", "qa@example.test", "01000000000", null) + repository.profile = saved + val pendingSave = CompletableDeferred() + val cancelled = CompletableDeferred() + var exits = 0 + repository.onUpdateMyProfile = { _, _, _ -> + try { + pendingSave.await() + } catch (error: CancellationException) { + cancelled.complete(Unit) + throw error + } + } + composeRule.activityRule.scenario.onActivity { activity -> + activity.setContent { + AfternoteTheme { + SettingNavHost( + navigationCallbacks = FeatureNavigationCallbacks { exits += 1 }, + externalActions = + object : SettingExternalActions { + override fun onLogoutSuccess() = Unit + + override fun onWithdrawSuccess() = Unit + }, + ) + } + } + } + waitForText("프로필 수정") + composeRule.onNodeWithText("프로필 수정").performClick() + waitForText("프로필 설정") + composeRule.onAllNodes(hasSetTextAction())[0].performTextReplacement("저장 중 임시 이름") + composeRule.onNodeWithText("수정하기").performScrollTo().performClick() + composeRule.waitUntil(10_000) { repository.profileUpdateCalls.size == 1 } + composeRule.onNodeWithText("수정하기").assertIsNotEnabled() + assertFalse(pendingSave.isCompleted) + + // Activity의 실제 back dispatcher가 SettingNavHost/NavDisplay의 back 처리를 실행한다. + composeRule.activityRule.scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() } + waitForText("프로필 수정") + composeRule.waitUntil(10_000) { cancelled.isCompleted } + assertEquals(0, exits) + assertEquals(saved, repository.profile) + + val loadsBeforeReentry = repository.profileCalls + composeRule.onNodeWithText("프로필 수정").performClick() + waitForText("서버 이름") + composeRule.waitUntil(10_000) { repository.profileCalls > loadsBeforeReentry } + composeRule.onNodeWithText("수정하기").performScrollTo().assertIsEnabled() + + // 취소 뒤 늦은 성공도 현재 entry를 닫거나 서버 fake의 정본을 바꾸지 않는다. + pendingSave.complete(saved.copy(name = "저장 중 임시 이름")) + composeRule.waitForIdle() + composeRule.onNodeWithText("프로필 설정").assertIsDisplayed() + assertEquals(saved, repository.profile) + assertEquals(1, repository.profileUpdateCalls.size) + assertTrue(cancelled.isCompleted) + assertEquals(0, exits) + } + + private fun waitForText(text: String) { + composeRule.waitUntil(10_000) { composeRule.onAllNodes(hasText(text)).fetchSemanticsNodes().isNotEmpty() } + } +} diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt index 8b52e0098f..aed9ab51fc 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverAdvancedAndroidTest.kt @@ -25,9 +25,7 @@ import com.afternote.afternote_fe.test.FakeErrorReporter import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock -import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailRoute -import com.afternote.feature.afternote.presentation.receiver.detail.ReceivedAfternoteDetailViewModel -import com.afternote.feature.afternote.presentation.receiver.navigation.ReceivedAfternoteRoute +import com.afternote.feature.afternote.presentation.testing.ReceivedAfternoteDetailScreenFixture import com.afternote.feature.receiver.domain.model.ReceivedAccountCredentials import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail import com.afternote.feature.receiver.domain.model.ReceivedExportBundle @@ -177,20 +175,19 @@ class ReceiverAdvancedAndroidTest { } detailResults.addLast(Result.failure(IllegalStateException("offline"))) detailResults.addLast(Result.success(receivedSocialDetail())) - val viewModel = - ReceivedAfternoteDetailViewModel( - route = ReceivedAfternoteRoute.DetailRoute(afternoteId = 91L), + val detailFixture = + ReceivedAfternoteDetailScreenFixture( + afternoteId = 91L, receiverRepository = repository, errorReporter = FakeErrorReporter(), ) composeRule.setContent { AfternoteTheme { - ReceivedAfternoteDetailRoute( + detailFixture.Content( onNavigateBack = {}, onNavigateToFullList = {}, onNavigateToPlaylist = {}, - viewModel = viewModel, ) } } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt index 07e2cd1e76..6be388cec2 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverSelectionResultAndroidTest.kt @@ -24,7 +24,7 @@ import androidx.navigation.testing.TestNavHostController import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.afternote.afternote_fe.navigation.AppNavigation -import com.afternote.afternote_fe.navigation.AppState +import com.afternote.afternote_fe.navigation.rememberAfternoteAppState import com.afternote.afternote_fe.test.FailureArtifactRule import com.afternote.core.domain.repository.UserRepository import com.afternote.core.domain.testing.FakeUserRepository @@ -118,7 +118,7 @@ class ReceiverSelectionResultAndroidTest { AfternoteTheme { AppNavigation( startDestination = Route.Afternote, - appState = AppState(navController), + appState = rememberAfternoteAppState(Route.Afternote), ) } } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt new file mode 100644 index 0000000000..670eaaf970 --- /dev/null +++ b/app/src/androidTest/java/com/afternote/afternote_fe/SettingEditorSaveSignalAndroidTest.kt @@ -0,0 +1,241 @@ +package com.afternote.afternote_fe + +import androidx.activity.compose.setContent +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.hasScrollToIndexAction +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isEnabled +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextReplacement +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.afternote.afternote_fe.test.FailureArtifactRule +import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.model.user.ReceiverDetail +import com.afternote.core.ui.navigation.FeatureNavigationCallbacks +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.setting.presentation.navigation.SettingExternalActions +import com.afternote.feature.setting.presentation.navigation.SettingNavHost +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException +import javax.inject.Inject + +/** + * 설정 편집 화면의 저장 신호를 실제 로컬 스택에서 본다 (#2179). + * + * reducer 가 세운 완료 신호(수신자 수정의 `pendingEvent`, 전달 조건의 `isSaveSuccessPending`)가 화면에서 한 번만 + * 소비되어 스택을 정확히 한 칸 내리는지, 실패와 입력 검증은 화면을 닫지 않는지를 잰다. 신호 소비가 + * 두 번 일어나면 한 칸이 아니라 설정 홈까지 내려가고, 소비가 안 되면 편집 화면에 남는다. 둘 다 + * ViewModel 단위 테스트로는 보이지 않는 자리다. + */ +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class SettingEditorSaveSignalAndroidTest { + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeRule = createAndroidComposeRule() + + @get:Rule(order = 2) + val failureArtifactRule = + FailureArtifactRule { + composeRule.onRoot().captureToImage().asAndroidBitmap() + } + + @Inject + lateinit var userRepository: UserRepository + + private val fakeUserRepository: FakeUserRepository + get() = userRepository as FakeUserRepository + + /** 로컬 스택 바닥에서 셸로 나간 횟수 — 설정 안에서 도는 동안은 0 이어야 한다. */ + private var exits = 0 + + @Before + fun setUp() { + hiltRule.inject() + fakeUserRepository.receiverDetails[RECEIVER_ID] = + ReceiverDetail( + receiverId = RECEIVER_ID, + name = RECEIVER_NAME, + relation = "가족", + phone = "01012345678", + email = "kim@afternote.local", + dailyQuestionCount = 0, + timeLetterCount = 0, + afterNoteCount = 0, + message = "잘 지내", + ) + fakeUserRepository.onGetReceiverDetail = null + } + + @Test + fun receiverEdit_saveSuccessPopsExactlyOneScreenToReceiverList() { + fakeUserRepository.onUpdateReceiver = null + fakeUserRepository.onUpdateReceiverMessage = null + openReceiverEdit() + val updatesBefore = fakeUserRepository.receiverUpdateCalls.size + val messagesBefore = fakeUserRepository.receiverMessageCalls.size + + composeRule.onNode(hasSetTextAction() and hasText(RECEIVER_NAME)).performTextReplacement("김수정") + composeRule.onNodeWithText(SAVE_ACTION).assertIsEnabled().performClick() + + waitUntilGone(EDIT_TITLE) + // 한 칸만 내려와 목록이 보인다 — 신호가 두 번 소비되면 설정 홈까지 내려간다. + composeRule.onNodeWithText("김수정").assertIsDisplayed() + composeRule.onAllNodes(hasText("프로필 수정")).assertCountEquals(0) + assertEquals(1, fakeUserRepository.receiverUpdateCalls.size - updatesBefore) + assertEquals(1, fakeUserRepository.receiverMessageCalls.size - messagesBefore) + assertEquals(0, exits) + } + + @Test + fun receiverEdit_saveFailureShowsMessageAndStaysOnEdit() { + fakeUserRepository.onUpdateReceiver = { _, _, _, _, _ -> throw IOException("offline") } + openReceiverEdit() + val updatesBefore = fakeUserRepository.receiverUpdateCalls.size + val messagesBefore = fakeUserRepository.receiverMessageCalls.size + + composeRule.onNodeWithText(SAVE_ACTION).performClick() + + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.runOnIdle { fakeUserRepository.receiverUpdateCalls.size > updatesBefore } + } + // 오류는 LazyColumn의 마지막 항목이므로 작은 화면에서도 스크롤해 실제 표시를 확인한다. + composeRule.onNode(hasScrollToIndexAction()).performScrollToNode(hasText(EDIT_FAILED_MESSAGE)) + composeRule.onNodeWithText(EDIT_FAILED_MESSAGE).assertIsDisplayed() + // 실패는 화면을 닫지 않고, 저장 중 잠금도 풀려 다시 누를 수 있다. + composeRule.onNodeWithText(EDIT_TITLE).assertIsDisplayed() + composeRule.onNodeWithText(SAVE_ACTION).assertIsEnabled() + assertEquals(1, fakeUserRepository.receiverUpdateCalls.size - updatesBefore) + assertEquals(0, fakeUserRepository.receiverMessageCalls.size - messagesBefore) + assertEquals(0, exits) + } + + @Test + fun receiverEdit_invalidEmailOrPhoneBlocksSaveWithoutRequest() { + openReceiverEdit() + val updatesBefore = fakeUserRepository.receiverUpdateCalls.size + + composeRule.onNode(hasSetTextAction() and hasText("kim@afternote.local")).performTextReplacement("kim@afternote") + composeRule.onNodeWithText("올바른 이메일 주소를 입력해주세요.").assertIsDisplayed() + composeRule.onNodeWithText(SAVE_ACTION).assertIsNotEnabled().performClick() + + composeRule.onNode(hasSetTextAction() and hasText("kim@afternote")).performTextReplacement("kim@afternote.local") + composeRule.onNode(hasSetTextAction() and hasText("010", substring = true)).performTextReplacement("0101234") + composeRule.onNodeWithText("올바른 연락처를 입력해주세요.").assertIsDisplayed() + composeRule.onNodeWithText(SAVE_ACTION).assertIsNotEnabled().performClick() + + composeRule.onNodeWithText(EDIT_TITLE).assertIsDisplayed() + assertEquals(0, composeRule.runOnIdle { fakeUserRepository.receiverUpdateCalls.size - updatesBefore }) + assertEquals(0, exits) + } + + @Test + fun deliveryCondition_saveSuccessPopsExactlyOneScreenToReceiverSelection() { + fakeUserRepository.onGetReceiverDeliveryConditions = null + fakeUserRepository.onUpdateReceiverDeliveryConditions = null + launchHost() + waitForSettingHomeContent() + val updatesBefore = fakeUserRepository.deliveryUpdateCalls.size + composeRule.onNodeWithText("사후 전달 조건").performScrollTo().performClick() + composeRule.onAllNodes(checkboxMatcher).run { + assertCountEquals(1) + get(0).performClick() + } + composeRule.onNodeWithText(RECEIVER_SELECT_DONE).performClick() + + // 조회가 끝나야 저장이 열린다. + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodes(hasText(DELIVERY_SAVE) and isEnabled()).fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithText(DELIVERY_SAVE).performClick() + + waitUntilShown(RECEIVER_SELECT_DONE) + // 한 칸만 내려와 수신자 선택이 보인다 — 신호가 두 번 소비되면 설정 홈까지 내려간다. + composeRule.onAllNodes(hasText("프로필 수정")).assertCountEquals(0) + assertEquals(1, fakeUserRepository.deliveryUpdateCalls.size - updatesBefore) + assertEquals(RECEIVER_ID, fakeUserRepository.deliveryUpdateCalls[updatesBefore].receiverId) + assertEquals(0, exits) + } + + /** 설정 홈 → 수신자 목록 → 김수신 행으로 수정 화면을 연다. 프리필이 끝나야 폼이 그려진다. */ + private fun openReceiverEdit() { + launchHost() + waitForSettingHomeContent() + composeRule.onAllNodes(hasText("수신자 목록")).run { + assertCountEquals(2) + get(1).performScrollTo().performClick() + } + composeRule.onNodeWithText(RECEIVER_NAME).performClick() + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodes(hasSetTextAction() and hasText(RECEIVER_NAME)).fetchSemanticsNodes().isNotEmpty() + } + } + + private fun launchHost() { + composeRule.activityRule.scenario.onActivity { activity -> + activity.setContent { + AfternoteTheme { + SettingNavHost( + navigationCallbacks = FeatureNavigationCallbacks { exits += 1 }, + externalActions = + object : SettingExternalActions { + override fun onLogoutSuccess() = Unit + + override fun onWithdrawSuccess() = Unit + }, + startWithRecipientRegistration = false, + ) + } + } + } + } + + private fun waitForSettingHomeContent() = waitUntilShown("프로필 수정") + + private fun waitUntilShown(text: String) { + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodes(hasText(text)).fetchSemanticsNodes().isNotEmpty() + } + } + + private fun waitUntilGone(text: String) { + composeRule.waitUntil(timeoutMillis = TIMEOUT_MILLIS) { + composeRule.onAllNodes(hasText(text)).fetchSemanticsNodes().isEmpty() + } + } + + private companion object { + const val TIMEOUT_MILLIS = 10_000L + const val RECEIVER_ID = 7L + const val RECEIVER_NAME = "김수신" + const val EDIT_TITLE = "수신자 수정" + const val SAVE_ACTION = "수정" + const val EDIT_FAILED_MESSAGE = "수신자 수정에 실패했습니다. 잠시 후 다시 시도해주세요." + const val DELIVERY_SAVE = "저장" + const val RECEIVER_SELECT_DONE = "수신자 선택 완료하기" + val checkboxMatcher = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c0acfe832c..93d79d6ed8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -94,6 +94,21 @@ + + + + + + + + - \ No newline at end of file + diff --git a/app/src/test/java/com/afternote/afternote_fe/BiometricDialogThemeTest.kt b/app/src/test/java/com/afternote/afternote_fe/BiometricDialogThemeTest.kt new file mode 100644 index 0000000000..8744dce378 --- /dev/null +++ b/app/src/test/java/com/afternote/afternote_fe/BiometricDialogThemeTest.kt @@ -0,0 +1,100 @@ +package com.afternote.afternote_fe + +import android.app.Activity +import android.app.Application +import android.app.Dialog +import android.content.Context +import android.content.DialogInterface +import android.os.Looper +import android.util.TypedValue +import android.widget.Button +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** 실제 앱 테마로 AndroidX 지문 대화상자와 같은 AppCompat dialog를 생성한다. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [26, 27, 28], application = Application::class) +class BiometricDialogThemeTest { + @Test + fun `API26부터 28 앱 테마는 AppCompat 인증 대화상자를 띄우고 닫을 수 있다`() { + val controller = Robolectric.buildActivity(Activity::class.java) + controller.get().setTheme(R.style.Theme_Afternotefe_Main) + controller.setup() + val activity = controller.get() + var canceled = false + val dialog = createAppCompatDialog(activity) { canceled = true } + + try { + // 플랫폼 Material 테마만 있으면 show()에서 AppCompat 테마 요구 예외가 발생한다. + dialog.show() + assertTrue(dialog.isShowing) + dialog.findViewById