diff --git a/.gitignore b/.gitignore index 088acbb..de98ec4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ vendor/ drone-tree-config +.idea diff --git a/README.md b/README.md index 853d2a4..a47e534 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,10 @@ Environment variables: - `PLUGIN_ADDRESS`: Listen address for the plugins webserver. Defaults to `:3000`. - `PLUGIN_SECRET`: Shared secret with drone. You can generate the token using `openssl rand -hex 16`. - `GITHUB_TOKEN`: Github personal access token. Only needs repo rights. See [here][1]. -- `GITHUB_SERVER`: Custom Github server for Github Enterprise +- `AUTH_SERVER`: Custom auth server (uses SERVER if empty) +- `SERVER`: Custom SCM server +- `BITBUCKET_CLIENT`: Alternative to GITHUB_TOKEN, same value as DRONE_BITBUCKET_CLIENT. +- `BITBUCKET_SECRET`: Alternative to GITHUB_TOKEN, same value as DRONE_BITBUCKET_SECRET. If `PLUGIN_CONCAT` is not set, the first `.drone.yml` will be used. diff --git a/cmd/drone-tree-config/main.go b/cmd/drone-tree-config/main.go index 9611d0f..61ed10e 100644 --- a/cmd/drone-tree-config/main.go +++ b/cmd/drone-tree-config/main.go @@ -12,14 +12,17 @@ import ( type ( spec struct { - Concat bool `envconfig:"PLUGIN_CONCAT"` - MaxDepth int `envconfig:"PLUGIN_MAXDEPTH" default:"2"` - Fallback bool `envconfig:"PLUGIN_FALLBACK"` - Debug bool `envconfig:"PLUGIN_DEBUG"` - Address string `envconfig:"PLUGIN_ADDRESS" default:":3000"` - Secret string `envconfig:"PLUGIN_SECRET"` - Token string `envconfig:"GITHUB_TOKEN"` - Server string `envconfig:"GITHUB_SERVER"` + Concat bool `envconfig:"PLUGIN_CONCAT"` + MaxDepth int `envconfig:"PLUGIN_MAXDEPTH" default:"2"` + Fallback bool `envconfig:"PLUGIN_FALLBACK"` + Debug bool `envconfig:"PLUGIN_DEBUG"` + Address string `envconfig:"PLUGIN_ADDRESS" default:":3000"` + Secret string `envconfig:"PLUGIN_SECRET"` + GitHubToken string `envconfig:"GITHUB_TOKEN"` + AuthServer string `envconfig:"AUTH_SERVER"` + Server string `envconfig:"SERVER"` + BitBucketClient string `envconfig:"BITBUCKET_CLIENT"` + BitBucketSecret string `envconfig:"BITBUCKET_SECRET"` } ) @@ -35,17 +38,23 @@ func main() { if spec.Secret == "" { logrus.Fatalln("missing secret key") } - if spec.Token == "" { - logrus.Warnln("missing github token") + if spec.GitHubToken == "" && (spec.BitBucketClient == "" || spec.BitBucketSecret == "") { + logrus.Warnln("missing SCM credentials, e.g. GitHub token") } if spec.Address == "" { spec.Address = ":3000" } + if spec.AuthServer == "" { + spec.AuthServer = spec.Server + } handler := config.Handler( plugin.New( + spec.AuthServer, spec.Server, - spec.Token, + spec.GitHubToken, + spec.BitBucketClient, + spec.BitBucketSecret, spec.Concat, spec.Fallback, spec.MaxDepth, diff --git a/go.mod b/go.mod index f835d0e..05474d3 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/google/uuid v1.1.1 github.com/kelseyhightower/envconfig v1.3.0 github.com/sirupsen/logrus v1.4.1 + github.com/wbrefvem/go-bitbucket v0.0.0-20190128183802-fc08fd046abb golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 // indirect golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a golang.org/x/sync v0.0.0-20190423024810-112230192c58 // indirect diff --git a/go.sum b/go.sum index 779bf02..bed9391 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/wbrefvem/go-bitbucket v0.0.0-20190128183802-fc08fd046abb h1:KrmaSo+FHWBt1H652w/uerwzKvQqh4H7Jgyxm4hz2BQ= +github.com/wbrefvem/go-bitbucket v0.0.0-20190128183802-fc08fd046abb/go.mod h1:Z91j2jYBApRjJ0zlXDCxPrrZR8ohkkd4g0n+Hqs1w0Q= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e h1:bRhVy7zSSasaqNksaRZiA5EEI+Ei4I1nO5Jh72wfHlg= diff --git a/plugin/plugin.go b/plugin/plugin.go index d116411..7998473 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -9,34 +9,40 @@ import ( "strconv" "strings" + "github.com/bitsbeats/drone-tree-config/plugin/scm_clients" "github.com/drone/drone-go/drone" "github.com/drone/drone-go/plugin/config" - "github.com/google/go-github/github" "github.com/google/uuid" "github.com/sirupsen/logrus" - "golang.org/x/oauth2" "gopkg.in/yaml.v2" ) // New creates a drone plugin -func New(server, token string, concat bool, fallback bool, maxDepth int) config.Plugin { - return &plugin{ - server: server, - token: token, - concat: concat, - fallback: fallback, - maxDepth: maxDepth, +func New(authServer string, server string, gitHubToken string, bitBucketClient string, bitBucketSecret string, + concat bool, fallback bool, maxDepth int) config.Plugin { + return &Plugin{ + authServer: authServer, + server: server, + gitHubToken: gitHubToken, + bitBucketClient: bitBucketClient, + bitBucketSecret: bitBucketSecret, + concat: concat, + fallback: fallback, + maxDepth: maxDepth, } } type ( - plugin struct { - server string - token string - concat bool - fallback bool - maxDepth int + Plugin struct { + authServer string + server string + gitHubToken string + bitBucketClient string + bitBucketSecret string + concat bool + fallback bool + maxDepth int } droneConfig struct { @@ -47,35 +53,38 @@ type ( request struct { *config.Request UUID uuid.UUID - Client *github.Client + Client scm_clients.ScmClient } ) var dedupRegex = regexp.MustCompile(`(?ms)(---[\s]*){2,}`) -// Find is called by dorne -func (p *plugin) Find(ctx context.Context, droneRequest *config.Request) (*drone.Config, error) { - uuid := uuid.New() - logrus.Infof("%s %s/%s started", uuid, droneRequest.Repo.Namespace, droneRequest.Repo.Name) - defer logrus.Infof("%s finished", uuid) - - // connect to github - trans := oauth2.NewClient(ctx, oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: p.token}, - )) - var client *github.Client - if p.server == "" { - client = github.NewClient(trans) +func (p *Plugin) NewScmClient(uuid uuid.UUID, repo drone.Repo, ctx context.Context) scm_clients.ScmClient { + var scmClient scm_clients.ScmClient + var err error + if p.gitHubToken != "" { + scmClient, err = scm_clients.NewGitHubClient(uuid, p.server, p.gitHubToken, repo, ctx) + } else if p.bitBucketClient != "" { + scmClient, err = scm_clients.NewBitBucketClient(uuid, p.authServer, p.server, p.bitBucketClient, p.bitBucketSecret, repo) } else { - var err error - client, err = github.NewEnterpriseClient(p.server, p.server, trans) - if err != nil { - logrus.Errorf("%s Unable to connect to Github: '%v'", uuid, err) - return nil, err - } + err = fmt.Errorf("no SCM credentials specified") } + if err != nil { + logrus.Errorf("Unable to connect to SCM server.") + } + return scmClient +} + +// Find is called by drone +func (p *Plugin) Find(ctx context.Context, droneRequest *config.Request) (*drone.Config, error) { + someUuid := uuid.New() + logrus.Infof("%s %s/%s started", someUuid, droneRequest.Repo.Namespace, droneRequest.Repo.Name) + defer logrus.Infof("%s finished", someUuid) + + // connect to scm + client := p.NewScmClient(someUuid, droneRequest.Repo, ctx) - req := request{droneRequest, uuid, client} + req := request{droneRequest, someUuid, client} // get changed files changedFiles, err := p.getGithubChanges(ctx, &req) @@ -86,7 +95,7 @@ func (p *plugin) Find(ctx context.Context, droneRequest *config.Request) (*drone // get drone.yml for changed files or all of them if no changes/cron configData := "" if changedFiles != nil { - configData, err = p.getGithubConfigData(ctx, &req, changedFiles) + configData, err = p.getConfigDataForChanges(ctx, &req, changedFiles) } else if req.Build.Trigger == "@cron" { logrus.Warnf("%s @cron, rebuilding all", req.UUID) configData, err = p.getAllConfigData(ctx, &req, "/", 0) @@ -111,7 +120,7 @@ func (p *plugin) Find(ctx context.Context, droneRequest *config.Request) (*drone } // getGithubChanges tries to get a list of changed files from github -func (p *plugin) getGithubChanges(ctx context.Context, req *request) ([]string, error) { +func (p *Plugin) getGithubChanges(ctx context.Context, req *request) ([]string, error) { var changedFiles []string if req.Build.Trigger == "@cron" { @@ -124,14 +133,9 @@ func (p *plugin) getGithubChanges(ctx context.Context, req *request) ([]string, logrus.Errorf("%s unable to get pull request id %v", req.UUID, err) return nil, err } - opts := github.ListOptions{} - files, _, err := req.Client.PullRequests.ListFiles(ctx, req.Repo.Namespace, req.Repo.Name, pullRequestID, &opts) + changedFiles, err = req.Client.ChangedFilesInPullRequest(ctx, pullRequestID) if err != nil { logrus.Errorf("%s unable to fetch diff for Pull request %v", req.UUID, err) - return nil, err - } - for _, file := range files { - changedFiles = append(changedFiles, *file.Filename) } } else { // use diff to get changed files @@ -139,14 +143,12 @@ func (p *plugin) getGithubChanges(ctx context.Context, req *request) ([]string, if before == "0000000000000000000000000000000000000000" || before == "" { before = fmt.Sprintf("%s~1", req.Build.After) } - changes, _, err := req.Client.Repositories.CompareCommits(ctx, req.Repo.Namespace, req.Repo.Name, before, req.Build.After) + var err error + changedFiles, err = req.Client.ChangedFilesInDiff(ctx, before, req.Build.After) if err != nil { logrus.Errorf("%s unable to fetch diff: '%v'", req.UUID, err) return nil, err } - for _, file := range changes.Files { - changedFiles = append(changedFiles, *file.Filename) - } } if len(changedFiles) > 0 { @@ -159,21 +161,13 @@ func (p *plugin) getGithubChanges(ctx context.Context, req *request) ([]string, } // getGithubFile downloads a file from github -func (p *plugin) getGithubFile(ctx context.Context, req *request, file string) (content string, err error) { +func (p *Plugin) getGithubFile(ctx context.Context, req *request, file string) (content string, err error) { logrus.Debugf("%s checking %s/%s %s", req.UUID, req.Repo.Namespace, req.Repo.Name, file) - ref := github.RepositoryContentGetOptions{Ref: req.Build.After} - data, _, _, err := req.Client.Repositories.GetContents(ctx, req.Repo.Namespace, req.Repo.Name, file, &ref) - if data == nil { - err = fmt.Errorf("failed to get %s: is not a file", file) - } - if err != nil { - return "", err - } - return data.GetContent() + return req.Client.GetFileContents(ctx, file, req.Build.After) } // getGithubDroneConfig downloads a drone config and validates it -func (p *plugin) getGithubDroneConfig(ctx context.Context, req *request, file string) (configData string, critical bool, err error) { +func (p *Plugin) getGithubDroneConfig(ctx context.Context, req *request, file string) (configData string, critical bool, err error) { fileContent, err := p.getGithubFile(ctx, req, file) if err != nil { logrus.Debugf("%s skipping: unable to load file: %s %v", req.UUID, file, err) @@ -196,8 +190,8 @@ func (p *plugin) getGithubDroneConfig(ctx context.Context, req *request, file st return fileContent, false, nil } -// getGithubConfigData scans a repository based on the changed files -func (p *plugin) getGithubConfigData(ctx context.Context, req *request, changedFiles []string) (configData string, err error) { +// getConfigDataForChanges scans a repository based on the changed files +func (p *Plugin) getConfigDataForChanges(ctx context.Context, req *request, changedFiles []string) (configData string, err error) { // collect drone.yml files configData = "" cache := map[string]bool{} @@ -241,10 +235,9 @@ func (p *plugin) getGithubConfigData(ctx context.Context, req *request, changedF return configData, nil } -// getAllConfigData searches for all or fist 'drone.yml' in the repo -func (p *plugin) getAllConfigData(ctx context.Context, req *request, dir string, depth int) (configData string, err error) { - ref := github.RepositoryContentGetOptions{Ref: req.Build.After} - _, ls, _, err := req.Client.Repositories.GetContents(ctx, req.Repo.Namespace, req.Repo.Name, dir, &ref) +// getAllConfigData searches for all or first 'drone.yml' in the repo +func (p *Plugin) getAllConfigData(ctx context.Context, req *request, dir string, depth int) (configData string, err error) { + ls, err := req.Client.GetFileListing(ctx, dir, req.Build.After) if err != nil { return "", err } @@ -255,15 +248,15 @@ func (p *plugin) getAllConfigData(ctx context.Context, req *request, dir string, } depth += 1 - // check recursivly for drone.yml + // check recursively for drone.yml configData = "" for _, f := range ls { var fileContent string - if *f.Type == "dir" { - fileContent, _ = p.getAllConfigData(ctx, req, *f.Path, depth) - } else if *f.Type == "file" && *f.Name == req.Repo.Config { + if f.Type == "dir" { + fileContent, _ = p.getAllConfigData(ctx, req, f.Path, depth) + } else if f.Type == "file" && f.Name == req.Repo.Config { var critical bool - fileContent, critical, err = p.getGithubDroneConfig(ctx, req, *f.Path) + fileContent, critical, err = p.getGithubDroneConfig(ctx, req, f.Path) if critical { return "", err } @@ -277,12 +270,11 @@ func (p *plugin) getAllConfigData(ctx context.Context, req *request, dir string, } return configData, nil - } // droneConfigAppend concats multiple 'drone.yml's to a multi-machine pipeline // see https://docs.drone.io/user-guide/pipeline/multi-machine/ -func (p *plugin) droneConfigAppend(droneConfig string, appends ...string) string { +func (p *Plugin) droneConfigAppend(droneConfig string, appends ...string) string { for _, a := range appends { a = strings.Trim(a, " \n") if a != "" { diff --git a/plugin/plugin_test.go b/plugin/plugin_test.go index fd87a45..8fecd78 100644 --- a/plugin/plugin_test.go +++ b/plugin/plugin_test.go @@ -35,7 +35,7 @@ func TestPlugin(t *testing.T) { Config: ".drone.yml", }, } - plugin := New(ts.URL, mockToken, false, true, 2) + plugin := New("", ts.URL, mockToken, "", "", false, true, 2) droneConfig, err := plugin.Find(noContext, req) if err != nil { t.Error(err) @@ -63,7 +63,7 @@ func TestConcat(t *testing.T) { Config: ".drone.yml", }, } - plugin := New(ts.URL, mockToken, true, true, 2) + plugin := New("", ts.URL, mockToken, "", "", true, true, 2) droneConfig, err := plugin.Find(noContext, req) if err != nil { t.Error(err) @@ -91,7 +91,7 @@ func TestPullRequest(t *testing.T) { Config: ".drone.yml", }, } - plugin := New(ts.URL, mockToken, true, true, 2) + plugin := New("", ts.URL, mockToken, "", "", true, true, 2) droneConfig, err := plugin.Find(noContext, req) if err != nil { t.Error(err) @@ -119,7 +119,7 @@ func TestCron(t *testing.T) { Config: ".drone.yml", }, } - plugin := New(ts.URL, mockToken, false, true, 2) + plugin := New("", ts.URL, mockToken, "", "", false, true, 2) droneConfig, err := plugin.Find(noContext, req) if err != nil { t.Error(err) @@ -147,7 +147,7 @@ func TestCronConcat(t *testing.T) { Config: ".drone.yml", }, } - plugin := New(ts.URL, mockToken, true, true, 2) + plugin := New("", ts.URL, mockToken, "", "", true, true, 2) droneConfig, err := plugin.Find(noContext, req) if err != nil { t.Error(err) @@ -155,7 +155,7 @@ func TestCronConcat(t *testing.T) { } if want, got := "---\nkind: pipeline\nname: default\n\nsteps:\n- name: frontend\n image: node\n commands:\n - npm install\n - npm test\n\n- name: backend\n image: golang\n commands:\n - go build\n - go test\n---\nkind: pipeline\nname: default\n\nsteps:\n- name: build\n image: golang\n commands:\n - go build\n - go test -short\n\n- name: integration\n image: golang\n commands:\n - go test -v\n", droneConfig.Data; want != got { - t.Errorf("Want %q got %q", want, got) + t.Errorf("Want\n %q\ngot\n %q", want, got) } } @@ -163,37 +163,42 @@ func testMux() *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("/repos/foosinn/dronetest/contents/", func(w http.ResponseWriter, r *http.Request) { - f, _ := os.Open("testdata/root.json") + f, _ := os.Open("testdata/github/root.json") _, _ = io.Copy(w, f) }) mux.HandleFunc("/repos/foosinn/dronetest/compare/2897b31ec3a1b59279a08a8ad54dc360686327f7...8ecad91991d5da985a2a8dd97cc19029dc1c2899", func(w http.ResponseWriter, r *http.Request) { - f, _ := os.Open("testdata/compare.json") + f, _ := os.Open("testdata/github/compare.json") _, _ = io.Copy(w, f) }) mux.HandleFunc("/repos/foosinn/dronetest/contents/a/b/.drone.yml", func(w http.ResponseWriter, r *http.Request) { - f, _ := os.Open("testdata/a_b_.drone.yml.json") + f, _ := os.Open("testdata/github/a_b_.drone.yml.json") _, _ = io.Copy(w, f) }) mux.HandleFunc("/repos/foosinn/dronetest/contents/.drone.yml", func(w http.ResponseWriter, r *http.Request) { - f, _ := os.Open("testdata/.drone.yml.json") + f, _ := os.Open("testdata/github/.drone.yml.json") _, _ = io.Copy(w, f) }) mux.HandleFunc("/repos/foosinn/dronetest/pulls/3/files", func(w http.ResponseWriter, r *http.Request) { - f, _ := os.Open("testdata/pull_3_files.json") + f, _ := os.Open("testdata/github/pull_3_files.json") _, _ = io.Copy(w, f) }) mux.HandleFunc("/repos/foosinn/dronetest/contents/afolder/.drone.yml", func(w http.ResponseWriter, r *http.Request) { - f, _ := os.Open("testdata/afolder_.drone.yml.json") + f, _ := os.Open("testdata/github/afolder_.drone.yml.json") _, _ = io.Copy(w, f) }) mux.HandleFunc("/repos/foosinn/dronetest/contents/afolder", func(w http.ResponseWriter, r *http.Request) { - f, _ := os.Open("testdata/afolder.json") + f, _ := os.Open("testdata/github/afolder.json") + _, _ = io.Copy(w, f) + }) + mux.HandleFunc("/repos/foosinn/dronetest/contents/afolder/abfolder", + func(w http.ResponseWriter, r *http.Request) { + f, _ := os.Open("testdata/github/afolder_abfolder.json") _, _ = io.Copy(w, f) }) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { diff --git a/plugin/scm_clients/bitbucket_client.go b/plugin/scm_clients/bitbucket_client.go new file mode 100644 index 0000000..c63c855 --- /dev/null +++ b/plugin/scm_clients/bitbucket_client.go @@ -0,0 +1,184 @@ +package scm_clients + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "github.com/drone/drone-go/drone" + "github.com/google/uuid" + "github.com/sirupsen/logrus" + "github.com/wbrefvem/go-bitbucket" + "io/ioutil" + "log" + "net/http" + "net/url" + "path/filepath" + "strings" +) + +type BitBucketClient struct { + delegate *bitbucket.APIClient + basePath string + authorization string + repo drone.Repo +} + +type BitBucketCredentials struct { + AccessToken string `json:"access_token"` +} + +func NewBitBucketClient(someUuid uuid.UUID, authServer string, server string, + clientID string, clientSecret string, repo drone.Repo) (ScmClient, error) { + + form := url.Values{} + form.Add("grant_type", "client_credentials") + req, err := http.NewRequest("POST", authServer+"/site/oauth2/access_token", strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Add("Authorization", "Basic "+basicAuth(clientID, clientSecret)) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + response, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + var creds BitBucketCredentials + if err = json.NewDecoder(response.Body).Decode(&creds); err != nil && creds.AccessToken != "" { + return nil, err + } + logrus.Infof("%s Authenticated with BitBucket: '%v'", someUuid, authServer) + + authorization := "Bearer " + creds.AccessToken + conf := bitbucket.NewConfiguration() + conf.Host = server + conf.Scheme = "https" + conf.AddDefaultHeader("Authorization", authorization) + + basePath := server + "/2.0" + client := bitbucket.NewAPIClient(conf) + client.ChangeBasePath(basePath) + logrus.Infof("%s Created BitBucket API client: '%v'", someUuid, server) + + return BitBucketClient{ + delegate: client, + basePath: basePath, + authorization: authorization, + repo: repo, + }, nil +} + +func (s BitBucketClient) ChangedFilesInPullRequest(ctx context.Context, pullRequestID int) ([]string, error) { + var changedFiles []string + // Custom implementation because the BitBucket client does not specify the right type + requestUrl := fmt.Sprintf("%v/repositories/%v/%v/pullrequests/%v/diffstat", + s.basePath, s.repo.Namespace, s.repo.Name, pullRequestID) + request, err := http.NewRequest("GET", requestUrl, nil) + if err != nil { + return []string{}, fmt.Errorf("failed to construct request for pull request %v", pullRequestID) + } + request.Header.Add("Authorization", s.authorization) + response, err := http.DefaultClient.Do(request) + + if response == nil || err != nil { + return []string{}, fmt.Errorf("failed to get %v: is not a pull request", pullRequestID) + } + var diffStat bitbucket.PaginatedDiffstats + if err = json.NewDecoder(response.Body).Decode(&diffStat); err != nil { + return nil, err + } + for _, fileDiff := range diffStat.Values { + if fileDiff.Status == "removed" || fileDiff.Status == "renamed" { + changedFiles = append(changedFiles, fileDiff.Old.Path) + } + if fileDiff.Status == "modified" || fileDiff.Status == "added" || fileDiff.Status == "renamed" { + changedFiles = append(changedFiles, fileDiff.New.Path) + } + } + return changedFiles, nil +} + +func (s BitBucketClient) ChangedFilesInDiff(ctx context.Context, base string, head string) ([]string, error) { + var changedFiles []string + spec := fmt.Sprintf("%s..%s", base, head) + diffStat, _, err := s.delegate.DefaultApi.RepositoriesUsernameRepoSlugDiffstatSpecGet( + ctx, s.repo.Namespace, s.repo.Name, spec, make(map[string]interface{})) + if err != nil { + return nil, err + } + if diffStat.Values == nil { + return nil, err + } + for _, fileDiff := range diffStat.Values { + if fileDiff.Status == "removed" || fileDiff.Status == "renamed" { + changedFiles = append(changedFiles, fileDiff.Old.Path) + } + if fileDiff.Status == "modified" || fileDiff.Status == "added" || fileDiff.Status == "renamed" { + changedFiles = append(changedFiles, fileDiff.New.Path) + } + } + return changedFiles, nil +} + +func (s BitBucketClient) GetFileContents(ctx context.Context, path string, commitRef string) (content string, err error) { + // Custom implementation because the BitBucket client always tries to deserialize the file as JSON + requestUrl := fmt.Sprintf("%v/repositories/%v/%v/src/%v/%v", + s.basePath, s.repo.Namespace, s.repo.Name, commitRef, path) + request, err := http.NewRequest("GET", requestUrl, nil) + if err != nil { + return "", fmt.Errorf("failed to construct request for %s", path) + } + request.Header.Add("Authorization", s.authorization) + response, err := http.DefaultClient.Do(request) + + if response == nil || err != nil { + return "", fmt.Errorf("failed to get %s: is not a file", path) + } + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to get %s: status code %v", path, response.StatusCode) + } + bodyBytes, err := ioutil.ReadAll(response.Body) + if err != nil { + log.Fatal(err) + } + bodyString := string(bodyBytes) + return bodyString, nil +} + +func (s BitBucketClient) GetFileListing(ctx context.Context, path string, commitRef string) ( + fileListing []FileListingEntry, err error) { + opts := make(map[string]interface{}) + opts["format"] = "meta" + ls, _, err := s.delegate.RepositoriesApi.RepositoriesUsernameRepoSlugSrcNodePathGet( + ctx, s.repo.Namespace, commitRef, path+"/", s.repo.Name, opts) + + var result []FileListingEntry + + if err != nil { + return result, err + } + + for _, f := range ls.Values { + var fileType string + if f.Type_ == "commit_file" { + fileType = "file" + } else if f.Type_ == "commit_directory" { + fileType = "dir" + } else { + continue + } + fileName := filepath.Base(f.Path) + fileListingEntry := FileListingEntry{ + Path: f.Path, + Name: fileName, + Type: fileType, + } + result = append(result, fileListingEntry) + } + return result, err +} + +func basicAuth(username string, password string) string { + auth := username + ":" + password + return base64.StdEncoding.EncodeToString([]byte(auth)) +} diff --git a/plugin/scm_clients/bitbucket_client_test.go b/plugin/scm_clients/bitbucket_client_test.go new file mode 100644 index 0000000..a228c19 --- /dev/null +++ b/plugin/scm_clients/bitbucket_client_test.go @@ -0,0 +1,103 @@ +package scm_clients + +import ( + "github.com/drone/drone-go/drone" + "github.com/google/uuid" + "github.com/sirupsen/logrus" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +const mockClientId = "abra" +const mockSecret = "c4d4br4" + +func TestBitBucket_GetFileContents(t *testing.T) { + ts := httptest.NewServer(testMuxBitBucket()) + defer ts.Close() + client, err := createBitBucketClient(ts.URL) + if err != nil { + t.Error(err) + return + } + BaseTest_GetFileContents(t, client) +} + +func TestBitBucket_ChangedFilesInDiff(t *testing.T) { + ts := httptest.NewServer(testMuxBitBucket()) + defer ts.Close() + client, err := createBitBucketClient(ts.URL) + if err != nil { + t.Error(err) + return + } + BaseTest_ChangedFilesInDiff(t, client) +} + +func TestBitBucket_ChangedFilesInPullRequest(t *testing.T) { + ts := httptest.NewServer(testMuxBitBucket()) + defer ts.Close() + client, err := createBitBucketClient(ts.URL) + if err != nil { + t.Error(err) + return + } + + BaseTest_ChangedFilesInPullRequest(t, client) +} + +func TestBitBucket_GetFileListing(t *testing.T) { + ts := httptest.NewServer(testMuxBitBucket()) + defer ts.Close() + client, err := createBitBucketClient(ts.URL) + if err != nil { + t.Error(err) + return + } + + BaseTest_GetFileListing(t, client) +} + +func createBitBucketClient(server string) (ScmClient, error) { + repo := drone.Repo{ + Namespace: "foosinn", + Name: "dronetest", + Slug: "foosinn/dronetest", + } + return NewBitBucketClient(uuid.New(), server, server, mockClientId, mockSecret, repo) +} + +func testMuxBitBucket() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("/site/oauth2/access_token", + func(w http.ResponseWriter, r *http.Request) { + f, _ := os.Open("../testdata/bitbucket/token.json") + _, _ = io.Copy(w, f) + }) + mux.HandleFunc("/2.0/repositories/foosinn/dronetest/diffstat/2897b31ec3a1b59279a08a8ad54dc360686327f7..8ecad91991d5da985a2a8dd97cc19029dc1c2899", + func(w http.ResponseWriter, r *http.Request) { + f, _ := os.Open("../testdata/bitbucket/compare.json") + _, _ = io.Copy(w, f) + }) + mux.HandleFunc("/2.0/repositories/foosinn/dronetest/pullrequests/3/diffstat", + func(w http.ResponseWriter, r *http.Request) { + f, _ := os.Open("../testdata/bitbucket/pull_3_files.json") + _, _ = io.Copy(w, f) + }) + mux.HandleFunc("/2.0/repositories/foosinn/dronetest/src/8ecad91991d5da985a2a8dd97cc19029dc1c2899/afolder/.drone.yml", + func(w http.ResponseWriter, r *http.Request) { + f, _ := os.Open("../testdata/bitbucket/afolder_.drone.yml") + _, _ = io.Copy(w, f) + }) + mux.HandleFunc("/2.0/repositories/foosinn/dronetest/src/8ecad91991d5da985a2a8dd97cc19029dc1c2899/afolder/", + func(w http.ResponseWriter, r *http.Request) { + f, _ := os.Open("../testdata/bitbucket/afolder.json") + _, _ = io.Copy(w, f) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + logrus.Errorf("Url not found: %s", r.URL) + }) + return mux +} diff --git a/plugin/scm_clients/github_client.go b/plugin/scm_clients/github_client.go new file mode 100644 index 0000000..50bcf06 --- /dev/null +++ b/plugin/scm_clients/github_client.go @@ -0,0 +1,109 @@ +package scm_clients + +import ( + "context" + "fmt" + "github.com/drone/drone-go/drone" + "github.com/google/go-github/github" + "github.com/google/uuid" + "github.com/sirupsen/logrus" + "golang.org/x/oauth2" +) + +type GithubClient struct { + delegate *github.Client + repo drone.Repo +} + +func NewGitHubClient(uuid uuid.UUID, server string, token string, repo drone.Repo, ctx context.Context) (ScmClient, error) { + trans := oauth2.NewClient(ctx, oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + )) + var client *github.Client + if server == "" { + client = github.NewClient(trans) + } else { + var err error + client, err = github.NewEnterpriseClient(server, server, trans) + if err != nil { + logrus.Errorf("%s Unable to connect to Github: '%v'", uuid, err) + return nil, err + } + } + return GithubClient{ + delegate: client, + repo: repo, + }, nil +} + +func (s GithubClient) ChangedFilesInPullRequest(ctx context.Context, pullRequestID int) ([]string, error) { + var changedFiles []string + files, _, err := s.listFiles(ctx, pullRequestID) + if err != nil { + return nil, err + } + for _, file := range files { + changedFiles = append(changedFiles, *file.Filename) + } + return changedFiles, nil +} + +func (s GithubClient) ChangedFilesInDiff(ctx context.Context, base string, head string) ([]string, error) { + var changedFiles []string + changes, _, err := s.compareCommits(ctx, base, head) + if err != nil { + return nil, err + } + for _, file := range changes.Files { + changedFiles = append(changedFiles, *file.Filename) + } + return changedFiles, nil +} + +func (s GithubClient) GetFileContents(ctx context.Context, path string, commitRef string) (content string, err error) { + data, _, _, err := s.getContents(ctx, path, commitRef) + if data == nil { + err = fmt.Errorf("failed to get %s: is not a file", path) + } + if err != nil { + return "", err + } + return data.GetContent() +} + +func (s GithubClient) GetFileListing(ctx context.Context, path string, commitRef string) ( + fileListing []FileListingEntry, err error) { + _, ls, _, err := s.getContents(ctx, path, commitRef) + var result []FileListingEntry + + if err != nil { + return result, err + } + + for _, f := range ls { + fileListingEntry := FileListingEntry{ + Path: *f.Path, + Name: *f.Name, + Type: *f.Type, + } + result = append(result, fileListingEntry) + } + return result, err +} + +func (s GithubClient) listFiles(ctx context.Context, number int) ( + []*github.CommitFile, *github.Response, error) { + opts := &github.ListOptions{} + return s.delegate.PullRequests.ListFiles(ctx, s.repo.Namespace, s.repo.Name, number, opts) +} + +func (s GithubClient) compareCommits(ctx context.Context, base, head string) ( + *github.CommitsComparison, *github.Response, error) { + return s.delegate.Repositories.CompareCommits(ctx, s.repo.Namespace, s.repo.Name, base, head) +} + +func (s GithubClient) getContents(ctx context.Context, path string, commitRef string) ( + fileContent *github.RepositoryContent, directoryContent []*github.RepositoryContent, resp *github.Response, err error) { + opts := &github.RepositoryContentGetOptions{Ref: commitRef} + return s.delegate.Repositories.GetContents(ctx, s.repo.Namespace, s.repo.Name, path, opts) +} diff --git a/plugin/scm_clients/github_client_test.go b/plugin/scm_clients/github_client_test.go new file mode 100644 index 0000000..d4854ff --- /dev/null +++ b/plugin/scm_clients/github_client_test.go @@ -0,0 +1,113 @@ +package scm_clients + +import ( + "github.com/drone/drone-go/drone" + "github.com/google/uuid" + "github.com/sirupsen/logrus" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +const mockGithubToken = "7535706b694c63526c6e4f5230374243" + +func TestGithubClient_GetFileContents(t *testing.T) { + ts := httptest.NewServer(testMuxGithub()) + defer ts.Close() + client, err := createGithubClient(ts.URL) + if err != nil { + t.Error(err) + return + } + BaseTest_GetFileContents(t, client) +} + +func TestGithubClient_ChangedFilesInDiff(t *testing.T) { + ts := httptest.NewServer(testMuxGithub()) + defer ts.Close() + client, err := createGithubClient(ts.URL) + if err != nil { + t.Error(err) + return + } + BaseTest_ChangedFilesInDiff(t, client) +} + +func TestGithubClient_ChangedFilesInPullRequest(t *testing.T) { + ts := httptest.NewServer(testMuxGithub()) + defer ts.Close() + client, err := createGithubClient(ts.URL) + if err != nil { + t.Error(err) + return + } + + BaseTest_ChangedFilesInPullRequest(t, client) +} + +func TestGithubClient_GetFileListing(t *testing.T) { + ts := httptest.NewServer(testMuxGithub()) + defer ts.Close() + client, err := createGithubClient(ts.URL) + if err != nil { + t.Error(err) + return + } + + BaseTest_GetFileListing(t, client) +} + +func createGithubClient(server string) (ScmClient, error) { + someUuid := uuid.New() + repo := drone.Repo{ + Namespace: "foosinn", + Name: "dronetest", + Slug: "foosinn/dronetest", + } + return NewGitHubClient(someUuid, server, mockGithubToken, repo, noContext) +} + +func testMuxGithub() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("/repos/foosinn/dronetest/contents/", + func(w http.ResponseWriter, r *http.Request) { + f, _ := os.Open("../testdata/github/root.json") + _, _ = io.Copy(w, f) + }) + mux.HandleFunc("/repos/foosinn/dronetest/compare/2897b31ec3a1b59279a08a8ad54dc360686327f7...8ecad91991d5da985a2a8dd97cc19029dc1c2899", + func(w http.ResponseWriter, r *http.Request) { + f, _ := os.Open("../testdata/github/compare.json") + _, _ = io.Copy(w, f) + }) + mux.HandleFunc("/repos/foosinn/dronetest/contents/a/b/.drone.yml", + func(w http.ResponseWriter, r *http.Request) { + f, _ := os.Open("../testdata/github/a_b_.drone.yml.json") + _, _ = io.Copy(w, f) + }) + mux.HandleFunc("/repos/foosinn/dronetest/contents/.drone.yml", + func(w http.ResponseWriter, r *http.Request) { + f, _ := os.Open("../testdata/github/.drone.yml.json") + _, _ = io.Copy(w, f) + }) + mux.HandleFunc("/repos/foosinn/dronetest/pulls/3/files", + func(w http.ResponseWriter, r *http.Request) { + f, _ := os.Open("../testdata/github/pull_3_files.json") + _, _ = io.Copy(w, f) + }) + mux.HandleFunc("/repos/foosinn/dronetest/contents/afolder/.drone.yml", + func(w http.ResponseWriter, r *http.Request) { + f, _ := os.Open("../testdata/github/afolder_.drone.yml.json") + _, _ = io.Copy(w, f) + }) + mux.HandleFunc("/repos/foosinn/dronetest/contents/afolder", + func(w http.ResponseWriter, r *http.Request) { + f, _ := os.Open("../testdata/github/afolder.json") + _, _ = io.Copy(w, f) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + logrus.Errorf("Url not found: %s", r.URL) + }) + return mux +} diff --git a/plugin/scm_clients/scm_client.go b/plugin/scm_clients/scm_client.go new file mode 100644 index 0000000..5d29446 --- /dev/null +++ b/plugin/scm_clients/scm_client.go @@ -0,0 +1,20 @@ +package scm_clients + +import ( + "context" +) + +type FileListingEntry struct { + Type string + Name string + Path string +} + +type ScmClient interface { + ChangedFilesInPullRequest(ctx context.Context, pullRequestID int) ([]string, error) + ChangedFilesInDiff(ctx context.Context, base string, head string) ([]string, error) + GetFileContents(ctx context.Context, path string, commitRef string) ( + fileContent string, err error) + GetFileListing(ctx context.Context, path string, commitRef string) ( + fileListing []FileListingEntry, err error) +} diff --git a/plugin/scm_clients/scm_client_test.go b/plugin/scm_clients/scm_client_test.go new file mode 100644 index 0000000..635c491 --- /dev/null +++ b/plugin/scm_clients/scm_client_test.go @@ -0,0 +1,72 @@ +package scm_clients + +import ( + "context" + "reflect" + "strings" + "testing" +) + +var noContext = context.Background() + +func BaseTest_GetFileContents(t *testing.T, client ScmClient) { + actualContent, err := client.GetFileContents(noContext, "afolder/.drone.yml", "8ecad91991d5da985a2a8dd97cc19029dc1c2899") + actualContent = strings.Replace(actualContent, "\r", "", -1) + if err != nil { + t.Error(err) + return + } + + if want, got := "kind: pipeline\nname: default\n\nsteps:\n- name: build\n image: golang\n commands:\n - go build\n - go test -short\n\n- name: integration\n image: golang\n commands:\n - go test -v\n", actualContent; want != got { + t.Errorf("Test failed:\n want %q\n got %q", want, got) + } +} + +func BaseTest_ChangedFilesInDiff(t *testing.T, client ScmClient) { + actualFiles, err := client.ChangedFilesInDiff(noContext, "2897b31ec3a1b59279a08a8ad54dc360686327f7", "8ecad91991d5da985a2a8dd97cc19029dc1c2899") + if err != nil { + t.Error(err) + return + } + + expectedFiles := []string{ + "a/b/c/d/file", + } + + if want, got := expectedFiles, actualFiles; !reflect.DeepEqual(want, got) { + t.Errorf("Test failed:\n want %q\n got %q", want, got) + } +} + +func BaseTest_ChangedFilesInPullRequest(t *testing.T, client ScmClient) { + actualFiles, err := client.ChangedFilesInPullRequest(noContext, 3) + if err != nil { + t.Error(err) + return + } + + expectedFiles := []string{ + "e/f/g/h/.drone.yml", + } + + if want, got := expectedFiles, actualFiles; !reflect.DeepEqual(want, got) { + t.Errorf("Test failed:\n want %q\n got %q", want, got) + } +} + +func BaseTest_GetFileListing(t *testing.T, client ScmClient) { + actualFiles, err := client.GetFileListing(noContext, "afolder", "8ecad91991d5da985a2a8dd97cc19029dc1c2899") + if err != nil { + t.Error(err) + return + } + + expectedFiles := []FileListingEntry{ + {Type: "file", Path: "afolder/.drone.yml", Name: ".drone.yml"}, + {Type: "dir", Path: "afolder/abfolder", Name: "abfolder"}, + } + + if want, got := expectedFiles, actualFiles; !reflect.DeepEqual(want, got) { + t.Errorf("Test failed:\n want %q\n got %q", want, got) + } +} diff --git a/plugin/testdata/bitbucket/afolder.json b/plugin/testdata/bitbucket/afolder.json new file mode 100644 index 0000000..ec96342 --- /dev/null +++ b/plugin/testdata/bitbucket/afolder.json @@ -0,0 +1,55 @@ +{ + "pagelen": 500, + "values": [ + { + "path": "afolder/.drone.yml", + "type": "commit_file", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/foosinn/dronetest/src/e69de29bb2d1d6434b8b29ae775ad8c2e48c5391/afolder/.drone.yml" + }, + "meta": { + "href": "https://api.bitbucket.org/2.0/repositories/foosinn/dronetest/src/e69de29bb2d1d6434b8b29ae775ad8c2e48c5391/afolder/.drone.yml?format=meta" + } + }, + "commit": { + "type": "commit", + "hash": "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/foosinn/dronetest/commit/e69de29bb2d1d6434b8b29ae775ad8c2e48c5391" + }, + "html": { + "href": "https://bitbucket.org/foosinn/dronetest/commits/e69de29bb2d1d6434b8b29ae775ad8c2e48c5391" + } + } + } + }, + { + "path": "afolder/abfolder", + "type": "commit_directory", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/foosinn/dronetest/src/e69de29bb2d1d6434b8b29ae775ad8c2e48c5391/afolder/abfolder/" + }, + "meta": { + "href": "https://api.bitbucket.org/2.0/repositories/foosinn/dronetest/src/e69de29bb2d1d6434b8b29ae775ad8c2e48c5391/afolder/abfolder/?format=meta" + } + }, + "commit": { + "type": "commit", + "hash": "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/foosinn/dronetest/commit/e69de29bb2d1d6434b8b29ae775ad8c2e48c5391" + }, + "html": { + "href": "https://bitbucket.org/foosinn/dronetest/commits/e69de29bb2d1d6434b8b29ae775ad8c2e48c5391" + } + } + } + } + ], + "page": 1, + "size": 2 +} \ No newline at end of file diff --git a/plugin/testdata/bitbucket/afolder_.drone.yml b/plugin/testdata/bitbucket/afolder_.drone.yml new file mode 100644 index 0000000..6cc38e8 --- /dev/null +++ b/plugin/testdata/bitbucket/afolder_.drone.yml @@ -0,0 +1,14 @@ +kind: pipeline +name: default + +steps: +- name: build + image: golang + commands: + - go build + - go test -short + +- name: integration + image: golang + commands: + - go test -v diff --git a/plugin/testdata/bitbucket/compare.json b/plugin/testdata/bitbucket/compare.json new file mode 100644 index 0000000..1e3ccc8 --- /dev/null +++ b/plugin/testdata/bitbucket/compare.json @@ -0,0 +1,31 @@ +{ + "pagelen": 500, + "values": [ + { + "status": "modified", + "old": { + "path": "a/b/c/d/file", + "type": "commit_file", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/foosinn/dronetest/src/2897b31ec3a1b59279a08a8ad54dc360686327f7/a/b/c/d/file" + } + } + }, + "lines_removed": 0, + "lines_added": 2, + "new": { + "path": "a/b/c/d/file", + "type": "commit_file", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/foosinn/dronetest/src/8ecad91991d5da985a2a8dd97cc19029dc1c2899/a/b/c/d/file" + } + } + }, + "type": "diffstat" + } + ], + "page": 1, + "size": 1 +} diff --git a/plugin/testdata/bitbucket/pull_3_files.json b/plugin/testdata/bitbucket/pull_3_files.json new file mode 100644 index 0000000..9d53b4d --- /dev/null +++ b/plugin/testdata/bitbucket/pull_3_files.json @@ -0,0 +1,31 @@ +{ + "pagelen": 500, + "values": [ + { + "status": "modified", + "old": { + "path": "e/f/g/h/.drone.yml", + "type": "commit_file", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/foosinn/dronetest/src/e69de29bb2d1d6434b8b29ae775ad8c2e48c5391/e/f/g/h/.drone.yml" + } + } + }, + "lines_removed": 0, + "lines_added": 1, + "new": { + "path": "e/f/g/h/.drone.yml", + "type": "commit_file", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/foosinn/dronetest/src/e69de29bb2d1d6434b8b29ae775ad8c2e48c5391/e/f/g/h/.drone.yml" + } + } + }, + "type": "diffstat" + } + ], + "page": 1, + "size": 1 +} \ No newline at end of file diff --git a/plugin/testdata/bitbucket/token.json b/plugin/testdata/bitbucket/token.json new file mode 100644 index 0000000..cdf5bc0 --- /dev/null +++ b/plugin/testdata/bitbucket/token.json @@ -0,0 +1,4 @@ +{ + "access_token": "7535706b694c63526c6e4f5230374243", + "other_things": "Don't care" +} \ No newline at end of file diff --git a/plugin/testdata/.drone.yml.json b/plugin/testdata/github/.drone.yml.json similarity index 100% rename from plugin/testdata/.drone.yml.json rename to plugin/testdata/github/.drone.yml.json diff --git a/plugin/testdata/a_b_.drone.yml.json b/plugin/testdata/github/a_b_.drone.yml.json similarity index 100% rename from plugin/testdata/a_b_.drone.yml.json rename to plugin/testdata/github/a_b_.drone.yml.json diff --git a/plugin/testdata/afolder.json b/plugin/testdata/github/afolder.json similarity index 52% rename from plugin/testdata/afolder.json rename to plugin/testdata/github/afolder.json index 88619c6..afe9ccb 100644 --- a/plugin/testdata/afolder.json +++ b/plugin/testdata/github/afolder.json @@ -4,5 +4,11 @@ "size": 625, "name": ".drone.yml", "path": "afolder/.drone.yml" + }, + { + "type": "dir", + "size": 0, + "name": "abfolder", + "path": "afolder/abfolder" } ] diff --git a/plugin/testdata/afolder_.drone.yml.json b/plugin/testdata/github/afolder_.drone.yml.json similarity index 100% rename from plugin/testdata/afolder_.drone.yml.json rename to plugin/testdata/github/afolder_.drone.yml.json diff --git a/plugin/testdata/github/afolder_abfolder.json b/plugin/testdata/github/afolder_abfolder.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/plugin/testdata/github/afolder_abfolder.json @@ -0,0 +1 @@ +[] diff --git a/plugin/testdata/compare.json b/plugin/testdata/github/compare.json similarity index 100% rename from plugin/testdata/compare.json rename to plugin/testdata/github/compare.json diff --git a/plugin/testdata/pull_3_files.json b/plugin/testdata/github/pull_3_files.json similarity index 100% rename from plugin/testdata/pull_3_files.json rename to plugin/testdata/github/pull_3_files.json diff --git a/plugin/testdata/root.json b/plugin/testdata/github/root.json similarity index 100% rename from plugin/testdata/root.json rename to plugin/testdata/github/root.json