56bfeec0a9
Sanitization was previously applied ad hoc at a handful of tool call sites (GetIssue, GetPullRequest, ListPullRequests) rather than in the shared convertToMinimal* converters, so equivalent user-authored text returned by other tools (issue comments, PR reviews, review comments, releases, commit messages, discussions, project item titles) was returned unsanitized. - Apply sanitize.Sanitize inside the convertToMinimal* helpers in minimal_types.go for issue/PR titles and bodies, issue comments, PR reviews, review comments, releases, commit messages, and project item content titles. This is the single, shared conversion point used by nearly every read tool, so fixing it there covers get/list issues, pull requests, comments, reviews, review comments, releases, commits, and project items consistently. - Add a sanitizeIssueTitleAndBody helper and use it for the two response paths that marshal a raw *github.Issue directly instead of a Minimal* type: search_issues (SearchIssueResult.MarshalJSON) and search_pull_requests (searchHandler). - Sanitize discussion titles/bodies/comments (list_discussions, get_discussion, get_discussion_comments), which previously had no sanitization at all, via a new newMinimalDiscussionComment constructor and inline fixes. - Sanitize project status update bodies. - Remove the now-redundant scattered sanitize calls in GetIssue, GetPullRequest, and ListPullRequests now that the shared converters sanitize on their own. Patches, diffs, and raw file contents are intentionally left untouched to preserve fidelity. Adds table-driven regression tests covering every touched converter, the search_issues/search_pull_requests raw-passthrough paths, and a fidelity check that patches/diffs are not altered. Fixes #3106 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1849 lines
60 KiB
Go
1849 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",
|
|
},
|
|
{
|
|
name: "sanitizes malicious title and body",
|
|
response: githubv4mock.DataResponse(map[string]any{
|
|
"repository": map[string]any{"discussion": map[string]any{
|
|
"number": 1,
|
|
"title": maliciousText,
|
|
"body": maliciousText,
|
|
"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": sanitizedText,
|
|
"body": sanitizedText,
|
|
"url": "https://github.com/owner/repo/discussions/1",
|
|
"closed": false,
|
|
"isAnswered": false,
|
|
},
|
|
},
|
|
}
|
|
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)
|
|
}
|