Skip to content

Commit 0903c25

Browse files
authored
Send GraphQL-Features header for issue intent mutations when issue_intents runtime feature is enabled (#41425)
1 parent 019f3ba commit 0903c25

6 files changed

Lines changed: 142 additions & 21 deletions

File tree

actions/setup/js/set_issue_field.cjs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,18 @@ function buildFieldUpdatePayload(field, rawValue) {
157157
* @param {Object} githubClient - Authenticated GitHub client
158158
* @param {string} issueNodeId - GraphQL node ID of the issue
159159
* @param {{fieldId: string, singleSelectOptionId?: string, numberValue?: number, dateValue?: string, textValue?: string, rationale?: string, confidence?: "LOW"|"MEDIUM"|"HIGH", suggest?: boolean}} fieldUpdate
160+
* @param {boolean} [useIntentHeader] - When true, includes the GraphQL-Features header to expose intent input types
160161
* @returns {Promise<void>}
161162
*/
162-
async function setIssueFieldValue(githubClient, issueNodeId, fieldUpdate) {
163+
async function setIssueFieldValue(githubClient, issueNodeId, fieldUpdate, useIntentHeader) {
164+
/** @type {Record<string, unknown>} */
165+
const variables = {
166+
issueId: issueNodeId,
167+
issueFields: [fieldUpdate],
168+
};
169+
if (useIntentHeader) {
170+
variables.headers = { "GraphQL-Features": "update_issue_suggestions" };
171+
}
163172
await githubClient.graphql(
164173
`mutation($issueId: ID!, $issueFields: [IssueFieldCreateOrUpdateInput!]!) {
165174
setIssueFieldValue(input: { issueId: $issueId, issueFields: $issueFields }) {
@@ -168,10 +177,7 @@ async function setIssueFieldValue(githubClient, issueNodeId, fieldUpdate) {
168177
}
169178
}
170179
}`,
171-
{
172-
issueId: issueNodeId,
173-
issueFields: [fieldUpdate],
174-
}
180+
variables
175181
);
176182
}
177183

@@ -349,11 +355,13 @@ async function main(config = {}) {
349355
...fieldUpdateResult.update,
350356
};
351357

352-
if (hasIssueIntentsRuntimeFeature()) {
358+
const useIntentHeader = hasIssueIntentsRuntimeFeature();
359+
if (useIntentHeader) {
353360
Object.assign(fieldUpdate, normalizeIssueIntentMetadata(item));
361+
core.info(`Using GraphQL-Features header for issue field mutation (issue_intents runtime feature enabled)`);
354362
}
355363

356-
await setIssueFieldValue(githubClient, issueNodeId, fieldUpdate);
364+
await setIssueFieldValue(githubClient, issueNodeId, fieldUpdate, useIntentHeader);
357365

358366
core.info(`Successfully set issue field ${JSON.stringify(fieldName || fieldNodeId)} to ${JSON.stringify(value)} on issue #${issueNumber}`);
359367

actions/setup/js/set_issue_field.test.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,7 @@ describe("set_issue_field (Handler Factory Architecture)", () => {
447447
suggest: true,
448448
}),
449449
],
450+
headers: { "GraphQL-Features": "update_issue_suggestions" },
450451
})
451452
);
452453
} finally {

actions/setup/js/set_issue_type.cjs

Lines changed: 92 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,69 @@ const AVAILABLE_TYPES_PATTERNS = [/one of:\s*(.+)$/i, /available(?: types?)?:\s*
2020
const NO_ISSUE_TYPES_PATTERNS = [/no issue types? (?:are )?available/i, /issue types? (?:is|are) not (?:enabled|configured)/i];
2121
const NO_ISSUE_TYPES_AVAILABLE_ERROR = "No issue types are available for this repository. Issue types must be configured in the repository or organization settings.";
2222

23+
/**
24+
* Fetches the node ID of an issue for use in GraphQL mutations.
25+
* @param {Object} githubClient - Authenticated GitHub client
26+
* @param {string} owner - Repository owner
27+
* @param {string} repo - Repository name
28+
* @param {number} issueNumber - Issue number
29+
* @returns {Promise<string>} Issue node ID
30+
*/
31+
async function getIssueNodeId(githubClient, owner, repo, issueNumber) {
32+
const { data } = await githubClient.rest.issues.get({ owner, repo, issue_number: issueNumber });
33+
return data.node_id;
34+
}
35+
36+
/**
37+
* Fetches the available issue types for an organization.
38+
* For personal-account owners the query returns null and the call site receives an empty array.
39+
* @param {Object} githubClient - Authenticated GitHub client
40+
* @param {string} owner - Organization login
41+
* @returns {Promise<Array<{id: string, name: string}>>} Issue type nodes
42+
*/
43+
async function fetchIssueTypesForOrg(githubClient, owner) {
44+
const result = await githubClient.graphql(
45+
`query($owner: String!) {
46+
organization(login: $owner) {
47+
issueTypes(first: 100) {
48+
nodes {
49+
id
50+
name
51+
}
52+
}
53+
}
54+
}`,
55+
{ owner }
56+
);
57+
return result?.organization?.issueTypes?.nodes ?? [];
58+
}
59+
60+
/**
61+
* Sets the issue type via GraphQL mutation using `IssueTypeUpdateInput`.
62+
* @param {Object} githubClient - Authenticated GitHub client
63+
* @param {string} issueNodeId - GraphQL node ID of the issue
64+
* @param {string} issueTypeId - GraphQL node ID of the issue type
65+
* @param {{ rationale?: string, confidence?: "LOW"|"MEDIUM"|"HIGH", suggest?: boolean }} intentMetadata - Intent metadata in GraphQL format
66+
* @returns {Promise<void>}
67+
*/
68+
async function setIssueTypeById(githubClient, issueNodeId, issueTypeId, intentMetadata) {
69+
const issueType = { id: issueTypeId, ...intentMetadata };
70+
await githubClient.graphql(
71+
`mutation($issueId: ID!, $issueType: IssueTypeUpdateInput!) {
72+
updateIssue(input: { id: $issueId, issueType: $issueType }) {
73+
issue {
74+
id
75+
}
76+
}
77+
}`,
78+
{
79+
issueId: issueNodeId,
80+
issueType,
81+
headers: { "GraphQL-Features": "update_issue_suggestions" },
82+
}
83+
);
84+
}
85+
2386
/**
2487
* @param {{ rationale?: string, confidence?: "LOW"|"MEDIUM"|"HIGH", suggest?: boolean }} intentMetadata Intent metadata in GraphQL format.
2588
* @returns {{ rationale?: string, confidence?: "low"|"medium"|"high", suggest?: boolean }} Intent metadata formatted for REST.
@@ -271,13 +334,35 @@ async function main(config = {}) {
271334
try {
272335
const { owner, repo } = repoParts;
273336
const intentMetadata = normalizeIssueIntentMetadata(item);
274-
const typeValue = buildIssueTypeValue(isClear, resolvedIssueTypeName, intentMetadata);
275-
await githubClient.rest.issues.update({
276-
owner,
277-
repo,
278-
issue_number: issueNumber,
279-
type: typeValue,
280-
});
337+
338+
if (hasIssueIntentsRuntimeFeature() && !isClear) {
339+
// GraphQL intent path: resolve the type's node ID from org issue types, then
340+
// call setIssueTypeById with IssueTypeUpdateInput + the GraphQL-Features header.
341+
core.info(`Using GraphQL intent path (issue_intents runtime feature enabled)`);
342+
core.info(`Fetching issue node ID for issue #${issueNumber}`);
343+
const issueNodeId = await getIssueNodeId(githubClient, owner, repo, issueNumber);
344+
core.info(`Fetching issue types for org ${owner}`);
345+
const issueTypes = await fetchIssueTypesForOrg(githubClient, owner);
346+
core.info(`Found ${issueTypes.length} issue type(s) for org ${owner}`);
347+
const typeNode = issueTypes.find(t => t.name.toLowerCase() === resolvedIssueTypeName.toLowerCase());
348+
if (!typeNode) {
349+
const availableNames = issueTypes.map(t => t.name).join(", ");
350+
const error = availableNames ? `Issue type ${JSON.stringify(resolvedIssueTypeName)} not found. Available types: ${availableNames}` : NO_ISSUE_TYPES_AVAILABLE_ERROR;
351+
core.error(`Failed to set issue type on issue #${issueNumber}: ${error}`);
352+
return { success: false, error };
353+
}
354+
core.info(`Resolved issue type ${JSON.stringify(resolvedIssueTypeName)} to node ID ${typeNode.id}`);
355+
await setIssueTypeById(githubClient, issueNodeId, typeNode.id, intentMetadata);
356+
} else {
357+
// REST path: used for the clear case and when the issue_intents feature is off.
358+
const typeValue = buildIssueTypeValue(isClear, resolvedIssueTypeName, intentMetadata);
359+
await githubClient.rest.issues.update({
360+
owner,
361+
repo,
362+
issue_number: issueNumber,
363+
type: typeValue,
364+
});
365+
}
281366

282367
const successMsg = isClear ? `Successfully cleared issue type on issue #${issueNumber}` : `Successfully set issue type to ${JSON.stringify(resolvedIssueTypeName)} on issue #${issueNumber}`;
283368
core.info(successMsg);

actions/setup/js/set_issue_type.test.cjs

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const mockGithub = {
3333
update: vi.fn(),
3434
},
3535
},
36+
graphql: vi.fn(),
3637
};
3738

3839
global.core = mockCore;
@@ -325,9 +326,29 @@ describe("set_issue_type (Handler Factory Architecture)", () => {
325326
);
326327
});
327328

328-
it("should pass issue intent metadata when runtime feature is enabled", async () => {
329+
it("should use GraphQL intent path with IssueTypeUpdateInput and GraphQL-Features header when runtime feature is enabled", async () => {
329330
process.env.GH_AW_RUNTIME_FEATURES = "issue_intents";
330331

332+
const issueNodeId = "I_kwDO_testissue";
333+
const issueTypeNodeId = "IT_kwDO_bug";
334+
335+
mockGithub.rest.issues.get.mockResolvedValueOnce({ data: { node_id: issueNodeId } });
336+
mockGithub.graphql.mockImplementation(async query => {
337+
if (query.includes("organization(login")) {
338+
return {
339+
organization: {
340+
issueTypes: {
341+
nodes: [{ id: issueTypeNodeId, name: "Bug" }],
342+
},
343+
},
344+
};
345+
}
346+
if (query.includes("updateIssue")) {
347+
return { updateIssue: { issue: { id: issueNodeId } } };
348+
}
349+
return {};
350+
});
351+
331352
try {
332353
const { main } = require("./set_issue_type.cjs");
333354
const featureHandler = await main({ max: 5 });
@@ -345,15 +366,18 @@ describe("set_issue_type (Handler Factory Architecture)", () => {
345366
);
346367

347368
expect(result.success).toBe(true);
348-
expect(mockGithub.rest.issues.update).toHaveBeenCalledWith(
369+
expect(mockGithub.rest.issues.update).not.toHaveBeenCalled();
370+
expect(mockGithub.graphql).toHaveBeenCalledWith(
371+
expect.stringContaining("IssueTypeUpdateInput"),
349372
expect.objectContaining({
350-
issue_number: 42,
351-
type: {
352-
value: "Bug",
373+
issueId: issueNodeId,
374+
issueType: {
375+
id: issueTypeNodeId,
353376
rationale: "Author explicitly requests a bug fix",
354-
confidence: "high",
377+
confidence: "HIGH",
355378
suggest: true,
356379
},
380+
headers: { "GraphQL-Features": "update_issue_suggestions" },
357381
})
358382
);
359383
} finally {

actions/setup/js/update_issue.cjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,11 @@ async function executeIssueUpdate(github, context, issueNumber, updateData) {
135135
throw new Error(`Failed to resolve GraphQL node ID for issue #${issueNumber}`);
136136
}
137137

138+
core.info(`Using GraphQL intent path for label update with GraphQL-Features header (issue_intents runtime feature enabled)`);
138139
const repoLabels = await fetchAllRepoLabels(github, context.repo.owner, context.repo.repo);
139140
const labelIdByName = new Map(repoLabels.map(label => [label.name.toLowerCase(), label.id]));
140141
const labels = buildIssueIntentLabelUpdates(labelSpecs, labelIdByName);
142+
core.info(`Updating ${labels.length} label(s) on issue #${issueNumber} via GraphQL intent mutation`);
141143
const result = await github.graphql(
142144
`mutation($issueId: ID!, $labels: [LabelUpdateInput!]!) {
143145
updateIssue(input: { id: $issueId, labels: $labels }) {
@@ -151,7 +153,7 @@ async function executeIssueUpdate(github, context, issueNumber, updateData) {
151153
}
152154
}
153155
}`,
154-
{ issueId: issueNodeId, labels }
156+
{ issueId: issueNodeId, labels, headers: { "GraphQL-Features": "update_issue_suggestions" } }
155157
);
156158

157159
issue = {

actions/setup/js/update_issue.test.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,6 +1016,7 @@ describe("update_issue.cjs - cross-repo and operation integration", () => {
10161016
suggest: true,
10171017
},
10181018
],
1019+
headers: { "GraphQL-Features": "update_issue_suggestions" },
10191020
})
10201021
);
10211022
} finally {

0 commit comments

Comments
 (0)