Skip to content

Commit d4325d2

Browse files
committed
Progress
1 parent a85572c commit d4325d2

File tree

17 files changed

+618
-490
lines changed

17 files changed

+618
-490
lines changed
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* Labeling logic for PR Labeler
3+
* Handles both title-based and file-based labeling
4+
*/
5+
6+
/**
7+
* Find matching label based on PR title prefix
8+
*/
9+
function findMatchingTitleLabel(title, titleMappings) {
10+
const normalizedTitle = title.toLowerCase();
11+
12+
for (const mapping of titleMappings) {
13+
for (const prefix of mapping.prefixes) {
14+
const prefixWithColon = `${prefix}:`;
15+
if (normalizedTitle.startsWith(prefixWithColon)) {
16+
return {
17+
label: mapping.label,
18+
source: 'title',
19+
reason: `title prefix "${prefix}"`
20+
};
21+
}
22+
}
23+
}
24+
25+
return null;
26+
}
27+
28+
/**
29+
* Find matching labels based on file changes
30+
*/
31+
function findMatchingFileLabels(changedFiles, fileMappings) {
32+
const matchingLabels = [];
33+
34+
for (const mapping of fileMappings) {
35+
const label = mapping.label;
36+
const patterns = mapping.patterns || [];
37+
38+
for (const pattern of patterns) {
39+
// More precise pattern matching
40+
const matches = changedFiles.filter(file => {
41+
// Handle directory patterns (ending with /)
42+
if (pattern.endsWith('/')) {
43+
return file.startsWith(pattern);
44+
}
45+
// Handle file patterns (containing dots or specific files)
46+
if (pattern.includes('.')) {
47+
return file.includes(pattern);
48+
}
49+
// Handle directory patterns without trailing slash
50+
return file.startsWith(pattern + '/') || file === pattern;
51+
});
52+
53+
if (matches.length > 0) {
54+
matchingLabels.push({
55+
label: label,
56+
source: 'files',
57+
reason: `file pattern "${pattern}" matches [${matches.join(', ')}]`
58+
});
59+
console.log(`📁 File pattern "${pattern}" matches [${matches.join(', ')}] -> adding label "${label}"`);
60+
}
61+
}
62+
}
63+
64+
return matchingLabels;
65+
}
66+
67+
/**
68+
* Smart label management - determines which labels to add/remove
69+
*/
70+
function calculateLabelChanges(currentLabels, titleLabelInfo, fileLabelInfos, config) {
71+
const labelsToAdd = [];
72+
const labelsToRemove = [];
73+
74+
// Get all configured labels by source
75+
const titleConfiguredLabels = new Set((config.titleMappings || []).map(m => m.label));
76+
const fileConfiguredLabels = new Set((config.fileMappings || []).map(m => m.label));
77+
const allConfiguredLabels = new Set([...titleConfiguredLabels, ...fileConfiguredLabels]);
78+
79+
// Determine what labels should be present and why
80+
const targetLabels = new Map(); // label -> { source, reason }
81+
82+
// Add title-based label
83+
if (titleLabelInfo) {
84+
targetLabels.set(titleLabelInfo.label, {
85+
source: titleLabelInfo.source,
86+
reason: titleLabelInfo.reason
87+
});
88+
}
89+
90+
// Add file-based labels
91+
for (const fileLabelInfo of fileLabelInfos) {
92+
targetLabels.set(fileLabelInfo.label, {
93+
source: fileLabelInfo.source,
94+
reason: fileLabelInfo.reason
95+
});
96+
}
97+
98+
console.log(`🎯 Target labels: ${Array.from(targetLabels.entries()).map(([label, info]) => `${label} (${info.source}: ${info.reason})`).join(', ')}`);
99+
100+
// Determine labels to add (not currently present)
101+
for (const [label, info] of targetLabels) {
102+
if (!currentLabels.has(label)) {
103+
labelsToAdd.push(label);
104+
console.log(`➕ Will add: ${label} (${info.source}: ${info.reason})`);
105+
} else {
106+
console.log(`✅ Already present: ${label} (${info.source}: ${info.reason})`);
107+
}
108+
}
109+
110+
// Determine labels to remove (currently present but no longer needed)
111+
for (const currentLabel of currentLabels) {
112+
if (allConfiguredLabels.has(currentLabel)) {
113+
if (!targetLabels.has(currentLabel)) {
114+
// This label is configured but no longer needed
115+
labelsToRemove.push(currentLabel);
116+
console.log(`🗑️ Will remove: ${currentLabel} (no longer needed)`);
117+
} else {
118+
const targetInfo = targetLabels.get(currentLabel);
119+
console.log(`✅ Keeping: ${currentLabel} (${targetInfo.source}: ${targetInfo.reason})`);
120+
}
121+
} else {
122+
console.log(`🔒 Preserving: ${currentLabel} (not managed by this labeler)`);
123+
}
124+
}
125+
126+
return { labelsToAdd, labelsToRemove };
127+
}
128+
129+
module.exports = {
130+
findMatchingTitleLabel,
131+
findMatchingFileLabels,
132+
calculateLabelChanges
133+
};

