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