sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/cmd/external-plugins/cherrypicker/server.go (about)

     1  /*
     2  Copyright 2017 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package main
    18  
    19  import (
    20  	"bytes"
    21  	"encoding/json"
    22  	"fmt"
    23  	"io"
    24  	"net/http"
    25  	"os"
    26  	"regexp"
    27  	"strings"
    28  	"sync"
    29  	"time"
    30  
    31  	"github.com/sirupsen/logrus"
    32  
    33  	utilerrors "k8s.io/apimachinery/pkg/util/errors"
    34  	cherrypicker "sigs.k8s.io/prow/cmd/external-plugins/cherrypicker/lib"
    35  	"sigs.k8s.io/prow/pkg/config"
    36  	"sigs.k8s.io/prow/pkg/git/v2"
    37  	"sigs.k8s.io/prow/pkg/github"
    38  	"sigs.k8s.io/prow/pkg/pluginhelp"
    39  	"sigs.k8s.io/prow/pkg/plugins"
    40  )
    41  
    42  const pluginName = "cherrypick"
    43  const defaultLabelPrefix = "cherrypick/"
    44  
    45  var cherryPickRe = regexp.MustCompile(`(?m)^(?:/cherrypick|/cherry-pick)\s+(.+)$`)
    46  var releaseNoteRe = regexp.MustCompile(`(?s)(?:Release note\*\*:\s*(?:<!--[^<>]*-->\s*)?` + "```(?:release-note)?|```release-note)(.+?)```")
    47  var titleTargetBranchIndicatorTemplate = `[%s] `
    48  
    49  var notOrgMemberMessageTemplate = "only [%s](https://github.com/orgs/%s/people) org members may request cherry picks. If you are already part of the org, make sure to [change](https://github.com/orgs/%s/people?query=%s) your membership to public. Otherwise you can still do the cherry-pick manually. "
    50  
    51  type githubClient interface {
    52  	AddLabel(org, repo string, number int, label string) error
    53  	AssignIssue(org, repo string, number int, logins []string) error
    54  	CreateComment(org, repo string, number int, comment string) error
    55  	CreateFork(org, repo string) (string, error)
    56  	CreatePullRequest(org, repo, title, body, head, base string, canModify bool) (int, error)
    57  	CreateIssue(org, repo, title, body string, milestone int, labels, assignees []string) (int, error)
    58  	EnsureFork(forkingUser, org, repo string) (string, error)
    59  	GetPullRequest(org, repo string, number int) (*github.PullRequest, error)
    60  	GetPullRequestPatch(org, repo string, number int) ([]byte, error)
    61  	GetPullRequests(org, repo string) ([]github.PullRequest, error)
    62  	GetRepo(owner, name string) (github.FullRepo, error)
    63  	IsMember(org, user string) (bool, error)
    64  	ListIssueComments(org, repo string, number int) ([]github.IssueComment, error)
    65  	GetIssueLabels(org, repo string, number int) ([]github.Label, error)
    66  	ListOrgMembers(org, role string) ([]github.TeamMember, error)
    67  }
    68  
    69  // HelpProvider construct the pluginhelp.PluginHelp for this plugin.
    70  func HelpProvider(_ []config.OrgRepo) (*pluginhelp.PluginHelp, error) {
    71  	pluginHelp := &pluginhelp.PluginHelp{
    72  		Description: `The cherrypick plugin is used for cherrypicking PRs across branches. For every successful cherrypick invocation a new PR is opened against the target branch and assigned to the requestor. If the parent PR contains a release note, it is copied to the cherrypick PR.`,
    73  	}
    74  	pluginHelp.AddCommand(pluginhelp.Command{
    75  		Usage:       "/cherrypick [branch]",
    76  		Description: "Cherrypick a PR to a different branch. This command works both in merged PRs (the cherrypick PR is opened immediately) and open PRs (the cherrypick PR opens as soon as the original PR merges). If multiple branches are specified, separated by a space, a cherrypick for the first branch will be created with a comment to cherrypick the remaining branches after the first merges.",
    77  		Featured:    true,
    78  		// depends on how the cherrypick server runs; needs auth by default (--allow-all=false)
    79  		WhoCanUse: "Members of the trusted organization for the repo.",
    80  		Examples:  []string{"/cherrypick release-3.9", "/cherry-pick release-1.15", "/cherrypick release-1.6 release-1.5 release-1.4"},
    81  	})
    82  	return pluginHelp, nil
    83  }
    84  
    85  // Server implements http.Handler. It validates incoming GitHub webhooks and
    86  // then dispatches them to the appropriate plugins.
    87  type Server struct {
    88  	tokenGenerator func() []byte
    89  	botUser        *github.UserData
    90  	email          string
    91  
    92  	gc git.ClientFactory
    93  	// Used for unit testing
    94  	push func(forkName, newBranch string, force bool) error
    95  	ghc  githubClient
    96  	log  *logrus.Entry
    97  
    98  	// Labels to apply to the cherrypicked PR.
    99  	labels []string
   100  	// Use prow to assign users to cherrypicked PRs.
   101  	prowAssignments bool
   102  	// Allow anybody to do cherrypicks.
   103  	allowAll bool
   104  	// Create an issue on cherrypick conflict.
   105  	issueOnConflict bool
   106  	// Set a custom label prefix.
   107  	labelPrefix string
   108  
   109  	bare     *http.Client
   110  	patchURL string
   111  
   112  	repoLock sync.Mutex
   113  	repos    []github.Repo
   114  	mapLock  sync.Mutex
   115  	lockMap  map[cherryPickRequest]*sync.Mutex
   116  }
   117  
   118  type cherryPickRequest struct {
   119  	org          string
   120  	repo         string
   121  	pr           int
   122  	targetBranch string
   123  }
   124  
   125  // ServeHTTP validates an incoming webhook and puts it into the event channel.
   126  func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
   127  	eventType, eventGUID, payload, ok, _ := github.ValidateWebhook(w, r, s.tokenGenerator)
   128  	if !ok {
   129  		return
   130  	}
   131  	fmt.Fprint(w, "Event received. Have a nice day.")
   132  
   133  	if err := s.handleEvent(eventType, eventGUID, payload); err != nil {
   134  		logrus.WithError(err).Error("Error parsing event.")
   135  	}
   136  }
   137  
   138  func (s *Server) handleEvent(eventType, eventGUID string, payload []byte) error {
   139  	l := logrus.WithFields(logrus.Fields{
   140  		"event-type":     eventType,
   141  		github.EventGUID: eventGUID,
   142  	})
   143  	switch eventType {
   144  	case "issue_comment":
   145  		var ic github.IssueCommentEvent
   146  		if err := json.Unmarshal(payload, &ic); err != nil {
   147  			return err
   148  		}
   149  		go func() {
   150  			if err := s.handleIssueComment(l, ic); err != nil {
   151  				s.log.WithError(err).WithFields(l.Data).Info("Cherry-pick failed.")
   152  			}
   153  		}()
   154  	case "pull_request":
   155  		var pr github.PullRequestEvent
   156  		if err := json.Unmarshal(payload, &pr); err != nil {
   157  			return err
   158  		}
   159  		go func() {
   160  			if err := s.handlePullRequest(l, pr); err != nil {
   161  				s.log.WithError(err).WithFields(l.Data).Info("Cherry-pick failed.")
   162  			}
   163  		}()
   164  	default:
   165  		logrus.Debugf("skipping event of type %q", eventType)
   166  	}
   167  	return nil
   168  }
   169  
   170  func (s *Server) handleIssueComment(l *logrus.Entry, ic github.IssueCommentEvent) error {
   171  	// Only consider new comments in PRs.
   172  	if !ic.Issue.IsPullRequest() || ic.Action != github.IssueCommentActionCreated {
   173  		return nil
   174  	}
   175  
   176  	org := ic.Repo.Owner.Login
   177  	repo := ic.Repo.Name
   178  	num := ic.Issue.Number
   179  	commentAuthor := ic.Comment.User.Login
   180  
   181  	// Do not create a new logger, its fields are re-used by the caller in case of errors
   182  	*l = *l.WithFields(logrus.Fields{
   183  		github.OrgLogField:  org,
   184  		github.RepoLogField: repo,
   185  		github.PrLogField:   num,
   186  	})
   187  
   188  	cherryPickMatches := cherryPickRe.FindAllStringSubmatch(ic.Comment.Body, -1)
   189  	if len(cherryPickMatches) == 0 || len(cherryPickMatches[0]) < 2 {
   190  		return nil
   191  	}
   192  	branches := strings.Fields(cherryPickMatches[0][1])
   193  	targetBranch := branches[0]
   194  	var chainBranches []string
   195  	if len(branches) > 1 {
   196  		chainBranches = branches[1:]
   197  	}
   198  
   199  	if ic.Issue.State != "closed" {
   200  		if !s.allowAll {
   201  			// Only members should be able to do cherry-picks.
   202  			ok, err := s.ghc.IsMember(org, commentAuthor)
   203  			if err != nil {
   204  				return err
   205  			}
   206  			if !ok {
   207  				resp := fmt.Sprintf(notOrgMemberMessageTemplate, org, org, org, commentAuthor)
   208  				l.Info(resp)
   209  				return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(ic.Comment, resp))
   210  			}
   211  		}
   212  		resp := fmt.Sprintf("once the present PR merges, I will cherry-pick it on top of %s in a new PR and assign it to you.", targetBranch)
   213  		l.Info(resp)
   214  		return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(ic.Comment, resp))
   215  	}
   216  
   217  	pr, err := s.ghc.GetPullRequest(org, repo, num)
   218  	if err != nil {
   219  		return fmt.Errorf("failed to get pull request %s/%s#%d: %w", org, repo, num, err)
   220  	}
   221  	baseBranch := pr.Base.Ref
   222  	title := pr.Title
   223  	body := pr.Body
   224  
   225  	// Cherry-pick only merged PRs.
   226  	if !pr.Merged {
   227  		resp := "cannot cherry-pick an unmerged PR"
   228  		l.Info(resp)
   229  		return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(ic.Comment, resp))
   230  	}
   231  
   232  	// TODO: Use an allowlist for allowed base and target branches.
   233  	if baseBranch == targetBranch {
   234  		resp := fmt.Sprintf("base branch (%s) needs to differ from target branch (%s)", baseBranch, targetBranch)
   235  		l.Info(resp)
   236  		return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(ic.Comment, resp))
   237  	}
   238  
   239  	if !s.allowAll {
   240  		// Only org members should be able to do cherry-picks.
   241  		ok, err := s.ghc.IsMember(org, commentAuthor)
   242  		if err != nil {
   243  			return err
   244  		}
   245  		if !ok {
   246  			resp := fmt.Sprintf(notOrgMemberMessageTemplate, org, org, org, commentAuthor)
   247  			l.Info(resp)
   248  			return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(ic.Comment, resp))
   249  		}
   250  	}
   251  
   252  	*l = *l.WithFields(logrus.Fields{
   253  		"requestor":     ic.Comment.User.Login,
   254  		"target_branch": targetBranch,
   255  	})
   256  	l.Debug("Cherrypick request.")
   257  	return s.handle(l, ic.Comment.User.Login, &ic.Comment, org, repo, targetBranch, baseBranch, chainBranches, title, body, num)
   258  }
   259  
   260  func (s *Server) handlePullRequest(l *logrus.Entry, pre github.PullRequestEvent) error {
   261  	// Only consider newly merged PRs
   262  	if pre.Action != github.PullRequestActionClosed && pre.Action != github.PullRequestActionLabeled {
   263  		return nil
   264  	}
   265  
   266  	pr := pre.PullRequest
   267  	if !pr.Merged || pr.MergeSHA == nil {
   268  		return nil
   269  	}
   270  
   271  	org := pr.Base.Repo.Owner.Login
   272  	repo := pr.Base.Repo.Name
   273  	baseBranch := pr.Base.Ref
   274  	num := pr.Number
   275  	title := pr.Title
   276  	body := pr.Body
   277  
   278  	// Do not create a new logger, its fields are re-used by the caller in case of errors
   279  	*l = *l.WithFields(logrus.Fields{
   280  		github.OrgLogField:  org,
   281  		github.RepoLogField: repo,
   282  		github.PrLogField:   num,
   283  	})
   284  
   285  	comments, err := s.ghc.ListIssueComments(org, repo, num)
   286  	if err != nil {
   287  		return fmt.Errorf("failed to list comments: %w", err)
   288  	}
   289  
   290  	// requestor -> target branch -> issue comment
   291  	requestorToComments := make(map[string]map[string]*github.IssueComment)
   292  	// target branch -> chain branches (eg. "release-1.6" -> []string{"release-1.5", "release-1.4"})
   293  	targetBranchToChainBranches := make(map[string][]string)
   294  
   295  	// first look for our special comments
   296  	for i := range comments {
   297  		c := comments[i]
   298  		cherryPickMatches := cherryPickRe.FindAllStringSubmatch(c.Body, -1)
   299  		for _, match := range cherryPickMatches {
   300  			targetBranch := strings.Fields(match[1])
   301  			if requestorToComments[c.User.Login] == nil {
   302  				requestorToComments[c.User.Login] = make(map[string]*github.IssueComment)
   303  			}
   304  			requestorToComments[c.User.Login][targetBranch[0]] = &c
   305  			if len(targetBranch) > 1 {
   306  				targetBranchToChainBranches[targetBranch[0]] = targetBranch[1:]
   307  			}
   308  		}
   309  	}
   310  
   311  	foundCherryPickComments := len(requestorToComments) != 0
   312  
   313  	// now look for our special labels
   314  	labels, err := s.ghc.GetIssueLabels(org, repo, num)
   315  	if err != nil {
   316  		return fmt.Errorf("failed to get issue labels: %w", err)
   317  	}
   318  
   319  	if requestorToComments[pr.User.Login] == nil {
   320  		requestorToComments[pr.User.Login] = make(map[string]*github.IssueComment)
   321  	}
   322  
   323  	foundCherryPickLabels := false
   324  	for _, label := range labels {
   325  		if strings.HasPrefix(label.Name, s.labelPrefix) {
   326  			requestorToComments[pr.User.Login][label.Name[len(s.labelPrefix):]] = nil // leave this nil which indicates a label-initiated cherry-pick
   327  			foundCherryPickLabels = true
   328  		}
   329  	}
   330  
   331  	if !foundCherryPickComments && !foundCherryPickLabels {
   332  		return nil
   333  	}
   334  
   335  	if !foundCherryPickLabels && pre.Action == github.PullRequestActionLabeled {
   336  		return nil
   337  	}
   338  
   339  	// Figure out membership.
   340  	if !s.allowAll {
   341  		// TODO: Possibly cache this.
   342  		members, err := s.ghc.ListOrgMembers(org, "all")
   343  		if err != nil {
   344  			return err
   345  		}
   346  		for requestor := range requestorToComments {
   347  			isMember := false
   348  			for _, m := range members {
   349  				if requestor == m.Login {
   350  					isMember = true
   351  					break
   352  				}
   353  			}
   354  			if !isMember {
   355  				delete(requestorToComments, requestor)
   356  			}
   357  		}
   358  	}
   359  
   360  	// Handle multiple comments serially. Make sure to filter out
   361  	// comments targeting the same branch.
   362  	handledBranches := make(map[string]bool)
   363  	var errs []error
   364  	for requestor, branches := range requestorToComments {
   365  		for targetBranch, ic := range branches {
   366  			if handledBranches[targetBranch] {
   367  				// Branch already handled. Skip.
   368  				continue
   369  			}
   370  			if targetBranch == baseBranch {
   371  				resp := fmt.Sprintf("base branch (%s) needs to differ from target branch (%s)", baseBranch, targetBranch)
   372  				l.Info(resp)
   373  				if err := s.createComment(l, org, repo, num, ic, resp); err != nil {
   374  					l.WithError(err).WithField("response", resp).Error("Failed to create comment.")
   375  				}
   376  				continue
   377  			}
   378  			handledBranches[targetBranch] = true
   379  			l := l.WithFields(logrus.Fields{
   380  				"requestor":     requestor,
   381  				"target_branch": targetBranch,
   382  			})
   383  			l.Debug("Cherrypick request.")
   384  			var chainedBranches []string
   385  			if branches, ok := targetBranchToChainBranches[targetBranch]; ok {
   386  				chainedBranches = branches
   387  			}
   388  			err := s.handle(l, requestor, ic, org, repo, targetBranch, baseBranch, chainedBranches, title, body, num)
   389  			if err != nil {
   390  				errs = append(errs, fmt.Errorf("failed to create cherrypick: %w", err))
   391  			}
   392  		}
   393  	}
   394  	return utilerrors.NewAggregate(errs)
   395  }
   396  
   397  var cherryPickBranchFmt = "cherry-pick-%d-to-%s"
   398  
   399  func (s *Server) handle(logger *logrus.Entry, requestor string, comment *github.IssueComment, org, repo, targetBranch, baseBranch string, chainBranches []string, title, body string, num int) error {
   400  	var lock *sync.Mutex
   401  	func() {
   402  		s.mapLock.Lock()
   403  		defer s.mapLock.Unlock()
   404  		if _, ok := s.lockMap[cherryPickRequest{org, repo, num, targetBranch}]; !ok {
   405  			if s.lockMap == nil {
   406  				s.lockMap = map[cherryPickRequest]*sync.Mutex{}
   407  			}
   408  			s.lockMap[cherryPickRequest{org, repo, num, targetBranch}] = &sync.Mutex{}
   409  		}
   410  		lock = s.lockMap[cherryPickRequest{org, repo, num, targetBranch}]
   411  	}()
   412  	lock.Lock()
   413  	defer lock.Unlock()
   414  
   415  	forkName, err := s.ensureForkExists(org, repo)
   416  	if err != nil {
   417  		logger.WithError(err).Warn("failed to ensure fork exists")
   418  		resp := fmt.Sprintf("cannot fork %s/%s: %v", org, repo, err)
   419  		return s.createComment(logger, org, repo, num, comment, resp)
   420  	}
   421  
   422  	// Clone the repo, checkout the target branch.
   423  	startClone := time.Now()
   424  	r, err := s.gc.ClientFor(org, repo)
   425  	if err != nil {
   426  		return fmt.Errorf("failed to get git client for %s/%s: %w", org, forkName, err)
   427  	}
   428  	defer func() {
   429  		if err := r.Clean(); err != nil {
   430  			logger.WithError(err).Error("Error cleaning up repo.")
   431  		}
   432  	}()
   433  	if err := r.Checkout(targetBranch); err != nil {
   434  		logger.WithError(err).Warn("failed to checkout target branch")
   435  		resp := fmt.Sprintf("cannot checkout `%s`: %v", targetBranch, err)
   436  		return s.createComment(logger, org, repo, num, comment, resp)
   437  	}
   438  	logger.WithField("duration", time.Since(startClone)).Info("Cloned and checked out target branch.")
   439  
   440  	// Fetch the patch from GitHub
   441  	localPath, err := s.getPatch(org, repo, targetBranch, num)
   442  	if err != nil {
   443  		return fmt.Errorf("failed to get patch: %w", err)
   444  	}
   445  
   446  	if err := r.Config("user.name", s.botUser.Login); err != nil {
   447  		return fmt.Errorf("failed to configure git user: %w", err)
   448  	}
   449  	email := s.email
   450  	if email == "" {
   451  		email = s.botUser.Email
   452  	}
   453  	if err := r.Config("user.email", email); err != nil {
   454  		return fmt.Errorf("failed to configure git email: %w", err)
   455  	}
   456  
   457  	// New branch for the cherry-pick.
   458  	newBranch := fmt.Sprintf(cherryPickBranchFmt, num, targetBranch)
   459  
   460  	// Check if that branch already exists, which means there is already a PR for that cherry-pick.
   461  	if r.BranchExists(newBranch) {
   462  		// Find the PR and link to it.
   463  		prs, err := s.ghc.GetPullRequests(org, repo)
   464  		if err != nil {
   465  			return fmt.Errorf("failed to get pullrequests for %s/%s: %w", org, repo, err)
   466  		}
   467  		for _, pr := range prs {
   468  			if pr.Head.Ref == fmt.Sprintf("%s:%s", s.botUser.Login, newBranch) {
   469  				logger.WithField("preexisting_cherrypick", pr.HTMLURL).Info("PR already has cherrypick")
   470  				resp := fmt.Sprintf("Looks like #%d has already been cherry picked in %s", num, pr.HTMLURL)
   471  				return s.createComment(logger, org, repo, num, comment, resp)
   472  			}
   473  		}
   474  	}
   475  
   476  	// Create the branch for the cherry-pick.
   477  	if err := r.CheckoutNewBranch(newBranch); err != nil {
   478  		return fmt.Errorf("failed to checkout %s: %w", newBranch, err)
   479  	}
   480  
   481  	// Title for GitHub issue/PR.
   482  	titleTargetBranchIndicator := fmt.Sprintf(titleTargetBranchIndicatorTemplate, targetBranch)
   483  	title = fmt.Sprintf("%s%s", titleTargetBranchIndicator, omitBaseBranchFromTitle(title, baseBranch))
   484  
   485  	// Apply the patch.
   486  	if err := r.Am(localPath); err != nil {
   487  		errs := []error{fmt.Errorf("failed to `git am`: %w", err)}
   488  		logger.WithError(err).Warn("failed to apply PR on top of target branch")
   489  		resp := fmt.Sprintf("#%d failed to apply on top of branch %q:\n```\n%v\n```", num, targetBranch, err)
   490  		if err := s.createComment(logger, org, repo, num, comment, resp); err != nil {
   491  			errs = append(errs, fmt.Errorf("failed to create comment: %w", err))
   492  		}
   493  
   494  		if s.issueOnConflict {
   495  			resp = fmt.Sprintf("Manual cherrypick required.\n\n%v", resp)
   496  			if err := s.createIssue(logger, org, repo, title, resp, num, comment, nil, []string{requestor}); err != nil {
   497  				errs = append(errs, fmt.Errorf("failed to create issue: %w", err))
   498  			}
   499  		}
   500  
   501  		return utilerrors.NewAggregate(errs)
   502  	}
   503  
   504  	push := r.PushToNamedFork
   505  	if s.push != nil {
   506  		push = s.push
   507  	}
   508  	// Push the new branch in the bot's fork.
   509  	if err := push(forkName, newBranch, true); err != nil {
   510  		logger.WithError(err).Warn("failed to push chery-picked changes to GitHub")
   511  		resp := fmt.Sprintf("failed to push cherry-picked changes in GitHub: %v", err)
   512  		return utilerrors.NewAggregate([]error{err, s.createComment(logger, org, repo, num, comment, resp)})
   513  	}
   514  
   515  	// Open a PR in GitHub.
   516  	var cherryPickBody string
   517  	if s.prowAssignments {
   518  		cherryPickBody = cherrypicker.CreateCherrypickBody(num, requestor, releaseNoteFromParentPR(body), chainBranches)
   519  	} else {
   520  		cherryPickBody = cherrypicker.CreateCherrypickBody(num, "", releaseNoteFromParentPR(body), chainBranches)
   521  	}
   522  	head := fmt.Sprintf("%s:%s", s.botUser.Login, newBranch)
   523  	createdNum, err := s.ghc.CreatePullRequest(org, repo, title, cherryPickBody, head, targetBranch, true)
   524  	if err != nil {
   525  		logger.WithError(err).Warn("failed to create new pull request")
   526  		resp := fmt.Sprintf("new pull request could not be created: %v", err)
   527  		return utilerrors.NewAggregate([]error{err, s.createComment(logger, org, repo, num, comment, resp)})
   528  	}
   529  	*logger = *logger.WithField("new_pull_request_number", createdNum)
   530  	resp := fmt.Sprintf("new pull request created: #%d", createdNum)
   531  	logger.Info("new pull request created")
   532  	if err := s.createComment(logger, org, repo, num, comment, resp); err != nil {
   533  		return fmt.Errorf("failed to create comment: %w", err)
   534  	}
   535  	for _, label := range s.labels {
   536  		if err := s.ghc.AddLabel(org, repo, createdNum, label); err != nil {
   537  			return fmt.Errorf("failed to add label %s: %w", label, err)
   538  		}
   539  	}
   540  	if s.prowAssignments {
   541  		if err := s.ghc.AssignIssue(org, repo, createdNum, []string{requestor}); err != nil {
   542  			logger.WithError(err).Warn("failed to assign to new PR")
   543  			// Ignore returning errors on failure to assign as this is most likely
   544  			// due to users not being members of the org so that they can't be assigned
   545  			// in PRs.
   546  			return nil
   547  		}
   548  	}
   549  	return nil
   550  }
   551  
   552  // omitBaseBranchFromTitle returns the title without the base branch's
   553  // indicator, if there is one. We do this to avoid long cherry-pick titles when
   554  // doing a backport of a backport.
   555  //
   556  // Example of long cherry-pick titles:
   557  // Original PR title: "Hello world"
   558  // Backport to release-9.9 title: "[release-9.9] Hello world"
   559  // Backport to release-9.8 title: "[release-9.8] [release-9.9] Hello world"
   560  //
   561  // This function helps by making the second backport title
   562  // be "[release-9.8] Hello world" instead, by deleting the first occurrence
   563  // of "[release-9.9]" from the first backport's title.
   564  //
   565  // When baseBranch is empty, this function simply returns the title as-is for convenience.
   566  func omitBaseBranchFromTitle(title, baseBranch string) string {
   567  	if baseBranch == "" {
   568  		return title
   569  	}
   570  
   571  	return strings.Replace(title, fmt.Sprintf(titleTargetBranchIndicatorTemplate, baseBranch), "", 1)
   572  }
   573  
   574  func (s *Server) createComment(l *logrus.Entry, org, repo string, num int, comment *github.IssueComment, resp string) error {
   575  	if err := func() error {
   576  		if comment != nil {
   577  			return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(*comment, resp))
   578  		}
   579  		return s.ghc.CreateComment(org, repo, num, fmt.Sprintf("In response to a cherrypick label: %s", resp))
   580  	}(); err != nil {
   581  		l.WithError(err).Warn("failed to create comment")
   582  		return err
   583  	}
   584  	logrus.Debug("Created comment")
   585  	return nil
   586  }
   587  
   588  // createIssue creates an issue on GitHub.
   589  func (s *Server) createIssue(l *logrus.Entry, org, repo, title, body string, num int, comment *github.IssueComment, labels, assignees []string) error {
   590  	issueNum, err := s.ghc.CreateIssue(org, repo, title, body, 0, labels, assignees)
   591  	if err != nil {
   592  		return s.createComment(l, org, repo, num, comment, fmt.Sprintf("new issue could not be created for failed cherrypick: %v", err))
   593  	}
   594  
   595  	return s.createComment(l, org, repo, num, comment, fmt.Sprintf("new issue created for failed cherrypick: #%d", issueNum))
   596  }
   597  
   598  // ensureForkExists ensures a fork of org/repo exists for the bot.
   599  func (s *Server) ensureForkExists(org, repo string) (string, error) {
   600  	fork := s.botUser.Login + "/" + repo
   601  
   602  	// fork repo if it doesn't exist
   603  	repo, err := s.ghc.EnsureFork(s.botUser.Login, org, repo)
   604  	if err != nil {
   605  		return repo, err
   606  	}
   607  
   608  	s.repoLock.Lock()
   609  	defer s.repoLock.Unlock()
   610  	s.repos = append(s.repos, github.Repo{FullName: fork, Fork: true})
   611  	return repo, nil
   612  }
   613  
   614  // getPatch gets the patch for the provided PR and creates a local
   615  // copy of it. It returns its location in the filesystem and any
   616  // encountered error.
   617  func (s *Server) getPatch(org, repo, targetBranch string, num int) (string, error) {
   618  	patch, err := s.ghc.GetPullRequestPatch(org, repo, num)
   619  	if err != nil {
   620  		return "", err
   621  	}
   622  	localPath := fmt.Sprintf("/tmp/%s_%s_%d_%s.patch", org, repo, num, normalize(targetBranch))
   623  	out, err := os.Create(localPath)
   624  	if err != nil {
   625  		return "", err
   626  	}
   627  	defer out.Close()
   628  	if _, err := io.Copy(out, bytes.NewBuffer(patch)); err != nil {
   629  		return "", err
   630  	}
   631  	return localPath, nil
   632  }
   633  
   634  func normalize(input string) string {
   635  	return strings.Replace(input, "/", "-", -1)
   636  }
   637  
   638  // releaseNoteNoteFromParentPR gets the release note from the
   639  // parent PR and formats it as per the PR template so that
   640  // it can be copied to the cherry-pick PR.
   641  func releaseNoteFromParentPR(body string) string {
   642  	potentialMatch := releaseNoteRe.FindStringSubmatch(body)
   643  	if potentialMatch == nil {
   644  		return ""
   645  	}
   646  	return fmt.Sprintf("```release-note\n%s\n```", strings.TrimSpace(potentialMatch[1]))
   647  }