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.

<img width="591" height="171" alt="image"
src="https://github.com/user-attachments/assets/15ff75e2-0335-4b18-b6d7-3aefb312d173"
/>

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
This commit is contained in:
Bloop
2025-08-12 18:37:20 -04:00
committed by GitHub
parent 12d5664395
commit 52107b29a0
2 changed files with 159 additions and 96 deletions
+148 -88
View File
@@ -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 {
+11 -8
View File
@@ -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,
},
};