Skip to content

Commit 25ca45b

Browse files
committed
add get automation tag tool
1 parent 6e7546a commit 25ca45b

5 files changed

Lines changed: 256 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,12 @@ 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+
- **get_automation_tag** - Get automation tag
783+
- **OAuth Challenge Scopes**: `repo`
784+
- `owner`: Repository owner (string, required)
785+
- `repo`: Repository name (string, required)
786+
- `tag_id`: Automation tag ID, as returned by list_automation_tags (string, required)
787+
782788
- **list_automation_tags** - List automation tags
783789
- **OAuth Challenge Scopes**: `repo`
784790
- `owner`: Repository owner (string, required)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"annotations": {
3+
"idempotentHint": false,
4+
"readOnlyHint": true,
5+
"title": "Get automation tag"
6+
},
7+
"description": "Get a caller-visible automation tag in a repository by ID. Tags organize automations; they do not execute them. A tag that is missing, deleted, or not visible to the caller returns a 404 error.",
8+
"inputSchema": {
9+
"properties": {
10+
"owner": {
11+
"description": "Repository owner",
12+
"minLength": 1,
13+
"type": "string"
14+
},
15+
"repo": {
16+
"description": "Repository name",
17+
"minLength": 1,
18+
"type": "string"
19+
},
20+
"tag_id": {
21+
"description": "Automation tag ID, as returned by list_automation_tags",
22+
"minLength": 1,
23+
"type": "string"
24+
}
25+
},
26+
"required": [
27+
"owner",
28+
"repo",
29+
"tag_id"
30+
],
31+
"type": "object"
32+
},
33+
"name": "get_automation_tag"
34+
}

‎pkg/github/automation_tags.go‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,68 @@ func ListAutomationTags(t translations.TranslationHelperFunc) inventory.ServerTo
9090
},
9191
)
9292
}
93+
94+
type GetAutomationTagInput struct {
95+
Owner string `json:"owner"`
96+
Repo string `json:"repo"`
97+
TagID string `json:"tag_id"`
98+
}
99+
100+
func GetAutomationTag(t translations.TranslationHelperFunc) inventory.ServerTool {
101+
return NewTool[GetAutomationTagInput, *AutomationTag](
102+
ToolsetMetadataCopilotAutomations,
103+
mcp.Tool{
104+
Name: "get_automation_tag",
105+
Description: t("TOOL_GET_AUTOMATION_TAG_DESCRIPTION", "Get a caller-visible automation tag in a repository by ID. Tags organize automations; they do not execute them. A tag that is missing, deleted, or not visible to the caller returns a 404 error."),
106+
Annotations: &mcp.ToolAnnotations{
107+
Title: t("TOOL_GET_AUTOMATION_TAG_USER_TITLE", "Get automation tag"),
108+
ReadOnlyHint: true,
109+
},
110+
InputSchema: &jsonschema.Schema{
111+
Type: "object",
112+
Properties: map[string]*jsonschema.Schema{
113+
"owner": {Type: "string", Description: "Repository owner", MinLength: new(1)},
114+
"repo": {Type: "string", Description: "Repository name", MinLength: new(1)},
115+
"tag_id": {Type: "string", Description: "Automation tag ID, as returned by list_automation_tags", MinLength: new(1)},
116+
},
117+
Required: []string{"owner", "repo", "tag_id"},
118+
},
119+
},
120+
scopes.RequireAll(scopes.Repo),
121+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input GetAutomationTagInput) (*mcp.CallToolResult, *AutomationTag, error) {
122+
if input.Owner == "." {
123+
return utils.NewToolResultError(`owner must not be "."`), nil, nil
124+
}
125+
if input.Repo == "." {
126+
return utils.NewToolResultError(`repo must not be "."`), nil, nil
127+
}
128+
// URL resolution removes a "." segment, which would send this request to
129+
// ".../automation-tags/". NewRequest already rejects "..".
130+
if input.TagID == "." {
131+
return utils.NewToolResultError(`tag_id must not be "."`), nil, nil
132+
}
133+
client, err := deps.GetClient(ctx)
134+
if err != nil {
135+
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
136+
}
137+
apiURL := fmt.Sprintf("agents/repos/%s/%s/automation-tags/%s",
138+
url.PathEscape(input.Owner), url.PathEscape(input.Repo), url.PathEscape(input.TagID))
139+
req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil)
140+
if err != nil {
141+
return utils.NewToolResultErrorFromErr("failed to create automation tag request", err), nil, nil
142+
}
143+
var output AutomationTag
144+
resp, err := client.Do(req, &output)
145+
if resp != nil {
146+
defer func() { _ = resp.Body.Close() }()
147+
}
148+
if err != nil {
149+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get automation tag", resp, err), nil, nil
150+
}
151+
if resp.StatusCode != http.StatusOK {
152+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get automation tag", resp, nil), nil, nil
153+
}
154+
return MarshalledTextResult(&output), &output, nil
155+
},
156+
)
157+
}

