github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/cmd/generic-autobumper/bumper/bumper.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 bumper
    18  
    19  import (
    20  	"bytes"
    21  	"context"
    22  	"crypto/sha1"
    23  	"errors"
    24  	"flag"
    25  	"fmt"
    26  	"io"
    27  	"os"
    28  	"os/exec"
    29  	"strings"
    30  
    31  	"github.com/sirupsen/logrus"
    32  
    33  	"sigs.k8s.io/prow/cmd/generic-autobumper/updater"
    34  	"sigs.k8s.io/prow/pkg/config/secret"
    35  	"sigs.k8s.io/prow/pkg/github"
    36  )
    37  
    38  const (
    39  	forkRemoteName = "bumper-fork-remote"
    40  
    41  	defaultHeadBranchName = "autobump"
    42  
    43  	gitCmd = "git"
    44  )
    45  
    46  // Options is the options for autobumper operations.
    47  type Options struct {
    48  	// The target GitHub org name where the autobump PR will be created. Only required when SkipPullRequest is false.
    49  	GitHubOrg string `json:"gitHubOrg"`
    50  	// The target GitHub repo name where the autobump PR will be created. Only required when SkipPullRequest is false.
    51  	GitHubRepo string `json:"gitHubRepo"`
    52  	// The name of the branch in the target GitHub repo on which the autobump PR will be based.  If not specified, will be autodetected via GitHub API.
    53  	GitHubBaseBranch string `json:"gitHubBaseBranch"`
    54  	// The GitHub username to use. If not specified, uses values from the user associated with the access token.
    55  	GitHubLogin string `json:"gitHubLogin"`
    56  	// The path to the GitHub token file. Only required when SkipPullRequest is false.
    57  	GitHubToken string `json:"gitHubToken"`
    58  	// The name to use on the git commit. Only required when GitEmail is specified and SkipPullRequest is false. If not specified, uses values from the user associated with the access token
    59  	GitName string `json:"gitName"`
    60  	// The email to use on the git commit. Only required when GitName is specified and SkipPullRequest is false. If not specified, uses values from the user associated with the access token.
    61  	GitEmail string `json:"gitEmail"`
    62  	// AssignTo specifies who to assign the created PR to. Takes precedence over onCallAddress and onCallGroup if set.
    63  	AssignTo string `json:"assign_to"`
    64  	// Whether to skip creating the pull request for this bump.
    65  	SkipPullRequest bool `json:"skipPullRequest"`
    66  	// Whether to signoff the commits.
    67  	Signoff bool `json:"signoff"`
    68  	// Information needed to do a gerrit bump. Do not include if doing github bump
    69  	Gerrit *Gerrit `json:"gerrit"`
    70  	// The name used in the address when creating remote. This should be the same name as the fork. If fork does not exist this will be the name of the fork that is created.
    71  	// If it is not the same as the fork, the robot will change the name of the fork to this. Format will be git@github.com:{GitLogin}/{RemoteName}.git
    72  	RemoteName string `json:"remoteName"`
    73  	// The name of the branch that will be used when creating the pull request. If unset, defaults to "autobump".
    74  	HeadBranchName string `json:"headBranchName"`
    75  	// Optional list of labels to add to the bump PR
    76  	Labels []string `json:"labels"`
    77  }
    78  
    79  // Information needed for gerrit bump
    80  type Gerrit struct {
    81  	// Unique tag in commit messages to identify a Gerrit bump CR. Required if using gerrit
    82  	AutobumpPRIdentifier string `json:"autobumpPRIdentifier"`
    83  	// Gerrit CR Author. Only Required if using gerrit
    84  	Author string `json:"author"`
    85  	// Email account associated with gerrit author. Only required if using gerrit.
    86  	Email string `json:"email"`
    87  	// The path to the Gerrit httpcookie file. Only Required if using gerrit
    88  	CookieFile string `json:"cookieFile"`
    89  	// The path to the hosted Gerrit repo
    90  	HostRepo string `json:"hostRepo"`
    91  }
    92  
    93  // PRHandler is the interface implemented by consumer of prcreator, for
    94  // manipulating the repo, and provides commit messages, PR title and body.
    95  type PRHandler interface {
    96  	// Changes returns a slice of functions, each one does some stuff, and
    97  	// returns commit message for the changes
    98  	Changes() []func(context.Context) (string, error)
    99  	// PRTitleBody returns the body of the PR, this function runs after all
   100  	// changes have been executed
   101  	PRTitleBody() (string, string)
   102  }
   103  
   104  // GitAuthorOptions is specifically to read the author info for a commit
   105  type GitAuthorOptions struct {
   106  	GitName  string
   107  	GitEmail string
   108  }
   109  
   110  // AddFlags will read the author info from the command line parameters
   111  func (o *GitAuthorOptions) AddFlags(fs *flag.FlagSet) {
   112  	fs.StringVar(&o.GitName, "git-name", "", "The name to use on the git commit.")
   113  	fs.StringVar(&o.GitEmail, "git-email", "", "The email to use on the git commit.")
   114  }
   115  
   116  // Validate will validate the input GitAuthorOptions
   117  func (o *GitAuthorOptions) Validate() error {
   118  	if (o.GitEmail == "") != (o.GitName == "") {
   119  		return fmt.Errorf("--git-name and --git-email must be specified together")
   120  	}
   121  	return nil
   122  }
   123  
   124  // GitCommand is used to pass the various components of the git command which needs to be executed
   125  type GitCommand struct {
   126  	baseCommand string
   127  	args        []string
   128  	workingDir  string
   129  }
   130  
   131  // Call will execute the Git command and switch the working directory if specified
   132  func (gc GitCommand) Call(stdout, stderr io.Writer, opts ...CallOption) error {
   133  	return Call(stdout, stderr, gc.baseCommand, gc.buildCommand(), opts...)
   134  }
   135  
   136  func (gc GitCommand) buildCommand() []string {
   137  	args := []string{}
   138  	if gc.workingDir != "" {
   139  		args = append(args, "-C", gc.workingDir)
   140  	}
   141  	args = append(args, gc.args...)
   142  	return args
   143  }
   144  
   145  func (gc GitCommand) getCommand() string {
   146  	return fmt.Sprintf("%s %s", gc.baseCommand, strings.Join(gc.buildCommand(), " "))
   147  }
   148  
   149  func validateOptions(o *Options) error {
   150  	if !o.SkipPullRequest && o.Gerrit == nil {
   151  		if o.GitHubToken == "" {
   152  			return fmt.Errorf("gitHubToken is mandatory when skipPullRequest is false or unspecified")
   153  		}
   154  		if (o.GitEmail == "") != (o.GitName == "") {
   155  			return fmt.Errorf("gitName and gitEmail must be specified together")
   156  		}
   157  		if o.GitHubOrg == "" || o.GitHubRepo == "" {
   158  			return fmt.Errorf("gitHubOrg and gitHubRepo are mandatory when skipPullRequest is false or unspecified")
   159  		}
   160  		if o.RemoteName == "" {
   161  			return fmt.Errorf("remoteName is mandatory when skipPullRequest is false or unspecified")
   162  		}
   163  	}
   164  	if !o.SkipPullRequest && o.Gerrit != nil {
   165  		if o.Gerrit.Author == "" {
   166  			return fmt.Errorf("GerritAuthor is required when skipPullRequest is false and Gerrit is true")
   167  		}
   168  		if o.Gerrit.AutobumpPRIdentifier == "" {
   169  			return fmt.Errorf("GerritCommitId is required when skipPullRequest is false and Gerrit is true")
   170  		}
   171  		if o.Gerrit.HostRepo == "" {
   172  			return fmt.Errorf("GerritHostRepo is required when skipPullRequest is false and Gerrit is true")
   173  		}
   174  		if o.Gerrit.CookieFile == "" {
   175  			return fmt.Errorf("GerritCookieFile is required when skipPullRequest is false and Gerrit is true")
   176  		}
   177  	}
   178  	if !o.SkipPullRequest {
   179  		if o.HeadBranchName == "" {
   180  			o.HeadBranchName = defaultHeadBranchName
   181  		}
   182  	}
   183  
   184  	return nil
   185  }
   186  
   187  // Run is the entrypoint which will update Prow config files based on the
   188  // provided options.
   189  //
   190  // updateFunc: a function that returns commit message and error
   191  func Run(ctx context.Context, o *Options, prh PRHandler) error {
   192  	if err := validateOptions(o); err != nil {
   193  		return fmt.Errorf("validating options: %w", err)
   194  	}
   195  
   196  	if o.SkipPullRequest {
   197  		logrus.Debugf("--skip-pull-request is set to true, won't create a pull request.")
   198  	}
   199  	if o.Gerrit == nil {
   200  		return processGitHub(ctx, o, prh)
   201  	}
   202  	return processGerrit(ctx, o, prh)
   203  }
   204  
   205  func processGitHub(ctx context.Context, o *Options, prh PRHandler) error {
   206  	stdout := HideSecretsWriter{Delegate: os.Stdout, Censor: secret.Censor}
   207  	stderr := HideSecretsWriter{Delegate: os.Stderr, Censor: secret.Censor}
   208  	if err := secret.Add(o.GitHubToken); err != nil {
   209  		return fmt.Errorf("start secrets agent: %w", err)
   210  	}
   211  
   212  	gc, err := github.NewClient(secret.GetTokenGenerator(o.GitHubToken), secret.Censor, github.DefaultGraphQLEndpoint, github.DefaultAPIEndpoint)
   213  	if err != nil {
   214  		return fmt.Errorf("failed to construct GitHub client: %v", err)
   215  	}
   216  
   217  	if o.GitHubLogin == "" || o.GitName == "" || o.GitEmail == "" {
   218  		user, err := gc.BotUser()
   219  		if err != nil {
   220  			return fmt.Errorf("get the user data for the provided GH token: %w", err)
   221  		}
   222  		if o.GitHubLogin == "" {
   223  			o.GitHubLogin = user.Login
   224  		}
   225  		if o.GitName == "" {
   226  			o.GitName = user.Name
   227  		}
   228  		if o.GitEmail == "" {
   229  			o.GitEmail = user.Email
   230  		}
   231  	}
   232  
   233  	// Make change, commit and push
   234  	var anyChange bool
   235  	for i, changeFunc := range prh.Changes() {
   236  		msg, err := changeFunc(ctx)
   237  		if err != nil {
   238  			return fmt.Errorf("process function %d: %w", i, err)
   239  		}
   240  
   241  		changed, err := HasChanges()
   242  		if err != nil {
   243  			return fmt.Errorf("checking changes: %w", err)
   244  		}
   245  
   246  		if !changed {
   247  			logrus.WithField("function", i).Info("Nothing changed, skip commit ...")
   248  			continue
   249  		}
   250  
   251  		anyChange = true
   252  		if err := gitCommit(o.GitName, o.GitEmail, msg, stdout, stderr, o.Signoff); err != nil {
   253  			return fmt.Errorf("git commit: %w", err)
   254  		}
   255  	}
   256  	if !anyChange {
   257  		logrus.Info("Nothing changed from all functions, skip PR ...")
   258  		return nil
   259  	}
   260  
   261  	if err := MinimalGitPush(fmt.Sprintf("https://%s:%s@github.com/%s/%s.git", o.GitHubLogin, string(secret.GetTokenGenerator(o.GitHubToken)()), o.GitHubLogin, o.RemoteName), o.HeadBranchName, stdout, stderr, o.SkipPullRequest); err != nil {
   262  		return fmt.Errorf("push changes to the remote branch: %w", err)
   263  	}
   264  
   265  	summary, body := prh.PRTitleBody()
   266  	if o.GitHubBaseBranch == "" {
   267  		repo, err := gc.GetRepo(o.GitHubOrg, o.GitHubRepo)
   268  		if err != nil {
   269  			return fmt.Errorf("detect default remote branch for %s/%s: %w", o.GitHubOrg, o.GitHubRepo, err)
   270  		}
   271  		o.GitHubBaseBranch = repo.DefaultBranch
   272  	}
   273  	if err := updatePRWithLabels(gc, o.GitHubOrg, o.GitHubRepo, getAssignment(o.AssignTo), o.GitHubLogin, o.GitHubBaseBranch, o.HeadBranchName, updater.PreventMods, summary, body, o.Labels, o.SkipPullRequest); err != nil {
   274  		return fmt.Errorf("to create the PR: %w", err)
   275  	}
   276  	return nil
   277  }
   278  
   279  func processGerrit(ctx context.Context, o *Options, prh PRHandler) error {
   280  	stdout := HideSecretsWriter{Delegate: os.Stdout, Censor: secret.Censor}
   281  	stderr := HideSecretsWriter{Delegate: os.Stderr, Censor: secret.Censor}
   282  
   283  	if err := Call(stdout, stderr, gitCmd, []string{"config", "http.cookiefile", o.Gerrit.CookieFile}); err != nil {
   284  		return fmt.Errorf("unable to load cookiefile: %w", err)
   285  	}
   286  	if err := Call(stdout, stderr, gitCmd, []string{"config", "user.name", o.Gerrit.Author}); err != nil {
   287  		return fmt.Errorf("unable to set username: %w", err)
   288  	}
   289  	if err := Call(stdout, stderr, gitCmd, []string{"config", "user.email", o.Gerrit.Email}); err != nil {
   290  		return fmt.Errorf("unable to set password: %w", err)
   291  	}
   292  	if err := Call(stdout, stderr, gitCmd, []string{"remote", "add", "upstream", o.Gerrit.HostRepo}); err != nil {
   293  		return fmt.Errorf("unable to add upstream remote: %w", err)
   294  	}
   295  	changeId, err := getChangeId(o.Gerrit.Author, o.Gerrit.AutobumpPRIdentifier, "")
   296  	if err != nil {
   297  		return fmt.Errorf("Failed to create CR: %w", err)
   298  	}
   299  
   300  	// Make change, commit and push
   301  	for i, changeFunc := range prh.Changes() {
   302  		msg, err := changeFunc(ctx)
   303  		if err != nil {
   304  			return fmt.Errorf("process function %d: %w", i, err)
   305  		}
   306  
   307  		changed, err := HasChanges()
   308  		if err != nil {
   309  			return fmt.Errorf("checking changes: %w", err)
   310  		}
   311  
   312  		if !changed {
   313  			logrus.WithField("function", i).Info("Nothing changed, skip commit ...")
   314  			continue
   315  		}
   316  
   317  		if err = gerritCommitandPush(msg, o.Gerrit.AutobumpPRIdentifier, changeId, nil, nil, stdout, stderr); err != nil {
   318  			// If push because a closed PR already exists with this
   319  			// change ID (the PR was abandoned). Hash the ID again and try one
   320  			// more time.
   321  			if !strings.Contains(err.Error(), "push some refs") || !strings.Contains(err.Error(), "closed") {
   322  				return err
   323  			}
   324  			logrus.Warn("Error pushing CR due to already used ChangeID. PR may have been abandoned. Trying again with new ChangeID.")
   325  			if changeId, err = getChangeId(o.Gerrit.Author, o.Gerrit.AutobumpPRIdentifier, changeId); err != nil {
   326  				return err
   327  			}
   328  			if err := Call(stdout, stderr, gitCmd, []string{"reset", "HEAD^"}); err != nil {
   329  				return fmt.Errorf("unable to call git reset: %w", err)
   330  			}
   331  			return gerritCommitandPush(msg, o.Gerrit.AutobumpPRIdentifier, changeId, nil, nil, stdout, stderr)
   332  		}
   333  	}
   334  	return nil
   335  }
   336  
   337  func gerritCommitandPush(summary, autobumpId, changeId string, reviewers, cc []string, stdout, stderr io.Writer) error {
   338  	msg := makeGerritCommit(summary, autobumpId, changeId)
   339  
   340  	// TODO(mpherman): Add reviewers to CreateCR
   341  	if err := createCR(msg, "master", changeId, reviewers, cc, stdout, stderr); err != nil {
   342  		return fmt.Errorf("create CR: %w", err)
   343  	}
   344  	return nil
   345  }
   346  
   347  func cdToRootDir() error {
   348  	if bazelWorkspace := os.Getenv("BUILD_WORKSPACE_DIRECTORY"); bazelWorkspace != "" {
   349  		if err := os.Chdir(bazelWorkspace); err != nil {
   350  			return fmt.Errorf("chdir to bazel workspace (%s): %w", bazelWorkspace, err)
   351  		}
   352  		return nil
   353  	}
   354  	cmd := exec.Command(gitCmd, "rev-parse", "--show-toplevel")
   355  	output, err := cmd.Output()
   356  	if err != nil {
   357  		return fmt.Errorf("get the repo's root directory: %w", err)
   358  	}
   359  	d := strings.TrimSpace(string(output))
   360  	logrus.Infof("Changing working directory to %s...", d)
   361  	return os.Chdir(d)
   362  }
   363  
   364  type callOptions struct {
   365  	ctx context.Context
   366  	dir string
   367  }
   368  
   369  type CallOption func(*callOptions)
   370  
   371  func WithContext(ctx context.Context) CallOption {
   372  	return func(opts *callOptions) {
   373  		opts.ctx = ctx
   374  	}
   375  }
   376  
   377  func WithDir(dir string) CallOption {
   378  	return func(opts *callOptions) {
   379  		opts.dir = dir
   380  	}
   381  }
   382  
   383  func Call(stdout, stderr io.Writer, cmd string, args []string, opts ...CallOption) error {
   384  	var options callOptions
   385  	for _, opt := range opts {
   386  		opt(&options)
   387  	}
   388  	logger := (&logrus.Logger{
   389  		Out:       stderr,
   390  		Formatter: logrus.StandardLogger().Formatter,
   391  		Hooks:     logrus.StandardLogger().Hooks,
   392  		Level:     logrus.StandardLogger().Level,
   393  	}).WithField("cmd", cmd).
   394  		// The default formatting uses a space as separator, which is hard to read if an arg contains a space
   395  		WithField("args", fmt.Sprintf("['%s']", strings.Join(args, "', '")))
   396  
   397  	if options.dir != "" {
   398  		logger = logger.WithField("dir", options.dir)
   399  	}
   400  	logger.Info("running command")
   401  
   402  	var c *exec.Cmd
   403  	if options.ctx != nil {
   404  		c = exec.CommandContext(options.ctx, cmd, args...)
   405  	} else {
   406  		c = exec.Command(cmd, args...)
   407  	}
   408  	c.Stdout = stdout
   409  	c.Stderr = stderr
   410  	if options.dir != "" {
   411  		c.Dir = options.dir
   412  	}
   413  	return c.Run()
   414  }
   415  
   416  type HideSecretsWriter struct {
   417  	Delegate io.Writer
   418  	Censor   func(content []byte) []byte
   419  }
   420  
   421  func (w HideSecretsWriter) Write(content []byte) (int, error) {
   422  	_, err := w.Delegate.Write(w.Censor(content))
   423  	if err != nil {
   424  		return 0, err
   425  	}
   426  	return len(content), nil
   427  }
   428  
   429  // UpdatePR updates with github client "gc" the PR of github repo org/repo
   430  // with headBranch from "source" to "baseBranch"
   431  // "images" contains the tag replacements that have been made which is returned from "updateReferences([]string{"."}, extraFiles)"
   432  // "images" and "extraLineInPRBody" are used to generate commit summary and body of the PR
   433  func UpdatePR(gc github.Client, org, repo string, extraLineInPRBody, login, baseBranch, headBranch string, allowMods bool, summary, body string) error {
   434  	return updatePRWithLabels(gc, org, repo, extraLineInPRBody, login, baseBranch, headBranch, allowMods, summary, body, nil, false)
   435  }
   436  func updatePRWithLabels(gc github.Client, org, repo string, extraLineInPRBody, login, baseBranch, headBranch string, allowMods bool, summary, body string, labels []string, dryrun bool) error {
   437  	return UpdatePullRequestWithLabels(gc, org, repo, summary, generatePRBody(body, extraLineInPRBody), login+":"+headBranch, baseBranch, headBranch, allowMods, labels, dryrun)
   438  }
   439  
   440  // UpdatePullRequest updates with github client "gc" the PR of github repo org/repo
   441  // with "title" and "body" of PR matching author and headBranch from "source" to "baseBranch"
   442  func UpdatePullRequest(gc github.Client, org, repo, title, body, source, baseBranch, headBranch string, allowMods bool, dryrun bool) error {
   443  	return UpdatePullRequestWithLabels(gc, org, repo, title, body, source, baseBranch, headBranch, allowMods, nil, dryrun)
   444  }
   445  
   446  // UpdatePullRequestWithLabels updates with github client "gc" the PR of github repo org/repo
   447  // with "title" and "body" of PR matching author and headBranch from "source" to "baseBranch" with labels
   448  func UpdatePullRequestWithLabels(gc github.Client, org, repo, title, body, source, baseBranch,
   449  	headBranch string, allowMods bool, labels []string, dryrun bool) error {
   450  	logrus.Info("Creating or updating PR...")
   451  	if dryrun {
   452  		logrus.Info("[Dryrun] ensure PR with:")
   453  		logrus.Info(org, repo, title, body, source, baseBranch, headBranch, allowMods, gc, labels, dryrun)
   454  		return nil
   455  	}
   456  	n, err := updater.EnsurePRWithLabels(org, repo, title, body, source, baseBranch, headBranch, allowMods, gc, labels)
   457  	if err != nil {
   458  		return fmt.Errorf("ensure PR exists: %w", err)
   459  	}
   460  	logrus.Infof("PR %s/%s#%d will merge %s into %s: %s", org, repo, *n, source, baseBranch, title)
   461  	return nil
   462  }
   463  
   464  // HasChanges checks if the current git repo contains any changes
   465  func HasChanges() (bool, error) {
   466  	args := []string{"status", "--porcelain"}
   467  	logrus.WithField("cmd", gitCmd).WithField("args", args).Info("running command ...")
   468  	combinedOutput, err := exec.Command(gitCmd, args...).CombinedOutput()
   469  	if err != nil {
   470  		logrus.WithField("cmd", gitCmd).Debugf("output is '%s'", string(combinedOutput))
   471  		return false, fmt.Errorf("running command %s %s: %w", gitCmd, args, err)
   472  	}
   473  	return len(strings.TrimSuffix(string(combinedOutput), "\n")) > 0, nil
   474  }
   475  
   476  // MakeGitCommit runs a sequence of git commands to
   477  // commit and push the changes the "remote" on "remoteBranch"
   478  // "name" and "email" are used for git-commit command
   479  // "images" contains the tag replacements that have been made which is returned from "updateReferences([]string{"."}, extraFiles)"
   480  // "images" is used to generate commit message
   481  func MakeGitCommit(remote, remoteBranch, name, email string, stdout, stderr io.Writer, summary string, dryrun bool) error {
   482  	return GitCommitAndPush(remote, remoteBranch, name, email, summary, stdout, stderr, dryrun)
   483  }
   484  
   485  func makeGerritCommit(summary, commitTag, changeId string) string {
   486  	//Gerrit commits do not recognize "‑" as NON-BREAKING HYPHEN, so just replace with a regular hyphen.
   487  	return fmt.Sprintf("%s\n\n[%s]\n\nChange-Id: %s", strings.ReplaceAll(summary, "‑", "-"), commitTag, changeId)
   488  }
   489  
   490  // GitCommitAndPush runs a sequence of git commands to commit.
   491  // The "name", "email", and "message" are used for git-commit command
   492  func GitCommitAndPush(remote, remoteBranch, name, email, message string, stdout, stderr io.Writer, dryrun bool) error {
   493  	return GitCommitSignoffAndPush(remote, remoteBranch, name, email, message, stdout, stderr, false, dryrun)
   494  }
   495  
   496  // GitCommitSignoffAndPush runs a sequence of git commands to commit with optional signoff for the commit.
   497  // The "name", "email", and "message" are used for git-commit command
   498  func GitCommitSignoffAndPush(remote, remoteBranch, name, email, message string, stdout, stderr io.Writer, signoff bool, dryrun bool) error {
   499  	logrus.Info("Making git commit...")
   500  
   501  	if err := gitCommit(name, email, message, stdout, stderr, signoff); err != nil {
   502  		return err
   503  	}
   504  	return MinimalGitPush(remote, remoteBranch, stdout, stderr, dryrun)
   505  }
   506  func gitCommit(name, email, message string, stdout, stderr io.Writer, signoff bool) error {
   507  	if err := Call(stdout, stderr, gitCmd, []string{"add", "-A"}); err != nil {
   508  		return fmt.Errorf("git add: %w", err)
   509  	}
   510  	commitArgs := []string{"commit", "-m", message}
   511  	if name != "" && email != "" {
   512  		commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", name, email))
   513  	}
   514  	if signoff {
   515  		commitArgs = append(commitArgs, "--signoff")
   516  	}
   517  	if err := Call(stdout, stderr, gitCmd, commitArgs); err != nil {
   518  		return fmt.Errorf("git commit: %w", err)
   519  	}
   520  	return nil
   521  }
   522  
   523  // MinimalGitPush pushes the content of the local repository to the remote, checking to make
   524  // sure that there are real changes that need updating by diffing the tree refs, ensuring that
   525  // no metadata-only pushes occur, as those re-trigger tests, remove LGTM, and cause churn whithout
   526  // changing the content being proposed in the PR.
   527  func MinimalGitPush(remote, remoteBranch string, stdout, stderr io.Writer, dryrun bool, opts ...CallOption) error {
   528  	if err := Call(stdout, stderr, gitCmd, []string{"remote", "add", forkRemoteName, remote}, opts...); err != nil {
   529  		return fmt.Errorf("add remote: %w", err)
   530  	}
   531  	fetchStderr := &bytes.Buffer{}
   532  	var remoteTreeRef string
   533  	if err := Call(stdout, fetchStderr, gitCmd, []string{"fetch", forkRemoteName, remoteBranch}, opts...); err != nil {
   534  		logrus.Info("fetchStderr is : ", fetchStderr.String())
   535  		if !strings.Contains(strings.ToLower(fetchStderr.String()), fmt.Sprintf("couldn't find remote ref %s", remoteBranch)) {
   536  			return fmt.Errorf("fetch from fork: %w", err)
   537  		}
   538  	} else {
   539  		var err error
   540  		remoteTreeRef, err = getTreeRef(stderr, fmt.Sprintf("refs/remotes/%s/%s", forkRemoteName, remoteBranch), opts...)
   541  		if err != nil {
   542  			return fmt.Errorf("get remote tree ref: %w", err)
   543  		}
   544  	}
   545  	localTreeRef, err := getTreeRef(stderr, "HEAD", opts...)
   546  	if err != nil {
   547  		return fmt.Errorf("get local tree ref: %w", err)
   548  	}
   549  
   550  	if dryrun {
   551  		logrus.Info("[Dryrun] Skip git push with: ")
   552  		logrus.Info(forkRemoteName, remoteBranch, stdout, stderr, "")
   553  		return nil
   554  	}
   555  	// Avoid doing metadata-only pushes that re-trigger tests and remove lgtm
   556  	if localTreeRef != remoteTreeRef {
   557  		if err := GitPush(forkRemoteName, remoteBranch, stdout, stderr, "", opts...); err != nil {
   558  			return err
   559  		}
   560  	} else {
   561  		logrus.Info("Not pushing as up-to-date remote branch already exists")
   562  	}
   563  	return nil
   564  }
   565  
   566  // GitPush push the changes to the given remote and branch.
   567  func GitPush(remote, remoteBranch string, stdout, stderr io.Writer, workingDir string, opts ...CallOption) error {
   568  	logrus.Info("Pushing to remote...")
   569  	gc := GitCommand{
   570  		baseCommand: gitCmd,
   571  		args:        []string{"push", "-f", remote, fmt.Sprintf("HEAD:%s", remoteBranch)},
   572  		workingDir:  workingDir,
   573  	}
   574  	if err := gc.Call(stdout, stderr, opts...); err != nil {
   575  		return fmt.Errorf("%s: %w", gc.getCommand(), err)
   576  	}
   577  	return nil
   578  }
   579  func generatePRBody(body, assignment string) string {
   580  	return body + assignment + "\n"
   581  }
   582  
   583  func getAssignment(assignTo string) string {
   584  	if assignTo != "" {
   585  		return "/cc @" + assignTo
   586  	}
   587  	return ""
   588  }
   589  
   590  func getTreeRef(stderr io.Writer, refname string, opts ...CallOption) (string, error) {
   591  	revParseStdout := &bytes.Buffer{}
   592  	if err := Call(revParseStdout, stderr, gitCmd, []string{"rev-parse", refname + ":"}, opts...); err != nil {
   593  		return "", fmt.Errorf("parse ref: %w", err)
   594  	}
   595  	fields := strings.Fields(revParseStdout.String())
   596  	if n := len(fields); n < 1 {
   597  		return "", errors.New("got no otput when trying to rev-parse")
   598  	}
   599  	return fields[0], nil
   600  }
   601  
   602  func buildPushRef(branch string, reviewers, cc []string) string {
   603  	pushRef := fmt.Sprintf("HEAD:refs/for/%s", branch)
   604  	var addedOptions []string
   605  	for _, v := range reviewers {
   606  		addedOptions = append(addedOptions, fmt.Sprintf("r=%s", v))
   607  	}
   608  	for _, v := range cc {
   609  		addedOptions = append(addedOptions, fmt.Sprintf("cc=%s", v))
   610  	}
   611  	if len(addedOptions) > 0 {
   612  		pushRef = fmt.Sprintf("%s%%%s", pushRef, strings.Join(addedOptions, ","))
   613  	}
   614  	return pushRef
   615  }
   616  
   617  func getDiff(prevCommit string) (string, error) {
   618  	var diffBuf bytes.Buffer
   619  	var errBuf bytes.Buffer
   620  	if err := Call(&diffBuf, &errBuf, gitCmd, []string{"diff", prevCommit}); err != nil {
   621  		return "", fmt.Errorf("diffing previous bump: %v -- %s", err, errBuf.String())
   622  	}
   623  	return diffBuf.String(), nil
   624  }
   625  
   626  func gerritNoOpChange(changeID string) (bool, error) {
   627  	var garbageBuf bytes.Buffer
   628  	var outBuf bytes.Buffer
   629  	// Fetch current pending CRs
   630  	if err := Call(&garbageBuf, &garbageBuf, gitCmd, []string{"fetch", "upstream", "+refs/changes/*:refs/remotes/upstream/changes/*"}); err != nil {
   631  		return false, fmt.Errorf("unable to fetch upstream changes: %v -- \nOUTPUT: %s", err, garbageBuf.String())
   632  	}
   633  	// Get PR with same ChangeID for this bump
   634  	if err := Call(&outBuf, &garbageBuf, gitCmd, []string{"log", "--all", fmt.Sprintf("--grep=Change-Id: %s", changeID), "-1", "--format=%H"}); err != nil {
   635  		return false, fmt.Errorf("getting previous bump: %w", err)
   636  	}
   637  	prevCommit := strings.TrimSpace(outBuf.String())
   638  	// No current CRs with cur ChangeID means this is not a noOp change
   639  	if prevCommit == "" {
   640  		return false, nil
   641  	}
   642  	diff, err := getDiff(prevCommit)
   643  	if err != nil {
   644  		return false, err
   645  	}
   646  	if diff == "" {
   647  		return true, nil
   648  	}
   649  	return false, nil
   650  
   651  }
   652  
   653  func createCR(msg, branch, changeID string, reviewers, cc []string, stdout, stderr io.Writer) error {
   654  	noOp, err := gerritNoOpChange(changeID)
   655  	if err != nil {
   656  		return fmt.Errorf("diffing previous bump: %w", err)
   657  	}
   658  	if noOp {
   659  		logrus.Info("CR is a no-op change. Returning without pushing update")
   660  		return nil
   661  	}
   662  
   663  	pushRef := buildPushRef(branch, reviewers, cc)
   664  	if err := Call(stdout, stderr, gitCmd, []string{"commit", "-a", "-v", "-m", msg}); err != nil {
   665  		return fmt.Errorf("unable to commit: %w", err)
   666  	}
   667  	if err := Call(stdout, stderr, gitCmd, []string{"push", "upstream", pushRef}); err != nil {
   668  		return fmt.Errorf("unable to push: %w", err)
   669  	}
   670  	return nil
   671  }
   672  
   673  func getLastBumpCommit(gerritAuthor, commitTag string) (string, error) {
   674  	var outBuf bytes.Buffer
   675  	var errBuf bytes.Buffer
   676  
   677  	if err := Call(&outBuf, &errBuf, gitCmd, []string{"log", fmt.Sprintf("--author=%s", gerritAuthor), fmt.Sprintf("--grep=%s", commitTag), "-1", "--format='%H'"}); err != nil {
   678  		return "", errors.New("running git command")
   679  	}
   680  
   681  	return outBuf.String(), nil
   682  }
   683  
   684  // getChangeId generates a change ID for the gerrit PR that is deterministic
   685  // rather than being random as is normally preferable.
   686  // In particular this chooses a change ID by hashing the last commit by the
   687  // robot with a given string in the commit message (This string will be added to all autobump commit messages)
   688  // if there is no commit by the robot with this commit tag, we assume that the job has never run, or that the robot/commit tag has changed
   689  // in either case, the deterministic ID is generated by just hashing a string of the author + commit tag
   690  func getChangeId(gerritAuthor, commitTag, startingID string) (string, error) {
   691  	var id string
   692  	if startingID == "" {
   693  		lastBumpCommit, err := getLastBumpCommit(gerritAuthor, commitTag)
   694  		if err != nil {
   695  			return "", fmt.Errorf("Error getting change Id: %w", err)
   696  		}
   697  		if lastBumpCommit != "" {
   698  			id = "I" + GitHash(lastBumpCommit)
   699  		} else {
   700  			// If it is the first time the autobumper has run a commit will not exist with the tag
   701  			// create a deterministic tag by hashing the tag itself instead of the last commit.
   702  			id = "I" + GitHash(gerritAuthor+commitTag)
   703  		}
   704  	} else {
   705  		id = GitHash(startingID)
   706  	}
   707  	gitLog, err := getFullLog()
   708  	if err != nil {
   709  		return "", err
   710  	}
   711  	//While a commit on the base branch exists with this change ID...
   712  	for strings.Contains(gitLog, id) {
   713  		// Choose another ID by hashing the current ID.
   714  		id = "I" + GitHash(id)
   715  	}
   716  
   717  	return id, nil
   718  }
   719  
   720  func getFullLog() (string, error) {
   721  	var outBuf bytes.Buffer
   722  	var errBuf bytes.Buffer
   723  
   724  	if err := Call(&outBuf, &errBuf, gitCmd, []string{"log"}); err != nil {
   725  		return "", fmt.Errorf("unable to run git log: %w, %s", err, errBuf.String())
   726  	}
   727  	return outBuf.String(), nil
   728  }
   729  
   730  func GitHash(hashing string) string {
   731  	h := sha1.New()
   732  	io.WriteString(h, hashing)
   733  	return fmt.Sprintf("%x", h.Sum(nil))
   734  }