From 1d8dbea4b781a1c607f3d5335e52afea1e575b41 Mon Sep 17 00:00:00 2001 From: Michael Burton Date: Tue, 25 Aug 2026 20:48:49 +0000 Subject: [PATCH 1/3] feat: emit PENDING status on invitation resources Invitation resources previously emitted STATUS_UNSPECIFIED via the deprecated trait-level WithStatus option, because the SDK had no way to express "pending" and NewUserTrait force-defaults an unset trait status to ENABLED. Now that the SDK has a PENDING value, emit it explicitly: - resource level via the non-deprecated WithResourceStatus, matching the accepted-member path in user.go - trait level via WithDetailedStatus, which is still required to override NewUserTrait's ENABLED default Both levels carry the invitation_status value (pending acceptance vs expired) as status details. Expired invitations stay PENDING rather than getting a distinct enum value: an expired invite is still not a usable account, and the details field preserves the distinction. Co-authored-by: c1-squire-dev[bot] Co-Authored-By: Claude Fable 5 --- pkg/connector/connector.go | 2 +- pkg/connector/invitation.go | 18 ++++++++---------- pkg/connector/invitation_test.go | 24 ++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 76577f5d..583168c4 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -68,7 +68,7 @@ var ( resourceTypeInvitation = &v2.ResourceType{ Id: "invitation", DisplayName: "Invitation", - // Invitations emit TRAIT_USER with UserTrait_Status_STATUS_UNSPECIFIED. + // Invitations emit TRAIT_USER with STATUS_PENDING. // Accepted members from user.go emit STATUS_ENABLED. Traits: []v2.ResourceType_Trait{ v2.ResourceType_TRAIT_USER, diff --git a/pkg/connector/invitation.go b/pkg/connector/invitation.go index fe49b5c6..7ed54fa6 100644 --- a/pkg/connector/invitation.go +++ b/pkg/connector/invitation.go @@ -64,19 +64,17 @@ func invitationToUserResource(invitation *github.Invitation, status string) (*v2 invitation.GetID(), []resourceSdk.UserTraitOption{ resourceSdk.WithEmail(invitation.GetEmail(), true), - // An invitation is a pending/expired user that must not be - // reported as enabled. WithResourceStatus cannot express this: - // NewUserTrait force-defaults an unset trait status to ENABLED, so - // migrating this line would flip the emitted status from - // UNSPECIFIED to ENABLED. Keep the deprecated trait option (which - // also mirrors UNSPECIFIED to the resource level) to preserve the - // exact status semantics. - //nolint:staticcheck // deliberate: WithResourceStatus would force the trait status to ENABLED; UNSPECIFIED must be preserved for invitations. - resourceSdk.WithStatus(v2.UserTrait_Status_STATUS_UNSPECIFIED), + // Set explicitly: NewUserTrait defaults an unset trait status to + // ENABLED, which an unaccepted invitation is not. + //nolint:staticcheck // trait status is deprecated but must be set to override the ENABLED default. + resourceSdk.WithDetailedStatus(v2.UserTrait_Status_STATUS_PENDING, status), resourceSdk.WithUserLogin(login), }, - // profile has moved from UserTrait to a Resource-level attribute. + // profile and status have moved from UserTrait to Resource-level + // attributes. Expired invitations stay PENDING - they are still not a + // usable account - and carry the distinction in the status details. resourceSdk.WithResourceProfile(profile), + resourceSdk.WithResourceStatus(v2.Status_RESOURCE_STATUS_PENDING, status), ) if err != nil { return nil, err diff --git a/pkg/connector/invitation_test.go b/pkg/connector/invitation_test.go index 9f79e299..dea6e460 100644 --- a/pkg/connector/invitation_test.go +++ b/pkg/connector/invitation_test.go @@ -199,6 +199,7 @@ func TestInvitationListPagination(t *testing.T) { pendingCreated1.Add(invitationLifetime).UTC().Format(time.RFC3339), aliceProfile["invitation_expires_at"], ) + requireInvitationPending(t, byID["1001"], invitationStatusPendingAcceptance) // Expired resources carry status=expired and expires_at = failed_at. daveProfile := invitationProfile(t, byID["2001"]) @@ -207,6 +208,9 @@ func TestInvitationListPagination(t *testing.T) { expiredFailedAt1.UTC().Format(time.RFC3339), daveProfile["invitation_expires_at"], ) + // An expired invitation is still not a usable account, so it stays + // PENDING at both levels; only the details distinguish it. + requireInvitationPending(t, byID["2001"], invitationStatusExpired) }) t.Run("pending 404 falls through to failed", func(t *testing.T) { @@ -222,6 +226,7 @@ func TestInvitationListPagination(t *testing.T) { require.Equal(t, "2001", got[0].Id.Resource) require.Equal(t, invitationStatusExpired, invitationProfile(t, got[0])["invitation_status"]) + requireInvitationPending(t, got[0], invitationStatusExpired) }) t.Run("failed 404 terminates cleanly", func(t *testing.T) { @@ -236,6 +241,7 @@ func TestInvitationListPagination(t *testing.T) { require.Len(t, got, 2) require.Equal(t, invitationStatusPendingAcceptance, invitationProfile(t, got[0])["invitation_status"]) + requireInvitationPending(t, got[0], invitationStatusPendingAcceptance) }) t.Run("both endpoints empty terminates without API errors", func(t *testing.T) { @@ -262,3 +268,21 @@ func invitationProfile(t *testing.T, r *v2.Resource) map[string]any { require.NotNil(t, profile) return profile.AsMap() } + +// requireInvitationPending asserts that an invitation resource reports PENDING +// at both the resource level and the (deprecated) user-trait level, with +// details naming which flavor of pending it is. +func requireInvitationPending(t *testing.T, r *v2.Resource, wantDetails string) { + t.Helper() + + require.Equal(t, v2.Status_RESOURCE_STATUS_PENDING, r.GetStatus().GetStatus()) + require.Equal(t, wantDetails, r.GetStatus().GetDetails()) + + ut, err := resourceSdk.GetUserTrait(r) + require.NoError(t, err) + require.NotNil(t, ut) + //nolint:staticcheck // asserting the deprecated trait status is the point of this check. + require.Equal(t, v2.UserTrait_Status_STATUS_PENDING, ut.GetStatus().GetStatus()) + //nolint:staticcheck // asserting the deprecated trait status is the point of this check. + require.Equal(t, wantDetails, ut.GetStatus().GetDetails()) +} From 995a4a1ab208c80e379e0079b49a96bd3d2ce493 Mon Sep 17 00:00:00 2001 From: Michael Burton Date: Mon, 31 Aug 2026 23:39:13 +0000 Subject: [PATCH 2/3] docs: pending invitations now surface as Pending, not Unspecified Co-authored-by: c1-squire-dev[bot] Co-Authored-By: Claude Fable 5 --- docs/connector.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/connector.mdx b/docs/connector.mdx index b44aacb2..8bf5574b 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -23,7 +23,7 @@ Use this integration if your organization accesses GitHub at `github.com`. If yo | GitHub Apps (NHI) | | | | Secrets - API keys | | | -The GitHub connector supports [automatic account provisioning and deprovisioning](/product/admin/account-provisioning). New accounts will send an invitation to the account owner; if an invitation is pending, the account status will be shown as **Unspecified**. +The GitHub connector supports [automatic account provisioning and deprovisioning](/product/admin/account-provisioning). New accounts will send an invitation to the account owner; while an invitation is pending, the account status will be shown as **Pending**. Expired invitations also report **Pending**, distinguished by `invitation_expired` in the status details. Repository permissions that are inherited through team membership are labeled as such on the relevant entitlement's **Grants** tab in the C1 web app. From 487f98d99e30ef0743a5b07b9fe5ce821a59cba1 Mon Sep 17 00:00:00 2001 From: Michael Burton Date: Tue, 1 Sep 2026 17:36:34 +0000 Subject: [PATCH 3/3] refactor: drop the trait-level status; resource status is the contract Consumers resolve status resource-first via GetStatus, which never consults the trait when the resource status is set. Audited every reader in baton-sdk and c1: none reads the trait directly. The trait falls to NewUserTrait's ENABLED default, pinned in tests as a known artifact readers must not consult. Co-authored-by: c1-squire-dev[bot] Co-Authored-By: Claude Fable 5 --- pkg/connector/invitation.go | 4 ---- pkg/connector/invitation_test.go | 13 +++++++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/pkg/connector/invitation.go b/pkg/connector/invitation.go index 7ed54fa6..38ef9575 100644 --- a/pkg/connector/invitation.go +++ b/pkg/connector/invitation.go @@ -64,10 +64,6 @@ func invitationToUserResource(invitation *github.Invitation, status string) (*v2 invitation.GetID(), []resourceSdk.UserTraitOption{ resourceSdk.WithEmail(invitation.GetEmail(), true), - // Set explicitly: NewUserTrait defaults an unset trait status to - // ENABLED, which an unaccepted invitation is not. - //nolint:staticcheck // trait status is deprecated but must be set to override the ENABLED default. - resourceSdk.WithDetailedStatus(v2.UserTrait_Status_STATUS_PENDING, status), resourceSdk.WithUserLogin(login), }, // profile and status have moved from UserTrait to Resource-level diff --git a/pkg/connector/invitation_test.go b/pkg/connector/invitation_test.go index dea6e460..8fd3beea 100644 --- a/pkg/connector/invitation_test.go +++ b/pkg/connector/invitation_test.go @@ -278,11 +278,16 @@ func requireInvitationPending(t *testing.T, r *v2.Resource, wantDetails string) require.Equal(t, v2.Status_RESOURCE_STATUS_PENDING, r.GetStatus().GetStatus()) require.Equal(t, wantDetails, r.GetStatus().GetDetails()) + // Consumers resolve status resource-first via GetStatus; the trait-level + // status is left to NewUserTrait's ENABLED force-default and must not be + // read directly. + resolved := resourceSdk.GetStatus(r) + require.Equal(t, v2.Status_RESOURCE_STATUS_PENDING, resolved.GetStatus()) + require.Equal(t, wantDetails, resolved.GetDetails()) + ut, err := resourceSdk.GetUserTrait(r) require.NoError(t, err) require.NotNil(t, ut) - //nolint:staticcheck // asserting the deprecated trait status is the point of this check. - require.Equal(t, v2.UserTrait_Status_STATUS_PENDING, ut.GetStatus().GetStatus()) - //nolint:staticcheck // asserting the deprecated trait status is the point of this check. - require.Equal(t, wantDetails, ut.GetStatus().GetDetails()) + //nolint:staticcheck // pins the known force-default artifact so a change to it is caught. + require.Equal(t, v2.UserTrait_Status_STATUS_ENABLED, ut.GetStatus().GetStatus()) }