From 1f325b19cf530a4eab3a93ff762327a8197d45fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 27 Sep 2026 11:05:50 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(platform):=20=EC=8A=A4=ED=83=9D=20PR=20?= =?UTF-8?q?=EC=9D=98=20merge=20queue=20=EC=9E=AC=ED=88=AC=EC=9E=85?= =?UTF-8?q?=EC=9D=84=20REST=20merge-async=20=EB=A1=9C=20=EB=B3=B4=EB=82=B8?= =?UTF-8?q?=EB=8B=A4=20(#2177)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GraphQL enqueuePullRequest 는 스택 일원 PR 을 UNPROCESSABLE 로 거부한다(0919 #2053, 0926 #2123). live 조회에 stack { number } 를 얹고, 스택이면 PUT pulls/{n}/merge-async 에 merge_action=merge_queue, sha=판정한 head 로 넣는다. 비스택은 종전 경로 그대로다. 판정표와 마커 선기록 순서는 바꾸지 않았다. 스택 PR 실패 코멘트는 gh stack merge 를 안내한다. --- .../scripts/handle-merge-queue-dequeue.mjs | 53 ++++++++- .../handle-merge-queue-dequeue.test.mjs | 110 ++++++++++++++++-- 2 files changed, 150 insertions(+), 13 deletions(-) diff --git a/.github/scripts/handle-merge-queue-dequeue.mjs b/.github/scripts/handle-merge-queue-dequeue.mjs index 1ff578ce31..9be3a6a3bb 100644 --- a/.github/scripts/handle-merge-queue-dequeue.mjs +++ b/.github/scripts/handle-merge-queue-dequeue.mjs @@ -13,6 +13,10 @@ // 판정 순서는 «실패 job 이 있는가» 가 먼저다. payload 의 reason 문자열은 GitHub 이 열거값을 문서화하지 // 않아 정본으로 삼지 않는다 — 실패 job 은 데이터로 확인하고, reason 은 «사람이 뺐다·이미 머지됐다» 를 // 거르는 데만 쓴다. 그 밖의 reason 은 전부 조용한 방출로 본다. +// +// 재투입 수단은 PR 이 GitHub 네이티브 스택의 일원인지로 갈린다(#2177). 스택 PR 은 GraphQL enqueuePullRequest 가 +// UNPROCESSABLE("part of a stack and must be enqueued using the asynchronous merge REST API")로 거부되므로 +// (0919 #2053, 0926 #2123 실측) REST merge-async 에 merge_action=merge_queue 로 넣는다. 판정표는 같다. import { readFile } from "node:fs/promises"; import path from "node:path"; @@ -81,7 +85,15 @@ export function decide({ reason, failedJobs, requeueCount }) { const shortSha = (sha) => sha.slice(0, 7); -export function renderFailureComment({ reason, headSha, failedJobs, repository, number }) { +/** + * 스택 PR 은 gh pr merge 가 "Auto-merge is not supported for stacked pull requests" 로 거부하므로 gh stack merge 를 안내한다. + * gh stack 은 --repo 플래그가 없어(gh 2.85 실측) 저장소 체크아웃 안에서 번호로 부른다. + */ +export function manualEnqueueCommand({ repository, number, stack = false }) { + return stack ? `gh stack merge ${number} --yes` : `gh pr merge ${number} --repo ${repository}`; +} + +export function renderFailureComment({ reason, headSha, failedJobs, repository, number, stack = false }) { const lines = failedJobs.map((job) => `- [${job.runName} / ${job.jobName}](${job.jobUrl}) — ${job.conclusion}`); return [ "### merge queue 방출 — merge group CI 실패", @@ -91,7 +103,7 @@ export function renderFailureComment({ reason, headSha, failedJobs, repository, "실패한 job:", ...lines, "", - `고친 뒤 \`gh pr merge ${number} --repo ${repository}\` 로 다시 투입한다.`, + `고친 뒤 \`${manualEnqueueCommand({ repository, number, stack })}\` 로 다시 투입한다.`, ].join("\n"); } @@ -160,6 +172,7 @@ export async function fetchLivePullRequest(api, repository, number) { headRefOid baseRefName mergeQueueEntry { state position } + stack { number } timelineItems(last: 5, itemTypes: [ADDED_TO_MERGE_QUEUE_EVENT]) { nodes { ... on AddedToMergeQueueEvent { createdAt } } } @@ -171,6 +184,11 @@ export async function fetchLivePullRequest(api, repository, number) { return data.repository.pullRequest; } +/** GitHub 네이티브 스택의 일원인가. 비스택 PR 은 stack 이 null 로 온다(0926 #2176, #2171 실측). */ +export function isStackMember(pullRequest) { + return pullRequest?.stack != null; +} + /** 마지막으로 큐에 들어간 시각 — 그 뒤의 merge_group run 만 이번 방출의 근거다. */ export function lastEnqueuedAt(pullRequest) { const stamps = (pullRequest.timelineItems?.nodes ?? []).map((node) => node?.createdAt).filter(Boolean); @@ -219,6 +237,23 @@ export async function enqueue(api, pullRequestId) { return data.enqueuePullRequest.mergeQueueEntry; } +/** + * 스택 PR 의 큐 투입. GraphQL enqueuePullRequest 는 스택 PR 을 거부하므로 REST 비동기 머지에 + * merge_action=merge_queue 를 준다. GitHub 은 요청한 PR 과 스택에서 그 아래 PR 을 함께 큐에 넣는다. + * sha 는 판정한 head 로 고정한다. 그 사이 새 커밋이 올라왔으면 409 로 실패하고, 새 head 는 마커 0 이라 + * 다음 방출에서 다시 판정된다. 응답 status 가 failed 면 GraphQL 오류와 같이 예외로 올려 job 을 red 로 남긴다. + */ +export async function enqueueStacked(api, repository, number, headSha) { + const result = await api(`/repos/${repository}/pulls/${number}/merge-async`, { + method: "PUT", + body: { merge_action: "merge_queue", sha: headSha }, + }); + if (result?.status === "failed") { + throw new Error(`merge-async 실패: ${JSON.stringify(result)}`); + } + return result; +} + export async function handleDequeue({ api, repository, number, reason, dryRun = false, logger = console }) { const live = await fetchLivePullRequest(api, repository, number); if (!live) throw new Error(`#${number} 를 찾을 수 없습니다`); @@ -232,24 +267,30 @@ export async function handleDequeue({ api, repository, number, reason, dryRun = } const headSha = live.headRefOid; + const stack = isStackMember(live); const since = lastEnqueuedAt(live); const failedJobs = await fetchQueueFailedJobs(api, repository, { baseRef: live.baseRefName, number, since }); const comments = await fetchComments(api, repository, number); const requeueCount = countRequeuesForHead(comments, headSha); const action = decide({ reason, failedJobs, requeueCount }); - logger.log(`#${number} reason=${reason} head=${shortSha(headSha)} 마지막투입=${since ?? "?"} 실패job=${failedJobs.length} 재투입횟수=${requeueCount} → ${action}`); + logger.log(`#${number} reason=${reason} head=${shortSha(headSha)} 스택=${stack ? live.stack.number : "없음"} 마지막투입=${since ?? "?"} 실패job=${failedJobs.length} 재투입횟수=${requeueCount} → ${action}`); if (dryRun) return { action, dryRun: true, failedJobs, requeueCount }; if (action === "comment-failure") { - await postComment(api, repository, number, renderFailureComment({ reason, headSha, failedJobs, repository, number })); + await postComment(api, repository, number, renderFailureComment({ reason, headSha, failedJobs, repository, number, stack })); } else if (action === "comment-give-up") { await postComment(api, repository, number, renderGiveUpComment({ reason, headSha })); } else if (action === "requeue") { // 마커를 먼저 남긴다 — 재투입 뒤 코멘트가 실패하면 다음 방출에서 두 번째 재투입이 나간다. await postComment(api, repository, number, renderRequeueComment({ reason, headSha })); - const entry = await enqueue(api, live.id); - logger.log(`#${number} 재투입 → ${entry?.state ?? "?"} position=${entry?.position ?? "?"}`); + if (stack) { + const result = await enqueueStacked(api, repository, number, headSha); + logger.log(`#${number} 스택 ${live.stack.number} 재투입(merge-async) → ${result?.status ?? "?"} ${result?.details?.message ?? ""}`.trimEnd()); + } else { + const entry = await enqueue(api, live.id); + logger.log(`#${number} 재투입 → ${entry?.state ?? "?"} position=${entry?.position ?? "?"}`); + } } return { action, failedJobs, requeueCount }; } diff --git a/.github/scripts/handle-merge-queue-dequeue.test.mjs b/.github/scripts/handle-merge-queue-dequeue.test.mjs index f09f58d9a2..8b6743a355 100644 --- a/.github/scripts/handle-merge-queue-dequeue.test.mjs +++ b/.github/scripts/handle-merge-queue-dequeue.test.mjs @@ -7,8 +7,11 @@ import { collectFailedJobs, countRequeuesForHead, decide, + enqueueStacked, handleDequeue, + isStackMember, lastEnqueuedAt, + manualEnqueueCommand, queueBranchPrefix, renderFailureComment, renderRequeueComment, @@ -109,9 +112,32 @@ test("실패 코멘트는 job 링크와 재투입 명령을 담고, 재투입했 assert.ok(!body.includes(requeueMarker(HEAD))); }); +test("스택 PR 의 실패 코멘트는 gh pr merge 가 아니라 gh stack merge 를 안내한다 — gh pr merge 는 스택 PR 을 거부한다", () => { + assert.equal(manualEnqueueCommand({ repository: REPO, number: 2123 }), "gh pr merge 2123 --repo Afternote/Afternote-FE"); + assert.equal(manualEnqueueCommand({ repository: REPO, number: 2123, stack: true }), "gh stack merge 2123 --yes", "gh stack 에는 --repo 가 없다"); + const body = renderFailureComment({ + reason: "CI_FAILURE", + headSha: HEAD, + repository: REPO, + number: 2123, + stack: true, + failedJobs: [{ runName: "Unit Test", jobName: "Run Unit Tests", jobUrl: "https://x/jobs/11", conclusion: "failure" }], + }); + assert.match(body, /gh stack merge 2123 --yes/); + assert.ok(!body.includes("gh pr merge")); +}); + +// ---- 스택 판정 (#2177) ---- + +test("stack 이 null·없음이면 비스택, 객체면 스택이다", () => { + assert.equal(isStackMember({ stack: null }), false); + assert.equal(isStackMember({}), false); + assert.equal(isStackMember({ stack: { number: 2125 } }), true); +}); + // ---- handleDequeue 흐름: live 상태가 정본, 쓰기 순서 ---- -function fakeApi({ live, runs = [], jobs = {}, comments = [] }) { +function fakeApi({ live, runs = [], jobs = {}, comments = [], mergeAsync = { status: "enqueued", details: { message: "Pull request added to merge queue" } } }) { const calls = []; const api = async (apiPath, options = {}) => { calls.push({ apiPath, method: options.method ?? "GET", body: options.body }); @@ -122,6 +148,7 @@ function fakeApi({ live, runs = [], jobs = {}, comments = [] }) { } return { data: { repository: { pullRequest: live } } }; } + if (/\/pulls\/\d+\/merge-async$/.test(apiPath) && options.method === "PUT") return mergeAsync; if (apiPath.startsWith(`/repos/${REPO}/actions/runs?`)) return { workflow_runs: runs }; const jobsMatch = /\/actions\/runs\/(\d+)\/jobs/.exec(apiPath); if (jobsMatch) return { jobs: jobs[jobsMatch[1]] ?? [] }; @@ -133,15 +160,23 @@ function fakeApi({ live, runs = [], jobs = {}, comments = [] }) { return api; } +const enqueueMutations = (api) => api.calls.filter((call) => call.body?.query?.includes("enqueuePullRequest")); +const mergeAsyncCalls = (api) => api.calls.filter((call) => call.apiPath.endsWith("/merge-async")); +const commentPosts = (api) => api.calls.filter((call) => call.method === "POST" && call.apiPath.endsWith("/comments")); + const openLive = { id: "PR_1", state: "OPEN", headRefOid: HEAD, baseRefName: "develop", mergeQueueEntry: null, + stack: null, timelineItems: { nodes: [{ createdAt: "2026-09-04T08:00:00Z" }] }, }; +// 0926 #2123: base 는 develop 이지만 위에 #2124 가 얹혀 스택 2125 의 밑단이다. +const stackedLive = { ...openLive, stack: { number: 2125 } }; + test("MERGED·CLOSED 거나 이미 큐에 있으면 payload 와 무관하게 아무것도 쓰지 않는다", async () => { for (const live of [ { ...openLive, state: "MERGED" }, @@ -165,6 +200,64 @@ test("조용한 방출: 마커 코멘트를 먼저 남기고 그 다음 enqueueP assert.match(writes[0].apiPath, /\/issues\/1509\/comments$/); assert.ok(writes[0].body.body.startsWith(requeueMarker(HEAD))); assert.equal(writes[1].apiPath, "/graphql"); + assert.equal(mergeAsyncCalls(api).length, 0, "비스택 PR 은 종전대로 GraphQL 로만 넣는다"); +}); + +// ---- 스택 PR 의 재투입은 REST merge-async (#2177) ---- + +test("live 조회는 stack 필드를 함께 읽는다 — 재투입 수단을 고르는 근거다", async () => { + const api = fakeApi({ live: openLive }); + await handleDequeue({ api, repository: REPO, number: 1509, reason: "UNKNOWN_REMOVAL_REASON", dryRun: true, logger: silent }); + const liveQuery = api.calls.find((call) => call.apiPath === "/graphql").body.query; + assert.match(liveQuery, /stack\s*\{\s*number\s*\}/); +}); + +test("스택 PR 의 조용한 방출: 마커 코멘트 뒤 merge-async(merge_action=merge_queue, sha=head) 를 쏘고 enqueuePullRequest 는 쏘지 않는다", async () => { + const api = fakeApi({ live: stackedLive }); + const result = await handleDequeue({ api, repository: REPO, number: 2123, reason: "MERGE_CONFLICT", logger: silent }); + assert.equal(result.action, "requeue"); + assert.equal(enqueueMutations(api).length, 0, "GraphQL enqueuePullRequest 는 스택 PR 을 UNPROCESSABLE 로 거부한다"); + const writes = api.calls.filter((call) => call.method !== "GET" && call.apiPath !== "/graphql"); + assert.equal(writes.length, 2); + assert.match(writes[0].apiPath, /\/issues\/2123\/comments$/); + assert.ok(writes[0].body.body.startsWith(requeueMarker(HEAD)), "마커가 먼저다 — 재투입 뒤 코멘트가 실패하면 두 번째 재투입이 나간다"); + assert.deepEqual( + { apiPath: writes[1].apiPath, method: writes[1].method, body: writes[1].body }, + { apiPath: `/repos/${REPO}/pulls/2123/merge-async`, method: "PUT", body: { merge_action: "merge_queue", sha: HEAD } }, + ); +}); + +test("스택 여부는 판정표를 바꾸지 않는다 — 실패 job 이면 comment-failure, 같은 head 두 번째면 comment-give-up, 어느 쪽도 재투입 없음", async () => { + const runs = [{ id: 7, name: "Unit Test", html_url: "https://x/runs/7", head_branch: queueBranchPrefix("develop", 2123) + "abc", created_at: "2026-09-04T08:05:00Z" }]; + const jobs = { 7: [{ name: "Run Unit Tests", conclusion: "failure", html_url: "https://x/jobs/71" }] }; + const failed = fakeApi({ live: stackedLive, runs, jobs }); + assert.equal((await handleDequeue({ api: failed, repository: REPO, number: 2123, reason: "CI_FAILURE", logger: silent })).action, "comment-failure"); + assert.match(commentPosts(failed)[0].body.body, /gh stack merge 2123 --yes/); + + const second = fakeApi({ live: stackedLive, comments: [{ body: renderRequeueComment({ reason: "MERGE_CONFLICT", headSha: HEAD }) }] }); + assert.equal((await handleDequeue({ api: second, repository: REPO, number: 2123, reason: "MERGE_CONFLICT", logger: silent })).action, "comment-give-up"); + + const manual = fakeApi({ live: stackedLive }); + assert.equal((await handleDequeue({ api: manual, repository: REPO, number: 2123, reason: "MANUAL", logger: silent })).action, "none"); + + for (const api of [failed, second, manual]) { + assert.equal(mergeAsyncCalls(api).length, 0); + assert.equal(enqueueMutations(api).length, 0); + } + assert.equal(commentPosts(failed).length, 1); + assert.equal(commentPosts(second).length, 1); + assert.equal(commentPosts(manual).length, 0); +}); + +test("merge-async 응답 status 가 failed 면 GraphQL 오류처럼 예외로 올린다 — job 이 red 로 남아 사람이 본다", async () => { + const api = fakeApi({ live: stackedLive, mergeAsync: { status: "failed", details: { message: "Pull request is not mergeable" } } }); + await assert.rejects( + handleDequeue({ api, repository: REPO, number: 2123, reason: "MERGE_CONFLICT", logger: silent }), + /merge-async 실패: .*not mergeable/, + ); + assert.equal(mergeAsyncCalls(api).length, 1); + await assert.rejects(enqueueStacked(api, REPO, 2123, HEAD), /merge-async 실패/); + assert.equal(mergeAsyncCalls(api).length, 2); }); test("같은 head 의 두 번째 방출은 코멘트만 남기고 enqueuePullRequest 를 쏘지 않는다", async () => { @@ -186,12 +279,15 @@ test("CI 실패: 실패 job 링크 코멘트만, 재투입 없음", async () => assert.ok(!api.calls.some((call) => call.body?.query?.includes("enqueuePullRequest"))); }); -test("dry-run 은 판정만 하고 아무것도 쓰지 않는다", async () => { - const api = fakeApi({ live: openLive }); - const result = await handleDequeue({ api, repository: REPO, number: 1509, reason: "ROLL_BACK", dryRun: true, logger: silent }); - assert.equal(result.action, "requeue"); - assert.equal(api.calls.filter((call) => call.method === "POST" && call.apiPath !== "/graphql").length, 0); - assert.ok(!api.calls.some((call) => call.body?.query?.includes("enqueuePullRequest"))); +test("dry-run 은 판정만 하고 아무것도 쓰지 않는다 — 스택 PR 도 같다", async () => { + for (const live of [openLive, stackedLive]) { + const api = fakeApi({ live }); + const result = await handleDequeue({ api, repository: REPO, number: 1509, reason: "ROLL_BACK", dryRun: true, logger: silent }); + assert.equal(result.action, "requeue"); + assert.equal(api.calls.filter((call) => call.method !== "GET" && call.apiPath !== "/graphql").length, 0); + assert.equal(enqueueMutations(api).length, 0); + assert.equal(mergeAsyncCalls(api).length, 0); + } }); // ---- 워크플로 정책 ---- From d8b6d1db7d180e73d098496667efec02549953fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 27 Sep 2026 11:14:42 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(platform):=20merge-async=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EB=A5=BC=20=ED=8F=B4=EB=A7=81=ED=95=B4=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=ED=95=98=EA=B3=A0=20=EC=8A=A4=ED=83=9D=20=EC=9C=84?= =?UTF-8?q?=EC=AA=BD=20PR=20=EC=9D=80=20=EC=9E=90=EB=8F=99=20=EC=9E=AC?= =?UTF-8?q?=ED=88=AC=EC=9E=85=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94?= =?UTF-8?q?=EB=8B=A4=20(#2177)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit merge-async 는 202 pending 과 uuid 를 돌려주고 결과는 GET merge-async/{uuid} 로만 보인다. 5초 간격 최대 24회 폴링해 enqueued·merged 면 성공, failed 면 예외로 job 을 red 로 남긴다. 끝까지 pending 이면 live 큐 상태를 다시 읽어 없으면 예외다. merge-async 는 downstack 의 열린 PR 까지 함께 넣으므로, 스택 위쪽 PR 의 재투입은 아래 PR 의 판정(실패 job 이면 재투입 안 함)을 덮는다. PR base 가 스택 base 와 같은 밑단만 재투입하고, 위쪽은 마커 없는 안내 코멘트만 남긴다. decide() 판정표는 그대로다. give-up 코멘트에 스택 여부에 맞는 수동 투입 명령을 붙이고, 결과와 스텝 요약에 스택 번호와 재투입 수단을 남긴다. --- .../scripts/handle-merge-queue-dequeue.mjs | 96 ++++-- .../handle-merge-queue-dequeue.test.mjs | 317 ++++++++++++++++-- 2 files changed, 358 insertions(+), 55 deletions(-) diff --git a/.github/scripts/handle-merge-queue-dequeue.mjs b/.github/scripts/handle-merge-queue-dequeue.mjs index 9be3a6a3bb..b1498e601d 100644 --- a/.github/scripts/handle-merge-queue-dequeue.mjs +++ b/.github/scripts/handle-merge-queue-dequeue.mjs @@ -17,6 +17,9 @@ // 재투입 수단은 PR 이 GitHub 네이티브 스택의 일원인지로 갈린다(#2177). 스택 PR 은 GraphQL enqueuePullRequest 가 // UNPROCESSABLE("part of a stack and must be enqueued using the asynchronous merge REST API")로 거부되므로 // (0919 #2053, 0926 #2123 실측) REST merge-async 에 merge_action=merge_queue 로 넣는다. 판정표는 같다. +// merge-async 는 202 pending 으로 받으므로 결과(enqueued·merged·failed)를 폴링해 확인하고, failed·미확정은 job 을 +// red 로 남긴다. 스택 위쪽 PR 은 자동 재투입하지 않는다 — merge-async 가 downstack 의 열린 PR 까지 함께 넣어 +// 아래 PR 의 판정(실패 job 이면 재투입 안 함)을 덮기 때문이다. 위쪽 PR 에는 마커 없는 안내 코멘트만 남긴다. import { readFile } from "node:fs/promises"; import path from "node:path"; @@ -28,6 +31,7 @@ export const REQUEUE_MARKER_PREFIX = "