diff --git a/actions/setup/js/add_reaction_and_edit_comment.cjs b/actions/setup/js/add_reaction_and_edit_comment.cjs index 3daf1e5a125..aaa76782953 100644 --- a/actions/setup/js/add_reaction_and_edit_comment.cjs +++ b/actions/setup/js/add_reaction_and_edit_comment.cjs @@ -8,6 +8,7 @@ const { sanitizeContent } = require("./sanitize_content.cjs"); const { ERR_API, ERR_NOT_FOUND, ERR_VALIDATION } = require("./error_codes.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { resolveTopLevelDiscussionCommentId } = require("./github_api_helpers.cjs"); +const { resolveInvocationContext } = require("./invocation_context_helpers.cjs"); /** * Event type descriptions for comment messages @@ -38,7 +39,8 @@ const DISCUSSION_REACTION_MAP = { async function main() { const reaction = process.env.GH_AW_REACTION || "eyes"; const command = process.env.GH_AW_COMMAND; // Only present for command workflows - const runUrl = buildWorkflowRunUrl(context, context.repo); + const invocationContext = resolveInvocationContext(context); + const runUrl = buildWorkflowRunUrl(context, invocationContext.workflowRepo); core.info(`Reaction type: ${reaction}`); core.info(`Command name: ${command || "none"}`); @@ -54,14 +56,15 @@ async function main() { let reactionEndpoint; let commentUpdateEndpoint; - const eventName = context.eventName; - const owner = context.repo.owner; - const repo = context.repo.repo; + const eventName = invocationContext.eventName; + const owner = invocationContext.eventRepo.owner; + const repo = invocationContext.eventRepo.repo; + const payload = invocationContext.eventPayload; try { switch (eventName) { case "issues": { - const issueNumber = context.payload?.issue?.number; + const issueNumber = payload?.issue?.number; if (!issueNumber) { core.setFailed(`${ERR_NOT_FOUND}: Issue number not found in event payload`); return; @@ -72,8 +75,8 @@ async function main() { } case "issue_comment": { - const commentId = context.payload?.comment?.id; - const issueNumberForComment = context.payload?.issue?.number; + const commentId = payload?.comment?.id; + const issueNumberForComment = payload?.issue?.number; if (!commentId) { core.setFailed(`${ERR_VALIDATION}: Comment ID not found in event payload`); return; @@ -89,7 +92,7 @@ async function main() { } case "pull_request": { - const prNumber = context.payload?.pull_request?.number; + const prNumber = payload?.pull_request?.number; if (!prNumber) { core.setFailed(`${ERR_NOT_FOUND}: Pull request number not found in event payload`); return; @@ -101,8 +104,8 @@ async function main() { } case "pull_request_review_comment": { - const reviewCommentId = context.payload?.comment?.id; - const prNumberForReviewComment = context.payload?.pull_request?.number; + const reviewCommentId = payload?.comment?.id; + const prNumberForReviewComment = payload?.pull_request?.number; if (!reviewCommentId) { core.setFailed(`${ERR_VALIDATION}: Review comment ID not found in event payload`); return; @@ -118,7 +121,7 @@ async function main() { } case "discussion": { - const discussionNumber = context.payload?.discussion?.number; + const discussionNumber = payload?.discussion?.number; if (!discussionNumber) { core.setFailed(`${ERR_NOT_FOUND}: Discussion number not found in event payload`); return; @@ -131,13 +134,13 @@ async function main() { } case "discussion_comment": { - const discussionCommentNumber = context.payload?.discussion?.number; - const discussionCommentId = context.payload?.comment?.id; + const discussionCommentNumber = payload?.discussion?.number; + const discussionCommentId = payload?.comment?.id; if (!discussionCommentNumber || !discussionCommentId) { core.setFailed(`${ERR_NOT_FOUND}: Discussion or comment information not found in event payload`); return; } - const commentNodeId = context.payload?.comment?.node_id; + const commentNodeId = payload?.comment?.node_id; if (!commentNodeId) { core.setFailed(`${ERR_NOT_FOUND}: Discussion comment node ID not found in event payload`); return; @@ -163,7 +166,7 @@ async function main() { if (commentUpdateEndpoint) { core.info(`Comment endpoint: ${commentUpdateEndpoint}`); - await addCommentWithWorkflowLink(commentUpdateEndpoint, runUrl, eventName); + await addCommentWithWorkflowLink(commentUpdateEndpoint, runUrl, eventName, invocationContext); } } catch (error) { const errorMessage = getErrorMessage(error); @@ -270,15 +273,16 @@ async function getDiscussionId(owner, repo, discussionNumber) { * Helper function to set comment outputs * @param {string} commentId - The comment ID * @param {string} commentUrl - The comment URL + * @param {{ owner: string, repo: string }} [eventRepo=context.repo] - Repository where the comment was created */ -function setCommentOutputs(commentId, commentUrl) { +function setCommentOutputs(commentId, commentUrl, eventRepo = context.repo) { core.info(`Successfully created comment with workflow link`); core.info(`Comment ID: ${commentId}`); core.info(`Comment URL: ${commentUrl}`); - core.info(`Comment Repo: ${context.repo.owner}/${context.repo.repo}`); + core.info(`Comment Repo: ${eventRepo.owner}/${eventRepo.repo}`); core.setOutput("comment-id", commentId); core.setOutput("comment-url", commentUrl); - core.setOutput("comment-repo", `${context.repo.owner}/${context.repo.repo}`); + core.setOutput("comment-repo", `${eventRepo.owner}/${eventRepo.repo}`); } /** @@ -286,8 +290,17 @@ function setCommentOutputs(commentId, commentUrl) { * @param {string} endpoint - The GitHub API endpoint to create the comment (or special format for discussions) * @param {string} runUrl - The URL of the workflow run * @param {string} eventName - The event type (to determine the comment text) + * @param {{ + * source?: "native" | "workflow_dispatch" | "repository_dispatch", + * eventName?: string, + * eventPayload?: any, + * workflowRepo?: { owner: string, repo: string }, + * eventRepo?: { owner: string, repo: string } + * } | null} [invocationContext=null] - Resolved invocation event context. When omitted, falls back to global context payload/repo. */ -async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) { +async function addCommentWithWorkflowLink(endpoint, runUrl, eventName, invocationContext = null) { + const eventPayload = invocationContext?.eventPayload || context.payload; + const eventRepo = invocationContext?.eventRepo || context.repo; try { const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; const eventTypeDescription = EVENT_TYPE_DESCRIPTIONS[eventName] ?? "event"; @@ -316,7 +329,7 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) { if (eventName === "discussion") { // Parse discussion number from special format: "discussion:NUMBER" const discussionNumber = parseInt(endpoint.split(":")[1], 10); - const { id: discussionId } = await getDiscussionId(context.repo.owner, context.repo.repo, discussionNumber); + const { id: discussionId } = await getDiscussionId(eventRepo.owner, eventRepo.repo, discussionNumber); const result = await github.graphql( ` @@ -332,17 +345,17 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) { ); const comment = result.addDiscussionComment.comment; - setCommentOutputs(comment.id, comment.url); + setCommentOutputs(comment.id, comment.url, eventRepo); return; } else if (eventName === "discussion_comment") { // Parse discussion number from special format: "discussion_comment:NUMBER:COMMENT_ID" const discussionNumber = parseInt(endpoint.split(":")[1], 10); - const { id: discussionId } = await getDiscussionId(context.repo.owner, context.repo.repo, discussionNumber); + const { id: discussionId } = await getDiscussionId(eventRepo.owner, eventRepo.repo, discussionNumber); // Get the comment node ID to use as the parent for threading. // GitHub Discussions only supports two nesting levels, so if the triggering comment is // itself a reply, we resolve the top-level parent's node ID. - const commentNodeId = await resolveTopLevelDiscussionCommentId(github, context.payload?.comment?.node_id); + const commentNodeId = await resolveTopLevelDiscussionCommentId(github, eventPayload?.comment?.node_id); const result = await github.graphql( ` @@ -358,7 +371,7 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) { ); const comment = result.addDiscussionComment.comment; - setCommentOutputs(comment.id, comment.url); + setCommentOutputs(comment.id, comment.url, eventRepo); return; } @@ -370,7 +383,7 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) { }, }); - setCommentOutputs(createResponse.data.id.toString(), createResponse.data.html_url); + setCommentOutputs(createResponse.data.id.toString(), createResponse.data.html_url, eventRepo); } catch (error) { // Don't fail the entire job if comment creation fails - just log it const errorMessage = getErrorMessage(error); diff --git a/actions/setup/js/add_reaction_and_edit_comment.test.cjs b/actions/setup/js/add_reaction_and_edit_comment.test.cjs index f9d700308a6..ec2125d4f20 100644 --- a/actions/setup/js/add_reaction_and_edit_comment.test.cjs +++ b/actions/setup/js/add_reaction_and_edit_comment.test.cjs @@ -187,6 +187,33 @@ describe("add_reaction_and_edit_comment.cjs", () => { }); }); + describe("repository_dispatch reactions", () => { + it("should use workflow repo for run URL and event repo for reaction/comment APIs", async () => { + process.env.GH_AW_REACTION = "eyes"; + global.context = { + eventName: "repository_dispatch", + runId: 12345, + repo: { owner: "sideowner", repo: "siderepo" }, + payload: { + action: "issue_comment", + client_payload: { + issue: { number: 123 }, + comment: { id: 456 }, + repository: { owner: { login: "targetowner" }, name: "targetrepo" }, + }, + }, + }; + mockGithub.request.mockResolvedValueOnce({ data: { id: 111 } }).mockResolvedValueOnce({ data: { id: 789, html_url: "https://github.com/targetowner/targetrepo/issues/123#issuecomment-789" } }); + + const { main } = await loadModule(); + await main(); + + expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/targetowner/targetrepo/issues/comments/456/reactions", expect.objectContaining({ content: "eyes" })); + expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/targetowner/targetrepo/issues/123/comments", expect.objectContaining({ body: expect.stringContaining("https://github.com/sideowner/siderepo/actions/runs/12345") })); + expect(mockCore.setOutput).toHaveBeenCalledWith("comment-repo", "targetowner/targetrepo"); + }); + }); + describe("Pull request review comment reactions", () => { it("should create new comment for pull_request_review_comment event (not edit)", async () => { process.env.GH_AW_REACTION = "rocket"; diff --git a/actions/setup/js/add_workflow_run_comment.cjs b/actions/setup/js/add_workflow_run_comment.cjs index b1997835654..24069173d60 100644 --- a/actions/setup/js/add_workflow_run_comment.cjs +++ b/actions/setup/js/add_workflow_run_comment.cjs @@ -10,6 +10,7 @@ const { getMessages } = require("./messages_core.cjs"); const { parseBoolTemplatable } = require("./templatable.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { resolveTopLevelDiscussionCommentId } = require("./github_api_helpers.cjs"); +const { resolveInvocationContext } = require("./invocation_context_helpers.cjs"); /** * Event type descriptions for comment messages @@ -26,9 +27,10 @@ const EVENT_TYPE_DESCRIPTIONS = { /** * Helper function to get discussion node ID via GraphQL * @param {number} discussionNumber - The discussion number + * @param {{ owner: string, repo: string }} [eventRepo] - Repository where the discussion event occurred (defaults to context.repo at runtime) * @returns {Promise} The discussion node ID */ -async function getDiscussionNodeId(discussionNumber) { +async function getDiscussionNodeId(discussionNumber, eventRepo = context.repo) { const { repository } = await github.graphql( ` query($owner: String!, $repo: String!, $num: Int!) { @@ -38,7 +40,7 @@ async function getDiscussionNodeId(discussionNumber) { } } }`, - { owner: context.repo.owner, repo: context.repo.repo, num: discussionNumber } + { owner: eventRepo.owner, repo: eventRepo.repo, num: discussionNumber } ); return repository.discussion.id; } @@ -47,15 +49,16 @@ async function getDiscussionNodeId(discussionNumber) { * Helper function to set comment outputs * @param {string|number} commentId - The comment ID * @param {string} commentUrl - The comment URL + * @param {{ owner: string, repo: string }} [eventRepo] - Repository where the comment was created (defaults to context.repo at runtime) */ -function setCommentOutputs(commentId, commentUrl) { +function setCommentOutputs(commentId, commentUrl, eventRepo = context.repo) { core.info(`Successfully created comment with workflow link`); core.info(`Comment ID: ${commentId}`); core.info(`Comment URL: ${commentUrl}`); - core.info(`Comment Repo: ${context.repo.owner}/${context.repo.repo}`); + core.info(`Comment Repo: ${eventRepo.owner}/${eventRepo.repo}`); core.setOutput("comment-id", commentId.toString()); core.setOutput("comment-url", commentUrl); - core.setOutput("comment-repo", `${context.repo.owner}/${context.repo.repo}`); + core.setOutput("comment-repo", `${eventRepo.owner}/${eventRepo.repo}`); } /** @@ -71,22 +74,25 @@ async function main() { return; } - const runUrl = buildWorkflowRunUrl(context, context.repo); + const invocationContext = resolveInvocationContext(context); + const runUrl = buildWorkflowRunUrl(context, invocationContext.workflowRepo); core.info(`Run ID: ${context.runId}`); core.info(`Run URL: ${runUrl}`); + core.info(`Event source: ${invocationContext.source}`); // Determine the API endpoint based on the event type let commentEndpoint; - const eventName = context.eventName; - const owner = context.repo.owner; - const repo = context.repo.repo; + const eventName = invocationContext.eventName; + const owner = invocationContext.eventRepo.owner; + const repo = invocationContext.eventRepo.repo; + const payload = invocationContext.eventPayload; try { switch (eventName) { case "issues": case "issue_comment": { - const number = context.payload?.issue?.number; + const number = payload?.issue?.number; if (!number) { core.setFailed(`${ERR_NOT_FOUND}: Issue number not found in event payload`); return; @@ -97,7 +103,7 @@ async function main() { case "pull_request": case "pull_request_review_comment": { - const number = context.payload?.pull_request?.number; + const number = payload?.pull_request?.number; if (!number) { core.setFailed(`${ERR_NOT_FOUND}: Pull request number not found in event payload`); return; @@ -108,7 +114,7 @@ async function main() { } case "discussion": { - const discussionNumber = context.payload?.discussion?.number; + const discussionNumber = payload?.discussion?.number; if (!discussionNumber) { core.setFailed(`${ERR_NOT_FOUND}: Discussion number not found in event payload`); return; @@ -118,8 +124,8 @@ async function main() { } case "discussion_comment": { - const discussionCommentNumber = context.payload?.discussion?.number; - const discussionCommentId = context.payload?.comment?.id; + const discussionCommentNumber = payload?.discussion?.number; + const discussionCommentId = payload?.comment?.id; if (!discussionCommentNumber || !discussionCommentId) { core.setFailed(`${ERR_NOT_FOUND}: Discussion or comment information not found in event payload`); return; @@ -134,7 +140,7 @@ async function main() { } core.info(`Creating comment on: ${commentEndpoint}`); - await addCommentWithWorkflowLink(commentEndpoint, runUrl, eventName); + await addCommentWithWorkflowLink(commentEndpoint, runUrl, eventName, invocationContext); } catch (error) { const errorMessage = getErrorMessage(error); // Don't fail the job - just warn since this is not critical @@ -184,9 +190,10 @@ function buildCommentBody(eventName, runUrl) { * @param {number} discussionNumber - The discussion number * @param {string} commentBody - The comment body * @param {string|null} replyToNodeId - Parent comment node ID for threading (null for top-level) + * @param {{ owner: string, repo: string }} [eventRepo] - Repository where the discussion exists (defaults to context.repo at runtime) */ -async function postDiscussionComment(discussionNumber, commentBody, replyToNodeId = null) { - const discussionId = await getDiscussionNodeId(discussionNumber); +async function postDiscussionComment(discussionNumber, commentBody, replyToNodeId = null, eventRepo = context.repo) { + const discussionId = await getDiscussionNodeId(discussionNumber, eventRepo); /** @type {any} */ let result; @@ -213,7 +220,7 @@ async function postDiscussionComment(discussionNumber, commentBody, replyToNodeI } const comment = result.addDiscussionComment.comment; - setCommentOutputs(comment.id, comment.url); + setCommentOutputs(comment.id, comment.url, eventRepo); } /** @@ -222,13 +229,15 @@ async function postDiscussionComment(discussionNumber, commentBody, replyToNodeI * @param {string} runUrl - The URL of the workflow run * @param {string} eventName - The event type (to determine the comment text) */ -async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) { +async function addCommentWithWorkflowLink(endpoint, runUrl, eventName, invocationContext = null) { + const eventPayload = invocationContext?.eventPayload || context.payload; + const eventRepo = invocationContext?.eventRepo || context.repo; const commentBody = buildCommentBody(eventName, runUrl); if (eventName === "discussion") { // Parse discussion number from special format: "discussion:NUMBER" const discussionNumber = parseInt(endpoint.split(":")[1], 10); - await postDiscussionComment(discussionNumber, commentBody); + await postDiscussionComment(discussionNumber, commentBody, null, eventRepo); return; } @@ -237,8 +246,8 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) { const discussionNumber = parseInt(endpoint.split(":")[1], 10); // GitHub Discussions only supports two nesting levels, so resolve the top-level parent's node ID - const commentNodeId = await resolveTopLevelDiscussionCommentId(github, context.payload?.comment?.node_id); - await postDiscussionComment(discussionNumber, commentBody, commentNodeId); + const commentNodeId = await resolveTopLevelDiscussionCommentId(github, eventPayload?.comment?.node_id); + await postDiscussionComment(discussionNumber, commentBody, commentNodeId, eventRepo); return; } @@ -248,7 +257,7 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) { headers: { Accept: "application/vnd.github+json" }, }); - setCommentOutputs(createResponse.data.id, createResponse.data.html_url); + setCommentOutputs(createResponse.data.id, createResponse.data.html_url, eventRepo); } module.exports = { main, addCommentWithWorkflowLink, buildCommentBody, postDiscussionComment }; diff --git a/actions/setup/js/add_workflow_run_comment.test.cjs b/actions/setup/js/add_workflow_run_comment.test.cjs index 51cde9c5c48..b049d8cc11c 100644 --- a/actions/setup/js/add_workflow_run_comment.test.cjs +++ b/actions/setup/js/add_workflow_run_comment.test.cjs @@ -170,6 +170,34 @@ describe("add_workflow_run_comment", () => { }); }); + describe("main() - repository_dispatch event", () => { + it("should use workflow repo for run URL and client payload repo for comments", async () => { + global.context = { + eventName: "repository_dispatch", + runId: 12345, + repo: { owner: "sideowner", repo: "siderepo" }, + payload: { + action: "issue_comment", + client_payload: { + issue: { number: 789 }, + repository: { owner: { login: "targetowner" }, name: "targetrepo" }, + }, + }, + }; + + await runScript(); + + expect(mockGithub.request).toHaveBeenCalledWith( + expect.stringContaining("POST /repos/targetowner/targetrepo/issues/789/comments"), + expect.objectContaining({ + body: expect.stringContaining("https://github.com/sideowner/siderepo/actions/runs/12345"), + }) + ); + expect(mockCore.setOutput).toHaveBeenCalledWith("comment-repo", "targetowner/targetrepo"); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + }); + describe("main() - pull_request event", () => { it("should create comment on a pull request", async () => { global.context = { diff --git a/actions/setup/js/invocation_context_helpers.cjs b/actions/setup/js/invocation_context_helpers.cjs new file mode 100644 index 00000000000..9c1b18363b4 --- /dev/null +++ b/actions/setup/js/invocation_context_helpers.cjs @@ -0,0 +1,162 @@ +// @ts-check +/// + +const { parseRepoSlug: parseSharedRepoSlug } = require("./repo_helpers.cjs"); + +/** + * @typedef {{ owner: string, repo: string }} RepoRef + */ + +/** + * Parse a repository slug in owner/repo format. + * @param {unknown} value + * @returns {RepoRef|null} + */ +function parseRepoSlug(value) { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + return parseSharedRepoSlug(trimmed); +} + +/** + * Normalize a repo object into { owner, repo } shape. + * @param {unknown} repoValue + * @returns {RepoRef|null} + */ +function normalizeRepo(repoValue) { + if (!repoValue || typeof repoValue !== "object") { + return null; + } + + const maybeRepo = /** @type {any} */ repoValue; + if (typeof maybeRepo.owner === "string" && typeof maybeRepo.repo === "string" && maybeRepo.owner && maybeRepo.repo) { + return { + owner: maybeRepo.owner, + repo: maybeRepo.repo, + }; + } + + return null; +} + +/** + * Extract a repository from event payload.repository. + * Supports both REST event shape (owner.login + name) and + * github-script context-style payload.repo style. + * @param {unknown} payload + * @returns {RepoRef|null} + */ +function extractRepoFromPayload(payload) { + if (!payload || typeof payload !== "object") { + return null; + } + + const repository = /** @type {any} */ payload.repository; + if (!repository || typeof repository !== "object") { + return null; + } + + const owner = typeof repository.owner?.login === "string" ? repository.owner.login : typeof repository.owner === "string" ? repository.owner : undefined; + const repo = typeof repository.name === "string" ? repository.name : typeof repository.repo === "string" ? repository.repo : undefined; + + if (owner && repo) { + return { owner, repo }; + } + + return null; +} + +/** + * Parse a JSON input string into object payload. + * @param {unknown} value + * @returns {Record|null} + */ +function parseJSONPayload(value) { + if (typeof value !== "string" || value.trim() === "") { + return null; + } + + try { + const parsed = JSON.parse(value); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return /** @type {Record} */ parsed; + } + } catch (_error) { + // Best-effort parsing only. + } + + return null; +} + +/** + * Resolve workflow repo and effective event context across invocation styles: + * - native events + * - workflow_dispatch (optional explicit overrides in inputs) + * - repository_dispatch (event wrapped in client_payload) + * + * @param {any} rawContext + * @returns {{ + * source: "native" | "workflow_dispatch" | "repository_dispatch", + * eventName: string, + * eventPayload: any, + * workflowRepo: RepoRef, + * eventRepo: RepoRef + * }} + */ +function resolveInvocationContext(rawContext) { + const contextRepo = normalizeRepo(rawContext?.repo) || { owner: "", repo: "" }; + const workflowRepo = normalizeRepo(rawContext?.workflowRepo) || contextRepo; + + /** @type {"native" | "workflow_dispatch" | "repository_dispatch"} */ + let source = "native"; + let eventName = rawContext?.eventName || ""; + let eventPayload = rawContext?.payload || {}; + let eventRepo = normalizeRepo(rawContext?.eventRepo); + + if (eventName === "repository_dispatch") { + const clientPayload = rawContext?.payload?.client_payload; + if (clientPayload && typeof clientPayload === "object") { + source = "repository_dispatch"; + eventName = rawContext?.payload?.action || eventName; + eventPayload = clientPayload; + eventRepo = eventRepo || extractRepoFromPayload(clientPayload) || parseRepoSlug(clientPayload?.aw_context?.repo); + } + } else if (eventName === "workflow_dispatch") { + source = "workflow_dispatch"; + const inputs = rawContext?.payload?.inputs; + if (inputs && typeof inputs === "object") { + const inputsEventName = typeof inputs.event_name === "string" ? inputs.event_name : typeof inputs.eventName === "string" ? inputs.eventName : ""; + const parsedPayload = parseJSONPayload(inputs.event_payload) || parseJSONPayload(inputs.eventPayload); + if (inputsEventName) { + eventName = inputsEventName; + } + if (parsedPayload) { + eventPayload = parsedPayload; + } + eventRepo = eventRepo || parseRepoSlug(inputs.event_repo) || parseRepoSlug(inputs.eventRepo) || parseRepoSlug(inputs.target_repo) || parseRepoSlug(inputs.targetRepo); + } + } + + if (!eventRepo) { + eventRepo = extractRepoFromPayload(eventPayload) || workflowRepo; + } + + return { + source, + eventName, + eventPayload, + workflowRepo, + eventRepo, + }; +} + +module.exports = { + resolveInvocationContext, +}; diff --git a/actions/setup/js/invocation_context_helpers.test.cjs b/actions/setup/js/invocation_context_helpers.test.cjs new file mode 100644 index 00000000000..5a3e1ba8e7f --- /dev/null +++ b/actions/setup/js/invocation_context_helpers.test.cjs @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; + +const { resolveInvocationContext } = await import("./invocation_context_helpers.cjs"); + +describe("invocation_context_helpers", () => { + it("keeps native event context unchanged", () => { + const resolved = resolveInvocationContext({ + eventName: "issue_comment", + repo: { owner: "side-owner", repo: "side-repo" }, + payload: { + issue: { number: 42 }, + repository: { + owner: { login: "side-owner" }, + name: "side-repo", + }, + }, + }); + + expect(resolved.source).toBe("native"); + expect(resolved.eventName).toBe("issue_comment"); + expect(resolved.workflowRepo).toEqual({ owner: "side-owner", repo: "side-repo" }); + expect(resolved.eventRepo).toEqual({ owner: "side-owner", repo: "side-repo" }); + expect(resolved.eventPayload.issue.number).toBe(42); + }); + + it("unwraps repository_dispatch payload and repo", () => { + const resolved = resolveInvocationContext({ + eventName: "repository_dispatch", + repo: { owner: "side-owner", repo: "side-repo" }, + payload: { + action: "issue_comment", + client_payload: { + issue: { number: 99 }, + repository: { + owner: { login: "target-owner" }, + name: "target-repo", + }, + }, + }, + }); + + expect(resolved.source).toBe("repository_dispatch"); + expect(resolved.eventName).toBe("issue_comment"); + expect(resolved.workflowRepo).toEqual({ owner: "side-owner", repo: "side-repo" }); + expect(resolved.eventRepo).toEqual({ owner: "target-owner", repo: "target-repo" }); + expect(resolved.eventPayload.issue.number).toBe(99); + }); + + it("supports workflow_dispatch overrides from inputs", () => { + const resolved = resolveInvocationContext({ + eventName: "workflow_dispatch", + repo: { owner: "side-owner", repo: "side-repo" }, + payload: { + inputs: { + event_name: "issues", + event_repo: "target-owner/target-repo", + event_payload: JSON.stringify({ + issue: { number: 777 }, + }), + }, + }, + }); + + expect(resolved.source).toBe("workflow_dispatch"); + expect(resolved.eventName).toBe("issues"); + expect(resolved.workflowRepo).toEqual({ owner: "side-owner", repo: "side-repo" }); + expect(resolved.eventRepo).toEqual({ owner: "target-owner", repo: "target-repo" }); + expect(resolved.eventPayload.issue.number).toBe(777); + }); +}); diff --git a/actions/setup/js/update_activation_comment.cjs b/actions/setup/js/update_activation_comment.cjs index ec7cceef0cc..cfa250ce20b 100644 --- a/actions/setup/js/update_activation_comment.cjs +++ b/actions/setup/js/update_activation_comment.cjs @@ -10,6 +10,7 @@ const { generateXMLMarker } = require("./generate_footer.cjs"); const { getFooterMessage, getDetectionCautionAlert } = require("./messages_footer.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { resolveTopLevelDiscussionCommentId } = require("./github_api_helpers.cjs"); +const { resolveInvocationContext } = require("./invocation_context_helpers.cjs"); /** * Update the activation comment with a link to the created pull request or issue @@ -23,7 +24,8 @@ const { resolveTopLevelDiscussionCommentId } = require("./github_api_helpers.cjs async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { const itemLabel = itemType === "issue" ? "issue" : "pull request"; const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runUrl = buildWorkflowRunUrl(context, context.repo); + const invocationContext = resolveInvocationContext(context); + const runUrl = buildWorkflowRunUrl(context, invocationContext.workflowRepo); const body = itemType === "issue" ? getIssueCreatedMessage({ itemNumber, itemUrl }) : getPullRequestCreatedMessage({ itemNumber, itemUrl }); const footerMessage = getFooterMessage({ workflowName, runUrl }); const detectionCaution = getDetectionCautionAlert(workflowName, runUrl); @@ -45,7 +47,8 @@ async function updateActivationComment(github, context, core, itemUrl, itemNumbe async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl, options = {}) { const shortSha = commitSha.substring(0, 7); const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runUrl = buildWorkflowRunUrl(context, context.repo); + const invocationContext = resolveInvocationContext(context); + const runUrl = buildWorkflowRunUrl(context, invocationContext.workflowRepo); const footerMessage = getFooterMessage({ workflowName, runUrl }); const detectionCaution = getDetectionCautionAlert(workflowName, runUrl); const cautionSection = detectionCaution ? `${detectionCaution}\n\n` : ""; diff --git a/actions/setup/js/update_activation_comment.test.cjs b/actions/setup/js/update_activation_comment.test.cjs index b9cce90de8c..ffe4a30b8ea 100644 --- a/actions/setup/js/update_activation_comment.test.cjs +++ b/actions/setup/js/update_activation_comment.test.cjs @@ -104,6 +104,57 @@ const createTestableFunction = scriptContent => { }, }; } + if (module === "./invocation_context_helpers.cjs") { + return { + resolveInvocationContext: ctx => { + if (ctx?.eventName === "repository_dispatch" && ctx?.payload?.client_payload) { + const payload = ctx.payload.client_payload; + const eventRepo = payload?.repository?.owner?.login && payload?.repository?.name ? { owner: payload.repository.owner.login, repo: payload.repository.name } : ctx.eventRepo || ctx.repo; + return { + source: "repository_dispatch", + eventName: ctx.payload.action || ctx.eventName, + eventPayload: payload, + workflowRepo: ctx.workflowRepo || ctx.repo, + eventRepo, + }; + } + + if (ctx?.eventName === "workflow_dispatch" && ctx?.payload?.inputs) { + const inputs = ctx.payload.inputs; + let parsedPayload = ctx.payload; + if (typeof inputs.event_payload === "string" && inputs.event_payload) { + try { + parsedPayload = JSON.parse(inputs.event_payload); + } catch (_error) { + parsedPayload = ctx.payload; + } + } + const eventRepo = + typeof inputs.event_repo === "string" && inputs.event_repo.includes("/") + ? (() => { + const [owner, repo] = inputs.event_repo.split("/"); + return { owner, repo }; + })() + : ctx.eventRepo || ctx.repo; + return { + source: "workflow_dispatch", + eventName: inputs.event_name || ctx.eventName, + eventPayload: parsedPayload, + workflowRepo: ctx.workflowRepo || ctx.repo, + eventRepo, + }; + } + + return { + source: "native", + eventName: ctx?.eventName || "", + eventPayload: ctx?.payload || {}, + workflowRepo: ctx?.workflowRepo || ctx?.repo, + eventRepo: ctx?.eventRepo || ctx?.repo, + }; + }, + }; + } throw new Error(`Module ${module} not mocked in test`); }; return new Function( @@ -145,6 +196,29 @@ describe("update_activation_comment.cjs", () => { ); expect(mockDependencies.core.info).toHaveBeenCalledWith("Successfully created comment with pull request link on #10"); }), + it("should use workflowRepo for run attribution in footer when context includes workflowRepo", async () => { + mockDependencies.process.env.GH_AW_COMMENT_ID = ""; + mockDependencies.context = { + ...mockDependencies.context, + runId: 12345, + repo: { owner: "targetowner", repo: "targetrepo" }, + workflowRepo: { owner: "sideowner", repo: "siderepo" }, + payload: { pull_request: { number: 10 } }, + }; + mockDependencies.github.request.mockResolvedValue({ + data: { id: 123, html_url: "https://github.com/targetowner/targetrepo/issues/10#issuecomment-123" }, + }); + + const { updateActivationComment } = createFunctionFromScript(mockDependencies); + await updateActivationComment(mockDependencies.github, mockDependencies.context, mockDependencies.core, "https://github.com/targetowner/targetrepo/pull/42", 42); + + expect(mockDependencies.github.request).toHaveBeenCalledWith( + "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", + expect.objectContaining({ + body: expect.stringContaining("https://github.com/sideowner/siderepo/actions/runs/12345"), + }) + ); + }), it("should skip update when GH_AW_COMMENT_ID is not set and no target issue number", async () => { mockDependencies.process.env.GH_AW_COMMENT_ID = ""; const { updateActivationCommentWithMessage } = createFunctionFromScript(mockDependencies);