diff --git a/.github/scripts/label-conflicting-prs.mjs b/.github/scripts/label-conflicting-prs.mjs new file mode 100644 index 0000000000..127a241a88 --- /dev/null +++ b/.github/scripts/label-conflicting-prs.mjs @@ -0,0 +1,306 @@ +#!/usr/bin/env node + +// base 와 충돌한 PR 에 라벨을 붙여 «CI 가 돌지 않는 상태» 를 드러낸다 (#1028). +// +// `pull_request` 워크플로는 base 와 head 를 합친 가상 머지 커밋(`refs/pull//merge`)에서 +// 실행되는데, 충돌이면 GitHub 이 그 ref 를 만들지 못해 워크플로가 **트리거조차 되지 않는다.** +// 체크가 실패하는 게 아니라 아예 생성되지 않으므로, 저자도 리뷰어도 「아직 안 끝났나」로 읽고 +// 최신 커밋이 미검증인 채 남는다. 머지 자체는 mergeable_state=dirty 와 required check 의 +// pending 이 막으므로, 이 스크립트가 고치는 것은 게이트가 아니라 가시성이다. + +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +export const DEFAULT_LABEL = "conflict"; +export const COMMENT_MARKER = ""; + +const LABEL_COLOR = "e99695"; +const LABEL_DESCRIPTION = "base 와 충돌 — CI 가 트리거되지 않는다. base 를 병합하면 자동으로 떨어진다"; +const PULL_REQUEST_PAGE_SIZE = 50; +const UNKNOWN_RESOLVE_ATTEMPTS = 3; +const UNKNOWN_RESOLVE_DELAY_MS = 2000; + +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 + mergeable + baseRefName + headRefName + isDraft + labels(first: 30) { nodes { name } } + } + } + } +}`; + +/** + * 조회 결과를 라벨 조작 계획으로 바꾼다. + * + * `UNKNOWN` 은 GitHub 이 mergeable 을 아직 계산하지 못한 상태다. 충돌로 단정하면 멀쩡한 PR 에 + * 라벨이 붙고, 그 오탐은 라벨이 없는 것보다 나쁘다 — 건드리지 않고 다음 실행에 맡긴다. + */ +export function planLabelChanges({ pullRequests, label = DEFAULT_LABEL }) { + const toLabel = []; + const toUnlabel = []; + const skipped = []; + + for (const pullRequest of pullRequests) { + const labels = pullRequest.labels ?? []; + const labeled = labels.includes(label); + + if (pullRequest.mergeable === "CONFLICTING") { + // 이미 붙어 있으면 아무것도 하지 않는다 — 코멘트가 실행마다 쌓이면 소음이 된다. + if (!labeled) { + toLabel.push(pullRequest); + } + continue; + } + + if (pullRequest.mergeable === "MERGEABLE") { + if (labeled) { + toUnlabel.push(pullRequest); + } + continue; + } + + skipped.push(pullRequest); + } + + return { toLabel, toUnlabel, skipped }; +} + +export function renderConflictComment({ baseRefName }) { + return [ + COMMENT_MARKER, + `이 PR 은 \`${baseRefName}\` 와 충돌 상태이고, **그래서 CI 가 돌지 않는다.**`, + "", + "`pull_request` 워크플로는 base 와 head 를 합친 가상 머지 커밋(`refs/pull//merge`)에서", + "실행되는데, 충돌이면 GitHub 이 그 ref 를 만들지 못해 워크플로가 트리거조차 되지 않는다.", + "체크가 실패하는 것이 아니라 아예 생성되지 않으므로, 새 커밋을 올려도 `no checks reported`", + "인 채 **최신 커밋이 한 번도 검증되지 않은 상태**로 남는다 (#1028).", + "", + `\`${baseRefName}\` 를 병합해 충돌을 풀면 검증이 다시 돈다. 해소되면 이 라벨은 자동으로 떨어진다.`, + ].join("\n"); +} + +export function renderSummary({ plan, label, dryRun }) { + const lines = [ + `## 충돌 PR 라벨 (\`${label}\`)${dryRun ? " — dry run" : ""}`, + "", + `- 라벨 부착: ${plan.toLabel.length}건${formatNumbers(plan.toLabel)}`, + `- 라벨 제거: ${plan.toUnlabel.length}건${formatNumbers(plan.toUnlabel)}`, + `- 판정 보류(UNKNOWN): ${plan.skipped.length}건${formatNumbers(plan.skipped)}`, + ]; + return lines.join("\n"); +} + +function formatNumbers(pullRequests) { + if (pullRequests.length === 0) { + return ""; + } + return ` — ${pullRequests.map((pullRequest) => `#${pullRequest.number}`).join(", ")}`; +} + +function normalizePullRequest(node) { + return { + number: node.number, + mergeable: node.mergeable, + baseRefName: node.baseRefName, + headRefName: node.headRefName, + isDraft: node.isDraft, + 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; +} + +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(normalizePullRequest)); + + if (!page.pageInfo.hasNextPage) { + return pullRequests; + } + cursor = page.pageInfo.endCursor; + } +} + +const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +/** + * `UNKNOWN` 이 남아 있으면 다시 조회한다. + * + * GitHub 은 mergeable 을 비동기로 계산하고, 조회 자체가 계산을 촉발한다. 첫 응답에서 UNKNOWN 이 + * 흔하므로 몇 번 더 물어본 뒤에 판정한다. + */ +export async function resolveMergeStates( + api, + repository, + { attempts = UNKNOWN_RESOLVE_ATTEMPTS, delayMs = UNKNOWN_RESOLVE_DELAY_MS, wait = sleep, fetchPage = fetchOpenPullRequests } = {}, +) { + let pullRequests = await fetchPage(api, repository); + + for (let attempt = 1; attempt < attempts; attempt += 1) { + const unknown = pullRequests.filter((pullRequest) => pullRequest.mergeable === "UNKNOWN"); + if (unknown.length === 0) { + break; + } + await wait(delayMs * attempt); + pullRequests = await fetchPage(api, repository); + } + + return pullRequests; +} + +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 hasConflictComment(api, repository, number) { + const comments = await api(`/repos/${repository}/issues/${number}/comments?per_page=100`); + return (comments ?? []).some((comment) => (comment.body ?? "").includes(COMMENT_MARKER)); +} + +export async function applyPlan(api, repository, plan, { label, dryRun }) { + const failures = []; + + for (const pullRequest of plan.toLabel) { + try { + if (dryRun) { + console.log(`[dry-run] #${pullRequest.number} 라벨 부착 + 코멘트`); + continue; + } + await api(`/repos/${repository}/issues/${pullRequest.number}/labels`, { + method: "POST", + body: { labels: [label] }, + }); + // 사람이 라벨을 손으로 뗐다 붙였다 하는 경우까지 감안해 마커로 한 번 더 확인한다. + if (!(await hasConflictComment(api, repository, pullRequest.number))) { + await api(`/repos/${repository}/issues/${pullRequest.number}/comments`, { + method: "POST", + body: { body: renderConflictComment(pullRequest) }, + }); + } + console.log(`#${pullRequest.number} 라벨 부착 (base ${pullRequest.baseRefName})`); + } catch (error) { + failures.push(`#${pullRequest.number} 라벨 부착 실패: ${error.message}`); + } + } + + for (const pullRequest of plan.toUnlabel) { + try { + if (dryRun) { + console.log(`[dry-run] #${pullRequest.number} 라벨 제거`); + continue; + } + await api(`/repos/${repository}/issues/${pullRequest.number}/labels/${encodeURIComponent(label)}`, { + method: "DELETE", + allowNotFound: true, + }); + console.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; + if (!token || !repository) { + throw new Error("GITHUB_TOKEN 과 GITHUB_REPOSITORY 가 필요합니다."); + } + + const label = process.env.CONFLICT_LABEL ?? DEFAULT_LABEL; + const dryRun = process.env.DRY_RUN === "true"; + const api = createApi(token); + + const pullRequests = await resolveMergeStates(api, repository); + const plan = planLabelChanges({ pullRequests, label }); + + if (!dryRun && plan.toLabel.length > 0) { + await ensureLabelExists(api, repository, label); + } + + const failures = await applyPlan(api, repository, plan, { label, dryRun }); + const summary = renderSummary({ plan, 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-conflicting-prs.test.mjs b/.github/scripts/label-conflicting-prs.test.mjs new file mode 100644 index 0000000000..9537fc263b --- /dev/null +++ b/.github/scripts/label-conflicting-prs.test.mjs @@ -0,0 +1,307 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + COMMENT_MARKER, + DEFAULT_LABEL, + applyPlan, + ensureLabelExists, + planLabelChanges, + renderConflictComment, + renderSummary, + resolveMergeStates, +} from "./label-conflicting-prs.mjs"; + +/** + * 호출을 기록하는 가짜 API. `responses` 로 특정 경로의 응답을 지정한다. + */ +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, + mergeable: "MERGEABLE", + baseRefName: "develop", + headRefName: "feature/x", + isDraft: false, + labels: [], + ...overrides, + }; +} + +test("충돌 PR 에 라벨을 붙이고 해소된 PR 에서 뗀다", () => { + const plan = planLabelChanges({ + pullRequests: [ + pullRequest({ number: 10, mergeable: "CONFLICTING" }), + pullRequest({ number: 11, mergeable: "MERGEABLE", labels: [DEFAULT_LABEL] }), + ], + label: DEFAULT_LABEL, + }); + + assert.deepEqual(plan.toLabel.map((item) => item.number), [10]); + assert.deepEqual(plan.toUnlabel.map((item) => item.number), [11]); + assert.deepEqual(plan.skipped, []); +}); + +test("이미 라벨이 붙은 충돌 PR 은 다시 건드리지 않는다", () => { + // 실행마다 코멘트가 쌓이면 알림이 소음이 되고, 저자가 라벨 자체를 무시하게 된다. + const plan = planLabelChanges({ + pullRequests: [pullRequest({ number: 12, mergeable: "CONFLICTING", labels: [DEFAULT_LABEL, "bug"] })], + label: DEFAULT_LABEL, + }); + + assert.deepEqual(plan.toLabel, []); + assert.deepEqual(plan.toUnlabel, []); +}); + +test("라벨이 없는 정상 PR 은 아무 대상도 아니다", () => { + const plan = planLabelChanges({ + pullRequests: [pullRequest({ number: 13, mergeable: "MERGEABLE" })], + label: DEFAULT_LABEL, + }); + + assert.deepEqual(plan.toLabel, []); + assert.deepEqual(plan.toUnlabel, []); + assert.deepEqual(plan.skipped, []); +}); + +test("UNKNOWN 은 충돌로 단정하지 않고 보류한다", () => { + // GitHub 이 mergeable 을 계산 중인 상태다. 멀쩡한 PR 에 라벨이 붙는 오탐은 + // 라벨이 없는 것보다 나쁘다. + const plan = planLabelChanges({ + pullRequests: [pullRequest({ number: 14, mergeable: "UNKNOWN" })], + label: DEFAULT_LABEL, + }); + + assert.deepEqual(plan.toLabel, []); + assert.deepEqual(plan.skipped.map((item) => item.number), [14]); +}); + +test("UNKNOWN 이면서 라벨이 붙어 있어도 성급히 떼지 않는다", () => { + const plan = planLabelChanges({ + pullRequests: [pullRequest({ number: 15, mergeable: "UNKNOWN", labels: [DEFAULT_LABEL] })], + label: DEFAULT_LABEL, + }); + + assert.deepEqual(plan.toUnlabel, []); + assert.deepEqual(plan.skipped.map((item) => item.number), [15]); +}); + +test("스택 PR 도 base 가 develop 이 아닐 뿐 같은 규칙으로 본다", () => { + const plan = planLabelChanges({ + pullRequests: [pullRequest({ number: 16, mergeable: "CONFLICTING", baseRefName: "feat/601" })], + label: DEFAULT_LABEL, + }); + + assert.deepEqual(plan.toLabel.map((item) => item.baseRefName), ["feat/601"]); +}); + +test("사용자 지정 라벨 이름을 따른다", () => { + const plan = planLabelChanges({ + pullRequests: [ + pullRequest({ number: 17, mergeable: "CONFLICTING", labels: ["conflict"] }), + pullRequest({ number: 18, mergeable: "MERGEABLE", labels: ["needs-rebase"] }), + ], + label: "needs-rebase", + }); + + assert.deepEqual(plan.toLabel.map((item) => item.number), [17]); + assert.deepEqual(plan.toUnlabel.map((item) => item.number), [18]); +}); + +test("코멘트에 마커와 base 이름이 들어간다", () => { + const body = renderConflictComment({ baseRefName: "develop" }); + + assert.ok(body.startsWith(COMMENT_MARKER)); + assert.ok(body.includes("`develop` 를 병합해")); + assert.ok(body.includes("#1028")); +}); + +test("UNKNOWN 이 남아 있으면 다시 조회한다", async () => { + const pages = [ + [pullRequest({ number: 20, mergeable: "UNKNOWN" })], + [pullRequest({ number: 20, mergeable: "CONFLICTING" })], + ]; + let calls = 0; + const waits = []; + + const resolved = await resolveMergeStates(null, "o/r", { + attempts: 3, + delayMs: 5, + wait: async (ms) => { + waits.push(ms); + }, + fetchPage: async () => pages[Math.min(calls++, pages.length - 1)], + }); + + assert.equal(calls, 2); + assert.deepEqual(waits, [5]); + assert.equal(resolved[0].mergeable, "CONFLICTING"); +}); + +test("UNKNOWN 이 없으면 한 번만 조회한다", async () => { + let calls = 0; + + await resolveMergeStates(null, "o/r", { + attempts: 3, + delayMs: 5, + wait: async () => {}, + fetchPage: async () => { + calls += 1; + return [pullRequest({ number: 21, mergeable: "MERGEABLE" })]; + }, + }); + + assert.equal(calls, 1); +}); + +test("끝내 UNKNOWN 이면 시도 횟수만큼만 조회하고 보류로 남긴다", async () => { + let calls = 0; + + const resolved = await resolveMergeStates(null, "o/r", { + attempts: 3, + delayMs: 1, + wait: async () => {}, + fetchPage: async () => { + calls += 1; + return [pullRequest({ number: 22, mergeable: "UNKNOWN" })]; + }, + }); + + assert.equal(calls, 3); + assert.deepEqual(planLabelChanges({ pullRequests: resolved, label: DEFAULT_LABEL }).skipped.length, 1); +}); + +test("라벨을 붙이고 안내 코멘트를 한 번 남긴다", async () => { + const api = fakeApi({ responses: { "/repos/o/r/issues/40/comments": [] } }); + + const failures = await applyPlan( + api, + "o/r", + { toLabel: [{ number: 40, baseRefName: "develop" }], toUnlabel: [] }, + { label: DEFAULT_LABEL, dryRun: false }, + ); + + assert.deepEqual(failures, []); + const writes = api.calls.filter((call) => call.method === "POST"); + assert.deepEqual(writes.map((call) => call.apiPath), [ + "/repos/o/r/issues/40/labels", + "/repos/o/r/issues/40/comments", + ]); + assert.ok(writes[1].body.body.includes(COMMENT_MARKER)); +}); + +test("마커 코멘트가 이미 있으면 다시 달지 않는다", async () => { + // 사람이 라벨을 손으로 떼었다가 다시 붙는 경우에도 안내가 중복되지 않아야 한다. + const api = fakeApi({ + responses: { "/repos/o/r/issues/41/comments": [{ body: `${COMMENT_MARKER}\n이전 안내` }] }, + }); + + await applyPlan( + api, + "o/r", + { toLabel: [{ number: 41, baseRefName: "develop" }], toUnlabel: [] }, + { label: DEFAULT_LABEL, dryRun: false }, + ); + + const posts = api.calls.filter((call) => call.method === "POST").map((call) => call.apiPath); + assert.deepEqual(posts, ["/repos/o/r/issues/41/labels"]); +}); + +test("해소된 PR 에서 라벨만 지운다", async () => { + const api = fakeApi(); + + await applyPlan( + api, + "o/r", + { toLabel: [], toUnlabel: [{ number: 42 }] }, + { label: DEFAULT_LABEL, dryRun: false }, + ); + + assert.deepEqual(api.calls, [ + { apiPath: "/repos/o/r/issues/42/labels/conflict", method: "DELETE", body: undefined }, + ]); +}); + +test("dry run 은 아무것도 쓰지 않는다", async () => { + const api = fakeApi(); + + await applyPlan( + api, + "o/r", + { toLabel: [{ number: 43, baseRefName: "develop" }], toUnlabel: [{ number: 44 }] }, + { label: DEFAULT_LABEL, dryRun: true }, + ); + + assert.deepEqual(api.calls, []); +}); + +test("한 PR 이 실패해도 나머지를 처리하고 실패를 모아 돌려준다", async () => { + const api = fakeApi({ + responses: { "/repos/o/r/issues": [] }, + failOn: (apiPath) => apiPath === "/repos/o/r/issues/50/labels", + }); + + const failures = await applyPlan( + api, + "o/r", + { + toLabel: [ + { number: 50, baseRefName: "develop" }, + { number: 51, baseRefName: "develop" }, + ], + toUnlabel: [], + }, + { label: DEFAULT_LABEL, dryRun: false }, + ); + + assert.equal(failures.length, 1); + assert.match(failures[0], /#50 라벨 부착 실패/); + assert.ok(api.calls.some((call) => call.apiPath === "/repos/o/r/issues/51/labels")); +}); + +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/conflict": { name: "conflict" } } }); + await ensureLabelExists(existing, "o/r", DEFAULT_LABEL); + assert.equal(existing.calls.filter((call) => call.method === "POST").length, 0); +}); + +test("요약에 건수와 PR 번호가 남는다", () => { + const plan = planLabelChanges({ + pullRequests: [ + pullRequest({ number: 30, mergeable: "CONFLICTING" }), + pullRequest({ number: 31, mergeable: "MERGEABLE", labels: [DEFAULT_LABEL] }), + pullRequest({ number: 32, mergeable: "UNKNOWN" }), + ], + label: DEFAULT_LABEL, + }); + + const summary = renderSummary({ plan, label: DEFAULT_LABEL, dryRun: true }); + + assert.ok(summary.includes("dry run")); + assert.ok(summary.includes("라벨 부착: 1건 — #30")); + assert.ok(summary.includes("라벨 제거: 1건 — #31")); + assert.ok(summary.includes("판정 보류(UNKNOWN): 1건 — #32")); +}); diff --git a/.github/workflows/conflict-label.yml b/.github/workflows/conflict-label.yml new file mode 100644 index 0000000000..710b5802e7 --- /dev/null +++ b/.github/workflows/conflict-label.yml @@ -0,0 +1,56 @@ +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분 +# ``` +on: + workflow_dispatch: + inputs: + dry_run: + description: "라벨·코멘트를 쓰지 않고 계획만 출력" + required: false + # 자동 트리거가 없는 동안의 기본값은 안전한 쪽이다. 실제로 쓰려면 끄고 실행한다. + default: true + type: boolean + +# 실행이 겹치면 같은 PR 에 코멘트가 두 번 달릴 수 있다. 취소하지 않고 줄을 세운다. +concurrency: + group: conflict-label + cancel-in-progress: false + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + label: + name: Label conflicting pull requests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Clone repo + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Run script tests + run: node --test .github/scripts/label-conflicting-prs.test.mjs + + - name: Label conflicting pull requests + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + DRY_RUN: ${{ inputs.dry_run == true && 'true' || 'false' }} + run: node .github/scripts/label-conflicting-prs.mjs