3c453dd9dd
This refactor addresses performance issues in per-request server scenarios
where creating ~90 handler closures per request was causing latency.
Changes:
- Add ContextWithDeps, DepsFromContext, MustDepsFromContext to dependencies.go
- Add NewServerToolWithContextHandler, NewServerToolWithRawContextHandler to inventory
- Convert all 89 tool handlers from closure pattern to direct context-based deps
- Update all tests to inject deps into context before calling handlers
- Mark old NewServerTool and NewServerToolFromHandler as deprecated
The new pattern:
- Before: func(deps) handler { return func(ctx, req, args) { use deps } }
- After: func(ctx, deps, req, args) { use deps }
Dependencies are now injected into context once (via ContextWithDeps) and
extracted by NewTool internally before passing to handlers. This eliminates
closure creation on the hot path for remote servers.
3756 lines
114 KiB
Go
3756 lines
114 KiB
Go
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/migueleliasweb/go-github-mock/src/mock"
|
|
"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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatch(
|
|
mock.GetReposIssuesByOwnerByRepoByIssueNumber,
|
|
mockIssue,
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{
|
|
"method": "get",
|
|
"owner": "owner2",
|
|
"repo": "repo2",
|
|
"issue_number": float64(42),
|
|
},
|
|
expectedIssue: mockIssue,
|
|
},
|
|
{
|
|
name: "issue not found",
|
|
mockedClient: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.GetReposIssuesByOwnerByRepoByIssueNumber,
|
|
mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`),
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{
|
|
"method": "get",
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issue_number": float64(999),
|
|
},
|
|
expectHandlerError: true,
|
|
expectedErrMsg: "failed to get issue",
|
|
},
|
|
{
|
|
name: "lockdown enabled - private repository",
|
|
mockedClient: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatch(
|
|
mock.GetReposIssuesByOwnerByRepoByIssueNumber,
|
|
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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatch(
|
|
mock.GetReposIssuesByOwnerByRepoByIssueNumber,
|
|
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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.PostReposIssuesCommentsByOwnerByRepoByIssueNumber,
|
|
mockResponse(t, http.StatusCreated, mockComment),
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issue_number": float64(42),
|
|
"body": "This is a test comment",
|
|
},
|
|
expectError: false,
|
|
expectedComment: mockComment,
|
|
},
|
|
{
|
|
name: "comment creation fails",
|
|
mockedClient: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.PostReposIssuesCommentsByOwnerByRepoByIssueNumber,
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
_, _ = w.Write([]byte(`{"message": "Invalid request"}`))
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issue_number": float64(42),
|
|
"body": "",
|
|
},
|
|
expectError: false,
|
|
expectedErrMsg: "missing required parameter: body",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Setup client with mock
|
|
client := github.NewClient(tc.mockedClient)
|
|
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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.GetSearchIssues,
|
|
expectQueryParams(
|
|
t,
|
|
map[string]string{
|
|
"q": "is:issue repo:owner/repo is:open",
|
|
"sort": "created",
|
|
"order": "desc",
|
|
"page": "1",
|
|
"per_page": "30",
|
|
},
|
|
).andThen(
|
|
mockResponse(t, http.StatusOK, mockSearchResult),
|
|
),
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{
|
|
"query": "repo:owner/repo is:open",
|
|
"sort": "created",
|
|
"order": "desc",
|
|
"page": float64(1),
|
|
"perPage": float64(30),
|
|
},
|
|
expectError: false,
|
|
expectedResult: mockSearchResult,
|
|
},
|
|
{
|
|
name: "issues search with owner and repo parameters",
|
|
mockedClient: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.GetSearchIssues,
|
|
expectQueryParams(
|
|
t,
|
|
map[string]string{
|
|
"q": "repo:test-owner/test-repo is:issue is:open",
|
|
"sort": "created",
|
|
"order": "asc",
|
|
"page": "1",
|
|
"per_page": "30",
|
|
},
|
|
).andThen(
|
|
mockResponse(t, http.StatusOK, mockSearchResult),
|
|
),
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{
|
|
"query": "is:open",
|
|
"owner": "test-owner",
|
|
"repo": "test-repo",
|
|
"sort": "created",
|
|
"order": "asc",
|
|
},
|
|
expectError: false,
|
|
expectedResult: mockSearchResult,
|
|
},
|
|
{
|
|
name: "issues search with only owner parameter (should ignore it)",
|
|
mockedClient: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.GetSearchIssues,
|
|
expectQueryParams(
|
|
t,
|
|
map[string]string{
|
|
"q": "is:issue bug",
|
|
"page": "1",
|
|
"per_page": "30",
|
|
},
|
|
).andThen(
|
|
mockResponse(t, http.StatusOK, mockSearchResult),
|
|
),
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{
|
|
"query": "bug",
|
|
"owner": "test-owner",
|
|
},
|
|
expectError: false,
|
|
expectedResult: mockSearchResult,
|
|
},
|
|
{
|
|
name: "issues search with only repo parameter (should ignore it)",
|
|
mockedClient: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.GetSearchIssues,
|
|
expectQueryParams(
|
|
t,
|
|
map[string]string{
|
|
"q": "is:issue feature",
|
|
"page": "1",
|
|
"per_page": "30",
|
|
},
|
|
).andThen(
|
|
mockResponse(t, http.StatusOK, mockSearchResult),
|
|
),
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{
|
|
"query": "feature",
|
|
"repo": "test-repo",
|
|
},
|
|
expectError: false,
|
|
expectedResult: mockSearchResult,
|
|
},
|
|
{
|
|
name: "issues search with minimal parameters",
|
|
mockedClient: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatch(
|
|
mock.GetSearchIssues,
|
|
mockSearchResult,
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{
|
|
"query": "is:issue repo:owner/repo is:open",
|
|
},
|
|
expectError: false,
|
|
expectedResult: mockSearchResult,
|
|
},
|
|
{
|
|
name: "query with existing is:issue filter - no duplication",
|
|
mockedClient: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.GetSearchIssues,
|
|
expectQueryParams(
|
|
t,
|
|
map[string]string{
|
|
"q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)",
|
|
"page": "1",
|
|
"per_page": "30",
|
|
},
|
|
).andThen(
|
|
mockResponse(t, http.StatusOK, mockSearchResult),
|
|
),
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{
|
|
"query": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)",
|
|
},
|
|
expectError: false,
|
|
expectedResult: mockSearchResult,
|
|
},
|
|
{
|
|
name: "query with existing repo: filter and conflicting owner/repo params - uses query filter",
|
|
mockedClient: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.GetSearchIssues,
|
|
expectQueryParams(
|
|
t,
|
|
map[string]string{
|
|
"q": "is:issue repo:github/github-mcp-server critical",
|
|
"page": "1",
|
|
"per_page": "30",
|
|
},
|
|
).andThen(
|
|
mockResponse(t, http.StatusOK, mockSearchResult),
|
|
),
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{
|
|
"query": "repo:github/github-mcp-server critical",
|
|
"owner": "different-owner",
|
|
"repo": "different-repo",
|
|
},
|
|
expectError: false,
|
|
expectedResult: mockSearchResult,
|
|
},
|
|
{
|
|
name: "query with both is: and repo: filters already present",
|
|
mockedClient: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.GetSearchIssues,
|
|
expectQueryParams(
|
|
t,
|
|
map[string]string{
|
|
"q": "is:issue repo:octocat/Hello-World bug",
|
|
"page": "1",
|
|
"per_page": "30",
|
|
},
|
|
).andThen(
|
|
mockResponse(t, http.StatusOK, mockSearchResult),
|
|
),
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{
|
|
"query": "is:issue repo:octocat/Hello-World bug",
|
|
},
|
|
expectError: false,
|
|
expectedResult: mockSearchResult,
|
|
},
|
|
{
|
|
name: "complex query with multiple OR operators and existing filters",
|
|
mockedClient: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.GetSearchIssues,
|
|
expectQueryParams(
|
|
t,
|
|
map[string]string{
|
|
"q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)",
|
|
"page": "1",
|
|
"per_page": "30",
|
|
},
|
|
).andThen(
|
|
mockResponse(t, http.StatusOK, mockSearchResult),
|
|
),
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{
|
|
"query": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)",
|
|
},
|
|
expectError: false,
|
|
expectedResult: mockSearchResult,
|
|
},
|
|
{
|
|
name: "search issues fails",
|
|
mockedClient: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.GetSearchIssues,
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(`{"message": "Validation Failed"}`))
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{
|
|
"query": "invalid:query",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "failed to search issues",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Setup client with mock
|
|
client := github.NewClient(tc.mockedClient)
|
|
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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.PostReposIssuesByOwnerByRepo,
|
|
expectRequestBody(t, map[string]any{
|
|
"title": "Test Issue",
|
|
"body": "This is a test issue",
|
|
"labels": []any{"bug", "help wanted"},
|
|
"assignees": []any{"user1", "user2"},
|
|
"milestone": float64(5),
|
|
"type": "Bug",
|
|
}).andThen(
|
|
mockResponse(t, http.StatusCreated, mockIssue),
|
|
),
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{
|
|
"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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.PostReposIssuesByOwnerByRepo,
|
|
mockResponse(t, http.StatusCreated, &github.Issue{
|
|
Number: github.Ptr(124),
|
|
Title: github.Ptr("Minimal Issue"),
|
|
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"),
|
|
State: github.Ptr("open"),
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{
|
|
"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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.PostReposIssuesByOwnerByRepo,
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
_, _ = w.Write([]byte(`{"message": "Validation failed"}`))
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{
|
|
"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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatch(
|
|
mock.PatchReposIssuesByOwnerByRepoByIssueNumber,
|
|
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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatch(
|
|
mock.PatchReposIssuesByOwnerByRepoByIssueNumber,
|
|
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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatch(
|
|
mock.PatchReposIssuesByOwnerByRepoByIssueNumber,
|
|
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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatch(
|
|
mock.PatchReposIssuesByOwnerByRepoByIssueNumber,
|
|
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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(),
|
|
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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatch(
|
|
mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber,
|
|
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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber,
|
|
expectQueryParams(t, map[string]string{
|
|
"page": "2",
|
|
"per_page": "10",
|
|
}).andThen(
|
|
mockResponse(t, http.StatusOK, mockComments),
|
|
),
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{
|
|
"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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatch(
|
|
mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber,
|
|
[]*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, "issueNumber")
|
|
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "issueNumber"})
|
|
|
|
var pageOfFakeBots = func(n int) []struct{} {
|
|
// We don't _really_ need real bots here, just objects that count as entries for the page
|
|
bots := make([]struct{}, n)
|
|
for i := range n {
|
|
bots[i] = struct{}{}
|
|
}
|
|
return bots
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
requestArgs map[string]any
|
|
mockedClient *http.Client
|
|
expectToolError bool
|
|
expectedToolErrMsg string
|
|
}{
|
|
{
|
|
name: "successful assignment when there are no existing assignees",
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issueNumber": float64(123),
|
|
},
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(
|
|
githubv4mock.NewQueryMatcher(
|
|
struct {
|
|
Repository struct {
|
|
SuggestedActors struct {
|
|
Nodes []struct {
|
|
Bot struct {
|
|
ID githubv4.ID
|
|
Login githubv4.String
|
|
TypeName string `graphql:"__typename"`
|
|
} `graphql:"... on Bot"`
|
|
}
|
|
PageInfo struct {
|
|
HasNextPage bool
|
|
EndCursor string
|
|
}
|
|
} `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"`
|
|
} `graphql:"repository(owner: $owner, name: $name)"`
|
|
}{},
|
|
map[string]any{
|
|
"owner": githubv4.String("owner"),
|
|
"name": githubv4.String("repo"),
|
|
"endCursor": (*githubv4.String)(nil),
|
|
},
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"repository": map[string]any{
|
|
"suggestedActors": map[string]any{
|
|
"nodes": []any{
|
|
map[string]any{
|
|
"id": githubv4.ID("copilot-swe-agent-id"),
|
|
"login": githubv4.String("copilot-swe-agent"),
|
|
"__typename": "Bot",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
githubv4mock.NewQueryMatcher(
|
|
struct {
|
|
Repository struct {
|
|
Issue struct {
|
|
ID githubv4.ID
|
|
Assignees struct {
|
|
Nodes []struct {
|
|
ID githubv4.ID
|
|
}
|
|
} `graphql:"assignees(first: 100)"`
|
|
} `graphql:"issue(number: $number)"`
|
|
} `graphql:"repository(owner: $owner, name: $name)"`
|
|
}{},
|
|
map[string]any{
|
|
"owner": githubv4.String("owner"),
|
|
"name": githubv4.String("repo"),
|
|
"number": githubv4.Int(123),
|
|
},
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"repository": map[string]any{
|
|
"issue": map[string]any{
|
|
"id": githubv4.ID("test-issue-id"),
|
|
"assignees": map[string]any{
|
|
"nodes": []any{},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
githubv4mock.NewMutationMatcher(
|
|
struct {
|
|
ReplaceActorsForAssignable struct {
|
|
Typename string `graphql:"__typename"`
|
|
} `graphql:"replaceActorsForAssignable(input: $input)"`
|
|
}{},
|
|
ReplaceActorsForAssignableInput{
|
|
AssignableID: githubv4.ID("test-issue-id"),
|
|
ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")},
|
|
},
|
|
nil,
|
|
githubv4mock.DataResponse(map[string]any{}),
|
|
),
|
|
),
|
|
},
|
|
{
|
|
name: "successful assignment when there are existing assignees",
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issueNumber": float64(123),
|
|
},
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(
|
|
githubv4mock.NewQueryMatcher(
|
|
struct {
|
|
Repository struct {
|
|
SuggestedActors struct {
|
|
Nodes []struct {
|
|
Bot struct {
|
|
ID githubv4.ID
|
|
Login githubv4.String
|
|
TypeName string `graphql:"__typename"`
|
|
} `graphql:"... on Bot"`
|
|
}
|
|
PageInfo struct {
|
|
HasNextPage bool
|
|
EndCursor string
|
|
}
|
|
} `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"`
|
|
} `graphql:"repository(owner: $owner, name: $name)"`
|
|
}{},
|
|
map[string]any{
|
|
"owner": githubv4.String("owner"),
|
|
"name": githubv4.String("repo"),
|
|
"endCursor": (*githubv4.String)(nil),
|
|
},
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"repository": map[string]any{
|
|
"suggestedActors": map[string]any{
|
|
"nodes": []any{
|
|
map[string]any{
|
|
"id": githubv4.ID("copilot-swe-agent-id"),
|
|
"login": githubv4.String("copilot-swe-agent"),
|
|
"__typename": "Bot",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
githubv4mock.NewQueryMatcher(
|
|
struct {
|
|
Repository struct {
|
|
Issue struct {
|
|
ID githubv4.ID
|
|
Assignees struct {
|
|
Nodes []struct {
|
|
ID githubv4.ID
|
|
}
|
|
} `graphql:"assignees(first: 100)"`
|
|
} `graphql:"issue(number: $number)"`
|
|
} `graphql:"repository(owner: $owner, name: $name)"`
|
|
}{},
|
|
map[string]any{
|
|
"owner": githubv4.String("owner"),
|
|
"name": githubv4.String("repo"),
|
|
"number": githubv4.Int(123),
|
|
},
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"repository": map[string]any{
|
|
"issue": map[string]any{
|
|
"id": githubv4.ID("test-issue-id"),
|
|
"assignees": map[string]any{
|
|
"nodes": []any{
|
|
map[string]any{
|
|
"id": githubv4.ID("existing-assignee-id"),
|
|
},
|
|
map[string]any{
|
|
"id": githubv4.ID("existing-assignee-id-2"),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
githubv4mock.NewMutationMatcher(
|
|
struct {
|
|
ReplaceActorsForAssignable struct {
|
|
Typename string `graphql:"__typename"`
|
|
} `graphql:"replaceActorsForAssignable(input: $input)"`
|
|
}{},
|
|
ReplaceActorsForAssignableInput{
|
|
AssignableID: githubv4.ID("test-issue-id"),
|
|
ActorIDs: []githubv4.ID{
|
|
githubv4.ID("existing-assignee-id"),
|
|
githubv4.ID("existing-assignee-id-2"),
|
|
githubv4.ID("copilot-swe-agent-id"),
|
|
},
|
|
},
|
|
nil,
|
|
githubv4mock.DataResponse(map[string]any{}),
|
|
),
|
|
),
|
|
},
|
|
{
|
|
name: "copilot bot not on first page of suggested actors",
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issueNumber": float64(123),
|
|
},
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(
|
|
// First page of suggested actors
|
|
githubv4mock.NewQueryMatcher(
|
|
struct {
|
|
Repository struct {
|
|
SuggestedActors struct {
|
|
Nodes []struct {
|
|
Bot struct {
|
|
ID githubv4.ID
|
|
Login githubv4.String
|
|
TypeName string `graphql:"__typename"`
|
|
} `graphql:"... on Bot"`
|
|
}
|
|
PageInfo struct {
|
|
HasNextPage bool
|
|
EndCursor string
|
|
}
|
|
} `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"`
|
|
} `graphql:"repository(owner: $owner, name: $name)"`
|
|
}{},
|
|
map[string]any{
|
|
"owner": githubv4.String("owner"),
|
|
"name": githubv4.String("repo"),
|
|
"endCursor": (*githubv4.String)(nil),
|
|
},
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"repository": map[string]any{
|
|
"suggestedActors": map[string]any{
|
|
"nodes": pageOfFakeBots(100),
|
|
"pageInfo": map[string]any{
|
|
"hasNextPage": true,
|
|
"endCursor": githubv4.String("next-page-cursor"),
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
// Second page of suggested actors
|
|
githubv4mock.NewQueryMatcher(
|
|
struct {
|
|
Repository struct {
|
|
SuggestedActors struct {
|
|
Nodes []struct {
|
|
Bot struct {
|
|
ID githubv4.ID
|
|
Login githubv4.String
|
|
TypeName string `graphql:"__typename"`
|
|
} `graphql:"... on Bot"`
|
|
}
|
|
PageInfo struct {
|
|
HasNextPage bool
|
|
EndCursor string
|
|
}
|
|
} `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"`
|
|
} `graphql:"repository(owner: $owner, name: $name)"`
|
|
}{},
|
|
map[string]any{
|
|
"owner": githubv4.String("owner"),
|
|
"name": githubv4.String("repo"),
|
|
"endCursor": githubv4.String("next-page-cursor"),
|
|
},
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"repository": map[string]any{
|
|
"suggestedActors": map[string]any{
|
|
"nodes": []any{
|
|
map[string]any{
|
|
"id": githubv4.ID("copilot-swe-agent-id"),
|
|
"login": githubv4.String("copilot-swe-agent"),
|
|
"__typename": "Bot",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
githubv4mock.NewQueryMatcher(
|
|
struct {
|
|
Repository struct {
|
|
Issue struct {
|
|
ID githubv4.ID
|
|
Assignees struct {
|
|
Nodes []struct {
|
|
ID githubv4.ID
|
|
}
|
|
} `graphql:"assignees(first: 100)"`
|
|
} `graphql:"issue(number: $number)"`
|
|
} `graphql:"repository(owner: $owner, name: $name)"`
|
|
}{},
|
|
map[string]any{
|
|
"owner": githubv4.String("owner"),
|
|
"name": githubv4.String("repo"),
|
|
"number": githubv4.Int(123),
|
|
},
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"repository": map[string]any{
|
|
"issue": map[string]any{
|
|
"id": githubv4.ID("test-issue-id"),
|
|
"assignees": map[string]any{
|
|
"nodes": []any{},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
githubv4mock.NewMutationMatcher(
|
|
struct {
|
|
ReplaceActorsForAssignable struct {
|
|
Typename string `graphql:"__typename"`
|
|
} `graphql:"replaceActorsForAssignable(input: $input)"`
|
|
}{},
|
|
ReplaceActorsForAssignableInput{
|
|
AssignableID: githubv4.ID("test-issue-id"),
|
|
ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")},
|
|
},
|
|
nil,
|
|
githubv4mock.DataResponse(map[string]any{}),
|
|
),
|
|
),
|
|
},
|
|
{
|
|
name: "copilot not a suggested actor",
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issueNumber": float64(123),
|
|
},
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(
|
|
githubv4mock.NewQueryMatcher(
|
|
struct {
|
|
Repository struct {
|
|
SuggestedActors struct {
|
|
Nodes []struct {
|
|
Bot struct {
|
|
ID githubv4.ID
|
|
Login githubv4.String
|
|
TypeName string `graphql:"__typename"`
|
|
} `graphql:"... on Bot"`
|
|
}
|
|
PageInfo struct {
|
|
HasNextPage bool
|
|
EndCursor string
|
|
}
|
|
} `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"`
|
|
} `graphql:"repository(owner: $owner, name: $name)"`
|
|
}{},
|
|
map[string]any{
|
|
"owner": githubv4.String("owner"),
|
|
"name": githubv4.String("repo"),
|
|
"endCursor": (*githubv4.String)(nil),
|
|
},
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"repository": map[string]any{
|
|
"suggestedActors": map[string]any{
|
|
"nodes": []any{},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
expectToolError: true,
|
|
expectedToolErrMsg: "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information.",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
t.Parallel()
|
|
// Setup client with mock
|
|
client := githubv4.NewClient(tc.mockedClient)
|
|
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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
|
|
mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Sub-issue cannot be a parent of itself"}]}`),
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{
|
|
"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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
// No mocked requests needed since validation fails before HTTP call
|
|
),
|
|
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: mock.NewMockedHTTPClient(
|
|
// No mocked requests needed since validation fails before HTTP call
|
|
),
|
|
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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatch(
|
|
mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
|
|
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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
|
|
expectQueryParams(t, map[string]string{
|
|
"page": "2",
|
|
"per_page": "10",
|
|
}).andThen(
|
|
mockResponse(t, http.StatusOK, mockSubIssues),
|
|
),
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{
|
|
"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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatch(
|
|
mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
|
|
[]*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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
// No mocked requests needed since validation fails before HTTP call
|
|
),
|
|
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: mock.NewMockedHTTPClient(
|
|
// No mocked requests needed since validation fails before HTTP call
|
|
),
|
|
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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
// No mocked requests needed since validation fails before HTTP call
|
|
),
|
|
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: mock.NewMockedHTTPClient(
|
|
// No mocked requests needed since validation fails before HTTP call
|
|
),
|
|
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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
// No mocked requests needed since validation fails before HTTP call
|
|
),
|
|
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: mock.NewMockedHTTPClient(
|
|
// No mocked requests needed since validation fails before HTTP call
|
|
),
|
|
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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.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: mock.NewMockedHTTPClient(
|
|
// No mocked requests needed since validation fails before HTTP call
|
|
),
|
|
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: mock.NewMockedHTTPClient(
|
|
// No mocked requests needed since validation fails before HTTP call
|
|
),
|
|
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: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.EndpointPattern{
|
|
Pattern: "/orgs/testorg/issue-types",
|
|
Method: "GET",
|
|
},
|
|
mockResponse(t, http.StatusOK, mockIssueTypes),
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{
|
|
"owner": "testorg",
|
|
},
|
|
expectError: false,
|
|
expectedIssueTypes: mockIssueTypes,
|
|
},
|
|
{
|
|
name: "organization not found",
|
|
mockedClient: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.EndpointPattern{
|
|
Pattern: "/orgs/nonexistent/issue-types",
|
|
Method: "GET",
|
|
},
|
|
mockResponse(t, http.StatusNotFound, `{"message": "Organization not found"}`),
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{
|
|
"owner": "nonexistent",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "failed to list issue types",
|
|
},
|
|
{
|
|
name: "missing owner parameter",
|
|
mockedClient: mock.NewMockedHTTPClient(
|
|
mock.WithRequestMatchHandler(
|
|
mock.EndpointPattern{
|
|
Pattern: "/orgs/testorg/issue-types",
|
|
Method: "GET",
|
|
},
|
|
mockResponse(t, http.StatusOK, mockIssueTypes),
|
|
),
|
|
),
|
|
requestArgs: map[string]interface{}{},
|
|
expectError: false, // This should be handled by parameter validation, error returned in result
|
|
expectedErrMsg: "missing required parameter: owner",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Setup client with mock
|
|
client := github.NewClient(tc.mockedClient)
|
|
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|