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)) +}