Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,13 @@ For negotiated supported protocol versions `2026-07-28` or newer, the consolidat

<summary><picture><source media="(prefers-color-scheme: dark)" srcset="pkg/octicons/icons/workflow-dark.png"><source media="(prefers-color-scheme: light)" srcset="pkg/octicons/icons/workflow-light.png"><img src="pkg/octicons/icons/workflow-light.png" width="20" height="20" alt="workflow"></picture> Copilot Automations</summary>

- **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)
Expand Down
39 changes: 39 additions & 0 deletions pkg/github/__toolsnaps__/create_automation_tag.snap
Original file line number Diff line number Diff line change
@@ -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"
}
77 changes: 77 additions & 0 deletions pkg/github/automation_tags.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package github

import (
"context"
"crypto/rand"
"fmt"
"net/http"
"net/url"
Expand Down Expand Up @@ -182,6 +183,82 @@ 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 {
Comment thread
notapopsicle marked this conversation as resolved.
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("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
}
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
}
sanitizeAutomationTagNameAndDescription(&output)
return MarshalledTextResult(&output), &output, nil
Comment thread
notapopsicle marked this conversation as resolved.
},
)
}

func sanitizeAutomationTagNameAndDescription(tag *AutomationTag) {
tag.Name = sanitize.PlainText(tag.Name)
tag.Description = sanitize.Content(tag.Description)
Expand Down
213 changes: 213 additions & 0 deletions pkg/github/automation_tags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -413,3 +413,216 @@ 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("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
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: "sanitized name and description",
status: http.StatusCreated,
body: `{"id":"tag-1","name":"<script>alert(1)</script>Hello\u200bWorld","description":"<script>alert(1)</script>Hello\u200bWorld","version":"v1","created_at":"","updated_at":""}`,
want: `{"id":"tag-1","name":"HelloWorld","description":"<script>alert(1)</script>HelloWorld","version":"v1","created_at":"","updated_at":""}`,
},
{
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)
}
})
}
})
}
}
1 change: 1 addition & 0 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading