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: 27 additions & 11 deletions actions/setup/js/assign_milestone.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const { logStagedPreviewInfo } = require("./staged_preview.cjs");
const { isStagedMode, checkRequiredFilter } = require("./safe_output_helpers.cjs");
const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
const { loadTemporaryIdMapFromResolved, resolveRepoIssueTarget } = require("./temporary_id.cjs");
const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs");

/** @type {string} Safe output type handled by this module */
const HANDLER_TYPE = "assign_milestone";
Expand Down Expand Up @@ -54,6 +55,10 @@ async function main(config = {}) {
if (requiredLabels.length > 0) core.info(`Required labels (all): ${requiredLabels.join(", ")}`);
if (requiredTitlePrefix) core.info(`Required title prefix: ${requiredTitlePrefix}`);

const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
if (defaultTargetRepo) core.info(`Target repository: ${defaultTargetRepo}`);
if (allowedRepos.size > 0) core.info(`Allowed repositories: ${Array.from(allowedRepos).join(", ")}`);

core.info(`Assign milestone configuration: max=${maxCount}, auto_create=${autoCreate}`);
if (allowedMilestones.length > 0) {
core.info(`Allowed milestones: ${allowedMilestones.join(", ")}`);
Expand All @@ -73,17 +78,19 @@ async function main(config = {}) {
* Find a milestone by title using lazy paginated search with early exit.
* Results are cached so repeated lookups don't re-paginate.
* @param {string} title
* @param {string} owner
* @param {string} repo
* @returns {Promise<Object|null>}
*/
async function findMilestoneByTitle(title) {
async function findMilestoneByTitle(title, owner, repo) {
if (milestoneByTitle.has(title)) {
return milestoneByTitle.get(title);
}
if (milestonesExhausted) {
return null;
}
let found = false;
await githubClient.paginate(githubClient.rest.issues.listMilestones, { owner: context.repo.owner, repo: context.repo.repo, state: "all", per_page: 100 }, (response, done) => {
await githubClient.paginate(githubClient.rest.issues.listMilestones, { owner, repo, state: "all", per_page: 100 }, (response, done) => {
for (const m of response.data) {
if (!milestoneByTitle.has(m.title)) {
milestoneByTitle.set(m.title, m);
Expand Down Expand Up @@ -126,8 +133,17 @@ async function main(config = {}) {
// Convert resolvedTemporaryIds to a normalized Map for resolveRepoIssueTarget
const temporaryIdMap = loadTemporaryIdMapFromResolved(resolvedTemporaryIds);

// Resolve target repo from item or default target-repo config
const repoResult = resolveAndValidateRepo(item, defaultTargetRepo, allowedRepos, "milestone assignment");
if (!repoResult.success) {
core.warning(`assign_milestone: ${repoResult.error}`);
return { success: false, error: repoResult.error };
}
const milestoneOwner = repoResult.repoParts.owner;
const milestoneRepo = repoResult.repoParts.repo;

// Resolve issue_number, which may be a temporary ID (e.g. "aw_abc123") or a plain number
const resolvedIssueTarget = resolveRepoIssueTarget(item.issue_number, temporaryIdMap, context.repo.owner, context.repo.repo);
const resolvedIssueTarget = resolveRepoIssueTarget(item.issue_number, temporaryIdMap, milestoneOwner, milestoneRepo);

// If the issue_number is a temporary ID that hasn't been resolved yet, defer processing
if (resolvedIssueTarget.wasTemporaryId && !resolvedIssueTarget.resolved) {
Expand All @@ -149,7 +165,7 @@ async function main(config = {}) {

const issueNumber = resolvedIssueTarget.resolved.number;

const repoParts = { owner: context.repo.owner, repo: context.repo.repo };
const repoParts = { owner: milestoneOwner, repo: milestoneRepo };
const filterResult = await checkRequiredFilter(githubClient, repoParts, issueNumber, requiredLabels, requiredTitlePrefix, "assign_milestone");
if (filterResult) return filterResult;

Expand All @@ -174,15 +190,15 @@ async function main(config = {}) {
// Resolve milestone by title if milestone_number is not valid
if (!hasMilestoneNumber && milestoneTitle !== null) {
try {
const match = await findMilestoneByTitle(milestoneTitle);
const match = await findMilestoneByTitle(milestoneTitle, milestoneOwner, milestoneRepo);
if (match) {
milestoneNumber = match.number;
core.info(`Resolved milestone title "${milestoneTitle}" to #${milestoneNumber}`);
} else if (autoCreate) {
// Create the milestone automatically
const created = await githubClient.rest.issues.createMilestone({
owner: context.repo.owner,
repo: context.repo.repo,
owner: milestoneOwner,
repo: milestoneRepo,
title: milestoneTitle,
});
milestoneNumber = created.data.number;
Expand Down Expand Up @@ -211,8 +227,8 @@ async function main(config = {}) {
if (allowedMilestones.length > 0) {
try {
const { data: milestone } = await githubClient.rest.issues.getMilestone({
owner: context.repo.owner,
repo: context.repo.repo,
owner: milestoneOwner,
repo: milestoneRepo,
milestone_number: milestoneNumber,
});

Expand Down Expand Up @@ -251,8 +267,8 @@ async function main(config = {}) {
}

await githubClient.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
owner: milestoneOwner,
repo: milestoneRepo,
issue_number: issueNumber,
milestone: milestoneNumber,
});
Expand Down
65 changes: 65 additions & 0 deletions actions/setup/js/assign_milestone.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -376,4 +376,69 @@ describe("assign_milestone (Handler Factory Architecture)", () => {
expect(result.error).toBe("Either milestone_number or milestone_title must be provided");
expect(mockGithub.rest.issues.update).not.toHaveBeenCalled();
});

describe("target-repo support", () => {
it("should use target-repo config for all API calls", async () => {
const { main } = require("./assign_milestone.cjs");
const handlerWithTarget = await main({
max: 10,
"target-repo": "external-org/external-repo",
});

mockGithub.rest.issues.update.mockResolvedValue({});
const paginateCalls = [];
mockGithub.paginate.mockImplementation(async (_method, params, callback) => {
paginateCalls.push(params);
if (callback) {
const done = vi.fn();
callback({ data: [] }, done);
}
return [];
});

const result = await handlerWithTarget({ type: "assign_milestone", issue_number: 42, milestone_number: 5 }, {});

expect(result.success).toBe(true);
expect(mockGithub.rest.issues.update).toHaveBeenCalledWith(expect.objectContaining({ owner: "external-org", repo: "external-repo" }));
});

it("should use context.repo as default when no target-repo configured", async () => {
mockGithub.rest.issues.update.mockResolvedValue({});

const result = await handler({ type: "assign_milestone", issue_number: 42, milestone_number: 5 }, {});

expect(result.success).toBe(true);
expect(mockGithub.rest.issues.update).toHaveBeenCalledWith(expect.objectContaining({ owner: "test-owner", repo: "test-repo" }));
});

it("should use repo from message when allowed_repos is configured", async () => {
const { main } = require("./assign_milestone.cjs");
const handlerWithAllowedRepos = await main({
max: 10,
"target-repo": "default-org/default-repo",
allowed_repos: ["cross-org/cross-repo"],
});

mockGithub.rest.issues.update.mockResolvedValue({});

const result = await handlerWithAllowedRepos({ type: "assign_milestone", issue_number: 42, milestone_number: 5, repo: "cross-org/cross-repo" }, {});

expect(result.success).toBe(true);
expect(mockGithub.rest.issues.update).toHaveBeenCalledWith(expect.objectContaining({ owner: "cross-org", repo: "cross-repo" }));
});

it("should reject repo not in allowed_repos list", async () => {
const { main } = require("./assign_milestone.cjs");
const handlerWithAllowedRepos = await main({
max: 10,
"target-repo": "default-org/default-repo",
allowed_repos: ["allowed-org/allowed-repo"],
});

const result = await handlerWithAllowedRepos({ type: "assign_milestone", issue_number: 42, milestone_number: 5, repo: "unauthorized-org/unauthorized-repo" }, {});

expect(result.success).toBe(false);
expect(result.error).toContain("not in the allowed-repos list");
});
});
});
18 changes: 17 additions & 1 deletion actions/setup/js/close_discussion.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
const { ERR_NOT_FOUND } = require("./error_codes.cjs");
const { resolveNumberFromTemporaryId } = require("./temporary_id.cjs");
const { resolveAllowedMentionsFromPayload } = require("./resolve_mentions_from_payload.cjs");
const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs");

/**
* Get discussion details using GraphQL with pagination for labels
Expand Down Expand Up @@ -172,6 +173,12 @@ async function main(config = {}) {
allowedMentionAliases = await resolveAllowedMentionsFromPayload(context, githubClient, core, config.mentions);
}

const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
if (defaultTargetRepo) {
core.info(`Target repository: ${defaultTargetRepo}`);
}
if (allowedRepos.size > 0) core.info(`Allowed repositories: ${Array.from(allowedRepos).join(", ")}`);

// Check if we're in staged mode
const isStaged = isStagedMode(config);

Expand Down Expand Up @@ -204,6 +211,15 @@ async function main(config = {}) {

processedCount++;

// Resolve and validate target repository for this item
const repoResult = resolveAndValidateRepo(item, defaultTargetRepo, allowedRepos, "discussion");
if (!repoResult.success) {
core.warning(`Skipping close_discussion: ${repoResult.error}`);
return { success: false, error: repoResult.error };
}
const discussionOwner = repoResult.repoParts.owner;
const discussionRepo = repoResult.repoParts.repo;

// Determine discussion number
let discussionNumber;
if (item.discussion_number !== undefined) {
Expand Down Expand Up @@ -235,7 +251,7 @@ async function main(config = {}) {

try {
// Fetch discussion details
const discussion = await getDiscussionDetails(githubClient, context.repo.owner, context.repo.repo, discussionNumber);
const discussion = await getDiscussionDetails(githubClient, discussionOwner, discussionRepo, discussionNumber);

// Validate required labels if configured
if (requiredLabels.length > 0) {
Expand Down
127 changes: 127 additions & 0 deletions actions/setup/js/close_discussion.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -664,4 +664,131 @@ describe("close_discussion", () => {
});
});
});

describe("target-repo support", () => {
it("should use target-repo config for getDiscussionDetails query", async () => {
const handler = await main({
max: 10,
"target-repo": "external-org/external-repo",
});

const queryCalls = [];
mockGithub.graphql = async (query, variables) => {
queryCalls.push({ query, variables });
if (query.includes("closeDiscussion")) {
return { closeDiscussion: { discussion: { id: "D_1", url: "https://github.com/external-org/external-repo/discussions/5" } } };
}
if (query.includes("addDiscussionComment")) {
return { addDiscussionComment: { comment: { id: "DC_1", url: "url" } } };
}
return {
repository: {
discussion: {
id: "D_kwDOTest123",
title: "Test Discussion",
closed: false,
category: { name: "General" },
url: "https://github.com/external-org/external-repo/discussions/5",
labels: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } },
},
},
};
};

const result = await handler({ discussion_number: 5, body: "Closing" }, {});

expect(result.success).toBe(true);
const detailsQuery = queryCalls.find(c => c.query.includes("repository(owner"));
expect(detailsQuery).toBeDefined();
expect(detailsQuery.variables.owner).toBe("external-org");
expect(detailsQuery.variables.repo).toBe("external-repo");
});

it("should use context.repo as default when no target-repo configured", async () => {
const handler = await main({ max: 10 });

const queryCalls = [];
mockGithub.graphql = async (query, variables) => {
queryCalls.push({ query, variables });
if (query.includes("closeDiscussion")) {
return { closeDiscussion: { discussion: { id: "D_1", url: "url" } } };
}
if (query.includes("addDiscussionComment")) {
return { addDiscussionComment: { comment: { id: "DC_1", url: "url" } } };
}
return {
repository: {
discussion: {
id: "D_kwDOTest123",
title: "Test Discussion",
closed: false,
category: { name: "General" },
url: "https://github.com/test-owner/test-repo/discussions/42",
labels: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } },
},
},
};
};

const result = await handler({ discussion_number: 42, body: "Closing" }, {});

expect(result.success).toBe(true);
const detailsQuery = queryCalls.find(c => c.query.includes("repository(owner"));
expect(detailsQuery).toBeDefined();
expect(detailsQuery.variables.owner).toBe("test-owner");
expect(detailsQuery.variables.repo).toBe("test-repo");
});

it("should reject item.repo not in allowed_repos", async () => {
const handler = await main({
max: 10,
"target-repo": "default-org/default-repo",
allowed_repos: ["default-org/default-repo"],
});

const result = await handler({ discussion_number: 5, repo: "evil-org/evil-repo" }, {});

expect(result.success).toBe(false);
expect(result.error).toMatch(/not allowed|evil-org\/evil-repo/);
});

it("should use item.repo when it is in allowed_repos", async () => {
const handler = await main({
max: 10,
"target-repo": "default-org/default-repo",
allowed_repos: ["default-org/default-repo", "other-org/other-repo"],
});

const queryCalls = [];
mockGithub.graphql = async (query, variables) => {
queryCalls.push({ query, variables });
if (query.includes("closeDiscussion")) {
return { closeDiscussion: { discussion: { id: "D_1", url: "https://github.com/other-org/other-repo/discussions/9" } } };
}
if (query.includes("addDiscussionComment")) {
return { addDiscussionComment: { comment: { id: "DC_1", url: "url" } } };
}
return {
repository: {
discussion: {
id: "D_kwDOOther",
title: "Other Discussion",
closed: false,
category: { name: "General" },
url: "https://github.com/other-org/other-repo/discussions/9",
labels: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } },
},
},
};
};

const result = await handler({ discussion_number: 9, repo: "other-org/other-repo" }, {});

expect(result.success).toBe(true);
const detailsQuery = queryCalls.find(c => c.query.includes("repository(owner"));
expect(detailsQuery).toBeDefined();
expect(detailsQuery.variables.owner).toBe("other-org");
expect(detailsQuery.variables.repo).toBe("other-repo");
});
});
});
Loading
Loading