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
57 changes: 10 additions & 47 deletions .github/scripts/find-viewed-auto-merge-targets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 노드
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -117,35 +92,23 @@ async function readAllFiles(request, owner, name, pullRequest) {
}

/**
* 열린 PR 마다 예약·해제를 판정한다. VIEWED_BY 본인 PR 이 아니면 파일도 읽지 않고 건너뛴다.
* 열린 PR 마다 예약을 판정한다. VIEWED_BY 본인 PR 이 아니면 파일도 읽지 않고 건너뛴다.
*
* @returns {Promise<{ targets: Array<object>, cancels: Array<object> }>}
* @returns {Promise<{ targets: Array<object> }>}
*/
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() {
Expand All @@ -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`,
);
}
}
Expand Down
80 changes: 10 additions & 70 deletions .github/scripts/find-viewed-auto-merge-targets.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import test from "node:test";
import {
collectViewedAutoMergeActions,
decideViewedAutoMerge,
decideViewedAutoMergeCancel,
isAuthoredByViewer,
} from "./find-viewed-auto-merge-targets.mjs";

Expand Down Expand Up @@ -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);
Expand All @@ -130,31 +79,26 @@ 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"]),
listedPullRequest(12, "1hyok"),
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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 \}\}/);
Expand Down
47 changes: 8 additions & 39 deletions .github/workflows/viewed-auto-merge.yml
Original file line number Diff line number Diff line change
@@ -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
# 시크릿으로 둔다. 시크릿이 없으면 아무것도 하지 않는다.
Expand All @@ -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
Expand All @@ -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 }}
Expand All @@ -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 }}
Expand Down
Loading
Loading