Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 8 additions & 30 deletions actions/setup/js/create_discussion.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
38 changes: 8 additions & 30 deletions actions/setup/js/create_issue.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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");
Expand Down
37 changes: 34 additions & 3 deletions actions/setup/js/repo_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

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

The documentation comment uses "STAR" as a placeholder instead of the actual asterisk character. This is unnecessarily confusing. The comment should directly show the pattern as "*/gh-aw" to match how the other patterns are documented on lines 55-56.

Suggested change
* - "STAR/gh-aw" (where STAR is *) matches "gh-aw" in any org
* - "*/gh-aw" matches "gh-aw" in any org

Copilot uses AI. Check for mistakes.
* @param {string} qualifiedRepo - Fully qualified repo slug "owner/repo"
* @param {Set<string>} 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)) {

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

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

The regex pattern is being compiled on every iteration of the loop. While the performance impact is likely minimal given the small size of typical allowedRepos sets and the fast-path exact match on line 64, this could be optimized by caching compiled patterns. Consider precompiling patterns when parseAllowedRepos is called, storing both the original pattern string and its compiled regex in a Map or similar structure.

Copilot uses AI. Check for mistakes.
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<string>} allowedRepos - Set of explicitly allowed repos
* @param {Set<string>} allowedRepos - Set of explicitly allowed repo patterns
* @returns {{valid: boolean, error: string|null, qualifiedRepo: string}}
*/
function validateRepo(repo, defaultRepo, allowedRepos) {
Expand All @@ -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 {
Expand Down Expand Up @@ -159,6 +189,7 @@ function resolveAndValidateRepo(item, defaultTargetRepo, allowedRepos, operation
module.exports = {
parseAllowedRepos,
getDefaultTargetRepo,
isRepoAllowed,
validateRepo,
parseRepoSlug,
resolveTargetRepoConfig,
Expand Down
85 changes: 85 additions & 0 deletions actions/setup/js/repo_helpers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading