github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/cmd/peribolos/main.go (about)

     1  /*
     2  Copyright 2018 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package main
    18  
    19  import (
    20  	"errors"
    21  	"flag"
    22  	"fmt"
    23  	"os"
    24  	"strings"
    25  
    26  	"github.com/sirupsen/logrus"
    27  	"k8s.io/apimachinery/pkg/util/sets"
    28  	"sigs.k8s.io/yaml"
    29  
    30  	utilerrors "k8s.io/apimachinery/pkg/util/errors"
    31  
    32  	"sigs.k8s.io/prow/pkg/config/org"
    33  	"sigs.k8s.io/prow/pkg/flagutil"
    34  	"sigs.k8s.io/prow/pkg/github"
    35  	"sigs.k8s.io/prow/pkg/logrusutil"
    36  )
    37  
    38  const (
    39  	defaultMinAdmins = 5
    40  	defaultDelta     = 0.25
    41  	defaultTokens    = 300
    42  	defaultBurst     = 100
    43  )
    44  
    45  type options struct {
    46  	config            string
    47  	confirm           bool
    48  	dump              string
    49  	dumpFull          bool
    50  	maximumDelta      float64
    51  	minAdmins         int
    52  	requireSelf       bool
    53  	requiredAdmins    flagutil.Strings
    54  	fixOrg            bool
    55  	fixOrgMembers     bool
    56  	fixTeamMembers    bool
    57  	fixTeams          bool
    58  	fixTeamRepos      bool
    59  	fixRepos          bool
    60  	ignoreInvitees    bool
    61  	ignoreSecretTeams bool
    62  	allowRepoArchival bool
    63  	allowRepoPublish  bool
    64  	github            flagutil.GitHubOptions
    65  
    66  	logLevel string
    67  }
    68  
    69  func parseOptions() options {
    70  	var o options
    71  	if err := o.parseArgs(flag.CommandLine, os.Args[1:]); err != nil {
    72  		logrus.Fatalf("Invalid flags: %v", err)
    73  	}
    74  	return o
    75  }
    76  
    77  func (o *options) parseArgs(flags *flag.FlagSet, args []string) error {
    78  	o.requiredAdmins = flagutil.NewStrings()
    79  	flags.Var(&o.requiredAdmins, "required-admins", "Ensure config specifies these users as admins")
    80  	flags.IntVar(&o.minAdmins, "min-admins", defaultMinAdmins, "Ensure config specifies at least this many admins")
    81  	flags.BoolVar(&o.requireSelf, "require-self", true, "Ensure --github-token-path user is an admin")
    82  	flags.Float64Var(&o.maximumDelta, "maximum-removal-delta", defaultDelta, "Fail if config removes more than this fraction of current members")
    83  	flags.StringVar(&o.config, "config-path", "", "Path to org config.yaml")
    84  	flags.BoolVar(&o.confirm, "confirm", false, "Mutate github if set")
    85  	flags.StringVar(&o.dump, "dump", "", "Output current config of this org if set")
    86  	flags.BoolVar(&o.dumpFull, "dump-full", false, "Output current config of the org as a valid input config file instead of a snippet")
    87  	flags.BoolVar(&o.ignoreInvitees, "ignore-invitees", false, "Do not compare missing members with active invitations (compatibility for GitHub Enterprise)")
    88  	flags.BoolVar(&o.ignoreSecretTeams, "ignore-secret-teams", false, "Do not dump or update secret teams if set")
    89  	flags.BoolVar(&o.fixOrg, "fix-org", false, "Change org metadata if set")
    90  	flags.BoolVar(&o.fixOrgMembers, "fix-org-members", false, "Add/remove org members if set")
    91  	flags.BoolVar(&o.fixTeams, "fix-teams", false, "Create/delete/update teams if set")
    92  	flags.BoolVar(&o.fixTeamMembers, "fix-team-members", false, "Add/remove team members if set")
    93  	flags.BoolVar(&o.fixTeamRepos, "fix-team-repos", false, "Add/remove team permissions on repos if set")
    94  	flags.BoolVar(&o.fixRepos, "fix-repos", false, "Create/update repositories if set")
    95  	flags.BoolVar(&o.allowRepoArchival, "allow-repo-archival", false, "If set, archiving repos is allowed while updating repos")
    96  	flags.BoolVar(&o.allowRepoPublish, "allow-repo-publish", false, "If set, making private repos public is allowed while updating repos")
    97  	flags.StringVar(&o.logLevel, "log-level", logrus.InfoLevel.String(), fmt.Sprintf("Logging level, one of %v", logrus.AllLevels))
    98  	o.github.AddCustomizedFlags(flags, flagutil.ThrottlerDefaults(defaultTokens, defaultBurst))
    99  	if err := flags.Parse(args); err != nil {
   100  		return err
   101  	}
   102  
   103  	level, err := logrus.ParseLevel(o.logLevel)
   104  	if err != nil {
   105  		return fmt.Errorf("--log-level invalid: %w", err)
   106  	}
   107  	logrus.SetLevel(level)
   108  	logrus.SetReportCaller(level >= logrus.DebugLevel)
   109  
   110  	if err := o.github.Validate(!o.confirm); err != nil {
   111  		return err
   112  	}
   113  
   114  	if o.minAdmins < 2 {
   115  		return fmt.Errorf("--min-admins=%d must be at least 2", o.minAdmins)
   116  	}
   117  	if o.maximumDelta > 1 || o.maximumDelta < 0 {
   118  		return fmt.Errorf("--maximum-removal-delta=%f must be a non-negative number less than 1.0", o.maximumDelta)
   119  	}
   120  
   121  	if o.confirm && o.dump != "" && o.github.AppID == "" {
   122  		return fmt.Errorf("--confirm cannot be used with --dump=%s", o.dump)
   123  	}
   124  
   125  	if o.dump != "" && !o.confirm && o.github.AppID != "" {
   126  		return fmt.Errorf("--confirm has to be used with --dump=%s and --github-app-id", o.dump)
   127  	}
   128  
   129  	if o.config == "" && o.dump == "" {
   130  		return errors.New("--config-path or --dump required")
   131  	}
   132  	if o.config != "" && o.dump != "" {
   133  		return fmt.Errorf("--config-path=%s and --dump=%s cannot both be set", o.config, o.dump)
   134  	}
   135  
   136  	if o.dumpFull && o.dump == "" {
   137  		return errors.New("--dump-full can't be used without --dump")
   138  	}
   139  
   140  	if o.fixTeamMembers && !o.fixTeams {
   141  		return fmt.Errorf("--fix-team-members requires --fix-teams")
   142  	}
   143  
   144  	if o.fixTeamRepos && !o.fixTeams {
   145  		return fmt.Errorf("--fix-team-repos requires --fix-teams")
   146  	}
   147  
   148  	return nil
   149  }
   150  
   151  func main() {
   152  	logrusutil.ComponentInit()
   153  
   154  	o := parseOptions()
   155  
   156  	githubClient, err := o.github.GitHubClient(!o.confirm)
   157  	if err != nil {
   158  		logrus.WithError(err).Fatal("Error getting GitHub client.")
   159  	}
   160  
   161  	if o.dump != "" {
   162  		ret, err := dumpOrgConfig(githubClient, o.dump, o.ignoreSecretTeams, o.github.AppID)
   163  		if err != nil {
   164  			logrus.WithError(err).Fatalf("Dump %s failed to collect current data.", o.dump)
   165  		}
   166  		var output interface{}
   167  		if o.dumpFull {
   168  			output = org.FullConfig{
   169  				Orgs: map[string]org.Config{o.dump: *ret},
   170  			}
   171  		} else {
   172  			output = ret
   173  		}
   174  		out, err := yaml.Marshal(output)
   175  		if err != nil {
   176  			logrus.WithError(err).Fatalf("Dump %s failed to marshal output.", o.dump)
   177  		}
   178  		logrus.Infof("Dumping orgs[\"%s\"]:", o.dump)
   179  		fmt.Println(string(out))
   180  		return
   181  	}
   182  
   183  	raw, err := os.ReadFile(o.config)
   184  	if err != nil {
   185  		logrus.WithError(err).Fatal("Could not read --config-path file")
   186  	}
   187  
   188  	var cfg org.FullConfig
   189  	if err := yaml.Unmarshal(raw, &cfg); err != nil {
   190  		logrus.WithError(err).Fatal("Failed to load configuration")
   191  	}
   192  
   193  	for name, orgcfg := range cfg.Orgs {
   194  		if err := configureOrg(o, githubClient, name, orgcfg); err != nil {
   195  			logrus.Fatalf("Configuration failed: %v", err)
   196  		}
   197  	}
   198  	logrus.Info("Finished syncing configuration.")
   199  }
   200  
   201  type dumpClient interface {
   202  	GetOrg(name string) (*github.Organization, error)
   203  	ListOrgMembers(org, role string) ([]github.TeamMember, error)
   204  	ListTeams(org string) ([]github.Team, error)
   205  	ListTeamMembersBySlug(org, teamSlug, role string) ([]github.TeamMember, error)
   206  	ListTeamReposBySlug(org, teamSlug string) ([]github.Repo, error)
   207  	GetRepo(owner, name string) (github.FullRepo, error)
   208  	GetRepos(org string, isUser bool) ([]github.Repo, error)
   209  	BotUser() (*github.UserData, error)
   210  }
   211  
   212  func dumpOrgConfig(client dumpClient, orgName string, ignoreSecretTeams bool, appID string) (*org.Config, error) {
   213  	out := org.Config{}
   214  	meta, err := client.GetOrg(orgName)
   215  	if err != nil {
   216  		return nil, fmt.Errorf("failed to get org: %w", err)
   217  	}
   218  	out.Metadata.BillingEmail = &meta.BillingEmail
   219  	out.Metadata.Company = &meta.Company
   220  	out.Metadata.Email = &meta.Email
   221  	out.Metadata.Name = &meta.Name
   222  	out.Metadata.Description = &meta.Description
   223  	out.Metadata.Location = &meta.Location
   224  	out.Metadata.HasOrganizationProjects = &meta.HasOrganizationProjects
   225  	out.Metadata.HasRepositoryProjects = &meta.HasRepositoryProjects
   226  	drp := github.RepoPermissionLevel(meta.DefaultRepositoryPermission)
   227  	out.Metadata.DefaultRepositoryPermission = &drp
   228  	out.Metadata.MembersCanCreateRepositories = &meta.MembersCanCreateRepositories
   229  
   230  	var runningAsAdmin bool
   231  	runningAs, err := client.BotUser()
   232  	if err != nil {
   233  		return nil, fmt.Errorf("failed to obtain username for this token")
   234  	}
   235  	admins, err := client.ListOrgMembers(orgName, github.RoleAdmin)
   236  	if err != nil {
   237  		return nil, fmt.Errorf("failed to list org admins: %w", err)
   238  	}
   239  	logrus.Debugf("Found %d admins", len(admins))
   240  	for _, m := range admins {
   241  		logrus.WithField("login", m.Login).Debug("Recording admin.")
   242  		out.Admins = append(out.Admins, m.Login)
   243  		if runningAs.Login == m.Login || appID != "" {
   244  			runningAsAdmin = true
   245  		}
   246  	}
   247  
   248  	if !runningAsAdmin {
   249  		return nil, fmt.Errorf("--dump must be run with admin:org scope token")
   250  	}
   251  
   252  	orgMembers, err := client.ListOrgMembers(orgName, github.RoleMember)
   253  	if err != nil {
   254  		return nil, fmt.Errorf("failed to list org members: %w", err)
   255  	}
   256  	logrus.Debugf("Found %d members", len(orgMembers))
   257  	for _, m := range orgMembers {
   258  		logrus.WithField("login", m.Login).Debug("Recording member.")
   259  		out.Members = append(out.Members, m.Login)
   260  	}
   261  
   262  	teams, err := client.ListTeams(orgName)
   263  	if err != nil {
   264  		return nil, fmt.Errorf("failed to list teams: %w", err)
   265  	}
   266  	logrus.Debugf("Found %d teams", len(teams))
   267  
   268  	names := map[int]string{}   // what's the name of a team?
   269  	idMap := map[int]org.Team{} // metadata for a team
   270  	children := map[int][]int{} // what children does it have
   271  	var tops []int              // what are the top-level teams
   272  
   273  	for _, t := range teams {
   274  		logger := logrus.WithFields(logrus.Fields{"id": t.ID, "name": t.Name})
   275  		p := org.Privacy(t.Privacy)
   276  		if ignoreSecretTeams && p == org.Secret {
   277  			logger.Debug("Ignoring secret team.")
   278  			continue
   279  		}
   280  		d := t.Description
   281  		nt := org.Team{
   282  			TeamMetadata: org.TeamMetadata{
   283  				Description: &d,
   284  				Privacy:     &p,
   285  			},
   286  			Maintainers: []string{},
   287  			Members:     []string{},
   288  			Children:    map[string]org.Team{},
   289  			Repos:       map[string]github.RepoPermissionLevel{},
   290  		}
   291  		maintainers, err := client.ListTeamMembersBySlug(orgName, t.Slug, github.RoleMaintainer)
   292  		if err != nil {
   293  			return nil, fmt.Errorf("failed to list team %d(%s) maintainers: %w", t.ID, t.Name, err)
   294  		}
   295  		logger.Debugf("Found %d maintainers.", len(maintainers))
   296  		for _, m := range maintainers {
   297  			logger.WithField("login", m.Login).Debug("Recording maintainer.")
   298  			nt.Maintainers = append(nt.Maintainers, m.Login)
   299  		}
   300  		teamMembers, err := client.ListTeamMembersBySlug(orgName, t.Slug, github.RoleMember)
   301  		if err != nil {
   302  			return nil, fmt.Errorf("failed to list team %d(%s) members: %w", t.ID, t.Name, err)
   303  		}
   304  		logger.Debugf("Found %d members.", len(teamMembers))
   305  		for _, m := range teamMembers {
   306  			logger.WithField("login", m.Login).Debug("Recording member.")
   307  			nt.Members = append(nt.Members, m.Login)
   308  		}
   309  
   310  		names[t.ID] = t.Name
   311  		idMap[t.ID] = nt
   312  
   313  		if t.Parent == nil { // top level team
   314  			logger.Debug("Marking as top-level team.")
   315  			tops = append(tops, t.ID)
   316  		} else { // add this id to the list of the parent's children
   317  			logger.Debugf("Marking as child team of %d.", t.Parent.ID)
   318  			children[t.Parent.ID] = append(children[t.Parent.ID], t.ID)
   319  		}
   320  
   321  		repos, err := client.ListTeamReposBySlug(orgName, t.Slug)
   322  		if err != nil {
   323  			return nil, fmt.Errorf("failed to list team %d(%s) repos: %w", t.ID, t.Name, err)
   324  		}
   325  		logger.Debugf("Found %d repo permissions.", len(repos))
   326  		for _, repo := range repos {
   327  			level := github.LevelFromPermissions(repo.Permissions)
   328  			logger.WithFields(logrus.Fields{"repo": repo, "permission": level}).Debug("Recording repo permission.")
   329  			nt.Repos[repo.Name] = level
   330  		}
   331  	}
   332  
   333  	var makeChild func(id int) org.Team
   334  	makeChild = func(id int) org.Team {
   335  		t := idMap[id]
   336  		for _, cid := range children[id] {
   337  			child := makeChild(cid)
   338  			t.Children[names[cid]] = child
   339  		}
   340  		return t
   341  	}
   342  
   343  	out.Teams = make(map[string]org.Team, len(tops))
   344  	for _, id := range tops {
   345  		out.Teams[names[id]] = makeChild(id)
   346  	}
   347  
   348  	repos, err := client.GetRepos(orgName, false)
   349  	if err != nil {
   350  		return nil, fmt.Errorf("failed to list org repos: %w", err)
   351  	}
   352  	logrus.Debugf("Found %d repos", len(repos))
   353  	out.Repos = make(map[string]org.Repo, len(repos))
   354  	for _, repo := range repos {
   355  		full, err := client.GetRepo(orgName, repo.Name)
   356  		if err != nil {
   357  			return nil, fmt.Errorf("failed to get repo: %w", err)
   358  		}
   359  		logrus.WithField("repo", full.FullName).Debug("Recording repo.")
   360  		out.Repos[full.Name] = org.PruneRepoDefaults(org.Repo{
   361  			Description:      &full.Description,
   362  			HomePage:         &full.Homepage,
   363  			Private:          &full.Private,
   364  			HasIssues:        &full.HasIssues,
   365  			HasProjects:      &full.HasProjects,
   366  			HasWiki:          &full.HasWiki,
   367  			AllowMergeCommit: &full.AllowMergeCommit,
   368  			AllowSquashMerge: &full.AllowSquashMerge,
   369  			AllowRebaseMerge: &full.AllowRebaseMerge,
   370  			Archived:         &full.Archived,
   371  			DefaultBranch:    &full.DefaultBranch,
   372  		})
   373  	}
   374  
   375  	return &out, nil
   376  }
   377  
   378  type orgClient interface {
   379  	BotUser() (*github.UserData, error)
   380  	ListOrgMembers(org, role string) ([]github.TeamMember, error)
   381  	RemoveOrgMembership(org, user string) error
   382  	UpdateOrgMembership(org, user string, admin bool) (*github.OrgMembership, error)
   383  }
   384  
   385  func configureOrgMembers(opt options, client orgClient, orgName string, orgConfig org.Config, invitees sets.Set[string]) error {
   386  	// Get desired state
   387  	wantAdmins := sets.New[string](orgConfig.Admins...)
   388  	wantMembers := sets.New[string](orgConfig.Members...)
   389  
   390  	// Sanity desired state
   391  	if n := len(wantAdmins); n < opt.minAdmins {
   392  		return fmt.Errorf("%s must specify at least %d admins, only found %d", orgName, opt.minAdmins, n)
   393  	}
   394  	var missing []string
   395  	for _, r := range opt.requiredAdmins.Strings() {
   396  		if !wantAdmins.Has(r) {
   397  			missing = append(missing, r)
   398  		}
   399  	}
   400  	if len(missing) > 0 {
   401  		return fmt.Errorf("%s must specify %v as admins, missing %v", orgName, opt.requiredAdmins, missing)
   402  	}
   403  	if opt.requireSelf {
   404  		if me, err := client.BotUser(); err != nil {
   405  			return fmt.Errorf("cannot determine user making requests for %s: %v", opt.github.TokenPath, err)
   406  		} else if !wantAdmins.Has(me.Login) {
   407  			return fmt.Errorf("authenticated user %s is not an admin of %s", me.Login, orgName)
   408  		}
   409  	}
   410  
   411  	// Get current state
   412  	haveAdmins := sets.Set[string]{}
   413  	haveMembers := sets.Set[string]{}
   414  	ms, err := client.ListOrgMembers(orgName, github.RoleAdmin)
   415  	if err != nil {
   416  		return fmt.Errorf("failed to list %s admins: %w", orgName, err)
   417  	}
   418  	for _, m := range ms {
   419  		haveAdmins.Insert(m.Login)
   420  	}
   421  	if ms, err = client.ListOrgMembers(orgName, github.RoleMember); err != nil {
   422  		return fmt.Errorf("failed to list %s members: %w", orgName, err)
   423  	}
   424  	for _, m := range ms {
   425  		haveMembers.Insert(m.Login)
   426  	}
   427  
   428  	have := memberships{members: haveMembers, super: haveAdmins}
   429  	want := memberships{members: wantMembers, super: wantAdmins}
   430  	have.normalize()
   431  	want.normalize()
   432  	// Figure out who to remove
   433  	remove := have.all().Difference(want.all())
   434  
   435  	// Sanity check changes
   436  	if d := float64(len(remove)) / float64(len(have.all())); d > opt.maximumDelta {
   437  		return fmt.Errorf("cannot delete %d memberships or %.3f of %s (exceeds limit of %.3f)", len(remove), d, orgName, opt.maximumDelta)
   438  	}
   439  
   440  	teamMembers := sets.Set[string]{}
   441  	teamNames := sets.Set[string]{}
   442  	duplicateTeamNames := sets.Set[string]{}
   443  	for name, team := range orgConfig.Teams {
   444  		teamMembers.Insert(team.Members...)
   445  		teamMembers.Insert(team.Maintainers...)
   446  		if teamNames.Has(name) {
   447  			duplicateTeamNames.Insert(name)
   448  		}
   449  		teamNames.Insert(name)
   450  		for _, n := range team.Previously {
   451  			if teamNames.Has(n) {
   452  				duplicateTeamNames.Insert(n)
   453  			}
   454  			teamNames.Insert(n)
   455  		}
   456  	}
   457  
   458  	teamMembers = normalize(teamMembers)
   459  	if outside := teamMembers.Difference(want.all()); len(outside) > 0 {
   460  		return fmt.Errorf("all team members/maintainers must also be org members: %s", strings.Join(sets.List(outside), ", "))
   461  	}
   462  
   463  	if n := len(duplicateTeamNames); n > 0 {
   464  		return fmt.Errorf("team names must be unique (including previous names), %d duplicated names: %s", n, strings.Join(sets.List(duplicateTeamNames), ", "))
   465  	}
   466  
   467  	adder := func(user string, super bool) error {
   468  		if invitees.Has(user) { // Do not add them, as this causes another invite.
   469  			logrus.Infof("Waiting for %s to accept invitation to %s", user, orgName)
   470  			return nil
   471  		}
   472  		role := github.RoleMember
   473  		if super {
   474  			role = github.RoleAdmin
   475  		}
   476  		om, err := client.UpdateOrgMembership(orgName, user, super)
   477  		if err != nil {
   478  			logrus.WithError(err).Warnf("UpdateOrgMembership(%s, %s, %t) failed", orgName, user, super)
   479  			if github.IsNotFound(err) {
   480  				// this could be caused by someone removing their account
   481  				// or a typo in the configuration but should not crash the sync
   482  				err = nil
   483  			}
   484  		} else if om.State == github.StatePending {
   485  			logrus.Infof("Invited %s to %s as a %s", user, orgName, role)
   486  		} else {
   487  			logrus.Infof("Set %s as a %s of %s", user, role, orgName)
   488  		}
   489  		return err
   490  	}
   491  
   492  	remover := func(user string) error {
   493  		err := client.RemoveOrgMembership(orgName, user)
   494  		if err != nil {
   495  			logrus.WithError(err).Warnf("RemoveOrgMembership(%s, %s) failed", orgName, user)
   496  		}
   497  		return err
   498  	}
   499  
   500  	return configureMembers(have, want, invitees, adder, remover)
   501  }
   502  
   503  type memberships struct {
   504  	members sets.Set[string]
   505  	super   sets.Set[string]
   506  }
   507  
   508  func (m memberships) all() sets.Set[string] {
   509  	return m.members.Union(m.super)
   510  }
   511  
   512  func normalize(s sets.Set[string]) sets.Set[string] {
   513  	out := sets.Set[string]{}
   514  	for i := range s {
   515  		out.Insert(github.NormLogin(i))
   516  	}
   517  	return out
   518  }
   519  
   520  func (m *memberships) normalize() {
   521  	m.members = normalize(m.members)
   522  	m.super = normalize(m.super)
   523  }
   524  
   525  func configureMembers(have, want memberships, invitees sets.Set[string], adder func(user string, super bool) error, remover func(user string) error) error {
   526  	have.normalize()
   527  	want.normalize()
   528  	if both := want.super.Intersection(want.members); len(both) > 0 {
   529  		return fmt.Errorf("users in both roles: %s", strings.Join(sets.List(both), ", "))
   530  	}
   531  	havePlusInvites := have.all().Union(invitees)
   532  	remove := havePlusInvites.Difference(want.all())
   533  	members := want.members.Difference(have.members)
   534  	supers := want.super.Difference(have.super)
   535  
   536  	var errs []error
   537  	for u := range members {
   538  		if err := adder(u, false); err != nil {
   539  			errs = append(errs, err)
   540  		}
   541  	}
   542  	for u := range supers {
   543  		if err := adder(u, true); err != nil {
   544  			errs = append(errs, err)
   545  		}
   546  	}
   547  
   548  	for u := range remove {
   549  		if err := remover(u); err != nil {
   550  			errs = append(errs, err)
   551  		}
   552  	}
   553  
   554  	return utilerrors.NewAggregate(errs)
   555  }
   556  
   557  // findTeam returns teams[n] for the first n in [name, previousNames, ...] that is in teams.
   558  func findTeam(teams map[string]github.Team, name string, previousNames ...string) *github.Team {
   559  	if t, ok := teams[name]; ok {
   560  		return &t
   561  	}
   562  	for _, p := range previousNames {
   563  		if t, ok := teams[p]; ok {
   564  			return &t
   565  		}
   566  	}
   567  	return nil
   568  }
   569  
   570  // validateTeamNames returns an error if any current/previous names are used multiple times in the config.
   571  func validateTeamNames(orgConfig org.Config) error {
   572  	// Does the config duplicate any team names?
   573  	used := sets.Set[string]{}
   574  	dups := sets.Set[string]{}
   575  	for name, orgTeam := range orgConfig.Teams {
   576  		if used.Has(name) {
   577  			dups.Insert(name)
   578  		} else {
   579  			used.Insert(name)
   580  		}
   581  		for _, n := range orgTeam.Previously {
   582  			if used.Has(n) {
   583  				dups.Insert(n)
   584  			} else {
   585  				used.Insert(n)
   586  			}
   587  		}
   588  	}
   589  	if n := len(dups); n > 0 {
   590  		return fmt.Errorf("%d duplicated names: %s", n, strings.Join(sets.List(dups), ", "))
   591  	}
   592  	return nil
   593  }
   594  
   595  type teamClient interface {
   596  	ListTeams(org string) ([]github.Team, error)
   597  	CreateTeam(org string, team github.Team) (*github.Team, error)
   598  	DeleteTeamBySlug(org, teamSlug string) error
   599  }
   600  
   601  // configureTeams returns the ids for all expected team names, creating/deleting teams as necessary.
   602  func configureTeams(client teamClient, orgName string, orgConfig org.Config, maxDelta float64, ignoreSecretTeams bool) (map[string]github.Team, error) {
   603  	if err := validateTeamNames(orgConfig); err != nil {
   604  		return nil, err
   605  	}
   606  
   607  	// What teams exist?
   608  	teams := map[string]github.Team{}
   609  	slugs := sets.Set[string]{}
   610  	teamList, err := client.ListTeams(orgName)
   611  	if err != nil {
   612  		return nil, fmt.Errorf("failed to list teams: %w", err)
   613  	}
   614  	logrus.Debugf("Found %d teams", len(teamList))
   615  	for _, t := range teamList {
   616  		if ignoreSecretTeams && org.Privacy(t.Privacy) == org.Secret {
   617  			continue
   618  		}
   619  		teams[t.Slug] = t
   620  		slugs.Insert(t.Slug)
   621  	}
   622  	if ignoreSecretTeams {
   623  		logrus.Debugf("Found %d non-secret teams", len(teamList))
   624  	}
   625  
   626  	// What is the lowest ID for each team?
   627  	older := map[string][]github.Team{}
   628  	names := map[string]github.Team{}
   629  	for _, t := range teams {
   630  		logger := logrus.WithFields(logrus.Fields{"id": t.ID, "name": t.Name})
   631  		n := t.Name
   632  		switch val, ok := names[n]; {
   633  		case !ok: // first occurrence of the name
   634  			logger.Debug("First occurrence of this team name.")
   635  			names[n] = t
   636  		case ok && t.ID < val.ID: // t has the lower ID, replace and send current to older set
   637  			logger.Debugf("Replacing previous recorded team (%d) with this one due to smaller ID.", val.ID)
   638  			names[n] = t
   639  			older[n] = append(older[n], val)
   640  		default: // t does not have smallest id, add it to older set
   641  			logger.Debugf("Adding team (%d) to older set as a smaller ID is already recoded for it.", val.ID)
   642  			older[n] = append(older[n], val)
   643  		}
   644  	}
   645  
   646  	// What team are we using for each configured name, and which names are missing?
   647  	matches := map[string]github.Team{}
   648  	missing := map[string]org.Team{}
   649  	used := sets.Set[string]{}
   650  	var match func(teams map[string]org.Team)
   651  	match = func(teams map[string]org.Team) {
   652  		for name, orgTeam := range teams {
   653  			logger := logrus.WithField("name", name)
   654  			match(orgTeam.Children)
   655  			t := findTeam(names, name, orgTeam.Previously...)
   656  			if t == nil {
   657  				missing[name] = orgTeam
   658  				logger.Debug("Could not find team in GitHub for this configuration.")
   659  				continue
   660  			}
   661  			matches[name] = *t // t.Name != name if we matched on orgTeam.Previously
   662  			logger.WithField("id", t.ID).Debug("Found a team in GitHub for this configuration.")
   663  			used.Insert(t.Slug)
   664  		}
   665  	}
   666  	match(orgConfig.Teams)
   667  
   668  	// First compute teams we will delete, ensure we are not deleting too many
   669  	unused := slugs.Difference(used)
   670  	if delta := float64(len(unused)) / float64(len(slugs)); delta > maxDelta {
   671  		return nil, fmt.Errorf("cannot delete %d teams or %.3f of %s teams (exceeds limit of %.3f)", len(unused), delta, orgName, maxDelta)
   672  	}
   673  
   674  	// Create any missing team names
   675  	var failures []string
   676  	for name, orgTeam := range missing {
   677  		t := &github.Team{Name: name}
   678  		if orgTeam.Description != nil {
   679  			t.Description = *orgTeam.Description
   680  		}
   681  		if orgTeam.Privacy != nil {
   682  			t.Privacy = string(*orgTeam.Privacy)
   683  		}
   684  		t, err := client.CreateTeam(orgName, *t)
   685  		if err != nil {
   686  			logrus.WithError(err).Warnf("Failed to create %s in %s", name, orgName)
   687  			failures = append(failures, name)
   688  			continue
   689  		}
   690  		matches[name] = *t
   691  		// t.Slug may include a slug already present in slugs if other actors are deleting teams.
   692  		used.Insert(t.Slug)
   693  	}
   694  	if n := len(failures); n > 0 {
   695  		return nil, fmt.Errorf("failed to create %d teams: %s", n, strings.Join(failures, ", "))
   696  	}
   697  
   698  	// Remove any IDs returned by CreateTeam() that are in the unused set.
   699  	if reused := unused.Intersection(used); len(reused) > 0 {
   700  		// Logically possible for:
   701  		// * another actor to delete team N after the ListTeams() call
   702  		// * github to reuse team N after someone deleted it
   703  		// Therefore used may now include IDs in unused, handle this situation.
   704  		logrus.Warnf("Will not delete %d team IDs reused by github: %v", len(reused), sets.List(reused))
   705  		unused = unused.Difference(reused)
   706  	}
   707  	// Delete undeclared teams.
   708  	for slug := range unused {
   709  		if err := client.DeleteTeamBySlug(orgName, slug); err != nil {
   710  			str := fmt.Sprintf("%s(%s)", slug, teams[slug].Name)
   711  			logrus.WithError(err).Warnf("Failed to delete team %s from %s", str, orgName)
   712  			failures = append(failures, str)
   713  		}
   714  	}
   715  	if n := len(failures); n > 0 {
   716  		return nil, fmt.Errorf("failed to delete %d teams: %s", n, strings.Join(failures, ", "))
   717  	}
   718  
   719  	// Return matches
   720  	return matches, nil
   721  }
   722  
   723  // updateString will return true and set have to want iff they are set and different.
   724  func updateString(have, want *string) bool {
   725  	switch {
   726  	case have == nil:
   727  		panic("have must be non-nil")
   728  	case want == nil:
   729  		return false // do not care what we have
   730  	case *have == *want:
   731  		return false // already have it
   732  	}
   733  	*have = *want // update value
   734  	return true
   735  }
   736  
   737  // updateBool will return true and set have to want iff they are set and different.
   738  func updateBool(have, want *bool) bool {
   739  	switch {
   740  	case have == nil:
   741  		panic("have must not be nil")
   742  	case want == nil:
   743  		return false // do not care what we have
   744  	case *have == *want:
   745  		return false // already have it
   746  	}
   747  	*have = *want // update value
   748  	return true
   749  }
   750  
   751  type orgMetadataClient interface {
   752  	GetOrg(name string) (*github.Organization, error)
   753  	EditOrg(name string, org github.Organization) (*github.Organization, error)
   754  }
   755  
   756  // configureOrgMeta will update github to have the non-nil wanted metadata values.
   757  func configureOrgMeta(client orgMetadataClient, orgName string, want org.Metadata) error {
   758  	cur, err := client.GetOrg(orgName)
   759  	if err != nil {
   760  		return fmt.Errorf("failed to get %s metadata: %w", orgName, err)
   761  	}
   762  	change := false
   763  	change = updateString(&cur.BillingEmail, want.BillingEmail) || change
   764  	change = updateString(&cur.Company, want.Company) || change
   765  	change = updateString(&cur.Email, want.Email) || change
   766  	change = updateString(&cur.Name, want.Name) || change
   767  	change = updateString(&cur.Description, want.Description) || change
   768  	change = updateString(&cur.Location, want.Location) || change
   769  	if want.DefaultRepositoryPermission != nil {
   770  		w := string(*want.DefaultRepositoryPermission)
   771  		change = updateString(&cur.DefaultRepositoryPermission, &w) || change
   772  	}
   773  	change = updateBool(&cur.HasOrganizationProjects, want.HasOrganizationProjects) || change
   774  	change = updateBool(&cur.HasRepositoryProjects, want.HasRepositoryProjects) || change
   775  	change = updateBool(&cur.MembersCanCreateRepositories, want.MembersCanCreateRepositories) || change
   776  	if change {
   777  		if _, err := client.EditOrg(orgName, *cur); err != nil {
   778  			return fmt.Errorf("failed to edit %s metadata: %w", orgName, err)
   779  		}
   780  	}
   781  	return nil
   782  }
   783  
   784  type inviteClient interface {
   785  	ListOrgInvitations(org string) ([]github.OrgInvitation, error)
   786  }
   787  
   788  func orgInvitations(opt options, client inviteClient, orgName string) (sets.Set[string], error) {
   789  	invitees := sets.Set[string]{}
   790  	if (!opt.fixOrgMembers && !opt.fixTeamMembers) || opt.ignoreInvitees {
   791  		return invitees, nil
   792  	}
   793  	is, err := client.ListOrgInvitations(orgName)
   794  	if err != nil {
   795  		return nil, err
   796  	}
   797  	for _, i := range is {
   798  		if i.Login == "" {
   799  			continue
   800  		}
   801  		invitees.Insert(github.NormLogin(i.Login))
   802  	}
   803  	return invitees, nil
   804  }
   805  
   806  func configureOrg(opt options, client github.Client, orgName string, orgConfig org.Config) error {
   807  	// Ensure that metadata is configured correctly.
   808  	if !opt.fixOrg {
   809  		logrus.Infof("Skipping org metadata configuration")
   810  	} else if err := configureOrgMeta(client, orgName, orgConfig.Metadata); err != nil {
   811  		return err
   812  	}
   813  
   814  	invitees, err := orgInvitations(opt, client, orgName)
   815  	if err != nil {
   816  		return fmt.Errorf("failed to list %s invitations: %w", orgName, err)
   817  	}
   818  
   819  	// Invite/remove/update members to the org.
   820  	if !opt.fixOrgMembers {
   821  		logrus.Infof("Skipping org member configuration")
   822  	} else if err := configureOrgMembers(opt, client, orgName, orgConfig, invitees); err != nil {
   823  		return fmt.Errorf("failed to configure %s members: %w", orgName, err)
   824  	}
   825  
   826  	// Create repositories in the org
   827  	if !opt.fixRepos {
   828  		logrus.Info("Skipping org repositories configuration")
   829  	} else if err := configureRepos(opt, client, orgName, orgConfig); err != nil {
   830  		return fmt.Errorf("failed to configure %s repos: %w", orgName, err)
   831  	}
   832  
   833  	if !opt.fixTeams {
   834  		logrus.Infof("Skipping team and team member configuration")
   835  		return nil
   836  	}
   837  
   838  	// Find the id and current state of each declared team (create/delete as necessary)
   839  	githubTeams, err := configureTeams(client, orgName, orgConfig, opt.maximumDelta, opt.ignoreSecretTeams)
   840  	if err != nil {
   841  		return fmt.Errorf("failed to configure %s teams: %w", orgName, err)
   842  	}
   843  
   844  	for name, team := range orgConfig.Teams {
   845  		err := configureTeamAndMembers(opt, client, githubTeams, name, orgName, team, nil)
   846  		if err != nil {
   847  			return fmt.Errorf("failed to configure %s teams: %w", orgName, err)
   848  		}
   849  
   850  		if !opt.fixTeamRepos {
   851  			logrus.Infof("Skipping team repo permissions configuration")
   852  			continue
   853  		}
   854  		if err := configureTeamRepos(client, githubTeams, name, orgName, team); err != nil {
   855  			return fmt.Errorf("failed to configure %s team %s repos: %w", orgName, name, err)
   856  		}
   857  	}
   858  	return nil
   859  }
   860  
   861  type repoClient interface {
   862  	GetRepo(orgName, repo string) (github.FullRepo, error)
   863  	GetRepos(orgName string, isUser bool) ([]github.Repo, error)
   864  	CreateRepo(owner string, isUser bool, repo github.RepoCreateRequest) (*github.FullRepo, error)
   865  	UpdateRepo(owner, name string, repo github.RepoUpdateRequest) (*github.FullRepo, error)
   866  }
   867  
   868  func newRepoCreateRequest(name string, definition org.Repo) github.RepoCreateRequest {
   869  	repoCreate := github.RepoCreateRequest{
   870  		RepoRequest: github.RepoRequest{
   871  			Name:                     &name,
   872  			Description:              definition.Description,
   873  			Homepage:                 definition.HomePage,
   874  			Private:                  definition.Private,
   875  			HasIssues:                definition.HasIssues,
   876  			HasProjects:              definition.HasProjects,
   877  			HasWiki:                  definition.HasWiki,
   878  			AllowSquashMerge:         definition.AllowSquashMerge,
   879  			AllowMergeCommit:         definition.AllowMergeCommit,
   880  			AllowRebaseMerge:         definition.AllowRebaseMerge,
   881  			SquashMergeCommitTitle:   definition.SquashMergeCommitTitle,
   882  			SquashMergeCommitMessage: definition.SquashMergeCommitMessage,
   883  		},
   884  	}
   885  
   886  	if definition.OnCreate != nil {
   887  		repoCreate.AutoInit = definition.OnCreate.AutoInit
   888  		repoCreate.GitignoreTemplate = definition.OnCreate.GitignoreTemplate
   889  		repoCreate.LicenseTemplate = definition.OnCreate.LicenseTemplate
   890  	}
   891  
   892  	return repoCreate
   893  }
   894  
   895  func validateRepos(repos map[string]org.Repo) error {
   896  	seen := map[string]string{}
   897  	var dups []string
   898  
   899  	for wantName, repo := range repos {
   900  		toCheck := append([]string{wantName}, repo.Previously...)
   901  		for _, name := range toCheck {
   902  			normName := strings.ToLower(name)
   903  			if seenName, have := seen[normName]; have {
   904  				dups = append(dups, fmt.Sprintf("%s/%s", seenName, name))
   905  			}
   906  		}
   907  		for _, name := range toCheck {
   908  			normName := strings.ToLower(name)
   909  			seen[normName] = name
   910  		}
   911  
   912  	}
   913  
   914  	if len(dups) > 0 {
   915  		return fmt.Errorf("found duplicate repo names (GitHub repo names are case-insensitive): %s", strings.Join(dups, ", "))
   916  	}
   917  
   918  	return nil
   919  }
   920  
   921  // newRepoUpdateRequest creates a minimal github.RepoUpdateRequest instance
   922  // needed to update the current repo into the target state.
   923  func newRepoUpdateRequest(current github.FullRepo, name string, repo org.Repo) github.RepoUpdateRequest {
   924  	setString := func(current string, want *string) *string {
   925  		if want != nil && *want != current {
   926  			return want
   927  		}
   928  		return nil
   929  	}
   930  	setBool := func(current bool, want *bool) *bool {
   931  		if want != nil && *want != current {
   932  			return want
   933  		}
   934  		return nil
   935  	}
   936  	repoUpdate := github.RepoUpdateRequest{
   937  		RepoRequest: github.RepoRequest{
   938  			Name:                     setString(current.Name, &name),
   939  			Description:              setString(current.Description, repo.Description),
   940  			Homepage:                 setString(current.Homepage, repo.HomePage),
   941  			Private:                  setBool(current.Private, repo.Private),
   942  			HasIssues:                setBool(current.HasIssues, repo.HasIssues),
   943  			HasProjects:              setBool(current.HasProjects, repo.HasProjects),
   944  			HasWiki:                  setBool(current.HasWiki, repo.HasWiki),
   945  			AllowSquashMerge:         setBool(current.AllowSquashMerge, repo.AllowSquashMerge),
   946  			AllowMergeCommit:         setBool(current.AllowMergeCommit, repo.AllowMergeCommit),
   947  			AllowRebaseMerge:         setBool(current.AllowRebaseMerge, repo.AllowRebaseMerge),
   948  			SquashMergeCommitTitle:   setString(current.SquashMergeCommitTitle, repo.SquashMergeCommitTitle),
   949  			SquashMergeCommitMessage: setString(current.SquashMergeCommitMessage, repo.SquashMergeCommitMessage),
   950  		},
   951  		DefaultBranch: setString(current.DefaultBranch, repo.DefaultBranch),
   952  		Archived:      setBool(current.Archived, repo.Archived),
   953  	}
   954  
   955  	return repoUpdate
   956  
   957  }
   958  
   959  func sanitizeRepoDelta(opt options, delta *github.RepoUpdateRequest) []error {
   960  	var errs []error
   961  	if delta.Archived != nil && !*delta.Archived {
   962  		delta.Archived = nil
   963  		errs = append(errs, fmt.Errorf("asked to unarchive an archived repo, unsupported by GH API"))
   964  	}
   965  	if delta.Archived != nil && *delta.Archived && !opt.allowRepoArchival {
   966  		delta.Archived = nil
   967  		errs = append(errs, fmt.Errorf("asked to archive a repo but this is not allowed by default (see --allow-repo-archival)"))
   968  	}
   969  	if delta.Private != nil && !(*delta.Private || opt.allowRepoPublish) {
   970  		delta.Private = nil
   971  		errs = append(errs, fmt.Errorf("asked to publish a private repo but this is not allowed by default (see --allow-repo-publish)"))
   972  	}
   973  
   974  	return errs
   975  }
   976  
   977  func configureRepos(opt options, client repoClient, orgName string, orgConfig org.Config) error {
   978  	if err := validateRepos(orgConfig.Repos); err != nil {
   979  		return err
   980  	}
   981  
   982  	repoList, err := client.GetRepos(orgName, false)
   983  	if err != nil {
   984  		return fmt.Errorf("failed to get repos: %w", err)
   985  	}
   986  	logrus.Debugf("Found %d repositories", len(repoList))
   987  	byName := make(map[string]github.Repo, len(repoList))
   988  	for _, repo := range repoList {
   989  		byName[strings.ToLower(repo.Name)] = repo
   990  	}
   991  
   992  	var allErrors []error
   993  
   994  	for wantName, wantRepo := range orgConfig.Repos {
   995  		repoLogger := logrus.WithField("repo", wantName)
   996  		pastErrors := len(allErrors)
   997  		var existing *github.FullRepo = nil
   998  		for _, possibleName := range append([]string{wantName}, wantRepo.Previously...) {
   999  			if repo, exists := byName[strings.ToLower(possibleName)]; exists {
  1000  				switch {
  1001  				case existing == nil:
  1002  					if full, err := client.GetRepo(orgName, repo.Name); err != nil {
  1003  						repoLogger.WithError(err).Error("failed to get repository data")
  1004  						allErrors = append(allErrors, err)
  1005  					} else {
  1006  						existing = &full
  1007  					}
  1008  				case existing.Name != repo.Name:
  1009  					err := fmt.Errorf("different repos already exist for current and previous names: %s and %s", existing.Name, repo.Name)
  1010  					allErrors = append(allErrors, err)
  1011  				}
  1012  			}
  1013  		}
  1014  
  1015  		if len(allErrors) > pastErrors {
  1016  			continue
  1017  		}
  1018  
  1019  		if existing == nil {
  1020  			if wantRepo.Archived != nil && *wantRepo.Archived {
  1021  				repoLogger.Error("repo does not exist but is configured as archived: not creating")
  1022  				allErrors = append(allErrors, fmt.Errorf("nonexistent repo configured as archived: %s", wantName))
  1023  				continue
  1024  			}
  1025  			repoLogger.Info("repo does not exist, creating")
  1026  			created, err := client.CreateRepo(orgName, false, newRepoCreateRequest(wantName, wantRepo))
  1027  			if err != nil {
  1028  				repoLogger.WithError(err).Error("failed to create repository")
  1029  				allErrors = append(allErrors, err)
  1030  			} else {
  1031  				existing = created
  1032  			}
  1033  		}
  1034  
  1035  		if existing != nil {
  1036  			if existing.Archived {
  1037  				if wantRepo.Archived != nil && *wantRepo.Archived {
  1038  					repoLogger.Infof("repo %q is archived, skipping changes", wantName)
  1039  					continue
  1040  				}
  1041  			}
  1042  			repoLogger.Info("repo exists, considering an update")
  1043  			delta := newRepoUpdateRequest(*existing, wantName, wantRepo)
  1044  			if deltaErrors := sanitizeRepoDelta(opt, &delta); len(deltaErrors) > 0 {
  1045  				for _, err := range deltaErrors {
  1046  					repoLogger.WithError(err).Error("requested repo change is not allowed, removing from delta")
  1047  				}
  1048  				allErrors = append(allErrors, deltaErrors...)
  1049  			}
  1050  			if delta.Defined() {
  1051  				repoLogger.Info("repo exists and differs from desired state, updating")
  1052  				if _, err := client.UpdateRepo(orgName, existing.Name, delta); err != nil {
  1053  					repoLogger.WithError(err).Error("failed to update repository")
  1054  					allErrors = append(allErrors, err)
  1055  				}
  1056  			}
  1057  		}
  1058  	}
  1059  
  1060  	return utilerrors.NewAggregate(allErrors)
  1061  }
  1062  
  1063  func configureTeamAndMembers(opt options, client github.Client, githubTeams map[string]github.Team, name, orgName string, team org.Team, parent *int) error {
  1064  	gt, ok := githubTeams[name]
  1065  	if !ok { // configureTeams is buggy if this is the case
  1066  		return fmt.Errorf("%s not found in id list", name)
  1067  	}
  1068  
  1069  	// Configure team metadata
  1070  	err := configureTeam(client, orgName, name, team, gt, parent)
  1071  	if err != nil {
  1072  		return fmt.Errorf("failed to update %s metadata: %w", name, err)
  1073  	}
  1074  
  1075  	// Configure team members
  1076  	if !opt.fixTeamMembers {
  1077  		logrus.Infof("Skipping %s member configuration", name)
  1078  	} else if err = configureTeamMembers(client, orgName, gt, team, opt.ignoreInvitees); err != nil {
  1079  		if opt.confirm {
  1080  			return fmt.Errorf("failed to update %s members: %w", name, err)
  1081  		}
  1082  		logrus.WithError(err).Warnf("failed to update %s members: %s", name, err)
  1083  		return nil
  1084  	}
  1085  
  1086  	for childName, childTeam := range team.Children {
  1087  		err = configureTeamAndMembers(opt, client, githubTeams, childName, orgName, childTeam, &gt.ID)
  1088  		if err != nil {
  1089  			return fmt.Errorf("failed to update %s child teams: %w", name, err)
  1090  		}
  1091  	}
  1092  
  1093  	return nil
  1094  }
  1095  
  1096  type editTeamClient interface {
  1097  	EditTeam(org string, team github.Team) (*github.Team, error)
  1098  }
  1099  
  1100  // configureTeam patches the team name/description/privacy when values differ
  1101  func configureTeam(client editTeamClient, orgName, teamName string, team org.Team, gt github.Team, parent *int) error {
  1102  	// Do we need to reconfigure any team settings?
  1103  	patch := false
  1104  	if gt.Name != teamName {
  1105  		patch = true
  1106  	}
  1107  	gt.Name = teamName
  1108  	if team.Description != nil && gt.Description != *team.Description {
  1109  		patch = true
  1110  		gt.Description = *team.Description
  1111  	} else {
  1112  		gt.Description = ""
  1113  	}
  1114  	// doesn't have parent in github, but has parent in config
  1115  	if gt.Parent == nil && parent != nil {
  1116  		patch = true
  1117  		gt.ParentTeamID = parent
  1118  	}
  1119  	if gt.Parent != nil { // has parent in github ...
  1120  		if parent == nil { // ... but doesn't need one
  1121  			patch = true
  1122  			gt.Parent = nil
  1123  			gt.ParentTeamID = parent
  1124  		} else if gt.Parent.ID != *parent { // but it's different than the config
  1125  			patch = true
  1126  			gt.Parent = nil
  1127  			gt.ParentTeamID = parent
  1128  		}
  1129  	}
  1130  
  1131  	if team.Privacy != nil && gt.Privacy != string(*team.Privacy) {
  1132  		patch = true
  1133  		gt.Privacy = string(*team.Privacy)
  1134  
  1135  	} else if team.Privacy == nil && (parent != nil || len(team.Children) > 0) && gt.Privacy != "closed" {
  1136  		patch = true
  1137  		gt.Privacy = github.PrivacyClosed // nested teams must be closed
  1138  	}
  1139  
  1140  	if patch { // yes we need to patch
  1141  		if _, err := client.EditTeam(orgName, gt); err != nil {
  1142  			return fmt.Errorf("failed to edit %s team %s(%s): %w", orgName, gt.Slug, gt.Name, err)
  1143  		}
  1144  	}
  1145  	return nil
  1146  }
  1147  
  1148  type teamRepoClient interface {
  1149  	ListTeamReposBySlug(org, teamSlug string) ([]github.Repo, error)
  1150  	UpdateTeamRepoBySlug(org, teamSlug, repo string, permission github.TeamPermission) error
  1151  	RemoveTeamRepoBySlug(org, teamSlug, repo string) error
  1152  }
  1153  
  1154  // configureTeamRepos updates the list of repos that the team has permissions for when necessary
  1155  func configureTeamRepos(client teamRepoClient, githubTeams map[string]github.Team, name, orgName string, team org.Team) error {
  1156  	gt, ok := githubTeams[name]
  1157  	if !ok { // configureTeams is buggy if this is the case
  1158  		return fmt.Errorf("%s not found in id list", name)
  1159  	}
  1160  
  1161  	want := team.Repos
  1162  	have := map[string]github.RepoPermissionLevel{}
  1163  	repos, err := client.ListTeamReposBySlug(orgName, gt.Slug)
  1164  	if err != nil {
  1165  		return fmt.Errorf("failed to list team %d(%s) repos: %w", gt.ID, name, err)
  1166  	}
  1167  	for _, repo := range repos {
  1168  		have[repo.Name] = github.LevelFromPermissions(repo.Permissions)
  1169  	}
  1170  
  1171  	actions := map[string]github.RepoPermissionLevel{}
  1172  	for wantRepo, wantPermission := range want {
  1173  		if havePermission, haveRepo := have[wantRepo]; haveRepo && havePermission == wantPermission {
  1174  			// nothing to do
  1175  			continue
  1176  		}
  1177  		// create or update this permission
  1178  		actions[wantRepo] = wantPermission
  1179  	}
  1180  
  1181  	for haveRepo := range have {
  1182  		if _, wantRepo := want[haveRepo]; !wantRepo {
  1183  			// should remove these permissions
  1184  			actions[haveRepo] = github.None
  1185  		}
  1186  	}
  1187  
  1188  	var updateErrors []error
  1189  	for repo, permission := range actions {
  1190  		var err error
  1191  		switch permission {
  1192  		case github.None:
  1193  			err = client.RemoveTeamRepoBySlug(orgName, gt.Slug, repo)
  1194  		case github.Admin:
  1195  			err = client.UpdateTeamRepoBySlug(orgName, gt.Slug, repo, github.RepoAdmin)
  1196  		case github.Write:
  1197  			err = client.UpdateTeamRepoBySlug(orgName, gt.Slug, repo, github.RepoPush)
  1198  		case github.Read:
  1199  			err = client.UpdateTeamRepoBySlug(orgName, gt.Slug, repo, github.RepoPull)
  1200  		case github.Triage:
  1201  			err = client.UpdateTeamRepoBySlug(orgName, gt.Slug, repo, github.RepoTriage)
  1202  		case github.Maintain:
  1203  			err = client.UpdateTeamRepoBySlug(orgName, gt.Slug, repo, github.RepoMaintain)
  1204  		}
  1205  
  1206  		if err != nil {
  1207  			updateErrors = append(updateErrors, fmt.Errorf("failed to update team %d(%s) permissions on repo %s to %s: %w", gt.ID, name, repo, permission, err))
  1208  		}
  1209  	}
  1210  
  1211  	for childName, childTeam := range team.Children {
  1212  		if err := configureTeamRepos(client, githubTeams, childName, orgName, childTeam); err != nil {
  1213  			updateErrors = append(updateErrors, fmt.Errorf("failed to configure %s child team %s repos: %w", orgName, childName, err))
  1214  		}
  1215  	}
  1216  
  1217  	return utilerrors.NewAggregate(updateErrors)
  1218  }
  1219  
  1220  // teamMembersClient can list/remove/update people to a team.
  1221  type teamMembersClient interface {
  1222  	ListTeamMembersBySlug(org, teamSlug, role string) ([]github.TeamMember, error)
  1223  	ListTeamInvitationsBySlug(org, teamSlug string) ([]github.OrgInvitation, error)
  1224  	RemoveTeamMembershipBySlug(org, teamSlug, user string) error
  1225  	UpdateTeamMembershipBySlug(org, teamSlug, user string, maintainer bool) (*github.TeamMembership, error)
  1226  }
  1227  
  1228  func teamInvitations(client teamMembersClient, orgName, teamSlug string) (sets.Set[string], error) {
  1229  	invitees := sets.Set[string]{}
  1230  	is, err := client.ListTeamInvitationsBySlug(orgName, teamSlug)
  1231  	if err != nil {
  1232  		return nil, err
  1233  	}
  1234  	for _, i := range is {
  1235  		if i.Login == "" {
  1236  			continue
  1237  		}
  1238  		invitees.Insert(github.NormLogin(i.Login))
  1239  	}
  1240  	return invitees, nil
  1241  }
  1242  
  1243  // configureTeamMembers will add/update people to the appropriate role on the team, and remove anyone else.
  1244  func configureTeamMembers(client teamMembersClient, orgName string, gt github.Team, team org.Team, ignoreInvitees bool) error {
  1245  	// Get desired state
  1246  	wantMaintainers := sets.New[string](team.Maintainers...)
  1247  	wantMembers := sets.New[string](team.Members...)
  1248  
  1249  	// Get current state
  1250  	haveMaintainers := sets.Set[string]{}
  1251  	haveMembers := sets.Set[string]{}
  1252  
  1253  	members, err := client.ListTeamMembersBySlug(orgName, gt.Slug, github.RoleMember)
  1254  	if err != nil {
  1255  		return fmt.Errorf("failed to list %s(%s) members: %w", gt.Slug, gt.Name, err)
  1256  	}
  1257  	for _, m := range members {
  1258  		haveMembers.Insert(m.Login)
  1259  	}
  1260  
  1261  	maintainers, err := client.ListTeamMembersBySlug(orgName, gt.Slug, github.RoleMaintainer)
  1262  	if err != nil {
  1263  		return fmt.Errorf("failed to list %s(%s) maintainers: %w", gt.Slug, gt.Name, err)
  1264  	}
  1265  	for _, m := range maintainers {
  1266  		haveMaintainers.Insert(m.Login)
  1267  	}
  1268  
  1269  	invitees := sets.Set[string]{}
  1270  	if !ignoreInvitees {
  1271  		invitees, err = teamInvitations(client, orgName, gt.Slug)
  1272  		if err != nil {
  1273  			return fmt.Errorf("failed to list %s(%s) invitees: %w", gt.Slug, gt.Name, err)
  1274  		}
  1275  	}
  1276  
  1277  	adder := func(user string, super bool) error {
  1278  		if invitees.Has(user) {
  1279  			logrus.Infof("Waiting for %s to accept invitation to %s(%s)", user, gt.Slug, gt.Name)
  1280  			return nil
  1281  		}
  1282  		role := github.RoleMember
  1283  		if super {
  1284  			role = github.RoleMaintainer
  1285  		}
  1286  		tm, err := client.UpdateTeamMembershipBySlug(orgName, gt.Slug, user, super)
  1287  		if err != nil {
  1288  			// Augment the error with the operation we attempted so that the error makes sense after return
  1289  			err = fmt.Errorf("UpdateTeamMembership(%s(%s), %s, %t) failed: %w", gt.Slug, gt.Name, user, super, err)
  1290  			logrus.Warnf(err.Error())
  1291  		} else if tm.State == github.StatePending {
  1292  			logrus.Infof("Invited %s to %s(%s) as a %s", user, gt.Slug, gt.Name, role)
  1293  		} else {
  1294  			logrus.Infof("Set %s as a %s of %s(%s)", user, role, gt.Slug, gt.Name)
  1295  		}
  1296  		return err
  1297  	}
  1298  
  1299  	remover := func(user string) error {
  1300  		err := client.RemoveTeamMembershipBySlug(orgName, gt.Slug, user)
  1301  		if err != nil {
  1302  			// Augment the error with the operation we attempted so that the error makes sense after return
  1303  			err = fmt.Errorf("RemoveTeamMembership(%s(%s), %s) failed: %w", gt.Slug, gt.Name, user, err)
  1304  			logrus.Warnf(err.Error())
  1305  		} else {
  1306  			logrus.Infof("Removed %s from team %s(%s)", user, gt.Slug, gt.Name)
  1307  		}
  1308  		return err
  1309  	}
  1310  
  1311  	want := memberships{members: wantMembers, super: wantMaintainers}
  1312  	have := memberships{members: haveMembers, super: haveMaintainers}
  1313  	return configureMembers(have, want, invitees, adder, remover)
  1314  }