package github import ( "context" "encoding/json" "fmt" "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/v74/github" "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func Test_GetIssue(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) tool, _ := GetIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "get_issue", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issue_number") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"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"), }, } tests := []struct { name string mockedClient *http.Client requestArgs map[string]interface{} expectError bool expectedIssue *github.Issue expectedErrMsg string }{ { name: "successful issue retrieval", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetReposIssuesByOwnerByRepoByIssueNumber, mockIssue, ), ), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", "issue_number": float64(42), }, expectError: false, expectedIssue: mockIssue, }, { name: "issue not found", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.GetReposIssuesByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), ), ), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", "issue_number": float64(999), }, expectError: true, expectedErrMsg: "failed to get issue", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) _, handler := GetIssue(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) // Call handler result, err := handler(context.Background(), 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 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_AddIssueComment(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) tool, _ := AddIssueComment(stubGetClientFn(mockClient), translations.NullTranslationHelper) 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.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issue_number") assert.Contains(t, tool.InputSchema.Properties, "body") assert.ElementsMatch(t, tool.InputSchema.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]interface{} expectError bool expectedComment *github.IssueComment expectedErrMsg string }{ { name: "successful comment creation", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PostReposIssuesCommentsByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusCreated, mockComment), ), ), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", "issue_number": float64(42), "body": "This is a test comment", }, expectError: false, expectedComment: mockComment, }, { name: "comment creation fails", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PostReposIssuesCommentsByOwnerByRepoByIssueNumber, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnprocessableEntity) _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) }), ), ), requestArgs: map[string]interface{}{ "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) _, handler := AddIssueComment(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) // Call handler result, err := handler(context.Background(), 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 returnedComment github.IssueComment err = json.Unmarshal([]byte(textContent.Text), &returnedComment) require.NoError(t, err) assert.Equal(t, *tc.expectedComment.ID, *returnedComment.ID) assert.Equal(t, *tc.expectedComment.Body, *returnedComment.Body) assert.Equal(t, *tc.expectedComment.User.Login, *returnedComment.User.Login) }) } } func Test_SearchIssues(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) tool, _ := SearchIssues(stubGetClientFn(mockClient), translations.NullTranslationHelper) 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.Properties, "query") assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "sort") assert.Contains(t, tool.InputSchema.Properties, "order") assert.Contains(t, tool.InputSchema.Properties, "perPage") assert.Contains(t, tool.InputSchema.Properties, "page") assert.ElementsMatch(t, tool.InputSchema.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]interface{} expectError bool expectedResult *github.IssuesSearchResult expectedErrMsg string }{ { name: "successful issues search with all parameters", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.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]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.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]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.GetSearchIssues, expectQueryParams( t, map[string]string{ "q": "is:issue bug", "page": "1", "per_page": "30", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), ), ), ), requestArgs: map[string]interface{}{ "query": "bug", "owner": "test-owner", }, expectError: false, expectedResult: mockSearchResult, }, { name: "issues search with only repo parameter (should ignore it)", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.GetSearchIssues, expectQueryParams( t, map[string]string{ "q": "is:issue feature", "page": "1", "per_page": "30", }, ).andThen( mockResponse(t, http.StatusOK, mockSearchResult), ), ), ), requestArgs: map[string]interface{}{ "query": "feature", "repo": "test-repo", }, expectError: false, expectedResult: mockSearchResult, }, { name: "issues search with minimal parameters", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetSearchIssues, mockSearchResult, ), ), requestArgs: map[string]interface{}{ "query": "is:issue repo:owner/repo is:open", }, expectError: false, expectedResult: mockSearchResult, }, { name: "query with existing is:issue filter - no duplication", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.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]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.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]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.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]interface{}{ "query": "is:issue repo:octocat/Hello-World bug", }, expectError: false, expectedResult: mockSearchResult, }, { name: "complex query with multiple OR operators and existing filters", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.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]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.GetSearchIssues, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) }), ), ), requestArgs: map[string]interface{}{ "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) _, handler := SearchIssues(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) // Call handler result, err := handler(context.Background(), request) // Verify results if tc.expectError { require.Error(t, err) assert.Contains(t, err.Error(), 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 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 mockClient := github.NewClient(nil) tool, _ := CreateIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "create_issue", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "title") assert.Contains(t, tool.InputSchema.Properties, "body") assert.Contains(t, tool.InputSchema.Properties, "assignees") assert.Contains(t, tool.InputSchema.Properties, "labels") assert.Contains(t, tool.InputSchema.Properties, "milestone") assert.Contains(t, tool.InputSchema.Properties, "type") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "title"}) // 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]interface{} expectError bool expectedIssue *github.Issue expectedErrMsg string }{ { name: "successful issue creation with all fields", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.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]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.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]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PostReposIssuesByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnprocessableEntity) _, _ = w.Write([]byte(`{"message": "Validation failed"}`)) }), ), ), requestArgs: map[string]interface{}{ "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) _, handler := CreateIssue(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) // Call handler result, err := handler(context.Background(), 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 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.State, *returnedIssue.State) assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) if tc.expectedIssue.Body != nil { assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) } if tc.expectedIssue.Type != nil { assert.Equal(t, *tc.expectedIssue.Type.Name, *returnedIssue.Type.Name) } // Check assignees if expected if len(tc.expectedIssue.Assignees) > 0 { assert.Equal(t, len(tc.expectedIssue.Assignees), len(returnedIssue.Assignees)) for i, assignee := range returnedIssue.Assignees { assert.Equal(t, *tc.expectedIssue.Assignees[i].Login, *assignee.Login) } } // Check labels if expected if len(tc.expectedIssue.Labels) > 0 { assert.Equal(t, len(tc.expectedIssue.Labels), len(returnedIssue.Labels)) for i, label := range returnedIssue.Labels { assert.Equal(t, *tc.expectedIssue.Labels[i].Name, *label.Name) } } }) } } func Test_ListIssues(t *testing.T) { // Verify tool definition mockClient := githubv4.NewClient(nil) tool, _ := ListIssues(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) 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.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "state") assert.Contains(t, tool.InputSchema.Properties, "labels") assert.Contains(t, tool.InputSchema.Properties, "orderBy") assert.Contains(t, tool.InputSchema.Properties, "direction") assert.Contains(t, tool.InputSchema.Properties, "since") assert.Contains(t, tool.InputSchema.Properties, "after") assert.Contains(t, tool.InputSchema.Properties, "perPage") assert.ElementsMatch(t, tool.InputSchema.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]interface{}{ "owner": "owner", "repo": "repo", "states": []interface{}{"OPEN", "CLOSED"}, "orderBy": "CREATED_AT", "direction": "DESC", "first": float64(30), "after": (*string)(nil), } varsOpenOnly := map[string]interface{}{ "owner": "owner", "repo": "repo", "states": []interface{}{"OPEN"}, "orderBy": "CREATED_AT", "direction": "DESC", "first": float64(30), "after": (*string)(nil), } varsClosedOnly := map[string]interface{}{ "owner": "owner", "repo": "repo", "states": []interface{}{"CLOSED"}, "orderBy": "CREATED_AT", "direction": "DESC", "first": float64(30), "after": (*string)(nil), } varsWithLabels := map[string]interface{}{ "owner": "owner", "repo": "repo", "states": []interface{}{"OPEN", "CLOSED"}, "labels": []interface{}{"bug", "enhancement"}, "orderBy": "CREATED_AT", "direction": "DESC", "first": float64(30), "after": (*string)(nil), } varsRepoNotFound := map[string]interface{}{ "owner": "owner", "repo": "nonexistent-repo", "states": []interface{}{"OPEN", "CLOSED"}, "orderBy": "CREATED_AT", "direction": "DESC", "first": float64(30), "after": (*string)(nil), } tests := []struct { name string reqParams map[string]interface{} expectError bool errContains string expectedCount int verifyOrder func(t *testing.T, issues []*github.Issue) }{ { name: "list all issues", reqParams: map[string]interface{}{ "owner": "owner", "repo": "repo", }, expectError: false, expectedCount: 2, }, { name: "filter by open state", reqParams: map[string]interface{}{ "owner": "owner", "repo": "repo", "state": "OPEN", }, expectError: false, expectedCount: 2, }, { name: "filter by closed state", reqParams: map[string]interface{}{ "owner": "owner", "repo": "repo", "state": "CLOSED", }, expectError: false, expectedCount: 1, }, { name: "filter by labels", reqParams: map[string]interface{}{ "owner": "owner", "repo": "repo", "labels": []any{"bug", "enhancement"}, }, expectError: false, expectedCount: 2, }, { name: "repository not found error", reqParams: map[string]interface{}{ "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 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) _, handler := ListIssues(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) req := createMCPRequest(tc.reqParams) res, err := handler(context.Background(), req) text := getTextResult(t, res).Text if tc.expectError { require.True(t, res.IsError) assert.Contains(t, text, tc.errContains) return } require.NoError(t, err) // Parse the structured response with pagination info var response struct { Issues []*github.Issue `json:"issues"` PageInfo struct { HasNextPage bool `json:"hasNextPage"` HasPreviousPage bool `json:"hasPreviousPage"` StartCursor string `json:"startCursor"` EndCursor string `json:"endCursor"` } `json:"pageInfo"` TotalCount int `json:"totalCount"` } err = json.Unmarshal([]byte(text), &response) require.NoError(t, err) assert.Len(t, response.Issues, tc.expectedCount, "Expected %d issues, got %d", tc.expectedCount, len(response.Issues)) // Verify order if verifyOrder function is provided if tc.verifyOrder != nil { tc.verifyOrder(t, response.Issues) } // Verify that returned issues have expected structure for _, issue := range response.Issues { assert.NotNil(t, issue.Number, "Issue should have number") assert.NotNil(t, issue.Title, "Issue should have title") assert.NotNil(t, issue.State, "Issue should have state") } }) } } func Test_UpdateIssue(t *testing.T) { // Verify tool definition mockClient := github.NewClient(nil) tool, _ := UpdateIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "update_issue", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issue_number") assert.Contains(t, tool.InputSchema.Properties, "title") assert.Contains(t, tool.InputSchema.Properties, "body") assert.Contains(t, tool.InputSchema.Properties, "state") assert.Contains(t, tool.InputSchema.Properties, "labels") assert.Contains(t, tool.InputSchema.Properties, "assignees") assert.Contains(t, tool.InputSchema.Properties, "milestone") assert.Contains(t, tool.InputSchema.Properties, "type") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"}) // Setup mock issue for success case mockIssue := &github.Issue{ Number: github.Ptr(123), Title: github.Ptr("Updated Issue Title"), Body: github.Ptr("Updated issue description"), State: github.Ptr("closed"), 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")}, } tests := []struct { name string mockedClient *http.Client requestArgs map[string]interface{} expectError bool expectedIssue *github.Issue expectedErrMsg string }{ { name: "update issue with all fields", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PatchReposIssuesByOwnerByRepoByIssueNumber, expectRequestBody(t, map[string]any{ "title": "Updated Issue Title", "body": "Updated issue description", "state": "closed", "labels": []any{"bug", "priority"}, "assignees": []any{"assignee1", "assignee2"}, "milestone": float64(5), "type": "Bug", }).andThen( mockResponse(t, http.StatusOK, mockIssue), ), ), ), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", "issue_number": float64(123), "title": "Updated Issue Title", "body": "Updated issue description", "state": "closed", "labels": []any{"bug", "priority"}, "assignees": []any{"assignee1", "assignee2"}, "milestone": float64(5), "type": "Bug", }, expectError: false, expectedIssue: mockIssue, }, { name: "update issue with minimal fields", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PatchReposIssuesByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusOK, &github.Issue{ Number: github.Ptr(123), Title: github.Ptr("Updated Issue Title"), HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), State: github.Ptr("open"), Type: &github.IssueType{Name: github.Ptr("Feature")}, }), ), ), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", "issue_number": float64(123), "title": "Updated Issue Title", "type": "Feature", }, expectError: false, expectedIssue: &github.Issue{ Number: github.Ptr(123), Title: github.Ptr("Updated Issue Title"), HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), State: github.Ptr("open"), Type: &github.IssueType{Name: github.Ptr("Feature")}, }, }, { name: "update issue fails with not found", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PatchReposIssuesByOwnerByRepoByIssueNumber, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Issue not found"}`)) }), ), ), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", "issue_number": float64(999), "title": "This issue doesn't exist", }, expectError: true, expectedErrMsg: "failed to update issue", }, { name: "update issue fails with validation error", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PatchReposIssuesByOwnerByRepoByIssueNumber, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnprocessableEntity) _, _ = w.Write([]byte(`{"message": "Invalid state value"}`)) }), ), ), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", "issue_number": float64(123), "state": "invalid_state", }, expectError: true, expectedErrMsg: "failed to update issue", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) _, handler := UpdateIssue(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) // Call handler result, err := handler(context.Background(), request) // Verify results if tc.expectError { if err != nil { assert.Contains(t, err.Error(), tc.expectedErrMsg) } else { // For errors returned as part of the result, not as an error 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.State, *returnedIssue.State) assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) if tc.expectedIssue.Body != nil { assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) } if tc.expectedIssue.Type != nil { assert.Equal(t, *tc.expectedIssue.Type.Name, *returnedIssue.Type.Name) } // Check assignees if expected if len(tc.expectedIssue.Assignees) > 0 { assert.Len(t, returnedIssue.Assignees, len(tc.expectedIssue.Assignees)) for i, assignee := range returnedIssue.Assignees { assert.Equal(t, *tc.expectedIssue.Assignees[i].Login, *assignee.Login) } } // Check labels if expected if len(tc.expectedIssue.Labels) > 0 { assert.Len(t, returnedIssue.Labels, len(tc.expectedIssue.Labels)) for i, label := range returnedIssue.Labels { assert.Equal(t, *tc.expectedIssue.Labels[i].Name, *label.Name) } } // Check milestone if expected if tc.expectedIssue.Milestone != nil { assert.NotNil(t, returnedIssue.Milestone) assert.Equal(t, *tc.expectedIssue.Milestone.Number, *returnedIssue.Milestone.Number) } }) } } 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 mockClient := github.NewClient(nil) tool, _ := GetIssueComments(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "get_issue_comments", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issue_number") assert.Contains(t, tool.InputSchema.Properties, "page") assert.Contains(t, tool.InputSchema.Properties, "perPage") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"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]interface{} expectError bool expectedComments []*github.IssueComment expectedErrMsg string }{ { name: "successful comments retrieval", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, mockComments, ), ), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", "issue_number": float64(42), }, expectError: false, expectedComments: mockComments, }, { name: "successful comments retrieval with pagination", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, expectQueryParams(t, map[string]string{ "page": "2", "per_page": "10", }).andThen( mockResponse(t, http.StatusOK, mockComments), ), ), ), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", "issue_number": float64(42), "page": float64(2), "perPage": float64(10), }, expectError: false, expectedComments: mockComments, }, { name: "issue not found", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), ), ), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", "issue_number": float64(999), }, expectError: true, expectedErrMsg: "failed to get issue comments", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) _, handler := GetIssueComments(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) // Call handler result, err := handler(context.Background(), 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 []*github.IssueComment err = json.Unmarshal([]byte(textContent.Text), &returnedComments) require.NoError(t, err) assert.Equal(t, len(tc.expectedComments), len(returnedComments)) if len(returnedComments) > 0 { assert.Equal(t, *tc.expectedComments[0].Body, *returnedComments[0].Body) assert.Equal(t, *tc.expectedComments[0].User.Login, *returnedComments[0].User.Login) } }) } } func TestAssignCopilotToIssue(t *testing.T) { t.Parallel() // Verify tool definition mockClient := githubv4.NewClient(nil) tool, _ := AssignCopilotToIssue(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "assign_copilot_to_issue", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issueNumber") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issueNumber"}) var pageOfFakeBots = func(n int) []struct{} { // We don't _really_ need real bots here, just objects that count as entries for the page bots := make([]struct{}, n) for i := range n { bots[i] = struct{}{} } return bots } tests := []struct { name string requestArgs map[string]any mockedClient *http.Client expectToolError bool expectedToolErrMsg string }{ { name: "successful assignment when there are no existing assignees", requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "issueNumber": float64(123), }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { Repository struct { SuggestedActors struct { Nodes []struct { Bot struct { ID githubv4.ID Login githubv4.String TypeName string `graphql:"__typename"` } `graphql:"... on Bot"` } PageInfo struct { HasNextPage bool EndCursor string } } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` } `graphql:"repository(owner: $owner, name: $name)"` }{}, map[string]any{ "owner": githubv4.String("owner"), "name": githubv4.String("repo"), "endCursor": (*githubv4.String)(nil), }, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "suggestedActors": map[string]any{ "nodes": []any{ map[string]any{ "id": githubv4.ID("copilot-swe-agent-id"), "login": githubv4.String("copilot-swe-agent"), "__typename": "Bot", }, }, }, }, }), ), githubv4mock.NewQueryMatcher( struct { Repository struct { Issue struct { ID githubv4.ID Assignees struct { Nodes []struct { ID githubv4.ID } } `graphql:"assignees(first: 100)"` } `graphql:"issue(number: $number)"` } `graphql:"repository(owner: $owner, name: $name)"` }{}, map[string]any{ "owner": githubv4.String("owner"), "name": githubv4.String("repo"), "number": githubv4.Int(123), }, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "issue": map[string]any{ "id": githubv4.ID("test-issue-id"), "assignees": map[string]any{ "nodes": []any{}, }, }, }, }), ), githubv4mock.NewMutationMatcher( struct { ReplaceActorsForAssignable struct { Typename string `graphql:"__typename"` } `graphql:"replaceActorsForAssignable(input: $input)"` }{}, ReplaceActorsForAssignableInput{ AssignableID: githubv4.ID("test-issue-id"), ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, }, nil, githubv4mock.DataResponse(map[string]any{}), ), ), }, { name: "successful assignment when there are existing assignees", requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "issueNumber": float64(123), }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { Repository struct { SuggestedActors struct { Nodes []struct { Bot struct { ID githubv4.ID Login githubv4.String TypeName string `graphql:"__typename"` } `graphql:"... on Bot"` } PageInfo struct { HasNextPage bool EndCursor string } } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` } `graphql:"repository(owner: $owner, name: $name)"` }{}, map[string]any{ "owner": githubv4.String("owner"), "name": githubv4.String("repo"), "endCursor": (*githubv4.String)(nil), }, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "suggestedActors": map[string]any{ "nodes": []any{ map[string]any{ "id": githubv4.ID("copilot-swe-agent-id"), "login": githubv4.String("copilot-swe-agent"), "__typename": "Bot", }, }, }, }, }), ), githubv4mock.NewQueryMatcher( struct { Repository struct { Issue struct { ID githubv4.ID Assignees struct { Nodes []struct { ID githubv4.ID } } `graphql:"assignees(first: 100)"` } `graphql:"issue(number: $number)"` } `graphql:"repository(owner: $owner, name: $name)"` }{}, map[string]any{ "owner": githubv4.String("owner"), "name": githubv4.String("repo"), "number": githubv4.Int(123), }, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "issue": map[string]any{ "id": githubv4.ID("test-issue-id"), "assignees": map[string]any{ "nodes": []any{ map[string]any{ "id": githubv4.ID("existing-assignee-id"), }, map[string]any{ "id": githubv4.ID("existing-assignee-id-2"), }, }, }, }, }, }), ), githubv4mock.NewMutationMatcher( struct { ReplaceActorsForAssignable struct { Typename string `graphql:"__typename"` } `graphql:"replaceActorsForAssignable(input: $input)"` }{}, ReplaceActorsForAssignableInput{ AssignableID: githubv4.ID("test-issue-id"), ActorIDs: []githubv4.ID{ githubv4.ID("existing-assignee-id"), githubv4.ID("existing-assignee-id-2"), githubv4.ID("copilot-swe-agent-id"), }, }, nil, githubv4mock.DataResponse(map[string]any{}), ), ), }, { name: "copilot bot not on first page of suggested actors", requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "issueNumber": float64(123), }, mockedClient: githubv4mock.NewMockedHTTPClient( // First page of suggested actors githubv4mock.NewQueryMatcher( struct { Repository struct { SuggestedActors struct { Nodes []struct { Bot struct { ID githubv4.ID Login githubv4.String TypeName string `graphql:"__typename"` } `graphql:"... on Bot"` } PageInfo struct { HasNextPage bool EndCursor string } } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` } `graphql:"repository(owner: $owner, name: $name)"` }{}, map[string]any{ "owner": githubv4.String("owner"), "name": githubv4.String("repo"), "endCursor": (*githubv4.String)(nil), }, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "suggestedActors": map[string]any{ "nodes": pageOfFakeBots(100), "pageInfo": map[string]any{ "hasNextPage": true, "endCursor": githubv4.String("next-page-cursor"), }, }, }, }), ), // Second page of suggested actors githubv4mock.NewQueryMatcher( struct { Repository struct { SuggestedActors struct { Nodes []struct { Bot struct { ID githubv4.ID Login githubv4.String TypeName string `graphql:"__typename"` } `graphql:"... on Bot"` } PageInfo struct { HasNextPage bool EndCursor string } } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` } `graphql:"repository(owner: $owner, name: $name)"` }{}, map[string]any{ "owner": githubv4.String("owner"), "name": githubv4.String("repo"), "endCursor": githubv4.String("next-page-cursor"), }, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "suggestedActors": map[string]any{ "nodes": []any{ map[string]any{ "id": githubv4.ID("copilot-swe-agent-id"), "login": githubv4.String("copilot-swe-agent"), "__typename": "Bot", }, }, }, }, }), ), githubv4mock.NewQueryMatcher( struct { Repository struct { Issue struct { ID githubv4.ID Assignees struct { Nodes []struct { ID githubv4.ID } } `graphql:"assignees(first: 100)"` } `graphql:"issue(number: $number)"` } `graphql:"repository(owner: $owner, name: $name)"` }{}, map[string]any{ "owner": githubv4.String("owner"), "name": githubv4.String("repo"), "number": githubv4.Int(123), }, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "issue": map[string]any{ "id": githubv4.ID("test-issue-id"), "assignees": map[string]any{ "nodes": []any{}, }, }, }, }), ), githubv4mock.NewMutationMatcher( struct { ReplaceActorsForAssignable struct { Typename string `graphql:"__typename"` } `graphql:"replaceActorsForAssignable(input: $input)"` }{}, ReplaceActorsForAssignableInput{ AssignableID: githubv4.ID("test-issue-id"), ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, }, nil, githubv4mock.DataResponse(map[string]any{}), ), ), }, { name: "copilot not a suggested actor", requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "issueNumber": float64(123), }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { Repository struct { SuggestedActors struct { Nodes []struct { Bot struct { ID githubv4.ID Login githubv4.String TypeName string `graphql:"__typename"` } `graphql:"... on Bot"` } PageInfo struct { HasNextPage bool EndCursor string } } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` } `graphql:"repository(owner: $owner, name: $name)"` }{}, map[string]any{ "owner": githubv4.String("owner"), "name": githubv4.String("repo"), "endCursor": (*githubv4.String)(nil), }, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "suggestedActors": map[string]any{ "nodes": []any{}, }, }, }), ), ), expectToolError: true, expectedToolErrMsg: "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information.", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() // Setup client with mock client := githubv4.NewClient(tc.mockedClient) _, handler := AssignCopilotToIssue(stubGetGQLClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) // Call handler result, err := handler(context.Background(), request) require.NoError(t, err) textContent := getTextResult(t, result) if tc.expectToolError { require.True(t, result.IsError) assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) return } require.False(t, result.IsError, fmt.Sprintf("expected there to be no tool error, text was %s", textContent.Text)) require.Equal(t, textContent.Text, "successfully assigned copilot to issue") }) } } func Test_AddSubIssue(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) tool, _ := AddSubIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "add_sub_issue", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issue_number") assert.Contains(t, tool.InputSchema.Properties, "sub_issue_id") assert.Contains(t, tool.InputSchema.Properties, "replace_parent") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"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]interface{} expectError bool expectedIssue *github.Issue expectedErrMsg string }{ { name: "successful sub-issue addition with all parameters", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusCreated, mockIssue), ), ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusCreated, mockIssue), ), ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusCreated, mockIssue), ), ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusNotFound, `{"message": "Parent issue not found"}`), ), ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), ), ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Sub-issue cannot be a parent of itself"}]}`), ), ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), ), ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( // No mocked requests needed since validation fails before HTTP call ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( // No mocked requests needed since validation fails before HTTP call ), requestArgs: map[string]interface{}{ "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) _, handler := AddSubIssue(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) // Call handler result, err := handler(context.Background(), 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_ListSubIssues(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) tool, _ := ListSubIssues(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "list_sub_issues", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issue_number") assert.Contains(t, tool.InputSchema.Properties, "page") assert.Contains(t, tool.InputSchema.Properties, "per_page") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"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]interface{} expectError bool expectedSubIssues []*github.Issue expectedErrMsg string }{ { name: "successful sub-issues listing with minimal parameters", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, mockSubIssues, ), ), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", "issue_number": float64(42), }, expectError: false, expectedSubIssues: mockSubIssues, }, { name: "successful sub-issues listing with pagination", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, expectQueryParams(t, map[string]string{ "page": "2", "per_page": "10", }).andThen( mockResponse(t, http.StatusOK, mockSubIssues), ), ), ), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", "issue_number": float64(42), "page": float64(2), "per_page": float64(10), }, expectError: false, expectedSubIssues: mockSubIssues, }, { name: "successful sub-issues listing with empty result", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, []*github.Issue{}, ), ), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", "issue_number": float64(42), }, expectError: false, expectedSubIssues: []*github.Issue{}, }, { name: "parent issue not found", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), ), ), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", "issue_number": float64(999), }, expectError: false, expectedErrMsg: "failed to list sub-issues", }, { name: "repository not found", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), ), ), requestArgs: map[string]interface{}{ "owner": "nonexistent", "repo": "repo", "issue_number": float64(42), }, expectError: false, expectedErrMsg: "failed to list sub-issues", }, { name: "sub-issues feature gone/deprecated", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusGone, `{"message": "This feature has been deprecated"}`), ), ), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", "issue_number": float64(42), }, expectError: false, expectedErrMsg: "failed to list sub-issues", }, { name: "missing required parameter owner", mockedClient: mock.NewMockedHTTPClient( // No mocked requests needed since validation fails before HTTP call ), requestArgs: map[string]interface{}{ "repo": "repo", "issue_number": float64(42), }, expectError: false, expectedErrMsg: "missing required parameter: owner", }, { name: "missing required parameter issue_number", mockedClient: mock.NewMockedHTTPClient( // No mocked requests needed since validation fails before HTTP call ), requestArgs: map[string]interface{}{ "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) _, handler := ListSubIssues(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) // Call handler result, err := handler(context.Background(), 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 mockClient := github.NewClient(nil) tool, _ := RemoveSubIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "remove_sub_issue", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issue_number") assert.Contains(t, tool.InputSchema.Properties, "sub_issue_id") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"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]interface{} expectError bool expectedIssue *github.Issue expectedErrMsg string }{ { name: "successful sub-issue removal", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusOK, mockIssue), ), ), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", "issue_number": float64(42), "sub_issue_id": float64(123), }, expectError: false, expectedIssue: mockIssue, }, { name: "parent issue not found", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), ), ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), ), ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusBadRequest, `{"message": "Invalid sub_issue_id"}`), ), ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), ), ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), ), ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( // No mocked requests needed since validation fails before HTTP call ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( // No mocked requests needed since validation fails before HTTP call ), requestArgs: map[string]interface{}{ "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) _, handler := RemoveSubIssue(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) // Call handler result, err := handler(context.Background(), 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 mockClient := github.NewClient(nil) tool, _ := ReprioritizeSubIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "reprioritize_sub_issue", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issue_number") assert.Contains(t, tool.InputSchema.Properties, "sub_issue_id") assert.Contains(t, tool.InputSchema.Properties, "after_id") assert.Contains(t, tool.InputSchema.Properties, "before_id") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"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]interface{} expectError bool expectedIssue *github.Issue expectedErrMsg string }{ { name: "successful reprioritization with after_id", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusOK, mockIssue), ), ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusOK, mockIssue), ), ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( // No mocked requests needed since validation fails before HTTP call ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( // No mocked requests needed since validation fails before HTTP call ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), ), ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), ), ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Positioning sub-issue not found"}]}`), ), ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), ), ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, mockResponse(t, http.StatusServiceUnavailable, `{"message": "Service Unavailable"}`), ), ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( // No mocked requests needed since validation fails before HTTP call ), requestArgs: map[string]interface{}{ "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: mock.NewMockedHTTPClient( // No mocked requests needed since validation fails before HTTP call ), requestArgs: map[string]interface{}{ "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) _, handler := ReprioritizeSubIssue(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) // Call handler result, err := handler(context.Background(), 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 mockClient := github.NewClient(nil) tool, _ := ListIssueTypes(stubGetClientFn(mockClient), translations.NullTranslationHelper) 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.Properties, "owner") assert.ElementsMatch(t, tool.InputSchema.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]interface{} expectError bool expectedIssueTypes []*github.IssueType expectedErrMsg string }{ { name: "successful issue types retrieval", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.EndpointPattern{ Pattern: "/orgs/testorg/issue-types", Method: "GET", }, mockResponse(t, http.StatusOK, mockIssueTypes), ), ), requestArgs: map[string]interface{}{ "owner": "testorg", }, expectError: false, expectedIssueTypes: mockIssueTypes, }, { name: "organization not found", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.EndpointPattern{ Pattern: "/orgs/nonexistent/issue-types", Method: "GET", }, mockResponse(t, http.StatusNotFound, `{"message": "Organization not found"}`), ), ), requestArgs: map[string]interface{}{ "owner": "nonexistent", }, expectError: true, expectedErrMsg: "failed to list issue types", }, { name: "missing owner parameter", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( mock.EndpointPattern{ Pattern: "/orgs/testorg/issue-types", Method: "GET", }, mockResponse(t, http.StatusOK, mockIssueTypes), ), ), requestArgs: map[string]interface{}{}, 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) _, handler := ListIssueTypes(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) // Call handler result, err := handler(context.Background(), 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) } } }) } }