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: 20 additions & 1 deletion actions/setup/js/add_labels.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ const main = createCountGatedHandler({
handlerType: HANDLER_TYPE,
setup: async (config, maxCount, isStaged) => {
const { allowed: allowedLabels = [], blocked: blockedPatterns = [] } = config;
const issueIntentEnabled = config.issue_intent === true;
const issueIntentEnabled = config.issue_intent !== false;
const issueIntentStrict = config.issue_intent === true; // strict mode: plain-string labels rejected, metadata required
const requiredLabels = Array.isArray(config.required_labels) ? config.required_labels : [];
const requiredTitlePrefix = config.required_title_prefix || "";
const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
Expand Down Expand Up @@ -98,6 +99,24 @@ const main = createCountGatedHandler({
let requestedLabelNames;
try {
const requestedLabelInputs = normalizeIssueIntentLabelInputs(requestedLabels);

// In strict mode (issue_intent: true), reject plain string labels — metadata is required
if (issueIntentStrict) {
const plainStringLabels = requestedLabelInputs.filter(label => typeof label === "string");
if (plainStringLabels.length > 0) {
const error = `Plain string label names are not permitted when issue_intent is explicitly enabled. Provide label objects with a "name" field and intent metadata (rationale, confidence). Plain labels: ${plainStringLabels.map(l => JSON.stringify(l)).join(", ")}`;
Comment on lines +104 to +107
core.warning(error);
return { success: false, error };
}
// In strict mode, objects without rationale and confidence are also rejected
const missingMetadataLabels = requestedLabelInputs.filter(label => typeof label === "object" && (!label.rationale || !label.confidence));
if (missingMetadataLabels.length > 0) {
const error = `Label objects must include both "rationale" and "confidence" when issue_intent is explicitly enabled. Missing metadata on: ${missingMetadataLabels.map(l => JSON.stringify(l.name)).join(", ")}`;
core.warning(error);
return { success: false, error };
}
}

requestedLabelNames = requestedLabelInputs.map(label => {
if (typeof label === "string") {
return label;
Expand Down
115 changes: 114 additions & 1 deletion actions/setup/js/add_labels.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,8 @@ describe("add_labels", () => {
});

it("should prefer the metadata-bearing entry when a duplicate label name appears", async () => {
const handler = await main({ max: 10, issue_intent: true });
// Default (omitted issue_intent) accepts both strings and objects; deduplication favours the metadata-bearing entry.
const handler = await main({ max: 10 });
const addLabelsCalls = [];

mockGithub.rest.issues.addLabels = async params => {
Expand Down Expand Up @@ -559,6 +560,118 @@ describe("add_labels", () => {
expect(addLabelsCalls[0].labels).toEqual(["bug"]);
});

it("should forward per-label intent metadata by default when issue_intent is omitted", 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: "Known crash path", confidence: "HIGH", suggest: true }],
},
{}
);

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

it("should accept plain string labels by default when issue_intent is omitted", 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: ["bug", "enhancement"],
},
{}
);

expect(result.success).toBe(true);
expect(addLabelsCalls).toHaveLength(1);
expect(addLabelsCalls[0].labels).toEqual(["bug", "enhancement"]);
});

it("should reject plain string labels when issue_intent is explicitly true (strict mode)", async () => {
const handler = await main({ max: 10, issue_intent: true });

const result = await handler(
{
item_number: 456,
labels: ["bug", "enhancement"],
},
{}
);

expect(result.success).toBe(false);
expect(result.error).toContain("Plain string label names are not permitted when issue_intent is explicitly enabled");
expect(result.error).toContain('"bug"');
});

it("should reject label objects missing rationale or confidence in strict mode", async () => {
const handler = await main({ max: 10, issue_intent: true });

const result = await handler(
{
item_number: 456,
labels: [{ name: "bug" }],
},
{}
);

expect(result.success).toBe(false);
expect(result.error).toContain('both "rationale" and "confidence"');
expect(result.error).toContain('"bug"');
});

it("should reject label object missing confidence in strict mode even when rationale is present", async () => {
const handler = await main({ max: 10, issue_intent: true });

const result = await handler(
{
item_number: 456,
labels: [{ name: "bug", rationale: "Crash on upload" }],
},
{}
);

expect(result.success).toBe(false);
expect(result.error).toContain('both "rationale" and "confidence"');
});

it("should accept label objects with both rationale and confidence in strict mode", async () => {
const handler = await main({ max: 10, issue_intent: true });
const addLabelsCalls = [];

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

const result = await handler(
{
item_number: 456,
labels: [{ name: "bug", rationale: "Crash on upload", confidence: "HIGH" }],
},
{}
);

expect(result.success).toBe(true);
expect(addLabelsCalls).toHaveLength(1);
});

it("should sanitize and trim label names", async () => {
const handler = await main({ max: 10 });
const addLabelsCalls = [];
Expand Down
15 changes: 15 additions & 0 deletions actions/setup/js/generate_safe_outputs_tools.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,21 @@ async function main() {
}
if (isIssueIntentEnabledForTool(tool.name, config[tool.name])) {
enhancedTool.description = `${enhancedTool.description || ""} ${ISSUE_INTENT_SUFFIX}`.trim();
// For add_labels strict mode, replace the labels items schema with an object-only
// variant that requires name, rationale, and confidence.
if (tool.name === "add_labels") {
const labelsSchema = enhancedTool.inputSchema?.properties?.labels;
if (labelsSchema && labelsSchema.items && Array.isArray(labelsSchema.items.oneOf)) {
const objectSchema = labelsSchema.items.oneOf.find(/** @param {{type: string}} s */ s => s.type === "object");
if (objectSchema) {
labelsSchema.items = {
...objectSchema,
required: ["name", "rationale", "confidence"],
};
delete labelsSchema.items.oneOf;
}
}
}
}
if (isIssueIntentDisabledForTool(tool.name, config[tool.name])) {
stripIssueIntentSchemaFields(enhancedTool);
Expand Down
84 changes: 84 additions & 0 deletions actions/setup/js/generate_safe_outputs_tools.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,90 @@ describe("generate_safe_outputs_tools", () => {
expect(result.find((/** @type {{name: string, description: string}} */ t) => t.name === "assign_to_user").description).not.toContain(intentSuffix);
});

it("makes add_labels label items object-only with required name/rationale/confidence when issue_intent is true", () => {
const addLabelsSourceTool = {
name: "add_labels",
description: "Adds labels.",
inputSchema: {
type: "object",
properties: {
labels: {
type: "array",
items: {
oneOf: [
{ type: "string" },
{
type: "object",
required: ["name"],
properties: {
name: { type: "string" },
rationale: { type: "string" },
confidence: { type: "string" },
suggest: { type: "boolean" },
},
additionalProperties: false,
},
],
},
},
},
required: ["labels"],
},
};
fs.writeFileSync(toolsSourcePath, JSON.stringify([addLabelsSourceTool]));
fs.writeFileSync(configPath, JSON.stringify({ add_labels: { issue_intent: true } }));
fs.writeFileSync(toolsMetaPath, JSON.stringify({ description_suffixes: {}, repo_params: {}, dynamic_tools: [] }));

runScript();

const result = JSON.parse(fs.readFileSync(outputPath, "utf8"));
const tool = result.find((/** @type {{name: string}} */ t) => t.name === "add_labels");
expect(tool).toBeDefined();
const items = tool.inputSchema.properties.labels.items;
// No longer a oneOf — plain strings are removed
expect(items.oneOf).toBeUndefined();
expect(items.type).toBe("object");
// name, rationale, and confidence are all required
expect(items.required).toEqual(expect.arrayContaining(["name", "rationale", "confidence"]));
});

it("does not modify add_labels label items schema when issue_intent is omitted or false", () => {
const addLabelsSourceTool = {
name: "add_labels",
description: "Adds labels.",
inputSchema: {
type: "object",
properties: {
labels: {
type: "array",
items: {
oneOf: [{ type: "string" }, { type: "object", required: ["name"], properties: { name: { type: "string" } } }],
},
},
},
required: ["labels"],
},
};
fs.writeFileSync(toolsSourcePath, JSON.stringify([addLabelsSourceTool]));
// Test omitted (default)
fs.writeFileSync(configPath, JSON.stringify({ add_labels: {} }));
fs.writeFileSync(toolsMetaPath, JSON.stringify({ description_suffixes: {}, repo_params: {}, dynamic_tools: [] }));

runScript();

const defaultResult = JSON.parse(fs.readFileSync(outputPath, "utf8"));
const defaultTool = defaultResult.find((/** @type {{name: string}} */ t) => t.name === "add_labels");
expect(defaultTool.inputSchema.properties.labels.items.oneOf).toBeDefined();

// Test explicit false
fs.writeFileSync(configPath, JSON.stringify({ add_labels: { issue_intent: false } }));
runScript();

const disabledResult = JSON.parse(fs.readFileSync(outputPath, "utf8"));
const disabledTool = disabledResult.find((/** @type {{name: string}} */ t) => t.name === "add_labels");
expect(disabledTool.inputSchema.properties.labels.items.oneOf).toBeDefined();
});

it("reflects required/optional/absent intent fields per tool configuration", () => {
fs.writeFileSync(
toolsSourcePath,
Expand Down
2 changes: 1 addition & 1 deletion actions/setup/js/set_issue_field.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ async function main(config = {}) {
const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
const githubClient = await createAuthenticatedGitHubClient(config);
const isStaged = isStagedMode(config);
const issueIntentEnabled = config.issue_intent === true;
const issueIntentEnabled = config.issue_intent !== false;

core.info(`Set issue field configuration: max=${maxCount}`);
const requiredLabels = Array.isArray(config.required_labels) ? config.required_labels : [];
Expand Down
31 changes: 31 additions & 0 deletions actions/setup/js/set_issue_field.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -554,4 +554,35 @@ describe("set_issue_field (Handler Factory Architecture)", () => {
expect(mutationCall[1].issueFields[0]).not.toHaveProperty("confidence");
expect(mutationCall[1].issueFields[0]).not.toHaveProperty("suggest");
});

it("should include intent metadata by default when issue_intent is omitted", async () => {
const { main } = require("./set_issue_field.cjs");
const defaultHandler = await main({ max: 5 });

const result = await defaultHandler(
{
type: "set_issue_field",
issue_number: 42,
field_name: "Customer Impact",
value: "High",
rationale: "Customer-reported with SLA breach risk",
confidence: "high",
suggest: true,
},
{}
);

expect(result.success).toBe(true);
const mutationCall = mockGraphql.mock.calls.find(([query]) => query.includes("setIssueFieldValue"));
expect(mutationCall).toBeTruthy();
expect(mutationCall[1].issueFields[0]).toEqual(
expect.objectContaining({
fieldId: textFieldId,
textValue: "High",
rationale: "Customer-reported with SLA breach risk",
confidence: "HIGH",
suggest: true,
})
);
});
});
2 changes: 1 addition & 1 deletion actions/setup/js/set_issue_type.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ async function main(config = {}) {
core.info(`Set issue type configuration: max=${maxCount}`);
const requiredLabels = Array.isArray(config.required_labels) ? config.required_labels : [];
const requiredTitlePrefix = config.required_title_prefix || "";
const issueIntentEnabled = config.issue_intent === true;
const issueIntentEnabled = config.issue_intent !== false;
if (requiredLabels.length > 0) core.info(`Required labels (all): ${requiredLabels.join(", ")}`);
if (requiredTitlePrefix) core.info(`Required title prefix: ${requiredTitlePrefix}`);
if (allowedTypes.length > 0) {
Expand Down
54 changes: 54 additions & 0 deletions actions/setup/js/set_issue_type.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -495,4 +495,58 @@ describe("set_issue_type (Handler Factory Architecture)", () => {
});
expect(mockGithub.graphql).not.toHaveBeenCalledWith(expect.stringContaining("updateIssue"), expect.anything());
});

it("should use GraphQL intent path by default when issue_intent is omitted", async () => {
const { main } = require("./set_issue_type.cjs");
const defaultHandler = await main({ max: 5 });

const result = await defaultHandler(
{
type: "set_issue_type",
issue_number: 42,
issue_type: "Bug",
},
{}
);

expect(result.success).toBe(true);
expect(mockGithub.rest.issues.update).not.toHaveBeenCalled();
expect(mockGithub.graphql).toHaveBeenCalledWith(
expect.stringContaining("IssueTypeUpdateInput"),
expect.objectContaining({
issueId: "I_kwDO_testissue",
issueType: expect.objectContaining({ issueTypeId: "IT_kwDO_bug" }),
})
);
});

it("should forward intent metadata by default when issue_intent is omitted", async () => {
const { main } = require("./set_issue_type.cjs");
const defaultHandler = await main({ max: 5 });

const result = await defaultHandler(
{
type: "set_issue_type",
issue_number: 42,
issue_type: "Bug",
rationale: "Author explicitly requests a bug fix",
confidence: "high",
},
{}
);

expect(result.success).toBe(true);
expect(mockGithub.rest.issues.update).not.toHaveBeenCalled();
expect(mockGithub.graphql).toHaveBeenCalledWith(
expect.stringContaining("IssueTypeUpdateInput"),
expect.objectContaining({
issueId: "I_kwDO_testissue",
issueType: {
issueTypeId: "IT_kwDO_bug",
rationale: "Author explicitly requests a bug fix",
confidence: "HIGH",
},
})
);
});
});
Loading