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  }