diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml index e17af58f820..b075d7af731 100644 --- a/.github/workflows/smoke-codex.lock.yml +++ b/.github/workflows/smoke-codex.lock.yml @@ -977,7 +977,7 @@ jobs: "rationale": { "type": "string", "sanitize": true, - "maxLength": 1024 + "maxLength": 280 }, "repo": { "type": "string", diff --git a/.github/workflows/smoke-copilot-aoai-apikey.lock.yml b/.github/workflows/smoke-copilot-aoai-apikey.lock.yml index 5eca1511cf7..3e5fdd0c01f 100644 --- a/.github/workflows/smoke-copilot-aoai-apikey.lock.yml +++ b/.github/workflows/smoke-copilot-aoai-apikey.lock.yml @@ -1169,7 +1169,7 @@ jobs: "rationale": { "type": "string", "sanitize": true, - "maxLength": 1024 + "maxLength": 280 }, "repo": { "type": "string", diff --git a/.github/workflows/smoke-copilot-aoai-entra.lock.yml b/.github/workflows/smoke-copilot-aoai-entra.lock.yml index 6e15c6a8a32..fb3e0751df3 100644 --- a/.github/workflows/smoke-copilot-aoai-entra.lock.yml +++ b/.github/workflows/smoke-copilot-aoai-entra.lock.yml @@ -1170,7 +1170,7 @@ jobs: "rationale": { "type": "string", "sanitize": true, - "maxLength": 1024 + "maxLength": 280 }, "repo": { "type": "string", diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index 7f948cd4f86..686118986ed 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -1185,7 +1185,7 @@ jobs: "rationale": { "type": "string", "sanitize": true, - "maxLength": 1024 + "maxLength": 280 }, "repo": { "type": "string", diff --git a/actions/setup/js/issue_intents.cjs b/actions/setup/js/issue_intents.cjs index 73d6865382b..c489539b40e 100644 --- a/actions/setup/js/issue_intents.cjs +++ b/actions/setup/js/issue_intents.cjs @@ -7,7 +7,7 @@ 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 = 1024; +const ISSUE_INTENT_RATIONALE_MAX_LENGTH = 280; function hasIssueIntentsRuntimeFeature() { if (typeof global.hasRuntimeFeature === "function") { @@ -25,7 +25,10 @@ function normalizeIssueIntentMetadata(source) { const metadata = {}; if (typeof source.rationale === "string") { - const rationale = sanitizeContent(source.rationale, { maxLength: ISSUE_INTENT_RATIONALE_MAX_LENGTH }).trim(); + const sanitizedRationale = sanitizeContent(source.rationale, { maxLength: ISSUE_INTENT_RATIONALE_MAX_LENGTH }).trim(); + // sanitizeContent appends "\n[Content truncated due to length]" when it truncates, + // so clamp again to guarantee the GitHub API hard limit. + const rationale = sanitizedRationale.length > ISSUE_INTENT_RATIONALE_MAX_LENGTH ? sanitizedRationale.slice(0, ISSUE_INTENT_RATIONALE_MAX_LENGTH) : sanitizedRationale; if (rationale) { metadata.rationale = rationale; } diff --git a/actions/setup/js/safe_output_type_validator.cjs b/actions/setup/js/safe_output_type_validator.cjs index fc377c08d0d..d743ef1cd4d 100644 --- a/actions/setup/js/safe_output_type_validator.cjs +++ b/actions/setup/js/safe_output_type_validator.cjs @@ -24,6 +24,7 @@ const MAX_BODY_LENGTH = 65000; * Reference: https://github.com/dead-claudia/github-limits */ const MAX_GITHUB_USERNAME_LENGTH = 39; +const ISSUE_INTENT_RATIONALE_MAX_LENGTH = 280; /** * @typedef {{ allowedAliases?: string[], maxBotMentions?: number, normalizeIssueClosingKeywords?: boolean }} ValidateOptions @@ -64,6 +65,25 @@ function normalizeIssueClosingKeywordBackticks(content) { return normalized.replace(ISSUE_CLOSING_REFERENCE_BACKTICK_PATTERN, "$1$2$3"); } +/** + * Normalize issue-intent rationale text while enforcing the GitHub API hard limit. + * sanitizeContent appends a truncation marker when it shortens content, so this + * helper slices again to ensure the final payload never exceeds 280 characters. + * @param {string} rationale + * @param {ValidateOptions} [options] + * @returns {string} + */ +function normalizeIssueIntentRationale(rationale, options) { + const sanitizedRationale = sanitizeContent(unfenceMarkdown(rationale), { + maxLength: ISSUE_INTENT_RATIONALE_MAX_LENGTH, + allowedAliases: options?.allowedAliases || [], + maxBotMentions: options?.maxBotMentions, + }).trim(); + // sanitizeContent appends "\n[Content truncated due to length]" when it truncates, + // so clamp again to guarantee the GitHub API hard limit. + return sanitizedRationale.length > ISSUE_INTENT_RATIONALE_MAX_LENGTH ? sanitizedRationale.slice(0, ISSUE_INTENT_RATIONALE_MAX_LENGTH) : sanitizedRationale; +} + /** * Validate and normalize issue-intent-aware label arrays. * @param {any[]} value @@ -132,11 +152,7 @@ function validateIssueIntentLabels(value, lineNum, itemType, fieldName, options) error: `Line ${lineNum}: ${itemType} ${fieldName}[${i}].rationale must be a string`, }; } - const rationale = sanitizeContent(unfenceMarkdown(label.rationale), { - maxLength: 1024, - allowedAliases: options?.allowedAliases || [], - maxBotMentions: options?.maxBotMentions, - }).trim(); + const rationale = normalizeIssueIntentRationale(label.rationale, options); if (rationale) { normalizedLabel.rationale = rationale; } diff --git a/actions/setup/js/safe_output_type_validator.test.cjs b/actions/setup/js/safe_output_type_validator.test.cjs index 6474570afba..5ee9e59c01d 100644 --- a/actions/setup/js/safe_output_type_validator.test.cjs +++ b/actions/setup/js/safe_output_type_validator.test.cjs @@ -116,7 +116,7 @@ const SAMPLE_VALIDATION_CONFIG = { fields: { issue_number: { issueOrPRNumber: true }, issue_type: { required: true, type: "string", sanitize: true, maxLength: 128 }, - rationale: { type: "string", sanitize: true, maxLength: 1024 }, + rationale: { type: "string", sanitize: true, maxLength: 280 }, confidence: { type: "string", enum: ["LOW", "MEDIUM", "HIGH"] }, suggest: { type: "boolean" }, }, @@ -129,7 +129,7 @@ const SAMPLE_VALIDATION_CONFIG = { field_name: { type: "string", sanitize: true, maxLength: 128 }, field_node_id: { type: "string", maxLength: 256 }, value: { required: true, type: "string", sanitize: true, maxLength: 256 }, - rationale: { type: "string", sanitize: true, maxLength: 1024 }, + rationale: { type: "string", sanitize: true, maxLength: 280 }, confidence: { type: "string", enum: ["LOW", "MEDIUM", "HIGH"] }, suggest: { type: "boolean" }, }, @@ -329,6 +329,21 @@ describe("safe_output_type_validator", () => { expect(result.normalizedItem.labels).toEqual([{ name: "bug", rationale: "Known failure mode", confidence: "HIGH", suggest: true }]); }); + it("should truncate structured label rationale for all issue-intent label mutations", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + for (const [type, item] of [ + ["add_labels", { type: "add_labels", item_number: 123, labels: [{ name: "bug", rationale: "a".repeat(350) }] }], + ["remove_labels", { type: "remove_labels", item_number: 123, labels: [{ name: "bug", rationale: "a".repeat(350) }] }], + ["update_issue", { type: "update_issue", issue_number: 123, labels: [{ name: "bug", rationale: "a".repeat(350) }] }], + ]) { + const result = validateItem(item, type, 1); + + expect(result.isValid).toBe(true); + expect(result.normalizedItem.labels[0].rationale).toBe("a".repeat(280)); + } + }); + it("should fail add_labels when structured label entry is invalid", async () => { const { validateItem } = await import("./safe_output_type_validator.cjs"); diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index 7bf35a642bc..bd789c3fff2 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -602,7 +602,8 @@ }, "rationale": { "type": "string", - "description": "Optional rationale for the change." + "maxLength": 280, + "description": "Optional rationale for the change (max 280 characters)." }, "confidence": { "type": "string", @@ -662,7 +663,8 @@ }, "rationale": { "type": "string", - "description": "Optional rationale for the change." + "maxLength": 280, + "description": "Optional rationale for the change (max 280 characters)." }, "confidence": { "type": "string", @@ -922,7 +924,8 @@ }, "rationale": { "type": "string", - "description": "Optional rationale for the change." + "maxLength": 280, + "description": "Optional rationale for the change (max 280 characters)." }, "confidence": { "type": "string", @@ -1350,7 +1353,8 @@ }, "rationale": { "type": "string", - "description": "Optional rationale for the change." + "maxLength": 280, + "description": "Optional rationale for the change (max 280 characters)." }, "confidence": { "type": "string", @@ -1401,7 +1405,8 @@ }, "rationale": { "type": "string", - "description": "Optional rationale for the change." + "maxLength": 280, + "description": "Optional rationale for the change (max 280 characters)." }, "confidence": { "type": "string", diff --git a/actions/setup/js/set_issue_type.test.cjs b/actions/setup/js/set_issue_type.test.cjs index 7170242a6f1..7dbf07c7fe8 100644 --- a/actions/setup/js/set_issue_type.test.cjs +++ b/actions/setup/js/set_issue_type.test.cjs @@ -384,4 +384,75 @@ describe("set_issue_type (Handler Factory Architecture)", () => { delete process.env.GH_AW_RUNTIME_FEATURES; } }); + + it("should truncate issue_intents rationale to 280 characters", async () => { + process.env.GH_AW_RUNTIME_FEATURES = "issue_intents"; + + const issueNodeId = "I_kwDO_testissue"; + const issueTypeNodeId = "IT_kwDO_bug"; + const longRationale = "a".repeat(350); + + mockGithub.rest.issues.get.mockResolvedValueOnce({ data: { node_id: issueNodeId } }); + mockGithub.graphql.mockImplementation(async query => { + if (query.includes("repository(owner")) { + return { + repository: { + issueTypes: { + nodes: [{ id: issueTypeNodeId, name: "Bug" }], + }, + }, + }; + } + if (query.includes("updateIssue")) { + return { updateIssue: { issue: { id: issueNodeId } } }; + } + return {}; + }); + + try { + const { main } = require("./set_issue_type.cjs"); + const featureHandler = await main({ max: 5 }); + + const result = await featureHandler( + { + type: "set_issue_type", + issue_number: 42, + issue_type: "Bug", + rationale: longRationale, + }, + {} + ); + + expect(result.success).toBe(true); + const mutationCall = mockGithub.graphql.mock.calls.find(([query]) => typeof query === "string" && query.includes("IssueTypeUpdateInput")); + expect(mutationCall).toBeDefined(); + expect(mutationCall[1].issueType.rationale).toBe("a".repeat(280)); + } finally { + delete process.env.GH_AW_RUNTIME_FEATURES; + } + }); + + it("should preserve issue_intents rationale of exactly 280 characters", () => { + const { normalizeIssueIntentMetadata } = require("./issue_intents.cjs"); + + const metadata = normalizeIssueIntentMetadata({ rationale: "a".repeat(280) }); + + expect(metadata.rationale).toBe("a".repeat(280)); + }); + + it("should truncate issue_intents rationale of 281 characters", () => { + const { normalizeIssueIntentMetadata } = require("./issue_intents.cjs"); + + const metadata = normalizeIssueIntentMetadata({ rationale: "a".repeat(281) }); + + expect(metadata.rationale).toBe("a".repeat(280)); + }); + + it("should omit empty issue_intents rationale after sanitization", () => { + const { normalizeIssueIntentMetadata } = require("./issue_intents.cjs"); + + const metadata = normalizeIssueIntentMetadata({ rationale: " " }); + + expect(metadata).not.toHaveProperty("rationale"); + }); }); diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index 763abf7ba55..ce02572644e 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -770,7 +770,8 @@ }, "rationale": { "type": "string", - "description": "Optional rationale for the change." + "maxLength": 280, + "description": "Optional rationale for the change (max 280 characters)." }, "confidence": { "type": "string", @@ -843,7 +844,8 @@ }, "rationale": { "type": "string", - "description": "Optional rationale for the change." + "maxLength": 280, + "description": "Optional rationale for the change (max 280 characters)." }, "confidence": { "type": "string", @@ -1169,7 +1171,8 @@ }, "rationale": { "type": "string", - "description": "Optional rationale for the change." + "maxLength": 280, + "description": "Optional rationale for the change (max 280 characters)." }, "confidence": { "type": "string", @@ -1707,7 +1710,8 @@ }, "rationale": { "type": "string", - "description": "Optional rationale for the change." + "maxLength": 280, + "description": "Optional rationale for the change (max 280 characters)." }, "confidence": { "type": "string", @@ -1773,7 +1777,8 @@ }, "rationale": { "type": "string", - "description": "Optional rationale for the change." + "maxLength": 280, + "description": "Optional rationale for the change (max 280 characters)." }, "confidence": { "type": "string", diff --git a/pkg/workflow/safe_output_validation_config_test.go b/pkg/workflow/safe_output_validation_config_test.go index f8a1b6349ba..f04139df6db 100644 --- a/pkg/workflow/safe_output_validation_config_test.go +++ b/pkg/workflow/safe_output_validation_config_test.go @@ -229,6 +229,16 @@ func TestFieldValidationMarshaling(t *testing.T) { } } +func TestIssueIntentRationaleMaxLength(t *testing.T) { + if got := ValidationConfig["set_issue_type"].Fields["rationale"].MaxLength; got != 280 { + t.Fatalf("set_issue_type rationale maxLength = %d, want 280", got) + } + + if got := ValidationConfig["set_issue_field"].Fields["rationale"].MaxLength; got != 280 { + t.Fatalf("set_issue_field rationale maxLength = %d, want 280", got) + } +} + func TestUpdateDiscussionValidationConfig(t *testing.T) { // Verify update_discussion accepts label-only updates (regression test for // https://github.com/github/gh-aw/issues/24979 where label-only updates were diff --git a/pkg/workflow/safe_outputs_validation_config.go b/pkg/workflow/safe_outputs_validation_config.go index 143bb17aa71..9257e5f1409 100644 --- a/pkg/workflow/safe_outputs_validation_config.go +++ b/pkg/workflow/safe_outputs_validation_config.go @@ -135,7 +135,7 @@ var ValidationConfig = map[string]TypeValidationConfig{ Fields: map[string]FieldValidation{ "issue_number": {IssueOrPRNumber: true}, "issue_type": {Required: true, Type: "string", Sanitize: true, MaxLength: 128}, // Empty string clears the type - "rationale": {Type: "string", Sanitize: true, MaxLength: 1024}, + "rationale": {Type: "string", Sanitize: true, MaxLength: 280}, "confidence": {Type: "string", Enum: []string{"LOW", "MEDIUM", "HIGH"}}, "suggest": {Type: "boolean"}, "repo": {Type: "string", MaxLength: 256}, // Optional: target repository in format "owner/repo" @@ -149,7 +149,7 @@ var ValidationConfig = map[string]TypeValidationConfig{ "field_name": {Type: "string", Sanitize: true, MaxLength: 128}, "field_node_id": {Type: "string", MaxLength: 256}, "value": {Required: true, Type: "string", Sanitize: true, MaxLength: 256}, - "rationale": {Type: "string", Sanitize: true, MaxLength: 1024}, + "rationale": {Type: "string", Sanitize: true, MaxLength: 280}, "confidence": {Type: "string", Enum: []string{"LOW", "MEDIUM", "HIGH"}}, "suggest": {Type: "boolean"}, "repo": {Type: "string", MaxLength: 256}, // Optional: target repository in format "owner/repo"