Skip to content

Commit 1e74312

Browse files
committed
add add_discussion_comment tool and corresponding tests
1 parent 4bded57 commit 1e74312

4 files changed

Lines changed: 328 additions & 0 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"annotations": {
3+
"title": "Add discussion comment"
4+
},
5+
"description": "Add a comment to a discussion",
6+
"inputSchema": {
7+
"properties": {
8+
"body": {
9+
"description": "Comment content",
10+
"type": "string"
11+
},
12+
"discussionNumber": {
13+
"description": "Discussion Number",
14+
"type": "number"
15+
},
16+
"owner": {
17+
"description": "Repository owner",
18+
"type": "string"
19+
},
20+
"repo": {
21+
"description": "Repository name",
22+
"type": "string"
23+
}
24+
},
25+
"required": [
26+
"owner",
27+
"repo",
28+
"discussionNumber",
29+
"body"
30+
],
31+
"type": "object"
32+
},
33+
"name": "add_discussion_comment"
34+
}

pkg/github/discussions.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,107 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve
507507
)
508508
}
509509

510+
func AddDiscussionComment(t translations.TranslationHelperFunc) inventory.ServerTool {
511+
return NewTool(
512+
ToolsetMetadataDiscussions,
513+
mcp.Tool{
514+
Name: "add_discussion_comment",
515+
Description: t("TOOL_ADD_DISCUSSION_COMMENT_DESCRIPTION", "Add a comment to a discussion"), // TODO: Finalise the description
516+
Annotations: &mcp.ToolAnnotations{
517+
Title: t("TOOL_ADD_DISCUSSION_COMMENT_USER_TITLE", "Add discussion comment"),
518+
ReadOnlyHint: false,
519+
},
520+
InputSchema: &jsonschema.Schema{
521+
Type: "object",
522+
Properties: map[string]*jsonschema.Schema{
523+
"owner": {
524+
Type: "string",
525+
Description: "Repository owner",
526+
},
527+
"repo": {
528+
Type: "string",
529+
Description: "Repository name",
530+
},
531+
"discussionNumber": {
532+
Type: "number",
533+
Description: "Discussion Number",
534+
},
535+
"body": {
536+
Type: "string",
537+
Description: "Comment content",
538+
},
539+
},
540+
Required: []string{"owner", "repo", "discussionNumber", "body"},
541+
},
542+
},
543+
[]scopes.Scope{scopes.Repo},
544+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
545+
// Decode params
546+
var params struct {
547+
Owner string
548+
Repo string
549+
DiscussionNumber int32
550+
Body string
551+
}
552+
if err := mapstructure.WeakDecode(args, &params); err != nil {
553+
return utils.NewToolResultError(err.Error()), nil, nil
554+
}
555+
client, err := deps.GetGQLClient(ctx)
556+
if err != nil {
557+
return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil
558+
}
559+
560+
// First, get the discussion's node ID using its number
561+
var q struct {
562+
Repository struct {
563+
Discussion struct {
564+
ID githubv4.ID
565+
} `graphql:"discussion(number: $discussionNumber)"`
566+
} `graphql:"repository(owner: $owner, name: $repo)"`
567+
}
568+
vars := map[string]any{
569+
"owner": githubv4.String(params.Owner),
570+
"repo": githubv4.String(params.Repo),
571+
"discussionNumber": githubv4.Int(params.DiscussionNumber),
572+
}
573+
if err := client.Query(ctx, &q, vars); err != nil {
574+
return utils.NewToolResultError(err.Error()), nil, nil
575+
}
576+
577+
// Now add the comment using the discussion's node ID
578+
input := githubv4.AddDiscussionCommentInput{
579+
DiscussionID: q.Repository.Discussion.ID,
580+
Body: githubv4.String(params.Body),
581+
}
582+
583+
var mutation struct {
584+
AddDiscussionComment struct {
585+
Comment struct {
586+
ID githubv4.ID
587+
URL githubv4.String `graphql:"url"`
588+
}
589+
} `graphql:"addDiscussionComment(input: $input)"`
590+
}
591+
592+
if err := client.Mutate(ctx, &mutation, input, nil); err != nil {
593+
return utils.NewToolResultError(err.Error()), nil, nil
594+
}
595+
596+
comment := mutation.AddDiscussionComment.Comment
597+
minimalResponse := MinimalResponse{
598+
ID: fmt.Sprintf("%v", comment.ID),
599+
URL: string(comment.URL),
600+
}
601+
602+
out, err := json.Marshal(minimalResponse)
603+
if err != nil {
604+
return nil, nil, fmt.Errorf("failed to marshal comment: %w", err)
605+
}
606+
607+
return utils.NewToolResultText(string(out)), nil, nil
608+
})
609+
}
610+
510611
func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.ServerTool {
511612
return NewTool(
512613
ToolsetMetadataDiscussions,

pkg/github/discussions_test.go

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -924,3 +924,195 @@ func Test_ListDiscussionCategories(t *testing.T) {
924924
})
925925
}
926926
}
927+
928+
func Test_AddDiscussionComment(t *testing.T) {
929+
t.Parallel()
930+
931+
// Verify tool definition and schema
932+
toolDef := AddDiscussionComment(translations.NullTranslationHelper)
933+
tool := toolDef.Tool
934+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
935+
936+
assert.Equal(t, "add_discussion_comment", tool.Name)
937+
assert.NotEmpty(t, tool.Description)
938+
assert.False(t, tool.Annotations.ReadOnlyHint, "add_discussion_comment should not be read-only")
939+
schema, ok := tool.InputSchema.(*jsonschema.Schema)
940+
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
941+
assert.Contains(t, schema.Properties, "owner")
942+
assert.Contains(t, schema.Properties, "repo")
943+
assert.Contains(t, schema.Properties, "discussionNumber")
944+
assert.Contains(t, schema.Properties, "body")
945+
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "discussionNumber", "body"})
946+
947+
tests := []struct {
948+
name string
949+
requestArgs map[string]any
950+
mockedClient *http.Client
951+
expectToolError bool
952+
expectedErrMsg string
953+
expectedID string
954+
expectedURL string
955+
}{
956+
{
957+
name: "successful comment creation",
958+
requestArgs: map[string]any{
959+
"owner": "owner",
960+
"repo": "repo",
961+
"discussionNumber": int32(1),
962+
"body": "This is a test comment",
963+
},
964+
mockedClient: githubv4mock.NewMockedHTTPClient(
965+
githubv4mock.NewQueryMatcher(
966+
struct {
967+
Repository struct {
968+
Discussion struct {
969+
ID githubv4.ID
970+
} `graphql:"discussion(number: $discussionNumber)"`
971+
} `graphql:"repository(owner: $owner, name: $repo)"`
972+
}{},
973+
map[string]any{
974+
"owner": githubv4.String("owner"),
975+
"repo": githubv4.String("repo"),
976+
"discussionNumber": githubv4.Int(1),
977+
},
978+
githubv4mock.DataResponse(map[string]any{
979+
"repository": map[string]any{
980+
"discussion": map[string]any{
981+
"id": "D_kwDOTest123",
982+
},
983+
},
984+
}),
985+
),
986+
githubv4mock.NewMutationMatcher(
987+
struct {
988+
AddDiscussionComment struct {
989+
Comment struct {
990+
ID githubv4.ID
991+
URL githubv4.String `graphql:"url"`
992+
}
993+
} `graphql:"addDiscussionComment(input: $input)"`
994+
}{},
995+
githubv4.AddDiscussionCommentInput{
996+
DiscussionID: githubv4.ID("D_kwDOTest123"),
997+
Body: githubv4.String("This is a test comment"),
998+
},
999+
nil,
1000+
githubv4mock.DataResponse(map[string]any{
1001+
"addDiscussionComment": map[string]any{
1002+
"comment": map[string]any{
1003+
"id": "DC_kwDOComment456",
1004+
"url": "https://github.com/owner/repo/discussions/1#discussioncomment-456",
1005+
},
1006+
},
1007+
}),
1008+
),
1009+
),
1010+
expectToolError: false,
1011+
expectedID: "DC_kwDOComment456",
1012+
expectedURL: "https://github.com/owner/repo/discussions/1#discussioncomment-456",
1013+
},
1014+
{
1015+
name: "discussion not found",
1016+
requestArgs: map[string]any{
1017+
"owner": "owner",
1018+
"repo": "repo",
1019+
"discussionNumber": int32(999),
1020+
"body": "This is a comment",
1021+
},
1022+
mockedClient: githubv4mock.NewMockedHTTPClient(
1023+
githubv4mock.NewQueryMatcher(
1024+
struct {
1025+
Repository struct {
1026+
Discussion struct {
1027+
ID githubv4.ID
1028+
} `graphql:"discussion(number: $discussionNumber)"`
1029+
} `graphql:"repository(owner: $owner, name: $repo)"`
1030+
}{},
1031+
map[string]any{
1032+
"owner": githubv4.String("owner"),
1033+
"repo": githubv4.String("repo"),
1034+
"discussionNumber": githubv4.Int(999),
1035+
},
1036+
githubv4mock.ErrorResponse("Could not resolve to a Discussion with the number of 999."),
1037+
),
1038+
),
1039+
expectToolError: true,
1040+
expectedErrMsg: "Could not resolve to a Discussion with the number of 999.",
1041+
},
1042+
{
1043+
name: "mutation failure",
1044+
requestArgs: map[string]any{
1045+
"owner": "owner",
1046+
"repo": "repo",
1047+
"discussionNumber": int32(1),
1048+
"body": "This is a comment",
1049+
},
1050+
mockedClient: githubv4mock.NewMockedHTTPClient(
1051+
githubv4mock.NewQueryMatcher(
1052+
struct {
1053+
Repository struct {
1054+
Discussion struct {
1055+
ID githubv4.ID
1056+
} `graphql:"discussion(number: $discussionNumber)"`
1057+
} `graphql:"repository(owner: $owner, name: $repo)"`
1058+
}{},
1059+
map[string]any{
1060+
"owner": githubv4.String("owner"),
1061+
"repo": githubv4.String("repo"),
1062+
"discussionNumber": githubv4.Int(1),
1063+
},
1064+
githubv4mock.DataResponse(map[string]any{
1065+
"repository": map[string]any{
1066+
"discussion": map[string]any{
1067+
"id": "D_kwDOTest123",
1068+
},
1069+
},
1070+
}),
1071+
),
1072+
githubv4mock.NewMutationMatcher(
1073+
struct {
1074+
AddDiscussionComment struct {
1075+
Comment struct {
1076+
ID githubv4.ID
1077+
URL githubv4.String `graphql:"url"`
1078+
}
1079+
} `graphql:"addDiscussionComment(input: $input)"`
1080+
}{},
1081+
githubv4.AddDiscussionCommentInput{
1082+
DiscussionID: githubv4.ID("D_kwDOTest123"),
1083+
Body: githubv4.String("This is a comment"),
1084+
},
1085+
nil,
1086+
githubv4mock.ErrorResponse("insufficient permissions to comment on this discussion"),
1087+
),
1088+
),
1089+
expectToolError: true,
1090+
expectedErrMsg: "insufficient permissions to comment on this discussion",
1091+
},
1092+
}
1093+
for _, tc := range tests {
1094+
t.Run(tc.name, func(t *testing.T) {
1095+
gqlClient := githubv4.NewClient(tc.mockedClient)
1096+
deps := BaseDeps{GQLClient: gqlClient}
1097+
handler := toolDef.Handler(deps)
1098+
1099+
req := createMCPRequest(tc.requestArgs)
1100+
res, err := handler(ContextWithDeps(context.Background(), deps), &req)
1101+
require.NoError(t, err)
1102+
1103+
text := getTextResult(t, res).Text
1104+
1105+
if tc.expectToolError {
1106+
require.True(t, res.IsError)
1107+
assert.Contains(t, text, tc.expectedErrMsg)
1108+
return
1109+
}
1110+
1111+
require.False(t, res.IsError)
1112+
var response MinimalResponse
1113+
require.NoError(t, json.Unmarshal([]byte(text), &response))
1114+
assert.Equal(t, tc.expectedID, response.ID)
1115+
assert.Equal(t, tc.expectedURL, response.URL)
1116+
})
1117+
}
1118+
}

pkg/github/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
258258
ListDiscussions(t),
259259
GetDiscussion(t),
260260
GetDiscussionComments(t),
261+
AddDiscussionComment(t),
261262
ListDiscussionCategories(t),
262263

263264
// Actions tools

0 commit comments

Comments
 (0)