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 }