├── .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 |
3 |
pullkee
4 | A simple Pull Requests analyzer.
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
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 |
--------------------------------------------------------------------------------