sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/plugins/invalidcommitmsg/invalidcommitmsg.go (about)

     1  /*
     2  Copyright 2019 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 invalidcommitmsg adds the "do-not-merge/invalid-commit-message"
    18  // label on PRs containing commit messages with @mentions or
    19  // keywords that can automatically close issues.
    20  package invalidcommitmsg
    21  
    22  import (
    23  	"fmt"
    24  	"regexp"
    25  	"strings"
    26  
    27  	"github.com/sirupsen/logrus"
    28  
    29  	"sigs.k8s.io/prow/pkg/config"
    30  	"sigs.k8s.io/prow/pkg/github"
    31  	"sigs.k8s.io/prow/pkg/pluginhelp"
    32  	"sigs.k8s.io/prow/pkg/plugins"
    33  	"sigs.k8s.io/prow/pkg/plugins/dco"
    34  )
    35  
    36  const (
    37  	pluginName                  = "invalidcommitmsg"
    38  	invalidCommitMsgLabel       = "do-not-merge/invalid-commit-message"
    39  	invalidCommitMsgCommentBody = `[Keywords](https://help.github.com/articles/closing-issues-using-keywords) which can automatically close issues and at(@) or hashtag(#) mentions are not allowed in commit messages.
    40  
    41  **The list of commits with invalid commit messages**:
    42  
    43  %s
    44  
    45  <details>
    46  
    47  %s
    48  </details>
    49  `
    50  	invalidCommitMsgCommentPruneBody = "**The list of commits with invalid commit messages**:"
    51  	invalidTitleCommentBody          = `[Keywords](https://help.github.com/articles/closing-issues-using-keywords) which can automatically close issues and at(@) mentions are not allowed in the title of a Pull Request.
    52  
    53  You can edit the title by writing **/retitle <new-title>** in a comment.
    54  
    55  <details>
    56  When GitHub merges a Pull Request, the title is included in the merge commit. To avoid invalid keywords in the merge commit, please edit the title of the PR.
    57  
    58  %s
    59  </details>
    60  `
    61  	invalidTitleCommentPruneBody = "not allowed in the title of a Pull Request"
    62  )
    63  
    64  var (
    65  	CloseIssueRegex = regexp.MustCompile(`((?i)(clos(?:e[sd]?))|(fix(?:(es|ed)?))|(resolv(?:e[sd]?)))[\s:]+(\w+/\w+)?#(\d+)`)
    66  	AtMentionRegex  = regexp.MustCompile(`\B([@][\w_-]+)`)
    67  )
    68  
    69  func init() {
    70  	plugins.RegisterPullRequestHandler(pluginName, handlePullRequest, helpProvider)
    71  }
    72  
    73  func helpProvider(config *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.PluginHelp, error) {
    74  	// Only the Description field is specified because this plugin is not triggered with commands and is not configurable.
    75  	return &pluginhelp.PluginHelp{
    76  			Description: "The invalidcommitmsg plugin applies the '" + invalidCommitMsgLabel + "' label to pull requests whose commit messages and titles contain @ mentions or keywords which can automatically close issues.",
    77  		},
    78  		nil
    79  }
    80  
    81  type githubClient interface {
    82  	AddLabel(owner, repo string, number int, label string) error
    83  	RemoveLabel(owner, repo string, number int, label string) error
    84  	GetIssueLabels(org, repo string, number int) ([]github.Label, error)
    85  	CreateComment(owner, repo string, number int, comment string) error
    86  	ListPullRequestCommits(org, repo string, number int) ([]github.RepositoryCommit, error)
    87  }
    88  
    89  type commentPruner interface {
    90  	PruneComments(shouldPrune func(github.IssueComment) bool)
    91  }
    92  
    93  func handlePullRequest(pc plugins.Agent, pr github.PullRequestEvent) error {
    94  	cp, err := pc.CommentPruner()
    95  	if err != nil {
    96  		return err
    97  	}
    98  	return handle(pc.GitHubClient, pc.Logger, pr, cp)
    99  }
   100  
   101  func handle(gc githubClient, log *logrus.Entry, pr github.PullRequestEvent, cp commentPruner) error {
   102  	// Only consider actions indicating that the code diffs may have changed.
   103  	if !hasPRChanged(pr) {
   104  		return nil
   105  	}
   106  
   107  	var (
   108  		org    = pr.Repo.Owner.Login
   109  		repo   = pr.Repo.Name
   110  		number = pr.Number
   111  		title  = pr.PullRequest.Title
   112  	)
   113  
   114  	labels, err := gc.GetIssueLabels(org, repo, number)
   115  	if err != nil {
   116  		return err
   117  	}
   118  	hasInvalidCommitMsgLabel := github.HasLabel(invalidCommitMsgLabel, labels)
   119  
   120  	allCommits, err := gc.ListPullRequestCommits(org, repo, number)
   121  	if err != nil {
   122  		return fmt.Errorf("error listing commits for pull request: %w", err)
   123  	}
   124  	log.Debugf("Found %d commits in PR", len(allCommits))
   125  
   126  	var invalidCommits []github.RepositoryCommit
   127  	for _, commit := range allCommits {
   128  		if CloseIssueRegex.MatchString(commit.Commit.Message) || AtMentionRegex.MatchString(commit.Commit.Message) {
   129  			invalidCommits = append(invalidCommits, commit)
   130  		}
   131  	}
   132  
   133  	invalidPRTitle := CloseIssueRegex.MatchString(title) || AtMentionRegex.MatchString(title)
   134  
   135  	// if we have the label but all commits and the PR title is valid,
   136  	// remove the label and prune comments
   137  	if hasInvalidCommitMsgLabel && len(invalidCommits) == 0 && !invalidPRTitle {
   138  		if err := gc.RemoveLabel(org, repo, number, invalidCommitMsgLabel); err != nil {
   139  			log.WithError(err).Errorf("GitHub failed to remove the following label: %s", invalidCommitMsgLabel)
   140  		}
   141  		cp.PruneComments(func(comment github.IssueComment) bool {
   142  			return strings.Contains(comment.Body, invalidCommitMsgCommentPruneBody)
   143  		})
   144  		cp.PruneComments(func(comment github.IssueComment) bool {
   145  			return strings.Contains(comment.Body, invalidTitleCommentPruneBody)
   146  		})
   147  	}
   148  
   149  	// if we don't have the label and
   150  	// if the PR title is invalid OR there are invalid commits
   151  	// add the label
   152  	if !hasInvalidCommitMsgLabel && (len(invalidCommits) != 0 || invalidPRTitle) {
   153  		if err := gc.AddLabel(org, repo, number, invalidCommitMsgLabel); err != nil {
   154  			log.WithError(err).Errorf("GitHub failed to add the following label: %s", invalidCommitMsgLabel)
   155  		}
   156  	}
   157  
   158  	// if there are invalid commits, add a comment
   159  	if len(invalidCommits) != 0 {
   160  		// prune old comments before adding a new one
   161  		cp.PruneComments(func(comment github.IssueComment) bool {
   162  			return strings.Contains(comment.Body, invalidCommitMsgCommentPruneBody)
   163  		})
   164  
   165  		log.Debug("Commenting on PR to advise users of invalid commit messages")
   166  		if err := gc.CreateComment(org, repo, number, fmt.Sprintf(invalidCommitMsgCommentBody, dco.MarkdownSHAList(org, repo, invalidCommits), plugins.AboutThisBot)); err != nil {
   167  			log.WithError(err).Error("Could not create comment for invalid commit messages")
   168  		}
   169  	}
   170  
   171  	// if the PR title is invalid, add a comment
   172  	if invalidPRTitle {
   173  		// prune old comments before adding a new one
   174  		cp.PruneComments(func(comment github.IssueComment) bool {
   175  			return strings.Contains(comment.Body, invalidTitleCommentPruneBody)
   176  		})
   177  
   178  		log.Debug("Commenting on PR to advise users of an invalid PR title")
   179  		if err := gc.CreateComment(org, repo, number, fmt.Sprintf(invalidTitleCommentBody, plugins.AboutThisBot)); err != nil {
   180  			log.WithError(err).Error("Could not create comment for invalid PR title")
   181  		}
   182  	}
   183  
   184  	return nil
   185  }
   186  
   187  // hasPRChanged indicates that the code diff or PR title may have changed.
   188  func hasPRChanged(pr github.PullRequestEvent) bool {
   189  	switch pr.Action {
   190  	case github.PullRequestActionOpened:
   191  		return true
   192  	case github.PullRequestActionReopened:
   193  		return true
   194  	case github.PullRequestActionSynchronize:
   195  		return true
   196  	case github.PullRequestActionEdited:
   197  		return true
   198  	default:
   199  		return false
   200  	}
   201  }