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  }