forked from kubernetes-retired/contrib
-
Notifications
You must be signed in to change notification settings - Fork 0
/
github.go
1919 lines (1765 loc) · 57.8 KB
/
github.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
Copyright 2015 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package github
import (
"bytes"
"encoding/json"
goflag "flag"
"fmt"
"io/ioutil"
"math"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"text/tabwriter"
"time"
"k8s.io/kubernetes/pkg/util/sets"
"github.com/golang/glog"
"github.com/google/go-github/github"
"github.com/gregjones/httpcache"
"github.com/gregjones/httpcache/diskcache"
"github.com/peterbourgon/diskv"
"github.com/spf13/cobra"
"golang.org/x/oauth2"
)
const (
// stolen from https://groups.google.com/forum/#!msg/golang-nuts/a9PitPAHSSU/ziQw1-QHw3EJ
maxInt = int(^uint(0) >> 1)
tokenLimit = 250 // How many github api tokens to not use
// Unit tests take over an hour now...
prMaxWaitTime = 2 * time.Hour
headerRateRemaining = "X-RateLimit-Remaining"
headerRateReset = "X-RateLimit-Reset"
)
var (
releaseMilestoneRE = regexp.MustCompile(`^v[\d]+.[\d]$`)
priorityLabelRE = regexp.MustCompile(`priority/[pP]([\d]+)`)
fixesIssueRE = regexp.MustCompile(`(?i)(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[\s]+#([\d]+)`)
reviewableFooterRE = regexp.MustCompile(`(?s)<!-- Reviewable:start -->.*<!-- Reviewable:end -->`)
maxTime = time.Unix(1<<63-62135596801, 999999999) // http://stackoverflow.com/questions/25065055/what-is-the-maximum-time-time-in-go
)
type callLimitRoundTripper struct {
sync.Mutex
delegate http.RoundTripper
remaining int
resetTime time.Time
}
func (c *callLimitRoundTripper) getTokenExcept(remaining int) {
c.Lock()
if c.remaining > remaining {
c.remaining--
c.Unlock()
return
}
resetTime := c.resetTime
c.Unlock()
sleepTime := resetTime.Sub(time.Now()) + (1 * time.Minute)
if sleepTime > 0 {
glog.Errorf("*****************")
glog.Errorf("Ran out of github API tokens. Sleeping for %v minutes", sleepTime.Minutes())
glog.Errorf("*****************")
}
// negative duration is fine, it means we are past the github api reset and we won't sleep
time.Sleep(sleepTime)
}
func (c *callLimitRoundTripper) getToken() {
c.getTokenExcept(tokenLimit)
}
func (c *callLimitRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if c.delegate == nil {
c.delegate = http.DefaultTransport
}
c.getToken()
resp, err := c.delegate.RoundTrip(req)
c.Lock()
defer c.Unlock()
if resp != nil {
if remaining := resp.Header.Get(headerRateRemaining); remaining != "" {
c.remaining, _ = strconv.Atoi(remaining)
}
if reset := resp.Header.Get(headerRateReset); reset != "" {
if v, _ := strconv.ParseInt(reset, 10, 64); v != 0 {
c.resetTime = time.Unix(v, 0)
}
}
}
return resp, err
}
// By default github responds to PR requests with:
// Cache-Control:[private, max-age=60, s-maxage=60]
// Which means the httpcache would not consider anything stale for 60 seconds.
// However, when we re-check 'PR.mergeable' we need to skip the cache.
// I considered checking the req.URL.Path and only setting max-age=0 when
// getting a PR or getting the CombinedStatus, as these are the times we need
// a super fresh copy. But since all of the other calls are only going to be made
// once per poll loop the 60 second github freshness doesn't matter. So I can't
// think of a reason not to just keep this simple and always set max-age=0 on
// every request.
type zeroCacheRoundTripper struct {
delegate http.RoundTripper
}
func (r *zeroCacheRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("Cache-Control", "max-age=0")
delegate := r.delegate
if delegate == nil {
delegate = http.DefaultTransport
}
return delegate.RoundTrip(req)
}
// Config is how we are configured to talk to github and provides access
// methods for doing so.
type Config struct {
client *github.Client
apiLimit *callLimitRoundTripper
Org string
Project string
State string
Labels []string
// token is private so it won't get printed in the logs.
token string
TokenFile string
Address string // if a munger runs a web server, where it should live
WWWRoot string
HTTPCacheDir string
HTTPCacheSize uint64
MinPRNumber int
MaxPRNumber int
// If true, don't make any mutating API calls
DryRun bool
// Base sleep time for retry loops. Defaults to 1 second.
BaseWaitTime time.Duration
// When we clear analytics we store the last values here
lastAnalytics analytics
analytics analytics
}
type analytic struct {
Count int
CachedCount int
}
func (a *analytic) Call(config *Config, response *github.Response) {
if response != nil && response.Response.Header.Get(httpcache.XFromCache) != "" {
config.analytics.cachedAPICount++
a.CachedCount++
}
config.analytics.apiCount++
a.Count++
}
type analytics struct {
lastAPIReset time.Time
nextAnalyticUpdate time.Time // when we expect the next update
apiCount int // number of times we called a github API
cachedAPICount int // how many api calls were answered by the local cache
apiPerSec float64
AddLabels analytic
AddLabelToRepository analytic
RemoveLabels analytic
ListCollaborators analytic
GetIssue analytic
CloseIssue analytic
CreateIssue analytic
ListIssues analytic
ListIssueEvents analytic
ListCommits analytic
ListLabels analytic
GetCommit analytic
ListFiles analytic
GetCombinedStatus analytic
SetStatus analytic
GetPR analytic
AssignPR analytic
ClosePR analytic
OpenPR analytic
GetContents analytic
ListComments analytic
ListReviewComments analytic
CreateComment analytic
DeleteComment analytic
Merge analytic
GetUser analytic
SetMilestone analytic
ListMilestones analytic
}
func (a analytics) print() {
glog.Infof("Made %d API calls since the last Reset %f calls/sec", a.apiCount, a.apiPerSec)
buf := new(bytes.Buffer)
w := new(tabwriter.Writer)
w.Init(buf, 0, 0, 1, ' ', tabwriter.AlignRight)
fmt.Fprintf(w, "AddLabels\t%d\t\n", a.AddLabels.Count)
fmt.Fprintf(w, "AddLabelToRepository\t%d\t\n", a.AddLabelToRepository.Count)
fmt.Fprintf(w, "RemoveLabels\t%d\t\n", a.RemoveLabels.Count)
fmt.Fprintf(w, "ListCollaborators\t%d\t\n", a.ListCollaborators.Count)
fmt.Fprintf(w, "GetIssue\t%d\t\n", a.GetIssue.Count)
fmt.Fprintf(w, "CloseIssue\t%d\t\n", a.CloseIssue.Count)
fmt.Fprintf(w, "CreateIssue\t%d\t\n", a.CreateIssue.Count)
fmt.Fprintf(w, "ListIssues\t%d\t\n", a.ListIssues.Count)
fmt.Fprintf(w, "ListIssueEvents\t%d\t\n", a.ListIssueEvents.Count)
fmt.Fprintf(w, "ListCommits\t%d\t\n", a.ListCommits.Count)
fmt.Fprintf(w, "ListLabels\t%d\t\n", a.ListLabels.Count)
fmt.Fprintf(w, "GetCommit\t%d\t\n", a.GetCommit.Count)
fmt.Fprintf(w, "ListFiles\t%d\t\n", a.ListFiles.Count)
fmt.Fprintf(w, "GetCombinedStatus\t%d\t\n", a.GetCombinedStatus.Count)
fmt.Fprintf(w, "SetStatus\t%d\t\n", a.SetStatus.Count)
fmt.Fprintf(w, "GetPR\t%d\t\n", a.GetPR.Count)
fmt.Fprintf(w, "AssignPR\t%d\t\n", a.AssignPR.Count)
fmt.Fprintf(w, "ClosePR\t%d\t\n", a.ClosePR.Count)
fmt.Fprintf(w, "OpenPR\t%d\t\n", a.OpenPR.Count)
fmt.Fprintf(w, "GetContents\t%d\t\n", a.GetContents.Count)
fmt.Fprintf(w, "ListReviewComments\t%d\t\n", a.ListReviewComments.Count)
fmt.Fprintf(w, "ListComments\t%d\t\n", a.ListComments.Count)
fmt.Fprintf(w, "CreateComment\t%d\t\n", a.CreateComment.Count)
fmt.Fprintf(w, "DeleteComment\t%d\t\n", a.DeleteComment.Count)
fmt.Fprintf(w, "Merge\t%d\t\n", a.Merge.Count)
fmt.Fprintf(w, "GetUser\t%d\t\n", a.GetUser.Count)
fmt.Fprintf(w, "SetMilestone\t%d\t\n", a.SetMilestone.Count)
fmt.Fprintf(w, "ListMilestones\t%d\t\n", a.ListMilestones.Count)
w.Flush()
glog.V(2).Infof("\n%v", buf)
}
// MungeObject is the object that mungers deal with. It is a combination of
// different github API objects.
type MungeObject struct {
config *Config
Issue *github.Issue
pr *github.PullRequest
commits []*github.RepositoryCommit
events []*github.IssueEvent
comments []*github.IssueComment
prComments []*github.PullRequestComment
commitFiles []*github.CommitFile
Annotations map[string]string //annotations are things you can set yourself.
}
// Number is short for *obj.Issue.Number.
func (obj *MungeObject) Number() int {
return *obj.Issue.Number
}
// DebugStats is a structure that tells information about how we have interacted
// with github
type DebugStats struct {
Analytics analytics
APIPerSec float64
APICount int
CachedAPICount int
NextLoopTime time.Time
LimitRemaining int
LimitResetTime time.Time
}
// TestObject should NEVER be used outside of _test.go code. It creates a
// MungeObject with the given fields. Normally these should be filled in lazily
// as needed
func TestObject(config *Config, issue *github.Issue, pr *github.PullRequest, commits []*github.RepositoryCommit, events []*github.IssueEvent) *MungeObject {
return &MungeObject{
config: config,
Issue: issue,
pr: pr,
commits: commits,
events: events,
Annotations: map[string]string{},
}
}
// AddRootFlags will add all of the flags needed for the github config to the cobra command
func (config *Config) AddRootFlags(cmd *cobra.Command) {
cmd.PersistentFlags().StringVar(&config.token, "token", "", "The OAuth Token to use for requests.")
cmd.PersistentFlags().StringVar(&config.TokenFile, "token-file", "", "The file containing the OAuth token to use for requests.")
cmd.PersistentFlags().IntVar(&config.MinPRNumber, "min-pr-number", 0, "The minimum PR to start with")
cmd.PersistentFlags().IntVar(&config.MaxPRNumber, "max-pr-number", maxInt, "The maximum PR to start with")
cmd.PersistentFlags().BoolVar(&config.DryRun, "dry-run", true, "If true, don't actually merge anything")
cmd.PersistentFlags().StringVar(&config.Org, "organization", "", "The github organization to scan")
cmd.PersistentFlags().StringVar(&config.Project, "project", "", "The github project to scan")
cmd.PersistentFlags().StringVar(&config.State, "state", "", "State of PRs to process: 'open', 'all', etc")
cmd.PersistentFlags().StringSliceVar(&config.Labels, "labels", []string{}, "CSV list of label which should be set on processed PRs. Unset is all labels.")
cmd.PersistentFlags().StringVar(&config.Address, "address", ":8080", "The address to listen on for HTTP Status")
cmd.PersistentFlags().StringVar(&config.WWWRoot, "www", "www", "Path to static web files to serve from the webserver")
cmd.PersistentFlags().StringVar(&config.HTTPCacheDir, "http-cache-dir", "", "Path to directory where github data can be cached across restarts, if unset use in memory cache")
cmd.PersistentFlags().Uint64Var(&config.HTTPCacheSize, "http-cache-size", 1000, "Maximum size for the HTTP cache (in MB)")
cmd.PersistentFlags().AddGoFlagSet(goflag.CommandLine)
}
// Token returns the token
func (config *Config) Token() string {
return config.token
}
// PreExecute will initialize the Config. It MUST be run before the config
// may be used to get information from Github
func (config *Config) PreExecute() error {
if len(config.Org) == 0 {
glog.Fatalf("--organization is required.")
}
if len(config.Project) == 0 {
glog.Fatalf("--project is required.")
}
token := config.token
if len(token) == 0 && len(config.TokenFile) != 0 {
data, err := ioutil.ReadFile(config.TokenFile)
if err != nil {
glog.Fatalf("error reading token file: %v", err)
}
token = strings.TrimSpace(string(data))
config.token = token
}
// We need to get our Transport/RoundTripper in order based on arguments
// oauth2 Transport // if we have an auth token
// zeroCacheRoundTripper // if we are using the cache want faster timeouts
// webCacheRoundTripper // if we are using the cache
// callLimitRoundTripper ** always
// [http.DefaultTransport] ** always implicit
var transport http.RoundTripper
callLimitTransport := &callLimitRoundTripper{
remaining: tokenLimit + 500, // put in 500 so we at least have a couple to check our real limits
resetTime: time.Now().Add(1 * time.Minute),
}
config.apiLimit = callLimitTransport
transport = callLimitTransport
var t *httpcache.Transport
if config.HTTPCacheDir != "" {
maxBytes := config.HTTPCacheSize * 1000000 // convert M to B. This is storage so not base 2...
d := diskv.New(diskv.Options{
BasePath: config.HTTPCacheDir,
CacheSizeMax: maxBytes,
})
cache := diskcache.NewWithDiskv(d)
t = httpcache.NewTransport(cache)
} else {
t = httpcache.NewMemoryCacheTransport()
}
t.Transport = transport
zeroCacheTransport := &zeroCacheRoundTripper{
delegate: t,
}
transport = zeroCacheTransport
if len(token) > 0 {
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
transport = &oauth2.Transport{
Base: transport,
Source: oauth2.ReuseTokenSource(nil, ts),
}
}
client := &http.Client{
Transport: transport,
}
config.client = github.NewClient(client)
config.ResetAPICount()
return nil
}
// GetDebugStats returns information about the bot iself. Things like how many
// API calls has it made, how many of each type, etc.
func (config *Config) GetDebugStats() DebugStats {
d := DebugStats{
Analytics: config.lastAnalytics,
APIPerSec: config.lastAnalytics.apiPerSec,
APICount: config.lastAnalytics.apiCount,
CachedAPICount: config.lastAnalytics.cachedAPICount,
NextLoopTime: config.lastAnalytics.nextAnalyticUpdate,
}
config.apiLimit.Lock()
defer config.apiLimit.Unlock()
d.LimitRemaining = config.apiLimit.remaining
d.LimitResetTime = config.apiLimit.resetTime
return d
}
func (config *Config) serveDebugStats(res http.ResponseWriter, req *http.Request) {
stats := config.GetDebugStats()
b, err := json.Marshal(stats)
if err != nil {
glog.Errorf("Unable to Marshal Status: %v: %v", stats, err)
res.Header().Set("Content-type", "text/plain")
res.WriteHeader(http.StatusInternalServerError)
return
}
res.Header().Set("Content-type", "application/json")
res.WriteHeader(http.StatusOK)
res.Write(b)
}
// ServeDebugStats will serve out debug information at the path
func (config *Config) ServeDebugStats(path string) {
http.HandleFunc(path, config.serveDebugStats)
}
// NextExpectedUpdate will set the debug information concerning when the
// mungers are likely to run again.
func (config *Config) NextExpectedUpdate(t time.Time) {
config.analytics.nextAnalyticUpdate = t
}
// ResetAPICount will both reset the counters of how many api calls have been
// made but will also print the information from the last run.
func (config *Config) ResetAPICount() {
since := time.Since(config.analytics.lastAPIReset)
config.analytics.apiPerSec = float64(config.analytics.apiCount) / since.Seconds()
config.lastAnalytics = config.analytics
config.analytics.print()
config.analytics = analytics{}
config.analytics.lastAPIReset = time.Now()
}
// SetClient should ONLY be used by testing. Normal commands should use PreExecute()
func (config *Config) SetClient(client *github.Client) {
config.client = client
}
func (config *Config) getPR(num int) (*github.PullRequest, error) {
pr, response, err := config.client.PullRequests.Get(config.Org, config.Project, num)
config.analytics.GetPR.Call(config, response)
if err != nil {
glog.Errorf("Error getting PR# %d: %v", num, err)
return nil, err
}
return pr, nil
}
func (config *Config) getIssue(num int) (*github.Issue, error) {
issue, resp, err := config.client.Issues.Get(config.Org, config.Project, num)
config.analytics.GetIssue.Call(config, resp)
if err != nil {
glog.Errorf("getIssue: %v", err)
return nil, err
}
return issue, nil
}
// Refresh will refresh the Issue (and PR if this is a PR)
// (not the commits or events)
func (obj *MungeObject) Refresh() error {
num := *obj.Issue.Number
issue, err := obj.config.getIssue(num)
if err != nil {
return err
}
obj.Issue = issue
if !obj.IsPR() {
return nil
}
pr, err := obj.config.getPR(*obj.Issue.Number)
if err != nil {
return err
}
obj.pr = pr
return nil
}
// ListMilestones will return all milestones of the given `state`
func (config *Config) ListMilestones(state string) []*github.Milestone {
listopts := github.MilestoneListOptions{
State: state,
}
milestones, resp, err := config.client.Issues.ListMilestones(config.Org, config.Project, &listopts)
config.analytics.ListMilestones.Call(config, resp)
if err != nil {
glog.Errorf("Error getting milestones of state %q: %v", state, err)
}
return milestones
}
// GetObject will return an object (with only the issue filled in)
func (config *Config) GetObject(num int) (*MungeObject, error) {
issue, err := config.getIssue(num)
if err != nil {
return nil, err
}
obj := &MungeObject{
config: config,
Issue: issue,
Annotations: map[string]string{},
}
return obj, nil
}
// NewIssue will file a new issue and return an object for it.
// If "owner" is not empty, the issue will be assigned to "owner".
func (config *Config) NewIssue(title, body string, labels []string, owner string) (*MungeObject, error) {
config.analytics.CreateIssue.Call(config, nil)
glog.Infof("Creating an issue: %q", title)
if config.DryRun {
return nil, fmt.Errorf("can't make issues in dry-run mode")
}
var assignee *string
if owner != "" {
assignee = &owner
}
issue, _, err := config.client.Issues.Create(config.Org, config.Project, &github.IssueRequest{
Title: &title,
Body: &body,
Labels: &labels,
Assignee: assignee,
})
if err != nil {
glog.Errorf("createIssue: %v", err)
return nil, err
}
obj := &MungeObject{
config: config,
Issue: issue,
Annotations: map[string]string{},
}
return obj, nil
}
// Branch returns the branch the PR is for. Return "" if this is not a PR or
// it does not have the required information.
func (obj *MungeObject) Branch() string {
pr, err := obj.GetPR()
if err != nil {
return ""
}
if pr.Base != nil && pr.Base.Ref != nil {
return *pr.Base.Ref
}
return ""
}
// IsForBranch return true if the object is a PR for a branch with the given
// name. It return false if it is not a pr, it isn't against the given branch,
// or we can't tell
func (obj *MungeObject) IsForBranch(branch string) bool {
objBranch := obj.Branch()
if objBranch == branch {
return true
}
return false
}
// LastModifiedTime returns the time the last commit was made
// BUG: this should probably return the last time a git push happened or something like that.
func (obj *MungeObject) LastModifiedTime() *time.Time {
var lastModified *time.Time
commits, err := obj.GetCommits()
if err != nil {
return lastModified
}
for _, commit := range commits {
if commit.Commit == nil || commit.Commit.Committer == nil || commit.Commit.Committer.Date == nil {
glog.Errorf("PR %d: Found invalid RepositoryCommit: %v", *obj.Issue.Number, commit)
continue
}
if lastModified == nil || commit.Commit.Committer.Date.After(*lastModified) {
lastModified = commit.Commit.Committer.Date
}
}
return lastModified
}
// FirstLabelTime returns the first time the request label was added to an issue.
// If the label was never added you will get a nil time.
func (obj *MungeObject) FirstLabelTime(label string) *time.Time {
event := obj.labelEvent(label, firstTime)
if event == nil {
return nil
}
return event.CreatedAt
}
// Return true if 'a' is preferable to 'b'. Handle nil times!
type timePred func(a, b *time.Time) bool
func firstTime(a, b *time.Time) bool {
if a == nil {
return false
}
if b == nil {
return true
}
return !a.After(*b)
}
func lastTime(a, b *time.Time) bool {
if a == nil {
return false
}
if b == nil {
return true
}
return a.After(*b)
}
// labelEvent returns the event where the given label was added to an issue.
// 'pred' is used to select which label event is chosen if there are multiple.
func (obj *MungeObject) labelEvent(label string, pred timePred) *github.IssueEvent {
var labelTime *time.Time
var out *github.IssueEvent
events, err := obj.GetEvents()
if err != nil {
return out
}
for _, event := range events {
if *event.Event == "labeled" && *event.Label.Name == label {
if pred(event.CreatedAt, labelTime) {
labelTime = event.CreatedAt
out = event
}
}
}
return out
}
// LabelTime returns the last time the request label was added to an issue.
// If the label was never added you will get a nil time.
func (obj *MungeObject) LabelTime(label string) *time.Time {
event := obj.labelEvent(label, lastTime)
if event == nil {
return nil
}
return event.CreatedAt
}
// LabelCreator returns the login name of the user who (last) created the given label
func (obj *MungeObject) LabelCreator(label string) string {
event := obj.labelEvent(label, lastTime)
if event == nil || event.Actor == nil || event.Actor.Login == nil {
return ""
}
return *event.Actor.Login
}
// HasLabel returns if the label `name` is in the array of `labels`
func (obj *MungeObject) HasLabel(name string) bool {
labels := obj.Issue.Labels
for i := range labels {
label := &labels[i]
if label.Name != nil && *label.Name == name {
return true
}
}
return false
}
// HasLabels returns if all of the label `names` are in the array of `labels`
func (obj *MungeObject) HasLabels(names []string) bool {
for i := range names {
if !obj.HasLabel(names[i]) {
return false
}
}
return true
}
// LabelSet returns the name of all of he labels applied to the object as a
// kubernetes string set.
func (obj *MungeObject) LabelSet() sets.String {
out := sets.NewString()
for _, label := range obj.Issue.Labels {
out.Insert(*label.Name)
}
return out
}
// GetLabelsWithPrefix will return a slice of all label names in `labels` which
// start with given prefix.
func GetLabelsWithPrefix(labels []github.Label, prefix string) []string {
var ret []string
for _, label := range labels {
if label.Name != nil && strings.HasPrefix(*label.Name, prefix) {
ret = append(ret, *label.Name)
}
}
return ret
}
// AddLabel adds a single `label` to the issue
func (obj *MungeObject) AddLabel(label string) error {
return obj.AddLabels([]string{label})
}
// AddLabels will add all of the named `labels` to the issue
func (obj *MungeObject) AddLabels(labels []string) error {
config := obj.config
prNum := *obj.Issue.Number
config.analytics.AddLabels.Call(config, nil)
glog.Infof("Adding labels %v to PR %d", labels, prNum)
if len(labels) == 0 {
glog.Info("No labels to add: quitting")
return nil
}
if config.DryRun {
return nil
}
for _, l := range labels {
label := github.Label{
Name: &l,
}
obj.Issue.Labels = append(obj.Issue.Labels, label)
}
if _, _, err := config.client.Issues.AddLabelsToIssue(config.Org, config.Project, prNum, labels); err != nil {
glog.Errorf("Failed to set labels %v for %d: %v", labels, prNum, err)
return err
}
return nil
}
// RemoveLabel will remove the `label` from the PR
func (obj *MungeObject) RemoveLabel(label string) error {
config := obj.config
prNum := *obj.Issue.Number
which := -1
for i, l := range obj.Issue.Labels {
if l.Name != nil && *l.Name == label {
which = i
break
}
}
if which != -1 {
// We do this crazy delete since users might be iterating over `range obj.Issue.Labels`
// Make a completely new copy and leave their ranging alone.
temp := make([]github.Label, len(obj.Issue.Labels)-1)
copy(temp, obj.Issue.Labels[:which])
copy(temp[which:], obj.Issue.Labels[which+1:])
obj.Issue.Labels = temp
}
config.analytics.RemoveLabels.Call(config, nil)
glog.Infof("Removing label %q to PR %d", label, prNum)
if config.DryRun {
return nil
}
if _, err := config.client.Issues.RemoveLabelForIssue(config.Org, config.Project, prNum, label); err != nil {
glog.Errorf("Failed to remove %v from issue %d: %v", label, prNum, err)
return err
}
return nil
}
// GetHeadAndBase returns the head SHA and the base ref, so that you can get
// the base's sha in a second step. Purpose: if head and base SHA are the same
// across two merge attempts, we don't need to rerun tests.
func (obj *MungeObject) GetHeadAndBase() (headSHA, baseRef string, ok bool) {
pr, err := obj.GetPR()
if err != nil {
return "", "", false
}
if pr.Head == nil || pr.Head.SHA == nil {
return "", "", false
}
headSHA = *pr.Head.SHA
if pr.Base == nil || pr.Base.Ref == nil {
return "", "", false
}
baseRef = *pr.Base.Ref
return headSHA, baseRef, true
}
// GetSHAFromRef returns the current SHA of the given ref (i.e., branch).
func (obj *MungeObject) GetSHAFromRef(ref string) (sha string, ok bool) {
commit, response, err := obj.config.client.Repositories.GetCommit(obj.config.Org, obj.config.Project, ref)
obj.config.analytics.GetCommit.Call(obj.config, response)
if err != nil {
glog.Errorf("Failed to get commit for %v, %v, %v: %v", obj.config.Org, obj.config.Project, ref, err)
return "", false
}
if commit.SHA == nil {
return "", false
}
return *commit.SHA, true
}
// SetMilestone will set the milestone to the value specified
func (obj *MungeObject) SetMilestone(title string) error {
milestones := obj.config.ListMilestones("all")
var milestone *github.Milestone
for _, m := range milestones {
if m.Title == nil || m.Number == nil {
glog.Errorf("Found milestone with nil title of number: %v", m)
continue
}
if *m.Title == title {
milestone = m
break
}
}
if milestone == nil {
glog.Errorf("Unable to find milestone with title %q", title)
return fmt.Errorf("Unable to find milestone")
}
obj.config.analytics.SetMilestone.Call(obj.config, nil)
obj.Issue.Milestone = milestone
if obj.config.DryRun {
return nil
}
request := &github.IssueRequest{Milestone: milestone.Number}
if _, _, err := obj.config.client.Issues.Edit(obj.config.Org, obj.config.Project, *obj.Issue.Number, request); err != nil {
glog.Errorf("Failed to set milestone %d on issue %d: %v", *milestone.Number, *obj.Issue.Number, err)
return err
}
return nil
}
// ReleaseMilestone returns the name of the 'release' milestone or an empty string
// if none found. Release milestones are determined by the format "vX.Y"
func (obj *MungeObject) ReleaseMilestone() string {
milestone := obj.Issue.Milestone
if milestone == nil {
return ""
}
title := milestone.Title
if title == nil {
return ""
}
if !releaseMilestoneRE.MatchString(*title) {
return ""
}
return *title
}
// ReleaseMilestoneDue returns the due date for a milestone. It ONLY looks at
// milestones of the form 'vX.Y' where X and Y are integeters. Return the maximum
// possible time if there is no milestone or the milestone doesn't look like a
// release milestone
func (obj *MungeObject) ReleaseMilestoneDue() time.Time {
milestone := obj.Issue.Milestone
if milestone == nil {
return maxTime
}
title := milestone.Title
if title == nil {
return maxTime
}
if !releaseMilestoneRE.MatchString(*title) {
return maxTime
}
if milestone.DueOn == nil {
return maxTime
}
return *milestone.DueOn
}
// Priority returns the priority an issue was labeled with.
// The labels must take the form 'priority/[pP][0-9]+'
// or math.MaxInt32 if unset
//
// If a PR has both priority/p0 and priority/p1 it will be considered a p0.
func (obj *MungeObject) Priority() int {
priority := math.MaxInt32
priorityLabels := GetLabelsWithPrefix(obj.Issue.Labels, "priority/")
for _, label := range priorityLabels {
matches := priorityLabelRE.FindStringSubmatch(label)
// First match should be the whole label, second match the number itself
if len(matches) != 2 {
continue
}
prio, err := strconv.Atoi(matches[1])
if err != nil {
continue
}
if prio < priority {
priority = prio
}
}
return priority
}
// MungeFunction is the type that must be implemented and passed to ForEachIssueDo
type MungeFunction func(*MungeObject) error
func (config *Config) fetchAllCollaborators() ([]*github.User, error) {
page := 1
var result []*github.User
for {
glog.V(4).Infof("Fetching page %d of all users", page)
listOpts := &github.ListOptions{PerPage: 100, Page: page}
users, response, err := config.client.Repositories.ListCollaborators(config.Org, config.Project, listOpts)
if err != nil {
return nil, err
}
config.analytics.ListCollaborators.Call(config, response)
result = append(result, users...)
if response.LastPage == 0 || response.LastPage <= page {
break
}
page++
}
return result, nil
}
// UsersWithAccess returns two sets of users. The first set are users with push
// access. The second set is the specific set of user with pull access. If the
// repo is public all users will have pull access, but some with have it
// explicitly
func (config *Config) UsersWithAccess() ([]*github.User, []*github.User, error) {
pushUsers := []*github.User{}
pullUsers := []*github.User{}
users, err := config.fetchAllCollaborators()
if err != nil {
glog.Errorf("%v", err)
return nil, nil, err
}
for _, user := range users {
if user.Permissions == nil || user.Login == nil {
err := fmt.Errorf("found a user with nil Permissions or Login")
glog.Errorf("%v", err)
return nil, nil, err
}
perms := *user.Permissions
if perms["push"] {
pushUsers = append(pushUsers, user)
} else if perms["pull"] {
pullUsers = append(pullUsers, user)
}
}
return pushUsers, pullUsers, nil
}
// GetUser will return information about the github user with the given login name
func (config *Config) GetUser(login string) (*github.User, error) {
user, response, err := config.client.Users.Get(login)
config.analytics.GetUser.Call(config, response)
return user, err
}
// DescribeUser returns the Login string, which may be nil.
func DescribeUser(u *github.User) string {
if u != nil && u.Login != nil {
return *u.Login
}
return "<nil>"
}
// IsPR returns if the obj is a PR or an Issue.
func (obj *MungeObject) IsPR() bool {
if obj.Issue.PullRequestLinks == nil {
return false
}
return true
}
// GetEvents returns a list of all events for a given pr.
func (obj *MungeObject) GetEvents() ([]*github.IssueEvent, error) {
config := obj.config
prNum := *obj.Issue.Number
events := []*github.IssueEvent{}
page := 1
// Try to work around not finding events--suspect some cache invalidation bug when the number of pages changes.
tryNextPageAnyway := false
for {
eventPage, response, err := config.client.Issues.ListIssueEvents(config.Org, config.Project, prNum, &github.ListOptions{PerPage: 100, Page: page})
config.analytics.ListIssueEvents.Call(config, response)