Files
github--github-mcp-server/pkg/github/issues_test.go
T
2026-07-13 12:37:57 +08:00

5277 lines
183 KiB
Go

package github
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/http/headers"
transportpkg "github.com/github/github-mcp-server/pkg/http/transport"
"github.com/github/github-mcp-server/pkg/inventory"
"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 defaultGQLClient *githubv4.Client = githubv4.NewClient(newRepoAccessHTTPClient())
type repoAccessKey struct {
owner string
repo string
}
type repoAccessValue struct {
isPrivate bool
}
type repoAccessMockTransport struct {
responses map[repoAccessKey]repoAccessValue
}
func newRepoAccessHTTPClient() *http.Client {
responses := map[repoAccessKey]repoAccessValue{
{owner: "owner2", repo: "repo2"}: {isPrivate: true},
{owner: "owner", repo: "repo"}: {isPrivate: false},
}
return &http.Client{Transport: &repoAccessMockTransport{responses: responses}}
}
const issueReadEnrichmentQueryString = "query($ids:[ID!]!){nodes(ids: $ids){... on Issue{id,issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}},parent{number,title,state,url,author{login},repository{nameWithOwner}},subIssuesSummary{total,completed,percentCompleted}}}}"
// newIssueReadEnrichmentMatcher builds a matcher for the issue_read `get` enrichment query for a
// single issue node ID.
func newIssueReadEnrichmentMatcher(nodeID string, response githubv4mock.GQLResponse) githubv4mock.Matcher {
return githubv4mock.NewQueryMatcher(
issueReadEnrichmentQueryString,
map[string]any{"ids": []any{nodeID}},
response,
)
}
func (rt *repoAccessMockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.Body == nil {
return nil, fmt.Errorf("missing request body")
}
var payload struct {
Query string `json:"query"`
Variables map[string]any `json:"variables"`
}
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
return nil, err
}
_ = req.Body.Close()
owner := toString(payload.Variables["owner"])
repo := toString(payload.Variables["name"])
value, ok := rt.responses[repoAccessKey{owner: owner, repo: repo}]
if !ok {
value = repoAccessValue{isPrivate: false}
}
data := map[string]any{}
if strings.Contains(payload.Query, "viewer") {
data["viewer"] = map[string]any{"login": "test-viewer"}
}
if strings.Contains(payload.Query, "repository") {
data["repository"] = map[string]any{"isPrivate": value.isPrivate}
}
responseBody, err := json.Marshal(map[string]any{"data": data})
if err != nil {
return nil, err
}
resp := &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewReader(responseBody)),
}
resp.Header.Set("Content-Type", "application/json")
return resp, nil
}
func toString(v any) string {
switch value := v.(type) {
case string:
return value
case fmt.Stringer:
return value.String()
case nil:
return ""
default:
return fmt.Sprintf("%v", value)
}
}
func Test_GetIssue(t *testing.T) {
// Verify tool definition once
serverTool := IssueRead(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "issue_read", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number"})
// Setup mock issue for success case
mockIssue := &github.Issue{
Number: github.Ptr(42),
Title: github.Ptr("Test Issue"),
Body: github.Ptr("This is a test issue"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
User: &github.User{
Login: github.Ptr("testuser"),
},
Repository: &github.Repository{
Name: github.Ptr("repo"),
Owner: &github.User{
Login: github.Ptr("owner"),
},
},
}
mockIssue2 := &github.Issue{
Number: github.Ptr(422),
Title: github.Ptr("Test Issue 2"),
Body: github.Ptr("This is a test issue 2"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
User: &github.User{
Login: github.Ptr("testuser2"),
},
Repository: &github.Repository{
Name: github.Ptr("repo2"),
Owner: &github.User{
Login: github.Ptr("owner2"),
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectHandlerError bool
expectResultError bool
expectedIssue *github.Issue
expectedErrMsg string
lockdownEnabled bool
restPermission string
}{
{
name: "successful issue retrieval",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue),
}),
requestArgs: map[string]any{
"method": "get",
"owner": "owner2",
"repo": "repo2",
"issue_number": float64(42),
},
expectedIssue: mockIssue,
},
{
name: "issue not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`),
}),
requestArgs: map[string]any{
"method": "get",
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
},
expectHandlerError: true,
expectedErrMsg: "failed to get issue",
},
{
name: "lockdown enabled - private repository",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue2),
}),
requestArgs: map[string]any{
"method": "get",
"owner": "owner2",
"repo": "repo2",
"issue_number": float64(422),
},
expectedIssue: mockIssue2,
lockdownEnabled: true,
restPermission: "none",
},
{
name: "lockdown enabled - user lacks push access",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue),
}),
requestArgs: map[string]any{
"method": "get",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectResultError: true,
expectedErrMsg: "access to issue details is restricted by lockdown mode",
lockdownEnabled: true,
restPermission: "read",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client := mustNewGHClient(t, tc.mockedClient)
var restClient *github.Client
if tc.restPermission != "" {
restClient = mockRESTPermissionServer(t, tc.restPermission, nil)
}
cache := stubRepoAccessCache(restClient, 15*time.Minute)
flags := stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled})
deps := BaseDeps{
Client: client,
GQLClient: defaultGQLClient,
RepoAccessCache: cache,
Flags: flags,
}
handler := serverTool.Handler(deps)
request := createMCPRequest(tc.requestArgs)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
if tc.expectHandlerError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
require.NoError(t, err)
require.NotNil(t, result)
if tc.expectResultError {
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
textContent := getTextResult(t, result)
var returnedIssue MinimalIssue
err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
require.NoError(t, err)
assert.Equal(t, tc.expectedIssue.GetNumber(), returnedIssue.Number)
assert.Equal(t, tc.expectedIssue.GetTitle(), returnedIssue.Title)
assert.Equal(t, tc.expectedIssue.GetBody(), returnedIssue.Body)
assert.Equal(t, tc.expectedIssue.GetState(), returnedIssue.State)
assert.Equal(t, tc.expectedIssue.GetHTMLURL(), returnedIssue.HTMLURL)
assert.Equal(t, tc.expectedIssue.GetUser().GetLogin(), returnedIssue.User.Login)
})
}
}
func Test_IssueRead_IFC_InsidersMode(t *testing.T) {
t.Parallel()
serverTool := IssueRead(translations.NullTranslationHelper)
mockIssue := &github.Issue{
Number: github.Ptr(1),
Title: github.Ptr("Test"),
Body: github.Ptr("body"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/octocat/repo/issues/1"),
User: &github.User{Login: github.Ptr("u")},
}
mockComments := []*github.IssueComment{
{Body: github.Ptr("hello"), User: &github.User{Login: github.Ptr("u")}},
}
makeMockClient := func(isPrivate bool, repoStatus int) *http.Client {
handlers := map[string]http.HandlerFunc{
GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue),
GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockComments),
}
if repoStatus != 0 && repoStatus != http.StatusOK {
handlers[GetReposByOwnerByRepo] = mockResponse(t, repoStatus, "boom")
} else {
handlers[GetReposByOwnerByRepo] = mockResponse(t, http.StatusOK, map[string]any{
"name": "repo",
"private": isPrivate,
})
}
return MockHTTPClientWithHandlers(handlers)
}
getReq := map[string]any{
"method": "get",
"owner": "octocat",
"repo": "repo",
"issue_number": float64(1),
}
commentsReq := map[string]any{
"method": "get_comments",
"owner": "octocat",
"repo": "repo",
"issue_number": float64(1),
}
t.Run("insiders mode disabled omits ifc label", func(t *testing.T) {
deps := BaseDeps{
Client: mustNewGHClient(t, makeMockClient(false, 0)),
}
handler := serverTool.Handler(deps)
request := createMCPRequest(getReq)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
assert.Nil(t, result.Meta)
})
t.Run("insiders mode enabled on public repo emits public untrusted", func(t *testing.T) {
deps := BaseDeps{
Client: mustNewGHClient(t, makeMockClient(false, 0)),
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := serverTool.Handler(deps)
request := createMCPRequest(getReq)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
require.NotNil(t, result.Meta)
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
assert.Equal(t, "untrusted", ifcMap["integrity"])
assert.Equal(t, "public", ifcMap["confidentiality"])
})
t.Run("insiders mode enabled on private repo with get_comments emits private trusted", func(t *testing.T) {
deps := BaseDeps{
Client: mustNewGHClient(t, makeMockClient(true, 0)),
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := serverTool.Handler(deps)
request := createMCPRequest(commentsReq)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
require.NotNil(t, result.Meta)
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
assert.Equal(t, "trusted", ifcMap["integrity"])
assert.Equal(t, "private", ifcMap["confidentiality"])
})
t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) {
deps := BaseDeps{
Client: mustNewGHClient(t, makeMockClient(false, http.StatusInternalServerError)),
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := serverTool.Handler(deps)
request := createMCPRequest(getReq)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, "tool call should still succeed when visibility lookup fails")
if result.Meta != nil {
_, hasIFC := result.Meta["ifc"]
assert.False(t, hasIFC, "ifc label should be omitted when visibility lookup fails")
}
})
}
func Test_GetIssue_FieldValues(t *testing.T) {
// The raw REST issue_field_values are always cleared. Enriched field_values are
// only populated via GraphQL when the issue has a node ID; this issue has none,
// so field_values stays empty.
serverTool := IssueRead(translations.NullTranslationHelper)
mockIssueWithFields := &github.Issue{
Number: github.Ptr(99),
Title: github.Ptr("Issue with field values"),
Body: github.Ptr("body"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/99"),
User: &github.User{
Login: github.Ptr("testuser"),
},
IssueFieldValues: []*github.IssueFieldValue{
{
IssueFieldID: 1001,
NodeID: "FV_node_1",
DataType: "single_select",
Value: "High",
SingleSelectOption: &github.IssueFieldValueSingleSelectOption{
ID: 42,
Name: "High",
Color: "red",
},
},
{
IssueFieldID: 1002,
NodeID: "FV_node_2",
DataType: "text",
Value: "some text value",
},
},
}
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssueWithFields),
})
cache := stubRepoAccessCache(nil, 15*time.Minute)
flags := stubFeatureFlags(map[string]bool{"lockdown-mode": false})
deps := BaseDeps{
Client: mustNewGHClient(t, mockedClient),
GQLClient: defaultGQLClient,
RepoAccessCache: cache,
Flags: flags,
}
handler := serverTool.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "get",
"owner": "owner",
"repo": "repo",
"issue_number": float64(99),
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.NotNil(t, result)
textContent := getTextResult(t, result)
var returnedIssue MinimalIssue
err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
require.NoError(t, err)
// Raw REST IssueFieldValues must be cleared, and no enriched field_values are
// present because this issue has no node ID.
assert.Empty(t, returnedIssue.IssueFieldValues, "raw REST issue_field_values should not be exposed")
assert.Empty(t, returnedIssue.FieldValues, "enriched field_values should not be present without a node ID")
}
func Test_GetIssue_FieldValues_Enriched(t *testing.T) {
// Verify the enriched field_values are populated via GraphQL when the issue has
// a node ID, and the raw REST issue_field_values stays cleared.
serverTool := IssueRead(translations.NullTranslationHelper)
mockIssueWithFields := &github.Issue{
Number: github.Ptr(99),
NodeID: github.Ptr("I_node_99"),
Title: github.Ptr("Issue with field values"),
Body: github.Ptr("body"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/99"),
User: &github.User{
Login: github.Ptr("testuser"),
},
IssueFieldValues: []*github.IssueFieldValue{
{
IssueFieldID: 1001,
NodeID: "FV_node_1",
DataType: "single_select",
Value: "High",
},
},
}
restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssueWithFields),
})
gqlResponse := githubv4mock.DataResponse(map[string]any{
"nodes": []map[string]any{
{
"id": "I_node_99",
"issueFieldValues": map[string]any{
"nodes": []map[string]any{
{
"__typename": "IssueFieldSingleSelectValue",
"field": map[string]any{"name": "priority"},
"value": "P1",
},
{
"__typename": "IssueFieldNumberValue",
"field": map[string]any{"name": "estimate"},
"valueNumber": 2.5,
},
},
},
"parent": nil,
"subIssuesSummary": map[string]any{"total": 0, "completed": 0, "percentCompleted": 0},
},
},
})
matcher := newIssueReadEnrichmentMatcher("I_node_99", gqlResponse)
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher))
cache := stubRepoAccessCache(nil, 15*time.Minute)
deps := BaseDeps{
Client: mustNewGHClient(t, restClient),
GQLClient: gqlClient,
RepoAccessCache: cache,
}
handler := serverTool.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "get",
"owner": "owner",
"repo": "repo",
"issue_number": float64(99),
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.NotNil(t, result)
require.False(t, result.IsError, "expected result to not be an error")
textContent := getTextResult(t, result)
var returnedIssue MinimalIssue
err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
require.NoError(t, err)
// Raw REST IssueFieldValues is always cleared.
assert.Empty(t, returnedIssue.IssueFieldValues, "raw REST issue_field_values should not be exposed")
// Enriched FieldValues comes from the GraphQL nodes() round-trip.
require.Len(t, returnedIssue.FieldValues, 2, "field_values should be populated from GraphQL")
assert.Equal(t, "priority", returnedIssue.FieldValues[0].Field)
assert.Equal(t, "P1", returnedIssue.FieldValues[0].Value)
assert.Equal(t, "estimate", returnedIssue.FieldValues[1].Field)
assert.Equal(t, "2.5", returnedIssue.FieldValues[1].Value)
// With no parent and no sub-issues, the routing booleans are explicit false and the
// optional relationship payloads are omitted.
assert.Equal(t, github.Ptr(false), returnedIssue.HasParent, "has_parent should be false without a parent")
assert.Equal(t, github.Ptr(false), returnedIssue.HasChildren, "has_children should be false without sub-issues")
assert.Nil(t, returnedIssue.Parent, "parent should be omitted when there is no parent")
assert.Nil(t, returnedIssue.SubIssuesSummary, "sub_issues_summary should be omitted with no sub-issues")
}
func Test_GetIssue_HierarchyEnrichment(t *testing.T) {
mockIssue := &github.Issue{
Number: github.Ptr(2990),
NodeID: github.Ptr("I_node_2990"),
Title: github.Ptr("Child issue"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/2990"),
User: &github.User{Login: github.Ptr("author")},
}
parentNode := map[string]any{
"number": 2820,
"title": "Parent issue",
"state": "OPEN",
"url": "https://github.com/owner/repo/issues/2820",
"author": map[string]any{"login": "parentauthor"},
"repository": map[string]any{
"nameWithOwner": "owner/repo",
},
}
tests := []struct {
name string
parent any
summary map[string]any
lockdown bool
assertResponse func(t *testing.T, issue MinimalIssue)
}{
{
name: "parent and children present",
parent: parentNode,
summary: map[string]any{"total": 4, "completed": 1, "percentCompleted": 25},
assertResponse: func(t *testing.T, issue MinimalIssue) {
assert.Equal(t, github.Ptr(true), issue.HasParent)
assert.Equal(t, github.Ptr(true), issue.HasChildren)
require.NotNil(t, issue.Parent)
assert.Equal(t, 2820, issue.Parent.Number)
assert.Equal(t, "Parent issue", issue.Parent.Title)
assert.Equal(t, "OPEN", issue.Parent.State)
assert.Equal(t, "owner/repo", issue.Parent.Repository)
require.NotNil(t, issue.SubIssuesSummary)
assert.Equal(t, 4, issue.SubIssuesSummary.Total)
assert.Equal(t, 1, issue.SubIssuesSummary.Completed)
assert.Equal(t, 25, issue.SubIssuesSummary.PercentCompleted)
},
},
{
name: "no parent omits parent and sets has_parent false",
parent: nil,
summary: map[string]any{"total": 0, "completed": 0, "percentCompleted": 0},
assertResponse: func(t *testing.T, issue MinimalIssue) {
assert.Equal(t, github.Ptr(false), issue.HasParent)
assert.Nil(t, issue.Parent)
},
},
{
name: "has_children is false when total is zero even with completed nonzero",
parent: nil,
summary: map[string]any{"total": 0, "completed": 1, "percentCompleted": 0},
assertResponse: func(t *testing.T, issue MinimalIssue) {
assert.Equal(t, github.Ptr(false), issue.HasChildren)
assert.Nil(t, issue.SubIssuesSummary)
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue),
})
gqlResponse := githubv4mock.DataResponse(map[string]any{
"nodes": []map[string]any{
{
"id": "I_node_2990",
"issueFieldValues": map[string]any{"nodes": []map[string]any{}},
"parent": tc.parent,
"subIssuesSummary": tc.summary,
},
},
})
matcher := newIssueReadEnrichmentMatcher("I_node_2990", gqlResponse)
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher))
deps := BaseDeps{
Client: mustNewGHClient(t, restClient),
GQLClient: gqlClient,
RepoAccessCache: stubRepoAccessCache(nil, 15*time.Minute),
Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdown}),
}
serverTool := IssueRead(translations.NullTranslationHelper)
handler := serverTool.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "get",
"owner": "owner",
"repo": "repo",
"issue_number": float64(2990),
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.NotNil(t, result)
require.False(t, result.IsError, "expected result to not be an error")
var returnedIssue MinimalIssue
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returnedIssue))
tc.assertResponse(t, returnedIssue)
})
}
}
func Test_GetIssue_HierarchyEnrichment_Lockdown(t *testing.T) {
mockIssue := &github.Issue{
Number: github.Ptr(2990),
NodeID: github.Ptr("I_node_2990"),
Title: github.Ptr("Child issue"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/2990"),
User: &github.User{Login: github.Ptr("author")},
}
parentNode := map[string]any{
"number": 2820,
"title": "Sensitive parent title",
"state": "OPEN",
"url": "https://github.com/owner/repo/issues/2820",
"author": map[string]any{"login": "parentauthor"},
"repository": map[string]any{
"nameWithOwner": "owner/repo",
},
}
// In lockdown mode the issue's own author must be verified as safe (mirrors the existing
// REST lockdown gate). The repo-access cache performs push-access checks against its own
// REST client: the issue author ("author") has write access, while the parent author
// ("parentauthor") only has read access and so cannot be verified as safe. The parent
// reference is therefore omitted entirely, while has_parent stays true so an agent can
// still route to get_parent.
restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue),
})
permClient := mockRESTPermissionServer(t, "read", map[string]string{"author": "write"})
gqlResponse := githubv4mock.DataResponse(map[string]any{
"nodes": []map[string]any{
{
"id": "I_node_2990",
"issueFieldValues": map[string]any{"nodes": []map[string]any{}},
"parent": parentNode,
"subIssuesSummary": map[string]any{"total": 0, "completed": 0, "percentCompleted": 0},
},
},
})
matcher := newIssueReadEnrichmentMatcher("I_node_2990", gqlResponse)
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher))
deps := BaseDeps{
Client: mustNewGHClient(t, restClient),
GQLClient: gqlClient,
RepoAccessCache: stubRepoAccessCache(permClient, 15*time.Minute),
Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": true}),
}
serverTool := IssueRead(translations.NullTranslationHelper)
handler := serverTool.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "get",
"owner": "owner",
"repo": "repo",
"issue_number": float64(2990),
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.NotNil(t, result)
require.False(t, result.IsError, "expected result to not be an error")
var returnedIssue MinimalIssue
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returnedIssue))
require.Nil(t, returnedIssue.Parent, "parent reference should be omitted under lockdown when it cannot be verified safe")
assert.Equal(t, github.Ptr(true), returnedIssue.HasParent, "has_parent should still be true so agents can route to get_parent")
}
func Test_GetIssue_HierarchyEnrichment_QueryFailureReturnsBaseIssue(t *testing.T) {
mockIssue := &github.Issue{
Number: github.Ptr(2990),
NodeID: github.Ptr("I_node_2990"),
Title: github.Ptr("Child issue"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/2990"),
User: &github.User{Login: github.Ptr("author")},
}
restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue),
})
matcher := newIssueReadEnrichmentMatcher("I_node_2990", githubv4mock.ErrorResponse("enrichment failed"))
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher))
deps := BaseDeps{
Client: mustNewGHClient(t, restClient),
GQLClient: gqlClient,
RepoAccessCache: stubRepoAccessCache(nil, 15*time.Minute),
Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}),
}
serverTool := IssueRead(translations.NullTranslationHelper)
handler := serverTool.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "get",
"owner": "owner",
"repo": "repo",
"issue_number": float64(2990),
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.NotNil(t, result)
// Relationship enrichment must never fail `get`: the base issue is still returned.
require.False(t, result.IsError, "enrichment failure should not fail get")
var returnedIssue MinimalIssue
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returnedIssue))
assert.Equal(t, 2990, returnedIssue.Number)
assert.Nil(t, returnedIssue.HasParent)
assert.Nil(t, returnedIssue.HasChildren)
assert.Nil(t, returnedIssue.Parent)
assert.Nil(t, returnedIssue.SubIssuesSummary)
}
func Test_SearchIssues(t *testing.T) {
// Verify tool definition once
serverTool := SearchIssues(translations.NullTranslationHelper)
tool := serverTool.Tool
// SearchIssues is the FeatureFlagFieldsParam-enabled variant; it owns the
// _ff_<flag> snapshot. The canonical search_issues.snap is owned by
// LegacySearchIssues (see Test_LegacySearchIssues_Definition).
require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagFieldsParam, tool))
require.Equal(t, FeatureFlagFieldsParam, serverTool.FeatureFlagEnable)
assert.Equal(t, "search_issues", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "query")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "sort")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "order")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "perPage")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "page")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "fields")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"query"})
// Setup mock search results
mockSearchResult := &github.IssuesSearchResult{
Total: github.Ptr(2),
IncompleteResults: github.Ptr(false),
Issues: []*github.Issue{
{
Number: github.Ptr(42),
Title: github.Ptr("Bug: Something is broken"),
Body: github.Ptr("This is a bug report"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
Comments: github.Ptr(5),
User: &github.User{
Login: github.Ptr("user1"),
},
},
{
Number: github.Ptr(43),
Title: github.Ptr("Feature: Add new functionality"),
Body: github.Ptr("This is a feature request"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/43"),
Comments: github.Ptr(3),
User: &github.User{
Login: github.Ptr("user2"),
},
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedResult *github.IssuesSearchResult
expectedErrMsg string
}{
{
name: "successful issues search with all parameters",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:issue repo:owner/repo is:open",
"sort": "created",
"order": "desc",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "repo:owner/repo is:open",
"sort": "created",
"order": "desc",
"page": float64(1),
"perPage": float64(30),
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "issues search with owner and repo parameters",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "repo:test-owner/test-repo is:issue is:open",
"sort": "created",
"order": "asc",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "is:open",
"owner": "test-owner",
"repo": "test-repo",
"sort": "created",
"order": "asc",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "issues search with only owner parameter (should ignore it)",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:issue bug",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "bug",
"owner": "test-owner",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "issues search with only repo parameter (should ignore it)",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:issue feature",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "feature",
"repo": "test-repo",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "issues search with minimal parameters",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult),
}),
requestArgs: map[string]any{
"query": "is:issue repo:owner/repo is:open",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "query with existing is:issue filter - no duplication",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "query with existing repo: filter and conflicting owner/repo params - uses query filter",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:issue repo:github/github-mcp-server critical",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "repo:github/github-mcp-server critical",
"owner": "different-owner",
"repo": "different-repo",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "query with both is: and repo: filters already present",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:issue repo:octocat/Hello-World bug",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "is:issue repo:octocat/Hello-World bug",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "complex query with multiple OR operators and existing filters",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "query with field. qualifier enables advanced_search",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:issue field.priority:P1",
"page": "1",
"per_page": "30",
"advanced_search": "true",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "field.priority:P1",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "query without field. qualifier does not set advanced_search",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:issue is:open",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "is:open",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "search issues fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"message": "Validation Failed"}`))
}),
}),
requestArgs: map[string]any{
"query": "invalid:query",
},
expectError: true,
expectedErrMsg: "failed to search issues",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.NoError(t, err) // No Go error, but result should be an error
require.NotNil(t, result)
require.True(t, result.IsError, "expected result to be an error")
textContent := getErrorResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
require.False(t, result.IsError, "expected result to not be an error")
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedResult github.IssuesSearchResult
err = json.Unmarshal([]byte(textContent.Text), &returnedResult)
require.NoError(t, err)
assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total)
assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults)
assert.Len(t, returnedResult.Issues, len(tc.expectedResult.Issues))
for i, issue := range returnedResult.Issues {
assert.Equal(t, *tc.expectedResult.Issues[i].Number, *issue.Number)
assert.Equal(t, *tc.expectedResult.Issues[i].Title, *issue.Title)
assert.Equal(t, *tc.expectedResult.Issues[i].State, *issue.State)
assert.Equal(t, *tc.expectedResult.Issues[i].HTMLURL, *issue.HTMLURL)
assert.Equal(t, *tc.expectedResult.Issues[i].User.Login, *issue.User.Login)
}
})
}
}
func Test_SearchIssues_IFC_InsidersMode(t *testing.T) {
t.Parallel()
serverTool := SearchIssues(translations.NullTranslationHelper)
makeIssue := func(owner, repo string, number int) *github.Issue {
return &github.Issue{
Number: github.Ptr(number),
Title: github.Ptr("issue"),
State: github.Ptr("open"),
RepositoryURL: github.Ptr("https://api.github.com/repos/" + owner + "/" + repo),
User: &github.User{Login: github.Ptr("u")},
}
}
type repoFixture struct {
owner string
repo string
isPrivate bool
repoStatus int
}
repoHandlers := func(repos []repoFixture) map[string]http.HandlerFunc {
repoByPath := map[string]repoFixture{}
for _, r := range repos {
repoByPath["/repos/"+r.owner+"/"+r.repo] = r
}
return map[string]http.HandlerFunc{
GetReposByOwnerByRepo: func(w http.ResponseWriter, req *http.Request) {
r, ok := repoByPath[req.URL.Path]
if !ok {
w.WriteHeader(http.StatusNotFound)
return
}
if r.repoStatus != 0 && r.repoStatus != http.StatusOK {
w.WriteHeader(r.repoStatus)
return
}
body, _ := json.Marshal(map[string]any{
"name": r.repo,
"private": r.isPrivate,
})
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
},
}
}
makeMockClient := func(searchResult *github.IssuesSearchResult, repos []repoFixture) *http.Client {
handlers := repoHandlers(repos)
handlers[GetSearchIssues] = mockResponse(t, http.StatusOK, searchResult)
return MockHTTPClientWithHandlers(handlers)
}
reqParams := map[string]any{"query": "bug"}
t.Run("insiders mode disabled omits ifc label", func(t *testing.T) {
searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "public-repo", 1)}}
deps := BaseDeps{
Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})),
}
handler := serverTool.Handler(deps)
request := createMCPRequest(reqParams)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
assert.Nil(t, result.Meta)
})
t.Run("insiders mode all public emits public untrusted", func(t *testing.T) {
searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "public-repo", 1)}}
deps := BaseDeps{
Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})),
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := serverTool.Handler(deps)
request := createMCPRequest(reqParams)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
require.NotNil(t, result.Meta)
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
assert.Equal(t, "untrusted", ifcMap["integrity"])
assert.Equal(t, "public", ifcMap["confidentiality"])
})
t.Run("insiders mode mixed public and private emits private untrusted", func(t *testing.T) {
searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{
makeIssue("octocat", "private-repo", 1),
makeIssue("octocat", "public-repo", 2),
}}
deps := BaseDeps{
Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{
{owner: "octocat", repo: "private-repo", isPrivate: true},
{owner: "octocat", repo: "public-repo"},
})),
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := serverTool.Handler(deps)
request := createMCPRequest(reqParams)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
require.NotNil(t, result.Meta)
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
assert.Equal(t, "untrusted", ifcMap["integrity"])
assert.Equal(t, "private", ifcMap["confidentiality"])
})
t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) {
searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "broken", 1)}}
deps := BaseDeps{
Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{
{owner: "octocat", repo: "broken", repoStatus: http.StatusInternalServerError},
})),
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := serverTool.Handler(deps)
request := createMCPRequest(reqParams)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, "tool call should still succeed when visibility lookup fails")
if result.Meta != nil {
_, hasIFC := result.Meta["ifc"]
assert.False(t, hasIFC, "ifc label should be omitted when visibility lookup fails")
}
})
t.Run("insiders mode empty results emits public untrusted", func(t *testing.T) {
searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{}}
deps := BaseDeps{
Client: mustNewGHClient(t, makeMockClient(searchResult, nil)),
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := serverTool.Handler(deps)
request := createMCPRequest(reqParams)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
require.NotNil(t, result.Meta)
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
assert.Equal(t, "untrusted", ifcMap["integrity"])
assert.Equal(t, "public", ifcMap["confidentiality"])
})
}
func unmarshalIFC(t *testing.T, ifcLabel any) map[string]any {
t.Helper()
require.NotNil(t, ifcLabel, "ifc label should be present")
ifcJSON, err := json.Marshal(ifcLabel)
require.NoError(t, err)
var ifcMap map[string]any
require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap))
return ifcMap
}
func Test_SearchIssues_FieldValuesEnrichment(t *testing.T) {
serverTool := SearchIssues(translations.NullTranslationHelper)
mockSearchResult := &github.IssuesSearchResult{
Total: github.Ptr(2),
IncompleteResults: github.Ptr(false),
Issues: []*github.Issue{
{
Number: github.Ptr(42),
Title: github.Ptr("Bug: Something is broken"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
NodeID: github.Ptr("I_node_42"),
User: &github.User{Login: github.Ptr("user1")},
},
{
Number: github.Ptr(43),
Title: github.Ptr("Feature request"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/43"),
NodeID: github.Ptr("I_node_43"),
User: &github.User{Login: github.Ptr("user2")},
},
},
}
restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult),
})
gqlVars := map[string]any{
"ids": []any{"I_node_42", "I_node_43"},
}
gqlResponse := githubv4mock.DataResponse(map[string]any{
"nodes": []map[string]any{
{
"id": "I_node_42",
"issueFieldValues": map[string]any{
"nodes": []map[string]any{
{
"__typename": "IssueFieldSingleSelectValue",
"field": map[string]any{"name": "priority"},
"value": "P1",
},
{
"__typename": "IssueFieldNumberValue",
"field": map[string]any{"name": "estimate"},
"valueNumber": 2.5,
},
},
},
},
{
"id": "I_node_43",
"issueFieldValues": map[string]any{
"nodes": []map[string]any{},
},
},
},
})
const nodesQueryString = "query($ids:[ID!]!){nodes(ids: $ids){... on Issue{id,issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}}}}"
matcher := githubv4mock.NewQueryMatcher(nodesQueryString, gqlVars, gqlResponse)
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher))
deps := BaseDeps{
Client: mustNewGHClient(t, restClient),
GQLClient: gqlClient,
}
handler := serverTool.Handler(deps)
request := createMCPRequest(map[string]any{
"query": "repo:owner/repo is:open",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError, "expected result to not be an error")
textContent := getTextResult(t, result)
var response SearchIssuesResponse
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &response))
require.Equal(t, 2, *response.Total)
require.Len(t, response.Items, 2)
assert.Equal(t, 42, *response.Items[0].Number)
assert.Equal(t, []MinimalFieldValue{
{Field: "priority", Value: "P1"},
{Field: "estimate", Value: "2.5"},
}, response.Items[0].FieldValues)
assert.Equal(t, 43, *response.Items[1].Number)
assert.Empty(t, response.Items[1].FieldValues)
}
func Test_CreateIssue(t *testing.T) {
// Verify tool definition once
serverTool := IssueWrite(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
require.Empty(t, serverTool.FeatureFlagEnable)
assert.Equal(t, "issue_write", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "title")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "body")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "assignees")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "labels")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "milestone")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "type")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_fields")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo"})
// Setup mock issue for success case
mockIssue := &github.Issue{
Number: github.Ptr(123),
Title: github.Ptr("Test Issue"),
Body: github.Ptr("This is a test issue"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
Assignees: []*github.User{{Login: github.Ptr("user1")}, {Login: github.Ptr("user2")}},
Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("help wanted")}},
Milestone: &github.Milestone{Number: github.Ptr(5)},
Type: &github.IssueType{Name: github.Ptr("Bug")},
}
tests := []struct {
name string
mockedClient *http.Client
mockedGQLClient *http.Client
requestArgs map[string]any
expectError bool
expectedIssue *github.Issue
expectedErrMsg string
}{
{
name: "successful issue creation with all fields",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesByOwnerByRepo: expectRequestBody(t, map[string]any{
"title": "Test Issue",
"body": "This is a test issue",
"labels": []any{"bug", "help wanted"},
"assignees": []any{"user1", "user2"},
"milestone": float64(5),
"type": "Bug",
}).andThen(
mockResponse(t, http.StatusCreated, mockIssue),
),
}),
requestArgs: map[string]any{
"method": "create",
"owner": "owner",
"repo": "repo",
"title": "Test Issue",
"body": "This is a test issue",
"assignees": []any{"user1", "user2"},
"labels": []any{"bug", "help wanted"},
"milestone": float64(5),
"type": "Bug",
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "successful issue creation with minimal fields",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesByOwnerByRepo: mockResponse(t, http.StatusCreated, &github.Issue{
Number: github.Ptr(124),
Title: github.Ptr("Minimal Issue"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"),
State: github.Ptr("open"),
}),
}),
requestArgs: map[string]any{
"method": "create",
"owner": "owner",
"repo": "repo",
"title": "Minimal Issue",
"assignees": nil, // Expect no failure with nil optional value.
},
expectError: false,
expectedIssue: &github.Issue{
Number: github.Ptr(124),
Title: github.Ptr("Minimal Issue"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"),
State: github.Ptr("open"),
},
},
{
name: "successful issue creation with issue fields reconciled by names",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesByOwnerByRepo: expectRequestBody(t, map[string]any{
"title": "Issue with fields",
"body": "",
"labels": []any{},
"assignees": []any{},
"issue_field_values": []any{
map[string]any{"field_id": float64(101), "value": "P1"},
map[string]any{"field_id": float64(102), "value": "Acme"},
},
}).andThen(
mockResponse(t, http.StatusCreated, &github.Issue{
Number: github.Ptr(125),
Title: github.Ptr("Issue with fields"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/125"),
State: github.Ptr("open"),
}),
),
}),
mockedGQLClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
issueFieldWriteMetadataQuery{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issueFields": map[string]any{
"nodes": []any{
map[string]any{
"__typename": "IssueFieldSingleSelect",
"fullDatabaseId": "101",
"name": "Priority",
"dataType": "single_select",
"options": []any{
map[string]any{"fullDatabaseId": "9001", "name": "P1"},
},
},
map[string]any{
"__typename": "IssueFieldText",
"fullDatabaseId": "102",
"name": "Customer",
"dataType": "text",
},
},
},
},
}),
),
),
requestArgs: map[string]any{
"method": "create",
"owner": "owner",
"repo": "repo",
"title": "Issue with fields",
"issue_fields": []any{
map[string]any{"field_name": "Priority", "field_option_name": "P1"},
map[string]any{"field_name": "Customer", "value": "Acme"},
},
},
expectError: false,
expectedIssue: &github.Issue{
Number: github.Ptr(125),
Title: github.Ptr("Issue with fields"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/125"),
State: github.Ptr("open"),
},
},
{
name: "issue creation fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesByOwnerByRepo: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message": "Validation failed"}`))
}),
}),
requestArgs: map[string]any{
"method": "create",
"owner": "owner",
"repo": "repo",
"title": "",
},
expectError: false,
expectedErrMsg: "missing required parameter: title",
},
{
name: "issue_fields rejects both value and field_option_name",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"method": "create",
"owner": "owner",
"repo": "repo",
"title": "Invalid fields",
"issue_fields": []any{
map[string]any{"field_name": "Priority", "value": "P1", "field_option_name": "P1"},
},
},
expectError: false,
expectedErrMsg: "cannot specify both value and field_option_name",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
gqlHTTPClient := tc.mockedGQLClient
if gqlHTTPClient == nil {
gqlHTTPClient = githubv4mock.NewMockedHTTPClient()
}
gqlClient := githubv4.NewClient(gqlHTTPClient)
deps := BaseDeps{
Client: client,
GQLClient: gqlClient,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
if tc.expectedErrMsg != "" {
require.NotNil(t, result)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
textContent := getTextResult(t, result)
// Unmarshal and verify the minimal result
var returnedIssue MinimalResponse
err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
require.NoError(t, err)
assert.Equal(t, tc.expectedIssue.GetHTMLURL(), returnedIssue.URL)
})
}
}
// Test_IssueWrite_MCPAppsFeature_UIGate verifies the MCP Apps feature UI gate
// behavior: UI clients get a form message, non-UI clients execute directly.
func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) {
t.Parallel()
mockIssue := &github.Issue{
Number: github.Ptr(1),
Title: github.Ptr("Test"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/1"),
}
serverTool := IssueWrite(translations.NullTranslationHelper)
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesByOwnerByRepo: mockResponse(t, http.StatusCreated, mockIssue),
}))
deps := BaseDeps{
Client: client,
GQLClient: githubv4.NewClient(nil),
featureChecker: featureCheckerFor(MCPAppsFeatureFlag),
}
handler := serverTool.Handler(deps)
t.Run("UI client without _ui_submitted returns form message", func(t *testing.T) {
request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{
"method": "create",
"owner": "owner",
"repo": "repo",
"title": "Test",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, "interactive form has been shown to the user for creating a new issue")
assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success")
})
t.Run("UI client with _ui_submitted executes directly", func(t *testing.T) {
request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{
"method": "create",
"owner": "owner",
"repo": "repo",
"title": "Test",
"_ui_submitted": true,
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1",
"tool should return the created issue URL")
})
t.Run("non-UI client executes directly without _ui_submitted", func(t *testing.T) {
request := createMCPRequest(map[string]any{
"method": "create",
"owner": "owner",
"repo": "repo",
"title": "Test",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1",
"non-UI client should execute directly")
})
t.Run("UI client with state change routes through UI form", func(t *testing.T) {
// state/state_reason/duplicate_of are form params (the issue-write view
// renders close/reopen controls), so a call carrying them must go to
// the form rather than execute directly.
request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"state": "closed",
"state_reason": "completed",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, "interactive form has been shown to the user for editing issue #1",
"state change should route through UI form")
assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success")
})
t.Run("UI client update without state change returns form message", func(t *testing.T) {
request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"title": "New Title",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, "interactive form has been shown to the user for editing issue #1",
"update without state should show UI form")
assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success")
})
t.Run("UI client with issue_fields routes through UI form", func(t *testing.T) {
// issue_fields is now a form param (the issue-write view renders a
// per-field editor), so a call carrying it must go to the form rather
// than execute directly.
request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{
"method": "create",
"owner": "owner",
"repo": "repo",
"title": "Issue with fields",
"issue_fields": []any{
map[string]any{"field_name": "Priority", "field_option_name": "P1"},
},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, "interactive form has been shown to the user for creating a new issue",
"issue_fields should route through UI form")
assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success")
})
t.Run("UI client with labels routes through UI form", func(t *testing.T) {
// labels is now a form param (the issue-write view prefills and renders
// a label selector), so a call carrying them must route to the form
// rather than execute directly.
request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{
"method": "create",
"owner": "owner",
"repo": "repo",
"title": "Test",
"labels": []any{"bug"},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, "interactive form has been shown to the user for creating a new issue",
"labels should route through UI form")
assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success")
})
}
func Test_issueWriteHasNonFormParams(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args map[string]any
want bool
}{
{name: "no params", args: map[string]any{}, want: false},
{name: "only form params", args: map[string]any{"method": "create", "owner": "o", "repo": "r", "title": "t", "body": "b", "issue_number": float64(1), "_ui_submitted": true}, want: false},
{name: "labels present", args: map[string]any{"title": "t", "labels": []any{"bug"}}, want: false},
{name: "assignees present", args: map[string]any{"title": "t", "assignees": []any{"octocat"}}, want: false},
{name: "milestone present", args: map[string]any{"title": "t", "milestone": float64(2)}, want: false},
{name: "type present", args: map[string]any{"title": "t", "type": "Bug"}, want: false},
{name: "issue_fields present", args: map[string]any{"issue_fields": []any{map[string]any{"field_name": "Priority"}}}, want: false},
{name: "state present", args: map[string]any{"state": "closed"}, want: false},
{name: "state_reason present", args: map[string]any{"state_reason": "completed"}, want: false},
{name: "duplicate_of present", args: map[string]any{"duplicate_of": float64(7)}, want: false},
{name: "unknown non-schema param present", args: map[string]any{"title": "t", "not_a_real_param": "x"}, want: true},
{name: "nil value is ignored", args: map[string]any{"issue_fields": nil}, want: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tc.want, hasNonFormParams(tc.args, issueWriteFormParams))
})
}
}
// Test_issueWriteSchemaClassification fails when a schema property is added
// without classifying it as either form-resendable (issueWriteFormParams) or
// known-non-form (knownNonForm below). Without this guard, an unclassified
// property would silently flip UI gating: form-incompatible fields would
// stop tripping the safety-net bypass and the form would drop their values.
func Test_issueWriteSchemaClassification(t *testing.T) {
t.Parallel()
// Schema properties the MCP App form cannot represent — their presence
// must trigger the safety-net bypass via hasNonFormParams. The
// form currently collects every schema property, so this allowlist is
// empty; add a property here only if it is added to the schema without
// corresponding form support.
knownNonForm := map[string]struct{}{}
cases := []struct {
name string
tool inventory.ServerTool
}{
{name: "IssueWrite", tool: IssueWrite(translations.NullTranslationHelper)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
schema, ok := tc.tool.Tool.InputSchema.(*jsonschema.Schema)
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
for prop := range schema.Properties {
_, isForm := issueWriteFormParams[prop]
_, isNonForm := knownNonForm[prop]
assert.Falsef(t, isForm && isNonForm,
"property %q is classified as both form-resendable and non-form — pick one", prop)
assert.Truef(t, isForm || isNonForm,
"property %q in %s schema is unclassified — add it to issueWriteFormParams (pkg/github/issues.go) "+
"if the MCP App form can carry it on submit, otherwise add it to the knownNonForm allowlist in this test",
prop, tc.name)
}
})
}
}
func Test_ListIssues(t *testing.T) {
// Verify tool definition
serverTool := ListIssues(translations.NullTranslationHelper)
tool := serverTool.Tool
// ListIssues is the FeatureFlagFieldsParam-enabled variant; it owns the
// _ff_<flag> snapshot. The canonical list_issues.snap is owned by
// LegacyListIssues (see Test_LegacyListIssues_Definition).
require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagFieldsParam, tool))
require.Equal(t, FeatureFlagFieldsParam, serverTool.FeatureFlagEnable)
assert.Equal(t, "list_issues", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "state")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "labels")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "orderBy")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "direction")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "since")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "after")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "perPage")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "fields")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo"})
// Mock issues data
mockIssuesAll := []map[string]any{
{
"number": 123,
"title": "First Issue",
"body": "This is the first test issue",
"state": "OPEN",
"databaseId": 1001,
"createdAt": "2023-01-01T00:00:00Z",
"updatedAt": "2023-01-01T00:00:00Z",
"author": map[string]any{"login": "user1"},
"labels": map[string]any{
"nodes": []map[string]any{
{"name": "bug", "id": "label1", "description": "Bug label"},
},
},
"comments": map[string]any{
"totalCount": 5,
},
"issueFieldValues": map[string]any{
"nodes": []map[string]any{
{
"__typename": "IssueFieldSingleSelectValue",
"field": map[string]any{"name": "priority"},
"value": "P1",
},
},
},
},
{
"number": 456,
"title": "Second Issue",
"body": "This is the second test issue",
"state": "OPEN",
"databaseId": 1002,
"createdAt": "2023-02-01T00:00:00Z",
"updatedAt": "2023-02-01T00:00:00Z",
"author": map[string]any{"login": "user2"},
"labels": map[string]any{
"nodes": []map[string]any{
{"name": "enhancement", "id": "label2", "description": "Enhancement label"},
},
},
"comments": map[string]any{
"totalCount": 3,
},
"issueFieldValues": map[string]any{
"nodes": []map[string]any{
{
"__typename": "IssueFieldDateValue",
"field": map[string]any{"name": "due"},
"value": "2026-06-01",
},
{
"__typename": "IssueFieldNumberValue",
"field": map[string]any{"name": "estimate"},
"valueNumber": 2.5,
},
{
"__typename": "IssueFieldTextValue",
"field": map[string]any{"name": "notes"},
"value": "needs triage",
},
},
},
},
}
mockIssuesOpen := []map[string]any{mockIssuesAll[0], mockIssuesAll[1]}
mockIssuesClosed := []map[string]any{
{
"number": 789,
"title": "Closed Issue",
"body": "This is a closed issue",
"state": "CLOSED",
"databaseId": 1003,
"createdAt": "2023-03-01T00:00:00Z",
"updatedAt": "2023-03-01T00:00:00Z",
"author": map[string]any{"login": "user3"},
"labels": map[string]any{
"nodes": []map[string]any{},
},
"comments": map[string]any{
"totalCount": 1,
},
"issueFieldValues": map[string]any{
"nodes": []map[string]any{},
},
},
}
// Mock responses
mockResponseListAll := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issues": map[string]any{
"nodes": mockIssuesAll,
"pageInfo": map[string]any{
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "",
"endCursor": "",
},
"totalCount": 2,
},
"isPrivate": false,
},
})
mockResponseOpenOnly := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issues": map[string]any{
"nodes": mockIssuesOpen,
"pageInfo": map[string]any{
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "",
"endCursor": "",
},
"totalCount": 2,
},
"isPrivate": false,
},
})
mockResponseClosedOnly := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issues": map[string]any{
"nodes": mockIssuesClosed,
"pageInfo": map[string]any{
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "",
"endCursor": "",
},
"totalCount": 1,
},
"isPrivate": false,
},
})
mockErrorRepoNotFound := githubv4mock.ErrorResponse("repository not found")
// Variables matching what GraphQL receives after JSON marshaling/unmarshaling.
// issueFieldValues is always sent as an (empty by default) list because the query
// declares the variable unconditionally; the server treats an empty list as no filter.
varsListAll := map[string]any{
"owner": "owner",
"repo": "repo",
"states": []any{"OPEN", "CLOSED"},
"orderBy": "CREATED_AT",
"direction": "DESC",
"first": float64(30),
"after": (*string)(nil),
"issueFieldValues": []any{},
}
varsOpenOnly := map[string]any{
"owner": "owner",
"repo": "repo",
"states": []any{"OPEN"},
"orderBy": "CREATED_AT",
"direction": "DESC",
"first": float64(30),
"after": (*string)(nil),
"issueFieldValues": []any{},
}
varsClosedOnly := map[string]any{
"owner": "owner",
"repo": "repo",
"states": []any{"CLOSED"},
"orderBy": "CREATED_AT",
"direction": "DESC",
"first": float64(30),
"after": (*string)(nil),
"issueFieldValues": []any{},
}
varsWithLabels := map[string]any{
"owner": "owner",
"repo": "repo",
"states": []any{"OPEN", "CLOSED"},
"labels": []any{"bug", "enhancement"},
"orderBy": "CREATED_AT",
"direction": "DESC",
"first": float64(30),
"after": (*string)(nil),
"issueFieldValues": []any{},
}
varsRepoNotFound := map[string]any{
"owner": "owner",
"repo": "nonexistent-repo",
"states": []any{"OPEN", "CLOSED"},
"orderBy": "CREATED_AT",
"direction": "DESC",
"first": float64(30),
"after": (*string)(nil),
"issueFieldValues": []any{},
}
tests := []struct {
name string
reqParams map[string]any
expectError bool
errContains string
expectedCount int
}{
{
name: "list all issues",
reqParams: map[string]any{
"owner": "owner",
"repo": "repo",
},
expectError: false,
expectedCount: 2,
},
{
name: "filter by open state",
reqParams: map[string]any{
"owner": "owner",
"repo": "repo",
"state": "OPEN",
},
expectError: false,
expectedCount: 2,
},
{
name: "filter by open state - lc",
reqParams: map[string]any{
"owner": "owner",
"repo": "repo",
"state": "open",
},
expectError: false,
expectedCount: 2,
},
{
name: "filter by closed state",
reqParams: map[string]any{
"owner": "owner",
"repo": "repo",
"state": "CLOSED",
},
expectError: false,
expectedCount: 1,
},
{
name: "filter by labels",
reqParams: map[string]any{
"owner": "owner",
"repo": "repo",
"labels": []any{"bug", "enhancement"},
},
expectError: false,
expectedCount: 2,
},
{
name: "repository not found error",
reqParams: map[string]any{
"owner": "owner",
"repo": "nonexistent-repo",
},
expectError: true,
errContains: "repository not found",
},
}
// Define the actual query strings that match the implementation
issueFieldValuesSelection := "issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}"
qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}," + issueFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}"
qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}," + issueFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}"
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var httpClient *http.Client
switch tc.name {
case "list all issues":
matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsListAll, mockResponseListAll)
httpClient = githubv4mock.NewMockedHTTPClient(matcher)
case "filter by open state":
matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsOpenOnly, mockResponseOpenOnly)
httpClient = githubv4mock.NewMockedHTTPClient(matcher)
case "filter by open state - lc":
matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsOpenOnly, mockResponseOpenOnly)
httpClient = githubv4mock.NewMockedHTTPClient(matcher)
case "filter by closed state":
matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsClosedOnly, mockResponseClosedOnly)
httpClient = githubv4mock.NewMockedHTTPClient(matcher)
case "filter by labels":
matcher := githubv4mock.NewQueryMatcher(qWithLabels, varsWithLabels, mockResponseListAll)
httpClient = githubv4mock.NewMockedHTTPClient(matcher)
case "repository not found error":
matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsRepoNotFound, mockErrorRepoNotFound)
httpClient = githubv4mock.NewMockedHTTPClient(matcher)
}
gqlClient := githubv4.NewClient(httpClient)
deps := BaseDeps{
GQLClient: gqlClient,
}
handler := serverTool.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 MinimalIssuesResponse
err = json.Unmarshal([]byte(text), &response)
require.NoError(t, err)
assert.Len(t, response.Issues, tc.expectedCount, "Expected %d issues, got %d", tc.expectedCount, len(response.Issues))
// Verify pagination metadata
assert.Equal(t, tc.expectedCount, response.TotalCount)
assert.False(t, response.PageInfo.HasNextPage)
assert.False(t, response.PageInfo.HasPreviousPage)
// Verify that returned issues have expected structure
for _, issue := range response.Issues {
assert.NotZero(t, issue.Number, "Issue should have number")
assert.NotEmpty(t, issue.Title, "Issue should have title")
assert.NotEmpty(t, issue.State, "Issue should have state")
assert.NotEmpty(t, issue.CreatedAt, "Issue should have created_at")
assert.NotEmpty(t, issue.UpdatedAt, "Issue should have updated_at")
assert.NotNil(t, issue.User, "Issue should have user")
assert.NotEmpty(t, issue.User.Login, "Issue user should have login")
assert.Empty(t, issue.HTMLURL, "html_url should be empty (not populated by GraphQL fragment)")
// Labels should be flattened to name strings
for _, label := range issue.Labels {
assert.NotEmpty(t, label, "Label should be a non-empty string")
}
// Field values should be flattened to {field, value} pairs. Issue #123 has a
// SingleSelectValue; issue #456 exercises the Date/Number/Text branches
// (including float formatting); #789 has no field values.
switch issue.Number {
case 123:
assert.Equal(t, []MinimalFieldValue{{Field: "priority", Value: "P1"}}, issue.FieldValues)
case 456:
assert.Equal(t, []MinimalFieldValue{
{Field: "due", Value: "2026-06-01"},
{Field: "estimate", Value: "2.5"},
{Field: "notes", Value: "needs triage"},
}, issue.FieldValues)
default:
assert.Empty(t, issue.FieldValues)
}
}
})
}
}
func Test_ListIssues_FieldFilters(t *testing.T) {
t.Parallel()
serverTool := ListIssues(translations.NullTranslationHelper)
mockIssues := []map[string]any{
{
"number": 1,
"title": "An issue",
"body": "body",
"state": "OPEN",
"databaseId": 1,
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-01-01T00:00:00Z",
"author": map[string]any{"login": "user1"},
"labels": map[string]any{"nodes": []map[string]any{}},
"comments": map[string]any{"totalCount": 0},
},
}
pageInfo := map[string]any{
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "",
"endCursor": "",
}
response := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issues": map[string]any{
"nodes": mockIssues,
"pageInfo": pageInfo,
"totalCount": 1,
},
"isPrivate": false,
},
})
// Field-lookup matcher used by every subtest that supplies field_filters.
// The handler calls fetchIssueFields(owner, repo) before issuing the issues query.
fieldsResponse := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issueFields": map[string]any{
"nodes": []any{
map[string]any{
"__typename": "IssueFieldSingleSelect",
"id": "IFSS_1",
"name": "Priority",
"dataType": "SINGLE_SELECT",
"visibility": "ALL",
"options": []any{
map[string]any{"id": "OPT_P1", "name": "P1", "color": "red"},
map[string]any{"id": "OPT_P2", "name": "P2", "color": "yellow"},
},
},
map[string]any{
"__typename": "IssueFieldText",
"id": "IFT_1",
"name": "Notes",
"dataType": "TEXT",
"visibility": "ALL",
},
map[string]any{
"__typename": "IssueFieldNumber",
"id": "IFN_1",
"name": "Estimate",
"dataType": "NUMBER",
"visibility": "ALL",
},
map[string]any{
"__typename": "IssueFieldDate",
"id": "IFD_1",
"name": "Due",
"dataType": "DATE",
"visibility": "ALL",
},
},
},
},
})
fieldsMatcher := func() githubv4mock.Matcher {
return githubv4mock.NewQueryMatcher(
issueFieldsRepoQuery{},
map[string]any{
"owner": githubv4.String("owner"),
"name": githubv4.String("repo"),
},
fieldsResponse,
)
}
qNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount},issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}"
qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount},issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}"
baseVars := func() map[string]any {
return map[string]any{
"owner": "owner",
"repo": "repo",
"states": []any{"OPEN", "CLOSED"},
"orderBy": "CREATED_AT",
"direction": "DESC",
"first": float64(30),
"after": (*string)(nil),
}
}
t.Run("single select field filter", func(t *testing.T) {
vars := baseVars()
vars["issueFieldValues"] = []any{
map[string]any{"fieldName": "Priority", "singleSelectOptionValue": "P1"},
}
matcher := githubv4mock.NewQueryMatcher(qNoLabels, vars, response)
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher(), matcher))
deps := BaseDeps{GQLClient: gqlClient}
handler := serverTool.Handler(deps)
req := createMCPRequest(map[string]any{
"owner": "owner",
"repo": "repo",
"field_filters": []any{
map[string]any{"field_name": "Priority", "value": "P1"},
},
})
res, err := handler(ContextWithDeps(context.Background(), deps), &req)
require.NoError(t, err)
require.False(t, res.IsError, getTextResult(t, res).Text)
})
t.Run("text field filter combined with labels", func(t *testing.T) {
vars := baseVars()
vars["labels"] = []any{"bug"}
vars["issueFieldValues"] = []any{
map[string]any{"fieldName": "Notes", "textValue": "needs triage"},
}
matcher := githubv4mock.NewQueryMatcher(qWithLabels, vars, response)
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher(), matcher))
deps := BaseDeps{GQLClient: gqlClient}
handler := serverTool.Handler(deps)
req := createMCPRequest(map[string]any{
"owner": "owner",
"repo": "repo",
"labels": []any{"bug"},
"field_filters": []any{
map[string]any{"field_name": "Notes", "value": "needs triage"},
},
})
res, err := handler(ContextWithDeps(context.Background(), deps), &req)
require.NoError(t, err)
require.False(t, res.IsError, getTextResult(t, res).Text)
})
t.Run("number and date field filters", func(t *testing.T) {
vars := baseVars()
vars["issueFieldValues"] = []any{
map[string]any{"fieldName": "Estimate", "numberValue": float64(2.5)},
map[string]any{"fieldName": "Due", "dateValue": "2026-06-01"},
}
matcher := githubv4mock.NewQueryMatcher(qNoLabels, vars, response)
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher(), matcher))
deps := BaseDeps{GQLClient: gqlClient}
handler := serverTool.Handler(deps)
req := createMCPRequest(map[string]any{
"owner": "owner",
"repo": "repo",
"field_filters": []any{
map[string]any{"field_name": "Estimate", "value": "2.5"},
map[string]any{"field_name": "Due", "value": "2026-06-01"},
},
})
res, err := handler(ContextWithDeps(context.Background(), deps), &req)
require.NoError(t, err)
require.False(t, res.IsError, getTextResult(t, res).Text)
})
t.Run("number field accepts zero values", func(t *testing.T) {
for _, value := range []string{"0", "0.0"} {
t.Run(value, func(t *testing.T) {
vars := baseVars()
vars["issueFieldValues"] = []any{
map[string]any{"fieldName": "Estimate", "numberValue": float64(0)},
}
matcher := githubv4mock.NewQueryMatcher(qNoLabels, vars, response)
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher(), matcher))
deps := BaseDeps{GQLClient: gqlClient}
handler := serverTool.Handler(deps)
req := createMCPRequest(map[string]any{
"owner": "owner",
"repo": "repo",
"field_filters": []any{
map[string]any{"field_name": "Estimate", "value": value},
},
})
res, err := handler(ContextWithDeps(context.Background(), deps), &req)
require.NoError(t, err)
require.False(t, res.IsError, getTextResult(t, res).Text)
})
}
})
t.Run("validation error when value missing", func(t *testing.T) {
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(githubv4mock.NewQueryMatcher("", nil, response)))
deps := BaseDeps{GQLClient: gqlClient}
handler := serverTool.Handler(deps)
req := createMCPRequest(map[string]any{
"owner": "owner",
"repo": "repo",
"field_filters": []any{
map[string]any{"field_name": "Priority"},
},
})
res, err := handler(ContextWithDeps(context.Background(), deps), &req)
require.NoError(t, err)
require.True(t, res.IsError)
text := getTextResult(t, res).Text
assert.Contains(t, text, "field_filters entry")
assert.Contains(t, text, "Priority")
assert.Contains(t, text, "value")
})
t.Run("validation error when field_name missing", func(t *testing.T) {
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(githubv4mock.NewQueryMatcher("", nil, response)))
deps := BaseDeps{GQLClient: gqlClient}
handler := serverTool.Handler(deps)
req := createMCPRequest(map[string]any{
"owner": "owner",
"repo": "repo",
"field_filters": []any{
map[string]any{"value": "P1"},
},
})
res, err := handler(ContextWithDeps(context.Background(), deps), &req)
require.NoError(t, err)
require.True(t, res.IsError)
text := getTextResult(t, res).Text
assert.Contains(t, text, "field_filters entry")
assert.Contains(t, text, "field_name")
})
t.Run("error when field is unknown", func(t *testing.T) {
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher()))
deps := BaseDeps{GQLClient: gqlClient}
handler := serverTool.Handler(deps)
req := createMCPRequest(map[string]any{
"owner": "owner",
"repo": "repo",
"field_filters": []any{
map[string]any{"field_name": "NotARealField", "value": "x"},
},
})
res, err := handler(ContextWithDeps(context.Background(), deps), &req)
require.NoError(t, err)
require.True(t, res.IsError)
text := getTextResult(t, res).Text
assert.Contains(t, text, "unknown field")
assert.Contains(t, text, "Priority")
})
t.Run("error when single-select option is invalid", func(t *testing.T) {
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher()))
deps := BaseDeps{GQLClient: gqlClient}
handler := serverTool.Handler(deps)
req := createMCPRequest(map[string]any{
"owner": "owner",
"repo": "repo",
"field_filters": []any{
map[string]any{"field_name": "Priority", "value": "P9"},
},
})
res, err := handler(ContextWithDeps(context.Background(), deps), &req)
require.NoError(t, err)
require.True(t, res.IsError)
text := getTextResult(t, res).Text
assert.Contains(t, text, "not a valid option")
assert.Contains(t, text, "P1")
})
t.Run("error when number value is non-numeric", func(t *testing.T) {
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher()))
deps := BaseDeps{GQLClient: gqlClient}
handler := serverTool.Handler(deps)
req := createMCPRequest(map[string]any{
"owner": "owner",
"repo": "repo",
"field_filters": []any{
map[string]any{"field_name": "Estimate", "value": "not-a-number"},
},
})
res, err := handler(ContextWithDeps(context.Background(), deps), &req)
require.NoError(t, err)
require.True(t, res.IsError)
assert.Contains(t, getTextResult(t, res).Text, "not a valid number")
})
t.Run("error when date value is malformed", func(t *testing.T) {
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher()))
deps := BaseDeps{GQLClient: gqlClient}
handler := serverTool.Handler(deps)
req := createMCPRequest(map[string]any{
"owner": "owner",
"repo": "repo",
"field_filters": []any{
map[string]any{"field_name": "Due", "value": "06/01/2026"},
},
})
res, err := handler(ContextWithDeps(context.Background(), deps), &req)
require.NoError(t, err)
require.True(t, res.IsError)
assert.Contains(t, getTextResult(t, res).Text, "not a valid date")
})
// Query string fragments for the `since` variants. Built by string concatenation
// because they only differ from the base variants by the variable declaration and
// the filterBy clause.
qNoLabelsWithSince := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$since:DateTime!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since, issueFieldValues: $issueFieldValues})" + qNoLabels[len("query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues})"):]
qLabelsWithSince := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$since:DateTime!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since, issueFieldValues: $issueFieldValues})" + qWithLabels[len("query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues})"):]
t.Run("field filter with since", func(t *testing.T) {
vars := baseVars()
vars["since"] = "2026-01-01T00:00:00Z"
vars["issueFieldValues"] = []any{
map[string]any{"fieldName": "Priority", "singleSelectOptionValue": "P1"},
}
matcher := githubv4mock.NewQueryMatcher(qNoLabelsWithSince, vars, response)
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher(), matcher))
deps := BaseDeps{GQLClient: gqlClient}
handler := serverTool.Handler(deps)
req := createMCPRequest(map[string]any{
"owner": "owner",
"repo": "repo",
"since": "2026-01-01T00:00:00Z",
"field_filters": []any{
map[string]any{"field_name": "Priority", "value": "P1"},
},
})
res, err := handler(ContextWithDeps(context.Background(), deps), &req)
require.NoError(t, err)
require.False(t, res.IsError, getTextResult(t, res).Text)
})
t.Run("field filter with labels and since", func(t *testing.T) {
vars := baseVars()
vars["labels"] = []any{"bug"}
vars["since"] = "2026-01-01T00:00:00Z"
vars["issueFieldValues"] = []any{
map[string]any{"fieldName": "Priority", "singleSelectOptionValue": "P1"},
}
matcher := githubv4mock.NewQueryMatcher(qLabelsWithSince, vars, response)
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher(), matcher))
deps := BaseDeps{GQLClient: gqlClient}
handler := serverTool.Handler(deps)
req := createMCPRequest(map[string]any{
"owner": "owner",
"repo": "repo",
"labels": []any{"bug"},
"since": "2026-01-01T00:00:00Z",
"field_filters": []any{
map[string]any{"field_name": "Priority", "value": "P1"},
},
})
res, err := handler(ContextWithDeps(context.Background(), deps), &req)
require.NoError(t, err)
require.False(t, res.IsError, getTextResult(t, res).Text)
})
t.Run("sends GraphQL-Features: issue_fields, repo_issue_fields header", func(t *testing.T) {
vars := baseVars()
vars["issueFieldValues"] = []any{}
matcher := githubv4mock.NewQueryMatcher(qNoLabels, vars, response)
// Build a transport chain matching production: GraphQLFeaturesTransport
// wraps a header-capturing spy, which forwards to the mock's RoundTripper.
// This verifies the handler sets the issue_fields context value and the
// transport translates it into the outgoing header.
mockClient := githubv4mock.NewMockedHTTPClient(matcher)
spy := &headerCaptureTransport{inner: mockClient.Transport}
httpClient := &http.Client{
Transport: &transportpkg.GraphQLFeaturesTransport{Transport: spy},
}
gqlClient := githubv4.NewClient(httpClient)
deps := BaseDeps{GQLClient: gqlClient}
handler := serverTool.Handler(deps)
req := createMCPRequest(map[string]any{"owner": "owner", "repo": "repo"})
res, err := handler(ContextWithDeps(context.Background(), deps), &req)
require.NoError(t, err)
require.False(t, res.IsError, getTextResult(t, res).Text)
assert.Equal(t, "issue_fields, repo_issue_fields", spy.captured.Get(headers.GraphQLFeaturesHeader))
})
}
// headerCaptureTransport records the headers of the most recent request that passed
// through it before forwarding to the inner RoundTripper.
type headerCaptureTransport struct {
inner http.RoundTripper
captured http.Header
}
func (t *headerCaptureTransport) RoundTrip(req *http.Request) (*http.Response, error) {
t.captured = req.Header.Clone()
return t.inner.RoundTrip(req)
}
func Test_ListIssues_IFC_InsidersMode(t *testing.T) {
t.Parallel()
serverTool := ListIssues(translations.NullTranslationHelper)
mockIssues := []map[string]any{
{
"number": 1,
"title": "An issue",
"body": "body",
"state": "OPEN",
"databaseId": 1,
"createdAt": "2023-01-01T00:00:00Z",
"updatedAt": "2023-01-01T00:00:00Z",
"author": map[string]any{"login": "user1"},
"labels": map[string]any{"nodes": []map[string]any{}},
"comments": map[string]any{"totalCount": 0},
},
}
pageInfo := map[string]any{
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "",
"endCursor": "",
}
makeResponse := func(isPrivate bool) githubv4mock.GQLResponse {
return githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issues": map[string]any{
"nodes": mockIssues,
"pageInfo": pageInfo,
"totalCount": 1,
},
"isPrivate": isPrivate,
},
})
}
query := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount},issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}"
vars := map[string]any{
"owner": "octocat",
"repo": "hello",
"states": []any{"OPEN", "CLOSED"},
"orderBy": "CREATED_AT",
"direction": "DESC",
"first": float64(30),
"after": (*string)(nil),
"issueFieldValues": []any{},
}
reqParams := map[string]any{"owner": "octocat", "repo": "hello"}
t.Run("insiders mode disabled omits ifc label from result meta", func(t *testing.T) {
matcher := githubv4mock.NewQueryMatcher(query, vars, makeResponse(false))
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher))
deps := BaseDeps{
GQLClient: gqlClient,
}
handler := serverTool.Handler(deps)
request := createMCPRequest(reqParams)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
assert.Nil(t, result.Meta, "result meta should be nil when insiders mode is disabled")
})
t.Run("insiders mode enabled on public repo emits public untrusted label", func(t *testing.T) {
matcher := githubv4mock.NewQueryMatcher(query, vars, makeResponse(false))
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher))
deps := BaseDeps{
GQLClient: gqlClient,
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := serverTool.Handler(deps)
request := createMCPRequest(reqParams)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
require.NotNil(t, result.Meta)
ifcLabel, ok := result.Meta["ifc"]
require.True(t, ok, "result meta should contain ifc key")
ifcJSON, err := json.Marshal(ifcLabel)
require.NoError(t, err)
var ifcMap map[string]any
require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap))
assert.Equal(t, "untrusted", ifcMap["integrity"])
assert.Equal(t, "public", ifcMap["confidentiality"])
})
t.Run("insiders mode enabled on private repo emits private trusted label", func(t *testing.T) {
matcher := githubv4mock.NewQueryMatcher(query, vars, makeResponse(true))
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher))
deps := BaseDeps{
GQLClient: gqlClient,
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := serverTool.Handler(deps)
request := createMCPRequest(reqParams)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.False(t, result.IsError)
require.NotNil(t, result.Meta)
ifcLabel, ok := result.Meta["ifc"]
require.True(t, ok, "result meta should contain ifc key")
ifcJSON, err := json.Marshal(ifcLabel)
require.NoError(t, err)
var ifcMap map[string]any
require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap))
assert.Equal(t, "trusted", ifcMap["integrity"])
assert.Equal(t, "private", ifcMap["confidentiality"])
})
}
func Test_UpdateIssue(t *testing.T) {
// Verify tool definition
serverTool := IssueWrite(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "issue_write", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "title")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "body")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "labels")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "assignees")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "milestone")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "type")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "state")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "state_reason")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "duplicate_of")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_fields")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo"})
// Mock issues for reuse across test cases
mockBaseIssue := &github.Issue{
Number: github.Ptr(123),
Title: github.Ptr("Title"),
Body: github.Ptr("Description"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}},
Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}},
Milestone: &github.Milestone{Number: github.Ptr(5)},
Type: &github.IssueType{Name: github.Ptr("Bug")},
}
mockUpdatedIssue := &github.Issue{
Number: github.Ptr(123),
Title: github.Ptr("Updated Title"),
Body: github.Ptr("Updated Description"),
State: github.Ptr("closed"),
StateReason: github.Ptr("duplicate"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}},
Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}},
Milestone: &github.Milestone{Number: github.Ptr(5)},
Type: &github.IssueType{Name: github.Ptr("Bug")},
}
mockReopenedIssue := &github.Issue{
Number: github.Ptr(123),
Title: github.Ptr("Title"),
State: github.Ptr("open"),
StateReason: github.Ptr("reopened"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
}
// Mock GraphQL responses for reuse across test cases
issueIDQueryResponse := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"id": "I_kwDOA0xdyM50BPaO",
},
},
})
duplicateIssueIDQueryResponse := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"id": "I_kwDOA0xdyM50BPaO",
},
"duplicateIssue": map[string]any{
"id": "I_kwDOA0xdyM50BPbP",
},
},
})
closeSuccessResponse := githubv4mock.DataResponse(map[string]any{
"closeIssue": map[string]any{
"issue": map[string]any{
"id": "I_kwDOA0xdyM50BPaO",
"number": 123,
"url": "https://github.com/owner/repo/issues/123",
"state": "CLOSED",
},
},
})
reopenSuccessResponse := githubv4mock.DataResponse(map[string]any{
"reopenIssue": map[string]any{
"issue": map[string]any{
"id": "I_kwDOA0xdyM50BPaO",
"number": 123,
"url": "https://github.com/owner/repo/issues/123",
"state": "OPEN",
},
},
})
duplicateStateReason := IssueClosedStateReasonDuplicate
tests := []struct {
name string
mockedRESTClient *http.Client
mockedGQLClient *http.Client
requestArgs map[string]any
expectError bool
expectedIssue *github.Issue
expectedErrMsg string
}{
{
name: "partial update of non-state fields only",
mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{
"title": "Updated Title",
"body": "Updated Description",
}).andThen(
mockResponse(t, http.StatusOK, mockUpdatedIssue),
),
}),
mockedGQLClient: githubv4mock.NewMockedHTTPClient(),
requestArgs: map[string]any{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
"title": "Updated Title",
"body": "Updated Description",
},
expectError: false,
expectedIssue: mockUpdatedIssue,
},
{
name: "partial update clears labels and assignees",
mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{
"labels": []any{},
"assignees": []any{},
}).andThen(
mockResponse(t, http.StatusOK, &github.Issue{
Number: github.Ptr(123),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
}),
),
}),
mockedGQLClient: githubv4mock.NewMockedHTTPClient(),
requestArgs: map[string]any{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
"labels": []any{},
"assignees": []any{},
},
expectError: false,
expectedIssue: &github.Issue{
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
},
},
{
name: "partial update with issue fields reconciled by names",
mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{
"issue_field_values": []any{
map[string]any{"field_id": float64(101), "value": "P1"},
map[string]any{"field_id": float64(102), "value": "Acme"},
},
"title": "Updated Title",
}).andThen(
mockResponse(t, http.StatusOK, mockUpdatedIssue),
),
}),
mockedGQLClient: githubv4mock.NewMockedHTTPClient(
// fetch-and-merge: returns no existing fields so the incoming values are used as-is
githubv4mock.NewQueryMatcher(
"query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){issue(number: $number){issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}}}}",
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"number": githubv4.Int(123),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"issueFieldValues": map[string]any{
"nodes": []any{},
},
},
},
}),
),
githubv4mock.NewQueryMatcher(
issueFieldWriteMetadataQuery{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issueFields": map[string]any{
"nodes": []any{
map[string]any{
"__typename": "IssueFieldSingleSelect",
"fullDatabaseId": "101",
"name": "Priority",
"dataType": "single_select",
"options": []any{map[string]any{"fullDatabaseId": "9001", "name": "P1"}},
},
map[string]any{
"__typename": "IssueFieldText",
"fullDatabaseId": "102",
"name": "Customer",
"dataType": "text",
},
},
},
},
}),
),
),
requestArgs: map[string]any{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
"title": "Updated Title",
"issue_fields": []any{
map[string]any{"field_name": "Priority", "field_option_name": "P1"},
map[string]any{"field_name": "Customer", "value": "Acme"},
},
},
expectError: false,
expectedIssue: mockUpdatedIssue,
},
{
name: "issue not found when updating non-state fields only",
mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
}),
}),
mockedGQLClient: githubv4mock.NewMockedHTTPClient(),
requestArgs: map[string]any{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
"title": "Updated Title",
},
expectError: true,
expectedErrMsg: "failed to update issue",
},
{
name: "close issue as duplicate",
mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue),
}),
mockedGQLClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
ID githubv4.ID
} `graphql:"issue(number: $issueNumber)"`
DuplicateIssue struct {
ID githubv4.ID
} `graphql:"duplicateIssue: issue(number: $duplicateOf)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"issueNumber": githubv4.Int(123),
"duplicateOf": githubv4.Int(456),
},
duplicateIssueIDQueryResponse,
),
githubv4mock.NewMutationMatcher(
struct {
CloseIssue struct {
Issue struct {
ID githubv4.ID
Number githubv4.Int
URL githubv4.String
State githubv4.String
}
} `graphql:"closeIssue(input: $input)"`
}{},
CloseIssueInput{
IssueID: "I_kwDOA0xdyM50BPaO",
StateReason: &duplicateStateReason,
DuplicateIssueID: githubv4.NewID("I_kwDOA0xdyM50BPbP"),
},
nil,
closeSuccessResponse,
),
),
requestArgs: map[string]any{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
"state": "closed",
"state_reason": "duplicate",
"duplicate_of": float64(456),
},
expectError: false,
expectedIssue: mockUpdatedIssue,
},
{
name: "reopen issue",
mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue),
}),
mockedGQLClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
ID githubv4.ID
} `graphql:"issue(number: $issueNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"issueNumber": githubv4.Int(123),
},
issueIDQueryResponse,
),
githubv4mock.NewMutationMatcher(
struct {
ReopenIssue struct {
Issue struct {
ID githubv4.ID
Number githubv4.Int
URL githubv4.String
State githubv4.String
}
} `graphql:"reopenIssue(input: $input)"`
}{},
githubv4.ReopenIssueInput{
IssueID: "I_kwDOA0xdyM50BPaO",
},
nil,
reopenSuccessResponse,
),
),
requestArgs: map[string]any{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
"state": "open",
},
expectError: false,
expectedIssue: mockReopenedIssue,
},
{
name: "main issue not found when trying to close it",
mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue),
}),
mockedGQLClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
ID githubv4.ID
} `graphql:"issue(number: $issueNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"issueNumber": githubv4.Int(999),
},
githubv4mock.ErrorResponse("Could not resolve to an Issue with the number of 999."),
),
),
requestArgs: map[string]any{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
"state": "closed",
"state_reason": "not_planned",
},
expectError: true,
expectedErrMsg: "Failed to find issues",
},
{
name: "duplicate issue not found when closing as duplicate",
mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue),
}),
mockedGQLClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
ID githubv4.ID
} `graphql:"issue(number: $issueNumber)"`
DuplicateIssue struct {
ID githubv4.ID
} `graphql:"duplicateIssue: issue(number: $duplicateOf)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"issueNumber": githubv4.Int(123),
"duplicateOf": githubv4.Int(999),
},
githubv4mock.ErrorResponse("Could not resolve to an Issue with the number of 999."),
),
),
requestArgs: map[string]any{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
"state": "closed",
"state_reason": "duplicate",
"duplicate_of": float64(999),
},
expectError: true,
expectedErrMsg: "Failed to find issues",
},
{
name: "close as duplicate with combined non-state updates",
mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{
"title": "Updated Title",
"body": "Updated Description",
"labels": []any{"bug", "priority"},
"assignees": []any{"assignee1", "assignee2"},
"milestone": float64(5),
"type": "Bug",
}).andThen(
mockResponse(t, http.StatusOK, &github.Issue{
Number: github.Ptr(123),
Title: github.Ptr("Updated Title"),
Body: github.Ptr("Updated Description"),
Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}},
Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}},
Milestone: &github.Milestone{Number: github.Ptr(5)},
Type: &github.IssueType{Name: github.Ptr("Bug")},
State: github.Ptr("open"), // Still open after REST update
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
}),
),
}),
mockedGQLClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
ID githubv4.ID
} `graphql:"issue(number: $issueNumber)"`
DuplicateIssue struct {
ID githubv4.ID
} `graphql:"duplicateIssue: issue(number: $duplicateOf)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"issueNumber": githubv4.Int(123),
"duplicateOf": githubv4.Int(456),
},
duplicateIssueIDQueryResponse,
),
githubv4mock.NewMutationMatcher(
struct {
CloseIssue struct {
Issue struct {
ID githubv4.ID
Number githubv4.Int
URL githubv4.String
State githubv4.String
}
} `graphql:"closeIssue(input: $input)"`
}{},
CloseIssueInput{
IssueID: "I_kwDOA0xdyM50BPaO",
StateReason: &duplicateStateReason,
DuplicateIssueID: githubv4.NewID("I_kwDOA0xdyM50BPbP"),
},
nil,
closeSuccessResponse,
),
),
requestArgs: map[string]any{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
"title": "Updated Title",
"body": "Updated Description",
"labels": []any{"bug", "priority"},
"assignees": []any{"assignee1", "assignee2"},
"milestone": float64(5),
"type": "Bug",
"state": "closed",
"state_reason": "duplicate",
"duplicate_of": float64(456),
},
expectError: false,
expectedIssue: mockUpdatedIssue,
},
{
name: "duplicate_of without duplicate state_reason should fail",
mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
mockedGQLClient: githubv4mock.NewMockedHTTPClient(),
requestArgs: map[string]any{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
"state": "closed",
"state_reason": "completed",
"duplicate_of": float64(456),
},
expectError: true,
expectedErrMsg: "duplicate_of can only be used when state_reason is 'duplicate'",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup clients with mocks
restClient := mustNewGHClient(t, tc.mockedRESTClient)
gqlClient := githubv4.NewClient(tc.mockedGQLClient)
deps := BaseDeps{
Client: restClient,
GQLClient: gqlClient,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError || tc.expectedErrMsg != "" {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
if tc.expectedErrMsg != "" {
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
}
return
}
require.NoError(t, err)
if result.IsError {
t.Fatalf("Unexpected error result: %s", getErrorResult(t, result).Text)
}
require.False(t, result.IsError)
// Parse the result and get the text content
textContent := getTextResult(t, result)
// Unmarshal and verify the minimal result
var updateResp MinimalResponse
err = json.Unmarshal([]byte(textContent.Text), &updateResp)
require.NoError(t, err)
assert.Equal(t, tc.expectedIssue.GetHTMLURL(), updateResp.URL)
})
}
}
func Test_UpdateIssueClearsLabelsAndAssignees(t *testing.T) {
serverTool := IssueWrite(translations.NullTranslationHelper)
updatedIssue := &github.Issue{
Number: github.Ptr(8),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/8"),
}
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{
"labels": []any{},
"assignees": []any{},
}).andThen(mockResponse(t, http.StatusOK, updatedIssue)),
}))
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient())
deps := BaseDeps{
Client: client,
GQLClient: gqlClient,
}
handler := serverTool.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(8),
"labels": []any{},
"assignees": []any{},
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
if result.IsError {
t.Fatalf("Unexpected error result: %s", getErrorResult(t, result).Text)
}
textContent := getTextResult(t, result)
var updateResp MinimalResponse
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &updateResp))
assert.Equal(t, updatedIssue.GetHTMLURL(), updateResp.URL)
}
func Test_ParseISOTimestamp(t *testing.T) {
tests := []struct {
name string
input string
expectedErr bool
expectedTime time.Time
}{
{
name: "valid RFC3339 format",
input: "2023-01-15T14:30:00Z",
expectedErr: false,
expectedTime: time.Date(2023, 1, 15, 14, 30, 0, 0, time.UTC),
},
{
name: "valid date only format",
input: "2023-01-15",
expectedErr: false,
expectedTime: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC),
},
{
name: "empty timestamp",
input: "",
expectedErr: true,
},
{
name: "invalid format",
input: "15/01/2023",
expectedErr: true,
},
{
name: "invalid date",
input: "2023-13-45",
expectedErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
parsedTime, err := parseISOTimestamp(tc.input)
if tc.expectedErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expectedTime, parsedTime)
}
})
}
}
func Test_GetIssueComments(t *testing.T) {
// Verify tool definition once
serverTool := IssueRead(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "issue_read", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "page")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "perPage")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number"})
// Setup mock comments for success case
mockComments := []*github.IssueComment{
{
ID: github.Ptr(int64(123)),
Body: github.Ptr("This is the first comment"),
User: &github.User{
Login: github.Ptr("user1"),
},
CreatedAt: &github.Timestamp{Time: time.Now().Add(-time.Hour * 24)},
},
{
ID: github.Ptr(int64(456)),
Body: github.Ptr("This is the second comment"),
User: &github.User{
Login: github.Ptr("user2"),
},
CreatedAt: &github.Timestamp{Time: time.Now().Add(-time.Hour)},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedComments []*github.IssueComment
expectedErrMsg string
lockdownEnabled bool
}{
{
name: "successful comments retrieval",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockComments),
}),
requestArgs: map[string]any{
"method": "get_comments",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedComments: mockComments,
},
{
name: "successful comments retrieval with pagination",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesCommentsByOwnerByRepoByIssueNumber: expectQueryParams(t, map[string]string{
"page": "2",
"per_page": "10",
}).andThen(
mockResponse(t, http.StatusOK, mockComments),
),
}),
requestArgs: map[string]any{
"method": "get_comments",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"page": float64(2),
"perPage": float64(10),
},
expectError: false,
expectedComments: mockComments,
},
{
name: "issue not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`),
}),
requestArgs: map[string]any{
"method": "get_comments",
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
},
expectError: true,
expectedErrMsg: "failed to get issue comments",
},
{
name: "lockdown enabled filters comments without push access",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, []*github.IssueComment{
{
ID: github.Ptr(int64(789)),
Body: github.Ptr("Maintainer comment"),
User: &github.User{Login: github.Ptr("maintainer")},
},
{
ID: github.Ptr(int64(790)),
Body: github.Ptr("External user comment"),
User: &github.User{Login: github.Ptr("testuser")},
},
}),
}),
requestArgs: map[string]any{
"method": "get_comments",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedComments: []*github.IssueComment{
{
ID: github.Ptr(int64(789)),
Body: github.Ptr("Maintainer comment"),
User: &github.User{Login: github.Ptr("maintainer")},
},
},
lockdownEnabled: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
var restClient *github.Client
if tc.lockdownEnabled {
restClient = mockRESTPermissionServer(t, "read", map[string]string{
"maintainer": "write",
"testuser": "read",
})
}
cache := stubRepoAccessCache(restClient, 15*time.Minute)
flags := stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled})
deps := BaseDeps{
Client: client,
GQLClient: defaultGQLClient,
RepoAccessCache: cache,
Flags: flags,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
require.NoError(t, err)
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedComments []MinimalIssueComment
err = json.Unmarshal([]byte(textContent.Text), &returnedComments)
require.NoError(t, err)
assert.Equal(t, len(tc.expectedComments), len(returnedComments))
for i := range tc.expectedComments {
require.NotNil(t, tc.expectedComments[i].User)
require.NotNil(t, returnedComments[i].User)
assert.Equal(t, tc.expectedComments[i].GetID(), returnedComments[i].ID)
assert.Equal(t, tc.expectedComments[i].GetBody(), returnedComments[i].Body)
assert.Equal(t, tc.expectedComments[i].GetUser().GetLogin(), returnedComments[i].User.Login)
}
})
}
}
func Test_GetIssueLabels(t *testing.T) {
t.Parallel()
// Verify tool definition
serverTool := IssueRead(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "issue_read", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number"})
tests := []struct {
name string
requestArgs map[string]any
mockedClient *http.Client
expectToolError bool
expectedToolErrMsg string
}{
{
name: "successful issue labels listing",
requestArgs: map[string]any{
"method": "get_labels",
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
},
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
Labels struct {
Nodes []struct {
ID githubv4.ID
Name githubv4.String
Color githubv4.String
Description githubv4.String
}
TotalCount githubv4.Int
} `graphql:"labels(first: 100)"`
} `graphql:"issue(number: $issueNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"issueNumber": githubv4.Int(123),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"labels": map[string]any{
"nodes": []any{
map[string]any{
"id": githubv4.ID("label-1"),
"name": githubv4.String("bug"),
"color": githubv4.String("d73a4a"),
"description": githubv4.String("Something isn't working"),
},
},
"totalCount": githubv4.Int(1),
},
},
},
}),
),
),
expectToolError: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gqlClient := githubv4.NewClient(tc.mockedClient)
client := mustNewGHClient(t, nil)
deps := BaseDeps{
Client: client,
GQLClient: gqlClient,
RepoAccessCache: stubRepoAccessCache(nil, 15*time.Minute),
Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}),
}
handler := serverTool.Handler(deps)
request := createMCPRequest(tc.requestArgs)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
assert.NotNil(t, result)
if tc.expectToolError {
assert.True(t, result.IsError)
if tc.expectedToolErrMsg != "" {
textContent := getErrorResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
}
} else {
assert.False(t, result.IsError)
}
})
}
}
func Test_GetIssueParent(t *testing.T) {
t.Parallel()
serverTool := IssueRead(translations.NullTranslationHelper)
parentMatcherStruct := struct {
Repository struct {
Issue struct {
Parent *struct {
Number githubv4.Int
Title githubv4.String
State githubv4.String
URL githubv4.String
Author struct {
Login githubv4.String
}
Repository struct {
NameWithOwner githubv4.String
}
}
} `graphql:"issue(number: $issueNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{}
vars := map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"issueNumber": githubv4.Int(123),
}
parentResponse := func(authorLogin string) githubv4mock.GQLResponse {
return githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"parent": map[string]any{
"number": githubv4.Int(42),
"title": githubv4.String("Parent\u202e issue"),
"state": githubv4.String("OPEN"),
"url": githubv4.String("https://github.com/owner/repo/issues/42"),
"author": map[string]any{
"login": githubv4.String(authorLogin),
},
"repository": map[string]any{
"nameWithOwner": githubv4.String("owner/repo"),
},
},
},
},
})
}
tests := []struct {
name string
mockedClient *http.Client
lockdownEnabled bool
restOverrides map[string]string
expectToolError bool
expectedText string
}{
{
name: "issue has a parent",
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
parentMatcherStruct,
vars,
parentResponse("author"),
),
),
expectedText: `"title":"Parent issue"`,
},
{
name: "issue has no parent",
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
parentMatcherStruct,
vars,
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"parent": nil,
},
},
}),
),
),
expectedText: `"parent":null`,
},
{
name: "graphql error",
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
parentMatcherStruct,
vars,
githubv4mock.ErrorResponse("issue not found"),
),
),
expectToolError: true,
},
{
name: "lockdown enabled - parent author has push access",
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
parentMatcherStruct,
vars,
parentResponse("maintainer"),
),
),
lockdownEnabled: true,
restOverrides: map[string]string{"maintainer": "write"},
expectedText: `"title":"Parent issue"`,
},
{
name: "lockdown enabled - parent author lacks push access",
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
parentMatcherStruct,
vars,
parentResponse("externaluser"),
),
),
lockdownEnabled: true,
restOverrides: map[string]string{"externaluser": "read"},
expectedText: `"parent":null`,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gqlClient := githubv4.NewClient(tc.mockedClient)
client := mustNewGHClient(t, nil)
var restClient *github.Client
if tc.lockdownEnabled {
restClient = mockRESTPermissionServer(t, "read", tc.restOverrides)
}
deps := BaseDeps{
Client: client,
GQLClient: gqlClient,
RepoAccessCache: stubRepoAccessCache(restClient, 15*time.Minute),
Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}),
}
handler := serverTool.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "get_parent",
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.NotNil(t, result)
if tc.expectToolError {
assert.True(t, result.IsError)
return
}
assert.False(t, result.IsError)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedText)
})
}
}
func Test_AddSubIssue(t *testing.T) {
// Verify tool definition once
serverTool := SubIssueWrite(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "sub_issue_write", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "sub_issue_id")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "replace_parent")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number", "sub_issue_id"})
// Setup mock issue for success case (matches GitHub API response format)
mockIssue := &github.Issue{
Number: github.Ptr(42),
Title: github.Ptr("Parent Issue"),
Body: github.Ptr("This is the parent issue with a sub-issue"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
User: &github.User{
Login: github.Ptr("testuser"),
},
Labels: []*github.Label{
{
Name: github.Ptr("enhancement"),
Color: github.Ptr("84b6eb"),
Description: github.Ptr("New feature or request"),
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedIssue *github.Issue
expectedErrMsg string
}{
{
name: "successful sub-issue addition with all parameters",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockIssue),
}),
requestArgs: map[string]any{
"method": "add",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"replace_parent": true,
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "successful sub-issue addition with minimal parameters",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockIssue),
}),
requestArgs: map[string]any{
"method": "add",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(456),
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "successful sub-issue addition with replace_parent false",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockIssue),
}),
requestArgs: map[string]any{
"method": "add",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(789),
"replace_parent": false,
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "parent issue not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Parent issue not found"}`),
}),
requestArgs: map[string]any{
"method": "add",
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "failed to add sub-issue",
},
{
name: "sub-issue not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`),
}),
requestArgs: map[string]any{
"method": "add",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(999),
},
expectError: false,
expectedErrMsg: "failed to add sub-issue",
},
{
name: "validation failed - sub-issue cannot be parent of itself",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Sub-issue cannot be a parent of itself"}]}`),
}),
requestArgs: map[string]any{
"method": "add",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(42),
},
expectError: false,
expectedErrMsg: "failed to add sub-issue",
},
{
name: "insufficient permissions",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`),
}),
requestArgs: map[string]any{
"method": "add",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "failed to add sub-issue",
},
{
name: "missing required parameter owner",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"method": "add",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "missing required parameter: owner",
},
{
name: "missing required parameter sub_issue_id",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"method": "add",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedErrMsg: "missing required parameter: sub_issue_id",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
if tc.expectedErrMsg != "" {
require.NotNil(t, result)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedIssue github.Issue
err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
require.NoError(t, err)
assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number)
assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title)
assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body)
assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State)
assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL)
assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login)
})
}
}
func Test_GetSubIssues(t *testing.T) {
// Verify tool definition once
serverTool := IssueRead(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "issue_read", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "page")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "perPage")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number"})
// Setup mock sub-issues for success case
mockSubIssues := []*github.Issue{
{
Number: github.Ptr(123),
Title: github.Ptr("Sub-issue 1"),
Body: github.Ptr("This is the first sub-issue"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
User: &github.User{
Login: github.Ptr("user1"),
},
Labels: []*github.Label{
{
Name: github.Ptr("bug"),
Color: github.Ptr("d73a4a"),
Description: github.Ptr("Something isn't working"),
},
},
},
{
Number: github.Ptr(124),
Title: github.Ptr("Sub-issue 2"),
Body: github.Ptr("This is the second sub-issue"),
State: github.Ptr("closed"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"),
User: &github.User{
Login: github.Ptr("user2"),
},
Assignees: []*github.User{
{Login: github.Ptr("assignee1")},
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedSubIssues []*github.Issue
expectedErrMsg string
}{
{
name: "successful sub-issues listing with minimal parameters",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockSubIssues),
}),
requestArgs: map[string]any{
"method": "get_sub_issues",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedSubIssues: mockSubIssues,
},
{
name: "successful sub-issues listing with pagination",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: expectQueryParams(t, map[string]string{
"page": "2",
"per_page": "10",
}).andThen(
mockResponse(t, http.StatusOK, mockSubIssues),
),
}),
requestArgs: map[string]any{
"method": "get_sub_issues",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"page": float64(2),
"perPage": float64(10),
},
expectError: false,
expectedSubIssues: mockSubIssues,
},
{
name: "successful sub-issues listing with empty result",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, []*github.Issue{}),
}),
requestArgs: map[string]any{
"method": "get_sub_issues",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedSubIssues: []*github.Issue{},
},
{
name: "parent issue not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
}),
requestArgs: map[string]any{
"method": "get_sub_issues",
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
},
expectError: false,
expectedErrMsg: "failed to list sub-issues",
},
{
name: "repository not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
}),
requestArgs: map[string]any{
"method": "get_sub_issues",
"owner": "nonexistent",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedErrMsg: "failed to list sub-issues",
},
{
name: "sub-issues feature gone/deprecated",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusGone, `{"message": "This feature has been deprecated"}`),
}),
requestArgs: map[string]any{
"method": "get_sub_issues",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedErrMsg: "failed to list sub-issues",
},
{
name: "missing required parameter owner",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"method": "get_sub_issues",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedErrMsg: "missing required parameter: owner",
},
{
name: "missing required parameter issue_number",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"method": "get_sub_issues",
"owner": "owner",
"repo": "repo",
},
expectError: false,
expectedErrMsg: "missing required parameter: issue_number",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
gqlClient := githubv4.NewClient(nil)
deps := BaseDeps{
Client: client,
GQLClient: gqlClient,
RepoAccessCache: stubRepoAccessCache(nil, 15*time.Minute),
Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}),
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
if tc.expectedErrMsg != "" {
require.NotNil(t, result)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedSubIssues []*github.Issue
err = json.Unmarshal([]byte(textContent.Text), &returnedSubIssues)
require.NoError(t, err)
assert.Len(t, returnedSubIssues, len(tc.expectedSubIssues))
for i, subIssue := range returnedSubIssues {
if i < len(tc.expectedSubIssues) {
assert.Equal(t, *tc.expectedSubIssues[i].Number, *subIssue.Number)
assert.Equal(t, *tc.expectedSubIssues[i].Title, *subIssue.Title)
assert.Equal(t, *tc.expectedSubIssues[i].State, *subIssue.State)
assert.Equal(t, *tc.expectedSubIssues[i].HTMLURL, *subIssue.HTMLURL)
assert.Equal(t, *tc.expectedSubIssues[i].User.Login, *subIssue.User.Login)
if tc.expectedSubIssues[i].Body != nil {
assert.Equal(t, *tc.expectedSubIssues[i].Body, *subIssue.Body)
}
}
}
})
}
}
func TestAddIssueComment(t *testing.T) {
t.Parallel()
serverTool := AddIssueComment(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "add_issue_comment", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "issue_number")
assert.Contains(t, schema.Properties, "comment_id")
assert.Contains(t, schema.Properties, "body")
assert.Contains(t, schema.Properties, "reaction")
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "issue_number"})
mockComment := &github.IssueComment{
ID: github.Ptr(int64(456)),
Body: github.Ptr("This is a comment"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42#issuecomment-456"),
}
mockReaction := &github.Reaction{
ID: github.Ptr(int64(789)),
Content: github.Ptr("heart"),
}
mockIssueComment := &github.IssueComment{
ID: github.Ptr(int64(999)),
IssueURL: github.Ptr("https://api.github.com/repos/owner/repo/issues/42"),
}
commentCreatedAfterReactionFailure := &atomic.Bool{}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectToolError bool
expectedToolErrMsg string
unexpectedCall *atomic.Bool
}{
{
name: "successful comment on issue",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockComment),
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"body": "This is a comment",
},
},
{
name: "successful reaction to issue",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesReactionsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockReaction),
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"reaction": "heart",
},
},
{
name: "successful reaction to issue comment",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesCommentByOwnerByRepoByCommentID: mockResponse(t, http.StatusOK, mockIssueComment),
PostReposIssuesCommentsReactionsByOwnerByRepoByCommentID: mockResponse(t, http.StatusCreated, mockReaction),
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"comment_id": float64(999),
"reaction": "heart",
},
},
{
name: "issue comment reaction requires matching issue_number",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesCommentByOwnerByRepoByCommentID: mockResponse(t, http.StatusOK, &github.IssueComment{
ID: github.Ptr(int64(999)),
IssueURL: github.Ptr("https://api.github.com/repos/owner/repo/issues/43"),
}),
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"comment_id": float64(999),
"reaction": "heart",
},
expectToolError: true,
expectedToolErrMsg: "comment_id does not belong to issue_number 42",
},
{
name: "issue comment lookup fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesCommentByOwnerByRepoByCommentID: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
},
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"comment_id": float64(999),
"reaction": "heart",
},
expectToolError: true,
expectedToolErrMsg: "failed to get issue comment",
},
{
name: "successful comment and reaction to issue",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockComment),
PostReposIssuesReactionsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockReaction),
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"body": "This is a comment",
"reaction": "heart",
},
},
{
name: "missing body and reaction",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectToolError: true,
expectedToolErrMsg: "at least one of body or reaction is required",
},
{
name: "missing issue_number for reaction",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"reaction": "heart",
},
expectToolError: true,
expectedToolErrMsg: "missing required parameter: issue_number",
},
{
name: "missing issue_number for body",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"body": "This is a comment",
},
expectToolError: true,
expectedToolErrMsg: "missing required parameter: issue_number",
},
{
name: "comment_id without reaction",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"comment_id": float64(999),
},
expectToolError: true,
expectedToolErrMsg: "comment_id can only be provided when reaction is provided",
},
{
name: "negative comment_id",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"comment_id": float64(-1),
"reaction": "heart",
},
expectToolError: true,
expectedToolErrMsg: "comment_id must be greater than 0",
},
{
name: "comment_id with body",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"comment_id": float64(999),
"body": "This is a comment",
"reaction": "heart",
},
expectToolError: true,
expectedToolErrMsg: "comment_id cannot be combined with body",
},
{
name: "does not create comment when reaction fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesReactionsByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message": "server error"}`))
},
PostReposIssuesCommentsByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, _ *http.Request) {
commentCreatedAfterReactionFailure.Store(true)
w.WriteHeader(http.StatusCreated)
responseData, _ := json.Marshal(mockComment)
_, _ = w.Write(responseData)
},
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"body": "This is a comment",
"reaction": "heart",
},
expectToolError: true,
expectedToolErrMsg: "failed to add reaction to issue",
unexpectedCall: commentCreatedAfterReactionFailure,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
client := mustNewGHClient(t, tc.mockedClient)
deps := BaseDeps{Client: client}
handler := serverTool.Handler(deps)
request := createMCPRequest(tc.requestArgs)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
if tc.expectToolError {
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedToolErrMsg)
if tc.unexpectedCall != nil {
assert.False(t, tc.unexpectedCall.Load())
}
return
}
require.False(t, result.IsError)
textContent := getTextResult(t, result)
if _, ok := tc.requestArgs["body"]; ok {
assert.Contains(t, textContent.Text, "456")
}
if _, ok := tc.requestArgs["reaction"]; ok {
assert.Contains(t, textContent.Text, "789")
}
})
}
}
func Test_RemoveSubIssue(t *testing.T) {
// Verify tool definition once
serverTool := SubIssueWrite(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "sub_issue_write", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "sub_issue_id")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number", "sub_issue_id"})
// Setup mock issue for success case (matches GitHub API response format - the updated parent issue)
mockIssue := &github.Issue{
Number: github.Ptr(42),
Title: github.Ptr("Parent Issue"),
Body: github.Ptr("This is the parent issue after sub-issue removal"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
User: &github.User{
Login: github.Ptr("testuser"),
},
Labels: []*github.Label{
{
Name: github.Ptr("enhancement"),
Color: github.Ptr("84b6eb"),
Description: github.Ptr("New feature or request"),
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedIssue *github.Issue
expectedErrMsg string
}{
{
name: "successful sub-issue removal",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue),
}),
requestArgs: map[string]any{
"method": "remove",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "parent issue not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
}),
requestArgs: map[string]any{
"method": "remove",
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "failed to remove sub-issue",
},
{
name: "sub-issue not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`),
}),
requestArgs: map[string]any{
"method": "remove",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(999),
},
expectError: false,
expectedErrMsg: "failed to remove sub-issue",
},
{
name: "bad request - invalid sub_issue_id",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusBadRequest, `{"message": "Invalid sub_issue_id"}`),
}),
requestArgs: map[string]any{
"method": "remove",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(-1),
},
expectError: false,
expectedErrMsg: "failed to remove sub-issue",
},
{
name: "repository not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
}),
requestArgs: map[string]any{
"method": "remove",
"owner": "nonexistent",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "failed to remove sub-issue",
},
{
name: "insufficient permissions",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`),
}),
requestArgs: map[string]any{
"method": "remove",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "failed to remove sub-issue",
},
{
name: "missing required parameter owner",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"method": "remove",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "missing required parameter: owner",
},
{
name: "missing required parameter sub_issue_id",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"method": "remove",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedErrMsg: "missing required parameter: sub_issue_id",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
if tc.expectedErrMsg != "" {
require.NotNil(t, result)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedIssue github.Issue
err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
require.NoError(t, err)
assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number)
assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title)
assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body)
assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State)
assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL)
assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login)
})
}
}
func Test_ReprioritizeSubIssue(t *testing.T) {
// Verify tool definition once
serverTool := SubIssueWrite(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "sub_issue_write", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "sub_issue_id")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "after_id")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "before_id")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number", "sub_issue_id"})
// Setup mock issue for success case (matches GitHub API response format - the updated parent issue)
mockIssue := &github.Issue{
Number: github.Ptr(42),
Title: github.Ptr("Parent Issue"),
Body: github.Ptr("This is the parent issue with reprioritized sub-issues"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
User: &github.User{
Login: github.Ptr("testuser"),
},
Labels: []*github.Label{
{
Name: github.Ptr("enhancement"),
Color: github.Ptr("84b6eb"),
Description: github.Ptr("New feature or request"),
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedIssue *github.Issue
expectedErrMsg string
}{
{
name: "successful reprioritization with after_id",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue),
}),
requestArgs: map[string]any{
"method": "reprioritize",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"after_id": float64(456),
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "successful reprioritization with before_id",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue),
}),
requestArgs: map[string]any{
"method": "reprioritize",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"before_id": float64(789),
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "validation error - neither after_id nor before_id specified",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"method": "reprioritize",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "either after_id or before_id must be specified",
},
{
name: "validation error - both after_id and before_id specified",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"method": "reprioritize",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"after_id": float64(456),
"before_id": float64(789),
},
expectError: false,
expectedErrMsg: "only one of after_id or before_id should be specified, not both",
},
{
name: "parent issue not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
}),
requestArgs: map[string]any{
"method": "reprioritize",
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
"sub_issue_id": float64(123),
"after_id": float64(456),
},
expectError: false,
expectedErrMsg: "failed to reprioritize sub-issue",
},
{
name: "sub-issue not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`),
}),
requestArgs: map[string]any{
"method": "reprioritize",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(999),
"after_id": float64(456),
},
expectError: false,
expectedErrMsg: "failed to reprioritize sub-issue",
},
{
name: "validation failed - positioning sub-issue not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Positioning sub-issue not found"}]}`),
}),
requestArgs: map[string]any{
"method": "reprioritize",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"after_id": float64(999),
},
expectError: false,
expectedErrMsg: "failed to reprioritize sub-issue",
},
{
name: "insufficient permissions",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`),
}),
requestArgs: map[string]any{
"method": "reprioritize",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"after_id": float64(456),
},
expectError: false,
expectedErrMsg: "failed to reprioritize sub-issue",
},
{
name: "service unavailable",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusServiceUnavailable, `{"message": "Service Unavailable"}`),
}),
requestArgs: map[string]any{
"method": "reprioritize",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"before_id": float64(456),
},
expectError: false,
expectedErrMsg: "failed to reprioritize sub-issue",
},
{
name: "missing required parameter owner",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"method": "reprioritize",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"after_id": float64(456),
},
expectError: false,
expectedErrMsg: "missing required parameter: owner",
},
{
name: "missing required parameter sub_issue_id",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"method": "reprioritize",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"after_id": float64(456),
},
expectError: false,
expectedErrMsg: "missing required parameter: sub_issue_id",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
if tc.expectedErrMsg != "" {
require.NotNil(t, result)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedIssue github.Issue
err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
require.NoError(t, err)
assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number)
assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title)
assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body)
assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State)
assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL)
assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login)
})
}
}
func Test_ListIssueTypes(t *testing.T) {
// Verify tool definition once
serverTool := ListIssueTypes(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "list_issue_types", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner"})
// Setup mock issue types for success case
mockIssueTypes := []*github.IssueType{
{
ID: github.Ptr(int64(1)),
Name: github.Ptr("bug"),
Description: github.Ptr("Something isn't working"),
Color: github.Ptr("d73a4a"),
},
{
ID: github.Ptr(int64(2)),
Name: github.Ptr("feature"),
Description: github.Ptr("New feature or enhancement"),
Color: github.Ptr("a2eeef"),
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedIssueTypes []*github.IssueType
expectedErrMsg string
}{
{
name: "successful issue types retrieval",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /orgs/testorg/issue-types": mockResponse(t, http.StatusOK, mockIssueTypes),
}),
requestArgs: map[string]any{
"owner": "testorg",
},
expectError: false,
expectedIssueTypes: mockIssueTypes,
},
{
name: "organization not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /orgs/nonexistent/issue-types": mockResponse(t, http.StatusNotFound, `{"message": "Organization not found"}`),
}),
requestArgs: map[string]any{
"owner": "nonexistent",
},
expectError: true,
expectedErrMsg: "failed to list issue types",
},
{
name: "missing owner parameter",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /orgs/testorg/issue-types": mockResponse(t, http.StatusOK, mockIssueTypes),
}),
requestArgs: map[string]any{},
expectError: false, // This should be handled by parameter validation, error returned in result
expectedErrMsg: "missing required parameter: owner",
},
{
name: "successful repo issue types retrieval",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /repos/testorg/testrepo/issue-types": mockResponse(t, http.StatusOK, mockIssueTypes),
}),
requestArgs: map[string]any{
"owner": "testorg",
"repo": "testrepo",
},
expectError: false,
expectedIssueTypes: mockIssueTypes,
},
{
name: "repo not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /repos/testorg/nonexistent/issue-types": mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
}),
requestArgs: map[string]any{
"owner": "testorg",
"repo": "nonexistent",
},
expectError: true,
expectedErrMsg: "failed to list issue types",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
if err != nil {
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
// Check if error is returned as tool result error
require.NotNil(t, result)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
// Check if it's a parameter validation error (returned as tool result error)
if result != nil && result.IsError {
errorContent := getErrorResult(t, result)
if tc.expectedErrMsg != "" && strings.Contains(errorContent.Text, tc.expectedErrMsg) {
return // This is expected for parameter validation errors
}
}
require.NoError(t, err)
require.NotNil(t, result)
require.False(t, result.IsError)
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedIssueTypes []*github.IssueType
err = json.Unmarshal([]byte(textContent.Text), &returnedIssueTypes)
require.NoError(t, err)
if tc.expectedIssueTypes != nil {
require.Equal(t, len(tc.expectedIssueTypes), len(returnedIssueTypes))
for i, expected := range tc.expectedIssueTypes {
assert.Equal(t, *expected.Name, *returnedIssueTypes[i].Name)
assert.Equal(t, *expected.Description, *returnedIssueTypes[i].Description)
assert.Equal(t, *expected.Color, *returnedIssueTypes[i].Color)
assert.Equal(t, *expected.ID, *returnedIssueTypes[i].ID)
}
}
})
}
}