.github/actions/labeler/index.js

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* PR Labeler Action
5+
* Labels PRs based on both title prefix AND file changes
6+
* This eliminates conflicts between multiple labelers
7+
*/
8+
9+
const { context, getOctokit } = require('@actions/github');
10+
const { loadConfiguration, validateConfiguration } = require('./utils/config');
11+
const {
12+
addLabels,
13+
getChangedFiles,
14+
getCurrentLabels,
15+
removeLabels
16+
} = require('./utils/github');
17+
const {
18+
calculateLabelChanges,
19+
findMatchingFileLabels,
20+
findMatchingTitleLabel
21+
} = require('./handlers/labeling');
22+
23+
/**
24+
* Validate environment variables and GitHub context
25+
*/
26+
function validateEnvironment() {
27+
const githubToken = process.env.GITHUB_TOKEN;
28+
29+
if (!githubToken) {
30+
console.error('❌ Missing GITHUB_TOKEN environment variable');
31+
process.exit(1);
32+
}
33+
34+
if (!context.payload.pull_request) {
35+
console.error('❌ No pull request found in context');
36+
process.exit(1);
37+
}
38+
39+
return githubToken;
40+
}
41+
42+
/**
43+
* Load and validate configuration
44+
*/
45+
function loadAndValidateConfig() {
46+
const configFilePath = process.env.CONFIG_FILE || 'labeler-config.yml';
47+
const config = loadConfiguration(configFilePath);
48+
validateConfiguration(config);
49+
return config;
50+
}
51+
52+
/**
53+
* Analyze PR and determine label changes
54+
*/
55+
async function analyzePR(octokit, pr, config) {
56+
// Get current state
57+
const currentLabels = getCurrentLabels();
58+
const changedFiles = await getChangedFiles(octokit);
59+
60+
console.log(`🔍 Current labels: [${Array.from(currentLabels).join(', ')}]`);
61+
console.log(`📁 Changed files: [${changedFiles.join(', ')}]`);
62+
63+
// Find labels based on title prefix
64+
const titleLabelInfo = findMatchingTitleLabel(
65+
pr.title,
66+
config.titleMappings || []
67+
);
68+
if (titleLabelInfo) {
69+
console.log(
70+
`🎯 Found title prefix -> applying label "${titleLabelInfo.label}" (${titleLabelInfo.reason})`
71+
);
72+
} else {
73+
console.log(`📝 No matching title prefix found`);
74+
}
75+
76+
// Find labels based on file changes
77+
const fileLabelInfos = findMatchingFileLabels(
78+
changedFiles,
79+
config.fileMappings || []
80+
);
81+
console.log(
82+
`📁 File-based labels: [${fileLabelInfos.map(info => `${info.label} (${info.reason})`).join(', ')}]`
83+
);
84+
85+
// Calculate smart label changes
86+
const { labelsToAdd, labelsToRemove } = calculateLabelChanges(
87+
currentLabels,
88+
titleLabelInfo,
89+
fileLabelInfos,
90+
config
91+
);
92+
93+
return { fileLabelInfos, labelsToAdd, labelsToRemove, titleLabelInfo };
94+
}
95+
96+
/**
97+
* Apply label changes to the PR
98+
*/
99+
async function applyLabelChanges(octokit, labelsToAdd, labelsToRemove) {
100+
// Remove outdated labels first
101+
if (labelsToRemove.length > 0) {
102+
console.log(
103+
`🗑️ Removing ${labelsToRemove.length} labels: [${labelsToRemove.join(', ')}]`
104+
);
105+
await removeLabels(octokit, labelsToRemove);
106+
}
107+
108+
// Add new labels
109+
if (labelsToAdd.length > 0) {
110+
console.log(
111+
`➕ Adding ${labelsToAdd.length} labels: [${labelsToAdd.join(', ')}]`
112+
);
113+
await addLabels(octokit, labelsToAdd);
114+
}
115+
}
116+
117+
/**
118+
* Log completion summary
119+
*/
120+
function logSummary(titleLabelInfo, fileLabelInfos) {
121+
const finalTargetLabels = [
122+
...(titleLabelInfo ? [titleLabelInfo.label] : []),
123+
...fileLabelInfos.map(info => info.label)
124+
];
125+
126+
if (finalTargetLabels.length > 0) {
127+
console.log(
128+
`🎉 PR labeler completed! Final labels: [${finalTargetLabels.join(', ')}]`
129+
);
130+
} else {
131+
console.log('🎉 PR labeler completed! No labels to apply.');
132+
}
133+
}
134+
135+
/**
136+
* Main execution function
137+
*/
138+
async function run() {
139+
try {
140+
// Setup and validation
141+
const githubToken = validateEnvironment();
142+
const config = loadAndValidateConfig();
143+
const pr = context.payload.pull_request;
144+
145+
console.log(`📝 Processing PR #${pr.number}: "${pr.title}"`);
146+
147+
// Create GitHub client
148+
const octokit = getOctokit(githubToken);
149+
150+
// Analyze PR and determine changes
151+
const { fileLabelInfos, labelsToAdd, labelsToRemove, titleLabelInfo } =
152+
await analyzePR(octokit, pr, config);
153+
154+
// Apply changes
155+
await applyLabelChanges(octokit, labelsToAdd, labelsToRemove);
156+
157+
// Summary
158+
logSummary(titleLabelInfo, fileLabelInfos);
159+
} catch (error) {
160+
console.error(`💥 Error labeling PR: ${error.message}`);
161+
process.exit(1);
162+
}
163+
}
164+
165+
run();

0 commit comments

Comments
 (0)