Files
github--github-mcp-server/pkg/github/issues_test.go
T
2025-03-20 18:33:30 +01:00

745 lines
22 KiB
Go

package github
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v69/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/migueleliasweb/go-github-mock/src/mock"
"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(mockClient, translations.NullTranslationHelper)
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"),
}
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(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)
})
}
}
func Test_AddIssueComment(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := addIssueComment(mockClient, translations.NullTranslationHelper)
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, r *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: true,
expectedErrMsg: "failed to create comment",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
_, handler := addIssueComment(client, translations.NullTranslationHelper)
// Create call request
request := mcp.CallToolRequest{
Params: struct {
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments,omitempty"`
Meta *struct {
ProgressToken mcp.ProgressToken `json:"progressToken,omitempty"`
} `json:"_meta,omitempty"`
}{
Arguments: 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 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(mockClient, translations.NullTranslationHelper)
assert.Equal(t, "search_issues", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "q")
assert.Contains(t, tool.InputSchema.Properties, "sort")
assert.Contains(t, tool.InputSchema.Properties, "order")
assert.Contains(t, tool.InputSchema.Properties, "per_page")
assert.Contains(t, tool.InputSchema.Properties, "page")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"q"})
// 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.WithRequestMatch(
mock.GetSearchIssues,
mockSearchResult,
),
),
requestArgs: map[string]interface{}{
"q": "repo:owner/repo is:issue is:open",
"sort": "created",
"order": "desc",
"page": float64(1),
"per_page": float64(30),
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "issues search with minimal parameters",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetSearchIssues,
mockSearchResult,
),
),
requestArgs: map[string]interface{}{
"q": "repo:owner/repo is:issue is:open",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "search issues fails",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetSearchIssues,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"message": "Validation Failed"}`))
}),
),
),
requestArgs: map[string]interface{}{
"q": "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(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(mockClient, translations.NullTranslationHelper)
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.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")}},
}
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,
mockResponse(t, http.StatusCreated, mockIssue),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"title": "Test Issue",
"body": "This is a test issue",
"assignees": []interface{}{"user1", "user2"},
"labels": []interface{}{"bug", "help wanted"},
},
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",
},
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, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message": "Validation failed"}`))
}),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"title": "",
},
expectError: true,
expectedErrMsg: "failed to create issue",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
_, handler := createIssue(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.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(mockClient, translations.NullTranslationHelper)
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, "per_page")
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.WithRequestMatch(
mock.GetReposIssuesByOwnerByRepo,
mockIssues,
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"state": "open",
"labels": "bug,enhancement",
"sort": "created",
"direction": "desc",
"since": "2023-01-01T00:00:00Z",
"page": float64(1),
"per_page": 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, r *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(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_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)
}
})
}
}