diff --git a/.github/scripts/find-viewed-auto-merge-targets.mjs b/.github/scripts/find-viewed-auto-merge-targets.mjs index ad697e553..55b30a00f 100644 --- a/.github/scripts/find-viewed-auto-merge-targets.mjs +++ b/.github/scripts/find-viewed-auto-merge-targets.mjs @@ -8,24 +8,23 @@ // 읽고, 토큰 주인이 VIEWED_BY 와 다르면 판정하지 않는다. 쓰기(예약)는 워크플로의 GITHUB_TOKEN 이 한다. // 체크한 뒤 바뀐 파일은 GitHub 가 DISMISSED 로 되돌리므로 새 커밋은 다시 봐야 대상이 된다. // -// 예약을 건 뒤 새 push 로 Viewed 가 풀려도 GitHub 는 예약·큐 등록을 그대로 두고, develop 룰셋은 push 로 승인을 -// 지우지 않는다(dismiss_stale_reviews_on_push false). 그래서 예약됐거나 큐에 있는 PR 중 Viewed 가 다 차지 않은 -// 것은 해제 대상으로 고른다(#2252). 누가 걸었는지는 따지지 않는다. 다시 전부 Viewed 가 되면 다음 실행이 다시 건다. +// 반대 방향(예약 해제·큐에서 빼기)은 하지 않는다(#2252 폐지). Viewed 가 덜 찬 PR 도 사람이 직접 예약하거나 큐에 넣으면 +// 그대로 머지된다. Viewed 는 자동 예약의 조건일 뿐 머지를 막는 장치가 아니다. // // 이 규칙은 VIEWED_BY 개인의 운영 규칙이라 VIEWED_BY 본인이 작성한 PR 에만 적용한다(#2270). 팀원·봇 PR 은 -// 예약도 해제도 하지 않는다. 걸지 않으면 VIEWED_BY 가 보지 않은 팀원·dependabot PR 이 큐에서 계속 빠진다. +// 예약하지 않는다. import { appendFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; const PULL_REQUEST_FIELDS = ` - id number isDraft headRefOid isInMergeQueue changedFiles + number isDraft headRefOid isInMergeQueue changedFiles author { login } autoMergeRequest { enabledAt } files(first: 100) { pageInfo { hasNextPage endCursor } nodes { path viewerViewedState } } `; /** - * 예약·해제를 판정할 PR 인지 가른다. VIEWED_BY 본인이 작성한 PR 만 대상이다(대소문자 무시). + * 예약을 판정할 PR 인지 가른다. VIEWED_BY 본인이 작성한 PR 만 대상이다(대소문자 무시). * 작성자를 알 수 없는 PR(탈퇴 계정 등)은 대상이 아니다. * * @param {object} pullRequest GraphQL PullRequest 노드 @@ -58,30 +57,6 @@ export function decideViewedAutoMerge(pullRequest, files) { return { enable: true, reason: `모든 파일 Viewed — 자동 머지 예약` }; } -/** - * 자동 머지 예약 또는 merge queue 등록을 풀어야 하는지 판정한다. - * - * @param {object} pullRequest GraphQL PullRequest 노드 - * @param {Array<{ path: string, viewerViewedState: string }>} files PR 의 전체 변경 파일 - * @returns {{ disableAutoMerge: boolean, dequeue: boolean, reason: string }} - */ -export function decideViewedAutoMergeCancel(pullRequest, files) { - const autoMerge = Boolean(pullRequest.autoMergeRequest); - const queued = Boolean(pullRequest.isInMergeQueue); - if (!autoMerge && !queued) return { disableAutoMerge: false, dequeue: false, reason: "예약·큐 등록 없음" }; - const keep = (reason) => ({ disableAutoMerge: false, dequeue: false, reason }); - const cancel = (reason) => ({ disableAutoMerge: autoMerge, dequeue: queued, reason }); - // 파일 목록을 다 못 읽었으면 «다 봤다» 고 단정하지 않는다. 예약 쪽과 같은 기준이다. - if (files.length === 0 || files.length !== pullRequest.changedFiles) { - return cancel(`파일 ${files.length}/${pullRequest.changedFiles} 만 읽었다 — 예약·큐 해제`); - } - const unviewed = files.filter((file) => file.viewerViewedState !== "VIEWED"); - if (unviewed.length > 0) { - return cancel(`Viewed 안 된 파일 ${unviewed.length}/${files.length} — 예약·큐 해제`); - } - return keep("이미 자동 머지 예약 또는 merge queue 에 있고 모든 파일 Viewed"); -} - async function graphql(token, apiUrl, query, variables) { const response = await fetch(`${apiUrl}/graphql`, { method: "POST", @@ -117,35 +92,23 @@ async function readAllFiles(request, owner, name, pullRequest) { } /** - * 열린 PR 마다 예약·해제를 판정한다. VIEWED_BY 본인 PR 이 아니면 파일도 읽지 않고 건너뛴다. + * 열린 PR 마다 예약을 판정한다. VIEWED_BY 본인 PR 이 아니면 파일도 읽지 않고 건너뛴다. * - * @returns {Promise<{ targets: Array, cancels: Array }>} + * @returns {Promise<{ targets: Array }>} */ export async function collectViewedAutoMergeActions(request, owner, name, viewedBy, pullRequests) { const targets = []; - const cancels = []; for (const pullRequest of pullRequests) { if (!isAuthoredByViewer(pullRequest, viewedBy)) { console.log(`#${pullRequest.number}: 작성자 ${pullRequest.author?.login ?? "없음"} — ${viewedBy} 의 PR 이 아니라 건드리지 않는다`); continue; } const files = await readAllFiles(request, owner, name, pullRequest); - const cancel = decideViewedAutoMergeCancel(pullRequest, files); - if (cancel.disableAutoMerge || cancel.dequeue) { - console.log(`#${pullRequest.number}: ${cancel.reason}`); - cancels.push({ - number: pullRequest.number, - id: pullRequest.id, - disableAutoMerge: cancel.disableAutoMerge, - dequeue: cancel.dequeue, - }); - continue; - } const decision = decideViewedAutoMerge(pullRequest, files); console.log(`#${pullRequest.number}: ${decision.reason}`); if (decision.enable) targets.push({ number: pullRequest.number, headSha: pullRequest.headRefOid }); } - return { targets, cancels }; + return { targets }; } async function main() { @@ -172,13 +135,13 @@ async function main() { throw new Error(`VIEWED_TOKEN 주인이 ${data.viewer.login} 입니다 — ${viewedBy} 의 토큰이어야 합니다.`); } - const { targets, cancels } = await collectViewedAutoMergeActions( + const { targets } = await collectViewedAutoMergeActions( request, owner, name, viewedBy, data.repository.pullRequests.nodes, ); if (process.env.GITHUB_OUTPUT) { appendFileSync( process.env.GITHUB_OUTPUT, - `targets=${JSON.stringify(targets)}\ncancels=${JSON.stringify(cancels)}\n`, + `targets=${JSON.stringify(targets)}\n`, ); } } diff --git a/.github/scripts/find-viewed-auto-merge-targets.test.mjs b/.github/scripts/find-viewed-auto-merge-targets.test.mjs index 8515ccbbc..19ce8a030 100644 --- a/.github/scripts/find-viewed-auto-merge-targets.test.mjs +++ b/.github/scripts/find-viewed-auto-merge-targets.test.mjs @@ -5,7 +5,6 @@ import test from "node:test"; import { collectViewedAutoMergeActions, decideViewedAutoMerge, - decideViewedAutoMergeCancel, isAuthoredByViewer, } from "./find-viewed-auto-merge-targets.mjs"; @@ -55,56 +54,6 @@ test("Draft·이미 예약·큐에 있는 PR 은 건드리지 않는다", () => const scheduled = { autoMergeRequest: { enabledAt: "2026-10-04T00:00:00Z" } }; -test("예약·큐 등록이 없으면 Viewed 와 무관하게 해제하지 않는다", () => { - const decision = decideViewedAutoMergeCancel(pullRequest(), [viewed("a.kt"), { path: "b.kt", viewerViewedState: "UNVIEWED" }]); - assert.equal(decision.disableAutoMerge, false); - assert.equal(decision.dequeue, false); -}); - -test("예약 뒤 새 push 로 Viewed 가 풀리면 예약을 끈다 — DISMISSED·UNVIEWED", () => { - for (const viewerViewedState of ["DISMISSED", "UNVIEWED"]) { - const files = [viewed("a.kt"), { path: "b.kt", viewerViewedState }]; - const decision = decideViewedAutoMergeCancel(pullRequest(scheduled), files); - assert.equal(decision.disableAutoMerge, true, viewerViewedState); - assert.equal(decision.dequeue, false, viewerViewedState); - } -}); - -test("merge queue 에 있는데 Viewed 가 풀리면 큐에서 빼고, 예약도 있으면 함께 끈다", () => { - const files = [viewed("a.kt"), { path: "b.kt", viewerViewedState: "DISMISSED" }]; - assert.deepEqual( - pickActions(decideViewedAutoMergeCancel(pullRequest({ isInMergeQueue: true }), files)), - { disableAutoMerge: false, dequeue: true }, - ); - assert.deepEqual( - pickActions(decideViewedAutoMergeCancel(pullRequest({ ...scheduled, isInMergeQueue: true }), files)), - { disableAutoMerge: true, dequeue: true }, - ); -}); - -test("리뷰·Draft 상태와 무관하게 Viewed 로만 해제를 가른다", () => { - const files = [viewed("a.kt"), { path: "b.kt", viewerViewedState: "UNVIEWED" }]; - for (const overrides of [{ reviewDecision: "APPROVED" }, { reviewDecision: "CHANGES_REQUESTED" }, { isDraft: true }]) { - assert.equal(decideViewedAutoMergeCancel(pullRequest({ ...scheduled, ...overrides }), files).disableAutoMerge, true); - } -}); - -test("파일 목록을 다 못 읽었으면 다 봤다고 단정하지 않고 해제한다", () => { - assert.equal(decideViewedAutoMergeCancel(pullRequest({ ...scheduled, changedFiles: 3 }), [viewed("a.kt"), viewed("b.kt")]).disableAutoMerge, true); - assert.equal(decideViewedAutoMergeCancel(pullRequest({ isInMergeQueue: true, changedFiles: 0 }), []).dequeue, true); -}); - -test("예약·큐에 있고 모든 파일이 Viewed 면 그대로 둔다", () => { - const files = [viewed("a.kt"), viewed("b.kt")]; - for (const overrides of [scheduled, { isInMergeQueue: true }, { ...scheduled, isInMergeQueue: true }]) { - assert.deepEqual(pickActions(decideViewedAutoMergeCancel(pullRequest(overrides), files)), { disableAutoMerge: false, dequeue: false }); - } -}); - -function pickActions({ disableAutoMerge, dequeue }) { - return { disableAutoMerge, dequeue }; -} - test("VIEWED_BY 본인이 작성한 PR 만 대상이다 — 대소문자 무시, 작성자 없음은 제외", () => { assert.equal(isAuthoredByViewer({ author: { login: "1hyok" } }, "1hyok"), true); assert.equal(isAuthoredByViewer({ author: { login: "1Hyok" } }, "1hyok"), true); @@ -130,19 +79,18 @@ const noRequest = async () => { throw new Error("파일 추가 조회는 없어야 한다"); }; -test("팀원·봇 PR 은 큐에 있고 Viewed 가 0 이어도 해제하지 않고, 승인·Viewed 가 다 차도 예약하지 않는다", async () => { +test("팀원·봇 PR 은 Viewed 가 다 차도 예약하지 않는다", async () => { const pullRequests = [ listedPullRequest(2262, "dependabot", { isInMergeQueue: true }, ["UNVIEWED", "UNVIEWED"]), - listedPullRequest(2269, "koongmai", { ...scheduled, isInMergeQueue: true }, ["UNVIEWED", "DISMISSED"]), listedPullRequest(2300, "Sadturtleman", { isInMergeQueue: true, changedFiles: 0 }, []), - listedPullRequest(2301, null, scheduled, ["UNVIEWED"]), + listedPullRequest(2301, null), listedPullRequest(2302, "koongmai"), ]; const actions = await collectViewedAutoMergeActions(noRequest, "Afternote", "Afternote-FE", "1hyok", pullRequests); - assert.deepEqual(actions, { targets: [], cancels: [] }); + assert.deepEqual(actions, { targets: [] }); }); -test("본인 PR 은 지금처럼 Viewed 가 풀리면 해제하고, 승인·Viewed 가 다 차면 예약한다", async () => { +test("본인 PR 은 Viewed 가 다 차면 예약하고, 덜 찬 채 예약·큐에 있어도 빼지 않는다", async () => { const pullRequests = [ listedPullRequest(10, "1hyok", { isInMergeQueue: true }, ["UNVIEWED", "UNVIEWED"]), listedPullRequest(11, "1Hyok", scheduled, ["VIEWED", "DISMISSED"]), @@ -150,11 +98,7 @@ test("본인 PR 은 지금처럼 Viewed 가 풀리면 해제하고, 승인·View listedPullRequest(13, "dependabot", { isInMergeQueue: true }, ["UNVIEWED", "UNVIEWED"]), ]; const actions = await collectViewedAutoMergeActions(noRequest, "Afternote", "Afternote-FE", "1hyok", pullRequests); - assert.deepEqual(actions.cancels, [ - { number: 10, id: "PR_10", disableAutoMerge: false, dequeue: true }, - { number: 11, id: "PR_11", disableAutoMerge: true, dequeue: false }, - ]); - assert.deepEqual(actions.targets, [{ number: 12, headSha: pullRequest().headRefOid }]); + assert.deepEqual(actions, { targets: [{ number: 12, headSha: pullRequest().headRefOid }] }); }); test("GraphQL 조회가 작성자를 가져온다", async () => { @@ -163,15 +107,12 @@ test("GraphQL 조회가 작성자를 가져온다", async () => { assert.match(fields, /author \{ login \}/); }); -test("워크플로는 해제 대상의 예약을 끄고 큐에서 빼며, 해제 실패는 red 로 남긴다", async () => { +test("워크플로는 예약을 끄거나 큐에서 빼지 않는다 — Viewed 는 머지를 막지 않는다", async () => { const workflow = await readFile(new URL("../workflows/viewed-auto-merge.yml", import.meta.url), "utf8"); - assert.match(workflow, /echo 'cancels=\[\]' >> "\$GITHUB_OUTPUT"/); - assert.match(workflow, /CANCELS: \$\{\{ steps\.find\.outputs\.cancels \}\}/); - assert.match(workflow, /gh pr merge "\$number" --disable-auto/); - assert.match(workflow, /dequeuePullRequest\(input: \{ id: \$id \}\)/); - assert.match(workflow, /exit "\$failed"/); - // 해제는 예약보다 먼저 돈다. - assert.ok(workflow.indexOf("--disable-auto") < workflow.indexOf("--match-head-commit")); + assert.doesNotMatch(workflow, /--disable-auto/); + assert.doesNotMatch(workflow, /dequeuePullRequest/); + assert.doesNotMatch(workflow, /cancels/); + assert.doesNotMatch(workflow, /pull-requests: write|contents: write/); }); test("워크플로는 본인 토큰으로 읽고 사람 토큰으로 승인한 HEAD 만 예약한다", async () => { @@ -181,7 +122,6 @@ test("워크플로는 본인 토큰으로 읽고 사람 토큰으로 승인한 H assert.match(workflow, /ref: \$\{\{ github\.event\.repository\.default_branch \}\}/); assert.match(workflow, /persist-credentials: false/); // GITHUB_TOKEN 으로 예약·투입하면 merge_group CI 가 뜨지 않아 큐가 60분 막힌다(1005 #2208). - // 해제 단계는 CI 가 필요 없어 GITHUB_TOKEN 을 써도 되므로, 예약 단계만 본다. const enableStep = workflow.slice(workflow.indexOf("- name: Enable auto-merge")); assert.match(enableStep, /GH_TOKEN: \$\{\{ secrets\.MERGE_QUEUE_TOKEN \}\}/); assert.doesNotMatch(enableStep, /GH_TOKEN: \$\{\{ github\.token \}\}/); diff --git a/.github/workflows/viewed-auto-merge.yml b/.github/workflows/viewed-auto-merge.yml index 4a89af9fc..df32ac8c8 100644 --- a/.github/workflows/viewed-auto-merge.yml +++ b/.github/workflows/viewed-auto-merge.yml @@ -1,14 +1,12 @@ # Viewed 기반 자동 머지 예약 (#2248) # -# 적용 범위는 VIEWED_BY 본인이 작성한 PR 이다(#2270). 팀원·봇 PR 은 예약도 해제도 하지 않는다. +# 적용 범위는 VIEWED_BY 본인이 작성한 PR 이다(#2270). 팀원·봇 PR 은 예약하지 않는다. # # VIEWED_BY 가 PR 의 모든 파일을 Viewed 로 체크하면 «Merge when ready» 를 대신 건다. 리뷰 상태는 보지 않는다. # 머지는 GitHub 가 룰셋·required check·merge queue 를 모두 통과시킨 뒤에만 하므로 CI 가 빨가면 머지되지 않는다. # -# 반대 방향도 매 실행이 맞춘다(#2252). 예약됐거나 merge queue 에 있는 VIEWED_BY 본인 PR 중 VIEWED_BY 기준 Viewed 가 다 차지 -# 않은 것은 예약을 끄고 큐에서 뺀다. 새 push 로 바뀐 파일은 DISMISSED 가 되고 룰셋은 push 로 승인을 지우지 -# 않으므로, 이 단계가 없으면 아무도 다시 보지 않은 커밋이 머지된다. 누가 건 예약인지는 따지지 않는다. 예약을 -# 멈추려면 파일 하나의 Viewed 를 풀면 되고, 다시 전부 Viewed 가 되면 다음 실행이 다시 건다. +# 반대 방향(예약 해제·큐에서 빼기)은 하지 않는다(#2252 폐지). Viewed 가 덜 찬 PR 도 사람이 직접 예약하거나 큐에 넣으면 +# 그대로 머지된다. Viewed 는 자동 예약의 조건일 뿐 머지를 막는 장치가 아니다. # # Viewed 체크는 webhook 이벤트가 없어 주기 실행으로 훑는다. viewerViewedState 는 토큰 주인 본인 것만 보이므로 VIEWED_BY 의 읽기 전용 토큰을 VIEWED_TOKEN # 시크릿으로 둔다. 시크릿이 없으면 아무것도 하지 않는다. @@ -31,13 +29,12 @@ permissions: {} jobs: enable: - name: Sync auto-merge with Viewed state + name: Enable auto-merge for fully viewed PRs runs-on: ubuntu-latest timeout-minutes: 5 permissions: - # 해제 단계가 GITHUB_TOKEN 으로 예약을 끄고 큐에서 뺀다. 해제는 CI 를 띄울 필요가 없다. - contents: write - pull-requests: write + # 기본 브랜치의 스크립트만 체크아웃한다. 예약 쓰기는 MERGE_QUEUE_TOKEN 이 한다. + contents: read steps: - name: Clone script from the default branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -46,7 +43,7 @@ jobs: persist-credentials: false sparse-checkout: .github/scripts - - name: Find PRs to enable or cancel auto-merge + - name: Find PRs to enable auto-merge id: find env: VIEWED_TOKEN: ${{ secrets.VIEWED_TOKEN }} @@ -58,40 +55,12 @@ jobs: if [ -z "$VIEWED_TOKEN" ]; then echo "::notice::VIEWED_TOKEN 시크릿이 없어 건너뜁니다" echo 'targets=[]' >> "$GITHUB_OUTPUT" - echo 'cancels=[]' >> "$GITHUB_OUTPUT" exit 0 fi node .github/scripts/find-viewed-auto-merge-targets.mjs - - name: Cancel auto-merge for PRs no longer fully viewed - if: steps.find.outputs.cancels != '[]' - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - CANCELS: ${{ steps.find.outputs.cancels }} - run: | - # 예약을 먼저 끄고 큐에서 뺀다. 해제는 HEAD 를 따지지 않는다 — 판정 뒤 Viewed 가 다 찼더라도 다음 - # 실행이 다시 건다. 해제가 실패하면 보지 않은 커밋이 머지될 수 있으므로 job 을 red 로 남긴다. - failed=0 - while read -r target; do - number=$(jq -r '.number' <<< "$target") - if [ "$(jq -r '.disableAutoMerge' <<< "$target")" = true ] && ! gh pr merge "$number" --disable-auto; then - echo "::error::#$number 자동 머지 예약 해제 실패" - failed=1 - fi - # shellcheck disable=SC2016 # GraphQL 변수는 gh가 -f 값으로 바인딩한다. - if [ "$(jq -r '.dequeue' <<< "$target")" = true ] && ! gh api graphql \ - -f query='mutation($id: ID!) { dequeuePullRequest(input: { id: $id }) { clientMutationId } }' \ - -f id="$(jq -r '.id' <<< "$target")" > /dev/null; then - echo "::error::#$number merge queue 해제 실패" - failed=1 - fi - done < <(jq -c '.[]' <<< "$CANCELS") - exit "$failed" - - name: Enable auto-merge - # 해제 단계가 red 여도 다른 PR 의 예약은 그대로 판정한다. - if: ${{ !cancelled() && steps.find.outcome == 'success' && steps.find.outputs.targets != '[]' }} + if: steps.find.outputs.targets != '[]' env: GH_TOKEN: ${{ secrets.MERGE_QUEUE_TOKEN }} GH_REPO: ${{ github.repository }} diff --git a/README.md b/README.md index a30bebaa2..9bb88e976 100644 --- a/README.md +++ b/README.md @@ -187,4 +187,4 @@ Kover는 임의의 절대 커버리지 목표를 강제하지 않는다. 정확 - PR의 담당자는 올린 사람이 아니라 **어사인된 사람**이다. 어사인이 비어 있으면 [자동 어사인 workflow](.github/workflows/pr-assign-author.yml)가 작성자를 건다. 사람이 담당자를 바꾼 것은 되돌리지 않는다. - `develop`·`main` 머지에 리뷰 승인은 필요 없다. PR 경유, 필수 검사, `develop`의 merge queue는 그대로 요구된다. -- `develop` 대상 PR은 1hyok가 모든 파일을 Viewed로 체크하면 [Viewed 자동 머지 workflow](.github/workflows/viewed-auto-merge.yml)가 10분 안팎으로 자동 머지(Merge when ready)를 예약한다. Viewed 체크에는 이벤트가 없어 주기 실행으로 훑고, 본인 체크만 조회되므로 1hyok의 읽기 전용 토큰을 `VIEWED_TOKEN` 시크릿으로 둔다. 필수 검사가 통과해야 merge queue로 들어가며, 예약을 멈추려면 파일 하나의 Viewed를 풀고 PR 화면에서 예약을 끈다. +- `develop` 대상 PR은 1hyok가 모든 파일을 Viewed로 체크하면 [Viewed 자동 머지 workflow](.github/workflows/viewed-auto-merge.yml)가 10분 안팎으로 자동 머지(Merge when ready)를 예약한다. Viewed 체크에는 이벤트가 없어 주기 실행으로 훑고, 본인 체크만 조회되므로 1hyok의 읽기 전용 토큰을 `VIEWED_TOKEN` 시크릿으로 둔다. 필수 검사가 통과해야 merge queue로 들어간다. 이 workflow는 예약을 걸기만 하고 풀거나 큐에서 빼지 않으므로, Viewed가 덜 찬 PR도 직접 예약하거나 큐에 넣으면 그대로 머지된다. 예약을 멈추려면 PR 화면에서 끈다.