@@ -57,6 +57,7 @@ type options struct {
5757 fixTeams bool
5858 fixTeamRepos bool
5959 fixRepos bool
60+ fixCollaborators bool
6061 ignoreInvitees bool
6162 ignoreSecretTeams bool
6263 allowRepoArchival bool
@@ -92,6 +93,7 @@ func (o *options) parseArgs(flags *flag.FlagSet, args []string) error {
9293 flags .BoolVar (& o .fixTeamMembers , "fix-team-members" , false , "Add/remove team members if set" )
9394 flags .BoolVar (& o .fixTeamRepos , "fix-team-repos" , false , "Add/remove team permissions on repos if set" )
9495 flags .BoolVar (& o .fixRepos , "fix-repos" , false , "Create/update repositories if set" )
96+ flags .BoolVar (& o .fixCollaborators , "fix-collaborators" , false , "Add/remove/update repository collaborators if set" )
9597 flags .BoolVar (& o .allowRepoArchival , "allow-repo-archival" , false , "If set, archiving repos is allowed while updating repos" )
9698 flags .BoolVar (& o .allowRepoPublish , "allow-repo-publish" , false , "If set, making private repos public is allowed while updating repos" )
9799 flags .StringVar (& o .logLevel , "log-level" , logrus .InfoLevel .String (), fmt .Sprintf ("Logging level, one of %v" , logrus .AllLevels ))
@@ -206,6 +208,7 @@ type dumpClient interface {
206208 ListTeamReposBySlug (org , teamSlug string ) ([]github.Repo , error )
207209 GetRepo (owner , name string ) (github.FullRepo , error )
208210 GetRepos (org string , isUser bool ) ([]github.Repo , error )
211+ ListDirectCollaboratorsWithPermissions (org , repo string ) (map [string ]github.RepoPermissionLevel , error )
209212 BotUser () (* github.UserData , error )
210213}
211214
@@ -357,7 +360,8 @@ func dumpOrgConfig(client dumpClient, orgName string, ignoreSecretTeams bool, ap
357360 return nil , fmt .Errorf ("failed to get repo: %w" , err )
358361 }
359362 logrus .WithField ("repo" , full .FullName ).Debug ("Recording repo." )
360- out .Repos [full .Name ] = org .PruneRepoDefaults (org.Repo {
363+
364+ repoConfig := org .PruneRepoDefaults (org.Repo {
361365 Description : & full .Description ,
362366 HomePage : & full .Homepage ,
363367 Private : & full .Private ,
@@ -369,7 +373,16 @@ func dumpOrgConfig(client dumpClient, orgName string, ignoreSecretTeams bool, ap
369373 AllowRebaseMerge : & full .AllowRebaseMerge ,
370374 Archived : & full .Archived ,
371375 DefaultBranch : & full .DefaultBranch ,
376+ // Collaborators will be set conditionally below
372377 })
378+
379+ // Get direct collaborators (explicitly added) via GraphQL
380+ if directCollabs , err := client .ListDirectCollaboratorsWithPermissions (orgName , repo .Name ); err != nil {
381+ logrus .WithError (err ).Warnf ("Failed to list direct collaborators for %s/%s" , orgName , repo .Name )
382+ } else if len (directCollabs ) > 0 {
383+ repoConfig .Collaborators = directCollabs
384+ }
385+ out .Repos [full .Name ] = repoConfig
373386 }
374387
375388 return & out , nil
@@ -517,11 +530,64 @@ func normalize(s sets.Set[string]) sets.Set[string] {
517530 return out
518531}
519532
533+ // collaboratorInfo holds permission and original username for a normalized user
534+ type collaboratorInfo struct {
535+ permission github.RepoPermissionLevel
536+ originalName string
537+ }
538+
539+ // collaboratorMap manages collaborator usernames to permissions with normalization support
540+ type collaboratorMap struct {
541+ collaborators map [string ]collaboratorInfo // normalized_username -> collaborator info
542+ }
543+
544+ // newCollaboratorMap creates a collaborator map from a raw username->permission map
545+ func newCollaboratorMap (raw map [string ]github.RepoPermissionLevel ) collaboratorMap {
546+ cm := collaboratorMap {
547+ collaborators : make (map [string ]collaboratorInfo , len (raw )),
548+ }
549+ for username , permission := range raw {
550+ normalized := github .NormLogin (username )
551+ cm .collaborators [normalized ] = collaboratorInfo {
552+ permission : permission ,
553+ originalName : username ,
554+ }
555+ }
556+ return cm
557+ }
558+
559+ // originalName returns the original casing for a normalized username
560+ func (cm collaboratorMap ) originalName (normalizedUser string ) string {
561+ return cm .collaborators [normalizedUser ].originalName
562+ }
563+
520564func (m * memberships ) normalize () {
521565 m .members = normalize (m .members )
522566 m .super = normalize (m .super )
523567}
524568
569+ // repoInvitationsData returns pending repository invitations with both permissions and IDs
570+ func repoInvitationsData (client collaboratorClient , orgName , repoName string ) (map [string ]github.RepoPermissionLevel , map [string ]int , error ) {
571+ permissions := map [string ]github.RepoPermissionLevel {}
572+ invitationIDs := map [string ]int {}
573+
574+ is , err := client .ListRepoInvitations (orgName , repoName )
575+ if err != nil {
576+ return nil , nil , err
577+ }
578+
579+ for _ , i := range is {
580+ if i .Invitee == nil || i .Invitee .Login == "" {
581+ continue
582+ }
583+ normalizedLogin := github .NormLogin (i .Invitee .Login )
584+ permissions [normalizedLogin ] = i .Permission
585+ invitationIDs [normalizedLogin ] = i .InvitationID
586+ }
587+
588+ return permissions , invitationIDs , nil
589+ }
590+
525591func configureMembers (have , want memberships , invitees sets.Set [string ], adder func (user string , super bool ) error , remover func (user string ) error ) error {
526592 have .normalize ()
527593 want .normalize ()
@@ -830,6 +896,17 @@ func configureOrg(opt options, client github.Client, orgName string, orgConfig o
830896 return fmt .Errorf ("failed to configure %s repos: %w" , orgName , err )
831897 }
832898
899+ // Configure repository collaborators
900+ if ! opt .fixCollaborators {
901+ logrus .Info ("Skipping repository collaborators configuration" )
902+ } else {
903+ for repoName , repo := range orgConfig .Repos {
904+ if err := configureCollaborators (client , orgName , repoName , repo ); err != nil {
905+ return fmt .Errorf ("failed to configure %s/%s collaborators: %w" , orgName , repoName , err )
906+ }
907+ }
908+ }
909+
833910 if ! opt .fixTeams {
834911 logrus .Infof ("Skipping team and team member configuration" )
835912 return nil
@@ -1060,6 +1137,165 @@ func configureRepos(opt options, client repoClient, orgName string, orgConfig or
10601137 return utilerrors .NewAggregate (allErrors )
10611138}
10621139
1140+ type collaboratorClient interface {
1141+ ListCollaborators (org , repo string ) ([]github.User , error )
1142+ ListDirectCollaboratorsWithPermissions (org , repo string ) (map [string ]github.RepoPermissionLevel , error )
1143+ AddCollaborator (org , repo , user string , permission github.RepoPermissionLevel ) error
1144+ UpdateCollaborator (org , repo , user string , permission github.RepoPermissionLevel ) error
1145+ UpdateCollaboratorRepoInvitation (org , repo string , invitationID int , permission github.RepoPermissionLevel ) error
1146+ DeleteCollaboratorRepoInvitation (org , repo string , invitationID int ) error
1147+ RemoveCollaborator (org , repo , user string ) error
1148+ UpdateCollaboratorPermission (org , repo , user string , permission github.RepoPermissionLevel ) error
1149+ ListRepoInvitations (org , repo string ) ([]github.CollaboratorRepoInvitation , error )
1150+ }
1151+
1152+ // configureCollaborators updates the list of repository collaborators when necessary
1153+ // This function uses GraphQL to get only direct collaborators (explicitly added) and manages them
1154+ // according to the configuration. Org members with inherited access are not affected.
1155+ func configureCollaborators (client collaboratorClient , orgName , repoName string , repo org.Repo ) error {
1156+ want := repo .Collaborators
1157+ if want == nil {
1158+ want = map [string ]github.RepoPermissionLevel {}
1159+ }
1160+
1161+ // Get current direct collaborators (only explicitly added ones) with their permissions via GraphQL
1162+ currentCollaboratorsRaw , err := client .ListDirectCollaboratorsWithPermissions (orgName , repoName )
1163+ if err != nil {
1164+ return fmt .Errorf ("failed to list direct collaborators for %s/%s: %w" , orgName , repoName , err )
1165+ }
1166+ logrus .Debugf ("Found %d direct collaborators" , len (currentCollaboratorsRaw ))
1167+
1168+ // Get pending repository invitations with their permission levels and IDs
1169+ pendingInvitations , pendingInvitationIDs , err := repoInvitationsData (client , orgName , repoName )
1170+ if err != nil {
1171+ logrus .WithError (err ).Warnf ("Failed to list repository invitations for %s/%s, may send duplicate invitations" , orgName , repoName )
1172+ pendingInvitations = map [string ]github.RepoPermissionLevel {} // Continue with empty map
1173+ pendingInvitationIDs = map [string ]int {} // Continue with empty map
1174+ }
1175+
1176+ // Create combined map of current direct collaborators + pending invitations
1177+ // This treats pending invitations as current collaborators for removal purposes
1178+ combinedCollaboratorsRaw := make (map [string ]github.RepoPermissionLevel )
1179+ for user , permission := range currentCollaboratorsRaw {
1180+ combinedCollaboratorsRaw [user ] = permission
1181+ }
1182+ for user , permission := range pendingInvitations {
1183+ // Add pending invitations to our combined view (normalized usernames)
1184+ if _ , exists := combinedCollaboratorsRaw [user ]; ! exists {
1185+ combinedCollaboratorsRaw [user ] = permission
1186+ }
1187+ }
1188+
1189+ currentCollaborators := newCollaboratorMap (currentCollaboratorsRaw )
1190+ combinedCollaborators := newCollaboratorMap (combinedCollaboratorsRaw )
1191+
1192+ // Determine what actions to take
1193+ actions := map [string ]github.RepoPermissionLevel {}
1194+
1195+ // Process wanted collaborators using normalized approach
1196+ wantedCollaborators := newCollaboratorMap (want )
1197+
1198+ // Add or update permissions for users in our config
1199+ for normalizedUser , collaboratorInfo := range wantedCollaborators .collaborators {
1200+ wantPermission := collaboratorInfo .permission
1201+ wantUser := wantedCollaborators .originalName (normalizedUser )
1202+
1203+ if currentInfo , exists := currentCollaborators .collaborators [normalizedUser ]; exists && currentInfo .permission == wantPermission {
1204+ // Permission is already correct
1205+ continue
1206+ }
1207+
1208+ // Check if this user already has a pending invitation with the same permission
1209+ if pendingPermission , hasPendingInvitation := pendingInvitations [normalizedUser ]; hasPendingInvitation && pendingPermission == wantPermission {
1210+ logrus .Infof ("Waiting for %s to accept invitation to %s/%s with %s permission" , wantUser , orgName , repoName , wantPermission )
1211+ continue
1212+ }
1213+
1214+ // Need to create or update this permission
1215+ actions [wantUser ] = wantPermission
1216+
1217+ // Determine the appropriate action and log message
1218+ if _ , exists := currentCollaborators .collaborators [normalizedUser ]; exists {
1219+ logrus .Infof ("Will update collaborator %s with %s permission" , wantUser , wantPermission )
1220+ } else if pendingPermission , hasPendingInvitation := pendingInvitations [normalizedUser ]; hasPendingInvitation {
1221+ logrus .Infof ("Will update pending invitation for %s from %s to %s permission" , wantUser , pendingPermission , wantPermission )
1222+ } else {
1223+ logrus .Infof ("Will add collaborator %s with %s permission" , wantUser , wantPermission )
1224+ }
1225+ }
1226+
1227+ // Remove direct collaborators not in our config (including those with pending invitations)
1228+ // Since we only get direct collaborators via GraphQL, we can safely remove anyone not in config
1229+ for normalizedCurrentUser := range combinedCollaborators .collaborators {
1230+ // Check if this user (normalized) is in our wanted config
1231+ if _ , exists := wantedCollaborators .collaborators [normalizedCurrentUser ]; ! exists {
1232+ originalName := combinedCollaborators .originalName (normalizedCurrentUser )
1233+ actions [originalName ] = github .None
1234+
1235+ // Check if this is a pending invitation
1236+ if _ , isPending := pendingInvitations [normalizedCurrentUser ]; isPending {
1237+ logrus .Infof ("Will remove pending collaborator invitation for %s (not in config)" , originalName )
1238+ } else {
1239+ logrus .Infof ("Will remove direct collaborator %s (not in config)" , originalName )
1240+ }
1241+ }
1242+ }
1243+
1244+ // Execute the actions
1245+ var updateErrors []error
1246+ for user , permission := range actions {
1247+ var err error
1248+ switch permission {
1249+ case github .None :
1250+ // Determine the appropriate removal method based on whether this is a pending invitation
1251+ normalizedUser := github .NormLogin (user )
1252+ if invitationID , hasPendingInvitation := pendingInvitationIDs [normalizedUser ]; hasPendingInvitation {
1253+ // Use DeleteRepoInvitation (DELETE) for pending invitations with invitation ID
1254+ err = client .DeleteCollaboratorRepoInvitation (orgName , repoName , invitationID )
1255+ if err != nil {
1256+ logrus .WithError (err ).Warnf ("Failed to delete pending invitation for %s" , user )
1257+ } else {
1258+ logrus .Infof ("Deleted pending invitation for %s from %s/%s" , user , orgName , repoName )
1259+ }
1260+ } else {
1261+ // Use RemoveCollaborator (DELETE) for actual collaborators
1262+ err = client .RemoveCollaborator (orgName , repoName , user )
1263+ if err != nil {
1264+ logrus .WithError (err ).Warnf ("Failed to remove collaborator %s" , user )
1265+ } else {
1266+ logrus .Infof ("Removed collaborator %s from %s/%s" , user , orgName , repoName )
1267+ }
1268+ }
1269+ case github .Admin , github .Maintain , github .Triage , github .Write , github .Read :
1270+ // Determine the appropriate API call based on whether this is updating a pending invitation
1271+ normalizedUser := github .NormLogin (user )
1272+ if invitationID , hasPendingInvitation := pendingInvitationIDs [normalizedUser ]; hasPendingInvitation {
1273+ // Use UpdateRepoInvitation (PATCH) for pending invitations with invitation ID
1274+ err = client .UpdateCollaboratorRepoInvitation (orgName , repoName , invitationID , permission )
1275+ if err != nil {
1276+ logrus .WithError (err ).Warnf ("Failed to update pending invitation for %s to %s permission" , user , permission )
1277+ } else {
1278+ logrus .Infof ("Updated pending invitation for %s to %s permission on %s/%s" , user , permission , orgName , repoName )
1279+ }
1280+ } else {
1281+ // Use AddCollaborator (PUT) for new invitations or existing collaborators
1282+ err = client .AddCollaborator (orgName , repoName , user , permission )
1283+ if err != nil {
1284+ logrus .WithError (err ).Warnf ("Failed to set %s permission for collaborator %s" , permission , user )
1285+ } else {
1286+ logrus .Infof ("Set %s as %s collaborator on %s/%s" , user , permission , orgName , repoName )
1287+ }
1288+ }
1289+ }
1290+
1291+ if err != nil {
1292+ updateErrors = append (updateErrors , fmt .Errorf ("failed to update %s/%s collaborator %s to %s: %w" , orgName , repoName , user , permission , err ))
1293+ }
1294+ }
1295+
1296+ return utilerrors .NewAggregate (updateErrors )
1297+ }
1298+
10631299func configureTeamAndMembers (opt options , client github.Client , githubTeams map [string ]github.Team , name , orgName string , team org.Team , parent * int ) error {
10641300 gt , ok := githubTeams [name ]
10651301 if ! ok { // configureTeams is buggy if this is the case
0 commit comments