Skip to content

Commit 65d9de7

Browse files
committed
add create automation tag tool
1 parent 25ca45b commit 65d9de7

5 files changed

Lines changed: 293 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,13 @@ For negotiated supported protocol versions `2026-07-28` or newer, the consolidat
779779

780780
<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>
781781

782+
- **create_automation_tag** - Create automation tag
783+
- **OAuth Challenge Scopes**: `repo`
784+
- `description`: Description of the new tag. Omit for an empty description. (string, optional)
785+
- `name`: Display name for the new tag (string, required)
786+
- `owner`: Repository owner (string, required)
787+
- `repo`: Repository name (string, required)
788+
782789
- **get_automation_tag** - Get automation tag
783790
- **OAuth Challenge Scopes**: `repo`
784791
- `owner`: Repository owner (string, required)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"annotations": {
3+
"destructiveHint": false,
4+
"idempotentHint": false,
5+
"openWorldHint": true,
6+
"readOnlyHint": false,
7+
"title": "Create automation tag"
8+
},
9+
"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.",
10+
"inputSchema": {
11+
"properties": {
12+
"description": {
13+
"description": "Description of the new tag. Omit for an empty description.",
14+
"type": "string"
15+
},
16+
"name": {
17+
"description": "Display name for the new tag",
18+
"type": "string"
19+
},
20+
"owner": {
21+
"description": "Repository owner",
22+
"minLength": 1,
23+
"type": "string"
24+
},
25+
"repo": {
26+
"description": "Repository name",
27+
"minLength": 1,
28+
"type": "string"
29+
}
30+
},
31+
"required": [
32+
"owner",
33+
"repo",
34+
"name"
35+
],
36+
"type": "object"
37+
},
38+
"name": "create_automation_tag"
39+
}

‎pkg/github/automation_tags.go‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package github
22

33
import (
44
"context"
5+
"crypto/rand"
56
"fmt"
67
"net/http"
78
"net/url"
@@ -155,3 +156,72 @@ func GetAutomationTag(t translations.TranslationHelperFunc) inventory.ServerTool
155156
},
156157
)
157158
}
159+
160+
type CreateAutomationTagInput struct {
161+
Owner string `json:"owner"`
162+
Repo string `json:"repo"`
163+
Name string `json:"name"`
164+
Description string `json:"description,omitempty"`
165+
}
166+
167+
func CreateAutomationTag(t translations.TranslationHelperFunc) inventory.ServerTool {
168+
return NewTool[CreateAutomationTagInput, *AutomationTag](
169+
ToolsetMetadataCopilotAutomations,
170+
mcp.Tool{
171+
Name: "create_automation_tag",
172+
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."),
173+
Annotations: &mcp.ToolAnnotations{
174+
Title: t("TOOL_CREATE_AUTOMATION_TAG_USER_TITLE", "Create automation tag"),
175+
ReadOnlyHint: false,
176+
DestructiveHint: new(false),
177+
OpenWorldHint: new(true),
178+
},
179+
InputSchema: &jsonschema.Schema{
180+
Type: "object",
181+
Properties: map[string]*jsonschema.Schema{
182+
"owner": {Type: "string", Description: "Repository owner", MinLength: new(1)},
183+
"repo": {Type: "string", Description: "Repository name", MinLength: new(1)},
184+
"name": {Type: "string", Description: "Display name for the new tag"},
185+
"description": {Type: "string", Description: "Description of the new tag. Omit for an empty description."},
186+
},
187+
Required: []string{"owner", "repo", "name"},
188+
},
189+
},
190+
scopes.RequireAll(scopes.Repo),
191+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input CreateAutomationTagInput) (*mcp.CallToolResult, *AutomationTag, error) {
192+
if input.Owner == "." {
193+
return utils.NewToolResultError(`owner must not be "."`), nil, nil
194+
}
195+
if input.Repo == "." {
196+
return utils.NewToolResultError(`repo must not be "."`), nil, nil
197+
}
198+
client, err := deps.GetClient(ctx)
199+
if err != nil {
200+
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
201+
}
202+
// A reused key returns the tag it first created without comparing name or
203+
// description, so each call sends a new key.
204+
body := map[string]any{"name": input.Name, "idempotency_key": rand.Text()}
205+
if input.Description != "" {
206+
body["description"] = input.Description
207+
}
208+
apiURL := fmt.Sprintf("agents/repos/%s/%s/automation-tags", url.PathEscape(input.Owner), url.PathEscape(input.Repo))
209+
req, err := client.NewRequest(ctx, http.MethodPost, apiURL, body)
210+
if err != nil {
211+
return utils.NewToolResultErrorFromErr("failed to create automation tag request", err), nil, nil
212+
}
213+
var output AutomationTag
214+
resp, err := client.Do(req, &output)
215+
if resp != nil {
216+
defer func() { _ = resp.Body.Close() }()
217+
}
218+
if err != nil {
219+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create automation tag", resp, err), nil, nil
220+
}
221+
if resp.StatusCode != http.StatusCreated {
222+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create automation tag", resp, nil), nil, nil
223+
}
224+
return MarshalledTextResult(&output), &output, nil
225+
},
226+
)
227+
}

