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 }