github.com/shashidharatd/test-infra@v0.0.0-20171006011030-71304e1ca560/mungegithub/mungers/close-stale.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 mungers
    18  
    19  import (
    20  	"fmt"
    21  	"math"
    22  	"regexp"
    23  	"time"
    24  
    25  	"k8s.io/kubernetes/pkg/util/sets"
    26  	"k8s.io/test-infra/mungegithub/features"
    27  	"k8s.io/test-infra/mungegithub/github"
    28  	"k8s.io/test-infra/mungegithub/mungers/mungerutil"
    29  	"k8s.io/test-infra/mungegithub/options"
    30  
    31  	githubapi "github.com/google/go-github/github"
    32  )
    33  
    34  const (
    35  	day            = time.Hour * 24
    36  	keepOpenLabel  = "keep-open"
    37  	kindFlakeLabel = "kind/flake"
    38  	stalePeriod    = 90 * day // Close the PR/Issue if no human interaction for `stalePeriod`
    39  	startWarning   = 60 * day
    40  	remindWarning  = 30 * day
    41  	closingComment = `This %s hasn't been active in %s. Closing this %s. Please reopen if you would like to work towards merging this change, if/when the %s is ready for the next round of review.
    42  
    43  %s
    44  You can add 'keep-open' label to prevent this from happening again, or add a comment to keep it open another 90 days`
    45  	warningComment = `This %s hasn't been active in %s. It will be closed in %s (%s).
    46  
    47  %s
    48  You can add 'keep-open' label to prevent this from happening, or add a comment to keep it open another 90 days`
    49  )
    50  
    51  var (
    52  	closingCommentRE = regexp.MustCompile(`This \w+ hasn't been active in \d+ days?\..*label to prevent this from happening again`)
    53  	warningCommentRE = regexp.MustCompile(`This \w+ hasn't been active in \d+ days?\..*be closed in \d+ days?`)
    54  )
    55  
    56  // CloseStale will ask the Bot to close any PR/Issue that didn't
    57  // have any human interactions in `stalePeriod` duration.
    58  //
    59  // This is done by checking both review and issue comments, and by
    60  // ignoring comments done with a bot name. We also consider re-open on the PR/Issue.
    61  type CloseStale struct{}
    62  
    63  func init() {
    64  	s := CloseStale{}
    65  	RegisterMungerOrDie(s)
    66  	RegisterStaleIssueComments(s)
    67  }
    68  
    69  // Name is the name usable in --pr-mungers
    70  func (CloseStale) Name() string { return "close-stale" }
    71  
    72  // RequiredFeatures is a slice of 'features' that must be provided
    73  func (CloseStale) RequiredFeatures() []string { return []string{} }
    74  
    75  // Initialize will initialize the munger
    76  func (CloseStale) Initialize(config *github.Config, features *features.Features) error {
    77  	return nil
    78  }
    79  
    80  // EachLoop is called at the start of every munge loop
    81  func (CloseStale) EachLoop() error { return nil }
    82  
    83  // RegisterOptions registers options for this munger; returns any that require a restart when changed.
    84  func (CloseStale) RegisterOptions(opts *options.Options) sets.String { return nil }
    85  
    86  func findLastHumanPullRequestUpdate(obj *github.MungeObject) (*time.Time, bool) {
    87  	pr, ok := obj.GetPR()
    88  	if !ok {
    89  		return nil, ok
    90  	}
    91  
    92  	comments, ok := obj.ListReviewComments()
    93  	if !ok {
    94  		return nil, ok
    95  	}
    96  
    97  	lastHuman := pr.CreatedAt
    98  	for i := range comments {
    99  		comment := comments[i]
   100  		if comment.User == nil || comment.User.Login == nil || comment.CreatedAt == nil || comment.Body == nil {
   101  			continue
   102  		}
   103  		if obj.IsRobot(comment.User) || *comment.User.Login == jenkinsBotName {
   104  			continue
   105  		}
   106  		if lastHuman.Before(*comment.UpdatedAt) {
   107  			lastHuman = comment.UpdatedAt
   108  		}
   109  	}
   110  
   111  	return lastHuman, true
   112  }
   113  
   114  func findLastHumanIssueUpdate(obj *github.MungeObject) (*time.Time, bool) {
   115  	lastHuman := obj.Issue.CreatedAt
   116  
   117  	comments, ok := obj.ListComments()
   118  	if !ok {
   119  		return nil, ok
   120  	}
   121  
   122  	for i := range comments {
   123  		comment := comments[i]
   124  		if !validComment(comment) {
   125  			continue
   126  		}
   127  		if obj.IsRobot(comment.User) || jenkinsBotComment(comment) {
   128  			continue
   129  		}
   130  		if lastHuman.Before(*comment.UpdatedAt) {
   131  			lastHuman = comment.UpdatedAt
   132  		}
   133  	}
   134  
   135  	return lastHuman, true
   136  }
   137  
   138  func findLastInterestingEventUpdate(obj *github.MungeObject) (*time.Time, bool) {
   139  	lastInteresting := obj.Issue.CreatedAt
   140  
   141  	events, ok := obj.GetEvents()
   142  	if !ok {
   143  		return nil, ok
   144  	}
   145  
   146  	for i := range events {
   147  		event := events[i]
   148  		if event.Event == nil || *event.Event != "reopened" {
   149  			continue
   150  		}
   151  
   152  		if lastInteresting.Before(*event.CreatedAt) {
   153  			lastInteresting = event.CreatedAt
   154  		}
   155  	}
   156  
   157  	return lastInteresting, true
   158  }
   159  
   160  func findLastModificationTime(obj *github.MungeObject) (*time.Time, bool) {
   161  	lastHumanIssue, ok := findLastHumanIssueUpdate(obj)
   162  	if !ok {
   163  		return nil, ok
   164  	}
   165  
   166  	lastInterestingEvent, ok := findLastInterestingEventUpdate(obj)
   167  	if !ok {
   168  		return nil, ok
   169  	}
   170  
   171  	var lastModif *time.Time
   172  	lastModif = lastHumanIssue
   173  
   174  	if lastInterestingEvent.After(*lastModif) {
   175  		lastModif = lastInterestingEvent
   176  	}
   177  
   178  	if obj.IsPR() {
   179  		lastHumanPR, ok := findLastHumanPullRequestUpdate(obj)
   180  		if !ok {
   181  			return lastModif, true
   182  		}
   183  
   184  		if lastHumanPR.After(*lastModif) {
   185  			lastModif = lastHumanPR
   186  		}
   187  	}
   188  
   189  	return lastModif, true
   190  }
   191  
   192  // Find the last warning comment that the bot has posted.
   193  // It can return an empty comment if it fails to find one, even if there are no errors.
   194  func findLatestWarningComment(obj *github.MungeObject) (*githubapi.IssueComment, bool) {
   195  	var lastFoundComment *githubapi.IssueComment
   196  
   197  	comments, ok := obj.ListComments()
   198  	if !ok {
   199  		return nil, ok
   200  	}
   201  
   202  	for i := range comments {
   203  		comment := comments[i]
   204  		if !validComment(comment) {
   205  			continue
   206  		}
   207  		if !obj.IsRobot(comment.User) {
   208  			continue
   209  		}
   210  
   211  		if !warningCommentRE.MatchString(*comment.Body) {
   212  			continue
   213  		}
   214  
   215  		if lastFoundComment == nil || lastFoundComment.CreatedAt.Before(*comment.UpdatedAt) {
   216  			if lastFoundComment != nil {
   217  				obj.DeleteComment(lastFoundComment)
   218  			}
   219  			lastFoundComment = comment
   220  		}
   221  	}
   222  
   223  	return lastFoundComment, true
   224  }
   225  
   226  func dayPhrase(days int) string {
   227  	dayString := "days"
   228  	if days == 1 || days == -1 {
   229  		dayString = "day"
   230  	}
   231  	return fmt.Sprintf("%d %s", days, dayString)
   232  }
   233  
   234  func durationToMinDays(duration time.Duration) string {
   235  	days := int(math.Floor(duration.Hours() / 24))
   236  	return dayPhrase(days)
   237  }
   238  
   239  func durationToMaxDays(duration time.Duration) string {
   240  	days := int(math.Floor(duration.Hours() / 24))
   241  	return dayPhrase(days)
   242  }
   243  
   244  func closeObj(obj *github.MungeObject, inactiveFor time.Duration) {
   245  	mention := mungerutil.GetIssueUsers(obj.Issue).AllUsers().Mention().Join()
   246  	if mention != "" {
   247  		mention = "cc " + mention + "\n"
   248  	}
   249  
   250  	comment, ok := findLatestWarningComment(obj)
   251  	if !ok {
   252  		return
   253  	}
   254  	if comment != nil {
   255  		obj.DeleteComment(comment)
   256  	}
   257  
   258  	var objType string
   259  
   260  	if obj.IsPR() {
   261  		objType = "PR"
   262  	} else {
   263  		objType = "Issue"
   264  	}
   265  
   266  	obj.WriteComment(fmt.Sprintf(closingComment, objType, durationToMinDays(inactiveFor), objType, objType, mention))
   267  
   268  	if obj.IsPR() {
   269  		obj.ClosePR()
   270  	} else {
   271  		obj.CloseIssuef("")
   272  	}
   273  }
   274  
   275  func postWarningComment(obj *github.MungeObject, inactiveFor time.Duration, closeIn time.Duration) {
   276  	mention := mungerutil.GetIssueUsers(obj.Issue).AllUsers().Mention().Join()
   277  	if mention != "" {
   278  		mention = "cc " + mention + "\n"
   279  	}
   280  
   281  	closeDate := time.Now().Add(closeIn).Format("Jan 2, 2006")
   282  
   283  	var objType string
   284  
   285  	if obj.IsPR() {
   286  		objType = "PR"
   287  	} else {
   288  		objType = "Issue"
   289  	}
   290  
   291  	obj.WriteComment(fmt.Sprintf(
   292  		warningComment,
   293  		objType,
   294  		durationToMinDays(inactiveFor),
   295  		durationToMaxDays(closeIn),
   296  		closeDate,
   297  		mention,
   298  	))
   299  }
   300  
   301  func checkAndWarn(obj *github.MungeObject, inactiveFor time.Duration, closeIn time.Duration) {
   302  	if closeIn < day {
   303  		// We are going to close the PR/Issue in less than a day. Too late to warn
   304  		return
   305  	}
   306  	comment, ok := findLatestWarningComment(obj)
   307  	if !ok {
   308  		return
   309  	}
   310  	if comment == nil {
   311  		// We don't already have the comment. Post it
   312  		postWarningComment(obj, inactiveFor, closeIn)
   313  	} else if time.Since(*comment.UpdatedAt) > remindWarning {
   314  		// It's time to warn again
   315  		obj.DeleteComment(comment)
   316  		postWarningComment(obj, inactiveFor, closeIn)
   317  	} else {
   318  		// We already have a warning, and it's not expired. Do nothing
   319  	}
   320  }
   321  
   322  // Munge is the workhorse that will actually close the PRs/Issues
   323  func (CloseStale) Munge(obj *github.MungeObject) {
   324  	if !obj.IsPR() && !obj.HasLabel(kindFlakeLabel) {
   325  		return
   326  	}
   327  
   328  	if obj.HasLabel(keepOpenLabel) {
   329  		return
   330  	}
   331  
   332  	lastModif, ok := findLastModificationTime(obj)
   333  	if !ok {
   334  		return
   335  	}
   336  
   337  	closeIn := -time.Since(lastModif.Add(stalePeriod))
   338  	inactiveFor := time.Since(*lastModif)
   339  	if closeIn <= 0 {
   340  		closeObj(obj, inactiveFor)
   341  	} else if closeIn <= startWarning {
   342  		checkAndWarn(obj, inactiveFor, closeIn)
   343  	} else {
   344  		// PR/Issue is active. Remove previous potential warning
   345  		comment, ok := findLatestWarningComment(obj)
   346  		if comment != nil && ok {
   347  			obj.DeleteComment(comment)
   348  		}
   349  	}
   350  }
   351  
   352  func (CloseStale) isStaleIssueComment(obj *github.MungeObject, comment *githubapi.IssueComment) bool {
   353  	if !obj.IsRobot(comment.User) {
   354  		return false
   355  	}
   356  
   357  	if !closingCommentRE.MatchString(*comment.Body) {
   358  		return false
   359  	}
   360  
   361  	return true
   362  }
   363  
   364  // StaleIssueComments returns a slice of stale issue comments.
   365  func (s CloseStale) StaleIssueComments(obj *github.MungeObject, comments []*githubapi.IssueComment) []*githubapi.IssueComment {
   366  	return forEachCommentTest(obj, comments, s.isStaleIssueComment)
   367  }