sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/plugins/approve/approve.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 approve
    18  
    19  import (
    20  	"fmt"
    21  	"net/url"
    22  	"regexp"
    23  	"sort"
    24  	"strconv"
    25  	"strings"
    26  	"time"
    27  
    28  	"github.com/sirupsen/logrus"
    29  
    30  	"sigs.k8s.io/prow/pkg/config"
    31  	"sigs.k8s.io/prow/pkg/github"
    32  	"sigs.k8s.io/prow/pkg/labels"
    33  	"sigs.k8s.io/prow/pkg/pluginhelp"
    34  	"sigs.k8s.io/prow/pkg/plugins"
    35  	"sigs.k8s.io/prow/pkg/plugins/approve/approvers"
    36  	"sigs.k8s.io/prow/pkg/repoowners"
    37  )
    38  
    39  const (
    40  	// PluginName defines this plugin's registered name.
    41  	PluginName = "approve"
    42  
    43  	approveCommand       = "APPROVE"
    44  	cancelArgument       = "cancel"
    45  	lgtmCommand          = "LGTM"
    46  	noIssueArgument      = "no-issue"
    47  	removeApproveCommand = "REMOVE-APPROVE"
    48  )
    49  
    50  var (
    51  	associatedIssueRegexFormat = `(?:%s/[^/]+/issues/|#)(\d+)`
    52  	commandRegex               = regexp.MustCompile(`(?m)^/([^\s]+)[\t ]*([^\n\r]*)`)
    53  	notificationRegex          = regexp.MustCompile(`(?is)^\[` + approvers.ApprovalNotificationName + `\] *?([^\n]*)(?:\n\n(.*))?`)
    54  
    55  	// handleFunc is used to allow mocking out the behavior of 'handle' while testing.
    56  	handleFunc = handle
    57  )
    58  
    59  type githubClient interface {
    60  	GetPullRequest(org, repo string, number int) (*github.PullRequest, error)
    61  	GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error)
    62  	GetIssueLabels(org, repo string, number int) ([]github.Label, error)
    63  	ListIssueComments(org, repo string, number int) ([]github.IssueComment, error)
    64  	ListReviews(org, repo string, number int) ([]github.Review, error)
    65  	ListPullRequestComments(org, repo string, number int) ([]github.ReviewComment, error)
    66  	DeleteComment(org, repo string, ID int) error
    67  	CreateComment(org, repo string, number int, comment string) error
    68  	BotUserChecker() (func(candidate string) bool, error)
    69  	AddLabel(org, repo string, number int, label string) error
    70  	RemoveLabel(org, repo string, number int, label string) error
    71  	WasLabelAddedByHuman(org, repo string, num int, label string) (bool, error)
    72  }
    73  
    74  type ownersClient interface {
    75  	LoadRepoOwners(org, repo, base string) (repoowners.RepoOwner, error)
    76  }
    77  
    78  type state struct {
    79  	org    string
    80  	repo   string
    81  	branch string
    82  	number int
    83  
    84  	body      string
    85  	author    string
    86  	assignees []github.User
    87  	htmlURL   string
    88  }
    89  
    90  func init() {
    91  	plugins.RegisterGenericCommentHandler(PluginName, handleGenericCommentEvent, helpProvider)
    92  	plugins.RegisterReviewEventHandler(PluginName, handleReviewEvent, helpProvider)
    93  	plugins.RegisterPullRequestHandler(PluginName, handlePullRequestEvent, helpProvider)
    94  }
    95  
    96  func helpProvider(config *plugins.Configuration, enabledRepos []config.OrgRepo) (*pluginhelp.PluginHelp, error) {
    97  	doNot := func(b bool) string {
    98  		if b {
    99  			return ""
   100  		}
   101  		return "do not "
   102  	}
   103  	willNot := func(b bool) string {
   104  		if b {
   105  			return "will "
   106  		}
   107  		return "will not "
   108  	}
   109  
   110  	approveConfig := map[string]string{}
   111  	for _, repo := range enabledRepos {
   112  		opts := config.ApproveFor(repo.Org, repo.Repo)
   113  		approveConfig[repo.String()] = fmt.Sprintf("Pull requests %s require an associated issue.<br>Pull request authors %s implicitly approve their own PRs.<br>The /lgtm [cancel] command(s) %s act as approval.<br>A GitHub approved or changes requested review %s act as approval or cancel respectively.", doNot(opts.IssueRequired), doNot(opts.HasSelfApproval()), willNot(opts.LgtmActsAsApprove), willNot(opts.ConsiderReviewState()))
   114  	}
   115  
   116  	yamlSnippet, err := plugins.CommentMap.GenYaml(&plugins.Configuration{
   117  		Approve: []plugins.Approve{
   118  			{
   119  				Repos: []string{
   120  					"ORGANIZATION",
   121  					"ORGANIZATION/REPOSITORY",
   122  				},
   123  				RequireSelfApproval: new(bool),
   124  				IgnoreReviewState:   new(bool),
   125  			},
   126  		},
   127  	})
   128  	if err != nil {
   129  		logrus.WithError(err).Warnf("cannot generate comments for %s plugin", PluginName)
   130  	}
   131  
   132  	pluginHelp := &pluginhelp.PluginHelp{
   133  		Description: `The approve plugin implements a pull request approval process that manages the '` + labels.Approved + `' label and an approval notification comment. Approval is achieved when the set of users that have approved the PR is capable of approving every file changed by the PR. A user is able to approve a file if their username or an alias they belong to is listed in the 'approvers' section of an OWNERS file in the directory of the file or higher in the directory tree.
   134  <br>
   135  <br>Per-repo configuration may be used to require that PRs link to an associated issue before approval is granted. It may also be used to specify that the PR authors implicitly approve their own PRs.
   136  <br>For more information see <a href="https://docs.prow.k8s.io/docs/components/plugins/approve/approvers/">here</a>.`,
   137  		Config:  approveConfig,
   138  		Snippet: yamlSnippet,
   139  	}
   140  	pluginHelp.AddCommand(pluginhelp.Command{
   141  		Usage:       "/[remove-]approve [no-issue|cancel]",
   142  		Description: "Approves a pull request",
   143  		Featured:    true,
   144  		WhoCanUse:   "Users listed as 'approvers' in appropriate OWNERS files.",
   145  		Examples:    []string{"/approve", "/approve no-issue", "/remove-approve"},
   146  	})
   147  	return pluginHelp, nil
   148  }
   149  
   150  func handleGenericCommentEvent(pc plugins.Agent, ce github.GenericCommentEvent) error {
   151  	return handleGenericComment(
   152  		pc.Logger,
   153  		pc.GitHubClient,
   154  		pc.OwnersClient,
   155  		pc.Config.GitHubOptions,
   156  		pc.PluginConfig,
   157  		&ce,
   158  	)
   159  }
   160  
   161  func handleGenericComment(log *logrus.Entry, ghc githubClient, oc ownersClient, githubConfig config.GitHubOptions, config *plugins.Configuration, ce *github.GenericCommentEvent) error {
   162  	funcStart := time.Now()
   163  	defer func() {
   164  		log.WithField("duration", time.Since(funcStart).String()).Debug("Completed handleGenericComment")
   165  	}()
   166  	if ce.Action != github.GenericCommentActionCreated || !ce.IsPR || ce.IssueState == "closed" {
   167  		log.Debug("Event is not a creation of a comment on an open PR, skipping.")
   168  		return nil
   169  	}
   170  
   171  	botUserChecker, err := ghc.BotUserChecker()
   172  	if err != nil {
   173  		return err
   174  	}
   175  
   176  	opts := config.ApproveFor(ce.Repo.Owner.Login, ce.Repo.Name)
   177  	if !isApprovalCommand(botUserChecker, opts.LgtmActsAsApprove, &comment{Body: ce.Body, Author: ce.User.Login}) {
   178  		log.Debug("Comment does not constitute approval, skipping event.")
   179  		return nil
   180  	}
   181  
   182  	log.Debug("Resolving pull request...")
   183  	pr, err := ghc.GetPullRequest(ce.Repo.Owner.Login, ce.Repo.Name, ce.Number)
   184  	if err != nil {
   185  		return err
   186  	}
   187  
   188  	log.Debug("Resolving repository owners...")
   189  	repo, err := oc.LoadRepoOwners(ce.Repo.Owner.Login, ce.Repo.Name, pr.Base.Ref)
   190  	if err != nil {
   191  		return err
   192  	}
   193  
   194  	return handleFunc(
   195  		log,
   196  		ghc,
   197  		repo,
   198  		githubConfig,
   199  		opts,
   200  		&state{
   201  			org:       ce.Repo.Owner.Login,
   202  			repo:      ce.Repo.Name,
   203  			branch:    pr.Base.Ref,
   204  			number:    ce.Number,
   205  			body:      ce.IssueBody,
   206  			author:    ce.IssueAuthor.Login,
   207  			assignees: ce.Assignees,
   208  			htmlURL:   ce.IssueHTMLURL,
   209  		},
   210  	)
   211  }
   212  
   213  // handleReviewEvent should only handle reviews that have no approval command.
   214  // Reviews with approval commands will be handled by handleGenericCommentEvent.
   215  func handleReviewEvent(pc plugins.Agent, re github.ReviewEvent) error {
   216  	return handleReview(
   217  		pc.Logger,
   218  		pc.GitHubClient,
   219  		pc.OwnersClient,
   220  		pc.Config.GitHubOptions,
   221  		pc.PluginConfig,
   222  		&re,
   223  	)
   224  }
   225  
   226  func handleReview(log *logrus.Entry, ghc githubClient, oc ownersClient, githubConfig config.GitHubOptions, config *plugins.Configuration, re *github.ReviewEvent) error {
   227  	funcStart := time.Now()
   228  	defer func() {
   229  		log.WithField("duration", time.Since(funcStart).String()).Debug("Completed handleReview")
   230  	}()
   231  	if re.Action != github.ReviewActionSubmitted && re.Action != github.ReviewActionDismissed {
   232  		log.Debug("Event is not a creation or dismissal of a review on an open PR, skipping.")
   233  		return nil
   234  	}
   235  
   236  	botUserChecker, err := ghc.BotUserChecker()
   237  	if err != nil {
   238  		return err
   239  	}
   240  
   241  	opts := config.ApproveFor(re.Repo.Owner.Login, re.Repo.Name)
   242  
   243  	// Check for an approval command is in the body. If one exists, let the
   244  	// genericCommentEventHandler handle this event. Approval commands override
   245  	// review state.
   246  	if isApprovalCommand(botUserChecker, opts.LgtmActsAsApprove, &comment{Body: re.Review.Body, Author: re.Review.User.Login}) {
   247  		log.Debug("Review constitutes approval, skipping event.")
   248  		return nil
   249  	}
   250  
   251  	// Check for an approval command via review state. If none exists, don't
   252  	// handle this event.
   253  	if !isApprovalState(botUserChecker, opts.ConsiderReviewState(), &comment{Author: re.Review.User.Login, ReviewState: re.Review.State}) {
   254  		log.Debug("Review does not constitute approval, skipping event.")
   255  		return nil
   256  	}
   257  
   258  	log.Debug("Resolving repository owners...")
   259  	repo, err := oc.LoadRepoOwners(re.Repo.Owner.Login, re.Repo.Name, re.PullRequest.Base.Ref)
   260  	if err != nil {
   261  		return err
   262  	}
   263  
   264  	return handleFunc(
   265  		log,
   266  		ghc,
   267  		repo,
   268  		githubConfig,
   269  		opts,
   270  		&state{
   271  			org:       re.Repo.Owner.Login,
   272  			repo:      re.Repo.Name,
   273  			branch:    re.PullRequest.Base.Ref,
   274  			number:    re.PullRequest.Number,
   275  			body:      re.PullRequest.Body,
   276  			author:    re.PullRequest.User.Login,
   277  			assignees: re.PullRequest.Assignees,
   278  			htmlURL:   re.PullRequest.HTMLURL,
   279  		},
   280  	)
   281  
   282  }
   283  
   284  func handlePullRequestEvent(pc plugins.Agent, pre github.PullRequestEvent) error {
   285  	return handlePullRequest(
   286  		pc.Logger,
   287  		pc.GitHubClient,
   288  		pc.OwnersClient,
   289  		pc.Config.GitHubOptions,
   290  		pc.PluginConfig,
   291  		&pre,
   292  	)
   293  }
   294  
   295  func handlePullRequest(log *logrus.Entry, ghc githubClient, oc ownersClient, githubConfig config.GitHubOptions, config *plugins.Configuration, pre *github.PullRequestEvent) error {
   296  	funcStart := time.Now()
   297  	defer func() {
   298  		log.WithField("duration", time.Since(funcStart).String()).Debug("Completed handlePullRequest")
   299  	}()
   300  	if pre.Action != github.PullRequestActionOpened &&
   301  		pre.Action != github.PullRequestActionReopened &&
   302  		pre.Action != github.PullRequestActionSynchronize &&
   303  		pre.Action != github.PullRequestActionLabeled {
   304  		log.Debug("Pull request event action cannot constitute approval, skipping...")
   305  		return nil
   306  	}
   307  	botUserChecker, err := ghc.BotUserChecker()
   308  	if err != nil {
   309  		return err
   310  	}
   311  	if pre.Action == github.PullRequestActionLabeled &&
   312  		(pre.Label.Name != labels.Approved || botUserChecker(pre.Sender.Login) || pre.PullRequest.State == "closed") {
   313  		log.Debug("Pull request label event does not constitute approval, skipping...")
   314  		return nil
   315  	}
   316  
   317  	log.Debug("Resolving repository owners...")
   318  	repo, err := oc.LoadRepoOwners(pre.Repo.Owner.Login, pre.Repo.Name, pre.PullRequest.Base.Ref)
   319  	if err != nil {
   320  		return err
   321  	}
   322  
   323  	return handleFunc(
   324  		log,
   325  		ghc,
   326  		repo,
   327  		githubConfig,
   328  		config.ApproveFor(pre.Repo.Owner.Login, pre.Repo.Name),
   329  		&state{
   330  			org:       pre.Repo.Owner.Login,
   331  			repo:      pre.Repo.Name,
   332  			branch:    pre.PullRequest.Base.Ref,
   333  			number:    pre.Number,
   334  			body:      pre.PullRequest.Body,
   335  			author:    pre.PullRequest.User.Login,
   336  			assignees: pre.PullRequest.Assignees,
   337  			htmlURL:   pre.PullRequest.HTMLURL,
   338  		},
   339  	)
   340  }
   341  
   342  // Returns associated issue, or 0 if it can't find any.
   343  // This is really simple, and could be improved later.
   344  func findAssociatedIssue(body, org string) (int, error) {
   345  	associatedIssueRegex, err := regexp.Compile(fmt.Sprintf(associatedIssueRegexFormat, org))
   346  	if err != nil {
   347  		return 0, err
   348  	}
   349  	match := associatedIssueRegex.FindStringSubmatch(body)
   350  	if len(match) == 0 {
   351  		return 0, nil
   352  	}
   353  	v, err := strconv.Atoi(match[1])
   354  	if err != nil {
   355  		return 0, err
   356  	}
   357  	return v, nil
   358  }
   359  
   360  // handle is the workhorse the will actually make updates to the PR.
   361  // The algorithm goes as:
   362  // - Initially, we build an approverSet
   363  //   - Go through all comments in order of creation.
   364  //   - (Issue/PR comments, PR review comments, and PR review bodies are considered as comments)
   365  //   - If anyone said "/approve", add them to approverSet.
   366  //   - If anyone said "/lgtm" AND LgtmActsAsApprove is enabled, add them to approverSet.
   367  //   - If anyone created an approved review AND ReviewActsAsApprove is enabled, add them to approverSet.
   368  //
   369  // - Then, for each file, we see if any approver of this file is in approverSet and keep track of files without approval
   370  //   - An approver of a file is defined as:
   371  //   - Someone listed as an "approver" in an OWNERS file in the files directory OR
   372  //   - in one of the file's parent directories
   373  //   - Iff all files have been approved, the bot will add the "approved" label.
   374  //   - Iff a cancel command is found, that reviewer will be removed from the approverSet
   375  //     and the munger will remove the approved label if it has been applied
   376  func handle(log *logrus.Entry, ghc githubClient, repo approvers.Repo, githubConfig config.GitHubOptions, opts *plugins.Approve, pr *state) error {
   377  	funcStart := time.Now()
   378  	defer func() {
   379  		log.WithField("duration", time.Since(funcStart).String()).Debug("Completed handle")
   380  	}()
   381  	fetchErr := func(context string, err error) error {
   382  		return fmt.Errorf("failed to get %s for %s/%s#%d: %w", context, pr.org, pr.repo, pr.number, err)
   383  	}
   384  
   385  	start := time.Now()
   386  	changes, err := ghc.GetPullRequestChanges(pr.org, pr.repo, pr.number)
   387  	if err != nil {
   388  		return fetchErr("PR file changes", err)
   389  	}
   390  	var filenames []string
   391  	for _, change := range changes {
   392  		filenames = append(filenames, change.Filename)
   393  	}
   394  	issueLabels, err := ghc.GetIssueLabels(pr.org, pr.repo, pr.number)
   395  	if err != nil {
   396  		return fetchErr("issue labels", err)
   397  	}
   398  	var hasApprovedLabel bool
   399  	for _, label := range issueLabels {
   400  		if label.Name == labels.Approved {
   401  			hasApprovedLabel = true
   402  			break
   403  		}
   404  	}
   405  	botUserChecker, err := ghc.BotUserChecker()
   406  	if err != nil {
   407  		return fetchErr("bot name", err)
   408  	}
   409  	issueComments, err := ghc.ListIssueComments(pr.org, pr.repo, pr.number)
   410  	if err != nil {
   411  		return fetchErr("issue comments", err)
   412  	}
   413  	reviewComments, err := ghc.ListPullRequestComments(pr.org, pr.repo, pr.number)
   414  	if err != nil {
   415  		return fetchErr("review comments", err)
   416  	}
   417  	reviews, err := ghc.ListReviews(pr.org, pr.repo, pr.number)
   418  	if err != nil {
   419  		return fetchErr("reviews", err)
   420  	}
   421  	log.WithField("duration", time.Since(start).String()).Debug("Completed github functions in handle")
   422  
   423  	start = time.Now()
   424  	approversHandler := approvers.NewApprovers(
   425  		approvers.NewOwners(
   426  			log,
   427  			filenames,
   428  			repo,
   429  			int64(pr.number),
   430  		),
   431  	)
   432  	approversHandler.AssociatedIssue, err = findAssociatedIssue(pr.body, pr.org)
   433  	if err != nil {
   434  		log.WithError(err).Errorf("Failed to find associated issue from PR body: %v", err)
   435  	}
   436  	approversHandler.RequireIssue = opts.IssueRequired
   437  	approversHandler.ManuallyApproved = humanAddedApproved(ghc, log, pr.org, pr.repo, pr.number, hasApprovedLabel)
   438  
   439  	// Author implicitly approves their own PR if config allows it
   440  	if opts.HasSelfApproval() {
   441  		approversHandler.AddAuthorSelfApprover(pr.author, pr.htmlURL+"#", false)
   442  	} else {
   443  		// Treat the author as an assignee, and suggest them if possible
   444  		approversHandler.AddAssignees(pr.author)
   445  	}
   446  	log.WithField("duration", time.Since(start).String()).Debug("Completed configuring approversHandler in handle")
   447  
   448  	start = time.Now()
   449  	commentsFromIssueComments := commentsFromIssueComments(issueComments)
   450  	comments := append(commentsFromReviewComments(reviewComments), commentsFromIssueComments...)
   451  	comments = append(comments, commentsFromReviews(reviews)...)
   452  	sort.SliceStable(comments, func(i, j int) bool {
   453  		return comments[i].CreatedAt.Before(comments[j].CreatedAt)
   454  	})
   455  	approveComments := filterComments(comments, approvalMatcher(botUserChecker, opts.LgtmActsAsApprove, opts.ConsiderReviewState()))
   456  	addApprovers(&approversHandler, approveComments, pr.author, opts.ConsiderReviewState())
   457  	log.WithField("duration", time.Since(start).String()).Debug("Completed filtering approval comments in handle")
   458  
   459  	for _, user := range pr.assignees {
   460  		approversHandler.AddAssignees(user.Login)
   461  	}
   462  
   463  	start = time.Now()
   464  	notifications := filterComments(commentsFromIssueComments, notificationMatcher(botUserChecker))
   465  	latestNotification := getLast(notifications)
   466  	newMessage := updateNotification(githubConfig.LinkURL, opts.CommandHelpLink, opts.PrProcessLink, pr.org, pr.repo, pr.branch, latestNotification, approversHandler)
   467  	log.WithField("duration", time.Since(start).String()).Debug("Completed getting notifications in handle")
   468  	start = time.Now()
   469  	if newMessage != nil {
   470  		for _, notif := range notifications {
   471  			if err := ghc.DeleteComment(pr.org, pr.repo, notif.ID); err != nil {
   472  				log.WithError(err).Errorf("Failed to delete comment from %s/%s#%d, ID: %d.", pr.org, pr.repo, pr.number, notif.ID)
   473  			}
   474  		}
   475  		if err := ghc.CreateComment(pr.org, pr.repo, pr.number, *newMessage); err != nil {
   476  			log.WithError(err).Errorf("Failed to create comment on %s/%s#%d: %q.", pr.org, pr.repo, pr.number, *newMessage)
   477  		}
   478  	}
   479  	log.WithField("duration", time.Since(start).String()).Debug("Completed adding/deleting approval comments in handle")
   480  
   481  	start = time.Now()
   482  	if !approversHandler.IsApproved() {
   483  		if hasApprovedLabel {
   484  			if err := ghc.RemoveLabel(pr.org, pr.repo, pr.number, labels.Approved); err != nil {
   485  				log.WithError(err).Errorf("Failed to remove %q label from %s/%s#%d.", labels.Approved, pr.org, pr.repo, pr.number)
   486  			}
   487  		}
   488  	} else if !hasApprovedLabel {
   489  		if err := ghc.AddLabel(pr.org, pr.repo, pr.number, labels.Approved); err != nil {
   490  			log.WithError(err).Errorf("Failed to add %q label to %s/%s#%d.", labels.Approved, pr.org, pr.repo, pr.number)
   491  		}
   492  	}
   493  	log.WithField("duration", time.Since(start).String()).Debug("Completed adding/deleting approval labels in handle")
   494  	return nil
   495  }
   496  
   497  func humanAddedApproved(ghc githubClient, log *logrus.Entry, org, repo string, number int, hasLabel bool) func() bool {
   498  	findOut := func() bool {
   499  		if !hasLabel {
   500  			return false
   501  		}
   502  		humanApproved, err := ghc.WasLabelAddedByHuman(org, repo, number, labels.Approved)
   503  		if err != nil {
   504  			log.WithError(err).Errorf("failed to check if %s label was added by bot", labels.Approved)
   505  			return false
   506  		}
   507  
   508  		return humanApproved
   509  	}
   510  
   511  	var cache *bool
   512  	return func() bool {
   513  		if cache == nil {
   514  			val := findOut()
   515  			cache = &val
   516  		}
   517  		return *cache
   518  	}
   519  }
   520  
   521  func approvalMatcher(isBot func(string) bool, lgtmActsAsApprove, reviewActsAsApprove bool) func(*comment) bool {
   522  	return func(c *comment) bool {
   523  		return isApprovalCommand(isBot, lgtmActsAsApprove, c) || isApprovalState(isBot, reviewActsAsApprove, c)
   524  	}
   525  }
   526  
   527  func isApprovalCommand(isBot func(string) bool, lgtmActsAsApprove bool, c *comment) bool {
   528  	if isBot(c.Author) {
   529  		return false
   530  	}
   531  
   532  	for _, match := range commandRegex.FindAllStringSubmatch(c.Body, -1) {
   533  		cmd := strings.ToUpper(match[1])
   534  		if (cmd == lgtmCommand && lgtmActsAsApprove) || cmd == approveCommand || cmd == removeApproveCommand {
   535  			return true
   536  		}
   537  	}
   538  	return false
   539  }
   540  
   541  func isApprovalState(isBot func(string) bool, reviewActsAsApprove bool, c *comment) bool {
   542  	if isBot(c.Author) {
   543  		return false
   544  	}
   545  
   546  	// The review webhook returns state as lowercase, while the review API
   547  	// returns state as uppercase. Uppercase the value here so it always
   548  	// matches the constant.
   549  	reviewState := github.ReviewState(strings.ToUpper(string(c.ReviewState)))
   550  
   551  	// ReviewStateApproved = /approve
   552  	// ReviewStateChangesRequested = /approve cancel
   553  	// ReviewStateDismissed = remove previous approval or disapproval
   554  	// (Reviews can go from Approved or ChangesRequested to Dismissed
   555  	// state if the Dismiss action is used)
   556  	if reviewActsAsApprove && (reviewState == github.ReviewStateApproved ||
   557  		reviewState == github.ReviewStateChangesRequested ||
   558  		reviewState == github.ReviewStateDismissed) {
   559  		return true
   560  	}
   561  	return false
   562  }
   563  
   564  func notificationMatcher(isBot func(string) bool) func(*comment) bool {
   565  	return func(c *comment) bool {
   566  		if !isBot(c.Author) {
   567  			return false
   568  		}
   569  		match := notificationRegex.FindStringSubmatch(c.Body)
   570  		return len(match) > 0
   571  	}
   572  }
   573  
   574  func updateNotification(linkURL *url.URL, commandHelpLink, prProcessLink, org, repo, branch string, latestNotification *comment, approversHandler approvers.Approvers) *string {
   575  	message := approvers.GetMessage(approversHandler, linkURL, commandHelpLink, prProcessLink, org, repo, branch)
   576  	if message == nil || (latestNotification != nil && strings.Contains(latestNotification.Body, *message)) {
   577  		return nil
   578  	}
   579  	return message
   580  }
   581  
   582  // addApprovers iterates through the list of comments on a PR
   583  // and identifies all of the people that have said /approve and adds
   584  // them to the Approvers.  The function uses the latest approve or cancel comment
   585  // to determine the Users intention. A review in requested changes state is
   586  // considered a cancel.
   587  func addApprovers(approversHandler *approvers.Approvers, approveComments []*comment, author string, reviewActsAsApprove bool) {
   588  	for _, c := range approveComments {
   589  		if c.Author == "" {
   590  			continue
   591  		}
   592  
   593  		if reviewActsAsApprove && c.ReviewState == github.ReviewStateApproved {
   594  			approversHandler.AddApprover(
   595  				c.Author,
   596  				c.HTMLURL,
   597  				false,
   598  			)
   599  		}
   600  		if reviewActsAsApprove && c.ReviewState == github.ReviewStateChangesRequested {
   601  			approversHandler.RemoveApprover(c.Author)
   602  		}
   603  
   604  		for _, match := range commandRegex.FindAllStringSubmatch(c.Body, -1) {
   605  			name := strings.ToUpper(match[1])
   606  			if name == removeApproveCommand {
   607  				approversHandler.RemoveApprover(c.Author)
   608  				continue
   609  			}
   610  			if name != approveCommand && name != lgtmCommand {
   611  				continue
   612  			}
   613  			args := strings.ToLower(strings.TrimSpace(match[2]))
   614  			if strings.Contains(args, cancelArgument) {
   615  				approversHandler.RemoveApprover(c.Author)
   616  				continue
   617  			}
   618  
   619  			if c.Author == author {
   620  				approversHandler.AddAuthorSelfApprover(
   621  					c.Author,
   622  					c.HTMLURL,
   623  					args == noIssueArgument,
   624  				)
   625  			}
   626  
   627  			if name == approveCommand {
   628  				approversHandler.AddApprover(
   629  					c.Author,
   630  					c.HTMLURL,
   631  					args == noIssueArgument,
   632  				)
   633  			} else {
   634  				approversHandler.AddLGTMer(
   635  					c.Author,
   636  					c.HTMLURL,
   637  					args == noIssueArgument,
   638  				)
   639  			}
   640  
   641  		}
   642  	}
   643  }
   644  
   645  type comment struct {
   646  	Body        string
   647  	Author      string
   648  	CreatedAt   time.Time
   649  	HTMLURL     string
   650  	ID          int
   651  	ReviewState github.ReviewState
   652  }
   653  
   654  func commentFromIssueComment(ic *github.IssueComment) *comment {
   655  	if ic == nil {
   656  		return nil
   657  	}
   658  	return &comment{
   659  		Body:      ic.Body,
   660  		Author:    ic.User.Login,
   661  		CreatedAt: ic.CreatedAt,
   662  		HTMLURL:   ic.HTMLURL,
   663  		ID:        ic.ID,
   664  	}
   665  }
   666  
   667  func commentsFromIssueComments(ics []github.IssueComment) []*comment {
   668  	comments := make([]*comment, 0, len(ics))
   669  	for i := range ics {
   670  		comments = append(comments, commentFromIssueComment(&ics[i]))
   671  	}
   672  	return comments
   673  }
   674  
   675  func commentFromReviewComment(rc *github.ReviewComment) *comment {
   676  	if rc == nil {
   677  		return nil
   678  	}
   679  	return &comment{
   680  		Body:      rc.Body,
   681  		Author:    rc.User.Login,
   682  		CreatedAt: rc.CreatedAt,
   683  		HTMLURL:   rc.HTMLURL,
   684  		ID:        rc.ID,
   685  	}
   686  }
   687  
   688  func commentsFromReviewComments(rcs []github.ReviewComment) []*comment {
   689  	comments := make([]*comment, 0, len(rcs))
   690  	for i := range rcs {
   691  		comments = append(comments, commentFromReviewComment(&rcs[i]))
   692  	}
   693  	return comments
   694  }
   695  
   696  func commentFromReview(review *github.Review) *comment {
   697  	if review == nil {
   698  		return nil
   699  	}
   700  	return &comment{
   701  		Body:        review.Body,
   702  		Author:      review.User.Login,
   703  		CreatedAt:   review.SubmittedAt,
   704  		HTMLURL:     review.HTMLURL,
   705  		ID:          review.ID,
   706  		ReviewState: review.State,
   707  	}
   708  }
   709  
   710  func commentsFromReviews(reviews []github.Review) []*comment {
   711  	comments := make([]*comment, 0, len(reviews))
   712  	for i := range reviews {
   713  		comments = append(comments, commentFromReview(&reviews[i]))
   714  	}
   715  	return comments
   716  }
   717  
   718  func filterComments(comments []*comment, filter func(*comment) bool) []*comment {
   719  	filtered := make([]*comment, 0, len(comments))
   720  	for _, c := range comments {
   721  		if filter(c) {
   722  			filtered = append(filtered, c)
   723  		}
   724  	}
   725  	return filtered
   726  }
   727  
   728  func getLast(cs []*comment) *comment {
   729  	if len(cs) == 0 {
   730  		return nil
   731  	}
   732  	return cs[len(cs)-1]
   733  }