diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml index c291a5f6559..1b30a868f80 100644 --- a/.github/workflows/smoke-codex.lock.yml +++ b/.github/workflows/smoke-codex.lock.yml @@ -965,14 +965,23 @@ jobs: "field_name": { "type": "string", "sanitize": true, - "maxLength": 128 + "maxLength": 128, + "x-synonyms": [ + "fieldName" + ] }, "field_node_id": { "type": "string", - "maxLength": 256 + "maxLength": 256, + "x-synonyms": [ + "fieldNodeId" + ] }, "issue_number": { - "issueOrPRNumber": true + "issueOrPRNumber": true, + "x-synonyms": [ + "issueNumber" + ] }, "rationale": { "type": "string", diff --git a/.github/workflows/smoke-copilot-aoai-apikey.lock.yml b/.github/workflows/smoke-copilot-aoai-apikey.lock.yml index 4a7f8d12aa8..731f14c65f9 100644 --- a/.github/workflows/smoke-copilot-aoai-apikey.lock.yml +++ b/.github/workflows/smoke-copilot-aoai-apikey.lock.yml @@ -1158,13 +1158,19 @@ jobs: ] }, "issue_number": { - "issueOrPRNumber": true + "issueOrPRNumber": true, + "x-synonyms": [ + "issueNumber" + ] }, "issue_type": { "required": true, "type": "string", "sanitize": true, - "maxLength": 128 + "maxLength": 128, + "x-synonyms": [ + "issueType" + ] }, "rationale": { "type": "string", diff --git a/.github/workflows/smoke-copilot-aoai-entra.lock.yml b/.github/workflows/smoke-copilot-aoai-entra.lock.yml index 765749a467f..5e81d0f5f45 100644 --- a/.github/workflows/smoke-copilot-aoai-entra.lock.yml +++ b/.github/workflows/smoke-copilot-aoai-entra.lock.yml @@ -1159,13 +1159,19 @@ jobs: ] }, "issue_number": { - "issueOrPRNumber": true + "issueOrPRNumber": true, + "x-synonyms": [ + "issueNumber" + ] }, "issue_type": { "required": true, "type": "string", "sanitize": true, - "maxLength": 128 + "maxLength": 128, + "x-synonyms": [ + "issueType" + ] }, "rationale": { "type": "string", diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index 7dc5288fb07..d13db309384 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -1174,13 +1174,19 @@ jobs: ] }, "issue_number": { - "issueOrPRNumber": true + "issueOrPRNumber": true, + "x-synonyms": [ + "issueNumber" + ] }, "issue_type": { "required": true, "type": "string", "sanitize": true, - "maxLength": 128 + "maxLength": 128, + "x-synonyms": [ + "issueType" + ] }, "rationale": { "type": "string", diff --git a/actions/setup/js/collect_ndjson_output.test.cjs b/actions/setup/js/collect_ndjson_output.test.cjs index a774b468b83..84bf3d00f40 100644 --- a/actions/setup/js/collect_ndjson_output.test.cjs +++ b/actions/setup/js/collect_ndjson_output.test.cjs @@ -74,6 +74,29 @@ describe("collect_ndjson_output.cjs", () => { customValidation: "requiresOneOf:status,title,body", fields: { status: { type: "string", enum: ["open", "closed"] }, title: { type: "string", sanitize: !0, maxLength: 128 }, body: { type: "string", sanitize: !0, maxLength: 65e3 }, issue_number: { issueOrPRNumber: !0 } }, }, + set_issue_type: { + defaultMax: 5, + fields: { + issue_number: { issueOrPRNumber: !0, "x-synonyms": ["issueNumber"] }, + issue_type: { required: !0, type: "string", sanitize: !0, maxLength: 128, "x-synonyms": ["issueType"] }, + rationale: { type: "string", sanitize: !0, maxLength: 280 }, + confidence: { type: "string", enum: ["LOW", "MEDIUM", "HIGH"] }, + suggest: { type: "boolean" }, + }, + }, + set_issue_field: { + defaultMax: 5, + customValidation: "requiresOneOf:field_name,field_node_id", + fields: { + issue_number: { issueOrPRNumber: !0, "x-synonyms": ["issueNumber"] }, + field_name: { type: "string", sanitize: !0, maxLength: 128, "x-synonyms": ["fieldName"] }, + field_node_id: { type: "string", maxLength: 256, "x-synonyms": ["fieldNodeId"] }, + value: { required: !0, type: "string", sanitize: !0, maxLength: 256 }, + rationale: { type: "string", sanitize: !0, maxLength: 280 }, + confidence: { type: "string", enum: ["LOW", "MEDIUM", "HIGH"] }, + suggest: { type: "boolean" }, + }, + }, create_pull_request_review_comment: { defaultMax: 1, customValidation: "startLineLessOrEqualLine", @@ -195,6 +218,41 @@ describe("collect_ndjson_output.cjs", () => { const parsedOutput = JSON.parse(outputCall[1]); (expect(parsedOutput.items).toHaveLength(2), expect(parsedOutput.items[0].type).toBe("create_issue"), expect(parsedOutput.items[1].type).toBe("add_comment"), expect(parsedOutput.errors).toHaveLength(0)); }), + it("should strip invalid optional issue intent fields instead of dropping valid items", async () => { + const testFile = "/tmp/gh-aw/test-ndjson-output.txt", + ndjsonContent = + '{"type": "set_issue_type", "issue_type": "Bug", "confidence": 0.9, "rationale": 17, "suggest": true}\n{"type": "set_issue_field", "field_name": "Priority", "value": "P1", "confidence": "0.95", "rationale": {"why": "bad"}}'; + (fs.writeFileSync(testFile, ndjsonContent), (process.env.GH_AW_SAFE_OUTPUTS = testFile)); + const __config = '{"set_issue_type": true, "set_issue_field": true}', + configPath = "/tmp/gh-aw/safeoutputs/config.json"; + (fs.writeFileSync(configPath, __config), await eval(`(async () => { ${collectScript}; await main(); })()`)); + const setOutputCalls = mockCore.setOutput.mock.calls, + outputCall = setOutputCalls.find(call => "output" === call[0]); + expect(outputCall).toBeDefined(); + const parsedOutput = JSON.parse(outputCall[1]); + expect(parsedOutput.errors).toHaveLength(0); + expect(parsedOutput.items).toEqual([ + { type: "set_issue_type", issue_type: "Bug", suggest: !0 }, + { type: "set_issue_field", field_name: "Priority", value: "P1" }, + ]); + }), + it("should map issue-intent synonym fields and parse confidence case-insensitively", async () => { + const testFile = "/tmp/gh-aw/test-ndjson-output.txt", + ndjsonContent = '{"type":"set_issue_type","issueType":"Bug","confidence":" high "}\n{"type":"set_issue_field","FieldName":"Priority","value":"P1","confidence":" medium "}'; + (fs.writeFileSync(testFile, ndjsonContent), (process.env.GH_AW_SAFE_OUTPUTS = testFile)); + const __config = '{"set_issue_type": true, "set_issue_field": true}', + configPath = "/tmp/gh-aw/safeoutputs/config.json"; + (fs.writeFileSync(configPath, __config), await eval(`(async () => { ${collectScript}; await main(); })()`)); + const setOutputCalls = mockCore.setOutput.mock.calls, + outputCall = setOutputCalls.find(call => "output" === call[0]); + expect(outputCall).toBeDefined(); + const parsedOutput = JSON.parse(outputCall[1]); + expect(parsedOutput.errors).toHaveLength(0); + expect(parsedOutput.items).toEqual([ + { type: "set_issue_type", issue_type: "Bug", confidence: "HIGH" }, + { type: "set_issue_field", field_name: "Priority", value: "P1", confidence: "MEDIUM" }, + ]); + }), it("should reject items with unexpected output types", async () => { const testFile = "/tmp/gh-aw/test-ndjson-output.txt", ndjsonContent = '{"type": "create_issue", "title": "Test Issue", "body": "Test body"}\n{"type": "unexpected-type", "data": "some data"}'; diff --git a/actions/setup/js/generate_safe_outputs_tools.cjs b/actions/setup/js/generate_safe_outputs_tools.cjs index 9c90f7f2c4d..00e49f933a2 100644 --- a/actions/setup/js/generate_safe_outputs_tools.cjs +++ b/actions/setup/js/generate_safe_outputs_tools.cjs @@ -135,7 +135,7 @@ async function main() { enhancedTool.description = (enhancedTool.description || "") + descSuffix; } if (hasRuntimeFeature(runtimeFeatures, "issue_intents") && ["set_issue_type", "set_issue_field", "add_labels"].includes(tool.name)) { - enhancedTool.description = `${enhancedTool.description || ""} INTENT: Include rationale (max 280 chars) and confidence (LOW/MEDIUM/HIGH) with each call.`.trim(); + enhancedTool.description = `${enhancedTool.description || ""} INTENT: Include rationale (string, max 280 chars) and confidence (string, exactly one of: LOW, MEDIUM, HIGH) with each call.`.trim(); } if (tool.name === "add_comment") { diff --git a/actions/setup/js/generate_safe_outputs_tools.test.cjs b/actions/setup/js/generate_safe_outputs_tools.test.cjs index dcc59e97d1c..0f0417f2ea1 100644 --- a/actions/setup/js/generate_safe_outputs_tools.test.cjs +++ b/actions/setup/js/generate_safe_outputs_tools.test.cjs @@ -323,7 +323,7 @@ describe("generate_safe_outputs_tools", () => { runScript({ GH_AW_RUNTIME_FEATURES: "other\nissue_intents\nanother=true" }); const result = JSON.parse(fs.readFileSync(outputPath, "utf8")); - const intentSuffix = "INTENT: Include rationale (max 280 chars) and confidence (LOW/MEDIUM/HIGH) with each call."; + 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).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); @@ -345,7 +345,7 @@ describe("generate_safe_outputs_tools", () => { runScript({ GH_AW_RUNTIME_FEATURES: "other\nanother=true" }); const result = JSON.parse(fs.readFileSync(outputPath, "utf8")); - const intentSuffix = "INTENT: Include rationale (max 280 chars) and confidence (LOW/MEDIUM/HIGH) with each call."; + 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); diff --git a/actions/setup/js/safe_output_type_validator.cjs b/actions/setup/js/safe_output_type_validator.cjs index d743ef1cd4d..85fcf13ddb7 100644 --- a/actions/setup/js/safe_output_type_validator.cjs +++ b/actions/setup/js/safe_output_type_validator.cjs @@ -40,6 +40,8 @@ const ISSUE_CLOSING_KEYWORD_BACKTICK_PATTERN = new RegExp(`\`(\\b(?:${ISSUE_CLOS const ISSUE_CLOSING_REFERENCE_BACKTICK_PATTERN = new RegExp(`(\\b(?:${ISSUE_CLOSING_KEYWORDS})\\b)(\\s+)\`(${ISSUE_REFERENCE_PATTERN})\``, "gi"); const NORMALIZE_CLOSER_BODY_TYPES = new Set(["create_issue", "add_comment", "create_pull_request"]); const ISSUE_INTENT_LABEL_TYPES = new Set(["add_labels", "remove_labels", "update_issue"]); +const ISSUE_INTENT_TOP_LEVEL_TYPES = new Set(["set_issue_type", "set_issue_field"]); +const LENIENT_ISSUE_INTENT_FIELDS = new Set(["rationale", "confidence"]); /** * Remove markdown backticks around recognized issue-closing keyword references. @@ -84,6 +86,18 @@ function normalizeIssueIntentRationale(rationale, options) { return sanitizedRationale.length > ISSUE_INTENT_RATIONALE_MAX_LENGTH ? sanitizedRationale.slice(0, ISSUE_INTENT_RATIONALE_MAX_LENGTH) : sanitizedRationale; } +/** + * Optional issue-intent enrichment fields should be stripped rather than causing + * the entire safe-output item to be rejected when they are malformed. + * @param {string} fieldName + * @param {FieldValidation} validation + * @param {string} itemType + * @returns {boolean} + */ +function shouldStripInvalidIssueIntentField(fieldName, validation, itemType) { + return !validation.required && LENIENT_ISSUE_INTENT_FIELDS.has(fieldName) && ISSUE_INTENT_TOP_LEVEL_TYPES.has(itemType); +} + /** * Validate and normalize issue-intent-aware label arrays. * @param {any[]} value @@ -145,13 +159,7 @@ function validateIssueIntentLabels(value, lineNum, itemType, fieldName, options) /** @type {{ name: string, rationale?: string, confidence?: "LOW"|"MEDIUM"|"HIGH", suggest?: boolean }} */ const normalizedLabel = { name }; - if (label.rationale !== undefined) { - if (typeof label.rationale !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${itemType} ${fieldName}[${i}].rationale must be a string`, - }; - } + if (typeof label.rationale === "string") { const rationale = normalizeIssueIntentRationale(label.rationale, options); if (rationale) { normalizedLabel.rationale = rationale; @@ -159,26 +167,27 @@ function validateIssueIntentLabels(value, lineNum, itemType, fieldName, options) } if (label.confidence !== undefined) { if (label.confidence !== null && label.confidence !== "") { - const confidenceRaw = String(label.confidence).trim().toUpperCase(); - /** @type {"LOW"|"MEDIUM"|"HIGH"} */ - let confidence; - switch (confidenceRaw) { - case "LOW": - confidence = "LOW"; - break; - case "MEDIUM": - confidence = "MEDIUM"; - break; - case "HIGH": - confidence = "HIGH"; - break; - default: - return { - isValid: false, - error: `Line ${lineNum}: ${itemType} ${fieldName}[${i}].confidence must be one of: LOW, MEDIUM, HIGH`, - }; + if (typeof label.confidence === "string") { + const confidenceRaw = label.confidence.trim().toUpperCase(); + /** @type {"LOW"|"MEDIUM"|"HIGH"|undefined} */ + let confidence; + switch (confidenceRaw) { + case "LOW": + confidence = "LOW"; + break; + case "MEDIUM": + confidence = "MEDIUM"; + break; + case "HIGH": + confidence = "HIGH"; + break; + default: + confidence = undefined; + } + if (confidence) { + normalizedLabel.confidence = confidence; + } } - normalizedLabel.confidence = confidence; } } if (label.suggest !== undefined) { @@ -215,6 +224,7 @@ function validateIssueIntentLabels(value, lineNum, itemType, fieldName, options) * @property {number} [itemMaxLength] - For arrays, max length per item * @property {string} [pattern] - Regex pattern the value must match * @property {string} [patternError] - Error message for pattern mismatch + * @property {string[]} [x-synonyms] - Alternate field names accepted for this field */ /** @@ -417,7 +427,7 @@ function validateIssueNumberOrTemporaryId(value, fieldName, lineNum) { * @param {string} itemType - The item type for error messages * @param {number} lineNum - Line number for error messages * @param {ValidateOptions} [options] - Optional sanitization options - * @returns {{isValid: boolean, normalizedValue?: any, error?: string}} + * @returns {{isValid: boolean, normalizedValue?: any, error?: string, removeField?: boolean}} */ function validateField(value, fieldName, validation, itemType, lineNum, options) { // For positiveInteger fields, delegate required check to validatePositiveInteger @@ -461,6 +471,9 @@ function validateField(value, fieldName, validation, itemType, lineNum, options) // Handle type validation if (validation.type === "string") { if (typeof value !== "string") { + if (shouldStripInvalidIssueIntentField(fieldName, validation, itemType)) { + return { isValid: true, removeField: true }; + } // For required fields, use "requires a" format for both missing and wrong type if (validation.required) { const fieldType = validation.typeHint || "string"; @@ -489,9 +502,12 @@ function validateField(value, fieldName, validation, itemType, lineNum, options) // Handle enum validation if (validation.enum) { - const normalizedValue = value.toLowerCase ? value.toLowerCase() : value; + const normalizedValue = value.trim().toLowerCase(); const normalizedEnum = validation.enum.map(e => (e.toLowerCase ? e.toLowerCase() : e)); if (!normalizedEnum.includes(normalizedValue)) { + if (shouldStripInvalidIssueIntentField(fieldName, validation, itemType)) { + return { isValid: true, removeField: true }; + } // Use special format for 2-option enums: "'field' must be 'A' or 'B'" // Use standard format for more options: "'field' must be one of: A, B, C" let errorMsg; @@ -521,7 +537,9 @@ function validateField(value, fieldName, validation, itemType, lineNum, options) // Handle sanitization let finalValue = value; - if (validation.sanitize) { + if (fieldName === "rationale" && shouldStripInvalidIssueIntentField(fieldName, validation, itemType)) { + finalValue = normalizeIssueIntentRationale(value, options); + } else if (validation.sanitize) { // Apply unfencing to remove accidental outer markdown fences before sanitization finalValue = sanitizeContent(unfenceMarkdown(value), { maxLength: validation.maxLength || MAX_BODY_LENGTH, @@ -624,6 +642,85 @@ function validateField(value, fieldName, validation, itemType, lineNum, options) return { isValid: true, normalizedValue: value }; } +/** + * Normalize a field key for case-insensitive and separator-insensitive matching. + * @param {string} key + * @returns {string} + */ +function normalizeFieldKey(key) { + return String(key) + .trim() + .toLowerCase() + .replace(/[\s-]+/g, "_"); +} + +const FIELD_SYNONYMS_KEY = "x-synonyms"; + +/** + * Return configured alternate field names for a validation field. + * x-synonyms is the single extension key for field-name aliases. + * + * @param {FieldValidation|undefined} validation + * @returns {string[]} + */ +function getFieldSynonyms(validation) { + const rawSynonyms = validation?.[FIELD_SYNONYMS_KEY]; + if (!Array.isArray(rawSynonyms)) { + return []; + } + + const seen = new Set(); + const synonyms = []; + for (const synonym of rawSynonyms) { + if (typeof synonym !== "string") { + continue; + } + const trimmed = synonym.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + synonyms.push(trimmed); + } + + return synonyms; +} + +/** + * Canonicalize item field names using configured x-synonyms. + * Preserves canonical fields when both canonical and synonym forms are present. + * @param {Object} item + * @param {Object.} fieldsConfig + * @returns {Object} + */ +function normalizeItemFieldNames(item, fieldsConfig) { + const canonicalByNormalized = new Map(); + for (const [fieldName, validation] of Object.entries(fieldsConfig)) { + canonicalByNormalized.set(normalizeFieldKey(fieldName), fieldName); + const synonyms = getFieldSynonyms(validation); + for (const synonym of synonyms) { + const normalizedSynonym = normalizeFieldKey(synonym); + if (!canonicalByNormalized.has(normalizedSynonym)) { + canonicalByNormalized.set(normalizedSynonym, fieldName); + } + } + } + + const normalizedItem = { ...item }; + for (const [providedName, value] of Object.entries(item)) { + const mappedName = canonicalByNormalized.get(normalizeFieldKey(providedName)); + if (!mappedName || mappedName === providedName) { + continue; + } + if (!Object.prototype.hasOwnProperty.call(normalizedItem, mappedName)) { + normalizedItem[mappedName] = value; + } + delete normalizedItem[providedName]; + } + + return normalizedItem; +} + /** * Execute custom validation rules * @param {Object} item - The item to validate @@ -693,12 +790,16 @@ function validateItem(item, itemType, lineNum, options) { return { isValid: true, normalizedItem: item }; } - const normalizedItem = { ...item }; + const validationItem = normalizeItemFieldNames(item, typeConfig.fields); + const normalizedItem = { ...validationItem }; + for (const fieldName of Object.keys(typeConfig.fields)) { + delete normalizedItem[fieldName]; + } const errors = []; // Run custom validation first if defined if (typeConfig.customValidation) { - const customResult = executeCustomValidation(item, typeConfig.customValidation, lineNum, itemType); + const customResult = executeCustomValidation(validationItem, typeConfig.customValidation, lineNum, itemType); if (customResult && !customResult.isValid) { return customResult; } @@ -706,13 +807,17 @@ function validateItem(item, itemType, lineNum, options) { // Validate each configured field for (const [fieldName, validation] of Object.entries(typeConfig.fields)) { - const fieldValue = item[fieldName]; + const fieldValue = validationItem[fieldName]; const result = validateField(fieldValue, fieldName, validation, itemType, lineNum, options); if (!result.isValid) { errors.push(result.error); + } else if (result.removeField) { + delete normalizedItem[fieldName]; } else if (result.normalizedValue !== undefined) { normalizedItem[fieldName] = result.normalizedValue; + } else if (Object.prototype.hasOwnProperty.call(validationItem, fieldName)) { + normalizedItem[fieldName] = fieldValue; } } diff --git a/actions/setup/js/safe_output_type_validator.test.cjs b/actions/setup/js/safe_output_type_validator.test.cjs index 5ee9e59c01d..662130006a5 100644 --- a/actions/setup/js/safe_output_type_validator.test.cjs +++ b/actions/setup/js/safe_output_type_validator.test.cjs @@ -114,8 +114,8 @@ const SAMPLE_VALIDATION_CONFIG = { set_issue_type: { defaultMax: 5, fields: { - issue_number: { issueOrPRNumber: true }, - issue_type: { required: true, type: "string", sanitize: true, maxLength: 128 }, + issue_number: { issueOrPRNumber: true, "x-synonyms": ["issueNumber"] }, + issue_type: { required: true, type: "string", sanitize: true, maxLength: 128, "x-synonyms": ["issueType"] }, rationale: { type: "string", sanitize: true, maxLength: 280 }, confidence: { type: "string", enum: ["LOW", "MEDIUM", "HIGH"] }, suggest: { type: "boolean" }, @@ -125,9 +125,9 @@ const SAMPLE_VALIDATION_CONFIG = { defaultMax: 5, customValidation: "requiresOneOf:field_name,field_node_id", fields: { - issue_number: { issueOrPRNumber: true }, - field_name: { type: "string", sanitize: true, maxLength: 128 }, - field_node_id: { type: "string", maxLength: 256 }, + issue_number: { issueOrPRNumber: true, "x-synonyms": ["issueNumber"] }, + field_name: { type: "string", sanitize: true, maxLength: 128, "x-synonyms": ["fieldName"] }, + field_node_id: { type: "string", maxLength: 256, "x-synonyms": ["fieldNodeId"] }, value: { required: true, type: "string", sanitize: true, maxLength: 256 }, rationale: { type: "string", sanitize: true, maxLength: 280 }, confidence: { type: "string", enum: ["LOW", "MEDIUM", "HIGH"] }, @@ -344,6 +344,40 @@ describe("safe_output_type_validator", () => { } }); + it("should strip non-string rationale from structured labels", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem( + { + type: "add_labels", + item_number: 123, + labels: [{ name: "bug", rationale: 42, confidence: "HIGH", suggest: true }], + }, + "add_labels", + 1 + ); + + expect(result.isValid).toBe(true); + expect(result.normalizedItem.labels).toEqual([{ name: "bug", confidence: "HIGH", suggest: true }]); + }); + + it("should strip non-enum confidence from structured labels", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem( + { + type: "add_labels", + item_number: 123, + labels: [{ name: "bug", rationale: "triage context", confidence: 0.95, suggest: true }], + }, + "add_labels", + 1 + ); + + expect(result.isValid).toBe(true); + expect(result.normalizedItem.labels).toEqual([{ name: "bug", rationale: "triage context", suggest: true }]); + }); + it("should fail add_labels when structured label entry is invalid", async () => { const { validateItem } = await import("./safe_output_type_validator.cjs"); @@ -948,6 +982,38 @@ describe("safe_output_type_validator", () => { const fieldResult = validateItem({ type: "set_issue_field", field_name: "Priority", value: "P1", confidence: "medium", rationale: "Customer escalation" }, "set_issue_field", 1); expect(fieldResult.isValid).toBe(true); expect(fieldResult.normalizedItem.confidence).toBe("MEDIUM"); + + const spacedResult = validateItem({ type: "set_issue_type", issue_type: "Bug", confidence: " medium " }, "set_issue_type", 1); + expect(spacedResult.isValid).toBe(true); + expect(spacedResult.normalizedItem.confidence).toBe("MEDIUM"); + }); + + it("should normalize issue-intent synonyms and parse values case-insensitively", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const typeResult = validateItem({ type: "set_issue_type", issueType: "Bug", confidence: " high " }, "set_issue_type", 1); + expect(typeResult.isValid).toBe(true); + expect(typeResult.normalizedItem).toEqual({ type: "set_issue_type", issue_type: "Bug", confidence: "HIGH" }); + + const fieldResult = validateItem({ type: "set_issue_field", FieldName: "Priority", value: "P1", confidence: " medium " }, "set_issue_field", 1); + expect(fieldResult.isValid).toBe(true); + expect(fieldResult.normalizedItem).toEqual({ type: "set_issue_field", field_name: "Priority", value: "P1", confidence: "MEDIUM" }); + }); + + it("should strip invalid optional issue-intent fields instead of rejecting the item", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const typeResult = validateItem({ type: "set_issue_type", issue_type: "Bug", confidence: 0.9, rationale: 17, suggest: true }, "set_issue_type", 1); + expect(typeResult.isValid).toBe(true); + expect(typeResult.normalizedItem).toEqual({ type: "set_issue_type", issue_type: "Bug", suggest: true }); + + const fieldResult = validateItem({ type: "set_issue_field", field_name: "Priority", value: "P1", confidence: "0.95", rationale: { why: "bad" } }, "set_issue_field", 1); + expect(fieldResult.isValid).toBe(true); + expect(fieldResult.normalizedItem).toEqual({ type: "set_issue_field", field_name: "Priority", value: "P1" }); + + const longRationaleResult = validateItem({ type: "set_issue_type", issue_type: "Bug", rationale: "a".repeat(350) }, "set_issue_type", 1); + expect(longRationaleResult.isValid).toBe(true); + expect(longRationaleResult.normalizedItem.rationale).toHaveLength(280); }); }); diff --git a/pkg/workflow/safe_outputs_validation_config.go b/pkg/workflow/safe_outputs_validation_config.go index 9257e5f1409..ef46e5f9efc 100644 --- a/pkg/workflow/safe_outputs_validation_config.go +++ b/pkg/workflow/safe_outputs_validation_config.go @@ -31,6 +31,7 @@ type FieldValidation struct { Pattern string `json:"pattern,omitempty"` PatternError string `json:"patternError,omitempty"` TemporaryID bool `json:"temporaryId,omitempty"` + XSynonyms []string `json:"x-synonyms,omitempty"` } // TypeValidationConfig defines validation configuration for a safe output type @@ -133,8 +134,8 @@ var ValidationConfig = map[string]TypeValidationConfig{ "set_issue_type": { DefaultMax: 5, Fields: map[string]FieldValidation{ - "issue_number": {IssueOrPRNumber: true}, - "issue_type": {Required: true, Type: "string", Sanitize: true, MaxLength: 128}, // Empty string clears the type + "issue_number": {IssueOrPRNumber: true, XSynonyms: []string{"issueNumber"}}, + "issue_type": {Required: true, Type: "string", Sanitize: true, MaxLength: 128, XSynonyms: []string{"issueType"}}, // Empty string clears the type "rationale": {Type: "string", Sanitize: true, MaxLength: 280}, "confidence": {Type: "string", Enum: []string{"LOW", "MEDIUM", "HIGH"}}, "suggest": {Type: "boolean"}, @@ -145,9 +146,9 @@ var ValidationConfig = map[string]TypeValidationConfig{ DefaultMax: 5, CustomValidation: "requiresOneOf:field_name,field_node_id", Fields: map[string]FieldValidation{ - "issue_number": {IssueOrPRNumber: true}, - "field_name": {Type: "string", Sanitize: true, MaxLength: 128}, - "field_node_id": {Type: "string", MaxLength: 256}, + "issue_number": {IssueOrPRNumber: true, XSynonyms: []string{"issueNumber"}}, + "field_name": {Type: "string", Sanitize: true, MaxLength: 128, XSynonyms: []string{"fieldName"}}, + "field_node_id": {Type: "string", MaxLength: 256, XSynonyms: []string{"fieldNodeId"}}, "value": {Required: true, Type: "string", Sanitize: true, MaxLength: 256}, "rationale": {Type: "string", Sanitize: true, MaxLength: 280}, "confidence": {Type: "string", Enum: []string{"LOW", "MEDIUM", "HIGH"}},