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