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
31 changes: 17 additions & 14 deletions actions/setup/js/create_discussion.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand All @@ -184,28 +187,28 @@ 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,
error,
};
}
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,
Expand All @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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 = `
Expand Down Expand Up @@ -304,22 +307,22 @@ 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,
};
} catch (error) {
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,
Expand Down
37 changes: 20 additions & 17 deletions actions/setup/js/create_issue.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand All @@ -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();
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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);
Expand All @@ -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}`);
Expand Down Expand Up @@ -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(", ")}`);
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
32 changes: 24 additions & 8 deletions actions/setup/js/repo_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>} 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,
};
}

Expand Down Expand Up @@ -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,
Expand All @@ -135,7 +151,7 @@ function resolveAndValidateRepo(item, defaultTargetRepo, allowedRepos, operation

return {
success: true,
repo: itemRepo,
repo: qualifiedItemRepo,
repoParts: repoParts,
};
}
Expand Down
62 changes: 60 additions & 2 deletions actions/setup/js/repo_helpers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");

Expand All @@ -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", () => {
Expand Down
Loading