github.com/yrj2011/jx-test-infra@v0.0.0-20190529031832-7a2065ee98eb/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 "errors" 22 "flag" 23 "fmt" 24 "io/ioutil" 25 "os" 26 "path/filepath" 27 "strings" 28 "sync" 29 "text/template" 30 "time" 31 32 "github.com/ghodss/yaml" 33 "github.com/sirupsen/logrus" 34 35 "k8s.io/test-infra/prow/config" 36 "k8s.io/test-infra/prow/flagutil" 37 "k8s.io/test-infra/prow/github" 38 ) 39 40 const maxConcurrentWorkers = 20 41 42 // A label in a repository. 43 44 // LabelTarget specifies the intent of the label (PR or issue) 45 type LabelTarget string 46 47 const ( 48 prTarget LabelTarget = "prs" 49 issueTarget = "issues" 50 bothTarget = "both" 51 ) 52 53 // LabelTargets is a slice of options: pr, issue, both 54 var LabelTargets = []LabelTarget{prTarget, issueTarget, bothTarget} 55 56 // Label holds declarative data about the label. 57 type Label struct { 58 // Name is the current name of the label 59 Name string `json:"name"` 60 // Color is rrggbb or color 61 Color string `json:"color"` 62 // Description is brief text explaining its meaning, who can apply it 63 Description string `json:"description"` // What does this label mean, who can apply it 64 // Target specifies whether it targets PRs, issues or both 65 Target LabelTarget `json:"target"` 66 // ProwPlugin specifies which prow plugin add/removes this label 67 ProwPlugin string `json:"prowPlugin"` 68 // AddedBy specifies whether human/munger/bot adds the label 69 AddedBy string `json:"addedBy"` 70 // Previously lists deprecated names for this label 71 Previously []Label `json:"previously,omitempty"` 72 // DeleteAfter specifies the label is retired and a safe date for deletion 73 DeleteAfter *time.Time `json:"deleteAfter,omitempty"` 74 parent *Label // Current name for previous labels (used internally) 75 } 76 77 // Configuration is a list of Required Labels to sync in all kubernetes repos 78 type Configuration struct { 79 Labels []Label `json:"labels"` 80 } 81 82 // RepoList holds a slice of repos. 83 type RepoList []github.Repo 84 85 // RepoLabels holds a repo => []github.Label mapping. 86 type RepoLabels map[string][]github.Label 87 88 // Update a label in a repo 89 type Update struct { 90 repo string 91 Why string 92 Wanted *Label `json:"wanted,omitempty"` 93 Current *Label `json:"current,omitempty"` 94 } 95 96 // RepoUpdates Repositories to update: map repo name --> list of Updates 97 type RepoUpdates map[string][]Update 98 99 const ( 100 defaultTokens = 300 101 defaultBurst = 100 102 ) 103 104 // TODO(fejta): rewrite this to use an option struct which we can unit test, like everything else. 105 var ( 106 debug = flag.Bool("debug", false, "Turn on debug to be more verbose") 107 confirm = flag.Bool("confirm", false, "Make mutating API calls to GitHub.") 108 endpoint = flagutil.NewStrings("https://api.github.com") 109 labelsPath = flag.String("config", "", "Path to labels.yaml") 110 onlyRepos = flag.String("only", "", "Only look at the following comma separated org/repos") 111 orgs = flag.String("orgs", "", "Comma separated list of orgs to sync") 112 skipRepos = flag.String("skip", "", "Comma separated list of org/repos to skip syncing") 113 token = flag.String("token", "", "Path to github oauth secret") 114 action = flag.String("action", "sync", "One of: sync, docs") 115 docsTemplate = flag.String("docs-template", "", "Path to template file for label docs") 116 docsOutput = flag.String("docs-output", "", "Path to output file for docs") 117 tokens = flag.Int("tokens", defaultTokens, "Throttle hourly token consumption (0 to disable)") 118 tokenBurst = flag.Int("token-burst", defaultBurst, "Allow consuming a subset of hourly tokens in a short burst") 119 ) 120 121 func init() { 122 flag.Var(&endpoint, "endpoint", "GitHub's API endpoint") 123 } 124 125 func pathExists(path string) bool { 126 _, err := os.Stat(path) 127 return err == nil 128 } 129 130 // Writes the golang text template at templatePath to outputPath using the given data 131 func writeTemplate(templatePath string, outputPath string, data interface{}) error { 132 // set up template 133 funcMap := template.FuncMap{ 134 "anchor": func(input string) string { 135 return strings.Replace(input, ":", " ", -1) 136 }, 137 } 138 t, err := template.New(filepath.Base(templatePath)).Funcs(funcMap).ParseFiles(templatePath) 139 if err != nil { 140 return err 141 } 142 143 // ensure output path exists 144 if !pathExists(outputPath) { 145 _, err = os.Create(outputPath) 146 if err != nil { 147 return err 148 } 149 } 150 151 // open file at output path and truncate 152 f, err := os.OpenFile(outputPath, os.O_RDWR, 0644) 153 if err != nil { 154 return err 155 } 156 defer f.Close() 157 f.Truncate(0) 158 159 // render template to output path 160 err = t.Execute(f, data) 161 if err != nil { 162 return err 163 } 164 165 return nil 166 } 167 168 // validate runs checks to ensure the label inputs are valid 169 // It ensures that no two label names (including previous names) have the same 170 // lowercase value, and that the description is not over 100 characters. 171 func validate(labels []Label, parent string, seen map[string]string) error { 172 for _, l := range labels { 173 name := strings.ToLower(l.Name) 174 path := parent + "." + name 175 if other, present := seen[name]; present { 176 return fmt.Errorf("duplicate label %s at %s and %s", name, path, other) 177 } 178 seen[name] = path 179 if err := validate(l.Previously, path, seen); err != nil { 180 return err 181 } 182 if len(l.Description) > 99 { // github limits the description field to 100 chars 183 return fmt.Errorf("description for %s is too long", name) 184 } 185 } 186 return nil 187 } 188 189 // Ensures the config does not duplicate label names 190 func (c Configuration) validate() error { 191 seen := make(map[string]string) 192 if err := validate(c.Labels, "", seen); err != nil { 193 return fmt.Errorf("invalid config: %v", err) 194 } 195 return nil 196 } 197 198 // LabelsByTarget returns labels that have a given target 199 func (c Configuration) LabelsByTarget(target LabelTarget) (labels []Label) { 200 for _, label := range c.Labels { 201 if target == label.Target { 202 labels = append(labels, label) 203 } 204 } 205 return 206 } 207 208 // LoadConfig reads the yaml config at path 209 func LoadConfig(path string) (*Configuration, error) { 210 if path == "" { 211 return nil, errors.New("empty path") 212 } 213 var c Configuration 214 data, err := ioutil.ReadFile(path) 215 if err != nil { 216 return nil, err 217 } 218 if err = yaml.Unmarshal(data, &c); err != nil { 219 return nil, err 220 } 221 if err = c.validate(); err != nil { // Ensure no dups 222 return nil, err 223 } 224 return &c, nil 225 } 226 227 // GetOrg returns organization from "org" or "user:name" 228 // Org can be organization name like "kubernetes" 229 // But we can also request all user's public repos via user:github_user_name 230 func GetOrg(org string) (string, bool) { 231 data := strings.Split(org, ":") 232 if len(data) == 2 && data[0] == "user" { 233 return data[1], true 234 } 235 return org, false 236 } 237 238 // loadRepos read what (filtered) repos exist under an org 239 func loadRepos(org string, gc client, filt filter) (RepoList, error) { 240 org, isUser := GetOrg(org) 241 repos, err := gc.GetRepos(org, isUser) 242 if err != nil { 243 return nil, err 244 } 245 var rl RepoList 246 for _, r := range repos { 247 if !filt(org, r.Name) { 248 continue 249 } 250 rl = append(rl, r) 251 } 252 return rl, nil 253 } 254 255 // loadLabels returns what labels exist in github 256 func loadLabels(gc client, org string, repos RepoList) (*RepoLabels, error) { 257 repoChan := make(chan github.Repo, len(repos)) 258 for _, repo := range repos { 259 repoChan <- repo 260 } 261 close(repoChan) 262 263 wg := sync.WaitGroup{} 264 wg.Add(maxConcurrentWorkers) 265 labels := make(chan RepoLabels, len(repos)) 266 errChan := make(chan error, len(repos)) 267 for i := 0; i < maxConcurrentWorkers; i++ { 268 go func(repositories <-chan github.Repo) { 269 defer wg.Done() 270 for repository := range repositories { 271 logrus.WithField("org", org).WithField("repo", repository.Name).Info("Listing labels for repo") 272 repoLabels, err := gc.GetRepoLabels(org, repository.Name) 273 if err != nil { 274 logrus.WithField("org", org).WithField("repo", repository.Name).Error("Failed listing labels for repo") 275 errChan <- err 276 } 277 labels <- RepoLabels{repository.Name: repoLabels} 278 } 279 }(repoChan) 280 } 281 282 wg.Wait() 283 close(labels) 284 close(errChan) 285 286 rl := RepoLabels{} 287 for data := range labels { 288 for repo, repoLabels := range data { 289 rl[repo] = repoLabels 290 } 291 } 292 293 var overallErr error 294 if len(errChan) > 0 { 295 var listErrs []error 296 for listErr := range errChan { 297 listErrs = append(listErrs, listErr) 298 } 299 overallErr = fmt.Errorf("failed to list labels: %v", listErrs) 300 } 301 302 return &rl, overallErr 303 } 304 305 // Delete the label 306 func kill(repo string, label Label) Update { 307 logrus.WithField("repo", repo).WithField("label", label.Name).Info("kill") 308 return Update{Why: "dead", Current: &label, repo: repo} 309 } 310 311 // Create the label 312 func create(repo string, label Label) Update { 313 logrus.WithField("repo", repo).WithField("label", label.Name).Info("create") 314 return Update{Why: "missing", Wanted: &label, repo: repo} 315 } 316 317 // Rename the label (will also update color) 318 func rename(repo string, previous, wanted Label) Update { 319 logrus.WithField("repo", repo).WithField("from", previous.Name).WithField("to", wanted.Name).Info("rename") 320 return Update{Why: "rename", Current: &previous, Wanted: &wanted, repo: repo} 321 } 322 323 // Update the label color/description 324 func change(repo string, label Label) Update { 325 logrus.WithField("repo", repo).WithField("label", label.Name).WithField("color", label.Color).Info("change") 326 return Update{Why: "change", Current: &label, Wanted: &label, repo: repo} 327 } 328 329 // Migrate labels to another label 330 func move(repo string, previous, wanted Label) Update { 331 logrus.WithField("repo", repo).WithField("from", previous.Name).WithField("to", wanted.Name).Info("migrate") 332 return Update{Why: "migrate", Wanted: &wanted, Current: &previous, repo: repo} 333 } 334 335 // classifyLabels will put labels into the required, archaic, dead maps as appropriate. 336 func classifyLabels(labels []Label, required, archaic, dead map[string]Label, now time.Time, parent *Label) { 337 for i, l := range labels { 338 first := parent 339 if first == nil { 340 first = &labels[i] 341 } 342 lower := strings.ToLower(l.Name) 343 switch { 344 case parent == nil && l.DeleteAfter == nil: // Live label 345 required[lower] = l 346 case l.DeleteAfter != nil && now.After(*l.DeleteAfter): 347 dead[lower] = l 348 case parent != nil: 349 l.parent = parent 350 archaic[lower] = l 351 } 352 classifyLabels(l.Previously, required, archaic, dead, now, first) 353 } 354 } 355 356 func syncLabels(config Configuration, repos RepoLabels) (RepoUpdates, error) { 357 // Ensure the config is valid 358 if err := config.validate(); err != nil { 359 return nil, fmt.Errorf("invalid config: %v", err) 360 } 361 362 // Find required, dead and archaic labels 363 required := make(map[string]Label) // Must exist 364 archaic := make(map[string]Label) // Migrate 365 dead := make(map[string]Label) // Delete 366 classifyLabels(config.Labels, required, archaic, dead, time.Now(), nil) 367 368 var validationErrors []error 369 var actions []Update 370 // Process all repos 371 for repo, repoLabels := range repos { 372 // Convert github.Label to Label 373 var labels []Label 374 for _, l := range repoLabels { 375 labels = append(labels, Label{Name: l.Name, Description: l.Description, Color: l.Color}) 376 } 377 // Check for any duplicate labels 378 if err := validate(labels, "", make(map[string]string)); err != nil { 379 validationErrors = append(validationErrors, fmt.Errorf("invalid labels in %s: %v", repo, err)) 380 continue 381 } 382 // Create lowercase map of current labels, checking for dead labels to delete. 383 current := make(map[string]Label) 384 for _, l := range labels { 385 lower := strings.ToLower(l.Name) 386 // Should we delete this dead label? 387 if _, found := dead[lower]; found { 388 actions = append(actions, kill(repo, l)) 389 } 390 current[lower] = l 391 } 392 393 var moveActions []Update // Separate list to do last 394 // Look for labels to migrate 395 for name, l := range archaic { 396 // Does the archaic label exist? 397 cur, found := current[name] 398 if !found { // No 399 continue 400 } 401 // What do we want to migrate it to? 402 desired := Label{Name: l.parent.Name, Description: l.Description, Color: l.parent.Color} 403 desiredName := strings.ToLower(l.parent.Name) 404 // Does the new label exist? 405 _, found = current[desiredName] 406 if found { // Yes, migrate all these labels 407 moveActions = append(moveActions, move(repo, cur, desired)) 408 } else { // No, rename the existing label 409 actions = append(actions, rename(repo, cur, desired)) 410 current[desiredName] = desired 411 } 412 } 413 414 // Look for missing labels 415 for name, l := range required { 416 cur, found := current[name] 417 switch { 418 case !found: 419 actions = append(actions, create(repo, l)) 420 case l.Name != cur.Name: 421 actions = append(actions, rename(repo, cur, l)) 422 case l.Color != cur.Color: 423 actions = append(actions, change(repo, l)) 424 case l.Description != cur.Description: 425 actions = append(actions, change(repo, l)) 426 } 427 } 428 429 for _, a := range moveActions { 430 actions = append(actions, a) 431 } 432 } 433 434 u := RepoUpdates{} 435 for _, a := range actions { 436 u[a.repo] = append(u[a.repo], a) 437 } 438 439 var overallErr error 440 if len(validationErrors) > 0 { 441 overallErr = fmt.Errorf("label validation failed: %v", validationErrors) 442 } 443 return u, overallErr 444 } 445 446 type repoUpdate struct { 447 repo string 448 update Update 449 } 450 451 // DoUpdates iterates generated update data and adds and/or modifies labels on repositories 452 // Uses AddLabel GH API to add missing labels 453 // And UpdateLabel GH API to update color or name (name only when case differs) 454 func (ru RepoUpdates) DoUpdates(org string, gc client) error { 455 var numUpdates int 456 for _, updates := range ru { 457 numUpdates += len(updates) 458 } 459 460 updateChan := make(chan repoUpdate, numUpdates) 461 for repo, updates := range ru { 462 logrus.WithField("org", org).WithField("repo", repo).Infof("Applying %d changes", len(updates)) 463 for _, item := range updates { 464 updateChan <- repoUpdate{repo: repo, update: item} 465 } 466 } 467 close(updateChan) 468 469 wg := sync.WaitGroup{} 470 wg.Add(maxConcurrentWorkers) 471 errChan := make(chan error, numUpdates) 472 for i := 0; i < maxConcurrentWorkers; i++ { 473 go func(updates <-chan repoUpdate) { 474 defer wg.Done() 475 for item := range updates { 476 repo := item.repo 477 update := item.update 478 logrus.WithField("org", org).WithField("repo", repo).WithField("why", update.Why).Debug("running update") 479 switch update.Why { 480 case "missing": 481 err := gc.AddRepoLabel(org, repo, update.Wanted.Name, update.Wanted.Description, update.Wanted.Color) 482 if err != nil { 483 errChan <- err 484 } 485 case "change", "rename": 486 err := gc.UpdateRepoLabel(org, repo, update.Current.Name, update.Wanted.Name, update.Wanted.Description, update.Wanted.Color) 487 if err != nil { 488 errChan <- err 489 } 490 case "dead": 491 err := gc.DeleteRepoLabel(org, repo, update.Current.Name) 492 if err != nil { 493 errChan <- err 494 } 495 case "migrate": 496 issues, err := gc.FindIssues(fmt.Sprintf("is:open repo:%s/%s label:\"%s\" -label:\"%s\"", org, repo, update.Current.Name, update.Wanted.Name), "", false) 497 if err != nil { 498 errChan <- err 499 } 500 if len(issues) == 0 { 501 if err = gc.DeleteRepoLabel(org, repo, update.Current.Name); err != nil { 502 errChan <- err 503 } 504 } 505 for _, i := range issues { 506 if err = gc.AddLabel(org, repo, i.Number, update.Wanted.Name); err != nil { 507 errChan <- err 508 continue 509 } 510 if err = gc.RemoveLabel(org, repo, i.Number, update.Current.Name); err != nil { 511 errChan <- err 512 } 513 } 514 default: 515 errChan <- errors.New("unknown label operation: " + update.Why) 516 } 517 } 518 }(updateChan) 519 } 520 521 wg.Wait() 522 close(errChan) 523 524 var overallErr error 525 if len(errChan) > 0 { 526 var updateErrs []error 527 for updateErr := range errChan { 528 updateErrs = append(updateErrs, updateErr) 529 } 530 overallErr = fmt.Errorf("failed to list labels: %v", updateErrs) 531 } 532 533 return overallErr 534 } 535 536 type client interface { 537 AddRepoLabel(org, repo, name, description, color string) error 538 UpdateRepoLabel(org, repo, currentName, newName, description, color string) error 539 DeleteRepoLabel(org, repo, label string) error 540 AddLabel(org, repo string, number int, label string) error 541 RemoveLabel(org, repo string, number int, label string) error 542 FindIssues(query, order string, ascending bool) ([]github.Issue, error) 543 GetRepos(org string, isUser bool) ([]github.Repo, error) 544 GetRepoLabels(string, string) ([]github.Label, error) 545 } 546 547 func newClient(tokenPath string, tokens, tokenBurst int, dryRun bool, hosts ...string) (client, error) { 548 if tokenPath == "" { 549 return nil, errors.New("--token unset") 550 } 551 552 secretAgent := &config.SecretAgent{} 553 if err := secretAgent.Start([]string{tokenPath}); err != nil { 554 logrus.WithError(err).Fatal("Error starting secrets agent.") 555 } 556 557 if dryRun { 558 return github.NewDryRunClient(secretAgent.GetTokenGenerator(tokenPath), hosts...), nil 559 } 560 c := github.NewClient(secretAgent.GetTokenGenerator(tokenPath), hosts...) 561 if tokens > 0 && tokenBurst >= tokens { 562 return nil, fmt.Errorf("--tokens=%d must exceed --token-burst=%d", tokens, tokenBurst) 563 } 564 if tokens > 0 { 565 c.Throttle(tokens, tokenBurst) // 300 hourly tokens, bursts of 100 566 } 567 return c, nil 568 } 569 570 // Main function 571 // Typical run with production configuration should require no parameters 572 // It expects: 573 // "labels" file in "/etc/config/labels.yaml" 574 // github OAuth2 token in "/etc/github/oauth", this token must have write access to all org's repos 575 // default org is "kubernetes" 576 // It uses request retrying (in case of run out of GH API points) 577 // It took about 10 minutes to process all my 8 repos with all wanted "kubernetes" labels (70+) 578 // Next run takes about 22 seconds to check if all labels are correct on all repos 579 func main() { 580 flag.Parse() 581 if *debug { 582 logrus.SetLevel(logrus.DebugLevel) 583 } 584 585 config, err := LoadConfig(*labelsPath) 586 if err != nil { 587 logrus.WithError(err).Fatalf("failed to load --config=%s", *labelsPath) 588 } 589 590 switch { 591 case *action == "docs": 592 if err := writeDocs(*docsTemplate, *docsOutput, *config); err != nil { 593 logrus.WithError(err).Fatalf("failed to write docs using docs-template %s to docs-output %s", *docsTemplate, *docsOutput) 594 } 595 case *action == "sync": 596 githubClient, err := newClient(*token, *tokens, *tokenBurst, !*confirm, endpoint.Strings()...) 597 if err != nil { 598 logrus.WithError(err).Fatal("failed to create client") 599 } 600 601 var filt filter 602 switch { 603 case *onlyRepos != "": 604 if *skipRepos != "" { 605 logrus.Fatalf("--only and --skip cannot both be set") 606 } 607 only := make(map[string]bool) 608 for _, r := range strings.Split(*onlyRepos, ",") { 609 only[strings.TrimSpace(r)] = true 610 } 611 filt = func(org, repo string) bool { 612 _, ok := only[org+"/"+repo] 613 return ok 614 } 615 case *skipRepos != "": 616 skip := make(map[string]bool) 617 for _, r := range strings.Split(*skipRepos, ",") { 618 skip[strings.TrimSpace(r)] = true 619 } 620 filt = func(org, repo string) bool { 621 _, ok := skip[org+"/"+repo] 622 return !ok 623 } 624 default: 625 filt = func(o, r string) bool { 626 return true 627 } 628 } 629 630 for _, org := range strings.Split(*orgs, ",") { 631 org = strings.TrimSpace(org) 632 633 if err = syncOrg(org, githubClient, *config, filt); err != nil { 634 logrus.WithError(err).Fatalf("failed to update %s", org) 635 } 636 } 637 default: 638 logrus.Fatalf("unrecognized action: %s", *action) 639 } 640 } 641 642 type filter func(string, string) bool 643 644 type labelData struct { 645 Description, Link, Labels interface{} 646 } 647 648 func writeDocs(template string, output string, config Configuration) error { 649 data := []labelData{ 650 {"both issues and PRs", "both-issues-and-prs", config.LabelsByTarget(bothTarget)}, 651 {"only issues", "only-issues", config.LabelsByTarget(issueTarget)}, 652 {"only PRs", "only-prs", config.LabelsByTarget(prTarget)}, 653 } 654 if err := writeTemplate(*docsTemplate, *docsOutput, data); err != nil { 655 return err 656 } 657 return nil 658 } 659 660 func syncOrg(org string, githubClient client, config Configuration, filt filter) error { 661 logrus.WithField("org", org).Info("Reading repos") 662 repos, err := loadRepos(org, githubClient, filt) 663 if err != nil { 664 return err 665 } 666 667 logrus.WithField("org", org).Infof("Found %d repos", len(repos)) 668 currLabels, err := loadLabels(githubClient, org, repos) 669 if err != nil { 670 return err 671 } 672 673 logrus.WithField("org", org).Infof("Syncing labels for %d repos", len(repos)) 674 updates, err := syncLabels(config, *currLabels) 675 if err != nil { 676 return err 677 } 678 679 y, _ := yaml.Marshal(updates) 680 logrus.Debug(string(y)) 681 682 if !*confirm { 683 logrus.Infof("Running without --confirm, no mutations made") 684 return nil 685 } 686 687 if err = updates.DoUpdates(org, githubClient); err != nil { 688 return err 689 } 690 return nil 691 }