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  }