@@ -7,32 +7,62 @@ import (
77 "net/http"
88 "strconv"
99 "strings"
10+ "time"
1011
1112 v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
1213 "github.com/conductorone/baton-sdk/pkg/annotations"
1314 "github.com/conductorone/baton-sdk/pkg/connectorbuilder"
15+ "github.com/conductorone/baton-sdk/pkg/pagination"
1416 resourceSdk "github.com/conductorone/baton-sdk/pkg/types/resource"
1517 "github.com/google/go-github/v69/github"
1618 "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
1719 "go.uber.org/zap"
1820)
1921
20- func invitationToUserResource (invitation * github.Invitation ) (* v2.Resource , error ) {
22+ const (
23+ // Profile keys for invitation status metadata.
24+ invitationProfileKeyStatus = "invitation_status"
25+ invitationProfileKeyExpiresAt = "invitation_expires_at"
26+
27+ // Values exposed via invitation_status.
28+ invitationStatusPendingAcceptance = "invitation_pending_acceptance"
29+ invitationStatusExpired = "invitation_expired"
30+
31+ // Pagination bag states used to drive the two upstream listing endpoints.
32+ invitationStatePending = "invitation:pending"
33+ invitationStateFailed = "invitation:failed"
34+
35+ // GitHub returns the literal string "expired" in failed_reason when an
36+ // invitation has expired without being accepted.
37+ githubInvitationFailedReasonExpired = "expired"
38+
39+ // Organization invitations expire 7 days after creation.
40+ // https://github.blog/changelog/2020-02-05-self-expiring-repository-and-organization-invitations/
41+ invitationLifetime = 7 * 24 * time .Hour
42+ )
43+
44+ func invitationToUserResource (invitation * github.Invitation , status string ) (* v2.Resource , error ) {
2145 login := invitation .GetLogin ()
2246 if login == "" {
2347 login = invitation .GetEmail ()
2448 }
2549
50+ profile := map [string ]interface {}{
51+ "login" : login ,
52+ "inviter" : invitation .GetInviter ().GetLogin (),
53+ invitationProfileKeyStatus : status ,
54+ }
55+ if expiresAt , ok := invitationExpiresAt (invitation , status ); ok {
56+ profile [invitationProfileKeyExpiresAt ] = expiresAt .UTC ().Format (time .RFC3339 )
57+ }
58+
2659 ret , err := resourceSdk .NewUserResource (
2760 login ,
2861 resourceTypeInvitation ,
2962 invitation .GetID (),
3063 []resourceSdk.UserTraitOption {
3164 resourceSdk .WithEmail (invitation .GetEmail (), true ),
32- resourceSdk .WithUserProfile (map [string ]interface {}{
33- "login" : login ,
34- "inviter" : invitation .GetInviter ().GetLogin (),
35- }),
65+ resourceSdk .WithUserProfile (profile ),
3666 resourceSdk .WithStatus (v2 .UserTrait_Status_STATUS_UNSPECIFIED ),
3767 resourceSdk .WithUserLogin (login ),
3868 },
@@ -43,6 +73,22 @@ func invitationToUserResource(invitation *github.Invitation) (*v2.Resource, erro
4373 return ret , nil
4474}
4575
76+ // invitationExpiresAt returns the moment the invitation expired (for already
77+ // expired invitations) or will expire (for pending invitations). GitHub does
78+ // not surface an expires_at field on the org invitation payload, so for
79+ // pending invitations we derive it from created_at + 7 days.
80+ func invitationExpiresAt (invitation * github.Invitation , status string ) (time.Time , bool ) {
81+ if status == invitationStatusExpired {
82+ if t := invitation .GetFailedAt (); ! t .IsZero () {
83+ return t .Time , true
84+ }
85+ }
86+ if t := invitation .GetCreatedAt (); ! t .IsZero () {
87+ return t .Add (invitationLifetime ), true
88+ }
89+ return time.Time {}, false
90+ }
91+
4692type invitationResourceType struct {
4793 client * github.Client
4894 orgCache * orgNameCache
@@ -54,7 +100,6 @@ func (i *invitationResourceType) ResourceType(_ context.Context) *v2.ResourceTyp
54100}
55101
56102func (i * invitationResourceType ) List (ctx context.Context , parentID * v2.ResourceId , opts resourceSdk.SyncOpAttrs ) ([]* v2.Resource , * resourceSdk.SyncOpResults , error ) {
57- var annotations annotations.Annotations
58103 if parentID == nil {
59104 return nil , & resourceSdk.SyncOpResults {}, nil
60105 }
@@ -68,44 +113,106 @@ func (i *invitationResourceType) List(ctx context.Context, parentID *v2.Resource
68113 if err != nil {
69114 return nil , nil , err
70115 }
71- invitations , resp , err := i .client .Organizations .ListPendingOrgInvitations (ctx , orgName , & github.ListOptions {
116+
117+ listOpts := & github.ListOptions {
72118 Page : page ,
73119 PerPage : opts .PageToken .Size ,
74- })
75- if err != nil {
76- if isNotFoundError (resp ) {
77- return nil , & resourceSdk.SyncOpResults {}, nil
78- }
79- return nil , nil , wrapGitHubError (err , resp , "github-connector: failed to list pending org invitations" )
80120 }
81121
82- restApiRateLimit , err := extractRateLimitData ( resp )
83- if err != nil {
84- return nil , nil , err
85- }
122+ var (
123+ invitationResources [] * v2. Resource
124+ respAnnos annotations. Annotations
125+ )
86126
87- nextPage , _ , err := parseResp (resp )
88- if err != nil {
89- return nil , nil , err
90- }
127+ switch bag .ResourceTypeID () {
128+ case resourceTypeInvitation .Id :
129+ // First call: fan out into the two listing states. Pending is pushed
130+ // last so it is processed first; failed/expired runs after pending
131+ // fully drains.
132+ bag .Pop ()
133+ bag .Push (pagination.PageState {ResourceTypeID : invitationStateFailed })
134+ bag .Push (pagination.PageState {ResourceTypeID : invitationStatePending })
135+
136+ case invitationStatePending :
137+ invitations , resp , err := i .client .Organizations .ListPendingOrgInvitations (ctx , orgName , listOpts )
138+ if err != nil {
139+ if isNotFoundError (resp ) {
140+ if err := bag .Next ("" ); err != nil {
141+ return nil , nil , err
142+ }
143+ break
144+ }
145+ return nil , nil , wrapGitHubError (err , resp , "github-connector: failed to list pending org invitations" )
146+ }
91147
92- pageToken , err := bag .NextToken (nextPage )
93- if err != nil {
94- return nil , nil , err
95- }
148+ nextPage , annos , err := parseResp (resp )
149+ if err != nil {
150+ return nil , nil , err
151+ }
152+ respAnnos = annos
153+
154+ if err := bag .Next (nextPage ); err != nil {
155+ return nil , nil , err
156+ }
96157
97- invitationResources := make ([]* v2.Resource , 0 , len (invitations ))
98- for _ , invitation := range invitations {
99- ir , err := invitationToUserResource (invitation )
158+ invitationResources = make ([]* v2.Resource , 0 , len (invitations ))
159+ for _ , invitation := range invitations {
160+ ir , err := invitationToUserResource (invitation , invitationStatusPendingAcceptance )
161+ if err != nil {
162+ return nil , nil , err
163+ }
164+ invitationResources = append (invitationResources , ir )
165+ }
166+
167+ case invitationStateFailed :
168+ invitations , resp , err := i .client .Organizations .ListFailedOrgInvitations (ctx , orgName , listOpts )
169+ if err != nil {
170+ if isNotFoundError (resp ) {
171+ if err := bag .Next ("" ); err != nil {
172+ return nil , nil , err
173+ }
174+ break
175+ }
176+ return nil , nil , wrapGitHubError (err , resp , "github-connector: failed to list failed org invitations" )
177+ }
178+
179+ nextPage , annos , err := parseResp (resp )
100180 if err != nil {
101181 return nil , nil , err
102182 }
103- invitationResources = append (invitationResources , ir )
183+ respAnnos = annos
184+
185+ if err := bag .Next (nextPage ); err != nil {
186+ return nil , nil , err
187+ }
188+
189+ invitationResources = make ([]* v2.Resource , 0 , len (invitations ))
190+ for _ , invitation := range invitations {
191+ // The failed_invitations endpoint includes failures other than
192+ // expirations (e.g. user_was_inactive, unexpected_failure). Only
193+ // surface invitations that explicitly expired.
194+ if invitation .GetFailedReason () != githubInvitationFailedReasonExpired {
195+ continue
196+ }
197+ ir , err := invitationToUserResource (invitation , invitationStatusExpired )
198+ if err != nil {
199+ return nil , nil , err
200+ }
201+ invitationResources = append (invitationResources , ir )
202+ }
203+
204+ default :
205+ return nil , nil , fmt .Errorf ("github-connector: unexpected invitation page state %q" , bag .ResourceTypeID ())
104206 }
105- annotations .WithRateLimiting (restApiRateLimit )
207+
208+ pageToken , err := bag .Marshal ()
209+ if err != nil {
210+ return nil , nil , err
211+ }
212+
106213 return invitationResources , & resourceSdk.SyncOpResults {
107214 NextPageToken : pageToken ,
108- Annotations : annotations ,
215+ Annotations : respAnnos ,
109216 }, nil
110217}
111218
@@ -195,7 +302,7 @@ func (i *invitationResourceType) CreateAccount(
195302 var annotations annotations.Annotations
196303 annotations .WithRateLimiting (restApiRateLimit )
197304
198- r , err := invitationToUserResource (invitation )
305+ r , err := invitationToUserResource (invitation , invitationStatusPendingAcceptance )
199306 if err != nil {
200307 return nil , nil , nil , fmt .Errorf ("github-connectorv2: cannot create user resource: %w" , err )
201308 }
@@ -229,6 +336,12 @@ func (i *invitationResourceType) Delete(ctx context.Context, resourceId *v2.Reso
229336 resp , err = i .client .Organizations .CancelInvite (ctx , org , invitationID )
230337 if err == nil {
231338 isRemoved = true
339+ continue
340+ }
341+ if isNotFoundError (resp ) {
342+ // Invitation is already gone (expired or previously cancelled).
343+ // Desired state is achieved, so treat as success.
344+ isRemoved = true
232345 }
233346 }
234347
@@ -315,7 +428,7 @@ func (i *invitationResourceType) lookupPendingInvitation(ctx context.Context, or
315428 }
316429 for _ , inv := range invitations {
317430 if invitationMatches (inv , login , email ) {
318- return invitationToUserResource (inv )
431+ return invitationToUserResource (inv , invitationStatusPendingAcceptance )
319432 }
320433 }
321434 if resp .NextPage == 0 {
0 commit comments