From 52107b29a08f4b60713a679656ffc7e643f653bb Mon Sep 17 00:00:00 2001 From: Bloop <13398309+vinylspiders@users.noreply.github.com> Date: Tue, 12 Aug 2025 18:37:20 -0400 Subject: [PATCH] Redoes the labeler action js backend (#92504) ## About The Pull Request Redoes the labeler script to work for private or restricted repositories (both of which need an auth token for using the REST api). Previous version used `fetch()`, and checked the line diffs which was inefficient and unnecessary. You only really need to be checking the filenames when it comes to the diffs. Now it uses [octokit.rest.pulls.listFiles](https://octokit.github.io/rest.js/v20/#pulls-list-files) and pagination. This version is faster and can support a larger amount of diffs, up to 3000 files changed, and plays nicely with auth tokens for private or restricted repos. Hooray! Tested the various functions of it & things should work same as before. image Also adds support for matching file extensions, so it can find .dmm and .dmis that might be in directories that do not start with `_maps/` or `icons/`. Thanks to @Kocma-san for doing a large part of this and helping figure out what the issue was. ## Changelog Not player-facing --- tools/pull_request_hooks/autoLabel.js | 236 ++++++++++++-------- tools/pull_request_hooks/autoLabelConfig.js | 19 +- 2 files changed, 159 insertions(+), 96 deletions(-) diff --git a/tools/pull_request_hooks/autoLabel.js b/tools/pull_request_hooks/autoLabel.js index 90109849f8e..809e81f49c0 100644 --- a/tools/pull_request_hooks/autoLabel.js +++ b/tools/pull_request_hooks/autoLabel.js @@ -1,110 +1,177 @@ import * as autoLabelConfig from "./autoLabelConfig.js"; -function keyword_to_cl_label() { - const keyword_to_cl_label = {}; - for (const label in autoLabelConfig.changelog_labels) { - for (const keyword of autoLabelConfig.changelog_labels[label].keywords) { - keyword_to_cl_label[keyword] = label; +/** + * 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 keyword_to_cl_label; -} + return map; +})(); -// Checks the body (primarily the changelog) for labels to add +/** + * 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 = []; - // if the body contains a github "fixes #1234" line, add the Fix tag - const fix_regex = new RegExp(`(fix[des]*|resolve[sd]*)\s*#\d+`, "gmi"); + // 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 keywords = keyword_to_cl_label(); + const lines = body.split("\n"); + let inChangelog = false; - let found_cl = false; - for (const line of body.split("\n")) { + for (const line of lines) { if (line.startsWith(":cl:")) { - found_cl = true; - continue; - } else if (line.startsWith("/:cl:")) { - break; - } else if (!found_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 found_label = keywords[line.split(":")[0]?.toLowerCase()]; - if (found_label) { - // don't add a billion tags if they forgot to clear all the default ones - const line_text = line.split(":")[1].trim(); - const cl_label = autoLabelConfig.changelog_labels[found_label]; - if ( - line_text !== cl_label.default_text && - line_text !== cl_label.alt_default_text - ) { - labels_to_add.push(found_label); - } + 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 +/** + * Checks the title for labels to add (O(1) keyword lookup) + */ function check_title_for_labels(title) { - const labels_to_add = []; const title_lower = title.toLowerCase(); - for (const label in autoLabelConfig.title_labels) { - let found = false; - for (const keyword of autoLabelConfig.title_labels[label].keywords) { + const labels_to_add = []; + + for (const [label, keywordSet] of Object.entries(titleKeywordSets)) { + for (const keyword of keywordSet) { if (title_lower.includes(keyword)) { - found = true; + labels_to_add.push(label); break; } } - if (found) { - labels_to_add.push(label); - } } return labels_to_add; } -function check_diff_line_for_element(diff, element) { - const tag_re = new RegExp(`^diff --git a/${element}/`); - return tag_re.test(diff); -} - -// Checks the file diff for labels to add or remove -async function check_diff_for_labels(diff_url) { +/** + * 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 { - const diff = await fetch(diff_url); - if (diff.ok) { - const diff_txt = await diff.text(); - for (const label in autoLabelConfig.file_labels) { - let found = false; - const { filepaths, add_only } = autoLabelConfig.file_labels[label]; - for (const filepath of filepaths) { - if (check_diff_line_for_element(diff_txt, filepath)) { + // 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) { - labels_to_add.push(label); - } else if (!add_only) { - labels_to_remove.push(label); + 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; } } - } else { - console.error(`Failed to fetch diff: ${diff.status} ${diff.statusText}`); + + if (found) { + labels_to_add.push(label); + } else if (!add_only) { + labels_to_remove.push(label); + } } - } catch (e) { - console.error(e); + } 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 { @@ -115,39 +182,30 @@ export async function get_updated_label_set({ github, context }) { title = "", } = pull_request; - const updated_labels = new Set(); - for (const label of labels) { - updated_labels.add(label.name); - } + const updated_labels = new Set(labels.map((l) => l.name)); - // diff is always checked + // Always check file diffs if (diff_url) { - const diff_tags = await check_diff_for_labels(diff_url); - for (const label of diff_tags.labels_to_add) { - updated_labels.add(label); - } - for (const label of diff_tags.labels_to_remove) { - updated_labels.delete(label); - } - } - // body and title are only checked on open, not on sync - if (action === "opened") { - if (title) { - for (const label of check_title_for_labels(title)) { - updated_labels.add(label); - } - } - if (body) { - for (const label of check_body_for_labels(body)) { - updated_labels.add(label); - } - } + 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)); } - // this is always removed on updates + // 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"); - // update merge conflict label + // 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 @@ -159,6 +217,7 @@ export async function get_updated_label_set({ github, context }) { 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)); @@ -177,6 +236,7 @@ export async function get_updated_label_set({ github, context }) { console.error(e); } } + if (merge_conflict) { updated_labels.add("Merge Conflict"); } else { diff --git a/tools/pull_request_hooks/autoLabelConfig.js b/tools/pull_request_hooks/autoLabelConfig.js index ca5c14b1d76..6d848fa715c 100644 --- a/tools/pull_request_hooks/autoLabelConfig.js +++ b/tools/pull_request_hooks/autoLabelConfig.js @@ -7,31 +7,34 @@ // the label will not be removed export const file_labels = { GitHub: { - filepaths: [".github"], + filepaths: [".github/"], }, SQL: { - filepaths: ["SQL"], + filepaths: ["SQL/"], }, "Map Edit": { - filepaths: ["_maps"], + filepaths: ["_maps/"], + file_extensions: [".dmm"], }, Tools: { - filepaths: ["tools"], + filepaths: ["tools/"], }, "Config Update": { - filepaths: ["config", "code/controllers/configuration/entries"], + filepaths: ["config/", "code/controllers/configuration/entries/"], add_only: true, }, Sprites: { - filepaths: ["icons"], + filepaths: ["icons/"], + file_extensions: [".dmi"], add_only: true, }, Sound: { - filepaths: ["sound"], + filepaths: ["sound/"], + file_extensions: [".ogg"], add_only: true, }, UI: { - filepaths: ["tgui"], + filepaths: ["tgui/"], add_only: true, }, };