diff --git a/.github/workflows/require_issue_link.yml b/.github/workflows/require_issue_link.yml new file mode 100644 index 000000000..6ee800f24 --- /dev/null +++ b/.github/workflows/require_issue_link.yml @@ -0,0 +1,413 @@ +# Require external PRs to reference an approved issue (e.g. Fixes #NNN) and +# the PR author to be assigned to that issue. On failure the PR is +# labeled "missing-issue-link", commented on, and closed. +# +# Maintainer override: an org member can reopen the PR or remove +# "missing-issue-link" — both add "bypass-issue-check" and reopen. +# +# Dependency: tag-external-contributions.yml must apply the "external" label +# first. This workflow does NOT trigger on "opened" (new PRs have no labels +# yet, so the gate would always skip). + +name: Require Issue Link + +on: + pull_request_target: + types: [edited, reopened, labeled, unlabeled] + +# ────────────────────────────────────────────────────────────────────────────── +# Enforcement gate: set to 'true' to activate the issue link requirement. +# When 'false', the workflow still runs the check logic (useful for dry-run +# visibility) but will NOT label, comment, close, or fail PRs. +# ────────────────────────────────────────────────────────────────────────────── +env: + ENFORCE_ISSUE_LINK: "true" + +permissions: + contents: read + +jobs: + check-issue-link: + # Run when the "external" label is added, on edit/reopen if already labeled, + # or when "missing-issue-link" is removed (triggers maintainer override check). + # Skip entirely when the PR already carries "trusted-contributor" or + # "bypass-issue-check". + if: >- + !contains(github.event.pull_request.labels.*.name, 'trusted-contributor') && + !contains(github.event.pull_request.labels.*.name, 'bypass-issue-check') && + ( + (github.event.action == 'labeled' && github.event.label.name == 'external') || + (github.event.action == 'unlabeled' && github.event.label.name == 'missing-issue-link' && contains(github.event.pull_request.labels.*.name, 'external')) || + (github.event.action != 'labeled' && github.event.action != 'unlabeled' && contains(github.event.pull_request.labels.*.name, 'external')) + ) + runs-on: ubuntu-latest + permissions: + actions: write + pull-requests: write + + steps: + - name: Check for issue link and assignee + id: check-link + uses: actions/github-script@v8 + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const action = context.payload.action; + + // ── Helper: ensure a label exists, then add it to the PR ──────── + async function ensureAndAddLabel(labelName, color) { + try { + await github.rest.issues.getLabel({ owner, repo, name: labelName }); + } catch (e) { + if (e.status !== 404) throw e; + try { + await github.rest.issues.createLabel({ owner, repo, name: labelName, color }); + } catch (createErr) { + // 422 = label was created by a concurrent run between our + // GET and POST — safe to ignore. + if (createErr.status !== 422) throw createErr; + } + } + await github.rest.issues.addLabels({ + owner, repo, issue_number: prNumber, labels: [labelName], + }); + } + + // ── Helper: check if sender is an active org member ───────────── + async function senderIsOrgMember() { + const sender = context.payload.sender?.login; + if (!sender) { + throw new Error('Event has no sender — cannot check org membership'); + } + try { + const { data: membership } = await github.rest.orgs.getMembershipForUser({ + org: owner, + username: sender, + }); + if (membership.state === 'active') { + return { isMember: true, login: sender }; + } + console.log(`${sender} is an org member but state is "${membership.state}"`); + return { isMember: false, login: sender }; + } catch (e) { + if (e.status === 404) { + console.log(`${sender} is not an org member`); + return { isMember: false, login: sender }; + } + const status = e.status ?? 'unknown'; + throw new Error( + `Membership check failed for ${sender} (HTTP ${status}): ${e.message}`, + ); + } + } + + // ── Helper: apply maintainer bypass (shared by both override paths) ── + async function applyMaintainerBypass(reason) { + console.log(reason); + + // Remove missing-issue-link if present + try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: prNumber, name: 'missing-issue-link', + }); + } catch (e) { + if (e.status !== 404) throw e; + } + + // Reopen before adding bypass label — a failed reopen is more + // actionable than a closed PR with a bypass label stuck on it. + if (context.payload.pull_request.state === 'closed') { + try { + await github.rest.pulls.update({ + owner, repo, pull_number: prNumber, state: 'open', + }); + console.log(`Reopened PR #${prNumber}`); + } catch (e) { + // 422 if head branch deleted; 403 if permissions insufficient. + // Bypass labels still apply — maintainer can reopen manually. + core.warning( + `Could not reopen PR #${prNumber} (HTTP ${e.status ?? 'unknown'}): ${e.message}. ` + + `Bypass labels were applied — a maintainer may need to reopen manually.`, + ); + } + } + + // Add bypass-issue-check so future triggers skip enforcement + await ensureAndAddLabel('bypass-issue-check', '0e8a16'); + + core.setOutput('has-link', 'true'); + core.setOutput('is-assigned', 'true'); + } + + // ── Maintainer override: removed "missing-issue-link" label ───── + if (action === 'unlabeled') { + const { isMember, login } = await senderIsOrgMember(); + if (isMember) { + await applyMaintainerBypass( + `Maintainer ${login} removed missing-issue-link from PR #${prNumber} — bypassing enforcement`, + ); + return; + } + // Non-member removed the label — re-add it defensively and + // set failure outputs so downstream steps (comment, close) fire. + // NOTE: addLabels fires a "labeled" event, but the job-level gate + // only matches labeled events for "external", so no re-trigger. + console.log(`Non-member ${login} removed missing-issue-link — re-adding`); + try { + await ensureAndAddLabel('missing-issue-link', 'b76e79'); + } catch (e) { + core.warning( + `Failed to re-add missing-issue-link (HTTP ${e.status ?? 'unknown'}): ${e.message}. ` + + `Downstream step will retry.`, + ); + } + core.setOutput('has-link', 'false'); + core.setOutput('is-assigned', 'false'); + return; + } + + // ── Maintainer override: reopened PR with "missing-issue-link" ── + const prLabels = context.payload.pull_request.labels.map(l => l.name); + if (action === 'reopened' && prLabels.includes('missing-issue-link')) { + const { isMember, login } = await senderIsOrgMember(); + if (isMember) { + await applyMaintainerBypass( + `Maintainer ${login} reopened PR #${prNumber} — bypassing enforcement`, + ); + return; + } + console.log(`Non-member ${login} reopened PR — proceeding with check`); + } + + // ── Fetch live labels (race guard) ────────────────────────────── + const { data: liveLabels } = await github.rest.issues.listLabelsOnIssue({ + owner, repo, issue_number: prNumber, + }); + const liveNames = liveLabels.map(l => l.name); + if (liveNames.includes('trusted-contributor') || liveNames.includes('bypass-issue-check')) { + console.log('PR has trusted-contributor or bypass-issue-check label — bypassing'); + core.setOutput('has-link', 'true'); + core.setOutput('is-assigned', 'true'); + return; + } + + const body = context.payload.pull_request.body || ''; + const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*#(\d+)/gi; + const matches = [...body.matchAll(pattern)]; + + if (matches.length === 0) { + console.log('No issue link found in PR body'); + core.setOutput('has-link', 'false'); + core.setOutput('is-assigned', 'false'); + return; + } + + const issues = matches.map(m => `#${m[1]}`).join(', '); + console.log(`Found issue link(s): ${issues}`); + core.setOutput('has-link', 'true'); + + // Check whether the PR author is assigned to at least one linked issue + const prAuthor = context.payload.pull_request.user.login; + const MAX_ISSUES = 5; + const allIssueNumbers = [...new Set(matches.map(m => parseInt(m[1], 10)))]; + const issueNumbers = allIssueNumbers.slice(0, MAX_ISSUES); + if (allIssueNumbers.length > MAX_ISSUES) { + core.warning( + `PR references ${allIssueNumbers.length} issues — only checking the first ${MAX_ISSUES}`, + ); + } + + let assignedToAny = false; + for (const num of issueNumbers) { + try { + const { data: issue } = await github.rest.issues.get({ + owner, repo, issue_number: num, + }); + const assignees = issue.assignees.map(a => a.login.toLowerCase()); + if (assignees.includes(prAuthor.toLowerCase())) { + console.log(`PR author "${prAuthor}" is assigned to #${num}`); + assignedToAny = true; + break; + } else { + console.log(`PR author "${prAuthor}" is NOT assigned to #${num} (assignees: ${assignees.join(', ') || 'none'})`); + } + } catch (error) { + if (error.status === 404) { + console.log(`Issue #${num} not found — skipping`); + } else { + // Non-404 errors (rate limit, server error) must not be + // silently skipped — they could cause false enforcement + // (closing a legitimate PR whose assignment can't be verified). + throw new Error( + `Cannot verify assignee for issue #${num} (${error.status}): ${error.message}`, + ); + } + } + } + + core.setOutput('is-assigned', assignedToAny ? 'true' : 'false'); + + - name: Add missing-issue-link label + if: >- + env.ENFORCE_ISSUE_LINK == 'true' && + (steps.check-link.outputs.has-link != 'true' || steps.check-link.outputs.is-assigned != 'true') + uses: actions/github-script@v8 + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const labelName = 'missing-issue-link'; + + // Ensure the label exists (no checkout/shared helper available) + try { + await github.rest.issues.getLabel({ owner, repo, name: labelName }); + } catch (e) { + if (e.status !== 404) throw e; + try { + await github.rest.issues.createLabel({ + owner, repo, name: labelName, color: 'b76e79', + }); + } catch (createErr) { + if (createErr.status !== 422) throw createErr; + } + } + + await github.rest.issues.addLabels({ + owner, repo, issue_number: prNumber, labels: [labelName], + }); + + - name: Remove missing-issue-link label and reopen PR + if: >- + env.ENFORCE_ISSUE_LINK == 'true' && + steps.check-link.outputs.has-link == 'true' && steps.check-link.outputs.is-assigned == 'true' + uses: actions/github-script@v8 + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: prNumber, name: 'missing-issue-link', + }); + } catch (error) { + if (error.status !== 404) throw error; + } + + // Reopen if this workflow previously closed the PR. We check the + // event payload labels (not live labels) because we already removed + // missing-issue-link above; the payload still reflects pre-step state. + const labels = context.payload.pull_request.labels.map(l => l.name); + if (context.payload.pull_request.state === 'closed' && labels.includes('missing-issue-link')) { + await github.rest.pulls.update({ + owner, + repo, + pull_number: prNumber, + state: 'open', + }); + console.log(`Reopened PR #${prNumber}`); + } + + - name: Post comment, close PR, and fail + if: >- + env.ENFORCE_ISSUE_LINK == 'true' && + (steps.check-link.outputs.has-link != 'true' || steps.check-link.outputs.is-assigned != 'true') + uses: actions/github-script@v8 + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const hasLink = '${{ steps.check-link.outputs.has-link }}' === 'true'; + const isAssigned = '${{ steps.check-link.outputs.is-assigned }}' === 'true'; + const marker = ''; + + let lines; + if (!hasLink) { + lines = [ + marker, + '**This PR has been automatically closed** because it does not link to an approved issue.', + '', + 'All external contributions must reference an approved issue or discussion. Please:', + '1. Find or [open an issue](https://github.com/' + owner + '/' + repo + '/issues/new/choose) describing the change', + '2. Wait for a maintainer to approve and assign you', + '3. Add `Fixes #`, `Closes #`, or `Resolves #` to your PR description and the PR will be reopened automatically', + '', + '*Maintainers: reopen this PR or remove the `missing-issue-link` label to bypass this check.*', + ]; + } else { + lines = [ + marker, + '**This PR has been automatically closed** because you are not assigned to the linked issue.', + '', + 'External contributors must be assigned to an issue before opening a PR for it. Please:', + '1. Comment on the linked issue to request assignment from a maintainer', + '2. Once assigned, edit your PR description and the PR will be reopened automatically', + '', + '*Maintainers: reopen this PR or remove the `missing-issue-link` label to bypass this check.*', + ]; + } + + const body = lines.join('\n'); + + // Deduplicate: check for existing comment with the marker + const comments = await github.paginate( + github.rest.issues.listComments, + { owner, repo, issue_number: prNumber, per_page: 100 }, + ); + const existing = comments.find(c => c.body && c.body.includes(marker)); + + if (!existing) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + console.log('Posted requirement comment'); + } else if (existing.body !== body) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + console.log('Updated existing comment with new message'); + } else { + console.log('Comment already exists — skipping'); + } + + // Close the PR + if (context.payload.pull_request.state === 'open') { + await github.rest.pulls.update({ + owner, + repo, + pull_number: prNumber, + state: 'closed', + }); + console.log(`Closed PR #${prNumber}`); + } + + // Cancel all other in-progress and queued workflow runs for this PR + const headSha = context.payload.pull_request.head.sha; + for (const status of ['in_progress', 'queued']) { + const runs = await github.paginate( + github.rest.actions.listWorkflowRunsForRepo, + { owner, repo, head_sha: headSha, status, per_page: 100 }, + ); + for (const run of runs) { + if (run.id === context.runId) continue; + try { + await github.rest.actions.cancelWorkflowRun({ + owner, repo, run_id: run.id, + }); + console.log(`Cancelled ${status} run ${run.id} (${run.name})`); + } catch (err) { + console.log(`Could not cancel run ${run.id}: ${err.message}`); + } + } + } + + const reason = !hasLink + ? 'PR must reference an issue using auto-close keywords (e.g., "Fixes #123").' + : 'PR author must be assigned to the linked issue.'; + core.setFailed(reason); diff --git a/.github/workflows/tag-external-contributions.yml b/.github/workflows/tag-external-contributions.yml new file mode 100644 index 000000000..00ae30d8f --- /dev/null +++ b/.github/workflows/tag-external-contributions.yml @@ -0,0 +1,442 @@ +# Automatically tag issues and pull requests as "external" or "internal" +# based on whether the author is a member of the langchain-ai GitHub +# organization, and apply contributor tier labels to external contributors +# based on their merged PR history. +# +# NOTE: This repo does not have a separate pr_labeler.yml — this workflow +# handles both issues and PRs. +# +# Setup Requirements: +# 1. Create a GitHub App with permissions: +# - Repository: Issues (write), Pull requests (write) +# - Organization: Members (read) +# 2. Install the app on your organization and this repository +# 3. Add these repository secrets: +# - ORG_MEMBERSHIP_APP_ID: Your app's ID +# - ORG_MEMBERSHIP_APP_PRIVATE_KEY: Your app's private key +# +# The GitHub App token is required to check private organization membership. +# Without it, the workflow will fail. + +name: Tag External Contributions + +on: + issues: + types: [opened] + pull_request_target: + types: [opened] + workflow_dispatch: + inputs: + backfill_type: + description: "Backfill type (for initial run)" + default: "both" + type: choice + options: + - prs + - issues + - both + max_items: + description: "Maximum number of items to process" + default: "100" + type: string + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + tag-external: + if: github.event_name != 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.ORG_MEMBERSHIP_APP_ID }} + private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }} + + - name: Check if contributor is external + if: steps.app-token.outcome == 'success' + id: check-membership + uses: actions/github-script@v8 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const { owner, repo } = context.repo; + const author = context.payload.sender.login; + const senderType = context.payload.sender.type; + + if (senderType === 'Bot') { + console.log(`${author} is a Bot — treating as internal`); + core.setOutput('is-external', 'false'); + return; + } + + try { + const membership = await github.rest.orgs.getMembershipForUser({ + org: 'langchain-ai', + username: author, + }); + const isExternal = membership.data.state !== 'active'; + console.log( + isExternal + ? `${author} has pending membership — treating as external` + : `${author} is an active member of langchain-ai`, + ); + core.setOutput('is-external', isExternal ? 'true' : 'false'); + } catch (e) { + if (e.status === 404) { + console.log(`${author} is not a member of langchain-ai`); + core.setOutput('is-external', 'true'); + } else { + throw new Error( + `Membership check failed for ${author} (${e.status}): ${e.message}`, + ); + } + } + + # Apply tier label BEFORE the external/internal labels so that + # "trusted-contributor" is already present when the "external" labeled + # event fires and triggers require_issue_link.yml. + - name: Apply contributor tier label + if: steps.check-membership.outputs.is-external == 'true' + uses: actions/github-script@v8 + with: + # Use App token so the "labeled" event propagates to downstream + # workflows (e.g. require_issue_link.yml). + github-token: ${{ steps.app-token.outputs.token }} + script: | + const { owner, repo } = context.repo; + const isPR = context.eventName === 'pull_request_target'; + const item = isPR + ? context.payload.pull_request + : context.payload.issue; + const author = item.user.login; + const issueNumber = item.number; + + const TRUSTED_THRESHOLD = 5; + const LABEL_COLOR = 'b76e79'; + + let mergedCount; + try { + const result = await github.rest.search.issuesAndPullRequests({ + q: `repo:${owner}/${repo} is:pr is:merged author:"${author}"`, + per_page: 1, + }); + mergedCount = result?.data?.total_count; + } catch (error) { + if (error?.status !== 422) throw error; + core.warning(`Search failed for ${author}; skipping tier label.`); + return; + } + + if (mergedCount == null) { + core.warning(`Search response missing total_count for ${author}; skipping tier label.`); + return; + } + + const tierLabel = mergedCount >= TRUSTED_THRESHOLD ? 'trusted-contributor' : null; + + if (tierLabel) { + try { + await github.rest.issues.getLabel({ owner, repo, name: tierLabel }); + } catch (e) { + if (e.status !== 404) throw e; + try { + await github.rest.issues.createLabel({ owner, repo, name: tierLabel, color: LABEL_COLOR }); + } catch (createErr) { + if (createErr.status !== 422) throw createErr; + } + } + await github.rest.issues.addLabels({ + owner, repo, issue_number: issueNumber, labels: [tierLabel], + }); + console.log(`Applied '${tierLabel}' to #${issueNumber} (${mergedCount} merged PRs)`); + } else { + console.log(`No tier label for ${author} (${mergedCount} merged PRs)`); + } + + - name: Add external label to issue + if: steps.check-membership.outputs.is-external == 'true' && github.event_name == 'issues' + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const issue_number = context.payload.issue.number; + await github.rest.issues.addLabels({ + owner, repo, issue_number, labels: ['external'], + }); + console.log(`Added 'external' label to issue #${issue_number}`); + + - name: Add external label to pull request + if: steps.check-membership.outputs.is-external == 'true' && github.event_name == 'pull_request_target' + uses: actions/github-script@v8 + with: + # Use App token so the "labeled" event propagates to downstream + # workflows (e.g. require_issue_link.yml). Events created by the + # default GITHUB_TOKEN do not trigger additional workflow runs. + github-token: ${{ steps.app-token.outputs.token }} + script: | + const { owner, repo } = context.repo; + const issue_number = context.payload.pull_request.number; + await github.rest.issues.addLabels({ + owner, repo, issue_number, labels: ['external'], + }); + console.log(`Added 'external' label to PR #${issue_number}`); + + - name: Add internal label to issue + if: steps.check-membership.outputs.is-external == 'false' && github.event_name == 'issues' + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const issue_number = context.payload.issue.number; + await github.rest.issues.addLabels({ + owner, repo, issue_number, labels: ['internal'], + }); + console.log(`Added 'internal' label to issue #${issue_number}`); + + - name: Add internal label to pull request + if: steps.check-membership.outputs.is-external == 'false' && github.event_name == 'pull_request_target' + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const issue_number = context.payload.pull_request.number; + await github.rest.issues.addLabels({ + owner, repo, issue_number, labels: ['internal'], + }); + console.log(`Added 'internal' label to PR #${issue_number}`); + + backfill: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: write + + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.ORG_MEMBERSHIP_APP_ID }} + private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }} + + - name: Backfill labels + uses: actions/github-script@v8 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const { owner, repo } = context.repo; + const rawMax = '${{ inputs.max_items }}'; + const maxItems = parseInt(rawMax, 10); + if (isNaN(maxItems) || maxItems <= 0) { + core.setFailed(`Invalid max_items: "${rawMax}" — must be a positive integer`); + return; + } + const backfillType = '${{ inputs.backfill_type }}'; + + const TRUSTED_THRESHOLD = 5; + const LABEL_COLOR = 'b76e79'; + + const tierLabels = ['trusted-contributor']; + + // ── Helpers (inlined from pr-labeler.js) ───────────────────────── + + async function ensureLabel(name) { + try { + await github.rest.issues.getLabel({ owner, repo, name }); + } catch (e) { + if (e.status !== 404) throw e; + try { + await github.rest.issues.createLabel({ owner, repo, name, color: LABEL_COLOR }); + } catch (createErr) { + if (createErr.status !== 422) throw createErr; + } + } + } + + async function checkMembership(author, userType) { + if (userType === 'Bot') { + console.log(`${author} is a Bot — treating as internal`); + return { isExternal: false }; + } + try { + const membership = await github.rest.orgs.getMembershipForUser({ + org: 'langchain-ai', + username: author, + }); + const isExternal = membership.data.state !== 'active'; + console.log( + isExternal + ? `${author} has pending membership — treating as external` + : `${author} is an active member of langchain-ai`, + ); + return { isExternal }; + } catch (e) { + if (e.status === 404) { + console.log(`${author} is not a member of langchain-ai`); + return { isExternal: true }; + } + throw new Error( + `Membership check failed for ${author} (${e.status}): ${e.message}`, + ); + } + } + + async function getContributorInfo(contributorCache, author, userType) { + if (contributorCache.has(author)) return contributorCache.get(author); + + const { isExternal } = await checkMembership(author, userType); + + let mergedCount = null; + if (isExternal) { + try { + const result = await github.rest.search.issuesAndPullRequests({ + q: `repo:${owner}/${repo} is:pr is:merged author:"${author}"`, + per_page: 1, + }); + mergedCount = result?.data?.total_count ?? null; + } catch (e) { + if (e?.status !== 422) throw e; + core.warning(`Search failed for ${author}; skipping tier.`); + } + } + + const info = { isExternal, mergedCount }; + contributorCache.set(author, info); + return info; + } + + // ── Setup ──────────────────────────────────────────────────────── + + for (const name of tierLabels) { + await ensureLabel(name); + } + + const contributorCache = new Map(); + + let processed = 0; + let failures = 0; + + // ── Backfill PRs ───────────────────────────────────────────────── + + if (backfillType === 'prs' || backfillType === 'both') { + const prs = await github.paginate(github.rest.pulls.list, { + owner, repo, state: 'open', per_page: 100, + }); + + for (const pr of prs) { + if (processed >= maxItems) break; + + try { + const author = pr.user.login; + const info = await getContributorInfo(contributorCache, author, pr.user.type); + + const labels = [info.isExternal ? 'external' : 'internal']; + if (info.isExternal && info.mergedCount != null && info.mergedCount >= TRUSTED_THRESHOLD) { + labels.push('trusted-contributor'); + } + + // Ensure all labels exist before batch add + for (const name of labels) { + await ensureLabel(name); + } + + // Remove stale tier labels + const currentLabels = (await github.paginate( + github.rest.issues.listLabelsOnIssue, + { owner, repo, issue_number: pr.number, per_page: 100 }, + )).map(l => l.name ?? ''); + for (const name of currentLabels) { + if (tierLabels.includes(name) && !labels.includes(name)) { + try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: pr.number, name, + }); + } catch (e) { + if (e.status !== 404) throw e; + } + } + } + + await github.rest.issues.addLabels({ + owner, repo, issue_number: pr.number, labels, + }); + console.log(`PR #${pr.number} (${author}): ${labels.join(', ')}`); + processed++; + } catch (e) { + failures++; + core.warning(`Failed to process PR #${pr.number}: ${e.message}`); + } + } + } + + // ── Backfill issues ────────────────────────────────────────────── + + if (backfillType === 'issues' || backfillType === 'both') { + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner, repo, state: 'open', per_page: 100, + }); + + for (const issue of issues) { + if (processed >= maxItems) break; + if (issue.pull_request) continue; + + try { + const author = issue.user.login; + const info = await getContributorInfo(contributorCache, author, issue.user.type); + + const labels = [info.isExternal ? 'external' : 'internal']; + if (info.isExternal && info.mergedCount != null && info.mergedCount >= TRUSTED_THRESHOLD) { + labels.push('trusted-contributor'); + } + + // Ensure all labels exist before batch add + for (const name of labels) { + await ensureLabel(name); + } + + // Remove stale tier labels + const currentLabels = (await github.paginate( + github.rest.issues.listLabelsOnIssue, + { owner, repo, issue_number: issue.number, per_page: 100 }, + )).map(l => l.name ?? ''); + for (const name of currentLabels) { + if (tierLabels.includes(name) && !labels.includes(name)) { + try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: issue.number, name, + }); + } catch (e) { + if (e.status !== 404) throw e; + } + } + } + + await github.rest.issues.addLabels({ + owner, repo, issue_number: issue.number, labels, + }); + console.log(`Issue #${issue.number} (${author}): ${labels.join(', ')}`); + processed++; + } catch (e) { + failures++; + core.warning(`Failed to process issue #${issue.number}: ${e.message}`); + } + } + } + + console.log(`\nBackfill complete. Processed ${processed} items, ${failures} failures. ${contributorCache.size} unique authors.`);