Skip to content

Commit 8026e80

Browse files
authored
Merge pull request #497 from hoxhaeris/add_repository_collaborator_functionality
peribolos: Add repository collaborator management support
2 parents 7dbcd42 + 00b2616 commit 8026e80

File tree

7 files changed

+1530
-16
lines changed

7 files changed

+1530
-16
lines changed

cmd/peribolos/main.go

Lines changed: 237 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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+
520564
func (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+
525591
func 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+
10631299
func 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

Comments
 (0)