diff --git a/README.md b/README.md index b836a1f6c..047b8aa20 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 3a23825ba..0151a67a0 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) +} \ No newline at end of file diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index c1ebf6d01..fe500c9fd 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 31521fb87..248fe748c 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))
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: