diff --git a/actions/setup/js/create_discussion.cjs b/actions/setup/js/create_discussion.cjs index 550a961d18..6069bfb18d 100644 --- a/actions/setup/js/create_discussion.cjs +++ b/actions/setup/js/create_discussion.cjs @@ -172,8 +172,11 @@ async function main(config = {}) { }; } + // Use the qualified repo from validation (handles bare names like "gh-aw" -> "githubnext/gh-aw") + const qualifiedItemRepo = repoValidation.qualifiedRepo; + // Parse repository slug - const repoParts = parseRepoSlug(itemRepo); + const repoParts = parseRepoSlug(qualifiedItemRepo); if (!repoParts) { const error = `Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`; core.warning(`Skipping discussion: ${error}`); @@ -184,12 +187,12 @@ async function main(config = {}) { } // Get repository info (cached) - let repoInfo = repoInfoCache.get(itemRepo); + let repoInfo = repoInfoCache.get(qualifiedItemRepo); if (!repoInfo) { try { const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); if (!fetchedInfo) { - const error = `Failed to fetch repository information for '${itemRepo}'`; + const error = `Failed to fetch repository information for '${qualifiedItemRepo}'`; core.warning(error); return { success: false, @@ -197,15 +200,15 @@ async function main(config = {}) { }; } repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}`); + repoInfoCache.set(qualifiedItemRepo, repoInfo); + core.info(`Fetched discussion categories for ${qualifiedItemRepo}`); } catch (error) { const errorMessage = getErrorMessage(error); // Provide enhanced error message with troubleshooting hints const enhancedError = - `Failed to fetch repository information for '${itemRepo}': ${errorMessage}. ` + + `Failed to fetch repository information for '${qualifiedItemRepo}': ${errorMessage}. ` + `This may indicate that discussions are not enabled for this repository. ` + - `Please verify that discussions are enabled in the repository settings at https://github.com/${itemRepo}/settings.`; + `Please verify that discussions are enabled in the repository settings at https://github.com/${qualifiedItemRepo}/settings.`; core.error(enhancedError); return { success: false, @@ -217,7 +220,7 @@ async function main(config = {}) { // Resolve category const resolvedCategory = resolveCategoryId(configCategory, item.category, repoInfo.discussionCategories); if (!resolvedCategory) { - const error = `No discussion categories available in ${itemRepo}`; + const error = `No discussion categories available in ${qualifiedItemRepo}`; core.error(error); return { success: false, @@ -230,7 +233,7 @@ async function main(config = {}) { // Build title let title = item.title ? item.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(item.body || "", temporaryIdMap, itemRepo); + let processedBody = replaceTemporaryIdReferences(item.body || "", temporaryIdMap, qualifiedItemRepo); processedBody = removeDuplicateTitleFromDescription(title, processedBody); if (!title) { @@ -266,7 +269,7 @@ async function main(config = {}) { bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); + core.info(`Creating discussion in ${qualifiedItemRepo} with title: ${title}`); try { const createDiscussionMutation = ` @@ -304,11 +307,11 @@ async function main(config = {}) { }; } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); + core.info(`Created discussion ${qualifiedItemRepo}#${discussion.number}: ${discussion.url}`); return { success: true, - repo: itemRepo, + repo: qualifiedItemRepo, number: discussion.number, url: discussion.url, }; @@ -316,10 +319,10 @@ async function main(config = {}) { const errorMessage = getErrorMessage(error); // Provide enhanced error message with troubleshooting hints const enhancedError = - `Failed to create discussion in '${itemRepo}': ${errorMessage}. ` + + `Failed to create discussion in '${qualifiedItemRepo}': ${errorMessage}. ` + `Common causes: (1) Discussions not enabled in repository settings, ` + `(2) Invalid category ID, or (3) Insufficient permissions. ` + - `Verify discussions are enabled at https://github.com/${itemRepo}/settings and check the category configuration.`; + `Verify discussions are enabled at https://github.com/${qualifiedItemRepo}/settings and check the category configuration.`; core.error(enhancedError); return { success: false, diff --git a/actions/setup/js/create_issue.cjs b/actions/setup/js/create_issue.cjs index 7f7a6f2ce4..abbeaa9bad 100644 --- a/actions/setup/js/create_issue.cjs +++ b/actions/setup/js/create_issue.cjs @@ -111,8 +111,11 @@ async function main(config = {}) { }; } + // Use the qualified repo from validation (handles bare names like "gh-aw" -> "githubnext/gh-aw") + const qualifiedItemRepo = repoValidation.qualifiedRepo; + // Parse the repository slug - const repoParts = parseRepoSlug(itemRepo); + const repoParts = parseRepoSlug(qualifiedItemRepo); if (!repoParts) { const error = `Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`; core.warning(`Skipping issue: ${error}`); @@ -124,11 +127,11 @@ async function main(config = {}) { // Get or generate the temporary ID for this issue const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create_issue: title=${createIssueItem.title}, bodyLength=${createIssueItem.body?.length || 0}, temporaryId=${temporaryId}, repo=${itemRepo}`); + core.info(`Processing create_issue: title=${createIssueItem.title}, bodyLength=${createIssueItem.body?.length || 0}, temporaryId=${temporaryId}, repo=${qualifiedItemRepo}`); // Resolve parent: check if it's a temporary ID reference let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; // Default to same repo + let effectiveParentRepo = qualifiedItemRepo; // Default to same repo if (createIssueItem.parent !== undefined) { // Strip # prefix if present to allow flexible temporary ID format const parentStr = String(createIssueItem.parent).trim(); @@ -162,7 +165,7 @@ async function main(config = {}) { } else { // Only use context parent if we're in the same repo as context const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { + if (qualifiedItemRepo === contextRepo) { effectiveParentIssueNumber = parentIssueNumber; } } @@ -195,7 +198,7 @@ async function main(config = {}) { let title = createIssueItem.title ? createIssueItem.title.trim() : ""; // Replace temporary ID references in the body using already-created issues - let processedBody = replaceTemporaryIdReferences(createIssueItem.body || "", temporaryIdMap, itemRepo); + let processedBody = replaceTemporaryIdReferences(createIssueItem.body || "", temporaryIdMap, qualifiedItemRepo); // Remove duplicate title from description if it starts with a header matching the title processedBody = removeDuplicateTitleFromDescription(title, processedBody); @@ -215,7 +218,7 @@ async function main(config = {}) { if (effectiveParentIssueNumber) { core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); // Use full repo reference if cross-repo, short reference if same repo - if (effectiveParentRepo === itemRepo) { + if (effectiveParentRepo === qualifiedItemRepo) { bodyLines.push(`Related to #${effectiveParentIssueNumber}`); } else { bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); @@ -247,7 +250,7 @@ async function main(config = {}) { bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); + core.info(`Creating issue in ${qualifiedItemRepo} with title: ${title}`); core.info(`Labels: ${labels.join(", ")}`); if (assignees.length > 0) { core.info(`Assignees: ${assignees.join(", ")}`); @@ -264,15 +267,15 @@ async function main(config = {}) { assignees: assignees, }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); + core.info(`Created issue ${qualifiedItemRepo}#${issue.number}: ${issue.html_url}`); + createdIssues.push({ ...issue, _repo: qualifiedItemRepo }); // Store the mapping of temporary_id -> {repo, number} - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); + temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: qualifiedItemRepo, number: issue.number }); + core.info(`Stored temporary ID mapping: ${temporaryId} -> ${qualifiedItemRepo}#${issue.number}`); // Sub-issue linking only works within the same repository - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { + if (effectiveParentIssueNumber && effectiveParentRepo === qualifiedItemRepo) { core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); try { // First, get the node IDs for both parent and child issues @@ -345,30 +348,30 @@ async function main(config = {}) { core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); } } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { + } else if (effectiveParentIssueNumber && effectiveParentRepo !== qualifiedItemRepo) { core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); } // Return result with temporary ID mapping info return { success: true, - repo: itemRepo, + repo: qualifiedItemRepo, number: issue.number, url: issue.html_url, temporaryId: temporaryId, - _repo: itemRepo, // For tracking in the closure + _repo: qualifiedItemRepo, // For tracking in the closure }; } catch (error) { const errorMessage = getErrorMessage(error); if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); + core.info(`⚠ Cannot create issue "${title}" in ${qualifiedItemRepo}: Issues are disabled for this repository`); core.info("Consider enabling issues in repository settings if you want to create issues automatically"); return { success: false, error: "Issues disabled for repository", }; } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); + core.error(`✗ Failed to create issue "${title}" in ${qualifiedItemRepo}: ${errorMessage}`); return { success: false, error: errorMessage, diff --git a/actions/setup/js/repo_helpers.cjs b/actions/setup/js/repo_helpers.cjs index e3a3fb5365..86f457ee48 100644 --- a/actions/setup/js/repo_helpers.cjs +++ b/actions/setup/js/repo_helpers.cjs @@ -49,23 +49,36 @@ function getDefaultTargetRepo(config) { /** * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate + * If repo is a bare name (no slash), it is automatically qualified with the + * default repo's organization (e.g., "gh-aw" becomes "githubnext/gh-aw" if + * the default repo is "githubnext/something"). + * @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 - * @returns {{valid: boolean, error: string|null}} + * @returns {{valid: boolean, error: string|null, qualifiedRepo: string}} */ function validateRepo(repo, defaultRepo, allowedRepos) { + // If repo is a bare name (no slash), qualify it with the default repo's org + let qualifiedRepo = repo; + if (!repo.includes("/")) { + const defaultRepoParts = parseRepoSlug(defaultRepo); + if (defaultRepoParts) { + qualifiedRepo = `${defaultRepoParts.owner}/${repo}`; + } + } + // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; + if (qualifiedRepo === defaultRepo) { + return { valid: true, error: null, qualifiedRepo }; } // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; + if (allowedRepos.has(qualifiedRepo)) { + return { valid: true, error: null, qualifiedRepo }; } return { valid: false, error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + qualifiedRepo, }; } @@ -124,8 +137,11 @@ function resolveAndValidateRepo(item, defaultTargetRepo, allowedRepos, operation }; } + // Use the qualified repo from validation (handles bare names) + const qualifiedItemRepo = repoValidation.qualifiedRepo; + // Parse the repository slug - const repoParts = parseRepoSlug(itemRepo); + const repoParts = parseRepoSlug(qualifiedItemRepo); if (!repoParts) { return { success: false, @@ -135,7 +151,7 @@ function resolveAndValidateRepo(item, defaultTargetRepo, allowedRepos, operation return { success: true, - repo: itemRepo, + repo: qualifiedItemRepo, repoParts: repoParts, }; } diff --git a/actions/setup/js/repo_helpers.test.cjs b/actions/setup/js/repo_helpers.test.cjs index 7e8434eee0..80e44a9918 100644 --- a/actions/setup/js/repo_helpers.test.cjs +++ b/actions/setup/js/repo_helpers.test.cjs @@ -131,6 +131,37 @@ describe("repo_helpers", () => { expect(result.error).toContain("org/repo-a"); expect(result.error).toContain("org/repo-b"); }); + + it("should qualify bare repo name with default repo's org", async () => { + const { validateRepo } = await import("./repo_helpers.cjs"); + const allowedRepos = new Set(["githubnext/gh-aw"]); + const result = validateRepo("gh-aw", "githubnext/other-repo", allowedRepos); + expect(result.valid).toBe(true); + expect(result.error).toBe(null); + }); + + it("should allow bare repo name matching default repo", async () => { + const { validateRepo } = await import("./repo_helpers.cjs"); + const result = validateRepo("gh-aw", "githubnext/gh-aw", new Set()); + expect(result.valid).toBe(true); + expect(result.error).toBe(null); + }); + + it("should reject bare repo name not in allowed list", async () => { + const { validateRepo } = await import("./repo_helpers.cjs"); + const allowedRepos = new Set(["githubnext/other-repo"]); + const result = validateRepo("gh-aw", "githubnext/default-repo", allowedRepos); + expect(result.valid).toBe(false); + expect(result.error).toContain("not in the allowed-repos list"); + }); + + it("should not qualify repo name that already has org", async () => { + const { validateRepo } = await import("./repo_helpers.cjs"); + const allowedRepos = new Set(["githubnext/gh-aw"]); + const result = validateRepo("other-org/gh-aw", "githubnext/default-repo", allowedRepos); + expect(result.valid).toBe(false); + expect(result.error).toContain("not in the allowed-repos list"); + }); }); describe("parseRepoSlug", () => { @@ -206,9 +237,10 @@ describe("repo_helpers", () => { it("should fail with invalid repo format", async () => { const { resolveAndValidateRepo } = await import("./repo_helpers.cjs"); - const item = { repo: "invalid-format" }; + // Use a repo with slash but invalid format (empty parts) + const item = { repo: "owner/" }; const defaultRepo = "default/repo"; - const allowedRepos = new Set(["invalid-format"]); + const allowedRepos = new Set(["owner/"]); const result = resolveAndValidateRepo(item, defaultRepo, allowedRepos, "test"); @@ -228,6 +260,32 @@ describe("repo_helpers", () => { expect(result.success).toBe(true); expect(result.repo).toBe("org/trimmed-repo"); }); + + it("should qualify bare repo name and return qualified version", async () => { + const { resolveAndValidateRepo } = await import("./repo_helpers.cjs"); + const item = { repo: "gh-aw" }; + const defaultRepo = "githubnext/other-repo"; + const allowedRepos = new Set(["githubnext/gh-aw"]); + + const result = resolveAndValidateRepo(item, defaultRepo, allowedRepos, "test"); + + expect(result.success).toBe(true); + expect(result.repo).toBe("githubnext/gh-aw"); + expect(result.repoParts).toEqual({ owner: "githubnext", repo: "gh-aw" }); + }); + + it("should qualify bare repo name matching default repo", async () => { + const { resolveAndValidateRepo } = await import("./repo_helpers.cjs"); + const item = { repo: "gh-aw" }; + const defaultRepo = "githubnext/gh-aw"; + const allowedRepos = new Set(); + + const result = resolveAndValidateRepo(item, defaultRepo, allowedRepos, "test"); + + expect(result.success).toBe(true); + expect(result.repo).toBe("githubnext/gh-aw"); + expect(result.repoParts).toEqual({ owner: "githubnext", repo: "gh-aw" }); + }); }); describe("resolveTargetRepoConfig", () => {