Skip to content
6 changes: 5 additions & 1 deletion actions/setup/js/add_labels.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const MAX_LABELS = 10;
async function main(config = {}) {
// Extract configuration
const allowedLabels = config.allowed || [];
const blockedPatterns = config.blocked || [];
const maxCount = config.max || 10;
const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);

Expand All @@ -38,6 +39,9 @@ async function main(config = {}) {
if (allowedLabels.length > 0) {
core.info(`Allowed labels: ${allowedLabels.join(", ")}`);
}
if (blockedPatterns.length > 0) {
core.info(`Blocked patterns: ${blockedPatterns.join(", ")}`);
}
core.info(`Default target repo: ${defaultTargetRepo}`);
if (allowedRepos.size > 0) {
core.info(`Allowed repos: ${[...allowedRepos].join(", ")}`);
Expand Down Expand Up @@ -105,7 +109,7 @@ async function main(config = {}) {
}

// Use validation helper to sanitize and validate labels
const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount);
const labelsResult = validateLabels(requestedLabels, allowedLabels, blockedPatterns, maxCount);
if (!labelsResult.valid) {
// If no valid labels, log info and return gracefully
if (labelsResult.error?.includes("No valid labels")) {
Expand Down
68 changes: 68 additions & 0 deletions actions/setup/js/add_labels.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,74 @@ describe("add_labels", () => {
expect(result.labelsAdded).toEqual(["bug", "enhancement"]);
});

it("should filter labels based on blocked patterns", async () => {
const handler = await main({
blocked: ["~*", "\\**"],
max: 10,
});

const addLabelsCalls = [];
mockGithub.rest.issues.addLabels = async params => {
addLabelsCalls.push(params);
return {};
};

const result = await handler(
{
item_number: 100,
labels: ["bug", "~triage", "*admin", "enhancement"],
},
{}
);

expect(result.success).toBe(true);
expect(result.labelsAdded).toEqual(["bug", "enhancement"]);
});

it("should work with both allowed and blocked patterns", async () => {
const handler = await main({
allowed: ["bug", "~triage", "enhancement"],
blocked: ["~*"],
max: 10,
});

const addLabelsCalls = [];
mockGithub.rest.issues.addLabels = async params => {
addLabelsCalls.push(params);
return {};
};

const result = await handler(
{
item_number: 100,
labels: ["bug", "~triage", "custom", "enhancement"],
},
{}
);

expect(result.success).toBe(true);
expect(result.labelsAdded).toEqual(["bug", "enhancement"]);
});

it("should handle all labels being blocked", async () => {
const handler = await main({
blocked: ["~*"],
max: 10,
});

const result = await handler(
{
item_number: 100,
labels: ["~triage", "~workflow"],
},
{}
);

expect(result.success).toBe(true);
expect(result.labelsAdded).toEqual([]);
expect(result.message).toContain("No valid labels found");
});

it("should handle empty labels array", async () => {
const handler = await main({ max: 10 });

Expand Down
20 changes: 19 additions & 1 deletion actions/setup/js/glob_pattern_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function escapeRegexChars(pattern) {
* Supports:
* - * matches any characters except / (in path mode) or any characters (in simple mode)
* - ** matches any characters including / (only in path mode)
* - \* matches literal asterisk (escaped)
* - . is escaped to match literal dots
* - \ is escaped properly
*
Expand All @@ -37,11 +38,23 @@ function escapeRegexChars(pattern) {
* const regex = globPatternToRegex("metrics/**");
* regex.test("metrics/data.json"); // true
* regex.test("metrics/daily/data.json"); // true
*
* @example
* const regex = globPatternToRegex("\\**");
* regex.test("*admin"); // true
* regex.test("admin"); // false
*/
function globPatternToRegex(pattern, options) {
const { pathMode = true, caseSensitive = true } = options || {};

let regexPattern = escapeRegexChars(pattern);
// First, handle escaped asterisks before escaping other characters
// This preserves \* as a literal asterisk marker
let regexPattern = pattern
.replace(/\\\*/g, "<!ESCAPED_STAR>") // Temporarily mark escaped asterisks
.replace(/\\\\/g, "<!ESCAPED_BACKSLASH>"); // Temporarily mark escaped backslashes

// Now escape regex special characters
regexPattern = escapeRegexChars(regexPattern);

if (pathMode) {
// Path mode: handle ** and * differently
Expand All @@ -54,6 +67,11 @@ function globPatternToRegex(pattern, options) {
regexPattern = regexPattern.replace(/\*/g, ".*");
}

// Restore escaped characters
regexPattern = regexPattern
.replace(/<!ESCAPED_STAR>/g, "\\*") // Restore escaped asterisks as literal *
.replace(/<!ESCAPED_BACKSLASH>/g, "\\\\"); // Restore escaped backslashes as literal \

return new RegExp(`^${regexPattern}$`, caseSensitive ? "" : "i");
}

Expand Down
26 changes: 26 additions & 0 deletions actions/setup/js/glob_pattern_helpers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,32 @@ describe("glob_pattern_helpers.cjs", () => {
expect(regex.test("file.min.js")).toBe(true);
expect(regex.test("filexminxjs")).toBe(false);
});

it("should handle escaped asterisks for literal matching", () => {
// Test pattern with escaped asterisk (for label names like "*admin", "~workflow")
const regex = globPatternToRegex("\\**");

expect(regex.test("*admin")).toBe(true);
expect(regex.test("*special")).toBe(true);
expect(regex.test("admin")).toBe(false); // Should not match without leading *
expect(regex.test("~admin")).toBe(false);
});

it("should handle multiple escaped characters in combination", () => {
// Test pattern combining escaped asterisk with wildcard
const tildePattern = globPatternToRegex("~*");
const starPattern = globPatternToRegex("\\**");

// Tilde pattern: ~<anything>
expect(tildePattern.test("~triage")).toBe(true);
expect(tildePattern.test("~workflow")).toBe(true);
expect(tildePattern.test("triage")).toBe(false);

// Star pattern: *<anything>
expect(starPattern.test("*admin")).toBe(true);
expect(starPattern.test("*special")).toBe(true);
expect(starPattern.test("admin")).toBe(false);
});
});

describe("real-world patterns", () => {
Expand Down
6 changes: 5 additions & 1 deletion actions/setup/js/remove_labels.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_help
async function main(config = {}) {
// Extract configuration
const allowedLabels = config.allowed || [];
const blockedPatterns = config.blocked || [];
const maxCount = config.max || 10;
const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);

Expand All @@ -30,6 +31,9 @@ async function main(config = {}) {
if (allowedLabels.length > 0) {
core.info(`Allowed labels to remove: ${allowedLabels.join(", ")}`);
}
if (blockedPatterns.length > 0) {
core.info(`Blocked patterns: ${blockedPatterns.join(", ")}`);
}
core.info(`Default target repo: ${defaultTargetRepo}`);
Comment on lines 23 to 37
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The remove_labels handler has been updated to support blocked patterns (config.blocked), but there are no tests for this functionality in remove_labels.test.cjs. Similar to add_labels.test.cjs which has comprehensive tests for blocked patterns (lines 248-314), remove_labels.test.cjs should include tests to verify that blocked patterns work correctly.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot:claude-sonnet-4.5 apply changes based on this feedback

if (allowedRepos.size > 0) {
core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`);
Expand Down Expand Up @@ -100,7 +104,7 @@ async function main(config = {}) {
}

// Use validation helper to sanitize and validate labels
const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount);
const labelsResult = validateLabels(requestedLabels, allowedLabels, blockedPatterns, maxCount);
if (!labelsResult.valid) {
// If no valid labels, log info and return gracefully
if (labelsResult.error?.includes("No valid labels")) {
Expand Down
77 changes: 77 additions & 0 deletions actions/setup/js/remove_labels.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,83 @@ describe("remove_labels", () => {
expect(result.labelsRemoved).toEqual(["bug", "enhancement"]);
});

it("should filter labels based on blocked patterns", async () => {
const handler = await main({
blocked: ["~*", "\\**"],
max: 10,
});

const removeLabelCalls = [];
mockGithub.rest.issues.removeLabel = async params => {
removeLabelCalls.push(params);
return {};
};

const result = await handler(
{
item_number: 100,
labels: ["bug", "~triage", "*admin", "enhancement"],
},
{}
);

expect(result.success).toBe(true);
expect(result.labelsRemoved).toEqual(["bug", "enhancement"]);
// Verify individual blocked labels are logged
expect(mockCore.infos.some(msg => msg.includes('Label "~triage" matched blocked pattern'))).toBe(true);
expect(mockCore.infos.some(msg => msg.includes('Label "*admin" matched blocked pattern'))).toBe(true);
});

it("should apply both allowed and blocked filters", async () => {
const handler = await main({
allowed: ["bug", "~triage", "enhancement"],
blocked: ["~*"],
max: 10,
});

const removeLabelCalls = [];
mockGithub.rest.issues.removeLabel = async params => {
removeLabelCalls.push(params);
return {};
};

const result = await handler(
{
item_number: 100,
labels: ["bug", "~triage", "invalid-label", "enhancement"],
},
{}
);

expect(result.success).toBe(true);
// "~triage" is in allowed list but blocked by pattern
expect(result.labelsRemoved).toEqual(["bug", "enhancement"]);
});

it("should handle no labels remaining after blocked filtering", async () => {
const handler = await main({
blocked: ["~*", "\\**"],
max: 10,
});

const result = await handler(
{
item_number: 100,
labels: ["~triage", "*admin", "~stale"],
},
{}
);

// Remove labels returns success=true with empty list when all labels are blocked (graceful handling)
expect(result.success).toBe(true);
expect(result.labelsRemoved).toEqual([]);
expect(result.message).toContain("No valid labels");
// Verify blocked labels are logged individually
expect(mockCore.infos.some(msg => msg.includes('Label "~triage" matched blocked pattern'))).toBe(true);
expect(mockCore.infos.some(msg => msg.includes('Label "*admin" matched blocked pattern'))).toBe(true);
expect(mockCore.infos.some(msg => msg.includes('Label "~stale" matched blocked pattern'))).toBe(true);
});

it("should handle empty labels array", async () => {
const handler = await main({ max: 10 });

Expand Down
49 changes: 43 additions & 6 deletions actions/setup/js/safe_output_validator.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,21 @@ function validateBody(body, fieldName = "body", required = false) {

/**
* Validate and sanitize an array of labels
*
* Processing pipeline (in order):
* 1. Check for invalid removal attempts (labels starting with '-')
* 2. Filter by allowed list (if configured)
* 3. Sanitize and deduplicate labels
* 4. Filter by blocked patterns (if configured) - TAKES PRECEDENCE over allowed list
* 5. Apply max count limit
*
* @param {any} labels - The labels to validate
* @param {string[]|undefined} allowedLabels - Optional list of allowed labels
* @param {string[]|undefined} blockedPatterns - Optional list of blocked label patterns (supports glob patterns)
* @param {number} maxCount - Maximum number of labels allowed
* @returns {{valid: boolean, value?: string[], error?: string}} Validation result
*/
function validateLabels(labels, allowedLabels = undefined, maxCount = 3) {
function validateLabels(labels, allowedLabels = undefined, blockedPatterns = undefined, maxCount = 3) {
if (!labels || !Array.isArray(labels)) {
return { valid: false, error: "labels must be an array" };
}
Expand Down Expand Up @@ -114,17 +123,45 @@ function validateLabels(labels, allowedLabels = undefined, maxCount = 3) {
.map(label => (label.length > 64 ? label.substring(0, 64) : label))
.filter((label, index, arr) => arr.indexOf(label) === index);

// Filter out blocked labels if blocked patterns are provided
let filteredLabels = uniqueLabels;
if (blockedPatterns && blockedPatterns.length > 0) {
const { globPatternToRegex } = require("./glob_pattern_helpers.cjs");

// Compile patterns once for performance (outside the filter loop)
/** @type {Array<{pattern: string, regex: RegExp}>} */
const blockedRegexes = [];
for (const pattern of blockedPatterns) {
try {
// Use simple mode (pathMode: false) for label matching - labels don't contain paths
blockedRegexes.push({ pattern, regex: globPatternToRegex(pattern, { pathMode: false }) });
} catch (/** @type {any} */ error) {
core.warning(`Invalid blocked pattern "${pattern}": ${error.message}`);
}
}

filteredLabels = uniqueLabels.filter(label => {
// Check if label matches any blocked pattern
const matchedPattern = blockedRegexes.find(({ regex }) => regex.test(label));
if (matchedPattern) {
core.info(`Label "${label}" matched blocked pattern "${matchedPattern.pattern}", filtering out`);
return false;
}
return true;
});
}

// Apply max count limit
if (uniqueLabels.length > maxCount) {
core.info(`Too many labels (${uniqueLabels.length}), limiting to ${maxCount}`);
return { valid: true, value: uniqueLabels.slice(0, maxCount) };
if (filteredLabels.length > maxCount) {
core.info(`Too many labels (${filteredLabels.length}), limiting to ${maxCount}`);
return { valid: true, value: filteredLabels.slice(0, maxCount) };
}

if (uniqueLabels.length === 0) {
if (filteredLabels.length === 0) {
return { valid: false, error: "No valid labels found after sanitization" };
}

return { valid: true, value: uniqueLabels };
return { valid: true, value: filteredLabels };
}

/**
Expand Down
Loading
Loading