github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/cmd/generic-autobumper/main.go (about)

     1  /*
     2  Copyright 2019 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package main
    18  
    19  import (
    20  	"context"
    21  	"encoding/json"
    22  	"errors"
    23  	"fmt"
    24  	"io"
    25  	"net/http"
    26  	"os"
    27  	"path/filepath"
    28  	"regexp"
    29  	"sort"
    30  	"strings"
    31  
    32  	flag "github.com/spf13/pflag"
    33  	"golang.org/x/oauth2/google"
    34  
    35  	"github.com/sirupsen/logrus"
    36  
    37  	"k8s.io/apimachinery/pkg/util/sets"
    38  	"sigs.k8s.io/prow/cmd/generic-autobumper/bumper"
    39  	"sigs.k8s.io/prow/cmd/generic-autobumper/imagebumper"
    40  
    41  	"sigs.k8s.io/yaml"
    42  )
    43  
    44  const (
    45  	latestVersion           = "latest"
    46  	upstreamVersion         = "upstream"
    47  	upstreamStagingVersion  = "upstream-staging"
    48  	tagVersion              = "vYYYYMMDD-deadbeef"
    49  	defaultUpstreamURLBase  = "https://raw.githubusercontent.com/kubernetes/test-infra/master"
    50  	googleImageRegistryAuth = "google"
    51  	cloudPlatformScope      = "https://www.googleapis.com/auth/cloud-platform"
    52  
    53  	defaultOncallGroup = "testinfra"
    54  	errOncallMsgTempl  = "An error occurred while finding an assignee: `%s`.\nFalling back to Blunderbuss."
    55  	noOncallMsg        = "Nobody is currently oncall, so falling back to Blunderbuss."
    56  )
    57  
    58  var (
    59  	tagRegexp    = regexp.MustCompile("v[0-9]{8}-[a-f0-9]{6,9}")
    60  	imageMatcher = regexp.MustCompile(`(?s)^.+image:(.+):(v[a-zA-Z0-9_.-]+)`)
    61  )
    62  
    63  var _ bumper.PRHandler = (*client)(nil)
    64  
    65  type client struct {
    66  	o        *options
    67  	images   map[string]string
    68  	versions map[string][]string
    69  }
    70  
    71  // Changes returns a slice of functions, each one does some stuff, and
    72  // returns commit message for the changes
    73  func (c *client) Changes() []func(context.Context) (string, error) {
    74  	return []func(context.Context) (string, error){
    75  		func(ctx context.Context) (string, error) {
    76  			var err error
    77  			if c.images, err = updateReferencesWrapper(ctx, c.o); err != nil {
    78  				return "", fmt.Errorf("failed to update image references: %w", err)
    79  			}
    80  
    81  			if c.versions, err = getVersionsAndCheckConsistency(c.o.Prefixes, c.images); err != nil {
    82  				return "", err
    83  			}
    84  
    85  			var body string
    86  			var prefixNames []string
    87  			for _, prefix := range c.o.Prefixes {
    88  				prefixNames = append(prefixNames, prefix.Name)
    89  				body = body + generateSummary(prefix.Name, prefix.Repo, prefix.Prefix, prefix.Summarise, c.images) + "\n\n"
    90  			}
    91  
    92  			return fmt.Sprintf("Bumping %s\n\n%s", strings.Join(prefixNames, " and "), body), nil
    93  		},
    94  	}
    95  }
    96  
    97  // PRTitleBody returns the body of the PR, this function runs after each commit
    98  func (c *client) PRTitleBody() (string, string) {
    99  	body := generatePRBody(c.images, c.o.Prefixes) +
   100  		getAssignment(c.o.OncallAddress, c.o.OncallGroup, c.o.SkipOncallAssignment, c.o.SelfAssign) + "\n"
   101  	if c.o.AdditionalPRBody != "" {
   102  		body += c.o.AdditionalPRBody + "\n"
   103  	}
   104  	return makeCommitSummary(c.o.Prefixes, c.versions), body
   105  }
   106  
   107  func generatePRBody(images map[string]string, prefixes []prefix) (body string) {
   108  	body = ""
   109  	for _, prefix := range prefixes {
   110  		body = body + generateSummary(prefix.Name, prefix.Repo, prefix.Prefix, prefix.Summarise, images) + "\n\n"
   111  	}
   112  	return body + "\n"
   113  }
   114  
   115  // options is the options for autobumper operations.
   116  type options struct {
   117  	// The URL where upstream image references are located. Only required if Target Version is "upstream" or "upstreamStaging". Use "https://raw.githubusercontent.com/{ORG}/{REPO}"
   118  	// Images will be bumped based off images located at the address using this URL and the refConfigFile or stagingRefConigFile for each Prefix.
   119  	UpstreamURLBase string `yaml:"upstreamURLBase"`
   120  	// The config paths to be included in this bump, in which only .yaml files will be considered. By default all files are included.
   121  	IncludedConfigPaths []string `yaml:"includedConfigPaths"`
   122  	// The config paths to be excluded in this bump, in which only .yaml files will be considered.
   123  	ExcludedConfigPaths []string `yaml:"excludedConfigPaths"`
   124  	// The extra non-yaml file to be considered in this bump.
   125  	ExtraFiles []string `yaml:"extraFiles"`
   126  	// The target version to bump images version to, which can be one of latest, upstream, upstream-staging and vYYYYMMDD-deadbeef.
   127  	TargetVersion string `yaml:"targetVersion"`
   128  	// List of prefixes that the autobumped is looking for, and other information needed to bump them. Must have at least 1 prefix.
   129  	Prefixes []prefix `yaml:"prefixes"`
   130  	// The oncall address where we can get the JSON file that stores the current oncall information.
   131  	OncallAddress string `json:"onCallAddress"`
   132  	// The oncall group that is responsible for reviewing the change, i.e. "test-infra".
   133  	OncallGroup string `json:"onCallGroup"`
   134  	// Whether skip if no oncall is discovered
   135  	SkipIfNoOncall bool `yaml:"skipIfNoOncall"`
   136  	// SkipOncallAssignment skips assigning to oncall.
   137  	// The OncallAddress and OncallGroup are required for auto-bumper to figure out whether there are active oncall,
   138  	// which is used to avoid bumping when there is no active oncall.
   139  	SkipOncallAssignment bool `yaml:"skipOncallAssignment"`
   140  	// SelfAssign is used to comment `/assign` and `/cc` so that blunderbuss wouldn't assign
   141  	// bump PR to someone else.
   142  	SelfAssign bool `yaml:"selfAssign"`
   143  	// ImageRegistryAuth determines a way the autobumper with authenticate when talking to image registry.
   144  	// Allowed values:
   145  	// * "" (empty) -- uses no auth token
   146  	// * "google" -- uses Google's "Application Default Credentials" as defined on https://pkg.go.dev/golang.org/x/oauth2/google#hdr-Credentials.
   147  	ImageRegistryAuth string `yaml:"imageRegistryAuth"`
   148  	// AdditionalPRBody allows for generic, additional content in the body of the PR
   149  	AdditionalPRBody string `yaml:"additionalPRBody"`
   150  }
   151  
   152  // prefix is the information needed for each prefix being bumped.
   153  type prefix struct {
   154  	// Name of the tool being bumped
   155  	Name string `yaml:"name"`
   156  	// The image prefix that the autobumper should look for
   157  	Prefix string `yaml:"prefix"`
   158  	// File that is looked at to determine current upstream image when bumping to upstream. Required only if targetVersion is "upstream"
   159  	RefConfigFile string `yaml:"refConfigFile"`
   160  	// File that is looked at to determine current upstream staging image when bumping to upstream staging. Required only if targetVersion is "upstream-staging"
   161  	StagingRefConfigFile string `yaml:"stagingRefConfigFile"`
   162  	// The repo where the image source resides for the images with this prefix. Used to create the links to see comparisons between images in the PR summary.
   163  	Repo string `yaml:"repo"`
   164  	// Whether or not the format of the PR summary for this prefix should be summarised.
   165  	Summarise bool `yaml:"summarise"`
   166  	// Whether the prefix tags should be consistent after the bump
   167  	ConsistentImages bool `yaml:"consistentImages"`
   168  	// A list of images whose tags are not required to be consistent after the bump. Requires `consistentImages: true`.
   169  	ConsistentImageExceptions []string `yaml:"consistentImageExceptions"`
   170  }
   171  
   172  func parseOptions() (*options, *bumper.Options, error) {
   173  	var config string
   174  	var labelsOverride []string
   175  	var skipPullRequest bool
   176  	var signoff bool
   177  
   178  	var o options
   179  	flag.StringVar(&config, "config", "", "The path to the config file for the autobumber.")
   180  	flag.StringSliceVar(&labelsOverride, "labels-override", nil, "Override labels to be added to PR.")
   181  	flag.BoolVar(&skipPullRequest, "skip-pullrequest", false, "")
   182  	flag.BoolVar(&signoff, "signoff", false, "Signoff the commits.")
   183  	flag.BoolVar(&o.SkipIfNoOncall, "skip-if-no-oncall", false, "Don't run anything if no oncall is discovered")
   184  	flag.Parse()
   185  
   186  	var pro bumper.Options
   187  	data, err := os.ReadFile(config)
   188  	if err != nil {
   189  		return nil, nil, fmt.Errorf("read %q: %w", config, err)
   190  	}
   191  
   192  	if err = yaml.Unmarshal(data, &o); err != nil {
   193  		return nil, nil, fmt.Errorf("unmarshal %q: %w", config, err)
   194  	}
   195  
   196  	if err := yaml.Unmarshal(data, &pro); err != nil {
   197  		return nil, nil, fmt.Errorf("unmarshal %q: %w", config, err)
   198  	}
   199  
   200  	if labelsOverride != nil {
   201  		pro.Labels = labelsOverride
   202  	}
   203  	if o.OncallGroup == "" {
   204  		o.OncallGroup = defaultOncallGroup
   205  	}
   206  	pro.SkipPullRequest = skipPullRequest
   207  	pro.Signoff = signoff
   208  	return &o, &pro, nil
   209  }
   210  
   211  func validateOptions(o *options) error {
   212  	if len(o.Prefixes) == 0 {
   213  		return errors.New("must have at least one Prefix specified")
   214  	}
   215  	for _, prefix := range o.Prefixes {
   216  		if len(prefix.ConsistentImageExceptions) > 0 && !prefix.ConsistentImages {
   217  			return fmt.Errorf("consistentImageExceptions requires consistentImages to be true, found in prefix %q", prefix.Name)
   218  		}
   219  	}
   220  	if len(o.IncludedConfigPaths) == 0 {
   221  		return errors.New("includedConfigPaths is mandatory")
   222  	}
   223  	if o.TargetVersion != latestVersion && o.TargetVersion != upstreamVersion &&
   224  		o.TargetVersion != upstreamStagingVersion && !tagRegexp.MatchString(o.TargetVersion) {
   225  		logrus.WithField("allowed", []string{latestVersion, upstreamVersion, upstreamStagingVersion, tagVersion}).Warn(
   226  			"Warning: targetVersion mot in allowed so it might not work properly.")
   227  	}
   228  	if o.TargetVersion == upstreamVersion {
   229  		for _, prefix := range o.Prefixes {
   230  			if prefix.RefConfigFile == "" {
   231  				return fmt.Errorf("targetVersion can't be %q without refConfigFile for each prefix. %q is missing one", upstreamVersion, prefix.Name)
   232  			}
   233  		}
   234  	}
   235  	if o.TargetVersion == upstreamStagingVersion {
   236  		for _, prefix := range o.Prefixes {
   237  			if prefix.StagingRefConfigFile == "" {
   238  				return fmt.Errorf("targetVersion can't be %q without stagingRefConfigFile for each prefix. %q is missing one", upstreamStagingVersion, prefix.Name)
   239  			}
   240  		}
   241  	}
   242  	if (o.TargetVersion == upstreamVersion || o.TargetVersion == upstreamStagingVersion) && o.UpstreamURLBase == "" {
   243  		o.UpstreamURLBase = defaultUpstreamURLBase
   244  		logrus.Warnf("targetVersion can't be 'upstream' or 'upstreamStaging` without upstreamURLBase set. Default upstreamURLBase is %q", defaultUpstreamURLBase)
   245  	}
   246  
   247  	if o.ImageRegistryAuth != "" && o.ImageRegistryAuth != googleImageRegistryAuth {
   248  		return fmt.Errorf("imageRegistryAuth has incorrect value: %q. Only \"\" and %q are allowed", o.ImageRegistryAuth, googleImageRegistryAuth)
   249  	}
   250  
   251  	return nil
   252  }
   253  
   254  func isOncallActive(oncallAddress, oncallGroup string) bool {
   255  	_, oncallActive, _ := getOncallInfo(oncallAddress, oncallGroup)
   256  	return oncallActive
   257  }
   258  
   259  func getAssignment(oncallAddress, oncallGroup string, skipOncallAssignment, selfAssign bool) string {
   260  	// No reason to self assign if wants to assign to oncall
   261  	if selfAssign {
   262  		return "/cc"
   263  	}
   264  	if skipOncallAssignment {
   265  		return ""
   266  	}
   267  	// Processing oncall info now
   268  	curtOncall, _, err := getOncallInfo(oncallAddress, oncallGroup)
   269  	if err != nil {
   270  		return fmt.Sprintf(errOncallMsgTempl, err.Error())
   271  	}
   272  	if curtOncall == "" {
   273  		return noOncallMsg
   274  	}
   275  	return curtOncall
   276  }
   277  
   278  func getOncallInfo(oncallAddress, oncallGroup string) (string, bool, error) {
   279  	if oncallAddress == "" {
   280  		return "", false, nil
   281  	}
   282  
   283  	req, err := http.Get(oncallAddress)
   284  	if err != nil {
   285  		return "", false, err
   286  	}
   287  	defer req.Body.Close()
   288  	if req.StatusCode != http.StatusOK {
   289  		return "", false, fmt.Errorf("requesting oncall address: HTTP error %d: %q", req.StatusCode, req.Status)
   290  	}
   291  	oncall := struct {
   292  		Oncall map[string]string `json:"Oncall"`
   293  		Active map[string]bool   `json:"Active"`
   294  	}{}
   295  	if err := json.NewDecoder(req.Body).Decode(&oncall); err != nil {
   296  		return "", false, err
   297  	}
   298  	curtOncall, ok := oncall.Oncall[oncallGroup]
   299  	if !ok {
   300  		return "", false, fmt.Errorf("oncall map doesn't contain group '%s'", oncallGroup)
   301  	}
   302  	oncallActive, ok := oncall.Active[oncallGroup]
   303  	if !ok {
   304  		return "", false, fmt.Errorf("oncall map doesn't contain group '%s'", oncallGroup)
   305  	}
   306  	if curtOncall != "" {
   307  		return "/cc @" + curtOncall, oncallActive, nil
   308  	}
   309  	return "", false, nil
   310  }
   311  
   312  // updateReferencesWrapper update the references of prow-images and/or boskos-images and/or testimages
   313  // in the files in any of "subfolders" of the includeConfigPaths but not in excludeConfigPaths
   314  // if the file is a yaml file (*.yaml) or extraFiles[file]=true
   315  func updateReferencesWrapper(ctx context.Context, o *options) (map[string]string, error) {
   316  	logrus.Info("Bumping image references...")
   317  	var allPrefixes []string
   318  	for _, prefix := range o.Prefixes {
   319  		allPrefixes = append(allPrefixes, prefix.Prefix)
   320  	}
   321  	filterRegexp, err := regexp.Compile(strings.Join(allPrefixes, "|"))
   322  	if err != nil {
   323  		return nil, fmt.Errorf("bad regexp %q: %w", strings.Join(allPrefixes, "|"), err)
   324  	}
   325  	var client *http.Client = http.DefaultClient
   326  	if o.ImageRegistryAuth == googleImageRegistryAuth {
   327  		var err error
   328  		client, err = google.DefaultClient(ctx, cloudPlatformScope)
   329  		if err != nil {
   330  			return nil, fmt.Errorf("failed to create authed client: %v", err)
   331  		}
   332  	}
   333  	imageBumperCli := imagebumper.NewClient(client)
   334  	return updateReferences(imageBumperCli, filterRegexp, o)
   335  }
   336  
   337  type imageBumper interface {
   338  	FindLatestTag(imageHost, imageName, currentTag string) (string, error)
   339  	UpdateFile(tagPicker func(imageHost, imageName, currentTag string) (string, error), path string, imageFilter *regexp.Regexp) error
   340  	GetReplacements() map[string]string
   341  	AddToCache(image, newTag string)
   342  	TagExists(imageHost, imageName, currentTag string) (bool, error)
   343  }
   344  
   345  func updateReferences(imageBumperCli imageBumper, filterRegexp *regexp.Regexp, o *options) (map[string]string, error) {
   346  	var tagPicker func(string, string, string) (string, error)
   347  
   348  	switch o.TargetVersion {
   349  	case latestVersion:
   350  		tagPicker = imageBumperCli.FindLatestTag
   351  	case upstreamVersion, upstreamStagingVersion:
   352  		var err error
   353  		if tagPicker, err = upstreamImageVersionResolver(o, o.TargetVersion, parseUpstreamImageVersion, imageBumperCli); err != nil {
   354  			return nil, fmt.Errorf("failed to resolve the %s image version: %w", o.TargetVersion, err)
   355  		}
   356  	default:
   357  		tagPicker = func(imageHost, imageName, currentTag string) (string, error) { return o.TargetVersion, nil }
   358  	}
   359  
   360  	updateFile := func(name string) error {
   361  		logrus.WithField("file", name).Info("Updating file")
   362  		if err := imageBumperCli.UpdateFile(tagPicker, name, filterRegexp); err != nil {
   363  			return fmt.Errorf("failed to update the file: %w", err)
   364  		}
   365  		return nil
   366  	}
   367  	updateYAMLFile := func(name string) error {
   368  		if strings.HasSuffix(name, ".yaml") && !isUnderPath(name, o.ExcludedConfigPaths) {
   369  			return updateFile(name)
   370  		}
   371  		return nil
   372  	}
   373  
   374  	// Updated all .yaml files under the included config paths but not under excluded config paths.
   375  	for _, path := range o.IncludedConfigPaths {
   376  		info, err := os.Stat(path)
   377  		if err != nil {
   378  			return nil, fmt.Errorf("failed to get the file info for %q: %w", path, err)
   379  		}
   380  		if info.IsDir() {
   381  			err := filepath.Walk(path, func(subpath string, info os.FileInfo, err error) error {
   382  				return updateYAMLFile(subpath)
   383  			})
   384  			if err != nil {
   385  				return nil, fmt.Errorf("failed to update yaml files under %q: %w", path, err)
   386  			}
   387  		} else {
   388  			if err := updateYAMLFile(path); err != nil {
   389  				return nil, fmt.Errorf("failed to update the yaml file %q: %w", path, err)
   390  			}
   391  		}
   392  	}
   393  
   394  	// Update the extra files in any case.
   395  	for _, file := range o.ExtraFiles {
   396  		if err := updateFile(file); err != nil {
   397  			return nil, fmt.Errorf("failed to update the extra file %q: %w", file, err)
   398  		}
   399  	}
   400  
   401  	return imageBumperCli.GetReplacements(), nil
   402  }
   403  
   404  // used by updateReferences
   405  func upstreamImageVersionResolver(
   406  	o *options, upstreamVersionType string, parse func(upstreamAddress, prefix string) (string, error), imageBumperCli imageBumper) (func(imageHost, imageName, currentTag string) (string, error), error) {
   407  	upstreamVersions, err := upstreamConfigVersions(upstreamVersionType, o, parse)
   408  	if err != nil {
   409  		return nil, err
   410  	}
   411  
   412  	return func(imageHost, imageName, currentTag string) (string, error) {
   413  		imageFullPath := imageHost + "/" + imageName + ":" + currentTag
   414  		for prefix, version := range upstreamVersions {
   415  			if !strings.HasPrefix(imageFullPath, prefix) {
   416  				continue
   417  			}
   418  			if exists, err := imageBumperCli.TagExists(imageHost, imageName, version); err != nil {
   419  				return "", err
   420  			} else if exists {
   421  				imageBumperCli.AddToCache(imageFullPath, version)
   422  				return version, nil
   423  			}
   424  			imageBumperCli.AddToCache(imageFullPath, currentTag)
   425  			return "", fmt.Errorf("Unable to bump to %s, image tag %s does not exist for %s", imageFullPath, version, imageName)
   426  		}
   427  		return currentTag, nil
   428  	}, nil
   429  }
   430  
   431  // used by upstreamImageVersionResolver
   432  func upstreamConfigVersions(upstreamVersionType string, o *options, parse func(upstreamAddress, prefix string) (string, error)) (versions map[string]string, err error) {
   433  	versions = make(map[string]string)
   434  	var upstreamAddress string
   435  	for _, prefix := range o.Prefixes {
   436  		if upstreamVersionType == upstreamVersion {
   437  			upstreamAddress = o.UpstreamURLBase + "/" + prefix.RefConfigFile
   438  		} else if upstreamVersionType == upstreamStagingVersion {
   439  			upstreamAddress = o.UpstreamURLBase + "/" + prefix.StagingRefConfigFile
   440  		} else {
   441  			return nil, fmt.Errorf("unsupported upstream version type: %s, must be one of %v",
   442  				upstreamVersionType, []string{upstreamVersion, upstreamStagingVersion})
   443  		}
   444  		version, err := parse(upstreamAddress, prefix.Prefix)
   445  		if err != nil {
   446  			return nil, err
   447  		}
   448  		versions[prefix.Prefix] = version
   449  	}
   450  
   451  	return versions, nil
   452  }
   453  
   454  // used by updateReferences
   455  func parseUpstreamImageVersion(upstreamAddress, prefix string) (string, error) {
   456  	resp, err := http.Get(upstreamAddress)
   457  	if err != nil {
   458  		return "", fmt.Errorf("error sending GET request to %q: %w", upstreamAddress, err)
   459  	}
   460  	defer resp.Body.Close()
   461  	if resp.StatusCode != http.StatusOK {
   462  		return "", fmt.Errorf("HTTP error %d (%q) fetching upstream config file", resp.StatusCode, resp.Status)
   463  	}
   464  	body, err := io.ReadAll(resp.Body)
   465  	if err != nil {
   466  		return "", fmt.Errorf("error reading the response body: %w", err)
   467  	}
   468  	for _, line := range strings.Split(strings.TrimSuffix(string(body), "\n"), "\n") {
   469  		res := imageMatcher.FindStringSubmatch(string(line))
   470  		if len(res) > 2 && strings.Contains(res[1], prefix) {
   471  			return res[2], nil
   472  		}
   473  	}
   474  	return "", fmt.Errorf("unable to find match for %s in upstream refConfigFile", prefix)
   475  }
   476  
   477  // getVersionsAndCheckConisistency takes a list of Prefixes and a map of
   478  // all the images found in the code before the bump : their versions after the bump
   479  // For example {"gcr.io/k8s-prow/test1:tag": "newtag", "gcr.io/k8s-prow/test2:tag": "newtag"},
   480  // and returns a map of new versions resulted from bumping : the images using those versions.
   481  // It will error if one of the Prefixes was bumped inconsistently when it was not supposed to
   482  func getVersionsAndCheckConsistency(prefixes []prefix, images map[string]string) (map[string][]string, error) {
   483  	// Key is tag, value is full image.
   484  	versions := map[string][]string{}
   485  	for _, prefix := range prefixes {
   486  		exceptions := sets.NewString(prefix.ConsistentImageExceptions...)
   487  		var consistencyVersion, consistencySourceImage string
   488  		for k, v := range images {
   489  			if strings.HasPrefix(k, prefix.Prefix) {
   490  				image := imageFromName(k)
   491  				if prefix.ConsistentImages && !exceptions.Has(image) {
   492  					if consistencySourceImage != "" && (consistencyVersion != v) {
   493  						return nil, fmt.Errorf("%s -> %s not bumped consistently for prefix %s (%s), expected version %s based on bump of %s", k, v, prefix.Prefix, prefix.Name, consistencyVersion, consistencySourceImage)
   494  					}
   495  					if consistencySourceImage == "" {
   496  						consistencyVersion = v
   497  						consistencySourceImage = k
   498  					}
   499  				}
   500  
   501  				//Only add bumped images to the new versions map
   502  				if !strings.Contains(k, v) {
   503  					versions[v] = append(versions[v], k)
   504  				}
   505  
   506  			}
   507  		}
   508  	}
   509  	return versions, nil
   510  }
   511  
   512  // makeCommitSummary takes a list of Prefixes and a map of new tags resulted
   513  // from bumping : the images using those tags and returns a summary of what was
   514  // bumped for use in the commit message
   515  func makeCommitSummary(prefixes []prefix, versions map[string][]string) string {
   516  	var allPrefixes []string
   517  	for _, prefix := range prefixes {
   518  		allPrefixes = append(allPrefixes, prefix.Name)
   519  	}
   520  	if len(versions) == 0 {
   521  		return fmt.Sprintf("Update %s images as necessary", strings.Join(allPrefixes, ", "))
   522  	}
   523  	var inconsistentBumps []string
   524  	var consistentBumps []string
   525  	for _, prefix := range prefixes {
   526  		tag, bumped := isBumpedPrefix(prefix, versions)
   527  		if !prefix.ConsistentImages && bumped {
   528  			inconsistentBumps = append(inconsistentBumps, prefix.Name)
   529  		} else if prefix.ConsistentImages && bumped {
   530  			consistentBumps = append(consistentBumps, fmt.Sprintf("%s to %s", prefix.Name, tag))
   531  		}
   532  	}
   533  	var msgs []string
   534  	if len(consistentBumps) != 0 {
   535  		msgs = append(msgs, strings.Join(consistentBumps, ", "))
   536  	}
   537  	if len(inconsistentBumps) != 0 {
   538  		msgs = append(msgs, fmt.Sprintf("%s as needed", strings.Join(inconsistentBumps, ", ")))
   539  	}
   540  	return fmt.Sprintf("Update %s", strings.Join(msgs, " and "))
   541  
   542  }
   543  
   544  // Generate PR summary for github
   545  func generateSummary(name, repo, prefix string, summarise bool, images map[string]string) string {
   546  	type delta struct {
   547  		oldCommit string
   548  		newCommit string
   549  		oldDate   string
   550  		newDate   string
   551  		variant   string
   552  		component string
   553  	}
   554  	versions := map[string][]delta{}
   555  	for image, newTag := range images {
   556  		if !strings.HasPrefix(image, prefix) {
   557  			continue
   558  		}
   559  		if strings.HasSuffix(image, ":"+newTag) {
   560  			continue
   561  		}
   562  		oldDate, oldCommit, oldVariant := imagebumper.DeconstructTag(tagFromName(image))
   563  		newDate, newCommit, _ := imagebumper.DeconstructTag(newTag)
   564  		oldCommit = commitToRef(oldCommit)
   565  		newCommit = commitToRef(newCommit)
   566  		k := oldCommit + ":" + newCommit
   567  		d := delta{
   568  			oldCommit: oldCommit,
   569  			newCommit: newCommit,
   570  			oldDate:   oldDate,
   571  			newDate:   newDate,
   572  			variant:   formatVariant(oldVariant),
   573  			component: componentFromName(image),
   574  		}
   575  		versions[k] = append(versions[k], d)
   576  	}
   577  
   578  	switch {
   579  	case len(versions) == 0:
   580  		return fmt.Sprintf("No %s changes.", prefix)
   581  	case len(versions) == 1 && summarise:
   582  		for k, v := range versions {
   583  			s := strings.Split(k, ":")
   584  			return fmt.Sprintf("%s changes: %s/compare/%s...%s (%s → %s)", prefix, repo, s[0], s[1], formatTagDate(v[0].oldDate), formatTagDate(v[0].newDate))
   585  		}
   586  	default:
   587  		changes := make([]string, 0, len(versions))
   588  		for k, v := range versions {
   589  			s := strings.Split(k, ":")
   590  			names := make([]string, 0, len(v))
   591  			for _, d := range v {
   592  				names = append(names, d.component+d.variant)
   593  			}
   594  			sort.Strings(names)
   595  			changes = append(changes, fmt.Sprintf("%s/compare/%s...%s | %s → %s | %s",
   596  				repo, s[0], s[1], formatTagDate(v[0].oldDate), formatTagDate(v[0].newDate), strings.Join(names, ", ")))
   597  		}
   598  		sort.Slice(changes, func(i, j int) bool { return strings.Split(changes[i], "|")[1] < strings.Split(changes[j], "|")[1] })
   599  		return fmt.Sprintf("Multiple distinct %s changes:\n\nCommits | Dates | Images\n--- | --- | ---\n%s\n", prefix, strings.Join(changes, "\n"))
   600  	}
   601  	panic("unreachable!")
   602  }
   603  
   604  func main() {
   605  	ctx := context.Background()
   606  	logrus.SetLevel(logrus.DebugLevel)
   607  	o, pro, err := parseOptions()
   608  	if err != nil {
   609  		logrus.WithError(err).Fatalf("Failed to run the bumper tool")
   610  	}
   611  
   612  	if o.SkipIfNoOncall {
   613  		if !isOncallActive(o.OncallAddress, o.OncallGroup) {
   614  
   615  			logrus.Info("`skip-if-no-oncall` is configured and there is no active oncall. Skip bumping.")
   616  			return
   617  		}
   618  	}
   619  	if err := validateOptions(o); err != nil {
   620  		logrus.WithError(err).Fatalf("Failed validating flags")
   621  	}
   622  
   623  	if err := bumper.Run(ctx, pro, &client{o: o}); err != nil {
   624  		logrus.WithError(err).Fatalf("failed to run the bumper tool")
   625  	}
   626  }