@@ -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
@@ -870,6 +928,13 @@ func orgInvitations(opt options, client inviteClient, orgName string) (sets.Set[
870928}
871929
872930func configureOrg (opt options , client github.Client , orgName string , orgConfig org.Config ) error {
931+ // Validate role configuration early (before any API calls) if we're going to configure roles
932+ if opt .fixOrgRoles {
933+ if err := orgConfig .ValidateRoles (); err != nil {
934+ return fmt .Errorf ("invalid role configuration: %w" , err )
935+ }
936+ }
937+
873938 // Ensure that metadata is configured correctly.
874939 if ! opt .fixOrg {
875940 logrus .Infof ("Skipping org metadata configuration" )
@@ -932,6 +997,14 @@ func configureOrg(opt options, client github.Client, orgName string, orgConfig o
932997 return fmt .Errorf ("failed to configure %s team %s repos: %w" , orgName , name , err )
933998 }
934999 }
1000+
1001+ // Configure organization roles
1002+ if ! opt .fixOrgRoles {
1003+ logrus .Infof ("Skipping organization roles configuration" )
1004+ } else if err := configureOrgRoles (client , orgName , orgConfig , githubTeams ); err != nil {
1005+ return fmt .Errorf ("failed to configure %s organization roles: %w" , orgName , err )
1006+ }
1007+
9351008 return nil
9361009}
9371010
@@ -1453,6 +1526,187 @@ func configureTeamRepos(client teamRepoClient, githubTeams map[string]github.Tea
14531526 return utilerrors .NewAggregate (updateErrors )
14541527}
14551528
1529+ type orgRolesClient interface {
1530+ ListOrganizationRoles (org string ) ([]github.OrganizationRole , error )
1531+ AssignOrganizationRoleToTeam (org , teamSlug string , roleID int ) error
1532+ RemoveOrganizationRoleFromTeam (org , teamSlug string , roleID int ) error
1533+ AssignOrganizationRoleToUser (org , user string , roleID int ) error
1534+ RemoveOrganizationRoleFromUser (org , user string , roleID int ) error
1535+ ListTeamsWithRole (org string , roleID int ) ([]github.OrganizationRoleAssignment , error )
1536+ ListUsersWithRole (org string , roleID int ) ([]github.OrganizationRoleAssignment , error )
1537+ }
1538+
1539+ // configureOrgRoles configures organization roles for teams and users
1540+ func configureOrgRoles (client orgRolesClient , orgName string , orgConfig org.Config , githubTeams map [string ]github.Team ) error {
1541+ if len (orgConfig .Roles ) == 0 {
1542+ logrus .Debugf ("No organization roles configured for %s" , orgName )
1543+ return nil
1544+ }
1545+
1546+ // Note: Role configuration is validated at the start of configureOrg() before any API calls
1547+
1548+ // Get current organization roles
1549+ roles , err := client .ListOrganizationRoles (orgName )
1550+ if err != nil {
1551+ return fmt .Errorf ("failed to list organization roles: %w" , err )
1552+ }
1553+
1554+ // Create a map of role names (lowercase) to role IDs for case-insensitive matching
1555+ roleMap := make (map [string ]int )
1556+ roleOriginalNames := make (map [string ]string ) // lowercase -> original name for error messages
1557+ for _ , role := range roles {
1558+ lower := strings .ToLower (role .Name )
1559+ roleMap [lower ] = role .ID
1560+ roleOriginalNames [lower ] = role .Name
1561+ }
1562+
1563+ var allErrors []error
1564+
1565+ // Configure each role
1566+ for roleName , roleConfig := range orgConfig .Roles {
1567+ roleID , exists := roleMap [strings .ToLower (roleName )]
1568+ if ! exists {
1569+ 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 )
1570+ }
1571+
1572+ // Configure team role assignments
1573+ if err := configureRoleTeamAssignments (client , orgName , roleName , roleID , roleConfig .Teams , githubTeams ); err != nil {
1574+ allErrors = append (allErrors , fmt .Errorf ("failed to configure team assignments for role %s: %w" , roleName , err ))
1575+ }
1576+
1577+ // Configure user role assignments
1578+ if err := configureRoleUserAssignments (client , orgName , roleName , roleID , roleConfig .Users ); err != nil {
1579+ allErrors = append (allErrors , fmt .Errorf ("failed to configure user assignments for role %s: %w" , roleName , err ))
1580+ }
1581+ }
1582+
1583+ return utilerrors .NewAggregate (allErrors )
1584+ }
1585+
1586+ // configureRoleTeamAssignments configures team assignments for a specific role
1587+ func configureRoleTeamAssignments (client orgRolesClient , orgName , roleName string , roleID int , wantTeams []string , githubTeams map [string ]github.Team ) error {
1588+ // Get current team assignments for this role
1589+ currentTeams , err := client .ListTeamsWithRole (orgName , roleID )
1590+ if err != nil {
1591+ return fmt .Errorf ("failed to list teams with role %s: %w" , roleName , err )
1592+ }
1593+
1594+ // If we want no teams and have no teams, we're done
1595+ if len (wantTeams ) == 0 && len (currentTeams ) == 0 {
1596+ return nil
1597+ }
1598+
1599+ // Build a map of normalized team name to team slug for the teams we have in config
1600+ // This allows resolving "MyTeam" (config name) to "my-team" (GitHub slug)
1601+ normalizedTeams := make (map [string ]string )
1602+ for name , team := range githubTeams {
1603+ normalizedTeams [strings .ToLower (name )] = team .Slug
1604+ }
1605+
1606+ // Create sets for comparison using slugs
1607+ wantSet := sets .New [string ]()
1608+ for _ , teamName := range wantTeams {
1609+ // Resolve config team name to slug
1610+ if slug , ok := normalizedTeams [strings .ToLower (teamName )]; ok {
1611+ wantSet .Insert (slug )
1612+ } else {
1613+ 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 )
1614+ }
1615+ }
1616+
1617+ haveSet := sets .New [string ]()
1618+ for _ , team := range currentTeams {
1619+ haveSet .Insert (team .Slug )
1620+ }
1621+
1622+ // Teams to add
1623+ var errors []error
1624+ toAdd := wantSet .Difference (haveSet )
1625+ for teamSlug := range toAdd {
1626+ if err := client .AssignOrganizationRoleToTeam (orgName , teamSlug , roleID ); err != nil {
1627+ errors = append (errors , fmt .Errorf ("failed to assign role %s to team %s: %w" , roleName , teamSlug , err ))
1628+ logrus .WithError (err ).Warnf ("Failed to assign role %s to team %s" , roleName , teamSlug )
1629+ } else {
1630+ logrus .Infof ("Assigned role %s to team %s" , roleName , teamSlug )
1631+ }
1632+ }
1633+
1634+ // Teams to remove
1635+ toRemove := haveSet .Difference (wantSet )
1636+ for teamSlug := range toRemove {
1637+ if err := client .RemoveOrganizationRoleFromTeam (orgName , teamSlug , roleID ); err != nil {
1638+ errors = append (errors , fmt .Errorf ("failed to remove role %s from team %s: %w" , roleName , teamSlug , err ))
1639+ logrus .WithError (err ).Warnf ("Failed to remove role %s from team %s" , roleName , teamSlug )
1640+ } else {
1641+ logrus .Infof ("Removed role %s from team %s" , roleName , teamSlug )
1642+ }
1643+ }
1644+
1645+ return utilerrors .NewAggregate (errors )
1646+ }
1647+
1648+ // configureRoleUserAssignments configures user assignments for a specific role
1649+ func configureRoleUserAssignments (client orgRolesClient , orgName , roleName string , roleID int , wantUsers []string ) error {
1650+ // Get current user assignments for this role
1651+ currentUsers , err := client .ListUsersWithRole (orgName , roleID )
1652+ if err != nil {
1653+ return fmt .Errorf ("failed to list users with role %s: %w" , roleName , err )
1654+ }
1655+
1656+ // If we want no users and have no users, we're done
1657+ if len (wantUsers ) == 0 && len (currentUsers ) == 0 {
1658+ return nil
1659+ }
1660+
1661+ // Create maps to preserve original casing while comparing normalized usernames
1662+ wantMap := make (map [string ]string ) // normalized -> original
1663+ for _ , user := range wantUsers {
1664+ wantMap [github .NormLogin (user )] = user
1665+ }
1666+
1667+ haveMap := make (map [string ]string ) // normalized -> original
1668+ for _ , user := range currentUsers {
1669+ haveMap [github .NormLogin (user .Login )] = user .Login
1670+ }
1671+
1672+ // Create sets for comparison with normalized usernames
1673+ wantSet := sets .New [string ]()
1674+ for normalized := range wantMap {
1675+ wantSet .Insert (normalized )
1676+ }
1677+ haveSet := sets .New [string ]()
1678+ for normalized := range haveMap {
1679+ haveSet .Insert (normalized )
1680+ }
1681+
1682+ // Users to add
1683+ var errors []error
1684+ toAdd := wantSet .Difference (haveSet )
1685+ for normalizedUser := range toAdd {
1686+ originalUser := wantMap [normalizedUser ]
1687+ if err := client .AssignOrganizationRoleToUser (orgName , originalUser , roleID ); err != nil {
1688+ errors = append (errors , fmt .Errorf ("failed to assign role %s to user %s: %w" , roleName , originalUser , err ))
1689+ logrus .WithError (err ).Warnf ("Failed to assign role %s to user %s" , roleName , originalUser )
1690+ } else {
1691+ logrus .Infof ("Assigned role %s to user %s" , roleName , originalUser )
1692+ }
1693+ }
1694+
1695+ // Users to remove
1696+ toRemove := haveSet .Difference (wantSet )
1697+ for normalizedUser := range toRemove {
1698+ originalUser := haveMap [normalizedUser ]
1699+ if err := client .RemoveOrganizationRoleFromUser (orgName , originalUser , roleID ); err != nil {
1700+ errors = append (errors , fmt .Errorf ("failed to remove role %s from user %s: %w" , roleName , originalUser , err ))
1701+ logrus .WithError (err ).Warnf ("Failed to remove role %s from user %s" , roleName , originalUser )
1702+ } else {
1703+ logrus .Infof ("Removed role %s from user %s" , roleName , originalUser )
1704+ }
1705+ }
1706+
1707+ return utilerrors .NewAggregate (errors )
1708+ }
1709+
14561710// teamMembersClient can list/remove/update people to a team.
14571711type teamMembersClient interface {
14581712 ListTeamMembersBySlug (org , teamSlug , role string ) ([]github.TeamMember , error )
0 commit comments