diff --git a/pkg/github/pullrequests_stacks.go b/pkg/github/pullrequests_stacks.go new file mode 100644 index 0000000000..cdb53ddeae --- /dev/null +++ b/pkg/github/pullrequests_stacks.go @@ -0,0 +1,519 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + + 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" +) + +// StackLayer represents a pull request layer inside a stack. +type StackLayer struct { + PullNumber int `json:"pull_number,omitempty"` + Head string `json:"head,omitempty"` + Base string `json:"base,omitempty"` + Title string `json:"title,omitempty"` + State string `json:"state,omitempty"` + Mergeable *bool `json:"mergeable,omitempty"` + ReviewDecision string `json:"review_decision,omitempty"` +} + +// Stack represents a GitHub native pull request stack. +type Stack struct { + ID int64 `json:"id,omitempty"` + StackNumber int `json:"stack_number,omitempty"` + Title string `json:"title,omitempty"` + Base string `json:"base,omitempty"` + PullRequests []StackLayer `json:"pull_requests,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// LinkStackInput represents the JSON payload to create/link a stack. +type LinkStackInput struct { + Base string `json:"base,omitempty"` + PullNumbers []int `json:"pull_numbers"` +} + +// UpdateStackInput represents the JSON payload to update a stack. +type UpdateStackInput struct { + Base string `json:"base,omitempty"` + PullNumbers []int `json:"pull_numbers,omitempty"` +} + +func parseIntArray(args map[string]any, p string) ([]int, error) { + val, ok := args[p] + if !ok { + return nil, nil + } + switch v := val.(type) { + case []any: + res := make([]int, len(v)) + for i, item := range v { + num, err := toInt(item) + if err != nil { + return nil, fmt.Errorf("item at index %d in %s is invalid: %w", i, p, err) + } + res[i] = num + } + return res, nil + case []int: + return v, nil + case []float64: + res := make([]int, len(v)) + for i, num := range v { + res[i] = int(num) + } + return res, nil + default: + return nil, fmt.Errorf("parameter %s is not an array", p) + } +} + +// GetStack creates a tool to fetch details for a pull request stack. +func GetStack(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "stackNumber": { + Type: "number", + Description: "Stack number", + }, + "pullNumber": { + Type: "number", + Description: "Pull request number contained within the target stack", + }, + }, + Required: []string{"owner", "repo"}, + } + + return NewTool( + ToolsetMetadataPullRequests, + mcp.Tool{ + Name: "get_stack", + Description: t("TOOL_GET_STACK_DESCRIPTION", "Get details of a specific pull request stack in a GitHub repository."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_STACK_TITLE", "Get pull request stack details"), + ReadOnlyHint: true, + }, + InputSchema: schema, + }, + []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 + } + + stackNumber, err := OptionalIntParam(args, "stackNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + pullNumber, err := OptionalIntParam(args, "pullNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + if stackNumber == 0 && pullNumber == 0 { + return utils.NewToolResultError("must provide either stackNumber or pullNumber"), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + var urlStr string + if stackNumber != 0 { + urlStr = fmt.Sprintf("repos/%s/%s/stacks/%d", owner, repo, stackNumber) + } else { + urlStr = fmt.Sprintf("repos/%s/%s/stacks?pull_request=%d", owner, repo, pullNumber) + } + + req, err := client.NewRequest(http.MethodGet, urlStr, nil) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil + } + + if stackNumber != 0 { + var stack Stack + resp, err := client.Do(ctx, req, &stack) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get pull request stack", resp, err), nil, nil + } + + r, err := json.Marshal(stack) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil + } + + var stacks []Stack + resp, err := client.Do(ctx, req, &stacks) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get pull request stack", resp, err), nil, nil + } + + r, err := json.Marshal(stacks) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil + }, + ) +} + +// ListStacks creates a tool to list pull request stacks in a repository. +func ListStacks(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + }, + Required: []string{"owner", "repo"}, + } + WithPagination(schema) + + return NewTool( + ToolsetMetadataPullRequests, + mcp.Tool{ + Name: "list_stacks", + Description: t("TOOL_LIST_STACKS_DESCRIPTION", "List pull request stacks in a GitHub repository."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_STACKS_TITLE", "List pull request stacks"), + ReadOnlyHint: true, + }, + InputSchema: schema, + }, + []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 + } + + pagination, err := OptionalPaginationParams(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 + } + + urlStr := fmt.Sprintf("repos/%s/%s/stacks?page=%d&per_page=%d", owner, repo, pagination.Page, pagination.PerPage) + req, err := client.NewRequest(http.MethodGet, urlStr, nil) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil + } + + var stacks []Stack + resp, err := client.Do(ctx, req, &stacks) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list pull request stacks", resp, err), nil, nil + } + + r, err := json.Marshal(stacks) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil + }, + ) +} + +// LinkStack creates a tool to link PRs into a new stack. +func LinkStack(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "pullNumbers": { + Type: "array", + Description: "Ordered list of pull request numbers (bottom to top)", + Items: &jsonschema.Schema{ + Type: "number", + }, + }, + "base": { + Type: "string", + Description: "Base/trunk branch name", + }, + }, + Required: []string{"owner", "repo", "pullNumbers"}, + } + + return NewTool( + ToolsetMetadataPullRequests, + mcp.Tool{ + Name: "link_stack", + Description: t("TOOL_LINK_STACK_DESCRIPTION", "Create or link a pull request stack from an ordered sequence of pull request numbers."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LINK_STACK_TITLE", "Link pull request stack"), + ReadOnlyHint: false, + }, + InputSchema: schema, + }, + []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 + } + + pullNumbers, err := parseIntArray(args, "pullNumbers") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if len(pullNumbers) == 0 { + return utils.NewToolResultError("missing required parameter: pullNumbers"), nil, nil + } + + base, err := OptionalParam[string](args, "base") + 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 + } + + input := LinkStackInput{ + Base: base, + PullNumbers: pullNumbers, + } + + urlStr := fmt.Sprintf("repos/%s/%s/stacks", owner, repo) + req, err := client.NewRequest(http.MethodPost, urlStr, input) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil + } + + var stack Stack + resp, err := client.Do(ctx, req, &stack) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to link pull request stack", resp, err), nil, nil + } + + r, err := json.Marshal(stack) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil + }, + ) +} + +// UpdateStack creates a tool to update an existing pull request stack. +func UpdateStack(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "stackNumber": { + Type: "number", + Description: "Stack number to update", + }, + "pullNumbers": { + Type: "array", + Description: "Updated ordered list of pull request numbers", + Items: &jsonschema.Schema{ + Type: "number", + }, + }, + "base": { + Type: "string", + Description: "Updated base/trunk branch name", + }, + }, + Required: []string{"owner", "repo", "stackNumber"}, + } + + return NewTool( + ToolsetMetadataPullRequests, + mcp.Tool{ + Name: "update_stack", + Description: t("TOOL_UPDATE_STACK_DESCRIPTION", "Update an existing pull request stack's layers or base branch."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UPDATE_STACK_TITLE", "Update pull request stack"), + ReadOnlyHint: false, + }, + InputSchema: schema, + }, + []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 + } + stackNumber, err := RequiredInt(args, "stackNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + pullNumbers, err := parseIntArray(args, "pullNumbers") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + base, err := OptionalParam[string](args, "base") + 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 + } + + input := UpdateStackInput{ + Base: base, + PullNumbers: pullNumbers, + } + + urlStr := fmt.Sprintf("repos/%s/%s/stacks/%d", owner, repo, stackNumber) + req, err := client.NewRequest(http.MethodPatch, urlStr, input) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil + } + + var stack Stack + resp, err := client.Do(ctx, req, &stack) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update pull request stack", resp, err), nil, nil + } + + r, err := json.Marshal(stack) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil + }, + ) +} + +// DissolveStack creates a tool to dissolve a pull request stack. +func DissolveStack(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "stackNumber": { + Type: "number", + Description: "Stack number to dissolve", + }, + }, + Required: []string{"owner", "repo", "stackNumber"}, + } + + return NewTool( + ToolsetMetadataPullRequests, + mcp.Tool{ + Name: "dissolve_stack", + Description: t("TOOL_DISSOLVE_STACK_DESCRIPTION", "Dissolve a pull request stack object without deleting the underlying pull requests."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_DISSOLVE_STACK_TITLE", "Dissolve pull request stack"), + ReadOnlyHint: false, + }, + InputSchema: schema, + }, + []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 + } + stackNumber, err := RequiredInt(args, "stackNumber") + 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 + } + + urlStr := fmt.Sprintf("repos/%s/%s/stacks/%d", owner, repo, stackNumber) + req, err := client.NewRequest(http.MethodDelete, urlStr, nil) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil + } + + resp, err := client.Do(ctx, req, nil) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to dissolve pull request stack", resp, err), nil, nil + } + + return utils.NewToolResultText(fmt.Sprintf("Successfully dissolved stack %d in %s/%s", stackNumber, owner, repo)), nil, nil + }, + ) +} diff --git a/pkg/github/pullrequests_stacks_test.go b/pkg/github/pullrequests_stacks_test.go new file mode 100644 index 0000000000..cc88b56e88 --- /dev/null +++ b/pkg/github/pullrequests_stacks_test.go @@ -0,0 +1,213 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "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_GetStack_ToolDefinition(t *testing.T) { + serverTool := GetStack(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_stack", tool.Name) + assert.NotEmpty(t, tool.Description) + schema := tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "stackNumber") + assert.Contains(t, schema.Properties, "pullNumber") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"}) +} + +func Test_ListStacks_ToolDefinition(t *testing.T) { + serverTool := ListStacks(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_stacks", tool.Name) + assert.NotEmpty(t, tool.Description) + schema := tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "page") + assert.Contains(t, schema.Properties, "perPage") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"}) +} + +func Test_LinkStack_ToolDefinition(t *testing.T) { + serverTool := LinkStack(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "link_stack", tool.Name) + assert.NotEmpty(t, tool.Description) + schema := tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "pullNumbers") + assert.Contains(t, schema.Properties, "base") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "pullNumbers"}) +} + +func Test_UpdateStack_ToolDefinition(t *testing.T) { + serverTool := UpdateStack(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "update_stack", tool.Name) + assert.NotEmpty(t, tool.Description) + schema := tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "stackNumber") + assert.Contains(t, schema.Properties, "pullNumbers") + assert.Contains(t, schema.Properties, "base") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "stackNumber"}) +} + +func Test_DissolveStack_ToolDefinition(t *testing.T) { + serverTool := DissolveStack(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "dissolve_stack", tool.Name) + assert.NotEmpty(t, tool.Description) + schema := tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "stackNumber") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "stackNumber"}) +} + +func Test_GetStack_Execution(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/repos/owner/repo/stacks/10", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(Stack{ + ID: 100, + StackNumber: 10, + Title: "Test Stack", + Base: "main", + PullRequests: []StackLayer{ + {PullNumber: 101, Head: "feature-1", Base: "main"}, + {PullNumber: 102, Head: "feature-2", Base: "feature-1"}, + }, + }) + }) + + ts := httptest.NewServer(mux) + defer ts.Close() + + client := github.NewClient(ts.Client()) + url, _ := url.Parse(ts.URL + "/") + client.BaseURL = url + + deps := ToolDependencies{ + GetClient: func(ctx context.Context) (*github.Client, error) { + return client, nil + }, + } + + serverTool := GetStack(translations.NullTranslationHelper) + handler := serverTool.HandlerFunc(deps) + + res, err := handler(context.Background(), &mcp.CallToolRequest{}, map[string]any{ + "owner": "owner", + "repo": "repo", + "stackNumber": 10, + }) + require.NoError(t, err) + assert.False(t, res.IsError) + assert.Contains(t, res.Content[0].(mcp.TextContent).Text, `"stack_number":10`) +} + +func Test_LinkStack_Execution(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/repos/owner/repo/stacks", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + var body LinkStackInput + _ = json.NewDecoder(r.Body).Decode(&body) + assert.Equal(t, "main", body.Base) + assert.Equal(t, []int{101, 102}, body.PullNumbers) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(Stack{ + ID: 200, + StackNumber: 15, + Base: body.Base, + }) + }) + + ts := httptest.NewServer(mux) + defer ts.Close() + + client := github.NewClient(ts.Client()) + url, _ := url.Parse(ts.URL + "/") + client.BaseURL = url + + deps := ToolDependencies{ + GetClient: func(ctx context.Context) (*github.Client, error) { + return client, nil + }, + } + + serverTool := LinkStack(translations.NullTranslationHelper) + handler := serverTool.HandlerFunc(deps) + + res, err := handler(context.Background(), &mcp.CallToolRequest{}, map[string]any{ + "owner": "owner", + "repo": "repo", + "base": "main", + "pullNumbers": []any{101, 102}, + }) + require.NoError(t, err) + assert.False(t, res.IsError) + assert.Contains(t, res.Content[0].(mcp.TextContent).Text, `"stack_number":15`) +} + +func Test_DissolveStack_Execution(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/repos/owner/repo/stacks/10", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "DELETE", r.Method) + w.WriteHeader(http.StatusNoContent) + }) + + ts := httptest.NewServer(mux) + defer ts.Close() + + client := github.NewClient(ts.Client()) + url, _ := url.Parse(ts.URL + "/") + client.BaseURL = url + + deps := ToolDependencies{ + GetClient: func(ctx context.Context) (*github.Client, error) { + return client, nil + }, + } + + serverTool := DissolveStack(translations.NullTranslationHelper) + handler := serverTool.HandlerFunc(deps) + + res, err := handler(context.Background(), &mcp.CallToolRequest{}, map[string]any{ + "owner": "owner", + "repo": "repo", + "stackNumber": 10, + }) + require.NoError(t, err) + assert.False(t, res.IsError) + assert.Contains(t, res.Content[0].(mcp.TextContent).Text, "Successfully dissolved stack 10") +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 5c6123c277..30524de03c 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -244,6 +244,11 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { PullRequestReviewWrite(t), AddCommentToPendingReview(t), AddReplyToPullRequestComment(t), + GetStack(t), + ListStacks(t), + LinkStack(t), + UpdateStack(t), + DissolveStack(t), // Copilot tools AssignCopilotToIssue(t), diff --git a/pkg/github/toolset_instructions.go b/pkg/github/toolset_instructions.go index ba6659612a..4dce9c01b5 100644 --- a/pkg/github/toolset_instructions.go +++ b/pkg/github/toolset_instructions.go @@ -18,7 +18,9 @@ Check 'list_issue_types' first for organizations to use proper issue types. Use func generatePullRequestsToolsetInstructions(inv *inventory.Inventory) string { instructions := `## Pull Requests -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.` +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. + +Stacked PRs workflow: Use 'link_stack' to group dependent pull requests into a native GitHub stack, 'get_stack' or 'list_stacks' to inspect stack layers, 'update_stack' to update ordering or base branches, and 'dissolve_stack' to un-group stack layers.` if inv.HasToolset("repos") { instructions += `