1825 lines
60 KiB
Go
1825 lines
60 KiB
Go
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)
|
|
}
|