From bcbc61e1fbdb227c536e382a7a0e0bce4669861c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Wed, 2 Sep 2026 21:49:44 +0900 Subject: [PATCH] =?UTF-8?q?ci:=20=EC=97=B4=EB=A6=B0=20PR=20=EB=81=BC?= =?UTF-8?q?=EB=A6=AC=20=EA=B2=B9=EC=B9=98=EB=8A=94=20=EC=B6=A9=EB=8F=8C?= =?UTF-8?q?=EC=9D=84=20sibling-conflict=20=EB=9D=BC=EB=B2=A8=EB=A1=9C=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=20=EC=95=8C=EB=A6=B0=EB=8B=A4=20(#1750)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit conflict 라벨은 PR 과 base 의 충돌만 본다. 열린 PR 둘이 같은 파일을 서로 다르게 고쳐도 한쪽이 머지되기 전까지는 어디서도 신호가 없었다. conflict-label 리컨사일러에 전체 히스토리 checkout 으로 도는 job 을 붙여, 열린 PR 쌍의 두 head 를 merge-tree 로 합쳐 두 PR 의 자기 커밋이 모두 고친 파일에서 난 충돌만 잡는다. 스택 PR 은 부모 체인이 이미 가진 충돌을 빼 가장 아래 PR 한 곳에 귀속시킨다. 양쪽 PR 에 라벨과 상대·파일을 적은 코멘트를 남기고, 코멘트는 PR 당 하나를 매 실행 현재 상태로 고쳐 쓴다. 차단이 아니라 경고다. 머지 시점 차단은 GitHub 이 이미 한다. --- .github/scripts/label-sibling-conflicts.mjs | 571 +++++++++++++++++ .../scripts/label-sibling-conflicts.test.mjs | 585 ++++++++++++++++++ .github/workflows/conflict-label.yml | 34 + 3 files changed, 1190 insertions(+) create mode 100644 .github/scripts/label-sibling-conflicts.mjs create mode 100644 .github/scripts/label-sibling-conflicts.test.mjs diff --git a/.github/scripts/label-sibling-conflicts.mjs b/.github/scripts/label-sibling-conflicts.mjs new file mode 100644 index 0000000000..73dce2ac82 --- /dev/null +++ b/.github/scripts/label-sibling-conflicts.mjs @@ -0,0 +1,571 @@ +#!/usr/bin/env node + +// 열린 PR 끼리 같은 파일을 서로 다르게 고친 «형제 충돌» 에 라벨을 붙인다 (#1750). +// +// `conflict` 라벨(#1028)은 PR 과 base 의 충돌만 본다. 열린 PR 둘이 같은 파일을 서로 다르게 +// 고쳐도 한쪽이 base 에 들어가기 전까지는 어디서도 신호가 없고, 들어간 뒤에야 나머지 쪽이 +// CONFLICTING 이 된다. merge-order-guard 는 이슈 blocked_by 만 보고, 스택은 의존 관계용이라 +// 독립 이슈 둘을 묶을 자리가 아니다. 머지 자체는 GitHub 이 막으므로(나중 쪽이 merge queue 에 +// 못 들어간다) 비어 있는 것은 «머지 전에 미리 아는 것» 뿐이다. +// +// 이 스크립트는 그 알림이다 — 차단이 아니다. 둘 다 옳은 PR 이라 어느 쪽을 red 로 만들지 정할 +// 근거가 없고, 작성자가 손쓸 수 없는 red 는 #1059 가 걷어낸 상태다. +// +// 판정. 두 head 를 `git merge-tree --write-tree` 로 합쳐 충돌 파일을 구하고, 그중 **두 PR 의 +// 자기 커밋이 모두 고친 파일** 만 남긴다. 한쪽만 고친 파일에서 난 충돌은 상대가 품고 있는 trunk +// 커밋과의 충돌이라 `conflict` 라벨 몫이다. 스택 PR 의 «자기 커밋» 은 부모 PR head 와의 공통 +// 조상부터의 diff 이고, 부모 체인이 이미 같은 상대와 부딪히는 파일은 자식에서 뺀다 — 충돌은 체인의 +// 가장 아래 PR 한 곳에 귀속되고, 그 PR 이 풀면 다음 실행이 자식을 다시 판정한다. 같은 체인 +// (부모·자식)은 비교하지 않는다 — 그쪽은 base 축이다. +// +// 한계. 파일 이름이 겹치지 않는 충돌(한쪽이 디렉터리를 옮기고 다른 쪽이 옛 자리에 파일을 더한 +// «file location» 충돌)은 잡지 않는다. 내용 충돌만 본다. +// +// 이벤트가 아니라 리컨사일러인 이유는 다른 라벨 스크립트와 같다. 매 실행이 «지금 충돌하는 쌍» 을 +// 다시 계산해 차이만 쓰므로 붙이기와 떼기가 갈라지지 않고 스테일 라벨이 남지 않는다. 코멘트는 +// PR 당 하나를 마커로 찾아 현재 상태로 고쳐 쓴다 — 쌍마다 새로 달면 상대가 바뀔 때마다 쌓여 +// 소음이 되고, 한 번만 달면 상대·파일이 바뀐 뒤 낡은 안내가 남는다. + +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +export const DEFAULT_LABEL = "sibling-conflict"; +export const COMMENT_MARKER = ""; + +const LABEL_COLOR = "F9D0C4"; +const LABEL_DESCRIPTION = "열린 형제 PR 과 같은 파일에서 충돌 — 나중에 머지되는 쪽이 base 를 병합해야 한다. 해소되면 자동으로 떨어진다"; +const PULL_REQUEST_PAGE_SIZE = 50; +const COMMENT_PAGE_SIZE = 100; + +const OPEN_PULL_REQUESTS_QUERY = ` +query($owner: String!, $name: String!, $cursor: String, $pageSize: Int!) { + repository(owner: $owner, name: $name) { + pullRequests(states: OPEN, first: $pageSize, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + number + baseRefName + headRefName + headRefOid + isDraft + headRepository { nameWithOwner } + labels(first: 30) { nodes { name } } + } + } + } +}`; + +/** + * `git merge-tree --write-tree --name-only -z` 출력을 트리 OID 와 충돌 파일 목록으로 가른다. + * + * 형식은 `NUL NUL ... NUL ` 다. 충돌 파일 구간은 빈 항목으로 + * 끝나고 그 뒤는 사람이 읽는 메시지라 버린다. `--name-only` 라 같은 파일이 stage 마다 반복되지 않는다. + */ +export function parseMergeTreeOutput(stdout) { + const parts = stdout.split("\0"); + const tree = (parts[0] ?? "").trim(); + const conflictedFiles = []; + for (const part of parts.slice(1)) { + if (part === "") { + break; + } + conflictedFiles.push(part); + } + return { tree, conflictedFiles }; +} + +/** + * 판정에 필요한 git 질의만 노출한다. 테스트는 임시 저장소에 실제 git 을 돌린다. + */ +export function createGit({ cwd = process.cwd(), spawn = spawnSync } = {}) { + function run(args) { + const result = spawn("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); + if (result.error) { + throw result.error; + } + return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" }; + } + + return { + commitExists(ref) { + return run(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).status === 0; + }, + mergeBase(a, b) { + const result = run(["merge-base", a, b]); + return result.status === 0 ? result.stdout.trim() : null; + }, + isAncestor(ancestor, descendant) { + return run(["merge-base", "--is-ancestor", ancestor, descendant]).status === 0; + }, + changedFiles(from, to) { + const result = run(["diff", "--name-only", "-z", from, to]); + if (result.status !== 0) { + throw new Error(`git diff ${from} ${to} 실패: ${result.stderr.trim()}`); + } + return result.stdout.split("\0").filter(Boolean); + }, + /** 종료 코드 0 은 깨끗, 1 은 충돌, 그 밖은 오류(공통 조상 없음 등)다. */ + mergeTreeConflicts(a, b) { + const result = run(["merge-tree", "--write-tree", "--name-only", "-z", a, b]); + if (result.status === 0) { + return { conflictedFiles: [], error: null }; + } + if (result.status === 1) { + return { conflictedFiles: parseMergeTreeOutput(result.stdout).conflictedFiles, error: null }; + } + return { conflictedFiles: [], error: result.stderr.trim() || `exit ${result.status}` }; + }, + fetchPullRequestHead(number) { + return run(["fetch", "--quiet", "origin", `refs/pull/${number}/head`]).status === 0; + }, + }; +} + +function sortedPair(a, b) { + return a < b ? [a, b] : [b, a]; +} + +/** + * 열린 PR 들 사이의 형제 충돌 쌍을 찾는다. + * + * 각 PR 의 «자기 파일» 은 upstream(부모 PR head, 없으면 trunk)과의 공통 조상부터 head 까지의 + * diff 다. 쌍은 자기 파일이 겹칠 때만 merge-tree 를 돌리고, 충돌 파일 중 양쪽 자기 파일에 모두 + * 든 것만 남긴다. 같은 체인은 부모 링크와 git 조상 관계 두 축으로 걸러 낸다 — base 를 develop 로 + * 옮긴 뒤 아직 rebase 하지 않은 자식은 부모 링크가 끊겨도 커밋 조상으로는 이어져 있다. + */ +export function detectSiblingConflicts({ pullRequests, trunk, git }) { + const byHead = new Map(); + for (const pullRequest of pullRequests) { + if (pullRequest.sameRepository !== false) { + byHead.set(pullRequest.headRefName, pullRequest); + } + } + + const parentOf = (pullRequest) => { + if (pullRequest.sameRepository === false) { + return null; + } + const parent = byHead.get(pullRequest.baseRefName); + return parent && parent.number !== pullRequest.number ? parent : null; + }; + + const isChainAncestor = (ancestor, pullRequest) => { + const seen = new Set(); + for (let current = parentOf(pullRequest); current && !seen.has(current.number); current = parentOf(current)) { + if (current.number === ancestor.number) { + return true; + } + seen.add(current.number); + } + return false; + }; + + const eligible = []; + const skipped = []; + for (const pullRequest of pullRequests) { + if (!git.commitExists(pullRequest.headRefOid)) { + skipped.push({ number: pullRequest.number, reason: `head ${pullRequest.headRefOid.slice(0, 7)} 을 찾지 못함` }); + continue; + } + const parent = parentOf(pullRequest); + const upstreamLabel = parent && git.commitExists(parent.headRefOid) ? `#${parent.number}` : trunk; + const upstream = parent && git.commitExists(parent.headRefOid) ? parent.headRefOid : trunk; + const ownBase = git.mergeBase(upstream, pullRequest.headRefOid); + if (!ownBase) { + skipped.push({ number: pullRequest.number, reason: `${upstreamLabel} 와 공통 조상 없음` }); + continue; + } + eligible.push({ ...pullRequest, ownFiles: new Set(git.changedFiles(ownBase, pullRequest.headRefOid)) }); + } + + // merge-tree 는 쌍마다 한 번이면 된다 — 부모 체인 제외에서 같은 쌍을 다시 묻는다. + const rawCache = new Map(); + const rawConflicts = (a, b) => { + const key = a.headRefOid < b.headRefOid ? `${a.headRefOid}:${b.headRefOid}` : `${b.headRefOid}:${a.headRefOid}`; + if (!rawCache.has(key)) { + rawCache.set(key, git.mergeTreeConflicts(a.headRefOid, b.headRefOid)); + } + return rawCache.get(key); + }; + const availableParentOf = (pullRequest) => { + const parent = parentOf(pullRequest); + return parent && git.commitExists(parent.headRefOid) ? parent : null; + }; + // 부모 체인이 같은 상대와 이미 부딪히는 파일은 자식의 것이 아니다. + const withoutInherited = (files, self, other) => { + const parent = availableParentOf(self); + if (!parent || files.length === 0) { + return files; + } + const inherited = new Set(rawConflicts(parent, other).conflictedFiles); + return files.filter((file) => !inherited.has(file)); + }; + + const pairs = []; + const skippedPairs = []; + for (let i = 0; i < eligible.length; i += 1) { + for (let j = i + 1; j < eligible.length; j += 1) { + const a = eligible[i]; + const b = eligible[j]; + const overlap = [...a.ownFiles].filter((file) => b.ownFiles.has(file)); + if (overlap.length === 0) { + continue; + } + if (isChainAncestor(a, b) || isChainAncestor(b, a)) { + continue; + } + if (git.isAncestor(a.headRefOid, b.headRefOid) || git.isAncestor(b.headRefOid, a.headRefOid)) { + continue; + } + const { conflictedFiles, error } = rawConflicts(a, b); + if (error) { + skippedPairs.push({ numbers: sortedPair(a.number, b.number), reason: error }); + continue; + } + let files = conflictedFiles.filter((file) => a.ownFiles.has(file) && b.ownFiles.has(file)).sort(); + files = withoutInherited(files, a, b); + files = withoutInherited(files, b, a); + if (files.length > 0) { + pairs.push({ numbers: sortedPair(a.number, b.number), files }); + } + } + } + + pairs.sort((left, right) => left.numbers[0] - right.numbers[0] || left.numbers[1] - right.numbers[1]); + return { pairs, skipped, skippedPairs }; +} + +/** + * 충돌 쌍을 라벨·코멘트 계획으로 바꾼다. 라벨은 «지금 어떤 쌍에든 든 PR» 집합과의 차이만 쓰고, + * 코멘트는 그 집합의 PR 마다 현재 상대 목록 하나를 계획한다. 집합에서 빠진 PR 은 라벨을 떼면서 + * 남아 있는 코멘트를 «해소됨» 으로 고쳐 쓴다. + */ +export function planSiblingLabelChanges({ pullRequests, pairs, label = DEFAULT_LABEL }) { + const involved = new Map(); + for (const pair of pairs) { + const [a, b] = pair.numbers; + for (const [self, sibling] of [[a, b], [b, a]]) { + if (!involved.has(self)) { + involved.set(self, []); + } + involved.get(self).push({ sibling, files: pair.files }); + } + } + + const toLabel = []; + const toUnlabel = []; + const comments = []; + for (const pullRequest of pullRequests) { + const labeled = (pullRequest.labels ?? []).includes(label); + const entries = involved.get(pullRequest.number); + if (entries) { + if (!labeled) { + toLabel.push(pullRequest); + } + comments.push({ + number: pullRequest.number, + siblings: [...entries].sort((left, right) => left.sibling - right.sibling), + }); + continue; + } + if (labeled) { + toUnlabel.push(pullRequest); + } + } + + return { toLabel, toUnlabel, comments }; +} + +export function renderSiblingComment({ siblings, label = DEFAULT_LABEL }) { + return [ + COMMENT_MARKER, + "열린 PR 중 이 PR 과 같은 파일을 서로 다르게 고친 것이 있다. **어느 쪽이 먼저 머지되든 나중 쪽은 base 를 병합해야 통과한다** (#1750).", + "", + ...siblings.map((entry) => `- #${entry.sibling} — ${entry.files.map((file) => `\`${file}\``).join(", ")}`), + "", + "머지 자체는 GitHub 이 막는다 — 한쪽이 들어가면 다른 쪽은 CONFLICTING 이 되어 merge queue 에 못 들어간다.", + "이 라벨은 그 전에 순서를 정하라는 신호다. 먼저 갈 쪽을 정하고, 나중 쪽은 그 머지 뒤에 base 를 병합한다.", + "상대 PR 에도 같은 안내가 달린다.", + "", + `이 코멘트는 리컨사일러가 매 실행 현재 상태로 고쳐 쓴다. 해소되면 \`${label}\` 라벨은 자동으로 떨어진다.`, + ].join("\n"); +} + +export function renderResolvedComment() { + return [ + COMMENT_MARKER, + "형제 충돌이 해소됐다 — 지금은 이 PR 과 같은 파일을 서로 다르게 고친 열린 PR 이 없다 (#1750).", + "다시 생기면 이 코멘트를 현재 상태로 고쳐 쓴다.", + ].join("\n"); +} + +export function renderSummary({ pairs, plan, skipped = [], skippedPairs = [], label, dryRun }) { + const lines = [ + `## 형제 충돌 PR 라벨 (\`${label}\`)${dryRun ? " — dry run" : ""}`, + "", + `- 충돌 쌍: ${pairs.length}건`, + ...pairs.map((pair) => ` - #${pair.numbers[0]} ↔ #${pair.numbers[1]} — ${pair.files.map((file) => `\`${file}\``).join(", ")}`), + `- 라벨 부착: ${plan.toLabel.length}건${formatNumbers(plan.toLabel)}`, + `- 라벨 제거: ${plan.toUnlabel.length}건${formatNumbers(plan.toUnlabel)}`, + `- 코멘트 계획: ${plan.comments.length}건`, + `- 판정 보류: ${skipped.length}건${skipped.map((item) => ` — #${item.number} (${item.reason})`).join("")}`, + ]; + if (skippedPairs.length > 0) { + lines.push(`- 비교 실패: ${skippedPairs.length}건${skippedPairs.map((item) => ` — #${item.numbers[0]} ↔ #${item.numbers[1]} (${item.reason})`).join("")}`); + } + return lines.join("\n"); +} + +function formatNumbers(pullRequests) { + if (pullRequests.length === 0) { + return ""; + } + return ` — ${pullRequests.map((pullRequest) => `#${pullRequest.number}`).join(", ")}`; +} + +export function normalizePullRequest(node, repository) { + return { + number: node.number, + baseRefName: node.baseRefName, + headRefName: node.headRefName, + headRefOid: node.headRefOid, + isDraft: node.isDraft, + sameRepository: (node.headRepository?.nameWithOwner ?? "").toLowerCase() === repository.toLowerCase(), + labels: (node.labels?.nodes ?? []).map((item) => item.name), + }; +} + +function createApi(token) { + return async function api(apiPath, { method = "GET", body, allowNotFound = false } = {}) { + const response = await fetch(`https://api.github.com${apiPath}`, { + method, + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "content-type": "application/json", + "x-github-api-version": "2022-11-28", + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + if (allowNotFound && response.status === 404) { + return null; + } + if (!response.ok) { + const detail = await response.text(); + throw new Error(`GitHub API ${method} ${apiPath} 실패: ${response.status} ${detail}`); + } + if (response.status === 204) { + return null; + } + return response.json(); + }; +} + +async function graphql(api, query, variables) { + const payload = await api("/graphql", { method: "POST", body: { query, variables } }); + if (payload?.errors?.length) { + throw new Error(`GraphQL 실패: ${JSON.stringify(payload.errors)}`); + } + return payload.data; +} + +export async function fetchOpenPullRequests(api, repository) { + const [owner, name] = repository.split("/"); + const pullRequests = []; + let cursor = null; + + for (;;) { + const data = await graphql(api, OPEN_PULL_REQUESTS_QUERY, { + owner, + name, + cursor, + pageSize: PULL_REQUEST_PAGE_SIZE, + }); + const page = data.repository.pullRequests; + pullRequests.push(...page.nodes.map((node) => normalizePullRequest(node, repository))); + + if (!page.pageInfo.hasNextPage) { + return pullRequests; + } + cursor = page.pageInfo.endCursor; + } +} + +/** + * 전체 히스토리 checkout 은 저장소 안 브랜치를 모두 가져오지만, 조회 직전에 push 된 커밋이나 포크 + * PR 의 head 는 없을 수 있다. 없는 것만 `refs/pull/N/head` 로 채운다 — 실패해도 판정 보류로 남길 + * 뿐 실행을 멈추지 않는다. + */ +export function ensureHeadsAvailable(pullRequests, git, logger = console) { + for (const pullRequest of pullRequests) { + if (git.commitExists(pullRequest.headRefOid)) { + continue; + } + if (!git.fetchPullRequestHead(pullRequest.number)) { + logger.log(`#${pullRequest.number} head 를 가져오지 못했다 — 판정 보류`); + } + } +} + +export async function ensureLabelExists(api, repository, label) { + const existing = await api(`/repos/${repository}/labels/${encodeURIComponent(label)}`, { + allowNotFound: true, + }); + if (existing) { + return; + } + await api(`/repos/${repository}/labels`, { + method: "POST", + body: { name: label, color: LABEL_COLOR, description: LABEL_DESCRIPTION }, + }); +} + +async function findMarkerComment(api, repository, number) { + for (let page = 1; ; page += 1) { + const comments = await api(`/repos/${repository}/issues/${number}/comments?per_page=${COMMENT_PAGE_SIZE}&page=${page}`); + const found = (comments ?? []).find((comment) => (comment.body ?? "").includes(COMMENT_MARKER)); + if (found) { + return { id: found.id, body: found.body ?? "" }; + } + if (!comments || comments.length < COMMENT_PAGE_SIZE) { + return null; + } + } +} + +const normalizeBody = (body) => body.replace(/\r\n/g, "\n").trimEnd(); + +/** 마커 코멘트가 없으면 만들고, 있으면 내용이 다를 때만 고쳐 쓴다. 같으면 아무것도 쓰지 않는다. */ +async function upsertMarkerComment(api, repository, number, body) { + const existing = await findMarkerComment(api, repository, number); + if (!existing) { + await api(`/repos/${repository}/issues/${number}/comments`, { method: "POST", body: { body } }); + return "created"; + } + if (normalizeBody(existing.body) === normalizeBody(body)) { + return "unchanged"; + } + await api(`/repos/${repository}/issues/comments/${existing.id}`, { method: "PATCH", body: { body } }); + return "updated"; +} + +// `logger` 를 받는 이유: 테스트가 실제 동작처럼 보이는 줄을 CI 로그에 찍지 않게 하기 위해서다. +export async function applyPlan(api, repository, plan, { label, dryRun, logger = console }) { + const failures = []; + + for (const pullRequest of plan.toLabel) { + try { + if (dryRun) { + logger.log(`[dry-run] #${pullRequest.number} 라벨 부착`); + continue; + } + await api(`/repos/${repository}/issues/${pullRequest.number}/labels`, { + method: "POST", + body: { labels: [label] }, + }); + logger.log(`#${pullRequest.number} 라벨 부착`); + } catch (error) { + failures.push(`#${pullRequest.number} 라벨 부착 실패: ${error.message}`); + } + } + + for (const comment of plan.comments) { + const siblings = comment.siblings.map((entry) => `#${entry.sibling}`).join(", "); + try { + if (dryRun) { + logger.log(`[dry-run] #${comment.number} 코멘트 (상대 ${siblings})`); + continue; + } + const outcome = await upsertMarkerComment( + api, + repository, + comment.number, + renderSiblingComment({ siblings: comment.siblings, label }), + ); + if (outcome !== "unchanged") { + logger.log(`#${comment.number} 코멘트 ${outcome === "created" ? "작성" : "갱신"} (상대 ${siblings})`); + } + } catch (error) { + failures.push(`#${comment.number} 코멘트 실패 (상대 ${siblings}): ${error.message}`); + } + } + + for (const pullRequest of plan.toUnlabel) { + try { + if (dryRun) { + logger.log(`[dry-run] #${pullRequest.number} 라벨 제거`); + continue; + } + await api(`/repos/${repository}/issues/${pullRequest.number}/labels/${encodeURIComponent(label)}`, { + method: "DELETE", + allowNotFound: true, + }); + // 남아 있는 안내를 «해소됨» 으로 고쳐 써야 낡은 상대 목록이 PR 에 남지 않는다. 없으면 만들지 않는다. + const existing = await findMarkerComment(api, repository, pullRequest.number); + if (existing && normalizeBody(existing.body) !== normalizeBody(renderResolvedComment())) { + await api(`/repos/${repository}/issues/comments/${existing.id}`, { + method: "PATCH", + body: { body: renderResolvedComment() }, + }); + } + logger.log(`#${pullRequest.number} 라벨 제거`); + } catch (error) { + failures.push(`#${pullRequest.number} 라벨 제거 실패: ${error.message}`); + } + } + + return failures; +} + +async function main() { + const token = process.env.GITHUB_TOKEN; + const repository = process.env.GITHUB_REPOSITORY; + const defaultBranch = process.env.GITHUB_DEFAULT_BRANCH; + if (!token || !repository || !defaultBranch) { + throw new Error("GITHUB_TOKEN, GITHUB_REPOSITORY, GITHUB_DEFAULT_BRANCH 가 필요합니다."); + } + + const label = process.env.SIBLING_CONFLICT_LABEL ?? DEFAULT_LABEL; + const dryRun = process.env.DRY_RUN === "true"; + const trunk = process.env.TRUNK_REF ?? `origin/${defaultBranch}`; + const api = createApi(token); + const git = createGit(); + + if (!git.commitExists(trunk)) { + throw new Error(`trunk '${trunk}' 를 찾지 못했다 — 전체 히스토리 checkout 이 필요하다.`); + } + + const pullRequests = await fetchOpenPullRequests(api, repository); + ensureHeadsAvailable(pullRequests, git); + const { pairs, skipped, skippedPairs } = detectSiblingConflicts({ pullRequests, trunk, git }); + const plan = planSiblingLabelChanges({ pullRequests, pairs, label }); + + if (!dryRun && plan.toLabel.length > 0) { + await ensureLabelExists(api, repository, label); + } + + const failures = await applyPlan(api, repository, plan, { label, dryRun }); + const summary = renderSummary({ pairs, plan, skipped, skippedPairs, label, dryRun }); + console.log(summary); + + if (process.env.GITHUB_STEP_SUMMARY) { + const { appendFile } = await import("node:fs/promises"); + await appendFile(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`); + } + + if (failures.length > 0) { + // 개별 실패가 나머지 PR 처리를 막지 않게 모아 두었다가 여기서 한 번에 드러낸다. + throw new Error(failures.join("\n")); + } +} + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; +if (import.meta.url === invokedPath) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/label-sibling-conflicts.test.mjs b/.github/scripts/label-sibling-conflicts.test.mjs new file mode 100644 index 0000000000..197660864f --- /dev/null +++ b/.github/scripts/label-sibling-conflicts.test.mjs @@ -0,0 +1,585 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + COMMENT_MARKER, + DEFAULT_LABEL, + applyPlan, + createGit, + detectSiblingConflicts, + ensureHeadsAvailable, + ensureLabelExists, + normalizePullRequest, + parseMergeTreeOutput, + planSiblingLabelChanges, + renderResolvedComment, + renderSiblingComment, + renderSummary, +} from "./label-sibling-conflicts.mjs"; + +const workflow = await readFile(new URL("../workflows/conflict-label.yml", import.meta.url), "utf8"); + +/** 테스트 출력이 CI 로그에서 실제 조작처럼 보이지 않도록 삼킨다. */ +const silent = { log() {} }; + +function fakeApi({ responses = {}, failOn = null } = {}) { + const calls = []; + const api = async (apiPath, options = {}) => { + calls.push({ apiPath, method: options.method ?? "GET", body: options.body }); + if (failOn && failOn(apiPath, options)) { + throw new Error("boom"); + } + for (const [pattern, value] of Object.entries(responses)) { + if (apiPath.startsWith(pattern)) { + return typeof value === "function" ? value(apiPath, options) : value; + } + } + return options.allowNotFound ? null : {}; + }; + api.calls = calls; + return api; +} + +function pullRequest(overrides = {}) { + return { + number: 1, + baseRefName: "develop", + headRefName: "feature/x", + headRefOid: "0".repeat(40), + isDraft: false, + sameRepository: true, + labels: [], + ...overrides, + }; +} + +const writes = (api) => api.calls.filter((call) => call.method !== "GET"); + +// ---- 실제 git 저장소 픽스처 ---- +// +// 판정의 정본은 git 의 3-way merge 다. 가짜 git 으로는 «같은 파일을 고쳤지만 깨끗이 합쳐진다» +// 같은 경계를 흉내낼 수 없으므로 임시 저장소를 만들어 실제 merge-tree 를 돌린다. + +const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: "test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "test", + GIT_COMMITTER_EMAIL: "test@example.com", + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", +}; + +async function makeRepository() { + const dir = await mkdtemp(path.join(os.tmpdir(), "sibling-conflict-")); + const run = (args) => { + const result = spawnSync("git", args, { cwd: dir, encoding: "utf8", env: gitEnv }); + if (result.status !== 0) { + throw new Error(`git ${args.join(" ")} 실패: ${result.stderr}`); + } + return result.stdout.trim(); + }; + run(["init", "-q", "-b", "develop"]); + return { + dir, + run, + write: (name, content) => writeFile(path.join(dir, name), content), + commit: (message) => { + run(["add", "-A"]); + run(["commit", "-q", "-m", message]); + return run(["rev-parse", "HEAD"]); + }, + checkout: (branch, from) => run(from ? ["checkout", "-q", "-b", branch, from] : ["checkout", "-q", branch]), + git: createGit({ cwd: dir }), + cleanup: () => rm(dir, { recursive: true, force: true }), + }; +} + +const LINES = ["a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8"]; +const aText = (first = "a1", last = "a8") => [first, ...LINES.slice(1, -1), last].join("\n") + "\n"; + +/** + * 픽스처. + * + * develop D0 : a.txt(a1..a8) · b.txt · c.txt + * feat/x (D0) a1→x #10 base develop + * feat/y (D0) a1→y #11 base develop → #10 ↔ #11 은 a.txt 충돌 + * feat/v (D0) a8→v #12 base develop → #10 과 같은 파일이지만 깨끗이 합쳐진다 + * feat/z (D0) b.txt→z #13 base develop + * feat/x-child(feat/x) a8→xc8 · c.txt→xc #14 base feat/x → a.txt 의 a1 충돌은 #10 것, a8 충돌은 #14 것 + * develop D1 : a1→d → #10·#11 과 부딪히는 trunk 전진 + * feat/w (D1) b.txt→w #15 base develop → #13 ↔ #15 는 b.txt 충돌, + * #11 과는 a.txt 에서 부딪히지만 w 의 자기 파일이 아니다 + */ +async function makeFixture() { + const repo = await makeRepository(); + await repo.write("a.txt", aText()); + await repo.write("b.txt", "b\n"); + await repo.write("c.txt", "c\n"); + const d0 = await repo.commit("D0"); + + const heads = {}; + repo.checkout("feat/x", d0); + await repo.write("a.txt", aText("x")); + heads.x = await repo.commit("x"); + + repo.checkout("feat/y", d0); + await repo.write("a.txt", aText("y")); + heads.y = await repo.commit("y"); + + repo.checkout("feat/v", d0); + await repo.write("a.txt", aText("a1", "v")); + heads.v = await repo.commit("v"); + + repo.checkout("feat/z", d0); + await repo.write("b.txt", "z\n"); + heads.z = await repo.commit("z"); + + repo.checkout("feat/x-child", "feat/x"); + await repo.write("a.txt", aText("x", "xc8")); + await repo.write("c.txt", "xc\n"); + heads.xChild = await repo.commit("x-child"); + + repo.checkout("develop"); + await repo.write("a.txt", aText("d")); + const d1 = await repo.commit("D1"); + + repo.checkout("feat/w", d1); + await repo.write("b.txt", "w\n"); + heads.w = await repo.commit("w"); + repo.checkout("develop"); + + const pullRequests = [ + pullRequest({ number: 10, headRefName: "feat/x", headRefOid: heads.x }), + pullRequest({ number: 11, headRefName: "feat/y", headRefOid: heads.y }), + pullRequest({ number: 12, headRefName: "feat/v", headRefOid: heads.v }), + pullRequest({ number: 13, headRefName: "feat/z", headRefOid: heads.z }), + pullRequest({ number: 14, headRefName: "feat/x-child", headRefOid: heads.xChild, baseRefName: "feat/x" }), + pullRequest({ number: 15, headRefName: "feat/w", headRefOid: heads.w }), + ]; + return { repo, heads, pullRequests }; +} + +function pairNumbers(result) { + return result.pairs.map((pair) => pair.numbers); +} + +const hasPair = (result, a, b) => pairNumbers(result).some((numbers) => numbers[0] === a && numbers[1] === b); + +test("형제 충돌 쌍은 양쪽 자기 커밋이 모두 고친 파일에서만 잡힌다", async () => { + const { repo, pullRequests } = await makeFixture(); + try { + const result = detectSiblingConflicts({ pullRequests, trunk: "develop", git: repo.git }); + + assert.deepEqual(result.pairs, [ + { numbers: [10, 11], files: ["a.txt"] }, + { numbers: [12, 14], files: ["a.txt"] }, + { numbers: [13, 15], files: ["b.txt"] }, + ]); + assert.deepEqual(result.skipped, []); + assert.deepEqual(result.skippedPairs, []); + } finally { + await repo.cleanup(); + } +}); + +test("같은 파일을 고쳐도 깨끗이 합쳐지면 충돌이 아니다", async () => { + // 파일 겹침은 merge-tree 를 돌릴 조건일 뿐이고, 판정은 3-way merge 결과다. + const { repo, pullRequests } = await makeFixture(); + try { + const result = detectSiblingConflicts({ pullRequests, trunk: "develop", git: repo.git }); + assert.equal(hasPair(result, 10, 12), false); + } finally { + await repo.cleanup(); + } +}); + +test("스택 자식은 부모의 충돌을 물려받지 않고 자기 충돌만 갖는다", async () => { + // #14 의 head 는 #10 의 커밋을 품고 있어 #11 과 합치면 a.txt 의 a1 이 충돌한다. 그것은 #10 의 + // 것이다 — 부모 체인이 #11 과 이미 부딪히는 파일이라 #14 에서는 뺀다. 반면 #12 와의 a8 충돌은 + // #14 의 자기 커밋이 만든 것이고 부모는 #12 와 부딪히지 않으므로 #14 에 남는다. + const { repo, pullRequests } = await makeFixture(); + try { + const result = detectSiblingConflicts({ pullRequests, trunk: "develop", git: repo.git }); + assert.equal(hasPair(result, 11, 14), false); + assert.equal(hasPair(result, 12, 14), true); + } finally { + await repo.cleanup(); + } +}); + +test("trunk 전진이 만든 충돌은 형제 충돌이 아니다", async () => { + // #15 는 D1 에서 갈라져 a1→d 를 품고 있다. #11 과 합치면 a.txt 가 충돌하지만, 그것은 #11 과 + // develop 의 충돌(= conflict 라벨 몫)이지 #15 의 커밋이 만든 것이 아니다. + const { repo, pullRequests } = await makeFixture(); + try { + const result = detectSiblingConflicts({ pullRequests, trunk: "develop", git: repo.git }); + assert.equal(hasPair(result, 11, 15), false); + } finally { + await repo.cleanup(); + } +}); + +test("head 를 찾지 못한 PR 은 보류로 남기고 나머지 판정은 계속한다", async () => { + const { repo, pullRequests } = await makeFixture(); + try { + const result = detectSiblingConflicts({ + pullRequests: [...pullRequests, pullRequest({ number: 16, headRefName: "feat/fork", headRefOid: "f".repeat(40) })], + trunk: "develop", + git: repo.git, + }); + + assert.deepEqual(result.skipped.map((item) => item.number), [16]); + assert.match(result.skipped[0].reason, /찾지 못함/); + assert.deepEqual(pairNumbers(result), [[10, 11], [12, 14], [13, 15]]); + } finally { + await repo.cleanup(); + } +}); + +test("base 를 옮긴 뒤 rebase 하지 않은 자식은 커밋 조상으로 같은 체인임을 가린다", async () => { + // 부모 링크(base 브랜치명)가 끊겨도 #14 의 head 는 #10 의 head 를 조상으로 갖는다. + // 그 둘 사이의 겹침은 base 축이지 형제 축이 아니다. + const { repo, pullRequests } = await makeFixture(); + try { + const retargeted = pullRequests.map((item) => (item.number === 14 ? { ...item, baseRefName: "develop" } : item)); + const result = detectSiblingConflicts({ pullRequests: retargeted, trunk: "develop", git: repo.git }); + assert.equal(hasPair(result, 10, 14), false); + } finally { + await repo.cleanup(); + } +}); + +test("공통 조상이 없는 PR 은 자기 파일을 셀 수 없어 보류로 남긴다", async () => { + const { repo, pullRequests } = await makeFixture(); + try { + repo.run(["checkout", "-q", "--orphan", "feat/orphan"]); + await repo.write("a.txt", "orphan\n"); + const orphan = await repo.commit("orphan"); + repo.checkout("develop"); + + const result = detectSiblingConflicts({ + pullRequests: [...pullRequests, pullRequest({ number: 17, headRefName: "feat/orphan", headRefOid: orphan })], + trunk: "develop", + git: repo.git, + }); + + assert.deepEqual(result.skipped.map((item) => item.number), [17]); + assert.match(result.skipped[0].reason, /공통 조상 없음/); + assert.equal(result.pairs.length, 3); + } finally { + await repo.cleanup(); + } +}); + +test("merge-tree 결과를 종료 코드로 가른다", async () => { + const { repo, heads } = await makeFixture(); + try { + assert.deepEqual(repo.git.mergeTreeConflicts(heads.x, heads.v), { conflictedFiles: [], error: null }); + assert.deepEqual(repo.git.mergeTreeConflicts(heads.x, heads.y), { conflictedFiles: ["a.txt"], error: null }); + + repo.run(["checkout", "-q", "--orphan", "feat/orphan"]); + await repo.write("a.txt", "orphan\n"); + const orphan = await repo.commit("orphan"); + const unrelated = repo.git.mergeTreeConflicts(heads.x, orphan); + assert.deepEqual(unrelated.conflictedFiles, []); + assert.ok(unrelated.error); + } finally { + await repo.cleanup(); + } +}); + +test("merge-tree -z 출력에서 충돌 파일 구간만 읽는다", () => { + const stdout = ["abc123", "dir/one.kt", "two.txt", "", "1", "dir/one.kt", "Auto-merging", "Auto-merging dir/one.kt\n"].join("\0"); + assert.deepEqual(parseMergeTreeOutput(stdout), { tree: "abc123", conflictedFiles: ["dir/one.kt", "two.txt"] }); + assert.deepEqual(parseMergeTreeOutput("abc123\0"), { tree: "abc123", conflictedFiles: [] }); + assert.deepEqual(parseMergeTreeOutput("abc123\n"), { tree: "abc123", conflictedFiles: [] }); +}); + +test("없는 head 만 refs/pull/N/head 로 채운다", () => { + const fetched = []; + const git = { + commitExists: (oid) => oid.startsWith("1"), + fetchPullRequestHead: (number) => { + fetched.push(number); + return number !== 30; + }, + }; + const lines = []; + + ensureHeadsAvailable( + [ + pullRequest({ number: 20, headRefOid: "1".repeat(40) }), + pullRequest({ number: 21, headRefOid: "2".repeat(40) }), + pullRequest({ number: 30, headRefOid: "3".repeat(40) }), + ], + git, + { log: (line) => lines.push(line) }, + ); + + assert.deepEqual(fetched, [21, 30]); + assert.deepEqual(lines, ["#30 head 를 가져오지 못했다 — 판정 보류"]); +}); + +test("포크 PR 은 같은 저장소가 아니라고 표시한다", () => { + const own = normalizePullRequest({ number: 1, headRepository: { nameWithOwner: "Org/Repo" }, labels: { nodes: [] } }, "org/repo"); + const fork = normalizePullRequest({ number: 2, headRepository: { nameWithOwner: "someone/repo" }, labels: { nodes: [{ name: "x" }] } }, "org/repo"); + + assert.equal(own.sameRepository, true); + assert.equal(fork.sameRepository, false); + assert.deepEqual(fork.labels, ["x"]); +}); + +test("충돌 쌍의 양쪽에 라벨을 붙이고 PR 마다 상대 목록 코멘트를 계획한다", () => { + const plan = planSiblingLabelChanges({ + pullRequests: [pullRequest({ number: 10 }), pullRequest({ number: 11 }), pullRequest({ number: 12 })], + pairs: [{ numbers: [10, 11], files: ["a.txt"] }], + label: DEFAULT_LABEL, + }); + + assert.deepEqual(plan.toLabel.map((item) => item.number), [10, 11]); + assert.deepEqual(plan.toUnlabel, []); + assert.deepEqual(plan.comments, [ + { number: 10, siblings: [{ sibling: 11, files: ["a.txt"] }] }, + { number: 11, siblings: [{ sibling: 10, files: ["a.txt"] }] }, + ]); +}); + +test("이미 라벨이 붙은 PR 은 다시 붙이지 않고, 쌍에서 빠진 PR 에서는 뗀다", () => { + const plan = planSiblingLabelChanges({ + pullRequests: [ + pullRequest({ number: 10, labels: [DEFAULT_LABEL] }), + pullRequest({ number: 11 }), + pullRequest({ number: 12, labels: [DEFAULT_LABEL, "bug"] }), + ], + pairs: [{ numbers: [10, 11], files: ["a.txt"] }], + label: DEFAULT_LABEL, + }); + + assert.deepEqual(plan.toLabel.map((item) => item.number), [11]); + assert.deepEqual(plan.toUnlabel.map((item) => item.number), [12]); + // 라벨이 이미 있어도 코멘트는 현재 상태로 다시 계획한다 — 상대 목록이 바뀌었을 수 있다. + assert.deepEqual(plan.comments.map((item) => item.number), [10, 11]); +}); + +test("한 PR 이 여러 형제와 충돌하면 상대를 번호순으로 한 코멘트에 모은다", () => { + const plan = planSiblingLabelChanges({ + pullRequests: [pullRequest({ number: 10 }), pullRequest({ number: 11 }), pullRequest({ number: 12 })], + pairs: [ + { numbers: [10, 12], files: ["b.txt"] }, + { numbers: [10, 11], files: ["a.txt"] }, + ], + label: DEFAULT_LABEL, + }); + + assert.deepEqual(plan.comments.find((item) => item.number === 10).siblings, [ + { sibling: 11, files: ["a.txt"] }, + { sibling: 12, files: ["b.txt"] }, + ]); +}); + +test("코멘트에 마커·상대 번호·충돌 파일·추적 이슈가 들어간다", () => { + const body = renderSiblingComment({ + siblings: [ + { sibling: 1608, files: ["dir/AfternoteHomeScreen.kt"] }, + { sibling: 1626, files: ["x.kt", "y.kt"] }, + ], + }); + + assert.ok(body.startsWith(COMMENT_MARKER)); + assert.ok(body.includes("- #1608 — `dir/AfternoteHomeScreen.kt`")); + assert.ok(body.includes("- #1626 — `x.kt`, `y.kt`")); + assert.ok(body.includes("#1750")); + assert.ok(body.includes("자동으로 떨어진다")); + + const resolved = renderResolvedComment(); + assert.ok(resolved.startsWith(COMMENT_MARKER)); + assert.ok(resolved.includes("해소")); +}); + +test("라벨을 붙이고 마커 코멘트가 없으면 만든다", async () => { + const api = fakeApi({ responses: { "/repos/o/r/issues/40/comments": [], "/repos/o/r/issues/41/comments": [] } }); + + const failures = await applyPlan( + api, + "o/r", + { + toLabel: [{ number: 40 }, { number: 41 }], + toUnlabel: [], + comments: [ + { number: 40, siblings: [{ sibling: 41, files: ["a.txt"] }] }, + { number: 41, siblings: [{ sibling: 40, files: ["a.txt"] }] }, + ], + }, + { label: DEFAULT_LABEL, dryRun: false, logger: silent }, + ); + + assert.deepEqual(failures, []); + assert.deepEqual(writes(api).map((call) => [call.method, call.apiPath]), [ + ["POST", "/repos/o/r/issues/40/labels"], + ["POST", "/repos/o/r/issues/41/labels"], + ["POST", "/repos/o/r/issues/40/comments"], + ["POST", "/repos/o/r/issues/41/comments"], + ]); + assert.ok(writes(api)[2].body.body.includes("- #41 — `a.txt`")); +}); + +test("마커 코멘트가 있고 내용이 다르면 고쳐 쓴다", async () => { + const api = fakeApi({ + responses: { "/repos/o/r/issues/42/comments": [{ id: 7, body: `${COMMENT_MARKER}\r\n옛 상대 목록` }] }, + }); + + await applyPlan( + api, + "o/r", + { toLabel: [], toUnlabel: [], comments: [{ number: 42, siblings: [{ sibling: 43, files: ["a.txt"] }] }] }, + { label: DEFAULT_LABEL, dryRun: false, logger: silent }, + ); + + assert.deepEqual(writes(api).map((call) => [call.method, call.apiPath]), [["PATCH", "/repos/o/r/issues/comments/7"]]); + assert.ok(writes(api)[0].body.body.includes("- #43 — `a.txt`")); +}); + +test("마커 코멘트가 현재 상태와 같으면 아무것도 쓰지 않는다", async () => { + const siblings = [{ sibling: 43, files: ["a.txt"] }]; + const current = renderSiblingComment({ siblings, label: DEFAULT_LABEL }).replace(/\n/g, "\r\n"); + const api = fakeApi({ responses: { "/repos/o/r/issues/42/comments": [{ id: 7, body: current }] } }); + + await applyPlan( + api, + "o/r", + { toLabel: [], toUnlabel: [], comments: [{ number: 42, siblings }] }, + { label: DEFAULT_LABEL, dryRun: false, logger: silent }, + ); + + assert.deepEqual(writes(api), []); +}); + +test("해소된 PR 에서 라벨을 떼고 남은 안내를 «해소됨» 으로 고쳐 쓴다", async () => { + const api = fakeApi({ + responses: { "/repos/o/r/issues/45/comments": [{ id: 9, body: `${COMMENT_MARKER}\n옛 상대 목록` }] }, + }); + + await applyPlan( + api, + "o/r", + { toLabel: [], toUnlabel: [{ number: 45 }], comments: [] }, + { label: DEFAULT_LABEL, dryRun: false, logger: silent }, + ); + + assert.deepEqual(writes(api).map((call) => [call.method, call.apiPath]), [ + ["DELETE", "/repos/o/r/issues/45/labels/sibling-conflict"], + ["PATCH", "/repos/o/r/issues/comments/9"], + ]); + assert.equal(writes(api)[1].body.body, renderResolvedComment()); +}); + +test("해소된 PR 에 안내가 없으면 새로 만들지 않는다", async () => { + const api = fakeApi({ responses: { "/repos/o/r/issues/46/comments": [] } }); + + await applyPlan( + api, + "o/r", + { toLabel: [], toUnlabel: [{ number: 46 }], comments: [] }, + { label: DEFAULT_LABEL, dryRun: false, logger: silent }, + ); + + assert.deepEqual(writes(api).map((call) => [call.method, call.apiPath]), [ + ["DELETE", "/repos/o/r/issues/46/labels/sibling-conflict"], + ]); +}); + +test("dry run 은 아무것도 쓰지 않는다", async () => { + const api = fakeApi(); + + await applyPlan( + api, + "o/r", + { + toLabel: [{ number: 46 }], + toUnlabel: [{ number: 47 }], + comments: [{ number: 46, siblings: [{ sibling: 48, files: ["a.txt"] }] }], + }, + { label: DEFAULT_LABEL, dryRun: true, logger: silent }, + ); + + assert.deepEqual(api.calls, []); +}); + +test("한 PR 이 실패해도 나머지를 처리하고 실패를 모아 돌려준다", async () => { + const api = fakeApi({ + responses: { "/repos/o/r/issues/51/comments": [] }, + failOn: (apiPath) => apiPath === "/repos/o/r/issues/50/labels", + }); + + const failures = await applyPlan( + api, + "o/r", + { + toLabel: [{ number: 50 }, { number: 51 }], + toUnlabel: [], + comments: [{ number: 51, siblings: [{ sibling: 50, files: ["a.txt"] }] }], + }, + { label: DEFAULT_LABEL, dryRun: false, logger: silent }, + ); + + assert.equal(failures.length, 1); + assert.match(failures[0], /#50 라벨 부착 실패/); + assert.ok(api.calls.some((call) => call.apiPath === "/repos/o/r/issues/51/comments" && call.method === "POST")); +}); + +test("라벨이 없으면 만들고, 있으면 만들지 않는다", async () => { + const missing = fakeApi(); + await ensureLabelExists(missing, "o/r", DEFAULT_LABEL); + const created = missing.calls.filter((call) => call.method === "POST"); + assert.equal(created.length, 1); + assert.equal(created[0].body.name, DEFAULT_LABEL); + + const existing = fakeApi({ responses: { "/repos/o/r/labels/sibling-conflict": { name: DEFAULT_LABEL } } }); + await ensureLabelExists(existing, "o/r", DEFAULT_LABEL); + assert.equal(existing.calls.filter((call) => call.method === "POST").length, 0); +}); + +test("요약에 쌍·파일·건수·보류 사유가 남는다", () => { + const pairs = [{ numbers: [10, 11], files: ["a.txt"] }]; + const plan = planSiblingLabelChanges({ + pullRequests: [pullRequest({ number: 10 }), pullRequest({ number: 11 }), pullRequest({ number: 12, labels: [DEFAULT_LABEL] })], + pairs, + label: DEFAULT_LABEL, + }); + + const summary = renderSummary({ + pairs, + plan, + skipped: [{ number: 16, reason: "head 를 찾지 못함" }], + skippedPairs: [{ numbers: [10, 17], reason: "no merge base" }], + label: DEFAULT_LABEL, + dryRun: true, + }); + + assert.ok(summary.includes("dry run")); + assert.ok(summary.includes("#10 ↔ #11 — `a.txt`")); + assert.ok(summary.includes("라벨 부착: 2건 — #10, #11")); + assert.ok(summary.includes("라벨 제거: 1건 — #12")); + assert.ok(summary.includes("코멘트 계획: 2건")); + assert.ok(summary.includes("판정 보류: 1건 — #16 (head 를 찾지 못함)")); + assert.ok(summary.includes("비교 실패: 1건 — #10 ↔ #17 (no merge base)")); +}); + +test("리컨사일 워크플로가 전체 히스토리 checkout 으로 이 스크립트와 테스트를 부른다", () => { + const job = /^ sibling-conflicts:\n([\s\S]*?)(?=^ \w[\w-]*:\n|(?![\s\S]))/m.exec(workflow)?.[1]; + assert.ok(job, "sibling-conflicts job 이 있어야 한다"); + assert.match(job, /^ timeout-minutes: [1-9]\d*$/m); + assert.match(job, /^ fetch-depth: 0$/m); + assert.match(job, /^ persist-credentials: false$/m); + assert.match(job, /node --test \.github\/scripts\/label-sibling-conflicts\.test\.mjs/); + assert.match(job, /run: node \.github\/scripts\/label-sibling-conflicts\.mjs$/m); + assert.match(job, /GITHUB_DEFAULT_BRANCH: \$\{\{ github\.event\.repository\.default_branch \}\}/); + assert.match(job, /DRY_RUN: \$\{\{ inputs\.dry_run == true && 'true' \|\| 'false' \}\}/); + assert.match(job, /^ issues: write$/m); + assert.match(job, /^ pull-requests: write$/m); +}); diff --git a/.github/workflows/conflict-label.yml b/.github/workflows/conflict-label.yml index 51a47f8a8c..5b0cb8cea3 100644 --- a/.github/workflows/conflict-label.yml +++ b/.github/workflows/conflict-label.yml @@ -11,6 +11,10 @@ name: PR Label Reconcile # 변경요청 뒤 작성자가 아무 조치도 하지 않은 PR 에도 라벨을 붙인다 (#1552). `review-debt-guard` # 는 리뷰어의 빚만 잡고 그 상태를 «공은 작성자에게» 로 흘려보내므로, 그쪽 축은 어디서도 신호가 # 나지 않았다. 붙이기와 떼기를 한 번의 차이 계산으로 처리해 스테일 라벨이 남지 않게 한다. +# +# 열린 PR 끼리의 «형제 충돌» 도 같은 리컨사일러가 잡는다 (#1750). `conflict` 라벨은 base 와의 충돌만 +# 보므로, 두 PR 이 같은 파일을 서로 다르게 고친 사실은 한쪽이 머지된 뒤에야 드러났다. merge-tree 에 +# 전체 히스토리가 필요해 별도 job 으로 두고 checkout 을 fetch-depth 0 으로 받는다. on: push: branches: [develop] @@ -77,3 +81,33 @@ jobs: node .github/scripts/label-issues-with-open-prs.mjs || failures=1 node .github/scripts/label-awaiting-author-prs.mjs || failures=1 exit "$failures" + + sibling-conflicts: + if: github.event_name != 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch + name: Label sibling pull request conflicts + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + issues: write + pull-requests: write + steps: + # merge-tree 는 두 head 의 공통 조상까지 필요하다. 얕은 clone 이면 merge-base 가 없어 모든 쌍이 + # «비교 실패» 로 남는다. 전체 히스토리를 받으면 저장소 안 브랜치의 head 도 함께 온다. + - name: Clone repo with full history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 0 + persist-credentials: false + + - name: Run script tests + run: node --test .github/scripts/label-sibling-conflicts.test.mjs + + - name: Label sibling pull request conflicts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + DRY_RUN: ${{ inputs.dry_run == true && 'true' || 'false' }} + run: node .github/scripts/label-sibling-conflicts.mjs