diff --git a/.github/scripts/find-viewed-auto-merge-targets.mjs b/.github/scripts/find-viewed-auto-merge-targets.mjs index f527e9225..e605e60c4 100644 --- a/.github/scripts/find-viewed-auto-merge-targets.mjs +++ b/.github/scripts/find-viewed-auto-merge-targets.mjs @@ -7,11 +7,15 @@ // viewerViewedState 는 토큰 주인 본인의 체크만 돌려준다. 그래서 VIEWED_BY 본인의 토큰(VIEWED_TOKEN)으로 // 읽고, 토큰 주인이 VIEWED_BY 와 다르면 판정하지 않는다. 쓰기(예약)는 워크플로의 GITHUB_TOKEN 이 한다. // 체크한 뒤 바뀐 파일은 GitHub 가 DISMISSED 로 되돌리므로 새 커밋은 다시 봐야 대상이 된다. +// +// 예약을 건 뒤 새 push 로 Viewed 가 풀려도 GitHub 는 예약·큐 등록을 그대로 두고, develop 룰셋은 push 로 승인을 +// 지우지 않는다(dismiss_stale_reviews_on_push false). 그래서 예약됐거나 큐에 있는 PR 중 Viewed 가 다 차지 않은 +// 것은 해제 대상으로 고른다(#2252). 누가 걸었는지는 따지지 않는다. 다시 전부 Viewed 가 되면 다음 실행이 다시 건다. import { appendFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; const PULL_REQUEST_FIELDS = ` - number isDraft headRefOid reviewDecision isInMergeQueue changedFiles + id number isDraft headRefOid reviewDecision isInMergeQueue changedFiles autoMergeRequest { enabledAt } files(first: 100) { pageInfo { hasNextPage endCursor } nodes { path viewerViewedState } } `; @@ -40,6 +44,30 @@ 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", @@ -99,14 +127,29 @@ async function main() { } const targets = []; + const cancels = []; for (const pullRequest of data.repository.pullRequests.nodes) { 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 }); } if (process.env.GITHUB_OUTPUT) { - appendFileSync(process.env.GITHUB_OUTPUT, `targets=${JSON.stringify(targets)}\n`); + appendFileSync( + process.env.GITHUB_OUTPUT, + `targets=${JSON.stringify(targets)}\ncancels=${JSON.stringify(cancels)}\n`, + ); } } diff --git a/.github/scripts/find-viewed-auto-merge-targets.test.mjs b/.github/scripts/find-viewed-auto-merge-targets.test.mjs index 1ca002167..c4e0fd663 100644 --- a/.github/scripts/find-viewed-auto-merge-targets.test.mjs +++ b/.github/scripts/find-viewed-auto-merge-targets.test.mjs @@ -2,7 +2,7 @@ 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"; +import { decideViewedAutoMerge, decideViewedAutoMergeCancel } from "./find-viewed-auto-merge-targets.mjs"; const viewed = (path) => ({ path, viewerViewedState: "VIEWED" }); @@ -49,6 +49,69 @@ test("Draft·이미 예약·큐에 있는 PR 은 건드리지 않는다", () => assert.equal(decideViewedAutoMerge(pullRequest({ isInMergeQueue: true }), files).enable, false); }); +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: "REVIEW_REQUIRED" }, { 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("워크플로는 해제 대상의 예약을 끄고 큐에서 빼며, 해제 실패는 red 로 남긴다", 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")); +}); + 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); diff --git a/.github/workflows/viewed-auto-merge.yml b/.github/workflows/viewed-auto-merge.yml index 362d0d947..dfed7bcbb 100644 --- a/.github/workflows/viewed-auto-merge.yml +++ b/.github/workflows/viewed-auto-merge.yml @@ -2,8 +2,12 @@ # # VIEWED_BY 가 PR 의 모든 파일을 Viewed 로 체크했고 PR 이 승인 상태면 «Merge when ready» 를 대신 건다. # 머지는 GitHub 가 승인 수·required check·merge queue 를 모두 통과시킨 뒤에만 하므로 CI 가 빨갛거나 -# 변경요청이 남으면 머지되지 않는다. 예약을 멈추려면 파일 하나의 Viewed 를 풀면 된다(다음 실행이 다시 -# 걸지 않는다). 이미 걸린 예약은 PR 화면에서 끈다. +# 변경요청이 남으면 머지되지 않는다. +# +# 반대 방향도 매 실행이 맞춘다(#2252). 예약됐거나 merge queue 에 있는 PR 중 VIEWED_BY 기준 Viewed 가 다 차지 +# 않은 것은 예약을 끄고 큐에서 뺀다. 새 push 로 바뀐 파일은 DISMISSED 가 되고 룰셋은 push 로 승인을 지우지 +# 않으므로, 이 단계가 없으면 아무도 다시 보지 않은 커밋이 머지된다. 누가 건 예약인지는 따지지 않는다. 예약을 +# 멈추려면 파일 하나의 Viewed 를 풀면 되고, 다시 전부 Viewed 가 되면 다음 실행이 다시 건다. # # Viewed 체크는 webhook 이벤트가 없어 주기 실행으로 훑는다. 승인 직후에도 리컨사일 완료 신호로 한 번 # 돈다. viewerViewedState 는 토큰 주인 본인 것만 보이므로 VIEWED_BY 의 읽기 전용 토큰을 VIEWED_TOKEN @@ -26,7 +30,7 @@ permissions: {} jobs: enable: - name: Enable auto-merge for fully viewed PRs + name: Sync auto-merge with Viewed state runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -40,7 +44,7 @@ jobs: persist-credentials: false sparse-checkout: .github/scripts - - name: Find fully viewed approved PRs + - name: Find PRs to enable or cancel auto-merge id: find env: VIEWED_TOKEN: ${{ secrets.VIEWED_TOKEN }} @@ -52,12 +56,40 @@ 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 - if: steps.find.outputs.targets != '[]' + # 해제 단계가 red 여도 다른 PR 의 예약은 그대로 판정한다. + if: ${{ !cancelled() && steps.find.outcome == 'success' && steps.find.outputs.targets != '[]' }} env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }}