Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions .github/workflows/pr-labeler.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
name: PR labeler

on:
pull_request_target:
types: [opened, edited, ready_for_review, synchronize]

jobs:
label:
name: Label PR by type
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Apply labels from PR template checkboxes
uses: actions/github-script@v7
with:
script: |
// --- Checkbox rules ---
const checkboxToLabel = {
'Bugfix (a non-breaking change that fixes an issue)': 'bug fix + reliability',
'New feature (a non-breaking change that adds functionality)': 'new feature',
'Breaking change (a change that causes existing functionality not to work as expected)': 'BREAKING',
'Optimization': 'performance is good',
'Refactoring': 'refactoring',
'Documentation update': 'docs',
'Build-related changes': 'build changes',
};

// --- Title prefix rules (conventional commits) ---
const prefixToLabel = {
'fix': 'bug fix + reliability',
'feat': 'new feature',
'perf': 'performance is good',
'refactor': 'refactoring',
'chore': 'minor',
'docs': 'docs',
'test': 'test',
'ci': 'build changes',
'build': 'build changes',
};

// --- Path rules ---
const pathToLabel = [
{ pattern: 'src/Nethermind/Nethermind.Optimism', label: 'optimism' },
{ pattern: 'src/Nethermind/Nethermind.Taiko', label: 'taiko' },
{ pattern: 'src/Nethermind/Nethermind.Xdc', label: 'XDC' },
{ pattern: 'src/Nethermind/Nethermind.Network', label: 'network' },
{ pattern: 'src/Nethermind/Nethermind.Evm', label: 'evm' },
{ pattern: 'src/Nethermind/Nethermind.Trie', label: 'trie' },
{ pattern: 'src/Nethermind/Nethermind.State', label: 'state+storage' },
{ pattern: 'src/Nethermind/Nethermind.Synchronization', label: 'sync' },
{ pattern: 'src/Nethermind/Nethermind.Db.Rocks', label: 'rocksdb' },
{ pattern: 'src/Nethermind/Nethermind.Db', label: 'database' },
{ pattern: 'src/Nethermind/Nethermind.Init/Modules/DbModule.cs', label: 'database' },
{ pattern: 'src/Nethermind/Nethermind.Runner/configs', label: 'configuration' },
{ pattern: 'src/Nethermind/Nethermind.Config', label: 'configuration' },
{ pattern: 'README.md', label: 'docs' },
{ pattern: 'AGENTS.md', label: 'agentic 🤖' },
{ pattern: '.github/', label: 'devops' },
{ pattern: 'Directory.Packages.props', label: 'dependencies' },
{ pattern: 'tools/', label: 'tools' },
];

// test label: applied when ALL changed files are in a .Test project
const testOnlyLabel = 'test';

const checkboxLabels = new Set(Object.values(checkboxToLabel));
const prefixLabels = new Set(Object.values(prefixToLabel));
const pathLabels = new Set(pathToLabel.map(r => r.label));
const managedLabels = new Set([...checkboxLabels, ...prefixLabels, ...pathLabels, testOnlyLabel, 'cleanup', 'snap sync']);

const body = context.payload.pull_request.body || '';
const title = context.payload.pull_request.title || '';

const desiredLabels = new Set();

// Evaluate checkboxes
for (const [text, label] of Object.entries(checkboxToLabel)) {
const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (new RegExp(`-\\s*\\[\\s*[xX]\\s*\\]\\s*${escaped}`).test(body)) {
desiredLabels.add(label);
}
}

// Evaluate "Other" checkbox with keyword matching
const otherMatch = body.match(/-\s*\[\s*[xX]\s*\]\s*Other:\s*(.+)/);
if (otherMatch) {
const otherText = otherMatch[1].toLowerCase();
if (/\btest/.test(otherText)) desiredLabels.add('test');
if (/\btool/.test(otherText)) desiredLabels.add('tools');
if (/\bagent/.test(otherText)) desiredLabels.add('agentic 🤖');
if (/\bdoc/.test(otherText)) desiredLabels.add('docs');
}

// Evaluate title prefix: supports "fix:", "fix(scope):", "(fix)", "[fix]"
const prefixMatch = title.match(/^[(\[]?(\w+)[)\]]?[\s(:/]/)
if (prefixMatch) {
const prefix = prefixMatch[1].toLowerCase();
if (prefixToLabel[prefix]) {
desiredLabels.add(prefixToLabel[prefix]);
}
}

// Evaluate title for EIP mentions (eip-1234, EIP 1234, eip1234, etc.)
if (/eip[-\s]?\d+/i.test(title)) {
desiredLabels.add('eip');
}

// Evaluate title for optimization keyword
if (/\boptimiz/i.test(title)) {
desiredLabels.add('performance is good');
}

// Evaluate changed file paths
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: 100,
});

for (const { pattern, label } of pathToLabel) {
if (files.some(f => f.filename.startsWith(pattern))) {
desiredLabels.add(label);
}
}

// SnapSync in path
if (files.some(f => /SnapSync/i.test(f.filename))) {
desiredLabels.add('snap sync');
}

// Dockerfile changes
if (files.some(f => /(?:^|\/)[Dd]ockerfile/.test(f.filename))) {
desiredLabels.add('devops');
}

// Chain config files with integration keywords
const chainKeywordToLabel = {
'taiko': 'taiko',
'optimism': 'optimism',
'op-': 'optimism',
'xdc': 'XDC',
};
for (const f of files) {
if (/^src\/Nethermind\/Chains\//.test(f.filename)) {
const name = f.filename.toLowerCase();
for (const [keyword, label] of Object.entries(chainKeywordToLabel)) {
if (name.includes(keyword)) {
desiredLabels.add(label);
}
}
}
}

// Apply test label when all changed files are in Test projects
if (files.length > 0 && files.every(f => /\.Test[s]?\//.test(f.filename))) {
desiredLabels.add(testOnlyLabel);
}

// Apply cleanup label when PR only removes code
if (files.length > 0 && files.every(f => f.additions === 0 && f.deletions > 0)) {
desiredLabels.add('cleanup');
}

// Diff against current labels
const currentLabels = new Set(
context.payload.pull_request.labels.map(l => l.name)
);

const toAdd = [...desiredLabels].filter(l => !currentLabels.has(l));
const toRemove = [...currentLabels].filter(l => managedLabels.has(l) && !desiredLabels.has(l));

if (toAdd.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: toAdd,
});
core.info(`Added labels: ${toAdd.join(', ')}`);
}

for (const label of toRemove) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
name: label,
});
core.info(`Removed label: ${label}`);
} catch (e) {
if (e.status !== 404) throw e;
}
}

if (toAdd.length === 0 && toRemove.length === 0) {
core.info('No label changes needed');
}
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Before creating a pull request:
```bash
dotnet format whitespace src/Nethermind/ --folder
```
- Use [pull_request_template.md](.github/pull_request_template.md)
- Follow the [pull_request_template.md](.github/pull_request_template.md) format: fill in the changes section, tick the appropriate type-of-change checkboxes, and complete the testing/documentation sections. The checkboxes drive automatic PR labeling.

## Prerequisites

Expand Down