‎pkg/github/automation_tags_test.go‎

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,3 +313,179 @@ func Test_GetAutomationTag(t *testing.T) {
313313
})
314314
}
315315
}
316+
317+
func Test_CreateAutomationTag(t *testing.T) {
318+
toolDef := CreateAutomationTag(translations.NullTranslationHelper)
319+
require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool))
320+
assert.False(t, toolDef.Tool.Annotations.ReadOnlyHint)
321+
322+
t.Run("discovery", func(t *testing.T) {
323+
for _, tc := range []struct {
324+
name string
325+
toolsets []string
326+
scopes []string
327+
readOnly bool
328+
want bool
329+
}{
330+
{"default", []string{"default"}, []string{"repo"}, false, false},
331+
{"opt in", []string{"copilot_automations"}, []string{"repo"}, false, true},
332+
{"opt in read only", []string{"copilot_automations"}, []string{"repo"}, true, false},
333+
{"missing scope", []string{"copilot_automations"}, []string{}, false, false},
334+
} {
335+
t.Run(tc.name, func(t *testing.T) {
336+
inv, err := NewInventory(translations.NullTranslationHelper).
337+
WithToolsets(tc.toolsets).WithReadOnly(tc.readOnly).
338+
WithFilter(CreateToolScopeFilter(tc.scopes)).Build()
339+
require.NoError(t, err)
340+
require.Empty(t, inv.UnrecognizedToolsets())
341+
var found bool
342+
for _, tool := range inv.AvailableTools(context.Background()) {
343+
found = found || tool.Tool.Name == "create_automation_tag"
344+
}
345+
assert.Equal(t, tc.want, found)
346+
})
347+
}
348+
})
349+
350+
seen := make(map[string]bool)
351+
for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25"} {
352+
t.Run(protocol, func(t *testing.T) {
353+
for _, tc := range []struct {
354+
name string
355+
args map[string]any
356+
status int
357+
body string
358+
escapedPath string
359+
wantRequestBody string
360+
want string
361+
wantError string
362+
}{
363+
{
364+
name: "internal and additive fields",
365+
status: http.StatusCreated,
366+
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}`,
367+
want: `{"id":"tag-1","name":"Release","description":"","version":"opaque-version","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-01T00:00:00Z"}`,
368+
},
369+
{
370+
name: "description",
371+
args: map[string]any{"owner": "owner", "repo": "repo", "name": "Release", "description": "Release automations"},
372+
wantRequestBody: `{"name":"Release","description":"Release automations"}`,
373+
status: http.StatusCreated,
374+
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"}`,
375+
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"}`,
376+
},
377+
{
378+
name: "escaped path segments",
379+
args: map[string]any{"owner": "own er", "repo": "re/po", "name": "Release"},
380+
escapedPath: "/agents/repos/own%20er/re%2Fpo/automation-tags",
381+
status: http.StatusCreated,
382+
body: `{"id":"tag-1","name":"Release","description":"","version":"v","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-01T00:00:00Z"}`,
383+
want: `{"id":"tag-1","name":"Release","description":"","version":"v","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-01T00:00:00Z"}`,
384+
},
385+
{
386+
name: "blank name",
387+
args: map[string]any{"owner": "owner", "repo": "repo", "name": " "},
388+
wantRequestBody: `{"name":" "}`,
389+
status: http.StatusUnprocessableEntity,
390+
body: `{"message":"Validation Failed","errors":[{"code":"invalid"}],"documentation_url":"https://docs.github.com/rest"}`,
391+
wantError: "Validation Failed",
392+
},
393+
{name: "conflict", status: http.StatusConflict, body: `{"message":"Conflict","code":"automation_tag_deleted","documentation_url":"https://docs.github.com/rest"}`, wantError: "409"},
394+
{name: "non-disclosure", status: http.StatusNotFound, body: `{"message":"not found","documentation_url":"https://docs.github.com/rest"}`, wantError: "404"},
395+
{name: "empty denial", status: http.StatusNotFound, wantError: "404"},
396+
{name: "bad request", status: http.StatusBadRequest, body: `{"message":"Bad Request"}`, wantError: "400"},
397+
{name: "permission denied", status: http.StatusForbidden, body: `{"message":"Forbidden"}`, wantError: "403"},
398+
{name: "unauthenticated", status: http.StatusUnauthorized, body: `{"message":"Requires authentication"}`, wantError: "401"},
399+
{name: "temporarily unavailable", status: http.StatusServiceUnavailable, body: `{"message":"automation tagging is unavailable","documentation_url":"https://docs.github.com/rest"}`, wantError: "503"},
400+
{name: "server error", status: http.StatusInternalServerError, body: `{"message":"Internal Server Error"}`, wantError: "500"},
401+
{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"},
402+
{name: "missing name", args: map[string]any{"owner": "owner", "repo": "repo"}, wantError: `missing properties: ["name"]`},
403+
{name: "dot owner", args: map[string]any{"owner": ".", "repo": "repo", "name": "Release"}, wantError: `owner must not be "."`},
404+
{name: "dot repo", args: map[string]any{"owner": "owner", "repo": ".", "name": "Release"}, wantError: `repo must not be "."`},
405+
} {
406+
t.Run(tc.name, func(t *testing.T) {
407+
calls := 0
408+
client := mustNewGHClient(t, MockHTTPClientWithHandler(func(w http.ResponseWriter, r *http.Request) {
409+
calls++
410+
if tc.status == 0 {
411+
w.WriteHeader(http.StatusCreated)
412+
_, err := fmt.Fprint(w, `{}`)
413+
require.NoError(t, err)
414+
return
415+
}
416+
escapedPath := tc.escapedPath
417+
if escapedPath == "" {
418+
escapedPath = "/agents/repos/owner/repo/automation-tags"
419+
}
420+
assert.Equal(t, "api.github.com", r.URL.Host)
421+
assert.Equal(t, http.MethodPost, r.Method)
422+
assert.Equal(t, escapedPath, r.URL.EscapedPath())
423+
assert.Empty(t, r.URL.RawQuery)
424+
var requestBody map[string]any
425+
require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody))
426+
idempotencyKey, _ := requestBody["idempotency_key"].(string)
427+
assert.NotEmpty(t, idempotencyKey)
428+
assert.False(t, seen[idempotencyKey], "reused idempotency key %q", idempotencyKey)
429+
seen[idempotencyKey] = true
430+
delete(requestBody, "idempotency_key")
431+
wantRequestBody := tc.wantRequestBody
432+
if wantRequestBody == "" {
433+
wantRequestBody = `{"name":"Release"}`
434+
}
435+
encoded, err := json.Marshal(requestBody)
436+
require.NoError(t, err)
437+
assert.JSONEq(t, wantRequestBody, string(encoded))
438+
w.Header().Set("Content-Type", "application/json")
439+
w.WriteHeader(tc.status)
440+
_, err = fmt.Fprint(w, tc.body)
441+
require.NoError(t, err)
442+
}))
443+
deps := BaseDeps{Client: client}
444+
inv, err := NewInventory(translations.NullTranslationHelper).
445+
WithToolsets([]string{"copilot_automations"}).Build()
446+
require.NoError(t, err)
447+
server := mcp.NewServer(&mcp.Implementation{Name: "automation-tags", Version: "v1"}, nil)
448+
server.AddReceivingMiddleware(InjectDepsMiddleware(deps))
449+
inv.RegisterTools(context.Background(), server, deps)
450+
session := connectCommentVisibilityClient(t, server, protocol)
451+
list, err := session.ListTools(context.Background(), nil)
452+
require.NoError(t, err)
453+
byName := map[string]*mcp.Tool{}
454+
for _, tool := range list.Tools {
455+
byName[tool.Name] = tool
456+
}
457+
require.Contains(t, byName, "create_automation_tag")
458+
args := tc.args
459+
if args == nil {
460+
args = map[string]any{"owner": "owner", "repo": "repo", "name": "Release"}
461+
}
462+
result, err := session.CallTool(context.Background(), &mcp.CallToolParams{
463+
Name: "create_automation_tag", Arguments: args,
464+
})
465+
require.NoError(t, err)
466+
if tc.status == 0 {
467+
assert.Zero(t, calls)
468+
} else {
469+
assert.Equal(t, 1, calls)
470+
}
471+
if tc.wantError != "" {
472+
require.True(t, result.IsError)
473+
assert.Contains(t, getErrorResult(t, result).Text, tc.wantError)
474+
assert.Nil(t, result.StructuredContent)
475+
return
476+
}
477+
require.False(t, result.IsError)
478+
assert.JSONEq(t, tc.want, getTextResult(t, result).Text)
479+
if protocol == inventory.ProtocolVersionMultiRoundTrip {
480+
require.NotNil(t, byName["create_automation_tag"].OutputSchema)
481+
encoded, err := json.Marshal(result.StructuredContent)
482+
require.NoError(t, err)
483+
assert.JSONEq(t, tc.want, string(encoded))
484+
} else {
485+
assert.Nil(t, result.StructuredContent)
486+
}
487+
})
488+
}
489+
})
490+
}
491+
}

‎pkg/github/tools.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent
301301
RequestCopilotReview(t),
302302
ListAutomationTags(t),
303303
GetAutomationTag(t),
304+
CreateAutomationTag(t),
304305

305306
// Copilot issue intents (non-default, opt-in)
306307
AssignCopilotToIssueWithIntent(t),

0 commit comments

Comments
 (0)