diff --git a/docs/feature-flags.md b/docs/feature-flags.md index 2924b9f3d7..04beb92738 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -110,6 +110,12 @@ as output formatting) won't appear here. - `repo`: Repository name (string, required) - `sub_issue_id`: The ID of the sub-issue to add. ID is not the same as issue number (number, required) +- **batch_update_issue_labels** - Batch Update Issue Labels + - **OAuth Challenge Scopes**: `repo` + - `operations`: One entry per issue, in any order. Each entry requires issue_number plus at least one non-empty of add or remove (arrays of label names). Duplicate issue numbers are rejected. (object[], required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - **create_issue** - Create Issue - **OAuth Challenge Scopes**: `repo` - `body`: Issue body content (optional) (string, optional) diff --git a/pkg/github/__toolsnaps__/batch_update_issue_labels.snap b/pkg/github/__toolsnaps__/batch_update_issue_labels.snap new file mode 100644 index 0000000000..6d1fede0f8 --- /dev/null +++ b/pkg/github/__toolsnaps__/batch_update_issue_labels.snap @@ -0,0 +1,124 @@ +{ + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Batch Update Issue Labels" + }, + "description": "Apply label changes to multiple issues in one call. Each entry in operations targets one issue and can add labels, remove labels, or both. Labels not named in the operation are left untouched. Invalid input fails the whole call before any change is applied; per-issue API errors are reported individually.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC\r", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=\r", + "theme": "dark" + } + ], + "inputSchema": { + "properties": { + "operations": { + "description": "One entry per issue, in any order. Each entry requires issue_number plus at least one non-empty of add or remove (arrays of label names). Duplicate issue numbers are rejected.", + "items": { + "additionalProperties": false, + "properties": { + "add": { + "description": "Label names to add to this issue (GitHub creates missing labels implicitly)", + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "issue_number": { + "description": "The issue number to update", + "minimum": 1, + "type": "number" + }, + "remove": { + "description": "Label names to remove from this issue", + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "issue_number" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string", + "x-mcp-header": "owner" + }, + "repo": { + "description": "Repository name", + "type": "string", + "x-mcp-header": "repo" + } + }, + "required": [ + "owner", + "repo", + "operations" + ], + "type": "object" + }, + "name": "batch_update_issue_labels", + "outputSchema": { + "additionalProperties": false, + "properties": { + "results": { + "items": { + "additionalProperties": false, + "properties": { + "applied": { + "type": "boolean" + }, + "error": { + "type": "string" + }, + "issue_number": { + "type": "integer" + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "issue_number", + "applied" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "results" + ], + "type": [ + "null", + "object" + ] + } +} \ No newline at end of file diff --git a/pkg/github/batch_labels_granular_test.go b/pkg/github/batch_labels_granular_test.go new file mode 100644 index 0000000000..ae669b6d2b --- /dev/null +++ b/pkg/github/batch_labels_granular_test.go @@ -0,0 +1,284 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + "time" + + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + gogithub "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGranularBatchUpdateIssueLabels(t *testing.T) { + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectToolErr bool + expectedErrMsg string + }{ + { + name: "add and remove across multiple issues", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesByOwnerByRepoByIssueNumberLabels: expectRequestBody(t, []any{"bug", "priority/high"}). + andThen(mockResponse(t, http.StatusOK, []*gogithub.Label{ + {Name: "bug"}, + {Name: "priority/high"}, + })), + DeleteReposIssuesByOwnerByRepoByIssueNumberLabel: mockResponse(t, http.StatusNoContent, nil), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "operations": []any{ + map[string]any{ + "issue_number": float64(1), + "add": []any{"bug", "priority/high"}, + }, + map[string]any{ + "issue_number": float64(2), + "remove": []any{"wontfix"}, + }, + }, + }, + expectToolErr: false, + }, + { + name: "empty operations array rejected", + mockedClient: MockHTTPClientWithHandlers(nil), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "operations": []any{}, + }, + expectToolErr: true, + expectedErrMsg: "operations must contain at least one entry", + }, + { + name: "missing operations parameter rejected", + mockedClient: MockHTTPClientWithHandlers(nil), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectToolErr: true, + expectedErrMsg: "missing required parameter: operations", + }, + { + name: "operation without add or remove rejected", + mockedClient: MockHTTPClientWithHandlers(nil), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "operations": []any{ + map[string]any{"issue_number": float64(1)}, + }, + }, + expectToolErr: true, + expectedErrMsg: "at least one non-empty of add or remove is required", + }, + { + name: "duplicate issue numbers rejected", + mockedClient: MockHTTPClientWithHandlers(nil), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "operations": []any{ + map[string]any{"issue_number": float64(1), "add": []any{"bug"}}, + map[string]any{"issue_number": float64(1), "remove": []any{"wontfix"}}, + }, + }, + expectToolErr: true, + expectedErrMsg: "duplicate issue_number 1", + }, + { + name: "empty label name in add rejected", + mockedClient: MockHTTPClientWithHandlers(nil), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "operations": []any{ + map[string]any{"issue_number": float64(1), "add": []any{""}}, + }, + }, + expectToolErr: true, + expectedErrMsg: "add contains an empty label name", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := BaseDeps{Client: mustNewGHClient(t, tc.mockedClient)} + serverTool := GranularBatchUpdateIssueLabels(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + if tc.expectToolErr { + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + assert.False(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, `"issue_number":1`) + assert.Contains(t, textContent.Text, `"applied":true`) + }) + } +} + +func TestGranularBatchUpdateIssueLabelsPartialFailure(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesByOwnerByRepoByIssueNumberLabels: func(_ http.ResponseWriter, _ *http.Request) {}, + })) + deps := BaseDeps{Client: client} + serverTool := GranularBatchUpdateIssueLabels(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "operations": []any{ + map[string]any{"issue_number": float64(1), "add": []any{"bug"}}, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + // A per-issue API failure must surface as a tool error with a JSON body + // identifying the failing issue. + assert.True(t, result.IsError, "expected IsError on API failure") + textContent := getTextResult(t, result) + assert.True(t, strings.Contains(textContent.Text, "add failed")) +} + +func TestGranularBatchUpdateIssueLabelsTransport(t *testing.T) { + for _, invalid := range []bool{false, true} { + t.Run(map[bool]string{false: "valid batch", true: "invalid later operation"}[invalid], func(t *testing.T) { + calls := 0 + httpClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesByOwnerByRepoByIssueNumberLabels: func(w http.ResponseWriter, r *http.Request) { + calls++ + expectPath(t, "/repos/owner/repo/issues/1/labels"). + andThen(expectRequestBody(t, []any{"bug"}). + andThen(mockResponse(t, http.StatusOK, []*gogithub.Label{{Name: "bug"}})))(w, r) + }, + }) + deps := BaseDeps{Client: mustNewGHClient(t, httpClient)} + tool := GranularBatchUpdateIssueLabels(translations.NullTranslationHelper) + server := mcp.NewServer(&mcp.Implementation{Name: "batch-label-test"}, nil) + server.AddTool(&tool.Tool, tool.Handler(deps)) + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + return next(ContextWithDeps(ctx, deps), method, request) + } + }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, ct := mcp.NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + require.NoError(t, err) + defer func() { _ = ss.Close() }() + cs, err := mcp.NewClient(&mcp.Implementation{Name: "batch-label-client"}, nil).Connect(ctx, ct, nil) + require.NoError(t, err) + defer func() { _ = cs.Close() }() + operations := []any{map[string]any{"issue_number": 1, "add": []string{"bug"}}} + if invalid { + operations = append(operations, map[string]any{"issue_number": 2, "add": []string{" "}}) + } + result, err := cs.CallTool(ctx, &mcp.CallToolParams{ + Name: tool.Tool.Name, + Arguments: map[string]any{"owner": "owner", "repo": "repo", "operations": operations}, + }) + require.NoError(t, err) + assert.Equal(t, invalid, result.IsError) + if invalid { + assert.Zero(t, calls, "validation must precede every mutation") + assert.Contains(t, getErrorResult(t, result).Text, "operations[1]") + } else { + assert.Equal(t, 1, calls) + assert.Contains(t, getTextResult(t, result).Text, `"applied":true`) + } + }) + } +} + +func TestGranularBatchUpdateIssueLabelsModernTypedSession(t *testing.T) { + calls := 0 + httpClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesByOwnerByRepoByIssueNumberLabels: func(w http.ResponseWriter, r *http.Request) { + calls++ + expectPath(t, "/repos/owner/repo/issues/1/labels"). + andThen(expectRequestBody(t, []any{"bug"}). + andThen(mockResponse(t, http.StatusOK, []*gogithub.Label{{Name: "bug"}})))(w, r) + }, + }) + deps := BaseDeps{Client: mustNewGHClient(t, httpClient)} + tool := GranularBatchUpdateIssueLabels(translations.NullTranslationHelper) + inv, err := inventory.NewBuilder().SetTools([]inventory.ServerTool{tool}). + WithToolsets([]string{"all"}). + WithFeatureChecker(featureCheckerFor(inventory.FeatureFlag(FeatureFlagIssuesGranular))).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "batch-label-typed-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + session := connectCommentVisibilityClient(t, server, inventory.ProtocolVersionMultiRoundTrip) + + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + require.Equal(t, tool.Tool.Name, list.Tools[0].Name) + require.NotNil(t, list.Tools[0].OutputSchema) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, list.Tools[0].OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + require.NoError(t, toolsnaps.Test(tool.Tool.Name, *list.Tools[0])) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tool.Tool.Name, + Arguments: map[string]any{ + "owner": "owner", + "repo": "repo", + "operations": []any{map[string]any{ + // RequiredInt accepted this numeric string before typed decoding. + "issue_number": "1.0", + "add": []any{"bug"}, + }}, + }, + }) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + expected := `{"results":[{"issue_number":1,"applied":true,"labels":["bug"]}]}` + assert.JSONEq(t, expected, getTextResult(t, result).Text) + require.NotNil(t, result.StructuredContent) + assert.JSONEq(t, expected, mustMarshalJSON(t, result.StructuredContent)) + require.NoError(t, resolved.Validate(result.StructuredContent)) + assert.Equal(t, 1, calls) + + invalid, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tool.Tool.Name, + Arguments: map[string]any{ + "owner": "owner", + "repo": "repo", + "operations": []any{ + map[string]any{"issue_number": "1.0", "add": []any{"bug"}}, + map[string]any{"issue_number": "2", "add": []any{" "}}, + }, + }, + }) + require.NoError(t, err) + require.True(t, invalid.IsError) + assert.Nil(t, invalid.StructuredContent) + assert.Contains(t, getErrorResult(t, invalid).Text, "operations[1]") + assert.Equal(t, 1, calls, "validation must precede every mutation") +} diff --git a/pkg/github/granular_issue_types.go b/pkg/github/granular_issue_types.go index a1a8f47816..5723d2c61b 100644 --- a/pkg/github/granular_issue_types.go +++ b/pkg/github/granular_issue_types.go @@ -111,6 +111,35 @@ type GranularIssueLabelsInput struct { Labels []GranularIssueLabel `json:"labels"` } +// GranularBatchUpdateIssueLabelsInput contains one label mutation per issue. +// Add and Remove are both optional, but at least one must contain a label for +// every operation. +type GranularBatchUpdateIssueLabelsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Operations []GranularBatchUpdateIssueLabelsOperation `json:"operations"` +} + +type GranularBatchUpdateIssueLabelsOperation struct { + IssueNumber int `json:"issue_number"` + Add []string `json:"add,omitempty"` + Remove []string `json:"remove,omitempty"` +} + +// GranularBatchUpdateIssueLabelsResult reports the outcome for one issue. +type GranularBatchUpdateIssueLabelsResult struct { + IssueNumber int `json:"issue_number"` + Applied bool `json:"applied"` + Error string `json:"error,omitempty"` + Labels []string `json:"labels,omitempty"` +} + +// GranularBatchUpdateIssueLabelsOutput is the structured result of a batch +// label update. +type GranularBatchUpdateIssueLabelsOutput struct { + Results []GranularBatchUpdateIssueLabelsResult `json:"results"` +} + type GranularIssueTypeInput struct { GranularIssueCoordinate IssueType *string `json:"issue_type"` diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index 460a192766..9599ee78e0 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -46,6 +46,7 @@ func TestGranularToolSnaps(t *testing.T) { GranularUpdateIssueBody, GranularUpdateIssueAssignees, GranularUpdateIssueLabels, + GranularBatchUpdateIssueLabels, GranularUpdateIssueMilestone, GranularUpdateIssueType, GranularUpdateIssueState, @@ -91,6 +92,7 @@ func TestIssuesGranularToolset(t *testing.T) { "update_issue_body", "update_issue_assignees", "update_issue_labels", + "batch_update_issue_labels", "update_issue_milestone", "update_issue_type", "update_issue_state", diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 80a2e523f2..aedcc52054 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -64,6 +64,8 @@ const ( GetReposIssuesCommentsByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/comments" PostReposIssuesByOwnerByRepo = "POST /repos/{owner}/{repo}/issues" PostReposIssuesCommentsByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/comments" + PostReposIssuesByOwnerByRepoByIssueNumberLabels = "POST /repos/{owner}/{repo}/issues/{issue_number}/labels" + DeleteReposIssuesByOwnerByRepoByIssueNumberLabel = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}" PatchReposIssuesCommentByOwnerByRepoByCommentID = "PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}" PostReposIssuesReactionsByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/reactions" DeleteReposIssuesReactionsByOwnerByRepoByIssueNumber = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/reactions/{reaction_id}" diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index eab4a204cd..413bf3e953 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -2,6 +2,7 @@ package github import ( "context" + "encoding/json" "fmt" "maps" "strings" @@ -484,6 +485,211 @@ func GranularUpdateIssueMilestone(t translations.TranslationHelperFunc) inventor ) } +// GranularBatchUpdateIssueLabels creates a tool to apply label changes to +// several issues in a single call, avoiding N sequential update_issue_labels +// round-trips for bulk triage workflows. +func GranularBatchUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool[GranularBatchUpdateIssueLabelsInput, *GranularBatchUpdateIssueLabelsOutput]( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "batch_update_issue_labels", + Description: t("TOOL_BATCH_UPDATE_ISSUE_LABELS_DESCRIPTION", "Apply label changes to multiple issues in one call. Each entry in operations targets one issue and can add labels, remove labels, or both. Labels not named in the operation are left untouched. Invalid input fails the whole call before any change is applied; per-issue API errors are reported individually."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_BATCH_UPDATE_ISSUE_LABELS_USER_TITLE", "Batch Update Issue Labels"), + 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", + }, + "operations": { + Type: "array", + Description: "One entry per issue, in any order. Each entry requires issue_number plus at least one non-empty of add or remove (arrays of label names). Duplicate issue numbers are rejected.", + MinItems: jsonschema.Ptr(1), + Items: &jsonschema.Schema{ + Type: "object", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: map[string]*jsonschema.Schema{ + "issue_number": { + Type: "number", + Description: "The issue number to update", + Minimum: jsonschema.Ptr(1.0), + }, + "add": { + Type: "array", + Description: "Label names to add to this issue (GitHub creates missing labels implicitly)", + Items: &jsonschema.Schema{Type: "string", MinLength: jsonschema.Ptr(1)}, + }, + "remove": { + Type: "array", + Description: "Label names to remove from this issue", + Items: &jsonschema.Schema{Type: "string", MinLength: jsonschema.Ptr(1)}, + }, + }, + Required: []string{"issue_number"}, + }, + }, + }, + Required: []string{"owner", "repo", "operations"}, + }, + }, + scopes.RequireAll(scopes.Repo), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularBatchUpdateIssueLabelsInput) (*mcp.CallToolResult, *GranularBatchUpdateIssueLabelsOutput, error) { + owner, repo := args.Owner, args.Repo + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil + } + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil + } + if len(args.Operations) == 0 { + return utils.NewToolResultError("operations must contain at least one entry"), nil, nil + } + + // Validate every operation up front so an invalid entry fails the + // whole call before any mutation is attempted. + planned := make([]GranularBatchUpdateIssueLabelsOperation, 0, len(args.Operations)) + seenIssues := make(map[int]bool, len(args.Operations)) + for i, op := range args.Operations { + if op.IssueNumber == 0 { + return utils.NewToolResultError(fmt.Sprintf("operations[%d]: missing required parameter: issue_number", i)), nil, nil + } + if op.IssueNumber < 0 { + return utils.NewToolResultError(fmt.Sprintf("operations[%d]: issue_number must be greater than 0", i)), nil, nil + } + if seenIssues[op.IssueNumber] { + return utils.NewToolResultError(fmt.Sprintf("operations[%d]: duplicate issue_number %d; merge the label changes into a single operation", i, op.IssueNumber)), nil, nil + } + seenIssues[op.IssueNumber] = true + if len(op.Add) == 0 && len(op.Remove) == 0 { + return utils.NewToolResultError(fmt.Sprintf("operations[%d]: at least one non-empty of add or remove is required", i)), nil, nil + } + for _, l := range op.Add { + if strings.TrimSpace(l) == "" { + return utils.NewToolResultError(fmt.Sprintf("operations[%d]: add contains an empty label name", i)), nil, nil + } + } + for _, l := range op.Remove { + if strings.TrimSpace(l) == "" { + return utils.NewToolResultError(fmt.Sprintf("operations[%d]: remove contains an empty label name", i)), nil, nil + } + } + planned = append(planned, op) + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + outcomes := make([]GranularBatchUpdateIssueLabelsResult, 0, len(planned)) + hadFailure := false + for _, op := range planned { + outcome := GranularBatchUpdateIssueLabelsResult{IssueNumber: op.IssueNumber, Applied: true} + + // Add phase: POST .../issues/{n}/labels adds without touching + // existing labels and returns the full resulting label set. + if len(op.Add) > 0 { + labels, resp, err := client.Issues.AddLabelsToIssue(ctx, owner, repo, op.IssueNumber, op.Add) + if resp != nil { + _ = resp.Body.Close() + } + if err != nil { + outcome.Applied = false + outcome.Error = fmt.Sprintf("add failed: %v", err) + hadFailure = true + outcomes = append(outcomes, outcome) + continue + } + outcome.Labels = make([]string, 0, len(labels)) + for _, l := range labels { + outcome.Labels = append(outcome.Labels, l.GetName()) + } + } + + // Remove phase. + failed := false + for _, label := range op.Remove { + resp, err := client.Issues.RemoveLabelForIssue(ctx, owner, repo, op.IssueNumber, label) + if resp != nil { + _ = resp.Body.Close() + } + if err != nil { + outcome.Error = fmt.Sprintf("remove %q failed: %v", label, err) + outcome.Applied = false + hadFailure = true + failed = true + break + } + } + if failed { + outcomes = append(outcomes, outcome) + continue + } + + outcomes = append(outcomes, outcome) + } + + output := &GranularBatchUpdateIssueLabelsOutput{Results: outcomes} + r, err := json.Marshal(output) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + result := utils.NewToolResultText(string(r)) + result.IsError = hadFailure + return result, output, nil + }, + granularIssueInputNormalizer(normalizeGranularBatchUpdateIssueLabelsArguments), + ) + st.FeatureRule = issuesGranularFeatureRule + return st +} + +func normalizeGranularBatchUpdateIssueLabelsArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + for _, name := range []string{"owner", "repo"} { + if _, err := RequiredParam[string](args, name); err != nil { + return nil, err + } + } + operationsValue, ok := args["operations"] + if !ok { + return nil, fmt.Errorf("missing required parameter: operations") + } + operations, ok := operationsValue.([]any) + if !ok { + return nil, fmt.Errorf("parameter operations is not of type array, is %T", operationsValue) + } + for i, operationValue := range operations { + operation, ok := operationValue.(map[string]any) + if !ok { + return nil, fmt.Errorf("operations[%d] is not an object, is %T", i, operationValue) + } + issueNumber, ok := operation["issue_number"] + if !ok { + continue + } + normalizedIssueNumber, err := toInt(issueNumber) + if err != nil { + return nil, fmt.Errorf("operations[%d].issue_number: %w", i, err) + } + operation["issue_number"] = normalizedIssueNumber + } + return json.Marshal(args) +} + // issueTypeWithIntent represents the object form of the issue type field, // allowing a rationale, confidence level, and/or suggest flag to be sent alongside the type name. type issueTypeWithIntent struct { diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 53503f68fe..0ae92f9e3e 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -365,6 +365,7 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent GranularUpdateIssueBody(t), GranularUpdateIssueAssignees(t), GranularUpdateIssueLabels(t), + GranularBatchUpdateIssueLabels(t), GranularUpdateIssueMilestone(t), GranularUpdateIssueType(t), GranularUpdateIssueState(t), diff --git a/pkg/github/typed_granular_issue_outputs_test.go b/pkg/github/typed_granular_issue_outputs_test.go index d2b833d650..4915820d9f 100644 --- a/pkg/github/typed_granular_issue_outputs_test.go +++ b/pkg/github/typed_granular_issue_outputs_test.go @@ -23,7 +23,7 @@ import ( func granularIssueSession(t *testing.T, deps BaseDeps, protocol string) (*mcp.ClientSession, map[string]*jsonschema.Resolved) { t.Helper() tools := granularToolsForToolset(ToolsetMetadataIssues.ID, FeatureFlagIssuesGranular) - require.Len(t, tools, 18) + require.Len(t, tools, 19) inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}). WithFeatureChecker(featureCheckerFor(inventory.FeatureFlag(FeatureFlagIssuesGranular))).Build() require.NoError(t, err)