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
2 changes: 1 addition & 1 deletion .github/workflows/smoke-codex.lock.yml

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

2 changes: 1 addition & 1 deletion .github/workflows/smoke-copilot-aoai-apikey.lock.yml

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

2 changes: 1 addition & 1 deletion .github/workflows/smoke-copilot-aoai-entra.lock.yml

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

2 changes: 1 addition & 1 deletion .github/workflows/smoke-copilot.lock.yml

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

7 changes: 5 additions & 2 deletions actions/setup/js/issue_intents.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secondary clamp is essential but will look redundant to future readers — without a comment, this slice is one refactor away from being removed, which would silently ship the sanitizeContent truncation marker to the GitHub API.

💡 Why this is non-obvious

sanitizeContent internally calls applyTruncation, which — when the input exceeds maxLength — returns content.substring(0, maxLength) + "\n[Content truncated due to length]". This means for a 350-char input:

  1. sanitizeContent(source.rationale, { maxLength: 280 })"a"×280 + "\n[Content truncated due to length]" (311 chars)
  2. .trim() → no change (the \n is interior, not at the boundary)
  3. Without the slice: sanitizedRationale (311 chars) would be stored as metadata.rationale
  4. With the slice: sanitizedRationale.slice(0, 280)"a"×280

The secondary clamp is what keeps the truncation annotation out of the API payload. Add a comment:

// sanitizeContent appends "\n[Content truncated due to length]" when input exceeds maxLength,
// which inflates the result beyond the limit. Slice again to guarantee the API constraint.
const rationale = sanitizedRationale.length > ISSUE_INTENT_RATIONALE_MAX_LENGTH
  ? sanitizedRationale.slice(0, ISSUE_INTENT_RATIONALE_MAX_LENGTH)
  : sanitizedRationale;

if (rationale) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] The post-sanitize clamp is undocumented — it's unclear whether sanitizeContent already enforces maxLength or can overshoot it.

If sanitizeContent always honours its maxLength parameter, the guard is dead code. If it can exceed the limit (e.g. due to word-boundary logic), that is a bug in sanitizeContent that warrants its own test rather than a silent fallback.

💡 Suggested improvement

Add a brief comment explaining the guard:

// sanitizeContent may produce slightly more than maxLength in some edge cases;
// slice enforces the GitHub API hard limit of 280 chars.
const rationale = sanitizedRationale.length > ISSUE_INTENT_RATIONALE_MAX_LENGTH
  ? sanitizedRationale.slice(0, ISSUE_INTENT_RATIONALE_MAX_LENGTH)
  : sanitizedRationale;

Or, if sanitizeContent already guarantees the limit, remove the guard and add an assertion.

@copilot please address this.

metadata.rationale = rationale;
}
Expand Down
26 changes: 21 additions & 5 deletions actions/setup/js/safe_output_type_validator.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] The magic literal 280 is duplicated here instead of referencing a shared constant — if the limit changes again, this will need a separate update.

ISSUE_INTENT_RATIONALE_MAX_LENGTH is defined in issue_intents.cjs; the validator has no access to it, so the limit is hardcoded as 280 twice in this file.

💡 Suggested improvement

Extract a module-level constant in safe_output_type_validator.cjs:

const RATIONALE_MAX_LENGTH = 280;

Then use it in both the sanitizeContent call and the clamp guard so the limit is defined once.

@copilot please address this.

Expand Down
19 changes: 17 additions & 2 deletions actions/setup/js/safe_output_type_validator.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
},
Expand All @@ -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" },
},
Expand Down Expand Up @@ -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));
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The label-rationale truncation test only exercises the add_labels path. The same clamp logic also runs for remove_labels and update_issue label entries, but those paths are untested.

If the clamp is extracted into a shared helper (as suggested in the constant comment above), a single test of the helper covers all callers — otherwise add parallel tests for each mutating label intent type.

@copilot please address this.


it("should fail add_labels when structured label entry is invalid", async () => {
const { validateItem } = await import("./safe_output_type_validator.cjs");

Expand Down
15 changes: 10 additions & 5 deletions actions/setup/js/safe_outputs_tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
71 changes: 71 additions & 0 deletions actions/setup/js/set_issue_type.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The new truncation test only checks the happy path (350-char input → 280-char output). Edge cases at the boundary are not covered.

Missing boundary conditions: exactly 280 chars (should pass through unchanged), exactly 281 chars (first value that needs truncation), and empty string / whitespace-only (should produce no rationale field).

💡 Suggested additional tests
it('should not truncate rationale of exactly 280 characters', async () => {
  const exactRationale = 'a'.repeat(280);
  // ... same mock setup ...
  expect(mutationCall[1].issueType.rationale).toBe(exactRationale);
});

it('should truncate rationale of 281 characters to 280', async () => {
  // ... same mock setup, longRationale = 'a'.repeat(281) ...
  expect(mutationCall[1].issueType.rationale).toBe('a'.repeat(280));
});

it('should omit rationale when it is empty after sanitization', async () => {
  // ... same mock setup, rationale = '' ...
  expect(mutationCall[1].issueType.rationale).toBeUndefined();
});

@copilot please address this.

expect(mutationCall).toBeDefined();
expect(mutationCall[1].issueType.rationale).toBe("a".repeat(280));
} finally {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The test inspects the raw GraphQL mutation call to assert the rationale value, which couples the test to the internal mutation shape rather than the observable contract.

A higher-signal assertion would verify that the returned result object contains (or doesn't contain) a truncated rationale, rather than inspecting the mock call internals — or, better, test normalizeIssueIntentMetadata directly in issue_intents.test.cjs.

💡 Alternative approach

Test the normalizer directly so the test does not depend on GraphQL mock wiring:

// In a dedicated issue_intents.test.cjs or existing test file:
const { normalizeIssueIntentMetadata } = require('./issue_intents.cjs');

it('truncates rationale to 280 characters', () => {
  const meta = normalizeIssueIntentMetadata({ rationale: 'a'.repeat(350) });
  expect(meta.rationale).toHaveLength(280);
});

@copilot please address this.

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");
});
});
15 changes: 10 additions & 5 deletions pkg/workflow/js/safe_outputs_tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions pkg/workflow/safe_output_validation_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions pkg/workflow/safe_outputs_validation_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
Loading