name: "Pull Request Labeler" on: pull_request_target: types: [opened, synchronize, reopened, labeled, unlabeled] # Do not execute arbitrary code on this workflow. # See warnings at https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: labeler: permissions: contents: read pull-requests: write runs-on: ubuntu-24.04-arm steps: - name: Auto-label PR uses: actions/github-script@v9 with: script: | const branch = context.payload.pull_request.head.ref; const title = context.payload.pull_request.title || ''; const labels = new Set(); // Match branch prefix (e.g. feat/..., fix/..., chore-something) // Also parse conventional-commit-style PR titles as a fallback: type(scope): subject const branchPrefix = branch.split(/[\/\-_]/)[0].toLowerCase(); const titleMatch = title.match(/^(\w+)(?:\(([^)]+)\))?:/); const titlePrefix = titleMatch?.[1]?.toLowerCase(); const titleScope = titleMatch?.[2]?.toLowerCase(); // Map prefixes to changelog-aligned labels const prefixMap = { 'feat': 'enhancement', 'feature': 'enhancement', 'fix': 'bugfix', 'bug': 'bugfix', 'bugfix': 'bugfix', 'hotfix': 'bugfix', 'refactor': 'refactor', 'chore': 'chore', 'build': 'build', 'ci': 'ci', 'test': 'testing', 'tests': 'testing', 'docs': 'documentation', 'doc': 'documentation', 'perf': 'enhancement', 'style': 'refactor', 'repo': 'repo', 'release': 'release', }; // Map conventional-commit scopes to labels not derivable from the type alone const scopeMap = { 'desktop': 'desktop', }; // Label from branch prefix if (prefixMap[branchPrefix]) labels.add(prefixMap[branchPrefix]); // Label from PR title prefix (if different from branch) if (titlePrefix && prefixMap[titlePrefix] && !labels.has(prefixMap[titlePrefix])) { labels.add(prefixMap[titlePrefix]); } // Label from PR title scope, e.g. "feat(desktop): ..." if (titleScope && scopeMap[titleScope]) labels.add(scopeMap[titleScope]); // Path-based labels from changed files const pathLabels = [ { prefix: '.github/', label: 'repo' }, { prefix: 'build-logic/', label: 'build' }, { prefix: 'desktopApp/', label: 'desktop' }, ].filter(({ label }) => !labels.has(label)); if (pathLabels.length > 0) { try { const files = await github.paginate( github.rest.pulls.listFiles, { owner: context.repo.owner, repo: context.repo.repo, pull_number: context.payload.pull_request.number, per_page: 100 }, (res) => res.data.map(f => f.filename) ); for (const { prefix, label } of pathLabels) { if (files.some(f => f.startsWith(prefix))) labels.add(label); } } catch (e) { core.warning(`Could not list PR files (rate limited?): ${e.message}`); } } if (labels.size > 0) { const labelArray = [...labels]; core.info(`Applying labels: ${labelArray.join(', ')}`); try { await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.pull_request.number, labels: labelArray, }); } catch (e) { core.warning(`Could not apply labels (rate limited?): ${e.message}`); } } else { core.info('No labels matched for this PR.'); } // Enforce that the PR ends up with a required label. Done here (not in a // separate pull_request workflow) so the check sees labels this job just // applied: label events fired by GITHUB_TOKEN don't re-trigger workflows, // so a separate checker would read an empty label set at `opened` and stay // red forever. Bot PRs are labeled by their own flows and are skipped. const author = context.payload.pull_request.user.login; const headRef = context.payload.pull_request.head.ref; const skipAuthors = ['renovate[bot]', 'github-actions[bot]', 'dependabot[bot]']; const skipRefs = ['scheduled-updates', 'l10n_main']; if (!skipAuthors.includes(author) && !skipRefs.includes(headRef)) { const requiredLabels = ['bugfix', 'enhancement', 'automation', 'dependencies', 'repo', 'release', 'refactor', 'desktop', 'chore', 'ci', 'build', 'testing', 'documentation']; const effectiveLabels = new Set([ ...context.payload.pull_request.labels.map(l => l.name), ...labels, ]); if (!requiredLabels.some(l => effectiveLabels.has(l))) { core.setFailed(`PR must have at least one of the following labels before it can be merged: ${requiredLabels.join(', ')}.`); } }