Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions .github/scripts/find-viewed-auto-merge-targets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }
`;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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`,
);
}
}

Expand Down
65 changes: 64 additions & 1 deletion .github/scripts/find-viewed-auto-merge-targets.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" });

Expand Down Expand Up @@ -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);
Expand Down
42 changes: 37 additions & 5 deletions .github/workflows/viewed-auto-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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 }}
Expand All @@ -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 }}
Expand Down
Loading