package github import ( "context" "encoding/json" "net/http" "strings" "testing" "time" "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v72/github" "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", "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", "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", "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", "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", "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}}, } mockResponseListAll = githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "discussions": map[string]any{"nodes": discussionsAll}, }, }) mockResponseListGeneral = githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "discussions": map[string]any{"nodes": discussionsGeneral}, }, }) mockErrorRepoNotFound = githubv4mock.ErrorResponse("repository not found") ) func Test_ListDiscussions(t *testing.T) { mockClient := githubv4.NewClient(nil) // Verify tool definition and schema toolDef, _ := ListDiscussions(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) assert.Equal(t, "list_discussions", toolDef.Name) assert.NotEmpty(t, toolDef.Description) assert.Contains(t, toolDef.InputSchema.Properties, "owner") assert.Contains(t, toolDef.InputSchema.Properties, "repo") assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo"}) // mock for the call to ListDiscussions without category filter var qDiscussions struct { Repository struct { Discussions struct { Nodes []struct { Number githubv4.Int Title githubv4.String CreatedAt githubv4.DateTime Category struct { Name githubv4.String } `graphql:"category"` URL githubv4.String `graphql:"url"` } } `graphql:"discussions(first: 100)"` } `graphql:"repository(owner: $owner, name: $repo)"` } // mock for the call to get discussions with category filter var qDiscussionsFiltered struct { Repository struct { Discussions struct { Nodes []struct { Number githubv4.Int Title githubv4.String CreatedAt githubv4.DateTime Category struct { Name githubv4.String } `graphql:"category"` URL githubv4.String `graphql:"url"` } } `graphql:"discussions(first: 100, categoryId: $categoryId)"` } `graphql:"repository(owner: $owner, name: $repo)"` } varsListAll := map[string]interface{}{ "owner": githubv4.String("owner"), "repo": githubv4.String("repo"), } varsRepoNotFound := map[string]interface{}{ "owner": githubv4.String("owner"), "repo": githubv4.String("nonexistent-repo"), } varsDiscussionsFiltered := map[string]interface{}{ "owner": githubv4.String("owner"), "repo": githubv4.String("repo"), "categoryId": githubv4.ID("DIC_kwDOABC123"), } tests := []struct { name string reqParams map[string]interface{} expectError bool errContains string expectedCount int }{ { name: "list all discussions without category filter", reqParams: map[string]interface{}{ "owner": "owner", "repo": "repo", }, expectError: false, expectedCount: 3, // All discussions }, { name: "filter by category ID", reqParams: map[string]interface{}{ "owner": "owner", "repo": "repo", "category": "DIC_kwDOABC123", }, expectError: false, expectedCount: 2, // Only General discussions (matching the category ID) }, { name: "repository not found error", reqParams: map[string]interface{}{ "owner": "owner", "repo": "nonexistent-repo", }, expectError: true, errContains: "repository not found", }, } 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": // Simple case - no category filter matcher := githubv4mock.NewQueryMatcher(qDiscussions, varsListAll, mockResponseListAll) httpClient = githubv4mock.NewMockedHTTPClient(matcher) case "filter by category ID": // Simple case - category filter using category ID directly matcher := githubv4mock.NewQueryMatcher(qDiscussionsFiltered, varsDiscussionsFiltered, mockResponseListGeneral) httpClient = githubv4mock.NewMockedHTTPClient(matcher) case "repository not found error": matcher := githubv4mock.NewQueryMatcher(qDiscussions, varsRepoNotFound, mockErrorRepoNotFound) httpClient = githubv4mock.NewMockedHTTPClient(matcher) } gqlClient := githubv4.NewClient(httpClient) _, handler := ListDiscussions(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) req := createMCPRequest(tc.reqParams) res, err := handler(context.Background(), 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 returnedDiscussions []*github.Issue err = json.Unmarshal([]byte(text), &returnedDiscussions) require.NoError(t, err) assert.Len(t, returnedDiscussions, tc.expectedCount, "Expected %d discussions, got %d", tc.expectedCount, len(returnedDiscussions)) // Verify that all returned discussions have a category label if filtered if _, hasCategory := tc.reqParams["category"]; hasCategory { for _, discussion := range returnedDiscussions { require.NotEmpty(t, discussion.Labels, "Discussion should have category label") assert.True(t, strings.HasPrefix(*discussion.Labels[0].Name, "category:"), "Discussion should have category label prefix") } } }) } } func Test_GetDiscussion(t *testing.T) { // Verify tool definition and schema toolDef, _ := GetDiscussion(nil, translations.NullTranslationHelper) assert.Equal(t, "get_discussion", toolDef.Name) assert.NotEmpty(t, toolDef.Description) assert.Contains(t, toolDef.InputSchema.Properties, "owner") assert.Contains(t, toolDef.InputSchema.Properties, "repo") assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber") assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"}) var q struct { Repository struct { Discussion struct { Number githubv4.Int Body githubv4.String State githubv4.String CreatedAt githubv4.DateTime URL githubv4.String `graphql:"url"` Category struct { Name githubv4.String } `graphql:"category"` } `graphql:"discussion(number: $discussionNumber)"` } `graphql:"repository(owner: $owner, name: $repo)"` } vars := map[string]interface{}{ "owner": githubv4.String("owner"), "repo": githubv4.String("repo"), "discussionNumber": githubv4.Int(1), } tests := []struct { name string response githubv4mock.GQLResponse expectError bool expected *github.Issue errContains string }{ { name: "successful retrieval", response: githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{"discussion": map[string]any{ "number": 1, "body": "This is a test discussion", "state": "open", "url": "https://github.com/owner/repo/discussions/1", "createdAt": "2025-04-25T12:00:00Z", "category": map[string]any{"name": "General"}, }}, }), expectError: false, expected: &github.Issue{ HTMLURL: github.Ptr("https://github.com/owner/repo/discussions/1"), Number: github.Ptr(1), Body: github.Ptr("This is a test discussion"), State: github.Ptr("open"), CreatedAt: &github.Timestamp{Time: time.Date(2025, 4, 25, 12, 0, 0, 0, time.UTC)}, Labels: []*github.Label{ { Name: github.Ptr("category:General"), }, }, }, }, { 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(q, vars, tc.response) httpClient := githubv4mock.NewMockedHTTPClient(matcher) gqlClient := githubv4.NewClient(httpClient) _, handler := GetDiscussion(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) req := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo", "discussionNumber": int32(1)}) res, err := handler(context.Background(), 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 github.Issue require.NoError(t, json.Unmarshal([]byte(text), &out)) assert.Equal(t, *tc.expected.HTMLURL, *out.HTMLURL) assert.Equal(t, *tc.expected.Number, *out.Number) assert.Equal(t, *tc.expected.Body, *out.Body) assert.Equal(t, *tc.expected.State, *out.State) // Check category label require.Len(t, out.Labels, 1) assert.Equal(t, *tc.expected.Labels[0].Name, *out.Labels[0].Name) }) } } func Test_GetDiscussionComments(t *testing.T) { // Verify tool definition and schema toolDef, _ := GetDiscussionComments(nil, translations.NullTranslationHelper) assert.Equal(t, "get_discussion_comments", toolDef.Name) assert.NotEmpty(t, toolDef.Description) assert.Contains(t, toolDef.InputSchema.Properties, "owner") assert.Contains(t, toolDef.InputSchema.Properties, "repo") assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber") assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"}) var q struct { Repository struct { Discussion struct { Comments struct { Nodes []struct { Body githubv4.String } } `graphql:"comments(first:100)"` } `graphql:"discussion(number: $discussionNumber)"` } `graphql:"repository(owner: $owner, name: $repo)"` } vars := map[string]interface{}{ "owner": githubv4.String("owner"), "repo": githubv4.String("repo"), "discussionNumber": githubv4.Int(1), } mockResponse := githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "discussion": map[string]any{ "comments": map[string]any{ "nodes": []map[string]any{ {"body": "This is the first comment"}, {"body": "This is the second comment"}, }, }, }, }, }) matcher := githubv4mock.NewQueryMatcher(q, vars, mockResponse) httpClient := githubv4mock.NewMockedHTTPClient(matcher) gqlClient := githubv4.NewClient(httpClient) _, handler := GetDiscussionComments(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) request := createMCPRequest(map[string]interface{}{ "owner": "owner", "repo": "repo", "discussionNumber": int32(1), }) result, err := handler(context.Background(), request) require.NoError(t, err) textContent := getTextResult(t, result) var returnedComments []*github.IssueComment err = json.Unmarshal([]byte(textContent.Text), &returnedComments) require.NoError(t, err) assert.Len(t, returnedComments, 2) expectedBodies := []string{"This is the first comment", "This is the second comment"} for i, comment := range returnedComments { assert.Equal(t, expectedBodies[i], *comment.Body) } } func Test_ListDiscussionCategories(t *testing.T) { var q struct { Repository struct { DiscussionCategories struct { Nodes []struct { ID githubv4.ID Name githubv4.String } } `graphql:"discussionCategories(first: 100)"` } `graphql:"repository(owner: $owner, name: $repo)"` } vars := map[string]interface{}{ "owner": githubv4.String("owner"), "repo": githubv4.String("repo"), } mockResp := 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"}, }, }, }, }) matcher := githubv4mock.NewQueryMatcher(q, vars, mockResp) httpClient := githubv4mock.NewMockedHTTPClient(matcher) gqlClient := githubv4.NewClient(httpClient) tool, handler := ListDiscussionCategories(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) assert.Equal(t, "list_discussion_categories", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) request := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo"}) result, err := handler(context.Background(), request) require.NoError(t, err) text := getTextResult(t, result).Text var categories []map[string]string require.NoError(t, json.Unmarshal([]byte(text), &categories)) assert.Len(t, categories, 2) assert.Equal(t, "123", categories[0]["id"]) assert.Equal(t, "CategoryOne", categories[0]["name"]) assert.Equal(t, "456", categories[1]["id"]) assert.Equal(t, "CategoryTwo", categories[1]["name"]) }