Skip to content
Closed
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
2 changes: 1 addition & 1 deletion actions/setup/js/safe_outputs_tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
38 changes: 38 additions & 0 deletions actions/setup/js/set_issue_field.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>}
*/
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
Expand Down Expand Up @@ -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);
Expand Down
94 changes: 94 additions & 0 deletions actions/setup/js/set_issue_field.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const mockGithub = {
rest: {
issues: {
get: vi.fn(),
update: vi.fn(),
},
},
graphql: mockGraphql,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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" }));
});
});
2 changes: 1 addition & 1 deletion pkg/workflow/js/safe_outputs_tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down