├── .gitignore ├── .goreleaser.yml ├── .travis.yml ├── LICENSE ├── README.md ├── cache ├── cache.go └── cache_test.go ├── cmd ├── cli.go ├── fs.go └── main.go ├── coverage.sh ├── github ├── client │ ├── abusePreventing.go │ ├── abusePreventing_test.go │ ├── authenticating.go │ ├── authenticating_test.go │ ├── client.go │ ├── client_test.go │ ├── errorWrapping.go │ ├── errorWrapping_test.go │ ├── rateLimiting.go │ ├── rateLimiting_test.go │ ├── retrying.go │ └── retrying_test.go ├── comment.go ├── comment_test.go ├── diffsize.go ├── diffsize_test.go ├── github.go ├── github_test.go ├── page │ ├── page.go │ └── page_test.go ├── pullRequest.go ├── pullRequest_test.go ├── repository.go ├── repository_test.go ├── reviewRequest.go ├── reviewRequest_test.go └── util │ ├── github_util.go │ └── github_util_test.go ├── glide.lock ├── glide.yaml ├── godownloader.sh ├── main.go ├── metric ├── age.go ├── ageAssignee.go ├── assignee.go ├── assigneeMatrix.go ├── author.go ├── authorComments.go ├── commentCharsPerDay.go ├── descriptionSize.go ├── diffsize.go ├── diffsizePerDay.go ├── metric.go ├── reviewRequest.go └── util.go └── progress ├── progress.go └── progress_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | coverage.txt 3 | dist/ 4 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - binary: pullkee 3 | goos: 4 | - windows 5 | - darwin 6 | - linux 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.9 5 | 6 | script: 7 | - bash ./coverage.sh 8 | 9 | after_success: 10 | - bash <(curl -s https://codecov.io/bash) 11 | - test -n "$TRAVIS_TAG" && curl -sL https://git.io/goreleaser | bash 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Kirill Rogovoy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Pull request icon 3 |

pullkee

4 |

A simple Pull Requests analyzer.

5 |

6 | Release 7 | Software License 8 | Travis 9 | Codecov branch 10 | Go Report Card 11 | Go Doc 12 | Powered By: GoReleaser 13 |

14 |

15 | 16 | ## Why? 17 | 18 | It's always been fun for me to browse pages like this: https://github.com/facebook/react/graphs/contributors. 19 | 20 | Although it can't possibly give one a meaningful insight, I've been curious about a number of other metrics in my work project. 21 | Who is producing more code? Who's being picked as a reviewer more ofter? How long does it take for us on average to merge 22 | a pull request? Who writes more (or longer) comments? 23 | 24 | Again, it's not something you can strongly base your decisions on, but it's just plain curiosity. 25 | Also, maybe a combination of such metrics could actually mean something. 26 | 27 | **So,** the single purpose of this project is to provide that kind of insights given a Github repository name. 28 | 29 | Another great motivator for me was to learn Golang as this project presents a big deal of different challenges. 30 | 31 | ## Install 32 | 33 | If you have the Golang environment set up on your computer, just run: 34 | ``` 35 | go get github.com/kirillrogovoy/pullkee 36 | ``` 37 | and you are all set. 38 | 39 | Otherwise, you can manually download the binary from the [Releases page](https://github.com/kirillrogovoy/pullkee/releases). 40 | In order to install it automatically, run: 41 | ``` 42 | curl https://raw.githubusercontent.com/kirillrogovoy/pullkee/master/godownloader.sh | bash 43 | ``` 44 | 45 | ## Usage 46 | 47 | Just run `pullkee` to see the usage. Here's a copy for convenience: 48 | ``` 49 | Usage: 50 | pullkee [flags] [repo] 51 | repo - Github repository path as "username/reponame" 52 | 53 | Flags: 54 | --limit - Only use N last pull requests 55 | 56 | Environment variables: 57 | GITHUB_CREDS - API credentials in the format "username:personal_access_token" 58 | ``` 59 | 60 | For example, to get the reports for the last 500 merged pull requests of the React repo, run this: 61 | ```sh 62 | GITHUB_CREDS="your_name:your_key" pullkee --limit 500 facebook/react 63 | ``` 64 | 65 | ## API rate limits and cache 66 | 67 | Strongly consider using the `--limit` parameter on big repos since 68 | you have a limited number of requests to make to the Github API. For me, it's currently 5000 per 1 hour. 69 | Also, always provide the `GITHUB_CREDS` env var, otherwise you only have 60 requests per 1 hour without it. 70 | 71 | Don't have a token yet? [Say no more](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/). 72 | 73 | That said, pullkee always uses a per-PR local cache in order to avoid 74 | repetitive requests for the data of the same pull request. 75 | 76 | It means, even if you ran out of requests, you still can wait for them to renew and continue. 77 | 78 | ## Metrics 79 | 80 | The current list of metrics is "baked in" into the project and cannot be changed from outside. 81 | I'd prefer to keep it that way unless someone is explicitely interested in that. 82 | 83 | Just fork the repo to change or add metrics. 84 | 85 | ## Contribute 86 | 87 | Please, contribute in any way if you feel like it. 88 | Start from the [docs](https://godoc.org/github.com/kirillrogovoy/pullkee) to get a high-level overview of the code. 89 | Let me know if you can't do something. **Keep the test coverage > 95%**. 90 | -------------------------------------------------------------------------------- /cache/cache.go: -------------------------------------------------------------------------------- 1 | // Package cache provides an abstraction for data caching 2 | package cache 3 | 4 | import ( 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "strings" 9 | "path/filepath" 10 | ) 11 | 12 | var cachePath = filepath.Join(os.TempDir(), "pullkee_cache", "cache.json") 13 | 14 | // Cache is an interface for a Get/Set caching struct 15 | type Cache interface { 16 | Set(string, interface{}) error 17 | Get(string, interface{}) (bool, error) 18 | } 19 | 20 | // FS is an interface for interacting with the file system 21 | type FS interface { 22 | Mkdir(path string, perms os.FileMode) error 23 | WriteFile(path string, data []byte, perms os.FileMode) error 24 | ReadFile(path string) ([]byte, error) 25 | } 26 | 27 | // FSCache is an implementation of file-system cache using the FS interface 28 | type FSCache struct { 29 | CachePath string 30 | FS FS 31 | } 32 | 33 | // Set converts `x` to JSON and writes it to a file using `key` 34 | func (c FSCache) Set(key string, x interface{}) error { 35 | err := c.FS.Mkdir(c.CachePath, 0744) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | jsoned, err := json.Marshal(x) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | return c.FS.WriteFile(c.filePath(key), jsoned, 0644) 46 | } 47 | 48 | // Get gets the contents of the file (if exists) using `key` and Unmarshals it to the struct `x` 49 | func (c FSCache) Get(key string, x interface{}) (bool, error) { 50 | data, err := c.FS.ReadFile(c.filePath(key)) 51 | if err != nil { 52 | // "no such file or directory" just should mean there's no cache entry 53 | if strings.Contains(err.Error(), "no such file or directory") { 54 | return false, nil 55 | } 56 | return false, err 57 | } 58 | 59 | return true, json.Unmarshal(data, x) 60 | } 61 | 62 | func (c FSCache) filePath(key string) string { 63 | return filepath.Join(c.CachePath, fmt.Sprintf("%s.json", key)) 64 | } 65 | -------------------------------------------------------------------------------- /cache/cache_test.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestWrite(t *testing.T) { 13 | t.Run("Works when successfully JSON-ed and wrote to the file", func(t *testing.T) { 14 | m := mockFS{cache: map[string]cacheEntity{}} 15 | c := FSCache{ 16 | FS: &m, 17 | CachePath: "/tmp/", 18 | } 19 | err := c.Set("key1", testStruct{"val1"}) 20 | 21 | require.Nil(t, err) 22 | require.Equal(t, `{"x":"val1"}`, string(m.cache["/tmp/key1.json"].data)) 23 | require.Contains(t, m.mkdirs, "/tmp/") 24 | }) 25 | 26 | t.Run("Fails when couldn't do Mkdir", func(t *testing.T) { 27 | m := mockFS{ 28 | cache: map[string]cacheEntity{}, 29 | mkdirErr: fmt.Errorf("Mkdir: fail"), 30 | } 31 | c := FSCache{ 32 | FS: &m, 33 | CachePath: "/tmp/", 34 | } 35 | err := c.Set("key1", testStruct{"val1"}) 36 | 37 | require.EqualError(t, err, "Mkdir: fail") 38 | require.Nil(t, m.cache["/tmp/key1.json"].data) 39 | require.Contains(t, m.mkdirs, "/tmp/") 40 | }) 41 | 42 | t.Run("Fails when couldn't json.Marshal() the input", func(t *testing.T) { 43 | m := mockFS{cache: map[string]cacheEntity{}} 44 | c := FSCache{ 45 | FS: &m, 46 | CachePath: "/tmp/", 47 | } 48 | err := c.Set("key1", func() {}) // functions are unmarshable 49 | 50 | log.Println(err) 51 | require.EqualError(t, err, "json: unsupported type: func()") 52 | require.Nil(t, m.cache["/tmp/key1.json"].data) 53 | require.Contains(t, m.mkdirs, "/tmp/") 54 | }) 55 | 56 | t.Run("Fails when couldn't write to the file", func(t *testing.T) { 57 | m := mockFS{ 58 | cache: map[string]cacheEntity{}, 59 | writeFileErr: fmt.Errorf("Failed to write to the file"), 60 | } 61 | c := FSCache{ 62 | FS: &m, 63 | CachePath: "/tmp/", 64 | } 65 | err := c.Set("key1", testStruct{"val1"}) 66 | 67 | require.EqualError(t, err, "Failed to write to the file") 68 | require.Nil(t, m.cache["/tmp/key1.json"].data) 69 | require.Contains(t, m.mkdirs, "/tmp/") 70 | }) 71 | } 72 | 73 | func TestRead(t *testing.T) { 74 | t.Run("Works when the file is readable and is correct JSON", func(t *testing.T) { 75 | s := testStruct{} 76 | m := mockFS{ 77 | cache: map[string]cacheEntity{ 78 | "/tmp/key1.json": {[]byte(`{"x":"val1"}`), 0777}, 79 | }, 80 | } 81 | c := FSCache{ 82 | FS: &m, 83 | CachePath: "/tmp/", 84 | } 85 | ok, err := c.Get("key1", &s) 86 | 87 | require.Nil(t, err) 88 | require.True(t, ok) 89 | require.Equal(t, testStruct{"val1"}, s) 90 | }) 91 | 92 | t.Run("Fails when couldn't read the file", func(t *testing.T) { 93 | s := testStruct{} 94 | m := mockFS{ 95 | readFileErr: fmt.Errorf("Some weird FS error"), 96 | } 97 | c := FSCache{ 98 | FS: &m, 99 | CachePath: "/tmp/", 100 | } 101 | ok, err := c.Get("key1", &s) 102 | 103 | require.False(t, ok) 104 | require.EqualError(t, err, "Some weird FS error") 105 | }) 106 | 107 | t.Run("Fails when couldn't unmarshal the contents", func(t *testing.T) { 108 | s := testStruct{} 109 | m := mockFS{ 110 | cache: map[string]cacheEntity{ 111 | "/tmp/key1.json": {[]byte(`incorrect JSON`), 0777}, 112 | }, 113 | } 114 | c := FSCache{ 115 | FS: &m, 116 | CachePath: "/tmp/", 117 | } 118 | ok, err := c.Get("key1", &s) 119 | 120 | require.True(t, ok) 121 | require.Contains(t, err.Error(), "invalid character 'i'") 122 | }) 123 | 124 | t.Run("Doesn't fail when the file didn't exist", func(t *testing.T) { 125 | s := testStruct{} 126 | m := mockFS{ 127 | readFileErr: fmt.Errorf("/tmp/key1.json: no such file or directory"), 128 | } 129 | c := FSCache{ 130 | FS: &m, 131 | CachePath: "/tmp/", 132 | } 133 | ok, err := c.Get("key1", &s) 134 | 135 | require.False(t, ok) 136 | require.Nil(t, err) 137 | }) 138 | } 139 | 140 | type cacheEntity struct { 141 | data []byte 142 | perm os.FileMode 143 | } 144 | 145 | type mockFS struct { 146 | mkdirs []string 147 | cache map[string]cacheEntity 148 | mkdirErr error 149 | writeFileErr error 150 | readFileErr error 151 | } 152 | 153 | func (m *mockFS) Mkdir(path string, perms os.FileMode) error { 154 | m.mkdirs = append(m.mkdirs, path) 155 | return m.mkdirErr 156 | } 157 | 158 | func (m *mockFS) WriteFile(key string, data []byte, perm os.FileMode) error { 159 | if m.writeFileErr == nil { 160 | m.cache[key] = cacheEntity{data, perm} 161 | } 162 | return m.writeFileErr 163 | } 164 | 165 | func (m *mockFS) ReadFile(key string) ([]byte, error) { 166 | entity, ok := m.cache[key] 167 | if !ok { 168 | return nil, m.readFileErr 169 | } 170 | return entity.data, m.readFileErr 171 | } 172 | 173 | type testStruct struct { 174 | X string `json:"x"` 175 | } 176 | -------------------------------------------------------------------------------- /cmd/cli.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/kirillrogovoy/pullkee/github/client" 11 | ) 12 | 13 | const usage = `Usage: 14 | pullkee [flags] [repo] 15 | repo - Github repository path as "username/reponame" 16 | 17 | Flags: 18 | --limit - Only use N last pull requests 19 | 20 | Environment variables: 21 | GITHUB_CREDS - API credentials in the format "username:personal_access_token"` 22 | 23 | type flags struct { 24 | limit int 25 | reset bool 26 | } 27 | 28 | func getFlags() flags { 29 | flags := flags{} 30 | 31 | flag.IntVar(&flags.limit, "limit", 0, "") 32 | flag.BoolVar(&flags.reset, "reset", false, "") 33 | 34 | flag.Usage = func() { 35 | fmt.Println(usage) 36 | } 37 | flag.Parse() 38 | 39 | return flags 40 | } 41 | 42 | func getGithubCreds() *client.Credentials { 43 | creds := os.Getenv("GITHUB_CREDS") 44 | 45 | if creds == "" { 46 | return nil 47 | } 48 | 49 | if matches, _ := regexp.MatchString(`^[\w-]+:[\w-]+$`, creds); !matches { 50 | fmt.Printf("Invalid format of the GITHUB_CREDS environment variable!\n\n%s\n", usage) 51 | os.Exit(1) 52 | } 53 | 54 | split := strings.Split(creds, ":") 55 | return &client.Credentials{ 56 | Username: split[0], 57 | PersonalAccessToken: split[1], 58 | } 59 | } 60 | 61 | func getRepo() string { 62 | repo := flag.Arg(0) 63 | 64 | if repo == "" { 65 | fmt.Println(usage) 66 | os.Exit(1) 67 | } 68 | 69 | if matches, _ := regexp.MatchString(`^[\w-\.]+/[\w-\.]+$`, repo); !matches { 70 | fmt.Printf("Invalid format of the repo!\n\n%s\n", usage) 71 | os.Exit(1) 72 | } 73 | 74 | if len(flag.Args()) > 1 { 75 | fmt.Println("Excess arguments after the repo") 76 | fmt.Println(usage) 77 | os.Exit(1) 78 | } 79 | 80 | return repo 81 | } 82 | -------------------------------------------------------------------------------- /cmd/fs.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | ) 7 | 8 | // RealFS is an implementation of cache.FS 9 | type RealFS struct{} 10 | 11 | // Mkdir implementation 12 | func (f RealFS) Mkdir(path string, perms os.FileMode) error { 13 | return os.MkdirAll(path, perms) 14 | } 15 | 16 | // WriteFile implementation 17 | func (f RealFS) WriteFile(path string, data []byte, perms os.FileMode) error { 18 | return ioutil.WriteFile(path, data, perms) 19 | } 20 | 21 | // ReadFile implementation 22 | func (f RealFS) ReadFile(path string) ([]byte, error) { 23 | return ioutil.ReadFile(path) 24 | } 25 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | // Package cmd is the entry point of the app 2 | package cmd 3 | 4 | import ( 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | "reflect" 11 | "strconv" 12 | "time" 13 | 14 | "github.com/kirillrogovoy/pullkee/cache" 15 | "github.com/kirillrogovoy/pullkee/github" 16 | "github.com/kirillrogovoy/pullkee/github/client" 17 | "github.com/kirillrogovoy/pullkee/github/util" 18 | "github.com/kirillrogovoy/pullkee/metric" 19 | "github.com/kirillrogovoy/pullkee/progress" 20 | "github.com/pkg/errors" 21 | ) 22 | 23 | // Main is the entry function called by the "main" package 24 | func Main() { 25 | flags := getFlags() 26 | 27 | client := getHTTPClient(getGithubCreds()) 28 | repo := getRepo() 29 | api := getAPI(&client, repo) 30 | 31 | // check that we can at least successfully fetch repository's meta information 32 | if _, err := api.Repository(); err != nil { 33 | reportErrorAndExit(err) 34 | } 35 | 36 | printRateDetails(client) 37 | 38 | cache := getCache(repo) 39 | pulls := getPulls(flags, api, cache) 40 | 41 | runMetrics(pulls) 42 | } 43 | 44 | func getHTTPClient(creds *client.Credentials) client.Client { 45 | rateLimiter := time.Tick(time.Millisecond * 100) 46 | return client.New(http.DefaultClient, client.Options{ 47 | Credentials: creds, 48 | RateLimiter: &rateLimiter, 49 | MaxRetries: 3, 50 | }) 51 | } 52 | 53 | func getAPI(client client.HTTPClient, repo string) github.APIv3 { 54 | return github.APIv3{ 55 | RepoName: repo, 56 | HTTPClient: client, 57 | } 58 | } 59 | 60 | func getCache(repo string) cache.Cache { 61 | return cache.FSCache{ 62 | CachePath: filepath.Join(os.TempDir(), "pullkee_cache", repo), 63 | FS: RealFS{}, 64 | } 65 | } 66 | 67 | func getPulls(f flags, a github.API, c cache.Cache) []github.PullRequest { 68 | fmt.Println("Getting Pull Request list...") 69 | 70 | pulls, err := util.Pulls(a, f.limit) 71 | if err != nil { 72 | reportErrorAndExit(err) 73 | } 74 | 75 | fmt.Println("Attaching details...") 76 | 77 | bar := progress.Bar{ 78 | Len: 50, 79 | OnChange: func(v string) { 80 | fmt.Printf("\r%s", v) 81 | }, 82 | } 83 | bar.Set(0) 84 | 85 | ch := util.FillDetails( 86 | a, 87 | c, 88 | pulls, 89 | ) 90 | 91 | for i := range pulls { 92 | if err := <-ch; err != nil { 93 | reportErrorAndExit(errors.Wrap(err, "filling details for a pull request")) 94 | } 95 | bar.Set(float64(i+1) / float64(len(pulls))) 96 | } 97 | fmt.Print("\n\n") 98 | 99 | return pulls 100 | } 101 | 102 | func reportErrorAndExit(err error) { 103 | fmt.Printf("An unexpected error occurred:\n\n%s\n", err) 104 | os.Exit(1) 105 | } 106 | 107 | func printRateDetails(c client.Client) { 108 | l := c.LastResponse.Header 109 | resetAt, _ := strconv.Atoi(l.Get("X-RateLimit-Reset")) 110 | 111 | fmt.Printf( 112 | "Github rate limit details:\nLimit: %s\nRemaining: %s\nReset: %s\n\n", 113 | l.Get("X-RateLimit-Limit"), 114 | l.Get("X-RateLimit-Remaining"), 115 | time.Unix(int64(resetAt), 0).String(), 116 | ) 117 | } 118 | 119 | func runMetrics(pullRequests []github.PullRequest) { 120 | for _, m := range metric.Metrics() { 121 | name := reflect.TypeOf(m).Elem().Name() 122 | description := m.Description() 123 | err := m.Calculate(pullRequests) 124 | fmt.Printf("Metric '%s' (%s)\n", name, description) 125 | if err != nil { 126 | log.Printf("Error: %s\n", err) 127 | } else { 128 | fmt.Printf("%s\n", m.String()) 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /coverage.sh: -------------------------------------------------------------------------------- 1 | #bash 2 | set -e 3 | echo "" > coverage.txt 4 | 5 | for d in $(go list ./... | grep -v vendor); do 6 | go test -coverprofile=profile.out -covermode=atomic $d 7 | if [ -f profile.out ]; then 8 | cat profile.out >> coverage.txt 9 | rm profile.out 10 | fi 11 | done 12 | -------------------------------------------------------------------------------- /github/client/abusePreventing.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "time" 7 | ) 8 | 9 | // abusePreventing is a HTTPClient which retries a query in case of Retry-Later response header 10 | // https://developer.github.com/v3/guides/best-practices-for-integrators/#dealing-with-abuse-rate-limits 11 | type abusePreventing struct { 12 | HTTPClient // "back-end" HTTPClient to use for actual HTTP queries 13 | } 14 | 15 | // Do is HTTPClient.Do 16 | func (c abusePreventing) Do(req *http.Request) (*http.Response, error) { 17 | res, err := c.HTTPClient.Do(req) 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | is403 := res.StatusCode == 403 23 | retryAfter, parseError := strconv.ParseFloat(res.Header.Get("Retry-After"), 64) 24 | 25 | if is403 && parseError == nil { 26 | c.waitFor(retryAfter) 27 | res, err = c.Do(req) 28 | } 29 | return res, err 30 | } 31 | 32 | func (c abusePreventing) waitFor(seconds float64) { 33 | duration := time.Duration(seconds * float64(time.Second)) 34 | timer := time.NewTimer(duration) 35 | <-timer.C 36 | } 37 | -------------------------------------------------------------------------------- /github/client/abusePreventing_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestAbusePreventing(t *testing.T) { 13 | t.Run("Works when there is a successful response after waiting for the timeout", func(t *testing.T) { 14 | timesCalled := 0 15 | 16 | response := func() (*http.Response, error) { 17 | defer func() { timesCalled++ }() 18 | switch timesCalled { 19 | case 0: 20 | return &http.Response{ 21 | StatusCode: 403, 22 | Header: http.Header{ 23 | "Retry-After": []string{"0.1"}, // means 100ms 24 | "Success": []string{"no"}, 25 | }, 26 | }, nil 27 | case 1: 28 | return successfulResponse() 29 | default: 30 | panic("Should not be called") 31 | } 32 | } 33 | 34 | client := abusePreventing{ 35 | HTTPClient: httpClientMock{response}, 36 | } 37 | 38 | t1 := time.Now() 39 | res, err := client.Do(dummyRequest()) 40 | t2 := time.Now() 41 | secondsPassed := t2.Sub(t1).Seconds() 42 | 43 | require.Nil(t, err) 44 | require.Equal(t, "yes", res.Header.Get("Success")) 45 | require.Equal(t, 2, timesCalled) 46 | require.True( 47 | t, 48 | secondsPassed >= 0.1 && secondsPassed < 0.2, 49 | fmt.Sprintf("100ms should pass because of Retry-Later, passed %f", secondsPassed), 50 | ) 51 | }) 52 | 53 | t.Run("Works when Retry-After repeats more than once", func(t *testing.T) { 54 | timesCalled := 0 55 | 56 | response := func() (*http.Response, error) { 57 | defer func() { timesCalled++ }() 58 | switch timesCalled { 59 | case 0, 1: 60 | return &http.Response{ 61 | StatusCode: 403, 62 | Header: http.Header{ 63 | "Retry-After": []string{"0.1"}, // means 100ms 64 | "Success": []string{"no"}, 65 | }, 66 | }, nil 67 | case 2: 68 | return successfulResponse() 69 | default: 70 | panic("Should not be called") 71 | } 72 | } 73 | 74 | client := abusePreventing{ 75 | HTTPClient: httpClientMock{response}, 76 | } 77 | 78 | t1 := time.Now() 79 | res, err := client.Do(dummyRequest()) 80 | t2 := time.Now() 81 | secondsPassed := t2.Sub(t1).Seconds() 82 | 83 | require.Nil(t, err) 84 | require.Equal(t, "yes", res.Header.Get("Success")) 85 | require.Equal(t, 3, timesCalled) 86 | require.True( 87 | t, 88 | secondsPassed >= 0.2 && secondsPassed < 0.21, 89 | fmt.Sprintf("200ms should pass because of Retry-Later, passed %f", secondsPassed), 90 | ) 91 | }) 92 | 93 | t.Run("Works when the response is successful", func(t *testing.T) { 94 | client := abusePreventing{ 95 | HTTPClient: httpClientMock{successfulResponse}, 96 | } 97 | 98 | res, err := client.Do(dummyRequest()) 99 | 100 | require.Nil(t, err) 101 | require.Equal(t, "yes", res.Header.Get("Success")) 102 | }) 103 | 104 | t.Run("Fails right away if there was an error", func(t *testing.T) { 105 | client := abusePreventing{ 106 | HTTPClient: httpClientMock{func() (*http.Response, error) { 107 | return nil, fmt.Errorf("Some weird network error") 108 | }}, 109 | } 110 | 111 | res, err := client.Do(dummyRequest()) 112 | 113 | require.Nil(t, res) 114 | require.Contains(t, err.Error(), "Some weird network error") 115 | }) 116 | 117 | t.Run("Works when there is an error after waiting for the timeout", func(t *testing.T) { 118 | timesCalled := 0 119 | 120 | response := func() (*http.Response, error) { 121 | defer func() { timesCalled++ }() 122 | switch timesCalled { 123 | case 0: 124 | return &http.Response{ 125 | StatusCode: 403, 126 | Header: http.Header{ 127 | "Retry-After": []string{"0.1"}, // means 100ms 128 | "Success": []string{"no"}, 129 | }, 130 | }, nil 131 | case 1: 132 | return nil, fmt.Errorf("The weirdest network error ever") 133 | default: 134 | panic("Should not be called") 135 | } 136 | } 137 | 138 | client := abusePreventing{ 139 | HTTPClient: httpClientMock{response}, 140 | } 141 | 142 | res, err := client.Do(dummyRequest()) 143 | 144 | require.Nil(t, res) 145 | require.Contains(t, err.Error(), "The weirdest network error ever") 146 | }) 147 | 148 | t.Run("Ignores Retry-Later if it can't be parsed as float", func(t *testing.T) { 149 | timesCalled := 0 150 | 151 | response := func() (*http.Response, error) { 152 | defer func() { timesCalled++ }() 153 | switch timesCalled { 154 | case 0: 155 | return &http.Response{ 156 | StatusCode: 403, 157 | Header: http.Header{ 158 | "Retry-After": []string{"pitty"}, // broken float 159 | "Success": []string{"no"}, 160 | }, 161 | }, nil 162 | case 1: 163 | return successfulResponse() // actually, shouldn't be called in this test 164 | default: 165 | panic("Should not be called") 166 | } 167 | } 168 | 169 | client := abusePreventing{ 170 | HTTPClient: httpClientMock{response}, 171 | } 172 | 173 | res, err := client.Do(dummyRequest()) 174 | 175 | require.Nil(t, err) 176 | require.Equal(t, "no", res.Header.Get("Success")) 177 | require.Equal(t, 1, timesCalled) 178 | }) 179 | } 180 | -------------------------------------------------------------------------------- /github/client/authenticating.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import "net/http" 4 | 5 | // Credentials struct contains user's name and access token to access the Github API 6 | type Credentials struct { 7 | Username string 8 | PersonalAccessToken string 9 | } 10 | 11 | // authenticating is a HTTPClient which sets authorization headers for Github API 12 | type authenticating struct { 13 | HTTPClient // "back-end" HTTPClient to use for actual HTTP queries 14 | *Credentials 15 | } 16 | 17 | // Do is HTTPClient.Do 18 | func (c authenticating) Do(req *http.Request) (*http.Response, error) { 19 | if creds := c.Credentials; creds != nil { 20 | req.SetBasicAuth(creds.Username, creds.PersonalAccessToken) 21 | req.Header.Add("User-Agent", creds.Username) 22 | } 23 | return c.HTTPClient.Do(req) 24 | 25 | } 26 | -------------------------------------------------------------------------------- /github/client/authenticating_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestAuth(t *testing.T) { 11 | t.Run("Auth headers are set, when Credentials are present", func(t *testing.T) { 12 | request := dummyRequest() 13 | 14 | client := authenticating{ 15 | HTTPClient: httpClientMock{successfulResponse}, 16 | Credentials: &Credentials{ 17 | Username: "User1", 18 | PersonalAccessToken: "Token1", 19 | }, 20 | } 21 | 22 | res, err := client.Do(request) 23 | 24 | require.Nil(t, err) 25 | require.Equal(t, "yes", res.Header.Get("Success")) 26 | require.Equal(t, http.Header{ 27 | "Authorization": []string{"Basic VXNlcjE6VG9rZW4x"}, 28 | "User-Agent": []string{"User1"}, 29 | }, request.Header) 30 | }) 31 | 32 | t.Run("Still works when Credentials aren't present", func(t *testing.T) { 33 | request := dummyRequest() 34 | 35 | client := authenticating{ 36 | HTTPClient: httpClientMock{successfulResponse}, 37 | } 38 | 39 | res, err := client.Do(dummyRequest()) 40 | 41 | require.Nil(t, err) 42 | require.Equal(t, "yes", res.Header.Get("Success")) 43 | require.Equal(t, http.Header{}, request.Header) 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /github/client/client.go: -------------------------------------------------------------------------------- 1 | // Package client contains an implementation of a HTTP client aware of 2 | // all the Github rules like rate limiting or authenticating 3 | package client 4 | 5 | import ( 6 | "fmt" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | // HTTPClient is in interface for a HTTP client which "transforms" 12 | // a http.Request into http.Response (like http.Client) 13 | type HTTPClient interface { 14 | Do(req *http.Request) (*http.Response, error) 15 | } 16 | 17 | // Log is a which is used to report for logging 18 | type Log func(message string) 19 | 20 | // Client is a top-level HTTPClient that remembers the last 21 | // response and can log information out during the process 22 | type Client struct { 23 | HTTPClient 24 | LastResponse *http.Response 25 | Log 26 | } 27 | 28 | // Options is a set of configurable options to create a whole chain of clients 29 | type Options struct { 30 | *Credentials 31 | RateLimiter *<-chan time.Time 32 | MaxRetries int 33 | Log 34 | } 35 | 36 | // Do is HTTPClient.Do 37 | func (c *Client) Do(req *http.Request) (*http.Response, error) { 38 | res, err := c.HTTPClient.Do(req) 39 | 40 | c.LastResponse = res 41 | 42 | if c.Log != nil { 43 | c.Log(fmt.Sprintf( 44 | "DONE - %s: %s", 45 | req.Method, 46 | req.URL.String(), 47 | )) 48 | } 49 | 50 | return res, err 51 | } 52 | 53 | // New creates a new instance of Client chaining all the clients together given Options 54 | func New(httpClient HTTPClient, opts Options) Client { 55 | retrying := retrying{ 56 | HTTPClient: httpClient, 57 | MaxRetries: opts.MaxRetries, 58 | } 59 | rateLimiting := rateLimiting{ 60 | HTTPClient: retrying, 61 | RateLimiter: opts.RateLimiter, 62 | } 63 | abusePreventing := abusePreventing{ 64 | HTTPClient: rateLimiting, 65 | } 66 | auth := authenticating{ 67 | HTTPClient: abusePreventing, 68 | Credentials: opts.Credentials, 69 | } 70 | errorWrapping := errorWrapping{ 71 | HTTPClient: auth, 72 | } 73 | client := Client{ 74 | HTTPClient: errorWrapping, 75 | Log: opts.Log, 76 | } 77 | 78 | return client 79 | } 80 | -------------------------------------------------------------------------------- /github/client/client_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestClient(t *testing.T) { 11 | t.Run("Works fine, saves the last response, writes logs", func(t *testing.T) { 12 | logMock := Log(func(message string) {}) 13 | 14 | client := Client{ 15 | HTTPClient: httpClientMock{successfulResponse}, 16 | Log: logMock, 17 | } 18 | 19 | res, err := client.Do(dummyRequest()) 20 | 21 | require.Nil(t, err) 22 | require.Equal(t, "yes", res.Header.Get("Success")) 23 | require.Exactly(t, res, client.LastResponse) 24 | }) 25 | } 26 | 27 | func TestNew(t *testing.T) { 28 | opts := Options{ 29 | Credentials: nil, 30 | RateLimiter: nil, 31 | MaxRetries: 1, 32 | Log: nil, 33 | } 34 | 35 | client := New(httpClientMock{successfulResponse}, opts) 36 | res, err := client.Do(dummyRequest()) 37 | 38 | require.Nil(t, err) 39 | require.Equal(t, "yes", res.Header.Get("Success")) 40 | } 41 | 42 | type httpClientMock struct { 43 | response func() (*http.Response, error) 44 | } 45 | 46 | func (h httpClientMock) Do(request *http.Request) (*http.Response, error) { 47 | return h.response() 48 | } 49 | 50 | func dummyRequest() *http.Request { 51 | req, _ := http.NewRequest("GET", "http://example.com/url1", nil) 52 | return req 53 | } 54 | 55 | func successfulResponse() (*http.Response, error) { 56 | return &http.Response{ 57 | StatusCode: 200, 58 | Header: http.Header{ 59 | "Success": []string{"yes"}, 60 | }, 61 | }, nil 62 | } 63 | -------------------------------------------------------------------------------- /github/client/errorWrapping.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httputil" 7 | ) 8 | 9 | // errorWrapping is a HTTPClient which translates HTTP Response codes >= 300 into errors 10 | type errorWrapping struct { 11 | HTTPClient // "back-end" HTTPClient to use for actual HTTP queries 12 | } 13 | 14 | // Do is HTTPClient.Do 15 | func (c errorWrapping) Do(req *http.Request) (*http.Response, error) { 16 | res, err := c.HTTPClient.Do(req) 17 | 18 | if res.StatusCode >= 300 { 19 | err = fmt.Errorf( 20 | "Wrong HTTP response code: %d, Details:\n%s", 21 | res.StatusCode, 22 | composeHTTPError(req, res), 23 | ) 24 | res = nil 25 | } 26 | 27 | return res, err 28 | } 29 | 30 | func composeHTTPError(req *http.Request, res *http.Response) error { 31 | dump, err := httputil.DumpResponse(res, true) 32 | 33 | if err != nil { 34 | return fmt.Errorf("", err.Error()) 35 | } 36 | 37 | return fmt.Errorf( 38 | "HTTP Request failed.\nURL: %s\n\n%s", 39 | req.URL.String(), 40 | dump, 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /github/client/errorWrapping_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestErrorWrapping(t *testing.T) { 11 | t.Run("Successfully turns a 404 into an error", func(t *testing.T) { 12 | request := dummyRequest() 13 | 14 | client := errorWrapping{ 15 | HTTPClient: httpClientMock{func() (*http.Response, error) { 16 | return &http.Response{ 17 | StatusCode: 404, 18 | }, nil 19 | }}, 20 | } 21 | 22 | res, err := client.Do(request) 23 | 24 | require.Nil(t, res) 25 | require.Contains(t, err.Error(), "Wrong HTTP response code: 404") 26 | }) 27 | 28 | t.Run("Still works when the response code is OK", func(t *testing.T) { 29 | request := dummyRequest() 30 | 31 | client := errorWrapping{ 32 | HTTPClient: httpClientMock{successfulResponse}, 33 | } 34 | 35 | res, err := client.Do(dummyRequest()) 36 | 37 | require.Nil(t, err) 38 | require.Equal(t, "yes", res.Header.Get("Success")) 39 | require.Equal(t, http.Header{}, request.Header) 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /github/client/rateLimiting.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | ) 7 | 8 | // rateLimiting is a HTTPClient which limits the rate of HTTP queries 9 | // towards the server to avoid abuse mechanism triggering 10 | type rateLimiting struct { 11 | HTTPClient // "back-end" HTTPClient to use for actual HTTP queries 12 | RateLimiter *<-chan time.Time // Example: time.Tick(time.Millisecond * 75) 13 | } 14 | 15 | // Do is HTTPClient.Do 16 | func (c rateLimiting) Do(req *http.Request) (*http.Response, error) { 17 | if c.RateLimiter != nil { 18 | <-*c.RateLimiter 19 | } 20 | return c.HTTPClient.Do(req) 21 | 22 | } 23 | -------------------------------------------------------------------------------- /github/client/rateLimiting_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestRateLimiting(t *testing.T) { 12 | t.Run("Rate limit works", func(t *testing.T) { 13 | limiter := time.Tick(time.Millisecond * 50) 14 | 15 | client := rateLimiting{ 16 | HTTPClient: httpClientMock{successfulResponse}, 17 | RateLimiter: &limiter, 18 | } 19 | 20 | t1 := time.Now() 21 | 22 | res, err := client.Do(dummyRequest()) 23 | require.Equal(t, "yes", res.Header.Get("Success")) 24 | require.Nil(t, err) 25 | 26 | res, err = client.Do(dummyRequest()) 27 | require.Equal(t, "yes", res.Header.Get("Success")) 28 | require.Nil(t, err) 29 | 30 | t2 := time.Now() 31 | secondsPassed := t2.Sub(t1).Seconds() 32 | 33 | require.True( 34 | t, 35 | secondsPassed >= 0.1 && secondsPassed < 0.15, 36 | fmt.Sprintf("100ms should pass because of RateLimit, passed %f", secondsPassed), 37 | ) 38 | }) 39 | 40 | t.Run("Still works if there is no RateLimiter", func(t *testing.T) { 41 | client := rateLimiting{ 42 | HTTPClient: httpClientMock{successfulResponse}, 43 | } 44 | 45 | res, err := client.Do(dummyRequest()) 46 | require.Equal(t, "yes", res.Header.Get("Success")) 47 | require.Nil(t, err) 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /github/client/retrying.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import "net/http" 4 | 5 | // retrying is a HTTPClient which re-tries the request for a number of times in case of network errors 6 | type retrying struct { 7 | HTTPClient // "back-end" HTTPClient to use for actual HTTP queries 8 | MaxRetries int 9 | } 10 | 11 | // Do is HTTPClient.Do 12 | func (c retrying) Do(req *http.Request) (*http.Response, error) { 13 | res, err := c.HTTPClient.Do(req) 14 | 15 | retriesLeft := c.MaxRetries 16 | for err != nil && retriesLeft > 0 { 17 | retriesLeft-- 18 | res, err = c.HTTPClient.Do(req) 19 | } 20 | 21 | return res, err 22 | } 23 | -------------------------------------------------------------------------------- /github/client/retrying_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestRetrying(t *testing.T) { 12 | t.Run("It retries again if there is a network error", func(t *testing.T) { 13 | timesCalled := 0 14 | 15 | response := func() (*http.Response, error) { 16 | defer func() { timesCalled++ }() 17 | switch timesCalled { 18 | case 0: 19 | return nil, fmt.Errorf("Some weird network error") 20 | case 1: 21 | return nil, fmt.Errorf("Another weird network error") 22 | case 2: 23 | return successfulResponse() 24 | default: 25 | panic("Should not be called") 26 | } 27 | } 28 | 29 | client := retrying{ 30 | HTTPClient: httpClientMock{response}, 31 | MaxRetries: 2, 32 | } 33 | 34 | res, err := client.Do(dummyRequest()) 35 | 36 | require.Nil(t, err) 37 | require.Equal(t, "yes", res.Header.Get("Success")) 38 | require.Equal(t, 3, timesCalled) 39 | }) 40 | 41 | t.Run("It gives up retrying after the specified number of tries", func(t *testing.T) { 42 | timesCalled := 0 43 | 44 | response := func() (*http.Response, error) { 45 | defer func() { timesCalled++ }() 46 | return nil, fmt.Errorf("Some weird network error which doesn't go away, try #%d", timesCalled+1) 47 | } 48 | 49 | client := retrying{ 50 | HTTPClient: httpClientMock{response}, 51 | MaxRetries: 2, 52 | } 53 | 54 | res, err := client.Do(dummyRequest()) 55 | 56 | require.Nil(t, res) 57 | require.NotNil(t, err) 58 | require.Contains(t, err.Error(), "Some weird network error which doesn't go away, try #3") 59 | require.Equal(t, 3, timesCalled, fmt.Sprintf("Should try for 3 times before giving up. Tried %d times", timesCalled)) 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /github/comment.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "net/http" 7 | 8 | "github.com/kirillrogovoy/pullkee/github/page" 9 | ) 10 | 11 | // Comment is a representation of a Github issue comment 12 | type Comment struct { 13 | User User `json:"user"` 14 | Body string `json:"body"` 15 | } 16 | 17 | // Comments fetches all the comments of a Pull Request given its `number` 18 | func (a APIv3) Comments(number int) ([]Comment, error) { 19 | allComments := []Comment{} 20 | 21 | types := []string{"pulls", "issues"} 22 | for _, commentType := range types { 23 | comments := []Comment{} 24 | url := fmt.Sprintf( 25 | "https://api.github.com/repos/%s/%s/%d/comments?per_page=100", 26 | a.RepoName, 27 | commentType, 28 | number, 29 | ) 30 | req, _ := http.NewRequest("GET", url, nil) 31 | 32 | pageLimit := int(math.Inf(1)) 33 | if err := page.All(a.HTTPClient, *req, &comments, pageLimit); err != nil { 34 | return nil, err 35 | } 36 | allComments = append(allComments, comments...) 37 | } 38 | 39 | return allComments, nil 40 | } 41 | -------------------------------------------------------------------------------- /github/comment_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestComments(t *testing.T) { 14 | t.Run("Works on good response", func(t *testing.T) { 15 | goodLink := `; rel="next"` 16 | 17 | timesCalled := 0 18 | response := func() (*http.Response, error) { 19 | defer func() { timesCalled++ }() 20 | switch timesCalled { 21 | case 0: 22 | json := `[{"user": {"login": "User1"}, "body": "Body1"}]` 23 | return &http.Response{ 24 | StatusCode: 200, 25 | Body: ioutil.NopCloser(strings.NewReader(json)), 26 | Header: http.Header{ 27 | "Link": []string{goodLink}, 28 | }, 29 | }, nil 30 | case 1: 31 | json := `[{"user": {"login": "User2"}, "body": "Body2"}]` 32 | return &http.Response{ 33 | StatusCode: 200, 34 | Body: ioutil.NopCloser(strings.NewReader(json)), 35 | }, nil 36 | case 2: 37 | json := `[{"user": {"login": "User3"}, "body": "Body3"}]` 38 | return &http.Response{ 39 | StatusCode: 200, 40 | Body: ioutil.NopCloser(strings.NewReader(json)), 41 | Header: http.Header{ 42 | "Link": []string{goodLink}, 43 | }, 44 | }, nil 45 | case 3: 46 | json := `[{"user": {"login": "User4"}, "body": "Body4"}]` 47 | return &http.Response{ 48 | StatusCode: 200, 49 | Body: ioutil.NopCloser(strings.NewReader(json)), 50 | }, nil 51 | default: 52 | panic("Should not be called") 53 | } 54 | } 55 | a := APIv3{ 56 | HTTPClient: httpClientMock{response}, 57 | RepoName: "someuser/somerepo", 58 | } 59 | 60 | expected := []Comment{ 61 | {User{"User1"}, "Body1"}, 62 | {User{"User2"}, "Body2"}, 63 | {User{"User3"}, "Body3"}, 64 | {User{"User4"}, "Body4"}, 65 | } 66 | 67 | comments, err := a.Comments(1) 68 | require.Nil(t, err) 69 | require.Equal(t, expected, comments) 70 | }) 71 | 72 | t.Run("Fails when there is an error fetching the response", func(t *testing.T) { 73 | a := APIv3{ 74 | HTTPClient: httpClientMock{func() (*http.Response, error) { 75 | return nil, fmt.Errorf("Dogs have chewed the wires") 76 | }}, 77 | RepoName: "someuser/somerepo", 78 | } 79 | 80 | comments, err := a.Comments(1) 81 | require.EqualError(t, err, "Dogs have chewed the wires") 82 | require.Nil(t, comments) 83 | }) 84 | } 85 | -------------------------------------------------------------------------------- /github/diffsize.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | ) 8 | 9 | // DiffSize fetches the size of the diff of the particular Pull Request given `number` 10 | func (a APIv3) DiffSize(number int) (int, error) { 11 | req, _ := http.NewRequest("HEAD", fmt.Sprintf("https://api.github.com/repos/%s/pulls/%d", a.RepoName, number), nil) 12 | req.Header.Add("Accept", "application/vnd.github.diff") 13 | res, err := a.HTTPClient.Do(req) 14 | if err != nil { 15 | return 0, err 16 | } 17 | 18 | length := res.Header.Get("Content-Length") 19 | if length == "" { 20 | return 0, fmt.Errorf("Expected Content-Length in response") 21 | } 22 | 23 | lengthInt, _ := strconv.Atoi(length) 24 | return lengthInt, nil 25 | } 26 | -------------------------------------------------------------------------------- /github/diffsize_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestDiffsize(t *testing.T) { 12 | t.Run("Works on good response", func(t *testing.T) { 13 | a := APIv3{ 14 | HTTPClient: httpClientMock{func() (*http.Response, error) { 15 | return &http.Response{ 16 | StatusCode: 200, 17 | Header: http.Header{ 18 | "Content-Length": []string{"42"}, 19 | }, 20 | }, nil 21 | }}, 22 | RepoName: "someuser/somerepo", 23 | } 24 | 25 | size, err := a.DiffSize(1) 26 | require.Nil(t, err) 27 | require.Equal(t, 42, size) 28 | }) 29 | 30 | t.Run("Fails when Content-Length header is missing", func(t *testing.T) { 31 | a := APIv3{ 32 | HTTPClient: httpClientMock{func() (*http.Response, error) { 33 | return &http.Response{ 34 | StatusCode: 200, 35 | Header: http.Header{}, 36 | }, nil 37 | }}, 38 | RepoName: "someuser/somerepo", 39 | } 40 | 41 | _, err := a.DiffSize(1) 42 | require.EqualError(t, err, "Expected Content-Length in response") 43 | }) 44 | 45 | t.Run("Fails when there is an error fetching the response", func(t *testing.T) { 46 | a := APIv3{ 47 | HTTPClient: httpClientMock{func() (*http.Response, error) { 48 | return nil, fmt.Errorf("Some weird network error") 49 | }}, 50 | RepoName: "someuser/somerepo", 51 | } 52 | 53 | _, err := a.DiffSize(1) 54 | require.EqualError(t, err, "Some weird network error") 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /github/github.go: -------------------------------------------------------------------------------- 1 | // Package github provides low-level tools to retrieve information from the Github API 2 | package github 3 | 4 | import ( 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | 10 | "github.com/kirillrogovoy/pullkee/github/client" 11 | ) 12 | 13 | // API is an interface for a collection of methods to retrieve information from Github API 14 | type API interface { 15 | Get(url string, target interface{}) error 16 | Repository() (*Repository, error) 17 | ClosedPullRequests(limit int) ([]PullRequest, error) 18 | DiffSize(number int) (int, error) 19 | Comments(number int) ([]Comment, error) 20 | ReviewRequests(number int) ([]User, error) 21 | } 22 | 23 | // APIv3 is an implementation of API which works with Github REST API (v3) 24 | type APIv3 struct { 25 | HTTPClient client.HTTPClient 26 | RepoName string 27 | } 28 | 29 | // User is a representation of a Github user (e.g. an author of a Pull Request) 30 | type User struct { 31 | Login string `json:"login"` 32 | } 33 | 34 | // Get makes an HTTP request, checks the response, reads the body and unmarshals it to the `target` 35 | func (a APIv3) Get(url string, target interface{}) error { 36 | // According to the tests of http.Request an error might only occur on an invalid method which is not the case 37 | req, _ := http.NewRequest("GET", url, nil) 38 | 39 | res, err := a.HTTPClient.Do(req) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | if res.Body == nil { 45 | return fmt.Errorf("Expected res.Body not to be nil. URL: %s", req.URL) 46 | } 47 | 48 | body, err := ioutil.ReadAll(res.Body) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | return json.Unmarshal(body, target) 54 | } 55 | -------------------------------------------------------------------------------- /github/github_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestGet(t *testing.T) { 14 | t.Run("Works on correct response", func(t *testing.T) { 15 | a := APIv3{ 16 | HTTPClient: httpClientMock{func() (*http.Response, error) { 17 | return &http.Response{ 18 | StatusCode: 200, 19 | Body: ioutil.NopCloser(strings.NewReader(`{"full_name": "someuser/somerepo"}`)), 20 | }, nil 21 | }}, 22 | RepoName: "someuser/somerepo", 23 | } 24 | 25 | repo := &Repository{} 26 | err := a.Get("/some-url", repo) 27 | require.Nil(t, err) 28 | require.Equal(t, "someuser/somerepo", repo.FullName) 29 | }) 30 | 31 | t.Run("Fails when the request failed", func(t *testing.T) { 32 | a := APIv3{ 33 | HTTPClient: httpClientMock{func() (*http.Response, error) { 34 | return nil, fmt.Errorf("Some weird network error") 35 | }}, 36 | RepoName: "someuser/somerepo", 37 | } 38 | 39 | err := a.Get("/some-url", nil) 40 | require.Equal(t, "Some weird network error", err.Error()) 41 | }) 42 | 43 | t.Run("Fails when the body is nil", func(t *testing.T) { 44 | a := APIv3{ 45 | HTTPClient: httpClientMock{func() (*http.Response, error) { 46 | return &http.Response{ 47 | StatusCode: 200, 48 | Body: nil, 49 | }, nil 50 | }}, 51 | RepoName: "someuser/somerepo", 52 | } 53 | 54 | err := a.Get("/some-url", nil) 55 | require.NotNil(t, err) 56 | }) 57 | 58 | t.Run("Fails when couldn't read the body", func(t *testing.T) { 59 | a := APIv3{ 60 | HTTPClient: httpClientMock{func() (*http.Response, error) { 61 | return &http.Response{ 62 | StatusCode: 200, 63 | Body: ioutil.NopCloser(errorReader{}), 64 | }, nil 65 | }}, 66 | RepoName: "someuser/somerepo", 67 | } 68 | 69 | err := a.Get("/some-url", nil) 70 | require.Equal(t, "Some weird reader error", err.Error()) 71 | }) 72 | 73 | t.Run("Fails when the body is not correct JSON", func(t *testing.T) { 74 | a := APIv3{ 75 | HTTPClient: httpClientMock{func() (*http.Response, error) { 76 | return &http.Response{ 77 | StatusCode: 200, 78 | Body: ioutil.NopCloser(strings.NewReader(`I'm not JSON`)), 79 | }, nil 80 | }}, 81 | RepoName: "someuser/somerepo", 82 | } 83 | 84 | err := a.Get("/some-url", nil) 85 | require.Contains(t, err.Error(), "invalid character 'I'") 86 | }) 87 | } 88 | 89 | type httpClientMock struct { 90 | response func() (*http.Response, error) 91 | } 92 | 93 | func (h httpClientMock) Do(request *http.Request) (*http.Response, error) { 94 | return h.response() 95 | } 96 | 97 | type errorReader struct{} 98 | 99 | func (e errorReader) Read(p []byte) (int, error) { 100 | return 0, fmt.Errorf("Some weird reader error") 101 | } 102 | -------------------------------------------------------------------------------- /github/page/page.go: -------------------------------------------------------------------------------- 1 | // Package page provides an utility to fetch paginated resources 2 | package page 3 | 4 | import ( 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "reflect" 10 | "regexp" 11 | "runtime" 12 | "strings" 13 | 14 | "github.com/kirillrogovoy/pullkee/github/client" 15 | ) 16 | 17 | // All fetches multiple pages given only a request for the first one and unmarshals them into `target`. 18 | // JSON of each response must be an array and `target` must be a pointer to a slice of the according type. 19 | func All( 20 | httpClient client.HTTPClient, 21 | firstPageRequest http.Request, 22 | target interface{}, 23 | pageLimit int, 24 | ) error { 25 | first, err := httpClient.Do(&firstPageRequest) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | rest, err := getRest(httpClient, *first, pageLimit-1) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | all := append([]http.Response{*first}, rest...) 36 | 37 | return unmarshalResponses(all, target) 38 | } 39 | 40 | func getRest( 41 | httpClient client.HTTPClient, 42 | firstPageResponse http.Response, 43 | limit int, 44 | ) ([]http.Response, error) { 45 | responses := []http.Response{} 46 | 47 | cur := firstPageResponse 48 | 49 | for i := 0; ; { 50 | if i+1 > limit { 51 | return responses, nil 52 | } 53 | next, err := nextPage(httpClient, cur) 54 | if err != nil { 55 | return nil, err 56 | } 57 | if next == nil { 58 | return responses, nil 59 | } 60 | 61 | responses = append(responses, *next) 62 | cur = *next 63 | i++ 64 | } 65 | } 66 | 67 | func nextPage(httpClient client.HTTPClient, prevPageResponse http.Response) (*http.Response, error) { 68 | link := prevPageResponse.Header.Get("Link") 69 | if link == "" { 70 | return nil, nil 71 | } 72 | 73 | nextURL := extractLinkURL(link, "next") 74 | if nextURL == "" { 75 | return nil, nil 76 | } 77 | 78 | req, _ := http.NewRequest("GET", nextURL, nil) 79 | return httpClient.Do(req) 80 | } 81 | 82 | func extractLinkURL(header string, rel string) string { 83 | for _, link := range strings.Split(header, ",") { 84 | if strings.Contains(link, fmt.Sprintf("rel=\"%s\"", rel)) { 85 | matches := regexp.MustCompile(`<(.*)>`).FindStringSubmatch(link) 86 | if len(matches) > 1 { 87 | return matches[1] 88 | } 89 | } 90 | } 91 | 92 | return "" 93 | } 94 | 95 | func unmarshalResponses(responses []http.Response, target interface{}) (err error) { 96 | defer func() { 97 | if e := recover(); e != nil { 98 | if _, ok := e.(runtime.Error); ok { 99 | panic(e) 100 | } 101 | err = e.(error) 102 | } 103 | }() 104 | 105 | targetRefl := reflect.ValueOf(target).Elem() 106 | tmp, err := createSliceOfSameType(target) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | for _, res := range responses { 112 | if err := unmarshalResponse(res, tmp); err != nil { 113 | return err 114 | } 115 | 116 | targetRefl = reflect.AppendSlice(targetRefl, reflect.ValueOf(tmp).Elem()) 117 | } 118 | 119 | reflect.ValueOf(target).Elem().Set(targetRefl) 120 | return nil 121 | } 122 | 123 | func unmarshalResponse(res http.Response, target interface{}) error { 124 | if res.Body == nil { 125 | url := "" 126 | if res.Request != nil && res.Request.URL != nil { 127 | url = res.Request.URL.String() 128 | } 129 | return fmt.Errorf("Expected res.Body not to be nil. URL: %s", url) 130 | } 131 | 132 | body, err := ioutil.ReadAll(res.Body) 133 | if err != nil { 134 | return err 135 | } 136 | 137 | return json.Unmarshal(body, target) 138 | } 139 | 140 | func createSliceOfSameType(original interface{}) (interface{}, error) { 141 | targetRefl := reflect.ValueOf(original) 142 | resultSlice := reflect.New(targetRefl.Elem().Type()) 143 | 144 | if resultSlice.Elem().Kind() != reflect.Slice { 145 | return nil, fmt.Errorf("Expected target to be a pointer to a slice") 146 | } 147 | 148 | return resultSlice.Interface(), nil 149 | } 150 | -------------------------------------------------------------------------------- /github/page/page_test.go: -------------------------------------------------------------------------------- 1 | package page_test 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "strings" 8 | "testing" 9 | 10 | . "github.com/kirillrogovoy/pullkee/github/page" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestAll(t *testing.T) { 15 | t.Run("Works on successful responses", func(t *testing.T) { 16 | linkPage := func(n int) string { 17 | return fmt.Sprintf("; rel=\"next\"", n) 18 | } 19 | 20 | timesCalled := 0 21 | response := func() (*http.Response, error) { 22 | defer func() { timesCalled++ }() 23 | json := fmt.Sprintf("[{\"keyX\": \"val%d\"}]", timesCalled) 24 | res := &http.Response{ 25 | Header: http.Header{}, 26 | Body: ioutil.NopCloser(strings.NewReader(json)), 27 | } 28 | switch timesCalled { 29 | case 0: 30 | res.Header.Add("Link", linkPage(3)) 31 | case 1: 32 | res.Header.Add("Link", linkPage(4)) 33 | case 2: 34 | // nothing 35 | default: 36 | panic("Should not be called") 37 | } 38 | return res, nil 39 | } 40 | 41 | actual := &[]SomeStruct{} 42 | err := All(httpClientMock{response}, http.Request{}, actual, 99) 43 | 44 | expected := &[]SomeStruct{ 45 | {"val0"}, 46 | {"val1"}, 47 | {"val2"}, 48 | } 49 | 50 | require.Nil(t, err) 51 | require.Equal(t, 3, timesCalled) 52 | require.Equal(t, expected, actual) 53 | }) 54 | 55 | t.Run("Limit works", func(t *testing.T) { 56 | linkPage := func(n int) string { 57 | return fmt.Sprintf("; rel=\"next\"", n) 58 | } 59 | 60 | timesCalled := 0 61 | response := func() (*http.Response, error) { 62 | defer func() { timesCalled++ }() 63 | json := fmt.Sprintf("[{\"keyX\": \"val%d\"}]", timesCalled) 64 | res := &http.Response{ 65 | Header: http.Header{}, 66 | Body: ioutil.NopCloser(strings.NewReader(json)), 67 | } 68 | switch timesCalled { 69 | case 0: 70 | res.Header.Add("Link", linkPage(3)) 71 | case 1: 72 | res.Header.Add("Link", linkPage(4)) 73 | case 2: 74 | // nothing 75 | default: 76 | panic("Should not be called") 77 | } 78 | return res, nil 79 | } 80 | 81 | actual := &[]SomeStruct{} 82 | err := All(httpClientMock{response}, http.Request{}, actual, 2) 83 | 84 | expected := &[]SomeStruct{ 85 | {"val0"}, 86 | {"val1"}, 87 | } 88 | 89 | require.Nil(t, err) 90 | require.Equal(t, 2, timesCalled) 91 | require.Equal(t, expected, actual) 92 | }) 93 | 94 | t.Run("Fails when couldn't fetch the first page", func(t *testing.T) { 95 | result := &[]SomeStruct{} 96 | 97 | err := All(httpClientMock{func() (*http.Response, error) { 98 | return nil, fmt.Errorf("Some weird network error") 99 | }}, *dummyRequest(), result, 99) 100 | 101 | require.Equal(t, []SomeStruct{}, *result) 102 | require.Contains(t, err.Error(), "Some weird network error") 103 | }) 104 | 105 | t.Run("Fails when response body is absent", func(t *testing.T) { 106 | result := &[]SomeStruct{} 107 | req := dummyRequest() 108 | 109 | err := All(httpClientMock{func() (*http.Response, error) { 110 | return &http.Response{ 111 | Request: req, 112 | }, nil 113 | }}, *req, result, 99) 114 | 115 | require.Equal(t, []SomeStruct{}, *result) 116 | require.Contains(t, err.Error(), "Expected res.Body not to be nil") 117 | }) 118 | 119 | t.Run("Fails when there was an error reading response body", func(t *testing.T) { 120 | result := &[]SomeStruct{} 121 | req := dummyRequest() 122 | 123 | err := All(httpClientMock{func() (*http.Response, error) { 124 | return &http.Response{ 125 | Request: req, 126 | Body: ioutil.NopCloser(errorReader{}), 127 | }, nil 128 | }}, *req, result, 99) 129 | 130 | require.Equal(t, []SomeStruct{}, *result) 131 | require.Equal(t, "Some weird reader error", err.Error()) 132 | }) 133 | 134 | t.Run("Fails when couldn't fetch the rest of pages", func(t *testing.T) { 135 | goodLink := `; rel="next"` 136 | 137 | timesCalled := 0 138 | response := func() (*http.Response, error) { 139 | defer func() { timesCalled++ }() 140 | switch timesCalled { 141 | case 0: 142 | return &http.Response{ 143 | Header: http.Header{ 144 | "Link": []string{goodLink}, 145 | }, 146 | Body: ioutil.NopCloser(strings.NewReader(`[{"keyX": "val1"}]`)), 147 | }, nil 148 | case 1: 149 | return nil, fmt.Errorf("Some weird network error") 150 | default: 151 | panic("Should not be called") 152 | } 153 | } 154 | 155 | result := &[]SomeStruct{} 156 | err := All(httpClientMock{response}, *dummyRequest(), result, 99) 157 | 158 | require.Equal(t, []SomeStruct{}, *result) 159 | require.Contains(t, err.Error(), "Some weird network error") 160 | }) 161 | 162 | t.Run("Stops fetching when there is no Link header in the response", func(t *testing.T) { 163 | result := &[]SomeStruct{} 164 | err := All(httpClientMock{func() (*http.Response, error) { 165 | return &http.Response{ 166 | Body: ioutil.NopCloser(strings.NewReader(`[{"keyX": "val1"}]`)), 167 | }, nil 168 | }}, *dummyRequest(), result, 99) 169 | 170 | require.Nil(t, err) 171 | require.Equal(t, []SomeStruct{{KeyX: "val1"}}, *result) 172 | }) 173 | 174 | t.Run("Stops fetching when couldn't parse the Link header", func(t *testing.T) { 175 | result := &[]SomeStruct{} 176 | err := All(httpClientMock{func() (*http.Response, error) { 177 | return &http.Response{ 178 | Header: http.Header{ 179 | "Link": []string{"Total rubbish"}, 180 | }, 181 | Body: ioutil.NopCloser(strings.NewReader(`[{"keyX": "val1"}]`)), 182 | }, nil 183 | }}, *dummyRequest(), result, 99) 184 | 185 | require.Nil(t, err) 186 | require.Equal(t, []SomeStruct{{KeyX: "val1"}}, *result) 187 | }) 188 | 189 | t.Run("Fails when target is not a pointer", func(t *testing.T) { 190 | result := []SomeStruct{} 191 | err := All(httpClientMock{successfulResponse}, *dummyRequest(), result, 99) 192 | 193 | require.NotNil(t, err) 194 | }) 195 | 196 | t.Run("Fails when target is not a pointer to a slice", func(t *testing.T) { 197 | result := &SomeStruct{} 198 | err := All(httpClientMock{successfulResponse}, *dummyRequest(), result, 99) 199 | 200 | require.NotNil(t, err) 201 | }) 202 | 203 | t.Run("Fails when a subsequent request gets wrong kind of JSON", func(t *testing.T) { 204 | goodLink := `; rel="next"` 205 | 206 | timesCalled := 0 207 | response := func() (*http.Response, error) { 208 | defer func() { timesCalled++ }() 209 | switch timesCalled { 210 | case 0: 211 | return &http.Response{ 212 | Header: http.Header{ 213 | "Link": []string{goodLink}, 214 | }, 215 | Body: ioutil.NopCloser(strings.NewReader(`[{"keyX": "val1"}]`)), 216 | }, nil 217 | case 1: 218 | return &http.Response{ 219 | Body: ioutil.NopCloser(strings.NewReader(`"JSON, but not an array"`)), 220 | }, nil 221 | default: 222 | panic("Should not be called") 223 | } 224 | } 225 | 226 | result := &[]SomeStruct{} 227 | err := All(httpClientMock{response}, *dummyRequest(), result, 99) 228 | 229 | require.Equal(t, []SomeStruct{}, *result) 230 | require.Contains(t, err.Error(), "cannot unmarshal string into Go value of type []page_test.SomeStruct") 231 | }) 232 | } 233 | 234 | type httpClientMock struct { 235 | response func() (*http.Response, error) 236 | } 237 | 238 | func (h httpClientMock) Do(request *http.Request) (*http.Response, error) { 239 | return h.response() 240 | } 241 | 242 | func dummyRequest() *http.Request { 243 | req, _ := http.NewRequest("GET", "http://example.com/url1", nil) 244 | return req 245 | } 246 | 247 | func successfulResponse() (*http.Response, error) { 248 | return &http.Response{ 249 | StatusCode: 200, 250 | Header: http.Header{ 251 | "Success": []string{"yes"}, 252 | }, 253 | }, nil 254 | } 255 | 256 | type SomeStruct struct { 257 | KeyX string `json:"keyX"` 258 | } 259 | 260 | type errorReader struct{} 261 | 262 | func (e errorReader) Read(p []byte) (int, error) { 263 | return 0, fmt.Errorf("Some weird reader error") 264 | } 265 | -------------------------------------------------------------------------------- /github/pullRequest.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/kirillrogovoy/pullkee/github/page" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | // PullRequest is a representation of the Pull Request the Github API returns 14 | type PullRequest struct { 15 | Number int `json:"number"` 16 | Body string `json:"body"` 17 | CreatedAt time.Time `json:"created_at"` 18 | MergedAt time.Time `json:"merged_at,omitempty"` 19 | User User `json:"user"` 20 | State string `json:"state"` 21 | Assignees []User `json:"assignees"` 22 | DiffURL string `json:"diff_url"` 23 | DiffSize *int 24 | ReviewRequests *[]User 25 | Comments *[]Comment 26 | } 27 | 28 | // IsMerged tells if PullRequest was really merged, not just closed 29 | func (p PullRequest) IsMerged() bool { 30 | return p.State == "closed" && !p.MergedAt.IsZero() 31 | } 32 | 33 | // FillDetails makes additional requests to fill details about the Pull Request (such as diff size) 34 | func (p *PullRequest) FillDetails(a API) error { 35 | if p.DiffSize == nil { 36 | size, err := a.DiffSize(p.Number) 37 | if err != nil { 38 | return errors.Wrap(err, "diff size") 39 | } 40 | p.DiffSize = &size 41 | } 42 | 43 | if p.ReviewRequests == nil { 44 | users, err := a.ReviewRequests(p.Number) 45 | if err != nil { 46 | return errors.Wrap(err, "review requests") 47 | } 48 | p.ReviewRequests = &users 49 | } 50 | 51 | if p.Comments == nil { 52 | comments, err := a.Comments(p.Number) 53 | if err != nil { 54 | return errors.Wrap(err, "comments") 55 | } 56 | p.Comments = &comments 57 | } 58 | 59 | return nil 60 | } 61 | 62 | // ClosedPullRequests fetches a list of closed Pull Requests with a `limit` 63 | func (a APIv3) ClosedPullRequests(limit int) ([]PullRequest, error) { 64 | perPage := 100 65 | url := fmt.Sprintf( 66 | "https://api.github.com/repos/%s/pulls?state=closed&per_page=%d&page=1", 67 | a.RepoName, 68 | perPage, 69 | ) 70 | req, _ := http.NewRequest("GET", url, nil) 71 | 72 | prs := []PullRequest{} 73 | pageLimit := int(math.Ceil(float64(limit) / float64(perPage))) 74 | if limit <= 0 { 75 | pageLimit = int(math.Inf(1)) 76 | } 77 | if err := page.All(a.HTTPClient, *req, &prs, pageLimit); err != nil { 78 | return nil, err 79 | } 80 | 81 | len := len(prs) 82 | if limit > len || limit == 0 { 83 | limit = len 84 | } 85 | return prs[:limit], nil 86 | } 87 | -------------------------------------------------------------------------------- /github/pullRequest_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "net/http" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestClosedPullRequests(t *testing.T) { 16 | t.Run("Works on good response", func(t *testing.T) { 17 | pulls, err := successfulClosedPullRequests(0) 18 | require.Nil(t, err) 19 | 20 | require.Equal(t, 1, pulls[0].Number) 21 | require.Equal(t, "Body1", pulls[0].Body) 22 | 23 | require.Equal(t, 2, pulls[1].Number) 24 | require.Equal(t, "Body2", pulls[1].Body) 25 | }) 26 | 27 | t.Run("Works when limit is applied", func(t *testing.T) { 28 | pulls, err := successfulClosedPullRequests(1) 29 | require.Nil(t, err) 30 | 31 | require.Len(t, pulls, 1) 32 | require.Equal(t, 1, pulls[0].Number) 33 | require.Equal(t, "Body1", pulls[0].Body) 34 | }) 35 | 36 | t.Run("Works when limit is greater than len", func(t *testing.T) { 37 | pulls, err := successfulClosedPullRequests(999) 38 | require.Nil(t, err) 39 | 40 | require.Len(t, pulls, 2) 41 | require.Equal(t, 1, pulls[0].Number) 42 | require.Equal(t, "Body1", pulls[0].Body) 43 | 44 | require.Equal(t, 2, pulls[1].Number) 45 | require.Equal(t, "Body2", pulls[1].Body) 46 | }) 47 | 48 | t.Run("Fails when there is an error fetching the response", func(t *testing.T) { 49 | a := APIv3{ 50 | HTTPClient: httpClientMock{func() (*http.Response, error) { 51 | return nil, fmt.Errorf("Dogs have chewed the wires") 52 | }}, 53 | RepoName: "someuser/somerepo", 54 | } 55 | 56 | pulls, err := a.ClosedPullRequests(0) 57 | require.EqualError(t, err, "Dogs have chewed the wires") 58 | require.Nil(t, pulls) 59 | }) 60 | } 61 | 62 | func TestIsMerged(t *testing.T) { 63 | t.Run("Positive", func(t *testing.T) { 64 | pull := PullRequest{ 65 | State: "closed", 66 | MergedAt: time.Now(), 67 | } 68 | 69 | require.True(t, pull.IsMerged()) 70 | }) 71 | 72 | t.Run("Negative", func(t *testing.T) { 73 | pull := PullRequest{ 74 | State: "closed", 75 | MergedAt: time.Time{}, 76 | } 77 | 78 | require.False(t, pull.IsMerged()) 79 | }) 80 | } 81 | 82 | func TestFillDetails(t *testing.T) { 83 | t.Run("Works when the requests succeed", func(t *testing.T) { 84 | pr := PullRequest{ 85 | Number: 11, 86 | } 87 | 88 | a := apiMock{} 89 | 90 | err := pr.FillDetails(a) 91 | 92 | require.Nil(t, err) 93 | require.Equal(t, 100, *pr.DiffSize) 94 | require.Equal(t, "Neat!", (*pr.Comments)[0].Body) 95 | require.Equal(t, "User1", (*pr.ReviewRequests)[0].Login) 96 | }) 97 | 98 | t.Run("Fails when couldn't fetch the diff size", func(t *testing.T) { 99 | pr := PullRequest{ 100 | Number: 11, 101 | } 102 | 103 | err := pr.FillDetails(apiMock{ 104 | diffSizeErr: fmt.Errorf("Weird error"), 105 | }) 106 | require.EqualError(t, err, "diff size: Weird error") 107 | }) 108 | 109 | t.Run("Fails when couldn't fetch the comments", func(t *testing.T) { 110 | pr := PullRequest{ 111 | Number: 11, 112 | } 113 | 114 | err := pr.FillDetails(apiMock{ 115 | commentsErr: fmt.Errorf("Weird error"), 116 | }) 117 | require.EqualError(t, err, "comments: Weird error") 118 | }) 119 | 120 | t.Run("Fails when couldn't fetch the review requests", func(t *testing.T) { 121 | pr := PullRequest{ 122 | Number: 11, 123 | } 124 | 125 | err := pr.FillDetails(apiMock{ 126 | reviewRequestsErr: fmt.Errorf("Weird error"), 127 | }) 128 | require.EqualError(t, err, "review requests: Weird error") 129 | }) 130 | } 131 | 132 | func successfulClosedPullRequests(limit int) ([]PullRequest, error) { 133 | goodLink := `; rel="next"` 134 | 135 | timesCalled := 0 136 | response := func() (*http.Response, error) { 137 | defer func() { timesCalled++ }() 138 | switch timesCalled { 139 | case 0: 140 | json := `[{"number": 1, "body": "Body1"}]` 141 | return &http.Response{ 142 | StatusCode: 200, 143 | Body: ioutil.NopCloser(strings.NewReader(json)), 144 | Header: http.Header{ 145 | "Link": []string{goodLink}, 146 | }, 147 | }, nil 148 | case 1: 149 | json := `[{"number": 2, "body": "Body2"}]` 150 | return &http.Response{ 151 | StatusCode: 200, 152 | Body: ioutil.NopCloser(strings.NewReader(json)), 153 | }, nil 154 | default: 155 | panic("should never be called") 156 | } 157 | } 158 | a := APIv3{ 159 | HTTPClient: httpClientMock{response}, 160 | RepoName: "someuser/somerepo", 161 | } 162 | 163 | return a.ClosedPullRequests(limit) 164 | } 165 | 166 | type apiMock struct { 167 | diffSizeErr error 168 | commentsErr error 169 | reviewRequestsErr error 170 | } 171 | 172 | func (a apiMock) Get(url string, target interface{}) error { 173 | panic("not implemented") 174 | } 175 | 176 | func (a apiMock) Repository() (*Repository, error) { 177 | panic("not implemented") 178 | } 179 | 180 | func (a apiMock) ClosedPullRequests(limit int) ([]PullRequest, error) { 181 | panic("not implemented") 182 | } 183 | 184 | func (a apiMock) DiffSize(number int) (int, error) { 185 | if a.diffSizeErr != nil { 186 | return 0, a.diffSizeErr 187 | } 188 | 189 | return 100, nil 190 | } 191 | 192 | func (a apiMock) Comments(number int) ([]Comment, error) { 193 | if a.commentsErr != nil { 194 | return nil, a.commentsErr 195 | } 196 | 197 | return []Comment{{Body: "Neat!"}}, nil 198 | } 199 | 200 | func (a apiMock) ReviewRequests(number int) ([]User, error) { 201 | if a.reviewRequestsErr != nil { 202 | log.Println("not nil") 203 | return nil, a.reviewRequestsErr 204 | } 205 | 206 | return []User{{"User1"}}, nil 207 | } 208 | -------------------------------------------------------------------------------- /github/repository.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import "fmt" 4 | 5 | // Repository is a representation of a Github repository which is accessible via API 6 | type Repository struct { 7 | FullName string `json:"full_name"` 8 | } 9 | 10 | // Repository fetches the remote repository data 11 | func (a APIv3) Repository() (*Repository, error) { 12 | repo := &Repository{} 13 | 14 | if err := a.Get(fmt.Sprintf("https://api.github.com/repos/%s", a.RepoName), repo); err != nil { 15 | return nil, err 16 | } 17 | 18 | return repo, nil 19 | } 20 | -------------------------------------------------------------------------------- /github/repository_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestRepository(t *testing.T) { 14 | t.Run("Works on good response", func(t *testing.T) { 15 | a := APIv3{ 16 | HTTPClient: httpClientMock{func() (*http.Response, error) { 17 | return &http.Response{ 18 | StatusCode: 200, 19 | Body: ioutil.NopCloser(strings.NewReader(`{"full_name": "someuser/somerepo"}`)), 20 | }, nil 21 | }}, 22 | RepoName: "someuser/somerepo", 23 | } 24 | 25 | repo, err := a.Repository() 26 | require.Nil(t, err) 27 | require.Equal(t, "someuser/somerepo", repo.FullName) 28 | }) 29 | 30 | t.Run("Fails when there is an error fetching the response", func(t *testing.T) { 31 | a := APIv3{ 32 | HTTPClient: httpClientMock{func() (*http.Response, error) { 33 | return nil, fmt.Errorf("Dogs have chewed the wires") 34 | }}, 35 | RepoName: "someuser/somerepo", 36 | } 37 | 38 | repo, err := a.Repository() 39 | require.EqualError(t, err, "Dogs have chewed the wires") 40 | require.Nil(t, repo) 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /github/reviewRequest.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import "fmt" 4 | 5 | // ReviewRequestsResponse is a representation of the /requested_reviewers API response 6 | type ReviewRequestsResponse struct { 7 | Users []User `json:"users"` 8 | } 9 | 10 | // ReviewRequests fetches a list of users which were requested to do a review 11 | func (a APIv3) ReviewRequests(number int) ([]User, error) { 12 | url := fmt.Sprintf("https://api.github.com/repos/%s/pulls/%d/requested_reviewers", a.RepoName, number) 13 | response := ReviewRequestsResponse{} 14 | err := a.Get(url, &response) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | return response.Users, nil 20 | } 21 | -------------------------------------------------------------------------------- /github/reviewRequest_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestReviewRequest(t *testing.T) { 14 | t.Run("Works on good response", func(t *testing.T) { 15 | a := APIv3{ 16 | HTTPClient: httpClientMock{func() (*http.Response, error) { 17 | return &http.Response{ 18 | StatusCode: 200, 19 | Body: ioutil.NopCloser(strings.NewReader( 20 | `{"users": [{"login": "User1"}], "teams": []}`, 21 | )), 22 | }, nil 23 | }}, 24 | RepoName: "someuser/somerepo", 25 | } 26 | 27 | users, err := a.ReviewRequests(1) 28 | require.Nil(t, err) 29 | require.Equal(t, []User{{"User1"}}, users) 30 | }) 31 | 32 | t.Run("Fails when there is an error fetching the response", func(t *testing.T) { 33 | a := APIv3{ 34 | HTTPClient: httpClientMock{func() (*http.Response, error) { 35 | return nil, fmt.Errorf("Some weird network error") 36 | }}, 37 | RepoName: "someuser/somerepo", 38 | } 39 | 40 | _, err := a.ReviewRequests(1) 41 | require.EqualError(t, err, "Some weird network error") 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /github/util/github_util.go: -------------------------------------------------------------------------------- 1 | // Package util is a high-level API for Github 2 | package util 3 | 4 | import ( 5 | "fmt" 6 | "log" 7 | 8 | "github.com/kirillrogovoy/pullkee/cache" 9 | "github.com/kirillrogovoy/pullkee/github" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | // Pulls fetches the list of pull requests directly from the API 14 | func Pulls(a github.API, limit int) ([]github.PullRequest, error) { 15 | return a.ClosedPullRequests(limit) 16 | } 17 | 18 | // FillDetails calls .FillDetails for each PR in prs in parallel. 19 | // It returns a channel which will never be closed, so the caller 20 | // should expect len(prs) values from it 21 | func FillDetails( 22 | a github.API, 23 | c cache.Cache, 24 | prs []github.PullRequest, 25 | ) chan error { 26 | ch := make(chan error, len(prs)) 27 | 28 | for i, p := range prs { 29 | go (func(i int, p github.PullRequest) { 30 | var err error 31 | 32 | cacheKey := fmt.Sprintf("pr%d", p.Number) 33 | found, err := c.Get(cacheKey, &p) 34 | if err != nil { 35 | reportFsError(errors.Wrap(err, "getting cache")) 36 | } 37 | 38 | if !found { 39 | err = p.FillDetails(a) 40 | } 41 | 42 | if err != nil { 43 | ch <- err 44 | } else { 45 | // since p is a copy of i-th elem, we explicitly assign it to prs[i] to make the actual change 46 | prs[i] = p 47 | if err := c.Set(cacheKey, p); err != nil { 48 | reportFsError(errors.Wrap(err, "setting cache")) 49 | } 50 | ch <- nil 51 | } 52 | })(i, p) 53 | } 54 | 55 | return ch 56 | } 57 | 58 | func reportFsError(err error) { 59 | log.Printf("File system error occurred while accessing the cache: %s\n", err) 60 | } 61 | -------------------------------------------------------------------------------- /github/util/github_util_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/kirillrogovoy/pullkee/github" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | var pullsFromAPI = []github.PullRequest{{ 12 | Body: "PR from API", 13 | }} 14 | 15 | var pullsFromCache = []github.PullRequest{{ 16 | Body: "PR from cache", 17 | }} 18 | 19 | func TestPulls(t *testing.T) { 20 | t.Run("Works when the requests are successful", func(t *testing.T) { 21 | a := apiMock{} 22 | 23 | pulls, err := Pulls(a, 0) 24 | 25 | require.Equal(t, pullsFromAPI, pulls) 26 | require.Nil(t, err) 27 | }) 28 | 29 | t.Run("Fails when couldn't fetch something from the API", func(t *testing.T) { 30 | a := apiMock{ 31 | err: fmt.Errorf("Network failed"), 32 | } 33 | 34 | _, err := Pulls(a, 0) 35 | 36 | require.EqualError(t, err, "Network failed") 37 | }) 38 | } 39 | 40 | func TestFillDetails(t *testing.T) { 41 | t.Run("Works when the requests are successful", func(t *testing.T) { 42 | prs := []github.PullRequest{ 43 | {Number: 1}, 44 | {Number: 2}, 45 | {Number: 3}, 46 | {Number: 4}, 47 | } 48 | 49 | a := apiMock{} 50 | c := newCacheMock() 51 | 52 | var err error 53 | ch := FillDetails(a, c, prs) 54 | for range prs { 55 | e := <-ch 56 | if e != nil { 57 | err = e 58 | } 59 | } 60 | 61 | require.Nil(t, err) 62 | require.Len(t, prs, 4) 63 | 64 | require.Equal(t, 100, *prs[0].DiffSize) 65 | require.Equal(t, 100, *prs[1].DiffSize) 66 | require.Equal(t, 100, *prs[2].DiffSize) 67 | require.Equal(t, 100, *prs[3].DiffSize) 68 | 69 | require.Equal(t, prs[0], c.store["pr1"]) 70 | require.Equal(t, prs[3], c.store["pr4"]) 71 | }) 72 | 73 | t.Run("Works even when had a cache read error", func(t *testing.T) { 74 | prs := []github.PullRequest{ 75 | {Number: 1}, 76 | } 77 | 78 | a := apiMock{} 79 | c := newCacheMock() 80 | c.getErr = fmt.Errorf("Nasty cache error") 81 | 82 | var err error 83 | ch := FillDetails(a, c, prs) 84 | for range prs { 85 | e := <-ch 86 | if e != nil { 87 | err = e 88 | } 89 | } 90 | 91 | require.Nil(t, err) 92 | require.Len(t, prs, 1) 93 | 94 | require.Equal(t, 100, *prs[0].DiffSize) 95 | }) 96 | 97 | t.Run("Works even when had a cache write error", func(t *testing.T) { 98 | prs := []github.PullRequest{ 99 | {Number: 1}, 100 | } 101 | 102 | a := apiMock{} 103 | c := newCacheMock() 104 | c.setErr = fmt.Errorf("Nasty cache error") 105 | 106 | var err error 107 | ch := FillDetails(a, c, prs) 108 | for range prs { 109 | e := <-ch 110 | if e != nil { 111 | err = e 112 | } 113 | } 114 | 115 | require.Nil(t, err) 116 | require.Len(t, prs, 1) 117 | 118 | require.Equal(t, 100, *prs[0].DiffSize) 119 | }) 120 | 121 | t.Run("Fails when couldn't fetch details", func(t *testing.T) { 122 | prs := []github.PullRequest{ 123 | {Number: 1}, 124 | } 125 | 126 | a := apiMock{ 127 | err: fmt.Errorf("Fetching error"), 128 | } 129 | 130 | c := newCacheMock() 131 | 132 | var err error 133 | ch := FillDetails(a, c, prs) 134 | for range prs { 135 | e := <-ch 136 | if e != nil { 137 | err = e 138 | } 139 | } 140 | 141 | require.EqualError(t, err, "diff size: Fetching error") 142 | }) 143 | } 144 | 145 | type cacheMock struct { 146 | store map[string]interface{} 147 | found bool 148 | getErr error 149 | setErr error 150 | } 151 | 152 | func newCacheMock() cacheMock { 153 | c := cacheMock{} 154 | c.store = map[string]interface{}{} 155 | return c 156 | } 157 | 158 | func (c cacheMock) Set(key string, target interface{}) error { 159 | if c.setErr != nil { 160 | return c.setErr 161 | } 162 | 163 | c.store[key] = target 164 | return nil 165 | } 166 | 167 | func (c cacheMock) Get(key string, target interface{}) (bool, error) { 168 | if c.getErr != nil { 169 | return false, c.getErr 170 | } 171 | 172 | if c.found { 173 | t := target.(*[]github.PullRequest) 174 | *t = pullsFromCache 175 | } 176 | return c.found, c.getErr 177 | } 178 | 179 | type apiMock struct { 180 | err error 181 | } 182 | 183 | func (a apiMock) ClosedPullRequests(limit int) ([]github.PullRequest, error) { 184 | if a.err != nil { 185 | return nil, a.err 186 | } 187 | return pullsFromAPI, nil 188 | } 189 | 190 | func (a apiMock) Get(url string, target interface{}) error { 191 | panic("not implemented") 192 | } 193 | 194 | func (a apiMock) Repository() (*github.Repository, error) { 195 | panic("not implemented") 196 | } 197 | 198 | func (a apiMock) DiffSize(number int) (int, error) { 199 | if a.err != nil { 200 | return 0, a.err 201 | } 202 | return 100, nil 203 | } 204 | 205 | func (a apiMock) Comments(number int) ([]github.Comment, error) { 206 | return []github.Comment{{Body: "Neat!"}}, nil 207 | } 208 | 209 | func (a apiMock) ReviewRequests(number int) ([]github.User, error) { 210 | return []github.User{{ 211 | Login: "User1", 212 | }}, nil 213 | } 214 | -------------------------------------------------------------------------------- /glide.lock: -------------------------------------------------------------------------------- 1 | hash: 61e80d48c330e9c81b20d285d1915468a40dcb2b1967cd5a3f6d0ad4fc30b64f 2 | updated: 2018-04-28T12:47:39.925121+07:00 3 | imports: 4 | - name: github.com/pkg/errors 5 | version: 645ef00459ed84a119197bfb8d8205042c6df63d 6 | testImports: 7 | - name: github.com/davecgh/go-spew 8 | version: 6d212800a42e8ab5c146b8ace3490ee17e5225f9 9 | subpackages: 10 | - spew 11 | - name: github.com/pmezard/go-difflib 12 | version: d8ed2627bdf02c080bf22230dbb337003b7aba2d 13 | subpackages: 14 | - difflib 15 | - name: github.com/stretchr/testify 16 | version: 12b6f73e6084dad08a7c6e575284b177ecafbc71 17 | subpackages: 18 | - assert 19 | - require 20 | -------------------------------------------------------------------------------- /glide.yaml: -------------------------------------------------------------------------------- 1 | package: github.com/kirillrogovoy/pullk 2 | import: 3 | - package: github.com/pkg/errors 4 | version: ^0.8.0 5 | testImport: 6 | - package: github.com/stretchr/testify 7 | version: ^1.1.4 8 | subpackages: 9 | - require 10 | -------------------------------------------------------------------------------- /godownloader.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | # Code generated by godownloader. DO NOT EDIT. 4 | # 5 | 6 | usage() { 7 | this=$1 8 | cat </dev/null 107 | } 108 | uname_os() { 109 | os=$(uname -s | tr '[:upper:]' '[:lower:]') 110 | echo "$os" 111 | } 112 | uname_arch() { 113 | arch=$(uname -m) 114 | case $arch in 115 | x86_64) arch="amd64" ;; 116 | x86) arch="386" ;; 117 | i686) arch="386" ;; 118 | i386) arch="386" ;; 119 | aarch64) arch="arm64" ;; 120 | armv5*) arch="arm5" ;; 121 | armv6*) arch="arm6" ;; 122 | armv7*) arch="arm7" ;; 123 | esac 124 | echo ${arch} 125 | } 126 | uname_os_check() { 127 | os=$(uname_os) 128 | case "$os" in 129 | darwin) return 0 ;; 130 | dragonfly) return 0 ;; 131 | freebsd) return 0 ;; 132 | linux) return 0 ;; 133 | android) return 0 ;; 134 | nacl) return 0 ;; 135 | netbsd) return 0 ;; 136 | openbsd) return 0 ;; 137 | plan9) return 0 ;; 138 | solaris) return 0 ;; 139 | windows) return 0 ;; 140 | esac 141 | echo "$0: uname_os_check: internal error '$(uname -s)' got converted to '$os' which is not a GOOS value. Please file bug at https://github.com/client9/shlib" 142 | return 1 143 | } 144 | uname_arch_check() { 145 | arch=$(uname_arch) 146 | case "$arch" in 147 | 386) return 0 ;; 148 | amd64) return 0 ;; 149 | arm64) return 0 ;; 150 | armv5) return 0 ;; 151 | armv6) return 0 ;; 152 | armv7) return 0 ;; 153 | ppc64) return 0 ;; 154 | ppc64le) return 0 ;; 155 | mips) return 0 ;; 156 | mipsle) return 0 ;; 157 | mips64) return 0 ;; 158 | mips64le) return 0 ;; 159 | s390x) return 0 ;; 160 | amd64p32) return 0 ;; 161 | esac 162 | echo "$0: uname_arch_check: internal error '$(uname -m)' got converted to '$arch' which is not a GOARCH value. Please file bug report at https://github.com/client9/shlib" 163 | return 1 164 | } 165 | untar() { 166 | tarball=$1 167 | case "${tarball}" in 168 | *.tar.gz | *.tgz) tar -xzf "${tarball}" ;; 169 | *.tar) tar -xf "${tarball}" ;; 170 | *.zip) unzip "${tarball}" ;; 171 | *) 172 | echo "Unknown archive format for ${tarball}" 173 | return 1 174 | ;; 175 | esac 176 | } 177 | mktmpdir() { 178 | test -z "$TMPDIR" && TMPDIR="$(mktemp -d)" 179 | mkdir -p "${TMPDIR}" 180 | echo "${TMPDIR}" 181 | } 182 | http_download() { 183 | local_file=$1 184 | source_url=$2 185 | header=$3 186 | headerflag='' 187 | destflag='' 188 | if is_command curl; then 189 | cmd='curl --fail -sSL' 190 | destflag='-o' 191 | headerflag='-H' 192 | elif is_command wget; then 193 | cmd='wget -q' 194 | destflag='-O' 195 | headerflag='--header' 196 | else 197 | echo "http_download: unable to find wget or curl" 198 | return 1 199 | fi 200 | if [ -z "$header" ]; then 201 | $cmd $destflag "$local_file" "$source_url" 202 | else 203 | $cmd $headerflag "$header" $destflag "$local_file" "$source_url" 204 | fi 205 | } 206 | github_api() { 207 | local_file=$1 208 | source_url=$2 209 | header="" 210 | case "$source_url" in 211 | https://api.github.com*) 212 | test -z "$GITHUB_TOKEN" || header="Authorization: token $GITHUB_TOKEN" 213 | ;; 214 | esac 215 | http_download "$local_file" "$source_url" "$header" 216 | } 217 | github_last_release() { 218 | owner_repo=$1 219 | giturl="https://api.github.com/repos/${owner_repo}/releases/latest" 220 | html=$(github_api - "$giturl") 221 | version=$(echo "$html" | grep -m 1 "\"tag_name\":" | cut -f4 -d'"') 222 | test -z "$version" && return 1 223 | echo "$version" 224 | } 225 | hash_sha256() { 226 | TARGET=${1:-/dev/stdin} 227 | if is_command gsha256sum; then 228 | hash=$(gsha256sum "$TARGET") || return 1 229 | echo "$hash" | cut -d ' ' -f 1 230 | elif is_command sha256sum; then 231 | hash=$(sha256sum "$TARGET") || return 1 232 | echo "$hash" | cut -d ' ' -f 1 233 | elif is_command shasum; then 234 | hash=$(shasum -a 256 "$TARGET" 2>/dev/null) || return 1 235 | echo "$hash" | cut -d ' ' -f 1 236 | elif is_command openssl; then 237 | hash=$(openssl -dst openssl dgst -sha256 "$TARGET") || return 1 238 | echo "$hash" | cut -d ' ' -f a 239 | else 240 | echo "hash_sha256: unable to find command to compute sha-256 hash" 241 | return 1 242 | fi 243 | } 244 | hash_sha256_verify() { 245 | TARGET=$1 246 | checksums=$2 247 | if [ -z "$checksums" ]; then 248 | echo "hash_sha256_verify: checksum file not specified in arg2" 249 | return 1 250 | fi 251 | BASENAME=${TARGET##*/} 252 | want=$(grep "${BASENAME}" "${checksums}" 2>/dev/null | tr '\t' ' ' | cut -d ' ' -f 1) 253 | if [ -z "$want" ]; then 254 | echo "hash_sha256_verify: unable to find checksum for '${TARGET}' in '${checksums}'" 255 | return 1 256 | fi 257 | got=$(hash_sha256 "$TARGET") 258 | if [ "$want" != "$got" ]; then 259 | echo "hash_sha256_verify: checksum for '$TARGET' did not verify ${want} vs $got" 260 | return 1 261 | fi 262 | } 263 | cat /dev/null < 1 { 20 | panic(fmt.Sprintf("The value passed should be between 0 and 1, %f given", v)) 21 | } 22 | 23 | b.OnChange(render(v, b.Len)) 24 | } 25 | 26 | func render(v float64, len int) string { 27 | bar := "" 28 | 29 | cellsFilled := int(math.Floor(v * float64(len))) 30 | 31 | for i := 0; i < cellsFilled; i++ { 32 | bar += "#" 33 | } 34 | 35 | for i := 0; i < len-cellsFilled; i++ { 36 | bar += " " 37 | } 38 | 39 | return fmt.Sprintf("[%s]", bar) 40 | } 41 | -------------------------------------------------------------------------------- /progress/progress_test.go: -------------------------------------------------------------------------------- 1 | package progress 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestBar(t *testing.T) { 10 | t.Run(`0% progress`, func(t *testing.T) { 11 | var result string 12 | bar := Bar{10, func(s string) { 13 | result = s 14 | }} 15 | 16 | bar.Set(0) 17 | require.Equal(t, "[ ]", result) 18 | }) 19 | 20 | t.Run(`50% progress`, func(t *testing.T) { 21 | var result string 22 | bar := Bar{10, func(s string) { 23 | result = s 24 | }} 25 | 26 | bar.Set(0.5) 27 | require.Equal(t, "[##### ]", result) 28 | }) 29 | 30 | t.Run(`100% progress`, func(t *testing.T) { 31 | var result string 32 | bar := Bar{10, func(s string) { 33 | result = s 34 | }} 35 | 36 | bar.Set(1) 37 | require.Equal(t, "[##########]", result) 38 | }) 39 | 40 | t.Run("Panics on wrong input", func(t *testing.T) { 41 | bar := Bar{10, func(s string) {}} 42 | 43 | require.Panics(t, func() { 44 | bar.Set(2) 45 | }) 46 | }) 47 | } 48 | --------------------------------------------------------------------------------