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  }