@@ -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" )
@@ -889,6 +954,15 @@ func configureOrg(opt options, client github.Client, orgName string, orgConfig o
889954 return fmt .Errorf ("failed to configure %s members: %w" , orgName , err )
890955 }
891956
957+ // Re-fetch invitees after configuring org members, as new invitations may have been sent.
958+ // This is needed for role assignment - users with pending invitations cannot be assigned roles.
959+ if opt .fixOrgRoles && opt .fixOrgMembers {
960+ invitees , err = orgInvitations (opt , client , orgName )
961+ if err != nil {
962+ return fmt .Errorf ("failed to re-fetch %s invitations: %w" , orgName , err )
963+ }
964+ }
965+
892966 // Create repositories in the org
893967 if ! opt .fixRepos {
894968 logrus .Info ("Skipping org repositories configuration" )
@@ -932,6 +1006,14 @@ func configureOrg(opt options, client github.Client, orgName string, orgConfig o
9321006 return fmt .Errorf ("failed to configure %s team %s repos: %w" , orgName , name , err )
9331007 }
9341008 }
1009+
1010+ // Configure organization roles
1011+ if ! opt .fixOrgRoles {
1012+ logrus .Infof ("Skipping organization roles configuration" )
1013+ } else if err := configureOrgRoles (client , orgName , orgConfig , githubTeams , invitees ); err != nil {
1014+ return fmt .Errorf ("failed to configure %s organization roles: %w" , orgName , err )
1015+ }
1016+
9351017 return nil
9361018}
9371019
@@ -1453,6 +1535,213 @@ func configureTeamRepos(client teamRepoClient, githubTeams map[string]github.Tea
14531535 return utilerrors .NewAggregate (updateErrors )
14541536}
14551537
1538+ type orgRolesClient interface {
1539+ ListOrganizationRoles (org string ) ([]github.OrganizationRole , error )
1540+ AssignOrganizationRoleToTeam (org , teamSlug string , roleID int ) error
1541+ RemoveOrganizationRoleFromTeam (org , teamSlug string , roleID int ) error
1542+ AssignOrganizationRoleToUser (org , user string , roleID int ) error
1543+ RemoveOrganizationRoleFromUser (org , user string , roleID int ) error
1544+ ListTeamsWithRole (org string , roleID int ) ([]github.OrganizationRoleAssignment , error )
1545+ ListUsersWithRole (org string , roleID int ) ([]github.OrganizationRoleAssignment , error )
1546+ }
1547+
1548+ // configureOrgRoles configures organization roles for teams and users
1549+ func configureOrgRoles (client orgRolesClient , orgName string , orgConfig org.Config , githubTeams map [string ]github.Team , invitees sets.Set [string ]) error {
1550+ // Note: Role configuration is validated at the start of configureOrg() before any API calls
1551+
1552+ // Get current organization roles from GitHub
1553+ roles , err := client .ListOrganizationRoles (orgName )
1554+ if err != nil {
1555+ return fmt .Errorf ("failed to list organization roles: %w" , err )
1556+ }
1557+
1558+ if len (roles ) == 0 {
1559+ logrus .Debugf ("No organization roles exist in %s" , orgName )
1560+ return nil
1561+ }
1562+
1563+ // Create a map of configured role names (lowercase) for quick lookup
1564+ configuredRoles := make (map [string ]org.Role )
1565+ for roleName , roleConfig := range orgConfig .Roles {
1566+ configuredRoles [strings .ToLower (roleName )] = roleConfig
1567+ }
1568+
1569+ unconfiguredCount := len (roles ) - len (configuredRoles )
1570+ logrus .Debugf ("Processing %d organization roles (%d configured, %d unconfigured to check for cleanup)" ,
1571+ len (roles ), len (configuredRoles ), unconfiguredCount )
1572+
1573+ var allErrors []error
1574+
1575+ // Iterate over ALL GitHub roles to handle both configured and unconfigured roles
1576+ for _ , role := range roles {
1577+ roleNameLower := strings .ToLower (role .Name )
1578+
1579+ if roleConfig , isConfigured := configuredRoles [roleNameLower ]; isConfigured {
1580+ // Role is in config - sync to match desired state
1581+ if err := configureRoleTeamAssignments (client , orgName , role .Name , role .ID , roleConfig .Teams , githubTeams ); err != nil {
1582+ allErrors = append (allErrors , fmt .Errorf ("failed to configure team assignments for role %s: %w" , role .Name , err ))
1583+ }
1584+ if err := configureRoleUserAssignments (client , orgName , role .Name , role .ID , roleConfig .Users , invitees ); err != nil {
1585+ allErrors = append (allErrors , fmt .Errorf ("failed to configure user assignments for role %s: %w" , role .Name , err ))
1586+ }
1587+ } else {
1588+ // Role is NOT in config - remove all assignments (clean up orphaned assignments)
1589+ logrus .Debugf ("Role %q not in config, checking for assignments to clean up" , role .Name )
1590+ if err := configureRoleTeamAssignments (client , orgName , role .Name , role .ID , []string {}, githubTeams ); err != nil {
1591+ allErrors = append (allErrors , fmt .Errorf ("failed to remove team assignments for unconfigured role %s: %w" , role .Name , err ))
1592+ }
1593+ if err := configureRoleUserAssignments (client , orgName , role .Name , role .ID , []string {}, invitees ); err != nil {
1594+ allErrors = append (allErrors , fmt .Errorf ("failed to remove user assignments for unconfigured role %s: %w" , role .Name , err ))
1595+ }
1596+ }
1597+ }
1598+
1599+ // Check if any configured roles don't exist in GitHub
1600+ for roleName := range orgConfig .Roles {
1601+ found := false
1602+ for _ , role := range roles {
1603+ if strings .EqualFold (role .Name , roleName ) {
1604+ found = true
1605+ break
1606+ }
1607+ }
1608+ if ! found {
1609+ return fmt .Errorf ("role %q does not exist in organization %s - create the role in GitHub before assigning it" , roleName , orgName )
1610+ }
1611+ }
1612+
1613+ return utilerrors .NewAggregate (allErrors )
1614+ }
1615+
1616+ // configureRoleTeamAssignments configures team assignments for a specific role
1617+ func configureRoleTeamAssignments (client orgRolesClient , orgName , roleName string , roleID int , wantTeams []string , githubTeams map [string ]github.Team ) error {
1618+ // Get current team assignments for this role
1619+ currentTeams , err := client .ListTeamsWithRole (orgName , roleID )
1620+ if err != nil {
1621+ return fmt .Errorf ("failed to list teams with role %s: %w" , roleName , err )
1622+ }
1623+
1624+ // If we want no teams and have no teams, we're done
1625+ if len (wantTeams ) == 0 && len (currentTeams ) == 0 {
1626+ return nil
1627+ }
1628+
1629+ // Build a map of normalized team name to team slug for the teams we have in config
1630+ // This allows resolving "MyTeam" (config name) to "my-team" (GitHub slug)
1631+ normalizedTeams := make (map [string ]string )
1632+ for name , team := range githubTeams {
1633+ normalizedTeams [strings .ToLower (name )] = team .Slug
1634+ }
1635+
1636+ // Create sets for comparison using slugs
1637+ wantSet := sets .New [string ]()
1638+ for _ , teamName := range wantTeams {
1639+ // Resolve config team name to slug
1640+ if slug , ok := normalizedTeams [strings .ToLower (teamName )]; ok {
1641+ wantSet .Insert (slug )
1642+ } else {
1643+ 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 )
1644+ }
1645+ }
1646+
1647+ haveSet := sets .New [string ]()
1648+ for _ , team := range currentTeams {
1649+ haveSet .Insert (team .Slug )
1650+ }
1651+
1652+ // Teams to add
1653+ var errors []error
1654+ toAdd := wantSet .Difference (haveSet )
1655+ for teamSlug := range toAdd {
1656+ if err := client .AssignOrganizationRoleToTeam (orgName , teamSlug , roleID ); err != nil {
1657+ errors = append (errors , fmt .Errorf ("failed to assign role %s to team %s: %w" , roleName , teamSlug , err ))
1658+ logrus .WithError (err ).Warnf ("Failed to assign role %s to team %s" , roleName , teamSlug )
1659+ } else {
1660+ logrus .Infof ("Assigned role %s to team %s" , roleName , teamSlug )
1661+ }
1662+ }
1663+
1664+ // Teams to remove
1665+ toRemove := haveSet .Difference (wantSet )
1666+ for teamSlug := range toRemove {
1667+ if err := client .RemoveOrganizationRoleFromTeam (orgName , teamSlug , roleID ); err != nil {
1668+ errors = append (errors , fmt .Errorf ("failed to remove role %s from team %s: %w" , roleName , teamSlug , err ))
1669+ logrus .WithError (err ).Warnf ("Failed to remove role %s from team %s" , roleName , teamSlug )
1670+ } else {
1671+ logrus .Infof ("Removed role %s from team %s" , roleName , teamSlug )
1672+ }
1673+ }
1674+
1675+ return utilerrors .NewAggregate (errors )
1676+ }
1677+
1678+ // configureRoleUserAssignments configures user assignments for a specific role
1679+ func configureRoleUserAssignments (client orgRolesClient , orgName , roleName string , roleID int , wantUsers []string , invitees sets.Set [string ]) error {
1680+ // Get current user assignments for this role
1681+ currentUsers , err := client .ListUsersWithRole (orgName , roleID )
1682+ if err != nil {
1683+ return fmt .Errorf ("failed to list users with role %s: %w" , roleName , err )
1684+ }
1685+
1686+ // If we want no users and have no users, we're done
1687+ if len (wantUsers ) == 0 && len (currentUsers ) == 0 {
1688+ return nil
1689+ }
1690+
1691+ // Create maps to preserve original casing while comparing normalized usernames
1692+ wantMap := make (map [string ]string ) // normalized -> original
1693+ for _ , user := range wantUsers {
1694+ wantMap [github .NormLogin (user )] = user
1695+ }
1696+
1697+ haveMap := make (map [string ]string ) // normalized -> original
1698+ for _ , user := range currentUsers {
1699+ haveMap [github .NormLogin (user .Login )] = user .Login
1700+ }
1701+
1702+ // Create sets for comparison with normalized usernames
1703+ wantSet := sets .New [string ]()
1704+ for normalized := range wantMap {
1705+ wantSet .Insert (normalized )
1706+ }
1707+ haveSet := sets .New [string ]()
1708+ for normalized := range haveMap {
1709+ haveSet .Insert (normalized )
1710+ }
1711+
1712+ // Users to add
1713+ var errors []error
1714+ toAdd := wantSet .Difference (haveSet )
1715+ for normalizedUser := range toAdd {
1716+ originalUser := wantMap [normalizedUser ]
1717+ // Skip users who have pending org invitations - they must accept before we can assign roles
1718+ if invitees .Has (normalizedUser ) {
1719+ logrus .Infof ("Waiting for %s to accept org invitation before assigning role %s" , originalUser , roleName )
1720+ continue
1721+ }
1722+ if err := client .AssignOrganizationRoleToUser (orgName , originalUser , roleID ); err != nil {
1723+ errors = append (errors , fmt .Errorf ("failed to assign role %s to user %s: %w" , roleName , originalUser , err ))
1724+ logrus .WithError (err ).Warnf ("Failed to assign role %s to user %s" , roleName , originalUser )
1725+ } else {
1726+ logrus .Infof ("Assigned role %s to user %s" , roleName , originalUser )
1727+ }
1728+ }
1729+
1730+ // Users to remove
1731+ toRemove := haveSet .Difference (wantSet )
1732+ for normalizedUser := range toRemove {
1733+ originalUser := haveMap [normalizedUser ]
1734+ if err := client .RemoveOrganizationRoleFromUser (orgName , originalUser , roleID ); err != nil {
1735+ errors = append (errors , fmt .Errorf ("failed to remove role %s from user %s: %w" , roleName , originalUser , err ))
1736+ logrus .WithError (err ).Warnf ("Failed to remove role %s from user %s" , roleName , originalUser )
1737+ } else {
1738+ logrus .Infof ("Removed role %s from user %s" , roleName , originalUser )
1739+ }
1740+ }
1741+
1742+ return utilerrors .NewAggregate (errors )
1743+ }
1744+
14561745// teamMembersClient can list/remove/update people to a team.
14571746type teamMembersClient interface {
14581747 ListTeamMembersBySlug (org , teamSlug , role string ) ([]github.TeamMember , error )
0 commit comments