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..0151a67a 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 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))