github.com/shashidharatd/test-infra@v0.0.0-20171006011030-71304e1ca560/mungegithub/github/github.go (about)

     1  /*
     2  Copyright 2015 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 github
    18  
    19  import (
    20  	"bytes"
    21  	"context"
    22  	"encoding/json"
    23  	"errors"
    24  	"fmt"
    25  	"io/ioutil"
    26  	"math"
    27  	"net/http"
    28  	"net/url"
    29  	"regexp"
    30  	"strconv"
    31  	"strings"
    32  	"sync"
    33  	"text/tabwriter"
    34  	"time"
    35  
    36  	"k8s.io/kubernetes/pkg/util/sets"
    37  	"k8s.io/test-infra/mungegithub/options"
    38  
    39  	"github.com/golang/glog"
    40  	"github.com/google/go-github/github"
    41  	"github.com/gregjones/httpcache"
    42  	"github.com/gregjones/httpcache/diskcache"
    43  	"github.com/peterbourgon/diskv"
    44  	"golang.org/x/oauth2"
    45  )
    46  
    47  const (
    48  	// stolen from https://groups.google.com/forum/#!msg/golang-nuts/a9PitPAHSSU/ziQw1-QHw3EJ
    49  	maxInt     = int(^uint(0) >> 1)
    50  	tokenLimit = 250 // How many github api tokens to not use
    51  
    52  	headerRateRemaining = "X-RateLimit-Remaining"
    53  	headerRateReset     = "X-RateLimit-Reset"
    54  
    55  	maxCommentLen = 65535
    56  
    57  	ghApproved         = "APPROVED"
    58  	ghChangesRequested = "CHANGES_REQUESTED"
    59  )
    60  
    61  var (
    62  	releaseMilestoneRE = regexp.MustCompile(`^v[\d]+.[\d]$`)
    63  	priorityLabelRE    = regexp.MustCompile(`priority/[pP]([\d]+)`)
    64  	fixesIssueRE       = regexp.MustCompile(`(?i)(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[\s]+#([\d]+)`)
    65  	reviewableFooterRE = regexp.MustCompile(`(?s)<!-- Reviewable:start -->.*<!-- Reviewable:end -->`)
    66  	htmlCommentRE      = regexp.MustCompile(`(?s)<!--[^<>]*?-->\n?`)
    67  	maxTime            = time.Unix(1<<63-62135596801, 999999999) // http://stackoverflow.com/questions/25065055/what-is-the-maximum-time-time-in-go
    68  
    69  	// How long we locally cache the combined status of an object. We will not
    70  	// hit the github API more than this often (per mungeObject) no matter how
    71  	// often a caller asks for the status. Ca be much much faster for testing
    72  	combinedStatusLifetime = 5 * time.Second
    73  )
    74  
    75  func suggestOauthScopes(resp *github.Response, err error) error {
    76  	if resp != nil && resp.StatusCode == http.StatusForbidden {
    77  		if oauthScopes := resp.Header.Get("X-Accepted-OAuth-Scopes"); len(oauthScopes) > 0 {
    78  			err = fmt.Errorf("%v - are you using at least one of the following oauth scopes?: %s", err, oauthScopes)
    79  		}
    80  	}
    81  	return err
    82  }
    83  
    84  func stringPtr(val string) *string { return &val }
    85  func boolPtr(val bool) *bool       { return &val }
    86  
    87  type callLimitRoundTripper struct {
    88  	sync.Mutex
    89  	delegate  http.RoundTripper
    90  	remaining int
    91  	resetTime time.Time
    92  }
    93  
    94  func (c *callLimitRoundTripper) getTokenExcept(remaining int) {
    95  	c.Lock()
    96  	if c.remaining > remaining {
    97  		c.remaining--
    98  		c.Unlock()
    99  		return
   100  	}
   101  	resetTime := c.resetTime
   102  	c.Unlock()
   103  	sleepTime := resetTime.Sub(time.Now()) + (1 * time.Minute)
   104  	if sleepTime > 0 {
   105  		glog.Errorf("*****************")
   106  		glog.Errorf("Ran out of github API tokens. Sleeping for %v minutes", sleepTime.Minutes())
   107  		glog.Errorf("*****************")
   108  	}
   109  	// negative duration is fine, it means we are past the github api reset and we won't sleep
   110  	time.Sleep(sleepTime)
   111  }
   112  
   113  func (c *callLimitRoundTripper) getToken() {
   114  	c.getTokenExcept(tokenLimit)
   115  }
   116  
   117  func (c *callLimitRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
   118  	if c.delegate == nil {
   119  		c.delegate = http.DefaultTransport
   120  	}
   121  	c.getToken()
   122  	resp, err := c.delegate.RoundTrip(req)
   123  	c.Lock()
   124  	defer c.Unlock()
   125  	if resp != nil {
   126  		if remaining := resp.Header.Get(headerRateRemaining); remaining != "" {
   127  			c.remaining, _ = strconv.Atoi(remaining)
   128  		}
   129  		if reset := resp.Header.Get(headerRateReset); reset != "" {
   130  			if v, _ := strconv.ParseInt(reset, 10, 64); v != 0 {
   131  				c.resetTime = time.Unix(v, 0)
   132  			}
   133  		}
   134  	}
   135  	return resp, err
   136  }
   137  
   138  // By default github responds to PR requests with:
   139  //    Cache-Control:[private, max-age=60, s-maxage=60]
   140  // Which means the httpcache would not consider anything stale for 60 seconds.
   141  // However, when we re-check 'PR.mergeable' we need to skip the cache.
   142  // I considered checking the req.URL.Path and only setting max-age=0 when
   143  // getting a PR or getting the CombinedStatus, as these are the times we need
   144  // a super fresh copy. But since all of the other calls are only going to be made
   145  // once per poll loop the 60 second github freshness doesn't matter. So I can't
   146  // think of a reason not to just keep this simple and always set max-age=0 on
   147  // every request.
   148  type zeroCacheRoundTripper struct {
   149  	delegate http.RoundTripper
   150  }
   151  
   152  func (r *zeroCacheRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
   153  	req.Header.Set("Cache-Control", "max-age=0")
   154  	delegate := r.delegate
   155  	if delegate == nil {
   156  		delegate = http.DefaultTransport
   157  	}
   158  	return delegate.RoundTrip(req)
   159  }
   160  
   161  // Config is how we are configured to talk to github and provides access
   162  // methods for doing so.
   163  type Config struct {
   164  	client   *github.Client
   165  	apiLimit *callLimitRoundTripper
   166  
   167  	// BotName is the login for the authenticated user
   168  	BotName string
   169  
   170  	Org         string
   171  	Project     string
   172  	Url         string
   173  	mergeMethod string
   174  
   175  	// Filters used when munging issues
   176  	State  string
   177  	Labels []string
   178  
   179  	// token is private so it won't get printed in the logs.
   180  	token      string
   181  	tokenFile  string
   182  	tokenInUse string
   183  
   184  	httpCache     httpcache.Cache
   185  	HTTPCacheDir  string
   186  	HTTPCacheSize uint64
   187  
   188  	MinPRNumber int
   189  	MaxPRNumber int
   190  
   191  	// If true, don't make any mutating API calls
   192  	DryRun bool
   193  
   194  	// Base sleep time for retry loops. Defaults to 1 second.
   195  	BaseWaitTime time.Duration
   196  
   197  	// When we clear analytics we store the last values here
   198  	lastAnalytics analytics
   199  	analytics     analytics
   200  
   201  	// Webhook configuration
   202  	HookHandler *WebHook
   203  
   204  	// Last fetch
   205  	since time.Time
   206  }
   207  
   208  type analytic struct {
   209  	Count       int
   210  	CachedCount int
   211  }
   212  
   213  func (a *analytic) Call(config *Config, response *github.Response) {
   214  	if response != nil && response.Response.Header.Get(httpcache.XFromCache) != "" {
   215  		config.analytics.cachedAPICount++
   216  		a.CachedCount++
   217  	}
   218  	config.analytics.apiCount++
   219  	a.Count++
   220  }
   221  
   222  type analytics struct {
   223  	lastAPIReset       time.Time
   224  	nextAnalyticUpdate time.Time // when we expect the next update
   225  	apiCount           int       // number of times we called a github API
   226  	cachedAPICount     int       // how many api calls were answered by the local cache
   227  	apiPerSec          float64
   228  
   229  	AddLabels              analytic
   230  	AddLabelToRepository   analytic
   231  	RemoveLabels           analytic
   232  	ListCollaborators      analytic
   233  	GetIssue               analytic
   234  	CloseIssue             analytic
   235  	CreateIssue            analytic
   236  	ListIssues             analytic
   237  	ListIssueEvents        analytic
   238  	ListCommits            analytic
   239  	ListLabels             analytic
   240  	GetCommit              analytic
   241  	ListFiles              analytic
   242  	GetCombinedStatus      analytic
   243  	SetStatus              analytic
   244  	GetPR                  analytic
   245  	AddAssignee            analytic
   246  	RemoveAssignees        analytic
   247  	ClosePR                analytic
   248  	OpenPR                 analytic
   249  	GetContents            analytic
   250  	ListComments           analytic
   251  	ListReviewComments     analytic
   252  	CreateComment          analytic
   253  	DeleteComment          analytic
   254  	EditComment            analytic
   255  	Merge                  analytic
   256  	GetUser                analytic
   257  	ClearMilestone         analytic
   258  	SetMilestone           analytic
   259  	ListMilestones         analytic
   260  	GetBranch              analytic
   261  	UpdateBranchProtection analytic
   262  	GetBranchProtection    analytic
   263  	ListReviews            analytic
   264  }
   265  
   266  func (a analytics) print() {
   267  	glog.Infof("Made %d API calls since the last Reset %f calls/sec", a.apiCount, a.apiPerSec)
   268  
   269  	buf := new(bytes.Buffer)
   270  	w := new(tabwriter.Writer)
   271  	w.Init(buf, 0, 0, 1, ' ', tabwriter.AlignRight)
   272  	fmt.Fprintf(w, "AddLabels\t%d\t\n", a.AddLabels.Count)
   273  	fmt.Fprintf(w, "AddLabelToRepository\t%d\t\n", a.AddLabelToRepository.Count)
   274  	fmt.Fprintf(w, "RemoveLabels\t%d\t\n", a.RemoveLabels.Count)
   275  	fmt.Fprintf(w, "ListCollaborators\t%d\t\n", a.ListCollaborators.Count)
   276  	fmt.Fprintf(w, "GetIssue\t%d\t\n", a.GetIssue.Count)
   277  	fmt.Fprintf(w, "CloseIssue\t%d\t\n", a.CloseIssue.Count)
   278  	fmt.Fprintf(w, "CreateIssue\t%d\t\n", a.CreateIssue.Count)
   279  	fmt.Fprintf(w, "ListIssues\t%d\t\n", a.ListIssues.Count)
   280  	fmt.Fprintf(w, "ListIssueEvents\t%d\t\n", a.ListIssueEvents.Count)
   281  	fmt.Fprintf(w, "ListCommits\t%d\t\n", a.ListCommits.Count)
   282  	fmt.Fprintf(w, "ListLabels\t%d\t\n", a.ListLabels.Count)
   283  	fmt.Fprintf(w, "GetCommit\t%d\t\n", a.GetCommit.Count)
   284  	fmt.Fprintf(w, "ListFiles\t%d\t\n", a.ListFiles.Count)
   285  	fmt.Fprintf(w, "GetCombinedStatus\t%d\t\n", a.GetCombinedStatus.Count)
   286  	fmt.Fprintf(w, "SetStatus\t%d\t\n", a.SetStatus.Count)
   287  	fmt.Fprintf(w, "GetPR\t%d\t\n", a.GetPR.Count)
   288  	fmt.Fprintf(w, "AddAssignee\t%d\t\n", a.AddAssignee.Count)
   289  	fmt.Fprintf(w, "ClosePR\t%d\t\n", a.ClosePR.Count)
   290  	fmt.Fprintf(w, "OpenPR\t%d\t\n", a.OpenPR.Count)
   291  	fmt.Fprintf(w, "GetContents\t%d\t\n", a.GetContents.Count)
   292  	fmt.Fprintf(w, "ListReviewComments\t%d\t\n", a.ListReviewComments.Count)
   293  	fmt.Fprintf(w, "ListComments\t%d\t\n", a.ListComments.Count)
   294  	fmt.Fprintf(w, "CreateComment\t%d\t\n", a.CreateComment.Count)
   295  	fmt.Fprintf(w, "DeleteComment\t%d\t\n", a.DeleteComment.Count)
   296  	fmt.Fprintf(w, "Merge\t%d\t\n", a.Merge.Count)
   297  	fmt.Fprintf(w, "GetUser\t%d\t\n", a.GetUser.Count)
   298  	fmt.Fprintf(w, "ClearMilestone\t%d\t\n", a.ClearMilestone.Count)
   299  	fmt.Fprintf(w, "SetMilestone\t%d\t\n", a.SetMilestone.Count)
   300  	fmt.Fprintf(w, "ListMilestones\t%d\t\n", a.ListMilestones.Count)
   301  	fmt.Fprintf(w, "GetBranch\t%d\t\n", a.GetBranch.Count)
   302  	fmt.Fprintf(w, "UpdateBranchProtection\t%d\t\n", a.UpdateBranchProtection.Count)
   303  	fmt.Fprintf(w, "GetBranchProctection\t%d\t\n", a.GetBranchProtection.Count)
   304  	fmt.Fprintf(w, "ListReviews\t%d\t\n", a.ListReviews.Count)
   305  	w.Flush()
   306  	glog.V(2).Infof("\n%v", buf)
   307  }
   308  
   309  // MungeObject is the object that mungers deal with. It is a combination of
   310  // different github API objects.
   311  type MungeObject struct {
   312  	config      *Config
   313  	Issue       *github.Issue
   314  	pr          *github.PullRequest
   315  	commits     []*github.RepositoryCommit
   316  	events      []*github.IssueEvent
   317  	comments    []*github.IssueComment
   318  	prComments  []*github.PullRequestComment
   319  	prReviews   []*github.PullRequestReview
   320  	commitFiles []*github.CommitFile
   321  
   322  	// we cache the combinedStatus for `combinedStatusLifetime` seconds.
   323  	combinedStatus     *github.CombinedStatus
   324  	combinedStatusTime time.Time
   325  
   326  	Annotations map[string]string //annotations are things you can set yourself.
   327  }
   328  
   329  // Number is short for *obj.Issue.Number.
   330  func (obj *MungeObject) Number() int {
   331  	return *obj.Issue.Number
   332  }
   333  
   334  // Project is getter for obj.config.Project.
   335  func (obj *MungeObject) Project() string {
   336  	return obj.config.Project
   337  }
   338  
   339  // Org is getter for obj.config.Org.
   340  func (obj *MungeObject) Org() string {
   341  	return obj.config.Org
   342  }
   343  
   344  // Config is a getter for obj.config.
   345  func (obj *MungeObject) Config() *Config {
   346  	return obj.config
   347  }
   348  
   349  // IsRobot determines if the user is the robot running the munger
   350  func (obj *MungeObject) IsRobot(user *github.User) bool {
   351  	return *user.Login == obj.config.BotName
   352  }
   353  
   354  // DebugStats is a structure that tells information about how we have interacted
   355  // with github
   356  type DebugStats struct {
   357  	Analytics      analytics
   358  	APIPerSec      float64
   359  	APICount       int
   360  	CachedAPICount int
   361  	NextLoopTime   time.Time
   362  	LimitRemaining int
   363  	LimitResetTime time.Time
   364  }
   365  
   366  // NewTestObject should NEVER be used outside of _test.go code. It creates a
   367  // MungeObject with the given fields. Normally these should be filled in lazily
   368  // as needed
   369  func NewTestObject(config *Config, issue *github.Issue, pr *github.PullRequest, commits []*github.RepositoryCommit, events []*github.IssueEvent) *MungeObject {
   370  	return &MungeObject{
   371  		config:      config,
   372  		Issue:       issue,
   373  		pr:          pr,
   374  		commits:     commits,
   375  		events:      events,
   376  		Annotations: map[string]string{},
   377  	}
   378  }
   379  
   380  // SetCombinedStatusLifetime will set the lifetime of CombinedStatus responses.
   381  // Even though we would likely use conditional API calls hitting the CombinedStatus API
   382  // every time we want to get a specific value is just too mean to github. This defaults
   383  // to `combinedStatusLifetime` seconds. If you are doing local testing you may want to make
   384  // this (much) shorter
   385  func SetCombinedStatusLifetime(lifetime time.Duration) {
   386  	combinedStatusLifetime = lifetime
   387  }
   388  
   389  // RegisterOptions registers options for the github client and returns any that require a restart
   390  // if they are changed.
   391  func (config *Config) RegisterOptions(opts *options.Options) sets.String {
   392  	opts.RegisterString(&config.Org, "organization", "", "The github organization to scan")
   393  	opts.RegisterString(&config.Project, "project", "", "The github project to scan")
   394  
   395  	opts.RegisterString(&config.token, "token", "", "The OAuth Token to use for requests.")
   396  	opts.RegisterString(&config.tokenFile, "token-file", "", "The file containing the OAuth token to use for requests.")
   397  	opts.RegisterInt(&config.MinPRNumber, "min-pr-number", 0, "The minimum PR to start with")
   398  	opts.RegisterInt(&config.MaxPRNumber, "max-pr-number", maxInt, "The maximum PR to start with")
   399  	opts.RegisterString(&config.State, "state", "", "State of PRs to process: 'open', 'all', etc")
   400  	opts.RegisterStringSlice(&config.Labels, "labels", []string{}, "CSV list of label which should be set on processed PRs. Unset is all labels.")
   401  	opts.RegisterString(&config.HTTPCacheDir, "http-cache-dir", "", "Path to directory where github data can be cached across restarts, if unset use in memory cache")
   402  	opts.RegisterUint64(&config.HTTPCacheSize, "http-cache-size", 1000, "Maximum size for the HTTP cache (in MB)")
   403  	opts.RegisterString(&config.Url, "url", "", "The GitHub Enterprise server url (default: https://api.github.com/)")
   404  	opts.RegisterString(&config.mergeMethod, "merge-method", "merge", "The merge method to use: merge/squash/rebase")
   405  
   406  	return sets.NewString("token", "token-file", "min-pr-number", "max-pr-number", "state", "labels", "http-cache-dir", "http-cache-size", "url")
   407  }
   408  
   409  // Token returns the token.
   410  func (config *Config) Token() string {
   411  	return config.tokenInUse
   412  }
   413  
   414  // PreExecute will initialize the Config. It MUST be run before the config
   415  // may be used to get information from Github.
   416  func (config *Config) PreExecute() error {
   417  	if len(config.Org) == 0 {
   418  		glog.Fatalf("The '%s' option is required.", "organization")
   419  	}
   420  	if len(config.Project) == 0 {
   421  		glog.Fatalf("The '%s' option is required.", "project")
   422  	}
   423  
   424  	token := config.token
   425  	if len(token) == 0 && len(config.tokenFile) != 0 {
   426  		data, err := ioutil.ReadFile(config.tokenFile)
   427  		if err != nil {
   428  			glog.Fatalf("error reading token file: %v", err)
   429  		}
   430  		token = strings.TrimSpace(string(data))
   431  		config.tokenInUse = token
   432  	}
   433  
   434  	// We need to get our Transport/RoundTripper in order based on arguments
   435  	//    oauth2 Transport // if we have an auth token
   436  	//    zeroCacheRoundTripper // if we are using the cache want faster timeouts
   437  	//    webCacheRoundTripper // if we are using the cache
   438  	//    callLimitRoundTripper ** always
   439  	//    [http.DefaultTransport] ** always implicit
   440  
   441  	var transport http.RoundTripper
   442  
   443  	callLimitTransport := &callLimitRoundTripper{
   444  		remaining: tokenLimit + 500, // put in 500 so we at least have a couple to check our real limits
   445  		resetTime: time.Now().Add(1 * time.Minute),
   446  	}
   447  	config.apiLimit = callLimitTransport
   448  	transport = callLimitTransport
   449  
   450  	var t *httpcache.Transport
   451  	if config.HTTPCacheDir != "" {
   452  		maxBytes := config.HTTPCacheSize * 1000000 // convert M to B. This is storage so not base 2...
   453  		d := diskv.New(diskv.Options{
   454  			BasePath:     config.HTTPCacheDir,
   455  			CacheSizeMax: maxBytes,
   456  		})
   457  		cache := diskcache.NewWithDiskv(d)
   458  		t = httpcache.NewTransport(cache)
   459  		config.httpCache = cache
   460  	} else {
   461  		cache := httpcache.NewMemoryCache()
   462  		t = httpcache.NewTransport(cache)
   463  		config.httpCache = cache
   464  	}
   465  	t.Transport = transport
   466  
   467  	zeroCacheTransport := &zeroCacheRoundTripper{
   468  		delegate: t,
   469  	}
   470  
   471  	transport = zeroCacheTransport
   472  
   473  	var tokenObj *oauth2.Token
   474  	if len(token) > 0 {
   475  		tokenObj = &oauth2.Token{AccessToken: token}
   476  	}
   477  	if tokenObj != nil {
   478  		ts := oauth2.StaticTokenSource(tokenObj)
   479  		transport = &oauth2.Transport{
   480  			Base:   transport,
   481  			Source: oauth2.ReuseTokenSource(nil, ts),
   482  		}
   483  	}
   484  
   485  	client := &http.Client{
   486  		Transport: transport,
   487  	}
   488  	config.client = github.NewClient(client)
   489  	if len(config.Url) > 0 {
   490  		url, err := url.Parse(config.Url)
   491  		if err != nil {
   492  			glog.Fatalf("Unable to parse url: %v: %v", config.Url, err)
   493  		}
   494  		config.client.BaseURL = url
   495  	}
   496  	config.ResetAPICount()
   497  
   498  	// passing an empty username returns information
   499  	// about the currently authenticated user
   500  	username := ""
   501  	user, _, err := config.client.Users.Get(context.Background(), username)
   502  	if err != nil {
   503  		return fmt.Errorf("failed to retrieve currently authenticatd user: %v", err)
   504  	} else if user == nil {
   505  		return errors.New("failed to retrieve currently authenticatd user: got nil result")
   506  	} else if user.Login == nil {
   507  		return errors.New("failed to retrieve currently authenticatd user: got nil result for user login")
   508  	}
   509  	config.BotName = *user.Login
   510  
   511  	return nil
   512  }
   513  
   514  // GetDebugStats returns information about the bot iself. Things like how many
   515  // API calls has it made, how many of each type, etc.
   516  func (config *Config) GetDebugStats() DebugStats {
   517  	d := DebugStats{
   518  		Analytics:      config.lastAnalytics,
   519  		APIPerSec:      config.lastAnalytics.apiPerSec,
   520  		APICount:       config.lastAnalytics.apiCount,
   521  		CachedAPICount: config.lastAnalytics.cachedAPICount,
   522  		NextLoopTime:   config.lastAnalytics.nextAnalyticUpdate,
   523  	}
   524  	config.apiLimit.Lock()
   525  	defer config.apiLimit.Unlock()
   526  	d.LimitRemaining = config.apiLimit.remaining
   527  	d.LimitResetTime = config.apiLimit.resetTime
   528  	return d
   529  }
   530  
   531  func (config *Config) serveDebugStats(res http.ResponseWriter, req *http.Request) {
   532  	stats := config.GetDebugStats()
   533  	b, err := json.Marshal(stats)
   534  	if err != nil {
   535  		glog.Errorf("Unable to Marshal Status: %v: %v", stats, err)
   536  		res.Header().Set("Content-type", "text/plain")
   537  		res.WriteHeader(http.StatusInternalServerError)
   538  		return
   539  	}
   540  	res.Header().Set("Content-type", "application/json")
   541  	res.WriteHeader(http.StatusOK)
   542  	res.Write(b)
   543  }
   544  
   545  // ServeDebugStats will serve out debug information at the path
   546  func (config *Config) ServeDebugStats(path string) {
   547  	http.HandleFunc(path, config.serveDebugStats)
   548  }
   549  
   550  // NextExpectedUpdate will set the debug information concerning when the
   551  // mungers are likely to run again.
   552  func (config *Config) NextExpectedUpdate(t time.Time) {
   553  	config.analytics.nextAnalyticUpdate = t
   554  }
   555  
   556  // ResetAPICount will both reset the counters of how many api calls have been
   557  // made but will also print the information from the last run.
   558  func (config *Config) ResetAPICount() {
   559  	since := time.Since(config.analytics.lastAPIReset)
   560  	config.analytics.apiPerSec = float64(config.analytics.apiCount) / since.Seconds()
   561  	config.lastAnalytics = config.analytics
   562  	config.analytics.print()
   563  
   564  	config.analytics = analytics{}
   565  	config.analytics.lastAPIReset = time.Now()
   566  }
   567  
   568  // SetClient should ONLY be used by testing. Normal commands should use PreExecute()
   569  func (config *Config) SetClient(client *github.Client) {
   570  	config.client = client
   571  }
   572  
   573  func (config *Config) getPR(num int) (*github.PullRequest, error) {
   574  	pr, response, err := config.client.PullRequests.Get(
   575  		context.Background(),
   576  		config.Org,
   577  		config.Project,
   578  		num,
   579  	)
   580  	config.analytics.GetPR.Call(config, response)
   581  	if err != nil {
   582  		err = suggestOauthScopes(response, err)
   583  		glog.Errorf("Error getting PR# %d: %v", num, err)
   584  		return nil, err
   585  	}
   586  	return pr, nil
   587  }
   588  
   589  func (config *Config) getIssue(num int) (*github.Issue, error) {
   590  	issue, resp, err := config.client.Issues.Get(
   591  		context.Background(),
   592  		config.Org,
   593  		config.Project,
   594  		num,
   595  	)
   596  	config.analytics.GetIssue.Call(config, resp)
   597  	if err != nil {
   598  		err = suggestOauthScopes(resp, err)
   599  		glog.Errorf("getIssue: %v", err)
   600  		return nil, err
   601  	}
   602  	return issue, nil
   603  }
   604  
   605  func (config *Config) deleteCache(resp *github.Response) {
   606  	cache := config.httpCache
   607  	if cache == nil {
   608  		return
   609  	}
   610  	if resp.Response == nil {
   611  		return
   612  	}
   613  	req := resp.Response.Request
   614  	if req == nil {
   615  		return
   616  	}
   617  	cacheKey := req.URL.String()
   618  	glog.Infof("Deleting cache entry for %q", cacheKey)
   619  	cache.Delete(cacheKey)
   620  }
   621  
   622  // protects a branch and sets the required contexts
   623  func (config *Config) setBranchProtection(name string, request *github.ProtectionRequest) error {
   624  	glog.Infof("Setting protections for branch: %s", name)
   625  	config.analytics.UpdateBranchProtection.Call(config, nil)
   626  	if config.DryRun {
   627  		return nil
   628  	}
   629  	_, resp, err := config.client.Repositories.UpdateBranchProtection(
   630  		context.Background(),
   631  		config.Org,
   632  		config.Project,
   633  		name,
   634  		request,
   635  	)
   636  	if err != nil {
   637  		err = suggestOauthScopes(resp, err)
   638  		glog.Errorf("Unable to set branch protections for %s: %v", name, err)
   639  		return err
   640  	}
   641  	return nil
   642  }
   643  
   644  // needsBranchProtection returns false if the branch is protected by exactly the set of
   645  // contexts in the argument, otherwise returns true.
   646  func (config *Config) needsBranchProtection(prot *github.Protection, contexts []string) bool {
   647  	if prot == nil {
   648  		if len(contexts) == 0 {
   649  			return false
   650  		}
   651  		glog.Infof("Setting branch protections because Protection is nil or disabled.")
   652  		return true
   653  	}
   654  	if prot.RequiredStatusChecks == nil {
   655  		glog.Infof("Setting branch protections because branch.Protection.RequiredStatusChecks is nil")
   656  		return true
   657  	}
   658  	if prot.RequiredStatusChecks.Contexts == nil {
   659  		glog.Infof("Setting branch protections because Protection.RequiredStatusChecks.Contexts is wrong")
   660  		return true
   661  	}
   662  	if prot.RequiredStatusChecks.Strict {
   663  		glog.Infof("Setting branch protections because Protection.RequiredStatusChecks.Strict is wrong")
   664  		return true
   665  	}
   666  	if prot.EnforceAdmins == nil || prot.EnforceAdmins.Enabled {
   667  		glog.Infof("Setting branch protections because Protection.EnforceAdmins.Enabled is wrong")
   668  		return true
   669  	}
   670  	branchContexts := prot.RequiredStatusChecks.Contexts
   671  
   672  	oldSet := sets.NewString(branchContexts...)
   673  	newSet := sets.NewString(contexts...)
   674  	if !oldSet.Equal(newSet) {
   675  		glog.Infof("Updating branch protections old: %v new:%v", oldSet.List(), newSet.List())
   676  		return true
   677  	}
   678  	return false
   679  }
   680  
   681  // SetBranchProtection protects a branch and sets the required contexts
   682  func (config *Config) SetBranchProtection(name string, contexts []string) error {
   683  	branch, resp, err := config.client.Repositories.GetBranch(
   684  		context.Background(),
   685  		config.Org,
   686  		config.Project,
   687  		name,
   688  	)
   689  	config.analytics.GetBranch.Call(config, resp)
   690  	if err != nil {
   691  		err = suggestOauthScopes(resp, err)
   692  		glog.Errorf("Failed to get the branch '%s': %v\n", name, err)
   693  		return err
   694  	}
   695  	var prot *github.Protection
   696  	if branch != nil && branch.Protected != nil && *branch.Protected {
   697  		prot, resp, err = config.client.Repositories.GetBranchProtection(
   698  			context.Background(),
   699  			config.Org,
   700  			config.Project,
   701  			name,
   702  		)
   703  		config.analytics.GetBranchProtection.Call(config, resp)
   704  		if err != nil {
   705  			err = suggestOauthScopes(resp, err)
   706  			glog.Errorf("Got error getting branch protection for branch %s: %v", name, err)
   707  			return err
   708  		}
   709  	}
   710  
   711  	if !config.needsBranchProtection(prot, contexts) {
   712  		return nil
   713  	}
   714  
   715  	request := &github.ProtectionRequest{
   716  		RequiredStatusChecks: &github.RequiredStatusChecks{
   717  			Strict:   false,
   718  			Contexts: contexts,
   719  		},
   720  		RequiredPullRequestReviews: prot.RequiredPullRequestReviews,
   721  		Restrictions:               unchangedRestrictionRequest(prot.Restrictions),
   722  		EnforceAdmins:              false,
   723  	}
   724  	return config.setBranchProtection(name, request)
   725  }
   726  
   727  // unchangedRestrictionRequest generates a request that will
   728  // not make any changes to the teams and users that can merge
   729  // into a branch
   730  func unchangedRestrictionRequest(restrictions *github.BranchRestrictions) *github.BranchRestrictionsRequest {
   731  	if restrictions == nil {
   732  		return nil
   733  	}
   734  
   735  	request := &github.BranchRestrictionsRequest{
   736  		Users: []string{},
   737  		Teams: []string{},
   738  	}
   739  
   740  	if restrictions.Users != nil {
   741  		for _, user := range restrictions.Users {
   742  			request.Users = append(request.Users, *user.Login)
   743  		}
   744  	}
   745  	if restrictions.Teams != nil {
   746  		for _, team := range restrictions.Teams {
   747  			request.Teams = append(request.Teams, *team.Name)
   748  		}
   749  	}
   750  	return request
   751  }
   752  
   753  // Refresh will refresh the Issue (and PR if this is a PR)
   754  // (not the commits or events)
   755  func (obj *MungeObject) Refresh() bool {
   756  	num := *obj.Issue.Number
   757  	issue, err := obj.config.getIssue(num)
   758  	if err != nil {
   759  		glog.Errorf("Error in Refresh: %v", err)
   760  		return false
   761  	}
   762  	obj.Issue = issue
   763  	if !obj.IsPR() {
   764  		return true
   765  	}
   766  	pr, err := obj.config.getPR(*obj.Issue.Number)
   767  	if err != nil {
   768  		return false
   769  	}
   770  	obj.pr = pr
   771  	return true
   772  }
   773  
   774  // ListMilestones will return all milestones of the given `state`
   775  func (config *Config) ListMilestones(state string) ([]*github.Milestone, bool) {
   776  	listopts := github.MilestoneListOptions{
   777  		State: state,
   778  	}
   779  	milestones, resp, err := config.client.Issues.ListMilestones(
   780  		context.Background(),
   781  		config.Org,
   782  		config.Project,
   783  		&listopts,
   784  	)
   785  	config.analytics.ListMilestones.Call(config, resp)
   786  	if err != nil {
   787  		glog.Errorf("Error getting milestones of state %q: %v", state, suggestOauthScopes(resp, err))
   788  		return milestones, false
   789  	}
   790  	return milestones, true
   791  }
   792  
   793  // GetObject will return an object (with only the issue filled in)
   794  func (config *Config) GetObject(num int) (*MungeObject, error) {
   795  	issue, err := config.getIssue(num)
   796  	if err != nil {
   797  		return nil, err
   798  	}
   799  	obj := &MungeObject{
   800  		config:      config,
   801  		Issue:       issue,
   802  		Annotations: map[string]string{},
   803  	}
   804  	return obj, nil
   805  }
   806  
   807  // NewIssue will file a new issue and return an object for it.
   808  // If "owners" is not empty, the issue will be assigned to the owners.
   809  func (config *Config) NewIssue(title, body string, labels []string, owners []string) (*MungeObject, error) {
   810  	config.analytics.CreateIssue.Call(config, nil)
   811  	glog.Infof("Creating an issue: %q", title)
   812  	if config.DryRun {
   813  		return nil, fmt.Errorf("can't make issues in dry-run mode")
   814  	}
   815  	if len(owners) == 0 {
   816  		owners = []string{}
   817  	}
   818  	if len(body) > maxCommentLen {
   819  		body = body[:maxCommentLen]
   820  	}
   821  
   822  	issue, resp, err := config.client.Issues.Create(
   823  		context.Background(),
   824  		config.Org,
   825  		config.Project,
   826  		&github.IssueRequest{
   827  			Title:     &title,
   828  			Body:      &body,
   829  			Labels:    &labels,
   830  			Assignees: &owners,
   831  		},
   832  	)
   833  	if err != nil {
   834  		err = suggestOauthScopes(resp, err)
   835  		glog.Errorf("createIssue: %v", err)
   836  		return nil, err
   837  	}
   838  	obj := &MungeObject{
   839  		config:      config,
   840  		Issue:       issue,
   841  		Annotations: map[string]string{},
   842  	}
   843  	return obj, nil
   844  }
   845  
   846  // GetBranchCommits gets recent commits for the given branch.
   847  func (config *Config) GetBranchCommits(branch string, limit int) ([]*github.RepositoryCommit, error) {
   848  	commits := []*github.RepositoryCommit{}
   849  	page := 0
   850  	for {
   851  		commitsPage, response, err := config.client.Repositories.ListCommits(
   852  			context.Background(),
   853  			config.Org,
   854  			config.Project,
   855  			&github.CommitsListOptions{
   856  				ListOptions: github.ListOptions{PerPage: 100, Page: page},
   857  				SHA:         branch,
   858  			},
   859  		)
   860  		config.analytics.ListCommits.Call(config, response)
   861  		if err != nil {
   862  			err = suggestOauthScopes(response, err)
   863  			glog.Errorf("Error reading commits for branch %s: %v", branch, err)
   864  			return nil, err
   865  		}
   866  		commits = append(commits, commitsPage...)
   867  		if response.LastPage == 0 || response.LastPage <= page || len(commits) > limit {
   868  			break
   869  		}
   870  		page++
   871  	}
   872  	return commits, nil
   873  }
   874  
   875  // GetTokenUsage returns the api token usage of the current github user.
   876  func (config *Config) GetTokenUsage() (int, error) {
   877  	limits, _, err := config.client.RateLimits(context.Background())
   878  	if err != nil {
   879  		return -1, err
   880  	}
   881  	return limits.Core.Limit - limits.Core.Remaining, nil
   882  }
   883  
   884  // Branch returns the branch the PR is for. Return "" if this is not a PR or
   885  // it does not have the required information.
   886  func (obj *MungeObject) Branch() (string, bool) {
   887  	pr, ok := obj.GetPR()
   888  	if !ok {
   889  		return "", ok
   890  	}
   891  	if pr.Base != nil && pr.Base.Ref != nil {
   892  		return *pr.Base.Ref, ok
   893  	}
   894  	return "", ok
   895  }
   896  
   897  // IsForBranch return true if the object is a PR for a branch with the given
   898  // name. It return false if it is not a pr, it isn't against the given branch,
   899  // or we can't tell
   900  func (obj *MungeObject) IsForBranch(branch string) (bool, bool) {
   901  	objBranch, ok := obj.Branch()
   902  	if !ok {
   903  		return false, ok
   904  	}
   905  	if objBranch == branch {
   906  		return true, ok
   907  	}
   908  	return false, ok
   909  }
   910  
   911  // LastModifiedTime returns the time the last commit was made
   912  // BUG: this should probably return the last time a git push happened or something like that.
   913  func (obj *MungeObject) LastModifiedTime() (*time.Time, bool) {
   914  	var lastModified *time.Time
   915  	commits, ok := obj.GetCommits()
   916  	if !ok {
   917  		glog.Errorf("Error in LastModifiedTime, unable to get commits")
   918  		return lastModified, ok
   919  	}
   920  	for _, commit := range commits {
   921  		if commit.Commit == nil || commit.Commit.Committer == nil || commit.Commit.Committer.Date == nil {
   922  			glog.Errorf("PR %d: Found invalid RepositoryCommit: %v", *obj.Issue.Number, commit)
   923  			continue
   924  		}
   925  		if lastModified == nil || commit.Commit.Committer.Date.After(*lastModified) {
   926  			lastModified = commit.Commit.Committer.Date
   927  		}
   928  	}
   929  	return lastModified, true
   930  }
   931  
   932  // FirstLabelTime returns the first time the request label was added to an issue.
   933  // If the label was never added you will get a nil time.
   934  func (obj *MungeObject) FirstLabelTime(label string) *time.Time {
   935  	event := obj.labelEvent(label, firstTime)
   936  	if event == nil {
   937  		return nil
   938  	}
   939  	return event.CreatedAt
   940  }
   941  
   942  // Return true if 'a' is preferable to 'b'. Handle nil times!
   943  type timePred func(a, b *time.Time) bool
   944  
   945  func firstTime(a, b *time.Time) bool {
   946  	if a == nil {
   947  		return false
   948  	}
   949  	if b == nil {
   950  		return true
   951  	}
   952  	return !a.After(*b)
   953  }
   954  
   955  func lastTime(a, b *time.Time) bool {
   956  	if a == nil {
   957  		return false
   958  	}
   959  	if b == nil {
   960  		return true
   961  	}
   962  	return a.After(*b)
   963  }
   964  
   965  // labelEvent returns the event where the given label was added to an issue.
   966  // 'pred' is used to select which label event is chosen if there are multiple.
   967  func (obj *MungeObject) labelEvent(label string, pred timePred) *github.IssueEvent {
   968  	var labelTime *time.Time
   969  	var out *github.IssueEvent
   970  	events, ok := obj.GetEvents()
   971  	if !ok {
   972  		return nil
   973  	}
   974  	for _, event := range events {
   975  		if *event.Event == "labeled" && *event.Label.Name == label {
   976  			if pred(event.CreatedAt, labelTime) {
   977  				labelTime = event.CreatedAt
   978  				out = event
   979  			}
   980  		}
   981  	}
   982  	return out
   983  }
   984  
   985  // LabelTime returns the last time the request label was added to an issue.
   986  // If the label was never added you will get a nil time.
   987  func (obj *MungeObject) LabelTime(label string) (*time.Time, bool) {
   988  	event := obj.labelEvent(label, lastTime)
   989  	if event == nil {
   990  		glog.Errorf("Error in LabelTime, received nil event value")
   991  		return nil, false
   992  	}
   993  	return event.CreatedAt, true
   994  }
   995  
   996  // LabelCreator returns the user who (last) created the given label
   997  func (obj *MungeObject) LabelCreator(label string) (*github.User, bool) {
   998  	event := obj.labelEvent(label, lastTime)
   999  	if event == nil || event.Actor == nil || event.Actor.Login == nil {
  1000  		glog.Errorf("Error in LabelCreator, received nil event value")
  1001  		return nil, false
  1002  	}
  1003  	return event.Actor, true
  1004  }
  1005  
  1006  // HasLabel returns if the label `name` is in the array of `labels`
  1007  func (obj *MungeObject) HasLabel(name string) bool {
  1008  	labels := obj.Issue.Labels
  1009  	for i := range labels {
  1010  		label := &labels[i]
  1011  		if label.Name != nil && *label.Name == name {
  1012  			return true
  1013  		}
  1014  	}
  1015  	return false
  1016  }
  1017  
  1018  // HasLabels returns if all of the label `names` are in the array of `labels`
  1019  func (obj *MungeObject) HasLabels(names []string) bool {
  1020  	for i := range names {
  1021  		if !obj.HasLabel(names[i]) {
  1022  			return false
  1023  		}
  1024  	}
  1025  	return true
  1026  }
  1027  
  1028  // LabelSet returns the name of all of the labels applied to the object as a
  1029  // kubernetes string set.
  1030  func (obj *MungeObject) LabelSet() sets.String {
  1031  	out := sets.NewString()
  1032  	for _, label := range obj.Issue.Labels {
  1033  		out.Insert(*label.Name)
  1034  	}
  1035  	return out
  1036  }
  1037  
  1038  // GetLabelsWithPrefix will return a slice of all label names in `labels` which
  1039  // start with given prefix.
  1040  func GetLabelsWithPrefix(labels []github.Label, prefix string) []string {
  1041  	var ret []string
  1042  	for _, label := range labels {
  1043  		if label.Name != nil && strings.HasPrefix(*label.Name, prefix) {
  1044  			ret = append(ret, *label.Name)
  1045  		}
  1046  	}
  1047  	return ret
  1048  }
  1049  
  1050  // AddLabel adds a single `label` to the issue
  1051  func (obj *MungeObject) AddLabel(label string) error {
  1052  	return obj.AddLabels([]string{label})
  1053  }
  1054  
  1055  // AddLabels will add all of the named `labels` to the issue
  1056  func (obj *MungeObject) AddLabels(labels []string) error {
  1057  	config := obj.config
  1058  	prNum := *obj.Issue.Number
  1059  	config.analytics.AddLabels.Call(config, nil)
  1060  	glog.Infof("Adding labels %v to PR %d", labels, prNum)
  1061  	if len(labels) == 0 {
  1062  		glog.Info("No labels to add: quitting")
  1063  		return nil
  1064  	}
  1065  
  1066  	if config.DryRun {
  1067  		return nil
  1068  	}
  1069  	for _, l := range labels {
  1070  		label := github.Label{
  1071  			Name: &l,
  1072  		}
  1073  		obj.Issue.Labels = append(obj.Issue.Labels, label)
  1074  	}
  1075  	_, resp, err := config.client.Issues.AddLabelsToIssue(
  1076  		context.Background(),
  1077  		obj.Org(),
  1078  		obj.Project(),
  1079  		prNum,
  1080  		labels,
  1081  	)
  1082  	if err != nil {
  1083  		glog.Errorf("Failed to set labels %v for PR %d: %v", labels, prNum, suggestOauthScopes(resp, err))
  1084  		return err
  1085  	}
  1086  	return nil
  1087  }
  1088  
  1089  // RemoveLabel will remove the `label` from the PR
  1090  func (obj *MungeObject) RemoveLabel(label string) error {
  1091  	config := obj.config
  1092  	prNum := *obj.Issue.Number
  1093  
  1094  	which := -1
  1095  	for i, l := range obj.Issue.Labels {
  1096  		if l.Name != nil && *l.Name == label {
  1097  			which = i
  1098  			break
  1099  		}
  1100  	}
  1101  	if which != -1 {
  1102  		// We do this crazy delete since users might be iterating over `range obj.Issue.Labels`
  1103  		// Make a completely new copy and leave their ranging alone.
  1104  		temp := make([]github.Label, len(obj.Issue.Labels)-1)
  1105  		copy(temp, obj.Issue.Labels[:which])
  1106  		copy(temp[which:], obj.Issue.Labels[which+1:])
  1107  		obj.Issue.Labels = temp
  1108  	}
  1109  
  1110  	config.analytics.RemoveLabels.Call(config, nil)
  1111  	glog.Infof("Removing label %q to PR %d", label, prNum)
  1112  	if config.DryRun {
  1113  		return nil
  1114  	}
  1115  	resp, err := config.client.Issues.RemoveLabelForIssue(
  1116  		context.Background(),
  1117  		obj.Org(),
  1118  		obj.Project(),
  1119  		prNum,
  1120  		label,
  1121  	)
  1122  	if err != nil {
  1123  		glog.Errorf("Failed to remove %v from issue %d: %v", label, prNum, suggestOauthScopes(resp, err))
  1124  		return err
  1125  	}
  1126  	return nil
  1127  }
  1128  
  1129  // ModifiedAfterLabeled returns true if the PR was updated after the last time the
  1130  // label was applied.
  1131  func (obj *MungeObject) ModifiedAfterLabeled(label string) (after bool, ok bool) {
  1132  	labelTime, ok := obj.LabelTime(label)
  1133  	if !ok || labelTime == nil {
  1134  		glog.Errorf("Unable to find label time for: %q on %d", label, obj.Number())
  1135  		return false, false
  1136  	}
  1137  	lastModifiedTime, ok := obj.LastModifiedTime()
  1138  	if !ok || lastModifiedTime == nil {
  1139  		glog.Errorf("Unable to find last modification time for %d", obj.Number())
  1140  		return false, false
  1141  	}
  1142  	after = lastModifiedTime.After(*labelTime)
  1143  	return after, true
  1144  }
  1145  
  1146  // GetHeadAndBase returns the head SHA and the base ref, so that you can get
  1147  // the base's sha in a second step. Purpose: if head and base SHA are the same
  1148  // across two merge attempts, we don't need to rerun tests.
  1149  func (obj *MungeObject) GetHeadAndBase() (headSHA, baseRef string, ok bool) {
  1150  	pr, ok := obj.GetPR()
  1151  	if !ok {
  1152  		return "", "", false
  1153  	}
  1154  	if pr.Head == nil || pr.Head.SHA == nil {
  1155  		return "", "", false
  1156  	}
  1157  	headSHA = *pr.Head.SHA
  1158  	if pr.Base == nil || pr.Base.Ref == nil {
  1159  		return "", "", false
  1160  	}
  1161  	baseRef = *pr.Base.Ref
  1162  	return headSHA, baseRef, true
  1163  }
  1164  
  1165  // GetSHAFromRef returns the current SHA of the given ref (i.e., branch).
  1166  func (obj *MungeObject) GetSHAFromRef(ref string) (sha string, ok bool) {
  1167  	commit, response, err := obj.config.client.Repositories.GetCommit(
  1168  		context.Background(),
  1169  		obj.Org(),
  1170  		obj.Project(),
  1171  		ref,
  1172  	)
  1173  	obj.config.analytics.GetCommit.Call(obj.config, response)
  1174  	if err != nil {
  1175  		glog.Errorf("Failed to get commit for %v, %v, %v: %v", obj.Org(), obj.Project(), ref, suggestOauthScopes(response, err))
  1176  		return "", false
  1177  	}
  1178  	if commit.SHA == nil {
  1179  		return "", false
  1180  	}
  1181  	return *commit.SHA, true
  1182  }
  1183  
  1184  // ClearMilestone will remove a milestone if present
  1185  func (obj *MungeObject) ClearMilestone() bool {
  1186  	if obj.Issue.Milestone == nil {
  1187  		return true
  1188  	}
  1189  	obj.config.analytics.ClearMilestone.Call(obj.config, nil)
  1190  	obj.Issue.Milestone = nil
  1191  	if obj.config.DryRun {
  1192  		return true
  1193  	}
  1194  
  1195  	// Send the request manually to work around go-github's use of
  1196  	// omitempty (precluding the use of null) in the json field
  1197  	// definition for milestone.
  1198  	//
  1199  	// Reference: https://github.com/google/go-github/issues/236
  1200  	u := fmt.Sprintf("repos/%v/%v/issues/%d", obj.Org(), obj.Project(), *obj.Issue.Number)
  1201  	req, err := obj.config.client.NewRequest("PATCH", u, &struct {
  1202  		Milestone interface{} `json:"milestone"`
  1203  	}{})
  1204  	if err != nil {
  1205  		glog.Errorf("Failed to clear milestone on issue %d: %v", *obj.Issue.Number, err)
  1206  		return false
  1207  	}
  1208  	_, err = obj.config.client.Do(context.Background(), req, nil)
  1209  	if err != nil {
  1210  		glog.Errorf("Failed to clear milestone on issue %d: %v", *obj.Issue.Number, err)
  1211  		return false
  1212  	}
  1213  	return true
  1214  }
  1215  
  1216  // SetMilestone will set the milestone to the value specified
  1217  func (obj *MungeObject) SetMilestone(title string) bool {
  1218  	milestones, ok := obj.config.ListMilestones("all")
  1219  	if !ok {
  1220  		glog.Errorf("Error in SetMilestone, obj.config.ListMilestones failed")
  1221  		return false
  1222  	}
  1223  	var milestone *github.Milestone
  1224  	for _, m := range milestones {
  1225  		if m.Title == nil || m.Number == nil {
  1226  			glog.Errorf("Found milestone with nil title of number: %v", m)
  1227  			continue
  1228  		}
  1229  		if *m.Title == title {
  1230  			milestone = m
  1231  			break
  1232  		}
  1233  	}
  1234  	if milestone == nil {
  1235  		glog.Errorf("Unable to find milestone with title %q", title)
  1236  		return false
  1237  	}
  1238  
  1239  	obj.config.analytics.SetMilestone.Call(obj.config, nil)
  1240  	obj.Issue.Milestone = milestone
  1241  	if obj.config.DryRun {
  1242  		return true
  1243  	}
  1244  
  1245  	_, resp, err := obj.config.client.Issues.Edit(
  1246  		context.Background(),
  1247  		obj.Org(),
  1248  		obj.Project(),
  1249  		*obj.Issue.Number,
  1250  		&github.IssueRequest{Milestone: milestone.Number},
  1251  	)
  1252  	if err != nil {
  1253  		glog.Errorf("Failed to set milestone %d on issue %d: %v", *milestone.Number, *obj.Issue.Number, suggestOauthScopes(resp, err))
  1254  		return false
  1255  	}
  1256  	return true
  1257  }
  1258  
  1259  // ReleaseMilestone returns the name of the 'release' milestone or an empty string
  1260  // if none found. Release milestones are determined by the format "vX.Y"
  1261  func (obj *MungeObject) ReleaseMilestone() (string, bool) {
  1262  	milestone := obj.Issue.Milestone
  1263  	if milestone == nil {
  1264  		return "", true
  1265  	}
  1266  	title := milestone.Title
  1267  	if title == nil {
  1268  		glog.Errorf("Error in ReleaseMilestone, nil milestone.Title")
  1269  		return "", false
  1270  	}
  1271  	if !releaseMilestoneRE.MatchString(*title) {
  1272  		return "", true
  1273  	}
  1274  	return *title, true
  1275  }
  1276  
  1277  // ReleaseMilestoneDue returns the due date for a milestone. It ONLY looks at
  1278  // milestones of the form 'vX.Y' where X and Y are integeters. Return the maximum
  1279  // possible time if there is no milestone or the milestone doesn't look like a
  1280  // release milestone
  1281  func (obj *MungeObject) ReleaseMilestoneDue() (time.Time, bool) {
  1282  	milestone := obj.Issue.Milestone
  1283  	if milestone == nil {
  1284  		return maxTime, true
  1285  	}
  1286  	title := milestone.Title
  1287  	if title == nil {
  1288  		glog.Errorf("Error in ReleaseMilestoneDue, nil milestone.Title")
  1289  		return maxTime, false
  1290  	}
  1291  	if !releaseMilestoneRE.MatchString(*title) {
  1292  		return maxTime, true
  1293  	}
  1294  	if milestone.DueOn == nil {
  1295  		return maxTime, true
  1296  	}
  1297  	return *milestone.DueOn, true
  1298  }
  1299  
  1300  // Priority returns the priority an issue was labeled with.
  1301  // The labels must take the form 'priority/[pP][0-9]+'
  1302  // or math.MaxInt32 if unset
  1303  //
  1304  // If a PR has both priority/p0 and priority/p1 it will be considered a p0.
  1305  func (obj *MungeObject) Priority() int {
  1306  	priority := math.MaxInt32
  1307  	priorityLabels := GetLabelsWithPrefix(obj.Issue.Labels, "priority/")
  1308  	for _, label := range priorityLabels {
  1309  		matches := priorityLabelRE.FindStringSubmatch(label)
  1310  		// First match should be the whole label, second match the number itself
  1311  		if len(matches) != 2 {
  1312  			continue
  1313  		}
  1314  		prio, err := strconv.Atoi(matches[1])
  1315  		if err != nil {
  1316  			continue
  1317  		}
  1318  		if prio < priority {
  1319  			priority = prio
  1320  		}
  1321  	}
  1322  	return priority
  1323  }
  1324  
  1325  // MungeFunction is the type that must be implemented and passed to ForEachIssueDo
  1326  type MungeFunction func(*MungeObject) error
  1327  
  1328  // Collaborators is a set of all logins who can be
  1329  // listed as assignees, reviewers or approvers for
  1330  // issues and pull requests in this repo
  1331  func (config *Config) Collaborators() (sets.String, error) {
  1332  	logins := sets.NewString()
  1333  	users, err := config.fetchAllCollaborators()
  1334  	if err != nil {
  1335  		return logins, err
  1336  	}
  1337  	for _, user := range users {
  1338  		if user.Login != nil && *user.Login != "" {
  1339  			logins.Insert(strings.ToLower(*user.Login))
  1340  		}
  1341  	}
  1342  	return logins, nil
  1343  }
  1344  
  1345  func (config *Config) fetchAllCollaborators() ([]*github.User, error) {
  1346  	page := 1
  1347  	var result []*github.User
  1348  	for {
  1349  		glog.V(4).Infof("Fetching page %d of all users", page)
  1350  		listOpts := &github.ListOptions{PerPage: 100, Page: page}
  1351  		users, response, err := config.client.Repositories.ListCollaborators(
  1352  			context.Background(),
  1353  			config.Org,
  1354  			config.Project,
  1355  			listOpts,
  1356  		)
  1357  		if err != nil {
  1358  			return nil, suggestOauthScopes(response, err)
  1359  		}
  1360  		config.analytics.ListCollaborators.Call(config, response)
  1361  		result = append(result, users...)
  1362  		if response.LastPage == 0 || response.LastPage <= page {
  1363  			break
  1364  		}
  1365  		page++
  1366  	}
  1367  	return result, nil
  1368  }
  1369  
  1370  // UsersWithAccess returns two sets of users. The first set are users with push
  1371  // access. The second set is the specific set of user with pull access. If the
  1372  // repo is public all users will have pull access, but some with have it
  1373  // explicitly
  1374  func (config *Config) UsersWithAccess() ([]*github.User, []*github.User, error) {
  1375  	pushUsers := []*github.User{}
  1376  	pullUsers := []*github.User{}
  1377  
  1378  	users, err := config.fetchAllCollaborators()
  1379  	if err != nil {
  1380  		glog.Errorf("%v", err)
  1381  		return nil, nil, err
  1382  	}
  1383  
  1384  	for _, user := range users {
  1385  		if user.Permissions == nil || user.Login == nil {
  1386  			err := fmt.Errorf("found a user with nil Permissions or Login")
  1387  			glog.Errorf("%v", err)
  1388  			return nil, nil, err
  1389  		}
  1390  		perms := *user.Permissions
  1391  		if perms["push"] {
  1392  			pushUsers = append(pushUsers, user)
  1393  		} else if perms["pull"] {
  1394  			pullUsers = append(pullUsers, user)
  1395  		}
  1396  	}
  1397  	return pushUsers, pullUsers, nil
  1398  }
  1399  
  1400  // GetUser will return information about the github user with the given login name
  1401  func (config *Config) GetUser(login string) (*github.User, error) {
  1402  	user, response, err := config.client.Users.Get(context.Background(), login)
  1403  	config.analytics.GetUser.Call(config, response)
  1404  	return user, err
  1405  }
  1406  
  1407  // DescribeUser returns the Login string, which may be nil.
  1408  func DescribeUser(u *github.User) string {
  1409  	if u != nil && u.Login != nil {
  1410  		return *u.Login
  1411  	}
  1412  	return "<nil>"
  1413  }
  1414  
  1415  // IsPR returns if the obj is a PR or an Issue.
  1416  func (obj *MungeObject) IsPR() bool {
  1417  	if obj.Issue.PullRequestLinks == nil {
  1418  		return false
  1419  	}
  1420  	return true
  1421  }
  1422  
  1423  // GetEvents returns a list of all events for a given pr.
  1424  func (obj *MungeObject) GetEvents() ([]*github.IssueEvent, bool) {
  1425  	config := obj.config
  1426  	prNum := *obj.Issue.Number
  1427  	events := []*github.IssueEvent{}
  1428  	page := 1
  1429  	// Try to work around not finding events--suspect some cache invalidation bug when the number of pages changes.
  1430  	tryNextPageAnyway := false
  1431  	var lastResponse *github.Response
  1432  	for {
  1433  		eventPage, response, err := config.client.Issues.ListIssueEvents(
  1434  			context.Background(),
  1435  			obj.Org(),
  1436  			obj.Project(),
  1437  			prNum,
  1438  			&github.ListOptions{PerPage: 100, Page: page},
  1439  		)
  1440  		config.analytics.ListIssueEvents.Call(config, response)
  1441  		if err != nil {
  1442  			if tryNextPageAnyway {
  1443  				// Cached last page was actually truthful -- expected error.
  1444  				break
  1445  			}
  1446  			glog.Errorf("Error getting events for issue %d: %v", *obj.Issue.Number, suggestOauthScopes(response, err))
  1447  			return nil, false
  1448  		}
  1449  		if tryNextPageAnyway {
  1450  			if len(eventPage) == 0 {
  1451  				break
  1452  			}
  1453  			glog.Infof("For %v: supposedly there weren't more events, but we asked anyway and found %v more.", prNum, len(eventPage))
  1454  			obj.config.deleteCache(lastResponse)
  1455  			tryNextPageAnyway = false
  1456  		}
  1457  		events = append(events, eventPage...)
  1458  		if response.LastPage == 0 || response.LastPage <= page {
  1459  			if len(events)%100 == 0 {
  1460  				tryNextPageAnyway = true
  1461  				lastResponse = response
  1462  			} else {
  1463  				break
  1464  			}
  1465  		}
  1466  		page++
  1467  	}
  1468  	obj.events = events
  1469  	return events, true
  1470  }
  1471  
  1472  func computeStatus(combinedStatus *github.CombinedStatus, requiredContexts []string) string {
  1473  	states := sets.String{}
  1474  	providers := sets.String{}
  1475  
  1476  	if len(requiredContexts) == 0 {
  1477  		return *combinedStatus.State
  1478  	}
  1479  
  1480  	requires := sets.NewString(requiredContexts...)
  1481  	for _, status := range combinedStatus.Statuses {
  1482  		if !requires.Has(*status.Context) {
  1483  			continue
  1484  		}
  1485  		states.Insert(*status.State)
  1486  		providers.Insert(*status.Context)
  1487  	}
  1488  
  1489  	missing := requires.Difference(providers)
  1490  	if missing.Len() != 0 {
  1491  		glog.V(8).Infof("Failed to find %v in CombinedStatus for %s", missing.List(), *combinedStatus.SHA)
  1492  		return "incomplete"
  1493  	}
  1494  	switch {
  1495  	case states.Has("pending"):
  1496  		return "pending"
  1497  	case states.Has("error"):
  1498  		return "error"
  1499  	case states.Has("failure"):
  1500  		return "failure"
  1501  	default:
  1502  		return "success"
  1503  	}
  1504  }
  1505  
  1506  func (obj *MungeObject) getCombinedStatus() (status *github.CombinedStatus, ok bool) {
  1507  	now := time.Now()
  1508  	if now.Before(obj.combinedStatusTime.Add(combinedStatusLifetime)) {
  1509  		return obj.combinedStatus, true
  1510  	}
  1511  
  1512  	config := obj.config
  1513  	pr, ok := obj.GetPR()
  1514  	if !ok {
  1515  		return nil, false
  1516  	}
  1517  	if pr.Head == nil {
  1518  		glog.Errorf("pr.Head is nil in getCombinedStatus for PR# %d", *obj.Issue.Number)
  1519  		return nil, false
  1520  	}
  1521  	// TODO If we have more than 100 statuses we need to deal with paging.
  1522  	combinedStatus, response, err := config.client.Repositories.GetCombinedStatus(
  1523  		context.Background(),
  1524  		obj.Org(),
  1525  		obj.Project(),
  1526  		*pr.Head.SHA,
  1527  		&github.ListOptions{},
  1528  	)
  1529  	config.analytics.GetCombinedStatus.Call(config, response)
  1530  	if err != nil {
  1531  		glog.Errorf("Failed to get combined status: %v", suggestOauthScopes(response, err))
  1532  		return nil, false
  1533  	}
  1534  	obj.combinedStatus = combinedStatus
  1535  	obj.combinedStatusTime = now
  1536  	return combinedStatus, true
  1537  }
  1538  
  1539  // SetStatus allowes you to set the Github Status
  1540  func (obj *MungeObject) SetStatus(state, url, description, statusContext string) bool {
  1541  	config := obj.config
  1542  	status := &github.RepoStatus{
  1543  		State:       &state,
  1544  		Description: &description,
  1545  		Context:     &statusContext,
  1546  	}
  1547  	if len(url) > 0 {
  1548  		status.TargetURL = &url
  1549  	}
  1550  	pr, ok := obj.GetPR()
  1551  	if !ok {
  1552  		glog.Errorf("Error in SetStatus")
  1553  		return false
  1554  	}
  1555  	ref := *pr.Head.SHA
  1556  	glog.Infof("PR %d setting %q Github status to %q", *obj.Issue.Number, statusContext, description)
  1557  	config.analytics.SetStatus.Call(config, nil)
  1558  	if config.DryRun {
  1559  		return true
  1560  	}
  1561  	_, resp, err := config.client.Repositories.CreateStatus(
  1562  		context.Background(),
  1563  		obj.Org(),
  1564  		obj.Project(),
  1565  		ref,
  1566  		status,
  1567  	)
  1568  	if err != nil {
  1569  		glog.Errorf("Unable to set status. PR %d Ref: %q: %v", *obj.Issue.Number, ref, suggestOauthScopes(resp, err))
  1570  		return false
  1571  	}
  1572  	return false
  1573  }
  1574  
  1575  // GetStatus returns the actual requested status, or nil if not found
  1576  func (obj *MungeObject) GetStatus(context string) (*github.RepoStatus, bool) {
  1577  	combinedStatus, ok := obj.getCombinedStatus()
  1578  	if !ok {
  1579  		glog.Errorf("Error in GetStatus, getCombinedStatus returned error")
  1580  		return nil, false
  1581  	} else if combinedStatus == nil {
  1582  		return nil, true
  1583  	}
  1584  	for _, status := range combinedStatus.Statuses {
  1585  		if *status.Context == context {
  1586  			return &status, true
  1587  		}
  1588  	}
  1589  	return nil, true
  1590  }
  1591  
  1592  // GetStatusState gets the current status of a PR.
  1593  //    * If any member of the 'requiredContexts' list is missing, it is 'incomplete'
  1594  //    * If any is 'pending', the PR is 'pending'
  1595  //    * If any is 'error', the PR is in 'error'
  1596  //    * If any is 'failure', the PR is 'failure'
  1597  //    * Otherwise the PR is 'success'
  1598  func (obj *MungeObject) GetStatusState(requiredContexts []string) (string, bool) {
  1599  	combinedStatus, ok := obj.getCombinedStatus()
  1600  	if !ok || combinedStatus == nil {
  1601  		return "failure", ok
  1602  	}
  1603  	return computeStatus(combinedStatus, requiredContexts), ok
  1604  }
  1605  
  1606  // IsStatusSuccess makes sure that the combined status for all commits in a PR is 'success'
  1607  func (obj *MungeObject) IsStatusSuccess(requiredContexts []string) (bool, bool) {
  1608  	status, ok := obj.GetStatusState(requiredContexts)
  1609  	if ok && status == "success" {
  1610  		return true, ok
  1611  	}
  1612  	return false, ok
  1613  }
  1614  
  1615  // GetStatusTime returns when the status was set
  1616  func (obj *MungeObject) GetStatusTime(context string) (*time.Time, bool) {
  1617  	status, ok := obj.GetStatus(context)
  1618  	if status == nil || ok == false {
  1619  		return nil, false
  1620  	}
  1621  	if status.UpdatedAt != nil {
  1622  		return status.UpdatedAt, true
  1623  	}
  1624  	return status.CreatedAt, true
  1625  }
  1626  
  1627  // Sleep for the given amount of time and then write to the channel
  1628  func timeout(sleepTime time.Duration, c chan bool) {
  1629  	time.Sleep(sleepTime)
  1630  	c <- true
  1631  }
  1632  
  1633  func (obj *MungeObject) doWaitStatus(pending bool, requiredContexts []string, c chan bool) {
  1634  	config := obj.config
  1635  
  1636  	sleepTime := 30 * time.Second
  1637  	// If the time was explicitly set, use that instead
  1638  	if config.BaseWaitTime != 0 {
  1639  		sleepTime = 30 * config.BaseWaitTime
  1640  	}
  1641  
  1642  	for {
  1643  		status, ok := obj.GetStatusState(requiredContexts)
  1644  		if !ok {
  1645  			time.Sleep(sleepTime)
  1646  			continue
  1647  		}
  1648  		var done bool
  1649  		if pending {
  1650  			done = (status == "pending")
  1651  		} else {
  1652  			done = (status != "pending")
  1653  		}
  1654  		if done {
  1655  			c <- true
  1656  			return
  1657  		}
  1658  		if config.DryRun {
  1659  			glog.V(4).Infof("PR# %d is not pending, would wait 30 seconds, but --dry-run was set", *obj.Issue.Number)
  1660  			c <- true
  1661  			return
  1662  		}
  1663  		if pending {
  1664  			glog.V(4).Infof("PR# %d is not pending, waiting for %f seconds", *obj.Issue.Number, sleepTime.Seconds())
  1665  		} else {
  1666  			glog.V(4).Infof("PR# %d is pending, waiting for %f seconds", *obj.Issue.Number, sleepTime.Seconds())
  1667  		}
  1668  		time.Sleep(sleepTime)
  1669  
  1670  		// If it has been closed, assume that we want to break from the poll loop early.
  1671  		obj.Refresh()
  1672  		if obj.Issue != nil && obj.Issue.State != nil && *obj.Issue.State == "closed" {
  1673  			c <- true
  1674  		}
  1675  	}
  1676  }
  1677  
  1678  // WaitForPending will wait for a PR to move into Pending.  This is useful
  1679  // because the request to test a PR again is asynchronous with the PR actually
  1680  // moving into a pending state
  1681  // returns true if it completed and false if it timed out
  1682  func (obj *MungeObject) WaitForPending(requiredContexts []string, prMaxWaitTime time.Duration) bool {
  1683  	timeoutChan := make(chan bool, 1)
  1684  	done := make(chan bool, 1)
  1685  	// Wait for the github e2e test to start
  1686  	go timeout(prMaxWaitTime, timeoutChan)
  1687  	go obj.doWaitStatus(true, requiredContexts, done)
  1688  	select {
  1689  	case <-done:
  1690  		return true
  1691  	case <-timeoutChan:
  1692  		glog.Errorf("PR# %d timed out waiting to go \"pending\"", *obj.Issue.Number)
  1693  		return false
  1694  	}
  1695  }
  1696  
  1697  // WaitForNotPending will check if the github status is "pending" (CI still running)
  1698  // if so it will sleep and try again until all required status hooks have complete
  1699  // returns true if it completed and false if it timed out
  1700  func (obj *MungeObject) WaitForNotPending(requiredContexts []string, prMaxWaitTime time.Duration) bool {
  1701  	timeoutChan := make(chan bool, 1)
  1702  	done := make(chan bool, 1)
  1703  	// Wait for the github e2e test to finish
  1704  	go timeout(prMaxWaitTime, timeoutChan)
  1705  	go obj.doWaitStatus(false, requiredContexts, done)
  1706  	select {
  1707  	case <-done:
  1708  		return true
  1709  	case <-timeoutChan:
  1710  		glog.Errorf("PR# %d timed out waiting to go \"not pending\"", *obj.Issue.Number)
  1711  		return false
  1712  	}
  1713  }
  1714  
  1715  // GetCommits returns all of the commits for a given PR
  1716  func (obj *MungeObject) GetCommits() ([]*github.RepositoryCommit, bool) {
  1717  	if obj.commits != nil {
  1718  		return obj.commits, true
  1719  	}
  1720  	config := obj.config
  1721  	commits := []*github.RepositoryCommit{}
  1722  	page := 0
  1723  	for {
  1724  		commitsPage, response, err := config.client.PullRequests.ListCommits(
  1725  			context.Background(),
  1726  			obj.Org(),
  1727  			obj.Project(),
  1728  			*obj.Issue.Number,
  1729  			&github.ListOptions{PerPage: 100, Page: page},
  1730  		)
  1731  		config.analytics.ListCommits.Call(config, response)
  1732  		if err != nil {
  1733  			glog.Errorf("Error commits for PR %d: %v", *obj.Issue.Number, suggestOauthScopes(response, err))
  1734  			return nil, false
  1735  		}
  1736  		commits = append(commits, commitsPage...)
  1737  		if response.LastPage == 0 || response.LastPage <= page {
  1738  			break
  1739  		}
  1740  		page++
  1741  	}
  1742  
  1743  	filledCommits := []*github.RepositoryCommit{}
  1744  	for _, c := range commits {
  1745  		if c.SHA == nil {
  1746  			glog.Errorf("Invalid Repository Commit: %v", c)
  1747  			continue
  1748  		}
  1749  		commit, response, err := config.client.Repositories.GetCommit(
  1750  			context.Background(),
  1751  			obj.Org(),
  1752  			obj.Project(),
  1753  			*c.SHA,
  1754  		)
  1755  		config.analytics.GetCommit.Call(config, response)
  1756  		if err != nil {
  1757  			glog.Errorf("Can't load commit %s %s %s: %v", obj.Org(), obj.Project(), *c.SHA, suggestOauthScopes(response, err))
  1758  			continue
  1759  		}
  1760  		filledCommits = append(filledCommits, commit)
  1761  	}
  1762  	obj.commits = filledCommits
  1763  	return filledCommits, true
  1764  }
  1765  
  1766  // ListFiles returns all changed files in a pull-request
  1767  func (obj *MungeObject) ListFiles() ([]*github.CommitFile, bool) {
  1768  	if obj.commitFiles != nil {
  1769  		return obj.commitFiles, true
  1770  	}
  1771  
  1772  	pr, ok := obj.GetPR()
  1773  	if !ok {
  1774  		return nil, ok
  1775  	}
  1776  
  1777  	prNum := *pr.Number
  1778  	allFiles := []*github.CommitFile{}
  1779  
  1780  	listOpts := &github.ListOptions{}
  1781  
  1782  	config := obj.config
  1783  	page := 1
  1784  	for {
  1785  		listOpts.Page = page
  1786  		glog.V(8).Infof("Fetching page %d of changed files for issue %d", page, prNum)
  1787  		files, response, err := obj.config.client.PullRequests.ListFiles(
  1788  			context.Background(),
  1789  			obj.Org(),
  1790  			obj.Project(),
  1791  			prNum,
  1792  			listOpts,
  1793  		)
  1794  		config.analytics.ListFiles.Call(config, response)
  1795  		if err != nil {
  1796  			glog.Errorf("Unable to ListFiles: %v", suggestOauthScopes(response, err))
  1797  			return nil, false
  1798  		}
  1799  		allFiles = append(allFiles, files...)
  1800  		if response.LastPage == 0 || response.LastPage <= page {
  1801  			break
  1802  		}
  1803  		page++
  1804  	}
  1805  	obj.commitFiles = allFiles
  1806  	return allFiles, true
  1807  }
  1808  
  1809  // GetPR will return the PR of the object.
  1810  func (obj *MungeObject) GetPR() (*github.PullRequest, bool) {
  1811  	if obj.pr != nil {
  1812  		return obj.pr, true
  1813  	}
  1814  	if !obj.IsPR() {
  1815  		return nil, false
  1816  	}
  1817  	pr, err := obj.config.getPR(*obj.Issue.Number)
  1818  	if err != nil {
  1819  		glog.Errorf("Error in GetPR: %v", err)
  1820  		return nil, false
  1821  	}
  1822  	obj.pr = pr
  1823  	return pr, true
  1824  }
  1825  
  1826  // Returns true if the github usr is in the list of assignees
  1827  func userInList(user *github.User, assignees []string) bool {
  1828  	if user == nil {
  1829  		return false
  1830  	}
  1831  
  1832  	for _, assignee := range assignees {
  1833  		if *user.Login == assignee {
  1834  			return true
  1835  		}
  1836  	}
  1837  
  1838  	return false
  1839  }
  1840  
  1841  // RemoveAssignees removes the passed-in assignees from the github PR's assignees list
  1842  func (obj *MungeObject) RemoveAssignees(assignees ...string) error {
  1843  	config := obj.config
  1844  	prNum := *obj.Issue.Number
  1845  	config.analytics.RemoveAssignees.Call(config, nil)
  1846  	glog.Infof("Unassigning %v from PR# %d to %v", assignees, prNum)
  1847  	if config.DryRun {
  1848  		return nil
  1849  	}
  1850  	_, resp, err := config.client.Issues.RemoveAssignees(
  1851  		context.Background(),
  1852  		obj.Org(),
  1853  		obj.Project(),
  1854  		prNum,
  1855  		assignees,
  1856  	)
  1857  	if err != nil {
  1858  		err = suggestOauthScopes(resp, err)
  1859  		glog.Errorf("Error unassigning %v from PR# %d: %v", assignees, prNum, err)
  1860  		return err
  1861  	}
  1862  
  1863  	// Remove people from the local object. Replace with an entirely new copy of the list
  1864  	newIssueAssignees := []*github.User{}
  1865  	for _, user := range obj.Issue.Assignees {
  1866  		if userInList(user, assignees) {
  1867  			// Skip this user
  1868  			continue
  1869  		}
  1870  		newIssueAssignees = append(newIssueAssignees, user)
  1871  	}
  1872  	obj.Issue.Assignees = newIssueAssignees
  1873  
  1874  	return nil
  1875  }
  1876  
  1877  // AddAssignee will assign `prNum` to the `owner` where the `owner` is asignee's github login
  1878  func (obj *MungeObject) AddAssignee(owner string) error {
  1879  	config := obj.config
  1880  	prNum := *obj.Issue.Number
  1881  	config.analytics.AddAssignee.Call(config, nil)
  1882  	glog.Infof("Assigning PR# %d to %v", prNum, owner)
  1883  	if config.DryRun {
  1884  		return nil
  1885  	}
  1886  	_, resp, err := config.client.Issues.AddAssignees(
  1887  		context.Background(),
  1888  		obj.Org(),
  1889  		obj.Project(),
  1890  		prNum,
  1891  		[]string{owner},
  1892  	)
  1893  	if err != nil {
  1894  		err = suggestOauthScopes(resp, err)
  1895  		glog.Errorf("Error assigning issue #%d to %v: %v", prNum, owner, err)
  1896  		return err
  1897  	}
  1898  
  1899  	obj.Issue.Assignees = append(obj.Issue.Assignees, &github.User{
  1900  		Login: &owner,
  1901  	})
  1902  
  1903  	return nil
  1904  }
  1905  
  1906  // CloseIssuef will close the given issue with a message
  1907  func (obj *MungeObject) CloseIssuef(format string, args ...interface{}) error {
  1908  	config := obj.config
  1909  	msg := fmt.Sprintf(format, args...)
  1910  	if msg != "" {
  1911  		if err := obj.WriteComment(msg); err != nil {
  1912  			return fmt.Errorf("failed to write comment to %v: %q: %v", *obj.Issue.Number, msg, err)
  1913  		}
  1914  	}
  1915  	closed := "closed"
  1916  	state := &github.IssueRequest{State: &closed}
  1917  	config.analytics.CloseIssue.Call(config, nil)
  1918  	glog.Infof("Closing issue #%d: %v", *obj.Issue.Number, msg)
  1919  	if config.DryRun {
  1920  		return nil
  1921  	}
  1922  	_, resp, err := config.client.Issues.Edit(
  1923  		context.Background(),
  1924  		obj.Org(),
  1925  		obj.Project(),
  1926  		*obj.Issue.Number,
  1927  		state,
  1928  	)
  1929  	if err != nil {
  1930  		err = suggestOauthScopes(resp, err)
  1931  		glog.Errorf("Error closing issue #%d: %v: %v", *obj.Issue.Number, msg, err)
  1932  		return err
  1933  	}
  1934  	return nil
  1935  }
  1936  
  1937  // ClosePR will close the Given PR
  1938  func (obj *MungeObject) ClosePR() bool {
  1939  	config := obj.config
  1940  	pr, ok := obj.GetPR()
  1941  	if !ok {
  1942  		return false
  1943  	}
  1944  	config.analytics.ClosePR.Call(config, nil)
  1945  	glog.Infof("Closing PR# %d", *pr.Number)
  1946  	if config.DryRun {
  1947  		return true
  1948  	}
  1949  	state := "closed"
  1950  	pr.State = &state
  1951  	_, resp, err := config.client.PullRequests.Edit(
  1952  		context.Background(),
  1953  		obj.Org(),
  1954  		obj.Project(),
  1955  		*pr.Number,
  1956  		pr,
  1957  	)
  1958  	if err != nil {
  1959  		glog.Errorf("Failed to close pr %d: %v", *pr.Number, suggestOauthScopes(resp, err))
  1960  		return false
  1961  	}
  1962  	return true
  1963  }
  1964  
  1965  // OpenPR will attempt to open the given PR.
  1966  // It will attempt to reopen the pr `numTries` before returning an error
  1967  // and giving up.
  1968  func (obj *MungeObject) OpenPR(numTries int) bool {
  1969  	config := obj.config
  1970  	pr, ok := obj.GetPR()
  1971  	if !ok {
  1972  		glog.Errorf("Error in OpenPR")
  1973  		return false
  1974  	}
  1975  	config.analytics.OpenPR.Call(config, nil)
  1976  	glog.Infof("Opening PR# %d", *pr.Number)
  1977  	if config.DryRun {
  1978  		return true
  1979  	}
  1980  	state := "open"
  1981  	pr.State = &state
  1982  	// Try pretty hard to re-open, since it's pretty bad if we accidentally leave a PR closed
  1983  	for tries := 0; tries < numTries; tries++ {
  1984  		_, resp, err := config.client.PullRequests.Edit(
  1985  			context.Background(),
  1986  			obj.Org(),
  1987  			obj.Project(),
  1988  			*pr.Number,
  1989  			pr,
  1990  		)
  1991  		if err == nil {
  1992  			return true
  1993  		}
  1994  		glog.Warningf("failed to re-open pr %d: %v", *pr.Number, suggestOauthScopes(resp, err))
  1995  		time.Sleep(5 * time.Second)
  1996  	}
  1997  	if !ok {
  1998  		glog.Errorf("failed to re-open pr %d after %d tries, giving up", *pr.Number, numTries)
  1999  	}
  2000  	return false
  2001  }
  2002  
  2003  // GetFileContents will return the contents of the `file` in the repo at `sha`
  2004  // as a string
  2005  func (obj *MungeObject) GetFileContents(file, sha string) (string, error) {
  2006  	config := obj.config
  2007  	getOpts := &github.RepositoryContentGetOptions{Ref: sha}
  2008  	if len(sha) > 0 {
  2009  		getOpts.Ref = sha
  2010  	}
  2011  	output, _, response, err := config.client.Repositories.GetContents(
  2012  		context.Background(),
  2013  		obj.Org(),
  2014  		obj.Project(),
  2015  		file,
  2016  		getOpts,
  2017  	)
  2018  	config.analytics.GetContents.Call(config, response)
  2019  	if err != nil {
  2020  		err = fmt.Errorf("unable to get %q at commit %q", file, sha)
  2021  		err = suggestOauthScopes(response, err)
  2022  		// I'm using .V(2) because .generated docs is still not in the repo...
  2023  		glog.V(2).Infof("%v", err)
  2024  		return "", err
  2025  	}
  2026  	if output == nil {
  2027  		err = fmt.Errorf("got empty contents for %q at commit %q", file, sha)
  2028  		glog.Errorf("%v", err)
  2029  		return "", err
  2030  	}
  2031  	content, err := output.GetContent()
  2032  	if err != nil {
  2033  		glog.Errorf("Unable to decode file contents: %v", err)
  2034  		return "", err
  2035  	}
  2036  	return content, nil
  2037  }
  2038  
  2039  // MergeCommit will return the sha of the merge. PRs which have not merged
  2040  // (or if we hit an error) will return nil
  2041  func (obj *MungeObject) MergeCommit() (*string, bool) {
  2042  	events, ok := obj.GetEvents()
  2043  	if !ok {
  2044  		return nil, false
  2045  	}
  2046  	for _, event := range events {
  2047  		if *event.Event == "merged" {
  2048  			return event.CommitID, true
  2049  		}
  2050  	}
  2051  	return nil, true
  2052  }
  2053  
  2054  // cleanIssueBody removes irrelevant parts from the issue body,
  2055  // including Reviewable footers and extra whitespace.
  2056  func cleanIssueBody(issueBody string) string {
  2057  	issueBody = reviewableFooterRE.ReplaceAllString(issueBody, "")
  2058  	issueBody = htmlCommentRE.ReplaceAllString(issueBody, "")
  2059  	return strings.TrimSpace(issueBody)
  2060  }
  2061  
  2062  // MergePR will merge the given PR, duh
  2063  // "who" is who is doing the merging, like "submit-queue"
  2064  func (obj *MungeObject) MergePR(who string) bool {
  2065  	config := obj.config
  2066  	prNum := *obj.Issue.Number
  2067  	config.analytics.Merge.Call(config, nil)
  2068  	glog.Infof("Merging PR# %d", prNum)
  2069  	if config.DryRun {
  2070  		return true
  2071  	}
  2072  	mergeBody := fmt.Sprintf("Automatic merge from %s.", who)
  2073  	obj.WriteComment(mergeBody)
  2074  
  2075  	if obj.Issue.Title != nil {
  2076  		mergeBody = fmt.Sprintf("%s\n\n%s", mergeBody, *obj.Issue.Title)
  2077  	}
  2078  
  2079  	// Get the text of the issue body
  2080  	issueBody := ""
  2081  	if obj.Issue.Body != nil {
  2082  		issueBody = cleanIssueBody(*obj.Issue.Body)
  2083  	}
  2084  
  2085  	// Get the text of the first commit
  2086  	firstCommit := ""
  2087  	if commits, ok := obj.GetCommits(); !ok {
  2088  		return false
  2089  	} else if commits[0].Commit.Message != nil {
  2090  		firstCommit = *commits[0].Commit.Message
  2091  	}
  2092  
  2093  	// Include the contents of the issue body if it is not the exact same text as was
  2094  	// included in the first commit.  PRs with a single commit (by default when opened
  2095  	// via the web UI) have the same text as the first commit. If there are multiple
  2096  	// commits people often put summary info in the body. But sometimes, even with one
  2097  	// commit people will edit/update the issue body. So if there is any reason, include
  2098  	// the issue body in the merge commit in git.
  2099  	if !strings.Contains(firstCommit, issueBody) {
  2100  		mergeBody = fmt.Sprintf("%s\n\n%s", mergeBody, issueBody)
  2101  	}
  2102  
  2103  	option := &github.PullRequestOptions{
  2104  		MergeMethod: config.mergeMethod,
  2105  	}
  2106  
  2107  	_, resp, err := config.client.PullRequests.Merge(
  2108  		context.Background(),
  2109  		obj.Org(),
  2110  		obj.Project(),
  2111  		prNum,
  2112  		mergeBody,
  2113  		option,
  2114  	)
  2115  
  2116  	// The github API https://developer.github.com/v3/pulls/#merge-a-pull-request-merge-button indicates
  2117  	// we will only get the bellow error if we provided a particular sha to merge PUT. We aren't doing that
  2118  	// so our best guess is that the API also provides this error message when it is recalulating
  2119  	// "mergeable". So if we get this error, check "IsPRMergeable()" which should sleep just a bit until
  2120  	// github is finished calculating. If my guess is correct, that also means we should be able to
  2121  	// then merge this PR, so try again.
  2122  	if err != nil && strings.Contains(err.Error(), "branch was modified. Review and try the merge again.") {
  2123  		if mergeable, _ := obj.IsMergeable(); mergeable {
  2124  			_, resp, err = config.client.PullRequests.Merge(
  2125  				context.Background(),
  2126  				obj.Org(),
  2127  				obj.Project(),
  2128  				prNum,
  2129  				mergeBody,
  2130  				nil,
  2131  			)
  2132  		}
  2133  	}
  2134  	if err != nil {
  2135  		glog.Errorf("Failed to merge PR: %d: %v", prNum, suggestOauthScopes(resp, err))
  2136  		return false
  2137  	}
  2138  	return true
  2139  }
  2140  
  2141  // GetPRFixesList returns a list of issue numbers that are referenced in the PR body.
  2142  func (obj *MungeObject) GetPRFixesList() []int {
  2143  	prBody := ""
  2144  	if obj.Issue.Body != nil {
  2145  		prBody = *obj.Issue.Body
  2146  	}
  2147  	matches := fixesIssueRE.FindAllStringSubmatch(prBody, -1)
  2148  	if matches == nil {
  2149  		return nil
  2150  	}
  2151  
  2152  	issueNums := []int{}
  2153  	for _, match := range matches {
  2154  		if num, err := strconv.Atoi(match[1]); err == nil {
  2155  			issueNums = append(issueNums, num)
  2156  		}
  2157  	}
  2158  	return issueNums
  2159  }
  2160  
  2161  // ListReviewComments returns all review (diff) comments for the PR in question
  2162  func (obj *MungeObject) ListReviewComments() ([]*github.PullRequestComment, bool) {
  2163  	if obj.prComments != nil {
  2164  		return obj.prComments, true
  2165  	}
  2166  
  2167  	pr, ok := obj.GetPR()
  2168  	if !ok {
  2169  		return nil, ok
  2170  	}
  2171  	prNum := *pr.Number
  2172  	allComments := []*github.PullRequestComment{}
  2173  
  2174  	listOpts := &github.PullRequestListCommentsOptions{ListOptions: github.ListOptions{PerPage: 100}}
  2175  
  2176  	config := obj.config
  2177  	page := 1
  2178  	// Try to work around not finding comments--suspect some cache invalidation bug when the number of pages changes.
  2179  	tryNextPageAnyway := false
  2180  	var lastResponse *github.Response
  2181  	for {
  2182  		listOpts.ListOptions.Page = page
  2183  		glog.V(8).Infof("Fetching page %d of comments for PR %d", page, prNum)
  2184  		comments, response, err := obj.config.client.PullRequests.ListComments(
  2185  			context.Background(),
  2186  			obj.Org(),
  2187  			obj.Project(),
  2188  			prNum,
  2189  			listOpts,
  2190  		)
  2191  		config.analytics.ListReviewComments.Call(config, response)
  2192  		if err != nil {
  2193  			if tryNextPageAnyway {
  2194  				// Cached last page was actually truthful -- expected error.
  2195  				break
  2196  			}
  2197  			glog.Errorf("Failed to fetch page of comments for PR %d: %v", prNum, suggestOauthScopes(response, err))
  2198  			return nil, false
  2199  		}
  2200  		if tryNextPageAnyway {
  2201  			if len(comments) == 0 {
  2202  				break
  2203  			}
  2204  			glog.Infof("For %v: supposedly there weren't more review comments, but we asked anyway and found %v more.", prNum, len(comments))
  2205  			obj.config.deleteCache(lastResponse)
  2206  			tryNextPageAnyway = false
  2207  		}
  2208  		allComments = append(allComments, comments...)
  2209  		if response.LastPage == 0 || response.LastPage <= page {
  2210  			if len(allComments)%100 == 0 {
  2211  				tryNextPageAnyway = true
  2212  				lastResponse = response
  2213  			} else {
  2214  				break
  2215  			}
  2216  		}
  2217  		page++
  2218  	}
  2219  	obj.prComments = allComments
  2220  	return allComments, true
  2221  }
  2222  
  2223  // WithListOpt configures the options to list comments of github issue.
  2224  type WithListOpt func(*github.IssueListCommentsOptions) *github.IssueListCommentsOptions
  2225  
  2226  // ListComments returns all comments for the issue/PR in question
  2227  func (obj *MungeObject) ListComments() ([]*github.IssueComment, bool) {
  2228  	config := obj.config
  2229  	issueNum := *obj.Issue.Number
  2230  	allComments := []*github.IssueComment{}
  2231  
  2232  	if obj.comments != nil {
  2233  		return obj.comments, true
  2234  	}
  2235  
  2236  	listOpts := &github.IssueListCommentsOptions{ListOptions: github.ListOptions{PerPage: 100}}
  2237  
  2238  	page := 1
  2239  	// Try to work around not finding comments--suspect some cache invalidation bug when the number of pages changes.
  2240  	tryNextPageAnyway := false
  2241  	var lastResponse *github.Response
  2242  	for {
  2243  		listOpts.ListOptions.Page = page
  2244  		glog.V(8).Infof("Fetching page %d of comments for issue %d", page, issueNum)
  2245  		comments, response, err := obj.config.client.Issues.ListComments(
  2246  			context.Background(),
  2247  			obj.Org(),
  2248  			obj.Project(),
  2249  			issueNum,
  2250  			listOpts,
  2251  		)
  2252  		config.analytics.ListComments.Call(config, response)
  2253  		if err != nil {
  2254  			if tryNextPageAnyway {
  2255  				// Cached last page was actually truthful -- expected error.
  2256  				break
  2257  			}
  2258  			glog.Errorf("Failed to fetch page of comments for issue %d: %v", issueNum, suggestOauthScopes(response, err))
  2259  			return nil, false
  2260  		}
  2261  		if tryNextPageAnyway {
  2262  			if len(comments) == 0 {
  2263  				break
  2264  			}
  2265  			glog.Infof("For %v: supposedly there weren't more comments, but we asked anyway and found %v more.", issueNum, len(comments))
  2266  			obj.config.deleteCache(lastResponse)
  2267  			tryNextPageAnyway = false
  2268  		}
  2269  		allComments = append(allComments, comments...)
  2270  		if response.LastPage == 0 || response.LastPage <= page {
  2271  			if len(comments)%100 == 0 {
  2272  				tryNextPageAnyway = true
  2273  				lastResponse = response
  2274  			} else {
  2275  				break
  2276  			}
  2277  		}
  2278  		page++
  2279  	}
  2280  	obj.comments = allComments
  2281  	return allComments, true
  2282  }
  2283  
  2284  // WriteComment will send the `msg` as a comment to the specified PR
  2285  func (obj *MungeObject) WriteComment(msg string) error {
  2286  	config := obj.config
  2287  	prNum := obj.Number()
  2288  	config.analytics.CreateComment.Call(config, nil)
  2289  	comment := msg
  2290  	if len(comment) > 512 {
  2291  		comment = comment[:512]
  2292  	}
  2293  	glog.Infof("Commenting in %d: %q", prNum, comment)
  2294  	if config.DryRun {
  2295  		return nil
  2296  	}
  2297  	if len(msg) > maxCommentLen {
  2298  		glog.Info("Comment in %d was larger than %d and was truncated", prNum, maxCommentLen)
  2299  		msg = msg[:maxCommentLen]
  2300  	}
  2301  	_, resp, err := config.client.Issues.CreateComment(
  2302  		context.Background(),
  2303  		obj.Org(),
  2304  		obj.Project(),
  2305  		prNum,
  2306  		&github.IssueComment{Body: &msg},
  2307  	)
  2308  	if err != nil {
  2309  		err = suggestOauthScopes(resp, err)
  2310  		glog.Errorf("%v", err)
  2311  		return err
  2312  	}
  2313  	return nil
  2314  }
  2315  
  2316  // DeleteComment will remove the specified comment
  2317  func (obj *MungeObject) DeleteComment(comment *github.IssueComment) error {
  2318  	config := obj.config
  2319  	prNum := *obj.Issue.Number
  2320  	config.analytics.DeleteComment.Call(config, nil)
  2321  	if comment.ID == nil {
  2322  		err := fmt.Errorf("Found a comment with nil id for Issue %d", prNum)
  2323  		glog.Errorf("Found a comment with nil id for Issue %d", prNum)
  2324  		return err
  2325  	}
  2326  	which := -1
  2327  	for i, c := range obj.comments {
  2328  		if c.ID == nil || *c.ID != *comment.ID {
  2329  			continue
  2330  		}
  2331  		which = i
  2332  	}
  2333  	if which != -1 {
  2334  		// We do this crazy delete since users might be iterating over `range obj.comments`
  2335  		// Make a completely new copy and leave their ranging alone.
  2336  		temp := make([]*github.IssueComment, len(obj.comments)-1)
  2337  		copy(temp, obj.comments[:which])
  2338  		copy(temp[which:], obj.comments[which+1:])
  2339  		obj.comments = temp
  2340  	}
  2341  	body := "UNKNOWN"
  2342  	if comment.Body != nil {
  2343  		body = *comment.Body
  2344  	}
  2345  	author := "UNKNOWN"
  2346  	if comment.User != nil && comment.User.Login != nil {
  2347  		author = *comment.User.Login
  2348  	}
  2349  	glog.Infof("Removing comment %d from Issue %d. Author:%s Body:%q", *comment.ID, prNum, author, body)
  2350  	if config.DryRun {
  2351  		return nil
  2352  	}
  2353  	resp, err := config.client.Issues.DeleteComment(
  2354  		context.Background(),
  2355  		obj.Org(),
  2356  		obj.Project(),
  2357  		*comment.ID,
  2358  	)
  2359  	if err != nil {
  2360  		err = suggestOauthScopes(resp, err)
  2361  		glog.Errorf("Error removing comment: %v", err)
  2362  		return err
  2363  	}
  2364  	return nil
  2365  }
  2366  
  2367  // EditComment will change the specified comment's body.
  2368  func (obj *MungeObject) EditComment(comment *github.IssueComment, body string) error {
  2369  	config := obj.config
  2370  	prNum := *obj.Issue.Number
  2371  	config.analytics.EditComment.Call(config, nil)
  2372  	if comment.ID == nil {
  2373  		err := fmt.Errorf("Found a comment with nil id for Issue %d", prNum)
  2374  		glog.Errorf("Found a comment with nil id for Issue %d", prNum)
  2375  		return err
  2376  	}
  2377  	author := "UNKNOWN"
  2378  	if comment.User != nil && comment.User.Login != nil {
  2379  		author = *comment.User.Login
  2380  	}
  2381  	shortBody := body
  2382  	if len(shortBody) > 512 {
  2383  		shortBody = shortBody[:512]
  2384  	}
  2385  	glog.Infof("Editing comment %d from Issue %d. Author:%s New Body:%q", *comment.ID, prNum, author, shortBody)
  2386  	if config.DryRun {
  2387  		return nil
  2388  	}
  2389  	if len(body) > maxCommentLen {
  2390  		glog.Info("Comment in %d was larger than %d and was truncated", prNum, maxCommentLen)
  2391  		body = body[:maxCommentLen]
  2392  	}
  2393  	patch := github.IssueComment{Body: &body}
  2394  	ic, resp, err := config.client.Issues.EditComment(
  2395  		context.Background(),
  2396  		obj.Org(),
  2397  		obj.Project(),
  2398  		*comment.ID,
  2399  		&patch,
  2400  	)
  2401  	if err != nil {
  2402  		err = suggestOauthScopes(resp, err)
  2403  		glog.Errorf("Error editing comment: %v", err)
  2404  		return err
  2405  	}
  2406  	comment.Body = ic.Body
  2407  	return nil
  2408  }
  2409  
  2410  // IsMergeable will return if the PR is mergeable. It will pause and get the
  2411  // PR again if github did not respond the first time. So the hopefully github
  2412  // will have a response the second time. If we have no answer twice, we return
  2413  // false
  2414  func (obj *MungeObject) IsMergeable() (bool, bool) {
  2415  	if !obj.IsPR() {
  2416  		return false, true
  2417  	}
  2418  	pr, ok := obj.GetPR()
  2419  	if !ok {
  2420  		return false, ok
  2421  	}
  2422  	prNum := obj.Number()
  2423  	// Github might not have computed mergeability yet. Try again a few times.
  2424  	for try := 1; try <= 5 && pr.Mergeable == nil && (pr.Merged == nil || *pr.Merged == false); try++ {
  2425  		glog.V(4).Infof("Waiting for mergeability on %q %d", *pr.Title, prNum)
  2426  		// Sleep for 2-32 seconds on successive attempts.
  2427  		// Worst case, we'll wait for up to a minute for GitHub
  2428  		// to compute it before bailing out.
  2429  		baseDelay := time.Second
  2430  		if obj.config.BaseWaitTime != 0 { // Allow shorter delays in tests.
  2431  			baseDelay = obj.config.BaseWaitTime
  2432  		}
  2433  		time.Sleep((1 << uint(try)) * baseDelay)
  2434  		ok := obj.Refresh()
  2435  		if !ok {
  2436  			return false, ok
  2437  		}
  2438  		pr, ok = obj.GetPR()
  2439  		if !ok {
  2440  			return false, ok
  2441  		}
  2442  	}
  2443  	if pr.Merged != nil && *pr.Merged {
  2444  		glog.Errorf("Found that PR #%d is merged while looking up mergeability, Skipping", prNum)
  2445  		return false, false
  2446  	}
  2447  	if pr.Mergeable == nil {
  2448  		glog.Errorf("No mergeability information for %q %d, Skipping", *pr.Title, prNum)
  2449  		return false, false
  2450  	}
  2451  	return *pr.Mergeable, true
  2452  }
  2453  
  2454  // IsMerged returns if the issue in question was already merged
  2455  func (obj *MungeObject) IsMerged() (bool, bool) {
  2456  	if !obj.IsPR() {
  2457  		glog.Errorf("Issue: %d is not a PR and is thus 'merged' is indeterminate", *obj.Issue.Number)
  2458  		return false, false
  2459  	}
  2460  	pr, ok := obj.GetPR()
  2461  	if !ok {
  2462  		return false, false
  2463  	}
  2464  	if pr.Merged != nil {
  2465  		return *pr.Merged, true
  2466  	}
  2467  	glog.Errorf("Unable to determine if PR %d was merged", *obj.Issue.Number)
  2468  	return false, false
  2469  }
  2470  
  2471  // MergedAt returns the time an issue was merged (for nil if unmerged)
  2472  func (obj *MungeObject) MergedAt() (*time.Time, bool) {
  2473  	if !obj.IsPR() {
  2474  		return nil, false
  2475  	}
  2476  	pr, ok := obj.GetPR()
  2477  	if !ok {
  2478  		return nil, false
  2479  	}
  2480  	return pr.MergedAt, true
  2481  }
  2482  
  2483  // ListReviews returns a list of the Pull Request Reviews on a PR.
  2484  func (obj *MungeObject) ListReviews() ([]*github.PullRequestReview, bool) {
  2485  	if obj.prReviews != nil {
  2486  		return obj.prReviews, true
  2487  	}
  2488  	if !obj.IsPR() {
  2489  		return nil, false
  2490  	}
  2491  
  2492  	pr, ok := obj.GetPR()
  2493  	if !ok {
  2494  		return nil, false
  2495  	}
  2496  	prNum := *pr.Number
  2497  	allReviews := []*github.PullRequestReview{}
  2498  
  2499  	listOpts := &github.ListOptions{PerPage: 100}
  2500  
  2501  	config := obj.config
  2502  	page := 1
  2503  	// Try to work around not finding comments--suspect some cache invalidation bug when the number of pages changes.
  2504  	tryNextPageAnyway := false
  2505  	var lastResponse *github.Response
  2506  	for {
  2507  		listOpts.Page = page
  2508  		glog.V(8).Infof("Fetching page %d of reviews for pr %d", page, prNum)
  2509  		reviews, response, err := obj.config.client.PullRequests.ListReviews(
  2510  			context.Background(),
  2511  			obj.Org(),
  2512  			obj.Project(),
  2513  			prNum,
  2514  			listOpts,
  2515  		)
  2516  		config.analytics.ListReviews.Call(config, response)
  2517  		if err != nil {
  2518  			if tryNextPageAnyway {
  2519  				// Cached last page was actually truthful -- expected error.
  2520  				break
  2521  			}
  2522  			glog.Errorf("Failed to fetch page %d of reviews for pr %d: %v", page, prNum, suggestOauthScopes(response, err))
  2523  			return nil, false
  2524  		}
  2525  		if tryNextPageAnyway {
  2526  			if len(reviews) == 0 {
  2527  				break
  2528  			}
  2529  			glog.Infof("For %v: supposedly there weren't more reviews, but we asked anyway and found %v more.", prNum, len(reviews))
  2530  			obj.config.deleteCache(lastResponse)
  2531  			tryNextPageAnyway = false
  2532  		}
  2533  		allReviews = append(allReviews, reviews...)
  2534  		if response.LastPage == 0 || response.LastPage <= page {
  2535  			if len(allReviews)%100 == 0 {
  2536  				tryNextPageAnyway = true
  2537  				lastResponse = response
  2538  			} else {
  2539  				break
  2540  			}
  2541  		}
  2542  		page++
  2543  	}
  2544  	obj.prReviews = allReviews
  2545  	return allReviews, true
  2546  }
  2547  
  2548  func (obj *MungeObject) CollectGHReviewStatus() ([]*github.PullRequestReview, []*github.PullRequestReview, bool) {
  2549  	reviews, ok := obj.ListReviews()
  2550  	if !ok {
  2551  		glog.Warning("Cannot get all reviews")
  2552  		return nil, nil, false
  2553  	}
  2554  	var approvedReviews, changesRequestReviews []*github.PullRequestReview
  2555  	latestReviews := make(map[string]*github.PullRequestReview)
  2556  	for _, review := range reviews {
  2557  		reviewer := review.User.GetLogin()
  2558  		if r, exist := latestReviews[reviewer]; !exist || r.GetSubmittedAt().Before(review.GetSubmittedAt()) {
  2559  			latestReviews[reviewer] = review
  2560  		}
  2561  	}
  2562  
  2563  	for _, review := range latestReviews {
  2564  		if review.GetState() == ghApproved {
  2565  			approvedReviews = append(approvedReviews, review)
  2566  		} else if review.GetState() == ghChangesRequested {
  2567  			changesRequestReviews = append(changesRequestReviews, review)
  2568  		}
  2569  	}
  2570  	return approvedReviews, changesRequestReviews, true
  2571  }
  2572  
  2573  func (config *Config) runMungeFunction(obj *MungeObject, fn MungeFunction) error {
  2574  	if obj.Issue.Number == nil {
  2575  		glog.Infof("Skipping issue with no number, very strange")
  2576  		return nil
  2577  	}
  2578  	if obj.Issue.User == nil || obj.Issue.User.Login == nil {
  2579  		glog.V(2).Infof("Skipping PR %d with no user info %#v.", *obj.Issue.Number, obj.Issue.User)
  2580  		return nil
  2581  	}
  2582  	if *obj.Issue.Number < config.MinPRNumber {
  2583  		glog.V(6).Infof("Dropping %d < %d", *obj.Issue.Number, config.MinPRNumber)
  2584  		return nil
  2585  	}
  2586  	if *obj.Issue.Number > config.MaxPRNumber {
  2587  		glog.V(6).Infof("Dropping %d > %d", *obj.Issue.Number, config.MaxPRNumber)
  2588  		return nil
  2589  	}
  2590  
  2591  	// Update pull-requests references if we track them with webhooks
  2592  	if config.HookHandler != nil {
  2593  		if obj.IsPR() {
  2594  			if pr, ok := obj.GetPR(); !ok {
  2595  				return nil
  2596  			} else if pr.Head != nil && pr.Head.Ref != nil && pr.Head.SHA != nil {
  2597  				config.HookHandler.UpdatePullRequest(*obj.Issue.Number, *pr.Head.SHA)
  2598  			}
  2599  		}
  2600  	}
  2601  
  2602  	glog.V(2).Infof("----==== %d ====----", *obj.Issue.Number)
  2603  	glog.V(8).Infof("Issue %d labels: %v isPR: %v", *obj.Issue.Number, obj.Issue.Labels, obj.Issue.PullRequestLinks != nil)
  2604  	if err := fn(obj); err != nil {
  2605  		return err
  2606  	}
  2607  	return nil
  2608  }
  2609  
  2610  // ForEachIssueDo will run for each Issue in the project that matches:
  2611  //   * pr.Number >= minPRNumber
  2612  //   * pr.Number <= maxPRNumber
  2613  func (config *Config) ForEachIssueDo(fn MungeFunction) error {
  2614  	page := 1
  2615  	count := 0
  2616  
  2617  	extraIssues := sets.NewInt()
  2618  	// Add issues modified by a received event
  2619  	if config.HookHandler != nil {
  2620  		extraIssues.Insert(config.HookHandler.PopIssues()...)
  2621  	} else {
  2622  		// We're not using the webhooks, make sure we fetch all
  2623  		// the issues
  2624  		config.since = time.Time{}
  2625  	}
  2626  
  2627  	// It's a new day, let's restart from scratch.
  2628  	// Use PST timezone to determine when its a new day.
  2629  	pst := time.FixedZone("PacificStandardTime", -8*60*60 /* seconds offset from UTC */)
  2630  	if time.Now().In(pst).Format("Jan 2 2006") != config.since.In(pst).Format("Jan 2 2006") {
  2631  		config.since = time.Time{}
  2632  	}
  2633  
  2634  	since := time.Now()
  2635  	for {
  2636  		glog.V(4).Infof("Fetching page %d of issues", page)
  2637  		issues, response, err := config.client.Issues.ListByRepo(
  2638  			context.Background(),
  2639  			config.Org,
  2640  			config.Project,
  2641  			&github.IssueListByRepoOptions{
  2642  				Sort:        "created",
  2643  				State:       config.State,
  2644  				Labels:      config.Labels,
  2645  				Direction:   "asc",
  2646  				ListOptions: github.ListOptions{PerPage: 100, Page: page},
  2647  				Since:       config.since,
  2648  			},
  2649  		)
  2650  		config.analytics.ListIssues.Call(config, response)
  2651  		if err != nil {
  2652  			return suggestOauthScopes(response, err)
  2653  		}
  2654  		for i := range issues {
  2655  			obj := &MungeObject{
  2656  				config:      config,
  2657  				Issue:       issues[i],
  2658  				Annotations: map[string]string{},
  2659  			}
  2660  			count++
  2661  			err := config.runMungeFunction(obj, fn)
  2662  			if err != nil {
  2663  				return err
  2664  			}
  2665  			if obj.Issue.UpdatedAt != nil && obj.Issue.UpdatedAt.After(since) {
  2666  				since = *obj.Issue.UpdatedAt
  2667  			}
  2668  			if obj.Issue.Number != nil {
  2669  				delete(extraIssues, *obj.Issue.Number)
  2670  			}
  2671  		}
  2672  		if response.LastPage == 0 || response.LastPage <= page {
  2673  			break
  2674  		}
  2675  		page++
  2676  	}
  2677  	config.since = since
  2678  
  2679  	// Handle additional issues
  2680  	for id := range extraIssues {
  2681  		obj, err := config.GetObject(id)
  2682  		if err != nil {
  2683  			return err
  2684  		}
  2685  		count++
  2686  		glog.V(2).Info("Munging extra-issue: ", id)
  2687  		err = config.runMungeFunction(obj, fn)
  2688  		if err != nil {
  2689  			return err
  2690  		}
  2691  	}
  2692  
  2693  	glog.Infof("Munged %d modified issues. (%d because of status change)", count, len(extraIssues))
  2694  
  2695  	return nil
  2696  }
  2697  
  2698  // ListAllIssues grabs all issues matching the options, so you don't have to
  2699  // worry about paging. Enforces some constraints, like min/max PR number and
  2700  // having a valid user.
  2701  func (config *Config) ListAllIssues(listOpts *github.IssueListByRepoOptions) ([]*github.Issue, error) {
  2702  	allIssues := []*github.Issue{}
  2703  	page := 1
  2704  	for {
  2705  		glog.V(4).Infof("Fetching page %d of issues", page)
  2706  		listOpts.ListOptions = github.ListOptions{PerPage: 100, Page: page}
  2707  		issues, response, err := config.client.Issues.ListByRepo(
  2708  			context.Background(),
  2709  			config.Org,
  2710  			config.Project,
  2711  			listOpts,
  2712  		)
  2713  		config.analytics.ListIssues.Call(config, response)
  2714  		if err != nil {
  2715  			return nil, suggestOauthScopes(response, err)
  2716  		}
  2717  		for i := range issues {
  2718  			issue := issues[i]
  2719  			if issue.Number == nil {
  2720  				glog.Infof("Skipping issue with no number, very strange")
  2721  				continue
  2722  			}
  2723  			if issue.User == nil || issue.User.Login == nil {
  2724  				glog.V(2).Infof("Skipping PR %d with no user info %#v.", *issue.Number, issue.User)
  2725  				continue
  2726  			}
  2727  			if *issue.Number < config.MinPRNumber {
  2728  				glog.V(6).Infof("Dropping %d < %d", *issue.Number, config.MinPRNumber)
  2729  				continue
  2730  			}
  2731  			if *issue.Number > config.MaxPRNumber {
  2732  				glog.V(6).Infof("Dropping %d > %d", *issue.Number, config.MaxPRNumber)
  2733  				continue
  2734  			}
  2735  			allIssues = append(allIssues, issue)
  2736  		}
  2737  		if response.LastPage == 0 || response.LastPage <= page {
  2738  			break
  2739  		}
  2740  		page++
  2741  	}
  2742  	return allIssues, nil
  2743  }
  2744  
  2745  // GetLabels grabs all labels from a particular repository so you don't have to
  2746  // worry about paging.
  2747  func (config *Config) GetLabels() ([]*github.Label, error) {
  2748  	var listOpts github.ListOptions
  2749  	var allLabels []*github.Label
  2750  	page := 1
  2751  	for {
  2752  		glog.V(4).Infof("Fetching page %d of labels", page)
  2753  		listOpts = github.ListOptions{PerPage: 100, Page: page}
  2754  		labels, response, err := config.client.Issues.ListLabels(
  2755  			context.Background(),
  2756  			config.Org,
  2757  			config.Project,
  2758  			&listOpts,
  2759  		)
  2760  		config.analytics.ListLabels.Call(config, response)
  2761  		if err != nil {
  2762  			return nil, suggestOauthScopes(response, err)
  2763  		}
  2764  		for i := range labels {
  2765  			allLabels = append(allLabels, labels[i])
  2766  		}
  2767  		if response.LastPage == 0 || response.LastPage <= page {
  2768  			break
  2769  		}
  2770  		page++
  2771  	}
  2772  	return allLabels, nil
  2773  }
  2774  
  2775  // AddLabel adds a single github label to the repository.
  2776  func (config *Config) AddLabel(label *github.Label) error {
  2777  	config.analytics.AddLabelToRepository.Call(config, nil)
  2778  	glog.Infof("Adding label %v to %v, %v", *label.Name, config.Org, config.Project)
  2779  	if config.DryRun {
  2780  		return nil
  2781  	}
  2782  	_, resp, err := config.client.Issues.CreateLabel(
  2783  		context.Background(),
  2784  		config.Org,
  2785  		config.Project,
  2786  		label,
  2787  	)
  2788  	if err != nil {
  2789  		return suggestOauthScopes(resp, err)
  2790  	}
  2791  	return nil
  2792  }