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