Skip to content

Commit fcdd664

Browse files
fix(issues): flatten issue comment input schema
Keep cross-field validation in the handler so the canonical tool schema remains compatible with provider JSON Schema subsets. Add an inventory-wide regression guard against top-level schema combinators. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 55f7b72 commit fcdd664

4 files changed

Lines changed: 100 additions & 63 deletions

File tree

pkg/github/__toolsnaps__/add_issue_comment.snap

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,6 @@
66
},
77
"description": "Add a comment and/or reaction to a specific issue or issue comment in a GitHub repository. Use this tool with pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add or react to review comments. At least one of body or reaction is required.",
88
"inputSchema": {
9-
"anyOf": [
10-
{
11-
"required": [
12-
"body"
13-
]
14-
},
15-
{
16-
"required": [
17-
"reaction"
18-
]
19-
}
20-
],
21-
"dependentSchemas": {
22-
"comment_id": {
23-
"not": {
24-
"required": [
25-
"body"
26-
]
27-
},
28-
"required": [
29-
"reaction"
30-
]
31-
}
32-
},
339
"properties": {
3410
"body": {
3511
"description": "Comment content. Required unless reaction is provided.",

pkg/github/issues.go

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1399,16 +1399,6 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool
13991399
},
14001400
},
14011401
Required: []string{"owner", "repo", "issue_number"},
1402-
AnyOf: []*jsonschema.Schema{
1403-
{Required: []string{"body"}},
1404-
{Required: []string{"reaction"}},
1405-
},
1406-
DependentSchemas: map[string]*jsonschema.Schema{
1407-
"comment_id": {
1408-
Required: []string{"reaction"},
1409-
Not: &jsonschema.Schema{Required: []string{"body"}},
1410-
},
1411-
},
14121402
},
14131403
},
14141404
[]scopes.Scope{scopes.Repo},

pkg/github/issues_test.go

Lines changed: 76 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5410,6 +5410,10 @@ func TestAddIssueCommentSchema(t *testing.T) {
54105410
assert.Contains(t, schema.Properties, "body")
54115411
assert.Contains(t, schema.Properties, "reaction")
54125412
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "issue_number"})
5413+
assert.Empty(t, schema.AnyOf)
5414+
assert.Empty(t, schema.OneOf)
5415+
assert.Empty(t, schema.AllOf)
5416+
assert.Empty(t, schema.DependentSchemas)
54135417

