diff --git a/actions/setup/js/add_labels.cjs b/actions/setup/js/add_labels.cjs index a2c4865c3f8..469ce2a56d7 100644 --- a/actions/setup/js/add_labels.cjs +++ b/actions/setup/js/add_labels.cjs @@ -32,6 +32,7 @@ const { attachExecutionState, fetchIssueState, normalizeLabelNames } = require(" const { MAX_LABELS } = require("./constants.cjs"); const { createCountGatedHandler } = require("./handler_scaffold.cjs"); const { withRetry, RATE_LIMIT_RETRY_CONFIG } = require("./error_recovery.cjs"); +const { resolveInvocationContext } = require("./invocation_context_helpers.cjs"); /** * Main handler factory for add_labels @@ -78,7 +79,8 @@ const main = createCountGatedHandler({ // Accept common aliases: issue_number, pr_number, and pull_number are normalised to item_number const targetResult = resolveSafeOutputIssueTarget({ message, resolvedTemporaryIds, repoParts, handlerType: HANDLER_TYPE }); if (!targetResult.success) return targetResult; - const itemNumber = targetResult.number ?? context.payload?.issue?.number ?? context.payload?.pull_request?.number; + const effectiveContext = resolveInvocationContext(context); + const itemNumber = targetResult.number ?? effectiveContext.eventPayload?.issue?.number ?? effectiveContext.eventPayload?.pull_request?.number; if (!itemNumber || Number.isNaN(Number(itemNumber))) { const error = "No issue/PR number available"; @@ -86,7 +88,7 @@ const main = createCountGatedHandler({ return { success: false, error }; } - const contextType = context.payload?.pull_request ? "pull request" : "issue"; + const contextType = effectiveContext.eventPayload?.pull_request ? "pull request" : "issue"; const requestedLabels = message.labels ?? []; core.info(`Requested labels: ${JSON.stringify(requestedLabels)}`); diff --git a/actions/setup/js/add_labels.test.cjs b/actions/setup/js/add_labels.test.cjs index 81529fba70e..fbd0dceec57 100644 --- a/actions/setup/js/add_labels.test.cjs +++ b/actions/setup/js/add_labels.test.cjs @@ -201,6 +201,40 @@ describe("add_labels", () => { expect(result.contextType).toBe("issue"); }); + it("should add labels from workflow_dispatch aw_context when issue payload is absent", async () => { + mockContext.eventName = "workflow_dispatch"; + mockContext.payload = { + inputs: { + aw_context: JSON.stringify({ + event_type: "issue_comment", + item_type: "issue", + item_number: 456, + repo: "test-owner/test-repo", + }), + }, + }; + + const handler = await main({ max: 10 }); + const addLabelsCalls = []; + + mockGithub.rest.issues.addLabels = async params => { + addLabelsCalls.push(params); + return {}; + }; + + const result = await handler( + { + labels: ["documentation"], + }, + {} + ); + + expect(result.success).toBe(true); + expect(result.number).toBe(456); + expect(result.contextType).toBe("issue"); + expect(addLabelsCalls[0].issue_number).toBe(456); + }); + it("should add labels to a pull request from context", async () => { mockContext.payload = { pull_request: { diff --git a/actions/setup/js/remove_labels.cjs b/actions/setup/js/remove_labels.cjs index 6b90769ebc7..494f4cf16e1 100644 --- a/actions/setup/js/remove_labels.cjs +++ b/actions/setup/js/remove_labels.cjs @@ -15,6 +15,7 @@ const { logStagedPreviewInfo } = require("./staged_preview.cjs"); const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { resolveSafeOutputIssueTarget } = require("./temporary_id.cjs"); const { createCountGatedHandler } = require("./handler_scaffold.cjs"); +const { resolveInvocationContext } = require("./invocation_context_helpers.cjs"); /** * Main handler factory for remove_labels @@ -69,7 +70,8 @@ const main = createCountGatedHandler({ // Accept common aliases: issue_number, pr_number, and pull_number are normalised to item_number const targetResult = resolveSafeOutputIssueTarget({ message, resolvedTemporaryIds, repoParts, handlerType: HANDLER_TYPE }); if (!targetResult.success) return targetResult; - const itemNumber = targetResult.number ?? context.payload?.issue?.number ?? context.payload?.pull_request?.number; + const effectiveContext = resolveInvocationContext(context); + const itemNumber = targetResult.number ?? effectiveContext.eventPayload?.issue?.number ?? effectiveContext.eventPayload?.pull_request?.number; if (!itemNumber || Number.isNaN(Number(itemNumber))) { const error = "No issue/PR number available"; @@ -77,7 +79,7 @@ const main = createCountGatedHandler({ return { success: false, error }; } - const contextType = context.payload?.pull_request ? "pull request" : "issue"; + const contextType = effectiveContext.eventPayload?.pull_request ? "pull request" : "issue"; const requestedLabels = message.labels ?? []; core.info(`Requested labels to remove: ${JSON.stringify(requestedLabels)}`); diff --git a/actions/setup/js/remove_labels.test.cjs b/actions/setup/js/remove_labels.test.cjs index 6f4e0a455af..e5e32bd0a17 100644 --- a/actions/setup/js/remove_labels.test.cjs +++ b/actions/setup/js/remove_labels.test.cjs @@ -195,6 +195,40 @@ describe("remove_labels", () => { expect(result.contextType).toBe("issue"); }); + it("should remove labels from workflow_dispatch aw_context when issue payload is absent", async () => { + mockContext.eventName = "workflow_dispatch"; + mockContext.payload = { + inputs: { + aw_context: JSON.stringify({ + event_type: "issue_comment", + item_type: "issue", + item_number: 456, + repo: "test-owner/test-repo", + }), + }, + }; + + const handler = await main({ max: 10 }); + const removeLabelCalls = []; + + mockGithub.rest.issues.removeLabel = async params => { + removeLabelCalls.push(params); + return {}; + }; + + const result = await handler( + { + labels: ["documentation"], + }, + {} + ); + + expect(result.success).toBe(true); + expect(result.number).toBe(456); + expect(result.contextType).toBe("issue"); + expect(removeLabelCalls[0].issue_number).toBe(456); + }); + it("should remove labels from a pull request from context", async () => { mockContext.payload = { pull_request: { diff --git a/actions/setup/js/safe_output_helpers.cjs b/actions/setup/js/safe_output_helpers.cjs index 9e6f02b55c0..c89ab62d2b9 100644 --- a/actions/setup/js/safe_output_helpers.cjs +++ b/actions/setup/js/safe_output_helpers.cjs @@ -9,6 +9,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { matchesSimpleGlob } = require("./glob_pattern_helpers.cjs"); const { logStagedPreviewInfo } = require("./staged_preview.cjs"); +const { resolveInvocationContext } = require("./invocation_context_helpers.cjs"); /** * Parse a comma-separated list of allowed items from environment variable @@ -70,12 +71,24 @@ function parseMaxCount(envValue, defaultValue = 3) { */ function resolveTarget(params) { const { targetConfig, item, context, itemType, supportsPR = false, supportsIssue = false } = params; + let invocationContext; + try { + invocationContext = resolveInvocationContext(context); + } catch (err) { + return { + success: false, + error: `Failed to resolve invocation context for ${itemType}: ${getErrorMessage(err)}`, + shouldFail: true, + }; + } + const effectiveEventName = invocationContext?.eventName || context.eventName; + const effectivePayload = invocationContext?.eventPayload || context.payload; // Check context type const prEventNames = new Set(["pull_request", "pull_request_target", "pull_request_review", "pull_request_review_comment"]); - const isIssueCommentOnPR = context.eventName === "issue_comment" && Boolean(context.payload?.issue?.pull_request); - const isIssueContext = context.eventName === "issues" || (context.eventName === "issue_comment" && !isIssueCommentOnPR); - const isPRContext = prEventNames.has(context.eventName) || isIssueCommentOnPR; + const isIssueCommentOnPR = effectiveEventName === "issue_comment" && Boolean(effectivePayload?.issue?.pull_request); + const isIssueContext = effectiveEventName === "issues" || (effectiveEventName === "issue_comment" && !isIssueCommentOnPR); + const isPRContext = prEventNames.has(effectiveEventName) || isIssueCommentOnPR; // Default target is "triggering" const target = targetConfig || "triggering"; @@ -202,8 +215,8 @@ function resolveTarget(params) { } else { // Use triggering context if (isIssueContext) { - if (context.payload.issue) { - itemNumber = context.payload.issue.number; + if (effectivePayload.issue) { + itemNumber = effectivePayload.issue.number; contextType = "issue"; } else { return { @@ -213,11 +226,11 @@ function resolveTarget(params) { }; } } else if (isPRContext) { - if (context.payload.pull_request) { - itemNumber = context.payload.pull_request.number; + if (effectivePayload.pull_request) { + itemNumber = effectivePayload.pull_request.number; contextType = "pull request"; } else if (isIssueCommentOnPR) { - itemNumber = context.payload.issue.number; + itemNumber = effectivePayload.issue.number; contextType = "pull request"; } else { return { diff --git a/actions/setup/js/safe_output_helpers.test.cjs b/actions/setup/js/safe_output_helpers.test.cjs index 281858cab3f..188a62eb56d 100644 --- a/actions/setup/js/safe_output_helpers.test.cjs +++ b/actions/setup/js/safe_output_helpers.test.cjs @@ -95,6 +95,29 @@ describe("safe_output_helpers", () => { expect(result.contextType).toBe("issue"); }); + it("should resolve workflow_dispatch issue context from aw_context", () => { + const result = helpers.resolveTarget({ + ...baseParams, + context: { + eventName: "workflow_dispatch", + repo: { owner: "test-owner", repo: "test-repo" }, + payload: { + inputs: { + aw_context: JSON.stringify({ + event_type: "issue_comment", + item_type: "issue", + item_number: 321, + repo: "test-owner/test-repo", + }), + }, + }, + }, + }); + expect(result.success).toBe(true); + expect(result.number).toBe(321); + expect(result.contextType).toBe("issue"); + }); + it("should resolve triggering PR context", () => { const result = helpers.resolveTarget({ ...baseParams, @@ -458,6 +481,29 @@ describe("safe_output_helpers", () => { expect(result.contextType).toBe("issue"); }); + it("should resolve workflow_dispatch issue context from aw_context", () => { + const result = helpers.resolveTarget({ + ...baseParams, + context: { + eventName: "workflow_dispatch", + repo: { owner: "test-owner", repo: "test-repo" }, + payload: { + inputs: { + aw_context: JSON.stringify({ + event_type: "issue_comment", + item_type: "issue", + item_number: 654, + repo: "test-owner/test-repo", + }), + }, + }, + }, + }); + expect(result.success).toBe(true); + expect(result.number).toBe(654); + expect(result.contextType).toBe("issue"); + }); + it("should fail when triggering and not in issue context", () => { const result = helpers.resolveTarget({ ...baseParams, diff --git a/actions/setup/js/update_issue.test.cjs b/actions/setup/js/update_issue.test.cjs index be92fd6ae4f..a5562b17f37 100644 --- a/actions/setup/js/update_issue.test.cjs +++ b/actions/setup/js/update_issue.test.cjs @@ -778,6 +778,40 @@ describe("update_issue.cjs - cross-repo and operation integration", () => { expect(capturedRepo).toBe("other-repo"); }); + it("should resolve triggering issue from workflow_dispatch aw_context", async () => { + const savedEventName = mockContext.eventName; + const savedPayload = mockContext.payload; + mockContext.eventName = "workflow_dispatch"; + mockContext.payload = { + inputs: { + aw_context: JSON.stringify({ + event_type: "issue_comment", + item_type: "issue", + item_number: 123, + repo: "testowner/testrepo", + }), + }, + }; + let capturedIssueNumber; + + mockGithub.rest.issues.get.mockResolvedValue({ + data: { number: 123, title: "Test", body: "Original", html_url: "https://github.com/testowner/testrepo/issues/123" }, + }); + mockGithub.rest.issues.update.mockImplementation(async ({ issue_number, body }) => { + capturedIssueNumber = issue_number; + return { data: { number: issue_number, title: "Test", body, html_url: `https://github.com/testowner/testrepo/issues/${issue_number}` } }; + }); + + const { main } = await import("./update_issue.cjs"); + const handler = await main(); + const result = await handler({ body: "Updated from centralized dispatch" }, {}); + + expect(result.success).toBe(true); + expect(capturedIssueNumber).toBe(123); + mockContext.eventName = savedEventName; + mockContext.payload = savedPayload; + }); + it("should use current workflow repo in attribution URL for cross-repo updates", async () => { let capturedBody;