name: Labeler on: pull_request_target: types: [opened, synchronize, reopened] issues: types: [opened] workflow_dispatch: inputs: max_prs: description: "Maximum number of open PRs to process (0 = all)" required: false default: "200" per_page: description: "PRs per page (1-100)" required: false default: "50" permissions: {} jobs: label: permissions: contents: read pull-requests: write runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 id: app-token with: app-id: "2729701" private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5 with: configuration-path: .github/labeler.yml repo-token: ${{ steps.app-token.outputs.token }} sync-labels: true - name: Apply PR size label uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: github-token: ${{ steps.app-token.outputs.token }} script: | const pullRequest = context.payload.pull_request; if (!pullRequest) { return; } const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"]; const labelColor = "b76e79"; for (const label of sizeLabels) { try { await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: label, }); } catch (error) { if (error?.status !== 404) { throw error; } await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: label, color: labelColor, }); } } const files = await github.paginate(github.rest.pulls.listFiles, { owner: context.repo.owner, repo: context.repo.repo, pull_number: pullRequest.number, per_page: 100, }); const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]); const totalChangedLines = files.reduce((total, file) => { const path = file.filename ?? ""; if (path === "docs.acp.md" || path.startsWith("docs/") || excludedLockfiles.has(path)) { return total; } return total + (file.additions ?? 0) + (file.deletions ?? 0); }, 0); let targetSizeLabel = "size: XL"; if (totalChangedLines < 50) { targetSizeLabel = "size: XS"; } else if (totalChangedLines < 200) { targetSizeLabel = "size: S"; } else if (totalChangedLines < 500) { targetSizeLabel = "size: M"; } else if (totalChangedLines < 1000) { targetSizeLabel = "size: L"; } const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, { owner: context.repo.owner, repo: context.repo.repo, issue_number: pullRequest.number, per_page: 100, }); for (const label of currentLabels) { const name = label.name ?? ""; if (!sizeLabels.includes(name)) { continue; } if (name === targetSizeLabel) { continue; } await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pullRequest.number, name, }); } await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pullRequest.number, labels: [targetSizeLabel], }); - name: Apply maintainer or trusted-contributor label uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: github-token: ${{ steps.app-token.outputs.token }} script: | const login = context.payload.pull_request?.user?.login; if (!login) { return; } const repo = `${context.repo.owner}/${context.repo.repo}`; const trustedLabel = "trusted-contributor"; const experiencedLabel = "experienced-contributor"; const trustedThreshold = 4; const experiencedThreshold = 10; let isMaintainer = false; try { const membership = await github.rest.teams.getMembershipForUserInOrg({ org: context.repo.owner, team_slug: "maintainer", username: login, }); isMaintainer = membership?.data?.state === "active"; } catch (error) { if (error?.status !== 404) { throw error; } } if (isMaintainer) { await github.rest.issues.addLabels({ ...context.repo, issue_number: context.payload.pull_request.number, labels: ["maintainer"], }); return; } const mergedQuery = `repo:${repo} is:pr is:merged author:${login}`; let mergedCount = 0; try { const merged = await github.rest.search.issuesAndPullRequests({ q: mergedQuery, per_page: 1, }); mergedCount = merged?.data?.total_count ?? 0; } catch (error) { if (error?.status !== 422) { throw error; } core.warning(`Skipping merged search for ${login}; treating as 0.`); } if (mergedCount >= experiencedThreshold) { await github.rest.issues.addLabels({ ...context.repo, issue_number: context.payload.pull_request.number, labels: [experiencedLabel], }); return; } if (mergedCount >= trustedThreshold) { await github.rest.issues.addLabels({ ...context.repo, issue_number: context.payload.pull_request.number, labels: [trustedLabel], }); } backfill-pr-labels: if: github.event_name == 'workflow_dispatch' permissions: contents: read pull-requests: write runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 id: app-token with: app-id: "2729701" private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - name: Backfill PR labels uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: github-token: ${{ steps.app-token.outputs.token }} script: | const owner = context.repo.owner; const repo = context.repo.repo; const repoFull = `${owner}/${repo}`; const inputs = context.payload.inputs ?? {}; const maxPrsInput = inputs.max_prs ?? "200"; const perPageInput = inputs.per_page ?? "50"; const parsedMaxPrs = Number.parseInt(maxPrsInput, 10); const parsedPerPage = Number.parseInt(perPageInput, 10); const maxPrs = Number.isFinite(parsedMaxPrs) ? parsedMaxPrs : 200; const perPage = Number.isFinite(parsedPerPage) ? Math.min(100, Math.max(1, parsedPerPage)) : 50; const processAll = maxPrs <= 0; const maxCount = processAll ? Number.POSITIVE_INFINITY : Math.max(1, maxPrs); const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"]; const labelColor = "b76e79"; const trustedLabel = "trusted-contributor"; const experiencedLabel = "experienced-contributor"; const trustedThreshold = 4; const experiencedThreshold = 10; const contributorCache = new Map(); async function ensureSizeLabels() { for (const label of sizeLabels) { try { await github.rest.issues.getLabel({ owner, repo, name: label, }); } catch (error) { if (error?.status !== 404) { throw error; } await github.rest.issues.createLabel({ owner, repo, name: label, color: labelColor, }); } } } async function resolveContributorLabel(login) { if (contributorCache.has(login)) { return contributorCache.get(login); } let isMaintainer = false; try { const membership = await github.rest.teams.getMembershipForUserInOrg({ org: owner, team_slug: "maintainer", username: login, }); isMaintainer = membership?.data?.state === "active"; } catch (error) { if (error?.status !== 404) { throw error; } } if (isMaintainer) { contributorCache.set(login, "maintainer"); return "maintainer"; } const mergedQuery = `repo:${repoFull} is:pr is:merged author:${login}`; let mergedCount = 0; try { const merged = await github.rest.search.issuesAndPullRequests({ q: mergedQuery, per_page: 1, }); mergedCount = merged?.data?.total_count ?? 0; } catch (error) { if (error?.status !== 422) { throw error; } core.warning(`Skipping merged search for ${login}; treating as 0.`); } let label = null; if (mergedCount >= experiencedThreshold) { label = experiencedLabel; } else if (mergedCount >= trustedThreshold) { label = trustedLabel; } contributorCache.set(login, label); return label; } async function applySizeLabel(pullRequest, currentLabels, labelNames) { const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pullRequest.number, per_page: 100, }); const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]); const totalChangedLines = files.reduce((total, file) => { const path = file.filename ?? ""; if (path === "docs.acp.md" || path.startsWith("docs/") || excludedLockfiles.has(path)) { return total; } return total + (file.additions ?? 0) + (file.deletions ?? 0); }, 0); let targetSizeLabel = "size: XL"; if (totalChangedLines < 50) { targetSizeLabel = "size: XS"; } else if (totalChangedLines < 200) { targetSizeLabel = "size: S"; } else if (totalChangedLines < 500) { targetSizeLabel = "size: M"; } else if (totalChangedLines < 1000) { targetSizeLabel = "size: L"; } for (const label of currentLabels) { const name = label.name ?? ""; if (!sizeLabels.includes(name)) { continue; } if (name === targetSizeLabel) { continue; } await github.rest.issues.removeLabel({ owner, repo, issue_number: pullRequest.number, name, }); labelNames.delete(name); } if (!labelNames.has(targetSizeLabel)) { await github.rest.issues.addLabels({ owner, repo, issue_number: pullRequest.number, labels: [targetSizeLabel], }); labelNames.add(targetSizeLabel); } } async function applyContributorLabel(pullRequest, labelNames) { const login = pullRequest.user?.login; if (!login) { return; } const label = await resolveContributorLabel(login); if (!label) { return; } if (labelNames.has(label)) { return; } await github.rest.issues.addLabels({ owner, repo, issue_number: pullRequest.number, labels: [label], }); labelNames.add(label); } await ensureSizeLabels(); let page = 1; let processed = 0; while (processed < maxCount) { const remaining = maxCount - processed; const pageSize = processAll ? perPage : Math.min(perPage, remaining); const { data: pullRequests } = await github.rest.pulls.list({ owner, repo, state: "open", per_page: pageSize, page, }); if (pullRequests.length === 0) { break; } for (const pullRequest of pullRequests) { if (!processAll && processed >= maxCount) { break; } const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, { owner, repo, issue_number: pullRequest.number, per_page: 100, }); const labelNames = new Set( currentLabels.map((label) => label.name).filter((name) => typeof name === "string"), ); await applySizeLabel(pullRequest, currentLabels, labelNames); await applyContributorLabel(pullRequest, labelNames); processed += 1; } if (pullRequests.length < pageSize) { break; } page += 1; } core.info(`Processed ${processed} pull requests.`); label-issues: permissions: issues: write runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 id: app-token with: app-id: "2729701" private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - name: Apply maintainer or trusted-contributor label uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: github-token: ${{ steps.app-token.outputs.token }} script: | const login = context.payload.issue?.user?.login; if (!login) { return; } const repo = `${context.repo.owner}/${context.repo.repo}`; const trustedLabel = "trusted-contributor"; const experiencedLabel = "experienced-contributor"; const trustedThreshold = 4; const experiencedThreshold = 10; let isMaintainer = false; try { const membership = await github.rest.teams.getMembershipForUserInOrg({ org: context.repo.owner, team_slug: "maintainer", username: login, }); isMaintainer = membership?.data?.state === "active"; } catch (error) { if (error?.status !== 404) { throw error; } } if (isMaintainer) { await github.rest.issues.addLabels({ ...context.repo, issue_number: context.payload.issue.number, labels: ["maintainer"], }); return; } const mergedQuery = `repo:${repo} is:pr is:merged author:${login}`; let mergedCount = 0; try { const merged = await github.rest.search.issuesAndPullRequests({ q: mergedQuery, per_page: 1, }); mergedCount = merged?.data?.total_count ?? 0; } catch (error) { if (error?.status !== 422) { throw error; } core.warning(`Skipping merged search for ${login}; treating as 0.`); } if (mergedCount >= experiencedThreshold) { await github.rest.issues.addLabels({ ...context.repo, issue_number: context.payload.issue.number, labels: [experiencedLabel], }); return; } if (mergedCount >= trustedThreshold) { await github.rest.issues.addLabels({ ...context.repo, issue_number: context.payload.issue.number, labels: [trustedLabel], }); }