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
123 changes: 123 additions & 0 deletions .github/scripts/find-viewed-auto-merge-targets.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
}
64 changes: 64 additions & 0 deletions .github/scripts/find-viewed-auto-merge-targets.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
75 changes: 75 additions & 0 deletions .github/workflows/viewed-auto-merge.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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은 승인 상태에서 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에 다시 적용한다.
Expand Down
Loading