‎pkg/github/automation_tags_test.go‎

Lines changed: 150 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,11 @@ func Test_ListAutomationTags(t *testing.T) {
130130
session := connectCommentVisibilityClient(t, server, protocol)
131131
list, err := session.ListTools(context.Background(), nil)
132132
require.NoError(t, err)
133-
require.Len(t, list.Tools, 1)
134-
assert.Equal(t, "list_automation_tags", list.Tools[0].Name)
133+
byName := map[string]*mcp.Tool{}
134+
for _, tool := range list.Tools {
135+
byName[tool.Name] = tool
136+
}
137+
require.Contains(t, byName, "list_automation_tags")
135138
args := tc.args
136139
if args == nil {
137140
args = map[string]any{"owner": "owner", "repo": "repo"}
@@ -154,7 +157,151 @@ func Test_ListAutomationTags(t *testing.T) {
154157
require.False(t, result.IsError)
155158
assert.JSONEq(t, tc.want, getTextResult(t, result).Text)
156159
if protocol == inventory.ProtocolVersionMultiRoundTrip {
157-
require.NotNil(t, list.Tools[0].OutputSchema)
160+
require.NotNil(t, byName["list_automation_tags"].OutputSchema)
161+
encoded, err := json.Marshal(result.StructuredContent)
162+
require.NoError(t, err)
163+
assert.JSONEq(t, tc.want, string(encoded))
164+
} else {
165+
assert.Nil(t, result.StructuredContent)
166+
}
167+
})
168+
}
169+
})
170+
}
171+
}
172+
173+
func Test_GetAutomationTag(t *testing.T) {
174+
toolDef := GetAutomationTag(translations.NullTranslationHelper)
175+
require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool))
176+
assert.True(t, toolDef.Tool.Annotations.ReadOnlyHint)
177+
178+
t.Run("discovery", func(t *testing.T) {
179+
for _, tc := range []struct {
180+
name string
181+
toolsets []string
182+
scopes []string
183+
want bool
184+
}{
185+
{"default", []string{"default"}, []string{"repo"}, false},
186+
{"opt in read only", []string{"copilot_automations"}, []string{"repo"}, true},
187+
{"missing scope", []string{"copilot_automations"}, []string{}, false},
188+
} {
189+
t.Run(tc.name, func(t *testing.T) {
190+
inv, err := NewInventory(translations.NullTranslationHelper).
191+
WithToolsets(tc.toolsets).WithReadOnly(true).
192+
WithFilter(CreateToolScopeFilter(tc.scopes)).Build()
193+
require.NoError(t, err)
194+
require.Empty(t, inv.UnrecognizedToolsets())
195+
var found bool
196+
for _, tool := range inv.AvailableTools(context.Background()) {
197+
found = found || tool.Tool.Name == "get_automation_tag"
198+
}
199+
assert.Equal(t, tc.want, found)
200+
})
201+
}
202+
})
203+
204+
for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25"} {
205+
t.Run(protocol, func(t *testing.T) {
206+
for _, tc := range []struct {
207+
name string
208+
args map[string]any
209+
status int
210+
body string
211+
escapedPath string
212+
want string
213+
wantError string
214+
}{
215+
{
216+
name: "internal and additive fields",
217+
status: http.StatusOK,
218+
body: `{"id":"tag-1","name":"Release","description":"","version":"opaque-version","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-02T00:00:00Z","deleted_at":"2026-10-03T00:00:00Z","can_delete":true,"deletion_pending":false,"has_known_unavailable_members":false,"future_field":true}`,
219+
want: `{"id":"tag-1","name":"Release","description":"","version":"opaque-version","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-02T00:00:00Z"}`,
220+
},
221+
{
222+
name: "escaped path segments",
223+
args: map[string]any{"owner": "own er", "repo": "re/po", "tag_id": "a/b c"},
224+
escapedPath: "/agents/repos/own%20er/re%2Fpo/automation-tags/a%2Fb%20c",
225+
status: http.StatusOK,
226+
body: `{"id":"a/b c","name":"Release","description":"","version":"v","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-02T00:00:00Z"}`,
227+
want: `{"id":"a/b c","name":"Release","description":"","version":"v","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-02T00:00:00Z"}`,
228+
},
229+
{name: "non-disclosure", status: http.StatusNotFound, body: `{"message":"not found","documentation_url":"https://docs.github.com/rest"}`, wantError: "404"},
230+
{name: "empty denial", status: http.StatusNotFound, wantError: "404"},
231+
{name: "bad request", status: http.StatusBadRequest, body: `{"message":"Bad Request"}`, wantError: "400"},
232+
{name: "permission denied", status: http.StatusForbidden, body: `{"message":"Forbidden"}`, wantError: "403"},
233+
{name: "unauthenticated", status: http.StatusUnauthorized, body: `{"message":"Requires authentication"}`, wantError: "401"},
234+
{name: "conflict", status: http.StatusConflict, body: `{"message":"Conflict"}`, wantError: "409"},
235+
{name: "temporarily unavailable", status: http.StatusServiceUnavailable, body: `{"message":"automation tagging is unavailable","documentation_url":"https://docs.github.com/rest"}`, wantError: "503"},
236+
{name: "server error", status: http.StatusInternalServerError, body: `{"message":"Internal Server Error"}`, wantError: "500"},
237+
{name: "validation", status: http.StatusUnprocessableEntity, body: `{"message":"Validation Failed"}`, wantError: "Validation Failed"},
238+
{name: "unexpected success status", status: http.StatusNoContent, wantError: "204"},
239+
{name: "missing tag id", args: map[string]any{"owner": "owner", "repo": "repo"}, wantError: "tag_id"},
240+
{name: "empty tag id", args: map[string]any{"owner": "owner", "repo": "repo", "tag_id": ""}, wantError: "tag_id"},
241+
{name: "dot tag id", args: map[string]any{"owner": "owner", "repo": "repo", "tag_id": "."}, wantError: `tag_id must not be "."`},
242+
{name: "dot owner", args: map[string]any{"owner": ".", "repo": "repo", "tag_id": "tag-1"}, wantError: `owner must not be "."`},
243+
{name: "dot repo", args: map[string]any{"owner": "owner", "repo": ".", "tag_id": "tag-1"}, wantError: `repo must not be "."`},
244+
} {
245+
t.Run(tc.name, func(t *testing.T) {
246+
calls := 0
247+
client := mustNewGHClient(t, MockHTTPClientWithHandler(func(w http.ResponseWriter, r *http.Request) {
248+
calls++
249+
if tc.status == 0 {
250+
_, err := fmt.Fprint(w, `{}`)
251+
require.NoError(t, err)
252+
return
253+
}
254+
escapedPath := tc.escapedPath
255+
if escapedPath == "" {
256+
escapedPath = "/agents/repos/owner/repo/automation-tags/tag-1"
257+
}
258+
assert.Equal(t, "api.github.com", r.URL.Host)
259+
assert.Equal(t, http.MethodGet, r.Method)
260+
assert.Equal(t, escapedPath, r.URL.EscapedPath())
261+
assert.Empty(t, r.URL.RawQuery)
262+
w.Header().Set("Content-Type", "application/json")
263+
w.WriteHeader(tc.status)
264+
_, err := fmt.Fprint(w, tc.body)
265+
require.NoError(t, err)
266+
}))
267+
deps := BaseDeps{Client: client}
268+
inv, err := NewInventory(translations.NullTranslationHelper).
269+
WithToolsets([]string{"copilot_automations"}).WithReadOnly(true).Build()
270+
require.NoError(t, err)
271+
server := mcp.NewServer(&mcp.Implementation{Name: "automation-tags", Version: "v1"}, nil)
272+
server.AddReceivingMiddleware(InjectDepsMiddleware(deps))
273+
inv.RegisterTools(context.Background(), server, deps)
274+
session := connectCommentVisibilityClient(t, server, protocol)
275+
list, err := session.ListTools(context.Background(), nil)
276+
require.NoError(t, err)
277+
byName := map[string]*mcp.Tool{}
278+
for _, tool := range list.Tools {
279+
byName[tool.Name] = tool
280+
}
281+
require.Contains(t, byName, "get_automation_tag")
282+
args := tc.args
283+
if args == nil {
284+
args = map[string]any{"owner": "owner", "repo": "repo", "tag_id": "tag-1"}
285+
}
286+
result, err := session.CallTool(context.Background(), &mcp.CallToolParams{
287+
Name: "get_automation_tag", Arguments: args,
288+
})
289+
require.NoError(t, err)
290+
if tc.status == 0 {
291+
assert.Zero(t, calls)
292+
} else {
293+
assert.Equal(t, 1, calls)
294+
}
295+
if tc.wantError != "" {
296+
require.True(t, result.IsError)
297+
assert.Contains(t, getErrorResult(t, result).Text, tc.wantError)
298+
assert.Nil(t, result.StructuredContent)
299+
return
300+
}
301+
require.False(t, result.IsError)
302+
assert.JSONEq(t, tc.want, getTextResult(t, result).Text)
303+
if protocol == inventory.ProtocolVersionMultiRoundTrip {
304+
require.NotNil(t, byName["get_automation_tag"].OutputSchema)
158305
encoded, err := json.Marshal(result.StructuredContent)
159306
require.NoError(t, err)
160307
assert.JSONEq(t, tc.want, string(encoded))

‎pkg/github/tools.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent
300300
AssignCopilotToIssue(t),
301301
RequestCopilotReview(t),
302302
ListAutomationTags(t),
303+
GetAutomationTag(t),
303304

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

0 commit comments

Comments
 (0)