github.com/abayer/test-infra@v0.0.5/mungegithub/mungers/milestone-maintainer.go (about) 1 /* 2 Copyright 2017 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 "errors" 21 "fmt" 22 "math" 23 "sort" 24 "strings" 25 "time" 26 27 "k8s.io/apimachinery/pkg/util/sets" 28 "k8s.io/test-infra/mungegithub/features" 29 "k8s.io/test-infra/mungegithub/github" 30 "k8s.io/test-infra/mungegithub/mungers/approvers" 31 c "k8s.io/test-infra/mungegithub/mungers/matchers/comment" 32 "k8s.io/test-infra/mungegithub/mungers/matchers/event" 33 "k8s.io/test-infra/mungegithub/mungers/mungerutil" 34 "k8s.io/test-infra/mungegithub/options" 35 36 githubapi "github.com/google/go-github/github" 37 ) 38 39 type milestoneState int 40 41 type milestoneOptName string 42 43 // milestoneStateConfig defines the label and notification 44 // configuration for a given milestone state. 45 type milestoneStateConfig struct { 46 // The milestone label to apply to the label (all other milestone state labels will be removed) 47 label string 48 // The title of the notification message 49 title string 50 // Whether the notification should be repeated on the configured interval 51 warnOnInterval bool 52 // Whether sigs should be mentioned in the notification message 53 notifySIGs bool 54 } 55 56 const ( 57 day = time.Hour * 24 58 milestoneNotifierName = "MilestoneNotifier" 59 60 milestoneModeDev = "dev" 61 milestoneModeSlush = "slush" 62 milestoneModeFreeze = "freeze" 63 64 milestoneCurrent milestoneState = iota // No change is required. 65 milestoneNeedsLabeling // One or more priority/*, kind/* and sig/* labels are missing. 66 milestoneNeedsApproval // The status/needs-approval label is missing. 67 milestoneNeedsAttention // A status/* label is missing or an update is required. 68 milestoneNeedsRemoval // The issue needs to be removed from the milestone. 69 70 milestoneLabelsIncompleteLabel = "milestone/incomplete-labels" 71 milestoneNeedsApprovalLabel = "milestone/needs-approval" 72 milestoneNeedsAttentionLabel = "milestone/needs-attention" 73 milestoneRemovedLabel = "milestone/removed" 74 75 statusApprovedLabel = "status/approved-for-milestone" 76 statusInProgressLabel = "status/in-progress" 77 78 blockerLabel = "priority/critical-urgent" 79 80 sigLabelPrefix = "sig/" 81 sigMentionTemplate = "@kubernetes/sig-%s-misc" 82 83 milestoneOptModes = "milestone-modes" 84 milestoneOptWarningInterval = "milestone-warning-interval" 85 milestoneOptLabelGracePeriod = "milestone-label-grace-period" 86 milestoneOptApprovalGracePeriod = "milestone-approval-grace-period" 87 milestoneOptSlushUpdateInterval = "milestone-slush-update-interval" 88 milestoneOptFreezeUpdateInterval = "milestone-freeze-update-interval" 89 milestoneOptFreezeDate = "milestone-freeze-date" 90 91 milestoneDetail = `<details> 92 <summary>Help</summary> 93 <ul> 94 <li><a href="https://git.k8s.io/sig-release/ephemera/issues.md">Additional instructions</a></li> 95 <li><a href="https://go.k8s.io/bot-commands">Commands for setting labels</a></li> 96 </ul> 97 </details> 98 ` 99 100 milestoneMessageTemplate = ` 101 {{- if .warnUnapproved}} 102 **Action required**: This {{.objType}} must have the {{.approvedLabel}} label applied by a SIG maintainer.{{.unapprovedRemovalWarning}} 103 {{end -}} 104 {{- if .removeUnapproved}} 105 **Important**: This {{.objType}} was missing the {{.approvedLabel}} label for more than {{.approvalGracePeriod}}. 106 {{end -}} 107 {{- if .warnMissingInProgress}} 108 **Action required**: During code {{.mode}}, {{.objTypePlural}} in the milestone should be in progress. 109 If this {{.objType}} is not being actively worked on, please remove it from the milestone. 110 If it is being worked on, please add the {{.inProgressLabel}} label so it can be tracked with other in-flight {{.objTypePlural}}. 111 {{end -}} 112 {{- if .warnUpdateRequired}} 113 **Action Required**: This {{.objType}} has not been updated since {{.lastUpdated}}. Please provide an update. 114 {{end -}} 115 {{- if .warnUpdateInterval}} 116 **Note**: This {{.objType}} is marked as {{.blockerLabel}}, and must be updated every {{.updateInterval}} during code {{.mode}}. 117 118 Example update: 119 120 ` + "```" + ` 121 ACK. In progress 122 ETA: DD/MM/YYYY 123 Risks: Complicated fix required 124 ` + "```" + ` 125 {{end -}} 126 {{- if .warnNonBlockerRemoval}} 127 **Note**: If this {{.objType}} is not resolved or labeled as {{.blockerLabel}} by {{.freezeDate}} it will be moved out of the {{.milestone}}. 128 {{end -}} 129 {{- if .removeNonBlocker}} 130 **Important**: Code freeze is in effect and only {{.objTypePlural}} with {{.blockerLabel}} may remain in the {{.milestone}}. 131 {{end -}} 132 {{- if .warnIncompleteLabels}} 133 **Action required**: This {{.objType}} requires label changes.{{.incompleteLabelsRemovalWarning}} 134 135 {{range $index, $labelError := .labelErrors -}} 136 {{$labelError}} 137 {{end -}} 138 {{end -}} 139 {{- if .removeIncompleteLabels}} 140 **Important**: This {{.objType}} was missing labels required for the {{.milestone}} for more than {{.labelGracePeriod}}: 141 142 {{range $index, $labelError := .labelErrors -}} 143 {{$labelError}} 144 {{end}} 145 {{end -}} 146 {{- if .summarizeLabels -}} 147 <details{{if .onlySummary}} open{{end}}> 148 <summary>{{.objTypeTitle}} Labels</summary> 149 150 - {{range $index, $sigLabel := .sigLabels}}{{if $index}} {{end}}{{$sigLabel}}{{end}}: {{.objTypeTitle}} will be escalated to these SIGs if needed. 151 - {{.priorityLabel}}: {{.priorityDescription}} 152 - {{.kindLabel}}: {{.kindDescription}} 153 </details> 154 {{- end -}} 155 ` 156 ) 157 158 var ( 159 milestoneModes = sets.NewString(milestoneModeDev, milestoneModeSlush, milestoneModeFreeze) 160 161 milestoneStateConfigs = map[milestoneState]milestoneStateConfig{ 162 milestoneCurrent: { 163 title: "Milestone %s: **Up-to-date for process**", 164 }, 165 milestoneNeedsLabeling: { 166 title: "Milestone %s Labels **Incomplete**", 167 label: milestoneLabelsIncompleteLabel, 168 warnOnInterval: true, 169 }, 170 milestoneNeedsApproval: { 171 title: "Milestone %s **Needs Approval**", 172 label: milestoneNeedsApprovalLabel, 173 warnOnInterval: true, 174 notifySIGs: true, 175 }, 176 milestoneNeedsAttention: { 177 title: "Milestone %s **Needs Attention**", 178 label: milestoneNeedsAttentionLabel, 179 warnOnInterval: true, 180 notifySIGs: true, 181 }, 182 milestoneNeedsRemoval: { 183 title: "Milestone **Removed** From %s", 184 label: milestoneRemovedLabel, 185 notifySIGs: true, 186 }, 187 } 188 189 // milestoneStateLabels is the set of milestone labels applied by 190 // the munger. statusApprovedLabel is not included because it is 191 // applied manually rather than by the munger. 192 milestoneStateLabels = []string{ 193 milestoneLabelsIncompleteLabel, 194 milestoneNeedsApprovalLabel, 195 milestoneNeedsAttentionLabel, 196 milestoneRemovedLabel, 197 } 198 199 kindMap = map[string]string{ 200 "kind/bug": "Fixes a bug discovered during the current release.", 201 "kind/feature": "New functionality.", 202 "kind/cleanup": "Adding tests, refactoring, fixing old bugs.", 203 } 204 205 priorityMap = map[string]string{ 206 blockerLabel: "Never automatically move %s out of a release milestone; continually escalate to contributor and SIG through all available channels.", 207 "priority/important-soon": "Escalate to the %s owners and SIG owner; move out of milestone after several unsuccessful escalation attempts.", 208 "priority/important-longterm": "Escalate to the %s owners; move out of the milestone after 1 attempt.", 209 } 210 ) 211 212 // issueChange encapsulates changes to make to an issue. 213 type issueChange struct { 214 notification *c.Notification 215 label string 216 commentInterval *time.Duration 217 removeFromMilestone bool 218 } 219 220 type milestoneArgValidator func(name string) error 221 222 // MilestoneMaintainer enforces the process for shepherding issues into the release. 223 type MilestoneMaintainer struct { 224 botName string 225 features *features.Features 226 validators map[string]milestoneArgValidator 227 228 milestoneModes string 229 milestoneModeMap map[string]string 230 warningInterval time.Duration 231 labelGracePeriod time.Duration 232 approvalGracePeriod time.Duration 233 slushUpdateInterval time.Duration 234 freezeUpdateInterval time.Duration 235 freezeDate string 236 } 237 238 func init() { 239 RegisterMungerOrDie(NewMilestoneMaintainer()) 240 } 241 242 func NewMilestoneMaintainer() *MilestoneMaintainer { 243 m := &MilestoneMaintainer{} 244 m.validators = map[string]milestoneArgValidator{ 245 milestoneOptModes: func(name string) error { 246 modeMap, err := parseMilestoneModes(m.milestoneModes) 247 if err != nil { 248 return fmt.Errorf("%s: %s", name, err) 249 } 250 m.milestoneModeMap = modeMap 251 return nil 252 }, 253 milestoneOptWarningInterval: func(name string) error { 254 return durationGreaterThanZero(name, m.warningInterval) 255 }, 256 milestoneOptLabelGracePeriod: func(name string) error { 257 return durationGreaterThanZero(name, m.labelGracePeriod) 258 }, 259 milestoneOptApprovalGracePeriod: func(name string) error { 260 return durationGreaterThanZero(name, m.approvalGracePeriod) 261 }, 262 milestoneOptSlushUpdateInterval: func(name string) error { 263 return durationGreaterThanZero(name, m.slushUpdateInterval) 264 }, 265 milestoneOptFreezeUpdateInterval: func(name string) error { 266 return durationGreaterThanZero(name, m.freezeUpdateInterval) 267 }, 268 milestoneOptFreezeDate: func(name string) error { 269 if len(m.freezeDate) == 0 { 270 return fmt.Errorf("%s must be supplied", name) 271 } 272 return nil 273 }, 274 } 275 return m 276 } 277 func durationGreaterThanZero(name string, value time.Duration) error { 278 if value <= 0 { 279 return fmt.Errorf("%s must be greater than zero", name) 280 } 281 return nil 282 } 283 284 func dayPhrase(days int) string { 285 dayString := "days" 286 if days == 1 || days == -1 { 287 dayString = "day" 288 } 289 return fmt.Sprintf("%d %s", days, dayString) 290 } 291 292 func durationToMaxDays(duration time.Duration) string { 293 days := int(math.Ceil(duration.Hours() / 24)) 294 return dayPhrase(days) 295 } 296 297 func findLastHumanPullRequestUpdate(obj *github.MungeObject) (*time.Time, bool) { 298 pr, ok := obj.GetPR() 299 if !ok { 300 return nil, ok 301 } 302 303 comments, ok := obj.ListReviewComments() 304 if !ok { 305 return nil, ok 306 } 307 308 lastHuman := pr.CreatedAt 309 for i := range comments { 310 comment := comments[i] 311 if comment.User == nil || comment.User.Login == nil || comment.CreatedAt == nil || comment.Body == nil { 312 continue 313 } 314 if obj.IsRobot(comment.User) || *comment.User.Login == jenkinsBotName { 315 continue 316 } 317 if lastHuman.Before(*comment.UpdatedAt) { 318 lastHuman = comment.UpdatedAt 319 } 320 } 321 322 return lastHuman, true 323 } 324 325 func findLastHumanIssueUpdate(obj *github.MungeObject) (*time.Time, bool) { 326 lastHuman := obj.Issue.CreatedAt 327 328 comments, ok := obj.ListComments() 329 if !ok { 330 return nil, ok 331 } 332 333 for i := range comments { 334 comment := comments[i] 335 if !validComment(comment) { 336 continue 337 } 338 if obj.IsRobot(comment.User) || jenkinsBotComment(comment) { 339 continue 340 } 341 if lastHuman.Before(*comment.UpdatedAt) { 342 lastHuman = comment.UpdatedAt 343 } 344 } 345 346 return lastHuman, true 347 } 348 349 func findLastInterestingEventUpdate(obj *github.MungeObject) (*time.Time, bool) { 350 lastInteresting := obj.Issue.CreatedAt 351 352 events, ok := obj.GetEvents() 353 if !ok { 354 return nil, ok 355 } 356 357 for i := range events { 358 event := events[i] 359 if event.Event == nil || *event.Event != "reopened" { 360 continue 361 } 362 363 if lastInteresting.Before(*event.CreatedAt) { 364 lastInteresting = event.CreatedAt 365 } 366 } 367 368 return lastInteresting, true 369 } 370 371 func findLastModificationTime(obj *github.MungeObject) (*time.Time, bool) { 372 lastHumanIssue, ok := findLastHumanIssueUpdate(obj) 373 if !ok { 374 return nil, ok 375 } 376 377 lastInterestingEvent, ok := findLastInterestingEventUpdate(obj) 378 if !ok { 379 return nil, ok 380 } 381 382 var lastModif *time.Time 383 lastModif = lastHumanIssue 384 385 if lastInterestingEvent.After(*lastModif) { 386 lastModif = lastInterestingEvent 387 } 388 389 if obj.IsPR() { 390 lastHumanPR, ok := findLastHumanPullRequestUpdate(obj) 391 if !ok { 392 return lastModif, true 393 } 394 395 if lastHumanPR.After(*lastModif) { 396 lastModif = lastHumanPR 397 } 398 } 399 400 return lastModif, true 401 } 402 403 // parseMilestoneModes transforms a string containing milestones and 404 // their modes to a map: 405 // 406 // "v1.8=dev,v1.9=slush" -> map[string][string]{"v1.8": "dev", "v1.9": "slush"} 407 func parseMilestoneModes(target string) (map[string]string, error) { 408 const invalidFormatTemplate = "expected format for each milestone is [milestone]=[mode], got '%s'" 409 410 result := map[string]string{} 411 tokens := strings.Split(target, ",") 412 for _, token := range tokens { 413 parts := strings.Split(token, "=") 414 if len(parts) != 2 { 415 return nil, fmt.Errorf(invalidFormatTemplate, token) 416 } 417 milestone := strings.TrimSpace(parts[0]) 418 mode := strings.TrimSpace(parts[1]) 419 if len(milestone) == 0 || len(mode) == 0 { 420 return nil, fmt.Errorf(invalidFormatTemplate, token) 421 } 422 if !milestoneModes.Has(mode) { 423 return nil, fmt.Errorf("mode for milestone '%s' must be one of %v, but got '%s'", milestone, milestoneModes.List(), mode) 424 } 425 if _, exists := result[milestone]; exists { 426 return nil, fmt.Errorf("milestone %s is specified more than once", milestone) 427 } 428 result[milestone] = mode 429 } 430 if len(result) == 0 { 431 return nil, fmt.Errorf("at least one milestone must be configured") 432 } 433 434 return result, nil 435 } 436 437 // Name is the name usable in --pr-mungers 438 func (m *MilestoneMaintainer) Name() string { return "milestone-maintainer" } 439 440 // RequiredFeatures is a slice of 'features' that must be provided 441 func (m *MilestoneMaintainer) RequiredFeatures() []string { return []string{} } 442 443 // Initialize will initialize the munger 444 func (m *MilestoneMaintainer) Initialize(config *github.Config, features *features.Features) error { 445 for name, validator := range m.validators { 446 if err := validator(name); err != nil { 447 return err 448 } 449 } 450 451 m.botName = config.BotName 452 m.features = features 453 return nil 454 } 455 456 // EachLoop is called at the start of every munge loop. This function 457 // is a no-op for the munger because to munge an issue it only needs 458 // the state local to the issue. 459 func (m *MilestoneMaintainer) EachLoop() error { return nil } 460 461 // RegisterOptions registers options for this munger; returns any that require a restart when changed. 462 func (m *MilestoneMaintainer) RegisterOptions(opts *options.Options) sets.String { 463 opts.RegisterString(&m.milestoneModes, milestoneOptModes, "", fmt.Sprintf("The comma-separated list of milestones and the mode to maintain them in (one of %v). Example: v1.8=%s,v1.9=%s", milestoneModes.List(), milestoneModeDev, milestoneModeSlush)) 464 opts.RegisterDuration(&m.warningInterval, milestoneOptWarningInterval, 24*time.Hour, "The interval to wait between warning about an incomplete issue/pr in the active milestone.") 465 opts.RegisterDuration(&m.labelGracePeriod, milestoneOptLabelGracePeriod, 72*time.Hour, "The grace period to wait before removing a non-blocking issue/pr with incomplete labels from the active milestone.") 466 opts.RegisterDuration(&m.approvalGracePeriod, milestoneOptApprovalGracePeriod, 168*time.Hour, "The grace period to wait before removing a non-blocking issue/pr without sig approval from the active milestone.") 467 opts.RegisterDuration(&m.slushUpdateInterval, milestoneOptSlushUpdateInterval, 72*time.Hour, "The expected interval, during code slush, between updates to a blocking issue/pr in the active milestone.") 468 opts.RegisterDuration(&m.freezeUpdateInterval, milestoneOptFreezeUpdateInterval, 24*time.Hour, "The expected interval, during code freeze, between updates to a blocking issue/pr in the active milestone.") 469 // Slush mode requires a freeze date to include in notifications 470 // indicating the date by which non-critical issues must be closed 471 // or upgraded in priority to avoid being moved out of the 472 // milestone. Only a single freeze date can be set under the 473 // assumption that, where multiple milestones are targeted, only 474 // one at a time will be in slush mode. 475 opts.RegisterString(&m.freezeDate, milestoneOptFreezeDate, "", fmt.Sprintf("The date string indicating when code freeze will take effect.")) 476 477 opts.RegisterUpdateCallback(func(changed sets.String) error { 478 for name, validator := range m.validators { 479 if changed.Has(name) { 480 if err := validator(name); err != nil { 481 return err 482 } 483 } 484 } 485 return nil 486 }) 487 return nil 488 } 489 490 func (m *MilestoneMaintainer) updateInterval(mode string) time.Duration { 491 if mode == milestoneModeSlush { 492 return m.slushUpdateInterval 493 } 494 if mode == milestoneModeFreeze { 495 return m.freezeUpdateInterval 496 } 497 return 0 498 } 499 500 // milestoneMode determines the release milestone and mode for the 501 // provided github object. If a milestone is set and one of those 502 // targeted by the munger, the milestone and mode will be returned 503 // along with a boolean indication of success. Otherwise, if the 504 // milestone is not set or not targeted, a boolean indication of 505 // failure will be returned. 506 func (m *MilestoneMaintainer) milestoneMode(obj *github.MungeObject) (milestone string, mode string, success bool) { 507 // Ignore issues that lack an assigned milestone 508 milestone, ok := obj.ReleaseMilestone() 509 if !ok || len(milestone) == 0 { 510 return "", "", false 511 } 512 513 // Ignore issues that aren't in a targeted milestone 514 mode, exists := m.milestoneModeMap[milestone] 515 if !exists { 516 return "", "", false 517 } 518 return milestone, mode, true 519 } 520 521 // Munge is the workhorse the will actually make updates to the issue 522 func (m *MilestoneMaintainer) Munge(obj *github.MungeObject) { 523 if ignoreObject(obj) { 524 return 525 } 526 527 change := m.issueChange(obj) 528 if change == nil { 529 return 530 } 531 532 if !updateMilestoneStateLabel(obj, change.label) { 533 return 534 } 535 536 comment, ok := latestNotificationComment(obj, m.botName) 537 if !ok { 538 return 539 } 540 if !notificationIsCurrent(change.notification, comment, change.commentInterval) { 541 if comment != nil { 542 if err := obj.DeleteComment(comment.Source.(*githubapi.IssueComment)); err != nil { 543 return 544 } 545 } 546 if err := change.notification.Post(obj); err != nil { 547 return 548 } 549 } 550 551 if change.removeFromMilestone { 552 obj.ClearMilestone() 553 } 554 } 555 556 // issueChange computes the changes required to modify the state of 557 // the issue to reflect the milestone process. If a nil return value 558 // is returned, no action should be taken. 559 func (m *MilestoneMaintainer) issueChange(obj *github.MungeObject) *issueChange { 560 icc := m.issueChangeConfig(obj) 561 if icc == nil { 562 return nil 563 } 564 565 messageBody := icc.messageBody() 566 if messageBody == nil { 567 return nil 568 } 569 570 stateConfig := milestoneStateConfigs[icc.state] 571 572 mentions := mungerutil.GetIssueUsers(obj.Issue).AllUsers().Mention().Join() 573 if stateConfig.notifySIGs { 574 sigMentions := icc.sigMentions() 575 if len(sigMentions) > 0 { 576 mentions = fmt.Sprintf("%s %s", mentions, sigMentions) 577 } 578 } 579 580 message := fmt.Sprintf("%s\n\n%s\n%s", mentions, *messageBody, milestoneDetail) 581 582 var commentInterval *time.Duration 583 if stateConfig.warnOnInterval { 584 commentInterval = &m.warningInterval 585 } 586 587 // Ensure the title refers to the correct type (issue or pr) 588 title := fmt.Sprintf(stateConfig.title, strings.Title(objTypeString(obj))) 589 590 return &issueChange{ 591 notification: c.NewNotification(milestoneNotifierName, title, message), 592 label: stateConfig.label, 593 removeFromMilestone: icc.state == milestoneNeedsRemoval, 594 commentInterval: commentInterval, 595 } 596 } 597 598 // issueChangeConfig computes the configuration required to determine 599 // the changes to make to an issue so that it reflects the milestone 600 // process. If a nil return value is returned, no action should be 601 // taken. 602 func (m *MilestoneMaintainer) issueChangeConfig(obj *github.MungeObject) *issueChangeConfig { 603 milestone, mode, ok := m.milestoneMode(obj) 604 if !ok { 605 return nil 606 } 607 608 updateInterval := m.updateInterval(mode) 609 610 objType := objTypeString(obj) 611 612 icc := &issueChangeConfig{ 613 enabledSections: sets.String{}, 614 templateArguments: map[string]interface{}{ 615 "approvalGracePeriod": durationToMaxDays(m.approvalGracePeriod), 616 "approvedLabel": quoteLabel(statusApprovedLabel), 617 "blockerLabel": quoteLabel(blockerLabel), 618 "freezeDate": m.freezeDate, 619 "inProgressLabel": quoteLabel(statusInProgressLabel), 620 "labelGracePeriod": durationToMaxDays(m.labelGracePeriod), 621 "milestone": fmt.Sprintf("%s milestone", milestone), 622 "mode": mode, 623 "objType": objType, 624 "objTypePlural": fmt.Sprintf("%ss", objType), 625 "objTypeTitle": strings.Title(objType), 626 "updateInterval": durationToMaxDays(updateInterval), 627 }, 628 sigLabels: []string{}, 629 } 630 631 isBlocker := obj.HasLabel(blockerLabel) 632 633 if kind, priority, sigs, labelErrors := checkLabels(obj.Issue.Labels); len(labelErrors) == 0 { 634 icc.summarizeLabels(objType, kind, priority, sigs) 635 if !obj.HasLabel(statusApprovedLabel) { 636 if isBlocker { 637 icc.warnUnapproved(nil, objType, milestone) 638 } else { 639 removeAfter, ok := gracePeriodRemaining(obj, m.botName, milestoneNeedsApprovalLabel, m.approvalGracePeriod, time.Now(), false) 640 if !ok { 641 return nil 642 } 643 644 if removeAfter == nil || *removeAfter >= 0 { 645 icc.warnUnapproved(removeAfter, objType, milestone) 646 } else { 647 icc.removeUnapproved() 648 } 649 } 650 return icc 651 } 652 653 if mode == milestoneModeDev { 654 // Status and updates are not required for dev mode 655 return icc 656 } 657 658 if obj.IsPR() { 659 // Status and updates are not required for PRs, and 660 // non-blocking PRs should not be removed from the 661 // milestone. 662 return icc 663 } 664 665 if mode == milestoneModeFreeze && !isBlocker { 666 icc.removeNonBlocker() 667 return icc 668 } 669 670 if !obj.HasLabel(statusInProgressLabel) { 671 icc.warnMissingInProgress() 672 } 673 674 if !isBlocker { 675 icc.enableSection("warnNonBlockerRemoval") 676 } else if updateInterval > 0 { 677 lastUpdateTime, ok := findLastModificationTime(obj) 678 if !ok { 679 return nil 680 } 681 682 durationSinceUpdate := time.Since(*lastUpdateTime) 683 if durationSinceUpdate > updateInterval { 684 icc.warnUpdateRequired(*lastUpdateTime) 685 } 686 icc.enableSection("warnUpdateInterval") 687 } 688 } else { 689 removeAfter, ok := gracePeriodRemaining(obj, m.botName, milestoneLabelsIncompleteLabel, m.labelGracePeriod, time.Now(), isBlocker) 690 if !ok { 691 return nil 692 } 693 694 if removeAfter == nil || *removeAfter >= 0 { 695 icc.warnIncompleteLabels(removeAfter, labelErrors, objType, milestone) 696 } else { 697 icc.removeIncompleteLabels(labelErrors) 698 } 699 } 700 return icc 701 } 702 703 func objTypeString(obj *github.MungeObject) string { 704 if obj.IsPR() { 705 return "pull request" 706 } 707 return "issue" 708 } 709 710 // issueChangeConfig is the config required to change an issue (via 711 // comments and labeling) to reflect the reuqirements of the milestone 712 // maintainer. 713 type issueChangeConfig struct { 714 state milestoneState 715 enabledSections sets.String 716 sigLabels []string 717 templateArguments map[string]interface{} 718 } 719 720 func (icc *issueChangeConfig) messageBody() *string { 721 for _, sectionName := range icc.enabledSections.List() { 722 // If an issue will be removed from the milestone, suppress non-removal sections 723 if icc.state != milestoneNeedsRemoval || strings.HasPrefix(sectionName, "remove") { 724 icc.templateArguments[sectionName] = true 725 } 726 } 727 728 icc.templateArguments["onlySummary"] = icc.state == milestoneCurrent 729 730 return approvers.GenerateTemplateOrFail(milestoneMessageTemplate, "message", icc.templateArguments) 731 } 732 733 func (icc *issueChangeConfig) enableSection(sectionName string) { 734 icc.enabledSections.Insert(sectionName) 735 } 736 737 func (icc *issueChangeConfig) summarizeLabels(objType, kindLabel, priorityLabel string, sigLabels []string) { 738 icc.enableSection("summarizeLabels") 739 icc.state = milestoneCurrent 740 icc.sigLabels = sigLabels 741 quotedSigLabels := []string{} 742 for _, sigLabel := range sigLabels { 743 quotedSigLabels = append(quotedSigLabels, quoteLabel(sigLabel)) 744 } 745 arguments := map[string]interface{}{ 746 "kindLabel": quoteLabel(kindLabel), 747 "kindDescription": kindMap[kindLabel], 748 "priorityLabel": quoteLabel(priorityLabel), 749 "priorityDescription": fmt.Sprintf(priorityMap[priorityLabel], objType), 750 "sigLabels": quotedSigLabels, 751 } 752 for k, v := range arguments { 753 icc.templateArguments[k] = v 754 } 755 } 756 757 func (icc *issueChangeConfig) warnUnapproved(removeAfter *time.Duration, objType, milestone string) { 758 icc.enableSection("warnUnapproved") 759 icc.state = milestoneNeedsApproval 760 var warning string 761 if removeAfter != nil { 762 warning = fmt.Sprintf(" If the label is not applied within %s, the %s will be moved out of the %s milestone.", 763 durationToMaxDays(*removeAfter), objType, milestone) 764 } 765 icc.templateArguments["unapprovedRemovalWarning"] = warning 766 767 } 768 769 func (icc *issueChangeConfig) removeUnapproved() { 770 icc.enableSection("removeUnapproved") 771 icc.state = milestoneNeedsRemoval 772 } 773 774 func (icc *issueChangeConfig) removeNonBlocker() { 775 icc.enableSection("removeNonBlocker") 776 icc.state = milestoneNeedsRemoval 777 } 778 779 func (icc *issueChangeConfig) warnMissingInProgress() { 780 icc.enableSection("warnMissingInProgress") 781 icc.state = milestoneNeedsAttention 782 } 783 784 func (icc *issueChangeConfig) warnUpdateRequired(lastUpdated time.Time) { 785 icc.enableSection("warnUpdateRequired") 786 icc.state = milestoneNeedsAttention 787 icc.templateArguments["lastUpdated"] = lastUpdated.Format("Jan 2") 788 } 789 790 func (icc *issueChangeConfig) warnIncompleteLabels(removeAfter *time.Duration, labelErrors []string, objType, milestone string) { 791 icc.enableSection("warnIncompleteLabels") 792 icc.state = milestoneNeedsLabeling 793 var warning string 794 if removeAfter != nil { 795 warning = fmt.Sprintf(" If the required changes are not made within %s, the %s will be moved out of the %s milestone.", 796 durationToMaxDays(*removeAfter), objType, milestone) 797 } 798 icc.templateArguments["incompleteLabelsRemovalWarning"] = warning 799 icc.templateArguments["labelErrors"] = labelErrors 800 } 801 802 func (icc *issueChangeConfig) removeIncompleteLabels(labelErrors []string) { 803 icc.enableSection("removeIncompleteLabels") 804 icc.state = milestoneNeedsRemoval 805 icc.templateArguments["labelErrors"] = labelErrors 806 } 807 808 func (icc *issueChangeConfig) sigMentions() string { 809 mentions := []string{} 810 for _, label := range icc.sigLabels { 811 sig := strings.TrimPrefix(label, sigLabelPrefix) 812 target := fmt.Sprintf(sigMentionTemplate, sig) 813 mentions = append(mentions, target) 814 } 815 return strings.Join(mentions, " ") 816 } 817 818 // ignoreObject indicates whether the munger should ignore the given 819 // object. 820 func ignoreObject(obj *github.MungeObject) bool { 821 // Ignore closed 822 if obj.Issue.State != nil && *obj.Issue.State == "closed" { 823 return true 824 } 825 826 return false 827 } 828 829 // latestNotificationComment returns the most recent notification 830 // comment posted by the munger. 831 // 832 // Since the munger is careful to remove existing comments before 833 // adding new ones, only a single notification comment should exist. 834 func latestNotificationComment(obj *github.MungeObject, botName string) (*c.Comment, bool) { 835 issueComments, ok := obj.ListComments() 836 if !ok { 837 return nil, false 838 } 839 comments := c.FromIssueComments(issueComments) 840 notificationMatcher := c.MungerNotificationName(milestoneNotifierName, botName) 841 notifications := c.FilterComments(comments, notificationMatcher) 842 return notifications.GetLast(), true 843 } 844 845 // notificationIsCurrent indicates whether the given notification 846 // matches the most recent notification comment and the comment 847 // interval - if provided - has not been exceeded. 848 func notificationIsCurrent(notification *c.Notification, comment *c.Comment, commentInterval *time.Duration) bool { 849 oldNotification := c.ParseNotification(comment) 850 notificationsEqual := oldNotification != nil && oldNotification.Equal(notification) 851 return notificationsEqual && (commentInterval == nil || comment != nil && comment.CreatedAt != nil && time.Since(*comment.CreatedAt) < *commentInterval) 852 } 853 854 // gracePeriodRemaining returns the difference between the start of 855 // the grace period and the grace period interval. Returns nil the 856 // grace period start cannot be determined. 857 func gracePeriodRemaining(obj *github.MungeObject, botName, labelName string, gracePeriod time.Duration, defaultStart time.Time, isBlocker bool) (*time.Duration, bool) { 858 if isBlocker { 859 return nil, true 860 } 861 tempStart := gracePeriodStart(obj, botName, labelName, defaultStart) 862 if tempStart == nil { 863 return nil, false 864 } 865 start := *tempStart 866 867 remaining := -time.Since(start.Add(gracePeriod)) 868 return &remaining, true 869 } 870 871 // gracePeriodStart determines when the grace period for the given 872 // object should start as is indicated by when the 873 // milestone-labels-incomplete label was last applied. If the label 874 // is not set, the default will be returned. nil will be returned if 875 // an error occurs while accessing the object's label events. 876 func gracePeriodStart(obj *github.MungeObject, botName, labelName string, defaultStart time.Time) *time.Time { 877 if !obj.HasLabel(labelName) { 878 return &defaultStart 879 } 880 881 return labelLastCreatedAt(obj, botName, labelName) 882 } 883 884 // labelLastCreatedAt returns the time at which the given label was 885 // last applied to the given github object. Returns nil if an error 886 // occurs during event retrieval or if the label has never been set. 887 func labelLastCreatedAt(obj *github.MungeObject, botName, labelName string) *time.Time { 888 events, ok := obj.GetEvents() 889 if !ok { 890 return nil 891 } 892 893 labelMatcher := event.And([]event.Matcher{ 894 event.AddLabel{}, 895 event.LabelName(labelName), 896 event.Actor(botName), 897 }) 898 labelEvents := event.FilterEvents(events, labelMatcher) 899 lastAdded := labelEvents.GetLast() 900 if lastAdded != nil { 901 return lastAdded.CreatedAt 902 } 903 return nil 904 } 905 906 // checkLabels validates that the given labels are consistent with the 907 // requirements for an issue remaining in its chosen milestone. 908 // Returns the values of required labels (if present) and a slice of 909 // errors (where labels are not correct). 910 func checkLabels(labels []githubapi.Label) (kindLabel, priorityLabel string, sigLabels []string, labelErrors []string) { 911 labelErrors = []string{} 912 var err error 913 914 kindLabel, err = uniqueLabelName(labels, kindMap) 915 if err != nil || len(kindLabel) == 0 { 916 kindLabels := formatLabelString(kindMap) 917 labelErrors = append(labelErrors, fmt.Sprintf("_**kind**_: Must specify exactly one of %s.", kindLabels)) 918 } 919 920 priorityLabel, err = uniqueLabelName(labels, priorityMap) 921 if err != nil || len(priorityLabel) == 0 { 922 priorityLabels := formatLabelString(priorityMap) 923 labelErrors = append(labelErrors, fmt.Sprintf("_**priority**_: Must specify exactly one of %s.", priorityLabels)) 924 } 925 926 sigLabels = sigLabelNames(labels) 927 if len(sigLabels) == 0 { 928 labelErrors = append(labelErrors, fmt.Sprintf("_**sig owner**_: Must specify at least one label prefixed with `%s`.", sigLabelPrefix)) 929 } 930 931 return 932 } 933 934 // uniqueLabelName determines which label of a set indicated by a map 935 // - if any - is present in the given slice of labels. Returns an 936 // error if the slice contains more than one label from the set. 937 func uniqueLabelName(labels []githubapi.Label, labelMap map[string]string) (string, error) { 938 var labelName string 939 for _, label := range labels { 940 _, exists := labelMap[*label.Name] 941 if exists { 942 if len(labelName) == 0 { 943 labelName = *label.Name 944 } else { 945 return "", errors.New("Found more than one matching label") 946 } 947 } 948 } 949 return labelName, nil 950 } 951 952 // sigLabelNames returns a slice of the 'sig/' prefixed labels set on the issue. 953 func sigLabelNames(labels []githubapi.Label) []string { 954 labelNames := []string{} 955 for _, label := range labels { 956 if strings.HasPrefix(*label.Name, sigLabelPrefix) { 957 labelNames = append(labelNames, *label.Name) 958 } 959 } 960 return labelNames 961 } 962 963 // formatLabelString converts a map to a string in the format "`key-foo`, `key-bar`". 964 func formatLabelString(labelMap map[string]string) string { 965 labelList := []string{} 966 for k := range labelMap { 967 labelList = append(labelList, quoteLabel(k)) 968 } 969 sort.Strings(labelList) 970 971 maxIndex := len(labelList) - 1 972 if maxIndex == 0 { 973 return labelList[0] 974 } 975 return strings.Join(labelList[0:maxIndex], ", ") + " or " + labelList[maxIndex] 976 } 977 978 // quoteLabel formats a label name as inline code in markdown (e.g. `labelName`) 979 func quoteLabel(label string) string { 980 if len(label) > 0 { 981 return fmt.Sprintf("`%s`", label) 982 } 983 return label 984 } 985 986 // updateMilestoneStateLabel ensures that the given milestone state 987 // label is the only state label set on the given issue. 988 func updateMilestoneStateLabel(obj *github.MungeObject, labelName string) bool { 989 if len(labelName) > 0 && !obj.HasLabel(labelName) { 990 if err := obj.AddLabel(labelName); err != nil { 991 return false 992 } 993 } 994 for _, stateLabel := range milestoneStateLabels { 995 if stateLabel != labelName && obj.HasLabel(stateLabel) { 996 if err := obj.RemoveLabel(stateLabel); err != nil { 997 return false 998 } 999 } 1000 } 1001 return true 1002 }