Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changes/unreleased/Feature-20260825-165216.yaml
Original file line number Diff line number Diff line change
@@ -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
71 changes: 71 additions & 0 deletions team.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that this solution is very composable. If we want to extend what teams returns, we'll have to deprecate this and replace it with something else. I'll come up with a recommendation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that instead of doing ListTeamsIncludingProperties or hydrating properties per team, we can remove the properties opt out & hydrate them per page instead of per team.

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 {
Expand Down
75 changes: 75 additions & 0 deletions team_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Loading