@@ -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+ }
0 commit comments