diff --git a/docs/feature-flags.md b/docs/feature-flags.md index ec5282bd54..272e7153a5 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -184,6 +184,13 @@ as output formatting) won't appear here. - `repo`: Repository name (string, required) - `title`: Issue title (string, required) +- **hide_issue_comment** - Hide Issue Comment + - **OAuth Challenge Scopes**: `repo` + - `classifier`: The reason for hiding the comment (string, required) + - `comment_id`: The numeric ID of the issue or pull request conversation comment (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - **remove_issue_comment_reaction** - Remove Reaction from Issue or Pull Request Comment - **OAuth Challenge Scopes**: `repo` - `comment_id`: The issue or pull request comment ID (number, required) @@ -221,6 +228,12 @@ as output formatting) won't appear here. - `owner`: Repository owner (username or organization) (string, required) - `repo`: Repository name (string, required) +- **unhide_issue_comment** - Unhide Issue Comment + - **OAuth Challenge Scopes**: `repo` + - `comment_id`: The numeric ID of the issue or pull request conversation comment (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - **update_issue_assignees** - Update Issue Assignees - **OAuth Challenge Scopes**: `repo` - `assignees`: GitHub usernames to assign to this issue. ([], required) @@ -315,6 +328,21 @@ as output formatting) won't appear here. - `pullNumber`: The pull request number (number, required) - `repo`: Repository name (string, required) +- **hide_pull_request_review** - Hide Pull Request Review + - **OAuth Challenge Scopes**: `repo` + - `classifier`: The reason for hiding the comment (string, required) + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + - `review_id`: The numeric ID of the pull request review (number, required) + +- **hide_pull_request_review_comment** - Hide Pull Request Review Comment + - **OAuth Challenge Scopes**: `repo` + - `classifier`: The reason for hiding the comment (string, required) + - `comment_id`: The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...). (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - **remove_pull_request_review_comment_reaction** - Remove Pull Request Review Comment Reaction - **OAuth Challenge Scopes**: `repo` - `comment_id`: The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...). (number, required) @@ -341,6 +369,19 @@ as output formatting) won't appear here. - `pullNumber`: The pull request number (number, required) - `repo`: Repository name (string, required) +- **unhide_pull_request_review** - Unhide Pull Request Review + - **OAuth Challenge Scopes**: `repo` + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + - `review_id`: The numeric ID of the pull request review (number, required) + +- **unhide_pull_request_review_comment** - Unhide Pull Request Review Comment + - **OAuth Challenge Scopes**: `repo` + - `comment_id`: The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...). (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - **unresolve_review_thread** - Unresolve Review Thread - **OAuth Challenge Scopes**: `repo` - `threadID`: The node ID of the review thread to unresolve (e.g., PRRT_kwDOxxx) (string, required) diff --git a/pkg/github/__toolsnaps__/hide_issue_comment.snap b/pkg/github/__toolsnaps__/hide_issue_comment.snap new file mode 100644 index 0000000000..bb66c3fc22 --- /dev/null +++ b/pkg/github/__toolsnaps__/hide_issue_comment.snap @@ -0,0 +1,48 @@ +{ + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Hide Issue Comment" + }, + "description": "Hide (minimize) a comment on an issue, or a conversation comment on a pull request. Requires triage or write access to the repository, or being its author.", + "inputSchema": { + "properties": { + "classifier": { + "description": "The reason for hiding the comment", + "enum": [ + "SPAM", + "ABUSE", + "OFF_TOPIC", + "OUTDATED", + "DUPLICATE", + "RESOLVED", + "LOW_QUALITY" + ], + "type": "string" + }, + "comment_id": { + "description": "The numeric ID of the issue or pull request conversation comment", + "minimum": 1, + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "comment_id", + "classifier" + ], + "type": "object" + }, + "name": "hide_issue_comment" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/hide_pull_request_review.snap b/pkg/github/__toolsnaps__/hide_pull_request_review.snap new file mode 100644 index 0000000000..09ecfdf6cd --- /dev/null +++ b/pkg/github/__toolsnaps__/hide_pull_request_review.snap @@ -0,0 +1,54 @@ +{ + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Hide Pull Request Review" + }, + "description": "Hide (minimize) the body of a submitted pull request review. Requires triage or write access to the repository, or being its author.", + "inputSchema": { + "properties": { + "classifier": { + "description": "The reason for hiding the comment", + "enum": [ + "SPAM", + "ABUSE", + "OFF_TOPIC", + "OUTDATED", + "DUPLICATE", + "RESOLVED", + "LOW_QUALITY" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "pullNumber": { + "description": "The pull request number", + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "review_id": { + "description": "The numeric ID of the pull request review", + "minimum": 1, + "type": "number" + } + }, + "required": [ + "owner", + "repo", + "pullNumber", + "review_id", + "classifier" + ], + "type": "object" + }, + "name": "hide_pull_request_review" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/hide_pull_request_review_comment.snap b/pkg/github/__toolsnaps__/hide_pull_request_review_comment.snap new file mode 100644 index 0000000000..46c39c9885 --- /dev/null +++ b/pkg/github/__toolsnaps__/hide_pull_request_review_comment.snap @@ -0,0 +1,48 @@ +{ + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Hide Pull Request Review Comment" + }, + "description": "Hide (minimize) an inline review comment on a pull request diff. Requires triage or write access to the repository, or being its author.", + "inputSchema": { + "properties": { + "classifier": { + "description": "The reason for hiding the comment", + "enum": [ + "SPAM", + "ABUSE", + "OFF_TOPIC", + "OUTDATED", + "DUPLICATE", + "RESOLVED", + "LOW_QUALITY" + ], + "type": "string" + }, + "comment_id": { + "description": "The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...).", + "minimum": 1, + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "comment_id", + "classifier" + ], + "type": "object" + }, + "name": "hide_pull_request_review_comment" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/unhide_issue_comment.snap b/pkg/github/__toolsnaps__/unhide_issue_comment.snap new file mode 100644 index 0000000000..ea258d12f3 --- /dev/null +++ b/pkg/github/__toolsnaps__/unhide_issue_comment.snap @@ -0,0 +1,34 @@ +{ + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Unhide Issue Comment" + }, + "description": "Unhide (unminimize) a previously hidden comment on an issue, or conversation comment on a pull request. Requires triage or write access to the repository, or being its author.", + "inputSchema": { + "properties": { + "comment_id": { + "description": "The numeric ID of the issue or pull request conversation comment", + "minimum": 1, + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "comment_id" + ], + "type": "object" + }, + "name": "unhide_issue_comment" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/unhide_pull_request_review.snap b/pkg/github/__toolsnaps__/unhide_pull_request_review.snap new file mode 100644 index 0000000000..2e627d519f --- /dev/null +++ b/pkg/github/__toolsnaps__/unhide_pull_request_review.snap @@ -0,0 +1,40 @@ +{ + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Unhide Pull Request Review" + }, + "description": "Unhide (unminimize) the previously hidden body of a submitted pull request review. Requires triage or write access to the repository, or being its author.", + "inputSchema": { + "properties": { + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "pullNumber": { + "description": "The pull request number", + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "review_id": { + "description": "The numeric ID of the pull request review", + "minimum": 1, + "type": "number" + } + }, + "required": [ + "owner", + "repo", + "pullNumber", + "review_id" + ], + "type": "object" + }, + "name": "unhide_pull_request_review" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/unhide_pull_request_review_comment.snap b/pkg/github/__toolsnaps__/unhide_pull_request_review_comment.snap new file mode 100644 index 0000000000..38ff864f15 --- /dev/null +++ b/pkg/github/__toolsnaps__/unhide_pull_request_review_comment.snap @@ -0,0 +1,34 @@ +{ + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Unhide Pull Request Review Comment" + }, + "description": "Unhide (unminimize) a previously hidden inline review comment on a pull request diff. Requires triage or write access to the repository, or being its author.", + "inputSchema": { + "properties": { + "comment_id": { + "description": "The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...).", + "minimum": 1, + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "comment_id" + ], + "type": "object" + }, + "name": "unhide_pull_request_review_comment" +} \ No newline at end of file diff --git a/pkg/github/comment_minimize.go b/pkg/github/comment_minimize.go new file mode 100644 index 0000000000..0bdabae44f --- /dev/null +++ b/pkg/github/comment_minimize.go @@ -0,0 +1,323 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "slices" + "strings" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/go-github/v89/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" +) + +// MinimizeCommentResult is the response returned by the hide and unhide comment tools. +type MinimizeCommentResult struct { + NodeID string `json:"node_id"` + IsMinimized bool `json:"is_minimized"` + MinimizedReason string `json:"minimized_reason,omitempty"` +} + +var commentClassifiers = []any{"SPAM", "ABUSE", "OFF_TOPIC", "OUTDATED", "DUPLICATE", "RESOLVED", "LOW_QUALITY"} + +const commentVisibilityPermissionNote = " Requires triage or write access to the repository, or being its author." + +// commentVisibilityTarget describes one kind of hideable object: how the tools that +// hide and unhide it are named and described, and how to find its GraphQL node ID. +type commentVisibilityTarget struct { + toolset inventory.ToolsetMetadata + featureRule inventory.FeatureRule + // name is appended to "hide_" and "unhide_" to form the tool names. + name string + title string + hideDescription string + unhideDescription string + properties func() map[string]*jsonschema.Schema + required []string + resolveNodeID func(ctx context.Context, client *github.Client, owner, repo string, args map[string]any) (string, *mcp.CallToolResult) +} + +var issueCommentVisibilityTarget = commentVisibilityTarget{ + toolset: ToolsetMetadataIssues, + featureRule: issuesGranularFeatureRule, + name: "issue_comment", + title: "Issue Comment", + hideDescription: "Hide (minimize) a comment on an issue, or a conversation comment on a pull request.", + unhideDescription: "Unhide (unminimize) a previously hidden comment on an issue, or conversation comment on a pull request.", + properties: func() map[string]*jsonschema.Schema { + return map[string]*jsonschema.Schema{ + "comment_id": { + Type: "number", + Description: "The numeric ID of the issue or pull request conversation comment", + Minimum: jsonschema.Ptr(1.0), + }, + } + }, + required: []string{"comment_id"}, + resolveNodeID: func(ctx context.Context, client *github.Client, owner, repo string, args map[string]any) (string, *mcp.CallToolResult) { + commentID, err := requiredPositiveBigInt(args, "comment_id") + if err != nil { + return "", utils.NewToolResultError(err.Error()) + } + comment, resp, err := client.Issues.GetComment(ctx, owner, repo, commentID) + return nodeIDFromResponse(ctx, "failed to get issue comment", comment.GetNodeID(), resp, err) + }, +} + +var pullRequestReviewCommentVisibilityTarget = commentVisibilityTarget{ + toolset: ToolsetMetadataPullRequests, + featureRule: pullRequestsGranularFeatureRule, + name: "pull_request_review_comment", + title: "Pull Request Review Comment", + hideDescription: "Hide (minimize) an inline review comment on a pull request diff.", + unhideDescription: "Unhide (unminimize) a previously hidden inline review comment on a pull request diff.", + properties: func() map[string]*jsonschema.Schema { + return map[string]*jsonschema.Schema{ + "comment_id": { + Type: "number", + Description: "The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...).", + Minimum: jsonschema.Ptr(1.0), + }, + } + }, + required: []string{"comment_id"}, + resolveNodeID: func(ctx context.Context, client *github.Client, owner, repo string, args map[string]any) (string, *mcp.CallToolResult) { + commentID, err := requiredPositiveBigInt(args, "comment_id") + if err != nil { + return "", utils.NewToolResultError(err.Error()) + } + comment, resp, err := client.PullRequests.GetComment(ctx, owner, repo, commentID) + return nodeIDFromResponse(ctx, "failed to get pull request review comment", comment.GetNodeID(), resp, err) + }, +} + +var pullRequestReviewVisibilityTarget = commentVisibilityTarget{ + toolset: ToolsetMetadataPullRequests, + featureRule: pullRequestsGranularFeatureRule, + name: "pull_request_review", + title: "Pull Request Review", + hideDescription: "Hide (minimize) the body of a submitted pull request review.", + unhideDescription: "Unhide (unminimize) the previously hidden body of a submitted pull request review.", + properties: func() map[string]*jsonschema.Schema { + return map[string]*jsonschema.Schema{ + "pullNumber": { + Type: "number", + Description: "The pull request number", + Minimum: jsonschema.Ptr(1.0), + }, + "review_id": { + Type: "number", + Description: "The numeric ID of the pull request review", + Minimum: jsonschema.Ptr(1.0), + }, + } + }, + required: []string{"pullNumber", "review_id"}, + resolveNodeID: func(ctx context.Context, client *github.Client, owner, repo string, args map[string]any) (string, *mcp.CallToolResult) { + pullNumber, err := RequiredInt(args, "pullNumber") + if err != nil { + return "", utils.NewToolResultError(err.Error()) + } + if pullNumber < 1 { + return "", utils.NewToolResultError("pullNumber must be greater than 0") + } + reviewID, err := requiredPositiveBigInt(args, "review_id") + if err != nil { + return "", utils.NewToolResultError(err.Error()) + } + review, resp, err := client.PullRequests.GetReview(ctx, owner, repo, pullNumber, reviewID) + return nodeIDFromResponse(ctx, "failed to get pull request review", review.GetNodeID(), resp, err) + }, +} + +// commentVisibilityTool builds the hide_ tool when hide is true, and the unhide_ tool otherwise. +func commentVisibilityTool(t translations.TranslationHelperFunc, target commentVisibilityTarget, hide bool) inventory.ServerTool { + action, titleAction, description := "unhide", "Unhide", target.unhideDescription + if hide { + action, titleAction, description = "hide", "Hide", target.hideDescription + } + name := action + "_" + target.name + + properties := map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + } + maps.Copy(properties, target.properties()) + required := append([]string{"owner", "repo"}, target.required...) + if hide { + properties["classifier"] = &jsonschema.Schema{ + Type: "string", + Description: "The reason for hiding the comment", + Enum: commentClassifiers, + } + required = append(required, "classifier") + } + + st := NewTool( + target.toolset, + mcp.Tool{ + Name: name, + Description: t("TOOL_"+strings.ToUpper(name)+"_DESCRIPTION", description+commentVisibilityPermissionNote), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_"+strings.ToUpper(name)+"_USER_TITLE", titleAction+" "+target.title), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: properties, + Required: required, + }, + }, + scopes.RequireAll(scopes.Repo), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + return setCommentVisibility(ctx, deps, target, args, hide), nil, nil + }, + ) + st.FeatureRule = target.featureRule + return st +} + +// setCommentVisibility resolves the object identified by args and hides or unhides it. +func setCommentVisibility(ctx context.Context, deps ToolDependencies, target commentVisibilityTarget, args map[string]any, hide bool) *mcp.CallToolResult { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()) + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()) + } + var classifier string + if hide { + classifier, err = RequiredParam[string](args, "classifier") + if err != nil { + return utils.NewToolResultError(err.Error()) + } + classifier = strings.ToUpper(classifier) + if !slices.Contains(commentClassifiers, any(classifier)) { + return utils.NewToolResultError(fmt.Sprintf("invalid classifier %q: must be one of %v", classifier, commentClassifiers)) + } + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err) + } + + nodeID, errResult := target.resolveNodeID(ctx, client, owner, repo, args) + if errResult != nil { + return errResult + } + + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err) + } + + var result MinimizeCommentResult + if hide { + result, errResult = minimizeComment(ctx, gqlClient, nodeID, classifier) + } else { + result, errResult = unminimizeComment(ctx, gqlClient, nodeID) + } + if errResult != nil { + return errResult + } + + r, err := json.Marshal(result) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err) + } + return utils.NewToolResultText(string(r)) +} + +// requiredPositiveBigInt reads a required ID argument. The schema's minimum is not enforced +// when arguments are unmarshalled, so negative values are rejected here before any API call. +func requiredPositiveBigInt(args map[string]any, p string) (int64, error) { + v, err := RequiredBigInt(args, p) + if err != nil { + return 0, err + } + if v < 1 { + return 0, fmt.Errorf("%s must be greater than 0", p) + } + return v, nil +} + +// nodeIDFromResponse turns the result of a REST lookup into a GraphQL node ID, since the +// minimize mutations only accept node IDs. +func nodeIDFromResponse(ctx context.Context, errMessage, nodeID string, resp *github.Response, err error) (string, *mcp.CallToolResult) { + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return "", ghErrors.NewGitHubAPIErrorResponse(ctx, errMessage, resp, err) + } + if nodeID == "" { + return "", utils.NewToolResultError(errMessage + ": response has no node ID") + } + return nodeID, nil +} + +func minimizeComment(ctx context.Context, client *githubv4.Client, nodeID, classifier string) (MinimizeCommentResult, *mcp.CallToolResult) { + var mutation struct { + MinimizeComment struct { + MinimizedComment struct { + IsMinimized githubv4.Boolean + MinimizedReason githubv4.String + } + } `graphql:"minimizeComment(input: $input)"` + } + + input := githubv4.MinimizeCommentInput{ + SubjectID: githubv4.ID(nodeID), + Classifier: githubv4.ReportedContentClassifiers(classifier), + } + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return MinimizeCommentResult{}, ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to minimize comment", err) + } + + return MinimizeCommentResult{ + NodeID: nodeID, + IsMinimized: bool(mutation.MinimizeComment.MinimizedComment.IsMinimized), + MinimizedReason: string(mutation.MinimizeComment.MinimizedComment.MinimizedReason), + }, nil +} + +func unminimizeComment(ctx context.Context, client *githubv4.Client, nodeID string) (MinimizeCommentResult, *mcp.CallToolResult) { + var mutation struct { + UnminimizeComment struct { + UnminimizedComment struct { + IsMinimized githubv4.Boolean + } + } `graphql:"unminimizeComment(input: $input)"` + } + + input := githubv4.UnminimizeCommentInput{ + SubjectID: githubv4.ID(nodeID), + } + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return MinimizeCommentResult{}, ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to unminimize comment", err) + } + + return MinimizeCommentResult{ + NodeID: nodeID, + IsMinimized: bool(mutation.UnminimizeComment.UnminimizedComment.IsMinimized), + }, nil +} diff --git a/pkg/github/comment_minimize_test.go b/pkg/github/comment_minimize_test.go new file mode 100644 index 0000000000..75e97e0fbb --- /dev/null +++ b/pkg/github/comment_minimize_test.go @@ -0,0 +1,383 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v89/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + getIssueCommentRoute = "GET /repos/{owner}/{repo}/issues/comments/{comment_id}" + getReviewCommentRoute = "GET /repos/{owner}/{repo}/pulls/comments/{comment_id}" + getReviewRoute = "GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}" +) + +// minimizedReason is the lowercase, hyphenated form GitHub returns (e.g. "off-topic"), not the classifier enum. +func minimizeCommentMatcher(nodeID, classifier, minimizedReason string) githubv4mock.Matcher { + return githubv4mock.NewMutationMatcher( + struct { + MinimizeComment struct { + MinimizedComment struct { + IsMinimized githubv4.Boolean + MinimizedReason githubv4.String + } + } `graphql:"minimizeComment(input: $input)"` + }{}, + githubv4.MinimizeCommentInput{ + SubjectID: githubv4.ID(nodeID), + Classifier: githubv4.ReportedContentClassifiers(classifier), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "minimizeComment": map[string]any{ + "minimizedComment": map[string]any{ + "isMinimized": true, + "minimizedReason": minimizedReason, + }, + }, + }), + ) +} + +func unminimizeCommentMatcher(nodeID string) githubv4mock.Matcher { + return githubv4mock.NewMutationMatcher( + struct { + UnminimizeComment struct { + UnminimizedComment struct { + IsMinimized githubv4.Boolean + } + } `graphql:"unminimizeComment(input: $input)"` + }{}, + githubv4.UnminimizeCommentInput{SubjectID: githubv4.ID(nodeID)}, + nil, + githubv4mock.DataResponse(map[string]any{ + "unminimizeComment": map[string]any{ + "unminimizedComment": map[string]any{"isMinimized": false}, + }, + }), + ) +} + +func minimizeCommentErrorMatcher(nodeID, classifier string) githubv4mock.Matcher { + return githubv4mock.NewMutationMatcher( + struct { + MinimizeComment struct { + MinimizedComment struct { + IsMinimized githubv4.Boolean + MinimizedReason githubv4.String + } + } `graphql:"minimizeComment(input: $input)"` + }{}, + githubv4.MinimizeCommentInput{ + SubjectID: githubv4.ID(nodeID), + Classifier: githubv4.ReportedContentClassifiers(classifier), + }, + nil, + githubv4mock.ErrorResponse("Resource not accessible by integration"), + ) +} + +func unminimizeCommentErrorMatcher(nodeID string) githubv4mock.Matcher { + return githubv4mock.NewMutationMatcher( + struct { + UnminimizeComment struct { + UnminimizedComment struct { + IsMinimized githubv4.Boolean + } + } `graphql:"unminimizeComment(input: $input)"` + }{}, + githubv4.UnminimizeCommentInput{SubjectID: githubv4.ID(nodeID)}, + nil, + githubv4mock.ErrorResponse("Resource not accessible by integration"), + ) +} + +func Test_CommentVisibilityToolSchemas(t *testing.T) { + tests := []struct { + tool inventory.ServerTool + name string + toolset inventory.ToolsetID + featureFlag string + expectedRequire []string + }{ + { + tool: GranularHideIssueComment(translations.NullTranslationHelper), + name: "hide_issue_comment", + toolset: ToolsetMetadataIssues.ID, + featureFlag: FeatureFlagIssuesGranular, + expectedRequire: []string{"owner", "repo", "comment_id", "classifier"}, + }, + { + tool: GranularUnhideIssueComment(translations.NullTranslationHelper), + name: "unhide_issue_comment", + toolset: ToolsetMetadataIssues.ID, + featureFlag: FeatureFlagIssuesGranular, + expectedRequire: []string{"owner", "repo", "comment_id"}, + }, + { + tool: GranularHidePullRequestReviewComment(translations.NullTranslationHelper), + name: "hide_pull_request_review_comment", + toolset: ToolsetMetadataPullRequests.ID, + featureFlag: FeatureFlagPullRequestsGranular, + expectedRequire: []string{"owner", "repo", "comment_id", "classifier"}, + }, + { + tool: GranularUnhidePullRequestReviewComment(translations.NullTranslationHelper), + name: "unhide_pull_request_review_comment", + toolset: ToolsetMetadataPullRequests.ID, + featureFlag: FeatureFlagPullRequestsGranular, + expectedRequire: []string{"owner", "repo", "comment_id"}, + }, + { + tool: GranularHidePullRequestReview(translations.NullTranslationHelper), + name: "hide_pull_request_review", + toolset: ToolsetMetadataPullRequests.ID, + featureFlag: FeatureFlagPullRequestsGranular, + expectedRequire: []string{"owner", "repo", "pullNumber", "review_id", "classifier"}, + }, + { + tool: GranularUnhidePullRequestReview(translations.NullTranslationHelper), + name: "unhide_pull_request_review", + toolset: ToolsetMetadataPullRequests.ID, + featureFlag: FeatureFlagPullRequestsGranular, + expectedRequire: []string{"owner", "repo", "pullNumber", "review_id"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tool := tc.tool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, tc.name, tool.Name) + assert.NotEmpty(t, tool.Description) + assert.False(t, tool.Annotations.ReadOnlyHint) + assert.Equal(t, tc.toolset, tc.tool.Toolset.ID) + assert.Equal(t, []inventory.FeatureFlag{inventory.FeatureFlag(tc.featureFlag)}, tc.tool.FeatureRule.Features()) + + schema := tool.InputSchema.(*jsonschema.Schema) + assert.ElementsMatch(t, tc.expectedRequire, schema.Required) + assert.Len(t, schema.Properties, len(tc.expectedRequire), "every property should be required") + }) + } +} + +func Test_HideAndUnhideComments(t *testing.T) { + issueComment := mockResponse(t, http.StatusOK, &github.IssueComment{ID: github.Ptr(int64(1)), NodeID: github.Ptr("IC_1")}) + reviewComment := mockResponse(t, http.StatusOK, &github.PullRequestComment{ID: github.Ptr(int64(2)), NodeID: github.Ptr("PRRC_2")}) + review := mockResponse(t, http.StatusOK, &github.PullRequestReview{ID: github.Ptr(int64(3)), NodeID: github.Ptr("PRR_3")}) + notFound := mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`) + + tests := []struct { + name string + tool inventory.ServerTool + restHandlers map[string]http.HandlerFunc + gqlMatchers []githubv4mock.Matcher + requestArgs map[string]any + expectedResult MinimizeCommentResult + expectedErrMsg string + }{ + { + name: "hide issue comment", + tool: GranularHideIssueComment(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getIssueCommentRoute: issueComment}, + gqlMatchers: []githubv4mock.Matcher{minimizeCommentMatcher("IC_1", "SPAM", "spam")}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(1), "classifier": "spam"}, + expectedResult: MinimizeCommentResult{NodeID: "IC_1", IsMinimized: true, MinimizedReason: "spam"}, + }, + { + name: "unhide issue comment", + tool: GranularUnhideIssueComment(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getIssueCommentRoute: issueComment}, + gqlMatchers: []githubv4mock.Matcher{unminimizeCommentMatcher("IC_1")}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(1)}, + expectedResult: MinimizeCommentResult{NodeID: "IC_1", IsMinimized: false}, + }, + { + name: "hide pull request review comment", + tool: GranularHidePullRequestReviewComment(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getReviewCommentRoute: reviewComment}, + gqlMatchers: []githubv4mock.Matcher{minimizeCommentMatcher("PRRC_2", "OUTDATED", "outdated")}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(2), "classifier": "OUTDATED"}, + expectedResult: MinimizeCommentResult{NodeID: "PRRC_2", IsMinimized: true, MinimizedReason: "outdated"}, + }, + { + name: "unhide pull request review comment", + tool: GranularUnhidePullRequestReviewComment(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getReviewCommentRoute: reviewComment}, + gqlMatchers: []githubv4mock.Matcher{unminimizeCommentMatcher("PRRC_2")}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(2)}, + expectedResult: MinimizeCommentResult{NodeID: "PRRC_2", IsMinimized: false}, + }, + { + name: "hide pull request review", + tool: GranularHidePullRequestReview(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getReviewRoute: review}, + gqlMatchers: []githubv4mock.Matcher{minimizeCommentMatcher("PRR_3", "RESOLVED", "resolved")}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "pullNumber": float64(42), "review_id": float64(3), "classifier": "RESOLVED"}, + expectedResult: MinimizeCommentResult{NodeID: "PRR_3", IsMinimized: true, MinimizedReason: "resolved"}, + }, + { + name: "unhide pull request review", + tool: GranularUnhidePullRequestReview(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getReviewRoute: review}, + gqlMatchers: []githubv4mock.Matcher{unminimizeCommentMatcher("PRR_3")}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "pullNumber": float64(42), "review_id": float64(3)}, + expectedResult: MinimizeCommentResult{NodeID: "PRR_3", IsMinimized: false}, + }, + { + name: "issue comment not found", + tool: GranularUnhideIssueComment(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getIssueCommentRoute: notFound}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(1)}, + expectedErrMsg: "failed to get issue comment", + }, + { + name: "pull request review comment not found", + tool: GranularUnhidePullRequestReviewComment(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getReviewCommentRoute: notFound}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(2)}, + expectedErrMsg: "failed to get pull request review comment", + }, + { + name: "pull request review not found", + tool: GranularUnhidePullRequestReview(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getReviewRoute: notFound}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "pullNumber": float64(42), "review_id": float64(3)}, + expectedErrMsg: "failed to get pull request review", + }, + { + name: "response without node ID", + tool: GranularUnhideIssueComment(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{ + getIssueCommentRoute: mockResponse(t, http.StatusOK, &github.IssueComment{ID: github.Ptr(int64(1))}), + }, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(1)}, + expectedErrMsg: "response has no node ID", + }, + { + name: "hide mutation fails", + tool: GranularHideIssueComment(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getIssueCommentRoute: issueComment}, + gqlMatchers: []githubv4mock.Matcher{minimizeCommentErrorMatcher("IC_1", "SPAM")}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(1), "classifier": "SPAM"}, + expectedErrMsg: "failed to minimize comment", + }, + { + name: "unhide mutation fails", + tool: GranularUnhideIssueComment(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getIssueCommentRoute: issueComment}, + gqlMatchers: []githubv4mock.Matcher{unminimizeCommentErrorMatcher("IC_1")}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(1)}, + expectedErrMsg: "failed to unminimize comment", + }, + { + name: "invalid classifier is rejected before any API call", + tool: GranularHideIssueComment(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(1), "classifier": "BOGUS"}, + expectedErrMsg: `invalid classifier "BOGUS"`, + }, + { + name: "negative issue comment_id", + tool: GranularHideIssueComment(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(-1), "classifier": "SPAM"}, + expectedErrMsg: "comment_id must be greater than 0", + }, + { + name: "negative pull request review comment_id", + tool: GranularUnhidePullRequestReviewComment(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(-2)}, + expectedErrMsg: "comment_id must be greater than 0", + }, + { + name: "negative review_id", + tool: GranularUnhidePullRequestReview(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "pullNumber": float64(42), "review_id": float64(-3)}, + expectedErrMsg: "review_id must be greater than 0", + }, + { + name: "negative pullNumber", + tool: GranularUnhidePullRequestReview(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "pullNumber": float64(-42), "review_id": float64(3)}, + expectedErrMsg: "pullNumber must be greater than 0", + }, + { + name: "missing owner", + tool: GranularUnhideIssueComment(translations.NullTranslationHelper), + requestArgs: map[string]any{"repo": "repo", "comment_id": float64(1)}, + expectedErrMsg: "owner", + }, + { + name: "missing repo", + tool: GranularUnhideIssueComment(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "comment_id": float64(1)}, + expectedErrMsg: "repo", + }, + { + name: "missing classifier", + tool: GranularHideIssueComment(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": float64(1)}, + expectedErrMsg: "classifier", + }, + { + name: "missing issue comment_id", + tool: GranularUnhideIssueComment(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "repo": "repo"}, + expectedErrMsg: "comment_id", + }, + { + name: "missing pull request review comment_id", + tool: GranularUnhidePullRequestReviewComment(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "repo": "repo"}, + expectedErrMsg: "comment_id", + }, + { + name: "missing pullNumber", + tool: GranularUnhidePullRequestReview(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "review_id": float64(3)}, + expectedErrMsg: "pullNumber", + }, + { + name: "missing review_id", + tool: GranularUnhidePullRequestReview(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "pullNumber": float64(42)}, + expectedErrMsg: "review_id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(tc.restHandlers)), + GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient(tc.gqlMatchers...)), + } + handler := tc.tool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + if tc.expectedErrMsg != "" { + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, tc.expectedErrMsg) + return + } + + require.False(t, result.IsError, getTextResult(t, result).Text) + var got MinimizeCommentResult + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &got)) + assert.Equal(t, tc.expectedResult, got) + }) + } +} diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index 425f954ef9..0d36ab97a5 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -56,6 +56,8 @@ func TestGranularToolSnaps(t *testing.T) { GranularRemoveIssueReaction, GranularAddIssueCommentReaction, GranularRemoveIssueCommentReaction, + GranularHideIssueComment, + GranularUnhideIssueComment, GranularUpdatePullRequestTitle, GranularUpdatePullRequestBody, GranularUpdatePullRequestState, @@ -69,6 +71,10 @@ func TestGranularToolSnaps(t *testing.T) { GranularUnresolveReviewThread, GranularAddPullRequestReviewCommentReaction, GranularRemovePullRequestReviewCommentReaction, + GranularHidePullRequestReviewComment, + GranularUnhidePullRequestReviewComment, + GranularHidePullRequestReview, + GranularUnhidePullRequestReview, } for _, constructor := range toolConstructors { @@ -105,6 +111,8 @@ func TestIssuesGranularToolset(t *testing.T) { "remove_issue_reaction", "add_issue_comment_reaction", "remove_issue_comment_reaction", + "hide_issue_comment", + "unhide_issue_comment", } for _, name := range expected { assert.Contains(t, toolNames, name) @@ -142,6 +150,10 @@ func TestPullRequestsGranularToolset(t *testing.T) { "unresolve_review_thread", "add_pull_request_review_comment_reaction", "remove_pull_request_review_comment_reaction", + "hide_pull_request_review_comment", + "unhide_pull_request_review_comment", + "hide_pull_request_review", + "unhide_pull_request_review", } for _, name := range expected { assert.Contains(t, toolNames, name) diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index f22a8a1536..bed003bef3 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -1901,3 +1901,13 @@ func GranularRemoveIssueCommentReaction(t translations.TranslationHelperFunc) in st.FeatureRule = issuesGranularFeatureRule return st } + +// GranularHideIssueComment hides (minimizes) an issue or pull request conversation comment. +func GranularHideIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool { + return commentVisibilityTool(t, issueCommentVisibilityTarget, true) +} + +// GranularUnhideIssueComment unhides (unminimizes) an issue or pull request conversation comment. +func GranularUnhideIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool { + return commentVisibilityTool(t, issueCommentVisibilityTarget, false) +} diff --git a/pkg/github/pullrequests_granular.go b/pkg/github/pullrequests_granular.go index e670da9a34..92e7fb6f5d 100644 --- a/pkg/github/pullrequests_granular.go +++ b/pkg/github/pullrequests_granular.go @@ -973,3 +973,23 @@ func GranularRemovePullRequestReviewCommentReaction(t translations.TranslationHe st.FeatureRule = pullRequestsGranularFeatureRule return st } + +// GranularHidePullRequestReviewComment hides (minimizes) an inline pull request review comment. +func GranularHidePullRequestReviewComment(t translations.TranslationHelperFunc) inventory.ServerTool { + return commentVisibilityTool(t, pullRequestReviewCommentVisibilityTarget, true) +} + +// GranularUnhidePullRequestReviewComment unhides (unminimizes) an inline pull request review comment. +func GranularUnhidePullRequestReviewComment(t translations.TranslationHelperFunc) inventory.ServerTool { + return commentVisibilityTool(t, pullRequestReviewCommentVisibilityTarget, false) +} + +// GranularHidePullRequestReview hides (minimizes) the body of a pull request review. +func GranularHidePullRequestReview(t translations.TranslationHelperFunc) inventory.ServerTool { + return commentVisibilityTool(t, pullRequestReviewVisibilityTarget, true) +} + +// GranularUnhidePullRequestReview unhides (unminimizes) the body of a pull request review. +func GranularUnhidePullRequestReview(t translations.TranslationHelperFunc) inventory.ServerTool { + return commentVisibilityTool(t, pullRequestReviewVisibilityTarget, false) +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index b91664a67f..eb1113754a 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -376,6 +376,8 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent GranularRemoveIssueReaction(t), GranularAddIssueCommentReaction(t), GranularRemoveIssueCommentReaction(t), + GranularHideIssueComment(t), + GranularUnhideIssueComment(t), // Granular pull request tools (feature-flagged, replace consolidated update_pull_request/pull_request_review_write) GranularUpdatePullRequestTitle(t), @@ -392,6 +394,10 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent GranularUnresolveReviewThread(t), GranularAddPullRequestReviewCommentReaction(t), GranularRemovePullRequestReviewCommentReaction(t), + GranularHidePullRequestReviewComment(t), + GranularUnhidePullRequestReviewComment(t), + GranularHidePullRequestReview(t), + GranularUnhidePullRequestReview(t), }) }