Skip to content
Merged
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
21 changes: 18 additions & 3 deletions .github/workflows/smoke-call-workflow.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 19 additions & 14 deletions actions/setup/js/add_labels.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ 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");
const { hasIssueIntentsRuntimeFeature, normalizeIssueIntentLabelNames, normalizeIssueIntentLabelSpecs } = require("./issue_intents.cjs");
const { normalizeIssueIntentLabelInputs } = require("./issue_intents.cjs");

/**
* Main handler factory for add_labels
Expand Down Expand Up @@ -92,23 +92,24 @@ const main = createCountGatedHandler({
const contextType = effectiveContext.eventPayload?.pull_request ? "pull request" : "issue";
const requestedLabels = message.labels ?? [];
core.info(`Requested labels: ${JSON.stringify(requestedLabels)}`);
const issueIntentsEnabled = hasIssueIntentsRuntimeFeature();
/** @type {Map<string, {name: string, rationale?: string, confidence?: "LOW"|"MEDIUM"|"HIGH", suggest?: boolean}>} */
const requestedLabelSpecByLowerName = new Map();
let requestedLabelNames;
try {
if (issueIntentsEnabled) {
const requestedLabelSpecs = normalizeIssueIntentLabelSpecs(requestedLabels);
for (const labelSpec of requestedLabelSpecs) {
const key = labelSpec.name.toLowerCase();
if (!requestedLabelSpecByLowerName.has(key)) {
requestedLabelSpecByLowerName.set(key, labelSpec);
}
const requestedLabelInputs = normalizeIssueIntentLabelInputs(requestedLabels);
requestedLabelNames = requestedLabelInputs.map(label => {
if (typeof label === "string") {
return label;
}
requestedLabelNames = requestedLabelSpecs.map(labelSpec => labelSpec.name);
} else {
requestedLabelNames = normalizeIssueIntentLabelNames(requestedLabels);
}
const key = label.name.toLowerCase();
const existing = requestedLabelSpecByLowerName.get(key);
const newHasMetadata = Boolean(label.rationale || label.confidence || label.suggest);
const existingHasMetadata = existing && Boolean(existing.rationale || existing.confidence || existing.suggest);
if (!existing || (!existingHasMetadata && newHasMetadata)) {
requestedLabelSpecByLowerName.set(key, label);
}
Comment thread
github-actions[bot] marked this conversation as resolved.
return label.name;
});
} catch (error) {
const errorMessage = getErrorMessage(error);
core.warning(`Invalid add_labels payload: ${errorMessage}`);
Expand Down Expand Up @@ -186,7 +187,11 @@ const main = createCountGatedHandler({
};
}

const labelsRequestPayload = issueIntentsEnabled ? uniqueLabels.map(name => requestedLabelSpecByLowerName.get(name.toLowerCase()) ?? { name }) : uniqueLabels;
const labelsRequestPayload = uniqueLabels.map(name => {
const labelSpec = requestedLabelSpecByLowerName.get(name.toLowerCase()) ?? { name };
const hasIntentMetadata = Boolean(labelSpec.rationale || labelSpec.confidence || labelSpec.suggest);
return hasIntentMetadata ? labelSpec : labelSpec.name;
});

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

Expand Down
97 changes: 67 additions & 30 deletions actions/setup/js/add_labels.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -125,47 +125,59 @@ describe("add_labels", () => {
const result = await handler(
{
item_number: 456,
labels: [{ name: "bug", rationale: "Known crash path", confidence: "high", suggest: true }],
labels: [{ name: "bug", rationale: "Known crash path", confidence: "HIGH", suggest: true }],
},
{}
);

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

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

mockGithub.rest.issues.addLabels = async params => {
addLabelsCalls.push(params);
return {};
};
mockGithub.rest.issues.addLabels = async params => {
addLabelsCalls.push(params);
return {};
};

const result = await handler(
{
item_number: 456,
labels: [{ name: "bug", rationale: "Application crashes on file uploads >5MB", confidence: "HIGH" }],
},
{}
);

expect(result.success).toBe(true);
expect(addLabelsCalls).toHaveLength(1);
expect(addLabelsCalls[0].labels).toEqual([{ name: "bug", rationale: "Application crashes on file uploads >5MB", confidence: "HIGH" }]);
} finally {
if (previousFeatures === undefined) {
delete process.env.GH_AW_RUNTIME_FEATURES;
} else {
process.env.GH_AW_RUNTIME_FEATURES = previousFeatures;
}
}
const result = await handler(
{
item_number: 456,
labels: [{ name: "bug", rationale: "Application crashes on file uploads >5MB", confidence: "HIGH" }],
},
{}
);

expect(result.success).toBe(true);
expect(addLabelsCalls).toHaveLength(1);
expect(addLabelsCalls[0].labels).toEqual([{ name: "bug", rationale: "Application crashes on file uploads >5MB", confidence: "HIGH" }]);
});

it("should normalize lowercase confidence in structured label metadata", async () => {
const handler = await main({ max: 10 });
const addLabelsCalls = [];

mockGithub.rest.issues.addLabels = async params => {
addLabelsCalls.push(params);
return {};
};

const result = await handler(
{
item_number: 456,
labels: [{ name: "bug", rationale: "Application crashes on file uploads >5MB", confidence: "high" }],
},
{}
);

expect(result.success).toBe(true);
expect(addLabelsCalls).toHaveLength(1);
expect(addLabelsCalls[0].labels).toEqual([{ name: "bug", rationale: "Application crashes on file uploads >5MB", confidence: "HIGH" }]);
});

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

it("should prefer the metadata-bearing entry when a duplicate label name appears", async () => {
const handler = await main({ max: 10 });
const addLabelsCalls = [];

mockGithub.rest.issues.addLabels = async params => {
addLabelsCalls.push(params);
return {};
};

const result = await handler(
{
item_number: 100,
// First "bug" has no metadata; second "bug" carries intent metadata — second should win
labels: [{ name: "bug" }, { name: "bug", rationale: "Known crash path", confidence: "HIGH", suggest: true }],
},
{}
);

expect(result.success).toBe(true);
expect(result.labelsAdded).toEqual(["bug"]);
expect(addLabelsCalls.length).toBe(1);
// The payload sent to the API must use the spec with metadata, not the plain string
expect(addLabelsCalls[0].labels).toEqual([{ name: "bug", rationale: "Known crash path", confidence: "HIGH", suggest: true }]);
});

it("should sanitize and trim label names", async () => {
const handler = await main({ max: 10 });
const addLabelsCalls = [];
Expand Down
8 changes: 1 addition & 7 deletions actions/setup/js/generate_safe_outputs_tools.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,12 @@
* Default: ${RUNNER_TEMP}/gh-aw/safeoutputs/tools_meta.json
* GH_AW_SAFE_OUTPUTS_TOOLS_PATH - Output path for the generated tools.json
* Default: ${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json
* GH_AW_RUNTIME_FEATURES - Newline-delimited runtime features in key or key=value format
* Parsed using runtime_features.cjs helpers
*/

const fs = require("fs");
const path = require("path");
const { ERR_CONFIG } = require("./error_codes.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { parseRuntimeFeatures, hasRuntimeFeature } = require("./runtime_features.cjs");

const ADD_COMMENT_DEFAULT_DISCUSSIONS_NOTE =
"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.";
const ADD_COMMENT_DISCUSSIONS_ENABLED_NOTE = "NOTE: Discussion comments are enabled for this workflow because discussions:write permission is available.";
Expand Down Expand Up @@ -135,8 +131,6 @@ async function main() {
// This filters out non-tool config entries like dispatch_workflow, call_workflow,
// mentions, max_bot_mentions, etc.
const enabledToolNames = new Set(Object.keys(config).filter(k => sourceToolNames.has(k)));
const runtimeFeatures = parseRuntimeFeatures(process.env.GH_AW_RUNTIME_FEATURES);

// Filter predefined tools to those enabled in config and apply enhancements
const filteredTools = allTools
.filter(tool => enabledToolNames.has(tool.name))
Expand All @@ -154,7 +148,7 @@ async function main() {
if (descSuffix) {
enhancedTool.description = (enhancedTool.description || "") + descSuffix;
}
if (hasRuntimeFeature(runtimeFeatures, "issue_intents") && ["set_issue_type", "set_issue_field", "add_labels"].includes(tool.name)) {
if (["set_issue_type", "set_issue_field", "add_labels"].includes(tool.name)) {
enhancedTool.description = `${enhancedTool.description || ""} INTENT: Include rationale (string, max 280 chars) and confidence (string, exactly one of: LOW, MEDIUM, HIGH) with each call.`.trim();
}

Expand Down
12 changes: 6 additions & 6 deletions actions/setup/js/generate_safe_outputs_tools.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ describe("generate_safe_outputs_tools", () => {
expect(addCommentTool.description).not.toContain("Supports reply_to_id for discussion threading.");
});

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

runScript({ GH_AW_RUNTIME_FEATURES: "other\nissue_intents\nanother=true" });
runScript();

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

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

const result = JSON.parse(fs.readFileSync(outputPath, "utf8"));
const intentSuffix = "INTENT: Include rationale (string, max 280 chars) and confidence (string, exactly one of: LOW, MEDIUM, HIGH) with each call.";
expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "set_issue_type").description).not.toContain(intentSuffix);
expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "set_issue_field").description).not.toContain(intentSuffix);
expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "add_labels").description).not.toContain(intentSuffix);
expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "set_issue_type").description).toContain(intentSuffix);
expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "set_issue_field").description).toContain(intentSuffix);
expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "add_labels").description).toContain(intentSuffix);
});
});
45 changes: 34 additions & 11 deletions actions/setup/js/issue_intents.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,8 @@

