sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/plugins/help/help.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 help 18 19 import ( 20 "fmt" 21 "regexp" 22 "strings" 23 24 "github.com/sirupsen/logrus" 25 "sigs.k8s.io/prow/pkg/config" 26 "sigs.k8s.io/prow/pkg/github" 27 "sigs.k8s.io/prow/pkg/labels" 28 "sigs.k8s.io/prow/pkg/pluginhelp" 29 "sigs.k8s.io/prow/pkg/plugins" 30 ) 31 32 const pluginName = "help" 33 34 var ( 35 helpRe = regexp.MustCompile(`(?mi)^/help\s*$`) 36 helpRemoveRe = regexp.MustCompile(`(?mi)^/remove-help\s*$`) 37 helpGoodFirstIssueRe = regexp.MustCompile(`(?mi)^/good-first-issue\s*$`) 38 helpGoodFirstIssueRemoveRe = regexp.MustCompile(`(?mi)^/remove-good-first-issue\s*$`) 39 helpMsgPruneMatch = "This request has been marked as needing help from a contributor." 40 goodFirstIssueMsgPruneMatch = "This request has been marked as suitable for new contributors." 41 ) 42 43 type issueGuidelines struct { 44 issueGuidelinesURL string 45 issueGuidelinesSummary string 46 } 47 48 func (ig issueGuidelines) helpMsg() string { 49 if len(ig.issueGuidelinesSummary) != 0 { 50 return ig.helpMsgWithGuidelineSummary() 51 } 52 return ` 53 This request has been marked as needing help from a contributor. 54 55 Please ensure the request meets the requirements listed [here](` + ig.issueGuidelinesURL + `). 56 57 If this request no longer meets these requirements, the label can be removed 58 by commenting with the ` + "`/remove-help`" + ` command. 59 ` 60 } 61 62 func (ig issueGuidelines) helpMsgWithGuidelineSummary() string { 63 return fmt.Sprintf(` 64 This request has been marked as needing help from a contributor. 65 66 ### Guidelines 67 %s 68 69 For more details on the requirements of such an issue, please see [here](%s) and ensure that they are met. 70 71 If this request no longer meets these requirements, the label can be removed 72 by commenting with the `+"`/remove-help`"+` command. 73 `, ig.issueGuidelinesSummary, ig.issueGuidelinesURL) 74 } 75 76 func (ig issueGuidelines) goodFirstIssueMsg() string { 77 if len(ig.issueGuidelinesSummary) != 0 { 78 return ig.goodFirstIssueMsgWithGuidelinesSummary() 79 } 80 return ` 81 This request has been marked as suitable for new contributors. 82 83 Please ensure the request meets the requirements listed [here](` + ig.issueGuidelinesURL + "#good-first-issue" + `). 84 85 If this request no longer meets these requirements, the label can be removed 86 by commenting with the ` + "`/remove-good-first-issue`" + ` command. 87 ` 88 } 89 90 func (ig issueGuidelines) goodFirstIssueMsgWithGuidelinesSummary() string { 91 return fmt.Sprintf(` 92 This request has been marked as suitable for new contributors. 93 94 ### Guidelines 95 %s 96 97 For more details on the requirements of such an issue, please see [here](%s#good-first-issue) and ensure that they are met. 98 99 If this request no longer meets these requirements, the label can be removed 100 by commenting with the `+"`/remove-good-first-issue`"+` command. 101 `, ig.issueGuidelinesSummary, ig.issueGuidelinesURL) 102 } 103 104 func init() { 105 plugins.RegisterGenericCommentHandler(pluginName, handleGenericComment, helpProvider) 106 } 107 108 func helpProvider(config *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.PluginHelp, error) { 109 // The Config field is omitted because this plugin is not configurable. 110 pluginHelp := &pluginhelp.PluginHelp{ 111 Description: "The help plugin provides commands that add or remove the '" + labels.Help + "' and the '" + labels.GoodFirstIssue + "' labels from issues.", 112 } 113 pluginHelp.AddCommand(pluginhelp.Command{ 114 Usage: "/[remove-](help|good-first-issue)", 115 Description: "Applies or removes the '" + labels.Help + "' and '" + labels.GoodFirstIssue + "' labels to an issue.", 116 Featured: false, 117 WhoCanUse: "Anyone can trigger this command on a PR.", 118 Examples: []string{"/help", "/remove-help", "/good-first-issue", "/remove-good-first-issue"}, 119 }) 120 return pluginHelp, nil 121 } 122 123 type githubClient interface { 124 BotUserChecker() (func(candidate string) bool, error) 125 CreateComment(owner, repo string, number int, comment string) error 126 AddLabel(owner, repo string, number int, label string) error 127 RemoveLabel(owner, repo string, number int, label string) error 128 GetIssueLabels(org, repo string, number int) ([]github.Label, error) 129 } 130 131 type commentPruner interface { 132 PruneComments(shouldPrune func(github.IssueComment) bool) 133 } 134 135 func handleGenericComment(pc plugins.Agent, e github.GenericCommentEvent) error { 136 cfg := pc.PluginConfig 137 cp, err := pc.CommentPruner() 138 if err != nil { 139 return err 140 } 141 ig := issueGuidelines{ 142 issueGuidelinesURL: cfg.Help.HelpGuidelinesURL, 143 issueGuidelinesSummary: cfg.Help.HelpGuidelinesSummary, 144 } 145 return handle(pc.GitHubClient, pc.Logger, cp, &e, ig) 146 } 147 148 func handle(gc githubClient, log *logrus.Entry, cp commentPruner, e *github.GenericCommentEvent, ig issueGuidelines) error { 149 // Only consider open issues and new comments. 150 if e.IsPR || e.IssueState != "open" || e.Action != github.GenericCommentActionCreated { 151 return nil 152 } 153 154 org := e.Repo.Owner.Login 155 repo := e.Repo.Name 156 commentAuthor := e.User.Login 157 158 // Determine if the issue has the help and the good-first-issue label 159 issueLabels, err := gc.GetIssueLabels(org, repo, e.Number) 160 if err != nil { 161 log.WithError(err).Errorf("Failed to get issue labels.") 162 } 163 hasHelp := github.HasLabel(labels.Help, issueLabels) 164 hasGoodFirstIssue := github.HasLabel(labels.GoodFirstIssue, issueLabels) 165 166 // If PR has help label and we're asking for it to be removed, remove label 167 if hasHelp && helpRemoveRe.MatchString(e.Body) { 168 if err := gc.RemoveLabel(org, repo, e.Number, labels.Help); err != nil { 169 log.WithError(err).Errorf("GitHub failed to remove the following label: %s", labels.Help) 170 } 171 172 botUserChecker, err := gc.BotUserChecker() 173 if err != nil { 174 log.WithError(err).Errorf("Failed to get bot name.") 175 } 176 cp.PruneComments(shouldPrune(log, botUserChecker, helpMsgPruneMatch)) 177 178 // if it has the good-first-issue label, remove it too 179 if hasGoodFirstIssue { 180 if err := gc.RemoveLabel(org, repo, e.Number, labels.GoodFirstIssue); err != nil { 181 log.WithError(err).Errorf("GitHub failed to remove the following label: %s", labels.GoodFirstIssue) 182 } 183 cp.PruneComments(shouldPrune(log, botUserChecker, goodFirstIssueMsgPruneMatch)) 184 } 185 186 return nil 187 } 188 189 // If PR does not have the good-first-issue label and we are asking for it to be added, 190 // add both the good-first-issue and help labels 191 if !hasGoodFirstIssue && helpGoodFirstIssueRe.MatchString(e.Body) { 192 if err := gc.CreateComment(org, repo, e.Number, plugins.FormatResponseRaw(e.Body, e.IssueHTMLURL, commentAuthor, ig.goodFirstIssueMsg())); err != nil { 193 log.WithError(err).Errorf("Failed to create comment \"%s\".", ig.goodFirstIssueMsg()) 194 } 195 196 if err := gc.AddLabel(org, repo, e.Number, labels.GoodFirstIssue); err != nil { 197 log.WithError(err).Errorf("GitHub failed to add the following label: %s", labels.GoodFirstIssue) 198 } 199 200 if !hasHelp { 201 if err := gc.AddLabel(org, repo, e.Number, labels.Help); err != nil { 202 log.WithError(err).Errorf("GitHub failed to add the following label: %s", labels.Help) 203 } 204 } 205 206 return nil 207 } 208 209 // If PR does not have the help label and we're asking it to be added, 210 // add the label 211 if !hasHelp && helpRe.MatchString(e.Body) { 212 if err := gc.CreateComment(org, repo, e.Number, plugins.FormatResponseRaw(e.Body, e.IssueHTMLURL, commentAuthor, ig.helpMsg())); err != nil { 213 log.WithError(err).Errorf("Failed to create comment \"%s\".", ig.helpMsg()) 214 } 215 if err := gc.AddLabel(org, repo, e.Number, labels.Help); err != nil { 216 log.WithError(err).Errorf("GitHub failed to add the following label: %s", labels.Help) 217 } 218 219 return nil 220 } 221 222 // If PR has good-first-issue label and we are asking for it to be removed, 223 // remove just the good-first-issue label 224 if hasGoodFirstIssue && helpGoodFirstIssueRemoveRe.MatchString(e.Body) { 225 if err := gc.RemoveLabel(org, repo, e.Number, labels.GoodFirstIssue); err != nil { 226 log.WithError(err).Errorf("GitHub failed to remove the following label: %s", labels.GoodFirstIssue) 227 } 228 229 botUserChecker, err := gc.BotUserChecker() 230 if err != nil { 231 log.WithError(err).Errorf("Failed to get bot name.") 232 } 233 cp.PruneComments(shouldPrune(log, botUserChecker, goodFirstIssueMsgPruneMatch)) 234 235 return nil 236 } 237 238 return nil 239 } 240 241 // shouldPrune finds comments left by this plugin. 242 func shouldPrune(log *logrus.Entry, isBot func(string) bool, msgPruneMatch string) func(github.IssueComment) bool { 243 return func(comment github.IssueComment) bool { 244 if !isBot(comment.User.Login) { 245 return false 246 } 247 return strings.Contains(comment.Body, msgPruneMatch) 248 } 249 }