diff --git a/actions/setup/js/resolve_mentions_from_payload.cjs b/actions/setup/js/resolve_mentions_from_payload.cjs index e51ee1d84af..738df027c07 100644 --- a/actions/setup/js/resolve_mentions_from_payload.cjs +++ b/actions/setup/js/resolve_mentions_from_payload.cjs @@ -8,6 +8,99 @@ const { resolveMentionsLazily, isPayloadUserBot } = require("./resolve_mentions.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); +/** + * Push a non-bot user's login to the array if present. + * @param {string[]} users - Target array + * @param {{ login?: string, type?: string } | null | undefined} user - User object from payload + */ +function pushNonBotUser(users, user) { + if (user?.login && !isPayloadUserBot(user)) { + users.push(user.login); + } +} + +/** + * Push non-bot assignee logins to the array. + * @param {string[]} users - Target array + * @param {Array<{ login?: string, type?: string }> | null | undefined} assignees - Assignees from payload + */ +function pushNonBotAssignees(users, assignees) { + if (Array.isArray(assignees)) { + for (const assignee of assignees) { + pushNonBotUser(users, assignee); + } + } +} + +/** + * Extract known authors from a GitHub event payload based on event type. + * @param {any} context - GitHub Actions context + * @returns {string[]} Array of known author logins from the payload + */ +function extractKnownAuthorsFromPayload(context) { + if (!context || typeof context !== "object") { + return []; + } + + const users = /** @type {string[]} */ []; + const { eventName, payload = {} } = context; + + switch (eventName) { + case "issues": + pushNonBotUser(users, payload.issue?.user); + pushNonBotAssignees(users, payload.issue?.assignees); + break; + + case "pull_request": + case "pull_request_target": + pushNonBotUser(users, payload.pull_request?.user); + pushNonBotAssignees(users, payload.pull_request?.assignees); + break; + + case "issue_comment": + pushNonBotUser(users, payload.comment?.user); + pushNonBotUser(users, payload.issue?.user); + pushNonBotAssignees(users, payload.issue?.assignees); + break; + + case "pull_request_review_comment": + pushNonBotUser(users, payload.comment?.user); + pushNonBotUser(users, payload.pull_request?.user); + pushNonBotAssignees(users, payload.pull_request?.assignees); + break; + + case "pull_request_review": + pushNonBotUser(users, payload.review?.user); + pushNonBotUser(users, payload.pull_request?.user); + pushNonBotAssignees(users, payload.pull_request?.assignees); + break; + + case "discussion": + pushNonBotUser(users, payload.discussion?.user); + break; + + case "discussion_comment": + pushNonBotUser(users, payload.comment?.user); + pushNonBotUser(users, payload.discussion?.user); + break; + + case "release": + pushNonBotUser(users, payload.release?.author); + break; + + case "workflow_dispatch": + if (typeof context.actor === "string" && context.actor.length > 0) { + users.push(context.actor); + } + break; + + default: + break; + } + + return users; +} + /** * Resolve allowed mentions from the current GitHub event context * @param {any} context - GitHub Actions context @@ -23,17 +116,12 @@ async function resolveAllowedMentionsFromPayload(context, github, core, mentions return []; } - // Handle mentions configuration // If mentions is explicitly set to false, return empty array (all mentions escaped) if (mentionsConfig && mentionsConfig.enabled === false) { core.info("[MENTIONS] Mentions explicitly disabled - all mentions will be escaped"); return []; } - // If mentions is explicitly set to true, we still need to resolve from payload - // but we'll be more permissive. In strict mode, this should error before reaching here. - const allowAllMentions = mentionsConfig && mentionsConfig.enabled === true; - // Get configuration options (with defaults) const allowTeamMembers = mentionsConfig?.allowTeamMembers !== false; // default: true const allowContext = mentionsConfig?.allowContext !== false; // default: true @@ -42,119 +130,9 @@ async function resolveAllowedMentionsFromPayload(context, github, core, mentions try { const { owner, repo } = context.repo; - const knownAuthors = []; - - // Extract known authors from the event payload (if allow-context is enabled) - if (allowContext) { - switch (context.eventName) { - case "issues": - if (context.payload.issue?.user?.login && !isPayloadUserBot(context.payload.issue.user)) { - knownAuthors.push(context.payload.issue.user.login); - } - if (context.payload.issue?.assignees && Array.isArray(context.payload.issue.assignees)) { - for (const assignee of context.payload.issue.assignees) { - if (assignee?.login && !isPayloadUserBot(assignee)) { - knownAuthors.push(assignee.login); - } - } - } - break; - - case "pull_request": - case "pull_request_target": - if (context.payload.pull_request?.user?.login && !isPayloadUserBot(context.payload.pull_request.user)) { - knownAuthors.push(context.payload.pull_request.user.login); - } - if (context.payload.pull_request?.assignees && Array.isArray(context.payload.pull_request.assignees)) { - for (const assignee of context.payload.pull_request.assignees) { - if (assignee?.login && !isPayloadUserBot(assignee)) { - knownAuthors.push(assignee.login); - } - } - } - break; - - case "issue_comment": - if (context.payload.comment?.user?.login && !isPayloadUserBot(context.payload.comment.user)) { - knownAuthors.push(context.payload.comment.user.login); - } - if (context.payload.issue?.user?.login && !isPayloadUserBot(context.payload.issue.user)) { - knownAuthors.push(context.payload.issue.user.login); - } - if (context.payload.issue?.assignees && Array.isArray(context.payload.issue.assignees)) { - for (const assignee of context.payload.issue.assignees) { - if (assignee?.login && !isPayloadUserBot(assignee)) { - knownAuthors.push(assignee.login); - } - } - } - break; - - case "pull_request_review_comment": - if (context.payload.comment?.user?.login && !isPayloadUserBot(context.payload.comment.user)) { - knownAuthors.push(context.payload.comment.user.login); - } - if (context.payload.pull_request?.user?.login && !isPayloadUserBot(context.payload.pull_request.user)) { - knownAuthors.push(context.payload.pull_request.user.login); - } - if (context.payload.pull_request?.assignees && Array.isArray(context.payload.pull_request.assignees)) { - for (const assignee of context.payload.pull_request.assignees) { - if (assignee?.login && !isPayloadUserBot(assignee)) { - knownAuthors.push(assignee.login); - } - } - } - break; - - case "pull_request_review": - if (context.payload.review?.user?.login && !isPayloadUserBot(context.payload.review.user)) { - knownAuthors.push(context.payload.review.user.login); - } - if (context.payload.pull_request?.user?.login && !isPayloadUserBot(context.payload.pull_request.user)) { - knownAuthors.push(context.payload.pull_request.user.login); - } - if (context.payload.pull_request?.assignees && Array.isArray(context.payload.pull_request.assignees)) { - for (const assignee of context.payload.pull_request.assignees) { - if (assignee?.login && !isPayloadUserBot(assignee)) { - knownAuthors.push(assignee.login); - } - } - } - break; - - case "discussion": - if (context.payload.discussion?.user?.login && !isPayloadUserBot(context.payload.discussion.user)) { - knownAuthors.push(context.payload.discussion.user.login); - } - break; - - case "discussion_comment": - if (context.payload.comment?.user?.login && !isPayloadUserBot(context.payload.comment.user)) { - knownAuthors.push(context.payload.comment.user.login); - } - if (context.payload.discussion?.user?.login && !isPayloadUserBot(context.payload.discussion.user)) { - knownAuthors.push(context.payload.discussion.user.login); - } - break; - - case "release": - if (context.payload.release?.author?.login && !isPayloadUserBot(context.payload.release.author)) { - knownAuthors.push(context.payload.release.author.login); - } - break; - - case "workflow_dispatch": - // Add the actor who triggered the workflow - knownAuthors.push(context.actor); - break; - - default: - // No known authors for other event types - break; - } - } + const knownAuthors = allowContext ? extractKnownAuthorsFromPayload(context) : []; - // Add allowed list to known authors (these are always allowed regardless of configuration) + // Add allowed list (always included regardless of configuration) knownAuthors.push(...allowedList); // Add extra known authors (e.g. pre-fetched target issue authors for explicit item_number) @@ -166,12 +144,10 @@ async function resolveAllowedMentionsFromPayload(context, github, core, mentions // If allow-team-members is disabled, only use known authors (context + allowed list) if (!allowTeamMembers) { core.info(`[MENTIONS] Team members disabled - only allowing context (${knownAuthors.length} users)`); - // Apply max limit - const limitedMentions = knownAuthors.slice(0, maxMentions); if (knownAuthors.length > maxMentions) { core.warning(`[MENTIONS] Mention limit exceeded: ${knownAuthors.length} mentions, limiting to ${maxMentions}`); } - return limitedMentions; + return knownAuthors.slice(0, maxMentions); } // Build allowed mentions list from known authors and collaborators @@ -186,7 +162,6 @@ async function resolveAllowedMentionsFromPayload(context, github, core, mentions allowedMentions = allowedMentions.slice(0, maxMentions); } - // Log allowed mentions for debugging if (allowedMentions.length > 0) { core.info(`[OUTPUT COLLECTOR] Allowed mentions: ${allowedMentions.join(", ")}`); } else { @@ -196,11 +171,13 @@ async function resolveAllowedMentionsFromPayload(context, github, core, mentions return allowedMentions; } catch (error) { core.warning(`Failed to resolve mentions for output collector: ${getErrorMessage(error)}`); - // Return empty array on error return []; } } module.exports = { resolveAllowedMentionsFromPayload, + extractKnownAuthorsFromPayload, + pushNonBotUser, + pushNonBotAssignees, }; diff --git a/actions/setup/js/resolve_mentions_from_payload.test.cjs b/actions/setup/js/resolve_mentions_from_payload.test.cjs new file mode 100644 index 00000000000..65ed3b3a43f --- /dev/null +++ b/actions/setup/js/resolve_mentions_from_payload.test.cjs @@ -0,0 +1,370 @@ +// @ts-check +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// Mock dependencies before importing the module +vi.mock("./resolve_mentions.cjs", () => ({ + resolveMentionsLazily: vi.fn(async (_text, knownAuthors) => ({ + allowedMentions: knownAuthors, + totalMentions: knownAuthors.length, + resolvedCount: 0, + limitExceeded: false, + })), + isPayloadUserBot: vi.fn(user => user?.type === "Bot"), +})); + +vi.mock("./error_helpers.cjs", () => ({ + getErrorMessage: vi.fn(err => (err instanceof Error ? err.message : String(err))), +})); + +const { resolveAllowedMentionsFromPayload, extractKnownAuthorsFromPayload, pushNonBotUser, pushNonBotAssignees } = await import("./resolve_mentions_from_payload.cjs"); + +/** @returns {{ info: ReturnType, warning: ReturnType, error: ReturnType }} */ +function makeMockCore() { + return { info: vi.fn(), warning: vi.fn(), error: vi.fn() }; +} + +/** @returns {any} */ +function makeMockGithub() { + return {}; +} + +describe("pushNonBotUser", () => { + it("pushes a regular user login", () => { + const users = /** @type {string[]} */ []; + pushNonBotUser(users, { login: "alice", type: "User" }); + expect(users).toEqual(["alice"]); + }); + + it("skips bot users", () => { + const users = /** @type {string[]} */ []; + pushNonBotUser(users, { login: "dependabot", type: "Bot" }); + expect(users).toEqual([]); + }); + + it("skips null user", () => { + const users = /** @type {string[]} */ []; + pushNonBotUser(users, null); + expect(users).toEqual([]); + }); + + it("skips user without login", () => { + const users = /** @type {string[]} */ []; + pushNonBotUser(users, { type: "User" }); + expect(users).toEqual([]); + }); +}); + +describe("pushNonBotAssignees", () => { + it("pushes non-bot assignees", () => { + const users = /** @type {string[]} */ []; + pushNonBotAssignees(users, [ + { login: "alice", type: "User" }, + { login: "bot", type: "Bot" }, + { login: "bob", type: "User" }, + ]); + expect(users).toEqual(["alice", "bob"]); + }); + + it("handles null assignees", () => { + const users = /** @type {string[]} */ []; + pushNonBotAssignees(users, null); + expect(users).toEqual([]); + }); + + it("handles empty assignees array", () => { + const users = /** @type {string[]} */ []; + pushNonBotAssignees(users, []); + expect(users).toEqual([]); + }); +}); + +describe("extractKnownAuthorsFromPayload", () => { + it("returns empty array when context is undefined", () => { + expect(extractKnownAuthorsFromPayload(undefined)).toEqual([]); + }); + + it("extracts issue author and assignees for issues event", () => { + const context = { + eventName: "issues", + payload: { + issue: { + user: { login: "alice", type: "User" }, + assignees: [{ login: "bob", type: "User" }], + }, + }, + }; + expect(extractKnownAuthorsFromPayload(context)).toEqual(["alice", "bob"]); + }); + + it("skips bot issue author", () => { + const context = { + eventName: "issues", + payload: { + issue: { + user: { login: "dependabot", type: "Bot" }, + assignees: [], + }, + }, + }; + expect(extractKnownAuthorsFromPayload(context)).toEqual([]); + }); + + it("extracts PR author and assignees for pull_request event", () => { + const context = { + eventName: "pull_request", + payload: { + pull_request: { + user: { login: "carol", type: "User" }, + assignees: [{ login: "dave", type: "User" }], + }, + }, + }; + expect(extractKnownAuthorsFromPayload(context)).toEqual(["carol", "dave"]); + }); + + it("pull_request_target is handled same as pull_request", () => { + const context = { + eventName: "pull_request_target", + payload: { + pull_request: { + user: { login: "eve", type: "User" }, + assignees: [], + }, + }, + }; + expect(extractKnownAuthorsFromPayload(context)).toEqual(["eve"]); + }); + + it("extracts comment author and issue author for issue_comment event", () => { + const context = { + eventName: "issue_comment", + payload: { + comment: { user: { login: "frank", type: "User" } }, + issue: { + user: { login: "grace", type: "User" }, + assignees: [], + }, + }, + }; + expect(extractKnownAuthorsFromPayload(context)).toEqual(["frank", "grace"]); + }); + + it("extracts authors for pull_request_review_comment event", () => { + const context = { + eventName: "pull_request_review_comment", + payload: { + comment: { user: { login: "henry", type: "User" } }, + pull_request: { + user: { login: "iris", type: "User" }, + assignees: [], + }, + }, + }; + expect(extractKnownAuthorsFromPayload(context)).toEqual(["henry", "iris"]); + }); + + it("extracts authors for pull_request_review event", () => { + const context = { + eventName: "pull_request_review", + payload: { + review: { user: { login: "jack", type: "User" } }, + pull_request: { + user: { login: "kate", type: "User" }, + assignees: [], + }, + }, + }; + expect(extractKnownAuthorsFromPayload(context)).toEqual(["jack", "kate"]); + }); + + it("extracts discussion author for discussion event", () => { + const context = { + eventName: "discussion", + payload: { + discussion: { user: { login: "lily", type: "User" } }, + }, + }; + expect(extractKnownAuthorsFromPayload(context)).toEqual(["lily"]); + }); + + it("extracts comment and discussion author for discussion_comment event", () => { + const context = { + eventName: "discussion_comment", + payload: { + comment: { user: { login: "mike", type: "User" } }, + discussion: { user: { login: "nina", type: "User" } }, + }, + }; + expect(extractKnownAuthorsFromPayload(context)).toEqual(["mike", "nina"]); + }); + + it("extracts release author for release event", () => { + const context = { + eventName: "release", + payload: { + release: { author: { login: "oscar", type: "User" } }, + }, + }; + expect(extractKnownAuthorsFromPayload(context)).toEqual(["oscar"]); + }); + + it("extracts actor for workflow_dispatch event", () => { + const context = { + eventName: "workflow_dispatch", + actor: "pat", + payload: {}, + }; + expect(extractKnownAuthorsFromPayload(context)).toEqual(["pat"]); + }); + + it("skips invalid actor values for workflow_dispatch event", () => { + const invalidActors = [123, null, undefined, ""]; + + for (const actor of invalidActors) { + const context = { + eventName: "workflow_dispatch", + actor, + payload: {}, + }; + expect(extractKnownAuthorsFromPayload(context)).toEqual([]); + } + }); + + it("returns empty array for unknown event types", () => { + const context = { + eventName: "unknown_event", + payload: {}, + }; + expect(extractKnownAuthorsFromPayload(context)).toEqual([]); + }); +}); + +describe("resolveAllowedMentionsFromPayload", () => { + let mockCore; + let mockGithub; + + beforeEach(() => { + mockCore = makeMockCore(); + mockGithub = makeMockGithub(); + vi.clearAllMocks(); + }); + + it("returns empty array when context is null", async () => { + const result = await resolveAllowedMentionsFromPayload(null, mockGithub, mockCore); + expect(result).toEqual([]); + }); + + it("returns empty array when github is null", async () => { + const context = { eventName: "issues", payload: {}, repo: { owner: "o", repo: "r" } }; + const result = await resolveAllowedMentionsFromPayload(context, null, mockCore); + expect(result).toEqual([]); + }); + + it("returns empty array when core is null", async () => { + const context = { eventName: "issues", payload: {}, repo: { owner: "o", repo: "r" } }; + const result = await resolveAllowedMentionsFromPayload(context, mockGithub, null); + expect(result).toEqual([]); + }); + + it("returns empty array when mentions explicitly disabled", async () => { + const context = { eventName: "issues", payload: {}, repo: { owner: "o", repo: "r" } }; + const result = await resolveAllowedMentionsFromPayload(context, mockGithub, mockCore, { enabled: false }); + expect(result).toEqual([]); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("disabled")); + }); + + it("respects allowContext: false by skipping payload extraction", async () => { + const context = { + eventName: "issues", + payload: { issue: { user: { login: "alice", type: "User" }, assignees: [] } }, + repo: { owner: "o", repo: "r" }, + }; + const result = await resolveAllowedMentionsFromPayload(context, mockGithub, mockCore, { allowContext: false, allowTeamMembers: false }); + expect(result).not.toContain("alice"); + }); + + it("resolves mentions with team members enabled", async () => { + const context = { + eventName: "issues", + payload: { issue: { user: { login: "alice", type: "User" }, assignees: [] } }, + repo: { owner: "owner", repo: "repo" }, + }; + const result = await resolveAllowedMentionsFromPayload(context, mockGithub, mockCore); + expect(result).toContain("alice"); + }); + + it("includes extra known authors", async () => { + const context = { + eventName: "issues", + payload: { issue: { user: { login: "alice", type: "User" }, assignees: [] } }, + repo: { owner: "owner", repo: "repo" }, + actor: "actor", + }; + const result = await resolveAllowedMentionsFromPayload(context, mockGithub, mockCore, undefined, ["extra-user"]); + expect(result).toContain("extra-user"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("extra known author")); + }); + + it("applies max limit when allowTeamMembers is false", async () => { + const context = { + eventName: "issues", + payload: { + issue: { + user: { login: "alice", type: "User" }, + assignees: Array.from({ length: 5 }, (_, i) => ({ login: `user${i}`, type: "User" })), + }, + }, + repo: { owner: "o", repo: "r" }, + }; + const result = await resolveAllowedMentionsFromPayload(context, mockGithub, mockCore, { + allowTeamMembers: false, + max: 3, + }); + expect(result.length).toBeLessThanOrEqual(3); + }); + + it("warns when mention limit is exceeded with team members disabled", async () => { + const context = { + eventName: "issues", + payload: { + issue: { + user: null, + assignees: Array.from({ length: 5 }, (_, i) => ({ login: `user${i}`, type: "User" })), + }, + }, + repo: { owner: "o", repo: "r" }, + }; + await resolveAllowedMentionsFromPayload(context, mockGithub, mockCore, { + allowTeamMembers: false, + max: 2, + }); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Mention limit exceeded")); + }); + + it("returns empty array and logs warning on error", async () => { + // Passing a context with a missing `repo` property causes the internal + // destructuring to throw, exercising the catch branch. + const context = { + eventName: "issues", + payload: { issue: { user: { login: "alice", type: "User" }, assignees: [] } }, + repo: null, // will cause "Cannot destructure property 'owner'" error + }; + const result = await resolveAllowedMentionsFromPayload(context, mockGithub, mockCore); + expect(result).toEqual([]); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to resolve mentions")); + }); + + it("includes allowed list from config regardless of context", async () => { + const context = { + eventName: "workflow_dispatch", + actor: "actor", + payload: {}, + repo: { owner: "owner", repo: "repo" }, + }; + const result = await resolveAllowedMentionsFromPayload(context, mockGithub, mockCore, { + allowed: ["trusted-user"], + allowTeamMembers: false, + }); + expect(result).toContain("trusted-user"); + }); +});