github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/pkg/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 "sigs.k8s.io/prow/pkg/labels" 26 27 "github.com/sirupsen/logrus" 28 29 "k8s.io/apimachinery/pkg/util/sets" 30 "sigs.k8s.io/prow/pkg/config" 31 "sigs.k8s.io/prow/pkg/github" 32 "sigs.k8s.io/prow/pkg/pluginhelp" 33 "sigs.k8s.io/prow/pkg/plugins" 34 ) 35 36 const ( 37 // PluginName defines this plugin's registered name. 38 PluginName = "release-note" 39 ) 40 const ( 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 releaseNoteDeprecationFormat = `Adding the "%s" label and removing any existing "%s" label because there is a "%s" label on the PR.` 44 45 actionRequiredNote = "action required" 46 ) 47 48 var ( 49 releaseNoteBody = fmt.Sprintf(releaseNoteFormat, labels.ReleaseNoteLabelNeeded) 50 parentReleaseNoteBody = fmt.Sprintf(parentReleaseNoteFormat, labels.ReleaseNote, labels.ReleaseNoteActionRequired) 51 releaseNoteDeprecationBody = fmt.Sprintf(releaseNoteDeprecationFormat, labels.ReleaseNoteLabelNeeded, labels.ReleaseNoteNone, labels.DeprecationLabel) 52 53 noteMatcherRE = regexp.MustCompile(`(?s)(?:Release note\*\*:\s*(?:<!--[^<>]*-->\s*)?` + "```(?:release-note)?|```release-note)(.+?)```") 54 cpRe = regexp.MustCompile(`Cherry pick of #([[:digit:]]+) on release-([[:digit:]]+\.[[:digit:]]+).`) 55 noneRe = regexp.MustCompile(`(?i)^\W*(NONE|NO)\W*$`) 56 57 allRNLabels = []string{ 58 labels.ReleaseNoteNone, 59 labels.ReleaseNoteActionRequired, 60 labels.ReleaseNoteLabelNeeded, 61 labels.ReleaseNote, 62 } 63 64 releaseNoteRe = regexp.MustCompile(`(?mi)^/release-note\s*$`) 65 releaseNoteEditRe = regexp.MustCompile(`(?mi)^/release-note-edit\s*$`) 66 releaseNoteNoneRe = regexp.MustCompile(`(?mi)^/release-note-none\s*$`) 67 releaseNoteActionRequiredRe = regexp.MustCompile(`(?mi)^/release-note-action-required\s*$`) 68 ) 69 70 func init() { 71 plugins.RegisterIssueCommentHandler(PluginName, handleIssueComment, helpProvider) 72 plugins.RegisterPullRequestHandler(PluginName, handlePullRequest, helpProvider) 73 } 74 75 func helpProvider(_ *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.PluginHelp, error) { 76 pluginHelp := &pluginhelp.PluginHelp{ 77 Description: `The releasenote plugin implements a release note process that uses a markdown 'release-note' code block to associate a release note with a pull request. Until the 'release-note' block in the pull request body is populated the PR will be assigned the '` + labels.ReleaseNoteLabelNeeded + `' label. 78 <br>There are three valid types of release notes that can replace this label: 79 <ol><li>PRs with a normal release note in the 'release-note' block are given the label '` + labels.ReleaseNote + `'.</li> 80 <li>PRs that have a release note of 'none' in the block are given the label '` + labels.ReleaseNoteNone + `' to indicate that the PR does not warrant a release note.</li> 81 <li>PRs that contain 'action required' in their 'release-note' block are given the label '` + labels.ReleaseNoteActionRequired + `' to indicate that the PR introduces potentially breaking changes that necessitate user action before upgrading to the release.</li></ol> 82 ` + "To use the plugin, in the pull request body text:\n\n```release-note\n<release note content>\n```", 83 } 84 // NOTE: the other two commands re deprecated, so we're not documenting them 85 pluginHelp.AddCommand(pluginhelp.Command{ 86 Usage: "/release-note-none", 87 Description: "Adds the '" + labels.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.`, 88 WhoCanUse: "PR Authors and Org Members.", 89 Examples: []string{"/release-note-none"}, 90 }) 91 pluginHelp.AddCommand(pluginhelp.Command{ 92 Usage: "/release-note-edit", 93 Description: "Replaces the release note block in the top level comment with the provided one.", 94 WhoCanUse: "Org Members.", 95 Examples: []string{"/release-note-edit\r\n```release-note\r\nThe new release note\r\n```"}, 96 }) 97 return pluginHelp, nil 98 } 99 100 type githubClient interface { 101 IsMember(org, user string) (bool, error) 102 CreateComment(owner, repo string, number int, comment string) error 103 AddLabel(owner, repo string, number int, label string) error 104 RemoveLabel(owner, repo string, number int, label string) error 105 GetIssueLabels(org, repo string, number int) ([]github.Label, error) 106 ListIssueComments(org, repo string, number int) ([]github.IssueComment, error) 107 DeleteStaleComments(org, repo string, number int, comments []github.IssueComment, isStale func(github.IssueComment) bool) error 108 BotUserChecker() (func(candidate string) bool, error) 109 EditIssue(org, repo string, number int, issue *github.Issue) (*github.Issue, error) 110 } 111 112 func handleIssueComment(pc plugins.Agent, ic github.IssueCommentEvent) error { 113 return handleComment(pc.GitHubClient, pc.Logger, ic) 114 } 115 116 func handleComment(gc githubClient, log *logrus.Entry, ic github.IssueCommentEvent) error { 117 // Only consider PRs and new comments. 118 if !ic.Issue.IsPullRequest() || ic.Action != github.IssueCommentActionCreated { 119 return nil 120 } 121 122 org := ic.Repo.Owner.Login 123 repo := ic.Repo.Name 124 number := ic.Issue.Number 125 126 if releaseNoteEditRe.MatchString(ic.Comment.Body) { 127 return editReleaseNote(gc, log, ic) 128 } 129 130 // Which label does the comment want us to add? 131 var nl string 132 switch { 133 case releaseNoteRe.MatchString(ic.Comment.Body): 134 nl = labels.ReleaseNote 135 case releaseNoteNoneRe.MatchString(ic.Comment.Body): 136 nl = labels.ReleaseNoteNone 137 case releaseNoteActionRequiredRe.MatchString(ic.Comment.Body): 138 nl = labels.ReleaseNoteActionRequired 139 default: 140 return nil 141 } 142 143 // Emit deprecation warning for /release-note and /release-note-action-required. 144 if nl == labels.ReleaseNote || nl == labels.ReleaseNoteActionRequired { 145 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````" 146 resp := fmt.Sprintf(format, labels.ReleaseNote, labels.ReleaseNoteActionRequired) 147 return gc.CreateComment(org, repo, number, plugins.FormatICResponse(ic.Comment, resp)) 148 } 149 150 // Only allow authors and org members to add currentLabels. 151 isMember, err := gc.IsMember(ic.Repo.Owner.Login, ic.Comment.User.Login) 152 if err != nil { 153 return err 154 } 155 156 isAuthor := ic.Issue.IsAuthor(ic.Comment.User.Login) 157 158 if !isMember && !isAuthor { 159 format := "you can only set the release note label to %s if you are the PR author or an org member." 160 resp := fmt.Sprintf(format, labels.ReleaseNoteNone) 161 return gc.CreateComment(org, repo, number, plugins.FormatICResponse(ic.Comment, resp)) 162 } 163 164 // Don't allow the /release-note-none command if the release-note block contains a valid release note. 165 blockNL := determineReleaseNoteLabel(ic.Issue.Body, labelsSet(ic.Issue.Labels)) 166 if blockNL == labels.ReleaseNote || blockNL == labels.ReleaseNoteActionRequired { 167 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\"." 168 resp := fmt.Sprintf(format, labels.ReleaseNoteNone) 169 return gc.CreateComment(org, repo, number, plugins.FormatICResponse(ic.Comment, resp)) 170 } 171 172 // Don't allow /release-note-none command if the PR has a 'kind/deprecation' 173 // label. 174 if ic.Issue.HasLabel(labels.DeprecationLabel) { 175 format := "you can not set the release note label to \"%s\" because the PR has the label \"%s\"." 176 resp := fmt.Sprintf(format, labels.ReleaseNoteNone, labels.DeprecationLabel) 177 return gc.CreateComment(org, repo, number, plugins.FormatICResponse(ic.Comment, resp)) 178 } 179 180 if !ic.Issue.HasLabel(labels.ReleaseNoteNone) { 181 if err := gc.AddLabel(org, repo, number, labels.ReleaseNoteNone); err != nil { 182 return err 183 } 184 } 185 186 currentLabels := sets.Set[string]{} 187 for _, label := range ic.Issue.Labels { 188 currentLabels.Insert(label.Name) 189 } 190 // Remove all other release-note-* currentLabels if necessary. 191 return removeOtherLabels( 192 func(l string) error { 193 return gc.RemoveLabel(org, repo, number, l) 194 }, 195 labels.ReleaseNoteNone, 196 allRNLabels, 197 currentLabels, 198 ) 199 } 200 201 func removeOtherLabels(remover func(string) error, label string, labelSet []string, currentLabels sets.Set[string]) error { 202 var errs []error 203 for _, elem := range labelSet { 204 if elem != label && currentLabels.Has(elem) { 205 if err := remover(elem); err != nil { 206 errs = append(errs, err) 207 } 208 currentLabels.Delete(elem) 209 } 210 } 211 if len(errs) > 0 { 212 return fmt.Errorf("encountered %d errors setting labels: %v", len(errs), errs) 213 } 214 return nil 215 } 216 217 func handlePullRequest(pc plugins.Agent, pr github.PullRequestEvent) error { 218 return handlePR(pc.GitHubClient, pc.Logger, &pr) 219 } 220 221 func shouldHandlePR(pr *github.PullRequestEvent) bool { 222 // Only consider events that edit the PR body or add a label 223 if pr.Action != github.PullRequestActionOpened && 224 pr.Action != github.PullRequestActionEdited && 225 pr.Action != github.PullRequestActionLabeled { 226 return false 227 } 228 229 // Ignoring unrelated PR labels prevents duplicate release note messages 230 if pr.Action == github.PullRequestActionLabeled { 231 for _, rnLabel := range allRNLabels { 232 if pr.Label.Name == rnLabel { 233 return true 234 } 235 } 236 return false 237 } 238 239 return true 240 } 241 242 func handlePR(gc githubClient, log *logrus.Entry, pr *github.PullRequestEvent) error { 243 if !shouldHandlePR(pr) { 244 return nil 245 } 246 org := pr.Repo.Owner.Login 247 repo := pr.Repo.Name 248 249 prInitLabels, err := gc.GetIssueLabels(org, repo, pr.Number) 250 if err != nil { 251 return fmt.Errorf("failed to list labels on PR #%d. err: %w", pr.Number, err) 252 } 253 prLabels := labelsSet(prInitLabels) 254 255 var comments []github.IssueComment 256 labelToAdd := determineReleaseNoteLabel(pr.PullRequest.Body, prLabels) 257 258 if labelToAdd == labels.ReleaseNoteLabelNeeded { 259 //Do not add do not merge label when the PR is merged 260 if pr.PullRequest.Merged { 261 return nil 262 } 263 if !prMustFollowRelNoteProcess(gc, log, pr, prLabels, true) { 264 ensureNoRelNoteNeededLabel(gc, log, pr, prLabels) 265 return clearStaleComments(gc, log, pr, prLabels, nil) 266 } 267 268 if prLabels.Has(labels.DeprecationLabel) { 269 if !prLabels.Has(labels.ReleaseNoteLabelNeeded) { 270 comment := plugins.FormatSimpleResponse(releaseNoteDeprecationBody) 271 if err := gc.CreateComment(org, repo, pr.Number, comment); err != nil { 272 log.WithError(err).Errorf("Failed to comment on %s/%s#%d with comment %q.", org, repo, pr.Number, comment) 273 } 274 } 275 } else { 276 comments, err = gc.ListIssueComments(org, repo, pr.Number) 277 if err != nil { 278 return fmt.Errorf("failed to list comments on %s/%s#%d. err: %w", org, repo, pr.Number, err) 279 } 280 if containsNoneCommand(comments) { 281 labelToAdd = labels.ReleaseNoteNone 282 } else if !prLabels.Has(labels.ReleaseNoteLabelNeeded) { 283 comment := plugins.FormatSimpleResponse(releaseNoteBody) 284 if err := gc.CreateComment(org, repo, pr.Number, comment); err != nil { 285 log.WithError(err).Errorf("Failed to comment on %s/%s#%d with comment %q.", org, repo, pr.Number, comment) 286 } 287 } 288 } 289 } 290 291 // Add the label if needed 292 if !prLabels.Has(labelToAdd) { 293 if err = gc.AddLabel(org, repo, pr.Number, labelToAdd); err != nil { 294 return err 295 } 296 prLabels.Insert(labelToAdd) 297 } 298 299 err = removeOtherLabels( 300 func(l string) error { 301 return gc.RemoveLabel(org, repo, pr.Number, l) 302 }, 303 labelToAdd, 304 allRNLabels, 305 prLabels, 306 ) 307 if err != nil { 308 log.Error(err) 309 } 310 311 return clearStaleComments(gc, log, pr, prLabels, comments) 312 } 313 314 // clearStaleComments deletes old comments that are no longer applicable. 315 func clearStaleComments(gc githubClient, log *logrus.Entry, pr *github.PullRequestEvent, prLabels sets.Set[string], comments []github.IssueComment) error { 316 // If the PR must follow the process and hasn't yet completed the process, don't remove comments. 317 if prMustFollowRelNoteProcess(gc, log, pr, prLabels, false) && !releaseNoteAlreadyAdded(prLabels) { 318 return nil 319 } 320 botUserChecker, err := gc.BotUserChecker() 321 if err != nil { 322 return err 323 } 324 return gc.DeleteStaleComments( 325 pr.Repo.Owner.Login, 326 pr.Repo.Name, 327 pr.Number, 328 comments, 329 func(c github.IssueComment) bool { // isStale function 330 return botUserChecker(c.User.Login) && 331 (strings.Contains(c.Body, releaseNoteBody) || 332 strings.Contains(c.Body, parentReleaseNoteBody)) 333 }, 334 ) 335 } 336 337 func containsNoneCommand(comments []github.IssueComment) bool { 338 for _, c := range comments { 339 if releaseNoteNoneRe.MatchString(c.Body) { 340 return true 341 } 342 } 343 return false 344 } 345 346 func ensureNoRelNoteNeededLabel(gc githubClient, log *logrus.Entry, pr *github.PullRequestEvent, prLabels sets.Set[string]) { 347 org := pr.Repo.Owner.Login 348 repo := pr.Repo.Name 349 format := "Failed to remove the label %q from %s/%s#%d." 350 if prLabels.Has(labels.ReleaseNoteLabelNeeded) { 351 if err := gc.RemoveLabel(org, repo, pr.Number, labels.ReleaseNoteLabelNeeded); err != nil { 352 log.WithError(err).Errorf(format, labels.ReleaseNoteLabelNeeded, org, repo, pr.Number) 353 } 354 } 355 } 356 357 // determineReleaseNoteLabel returns the label to be added based on the contents of the 'release-note' 358 // section of a PR's body text, as well as the set of PR's labels. 359 func determineReleaseNoteLabel(body string, prLabels sets.Set[string]) string { 360 composedReleaseNote := strings.ToLower(strings.TrimSpace(getReleaseNote(body))) 361 hasNoneNoteInPRBody := noneRe.MatchString(composedReleaseNote) 362 hasDeprecationLabel := prLabels.Has(labels.DeprecationLabel) 363 364 switch { 365 case composedReleaseNote == "" && hasDeprecationLabel: 366 return labels.ReleaseNoteLabelNeeded 367 case composedReleaseNote == "" && prLabels.Has(labels.ReleaseNoteNone): 368 return labels.ReleaseNoteNone 369 case composedReleaseNote == "": 370 return labels.ReleaseNoteLabelNeeded 371 case hasNoneNoteInPRBody && hasDeprecationLabel: 372 return labels.ReleaseNoteLabelNeeded 373 case hasNoneNoteInPRBody: 374 return labels.ReleaseNoteNone 375 case strings.Contains(composedReleaseNote, actionRequiredNote): 376 return labels.ReleaseNoteActionRequired 377 default: 378 return labels.ReleaseNote 379 } 380 } 381 382 // getReleaseNote returns the release note from a PR body 383 // assumes that the PR body followed the PR template 384 func getReleaseNote(body string) string { 385 potentialMatch := noteMatcherRE.FindStringSubmatch(body) 386 if potentialMatch == nil { 387 return "" 388 } 389 return strings.TrimSpace(potentialMatch[1]) 390 } 391 392 func releaseNoteAlreadyAdded(prLabels sets.Set[string]) bool { 393 return prLabels.HasAny(labels.ReleaseNote, labels.ReleaseNoteActionRequired, labels.ReleaseNoteNone) 394 } 395 396 func prMustFollowRelNoteProcess(gc githubClient, log *logrus.Entry, pr *github.PullRequestEvent, prLabels sets.Set[string], comment bool) bool { 397 if pr.PullRequest.Base.Ref == "master" { 398 return true 399 } 400 401 parents := getCherrypickParentPRNums(pr.PullRequest.Body) 402 // if it has no parents it needs to follow the release note process 403 if len(parents) == 0 { 404 return true 405 } 406 407 org := pr.Repo.Owner.Login 408 repo := pr.Repo.Name 409 410 var notelessParents []string 411 for _, parent := range parents { 412 // If the parent didn't set a release note, the CP must 413 parentLabels, err := gc.GetIssueLabels(org, repo, parent) 414 if err != nil { 415 log.WithError(err).Errorf("Failed to list labels on PR #%d (parent of #%d).", parent, pr.Number) 416 continue 417 } 418 if !github.HasLabel(labels.ReleaseNote, parentLabels) && 419 !github.HasLabel(labels.ReleaseNoteActionRequired, parentLabels) { 420 notelessParents = append(notelessParents, "#"+strconv.Itoa(parent)) 421 } 422 } 423 if len(notelessParents) == 0 { 424 // All of the parents set the releaseNote or releaseNoteActionRequired label, 425 // so this cherrypick PR needs to do nothing. 426 return false 427 } 428 429 if comment && !prLabels.Has(labels.ReleaseNoteLabelNeeded) { 430 comment := plugins.FormatResponse( 431 pr.PullRequest.User.Login, 432 parentReleaseNoteBody, 433 fmt.Sprintf("The following parent PRs have neither the %q nor the %q labels: %s.", 434 labels.ReleaseNote, 435 labels.ReleaseNoteActionRequired, 436 strings.Join(notelessParents, ", "), 437 ), 438 ) 439 if err := gc.CreateComment(org, repo, pr.Number, comment); err != nil { 440 log.WithError(err).Errorf("Error creating comment on %s/%s#%d with comment %q.", org, repo, pr.Number, comment) 441 } 442 } 443 return true 444 } 445 446 func getCherrypickParentPRNums(body string) []int { 447 lines := strings.Split(body, "\n") 448 449 var out []int 450 for _, line := range lines { 451 matches := cpRe.FindStringSubmatch(line) 452 if len(matches) != 3 { 453 continue 454 } 455 parentNum, err := strconv.Atoi(matches[1]) 456 if err != nil { 457 continue 458 } 459 out = append(out, parentNum) 460 } 461 return out 462 } 463 464 func labelsSet(labels []github.Label) sets.Set[string] { 465 prLabels := sets.Set[string]{} 466 for _, label := range labels { 467 prLabels.Insert(label.Name) 468 } 469 return prLabels 470 } 471 472 // editReleaseNote is used to edit the top level release note. 473 // Since the edit itself triggers an event we don't need to worry 474 // about labels because the plugin will run again and handle them. 475 func editReleaseNote(gc githubClient, log *logrus.Entry, ic github.IssueCommentEvent) error { 476 org := ic.Repo.Owner.Login 477 repo := ic.Repo.Name 478 user := ic.Comment.User.Login 479 480 isMember, err := gc.IsMember(org, user) 481 if err != nil { 482 return fmt.Errorf("unable to fetch if %s is an org member of %s: %w", user, org, err) 483 } 484 if !isMember { 485 return gc.CreateComment( 486 org, repo, ic.Issue.Number, 487 plugins.FormatResponseRaw(ic.Comment.Body, ic.Issue.HTMLURL, user, "You must be an org member to edit the release note."), 488 ) 489 } 490 491 newNote := getReleaseNote(ic.Comment.Body) 492 if newNote == "" { 493 return gc.CreateComment( 494 org, repo, ic.Issue.Number, 495 plugins.FormatResponseRaw(ic.Comment.Body, ic.Comment.HTMLURL, user, "/release-note-edit must be used with a release note block."), 496 ) 497 } 498 499 // 0: start of release note block 500 // 1: end of release note block 501 // 2: start of release note content 502 // 3: end of release note content 503 i := noteMatcherRE.FindStringSubmatchIndex(ic.Issue.Body) 504 if len(i) != 4 { 505 return gc.CreateComment( 506 org, repo, ic.Issue.Number, 507 plugins.FormatResponseRaw(ic.Comment.Body, ic.Comment.HTMLURL, user, "/release-note-edit must be used with a single release note block."), 508 ) 509 } 510 // Splice in the contents of the new release note block to the top level comment 511 // This accounts for all older regex matches 512 b := []byte(ic.Issue.Body) 513 replaced := append(b[:i[2]], append([]byte("\r\n"+strings.TrimSpace(newNote)+"\r\n"), b[i[3]:]...)...) 514 ic.Issue.Body = string(replaced) 515 516 _, err = gc.EditIssue(ic.Repo.Owner.Login, ic.Repo.Name, ic.Issue.Number, &ic.Issue) 517 if err != nil { 518 return fmt.Errorf("unable to edit issue: %w", err) 519 } 520 log.WithFields(logrus.Fields{ 521 "user": user, 522 "org": org, 523 "repo": repo, 524 "issueNumber": ic.Issue.Number, 525 }).Info("edited release note") 526 return nil 527 }