github.com/munnerz/test-infra@v0.0.0-20190108210205-ce3d181dc989/prow/plugins/lgtm/lgtm.go (about)

     1  /*
     2  Copyright 2016 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 lgtm implements the lgtm plugin
    18  package lgtm
    19  
    20  import (
    21  	"fmt"
    22  	"regexp"
    23  	"strings"
    24  
    25  	"github.com/sirupsen/logrus"
    26  	"k8s.io/apimachinery/pkg/util/sets"
    27  
    28  	"k8s.io/test-infra/prow/github"
    29  	"k8s.io/test-infra/prow/labels"
    30  	"k8s.io/test-infra/prow/pluginhelp"
    31  	"k8s.io/test-infra/prow/plugins"
    32  	"k8s.io/test-infra/prow/repoowners"
    33  )
    34  
    35  const (
    36  	// PluginName defines this plugin's registered name.
    37  	PluginName = labels.LGTM
    38  )
    39  
    40  var (
    41  	addLGTMLabelNotification   = "LGTM label has been added.  <details>Git tree hash: %s</details>"
    42  	addLGTMLabelNotificationRe = regexp.MustCompile(fmt.Sprintf(addLGTMLabelNotification, "(.*)"))
    43  	// LGTMLabel is the name of the lgtm label applied by the lgtm plugin
    44  	LGTMLabel           = labels.LGTM
    45  	lgtmRe              = regexp.MustCompile(`(?mi)^/lgtm(?: no-issue)?\s*$`)
    46  	lgtmCancelRe        = regexp.MustCompile(`(?mi)^/lgtm cancel\s*$`)
    47  	removeLGTMLabelNoti = "New changes are detected. LGTM label has been removed."
    48  )
    49  
    50  type commentPruner interface {
    51  	PruneComments(shouldPrune func(github.IssueComment) bool)
    52  }
    53  
    54  func init() {
    55  	plugins.RegisterGenericCommentHandler(PluginName, handleGenericCommentEvent, helpProvider)
    56  	plugins.RegisterPullRequestHandler(PluginName, func(pc plugins.Agent, pe github.PullRequestEvent) error {
    57  		return handlePullRequestEvent(pc, pe)
    58  	}, helpProvider)
    59  	plugins.RegisterReviewEventHandler(PluginName, handlePullRequestReviewEvent, helpProvider)
    60  }
    61  
    62  func helpProvider(config *plugins.Configuration, enabledRepos []string) (*pluginhelp.PluginHelp, error) {
    63  	// The Config field is omitted because this plugin is not configurable.
    64  	pluginHelp := &pluginhelp.PluginHelp{
    65  		Description: "The lgtm plugin manages the application and removal of the 'lgtm' (Looks Good To Me) label which is typically used to gate merging.",
    66  	}
    67  	pluginHelp.AddCommand(pluginhelp.Command{
    68  		Usage:       "/lgtm [cancel] or Github Review action",
    69  		Description: "Adds or removes the 'lgtm' label which is typically used to gate merging.",
    70  		Featured:    true,
    71  		WhoCanUse:   "Collaborators on the repository. '/lgtm cancel' can be used additionally by the PR author.",
    72  		Examples:    []string{"/lgtm", "/lgtm cancel", "<a href=\"https://help.github.com/articles/about-pull-request-reviews/\">'Approve' or 'Request Changes'</a>"},
    73  	})
    74  	return pluginHelp, nil
    75  }
    76  
    77  // optionsForRepo gets the plugins.Lgtm struct that is applicable to the indicated repo.
    78  func optionsForRepo(config *plugins.Configuration, org, repo string) *plugins.Lgtm {
    79  	fullName := fmt.Sprintf("%s/%s", org, repo)
    80  	for i := range config.Lgtm {
    81  		if !strInSlice(org, config.Lgtm[i].Repos) && !strInSlice(fullName, config.Lgtm[i].Repos) {
    82  			continue
    83  		}
    84  		return &config.Lgtm[i]
    85  	}
    86  	return &plugins.Lgtm{}
    87  }
    88  
    89  // strInSlice returns true if any string in slice matches str exactly
    90  func strInSlice(str string, slice []string) bool {
    91  	for _, elem := range slice {
    92  		if elem == str {
    93  			return true
    94  		}
    95  	}
    96  	return false
    97  }
    98  
    99  type githubClient interface {
   100  	IsCollaborator(owner, repo, login string) (bool, error)
   101  	AddLabel(owner, repo string, number int, label string) error
   102  	AssignIssue(owner, repo string, number int, assignees []string) error
   103  	CreateComment(owner, repo string, number int, comment string) error
   104  	RemoveLabel(owner, repo string, number int, label string) error
   105  	GetIssueLabels(org, repo string, number int) ([]github.Label, error)
   106  	GetPullRequest(org, repo string, number int) (*github.PullRequest, error)
   107  	GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error)
   108  	ListIssueComments(org, repo string, number int) ([]github.IssueComment, error)
   109  	DeleteComment(org, repo string, ID int) error
   110  	BotName() (string, error)
   111  	GetSingleCommit(org, repo, SHA string) (github.SingleCommit, error)
   112  	IsMember(org, user string) (bool, error)
   113  	ListTeams(org string) ([]github.Team, error)
   114  	ListTeamMembers(id int, role string) ([]github.TeamMember, error)
   115  }
   116  
   117  // reviewCtx contains information about each review event
   118  type reviewCtx struct {
   119  	author, issueAuthor, body, htmlURL string
   120  	repo                               github.Repo
   121  	assignees                          []github.User
   122  	number                             int
   123  }
   124  
   125  func handleGenericCommentEvent(pc plugins.Agent, e github.GenericCommentEvent) error {
   126  	cp, err := pc.CommentPruner()
   127  	if err != nil {
   128  		return err
   129  	}
   130  	return handleGenericComment(pc.GitHubClient, pc.PluginConfig, pc.OwnersClient, pc.Logger, cp, e)
   131  }
   132  
   133  func handlePullRequestEvent(pc plugins.Agent, pre github.PullRequestEvent) error {
   134  	return handlePullRequest(
   135  		pc.Logger,
   136  		pc.GitHubClient,
   137  		pc.PluginConfig,
   138  		&pre,
   139  	)
   140  }
   141  
   142  func handlePullRequestReviewEvent(pc plugins.Agent, e github.ReviewEvent) error {
   143  	// If ReviewActsAsLgtm is disabled, ignore review event.
   144  	opts := optionsForRepo(pc.PluginConfig, e.Repo.Owner.Login, e.Repo.Name)
   145  	if !opts.ReviewActsAsLgtm {
   146  		return nil
   147  	}
   148  	cp, err := pc.CommentPruner()
   149  	if err != nil {
   150  		return err
   151  	}
   152  	return handlePullRequestReview(pc.GitHubClient, pc.PluginConfig, pc.OwnersClient, pc.Logger, cp, e)
   153  }
   154  
   155  func handleGenericComment(gc githubClient, config *plugins.Configuration, ownersClient repoowners.Interface, log *logrus.Entry, cp commentPruner, e github.GenericCommentEvent) error {
   156  	rc := reviewCtx{
   157  		author:      e.User.Login,
   158  		issueAuthor: e.IssueAuthor.Login,
   159  		body:        e.Body,
   160  		htmlURL:     e.HTMLURL,
   161  		repo:        e.Repo,
   162  		assignees:   e.Assignees,
   163  		number:      e.Number,
   164  	}
   165  
   166  	// Only consider open PRs and new comments.
   167  	if !e.IsPR || e.IssueState != "open" || e.Action != github.GenericCommentActionCreated {
   168  		return nil
   169  	}
   170  
   171  	// If we create an "/lgtm" comment, add lgtm if necessary.
   172  	// If we create a "/lgtm cancel" comment, remove lgtm if necessary.
   173  	wantLGTM := false
   174  	if lgtmRe.MatchString(rc.body) {
   175  		wantLGTM = true
   176  	} else if lgtmCancelRe.MatchString(rc.body) {
   177  		wantLGTM = false
   178  	} else {
   179  		return nil
   180  	}
   181  
   182  	// use common handler to do the rest
   183  	return handle(wantLGTM, config, ownersClient, rc, gc, log, cp)
   184  }
   185  
   186  func handlePullRequestReview(gc githubClient, config *plugins.Configuration, ownersClient repoowners.Interface, log *logrus.Entry, cp commentPruner, e github.ReviewEvent) error {
   187  	rc := reviewCtx{
   188  		author:      e.Review.User.Login,
   189  		issueAuthor: e.PullRequest.User.Login,
   190  		repo:        e.Repo,
   191  		assignees:   e.PullRequest.Assignees,
   192  		number:      e.PullRequest.Number,
   193  		body:        e.Review.Body,
   194  		htmlURL:     e.Review.HTMLURL,
   195  	}
   196  
   197  	// If the review event body contains an '/lgtm' or '/lgtm cancel' comment,
   198  	// skip handling the review event
   199  	if lgtmRe.MatchString(rc.body) || lgtmCancelRe.MatchString(rc.body) {
   200  		return nil
   201  	}
   202  
   203  	// The review webhook returns state as lowercase, while the review API
   204  	// returns state as uppercase. Uppercase the value here so it always
   205  	// matches the constant.
   206  	reviewState := github.ReviewState(strings.ToUpper(string(e.Review.State)))
   207  
   208  	// If we review with Approve, add lgtm if necessary.
   209  	// If we review with Request Changes, remove lgtm if necessary.
   210  	wantLGTM := false
   211  	if reviewState == github.ReviewStateApproved {
   212  		wantLGTM = true
   213  	} else if reviewState == github.ReviewStateChangesRequested {
   214  		wantLGTM = false
   215  	} else {
   216  		return nil
   217  	}
   218  
   219  	// use common handler to do the rest
   220  	return handle(wantLGTM, config, ownersClient, rc, gc, log, cp)
   221  }
   222  
   223  func handle(wantLGTM bool, config *plugins.Configuration, ownersClient repoowners.Interface, rc reviewCtx, gc githubClient, log *logrus.Entry, cp commentPruner) error {
   224  	author := rc.author
   225  	issueAuthor := rc.issueAuthor
   226  	assignees := rc.assignees
   227  	number := rc.number
   228  	body := rc.body
   229  	htmlURL := rc.htmlURL
   230  	org := rc.repo.Owner.Login
   231  	repoName := rc.repo.Name
   232  
   233  	// Author cannot LGTM own PR, comment and abort
   234  	isAuthor := author == issueAuthor
   235  	if isAuthor && wantLGTM {
   236  		resp := "you cannot LGTM your own PR."
   237  		log.Infof("Commenting with \"%s\".", resp)
   238  		return gc.CreateComment(rc.repo.Owner.Login, rc.repo.Name, rc.number, plugins.FormatResponseRaw(rc.body, rc.htmlURL, rc.author, resp))
   239  	}
   240  
   241  	// Determine if reviewer is already assigned
   242  	isAssignee := false
   243  	for _, assignee := range assignees {
   244  		if assignee.Login == author {
   245  			isAssignee = true
   246  			break
   247  		}
   248  	}
   249  
   250  	// check if skip collaborators is enabled for this org/repo
   251  	skipCollaborators := skipCollaborators(config, org, repoName)
   252  
   253  	// either ensure that the commentor is a collaborator or an approver/reviwer
   254  	if !isAuthor && !isAssignee && !skipCollaborators {
   255  		// in this case we need to ensure the commentor is assignable to the PR
   256  		// by assigning them
   257  		log.Infof("Assigning %s/%s#%d to %s", org, repoName, number, author)
   258  		if err := gc.AssignIssue(org, repoName, number, []string{author}); err != nil {
   259  			msg := "assigning you to the PR failed"
   260  			if ok, merr := gc.IsCollaborator(org, repoName, author); merr == nil && !ok {
   261  				msg = fmt.Sprintf("only %s/%s repo collaborators may be assigned issues", org, repoName)
   262  			} else if merr != nil {
   263  				log.WithError(merr).Error("Failed to check if author is a collaborator.")
   264  			} else {
   265  				log.WithError(err).Error("Failed to assign issue to author.")
   266  			}
   267  			resp := "changing LGTM is restricted to assignees, and " + msg + "."
   268  			log.Infof("Reply to assign via /lgtm request with comment: \"%s\"", resp)
   269  			return gc.CreateComment(org, repoName, number, plugins.FormatResponseRaw(body, htmlURL, author, resp))
   270  		}
   271  	} else if !isAuthor && skipCollaborators {
   272  		// in this case we depend on OWNERS files instead to check if the author
   273  		// is an approver or reviwer of the changed files
   274  		log.Debugf("Skipping collaborator checks and loading OWNERS for %s/%s#%d", org, repoName, number)
   275  		ro, err := loadRepoOwners(gc, ownersClient, org, repoName, number)
   276  		if err != nil {
   277  			return err
   278  		}
   279  		filenames, err := getChangedFiles(gc, org, repoName, number)
   280  		if err != nil {
   281  			return err
   282  		}
   283  		if !loadReviewers(ro, filenames).Has(github.NormLogin(author)) {
   284  			resp := "adding LGTM is restricted to approvers and reviewers in OWNERS files."
   285  			log.Infof("Reply to /lgtm request with comment: \"%s\"", resp)
   286  			return gc.CreateComment(org, repoName, number, plugins.FormatResponseRaw(body, htmlURL, author, resp))
   287  		}
   288  	}
   289  
   290  	// now we update the LGTM labels, having checked all cases where changing
   291  	// LGTM was not allowed for the commentor
   292  
   293  	// Only add the label if it doesn't have it, and vice versa.
   294  	labels, err := gc.GetIssueLabels(org, repoName, number)
   295  	if err != nil {
   296  		log.WithError(err).Error("Failed to get issue labels.")
   297  	}
   298  	hasLGTM := github.HasLabel(LGTMLabel, labels)
   299  
   300  	// remove the label if necessary, we're done after this
   301  	opts := optionsForRepo(config, rc.repo.Owner.Login, rc.repo.Name)
   302  	if hasLGTM && !wantLGTM {
   303  		log.Info("Removing LGTM label.")
   304  		if err := gc.RemoveLabel(org, repoName, number, LGTMLabel); err != nil {
   305  			return err
   306  		}
   307  		if opts.StoreTreeHash {
   308  			cp.PruneComments(func(comment github.IssueComment) bool {
   309  				return addLGTMLabelNotificationRe.MatchString(comment.Body)
   310  			})
   311  		}
   312  	} else if !hasLGTM && wantLGTM {
   313  		log.Info("Adding LGTM label.")
   314  		if err := gc.AddLabel(org, repoName, number, LGTMLabel); err != nil {
   315  			return err
   316  		}
   317  		if !stickyLgtm(log, gc, config, opts, issueAuthor, org, repoName) {
   318  			if opts.StoreTreeHash {
   319  				pr, err := gc.GetPullRequest(org, repoName, number)
   320  				if err != nil {
   321  					log.WithError(err).Error("Failed to get pull request.")
   322  				}
   323  				commit, err := gc.GetSingleCommit(org, repoName, pr.Head.SHA)
   324  				if err != nil {
   325  					log.WithField("sha", pr.Head.SHA).WithError(err).Error("Failed to get commit.")
   326  				}
   327  				treeHash := commit.Commit.Tree.SHA
   328  				log.WithField("tree", treeHash).Info("Adding comment to store tree-hash.")
   329  				if err := gc.CreateComment(org, repoName, number, fmt.Sprintf(addLGTMLabelNotification, treeHash)); err != nil {
   330  					log.WithError(err).Error("Failed to add comment.")
   331  				}
   332  			}
   333  			// Delete the LGTM removed noti after the LGTM label is added.
   334  			cp.PruneComments(func(comment github.IssueComment) bool {
   335  				return strings.Contains(comment.Body, removeLGTMLabelNoti)
   336  			})
   337  		}
   338  	}
   339  
   340  	return nil
   341  }
   342  
   343  func stickyLgtm(log *logrus.Entry, gc githubClient, config *plugins.Configuration, lgtm *plugins.Lgtm, author, org, repo string) bool {
   344  	if len(lgtm.StickyLgtmTeam) > 0 {
   345  		if teams, err := gc.ListTeams(org); err == nil {
   346  			for _, teamInOrg := range teams {
   347  				// lgtm.TrustedAuthorTeams is supposed to be a very short list.
   348  				if strings.Compare(teamInOrg.Name, lgtm.StickyLgtmTeam) == 0 {
   349  					if members, err := gc.ListTeamMembers(teamInOrg.ID, github.RoleAll); err == nil {
   350  						for _, member := range members {
   351  							if strings.Compare(member.Login, author) == 0 {
   352  								// The author is in a trusted team
   353  								return true
   354  							}
   355  						}
   356  					} else {
   357  						log.WithError(err).Errorf("Failed to list members in %s:%s.", org, teamInOrg.Name)
   358  					}
   359  				}
   360  			}
   361  		} else {
   362  			log.WithError(err).Errorf("Failed to list teams in org %s.", org)
   363  		}
   364  	}
   365  	return false
   366  }
   367  
   368  func handlePullRequest(log *logrus.Entry, gc githubClient, config *plugins.Configuration, pe *github.PullRequestEvent) error {
   369  	if pe.PullRequest.Merged {
   370  		return nil
   371  	}
   372  
   373  	if pe.Action != github.PullRequestActionSynchronize {
   374  		return nil
   375  	}
   376  
   377  	org := pe.PullRequest.Base.Repo.Owner.Login
   378  	repo := pe.PullRequest.Base.Repo.Name
   379  	number := pe.PullRequest.Number
   380  
   381  	opts := optionsForRepo(config, org, repo)
   382  	if stickyLgtm(log, gc, config, opts, pe.PullRequest.User.Login, org, repo) {
   383  		// If the author is trusted, skip tree hash verification and LGTM removal.
   384  		return nil
   385  	}
   386  
   387  	// If we don't have the lgtm label, we don't need to check anything
   388  	labels, err := gc.GetIssueLabels(org, repo, number)
   389  	if err != nil {
   390  		log.WithError(err).Error("Failed to get labels.")
   391  	}
   392  	if !github.HasLabel(LGTMLabel, labels) {
   393  		return nil
   394  	}
   395  
   396  	if opts.StoreTreeHash {
   397  		// Check if we have a tree-hash comment
   398  		var lastLgtmTreeHash string
   399  		botname, err := gc.BotName()
   400  		if err != nil {
   401  			return err
   402  		}
   403  		comments, err := gc.ListIssueComments(org, repo, number)
   404  		if err != nil {
   405  			log.WithError(err).Error("Failed to get issue comments.")
   406  		}
   407  		for _, comment := range comments {
   408  			m := addLGTMLabelNotificationRe.FindStringSubmatch(comment.Body)
   409  			if comment.User.Login == botname && m != nil && comment.UpdatedAt.Equal(comment.CreatedAt) {
   410  				lastLgtmTreeHash = m[1]
   411  				break
   412  			}
   413  		}
   414  		if lastLgtmTreeHash != "" {
   415  			// Get the current tree-hash
   416  			commit, err := gc.GetSingleCommit(org, repo, pe.PullRequest.Head.SHA)
   417  			if err != nil {
   418  				log.WithField("sha", pe.PullRequest.Head.SHA).WithError(err).Error("Failed to get commit.")
   419  			}
   420  			treeHash := commit.Commit.Tree.SHA
   421  			if treeHash == lastLgtmTreeHash {
   422  				// Don't remove the label, PR code hasn't changed
   423  				log.Infof("Keeping LGTM label as the tree-hash remained the same: %s", treeHash)
   424  				return nil
   425  			}
   426  		}
   427  	}
   428  
   429  	if err := gc.RemoveLabel(org, repo, number, LGTMLabel); err != nil {
   430  		return fmt.Errorf("failed removing lgtm label: %v", err)
   431  	}
   432  
   433  	// Create a comment to inform participants that LGTM label is removed due to new
   434  	// pull request changes.
   435  	log.Infof("Commenting with an LGTM removed notification to %s/%s#%d with a message: %s", org, repo, number, removeLGTMLabelNoti)
   436  	return gc.CreateComment(org, repo, number, removeLGTMLabelNoti)
   437  }
   438  
   439  func skipCollaborators(config *plugins.Configuration, org, repo string) bool {
   440  	full := fmt.Sprintf("%s/%s", org, repo)
   441  	for _, elem := range config.Owners.SkipCollaborators {
   442  		if elem == org || elem == full {
   443  			return true
   444  		}
   445  	}
   446  	return false
   447  }
   448  
   449  func loadRepoOwners(gc githubClient, ownersClient repoowners.Interface, org, repo string, number int) (repoowners.RepoOwner, error) {
   450  	pr, err := gc.GetPullRequest(org, repo, number)
   451  	if err != nil {
   452  		return nil, err
   453  	}
   454  	return ownersClient.LoadRepoOwners(org, repo, pr.Base.Ref)
   455  }
   456  
   457  // getChangedFiles returns all the changed files for the provided pull request.
   458  func getChangedFiles(gc githubClient, org, repo string, number int) ([]string, error) {
   459  	changes, err := gc.GetPullRequestChanges(org, repo, number)
   460  	if err != nil {
   461  		return nil, fmt.Errorf("cannot get PR changes for %s/%s#%d", org, repo, number)
   462  	}
   463  	var filenames []string
   464  	for _, change := range changes {
   465  		filenames = append(filenames, change.Filename)
   466  	}
   467  	return filenames, nil
   468  }
   469  
   470  // loadReviewers returns all reviewers and approvers from all OWNERS files that
   471  // cover the provided filenames.
   472  func loadReviewers(ro repoowners.RepoOwner, filenames []string) sets.String {
   473  	reviewers := sets.String{}
   474  	for _, filename := range filenames {
   475  		reviewers = reviewers.Union(ro.Approvers(filename)).Union(ro.Reviewers(filename))
   476  	}
   477  	return reviewers
   478  }