PR Labels #5147
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Assisted-by: Claude Code / Opus 4.6 (Anthropic) | |
| name: PR Labels | |
| on: | |
| pull_request_target: | |
| types: [opened, synchronize, ready_for_review, converted_to_draft, edited, reopened, closed] | |
| # check_suite fires on the base repo for all PRs, including cross-fork PRs | |
| # where workflow_run doesn't trigger. Filter to non-GitHub-Actions suites to | |
| # avoid duplicating runs already handled by workflow_run. | |
| check_suite: | |
| types: [completed] | |
| workflow_run: | |
| workflows: ["Linting", "Unit Tests", "Sanity", "PR Review Trigger", "Codecov", "SonarCloud"] | |
| types: [completed] | |
| permissions: | |
| contents: read | |
| issues: write | |
| pull-requests: write | |
| checks: read | |
| jobs: | |
| label: | |
| if: >- | |
| github.event_name != 'check_suite' | |
| || github.event.check_suite.app.slug != 'github-actions' | |
| concurrency: | |
| group: pr-labels-${{ github.event.check_suite.head_sha || github.event.workflow_run.head_sha || github.event.pull_request.head.sha || github.run_id }} | |
| cancel-in-progress: true | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 | |
| id: app-token | |
| with: | |
| app-id: ${{ vars.ANSIBLE_CICD_BOT_ORG_RW_TOKEN_APP_ID }} | |
| private-key: ${{ secrets.ANSIBLE_CICD_BOT_ORG_RW_TOKEN_PRIVATE_KEY }} | |
| owner: ansible | |
| - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 | |
| id: labeler | |
| env: | |
| USERNAME_MAPPING: ${{ secrets.USERNAME_MAPPING }} | |
| ORG_TOKEN: ${{ steps.app-token.outputs.token }} | |
| with: | |
| script: | | |
| const LABELS = { | |
| 'Ready-for-review': { color: '0e8a16', description: 'PR is ready for review' }, | |
| 'fix-ci': { color: 'd93f0b', description: 'CI is failing — fix before review' }, | |
| 'blocked': { color: 'b60205', description: 'PR is blocked' }, | |
| 'WIP': { color: 'fbca04', description: 'Work in progress' }, | |
| 'Review-provided': { color: '1d76db', description: 'Review has been submitted' }, | |
| 'Community': { color: '5319e7', description: 'Community contribution' }, | |
| 'Ready-to-merge': { color: '0e8a16', description: 'PR is approved and CI is passing' }, | |
| 'Debuggernaut': { color: '00008b', description: 'PR created by the CI/CD bot' }, | |
| }; | |
| const { owner, repo } = context.repo; | |
| // --- Determine PR number --- | |
| let prNumber; | |
| if (context.eventName === 'check_suite') { | |
| // check_suite.pull_requests is populated for same-repo PRs (fast path); | |
| // for fork PRs it's empty, so fall back to paginating all open PRs. | |
| const csPulls = context.payload.check_suite.pull_requests || []; | |
| if (csPulls.length > 0) { | |
| prNumber = csPulls[0].number; | |
| } else { | |
| const headSha = context.payload.check_suite.head_sha; | |
| const pulls = await github.paginate(github.rest.pulls.list, { | |
| owner, repo, state: 'open', per_page: 100, | |
| }); | |
| const matched = pulls.filter(p => p.head.sha === headSha); | |
| if (matched.length === 0) { | |
| core.info('No open PR found for this check suite'); | |
| return; | |
| } | |
| prNumber = matched[0].number; | |
| } | |
| } else if (context.eventName === 'workflow_run') { | |
| const sourceEvent = context.payload.workflow_run.event; | |
| if (!['pull_request', 'pull_request_review', 'workflow_run'].includes(sourceEvent)) { | |
| core.info(`Ignoring workflow_run from ${sourceEvent}`); | |
| return; | |
| } | |
| const prs = context.payload.workflow_run.pull_requests; | |
| if (prs && prs.length > 0) { | |
| prNumber = prs[0].number; | |
| } else { | |
| const headBranch = context.payload.workflow_run.head_branch; | |
| const headRepo = context.payload.workflow_run.head_repository; | |
| let { data: pulls } = await github.rest.pulls.list({ | |
| owner, repo, state: 'open', | |
| head: `${headRepo.owner.login}:${headBranch}`, | |
| }); | |
| if (pulls.length === 0) { | |
| ({ data: pulls } = await github.rest.pulls.list({ | |
| owner, repo, state: 'open', per_page: 100, | |
| })); | |
| const headRepoFullName = headRepo?.full_name; | |
| pulls = pulls.filter(p => | |
| p.head.ref === headBranch && | |
| (!headRepoFullName || p.head.repo?.full_name === headRepoFullName) | |
| ); | |
| } | |
| if (pulls.length === 0) { | |
| core.info('No open PR found for this workflow run'); | |
| return; | |
| } | |
| prNumber = pulls[0].number; | |
| } | |
| } else { | |
| prNumber = context.payload.pull_request.number; | |
| } | |
| // --- Fetch fresh PR data --- | |
| const { data: pr } = await github.rest.pulls.get({ | |
| owner, repo, pull_number: prNumber, | |
| }); | |
| // --- Merged / closed: remove all managed labels --- | |
| if (pr.state !== 'open') { | |
| const currentLabels = new Set(pr.labels.map(l => l.name.toLowerCase())); | |
| for (const name of Object.keys(LABELS)) { | |
| if (currentLabels.has(name.toLowerCase())) { | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner, repo, issue_number: prNumber, name, | |
| }); | |
| core.info(`Removed label: ${name}`); | |
| } catch (e) { | |
| if (e.status !== 404) throw e; | |
| } | |
| } | |
| } | |
| core.info('PR closed/merged — removed all managed labels'); | |
| return; | |
| } | |
| // --- Ensure labels exist --- | |
| for (const [name, config] of Object.entries(LABELS)) { | |
| try { | |
| await github.rest.issues.getLabel({ owner, repo, name }); | |
| } catch (e) { | |
| if (e.status === 404) { | |
| try { | |
| await github.rest.issues.createLabel({ | |
| owner, repo, name, | |
| color: config.color, | |
| description: config.description, | |
| }); | |
| core.info(`Created label: ${name}`); | |
| } catch (createErr) { | |
| if (createErr.status !== 422) throw createErr; | |
| core.info(`Label ${name} already created by another run`); | |
| } | |
| } | |
| } | |
| } | |
| // --- Helpers --- | |
| const currentLabels = new Set(pr.labels.map(l => l.name.toLowerCase())); | |
| // --- Detect synchronize (new push) --- | |
| const isSynchronize = context.eventName === 'pull_request_target' | |
| && context.payload.action === 'synchronize'; | |
| // --- Detect manually changed labels --- | |
| const managedLabelNames = new Set(Object.keys(LABELS).map(n => n.toLowerCase())); | |
| const manualOverrides = new Set(); | |
| if (!isSynchronize) { | |
| try { | |
| const timelineEvents = await github.paginate( | |
| github.rest.issues.listEventsForTimeline, | |
| { owner, repo, issue_number: prNumber, per_page: 100 } | |
| ); | |
| const lastActorByLabel = new Map(); | |
| for (const event of timelineEvents) { | |
| if ((event.event === 'labeled' || event.event === 'unlabeled') | |
| && event.label | |
| && managedLabelNames.has(event.label.name.toLowerCase())) { | |
| lastActorByLabel.set(event.label.name.toLowerCase(), event.actor?.login || ''); | |
| } | |
| } | |
| for (const [labelName, actor] of lastActorByLabel) { | |
| if (actor && actor !== 'github-actions[bot]') { | |
| manualOverrides.add(labelName); | |
| core.info(`Label "${labelName}" last changed by ${actor} — skipping automation`); | |
| } | |
| } | |
| } catch (e) { | |
| core.warning(`Failed to fetch timeline events: ${e.message}`); | |
| } | |
| } | |
| function hasLabel(name) { | |
| return currentLabels.has(name.toLowerCase()); | |
| } | |
| async function addLabel(name) { | |
| if (manualOverrides.has(name.toLowerCase())) { | |
| core.info(`Skipping add "${name}" — manually overridden`); | |
| return; | |
| } | |
| if (!hasLabel(name)) { | |
| await github.rest.issues.addLabels({ | |
| owner, repo, issue_number: prNumber, labels: [name], | |
| }); | |
| currentLabels.add(name.toLowerCase()); | |
| core.info(`Added label: ${name}`); | |
| } | |
| } | |
| async function removeLabel(name) { | |
| if (manualOverrides.has(name.toLowerCase())) { | |
| core.info(`Skipping remove "${name}" — manually overridden`); | |
| return; | |
| } | |
| if (hasLabel(name)) { | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner, repo, issue_number: prNumber, name, | |
| }); | |
| currentLabels.delete(name.toLowerCase()); | |
| core.info(`Removed label: ${name}`); | |
| } catch (e) { | |
| if (e.status !== 404) throw e; | |
| } | |
| } | |
| } | |
| // --- Parse username mapping from secret (if available) --- | |
| let usernameMapping = {}; | |
| const mappingRaw = process.env.USERNAME_MAPPING; | |
| if (mappingRaw) { | |
| try { | |
| usernameMapping = JSON.parse(mappingRaw); | |
| core.info(`Username mapping loaded (${Object.keys(usernameMapping).length} entries)`); | |
| } catch (e) { | |
| core.warning(`Failed to parse USERNAME_MAPPING secret: ${e.message}`); | |
| } | |
| } | |
| // --- Auto-assign author --- | |
| if (context.payload.action === 'opened' && !pr.assignees.length) { | |
| try { | |
| const { status } = await github.request( | |
| 'GET /repos/{owner}/{repo}/assignees/{assignee}', | |
| { owner, repo, assignee: pr.user.login } | |
| ); | |
| if (status === 204) { | |
| await github.rest.issues.addAssignees({ | |
| owner, repo, issue_number: prNumber, | |
| assignees: [pr.user.login], | |
| }); | |
| core.info(`Assigned ${pr.user.login} to PR`); | |
| } | |
| } catch (e) { | |
| core.warning(`Could not auto-assign ${pr.user.login}: ${e.message}`); | |
| } | |
| } | |
| // --- WIP --- | |
| const isWip = pr.draft || /\b(WIP|DRAFT)\b/i.test(pr.title); | |
| if (isWip) { | |
| await addLabel('WIP'); | |
| await removeLabel('Ready-for-review'); | |
| } else { | |
| await removeLabel('WIP'); | |
| } | |
| // --- Community --- | |
| const internalAssociations = new Set(['MEMBER', 'COLLABORATOR', 'OWNER']); | |
| let isInternal = internalAssociations.has(pr.author_association); | |
| if (!isInternal) { | |
| try { | |
| const { Octokit } = require('@octokit/rest'); | |
| const orgOctokit = new Octokit({ auth: process.env.ORG_TOKEN }); | |
| const { status } = await orgOctokit.orgs.checkMembershipForUser({ | |
| org: 'ansible', | |
| username: pr.user.login, | |
| }); | |
| if (status === 204) { | |
| isInternal = true; | |
| core.info(`${pr.user.login} is a member of the ansible org`); | |
| } | |
| } catch (e) { | |
| if (e.status === 302) { | |
| isInternal = true; | |
| core.info(`${pr.user.login} is a member of the ansible org (requester is not org member)`); | |
| } else if (e.status !== 404) { | |
| core.warning(`Failed to check org membership for ${pr.user.login}: ${e.message}`); | |
| } | |
| } | |
| } | |
| if (!isInternal && Object.keys(usernameMapping).length > 0) { | |
| const normalizedMapping = Object.fromEntries( | |
| Object.entries(usernameMapping).map(([k, v]) => [k.toLowerCase(), v]) | |
| ); | |
| if (normalizedMapping[pr.user.login.toLowerCase()]) { | |
| isInternal = true; | |
| core.info(`${pr.user.login} found in USERNAME_MAPPING secret`); | |
| } | |
| } | |
| if (!isInternal) { | |
| await addLabel('Community'); | |
| } else { | |
| await removeLabel('Community'); | |
| } | |
| // --- Debuggernaut (CI/CD bot PRs) --- | |
| const botLogin = 'aap-platform-services-cicd-bot-sa'; | |
| if (pr.user.login.toLowerCase() === botLogin.toLowerCase()) { | |
| await addLabel('Debuggernaut'); | |
| } else { | |
| await removeLabel('Debuggernaut'); | |
| } | |
| // --- Reviews (latest state per reviewer) --- | |
| const { data: reviews } = await github.rest.pulls.listReviews({ | |
| owner, repo, pull_number: prNumber, | |
| }); | |
| const latestByReviewer = new Map(); | |
| for (const r of reviews) { | |
| if (['APPROVED', 'CHANGES_REQUESTED'].includes(r.state) && r.user.login !== pr.user.login) { | |
| latestByReviewer.set(r.user.login, r.state); | |
| } | |
| } | |
| const reviewStates = [...latestByReviewer.values()]; | |
| const hasReviews = reviewStates.length > 0; | |
| const hasApproval = reviewStates.includes('APPROVED'); | |
| const hasChangesRequested = reviewStates.includes('CHANGES_REQUESTED'); | |
| if (hasReviews) { | |
| await addLabel('Review-provided'); | |
| await removeLabel('Ready-for-review'); | |
| } else { | |
| await removeLabel('Review-provided'); | |
| } | |
| // --- CI status --- | |
| let ciPassed = false; | |
| if (isSynchronize) { | |
| await removeLabel('fix-ci'); | |
| await removeLabel('blocked'); | |
| await removeLabel('Ready-for-review'); | |
| await removeLabel('Ready-to-merge'); | |
| core.info('Cleared stale CI labels after new push'); | |
| } else { | |
| const { data: checkRuns } = await github.rest.checks.listForRef({ | |
| owner, repo, ref: pr.head.sha, per_page: 100, | |
| }); | |
| const ignoredChecks = new Set(['label']); | |
| const ignoredSuffixes = ['jewel-atf-tests-pull-request']; | |
| const ciChecks = checkRuns.check_runs.filter( | |
| cr => !ignoredChecks.has(cr.name) | |
| && !ignoredSuffixes.some(s => cr.name.endsWith(s)) | |
| ); | |
| const ignoredButTracked = checkRuns.check_runs.filter( | |
| cr => !ignoredChecks.has(cr.name) | |
| && ignoredSuffixes.some(s => cr.name.endsWith(s)) | |
| ); | |
| const allCompleted = ciChecks.length > 0 | |
| && ciChecks.every(cr => cr.status === 'completed') | |
| && ignoredButTracked.every(cr => cr.status === 'completed'); | |
| if (allCompleted) { | |
| const anyFailed = ciChecks.some(cr => | |
| cr.conclusion === 'failure' || cr.conclusion === 'timed_out' | |
| || cr.conclusion === 'cancelled' || cr.conclusion === 'action_required' | |
| ); | |
| if (anyFailed) { | |
| await addLabel('fix-ci'); | |
| await addLabel('blocked'); | |
| await removeLabel('Ready-for-review'); | |
| } else { | |
| ciPassed = true; | |
| await removeLabel('fix-ci'); | |
| await removeLabel('blocked'); | |
| if (!isWip && !hasReviews) { | |
| await addLabel('Ready-for-review'); | |
| } | |
| } | |
| } else if (ciChecks.length === 0) { | |
| core.info('No CI check runs found — skipping CI labels'); | |
| } else { | |
| core.info('CI checks still running — skipping CI labels'); | |
| } | |
| } | |
| // --- Ready to merge --- | |
| const alreadyReadyToMerge = hasLabel('Ready-to-merge'); | |
| if (hasApproval && !hasChangesRequested && ciPassed && !isWip) { | |
| await addLabel('Ready-to-merge'); | |
| await removeLabel('Ready-for-review'); | |
| if (!alreadyReadyToMerge) { | |
| const normalizedMappingForSlack = Object.fromEntries( | |
| Object.entries(usernameMapping).map(([k, v]) => [k.toLowerCase(), v]) | |
| ); | |
| let mention = ''; | |
| const authorSlackId = normalizedMappingForSlack[pr.user.login.toLowerCase()]; | |
| if (authorSlackId) { | |
| mention = `<@${authorSlackId}>`; | |
| } else { | |
| const reviewers = pr.requested_reviewers || []; | |
| for (const reviewer of reviewers) { | |
| const reviewerSlackId = normalizedMappingForSlack[reviewer.login.toLowerCase()]; | |
| if (reviewerSlackId) { | |
| mention = `<@${reviewerSlackId}>`; | |
| core.info(`Author not in USERNAME_MAPPING — mentioning reviewer ${reviewer.login}`); | |
| break; | |
| } | |
| } | |
| if (!mention) { | |
| mention = pr.user.login; | |
| } | |
| } | |
| core.setOutput('notify', 'true'); | |
| core.setOutput('slack_mention', mention); | |
| core.setOutput('pr_url', pr.html_url); | |
| const safeTitle = pr.title.replace(/[^\x20-\x7E]/g, '').slice(0, 200); | |
| core.setOutput('pr_title', safeTitle); | |
| } | |
| } else { | |
| await removeLabel('Ready-to-merge'); | |
| } | |
| - name: Notify Slack | |
| if: steps.labeler.outputs.notify == 'true' | |
| continue-on-error: true | |
| env: | |
| SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} | |
| SLACK_CHANNEL: ${{ secrets.SLACK_CHANNEL }} | |
| SLACK_MENTION: ${{ steps.labeler.outputs.slack_mention }} | |
| PR_URL: ${{ steps.labeler.outputs.pr_url }} | |
| PR_TITLE: ${{ steps.labeler.outputs.pr_title }} | |
| run: | | |
| if [ -z "$SLACK_BOT_TOKEN" ] || [ -z "$SLACK_CHANNEL" ]; then | |
| echo "Slack secrets not configured — skipping notification" | |
| exit 0 | |
| fi | |
| SAFE_TITLE=$(echo "$PR_TITLE" | sed 's/&/\&/g; s/</\</g; s/>/\>/g') | |
| MESSAGE="${SLACK_MENTION} your PR is ready to merge: <${PR_URL}|${SAFE_TITLE}>" | |
| PAYLOAD=$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$MESSAGE" \ | |
| '{channel: $channel, text: $text, unfurl_links: false}') | |
| curl -sf -X POST https://slack.com/api/chat.postMessage \ | |
| -H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \ | |
| -H "Content-Type: application/json" \ | |
| -d "$PAYLOAD" |