package github import ( "context" "encoding/json" "net/http" "testing" "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var ( discussionsGeneral = []map[string]any{ {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "updatedAt": "2023-01-01T00:00:00Z", "closed": false, "isAnswered": false, "author": map[string]any{"login": "user1"}, "url": "https://github.com/owner/repo/discussions/1", "category": map[string]any{"name": "General"}}, {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "updatedAt": "2023-02-01T00:00:00Z", "closed": false, "isAnswered": false, "author": map[string]any{"login": "user1"}, "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}}, } discussionsAll = []map[string]any{ { "number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "updatedAt": "2023-01-01T00:00:00Z", "closed": false, "isAnswered": false, "author": map[string]any{"login": "user1"}, "url": "https://github.com/owner/repo/discussions/1", "category": map[string]any{"name": "General"}, }, { "number": 2, "title": "Discussion 2 title", "createdAt": "2023-02-01T00:00:00Z", "updatedAt": "2023-02-01T00:00:00Z", "closed": false, "isAnswered": false, "author": map[string]any{"login": "user2"}, "url": "https://github.com/owner/repo/discussions/2", "category": map[string]any{"name": "Questions"}, }, { "number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "updatedAt": "2023-03-01T00:00:00Z", "closed": false, "isAnswered": false, "author": map[string]any{"login": "user3"}, "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}, }, } discussionsOrgLevel = []map[string]any{ { "number": 1, "title": "Org Discussion 1 - Community Guidelines", "createdAt": "2023-01-15T00:00:00Z", "updatedAt": "2023-01-15T00:00:00Z", "closed": false, "isAnswered": false, "author": map[string]any{"login": "org-admin"}, "url": "https://github.com/owner/.github/discussions/1", "category": map[string]any{"name": "Announcements"}, }, { "number": 2, "title": "Org Discussion 2 - Roadmap 2023", "createdAt": "2023-02-20T00:00:00Z", "updatedAt": "2023-02-20T00:00:00Z", "closed": false, "isAnswered": false, "author": map[string]any{"login": "org-admin"}, "url": "https://github.com/owner/.github/discussions/2", "category": map[string]any{"name": "General"}, }, { "number": 3, "title": "Org Discussion 3 - Roadmap 2024", "createdAt": "2023-02-20T00:00:00Z", "updatedAt": "2023-02-20T00:00:00Z", "closed": false, "isAnswered": false, "author": map[string]any{"login": "org-admin"}, "url": "https://github.com/owner/.github/discussions/3", "category": map[string]any{"name": "General"}, }, { "number": 4, "title": "Org Discussion 4 - Roadmap 2025", "createdAt": "2023-02-20T00:00:00Z", "updatedAt": "2023-02-20T00:00:00Z", "closed": false, "isAnswered": false, "author": map[string]any{"login": "org-admin"}, "url": "https://github.com/owner/.github/discussions/4", "category": map[string]any{"name": "General"}, }, } // Ordered mock responses discussionsOrderedCreatedAsc = []map[string]any{ discussionsAll[0], // Discussion 1 (created 2023-01-01) discussionsAll[1], // Discussion 2 (created 2023-02-01) discussionsAll[2], // Discussion 3 (created 2023-03-01) } discussionsOrderedUpdatedDesc = []map[string]any{ discussionsAll[2], // Discussion 3 (updated 2023-03-01) discussionsAll[1], // Discussion 2 (updated 2023-02-01) discussionsAll[0], // Discussion 1 (updated 2023-01-01) } // only 'General' category discussions ordered by created date descending discussionsGeneralOrderedDesc = []map[string]any{ discussionsGeneral[1], // Discussion 3 (created 2023-03-01) discussionsGeneral[0], // Discussion 1 (created 2023-01-01) } mockResponseListAll = githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "discussions": map[string]any{ "nodes": discussionsAll, "pageInfo": map[string]any{ "hasNextPage": false, "hasPreviousPage": false, "startCursor": "", "endCursor": "", }, "totalCount": 3, }, }, }) mockResponseListGeneral = githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "discussions": map[string]any{ "nodes": discussionsGeneral, "pageInfo": map[string]any{ "hasNextPage": false, "hasPreviousPage": false, "startCursor": "", "endCursor": "", }, "totalCount": 2, }, }, }) mockResponseOrderedCreatedAsc = githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "discussions": map[string]any{ "nodes": discussionsOrderedCreatedAsc, "pageInfo": map[string]any{ "hasNextPage": false, "hasPreviousPage": false, "startCursor": "", "endCursor": "", }, "totalCount": 3, }, }, }) mockResponseOrderedUpdatedDesc = githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "discussions": map[string]any{ "nodes": discussionsOrderedUpdatedDesc, "pageInfo": map[string]any{ "hasNextPage": false, "hasPreviousPage": false, "startCursor": "", "endCursor": "", }, "totalCount": 3, }, }, }) mockResponseGeneralOrderedDesc = githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "discussions": map[string]any{ "nodes": discussionsGeneralOrderedDesc, "pageInfo": map[string]any{ "hasNextPage": false, "hasPreviousPage": false, "startCursor": "", "endCursor": "", }, "totalCount": 2, }, }, }) mockResponseOrgLevel = githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "discussions": map[string]any{ "nodes": discussionsOrgLevel, "pageInfo": map[string]any{ "hasNextPage": false, "hasPreviousPage": false, "startCursor": "", "endCursor": "", }, "totalCount": 4, }, }, }) mockErrorRepoNotFound = githubv4mock.ErrorResponse("repository not found") ) func Test_ListDiscussions(t *testing.T) { toolDef := ListDiscussions(translations.NullTranslationHelper) tool := toolDef.Tool require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "list_discussions", tool.Name) assert.NotEmpty(t, tool.Description) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") assert.Contains(t, schema.Properties, "owner") assert.Contains(t, schema.Properties, "repo") assert.Contains(t, schema.Properties, "orderBy") assert.Contains(t, schema.Properties, "direction") assert.ElementsMatch(t, schema.Required, []string{"owner"}) // Variables matching what GraphQL receives after JSON marshaling/unmarshaling varsListAll := map[string]any{ "owner": "owner", "repo": "repo", "first": float64(30), "after": (*string)(nil), } varsRepoNotFound := map[string]any{ "owner": "owner", "repo": "nonexistent-repo", "first": float64(30), "after": (*string)(nil), } varsDiscussionsFiltered := map[string]any{ "owner": "owner", "repo": "repo", "categoryId": "DIC_kwDOABC123", "first": float64(30), "after": (*string)(nil), } varsOrderByCreatedAsc := map[string]any{ "owner": "owner", "repo": "repo", "orderByField": "CREATED_AT", "orderByDirection": "ASC", "first": float64(30), "after": (*string)(nil), } varsOrderByUpdatedDesc := map[string]any{ "owner": "owner", "repo": "repo", "orderByField": "UPDATED_AT", "orderByDirection": "DESC", "first": float64(30), "after": (*string)(nil), } varsCategoryWithOrder := map[string]any{ "owner": "owner", "repo": "repo", "categoryId": "DIC_kwDOABC123", "orderByField": "CREATED_AT", "orderByDirection": "DESC", "first": float64(30), "after": (*string)(nil), } varsOrgLevel := map[string]any{ "owner": "owner", "repo": ".github", // This is what gets set when repo is not provided "first": float64(30), "after": (*string)(nil), } tests := []struct { name string reqParams map[string]any expectError bool errContains string expectedCount int verifyOrder func(t *testing.T, discussions []*github.Discussion) }{ { name: "list all discussions without category filter", reqParams: map[string]any{ "owner": "owner", "repo": "repo", }, expectError: false, expectedCount: 3, // All discussions }, { name: "filter by category ID", reqParams: map[string]any{ "owner": "owner", "repo": "repo", "category": "DIC_kwDOABC123", }, expectError: false, expectedCount: 2, // Only General discussions (matching the category ID) }, { name: "order by created at ascending", reqParams: map[string]any{ "owner": "owner", "repo": "repo", "orderBy": "CREATED_AT", "direction": "ASC", }, expectError: false, expectedCount: 3, verifyOrder: func(t *testing.T, discussions []*github.Discussion) { // Verify discussions are ordered by created date ascending require.Len(t, discussions, 3) assert.Equal(t, 1, *discussions[0].Number, "First should be discussion 1 (created 2023-01-01)") assert.Equal(t, 2, *discussions[1].Number, "Second should be discussion 2 (created 2023-02-01)") assert.Equal(t, 3, *discussions[2].Number, "Third should be discussion 3 (created 2023-03-01)") }, }, { name: "order by updated at descending", reqParams: map[string]any{ "owner": "owner", "repo": "repo", "orderBy": "UPDATED_AT", "direction": "DESC", }, expectError: false, expectedCount: 3, verifyOrder: func(t *testing.T, discussions []*github.Discussion) { // Verify discussions are ordered by updated date descending require.Len(t, discussions, 3) assert.Equal(t, 3, *discussions[0].Number, "First should be discussion 3 (updated 2023-03-01)") assert.Equal(t, 2, *discussions[1].Number, "Second should be discussion 2 (updated 2023-02-01)") assert.Equal(t, 1, *discussions[2].Number, "Third should be discussion 1 (updated 2023-01-01)") }, }, { name: "filter by category with order", reqParams: map[string]any{ "owner": "owner", "repo": "repo", "category": "DIC_kwDOABC123", "orderBy": "CREATED_AT", "direction": "DESC", }, expectError: false, expectedCount: 2, verifyOrder: func(t *testing.T, discussions []*github.Discussion) { // Verify only General discussions, ordered by created date descending require.Len(t, discussions, 2) assert.Equal(t, 3, *discussions[0].Number, "First should be discussion 3 (created 2023-03-01)") assert.Equal(t, 1, *discussions[1].Number, "Second should be discussion 1 (created 2023-01-01)") }, }, { name: "order by without direction (should not use ordering)", reqParams: map[string]any{ "owner": "owner", "repo": "repo", "orderBy": "CREATED_AT", }, expectError: false, expectedCount: 3, }, { name: "direction without order by (should not use ordering)", reqParams: map[string]any{ "owner": "owner", "repo": "repo", "direction": "DESC", }, expectError: false, expectedCount: 3, }, { name: "repository not found error", reqParams: map[string]any{ "owner": "owner", "repo": "nonexistent-repo", }, expectError: true, errContains: "repository not found", }, { name: "list org-level discussions (no repo provided)", reqParams: map[string]any{ "owner": "owner", // repo is not provided, it will default to ".github" }, expectError: false, expectedCount: 4, }, } // Define the actual query strings that match the implementation qBasicNoOrder := "query($after:String$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after){nodes{number,title,createdAt,updatedAt,closed,isAnswered,answerChosenAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" qWithCategoryNoOrder := "query($after:String$categoryId:ID!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, categoryId: $categoryId){nodes{number,title,createdAt,updatedAt,closed,isAnswered,answerChosenAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" qBasicWithOrder := "query($after:String$first:Int!$orderByDirection:OrderDirection!$orderByField:DiscussionOrderField!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, orderBy: { field: $orderByField, direction: $orderByDirection }){nodes{number,title,createdAt,updatedAt,closed,isAnswered,answerChosenAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" qWithCategoryAndOrder := "query($after:String$categoryId:ID!$first:Int!$orderByDirection:OrderDirection!$orderByField:DiscussionOrderField!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussions(first: $first, after: $after, categoryId: $categoryId, orderBy: { field: $orderByField, direction: $orderByDirection }){nodes{number,title,createdAt,updatedAt,closed,isAnswered,answerChosenAt,author{login},category{name},url},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { var httpClient *http.Client switch tc.name { case "list all discussions without category filter": matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll) httpClient = githubv4mock.NewMockedHTTPClient(matcher) case "filter by category ID": matcher := githubv4mock.NewQueryMatcher(qWithCategoryNoOrder, varsDiscussionsFiltered, mockResponseListGeneral) httpClient = githubv4mock.NewMockedHTTPClient(matcher) case "order by created at ascending": matcher := githubv4mock.NewQueryMatcher(qBasicWithOrder, varsOrderByCreatedAsc, mockResponseOrderedCreatedAsc) httpClient = githubv4mock.NewMockedHTTPClient(matcher) case "order by updated at descending": matcher := githubv4mock.NewQueryMatcher(qBasicWithOrder, varsOrderByUpdatedDesc, mockResponseOrderedUpdatedDesc) httpClient = githubv4mock.NewMockedHTTPClient(matcher) case "filter by category with order": matcher := githubv4mock.NewQueryMatcher(qWithCategoryAndOrder, varsCategoryWithOrder, mockResponseGeneralOrderedDesc) httpClient = githubv4mock.NewMockedHTTPClient(matcher) case "order by without direction (should not use ordering)": matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll) httpClient = githubv4mock.NewMockedHTTPClient(matcher) case "direction without order by (should not use ordering)": matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsListAll, mockResponseListAll) httpClient = githubv4mock.NewMockedHTTPClient(matcher) case "repository not found error": matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsRepoNotFound, mockErrorRepoNotFound) httpClient = githubv4mock.NewMockedHTTPClient(matcher) case "list org-level discussions (no repo provided)": matcher := githubv4mock.NewQueryMatcher(qBasicNoOrder, varsOrgLevel, mockResponseOrgLevel) httpClient = githubv4mock.NewMockedHTTPClient(matcher) } gqlClient := githubv4.NewClient(httpClient) deps := BaseDeps{GQLClient: gqlClient} handler := toolDef.Handler(deps) req := createMCPRequest(tc.reqParams) res, err := handler(ContextWithDeps(context.Background(), deps), &req) text := getTextResult(t, res).Text if tc.expectError { require.True(t, res.IsError) assert.Contains(t, text, tc.errContains) return } require.NoError(t, err) // Parse the structured response with pagination info var response struct { Discussions []*github.Discussion `json:"discussions"` PageInfo struct { HasNextPage bool `json:"hasNextPage"` HasPreviousPage bool `json:"hasPreviousPage"` StartCursor string `json:"startCursor"` EndCursor string `json:"endCursor"` } `json:"pageInfo"` TotalCount int `json:"totalCount"` } err = json.Unmarshal([]byte(text), &response) require.NoError(t, err) assert.Len(t, response.Discussions, tc.expectedCount, "Expected %d discussions, got %d", tc.expectedCount, len(response.Discussions)) // Verify order if verifyOrder function is provided if tc.verifyOrder != nil { tc.verifyOrder(t, response.Discussions) } // Verify that all returned discussions have a category if filtered if _, hasCategory := tc.reqParams["category"]; hasCategory { for _, discussion := range response.Discussions { require.NotNil(t, discussion.DiscussionCategory, "Discussion should have category") assert.NotEmpty(t, *discussion.DiscussionCategory.Name, "Discussion should have category name") } } }) } } func Test_GetDiscussion(t *testing.T) { // Verify tool definition and schema toolDef := GetDiscussion(translations.NullTranslationHelper) tool := toolDef.Tool require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "get_discussion", tool.Name) assert.NotEmpty(t, tool.Description) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") assert.Contains(t, schema.Properties, "owner") assert.Contains(t, schema.Properties, "repo") assert.Contains(t, schema.Properties, "discussionNumber") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "discussionNumber"}) // Use exact string query that matches implementation output qGetDiscussion := "query($discussionNumber:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){number,title,body,createdAt,closed,isAnswered,answerChosenAt,url,category{name}}}}" vars := map[string]any{ "owner": "owner", "repo": "repo", "discussionNumber": float64(1), } tests := []struct { name string response githubv4mock.GQLResponse expectError bool expected map[string]any errContains string }{ { name: "successful retrieval", response: githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{"discussion": map[string]any{ "number": 1, "title": "Test Discussion Title", "body": "This is a test discussion", "url": "https://github.com/owner/repo/discussions/1", "createdAt": "2025-04-25T12:00:00Z", "closed": false, "isAnswered": false, "category": map[string]any{"name": "General"}, }}, }), expectError: false, expected: map[string]any{ "number": float64(1), "title": "Test Discussion Title", "body": "This is a test discussion", "url": "https://github.com/owner/repo/discussions/1", "closed": false, "isAnswered": false, }, }, { name: "discussion not found", response: githubv4mock.ErrorResponse("discussion not found"), expectError: true, errContains: "discussion not found", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { matcher := githubv4mock.NewQueryMatcher(qGetDiscussion, vars, tc.response) httpClient := githubv4mock.NewMockedHTTPClient(matcher) gqlClient := githubv4.NewClient(httpClient) deps := BaseDeps{GQLClient: gqlClient} handler := toolDef.Handler(deps) reqParams := map[string]any{"owner": "owner", "repo": "repo", "discussionNumber": int32(1)} req := createMCPRequest(reqParams) res, err := handler(ContextWithDeps(context.Background(), deps), &req) text := getTextResult(t, res).Text if tc.expectError { require.True(t, res.IsError) assert.Contains(t, text, tc.errContains) return } require.NoError(t, err) var out map[string]any require.NoError(t, json.Unmarshal([]byte(text), &out)) assert.Equal(t, tc.expected["number"], out["number"]) assert.Equal(t, tc.expected["title"], out["title"]) assert.Equal(t, tc.expected["body"], out["body"]) assert.Equal(t, tc.expected["url"], out["url"]) assert.Equal(t, tc.expected["closed"], out["closed"]) assert.Equal(t, tc.expected["isAnswered"], out["isAnswered"]) // Check category is present category, ok := out["category"].(map[string]any) require.True(t, ok) assert.Equal(t, "General", category["name"]) }) } } func Test_GetDiscussionWithStringNumber(t *testing.T) { // Test that WeakDecode handles string discussionNumber from MCP clients toolDef := GetDiscussion(translations.NullTranslationHelper) qGetDiscussion := "query($discussionNumber:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){number,title,body,createdAt,closed,isAnswered,answerChosenAt,url,category{name}}}}" vars := map[string]any{ "owner": "owner", "repo": "repo", "discussionNumber": float64(1), } matcher := githubv4mock.NewQueryMatcher(qGetDiscussion, vars, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{"discussion": map[string]any{ "number": 1, "title": "Test Discussion Title", "body": "This is a test discussion", "url": "https://github.com/owner/repo/discussions/1", "createdAt": "2025-04-25T12:00:00Z", "closed": false, "isAnswered": false, "category": map[string]any{"name": "General"}, }}, })) httpClient := githubv4mock.NewMockedHTTPClient(matcher) gqlClient := githubv4.NewClient(httpClient) deps := BaseDeps{GQLClient: gqlClient} handler := toolDef.Handler(deps) // Send discussionNumber as a string instead of a number reqParams := map[string]any{"owner": "owner", "repo": "repo", "discussionNumber": "1"} req := createMCPRequest(reqParams) res, err := handler(ContextWithDeps(context.Background(), deps), &req) require.NoError(t, err) text := getTextResult(t, res).Text require.False(t, res.IsError, "expected no error, got: %s", text) var out map[string]any require.NoError(t, json.Unmarshal([]byte(text), &out)) assert.Equal(t, float64(1), out["number"]) assert.Equal(t, "Test Discussion Title", out["title"]) } func Test_GetDiscussionComments(t *testing.T) { // Verify tool definition and schema toolDef := GetDiscussionComments(translations.NullTranslationHelper) tool := toolDef.Tool require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "get_discussion_comments", tool.Name) assert.NotEmpty(t, tool.Description) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") assert.Contains(t, schema.Properties, "owner") assert.Contains(t, schema.Properties, "repo") assert.Contains(t, schema.Properties, "discussionNumber") assert.Contains(t, schema.Properties, "includeReplies") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "discussionNumber"}) // Use exact string query that matches implementation output qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{id,body,isAnswer},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" // Variables matching what GraphQL receives after JSON marshaling/unmarshaling vars := map[string]any{ "owner": "owner", "repo": "repo", "discussionNumber": float64(1), "first": float64(30), "after": (*string)(nil), } mockResponse := githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "discussion": map[string]any{ "comments": map[string]any{ "nodes": []map[string]any{ {"id": "DC_id1", "body": "This is the first comment"}, {"id": "DC_id2", "body": "This is the second comment"}, }, "pageInfo": map[string]any{ "hasNextPage": false, "hasPreviousPage": false, "startCursor": "", "endCursor": "", }, "totalCount": 2, }, }, }, }) matcher := githubv4mock.NewQueryMatcher(qGetComments, vars, mockResponse) httpClient := githubv4mock.NewMockedHTTPClient(matcher) gqlClient := githubv4.NewClient(httpClient) deps := BaseDeps{GQLClient: gqlClient} handler := toolDef.Handler(deps) reqParams := map[string]any{ "owner": "owner", "repo": "repo", "discussionNumber": int32(1), } request := createMCPRequest(reqParams) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) textContent := getTextResult(t, result) // (Lines removed) var response struct { Comments []struct { ID string `json:"id"` Body string `json:"body"` } `json:"comments"` PageInfo struct { HasNextPage bool `json:"hasNextPage"` HasPreviousPage bool `json:"hasPreviousPage"` StartCursor string `json:"startCursor"` EndCursor string `json:"endCursor"` } `json:"pageInfo"` TotalCount int `json:"totalCount"` } err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) assert.Len(t, response.Comments, 2) assert.Equal(t, "DC_id1", response.Comments[0].ID) assert.Equal(t, "This is the first comment", response.Comments[0].Body) assert.Equal(t, "DC_id2", response.Comments[1].ID) assert.Equal(t, "This is the second comment", response.Comments[1].Body) } func Test_GetDiscussionCommentsWithStringNumber(t *testing.T) { // Test that WeakDecode handles string discussionNumber from MCP clients toolDef := GetDiscussionComments(translations.NullTranslationHelper) qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{id,body,isAnswer},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" vars := map[string]any{ "owner": "owner", "repo": "repo", "discussionNumber": float64(1), "first": float64(30), "after": (*string)(nil), } mockResponse := githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "discussion": map[string]any{ "comments": map[string]any{ "nodes": []map[string]any{ {"id": "DC_id3", "body": "First comment"}, }, "pageInfo": map[string]any{ "hasNextPage": false, "hasPreviousPage": false, "startCursor": "", "endCursor": "", }, "totalCount": 1, }, }, }, }) matcher := githubv4mock.NewQueryMatcher(qGetComments, vars, mockResponse) httpClient := githubv4mock.NewMockedHTTPClient(matcher) gqlClient := githubv4.NewClient(httpClient) deps := BaseDeps{GQLClient: gqlClient} handler := toolDef.Handler(deps) // Send discussionNumber as a string instead of a number reqParams := map[string]any{ "owner": "owner", "repo": "repo", "discussionNumber": "1", } request := createMCPRequest(reqParams) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) textContent := getTextResult(t, result) require.False(t, result.IsError, "expected no error, got: %s", textContent.Text) var out struct { Comments []map[string]any `json:"comments"` TotalCount int `json:"totalCount"` } require.NoError(t, json.Unmarshal([]byte(textContent.Text), &out)) assert.Len(t, out.Comments, 1) assert.Equal(t, "DC_id3", out.Comments[0]["id"]) assert.Equal(t, "First comment", out.Comments[0]["body"]) } func Test_ListDiscussionCategories(t *testing.T) { toolDef := ListDiscussionCategories(translations.NullTranslationHelper) tool := toolDef.Tool require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "list_discussion_categories", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.Description, "or organisation") schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") assert.Contains(t, schema.Properties, "owner") assert.Contains(t, schema.Properties, "repo") assert.ElementsMatch(t, schema.Required, []string{"owner"}) // Use exact string query that matches implementation output qListCategories := "query($first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussionCategories(first: $first){nodes{id,name},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" // Variables for repository-level categories varsRepo := map[string]any{ "owner": "owner", "repo": "repo", "first": float64(25), } // Variables for organization-level categories (using .github repo) varsOrg := map[string]any{ "owner": "owner", "repo": ".github", "first": float64(25), } mockRespRepo := githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "discussionCategories": map[string]any{ "nodes": []map[string]any{ {"id": "123", "name": "CategoryOne"}, {"id": "456", "name": "CategoryTwo"}, }, "pageInfo": map[string]any{ "hasNextPage": false, "hasPreviousPage": false, "startCursor": "", "endCursor": "", }, "totalCount": 2, }, }, }) mockRespOrg := githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "discussionCategories": map[string]any{ "nodes": []map[string]any{ {"id": "789", "name": "Announcements"}, {"id": "101", "name": "General"}, {"id": "112", "name": "Ideas"}, }, "pageInfo": map[string]any{ "hasNextPage": false, "hasPreviousPage": false, "startCursor": "", "endCursor": "", }, "totalCount": 3, }, }, }) tests := []struct { name string reqParams map[string]any vars map[string]any mockResponse githubv4mock.GQLResponse expectError bool expectedCount int expectedCategories []map[string]string }{ { name: "list repository-level discussion categories", reqParams: map[string]any{ "owner": "owner", "repo": "repo", }, vars: varsRepo, mockResponse: mockRespRepo, expectError: false, expectedCount: 2, expectedCategories: []map[string]string{ {"id": "123", "name": "CategoryOne"}, {"id": "456", "name": "CategoryTwo"}, }, }, { name: "list org-level discussion categories (no repo provided)", reqParams: map[string]any{ "owner": "owner", // repo is not provided, it will default to ".github" }, vars: varsOrg, mockResponse: mockRespOrg, expectError: false, expectedCount: 3, expectedCategories: []map[string]string{ {"id": "789", "name": "Announcements"}, {"id": "101", "name": "General"}, {"id": "112", "name": "Ideas"}, }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { matcher := githubv4mock.NewQueryMatcher(qListCategories, tc.vars, tc.mockResponse) httpClient := githubv4mock.NewMockedHTTPClient(matcher) gqlClient := githubv4.NewClient(httpClient) deps := BaseDeps{GQLClient: gqlClient} handler := toolDef.Handler(deps) req := createMCPRequest(tc.reqParams) res, err := handler(ContextWithDeps(context.Background(), deps), &req) text := getTextResult(t, res).Text if tc.expectError { require.True(t, res.IsError) return } require.NoError(t, err) var response struct { Categories []map[string]string `json:"categories"` PageInfo struct { HasNextPage bool `json:"hasNextPage"` HasPreviousPage bool `json:"hasPreviousPage"` StartCursor string `json:"startCursor"` EndCursor string `json:"endCursor"` } `json:"pageInfo"` TotalCount int `json:"totalCount"` } require.NoError(t, json.Unmarshal([]byte(text), &response)) assert.Equal(t, tc.expectedCategories, response.Categories) }) } } func Test_DiscussionCommentWrite(t *testing.T) { t.Parallel() toolDef := DiscussionCommentWrite(translations.NullTranslationHelper) tool := toolDef.Tool require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "discussion_comment_write", tool.Name) assert.NotEmpty(t, tool.Description) assert.False(t, tool.Annotations.ReadOnlyHint, "discussion_comment_write should not be read-only") require.NotNil(t, tool.Annotations.DestructiveHint) assert.True(t, *tool.Annotations.DestructiveHint, "discussion_comment_write should be destructive") schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") assert.Contains(t, schema.Properties, "method") assert.Contains(t, schema.Properties, "owner") assert.Contains(t, schema.Properties, "repo") assert.Contains(t, schema.Properties, "discussionNumber") assert.Contains(t, schema.Properties, "body") assert.Contains(t, schema.Properties, "commentNodeID") assert.ElementsMatch(t, schema.Required, []string{"method"}) runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ { name: "method: missing", requestArgs: map[string]any{}, mockedClient: githubv4mock.NewMockedHTTPClient(), expectToolError: true, expectedErrMsg: "missing required parameter: method", }, { name: "invalid method", requestArgs: map[string]any{ "method": "invalid", }, mockedClient: githubv4mock.NewMockedHTTPClient(), expectToolError: true, expectedErrMsg: "invalid method, must be one of: 'add', 'reply', 'update', 'delete', 'mark_answer', 'unmark_answer'", }, }) } func Test_DiscussionCommentWrite_Add(t *testing.T) { t.Parallel() discussionQueryMatcher := discussionCommentWriteDiscussionQueryMatcher( 1, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "discussion": map[string]any{ "id": "D_kwDOTest123", }, }, }), ) runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ { name: "add: successful comment creation", requestArgs: map[string]any{ "method": "add", "owner": "owner", "repo": "repo", "discussionNumber": int32(1), "body": "This is a test comment", }, mockedClient: githubv4mock.NewMockedHTTPClient( discussionQueryMatcher, githubv4mock.NewMutationMatcher( struct { AddDiscussionComment struct { Comment struct { ID githubv4.ID URL githubv4.String `graphql:"url"` } } `graphql:"addDiscussionComment(input: $input)"` }{}, githubv4.AddDiscussionCommentInput{ DiscussionID: githubv4.ID("D_kwDOTest123"), Body: githubv4.String("This is a test comment"), }, nil, githubv4mock.DataResponse(map[string]any{ "addDiscussionComment": map[string]any{ "comment": map[string]any{ "id": "DC_kwDOComment456", "url": "https://github.com/owner/repo/discussions/1#discussioncomment-456", }, }, }), ), ), expectedID: "DC_kwDOComment456", expectedURL: "https://github.com/owner/repo/discussions/1#discussioncomment-456", }, { name: "add: discussion not found", requestArgs: map[string]any{ "method": "add", "owner": "owner", "repo": "repo", "discussionNumber": int32(999), "body": "This is a comment", }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { Repository struct { Discussion struct { ID githubv4.ID } `graphql:"discussion(number: $discussionNumber)"` } `graphql:"repository(owner: $owner, name: $repo)"` }{}, map[string]any{ "owner": githubv4.String("owner"), "repo": githubv4.String("repo"), "discussionNumber": githubv4.Int(999), }, githubv4mock.ErrorResponse("Could not resolve to a Discussion with the number of 999."), ), ), expectToolError: true, expectedErrMsg: "Could not resolve to a Discussion with the number of 999.", }, { name: "add: mutation failure", requestArgs: map[string]any{ "method": "add", "owner": "owner", "repo": "repo", "discussionNumber": int32(1), "body": "This is a comment", }, mockedClient: githubv4mock.NewMockedHTTPClient( discussionQueryMatcher, githubv4mock.NewMutationMatcher( struct { AddDiscussionComment struct { Comment struct { ID githubv4.ID URL githubv4.String `graphql:"url"` } } `graphql:"addDiscussionComment(input: $input)"` }{}, githubv4.AddDiscussionCommentInput{ DiscussionID: githubv4.ID("D_kwDOTest123"), Body: githubv4.String("This is a comment"), }, nil, githubv4mock.ErrorResponse("insufficient permissions to comment on this discussion"), ), ), expectToolError: true, expectedErrMsg: "insufficient permissions to comment on this discussion", }, { name: "add: missing body", requestArgs: map[string]any{ "method": "add", "owner": "owner", "repo": "repo", "discussionNumber": int32(1), }, mockedClient: githubv4mock.NewMockedHTTPClient(), expectToolError: true, expectedErrMsg: "missing required parameter: body", }, }) } func Test_DiscussionCommentWrite_Reply(t *testing.T) { t.Parallel() discussionQueryMatcher := discussionCommentWriteDiscussionQueryMatcher( 1, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "discussion": map[string]any{ "id": "D_kwDOTest123", }, }, }), ) runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ { name: "reply: successful reply to comment", requestArgs: map[string]any{ "method": "reply", "owner": "owner", "repo": "repo", "discussionNumber": int32(1), "body": "This is a reply", "commentNodeID": "DC_kwDOComment456", }, mockedClient: githubv4mock.NewMockedHTTPClient( discussionCommentWriteReplyValidationQueryMatcher( "DC_kwDOComment456", githubv4mock.DataResponse(map[string]any{ "node": map[string]any{ "id": "DC_kwDOComment456", "discussion": map[string]any{ "id": "D_kwDOTest123", }, }, }), ), discussionQueryMatcher, githubv4mock.NewMutationMatcher( struct { AddDiscussionComment struct { Comment struct { ID githubv4.ID URL githubv4.String `graphql:"url"` } } `graphql:"addDiscussionComment(input: $input)"` }{}, githubv4.AddDiscussionCommentInput{ DiscussionID: githubv4.ID("D_kwDOTest123"), Body: githubv4.String("This is a reply"), ReplyToID: githubv4ptr("DC_kwDOComment456"), }, nil, githubv4mock.DataResponse(map[string]any{ "addDiscussionComment": map[string]any{ "comment": map[string]any{ "id": "DC_kwDOReply789", "url": "https://github.com/owner/repo/discussions/1#discussioncomment-789", }, }, }), ), ), expectedID: "DC_kwDOReply789", expectedURL: "https://github.com/owner/repo/discussions/1#discussioncomment-789", }, { name: "reply: missing commentNodeID", requestArgs: map[string]any{ "method": "reply", "owner": "owner", "repo": "repo", "discussionNumber": int32(1), "body": "This is a reply", }, mockedClient: githubv4mock.NewMockedHTTPClient(), expectToolError: true, expectedErrMsg: "missing required parameter: commentNodeID", }, { name: "reply: whitespace-only commentNodeID is rejected", requestArgs: map[string]any{ "method": "reply", "owner": "owner", "repo": "repo", "discussionNumber": int32(1), "body": "This is a reply", "commentNodeID": " ", }, mockedClient: githubv4mock.NewMockedHTTPClient(), expectToolError: true, expectedErrMsg: "commentNodeID cannot be blank", }, { name: "reply: invalid commentNodeID returns error", requestArgs: map[string]any{ "method": "reply", "owner": "owner", "repo": "repo", "discussionNumber": int32(1), "body": "This is a reply", "commentNodeID": "DC_kwDOInvalid", }, mockedClient: githubv4mock.NewMockedHTTPClient( discussionCommentWriteReplyValidationQueryMatcher( "DC_kwDOInvalid", githubv4mock.DataResponse(map[string]any{ "node": nil, }), ), ), expectToolError: true, expectedErrMsg: `commentNodeID "DC_kwDOInvalid" does not resolve to a valid discussion comment`, }, { name: "reply: comment from another discussion is rejected", requestArgs: map[string]any{ "method": "reply", "owner": "owner", "repo": "repo", "discussionNumber": int32(1), "body": "This is a reply", "commentNodeID": "DC_kwDOComment456", }, mockedClient: githubv4mock.NewMockedHTTPClient( discussionCommentWriteReplyValidationQueryMatcher( "DC_kwDOComment456", githubv4mock.DataResponse(map[string]any{ "node": map[string]any{ "id": "DC_kwDOComment456", "discussion": map[string]any{ "id": "D_kwDOOtherDiscussion456", }, }, }), ), discussionQueryMatcher, ), expectToolError: true, expectedErrMsg: `commentNodeID "DC_kwDOComment456" does not belong to discussion #1 in owner/repo`, }, { name: "reply: validation query failure", requestArgs: map[string]any{ "method": "reply", "owner": "owner", "repo": "repo", "discussionNumber": int32(1), "body": "This is a reply", "commentNodeID": "DC_kwDOComment456", }, mockedClient: githubv4mock.NewMockedHTTPClient( discussionCommentWriteReplyValidationQueryMatcher( "DC_kwDOComment456", githubv4mock.ErrorResponse("Could not resolve to a node with the global id of 'DC_kwDOComment456'."), ), ), expectToolError: true, expectedErrMsg: "failed to validate commentNodeID: Could not resolve to a node with the global id of 'DC_kwDOComment456'.", }, }) } func Test_DiscussionCommentWrite_Update(t *testing.T) { t.Parallel() runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ { name: "update: successful comment update", requestArgs: map[string]any{ "method": "update", "commentNodeID": "DC_kwDOComment456", "body": "Updated comment text", }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewMutationMatcher( struct { UpdateDiscussionComment struct { Comment struct { ID githubv4.ID URL githubv4.String `graphql:"url"` } } `graphql:"updateDiscussionComment(input: $input)"` }{}, githubv4.UpdateDiscussionCommentInput{ CommentID: githubv4.ID("DC_kwDOComment456"), Body: githubv4.String("Updated comment text"), }, nil, githubv4mock.DataResponse(map[string]any{ "updateDiscussionComment": map[string]any{ "comment": map[string]any{ "id": "DC_kwDOComment456", "url": "https://github.com/owner/repo/discussions/1#discussioncomment-456", }, }, }), ), ), expectedID: "DC_kwDOComment456", expectedURL: "https://github.com/owner/repo/discussions/1#discussioncomment-456", }, { name: "update: comment not found", requestArgs: map[string]any{ "method": "update", "commentNodeID": "DC_kwDOInvalid", "body": "Updated comment text", }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewMutationMatcher( struct { UpdateDiscussionComment struct { Comment struct { ID githubv4.ID URL githubv4.String `graphql:"url"` } } `graphql:"updateDiscussionComment(input: $input)"` }{}, githubv4.UpdateDiscussionCommentInput{ CommentID: githubv4.ID("DC_kwDOInvalid"), Body: githubv4.String("Updated comment text"), }, nil, githubv4mock.ErrorResponse("Could not resolve to a node with the global id of 'DC_kwDOInvalid'."), ), ), expectToolError: true, expectedErrMsg: "Could not resolve to a node with the global id of 'DC_kwDOInvalid'.", }, { name: "update: insufficient permissions", requestArgs: map[string]any{ "method": "update", "commentNodeID": "DC_kwDOComment456", "body": "Updated comment text", }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewMutationMatcher( struct { UpdateDiscussionComment struct { Comment struct { ID githubv4.ID URL githubv4.String `graphql:"url"` } } `graphql:"updateDiscussionComment(input: $input)"` }{}, githubv4.UpdateDiscussionCommentInput{ CommentID: githubv4.ID("DC_kwDOComment456"), Body: githubv4.String("Updated comment text"), }, nil, githubv4mock.ErrorResponse("insufficient permissions to update this discussion comment"), ), ), expectToolError: true, expectedErrMsg: "insufficient permissions to update this discussion comment", }, { name: "update: missing commentNodeID", requestArgs: map[string]any{ "method": "update", "body": "Updated comment text", }, mockedClient: githubv4mock.NewMockedHTTPClient(), expectToolError: true, expectedErrMsg: "missing required parameter: commentNodeID", }, { name: "update: whitespace-only commentNodeID is rejected", requestArgs: map[string]any{ "method": "update", "commentNodeID": " ", "body": "Updated comment text", }, mockedClient: githubv4mock.NewMockedHTTPClient(), expectToolError: true, expectedErrMsg: "commentNodeID cannot be blank", }, { name: "update: missing body", requestArgs: map[string]any{ "method": "update", "commentNodeID": "DC_kwDOComment456", }, mockedClient: githubv4mock.NewMockedHTTPClient(), expectToolError: true, expectedErrMsg: "missing required parameter: body", }, }) } func Test_DiscussionCommentWrite_Delete(t *testing.T) { t.Parallel() runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ { name: "delete: successful comment delete", requestArgs: map[string]any{ "method": "delete", "commentNodeID": "DC_kwDOComment456", }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewMutationMatcher( struct { DeleteDiscussionComment struct { Comment struct { ID githubv4.ID URL githubv4.String `graphql:"url"` } } `graphql:"deleteDiscussionComment(input: $input)"` }{}, githubv4.DeleteDiscussionCommentInput{ ID: githubv4.ID("DC_kwDOComment456"), }, nil, githubv4mock.DataResponse(map[string]any{ "deleteDiscussionComment": map[string]any{ "comment": map[string]any{ "id": "DC_kwDOComment456", "url": "https://github.com/owner/repo/discussions/1#discussioncomment-456", }, }, }), ), ), expectedID: "DC_kwDOComment456", expectedURL: "https://github.com/owner/repo/discussions/1#discussioncomment-456", }, { name: "delete: comment not found", requestArgs: map[string]any{ "method": "delete", "commentNodeID": "DC_kwDOInvalid", }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewMutationMatcher( struct { DeleteDiscussionComment struct { Comment struct { ID githubv4.ID URL githubv4.String `graphql:"url"` } } `graphql:"deleteDiscussionComment(input: $input)"` }{}, githubv4.DeleteDiscussionCommentInput{ ID: githubv4.ID("DC_kwDOInvalid"), }, nil, githubv4mock.ErrorResponse("Could not resolve to a node with the global id of 'DC_kwDOInvalid'."), ), ), expectToolError: true, expectedErrMsg: "Could not resolve to a node with the global id of 'DC_kwDOInvalid'.", }, { name: "delete: insufficient permissions", requestArgs: map[string]any{ "method": "delete", "commentNodeID": "DC_kwDOComment456", }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewMutationMatcher( struct { DeleteDiscussionComment struct { Comment struct { ID githubv4.ID URL githubv4.String `graphql:"url"` } } `graphql:"deleteDiscussionComment(input: $input)"` }{}, githubv4.DeleteDiscussionCommentInput{ ID: githubv4.ID("DC_kwDOComment456"), }, nil, githubv4mock.ErrorResponse("insufficient permissions to delete this discussion comment"), ), ), expectToolError: true, expectedErrMsg: "insufficient permissions to delete this discussion comment", }, { name: "delete: missing commentNodeID", requestArgs: map[string]any{ "method": "delete", }, mockedClient: githubv4mock.NewMockedHTTPClient(), expectToolError: true, expectedErrMsg: "missing required parameter: commentNodeID", }, }) } func Test_DiscussionCommentWrite_MarkAnswer(t *testing.T) { t.Parallel() runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ { name: "mark_answer: successful mark as answer", requestArgs: map[string]any{ "method": "mark_answer", "commentNodeID": "DC_kwDOComment456", }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewMutationMatcher( struct { MarkDiscussionCommentAsAnswer struct { Discussion struct { ID githubv4.ID URL githubv4.String `graphql:"url"` } } `graphql:"markDiscussionCommentAsAnswer(input: $input)"` }{}, githubv4.MarkDiscussionCommentAsAnswerInput{ ID: githubv4.ID("DC_kwDOComment456"), }, nil, githubv4mock.DataResponse(map[string]any{ "markDiscussionCommentAsAnswer": map[string]any{ "discussion": map[string]any{ "id": "D_kwDOTest123", "url": "https://github.com/owner/repo/discussions/1", }, }, }), ), ), expectedDiscussionID: "D_kwDOTest123", expectedDiscussionURL: "https://github.com/owner/repo/discussions/1", }, { name: "mark_answer: mutation failure", requestArgs: map[string]any{ "method": "mark_answer", "commentNodeID": "DC_kwDOComment456", }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewMutationMatcher( struct { MarkDiscussionCommentAsAnswer struct { Discussion struct { ID githubv4.ID URL githubv4.String `graphql:"url"` } } `graphql:"markDiscussionCommentAsAnswer(input: $input)"` }{}, githubv4.MarkDiscussionCommentAsAnswerInput{ ID: githubv4.ID("DC_kwDOComment456"), }, nil, githubv4mock.ErrorResponse("discussion is not a Q&A discussion"), ), ), expectToolError: true, expectedErrMsg: "discussion is not a Q&A discussion", }, { name: "mark_answer: whitespace-only commentNodeID is rejected", requestArgs: map[string]any{ "method": "mark_answer", "commentNodeID": " ", }, mockedClient: githubv4mock.NewMockedHTTPClient(), expectToolError: true, expectedErrMsg: "commentNodeID cannot be blank", }, }) } func Test_DiscussionCommentWrite_UnmarkAnswer(t *testing.T) { t.Parallel() runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ { name: "unmark_answer: successful unmark as answer", requestArgs: map[string]any{ "method": "unmark_answer", "commentNodeID": "DC_kwDOComment456", }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewMutationMatcher( struct { UnmarkDiscussionCommentAsAnswer struct { Discussion struct { ID githubv4.ID URL githubv4.String `graphql:"url"` } } `graphql:"unmarkDiscussionCommentAsAnswer(input: $input)"` }{}, githubv4.UnmarkDiscussionCommentAsAnswerInput{ ID: githubv4.ID("DC_kwDOComment456"), }, nil, githubv4mock.DataResponse(map[string]any{ "unmarkDiscussionCommentAsAnswer": map[string]any{ "discussion": map[string]any{ "id": "D_kwDOTest123", "url": "https://github.com/owner/repo/discussions/1", }, }, }), ), ), expectedDiscussionID: "D_kwDOTest123", expectedDiscussionURL: "https://github.com/owner/repo/discussions/1", }, { name: "unmark_answer: mutation failure", requestArgs: map[string]any{ "method": "unmark_answer", "commentNodeID": "DC_kwDOComment456", }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewMutationMatcher( struct { UnmarkDiscussionCommentAsAnswer struct { Discussion struct { ID githubv4.ID URL githubv4.String `graphql:"url"` } } `graphql:"unmarkDiscussionCommentAsAnswer(input: $input)"` }{}, githubv4.UnmarkDiscussionCommentAsAnswerInput{ ID: githubv4.ID("DC_kwDOComment456"), }, nil, githubv4mock.ErrorResponse("insufficient permissions"), ), ), expectToolError: true, expectedErrMsg: "insufficient permissions", }, }) } type discussionCommentWriteTestCase struct { name string requestArgs map[string]any mockedClient *http.Client expectToolError bool expectedErrMsg string expectedID string expectedURL string expectedDiscussionID string expectedDiscussionURL string } func runDiscussionCommentWriteTests(t *testing.T, tests []discussionCommentWriteTestCase) { t.Helper() toolDef := DiscussionCommentWrite(translations.NullTranslationHelper) for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { gqlClient := githubv4.NewClient(tc.mockedClient) deps := BaseDeps{GQLClient: gqlClient} handler := toolDef.Handler(deps) req := createMCPRequest(tc.requestArgs) res, err := handler(ContextWithDeps(context.Background(), deps), &req) require.NoError(t, err) text := getTextResult(t, res).Text if tc.expectToolError { require.True(t, res.IsError) assert.Contains(t, text, tc.expectedErrMsg) return } require.False(t, res.IsError) if tc.expectedDiscussionID != "" { var response struct { DiscussionID string `json:"discussionID"` DiscussionURL string `json:"discussionURL"` } require.NoError(t, json.Unmarshal([]byte(text), &response)) assert.Equal(t, tc.expectedDiscussionID, response.DiscussionID) assert.Equal(t, tc.expectedDiscussionURL, response.DiscussionURL) } else { var response MinimalResponse require.NoError(t, json.Unmarshal([]byte(text), &response)) assert.Equal(t, tc.expectedID, response.ID) assert.Equal(t, tc.expectedURL, response.URL) } }) } } func discussionCommentWriteDiscussionQueryMatcher(discussionNumber int32, response githubv4mock.GQLResponse) githubv4mock.Matcher { return githubv4mock.NewQueryMatcher( struct { Repository struct { Discussion struct { ID githubv4.ID } `graphql:"discussion(number: $discussionNumber)"` } `graphql:"repository(owner: $owner, name: $repo)"` }{}, map[string]any{ "owner": githubv4.String("owner"), "repo": githubv4.String("repo"), "discussionNumber": githubv4.Int(discussionNumber), }, response, ) } func discussionCommentWriteReplyValidationQueryMatcher(commentNodeID string, response githubv4mock.GQLResponse) githubv4mock.Matcher { return githubv4mock.NewQueryMatcher( struct { Node struct { DiscussionComment struct { ID *githubv4.ID Discussion struct { ID githubv4.ID } `graphql:"discussion"` } `graphql:"... on DiscussionComment"` } `graphql:"node(id: $replyToID)"` }{}, map[string]any{ "replyToID": githubv4.ID(commentNodeID), }, response, ) } func githubv4ptr(id githubv4.ID) *githubv4.ID { return &id } func Test_GetDiscussionCommentsWithReplies(t *testing.T) { t.Parallel() toolDef := GetDiscussionComments(translations.NullTranslationHelper) qWithReplies := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{id,body,isAnswer,replies(first: 100){nodes{id,body,isAnswer},totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" vars := map[string]any{ "owner": "owner", "repo": "repo", "discussionNumber": float64(1), "first": float64(30), "after": (*string)(nil), } mockResponse := githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "discussion": map[string]any{ "comments": map[string]any{ "nodes": []map[string]any{ { "id": "DC_id1", "body": "Top-level comment", "replies": map[string]any{ "nodes": []map[string]any{ {"id": "DC_reply1", "body": "Reply to first comment", "isAnswer": true}, }, "totalCount": 1, }, }, { "id": "DC_id2", "body": "Another top-level comment", "replies": map[string]any{ "nodes": []map[string]any{}, "totalCount": 0, }, }, }, "pageInfo": map[string]any{ "hasNextPage": false, "hasPreviousPage": false, "startCursor": "", "endCursor": "", }, "totalCount": 2, }, }, }, }) matcher := githubv4mock.NewQueryMatcher(qWithReplies, vars, mockResponse) httpClient := githubv4mock.NewMockedHTTPClient(matcher) gqlClient := githubv4.NewClient(httpClient) deps := BaseDeps{GQLClient: gqlClient} handler := toolDef.Handler(deps) reqParams := map[string]any{ "owner": "owner", "repo": "repo", "discussionNumber": int32(1), "includeReplies": true, } req := createMCPRequest(reqParams) res, err := handler(ContextWithDeps(context.Background(), deps), &req) require.NoError(t, err) text := getTextResult(t, res).Text require.False(t, res.IsError, "expected no error, got: %s", text) var response struct { Comments []MinimalDiscussionComment `json:"comments"` PageInfo struct { HasNextPage bool `json:"hasNextPage"` } `json:"pageInfo"` TotalCount int `json:"totalCount"` } require.NoError(t, json.Unmarshal([]byte(text), &response)) assert.Len(t, response.Comments, 2) assert.Equal(t, "DC_id1", response.Comments[0].ID) assert.Equal(t, "Top-level comment", response.Comments[0].Body) require.Len(t, response.Comments[0].Replies, 1) assert.Equal(t, "DC_reply1", response.Comments[0].Replies[0].ID) assert.Equal(t, "Reply to first comment", response.Comments[0].Replies[0].Body) assert.True(t, response.Comments[0].Replies[0].IsAnswer) assert.Equal(t, 1, response.Comments[0].ReplyTotalCount) assert.Equal(t, "DC_id2", response.Comments[1].ID) assert.Empty(t, response.Comments[1].Replies) assert.Equal(t, 0, response.Comments[1].ReplyTotalCount) }