Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,13 @@ The following sets of tools are available:
- `repo`: Repository name (string, required)
- `sub_issue_id`: The ID of the sub-issue to add. ID is not the same as issue number (number, required)

- **update_issue_comment** - Update issue comment
- **OAuth Challenge Scopes**: `repo`
- `body`: New comment content (string, required)
- `comment_id`: The numeric ID of the issue or pull request conversation comment to update. Do not use a pull request review comment ID. (integer, required)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)

</details>

<details>
Expand Down
38 changes: 38 additions & 0 deletions pkg/github/__toolsnaps__/update_issue_comment.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"annotations": {
"idempotentHint": false,
"readOnlyHint": false,
"title": "Update issue comment"
},
"description": "Update the body of an existing issue or pull request conversation comment. This tool cannot update pull request review comments.",
"inputSchema": {
"properties": {
"body": {
"description": "New comment content",
"minLength": 1,
"type": "string"
},
"comment_id": {
"description": "The numeric ID of the issue or pull request conversation comment to update. Do not use a pull request review comment ID.",
"minimum": 1,
"type": "integer"
},
"owner": {
"description": "Repository owner",
"type": "string"
},
"repo": {
"description": "Repository name",
"type": "string"
}
},
"required": [
"owner",
"repo",
"comment_id",
"body"
],
"type": "object"
},
"name": "update_issue_comment"
}
1 change: 1 addition & 0 deletions pkg/github/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const (
GetReposIssuesCommentsByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/comments"
PostReposIssuesByOwnerByRepo = "POST /repos/{owner}/{repo}/issues"
PostReposIssuesCommentsByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/comments"
PatchReposIssuesCommentByOwnerByRepoByCommentID = "PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}"
PostReposIssuesReactionsByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/reactions"
PatchReposIssuesByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}"
GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues"
Expand Down
91 changes: 91 additions & 0 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -1552,6 +1552,97 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool
})
}

// UpdateIssueComment creates a tool to update an issue or pull request conversation comment.
func UpdateIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
ToolsetMetadataIssues,
mcp.Tool{
Name: "update_issue_comment",
Description: t("TOOL_UPDATE_ISSUE_COMMENT_DESCRIPTION", "Update the body of an existing issue or pull request conversation comment. This tool cannot update pull request review comments."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_UPDATE_ISSUE_COMMENT_USER_TITLE", "Update issue comment"),
ReadOnlyHint: false,
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"comment_id": {
Type: "integer",
Description: "The numeric ID of the issue or pull request conversation comment to update. Do not use a pull request review comment ID.",
Minimum: jsonschema.Ptr(1.0),
},
"body": {
Type: "string",
Description: "New comment content",
MinLength: jsonschema.Ptr(1),
},
},
Required: []string{"owner", "repo", "comment_id", "body"},
},
},
publicRepositoryWriteScopeAccess(),
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
commentID, err := RequiredBigInt(args, "comment_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if commentID < 1 {
return utils.NewToolResultError("comment_id must be greater than 0"), nil, nil
}
body, hasBody, err := OptionalParamOK[string](args, "body")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if !hasBody {
return utils.NewToolResultError("missing required parameter: body"), nil, nil
}
if body == "" {
return utils.NewToolResultError("body cannot be empty when provided"), nil, nil
}

client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}

updatedComment, resp, err := client.Issues.EditComment(ctx, owner, repo, commentID, &github.IssueComment{
Body: github.Ptr(body),
})
if resp != nil && resp.Body != nil {
defer func() { _ = resp.Body.Close() }()
}
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue comment", resp, err), nil, nil
}

r, err := json.Marshal(MinimalResponse{
ID: fmt.Sprintf("%d", updatedComment.GetID()),
URL: updatedComment.GetHTMLURL(),
})
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}

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

func isValidIssueReaction(reaction string) bool {
switch reaction {
case "+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes":
Expand Down
189 changes: 189 additions & 0 deletions pkg/github/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6639,6 +6639,195 @@ func TestAddIssueCommentHandler(t *testing.T) {
}
}

func TestUpdateIssueCommentSchema(t *testing.T) {
t.Parallel()

tool := UpdateIssueComment(translations.NullTranslationHelper).Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))

assert.Equal(t, "update_issue_comment", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "comment_id")
assert.Contains(t, schema.Properties, "body")
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "comment_id", "body"})

resolved, err := schema.Resolve(nil)
require.NoError(t, err)

