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, }, };