From 30217499344f1cc0c840d9969d32681db5ce509d Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Tue, 25 Aug 2026 17:45:54 +0530 Subject: [PATCH 1/3] Add ListTeamsIncludingProperties for batched team metadata reads Team.Properties is excluded from generated queries with `graphql:"-"`, so a caller needing properties for many teams pays one extra request per team. Add a TeamWithProperties type that embeds Team and redeclares Properties with a graphql tag, selecting the connection inline, plus ListTeamsIncludingProperties which returns every team with its tags and properties at one request per page. Tags and memberships are completed by the existing Team.Hydrate; a team holding more than 100 properties is topped up on its own rather than charging every team an extra request. Purely additive. Team.Properties, ListTeams, and Hydrate are untouched, so existing callers execute identical code paths. --- .../unreleased/Feature-20260825-165216.yaml | 4 + team.go | 71 ++++++++++++++++++ team_test.go | 75 +++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 .changes/unreleased/Feature-20260825-165216.yaml diff --git a/.changes/unreleased/Feature-20260825-165216.yaml b/.changes/unreleased/Feature-20260825-165216.yaml new file mode 100644 index 00000000..5603de02 --- /dev/null +++ b/.changes/unreleased/Feature-20260825-165216.yaml @@ -0,0 +1,4 @@ +kind: Feature +body: Add ListTeamsIncludingProperties, which returns teams with their tags and custom + properties loaded in one request per page rather than one request per team. +time: 2026-08-25T16:52:16+05:30 diff --git a/team.go b/team.go index c9cf7d3c..02555f58 100644 --- a/team.go +++ b/team.go @@ -409,6 +409,77 @@ func (client *Client) ListTeams(variables *PayloadVariables) (*TeamConnection, e return &q.Account.Teams, nil } +// TeamWithProperties is a Team with its custom properties already loaded. +// +// Team.Properties is excluded from generated queries with `graphql:"-"`, so callers that +// need properties for many teams would otherwise pay one extra request per team. +// Embedding Team and redeclaring the field selects the connection inline instead. +type TeamWithProperties struct { + Team + Properties PropertiesConnection `graphql:"properties"` +} + +// ListTeamsIncludingProperties returns every team with its tags and properties populated, +// costing one request per page of teams rather than one request per team. +// +// The inlined properties connection is selected without pagination arguments, so the API +// returns its first 100 entries; any team holding more than that is topped up on its own. +func (client *Client) ListTeamsIncludingProperties(variables *PayloadVariables) ([]TeamWithProperties, error) { + if variables == nil { + variables = client.InitialPageVariablesPointer() + } + + teams := make([]TeamWithProperties, 0) + for { + var q struct { + Account struct { + Teams struct { + Nodes []TeamWithProperties + PageInfo PageInfo + } `graphql:"teams(after: $after, first: $first)"` + } + } + if err := client.Query(&q, *variables, WithName("TeamListIncludingProperties")); err != nil { + return nil, err + } + teams = append(teams, q.Account.Teams.Nodes...) + if !q.Account.Teams.PageInfo.HasNextPage { + break + } + (*variables)["after"] = q.Account.Teams.PageInfo.End + } + + for i := range teams { + // Hydrate covers the tags and memberships that came back inline; it issues no + // request unless one of those connections actually spilled past its first page. + if err := teams[i].Hydrate(client); err != nil { + return nil, err + } + if err := teams[i].hydrateProperties(client); err != nil { + return nil, err + } + } + return teams, nil +} + +// hydrateProperties collects any pages of properties beyond the first that the list query +// already returned. It issues no request for a team holding 100 properties or fewer. +func (team *TeamWithProperties) hydrateProperties(client *Client) error { + // Point the embedded Team at the inlined connection so both views agree, and so + // GetProperties appends later pages onto the nodes already returned. + team.Team.Properties = &team.Properties + + if !team.Properties.PageInfo.HasNextPage { + team.Properties.TotalCount = len(team.Properties.Nodes) + return nil + } + + variables := client.InitialPageVariablesPointer() + (*variables)["after"] = team.Properties.PageInfo.End + _, err := team.GetProperties(client, variables) + return err +} + func (client *Client) ListTeamsWithManager(email string, variables *PayloadVariables) (*TeamConnection, error) { var q struct { Account struct { diff --git a/team_test.go b/team_test.go index cd7299f4..1847542c 100644 --- a/team_test.go +++ b/team_test.go @@ -1024,3 +1024,78 @@ func TestSearchTeams(t *testing.T) { autopilot.Equals(t, "DevOps", result[0].Name) autopilot.Equals(t, "Own Infra & Tools.", result[0].Responsibilities) } + +// ListTeamsIncludingProperties exists so that reading properties for many teams costs one +// request per page instead of one per team. Registering a single request is what proves +// that: the harness fails on any request it was not told to expect, so a per-team +// GetProperties fan-out would surface here as an unregistered call. +func TestListTeamsIncludingProperties(t *testing.T) { + // Arrange + testRequest := autopilot.NewTestRequest( + `query TeamListIncludingProperties($after:String!$first:Int!){account{teams(after: $after, first: $first){nodes{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},{{ template "pagination_request" }}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},{{ template "pagination_request" }}},properties{nodes{definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value},{{ template "pagination_request" }}}},{{ template "pagination_request" }}}}}`, + `{{ template "pagination_initial_query_variables" }}`, + `{ "data": { + "account": { + "teams": { + "nodes": [ + { + "alias": "devops", + "aliases": [ "devops" ], + "contacts": [], + {{ template "id1" }}, + "name": "DevOps", + "responsibilities": "Own Infra & Tools.", + "tags": { + "nodes": [ {{ template "tag1" }}, {{ template "tag2" }} ], + {{ template "no_pagination_response" }} + }, + "properties": { + "nodes": [ {{ template "team_properties_page_1" }} ], + {{ template "no_pagination_response" }} + } + }, + { + "alias": "developers", + "aliases": [ "developers" ], + "contacts": [], + {{ template "id2" }}, + "name": "Developers", + "responsibilities": null, + "tags": { + "nodes": [ {{ template "tag3" }} ], + {{ template "no_pagination_response" }} + }, + "properties": { + "nodes": [], + {{ template "no_pagination_response" }} + } + } + ], + {{ template "no_pagination_response" }} + } + } + }}`, + ) + client := BestTestClient(t, "team/list_including_properties", testRequest) + + // Act + result, err := client.ListTeamsIncludingProperties(nil) + + // Assert + autopilot.Ok(t, err) + autopilot.Equals(t, 2, len(result)) + + autopilot.Equals(t, "devops", result[0].Alias) + autopilot.Equals(t, 2, len(result[0].Tags.Nodes)) + autopilot.Equals(t, "dev", result[0].Tags.Nodes[0].Key) + autopilot.Equals(t, 1, len(result[0].Properties.Nodes)) + autopilot.Equals(t, 1, result[0].Properties.TotalCount) + autopilot.Equals(t, "true", string(*result[0].Properties.Nodes[0].Value)) + + autopilot.Equals(t, "developers", result[1].Alias) + autopilot.Equals(t, 1, len(result[1].Tags.Nodes)) + autopilot.Equals(t, 0, len(result[1].Properties.Nodes)) + + // The embedded Team should expose the same properties the inlined field returned. + autopilot.Equals(t, 1, len(result[0].Team.Properties.Nodes)) +} From 4cc310eb6af9602d401e8bf7874e052fc8d78c31 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Tue, 1 Sep 2026 15:29:42 +0530 Subject: [PATCH 2/3] Return team properties inline instead of a dedicated list function Replaces ListTeamsIncludingProperties with the approach Andrew suggested in review: drop the properties opt-out so team queries select the connection directly, and hydrate each page rather than each team. Team.Properties loses `graphql:"-"`, so every team query returns properties with no extra request. Hydrate gains a properties block mirroring tags, which issues no request unless a team holds more than one page of them. ListTeams now hydrates the nodes on each page, matching ListUsers. It previously hydrated only nodes from its recursive call, so with a default page size of 500 most accounts hydrated nothing at all - properties would arrive with TotalCount unset and overflow never fetched. Removes TeamWithProperties, ListTeamsIncludingProperties and hydrateProperties. --- .../unreleased/Bugfix-20260831-232048.yaml | 5 + .../unreleased/Feature-20260825-165216.yaml | 4 - .../unreleased/Feature-20260831-232047.yaml | 5 + actions_test.go | 4 +- cache_test.go | 2 +- clientGQL_test.go | 2 +- team.go | 103 +++++------------- team_test.go | 77 +++++++------ testdata/templates/query/team/get.tpl | 31 ++++++ 9 files changed, 107 insertions(+), 126 deletions(-) create mode 100644 .changes/unreleased/Bugfix-20260831-232048.yaml delete mode 100644 .changes/unreleased/Feature-20260825-165216.yaml create mode 100644 .changes/unreleased/Feature-20260831-232047.yaml diff --git a/.changes/unreleased/Bugfix-20260831-232048.yaml b/.changes/unreleased/Bugfix-20260831-232048.yaml new file mode 100644 index 00000000..c550c534 --- /dev/null +++ b/.changes/unreleased/Bugfix-20260831-232048.yaml @@ -0,0 +1,5 @@ +kind: Bugfix +body: ListTeams now hydrates the teams on the first page. Previously only teams from + subsequent pages were hydrated, so accounts with a single page of teams had + Responsibilities left HTML-escaped and TotalCount left at zero. +time: 2026-08-31T23:20:48+05:30 diff --git a/.changes/unreleased/Feature-20260825-165216.yaml b/.changes/unreleased/Feature-20260825-165216.yaml deleted file mode 100644 index 5603de02..00000000 --- a/.changes/unreleased/Feature-20260825-165216.yaml +++ /dev/null @@ -1,4 +0,0 @@ -kind: Feature -body: Add ListTeamsIncludingProperties, which returns teams with their tags and custom - properties loaded in one request per page rather than one request per team. -time: 2026-08-25T16:52:16+05:30 diff --git a/.changes/unreleased/Feature-20260831-232047.yaml b/.changes/unreleased/Feature-20260831-232047.yaml new file mode 100644 index 00000000..ffe4c3cb --- /dev/null +++ b/.changes/unreleased/Feature-20260831-232047.yaml @@ -0,0 +1,5 @@ +kind: Feature +body: Team queries now return custom properties. Previously properties were excluded + from generated queries, so reading them for a list of teams cost one extra request + per team. +time: 2026-08-31T23:20:47+05:30 diff --git a/actions_test.go b/actions_test.go index 1b433a72..b33b509c 100644 --- a/actions_test.go +++ b/actions_test.go @@ -352,12 +352,12 @@ func TestDeleteTriggerDefinition(t *testing.T) { func TestListExtendedTeamAccess(t *testing.T) { // Arrange testRequestOne := autopilot.NewTestRequest( - `query ExtendedTeamAccessList($after:String!$first:Int!$input:IdentifierInput!){account{customActionsTriggerDefinition(input: $input){extendedTeamAccess(after: $after, first: $first){nodes{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},{{ template "pagination_request" }}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},{{ template "pagination_request" }}}},{{ template "pagination_request" }}}}}}`, + `query ExtendedTeamAccessList($after:String!$first:Int!$input:IdentifierInput!){account{customActionsTriggerDefinition(input: $input){extendedTeamAccess(after: $after, first: $first){nodes{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},{{ template "pagination_request" }}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},{{ template "pagination_request" }}},properties{nodes{definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value},{{ template "pagination_request" }}}},{{ template "pagination_request" }}}}}}`, `{{ template "extended_team_access_get_vars_1" }}`, `{{ template "extended_team_access_response_1" }}`, ) testRequestTwo := autopilot.NewTestRequest( - `query ExtendedTeamAccessList($after:String!$first:Int!$input:IdentifierInput!){account{customActionsTriggerDefinition(input: $input){extendedTeamAccess(after: $after, first: $first){nodes{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},{{ template "pagination_request" }}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},{{ template "pagination_request" }}}},{{ template "pagination_request" }}}}}}`, + `query ExtendedTeamAccessList($after:String!$first:Int!$input:IdentifierInput!){account{customActionsTriggerDefinition(input: $input){extendedTeamAccess(after: $after, first: $first){nodes{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},{{ template "pagination_request" }}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},{{ template "pagination_request" }}},properties{nodes{definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value},{{ template "pagination_request" }}}},{{ template "pagination_request" }}}}}}`, `{{ template "extended_team_access_get_vars_2" }}`, `{{ template "extended_team_access_response_2" }}`, ) diff --git a/cache_test.go b/cache_test.go index ef3158b1..38950613 100644 --- a/cache_test.go +++ b/cache_test.go @@ -25,7 +25,7 @@ func TestCache(t *testing.T) { `{"data":{"account":{ "systems":{ "nodes":[{{ template "system1_response" }}] } }}}`, ) testRequestFour := autopilot.NewTestRequest( - `query TeamList($after:String!$first:Int!){account{teams(after: $after, first: $first){nodes{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},{{ template "pagination_request" }}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},{{ template "pagination_request" }}}},{{ template "pagination_request" }}}}}`, + `query TeamList($after:String!$first:Int!){account{teams(after: $after, first: $first){nodes{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},{{ template "pagination_request" }}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},{{ template "pagination_request" }}},properties{nodes{definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value},{{ template "pagination_request" }}}},{{ template "pagination_request" }}}}}`, `{ "after": "", "first": 500 }`, `{"data":{"account":{ "teams":{ "nodes":[{{ template "team_1" }}] } }}}`, ) diff --git a/clientGQL_test.go b/clientGQL_test.go index 5437acd0..c8580506 100644 --- a/clientGQL_test.go +++ b/clientGQL_test.go @@ -210,7 +210,7 @@ func httpResponseByCode(statusCode int) autopilot.ResponseWriter { func TestMissingTeamIsAnOpsLevelApiError(t *testing.T) { testRequest := autopilot.NewTestRequest( - `query TeamGet($id:ID!){account{team(id: $id){alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},{{ template "pagination_request" }}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},{{ template "pagination_request" }}}}}}`, + `query TeamGet($id:ID!){account{team(id: $id){alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},{{ template "pagination_request" }}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},{{ template "pagination_request" }}},properties{nodes{definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value},{{ template "pagination_request" }}}}}}`, `{ {{ template "id1" }} }`, `{"errors": [ { diff --git a/team.go b/team.go index 02555f58..48d8f500 100644 --- a/team.go +++ b/team.go @@ -26,7 +26,7 @@ type Team struct { ParentTeam TeamId Responsibilities string Tags *TagConnection - Properties *PropertiesConnection `graphql:"-" json:"-"` + Properties *PropertiesConnection `json:"-"` } // TeamIdConnection exists to prevent circular references on User because Team has a UserConnection @@ -103,6 +103,20 @@ func (team *Team) Hydrate(client *Client) error { team.Tags = resp } team.Tags.TotalCount = len(team.Tags.Nodes) + + if team.Properties == nil { + team.Properties = &PropertiesConnection{} + } + if team.Properties.PageInfo.HasNextPage { + variables := client.InitialPageVariablesPointer() + (*variables)["after"] = team.Properties.PageInfo.End + // GetProperties appends onto team.Properties, so the pages already returned + // inline by the outer query are kept. + if _, err := team.GetProperties(client, variables); err != nil { + return err + } + } + team.Properties.TotalCount = len(team.Properties.Nodes) return nil } @@ -391,95 +405,28 @@ func (client *Client) ListTeams(variables *PayloadVariables) (*TeamConnection, e return nil, err } + // Hydrate inner connections for every team on this page. Without this, teams on + // the first outer page would never paginate their nested connections beyond the + // server's default page size. + for i := range q.Account.Teams.Nodes { + if err := q.Account.Teams.Nodes[i].Hydrate(client); err != nil { + return nil, err + } + } + if q.Account.Teams.PageInfo.HasNextPage { (*variables)["after"] = q.Account.Teams.PageInfo.End resp, err := client.ListTeams(variables) if err != nil { return nil, err } - for _, node := range resp.Nodes { - if err := node.Hydrate(client); err != nil { - return nil, err - } - q.Account.Teams.Nodes = append(q.Account.Teams.Nodes, node) - } + q.Account.Teams.Nodes = append(q.Account.Teams.Nodes, resp.Nodes...) q.Account.Teams.PageInfo = resp.PageInfo } q.Account.Teams.TotalCount = len(q.Account.Teams.Nodes) return &q.Account.Teams, nil } -// TeamWithProperties is a Team with its custom properties already loaded. -// -// Team.Properties is excluded from generated queries with `graphql:"-"`, so callers that -// need properties for many teams would otherwise pay one extra request per team. -// Embedding Team and redeclaring the field selects the connection inline instead. -type TeamWithProperties struct { - Team - Properties PropertiesConnection `graphql:"properties"` -} - -// ListTeamsIncludingProperties returns every team with its tags and properties populated, -// costing one request per page of teams rather than one request per team. -// -// The inlined properties connection is selected without pagination arguments, so the API -// returns its first 100 entries; any team holding more than that is topped up on its own. -func (client *Client) ListTeamsIncludingProperties(variables *PayloadVariables) ([]TeamWithProperties, error) { - if variables == nil { - variables = client.InitialPageVariablesPointer() - } - - teams := make([]TeamWithProperties, 0) - for { - var q struct { - Account struct { - Teams struct { - Nodes []TeamWithProperties - PageInfo PageInfo - } `graphql:"teams(after: $after, first: $first)"` - } - } - if err := client.Query(&q, *variables, WithName("TeamListIncludingProperties")); err != nil { - return nil, err - } - teams = append(teams, q.Account.Teams.Nodes...) - if !q.Account.Teams.PageInfo.HasNextPage { - break - } - (*variables)["after"] = q.Account.Teams.PageInfo.End - } - - for i := range teams { - // Hydrate covers the tags and memberships that came back inline; it issues no - // request unless one of those connections actually spilled past its first page. - if err := teams[i].Hydrate(client); err != nil { - return nil, err - } - if err := teams[i].hydrateProperties(client); err != nil { - return nil, err - } - } - return teams, nil -} - -// hydrateProperties collects any pages of properties beyond the first that the list query -// already returned. It issues no request for a team holding 100 properties or fewer. -func (team *TeamWithProperties) hydrateProperties(client *Client) error { - // Point the embedded Team at the inlined connection so both views agree, and so - // GetProperties appends later pages onto the nodes already returned. - team.Team.Properties = &team.Properties - - if !team.Properties.PageInfo.HasNextPage { - team.Properties.TotalCount = len(team.Properties.Nodes) - return nil - } - - variables := client.InitialPageVariablesPointer() - (*variables)["after"] = team.Properties.PageInfo.End - _, err := team.GetProperties(client, variables) - return err -} - func (client *Client) ListTeamsWithManager(email string, variables *PayloadVariables) (*TeamConnection, error) { var q struct { Account struct { diff --git a/team_test.go b/team_test.go index 1847542c..d5d3e6fb 100644 --- a/team_test.go +++ b/team_test.go @@ -165,7 +165,7 @@ func TestCreateTeam(t *testing.T) { ParentTeam: ol.NewIdentifier("parent_team"), }) testRequest := autopilot.NewTestRequest( - `mutation TeamCreate($input:TeamCreateInput!){teamCreate(input: $input){team{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}},errors{message,path}}}`, + `mutation TeamCreate($input:TeamCreateInput!){teamCreate(input: $input){team{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},properties{nodes{definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}},errors{message,path}}}`, `{"input": {{ template "team_create_input" }} }`, `{ "data": { "teamCreate": { @@ -213,7 +213,7 @@ func TestCreateTeam(t *testing.T) { func TestGetTeam(t *testing.T) { // Arrange testRequest := autopilot.NewTestRequest( - `query TeamGet($id:ID!){account{team(id: $id){alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},{{ template "pagination_request" }}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},{{ template "pagination_request" }}}}}}`, + `query TeamGet($id:ID!){account{team(id: $id){alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},{{ template "pagination_request" }}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},{{ template "pagination_request" }}},properties{nodes{definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value},{{ template "pagination_request" }}}}}}`, `{ {{ template "id1" }} }`, `{ "data": { "account": { @@ -363,7 +363,7 @@ func TestGetTeamWithAlias(t *testing.T) { func TestListTeams(t *testing.T) { // Arrange testRequestOne := autopilot.NewTestRequest( - `query TeamList($after:String!$first:Int!){account{teams(after: $after, first: $first){nodes{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}}}`, + `query TeamList($after:String!$first:Int!){account{teams(after: $after, first: $first){nodes{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},properties{nodes{definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}}}`, `{{ template "pagination_initial_query_variables" }}`, `{ "data": { "account": { @@ -434,7 +434,7 @@ func TestListTeams(t *testing.T) { }}}}`, ) testRequestTwo := autopilot.NewTestRequest( - `query TeamList($after:String!$first:Int!){account{teams(after: $after, first: $first){nodes{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}}}`, + `query TeamList($after:String!$first:Int!){account{teams(after: $after, first: $first){nodes{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},properties{nodes{definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}}}`, `{{ template "pagination_second_query_variables" }}`, `{ "data": { "account": { @@ -503,7 +503,7 @@ func TestListTeams(t *testing.T) { func TestListTeamsWithManager(t *testing.T) { // Arrange testRequestOne := autopilot.NewTestRequest( - `query TeamList($after:String!$email:String!$first:Int!){account{teams(managerEmail: $email, after: $after, first: $first){nodes{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}}}`, + `query TeamList($after:String!$email:String!$first:Int!){account{teams(managerEmail: $email, after: $after, first: $first){nodes{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},properties{nodes{definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}}}`, `{ "after": "", "first": 500, "email": "kyle@opslevel.com" }`, `{ "data": { "account": { @@ -574,7 +574,7 @@ func TestListTeamsWithManager(t *testing.T) { }}}}`, ) testRequestTwo := autopilot.NewTestRequest( - `query TeamList($after:String!$email:String!$first:Int!){account{teams(managerEmail: $email, after: $after, first: $first){nodes{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}}}`, + `query TeamList($after:String!$email:String!$first:Int!){account{teams(managerEmail: $email, after: $after, first: $first){nodes{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},properties{nodes{definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}}}`, `{ "after": "OA", "first": 500, "email": "kyle@opslevel.com" }`, `{ "data": { "account": { @@ -650,7 +650,7 @@ func TestUpdateTeam(t *testing.T) { }, ) testRequest := autopilot.NewTestRequest( - `mutation TeamUpdate($input:TeamUpdateInput!){teamUpdate(input: $input){team{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}},errors{message,path}}}`, + `mutation TeamUpdate($input:TeamUpdateInput!){teamUpdate(input: $input){team{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},properties{nodes{definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}},errors{message,path}}}`, `{"input": {{ template "team_update_input" }} }`, `{ "data": { "teamUpdate": { @@ -901,7 +901,7 @@ func TestTeamReconcileAliasesDeleteAll(t *testing.T) { ) // get team testRequestThree := autopilot.NewTestRequest( - `query TeamGet($id:ID!){account{team(id: $id){alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},{{ template "pagination_request" }}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},{{ template "pagination_request" }}}}}}`, + `query TeamGet($id:ID!){account{team(id: $id){alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},{{ template "pagination_request" }}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},{{ template "pagination_request" }}},properties{nodes{definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value},{{ template "pagination_request" }}}}}}`, `{ {{ template "id1" }} }`, `{ "data": { "account": { "team": { {{ template "id1" }}, "aliases": [], "managedAliases": [] }}}}`, ) @@ -936,7 +936,7 @@ func TestTeamReconcileAliasesDeleteSome(t *testing.T) { ) // get team testRequestTwo := autopilot.NewTestRequest( - `query TeamGet($id:ID!){account{team(id: $id){alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},{{ template "pagination_request" }}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},{{ template "pagination_request" }}}}}}`, + `query TeamGet($id:ID!){account{team(id: $id){alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},{{ template "pagination_request" }}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},{{ template "pagination_request" }}},properties{nodes{definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value},{{ template "pagination_request" }}}}}}`, `{ {{ template "id1" }} }`, `{ "data": { "account": { "team": { {{ template "id1" }}, "aliases": ["two"], "managedAliases": ["two"] }}}}`, ) @@ -989,7 +989,7 @@ func TestTeamReconcileAliases(t *testing.T) { ) // get team testRequestFive := autopilot.NewTestRequest( - `query TeamGet($id:ID!){account{team(id: $id){alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},{{ template "pagination_request" }}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},{{ template "pagination_request" }}}}}}`, + `query TeamGet($id:ID!){account{team(id: $id){alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},{{ template "pagination_request" }}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},{{ template "pagination_request" }}},properties{nodes{definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value},{{ template "pagination_request" }}}}}}`, `{ {{ template "id1" }} }`, `{ "data": { "account": { "team": { {{ template "id1" }}, "aliases": ["one", "two", "three"], "managedAliases": ["one", "two", "three"] }}}}`, ) @@ -1008,7 +1008,7 @@ func TestTeamReconcileAliases(t *testing.T) { func TestSearchTeams(t *testing.T) { // Arrange testRequest := autopilot.NewTestRequest( - `query TeamSearch($after:String!$first:Int!$searchTerm:String!){account{teams(searchTerm: $searchTerm, after: $after, first: $first){nodes{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}}}`, + `query TeamSearch($after:String!$first:Int!$searchTerm:String!){account{teams(searchTerm: $searchTerm, after: $after, first: $first){nodes{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},properties{nodes{definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}}}`, `{"searchTerm": "DevOps", "after": "", "first": 500}`, `{ "data": { "account": { "teams": { "nodes": [ { "alias": "devops", "id": "id1", "aliases": ["devops"], "managedAliases": ["devops"], "contacts": [], "htmlUrl": "https://app.opslevel.com/teams/devops", "manager": { "id": "user1", "email": "manager@opslevel.com", "name": "Manager" }, "memberships": { "nodes": [], "pageInfo": { "hasNextPage": false, "hasPreviousPage": false, "startCursor": null, "endCursor": null } }, "name": "DevOps", "parentTeam": null, "responsibilities": "Own Infra & Tools.", "tags": { "nodes": [], "pageInfo": { "hasNextPage": false, "hasPreviousPage": false, "startCursor": null, "endCursor": null } } } ], "pageInfo": { "hasNextPage": false, "hasPreviousPage": false, "startCursor": null, "endCursor": null } } } } }`, ) @@ -1025,14 +1025,17 @@ func TestSearchTeams(t *testing.T) { autopilot.Equals(t, "Own Infra & Tools.", result[0].Responsibilities) } -// ListTeamsIncludingProperties exists so that reading properties for many teams costs one -// request per page instead of one per team. Registering a single request is what proves -// that: the harness fails on any request it was not told to expect, so a per-team -// GetProperties fan-out would surface here as an unregistered call. -func TestListTeamsIncludingProperties(t *testing.T) { +// Properties used to be excluded from generated queries with `graphql:"-"`, forcing one +// GetProperties call per team. They now come back inline with the list query. Registering +// a single request is what proves that: the harness fails on any request it was not told +// to expect, so a per-team fan-out would surface as an unregistered call. +// +// This also covers first-page hydration - ListTeams previously hydrated only the nodes +// returned by its recursive call, so a single-page account got none at all. +func TestListTeamsIncludesPropertiesInOneRequest(t *testing.T) { // Arrange testRequest := autopilot.NewTestRequest( - `query TeamListIncludingProperties($after:String!$first:Int!){account{teams(after: $after, first: $first){nodes{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},{{ template "pagination_request" }}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},{{ template "pagination_request" }}},properties{nodes{definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value},{{ template "pagination_request" }}}},{{ template "pagination_request" }}}}}`, + `query TeamList($after:String!$first:Int!){account{teams(after: $after, first: $first){nodes{alias,id,aliases,managedAliases,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,manager{id,email,name,contacts{address,displayName,displayType,externalId,id,isDefault,type},htmlUrl,provisionedBy,role,tags{nodes{id,key,value},{{ template "pagination_request" }}},teams{nodes{alias,id},{{ template "pagination_request" }}}},memberships{nodes{role,team{alias,id},user{id,email,name}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},name,parentTeam{alias,id},responsibilities,tags{nodes{id,key,value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},properties{nodes{definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}}}`, `{{ template "pagination_initial_query_variables" }}`, `{ "data": { "account": { @@ -1044,7 +1047,7 @@ func TestListTeamsIncludingProperties(t *testing.T) { "contacts": [], {{ template "id1" }}, "name": "DevOps", - "responsibilities": "Own Infra & Tools.", + "responsibilities": "Infra & Tools", "tags": { "nodes": [ {{ template "tag1" }}, {{ template "tag2" }} ], {{ template "no_pagination_response" }} @@ -1061,14 +1064,8 @@ func TestListTeamsIncludingProperties(t *testing.T) { {{ template "id2" }}, "name": "Developers", "responsibilities": null, - "tags": { - "nodes": [ {{ template "tag3" }} ], - {{ template "no_pagination_response" }} - }, - "properties": { - "nodes": [], - {{ template "no_pagination_response" }} - } + "tags": { "nodes": [], {{ template "no_pagination_response" }} }, + "properties": { "nodes": [], {{ template "no_pagination_response" }} } } ], {{ template "no_pagination_response" }} @@ -1076,26 +1073,26 @@ func TestListTeamsIncludingProperties(t *testing.T) { } }}`, ) - client := BestTestClient(t, "team/list_including_properties", testRequest) + client := BestTestClient(t, "team/list_includes_properties", testRequest) // Act - result, err := client.ListTeamsIncludingProperties(nil) + result, err := client.ListTeams(nil) // Assert autopilot.Ok(t, err) - autopilot.Equals(t, 2, len(result)) + autopilot.Equals(t, 2, len(result.Nodes)) - autopilot.Equals(t, "devops", result[0].Alias) - autopilot.Equals(t, 2, len(result[0].Tags.Nodes)) - autopilot.Equals(t, "dev", result[0].Tags.Nodes[0].Key) - autopilot.Equals(t, 1, len(result[0].Properties.Nodes)) - autopilot.Equals(t, 1, result[0].Properties.TotalCount) - autopilot.Equals(t, "true", string(*result[0].Properties.Nodes[0].Value)) + autopilot.Equals(t, "devops", result.Nodes[0].Alias) + autopilot.Equals(t, 1, len(result.Nodes[0].Properties.Nodes)) + autopilot.Equals(t, "true", string(*result.Nodes[0].Properties.Nodes[0].Value)) - autopilot.Equals(t, "developers", result[1].Alias) - autopilot.Equals(t, 1, len(result[1].Tags.Nodes)) - autopilot.Equals(t, 0, len(result[1].Properties.Nodes)) + // Hydrate ran on the first page: TotalCount is computed client-side and + // Responsibilities is HTML-unescaped, neither of which happens without it. + autopilot.Equals(t, 1, result.Nodes[0].Properties.TotalCount) + autopilot.Equals(t, 2, result.Nodes[0].Tags.TotalCount) + autopilot.Equals(t, "Infra & Tools", result.Nodes[0].Responsibilities) - // The embedded Team should expose the same properties the inlined field returned. - autopilot.Equals(t, 1, len(result[0].Team.Properties.Nodes)) + // A team with no properties still returns an empty connection, never nil. + autopilot.Equals(t, 0, len(result.Nodes[1].Properties.Nodes)) + autopilot.Equals(t, 0, result.Nodes[1].Properties.TotalCount) } diff --git a/testdata/templates/query/team/get.tpl b/testdata/templates/query/team/get.tpl index 5ee7dd2f..e6d6eab9 100644 --- a/testdata/templates/query/team/get.tpl +++ b/testdata/templates/query/team/get.tpl @@ -94,6 +94,37 @@ startCursor, endCursor } + }, + properties{ + nodes{ + definition{ + id, + aliases + }, + locked, + owner{ + __typename, + ... on Team{ + alias, + id + }, + ... on Service{ + id, + aliases + } + }, + validationErrors{ + message, + path + }, + value + }, + pageInfo{ + hasNextPage, + hasPreviousPage, + startCursor, + endCursor + } } } {{- end }} \ No newline at end of file From dc15686aa7837e3a6f8e0b3292bb5d7994eda77f Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Fri, 4 Sep 2026 11:42:03 +0530 Subject: [PATCH 3/3] Apply review feedback: drop json:\"-\" and explanatory comments --- team.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/team.go b/team.go index 48d8f500..3a0d24b9 100644 --- a/team.go +++ b/team.go @@ -26,7 +26,7 @@ type Team struct { ParentTeam TeamId Responsibilities string Tags *TagConnection - Properties *PropertiesConnection `json:"-"` + Properties *PropertiesConnection } // TeamIdConnection exists to prevent circular references on User because Team has a UserConnection @@ -110,8 +110,6 @@ func (team *Team) Hydrate(client *Client) error { if team.Properties.PageInfo.HasNextPage { variables := client.InitialPageVariablesPointer() (*variables)["after"] = team.Properties.PageInfo.End - // GetProperties appends onto team.Properties, so the pages already returned - // inline by the outer query are kept. if _, err := team.GetProperties(client, variables); err != nil { return err } @@ -405,9 +403,6 @@ func (client *Client) ListTeams(variables *PayloadVariables) (*TeamConnection, e return nil, err } - // Hydrate inner connections for every team on this page. Without this, teams on - // the first outer page would never paginate their nested connections beyond the - // server's default page size. for i := range q.Account.Teams.Nodes { if err := q.Account.Teams.Nodes[i].Hydrate(client); err != nil { return nil, err