Files
github--github-mcp-server/pkg/github/discussions_test.go
2025-07-02 15:09:04 +02:00

401 lines
14 KiB
Go

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"])
}