diff --git a/README.md b/README.md index 5b90f64a58..84a61a9efc 100644 --- a/README.md +++ b/README.md @@ -1296,6 +1296,24 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - `threadId`: The node ID of the review thread (e.g., PRRT_kwDOxxx). Required for resolve_thread and unresolve_thread methods. Get thread IDs from pull_request_read with method get_review_comments. (string, optional) +- **pull_request_stack_read** - Read pull request stacks + - **OAuth Challenge Scopes**: `repo` + - `method`: The read operation: `get` retrieves one stack by stackNumber; `list` lists repository stacks and can filter by pullNumber. (string, required) + - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `pullNumber`: Filter listed stacks to the stack containing this repository pull request number. Used only when method is `list`. (number, optional) + - `repo`: Repository name (string, required) + - `stackNumber`: Stack number. Required when method is `get`. (number, optional) + +- **pull_request_stack_write** - Manage pull request stack + - **OAuth Challenge Scopes**: `repo` + - `method`: The write operation: `create`, `add`, or `unstack`. (string, required) + - `owner`: Repository owner (string, required) + - `pullNumbers`: Repository pull request numbers in bottom-to-top order. Required for `create` and `add`. (number[], optional) + - `repo`: Repository name (string, required) + - `stackNumber`: Stack number. Required for `add` and `unstack`. (number, optional) + - **search_pull_requests** - Search pull requests - **OAuth Challenge Scopes**: `repo` - `fields`: Subset of fields to return for each pull request result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body', 'reactions', and 'labels' in particular drops the largest per-result data. (string[], optional) diff --git a/pkg/github/__toolsnaps__/pull_request_stack_read.snap b/pkg/github/__toolsnaps__/pull_request_stack_read.snap new file mode 100644 index 0000000000..54d7f3e69e --- /dev/null +++ b/pkg/github/__toolsnaps__/pull_request_stack_read.snap @@ -0,0 +1,56 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Read pull request stacks" + }, + "description": "Read native GitHub pull request stacks. Use `get` for a stack number or `list` to enumerate stacks and optionally resolve the stack containing a pull request.", + "inputSchema": { + "properties": { + "method": { + "description": "The read operation: `get` retrieves one stack by stackNumber; `list` lists repository stacks and can filter by pullNumber.", + "enum": [ + "get", + "list" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "pullNumber": { + "description": "Filter listed stacks to the stack containing this repository pull request number. Used only when method is `list`.", + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "stackNumber": { + "description": "Stack number. Required when method is `get`.", + "minimum": 1, + "type": "number" + } + }, + "required": [ + "method", + "owner", + "repo" + ], + "type": "object" + }, + "name": "pull_request_stack_read" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/pull_request_stack_write.snap b/pkg/github/__toolsnaps__/pull_request_stack_write.snap new file mode 100644 index 0000000000..a45bc3d7f5 --- /dev/null +++ b/pkg/github/__toolsnaps__/pull_request_stack_write.snap @@ -0,0 +1,53 @@ +{ + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Manage pull request stack" + }, + "description": "Create, extend, or unstack a native GitHub pull request stack. `create` accepts 2-100 pullNumbers ordered bottom-to-top. `add` accepts 1-100 pullNumbers to append above the current top. `unstack` removes every removable unmerged pull request and may leave locked or queued pull requests in the stack. All pull requests must belong to the target repository, use branches in that repository, and form a linear base/head chain. These operations manage stack metadata only; they do not create pull requests, retarget bases, rebase commits, push branches, or merge.", + "inputSchema": { + "properties": { + "method": { + "description": "The write operation: `create`, `add`, or `unstack`.", + "enum": [ + "create", + "add", + "unstack" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "pullNumbers": { + "description": "Repository pull request numbers in bottom-to-top order. Required for `create` and `add`.", + "items": { + "minimum": 1, + "type": "number" + }, + "maxItems": 100, + "minItems": 1, + "type": "array" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "stackNumber": { + "description": "Stack number. Required for `add` and `unstack`.", + "minimum": 1, + "type": "number" + } + }, + "required": [ + "method", + "owner", + "repo" + ], + "type": "object" + }, + "name": "pull_request_stack_write" +} \ No newline at end of file diff --git a/pkg/github/pullrequests_stacks.go b/pkg/github/pullrequests_stacks.go new file mode 100644 index 0000000000..93670c06c8 --- /dev/null +++ b/pkg/github/pullrequests_stacks.go @@ -0,0 +1,463 @@ +package github + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" + "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" +) + +const pullRequestStacksAPIVersion = "2026-03-10" + +// PullRequestStackRepository identifies a repository referenced by a stack layer. +type PullRequestStackRepository struct { + ID int64 `json:"id"` + Name string `json:"name"` + URL string `json:"url"` +} + +// PullRequestStackRef identifies a branch and commit referenced by a stack. +type PullRequestStackRef struct { + Ref string `json:"ref"` + SHA string `json:"sha,omitempty"` + Repo *PullRequestStackRepository `json:"repo,omitempty"` +} + +// PullRequestStackPullRequest is the compact pull request representation returned +// by the stack tools. +type PullRequestStackPullRequest struct { + ID int64 `json:"id,omitempty"` + NodeID string `json:"node_id,omitempty"` + Number int `json:"number"` + URL string `json:"url,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + State string `json:"state"` + MergedAt *string `json:"merged_at"` + Draft bool `json:"draft"` + Head PullRequestStackRef `json:"head"` + Base *PullRequestStackRef `json:"base,omitempty"` +} + +// PullRequestStack is a compact representation of a native GitHub pull request +// stack. PullRequests are ordered from the bottom of the stack to the top. +type PullRequestStack struct { + ID int64 `json:"id"` + Number int `json:"number"` + NodeID string `json:"node_id"` + URL string `json:"url"` + Base PullRequestStackRef `json:"base"` + Open bool `json:"open"` + CreatedAt string `json:"created_at"` + PullRequests []PullRequestStackPullRequest `json:"pull_requests"` +} + +type pullRequestStackInput struct { + PullRequests []int `json:"pull_requests"` +} + +type pullRequestStackListResult struct { + Stacks []PullRequestStack `json:"stacks"` + PageInfo map[string]any `json:"pageInfo"` +} + +type pullRequestStackUnstackResult struct { + Dissolved bool `json:"dissolved"` + StackNumber int `json:"stack_number"` + Stack *PullRequestStack `json:"stack,omitempty"` +} + +// PullRequestStackRead creates a tool for reading native pull request stacks. +func PullRequestStackRead(t translations.TranslationHelperFunc, opts ...ToolOption) inventory.ServerTool { + cfg := newToolConfig(opts) + schema := WithPagination(&jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: "The read operation: `get` retrieves one stack by stackNumber; `list` lists repository stacks and can filter by pullNumber.", + Enum: []any{"get", "list"}, + }, + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "stackNumber": { + Type: "number", + Description: "Stack number. Required when method is `get`.", + Minimum: jsonschema.Ptr(1.0), + }, + "pullNumber": { + Type: "number", + Description: "Filter listed stacks to the stack containing this repository pull request number. Used only when method is `list`.", + Minimum: jsonschema.Ptr(1.0), + }, + }, + Required: []string{"method", "owner", "repo"}, + }) + + st := NewTool( + ToolsetMetadataPullRequests, + mcp.Tool{ + Name: "pull_request_stack_read", + Description: t("TOOL_PULL_REQUEST_STACK_READ_DESCRIPTION", "Read native GitHub pull request stacks. Use `get` for a stack number or `list` to enumerate stacks and optionally resolve the stack containing a pull request."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_PULL_REQUEST_STACK_READ_USER_TITLE", "Read pull request stacks"), + ReadOnlyHint: true, + }, + InputSchema: schema, + }, + scopes.PublicRead(scopes.Repo), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + 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 + } + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + var result *mcp.CallToolResult + switch method { + case "get": + stackNumber, err := requiredPullRequestStackNumber(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + stack, resp, err := GetPullRequestStack(ctx, client, owner, repo, stackNumber) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get pull request stack", resp, err), nil, nil + } + result = MarshalledTextResult(stack) + case "list": + pullNumber, err := OptionalIntParam(args, "pullNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if _, provided := args["pullNumber"]; provided && pullNumber < 1 { + return utils.NewToolResultError("parameter pullNumber must be greater than zero"), nil, nil + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + stacks, resp, err := ListPullRequestStacks(ctx, client, owner, repo, pullNumber, pagination) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list pull request stacks", resp, err), nil, nil + } + result = MarshalledTextResult(pullRequestStackListResult{ + Stacks: stacks, + PageInfo: map[string]any{ + "hasNextPage": resp.NextPage != 0, + "nextPage": resp.NextPage, + }, + }) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoUserContent) + return result, nil, nil + }, + ) + if cfg.hostType == utils.HostTypeGHES { + st.Enabled = func(context.Context) (bool, error) { return false, nil } + } + return st +} + +// PullRequestStackWrite creates a tool for creating, extending, or unstacking +// native pull request stacks. +func PullRequestStackWrite(t translations.TranslationHelperFunc, opts ...ToolOption) inventory.ServerTool { + cfg := newToolConfig(opts) + st := NewTool( + ToolsetMetadataPullRequests, + mcp.Tool{ + Name: "pull_request_stack_write", + Description: t("TOOL_PULL_REQUEST_STACK_WRITE_DESCRIPTION", + "Create, extend, or unstack a native GitHub pull request stack. "+ + "`create` accepts 2-100 pullNumbers ordered bottom-to-top. "+ + "`add` accepts 1-100 pullNumbers to append above the current top. "+ + "`unstack` removes every removable unmerged pull request and may leave locked or queued pull requests in the stack. "+ + "All pull requests must belong to the target repository, use branches in that repository, and form a linear base/head chain. "+ + "These operations manage stack metadata only; they do not create pull requests, retarget bases, rebase commits, push branches, or merge."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_PULL_REQUEST_STACK_WRITE_USER_TITLE", "Manage pull request stack"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(true), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: "The write operation: `create`, `add`, or `unstack`.", + Enum: []any{"create", "add", "unstack"}, + }, + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "stackNumber": { + Type: "number", + Description: "Stack number. Required for `add` and `unstack`.", + Minimum: jsonschema.Ptr(1.0), + }, + "pullNumbers": { + Type: "array", + Description: "Repository pull request numbers in bottom-to-top order. Required for `create` and `add`.", + Items: &jsonschema.Schema{ + Type: "number", + Minimum: jsonschema.Ptr(1.0), + }, + MinItems: jsonschema.Ptr(1), + MaxItems: jsonschema.Ptr(100), + }, + }, + Required: []string{"method", "owner", "repo"}, + }, + }, + publicRepositoryWriteScopeAccess(), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + 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 + } + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + var result *mcp.CallToolResult + switch method { + case "create": + pullNumbers, err := parsePullRequestStackNumbers(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if len(pullNumbers) < 2 { + return utils.NewToolResultError("method create requires at least two pullNumbers"), nil, nil + } + stack, resp, err := CreatePullRequestStack(ctx, client, owner, repo, pullNumbers) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create pull request stack", resp, err), nil, nil + } + result = MarshalledTextResult(stack) + case "add": + stackNumber, err := requiredPullRequestStackNumber(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pullNumbers, err := parsePullRequestStackNumbers(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if len(pullNumbers) == 0 { + return utils.NewToolResultError("method add requires at least one pullNumber"), nil, nil + } + stack, resp, err := AddPullRequestsToStack(ctx, client, owner, repo, stackNumber, pullNumbers) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add pull requests to stack", resp, err), nil, nil + } + result = MarshalledTextResult(stack) + case "unstack": + stackNumber, err := requiredPullRequestStackNumber(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + stack, resp, err := UnstackPullRequests(ctx, client, owner, repo, stackNumber) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to unstack pull requests", resp, err), nil, nil + } + result = MarshalledTextResult(pullRequestStackUnstackResult{ + Dissolved: stack == nil, + StackNumber: stackNumber, + Stack: stack, + }) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoUserContent) + return result, nil, nil + }, + ) + if cfg.hostType == utils.HostTypeGHES { + st.Enabled = func(context.Context) (bool, error) { return false, nil } + } + return st +} + +// GetPullRequestStack gets a native pull request stack by number. +func GetPullRequestStack(ctx context.Context, client *github.Client, owner, repo string, stackNumber int) (*PullRequestStack, *github.Response, error) { + apiURL := fmt.Sprintf("repos/%s/%s/stacks/%d", owner, repo, stackNumber) + req, err := newPullRequestStackRequest(ctx, client, http.MethodGet, apiURL, nil) + if err != nil { + return nil, nil, err + } + + var stack PullRequestStack + resp, err := client.Do(req, &stack) + return &stack, resp, err +} + +// ListPullRequestStacks lists native pull request stacks, optionally filtering +// to the stack containing pullNumber. +func ListPullRequestStacks(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) ([]PullRequestStack, *github.Response, error) { + query := url.Values{ + "page": {strconv.Itoa(pagination.Page)}, + "per_page": {strconv.Itoa(pagination.PerPage)}, + } + if pullNumber > 0 { + query.Set("pull_request", strconv.Itoa(pullNumber)) + } + apiURL := fmt.Sprintf("repos/%s/%s/stacks?%s", owner, repo, query.Encode()) + req, err := newPullRequestStackRequest(ctx, client, http.MethodGet, apiURL, nil) + if err != nil { + return nil, nil, err + } + + var stacks []PullRequestStack + resp, err := client.Do(req, &stacks) + return stacks, resp, err +} + +// CreatePullRequestStack creates a native stack from pull request numbers +// ordered from bottom to top. +func CreatePullRequestStack(ctx context.Context, client *github.Client, owner, repo string, pullNumbers []int) (*PullRequestStack, *github.Response, error) { + return mutatePullRequestStack(ctx, client, owner, repo, "", pullNumbers) +} + +// AddPullRequestsToStack appends pull request numbers above the current stack top. +func AddPullRequestsToStack(ctx context.Context, client *github.Client, owner, repo string, stackNumber int, pullNumbers []int) (*PullRequestStack, *github.Response, error) { + return mutatePullRequestStack(ctx, client, owner, repo, fmt.Sprintf("%d/add", stackNumber), pullNumbers) +} + +// UnstackPullRequests removes every removable unmerged pull request from a +// native stack. A nil stack means the stack was dissolved. +func UnstackPullRequests(ctx context.Context, client *github.Client, owner, repo string, stackNumber int) (*PullRequestStack, *github.Response, error) { + apiURL := fmt.Sprintf("repos/%s/%s/stacks/%d/unstack", owner, repo, stackNumber) + req, err := newPullRequestStackRequest(ctx, client, http.MethodPost, apiURL, nil) + if err != nil { + return nil, nil, err + } + + var stack PullRequestStack + resp, err := client.Do(req, &stack) + if err != nil { + return nil, resp, err + } + if resp.StatusCode == http.StatusNoContent { + return nil, resp, nil + } + return &stack, resp, nil +} + +func mutatePullRequestStack(ctx context.Context, client *github.Client, owner, repo, suffix string, pullNumbers []int) (*PullRequestStack, *github.Response, error) { + apiURL := fmt.Sprintf("repos/%s/%s/stacks", owner, repo) + if suffix != "" { + apiURL += "/" + suffix + } + req, err := newPullRequestStackRequest(ctx, client, http.MethodPost, apiURL, pullRequestStackInput{PullRequests: pullNumbers}) + if err != nil { + return nil, nil, err + } + + var stack PullRequestStack + resp, err := client.Do(req, &stack) + return &stack, resp, err +} + +func newPullRequestStackRequest(ctx context.Context, client *github.Client, method, apiURL string, body any) (*http.Request, error) { + return client.NewRequest(ctx, method, apiURL, body, github.WithVersion(pullRequestStacksAPIVersion)) +} + +func requiredPullRequestStackNumber(args map[string]any) (int, error) { + stackNumber, err := RequiredInt(args, "stackNumber") + if err != nil { + return 0, err + } + if stackNumber < 1 { + return 0, fmt.Errorf("parameter stackNumber must be greater than zero") + } + return stackNumber, nil +} + +func parsePullRequestStackNumbers(args map[string]any) ([]int, error) { + value, ok := args["pullNumbers"] + if !ok { + return nil, nil + } + + var pullNumbers []int + switch values := value.(type) { + case []int: + pullNumbers = append([]int(nil), values...) + case []any: + pullNumbers = make([]int, len(values)) + for i, value := range values { + if number, ok := value.(int); ok { + pullNumbers[i] = number + continue + } + number, err := toInt(value) + if err != nil { + return nil, fmt.Errorf("pullNumbers[%d] is invalid: %w", i, err) + } + pullNumbers[i] = number + } + default: + return nil, fmt.Errorf("parameter pullNumbers is not an array, is %T", value) + } + + if len(pullNumbers) > 100 { + return nil, fmt.Errorf("pullNumbers must contain at most 100 items") + } + seen := make(map[int]struct{}, len(pullNumbers)) + for i, number := range pullNumbers { + if number < 1 { + return nil, fmt.Errorf("pullNumbers[%d] must be greater than zero", i) + } + if _, exists := seen[number]; exists { + return nil, fmt.Errorf("pullNumbers[%d] duplicates pull request %d", i, number) + } + seen[number] = struct{}{} + } + return pullNumbers, nil +} diff --git a/pkg/github/pullrequests_stacks_test.go b/pkg/github/pullrequests_stacks_test.go new file mode 100644 index 0000000000..94ee86bfe9 --- /dev/null +++ b/pkg/github/pullrequests_stacks_test.go @@ -0,0 +1,464 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "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/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/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_PullRequestStackRead_ToolDefinition(t *testing.T) { + serverTool := PullRequestStackRead(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "pull_request_stack_read", tool.Name) + assert.True(t, tool.Annotations.ReadOnlyHint) + schema := tool.InputSchema.(*jsonschema.Schema) + assert.ElementsMatch(t, []string{"method", "owner", "repo"}, schema.Required) + assert.ElementsMatch(t, []any{"get", "list"}, schema.Properties["method"].Enum) + assert.Contains(t, schema.Properties, "stackNumber") + assert.Contains(t, schema.Properties, "pullNumber") + assert.Contains(t, schema.Properties, "page") + assert.Contains(t, schema.Properties, "perPage") + assert.True(t, serverTool.ScopeAccess.Visible(nil)) +} + +func Test_PullRequestStackWrite_ToolDefinition(t *testing.T) { + serverTool := PullRequestStackWrite(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "pull_request_stack_write", tool.Name) + assert.False(t, tool.Annotations.ReadOnlyHint) + require.NotNil(t, tool.Annotations.DestructiveHint) + assert.True(t, *tool.Annotations.DestructiveHint) + schema := tool.InputSchema.(*jsonschema.Schema) + assert.ElementsMatch(t, []string{"method", "owner", "repo"}, schema.Required) + assert.ElementsMatch(t, []any{"create", "add", "unstack"}, schema.Properties["method"].Enum) + assert.Contains(t, schema.Properties, "stackNumber") + assert.Contains(t, schema.Properties, "pullNumbers") + assert.Equal(t, 1, *schema.Properties["pullNumbers"].MinItems) + assert.Equal(t, 100, *schema.Properties["pullNumbers"].MaxItems) + assert.True(t, serverTool.ScopeAccess.Visible([]string{"public_repo"})) + assert.False(t, serverTool.ScopeAccess.Visible(nil)) +} + +func Test_PullRequestStackTools_HostAvailability(t *testing.T) { + tests := []struct { + name string + host utils.HostType + wantStack bool + }{ + {name: "dotcom", host: utils.HostTypeDotcom, wantStack: true}, + {name: "GHES", host: utils.HostTypeGHES, wantStack: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inv, err := NewInventory(translations.NullTranslationHelper, WithHost(tt.host)). + WithToolsets([]string{"pull_requests"}). + WithFeatureChecker(featureCheckerFor()). + WithServerInstructions(). + Build() + require.NoError(t, err) + + available := make(map[string]bool) + for _, tool := range inv.ToolsForRegistration(context.Background()) { + available[tool.Tool.Name] = true + } + assert.Equal(t, tt.wantStack, available["pull_request_stack_read"]) + assert.Equal(t, tt.wantStack, available["pull_request_stack_write"]) + if tt.wantStack { + assert.Contains(t, inv.Instructions(), "pull_request_stack_read") + assert.Contains(t, inv.Instructions(), "pull_request_stack_write") + } else { + assert.NotContains(t, inv.Instructions(), "pull_request_stack_read") + assert.NotContains(t, inv.Instructions(), "pull_request_stack_write") + } + }) + } +} + +func Test_PullRequestStackRead_Get(t *testing.T) { + client := newPullRequestStackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/repos/owner/repo/stacks/42", r.URL.Path) + assert.Equal(t, pullRequestStacksAPIVersion, r.Header.Get("X-GitHub-Api-Version")) + writePullRequestStack(t, w, http.StatusOK) + }) + + result := callPullRequestStackTool(t, PullRequestStackRead, client, map[string]any{ + "method": "get", + "owner": "owner", + "repo": "repo", + "stackNumber": float64(42), + }) + + assert.False(t, result.IsError) + text := getTextResult(t, result).Text + assert.Contains(t, text, `"number":42`) + assert.Contains(t, text, `"base":{"ref":"main"}`) + assert.Contains(t, text, `"head":{"ref":"feature","sha":"abc123"`) + assert.NotContains(t, text, `"title"`) + assert.NotContains(t, text, `"user"`) +} + +func Test_PullRequestStackRead_List(t *testing.T) { + client := newPullRequestStackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/repos/owner/repo/stacks", r.URL.Path) + assert.Equal(t, "17", r.URL.Query().Get("pull_request")) + assert.Equal(t, "2", r.URL.Query().Get("page")) + assert.Equal(t, "25", r.URL.Query().Get("per_page")) + assert.Equal(t, pullRequestStacksAPIVersion, r.Header.Get("X-GitHub-Api-Version")) + w.Header().Set("Link", `; rel="next"`) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + require.NoError(t, json.NewEncoder(w).Encode([]PullRequestStack{testPullRequestStack()})) + }) + + result := callPullRequestStackTool(t, PullRequestStackRead, client, map[string]any{ + "method": "list", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(17), + "page": float64(2), + "perPage": float64(25), + }) + + assert.False(t, result.IsError) + text := getTextResult(t, result).Text + assert.Contains(t, text, `"stacks":[{"id":9876543`) + assert.Contains(t, text, `"pageInfo":{"hasNextPage":true,"nextPage":3}`) +} + +func Test_PullRequestStackWrite_Create(t *testing.T) { + client := newPullRequestStackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/repos/owner/repo/stacks", r.URL.Path) + assert.Equal(t, pullRequestStacksAPIVersion, r.Header.Get("X-GitHub-Api-Version")) + var input pullRequestStackInput + require.NoError(t, json.NewDecoder(r.Body).Decode(&input)) + assert.Equal(t, []int{101, 102}, input.PullRequests) + writePullRequestStack(t, w, http.StatusCreated) + }) + + result := callPullRequestStackTool(t, PullRequestStackWrite, client, map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "pullNumbers": []any{float64(101), "102"}, + }) + + assert.False(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, `"number":42`) +} + +func Test_PullRequestStackWrite_Add(t *testing.T) { + client := newPullRequestStackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/repos/owner/repo/stacks/42/add", r.URL.Path) + var input pullRequestStackInput + require.NoError(t, json.NewDecoder(r.Body).Decode(&input)) + assert.Equal(t, []int{103}, input.PullRequests) + writePullRequestStack(t, w, http.StatusOK) + }) + + result := callPullRequestStackTool(t, PullRequestStackWrite, client, map[string]any{ + "method": "add", + "owner": "owner", + "repo": "repo", + "stackNumber": float64(42), + "pullNumbers": []int{103}, + }) + + assert.False(t, result.IsError) +} + +func Test_PullRequestStackWrite_Unstack(t *testing.T) { + t.Run("remaining locked pull requests", func(t *testing.T) { + client := newPullRequestStackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/repos/owner/repo/stacks/42/unstack", r.URL.Path) + writePullRequestStack(t, w, http.StatusOK) + }) + + result := callPullRequestStackTool(t, PullRequestStackWrite, client, map[string]any{ + "method": "unstack", + "owner": "owner", + "repo": "repo", + "stackNumber": float64(42), + }) + + assert.False(t, result.IsError) + text := getTextResult(t, result).Text + assert.Contains(t, text, `"dissolved":false`) + assert.Contains(t, text, `"stack":{"id":9876543`) + }) + + t.Run("dissolved stack", func(t *testing.T) { + client := newPullRequestStackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/repos/owner/repo/stacks/42/unstack", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + }) + + result := callPullRequestStackTool(t, PullRequestStackWrite, client, map[string]any{ + "method": "unstack", + "owner": "owner", + "repo": "repo", + "stackNumber": float64(42), + }) + + assert.False(t, result.IsError) + assert.JSONEq(t, `{"dissolved":true,"stack_number":42}`, getTextResult(t, result).Text) + }) +} + +func Test_PullRequestStackTool_Validation(t *testing.T) { + client := newPullRequestStackTestClient(t, func(http.ResponseWriter, *http.Request) { + t.Fatal("validation should fail before making a request") + }) + + tests := []struct { + name string + tool pullRequestStackToolConstructor + args map[string]any + want string + }{ + { + name: "get requires stack number", + tool: PullRequestStackRead, + args: map[string]any{"method": "get", "owner": "owner", "repo": "repo"}, + want: "missing required parameter: stackNumber", + }, + { + name: "get rejects negative stack number", + tool: PullRequestStackRead, + args: map[string]any{"method": "get", "owner": "owner", "repo": "repo", "stackNumber": float64(-1)}, + want: "parameter stackNumber must be greater than zero", + }, + { + name: "list rejects zero pull number", + tool: PullRequestStackRead, + args: map[string]any{"method": "list", "owner": "owner", "repo": "repo", "pullNumber": float64(0)}, + want: "parameter pullNumber must be greater than zero", + }, + { + name: "create requires two pull requests", + tool: PullRequestStackWrite, + args: map[string]any{"method": "create", "owner": "owner", "repo": "repo", "pullNumbers": []any{float64(1)}}, + want: "method create requires at least two pullNumbers", + }, + { + name: "add requires pull requests", + tool: PullRequestStackWrite, + args: map[string]any{"method": "add", "owner": "owner", "repo": "repo", "stackNumber": float64(1)}, + want: "method add requires at least one pullNumber", + }, + { + name: "duplicate pull request", + tool: PullRequestStackWrite, + args: map[string]any{"method": "create", "owner": "owner", "repo": "repo", "pullNumbers": []any{float64(1), float64(1)}}, + want: "duplicates pull request 1", + }, + { + name: "invalid pull request", + tool: PullRequestStackWrite, + args: map[string]any{"method": "create", "owner": "owner", "repo": "repo", "pullNumbers": []any{float64(1), float64(-2)}}, + want: "must be greater than zero", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := callPullRequestStackTool(t, tt.tool, client, tt.args) + assert.True(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, tt.want) + }) + } +} + +func Test_PullRequestStackTool_APIError(t *testing.T) { + client := newPullRequestStackTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message":"Pull requests must form a stack"}`)) + }) + + result := callPullRequestStackTool(t, PullRequestStackWrite, client, map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "pullNumbers": []any{float64(101), float64(102)}, + }) + + assert.True(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, "failed to create pull request stack") + assert.Contains(t, getTextResult(t, result).Text, "Pull requests must form a stack") +} + +func Test_PullRequestStackTools_IFCLabels(t *testing.T) { + tests := []struct { + name string + tool pullRequestStackToolConstructor + args map[string]any + status int + }{ + { + name: "read response", + tool: PullRequestStackRead, + args: map[string]any{ + "method": "get", + "owner": "owner", + "repo": "repo", + "stackNumber": float64(42), + }, + status: http.StatusOK, + }, + { + name: "write response", + tool: PullRequestStackWrite, + args: map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "pullNumbers": []any{float64(101), float64(102)}, + }, + status: http.StatusCreated, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := newPullRequestStackTestClient(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/repos/owner/repo", "/repositories/owner/repo": + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "name": "repo", + "private": false, + })) + default: + writePullRequestStack(t, w, tt.status) + } + }) + deps := BaseDeps{ + Client: client, + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + + result := callPullRequestStackToolWithDeps(t, tt.tool, deps, tt.args) + + require.False(t, result.IsError) + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) + } +} + +type pullRequestStackToolConstructor func(translations.TranslationHelperFunc, ...ToolOption) inventory.ServerTool + +func callPullRequestStackTool( + t *testing.T, + tool pullRequestStackToolConstructor, + client *github.Client, + args map[string]any, +) *mcp.CallToolResult { + t.Helper() + deps := BaseDeps{Client: client} + return callPullRequestStackToolWithDeps(t, tool, deps, args) +} + +func callPullRequestStackToolWithDeps( + t *testing.T, + tool pullRequestStackToolConstructor, + deps BaseDeps, + args map[string]any, +) *mcp.CallToolResult { + t.Helper() + request := createMCPRequest(args) + serverTool := tool(translations.NullTranslationHelper) + result, err := serverTool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.NotNil(t, result) + return result +} + +func newPullRequestStackTestClient(t *testing.T, handler http.HandlerFunc) *github.Client { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + baseURL := server.URL + "/" + client, err := github.NewClient( + github.WithHTTPClient(server.Client()), + github.WithURLs(&baseURL, nil), + ) + require.NoError(t, err) + return client +} + +func writePullRequestStack(t *testing.T, w http.ResponseWriter, status int) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + require.NoError(t, json.NewEncoder(w).Encode(testPullRequestStack())) +} + +func testPullRequestStack() PullRequestStack { + mergedAt := "2026-08-01T12:00:00Z" + return PullRequestStack{ + ID: 9876543, + Number: 42, + NodeID: "S_kwDOABCDEF4AAAAA", + URL: "https://api.github.test/repos/owner/repo/stacks/42", + Base: PullRequestStackRef{Ref: "main"}, + Open: true, + CreatedAt: "2026-08-01T10:00:00Z", + PullRequests: []PullRequestStackPullRequest{ + { + ID: 100001, + NodeID: "PR_kwDOABCDEF4AAAAA", + Number: 101, + URL: "https://api.github.test/repos/owner/repo/pulls/101", + HTMLURL: "https://github.test/owner/repo/pull/101", + State: "closed", + MergedAt: &mergedAt, + Draft: false, + Head: PullRequestStackRef{ + Ref: "feature", + SHA: "abc123", + Repo: &PullRequestStackRepository{ + ID: 1, + Name: "repo", + URL: "https://api.github.test/repos/owner/repo", + }, + }, + Base: &PullRequestStackRef{ + Ref: "main", + SHA: "def456", + Repo: &PullRequestStackRepository{ + ID: 1, + Name: "repo", + URL: "https://api.github.test/repos/owner/repo", + }, + }, + }, + }, + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 6764edfc26..ddc92b50c5 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -288,6 +288,8 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent PullRequestReviewWriteWithResolutionReason(t, opts...), AddCommentToPendingReview(t), AddReplyToPullRequestComment(t), + PullRequestStackRead(t, opts...), + PullRequestStackWrite(t, opts...), // Copilot tools AssignCopilotToIssue(t), diff --git a/pkg/github/toolset_instructions.go b/pkg/github/toolset_instructions.go index 3b3a54eadd..37f458929c 100644 --- a/pkg/github/toolset_instructions.go +++ b/pkg/github/toolset_instructions.go @@ -1,6 +1,10 @@ package github -import "github.com/github/github-mcp-server/pkg/inventory" +import ( + "context" + + "github.com/github/github-mcp-server/pkg/inventory" +) // Toolset instruction functions - these generate context-aware instructions for each toolset. // They are called during inventory build to generate server instructions. @@ -20,6 +24,12 @@ func generatePullRequestsToolsetInstructions(inv *inventory.Inventory) string { PR review workflow: Always use 'pull_request_review_write' with method 'create' to create a pending review, then 'add_comment_to_pending_review' to add comments, and finally 'pull_request_review_write' with method 'submit_pending' to submit the review for complex reviews with line-specific comments.` + if inventoryHasAvailableTool(inv, "pull_request_stack_read") { + instructions += ` + +Stacked PRs workflow: Use 'pull_request_stack_read' to inspect native stack metadata. Use 'pull_request_stack_write' with method 'create' for 2-100 repository pull requests ordered bottom-to-top, 'add' only to append new pull requests above the current top, and 'unstack' to remove every removable unmerged pull request. Native stack operations require same-repository branches in an existing linear base/head chain; they do not create pull requests, retarget bases, rebase commits, push branches, or merge. After 'unstack', inspect the result because locked or queued pull requests can remain.` + } + if inv.HasToolset("repos") { instructions += ` @@ -28,6 +38,15 @@ Before creating a pull request, search for pull request templates in the reposit return instructions } +func inventoryHasAvailableTool(inv *inventory.Inventory, name string) bool { + for _, tool := range inv.AvailableTools(context.Background()) { + if tool.Tool.Name == name { + return true + } + } + return false +} + func generateDiscussionsToolsetInstructions(_ *inventory.Inventory) string { return `## Discussions