@@ -58,6 +58,7 @@ type options struct {
5858 fixTeamRepos bool
5959 fixRepos bool
6060 fixCollaborators bool
61+ fixOrgRoles bool
6162 ignoreInvitees bool
6263 ignoreSecretTeams bool
6364 allowRepoArchival bool
@@ -94,6 +95,7 @@ func (o *options) parseArgs(flags *flag.FlagSet, args []string) error {
9495 flags .BoolVar (& o .fixTeamRepos , "fix-team-repos" , false , "Add/remove team permissions on repos if set" )
9596 flags .BoolVar (& o .fixRepos , "fix-repos" , false , "Create/update repositories if set" )
9697 flags .BoolVar (& o .fixCollaborators , "fix-collaborators" , false , "Add/remove/update repository collaborators if set" )
98+ flags .BoolVar (& o .fixOrgRoles , "fix-org-roles" , false , "Assign/remove organization roles to teams and users if set" )
9799 flags .BoolVar (& o .allowRepoArchival , "allow-repo-archival" , false , "If set, archiving repos is allowed while updating repos" )
98100 flags .BoolVar (& o .allowRepoPublish , "allow-repo-publish" , false , "If set, making private repos public is allowed while updating repos" )
99101 flags .StringVar (& o .logLevel , "log-level" , logrus .InfoLevel .String (), fmt .Sprintf ("Logging level, one of %v" , logrus .AllLevels ))
@@ -147,6 +149,10 @@ func (o *options) parseArgs(flags *flag.FlagSet, args []string) error {
147149 return fmt .Errorf ("--fix-team-repos requires --fix-teams" )
148150 }
149151
152+ if o .fixOrgRoles && ! o .fixTeams {
153+ return fmt .Errorf ("--fix-org-roles requires --fix-teams" )
154+ }
155+
150156 return nil
151157}
152158
@@ -209,6 +215,9 @@ type dumpClient interface {
209215 GetRepo (owner , name string ) (github.FullRepo , error )
210216 GetRepos (org string , isUser bool ) ([]github.Repo , error )
211217 ListDirectCollaboratorsWithPermissions (org , repo string ) (map [string ]github.RepoPermissionLevel , error )
218+ ListOrganizationRoles (org string ) ([]github.OrganizationRole , error )
219+ ListTeamsWithRole (org string , roleID int ) ([]github.OrganizationRoleAssignment , error )
220+ ListUsersWithRole (org string , roleID int ) ([]github.OrganizationRoleAssignment , error )
212221 BotUser () (* github.UserData , error )
213222}
214223
@@ -272,6 +281,7 @@ func dumpOrgConfig(client dumpClient, orgName string, ignoreSecretTeams bool, ap
272281 idMap := map [int ]org.Team {} // metadata for a team
273282 children := map [int ][]int {} // what children does it have
274283 var tops []int // what are the top-level teams
284+ slugToName := map [string ]string {}
275285
276286 for _ , t := range teams {
277287 logger := logrus .WithFields (logrus.Fields {"id" : t .ID , "name" : t .Name })
@@ -280,6 +290,7 @@ func dumpOrgConfig(client dumpClient, orgName string, ignoreSecretTeams bool, ap
280290 logger .Debug ("Ignoring secret team." )
281291 continue
282292 }
293+ slugToName [t .Slug ] = t .Name
283294 d := t .Description
284295 nt := org.Team {
285296 TeamMetadata : org.TeamMetadata {
@@ -385,6 +396,53 @@ func dumpOrgConfig(client dumpClient, orgName string, ignoreSecretTeams bool, ap
385396 out .Repos [full .Name ] = repoConfig
386397 }
387398
399+ // Dump organization roles
400+ roles , err := client .ListOrganizationRoles (orgName )
401+ if err != nil {
402+ return nil , fmt .Errorf ("failed to list organization roles: %w" , err )
403+ }
404+ logrus .Debugf ("Found %d organization roles" , len (roles ))
405+ if len (roles ) > 0 {
406+ out .Roles = make (map [string ]org.Role , len (roles ))
407+ }
408+ for _ , role := range roles {
409+ logrus .WithField ("role" , role .Name ).Debug ("Recording organization role." )
410+
411+ // Get teams with this role
412+ teamsWithRole , err := client .ListTeamsWithRole (orgName , role .ID )
413+ if err != nil {
414+ logrus .WithError (err ).Warnf ("Failed to list teams with role %s" , role .Name )
415+ continue
416+ }
417+
418+ // Get users with this role
419+ usersWithRole , err := client .ListUsersWithRole (orgName , role .ID )
420+ if err != nil {
421+ logrus .WithError (err ).Warnf ("Failed to list users with role %s" , role .Name )
422+ continue
423+ }
424+
425+ // Build team and user lists
426+ var teamSlugs []string
427+ for _ , team := range teamsWithRole {
428+ if name , ok := slugToName [team .Slug ]; ok {
429+ teamSlugs = append (teamSlugs , name )
430+ } else {
431+ teamSlugs = append (teamSlugs , team .Slug )
432+ }
433+ }
434+
435+ var userLogins []string
436+ for _ , user := range usersWithRole {
437+ userLogins = append (userLogins , user .Login )
438+ }
439+
440+ out .Roles [role .Name ] = org.Role {
441+ Teams : teamSlugs ,
442+ Users : userLogins ,
443+ }
444+ }
445+
388446 return & out , nil
389447}
390448
@@ -932,6 +990,14 @@ func configureOrg(opt options, client github.Client, orgName string, orgConfig o
932990 return fmt .Errorf ("failed to configure %s team %s repos: %w" , orgName , name , err )
933991 }
934992 }
993+
994+ // Configure organization roles
995+ if ! opt .fixOrgRoles {
996+ logrus .Infof ("Skipping organization roles configuration" )
997+ } else if err := configureOrgRoles (client , orgName , orgConfig , githubTeams ); err != nil {
998+ return fmt .Errorf ("failed to configure %s organization roles: %w" , orgName , err )
999+ }
1000+
9351001 return nil
9361002}
9371003
@@ -1453,6 +1519,190 @@ func configureTeamRepos(client teamRepoClient, githubTeams map[string]github.Tea
14531519 return utilerrors .NewAggregate (updateErrors )
14541520}
14551521
1522+ type orgRolesClient interface {
1523+ ListOrganizationRoles (org string ) ([]github.OrganizationRole , error )
1524+ AssignOrganizationRoleToTeam (org , teamSlug string , roleID int ) error
1525+ RemoveOrganizationRoleFromTeam (org , teamSlug string , roleID int ) error
1526+ AssignOrganizationRoleToUser (org , user string , roleID int ) error
1527+ RemoveOrganizationRoleFromUser (org , user string , roleID int ) error
1528+ ListTeamsWithRole (org string , roleID int ) ([]github.OrganizationRoleAssignment , error )
1529+ ListUsersWithRole (org string , roleID int ) ([]github.OrganizationRoleAssignment , error )
1530+ }
1531+
1532+ // configureOrgRoles configures organization roles for teams and users
1533+ func configureOrgRoles (client orgRolesClient , orgName string , orgConfig org.Config , githubTeams map [string ]github.Team ) error {
1534+ if len (orgConfig .Roles ) == 0 {
1535+ logrus .Debugf ("No organization roles configured for %s" , orgName )
1536+ return nil
1537+ }
1538+
1539+ // Validate role configuration (teams exist, users are org members)
1540+ if err := orgConfig .ValidateRoles (); err != nil {
1541+ return fmt .Errorf ("invalid role configuration: %w" , err )
1542+ }
1543+
1544+ // Get current organization roles
1545+ roles , err := client .ListOrganizationRoles (orgName )
1546+ if err != nil {
1547+ return fmt .Errorf ("failed to list organization roles: %w" , err )
1548+ }
1549+
1550+ // Create a map of role names (lowercase) to role IDs for case-insensitive matching
1551+ roleMap := make (map [string ]int )
1552+ roleOriginalNames := make (map [string ]string ) // lowercase -> original name for error messages
1553+ for _ , role := range roles {
1554+ lower := strings .ToLower (role .Name )
1555+ roleMap [lower ] = role .ID
1556+ roleOriginalNames [lower ] = role .Name
1557+ }
1558+
1559+ var allErrors []error
1560+
1561+ // Configure each role
1562+ for roleName , roleConfig := range orgConfig .Roles {
1563+ roleID , exists := roleMap [strings .ToLower (roleName )]
1564+ if ! exists {
1565+ return fmt .Errorf ("role %q does not exist in organization %s - create the role in GitHub before assigning it (available roles: check GitHub organization settings)" , roleName , orgName )
1566+ }
1567+
1568+ // Configure team role assignments
1569+ if err := configureRoleTeamAssignments (client , orgName , roleName , roleID , roleConfig .Teams , githubTeams ); err != nil {
1570+ allErrors = append (allErrors , fmt .Errorf ("failed to configure team assignments for role %s: %w" , roleName , err ))
1571+ }
1572+
1573+ // Configure user role assignments
1574+ if err := configureRoleUserAssignments (client , orgName , roleName , roleID , roleConfig .Users ); err != nil {
1575+ allErrors = append (allErrors , fmt .Errorf ("failed to configure user assignments for role %s: %w" , roleName , err ))
1576+ }
1577+ }
1578+
1579+ return utilerrors .NewAggregate (allErrors )
1580+ }
1581+
1582+ // configureRoleTeamAssignments configures team assignments for a specific role
1583+ func configureRoleTeamAssignments (client orgRolesClient , orgName , roleName string , roleID int , wantTeams []string , githubTeams map [string ]github.Team ) error {
1584+ // Get current team assignments for this role
1585+ currentTeams , err := client .ListTeamsWithRole (orgName , roleID )
1586+ if err != nil {
1587+ return fmt .Errorf ("failed to list teams with role %s: %w" , roleName , err )
1588+ }
1589+
1590+ // If we want no teams and have no teams, we're done
1591+ if len (wantTeams ) == 0 && len (currentTeams ) == 0 {
1592+ return nil
1593+ }
1594+
1595+ // Build a map of normalized team name to team slug for the teams we have in config
1596+ // This allows resolving "MyTeam" (config name) to "my-team" (GitHub slug)
1597+ normalizedTeams := make (map [string ]string )
1598+ for name , team := range githubTeams {
1599+ normalizedTeams [strings .ToLower (name )] = team .Slug
1600+ }
1601+
1602+ // Create sets for comparison using slugs
1603+ wantSet := sets .New [string ]()
1604+ for _ , teamName := range wantTeams {
1605+ // Resolve config team name to slug
1606+ if slug , ok := normalizedTeams [strings .ToLower (teamName )]; ok {
1607+ wantSet .Insert (slug )
1608+ } else {
1609+ return fmt .Errorf ("team %q referenced in role %q could not be resolved to a GitHub team slug - ensure the team exists in your teams configuration and was successfully created" , teamName , roleName )
1610+ }
1611+ }
1612+
1613+ haveSet := sets .New [string ]()
1614+ for _ , team := range currentTeams {
1615+ haveSet .Insert (team .Slug )
1616+ }
1617+
1618+ // Teams to add
1619+ var errors []error
1620+ toAdd := wantSet .Difference (haveSet )
1621+ for teamSlug := range toAdd {
1622+ if err := client .AssignOrganizationRoleToTeam (orgName , teamSlug , roleID ); err != nil {
1623+ errors = append (errors , fmt .Errorf ("failed to assign role %s to team %s: %w" , roleName , teamSlug , err ))
1624+ logrus .WithError (err ).Warnf ("Failed to assign role %s to team %s" , roleName , teamSlug )
1625+ } else {
1626+ logrus .Infof ("Assigned role %s to team %s" , roleName , teamSlug )
1627+ }
1628+ }
1629+
1630+ // Teams to remove
1631+ toRemove := haveSet .Difference (wantSet )
1632+ for teamSlug := range toRemove {
1633+ if err := client .RemoveOrganizationRoleFromTeam (orgName , teamSlug , roleID ); err != nil {
1634+ errors = append (errors , fmt .Errorf ("failed to remove role %s from team %s: %w" , roleName , teamSlug , err ))
1635+ logrus .WithError (err ).Warnf ("Failed to remove role %s from team %s" , roleName , teamSlug )
1636+ } else {
1637+ logrus .Infof ("Removed role %s from team %s" , roleName , teamSlug )
1638+ }
1639+ }
1640+
1641+ return utilerrors .NewAggregate (errors )
1642+ }
1643+
1644+ // configureRoleUserAssignments configures user assignments for a specific role
1645+ func configureRoleUserAssignments (client orgRolesClient , orgName , roleName string , roleID int , wantUsers []string ) error {
1646+ // Get current user assignments for this role
1647+ currentUsers , err := client .ListUsersWithRole (orgName , roleID )
1648+ if err != nil {
1649+ return fmt .Errorf ("failed to list users with role %s: %w" , roleName , err )
1650+ }
1651+
1652+ // If we want no users and have no users, we're done
1653+ if len (wantUsers ) == 0 && len (currentUsers ) == 0 {
1654+ return nil
1655+ }
1656+
1657+ // Create maps to preserve original casing while comparing normalized usernames
1658+ wantMap := make (map [string ]string ) // normalized -> original
1659+ for _ , user := range wantUsers {
1660+ wantMap [github .NormLogin (user )] = user
1661+ }
1662+
1663+ haveMap := make (map [string ]string ) // normalized -> original
1664+ for _ , user := range currentUsers {
1665+ haveMap [github .NormLogin (user .Login )] = user .Login
1666+ }
1667+
1668+ // Create sets for comparison with normalized usernames
1669+ wantSet := sets .New [string ]()
1670+ for normalized := range wantMap {
1671+ wantSet .Insert (normalized )
1672+ }
1673+ haveSet := sets .New [string ]()
1674+ for normalized := range haveMap {
1675+ haveSet .Insert (normalized )
1676+ }
1677+
1678+ // Users to add
1679+ var errors []error
1680+ toAdd := wantSet .Difference (haveSet )
1681+ for normalizedUser := range toAdd {
1682+ originalUser := wantMap [normalizedUser ]
1683+ if err := client .AssignOrganizationRoleToUser (orgName , originalUser , roleID ); err != nil {
1684+ errors = append (errors , fmt .Errorf ("failed to assign role %s to user %s: %w" , roleName , originalUser , err ))
1685+ logrus .WithError (err ).Warnf ("Failed to assign role %s to user %s" , roleName , originalUser )
1686+ } else {
1687+ logrus .Infof ("Assigned role %s to user %s" , roleName , originalUser )
1688+ }
1689+ }
1690+
1691+ // Users to remove
1692+ toRemove := haveSet .Difference (wantSet )
1693+ for normalizedUser := range toRemove {
1694+ originalUser := haveMap [normalizedUser ]
1695+ if err := client .RemoveOrganizationRoleFromUser (orgName , originalUser , roleID ); err != nil {
1696+ errors = append (errors , fmt .Errorf ("failed to remove role %s from user %s: %w" , roleName , originalUser , err ))
1697+ logrus .WithError (err ).Warnf ("Failed to remove role %s from user %s" , roleName , originalUser )
1698+ } else {
1699+ logrus .Infof ("Removed role %s from user %s" , roleName , originalUser )
1700+ }
1701+ }
1702+
1703+ return utilerrors .NewAggregate (errors )
1704+ }
1705+
14561706// teamMembersClient can list/remove/update people to a team.
14571707type teamMembersClient interface {
14581708 ListTeamMembersBySlug (org , teamSlug , role string ) ([]github.TeamMember , error )
0 commit comments