From fe9b4536238ca1d355a16a003d5375e4fc7f11d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 20:41:24 +0000 Subject: [PATCH 1/2] Initial plan From 7b2ebf8cbe1cc19f1bf19367922d95e8db835639 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 20:58:54 +0000 Subject: [PATCH 2/2] feat: add wildcard support to repo validation and standardize helper usage Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/create_discussion.cjs | 38 +++--------- actions/setup/js/create_issue.cjs | 38 +++--------- actions/setup/js/repo_helpers.cjs | 37 ++++++++++- actions/setup/js/repo_helpers.test.cjs | 85 ++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 63 deletions(-) diff --git a/actions/setup/js/create_discussion.cjs b/actions/setup/js/create_discussion.cjs index 166bd7a7e77..e8c831f155f 100644 --- a/actions/setup/js/create_discussion.cjs +++ b/actions/setup/js/create_discussion.cjs @@ -11,7 +11,7 @@ const HANDLER_TYPE = "create_discussion"; const { getTrackerID } = require("./get_tracker_id.cjs"); const { sanitizeTitle, applyTitlePrefix } = require("./sanitize_title.cjs"); const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, getOrGenerateTemporaryId, replaceTemporaryIdReferences } = require("./temporary_id.cjs"); -const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); +const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs"); const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); const { createExpirationLine, generateFooterWithExpiration } = require("./ephemerals.cjs"); @@ -300,8 +300,7 @@ async function handleFallbackToIssue(createIssueHandler, item, qualifiedItemRepo */ async function main(config = {}) { // Extract configuration - const allowedRepos = parseAllowedRepos(config.allowed_repos); - const defaultTargetRepo = getDefaultTargetRepo(config); + const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); const titlePrefix = config.title_prefix || ""; const configCategory = config.category || ""; const maxCount = config.max || 10; @@ -382,37 +381,16 @@ async function main(config = {}) { } } - // Determine target repository - const itemRepo = item.repo ? String(item.repo).trim() : defaultTargetRepo; - - // Validate repository - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - // When valid is false, error is guaranteed to be non-null - const errorMessage = repoValidation.error; - if (!errorMessage) { - throw new Error("Internal error: repoValidation.error should not be null when valid is false"); - } - core.warning(`Skipping discussion: ${errorMessage}`); + // Resolve and validate target repository + const repoResult = resolveAndValidateRepo(item, defaultTargetRepo, allowedRepos, "discussion"); + if (!repoResult.success) { + core.warning(`Skipping discussion: ${repoResult.error}`); return { success: false, - error: errorMessage, - }; - } - - // Use the qualified repo from validation (handles bare names like "gh-aw" -> "github/gh-aw") - const qualifiedItemRepo = repoValidation.qualifiedRepo; - - // Parse repository slug - const repoParts = parseRepoSlug(qualifiedItemRepo); - if (!repoParts) { - const error = `Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`; - core.warning(`Skipping discussion: ${error}`); - return { - success: false, - error, + error: repoResult.error, }; } + const { repo: qualifiedItemRepo, repoParts } = repoResult; // Get repository info (cached) let repoInfo = repoInfoCache.get(qualifiedItemRepo); diff --git a/actions/setup/js/create_issue.cjs b/actions/setup/js/create_issue.cjs index f4ce5b30a04..905ffa95dc0 100644 --- a/actions/setup/js/create_issue.cjs +++ b/actions/setup/js/create_issue.cjs @@ -31,7 +31,7 @@ const { generateFooterWithMessages } = require("./messages_footer.cjs"); const { generateWorkflowIdMarker } = require("./generate_footer.cjs"); const { getTrackerID } = require("./get_tracker_id.cjs"); const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, getOrGenerateTemporaryId, replaceTemporaryIdReferences } = require("./temporary_id.cjs"); -const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); +const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs"); const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); const { renderTemplate } = require("./messages_core.cjs"); @@ -208,8 +208,7 @@ async function main(config = {}) { const titlePrefix = config.title_prefix ?? ""; const expiresHours = config.expires ? parseInt(String(config.expires), 10) : 0; const maxCount = config.max ?? 10; - const allowedRepos = parseAllowedRepos(config.allowed_repos); - const defaultTargetRepo = getDefaultTargetRepo(config); + const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); const groupEnabled = config.group === true || config.group === "true"; const closeOlderIssuesEnabled = config.close_older_issues === true || config.close_older_issues === "true"; const includeFooter = config.footer !== false; // Default to true (include footer) @@ -289,37 +288,16 @@ async function main(config = {}) { } } - // Determine target repository for this issue - const itemRepo = message.repo ? String(message.repo).trim() : defaultTargetRepo; - - // Validate the repository is allowed - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - // When valid is false, error is guaranteed to be non-null - const errorMessage = repoValidation.error; - if (!errorMessage) { - throw new Error("Internal error: repoValidation.error should not be null when valid is false"); - } - core.warning(`Skipping issue: ${errorMessage}`); - return { - success: false, - error: errorMessage, - }; - } - - // Use the qualified repo from validation (handles bare names like "gh-aw" -> "github/gh-aw") - const qualifiedItemRepo = repoValidation.qualifiedRepo; - - // Parse the repository slug - const repoParts = parseRepoSlug(qualifiedItemRepo); - if (!repoParts) { - const error = `Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`; - core.warning(`Skipping issue: ${error}`); + // Resolve and validate target repository + const repoResult = resolveAndValidateRepo(message, defaultTargetRepo, allowedRepos, "issue"); + if (!repoResult.success) { + core.warning(`Skipping issue: ${repoResult.error}`); return { success: false, - error, + error: repoResult.error, }; } + const { repo: qualifiedItemRepo, repoParts } = repoResult; // Get or generate the temporary ID for this issue const tempIdResult = getOrGenerateTemporaryId(message, "issue"); diff --git a/actions/setup/js/repo_helpers.cjs b/actions/setup/js/repo_helpers.cjs index c8ca8763d57..5e373e4a25c 100644 --- a/actions/setup/js/repo_helpers.cjs +++ b/actions/setup/js/repo_helpers.cjs @@ -6,6 +6,8 @@ * Provides common repository parsing, validation, and resolution logic */ +const { globPatternToRegex } = require("./glob_pattern_helpers.cjs"); + /** * Parse the allowed repos from config value (array or comma-separated string) * @param {string[]|string|undefined} allowedReposValue - Allowed repos from config (array or comma-separated string) @@ -47,14 +49,42 @@ function getDefaultTargetRepo(config) { return `${context.repo.owner}/${context.repo.repo}`; } +/** + * Check if a qualified repo matches any allowed repo pattern. + * Supports exact matches and wildcard patterns using glob syntax: + * - "*" matches any repository + * - "github/*" matches any repository in the "github" org + * - "STAR/gh-aw" (where STAR is *) matches "gh-aw" in any org + * @param {string} qualifiedRepo - Fully qualified repo slug "owner/repo" + * @param {Set} allowedRepos - Set of allowed repo patterns + * @returns {boolean} + */ +function isRepoAllowed(qualifiedRepo, allowedRepos) { + // Fast path: exact match + if (allowedRepos.has(qualifiedRepo)) { + return true; + } + // Check for wildcard patterns + for (const pattern of allowedRepos) { + if (pattern === "*") { + return true; + } + if (pattern.includes("*") && globPatternToRegex(pattern, { pathMode: true, caseSensitive: true }).test(qualifiedRepo)) { + return true; + } + } + return false; +} + /** * Validate that a repo is allowed for operations * If repo is a bare name (no slash), it is automatically qualified with the * default repo's organization (e.g., "gh-aw" becomes "github/gh-aw" if * the default repo is "github/something"). + * Allowed repos support wildcard patterns (e.g., "github/*", "*"). * @param {string} repo - Repository slug to validate (can be "owner/repo" or just "repo") * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos + * @param {Set} allowedRepos - Set of explicitly allowed repo patterns * @returns {{valid: boolean, error: string|null, qualifiedRepo: string}} */ function validateRepo(repo, defaultRepo, allowedRepos) { @@ -71,8 +101,8 @@ function validateRepo(repo, defaultRepo, allowedRepos) { if (qualifiedRepo === defaultRepo) { return { valid: true, error: null, qualifiedRepo }; } - // Check if it's in the allowed repos list - if (allowedRepos.has(qualifiedRepo)) { + // Check if it's in the allowed repos list (supports wildcards) + if (isRepoAllowed(qualifiedRepo, allowedRepos)) { return { valid: true, error: null, qualifiedRepo }; } return { @@ -159,6 +189,7 @@ function resolveAndValidateRepo(item, defaultTargetRepo, allowedRepos, operation module.exports = { parseAllowedRepos, getDefaultTargetRepo, + isRepoAllowed, validateRepo, parseRepoSlug, resolveTargetRepoConfig, diff --git a/actions/setup/js/repo_helpers.test.cjs b/actions/setup/js/repo_helpers.test.cjs index e8e2a840162..8df2ab0c72f 100644 --- a/actions/setup/js/repo_helpers.test.cjs +++ b/actions/setup/js/repo_helpers.test.cjs @@ -99,6 +99,43 @@ describe("repo_helpers", () => { }); }); + describe("isRepoAllowed", () => { + it("should return true for exact match", async () => { + const { isRepoAllowed } = await import("./repo_helpers.cjs"); + const allowedRepos = new Set(["org/repo-a"]); + expect(isRepoAllowed("org/repo-a", allowedRepos)).toBe(true); + }); + + it('should return true when "*" is in the set', async () => { + const { isRepoAllowed } = await import("./repo_helpers.cjs"); + const allowedRepos = new Set(["*"]); + expect(isRepoAllowed("any-org/any-repo", allowedRepos)).toBe(true); + }); + + it('should return true for org wildcard "github/*"', async () => { + const { isRepoAllowed } = await import("./repo_helpers.cjs"); + const allowedRepos = new Set(["github/*"]); + expect(isRepoAllowed("github/gh-aw", allowedRepos)).toBe(true); + }); + + it("should return false when repo does not match org wildcard", async () => { + const { isRepoAllowed } = await import("./repo_helpers.cjs"); + const allowedRepos = new Set(["github/*"]); + expect(isRepoAllowed("other-org/gh-aw", allowedRepos)).toBe(false); + }); + + it("should return false for empty set", async () => { + const { isRepoAllowed } = await import("./repo_helpers.cjs"); + expect(isRepoAllowed("org/repo", new Set())).toBe(false); + }); + + it("should return false when no pattern matches", async () => { + const { isRepoAllowed } = await import("./repo_helpers.cjs"); + const allowedRepos = new Set(["org/repo-a", "org/repo-b"]); + expect(isRepoAllowed("org/repo-c", allowedRepos)).toBe(false); + }); + }); + describe("validateRepo", () => { it("should allow default repo", async () => { const { validateRepo } = await import("./repo_helpers.cjs"); @@ -162,6 +199,54 @@ describe("repo_helpers", () => { expect(result.valid).toBe(false); expect(result.error).toContain("not in the allowed-repos list"); }); + + it('should allow any repo when "*" is in allowed list', async () => { + const { validateRepo } = await import("./repo_helpers.cjs"); + const allowedRepos = new Set(["*"]); + const result = validateRepo("any-org/any-repo", "default/repo", allowedRepos); + expect(result.valid).toBe(true); + expect(result.error).toBe(null); + }); + + it('should allow org-scoped wildcard "github/*"', async () => { + const { validateRepo } = await import("./repo_helpers.cjs"); + const allowedRepos = new Set(["github/*"]); + const result = validateRepo("github/gh-aw", "default/repo", allowedRepos); + expect(result.valid).toBe(true); + expect(result.error).toBe(null); + }); + + it('should reject repo not matching org-scoped wildcard "github/*"', async () => { + const { validateRepo } = await import("./repo_helpers.cjs"); + const allowedRepos = new Set(["github/*"]); + const result = validateRepo("other-org/gh-aw", "default/repo", allowedRepos); + expect(result.valid).toBe(false); + expect(result.error).toContain("not in the allowed-repos list"); + }); + + it('should allow repo-scoped wildcard "*/gh-aw"', async () => { + const { validateRepo } = await import("./repo_helpers.cjs"); + const allowedRepos = new Set(["*/gh-aw"]); + const result = validateRepo("any-org/gh-aw", "default/repo", allowedRepos); + expect(result.valid).toBe(true); + expect(result.error).toBe(null); + }); + + it('should allow prefix wildcard "github/gh-*"', async () => { + const { validateRepo } = await import("./repo_helpers.cjs"); + const allowedRepos = new Set(["github/gh-*"]); + const result = validateRepo("github/gh-aw", "default/repo", allowedRepos); + expect(result.valid).toBe(true); + expect(result.error).toBe(null); + }); + + it('should reject repo not matching prefix wildcard "github/gh-*"', async () => { + const { validateRepo } = await import("./repo_helpers.cjs"); + const allowedRepos = new Set(["github/gh-*"]); + const result = validateRepo("github/other-repo", "default/repo", allowedRepos); + expect(result.valid).toBe(false); + expect(result.error).toContain("not in the allowed-repos list"); + }); }); describe("parseRepoSlug", () => {