Skip to content

Add tools for one-off PR comments and replying to PR review comments #143

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 12, 2025
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,29 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `draft`: Create as draft PR (boolean, optional)
- `maintainer_can_modify`: Allow maintainer edits (boolean, optional)

- **add_pull_request_review_comment** - Add a review comment to a pull request

- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- `pull_number`: Pull request number (number, required)
- `body`: The text of the review comment (string, required)
- `commit_id`: The SHA of the commit to comment on (string, required)
- `path`: The relative path to the file that necessitates a comment (string, required)
- `line`: The line of the blob in the pull request diff that the comment applies to (number, optional)
- `side`: The side of the diff to comment on (LEFT or RIGHT) (string, optional)
- `start_line`: For multi-line comments, the first line of the range (number, optional)
- `start_side`: For multi-line comments, the starting side of the diff (LEFT or RIGHT) (string, optional)
- `subject_type`: The level at which the comment is targeted (line or file) (string, optional)
- `in_reply_to`: The ID of the review comment to reply to (number, optional)

- **reply_to_pull_request_review_comment** - Reply to an existing review comment on a pull request

- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- `pull_number`: Pull request number (number, required)
- `comment_id`: The unique identifier of the comment to reply to (number, required)
- `body`: The text of the reply comment (string, required)

- **update_pull_request** - Update an existing pull request in a GitHub repository

- `owner`: Repository owner (string, required)
Expand Down
230 changes: 230 additions & 0 deletions pkg/github/pullrequests.go
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,236 @@ func GetPullRequestComments(getClient GetClientFn, t translations.TranslationHel
}
}

// AddPullRequestReviewComment creates a tool to add a review comment to a pull request.
func AddPullRequestReviewComment(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("add_pull_request_review_comment",
mcp.WithDescription(t("TOOL_ADD_PULL_REQUEST_COMMENT_DESCRIPTION", "Add a review comment to a pull request")),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("Repository name"),
),
mcp.WithNumber("pull_number",
mcp.Required(),
mcp.Description("Pull request number"),
),
mcp.WithString("body",
mcp.Required(),
mcp.Description("The text of the review comment"),
),
mcp.WithString("commit_id",
mcp.Required(),
mcp.Description("The SHA of the commit to comment on"),
),
mcp.WithString("path",
mcp.Required(),
mcp.Description("The relative path to the file that necessitates a comment"),
),
mcp.WithString("subject_type",
mcp.Description("The level at which the comment is targeted, 'line' or 'file'"),
mcp.Enum("line", "file"),
),
mcp.WithNumber("line",
mcp.Description("The line of the blob in the pull request diff that the comment applies to. For multi-line comments, the last line of the range"),
),
mcp.WithString("side",
mcp.Description("The side of the diff to comment on. Can be LEFT or RIGHT"),
mcp.Enum("LEFT", "RIGHT"),
),
mcp.WithNumber("start_line",
mcp.Description("For multi-line comments, the first line of the range that the comment applies to"),
),
mcp.WithString("start_side",
mcp.Description("For multi-line comments, the starting side of the diff that the comment applies to. Can be LEFT or RIGHT"),
mcp.Enum("LEFT", "RIGHT"),
),
mcp.WithNumber("in_reply_to",
mcp.Description("The ID of the review comment to reply to. When specified, all parameters other than body are ignored"),
),
),
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
}
pullNumber, err := RequiredInt(request, "pull_number")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
body, err := requiredParam[string](request, "body")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
commitID, err := requiredParam[string](request, "commit_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
path, err := requiredParam[string](request, "path")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

comment := &github.PullRequestComment{
Body: github.Ptr(body),
CommitID: github.Ptr(commitID),
Path: github.Ptr(path),
}

// Check for in_reply_to parameter which takes precedence
if replyToFloat, ok := request.Params.Arguments["in_reply_to"].(float64); ok {
comment.InReplyTo = github.Ptr(int64(replyToFloat))
} else {
// Handle subject_type parameter
subjectType, err := OptionalParam[string](request, "subject_type")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
if subjectType != "file" {
// Handle line or position-based comments
// No action needed if subjectType is "file"
line, lineExists := request.Params.Arguments["line"].(float64)
startLine, startLineExists := request.Params.Arguments["start_line"].(float64)
side, sideExists := request.Params.Arguments["side"].(string)
startSide, startSideExists := request.Params.Arguments["start_side"].(string)

if subjectType != "file" && !lineExists {
return mcp.NewToolResultError("line parameter is required unless using subject_type:file or in_reply_to"), nil
}

if lineExists {
comment.Line = github.Ptr(int(line))
}
if sideExists {
comment.Side = github.Ptr(side)
}
if startLineExists {
comment.StartLine = github.Ptr(int(startLine))
}
if startSideExists {
comment.StartSide = github.Ptr(startSide)
}

// Validate multi-line comment parameters
if startLineExists && !lineExists {
return mcp.NewToolResultError("if start_line is provided, line must also be provided"), nil
}
if startSideExists && !sideExists {
return mcp.NewToolResultError("if start_side is provided, side must also be provided"), nil
}
}
}

client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}

createdComment, resp, err := client.PullRequests.CreateComment(ctx, owner, repo, pullNumber, comment)
if err != nil {
return nil, fmt.Errorf("failed to create pull request comment: %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 pull request comment: %s", string(body))), nil
}

r, err := json.Marshal(createdComment)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}

return mcp.NewToolResultText(string(r)), nil
}
}

// ReplyToPullRequestReviewComment creates a tool to reply to an existing review comment on a pull request.
func ReplyToPullRequestReviewComment(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool,
handler server.ToolHandlerFunc) {
return mcp.NewTool("reply_to_pull_request_review_comment",
mcp.WithDescription(t("TOOL_REPLY_TO_PULL_REQUEST_REVIEW_COMMENT_DESCRIPTION", "Reply to an existing review comment on a pull request")),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("Repository name"),
),
mcp.WithNumber("pull_number",
mcp.Required(),
mcp.Description("Pull request number"),
),
mcp.WithNumber("comment_id",
mcp.Required(),
mcp.Description("The unique identifier of the comment to reply to"),
),
mcp.WithString("body",
mcp.Required(),
mcp.Description("The text of the reply comment"),
),
),
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
}
pullNumber, err := RequiredInt(request, "pull_number")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
commentID, err := RequiredInt(request, "comment_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
body, err := requiredParam[string](request, "body")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}

createdReply, resp, err := client.PullRequests.CreateCommentInReplyTo(ctx, owner, repo, pullNumber, body, int64(commentID))
if err != nil {
return nil, fmt.Errorf("failed to reply to pull request comment: %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 reply to pull request comment: %s", string(body))), nil
}

r, err := json.Marshal(createdReply)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}

return mcp.NewToolResultText(string(r)), nil
}
}

// GetPullRequestReviews creates a tool to get the reviews on a pull request.
func GetPullRequestReviews(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("get_pull_request_reviews",
Expand Down
Loading