Files
CHOMPStation2/tools/pull_request_hooks/autoLabel.js
2025-08-15 15:47:27 +02:00

252 lines
6.6 KiB
JavaScript

import * as autoLabelConfig from './autoLabelConfig.js';
/**
* Precompute a lowercase keyword → changelog label map
*/
const keywordToClLabel = (() => {
const map = {};
for (const [label, { keywords }] of Object.entries(
autoLabelConfig.changelog_labels,
)) {
for (const keyword of keywords) {
map[keyword.toLowerCase()] = label;
}
}
return map;
})();
/**
* Precompute title keyword Sets per label for O(1) lookup
*/
const titleKeywordSets = (() => {
const map = {};
for (const [label, { keywords }] of Object.entries(
autoLabelConfig.title_labels,
)) {
map[label] = new Set(keywords.map((k) => k.toLowerCase()));
}
return map;
})();
/**
* Precompute filepaths Sets per label for O(1) lookup
*/
const fileLabelFilepathSets = (() => {
const map = {};
for (const [
label,
{ filepaths = [], file_extensions = [], add_only },
] of Object.entries(autoLabelConfig.file_labels)) {
map[label] = {
filepaths: new Set(filepaths),
file_extensions: new Set(file_extensions),
add_only,
};
}
return map;
})();
/**
* Checks the body (primarily the changelog) for labels to add
*/
function check_body_for_labels(body) {
const labels_to_add = [];
// detect "fixes #1234" or "resolves #1234" in body
const fix_regex = /\b(?:fix(?:es|ed)?|resolve[sd]?)\s*#\d+\b/gim;
if (fix_regex.test(body)) {
labels_to_add.push('Fix');
}
const lines = body.split('\n');
let inChangelog = false;
for (const line of lines) {
if (line.startsWith(':cl:')) {
inChangelog = true;
continue;
}
if (line.startsWith('/:cl:')) break;
if (!inChangelog) continue;
// see if the first segment of the line is one of the keywords
const keyword = line.split(':')[0]?.toLowerCase();
const found_label = keywordToClLabel[keyword];
if (!found_label) continue;
// don't add a billion tags if they forgot to clear all the default ones
const line_text = line.split(':')[1]?.trim();
const { default_text, alt_default_text } =
autoLabelConfig.changelog_labels[found_label];
if (line_text !== default_text && line_text !== alt_default_text) {
labels_to_add.push(found_label);
}
}
return labels_to_add;
}
/**
* Checks the title for labels to add (O(1) keyword lookup)
*/
function check_title_for_labels(title) {
const title_lower = title.toLowerCase();
const labels_to_add = [];
for (const [label, keywordSet] of Object.entries(titleKeywordSets)) {
for (const keyword of keywordSet) {
if (title_lower.includes(keyword)) {
labels_to_add.push(label);
break;
}
}
}
return labels_to_add;
}
/**
* Checks changed files for labels to add/remove (O(1) filepath lookup)
*/
async function check_diff_files_for_labels(github, context) {
const labels_to_add = [];
const labels_to_remove = [];
try {
// Use github.paginate to fetch all files (up to ~3000 max)
const allFiles = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
per_page: 100, // max per request
});
if (!allFiles?.length) {
console.error('No files returned in pagination.');
return { labels_to_add, labels_to_remove };
}
// Set of changed filenames for quick lookup
const changedFiles = new Set(allFiles.map((f) => f.filename));
for (const [
label,
{ filepaths = new Set(), file_extensions = new Set(), add_only },
] of Object.entries(fileLabelFilepathSets)) {
let found = false;
// Filepath-based matching
for (const filename of changedFiles) {
for (const path of filepaths) {
if (filename.includes(path)) {
found = true;
break;
}
}
if (found) break;
}
// File extension-based matching
if (!found && file_extensions.size) {
for (const filename of changedFiles) {
for (const ext of file_extensions) {
if (filename.endsWith(ext)) {
found = true;
break;
}
}
if (found) break;
}
}
if (found) {
labels_to_add.push(label);
} else if (!add_only) {
labels_to_remove.push(label);
}
}
} catch (error) {
console.error('Error fetching paginated files:', error);
}
return { labels_to_add, labels_to_remove };
}
/**
* Main function to get the updated label set
*/
export async function get_updated_label_set({ github, context }) {
const { action, pull_request } = context.payload;
const {
body = '',
diff_url,
labels = [],
mergeable,
title = '',
} = pull_request;
const updated_labels = new Set(labels.map((l) => l.name));
// Always check file diffs
if (diff_url) {
const { labels_to_add, labels_to_remove } =
await check_diff_files_for_labels(github, context);
labels_to_add.forEach((label) => updated_labels.add(label));
labels_to_remove.forEach((label) => updated_labels.delete(label));
}
// Check body/title only when PR is opened, not on sync
if (action === 'opened') {
if (title)
check_title_for_labels(title).forEach((label) =>
updated_labels.add(label),
);
if (body)
check_body_for_labels(body).forEach((label) => updated_labels.add(label));
}
// Always remove Test Merge Candidate
updated_labels.delete('Test Merge Candidate');
// Handle merge conflict label
let merge_conflict = mergeable === false;
// null means it was not reported yet
// it is not normally included in the payload - a "get" is needed
if (mergeable === null) {
try {
let response = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pull_request.number,
});
// failed to find? still processing? try again in a few seconds
if (response.data.mergeable === null) {
console.log('Awaiting GitHub response for merge status...');
await new Promise((r) => setTimeout(r, 10000));
response = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pull_request.number,
});
if (response.data.mergeable === null) {
throw new Error('Merge status not available');
}
}
merge_conflict = response.data.mergeable === false;
} catch (e) {
console.error(e);
}
}
if (merge_conflict) {
updated_labels.add('Merge Conflict');
} else {
updated_labels.delete('Merge Conflict');
}
// return the labels to the action, which will apply it
return [...updated_labels];
}