const { sanitizeContent } = require("./sanitize_content.cjs");
const { sanitizeLabelContent } = require("./sanitize_label_content.cjs");
const { hasRuntimeFeature, parseRuntimeFeatures } = require("./runtime_features.cjs");

const ISSUE_INTENTS_FEATURE = "issue_intents";
const ISSUE_INTENT_RATIONALE_MAX_LENGTH = 280;

function hasIssueIntentsRuntimeFeature() {
if (typeof global.hasRuntimeFeature === "function") {
return global.hasRuntimeFeature(ISSUE_INTENTS_FEATURE);
}
return hasRuntimeFeature(parseRuntimeFeatures(process.env.GH_AW_RUNTIME_FEATURES), ISSUE_INTENTS_FEATURE);
}

function normalizeIssueIntentMetadata(source) {
if (!source || typeof source !== "object") {
return {};
Expand Down Expand Up @@ -106,6 +96,39 @@ function normalizeIssueIntentLabelSpecs(labels) {
});
}

function normalizeIssueIntentLabelInputs(labels) {
if (labels === undefined) {
return [];
}
if (!Array.isArray(labels)) {
const receivedType = labels === null ? "null" : typeof labels;
throw new Error(`Invalid labels. Expected an array of label names or label spec objects; received ${receivedType}.`);
}

return labels.map((label, index) => {
if (typeof label === "string") {
return label;
}

if (!label || typeof label !== "object" || typeof label.name !== "string") {
throw new Error(`Invalid labels[${index}] entry. Expected a string label name or an object with a string "name" field.`);
}

const name = sanitizeLabelContent(label.name);
if (!name) {
throw new Error(`Invalid labels[${index}] entry. Label names must be non-empty strings.`);
}
if (name.startsWith("-")) {
throw new Error(`Label removal is not permitted. Found line starting with '-': ${name}`);
}

return {
name,
...normalizeIssueIntentMetadata(label),
};
});
}

