package github import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "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/translations" "github.com/google/go-github/v82/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}} } 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} } responseBody, err := json.Marshal(map[string]any{ "data": map[string]any{ "viewer": map[string]any{ "login": "test-viewer", }, "repository": map[string]any{ "isPrivate": value.isPrivate, }, }, }) 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 := github.NewClient(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_AddIssueComment(t *testing.T) { // Verify tool definition once 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) 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, "body") assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "issue_number", "body"}) // Setup mock comment for success case mockComment := &github.IssueComment{ ID: github.Ptr(int64(123)), Body: github.Ptr("This is a test comment"), User: &github.User{ Login: github.Ptr("testuser"), }, HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42#issuecomment-123"), } tests := []struct { name string mockedClient *http.Client requestArgs map[string]any expectError bool expectedComment *github.IssueComment expectedErrMsg string }{ { name: "successful comment creation", 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 test comment", }, expectError: false, expectedComment: mockComment, }, { name: "comment creation fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposIssuesCommentsByOwnerByRepoByIssueNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnprocessableEntity) _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) }), }), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "issue_number": float64(42), "body": "", }, expectError: false, expectedErrMsg: "missing required parameter: body", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(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 contains minimal response var minimalResponse MinimalResponse err = json.Unmarshal([]byte(textContent.Text), &minimalResponse) require.NoError(t, err) assert.Equal(t, fmt.Sprintf("%d", tc.expectedComment.GetID()), minimalResponse.ID) assert.Equal(t, tc.expectedComment.GetHTMLURL(), minimalResponse.URL) }) } } func Test_SearchIssues(t *testing.T) { // Verify tool definition once serverTool := SearchIssues(translations.NullTranslationHelper) tool := serverTool.Tool require.NoError(t, toolsnaps.Test(tool.Name, tool)) 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.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: "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 := github.NewClient(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_CreateIssue(t *testing.T) { // Verify tool definition once 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, "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.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 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: "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", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) gqlClient := githubv4.NewClient(nil) 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_InsidersMode_UIGate verifies the insiders mode UI gate // behavior: UI clients get a form message, non-UI clients execute directly. func Test_IssueWrite_InsidersMode_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 := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposIssuesByOwnerByRepo: mockResponse(t, http.StatusCreated, mockIssue), })) deps := BaseDeps{ Client: client, GQLClient: githubv4.NewClient(nil), Flags: FeatureFlags{InsidersMode: true}, } 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, "Ready to create an issue") }) 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 skips form and executes directly", func(t *testing.T) { mockBaseIssue := &github.Issue{ Number: github.Ptr(1), Title: github.Ptr("Test"), State: github.Ptr("open"), HTMLURL: github.Ptr("https://github.com/owner/repo/issues/1"), } issueIDQueryResponse := githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "issue": map[string]any{ "id": "I_kwDOA0xdyM50BPaO", }, }, }) closeSuccessResponse := githubv4mock.DataResponse(map[string]any{ "closeIssue": map[string]any{ "issue": map[string]any{ "id": "I_kwDOA0xdyM50BPaO", "number": 1, "url": "https://github.com/owner/repo/issues/1", "state": "CLOSED", }, }, }) completedReason := IssueClosedStateReasonCompleted closeClient := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), })) closeGQLClient := githubv4.NewClient(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(1), }, issueIDQueryResponse, ), 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: &completedReason, }, nil, closeSuccessResponse, ), )) closeDeps := BaseDeps{ Client: closeClient, GQLClient: closeGQLClient, Flags: FeatureFlags{InsidersMode: true}, } closeHandler := serverTool.Handler(closeDeps) 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 := closeHandler(ContextWithDeps(context.Background(), closeDeps), &request) require.NoError(t, err) textContent := getTextResult(t, result) assert.NotContains(t, textContent.Text, "Ready to update issue", "state change should skip UI form") assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", "state change should execute directly and return issue URL") }) 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, "Ready to update issue #1", "update without state should show UI form") }) } func Test_ListIssues(t *testing.T) { // Verify tool definition serverTool := ListIssues(translations.NullTranslationHelper) tool := serverTool.Tool require.NoError(t, toolsnaps.Test(tool.Name, tool)) 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.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, }, }, { "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, }, }, } 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, }, }, } // 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, }, }, }) 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, }, }, }) 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, }, }, }) mockErrorRepoNotFound := githubv4mock.ErrorResponse("repository not found") // Variables matching what GraphQL receives after JSON marshaling/unmarshaling varsListAll := map[string]any{ "owner": "owner", "repo": "repo", "states": []any{"OPEN", "CLOSED"}, "orderBy": "CREATED_AT", "direction": "DESC", "first": float64(30), "after": (*string)(nil), } varsOpenOnly := map[string]any{ "owner": "owner", "repo": "repo", "states": []any{"OPEN"}, "orderBy": "CREATED_AT", "direction": "DESC", "first": float64(30), "after": (*string)(nil), } varsClosedOnly := map[string]any{ "owner": "owner", "repo": "repo", "states": []any{"CLOSED"}, "orderBy": "CREATED_AT", "direction": "DESC", "first": float64(30), "after": (*string)(nil), } 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), } varsRepoNotFound := map[string]any{ "owner": "owner", "repo": "nonexistent-repo", "states": []any{"OPEN", "CLOSED"}, "orderBy": "CREATED_AT", "direction": "DESC", "first": float64(30), "after": (*string)(nil), } 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 qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$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}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$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}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { var httpClient *http.Client switch tc.name { case "list all 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") } } }) } } 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.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: "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 := github.NewClient(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_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 := github.NewClient(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 := github.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) 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_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 := github.NewClient(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 := github.NewClient(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 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 := github.NewClient(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 := github.NewClient(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", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(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) } } }) } }