...

Source file src/edge-infra.dev/pkg/tools/github/teamster/sync.go

Documentation: edge-infra.dev/pkg/tools/github/teamster

     1  package teamster
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"strings"
     7  
     8  	"github.com/google/go-github/v47/github"
     9  
    10  	"edge-infra.dev/pkg/lib/cli/sink"
    11  	"edge-infra.dev/pkg/lib/fog"
    12  	"edge-infra.dev/pkg/tools/github/ghx"
    13  	"edge-infra.dev/pkg/tools/team"
    14  )
    15  
    16  // TODO: support force (delete + re-create)
    17  // TODO: handle team names vs slugs. our team names should basically always be
    18  // slug form but theres technically nothing stopping you from putting a gaudy name
    19  // TODO(?): reflect mission into description on github team
    20  
    21  func NewCmdSync() *sink.Command {
    22  	var (
    23  		ref, repo, org string
    24  		gh             = &ghx.GHX{}
    25  	)
    26  
    27  	cmd := &sink.Command{
    28  		Use:        "sync [flags] <teams file>",
    29  		Short:      "Sync GitHub team rosters from configuration files",
    30  		Flags:      commonFlags(&org, &repo, &ref),
    31  		Extensions: []sink.Extension{gh},
    32  		Exec: func(ctx context.Context, r sink.Run) error {
    33  			if len(r.Args()) < 1 {
    34  				return fmt.Errorf("path to teams file is required")
    35  			}
    36  			if org == "" {
    37  				return fmt.Errorf("--org is required to sync GitHub teams")
    38  			}
    39  
    40  			client := gh.V3
    41  			t, err := getTeam(ctx, r.Args()[0], org, repo, ref, gh.V3)
    42  			if err != nil {
    43  				return err
    44  			}
    45  
    46  			s := &syncer{client, org}
    47  			return s.sync(fog.IntoContext(ctx, r.Log), t)
    48  		},
    49  	}
    50  
    51  	return cmd
    52  }
    53  
    54  // GitHub team roles that are assigned based on team configuration
    55  const (
    56  	roleMember     = "member"
    57  	roleMaintainer = "maintainer"
    58  )
    59  
    60  // Syncer handles syncing configured team state with GitHub teams
    61  type syncer struct {
    62  	gh  *github.Client
    63  	org string
    64  }
    65  
    66  func (s *syncer) sync(ctx context.Context, t *team.Team) error {
    67  	log := fog.FromContext(ctx).WithName(t.Name)
    68  
    69  	ghteam, res, err := s.gh.Teams.GetTeamBySlug(ctx, s.org, t.Name)
    70  	switch {
    71  	case res.StatusCode == 404:
    72  		// If team doesn't exist, we can initialize it (create + add members) and
    73  		// then sync any children to avoid pruning logic for a team that should have
    74  		// no drift (because it didn't exist)
    75  		return s.initTeam(fog.IntoContext(ctx, log), t)
    76  	case err != nil:
    77  		return err
    78  	}
    79  
    80  	ctx = fog.IntoContext(ctx, log)
    81  
    82  	// Update parent relationships and related state if necessary
    83  	if err := s.reconcileParent(ctx, t, ghteam); err != nil {
    84  		return err
    85  	}
    86  
    87  	// Add/remove children teams and sync children teams. We do this before
    88  	// reconciling the members of the current team we are syncing because GH
    89  	// returns members of chilren teams when querying team members. Pruning/syncing
    90  	// child teams before the current team ensures that the list of members we get
    91  	// is as close to accurate as possible.
    92  	if err := s.reconcileChildTeams(ctx, t, ghteam); err != nil {
    93  		return err
    94  	}
    95  
    96  	// After all children have been recursively synced, prune members for current
    97  	// team.
    98  	if err := s.reconcileMembers(ctx, t, ghteam); err != nil {
    99  		return err
   100  	}
   101  	log.Info("reconciled team")
   102  
   103  	return nil
   104  }
   105  
   106  // reconcileParent determines if the team's parents on GitHub match the
   107  // configured team, updating the team as necessary
   108  func (s *syncer) reconcileParent(ctx context.Context, t *team.Team, ghteam *github.Team) error {
   109  	// Determine what changes we need to make to the team itself, if any.
   110  	var (
   111  		edit         = false
   112  		deleteParent = false
   113  		updatedOpts  = github.NewTeam{Name: *ghteam.Name, Privacy: ghteam.Privacy}
   114  	)
   115  
   116  	// If team privacy doesn't line up with parent/children relationships for t,
   117  	// update it.
   118  	if *ghteam.Privacy != *closed() {
   119  		edit = true
   120  		updatedOpts.Privacy = closed()
   121  	}
   122  
   123  	// Team configuration has parent that differs from current GH state.
   124  	// 1. Team has parent but GH team does not, or has disagreeing parent
   125  	if t.HasParent() &&
   126  		(ghteam.Parent == nil || (ghteam.Parent.Slug != nil && *ghteam.Parent.Slug != t.Parent().Name)) {
   127  		edit = true
   128  		// Look up team based on configured parent team name
   129  		ghparent, _, err := s.gh.Teams.GetTeamBySlug(ctx, s.org, t.Parent().Name)
   130  		// Since we always start at the top of the team tree, shouldn't ever look
   131  		// for a parent that we haven't aleady created
   132  		if err != nil {
   133  			return err
   134  		}
   135  		updatedOpts.ParentTeamID = ghparent.ID
   136  	}
   137  	// 2. Team has no parent but GH team does
   138  	if !t.HasParent() && ghteam.Parent != nil {
   139  		edit = true
   140  		deleteParent = true
   141  	}
   142  
   143  	// Reflect changes in GitHub if there are an
   144  	if edit {
   145  		if _, _, err := s.gh.Teams.EditTeamBySlug(
   146  			ctx,
   147  			s.org,
   148  			*ghteam.Slug,
   149  			updatedOpts,
   150  			deleteParent,
   151  		); err != nil {
   152  			return err
   153  		}
   154  
   155  		vals := []any{
   156  			"deletedParent", deleteParent,
   157  			"privacy", *updatedOpts.Privacy,
   158  		}
   159  		if updatedOpts.ParentTeamID != nil {
   160  			vals = append(vals, "parent", *updatedOpts.ParentTeamID)
   161  		}
   162  		fog.FromContext(ctx, vals...).Info("updated team")
   163  	}
   164  
   165  	return nil
   166  }
   167  
   168  // reconcileChildTeams lists child teams on GH and deletes any which are not
   169  // present in our configured set of child teams. it then calls sync() on each
   170  // configured child team to either synchronize or create the team
   171  func (s *syncer) reconcileChildTeams(ctx context.Context, t *team.Team, ghteam *github.Team) error {
   172  	children, err := s.listChildTeams(ctx, *ghteam.Slug)
   173  	if err != nil {
   174  		return err
   175  	}
   176  
   177  	for _, gc := range children {
   178  		found := false
   179  		for _, c := range t.Teams {
   180  			if *gc.Slug == c.Name {
   181  				found = true
   182  				break
   183  			}
   184  		}
   185  		if !found {
   186  			if err := s.deleteTeam(ctx, *gc.Slug); err != nil {
   187  				return err
   188  			}
   189  		}
   190  	}
   191  
   192  	for _, c := range t.Teams {
   193  		if err := s.sync(ctx, c); err != nil {
   194  			return err
   195  		}
   196  	}
   197  
   198  	return err
   199  }
   200  
   201  // reconcileMembers reflects the intended roster and their role for t into GH,
   202  // upating roles + removing/adding as necessary
   203  func (s *syncer) reconcileMembers(
   204  	ctx context.Context,
   205  	t *team.Team,
   206  	ghteam *github.Team,
   207  ) error {
   208  	var (
   209  		add     = map[string]string{} // login -> role, also for modifications
   210  		desired = map[string]string{} // login -> role for immediate team
   211  		log     = fog.FromContext(ctx)
   212  	)
   213  
   214  	// Populate desired from t
   215  	for _, login := range t.Leads {
   216  		desired[login] = roleMaintainer
   217  	}
   218  	for _, login := range t.Members {
   219  		desired[login] = roleMember
   220  	}
   221  
   222  	// Pull current state from GitHub.
   223  	// TODO: deal with pagination
   224  	current, err := s.getUsers(ctx, *ghteam.Slug)
   225  	if err != nil {
   226  		return err
   227  	}
   228  	log.V(1).Info("current team state in github", "users", current)
   229  
   230  	// First determine concrete actions for the set of direct user we desire to be
   231  	// in the team.
   232  	for login, role := range desired {
   233  		switch current[login] {
   234  		case "":
   235  			add[login] = role
   236  		case role:
   237  			// Do nothing
   238  			log.V(1).Info("user is expected role",
   239  				"user", login,
   240  				"role", role,
   241  			)
   242  		default:
   243  			// Not an empty string, not our desired role, must be the only other role
   244  			// for teams
   245  
   246  			// If a desired member is a maintainer, check if they are org admin,
   247  			// because they will always show up as team maintainers
   248  			if current[login] == "maintainer" && desired[login] == "member" {
   249  				orgmem, _, err := s.gh.Organizations.GetOrgMembership(ctx, login, s.org)
   250  				if err != nil {
   251  					return fmt.Errorf("failed to read org membership for %s: %w", login, err)
   252  				}
   253  				if orgmem.GetRole() == "admin" {
   254  					log.V(1).Info("team member is org admin, ignoring maintainer status",
   255  						"user", login,
   256  					)
   257  					continue
   258  				}
   259  			}
   260  			add[login] = reverseRole(current[login])
   261  			log.V(1).Info("users role needs updated",
   262  				"user", login,
   263  				"role", current[login],
   264  				"desired", add[login],
   265  			)
   266  		}
   267  	}
   268  
   269  	// Because GH returns members of children teams as well, we have to remove the
   270  	// full set of participants from `current` to be left with the team memberships
   271  	// that should be pruned.
   272  	for _, login := range t.AllParticipants() {
   273  		delete(current, login)
   274  	}
   275  
   276  	// Prune memberships
   277  	for login := range current {
   278  		if err := s.removeUser(ctx, *ghteam.Slug, login); err != nil {
   279  			return err
   280  		}
   281  	}
   282  
   283  	// Add / Update memberships
   284  	for login, role := range add {
   285  		if err := s.addUser(ctx, *ghteam.Slug, login, role); err != nil {
   286  			return err
   287  		}
   288  	}
   289  
   290  	return nil
   291  }
   292  
   293  func (s *syncer) deleteTeam(ctx context.Context, slug string) error {
   294  	_, err := s.gh.Teams.DeleteTeamBySlug(ctx, s.org, slug)
   295  	if err != nil {
   296  		return err
   297  	}
   298  	fog.FromContext(ctx).Info("deleted team", "team", slug)
   299  
   300  	return nil
   301  }
   302  
   303  func (s *syncer) createTeam(ctx context.Context, ghteam github.NewTeam) (*github.Team, error) {
   304  	t, _, err := s.gh.Teams.CreateTeam(ctx, s.org, ghteam)
   305  	if err != nil {
   306  		return nil, err
   307  	}
   308  	fog.FromContext(ctx).Info("created team")
   309  
   310  	return t, nil
   311  }
   312  
   313  func (s *syncer) listChildTeams(ctx context.Context, name string) ([]*github.Team, error) {
   314  	teams, _, err := s.gh.Teams.ListChildTeamsByParentSlug(ctx, s.org, name, nil)
   315  	return teams, err
   316  }
   317  
   318  // initTeam sets up a fresh GH team, used when the team doesn't exist. children
   319  // teams are synced recursively, in the event that the child team(s) already exist,
   320  // even if the parent doesn't
   321  func (s *syncer) initTeam(ctx context.Context, t *team.Team) error {
   322  	opts := github.NewTeam{
   323  		Name:        t.Name,
   324  		Maintainers: t.Leads,
   325  		Privacy:     closed(),
   326  	}
   327  	if t.HasParent() {
   328  		ghparent, _, err := s.gh.Teams.GetTeamBySlug(ctx, s.org, t.Parent().Name)
   329  		// Since we always start at the top of the team tree, shouldn't ever look
   330  		// for a parent that we haven't aleady created
   331  		if err != nil {
   332  			return err
   333  		}
   334  		opts.ParentTeamID = ghparent.ID
   335  	}
   336  	ghteam, err := s.createTeam(ctx, opts)
   337  	if err != nil {
   338  		return err
   339  	}
   340  	if err := s.addUsers(ctx, *ghteam.Slug, t.Members, roleMember); err != nil {
   341  		return err
   342  	}
   343  	for _, c := range t.Teams {
   344  		if err := s.sync(ctx, c); err != nil {
   345  			return err
   346  		}
   347  	}
   348  	return nil
   349  }
   350  
   351  func (s *syncer) removeUser(ctx context.Context, slug, user string) error {
   352  	_, err := s.gh.Teams.RemoveTeamMembershipBySlug(
   353  		ctx,
   354  		s.org,
   355  		slug,
   356  		user,
   357  	)
   358  	if err != nil {
   359  		return err
   360  	}
   361  
   362  	fog.FromContext(ctx).Info("removed user", "user", user)
   363  	return nil
   364  }
   365  
   366  func (s *syncer) addUser(ctx context.Context, slug, user, role string) error {
   367  	_, _, err := s.gh.Teams.AddTeamMembershipBySlug(
   368  		ctx,
   369  		s.org,
   370  		slug,
   371  		user,
   372  		&github.TeamAddTeamMembershipOptions{Role: role},
   373  	)
   374  	if err != nil {
   375  		return err
   376  	}
   377  
   378  	fog.FromContext(ctx).Info("added user", "user", user, "role", role)
   379  	return nil
   380  }
   381  
   382  func (s *syncer) addUsers(ctx context.Context, slug string, users []string, role string) error {
   383  	for _, u := range users {
   384  		if err := s.addUser(ctx, slug, u, role); err != nil {
   385  			return err
   386  		}
   387  	}
   388  	return nil
   389  }
   390  
   391  func (s *syncer) getUsersByRole(
   392  	ctx context.Context,
   393  	slug string,
   394  	role string,
   395  ) ([]*github.User, error) {
   396  	members, _, err := s.gh.Teams.ListTeamMembersBySlug(
   397  		ctx, s.org, slug, &github.TeamListTeamMembersOptions{
   398  			Role:        role,
   399  			ListOptions: github.ListOptions{PerPage: 100},
   400  		},
   401  	)
   402  	return members, err
   403  }
   404  
   405  func (s *syncer) getUsers(
   406  	ctx context.Context,
   407  	slug string,
   408  ) (map[string]string, error) {
   409  	members, err := s.getUsersByRole(ctx, slug, roleMember)
   410  	if err != nil {
   411  		return nil, err
   412  	}
   413  	maintainers, err := s.getUsersByRole(ctx, slug, roleMaintainer)
   414  	if err != nil {
   415  		return nil, err
   416  	}
   417  
   418  	users := make(map[string]string, len(members)+len(maintainers))
   419  	for _, m := range members {
   420  		users[strings.ToLower(*m.Login)] = roleMember
   421  	}
   422  	for _, m := range maintainers {
   423  		users[strings.ToLower(*m.Login)] = roleMaintainer
   424  	}
   425  
   426  	return users, nil
   427  }
   428  
   429  func reverseRole(r string) string {
   430  	switch r {
   431  	case roleMember:
   432  		return roleMaintainer
   433  	case roleMaintainer:
   434  		return roleMember
   435  	default:
   436  		// This should never happen since you can only be a "member" or "maintainer"
   437  		// of GH teams.
   438  		panic(fmt.Sprintf("unexpected team role: %s", r))
   439  	}
   440  }
   441  
   442  func closed() *string {
   443  	c := "closed"
   444  	return &c
   445  }
   446  

View as plain text