github-mcp-server / pkg /github /issues_test.go
Gemini
Initial commit
fce10de
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)
}
}
})
}
}