Skip to content

Commit 22f1428

Browse files
authored
ci: Custom PR labeler implementation (#491)
## Description This PR removes 2 separate PR labelers and replaces it by a single Github Action that gets the job done right. It also fixes one little issue with android build.
1 parent 6d11bf7 commit 22f1428

22 files changed

Lines changed: 1119 additions & 290 deletions

.github/actions/labeler/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Dependencies
2+
node_modules/
3+
4+
# Yarn cache and state
5+
.yarn/

.github/actions/labeler/action.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: 'PR Labeler'
2+
description: 'Labels PRs based on both title prefix AND file changes'
3+
author: 'Mateusz Łopaciński (https://github.com/MatiPl01)'
4+
# Uses environment variables:
5+
# GITHUB_TOKEN: GitHub token for API access
6+
# CONFIG_FILE: Path to the labeler configuration file (default: labeler-config.yml)
7+
runs:
8+
using: 'composite'
9+
steps:
10+
- name: Setup Node.js
11+
uses: actions/setup-node@v4
12+
with:
13+
node-version: '20'
14+
cache: 'yarn'
15+
cache-dependency-path: '.github/actions/labeler/yarn.lock'
16+
17+
- name: Install dependencies
18+
shell: bash
19+
working-directory: .github/actions/labeler
20+
run: |
21+
echo "📦 Installing dependencies for PR Labeler..."
22+
yarn install --immutable
23+
echo "✅ Dependencies installed successfully"
24+
25+
- name: Run labeler
26+
shell: bash
27+
working-directory: .github/actions/labeler
28+
run: node index.js
29+
env:
30+
GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }}
31+
CONFIG_FILE: ${{ env.CONFIG_FILE }}

