diff --git a/CHANGELOG.md b/CHANGELOG.md index 438117e69..5e185cc30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # UNRELEASED +## Deprecations +* The `Sourceable` field has been deprecated on `RunTrigger`. Instead, use `SourceableChoice` to locate the non-empty field representing the actual sourceable value by @brandonc [#816](https://github.com/hashicorp/go-tfe/pull/816) + +## Features +* Adds `LockedBy` relationship field to `Workspace` by @brandonc [#816](https://github.com/hashicorp/go-tfe/pull/816) +* Adds `CreatedBy` relationship field to `TeamToken`, `UserToken`, and `OrganizationToken` by @brandonc [#816](https://github.com/hashicorp/go-tfe/pull/816) + # v1.40.0 ## Bug Fixes @@ -9,7 +16,7 @@ * Add organization scope field for oauth clients by @Netra2104 [#812](https://github.com/hashicorp/go-tfe/pull/812) * Added BETA support for including `projects` relationship to oauth_client on create by @Netra2104 [#806](https://github.com/hashicorp/go-tfe/pull/806) * Added BETA method `AddProjects` and `RemoveProjects` for attaching/detaching oauth_client to projects by Netra2104 [#806](https://github.com/hashicorp/go-tfe/pull/806) -* Adds a missing interface `WorkspaceResources` and the `List` method by @stefan-kiss [Issue#754](https://github.com/hashicorp/go-tfe/issues/754) +* Adds a missing interface `WorkspaceResources` and the `List` method by @stefan-kiss [Issue#754](https://github.com/hashicorp/go-tfe/issues/754) # v1.39.2 diff --git a/go.mod b/go.mod index a5817af04..3101d24c7 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/hashicorp/go-slug v0.13.2 github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/go-version v1.6.0 - github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d + github.com/hashicorp/jsonapi v1.2.0 github.com/stretchr/testify v1.8.4 golang.org/x/sync v0.5.0 golang.org/x/time v0.4.0 diff --git a/go.sum b/go.sum index bb47f6fce..a61b158d0 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,8 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d h1:9ARUJJ1VVynB176G1HCwleORqCaXm/Vx0uUi0dL26I0= -github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d/go.mod h1:Yog5+CPEM3c99L1CL2CFCYoSzgWm5vTU58idbRUaLik= +github.com/hashicorp/jsonapi v1.2.0 h1:ezDCzOFsKTL+KxVQuA1rNxkIGTvZph1rNu8kT5A8trI= +github.com/hashicorp/jsonapi v1.2.0/go.mod h1:Yog5+CPEM3c99L1CL2CFCYoSzgWm5vTU58idbRUaLik= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= diff --git a/helper_test.go b/helper_test.go index 4a731aaf2..4508213c4 100644 --- a/helper_test.go +++ b/helper_test.go @@ -22,6 +22,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "runtime" "strings" "testing" @@ -2851,6 +2852,53 @@ func betaFeaturesEnabled() bool { return os.Getenv("ENABLE_BETA") == "1" } +// isEmpty gets whether the specified object is considered empty or not. +func isEmpty(object interface{}) bool { + // get nil case out of the way + if object == nil { + return true + } + + objValue := reflect.ValueOf(object) + + switch objValue.Kind() { + // collection types are empty when they have no element + case reflect.Chan, reflect.Map, reflect.Slice: + return objValue.Len() == 0 + // pointers are empty if nil or if the value they point to is empty + case reflect.Ptr: + if objValue.IsNil() { + return true + } + deref := objValue.Elem().Interface() + return isEmpty(deref) + // for all other types, compare against the zero value + // array types are empty when they match their zero-initialized state + default: + zero := reflect.Zero(objValue.Type()) + return reflect.DeepEqual(object, zero.Interface()) + } +} + +// requireExactlyOneNotEmpty accepts any number of values and calls t.Fatal if +// less or more than one is empty. +func requireExactlyOneNotEmpty(t *testing.T, v ...any) { + if len(v) == 0 { + t.Fatal("Expected some values for requireExactlyOneNotEmpty, but received none") + } + + empty := 0 + for _, value := range v { + if isEmpty(value) { + empty += 1 + } + } + + if empty != len(v)-1 { + t.Fatalf("Expected exactly one value to not be empty, but found %d empty values", empty) + } +} + // Useless key but enough to pass validation in the API const testGpgArmor string = ` -----BEGIN PGP PUBLIC KEY BLOCK----- diff --git a/organization_token.go b/organization_token.go index 2058f4695..68dd02c25 100644 --- a/organization_token.go +++ b/organization_token.go @@ -39,12 +39,13 @@ type organizationTokens struct { // OrganizationToken represents a Terraform Enterprise organization token. type OrganizationToken struct { - ID string `jsonapi:"primary,authentication-tokens"` - CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` - Description string `jsonapi:"attr,description"` - LastUsedAt time.Time `jsonapi:"attr,last-used-at,iso8601"` - Token string `jsonapi:"attr,token"` - ExpiredAt time.Time `jsonapi:"attr,expired-at,iso8601"` + ID string `jsonapi:"primary,authentication-tokens"` + CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` + Description string `jsonapi:"attr,description"` + LastUsedAt time.Time `jsonapi:"attr,last-used-at,iso8601"` + Token string `jsonapi:"attr,token"` + ExpiredAt time.Time `jsonapi:"attr,expired-at,iso8601"` + CreatedBy *CreatedByChoice `jsonapi:"polyrelation,created-by"` } // OrganizationTokenCreateOptions contains the options for creating an organization token. diff --git a/organization_token_integration_test.go b/organization_token_integration_test.go index c72d1de0d..b84d8ece0 100644 --- a/organization_token_integration_test.go +++ b/organization_token_integration_test.go @@ -24,6 +24,7 @@ func TestOrganizationTokensCreate(t *testing.T) { ot, err := client.OrganizationTokens.Create(ctx, orgTest.Name) require.NoError(t, err) require.NotEmpty(t, ot.Token) + requireExactlyOneNotEmpty(t, ot.CreatedBy.Organization, ot.CreatedBy.Team, ot.CreatedBy.User) tkToken = ot.Token }) diff --git a/run_trigger.go b/run_trigger.go index 1f42a3e57..c12e9b964 100644 --- a/run_trigger.go +++ b/run_trigger.go @@ -43,17 +43,23 @@ type RunTriggerList struct { Items []*RunTrigger } +// SourceableChoice is a choice type struct that represents the possible values +// within a polymorphic relation. If a value is available, exactly one field +// will be non-nil. +type SourceableChoice struct { + Workspace *Workspace +} + // RunTrigger represents a run trigger. type RunTrigger struct { ID string `jsonapi:"primary,run-triggers"` CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` SourceableName string `jsonapi:"attr,sourceable-name"` WorkspaceName string `jsonapi:"attr,workspace-name"` - - // Relations - // TODO: this will eventually need to be polymorphic - Sourceable *Workspace `jsonapi:"relation,sourceable"` - Workspace *Workspace `jsonapi:"relation,workspace"` + // DEPRECATED. The sourceable field is polymorphic. Use SourceableChoice instead. + Sourceable *Workspace `jsonapi:"relation,sourceable"` + SourceableChoice *SourceableChoice `jsonapi:"polyrelation,sourceable"` + Workspace *Workspace `jsonapi:"relation,workspace"` } // https://developer.hashicorp.com/terraform/cloud-docs/api-docs/run-triggers#query-parameters diff --git a/run_trigger_integration_test.go b/run_trigger_integration_test.go index e5630583b..a13055a13 100644 --- a/run_trigger_integration_test.go +++ b/run_trigger_integration_test.go @@ -129,7 +129,9 @@ func TestRunTriggerList(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, rtl.Items) require.NotNil(t, rtl.Items[0].Sourceable) - assert.NotEmpty(t, rtl.Items[0].Sourceable.Name) + assert.NotEmpty(t, rtl.Items[0].Sourceable) + assert.NotNil(t, rtl.Items[0].SourceableChoice.Workspace) + assert.NotEmpty(t, rtl.Items[0].SourceableChoice.Workspace) }) t.Run("with a RunTriggerType that does not return included data", func(t *testing.T) { diff --git a/team_token.go b/team_token.go index 3cf636086..31c586021 100644 --- a/team_token.go +++ b/team_token.go @@ -39,12 +39,13 @@ type teamTokens struct { // TeamToken represents a Terraform Enterprise team token. type TeamToken struct { - ID string `jsonapi:"primary,authentication-tokens"` - CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` - Description string `jsonapi:"attr,description"` - LastUsedAt time.Time `jsonapi:"attr,last-used-at,iso8601"` - Token string `jsonapi:"attr,token"` - ExpiredAt time.Time `jsonapi:"attr,expired-at,iso8601"` + ID string `jsonapi:"primary,authentication-tokens"` + CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` + Description string `jsonapi:"attr,description"` + LastUsedAt time.Time `jsonapi:"attr,last-used-at,iso8601"` + Token string `jsonapi:"attr,token"` + ExpiredAt time.Time `jsonapi:"attr,expired-at,iso8601"` + CreatedBy *CreatedByChoice `jsonapi:"polyrelation,created-by"` } // TeamTokenCreateOptions contains the options for creating a team token. diff --git a/team_token_integration_test.go b/team_token_integration_test.go index fa8433fc9..05ca77e11 100644 --- a/team_token_integration_test.go +++ b/team_token_integration_test.go @@ -24,6 +24,8 @@ func TestTeamTokensCreate(t *testing.T) { tt, err := client.TeamTokens.Create(ctx, tmTest.ID) require.NoError(t, err) require.NotEmpty(t, tt.Token) + require.NotEmpty(t, tt.CreatedBy) + requireExactlyOneNotEmpty(t, tt.CreatedBy.Organization, tt.CreatedBy.Team, tt.CreatedBy.User) tmToken = tt.Token }) diff --git a/user_token.go b/user_token.go index 2251aae33..6672412b5 100644 --- a/user_token.go +++ b/user_token.go @@ -43,14 +43,24 @@ type UserTokenList struct { Items []*UserToken } +// CreatedByChoice is a choice type struct that represents the possible values +// within a polymorphic relation. If a value is available, exactly one field +// will be non-nil. +type CreatedByChoice struct { + Organization *Organization + Team *Team + User *User +} + // UserToken represents a Terraform Enterprise user token. type UserToken struct { - ID string `jsonapi:"primary,authentication-tokens"` - CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` - Description string `jsonapi:"attr,description"` - LastUsedAt time.Time `jsonapi:"attr,last-used-at,iso8601"` - Token string `jsonapi:"attr,token"` - ExpiredAt time.Time `jsonapi:"attr,expired-at,iso8601"` + ID string `jsonapi:"primary,authentication-tokens"` + CreatedAt time.Time `jsonapi:"attr,created-at,iso8601"` + Description string `jsonapi:"attr,description"` + LastUsedAt time.Time `jsonapi:"attr,last-used-at,iso8601"` + Token string `jsonapi:"attr,token"` + ExpiredAt time.Time `jsonapi:"attr,expired-at,iso8601"` + CreatedBy *CreatedByChoice `jsonapi:"polyrelation,created-by"` } // UserTokenCreateOptions contains the options for creating a user token. diff --git a/user_token_integration_test.go b/user_token_integration_test.go index 09c101b84..1eb95eff6 100644 --- a/user_token_integration_test.go +++ b/user_token_integration_test.go @@ -6,10 +6,11 @@ package tfe import ( "context" "fmt" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "testing" "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // TestUserTokens_List tests listing user tokens @@ -122,6 +123,8 @@ func TestUserTokens_Read(t *testing.T) { // object. Empty that out for comparison token.Token = "" assert.Equal(t, token, to) + + requireExactlyOneNotEmpty(t, token.CreatedBy.Organization, token.CreatedBy.Team, token.CreatedBy.User) }) } diff --git a/workspace.go b/workspace.go index 1470e5599..728db37ca 100644 --- a/workspace.go +++ b/workspace.go @@ -114,6 +114,15 @@ type WorkspaceList struct { Items []*Workspace } +// LockedByChoice is a choice type struct that represents the possible values +// within a polymorphic relation. If a value is available, exactly one field +// will be non-nil. +type LockedByChoice struct { + Run *Run + User *User + Team *Team +} + // Workspace represents a Terraform Enterprise workspace. type Workspace struct { ID string `jsonapi:"primary,workspaces"` @@ -164,6 +173,7 @@ type Workspace struct { Project *Project `jsonapi:"relation,project"` Tags []*Tag `jsonapi:"relation,tags"` CurrentConfigurationVersion *ConfigurationVersion `jsonapi:"relation,current-configuration-version,omitempty"` + LockedBy *LockedByChoice `jsonapi:"polyrelation,locked-by"` // Links Links map[string]interface{} `jsonapi:"links,omitempty"` diff --git a/workspace_integration_test.go b/workspace_integration_test.go index d100e1a98..7cb7d7583 100644 --- a/workspace_integration_test.go +++ b/workspace_integration_test.go @@ -1874,9 +1874,15 @@ func TestWorkspacesLock(t *testing.T) { t.Cleanup(wTestCleanup) t.Run("with valid options", func(t *testing.T) { + require.Empty(t, wTest.LockedBy) + w, err := client.Workspaces.Lock(ctx, wTest.ID, WorkspaceLockOptions{}) require.NoError(t, err) assert.True(t, w.Locked) + + require.NoError(t, err) + require.NotEmpty(t, w.LockedBy) + requireExactlyOneNotEmpty(t, w.LockedBy.Run, w.LockedBy.Team, w.LockedBy.User) }) t.Run("when workspace is already locked", func(t *testing.T) {