From f1813d73ace0a75aec252826305ed849c23474c8 Mon Sep 17 00:00:00 2001 From: Matt Metzger Date: Fri, 4 Apr 2025 16:19:23 -0400 Subject: [PATCH 1/2] Add support for retrieving GitHub Issue Comments Add new tool 'get_issue_comments' that allows fetching comments associated with GitHub issues. This complements the existing issue retrieval functionality and follows the same patterns as the pull request comments implementation. The implementation includes: - New getIssueComments function in pkg/github/issues.go - Tool registration in server.go - Comprehensive test coverage in issues_test.go --- README.md | 6 +++ cmd/mcpcurl/README.md | 1 + pkg/github/issues.go | 60 +++++++++++++++++++++ pkg/github/issues_test.go | 109 ++++++++++++++++++++++++++++++++++++++ pkg/github/server.go | 1 + 5 files changed, 177 insertions(+) diff --git a/README.md b/README.md index 56d16b607..8c24d4740 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,12 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `repo`: Repository name (string, required) - `issue_number`: Issue number (number, required) +- **get_issue_comments** - Get comments for a GitHub issue + + - `owner`: Repository owner (string, required) + - `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) diff --git a/cmd/mcpcurl/README.md b/cmd/mcpcurl/README.md index 06ce26ee0..95e1339a7 100644 --- a/cmd/mcpcurl/README.md +++ b/cmd/mcpcurl/README.md @@ -50,6 +50,7 @@ Available Commands: fork_repository Fork a GitHub repository to your account or specified organization get_file_contents Get the contents of a file or directory from a GitHub repository get_issue Get details of a specific issue in a GitHub repository. + get_issue_comments Get comments for a GitHub issue list_commits Get list of commits of a branch in a GitHub repository list_issues List issues in a GitHub repository with filtering options push_files Push multiple files to a GitHub repository in a single commit diff --git a/pkg/github/issues.go b/pkg/github/issues.go index df2f6f584..4aee8c3ae 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -597,6 +597,66 @@ func updateIssue(client *github.Client, t translations.TranslationHelperFunc) (t } } +// getIssueComments creates a tool to get comments for a GitHub issue. +func getIssueComments(client *github.Client, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_issue_comments", + mcp.WithDescription(t("TOOL_GET_ISSUE_COMMENTS_DESCRIPTION", "Get comments for a GitHub issue")), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("issue_number", + mcp.Required(), + mcp.Description("Issue number"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := requiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := requiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + issueNumber, err := requiredInt(request, "issue_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.IssueListCommentsOptions{ + ListOptions: github.ListOptions{ + PerPage: 100, + }, + } + + comments, resp, err := client.Issues.ListComments(ctx, owner, repo, issueNumber, opts) + if err != nil { + return nil, fmt.Errorf("failed to get issue comments: %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 get issue comments: %s", string(body))), nil + } + + r, err := json.Marshal(comments) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %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" diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 5dab16312..8d4bf8d26 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -984,3 +984,112 @@ func Test_ParseISOTimestamp(t *testing.T) { }) } } + +func Test_GetIssueComments(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := getIssueComments(mockClient, translations.NullTranslationHelper) + + 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.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: "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(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) + } + }) + } +} diff --git a/pkg/github/server.go b/pkg/github/server.go index 66dbfd1ca..18e5da094 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -34,6 +34,7 @@ func NewServer(client *github.Client, readOnly bool, t translations.TranslationH s.AddTool(getIssue(client, t)) s.AddTool(searchIssues(client, t)) s.AddTool(listIssues(client, t)) + s.AddTool(getIssueComments(client, t)) if !readOnly { s.AddTool(createIssue(client, t)) s.AddTool(addIssueComment(client, t)) From 6c2a1594334279d10ff5a5bf525848923f70824d Mon Sep 17 00:00:00 2001 From: Matt Metzger Date: Sat, 5 Apr 2025 15:48:50 -0400 Subject: [PATCH 2/2] Support pagination for get_issue_comments --- pkg/github/issues.go | 17 ++++++++++++++++- pkg/github/issues_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 4aee8c3ae..d5aba2e76 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -613,6 +613,12 @@ func getIssueComments(client *github.Client, t translations.TranslationHelperFun mcp.Required(), mcp.Description("Issue number"), ), + mcp.WithNumber("page", + mcp.Description("Page number"), + ), + mcp.WithNumber("per_page", + mcp.Description("Number of records per page"), + ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := requiredParam[string](request, "owner") @@ -627,10 +633,19 @@ func getIssueComments(client *github.Client, t translations.TranslationHelperFun if err != nil { return mcp.NewToolResultError(err.Error()), nil } + page, err := optionalIntParamWithDefault(request, "page", 1) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + perPage, err := optionalIntParamWithDefault(request, "per_page", 30) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } opts := &github.IssueListCommentsOptions{ ListOptions: github.ListOptions{ - PerPage: 100, + Page: page, + PerPage: perPage, }, } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 8d4bf8d26..0326b9bee 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -995,6 +995,8 @@ func Test_GetIssueComments(t *testing.T) { 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 comments for success case @@ -1041,6 +1043,29 @@ func Test_GetIssueComments(t *testing.T) { 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), + "per_page": float64(10), + }, + expectError: false, + expectedComments: mockComments, + }, { name: "issue not found", mockedClient: mock.NewMockedHTTPClient( pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

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:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy