package github
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/lockdown"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v79/github"
"github.com/google/jsonschema-go/jsonschema"
"github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var defaultGQLClient *githubv4.Client = githubv4.NewClient(newRepoAccessHTTPClient())
var repoAccessCache *lockdown.RepoAccessCache = stubRepoAccessCache(defaultGQLClient, 15*time.Minute)
type repoAccessKey struct {
owner string
repo string
username string
}
type repoAccessValue struct {
isPrivate bool
permission string
}
type repoAccessMockTransport struct {
responses map[repoAccessKey]repoAccessValue
}
func newRepoAccessHTTPClient() *http.Client {
responses := map[repoAccessKey]repoAccessValue{
{owner: "owner2", repo: "repo2", username: "testuser2"}: {isPrivate: true},
{owner: "owner", repo: "repo", username: "testuser"}: {isPrivate: false, permission: "READ"},
}
return &http.Client{Transport: &repoAccessMockTransport{responses: responses}}
}
func (rt *repoAccessMockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.Body == nil {
return nil, fmt.Errorf("missing request body")
}
var payload struct {
Query string `json:"query"`
Variables map[string]any `json:"variables"`
}
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
return nil, err
}
_ = req.Body.Close()
owner := toString(payload.Variables["owner"])
repo := toString(payload.Variables["name"])
username := toString(payload.Variables["username"])
value, ok := rt.responses[repoAccessKey{owner: owner, repo: repo, username: username}]
if !ok {
value = repoAccessValue{isPrivate: false, permission: "WRITE"}
}
edges := []any{}
if value.permission != "" {
edges = append(edges, map[string]any{
"permission": value.permission,
"node": map[string]any{
"login": username,
},
})
}
responseBody, err := json.Marshal(map[string]any{
"data": map[string]any{
"repository": map[string]any{
"isPrivate": value.isPrivate,
"collaborators": map[string]any{
"edges": edges,
},
},
},
})
if err != nil {
return nil, err
}
resp := &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewReader(responseBody)),
}
resp.Header.Set("Content-Type", "application/json")
return resp, nil
}
func toString(v any) string {
switch value := v.(type) {
case string:
return value
case fmt.Stringer:
return value.String()
case nil:
return ""
default:
return fmt.Sprintf("%v", value)
}
}
func Test_GetIssue(t *testing.T) {
// Verify tool definition once
serverTool := IssueRead(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "issue_read", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number"})
// Setup mock issue for success case
mockIssue := &github.Issue{
Number: github.Ptr(42),
Title: github.Ptr("Test Issue"),
Body: github.Ptr("This is a test issue"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
User: &github.User{
Login: github.Ptr("testuser"),
},
Repository: &github.Repository{
Name: github.Ptr("repo"),
Owner: &github.User{
Login: github.Ptr("owner"),
},
},
}
mockIssue2 := &github.Issue{
Number: github.Ptr(422),
Title: github.Ptr("Test Issue 2"),
Body: github.Ptr("This is a test issue 2"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
User: &github.User{
Login: github.Ptr("testuser2"),
},
Repository: &github.Repository{
Name: github.Ptr("repo2"),
Owner: &github.User{
Login: github.Ptr("owner2"),
},
},
}
tests := []struct {
name string
mockedClient *http.Client
gqlHTTPClient *http.Client
requestArgs map[string]interface{}
expectHandlerError bool
expectResultError bool
expectedIssue *github.Issue
expectedErrMsg string
lockdownEnabled bool
}{
{
name: "successful issue retrieval",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue),
}),
requestArgs: map[string]interface{}{
"method": "get",
"owner": "owner2",
"repo": "repo2",
"issue_number": float64(42),
},
expectedIssue: mockIssue,
},
{
name: "issue not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`),
}),
requestArgs: map[string]interface{}{
"method": "get",
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
},
expectHandlerError: true,
expectedErrMsg: "failed to get issue",
},
{
name: "lockdown enabled - private repository",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue2),
}),
gqlHTTPClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
IsPrivate githubv4.Boolean
Collaborators struct {
Edges []struct {
Permission githubv4.String
Node struct {
Login githubv4.String
}
}
} `graphql:"collaborators(query: $username, first: 1)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}{},
map[string]any{
"owner": githubv4.String("owner2"),
"name": githubv4.String("repo2"),
"username": githubv4.String("testuser2"),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"isPrivate": true,
"collaborators": map[string]any{
"edges": []any{},
},
},
}),
),
),
requestArgs: map[string]interface{}{
"method": "get",
"owner": "owner2",
"repo": "repo2",
"issue_number": float64(422),
},
expectedIssue: mockIssue2,
lockdownEnabled: true,
},
{
name: "lockdown enabled - user lacks push access",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue),
}),
gqlHTTPClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
IsPrivate githubv4.Boolean
Collaborators struct {
Edges []struct {
Permission githubv4.String
Node struct {
Login githubv4.String
}
}
} `graphql:"collaborators(query: $username, first: 1)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"name": githubv4.String("repo"),
"username": githubv4.String("testuser"),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"isPrivate": false,
"collaborators": map[string]any{
"edges": []any{
map[string]any{
"permission": "READ",
"node": map[string]any{
"login": "testuser",
},
},
},
},
},
}),
),
),
requestArgs: map[string]interface{}{
"method": "get",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectResultError: true,
expectedErrMsg: "access to issue details is restricted by lockdown mode",
lockdownEnabled: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client := github.NewClient(tc.mockedClient)
var gqlClient *githubv4.Client
cache := repoAccessCache
if tc.gqlHTTPClient != nil {
gqlClient = githubv4.NewClient(tc.gqlHTTPClient)
cache = stubRepoAccessCache(gqlClient, 15*time.Minute)
} else {
gqlClient = githubv4.NewClient(nil)
}
flags := stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled})
deps := BaseDeps{
Client: client,
GQLClient: gqlClient,
RepoAccessCache: cache,
Flags: flags,
}
handler := serverTool.Handler(deps)
request := createMCPRequest(tc.requestArgs)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
if tc.expectHandlerError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
require.NoError(t, err)
require.NotNil(t, result)
if tc.expectResultError {
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
textContent := getTextResult(t, result)
var returnedIssue 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
serverTool := AddIssueComment(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "add_issue_comment", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "body")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "issue_number", "body"})
// Setup mock comment for success case
mockComment := &github.IssueComment{
ID: github.Ptr(int64(123)),
Body: github.Ptr("This is a test comment"),
User: &github.User{
Login: github.Ptr("testuser"),
},
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42#issuecomment-123"),
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedComment *github.IssueComment
expectedErrMsg string
}{
{
name: "successful comment creation",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockComment),
}),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"body": "This is a test comment",
},
expectError: false,
expectedComment: mockComment,
},
{
name: "comment creation fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesCommentsByOwnerByRepoByIssueNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message": "Invalid request"}`))
}),
}),
requestArgs: map[string]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)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
if tc.expectedErrMsg != "" {
require.NotNil(t, result)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var 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
serverTool := SearchIssues(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "search_issues", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "query")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "sort")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "order")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "perPage")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "page")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"query"})
// Setup mock search results
mockSearchResult := &github.IssuesSearchResult{
Total: github.Ptr(2),
IncompleteResults: github.Ptr(false),
Issues: []*github.Issue{
{
Number: github.Ptr(42),
Title: github.Ptr("Bug: Something is broken"),
Body: github.Ptr("This is a bug report"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
Comments: github.Ptr(5),
User: &github.User{
Login: github.Ptr("user1"),
},
},
{
Number: github.Ptr(43),
Title: github.Ptr("Feature: Add new functionality"),
Body: github.Ptr("This is a feature request"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/43"),
Comments: github.Ptr(3),
User: &github.User{
Login: github.Ptr("user2"),
},
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedResult *github.IssuesSearchResult
expectedErrMsg string
}{
{
name: "successful issues search with all parameters",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:issue repo:owner/repo is:open",
"sort": "created",
"order": "desc",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]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: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "repo:test-owner/test-repo is:issue is:open",
"sort": "created",
"order": "asc",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]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: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:issue bug",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]interface{}{
"query": "bug",
"owner": "test-owner",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "issues search with only repo parameter (should ignore it)",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:issue feature",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]interface{}{
"query": "feature",
"repo": "test-repo",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "issues search with minimal parameters",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult),
}),
requestArgs: map[string]interface{}{
"query": "is:issue repo:owner/repo is:open",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "query with existing is:issue filter - no duplication",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]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: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:issue repo:github/github-mcp-server critical",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]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: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:issue repo:octocat/Hello-World bug",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]interface{}{
"query": "is:issue repo:octocat/Hello-World bug",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "complex query with multiple OR operators and existing filters",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]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: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"message": "Validation Failed"}`))
}),
}),
requestArgs: map[string]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)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.NoError(t, err) // No Go error, but result should be an error
require.NotNil(t, result)
require.True(t, result.IsError, "expected result to be an error")
textContent := getErrorResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
require.False(t, result.IsError, "expected result to not be an error")
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedResult github.IssuesSearchResult
err = json.Unmarshal([]byte(textContent.Text), &returnedResult)
require.NoError(t, err)
assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total)
assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults)
assert.Len(t, returnedResult.Issues, len(tc.expectedResult.Issues))
for i, issue := range returnedResult.Issues {
assert.Equal(t, *tc.expectedResult.Issues[i].Number, *issue.Number)
assert.Equal(t, *tc.expectedResult.Issues[i].Title, *issue.Title)
assert.Equal(t, *tc.expectedResult.Issues[i].State, *issue.State)
assert.Equal(t, *tc.expectedResult.Issues[i].HTMLURL, *issue.HTMLURL)
assert.Equal(t, *tc.expectedResult.Issues[i].User.Login, *issue.User.Login)
}
})
}
}
func Test_CreateIssue(t *testing.T) {
// Verify tool definition once
serverTool := IssueWrite(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "issue_write", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "title")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "body")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "assignees")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "labels")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "milestone")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "type")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo"})
// Setup mock issue for success case
mockIssue := &github.Issue{
Number: github.Ptr(123),
Title: github.Ptr("Test Issue"),
Body: github.Ptr("This is a test issue"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
Assignees: []*github.User{{Login: github.Ptr("user1")}, {Login: github.Ptr("user2")}},
Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("help wanted")}},
Milestone: &github.Milestone{Number: github.Ptr(5)},
Type: &github.IssueType{Name: github.Ptr("Bug")},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedIssue *github.Issue
expectedErrMsg string
}{
{
name: "successful issue creation with all fields",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesByOwnerByRepo: expectRequestBody(t, map[string]any{
"title": "Test Issue",
"body": "This is a test issue",
"labels": []any{"bug", "help wanted"},
"assignees": []any{"user1", "user2"},
"milestone": float64(5),
"type": "Bug",
}).andThen(
mockResponse(t, http.StatusCreated, mockIssue),
),
}),
requestArgs: map[string]interface{}{
"method": "create",
"owner": "owner",
"repo": "repo",
"title": "Test Issue",
"body": "This is a test issue",
"assignees": []any{"user1", "user2"},
"labels": []any{"bug", "help wanted"},
"milestone": float64(5),
"type": "Bug",
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "successful issue creation with minimal fields",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesByOwnerByRepo: mockResponse(t, http.StatusCreated, &github.Issue{
Number: github.Ptr(124),
Title: github.Ptr("Minimal Issue"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"),
State: github.Ptr("open"),
}),
}),
requestArgs: map[string]interface{}{
"method": "create",
"owner": "owner",
"repo": "repo",
"title": "Minimal Issue",
"assignees": nil, // Expect no failure with nil optional value.
},
expectError: false,
expectedIssue: &github.Issue{
Number: github.Ptr(124),
Title: github.Ptr("Minimal Issue"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"),
State: github.Ptr("open"),
},
},
{
name: "issue creation fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesByOwnerByRepo: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message": "Validation failed"}`))
}),
}),
requestArgs: map[string]interface{}{
"method": "create",
"owner": "owner",
"repo": "repo",
"title": "",
},
expectError: false,
expectedErrMsg: "missing required parameter: title",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
gqlClient := githubv4.NewClient(nil)
deps := BaseDeps{
Client: client,
GQLClient: gqlClient,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
if tc.expectedErrMsg != "" {
require.NotNil(t, result)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
textContent := getTextResult(t, result)
// Unmarshal and verify the minimal result
var returnedIssue MinimalResponse
err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
require.NoError(t, err)
assert.Equal(t, tc.expectedIssue.GetHTMLURL(), returnedIssue.URL)
})
}
}
func Test_ListIssues(t *testing.T) {
// Verify tool definition
serverTool := ListIssues(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "list_issues", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "state")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "labels")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "orderBy")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "direction")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "since")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "after")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "perPage")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo"})
// Mock issues data
mockIssuesAll := []map[string]any{
{
"number": 123,
"title": "First Issue",
"body": "This is the first test issue",
"state": "OPEN",
"databaseId": 1001,
"createdAt": "2023-01-01T00:00:00Z",
"updatedAt": "2023-01-01T00:00:00Z",
"author": map[string]any{"login": "user1"},
"labels": map[string]any{
"nodes": []map[string]any{
{"name": "bug", "id": "label1", "description": "Bug label"},
},
},
"comments": map[string]any{
"totalCount": 5,
},
},
{
"number": 456,
"title": "Second Issue",
"body": "This is the second test issue",
"state": "OPEN",
"databaseId": 1002,
"createdAt": "2023-02-01T00:00:00Z",
"updatedAt": "2023-02-01T00:00:00Z",
"author": map[string]any{"login": "user2"},
"labels": map[string]any{
"nodes": []map[string]any{
{"name": "enhancement", "id": "label2", "description": "Enhancement label"},
},
},
"comments": map[string]any{
"totalCount": 3,
},
},
}
mockIssuesOpen := []map[string]any{mockIssuesAll[0], mockIssuesAll[1]}
mockIssuesClosed := []map[string]any{
{
"number": 789,
"title": "Closed Issue",
"body": "This is a closed issue",
"state": "CLOSED",
"databaseId": 1003,
"createdAt": "2023-03-01T00:00:00Z",
"updatedAt": "2023-03-01T00:00:00Z",
"author": map[string]any{"login": "user3"},
"labels": map[string]any{
"nodes": []map[string]any{},
},
"comments": map[string]any{
"totalCount": 1,
},
},
}
// Mock responses
mockResponseListAll := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issues": map[string]any{
"nodes": mockIssuesAll,
"pageInfo": map[string]any{
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "",
"endCursor": "",
},
"totalCount": 2,
},
},
})
mockResponseOpenOnly := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issues": map[string]any{
"nodes": mockIssuesOpen,
"pageInfo": map[string]any{
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "",
"endCursor": "",
},
"totalCount": 2,
},
},
})
mockResponseClosedOnly := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issues": map[string]any{
"nodes": mockIssuesClosed,
"pageInfo": map[string]any{
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "",
"endCursor": "",
},
"totalCount": 1,
},
},
})
mockErrorRepoNotFound := githubv4mock.ErrorResponse("repository not found")
// Variables matching what GraphQL receives after JSON marshaling/unmarshaling
varsListAll := map[string]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 open state - lc",
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 open state - lc":
matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsOpenOnly, mockResponseOpenOnly)
httpClient = githubv4mock.NewMockedHTTPClient(matcher)
case "filter by closed state":
matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsClosedOnly, mockResponseClosedOnly)
httpClient = githubv4mock.NewMockedHTTPClient(matcher)
case "filter by labels":
matcher := githubv4mock.NewQueryMatcher(qWithLabels, varsWithLabels, mockResponseListAll)
httpClient = githubv4mock.NewMockedHTTPClient(matcher)
case "repository not found error":
matcher := githubv4mock.NewQueryMatcher(qBasicNoLabels, varsRepoNotFound, mockErrorRepoNotFound)
httpClient = githubv4mock.NewMockedHTTPClient(matcher)
}
gqlClient := githubv4.NewClient(httpClient)
deps := BaseDeps{
GQLClient: gqlClient,
}
handler := serverTool.Handler(deps)
req := createMCPRequest(tc.reqParams)
res, err := handler(ContextWithDeps(context.Background(), deps), &req)
text := getTextResult(t, res).Text
if tc.expectError {
require.True(t, res.IsError)
assert.Contains(t, text, tc.errContains)
return
}
require.NoError(t, err)
// Parse the structured response with pagination info
var response 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
serverTool := IssueWrite(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "issue_write", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "title")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "body")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "labels")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "assignees")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "milestone")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "type")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "state")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "state_reason")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "duplicate_of")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo"})
// Mock issues for reuse across test cases
mockBaseIssue := &github.Issue{
Number: github.Ptr(123),
Title: github.Ptr("Title"),
Body: github.Ptr("Description"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}},
Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}},
Milestone: &github.Milestone{Number: github.Ptr(5)},
Type: &github.IssueType{Name: github.Ptr("Bug")},
}
mockUpdatedIssue := &github.Issue{
Number: github.Ptr(123),
Title: github.Ptr("Updated Title"),
Body: github.Ptr("Updated Description"),
State: github.Ptr("closed"),
StateReason: github.Ptr("duplicate"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}},
Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}},
Milestone: &github.Milestone{Number: github.Ptr(5)},
Type: &github.IssueType{Name: github.Ptr("Bug")},
}
mockReopenedIssue := &github.Issue{
Number: github.Ptr(123),
Title: github.Ptr("Title"),
State: github.Ptr("open"),
StateReason: github.Ptr("reopened"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
}
// Mock GraphQL responses for reuse across test cases
issueIDQueryResponse := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"id": "I_kwDOA0xdyM50BPaO",
},
},
})
duplicateIssueIDQueryResponse := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"id": "I_kwDOA0xdyM50BPaO",
},
"duplicateIssue": map[string]any{
"id": "I_kwDOA0xdyM50BPbP",
},
},
})
closeSuccessResponse := githubv4mock.DataResponse(map[string]any{
"closeIssue": map[string]any{
"issue": map[string]any{
"id": "I_kwDOA0xdyM50BPaO",
"number": 123,
"url": "https://github.com/owner/repo/issues/123",
"state": "CLOSED",
},
},
})
reopenSuccessResponse := githubv4mock.DataResponse(map[string]any{
"reopenIssue": map[string]any{
"issue": map[string]any{
"id": "I_kwDOA0xdyM50BPaO",
"number": 123,
"url": "https://github.com/owner/repo/issues/123",
"state": "OPEN",
},
},
})
duplicateStateReason := IssueClosedStateReasonDuplicate
tests := []struct {
name string
mockedRESTClient *http.Client
mockedGQLClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedIssue *github.Issue
expectedErrMsg string
}{
{
name: "partial update of non-state fields only",
mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]interface{}{
"title": "Updated Title",
"body": "Updated Description",
}).andThen(
mockResponse(t, http.StatusOK, mockUpdatedIssue),
),
}),
mockedGQLClient: githubv4mock.NewMockedHTTPClient(),
requestArgs: map[string]interface{}{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
"title": "Updated Title",
"body": "Updated Description",
},
expectError: false,
expectedIssue: mockUpdatedIssue,
},
{
name: "issue not found when updating non-state fields only",
mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
}),
}),
mockedGQLClient: githubv4mock.NewMockedHTTPClient(),
requestArgs: map[string]interface{}{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
"title": "Updated Title",
},
expectError: true,
expectedErrMsg: "failed to update issue",
},
{
name: "close issue as duplicate",
mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue),
}),
mockedGQLClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
ID githubv4.ID
} `graphql:"issue(number: $issueNumber)"`
DuplicateIssue struct {
ID githubv4.ID
} `graphql:"duplicateIssue: issue(number: $duplicateOf)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"issueNumber": githubv4.Int(123),
"duplicateOf": githubv4.Int(456),
},
duplicateIssueIDQueryResponse,
),
githubv4mock.NewMutationMatcher(
struct {
CloseIssue struct {
Issue struct {
ID githubv4.ID
Number githubv4.Int
URL githubv4.String
State githubv4.String
}
} `graphql:"closeIssue(input: $input)"`
}{},
CloseIssueInput{
IssueID: "I_kwDOA0xdyM50BPaO",
StateReason: &duplicateStateReason,
DuplicateIssueID: githubv4.NewID("I_kwDOA0xdyM50BPbP"),
},
nil,
closeSuccessResponse,
),
),
requestArgs: map[string]interface{}{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
"state": "closed",
"state_reason": "duplicate",
"duplicate_of": float64(456),
},
expectError: false,
expectedIssue: mockUpdatedIssue,
},
{
name: "reopen issue",
mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue),
}),
mockedGQLClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
ID githubv4.ID
} `graphql:"issue(number: $issueNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"issueNumber": githubv4.Int(123),
},
issueIDQueryResponse,
),
githubv4mock.NewMutationMatcher(
struct {
ReopenIssue struct {
Issue struct {
ID githubv4.ID
Number githubv4.Int
URL githubv4.String
State githubv4.String
}
} `graphql:"reopenIssue(input: $input)"`
}{},
githubv4.ReopenIssueInput{
IssueID: "I_kwDOA0xdyM50BPaO",
},
nil,
reopenSuccessResponse,
),
),
requestArgs: map[string]interface{}{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
"state": "open",
},
expectError: false,
expectedIssue: mockReopenedIssue,
},
{
name: "main issue not found when trying to close it",
mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue),
}),
mockedGQLClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
ID githubv4.ID
} `graphql:"issue(number: $issueNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"issueNumber": githubv4.Int(999),
},
githubv4mock.ErrorResponse("Could not resolve to an Issue with the number of 999."),
),
),
requestArgs: map[string]interface{}{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
"state": "closed",
"state_reason": "not_planned",
},
expectError: true,
expectedErrMsg: "Failed to find issues",
},
{
name: "duplicate issue not found when closing as duplicate",
mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue),
}),
mockedGQLClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
ID githubv4.ID
} `graphql:"issue(number: $issueNumber)"`
DuplicateIssue struct {
ID githubv4.ID
} `graphql:"duplicateIssue: issue(number: $duplicateOf)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"issueNumber": githubv4.Int(123),
"duplicateOf": githubv4.Int(999),
},
githubv4mock.ErrorResponse("Could not resolve to an Issue with the number of 999."),
),
),
requestArgs: map[string]interface{}{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
"state": "closed",
"state_reason": "duplicate",
"duplicate_of": float64(999),
},
expectError: true,
expectedErrMsg: "Failed to find issues",
},
{
name: "close as duplicate with combined non-state updates",
mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]interface{}{
"title": "Updated Title",
"body": "Updated Description",
"labels": []any{"bug", "priority"},
"assignees": []any{"assignee1", "assignee2"},
"milestone": float64(5),
"type": "Bug",
}).andThen(
mockResponse(t, http.StatusOK, &github.Issue{
Number: github.Ptr(123),
Title: github.Ptr("Updated Title"),
Body: github.Ptr("Updated Description"),
Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}},
Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}},
Milestone: &github.Milestone{Number: github.Ptr(5)},
Type: &github.IssueType{Name: github.Ptr("Bug")},
State: github.Ptr("open"), // Still open after REST update
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
}),
),
}),
mockedGQLClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
ID githubv4.ID
} `graphql:"issue(number: $issueNumber)"`
DuplicateIssue struct {
ID githubv4.ID
} `graphql:"duplicateIssue: issue(number: $duplicateOf)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"issueNumber": githubv4.Int(123),
"duplicateOf": githubv4.Int(456),
},
duplicateIssueIDQueryResponse,
),
githubv4mock.NewMutationMatcher(
struct {
CloseIssue struct {
Issue struct {
ID githubv4.ID
Number githubv4.Int
URL githubv4.String
State githubv4.String
}
} `graphql:"closeIssue(input: $input)"`
}{},
CloseIssueInput{
IssueID: "I_kwDOA0xdyM50BPaO",
StateReason: &duplicateStateReason,
DuplicateIssueID: githubv4.NewID("I_kwDOA0xdyM50BPbP"),
},
nil,
closeSuccessResponse,
),
),
requestArgs: map[string]interface{}{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
"title": "Updated Title",
"body": "Updated Description",
"labels": []any{"bug", "priority"},
"assignees": []any{"assignee1", "assignee2"},
"milestone": float64(5),
"type": "Bug",
"state": "closed",
"state_reason": "duplicate",
"duplicate_of": float64(456),
},
expectError: false,
expectedIssue: mockUpdatedIssue,
},
{
name: "duplicate_of without duplicate state_reason should fail",
mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
mockedGQLClient: githubv4mock.NewMockedHTTPClient(),
requestArgs: map[string]interface{}{
"method": "update",
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
"state": "closed",
"state_reason": "completed",
"duplicate_of": float64(456),
},
expectError: true,
expectedErrMsg: "duplicate_of can only be used when state_reason is 'duplicate'",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup clients with mocks
restClient := github.NewClient(tc.mockedRESTClient)
gqlClient := githubv4.NewClient(tc.mockedGQLClient)
deps := BaseDeps{
Client: restClient,
GQLClient: gqlClient,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError || tc.expectedErrMsg != "" {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
if tc.expectedErrMsg != "" {
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
}
return
}
require.NoError(t, err)
if result.IsError {
t.Fatalf("Unexpected error result: %s", getErrorResult(t, result).Text)
}
require.False(t, result.IsError)
// Parse the result and get the text content
textContent := getTextResult(t, result)
// Unmarshal and verify the minimal result
var updateResp MinimalResponse
err = json.Unmarshal([]byte(textContent.Text), &updateResp)
require.NoError(t, err)
assert.Equal(t, tc.expectedIssue.GetHTMLURL(), updateResp.URL)
})
}
}
func Test_ParseISOTimestamp(t *testing.T) {
tests := []struct {
name string
input string
expectedErr bool
expectedTime time.Time
}{
{
name: "valid RFC3339 format",
input: "2023-01-15T14:30:00Z",
expectedErr: false,
expectedTime: time.Date(2023, 1, 15, 14, 30, 0, 0, time.UTC),
},
{
name: "valid date only format",
input: "2023-01-15",
expectedErr: false,
expectedTime: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC),
},
{
name: "empty timestamp",
input: "",
expectedErr: true,
},
{
name: "invalid format",
input: "15/01/2023",
expectedErr: true,
},
{
name: "invalid date",
input: "2023-13-45",
expectedErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
parsedTime, err := parseISOTimestamp(tc.input)
if tc.expectedErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expectedTime, parsedTime)
}
})
}
}
func Test_GetIssueComments(t *testing.T) {
// Verify tool definition once
serverTool := IssueRead(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "issue_read", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "page")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "perPage")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number"})
// Setup mock comments for success case
mockComments := []*github.IssueComment{
{
ID: github.Ptr(int64(123)),
Body: github.Ptr("This is the first comment"),
User: &github.User{
Login: github.Ptr("user1"),
},
CreatedAt: &github.Timestamp{Time: time.Now().Add(-time.Hour * 24)},
},
{
ID: github.Ptr(int64(456)),
Body: github.Ptr("This is the second comment"),
User: &github.User{
Login: github.Ptr("user2"),
},
CreatedAt: &github.Timestamp{Time: time.Now().Add(-time.Hour)},
},
}
tests := []struct {
name string
mockedClient *http.Client
gqlHTTPClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedComments []*github.IssueComment
expectedErrMsg string
lockdownEnabled bool
}{
{
name: "successful comments retrieval",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockComments),
}),
requestArgs: map[string]interface{}{
"method": "get_comments",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedComments: mockComments,
},
{
name: "successful comments retrieval with pagination",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesCommentsByOwnerByRepoByIssueNumber: expectQueryParams(t, map[string]string{
"page": "2",
"per_page": "10",
}).andThen(
mockResponse(t, http.StatusOK, mockComments),
),
}),
requestArgs: map[string]interface{}{
"method": "get_comments",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"page": float64(2),
"perPage": float64(10),
},
expectError: false,
expectedComments: mockComments,
},
{
name: "issue not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`),
}),
requestArgs: map[string]interface{}{
"method": "get_comments",
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
},
expectError: true,
expectedErrMsg: "failed to get issue comments",
},
{
name: "lockdown enabled filters comments without push access",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, []*github.IssueComment{
{
ID: github.Ptr(int64(789)),
Body: github.Ptr("Maintainer comment"),
User: &github.User{Login: github.Ptr("maintainer")},
},
{
ID: github.Ptr(int64(790)),
Body: github.Ptr("External user comment"),
User: &github.User{Login: github.Ptr("testuser")},
},
}),
}),
gqlHTTPClient: newRepoAccessHTTPClient(),
requestArgs: map[string]interface{}{
"method": "get_comments",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedComments: []*github.IssueComment{
{
ID: github.Ptr(int64(789)),
Body: github.Ptr("Maintainer comment"),
User: &github.User{Login: github.Ptr("maintainer")},
},
},
lockdownEnabled: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
var gqlClient *githubv4.Client
if tc.gqlHTTPClient != nil {
gqlClient = githubv4.NewClient(tc.gqlHTTPClient)
} else {
gqlClient = githubv4.NewClient(nil)
}
cache := stubRepoAccessCache(gqlClient, 15*time.Minute)
flags := stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled})
deps := BaseDeps{
Client: client,
GQLClient: gqlClient,
RepoAccessCache: cache,
Flags: flags,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
require.NoError(t, err)
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedComments []*github.IssueComment
err = json.Unmarshal([]byte(textContent.Text), &returnedComments)
require.NoError(t, err)
assert.Equal(t, len(tc.expectedComments), len(returnedComments))
for i := range tc.expectedComments {
require.NotNil(t, tc.expectedComments[i].User)
require.NotNil(t, returnedComments[i].User)
assert.Equal(t, tc.expectedComments[i].GetID(), returnedComments[i].GetID())
assert.Equal(t, tc.expectedComments[i].GetBody(), returnedComments[i].GetBody())
assert.Equal(t, tc.expectedComments[i].GetUser().GetLogin(), returnedComments[i].GetUser().GetLogin())
}
})
}
}
func Test_GetIssueLabels(t *testing.T) {
t.Parallel()
// Verify tool definition
serverTool := IssueRead(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "issue_read", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number"})
tests := []struct {
name string
requestArgs map[string]any
mockedClient *http.Client
expectToolError bool
expectedToolErrMsg string
}{
{
name: "successful issue labels listing",
requestArgs: map[string]any{
"method": "get_labels",
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
},
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
Labels struct {
Nodes []struct {
ID githubv4.ID
Name githubv4.String
Color githubv4.String
Description githubv4.String
}
TotalCount githubv4.Int
} `graphql:"labels(first: 100)"`
} `graphql:"issue(number: $issueNumber)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"issueNumber": githubv4.Int(123),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"labels": map[string]any{
"nodes": []any{
map[string]any{
"id": githubv4.ID("label-1"),
"name": githubv4.String("bug"),
"color": githubv4.String("d73a4a"),
"description": githubv4.String("Something isn't working"),
},
},
"totalCount": githubv4.Int(1),
},
},
},
}),
),
),
expectToolError: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gqlClient := githubv4.NewClient(tc.mockedClient)
client := github.NewClient(nil)
deps := BaseDeps{
Client: client,
GQLClient: gqlClient,
RepoAccessCache: stubRepoAccessCache(gqlClient, 15*time.Minute),
Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}),
}
handler := serverTool.Handler(deps)
request := createMCPRequest(tc.requestArgs)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
assert.NotNil(t, result)
if tc.expectToolError {
assert.True(t, result.IsError)
if tc.expectedToolErrMsg != "" {
textContent := getErrorResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
}
} else {
assert.False(t, result.IsError)
}
})
}
}
func TestAssignCopilotToIssue(t *testing.T) {
t.Parallel()
// Verify tool definition
serverTool := AssignCopilotToIssue(translations.NullTranslationHelper)
tool := serverTool.Tool
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.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "issue_number"})
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",
"issue_number": 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",
"issue_number": 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",
"issue_number": 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",
"issue_number": 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)
deps := BaseDeps{
GQLClient: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &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
serverTool := SubIssueWrite(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "sub_issue_write", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "sub_issue_id")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "replace_parent")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number", "sub_issue_id"})
// Setup mock issue for success case (matches GitHub API response format)
mockIssue := &github.Issue{
Number: github.Ptr(42),
Title: github.Ptr("Parent Issue"),
Body: github.Ptr("This is the parent issue with a sub-issue"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
User: &github.User{
Login: github.Ptr("testuser"),
},
Labels: []*github.Label{
{
Name: github.Ptr("enhancement"),
Color: github.Ptr("84b6eb"),
Description: github.Ptr("New feature or request"),
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedIssue *github.Issue
expectedErrMsg string
}{
{
name: "successful sub-issue addition with all parameters",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockIssue),
}),
requestArgs: map[string]interface{}{
"method": "add",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"replace_parent": true,
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "successful sub-issue addition with minimal parameters",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockIssue),
}),
requestArgs: map[string]interface{}{
"method": "add",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(456),
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "successful sub-issue addition with replace_parent false",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockIssue),
}),
requestArgs: map[string]interface{}{
"method": "add",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(789),
"replace_parent": false,
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "parent issue not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Parent issue not found"}`),
}),
requestArgs: map[string]interface{}{
"method": "add",
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "failed to add sub-issue",
},
{
name: "sub-issue not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`),
}),
requestArgs: map[string]interface{}{
"method": "add",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(999),
},
expectError: false,
expectedErrMsg: "failed to add sub-issue",
},
{
name: "validation failed - sub-issue cannot be parent of itself",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Sub-issue cannot be a parent of itself"}]}`),
}),
requestArgs: map[string]interface{}{
"method": "add",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(42),
},
expectError: false,
expectedErrMsg: "failed to add sub-issue",
},
{
name: "insufficient permissions",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`),
}),
requestArgs: map[string]interface{}{
"method": "add",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "failed to add sub-issue",
},
{
name: "missing required parameter owner",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]interface{}{
"method": "add",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "missing required parameter: owner",
},
{
name: "missing required parameter sub_issue_id",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]interface{}{
"method": "add",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedErrMsg: "missing required parameter: sub_issue_id",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
if tc.expectedErrMsg != "" {
require.NotNil(t, result)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedIssue github.Issue
err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
require.NoError(t, err)
assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number)
assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title)
assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body)
assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State)
assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL)
assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login)
})
}
}
func Test_GetSubIssues(t *testing.T) {
// Verify tool definition once
serverTool := IssueRead(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "issue_read", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "page")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "perPage")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number"})
// Setup mock sub-issues for success case
mockSubIssues := []*github.Issue{
{
Number: github.Ptr(123),
Title: github.Ptr("Sub-issue 1"),
Body: github.Ptr("This is the first sub-issue"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
User: &github.User{
Login: github.Ptr("user1"),
},
Labels: []*github.Label{
{
Name: github.Ptr("bug"),
Color: github.Ptr("d73a4a"),
Description: github.Ptr("Something isn't working"),
},
},
},
{
Number: github.Ptr(124),
Title: github.Ptr("Sub-issue 2"),
Body: github.Ptr("This is the second sub-issue"),
State: github.Ptr("closed"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"),
User: &github.User{
Login: github.Ptr("user2"),
},
Assignees: []*github.User{
{Login: github.Ptr("assignee1")},
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedSubIssues []*github.Issue
expectedErrMsg string
}{
{
name: "successful sub-issues listing with minimal parameters",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockSubIssues),
}),
requestArgs: map[string]interface{}{
"method": "get_sub_issues",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedSubIssues: mockSubIssues,
},
{
name: "successful sub-issues listing with pagination",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: expectQueryParams(t, map[string]string{
"page": "2",
"per_page": "10",
}).andThen(
mockResponse(t, http.StatusOK, mockSubIssues),
),
}),
requestArgs: map[string]interface{}{
"method": "get_sub_issues",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"page": float64(2),
"perPage": float64(10),
},
expectError: false,
expectedSubIssues: mockSubIssues,
},
{
name: "successful sub-issues listing with empty result",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, []*github.Issue{}),
}),
requestArgs: map[string]interface{}{
"method": "get_sub_issues",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedSubIssues: []*github.Issue{},
},
{
name: "parent issue not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
}),
requestArgs: map[string]interface{}{
"method": "get_sub_issues",
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
},
expectError: false,
expectedErrMsg: "failed to list sub-issues",
},
{
name: "repository not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
}),
requestArgs: map[string]interface{}{
"method": "get_sub_issues",
"owner": "nonexistent",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedErrMsg: "failed to list sub-issues",
},
{
name: "sub-issues feature gone/deprecated",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusGone, `{"message": "This feature has been deprecated"}`),
}),
requestArgs: map[string]interface{}{
"method": "get_sub_issues",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedErrMsg: "failed to list sub-issues",
},
{
name: "missing required parameter owner",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]interface{}{
"method": "get_sub_issues",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedErrMsg: "missing required parameter: owner",
},
{
name: "missing required parameter issue_number",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]interface{}{
"method": "get_sub_issues",
"owner": "owner",
"repo": "repo",
},
expectError: false,
expectedErrMsg: "missing required parameter: issue_number",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
gqlClient := githubv4.NewClient(nil)
deps := BaseDeps{
Client: client,
GQLClient: gqlClient,
RepoAccessCache: stubRepoAccessCache(gqlClient, 15*time.Minute),
Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}),
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
if tc.expectedErrMsg != "" {
require.NotNil(t, result)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedSubIssues []*github.Issue
err = json.Unmarshal([]byte(textContent.Text), &returnedSubIssues)
require.NoError(t, err)
assert.Len(t, returnedSubIssues, len(tc.expectedSubIssues))
for i, subIssue := range returnedSubIssues {
if i < len(tc.expectedSubIssues) {
assert.Equal(t, *tc.expectedSubIssues[i].Number, *subIssue.Number)
assert.Equal(t, *tc.expectedSubIssues[i].Title, *subIssue.Title)
assert.Equal(t, *tc.expectedSubIssues[i].State, *subIssue.State)
assert.Equal(t, *tc.expectedSubIssues[i].HTMLURL, *subIssue.HTMLURL)
assert.Equal(t, *tc.expectedSubIssues[i].User.Login, *subIssue.User.Login)
if tc.expectedSubIssues[i].Body != nil {
assert.Equal(t, *tc.expectedSubIssues[i].Body, *subIssue.Body)
}
}
}
})
}
}
func Test_RemoveSubIssue(t *testing.T) {
// Verify tool definition once
serverTool := SubIssueWrite(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "sub_issue_write", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "sub_issue_id")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number", "sub_issue_id"})
// Setup mock issue for success case (matches GitHub API response format - the updated parent issue)
mockIssue := &github.Issue{
Number: github.Ptr(42),
Title: github.Ptr("Parent Issue"),
Body: github.Ptr("This is the parent issue after sub-issue removal"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
User: &github.User{
Login: github.Ptr("testuser"),
},
Labels: []*github.Label{
{
Name: github.Ptr("enhancement"),
Color: github.Ptr("84b6eb"),
Description: github.Ptr("New feature or request"),
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedIssue *github.Issue
expectedErrMsg string
}{
{
name: "successful sub-issue removal",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue),
}),
requestArgs: map[string]interface{}{
"method": "remove",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "parent issue not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
}),
requestArgs: map[string]interface{}{
"method": "remove",
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "failed to remove sub-issue",
},
{
name: "sub-issue not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`),
}),
requestArgs: map[string]interface{}{
"method": "remove",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(999),
},
expectError: false,
expectedErrMsg: "failed to remove sub-issue",
},
{
name: "bad request - invalid sub_issue_id",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusBadRequest, `{"message": "Invalid sub_issue_id"}`),
}),
requestArgs: map[string]interface{}{
"method": "remove",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(-1),
},
expectError: false,
expectedErrMsg: "failed to remove sub-issue",
},
{
name: "repository not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
}),
requestArgs: map[string]interface{}{
"method": "remove",
"owner": "nonexistent",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "failed to remove sub-issue",
},
{
name: "insufficient permissions",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`),
}),
requestArgs: map[string]interface{}{
"method": "remove",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "failed to remove sub-issue",
},
{
name: "missing required parameter owner",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]interface{}{
"method": "remove",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "missing required parameter: owner",
},
{
name: "missing required parameter sub_issue_id",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]interface{}{
"method": "remove",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedErrMsg: "missing required parameter: sub_issue_id",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
if tc.expectedErrMsg != "" {
require.NotNil(t, result)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedIssue github.Issue
err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
require.NoError(t, err)
assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number)
assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title)
assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body)
assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State)
assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL)
assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login)
})
}
}
func Test_ReprioritizeSubIssue(t *testing.T) {
// Verify tool definition once
serverTool := SubIssueWrite(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "sub_issue_write", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "sub_issue_id")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "after_id")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "before_id")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number", "sub_issue_id"})
// Setup mock issue for success case (matches GitHub API response format - the updated parent issue)
mockIssue := &github.Issue{
Number: github.Ptr(42),
Title: github.Ptr("Parent Issue"),
Body: github.Ptr("This is the parent issue with reprioritized sub-issues"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
User: &github.User{
Login: github.Ptr("testuser"),
},
Labels: []*github.Label{
{
Name: github.Ptr("enhancement"),
Color: github.Ptr("84b6eb"),
Description: github.Ptr("New feature or request"),
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedIssue *github.Issue
expectedErrMsg string
}{
{
name: "successful reprioritization with after_id",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue),
}),
requestArgs: map[string]interface{}{
"method": "reprioritize",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"after_id": float64(456),
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "successful reprioritization with before_id",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue),
}),
requestArgs: map[string]interface{}{
"method": "reprioritize",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"before_id": float64(789),
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "validation error - neither after_id nor before_id specified",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]interface{}{
"method": "reprioritize",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "either after_id or before_id must be specified",
},
{
name: "validation error - both after_id and before_id specified",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]interface{}{
"method": "reprioritize",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"after_id": float64(456),
"before_id": float64(789),
},
expectError: false,
expectedErrMsg: "only one of after_id or before_id should be specified, not both",
},
{
name: "parent issue not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
}),
requestArgs: map[string]interface{}{
"method": "reprioritize",
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
"sub_issue_id": float64(123),
"after_id": float64(456),
},
expectError: false,
expectedErrMsg: "failed to reprioritize sub-issue",
},
{
name: "sub-issue not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`),
}),
requestArgs: map[string]interface{}{
"method": "reprioritize",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(999),
"after_id": float64(456),
},
expectError: false,
expectedErrMsg: "failed to reprioritize sub-issue",
},
{
name: "validation failed - positioning sub-issue not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Positioning sub-issue not found"}]}`),
}),
requestArgs: map[string]interface{}{
"method": "reprioritize",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"after_id": float64(999),
},
expectError: false,
expectedErrMsg: "failed to reprioritize sub-issue",
},
{
name: "insufficient permissions",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`),
}),
requestArgs: map[string]interface{}{
"method": "reprioritize",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"after_id": float64(456),
},
expectError: false,
expectedErrMsg: "failed to reprioritize sub-issue",
},
{
name: "service unavailable",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusServiceUnavailable, `{"message": "Service Unavailable"}`),
}),
requestArgs: map[string]interface{}{
"method": "reprioritize",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"before_id": float64(456),
},
expectError: false,
expectedErrMsg: "failed to reprioritize sub-issue",
},
{
name: "missing required parameter owner",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]interface{}{
"method": "reprioritize",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"after_id": float64(456),
},
expectError: false,
expectedErrMsg: "missing required parameter: owner",
},
{
name: "missing required parameter sub_issue_id",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]interface{}{
"method": "reprioritize",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"after_id": float64(456),
},
expectError: false,
expectedErrMsg: "missing required parameter: sub_issue_id",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
if tc.expectedErrMsg != "" {
require.NotNil(t, result)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedIssue github.Issue
err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
require.NoError(t, err)
assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number)
assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title)
assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body)
assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State)
assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL)
assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login)
})
}
}
func Test_ListIssueTypes(t *testing.T) {
// Verify tool definition once
serverTool := ListIssueTypes(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "list_issue_types", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner"})
// Setup mock issue types for success case
mockIssueTypes := []*github.IssueType{
{
ID: github.Ptr(int64(1)),
Name: github.Ptr("bug"),
Description: github.Ptr("Something isn't working"),
Color: github.Ptr("d73a4a"),
},
{
ID: github.Ptr(int64(2)),
Name: github.Ptr("feature"),
Description: github.Ptr("New feature or enhancement"),
Color: github.Ptr("a2eeef"),
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedIssueTypes []*github.IssueType
expectedErrMsg string
}{
{
name: "successful issue types retrieval",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /orgs/testorg/issue-types": mockResponse(t, http.StatusOK, mockIssueTypes),
}),
requestArgs: map[string]interface{}{
"owner": "testorg",
},
expectError: false,
expectedIssueTypes: mockIssueTypes,
},
{
name: "organization not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /orgs/nonexistent/issue-types": mockResponse(t, http.StatusNotFound, `{"message": "Organization not found"}`),
}),
requestArgs: map[string]interface{}{
"owner": "nonexistent",
},
expectError: true,
expectedErrMsg: "failed to list issue types",
},
{
name: "missing owner parameter",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /orgs/testorg/issue-types": mockResponse(t, http.StatusOK, mockIssueTypes),
}),
requestArgs: map[string]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)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
if err != nil {
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
// Check if error is returned as tool result error
require.NotNil(t, result)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
// Check if it's a parameter validation error (returned as tool result error)
if result != nil && result.IsError {
errorContent := getErrorResult(t, result)
if tc.expectedErrMsg != "" && strings.Contains(errorContent.Text, tc.expectedErrMsg) {
return // This is expected for parameter validation errors
}
}
require.NoError(t, err)
require.NotNil(t, result)
require.False(t, result.IsError)
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedIssueTypes []*github.IssueType
err = json.Unmarshal([]byte(textContent.Text), &returnedIssueTypes)
require.NoError(t, err)
if tc.expectedIssueTypes != nil {
require.Equal(t, len(tc.expectedIssueTypes), len(returnedIssueTypes))
for i, expected := range tc.expectedIssueTypes {
assert.Equal(t, *expected.Name, *returnedIssueTypes[i].Name)
assert.Equal(t, *expected.Description, *returnedIssueTypes[i].Description)
assert.Equal(t, *expected.Color, *returnedIssueTypes[i].Color)
assert.Equal(t, *expected.ID, *returnedIssueTypes[i].ID)
}
}
})
}
}