Skip to content

Commit c2d54be

Browse files
authored
Remove the client-side issue_intents runtime gate (#45275)
1 parent 4e19d7b commit c2d54be

11 files changed

Lines changed: 455 additions & 305 deletions

actions/setup/js/add_labels.cjs

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const { MAX_LABELS } = require("./constants.cjs");
3333
const { createCountGatedHandler } = require("./handler_scaffold.cjs");
3434
const { withRetry, RATE_LIMIT_RETRY_CONFIG } = require("./error_recovery.cjs");
3535
const { resolveInvocationContext } = require("./invocation_context_helpers.cjs");
36-
const { hasIssueIntentsRuntimeFeature, normalizeIssueIntentLabelNames, normalizeIssueIntentLabelSpecs } = require("./issue_intents.cjs");
36+
const { normalizeIssueIntentLabelInputs } = require("./issue_intents.cjs");
3737

3838
/**
3939
* Main handler factory for add_labels
@@ -92,23 +92,24 @@ const main = createCountGatedHandler({
9292
const contextType = effectiveContext.eventPayload?.pull_request ? "pull request" : "issue";
9393
const requestedLabels = message.labels ?? [];
9494
core.info(`Requested labels: ${JSON.stringify(requestedLabels)}`);
95-
const issueIntentsEnabled = hasIssueIntentsRuntimeFeature();
9695
/** @type {Map<string, {name: string, rationale?: string, confidence?: "LOW"|"MEDIUM"|"HIGH", suggest?: boolean}>} */
9796
const requestedLabelSpecByLowerName = new Map();
9897
let requestedLabelNames;
9998
try {
100-
if (issueIntentsEnabled) {
101-
const requestedLabelSpecs = normalizeIssueIntentLabelSpecs(requestedLabels);
102-
for (const labelSpec of requestedLabelSpecs) {
103-
const key = labelSpec.name.toLowerCase();
104-
if (!requestedLabelSpecByLowerName.has(key)) {
105-
requestedLabelSpecByLowerName.set(key, labelSpec);
106-
}
99+
const requestedLabelInputs = normalizeIssueIntentLabelInputs(requestedLabels);
100+
requestedLabelNames = requestedLabelInputs.map(label => {
101+
if (typeof label === "string") {
102+
return label;
107103
}
108-
requestedLabelNames = requestedLabelSpecs.map(labelSpec => labelSpec.name);
109-
} else {
110-
requestedLabelNames = normalizeIssueIntentLabelNames(requestedLabels);
111-
}
104+
const key = label.name.toLowerCase();
105+
const existing = requestedLabelSpecByLowerName.get(key);
106+
const newHasMetadata = Boolean(label.rationale || label.confidence || label.suggest);
107+
const existingHasMetadata = existing && Boolean(existing.rationale || existing.confidence || existing.suggest);
108+
if (!existing || (!existingHasMetadata && newHasMetadata)) {
109+
requestedLabelSpecByLowerName.set(key, label);
110+
}
111+
return label.name;
112+
});
112113
} catch (error) {
113114
const errorMessage = getErrorMessage(error);
114115
core.warning(`Invalid add_labels payload: ${errorMessage}`);
@@ -186,7 +187,11 @@ const main = createCountGatedHandler({
186187
};
187188
}
188189

189-
const labelsRequestPayload = issueIntentsEnabled ? uniqueLabels.map(name => requestedLabelSpecByLowerName.get(name.toLowerCase()) ?? { name }) : uniqueLabels;
190+
const labelsRequestPayload = uniqueLabels.map(name => {
191+
const labelSpec = requestedLabelSpecByLowerName.get(name.toLowerCase()) ?? { name };
192+
const hasIntentMetadata = Boolean(labelSpec.rationale || labelSpec.confidence || labelSpec.suggest);
193+
return hasIntentMetadata ? labelSpec : labelSpec.name;
194+
});
190195

191196
core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${itemNumber} in ${itemRepo}: ${JSON.stringify(labelsRequestPayload)}`);
192197

actions/setup/js/add_labels.test.cjs

Lines changed: 67 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -125,47 +125,59 @@ describe("add_labels", () => {
125125
const result = await handler(
126126
{
127127
item_number: 456,
128-
labels: [{ name: "bug", rationale: "Known crash path", confidence: "high", suggest: true }],
128+
labels: [{ name: "bug", rationale: "Known crash path", confidence: "HIGH", suggest: true }],
129129
},
130130
{}
131131
);
132132

133133
expect(result.success).toBe(true);
134134
expect(result.number).toBe(456);
135135
expect(addLabelsCalls).toHaveLength(1);
136-
expect(addLabelsCalls[0].labels).toEqual(["bug"]);
136+
expect(addLabelsCalls[0].labels).toEqual([{ name: "bug", rationale: "Known crash path", confidence: "HIGH", suggest: true }]);
137137
});
138138

139-
it("should send structured label metadata when issue_intents runtime feature is enabled", async () => {
140-
const previousFeatures = process.env.GH_AW_RUNTIME_FEATURES;
141-
process.env.GH_AW_RUNTIME_FEATURES = "issue_intents";
142-
try {
143-
const handler = await main({ max: 10 });
144-
const addLabelsCalls = [];
139+
it("should send structured label metadata without requiring a runtime feature", async () => {
140+
const handler = await main({ max: 10 });
141+
const addLabelsCalls = [];
145142

146-
mockGithub.rest.issues.addLabels = async params => {
147-
addLabelsCalls.push(params);
148-
return {};
149-
};
143+
mockGithub.rest.issues.addLabels = async params => {
144+
addLabelsCalls.push(params);
145+
return {};
146+
};
150147

151-
const result = await handler(
152-
{
153-
item_number: 456,
154-
labels: [{ name: "bug", rationale: "Application crashes on file uploads >5MB", confidence: "HIGH" }],
155-
},
156-
{}
157-
);
158-
159-
expect(result.success).toBe(true);
160-
expect(addLabelsCalls).toHaveLength(1);
161-
expect(addLabelsCalls[0].labels).toEqual([{ name: "bug", rationale: "Application crashes on file uploads >5MB", confidence: "HIGH" }]);
162-
} finally {
163-
if (previousFeatures === undefined) {
164-
delete process.env.GH_AW_RUNTIME_FEATURES;
165-
} else {
166-
process.env.GH_AW_RUNTIME_FEATURES = previousFeatures;
167-
}
168-
}
148+
const result = await handler(
149+
{
150+
item_number: 456,
151+
labels: [{ name: "bug", rationale: "Application crashes on file uploads >5MB", confidence: "HIGH" }],
152+
},
153+
{}
154+
);
155+
156+
expect(result.success).toBe(true);
157+
expect(addLabelsCalls).toHaveLength(1);
158+
expect(addLabelsCalls[0].labels).toEqual([{ name: "bug", rationale: "Application crashes on file uploads >5MB", confidence: "HIGH" }]);
159+
});
160+
161+
it("should normalize lowercase confidence in structured label metadata", async () => {
162+
const handler = await main({ max: 10 });
163+
const addLabelsCalls = [];
164+
165+
mockGithub.rest.issues.addLabels = async params => {
166+
addLabelsCalls.push(params);
167+
return {};
168+
};
169+
170+
const result = await handler(
171+
{
172+
item_number: 456,
173+
labels: [{ name: "bug", rationale: "Application crashes on file uploads >5MB", confidence: "high" }],
174+
},
175+
{}
176+
);
177+
178+
expect(result.success).toBe(true);
179+
expect(addLabelsCalls).toHaveLength(1);
180+
expect(addLabelsCalls[0].labels).toEqual([{ name: "bug", rationale: "Application crashes on file uploads >5MB", confidence: "HIGH" }]);
169181
});
170182

171183
it("should accept issue_number as an alias for item_number", async () => {
@@ -500,6 +512,31 @@ describe("add_labels", () => {
500512
expect(result.labelsAdded).toEqual(["bug", "enhancement"]);
501513
});
502514

515+
it("should prefer the metadata-bearing entry when a duplicate label name appears", async () => {
516+
const handler = await main({ max: 10 });
517+
const addLabelsCalls = [];
518+
519+
mockGithub.rest.issues.addLabels = async params => {
520+
addLabelsCalls.push(params);
521+
return {};
522+
};
523+
524+
const result = await handler(
525+
{
526+
item_number: 100,
527+
// First "bug" has no metadata; second "bug" carries intent metadata — second should win
528+
labels: [{ name: "bug" }, { name: "bug", rationale: "Known crash path", confidence: "HIGH", suggest: true }],
529+
},
530+
{}
531+
);
532+
533+
expect(result.success).toBe(true);
534+
expect(result.labelsAdded).toEqual(["bug"]);
535+
expect(addLabelsCalls.length).toBe(1);
536+
// The payload sent to the API must use the spec with metadata, not the plain string
537+
expect(addLabelsCalls[0].labels).toEqual([{ name: "bug", rationale: "Known crash path", confidence: "HIGH", suggest: true }]);
538+
});
539+
503540
it("should sanitize and trim label names", async () => {
504541
const handler = await main({ max: 10 });
505542
const addLabelsCalls = [];

actions/setup/js/generate_safe_outputs_tools.cjs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,12 @@
2525
* Default: ${RUNNER_TEMP}/gh-aw/safeoutputs/tools_meta.json
2626
* GH_AW_SAFE_OUTPUTS_TOOLS_PATH - Output path for the generated tools.json
2727
* Default: ${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json
28-
* GH_AW_RUNTIME_FEATURES - Newline-delimited runtime features in key or key=value format
29-
* Parsed using runtime_features.cjs helpers
3028
*/
3129

3230
const fs = require("fs");
3331
const path = require("path");
3432
const { ERR_CONFIG } = require("./error_codes.cjs");
3533
const { getErrorMessage } = require("./error_helpers.cjs");
36-
const { parseRuntimeFeatures, hasRuntimeFeature } = require("./runtime_features.cjs");
37-
3834
const ADD_COMMENT_DEFAULT_DISCUSSIONS_NOTE =
3935
"NOTE: By default, this tool does not require discussions:write permission. Set 'discussions: true' in the workflow's safe-outputs.add-comment configuration to enable discussion comments and request this permission.";
4036
const ADD_COMMENT_DISCUSSIONS_ENABLED_NOTE = "NOTE: Discussion comments are enabled for this workflow because discussions:write permission is available.";
@@ -135,8 +131,6 @@ async function main() {
135131
// This filters out non-tool config entries like dispatch_workflow, call_workflow,
136132
// mentions, max_bot_mentions, etc.
137133
const enabledToolNames = new Set(Object.keys(config).filter(k => sourceToolNames.has(k)));
138-
const runtimeFeatures = parseRuntimeFeatures(process.env.GH_AW_RUNTIME_FEATURES);
139-
140134
// Filter predefined tools to those enabled in config and apply enhancements
141135
const filteredTools = allTools
142136
.filter(tool => enabledToolNames.has(tool.name))
@@ -154,7 +148,7 @@ async function main() {
154148
if (descSuffix) {
155149
enhancedTool.description = (enhancedTool.description || "") + descSuffix;
156150
}
157-
if (hasRuntimeFeature(runtimeFeatures, "issue_intents") && ["set_issue_type", "set_issue_field", "add_labels"].includes(tool.name)) {
151+
if (["set_issue_type", "set_issue_field", "add_labels"].includes(tool.name)) {
158152
enhancedTool.description = `${enhancedTool.description || ""} INTENT: Include rationale (string, max 280 chars) and confidence (string, exactly one of: LOW, MEDIUM, HIGH) with each call.`.trim();
159153
}
160154

actions/setup/js/generate_safe_outputs_tools.test.cjs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ describe("generate_safe_outputs_tools", () => {
307307
expect(addCommentTool.description).not.toContain("Supports reply_to_id for discussion threading.");
308308
});
309309

310-
it("adds issue intent suffix for issue tools when issue_intents runtime feature is enabled", () => {
310+
it("adds issue intent suffix for issue tools without requiring a runtime feature", () => {
311311
fs.writeFileSync(
312312
toolsSourcePath,
313313
JSON.stringify([
@@ -320,7 +320,7 @@ describe("generate_safe_outputs_tools", () => {
320320
fs.writeFileSync(configPath, JSON.stringify({ set_issue_type: {}, set_issue_field: {}, add_labels: {}, create_issue: {} }));
321321
fs.writeFileSync(toolsMetaPath, JSON.stringify({ description_suffixes: {}, repo_params: {}, dynamic_tools: [] }));
322322

323-
runScript({ GH_AW_RUNTIME_FEATURES: "other\nissue_intents\nanother=true" });
323+
runScript();
324324

325325
const result = JSON.parse(fs.readFileSync(outputPath, "utf8"));
326326
const intentSuffix = "INTENT: Include rationale (string, max 280 chars) and confidence (string, exactly one of: LOW, MEDIUM, HIGH) with each call.";
@@ -330,7 +330,7 @@ describe("generate_safe_outputs_tools", () => {
330330
expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "create_issue").description).not.toContain(intentSuffix);
331331
});
332332

333-
it("does not add issue intent suffix when issue_intents runtime feature is not enabled", () => {
333+
it("adds issue intent suffix even when unrelated runtime features are present", () => {
334334
fs.writeFileSync(
335335
toolsSourcePath,
336336
JSON.stringify([
@@ -346,8 +346,8 @@ describe("generate_safe_outputs_tools", () => {
346346

347347
const result = JSON.parse(fs.readFileSync(outputPath, "utf8"));
348348
const intentSuffix = "INTENT: Include rationale (string, max 280 chars) and confidence (string, exactly one of: LOW, MEDIUM, HIGH) with each call.";
349-
expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "set_issue_type").description).not.toContain(intentSuffix);
350-
expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "set_issue_field").description).not.toContain(intentSuffix);
351-
expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "add_labels").description).not.toContain(intentSuffix);
349+
expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "set_issue_type").description).toContain(intentSuffix);
350+
expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "set_issue_field").description).toContain(intentSuffix);
351+
expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "add_labels").description).toContain(intentSuffix);
352352
});
353353
});

actions/setup/js/issue_intents.cjs

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,8 @@
44

55
const { sanitizeContent } = require("./sanitize_content.cjs");
66
const { sanitizeLabelContent } = require("./sanitize_label_content.cjs");
7-
const { hasRuntimeFeature, parseRuntimeFeatures } = require("./runtime_features.cjs");
8-
9-
const ISSUE_INTENTS_FEATURE = "issue_intents";
107
const ISSUE_INTENT_RATIONALE_MAX_LENGTH = 280;
118

12-
function hasIssueIntentsRuntimeFeature() {
13-
if (typeof global.hasRuntimeFeature === "function") {
14-
return global.hasRuntimeFeature(ISSUE_INTENTS_FEATURE);
15-
}
16-
return hasRuntimeFeature(parseRuntimeFeatures(process.env.GH_AW_RUNTIME_FEATURES), ISSUE_INTENTS_FEATURE);
17-
}
18-
199
function normalizeIssueIntentMetadata(source) {
2010
if (!source || typeof source !== "object") {
2111
return {};
@@ -106,6 +96,39 @@ function normalizeIssueIntentLabelSpecs(labels) {
10696
});
10797
}
10898

99+
function normalizeIssueIntentLabelInputs(labels) {
100+
if (labels === undefined) {
101+
return [];
102+
}
103+
if (!Array.isArray(labels)) {
104+
const receivedType = labels === null ? "null" : typeof labels;
105+
throw new Error(`Invalid labels. Expected an array of label names or label spec objects; received ${receivedType}.`);
106+
}
107+
108+
return labels.map((label, index) => {
109+
if (typeof label === "string") {
110+
return label;
111+
}
112+
113+
if (!label || typeof label !== "object" || typeof label.name !== "string") {
114+
throw new Error(`Invalid labels[${index}] entry. Expected a string label name or an object with a string "name" field.`);
115+
}
116+
117+
const name = sanitizeLabelContent(label.name);
118+
if (!name) {
119+
throw new Error(`Invalid labels[${index}] entry. Label names must be non-empty strings.`);
120+
}
121+
if (name.startsWith("-")) {
122+
throw new Error(`Label removal is not permitted. Found line starting with '-': ${name}`);
123+
}
124+
125+
return {
126+
name,
127+
...normalizeIssueIntentMetadata(label),
128+
};
129+
});
130+
}
131+
109132
function normalizeIssueIntentLabelNames(labels) {
110133
if (labels === undefined) {
111134
return [];
@@ -145,7 +168,7 @@ function buildIssueIntentLabelUpdates(labelSpecs, labelIdByName) {
145168
module.exports = {
146169
buildIssueIntentLabelUpdates,
147170
getIssueIntentLabelNames,
148-
hasIssueIntentsRuntimeFeature,
171+
normalizeIssueIntentLabelInputs,
149172
normalizeIssueIntentLabelNames,
150173
normalizeIssueIntentLabelSpecs,
151174
normalizeIssueIntentMetadata,

actions/setup/js/set_issue_field.cjs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const { isStagedMode, checkRequiredFilter } = require("./safe_output_helpers.cjs
1212
const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
1313
const { parseAllowedIssueFields, validateAllowedIssueFieldName, BUILTIN_ISSUE_FIELD_NAMES } = require("./allowed_issue_fields.cjs");
1414
const { resolveSafeOutputIssueTarget } = require("./temporary_id.cjs");
15-
const { hasIssueIntentsRuntimeFeature, normalizeIssueIntentMetadata } = require("./issue_intents.cjs");
15+
const { normalizeIssueIntentMetadata } = require("./issue_intents.cjs");
1616

1717
/** @type {string} Safe output type handled by this module */
1818
const HANDLER_TYPE = "set_issue_field";
@@ -342,13 +342,10 @@ async function main(config = {}) {
342342
...fieldUpdateResult.update,
343343
};
344344

345-
const useIntentHeader = hasIssueIntentsRuntimeFeature();
346-
if (useIntentHeader) {
347-
Object.assign(fieldUpdate, normalizeIssueIntentMetadata(item));
348-
core.info(`Using GraphQL-Features header for issue field mutation (issue_intents runtime feature enabled)`);
349-
}
345+
Object.assign(fieldUpdate, normalizeIssueIntentMetadata(item));
346+
core.info("Using GraphQL-Features header for issue field mutation");
350347

351-
await setIssueFieldValue(githubClient, issueNodeId, fieldUpdate, useIntentHeader);
348+
await setIssueFieldValue(githubClient, issueNodeId, fieldUpdate, true);
352349

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

0 commit comments

Comments
 (0)