Files
Sam Morrow 56bfeec0a9 Centralize sanitization of untrusted GitHub response fields
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>
2026-08-19 15:20:33 +02:00

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