Skip to content

OSS granular PRs and issues toolsets#2306

Draft
mattdholloway wants to merge 2 commits intomainfrom
granular-issue-pr-toolsets
Draft

OSS granular PRs and issues toolsets#2306
mattdholloway wants to merge 2 commits intomainfrom
granular-issue-pr-toolsets

Conversation

@mattdholloway
Copy link
Copy Markdown
Contributor

@mattdholloway mattdholloway commented Apr 8, 2026

Summary

Add two new opt-in, non-default toolsets — issues_granular and pull_requests_granular — that provide single-purpose write tools decomposing the existing multi-method tools (issue_write, sub_issue_write, update_pull_request, pull_request_review_write).

This gives users fine-grained control over which write operations are available, which is especially useful for AI agents where limiting the tool surface area improves reliability and safety.

Closes https://github.com/github/sweagentd/issues/11219

Why

The existing consolidated tools use a method parameter to control behavior (e.g. issue_write with method: "create" or method: "update"). This makes it impossible to selectively enable individual operations — you either get all of issue_write or none of it.

Granular toolsets solve this by exposing each operation as its own tool, allowing users to enable exactly the capabilities they need.

What changed

New toolsets

issues_granular (11 tools):

Tool Decomposed from
create_issue issue_write method:create
update_issue_title issue_write method:update
update_issue_body issue_write method:update
update_issue_state issue_write method:update
update_issue_labels issue_write method:update
update_issue_assignees issue_write method:update
update_issue_milestone issue_write method:update
update_issue_type issue_write method:update
add_sub_issue sub_issue_write method:add
remove_sub_issue sub_issue_write method:remove
reprioritize_sub_issue sub_issue_write method:reprioritize

pull_requests_granular (9 tools):

Tool Decomposed from
update_pull_request_title update_pull_request
update_pull_request_body update_pull_request
update_pull_request_state update_pull_request
update_pull_request_draft_state New (GraphQL-based draft toggle)
request_pull_request_reviewers update_pull_request (reviewers field)
create_pull_request_review pull_request_review_write method:create
submit_pending_pull_request_review pull_request_review_write method:submit_pending
delete_pending_pull_request_review pull_request_review_write method:delete_pending
add_pull_request_review_comment pull_request_review_write (comment to pending review)

MCP impact

  • No tool or API changes
  • Tool schema or behavior changed
  • New tool added

Prompts tested (tool changes only)

  • --toolsets=default,issues_granular,pull_requests_granular enables both granular toolsets alongside defaults
  • --toolsets=issues_granular enables only granular issue tools

Security / limits

  • No security or limits impact
  • Auth / permissions considered
  • Data exposure, filtering, or token/size limits considered

All granular tools require the same repo scope as their parent tools.

Tool renaming

  • I am renaming tools as part of this PR (e.g. a part of a consolidation effort)
    • I have added the new tool aliases in deprecated_tool_aliases.go
  • I am not renaming tools as part of this PR

Lint & tests

  • Linted locally with ./script/lint
  • Tested locally with ./script/test

Docs

  • Not needed
  • Updated (README / docs / examples)

@mattdholloway mattdholloway requested a review from a team as a code owner April 8, 2026 14:32
Copilot AI review requested due to automatic review settings April 8, 2026 14:32
@mattdholloway mattdholloway changed the base branch from matt/mcp-apps-feature-flag to main April 8, 2026 14:39
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds new opt-in “granular” toolsets for issues and pull requests, providing single-operation tools intended to give callers finer control than the existing multi-method tools.

Changes:

  • Introduces issues_granular and pull_requests_granular toolset metadata and registers new tools in AllTools.
  • Adds new granular tool implementations for issue updates/sub-issues and PR updates/reviews.
  • Adds initial unit tests for toolset membership plus a small subset of granular tool behaviors.
Show a summary per file
File Description
pkg/github/tools.go Adds new toolset metadata and registers granular tools in the server tool list.
pkg/github/issues_granular.go Implements granular issue create/update tools and GraphQL-based sub-issue tools.
pkg/github/pullrequests_granular.go Implements granular PR update tools plus GraphQL-based draft/review/comment tools.
pkg/github/granular_tools_test.go Adds basic tests for granular toolset membership and a few REST-backed tools.

Copilot's findings

Comments suppressed due to low confidence (1)

pkg/github/pullrequests_granular.go:587

  • GranularDeletePendingPullRequestReview uses the same reviews(first: 1, states: PENDING) pattern, which can pick an arbitrary pending review rather than the caller’s pending review. This should target the current user's pending review deterministically (viewer-specific field / author filter) before deleting.
			// Find pending review
			var reviewQuery struct {
				Repository struct {
					PullRequest struct {
						Reviews struct {
							Nodes []struct{ ID string }
						} `graphql:"reviews(first: 1, states: PENDING)"`
					} `graphql:"pullRequest(number: $number)"`
				} `graphql:"repository(owner: $owner, name: $name)"`
			}

			vars := map[string]any{
				"owner":  githubv4.String(owner),
				"name":   githubv4.String(repo),
				"number": githubv4.Int(pullNumber), // #nosec G115 - PR numbers are always small positive integers
			}
			if err := gqlClient.Query(ctx, &reviewQuery, vars); err != nil {
				return utils.NewToolResultErrorFromErr("failed to find pending review", err), nil, nil
			}

			if len(reviewQuery.Repository.PullRequest.Reviews.Nodes) == 0 {
				return utils.NewToolResultError("no pending review found for the current user"), nil, nil
			}

			reviewID := reviewQuery.Repository.PullRequest.Reviews.Nodes[0].ID

  • Files reviewed: 16/16 changed files
  • Comments generated: 6

