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 }