github.com/munnerz/test-infra@v0.0.0-20190108210205-ce3d181dc989/prow/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  	"k8s.io/test-infra/prow/git"
    34  	"k8s.io/test-infra/prow/github"
    35  	"k8s.io/test-infra/prow/pluginhelp"
    36  	"k8s.io/test-infra/prow/plugins"
    37  )
    38  
    39  const pluginName = "cherrypick"
    40  
    41  var cherryPickRe = regexp.MustCompile(`(?m)^/cherrypick\s+(.+)$`)
    42  var releaseNoteRe = regexp.MustCompile(`(?s)(?:Release note\*\*:\s*(?:<!--[^<>]*-->\s*)?` + "```(?:release-note)?|```release-note)(.+?)```")
    43  
    44  type githubClient interface {
    45  	AssignIssue(org, repo string, number int, logins []string) error
    46  	CreateComment(org, repo string, number int, comment string) error
    47  	CreateFork(org, repo string) error
    48  	CreatePullRequest(org, repo, title, body, head, base string, canModify bool) (int, error)
    49  	GetPullRequest(org, repo string, number int) (*github.PullRequest, error)
    50  	GetPullRequestPatch(org, repo string, number int) ([]byte, error)
    51  	GetRepo(owner, name string) (github.Repo, error)
    52  	IsMember(org, user string) (bool, error)
    53  	ListIssueComments(org, repo string, number int) ([]github.IssueComment, error)
    54  	ListOrgMembers(org, role string) ([]github.TeamMember, error)
    55  }
    56  
    57  // HelpProvider construct the pluginhelp.PluginHelp for this plugin.
    58  func HelpProvider(enabledRepos []string) (*pluginhelp.PluginHelp, error) {
    59  	pluginHelp := &pluginhelp.PluginHelp{
    60  		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 requester. If the parent PR contains a release note, it is copied to the cherrypick PR.`,
    61  	}
    62  	pluginHelp.AddCommand(pluginhelp.Command{
    63  		Usage:       "/cherrypick [branch]",
    64  		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).",
    65  		Featured:    true,
    66  		// depends on how the cherrypick server runs; needs auth by default (--allow-all=false)
    67  		WhoCanUse: "Members of the trusted organization for the repo.",
    68  		Examples:  []string{"/cherrypick release-3.9"},
    69  	})
    70  	return pluginHelp, nil
    71  }
    72  
    73  // Server implements http.Handler. It validates incoming GitHub webhooks and
    74  // then dispatches them to the appropriate plugins.
    75  type Server struct {
    76  	tokenGenerator func() []byte
    77  	botName        string
    78  	email          string
    79  
    80  	gc *git.Client
    81  	// Used for unit testing
    82  	push func(repo, newBranch string) error
    83  	ghc  githubClient
    84  	log  *logrus.Entry
    85  
    86  	// Use prow to assign users to cherrypicked PRs.
    87  	prowAssignments bool
    88  	// Allow anybody to do cherrypicks.
    89  	allowAll bool
    90  
    91  	bare     *http.Client
    92  	patchURL string
    93  
    94  	repoLock sync.Mutex
    95  	repos    []github.Repo
    96  }
    97  
    98  // ServeHTTP validates an incoming webhook and puts it into the event channel.
    99  func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
   100  	eventType, eventGUID, payload, ok, _ := github.ValidateWebhook(w, r, s.tokenGenerator())
   101  	if !ok {
   102  		return
   103  	}
   104  	fmt.Fprint(w, "Event received. Have a nice day.")
   105  
   106  	if err := s.handleEvent(eventType, eventGUID, payload); err != nil {
   107  		logrus.WithError(err).Error("Error parsing event.")
   108  	}
   109  }
   110  
   111  func (s *Server) handleEvent(eventType, eventGUID string, payload []byte) error {
   112  	l := logrus.WithFields(
   113  		logrus.Fields{
   114  			"event-type":     eventType,
   115  			github.EventGUID: eventGUID,
   116  		},
   117  	)
   118  	switch eventType {
   119  	case "issue_comment":
   120  		var ic github.IssueCommentEvent
   121  		if err := json.Unmarshal(payload, &ic); err != nil {
   122  			return err
   123  		}
   124  		go func() {
   125  			if err := s.handleIssueComment(l, ic); err != nil {
   126  				s.log.WithError(err).WithFields(l.Data).Info("Cherry-pick failed.")
   127  			}
   128  		}()
   129  	case "pull_request":
   130  		var pr github.PullRequestEvent
   131  		if err := json.Unmarshal(payload, &pr); err != nil {
   132  			return err
   133  		}
   134  		go func() {
   135  			if err := s.handlePullRequest(l, pr); err != nil {
   136  				s.log.WithError(err).WithFields(l.Data).Info("Cherry-pick failed.")
   137  			}
   138  		}()
   139  	default:
   140  		logrus.Debugf("skipping event of type %q", eventType)
   141  	}
   142  	return nil
   143  }
   144  
   145  func (s *Server) handleIssueComment(l *logrus.Entry, ic github.IssueCommentEvent) error {
   146  	// Only consider new comments in PRs.
   147  	if !ic.Issue.IsPullRequest() || ic.Action != github.IssueCommentActionCreated {
   148  		return nil
   149  	}
   150  
   151  	org := ic.Repo.Owner.Login
   152  	repo := ic.Repo.Name
   153  	num := ic.Issue.Number
   154  	commentAuthor := ic.Comment.User.Login
   155  
   156  	l = l.WithFields(logrus.Fields{
   157  		github.OrgLogField:  org,
   158  		github.RepoLogField: repo,
   159  		github.PrLogField:   num,
   160  	})
   161  
   162  	cherryPickMatches := cherryPickRe.FindAllStringSubmatch(ic.Comment.Body, -1)
   163  	if len(cherryPickMatches) == 0 || len(cherryPickMatches[0]) != 2 {
   164  		return nil
   165  	}
   166  	targetBranch := strings.TrimSpace(cherryPickMatches[0][1])
   167  
   168  	if ic.Issue.State != "closed" {
   169  		if !s.allowAll {
   170  			// Only members should be able to do cherry-picks.
   171  			ok, err := s.ghc.IsMember(org, commentAuthor)
   172  			if err != nil {
   173  				return err
   174  			}
   175  			if !ok {
   176  				resp := fmt.Sprintf("only [%s](https://github.com/orgs/%s/people) org members may request cherry picks. You can still do the cherry-pick manually.", org, org)
   177  				s.log.WithFields(l.Data).Info(resp)
   178  				return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(ic.Comment, resp))
   179  			}
   180  		}
   181  		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)
   182  		s.log.WithFields(l.Data).Info(resp)
   183  		return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(ic.Comment, resp))
   184  	}
   185  
   186  	pr, err := s.ghc.GetPullRequest(org, repo, num)
   187  	if err != nil {
   188  		return err
   189  	}
   190  	baseBranch := pr.Base.Ref
   191  	title := pr.Title
   192  	body := pr.Body
   193  
   194  	// Cherry-pick only merged PRs.
   195  	if !pr.Merged {
   196  		resp := "cannot cherry-pick an unmerged PR"
   197  		s.log.WithFields(l.Data).Info(resp)
   198  		return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(ic.Comment, resp))
   199  	}
   200  
   201  	// TODO: Use a whitelist for allowed base and target branches.
   202  	if baseBranch == targetBranch {
   203  		resp := fmt.Sprintf("base branch (%s) needs to differ from target branch (%s)", baseBranch, targetBranch)
   204  		s.log.WithFields(l.Data).Info(resp)
   205  		return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(ic.Comment, resp))
   206  	}
   207  
   208  	if !s.allowAll {
   209  		// Only org members should be able to do cherry-picks.
   210  		ok, err := s.ghc.IsMember(org, commentAuthor)
   211  		if err != nil {
   212  			return err
   213  		}
   214  		if !ok {
   215  			resp := fmt.Sprintf("only [%s](https://github.com/orgs/%s/people) org members may request cherry picks. You can still do the cherry-pick manually.", org, org)
   216  			s.log.WithFields(l.Data).Info(resp)
   217  			return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(ic.Comment, resp))
   218  		}
   219  	}
   220  
   221  	s.log.WithFields(l.Data).
   222  		WithField("requestor", ic.Comment.User.Login).
   223  		WithField("target_branch", targetBranch).
   224  		Debug("Cherrypick request.")
   225  	return s.handle(l, ic.Comment.User.Login, ic.Comment, org, repo, targetBranch, title, body, num)
   226  }
   227  
   228  func (s *Server) handlePullRequest(l *logrus.Entry, pre github.PullRequestEvent) error {
   229  	// Only consider newly merged PRs
   230  	if pre.Action != github.PullRequestActionClosed {
   231  		return nil
   232  	}
   233  
   234  	pr := pre.PullRequest
   235  	if !pr.Merged || pr.MergeSHA == nil {
   236  		return nil
   237  	}
   238  
   239  	org := pr.Base.Repo.Owner.Login
   240  	repo := pr.Base.Repo.Name
   241  	baseBranch := pr.Base.Ref
   242  	num := pr.Number
   243  	title := pr.Title
   244  	body := pr.Body
   245  
   246  	l = l.WithFields(logrus.Fields{
   247  		github.OrgLogField:  org,
   248  		github.RepoLogField: repo,
   249  		github.PrLogField:   num,
   250  	})
   251  
   252  	comments, err := s.ghc.ListIssueComments(org, repo, num)
   253  	if err != nil {
   254  		return err
   255  	}
   256  
   257  	// requestor -> target branch -> issue comment
   258  	requestorToComments := make(map[string]map[string]*github.IssueComment)
   259  	for i := range comments {
   260  		c := comments[i]
   261  		cherryPickMatches := cherryPickRe.FindAllStringSubmatch(c.Body, -1)
   262  		if len(cherryPickMatches) == 0 || len(cherryPickMatches[0]) != 2 {
   263  			continue
   264  		}
   265  		// TODO: Support comments with multiple cherrypick invocations.
   266  		targetBranch := strings.TrimSpace(cherryPickMatches[0][1])
   267  		if requestorToComments[c.User.Login] == nil {
   268  			requestorToComments[c.User.Login] = make(map[string]*github.IssueComment)
   269  		}
   270  		requestorToComments[c.User.Login][targetBranch] = &c
   271  	}
   272  	if len(requestorToComments) == 0 {
   273  		return nil
   274  	}
   275  	// Figure out membership.
   276  	if !s.allowAll {
   277  		// TODO: Possibly cache this.
   278  		members, err := s.ghc.ListOrgMembers(org, "all")
   279  		if err != nil {
   280  			return err
   281  		}
   282  		for requestor := range requestorToComments {
   283  			isMember := false
   284  			for _, m := range members {
   285  				if requestor == m.Login {
   286  					isMember = true
   287  					break
   288  				}
   289  			}
   290  			if !isMember {
   291  				delete(requestorToComments, requestor)
   292  			}
   293  		}
   294  	}
   295  
   296  	// Handle multiple comments serially. Make sure to filter out
   297  	// comments targeting the same branch.
   298  	handledBranches := make(map[string]bool)
   299  	for requestor, branches := range requestorToComments {
   300  		for targetBranch, ic := range branches {
   301  			if targetBranch == baseBranch {
   302  				resp := fmt.Sprintf("base branch (%s) needs to differ from target branch (%s)", baseBranch, targetBranch)
   303  				s.log.WithFields(l.Data).Info(resp)
   304  				s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(*ic, resp))
   305  				continue
   306  			}
   307  			if handledBranches[targetBranch] {
   308  				// Branch already handled. Skip.
   309  				continue
   310  			}
   311  			handledBranches[targetBranch] = true
   312  			s.log.WithFields(l.Data).
   313  				WithField("requestor", requestor).
   314  				WithField("target_branch", targetBranch).
   315  				Debug("Cherrypick request.")
   316  			err := s.handle(l, requestor, *ic, org, repo, targetBranch, title, body, num)
   317  			if err != nil {
   318  				return err
   319  			}
   320  		}
   321  	}
   322  	return nil
   323  }
   324  
   325  var cherryPickBranchFmt = "cherry-pick-%d-to-%s"
   326  
   327  func (s *Server) handle(l *logrus.Entry, requestor string, comment github.IssueComment, org, repo, targetBranch, title, body string, num int) error {
   328  	if err := s.ensureForkExists(org, repo); err != nil {
   329  		return err
   330  	}
   331  
   332  	// Clone the repo, checkout the target branch.
   333  	startClone := time.Now()
   334  	r, err := s.gc.Clone(org + "/" + repo)
   335  	if err != nil {
   336  		return err
   337  	}
   338  	defer func() {
   339  		if err := r.Clean(); err != nil {
   340  			s.log.WithError(err).WithFields(l.Data).Error("Error cleaning up repo.")
   341  		}
   342  	}()
   343  	if err := r.Checkout(targetBranch); err != nil {
   344  		resp := fmt.Sprintf("cannot checkout %s: %v", targetBranch, err)
   345  		s.log.WithFields(l.Data).Info(resp)
   346  		return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(comment, resp))
   347  	}
   348  	s.log.WithFields(l.Data).WithField("duration", time.Since(startClone)).Info("Cloned and checked out target branch.")
   349  
   350  	// Fetch the patch from Github
   351  	localPath, err := s.getPatch(org, repo, targetBranch, num)
   352  	if err != nil {
   353  		return err
   354  	}
   355  
   356  	if err := r.Config("user.name", s.botName); err != nil {
   357  		return err
   358  	}
   359  	email := s.email
   360  	if email == "" {
   361  		email = fmt.Sprintf("%s@localhost", s.botName)
   362  	}
   363  	if err := r.Config("user.email", email); err != nil {
   364  		return err
   365  	}
   366  
   367  	// Checkout a new branch for the cherry-pick.
   368  	newBranch := fmt.Sprintf(cherryPickBranchFmt, num, targetBranch)
   369  	if err := r.CheckoutNewBranch(newBranch); err != nil {
   370  		return err
   371  	}
   372  
   373  	// Apply the patch.
   374  	if err := r.Am(localPath); err != nil {
   375  		resp := fmt.Sprintf("#%d failed to apply on top of branch %q:\n```%v\n```", num, targetBranch, err)
   376  		s.log.WithFields(l.Data).Info(resp)
   377  		return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(comment, resp))
   378  	}
   379  
   380  	push := r.Push
   381  	if s.push != nil {
   382  		push = s.push
   383  	}
   384  	// Push the new branch in the bot's fork.
   385  	if err := push(repo, newBranch); err != nil {
   386  		resp := fmt.Sprintf("failed to push cherry-picked changes in Github: %v", err)
   387  		s.log.WithFields(l.Data).Info(resp)
   388  		return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(comment, resp))
   389  	}
   390  
   391  	// Open a PR in Github.
   392  	title = fmt.Sprintf("[%s] %s", targetBranch, title)
   393  	cherryPickBody := fmt.Sprintf("This is an automated cherry-pick of #%d", num)
   394  	if s.prowAssignments {
   395  		cherryPickBody = fmt.Sprintf("%s\n\n/assign %s", cherryPickBody, requestor)
   396  	}
   397  	if releaseNote := releaseNoteFromParentPR(body); len(releaseNote) != 0 {
   398  		cherryPickBody = fmt.Sprintf("%s\n\n%s", cherryPickBody, releaseNote)
   399  	}
   400  
   401  	head := fmt.Sprintf("%s:%s", s.botName, newBranch)
   402  	createdNum, err := s.ghc.CreatePullRequest(org, repo, title, cherryPickBody, head, targetBranch, true)
   403  	if err != nil {
   404  		resp := fmt.Sprintf("new pull request could not be created: %v", err)
   405  		s.log.WithFields(l.Data).Info(resp)
   406  		return s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(comment, resp))
   407  	}
   408  	resp := fmt.Sprintf("new pull request created: #%d", createdNum)
   409  	s.log.WithFields(l.Data).Info(resp)
   410  	if err := s.ghc.CreateComment(org, repo, num, plugins.FormatICResponse(comment, resp)); err != nil {
   411  		return err
   412  	}
   413  	if !s.prowAssignments {
   414  		if err := s.ghc.AssignIssue(org, repo, createdNum, []string{comment.User.Login}); err != nil {
   415  			s.log.WithFields(l.Data).Warningf("Cannot assign to new PR: %v", err)
   416  			// Ignore returning errors on failure to assign as this is most likely
   417  			// due to users not being members of the org so that they can be assigned
   418  			// in PRs.
   419  			return nil
   420  		}
   421  	}
   422  	return nil
   423  }
   424  
   425  // ensureForkExists ensures a fork of org/repo exists for the bot.
   426  func (s *Server) ensureForkExists(org, repo string) error {
   427  	s.repoLock.Lock()
   428  	defer s.repoLock.Unlock()
   429  
   430  	// Fork repo if it doesn't exist.
   431  	fork := s.botName + "/" + repo
   432  	if !repoExists(fork, s.repos) {
   433  		if err := s.ghc.CreateFork(org, repo); err != nil {
   434  			return fmt.Errorf("cannot fork %s/%s: %v", org, repo, err)
   435  		}
   436  		if err := waitForRepo(s.botName, repo, s.ghc); err != nil {
   437  			return fmt.Errorf("fork of %s/%s cannot show up on Github: %v", org, repo, err)
   438  		}
   439  		s.repos = append(s.repos, github.Repo{FullName: fork, Fork: true})
   440  	}
   441  	return nil
   442  }
   443  
   444  func waitForRepo(owner, name string, ghc githubClient) error {
   445  	// Wait for at most 5 minutes for the fork to appear on Github.
   446  	after := time.After(5 * time.Minute)
   447  	tick := time.Tick(5 * time.Second)
   448  
   449  	var ghErr string
   450  	for {
   451  		select {
   452  		case <-tick:
   453  			repo, err := ghc.GetRepo(owner, name)
   454  			if err != nil {
   455  				ghErr = fmt.Sprintf(": %v", err)
   456  				logrus.WithError(err).Warn("Error getting bot repository.")
   457  				continue
   458  			}
   459  			ghErr = ""
   460  			if repoExists(owner+"/"+name, []github.Repo{repo}) {
   461  				return nil
   462  			}
   463  		case <-after:
   464  			return fmt.Errorf("timed out waiting for %s to appear on Github%s", owner+"/"+name, ghErr)
   465  		}
   466  	}
   467  }
   468  
   469  func repoExists(repo string, repos []github.Repo) bool {
   470  	for _, r := range repos {
   471  		if !r.Fork {
   472  			continue
   473  		}
   474  		if r.FullName == repo {
   475  			return true
   476  		}
   477  	}
   478  	return false
   479  }
   480  
   481  // getPatch gets the patch for the provided PR and creates a local
   482  // copy of it. It returns its location in the filesystem and any
   483  // encountered error.
   484  func (s *Server) getPatch(org, repo, targetBranch string, num int) (string, error) {
   485  	patch, err := s.ghc.GetPullRequestPatch(org, repo, num)
   486  	if err != nil {
   487  		return "", err
   488  	}
   489  	localPath := fmt.Sprintf("/tmp/%s_%s_%d_%s.patch", org, repo, num, normalize(targetBranch))
   490  	out, err := os.Create(localPath)
   491  	if err != nil {
   492  		return "", err
   493  	}
   494  	defer out.Close()
   495  	if _, err := io.Copy(out, bytes.NewBuffer(patch)); err != nil {
   496  		return "", err
   497  	}
   498  	return localPath, nil
   499  }
   500  
   501  func normalize(input string) string {
   502  	return strings.Replace(input, "/", "-", -1)
   503  }
   504  
   505  // releaseNoteNoteFromParentPR gets the release note from the
   506  // parent PR and formats it as per the PR template so that
   507  // it can be copied to the cherry-pick PR.
   508  func releaseNoteFromParentPR(body string) string {
   509  	potentialMatch := releaseNoteRe.FindStringSubmatch(body)
   510  	if potentialMatch == nil {
   511  		return ""
   512  	}
   513  	return fmt.Sprintf("```release-note\n%s\n```", strings.TrimSpace(potentialMatch[1]))
   514  }