Comment on lines +288 to +300
Type: "number",
Description: "The milestone number to set on the issue",
Minimum: jsonschema.Ptr(0.0),
},
},
[]string{"milestone"},
func(args map[string]any) (*github.IssueRequest, error) {
milestone, err := RequiredParam[float64](args, "milestone")
if err != nil {
return nil, err
}
m := int(milestone)
return &github.IssueRequest{Milestone: &m}, nil
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update_issue_milestone uses RequiredParam[float64] and then casts to int. This rejects milestone=0 (even though the schema allows 0) and will silently truncate non-integer values. Use the existing numeric helpers (e.g., RequiredInt/OptionalIntParam) and handle 0 explicitly if you want to support clearing the milestone.

Suggested change
Type: "number",
Description: "The milestone number to set on the issue",
Minimum: jsonschema.Ptr(0.0),
},
},
[]string{"milestone"},
func(args map[string]any) (*github.IssueRequest, error) {
milestone, err := RequiredParam[float64](args, "milestone")
if err != nil {
return nil, err
}
m := int(milestone)
return &github.IssueRequest{Milestone: &m}, nil
Type: "integer",
Description: "The milestone number to set on the issue",
Minimum: jsonschema.Ptr(0),
},
},
[]string{"milestone"},
func(args map[string]any) (*github.IssueRequest, error) {
milestone, err := RequiredInt(args, "milestone")
if err != nil {
return nil, err
}
return &github.IssueRequest{Milestone: &milestone}, nil

Copilot uses AI. Check for mistakes.
Comment on lines +461 to +488
// Find pending review
var reviewQuery struct {
Repository struct {
PullRequest struct {
Reviews struct {
Nodes []struct {
ID, State string
}
} `graphql:"reviews(first: 1, states: PENDING)"`
} `graphql:"pullRequest(number: $number)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}

vars := map[string]any{
"owner": githubv4.String(owner),
"name": githubv4.String(repo),
"number": githubv4.Int(pullNumber), // #nosec G115 - PR numbers are always small positive integers
}
if err := gqlClient.Query(ctx, &reviewQuery, vars); err != nil {
return utils.NewToolResultErrorFromErr("failed to find pending review", err), nil, nil
}

if len(reviewQuery.Repository.PullRequest.Reviews.Nodes) == 0 {
return utils.NewToolResultError("no pending review found for the current user"), nil, nil
}

reviewID := reviewQuery.Repository.PullRequest.Reviews.Nodes[0].ID

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GranularSubmitPendingPullRequestReview finds a pending review by querying reviews(first: 1, states: PENDING) and then uses the first node. This can select another user's pending review (or an arbitrary one), causing incorrect behavior or authorization failures. Prefer querying the viewer’s pending review explicitly (e.g., a viewer-specific field) or filtering by the current user so the mutation targets the correct review.

This issue also appears on line 562 of the same file.

Copilot uses AI. Check for mistakes.
Comment on lines +661 to +706
line, _ := OptionalParam[float64](args, "line")
side, _ := OptionalParam[string](args, "side")
startLine, _ := OptionalParam[float64](args, "startLine")
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
}

prNodeID, err := getGranularPullRequestNodeID(ctx, gqlClient, owner, repo, pullNumber)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get pull request", err), nil, nil
}

var mutation struct {
AddPullRequestReviewThread struct {
Thread struct {
ID string
Comments struct {
Nodes []struct {
ID, Body, URL string
}
} `graphql:"comments(first: 1)"`
}
} `graphql:"addPullRequestReviewThread(input: $input)"`
}

input := map[string]any{
"pullRequestId": githubv4.ID(prNodeID),
"path": githubv4.String(path),
"body": githubv4.String(body),
"subjectType": githubv4.PullRequestReviewThreadSubjectType(subjectType),
}
if line != 0 {
input["line"] = githubv4.Int(int(line)) // #nosec G115
}
if side != "" {
input["side"] = githubv4.DiffSide(side)
}
if startLine != 0 {
input["startLine"] = githubv4.Int(int(startLine)) // #nosec G115
}
if startSide != "" {
input["startSide"] = githubv4.DiffSide(startSide)
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line/startLine are parsed as float64 and converted with int(...) without validating they are integers. This will truncate values like 10.5 and send incorrect line numbers to the GraphQL API. Consider using OptionalIntParam (or an equivalent integer-validation helper) for these fields so non-integer inputs are rejected with a clear error.

Copilot uses AI. Check for mistakes.
@mattdholloway mattdholloway force-pushed the granular-issue-pr-toolsets branch from 0552c6d to d8732b5 Compare April 8, 2026 14:41
@mattdholloway mattdholloway force-pushed the granular-issue-pr-toolsets branch from 9119943 to bf18957 Compare April 8, 2026 14:46
@mattdholloway mattdholloway marked this pull request as draft April 8, 2026 15:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants