Conversation
There was a problem hiding this comment.
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_granularandpull_requests_granulartoolset metadata and registers new tools inAllTools. - 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
| 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 |
There was a problem hiding this comment.
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.
| 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 |
| // 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 | ||
|
|
There was a problem hiding this comment.
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.
| 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) | ||
| } |
There was a problem hiding this comment.
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.
0552c6d to
d8732b5
Compare
9119943 to
bf18957
Compare
Summary
Add two new opt-in, non-default toolsets —
issues_granularandpull_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
methodparameter to control behavior (e.g.issue_writewithmethod: "create"ormethod: "update"). This makes it impossible to selectively enable individual operations — you either get all ofissue_writeor 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):create_issueissue_writemethod:createupdate_issue_titleissue_writemethod:updateupdate_issue_bodyissue_writemethod:updateupdate_issue_stateissue_writemethod:updateupdate_issue_labelsissue_writemethod:updateupdate_issue_assigneesissue_writemethod:updateupdate_issue_milestoneissue_writemethod:updateupdate_issue_typeissue_writemethod:updateadd_sub_issuesub_issue_writemethod:addremove_sub_issuesub_issue_writemethod:removereprioritize_sub_issuesub_issue_writemethod:reprioritizepull_requests_granular(9 tools):update_pull_request_titleupdate_pull_requestupdate_pull_request_bodyupdate_pull_requestupdate_pull_request_stateupdate_pull_requestupdate_pull_request_draft_staterequest_pull_request_reviewersupdate_pull_request(reviewers field)create_pull_request_reviewpull_request_review_writemethod:createsubmit_pending_pull_request_reviewpull_request_review_writemethod:submit_pendingdelete_pending_pull_request_reviewpull_request_review_writemethod:delete_pendingadd_pull_request_review_commentpull_request_review_write(comment to pending review)MCP impact
Prompts tested (tool changes only)
--toolsets=default,issues_granular,pull_requests_granularenables both granular toolsets alongside defaults--toolsets=issues_granularenables only granular issue toolsSecurity / limits
All granular tools require the same
reposcope as their parent tools.Tool renaming
deprecated_tool_aliases.goLint & tests
./script/lint./script/testDocs