Repository: globocom/huskyCI (go)

Issue:
Add cache in the API to better serve the huskyCI Dashboard
<a href="https://opensource.globo.com/hacktoberfest/"><img align="right" src="https://user-images.githubusercontent.com/8943477/94613618-36e2d380-027b-11eb-8566-f8aa35ac792d.png" width="300"/></a>

## Motivation

As more and more teams are adding huskyCI to their pipelines and the data present in the database grows, we've been noticing some increase in the loading time for [huskyCI-dashboard](https://github.com/globocom/huskyCI-dashboard), which might not be the best for user experience.

## It would be great if

We have a caching solution implemented in huskyCI's API to allow for faster response and loading times.

## What we expect

* A working caching solution for the following routes:
```
<huskyCIAPIAddress>/stats/author
<huskyCIAPIAddress>/stats/analysis
<huskyCIAPIAddress>/stats/language
<huskyCIAPIAddress>/stats/container
<huskyCIAPIAddress>/stats/repository
<huskyCIAPIAddress>/stats/historyanalysis
<huskyCIAPIAddress>/stats/severity
```

* Local cache is a good start (Redis is not necessary! Go Mutex is a great choice!) 

## Tips

* [Golang Local cache](https://hackernoon.com/dancing-with-go-s-mutexes-92407ae927bf)



Pull request description:
513 Add stats caching
### Description

Add cache in the API to better serve the stats.

Closes #513

### Proposed Changes

Cache responses for the *stats* routes (using [go-cache](https://github.com/patrickmn/go-cache)).

The PR presents two new environment variables for the cache configuration:

- `HUSKYCI_CACHE_DEFAULT_EXPIRATION` (if not set equals 5m)
- `HUSKYCI_CACHE_CLEANUP_INTERVAL` (if not set equals 10m)

**This variables should be added on the HuskyCI [official documentation page](https://huskyci.opensource.globo.com/docs/api/env-vars)!**

### Testing

```
make install

# do it multiple times and also with another stats routes
curl http://localhost:8888/stats/author
```


Patch:
diff --git a/api/config.yaml b/api/config.yaml
index 00ada74..ac8c3a0 100644
--- a/api/config.yaml
+++ b/api/config.yaml
@@ -67,7 +67,12 @@ gosec:
     gitURL="%GIT_SSH_URL%"
     gitURLSubtitute="%GIT_URL_TO_SUBSTITUTE%"
     if [ $gitURL != "nil" ] && [ $gitURLSubtitute != "nil" ]; then
-      git config --global url."$gitURL:".insteadOf "$gitURLSubtitute"
+      if [ "${#gitURL[@]}" == "${#gitURLSubtitute[@]}" ]; then
+        for (( i=0; i<${#gitURL[@]}; i++ ));
+        do
+          git config --global url."${gitURL[$i]}:".insteadOf "${gitURLSubtitute[$i]}"
+        done
+      fi
     fi
     cd src
     GIT_TERMINAL_PROMPT=0 git clone -b %GIT_BRANCH% --single-branch %GIT_REPO% code --quiet 2> /tmp/errorGitCloneGosec
diff --git a/api/context/context.go b/api/context/context.go
index 9b24acd..d9e1cb8 100644
--- a/api/context/context.go
+++ b/api/context/context.go
@@ -10,6 +10,8 @@ import (
 	"sync"
 	"time"
 
+	"github.com/patrickmn/go-cache"
+
 	"github.com/globocom/huskyCI/api/db"
 	postgres "github.com/globocom/huskyCI/api/db/postgres"
 	"github.com/globocom/huskyCI/api/types"
@@ -83,6 +85,7 @@ type APIConfig struct {
 	SafetySecurityTest     *types.SecurityTest
 	TFSecSecurityTest      *types.SecurityTest
 	DBInstance             db.Requests
+	Cache                  *cache.Cache
 }
 
 // DefaultConfig is the struct that stores the caller for testing.
@@ -127,6 +130,7 @@ func (dF DefaultConfig) SetOnceConfig() {
 			SafetySecurityTest:     dF.getSecurityTestConfig("safety"),
 			TFSecSecurityTest:      dF.getSecurityTestConfig("tfsec"),
 			DBInstance:             dF.GetDB(),
+			Cache:                  dF.GetCache(),
 		}
 	})
 }
@@ -252,7 +256,7 @@ func (dF DefaultConfig) GetConnMaxLifetime() time.Duration {
 	return time.Hour * time.Duration(connMaxLifetime)
 }
 
-//GetDBPort returns the port where DB
+// GetDBPort returns the port where DB
 // will be listening to. It depends on an env
 // called HUSKYCI_DATABASE_DB_PORT.
 func (dF DefaultConfig) GetDBPort() int {
@@ -361,3 +365,29 @@ func (dF DefaultConfig) GetDB() db.Requests {
 	}
 	return &db.MongoRequests{}
 }
+
+// GetCache returns a new cache based on the HUSKYCI_CACHE_DEFAULT_EXPIRATION
+// and HUSKYCI_CACHE_CLEANUP_INTERVAL environment variables.
+func (dF DefaultConfig) GetCache() *cache.Cache {
+	var (
+		defaultExpiration time.Duration
+		cleanupInterval   time.Duration
+		err               error
+	)
+
+	defaultExpiration, err = time.ParseDuration(
+		dF.Caller.GetEnvironmentVariable("HUSKYCI_CACHE_DEFAULT_EXPIRATION"),
+	)
+	if err != nil {
+		defaultExpiration = 5 * time.Minute
+	}
+
+	cleanupInterval, err = time.ParseDuration(
+		dF.Caller.GetEnvironmentVariable("HUSKYCI_CACHE_CLEANUP_INTERVAL"),
+	)
+	if err != nil {
+		cleanupInterval = 10 * time.Minute
+	}
+
+	return cache.New(defaultExpiration, cleanupInterval)
+}
diff --git a/api/go.mod b/api/go.mod
index 3be4806..92c5516 100644
--- a/api/go.mod
+++ b/api/go.mod
@@ -16,6 +16,7 @@ require (
 	github.com/onsi/ginkgo v1.12.1
 	github.com/onsi/gomega v1.10.0
 	github.com/opencontainers/go-digest v1.0.0 // indirect
+	github.com/patrickmn/go-cache v2.1.0+incompatible
 	github.com/spf13/viper v1.7.0
 	github.com/valyala/fasttemplate v1.1.0 // indirect
 	golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37
diff --git a/api/go.sum b/api/go.sum
index d93a805..76cc706 100644
--- a/api/go.sum
+++ b/api/go.sum
@@ -164,6 +164,8 @@ github.com/onsi/gomega v1.10.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGco
 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
 github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
+github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
+github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
 github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
 github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
diff --git a/api/routes/stats.go b/api/routes/stats.go
index 7cf2b14..4c08e57 100644
--- a/api/routes/stats.go
+++ b/api/routes/stats.go
@@ -8,9 +8,11 @@ import (
 	"net/http"
 	"strings"
 
+	"github.com/labstack/echo"
+	"github.com/patrickmn/go-cache"
+
 	apiContext "github.com/globocom/huskyCI/api/context"
 	"github.com/globocom/huskyCI/api/log"
-	"github.com/labstack/echo"
 )
 
 const logActionGetMetric = "GetMetric"
@@ -18,13 +20,22 @@ const logInfoStats = "STATS"
 
 // GetMetric returns data about the metric received
 func GetMetric(c echo.Context) error {
+	url := c.Request().URL.String()
 	metricType := strings.ToLower(c.Param("metric_type"))
 	queryParams := c.QueryParams()
+
+	if result, ok := apiContext.APIConfiguration.Cache.Get(url); ok {
+		return c.JSON(http.StatusOK, result)
+	}
+
 	result, err := apiContext.APIConfiguration.DBInstance.GetMetricByType(metricType, queryParams)
 	if err != nil {
 		httpStatus, reply := checkError(err, metricType)
 		return c.JSON(httpStatus, reply)
 	}
+
+	apiContext.APIConfiguration.Cache.Set(url, result, cache.DefaultExpiration)
+
 	return c.JSON(http.StatusOK, result)
 }
 
diff --git a/api/server.go b/api/server.go
index 5faf2d8..d6a83e9 100644
--- a/api/server.go
+++ b/api/server.go
@@ -9,14 +9,15 @@ import (
 	"net/http"
 	"os"
 
+	"github.com/labstack/echo"
+	"github.com/labstack/echo/middleware"
+
 	"github.com/globocom/huskyCI/api/auth"
 	apiContext "github.com/globocom/huskyCI/api/context"
 	"github.com/globocom/huskyCI/api/log"
 	"github.com/globocom/huskyCI/api/routes"
 	"github.com/globocom/huskyCI/api/util"
 	apiUtil "github.com/globocom/huskyCI/api/util/api"
-	"github.com/labstack/echo"
-	"github.com/labstack/echo/middleware"
 )
 
 func main() {
diff --git a/cli/cmd/login.go b/cli/cmd/login.go
new file mode 100644
index 0000000..a45aef1
--- /dev/null
+++ b/cli/cmd/login.go
@@ -0,0 +1,69 @@
+// Copyright 2020 Globo.com authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package cmd
+
+import (
+	"bufio"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"os"
+	"time"
+
+	"github.com/pkg/browser"
+	"github.com/spf13/cobra"
+
+	"github.com/globocom/huskyCI/cli/pkg/github"
+)
+
+var loginCmd = &cobra.Command{
+	Use:   "login",
+	Short: "Log in with GitHub",
+	Long: `
+Log in to the GitHub.
+`,
+	Args: cobra.NoArgs,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		client := &http.Client{Timeout: time.Minute}
+		df := github.NewDeviceFlow(github.DefaultBaseURI, client)
+		getCodesResp, err := df.GetCodes(&github.GetCodesRequest{
+			ClientID: github.ClientID,
+		})
+		if err != nil {
+			return fmt.Errorf("error getting device codes: %w", err)
+		}
+
+		if err := browser.OpenURL(getCodesResp.VerificationURI); err != nil {
+			return fmt.Errorf("error opening verification page: %w", err)
+		}
+
+		fmt.Printf("Please enter the user code in a browser: %s\n", getCodesResp.UserCode)
+		fmt.Print("Then press Enter...")
+		if _, err := bufio.NewReader(os.Stdin).ReadBytes('\n'); err != nil {
+			panic(err)
+		}
+
+		resp, err := df.GetAccessToken(&github.GetAccessTokenRequest{
+			ClientID:   github.ClientID,
+			DeviceCode: getCodesResp.DeviceCode,
+			GrantType:  github.GrantTypeDeviceCode,
+		})
+		if err != nil {
+			return fmt.Errorf("error getting access token: %w", err)
+		}
+
+		if err := ioutil.WriteFile(".huskyci", []byte(resp.AccessToken), 0600); err != nil {
+			return fmt.Errorf("error saving access token: %w", err)
+		}
+
+		fmt.Println("Login successful!🚀")
+
+		return nil
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(loginCmd)
+}
diff --git a/cli/go.mod b/cli/go.mod
index d809591..8ef4c27 100644
--- a/cli/go.mod
+++ b/cli/go.mod
@@ -10,7 +10,10 @@ require (
 	github.com/mholt/archiver v3.1.1+incompatible
 	github.com/mitchellh/go-homedir v1.1.0
 	github.com/nwaples/rardecode v1.1.0 // indirect
+	github.com/onsi/ginkgo v1.14.1
+	github.com/onsi/gomega v1.10.2
 	github.com/pierrec/lz4 v2.5.2+incompatible // indirect
+	github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4
 	github.com/spf13/cobra v1.0.0
 	github.com/spf13/viper v1.7.0
 	github.com/src-d/enry/v2 v2.1.0
diff --git a/cli/go.sum b/cli/go.sum
index a321e4a..7a229ce 100644
--- a/cli/go.sum
+++ b/cli/go.sum
@@ -48,6 +48,8 @@ github.com/frankban/quicktest v1.10.0 h1:Gfh+GAJZOAoKZsIZeZbdn2JF10kN1XHNvjsvQK8
 github.com/frankban/quicktest v1.10.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y=
 github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
+github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
 github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
@@ -64,12 +66,20 @@ github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFU
 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
+github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
+github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
 github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
 github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
 github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
 github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
@@ -108,6 +118,7 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO
 github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
 github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
 github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
+github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
@@ -153,12 +164,24 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb
 github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
 github.com/nwaples/rardecode v1.1.0 h1:vSxaY8vQhOcVr4mm5e8XllHWTiM4JF507A0Katqw7MQ=
 github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
+github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78=
+github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
 github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
+github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
+github.com/onsi/ginkgo v1.14.1 h1:jMU0WaQrP0a/YAEq8eJmJKjBoMs+pClEr1vDMlM/Do4=
+github.com/onsi/ginkgo v1.14.1/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
+github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
+github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
+github.com/onsi/gomega v1.10.2 h1:aY/nuoWlKJud2J6U0E3NWsjlg+0GtwXxgEqthRdzlcs=
+github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
 github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
 github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
 github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
 github.com/pierrec/lz4 v2.5.2+incompatible h1:WCjObylUIOlKy/+7Abdn34TLIkXiA4UWUMhxq9m9ZXI=
 github.com/pierrec/lz4 v2.5.2+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
+github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 h1:49lOXmGaUpV9Fz3gd7TFZY106KVlPVa5jcYD1gaQf98=
+github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -252,6 +275,7 @@ golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKG
 golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -265,6 +289,8 @@ golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn
 golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
 golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7 h1:AeiKBIuRw3UomYXSbLy0Mc2dDLfdtbT/IVn4keq83P0=
+golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -276,6 +302,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -287,6 +314,12 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0 h1:HyfiK1WMnHj5FXFXatD+Qs1A/xC2Run6RzeW1SyHxpc=
 golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200519105757-fe76b779f299 h1:DYfZAGf2WMFjMxbgTjaC+2HC7NkNAQs+6Q8b9WEB/F4=
+golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
@@ -335,16 +368,26 @@ google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZi
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
+google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
+google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
+google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
+google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
+google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM=
+google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
 gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
 gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno=
 gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 h1:VpOs+IwYnYBaFnrNAeB8UUWtL3vEUnzSCL1nVjPhqrw=
 gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
 gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
 gopkg.in/toqueteos/substring.v1 v1.0.2 h1:urLqCeMm6x/eTuQa1oZerNw8N1KNOIp5hD5kGL7lFsE=
 gopkg.in/toqueteos/substring.v1 v1.0.2/go.mod h1:Eb2Z1UYehlVK8LYW2WBVR2rwbujsz3aX8XDrM1vbNew=
 gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
@@ -352,6 +395,8 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/cli/pkg/github/auth.go b/cli/pkg/github/auth.go
new file mode 100644
index 0000000..c56ad74
--- /dev/null
+++ b/cli/pkg/github/auth.go
@@ -0,0 +1,144 @@
+// Copyright 2020 Globo.com authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package github
+
+import (
+	"bytes"
+	"encoding/json"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"path"
+)
+
+// DeviceFlow allows to authorize users to GitHub.
+// See https://docs.github.com/en/developers/apps/authorizing-oauth-apps#device-flow.
+type DeviceFlow struct {
+	baseURI *url.URL
+	client  *http.Client
+}
+
+// NewDeviceFlow creates a new GitHub device authorization flow.
+func NewDeviceFlow(baseURL *url.URL, client *http.Client) DeviceFlow {
+	return DeviceFlow{baseURI: baseURL, client: client}
+}
+
+// GetCodes requests the device and user verification codes from GitHub.
+func (df DeviceFlow) GetCodes(req *GetCodesRequest) (*GetCodesResponse, error) {
+	uri := df.uri(LoginDeviceCodePath)
+	resp := new(GetCodesResponse)
+	if err := df.do(http.MethodPost, uri, req, resp); err != nil {
+		return nil, err
+	}
+	return resp, nil
+}
+
+// GetCodesRequest contains input parameters to request the device and user
+// verification codes from GitHub.
+type GetCodesRequest struct {
+	ClientID string `json:"client_id"`
+	Scope    string `json:"scope,omitempty"`
+}
+
+// GetCodesResponse contains response parameters when requesting the device
+// and user verification codes from GitHub.
+type GetCodesResponse struct {
+	DeviceCode      string `json:"device_code"`
+	UserCode        string `json:"user_code"`
+	VerificationURI string `json:"verification_uri"`
+	ExpiresIn       int    `json:"expires_in"`
+	Interval        int    `json:"interval"`
+}
+
+// GetAccessToken requests the access token for the authorized user.
+func (df DeviceFlow) GetAccessToken(
+	req *GetAccessTokenRequest,
+) (*GetAccessTokenResponse, error) {
+	uri := df.uri(LoginOAuthAccessTokenPath)
+	resp := new(GetAccessTokenResponse)
+	if err := df.do(http.MethodPost, uri, req, resp); err != nil {
+		return nil, err
+	}
+	return resp, nil
+}
+
+// GetAccessTokenRequest contains input parameters to request access token for
+// the authorized user.
+type GetAccessTokenRequest struct {
+	ClientID   string `json:"client_id"`
+	DeviceCode string `json:"device_code,omitempty"`
+	GrantType  string `json:"grant_type,omitempty"`
+}
+
+// GetAccessTokenResponse contains response parameters when requesting access
+// token for the authorized user.
+type GetAccessTokenResponse struct {
+	AccessToken string `json:"access_token"`
+	TokenType   string `json:"token_type"`
+	Scope       string `json:"scope"`
+}
+
+func (df DeviceFlow) uri(p string) string {
+	result := *df.baseURI
+	result.Path = path.Join(result.Path, p)
+	return result.String()
+}
+
+func (df DeviceFlow) do(method, url string, in, out interface{}) error {
+	body := new(bytes.Buffer)
+	if err := json.NewEncoder(body).Encode(in); err != nil {
+		return err
+	}
+
+	req, err := http.NewRequest(method, url, body)
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Accept", "application/json")
+
+	resp, err := df.client.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	data, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return err
+	}
+
+	if err := df.checkResponse(data); err != nil {
+		return err
+	}
+
+	if err := json.Unmarshal(data, out); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (DeviceFlow) checkResponse(data []byte) error {
+	err := new(ErrResponse)
+	decodeErr := json.Unmarshal(data, err)
+	if decodeErr != nil {
+		return decodeErr
+	}
+	if err.Error() != "" {
+		return err
+	}
+
+	return nil
+}
+
+// ErrResponse is a GitHub error response.
+type ErrResponse struct {
+	Err string `json:"error"`
+}
+
+func (er ErrResponse) Error() string {
+	return er.Err
+}
diff --git a/cli/pkg/github/doc.go b/cli/pkg/github/doc.go
new file mode 100644
index 0000000..03b0ec8
--- /dev/null
+++ b/cli/pkg/github/doc.go
@@ -0,0 +1,6 @@
+// Copyright 2020 Globo.com authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package github contains utilities to work with GitHub.
+package github
diff --git a/cli/pkg/github/github.go b/cli/pkg/github/github.go
new file mode 100644
index 0000000..cfc1ef9
--- /dev/null
+++ b/cli/pkg/github/github.go
@@ -0,0 +1,26 @@
+// Copyright 2020 Globo.com authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package github
+
+import (
+	"net/url"
+)
+
+const (
+	// ClientID is a GitHub App client id.
+	ClientID = "Iv1.d0c686433b9fc26f"
+
+	// GrantTypeDeviceCode is a grant type for the device authorization flow.
+	GrantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code"
+)
+
+// GitHub API paths.
+const (
+	LoginDeviceCodePath       = "login/device/code"
+	LoginOAuthAccessTokenPath = "login/oauth/access_token" // #nosec - just a GitHub API path
+)
+
+// DefaultBaseURI is a default GitHub base URI.
+var DefaultBaseURI, _ = url.Parse("https://github.com")


Test patch:
diff --git a/api/context/context_test.go b/api/context/context_test.go
index 9dd8797..7d78064 100644
--- a/api/context/context_test.go
+++ b/api/context/context_test.go
@@ -1,12 +1,12 @@
 package context_test
 
 import (
-	. "github.com/onsi/ginkgo"
-	. "github.com/onsi/gomega"
-
 	"errors"
 	"time"
 
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+
 	. "github.com/globocom/huskyCI/api/context"
 	"github.com/globocom/huskyCI/api/db"
 	"github.com/globocom/huskyCI/api/types"
@@ -438,6 +438,7 @@ var _ = Describe("Context", func() {
 						TimeOutInSeconds: fakeCaller.expectedIntFromConfig,
 					},
 					DBInstance: &db.MongoRequests{},
+					Cache:      apiConfig.Cache, // cannot be compared due to channels inside the structure
 				}
 				Expect(apiConfig).To(Equal(expectedConfig))
 				Expect(err).To(BeNil())
diff --git a/cli/pkg/github/auth_test.go b/cli/pkg/github/auth_test.go
new file mode 100644
index 0000000..e71c3b4
--- /dev/null
+++ b/cli/pkg/github/auth_test.go
@@ -0,0 +1,153 @@
+// Copyright 2020 Globo.com authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package github
+
+import (
+	"net/http"
+	"net/url"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+	"github.com/onsi/gomega/ghttp"
+)
+
+var _ = Describe("NewDeviceFlow", func() {
+	Context("Normal", func() {
+		It("should return a new GitHub device flow", func() {
+			want := DeviceFlow{
+				baseURI: DefaultBaseURI,
+				client:  http.DefaultClient,
+			}
+			got := NewDeviceFlow(DefaultBaseURI, http.DefaultClient)
+			Expect(got).To(Equal(want))
+		})
+	})
+})
+
+var _ = Describe("DeviceFlow", func() {
+	var (
+		server *ghttp.Server
+		df     DeviceFlow
+	)
+
+	BeforeEach(func() {
+		server = ghttp.NewServer()
+		baseURI, _ := url.Parse(server.URL())
+		df = DeviceFlow{baseURI: baseURI, client: http.DefaultClient}
+	})
+
+	AfterEach(func() { server.Close() })
+
+	Describe("GetCodes", func() {
+		var (
+			req = &GetCodesRequest{
+				ClientID: "client_id",
+				Scope:    "scope",
+			}
+			statusCode int
+			resp       interface{}
+		)
+
+		BeforeEach(func() {
+			server.AppendHandlers(
+				ghttp.CombineHandlers(
+					ghttp.VerifyRequest(http.MethodPost, "/login/device/code"),
+					ghttp.VerifyHeader(http.Header{
+						"Content-Type": []string{"application/json"},
+						"Accept":       []string{"application/json"},
+					}),
+					ghttp.VerifyJSONRepresenting(req),
+					ghttp.RespondWithJSONEncodedPtr(&statusCode, &resp),
+				),
+			)
+		})
+
+		When("The request succeeds", func() {
+			BeforeEach(func() {
+				statusCode = http.StatusOK
+				resp = &GetCodesResponse{
+					DeviceCode:      "device_code",
+					UserCode:        "user_code",
+					VerificationURI: "https://www.example.com/verify",
+					ExpiresIn:       600,
+					Interval:        60,
+				}
+			})
+
+			It("should return device codes without errors", func() {
+				got, err := df.GetCodes(req)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(got).To(Equal(resp))
+			})
+		})
+
+		When("The response is not found", func() {
+			BeforeEach(func() {
+				statusCode = http.StatusNotFound
+				resp = &ErrResponse{Err: "Not Found"}
+			})
+
+			It("should return the not found error", func() {
+				_, err := df.GetCodes(req)
+				Expect(err).To(MatchError("Not Found"))
+			})
+		})
+	})
+
+	Describe("GetAccessToken", func() {
+		var (
+			req = &GetAccessTokenRequest{
+				ClientID:   "client_id",
+				DeviceCode: "device_code",
+				GrantType:  "grant_type",
+			}
+			statusCode int
+			resp       interface{}
+		)
+
+		BeforeEach(func() {
+			server.AppendHandlers(
+				ghttp.CombineHandlers(
+					ghttp.VerifyRequest(http.MethodPost, "/login/oauth/access_token"),
+					ghttp.VerifyHeader(http.Header{
+						"Content-Type": []string{"application/json"},
+						"Accept":       []string{"application/json"},
+					}),
+					ghttp.VerifyJSONRepresenting(req),
+					ghttp.RespondWithJSONEncodedPtr(&statusCode, &resp),
+				),
+			)
+		})
+
+		When("The request succeeds", func() {
+			BeforeEach(func() {
+				statusCode = http.StatusOK
+				resp = &GetAccessTokenResponse{
+					AccessToken: "access_token",
+					TokenType:   "bearer",
+					Scope:       "scope",
+				}
+			})
+
+			It("should return access token without errors", func() {
+				got, err := df.GetAccessToken(req)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(got).To(Equal(resp))
+			})
+		})
+
+		When("The response is not found", func() {
+			BeforeEach(func() {
+				statusCode = http.StatusNotFound
+				resp = &ErrResponse{Err: "Not Found"}
+			})
+
+			It("should return the not found error", func() {
+				_, err := df.GetAccessToken(req)
+				Expect(err).To(MatchError("Not Found"))
+			})
+		})
+	})
+})
diff --git a/cli/pkg/github/github_suite_test.go b/cli/pkg/github/github_suite_test.go
new file mode 100644
index 0000000..d0cbc98
--- /dev/null
+++ b/cli/pkg/github/github_suite_test.go
@@ -0,0 +1,17 @@
+// Copyright 2020 Globo.com authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package github_test
+
+import (
+	"testing"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+)
+
+func TestGithub(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "Github Suite")
+}