Skip to content

ci: add the deterministic PR hygiene gate (extracted from #903) #2352

ci: add the deterministic PR hygiene gate (extracted from #903)

ci: add the deterministic PR hygiene gate (extracted from #903) #2352

Workflow file for this run

name: PR Labeler
# pull_request_target always loads this workflow from the repository DEFAULT
# branch (currently `main`), not from `dev`. Landing here on `dev` alone does
# not change live labeler behavior until the change is also on that default
# branch — same promotion model as enforce-issue-quality.yml.
on:
pull_request_target:
# labeled/unlabeled let a human type-label change enqueue a fresher run in the
# per-PR concurrency group, cancelling any in-flight title sync that started
# before the override (PR #518 race).
types: [opened, edited, synchronize, labeled, unlabeled]
concurrency:
group: pr-labeler-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
# pulls.get only needs read, but the issues label endpoints are shared with
# pull requests: adding or removing a label on a PR number is rejected with
# "Resource not accessible by integration" unless the token also carries
# pull_requests=write (the API reports `issues=write; pull_requests=write`
# in x-accepted-github-permissions). Read-only here silently worked while
# every run happened to be a no-op sync, and failed on the first PR that
# actually needed a label written.
pull-requests: write
issues: write
jobs:
label:
runs-on: ubuntu-latest
steps:
- name: Checkout labeler script (default-branch trusted code)
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Apply type label from PR title
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const {
planTypeLabelSync,
} = require('./.github/scripts/pr-labeler.cjs');
const title = context.payload.pull_request.title || '';
const pr = context.payload.pull_request.number;
const owner = context.repo.owner;
const repo = context.repo.repo;
// Refetch live PR title to avoid race with title edits.
const { data: livePr } = await github.rest.pulls.get({
owner, repo, pull_number: pr,
});
const liveTitle = livePr.title || title;
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner, repo, issue_number: pr,
});
// Issue event timeline (same number space as PRs). Used to detect a
// sticky human override — once someone other than github-actions[bot]
// changes a managed type label, we never overwrite that choice again
// (mirrors issue-quality maintainerOverride / no re-close after reopen).
const events = await github.paginate(github.rest.issues.listEvents, {
owner, repo, issue_number: pr, per_page: 100,
});
const plan = planTypeLabelSync({
title: liveTitle,
currentLabels: currentLabels.map((label) => label.name),
events,
});
if (plan.skip) {
core.info(`Skipping type-label sync for PR #${pr}: ${plan.reason}`);
return;
}
// Ensure the target label exists (create if missing).
try {
await github.rest.issues.getLabel({ owner, repo, name: plan.detected });
} catch (err) {
if (err.status === 404) {
const colors = {
enhancement: '0075ca',
bug: 'd73a4a',
documentation: '0075ca',
chore: 'e4e669',
};
try {
await github.rest.issues.createLabel({
owner, repo,
name: plan.detected,
color: colors[plan.detected] || 'ededed',
});
core.info(`Created missing label "${plan.detected}"`);
} catch (createErr) {
if (createErr.status !== 422) throw createErr;
core.info(`Label "${plan.detected}" was created concurrently; continuing.`);
}
} else {
throw err;
}
}
for (const label of plan.remove) {
await github.rest.issues.removeLabel({
owner, repo, issue_number: pr, name: label,
});
core.info(`Removed stale type label "${label}" from PR #${pr}`);
}
if (plan.add) {
await github.rest.issues.addLabels({
owner, repo, issue_number: pr, labels: [plan.add],
});
core.info(`Applied label "${plan.add}" to PR #${pr}`);
}