From bb4637b435e83f3e45679aec40a574f343443c7d Mon Sep 17 00:00:00 2001 From: Andrew Still Date: Thu, 19 Feb 2026 16:25:57 -0500 Subject: [PATCH 01/12] abstract team & service owners on property --- owner.go | 16 ++++++++++++++++ property.go | 10 +++++----- property_test.go | 28 +++++++++++++++------------- service.go | 4 ++-- testdata/templates/properties.tpl | 12 +++++++++--- 5 files changed, 47 insertions(+), 23 deletions(-) diff --git a/owner.go b/owner.go index 050db146..2b48c261 100644 --- a/owner.go +++ b/owner.go @@ -28,3 +28,19 @@ func (entityOwnerService *EntityOwnerService) Aliases() []string { func (entityOwnerService *EntityOwnerService) Id() ID { return entityOwnerService.OnService.Id } + +type PropertyOwner struct { + Typename string `graphql:"__typename"` + *TeamId `graphql:"... on Team"` + *ServiceId `graphql:"... on Service"` +} + +func (o PropertyOwner) Id() ID { + if o.ServiceId != nil { + return o.ServiceId.Id + } + if o.TeamId != nil { + return o.TeamId.Id + } + return "" +} diff --git a/property.go b/property.go index eef61e15..a98ea262 100644 --- a/property.go +++ b/property.go @@ -25,12 +25,12 @@ type PropertyDefinitionId struct { type Property struct { Definition PropertyDefinitionId `graphql:"definition"` Locked bool `graphql:"locked"` - Owner EntityOwnerService `graphql:"owner"` + Owner PropertyOwner `graphql:"owner"` ValidationErrors []Error `graphql:"validationErrors"` Value *JsonString `graphql:"value"` } -type ServicePropertiesConnection struct { +type PropertiesConnection struct { Nodes []Property PageInfo PageInfo TotalCount int `graphql:"-"` @@ -148,11 +148,11 @@ func (client *Client) PropertyUnassign(owner string, definition string) error { return HandleErrors(err, m.Payload.Errors) } -func (service *Service) GetProperties(client *Client, variables *PayloadVariables) (*ServicePropertiesConnection, error) { +func (service *Service) GetProperties(client *Client, variables *PayloadVariables) (*PropertiesConnection, error) { var q struct { Account struct { Service struct { - Properties ServicePropertiesConnection `graphql:"properties(after: $after, first: $first)"` + Properties PropertiesConnection `graphql:"properties(after: $after, first: $first)"` } `graphql:"service(id: $service)"` } } @@ -168,7 +168,7 @@ func (service *Service) GetProperties(client *Client, variables *PayloadVariable return nil, err } if service.Properties == nil { - service.Properties = &ServicePropertiesConnection{} + service.Properties = &PropertiesConnection{} } service.Properties.Nodes = append(service.Properties.Nodes, q.Account.Service.Properties.Nodes...) service.Properties.PageInfo = q.Account.Service.Properties.PageInfo diff --git a/property_test.go b/property_test.go index 95eaf7ae..f2e9b6a7 100644 --- a/property_test.go +++ b/property_test.go @@ -195,9 +195,9 @@ func TestListPropertyDefinitions(t *testing.T) { func TestGetProperty(t *testing.T) { // Arrange testRequest := autopilot.NewTestRequest( - `query PropertyGet($definition:IdentifierInput!$owner:IdentifierInput!){account{property(owner: $owner, definition: $definition){definition{id,aliases},locked,owner{... on Service{id,aliases}},validationErrors{message,path},value}}}`, + `query PropertyGet($definition:IdentifierInput!$owner:IdentifierInput!){account{property(owner: $owner, definition: $definition){definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value}}}`, `{"owner":{"alias":"monolith"},"definition":{"alias":"is_beta_feature"}}`, - `{"data":{"account":{"property":{"definition":{"id":"{{ template "id2_string" }}"},"locked":true,"owner":{"id":"{{ template "id1_string" }}"},"validationErrors":[],"value":"true"}}}}`, + `{"data":{"account":{"property":{"definition":{"id":"{{ template "id2_string" }}"},"locked":true,"owner":{"__typename":"Service","id":"{{ template "id1_string" }}","aliases":[]},"validationErrors":[],"value":"true"}}}}`, ) client := BestTestClient(t, "properties/property_get", testRequest) @@ -216,9 +216,9 @@ func TestGetProperty(t *testing.T) { func TestGetPropertyHasErrors(t *testing.T) { // Arrange testRequest := autopilot.NewTestRequest( - `query PropertyGet($definition:IdentifierInput!$owner:IdentifierInput!){account{property(owner: $owner, definition: $definition){definition{id,aliases},locked,owner{... on Service{id,aliases}},validationErrors{message,path},value}}}`, + `query PropertyGet($definition:IdentifierInput!$owner:IdentifierInput!){account{property(owner: $owner, definition: $definition){definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value}}}`, `{"owner":{"alias":"monolith"},"definition":{"alias":"dropdown"}}`, - `{"data":{"account":{"property":{"definition":{"id":"{{ template "id2_string" }}"},"locked":false,"owner":{"id":"{{ template "id1_string" }}"},"validationErrors":[{"message":"vmessage1","path":["vmp1","vmp2"]},{"message":"vmessage2","path":["vmp3"]}],"value":"\"orange\""}}}}`, + `{"data":{"account":{"property":{"definition":{"id":"{{ template "id2_string" }}"},"locked":false,"owner":{"__typename":"Service","id":"{{ template "id1_string" }}","aliases":[]},"validationErrors":[{"message":"vmessage1","path":["vmp1","vmp2"]},{"message":"vmessage2","path":["vmp3"]}],"value":"\"orange\""}}}}`, ) client := BestTestClient(t, "properties/property_get_has_errors", testRequest) @@ -246,9 +246,9 @@ func TestGetPropertyHasErrors(t *testing.T) { func TestGetPropertyHasNullValue(t *testing.T) { // Arrange testRequest := autopilot.NewTestRequest( - `query PropertyGet($definition:IdentifierInput!$owner:IdentifierInput!){account{property(owner: $owner, definition: $definition){definition{id,aliases},locked,owner{... on Service{id,aliases}},validationErrors{message,path},value}}}`, + `query PropertyGet($definition:IdentifierInput!$owner:IdentifierInput!){account{property(owner: $owner, definition: $definition){definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value}}}`, `{"owner":{"alias":"monolith"},"definition":{"alias":"is_beta_feature"}}`, - `{"data":{"account":{"property":{"definition":{"id":"{{ template "id2_string" }}"},"locked":true,"owner":{"id":"{{ template "id1_string" }}"},"validationErrors":[],"value":null}}}}`, + `{"data":{"account":{"property":{"definition":{"id":"{{ template "id2_string" }}"},"locked":true,"owner":{"__typename":"Service","id":"{{ template "id1_string" }}","aliases":[]},"validationErrors":[],"value":null}}}}`, ) client := BestTestClient(t, "properties/property_get_has_null_value", testRequest) @@ -272,9 +272,9 @@ func TestAssignProperty(t *testing.T) { Value: "true", } testRequest := autopilot.NewTestRequest( - `mutation PropertyAssign($input:PropertyInput!){propertyAssign(input: $input){property{definition{id,aliases},locked,owner{... on Service{id,aliases}},validationErrors{message,path},value},errors{message,path}}}`, + `mutation PropertyAssign($input:PropertyInput!){propertyAssign(input: $input){property{definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value},errors{message,path}}}`, `{"input": {{ template "property_assign_input" }} }`, - `{"data":{"propertyAssign":{"property":{"definition":{"id":"{{ template "id2_string" }}"},"locked":true,"owner":{"id":"{{ template "id1_string" }}"},"validationErrors":[],"value":"true"},"errors":[]}}}`, + `{"data":{"propertyAssign":{"property":{"definition":{"id":"{{ template "id2_string" }}"},"locked":true,"owner":{"__typename":"Service","id":"{{ template "id1_string" }}","aliases":[]},"validationErrors":[],"value":"true"},"errors":[]}}}`, ) client := BestTestClient(t, "properties/property_assign", testRequest) @@ -309,13 +309,15 @@ func TestUnassignProperty(t *testing.T) { func TestGetServiceProperties(t *testing.T) { // Arrange serviceId := ol.ServiceId{ - Id: id1, + Id: id1, + Aliases: []string{}, } service := ol.Service{ ServiceId: serviceId, } - owner := ol.EntityOwnerService{ - OnService: serviceId, + owner := ol.PropertyOwner{ + Typename: "Service", + ServiceId: &serviceId, } value1 := ol.JsonString("true") value2 := ol.JsonString("false") @@ -352,12 +354,12 @@ func TestGetServiceProperties(t *testing.T) { }, }) testRequestOne := autopilot.NewTestRequest( - `query ServicePropertiesList($after:String!$first:Int!$service:ID!){account{service(id: $service){properties(after: $after, first: $first){nodes{definition{id,aliases},locked,owner{... on Service{id,aliases}},validationErrors{message,path},value},{{ template "pagination_request" }}}}}}`, + `query ServicePropertiesList($after:String!$first:Int!$service:ID!){account{service(id: $service){properties(after: $after, first: $first){nodes{definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value},{{ template "pagination_request" }}}}}}`, `{ {{ template "first_page_variables" }}, "service": "{{ template "id1_string" }}" }`, `{"data":{"account":{"service":{"properties":{"nodes":[{{ template "service_properties_page_1" }}],{{ template "pagination_initial_pageInfo_response" }}}}}}}`, ) testRequestTwo := autopilot.NewTestRequest( - `query ServicePropertiesList($after:String!$first:Int!$service:ID!){account{service(id: $service){properties(after: $after, first: $first){nodes{definition{id,aliases},locked,owner{... on Service{id,aliases}},validationErrors{message,path},value},{{ template "pagination_request" }}}}}}`, + `query ServicePropertiesList($after:String!$first:Int!$service:ID!){account{service(id: $service){properties(after: $after, first: $first){nodes{definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value},{{ template "pagination_request" }}}}}}`, `{ {{ template "second_page_variables" }}, "service": "{{ template "id1_string" }}" }`, `{"data":{"account":{"service":{"properties":{"nodes":[{{ template "service_properties_page_2" }}],{{ template "pagination_second_pageInfo_response" }}}}}}}`, ) diff --git a/service.go b/service.go index d3f1d114..e6da096a 100644 --- a/service.go +++ b/service.go @@ -43,8 +43,8 @@ type Service struct { Dependencies *ServiceDependenciesConnection `graphql:"-"` Dependents *ServiceDependentsConnection `graphql:"-"` - LastDeploy *Deploy `graphql:"-"` - Properties *ServicePropertiesConnection `graphql:"-"` + LastDeploy *Deploy `graphql:"-"` + Properties *PropertiesConnection `graphql:"-"` } // Returns unique identifiers created by OpsLevel, values in Aliases but not ManagedAliases diff --git a/testdata/templates/properties.tpl b/testdata/templates/properties.tpl index 7daf992e..25a3f1aa 100644 --- a/testdata/templates/properties.tpl +++ b/testdata/templates/properties.tpl @@ -7,7 +7,9 @@ }, "locked": true, "owner": { - "id": "{{ template "id1_string" }}" + "__typename": "Service", + "id": "{{ template "id1_string" }}", + "aliases": [] }, "validationErrors": [], "value": "true" @@ -18,7 +20,9 @@ }, "locked": false, "owner": { - "id": "{{ template "id1_string" }}" + "__typename": "Service", + "id": "{{ template "id1_string" }}", + "aliases": [] }, "validationErrors": [], "value": "false" @@ -32,7 +36,9 @@ }, "locked": true, "owner": { - "id": "{{ template "id1_string" }}" + "__typename": "Service", + "id": "{{ template "id1_string" }}", + "aliases": [] }, "validationErrors": [], "value": "\"Hello World!\"" From 5fb4ab2452f86de167d34e85818284e023e96ae5 Mon Sep 17 00:00:00 2001 From: Andrew Still Date: Thu, 5 Mar 2026 01:01:05 -0500 Subject: [PATCH 02/12] feat: add CreateTeamPropertyDefinition Co-Authored-By: Claude Sonnet 4.6 --- property.go | 11 +++++++++++ property_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/property.go b/property.go index a98ea262..8ec2fff0 100644 --- a/property.go +++ b/property.go @@ -111,6 +111,17 @@ func (client *Client) DeletePropertyDefinition(input string) error { return HandleErrors(err, m.Payload.Errors) } +func (client *Client) CreateTeamPropertyDefinition(input TeamPropertyDefinitionInput) (*TeamPropertyDefinition, error) { + var m struct { + Payload TeamPropertyDefinitionPayload `graphql:"teamPropertyDefinitionCreate(input: $input)"` + } + v := PayloadVariables{ + "input": input, + } + err := client.Mutate(&m, v, WithName("TeamPropertyDefinitionCreate")) + return &m.Payload.Definition, HandleErrors(err, m.Payload.Errors) +} + func (client *Client) GetProperty(owner string, definition string) (*Property, error) { var q struct { Account struct { diff --git a/property_test.go b/property_test.go index f2e9b6a7..2755eb66 100644 --- a/property_test.go +++ b/property_test.go @@ -306,6 +306,37 @@ func TestUnassignProperty(t *testing.T) { autopilot.Ok(t, err) } +func TestCreateTeamPropertyDefinition(t *testing.T) { + // Arrange + schema, schemaErr := ol.NewJSONSchema(schemaString2) + autopilot.Ok(t, schemaErr) + expectedDefinition := autopilot.Register("expected_team_property_definition", ol.TeamPropertyDefinition{ + Alias: "my_team_prop", + Id: id1, + Name: "my-team-prop", + Schema: *schema, + }) + input := autopilot.Register("team_property_definition_input", ol.TeamPropertyDefinitionInput{ + Alias: "my_team_prop", + Name: "my-team-prop", + Schema: *schema, + }) + testRequest := autopilot.NewTestRequest( + `mutation TeamPropertyDefinitionCreate($input:TeamPropertyDefinitionInput!){teamPropertyDefinitionCreate(input: $input){definition{alias,description,displaySubtype,displayType,id,lockedStatus,name,schema},errors{message,path}}}`, + `{"input": {{ template "team_property_definition_input" }} }`, + fmt.Sprintf(`{"data":{"teamPropertyDefinitionCreate":{"definition":{"alias":"my_team_prop","id":"%s","name":"my-team-prop","schema":%s},"errors":[]}}}`, id1, schemaString2), + ) + client := BestTestClient(t, "properties/team_definition_create", testRequest) + + // Act + result, err := client.CreateTeamPropertyDefinition(input) + + // Assert + autopilot.Ok(t, err) + autopilot.Equals(t, expectedDefinition, *result) + autopilot.Equals(t, expectedDefinition.Schema, result.Schema) +} + func TestGetServiceProperties(t *testing.T) { // Arrange serviceId := ol.ServiceId{ From 01809a4988e4cee1f98bcb79978742b7d375d74c Mon Sep 17 00:00:00 2001 From: Andrew Still Date: Thu, 5 Mar 2026 01:03:42 -0500 Subject: [PATCH 03/12] feat: add UpdateTeamPropertyDefinition Co-Authored-By: Claude Sonnet 4.6 --- property.go | 12 ++++++++++++ property_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/property.go b/property.go index 8ec2fff0..d207782b 100644 --- a/property.go +++ b/property.go @@ -122,6 +122,18 @@ func (client *Client) CreateTeamPropertyDefinition(input TeamPropertyDefinitionI return &m.Payload.Definition, HandleErrors(err, m.Payload.Errors) } +func (client *Client) UpdateTeamPropertyDefinition(identifier string, input TeamPropertyDefinitionInput) (*TeamPropertyDefinition, error) { + var m struct { + Payload TeamPropertyDefinitionPayload `graphql:"teamPropertyDefinitionUpdate(propertyDefinition: $propertyDefinition, input: $input)"` + } + v := PayloadVariables{ + "propertyDefinition": *NewIdentifier(identifier), + "input": input, + } + err := client.Mutate(&m, v, WithName("TeamPropertyDefinitionUpdate")) + return &m.Payload.Definition, HandleErrors(err, m.Payload.Errors) +} + func (client *Client) GetProperty(owner string, definition string) (*Property, error) { var q struct { Account struct { diff --git a/property_test.go b/property_test.go index 2755eb66..cafbb4b1 100644 --- a/property_test.go +++ b/property_test.go @@ -337,6 +337,39 @@ func TestCreateTeamPropertyDefinition(t *testing.T) { autopilot.Equals(t, expectedDefinition.Schema, result.Schema) } +func TestUpdateTeamPropertyDefinition(t *testing.T) { + // Arrange + schema, schemaErr := ol.NewJSONSchema(schemaString2) + autopilot.Ok(t, schemaErr) + expectedDefinition := autopilot.Register("expected_team_property_definition", ol.TeamPropertyDefinition{ + Alias: "my_team_prop", + Description: "updated description", + Id: id1, + Name: "my-team-prop", + Schema: *schema, + }) + input := autopilot.Register("team_property_definition_input", ol.TeamPropertyDefinitionInput{ + Alias: "my_team_prop", + Description: "updated description", + Name: "my-team-prop", + Schema: *schema, + }) + testRequest := autopilot.NewTestRequest( + `mutation TeamPropertyDefinitionUpdate($input:TeamPropertyDefinitionInput!$propertyDefinition:IdentifierInput!){teamPropertyDefinitionUpdate(propertyDefinition: $propertyDefinition, input: $input){definition{alias,description,displaySubtype,displayType,id,lockedStatus,name,schema},errors{message,path}}}`, + `{"propertyDefinition":{"alias":"my_team_prop"}, "input": {{ template "team_property_definition_input" }} }`, + fmt.Sprintf(`{"data":{"teamPropertyDefinitionUpdate":{"definition":{"alias":"my_team_prop","description":"updated description","id":"%s","name":"my-team-prop","schema":%s},"errors":[]}}}`, id1, schemaString2), + ) + client := BestTestClient(t, "properties/team_definition_update", testRequest) + + // Act + result, err := client.UpdateTeamPropertyDefinition("my_team_prop", input) + + // Assert + autopilot.Ok(t, err) + autopilot.Equals(t, expectedDefinition, *result) + autopilot.Equals(t, expectedDefinition.Schema, result.Schema) +} + func TestGetServiceProperties(t *testing.T) { // Arrange serviceId := ol.ServiceId{ From b031a9e4a2c7f7828f8f6253acd35c5487fb98f7 Mon Sep 17 00:00:00 2001 From: Andrew Still Date: Thu, 5 Mar 2026 01:06:25 -0500 Subject: [PATCH 04/12] feat: add GetTeamPropertyDefinition Co-Authored-By: Claude Sonnet 4.6 --- property.go | 16 ++++++++++++++++ property_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/property.go b/property.go index d207782b..073d781e 100644 --- a/property.go +++ b/property.go @@ -134,6 +134,22 @@ func (client *Client) UpdateTeamPropertyDefinition(identifier string, input Team return &m.Payload.Definition, HandleErrors(err, m.Payload.Errors) } +func (client *Client) GetTeamPropertyDefinition(identifier string) (*TeamPropertyDefinition, error) { + var q struct { + Account struct { + Definition TeamPropertyDefinition `graphql:"teamPropertyDefinition(input: $input)"` + } + } + v := PayloadVariables{ + "input": *NewIdentifier(identifier), + } + err := client.Query(&q, v, WithName("TeamPropertyDefinitionGet")) + if q.Account.Definition.Id == "" { + err = fmt.Errorf("TeamPropertyDefinition with ID or Alias matching '%s' not found", identifier) + } + return &q.Account.Definition, HandleErrors(err, nil) +} + func (client *Client) GetProperty(owner string, definition string) (*Property, error) { var q struct { Account struct { diff --git a/property_test.go b/property_test.go index cafbb4b1..6b8c10ba 100644 --- a/property_test.go +++ b/property_test.go @@ -370,6 +370,33 @@ func TestUpdateTeamPropertyDefinition(t *testing.T) { autopilot.Equals(t, expectedDefinition.Schema, result.Schema) } +func TestGetTeamPropertyDefinition(t *testing.T) { + // Arrange + schema, schemaErr := ol.NewJSONSchema(schemaString2) + autopilot.Ok(t, schemaErr) + expectedDefinition := autopilot.Register("expected_team_property_definition", ol.TeamPropertyDefinition{ + Alias: "my_team_prop", + Id: id1, + Name: "my-team-prop", + Schema: *schema, + }) + testRequest := autopilot.NewTestRequest( + `query TeamPropertyDefinitionGet($input:IdentifierInput!){account{teamPropertyDefinition(input: $input){alias,description,displaySubtype,displayType,id,lockedStatus,name,schema}}}`, + `{"input":{"alias":"my_team_prop"}}`, + fmt.Sprintf(`{"data":{"account":{"teamPropertyDefinition":{"alias":"my_team_prop","id":"%s","name":"my-team-prop","schema":%s}}}}`, id1, schemaString2), + ) + client := BestTestClient(t, "properties/team_definition_get", testRequest) + + // Act + result, err := client.GetTeamPropertyDefinition("my_team_prop") + + // Assert + autopilot.Ok(t, err) + autopilot.Equals(t, expectedDefinition, *result) + autopilot.Equals(t, expectedDefinition.Schema, result.Schema) + autopilot.Equals(t, string(id1), string(result.Id)) +} + func TestGetServiceProperties(t *testing.T) { // Arrange serviceId := ol.ServiceId{ From 97fd0437a5fd5d4de6126e7bed5b0e90c63f4f97 Mon Sep 17 00:00:00 2001 From: Andrew Still Date: Thu, 5 Mar 2026 01:08:44 -0500 Subject: [PATCH 05/12] feat: add ListTeamPropertyDefinitions Co-Authored-By: Claude Sonnet 4.6 --- property.go | 26 ++++++++++++++++++++++++++ property_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/property.go b/property.go index 073d781e..29d9d7c1 100644 --- a/property.go +++ b/property.go @@ -150,6 +150,32 @@ func (client *Client) GetTeamPropertyDefinition(identifier string) (*TeamPropert return &q.Account.Definition, HandleErrors(err, nil) } +func (client *Client) ListTeamPropertyDefinitions(variables *PayloadVariables) (*TeamPropertyDefinitionConnection, error) { + var q struct { + Account struct { + Definitions TeamPropertyDefinitionConnection `graphql:"teamPropertyDefinitions(after: $after, first: $first)"` + } + } + if variables == nil { + variables = client.InitialPageVariablesPointer() + } + if err := client.Query(&q, *variables, WithName("TeamPropertyDefinitionList")); err != nil { + return nil, err + } + q.Account.Definitions.TotalCount = len(q.Account.Definitions.Nodes) + if q.Account.Definitions.PageInfo.HasNextPage { + (*variables)["after"] = q.Account.Definitions.PageInfo.End + resp, err := client.ListTeamPropertyDefinitions(variables) + if err != nil { + return nil, err + } + q.Account.Definitions.Nodes = append(q.Account.Definitions.Nodes, resp.Nodes...) + q.Account.Definitions.PageInfo = resp.PageInfo + q.Account.Definitions.TotalCount += resp.TotalCount + } + return &q.Account.Definitions, nil +} + func (client *Client) GetProperty(owner string, definition string) (*Property, error) { var q struct { Account struct { diff --git a/property_test.go b/property_test.go index 6b8c10ba..a2097886 100644 --- a/property_test.go +++ b/property_test.go @@ -397,6 +397,42 @@ func TestGetTeamPropertyDefinition(t *testing.T) { autopilot.Equals(t, string(id1), string(result.Id)) } +func TestListTeamPropertyDefinitions(t *testing.T) { + // Arrange + schema, schemaErr := ol.NewJSONSchema(schemaString2) + autopilot.Ok(t, schemaErr) + expectedPage1 := autopilot.Register("team_property_definitions_page1", []ol.TeamPropertyDefinition{ + {Alias: "prop_a", Id: id1, Name: "prop-a", Schema: *schema}, + {Alias: "prop_b", Id: id2, Name: "prop-b", Schema: *schema}, + }) + expectedPage2 := autopilot.Register("team_property_definition_page2", ol.TeamPropertyDefinition{ + Alias: "prop_c", Id: id3, Name: "prop-c", Schema: *schema, + }) + testRequestOne := autopilot.NewTestRequest( + `query TeamPropertyDefinitionList($after:String!$first:Int!){account{teamPropertyDefinitions(after: $after, first: $first){nodes{alias,description,displaySubtype,displayType,id,lockedStatus,name,schema},{{ template "pagination_request" }}}}}`, + `{{ template "pagination_initial_query_variables" }}`, + fmt.Sprintf(`{"data":{"account":{"teamPropertyDefinitions":{"nodes":[{"alias":"prop_a","id":"%s","name":"prop-a","schema":%s},{"alias":"prop_b","id":"%s","name":"prop-b","schema":%s}],{{ template "pagination_initial_pageInfo_response" }}}}}}`, id1, schemaString2, id2, schemaString2), + ) + testRequestTwo := autopilot.NewTestRequest( + `query TeamPropertyDefinitionList($after:String!$first:Int!){account{teamPropertyDefinitions(after: $after, first: $first){nodes{alias,description,displaySubtype,displayType,id,lockedStatus,name,schema},{{ template "pagination_request" }}}}}`, + `{{ template "pagination_second_query_variables" }}`, + fmt.Sprintf(`{"data":{"account":{"teamPropertyDefinitions":{"nodes":[{"alias":"prop_c","id":"%s","name":"prop-c","schema":%s}],{{ template "pagination_second_pageInfo_response" }}}}}}`, id3, schemaString2), + ) + client := BestTestClient(t, "properties/team_definition_list", testRequestOne, testRequestTwo) + + // Act + result, err := client.ListTeamPropertyDefinitions(nil) + + // Assert + autopilot.Ok(t, err) + autopilot.Equals(t, 3, len(result.Nodes)) + autopilot.Equals(t, expectedPage1[0], result.Nodes[0]) + autopilot.Equals(t, expectedPage1[1], result.Nodes[1]) + autopilot.Equals(t, expectedPage2, result.Nodes[2]) + autopilot.Equals(t, expectedPage1[0].Schema, result.Nodes[0].Schema) + autopilot.Equals(t, 3, result.TotalCount) +} + func TestGetServiceProperties(t *testing.T) { // Arrange serviceId := ol.ServiceId{ From c31349ba8f5ace9190bab1922f672ecaaa933f56 Mon Sep 17 00:00:00 2001 From: Andrew Still Date: Thu, 5 Mar 2026 01:18:07 -0500 Subject: [PATCH 06/12] feat: add AssignTeamPropertyDefinitions Co-Authored-By: Claude Sonnet 4.6 --- payload.go | 6 ++++++ property.go | 12 ++++++++++++ property_test.go | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/payload.go b/payload.go index 658d3c4a..e74b8408 100644 --- a/payload.go +++ b/payload.go @@ -330,6 +330,12 @@ type TeamPropertyDefinitionPayload struct { BasePayload } +// TeamPropertyDefinitionsAssignPayload The return type for the teamPropertyDefinitionsAssign mutation +type TeamPropertyDefinitionsAssignPayload struct { + Properties TeamPropertyDefinitionConnection // The property definitions that were assigned (Optional) + BasePayload +} + // TeamUpdatePayload The return type of a `teamUpdate` mutation type TeamUpdatePayload struct { Team Team // A team belongs to your organization. Teams can own multiple services (Optional) diff --git a/property.go b/property.go index 29d9d7c1..7429f567 100644 --- a/property.go +++ b/property.go @@ -176,6 +176,18 @@ func (client *Client) ListTeamPropertyDefinitions(variables *PayloadVariables) ( return &q.Account.Definitions, nil } +func (client *Client) AssignTeamPropertyDefinitions(input TeamPropertyDefinitionsAssignInput) (*TeamPropertyDefinitionConnection, error) { + var m struct { + Payload TeamPropertyDefinitionsAssignPayload `graphql:"teamPropertyDefinitionsAssign(input: $input)"` + } + v := PayloadVariables{ + "input": input, + } + err := client.Mutate(&m, v, WithName("TeamPropertyDefinitionsAssign")) + m.Payload.Properties.TotalCount = len(m.Payload.Properties.Nodes) + return &m.Payload.Properties, HandleErrors(err, m.Payload.Errors) +} + func (client *Client) GetProperty(owner string, definition string) (*Property, error) { var q struct { Account struct { diff --git a/property_test.go b/property_test.go index a2097886..f01b248f 100644 --- a/property_test.go +++ b/property_test.go @@ -507,3 +507,35 @@ func TestGetServiceProperties(t *testing.T) { autopilot.Equals(t, expectedPropsPageOne[1].Value, result[1].Value) autopilot.Equals(t, expectedPropsPageTwo[0].Value, result[2].Value) } + +func TestAssignTeamPropertyDefinitions(t *testing.T) { + // Arrange + schema, schemaErr := ol.NewJSONSchema(schemaString2) + autopilot.Ok(t, schemaErr) + input := autopilot.Register("team_property_definitions_assign_input", ol.TeamPropertyDefinitionsAssignInput{ + Properties: []ol.TeamPropertyDefinitionInput{ + {Alias: "prop_a", Name: "prop-a", Schema: *schema}, + {Alias: "prop_b", Name: "prop-b", Schema: *schema}, + }, + }) + expectedDefinitions := autopilot.Register("team_property_definitions_assigned", []ol.TeamPropertyDefinition{ + {Alias: "prop_a", Id: id1, Name: "prop-a", Schema: *schema}, + {Alias: "prop_b", Id: id2, Name: "prop-b", Schema: *schema}, + }) + testRequest := autopilot.NewTestRequest( + `mutation TeamPropertyDefinitionsAssign($input:TeamPropertyDefinitionsAssignInput!){teamPropertyDefinitionsAssign(input: $input){properties{nodes{alias,description,displaySubtype,displayType,id,lockedStatus,name,schema},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}},errors{message,path}}}`, + `{"input": {{ template "team_property_definitions_assign_input" }} }`, + fmt.Sprintf(`{"data":{"teamPropertyDefinitionsAssign":{"properties":{"nodes":[{"alias":"prop_a","id":"%s","name":"prop-a","schema":%s},{"alias":"prop_b","id":"%s","name":"prop-b","schema":%s}],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false,"startCursor":"MQ","endCursor":"NA"}},"errors":[]}}}`, id1, schemaString2, id2, schemaString2), + ) + client := BestTestClient(t, "properties/team_definitions_assign", testRequest) + + // Act + result, err := client.AssignTeamPropertyDefinitions(input) + + // Assert + autopilot.Ok(t, err) + autopilot.Equals(t, 2, len(result.Nodes)) + autopilot.Equals(t, expectedDefinitions[0], result.Nodes[0]) + autopilot.Equals(t, expectedDefinitions[1], result.Nodes[1]) + autopilot.Equals(t, 2, result.TotalCount) +} From 069ca7eb646399eb740bc1e30391081078f32e81 Mon Sep 17 00:00:00 2001 From: Andrew Still Date: Thu, 5 Mar 2026 01:35:52 -0500 Subject: [PATCH 07/12] feat: add Team.GetProperties with test --- team.go | 36 ++++++++++++++++++++++++++++ team_test.go | 40 +++++++++++++++++++++++++++++++ testdata/templates/properties.tpl | 16 +++++++++++++ 3 files changed, 92 insertions(+) diff --git a/team.go b/team.go index 148f016e..5c415eee 100644 --- a/team.go +++ b/team.go @@ -26,6 +26,7 @@ type Team struct { ParentTeam TeamId Responsibilities string Tags *TagConnection + Properties *PropertiesConnection `graphql:"-" json:"-"` } // TeamIdConnection exists to prevent circular references on User because Team has a UserConnection @@ -171,6 +172,41 @@ func (team *Team) GetTags(client *Client, variables *PayloadVariables) (*TagConn return &q.Account.Team.Tags, nil } +func (team *Team) GetProperties(client *Client, variables *PayloadVariables) (*PropertiesConnection, error) { + var q struct { + Account struct { + Team struct { + Properties PropertiesConnection `graphql:"properties(after: $after, first: $first)"` + } `graphql:"team(id: $team)"` + } + } + + if team.Id == "" { + return nil, fmt.Errorf("unable to get properties, invalid Team id: '%s'", team.Id) + } + if variables == nil { + variables = client.InitialPageVariablesPointer() + } + (*variables)["team"] = team.Id + if err := client.Query(&q, *variables, WithName("TeamPropertiesList")); err != nil { + return nil, err + } + if team.Properties == nil { + team.Properties = &PropertiesConnection{} + } + team.Properties.Nodes = append(team.Properties.Nodes, q.Account.Team.Properties.Nodes...) + team.Properties.PageInfo = q.Account.Team.Properties.PageInfo + if team.Properties.PageInfo.HasNextPage { + (*variables)["after"] = team.Properties.PageInfo.End + resp, err := team.GetProperties(client, variables) + if err != nil { + return nil, err + } + team.Properties.TotalCount += resp.TotalCount + } + return team.Properties, nil +} + func (team *Team) GetAliases() []string { return team.Aliases } diff --git a/team_test.go b/team_test.go index 975f88ac..62e9f896 100644 --- a/team_test.go +++ b/team_test.go @@ -7,6 +7,46 @@ import ( "github.com/rocktavious/autopilot/v2023" ) +func TestGetTeamProperties(t *testing.T) { + // Arrange + teamId := ol.TeamId{ + Alias: "example", + Id: id1, + } + team := ol.Team{ + TeamId: teamId, + } + owner := ol.PropertyOwner{ + Typename: "Team", + TeamId: &teamId, + } + value1 := ol.JsonString("true") + expectedProperties := autopilot.Register("team_properties", []ol.Property{ + { + Definition: ol.PropertyDefinitionId{Id: id2}, + Locked: false, + Owner: owner, + Value: &value1, + }, + }) + testRequest := autopilot.NewTestRequest( + `query TeamPropertiesList($after:String!$first:Int!$team:ID!){account{team(id: $team){properties(after: $after, first: $first){nodes{definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value},{{ template "pagination_request" }}}}}}`, + `{ {{ template "first_page_variables" }}, "team": "{{ template "id1_string" }}" }`, + `{"data":{"account":{"team":{"properties":{"nodes":[{{ template "team_properties_page_1" }}],{{ template "no_pagination_response" }}}}}}}`, + ) + client := BestTestClient(t, "teams/team_properties", testRequest) + + // Act + result, err := team.GetProperties(client, nil) + + // Assert + autopilot.Ok(t, err) + autopilot.Equals(t, 1, len(result.Nodes)) + autopilot.Equals(t, expectedProperties[0].Definition.Id, result.Nodes[0].Definition.Id) + autopilot.Equals(t, expectedProperties[0].Owner.Id(), result.Nodes[0].Owner.Id()) + autopilot.Equals(t, string(*expectedProperties[0].Value), string(*result.Nodes[0].Value)) +} + // Probably should be a feature of autopilot func getTestRequestWithAlias() autopilot.TestRequest { return autopilot.NewTestRequest( diff --git a/testdata/templates/properties.tpl b/testdata/templates/properties.tpl index 25a3f1aa..f0bf7de5 100644 --- a/testdata/templates/properties.tpl +++ b/testdata/templates/properties.tpl @@ -1,5 +1,21 @@ {{- define "property_assign_input" }}{"owner":{"id":"{{ template "id1_string" }}"},"definition":{"id":"{{ template "id2_string" }}"},"value":"true"}{{ end }} +{{- define "team_properties_page_1" }} +{ + "definition": { + "id": "{{ template "id2_string" }}" + }, + "locked": false, + "owner": { + "__typename": "Team", + "alias": "{{ template "alias1" }}", + "id": "{{ template "id1_string" }}" + }, + "validationErrors": [], + "value": "true" +} +{{ end }} + {{- define "service_properties_page_1" }} { "definition": { From a0a6cb24c198e1c4de46d4562f27881ca12123c1 Mon Sep 17 00:00:00 2001 From: Andrew Still Date: Thu, 5 Mar 2026 01:49:46 -0500 Subject: [PATCH 08/12] feat: add Team.GetProperty --- team.go | 19 +++++++++++++++++++ team_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/team.go b/team.go index 5c415eee..db05daaf 100644 --- a/team.go +++ b/team.go @@ -172,6 +172,25 @@ func (team *Team) GetTags(client *Client, variables *PayloadVariables) (*TagConn return &q.Account.Team.Tags, nil } +func (team *Team) GetProperty(client *Client, definition string) (*Property, error) { + var q struct { + Account struct { + Team struct { + Property Property `graphql:"property(definition: $definition)"` + } `graphql:"team(id: $team)"` + } + } + if team.Id == "" { + return nil, fmt.Errorf("unable to get property, invalid Team id: '%s'", team.Id) + } + v := PayloadVariables{ + "team": team.Id, + "definition": *NewIdentifier(definition), + } + err := client.Query(&q, v, WithName("TeamPropertyGet")) + return &q.Account.Team.Property, HandleErrors(err, nil) +} + func (team *Team) GetProperties(client *Client, variables *PayloadVariables) (*PropertiesConnection, error) { var q struct { Account struct { diff --git a/team_test.go b/team_test.go index 62e9f896..eb1bbc09 100644 --- a/team_test.go +++ b/team_test.go @@ -7,6 +7,39 @@ import ( "github.com/rocktavious/autopilot/v2023" ) +func TestGetTeamProperty(t *testing.T) { + // Arrange + teamId := ol.TeamId{ + Alias: "example", + Id: id1, + } + team := ol.Team{ + TeamId: teamId, + } + value := ol.JsonString("true") + expectedProperty := ol.Property{ + Definition: ol.PropertyDefinitionId{Id: id2}, + Locked: false, + Owner: ol.PropertyOwner{Typename: "Team", TeamId: &teamId}, + Value: &value, + } + testRequest := autopilot.NewTestRequest( + `query TeamPropertyGet($definition:IdentifierInput!$team:ID!){account{team(id: $team){property(definition: $definition){definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value}}}}`, + `{"definition":{"alias":"my-prop-alias"},"team":"{{ template "id1_string" }}"}`, + `{"data":{"account":{"team":{"property":{"definition":{"id":"{{ template "id2_string" }}","aliases":[]},"locked":false,"owner":{"__typename":"Team","alias":"{{ template "alias1" }}","id":"{{ template "id1_string" }}"},"validationErrors":[],"value":"true"}}}}}`, + ) + client := BestTestClient(t, "teams/team_property_get", testRequest) + + // Act + result, err := team.GetProperty(client, "my-prop-alias") + + // Assert + autopilot.Ok(t, err) + autopilot.Equals(t, expectedProperty.Definition.Id, result.Definition.Id) + autopilot.Equals(t, string(*expectedProperty.Value), string(*result.Value)) + autopilot.Equals(t, expectedProperty.Owner.Id(), result.Owner.Id()) +} + func TestGetTeamProperties(t *testing.T) { // Arrange teamId := ol.TeamId{ From 2c70402684a1f08427d514d616b296cfd8cc3827 Mon Sep 17 00:00:00 2001 From: Andrew Still Date: Thu, 5 Mar 2026 12:23:09 -0500 Subject: [PATCH 09/12] feat: add Service.GetProperty, deprecate Client.GetProperty --- property.go | 22 ++++++++++++++++++++++ property_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/property.go b/property.go index 7429f567..81084c84 100644 --- a/property.go +++ b/property.go @@ -188,6 +188,9 @@ func (client *Client) AssignTeamPropertyDefinitions(input TeamPropertyDefinition return &m.Payload.Properties, HandleErrors(err, m.Payload.Errors) } +// Deprecated: Use [Service.GetProperty] or [Team.GetProperty] instead. +// This method only resolves service owners. Passing a team identifier will +// return an error from the API. func (client *Client) GetProperty(owner string, definition string) (*Property, error) { var q struct { Account struct { @@ -202,6 +205,25 @@ func (client *Client) GetProperty(owner string, definition string) (*Property, e return &q.Account.Property, HandleErrors(err, nil) } +func (service *Service) GetProperty(client *Client, definition string) (*Property, error) { + var q struct { + Account struct { + Service struct { + Property Property `graphql:"property(definition: $definition)"` + } `graphql:"service(id: $service)"` + } + } + if service.Id == "" { + return nil, fmt.Errorf("unable to get property, invalid Service id: '%s'", service.Id) + } + v := PayloadVariables{ + "service": service.Id, + "definition": *NewIdentifier(definition), + } + err := client.Query(&q, v, WithName("ServicePropertyGet")) + return &q.Account.Service.Property, HandleErrors(err, nil) +} + func (client *Client) PropertyAssign(input PropertyInput) (*Property, error) { var m struct { Payload PropertyPayload `graphql:"propertyAssign(input: $input)"` diff --git a/property_test.go b/property_test.go index f01b248f..4c3d9b6f 100644 --- a/property_test.go +++ b/property_test.go @@ -433,6 +433,44 @@ func TestListTeamPropertyDefinitions(t *testing.T) { autopilot.Equals(t, 3, result.TotalCount) } +func TestGetServiceProperty(t *testing.T) { + // Arrange + serviceId := ol.ServiceId{ + Id: id1, + Aliases: []string{}, + } + service := ol.Service{ + ServiceId: serviceId, + } + owner := ol.PropertyOwner{ + Typename: "Service", + ServiceId: &serviceId, + } + value := ol.JsonString("true") + expectedProperty := ol.Property{ + Definition: ol.PropertyDefinitionId{Id: id2}, + Locked: true, + Owner: owner, + Value: &value, + } + testRequest := autopilot.NewTestRequest( + `query ServicePropertyGet($definition:IdentifierInput!$service:ID!){account{service(id: $service){property(definition: $definition){definition{id,aliases},locked,owner{__typename,... on Team{alias,id},... on Service{id,aliases}},validationErrors{message,path},value}}}}`, + `{"definition":{"alias":"is_beta_feature"},"service":"{{ template "id1_string" }}"}`, + `{"data":{"account":{"service":{"property":{"definition":{"id":"{{ template "id2_string" }}","aliases":[]},"locked":true,"owner":{"__typename":"Service","id":"{{ template "id1_string" }}","aliases":[]},"validationErrors":[],"value":"true"}}}}}`, + ) + client := BestTestClient(t, "properties/service_property_get", testRequest) + + // Act + result, err := service.GetProperty(client, "is_beta_feature") + + // Assert + autopilot.Ok(t, err) + autopilot.Equals(t, expectedProperty.Definition.Id, result.Definition.Id) + autopilot.Equals(t, expectedProperty.Locked, result.Locked) + autopilot.Equals(t, string(*expectedProperty.Value), string(*result.Value)) + autopilot.Equals(t, expectedProperty.Owner.Id(), result.Owner.Id()) +} + func TestGetServiceProperties(t *testing.T) { // Arrange serviceId := ol.ServiceId{ From 2ea5f1a6e98e2397c6d3d1e2e1d79022f6d142dd Mon Sep 17 00:00:00 2001 From: Andrew Still Date: Thu, 5 Mar 2026 12:26:58 -0500 Subject: [PATCH 10/12] chore: add changelog entries for team property definitions --- .changes/unreleased/deprecate-client-get-property.yaml | 2 ++ .changes/unreleased/team-property-definitions.yaml | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 .changes/unreleased/deprecate-client-get-property.yaml create mode 100644 .changes/unreleased/team-property-definitions.yaml diff --git a/.changes/unreleased/deprecate-client-get-property.yaml b/.changes/unreleased/deprecate-client-get-property.yaml new file mode 100644 index 00000000..04262b48 --- /dev/null +++ b/.changes/unreleased/deprecate-client-get-property.yaml @@ -0,0 +1,2 @@ +kind: Deprecated +body: '`Client.GetProperty` only resolves service owners and will fail for team identifiers. Use `Service.GetProperty` or `Team.GetProperty` instead.' diff --git a/.changes/unreleased/team-property-definitions.yaml b/.changes/unreleased/team-property-definitions.yaml new file mode 100644 index 00000000..ea214d1d --- /dev/null +++ b/.changes/unreleased/team-property-definitions.yaml @@ -0,0 +1,2 @@ +kind: Feature +body: Add CRUD operations for team property definitions (`CreateTeamPropertyDefinition`, `UpdateTeamPropertyDefinition`, `GetTeamPropertyDefinition`, `ListTeamPropertyDefinitions`, `AssignTeamPropertyDefinitions`) and entity-scoped property lookup methods (`Team.GetProperty`, `Service.GetProperty`) From 9a6fd8e975e4882ca8c04416d9d0547f6e6e5056 Mon Sep 17 00:00:00 2001 From: Andrew Still Date: Thu, 5 Mar 2026 22:17:53 -0500 Subject: [PATCH 11/12] chore: record breaking change for Property.Owner type --- .changes/unreleased/property-owner-type-change.yaml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changes/unreleased/property-owner-type-change.yaml diff --git a/.changes/unreleased/property-owner-type-change.yaml b/.changes/unreleased/property-owner-type-change.yaml new file mode 100644 index 00000000..1bb82842 --- /dev/null +++ b/.changes/unreleased/property-owner-type-change.yaml @@ -0,0 +1,2 @@ +kind: Removed +body: '[Breaking change] `Property.Owner` type changed from `EntityOwnerService` to `PropertyOwner` to support both service and team owners. Direct field access (e.g. `property.Owner.Aliases`) must be updated to go through the embedded type (e.g. `property.Owner.ServiceId.Aliases`).' From 06c519d27e14c751c6687aaeac3aee9934fbe926 Mon Sep 17 00:00:00 2001 From: Andrew Still Date: Thu, 5 Mar 2026 22:23:06 -0500 Subject: [PATCH 12/12] fix: set TotalCount correctly in GetProperties for Team and Service TotalCount is tagged graphql:"-" so it's never populated from the API. The recursive accumulation pattern (TotalCount += resp.TotalCount) always resulted in 0. Fix by setting TotalCount = len(Nodes) after all pages have been accumulated. Co-Authored-By: Claude Sonnet 4.6 --- property.go | 5 ++--- team.go | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/property.go b/property.go index 81084c84..24a2a2cb 100644 --- a/property.go +++ b/property.go @@ -273,11 +273,10 @@ func (service *Service) GetProperties(client *Client, variables *PayloadVariable service.Properties.PageInfo = q.Account.Service.Properties.PageInfo if service.Properties.PageInfo.HasNextPage { (*variables)["after"] = service.Properties.PageInfo.End - resp, err := service.GetProperties(client, variables) - if err != nil { + if _, err := service.GetProperties(client, variables); err != nil { return nil, err } - service.Properties.TotalCount += resp.TotalCount } + service.Properties.TotalCount = len(service.Properties.Nodes) return service.Properties, nil } diff --git a/team.go b/team.go index db05daaf..c9cf7d3c 100644 --- a/team.go +++ b/team.go @@ -217,12 +217,11 @@ func (team *Team) GetProperties(client *Client, variables *PayloadVariables) (*P team.Properties.PageInfo = q.Account.Team.Properties.PageInfo if team.Properties.PageInfo.HasNextPage { (*variables)["after"] = team.Properties.PageInfo.End - resp, err := team.GetProperties(client, variables) - if err != nil { + if _, err := team.GetProperties(client, variables); err != nil { return nil, err } - team.Properties.TotalCount += resp.TotalCount } + team.Properties.TotalCount = len(team.Properties.Nodes) return team.Properties, nil }