diff --git a/README.md b/README.md index 891e63a81..714415f71 100644 --- a/README.md +++ b/README.md @@ -602,6 +602,14 @@ The following sets of tools are available (all are on by default): - `title`: New title (string, optional) - `type`: New issue type (string, optional) +- **get_latest_issue_comment_user** - Get latest issue comments by a user on a particular issue + - `issue_number`: Issue number (number, required) + - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) + - `user`: Github username of that particular user (string, required) +
diff --git a/pkg/github/__toolsnaps__/get_latest_issue_comment_user.snap b/pkg/github/__toolsnaps__/get_latest_issue_comment_user.snap new file mode 100644 index 000000000..617e4428b --- /dev/null +++ b/pkg/github/__toolsnaps__/get_latest_issue_comment_user.snap @@ -0,0 +1,46 @@ +{ + "annotations": { + "title": "Get latest issue comment by user", + "readOnlyHint": true + }, + "description": "Get the latest comments by a specific user on a specific issue in a GitHub repository.", + "inputSchema": { + "properties": { + "issue_number": { + "description": "Issue number", + "type": "number" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "user": { + "description": "User", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "issue_number", + "user" + ], + "type": "object" + }, + "name": "get_latest_issue_comment_user" +} \ No newline at end of file diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 1c88a9fde..ed0794700 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -408,6 +408,80 @@ func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc } } +// UpdateIssueComment creates a tool to update a previously added comment to an issue. +func UpdateIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("update_issue_comment", + mcp.WithDescription(t("TOOL_UPDATE_ISSUE_COMMENT_DESCRIPTION", "Update a previously added comment to a specific issue in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_UPDATE_ISSUE_COMMENT_USER_TITLE", "Update a previously added comment to an issue"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithNumber("comment_id", + mcp.Required(), + mcp.Description("ID of the comment to update"), + ), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("body", + mcp.Required(), + mcp.Description("Comment content"), + ), + ), + 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 + } + body, err := RequiredParam[string](request, "body") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + commentID, err := RequiredInt(request, "comment_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + comment := &github.IssueComment{ + Body: github.Ptr(body), + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + updatedComment, resp, err := client.Issues.EditComment(ctx, owner, repo, int64(commentID), comment) + if err != nil { + return nil, fmt.Errorf("failed to update comment: %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 update comment: %s", string(body))), nil + } + + r, err := json.Marshal(updatedComment) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // AddSubIssue creates a tool to add a sub-issue to a parent issue. func AddSubIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("add_sub_issue", @@ -1511,6 +1585,105 @@ func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFun } } +// GetIssueCommentUser creates a tool to get the latest comment by a user on a specific issue in a GitHub repository. +func GetLatestIssueCommentUser(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_latest_issue_comment_user", + mcp.WithDescription(t("TOOL_GET_LATEST_ISSUE_COMMENT_USER_DESCRIPTION", "Get the latest comments by a specific user on a specific issue in a GitHub repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_LATEST_ISSUE_COMMENT_USER_TITLE", "Get latest issue comment by user"), + ReadOnlyHint: ToBoolPtr(true), + }), + 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"), + ), + mcp.WithString("user", + mcp.Required(), + mcp.Description("User"), + ), + WithPagination(), + ), + 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 + } + user, err := RequiredParam[string](request, "user") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.IssueListCommentsOptions{ + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } + var latestcomment *github.IssueComment + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + 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 + } + + for _, comment := range comments { + if comment == nil || comment.User == nil || comment.User.Login == nil { + continue + } + + if *comment.User.Login == user { + if latestcomment == nil { + latestcomment = comment + } else { + if comment.UpdatedAt.Time.After(latestcomment.UpdatedAt.Time) { + latestcomment = comment + } + } + } + } + + r, err := json.Marshal(latestcomment) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // mvpDescription is an MVP idea for generating tool descriptions from structured data in a shared format. // It is not intended for widespread usage and is not a complete implementation. type mvpDescription struct { diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index cc1923df9..37bb8efd8 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -3224,3 +3224,150 @@ func Test_ListIssueTypes(t *testing.T) { }) } } + +func Test_GetLatestIssueCommentUser(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := GetLatestIssueCommentUser(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_latest_issue_comment_user", 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, "user") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number", "user"}) + + // 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)}, + UpdatedAt: &github.Timestamp{Time: time.Now().Add(-time.Hour * 24)}, + }, + { + ID: github.Ptr(int64(123)), + Body: github.Ptr("This is the first comment"), + User: &github.User{ + Login: github.Ptr("user2"), + }, + CreatedAt: &github.Timestamp{Time: time.Now().Add(-time.Hour * 24)}, + UpdatedAt: &github.Timestamp{Time: time.Now().Add(-time.Hour * 24)}, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedComments []*github.IssueComment + expectedErrMsg string + }{ + { + name: "successful comment retrieval", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, + mockComments, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "user": "user1", + }, + expectError: false, + expectedComments: mockComments, + }, + { + name: "successful comment 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), + "perPage": float64(10), + "user": "user1", + }, + expectError: false, + expectedComments: mockComments, + }, + { + name: "comment retrieval fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "user": "", + }, + expectError: false, + expectedErrMsg: "missing required parameter: user", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetLatestIssueCommentUser(stubGetClientFn(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 + } + + if tc.expectedErrMsg != "" { + require.NotNil(t, result) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, 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, *tc.expectedComments[0].Body, *returnedComments.Body) + assert.Equal(t, *tc.expectedComments[0].User.Login, *returnedComments.User.Login) + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 0f294cef6..8e46e794c 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -61,10 +61,12 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(GetIssueComments(getClient, t)), toolsets.NewServerTool(ListIssueTypes(getClient, t)), toolsets.NewServerTool(ListSubIssues(getClient, t)), + toolsets.NewServerTool(GetLatestIssueCommentUser(getClient, t)), ). AddWriteTools( toolsets.NewServerTool(CreateIssue(getClient, t)), toolsets.NewServerTool(AddIssueComment(getClient, t)), + toolsets.NewServerTool(UpdateIssueComment(getClient, t)), toolsets.NewServerTool(UpdateIssue(getClient, getGQLClient, t)), toolsets.NewServerTool(AssignCopilotToIssue(getGQLClient, t)), toolsets.NewServerTool(AddSubIssue(getClient, t)),