@@ -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
@@ -496,6 +554,8 @@ func configureOrgMembers(opt options, client orgClient, orgName string, orgConfi
496554 }
497555 } else if om .State == github .StatePending {
498556 logrus .Infof ("Invited %s to %s as a %s" , user , orgName , role )
557+ // Track the new invitation so role assignment can skip this user
558+ invitees .Insert (github .NormLogin (user ))
499559 } else {
500560 logrus .Infof ("Set %s as a %s of %s" , user , role , orgName )
501561 }
@@ -870,6 +930,13 @@ func orgInvitations(opt options, client inviteClient, orgName string) (sets.Set[
870930}
871931
872932func configureOrg (opt options , client github.Client , orgName string , orgConfig org.Config ) error {
933+ // Validate role configuration early (before any API calls) if we're going to configure roles
934+ if opt .fixOrgRoles {
935+ if err := orgConfig .ValidateRoles (); err != nil {
936+ return fmt .Errorf ("invalid role configuration: %w" , err )
937+ }
938+ }
939+
873940 // Ensure that metadata is configured correctly.
874941 if ! opt .fixOrg {
875942 logrus .Infof ("Skipping org metadata configuration" )
@@ -889,6 +956,9 @@ func configureOrg(opt options, client github.Client, orgName string, orgConfig o
889956 return fmt .Errorf ("failed to configure %s members: %w" , orgName , err )
890957 }
891958
959+ // Note: New invitations sent by configureOrgMembers are tracked in the invitees set,
960+ // so role assignment can skip users with pending invitations without an extra API call.
961+
892962 // Create repositories in the org
893963 if ! opt .fixRepos {
894964 logrus .Info ("Skipping org repositories configuration" )
@@ -932,6 +1002,14 @@ func configureOrg(opt options, client github.Client, orgName string, orgConfig o
9321002 return fmt .Errorf ("failed to configure %s team %s repos: %w" , orgName , name , err )
9331003 }
9341004 }
1005+
1006+ // Configure organization roles
1007+ if ! opt .fixOrgRoles {
1008+ logrus .Infof ("Skipping organization roles configuration" )
1009+ } else if err := configureOrgRoles (client , orgName , orgConfig , githubTeams , invitees ); err != nil {
1010+ return fmt .Errorf ("failed to configure %s organization roles: %w" , orgName , err )
1011+ }
1012+
9351013 return nil
9361014}
9371015
@@ -1453,6 +1531,220 @@ func configureTeamRepos(client teamRepoClient, githubTeams map[string]github.Tea
14531531 return utilerrors .NewAggregate (updateErrors )
14541532}
14551533
1534+ type orgRolesClient interface {
1535+ ListOrganizationRoles (org string ) ([]github.OrganizationRole , error )
1536+ AssignOrganizationRoleToTeam (org , teamSlug string , roleID int ) error
1537+ RemoveOrganizationRoleFromTeam (org , teamSlug string , roleID int ) error
1538+ AssignOrganizationRoleToUser (org , user string , roleID int ) error
1539+ RemoveOrganizationRoleFromUser (org , user string , roleID int ) error
1540+ ListTeamsWithRole (org string , roleID int ) ([]github.OrganizationRoleAssignment , error )
1541+ ListUsersWithRole (org string , roleID int ) ([]github.OrganizationRoleAssignment , error )
1542+ }
1543+
1544+ // configureOrgRoles configures organization roles for teams and users
1545+ func configureOrgRoles (client orgRolesClient , orgName string , orgConfig org.Config , githubTeams map [string ]github.Team , invitees sets.Set [string ]) error {
1546+ // Note: Role configuration is validated at the start of configureOrg() before any API calls
1547+
1548+ // Get current organization roles from GitHub
1549+ roles , err := client .ListOrganizationRoles (orgName )
1550+ if err != nil {
1551+ return fmt .Errorf ("failed to list organization roles: %w" , err )
1552+ }
1553+
1554+ if len (roles ) == 0 {
1555+ logrus .Debugf ("No organization roles exist in %s" , orgName )
1556+ return nil
1557+ }
1558+
1559+ // Create a map of configured role names (lowercase) for quick lookup
1560+ configuredRoles := make (map [string ]org.Role )
1561+ for roleName , roleConfig := range orgConfig .Roles {
1562+ configuredRoles [strings .ToLower (roleName )] = roleConfig
1563+ }
1564+
1565+ unconfiguredCount := len (roles ) - len (configuredRoles )
1566+ logrus .Debugf ("Processing %d organization roles (%d configured, %d unconfigured to check for cleanup)" ,
1567+ len (roles ), len (configuredRoles ), unconfiguredCount )
1568+
1569+ var allErrors []error
1570+
1571+ // Iterate over ALL GitHub roles to handle both configured and unconfigured roles
1572+ for _ , role := range roles {
1573+ roleNameLower := strings .ToLower (role .Name )
1574+
1575+ if roleConfig , isConfigured := configuredRoles [roleNameLower ]; isConfigured {
1576+ // Role is in config - sync to match desired state
1577+ if err := configureRoleTeamAssignments (client , orgName , role .Name , role .ID , roleConfig .Teams , githubTeams ); err != nil {
1578+ allErrors = append (allErrors , fmt .Errorf ("failed to configure team assignments for role %s: %w" , role .Name , err ))
1579+ }
1580+ if err := configureRoleUserAssignments (client , orgName , role .Name , role .ID , roleConfig .Users , invitees ); err != nil {
1581+ allErrors = append (allErrors , fmt .Errorf ("failed to configure user assignments for role %s: %w" , role .Name , err ))
1582+ }
1583+ } else {
1584+ // Role is NOT in config - remove all assignments (clean up orphaned assignments)
1585+ logrus .Debugf ("Role %q not in config, checking for assignments to clean up" , role .Name )
1586+ if err := configureRoleTeamAssignments (client , orgName , role .Name , role .ID , []string {}, githubTeams ); err != nil {
1587+ allErrors = append (allErrors , fmt .Errorf ("failed to remove team assignments for unconfigured role %s: %w" , role .Name , err ))
1588+ }
1589+ if err := configureRoleUserAssignments (client , orgName , role .Name , role .ID , []string {}, invitees ); err != nil {
1590+ allErrors = append (allErrors , fmt .Errorf ("failed to remove user assignments for unconfigured role %s: %w" , role .Name , err ))
1591+ }
1592+ }
1593+ }
1594+
1595+ // Check if any configured roles don't exist in GitHub
1596+ for roleName := range orgConfig .Roles {
1597+ found := false
1598+ for _ , role := range roles {
1599+ if strings .EqualFold (role .Name , roleName ) {
1600+ found = true
1601+ break
1602+ }
1603+ }
1604+ if ! found {
1605+ return fmt .Errorf ("role %q does not exist in organization %s - create the role in GitHub before assigning it" , roleName , orgName )
1606+ }
1607+ }
1608+
1609+ return utilerrors .NewAggregate (allErrors )
1610+ }
1611+
1612+ // configureRoleTeamAssignments configures team assignments for a specific role
1613+ func configureRoleTeamAssignments (client orgRolesClient , orgName , roleName string , roleID int , wantTeams []string , githubTeams map [string ]github.Team ) error {
1614+ // Get current team assignments for this role
1615+ currentTeams , err := client .ListTeamsWithRole (orgName , roleID )
1616+ if err != nil {
1617+ return fmt .Errorf ("failed to list teams with role %s: %w" , roleName , err )
1618+ }
1619+
1620+ // If we want no teams and have no teams, we're done
1621+ if len (wantTeams ) == 0 && len (currentTeams ) == 0 {
1622+ return nil
1623+ }
1624+
1625+ // Build a map of normalized team name to team slug for the teams we have in config
1626+ // This allows resolving "MyTeam" (config name) to "my-team" (GitHub slug)
1627+ normalizedTeams := make (map [string ]string )
1628+ for name , team := range githubTeams {
1629+ normalizedTeams [strings .ToLower (name )] = team .Slug
1630+ }
1631+
1632+ // Create sets for comparison using slugs
1633+ wantSet := sets .New [string ]()
1634+ for _ , teamName := range wantTeams {
1635+ // Resolve config team name to slug
1636+ if slug , ok := normalizedTeams [strings .ToLower (teamName )]; ok {
1637+ wantSet .Insert (slug )
1638+ } else {
1639+ 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 )
1640+ }
1641+ }
1642+
1643+ haveSet := sets .New [string ]()
1644+ for _ , team := range currentTeams {
1645+ haveSet .Insert (team .Slug )
1646+ }
1647+
1648+ // Teams to add
1649+ var errors []error
1650+ toAdd := wantSet .Difference (haveSet )
1651+ for teamSlug := range toAdd {
1652+ if err := client .AssignOrganizationRoleToTeam (orgName , teamSlug , roleID ); err != nil {
1653+ errors = append (errors , fmt .Errorf ("failed to assign role %s to team %s: %w" , roleName , teamSlug , err ))
1654+ logrus .WithError (err ).Warnf ("Failed to assign role %s to team %s" , roleName , teamSlug )
1655+ } else {
1656+ logrus .Infof ("Assigned role %s to team %s" , roleName , teamSlug )
1657+ }
1658+ }
1659+
1660+ // Teams to remove
1661+ toRemove := haveSet .Difference (wantSet )
1662+ for teamSlug := range toRemove {
1663+ if err := client .RemoveOrganizationRoleFromTeam (orgName , teamSlug , roleID ); err != nil {
1664+ errors = append (errors , fmt .Errorf ("failed to remove role %s from team %s: %w" , roleName , teamSlug , err ))
1665+ logrus .WithError (err ).Warnf ("Failed to remove role %s from team %s" , roleName , teamSlug )
1666+ } else {
1667+ logrus .Infof ("Removed role %s from team %s" , roleName , teamSlug )
1668+ }
1669+ }
1670+
1671+ return utilerrors .NewAggregate (errors )
1672+ }
1673+
1674+ // configureRoleUserAssignments configures user assignments for a specific role
1675+ func configureRoleUserAssignments (client orgRolesClient , orgName , roleName string , roleID int , wantUsers []string , invitees sets.Set [string ]) error {
1676+ // Get current user assignments for this role
1677+ currentUsers , err := client .ListUsersWithRole (orgName , roleID )
1678+ if err != nil {
1679+ return fmt .Errorf ("failed to list users with role %s: %w" , roleName , err )
1680+ }
1681+
1682+ // Create maps to preserve original casing while comparing normalized usernames
1683+ wantMap := make (map [string ]string ) // normalized -> original
1684+ for _ , user := range wantUsers {
1685+ wantMap [github .NormLogin (user )] = user
1686+ }
1687+
1688+ // Only consider DIRECT assignments when building haveMap.
1689+ // Users with "indirect" assignment have the role via team membership and should not be
1690+ // removed just because they're not in the users list - they keep the role through their team.
1691+ haveMap := make (map [string ]string ) // normalized -> original
1692+ for _ , user := range currentUsers {
1693+ if user .Assignment == "indirect" {
1694+ logrus .Debugf ("Skipping indirect role assignment for user %s (has role via team membership)" , user .Login )
1695+ continue
1696+ }
1697+ haveMap [github .NormLogin (user .Login )] = user .Login
1698+ }
1699+
1700+ // If we want no direct users and have no direct users, we're done
1701+ if len (wantUsers ) == 0 && len (haveMap ) == 0 {
1702+ return nil
1703+ }
1704+
1705+ // Create sets for comparison with normalized usernames
1706+ wantSet := sets .New [string ]()
1707+ for normalized := range wantMap {
1708+ wantSet .Insert (normalized )
1709+ }
1710+ haveSet := sets .New [string ]()
1711+ for normalized := range haveMap {
1712+ haveSet .Insert (normalized )
1713+ }
1714+
1715+ // Users to add
1716+ var errors []error
1717+ toAdd := wantSet .Difference (haveSet )
1718+ for normalizedUser := range toAdd {
1719+ originalUser := wantMap [normalizedUser ]
1720+ // Skip users who have pending org invitations - they must accept before we can assign roles
1721+ if invitees .Has (normalizedUser ) {
1722+ logrus .Infof ("Waiting for %s to accept org invitation before assigning role %s" , originalUser , roleName )
1723+ continue
1724+ }
1725+ if err := client .AssignOrganizationRoleToUser (orgName , originalUser , roleID ); err != nil {
1726+ errors = append (errors , fmt .Errorf ("failed to assign role %s to user %s: %w" , roleName , originalUser , err ))
1727+ logrus .WithError (err ).Warnf ("Failed to assign role %s to user %s" , roleName , originalUser )
1728+ } else {
1729+ logrus .Infof ("Assigned role %s to user %s" , roleName , originalUser )
1730+ }
1731+ }
1732+
1733+ // Users to remove
1734+ toRemove := haveSet .Difference (wantSet )
1735+ for normalizedUser := range toRemove {
1736+ originalUser := haveMap [normalizedUser ]
1737+ if err := client .RemoveOrganizationRoleFromUser (orgName , originalUser , roleID ); err != nil {
1738+ errors = append (errors , fmt .Errorf ("failed to remove role %s from user %s: %w" , roleName , originalUser , err ))
1739+ logrus .WithError (err ).Warnf ("Failed to remove role %s from user %s" , roleName , originalUser )
1740+ } else {
1741+ logrus .Infof ("Removed role %s from user %s" , roleName , originalUser )
1742+ }
1743+ }
1744+
1745+ return utilerrors .NewAggregate (errors )
1746+ }
1747+
14561748// teamMembersClient can list/remove/update people to a team.
14571749type teamMembersClient interface {
14581750 ListTeamMembersBySlug (org , teamSlug , role string ) ([]github.TeamMember , error )
0 commit comments