.github/actions/labeler/index.js

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* PR Labeler Action
5+
* Labels PRs based on both title prefix AND file changes
6+
*
7+
* @fileoverview Main entry point for the PR Labeler GitHub Action
8+
* @author Mateusz Łopaciński
9+
* @version 1.0.0
10+
*/
11+
12+
const { context, getOctokit } = require('@actions/github');
13+
const {
14+
validateEnvironment,
15+
loadAndValidateConfig,
16+
logCurrentState,
17+
logTitleAnalysis,
18+
logFileAnalysis,
19+
logSummary
20+
} = require('./utils/helpers');
21+
const {
22+
addLabels,
23+
getChangedFiles,
24+
getCurrentLabels,
25+
removeLabels
26+
} = require('./utils/github');
27+
const {
28+
determineLabelUpdates,
29+
findMatchingFileLabels,
30+
findMatchingTitleLabel
31+
} = require('./utils/labelAnalyzer');
32+
33+
/**
34+
* Analyzes PR and determines which labels should be added or removed
35+
*
36+
* @param {Object} octokit - GitHub API client
37+
* @param {Object} pr - Pull request object from GitHub context
38+
* @param {Object} config - Labeler configuration object
39+
* @returns {Promise<Object>} Analysis results with labels to add/remove
40+
*/
41+
async function analyzePR(octokit, pr, config) {
42+
// Get current state
43+
const currentLabels = getCurrentLabels();
44+
const changedFiles = await getChangedFiles(octokit);
45+
46+
// Log current state
47+
logCurrentState(currentLabels, changedFiles);
48+
49+
// Find labels based on title prefix
50+
const titleLabelInfo = findMatchingTitleLabel(
51+
pr.title,
52+
config.titleMappings || []
53+
);
54+
logTitleAnalysis(titleLabelInfo);
55+
56+
// Find labels based on file changes
57+
const fileLabelInfos = findMatchingFileLabels(
58+
changedFiles,
59+
config.fileMappings || []
60+
);
61+
logFileAnalysis(fileLabelInfos);
62+
63+
// Determine which labels need to be updated
64+
const { labelsToAdd, labelsToRemove } = determineLabelUpdates(
65+
currentLabels,
66+
titleLabelInfo,
67+
fileLabelInfos,
68+
config
69+
);
70+
71+
return { fileLabelInfos, labelsToAdd, labelsToRemove, titleLabelInfo };
72+
}
73+
74+
/**
75+
* Applies label changes to the PR (removes outdated, adds new)
76+
*
77+
* @param {Object} octokit - GitHub API client
78+
* @param {string[]} labelsToAdd - Array of labels to add
79+
* @param {string[]} labelsToRemove - Array of labels to remove
80+
* @returns {Promise<void>}
81+
*/
82+
async function applyLabelChanges(octokit, labelsToAdd, labelsToRemove) {
83+
// Remove outdated labels first
84+
if (labelsToRemove.length > 0) {
85+
console.log(
86+
`\n🗑️ Removing ${labelsToRemove.length} outdated label(s): [${labelsToRemove.join(', ')}]`
87+
);
88+
await removeLabels(octokit, labelsToRemove);
89+
}
90+
91+
// Add new labels
92+
if (labelsToAdd.length > 0) {
93+
console.log(
94+
`\n➕ Adding ${labelsToAdd.length} new label(s): [${labelsToAdd.join(', ')}]`
95+
);
96+
await addLabels(octokit, labelsToAdd);
97+
}
98+
}
99+
100+
/**
101+
* Main execution function - orchestrates the entire labeling process
102+
*
103+
* @returns {Promise<void>}
104+
*/
105+
async function run() {
106+
try {
107+
// Setup and validation
108+
const githubToken = validateEnvironment();
109+
const config = loadAndValidateConfig();
110+
const pr = context.payload.pull_request;
111+
112+
console.log(`📝 Processing PR #${pr.number}: "${pr.title}"`);
113+
114+
// Create GitHub client
115+
const octokit = getOctokit(githubToken);
116+
117+
// Analyze PR and determine changes
118+
const { fileLabelInfos, labelsToAdd, labelsToRemove, titleLabelInfo } =
119+
await analyzePR(octokit, pr, config);
120+
121+
// Apply changes
122+
await applyLabelChanges(octokit, labelsToAdd, labelsToRemove);
123+
124+
// Summary
125+
logSummary(titleLabelInfo, fileLabelInfos);
126+
} catch (error) {
127+
console.error(`💥 Error labeling PR: ${error.message}`);
128+
process.exit(1);
129+
}
130+
}
131+
132+
run();
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# PR Labeler Configuration
2+
# Handles both title prefixes AND file changes in a single action
3+
4+
# Title-based labeling
5+
titleMappings:
6+
- label: feature
7+
prefixes:
8+
- feat
9+
- feature
10+
11+
- label: fix
12+
prefixes:
13+
- fix
14+
- bugfix
15+
16+
- label: documentation
17+
prefixes:
18+
- docs
19+
- doc
20+
21+
- label: cleanup
22+
prefixes:
23+
- style
24+
- format
25+
26+
- label: refactor
27+
prefixes:
28+
- refactor
29+
30+
- label: performance
31+
prefixes:
32+
- perf
33+
- performance
34+
35+
- label: test
36+
prefixes:
37+
- test
38+
- tests
39+
40+
- label: chore
41+
prefixes:
42+
- chore
43+
- build
44+
45+
- label: ci
46+
prefixes:
47+
- ci
48+
49+
- label: dependencies
50+
prefixes:
51+
- deps
52+
- dependencies
53+
54+
- label: example
55+
prefixes:
56+
- example
57+
- demo
58+
59+
- label: enhancement
60+
prefixes:
61+
- enhancement
62+
63+
- label: critical
64+
prefixes:
65+
- critical
66+
67+
- label: released
68+
prefixes:
69+
- released
70+
71+
# File-based labeling
72+
fileMappings:
73+
- label: documentation
74+
patterns:
75+
- 'packages/docs/**'
76+
- '**/README.md'
77+
- '**/*.md'
78+
79+
- label: example
80+
patterns:
81+
- 'example/**'
82+
83+
- label: test
84+
patterns:
85+
- '**/*.test.*'
86+
- '**/*.spec.*'
87+
- '**/__tests__/**'
88+
- '**/tests/**'
89+
- '**/__mocks__/**'
90+
91+
- label: ci
92+
patterns:
93+
- '.github/**'
94+
- '**/workflows/**'
95+
- '**/.github/**'
96+
97+
- label: dependencies
98+
patterns:
99+
- '**/package.json'
100+
- '**/yarn.lock'
101+
- '**/package-lock.json'
102+
- '**/pnpm-lock.yaml'
103+
- '**/bun.lockb'
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "labeler",
3+
"version": "1.0.0",
4+
"description": "Labeler that handles both title prefixes and file changes",
5+
"main": "index.js",
6+
"dependencies": {
7+
"@actions/github": "^6.0.0",
8+
"js-yaml": "^4.1.0",
9+
"minimatch": "^9.0.3"
10+
},
11+
"engines": {
12+
"node": ">=16"
13+
}
14+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Configuration utilities for PR Labeler
3+
*/
4+
5+
const yaml = require('js-yaml');
6+
const fs = require('fs');
7+
const path = require('path');
8+
9+
/**
10+
* Load configuration from YAML file
11+
*/
12+
function loadConfiguration(configFilePath) {
13+
const configPath = path.resolve(__dirname, '..', configFilePath);
14+
15+
try {
16+
const configContent = fs.readFileSync(configPath, 'utf8');
17+
return yaml.load(configContent);
18+
} catch (error) {
19+
console.error(
20+
`❌ Error reading config file "${configFilePath}": ${error.message}`
21+
);
22+
process.exit(1);
23+
}
24+
}
25+
26+
/**
27+
* Validate configuration structure
28+
*/
29+
function validateConfiguration(config) {
30+
if (!config.titleMappings && !config.fileMappings) {
31+
console.error('❌ No titleMappings or fileMappings found in configuration');
32+
process.exit(1);
33+
}
34+
35+
// Validate title mappings
36+
if (config.titleMappings) {
37+
for (const mapping of config.titleMappings) {
38+
if (
39+
!mapping.label ||
40+
!mapping.prefixes ||
41+
!Array.isArray(mapping.prefixes)
42+
) {
43+
console.error(
44+
'❌ Invalid title mapping structure. Expected: { label: string, prefixes: string[] }'
45+
);
46+
process.exit(1);
47+
}
48+
}
49+
}
50+
51+
// Validate file mappings
52+
if (config.fileMappings) {
53+
for (const mapping of config.fileMappings) {
54+
if (
55+
!mapping.label ||
56+
!mapping.patterns ||
57+
!Array.isArray(mapping.patterns)
58+
) {
59+
console.error(
60+
'❌ Invalid file mapping structure. Expected: { label: string, patterns: string[] }'
61+
);
62+
process.exit(1);
63+
}
64+
}
65+
}
66+
}
67+
68+
module.exports = {
69+
loadConfiguration,
70+
validateConfiguration
71+
};

0 commit comments

Comments
 (0)