54145418
resolved, err := schema.Resolve(nil)
54155419
require.NoError(t, err)
@@ -5425,62 +5429,47 @@ func TestAddIssueCommentSchema(t *testing.T) {
54255429
isValid bool
54265430
}{
54275431
{
5428-
name: "body-only comment",
5429-
args: map[string]any{"body": "This is a comment"},
5432+
name: "cross-field requirements are handler validated",
5433+
args: map[string]any{},
54305434
isValid: true,
54315435
},
54325436
{
5433-
name: "issue or pull request reaction",
5434-
args: map[string]any{"reaction": "heart"},
5437+
name: "comment_id relationships are handler validated",
5438+
args: map[string]any{"comment_id": 999, "body": "This is a comment"},
54355439
isValid: true,
54365440
},
54375441
{
5438-
name: "comment and issue or pull request reaction",
5439-
args: map[string]any{"body": "This is a comment", "reaction": "heart"},
5442+
name: "body minLength accepts non-empty body",
5443+
args: map[string]any{"body": "This is a comment"},
54405444
isValid: true,
54415445
},
54425446
{
5443-
name: "existing comment reaction",
5444-
args: map[string]any{"comment_id": 999, "reaction": "heart"},
5447+
name: "reaction enum accepts supported reaction",
5448+
args: map[string]any{"reaction": "heart"},
54455449
isValid: true,
54465450
},
54475451
{
5448-
name: "missing body and reaction",
5449-
args: map[string]any{},
5452+
name: "missing required owner",
5453+
args: map[string]any{"owner": nil},
54505454
isValid: false,
54515455
},
54525456
{
5453-
name: "empty body",
5457+
name: "body minLength rejects empty body",
54545458
args: map[string]any{"body": ""},
54555459
isValid: false,
54565460
},
54575461
{
5458-
name: "comment_id without reaction",
5459-
args: map[string]any{"comment_id": 999},
5460-
isValid: false,
5461-
},
5462-
{
5463-
name: "comment_id with body",
5464-
args: map[string]any{"comment_id": 999, "body": "This is a comment"},
5465-
isValid: false,
5466-
},
5467-
{
5468-
name: "comment_id with body and reaction",
5469-
args: map[string]any{"comment_id": 999, "body": "This is a comment", "reaction": "heart"},
5470-
isValid: false,
5471-
},
5472-
{
5473-
name: "zero comment_id",
5462+
name: "comment_id minimum rejects zero",
54745463
args: map[string]any{"comment_id": 0, "reaction": "heart"},
54755464
isValid: false,
54765465
},
54775466
{
5478-
name: "fractional comment_id",
5467+
name: "comment_id integer rejects fraction",
54795468
args: map[string]any{"comment_id": 1.5, "reaction": "heart"},
54805469
isValid: false,
54815470
},
54825471
{
5483-
name: "invalid reaction",
5472+
name: "reaction enum rejects unsupported reaction",
54845473
args: map[string]any{"reaction": "party"},
54855474
isValid: false,
54865475
},
@@ -5627,6 +5616,28 @@ func TestAddIssueCommentHandler(t *testing.T) {
56275616
expectToolError: true,
56285617
expectedToolErrMsg: "at least one of body or reaction is required",
56295618
},
5619+
{
5620+
name: "empty body",
5621+
requestArgs: map[string]any{
5622+
"owner": "owner",
5623+
"repo": "repo",
5624+
"issue_number": float64(42),
5625+
"body": "",
5626+
},
5627+
expectToolError: true,
5628+
expectedToolErrMsg: "body cannot be empty when provided",
5629+
},
5630+
{
5631+
name: "empty reaction",
5632+
requestArgs: map[string]any{
5633+
"owner": "owner",
5634+
"repo": "repo",
5635+
"issue_number": float64(42),
5636+
"reaction": "",
5637+
},
5638+
expectToolError: true,
5639+
expectedToolErrMsg: "reaction cannot be empty when provided",
5640+
},
56305641
{
56315642
name: "missing issue_number for reaction",
56325643
requestArgs: map[string]any{
@@ -5658,6 +5669,18 @@ func TestAddIssueCommentHandler(t *testing.T) {
56585669
expectToolError: true,
56595670
expectedToolErrMsg: "comment_id can only be provided when reaction is provided",
56605671
},
5672+
{
5673+
name: "comment_id with body but without reaction",
5674+
requestArgs: map[string]any{
5675+
"owner": "owner",
5676+
"repo": "repo",
5677+
"issue_number": float64(42),
5678+
"comment_id": float64(999),
5679+
"body": "This is a comment",
5680+
},
5681+
expectToolError: true,
5682+
expectedToolErrMsg: "comment_id cannot be combined with body",
5683+
},
56615684
{
56625685
name: "zero comment_id",
56635686
requestArgs: map[string]any{
@@ -5682,6 +5705,30 @@ func TestAddIssueCommentHandler(t *testing.T) {
56825705
expectToolError: true,
56835706
expectedToolErrMsg: "comment_id must be greater than 0",
56845707
},
5708+
{
5709+
name: "fractional comment_id",
5710+
requestArgs: map[string]any{
5711+
"owner": "owner",
5712+
"repo": "repo",
5713+
"issue_number": float64(42),
5714+
"comment_id": float64(1.5),
5715+
"reaction": "heart",
5716+
},
5717+
expectToolError: true,
5718+
expectedToolErrMsg: "parameter comment_id is not a valid number",
5719+
},
5720+
{
5721+
name: "non-numeric comment_id",
5722+
requestArgs: map[string]any{
5723+
"owner": "owner",
5724+
"repo": "repo",
5725+
"issue_number": float64(42),
5726+
"comment_id": "not-a-number",
5727+
"reaction": "heart",
5728+
},
5729+
expectToolError: true,
5730+
expectedToolErrMsg: "parameter comment_id is not a valid number",
5731+
},
56855732
{
56865733
name: "comment_id with body",
56875734
requestArgs: map[string]any{

pkg/github/tools_validation_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package github
22

33
import (
4+
"encoding/json"
45
"go/ast"
56
"go/parser"
67
"go/token"
@@ -47,6 +48,29 @@ func TestAllToolsHaveRequiredMetadata(t *testing.T) {
4748
}
4849
}
4950

51+
// TestAllToolInputSchemasAvoidTopLevelCombinators keeps the complete OSS tool
52+
// inventory portable across provider JSON Schema subsets. Some providers reject
53+
// an entire tools/list payload when any input schema has a top-level combinator,
54+
// so cross-field constraints belong in handlers or below ordinary properties.
55+
func TestAllToolInputSchemasAvoidTopLevelCombinators(t *testing.T) {
56+
tools := AllTools(stubTranslation)
57+
require.NotEmpty(t, tools, "AllTools should return at least one tool")
58+
59+
for _, serverTool := range tools {
60+
tool := serverTool.Tool
61+
t.Run(tool.Name, func(t *testing.T) {
62+
data, err := json.Marshal(tool.InputSchema)
63+
require.NoError(t, err, "Tool %q InputSchema must marshal", tool.Name)
64+
65+
var schema map[string]json.RawMessage
66+
require.NoError(t, json.Unmarshal(data, &schema), "Tool %q InputSchema must be a JSON object", tool.Name)
67+
assert.NotContains(t, schema, "anyOf", "Tool %q InputSchema must not use top-level anyOf", tool.Name)
68+
assert.NotContains(t, schema, "oneOf", "Tool %q InputSchema must not use top-level oneOf", tool.Name)
69+
assert.NotContains(t, schema, "allOf", "Tool %q InputSchema must not use top-level allOf", tool.Name)
70+
})
71+
}
72+
}
73+
5074
// TestAllResourcesHaveRequiredMetadata validates that all resources have mandatory metadata
5175
func TestAllResourcesHaveRequiredMetadata(t *testing.T) {
5276
// Resources are now stateless - no client functions needed

0 commit comments

Comments
 (0)