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  }