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  }