From 30217499344f1cc0c840d9969d32681db5ce509d Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Tue, 25 Aug 2026 17:45:54 +0530 Subject: [PATCH] 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)) +}