60a5391d67
* 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>
2604 lines
79 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|