From 50ed2bbe4f23d5093ea9a59f44a367129574e361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 4 Oct 2026 06:42:43 +0000 Subject: [PATCH 1/3] =?UTF-8?q?ci(review):=20=EC=8A=B9=EC=9D=B8=EB=90=9C?= =?UTF-8?q?=20develop=20PR=20=EC=97=90=20=EC=9E=90=EB=8F=99=20=EB=A8=B8?= =?UTF-8?q?=EC=A7=80=EB=A5=BC=20=EC=98=88=EC=95=BD=ED=95=9C=EB=8B=A4=20(#2?= =?UTF-8?q?248)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/decide-auto-merge-on-approval.mjs | 69 +++++++++++++++ .../decide-auto-merge-on-approval.test.mjs | 83 +++++++++++++++++++ .../latest-review-decision-reconcile.yml | 57 +++++++++++++ README.md | 1 + 4 files changed, 210 insertions(+) create mode 100644 .github/scripts/decide-auto-merge-on-approval.mjs create mode 100644 .github/scripts/decide-auto-merge-on-approval.test.mjs diff --git a/.github/scripts/decide-auto-merge-on-approval.mjs b/.github/scripts/decide-auto-merge-on-approval.mjs new file mode 100644 index 0000000000..645c2adff4 --- /dev/null +++ b/.github/scripts/decide-auto-merge-on-approval.mjs @@ -0,0 +1,69 @@ +// 승인 직후 자동 머지 예약 판정 +// +// 리뷰어가 승인하면 «Merge when ready» 를 대신 눌러 둔다. 실제 머지는 GitHub 가 required check· +// 승인 수·merge queue 를 모두 통과시킨 뒤에만 하므로, 이 판정은 «예약을 걸어도 되는가» 만 본다. +// +// 리뷰어의 파일 «Viewed» 체크는 조건에 넣지 못한다. GraphQL 의 viewerViewedState 는 토큰 주인 +// 본인의 상태만 돌려주므로 GITHUB_TOKEN(github-actions)으로는 리뷰어의 체크를 볼 수 없고, +// 체크 변경을 알리는 webhook 이벤트도 없다. 승인을 «다 봤다» 는 신호로 쓴다. +import { appendFileSync, readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +/** + * @param {object} pullRequest GraphQL repository.pullRequest 노드 + * @param {string} defaultBranch 자동 머지를 허용할 base(통합 브랜치) + * @returns {{ enable: boolean, reason: string, headSha?: string }} + */ +export function decideAutoMerge(pullRequest, defaultBranch) { + if (!pullRequest) return { enable: false, reason: "PR 을 찾지 못했다" }; + if (pullRequest.state !== "OPEN") return { enable: false, reason: `PR 상태가 ${pullRequest.state}` }; + if (pullRequest.isDraft) return { enable: false, reason: "Draft PR" }; + // 릴리스(main)·스택 상위 PR 은 사람이 머지 시점을 정한다. + if (pullRequest.baseRefName !== defaultBranch) { + return { enable: false, reason: `base 가 ${pullRequest.baseRefName} (자동 머지는 ${defaultBranch} 만)` }; + } + if (pullRequest.autoMergeRequest || pullRequest.isInMergeQueue) { + return { enable: false, reason: "이미 자동 머지 예약 또는 merge queue 에 있다" }; + } + // 방금 들어온 리뷰가 승인일 때만 건다. 사람이 예약을 끈 뒤 달린 일반 코멘트 리뷰가 예약을 + // 되살리지 않게 한다. + const lastReview = pullRequest.reviews?.nodes?.at(-1); + if (lastReview?.state !== "APPROVED") { + return { enable: false, reason: `마지막 리뷰가 승인이 아니다 (${lastReview?.state ?? "없음"})` }; + } + // 최신 판정 리컨사일 뒤의 PR 전체 판정. 변경요청이 남아 있으면 걸지 않는다. + if (pullRequest.reviewDecision !== "APPROVED") { + return { enable: false, reason: `reviewDecision 이 ${pullRequest.reviewDecision ?? "없음"}` }; + } + // 승인 뒤 새 커밋이 올라왔으면 그 커밋은 승인받지 않았다. + if (lastReview.commit?.oid !== pullRequest.headRefOid) { + return { enable: false, reason: "승인 뒤 새 커밋이 있다" }; + } + return { enable: true, reason: "승인됨 — 자동 머지 예약", headSha: pullRequest.headRefOid }; +} + +function main() { + const [responsePath] = process.argv.slice(2); + const defaultBranch = process.env.DEFAULT_BRANCH; + if (!responsePath || !defaultBranch) { + throw new Error("사용법: DEFAULT_BRANCH= node decide-auto-merge-on-approval.mjs "); + } + const response = JSON.parse(readFileSync(responsePath, "utf8")); + const decision = decideAutoMerge(response?.data?.repository?.pullRequest, defaultBranch); + console.log(`auto-merge: ${decision.reason}`); + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `enable=${decision.enable}\nhead_sha=${decision.headSha ?? ""}\n`); + } +} + +const isDirectExecution = process.argv[1] + && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isDirectExecution) { + try { + main(); + } catch (error) { + console.error(`::error::${error.message}`); + process.exitCode = 1; + } +} diff --git a/.github/scripts/decide-auto-merge-on-approval.test.mjs b/.github/scripts/decide-auto-merge-on-approval.test.mjs new file mode 100644 index 0000000000..9f49faf634 --- /dev/null +++ b/.github/scripts/decide-auto-merge-on-approval.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { decideAutoMerge } from "./decide-auto-merge-on-approval.mjs"; + +const HEAD = "0123456789abcdef0123456789abcdef01234567"; +const OLD = "fedcba9876543210fedcba9876543210fedcba98"; + +function approvedPullRequest(overrides = {}) { + return { + state: "OPEN", + isDraft: false, + baseRefName: "develop", + headRefOid: HEAD, + reviewDecision: "APPROVED", + autoMergeRequest: null, + isInMergeQueue: false, + reviews: { nodes: [{ state: "APPROVED", commit: { oid: HEAD } }] }, + ...overrides, + }; +} + +test("develop 대상 PR 이 최신 커밋에서 승인되면 자동 머지를 예약한다", () => { + assert.deepEqual(decideAutoMerge(approvedPullRequest(), "develop"), { + enable: true, + reason: "승인됨 — 자동 머지 예약", + headSha: HEAD, + }); +}); + +test("닫힘·Draft·다른 base 는 예약하지 않는다", () => { + for (const overrides of [ + { state: "CLOSED" }, + { state: "MERGED" }, + { isDraft: true }, + { baseRefName: "main" }, + { baseRefName: "feat/123-parent" }, + ]) { + assert.equal(decideAutoMerge(approvedPullRequest(overrides), "develop").enable, false, JSON.stringify(overrides)); + } + assert.equal(decideAutoMerge(null, "develop").enable, false); +}); + +test("마지막 리뷰가 승인이 아니면 예약하지 않는다 — 끈 예약을 코멘트가 되살리지 않는다", () => { + for (const state of ["COMMENTED", "CHANGES_REQUESTED", "DISMISSED"]) { + const pullRequest = approvedPullRequest({ + reviews: { nodes: [{ state: "APPROVED", commit: { oid: HEAD } }, { state, commit: { oid: HEAD } }] }, + }); + assert.equal(decideAutoMerge(pullRequest, "develop").enable, false, state); + } + assert.equal(decideAutoMerge(approvedPullRequest({ reviews: { nodes: [] } }), "develop").enable, false); +}); + +test("PR 전체 판정이 승인이 아니면 예약하지 않는다", () => { + for (const reviewDecision of ["CHANGES_REQUESTED", "REVIEW_REQUIRED", null]) { + assert.equal(decideAutoMerge(approvedPullRequest({ reviewDecision }), "develop").enable, false, String(reviewDecision)); + } +}); + +test("승인 뒤 새 커밋이 있으면 예약하지 않는다", () => { + const pullRequest = approvedPullRequest({ reviews: { nodes: [{ state: "APPROVED", commit: { oid: OLD } }] } }); + assert.equal(decideAutoMerge(pullRequest, "develop").enable, false); +}); + +test("이미 예약됐거나 큐에 있으면 다시 걸지 않는다", () => { + assert.equal(decideAutoMerge(approvedPullRequest({ autoMergeRequest: { enabledAt: "2026-10-04T00:00:00Z" } }), "develop").enable, false); + assert.equal(decideAutoMerge(approvedPullRequest({ isInMergeQueue: true }), "develop").enable, false); +}); + +test("워크플로는 리컨사일 뒤에만, 승인한 HEAD 로만 예약한다", async () => { + const workflow = await readFile( + new URL("../workflows/latest-review-decision-reconcile.yml", import.meta.url), + "utf8", + ); + const job = workflow.slice(workflow.indexOf(" auto-merge:")); + assert.match(job, /needs: reconcile/); + assert.match(job, /github\.event_name == 'workflow_run'/); + assert.match(job, /decide-auto-merge-on-approval\.mjs/); + assert.match(job, /--match-head-commit "\$HEAD_SHA"/); + // 우회 머지 금지. merge queue 와 required check 를 그대로 탄다. + assert.doesNotMatch(job, /--admin/); +}); diff --git a/.github/workflows/latest-review-decision-reconcile.yml b/.github/workflows/latest-review-decision-reconcile.yml index 270dfc1ece..19b7331c12 100644 --- a/.github/workflows/latest-review-decision-reconcile.yml +++ b/.github/workflows/latest-review-decision-reconcile.yml @@ -44,3 +44,60 @@ jobs: TARGET_PULL_NUMBER: >- ${{ inputs.pull_number || github.event.workflow_run.pull_requests[0].number }} run: node .github/scripts/reconcile-latest-review-decision.mjs + + # 승인 직후 «Merge when ready» 를 대신 건다. 머지 자체는 GitHub 가 승인 수·required check· + # merge queue 를 모두 통과한 뒤에 하므로 CI 가 빨갛거나 변경요청이 남으면 머지되지 않는다. + # 리컨사일이 낡은 변경요청을 걷은 뒤의 판정을 보려고 needs 로 잇는다. 리뷰어의 파일 Viewed + # 체크는 토큰 주인 본인 것만 조회되고 이벤트도 없어 조건에 넣지 못한다(판정 스크립트 머리말). + auto-merge: + name: Enable auto-merge on approval + needs: reconcile + if: github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + pull-requests: write + steps: + - name: Clone decision 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: Decide + id: decide + env: + GH_TOKEN: ${{ github.token }} + OWNER: ${{ github.repository_owner }} + NAME: ${{ github.event.repository.name }} + PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + gh api graphql -F owner="$OWNER" -F name="$NAME" -F number="$PR_NUMBER" -f query=' + query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + state isDraft baseRefName headRefOid reviewDecision isInMergeQueue + autoMergeRequest { enabledAt } + reviews(last: 1) { nodes { state commit { oid } } } + } + } + }' > "$RUNNER_TEMP/pull-request.json" + node .github/scripts/decide-auto-merge-on-approval.mjs "$RUNNER_TEMP/pull-request.json" + + - name: Enable auto-merge + if: steps.decide.outputs.enable == 'true' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} + HEAD_SHA: ${{ steps.decide.outputs.head_sha }} + run: | + # merge queue 대상이면 전략 없이 부른다. check 가 남았으면 auto-merge, 끝났으면 큐 투입이다. + # 스택 PR 처럼 GitHub 가 거부하는 경우는 사람이 버튼을 누르면 되므로 경고로 남긴다. + if ! gh pr merge "$PR_NUMBER" --match-head-commit "$HEAD_SHA"; then + echo "::warning::#$PR_NUMBER 자동 머지 예약 실패 — PR 화면에서 Merge when ready 를 직접 누르세요" + fi diff --git a/README.md b/README.md index b7d2e69a98..7300ec3f48 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은 최신 커밋에 승인이 달리면 [리뷰 판정 리컨사일 workflow](.github/workflows/latest-review-decision-reconcile.yml)가 자동 머지(Merge when ready)를 예약한다. 필수 검사가 통과해야 merge queue로 들어가고, 원치 않으면 PR 화면에서 예약을 끄면 된다. 리뷰어의 파일 Viewed 체크는 본인 토큰으로만 조회되고 이벤트도 없어 조건에 넣지 못하므로, 승인은 모든 파일을 확인한 뒤에 한다. - 리뷰 결과는 GitHub의 `APPROVED`·`CHANGES_REQUESTED`·일반 코멘트로 표현한다. 별도 RCA prefix나 12시간 SLA는 두지 않는다. - [리뷰 적체 가드](.github/workflows/review-debt-guard.yml)는 응답을 기다리는 다른 PR이 남았거나 자기가 담당하는 PR의 최신 변경요청 뒤 아무 조치도 하지 않은 팀원의 새 PR을 닫을 수 있다. 아래 면제 목록에 있는 작성자는 대상이 아니다. 각 PR은 팀원 한 명이 먼저 유효한 판정을 내리면 최초 미응답 목록에서 빠지고, 담당자가 실질 커밋이나 응답을 남기면 작성자 대기 목록에서 빠진다. - `awaiting-author` 라벨은 리뷰 게이트 적용 대상 중 변경요청 뒤 담당자 무조치 상태인 PR을 보여 준다. 면제 작성자에게는 붙이지 않으며, 이미 붙은 라벨도 리컨사일러가 제거한다. 새 PR 가드는 라벨 갱신 시점에 의존하지 않고 같은 판정을 현재 열린 PR에 다시 적용한다. From b2e293b7a9bbdd1df16255f8dae389ffd69767f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 4 Oct 2026 06:44:05 +0000 Subject: [PATCH 2/3] =?UTF-8?q?ci(review):=20GraphQL=20=EC=BF=BC=EB=A6=AC?= =?UTF-8?q?=EB=A5=BC=20env=20=EB=A1=9C=20=EC=98=AE=EA=B2=A8=20SC2016=20?= =?UTF-8?q?=EC=9D=84=20=EC=97=86=EC=95=A4=EB=8B=A4=20(#2248)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/latest-review-decision-reconcile.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/latest-review-decision-reconcile.yml b/.github/workflows/latest-review-decision-reconcile.yml index 19b7331c12..dfc61e24f5 100644 --- a/.github/workflows/latest-review-decision-reconcile.yml +++ b/.github/workflows/latest-review-decision-reconcile.yml @@ -74,9 +74,7 @@ jobs: NAME: ${{ github.event.repository.name }} PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: | - set -euo pipefail - gh api graphql -F owner="$OWNER" -F name="$NAME" -F number="$PR_NUMBER" -f query=' + QUERY: | query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { @@ -85,7 +83,11 @@ jobs: reviews(last: 1) { nodes { state commit { oid } } } } } - }' > "$RUNNER_TEMP/pull-request.json" + } + run: | + set -euo pipefail + gh api graphql -F owner="$OWNER" -F name="$NAME" -F number="$PR_NUMBER" -f query="$QUERY" \ + > "$RUNNER_TEMP/pull-request.json" node .github/scripts/decide-auto-merge-on-approval.mjs "$RUNNER_TEMP/pull-request.json" - name: Enable auto-merge From d66273af142d5c07a0c31855dd0b4a23c79202d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 4 Oct 2026 06:52:26 +0000 Subject: [PATCH 3/3] =?UTF-8?q?ci(review):=20=EC=8A=B9=EC=9D=B8=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=EB=8C=80=EC=8B=A0=201hyok=20=EC=9D=98=20V?= =?UTF-8?q?iewed=20=EC=A0=84=EC=B2=B4=20=EC=B2=B4=ED=81=AC=EB=A1=9C=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EB=A8=B8=EC=A7=80=EB=A5=BC=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=ED=95=9C=EB=8B=A4=20(#2248)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/decide-auto-merge-on-approval.mjs | 69 ---------- .../decide-auto-merge-on-approval.test.mjs | 83 ------------ .../find-viewed-auto-merge-targets.mjs | 123 ++++++++++++++++++ .../find-viewed-auto-merge-targets.test.mjs | 64 +++++++++ .../latest-review-decision-reconcile.yml | 59 --------- .github/workflows/viewed-auto-merge.yml | 75 +++++++++++ README.md | 2 +- 7 files changed, 263 insertions(+), 212 deletions(-) delete mode 100644 .github/scripts/decide-auto-merge-on-approval.mjs delete mode 100644 .github/scripts/decide-auto-merge-on-approval.test.mjs create mode 100644 .github/scripts/find-viewed-auto-merge-targets.mjs create mode 100644 .github/scripts/find-viewed-auto-merge-targets.test.mjs create mode 100644 .github/workflows/viewed-auto-merge.yml diff --git a/.github/scripts/decide-auto-merge-on-approval.mjs b/.github/scripts/decide-auto-merge-on-approval.mjs deleted file mode 100644 index 645c2adff4..0000000000 --- a/.github/scripts/decide-auto-merge-on-approval.mjs +++ /dev/null @@ -1,69 +0,0 @@ -// 승인 직후 자동 머지 예약 판정 -// -// 리뷰어가 승인하면 «Merge when ready» 를 대신 눌러 둔다. 실제 머지는 GitHub 가 required check· -// 승인 수·merge queue 를 모두 통과시킨 뒤에만 하므로, 이 판정은 «예약을 걸어도 되는가» 만 본다. -// -// 리뷰어의 파일 «Viewed» 체크는 조건에 넣지 못한다. GraphQL 의 viewerViewedState 는 토큰 주인 -// 본인의 상태만 돌려주므로 GITHUB_TOKEN(github-actions)으로는 리뷰어의 체크를 볼 수 없고, -// 체크 변경을 알리는 webhook 이벤트도 없다. 승인을 «다 봤다» 는 신호로 쓴다. -import { appendFileSync, readFileSync } from "node:fs"; -import { pathToFileURL } from "node:url"; - -/** - * @param {object} pullRequest GraphQL repository.pullRequest 노드 - * @param {string} defaultBranch 자동 머지를 허용할 base(통합 브랜치) - * @returns {{ enable: boolean, reason: string, headSha?: string }} - */ -export function decideAutoMerge(pullRequest, defaultBranch) { - if (!pullRequest) return { enable: false, reason: "PR 을 찾지 못했다" }; - if (pullRequest.state !== "OPEN") return { enable: false, reason: `PR 상태가 ${pullRequest.state}` }; - if (pullRequest.isDraft) return { enable: false, reason: "Draft PR" }; - // 릴리스(main)·스택 상위 PR 은 사람이 머지 시점을 정한다. - if (pullRequest.baseRefName !== defaultBranch) { - return { enable: false, reason: `base 가 ${pullRequest.baseRefName} (자동 머지는 ${defaultBranch} 만)` }; - } - if (pullRequest.autoMergeRequest || pullRequest.isInMergeQueue) { - return { enable: false, reason: "이미 자동 머지 예약 또는 merge queue 에 있다" }; - } - // 방금 들어온 리뷰가 승인일 때만 건다. 사람이 예약을 끈 뒤 달린 일반 코멘트 리뷰가 예약을 - // 되살리지 않게 한다. - const lastReview = pullRequest.reviews?.nodes?.at(-1); - if (lastReview?.state !== "APPROVED") { - return { enable: false, reason: `마지막 리뷰가 승인이 아니다 (${lastReview?.state ?? "없음"})` }; - } - // 최신 판정 리컨사일 뒤의 PR 전체 판정. 변경요청이 남아 있으면 걸지 않는다. - if (pullRequest.reviewDecision !== "APPROVED") { - return { enable: false, reason: `reviewDecision 이 ${pullRequest.reviewDecision ?? "없음"}` }; - } - // 승인 뒤 새 커밋이 올라왔으면 그 커밋은 승인받지 않았다. - if (lastReview.commit?.oid !== pullRequest.headRefOid) { - return { enable: false, reason: "승인 뒤 새 커밋이 있다" }; - } - return { enable: true, reason: "승인됨 — 자동 머지 예약", headSha: pullRequest.headRefOid }; -} - -function main() { - const [responsePath] = process.argv.slice(2); - const defaultBranch = process.env.DEFAULT_BRANCH; - if (!responsePath || !defaultBranch) { - throw new Error("사용법: DEFAULT_BRANCH= node decide-auto-merge-on-approval.mjs "); - } - const response = JSON.parse(readFileSync(responsePath, "utf8")); - const decision = decideAutoMerge(response?.data?.repository?.pullRequest, defaultBranch); - console.log(`auto-merge: ${decision.reason}`); - if (process.env.GITHUB_OUTPUT) { - appendFileSync(process.env.GITHUB_OUTPUT, `enable=${decision.enable}\nhead_sha=${decision.headSha ?? ""}\n`); - } -} - -const isDirectExecution = process.argv[1] - && import.meta.url === pathToFileURL(process.argv[1]).href; - -if (isDirectExecution) { - try { - main(); - } catch (error) { - console.error(`::error::${error.message}`); - process.exitCode = 1; - } -} diff --git a/.github/scripts/decide-auto-merge-on-approval.test.mjs b/.github/scripts/decide-auto-merge-on-approval.test.mjs deleted file mode 100644 index 9f49faf634..0000000000 --- a/.github/scripts/decide-auto-merge-on-approval.test.mjs +++ /dev/null @@ -1,83 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; - -import { decideAutoMerge } from "./decide-auto-merge-on-approval.mjs"; - -const HEAD = "0123456789abcdef0123456789abcdef01234567"; -const OLD = "fedcba9876543210fedcba9876543210fedcba98"; - -function approvedPullRequest(overrides = {}) { - return { - state: "OPEN", - isDraft: false, - baseRefName: "develop", - headRefOid: HEAD, - reviewDecision: "APPROVED", - autoMergeRequest: null, - isInMergeQueue: false, - reviews: { nodes: [{ state: "APPROVED", commit: { oid: HEAD } }] }, - ...overrides, - }; -} - -test("develop 대상 PR 이 최신 커밋에서 승인되면 자동 머지를 예약한다", () => { - assert.deepEqual(decideAutoMerge(approvedPullRequest(), "develop"), { - enable: true, - reason: "승인됨 — 자동 머지 예약", - headSha: HEAD, - }); -}); - -test("닫힘·Draft·다른 base 는 예약하지 않는다", () => { - for (const overrides of [ - { state: "CLOSED" }, - { state: "MERGED" }, - { isDraft: true }, - { baseRefName: "main" }, - { baseRefName: "feat/123-parent" }, - ]) { - assert.equal(decideAutoMerge(approvedPullRequest(overrides), "develop").enable, false, JSON.stringify(overrides)); - } - assert.equal(decideAutoMerge(null, "develop").enable, false); -}); - -test("마지막 리뷰가 승인이 아니면 예약하지 않는다 — 끈 예약을 코멘트가 되살리지 않는다", () => { - for (const state of ["COMMENTED", "CHANGES_REQUESTED", "DISMISSED"]) { - const pullRequest = approvedPullRequest({ - reviews: { nodes: [{ state: "APPROVED", commit: { oid: HEAD } }, { state, commit: { oid: HEAD } }] }, - }); - assert.equal(decideAutoMerge(pullRequest, "develop").enable, false, state); - } - assert.equal(decideAutoMerge(approvedPullRequest({ reviews: { nodes: [] } }), "develop").enable, false); -}); - -test("PR 전체 판정이 승인이 아니면 예약하지 않는다", () => { - for (const reviewDecision of ["CHANGES_REQUESTED", "REVIEW_REQUIRED", null]) { - assert.equal(decideAutoMerge(approvedPullRequest({ reviewDecision }), "develop").enable, false, String(reviewDecision)); - } -}); - -test("승인 뒤 새 커밋이 있으면 예약하지 않는다", () => { - const pullRequest = approvedPullRequest({ reviews: { nodes: [{ state: "APPROVED", commit: { oid: OLD } }] } }); - assert.equal(decideAutoMerge(pullRequest, "develop").enable, false); -}); - -test("이미 예약됐거나 큐에 있으면 다시 걸지 않는다", () => { - assert.equal(decideAutoMerge(approvedPullRequest({ autoMergeRequest: { enabledAt: "2026-10-04T00:00:00Z" } }), "develop").enable, false); - assert.equal(decideAutoMerge(approvedPullRequest({ isInMergeQueue: true }), "develop").enable, false); -}); - -test("워크플로는 리컨사일 뒤에만, 승인한 HEAD 로만 예약한다", async () => { - const workflow = await readFile( - new URL("../workflows/latest-review-decision-reconcile.yml", import.meta.url), - "utf8", - ); - const job = workflow.slice(workflow.indexOf(" auto-merge:")); - assert.match(job, /needs: reconcile/); - assert.match(job, /github\.event_name == 'workflow_run'/); - assert.match(job, /decide-auto-merge-on-approval\.mjs/); - assert.match(job, /--match-head-commit "\$HEAD_SHA"/); - // 우회 머지 금지. merge queue 와 required check 를 그대로 탄다. - assert.doesNotMatch(job, /--admin/); -}); 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..f527e92250 --- /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 0000000000..1ca002167e --- /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/latest-review-decision-reconcile.yml b/.github/workflows/latest-review-decision-reconcile.yml index dfc61e24f5..270dfc1ece 100644 --- a/.github/workflows/latest-review-decision-reconcile.yml +++ b/.github/workflows/latest-review-decision-reconcile.yml @@ -44,62 +44,3 @@ jobs: TARGET_PULL_NUMBER: >- ${{ inputs.pull_number || github.event.workflow_run.pull_requests[0].number }} run: node .github/scripts/reconcile-latest-review-decision.mjs - - # 승인 직후 «Merge when ready» 를 대신 건다. 머지 자체는 GitHub 가 승인 수·required check· - # merge queue 를 모두 통과한 뒤에 하므로 CI 가 빨갛거나 변경요청이 남으면 머지되지 않는다. - # 리컨사일이 낡은 변경요청을 걷은 뒤의 판정을 보려고 needs 로 잇는다. 리뷰어의 파일 Viewed - # 체크는 토큰 주인 본인 것만 조회되고 이벤트도 없어 조건에 넣지 못한다(판정 스크립트 머리말). - auto-merge: - name: Enable auto-merge on approval - needs: reconcile - if: github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: write - pull-requests: write - steps: - - name: Clone decision 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: Decide - id: decide - env: - GH_TOKEN: ${{ github.token }} - OWNER: ${{ github.repository_owner }} - NAME: ${{ github.event.repository.name }} - PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - QUERY: | - query($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - state isDraft baseRefName headRefOid reviewDecision isInMergeQueue - autoMergeRequest { enabledAt } - reviews(last: 1) { nodes { state commit { oid } } } - } - } - } - run: | - set -euo pipefail - gh api graphql -F owner="$OWNER" -F name="$NAME" -F number="$PR_NUMBER" -f query="$QUERY" \ - > "$RUNNER_TEMP/pull-request.json" - node .github/scripts/decide-auto-merge-on-approval.mjs "$RUNNER_TEMP/pull-request.json" - - - name: Enable auto-merge - if: steps.decide.outputs.enable == 'true' - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} - HEAD_SHA: ${{ steps.decide.outputs.head_sha }} - run: | - # merge queue 대상이면 전략 없이 부른다. check 가 남았으면 auto-merge, 끝났으면 큐 투입이다. - # 스택 PR 처럼 GitHub 가 거부하는 경우는 사람이 버튼을 누르면 되므로 경고로 남긴다. - if ! gh pr merge "$PR_NUMBER" --match-head-commit "$HEAD_SHA"; then - echo "::warning::#$PR_NUMBER 자동 머지 예약 실패 — PR 화면에서 Merge when ready 를 직접 누르세요" - fi diff --git a/.github/workflows/viewed-auto-merge.yml b/.github/workflows/viewed-auto-merge.yml new file mode 100644 index 0000000000..362d0d947b --- /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 7300ec3f48..d308fb7f67 100644 --- a/README.md +++ b/README.md @@ -188,7 +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은 최신 커밋에 승인이 달리면 [리뷰 판정 리컨사일 workflow](.github/workflows/latest-review-decision-reconcile.yml)가 자동 머지(Merge when ready)를 예약한다. 필수 검사가 통과해야 merge queue로 들어가고, 원치 않으면 PR 화면에서 예약을 끄면 된다. 리뷰어의 파일 Viewed 체크는 본인 토큰으로만 조회되고 이벤트도 없어 조건에 넣지 못하므로, 승인은 모든 파일을 확인한 뒤에 한다. +- `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에 다시 적용한다.