From 80946ecfe617e5248fc23150b3d878ee363a05c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 24 Aug 2026 20:00:28 +0900 Subject: [PATCH] =?UTF-8?q?ci:=20conflict=20=EB=9D=BC=EB=B2=A8=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9D=98=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EA=B1=B0=EB=A5=BC=20=EC=97=B0=EB=8B=A4=20(cl?= =?UTF-8?q?oses=201028)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수동 실행으로 두 가지를 확인했다. dry_run 이 실제 러너에서 충돌 10건을 정확히 잡았고 (로컬 결과와 한 건도 다르지 않다), 실제 부착 뒤 같은 조건으로 재실행하니 부착·제거 0건이라 중복 방지도 실측됐다. 라벨과 안내 코멘트는 10개 PR 에 각 1개씩 붙었다. 그래서 develop push 직후와 매시 주기로 자동으로 돌게 한다. schedule 은 보조 경로다 — push 로 대부분 잡히지만 mergeable 이 끝내 UNKNOWN 이라 보류된 PR 은 다음 주기에 확정된다. 테스트가 CI 로그에 «#40 라벨 부착» 을 찍어 실제 조작과 섞이던 것도 함께 고친다. applyPlan 이 logger 를 받고, 테스트는 no-op 을 주입한다. 주입 지점이 사라지지 않게 회귀 테스트도 하나 둔다. --- .github/scripts/label-conflicting-prs.mjs | 12 ++++---- .../scripts/label-conflicting-prs.test.mjs | 29 +++++++++++++++---- .github/workflows/conflict-label.yml | 22 +++++--------- 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/.github/scripts/label-conflicting-prs.mjs b/.github/scripts/label-conflicting-prs.mjs index 127a241a88..dc6c5d4aee 100644 --- a/.github/scripts/label-conflicting-prs.mjs +++ b/.github/scripts/label-conflicting-prs.mjs @@ -219,13 +219,15 @@ async function hasConflictComment(api, repository, number) { return (comments ?? []).some((comment) => (comment.body ?? "").includes(COMMENT_MARKER)); } -export async function applyPlan(api, repository, plan, { label, dryRun }) { +// `logger` 를 받는 이유: 테스트가 실제 동작처럼 보이는 줄을 CI 로그에 찍지 않게 하기 위해서다. +// 워크플로의 «Run script tests» 단계에 «#40 라벨 부착» 이 섞이면 무엇이 실제 조작인지 흐려진다. +export async function applyPlan(api, repository, plan, { label, dryRun, logger = console }) { const failures = []; for (const pullRequest of plan.toLabel) { try { if (dryRun) { - console.log(`[dry-run] #${pullRequest.number} 라벨 부착 + 코멘트`); + logger.log(`[dry-run] #${pullRequest.number} 라벨 부착 + 코멘트`); continue; } await api(`/repos/${repository}/issues/${pullRequest.number}/labels`, { @@ -239,7 +241,7 @@ export async function applyPlan(api, repository, plan, { label, dryRun }) { body: { body: renderConflictComment(pullRequest) }, }); } - console.log(`#${pullRequest.number} 라벨 부착 (base ${pullRequest.baseRefName})`); + logger.log(`#${pullRequest.number} 라벨 부착 (base ${pullRequest.baseRefName})`); } catch (error) { failures.push(`#${pullRequest.number} 라벨 부착 실패: ${error.message}`); } @@ -248,14 +250,14 @@ export async function applyPlan(api, repository, plan, { label, dryRun }) { for (const pullRequest of plan.toUnlabel) { try { if (dryRun) { - console.log(`[dry-run] #${pullRequest.number} 라벨 제거`); + logger.log(`[dry-run] #${pullRequest.number} 라벨 제거`); continue; } await api(`/repos/${repository}/issues/${pullRequest.number}/labels/${encodeURIComponent(label)}`, { method: "DELETE", allowNotFound: true, }); - console.log(`#${pullRequest.number} 라벨 제거`); + logger.log(`#${pullRequest.number} 라벨 제거`); } catch (error) { failures.push(`#${pullRequest.number} 라벨 제거 실패: ${error.message}`); } diff --git a/.github/scripts/label-conflicting-prs.test.mjs b/.github/scripts/label-conflicting-prs.test.mjs index 9537fc263b..dc301df965 100644 --- a/.github/scripts/label-conflicting-prs.test.mjs +++ b/.github/scripts/label-conflicting-prs.test.mjs @@ -15,6 +15,9 @@ import { /** * 호출을 기록하는 가짜 API. `responses` 로 특정 경로의 응답을 지정한다. */ +/** 테스트 출력이 CI 로그에서 실제 조작처럼 보이지 않도록 삼킨다. */ +const silent = { log() {} }; + function fakeApi({ responses = {}, failOn = null } = {}) { const calls = []; const api = async (apiPath, options = {}) => { @@ -195,7 +198,7 @@ test("라벨을 붙이고 안내 코멘트를 한 번 남긴다", async () => { api, "o/r", { toLabel: [{ number: 40, baseRefName: "develop" }], toUnlabel: [] }, - { label: DEFAULT_LABEL, dryRun: false }, + { label: DEFAULT_LABEL, dryRun: false, logger: silent }, ); assert.deepEqual(failures, []); @@ -217,7 +220,7 @@ test("마커 코멘트가 이미 있으면 다시 달지 않는다", async () => api, "o/r", { toLabel: [{ number: 41, baseRefName: "develop" }], toUnlabel: [] }, - { label: DEFAULT_LABEL, dryRun: false }, + { label: DEFAULT_LABEL, dryRun: false, logger: silent }, ); const posts = api.calls.filter((call) => call.method === "POST").map((call) => call.apiPath); @@ -231,7 +234,7 @@ test("해소된 PR 에서 라벨만 지운다", async () => { api, "o/r", { toLabel: [], toUnlabel: [{ number: 42 }] }, - { label: DEFAULT_LABEL, dryRun: false }, + { label: DEFAULT_LABEL, dryRun: false, logger: silent }, ); assert.deepEqual(api.calls, [ @@ -246,7 +249,7 @@ test("dry run 은 아무것도 쓰지 않는다", async () => { api, "o/r", { toLabel: [{ number: 43, baseRefName: "develop" }], toUnlabel: [{ number: 44 }] }, - { label: DEFAULT_LABEL, dryRun: true }, + { label: DEFAULT_LABEL, dryRun: true, logger: silent }, ); assert.deepEqual(api.calls, []); @@ -268,7 +271,7 @@ test("한 PR 이 실패해도 나머지를 처리하고 실패를 모아 돌려 ], toUnlabel: [], }, - { label: DEFAULT_LABEL, dryRun: false }, + { label: DEFAULT_LABEL, dryRun: false, logger: silent }, ); assert.equal(failures.length, 1); @@ -288,6 +291,22 @@ test("라벨이 없으면 만들고, 있으면 만들지 않는다", async () => assert.equal(existing.calls.filter((call) => call.method === "POST").length, 0); }); +test("진행 상황은 주입한 로거로 나간다", async () => { + // 기본값이 console 이면 테스트 출력이 CI 로그에 «#60 라벨 부착» 으로 섞여 + // 실제 조작과 구분되지 않는다. 주입 지점이 사라지지 않게 고정한다. + const lines = []; + const api = fakeApi({ responses: { "/repos/o/r/issues/60/comments": [] } }); + + await applyPlan( + api, + "o/r", + { toLabel: [{ number: 60, baseRefName: "develop" }], toUnlabel: [] }, + { label: DEFAULT_LABEL, dryRun: false, logger: { log: (line) => lines.push(line) } }, + ); + + assert.deepEqual(lines, ["#60 라벨 부착 (base develop)"]); +}); + test("요약에 건수와 PR 번호가 남는다", () => { const plan = planLabelChanges({ pullRequests: [ diff --git a/.github/workflows/conflict-label.yml b/.github/workflows/conflict-label.yml index 710b5802e7..b6f13c0774 100644 --- a/.github/workflows/conflict-label.yml +++ b/.github/workflows/conflict-label.yml @@ -2,26 +2,20 @@ name: Conflict Label # 충돌 PR 은 `pull_request` 워크플로가 트리거되지 않는다 (#1028) — GitHub 이 base 와 head 를 # 합친 `refs/pull//merge` 를 만들지 못하기 때문이다. 그래서 이 워크플로는 `pull_request` 를 -# 쓰지 않는다. -# -# **지금은 수동 실행 전용이다.** 도입 시점에 이미 충돌 PR 이 10건이라, 자동 트리거를 켜면 첫 -# 실행에서 라벨과 안내 코멘트가 한꺼번에 쏟아진다. 먼저 `dry_run` 으로 실제 러너 환경에서 -# 판정을 확인하고, 결과를 보고 아래 두 트리거를 여는 후속 PR 을 낸다. -# -# ```yaml -# push: -# branches: [develop] # 충돌을 만들어 내는 쪽 — develop 이 움직인 직후 -# schedule: -# - cron: '23 * * * *' # 보조. 정각 부하를 피해 매시 23분 -# ``` +# 쓰지 않는다. 충돌을 만들어 내는 쪽(develop 이 움직이는 순간)과 주기 실행으로 상태를 따라간다. on: + push: + branches: [develop] + schedule: + # 보조 경로. develop push 로 대부분 잡히지만, mergeable 이 끝내 UNKNOWN 이라 보류된 PR 은 + # 다음 주기에 확정된다. 정각 부하를 피해 매시 23분. + - cron: '23 * * * *' workflow_dispatch: inputs: dry_run: description: "라벨·코멘트를 쓰지 않고 계획만 출력" required: false - # 자동 트리거가 없는 동안의 기본값은 안전한 쪽이다. 실제로 쓰려면 끄고 실행한다. - default: true + default: false type: boolean # 실행이 겹치면 같은 PR 에 코멘트가 두 번 달릴 수 있다. 취소하지 않고 줄을 세운다.