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