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