github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/cmd/peribolos/main_test.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  	"reflect"
    24  	"sort"
    25  	"testing"
    26  
    27  	"github.com/google/go-cmp/cmp"
    28  	"sigs.k8s.io/prow/pkg/config/org"
    29  	"sigs.k8s.io/prow/pkg/flagutil"
    30  	"sigs.k8s.io/prow/pkg/github"
    31  
    32  	"k8s.io/apimachinery/pkg/util/sets"
    33  )
    34  
    35  func TestOptions(t *testing.T) {
    36  	cases := []struct {
    37  		name     string
    38  		args     []string
    39  		expected *options
    40  	}{
    41  		{
    42  			name: "missing --config",
    43  			args: []string{},
    44  		},
    45  		{
    46  			name: "bad --github-endpoint",
    47  			args: []string{"--config-path=foo", "--github-endpoint=ht!tp://:dumb"},
    48  		},
    49  		{
    50  			name: "--minAdmins too low",
    51  			args: []string{"--config-path=foo", "--min-admins=1"},
    52  		},
    53  		{
    54  			name: "--maximum-removal-delta too high",
    55  			args: []string{"--config-path=foo", "--maximum-removal-delta=1.1"},
    56  		},
    57  		{
    58  			name: "--maximum-removal-delta too low",
    59  			args: []string{"--config-path=foo", "--maximum-removal-delta=-0.1"},
    60  		},
    61  		{
    62  			name: "reject --dump-full-config without --dump",
    63  			args: []string{"--config-path=foo", "--dump-full-config"},
    64  		},
    65  		{
    66  			name: "maximal delta",
    67  			args: []string{"--config-path=foo", "--maximum-removal-delta=1"},
    68  			expected: &options{
    69  				config:       "foo",
    70  				minAdmins:    defaultMinAdmins,
    71  				requireSelf:  true,
    72  				maximumDelta: 1,
    73  				logLevel:     "info",
    74  			},
    75  		},
    76  		{
    77  			name: "minimal delta",
    78  			args: []string{"--config-path=foo", "--maximum-removal-delta=0"},
    79  			expected: &options{
    80  				config:       "foo",
    81  				minAdmins:    defaultMinAdmins,
    82  				requireSelf:  true,
    83  				maximumDelta: 0,
    84  				logLevel:     "info",
    85  			},
    86  		},
    87  		{
    88  			name: "minimal admins",
    89  			args: []string{"--config-path=foo", "--min-admins=2"},
    90  			expected: &options{
    91  				config:       "foo",
    92  				minAdmins:    2,
    93  				requireSelf:  true,
    94  				maximumDelta: defaultDelta,
    95  				logLevel:     "info",
    96  			},
    97  		},
    98  		{
    99  			name: "reject dump and confirm",
   100  			args: []string{"--confirm", "--dump=frogger"},
   101  		},
   102  		{
   103  			name: "reject dump and config-path",
   104  			args: []string{"--config-path=foo", "--dump=frogger"},
   105  		},
   106  		{
   107  			name: "reject --fix-team-members without --fix-teams",
   108  			args: []string{"--config-path=foo", "--fix-team-members"},
   109  		},
   110  		{
   111  			name: "allow dump without config",
   112  			args: []string{"--dump=frogger"},
   113  			expected: &options{
   114  				minAdmins:    defaultMinAdmins,
   115  				requireSelf:  true,
   116  				maximumDelta: defaultDelta,
   117  				dump:         "frogger",
   118  				logLevel:     "info",
   119  			},
   120  		},
   121  		{
   122  			name: "minimal",
   123  			args: []string{"--config-path=foo"},
   124  			expected: &options{
   125  				config:       "foo",
   126  				minAdmins:    defaultMinAdmins,
   127  				requireSelf:  true,
   128  				maximumDelta: defaultDelta,
   129  				logLevel:     "info",
   130  			},
   131  		},
   132  		{
   133  			name: "full",
   134  			args: []string{"--config-path=foo", "--github-token-path=bar", "--github-endpoint=weird://url", "--confirm=true", "--require-self=false", "--dump=", "--fix-org", "--fix-org-members", "--fix-teams", "--fix-team-members", "--log-level=debug"},
   135  			expected: &options{
   136  				config:         "foo",
   137  				confirm:        true,
   138  				requireSelf:    false,
   139  				minAdmins:      defaultMinAdmins,
   140  				maximumDelta:   defaultDelta,
   141  				fixOrg:         true,
   142  				fixOrgMembers:  true,
   143  				fixTeams:       true,
   144  				fixTeamMembers: true,
   145  				logLevel:       "debug",
   146  			},
   147  		},
   148  	}
   149  
   150  	for _, tc := range cases {
   151  		t.Run(tc.name, func(t *testing.T) {
   152  			flags := flag.NewFlagSet(tc.name, flag.ContinueOnError)
   153  			var actual options
   154  			err := actual.parseArgs(flags, tc.args)
   155  			actual.github = flagutil.GitHubOptions{}
   156  			switch {
   157  			case err == nil && tc.expected == nil:
   158  				t.Errorf("%s: failed to return an error", tc.name)
   159  			case err != nil && tc.expected != nil:
   160  				t.Errorf("%s: unexpected error: %v", tc.name, err)
   161  			case tc.expected != nil && !reflect.DeepEqual(*tc.expected, actual):
   162  				t.Errorf("%s: got incorrect options: %v", tc.name, cmp.Diff(actual, *tc.expected, cmp.AllowUnexported(options{}, flagutil.Strings{}, flagutil.GitHubOptions{})))
   163  			}
   164  		})
   165  	}
   166  }
   167  
   168  type fakeClient struct {
   169  	orgMembers sets.Set[string]
   170  	admins     sets.Set[string]
   171  	invitees   sets.Set[string]
   172  	members    sets.Set[string]
   173  	removed    sets.Set[string]
   174  	newAdmins  sets.Set[string]
   175  	newMembers sets.Set[string]
   176  }
   177  
   178  func (c *fakeClient) BotUser() (*github.UserData, error) {
   179  	return &github.UserData{Login: "me"}, nil
   180  }
   181  
   182  func (c fakeClient) makeMembers(people sets.Set[string]) []github.TeamMember {
   183  	var ret []github.TeamMember
   184  	for p := range people {
   185  		ret = append(ret, github.TeamMember{Login: p})
   186  	}
   187  	return ret
   188  }
   189  
   190  func (c *fakeClient) ListOrgMembers(org, role string) ([]github.TeamMember, error) {
   191  	switch role {
   192  	case github.RoleMember:
   193  		return c.makeMembers(c.members), nil
   194  	case github.RoleAdmin:
   195  		return c.makeMembers(c.admins), nil
   196  	default:
   197  		// RoleAll: implement when/if necessary
   198  		return nil, fmt.Errorf("bad role: %s", role)
   199  	}
   200  }
   201  
   202  func (c *fakeClient) ListOrgInvitations(org string) ([]github.OrgInvitation, error) {
   203  	var ret []github.OrgInvitation
   204  	for p := range c.invitees {
   205  		if p == "fail" {
   206  			return nil, errors.New("injected list org invitations failure")
   207  		}
   208  		ret = append(ret, github.OrgInvitation{
   209  			TeamMember: github.TeamMember{
   210  				Login: p,
   211  			},
   212  		})
   213  	}
   214  	return ret, nil
   215  }
   216  
   217  func (c *fakeClient) RemoveOrgMembership(org, user string) error {
   218  	if user == "fail" {
   219  		return errors.New("injected remove org membership failure")
   220  	}
   221  	c.removed.Insert(user)
   222  	c.admins.Delete(user)
   223  	c.members.Delete(user)
   224  	return nil
   225  }
   226  
   227  func (c *fakeClient) UpdateOrgMembership(org, user string, admin bool) (*github.OrgMembership, error) {
   228  	if user == "fail" {
   229  		return nil, errors.New("injected update org failure")
   230  	}
   231  	var state string
   232  	if c.members.Has(user) || c.admins.Has(user) {
   233  		state = github.StateActive
   234  	} else {
   235  		state = github.StatePending
   236  	}
   237  	var role string
   238  	if admin {
   239  		c.newAdmins.Insert(user)
   240  		c.admins.Insert(user)
   241  		role = github.RoleAdmin
   242  	} else {
   243  		c.newMembers.Insert(user)
   244  		c.members.Insert(user)
   245  		role = github.RoleMember
   246  	}
   247  	return &github.OrgMembership{
   248  		Membership: github.Membership{
   249  			Role:  role,
   250  			State: state,
   251  		},
   252  	}, nil
   253  }
   254  
   255  func (c *fakeClient) ListTeamMembersBySlug(org, teamSlug, role string) ([]github.TeamMember, error) {
   256  	if teamSlug != configuredTeamSlug {
   257  		return nil, fmt.Errorf("only team: %s supported, not %s", configuredTeamSlug, teamSlug)
   258  	}
   259  	switch role {
   260  	case github.RoleMember:
   261  		return c.makeMembers(c.members), nil
   262  	case github.RoleMaintainer:
   263  		return c.makeMembers(c.admins), nil
   264  	default:
   265  		return nil, fmt.Errorf("fake does not support: %s", role)
   266  	}
   267  }
   268  
   269  func (c *fakeClient) ListTeamInvitationsBySlug(org, teamSlug string) ([]github.OrgInvitation, error) {
   270  	if teamSlug != configuredTeamSlug {
   271  		return nil, fmt.Errorf("only team: %s supported, not %s", configuredTeamSlug, teamSlug)
   272  	}
   273  	var ret []github.OrgInvitation
   274  	for p := range c.invitees {
   275  		if p == "fail" {
   276  			return nil, errors.New("injected list org invitations failure")
   277  		}
   278  		ret = append(ret, github.OrgInvitation{
   279  			TeamMember: github.TeamMember{
   280  				Login: p,
   281  			},
   282  		})
   283  	}
   284  	return ret, nil
   285  }
   286  
   287  const configuredTeamSlug = "team-slug"
   288  
   289  func (c *fakeClient) UpdateTeamMembershipBySlug(org, teamSlug, user string, maintainer bool) (*github.TeamMembership, error) {
   290  	if teamSlug != configuredTeamSlug {
   291  		return nil, fmt.Errorf("only team: %s supported, not %s", configuredTeamSlug, teamSlug)
   292  	}
   293  	if user == "fail" {
   294  		return nil, fmt.Errorf("injected failure for %s", user)
   295  	}
   296  	var state string
   297  	if c.orgMembers.Has(user) || len(c.orgMembers) == 0 {
   298  		state = github.StateActive
   299  	} else {
   300  		state = github.StatePending
   301  	}
   302  	var role string
   303  	if maintainer {
   304  		c.newAdmins.Insert(user)
   305  		c.admins.Insert(user)
   306  		role = github.RoleMaintainer
   307  	} else {
   308  		c.newMembers.Insert(user)
   309  		c.members.Insert(user)
   310  		role = github.RoleMember
   311  	}
   312  	return &github.TeamMembership{
   313  		Membership: github.Membership{
   314  			Role:  role,
   315  			State: state,
   316  		},
   317  	}, nil
   318  }
   319  
   320  func (c *fakeClient) RemoveTeamMembershipBySlug(org, teamSlug, user string) error {
   321  	if teamSlug != configuredTeamSlug {
   322  		return fmt.Errorf("only team: %s supported, not %s", configuredTeamSlug, teamSlug)
   323  	}
   324  	if user == "fail" {
   325  		return fmt.Errorf("injected failure for %s", user)
   326  	}
   327  	c.removed.Insert(user)
   328  	c.admins.Delete(user)
   329  	c.members.Delete(user)
   330  	return nil
   331  }
   332  
   333  func TestConfigureMembers(t *testing.T) {
   334  	cases := []struct {
   335  		name     string
   336  		want     memberships
   337  		have     memberships
   338  		remove   sets.Set[string]
   339  		members  sets.Set[string]
   340  		supers   sets.Set[string]
   341  		invitees sets.Set[string]
   342  		err      bool
   343  	}{
   344  		{
   345  			name: "forgot to remove duplicate entry",
   346  			want: memberships{
   347  				members: sets.New[string]("me"),
   348  				super:   sets.New[string]("me"),
   349  			},
   350  			err: true,
   351  		},
   352  		{
   353  			name: "removal fails",
   354  			have: memberships{
   355  				members: sets.New[string]("fail"),
   356  			},
   357  			err: true,
   358  		},
   359  		{
   360  			name: "adding admin fails",
   361  			want: memberships{
   362  				super: sets.New[string]("fail"),
   363  			},
   364  			err: true,
   365  		},
   366  		{
   367  			name: "adding member fails",
   368  			want: memberships{
   369  				members: sets.New[string]("fail"),
   370  			},
   371  			err: true,
   372  		},
   373  		{
   374  			name: "promote to admin",
   375  			have: memberships{
   376  				members: sets.New[string]("promote"),
   377  			},
   378  			want: memberships{
   379  				super: sets.New[string]("promote"),
   380  			},
   381  			supers: sets.New[string]("promote"),
   382  		},
   383  		{
   384  			name: "downgrade to member",
   385  			have: memberships{
   386  				super: sets.New[string]("downgrade"),
   387  			},
   388  			want: memberships{
   389  				members: sets.New[string]("downgrade"),
   390  			},
   391  			members: sets.New[string]("downgrade"),
   392  		},
   393  		{
   394  			name: "some of everything",
   395  			have: memberships{
   396  				super:   sets.New[string]("keep-admin", "drop-admin"),
   397  				members: sets.New[string]("keep-member", "drop-member"),
   398  			},
   399  			want: memberships{
   400  				members: sets.New[string]("keep-member", "new-member"),
   401  				super:   sets.New[string]("keep-admin", "new-admin"),
   402  			},
   403  			remove:  sets.New[string]("drop-admin", "drop-member"),
   404  			members: sets.New[string]("new-member"),
   405  			supers:  sets.New[string]("new-admin"),
   406  		},
   407  		{
   408  			name: "ensure case insensitivity",
   409  			have: memberships{
   410  				super:   sets.New[string]("lower"),
   411  				members: sets.New[string]("UPPER"),
   412  			},
   413  			want: memberships{
   414  				super:   sets.New[string]("Lower"),
   415  				members: sets.New[string]("UpPeR"),
   416  			},
   417  		},
   418  		{
   419  			name: "remove invites for those not in org config",
   420  			have: memberships{
   421  				members: sets.New[string]("member-one", "member-two"),
   422  			},
   423  			want: memberships{
   424  				members: sets.New[string]("member-one", "member-two"),
   425  			},
   426  			remove:   sets.New[string]("member-three"),
   427  			invitees: sets.New[string]("member-three"),
   428  		},
   429  	}
   430  
   431  	for _, tc := range cases {
   432  		t.Run(tc.name, func(t *testing.T) {
   433  			removed := sets.Set[string]{}
   434  			members := sets.Set[string]{}
   435  			supers := sets.Set[string]{}
   436  			adder := func(user string, super bool) error {
   437  				if user == "fail" {
   438  					return fmt.Errorf("injected adder failure for %s", user)
   439  				}
   440  				if super {
   441  					supers.Insert(user)
   442  				} else {
   443  					members.Insert(user)
   444  				}
   445  				return nil
   446  			}
   447  
   448  			remover := func(user string) error {
   449  				if user == "fail" {
   450  					return fmt.Errorf("injected remover failure for %s", user)
   451  				}
   452  				removed.Insert(user)
   453  				return nil
   454  			}
   455  
   456  			err := configureMembers(tc.have, tc.want, tc.invitees, adder, remover)
   457  			switch {
   458  			case err != nil:
   459  				if !tc.err {
   460  					t.Errorf("Unexpected error: %v", err)
   461  				}
   462  			case tc.err:
   463  				t.Errorf("Failed to receive error")
   464  			default:
   465  				if err := cmpLists(sets.List(tc.remove), sets.List(removed)); err != nil {
   466  					t.Errorf("Wrong users removed: %v", err)
   467  				} else if err := cmpLists(sets.List(tc.members), sets.List(members)); err != nil {
   468  					t.Errorf("Wrong members added: %v", err)
   469  				} else if err := cmpLists(sets.List(tc.supers), sets.List(supers)); err != nil {
   470  					t.Errorf("Wrong supers added: %v", err)
   471  				}
   472  			}
   473  		})
   474  	}
   475  }
   476  
   477  func TestConfigureOrgMembers(t *testing.T) {
   478  	cases := []struct {
   479  		name        string
   480  		opt         options
   481  		config      org.Config
   482  		admins      []string
   483  		members     []string
   484  		invitations []string
   485  		err         bool
   486  		remove      []string
   487  		addAdmins   []string
   488  		addMembers  []string
   489  	}{
   490  		{
   491  			name: "too few admins",
   492  			opt: options{
   493  				minAdmins: 5,
   494  			},
   495  			config: org.Config{
   496  				Admins: []string{"joe"},
   497  			},
   498  			err: true,
   499  		},
   500  		{
   501  			name: "remove too many admins",
   502  			opt: options{
   503  				maximumDelta: 0.3,
   504  			},
   505  			config: org.Config{
   506  				Admins: []string{"keep", "me"},
   507  			},
   508  			admins: []string{"a", "b", "c", "keep"},
   509  			err:    true,
   510  		},
   511  		{
   512  			name: "forgot to add self",
   513  			opt: options{
   514  				requireSelf: true,
   515  			},
   516  			config: org.Config{
   517  				Admins: []string{"other"},
   518  			},
   519  			err: true,
   520  		},
   521  		{
   522  			name: "forgot to add required admins",
   523  			opt: options{
   524  				requiredAdmins: flagutil.NewStrings("francis"),
   525  			},
   526  			err: true,
   527  		},
   528  		{
   529  			name:   "can remove self with flag",
   530  			config: org.Config{},
   531  			opt: options{
   532  				maximumDelta: 1,
   533  				requireSelf:  false,
   534  			},
   535  			admins: []string{"me"},
   536  			remove: []string{"me"},
   537  		},
   538  		{
   539  			name: "reject same person with both roles",
   540  			config: org.Config{
   541  				Admins:  []string{"me"},
   542  				Members: []string{"me"},
   543  			},
   544  			err: true,
   545  		},
   546  		{
   547  			name:   "github remove rpc fails",
   548  			admins: []string{"fail"},
   549  			err:    true,
   550  		},
   551  		{
   552  			name: "github add rpc fails",
   553  			config: org.Config{
   554  				Admins: []string{"fail"},
   555  			},
   556  			err: true,
   557  		},
   558  		{
   559  			name: "require team member to be org member",
   560  			config: org.Config{
   561  				Teams: map[string]org.Team{
   562  					"group": {
   563  						Members: []string{"non-member"},
   564  					},
   565  				},
   566  			},
   567  			err: true,
   568  		},
   569  		{
   570  			name: "require team maintainer to be org member",
   571  			config: org.Config{
   572  				Teams: map[string]org.Team{
   573  					"group": {
   574  						Maintainers: []string{"non-member"},
   575  					},
   576  				},
   577  			},
   578  			err: true,
   579  		},
   580  		{
   581  			name: "require team members with upper name to be org member",
   582  			config: org.Config{
   583  				Teams: map[string]org.Team{
   584  					"foo": {
   585  						Members: []string{"Me"},
   586  					},
   587  				},
   588  				Members: []string{"Me"},
   589  			},
   590  			members: []string{"Me"},
   591  		},
   592  		{
   593  			name: "require team maintainer with upper name to be org member",
   594  			config: org.Config{
   595  				Teams: map[string]org.Team{
   596  					"foo": {
   597  						Maintainers: []string{"Me"},
   598  					},
   599  				},
   600  				Admins: []string{"Me"},
   601  			},
   602  			admins: []string{"Me"},
   603  		},
   604  		{
   605  			name: "disallow duplicate names",
   606  			config: org.Config{
   607  				Teams: map[string]org.Team{
   608  					"duplicate": {},
   609  					"other": {
   610  						Previously: []string{"duplicate"},
   611  					},
   612  				},
   613  			},
   614  			err: true,
   615  		},
   616  		{
   617  			name: "disallow duplicate names (single team)",
   618  			config: org.Config{
   619  				Teams: map[string]org.Team{
   620  					"foo": {
   621  						Previously: []string{"foo"},
   622  					},
   623  				},
   624  			},
   625  			err: true,
   626  		},
   627  		{
   628  			name: "trivial case works",
   629  		},
   630  		{
   631  			name: "some of everything",
   632  			config: org.Config{
   633  				Admins:  []string{"keep-admin", "new-admin"},
   634  				Members: []string{"keep-member", "new-member"},
   635  			},
   636  			opt: options{
   637  				maximumDelta: 0.5,
   638  			},
   639  			admins:     []string{"keep-admin", "drop-admin"},
   640  			members:    []string{"keep-member", "drop-member"},
   641  			remove:     []string{"drop-admin", "drop-member"},
   642  			addMembers: []string{"new-member"},
   643  			addAdmins:  []string{"new-admin"},
   644  		},
   645  		{
   646  			name: "do not reinvite",
   647  			config: org.Config{
   648  				Admins:  []string{"invited-admin"},
   649  				Members: []string{"invited-member"},
   650  			},
   651  			invitations: []string{"invited-admin", "invited-member"},
   652  		},
   653  	}
   654  
   655  	for _, tc := range cases {
   656  		t.Run(tc.name, func(t *testing.T) {
   657  			fc := &fakeClient{
   658  				admins:     sets.New[string](tc.admins...),
   659  				members:    sets.New[string](tc.members...),
   660  				removed:    sets.Set[string]{},
   661  				newAdmins:  sets.Set[string]{},
   662  				newMembers: sets.Set[string]{},
   663  			}
   664  
   665  			err := configureOrgMembers(tc.opt, fc, fakeOrg, tc.config, sets.New[string](tc.invitations...))
   666  			switch {
   667  			case err != nil:
   668  				if !tc.err {
   669  					t.Errorf("Unexpected error: %v", err)
   670  				}
   671  			case tc.err:
   672  				t.Errorf("Failed to receive error")
   673  			default:
   674  				if err := cmpLists(tc.remove, sets.List(fc.removed)); err != nil {
   675  					t.Errorf("Wrong users removed: %v", err)
   676  				} else if err := cmpLists(tc.addMembers, sets.List(fc.newMembers)); err != nil {
   677  					t.Errorf("Wrong members added: %v", err)
   678  				} else if err := cmpLists(tc.addAdmins, sets.List(fc.newAdmins)); err != nil {
   679  					t.Errorf("Wrong admins added: %v", err)
   680  				}
   681  			}
   682  		})
   683  	}
   684  }
   685  
   686  type fakeTeamClient struct {
   687  	teams map[string]github.Team
   688  	max   int
   689  }
   690  
   691  func makeFakeTeamClient(teams ...github.Team) *fakeTeamClient {
   692  	fc := fakeTeamClient{
   693  		teams: map[string]github.Team{},
   694  	}
   695  	for _, t := range teams {
   696  		fc.teams[t.Slug] = t
   697  		if t.ID >= fc.max {
   698  			fc.max = t.ID + 1
   699  		}
   700  	}
   701  	return &fc
   702  }
   703  
   704  const fakeOrg = "random-org"
   705  
   706  func (c *fakeTeamClient) CreateTeam(org string, team github.Team) (*github.Team, error) {
   707  	if org != fakeOrg {
   708  		return nil, fmt.Errorf("org must be %s, not %s", fakeOrg, org)
   709  	}
   710  	if team.Name == "fail" {
   711  		return nil, errors.New("injected CreateTeam error")
   712  	}
   713  	c.max++
   714  	team.ID = c.max
   715  	c.teams[team.Slug] = team
   716  	return &team, nil
   717  
   718  }
   719  
   720  func (c *fakeTeamClient) ListTeams(name string) ([]github.Team, error) {
   721  	if name == "fail" {
   722  		return nil, errors.New("injected ListTeams error")
   723  	}
   724  	var teams []github.Team
   725  	for _, t := range c.teams {
   726  		teams = append(teams, t)
   727  	}
   728  	return teams, nil
   729  }
   730  
   731  func (c *fakeTeamClient) DeleteTeamBySlug(org, teamSlug string) error {
   732  	switch _, ok := c.teams[teamSlug]; {
   733  	case !ok:
   734  		return fmt.Errorf("not found %s", teamSlug)
   735  	case teamSlug == "":
   736  		return errors.New("injected DeleteTeam error")
   737  	}
   738  	delete(c.teams, teamSlug)
   739  	return nil
   740  }
   741  
   742  func (c *fakeTeamClient) EditTeam(org string, team github.Team) (*github.Team, error) {
   743  	slug := team.Slug
   744  	t, ok := c.teams[slug]
   745  	if !ok {
   746  		return nil, fmt.Errorf("team %s does not exist", slug)
   747  	}
   748  	switch {
   749  	case team.Description == "fail":
   750  		return nil, errors.New("injected description failure")
   751  	case team.Name == "fail":
   752  		return nil, errors.New("injected name failure")
   753  	case team.Privacy == "fail":
   754  		return nil, errors.New("injected privacy failure")
   755  	}
   756  	if team.Description != "" {
   757  		t.Description = team.Description
   758  	}
   759  	if team.Name != "" {
   760  		t.Name = team.Name
   761  	}
   762  	if team.Privacy != "" {
   763  		t.Privacy = team.Privacy
   764  	}
   765  	if team.ParentTeamID != nil {
   766  		t.Parent = &github.Team{
   767  			ID: *team.ParentTeamID,
   768  		}
   769  	} else {
   770  		t.Parent = nil
   771  	}
   772  	c.teams[slug] = t
   773  	return &t, nil
   774  }
   775  
   776  func TestFindTeam(t *testing.T) {
   777  	cases := []struct {
   778  		name     string
   779  		teams    map[string]github.Team
   780  		current  string
   781  		previous []string
   782  		expected int
   783  	}{
   784  		{
   785  			name: "will find current team",
   786  			teams: map[string]github.Team{
   787  				"hello": {ID: 17},
   788  			},
   789  			current:  "hello",
   790  			expected: 17,
   791  		},
   792  		{
   793  			name: "team does not exist returns nil",
   794  			teams: map[string]github.Team{
   795  				"unrelated": {ID: 5},
   796  			},
   797  			current: "hypothetical",
   798  		},
   799  		{
   800  			name: "will find previous name",
   801  			teams: map[string]github.Team{
   802  				"deprecated name": {ID: 1},
   803  			},
   804  			current:  "current name",
   805  			previous: []string{"archaic name", "deprecated name"},
   806  			expected: 1,
   807  		},
   808  		{
   809  			name: "prioritize current when previous also exists",
   810  			teams: map[string]github.Team{
   811  				"deprecated": {ID: 1},
   812  				"current":    {ID: 2},
   813  			},
   814  			current:  "current",
   815  			previous: []string{"deprecated"},
   816  			expected: 2,
   817  		},
   818  	}
   819  
   820  	for _, tc := range cases {
   821  		t.Run(tc.name, func(t *testing.T) {
   822  			actual := findTeam(tc.teams, tc.current, tc.previous...)
   823  			switch {
   824  			case actual == nil:
   825  				if tc.expected != 0 {
   826  					t.Errorf("failed to find team %d", tc.expected)
   827  				}
   828  			case tc.expected == 0:
   829  				t.Errorf("unexpected team returned: %v", *actual)
   830  			case actual.ID != tc.expected:
   831  				t.Errorf("team %v != expected ID %d", actual, tc.expected)
   832  			}
   833  		})
   834  	}
   835  }
   836  
   837  func TestConfigureTeams(t *testing.T) {
   838  	desc := "so interesting"
   839  	priv := org.Secret
   840  	cases := []struct {
   841  		name              string
   842  		err               bool
   843  		orgNameOverride   string
   844  		ignoreSecretTeams bool
   845  		config            org.Config
   846  		teams             []github.Team
   847  		expected          map[string]github.Team
   848  		deleted           []string
   849  		delta             float64
   850  	}{
   851  		{
   852  			name: "do nothing without error",
   853  		},
   854  		{
   855  			name: "reject duplicated team names (different teams)",
   856  			err:  true,
   857  			config: org.Config{
   858  				Teams: map[string]org.Team{
   859  					"hello": {},
   860  					"there": {Previously: []string{"hello"}},
   861  				},
   862  			},
   863  		},
   864  		{
   865  			name: "reject duplicated team names (single team)",
   866  			err:  true,
   867  			config: org.Config{
   868  				Teams: map[string]org.Team{
   869  					"hello": {Previously: []string{"hello"}},
   870  				},
   871  			},
   872  		},
   873  		{
   874  			name:            "fail to list teams",
   875  			orgNameOverride: "fail",
   876  			err:             true,
   877  		},
   878  		{
   879  			name: "fail to create team",
   880  			config: org.Config{
   881  				Teams: map[string]org.Team{
   882  					"fail": {},
   883  				},
   884  			},
   885  			err: true,
   886  		},
   887  		{
   888  			name: "fail to delete team",
   889  			teams: []github.Team{
   890  				{Name: "fail", ID: -55},
   891  			},
   892  			err: true,
   893  		},
   894  		{
   895  			name: "create missing team",
   896  			teams: []github.Team{
   897  				{Name: "old", ID: 1},
   898  			},
   899  			config: org.Config{
   900  				Teams: map[string]org.Team{
   901  					"new": {},
   902  					"old": {},
   903  				},
   904  			},
   905  			expected: map[string]github.Team{
   906  				"old": {Name: "old", ID: 1},
   907  				"new": {Name: "new", ID: 3},
   908  			},
   909  		},
   910  		{
   911  			name: "reuse existing teams",
   912  			teams: []github.Team{
   913  				{Name: "current", Slug: "current", ID: 1},
   914  				{Name: "deprecated", Slug: "deprecated", ID: 5},
   915  			},
   916  			config: org.Config{
   917  				Teams: map[string]org.Team{
   918  					"current": {},
   919  					"updated": {Previously: []string{"deprecated"}},
   920  				},
   921  			},
   922  			expected: map[string]github.Team{
   923  				"current": {Name: "current", Slug: "current", ID: 1},
   924  				"updated": {Name: "deprecated", Slug: "deprecated", ID: 5},
   925  			},
   926  		},
   927  		{
   928  			name: "delete unused teams",
   929  			teams: []github.Team{
   930  				{
   931  					Name: "unused",
   932  					Slug: "unused",
   933  					ID:   1,
   934  				},
   935  				{
   936  					Name: "used",
   937  					Slug: "used",
   938  					ID:   2,
   939  				},
   940  			},
   941  			config: org.Config{
   942  				Teams: map[string]org.Team{
   943  					"used": {},
   944  				},
   945  			},
   946  			expected: map[string]github.Team{
   947  				"used": {ID: 2, Name: "used", Slug: "used"},
   948  			},
   949  			deleted: []string{"unused"},
   950  		},
   951  		{
   952  			name: "create team with metadata",
   953  			config: org.Config{
   954  				Teams: map[string]org.Team{
   955  					"new": {
   956  						TeamMetadata: org.TeamMetadata{
   957  							Description: &desc,
   958  							Privacy:     &priv,
   959  						},
   960  					},
   961  				},
   962  			},
   963  			expected: map[string]github.Team{
   964  				"new": {ID: 1, Name: "new", Description: desc, Privacy: string(priv)},
   965  			},
   966  		},
   967  		{
   968  			name: "allow deleting many teams",
   969  			teams: []github.Team{
   970  				{
   971  					Name: "unused",
   972  					Slug: "unused",
   973  					ID:   1,
   974  				},
   975  				{
   976  					Name: "used",
   977  					Slug: "used",
   978  					ID:   2,
   979  				},
   980  			},
   981  			config: org.Config{
   982  				Teams: map[string]org.Team{
   983  					"used": {},
   984  				},
   985  			},
   986  			expected: map[string]github.Team{
   987  				"used": {ID: 2, Name: "used", Slug: "used"},
   988  			},
   989  			deleted: []string{"unused"},
   990  			delta:   0.6,
   991  		},
   992  		{
   993  			name: "refuse to delete too many teams",
   994  			teams: []github.Team{
   995  				{
   996  					Name: "unused",
   997  					Slug: "unused",
   998  					ID:   1,
   999  				},
  1000  				{
  1001  					Name: "used",
  1002  					Slug: "used",
  1003  					ID:   2,
  1004  				},
  1005  			},
  1006  			config: org.Config{
  1007  				Teams: map[string]org.Team{
  1008  					"used": {},
  1009  				},
  1010  			},
  1011  			err:   true,
  1012  			delta: 0.1,
  1013  		},
  1014  		{
  1015  			name:              "refuse to delete private teams if ignoring them",
  1016  			ignoreSecretTeams: true,
  1017  			teams: []github.Team{
  1018  				{
  1019  					Name:    "secret",
  1020  					Slug:    "secret",
  1021  					ID:      1,
  1022  					Privacy: string(org.Secret),
  1023  				},
  1024  				{
  1025  					Name:    "closed",
  1026  					Slug:    "closed",
  1027  					ID:      2,
  1028  					Privacy: string(org.Closed),
  1029  				},
  1030  			},
  1031  			config:   org.Config{Teams: map[string]org.Team{}},
  1032  			err:      false,
  1033  			expected: map[string]github.Team{},
  1034  			deleted:  []string{"closed"},
  1035  			delta:    1,
  1036  		},
  1037  	}
  1038  
  1039  	for _, tc := range cases {
  1040  		t.Run(tc.name, func(t *testing.T) {
  1041  			fc := makeFakeTeamClient(tc.teams...)
  1042  			orgName := tc.orgNameOverride
  1043  			if orgName == "" {
  1044  				orgName = fakeOrg
  1045  			}
  1046  			if tc.expected == nil {
  1047  				tc.expected = map[string]github.Team{}
  1048  			}
  1049  			if tc.delta == 0 {
  1050  				tc.delta = 1
  1051  			}
  1052  			actual, err := configureTeams(fc, orgName, tc.config, tc.delta, tc.ignoreSecretTeams)
  1053  			switch {
  1054  			case err != nil:
  1055  				if !tc.err {
  1056  					t.Errorf("unexpected error: %v", err)
  1057  				}
  1058  			case tc.err:
  1059  				t.Errorf("failed to receive error")
  1060  			case !reflect.DeepEqual(actual, tc.expected):
  1061  				t.Errorf("%#v != actual %#v", tc.expected, actual)
  1062  			}
  1063  			for _, slug := range tc.deleted {
  1064  				if team, ok := fc.teams[slug]; ok {
  1065  					t.Errorf("%s still present: %#v", slug, team)
  1066  				}
  1067  			}
  1068  			original, current, deleted := sets.New[string](), sets.New[string](), sets.New[string](tc.deleted...)
  1069  			for _, team := range tc.teams {
  1070  				original.Insert(team.Slug)
  1071  			}
  1072  			for slug := range fc.teams {
  1073  				current.Insert(slug)
  1074  			}
  1075  			if unexpected := original.Difference(current).Difference(deleted); unexpected.Len() > 0 {
  1076  				t.Errorf("the following teams were unexpectedly deleted: %v", sets.List(unexpected))
  1077  			}
  1078  		})
  1079  	}
  1080  }
  1081  
  1082  func TestConfigureTeam(t *testing.T) {
  1083  	old := "old value"
  1084  	cur := "current value"
  1085  	fail := "fail"
  1086  	pfail := org.Privacy(fail)
  1087  	whatev := "whatever"
  1088  	secret := org.Secret
  1089  	parent := 2
  1090  	cases := []struct {
  1091  		name     string
  1092  		err      bool
  1093  		teamName string
  1094  		parent   *int
  1095  		config   org.Team
  1096  		github   github.Team
  1097  		expected github.Team
  1098  	}{
  1099  		{
  1100  			name:     "patch team when name changes",
  1101  			teamName: cur,
  1102  			config: org.Team{
  1103  				Previously: []string{old},
  1104  			},
  1105  			github: github.Team{
  1106  				ID:   1,
  1107  				Name: old,
  1108  			},
  1109  			expected: github.Team{
  1110  				ID:   1,
  1111  				Name: cur,
  1112  			},
  1113  		},
  1114  		{
  1115  			name:     "patch team when description changes",
  1116  			teamName: whatev,
  1117  			parent:   nil,
  1118  			config: org.Team{
  1119  				TeamMetadata: org.TeamMetadata{
  1120  					Description: &cur,
  1121  				},
  1122  			},
  1123  			github: github.Team{
  1124  				ID:          2,
  1125  				Name:        whatev,
  1126  				Description: old,
  1127  			},
  1128  			expected: github.Team{
  1129  				ID:          2,
  1130  				Name:        whatev,
  1131  				Description: cur,
  1132  			},
  1133  		},
  1134  		{
  1135  			name:     "patch team when privacy changes",
  1136  			teamName: whatev,
  1137  			parent:   nil,
  1138  			config: org.Team{
  1139  				TeamMetadata: org.TeamMetadata{
  1140  					Privacy: &secret,
  1141  				},
  1142  			},
  1143  			github: github.Team{
  1144  				ID:      3,
  1145  				Name:    whatev,
  1146  				Privacy: string(org.Closed),
  1147  			},
  1148  			expected: github.Team{
  1149  				ID:      3,
  1150  				Name:    whatev,
  1151  				Privacy: string(secret),
  1152  			},
  1153  		},
  1154  		{
  1155  			name:     "patch team when parent changes",
  1156  			teamName: whatev,
  1157  			parent:   &parent,
  1158  			config:   org.Team{},
  1159  			github: github.Team{
  1160  				ID:   3,
  1161  				Name: whatev,
  1162  				Parent: &github.Team{
  1163  					ID: 4,
  1164  				},
  1165  			},
  1166  			expected: github.Team{
  1167  				ID:   3,
  1168  				Name: whatev,
  1169  				Parent: &github.Team{
  1170  					ID: 2,
  1171  				},
  1172  				Privacy: string(org.Closed),
  1173  			},
  1174  		},
  1175  		{
  1176  			name:     "patch team when parent removed",
  1177  			teamName: whatev,
  1178  			parent:   nil,
  1179  			config:   org.Team{},
  1180  			github: github.Team{
  1181  				ID:   3,
  1182  				Name: whatev,
  1183  				Parent: &github.Team{
  1184  					ID: 2,
  1185  				},
  1186  			},
  1187  			expected: github.Team{
  1188  				ID:     3,
  1189  				Name:   whatev,
  1190  				Parent: nil,
  1191  			},
  1192  		},
  1193  		{
  1194  			name:     "do not patch team when values are the same",
  1195  			teamName: fail,
  1196  			parent:   &parent,
  1197  			config: org.Team{
  1198  				TeamMetadata: org.TeamMetadata{
  1199  					Description: &fail,
  1200  					Privacy:     &pfail,
  1201  				},
  1202  			},
  1203  			github: github.Team{
  1204  				ID:          4,
  1205  				Name:        fail,
  1206  				Description: fail,
  1207  				Privacy:     fail,
  1208  				Parent: &github.Team{
  1209  					ID: 2,
  1210  				},
  1211  			},
  1212  			expected: github.Team{
  1213  				ID:          4,
  1214  				Name:        fail,
  1215  				Description: fail,
  1216  				Privacy:     fail,
  1217  				Parent: &github.Team{
  1218  					ID: 2,
  1219  				},
  1220  			},
  1221  		},
  1222  		{
  1223  			name:     "fail to patch team",
  1224  			teamName: "team",
  1225  			parent:   nil,
  1226  			config: org.Team{
  1227  				TeamMetadata: org.TeamMetadata{
  1228  					Description: &fail,
  1229  				},
  1230  			},
  1231  			github: github.Team{
  1232  				ID:          1,
  1233  				Name:        "team",
  1234  				Description: whatev,
  1235  			},
  1236  			err: true,
  1237  		},
  1238  	}
  1239  
  1240  	for _, tc := range cases {
  1241  		t.Run(tc.name, func(t *testing.T) {
  1242  			fc := makeFakeTeamClient(tc.github)
  1243  			err := configureTeam(fc, fakeOrg, tc.teamName, tc.config, tc.github, tc.parent)
  1244  			switch {
  1245  			case err != nil:
  1246  				if !tc.err {
  1247  					t.Errorf("unexpected error: %v", err)
  1248  				}
  1249  			case tc.err:
  1250  				t.Errorf("failed to receive expected error")
  1251  			case !reflect.DeepEqual(fc.teams[tc.expected.Slug], tc.expected):
  1252  				t.Errorf("actual %+v != expected %+v", fc.teams[tc.expected.Slug], tc.expected)
  1253  			}
  1254  		})
  1255  	}
  1256  }
  1257  
  1258  func TestConfigureTeamMembers(t *testing.T) {
  1259  	cases := []struct {
  1260  		name           string
  1261  		err            bool
  1262  		members        sets.Set[string]
  1263  		maintainers    sets.Set[string]
  1264  		remove         sets.Set[string]
  1265  		addMembers     sets.Set[string]
  1266  		addMaintainers sets.Set[string]
  1267  		ignoreInvitees bool
  1268  		invitees       sets.Set[string]
  1269  		team           org.Team
  1270  		slug           string
  1271  	}{
  1272  		{
  1273  			name: "fail when listing fails",
  1274  			slug: "some-slug",
  1275  			err:  true,
  1276  		},
  1277  		{
  1278  			name:    "fail when removal fails",
  1279  			members: sets.New[string]("fail"),
  1280  			err:     true,
  1281  		},
  1282  		{
  1283  			name: "fail when add fails",
  1284  			team: org.Team{
  1285  				Maintainers: []string{"fail"},
  1286  			},
  1287  			err: true,
  1288  		},
  1289  		{
  1290  			name: "some of everything",
  1291  			team: org.Team{
  1292  				Maintainers: []string{"keep-maintainer", "new-maintainer"},
  1293  				Members:     []string{"keep-member", "new-member"},
  1294  			},
  1295  			maintainers:    sets.New[string]("keep-maintainer", "drop-maintainer"),
  1296  			members:        sets.New[string]("keep-member", "drop-member"),
  1297  			remove:         sets.New[string]("drop-maintainer", "drop-member"),
  1298  			addMembers:     sets.New[string]("new-member"),
  1299  			addMaintainers: sets.New[string]("new-maintainer"),
  1300  		},
  1301  		{
  1302  			name: "do not reinvitee invitees",
  1303  			team: org.Team{
  1304  				Maintainers: []string{"invited-maintainer", "newbie"},
  1305  				Members:     []string{"invited-member"},
  1306  			},
  1307  			invitees:       sets.New[string]("invited-maintainer", "invited-member"),
  1308  			addMaintainers: sets.New[string]("newbie"),
  1309  		},
  1310  		{
  1311  			name: "do not remove pending invitees",
  1312  			team: org.Team{
  1313  				Maintainers: []string{"keep-maintainer"},
  1314  				Members:     []string{"invited-member"},
  1315  			},
  1316  			maintainers: sets.New[string]("keep-maintainer"),
  1317  			invitees:    sets.New[string]("invited-member"),
  1318  			remove:      sets.Set[string]{},
  1319  		},
  1320  		{
  1321  			name: "ignore invitees",
  1322  			team: org.Team{
  1323  				Maintainers: []string{"keep-maintainer"},
  1324  				Members:     []string{"keep-member", "new-member"},
  1325  			},
  1326  			maintainers:    sets.New[string]("keep-maintainer"),
  1327  			members:        sets.New[string]("keep-member"),
  1328  			invitees:       sets.Set[string]{},
  1329  			remove:         sets.Set[string]{},
  1330  			addMembers:     sets.New[string]("new-member"),
  1331  			ignoreInvitees: true,
  1332  		},
  1333  	}
  1334  
  1335  	for _, tc := range cases {
  1336  		gt := github.Team{
  1337  			Slug: configuredTeamSlug,
  1338  			Name: "whatev",
  1339  		}
  1340  		if tc.slug != "" {
  1341  			gt.Slug = tc.slug
  1342  		}
  1343  		t.Run(tc.name, func(t *testing.T) {
  1344  			fc := &fakeClient{
  1345  				admins:     sets.KeySet[string](tc.maintainers),
  1346  				members:    sets.KeySet[string](tc.members),
  1347  				invitees:   sets.KeySet[string](tc.invitees),
  1348  				removed:    sets.Set[string]{},
  1349  				newAdmins:  sets.Set[string]{},
  1350  				newMembers: sets.Set[string]{},
  1351  			}
  1352  			err := configureTeamMembers(fc, "", gt, tc.team, tc.ignoreInvitees)
  1353  			switch {
  1354  			case err != nil:
  1355  				if !tc.err {
  1356  					t.Errorf("Unexpected error: %v", err)
  1357  				}
  1358  			case tc.err:
  1359  				t.Errorf("Failed to receive error")
  1360  			default:
  1361  				if err := cmpLists(sets.List(tc.remove), sets.List(fc.removed)); err != nil {
  1362  					t.Errorf("Wrong users removed: %v", err)
  1363  				} else if err := cmpLists(sets.List(tc.addMembers), sets.List(fc.newMembers)); err != nil {
  1364  					t.Errorf("Wrong members added: %v", err)
  1365  				} else if err := cmpLists(sets.List(tc.addMaintainers), sets.List(fc.newAdmins)); err != nil {
  1366  					t.Errorf("Wrong admins added: %v", err)
  1367  				}
  1368  			}
  1369  
  1370  		})
  1371  	}
  1372  }
  1373  
  1374  func cmpLists(a, b []string) error {
  1375  	if a == nil {
  1376  		a = []string{}
  1377  	}
  1378  	if b == nil {
  1379  		b = []string{}
  1380  	}
  1381  	sort.Strings(a)
  1382  	sort.Strings(b)
  1383  	if !reflect.DeepEqual(a, b) {
  1384  		return fmt.Errorf("%v != %v", a, b)
  1385  	}
  1386  	return nil
  1387  }
  1388  
  1389  type fakeOrgClient struct {
  1390  	current github.Organization
  1391  	changed bool
  1392  }
  1393  
  1394  func (o *fakeOrgClient) GetOrg(name string) (*github.Organization, error) {
  1395  	if name == "fail" {
  1396  		return nil, errors.New("injected GetOrg error")
  1397  	}
  1398  	return &o.current, nil
  1399  }
  1400  
  1401  func (o *fakeOrgClient) EditOrg(name string, org github.Organization) (*github.Organization, error) {
  1402  	if org.Description == "fail" {
  1403  		return nil, errors.New("injected EditOrg error")
  1404  	}
  1405  	o.current = org
  1406  	o.changed = true
  1407  	return &o.current, nil
  1408  }
  1409  
  1410  func TestUpdateBool(t *testing.T) {
  1411  	yes := true
  1412  	no := false
  1413  	cases := []struct {
  1414  		name string
  1415  		have *bool
  1416  		want *bool
  1417  		end  bool
  1418  		ret  *bool
  1419  	}{
  1420  		{
  1421  			name: "panic on nil have",
  1422  			want: &no,
  1423  		},
  1424  		{
  1425  			name: "never change on nil want",
  1426  			want: nil,
  1427  			have: &yes,
  1428  			end:  yes,
  1429  			ret:  &no,
  1430  		},
  1431  		{
  1432  			name: "do not change if same",
  1433  			want: &yes,
  1434  			have: &yes,
  1435  			end:  yes,
  1436  			ret:  &no,
  1437  		},
  1438  		{
  1439  			name: "change if different",
  1440  			want: &no,
  1441  			have: &yes,
  1442  			end:  no,
  1443  			ret:  &yes,
  1444  		},
  1445  	}
  1446  
  1447  	for _, tc := range cases {
  1448  		t.Run(tc.name, func(t *testing.T) {
  1449  			defer func() {
  1450  				wantPanic := tc.ret == nil
  1451  				r := recover()
  1452  				gotPanic := r != nil
  1453  				switch {
  1454  				case gotPanic && !wantPanic:
  1455  					t.Errorf("unexpected panic: %v", r)
  1456  				case wantPanic && !gotPanic:
  1457  					t.Errorf("failed to receive panic")
  1458  				}
  1459  			}()
  1460  			if tc.have != nil { // prevent overwriting what tc.have points to for next test case
  1461  				have := *tc.have
  1462  				tc.have = &have
  1463  			}
  1464  			ret := updateBool(tc.have, tc.want)
  1465  			switch {
  1466  			case ret != *tc.ret:
  1467  				t.Errorf("return value %t != expected %t", ret, *tc.ret)
  1468  			case *tc.have != tc.end:
  1469  				t.Errorf("end value %t != expected %t", *tc.have, tc.end)
  1470  			}
  1471  		})
  1472  	}
  1473  }
  1474  
  1475  func TestUpdateString(t *testing.T) {
  1476  	no := false
  1477  	yes := true
  1478  	hello := "hello"
  1479  	world := "world"
  1480  	empty := ""
  1481  	cases := []struct {
  1482  		name     string
  1483  		have     *string
  1484  		want     *string
  1485  		expected string
  1486  		ret      *bool
  1487  	}{
  1488  		{
  1489  			name: "panic on nil have",
  1490  			want: &hello,
  1491  		},
  1492  		{
  1493  			name:     "never change on nil want",
  1494  			want:     nil,
  1495  			have:     &hello,
  1496  			expected: hello,
  1497  			ret:      &no,
  1498  		},
  1499  		{
  1500  			name:     "do not change if same",
  1501  			want:     &world,
  1502  			have:     &world,
  1503  			expected: world,
  1504  			ret:      &no,
  1505  		},
  1506  		{
  1507  			name:     "change if different",
  1508  			want:     &empty,
  1509  			have:     &hello,
  1510  			expected: empty,
  1511  			ret:      &yes,
  1512  		},
  1513  	}
  1514  
  1515  	for _, tc := range cases {
  1516  		t.Run(tc.name, func(t *testing.T) {
  1517  			defer func() {
  1518  				wantPanic := tc.ret == nil
  1519  				r := recover()
  1520  				gotPanic := r != nil
  1521  				switch {
  1522  				case gotPanic && !wantPanic:
  1523  					t.Errorf("unexpected panic: %v", r)
  1524  				case wantPanic && !gotPanic:
  1525  					t.Errorf("failed to receive panic")
  1526  				}
  1527  			}()
  1528  			if tc.have != nil { // prevent overwriting what tc.have points to for next test case
  1529  				have := *tc.have
  1530  				tc.have = &have
  1531  			}
  1532  			ret := updateString(tc.have, tc.want)
  1533  			switch {
  1534  			case ret != *tc.ret:
  1535  				t.Errorf("return value %t != expected %t", ret, *tc.ret)
  1536  			case *tc.have != tc.expected:
  1537  				t.Errorf("end value %s != expected %s", *tc.have, tc.expected)
  1538  			}
  1539  		})
  1540  	}
  1541  }
  1542  
  1543  func TestConfigureOrgMeta(t *testing.T) {
  1544  	filled := github.Organization{
  1545  		BillingEmail:                 "be",
  1546  		Company:                      "co",
  1547  		Email:                        "em",
  1548  		Location:                     "lo",
  1549  		Name:                         "na",
  1550  		Description:                  "de",
  1551  		HasOrganizationProjects:      true,
  1552  		HasRepositoryProjects:        true,
  1553  		DefaultRepositoryPermission:  "not-a-real-value",
  1554  		MembersCanCreateRepositories: true,
  1555  	}
  1556  	yes := true
  1557  	no := false
  1558  	str := "random-letters"
  1559  	fail := "fail"
  1560  	read := github.Read
  1561  
  1562  	cases := []struct {
  1563  		name     string
  1564  		orgName  string
  1565  		want     org.Metadata
  1566  		have     github.Organization
  1567  		expected github.Organization
  1568  		err      bool
  1569  		change   bool
  1570  	}{
  1571  		{
  1572  			name:     "no want means no change",
  1573  			have:     filled,
  1574  			expected: filled,
  1575  			change:   false,
  1576  		},
  1577  		{
  1578  			name:    "fail if GetOrg fails",
  1579  			orgName: fail,
  1580  			err:     true,
  1581  		},
  1582  		{
  1583  			name: "fail if EditOrg fails",
  1584  			want: org.Metadata{Description: &fail},
  1585  			err:  true,
  1586  		},
  1587  		{
  1588  			name: "billing diff causes change",
  1589  			want: org.Metadata{BillingEmail: &str},
  1590  			expected: github.Organization{
  1591  				BillingEmail: str,
  1592  			},
  1593  			change: true,
  1594  		},
  1595  		{
  1596  			name: "company diff causes change",
  1597  			want: org.Metadata{Company: &str},
  1598  			expected: github.Organization{
  1599  				Company: str,
  1600  			},
  1601  			change: true,
  1602  		},
  1603  		{
  1604  			name: "email diff causes change",
  1605  			want: org.Metadata{Email: &str},
  1606  			expected: github.Organization{
  1607  				Email: str,
  1608  			},
  1609  			change: true,
  1610  		},
  1611  		{
  1612  			name: "location diff causes change",
  1613  			want: org.Metadata{Location: &str},
  1614  			expected: github.Organization{
  1615  				Location: str,
  1616  			},
  1617  			change: true,
  1618  		},
  1619  		{
  1620  			name: "name diff causes change",
  1621  			want: org.Metadata{Name: &str},
  1622  			expected: github.Organization{
  1623  				Name: str,
  1624  			},
  1625  			change: true,
  1626  		},
  1627  		{
  1628  			name: "org projects diff causes change",
  1629  			want: org.Metadata{HasOrganizationProjects: &yes},
  1630  			expected: github.Organization{
  1631  				HasOrganizationProjects: yes,
  1632  			},
  1633  			change: true,
  1634  		},
  1635  		{
  1636  			name: "repo projects diff causes change",
  1637  			want: org.Metadata{HasRepositoryProjects: &yes},
  1638  			expected: github.Organization{
  1639  				HasRepositoryProjects: yes,
  1640  			},
  1641  			change: true,
  1642  		},
  1643  		{
  1644  			name: "default permission diff causes change",
  1645  			want: org.Metadata{DefaultRepositoryPermission: &read},
  1646  			expected: github.Organization{
  1647  				DefaultRepositoryPermission: string(read),
  1648  			},
  1649  			change: true,
  1650  		},
  1651  		{
  1652  			name: "members can create diff causes change",
  1653  			want: org.Metadata{MembersCanCreateRepositories: &yes},
  1654  			expected: github.Organization{
  1655  				MembersCanCreateRepositories: yes,
  1656  			},
  1657  			change: true,
  1658  		},
  1659  		{
  1660  			name: "change all values at once",
  1661  			have: filled,
  1662  			want: org.Metadata{
  1663  				BillingEmail:                 &str,
  1664  				Company:                      &str,
  1665  				Email:                        &str,
  1666  				Location:                     &str,
  1667  				Name:                         &str,
  1668  				Description:                  &str,
  1669  				HasOrganizationProjects:      &no,
  1670  				HasRepositoryProjects:        &no,
  1671  				MembersCanCreateRepositories: &no,
  1672  				DefaultRepositoryPermission:  &read,
  1673  			},
  1674  			expected: github.Organization{
  1675  				BillingEmail:                 str,
  1676  				Company:                      str,
  1677  				Email:                        str,
  1678  				Location:                     str,
  1679  				Name:                         str,
  1680  				Description:                  str,
  1681  				HasOrganizationProjects:      no,
  1682  				HasRepositoryProjects:        no,
  1683  				MembersCanCreateRepositories: no,
  1684  				DefaultRepositoryPermission:  string(read),
  1685  			},
  1686  			change: true,
  1687  		},
  1688  	}
  1689  
  1690  	for _, tc := range cases {
  1691  		t.Run(tc.name, func(t *testing.T) {
  1692  			if tc.orgName == "" {
  1693  				tc.orgName = "whatever"
  1694  			}
  1695  			fc := fakeOrgClient{
  1696  				current: tc.have,
  1697  			}
  1698  			err := configureOrgMeta(&fc, tc.orgName, tc.want)
  1699  			switch {
  1700  			case err != nil:
  1701  				if !tc.err {
  1702  					t.Errorf("unexpected error: %v", err)
  1703  				}
  1704  			case tc.err:
  1705  				t.Errorf("failed to receive error")
  1706  			case tc.change != fc.changed:
  1707  				t.Errorf("changed %t != expected %t", fc.changed, tc.change)
  1708  			case !reflect.DeepEqual(fc.current, tc.expected):
  1709  				t.Errorf("current %#v != expected %#v", fc.current, tc.expected)
  1710  			}
  1711  		})
  1712  	}
  1713  }
  1714  
  1715  func TestDumpOrgConfig(t *testing.T) {
  1716  	empty := ""
  1717  	hello := "Hello"
  1718  	details := "wise and brilliant exemplary human specimens"
  1719  	yes := true
  1720  	no := false
  1721  	perm := github.Write
  1722  	pub := org.Privacy("")
  1723  	secret := org.Secret
  1724  	closed := org.Closed
  1725  	repoName := "project"
  1726  	repoDescription := "awesome testing project"
  1727  	repoHomepage := "https://www.somewhe.re/something/"
  1728  	master := "master-branch"
  1729  	cases := []struct {
  1730  		name              string
  1731  		orgOverride       string
  1732  		ignoreSecretTeams bool
  1733  		meta              github.Organization
  1734  		members           []string
  1735  		admins            []string
  1736  		teams             []github.Team
  1737  		teamMembers       map[string][]string
  1738  		maintainers       map[string][]string
  1739  		repoPermissions   map[string][]github.Repo
  1740  		repos             []github.FullRepo
  1741  		expected          org.Config
  1742  		err               bool
  1743  	}{
  1744  		{
  1745  			name:        "fails if GetOrg fails",
  1746  			orgOverride: "fail",
  1747  			err:         true,
  1748  		},
  1749  		{
  1750  			name:    "fails if ListOrgMembers fails",
  1751  			err:     true,
  1752  			members: []string{"hello", "fail"},
  1753  		},
  1754  		{
  1755  			name: "fails if ListTeams fails",
  1756  			err:  true,
  1757  			teams: []github.Team{
  1758  				{
  1759  					Name: "fail",
  1760  					ID:   3,
  1761  				},
  1762  			},
  1763  		},
  1764  		{
  1765  			name: "fails if ListTeamMembersFails",
  1766  			err:  true,
  1767  			teams: []github.Team{
  1768  				{
  1769  					Name: "fred",
  1770  					ID:   -1,
  1771  				},
  1772  			},
  1773  		},
  1774  		{
  1775  			name: "fails if GetTeams fails",
  1776  			err:  true,
  1777  			repos: []github.FullRepo{
  1778  				{
  1779  					Repo: github.Repo{
  1780  						Name: "fail",
  1781  					},
  1782  				},
  1783  			},
  1784  		},
  1785  		{
  1786  			name:   "fails if not as an admin of the org",
  1787  			err:    true,
  1788  			admins: []string{"not-admin"},
  1789  		},
  1790  		{
  1791  			name: "basically works",
  1792  			meta: github.Organization{
  1793  				Name:                         hello,
  1794  				MembersCanCreateRepositories: yes,
  1795  				DefaultRepositoryPermission:  string(perm),
  1796  			},
  1797  			members: []string{"george", "jungle", "banana"},
  1798  			admins:  []string{"admin", "james", "giant", "peach"},
  1799  			teams: []github.Team{
  1800  				{
  1801  					ID:          5,
  1802  					Slug:        "team-5",
  1803  					Name:        "friends",
  1804  					Description: details,
  1805  				},
  1806  				{
  1807  					ID:   6,
  1808  					Slug: "team-6",
  1809  					Name: "enemies",
  1810  				},
  1811  				{
  1812  					ID:   7,
  1813  					Slug: "team-7",
  1814  					Name: "archenemies",
  1815  					Parent: &github.Team{
  1816  						ID:   6,
  1817  						Slug: "team-6",
  1818  						Name: "enemies",
  1819  					},
  1820  					Privacy: string(org.Secret),
  1821  				},
  1822  			},
  1823  			teamMembers: map[string][]string{
  1824  				"team-5": {"george", "james"},
  1825  				"team-6": {"george"},
  1826  				"team-7": {},
  1827  			},
  1828  			maintainers: map[string][]string{
  1829  				"team-5": {},
  1830  				"team-6": {"giant", "jungle"},
  1831  				"team-7": {"banana"},
  1832  			},
  1833  			repoPermissions: map[string][]github.Repo{
  1834  				"team-5": {},
  1835  				"team-6": {{Name: "pull-repo", Permissions: github.RepoPermissions{Pull: true}}},
  1836  				"team-7": {{Name: "pull-repo", Permissions: github.RepoPermissions{Pull: true}}, {Name: "admin-repo", Permissions: github.RepoPermissions{Admin: true}}},
  1837  			},
  1838  			repos: []github.FullRepo{
  1839  				{
  1840  					Repo: github.Repo{
  1841  						Name:          repoName,
  1842  						Description:   repoDescription,
  1843  						Homepage:      repoHomepage,
  1844  						Private:       false,
  1845  						HasIssues:     true,
  1846  						HasProjects:   true,
  1847  						HasWiki:       true,
  1848  						Archived:      true,
  1849  						DefaultBranch: master,
  1850  					},
  1851  				},
  1852  			},
  1853  			expected: org.Config{
  1854  				Metadata: org.Metadata{
  1855  					Name:                         &hello,
  1856  					BillingEmail:                 &empty,
  1857  					Company:                      &empty,
  1858  					Email:                        &empty,
  1859  					Description:                  &empty,
  1860  					Location:                     &empty,
  1861  					HasOrganizationProjects:      &no,
  1862  					HasRepositoryProjects:        &no,
  1863  					DefaultRepositoryPermission:  &perm,
  1864  					MembersCanCreateRepositories: &yes,
  1865  				},
  1866  				Teams: map[string]org.Team{
  1867  					"friends": {
  1868  						TeamMetadata: org.TeamMetadata{
  1869  							Description: &details,
  1870  							Privacy:     &pub,
  1871  						},
  1872  						Members:     []string{"george", "james"},
  1873  						Maintainers: []string{},
  1874  						Children:    map[string]org.Team{},
  1875  						Repos:       map[string]github.RepoPermissionLevel{},
  1876  					},
  1877  					"enemies": {
  1878  						TeamMetadata: org.TeamMetadata{
  1879  							Description: &empty,
  1880  							Privacy:     &pub,
  1881  						},
  1882  						Members:     []string{"george"},
  1883  						Maintainers: []string{"giant", "jungle"},
  1884  						Repos: map[string]github.RepoPermissionLevel{
  1885  							"pull-repo": github.Read,
  1886  						},
  1887  						Children: map[string]org.Team{
  1888  							"archenemies": {
  1889  								TeamMetadata: org.TeamMetadata{
  1890  									Description: &empty,
  1891  									Privacy:     &secret,
  1892  								},
  1893  								Members:     []string{},
  1894  								Maintainers: []string{"banana"},
  1895  								Repos: map[string]github.RepoPermissionLevel{
  1896  									"pull-repo":  github.Read,
  1897  									"admin-repo": github.Admin,
  1898  								},
  1899  								Children: map[string]org.Team{},
  1900  							},
  1901  						},
  1902  					},
  1903  				},
  1904  				Members: []string{"george", "jungle", "banana"},
  1905  				Admins:  []string{"admin", "james", "giant", "peach"},
  1906  				Repos: map[string]org.Repo{
  1907  					"project": {
  1908  						Description:      &repoDescription,
  1909  						HomePage:         &repoHomepage,
  1910  						HasProjects:      &yes,
  1911  						AllowMergeCommit: &no,
  1912  						AllowRebaseMerge: &no,
  1913  						AllowSquashMerge: &no,
  1914  						Archived:         &yes,
  1915  						DefaultBranch:    &master,
  1916  					},
  1917  				},
  1918  			},
  1919  		},
  1920  		{
  1921  			name:              "ignores private teams when expected to",
  1922  			ignoreSecretTeams: true,
  1923  			meta: github.Organization{
  1924  				Name:                         hello,
  1925  				MembersCanCreateRepositories: yes,
  1926  				DefaultRepositoryPermission:  string(perm),
  1927  			},
  1928  			members: []string{"george", "jungle", "banana"},
  1929  			admins:  []string{"admin", "james", "giant", "peach"},
  1930  			teams: []github.Team{
  1931  				{
  1932  					ID:          5,
  1933  					Slug:        "team-5",
  1934  					Name:        "friends",
  1935  					Description: details,
  1936  				},
  1937  				{
  1938  					ID:   6,
  1939  					Slug: "team-6",
  1940  					Name: "enemies",
  1941  				},
  1942  				{
  1943  					ID:   7,
  1944  					Slug: "team-7",
  1945  					Name: "archenemies",
  1946  					Parent: &github.Team{
  1947  						Slug: "team-6",
  1948  						Name: "enemies",
  1949  					},
  1950  					Privacy: string(org.Secret),
  1951  				},
  1952  				{
  1953  					ID:   8,
  1954  					Slug: "team-8",
  1955  					Name: "frenemies",
  1956  					Parent: &github.Team{
  1957  						ID:   6,
  1958  						Slug: "team-6",
  1959  						Name: "enemies",
  1960  					},
  1961  					Privacy: string(org.Closed),
  1962  				},
  1963  			},
  1964  			teamMembers: map[string][]string{
  1965  				"team-5": {"george", "james"},
  1966  				"team-6": {"george"},
  1967  				"team-7": {},
  1968  				"team-8": {"patrick"},
  1969  			},
  1970  			maintainers: map[string][]string{
  1971  				"team-5": {},
  1972  				"team-6": {"giant", "jungle"},
  1973  				"team-7": {"banana"},
  1974  				"team-8": {"starfish"},
  1975  			},
  1976  			expected: org.Config{
  1977  				Metadata: org.Metadata{
  1978  					Name:                         &hello,
  1979  					BillingEmail:                 &empty,
  1980  					Company:                      &empty,
  1981  					Email:                        &empty,
  1982  					Description:                  &empty,
  1983  					Location:                     &empty,
  1984  					HasOrganizationProjects:      &no,
  1985  					HasRepositoryProjects:        &no,
  1986  					DefaultRepositoryPermission:  &perm,
  1987  					MembersCanCreateRepositories: &yes,
  1988  				},
  1989  				Teams: map[string]org.Team{
  1990  					"friends": {
  1991  						TeamMetadata: org.TeamMetadata{
  1992  							Description: &details,
  1993  							Privacy:     &pub,
  1994  						},
  1995  						Members:     []string{"george", "james"},
  1996  						Maintainers: []string{},
  1997  						Children:    map[string]org.Team{},
  1998  						Repos:       map[string]github.RepoPermissionLevel{},
  1999  					},
  2000  					"enemies": {
  2001  						TeamMetadata: org.TeamMetadata{
  2002  							Description: &empty,
  2003  							Privacy:     &pub,
  2004  						},
  2005  						Members:     []string{"george"},
  2006  						Maintainers: []string{"giant", "jungle"},
  2007  						Children: map[string]org.Team{
  2008  							"frenemies": {
  2009  								TeamMetadata: org.TeamMetadata{
  2010  									Description: &empty,
  2011  									Privacy:     &closed,
  2012  								},
  2013  								Members:     []string{"patrick"},
  2014  								Maintainers: []string{"starfish"},
  2015  								Children:    map[string]org.Team{},
  2016  								Repos:       map[string]github.RepoPermissionLevel{},
  2017  							},
  2018  						},
  2019  						Repos: map[string]github.RepoPermissionLevel{},
  2020  					},
  2021  				},
  2022  				Members: []string{"george", "jungle", "banana"},
  2023  				Admins:  []string{"admin", "james", "giant", "peach"},
  2024  				Repos:   map[string]org.Repo{},
  2025  			},
  2026  		},
  2027  	}
  2028  
  2029  	for _, tc := range cases {
  2030  		t.Run(tc.name, func(t *testing.T) {
  2031  			orgName := "random-org"
  2032  			if tc.orgOverride != "" {
  2033  				orgName = tc.orgOverride
  2034  			}
  2035  			fc := fakeDumpClient{
  2036  				name:            orgName,
  2037  				members:         tc.members,
  2038  				admins:          tc.admins,
  2039  				meta:            tc.meta,
  2040  				teams:           tc.teams,
  2041  				teamMembers:     tc.teamMembers,
  2042  				maintainers:     tc.maintainers,
  2043  				repoPermissions: tc.repoPermissions,
  2044  				repos:           tc.repos,
  2045  			}
  2046  			actual, err := dumpOrgConfig(fc, orgName, tc.ignoreSecretTeams, "")
  2047  			switch {
  2048  			case err != nil:
  2049  				if !tc.err {
  2050  					t.Errorf("unexpected error: %v", err)
  2051  				}
  2052  			case tc.err:
  2053  				t.Errorf("failed to receive error")
  2054  			default:
  2055  				fixup(actual)
  2056  				fixup(&tc.expected)
  2057  				if diff := cmp.Diff(actual, &tc.expected); diff != "" {
  2058  					t.Errorf("did not get correct config, diff: %s", diff)
  2059  				}
  2060  
  2061  			}
  2062  		})
  2063  	}
  2064  }
  2065  
  2066  type fakeDumpClient struct {
  2067  	name            string
  2068  	members         []string
  2069  	admins          []string
  2070  	meta            github.Organization
  2071  	teams           []github.Team
  2072  	teamMembers     map[string][]string
  2073  	maintainers     map[string][]string
  2074  	repoPermissions map[string][]github.Repo
  2075  	repos           []github.FullRepo
  2076  }
  2077  
  2078  func (c fakeDumpClient) GetOrg(name string) (*github.Organization, error) {
  2079  	if name != c.name {
  2080  		return nil, errors.New("bad name")
  2081  	}
  2082  	if name == "fail" {
  2083  		return nil, errors.New("injected GetOrg error")
  2084  	}
  2085  	return &c.meta, nil
  2086  }
  2087  
  2088  func (c fakeDumpClient) makeMembers(people []string) ([]github.TeamMember, error) {
  2089  	var ret []github.TeamMember
  2090  	for _, p := range people {
  2091  		if p == "fail" {
  2092  			return nil, errors.New("injected makeMembers error")
  2093  		}
  2094  		ret = append(ret, github.TeamMember{Login: p})
  2095  	}
  2096  	return ret, nil
  2097  }
  2098  
  2099  func (c fakeDumpClient) ListOrgMembers(name, role string) ([]github.TeamMember, error) {
  2100  	switch {
  2101  	case name != c.name:
  2102  		return nil, fmt.Errorf("bad org: %s", name)
  2103  	case role == github.RoleAdmin:
  2104  		return c.makeMembers(c.admins)
  2105  	case role == github.RoleMember:
  2106  		return c.makeMembers(c.members)
  2107  	}
  2108  	return nil, fmt.Errorf("bad role: %s", role)
  2109  }
  2110  
  2111  func (c fakeDumpClient) ListTeams(name string) ([]github.Team, error) {
  2112  	if name != c.name {
  2113  		return nil, fmt.Errorf("bad org: %s", name)
  2114  	}
  2115  
  2116  	for _, t := range c.teams {
  2117  		if t.Name == "fail" {
  2118  			return nil, errors.New("injected ListTeams error")
  2119  		}
  2120  	}
  2121  	return c.teams, nil
  2122  }
  2123  
  2124  func (c fakeDumpClient) ListTeamMembersBySlug(org, teamSlug, role string) ([]github.TeamMember, error) {
  2125  	var mapping map[string][]string
  2126  	switch {
  2127  	case teamSlug == "":
  2128  		return nil, errors.New("injected ListTeamMembers error")
  2129  	case role == github.RoleMaintainer:
  2130  		mapping = c.maintainers
  2131  	case role == github.RoleMember:
  2132  		mapping = c.teamMembers
  2133  	default:
  2134  		return nil, fmt.Errorf("bad role: %s", role)
  2135  	}
  2136  	people, ok := mapping[teamSlug]
  2137  	if !ok {
  2138  		return nil, fmt.Errorf("team does not exist: %s", teamSlug)
  2139  	}
  2140  	return c.makeMembers(people)
  2141  }
  2142  
  2143  func (c fakeDumpClient) ListTeamReposBySlug(org, teamSlug string) ([]github.Repo, error) {
  2144  	if teamSlug == "" {
  2145  		return nil, errors.New("injected ListTeamRepos error")
  2146  	}
  2147  
  2148  	return c.repoPermissions[teamSlug], nil
  2149  }
  2150  
  2151  func (c fakeDumpClient) GetRepos(org string, isUser bool) ([]github.Repo, error) {
  2152  	var repos []github.Repo
  2153  	for _, repo := range c.repos {
  2154  		if repo.Name == "fail" {
  2155  			return nil, fmt.Errorf("injected GetRepos error")
  2156  		}
  2157  		repos = append(repos, repo.Repo)
  2158  	}
  2159  
  2160  	return repos, nil
  2161  }
  2162  
  2163  func (c fakeDumpClient) GetRepo(owner, repo string) (github.FullRepo, error) {
  2164  	for _, r := range c.repos {
  2165  		switch {
  2166  		case r.Name == "fail":
  2167  			return r, fmt.Errorf("injected GetRepo error")
  2168  		case r.Name == repo:
  2169  			return r, nil
  2170  		}
  2171  	}
  2172  
  2173  	return github.FullRepo{}, fmt.Errorf("not found")
  2174  }
  2175  
  2176  func (c fakeDumpClient) BotUser() (*github.UserData, error) {
  2177  	return &github.UserData{Login: "admin"}, nil
  2178  }
  2179  
  2180  func fixup(ret *org.Config) {
  2181  	if ret == nil {
  2182  		return
  2183  	}
  2184  	sort.Strings(ret.Members)
  2185  	sort.Strings(ret.Admins)
  2186  	for name, team := range ret.Teams {
  2187  		sort.Strings(team.Members)
  2188  		sort.Strings(team.Maintainers)
  2189  		sort.Strings(team.Previously)
  2190  		ret.Teams[name] = team
  2191  	}
  2192  }
  2193  
  2194  func TestOrgInvitations(t *testing.T) {
  2195  	cases := []struct {
  2196  		name     string
  2197  		opt      options
  2198  		invitees sets.Set[string] // overrides
  2199  		expected sets.Set[string]
  2200  		err      bool
  2201  	}{
  2202  		{
  2203  			name:     "do not call on empty options",
  2204  			invitees: sets.New[string]("him", "her", "them"),
  2205  			expected: sets.Set[string]{},
  2206  		},
  2207  		{
  2208  			name: "call if fixOrgMembers",
  2209  			opt: options{
  2210  				fixOrgMembers: true,
  2211  			},
  2212  			invitees: sets.New[string]("him", "her", "them"),
  2213  			expected: sets.New[string]("him", "her", "them"),
  2214  		},
  2215  		{
  2216  			name: "call if fixTeamMembers",
  2217  			opt: options{
  2218  				fixTeamMembers: true,
  2219  			},
  2220  			invitees: sets.New[string]("him", "her", "them"),
  2221  			expected: sets.New[string]("him", "her", "them"),
  2222  		},
  2223  		{
  2224  			name: "ensure case normalization",
  2225  			opt: options{
  2226  				fixOrgMembers:  true,
  2227  				fixTeamMembers: true,
  2228  			},
  2229  			invitees: sets.New[string]("MiXeD", "lower", "UPPER"),
  2230  			expected: sets.New[string]("mixed", "lower", "upper"),
  2231  		},
  2232  		{
  2233  			name: "error if list fails",
  2234  			opt: options{
  2235  				fixTeamMembers: true,
  2236  				fixOrgMembers:  true,
  2237  			},
  2238  			invitees: sets.New[string]("erick", "fail"),
  2239  			err:      true,
  2240  		},
  2241  	}
  2242  
  2243  	for _, tc := range cases {
  2244  		t.Run(tc.name, func(t *testing.T) {
  2245  			fc := &fakeClient{
  2246  				invitees: tc.invitees,
  2247  			}
  2248  			actual, err := orgInvitations(tc.opt, fc, "random-org")
  2249  			switch {
  2250  			case err != nil:
  2251  				if !tc.err {
  2252  					t.Errorf("unexpected error: %v", err)
  2253  				}
  2254  			case tc.err:
  2255  				t.Errorf("failed to receive an error")
  2256  			case !reflect.DeepEqual(actual, tc.expected):
  2257  				t.Errorf("%#v != expected %#v", actual, tc.expected)
  2258  			}
  2259  		})
  2260  	}
  2261  }
  2262  
  2263  type fakeTeamRepoClient struct {
  2264  	repos                            map[string][]github.Repo
  2265  	failList, failUpdate, failRemove bool
  2266  }
  2267  
  2268  func (c *fakeTeamRepoClient) ListTeamReposBySlug(org, teamSlug string) ([]github.Repo, error) {
  2269  	if c.failList {
  2270  		return nil, errors.New("injected failure to ListTeamRepos")
  2271  	}
  2272  	return c.repos[teamSlug], nil
  2273  }
  2274  
  2275  func (c *fakeTeamRepoClient) UpdateTeamRepoBySlug(org, teamSlug, repo string, permission github.TeamPermission) error {
  2276  	if c.failUpdate {
  2277  		return errors.New("injected failure to UpdateTeamRepos")
  2278  	}
  2279  
  2280  	permissions := github.PermissionsFromTeamPermission(permission)
  2281  	updated := false
  2282  	for i, repository := range c.repos[teamSlug] {
  2283  		if repository.Name == repo {
  2284  			c.repos[teamSlug][i].Permissions = permissions
  2285  			updated = true
  2286  			break
  2287  		}
  2288  	}
  2289  
  2290  	if !updated {
  2291  		c.repos[teamSlug] = append(c.repos[teamSlug], github.Repo{Name: repo, Permissions: permissions})
  2292  	}
  2293  
  2294  	return nil
  2295  }
  2296  
  2297  func (c *fakeTeamRepoClient) RemoveTeamRepoBySlug(org, teamSlug, repo string) error {
  2298  	if c.failRemove {
  2299  		return errors.New("injected failure to RemoveTeamRepos")
  2300  	}
  2301  
  2302  	for i, repository := range c.repos[teamSlug] {
  2303  		if repository.Name == repo {
  2304  			c.repos[teamSlug] = append(c.repos[teamSlug][:i], c.repos[teamSlug][i+1:]...)
  2305  			break
  2306  		}
  2307  	}
  2308  
  2309  	return nil
  2310  }
  2311  
  2312  func TestConfigureTeamRepos(t *testing.T) {
  2313  	var testCases = []struct {
  2314  		name          string
  2315  		githubTeams   map[string]github.Team
  2316  		teamName      string
  2317  		team          org.Team
  2318  		existingRepos map[string][]github.Repo
  2319  		failList      bool
  2320  		failUpdate    bool
  2321  		failRemove    bool
  2322  		expected      map[string][]github.Repo
  2323  		expectedErr   bool
  2324  	}{
  2325  		{
  2326  			name:        "githubTeams cache not containing team errors",
  2327  			githubTeams: map[string]github.Team{},
  2328  			teamName:    "team",
  2329  			expectedErr: true,
  2330  		},
  2331  		{
  2332  			name:        "listing repos failing errors",
  2333  			githubTeams: map[string]github.Team{"team": {ID: 1, Slug: "team"}},
  2334  			teamName:    "team",
  2335  			failList:    true,
  2336  			expectedErr: true,
  2337  		},
  2338  		{
  2339  			name:        "nothing to do",
  2340  			githubTeams: map[string]github.Team{"team": {ID: 1, Slug: "team"}},
  2341  			teamName:    "team",
  2342  			team: org.Team{
  2343  				Repos: map[string]github.RepoPermissionLevel{
  2344  					"read":     github.Read,
  2345  					"triage":   github.Triage,
  2346  					"write":    github.Write,
  2347  					"maintain": github.Maintain,
  2348  					"admin":    github.Admin,
  2349  				},
  2350  			},
  2351  			existingRepos: map[string][]github.Repo{"team": {
  2352  				{Name: "read", Permissions: github.RepoPermissions{Pull: true}},
  2353  				{Name: "triage", Permissions: github.RepoPermissions{Pull: true, Triage: true}},
  2354  				{Name: "write", Permissions: github.RepoPermissions{Pull: true, Triage: true, Push: true}},
  2355  				{Name: "maintain", Permissions: github.RepoPermissions{Pull: true, Triage: true, Push: true, Maintain: true}},
  2356  				{Name: "admin", Permissions: github.RepoPermissions{Pull: true, Triage: true, Push: true, Maintain: true, Admin: true}},
  2357  			}},
  2358  			expected: map[string][]github.Repo{"team": {
  2359  				{Name: "read", Permissions: github.RepoPermissions{Pull: true}},
  2360  				{Name: "triage", Permissions: github.RepoPermissions{Pull: true, Triage: true}},
  2361  				{Name: "write", Permissions: github.RepoPermissions{Pull: true, Triage: true, Push: true}},
  2362  				{Name: "maintain", Permissions: github.RepoPermissions{Pull: true, Triage: true, Push: true, Maintain: true}},
  2363  				{Name: "admin", Permissions: github.RepoPermissions{Pull: true, Triage: true, Push: true, Maintain: true, Admin: true}},
  2364  			}},
  2365  		},
  2366  		{
  2367  			name:        "new requirement in org config gets added",
  2368  			githubTeams: map[string]github.Team{"team": {ID: 1, Slug: "team"}},
  2369  			teamName:    "team",
  2370  			team: org.Team{
  2371  				Repos: map[string]github.RepoPermissionLevel{
  2372  					"read":        github.Read,
  2373  					"write":       github.Write,
  2374  					"admin":       github.Admin,
  2375  					"other-admin": github.Admin,
  2376  				},
  2377  			},
  2378  			existingRepos: map[string][]github.Repo{"team": {
  2379  				{Name: "read", Permissions: github.RepoPermissions{Pull: true}},
  2380  				{Name: "write", Permissions: github.RepoPermissions{Pull: true, Triage: true, Push: true}},
  2381  				{Name: "admin", Permissions: github.RepoPermissions{Pull: true, Triage: true, Push: true, Maintain: true, Admin: true}},
  2382  			}},
  2383  			expected: map[string][]github.Repo{"team": {
  2384  				{Name: "read", Permissions: github.RepoPermissions{Pull: true}},
  2385  				{Name: "write", Permissions: github.RepoPermissions{Pull: true, Triage: true, Push: true}},
  2386  				{Name: "admin", Permissions: github.RepoPermissions{Pull: true, Triage: true, Push: true, Maintain: true, Admin: true}},
  2387  				{Name: "other-admin", Permissions: github.RepoPermissions{Pull: true, Triage: true, Push: true, Maintain: true, Admin: true}},
  2388  			}},
  2389  		},
  2390  		{
  2391  			name:        "change in permission on existing gets updated",
  2392  			githubTeams: map[string]github.Team{"team": {ID: 1, Slug: "team"}},
  2393  			teamName:    "team",
  2394  			team: org.Team{
  2395  				Repos: map[string]github.RepoPermissionLevel{
  2396  					"read":  github.Read,
  2397  					"write": github.Write,
  2398  					"admin": github.Read,
  2399  				},
  2400  			},
  2401  			existingRepos: map[string][]github.Repo{"team": {
  2402  				{Name: "read", Permissions: github.RepoPermissions{Pull: true}},
  2403  				{Name: "write", Permissions: github.RepoPermissions{Pull: true, Triage: true, Push: true}},
  2404  				{Name: "admin", Permissions: github.RepoPermissions{Pull: true, Triage: true, Push: true, Maintain: true, Admin: true}},
  2405  			}},
  2406  			expected: map[string][]github.Repo{"team": {
  2407  				{Name: "read", Permissions: github.RepoPermissions{Pull: true}},
  2408  				{Name: "write", Permissions: github.RepoPermissions{Pull: true, Triage: true, Push: true}},
  2409  				{Name: "admin", Permissions: github.RepoPermissions{Pull: true}},
  2410  			}},
  2411  		},
  2412  		{
  2413  			name:        "omitted requirement gets removed",
  2414  			githubTeams: map[string]github.Team{"team": {ID: 1, Slug: "team"}},
  2415  			teamName:    "team",
  2416  			team: org.Team{
  2417  				Repos: map[string]github.RepoPermissionLevel{
  2418  					"write": github.Write,
  2419  					"admin": github.Read,
  2420  				},
  2421  			},
  2422  			existingRepos: map[string][]github.Repo{"team": {
  2423  				{Name: "read", Permissions: github.RepoPermissions{Pull: true}},
  2424  				{Name: "write", Permissions: github.RepoPermissions{Pull: true, Triage: true, Push: true}},
  2425  				{Name: "admin", Permissions: github.RepoPermissions{Pull: true, Triage: true, Push: true, Maintain: true, Admin: true}},
  2426  			}},
  2427  			expected: map[string][]github.Repo{"team": {
  2428  				{Name: "write", Permissions: github.RepoPermissions{Pull: true, Triage: true, Push: true}},
  2429  				{Name: "admin", Permissions: github.RepoPermissions{Pull: true}},
  2430  			}},
  2431  		},
  2432  		{
  2433  			name:        "failed update errors",
  2434  			failUpdate:  true,
  2435  			githubTeams: map[string]github.Team{"team": {ID: 1}},
  2436  			teamName:    "team",
  2437  			team: org.Team{
  2438  				Repos: map[string]github.RepoPermissionLevel{
  2439  					"will-fail": github.Write,
  2440  				},
  2441  			},
  2442  			existingRepos: map[string][]github.Repo{"some-team": {}},
  2443  			expected:      map[string][]github.Repo{"some-team": {}},
  2444  			expectedErr:   true,
  2445  		},
  2446  		{
  2447  			name:        "failed delete errors",
  2448  			failRemove:  true,
  2449  			githubTeams: map[string]github.Team{"team": {ID: 1, Slug: "team"}},
  2450  			teamName:    "team",
  2451  			team: org.Team{
  2452  				Repos: map[string]github.RepoPermissionLevel{},
  2453  			},
  2454  			existingRepos: map[string][]github.Repo{"team": {
  2455  				{Name: "needs-deletion", Permissions: github.RepoPermissions{Pull: true}},
  2456  			}},
  2457  			expected: map[string][]github.Repo{"team": {
  2458  				{Name: "needs-deletion", Permissions: github.RepoPermissions{Pull: true}},
  2459  			}},
  2460  			expectedErr: true,
  2461  		},
  2462  		{
  2463  			name:        "new requirement in child team config gets added",
  2464  			githubTeams: map[string]github.Team{"team": {ID: 1, Slug: "team"}, "child": {ID: 2, Slug: "child"}},
  2465  			teamName:    "team",
  2466  			team: org.Team{
  2467  				Children: map[string]org.Team{
  2468  					"child": {
  2469  						Repos: map[string]github.RepoPermissionLevel{
  2470  							"read":        github.Read,
  2471  							"write":       github.Write,
  2472  							"admin":       github.Admin,
  2473  							"other-admin": github.Admin,
  2474  						},
  2475  					},
  2476  				},
  2477  			},
  2478  			existingRepos: map[string][]github.Repo{"child": {
  2479  				{Name: "read", Permissions: github.RepoPermissions{Pull: true}},
  2480  				{Name: "write", Permissions: github.RepoPermissions{Pull: true, Triage: true, Push: true}},
  2481  				{Name: "admin", Permissions: github.RepoPermissions{Pull: true, Triage: true, Push: true, Maintain: true, Admin: true}},
  2482  			}},
  2483  			expected: map[string][]github.Repo{"child": {
  2484  				{Name: "read", Permissions: github.RepoPermissions{Pull: true}},
  2485  				{Name: "write", Permissions: github.RepoPermissions{Pull: true, Triage: true, Push: true}},
  2486  				{Name: "admin", Permissions: github.RepoPermissions{Pull: true, Triage: true, Push: true, Maintain: true, Admin: true}},
  2487  				{Name: "other-admin", Permissions: github.RepoPermissions{Pull: true, Triage: true, Push: true, Maintain: true, Admin: true}},
  2488  			}},
  2489  		},
  2490  		{
  2491  			name:        "failure in a child errors",
  2492  			failRemove:  true,
  2493  			githubTeams: map[string]github.Team{"team": {ID: 1, Slug: "team"}, "child": {ID: 2, Slug: "child"}},
  2494  			teamName:    "team",
  2495  			team: org.Team{
  2496  				Repos: map[string]github.RepoPermissionLevel{},
  2497  				Children: map[string]org.Team{
  2498  					"child": {
  2499  						Repos: map[string]github.RepoPermissionLevel{},
  2500  					},
  2501  				},
  2502  			},
  2503  			existingRepos: map[string][]github.Repo{"child": {
  2504  				{Name: "needs-deletion", Permissions: github.RepoPermissions{Pull: true}},
  2505  			}},
  2506  			expected: map[string][]github.Repo{"child": {
  2507  				{Name: "needs-deletion", Permissions: github.RepoPermissions{Pull: true}},
  2508  			}},
  2509  			expectedErr: true,
  2510  		},
  2511  	}
  2512  
  2513  	for _, testCase := range testCases {
  2514  		client := fakeTeamRepoClient{
  2515  			repos:      testCase.existingRepos,
  2516  			failList:   testCase.failList,
  2517  			failUpdate: testCase.failUpdate,
  2518  			failRemove: testCase.failRemove,
  2519  		}
  2520  		err := configureTeamRepos(&client, testCase.githubTeams, testCase.teamName, "org", testCase.team)
  2521  		if err == nil && testCase.expectedErr {
  2522  			t.Errorf("%s: expected an error but got none", testCase.name)
  2523  		}
  2524  		if err != nil && !testCase.expectedErr {
  2525  			t.Errorf("%s: expected no error but got one: %v", testCase.name, err)
  2526  		}
  2527  		if diff := cmp.Diff(client.repos, testCase.expected); diff != "" {
  2528  			t.Errorf("%s: got incorrect team repos: %s", testCase.name, diff)
  2529  		}
  2530  	}
  2531  }
  2532  
  2533  type fakeRepoClient struct {
  2534  	t     *testing.T
  2535  	repos map[string]github.FullRepo
  2536  }
  2537  
  2538  func (f fakeRepoClient) GetRepo(owner, name string) (github.FullRepo, error) {
  2539  	repo, ok := f.repos[name]
  2540  	if !ok {
  2541  		return repo, fmt.Errorf("repo not found")
  2542  	}
  2543  	return repo, nil
  2544  }
  2545  
  2546  func (f fakeRepoClient) GetRepos(orgName string, isUser bool) ([]github.Repo, error) {
  2547  	if orgName == "fail" {
  2548  		return nil, fmt.Errorf("injected GetRepos failure")
  2549  	}
  2550  
  2551  	repos := make([]github.Repo, 0, len(f.repos))
  2552  	for _, repo := range f.repos {
  2553  		repos = append(repos, repo.Repo)
  2554  	}
  2555  
  2556  	// sort for deterministic output
  2557  	sort.Slice(repos, func(i, j int) bool {
  2558  		return repos[i].Name < repos[j].Name
  2559  	})
  2560  
  2561  	return repos, nil
  2562  }
  2563  
  2564  func (f fakeRepoClient) CreateRepo(owner string, isUser bool, repoReq github.RepoCreateRequest) (*github.FullRepo, error) {
  2565  	if *repoReq.Name == "fail" {
  2566  		return nil, fmt.Errorf("injected CreateRepo failure")
  2567  	}
  2568  
  2569  	if _, hasRepo := f.repos[*repoReq.Name]; hasRepo {
  2570  		f.t.Errorf("CreateRepo() called on repo that already exists")
  2571  		return nil, fmt.Errorf("CreateRepo() called on repo that already exists")
  2572  	}
  2573  
  2574  	repo := repoReq.ToRepo()
  2575  	f.repos[*repoReq.Name] = *repo
  2576  
  2577  	return repo, nil
  2578  }
  2579  
  2580  func (f fakeRepoClient) UpdateRepo(owner, name string, want github.RepoUpdateRequest) (*github.FullRepo, error) {
  2581  	if name == "fail" {
  2582  		return nil, fmt.Errorf("injected UpdateRepo failure")
  2583  	}
  2584  	if want.Archived != nil && !*want.Archived {
  2585  		f.t.Errorf("UpdateRepo() called to unarchive a repo (not supported by API)")
  2586  		return nil, fmt.Errorf("UpdateRepo() called to unarchive a repo (not supported by API)")
  2587  	}
  2588  
  2589  	have, exists := f.repos[name]
  2590  	if !exists {
  2591  		f.t.Errorf("UpdateRepo() called on repo that does not exists")
  2592  		return nil, fmt.Errorf("UpdateRepo() called on repo that does not exist")
  2593  	}
  2594  
  2595  	if have.Archived {
  2596  		return nil, fmt.Errorf("Repository was archived so is read-only.")
  2597  	}
  2598  
  2599  	updateString := func(have, want *string) {
  2600  		if want != nil {
  2601  			*have = *want
  2602  		}
  2603  	}
  2604  
  2605  	updateBool := func(have, want *bool) {
  2606  		if want != nil {
  2607  			*have = *want
  2608  		}
  2609  	}
  2610  
  2611  	updateString(&have.Name, want.Name)
  2612  	updateString(&have.DefaultBranch, want.DefaultBranch)
  2613  	updateString(&have.Homepage, want.Homepage)
  2614  	updateString(&have.Description, want.Description)
  2615  	updateBool(&have.Archived, want.Archived)
  2616  	updateBool(&have.Private, want.Private)
  2617  	updateBool(&have.HasIssues, want.HasIssues)
  2618  	updateBool(&have.HasProjects, want.HasProjects)
  2619  	updateBool(&have.HasWiki, want.HasWiki)
  2620  	updateBool(&have.AllowSquashMerge, want.AllowSquashMerge)
  2621  	updateBool(&have.AllowMergeCommit, want.AllowMergeCommit)
  2622  	updateBool(&have.AllowRebaseMerge, want.AllowRebaseMerge)
  2623  	updateString(&have.SquashMergeCommitTitle, want.SquashMergeCommitTitle)
  2624  	updateString(&have.SquashMergeCommitMessage, want.SquashMergeCommitMessage)
  2625  
  2626  	f.repos[name] = have
  2627  	return &have, nil
  2628  }
  2629  
  2630  func makeFakeRepoClient(t *testing.T, repos ...github.FullRepo) fakeRepoClient {
  2631  	fc := fakeRepoClient{
  2632  		repos: make(map[string]github.FullRepo, len(repos)),
  2633  		t:     t,
  2634  	}
  2635  	for _, repo := range repos {
  2636  		fc.repos[repo.Name] = repo
  2637  	}
  2638  
  2639  	return fc
  2640  }
  2641  
  2642  func TestConfigureRepos(t *testing.T) {
  2643  	orgName := "test-org"
  2644  	isOrg := false
  2645  	no := false
  2646  	yes := true
  2647  	updated := "UPDATED"
  2648  
  2649  	oldName := "old"
  2650  	oldRepo := github.Repo{
  2651  		Name:        oldName,
  2652  		FullName:    fmt.Sprintf("%s/%s", orgName, oldName),
  2653  		Description: "An old existing repository",
  2654  	}
  2655  
  2656  	newName := "new"
  2657  	newDescription := "A new repository."
  2658  	newConfigRepo := org.Repo{
  2659  		Description: &newDescription,
  2660  	}
  2661  	newRepo := github.Repo{
  2662  		Name:        newName,
  2663  		Description: newDescription,
  2664  	}
  2665  
  2666  	fail := "fail"
  2667  	failRepo := github.Repo{
  2668  		Name: fail,
  2669  	}
  2670  
  2671  	testCases := []struct {
  2672  		description     string
  2673  		opts            options
  2674  		orgConfig       org.Config
  2675  		orgNameOverride string
  2676  		repos           []github.FullRepo
  2677  
  2678  		expectError   bool
  2679  		expectedRepos []github.Repo
  2680  	}{
  2681  		{
  2682  			description:   "survives empty config",
  2683  			expectedRepos: []github.Repo{},
  2684  		},
  2685  		{
  2686  			description: "survives nil repos config",
  2687  			orgConfig: org.Config{
  2688  				Repos: nil,
  2689  			},
  2690  			expectedRepos: []github.Repo{},
  2691  		},
  2692  		{
  2693  			description: "survives empty repos config",
  2694  			orgConfig: org.Config{
  2695  				Repos: map[string]org.Repo{},
  2696  			},
  2697  			expectedRepos: []github.Repo{},
  2698  		},
  2699  		{
  2700  			description: "nonexistent repo is created",
  2701  			orgConfig: org.Config{
  2702  				Repos: map[string]org.Repo{
  2703  					newName: newConfigRepo,
  2704  				},
  2705  			},
  2706  			repos: []github.FullRepo{{Repo: oldRepo}},
  2707  
  2708  			expectedRepos: []github.Repo{newRepo, oldRepo},
  2709  		},
  2710  		{
  2711  			description:     "GetRepos failure is propagated",
  2712  			orgNameOverride: "fail",
  2713  			orgConfig: org.Config{
  2714  				Repos: map[string]org.Repo{
  2715  					newName: newConfigRepo,
  2716  				},
  2717  			},
  2718  			repos: []github.FullRepo{{Repo: oldRepo}},
  2719  
  2720  			expectError:   true,
  2721  			expectedRepos: []github.Repo{oldRepo},
  2722  		},
  2723  		{
  2724  			description: "CreateRepo failure is propagated",
  2725  			orgConfig: org.Config{
  2726  				Repos: map[string]org.Repo{
  2727  					fail: newConfigRepo,
  2728  				},
  2729  			},
  2730  			repos: []github.FullRepo{{Repo: oldRepo}},
  2731  
  2732  			expectError:   true,
  2733  			expectedRepos: []github.Repo{oldRepo},
  2734  		},
  2735  		{
  2736  			description: "duplicate repo names different only by case are detected",
  2737  			orgConfig: org.Config{
  2738  				Repos: map[string]org.Repo{
  2739  					"repo": newConfigRepo,
  2740  					"REPO": newConfigRepo,
  2741  				},
  2742  			},
  2743  			repos: []github.FullRepo{{Repo: oldRepo}},
  2744  
  2745  			expectError:   true,
  2746  			expectedRepos: []github.Repo{oldRepo},
  2747  		},
  2748  		{
  2749  			description: "existing repo is updated",
  2750  			orgConfig: org.Config{
  2751  				Repos: map[string]org.Repo{
  2752  					oldName: newConfigRepo,
  2753  				},
  2754  			},
  2755  			repos: []github.FullRepo{{Repo: oldRepo}},
  2756  			expectedRepos: []github.Repo{
  2757  				{
  2758  					Name:        oldName,
  2759  					Description: newDescription,
  2760  					FullName:    fmt.Sprintf("%s/%s", orgName, oldName),
  2761  				},
  2762  			},
  2763  		},
  2764  		{
  2765  			description: "UpdateRepo failure is propagated",
  2766  			orgConfig: org.Config{
  2767  				Repos: map[string]org.Repo{
  2768  					"fail": newConfigRepo,
  2769  				},
  2770  			},
  2771  			repos:         []github.FullRepo{{Repo: failRepo}},
  2772  			expectError:   true,
  2773  			expectedRepos: []github.Repo{failRepo},
  2774  		},
  2775  		{
  2776  			// https://developer.github.com/v3/repos/#edit
  2777  			// "Note: You cannot unarchive repositories through the API."
  2778  			// Archived repositories are read-only, and updates fail with 403:
  2779  			// "Repository was archived so is read-only."
  2780  			description: "request to unarchive a repo fails, repo is read-only",
  2781  			orgConfig: org.Config{
  2782  				Repos: map[string]org.Repo{
  2783  					oldName: {Archived: &no, Description: &updated},
  2784  				},
  2785  			},
  2786  			repos:         []github.FullRepo{{Repo: github.Repo{Name: oldName, Archived: true, Description: "OLD"}}},
  2787  			expectError:   true,
  2788  			expectedRepos: []github.Repo{{Name: oldName, Archived: true, Description: "OLD"}},
  2789  		},
  2790  		{
  2791  			// https://developer.github.com/v3/repos/#edit
  2792  			// "Note: You cannot unarchive repositories through the API."
  2793  			// Archived repositories are read-only, and updates fail with 403:
  2794  			// "Repository was archived so is read-only."
  2795  			description: "no field changes on archived repo",
  2796  			orgConfig: org.Config{
  2797  				Repos: map[string]org.Repo{
  2798  					oldName: {Archived: &yes, Description: &updated},
  2799  				},
  2800  			},
  2801  			repos:         []github.FullRepo{{Repo: github.Repo{Name: oldName, Archived: true, Description: "OLD"}}},
  2802  			expectError:   false,
  2803  			expectedRepos: []github.Repo{{Name: oldName, Archived: true, Description: "OLD"}},
  2804  		},
  2805  		{
  2806  			description: "request to archive repo fails when not allowed, but updates other fields",
  2807  			orgConfig: org.Config{
  2808  				Repos: map[string]org.Repo{
  2809  					oldName: {Archived: &yes, Description: &updated},
  2810  				},
  2811  			},
  2812  			repos:         []github.FullRepo{{Repo: github.Repo{Name: oldName, Archived: false, Description: "OLD"}}},
  2813  			expectError:   true,
  2814  			expectedRepos: []github.Repo{{Name: oldName, Archived: false, Description: updated}},
  2815  		},
  2816  		{
  2817  			description: "request to archive repo succeeds when allowed",
  2818  			opts: options{
  2819  				allowRepoArchival: true,
  2820  			},
  2821  			orgConfig: org.Config{
  2822  				Repos: map[string]org.Repo{
  2823  					oldName: {Archived: &yes},
  2824  				},
  2825  			},
  2826  			repos:         []github.FullRepo{{Repo: github.Repo{Name: oldName, Archived: false}}},
  2827  			expectedRepos: []github.Repo{{Name: oldName, Archived: true}},
  2828  		},
  2829  		{
  2830  			description: "request to publish a private repo fails when not allowed, but updates other fields",
  2831  			orgConfig: org.Config{
  2832  				Repos: map[string]org.Repo{
  2833  					oldName: {Private: &no, Description: &updated},
  2834  				},
  2835  			},
  2836  			repos:         []github.FullRepo{{Repo: github.Repo{Name: oldName, Private: true, Description: "OLD"}}},
  2837  			expectError:   true,
  2838  			expectedRepos: []github.Repo{{Name: oldName, Private: true, Description: updated}},
  2839  		},
  2840  		{
  2841  			description: "request to publish a private repo succeeds when allowed",
  2842  			opts: options{
  2843  				allowRepoPublish: true,
  2844  			},
  2845  			orgConfig: org.Config{
  2846  				Repos: map[string]org.Repo{
  2847  					oldName: {Private: &no},
  2848  				},
  2849  			},
  2850  			repos:         []github.FullRepo{{Repo: github.Repo{Name: oldName, Private: true}}},
  2851  			expectedRepos: []github.Repo{{Name: oldName, Private: false}},
  2852  		},
  2853  		{
  2854  			description: "renaming a repo is successful",
  2855  			orgConfig: org.Config{
  2856  				Repos: map[string]org.Repo{
  2857  					newName: {Previously: []string{oldName}},
  2858  				},
  2859  			},
  2860  			repos:         []github.FullRepo{{Repo: github.Repo{Name: oldName, Description: "renamed repo"}}},
  2861  			expectedRepos: []github.Repo{{Name: newName, Description: "renamed repo"}},
  2862  		},
  2863  		{
  2864  			description: "renaming a repo by just changing case is successful",
  2865  			orgConfig: org.Config{
  2866  				Repos: map[string]org.Repo{
  2867  					"repo": {Previously: []string{"REPO"}},
  2868  				},
  2869  			},
  2870  			repos:         []github.FullRepo{{Repo: github.Repo{Name: "REPO", Description: "renamed repo"}}},
  2871  			expectedRepos: []github.Repo{{Name: "repo", Description: "renamed repo"}},
  2872  		},
  2873  		{
  2874  			description: "dup between a repo name and a previous name is detected",
  2875  			orgConfig: org.Config{
  2876  				Repos: map[string]org.Repo{
  2877  					newName: {Previously: []string{oldName}},
  2878  					oldName: {Description: &newDescription},
  2879  				},
  2880  			},
  2881  			repos:         []github.FullRepo{{Repo: github.Repo{Name: oldName, Description: "this repo shall not be touched"}}},
  2882  			expectError:   true,
  2883  			expectedRepos: []github.Repo{{Name: oldName, Description: "this repo shall not be touched"}},
  2884  		},
  2885  		{
  2886  			description: "dup between two previous names is detected",
  2887  			orgConfig: org.Config{
  2888  				Repos: map[string]org.Repo{
  2889  					"wants-projects": {Previously: []string{oldName}, HasProjects: &yes, HasWiki: &no},
  2890  					"wants-wiki":     {Previously: []string{oldName}, HasProjects: &no, HasWiki: &yes},
  2891  				},
  2892  			},
  2893  			repos:         []github.FullRepo{{Repo: github.Repo{Name: oldName, Description: "this repo shall not be touched"}}},
  2894  			expectError:   true,
  2895  			expectedRepos: []github.Repo{{Name: oldName, Description: "this repo shall not be touched"}},
  2896  		},
  2897  		{
  2898  			description: "error detected when both a repo and a repo of its previous name exist",
  2899  			orgConfig: org.Config{
  2900  				Repos: map[string]org.Repo{
  2901  					newName: {Previously: []string{oldName}, Description: &newDescription},
  2902  				},
  2903  			},
  2904  			repos: []github.FullRepo{
  2905  				{Repo: github.Repo{Name: oldName, Description: "this repo shall not be touched"}},
  2906  				{Repo: github.Repo{Name: newName, Description: "this repo shall not be touched too"}},
  2907  			},
  2908  			expectError: true,
  2909  			expectedRepos: []github.Repo{
  2910  				{Name: newName, Description: "this repo shall not be touched too"},
  2911  				{Name: oldName, Description: "this repo shall not be touched"},
  2912  			},
  2913  		},
  2914  		{
  2915  			description: "error detected when multiple previous repos exist",
  2916  			orgConfig: org.Config{
  2917  				Repos: map[string]org.Repo{
  2918  					newName: {Previously: []string{oldName, "even-older"}, Description: &newDescription},
  2919  				},
  2920  			},
  2921  			repos: []github.FullRepo{
  2922  				{Repo: github.Repo{Name: oldName, Description: "this repo shall not be touched"}},
  2923  				{Repo: github.Repo{Name: "even-older", Description: "this repo shall not be touched too"}},
  2924  			},
  2925  			expectError: true,
  2926  			expectedRepos: []github.Repo{
  2927  				{Name: "even-older", Description: "this repo shall not be touched too"},
  2928  				{Name: oldName, Description: "this repo shall not be touched"},
  2929  			},
  2930  		},
  2931  		{
  2932  			description: "repos are renamed to defined case even without explicit `previously` field",
  2933  			orgConfig: org.Config{
  2934  				Repos: map[string]org.Repo{
  2935  					"CamelCase": {Description: &newDescription},
  2936  				},
  2937  			},
  2938  			repos:         []github.FullRepo{{Repo: github.Repo{Name: "CAMELCASE", Description: newDescription}}},
  2939  			expectedRepos: []github.Repo{{Name: "CamelCase", Description: newDescription}},
  2940  		},
  2941  		{
  2942  			description: "avoid creating archived repo",
  2943  			orgConfig: org.Config{
  2944  				Repos: map[string]org.Repo{
  2945  					oldName: {Archived: &yes},
  2946  				},
  2947  			},
  2948  			repos:         []github.FullRepo{},
  2949  			expectError:   true,
  2950  			expectedRepos: []github.Repo{},
  2951  		},
  2952  	}
  2953  	for _, tc := range testCases {
  2954  		t.Run(tc.description, func(t *testing.T) {
  2955  			fc := makeFakeRepoClient(t, tc.repos...)
  2956  			var err error
  2957  			if len(tc.orgNameOverride) > 0 {
  2958  				err = configureRepos(tc.opts, fc, tc.orgNameOverride, tc.orgConfig)
  2959  			} else {
  2960  				err = configureRepos(tc.opts, fc, orgName, tc.orgConfig)
  2961  			}
  2962  			if err != nil && !tc.expectError {
  2963  				t.Errorf("%s: unexpected error: %v", tc.description, err)
  2964  			}
  2965  			if err == nil && tc.expectError {
  2966  				t.Errorf("%s: expected error, got none", tc.description)
  2967  			}
  2968  
  2969  			reposAfter, err := fc.GetRepos(orgName, isOrg)
  2970  			if err != nil {
  2971  				t.Fatalf("%s: unexpected GetRepos error: %v", tc.description, err)
  2972  			}
  2973  			if !reflect.DeepEqual(reposAfter, tc.expectedRepos) {
  2974  				t.Errorf("%s: unexpected repos after configureRepos():\n%s", tc.description, cmp.Diff(reposAfter, tc.expectedRepos))
  2975  			}
  2976  		})
  2977  	}
  2978  }
  2979  
  2980  func TestValidateRepos(t *testing.T) {
  2981  	description := "cool repo"
  2982  	testCases := []struct {
  2983  		description string
  2984  		config      map[string]org.Repo
  2985  		expectError bool
  2986  	}{
  2987  		{
  2988  			description: "handles nil map",
  2989  		},
  2990  		{
  2991  			description: "handles empty map",
  2992  			config:      map[string]org.Repo{},
  2993  		},
  2994  		{
  2995  			description: "handles valid config",
  2996  			config: map[string]org.Repo{
  2997  				"repo": {Description: &description},
  2998  			},
  2999  		},
  3000  		{
  3001  			description: "finds repo names duplicate when normalized",
  3002  			config: map[string]org.Repo{
  3003  				"repo": {Description: &description},
  3004  				"Repo": {Description: &description},
  3005  			},
  3006  			expectError: true,
  3007  		},
  3008  		{
  3009  			description: "finds name confict between previous and current names",
  3010  			config: map[string]org.Repo{
  3011  				"repo":     {Previously: []string{"conflict"}},
  3012  				"conflict": {Description: &description},
  3013  			},
  3014  			expectError: true,
  3015  		},
  3016  		{
  3017  			description: "finds name confict between two previous names",
  3018  			config: map[string]org.Repo{
  3019  				"repo":         {Previously: []string{"conflict"}},
  3020  				"another-repo": {Previously: []string{"conflict"}},
  3021  			},
  3022  			expectError: true,
  3023  		},
  3024  		{
  3025  			description: "allows case-duplicate name between former and current name",
  3026  			config: map[string]org.Repo{
  3027  				"repo": {Previously: []string{"REPO"}},
  3028  			},
  3029  		},
  3030  	}
  3031  
  3032  	for _, tc := range testCases {
  3033  		t.Run(tc.description, func(t *testing.T) {
  3034  			err := validateRepos(tc.config)
  3035  			if err == nil && tc.expectError {
  3036  				t.Errorf("%s: expected error, got none", tc.description)
  3037  			} else if err != nil && !tc.expectError {
  3038  				t.Errorf("%s: unexpected error: %v", tc.description, err)
  3039  			}
  3040  		})
  3041  	}
  3042  }
  3043  
  3044  func TestNewRepoUpdateRequest(t *testing.T) {
  3045  	repoName := "repo-name"
  3046  	newRepoName := "renamed-repo"
  3047  	description := "description of repo-name"
  3048  	homepage := "https://somewhe.re"
  3049  	master := "master"
  3050  	branch := "branch"
  3051  	squashMergeCommitTitle := "PR_TITLE"
  3052  	squashMergeCommitMessage := "COMMIT_MESSAGES"
  3053  
  3054  	testCases := []struct {
  3055  		description string
  3056  		current     github.FullRepo
  3057  		name        string
  3058  		newState    org.Repo
  3059  
  3060  		expected github.RepoUpdateRequest
  3061  	}{
  3062  		{
  3063  			description: "update is just a delta from current state",
  3064  			current: github.FullRepo{
  3065  				Repo: github.Repo{
  3066  					Name:          repoName,
  3067  					Description:   description,
  3068  					Homepage:      homepage,
  3069  					DefaultBranch: master,
  3070  				},
  3071  			},
  3072  			name: repoName,
  3073  			newState: org.Repo{
  3074  				Description:   &description,
  3075  				DefaultBranch: &branch,
  3076  			},
  3077  			expected: github.RepoUpdateRequest{
  3078  				DefaultBranch: &branch,
  3079  			},
  3080  		},
  3081  		{
  3082  			description: "empty delta is returned when no update is needed",
  3083  			current: github.FullRepo{Repo: github.Repo{
  3084  				Name:        repoName,
  3085  				Description: description,
  3086  			}},
  3087  			name: repoName,
  3088  			newState: org.Repo{
  3089  				Description: &description,
  3090  			},
  3091  		},
  3092  		{
  3093  			description: "request to rename a repo works",
  3094  			current: github.FullRepo{Repo: github.Repo{
  3095  				Name: repoName,
  3096  			}},
  3097  			name: newRepoName,
  3098  			newState: org.Repo{
  3099  				Description: &description,
  3100  			},
  3101  			expected: github.RepoUpdateRequest{
  3102  				RepoRequest: github.RepoRequest{
  3103  					Name:        &newRepoName,
  3104  					Description: &description,
  3105  				},
  3106  			},
  3107  		},
  3108  		{
  3109  			description: "request to update commit messages works",
  3110  			current: github.FullRepo{
  3111  				Repo: github.Repo{
  3112  					Name: repoName,
  3113  				},
  3114  				SquashMergeCommitTitle:   "COMMIT_MESSAGES",
  3115  				SquashMergeCommitMessage: "COMMIT_OR_PR_TITLE",
  3116  			},
  3117  			name: newRepoName,
  3118  			newState: org.Repo{
  3119  				Description:              &description,
  3120  				SquashMergeCommitTitle:   &squashMergeCommitTitle,
  3121  				SquashMergeCommitMessage: &squashMergeCommitMessage,
  3122  			},
  3123  			expected: github.RepoUpdateRequest{
  3124  				RepoRequest: github.RepoRequest{
  3125  					Name:                     &newRepoName,
  3126  					Description:              &description,
  3127  					SquashMergeCommitTitle:   &squashMergeCommitTitle,
  3128  					SquashMergeCommitMessage: &squashMergeCommitMessage,
  3129  				},
  3130  			},
  3131  		},
  3132  	}
  3133  
  3134  	for _, tc := range testCases {
  3135  		t.Run(tc.description, func(t *testing.T) {
  3136  			update := newRepoUpdateRequest(tc.current, tc.name, tc.newState)
  3137  			if !reflect.DeepEqual(tc.expected, update) {
  3138  				t.Errorf("%s: update request differs from expected:%s", tc.description, cmp.Diff(tc.expected, update))
  3139  			}
  3140  		})
  3141  	}
  3142  }