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.
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..38ef9575 100644
--- a/pkg/connector/invitation.go
+++ b/pkg/connector/invitation.go
@@ -64,19 +64,13 @@ 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),
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..8fd3beea 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,26 @@ 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())
+
+ // 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 // pins the known force-default artifact so a change to it is caught.
+ require.Equal(t, v2.UserTrait_Status_STATUS_ENABLED, ut.GetStatus().GetStatus())
+}