From 31e72befa04d25cba5d0e8a3457a1563c54e194d Mon Sep 17 00:00:00 2001 From: Javier Uruen Val Date: Tue, 18 Mar 2025 15:33:39 +0100 Subject: [PATCH 1/2] add create_issue tool --- README.md | 13 +++- pkg/github/issues.go | 80 ++++++++++++++++++++ pkg/github/issues_test.go | 155 ++++++++++++++++++++++++++++++++++++++ pkg/github/server.go | 21 ++++++ pkg/github/server_test.go | 61 +++++++++++++++ 5 files changed, 327 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 582c2a1c..632e080d 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,15 @@ and set it as the GITHUB_PERSONAL_ACCESS_TOKEN environment variable. - `repo`: Repository name (string, required) - `issue_number`: Issue number (number, required) +- **create_issue** - Create a new issue in a GitHub repository + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `title`: Issue title (string, required) + - `body`: Issue body content (string, optional) + - `assignees`: Comma-separated list of usernames to assign to this issue (string, optional) + - `labels`: Comma-separated list of labels to apply to this issue (string, optional) + - **add_issue_comment** - Add a comment to an issue - `owner`: Repository owner (string, required) @@ -263,16 +272,14 @@ Lots of things! Missing tools: - push_files (files array) -- create_issue (assignees and labels arrays) - list_issues (labels array) - update_issue (labels and assignees arrays) - create_pull_request_review (comments array) Testing -- Unit tests - Integration tests -- Blackbox testing: ideally comparing output to Anthromorphic's server to make sure that this is a fully compatible drop-in replacement. +- Blackbox testing: ideally comparing output to Anthropic's server to make sure that this is a fully compatible drop-in replacement. And some other stuff: diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 6a43e59d..4780a4b1 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -182,3 +182,83 @@ func searchIssues(client *github.Client) (tool mcp.Tool, handler server.ToolHand return mcp.NewToolResultText(string(r)), nil } } + +// createIssue creates a tool to create a new issue in a GitHub repository. +func createIssue(client *github.Client) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("create_issue", + mcp.WithDescription("Create a new issue in a GitHub repository"), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("title", + mcp.Required(), + mcp.Description("Issue title"), + ), + mcp.WithString("body", + mcp.Description("Issue body content"), + ), + mcp.WithString("assignees", + mcp.Description("Comma-separate list of usernames to assign to this issue"), + ), + mcp.WithString("labels", + mcp.Description("Comma-separate list of labels to apply to this issue"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner := request.Params.Arguments["owner"].(string) + repo := request.Params.Arguments["repo"].(string) + title := request.Params.Arguments["title"].(string) + + // Optional parameters + var body string + if b, ok := request.Params.Arguments["body"].(string); ok { + body = b + } + + // Parse assignees if present + assignees := []string{} // default to empty slice, can't be nil + if a, ok := request.Params.Arguments["assignees"].(string); ok && a != "" { + assignees = parseCommaSeparatedList(a) + } + + // Parse labels if present + labels := []string{} // default to empty slice, can't be nil + if l, ok := request.Params.Arguments["labels"].(string); ok && l != "" { + labels = parseCommaSeparatedList(l) + } + + // Create the issue request + issueRequest := &github.IssueRequest{ + Title: github.Ptr(title), + Body: github.Ptr(body), + Assignees: &assignees, + Labels: &labels, + } + + issue, resp, err := client.Issues.Create(ctx, owner, repo, issueRequest) + if err != nil { + return nil, fmt.Errorf("failed to create issue: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to create issue: %s", string(body))), nil + } + + r, err := json.Marshal(issue) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 7e9944b3..c1ebf6d0 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -369,3 +369,158 @@ func Test_SearchIssues(t *testing.T) { }) } } + +func Test_CreateIssue(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := createIssue(mockClient) + + 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) + + // 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) + } + } + }) + } +} diff --git a/pkg/github/server.go b/pkg/github/server.go index 0a90b4d1..3a4d78cc 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "strings" "github.com/google/go-github/v69/github" "github.com/mark3labs/mcp-go/mcp" @@ -25,6 +26,7 @@ func NewServer(client *github.Client) *server.MCPServer { // Add GitHub tools - Issues s.AddTool(getIssue(client)) s.AddTool(addIssueComment(client)) + s.AddTool(createIssue(client)) s.AddTool(searchIssues(client)) // Add GitHub tools - Pull Requests @@ -97,3 +99,22 @@ func isAcceptedError(err error) bool { var acceptedError *github.AcceptedError return errors.As(err, &acceptedError) } + +// parseCommaSeparatedList is a helper function that parses a comma-separated list of strings from the input string. +func parseCommaSeparatedList(input string) []string { + if input == "" { + return nil + } + + parts := strings.Split(input, ",") + result := make([]string, 0, len(parts)) + + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } + } + + return result +} diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index d56993de..5515c881 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -166,3 +166,64 @@ func Test_IsAcceptedError(t *testing.T) { }) } } + +func Test_ParseCommaSeparatedList(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "simple comma separated values", + input: "one,two,three", + expected: []string{"one", "two", "three"}, + }, + { + name: "values with spaces", + input: "one, two, three", + expected: []string{"one", "two", "three"}, + }, + { + name: "values with extra spaces", + input: " one , two , three ", + expected: []string{"one", "two", "three"}, + }, + { + name: "empty values in between", + input: "one,,three", + expected: []string{"one", "three"}, + }, + { + name: "only spaces", + input: " , , ", + expected: []string{}, + }, + { + name: "empty string", + input: "", + expected: nil, + }, + { + name: "single value", + input: "one", + expected: []string{"one"}, + }, + { + name: "trailing comma", + input: "one,two,", + expected: []string{"one", "two"}, + }, + { + name: "leading comma", + input: ",one,two", + expected: []string{"one", "two"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := parseCommaSeparatedList(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} From 3bc863276e8db665b51e834978dbc69733020b73 Mon Sep 17 00:00:00 2001 From: Javier Uruen Val Date: Wed, 19 Mar 2025 15:36:01 +0100 Subject: [PATCH 2/2] add support for list_issues --- README.md | 12 +++ pkg/github/issues.go | 121 +++++++++++++++++++++ pkg/github/issues_test.go | 217 ++++++++++++++++++++++++++++++++++++++ pkg/github/server.go | 1 + 4 files changed, 351 insertions(+) diff --git a/README.md b/README.md index b836a1f6..047b8aa2 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,18 @@ and set it as the GITHUB_PERSONAL_ACCESS_TOKEN environment variable. - `issue_number`: Issue number (number, required) - `body`: Comment text (string, required) +- **list_issues** - List and filter repository issues + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `state`: Filter by state ('open', 'closed', 'all') (string, optional) + - `labels`: Comma-separated list of labels to filter by (string, optional) + - `sort`: Sort by ('created', 'updated', 'comments') (string, optional) + - `direction`: Sort direction ('asc', 'desc') (string, optional) + - `since`: Filter by date (ISO 8601 timestamp) (string, optional) + - `page`: Page number (number, optional) + - `per_page`: Results per page (number, optional) + - **search_issues** - Search for issues and pull requests - `query`: Search query (string, required) - `sort`: Sort field (string, optional) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 3a23825b..101f6ff5 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "time" "github.com/google/go-github/v69/github" "github.com/mark3labs/mcp-go/mcp" @@ -262,3 +263,123 @@ func createIssue(client *github.Client) (tool mcp.Tool, handler server.ToolHandl return mcp.NewToolResultText(string(r)), nil } } + +// listIssues creates a tool to list and filter repository issues +func listIssues(client *github.Client) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_issues", + mcp.WithDescription("List issues in a GitHub repository with filtering options"), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("state", + mcp.Description("Filter by state ('open', 'closed', 'all')"), + ), + mcp.WithString("labels", + mcp.Description("Comma-separated list of labels to filter by"), + ), + mcp.WithString("sort", + mcp.Description("Sort by ('created', 'updated', 'comments')"), + ), + mcp.WithString("direction", + mcp.Description("Sort direction ('asc', 'desc')"), + ), + mcp.WithString("since", + mcp.Description("Filter by date (ISO 8601 timestamp)"), + ), + mcp.WithNumber("page", + mcp.Description("Page number"), + ), + mcp.WithNumber("per_page", + mcp.Description("Results per page"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner := request.Params.Arguments["owner"].(string) + repo := request.Params.Arguments["repo"].(string) + + opts := &github.IssueListByRepoOptions{} + + // Set optional parameters if provided + if state, ok := request.Params.Arguments["state"].(string); ok && state != "" { + opts.State = state + } + + if labels, ok := request.Params.Arguments["labels"].(string); ok && labels != "" { + opts.Labels = parseCommaSeparatedList(labels) + } + + if sort, ok := request.Params.Arguments["sort"].(string); ok && sort != "" { + opts.Sort = sort + } + + if direction, ok := request.Params.Arguments["direction"].(string); ok && direction != "" { + opts.Direction = direction + } + + if since, ok := request.Params.Arguments["since"].(string); ok && since != "" { + timestamp, err := parseISOTimestamp(since) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil + } + opts.Since = timestamp + } + + if page, ok := request.Params.Arguments["page"].(float64); ok { + opts.Page = int(page) + } + + if perPage, ok := request.Params.Arguments["per_page"].(float64); ok { + opts.PerPage = int(perPage) + } + + issues, resp, err := client.Issues.ListByRepo(ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("failed to list issues: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", string(body))), nil + } + + r, err := json.Marshal(issues) + if err != nil { + return nil, fmt.Errorf("failed to marshal issues: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object. +// Returns the parsed time or an error if parsing fails. +// Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15" +func parseISOTimestamp(timestamp string) (time.Time, error) { + if timestamp == "" { + return time.Time{}, fmt.Errorf("empty timestamp") + } + + // Try RFC3339 format (standard ISO 8601 with time) + t, err := time.Parse(time.RFC3339, timestamp) + if err == nil { + return t, nil + } + + // Try simple date format (YYYY-MM-DD) + t, err = time.Parse("2006-01-02", timestamp) + if err == nil { + return t, nil + } + + // Return error with supported formats + return time.Time{}, fmt.Errorf("invalid ISO 8601 timestamp: %s (supported formats: YYYY-MM-DDThh:mm:ssZ or YYYY-MM-DD)", timestamp) +} diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index c1ebf6d0..fe500c9f 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "testing" + "time" "github.com/google/go-github/v69/github" "github.com/mark3labs/mcp-go/mcp" @@ -524,3 +525,219 @@ func Test_CreateIssue(t *testing.T) { }) } } + +func Test_ListIssues(t *testing.T) { + // Verify tool definition + mockClient := github.NewClient(nil) + tool, _ := listIssues(mockClient) + + 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) + + // 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) + } + }) + } +} diff --git a/pkg/github/server.go b/pkg/github/server.go index 31521fb8..248fe748 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -37,6 +37,7 @@ func NewServer(client *github.Client) *server.MCPServer { s.AddTool(addIssueComment(client)) s.AddTool(createIssue(client)) s.AddTool(searchIssues(client)) + s.AddTool(listIssues(client)) // Add GitHub tools - Pull Requests s.AddTool(getPullRequest(client))