diff --git a/.github/scripts/enforce-oldest-review-order.mjs b/.github/scripts/enforce-oldest-review-order.mjs index 8685ed0edc..2f3330b0f2 100644 --- a/.github/scripts/enforce-oldest-review-order.mjs +++ b/.github/scripts/enforce-oldest-review-order.mjs @@ -2,9 +2,8 @@ // 오래된 PR 을 내버려 둔 채 새 PR 만 리뷰하는 순서를 막는다. // -// GitHub 은 리뷰 제출 버튼 자체를 가로막을 수 없다. 따라서 결정 리뷰가 제출되면 -// 아직 판정이 필요한 더 오래된 PR 을 조회하고, 그런 PR 이 있으면 방금 리뷰를 -// dismiss 한다. 판정 근거를 전부 읽기 전에는 쓰기 API 를 호출하지 않는다. +// 리뷰 자체는 취소하지 않는다. trusted default branch workflow가 현재 head SHA에 +// 고정 commit status를 기록하고, 상태 변동 때 열린 PR 전체를 다시 계산한다. import fs from "node:fs/promises"; import path from "node:path"; @@ -13,9 +12,21 @@ import { pathToFileURL } from "node:url"; export const DECISIVE_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED"]); export const WRITE_CAPABLE_PERMISSIONS = new Set(["write", "maintain", "admin"]); +export const STATUS_CONTEXT = "oldest-review-order"; +export const REPLAY_MARKER_PREFIX = "$/); + if (!match) throw new Error("replay marker 형식이 올바르지 않습니다."); + return Number(match[1]); +} + +function historicalReplaySpec(reviewId) { + const spec = HISTORICAL_REPLAY_ALLOWLIST.get(reviewId); + if (!spec) { + throw new Error(`review_id ${reviewId}는 검증된 historical replay allowlist에 없습니다.`); + } + return spec; +} + +/** reviewer별 최신 활성 human 결정과 검증된 replay proxy를 모두 고른다. */ +export function selectLatestActiveDecisiveReviewsByReviewer(reviews) { + if (!Array.isArray(reviews)) { + throw new Error("리뷰 목록 응답이 배열이 아닙니다."); + } + const latestByReviewer = new Map(); + for (const review of reviews) { + const state = normalizeReviewState(review?.state); + if (!DECISIVE_REVIEW_STATES.has(state)) continue; + let reviewer = requiredString(review.user?.login, "review.user.login"); + let proxyReviewId = null; + if (isBotReviewer(review.user)) { + if (reviewer !== "github-actions[bot]" || state !== "APPROVED") continue; + proxyReviewId = replayReviewIdFromBody(review.body); + if (proxyReviewId == null) continue; + const replaySpec = historicalReplaySpec(proxyReviewId); + const originals = reviews.filter((candidate) => candidate?.id === proxyReviewId); + if (originals.length !== 1 || normalizeReviewState(originals[0].state) !== "DISMISSED") { + throw new Error(`replay marker의 원 review_id ${proxyReviewId}가 유효한 DISMISSED 리뷰가 아닙니다.`); + } + if (isBotReviewer(originals[0].user)) { + throw new Error(`replay marker의 원 review_id ${proxyReviewId} 작성자가 human이 아닙니다.`); + } + reviewer = requiredString(originals[0].user?.login, "original review.user.login"); + if (reviewer.toLowerCase() !== replaySpec.author.toLowerCase()) { + throw new Error(`replay marker의 원 review_id ${proxyReviewId} 작성자가 allowlist와 다릅니다.`); + } + } + const id = requiredInteger(review.id, "review.id"); + const submittedAt = requiredString(review.submitted_at, "review.submitted_at"); + const submittedTimestamp = parseTimestamp(submittedAt, "review.submitted_at"); + const key = reviewer.toLowerCase(); + const latest = latestByReviewer.get(key); + if (!latest || submittedTimestamp > latest.submittedTimestamp || + (submittedTimestamp === latest.submittedTimestamp && id > latest.id)) { + latestByReviewer.set(key, { + id, + reviewer, + state, + submittedAt, + submittedTimestamp, + proxyReviewId, + }); + } + } + return [...latestByReviewer.values()].sort((left, right) => + left.submittedTimestamp - right.submittedTimestamp || left.id - right.id + ); +} + +/** 기존 단일 selector 호환: reviewer별 결정을 모은 뒤 전체 최신을 반환한다. */ +export function selectLatestActiveHumanDecisiveReview(reviews) { + return selectLatestActiveDecisiveReviewsByReviewer(reviews).at(-1) ?? null; +} + /** * PR 전체의 최신 변경요청을 낸 뒤 현재 다시 요청받은 리뷰어만 고른다. * GitHub는 리뷰 제출 시 그 사람의 요청을 제거하므로, 같은 리뷰어가 현재 요청 목록에 @@ -360,6 +444,12 @@ export async function paginateRest(client, apiPath) { return items; } +async function cachedPaginateRest(client, cache, key, apiPath) { + if (!cache) return paginateRest(client, apiPath); + if (!cache.has(key)) cache.set(key, paginateRest(client, apiPath)); + return cache.get(key); +} + function truncate(value, limit = 2_000) { const text = String(value ?? ""); return text.length <= limit ? text : `${text.slice(0, limit)}...[truncated]`; @@ -416,17 +506,26 @@ export function createGitHubClient({ }; } -export async function findOldestReviewDebt(client, repository, currentPullRequest, reviewer) { +export async function findOldestReviewDebt( + client, + repository, + currentPullRequest, + reviewer, + providedOpenPullRequests, + reviewCache, +) { validateRepository(repository); - const openPullRequests = await paginateRest( + const openPullRequests = providedOpenPullRequests ?? await paginateRest( client, `/repos/${repository}/pulls?state=open&sort=created&direction=asc&per_page=${PAGE_SIZE}`, ); const candidates = selectOlderPullRequests(openPullRequests, currentPullRequest, reviewer); for (const pullRequest of candidates) { - const reviews = await paginateRest( + const reviews = await cachedPaginateRest( client, + reviewCache, + pullRequest.number, `/repos/${repository}/pulls/${pullRequest.number}/reviews?per_page=${PAGE_SIZE}`, ); const reviewState = analyzeDecisiveReviews(reviews); @@ -469,79 +568,391 @@ function oneLineTitle(title) { export function renderDismissalMessage(oldestPullRequest) { const number = requiredInteger(oldestPullRequest?.number, "oldest pull_request.number"); const title = oneLineTitle(oldestPullRequest?.title); - return `오래된 PR부터 리뷰해야 합니다. 가장 오래된 미처리 PR #${number} (${title})을 먼저 리뷰해 주세요. 이 리뷰는 순서 위반으로 자동 취소되었습니다.`; + return `${POLICY_DISMISSAL_PREFIX}${number} (${title})${POLICY_DISMISSAL_SUFFIX}`; } -/** - * 조회가 하나라도 실패하면 예외가 그대로 전파되고 PUT 은 호출되지 않는다. - * oldestDebt 를 확정한 뒤에만 dismiss API 를 한 번 호출한다. - */ -export async function enforceOldestReviewOrder({ - event, +export function parsePolicyDismissalMessage(message) { + const value = requiredString(message, "dismissal_message"); + if (!value.startsWith(POLICY_DISMISSAL_PREFIX) || !value.endsWith(POLICY_DISMISSAL_SUFFIX)) { + throw new Error("oldest-review-order의 정확한 dismissal message가 아닙니다."); + } + const middle = value.slice(POLICY_DISMISSAL_PREFIX.length, -POLICY_DISMISSAL_SUFFIX.length); + const match = middle.match(/^([1-9]\d*) \(([^\r\n]+)\)$/); + if (!match || renderDismissalMessage({ number: Number(match[1]), title: match[2] }) !== value) { + throw new Error("oldest-review-order의 정확한 dismissal message가 아닙니다."); + } + return { number: Number(match[1]), title: match[2] }; +} + +function normalizeStatusPullRequest(pullRequest) { + const normalized = normalizeOpenPullRequest(pullRequest); + const state = requiredString(pullRequest.state, "pull_request.state").toLowerCase(); + if (state !== "open" && state !== "closed") { + throw new Error(`PR #${normalized.number} state가 올바르지 않습니다: ${state}`); + } + const headSha = requiredString(pullRequest.head?.sha, "pull_request.head.sha"); + if (!/^[0-9a-f]{40,64}$/i.test(headSha)) { + throw new Error(`PR #${normalized.number} head SHA가 올바르지 않습니다.`); + } + return { + ...normalized, + state, + headSha, + headRepository: requiredString( + pullRequest.head?.repo?.full_name, + "pull_request.head.repo.full_name", + ), + }; +} + +function statusDescription(value) { + return Array.from(requiredString(value, "status description").replace(/\s+/g, " ")) + .slice(0, 140) + .join(""); +} + +export async function postOrderStatus({ client, repository, sha, state, description, targetUrl }) { + if (!new Set(["pending", "success", "failure"]).has(state)) { + throw new Error(`지원하지 않는 status state입니다: ${state}`); + } + const body = { state, context: STATUS_CONTEXT, description: statusDescription(description) }; + if (targetUrl) body.target_url = targetUrl; + const response = await client.request(`/repos/${validateRepository(repository)}/statuses/${sha}`, { + method: "POST", + body, + }); + if (response?.data?.state !== state || response?.data?.context !== STATUS_CONTEXT) { + throw new Error("commit status 응답이 요청과 다릅니다."); + } + return response.data; +} + +export async function evaluatePullRequestOrder({ + client, repository, + pullRequest, + openPullRequests, + reviewCache, +}) { + const current = normalizeStatusPullRequest(pullRequest); + if (current.state === "closed") { + return { state: "success", reason: "닫힌 PR은 리뷰 순서 검사 대상이 아닙니다." }; + } + if (current.draft) { + return { state: "success", reason: "draft PR은 리뷰 순서 검사 대상이 아닙니다." }; + } + if (current.headRepository !== repository) { + return { state: "success", reason: "fork PR은 리뷰 순서 검사 대상이 아닙니다." }; + } + const reviews = await cachedPaginateRest( + client, + reviewCache, + current.number, + `/repos/${repository}/pulls/${current.number}/reviews?per_page=${PAGE_SIZE}`, + ); + const activeReviews = selectLatestActiveDecisiveReviewsByReviewer(reviews); + if (activeReviews.length === 0) { + return { + state: "success", + reason: "활성 human 결정 리뷰가 없습니다. 승인 여부는 네이티브 규칙이 확인합니다.", + }; + } + const teamReviews = []; + const violations = []; + for (const activeReview of activeReviews) { + const permissionResponse = await client.request( + `/repos/${repository}/collaborators/${encodeURIComponent(activeReview.reviewer)}/permission`, + { method: "GET" }, + ); + if (classifyRepositoryPermission(permissionResponse?.data?.permission) === "non-team") { + if (activeReview.proxyReviewId != null) { + return { + state: "failure", + reason: `이관 승인 원 작성자 @${activeReview.reviewer}에게 현재 write 권한이 없습니다.`, + invalidProxyReviewId: activeReview.proxyReviewId, + }; + } + continue; + } + teamReviews.push(activeReview); + const oldestDebt = await findOldestReviewDebt( + client, + repository, + current, + activeReview.reviewer, + openPullRequests, + reviewCache, + ); + if (oldestDebt) violations.push({ activeReview, oldestDebt }); + } + if (teamReviews.length === 0) { + return { state: "success", reason: "활성 결정 리뷰 작성자에게 현재 write 권한이 없습니다." }; + } + if (violations.length === 0) { + return { state: "success", reason: "모든 팀 리뷰어에게 더 오래된 미처리 PR이 없습니다." }; + } + violations.sort((left, right) => comparePullRequestsOldestFirst(left.oldestDebt, right.oldestDebt)); + const { activeReview, oldestDebt } = violations[0]; + return { + state: "failure", + reason: `@${activeReview.reviewer}은 먼저 #${oldestDebt.number} (${oneLineTitle(oldestDebt.title)})을 리뷰해야 합니다.`, + oldestDebt, + }; +} + +async function getPullRequest(client, repository, number) { + const response = await client.request(`/repos/${repository}/pulls/${requiredInteger(number, "pull number")}`); + if (!response?.data || Array.isArray(response.data)) { + throw new Error(`PR #${number} 응답이 올바르지 않습니다.`); + } + return response.data; +} + +/** pending을 먼저 기록하고, 모든 판정 read가 성공한 뒤에만 final status를 쓴다. */ +export async function recalculatePullRequests({ client, - dryRun = false, + repository, + scope = "all", + affectedPullRequestNumber, + eventPullRequestNumber, + targetUrl, logger = console, }) { - const classification = classifyReviewEvent(event, repository); - if (classification.status === "skipped") { - logger.log(`건너뜀: ${classification.reason}`); - return classification; + const openPullRequests = await paginateRest( + client, + `/repos/${repository}/pulls?state=open&sort=created&direction=asc&per_page=${PAGE_SIZE}`, + ); + for (const pullRequest of openPullRequests) normalizeStatusPullRequest(pullRequest); + let targets = [...openPullRequests]; + if (scope === "affected") { + const affected = Number(affectedPullRequestNumber); + if (!Number.isInteger(affected) || affected < 1) throw new Error("affected PR 번호가 필요합니다."); + const current = targets.find((pullRequest) => pullRequest.number === affected) ?? + await getPullRequest(client, repository, affected); + const normalized = normalizeStatusPullRequest(current); + targets = targets.filter((pullRequest) => + comparePullRequestsOldestFirst(normalizeStatusPullRequest(pullRequest), normalized) >= 0 + ); + if (!targets.some((pullRequest) => pullRequest.number === affected)) targets.push(current); + } else if (scope !== "all") { + throw new Error(`지원하지 않는 재계산 범위입니다: ${scope}`); + } + if (eventPullRequestNumber) { + const eventNumber = Number(eventPullRequestNumber); + if (!targets.some((pullRequest) => pullRequest.number === eventNumber)) { + targets.push(await getPullRequest(client, repository, eventNumber)); + } + } + targets = [...new Map(targets.map((pullRequest) => [pullRequest.number, pullRequest])).values()] + .sort(comparePullRequestsOldestFirst); + + for (const pullRequest of targets) { + const current = normalizeStatusPullRequest(pullRequest); + await postOrderStatus({ + client, repository, sha: current.headSha, state: "pending", + description: "오래된 PR 리뷰 순서를 재계산하는 중입니다.", targetUrl, + }); + } + const decisions = []; + const reviewCache = new Map(); + for (const pullRequest of targets) { + decisions.push({ + pullRequest, + decision: await evaluatePullRequestOrder({ + client, repository, pullRequest, openPullRequests, reviewCache, + }), + }); + } + for (const { pullRequest } of decisions) { + const before = normalizeStatusPullRequest(pullRequest); + const after = normalizeStatusPullRequest(await getPullRequest(client, repository, before.number)); + if (before.headSha !== after.headSha) { + await postOrderStatus({ + client, repository, sha: after.headSha, state: "pending", + description: "PR head가 바뀌어 다음 재계산을 기다립니다.", targetUrl, + }); + throw new Error(`PR #${before.number}의 head가 재계산 중 변경됐습니다.`); + } + if (before.state !== after.state || before.draft !== after.draft || + before.headRepository !== after.headRepository) { + throw new Error(`PR #${before.number}의 상태가 재계산 중 변경됐습니다.`); + } + } + const results = []; + for (const { pullRequest, decision } of decisions) { + const current = normalizeStatusPullRequest(pullRequest); + await postOrderStatus({ + client, repository, sha: current.headSha, state: decision.state, + description: decision.reason, targetUrl, + }); + results.push({ number: current.number, headSha: current.headSha, ...decision }); + logger.log(`#${current.number}: ${decision.state} — ${decision.reason}`); + } + return { status: "recalculated", scope, results }; +} + +export function replayMarker(reviewId) { + const id = Number(reviewId); + if (!Number.isInteger(id) || id < 1) throw new Error("review_id가 올바르지 않습니다."); + return `${REPLAY_MARKER_PREFIX}${id} -->`; +} + +function hasExactMarker(body, marker) { + return typeof body === "string" && body.split(/\r?\n/).includes(marker); +} + +/** 과거 정책이 취소한 APPROVED 하나만 provenance 검증 뒤 bot 승인으로 이관한다. */ +export async function replayHistoricalApproval({ + client, + repository, + reviewId, + pullRequestNumber, + logger = console, +}) { + const id = Number(reviewId); + const marker = replayMarker(id); + const replaySpec = historicalReplaySpec(id); + const requestedPullNumber = pullRequestNumber == null ? null : Number(pullRequestNumber); + if (requestedPullNumber != null && (!Number.isInteger(requestedPullNumber) || requestedPullNumber < 1)) { + throw new Error("pull_number가 올바르지 않습니다."); + } + if (requestedPullNumber != null && requestedPullNumber !== replaySpec.pullRequestNumber) { + throw new Error(`review_id ${id}의 검증된 PR은 #${replaySpec.pullRequestNumber}입니다.`); + } + const openPullRequests = await paginateRest( + client, + `/repos/${repository}/pulls?state=open&sort=created&direction=asc&per_page=${PAGE_SIZE}`, + ); + const matches = []; + const reviewCache = new Map(); + for (const pullRequest of openPullRequests) { + const current = normalizeStatusPullRequest(pullRequest); + const reviews = await cachedPaginateRest( + client, + reviewCache, + current.number, + `/repos/${repository}/pulls/${current.number}/reviews?per_page=${PAGE_SIZE}`, + ); + const original = reviews.find((review) => review?.id === id); + if (original) matches.push({ pullRequest, reviews, original }); + } + if (matches.length !== 1) { + throw new Error(`열린 PR에서 review_id ${id}를 정확히 하나 찾지 못했습니다.`); + } + const { pullRequest, reviews, original } = matches[0]; + const current = normalizeStatusPullRequest(pullRequest); + if (current.number !== replaySpec.pullRequestNumber) { + throw new Error(`review_id ${id}가 allowlist의 PR #${replaySpec.pullRequestNumber}에 속하지 않습니다.`); + } + if (requestedPullNumber != null && current.number !== requestedPullNumber) { + throw new Error(`review_id ${id}가 요청한 PR #${requestedPullNumber}에 속하지 않습니다.`); + } + if (current.draft || current.headRepository !== repository) { + throw new Error("draft 또는 fork PR의 historical approval은 재생하지 않습니다."); + } + if (normalizeReviewState(original.state) !== "DISMISSED") { + throw new Error("원 리뷰의 현재 상태가 DISMISSED가 아닙니다."); + } + const author = requiredString(original.user?.login, "original review.user.login"); + if (isBotReviewer(original.user)) throw new Error("원 리뷰 작성자가 human이 아닙니다."); + if (author.toLowerCase() !== replaySpec.author.toLowerCase()) { + throw new Error(`원 리뷰 작성자 @${author}가 allowlist와 다릅니다.`); } - const context = classification.context; - const permissionResponse = await client.request( - `/repos/${repository}/collaborators/${encodeURIComponent(context.reviewer)}/permission`, - { method: "GET" }, + const timeline = await paginateRest( + client, + `/repos/${repository}/issues/${current.number}/timeline?per_page=${PAGE_SIZE}`, ); - const permission = permissionResponse?.data?.permission; - if (classifyRepositoryPermission(permission) === "non-team") { - const result = { status: "skipped", reason: `쓰기 권한 없는 리뷰어(${permission})` }; - logger.log(`건너뜀: ${result.reason}`); - return result; + const dismissals = timeline.filter((event) => + event?.event === "review_dismissed" && String(event.dismissed_review?.review_id) === String(id) + ); + if (dismissals.length !== 1) { + throw new Error(`review_id ${id}의 dismissal event를 정확히 하나 찾지 못했습니다.`); + } + const dismissal = dismissals[0]; + if (dismissal.id !== replaySpec.dismissalEventId) { + throw new Error(`review_id ${id}의 dismissal event가 allowlist와 다릅니다.`); + } + if (dismissal.actor?.login !== "github-actions[bot]") { + throw new Error("dismissal actor가 github-actions[bot]이 아닙니다."); + } + if (String(dismissal.dismissed_review?.state).toLowerCase() !== "approved") { + throw new Error("dismissal 직전 상태가 APPROVED가 아닙니다. CHANGES_REQUESTED는 재생하지 않습니다."); + } + const provenance = parsePolicyDismissalMessage(dismissal.dismissed_review?.dismissal_message); + if (provenance.number !== replaySpec.predecessorPullRequestNumber) { + throw new Error(`review_id ${id}의 선행 PR provenance가 allowlist와 다릅니다.`); } - const oldestDebt = await findOldestReviewDebt( + const originalLink = `https://github.com/${repository}/pull/${current.number}#pullrequestreview-${id}`; + const body = [ + marker, + `과거 oldest-review-order 정책이 자동 취소한 @${author}의 [원 승인 리뷰](${originalLink})를 1회 이관했습니다.`, + `검증된 당시 선행 PR: #${provenance.number} (${provenance.title})`, + ].join("\n\n"); + const existing = reviews.filter((review) => hasExactMarker(review?.body, marker)); + if (existing.length > 0) { + if (existing.length !== 1 || existing[0].user?.login !== "github-actions[bot]" || + normalizeReviewState(existing[0].state) !== "APPROVED" || existing[0].body !== body) { + throw new Error("동일 replay marker가 검증된 bot 승인에 있지 않습니다."); + } + logger.log(`review_id ${id}는 이미 이관됐습니다.`); + return { status: "already-replayed", pullRequestNumber: current.number, marker }; + } + + const permission = await client.request( + `/repos/${repository}/collaborators/${encodeURIComponent(author)}/permission`, + ); + if (classifyRepositoryPermission(permission?.data?.permission) !== "team") { + throw new Error(`원 리뷰 작성자 @${author}에게 현재 write 권한이 없습니다.`); + } + const originalTime = parseTimestamp(original.submitted_at, "original review.submitted_at"); + const newer = reviews.find((review) => { + const state = normalizeReviewState(review?.state); + if (!DECISIVE_REVIEW_STATES.has(state) && state !== "DISMISSED") return false; + if (review?.id === id || review.user?.login?.toLowerCase() !== author.toLowerCase()) { + return false; + } + const submittedTimestamp = parseTimestamp(review.submitted_at, "newer review.submitted_at"); + return submittedTimestamp > originalTime || + (submittedTimestamp === originalTime && requiredInteger(review.id, "newer review.id") > id); + }); + if (newer) throw new Error(`@${author}의 더 새로운 결정 리뷰가 있습니다.`); + + const debt = await findOldestReviewDebt( client, repository, - { number: context.number, createdAt: context.createdAt }, - context.reviewer, + current, + author, + openPullRequests, + reviewCache, ); - if (!oldestDebt) { - const result = { status: "allowed", reason: "더 오래된 미처리 PR 없음" }; - logger.log(result.reason); - return result; - } - - const message = renderDismissalMessage(oldestDebt); - if (dryRun) { - const result = { - status: "dry-run", - reason: `[dry-run] ${message}`, - message, - oldestDebt, - }; - logger.log(result.reason); - return result; + if (debt) throw new Error(`@${author}의 현재 가장 오래된 리뷰 빚은 #${debt.number}입니다.`); + + const refreshed = normalizeStatusPullRequest(await getPullRequest(client, repository, current.number)); + if (refreshed.state !== "open" || refreshed.headSha !== current.headSha) { + throw new Error("PR 상태 또는 head가 검증 중 변경됐습니다."); } - const dismissalResponse = await client.request( - `/repos/${repository}/pulls/${context.number}/reviews/${context.reviewId}/dismissals`, - { method: "PUT", body: { message, event: "DISMISS" } }, - ); - const dismissedState = normalizeReviewState(dismissalResponse?.data?.state); - if (dismissedState !== "DISMISSED") { - throw new Error(`review dismissal 응답 상태가 DISMISSED가 아닙니다: ${dismissedState}`); + const created = await client.request(`/repos/${repository}/pulls/${current.number}/reviews`, { + method: "POST", + body: { event: "APPROVE", commit_id: current.headSha, body }, + }); + if (normalizeReviewState(created?.data?.state) !== "APPROVED" || + created?.data?.user?.login !== "github-actions[bot]" || + !hasExactMarker(created?.data?.body, marker)) { + throw new Error("historical approval 생성 응답을 검증하지 못했습니다."); } - logger.log(message); - return { status: "dismissed", message, oldestDebt }; + logger.log(`review_id ${id}를 PR #${current.number}에 이관했습니다.`); + return { status: "replayed", pullRequestNumber: current.number, marker, author, originalLink }; } export function renderRunSummary(result) { - if (result.status === "dismissed") { - return `## 오래된 PR 리뷰 순서\n\n❌ ${result.message}`; + if (result.status === "recalculated") { + const blocked = result.results.filter((item) => item.state === "failure").length; + return `## 오래된 PR 리뷰 순서 재계산\n\n- 대상: ${result.results.length}개\n- 차단: ${blocked}개`; } - const marker = result.status === "allowed" ? "✅" : "ℹ️"; - return `## 오래된 PR 리뷰 순서\n\n${marker} ${result.reason}`; + const replayed = result.status === "replayed" ? "이관 완료" : "이미 이관됨 — 쓰기 생략"; + return `## 과거 승인 리뷰 이관\n\n✅ PR #${result.pullRequestNumber}: ${replayed}`; } function escapeWorkflowCommand(value) { @@ -570,31 +981,58 @@ async function main() { } catch (error) { throw new Error(`GitHub 이벤트를 읽지 못했습니다: ${error.message}`); } + if (event.repository?.full_name && event.repository.full_name !== repository) { + throw new Error(`이벤트 저장소(${event.repository.full_name})와 실행 저장소(${repository})가 다릅니다.`); + } const client = createGitHubClient({ token, apiUrl: process.env.GITHUB_API_URL ?? "https://api.github.com", }); - const result = await enforceOldestReviewOrder({ - event, - repository, + const targetUrl = process.env.GITHUB_RUN_ID + ? `${(process.env.GITHUB_SERVER_URL ?? "https://github.com").replace(/\/+$/, "")}/${repository}/actions/runs/${process.env.GITHUB_RUN_ID}` + : null; + const mode = process.env.MODE ?? "recalculate"; + if (mode === "replay") { + const replay = await replayHistoricalApproval({ + client, + repository, + reviewId: process.env.REVIEW_ID, + pullRequestNumber: process.env.REPLAY_PULL_NUMBER, + }); + const replaySummary = renderRunSummary(replay); + console.log(replaySummary); + await appendStepSummary(replaySummary); + const recalculated = await recalculatePullRequests({ + client, + repository, + scope: "affected", + affectedPullRequestNumber: replay.pullRequestNumber, + targetUrl, + }); + const recalculationSummary = renderRunSummary(recalculated); + console.log(recalculationSummary); + await appendStepSummary(recalculationSummary); + return; + } + if (mode !== "recalculate") throw new Error(`지원하지 않는 실행 mode입니다: ${mode}`); + const result = await recalculatePullRequests({ client, - dryRun: process.env.DRY_RUN === "true", + repository, + scope: process.env.RECALCULATE_SCOPE ?? "all", + affectedPullRequestNumber: process.env.AFFECTED_PULL_REQUEST, + eventPullRequestNumber: event.pull_request?.number, + targetUrl, }); const summary = renderRunSummary(result); console.log(summary); await appendStepSummary(summary); - - if (result.status === "dismissed") { - // 리뷰 취소를 성공시킨 뒤 job 도 실패시켜 순서 위반이 Checks 에 선명하게 남게 한다. - throw new Error(result.message); - } } const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; if (import.meta.url === invokedPath) { main().catch((error) => { - console.error(`::error::${escapeWorkflowCommand(error.message)}`); + console.error(`::error::${escapeWorkflowCommand(`재계산 실패 — pending을 유지합니다: ${error.message}`)}`); process.exitCode = 1; }); } diff --git a/.github/scripts/enforce-oldest-review-order.test.mjs b/.github/scripts/enforce-oldest-review-order.test.mjs index 5c8d864da2..6e9eeb7d07 100644 --- a/.github/scripts/enforce-oldest-review-order.test.mjs +++ b/.github/scripts/enforce-oldest-review-order.test.mjs @@ -6,18 +6,30 @@ import { analyzeDecisiveReviews, classifyRepositoryPermission, classifyReviewEvent, - enforceOldestReviewOrder, + evaluatePullRequestOrder, hasSubstantiveCommitAfter, nextPageUrl, paginateRest, + parsePolicyDismissalMessage, + postOrderStatus, + recalculatePullRequests, renderDismissalMessage, + replayHistoricalApproval, + replayMarker, reviewDebtStatus, + selectLatestActiveDecisiveReviewsByReviewer, + selectLatestActiveHumanDecisiveReview, selectRequestedOutstandingReview, selectOlderPullRequests, + STATUS_CONTEXT, } from "./enforce-oldest-review-order.mjs"; const REPOSITORY = "afternote/app"; const silent = { log() {} }; +const REPLAY_REVIEW_ID = 5032388275; +const REPLAY_PULL_NUMBER = 821; +const REPLAY_DISMISSAL_EVENT_ID = 30053730355; +const REPLAY_PREDECESSOR_PULL_NUMBER = 741; function reviewEvent(overrides = {}) { return { @@ -44,9 +56,14 @@ function pullRequest(overrides = {}) { number: 1, title: "오래된 PR", created_at: "2026-08-01T00:00:00Z", + state: "open", draft: false, requested_reviewers: [], user: { login: "author" }, + head: { + sha: "1111111111111111111111111111111111111111", + repo: { full_name: REPOSITORY }, + }, ...overrides, }; } @@ -309,283 +326,433 @@ test("REST pagination 순환과 비배열 응답은 실패한다", async () => { await assert.rejects(() => paginateRest(malformed, "/first"), /응답이 배열이 아닙니다/); }); -test("더 오래된 실제 리뷰 빚을 찾아 현재 리뷰를 dismiss한다", async () => { - const client = fakeClient(({ apiPath, method }) => { - if (apiPath.endsWith("/collaborators/reviewer/permission")) { - return response({ permission: "write" }); - } - if (apiPath.includes("/pulls?state=open")) { - // 응답 순서가 섞여 있어도 #1부터 검사한다. #1은 승인돼 있고 #2가 첫 빚이다. - return response([ - pullRequest({ number: 2, title: "두 번째 오래된 미처리", created_at: "2026-08-02T00:00:00Z" }), - pullRequest({ number: 1, title: "가장 오래됐지만 승인됨", created_at: "2026-08-01T00:00:00Z" }), - ]); - } - if (apiPath.includes("/pulls/1/reviews")) { - return response([review({ state: "APPROVED" })]); - } - if (apiPath.includes("/pulls/2/reviews")) { - return response([]); - } - if (apiPath.endsWith("/pulls/30/reviews/900/dismissals") && method === "PUT") { - return response({ state: "DISMISSED" }); - } - throw new Error(`unexpected ${method} ${apiPath}`); +test("dismiss 안내는 가장 오래된 PR 번호와 한 줄 제목을 명시한다", () => { + const message = renderDismissalMessage({ + number: 77, + title: "여러 줄\n제목", }); - - const result = await enforceOldestReviewOrder({ - event: reviewEvent(), - repository: REPOSITORY, - client, - logger: silent, + assert.match(message, /#77 \(여러 줄 제목\)/); + assert.match(message, /자동 취소/); + assert.deepEqual(parsePolicyDismissalMessage(message), { + number: 77, + title: "여러 줄 제목", }); - - assert.equal(result.status, "dismissed"); - assert.equal(result.oldestDebt.number, 2); - const dismissal = client.calls.at(-1); - assert.equal(dismissal.method, "PUT"); - assert.equal(dismissal.body.event, "DISMISS"); - assert.match(dismissal.body.message, /#2 \(두 번째 오래된 미처리\)/); }); -test("CHANGES_REQUESTED 뒤 같은 리뷰어에게 재요청하고 반영했으면 리뷰 빚으로 dismiss한다", async () => { - const client = fakeClient(({ apiPath, method }) => { - if (apiPath.includes("/permission")) return response({ permission: "maintain" }); - if (apiPath.includes("/pulls?state=open")) { - return response([pullRequest({ - number: 4, - requested_reviewers: [{ login: "alice" }], - })]); - } - if (apiPath.includes("/pulls/4/reviews")) { - return response([review({ state: "CHANGES_REQUESTED" })]); - } - if (apiPath.includes("/pulls/4/commits")) return response([commit()]); - if (method === "PUT") return response({ state: "dismissed" }); - throw new Error(`unexpected ${method} ${apiPath}`); - }); - - const result = await enforceOldestReviewOrder({ - event: reviewEvent(), repository: REPOSITORY, client, logger: silent, - }); - assert.equal(result.status, "dismissed"); - assert.equal(result.oldestDebt.debtReason, "changes-requested-fixed-rerequested"); +test("현재 활성 human 결정 리뷰만 최신순으로 고른다", () => { + const latest = selectLatestActiveHumanDecisiveReview([ + review({ id: 10, state: "DISMISSED", submitted_at: "2026-08-10T00:00:00Z" }), + review({ id: 11, state: "APPROVED", submitted_at: "2026-08-11T00:00:00Z" }), + review({ + id: 12, + state: "APPROVED", + submitted_at: "2026-08-12T00:00:00Z", + user: { login: "github-actions[bot]", type: "Bot" }, + }), + review({ + id: 13, + state: "CHANGES_REQUESTED", + submitted_at: "2026-08-13T00:00:00Z", + user: { login: "bob", type: "User" }, + }), + ]); + assert.equal(latest.id, 13); + assert.equal(latest.reviewer, "bob"); }); -test("과거 리뷰어 요청이 남아도 더 늦은 다른 변경요청 뒤에는 dismiss하지 않는다", async () => { - const client = fakeClient(({ apiPath }) => { - if (apiPath.includes("/permission")) return response({ permission: "maintain" }); - if (apiPath.includes("/pulls?state=open")) { - return response([pullRequest({ - number: 767, - requested_reviewers: [{ login: "Sadturtleman" }], - })]); - } - if (apiPath.includes("/pulls/767/reviews")) { - return response([ - review({ - state: "CHANGES_REQUESTED", - submitted_at: "2026-08-23T02:12:43Z", - user: { login: "Sadturtleman" }, - }), - review({ - state: "CHANGES_REQUESTED", - submitted_at: "2026-08-23T11:45:05Z", - user: { login: "1hyok" }, - }), - ]); - } - throw new Error(`최신 변경요청이 있는데 불필요한 API를 호출함: ${apiPath}`); +test("검증된 replay bot 승인은 원 DISMISSED human 리뷰어에게 귀속한다", () => { + const original = review({ + id: REPLAY_REVIEW_ID, + state: "DISMISSED", + submitted_at: "2026-08-01T00:00:00Z", + user: { login: "koongmai", type: "User" }, }); - - const result = await enforceOldestReviewOrder({ - event: reviewEvent(), repository: REPOSITORY, client, logger: silent, + const proxy = review({ + id: REPLAY_REVIEW_ID + 1, + state: "APPROVED", + submitted_at: "2026-08-10T00:00:00Z", + user: { login: "github-actions[bot]", type: "Bot" }, + body: `${replayMarker(REPLAY_REVIEW_ID)}\n\n원 리뷰 이관`, }); + const selected = selectLatestActiveDecisiveReviewsByReviewer([original, proxy]); + assert.equal(selected.length, 1); + assert.equal(selected[0].reviewer, "koongmai"); + assert.equal(selected[0].proxyReviewId, REPLAY_REVIEW_ID); +}); - assert.equal(result.status, "allowed"); - assert.equal(client.calls.some((call) => call.apiPath.includes("/commits")), false); - assert.equal(client.calls.some((call) => call.method !== "GET"), false); +test("allowlist 밖 marker를 bot 승인의 human proxy로 신뢰하지 않는다", () => { + const unknownReviewId = 900; + assert.throws( + () => selectLatestActiveDecisiveReviewsByReviewer([ + review({ + id: unknownReviewId, + state: "DISMISSED", + user: { login: "alice", type: "User" }, + }), + review({ + id: unknownReviewId + 1, + state: "APPROVED", + user: { login: "github-actions[bot]", type: "Bot" }, + body: replayMarker(unknownReviewId), + }), + ]), + /allowlist/, + ); }); -test("변경요청 뒤 수정했어도 재리뷰 요청이 없으면 허용한다", async () => { +test("이관 승인 원 작성자의 write 권한이 사라지면 failure로 막는다", async () => { + const original = review({ + id: REPLAY_REVIEW_ID, + state: "DISMISSED", + user: { login: "koongmai", type: "User" }, + }); + const proxy = review({ + id: REPLAY_REVIEW_ID + 1, + state: "APPROVED", + submitted_at: "2026-08-20T01:00:00Z", + user: { login: "github-actions[bot]", type: "Bot" }, + body: replayMarker(REPLAY_REVIEW_ID), + }); + const current = pullRequest({ number: REPLAY_PULL_NUMBER }); const client = fakeClient(({ apiPath }) => { - if (apiPath.includes("/permission")) return response({ permission: "maintain" }); - if (apiPath.includes("/pulls?state=open")) { - return response([pullRequest({ - number: 966, - requested_reviewers: [{ login: "koongmai" }], - })]); + if (apiPath.includes(`/pulls/${REPLAY_PULL_NUMBER}/reviews`)) { + return response([original, proxy]); } - if (apiPath.includes("/pulls/966/reviews")) { - return response([review({ state: "CHANGES_REQUESTED", user: { login: "1hyok" } })]); + if (apiPath.includes("/collaborators/koongmai/permission")) { + return response({ permission: "read" }); } - throw new Error(`재요청이 없는데 불필요한 API를 호출함: ${apiPath}`); + throw new Error(`unexpected ${apiPath}`); }); - const result = await enforceOldestReviewOrder({ - event: reviewEvent(), repository: REPOSITORY, client, logger: silent, + const result = await evaluatePullRequestOrder({ + client, + repository: REPOSITORY, + pullRequest: current, + openPullRequests: [current], + reviewCache: new Map(), }); - assert.equal(result.status, "allowed"); - assert.equal(client.calls.some((call) => call.apiPath.includes("/commits")), false); - assert.equal(client.calls.some((call) => call.method !== "GET"), false); + assert.equal(result.state, "failure"); + assert.equal(result.invalidProxyReviewId, REPLAY_REVIEW_ID); }); -test("변경요청 뒤 반영 커밋이 없거나 이미 승인된 PR만 있으면 허용한다", async () => { +test("A의 순서 위반 뒤 B가 정상 승인해도 A의 위반을 failure로 유지한다", async () => { + const older = pullRequest({ number: 1, user: { login: "bob" } }); + const current = pullRequest({ + number: 30, + created_at: "2026-08-20T00:00:00Z", + head: { sha: "3030303030303030303030303030303030303030", repo: { full_name: REPOSITORY } }, + }); + const currentReviews = [ + review({ + id: 10, + state: "APPROVED", + submitted_at: "2026-08-20T01:00:00Z", + user: { login: "alice", type: "User" }, + }), + review({ + id: 11, + state: "APPROVED", + submitted_at: "2026-08-20T02:00:00Z", + user: { login: "bob", type: "User" }, + }), + ]; const client = fakeClient(({ apiPath }) => { - if (apiPath.includes("/permission")) return response({ permission: "admin" }); - if (apiPath.includes("/pulls?state=open")) { - return response([ - pullRequest({ number: 1, requested_reviewers: [{ login: "alice" }] }), - pullRequest({ number: 2 }), - ]); - } - if (apiPath.includes("/pulls/1/reviews")) { - return response([review({ state: "CHANGES_REQUESTED" })]); - } - if (apiPath.includes("/pulls/1/commits")) { - return response([ - commit({ commit: { committer: { date: "2026-08-01T00:00:00Z" } } }), - ]); - } - if (apiPath.includes("/pulls/2/reviews")) return response([review()]); + if (apiPath.includes("/pulls/30/reviews")) return response(currentReviews); + if (apiPath.includes("/collaborators/alice/permission")) return response({ permission: "write" }); + if (apiPath.includes("/collaborators/bob/permission")) return response({ permission: "write" }); + if (apiPath.includes("/pulls/1/reviews")) return response([]); throw new Error(`unexpected ${apiPath}`); }); - - const result = await enforceOldestReviewOrder({ - event: reviewEvent(), repository: REPOSITORY, client, logger: silent, + const result = await evaluatePullRequestOrder({ + client, + repository: REPOSITORY, + pullRequest: current, + openPullRequests: [older, current], + reviewCache: new Map(), }); - assert.equal(result.status, "allowed"); - assert.equal(client.calls.some((call) => call.method === "PUT"), false); + assert.equal(result.state, "failure"); + assert.equal(result.oldestDebt.number, 1); + assert.match(result.reason, /@alice/); }); -test("dry run은 순서 위반을 끝까지 계산하되 리뷰를 dismiss하지 않는다", async () => { - const lines = []; - const client = fakeClient(({ apiPath }) => { - if (apiPath.includes("/permission")) return response({ permission: "write" }); - if (apiPath.includes("/pulls?state=open")) { - return response([pullRequest({ number: 6, title: "먼저 리뷰할 PR" })]); - } - if (apiPath.includes("/pulls/6/reviews")) return response([]); - throw new Error(`dry run에서 쓰기 API를 호출함: ${apiPath}`); +test("commit status는 고정 context와 요청 상태를 검증한다", async () => { + const client = fakeClient(({ apiPath, method, body }) => { + assert.match(apiPath, /\/statuses\/1{40}$/); + assert.equal(method, "POST"); + return response({ state: body.state, context: body.context }); }); - - const result = await enforceOldestReviewOrder({ - event: reviewEvent(), - repository: REPOSITORY, + await postOrderStatus({ client, - dryRun: true, - logger: { log: (line) => lines.push(line) }, + repository: REPOSITORY, + sha: "1111111111111111111111111111111111111111", + state: "pending", + description: "재계산 중", }); - - assert.equal(result.status, "dry-run"); - assert.equal(result.oldestDebt.number, 6); - assert.match(lines[0], /\[dry-run\].*#6/); - assert.equal(client.calls.some((call) => call.method !== "GET"), false); + assert.equal(client.calls[0].body.context, STATUS_CONTEXT); }); -test("dismiss API가 200이어도 상태가 DISMISSED가 아니면 성공으로 보고하지 않는다", async () => { - const client = fakeClient(({ apiPath, method }) => { - if (apiPath.includes("/permission")) return response({ permission: "write" }); - if (apiPath.includes("/pulls?state=open")) return response([pullRequest({ number: 6 })]); - if (apiPath.includes("/pulls/6/reviews")) return response([]); - if (method === "PUT") return response({ state: "APPROVED" }); +test("열린 PR 전체를 pending 뒤 success/failure로 재계산하고 리뷰는 쓰지 않는다", async () => { + const pulls = [ + pullRequest({ number: 1 }), + pullRequest({ + number: 2, + created_at: "2026-08-02T00:00:00Z", + head: { sha: "2222222222222222222222222222222222222222", repo: { full_name: REPOSITORY } }, + }), + ]; + const client = fakeClient(({ apiPath, method, body }) => { + if (apiPath.includes("/pulls?state=open")) return response(pulls); + if (apiPath.includes("/reviews")) return response([]); + if (apiPath === `/repos/${REPOSITORY}/pulls/1`) return response(pulls[0]); + if (apiPath === `/repos/${REPOSITORY}/pulls/2`) return response(pulls[1]); + if (apiPath.includes("/statuses/") && method === "POST") { + return response({ state: body.state, context: body.context }); + } throw new Error(`unexpected ${method} ${apiPath}`); }); + const result = await recalculatePullRequests({ + client, repository: REPOSITORY, logger: silent, + }); + assert.equal(result.results.length, 2); + const writes = client.calls.filter((call) => call.method !== "GET"); + assert.deepEqual(writes.map((call) => call.body.state), ["pending", "pending", "success", "success"]); + assert.equal(writes.some((call) => call.method === "PUT"), false); + assert.equal(writes.some((call) => call.apiPath.includes("/reviews")), false); +}); +test("판정 read 실패 시 pending만 남고 final status는 쓰지 않는다", async () => { + const client = fakeClient(({ apiPath, method, body }) => { + if (apiPath.includes("/pulls?state=open")) return response([pullRequest()]); + if (apiPath.includes("/statuses/") && method === "POST") { + return response({ state: body.state, context: body.context }); + } + if (apiPath.includes("/reviews")) throw new Error("reviews read failed"); + throw new Error(`unexpected ${method} ${apiPath}`); + }); await assert.rejects( - () => enforceOldestReviewOrder({ - event: reviewEvent(), repository: REPOSITORY, client, logger: silent, - }), - /상태가 DISMISSED가 아닙니다/, + () => recalculatePullRequests({ client, repository: REPOSITORY, logger: silent }), + /reviews read failed/, ); + const statusWrites = client.calls.filter((call) => call.apiPath.includes("/statuses/")); + assert.deepEqual(statusWrites.map((call) => call.body.state), ["pending"]); }); -test("쓰기 권한이 없는 리뷰어는 열린 PR 목록도 조회하지 않는다", async () => { - const client = fakeClient(({ apiPath }) => { - assert.match(apiPath, /\/permission$/); - return response({ permission: "read" }); +test("strict review_id provenance를 만족한 과거 APPROVED만 bot 승인으로 이관한다", async () => { + const original = review({ + id: REPLAY_REVIEW_ID, + state: "DISMISSED", + submitted_at: "2026-08-02T00:00:00Z", + user: { login: "koongmai", type: "User" }, }); - - const result = await enforceOldestReviewOrder({ - event: reviewEvent(), repository: REPOSITORY, client, logger: silent, + const current = pullRequest({ + number: REPLAY_PULL_NUMBER, + created_at: "2026-08-20T00:00:00Z", + }); + const client = fakeClient(({ apiPath, method, body }) => { + if (apiPath.includes("/pulls?state=open")) return response([current]); + if (apiPath.includes(`/pulls/${REPLAY_PULL_NUMBER}/reviews`) && method === "GET") { + return response([original]); + } + if (apiPath.includes(`/issues/${REPLAY_PULL_NUMBER}/timeline`)) { + return response([{ + id: REPLAY_DISMISSAL_EVENT_ID, + event: "review_dismissed", + actor: { login: "github-actions[bot]" }, + dismissed_review: { + review_id: String(REPLAY_REVIEW_ID), + state: "approved", + dismissal_message: renderDismissalMessage({ + number: REPLAY_PREDECESSOR_PULL_NUMBER, + title: "먼저 볼 PR", + }), + }, + }]); + } + if (apiPath.includes("/collaborators/koongmai/permission")) { + return response({ permission: "write" }); + } + if (apiPath.endsWith(`/pulls/${REPLAY_PULL_NUMBER}`) && method === "GET") { + return response(current); + } + if (apiPath.endsWith(`/pulls/${REPLAY_PULL_NUMBER}/reviews`) && method === "POST") { + return response({ + state: "APPROVED", + user: { login: "github-actions[bot]" }, + body: body.body, + }); + } + throw new Error(`unexpected ${method} ${apiPath}`); + }); + const result = await replayHistoricalApproval({ + client, repository: REPOSITORY, reviewId: String(REPLAY_REVIEW_ID), logger: silent, }); - assert.equal(result.status, "skipped"); - assert.equal(client.calls.length, 1); + assert.equal(result.status, "replayed"); + const created = client.calls.find((call) => call.method === "POST"); + assert.equal(created.body.event, "APPROVE"); + assert.match(created.body.body, new RegExp(replayMarker(REPLAY_REVIEW_ID))); + assert.match(created.body.body, /@koongmai/); + assert.match(created.body.body, new RegExp(`pullrequestreview-${REPLAY_REVIEW_ID}`)); }); -test("비결정 리뷰와 fork는 GitHub API를 전혀 호출하지 않는다", async () => { - const client = fakeClient(() => { - throw new Error("API를 호출하면 안 됨"); +test("CHANGES_REQUESTED dismissal은 historical replay 전에 fail-closed한다", async () => { + const original = review({ + id: REPLAY_REVIEW_ID, + state: "DISMISSED", + user: { login: "koongmai", type: "User" }, }); - const commented = reviewEvent({ - review: { id: 1, state: "commented", user: { login: "reviewer" } }, + const current = pullRequest({ number: REPLAY_PULL_NUMBER }); + const client = fakeClient(({ apiPath, method }) => { + if (apiPath.includes("/pulls?state=open")) return response([current]); + if (apiPath.includes(`/pulls/${REPLAY_PULL_NUMBER}/reviews`) && method === "GET") { + return response([original]); + } + if (apiPath.includes(`/issues/${REPLAY_PULL_NUMBER}/timeline`)) { + return response([{ + id: REPLAY_DISMISSAL_EVENT_ID, + event: "review_dismissed", + actor: { login: "github-actions[bot]" }, + dismissed_review: { + review_id: REPLAY_REVIEW_ID, + state: "changes_requested", + dismissal_message: renderDismissalMessage({ + number: REPLAY_PREDECESSOR_PULL_NUMBER, + title: "먼저 볼 PR", + }), + }, + }]); + } + throw new Error(`unexpected ${method} ${apiPath}`); }); - assert.equal((await enforceOldestReviewOrder({ - event: commented, repository: REPOSITORY, client, logger: silent, - })).status, "skipped"); - - const forkPayload = reviewEvent(); - forkPayload.pull_request.head.repo.full_name = "outside/fork"; - assert.equal((await enforceOldestReviewOrder({ - event: forkPayload, repository: REPOSITORY, client, logger: silent, - })).status, "skipped"); - assert.deepEqual(client.calls, []); + await assert.rejects( + () => replayHistoricalApproval({ client, repository: REPOSITORY, reviewId: REPLAY_REVIEW_ID }), + /CHANGES_REQUESTED/, + ); + assert.equal(client.calls.some((call) => call.method === "POST"), false); }); -test("열린 PR, 리뷰, 커밋 조회 실패 시 dismiss 쓰기가 발생하지 않는다", async (t) => { - const stages = ["open", "reviews", "commits"]; - for (const failingStage of stages) { - await t.test(failingStage, async () => { - const client = fakeClient(({ apiPath }) => { - if (apiPath.includes("/permission")) return response({ permission: "write" }); - if (apiPath.includes("/pulls?state=open")) { - if (failingStage === "open") throw new Error("open read failed"); - return response([pullRequest({ - number: 1, - requested_reviewers: [{ login: "alice" }], - })]); - } - if (apiPath.includes("/pulls/1/reviews")) { - if (failingStage === "reviews") throw new Error("reviews read failed"); - return response([review({ state: "CHANGES_REQUESTED" })]); - } - if (apiPath.includes("/pulls/1/commits")) { - if (failingStage === "commits") throw new Error("commits read failed"); - return response([commit()]); - } - throw new Error(`unexpected ${apiPath}`); - }); +test("동일 marker가 있어도 dismissal provenance를 먼저 다시 검증한다", async () => { + const original = review({ + id: REPLAY_REVIEW_ID, + state: "DISMISSED", + user: { login: "koongmai", type: "User" }, + }); + const existing = review({ + id: REPLAY_REVIEW_ID + 1, + state: "APPROVED", + user: { login: "github-actions[bot]", type: "Bot" }, + body: replayMarker(REPLAY_REVIEW_ID), + }); + const current = pullRequest({ number: REPLAY_PULL_NUMBER }); + const client = fakeClient(({ apiPath, method }) => { + if (apiPath.includes("/pulls?state=open")) return response([current]); + if (apiPath.includes(`/pulls/${REPLAY_PULL_NUMBER}/reviews`) && method === "GET") { + return response([original, existing]); + } + if (apiPath.includes(`/issues/${REPLAY_PULL_NUMBER}/timeline`)) { + return response([{ + id: REPLAY_DISMISSAL_EVENT_ID, + event: "review_dismissed", + actor: { login: "not-the-policy-bot" }, + dismissed_review: { + review_id: REPLAY_REVIEW_ID, + state: "approved", + dismissal_message: renderDismissalMessage({ + number: REPLAY_PREDECESSOR_PULL_NUMBER, + title: "먼저 볼 PR", + }), + }, + }]); + } + throw new Error(`unexpected ${method} ${apiPath}`); + }); - await assert.rejects( - () => enforceOldestReviewOrder({ - event: reviewEvent(), repository: REPOSITORY, client, logger: silent, - }), - /read failed/, - ); - assert.equal(client.calls.some((call) => call.method === "PUT"), false); - }); - } + await assert.rejects( + () => replayHistoricalApproval({ + client, + repository: REPOSITORY, + reviewId: REPLAY_REVIEW_ID, + }), + /dismissal actor/, + ); + assert.equal(client.calls.some((call) => call.method === "POST"), false); }); -test("dismiss 안내는 가장 오래된 PR 번호와 한 줄 제목을 명시한다", () => { - const message = renderDismissalMessage({ - number: 77, - title: "여러 줄\n제목", +test("동일 시각의 더 큰 DISMISSED 결정 리뷰가 있으면 오래된 승인을 이관하지 않는다", async () => { + const submittedAt = "2026-08-02T00:00:00Z"; + const original = review({ + id: REPLAY_REVIEW_ID, + state: "DISMISSED", + submitted_at: submittedAt, + user: { login: "koongmai", type: "User" }, }); - assert.match(message, /#77 \(여러 줄 제목\)/); - assert.match(message, /자동 취소/); + const newer = review({ + id: REPLAY_REVIEW_ID + 1, + state: "DISMISSED", + submitted_at: submittedAt, + user: { login: "koongmai", type: "User" }, + }); + const current = pullRequest({ number: REPLAY_PULL_NUMBER }); + const client = fakeClient(({ apiPath, method }) => { + if (apiPath.includes("/pulls?state=open")) return response([current]); + if (apiPath.includes(`/pulls/${REPLAY_PULL_NUMBER}/reviews`) && method === "GET") { + return response([original, newer]); + } + if (apiPath.includes(`/issues/${REPLAY_PULL_NUMBER}/timeline`)) { + return response([{ + id: REPLAY_DISMISSAL_EVENT_ID, + event: "review_dismissed", + actor: { login: "github-actions[bot]" }, + dismissed_review: { + review_id: REPLAY_REVIEW_ID, + state: "approved", + dismissal_message: renderDismissalMessage({ + number: REPLAY_PREDECESSOR_PULL_NUMBER, + title: "먼저 볼 PR", + }), + }, + }]); + } + if (apiPath.includes("/collaborators/koongmai/permission")) { + return response({ permission: "write" }); + } + throw new Error(`unexpected ${method} ${apiPath}`); + }); + + await assert.rejects( + () => replayHistoricalApproval({ + client, + repository: REPOSITORY, + reviewId: REPLAY_REVIEW_ID, + }), + /더 새로운 결정 리뷰/, + ); + assert.equal(client.calls.some((call) => call.method === "POST"), false); }); -test("workflow는 trusted default branch에서만 write-token 스크립트를 실행한다", async () => { +test("무권한 이벤트 감지와 trusted status/replay workflow를 분리한다", async () => { const workflow = await readFile( new URL("../workflows/oldest-review-order.yml", import.meta.url), "utf8", ); + const eventWorkflow = await readFile( + new URL("../workflows/oldest-review-order-event.yml", import.meta.url), + "utf8", + ); - assert.match(workflow, /^\s{2}pull_request_review:\s*$/m); - assert.match(workflow, /^\s{4}types: \[submitted\]\s*$/m); - assert.match(workflow, /^\s{2}contents: read\s*$/m); - assert.match(workflow, /^\s{2}pull-requests: write\s*$/m); + assert.match(eventWorkflow, /^\s{2}pull_request_review:\s*$/m); + assert.match(eventWorkflow, /types: \[submitted, dismissed\]/); + assert.match(eventWorkflow, /^\s{2}pull_request:\s*$/m); + assert.match(eventWorkflow, /^permissions: \{\}\s*$/m); + assert.doesNotMatch(eventWorkflow, /actions\/checkout|GITHUB_TOKEN|github\.token/); + assert.match(workflow, /^\s{2}workflow_run:\s*$/m); + assert.match(workflow, /workflows: \[Oldest Review Order Event\]/); + assert.doesNotMatch(workflow, /^\s{2}pull_request(?:_review)?:\s*$/m); + assert.doesNotMatch(workflow, /^\s{2}pull_request_target:\s*$/m); + assert.match(workflow, /^\s{2}workflow_dispatch:\s*$/m); + assert.match(workflow, /^\s{6}contents: read\s*$/m); + assert.match(workflow, /^\s{6}pull-requests: read\s*$/m); + assert.match(workflow, /^\s{6}statuses: write\s*$/m); + assert.match(workflow, /^\s{6}pull-requests: write\s*$/m); assert.match(workflow, /^\s{4}timeout-minutes: \d+\s*$/m); assert.match(workflow, /^\s{10}ref: \$\{\{ github\.event\.repository\.default_branch \}\}\s*$/m); assert.match(workflow, /^\s{10}persist-credentials: false\s*$/m); diff --git a/.github/workflows/oldest-review-order-event.yml b/.github/workflows/oldest-review-order-event.yml new file mode 100644 index 0000000000..0e93f96218 --- /dev/null +++ b/.github/workflows/oldest-review-order-event.yml @@ -0,0 +1,29 @@ +name: Oldest Review Order Event + +# 공개 저장소의 fork와 Dependabot PR에서도 이벤트를 빠짐없이 감지한다. 이 workflow는 +# PR 코드를 실행하거나 API를 호출하지 않는다. 완료 이벤트만 trusted default-branch의 +# Oldest Review Order workflow로 넘기고, 실제 조회와 status 쓰기는 그곳에서 수행한다. +on: + pull_request_review: + types: [submitted, dismissed] + pull_request: + types: + - opened + - reopened + - closed + - synchronize + - ready_for_review + - converted_to_draft + - review_requested + - review_request_removed + +permissions: {} + +jobs: + signal: + name: Signal oldest review order reconciliation + runs-on: ubuntu-latest + timeout-minutes: 1 + steps: + - name: Record event + run: echo 'Review-order state changed.' diff --git a/.github/workflows/oldest-review-order.yml b/.github/workflows/oldest-review-order.yml index 1f4780febc..e53358c07a 100644 --- a/.github/workflows/oldest-review-order.yml +++ b/.github/workflows/oldest-review-order.yml @@ -1,32 +1,103 @@ name: Oldest Review Order -# GitHub은 리뷰 제출 자체를 사전에 막을 수 없다. 결정 리뷰가 제출된 직후 더 오래된 -# 미처리 PR을 확인하고, 순서를 건너뛴 리뷰라면 그 리뷰를 dismiss한 뒤 job을 실패시킨다. on: - pull_request_review: - types: [submitted] + workflow_run: + workflows: [Oldest Review Order Event] + types: [completed] + workflow_dispatch: + inputs: + pull_number: + description: Pull request number for a historical review replay + required: false + type: string + review_id: + description: Review ID to replay + required: false + type: string -permissions: - contents: read - pull-requests: write +concurrency: + group: oldest-review-order-${{ github.repository }} + cancel-in-progress: false + +permissions: {} jobs: - enforce: - name: Enforce oldest pull request review first + reconcile: + name: Reconcile oldest review order status + if: >- + github.event_name == 'workflow_run' || + (github.event_name == 'workflow_dispatch' && + inputs.pull_number == '' && inputs.review_id == '') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: read + statuses: 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 oldest review order status + env: + MODE: recalculate + RECALCULATE_SCOPE: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number && 'affected' || 'all' }} + AFFECTED_PULL_REQUEST: ${{ github.event.workflow_run.pull_requests[0].number }} + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_EVENT_PATH: ${{ github.event_path }} + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_RUN_ID: ${{ github.run_id }} + GITHUB_SERVER_URL: ${{ github.server_url }} + run: node .github/scripts/enforce-oldest-review-order.mjs + + validate-replay-inputs: + name: Validate historical replay inputs + if: >- + github.event_name == 'workflow_dispatch' && + ((inputs.pull_number != '' && inputs.review_id == '') || + (inputs.pull_number == '' && inputs.review_id != '')) + runs-on: ubuntu-latest + timeout-minutes: 1 + permissions: {} + steps: + - name: Require both replay inputs + run: | + echo 'Both pull_number and review_id are required for a historical replay.' >&2 + exit 1 + + replay: + name: Replay historical review + if: >- + github.event_name == 'workflow_dispatch' && + inputs.pull_number != '' && + inputs.review_id != '' runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + contents: read + issues: read + pull-requests: write + statuses: write steps: - # pull_request_review의 GITHUB_REF는 refs/pull/.../merge다. write token으로 PR의 - # 스크립트를 실행하지 않도록 항상 저장소의 trusted default branch를 checkout한다. - name: Clone trusted default branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false - - name: Enforce oldest review order + - name: Replay historical review env: + MODE: replay GITHUB_TOKEN: ${{ github.token }} GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_EVENT_PATH: ${{ github.event_path }} + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_RUN_ID: ${{ github.run_id }} + GITHUB_SERVER_URL: ${{ github.server_url }} + REPLAY_PULL_NUMBER: ${{ inputs.pull_number }} + REVIEW_ID: ${{ inputs.review_id }} run: node .github/scripts/enforce-oldest-review-order.mjs