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