[CI] Label PRs from git diff instead of the pulls.listFiles API #4020
Workflow file for this run
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
| name: Auto-label PRs | |
| # PR labeling has two parts: | |
| # | |
| # 1. `paths` job — applies [Aspect], [Focus], [Feature], and | |
| # [Type] Documentation labels from the path globs in | |
| # .github/labeler.yml. It lists changed files with `git diff` (not | |
| # GitHub's pulls.listFiles API, which times out on large binary PRs) | |
| # and matches them with packages/meta/src/pr-labels/match-path-labels.mjs. | |
| # These labels have no count limit; a wide refactor can match many | |
| # of them legitimately. | |
| # | |
| # 2. `package-and-type` job — applies the `[Package][...]` labels and | |
| # a single inferred `[Type]` label. This one needs custom logic: | |
| # | |
| # - [Package]: ranks packages by total lines changed | |
| # (additions + deletions, file count as tiebreaker) and applies | |
| # at most the top 3. Without this cap, a cross-cutting change | |
| # ends up with a wall of package labels and the signal is lost. | |
| # | |
| # - [Type]: parses the PR title's conventional-commit prefix | |
| # (fix:/feat:/perf:) and applies one matching label only when | |
| # the signal is unambiguous. We skip refactor:/chore:/test: | |
| # because they have no clean target label. | |
| # | |
| # History note: an earlier version used GitHub Models (actions/ai-inference) | |
| # to suggest labels from PR title + body + paths. That returned 403 | |
| # because GitHub Models access in Actions is gated by an org-level toggle | |
| # we can't flip. Path + PR-title heuristics cover the structured labels | |
| # without any external service. | |
| on: | |
| pull_request_target: | |
| types: [opened, synchronize, reopened, ready_for_review] | |
| # Disable permissions for all available scopes by default. | |
| # Any needed permissions should be configured at the job level. | |
| permissions: {} | |
| jobs: | |
| paths: | |
| if: github.repository == 'WordPress/wordpress-playground' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read # Required for actions/labeler to read the configuration file. | |
| pull-requests: write # Required to apply labels to pull requests. | |
| steps: | |
| # We do NOT use actions/labeler here: it lists changed files via | |
| # GitHub's pulls.listFiles API, which makes GitHub generate the full | |
| # PR diff and times out on PRs with large binary churn (recompiled | |
| # PHP.wasm builds), failing this required check and blocking merges. | |
| # Instead we list files with `git diff` (size-independent) and apply | |
| # the same path labels. The matching logic — and its unit tests — | |
| # live in packages/meta/src/pr-labels/match-path-labels.{mjs,test.mjs}. | |
| # | |
| # Actions are pinned to commit SHAs, not tags: this job runs with | |
| # pull-requests:write, so a moved tag would be a supply-chain | |
| # foothold. Bump deliberately when upgrading. `git diff` runs no PR | |
| # code — it only reads paths — so reading the fork head is safe. | |
| - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| with: | |
| fetch-depth: 0 # Full base history so the merge-base is present. | |
| # minimatch + js-yaml, pinned and installed in isolation (not the | |
| # repo's deps). match-path-labels.mjs finds them via NODE_PATH. | |
| - name: Install label matcher libraries | |
| run: npm install --no-save --no-audit --no-fund --prefix "$RUNNER_TEMP/labeltools" minimatch@9.0.5 js-yaml@4.1.0 | |
| - name: Apply path-based labels | |
| run: node packages/meta/bin/label-pr-paths.mjs | |
| env: | |
| NODE_PATH: ${{ runner.temp }}/labeltools/node_modules | |
| GITHUB_TOKEN: ${{ github.token }} | |
| package-and-type: | |
| if: github.repository == 'WordPress/wordpress-playground' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| pull-requests: write # Required to apply labels to pull requests. | |
| steps: | |
| - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 | |
| with: | |
| script: | | |
| // Path prefix → [Package][...] label. Order matters: the | |
| // first matching prefix wins, so longer / more specific | |
| // prefixes must come before shorter ones (e.g. `web-service-worker` | |
| // before `web`). | |
| const PACKAGE_RULES = [ | |
| ['packages/php-wasm/cli-util/', '[Package][@php-wasm] CLI-Util'], | |
| ['packages/php-wasm/cli/', '[Package][@php-wasm] CLI'], | |
| ['packages/php-wasm/compile/', '[Package][@php-wasm] Compile'], | |
| ['packages/php-wasm/fs-journal/', '[Package][@php-wasm] FS Journal'], | |
| ['packages/php-wasm/logger/', '[Package][@php-wasm] Logger'], | |
| ['packages/php-wasm/node/', '[Package][@php-wasm] Node'], | |
| ['packages/php-wasm/progress/', '[Package][@php-wasm] Progress'], | |
| ['packages/php-wasm/scopes/', '[Package][@php-wasm] Scopes'], | |
| ['packages/php-wasm/stream-compression/','[Package][@php-wasm] Stream Compression'], | |
| ['packages/php-wasm/universal/', '[Package][@php-wasm] Universal'], | |
| ['packages/php-wasm/util/', '[Package][@php-wasm] Util'], | |
| ['packages/php-wasm/web-service-worker/','[Package][@php-wasm] Web'], | |
| ['packages/php-wasm/web/', '[Package][@php-wasm] Web'], | |
| ['packages/php-wasm/xdebug-bridge/', '[Package][@php-wasm] Xdebug Bridge'], | |
| ['packages/playground/blueprints/', '[Package][@wp-playground] Blueprints'], | |
| ['packages/playground/cli/', '[Package][@wp-playground] CLI'], | |
| ['packages/playground/client/', '[Package][@wp-playground] Client'], | |
| ['packages/playground/common/', '[Package][@wp-playground] Common'], | |
| ['packages/playground/components/', '[Package][@wp-playground] Components'], | |
| ['packages/playground/php-cors-proxy/', '[Package][@wp-playground] CORS Proxy'], | |
| ['packages/playground/remote/', '[Package][@wp-playground] Remote'], | |
| ['packages/playground/storage/', '[Package][@wp-playground] Storage'], | |
| ['packages/playground/sync/', '[Package][@wp-playground] Sync'], | |
| ['packages/playground/website-extras/', '[Package][@wp-playground] Website'], | |
| ['packages/playground/website/', '[Package][@wp-playground] Website'], | |
| ['packages/playground/wordpress-builds/','[Package][@wp-playground] WordPress Builds'], | |
| ['packages/playground/wordpress/', '[Package][@wp-playground] WordPress'], | |
| ]; | |
| // Conventional-commit prefix → [Type] label. Only entries | |
| // with an unambiguous target label are listed; refactor / | |
| // chore / test / build / ci are intentionally absent. | |
| const TYPE_RULES = [ | |
| [/^fix(\(|:)/i, '[Type] Bug'], | |
| [/^feat(ure)?(\(|:)/i, '[Type] Enhancement'], | |
| [/^perf(\(|:)/i, '[Type] Performance'], | |
| [/^docs(\(|:)/i, '[Type] Documentation'], | |
| ]; | |
| const pr = context.payload.pull_request; | |
| // Very large PRs (e.g. recompiled WASM binaries) make | |
| // GitHub's diff generation time out, so listFiles returns | |
| // 422 "Sorry, this diff is taking too long to generate." | |
| // That failure is persistent for such PRs — retrying just | |
| // loads their servers without helping. Package/type labels | |
| // are cosmetic, so skip them rather than fail the job. | |
| let files; | |
| try { | |
| files = await github.paginate( | |
| github.rest.pulls.listFiles, | |
| { owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number, per_page: 100 } | |
| ); | |
| } catch (err) { | |
| const status = err?.status; | |
| const message = err?.message ?? String(err); | |
| if (status !== 422 || !/diff is taking too long to generate/i.test(message)) { | |
| throw err; | |
| } | |
| core.warning( | |
| `Could not list PR files (status ${status}): ${message}. ` + | |
| `Skipping package/type labeling for this PR.` | |
| ); | |
| return; | |
| } | |
| // Tally lines + file count per package label. | |
| const stats = new Map(); | |
| for (const f of files) { | |
| const rule = PACKAGE_RULES.find(([prefix]) => f.filename.startsWith(prefix)); | |
| if (!rule) continue; | |
| const label = rule[1]; | |
| const lines = (f.additions || 0) + (f.deletions || 0); | |
| const cur = stats.get(label) || { lines: 0, files: 0 }; | |
| stats.set(label, { lines: cur.lines + lines, files: cur.files + 1 }); | |
| } | |
| // Rank: lines desc, then file count desc, then label name | |
| // for stable output. | |
| const topPackages = [...stats.entries()] | |
| .sort(([a, sa], [b, sb]) => | |
| sb.lines - sa.lines || | |
| sb.files - sa.files || | |
| a.localeCompare(b) | |
| ) | |
| .slice(0, 3) | |
| .map(([label]) => label); | |
| const labels = [...topPackages]; | |
| const titleMatch = TYPE_RULES.find(([re]) => re.test(pr.title)); | |
| if (titleMatch) labels.push(titleMatch[1]); | |
| core.info(`PR title: ${pr.title}`); | |
| core.info(`Package stats: ${JSON.stringify([...stats])}`); | |
| core.info(`Applying labels: ${JSON.stringify(labels)}`); | |
| if (labels.length === 0) return; | |
| await github.rest.issues.addLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr.number, | |
| labels, | |
| }); |