401 lines
14 KiB
Go
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"])
|
|
}
|