Files
github--github-mcp-server/pkg/github/pullrequests_granular.go
Matt Holloway 62266f804b
Build and Test Go Project / build (macos-latest) (push) Has been cancelled
Docker / build (push) Has been cancelled
Build and Test Go Project / build (ubuntu-latest) (push) Has been cancelled
Build and Test Go Project / build (windows-latest) (push) Has been cancelled
MCP Server Diff / mcp-diff (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
GoReleaser Release / release (push) Has been cancelled
Publish to MCP Registry / publish (push) Has been cancelled
OSS granular PRs and issues toolsets (#2306)
* initial OSS granular PRs and issues toolsets

* update docs

* refactor: reuse existing helpers in granular toolsets

Refactor granular issue and PR tools to delegate to existing tested
helper functions instead of reimplementing logic from scratch:

- Sub-issue tools (add/remove/reprioritize) now delegate to existing
  REST-based AddSubIssue, RemoveSubIssue, ReprioritizeSubIssue helpers
- PR review tools (create/submit/delete) now delegate to existing
  CreatePullRequestReview, SubmitPendingPullRequestReview,
  DeletePendingPullRequestReview helpers (fixes viewer filtering bug)
- Review comment tool now uses viewer-safe pattern from
  AddCommentToPendingReview (query viewer, filter by author, validate
  PENDING state, pass PullRequestReviewID)
- Fix milestone param to use RequiredInt instead of float64 cast
- Fix line/startLine params to use OptionalIntParam
- Draft state tool uses typed GraphQL inputs matching existing patterns
- Remove duplicate GraphQL types and helper functions
- Add toolsnap tests for all 20 granular tools
- Update generated docs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor: use feature flags instead of separate granular toolsets

Place granular tools in existing issues/pull_requests toolsets with
FeatureFlagEnable, instead of creating separate issues_granular and
pull_requests_granular toolsets. This is simpler and uses the existing
feature flag infrastructure to switch between consolidated and
granular tool variants at runtime.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: address review feedback on granular toolsets

- Fix REST response handling: capture resp, close body, use ghErrors
  helpers in issueUpdateTool, prUpdateTool, GranularCreateIssue, and
  GranularRequestPullRequestReviewers
- Add FeatureFlagDisable on consolidated tools (IssueWrite, SubIssueWrite,
  UpdatePullRequest, PullRequestReviewWrite, AddCommentToPendingReview)
  so they are hidden when granular variants are active
- Use OptionalStringArrayParam for assignees, labels, reviewers instead
  of manual loop that silently dropped non-string elements
- Fix side/startSide empty string leak: pass nil pointer when absent
  instead of pointer to empty string in GraphQL mutations
- Fix milestone minimum from 0 to 1 to match RequiredInt rejection of 0
- Return MinimalResponse {id, url} instead of full JSON objects
- Fix RequiredParam[bool] rejecting draft=false by using presence check
- Add handler tests for update_pull_request_draft_state (draft + ready)
  and add_pull_request_review_comment with full GraphQL mocking

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: address review feedback on granular toolsets

- Fix translation keys to use ALL_CAPS convention (strings.ToUpper)
- Fix assignees/labels clearing: check key presence instead of len==0
- Extract AddCommentToPendingReviewCall helper to deduplicate GraphQL
  logic between consolidated and granular tools
- Add missing granular tools: resolve_review_thread, unresolve_review_thread
  (were in pull_request_review_write but had no granular replacements)
- Add handler tests for new resolve/unresolve tools

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Sam Morrow <info@sam-morrow.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 15:40:00 +01:00

740 lines
28 KiB
Go

package github
import (
"context"
"encoding/json"
"fmt"
"maps"
"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"
gogithub "github.com/google/go-github/v82/github"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/shurcooL/githubv4"
)
// prUpdateTool is a helper to create single-field pull request update tools via REST.
func prUpdateTool(
t translations.TranslationHelperFunc,
name, description, title string,
extraProps map[string]*jsonschema.Schema,
extraRequired []string,
buildRequest func(args map[string]any) (*gogithub.PullRequest, error),
) inventory.ServerTool {
props := map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner (username or organization)",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"pullNumber": {
Type: "number",
Description: "The pull request number",
Minimum: jsonschema.Ptr(1.0),
},
}
maps.Copy(props, extraProps)
required := append([]string{"owner", "repo", "pullNumber"}, extraRequired...)
st := NewTool(
ToolsetMetadataPullRequests,
mcp.Tool{
Name: name,
Description: t("TOOL_"+strings.ToUpper(name)+"_DESCRIPTION", description),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_"+strings.ToUpper(name)+"_USER_TITLE", title),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(false),
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: props,
Required: required,
},
},
[]scopes.Scope{scopes.Repo},
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
}
pullNumber, err := RequiredInt(args, "pullNumber")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
prReq, err := buildRequest(args)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
pr, resp, err := client.PullRequests.Edit(ctx, owner, repo, pullNumber, prReq)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update pull request", resp, err), nil, nil
}
defer func() { _ = resp.Body.Close() }()
r, err := json.Marshal(MinimalResponse{
ID: fmt.Sprintf("%d", pr.GetID()),
URL: pr.GetHTMLURL(),
})
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}
return utils.NewToolResultText(string(r)), nil, nil
},
)
st.FeatureFlagEnable = FeatureFlagPullRequestsGranular
return st
}
// GranularUpdatePullRequestTitle creates a tool to update a PR's title.
func GranularUpdatePullRequestTitle(t translations.TranslationHelperFunc) inventory.ServerTool {
return prUpdateTool(t,
"update_pull_request_title",
"Update the title of an existing pull request.",
"Update Pull Request Title",
map[string]*jsonschema.Schema{
"title": {Type: "string", Description: "The new title for the pull request"},
},
[]string{"title"},
func(args map[string]any) (*gogithub.PullRequest, error) {
title, err := RequiredParam[string](args, "title")
if err != nil {
return nil, err
}
return &gogithub.PullRequest{Title: &title}, nil
},
)
}
// GranularUpdatePullRequestBody creates a tool to update a PR's body.
func GranularUpdatePullRequestBody(t translations.TranslationHelperFunc) inventory.ServerTool {
return prUpdateTool(t,
"update_pull_request_body",
"Update the body description of an existing pull request.",
"Update Pull Request Body",
map[string]*jsonschema.Schema{
"body": {Type: "string", Description: "The new body content for the pull request"},
},
[]string{"body"},
func(args map[string]any) (*gogithub.PullRequest, error) {
body, err := RequiredParam[string](args, "body")
if err != nil {
return nil, err
}
return &gogithub.PullRequest{Body: &body}, nil
},
)
}
// GranularUpdatePullRequestState creates a tool to update a PR's state.
func GranularUpdatePullRequestState(t translations.TranslationHelperFunc) inventory.ServerTool {
return prUpdateTool(t,
"update_pull_request_state",
"Update the state of an existing pull request (open or closed).",
"Update Pull Request State",
map[string]*jsonschema.Schema{
"state": {
Type: "string",
Description: "The new state for the pull request",
Enum: []any{"open", "closed"},
},
},
[]string{"state"},
func(args map[string]any) (*gogithub.PullRequest, error) {
state, err := RequiredParam[string](args, "state")
if err != nil {
return nil, err
}
return &gogithub.PullRequest{State: &state}, nil
},
)
}
// GranularUpdatePullRequestDraftState creates a tool to toggle draft state.
func GranularUpdatePullRequestDraftState(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
ToolsetMetadataPullRequests,
mcp.Tool{
Name: "update_pull_request_draft_state",
Description: t("TOOL_UPDATE_PULL_REQUEST_DRAFT_STATE_DESCRIPTION", "Mark a pull request as draft or ready for review."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_UPDATE_PULL_REQUEST_DRAFT_STATE_USER_TITLE", "Update Pull Request Draft State"),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(false),
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {Type: "string", Description: "Repository owner (username or organization)"},
"repo": {Type: "string", Description: "Repository name"},
"pullNumber": {Type: "number", Description: "The pull request number", Minimum: jsonschema.Ptr(1.0)},
"draft": {Type: "boolean", Description: "Set to true to convert to draft, false to mark as ready for review"},
},
Required: []string{"owner", "repo", "pullNumber", "draft"},
},
},
[]scopes.Scope{scopes.Repo},
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
}
pullNumber, err := RequiredInt(args, "pullNumber")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
// Use presence check + OptionalParam since RequiredParam rejects false (zero-value for bool)
if _, ok := args["draft"]; !ok {
return utils.NewToolResultError("missing required parameter: draft"), nil, nil
}
draft, err := OptionalParam[bool](args, "draft")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil
}
// Get PR node ID
var prQuery struct {
Repository struct {
PullRequest struct {
ID githubv4.ID
} `graphql:"pullRequest(number: $number)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
if err := gqlClient.Query(ctx, &prQuery, map[string]any{
"owner": githubv4.String(owner),
"name": githubv4.String(repo),
"number": githubv4.Int(pullNumber), // #nosec G115 - PR numbers are always small positive integers
}); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get pull request", err), nil, nil
}
if draft {
var mutation struct {
ConvertPullRequestToDraft struct {
PullRequest struct {
ID githubv4.ID
IsDraft githubv4.Boolean
}
} `graphql:"convertPullRequestToDraft(input: $input)"`
}
if err := gqlClient.Mutate(ctx, &mutation, githubv4.ConvertPullRequestToDraftInput{
PullRequestID: prQuery.Repository.PullRequest.ID,
}, nil); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to convert to draft", err), nil, nil
}
return utils.NewToolResultText("pull request converted to draft"), nil, nil
}
var mutation struct {
MarkPullRequestReadyForReview struct {
PullRequest struct {
ID githubv4.ID
IsDraft githubv4.Boolean
}
} `graphql:"markPullRequestReadyForReview(input: $input)"`
}
if err := gqlClient.Mutate(ctx, &mutation, githubv4.MarkPullRequestReadyForReviewInput{
PullRequestID: prQuery.Repository.PullRequest.ID,
}, nil); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to mark ready for review", err), nil, nil
}
return utils.NewToolResultText("pull request marked as ready for review"), nil, nil
},
)
st.FeatureFlagEnable = FeatureFlagPullRequestsGranular
return st
}
// GranularRequestPullRequestReviewers creates a tool to request reviewers.
func GranularRequestPullRequestReviewers(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
ToolsetMetadataPullRequests,
mcp.Tool{
Name: "request_pull_request_reviewers",
Description: t("TOOL_REQUEST_PULL_REQUEST_REVIEWERS_DESCRIPTION", "Request reviewers for a pull request."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_REQUEST_PULL_REQUEST_REVIEWERS_USER_TITLE", "Request Pull Request Reviewers"),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(false),
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {Type: "string", Description: "Repository owner (username or organization)"},
"repo": {Type: "string", Description: "Repository name"},
"pullNumber": {Type: "number", Description: "The pull request number", Minimum: jsonschema.Ptr(1.0)},
"reviewers": {
Type: "array",
Description: "GitHub usernames to request reviews from",
Items: &jsonschema.Schema{Type: "string"},
},
},
Required: []string{"owner", "repo", "pullNumber", "reviewers"},
},
},
[]scopes.Scope{scopes.Repo},
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
}
pullNumber, err := RequiredInt(args, "pullNumber")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
reviewers, err := OptionalStringArrayParam(args, "reviewers")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if len(reviewers) == 0 {
return utils.NewToolResultError("missing required parameter: reviewers"), nil, nil
}
client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
pr, resp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pullNumber, gogithub.ReviewersRequest{Reviewers: reviewers})
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to request reviewers", resp, err), nil, nil
}
defer func() { _ = resp.Body.Close() }()
r, err := json.Marshal(MinimalResponse{
ID: fmt.Sprintf("%d", pr.GetID()),
URL: pr.GetHTMLURL(),
})
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}
return utils.NewToolResultText(string(r)), nil, nil
},
)
st.FeatureFlagEnable = FeatureFlagPullRequestsGranular
return st
}
// GranularCreatePullRequestReview creates a tool to create a PR review.
func GranularCreatePullRequestReview(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
ToolsetMetadataPullRequests,
mcp.Tool{
Name: "create_pull_request_review",
Description: t("TOOL_CREATE_PULL_REQUEST_REVIEW_DESCRIPTION", "Create a review on a pull request. If event is provided, the review is submitted immediately; otherwise a pending review is created."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_CREATE_PULL_REQUEST_REVIEW_USER_TITLE", "Create Pull Request Review"),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(false),
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {Type: "string", Description: "Repository owner (username or organization)"},
"repo": {Type: "string", Description: "Repository name"},
"pullNumber": {Type: "number", Description: "The pull request number", Minimum: jsonschema.Ptr(1.0)},
"body": {Type: "string", Description: "The review body text (optional)"},
"event": {Type: "string", Description: "The review action to perform. If omitted, creates a pending review.", Enum: []any{"APPROVE", "REQUEST_CHANGES", "COMMENT"}},
"commitID": {Type: "string", Description: "The SHA of the commit to review (optional, defaults to latest)"},
},
Required: []string{"owner", "repo", "pullNumber"},
},
},
[]scopes.Scope{scopes.Repo},
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
}
pullNumber, err := RequiredInt(args, "pullNumber")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
body, _ := OptionalParam[string](args, "body")
event, _ := OptionalParam[string](args, "event")
commitID, _ := OptionalParam[string](args, "commitID")
gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil
}
var commitIDPtr *string
if commitID != "" {
commitIDPtr = &commitID
}
result, err := CreatePullRequestReview(ctx, gqlClient, PullRequestReviewWriteParams{
Owner: owner,
Repo: repo,
PullNumber: int32(pullNumber), // #nosec G115 - PR numbers are always small positive integers
Body: body,
Event: event,
CommitID: commitIDPtr,
})
return result, nil, err
},
)
st.FeatureFlagEnable = FeatureFlagPullRequestsGranular
return st
}
// GranularSubmitPendingPullRequestReview creates a tool to submit a pending review.
func GranularSubmitPendingPullRequestReview(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
ToolsetMetadataPullRequests,
mcp.Tool{
Name: "submit_pending_pull_request_review",
Description: t("TOOL_SUBMIT_PENDING_PULL_REQUEST_REVIEW_DESCRIPTION", "Submit a pending pull request review."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_SUBMIT_PENDING_PULL_REQUEST_REVIEW_USER_TITLE", "Submit Pending Pull Request Review"),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(false),
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {Type: "string", Description: "Repository owner (username or organization)"},
"repo": {Type: "string", Description: "Repository name"},
"pullNumber": {Type: "number", Description: "The pull request number", Minimum: jsonschema.Ptr(1.0)},
"event": {Type: "string", Description: "The review action to perform", Enum: []any{"APPROVE", "REQUEST_CHANGES", "COMMENT"}},
"body": {Type: "string", Description: "The review body text (optional)"},
},
Required: []string{"owner", "repo", "pullNumber", "event"},
},
},
[]scopes.Scope{scopes.Repo},
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
}
pullNumber, err := RequiredInt(args, "pullNumber")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
event, err := RequiredParam[string](args, "event")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
body, _ := OptionalParam[string](args, "body")
gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil
}
result, err := SubmitPendingPullRequestReview(ctx, gqlClient, PullRequestReviewWriteParams{
Owner: owner,
Repo: repo,
PullNumber: int32(pullNumber), // #nosec G115 - PR numbers are always small positive integers
Event: event,
Body: body,
})
return result, nil, err
},
)
st.FeatureFlagEnable = FeatureFlagPullRequestsGranular
return st
}
// GranularDeletePendingPullRequestReview creates a tool to delete a pending review.
func GranularDeletePendingPullRequestReview(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
ToolsetMetadataPullRequests,
mcp.Tool{
Name: "delete_pending_pull_request_review",
Description: t("TOOL_DELETE_PENDING_PULL_REQUEST_REVIEW_DESCRIPTION", "Delete a pending pull request review."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_DELETE_PENDING_PULL_REQUEST_REVIEW_USER_TITLE", "Delete Pending Pull Request Review"),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(true),
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {Type: "string", Description: "Repository owner (username or organization)"},
"repo": {Type: "string", Description: "Repository name"},
"pullNumber": {Type: "number", Description: "The pull request number", Minimum: jsonschema.Ptr(1.0)},
},
Required: []string{"owner", "repo", "pullNumber"},
},
},
[]scopes.Scope{scopes.Repo},
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
}
pullNumber, err := RequiredInt(args, "pullNumber")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil
}
result, err := DeletePendingPullRequestReview(ctx, gqlClient, PullRequestReviewWriteParams{
Owner: owner,
Repo: repo,
PullNumber: int32(pullNumber), // #nosec G115 - PR numbers are always small positive integers
})
return result, nil, err
},
)
st.FeatureFlagEnable = FeatureFlagPullRequestsGranular
return st
}
// GranularAddPullRequestReviewComment creates a tool to add a review comment.
func GranularAddPullRequestReviewComment(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
ToolsetMetadataPullRequests,
mcp.Tool{
Name: "add_pull_request_review_comment",
Description: t("TOOL_ADD_PULL_REQUEST_REVIEW_COMMENT_DESCRIPTION", "Add a review comment to the current user's pending pull request review."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_ADD_PULL_REQUEST_REVIEW_COMMENT_USER_TITLE", "Add Pull Request Review Comment"),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(false),
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {Type: "string", Description: "Repository owner (username or organization)"},
"repo": {Type: "string", Description: "Repository name"},
"pullNumber": {Type: "number", Description: "The pull request number", Minimum: jsonschema.Ptr(1.0)},
"path": {Type: "string", Description: "The relative path of the file to comment on"},
"body": {Type: "string", Description: "The comment body"},
"subjectType": {Type: "string", Description: "The subject type of the comment", Enum: []any{"FILE", "LINE"}},
"line": {Type: "number", Description: "The line number in the diff to comment on (optional)"},
"side": {Type: "string", Description: "The side of the diff to comment on (optional)", Enum: []any{"LEFT", "RIGHT"}},
"startLine": {Type: "number", Description: "The start line of a multi-line comment (optional)"},
"startSide": {Type: "string", Description: "The start side of a multi-line comment (optional)", Enum: []any{"LEFT", "RIGHT"}},
},
Required: []string{"owner", "repo", "pullNumber", "path", "body", "subjectType"},
},
},
[]scopes.Scope{scopes.Repo},
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
}
pullNumber, err := RequiredInt(args, "pullNumber")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
path, err := RequiredParam[string](args, "path")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
body, err := RequiredParam[string](args, "body")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
subjectType, err := RequiredParam[string](args, "subjectType")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
line, err := OptionalIntParam(args, "line")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
side, _ := OptionalParam[string](args, "side")
startLine, err := OptionalIntParam(args, "startLine")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
startSide, _ := OptionalParam[string](args, "startSide")
gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil
}
// Convert optional int params to *int32 for the helper
var linePtr, startLinePtr *int32
if line != 0 {
l := int32(line) // #nosec G115
linePtr = &l
}
if startLine != 0 {
sl := int32(startLine) // #nosec G115
startLinePtr = &sl
}
// Convert optional string params: pass nil (not empty string) when absent
var sidePtr, startSidePtr *string
if side != "" {
sidePtr = &side
}
if startSide != "" {
startSidePtr = &startSide
}
result, err := AddCommentToPendingReviewCall(ctx, gqlClient, AddCommentToPendingReviewParams{
Owner: owner,
Repo: repo,
PullNumber: int32(pullNumber), // #nosec G115 - PR numbers are always small positive integers
Path: path,
Body: body,
SubjectType: subjectType,
Line: linePtr,
Side: sidePtr,
StartLine: startLinePtr,
StartSide: startSidePtr,
})
return result, nil, err
},
)
st.FeatureFlagEnable = FeatureFlagPullRequestsGranular
return st
}
// GranularResolveReviewThread creates a tool to resolve a review thread.
func GranularResolveReviewThread(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
ToolsetMetadataPullRequests,
mcp.Tool{
Name: "resolve_review_thread",
Description: t("TOOL_RESOLVE_REVIEW_THREAD_DESCRIPTION", "Resolve a review thread on a pull request. Resolving an already-resolved thread is a no-op."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_RESOLVE_REVIEW_THREAD_USER_TITLE", "Resolve Review Thread"),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(false),
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"threadID": {
Type: "string",
Description: "The node ID of the review thread to resolve (e.g., PRRT_kwDOxxx)",
},
},
Required: []string{"threadID"},
},
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
threadID, err := RequiredParam[string](args, "threadID")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil
}
result, err := ResolveReviewThread(ctx, gqlClient, threadID, true)
return result, nil, err
},
)
st.FeatureFlagEnable = FeatureFlagPullRequestsGranular
return st
}
// GranularUnresolveReviewThread creates a tool to unresolve a review thread.
func GranularUnresolveReviewThread(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
ToolsetMetadataPullRequests,
mcp.Tool{
Name: "unresolve_review_thread",
Description: t("TOOL_UNRESOLVE_REVIEW_THREAD_DESCRIPTION", "Unresolve a previously resolved review thread on a pull request. Unresolving an already-unresolved thread is a no-op."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_UNRESOLVE_REVIEW_THREAD_USER_TITLE", "Unresolve Review Thread"),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(false),
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"threadID": {
Type: "string",
Description: "The node ID of the review thread to unresolve (e.g., PRRT_kwDOxxx)",
},
},
Required: []string{"threadID"},
},
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
threadID, err := RequiredParam[string](args, "threadID")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil
}
result, err := ResolveReviewThread(ctx, gqlClient, threadID, false)
return result, nil, err
},
)
st.FeatureFlagEnable = FeatureFlagPullRequestsGranular
return st
}