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