1# Unified PR labeler — applies size, file-based, title-based, and2# contributor classification labels in a single sequential workflow.3#4# Consolidates pr_labeler_file.yml, pr_labeler_title.yml,5# pr_size_labeler.yml, and PR-handling from tag-external-contributions.yml6# into one workflow to eliminate race conditions from concurrent label7# mutations. tag-external-issues.yml remains active for issue-only8# labeling. Backfill lives in pr_labeler_backfill.yml.9#10# Config and shared logic live in .github/scripts/pr-labeler-config.json11# and .github/scripts/pr-labeler.js — update those when adding partners.12#13# Setup Requirements:14# 1. Create a GitHub App with permissions:15# - Repository: Pull requests (write)16# - Repository: Issues (write)17# - Organization: Members (read)18# 2. Install the app on your organization and this repository19# 3. Add these repository secrets:20# - ORG_MEMBERSHIP_APP_CLIENT_ID: Your app's client ID21# - ORG_MEMBERSHIP_APP_PRIVATE_KEY: Your app's private key22#23# The GitHub App token is required to check private organization membership24# and to propagate label events to downstream workflows.2526name: "🏷️ PR Labeler"2728on:29 # Safe since we're not checking out or running the PR's code.30 # NEVER CHECK OUT UNTRUSTED CODE FROM A PR's HEAD IN A pull_request_target JOB.31 # Doing so would allow attackers to execute arbitrary code in the context of your repository.32 pull_request_target:33 types: [opened, synchronize, reopened, edited]3435permissions:36 contents: read3738concurrency:39 # Separate opened events so external/tier labels are never lost to cancellation40 group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}-${{ github.event.action == 'opened' && 'opened' || 'update' }}41 cancel-in-progress: ${{ github.event.action != 'opened' }}4243jobs:44 label:45 if: github.repository_owner == 'langchain-ai'46 runs-on: ubuntu-latest47 permissions:48 contents: read49 pull-requests: write50 issues: write5152 steps:53 # Checks out the BASE branch (safe for pull_request_target — never54 # the PR head). Needed to load .github/scripts/pr-labeler*.55 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v65657 - name: Generate GitHub App token58 if: github.event.action == 'opened'59 id: app-token60 uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v361 with:62 client-id: ${{ secrets.ORG_MEMBERSHIP_APP_CLIENT_ID }}63 private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}6465 - name: Verify App token66 if: github.event.action == 'opened'67 run: |68 if [ -z "${{ steps.app-token.outputs.token }}" ]; then69 echo "::error::GitHub App token generation failed — cannot classify contributor"70 exit 171 fi7273 - name: Check org membership74 if: github.event.action == 'opened'75 id: check-membership76 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.077 with:78 github-token: ${{ steps.app-token.outputs.token }}79 script: |80 const { owner, repo } = context.repo;81 const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);8283 const author = context.payload.sender.login;84 const { isExternal } = await h.checkMembership(85 author, context.payload.sender.type,86 );87 core.setOutput('is-external', isExternal ? 'true' : 'false');8889 - name: Apply PR labels90 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.091 env:92 IS_EXTERNAL: ${{ steps.check-membership.outputs.is-external }}93 with:94 github-token: ${{ secrets.GITHUB_TOKEN }}95 script: |96 const { owner, repo } = context.repo;97 const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);9899 const pr = context.payload.pull_request;100 if (!pr) return;101 const prNumber = pr.number;102 const action = context.payload.action;103104 const toAdd = new Set();105 const toRemove = new Set();106107 const currentLabels = (await github.paginate(108 github.rest.issues.listLabelsOnIssue,109 { owner, repo, issue_number: prNumber, per_page: 100 },110 )).map(l => l.name ?? '');111112 // ── Size + file labels (skip on 'edited' — files unchanged) ──113 if (action !== 'edited') {114 for (const sl of h.sizeLabels) await h.ensureLabel(sl);115116 const files = await github.paginate(github.rest.pulls.listFiles, {117 owner, repo, pull_number: prNumber, per_page: 100,118 });119120 const { totalChanged, sizeLabel } = h.computeSize(files);121 toAdd.add(sizeLabel);122 for (const sl of h.sizeLabels) {123 if (currentLabels.includes(sl) && sl !== sizeLabel) toRemove.add(sl);124 }125 console.log(`Size: ${totalChanged} changed lines → ${sizeLabel}`);126127 for (const label of h.matchFileLabels(files)) {128 toAdd.add(label);129 }130 }131132 // ── Title-based labels ──133 const { labels: titleLabels, typeLabel } = h.matchTitleLabels(pr.title || '');134 for (const label of titleLabels) toAdd.add(label);135136 // Remove stale type labels only when a type was detected137 if (typeLabel) {138 for (const tl of h.allTypeLabels) {139 if (currentLabels.includes(tl) && !titleLabels.has(tl)) toRemove.add(tl);140 }141 }142143 // ── Internal label (only on open, non-external contributors) ──144 // IS_EXTERNAL is empty string on non-opened events (step didn't145 // run), so this guard is only true for opened + internal.146 if (action === 'opened' && process.env.IS_EXTERNAL === 'false') {147 toAdd.add('internal');148 }149150 // ── Apply changes ──151 // Ensure all labels we're about to add exist (addLabels returns152 // 422 if any label in the batch is missing, which would prevent153 // ALL labels from being applied).154 for (const name of toAdd) {155 await h.ensureLabel(name);156 }157158 for (const name of toRemove) {159 if (toAdd.has(name)) continue;160 try {161 await github.rest.issues.removeLabel({162 owner, repo, issue_number: prNumber, name,163 });164 } catch (e) {165 if (e.status !== 404) throw e;166 }167 }168169 const addList = [...toAdd];170 if (addList.length > 0) {171 await github.rest.issues.addLabels({172 owner, repo, issue_number: prNumber, labels: addList,173 });174 }175176 const removed = [...toRemove].filter(r => !toAdd.has(r));177 console.log(`PR #${prNumber}: +[${addList.join(', ')}] -[${removed.join(', ')}]`);178179 # Apply tier label BEFORE the external label so that180 # "trusted-contributor" is already present when the "external" labeled181 # event fires and triggers require_issue_link.yml.182 - name: Apply contributor tier label183 if: github.event.action == 'opened' && steps.check-membership.outputs.is-external == 'true'184 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0185 with:186 github-token: ${{ steps.app-token.outputs.token }}187 script: |188 const { owner, repo } = context.repo;189 const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);190191 const pr = context.payload.pull_request;192 await h.applyTierLabel(pr.number, pr.user.login);193194 - name: Add external label195 if: github.event.action == 'opened' && steps.check-membership.outputs.is-external == 'true'196 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0197 with:198 # Use App token so the "labeled" event propagates to downstream199 # workflows (e.g. require_issue_link.yml). Events created by the200 # default GITHUB_TOKEN do not trigger additional workflow runs.201 github-token: ${{ steps.app-token.outputs.token }}202 script: |203 const { owner, repo } = context.repo;204 const prNumber = context.payload.pull_request.number;205206 const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);207208 await h.ensureLabel('external');209 await github.rest.issues.addLabels({210 owner, repo,211 issue_number: prNumber,212 labels: ['external'],213 });214 console.log(`Added 'external' label to PR #${prNumber}`);
Findings
✓ No findings reported for this file.