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 000000000..f527e9225 --- /dev/null +++ b/.github/scripts/find-viewed-auto-merge-targets.mjs @@ -0,0 +1,123 @@ +// Viewed 기반 자동 머지 대상 판정 (#2248) +// +// 지정한 한 사람(VIEWED_BY)이 PR 의 모든 파일을 Viewed 로 체크했고 PR 이 승인 상태면 «Merge when ready» +// 를 예약할 대상으로 고른다. 실제 머지는 GitHub 가 required check·승인 수·merge queue 를 통과시킨 뒤에만 +// 한다. +// +// viewerViewedState 는 토큰 주인 본인의 체크만 돌려준다. 그래서 VIEWED_BY 본인의 토큰(VIEWED_TOKEN)으로 +// 읽고, 토큰 주인이 VIEWED_BY 와 다르면 판정하지 않는다. 쓰기(예약)는 워크플로의 GITHUB_TOKEN 이 한다. +// 체크한 뒤 바뀐 파일은 GitHub 가 DISMISSED 로 되돌리므로 새 커밋은 다시 봐야 대상이 된다. +import { appendFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const PULL_REQUEST_FIELDS = ` + number isDraft headRefOid reviewDecision isInMergeQueue changedFiles + autoMergeRequest { enabledAt } + files(first: 100) { pageInfo { hasNextPage endCursor } nodes { path viewerViewedState } } +`; + +/** + * @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 (pullRequest.reviewDecision !== "APPROVED") { + return { enable: false, reason: `reviewDecision 이 ${pullRequest.reviewDecision ?? "없음"}` }; + } + // 파일 목록을 다 못 읽었으면 «다 봤다» 고 단정하지 않는다. + 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; +} + +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 = []; + for (const pullRequest of data.repository.pullRequests.nodes) { + 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 }); + } + 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 000000000..1ca002167 --- /dev/null +++ b/.github/scripts/find-viewed-auto-merge-targets.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { decideViewedAutoMerge } from "./find-viewed-auto-merge-targets.mjs"; + +const viewed = (path) => ({ path, viewerViewedState: "VIEWED" }); + +function pullRequest(overrides = {}) { + return { + number: 1, + isDraft: false, + headRefOid: "0123456789abcdef0123456789abcdef01234567", + reviewDecision: "APPROVED", + 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 ["CHANGES_REQUESTED", "REVIEW_REQUIRED", null]) { + const decision = decideViewedAutoMerge(pullRequest({ reviewDecision }), [viewed("a.kt"), viewed("b.kt")]); + assert.equal(decision.enable, false, 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); +}); + +test("워크플로는 본인 토큰으로 읽고 GITHUB_TOKEN 으로 승인한 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/); + assert.match(workflow, /GH_TOKEN: \$\{\{ github\.token \}\}/); + 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/workflows/viewed-auto-merge.yml b/.github/workflows/viewed-auto-merge.yml new file mode 100644 index 000000000..362d0d947 --- /dev/null +++ b/.github/workflows/viewed-auto-merge.yml @@ -0,0 +1,75 @@ +# Viewed 기반 자동 머지 예약 (#2248) +# +# VIEWED_BY 가 PR 의 모든 파일을 Viewed 로 체크했고 PR 이 승인 상태면 «Merge when ready» 를 대신 건다. +# 머지는 GitHub 가 승인 수·required check·merge queue 를 모두 통과시킨 뒤에만 하므로 CI 가 빨갛거나 +# 변경요청이 남으면 머지되지 않는다. 예약을 멈추려면 파일 하나의 Viewed 를 풀면 된다(다음 실행이 다시 +# 걸지 않는다). 이미 걸린 예약은 PR 화면에서 끈다. +# +# Viewed 체크는 webhook 이벤트가 없어 주기 실행으로 훑는다. 승인 직후에도 리컨사일 완료 신호로 한 번 +# 돈다. viewerViewedState 는 토큰 주인 본인 것만 보이므로 VIEWED_BY 의 읽기 전용 토큰을 VIEWED_TOKEN +# 시크릿으로 둔다. 시크릿이 없으면 아무것도 하지 않는다. 예약 쓰기는 GITHUB_TOKEN 이 한다. +name: Viewed Auto Merge + +on: + schedule: + - cron: '*/10 * * * *' + workflow_run: + workflows: [Latest Review Decision Reconcile] + types: [completed] + 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: + contents: write + pull-requests: write + 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 fully viewed approved PRs + 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: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TARGETS: ${{ steps.find.outputs.targets }} + run: | + # 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 b7d2e69a9..d308fb7f6 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ Kover는 임의의 절대 커버리지 목표를 강제하지 않는다. 정확 - PR의 담당자는 올린 사람이 아니라 **어사인된 사람**이다. 어사인이 비어 있으면 [자동 어사인 workflow](.github/workflows/pr-assign-author.yml)가 작성자를 걸고, 사람이 담당자를 바꾼 것은 되돌리지 않는다. 변경요청 대응·리뷰 요청 제외·아래 적체 가드의 «자기 PR» 판정은 모두 이 담당자 기준이다. - Draft가 아닌 내부 팀원 PR이 열리거나 리뷰 가능 상태가 되면 [자동 요청 workflow](.github/workflows/review-request-all.yml)가 담당자를 제외한 리뷰 담당 팀원(`TEAM`)에게 리뷰를 요청한다. 요청 인원 수와 필수 승인 수는 같은 뜻이 아니다. - 현재 `develop`·`main` 머지에는 **승인 1건**이 필요하다. `main`은 모든 리뷰 스레드 해결도 필요하다. +- `develop` 대상 PR은 승인 상태에서 1hyok가 모든 파일을 Viewed로 체크하면 [Viewed 자동 머지 workflow](.github/workflows/viewed-auto-merge.yml)가 10분 안팎으로 자동 머지(Merge when ready)를 예약한다. Viewed 체크에는 이벤트가 없어 주기 실행으로 훑고, 본인 체크만 조회되므로 1hyok의 읽기 전용 토큰을 `VIEWED_TOKEN` 시크릿으로 둔다. 필수 검사가 통과해야 merge queue로 들어가며, 예약을 멈추려면 파일 하나의 Viewed를 풀고 PR 화면에서 예약을 끈다. - 리뷰 결과는 GitHub의 `APPROVED`·`CHANGES_REQUESTED`·일반 코멘트로 표현한다. 별도 RCA prefix나 12시간 SLA는 두지 않는다. - [리뷰 적체 가드](.github/workflows/review-debt-guard.yml)는 응답을 기다리는 다른 PR이 남았거나 자기가 담당하는 PR의 최신 변경요청 뒤 아무 조치도 하지 않은 팀원의 새 PR을 닫을 수 있다. 아래 면제 목록에 있는 작성자는 대상이 아니다. 각 PR은 팀원 한 명이 먼저 유효한 판정을 내리면 최초 미응답 목록에서 빠지고, 담당자가 실질 커밋이나 응답을 남기면 작성자 대기 목록에서 빠진다. - `awaiting-author` 라벨은 리뷰 게이트 적용 대상 중 변경요청 뒤 담당자 무조치 상태인 PR을 보여 준다. 면제 작성자에게는 붙이지 않으며, 이미 붙은 라벨도 리컨사일러가 제거한다. 새 PR 가드는 라벨 갱신 시점에 의존하지 않고 같은 판정을 현재 열린 PR에 다시 적용한다.