Skip to content

[CI] Label PRs from git diff instead of the pulls.listFiles API #4020

[CI] Label PRs from git diff instead of the pulls.listFiles API

[CI] Label PRs from git diff instead of the pulls.listFiles API #4020

Workflow file for this run

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,
});