diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index 9e0d5a3699c..205240c184b 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -1434,7 +1434,7 @@ }, { "name": "set_issue_field", - "description": "Set a single GitHub issue field by name and value. Use field_name for discovery by field label (for example, \"Priority\"), or provide field_node_id to skip discovery. Supports text, number, date (YYYY-MM-DD), and single-select fields (value must match an option name).", + "description": "Set a single GitHub issue field by name and value. Use field_name for discovery by field label (for example, \"Priority\"), or provide field_node_id to skip discovery. Supports text, number, date (YYYY-MM-DD), and single-select fields (value must match an option name). Built-in issue properties (\"body\", \"title\") are also supported and are updated via the REST API.", "inputSchema": { "type": "object", "required": ["value"], diff --git a/actions/setup/js/set_issue_field.cjs b/actions/setup/js/set_issue_field.cjs index d24119c76b9..3902116937c 100644 --- a/actions/setup/js/set_issue_field.cjs +++ b/actions/setup/js/set_issue_field.cjs @@ -17,6 +17,13 @@ const { hasIssueIntentsRuntimeFeature, normalizeIssueIntentMetadata } = require( /** @type {string} Safe output type handled by this module */ const HANDLER_TYPE = "set_issue_field"; +/** + * Built-in issue fields that are REST properties, not custom project fields. + * These are handled via issues.update() instead of the GraphQL setIssueFieldValue mutation. + * @type {Set} + */ +const BUILTIN_ISSUE_FIELDS = new Set(["body", "title"]); + /** * Fetches the node ID of an issue for use in GraphQL mutations. * @param {Object} githubClient - Authenticated GitHub client @@ -265,6 +272,37 @@ async function main(config = {}) { }; } + const fieldNameLower = fieldName.toLowerCase(); + + // Handle built-in issue properties (body, title) via REST API instead of the custom fields GraphQL API. + if (fieldName && BUILTIN_ISSUE_FIELDS.has(fieldNameLower)) { + try { + validateAllowedIssueFieldName(fieldName, allowedIssueFields); + + const { owner, repo } = repoParts; + await githubClient.rest.issues.update({ + owner, + repo, + issue_number: issueNumber, + [fieldNameLower]: value, + }); + + core.info(`Successfully set builtin issue field ${JSON.stringify(fieldName)} on issue #${issueNumber}`); + + return { + success: true, + issue_number: issueNumber, + field_name: fieldName, + value, + repo: itemRepo, + }; + } catch (error) { + const errorMessage = getErrorMessage(error); + core.error(`Failed to set builtin issue field ${JSON.stringify(fieldName)} on issue #${issueNumber}: ${errorMessage}`); + return { success: false, error: errorMessage }; + } + } + try { const { owner, repo } = repoParts; const issueNodeId = await getIssueNodeId(githubClient, owner, repo, issueNumber); diff --git a/actions/setup/js/set_issue_field.test.cjs b/actions/setup/js/set_issue_field.test.cjs index 75d1a0c626c..25b6ee2b723 100644 --- a/actions/setup/js/set_issue_field.test.cjs +++ b/actions/setup/js/set_issue_field.test.cjs @@ -32,6 +32,7 @@ const mockGithub = { rest: { issues: { get: vi.fn(), + update: vi.fn(), }, }, graphql: mockGraphql, @@ -79,6 +80,7 @@ describe("set_issue_field (Handler Factory Architecture)", () => { vi.clearAllMocks(); mockGithub.rest.issues.get.mockResolvedValue({ data: { node_id: issueNodeId } }); + mockGithub.rest.issues.update.mockResolvedValue({ data: {} }); mockGraphql.mockImplementation(query => { if (query.includes("issueFields")) { return Promise.resolve(mockIssueFieldsQuery); @@ -451,4 +453,96 @@ describe("set_issue_field (Handler Factory Architecture)", () => { delete process.env.GH_AW_RUNTIME_FEATURES; } }); + + it("should update builtin 'body' field via REST API without querying project fields", async () => { + const message = { + type: "set_issue_field", + issue_number: 42, + field_name: "body", + value: "Updated issue body text", + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(true); + expect(result.issue_number).toBe(42); + expect(result.field_name).toBe("body"); + expect(result.value).toBe("Updated issue body text"); + expect(mockGithub.rest.issues.update).toHaveBeenCalledWith( + expect.objectContaining({ + issue_number: 42, + body: "Updated issue body text", + }) + ); + // Must NOT query GraphQL custom fields + expect(mockGraphql).not.toHaveBeenCalledWith(expect.stringContaining("issueFields"), expect.anything()); + expect(mockGraphql).not.toHaveBeenCalledWith(expect.stringContaining("setIssueFieldValue"), expect.anything()); + }); + + it("should update builtin 'title' field via REST API", async () => { + const message = { + type: "set_issue_field", + issue_number: 42, + field_name: "title", + value: "New issue title", + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(true); + expect(result.issue_number).toBe(42); + expect(result.field_name).toBe("title"); + expect(mockGithub.rest.issues.update).toHaveBeenCalledWith( + expect.objectContaining({ + issue_number: 42, + title: "New issue title", + }) + ); + expect(mockGraphql).not.toHaveBeenCalledWith(expect.stringContaining("issueFields"), expect.anything()); + }); + + it("should handle builtin field name case-insensitively (Body -> body)", async () => { + const message = { + type: "set_issue_field", + issue_number: 42, + field_name: "Body", + value: "Some body", + }; + + const result = await handler(message, {}); + + expect(result.success).toBe(true); + expect(mockGithub.rest.issues.update).toHaveBeenCalledWith(expect.objectContaining({ issue_number: 42, body: "Some body" })); + }); + + it("should respect allowed-fields restriction for builtin fields", async () => { + const { main } = require("./set_issue_field.cjs"); + const restrictedHandler = await main({ allowed_fields: ["title"] }); + + const result = await restrictedHandler({ + type: "set_issue_field", + issue_number: 42, + field_name: "body", + value: "Blocked body update", + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('"body" is not in the allowed-fields list'); + expect(mockGithub.rest.issues.update).not.toHaveBeenCalled(); + }); + + it("should allow builtin field when allowed-fields includes wildcard", async () => { + const { main } = require("./set_issue_field.cjs"); + const wildcardHandler = await main({ allowed_fields: ["*"] }); + + const result = await wildcardHandler({ + type: "set_issue_field", + issue_number: 42, + field_name: "body", + value: "Allowed body update", + }); + + expect(result.success).toBe(true); + expect(mockGithub.rest.issues.update).toHaveBeenCalledWith(expect.objectContaining({ issue_number: 42, body: "Allowed body update" })); + }); }); diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index 9e0d5a3699c..205240c184b 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -1434,7 +1434,7 @@ }, { "name": "set_issue_field", - "description": "Set a single GitHub issue field by name and value. Use field_name for discovery by field label (for example, \"Priority\"), or provide field_node_id to skip discovery. Supports text, number, date (YYYY-MM-DD), and single-select fields (value must match an option name).", + "description": "Set a single GitHub issue field by name and value. Use field_name for discovery by field label (for example, \"Priority\"), or provide field_node_id to skip discovery. Supports text, number, date (YYYY-MM-DD), and single-select fields (value must match an option name). Built-in issue properties (\"body\", \"title\") are also supported and are updated via the REST API.", "inputSchema": { "type": "object", "required": ["value"],