function normalizeIssueIntentLabelNames(labels) {
if (labels === undefined) {
return [];
Expand Down Expand Up @@ -145,7 +168,7 @@ function buildIssueIntentLabelUpdates(labelSpecs, labelIdByName) {
module.exports = {
buildIssueIntentLabelUpdates,
getIssueIntentLabelNames,
hasIssueIntentsRuntimeFeature,
normalizeIssueIntentLabelInputs,
normalizeIssueIntentLabelNames,
normalizeIssueIntentLabelSpecs,
normalizeIssueIntentMetadata,
Expand Down
11 changes: 4 additions & 7 deletions actions/setup/js/set_issue_field.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const { isStagedMode, checkRequiredFilter } = require("./safe_output_helpers.cjs
const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
const { parseAllowedIssueFields, validateAllowedIssueFieldName, BUILTIN_ISSUE_FIELD_NAMES } = require("./allowed_issue_fields.cjs");
const { resolveSafeOutputIssueTarget } = require("./temporary_id.cjs");
const { hasIssueIntentsRuntimeFeature, normalizeIssueIntentMetadata } = require("./issue_intents.cjs");
const { normalizeIssueIntentMetadata } = require("./issue_intents.cjs");

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

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

await setIssueFieldValue(githubClient, issueNodeId, fieldUpdate, useIntentHeader);
await setIssueFieldValue(githubClient, issueNodeId, fieldUpdate, true);

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

Expand Down
Loading
Loading