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  }