k8s.io/test-infra@v0.0.0-20240520184403-27c6b4c223d8/label_sync/main.go (about) 1 /* 2 Copyright 2017 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 // This is a label_sync tool, details in README.md 18 package main 19 20 import ( 21 "encoding/hex" 22 "errors" 23 "flag" 24 "fmt" 25 "math" 26 "os" 27 "path/filepath" 28 "regexp" 29 "sort" 30 "strings" 31 "sync" 32 "text/template" 33 "time" 34 "unicode" 35 36 "github.com/sirupsen/logrus" 37 "k8s.io/apimachinery/pkg/util/sets" 38 "sigs.k8s.io/yaml" 39 40 "sigs.k8s.io/prow/pkg/config/secret" 41 "sigs.k8s.io/prow/pkg/flagutil" 42 "sigs.k8s.io/prow/pkg/github" 43 "sigs.k8s.io/prow/pkg/logrusutil" 44 ) 45 46 const maxConcurrentWorkers = 20 47 48 // A label in a repository. 49 50 // LabelTarget specifies the intent of the label (PR or issue) 51 type LabelTarget string 52 53 const ( 54 prTarget LabelTarget = "prs" 55 issueTarget LabelTarget = "issues" 56 bothTarget LabelTarget = "both" 57 ) 58 59 // Label holds declarative data about the label. 60 type Label struct { 61 // Name is the current name of the label 62 Name string `json:"name"` 63 // Color is rrggbb or color 64 Color string `json:"color"` 65 // Description is brief text explaining its meaning, who can apply it 66 Description string `json:"description"` 67 // Target specifies whether it targets PRs, issues or both 68 Target LabelTarget `json:"target"` 69 // ProwPlugin specifies which prow plugin add/removes this label 70 ProwPlugin string `json:"prowPlugin"` 71 // IsExternalPlugin specifies if the prow plugin is external or not 72 IsExternalPlugin bool `json:"isExternalPlugin"` 73 // AddedBy specifies whether human/munger/bot adds the label 74 AddedBy string `json:"addedBy"` 75 // Previously lists deprecated names for this label 76 Previously []Label `json:"previously,omitempty"` 77 // DeleteAfter specifies the label is retired and a safe date for deletion 78 DeleteAfter *time.Time `json:"deleteAfter,omitempty"` 79 parent *Label // Current name for previous labels (used internally) 80 } 81 82 // Configuration is a list of Repos defining Required Labels to sync into them 83 // There is also a Default list of labels applied to every Repo 84 type Configuration struct { 85 Repos map[string]RepoConfig `json:"repos,omitempty"` 86 Orgs map[string]RepoConfig `json:"orgs,omitempty"` 87 Default RepoConfig `json:"default"` 88 } 89 90 // RepoConfig contains only labels for the moment 91 type RepoConfig struct { 92 Labels []Label `json:"labels"` 93 } 94 95 // RepoLabels holds a repo => []github.Label mapping. 96 type RepoLabels map[string][]github.Label 97 98 // Update a label in a repo 99 type Update struct { 100 repo string 101 Why string 102 Wanted *Label `json:"wanted,omitempty"` 103 Current *Label `json:"current,omitempty"` 104 } 105 106 // RepoUpdates Repositories to update: map repo name --> list of Updates 107 type RepoUpdates map[string][]Update 108 109 const ( 110 defaultTokens = 300 111 defaultBurst = 100 112 ) 113 114 type options struct { 115 debug bool 116 confirm bool 117 endpoint flagutil.Strings 118 graphqlEndpoint string 119 labelsPath string 120 onlyRepos string 121 orgs string 122 skipRepos string 123 token string 124 action string 125 cssTemplate string 126 cssOutput string 127 docsTemplate string 128 docsOutput string 129 tokens int 130 tokenBurst int 131 github flagutil.GitHubOptions 132 } 133 134 func gatherOptions() (opts options, deprecatedOptions bool) { 135 o := options{} 136 fs := flag.NewFlagSet(os.Args[0], flag.ExitOnError) 137 fs.BoolVar(&o.debug, "debug", false, "Turn on debug to be more verbose") 138 fs.BoolVar(&o.confirm, "confirm", false, "Make mutating API calls to GitHub.") 139 o.endpoint = flagutil.NewStrings(github.DefaultAPIEndpoint) 140 fs.Var(&o.endpoint, "endpoint", "GitHub's API endpoint. DEPRECATED: use --github-endpoint") 141 fs.StringVar(&o.graphqlEndpoint, "graphql-endpoint", github.DefaultGraphQLEndpoint, "GitHub's GraphQL API endpoint. DEPRECATED: use --github-graphql-endpoint") 142 fs.StringVar(&o.labelsPath, "config", "", "Path to labels.yaml") 143 fs.StringVar(&o.onlyRepos, "only", "", "Only look at the following comma separated org/repos") 144 fs.StringVar(&o.orgs, "orgs", "", "Comma separated list of orgs to sync") 145 fs.StringVar(&o.skipRepos, "skip", "", "Comma separated list of org/repos to skip syncing") 146 fs.StringVar(&o.token, "token", "", "Path to github oauth secret. DEPRECATED: use --github-token-path") 147 fs.StringVar(&o.action, "action", "sync", "One of: sync, docs") 148 fs.StringVar(&o.cssTemplate, "css-template", "", "Path to template file for label css") 149 fs.StringVar(&o.cssOutput, "css-output", "", "Path to output file for css") 150 fs.StringVar(&o.docsTemplate, "docs-template", "", "Path to template file for label docs") 151 fs.StringVar(&o.docsOutput, "docs-output", "", "Path to output file for docs") 152 fs.IntVar(&o.tokens, "tokens", defaultTokens, "Throttle hourly token consumption (0 to disable). DEPRECATED: use --github-hourly-tokens") 153 fs.IntVar(&o.tokenBurst, "token-burst", defaultBurst, "Allow consuming a subset of hourly tokens in a short burst. DEPRECATED: use --github-allowed-burst") 154 o.github.AddCustomizedFlags(fs, flagutil.ThrottlerDefaults(defaultTokens, defaultBurst)) 155 fs.Parse(os.Args[1:]) 156 157 deprecatedGitHubOptions := false 158 newGitHubOptions := false 159 fs.Visit(func(f *flag.Flag) { 160 switch f.Name { 161 case "github-endpoint", 162 "github-graphql-endpoint", 163 "github-token-path", 164 "github-hourly-tokens", 165 "github-allowed-burst": 166 newGitHubOptions = true 167 case "token", 168 "endpoint", 169 "graphql-endpoint", 170 "tokens", 171 "token-burst": 172 deprecatedGitHubOptions = true 173 } 174 }) 175 176 if deprecatedGitHubOptions && newGitHubOptions { 177 logrus.Fatalf("deprecated GitHub options, include --endpoint, --graphql-endpoint, --token, --tokens, --token-burst cannot be combined with new --github-XXX counterparts") 178 } 179 180 return o, deprecatedGitHubOptions 181 } 182 183 func pathExists(path string) bool { 184 _, err := os.Stat(path) 185 return err == nil 186 } 187 188 // Writes the golang text template at templatePath to outputPath using the given data 189 func writeTemplate(templatePath string, outputPath string, data interface{}) error { 190 // set up template 191 funcMap := template.FuncMap{ 192 "anchor": func(input string) string { 193 return strings.Replace(input, ":", " ", -1) 194 }, 195 } 196 t, err := template.New(filepath.Base(templatePath)).Funcs(funcMap).ParseFiles(templatePath) 197 if err != nil { 198 return err 199 } 200 201 // ensure output path exists 202 if !pathExists(outputPath) { 203 _, err = os.Create(outputPath) 204 if err != nil { 205 return err 206 } 207 } 208 209 // open file at output path and truncate 210 f, err := os.OpenFile(outputPath, os.O_RDWR, 0644) 211 if err != nil { 212 return err 213 } 214 defer f.Close() 215 f.Truncate(0) 216 217 // render template to output path 218 err = t.Execute(f, data) 219 if err != nil { 220 return err 221 } 222 223 return nil 224 } 225 226 // validate runs checks to ensure the label inputs are valid 227 // It ensures that no two label names (including previous names) have the same 228 // lowercase value, and that the description is not over 100 characters. 229 func validate(labels []Label, parent string, seen map[string]string) (map[string]string, error) { 230 newSeen := copyStringMap(seen) 231 for _, l := range labels { 232 name := strings.ToLower(l.Name) 233 path := parent + "." + name 234 if other, present := newSeen[name]; present { 235 return newSeen, fmt.Errorf("duplicate label %s at %s and %s", name, path, other) 236 } 237 newSeen[name] = path 238 if newSeen, err := validate(l.Previously, path, newSeen); err != nil { 239 return newSeen, err 240 } 241 if len(l.Description) > 100 { // github limits the description field to 100 chars 242 return newSeen, fmt.Errorf("description for %s is too long", name) 243 } 244 } 245 return newSeen, nil 246 } 247 248 func copyStringMap(originalMap map[string]string) map[string]string { 249 newMap := make(map[string]string) 250 for k, v := range originalMap { 251 newMap[k] = v 252 } 253 return newMap 254 } 255 256 func stringInSortedSlice(a string, list []string) bool { 257 i := sort.SearchStrings(list, a) 258 if i < len(list) && list[i] == a { 259 return true 260 } 261 return false 262 } 263 264 // Labels returns a sorted list of labels unique by name 265 func (c Configuration) Labels() []Label { 266 var labelarrays [][]Label 267 labelarrays = append(labelarrays, c.Default.Labels) 268 for _, org := range c.Orgs { 269 labelarrays = append(labelarrays, org.Labels) 270 } 271 for _, repo := range c.Repos { 272 labelarrays = append(labelarrays, repo.Labels) 273 } 274 275 labelmap := make(map[string]Label) 276 for _, labels := range labelarrays { 277 for _, l := range labels { 278 name := strings.ToLower(l.Name) 279 if _, ok := labelmap[name]; !ok { 280 labelmap[name] = l 281 } 282 } 283 } 284 285 var labels []Label 286 for _, label := range labelmap { 287 labels = append(labels, label) 288 } 289 sort.Slice(labels, func(i, j int) bool { return labels[i].Name < labels[j].Name }) 290 return labels 291 } 292 293 // TODO(spiffxp): needs to validate labels duped across repos are identical 294 // Ensures the config does not duplicate label names between default and repo 295 func (c Configuration) validate(orgs string) error { 296 // Check default labels 297 defaultSeen, err := validate(c.Default.Labels, "default", make(map[string]string)) 298 if err != nil { 299 return fmt.Errorf("invalid config: %w", err) 300 } 301 302 // Generate list of orgs 303 sortedOrgs := strings.Split(orgs, ",") 304 sort.Strings(sortedOrgs) 305 306 // Check org-level labels for duplicities with default labels 307 orgSeen := map[string]map[string]string{} 308 for org, orgConfig := range c.Orgs { 309 if orgSeen[org], err = validate(orgConfig.Labels, org, defaultSeen); err != nil { 310 return fmt.Errorf("invalid config: %w", err) 311 } 312 } 313 314 for repo, repoconfig := range c.Repos { 315 data := strings.Split(repo, "/") 316 if len(data) != 2 { 317 return fmt.Errorf("invalid repo name '%s', expected org/repo form", repo) 318 } 319 org := data[0] 320 if _, ok := orgSeen[org]; !ok { 321 orgSeen[org] = defaultSeen 322 } 323 324 // Check repo labels for duplicities with default and org-level labels 325 if _, err := validate(repoconfig.Labels, repo, orgSeen[org]); err != nil { 326 return fmt.Errorf("invalid config: %w", err) 327 } 328 // If orgs have been specified, warn if repo isn't under orgs 329 if len(orgs) > 0 && !stringInSortedSlice(org, sortedOrgs) { 330 logrus.WithField("orgs", orgs).WithField("org", org).WithField("repo", repo).Warn("Repo isn't inside orgs") 331 } 332 333 } 334 return nil 335 } 336 337 // LabelsForTarget returns labels that have a given target 338 func LabelsForTarget(labels []Label, target LabelTarget) (filteredLabels []Label) { 339 for _, label := range labels { 340 if target == label.Target { 341 filteredLabels = append(filteredLabels, label) 342 } 343 } 344 // We also sort to make nice tables 345 sort.Slice(filteredLabels, func(i, j int) bool { return filteredLabels[i].Name < filteredLabels[j].Name }) 346 return 347 } 348 349 // LoadConfig reads the yaml config at path 350 func LoadConfig(path string, orgs string) (*Configuration, error) { 351 if path == "" { 352 return nil, errors.New("empty path") 353 } 354 var c Configuration 355 data, err := os.ReadFile(path) 356 if err != nil { 357 return nil, err 358 } 359 if err = yaml.Unmarshal(data, &c); err != nil { 360 return nil, err 361 } 362 if err = c.validate(orgs); err != nil { // Ensure no dups 363 return nil, err 364 } 365 return &c, nil 366 } 367 368 // GetOrg returns organization from "org" or "user:name" 369 // Org can be organization name like "kubernetes" 370 // But we can also request all user's public repos via user:github_user_name 371 func GetOrg(org string) (string, bool) { 372 data := strings.Split(org, ":") 373 if len(data) == 2 && data[0] == "user" { 374 return data[1], true 375 } 376 return org, false 377 } 378 379 // loadRepos read what (filtered) repos exist under an org 380 func loadRepos(org string, gc client) ([]string, error) { 381 org, isUser := GetOrg(org) 382 repos, err := gc.GetRepos(org, isUser) 383 if err != nil { 384 return nil, err 385 } 386 var rl []string 387 for _, r := range repos { 388 // Skip Archived repos as they can't be modified in this way 389 if r.Archived { 390 continue 391 } 392 // Skip private security forks as they can't be modified in this way 393 if r.Private && github.SecurityForkNameRE.MatchString(r.Name) { 394 continue 395 } 396 rl = append(rl, r.Name) 397 } 398 return rl, nil 399 } 400 401 // loadLabels returns what labels exist in github 402 func loadLabels(gc client, org string, repos []string) (*RepoLabels, error) { 403 repoChan := make(chan string, len(repos)) 404 for _, repo := range repos { 405 repoChan <- repo 406 } 407 close(repoChan) 408 409 wg := sync.WaitGroup{} 410 wg.Add(maxConcurrentWorkers) 411 labels := make(chan RepoLabels, len(repos)) 412 errChan := make(chan error, len(repos)) 413 for i := 0; i < maxConcurrentWorkers; i++ { 414 go func(repositories <-chan string) { 415 defer wg.Done() 416 for repository := range repositories { 417 logrus.WithField("org", org).WithField("repo", repository).Info("Listing labels for repo") 418 repoLabels, err := gc.GetRepoLabels(org, repository) 419 if err != nil { 420 logrus.WithField("org", org).WithField("repo", repository).WithError(err).Error("Failed listing labels for repo") 421 errChan <- err 422 } 423 labels <- RepoLabels{repository: repoLabels} 424 } 425 }(repoChan) 426 } 427 428 wg.Wait() 429 close(labels) 430 close(errChan) 431 432 rl := RepoLabels{} 433 for data := range labels { 434 for repo, repoLabels := range data { 435 rl[repo] = repoLabels 436 } 437 } 438 439 var overallErr error 440 if len(errChan) > 0 { 441 var listErrs []error 442 for listErr := range errChan { 443 listErrs = append(listErrs, listErr) 444 } 445 overallErr = fmt.Errorf("failed to list labels: %v", listErrs) 446 } 447 448 return &rl, overallErr 449 } 450 451 // Delete the label 452 func kill(repo string, label Label) Update { 453 logrus.WithField("repo", repo).WithField("label", label.Name).Info("kill") 454 return Update{Why: "dead", Current: &label, repo: repo} 455 } 456 457 // Create the label 458 func create(repo string, label Label) Update { 459 logrus.WithField("repo", repo).WithField("label", label.Name).Info("create") 460 return Update{Why: "missing", Wanted: &label, repo: repo} 461 } 462 463 // Rename the label (will also update color) 464 func rename(repo string, previous, wanted Label) Update { 465 logrus.WithField("repo", repo).WithField("from", previous.Name).WithField("to", wanted.Name).Info("rename") 466 return Update{Why: "rename", Current: &previous, Wanted: &wanted, repo: repo} 467 } 468 469 // Update the label color/description 470 func change(repo string, label Label) Update { 471 logrus.WithField("repo", repo).WithField("label", label.Name).WithField("color", label.Color).Info("change") 472 return Update{Why: "change", Current: &label, Wanted: &label, repo: repo} 473 } 474 475 // Migrate labels to another label 476 func move(repo string, previous, wanted Label) Update { 477 logrus.WithField("repo", repo).WithField("from", previous.Name).WithField("to", wanted.Name).Info("migrate") 478 return Update{Why: "migrate", Wanted: &wanted, Current: &previous, repo: repo} 479 } 480 481 // classifyLabels will put labels into the required, archaic, dead maps as appropriate. 482 func classifyLabels(labels []Label, required, archaic, dead map[string]Label, now time.Time, parent *Label) (map[string]Label, map[string]Label, map[string]Label) { 483 newRequired := copyLabelMap(required) 484 newArchaic := copyLabelMap(archaic) 485 newDead := copyLabelMap(dead) 486 for i, l := range labels { 487 first := parent 488 if first == nil { 489 first = &labels[i] 490 } 491 lower := strings.ToLower(l.Name) 492 switch { 493 case parent == nil && l.DeleteAfter == nil: // Live label 494 newRequired[lower] = l 495 case l.DeleteAfter != nil && now.After(*l.DeleteAfter): 496 newDead[lower] = l 497 case parent != nil: 498 l.parent = parent 499 newArchaic[lower] = l 500 } 501 newRequired, newArchaic, newDead = classifyLabels(l.Previously, newRequired, newArchaic, newDead, now, first) 502 } 503 return newRequired, newArchaic, newDead 504 } 505 506 func copyLabelMap(originalMap map[string]Label) map[string]Label { 507 newMap := make(map[string]Label) 508 for k, v := range originalMap { 509 newMap[k] = v 510 } 511 return newMap 512 } 513 514 func syncLabels(config Configuration, org string, repos RepoLabels) (RepoUpdates, error) { 515 // Find required, dead and archaic labels 516 defaultRequired, defaultArchaic, defaultDead := classifyLabels(config.Default.Labels, make(map[string]Label), make(map[string]Label), make(map[string]Label), time.Now(), nil) 517 if orgLabels, ok := config.Orgs[org]; ok { 518 defaultRequired, defaultArchaic, defaultDead = classifyLabels(orgLabels.Labels, defaultRequired, defaultArchaic, defaultDead, time.Now(), nil) 519 } 520 521 var validationErrors []error 522 var actions []Update 523 // Process all repos 524 for repo, repoLabels := range repos { 525 var required, archaic, dead map[string]Label 526 // Check if we have more labels for repo 527 if repoconfig, ok := config.Repos[org+"/"+repo]; ok { 528 // Use classifyLabels() to add them to default ones 529 required, archaic, dead = classifyLabels(repoconfig.Labels, defaultRequired, defaultArchaic, defaultDead, time.Now(), nil) 530 } else { 531 // Otherwise just copy the pointers 532 required = defaultRequired // Must exist 533 archaic = defaultArchaic // Migrate 534 dead = defaultDead // Delete 535 } 536 // Convert github.Label to Label 537 var labels []Label 538 for _, l := range repoLabels { 539 labels = append(labels, Label{Name: l.Name, Description: l.Description, Color: l.Color}) 540 } 541 // Check for any duplicate labels 542 if _, err := validate(labels, "", make(map[string]string)); err != nil { 543 validationErrors = append(validationErrors, fmt.Errorf("invalid labels in %s: %w", repo, err)) 544 continue 545 } 546 // Create lowercase map of current labels, checking for dead labels to delete. 547 current := make(map[string]Label) 548 for _, l := range labels { 549 lower := strings.ToLower(l.Name) 550 // Should we delete this dead label? 551 if _, found := dead[lower]; found { 552 actions = append(actions, kill(repo, l)) 553 } 554 current[lower] = l 555 } 556 557 var moveActions []Update // Separate list to do last 558 // Look for labels to migrate 559 for name, l := range archaic { 560 // Does the archaic label exist? 561 cur, found := current[name] 562 if !found { // No 563 continue 564 } 565 // What do we want to migrate it to? 566 desired := Label{Name: l.parent.Name, Description: l.Description, Color: l.parent.Color} 567 desiredName := strings.ToLower(l.parent.Name) 568 // Does the new label exist? 569 _, found = current[desiredName] 570 if found { // Yes, migrate all these labels 571 moveActions = append(moveActions, move(repo, cur, desired)) 572 } else { // No, rename the existing label 573 actions = append(actions, rename(repo, cur, desired)) 574 current[desiredName] = desired 575 } 576 } 577 578 // Look for missing labels 579 for name, l := range required { 580 cur, found := current[name] 581 switch { 582 case !found: 583 actions = append(actions, create(repo, l)) 584 case l.Name != cur.Name: 585 actions = append(actions, rename(repo, cur, l)) 586 case l.Color != cur.Color: 587 actions = append(actions, change(repo, l)) 588 case l.Description != cur.Description: 589 actions = append(actions, change(repo, l)) 590 } 591 } 592 593 actions = append(actions, moveActions...) 594 } 595 596 u := RepoUpdates{} 597 for _, a := range actions { 598 u[a.repo] = append(u[a.repo], a) 599 } 600 601 var overallErr error 602 if len(validationErrors) > 0 { 603 overallErr = fmt.Errorf("label validation failed: %v", validationErrors) 604 } 605 return u, overallErr 606 } 607 608 type repoUpdate struct { 609 repo string 610 update Update 611 } 612 613 // DoUpdates iterates generated update data and adds and/or modifies labels on repositories 614 // Uses AddLabel GH API to add missing labels 615 // And UpdateLabel GH API to update color or name (name only when case differs) 616 func (ru RepoUpdates) DoUpdates(org string, gc client) error { 617 var numUpdates int 618 for _, updates := range ru { 619 numUpdates += len(updates) 620 } 621 622 updateChan := make(chan repoUpdate, numUpdates) 623 for repo, updates := range ru { 624 logrus.WithField("org", org).WithField("repo", repo).Infof("Applying %d changes", len(updates)) 625 for _, item := range updates { 626 updateChan <- repoUpdate{repo: repo, update: item} 627 } 628 } 629 close(updateChan) 630 631 wrapErr := func(action, why, org, repo string, err error) error { 632 return fmt.Errorf("update failed %s %s %s/%s: %w", action, why, org, repo, err) 633 } 634 635 wg := sync.WaitGroup{} 636 wg.Add(maxConcurrentWorkers) 637 errChan := make(chan error, numUpdates) 638 for i := 0; i < maxConcurrentWorkers; i++ { 639 go func(updates <-chan repoUpdate) { 640 defer wg.Done() 641 for item := range updates { 642 repo := item.repo 643 update := item.update 644 logrus.WithField("org", org).WithField("repo", repo).WithField("why", update.Why).Debug("running update") 645 switch update.Why { 646 case "missing": 647 err := gc.AddRepoLabel(org, repo, update.Wanted.Name, update.Wanted.Description, update.Wanted.Color) 648 if err != nil { 649 errChan <- wrapErr("add-repo-label", update.Why, org, item.repo, err) 650 } 651 case "change", "rename": 652 err := gc.UpdateRepoLabel(org, repo, update.Current.Name, update.Wanted.Name, update.Wanted.Description, update.Wanted.Color) 653 if err != nil { 654 errChan <- wrapErr("update-repo-label", update.Why, org, item.repo, err) 655 } 656 case "dead": 657 err := gc.DeleteRepoLabel(org, repo, update.Current.Name) 658 if err != nil { 659 errChan <- wrapErr("delete-repo-label", update.Why, org, item.repo, err) 660 } 661 case "migrate": 662 issues, err := gc.FindIssuesWithOrg(org, fmt.Sprintf("is:open repo:%s/%s label:\"%s\" -label:\"%s\"", org, repo, update.Current.Name, update.Wanted.Name), "", false) 663 if err != nil { 664 errChan <- wrapErr("find-issues-with-org", update.Why, org, item.repo, err) 665 } 666 if len(issues) == 0 { 667 if err = gc.DeleteRepoLabel(org, repo, update.Current.Name); err != nil { 668 errChan <- wrapErr("delete-repo-label", update.Why, org, item.repo, err) 669 } 670 } 671 for _, i := range issues { 672 if err = gc.AddLabel(org, repo, i.Number, update.Wanted.Name); err != nil { 673 errChan <- wrapErr("add-label", update.Why, org, item.repo, err) 674 continue 675 } 676 if err = gc.RemoveLabel(org, repo, i.Number, update.Current.Name); err != nil { 677 errChan <- wrapErr("remove-label", update.Why, org, item.repo, err) 678 } 679 } 680 default: 681 errChan <- errors.New("unknown label operation: " + update.Why) 682 } 683 } 684 }(updateChan) 685 } 686 687 wg.Wait() 688 close(errChan) 689 690 var overallErr error 691 if len(errChan) > 0 { 692 var updateErrs []error 693 for updateErr := range errChan { 694 updateErrs = append(updateErrs, updateErr) 695 } 696 overallErr = fmt.Errorf("failed to update labels: %v", updateErrs) 697 } 698 699 return overallErr 700 } 701 702 type client interface { 703 AddRepoLabel(org, repo, name, description, color string) error 704 UpdateRepoLabel(org, repo, currentName, newName, description, color string) error 705 DeleteRepoLabel(org, repo, label string) error 706 AddLabel(org, repo string, number int, label string) error 707 RemoveLabel(org, repo string, number int, label string) error 708 FindIssuesWithOrg(org, query, sort string, asc bool) ([]github.Issue, error) 709 GetRepos(org string, isUser bool) ([]github.Repo, error) 710 GetRepoLabels(string, string) ([]github.Label, error) 711 SetMax404Retries(int) 712 } 713 714 func newClient(tokenPath string, tokens, tokenBurst int, dryRun bool, graphqlEndpoint string, hosts ...string) (client, error) { 715 if tokenPath == "" { 716 return nil, errors.New("--token unset") 717 } 718 719 if err := secret.Add(tokenPath); err != nil { 720 logrus.WithError(err).Fatal("Error starting secrets agent.") 721 } 722 723 if dryRun { 724 return github.NewDryRunClient(secret.GetTokenGenerator(tokenPath), secret.Censor, graphqlEndpoint, hosts...) 725 } 726 c, err := github.NewClient(secret.GetTokenGenerator(tokenPath), secret.Censor, graphqlEndpoint, hosts...) 727 if err != nil { 728 return nil, fmt.Errorf("failed to construct github client: %v", err) 729 } 730 if tokens > 0 && tokenBurst >= tokens { 731 return nil, fmt.Errorf("--tokens=%d must exceed --token-burst=%d", tokens, tokenBurst) 732 } 733 if tokens > 0 { 734 c.Throttle(tokens, tokenBurst) // 300 hourly tokens, bursts of 100 735 } 736 return c, nil 737 } 738 739 // Main function 740 // Typical run with production configuration should require no parameters 741 // It expects: 742 // "labels" file in "/etc/config/labels.yaml" 743 // github OAuth2 token in "/etc/github/oauth", this token must have write access to all org's repos 744 // It uses request retrying (in case of run out of GH API points) 745 // It took about 10 minutes to process all my 8 repos with all wanted "kubernetes" labels (70+) 746 // Next run takes about 22 seconds to check if all labels are correct on all repos 747 func main() { 748 logrusutil.ComponentInit() 749 o, deprecated := gatherOptions() 750 751 if o.debug { 752 logrus.SetLevel(logrus.DebugLevel) 753 } 754 755 config, err := LoadConfig(o.labelsPath, o.orgs) 756 if err != nil { 757 logrus.WithError(err).Fatalf("failed to load --config=%s", o.labelsPath) 758 } 759 760 if o.onlyRepos != "" && o.skipRepos != "" { 761 logrus.Fatalf("--only and --skip cannot both be set") 762 } 763 764 if o.onlyRepos != "" && o.orgs != "" { 765 logrus.Fatalf("--only and --orgs cannot both be set") 766 } 767 768 switch { 769 case o.action == "docs": 770 if err := writeDocs(o.docsTemplate, o.docsOutput, *config); err != nil { 771 logrus.WithError(err).Fatalf("failed to write docs using docs-template %s to docs-output %s", o.docsTemplate, o.docsOutput) 772 } 773 case o.action == "css": 774 if err := writeCSS(o.cssTemplate, o.cssOutput, *config); err != nil { 775 logrus.WithError(err).Fatalf("failed to write css file using css-template %s to css-output %s", o.cssTemplate, o.cssOutput) 776 } 777 case o.action == "sync": 778 var githubClient client 779 var err error 780 if deprecated { 781 githubClient, err = newClient(o.token, o.tokens, o.tokenBurst, !o.confirm, o.graphqlEndpoint, o.endpoint.Strings()...) 782 } else { 783 err = o.github.Validate(!o.confirm) 784 if err == nil { 785 githubClient, err = o.github.GitHubClient(!o.confirm) 786 } 787 } 788 789 if err != nil { 790 logrus.WithError(err).Fatal("failed to create client") 791 } 792 793 githubClient.SetMax404Retries(0) 794 795 // there are three ways to configure which repos to sync: 796 // - a list of org/repo values 797 // - a list of orgs for which we sync all repos 798 // - a list of orgs to sync with a list of org/repo values to skip 799 if o.onlyRepos != "" { 800 reposToSync, parseError := parseCommaDelimitedList(o.onlyRepos) 801 if parseError != nil { 802 logrus.WithError(err).Fatal("invalid value for --only") 803 } 804 for org := range reposToSync { 805 if err = syncOrg(org, githubClient, *config, reposToSync[org], o.confirm); err != nil { 806 logrus.WithError(err).Fatalf("failed to update %s", org) 807 } 808 } 809 return 810 } 811 812 skippedRepos := map[string][]string{} 813 if o.skipRepos != "" { 814 reposToSkip, parseError := parseCommaDelimitedList(o.skipRepos) 815 if parseError != nil { 816 logrus.WithError(err).Fatal("invalid value for --skip") 817 } 818 skippedRepos = reposToSkip 819 } 820 821 for _, org := range strings.Split(o.orgs, ",") { 822 org = strings.TrimSpace(org) 823 logger := logrus.WithField("org", org) 824 logger.Info("Reading repos") 825 repos, err := loadRepos(org, githubClient) 826 if err != nil { 827 logger.WithError(err).Fatalf("failed to read repos") 828 } 829 if skipped, exist := skippedRepos[org]; exist { 830 repos = sets.NewString(repos...).Difference(sets.NewString(skipped...)).UnsortedList() 831 } 832 if err = syncOrg(org, githubClient, *config, repos, o.confirm); err != nil { 833 logrus.WithError(err).Fatalf("failed to update %s", org) 834 } 835 } 836 default: 837 logrus.Fatalf("unrecognized action: %s", o.action) 838 } 839 } 840 841 // parseCommaDelimitedList parses values in the format: 842 // 843 // org/repo,org2/repo2,org/repo3 844 // 845 // into a mapping of org to repos, i.e.: 846 // 847 // org: repo, repo3 848 // org2: repo2 849 func parseCommaDelimitedList(list string) (map[string][]string, error) { 850 mapping := map[string][]string{} 851 for _, r := range strings.Split(list, ",") { 852 value := strings.TrimSpace(r) 853 if strings.Count(value, "/") != 1 { 854 return nil, fmt.Errorf("invalid org/repo value %q", value) 855 } 856 parts := strings.SplitN(value, "/", 2) 857 if others, exist := mapping[parts[0]]; !exist { 858 mapping[parts[0]] = []string{parts[1]} 859 } else { 860 mapping[parts[0]] = append(others, parts[1]) 861 } 862 } 863 return mapping, nil 864 } 865 866 type labelData struct { 867 Description, Link, Labels interface{} 868 } 869 870 func writeDocs(template string, output string, config Configuration) error { 871 var desc string 872 var data []labelData 873 desc = "all repos, for both issues and PRs" 874 data = append(data, labelData{desc, linkify(desc), LabelsForTarget(config.Default.Labels, bothTarget)}) 875 desc = "all repos, only for issues" 876 data = append(data, labelData{desc, linkify(desc), LabelsForTarget(config.Default.Labels, issueTarget)}) 877 desc = "all repos, only for PRs" 878 data = append(data, labelData{desc, linkify(desc), LabelsForTarget(config.Default.Labels, prTarget)}) 879 // Let's sort orgs 880 var orgs []string 881 for org := range config.Orgs { 882 orgs = append(orgs, org) 883 } 884 sort.Strings(orgs) 885 // And append their labels 886 for _, org := range orgs { 887 lead := fmt.Sprintf("all repos in %s", org) 888 if l := LabelsForTarget(config.Orgs[org].Labels, bothTarget); len(l) > 0 { 889 desc = lead + ", for both issues and PRs" 890 data = append(data, labelData{desc, linkify(desc), l}) 891 } 892 if l := LabelsForTarget(config.Orgs[org].Labels, issueTarget); len(l) > 0 { 893 desc = lead + ", only for issues" 894 data = append(data, labelData{desc, linkify(desc), l}) 895 } 896 if l := LabelsForTarget(config.Orgs[org].Labels, prTarget); len(l) > 0 { 897 desc = lead + ", only for PRs" 898 data = append(data, labelData{desc, linkify(desc), l}) 899 } 900 } 901 902 // Let's sort repos 903 var repos []string 904 for repo := range config.Repos { 905 repos = append(repos, repo) 906 } 907 sort.Strings(repos) 908 // And append their labels 909 for _, repo := range repos { 910 if l := LabelsForTarget(config.Repos[repo].Labels, bothTarget); len(l) > 0 { 911 desc = repo + ", for both issues and PRs" 912 data = append(data, labelData{desc, linkify(desc), l}) 913 } 914 if l := LabelsForTarget(config.Repos[repo].Labels, issueTarget); len(l) > 0 { 915 desc = repo + ", only for issues" 916 data = append(data, labelData{desc, linkify(desc), l}) 917 } 918 if l := LabelsForTarget(config.Repos[repo].Labels, prTarget); len(l) > 0 { 919 desc = repo + ", only for PRs" 920 data = append(data, labelData{desc, linkify(desc), l}) 921 } 922 } 923 if err := writeTemplate(template, output, data); err != nil { 924 return err 925 } 926 return nil 927 } 928 929 // linkify transforms a string into a markdown anchor link 930 // I could not find a proper doc, so rules here a mostly empirical 931 func linkify(text string) string { 932 // swap space with dash 933 link := strings.Replace(text, " ", "-", -1) 934 // discard some special characters 935 discard, _ := regexp.Compile("[,/]") 936 link = discard.ReplaceAllString(link, "") 937 // lowercase 938 return strings.ToLower(link) 939 } 940 941 func syncOrg(org string, githubClient client, config Configuration, repos []string, confirm bool) error { 942 logger := logrus.WithField("org", org) 943 logger.Infof("Found %d repos", len(repos)) 944 currLabels, err := loadLabels(githubClient, org, repos) 945 if err != nil { 946 return err 947 } 948 949 logger.Infof("Syncing labels for %d repos", len(repos)) 950 updates, err := syncLabels(config, org, *currLabels) 951 if err != nil { 952 return err 953 } 954 955 y, _ := yaml.Marshal(updates) 956 logger.Debug(string(y)) 957 958 if !confirm { 959 logger.Infof("Running without --confirm, no mutations made") 960 return nil 961 } 962 963 if err = updates.DoUpdates(org, githubClient); err != nil { 964 return err 965 } 966 return nil 967 } 968 969 type labelCSSData struct { 970 BackgroundColor, Color, Name string 971 } 972 973 // Returns the CSS escaped label name. Escaped method based on 974 // https://www.w3.org/International/questions/qa-escapes#cssescapes 975 func cssEscape(s string) (escaped string) { 976 var IsAlpha = regexp.MustCompile(`^[a-zA-Z]+$`).MatchString 977 for i, c := range s { 978 if (i == 0 && unicode.IsDigit(c)) || !(unicode.IsDigit(c) || IsAlpha(string(c))) { 979 escaped += fmt.Sprintf("x%0.6x", c) 980 continue 981 } 982 escaped += string(c) 983 } 984 return 985 } 986 987 // Returns the text color (whether black or white) given the background color. 988 // Details: https://www.w3.org/TR/WCAG20/#contrastratio 989 func getTextColor(backgroundColor string) (string, error) { 990 d, err := hex.DecodeString(backgroundColor) 991 if err != nil || len(d) != 3 { 992 return "", errors.New("expect 6-digit color hex of label") 993 } 994 995 // Calculate the relative luminance (L) of a color 996 // L = 0.2126 * R + 0.7152 * G + 0.0722 * B 997 // Formula details at: https://www.w3.org/TR/WCAG20/#relativeluminancedef 998 color := [3]float64{} 999 for i, v := range d { 1000 color[i] = float64(v) / 255.0 1001 if color[i] <= 0.03928 { 1002 color[i] = color[i] / 12.92 1003 } else { 1004 color[i] = math.Pow((color[i]+0.055)/1.055, 2.4) 1005 } 1006 } 1007 L := 0.2126*color[0] + 0.7152*color[1] + 0.0722*color[2] 1008 1009 if (L+0.05)/(0.0+0.05) > (1.0+0.05)/(L+0.05) { 1010 return "000000", nil 1011 } 1012 return "ffffff", nil 1013 } 1014 1015 func writeCSS(tmplPath string, outPath string, config Configuration) error { 1016 var labelCSS []labelCSSData 1017 for _, l := range config.Labels() { 1018 textColor, err := getTextColor(l.Color) 1019 if err != nil { 1020 return err 1021 } 1022 1023 labelCSS = append(labelCSS, labelCSSData{ 1024 BackgroundColor: l.Color, 1025 Color: textColor, 1026 Name: cssEscape(l.Name), 1027 }) 1028 } 1029 1030 return writeTemplate(tmplPath, outPath, labelCSS) 1031 }