github.com/abayer/test-infra@v0.0.5/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
    18  
    19  import (
    20  	"fmt"
    21  	"regexp"
    22  
    23  	"github.com/sirupsen/logrus"
    24  	"k8s.io/apimachinery/pkg/util/sets"
    25  
    26  	"k8s.io/test-infra/prow/github"
    27  	"k8s.io/test-infra/prow/pluginhelp"
    28  	"k8s.io/test-infra/prow/plugins"
    29  	"k8s.io/test-infra/prow/repoowners"
    30  )
    31  
    32  const pluginName = "lgtm"
    33  
    34  var (
    35  	lgtmLabel           = "lgtm"
    36  	lgtmRe              = regexp.MustCompile(`(?mi)^/lgtm(?: no-issue)?\s*$`)
    37  	lgtmCancelRe        = regexp.MustCompile(`(?mi)^/lgtm cancel\s*$`)
    38  	removeLGTMLabelNoti = "New changes are detected. LGTM label has been removed."
    39  )
    40  
    41  func init() {
    42  	plugins.RegisterGenericCommentHandler(pluginName, handleGenericCommentEvent, helpProvider)
    43  	plugins.RegisterPullRequestHandler(pluginName, func(pc plugins.PluginClient, pe github.PullRequestEvent) error {
    44  		return handlePullRequest(pc.GitHubClient, pe, pc.Logger)
    45  	}, helpProvider)
    46  	plugins.RegisterReviewEventHandler(pluginName, handlePullRequestReviewEvent, helpProvider)
    47  }
    48  
    49  func helpProvider(config *plugins.Configuration, enabledRepos []string) (*pluginhelp.PluginHelp, error) {
    50  	// The Config field is omitted because this plugin is not configurable.
    51  	pluginHelp := &pluginhelp.PluginHelp{
    52  		Description: "The lgtm plugin manages the application and removal of the 'lgtm' (Looks Good To Me) label which is typically used to gate merging.",
    53  	}
    54  	pluginHelp.AddCommand(pluginhelp.Command{
    55  		Usage:       "/lgtm [cancel] or Github Review action",
    56  		Description: "Adds or removes the 'lgtm' label which is typically used to gate merging.",
    57  		Featured:    true,
    58  		WhoCanUse:   "Collaborators on the repository. '/lgtm cancel' can be used additionally by the PR author.",
    59  		Examples:    []string{"/lgtm", "/lgtm cancel", "toggle the Review button to 'Approve' or 'Request Changes' in the github GUI"},
    60  	})
    61  	return pluginHelp, nil
    62  }
    63  
    64  // optionsForRepo gets the plugins.Lgtm struct that is applicable to the indicated repo.
    65  func optionsForRepo(config *plugins.Configuration, org, repo string) *plugins.Lgtm {
    66  	fullName := fmt.Sprintf("%s/%s", org, repo)
    67  	for i := range config.Lgtm {
    68  		if !strInSlice(org, config.Lgtm[i].Repos) && !strInSlice(fullName, config.Lgtm[i].Repos) {
    69  			continue
    70  		}
    71  		return &config.Lgtm[i]
    72  	}
    73  	return &plugins.Lgtm{}
    74  }
    75  func strInSlice(str string, slice []string) bool {
    76  	for _, elem := range slice {
    77  		if elem == str {
    78  			return true
    79  		}
    80  	}
    81  	return false
    82  }
    83  
    84  type githubClient interface {
    85  	IsCollaborator(owner, repo, login string) (bool, error)
    86  	AddLabel(owner, repo string, number int, label string) error
    87  	AssignIssue(owner, repo string, number int, assignees []string) error
    88  	CreateComment(owner, repo string, number int, comment string) error
    89  	RemoveLabel(owner, repo string, number int, label string) error
    90  	GetIssueLabels(org, repo string, number int) ([]github.Label, error)
    91  	GetPullRequest(org, repo string, number int) (*github.PullRequest, error)
    92  	GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error)
    93  	ListIssueComments(org, repo string, number int) ([]github.IssueComment, error)
    94  	DeleteComment(org, repo string, ID int) error
    95  	BotName() (string, error)
    96  }
    97  
    98  // reviewCtx contains information about each review event
    99  type reviewCtx struct {
   100  	author, issueAuthor, body, htmlURL string
   101  	repo                               github.Repo
   102  	assignees                          []github.User
   103  	number                             int
   104  }
   105  
   106  func handleGenericCommentEvent(pc plugins.PluginClient, e github.GenericCommentEvent) error {
   107  	return handleGenericComment(pc.GitHubClient, pc.PluginConfig, pc.OwnersClient, pc.Logger, e)
   108  }
   109  
   110  func handlePullRequestReviewEvent(pc plugins.PluginClient, e github.ReviewEvent) error {
   111  	// If ReviewActsAsLgtm is disabled, ignore review event.
   112  	opts := optionsForRepo(pc.PluginConfig, e.Repo.Owner.Login, e.Repo.Name)
   113  	if !opts.ReviewActsAsLgtm {
   114  		return nil
   115  	}
   116  	return handlePullRequestReview(pc.GitHubClient, pc.PluginConfig, pc.OwnersClient, pc.Logger, e)
   117  }
   118  
   119  func handleGenericComment(gc githubClient, config *plugins.Configuration, ownersClient repoowners.Interface, log *logrus.Entry, e github.GenericCommentEvent) error {
   120  	rc := reviewCtx{
   121  		author:      e.User.Login,
   122  		issueAuthor: e.IssueAuthor.Login,
   123  		body:        e.Body,
   124  		htmlURL:     e.HTMLURL,
   125  		repo:        e.Repo,
   126  		assignees:   e.Assignees,
   127  		number:      e.Number,
   128  	}
   129  
   130  	// Only consider open PRs and new comments.
   131  	if !e.IsPR || e.IssueState != "open" || e.Action != github.GenericCommentActionCreated {
   132  		return nil
   133  	}
   134  
   135  	// If we create an "/lgtm" comment, add lgtm if necessary.
   136  	// If we create a "/lgtm cancel" comment, remove lgtm if necessary.
   137  	wantLGTM := false
   138  	if lgtmRe.MatchString(rc.body) {
   139  		wantLGTM = true
   140  	} else if lgtmCancelRe.MatchString(rc.body) {
   141  		wantLGTM = false
   142  	} else {
   143  		return nil
   144  	}
   145  
   146  	// Author cannot LGTM own PR
   147  	isAuthor := rc.author == rc.issueAuthor
   148  	if isAuthor && wantLGTM {
   149  		resp := "you cannot LGTM your own PR."
   150  		log.Infof("Commenting with \"%s\".", resp)
   151  		return gc.CreateComment(rc.repo.Owner.Login, rc.repo.Name, rc.number, plugins.FormatResponseRaw(rc.body, rc.htmlURL, rc.author, resp))
   152  	}
   153  
   154  	return handle(wantLGTM, config, ownersClient, rc, gc, log)
   155  }
   156  
   157  func handlePullRequestReview(gc githubClient, config *plugins.Configuration, ownersClient repoowners.Interface, log *logrus.Entry, e github.ReviewEvent) error {
   158  	rc := reviewCtx{
   159  		author:      e.Review.User.Login,
   160  		issueAuthor: e.PullRequest.User.Login,
   161  		repo:        e.Repo,
   162  		assignees:   e.PullRequest.Assignees,
   163  		number:      e.PullRequest.Number,
   164  		body:        e.Review.Body,
   165  		htmlURL:     e.Review.HTMLURL,
   166  	}
   167  
   168  	// If the review event body contains an '/lgtm' or '/lgtm cancel' comment,
   169  	// skip handling the review event
   170  	if lgtmRe.MatchString(rc.body) || lgtmCancelRe.MatchString(rc.body) {
   171  		return nil
   172  	}
   173  
   174  	// If we review with Approve, add lgtm if necessary.
   175  	// If we review with Request Changes, remove lgtm if necessary.
   176  	wantLGTM := false
   177  	if e.Review.State == github.ReviewStateApproved {
   178  		wantLGTM = true
   179  	} else if e.Review.State == github.ReviewStateChangesRequested {
   180  		wantLGTM = false
   181  	} else {
   182  		return nil
   183  	}
   184  	return handle(wantLGTM, config, ownersClient, rc, gc, log)
   185  }
   186  
   187  func handle(wantLGTM bool, config *plugins.Configuration, ownersClient repoowners.Interface, rc reviewCtx, gc githubClient, log *logrus.Entry) error {
   188  	author := rc.author
   189  	issueAuthor := rc.issueAuthor
   190  	assignees := rc.assignees
   191  	number := rc.number
   192  	body := rc.body
   193  	htmlURL := rc.htmlURL
   194  	org := rc.repo.Owner.Login
   195  	repoName := rc.repo.Name
   196  
   197  	// Determine if reviewer is already assigned
   198  	isAssignee := false
   199  	for _, assignee := range assignees {
   200  		if assignee.Login == author {
   201  			isAssignee = true
   202  			break
   203  		}
   204  	}
   205  	// If we need to skip collaborator checks for this repo, what we actually need
   206  	// to do is skip assignment checks and use OWNERS files to determine whether the
   207  	// commenter can lgtm the PR.
   208  	skipCollaborators := skipCollaborators(config, org, repoName)
   209  	isAuthor := author == issueAuthor
   210  	if isAuthor && wantLGTM {
   211  		resp := "you cannot LGTM your own PR."
   212  		log.Infof("Commenting with \"%s\".", resp)
   213  		return gc.CreateComment(org, repoName, number, plugins.FormatResponseRaw(body, htmlURL, author, resp))
   214  	} else if !isAuthor && !isAssignee && !skipCollaborators {
   215  		log.Infof("Assigning %s/%s#%d to %s", org, repoName, number, author)
   216  		if err := gc.AssignIssue(org, repoName, number, []string{author}); err != nil {
   217  			msg := "assigning you to the PR failed"
   218  			if ok, merr := gc.IsCollaborator(org, repoName, author); merr == nil && !ok {
   219  				msg = fmt.Sprintf("only %s/%s repo collaborators may be assigned issues", org, repoName)
   220  			} else if merr != nil {
   221  				log.WithError(merr).Errorf("Failed IsCollaborator(%s, %s, %s)", org, repoName, author)
   222  			} else {
   223  				log.WithError(err).Errorf("Failed AssignIssue(%s, %s, %d, %s)", org, repoName, number, author)
   224  			}
   225  			resp := "changing LGTM is restricted to assignees, and " + msg + "."
   226  			log.Infof("Reply to assign via /lgtm request with comment: \"%s\"", resp)
   227  			return gc.CreateComment(org, repoName, number, plugins.FormatResponseRaw(body, htmlURL, author, resp))
   228  		}
   229  	} else if !isAuthor && skipCollaborators {
   230  		log.Debugf("Skipping collaborator checks and loading OWNERS for %s/%s#%d", org, repoName, number)
   231  		ro, err := loadRepoOwners(gc, ownersClient, org, repoName, number)
   232  		if err != nil {
   233  			return err
   234  		}
   235  		filenames, err := getChangedFiles(gc, org, repoName, number)
   236  		if err != nil {
   237  			return err
   238  		}
   239  		if !loadReviewers(ro, filenames).Has(github.NormLogin(author)) {
   240  			resp := "adding LGTM is restricted to approvers and reviewers in OWNERS files."
   241  			log.Infof("Reply to /lgtm request with comment: \"%s\"", resp)
   242  			return gc.CreateComment(org, repoName, number, plugins.FormatResponseRaw(body, htmlURL, author, resp))
   243  		}
   244  	}
   245  
   246  	// Only add the label if it doesn't have it, and vice versa.
   247  	hasLGTM := false
   248  	labels, err := gc.GetIssueLabels(org, repoName, number)
   249  	if err != nil {
   250  		log.WithError(err).Errorf("Failed to get the labels on %s/%s#%d.", org, repoName, number)
   251  	}
   252  
   253  	hasLGTM = github.HasLabel(lgtmLabel, labels)
   254  
   255  	if hasLGTM && !wantLGTM {
   256  		log.Info("Removing LGTM label.")
   257  		return gc.RemoveLabel(org, repoName, number, lgtmLabel)
   258  	} else if !hasLGTM && wantLGTM {
   259  		log.Info("Adding LGTM label.")
   260  		if err := gc.AddLabel(org, repoName, number, lgtmLabel); err != nil {
   261  			return err
   262  		}
   263  		// Delete the LGTM removed noti after the LGTM label is added.
   264  		botname, err := gc.BotName()
   265  		if err != nil {
   266  			log.WithError(err).Errorf("Failed to get bot name.")
   267  		}
   268  		comments, err := gc.ListIssueComments(org, repoName, number)
   269  		if err != nil {
   270  			log.WithError(err).Errorf("Failed to get the list of issue comments on %s/%s#%d.", org, repoName, number)
   271  		}
   272  		for _, comment := range comments {
   273  			if comment.User.Login == botname && comment.Body == removeLGTMLabelNoti {
   274  				if err := gc.DeleteComment(org, repoName, comment.ID); err != nil {
   275  					log.WithError(err).Errorf("Failed to delete comment from %s/%s#%d, ID:%d.", org, repoName, number, comment.ID)
   276  				}
   277  			}
   278  		}
   279  	}
   280  	return nil
   281  }
   282  
   283  type ghLabelClient interface {
   284  	RemoveLabel(owner, repo string, number int, label string) error
   285  	CreateComment(owner, repo string, number int, comment string) error
   286  }
   287  
   288  func handlePullRequest(gc ghLabelClient, pe github.PullRequestEvent, log *logrus.Entry) error {
   289  	if pe.PullRequest.Merged {
   290  		return nil
   291  	}
   292  
   293  	if pe.Action != github.PullRequestActionSynchronize {
   294  		return nil
   295  	}
   296  
   297  	// Don't bother checking if it has the label...it's a race, and we'll have
   298  	// to handle failure due to not being labeled anyway.
   299  	org := pe.PullRequest.Base.Repo.Owner.Login
   300  	repo := pe.PullRequest.Base.Repo.Name
   301  	number := pe.PullRequest.Number
   302  
   303  	var labelNotFound bool
   304  	if err := gc.RemoveLabel(org, repo, number, lgtmLabel); err != nil {
   305  		if _, labelNotFound = err.(*github.LabelNotFound); !labelNotFound {
   306  			return fmt.Errorf("failed removing lgtm label: %v", err)
   307  		}
   308  
   309  		// If the error is indeed *github.LabelNotFound, consider it a success.
   310  	}
   311  	// Creates a comment to inform participants that LGTM label is removed due to new
   312  	// pull request changes.
   313  	if !labelNotFound {
   314  		log.Infof("Create a LGTM removed notification to %s/%s#%d  with a message: %s", org, repo, number, removeLGTMLabelNoti)
   315  		return gc.CreateComment(org, repo, number, removeLGTMLabelNoti)
   316  	}
   317  	return nil
   318  }
   319  
   320  func skipCollaborators(config *plugins.Configuration, org, repo string) bool {
   321  	full := fmt.Sprintf("%s/%s", org, repo)
   322  	for _, elem := range config.Owners.SkipCollaborators {
   323  		if elem == org || elem == full {
   324  			return true
   325  		}
   326  	}
   327  	return false
   328  }
   329  
   330  func loadRepoOwners(gc githubClient, ownersClient repoowners.Interface, org, repo string, number int) (repoowners.RepoOwnerInterface, error) {
   331  	pr, err := gc.GetPullRequest(org, repo, number)
   332  	if err != nil {
   333  		return nil, err
   334  	}
   335  	return ownersClient.LoadRepoOwners(org, repo, pr.Base.Ref)
   336  }
   337  
   338  // getChangedFiles returns all the changed files for the provided pull request.
   339  func getChangedFiles(gc githubClient, org, repo string, number int) ([]string, error) {
   340  	changes, err := gc.GetPullRequestChanges(org, repo, number)
   341  	if err != nil {
   342  		return nil, fmt.Errorf("cannot get PR changes for %s/%s#%d", org, repo, number)
   343  	}
   344  	var filenames []string
   345  	for _, change := range changes {
   346  		filenames = append(filenames, change.Filename)
   347  	}
   348  	return filenames, nil
   349  }
   350  
   351  // loadReviewers returns all reviewers and approvers from all OWNERS files that
   352  // cover the provided filenames.
   353  func loadReviewers(ro repoowners.RepoOwnerInterface, filenames []string) sets.String {
   354  	reviewers := sets.String{}
   355  	for _, filename := range filenames {
   356  		reviewers = reviewers.Union(ro.Approvers(filename)).Union(ro.Reviewers(filename))
   357  	}
   358  	return reviewers
   359  }