From a63ab2db7be9929d9455438e98262dc5e459056f Mon Sep 17 00:00:00 2001 From: Ludy Date: Mon, 20 Jul 2026 14:10:07 +0200 Subject: [PATCH] Add PR conflict labeler workflow (#7061) ### Motivation - Automatically detect and mark pull requests that have merge conflicts so maintainers can triage them quickly. - Ensure both new and existing open PRs are covered by running on PR events, a schedule, and manual dispatch. ### Description - Add workflow `: .github/workflows/pr-conflict-labeler.yml` that triggers on `pull_request_target`, a recurring `schedule`, and `workflow_dispatch` for manual runs. - The job uses the repository `stirling-bot` (`.github/actions/setup-bot`) and `actions/github-script` to poll `pull.mergeable` until GitHub computes mergeability and then add or remove the `has conflicts` label when `mergeable === false && mergeable_state === 'dirty'`. - The workflow idempotently ensures the `has conflicts` label exists (creates it if missing) and the repository label config ` .github/labels.yml` is updated to include `has conflicts` with an appropriate color and description. ### Testing - Parsed both ` .github/workflows/pr-conflict-labeler.yml` and ` .github/labels.yml` with Ruby `YAML.load_file`, which succeeded. - Installed and ran `actionlint` via `go install github.com/rhysd/actionlint/cmd/actionlint@latest` and validated the new workflow file with `actionlint`, which succeeded. ------ [Codex Task](https://chatgpt.com/codex/cloud/tasks/task_e_6a58b7061c2c8325b024980a4af8f632) --- .github/labels.yml | 3 + .github/workflows/pr-conflict-labeler.yml | 159 ++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 .github/workflows/pr-conflict-labeler.yml diff --git a/.github/labels.yml b/.github/labels.yml index d319aaa007..65861c0d4d 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -202,3 +202,6 @@ - name: "license-review-required" color: "EDEDED" description: "This PR requires a license review" +- name: "has conflicts" + color: "D93F0B" + description: "Pull request has merge conflicts with the base branch" diff --git a/.github/workflows/pr-conflict-labeler.yml b/.github/workflows/pr-conflict-labeler.yml new file mode 100644 index 0000000000..f6e1016a4c --- /dev/null +++ b/.github/workflows/pr-conflict-labeler.yml @@ -0,0 +1,159 @@ +name: PR conflict labeler + +on: + pull_request_target: + types: + - opened + - reopened + - synchronize + - edited + - ready_for_review + schedule: + - cron: "17 */6 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pr-conflict-labeler-${{ github.event.pull_request.number || 'all-open-prs' }} + cancel-in-progress: false + +env: + CONFLICT_LABEL: "has conflicts" + +jobs: + label-conflicts: + name: Label conflicted PRs + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: read + steps: + - name: Harden Runner + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + + - name: Check out the repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up stirling-bot token + id: setup-bot + uses: ./.github/actions/setup-bot + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + - name: Apply conflict label + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ steps.setup-bot.outputs.token }} + script: | + const conflictLabel = process.env.CONFLICT_LABEL; + const owner = context.repo.owner; + const repo = context.repo.repo; + const eventPullRequest = context.payload.pull_request; + + async function sleep(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); + } + + async function getPullRequestWithMergeableState(pullNumber) { + for (let attempt = 1; attempt <= 6; attempt += 1) { + const { data: pull } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + + if (pull.mergeable !== null) { + return pull; + } + + core.info(`PR #${pullNumber}: mergeable is not ready yet (attempt ${attempt}/6).`); + await sleep(5000); + } + + const { data: pull } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + return pull; + } + + async function ensureConflictLabel() { + try { + await github.rest.issues.getLabel({ + owner, + repo, + name: conflictLabel, + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + + await github.rest.issues.createLabel({ + owner, + repo, + name: conflictLabel, + color: 'D93F0B', + description: 'Pull request has merge conflicts with the base branch', + }); + core.info(`Created '${conflictLabel}' label.`); + } + } + + async function labelPullRequest(pull) { + const existingLabels = pull.labels.map((label) => label.name); + const hasConflictLabel = existingLabels.includes(conflictLabel); + const hasConflicts = pull.mergeable === false && pull.mergeable_state === 'dirty'; + + if (hasConflicts && !hasConflictLabel) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pull.number, + labels: [conflictLabel], + }); + core.info(`Added '${conflictLabel}' to PR #${pull.number}.`); + return; + } + + if (!hasConflicts && hasConflictLabel) { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: pull.number, + name: conflictLabel, + }); + core.info(`Removed '${conflictLabel}' from PR #${pull.number}.`); + return; + } + + core.info(`PR #${pull.number}: no label change needed (mergeable=${pull.mergeable}, mergeable_state=${pull.mergeable_state}).`); + } + + await ensureConflictLabel(); + + let pullNumbers; + if (eventPullRequest) { + pullNumbers = [eventPullRequest.number]; + } else { + const pulls = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + per_page: 100, + }); + pullNumbers = pulls.map((pull) => pull.number); + core.info(`Checking ${pullNumbers.length} open PR(s).`); + } + + for (const pullNumber of pullNumbers) { + const pull = await getPullRequestWithMergeableState(pullNumber); + await labelPullRequest(pull); + }