baseArgs := map[string]any{
"owner": "owner",
"repo": "repo",
"comment_id": 456,
"body": "Updated comment",
}
tests := []struct {
name string
args map[string]any
isValid bool
}{
{
name: "valid arguments",
args: map[string]any{},
isValid: true,
},
{
name: "missing required body",
args: map[string]any{"body": nil},
isValid: false,
},
{
name: "empty body",
args: map[string]any{"body": ""},
isValid: false,
},
{
name: "zero comment ID",
args: map[string]any{"comment_id": 0},
isValid: false,
},
{
name: "fractional comment ID",
args: map[string]any{"comment_id": 1.5},
isValid: false,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

args := maps.Clone(baseArgs)
maps.Copy(args, tc.args)
err := resolved.Validate(args)
if tc.isValid {
require.NoError(t, err)
return
}
require.Error(t, err)
})
}
}

func TestUpdateIssueCommentHandler(t *testing.T) {
t.Parallel()

updatedComment := &github.IssueComment{
ID: github.Ptr(int64(456)),
Body: github.Ptr("Updated comment"),
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42#issuecomment-456"),
}

tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectToolError bool
expectedToolErrMsg string
}{
{
name: "successful update",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesCommentByOwnerByRepoByCommentID: expectRequestBody(t, map[string]any{
"body": "Updated comment",
}).andThen(mockResponse(t, http.StatusOK, updatedComment)),
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"comment_id": float64(456),
"body": "Updated comment",
},
},
{
name: "missing body",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"comment_id": float64(456),
},
expectToolError: true,
expectedToolErrMsg: "missing required parameter: body",
},
{
name: "empty body",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"comment_id": float64(456),
"body": "",
},
expectToolError: true,
expectedToolErrMsg: "body cannot be empty when provided",
},
{
name: "negative comment ID",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"comment_id": float64(-1),
"body": "Updated comment",
},
expectToolError: true,
expectedToolErrMsg: "comment_id must be greater than 0",
},
{
name: "fractional comment ID",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"comment_id": float64(1.5),
"body": "Updated comment",
},
expectToolError: true,
expectedToolErrMsg: "parameter comment_id is not a valid number",
},
{
name: "API error",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesCommentByOwnerByRepoByCommentID: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"comment_id": float64(456),
"body": "Updated comment",
},
expectToolError: true,
expectedToolErrMsg: "failed to update issue comment",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

client := mustNewGHClient(t, tc.mockedClient)
deps := BaseDeps{Client: client}
serverTool := UpdateIssueComment(translations.NullTranslationHelper)
handler := serverTool.Handler(deps)

request := createMCPRequest(tc.requestArgs)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)

if tc.expectToolError {
require.True(t, result.IsError)
assert.Contains(t, getErrorResult(t, result).Text, tc.expectedToolErrMsg)
return
}

require.False(t, result.IsError)
var response MinimalResponse
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response))
assert.Equal(t, "456", response.ID)
assert.Equal(t, "https://github.com/owner/repo/issues/42#issuecomment-456", response.URL)
})
}
}

func Test_RemoveSubIssue(t *testing.T) {
// Verify tool definition once
serverTool := SubIssueWrite(translations.NullTranslationHelper)
Expand Down
2 changes: 2 additions & 0 deletions pkg/github/public_repo_scopes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ func TestPublicRepoContributionToolScopeAccess(t *testing.T) {
{name: "create_pull_request", tool: CreatePullRequest(translations.NullTranslationHelper)},
{name: "issue_write", tool: IssueWrite(translations.NullTranslationHelper)},
{name: "add_issue_comment", tool: AddIssueComment(translations.NullTranslationHelper)},
{name: "update_issue_comment", tool: UpdateIssueComment(translations.NullTranslationHelper)},
}

for _, tt := range tools {
Expand Down Expand Up @@ -50,6 +51,7 @@ func TestPublicRepoContributionToolsVisibleToPATs(t *testing.T) {
CreatePullRequest(translations.NullTranslationHelper),
IssueWrite(translations.NullTranslationHelper),
AddIssueComment(translations.NullTranslationHelper),
UpdateIssueComment(translations.NullTranslationHelper),
}

tests := []struct {
Expand Down
1 change: 1 addition & 0 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent
ListIssueFields(t),
IssueWrite(t),
AddIssueComment(t),
UpdateIssueComment(t),
SubIssueWrite(t),
IssueDependencyRead(t),
IssueDependencyWrite(t),
Expand Down
Loading