From 9235a9f40319b8745a907cd86bfd3f61b8dd2bc2 Mon Sep 17 00:00:00 2001 From: CHOMPStation2StaffMirrorBot <94713762+CHOMPStation2StaffMirrorBot@users.noreply.github.com> Date: Fri, 15 Aug 2025 06:47:27 -0700 Subject: [PATCH] [MIRROR] Updates internal GBP script (#11428) Co-authored-by: Selis <12716288+ItsSelis@users.noreply.github.com> --- tools/pull_request_hooks/autoLabel.js | 251 ++++++++++++++++++++ tools/pull_request_hooks/autoLabelConfig.js | 126 ++++++++++ 2 files changed, 377 insertions(+) create mode 100644 tools/pull_request_hooks/autoLabel.js create mode 100644 tools/pull_request_hooks/autoLabelConfig.js diff --git a/tools/pull_request_hooks/autoLabel.js b/tools/pull_request_hooks/autoLabel.js new file mode 100644 index 0000000000..bbb2688839 --- /dev/null +++ b/tools/pull_request_hooks/autoLabel.js @@ -0,0 +1,251 @@ +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]; +} diff --git a/tools/pull_request_hooks/autoLabelConfig.js b/tools/pull_request_hooks/autoLabelConfig.js new file mode 100644 index 0000000000..47602d53e8 --- /dev/null +++ b/tools/pull_request_hooks/autoLabelConfig.js @@ -0,0 +1,126 @@ +// File Labels +// +// Add a label based on if a file is modified in the diff +// +// You can optionally set add_only to make the label one-way - +// if the edit to the file is removed in a later commit, +// the label will not be removed +export const file_labels = { + GitHub: { + filepaths: ['.github/'], + }, + SQL: { + filepaths: ['SQL/'], + }, + 'Map Edit': { + filepaths: ['maps/'], + file_extensions: ['.dmm'], + }, + Tools: { + filepaths: ['tools/'], + }, + 'Config Update': { + filepaths: ['config/', 'code/controllers/configuration/entries/'], + add_only: true, + }, + Sprites: { + filepaths: ['icons/'], + file_extensions: ['.dmi'], + add_only: true, + }, + Sound: { + filepaths: ['sound/'], + file_extensions: ['.ogg'], + add_only: true, + }, + UI: { + filepaths: ['tgui/'], + add_only: true, + }, +}; + +// Title Labels +// +// Add a label based on keywords in the title +export const title_labels = { + Logging: { + keywords: ['log', 'logging'], + }, + Removal: { + keywords: ['remove', 'delete'], + }, + Refactor: { + keywords: ['refactor'], + }, + 'Unit Tests': { + keywords: ['unit test'], + }, + 'April Fools': { + keywords: ['[april fools]'], + }, + 'Do Not Merge': { + keywords: ['[dnm]', '[do not merge]'], + }, + 'GBP: No Update': { + keywords: ['[no gbp]'], + }, + 'Test Merge Only': { + keywords: ['[tm only]', '[test merge only]'], + }, +}; + +// Changelog Labels +// +// Adds labels based on keywords in the changelog +// TODO use the existing changelog parser +export const changelog_labels = { + Fix: { + default_text: 'fixed a few things', + keywords: ['fix', 'fixes', 'bugfix'], + }, + 'Quality of Life': { + default_text: 'made something easier to use', + keywords: ['qol'], + }, + Sound: { + default_text: 'added/modified/removed audio or sound effects', + keywords: ['sound'], + }, + Feature: { + default_text: 'Added new mechanics or gameplay changes', + alt_default_text: 'Added more things', + keywords: ['add', 'adds', 'rscadd'], + }, + Removal: { + default_text: 'Removed old things', + keywords: ['del', 'dels', 'rscdel'], + }, + Sprites: { + default_text: 'added/modified/removed some icons or images', + keywords: ['image'], + }, + 'Grammar and Formatting': { + default_text: 'fixed a few typos', + keywords: ['typo', 'spellcheck'], + }, + Balance: { + default_text: 'rebalanced something', + keywords: ['balance'], + }, + 'Code Improvement': { + default_text: 'changed some code', + keywords: ['code_imp', 'code'], + }, + Refactor: { + default_text: 'refactored some code', + keywords: ['refactor'], + }, + 'Config Update': { + default_text: 'changed some config setting', + keywords: ['config'], + }, + Administration: { + default_text: 'messed with admin stuff', + keywords: ['admin'], + }, +};