Files
Martin Høst Normark 60a5391d67 Add tools for sub-issue endpoint (#470)
* Create 'add sub-issue' tool

* Fix hardcoded API host

* Create 'list sub-issues' tool

* Create 'remove sub-issue' tool

* Fix Test_GetIssue mock data - add missing User field

The assertion was already checking User.Login but the mock was incomplete

* Create 'reprioritize sub-issue' tool

* fixes

* use go github pck to add sub-issues

* Update to use go github package

* update description

* update to use go github v73

* lint, docs

* refactor: tests to use go-github-mock

* add toolsnaps

* make RemoveSubIssue use NewGitHubAPIErrorResponse, update docstring

* Always include SHA in get_file_contents responses (#676)

* fix: Add SHA to get_file_contents while preserving MCP behavior (#595)

Enhance get_file_contents to include SHA information without changing
the existing MCP server response format.

Changes:
- Add Contents API call to retrieve SHA before fetching raw content
- Include SHA in resourceURI (repo://owner/repo/sha/{SHA}/contents/path)
- Add SHA to success messages
- Update tests to verify SHA inclusion
- Maintain original behavior: text files return raw text, binaries return base64

This preserves backward compatibility while providing SHA information
for better file versioning support.

Closes #595

* fix: Improve error handling for Contents API response

Ensure response body is properly closed even when an error occurs by moving
the defer statement before the error check. This prevents potential resource
leaks when the Contents API returns an error with a non-nil response.

Changes:
- Move defer respContents.Body.Close() before error checking
- Rename errContents to err for consistency
- Add nil check for respContents before attempting to close body

This follows Go best practices for handling HTTP responses and prevents
potential goroutine/memory leaks.

* revert changes to resource URI

* use GraphQL API to get file SHA

* refactor: mock GQL client instead of getFileSHA function to follow conventions

* lint

* revert GraphQL

---------

Co-authored-by: LuluBeatson <lulubeatson@github.com>

* Reorganize README, add dedicated install guides, include policies and governance info for the github server (#695)

* Refactor README and add host installation guides, governance docs

- Reorganized README for clarity and navigation
- Added dedicated installation guides for Claude, Cursor, Windsurf, JetBrains, and more
- Clarified contribution guidelines and approval criteria
- Added policies and governance documentation

* Update README.md

* Update README with configuration section for remote GitHub MCP Server

* Update MCP access policy description in README

Removing coding agent from the policy note, as the GitHub server is unaffected by this policy

* Update configuration steps for GitHub Copilot in JetBrains IDEs...

...to reflect changes in accessing settings and configuring MCP.

* Update install-other-copilot-ides.md

* Update Eclipse MCP support version and configuration steps...

...for GitHub Copilot plugin in installation guide.

* Update docs/installation-guides/install-cursor.md

* Update docs/installation-guides/install-windsurf.md

* Apply suggestion from @tonytrg

* Apply suggestion from @tonytrg

* Apply suggestion from @tonytrg

* Apply suggestion from @tonytrg

* Apply suggestion from @tonytrg

* Apply suggestion from @tonytrg

* Apply suggestion from @tonytrg

---------

Co-authored-by: Tony Truong <tonytrg@github.com>

* fix: shorten long tool name for adding pr review comments (#697)

* shorten tool name

* update function name to match tool name

* adjust wording of descriptions

* Update installation guide for GitHub MCP Server (#699)

* Update installation guide for GitHub MCP Server

Removed reference to GitHub.com in the installation guide. The GitHub server is available to Coding Agent by default, without installation needed.

* Rename section to 'Install in Other MCP Hosts'

Updating title for consistency and adding a link to the "other Copilot IDEs" install guide.

* Revise installation guide for Cursor MCP setup

Updated installation guide for Cursor with steps clarified, remote server installation, and one-click install deeplinks to open Cursor and add the github server to the config file.

* fix: make mcpcurl support "integer" type (#688)

- FYI:https://json-schema.org/understanding-json-schema/reference/numeric#integer

* Added installation instructions for mcpcurl (#719)

* Added installation instructions for mcpcurl

* Update cmd/mcpcurl/README.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Add pagination support to GraphQL-based tools (#683)

* initial pagination for `ListDiscussions`

* redo category id var cast

* add GraphQL pagination support for discussion comments and categories

* remove pageinfo returns

* fix out ref for linter

* update docs

* move to unified pagination for consensus on params

* update docs

* refactor pagination handling

* update docs

* linter fix

* conv rest to gql params for safe lint

* add nolint

* add error handling for perPage value in ToGraphQLParams

* refactor pagination error handling

* unified params for rest andn graphql and rennamed to be uniform for golang

* add 'after' for pagination

* update docs

* Update pkg/github/discussions.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update pkg/github/discussions.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update pkg/github/discussions_test.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* update default page size const

* reduce default pagination size from 100 to 30 in discussion tests

* update pagination for reverse and total

* update pagination to remove from discussions

* updated README

* improve the `ToGraphQLParams` function

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* shorten param descriptions

* fix: resp nil check in error handling in RemoveSubIssue function

---------

Co-authored-by: LuluBeatson <lulubeatson@github.com>
Co-authored-by: tommaso-moro <tommaso-moro@github.com>
2025-07-22 09:24:46 +01:00

2604 lines
79 KiB
Go

package github
import (
"context"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v73/github"
"github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_GetIssue(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := GetIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "get_issue", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "issue_number")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"})
// Setup mock issue for success case
mockIssue := &github.Issue{
Number: github.Ptr(42),
Title: github.Ptr("Test Issue"),
Body: github.Ptr("This is a test issue"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
User: &github.User{
Login: github.Ptr("testuser"),
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedIssue *github.Issue
expectedErrMsg string
}{
{
name: "successful issue retrieval",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetReposIssuesByOwnerByRepoByIssueNumber,
mockIssue,
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "issue not found",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetReposIssuesByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
},
expectError: true,
expectedErrMsg: "failed to get issue",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
_, handler := GetIssue(stubGetClientFn(client), translations.NullTranslationHelper)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(context.Background(), request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
require.NoError(t, err)
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedIssue github.Issue
err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
require.NoError(t, err)
assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number)
assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title)
assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body)
assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State)
assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL)
assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login)
})
}
}
func Test_AddIssueComment(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := AddIssueComment(stubGetClientFn(mockClient), translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "add_issue_comment", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "issue_number")
assert.Contains(t, tool.InputSchema.Properties, "body")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number", "body"})
// Setup mock comment for success case
mockComment := &github.IssueComment{
ID: github.Ptr(int64(123)),
Body: github.Ptr("This is a test comment"),
User: &github.User{
Login: github.Ptr("testuser"),
},
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42#issuecomment-123"),
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedComment *github.IssueComment
expectedErrMsg string
}{
{
name: "successful comment creation",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PostReposIssuesCommentsByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusCreated, mockComment),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"body": "This is a test comment",
},
expectError: false,
expectedComment: mockComment,
},
{
name: "comment creation fails",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PostReposIssuesCommentsByOwnerByRepoByIssueNumber,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message": "Invalid request"}`))
}),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"body": "",
},
expectError: false,
expectedErrMsg: "missing required parameter: body",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
_, handler := AddIssueComment(stubGetClientFn(client), translations.NullTranslationHelper)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(context.Background(), request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
if tc.expectedErrMsg != "" {
require.NotNil(t, result)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedComment github.IssueComment
err = json.Unmarshal([]byte(textContent.Text), &returnedComment)
require.NoError(t, err)
assert.Equal(t, *tc.expectedComment.ID, *returnedComment.ID)
assert.Equal(t, *tc.expectedComment.Body, *returnedComment.Body)
assert.Equal(t, *tc.expectedComment.User.Login, *returnedComment.User.Login)
})
}
}
func Test_SearchIssues(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := SearchIssues(stubGetClientFn(mockClient), translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "search_issues", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "query")
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "sort")
assert.Contains(t, tool.InputSchema.Properties, "order")
assert.Contains(t, tool.InputSchema.Properties, "perPage")
assert.Contains(t, tool.InputSchema.Properties, "page")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"})
// Setup mock search results
mockSearchResult := &github.IssuesSearchResult{
Total: github.Ptr(2),
IncompleteResults: github.Ptr(false),
Issues: []*github.Issue{
{
Number: github.Ptr(42),
Title: github.Ptr("Bug: Something is broken"),
Body: github.Ptr("This is a bug report"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
Comments: github.Ptr(5),
User: &github.User{
Login: github.Ptr("user1"),
},
},
{
Number: github.Ptr(43),
Title: github.Ptr("Feature: Add new functionality"),
Body: github.Ptr("This is a feature request"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/43"),
Comments: github.Ptr(3),
User: &github.User{
Login: github.Ptr("user2"),
},
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedResult *github.IssuesSearchResult
expectedErrMsg string
}{
{
name: "successful issues search with all parameters",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetSearchIssues,
expectQueryParams(
t,
map[string]string{
"q": "is:issue repo:owner/repo is:open",
"sort": "created",
"order": "desc",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
),
),
requestArgs: map[string]interface{}{
"query": "repo:owner/repo is:open",
"sort": "created",
"order": "desc",
"page": float64(1),
"perPage": float64(30),
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "issues search with owner and repo parameters",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetSearchIssues,
expectQueryParams(
t,
map[string]string{
"q": "repo:test-owner/test-repo is:issue is:open",
"sort": "created",
"order": "asc",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
),
),
requestArgs: map[string]interface{}{
"query": "is:open",
"owner": "test-owner",
"repo": "test-repo",
"sort": "created",
"order": "asc",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "issues search with only owner parameter (should ignore it)",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetSearchIssues,
expectQueryParams(
t,
map[string]string{
"q": "is:issue bug",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
),
),
requestArgs: map[string]interface{}{
"query": "bug",
"owner": "test-owner",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "issues search with only repo parameter (should ignore it)",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetSearchIssues,
expectQueryParams(
t,
map[string]string{
"q": "is:issue feature",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
),
),
requestArgs: map[string]interface{}{
"query": "feature",
"repo": "test-repo",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "issues search with minimal parameters",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetSearchIssues,
mockSearchResult,
),
),
requestArgs: map[string]interface{}{
"query": "is:issue repo:owner/repo is:open",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "search issues fails",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetSearchIssues,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"message": "Validation Failed"}`))
}),
),
),
requestArgs: map[string]interface{}{
"query": "invalid:query",
},
expectError: true,
expectedErrMsg: "failed to search issues",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
_, handler := SearchIssues(stubGetClientFn(client), translations.NullTranslationHelper)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(context.Background(), request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
require.NoError(t, err)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedResult github.IssuesSearchResult
err = json.Unmarshal([]byte(textContent.Text), &returnedResult)
require.NoError(t, err)
assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total)
assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults)
assert.Len(t, returnedResult.Issues, len(tc.expectedResult.Issues))
for i, issue := range returnedResult.Issues {
assert.Equal(t, *tc.expectedResult.Issues[i].Number, *issue.Number)
assert.Equal(t, *tc.expectedResult.Issues[i].Title, *issue.Title)
assert.Equal(t, *tc.expectedResult.Issues[i].State, *issue.State)
assert.Equal(t, *tc.expectedResult.Issues[i].HTMLURL, *issue.HTMLURL)
assert.Equal(t, *tc.expectedResult.Issues[i].User.Login, *issue.User.Login)
}
})
}
}
func Test_CreateIssue(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := CreateIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "create_issue", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "title")
assert.Contains(t, tool.InputSchema.Properties, "body")
assert.Contains(t, tool.InputSchema.Properties, "assignees")
assert.Contains(t, tool.InputSchema.Properties, "labels")
assert.Contains(t, tool.InputSchema.Properties, "milestone")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "title"})
// Setup mock issue for success case
mockIssue := &github.Issue{
Number: github.Ptr(123),
Title: github.Ptr("Test Issue"),
Body: github.Ptr("This is a test issue"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
Assignees: []*github.User{{Login: github.Ptr("user1")}, {Login: github.Ptr("user2")}},
Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("help wanted")}},
Milestone: &github.Milestone{Number: github.Ptr(5)},
}
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),
}).andThen(
mockResponse(t, http.StatusCreated, mockIssue),
),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"title": "Test Issue",
"body": "This is a test issue",
"assignees": []any{"user1", "user2"},
"labels": []any{"bug", "help wanted"},
"milestone": float64(5),
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "successful issue creation with minimal fields",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PostReposIssuesByOwnerByRepo,
mockResponse(t, http.StatusCreated, &github.Issue{
Number: github.Ptr(124),
Title: github.Ptr("Minimal Issue"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"),
State: github.Ptr("open"),
}),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"title": "Minimal Issue",
"assignees": nil, // Expect no failure with nil optional value.
},
expectError: false,
expectedIssue: &github.Issue{
Number: github.Ptr(124),
Title: github.Ptr("Minimal Issue"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"),
State: github.Ptr("open"),
},
},
{
name: "issue creation fails",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PostReposIssuesByOwnerByRepo,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message": "Validation failed"}`))
}),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"title": "",
},
expectError: false,
expectedErrMsg: "missing required parameter: title",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
_, handler := CreateIssue(stubGetClientFn(client), translations.NullTranslationHelper)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(context.Background(), request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
if tc.expectedErrMsg != "" {
require.NotNil(t, result)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedIssue github.Issue
err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
require.NoError(t, err)
assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number)
assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title)
assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State)
assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL)
if tc.expectedIssue.Body != nil {
assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body)
}
// Check assignees if expected
if len(tc.expectedIssue.Assignees) > 0 {
assert.Equal(t, len(tc.expectedIssue.Assignees), len(returnedIssue.Assignees))
for i, assignee := range returnedIssue.Assignees {
assert.Equal(t, *tc.expectedIssue.Assignees[i].Login, *assignee.Login)
}
}
// Check labels if expected
if len(tc.expectedIssue.Labels) > 0 {
assert.Equal(t, len(tc.expectedIssue.Labels), len(returnedIssue.Labels))
for i, label := range returnedIssue.Labels {
assert.Equal(t, *tc.expectedIssue.Labels[i].Name, *label.Name)
}
}
})
}
}
func Test_ListIssues(t *testing.T) {
// Verify tool definition
mockClient := github.NewClient(nil)
tool, _ := ListIssues(stubGetClientFn(mockClient), translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "list_issues", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "state")
assert.Contains(t, tool.InputSchema.Properties, "labels")
assert.Contains(t, tool.InputSchema.Properties, "sort")
assert.Contains(t, tool.InputSchema.Properties, "direction")
assert.Contains(t, tool.InputSchema.Properties, "since")
assert.Contains(t, tool.InputSchema.Properties, "page")
assert.Contains(t, tool.InputSchema.Properties, "perPage")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
// Setup mock issues for success case
mockIssues := []*github.Issue{
{
Number: github.Ptr(123),
Title: github.Ptr("First Issue"),
Body: github.Ptr("This is the first test issue"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
CreatedAt: &github.Timestamp{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)},
},
{
Number: github.Ptr(456),
Title: github.Ptr("Second Issue"),
Body: github.Ptr("This is the second test issue"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/456"),
Labels: []*github.Label{{Name: github.Ptr("bug")}},
CreatedAt: &github.Timestamp{Time: time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC)},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedIssues []*github.Issue
expectedErrMsg string
}{
{
name: "list issues with minimal parameters",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetReposIssuesByOwnerByRepo,
mockIssues,
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
},
expectError: false,
expectedIssues: mockIssues,
},
{
name: "list issues with all parameters",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetReposIssuesByOwnerByRepo,
expectQueryParams(t, map[string]string{
"state": "open",
"labels": "bug,enhancement",
"sort": "created",
"direction": "desc",
"since": "2023-01-01T00:00:00Z",
"page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, mockIssues),
),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"state": "open",
"labels": []any{"bug", "enhancement"},
"sort": "created",
"direction": "desc",
"since": "2023-01-01T00:00:00Z",
"page": float64(1),
"perPage": float64(30),
},
expectError: false,
expectedIssues: mockIssues,
},
{
name: "invalid since parameter",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetReposIssuesByOwnerByRepo,
mockIssues,
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"since": "invalid-date",
},
expectError: true,
expectedErrMsg: "invalid ISO 8601 timestamp",
},
{
name: "list issues fails with error",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetReposIssuesByOwnerByRepo,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message": "Repository not found"}`))
}),
),
),
requestArgs: map[string]interface{}{
"owner": "nonexistent",
"repo": "repo",
},
expectError: true,
expectedErrMsg: "failed to list issues",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
_, handler := ListIssues(stubGetClientFn(client), translations.NullTranslationHelper)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(context.Background(), request)
// Verify results
if tc.expectError {
if err != nil {
assert.Contains(t, err.Error(), tc.expectedErrMsg)
} else {
// For errors returned as part of the result, not as an error
assert.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 returnedIssues []*github.Issue
err = json.Unmarshal([]byte(textContent.Text), &returnedIssues)
require.NoError(t, err)
assert.Len(t, returnedIssues, len(tc.expectedIssues))
for i, issue := range returnedIssues {
assert.Equal(t, *tc.expectedIssues[i].Number, *issue.Number)
assert.Equal(t, *tc.expectedIssues[i].Title, *issue.Title)
assert.Equal(t, *tc.expectedIssues[i].State, *issue.State)
assert.Equal(t, *tc.expectedIssues[i].HTMLURL, *issue.HTMLURL)
}
})
}
}
func Test_UpdateIssue(t *testing.T) {
// Verify tool definition
mockClient := github.NewClient(nil)
tool, _ := UpdateIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "update_issue", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "issue_number")
assert.Contains(t, tool.InputSchema.Properties, "title")
assert.Contains(t, tool.InputSchema.Properties, "body")
assert.Contains(t, tool.InputSchema.Properties, "state")
assert.Contains(t, tool.InputSchema.Properties, "labels")
assert.Contains(t, tool.InputSchema.Properties, "assignees")
assert.Contains(t, tool.InputSchema.Properties, "milestone")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"})
// Setup mock issue for success case
mockIssue := &github.Issue{
Number: github.Ptr(123),
Title: github.Ptr("Updated Issue Title"),
Body: github.Ptr("Updated issue description"),
State: github.Ptr("closed"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}},
Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}},
Milestone: &github.Milestone{Number: github.Ptr(5)},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedIssue *github.Issue
expectedErrMsg string
}{
{
name: "update issue with all fields",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PatchReposIssuesByOwnerByRepoByIssueNumber,
expectRequestBody(t, map[string]any{
"title": "Updated Issue Title",
"body": "Updated issue description",
"state": "closed",
"labels": []any{"bug", "priority"},
"assignees": []any{"assignee1", "assignee2"},
"milestone": float64(5),
}).andThen(
mockResponse(t, http.StatusOK, mockIssue),
),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
"title": "Updated Issue Title",
"body": "Updated issue description",
"state": "closed",
"labels": []any{"bug", "priority"},
"assignees": []any{"assignee1", "assignee2"},
"milestone": float64(5),
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "update issue with minimal fields",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PatchReposIssuesByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusOK, &github.Issue{
Number: github.Ptr(123),
Title: github.Ptr("Only Title Updated"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
State: github.Ptr("open"),
}),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
"title": "Only Title Updated",
},
expectError: false,
expectedIssue: &github.Issue{
Number: github.Ptr(123),
Title: github.Ptr("Only Title Updated"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
State: github.Ptr("open"),
},
},
{
name: "update issue fails with not found",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PatchReposIssuesByOwnerByRepoByIssueNumber,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message": "Issue not found"}`))
}),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
"title": "This issue doesn't exist",
},
expectError: true,
expectedErrMsg: "failed to update issue",
},
{
name: "update issue fails with validation error",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PatchReposIssuesByOwnerByRepoByIssueNumber,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message": "Invalid state value"}`))
}),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(123),
"state": "invalid_state",
},
expectError: true,
expectedErrMsg: "failed to update issue",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
_, handler := UpdateIssue(stubGetClientFn(client), translations.NullTranslationHelper)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(context.Background(), request)
// Verify results
if tc.expectError {
if err != nil {
assert.Contains(t, err.Error(), tc.expectedErrMsg)
} else {
// For errors returned as part of the result, not as an error
require.NotNil(t, result)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
}
return
}
require.NoError(t, err)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedIssue github.Issue
err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
require.NoError(t, err)
assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number)
assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title)
assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State)
assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL)
if tc.expectedIssue.Body != nil {
assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body)
}
// Check assignees if expected
if len(tc.expectedIssue.Assignees) > 0 {
assert.Len(t, returnedIssue.Assignees, len(tc.expectedIssue.Assignees))
for i, assignee := range returnedIssue.Assignees {
assert.Equal(t, *tc.expectedIssue.Assignees[i].Login, *assignee.Login)
}
}
// Check labels if expected
if len(tc.expectedIssue.Labels) > 0 {
assert.Len(t, returnedIssue.Labels, len(tc.expectedIssue.Labels))
for i, label := range returnedIssue.Labels {
assert.Equal(t, *tc.expectedIssue.Labels[i].Name, *label.Name)
}
}
// Check milestone if expected
if tc.expectedIssue.Milestone != nil {
assert.NotNil(t, returnedIssue.Milestone)
assert.Equal(t, *tc.expectedIssue.Milestone.Number, *returnedIssue.Milestone.Number)
}
})
}
}
func Test_ParseISOTimestamp(t *testing.T) {
tests := []struct {
name string
input string
expectedErr bool
expectedTime time.Time
}{
{
name: "valid RFC3339 format",
input: "2023-01-15T14:30:00Z",
expectedErr: false,
expectedTime: time.Date(2023, 1, 15, 14, 30, 0, 0, time.UTC),
},
{
name: "valid date only format",
input: "2023-01-15",
expectedErr: false,
expectedTime: time.Date(2023, 1, 15, 0, 0, 0, 0, time.UTC),
},
{
name: "empty timestamp",
input: "",
expectedErr: true,
},
{
name: "invalid format",
input: "15/01/2023",
expectedErr: true,
},
{
name: "invalid date",
input: "2023-13-45",
expectedErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
parsedTime, err := parseISOTimestamp(tc.input)
if tc.expectedErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expectedTime, parsedTime)
}
})
}
}
func Test_GetIssueComments(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := GetIssueComments(stubGetClientFn(mockClient), translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "get_issue_comments", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "issue_number")
assert.Contains(t, tool.InputSchema.Properties, "page")
assert.Contains(t, tool.InputSchema.Properties, "perPage")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"})
// Setup mock comments for success case
mockComments := []*github.IssueComment{
{
ID: github.Ptr(int64(123)),
Body: github.Ptr("This is the first comment"),
User: &github.User{
Login: github.Ptr("user1"),
},
CreatedAt: &github.Timestamp{Time: time.Now().Add(-time.Hour * 24)},
},
{
ID: github.Ptr(int64(456)),
Body: github.Ptr("This is the second comment"),
User: &github.User{
Login: github.Ptr("user2"),
},
CreatedAt: &github.Timestamp{Time: time.Now().Add(-time.Hour)},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedComments []*github.IssueComment
expectedErrMsg string
}{
{
name: "successful comments retrieval",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber,
mockComments,
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedComments: mockComments,
},
{
name: "successful comments retrieval with pagination",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber,
expectQueryParams(t, map[string]string{
"page": "2",
"per_page": "10",
}).andThen(
mockResponse(t, http.StatusOK, mockComments),
),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"page": float64(2),
"perPage": float64(10),
},
expectError: false,
expectedComments: mockComments,
},
{
name: "issue not found",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
},
expectError: true,
expectedErrMsg: "failed to get issue comments",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
_, handler := GetIssueComments(stubGetClientFn(client), translations.NullTranslationHelper)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(context.Background(), request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
require.NoError(t, err)
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedComments []*github.IssueComment
err = json.Unmarshal([]byte(textContent.Text), &returnedComments)
require.NoError(t, err)
assert.Equal(t, len(tc.expectedComments), len(returnedComments))
if len(returnedComments) > 0 {
assert.Equal(t, *tc.expectedComments[0].Body, *returnedComments[0].Body)
assert.Equal(t, *tc.expectedComments[0].User.Login, *returnedComments[0].User.Login)
}
})
}
}
func TestAssignCopilotToIssue(t *testing.T) {
t.Parallel()
// Verify tool definition
mockClient := githubv4.NewClient(nil)
tool, _ := AssignCopilotToIssue(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "assign_copilot_to_issue", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "issueNumber")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issueNumber"})
var pageOfFakeBots = func(n int) []struct{} {
// We don't _really_ need real bots here, just objects that count as entries for the page
bots := make([]struct{}, n)
for i := range n {
bots[i] = struct{}{}
}
return bots
}
tests := []struct {
name string
requestArgs map[string]any
mockedClient *http.Client
expectToolError bool
expectedToolErrMsg string
}{
{
name: "successful assignment when there are no existing assignees",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issueNumber": float64(123),
},
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
SuggestedActors struct {
Nodes []struct {
Bot struct {
ID githubv4.ID
Login githubv4.String
TypeName string `graphql:"__typename"`
} `graphql:"... on Bot"`
}
PageInfo struct {
HasNextPage bool
EndCursor string
}
} `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"name": githubv4.String("repo"),
"endCursor": (*githubv4.String)(nil),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"suggestedActors": map[string]any{
"nodes": []any{
map[string]any{
"id": githubv4.ID("copilot-swe-agent-id"),
"login": githubv4.String("copilot-swe-agent"),
"__typename": "Bot",
},
},
},
},
}),
),
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
ID githubv4.ID
Assignees struct {
Nodes []struct {
ID githubv4.ID
}
} `graphql:"assignees(first: 100)"`
} `graphql:"issue(number: $number)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"name": githubv4.String("repo"),
"number": githubv4.Int(123),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"id": githubv4.ID("test-issue-id"),
"assignees": map[string]any{
"nodes": []any{},
},
},
},
}),
),
githubv4mock.NewMutationMatcher(
struct {
ReplaceActorsForAssignable struct {
Typename string `graphql:"__typename"`
} `graphql:"replaceActorsForAssignable(input: $input)"`
}{},
ReplaceActorsForAssignableInput{
AssignableID: githubv4.ID("test-issue-id"),
ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")},
},
nil,
githubv4mock.DataResponse(map[string]any{}),
),
),
},
{
name: "successful assignment when there are existing assignees",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issueNumber": float64(123),
},
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
SuggestedActors struct {
Nodes []struct {
Bot struct {
ID githubv4.ID
Login githubv4.String
TypeName string `graphql:"__typename"`
} `graphql:"... on Bot"`
}
PageInfo struct {
HasNextPage bool
EndCursor string
}
} `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"name": githubv4.String("repo"),
"endCursor": (*githubv4.String)(nil),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"suggestedActors": map[string]any{
"nodes": []any{
map[string]any{
"id": githubv4.ID("copilot-swe-agent-id"),
"login": githubv4.String("copilot-swe-agent"),
"__typename": "Bot",
},
},
},
},
}),
),
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
ID githubv4.ID
Assignees struct {
Nodes []struct {
ID githubv4.ID
}
} `graphql:"assignees(first: 100)"`
} `graphql:"issue(number: $number)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"name": githubv4.String("repo"),
"number": githubv4.Int(123),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"id": githubv4.ID("test-issue-id"),
"assignees": map[string]any{
"nodes": []any{
map[string]any{
"id": githubv4.ID("existing-assignee-id"),
},
map[string]any{
"id": githubv4.ID("existing-assignee-id-2"),
},
},
},
},
},
}),
),
githubv4mock.NewMutationMatcher(
struct {
ReplaceActorsForAssignable struct {
Typename string `graphql:"__typename"`
} `graphql:"replaceActorsForAssignable(input: $input)"`
}{},
ReplaceActorsForAssignableInput{
AssignableID: githubv4.ID("test-issue-id"),
ActorIDs: []githubv4.ID{
githubv4.ID("existing-assignee-id"),
githubv4.ID("existing-assignee-id-2"),
githubv4.ID("copilot-swe-agent-id"),
},
},
nil,
githubv4mock.DataResponse(map[string]any{}),
),
),
},
{
name: "copilot bot not on first page of suggested actors",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issueNumber": float64(123),
},
mockedClient: githubv4mock.NewMockedHTTPClient(
// First page of suggested actors
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
SuggestedActors struct {
Nodes []struct {
Bot struct {
ID githubv4.ID
Login githubv4.String
TypeName string `graphql:"__typename"`
} `graphql:"... on Bot"`
}
PageInfo struct {
HasNextPage bool
EndCursor string
}
} `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"name": githubv4.String("repo"),
"endCursor": (*githubv4.String)(nil),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"suggestedActors": map[string]any{
"nodes": pageOfFakeBots(100),
"pageInfo": map[string]any{
"hasNextPage": true,
"endCursor": githubv4.String("next-page-cursor"),
},
},
},
}),
),
// Second page of suggested actors
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
SuggestedActors struct {
Nodes []struct {
Bot struct {
ID githubv4.ID
Login githubv4.String
TypeName string `graphql:"__typename"`
} `graphql:"... on Bot"`
}
PageInfo struct {
HasNextPage bool
EndCursor string
}
} `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"name": githubv4.String("repo"),
"endCursor": githubv4.String("next-page-cursor"),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"suggestedActors": map[string]any{
"nodes": []any{
map[string]any{
"id": githubv4.ID("copilot-swe-agent-id"),
"login": githubv4.String("copilot-swe-agent"),
"__typename": "Bot",
},
},
},
},
}),
),
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Issue struct {
ID githubv4.ID
Assignees struct {
Nodes []struct {
ID githubv4.ID
}
} `graphql:"assignees(first: 100)"`
} `graphql:"issue(number: $number)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"name": githubv4.String("repo"),
"number": githubv4.Int(123),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issue": map[string]any{
"id": githubv4.ID("test-issue-id"),
"assignees": map[string]any{
"nodes": []any{},
},
},
},
}),
),
githubv4mock.NewMutationMatcher(
struct {
ReplaceActorsForAssignable struct {
Typename string `graphql:"__typename"`
} `graphql:"replaceActorsForAssignable(input: $input)"`
}{},
ReplaceActorsForAssignableInput{
AssignableID: githubv4.ID("test-issue-id"),
ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")},
},
nil,
githubv4mock.DataResponse(map[string]any{}),
),
),
},
{
name: "copilot not a suggested actor",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issueNumber": float64(123),
},
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
SuggestedActors struct {
Nodes []struct {
Bot struct {
ID githubv4.ID
Login githubv4.String
TypeName string `graphql:"__typename"`
} `graphql:"... on Bot"`
}
PageInfo struct {
HasNextPage bool
EndCursor string
}
} `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"name": githubv4.String("repo"),
"endCursor": (*githubv4.String)(nil),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"suggestedActors": map[string]any{
"nodes": []any{},
},
},
}),
),
),
expectToolError: true,
expectedToolErrMsg: "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information.",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Setup client with mock
client := githubv4.NewClient(tc.mockedClient)
_, handler := AssignCopilotToIssue(stubGetGQLClientFn(client), translations.NullTranslationHelper)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(context.Background(), request)
require.NoError(t, err)
textContent := getTextResult(t, result)
if tc.expectToolError {
require.True(t, result.IsError)
assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
return
}
require.False(t, result.IsError, fmt.Sprintf("expected there to be no tool error, text was %s", textContent.Text))
require.Equal(t, textContent.Text, "successfully assigned copilot to issue")
})
}
}
func Test_AddSubIssue(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := AddSubIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "add_sub_issue", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "issue_number")
assert.Contains(t, tool.InputSchema.Properties, "sub_issue_id")
assert.Contains(t, tool.InputSchema.Properties, "replace_parent")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number", "sub_issue_id"})
// Setup mock issue for success case (matches GitHub API response format)
mockIssue := &github.Issue{
Number: github.Ptr(42),
Title: github.Ptr("Parent Issue"),
Body: github.Ptr("This is the parent issue with a sub-issue"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
User: &github.User{
Login: github.Ptr("testuser"),
},
Labels: []*github.Label{
{
Name: github.Ptr("enhancement"),
Color: github.Ptr("84b6eb"),
Description: github.Ptr("New feature or request"),
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedIssue *github.Issue
expectedErrMsg string
}{
{
name: "successful sub-issue addition with all parameters",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusCreated, mockIssue),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"replace_parent": true,
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "successful sub-issue addition with minimal parameters",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusCreated, mockIssue),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(456),
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "successful sub-issue addition with replace_parent false",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusCreated, mockIssue),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(789),
"replace_parent": false,
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "parent issue not found",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusNotFound, `{"message": "Parent issue not found"}`),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "failed to add sub-issue",
},
{
name: "sub-issue not found",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(999),
},
expectError: false,
expectedErrMsg: "failed to add sub-issue",
},
{
name: "validation failed - sub-issue cannot be parent of itself",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Sub-issue cannot be a parent of itself"}]}`),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(42),
},
expectError: false,
expectedErrMsg: "failed to add sub-issue",
},
{
name: "insufficient permissions",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "failed to add sub-issue",
},
{
name: "missing required parameter owner",
mockedClient: mock.NewMockedHTTPClient(
// No mocked requests needed since validation fails before HTTP call
),
requestArgs: map[string]interface{}{
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "missing required parameter: owner",
},
{
name: "missing required parameter sub_issue_id",
mockedClient: mock.NewMockedHTTPClient(
// No mocked requests needed since validation fails before HTTP call
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedErrMsg: "missing required parameter: sub_issue_id",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
_, handler := AddSubIssue(stubGetClientFn(client), translations.NullTranslationHelper)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(context.Background(), request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
if tc.expectedErrMsg != "" {
require.NotNil(t, result)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedIssue github.Issue
err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
require.NoError(t, err)
assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number)
assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title)
assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body)
assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State)
assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL)
assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login)
})
}
}
func Test_ListSubIssues(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := ListSubIssues(stubGetClientFn(mockClient), translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "list_sub_issues", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "issue_number")
assert.Contains(t, tool.InputSchema.Properties, "page")
assert.Contains(t, tool.InputSchema.Properties, "per_page")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"})
// Setup mock sub-issues for success case
mockSubIssues := []*github.Issue{
{
Number: github.Ptr(123),
Title: github.Ptr("Sub-issue 1"),
Body: github.Ptr("This is the first sub-issue"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
User: &github.User{
Login: github.Ptr("user1"),
},
Labels: []*github.Label{
{
Name: github.Ptr("bug"),
Color: github.Ptr("d73a4a"),
Description: github.Ptr("Something isn't working"),
},
},
},
{
Number: github.Ptr(124),
Title: github.Ptr("Sub-issue 2"),
Body: github.Ptr("This is the second sub-issue"),
State: github.Ptr("closed"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"),
User: &github.User{
Login: github.Ptr("user2"),
},
Assignees: []*github.User{
{Login: github.Ptr("assignee1")},
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedSubIssues []*github.Issue
expectedErrMsg string
}{
{
name: "successful sub-issues listing with minimal parameters",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
mockSubIssues,
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedSubIssues: mockSubIssues,
},
{
name: "successful sub-issues listing with pagination",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
expectQueryParams(t, map[string]string{
"page": "2",
"per_page": "10",
}).andThen(
mockResponse(t, http.StatusOK, mockSubIssues),
),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"page": float64(2),
"per_page": float64(10),
},
expectError: false,
expectedSubIssues: mockSubIssues,
},
{
name: "successful sub-issues listing with empty result",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
[]*github.Issue{},
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedSubIssues: []*github.Issue{},
},
{
name: "parent issue not found",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
},
expectError: false,
expectedErrMsg: "failed to list sub-issues",
},
{
name: "repository not found",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
),
),
requestArgs: map[string]interface{}{
"owner": "nonexistent",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedErrMsg: "failed to list sub-issues",
},
{
name: "sub-issues feature gone/deprecated",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusGone, `{"message": "This feature has been deprecated"}`),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedErrMsg: "failed to list sub-issues",
},
{
name: "missing required parameter owner",
mockedClient: mock.NewMockedHTTPClient(
// No mocked requests needed since validation fails before HTTP call
),
requestArgs: map[string]interface{}{
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedErrMsg: "missing required parameter: owner",
},
{
name: "missing required parameter issue_number",
mockedClient: mock.NewMockedHTTPClient(
// No mocked requests needed since validation fails before HTTP call
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
},
expectError: false,
expectedErrMsg: "missing required parameter: issue_number",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
_, handler := ListSubIssues(stubGetClientFn(client), translations.NullTranslationHelper)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(context.Background(), request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
if tc.expectedErrMsg != "" {
require.NotNil(t, result)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedSubIssues []*github.Issue
err = json.Unmarshal([]byte(textContent.Text), &returnedSubIssues)
require.NoError(t, err)
assert.Len(t, returnedSubIssues, len(tc.expectedSubIssues))
for i, subIssue := range returnedSubIssues {
if i < len(tc.expectedSubIssues) {
assert.Equal(t, *tc.expectedSubIssues[i].Number, *subIssue.Number)
assert.Equal(t, *tc.expectedSubIssues[i].Title, *subIssue.Title)
assert.Equal(t, *tc.expectedSubIssues[i].State, *subIssue.State)
assert.Equal(t, *tc.expectedSubIssues[i].HTMLURL, *subIssue.HTMLURL)
assert.Equal(t, *tc.expectedSubIssues[i].User.Login, *subIssue.User.Login)
if tc.expectedSubIssues[i].Body != nil {
assert.Equal(t, *tc.expectedSubIssues[i].Body, *subIssue.Body)
}
}
}
})
}
}
func Test_RemoveSubIssue(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := RemoveSubIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "remove_sub_issue", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "issue_number")
assert.Contains(t, tool.InputSchema.Properties, "sub_issue_id")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number", "sub_issue_id"})
// Setup mock issue for success case (matches GitHub API response format - the updated parent issue)
mockIssue := &github.Issue{
Number: github.Ptr(42),
Title: github.Ptr("Parent Issue"),
Body: github.Ptr("This is the parent issue after sub-issue removal"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
User: &github.User{
Login: github.Ptr("testuser"),
},
Labels: []*github.Label{
{
Name: github.Ptr("enhancement"),
Color: github.Ptr("84b6eb"),
Description: github.Ptr("New feature or request"),
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedIssue *github.Issue
expectedErrMsg string
}{
{
name: "successful sub-issue removal",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusOK, mockIssue),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "parent issue not found",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "failed to remove sub-issue",
},
{
name: "sub-issue not found",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(999),
},
expectError: false,
expectedErrMsg: "failed to remove sub-issue",
},
{
name: "bad request - invalid sub_issue_id",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusBadRequest, `{"message": "Invalid sub_issue_id"}`),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(-1),
},
expectError: false,
expectedErrMsg: "failed to remove sub-issue",
},
{
name: "repository not found",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
),
),
requestArgs: map[string]interface{}{
"owner": "nonexistent",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "failed to remove sub-issue",
},
{
name: "insufficient permissions",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "failed to remove sub-issue",
},
{
name: "missing required parameter owner",
mockedClient: mock.NewMockedHTTPClient(
// No mocked requests needed since validation fails before HTTP call
),
requestArgs: map[string]interface{}{
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "missing required parameter: owner",
},
{
name: "missing required parameter sub_issue_id",
mockedClient: mock.NewMockedHTTPClient(
// No mocked requests needed since validation fails before HTTP call
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
},
expectError: false,
expectedErrMsg: "missing required parameter: sub_issue_id",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
_, handler := RemoveSubIssue(stubGetClientFn(client), translations.NullTranslationHelper)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(context.Background(), request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
if tc.expectedErrMsg != "" {
require.NotNil(t, result)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedIssue github.Issue
err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
require.NoError(t, err)
assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number)
assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title)
assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body)
assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State)
assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL)
assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login)
})
}
}
func Test_ReprioritizeSubIssue(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := ReprioritizeSubIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "reprioritize_sub_issue", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "issue_number")
assert.Contains(t, tool.InputSchema.Properties, "sub_issue_id")
assert.Contains(t, tool.InputSchema.Properties, "after_id")
assert.Contains(t, tool.InputSchema.Properties, "before_id")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number", "sub_issue_id"})
// Setup mock issue for success case (matches GitHub API response format - the updated parent issue)
mockIssue := &github.Issue{
Number: github.Ptr(42),
Title: github.Ptr("Parent Issue"),
Body: github.Ptr("This is the parent issue with reprioritized sub-issues"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
User: &github.User{
Login: github.Ptr("testuser"),
},
Labels: []*github.Label{
{
Name: github.Ptr("enhancement"),
Color: github.Ptr("84b6eb"),
Description: github.Ptr("New feature or request"),
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedIssue *github.Issue
expectedErrMsg string
}{
{
name: "successful reprioritization with after_id",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusOK, mockIssue),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"after_id": float64(456),
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "successful reprioritization with before_id",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusOK, mockIssue),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"before_id": float64(789),
},
expectError: false,
expectedIssue: mockIssue,
},
{
name: "validation error - neither after_id nor before_id specified",
mockedClient: mock.NewMockedHTTPClient(
// No mocked requests needed since validation fails before HTTP call
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
},
expectError: false,
expectedErrMsg: "either after_id or before_id must be specified",
},
{
name: "validation error - both after_id and before_id specified",
mockedClient: mock.NewMockedHTTPClient(
// No mocked requests needed since validation fails before HTTP call
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"after_id": float64(456),
"before_id": float64(789),
},
expectError: false,
expectedErrMsg: "only one of after_id or before_id should be specified, not both",
},
{
name: "parent issue not found",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(999),
"sub_issue_id": float64(123),
"after_id": float64(456),
},
expectError: false,
expectedErrMsg: "failed to reprioritize sub-issue",
},
{
name: "sub-issue not found",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(999),
"after_id": float64(456),
},
expectError: false,
expectedErrMsg: "failed to reprioritize sub-issue",
},
{
name: "validation failed - positioning sub-issue not found",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Positioning sub-issue not found"}]}`),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"after_id": float64(999),
},
expectError: false,
expectedErrMsg: "failed to reprioritize sub-issue",
},
{
name: "insufficient permissions",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"after_id": float64(456),
},
expectError: false,
expectedErrMsg: "failed to reprioritize sub-issue",
},
{
name: "service unavailable",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber,
mockResponse(t, http.StatusServiceUnavailable, `{"message": "Service Unavailable"}`),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"before_id": float64(456),
},
expectError: false,
expectedErrMsg: "failed to reprioritize sub-issue",
},
{
name: "missing required parameter owner",
mockedClient: mock.NewMockedHTTPClient(
// No mocked requests needed since validation fails before HTTP call
),
requestArgs: map[string]interface{}{
"repo": "repo",
"issue_number": float64(42),
"sub_issue_id": float64(123),
"after_id": float64(456),
},
expectError: false,
expectedErrMsg: "missing required parameter: owner",
},
{
name: "missing required parameter sub_issue_id",
mockedClient: mock.NewMockedHTTPClient(
// No mocked requests needed since validation fails before HTTP call
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
"after_id": float64(456),
},
expectError: false,
expectedErrMsg: "missing required parameter: sub_issue_id",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
_, handler := ReprioritizeSubIssue(stubGetClientFn(client), translations.NullTranslationHelper)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(context.Background(), request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
if tc.expectedErrMsg != "" {
require.NotNil(t, result)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedIssue github.Issue
err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
require.NoError(t, err)
assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number)
assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title)
assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body)
assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State)
assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL)
assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login)
})
}
}