From 3b6b2dcc928d0f4fc60cc6aff90f5d2ad241f5cd Mon Sep 17 00:00:00 2001 From: notapopsicle Date: Fri, 23 Oct 2026 12:44:06 -0400 Subject: [PATCH 1/3] add create automation tag tool --- README.md | 7 + .../__toolsnaps__/create_automation_tag.snap | 39 ++++ pkg/github/automation_tags.go | 70 +++++++ pkg/github/automation_tags_test.go | 176 ++++++++++++++++++ pkg/github/tools.go | 1 + 5 files changed, 293 insertions(+) create mode 100644 pkg/github/__toolsnaps__/create_automation_tag.snap diff --git a/README.md b/README.md index f7c41e8060..05700b7c6a 100644 --- a/README.md +++ b/README.md @@ -779,6 +779,13 @@ For negotiated supported protocol versions `2026-07-28` or newer, the consolidat workflow Copilot Automations +- **create_automation_tag** - Create automation tag + - **OAuth Challenge Scopes**: `repo` + - `description`: Description of the new tag. Omit for an empty description. (string, optional) + - `name`: Display name for the new tag (string, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - **get_automation_tag** - Get automation tag - **OAuth Challenge Scopes**: `repo` - `owner`: Repository owner (string, required) diff --git a/pkg/github/__toolsnaps__/create_automation_tag.snap b/pkg/github/__toolsnaps__/create_automation_tag.snap new file mode 100644 index 0000000000..a34fd43a63 --- /dev/null +++ b/pkg/github/__toolsnaps__/create_automation_tag.snap @@ -0,0 +1,39 @@ +{ + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Create automation tag" + }, + "description": "Create an automation tag in a repository. Tags organize automations; they do not execute them. Each call, including a retry, creates a new tag, even when another tag has the same name.", + "inputSchema": { + "properties": { + "description": { + "description": "Description of the new tag. Omit for an empty description.", + "type": "string" + }, + "name": { + "description": "Display name for the new tag", + "type": "string" + }, + "owner": { + "description": "Repository owner", + "minLength": 1, + "type": "string" + }, + "repo": { + "description": "Repository name", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "name" + ], + "type": "object" + }, + "name": "create_automation_tag" +} \ No newline at end of file diff --git a/pkg/github/automation_tags.go b/pkg/github/automation_tags.go index 1e3c382088..0235120603 100644 --- a/pkg/github/automation_tags.go +++ b/pkg/github/automation_tags.go @@ -2,6 +2,7 @@ package github import ( "context" + "crypto/rand" "fmt" "net/http" "net/url" @@ -182,6 +183,75 @@ func GetAutomationTag(t translations.TranslationHelperFunc) inventory.ServerTool ) } +type CreateAutomationTagInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +func CreateAutomationTag(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool[CreateAutomationTagInput, *AutomationTag]( + ToolsetMetadataCopilotAutomations, + mcp.Tool{ + Name: "create_automation_tag", + Description: t("TOOL_CREATE_AUTOMATION_TAG_DESCRIPTION", "Create an automation tag in a repository. Tags organize automations; they do not execute them. Each call, including a retry, creates a new tag, even when another tag has the same name."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_AUTOMATION_TAG_USER_TITLE", "Create automation tag"), + ReadOnlyHint: false, + DestructiveHint: new(false), + OpenWorldHint: new(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string", Description: "Repository owner", MinLength: new(1)}, + "repo": {Type: "string", Description: "Repository name", MinLength: new(1)}, + "name": {Type: "string", Description: "Display name for the new tag"}, + "description": {Type: "string", Description: "Description of the new tag. Omit for an empty description."}, + }, + Required: []string{"owner", "repo", "name"}, + }, + }, + scopes.RequireAll(scopes.Repo), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input CreateAutomationTagInput) (*mcp.CallToolResult, *AutomationTag, error) { + if input.Owner == "." { + return utils.NewToolResultError(`owner must not be "."`), nil, nil + } + if input.Repo == "." { + return utils.NewToolResultError(`repo must not be "."`), nil, nil + } + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + // A reused key returns the tag it first created without comparing name or + // description, so each call sends a new key. + body := map[string]any{"name": input.Name, "idempotency_key": rand.Text()} + if input.Description != "" { + body["description"] = input.Description + } + apiURL := fmt.Sprintf("agents/repos/%s/%s/automation-tags", url.PathEscape(input.Owner), url.PathEscape(input.Repo)) + req, err := client.NewRequest(ctx, http.MethodPost, apiURL, body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create automation tag request", err), nil, nil + } + var output AutomationTag + resp, err := client.Do(req, &output) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create automation tag", resp, err), nil, nil + } + if resp.StatusCode != http.StatusCreated { + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create automation tag", resp, nil), nil, nil + } + return MarshalledTextResult(&output), &output, nil + }, + ) +} + func sanitizeAutomationTagNameAndDescription(tag *AutomationTag) { tag.Name = sanitize.PlainText(tag.Name) tag.Description = sanitize.Content(tag.Description) diff --git a/pkg/github/automation_tags_test.go b/pkg/github/automation_tags_test.go index 8a73da543e..36e4de957b 100644 --- a/pkg/github/automation_tags_test.go +++ b/pkg/github/automation_tags_test.go @@ -413,3 +413,179 @@ func Test_GetAutomationTag(t *testing.T) { }) } } + +func Test_CreateAutomationTag(t *testing.T) { + toolDef := CreateAutomationTag(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + assert.False(t, toolDef.Tool.Annotations.ReadOnlyHint) + + t.Run("discovery", func(t *testing.T) { + for _, tc := range []struct { + name string + toolsets []string + scopes []string + readOnly bool + want bool + }{ + {"default", []string{"default"}, []string{"repo"}, false, false}, + {"opt in", []string{"copilot_automations"}, []string{"repo"}, false, true}, + {"opt in read only", []string{"copilot_automations"}, []string{"repo"}, true, false}, + {"missing scope", []string{"copilot_automations"}, []string{}, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + inv, err := NewInventory(translations.NullTranslationHelper). + WithToolsets(tc.toolsets).WithReadOnly(tc.readOnly). + WithFilter(CreateToolScopeFilter(tc.scopes)).Build() + require.NoError(t, err) + require.Empty(t, inv.UnrecognizedToolsets()) + var found bool + for _, tool := range inv.AvailableTools(context.Background()) { + found = found || tool.Tool.Name == "create_automation_tag" + } + assert.Equal(t, tc.want, found) + }) + } + }) + + seen := make(map[string]bool) + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25"} { + t.Run(protocol, func(t *testing.T) { + for _, tc := range []struct { + name string + args map[string]any + status int + body string + escapedPath string + wantRequestBody string + want string + wantError string + }{ + { + name: "internal and additive fields", + status: http.StatusCreated, + body: `{"id":"tag-1","name":"Release","description":"","version":"opaque-version","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-01T00:00:00Z","can_delete":true,"deletion_pending":false,"has_known_unavailable_members":false,"future_field":true}`, + want: `{"id":"tag-1","name":"Release","description":"","version":"opaque-version","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-01T00:00:00Z"}`, + }, + { + name: "description", + args: map[string]any{"owner": "owner", "repo": "repo", "name": "Release", "description": "Release automations"}, + wantRequestBody: `{"name":"Release","description":"Release automations"}`, + status: http.StatusCreated, + body: `{"id":"tag-1","name":"Release","description":"Release automations","version":"v","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-01T00:00:00Z"}`, + want: `{"id":"tag-1","name":"Release","description":"Release automations","version":"v","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-01T00:00:00Z"}`, + }, + { + name: "escaped path segments", + args: map[string]any{"owner": "own er", "repo": "re/po", "name": "Release"}, + escapedPath: "/agents/repos/own%20er/re%2Fpo/automation-tags", + status: http.StatusCreated, + body: `{"id":"tag-1","name":"Release","description":"","version":"v","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-01T00:00:00Z"}`, + want: `{"id":"tag-1","name":"Release","description":"","version":"v","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-01T00:00:00Z"}`, + }, + { + name: "blank name", + args: map[string]any{"owner": "owner", "repo": "repo", "name": " "}, + wantRequestBody: `{"name":" "}`, + status: http.StatusUnprocessableEntity, + body: `{"message":"Validation Failed","errors":[{"code":"invalid"}],"documentation_url":"https://docs.github.com/rest"}`, + wantError: "Validation Failed", + }, + {name: "conflict", status: http.StatusConflict, body: `{"message":"Conflict","code":"automation_tag_deleted","documentation_url":"https://docs.github.com/rest"}`, wantError: "409"}, + {name: "non-disclosure", status: http.StatusNotFound, body: `{"message":"not found","documentation_url":"https://docs.github.com/rest"}`, wantError: "404"}, + {name: "empty denial", status: http.StatusNotFound, wantError: "404"}, + {name: "bad request", status: http.StatusBadRequest, body: `{"message":"Bad Request"}`, wantError: "400"}, + {name: "permission denied", status: http.StatusForbidden, body: `{"message":"Forbidden"}`, wantError: "403"}, + {name: "unauthenticated", status: http.StatusUnauthorized, body: `{"message":"Requires authentication"}`, wantError: "401"}, + {name: "temporarily unavailable", status: http.StatusServiceUnavailable, body: `{"message":"automation tagging is unavailable","documentation_url":"https://docs.github.com/rest"}`, wantError: "503"}, + {name: "server error", status: http.StatusInternalServerError, body: `{"message":"Internal Server Error"}`, wantError: "500"}, + {name: "unexpected success status", status: http.StatusOK, body: `{"id":"tag-1","name":"Release","description":"","version":"v","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-01T00:00:00Z"}`, wantError: "200"}, + {name: "missing name", args: map[string]any{"owner": "owner", "repo": "repo"}, wantError: `missing properties: ["name"]`}, + {name: "dot owner", args: map[string]any{"owner": ".", "repo": "repo", "name": "Release"}, wantError: `owner must not be "."`}, + {name: "dot repo", args: map[string]any{"owner": "owner", "repo": ".", "name": "Release"}, wantError: `repo must not be "."`}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + client := mustNewGHClient(t, MockHTTPClientWithHandler(func(w http.ResponseWriter, r *http.Request) { + calls++ + if tc.status == 0 { + w.WriteHeader(http.StatusCreated) + _, err := fmt.Fprint(w, `{}`) + require.NoError(t, err) + return + } + escapedPath := tc.escapedPath + if escapedPath == "" { + escapedPath = "/agents/repos/owner/repo/automation-tags" + } + assert.Equal(t, "api.github.com", r.URL.Host) + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, escapedPath, r.URL.EscapedPath()) + assert.Empty(t, r.URL.RawQuery) + var requestBody map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody)) + idempotencyKey, _ := requestBody["idempotency_key"].(string) + assert.NotEmpty(t, idempotencyKey) + assert.False(t, seen[idempotencyKey], "reused idempotency key %q", idempotencyKey) + seen[idempotencyKey] = true + delete(requestBody, "idempotency_key") + wantRequestBody := tc.wantRequestBody + if wantRequestBody == "" { + wantRequestBody = `{"name":"Release"}` + } + encoded, err := json.Marshal(requestBody) + require.NoError(t, err) + assert.JSONEq(t, wantRequestBody, string(encoded)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + _, err = fmt.Fprint(w, tc.body) + require.NoError(t, err) + })) + deps := BaseDeps{Client: client} + inv, err := NewInventory(translations.NullTranslationHelper). + WithToolsets([]string{"copilot_automations"}).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "automation-tags", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + session := connectCommentVisibilityClient(t, server, protocol) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + byName := map[string]*mcp.Tool{} + for _, tool := range list.Tools { + byName[tool.Name] = tool + } + require.Contains(t, byName, "create_automation_tag") + args := tc.args + if args == nil { + args = map[string]any{"owner": "owner", "repo": "repo", "name": "Release"} + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "create_automation_tag", Arguments: args, + }) + require.NoError(t, err) + if tc.status == 0 { + assert.Zero(t, calls) + } else { + assert.Equal(t, 1, calls) + } + if tc.wantError != "" { + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, tc.wantError) + assert.Nil(t, result.StructuredContent) + return + } + require.False(t, result.IsError) + assert.JSONEq(t, tc.want, getTextResult(t, result).Text) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + require.NotNil(t, byName["create_automation_tag"].OutputSchema) + encoded, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + assert.JSONEq(t, tc.want, string(encoded)) + } else { + assert.Nil(t, result.StructuredContent) + } + }) + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index a6acb1a8ff..bf9d265db9 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -301,6 +301,7 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent RequestCopilotReview(t), ListAutomationTags(t), GetAutomationTag(t), + CreateAutomationTag(t), // Copilot issue intents (non-default, opt-in) AssignCopilotToIssueWithIntent(t), From f306e1eec661885febd143d49ddc0b9313f5498b Mon Sep 17 00:00:00 2001 From: notapopsicle Date: Fri, 23 Oct 2026 15:48:43 -0400 Subject: [PATCH 2/3] Reject empty owner and repo in direct create handler calls Calls through ServerTool.Handler skip input schema validation, so check owner and repo before sending the request. --- pkg/github/automation_tags.go | 6 ++++++ pkg/github/automation_tags_test.go | 31 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/pkg/github/automation_tags.go b/pkg/github/automation_tags.go index 0235120603..d755678ed0 100644 --- a/pkg/github/automation_tags.go +++ b/pkg/github/automation_tags.go @@ -215,6 +215,12 @@ func CreateAutomationTag(t translations.TranslationHelperFunc) inventory.ServerT }, scopes.RequireAll(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input CreateAutomationTagInput) (*mcp.CallToolResult, *AutomationTag, error) { + if input.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil + } + if input.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil + } if input.Owner == "." { return utils.NewToolResultError(`owner must not be "."`), nil, nil } diff --git a/pkg/github/automation_tags_test.go b/pkg/github/automation_tags_test.go index 36e4de957b..613355705b 100644 --- a/pkg/github/automation_tags_test.go +++ b/pkg/github/automation_tags_test.go @@ -419,6 +419,37 @@ func Test_CreateAutomationTag(t *testing.T) { require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) assert.False(t, toolDef.Tool.Annotations.ReadOnlyHint) + t.Run("requires owner and repo", func(t *testing.T) { + for _, tc := range []struct { + name string + args map[string]any + wantError string + }{ + {"missing owner", map[string]any{"repo": "repo", "name": "Release"}, "owner"}, + {"empty owner", map[string]any{"owner": "", "repo": "repo", "name": "Release"}, "owner"}, + {"missing repo", map[string]any{"owner": "owner", "name": "Release"}, "repo"}, + {"empty repo", map[string]any{"owner": "owner", "repo": "", "name": "Release"}, "repo"}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + client := mustNewGHClient(t, MockHTTPClientWithHandler(func(w http.ResponseWriter, _ *http.Request) { + calls++ + w.WriteHeader(http.StatusCreated) + _, err := fmt.Fprint(w, `{}`) + require.NoError(t, err) + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(tc.args) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.Zero(t, calls) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, tc.wantError) + }) + } + }) + t.Run("discovery", func(t *testing.T) { for _, tc := range []struct { name string From 8b003aeb524e7c7ddeb30d9e5ac4c625924b74be Mon Sep 17 00:00:00 2001 From: notapopsicle Date: Fri, 23 Oct 2026 16:45:22 -0400 Subject: [PATCH 3/3] Sanitize the name and description in create results Apply the same filters as list and get results to the created tag. --- pkg/github/automation_tags.go | 1 + pkg/github/automation_tags_test.go | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/pkg/github/automation_tags.go b/pkg/github/automation_tags.go index d755678ed0..1035296a6b 100644 --- a/pkg/github/automation_tags.go +++ b/pkg/github/automation_tags.go @@ -253,6 +253,7 @@ func CreateAutomationTag(t translations.TranslationHelperFunc) inventory.ServerT if resp.StatusCode != http.StatusCreated { return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create automation tag", resp, nil), nil, nil } + sanitizeAutomationTagNameAndDescription(&output) return MarshalledTextResult(&output), &output, nil }, ) diff --git a/pkg/github/automation_tags_test.go b/pkg/github/automation_tags_test.go index 613355705b..ee766b2cc1 100644 --- a/pkg/github/automation_tags_test.go +++ b/pkg/github/automation_tags_test.go @@ -513,6 +513,12 @@ func Test_CreateAutomationTag(t *testing.T) { body: `{"id":"tag-1","name":"Release","description":"","version":"v","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-01T00:00:00Z"}`, want: `{"id":"tag-1","name":"Release","description":"","version":"v","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-01T00:00:00Z"}`, }, + { + name: "sanitized name and description", + status: http.StatusCreated, + body: `{"id":"tag-1","name":"Hello\u200bWorld","description":"Hello\u200bWorld","version":"v1","created_at":"","updated_at":""}`, + want: `{"id":"tag-1","name":"HelloWorld","description":"HelloWorld","version":"v1","created_at":"","updated_at":""}`, + }, { name: "blank name", args: map[string]any{"owner": "owner", "repo": "repo", "name": " "},