diff --git a/.github/pr-labels.json b/.github/pr-labels.json new file mode 100644 index 0000000000..5f7bc8a636 --- /dev/null +++ b/.github/pr-labels.json @@ -0,0 +1,329 @@ +{ + "maxAreaLabels": 3, + "managedPrefixes": [ + "type:", + "area:", + "target:", + "size:", + "scope:" + ], + "domainAreas": [ + "area:web", + "area:magician" + ], + "typePrefixes": { + "feat": "type:feature", + "feature": "type:feature", + "fix": "type:fix", + "docs": "type:docs", + "refactor": "type:refactor", + "chore": "type:chore", + "test": "type:test", + "tests": "type:test" + }, + "labels": { + "type:feature": { + "color": "2DA44E", + "description": "Introduces new user-visible behavior or capabilities." + }, + "type:fix": { + "color": "D73A4A", + "description": "Corrects broken or incompatible behavior." + }, + "type:docs": { + "color": "0075CA", + "description": "Changes documentation only." + }, + "type:refactor": { + "color": "8250DF", + "description": "Restructures code without intended behavior changes." + }, + "type:chore": { + "color": "6E7781", + "description": "Updates maintenance, tooling, dependencies, or housekeeping." + }, + "type:test": { + "color": "BF8700", + "description": "Changes tests or test infrastructure only." + }, + "type:triage": { + "color": "FBCA04", + "description": "Needs a conventional title or branch prefix before review." + }, + "area:lexer": { + "color": "F9D0C4", + "description": "Touches tokenization or lexical analysis." + }, + "area:parser": { + "color": "F9D0C4", + "description": "Touches parsing or AST construction." + }, + "area:resolver": { + "color": "F9D0C4", + "description": "Touches include, namespace, name, or autoload resolution." + }, + "area:types": { + "color": "D4C5F9", + "description": "Touches type checking, inference, or compatibility." + }, + "area:optimizer": { + "color": "D4C5F9", + "description": "Touches AST or EIR optimization passes." + }, + "area:eir": { + "color": "C5DEF5", + "description": "Touches EIR definitions, lowering, validation, or passes." + }, + "area:codegen": { + "color": "1D76DB", + "description": "Touches target-aware assembly or backend lowering." + }, + "area:runtime": { + "color": "0052CC", + "description": "Touches runtime helpers, GC, ownership, or bridge runtimes." + }, + "area:builtins": { + "color": "A2EEEF", + "description": "Touches PHP builtin declarations or emitters." + }, + "area:web": { + "color": "0E8A16", + "description": "Touches --web mode, its prelude, or elephc-web." + }, + "area:magician": { + "color": "0E8A16", + "description": "Touches eval, include execution, or elephc-magician." + }, + "area:platform": { + "color": "5319E7", + "description": "Touches targets, object formats, linking, or platform support." + }, + "area:tooling-ci": { + "color": "EDEDED", + "description": "Touches CI, development tooling, Docker, or repository scripts." + }, + "area:docs": { + "color": "0075CA", + "description": "Touches documentation without changing code." + }, + "area:triage": { + "color": "FBCA04", + "description": "No primary component could be inferred from changed paths." + }, + "target:linux-x86_64": { + "color": "5319E7", + "description": "Contains behavior specific to the Linux x86_64 target." + }, + "target:linux-aarch64": { + "color": "5319E7", + "description": "Contains behavior specific to the Linux AArch64 target." + }, + "target:macos-aarch64": { + "color": "5319E7", + "description": "Contains behavior specific to the macOS AArch64 target." + }, + "target:windows-x86_64": { + "color": "5319E7", + "description": "Contains behavior specific to the Windows x86_64 target." + }, + "target:wasm32-wasi": { + "color": "5319E7", + "description": "Contains behavior specific to the wasm32-wasi target." + }, + "size:xs": { + "color": "EDEDED", + "description": "Very small pull request." + }, + "size:s": { + "color": "D4E5FF", + "description": "Small pull request." + }, + "size:m": { + "color": "BFDADC", + "description": "Medium-sized pull request." + }, + "size:l": { + "color": "FBCA04", + "description": "Large pull request." + }, + "size:xl": { + "color": "B60205", + "description": "Very large pull request that needs deliberate review planning." + }, + "scope:multi-area": { + "color": "B60205", + "description": "Touches more compiler areas than the automatic area-label cap." + }, + "topic:php-compat": { + "color": "D73A4A", + "description": "Changes PHP compatibility or observable PHP semantics." + }, + "topic:ownership-gc": { + "color": "B60205", + "description": "Changes ownership, refcounting, cleanup, COW, or GC behavior." + }, + "topic:performance": { + "color": "FBCA04", + "description": "Primarily concerns performance, latency, or resource usage." + }, + "topic:abi": { + "color": "FBCA04", + "description": "Changes calling conventions, registers, frames, or binary ABI behavior." + }, + "topic:arrays": { + "color": "BFD4F2", + "description": "Concerns PHP arrays, hashes, indexing, or array operations." + }, + "topic:strings": { + "color": "F9D0C4", + "description": "Concerns strings, encodings, offsets, or string operations." + }, + "topic:closures": { + "color": "BFD4F2", + "description": "Concerns closures, callables, or anonymous functions." + }, + "topic:generators": { + "color": "A2EEEF", + "description": "Concerns generators or yield semantics." + }, + "topic:fibers": { + "color": "A2EEEF", + "description": "Concerns PHP Fibers or cooperative coroutines." + }, + "topic:regex": { + "color": "C2E0C6", + "description": "Concerns regular expressions or preg_* behavior." + }, + "topic:magic-methods": { + "color": "F9D0C4", + "description": "Concerns PHP magic methods." + }, + "topic:control-flow": { + "color": "F9D0C4", + "description": "Concerns branching, loops, match, switch, or exceptions." + }, + "topic:numeric-literals": { + "color": "E8F0FE", + "description": "Concerns integer or floating-point literal handling." + }, + "topic:json": { + "color": "C5DEF5", + "description": "Concerns JSON encoding or decoding." + }, + "topic:errors": { + "color": "D876E3", + "description": "Concerns diagnostics, exceptions, or PHP Error classes." + }, + "priority:high": { + "color": "B60205", + "description": "Maintainer-assigned high-priority work." + } + }, + "areas": [ + { + "label": "area:lexer", + "patterns": ["^src/lexer(?:/|\\.rs$)"] + }, + { + "label": "area:parser", + "patterns": ["^src/parser(?:/|\\.rs$)"] + }, + { + "label": "area:resolver", + "patterns": ["^src/(?:resolver|name_resolver|autoload)(?:/|\\.rs$)"] + }, + { + "label": "area:types", + "patterns": ["^src/types(?:/|\\.rs$)"] + }, + { + "label": "area:optimizer", + "patterns": ["^src/(?:optimize|ir_passes)(?:/|\\.rs$)"] + }, + { + "label": "area:eir", + "patterns": ["^src/(?:ir|ir_lower|ir_passes|codegen_ir)(?:/|\\.rs$)"] + }, + { + "label": "area:codegen", + "patterns": [ + "^src/codegen(?:/|\\.rs$)", + "^src/codegen_support/(?!runtime(?:/|\\.rs$))" + ] + }, + { + "label": "area:runtime", + "patterns": [ + "^src/codegen_support/runtime(?:/|\\.rs$)", + "^src/codegen/runtime(?:/|\\.rs$)", + "^src/runtime_cache\\.rs$", + "^crates/elephc-(?!web(?:/|$)|magician(?:/|$))" + ] + }, + { + "label": "area:builtins", + "patterns": [ + "^src/builtins/", + "^src/codegen/lower_inst/builtins/" + ] + }, + { + "label": "area:web", + "patterns": [ + "^crates/elephc-web/", + "^src/web(?:_|/|\\.rs$)" + ] + }, + { + "label": "area:magician", + "patterns": [ + "^crates/elephc-magician/", + "^benchmarks/magician/", + "^src/magician(?:_|/|\\.rs$)" + ] + }, + { + "label": "area:platform", + "patterns": [ + "^src/codegen_wasm/", + "^src/linker\\.rs$", + "^src/codegen_support/(?:target|object|link|platform)(?:_|/|\\.rs$)" + ] + }, + { + "label": "area:tooling-ci", + "patterns": [ + "^\\.github/", + "^\\.config/", + "^Dockerfile", + "^scripts/" + ] + } + ], + "targets": [ + { + "label": "target:windows-x86_64", + "patterns": ["windows", "win32", "mingw", "pe32", "coff"] + }, + { + "label": "target:wasm32-wasi", + "patterns": ["wasm32", "wasi", "codegen_wasm", "\\.wat(?:$|/)"] + }, + { + "label": "target:linux-x86_64", + "patterns": [ + "linux[-_/](?:x86_64|amd64)", + "(?:^|[^a-z0-9])x86_64(?:$|[^a-z0-9])" + ], + "unless": ["target:windows-x86_64"] + }, + { + "label": "target:linux-aarch64", + "patterns": ["linux[-_/](?:aarch64|arm64)"] + }, + { + "label": "target:macos-aarch64", + "patterns": ["(?:macos|darwin)[-_/](?:aarch64|arm64)"] + } + ] +} diff --git a/.github/scripts/pr-labels.cjs b/.github/scripts/pr-labels.cjs new file mode 100644 index 0000000000..2ffced1e6f --- /dev/null +++ b/.github/scripts/pr-labels.cjs @@ -0,0 +1,465 @@ +'use strict'; + +/** + * Pull-request label classification and GitHub synchronization for elephc. + * + * The classifier manages type, area, target, size, and scope labels. Topic and + * priority labels are deliberately left to maintainers because their meaning + * cannot be inferred safely from filenames alone. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const CONFIG_PATH = path.join(__dirname, '..', 'pr-labels.json'); +const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); + +const compiledAreaRules = config.areas.map(compileRule); +const compiledTargetRules = config.targets.map(compileRule); +const supportedTargets = new Set([ + 'target:linux-x86_64', + 'target:linux-aarch64', + 'target:macos-aarch64', +]); + +/** Compile the regular expressions declared for one label rule. */ +function compileRule(rule) { + return { + ...rule, + regexes: rule.patterns.map((pattern) => new RegExp(pattern, 'i')), + }; +} + +/** Return a normalized repository-relative path. */ +function normalizePath(file) { + const value = typeof file === 'string' ? file : file.filename ?? file.path ?? file.name; + return String(value ?? '').replaceAll('\\', '/').replace(/^\.\//, ''); +} + +/** Identify files that count as documentation for docs-only classification. */ +function isDocumentationFile(file) { + return ( + file.startsWith('docs/') || + file.startsWith('.plans/') || + /(?:^|\/)(?:README|CHANGELOG|CONTRIBUTING|AGENTS|CLAUDE)\.md$/i.test(file) || + /\.md$/i.test(file) + ); +} + +/** Infer exactly one type label from title first and head branch second. */ +function inferType(title, headBranch) { + const titleMatch = String(title ?? '').match( + /^(feat|feature|fix|docs|refactor|chore|tests?)(?:\([^)]*\))?!?:/i, + ); + const branchMatch = String(headBranch ?? '').match( + /^(feat|feature|fix|docs|refactor|chore|tests?)\//i, + ); + const prefix = (titleMatch?.[1] ?? branchMatch?.[1] ?? '').toLowerCase(); + return config.typePrefixes[prefix] ?? 'type:triage'; +} + +/** Score each compiler area by the number of matching changed files. */ +function scoreAreas(files) { + const scores = new Map(); + + for (const file of files) { + for (const rule of compiledAreaRules) { + if (rule.regexes.some((regex) => regex.test(file))) { + scores.set(rule.label, (scores.get(rule.label) ?? 0) + 1); + } + } + } + + return scores; +} + +/** Select up to the configured number of primary areas from scored matches. */ +function selectAreas(files, preferredAreas = []) { + if (files.length > 0 && files.every(isDocumentationFile)) { + return { + labels: ['area:docs'], + matchedAreaCount: 1, + scores: new Map([['area:docs', files.length]]), + }; + } + + const scores = scoreAreas(files); + const order = new Map(config.areas.map((rule, index) => [rule.label, index])); + const ranked = [...scores.entries()].sort( + ([leftLabel, leftScore], [rightLabel, rightScore]) => + rightScore - leftScore || order.get(leftLabel) - order.get(rightLabel), + ); + const selected = []; + + for (const label of [...config.domainAreas, ...preferredAreas]) { + if ( + scores.has(label) && + !selected.includes(label) && + selected.length < config.maxAreaLabels + ) { + selected.push(label); + } + } + + for (const [label] of ranked) { + if (!selected.includes(label) && selected.length < config.maxAreaLabels) { + selected.push(label); + } + } + + return { + labels: selected.length > 0 ? selected : ['area:triage'], + matchedAreaCount: scores.size, + scores, + }; +} + +/** Infer area preferences from conventional title scopes and target metadata. */ +function inferPreferredAreas({ title, headBranch, targets }) { + const scopeMap = { + lexer: 'area:lexer', + parser: 'area:parser', + resolver: 'area:resolver', + types: 'area:types', + type: 'area:types', + optimizer: 'area:optimizer', + optimize: 'area:optimizer', + ir: 'area:eir', + eir: 'area:eir', + ir_lower: 'area:eir', + codegen: 'area:codegen', + runtime: 'area:runtime', + builtins: 'area:builtins', + builtin: 'area:builtins', + web: 'area:web', + platform: 'area:platform', + ci: 'area:tooling-ci', + tooling: 'area:tooling-ci', + docs: 'area:docs', + }; + const preferred = []; + const titleScope = String(title ?? '').match( + /^(?:feat|feature|fix|docs|refactor|chore|tests?)\(([^)]+)\)!?:/i, + )?.[1]; + + for (const scope of String(titleScope ?? '').toLowerCase().split(/[,|/]+/)) { + if (scopeMap[scope.trim()]) preferred.push(scopeMap[scope.trim()]); + } + + const branch = String(headBranch ?? '').toLowerCase(); + if (/^(?:feat|feature|fix)\/(?:eval|magician)(?:[/_-]|$)/.test(branch)) { + preferred.push('area:magician'); + } + if (/^(?:feat|feature|fix)\/web(?:[/_-]|$)/.test(branch)) { + preferred.push('area:web'); + } + if (targets.length > 0) preferred.push('area:platform'); + + return [...new Set(preferred)]; +} + +/** Infer target-specific labels without labeling an all-supported-target change. */ +function inferTargets({ title, headBranch, files }) { + const searchable = [title, headBranch, ...files].join('\n'); + const matched = []; + + for (const rule of compiledTargetRules) { + if (rule.regexes.some((regex) => regex.test(searchable))) { + matched.push(rule.label); + } + } + + const filtered = matched.filter((label) => { + const rule = config.targets.find((candidate) => candidate.label === label); + return !(rule.unless ?? []).some((excludedBy) => matched.includes(excludedBy)); + }); + const matchedSupportedTargets = filtered.filter((label) => supportedTargets.has(label)); + + if (matchedSupportedTargets.length > 1) { + return filtered.filter((label) => !supportedTargets.has(label)); + } + + return filtered; +} + +/** Infer one review-size label from changed-file and changed-line counts. */ +function inferSize({ changedFiles, additions, deletions }) { + const files = Number(changedFiles) || 0; + const lines = (Number(additions) || 0) + (Number(deletions) || 0); + + if (files > 100 || lines > 10_000) return 'size:xl'; + if (files > 30 || lines > 2_000) return 'size:l'; + if (files > 10 || lines > 500) return 'size:m'; + if (files > 3 || lines > 100) return 'size:s'; + return 'size:xs'; +} + +/** Classify a pull request from metadata and its complete changed-file list. */ +function classifyPullRequest(pullRequest) { + const files = (pullRequest.files ?? []).map(normalizePath).filter(Boolean); + const title = pullRequest.title ?? ''; + const headBranch = + pullRequest.headBranch ?? pullRequest.headRefName ?? pullRequest.head?.ref ?? ''; + const targets = inferTargets({ title, headBranch, files }); + const preferredAreas = inferPreferredAreas({ title, headBranch, targets }); + const areas = selectAreas(files, preferredAreas); + const labels = [ + inferType(title, headBranch), + ...areas.labels, + ...targets, + inferSize(pullRequest), + ]; + + if (areas.matchedAreaCount > config.maxAreaLabels || Number(pullRequest.changedFiles) > 100) { + labels.push('scope:multi-area'); + } + + return { + labels: [...new Set(labels)], + areaScores: Object.fromEntries(areas.scores), + }; +} + +/** Return whether a label belongs to a namespace managed by the automation. */ +function isManagedLabel(label) { + return config.managedPrefixes.some((prefix) => label.startsWith(prefix)); +} + +/** Calculate managed-label additions/removals while preserving manual labels. */ +function reconcileLabels(currentLabels, desiredLabels) { + const current = new Set(currentLabels); + const desired = new Set(desiredLabels); + return { + add: [...desired].filter((label) => !current.has(label)), + remove: [...current].filter((label) => isManagedLabel(label) && !desired.has(label)), + }; +} + +/** Parse comma/space separated PR numbers or the special all-open selection. */ +function parseSelection(selection) { + const value = String(selection ?? '').trim().toLowerCase(); + if (value === 'all-open') return value; + if (value === '') return []; + + const numbers = value + .split(/[\s,]+/) + .filter(Boolean) + .map((part) => Number(part)); + if (numbers.some((number) => !Number.isSafeInteger(number) || number <= 0)) { + throw new Error(`Invalid PR selection: ${selection}`); + } + return [...new Set(numbers)]; +} + +/** Validate that every classifier output has a catalog definition. */ +function validateConfig() { + if (!Number.isInteger(config.maxAreaLabels) || config.maxAreaLabels < 1) { + throw new Error('maxAreaLabels must be a positive integer.'); + } + + for (const [name, definition] of Object.entries(config.labels)) { + if (name.length > 50) throw new Error(`Label name exceeds 50 characters: ${name}`); + if (!/^[0-9a-f]{6}$/i.test(definition.color)) { + throw new Error(`Invalid label color for ${name}: ${definition.color}`); + } + if (definition.description.length > 100) { + throw new Error(`Label description exceeds 100 characters: ${name}`); + } + } + + const areaRuleLabels = config.areas.map((rule) => rule.label); + if (new Set(areaRuleLabels).size !== areaRuleLabels.length) { + throw new Error('Area rules must use unique labels.'); + } + const unknownDomainAreas = config.domainAreas.filter( + (label) => !areaRuleLabels.includes(label), + ); + if (unknownDomainAreas.length > 0) { + throw new Error(`Unknown domain areas: ${unknownDomainAreas.join(', ')}`); + } + + const referenced = new Set([ + ...Object.values(config.typePrefixes), + 'type:triage', + 'area:docs', + 'area:triage', + 'scope:multi-area', + 'size:xs', + 'size:s', + 'size:m', + 'size:l', + 'size:xl', + ...config.areas.map((rule) => rule.label), + ...config.targets.map((rule) => rule.label), + ]); + const missing = [...referenced].filter((label) => !config.labels[label]); + if (missing.length > 0) { + throw new Error(`Missing label definitions: ${missing.join(', ')}`); + } +} + +/** Ensure the declarative label catalog exists in the GitHub repository. */ +async function ensureLabelCatalog({ github, owner, repo, core, dryRun }) { + const repositoryLabels = await github.paginate(github.rest.issues.listLabelsForRepo, { + owner, + repo, + per_page: 100, + }); + const existing = new Set(repositoryLabels.map((label) => label.name)); + const missing = Object.entries(config.labels).filter(([name]) => !existing.has(name)); + + if (dryRun) { + if (missing.length > 0) { + core.info(`Would create labels: ${missing.map(([name]) => name).join(', ')}`); + } + return; + } + + for (const [name, definition] of missing) { + try { + await github.rest.issues.createLabel({ owner, repo, name, ...definition }); + } catch (error) { + if (error.status !== 422) throw error; + } + } +} + +/** Resolve the PRs selected by an event number, explicit numbers, or all-open. */ +async function resolvePullRequests({ github, owner, repo, context, selection }) { + const parsed = parseSelection(selection); + if (parsed === 'all-open') { + return github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + per_page: 100, + }); + } + + const eventPullRequest = context.payload.pull_request; + const numbers = parsed.length > 0 ? parsed : eventPullRequest ? [eventPullRequest.number] : []; + if (numbers.length === 0) { + throw new Error('No pull request number was supplied.'); + } + + return Promise.all( + numbers.map(async (number) => { + if (eventPullRequest?.number === number) return eventPullRequest; + const response = await github.rest.pulls.get({ owner, repo, pull_number: number }); + return response.data; + }), + ); +} + +/** Fetch every changed filename for one pull request. */ +async function fetchChangedFiles({ github, owner, repo, number }) { + return github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: number, + per_page: 100, + }); +} + +/** Synchronize labels for one pull request, or only report the planned changes. */ +async function labelPullRequest({ github, owner, repo, pullRequest, core, dryRun }) { + const number = pullRequest.number; + const files = await fetchChangedFiles({ github, owner, repo, number }); + let details = pullRequest; + if ( + details.changed_files == null || + details.additions == null || + details.deletions == null + ) { + const response = await github.rest.pulls.get({ owner, repo, pull_number: number }); + details = response.data; + } + const classification = classifyPullRequest({ + title: details.title, + headBranch: details.head?.ref, + changedFiles: details.changed_files ?? files.length, + additions: details.additions, + deletions: details.deletions, + files, + }); + const current = await github.paginate(github.rest.issues.listLabelsOnIssue, { + owner, + repo, + issue_number: number, + per_page: 100, + }); + const changes = reconcileLabels( + current.map((label) => label.name), + classification.labels, + ); + const summary = `#${number}: ${classification.labels.join(', ')}`; + + if (dryRun) { + core.info(`${summary}; add=[${changes.add.join(', ')}]; remove=[${changes.remove.join(', ')}]`); + return { number, ...classification, ...changes }; + } + + for (const label of changes.remove) { + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: number, + name: label, + }); + } catch (error) { + if (error.status !== 404) throw error; + } + } + + if (changes.add.length > 0) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: number, + labels: changes.add, + }); + } + + core.info(summary); + return { number, ...classification, ...changes }; +} + +/** Run PR label synchronization from a GitHub Actions event or manual dispatch. */ +async function run({ github, context, core, selection = '', dryRun = false }) { + validateConfig(); + const { owner, repo } = context.repo; + const pullRequests = await resolvePullRequests({ + github, + owner, + repo, + context, + selection, + }); + + await ensureLabelCatalog({ github, owner, repo, core, dryRun }); + const results = []; + for (const pullRequest of pullRequests) { + results.push( + await labelPullRequest({ github, owner, repo, pullRequest, core, dryRun }), + ); + } + return results; +} + +validateConfig(); + +module.exports = { + classifyPullRequest, + config, + inferSize, + inferPreferredAreas, + inferTargets, + inferType, + isDocumentationFile, + parseSelection, + reconcileLabels, + run, + selectAreas, + validateConfig, +}; diff --git a/.github/scripts/pr-labels.test.cjs b/.github/scripts/pr-labels.test.cjs new file mode 100644 index 0000000000..7be63d6452 --- /dev/null +++ b/.github/scripts/pr-labels.test.cjs @@ -0,0 +1,226 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + classifyPullRequest, + inferSize, + inferTargets, + inferType, + parseSelection, + reconcileLabels, + run, + selectAreas, + validateConfig, +} = require('./pr-labels.cjs'); + +test('the label configuration is internally complete', () => { + assert.doesNotThrow(validateConfig); +}); + +test('title type takes precedence over a conflicting branch prefix', () => { + assert.equal(inferType('fix(types): repair array widening', 'feat/array-widening'), 'type:fix'); +}); + +test('branch type classifies a non-conventional title', () => { + assert.equal(inferType('Magician', 'feat/eval'), 'type:feature'); +}); + +test('unclassified titles and branches receive the triage type', () => { + assert.equal(inferType('Improve the compiler', 'work/compiler'), 'type:triage'); +}); + +test('docs-only changes do not acquire source-code areas', () => { + const result = selectAreas(['README.md', 'docs/php/functions.md', '.plans/next-step.md']); + assert.deepEqual(result.labels, ['area:docs']); +}); + +test('distinctive domains are retained while areas are capped', () => { + const result = classifyPullRequest({ + title: 'feat(eval): add dynamic execution', + headBranch: 'feat/eval', + changedFiles: 8, + additions: 600, + deletions: 20, + files: [ + 'crates/elephc-magician/src/lib.rs', + 'crates/elephc-magician/src/interpreter.rs', + 'src/types/checker/functions.rs', + 'src/types/call_args/mod.rs', + 'src/ir_lower/expr/mod.rs', + 'src/codegen/lower_inst/calls.rs', + 'src/parser/expr/pratt.rs', + 'docs/php/eval.md', + ], + }); + + assert.equal(result.labels.filter((label) => label.startsWith('area:')).length, 3); + assert.ok(result.labels.includes('area:magician')); + assert.ok(result.labels.includes('scope:multi-area')); +}); + +test('incidental platform and builtin files do not displace Magician primary areas', () => { + const result = classifyPullRequest({ + title: 'Magician', + headBranch: 'feat/eval', + changedFiles: 12, + additions: 1_000, + deletions: 50, + files: [ + 'crates/elephc-magician/src/lib.rs', + 'crates/elephc-magician/src/interpreter/mod.rs', + 'crates/elephc-magician/src/interpreter/eval.rs', + 'src/types/checker/functions.rs', + 'src/types/call_args/mod.rs', + 'src/optimize/fold/calls.rs', + 'src/optimize/effects/calls.rs', + 'src/linker.rs', + 'src/builtins/internal/eval.rs', + 'docs/php/eval.md', + 'tests/codegen/eval.rs', + 'README.md', + ], + }); + + assert.deepEqual( + result.labels.filter((label) => label.startsWith('area:')), + ['area:magician', 'area:types', 'area:optimizer'], + ); +}); + +test('Windows x86_64 changes do not acquire the Linux x86_64 target', () => { + assert.deepEqual( + inferTargets({ + title: 'feat(platform): add Windows x86_64 PE32+ support', + headBranch: 'feat/windows-pe', + files: ['src/codegen_support/windows_x86_64.rs'], + }), + ['target:windows-x86_64'], + ); +}); + +test('a generic x86_64 backend fix maps to the supported Linux target', () => { + assert.deepEqual( + inferTargets({ + title: 'fix(codegen): use the canonical x86_64 heap magic', + headBranch: 'fix/canonical-heap-magic', + files: ['src/codegen_support/runtime/heap.rs'], + }), + ['target:linux-x86_64'], + ); +}); + +test('a target-specific PR keeps platform among its primary areas', () => { + const result = classifyPullRequest({ + title: 'feat(platform): add Windows x86_64 PE32+ support', + headBranch: 'feat/windows-pe', + changedFiles: 5, + additions: 400, + deletions: 20, + files: [ + 'src/codegen_support/windows_x86_64.rs', + 'src/codegen/lower_inst/calls.rs', + 'src/codegen/lower_inst/arrays.rs', + 'src/codegen_support/runtime/mod.rs', + 'src/linker.rs', + ], + }); + + assert.ok(result.labels.includes('area:platform')); + assert.ok(result.labels.includes('target:windows-x86_64')); +}); + +test('changes naming multiple supported targets are treated as cross-target', () => { + assert.deepEqual( + inferTargets({ + title: 'chore(ci): update target jobs', + headBranch: 'chore/targets', + files: ['config/linux-x86_64.toml', 'config/linux-aarch64.toml'], + }), + [], + ); +}); + +test('size thresholds use both changed files and changed lines', () => { + assert.equal(inferSize({ changedFiles: 2, additions: 20, deletions: 5 }), 'size:xs'); + assert.equal(inferSize({ changedFiles: 5, additions: 90, deletions: 20 }), 'size:s'); + assert.equal(inferSize({ changedFiles: 12, additions: 300, deletions: 20 }), 'size:m'); + assert.equal(inferSize({ changedFiles: 31, additions: 100, deletions: 10 }), 'size:l'); + assert.equal(inferSize({ changedFiles: 101, additions: 100, deletions: 10 }), 'size:xl'); +}); + +test('managed labels synchronize while manual topics remain untouched', () => { + assert.deepEqual( + reconcileLabels( + ['type:feature', 'area:parser', 'size:s', 'topic:php-compat', 'help wanted'], + ['type:fix', 'area:types', 'size:m'], + ), + { + add: ['type:fix', 'area:types', 'size:m'], + remove: ['type:feature', 'area:parser', 'size:s'], + }, + ); +}); + +test('manual selection accepts unique PR numbers and all-open', () => { + assert.deepEqual(parseSelection('535, 533 535'), [535, 533]); + assert.equal(parseSelection('all-open'), 'all-open'); + assert.throws(() => parseSelection('535,nope'), /Invalid PR selection/); +}); + +test('workflow dry-run reports changes without calling write endpoints', async () => { + const writes = []; + const github = { + rest: { + issues: { + listLabelsForRepo: async () => {}, + listLabelsOnIssue: async () => {}, + createLabel: async (args) => writes.push(['create', args]), + removeLabel: async (args) => writes.push(['remove', args]), + addLabels: async (args) => writes.push(['add', args]), + }, + pulls: { + list: async () => {}, + get: async () => {}, + listFiles: async () => {}, + }, + }, + }; + github.paginate = async (method) => { + if (method === github.rest.issues.listLabelsForRepo) return []; + if (method === github.rest.issues.listLabelsOnIssue) { + return [{ name: 'topic:ownership-gc' }, { name: 'type:feature' }]; + } + if (method === github.rest.pulls.listFiles) { + return [{ filename: 'src/ir_lower/ownership.rs' }]; + } + throw new Error('Unexpected pagination endpoint'); + }; + const messages = []; + const context = { + repo: { owner: 'illegalstudio', repo: 'elephc' }, + payload: { + pull_request: { + number: 535, + title: 'fix(ir): release loop-carried locals', + head: { ref: 'fix/loop-carry-release' }, + changed_files: 1, + additions: 20, + deletions: 2, + }, + }, + }; + + const results = await run({ + github, + context, + core: { info: (message) => messages.push(message) }, + dryRun: true, + }); + + assert.deepEqual(writes, []); + assert.deepEqual(results[0].labels, ['type:fix', 'area:eir', 'size:xs']); + assert.deepEqual(results[0].remove, ['type:feature']); + assert.ok(messages.some((message) => message.includes('Would create labels:'))); +}); diff --git a/.github/workflows/pr-labels.yml b/.github/workflows/pr-labels.yml new file mode 100644 index 0000000000..7a8d0c115f --- /dev/null +++ b/.github/workflows/pr-labels.yml @@ -0,0 +1,54 @@ +name: PR labels + +on: + pull_request_target: + branches: [main] + types: [opened, reopened, synchronize, edited, ready_for_review] + workflow_dispatch: + inputs: + pull_requests: + description: "Comma-separated PR numbers, or all-open" + required: true + type: string + dry_run: + description: "Report label changes without applying them" + required: true + default: true + type: boolean + +concurrency: + group: pr-labels-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + label: + name: Classify pull requests + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: write + steps: + # pull_request_target has a write-capable token. Check out only the trusted + # base commit and never execute code from the pull request head. + - name: Check out trusted label configuration + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.base.sha || github.sha }} + persist-credentials: false + + - name: Classify and synchronize labels + uses: actions/github-script@v9 + env: + PR_SELECTION: ${{ inputs.pull_requests || github.event.pull_request.number }} + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }} + with: + script: | + const { run } = require('./.github/scripts/pr-labels.cjs'); + await run({ + github, + context, + core, + selection: process.env.PR_SELECTION, + dryRun: process.env.DRY_RUN === 'true', + });