github.com/abayer/test-infra@v0.0.5/prow/plugins/releasenote/releasenote.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 releasenote 18 19 import ( 20 "fmt" 21 "regexp" 22 "strconv" 23 "strings" 24 25 "github.com/sirupsen/logrus" 26 27 "k8s.io/apimachinery/pkg/util/sets" 28 "k8s.io/test-infra/prow/github" 29 "k8s.io/test-infra/prow/pluginhelp" 30 "k8s.io/test-infra/prow/plugins" 31 ) 32 33 const pluginName = "release-note" 34 35 const ( 36 releaseNoteLabelNeeded = "do-not-merge/release-note-label-needed" 37 releaseNote = "release-note" 38 releaseNoteNone = "release-note-none" 39 releaseNoteActionRequired = "release-note-action-required" 40 41 releaseNoteFormat = `Adding the "%s" label because no release-note block was detected, please follow our [release note process](https://git.k8s.io/community/contributors/guide/release-notes.md) to remove it.` 42 parentReleaseNoteFormat = `All 'parent' PRs of a cherry-pick PR must have one of the %q or %q labels, or this PR must follow the standard/parent release note labeling requirement.` 43 44 actionRequiredNote = "action required" 45 ) 46 47 var ( 48 releaseNoteBody = fmt.Sprintf(releaseNoteFormat, releaseNoteLabelNeeded) 49 parentReleaseNoteBody = fmt.Sprintf(parentReleaseNoteFormat, releaseNote, releaseNoteActionRequired) 50 51 noteMatcherRE = regexp.MustCompile(`(?s)(?:Release note\*\*:\s*(?:<!--[^<>]*-->\s*)?` + "```(?:release-note)?|```release-note)(.+?)```") 52 cpRe = regexp.MustCompile(`Cherry pick of #([[:digit:]]+) on release-([[:digit:]]+\.[[:digit:]]+).`) 53 noneRe = regexp.MustCompile(`(?i)^\W*NONE\W*$`) 54 55 allRNLabels = []string{ 56 releaseNoteNone, 57 releaseNoteActionRequired, 58 releaseNoteLabelNeeded, 59 releaseNote, 60 } 61 62 releaseNoteRe = regexp.MustCompile(`(?mi)^/release-note\s*$`) 63 releaseNoteNoneRe = regexp.MustCompile(`(?mi)^/release-note-none\s*$`) 64 releaseNoteActionRequiredRe = regexp.MustCompile(`(?mi)^/release-note-action-required\s*$`) 65 ) 66 67 func init() { 68 plugins.RegisterIssueCommentHandler(pluginName, handleIssueComment, helpProvider) 69 plugins.RegisterPullRequestHandler(pluginName, handlePullRequest, helpProvider) 70 } 71 72 func helpProvider(config *plugins.Configuration, enabledRepos []string) (*pluginhelp.PluginHelp, error) { 73 pluginHelp := &pluginhelp.PluginHelp{ 74 Description: `The releasenote plugin implements a release note process that uses a markdown 'releasenote' code block to associate a release note with a pull request. Until the 'releasenote' block in the pull request body is populated the PR will be assigned the '` + releaseNoteLabelNeeded + `' label. 75 <br>There are three valid types of release notes that can replace this label: 76 <ol><li>PRs with a normal release note in the 'releasenote' block are given the label '` + releaseNote + `'.</li> 77 <li>PRs that have a release note of 'none' in the block are given the label '` + releaseNoteNone + `' to indicate that the PR does not warrant a release note.</li> 78 <li>PRs that contain 'action required' in their 'releasenote' block are given the label '` + releaseNoteActionRequired + `' to indicate that the PR introduces potentially breaking changes that necessitate user action before upgrading to the release.</li></ol> 79 ` + "To use the plugin, in the pull request body text:\n\n```releasenote\n<release note content>\n```", 80 } 81 // NOTE: the other two commands re deprecated, so we're not documenting them 82 pluginHelp.AddCommand(pluginhelp.Command{ 83 Usage: "/release-note-none", 84 Description: "Adds the '" + releaseNoteNone + `' label to indicate that the PR does not warrant a release note. This is deprecated and ideally <a href="https://git.k8s.io/community/contributors/guide/release-notes.md">the release note process</a> should be followed in the PR body instead.`, 85 WhoCanUse: "PR Authors and Org Members.", 86 Examples: []string{"/release-note-none"}, 87 }) 88 return pluginHelp, nil 89 } 90 91 type githubClient interface { 92 IsMember(org, user string) (bool, error) 93 CreateComment(owner, repo string, number int, comment string) error 94 AddLabel(owner, repo string, number int, label string) error 95 RemoveLabel(owner, repo string, number int, label string) error 96 GetIssueLabels(org, repo string, number int) ([]github.Label, error) 97 ListIssueComments(org, repo string, number int) ([]github.IssueComment, error) 98 DeleteStaleComments(org, repo string, number int, comments []github.IssueComment, isStale func(github.IssueComment) bool) error 99 BotName() (string, error) 100 } 101 102 func handleIssueComment(pc plugins.PluginClient, ic github.IssueCommentEvent) error { 103 return handleComment(pc.GitHubClient, pc.Logger, ic) 104 } 105 106 func handleComment(gc githubClient, log *logrus.Entry, ic github.IssueCommentEvent) error { 107 // Only consider PRs and new comments. 108 if !ic.Issue.IsPullRequest() || ic.Action != github.IssueCommentActionCreated { 109 return nil 110 } 111 112 org := ic.Repo.Owner.Login 113 repo := ic.Repo.Name 114 number := ic.Issue.Number 115 116 // Which label does the comment want us to add? 117 var nl string 118 switch { 119 case releaseNoteRe.MatchString(ic.Comment.Body): 120 nl = releaseNote 121 case releaseNoteNoneRe.MatchString(ic.Comment.Body): 122 nl = releaseNoteNone 123 case releaseNoteActionRequiredRe.MatchString(ic.Comment.Body): 124 nl = releaseNoteActionRequired 125 default: 126 return nil 127 } 128 129 // Emit deprecation warning for /release-note and /release-note-action-required. 130 if nl == releaseNote || nl == releaseNoteActionRequired { 131 format := "the `/%s` and `/%s` commands have been deprecated.\nPlease edit the `release-note` block in the PR body text to include the release note. If the release note requires additional action include the string `action required` in the release note. For example:\n````\n```release-note\nSome release note with action required.\n```\n````" 132 resp := fmt.Sprintf(format, releaseNote, releaseNoteActionRequired) 133 return gc.CreateComment(org, repo, number, plugins.FormatICResponse(ic.Comment, resp)) 134 } 135 136 // Only allow authors and org members to add labels. 137 isMember, err := gc.IsMember(ic.Repo.Owner.Login, ic.Comment.User.Login) 138 if err != nil { 139 return err 140 } 141 142 isAuthor := ic.Issue.IsAuthor(ic.Comment.User.Login) 143 144 if !isMember && !isAuthor { 145 format := "you can only set the release note label to %s if you are the PR author or an org member." 146 resp := fmt.Sprintf(format, releaseNoteNone) 147 return gc.CreateComment(org, repo, number, plugins.FormatICResponse(ic.Comment, resp)) 148 } 149 150 // Don't allow the /release-note-none command if the release-note block contains a valid release note. 151 blockNL := determineReleaseNoteLabel(ic.Issue.Body) 152 if blockNL == releaseNote || blockNL == releaseNoteActionRequired { 153 format := "you can only set the release note label to %s if the release-note block in the PR body text is empty or \"none\"." 154 resp := fmt.Sprintf(format, releaseNoteNone) 155 return gc.CreateComment(org, repo, number, plugins.FormatICResponse(ic.Comment, resp)) 156 } 157 if !ic.Issue.HasLabel(releaseNoteNone) { 158 if err := gc.AddLabel(org, repo, number, releaseNoteNone); err != nil { 159 return err 160 } 161 } 162 163 labels := sets.String{} 164 for _, label := range ic.Issue.Labels { 165 labels.Insert(label.Name) 166 } 167 // Remove all other release-note-* labels if necessary. 168 return removeOtherLabels( 169 func(l string) error { 170 return gc.RemoveLabel(org, repo, number, l) 171 }, 172 releaseNoteNone, 173 allRNLabels, 174 labels, 175 ) 176 } 177 178 func removeOtherLabels(remover func(string) error, label string, labelSet []string, currentLabels sets.String) error { 179 var errs []error 180 for _, elem := range labelSet { 181 if elem != label && currentLabels.Has(elem) { 182 if err := remover(elem); err != nil { 183 errs = append(errs, err) 184 } 185 currentLabels.Delete(elem) 186 } 187 } 188 if len(errs) > 0 { 189 return fmt.Errorf("encountered %d errors setting labels: %v", len(errs), errs) 190 } 191 return nil 192 } 193 194 func handlePullRequest(pc plugins.PluginClient, pr github.PullRequestEvent) error { 195 return handlePR(pc.GitHubClient, pc.Logger, &pr) 196 } 197 198 func handlePR(gc githubClient, log *logrus.Entry, pr *github.PullRequestEvent) error { 199 // Only consider events that edit the PR body. 200 if pr.Action != github.PullRequestActionOpened && pr.Action != github.PullRequestActionEdited { 201 return nil 202 } 203 org := pr.Repo.Owner.Login 204 repo := pr.Repo.Name 205 206 prInitLabels, err := gc.GetIssueLabels(org, repo, pr.Number) 207 if err != nil { 208 return fmt.Errorf("failed to list labels on PR #%d. err: %v", pr.Number, err) 209 } 210 prLabels := sets.String{} 211 for _, label := range prInitLabels { 212 prLabels.Insert(label.Name) 213 } 214 215 var comments []github.IssueComment 216 labelToAdd := determineReleaseNoteLabel(pr.PullRequest.Body) 217 if labelToAdd == releaseNoteLabelNeeded { 218 if !prMustFollowRelNoteProcess(gc, log, pr, prLabels, true) { 219 ensureNoRelNoteNeededLabel(gc, log, pr, prLabels) 220 return clearStaleComments(gc, log, pr, prLabels, nil) 221 } 222 comments, err = gc.ListIssueComments(org, repo, pr.Number) 223 if err != nil { 224 return fmt.Errorf("failed to list comments on %s/%s#%d. err: %v", org, repo, pr.Number, err) 225 } 226 if containsNoneCommand(comments) { 227 labelToAdd = releaseNoteNone 228 } else if !prLabels.Has(releaseNoteLabelNeeded) { 229 comment := plugins.FormatSimpleResponse(pr.PullRequest.User.Login, releaseNoteBody) 230 if err := gc.CreateComment(org, repo, pr.Number, comment); err != nil { 231 log.WithError(err).Errorf("Failed to comment on %s/%s#%d with comment %q.", org, repo, pr.Number, comment) 232 } 233 } 234 } 235 236 // Add the label if needed 237 if !prLabels.Has(labelToAdd) { 238 if err = gc.AddLabel(org, repo, pr.Number, labelToAdd); err != nil { 239 return err 240 } 241 prLabels.Insert(labelToAdd) 242 } 243 244 err = removeOtherLabels( 245 func(l string) error { 246 return gc.RemoveLabel(org, repo, pr.Number, l) 247 }, 248 labelToAdd, 249 allRNLabels, 250 prLabels, 251 ) 252 if err != nil { 253 log.Error(err) 254 } 255 256 return clearStaleComments(gc, log, pr, prLabels, comments) 257 } 258 259 // clearStaleComments deletes old comments that are no longer applicable. 260 func clearStaleComments(gc githubClient, log *logrus.Entry, pr *github.PullRequestEvent, prLabels sets.String, comments []github.IssueComment) error { 261 // If the PR must follow the process and hasn't yet completed the process, don't remove comments. 262 if prMustFollowRelNoteProcess(gc, log, pr, prLabels, false) && !releaseNoteAlreadyAdded(prLabels) { 263 return nil 264 } 265 botName, err := gc.BotName() 266 if err != nil { 267 return err 268 } 269 return gc.DeleteStaleComments( 270 pr.Repo.Owner.Login, 271 pr.Repo.Name, 272 pr.Number, 273 comments, 274 func(c github.IssueComment) bool { // isStale function 275 return c.User.Login == botName && 276 (strings.Contains(c.Body, releaseNoteBody) || 277 strings.Contains(c.Body, parentReleaseNoteBody)) 278 }, 279 ) 280 } 281 282 func containsNoneCommand(comments []github.IssueComment) bool { 283 for _, c := range comments { 284 if releaseNoteNoneRe.MatchString(c.Body) { 285 return true 286 } 287 } 288 return false 289 } 290 291 func ensureNoRelNoteNeededLabel(gc githubClient, log *logrus.Entry, pr *github.PullRequestEvent, prLabels sets.String) { 292 org := pr.Repo.Owner.Login 293 repo := pr.Repo.Name 294 format := "Failed to remove the label %q from %s/%s#%d." 295 if prLabels.Has(releaseNoteLabelNeeded) { 296 if err := gc.RemoveLabel(org, repo, pr.Number, releaseNoteLabelNeeded); err != nil { 297 log.WithError(err).Errorf(format, releaseNoteLabelNeeded, org, repo, pr.Number) 298 } 299 } 300 } 301 302 // determineReleaseNoteLabel returns the label to be added based on the contents of the 'release-note' 303 // section of a PR's body text. 304 func determineReleaseNoteLabel(body string) string { 305 composedReleaseNote := strings.ToLower(strings.TrimSpace(getReleaseNote(body))) 306 307 if composedReleaseNote == "" { 308 return releaseNoteLabelNeeded 309 } 310 if noneRe.MatchString(composedReleaseNote) { 311 return releaseNoteNone 312 } 313 if strings.Contains(composedReleaseNote, actionRequiredNote) { 314 return releaseNoteActionRequired 315 } 316 return releaseNote 317 } 318 319 // getReleaseNote returns the release note from a PR body 320 // assumes that the PR body followed the PR template 321 func getReleaseNote(body string) string { 322 potentialMatch := noteMatcherRE.FindStringSubmatch(body) 323 if potentialMatch == nil { 324 return "" 325 } 326 return strings.TrimSpace(potentialMatch[1]) 327 } 328 329 func releaseNoteAlreadyAdded(prLabels sets.String) bool { 330 return prLabels.HasAny(releaseNote, releaseNoteActionRequired, releaseNoteNone) 331 } 332 333 func prMustFollowRelNoteProcess(gc githubClient, log *logrus.Entry, pr *github.PullRequestEvent, prLabels sets.String, comment bool) bool { 334 if pr.PullRequest.Base.Ref == "master" { 335 return true 336 } 337 338 parents := getCherrypickParentPRNums(pr.PullRequest.Body) 339 // if it has no parents it needs to follow the release note process 340 if len(parents) == 0 { 341 return true 342 } 343 344 org := pr.Repo.Owner.Login 345 repo := pr.Repo.Name 346 347 var notelessParents []string 348 for _, parent := range parents { 349 // If the parent didn't set a release note, the CP must 350 parentLabels, err := gc.GetIssueLabels(org, repo, parent) 351 if err != nil { 352 log.WithError(err).Errorf("Failed to list labels on PR #%d (parent of #%d).", parent, pr.Number) 353 continue 354 } 355 if !github.HasLabel(releaseNote, parentLabels) && 356 !github.HasLabel(releaseNoteActionRequired, parentLabels) { 357 notelessParents = append(notelessParents, "#"+strconv.Itoa(parent)) 358 } 359 } 360 if len(notelessParents) == 0 { 361 // All of the parents set the releaseNote or releaseNoteActionRequired label, 362 // so this cherrypick PR needs to do nothing. 363 return false 364 } 365 366 if comment && !prLabels.Has(releaseNoteLabelNeeded) { 367 comment := plugins.FormatResponse( 368 pr.PullRequest.User.Login, 369 parentReleaseNoteBody, 370 fmt.Sprintf("The following parent PRs have neither the %q nor the %q labels: %s.", 371 releaseNote, 372 releaseNoteActionRequired, 373 strings.Join(notelessParents, ", "), 374 ), 375 ) 376 if err := gc.CreateComment(org, repo, pr.Number, comment); err != nil { 377 log.WithError(err).Errorf("Error creating comment on %s/%s#%d with comment %q.", org, repo, pr.Number, comment) 378 } 379 } 380 return true 381 } 382 383 func getCherrypickParentPRNums(body string) []int { 384 lines := strings.Split(body, "\n") 385 386 var out []int 387 for _, line := range lines { 388 matches := cpRe.FindStringSubmatch(line) 389 if len(matches) != 3 { 390 continue 391 } 392 parentNum, err := strconv.Atoi(matches[1]) 393 if err != nil { 394 continue 395 } 396 out = append(out, parentNum) 397 } 398 return out 399 }