diff --git a/apis/bitbucketapi/apis.go b/apis/bitbucketapi/apis.go new file mode 100644 index 0000000..b56c849 --- /dev/null +++ b/apis/bitbucketapi/apis.go @@ -0,0 +1,86 @@ +package bitbucketapi + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/kubescape/go-git-url/apis" +) + +const ( + DEFAULT_HOST string = "bitbucket.org" +) + +type IBitBucketAPI interface { + GetRepoTree(owner, repo, branch string, headers *Headers) (*Tree, error) + GetDefaultBranchName(owner, repo string, headers *Headers) (string, error) + GetLatestCommit(owner, repo, branch string, headers *Headers) (*Commit, error) +} + +type BitBucketAPI struct { + httpClient *http.Client +} + +func NewBitBucketAPI() *BitBucketAPI { return &BitBucketAPI{httpClient: &http.Client{}} } + +func (gl *BitBucketAPI) GetRepoTree(owner, repo, branch string, headers *Headers) (*Tree, error) { + //TODO implement me + return nil, fmt.Errorf("GetRepoTree is not supported") +} + +func (gl *BitBucketAPI) GetDefaultBranchName(owner, repo string, headers *Headers) (string, error) { + body, err := apis.HttpGet(gl.httpClient, APIBranchingModel(owner, repo), headers.ToMap()) + if err != nil { + return "", err + } + + var data bitbucketBranchingModel + if err = json.Unmarshal([]byte(body), &data); err != nil { + return "", err + } + + return data.Development.Name, nil +} + +func (gl *BitBucketAPI) GetLatestCommit(owner, repo, branch string, headers *Headers) (*Commit, error) { + body, err := apis.HttpGet(gl.httpClient, APILastCommitsOfBranch(owner, repo, branch), headers.ToMap()) + if err != nil { + return nil, err + } + + var data Commits + err = json.Unmarshal([]byte(body), &data) + if err != nil { + return nil, err + } + return &data.Values[0], nil +} + +// APIBranchingModel +// API Ref: https://developer.atlassian.com/cloud/bitbucket/rest/api-group-branching-model/#api-group-branching-model +// Example: https://bitbucket.org/!api/2.0/repositories/matthyx/ks-testing-public/branching-model +func APIBranchingModel(owner, repo string) string { + p, _ := url.JoinPath("!api/2.0/repositories", owner, repo, "branching-model") + u := url.URL{ + Scheme: "https", + Host: DEFAULT_HOST, + Path: p, + } + return u.String() +} + +// APILastCommitsOfBranch +// API Ref: https://developer.atlassian.com/cloud/bitbucket/rest/api-group-commits/#api-repositories-workspace-repo-slug-commits-get +// Example: https://bitbucket.org/!api/2.0/repositories/matthyx/ks-testing-public/commits/?include=master +func APILastCommitsOfBranch(owner, repo, branch string) string { + p, _ := url.JoinPath("!api/2.0/repositories", owner, repo, "commits") + u := url.URL{ + Scheme: "https", + Host: DEFAULT_HOST, + Path: p, + RawQuery: fmt.Sprintf("include=%s", branch), + } + return u.String() +} diff --git a/apis/bitbucketapi/datastructures.go b/apis/bitbucketapi/datastructures.go new file mode 100644 index 0000000..732bed2 --- /dev/null +++ b/apis/bitbucketapi/datastructures.go @@ -0,0 +1,34 @@ +package bitbucketapi + +import "time" + +type ObjectType string + +type Tree struct{} + +type bitbucketBranchingModel struct { + Development struct { + Name string `json:"name"` + } `json:"development"` +} + +type Headers struct { + Token string +} + +type Commits struct { + Values []Commit `json:"values"` + Pagelen int `json:"pagelen"` + Next string `json:"next"` +} + +type Commit struct { + Type string `json:"type"` + Hash string `json:"hash"` + Date time.Time `json:"date"` + Author struct { + Type string `json:"type"` + Raw string `json:"raw"` + } `json:"author"` + Message string `json:"message"` +} diff --git a/apis/bitbucketapi/methods.go b/apis/bitbucketapi/methods.go new file mode 100644 index 0000000..7d3b7ed --- /dev/null +++ b/apis/bitbucketapi/methods.go @@ -0,0 +1,12 @@ +package bitbucketapi + +import "fmt" + +// ToMap convert headers to map[string]string +func (h *Headers) ToMap() map[string]string { + m := make(map[string]string) + if h.Token != "" { + m["Authorization"] = fmt.Sprintf("Bearer %s", h.Token) + } + return m +} diff --git a/apis/provider.go b/apis/provider.go index 280bd56..d80844f 100644 --- a/apis/provider.go +++ b/apis/provider.go @@ -5,14 +5,15 @@ import "errors" type ProviderType string const ( - ProviderGitHub ProviderType = "github" - ProviderAzure ProviderType = "azure" - ProviderGitLab ProviderType = "gitlab" + ProviderGitHub ProviderType = "github" + ProviderAzure ProviderType = "azure" + ProviderBitBucket ProviderType = "bitbucket" + ProviderGitLab ProviderType = "gitlab" ) func (pt ProviderType) IsSupported() error { switch pt { - case ProviderGitHub, ProviderAzure, ProviderGitLab: + case ProviderGitHub, ProviderAzure, ProviderBitBucket, ProviderGitLab: return nil } return errors.New("unsupported provider") diff --git a/bitbucketparser/v1/commit.go b/bitbucketparser/v1/commit.go new file mode 100644 index 0000000..409133a --- /dev/null +++ b/bitbucketparser/v1/commit.go @@ -0,0 +1,58 @@ +package v1 + +import ( + "fmt" + "regexp" + "strings" + + "github.com/kubescape/go-git-url/apis" + "github.com/kubescape/go-git-url/apis/bitbucketapi" +) + +var rawUserRe = regexp.MustCompile("([^<]*)?(<(.+)>)?") + +func (gl *BitBucketURL) GetLatestCommit() (*apis.Commit, error) { + if gl.GetHostName() == "" || gl.GetOwnerName() == "" || gl.GetRepoName() == "" { + return nil, fmt.Errorf("missing host/owner/repo") + } + if gl.GetBranchName() == "" { + if err := gl.SetDefaultBranchName(); err != nil { + return nil, fmt.Errorf("failed to get default branch. reason: %s", err.Error()) + } + } + + c, err := gl.bitBucketAPI.GetLatestCommit(gl.GetOwnerName(), gl.GetRepoName(), gl.GetBranchName(), gl.headers()) + if err != nil { + return nil, fmt.Errorf("failed to get latest commit. reason: %s", err.Error()) + } + + return bitBucketAPICommitToCommit(c), nil +} + +func bitBucketAPICommitToCommit(c *bitbucketapi.Commit) *apis.Commit { + name, email := parseRawUser(c.Author.Raw) + latestCommit := &apis.Commit{ + SHA: c.Hash, + Author: apis.Committer{ + Name: name, + Email: email, + Date: c.Date, + }, + Committer: apis.Committer{ // same as author as API doesn't return the committer + Name: name, + Email: email, + Date: c.Date, + }, + Message: c.Message, + } + + return latestCommit +} + +func parseRawUser(raw string) (string, string) { + match := rawUserRe.FindStringSubmatch(raw) + if match != nil { + return strings.TrimSpace(match[1]), match[3] + } + return raw, "" +} diff --git a/bitbucketparser/v1/commit_test.go b/bitbucketparser/v1/commit_test.go new file mode 100644 index 0000000..8e5018d --- /dev/null +++ b/bitbucketparser/v1/commit_test.go @@ -0,0 +1,43 @@ +package v1 + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_parseRawUser(t *testing.T) { + tests := []struct { + raw string + wantName string + wantEmail string + }{ + { + raw: "David Wertenteil ", + wantName: "David Wertenteil", + wantEmail: "dwertent@cyberarmor.io", + }, + { + raw: "github-actions[bot] ", + wantName: "github-actions[bot]", + wantEmail: "github-actions[bot]@users.noreply.github.com", + }, + { + raw: "David Wertenteil", + wantName: "David Wertenteil", + wantEmail: "", + }, + { + raw: "", + wantName: "", + wantEmail: "dwertent@cyberarmor.io", + }, + } + for _, tt := range tests { + t.Run(tt.raw, func(t *testing.T) { + got, got1 := parseRawUser(tt.raw) + assert.Equalf(t, tt.wantName, got, "parseRawUser(%v)", tt.raw) + assert.Equalf(t, tt.wantEmail, got1, "parseRawUser(%v)", tt.raw) + }) + } +} diff --git a/bitbucketparser/v1/datastructures.go b/bitbucketparser/v1/datastructures.go new file mode 100644 index 0000000..b6c857b --- /dev/null +++ b/bitbucketparser/v1/datastructures.go @@ -0,0 +1,18 @@ +package v1 + +import ( + "github.com/kubescape/go-git-url/apis/bitbucketapi" +) + +type BitBucketURL struct { + host string + owner string // repo owner + repo string // repo name + project string + branch string + path string + token string // github token + isFile bool + + bitBucketAPI bitbucketapi.IBitBucketAPI +} diff --git a/bitbucketparser/v1/download.go b/bitbucketparser/v1/download.go new file mode 100644 index 0000000..b917895 --- /dev/null +++ b/bitbucketparser/v1/download.go @@ -0,0 +1,13 @@ +package v1 + +import "fmt" + +func (gl *BitBucketURL) DownloadAllFiles() (map[string][]byte, map[string]error) { + //TODO implement me + return nil, map[string]error{"": fmt.Errorf("DownloadAllFiles is not supported")} +} + +func (gl *BitBucketURL) DownloadFilesWithExtension(extensions []string) (map[string][]byte, map[string]error) { + //TODO implement me + return nil, map[string]error{"": fmt.Errorf("DownloadFilesWithExtension is not supported")} +} diff --git a/bitbucketparser/v1/parser.go b/bitbucketparser/v1/parser.go new file mode 100644 index 0000000..b1c49b5 --- /dev/null +++ b/bitbucketparser/v1/parser.go @@ -0,0 +1,126 @@ +package v1 + +import ( + "fmt" + "net/url" + "os" + "strings" + + "github.com/kubescape/go-git-url/apis" + "github.com/kubescape/go-git-url/apis/bitbucketapi" + giturl "github.com/whilp/git-urls" +) + +const HOST = "bitbucket.org" + +// NewBitBucketParser empty instance of a bitbucket parser +func NewBitBucketParser() *BitBucketURL { + + return &BitBucketURL{ + bitBucketAPI: bitbucketapi.NewBitBucketAPI(), + host: HOST, + token: os.Getenv("BITBUCKET_TOKEN"), + } +} + +// NewBitBucketParserWithURL parsed instance of a bitbucket parser +func NewBitBucketParserWithURL(fullURL string) (*BitBucketURL, error) { + gl := NewBitBucketParser() + + if err := gl.Parse(fullURL); err != nil { + return gl, err + } + + return gl, nil +} + +func (gl *BitBucketURL) GetURL() *url.URL { + return &url.URL{ + Scheme: "https", + Host: gl.GetHostName(), + Path: fmt.Sprintf("%s/%s", gl.GetOwnerName(), gl.GetRepoName()), + } +} + +func IsHostBitBucket(host string) bool { return strings.HasSuffix(host, HOST) } + +func (gl *BitBucketURL) GetProvider() string { return apis.ProviderBitBucket.String() } +func (gl *BitBucketURL) GetHostName() string { return gl.host } +func (gl *BitBucketURL) GetProjectName() string { return gl.project } +func (gl *BitBucketURL) GetBranchName() string { return gl.branch } +func (gl *BitBucketURL) GetOwnerName() string { return gl.owner } +func (gl *BitBucketURL) GetRepoName() string { return gl.repo } +func (gl *BitBucketURL) GetPath() string { return gl.path } +func (gl *BitBucketURL) GetToken() string { return gl.token } +func (gl *BitBucketURL) GetHttpCloneURL() string { + return fmt.Sprintf("https://bitbucket.org/%s/%s.git", gl.GetOwnerName(), gl.GetRepoName()) +} + +func (gl *BitBucketURL) SetOwnerName(o string) { gl.owner = o } +func (gl *BitBucketURL) SetProjectName(project string) { gl.project = project } +func (gl *BitBucketURL) SetRepoName(r string) { gl.repo = r } +func (gl *BitBucketURL) SetBranchName(branch string) { gl.branch = branch } +func (gl *BitBucketURL) SetPath(p string) { gl.path = p } +func (gl *BitBucketURL) SetToken(token string) { gl.token = token } + +// Parse URL +func (gl *BitBucketURL) Parse(fullURL string) error { + parsedURL, err := giturl.Parse(fullURL) + if err != nil { + return err + } + + index := 0 + + splitRepo := strings.FieldsFunc(parsedURL.Path, func(c rune) bool { return c == '/' }) // trim empty fields from returned slice + if len(splitRepo) < 2 { + return fmt.Errorf("expecting / in url path, received: '%s'", parsedURL.Path) + } + gl.owner = splitRepo[index] + index++ + gl.repo = strings.TrimSuffix(splitRepo[index], ".git") + index++ + + // root of repo + if len(splitRepo) < index+1 { + return nil + } + + if splitRepo[index] == "-" { + index++ // skip "-" symbol in URL + } + + // is file or dir + switch splitRepo[index] { + case "src", "raw": + index++ + } + + if len(splitRepo) < index+1 { + return nil + } + + gl.branch = splitRepo[index] + index += 1 + + if len(splitRepo) < index+1 { + return nil + } + gl.path = strings.Join(splitRepo[index:], "/") + + return nil +} + +// SetDefaultBranchName sets the default brach of the repo +func (gl *BitBucketURL) SetDefaultBranchName() error { + branch, err := gl.bitBucketAPI.GetDefaultBranchName(gl.GetOwnerName(), gl.GetRepoName(), gl.headers()) + if err != nil { + return err + } + gl.branch = branch + return nil +} + +func (gl *BitBucketURL) headers() *bitbucketapi.Headers { + return &bitbucketapi.Headers{Token: gl.GetToken()} +} diff --git a/bitbucketparser/v1/parser_test.go b/bitbucketparser/v1/parser_test.go new file mode 100644 index 0000000..429c832 --- /dev/null +++ b/bitbucketparser/v1/parser_test.go @@ -0,0 +1,113 @@ +package v1 + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + urlA = "https://bitbucket.org/matthyx/ks-testing-public" // general case + urlB = "https://bitbucket.org/matthyx/ks-testing-public/src/master/rules/etcd-encryption-native/raw.rego" // file + urlC = "https://bitbucket.org/matthyx/ks-testing-public/src/dev/README.md" // branch + urlD = "https://bitbucket.org/matthyx/ks-testing-public/src/dev/" // branch + urlE = "https://bitbucket.org/matthyx/ks-testing-public/src/v1.0.178/README.md" // TODO fix tag + urlF = "https://bitbucket.org/matthyx/ks-testing-public/raw/4502b9b51ee3ac1ea649bacfa0f48ebdeab05f4a/README.md" // TODO fix sha + // scp-like syntax supported by git for ssh + // see: https://mirrors.edge.kernel.org/pub/software/scm/git/docs/git-clone.html#URLS + // regular form + urlG = "git@bitbucket.org:matthyx/ks-testing-public.git" + // unexpected form: should not panic + urlH = "git@bitbucket.org:matthyx/to/ks-testing-public.git" +) + +func TestNewGitHubParserWithURL(t *testing.T) { + { + gl, err := NewBitBucketParserWithURL(urlA) + assert.NoError(t, err) + assert.Equal(t, "bitbucket.org", gl.GetHostName()) + assert.Equal(t, "bitbucket", gl.GetProvider()) + assert.Equal(t, "matthyx", gl.GetOwnerName()) + assert.Equal(t, "ks-testing-public", gl.GetRepoName()) + assert.Equal(t, urlA, gl.GetURL().String()) + assert.Equal(t, "", gl.GetBranchName()) + assert.Equal(t, "", gl.GetPath()) + } + { + gl, err := NewBitBucketParserWithURL(urlB) + assert.NoError(t, err) + assert.Equal(t, "bitbucket.org", gl.GetHostName()) + assert.Equal(t, "bitbucket", gl.GetProvider()) + assert.Equal(t, "matthyx", gl.GetOwnerName()) + assert.Equal(t, "ks-testing-public", gl.GetRepoName()) + assert.Equal(t, urlA, gl.GetURL().String()) + assert.Equal(t, "master", gl.GetBranchName()) + assert.Equal(t, "rules/etcd-encryption-native/raw.rego", gl.GetPath()) + } + { + gl, err := NewBitBucketParserWithURL(urlC) + assert.NoError(t, err) + assert.Equal(t, "bitbucket.org", gl.GetHostName()) + assert.Equal(t, "bitbucket", gl.GetProvider()) + assert.Equal(t, "matthyx", gl.GetOwnerName()) + assert.Equal(t, "ks-testing-public", gl.GetRepoName()) + assert.Equal(t, urlA, gl.GetURL().String()) + assert.Equal(t, "dev", gl.GetBranchName()) + assert.Equal(t, "README.md", gl.GetPath()) + } + { + gl, err := NewBitBucketParserWithURL(urlD) + assert.NoError(t, err) + assert.Equal(t, "bitbucket.org", gl.GetHostName()) + assert.Equal(t, "bitbucket", gl.GetProvider()) + assert.Equal(t, "matthyx", gl.GetOwnerName()) + assert.Equal(t, "ks-testing-public", gl.GetRepoName()) + assert.Equal(t, urlA, gl.GetURL().String()) + assert.Equal(t, "dev", gl.GetBranchName()) + assert.Equal(t, "", gl.GetPath()) + } + { + gl, err := NewBitBucketParserWithURL(urlE) + assert.NoError(t, err) + assert.Equal(t, "bitbucket.org", gl.GetHostName()) + assert.Equal(t, "bitbucket", gl.GetProvider()) + assert.Equal(t, "matthyx", gl.GetOwnerName()) + assert.Equal(t, "ks-testing-public", gl.GetRepoName()) + assert.Equal(t, urlA, gl.GetURL().String()) + assert.Equal(t, "v1.0.178", gl.GetBranchName()) + assert.Equal(t, "README.md", gl.GetPath()) + } + { + gl, err := NewBitBucketParserWithURL(urlF) + assert.NoError(t, err) + assert.Equal(t, "bitbucket.org", gl.GetHostName()) + assert.Equal(t, "bitbucket", gl.GetProvider()) + assert.Equal(t, "matthyx", gl.GetOwnerName()) + assert.Equal(t, "ks-testing-public", gl.GetRepoName()) + assert.Equal(t, urlA, gl.GetURL().String()) + assert.Equal(t, "4502b9b51ee3ac1ea649bacfa0f48ebdeab05f4a", gl.GetBranchName()) + assert.Equal(t, "README.md", gl.GetPath()) + } + { + gl, err := NewBitBucketParserWithURL(urlG) + assert.NoError(t, err) + assert.Equal(t, "bitbucket.org", gl.GetHostName()) + assert.Equal(t, "bitbucket", gl.GetProvider()) + assert.Equal(t, "matthyx", gl.GetOwnerName()) + assert.Equal(t, "ks-testing-public", gl.GetRepoName()) + assert.Equal(t, "https://bitbucket.org/matthyx/ks-testing-public", gl.GetURL().String()) + assert.Equal(t, "", gl.GetBranchName()) + assert.Equal(t, "", gl.GetPath()) + } + { + gl, err := NewBitBucketParserWithURL(urlH) + assert.NoError(t, err) + assert.Equal(t, "bitbucket.org", gl.GetHostName()) + assert.Equal(t, "bitbucket", gl.GetProvider()) + assert.Equal(t, "matthyx", gl.GetOwnerName()) + assert.Equal(t, "to", gl.GetRepoName()) + assert.Equal(t, "https://bitbucket.org/matthyx/to", gl.GetURL().String()) + assert.Equal(t, "ks-testing-public.git", gl.GetBranchName()) // invalid input leads to incorrect guess. At least this does not panic. + assert.Equal(t, "", gl.GetPath()) + } +} diff --git a/bitbucketparser/v1/tree.go b/bitbucketparser/v1/tree.go new file mode 100644 index 0000000..290d4d7 --- /dev/null +++ b/bitbucketparser/v1/tree.go @@ -0,0 +1,23 @@ +package v1 + +import "fmt" + +func (gl *BitBucketURL) ListAllNames() ([]string, error) { + //TODO implement me + return nil, fmt.Errorf("ListAllNames is not supported") +} + +func (gl *BitBucketURL) ListDirsNames() ([]string, error) { + //TODO implement me + return nil, fmt.Errorf("ListDirsNames is not supported") +} + +func (gl *BitBucketURL) ListFilesNames() ([]string, error) { + //TODO implement me + return nil, fmt.Errorf("ListFilesNames is not supported") +} + +func (gl *BitBucketURL) ListFilesNamesWithExtension(extensions []string) ([]string, error) { + //TODO implement me + return nil, fmt.Errorf("ListFilesNamesWithExtension is not supported") +} diff --git a/init.go b/init.go index 7315d66..bfc4561 100644 --- a/init.go +++ b/init.go @@ -6,9 +6,11 @@ import ( giturl "github.com/whilp/git-urls" "github.com/kubescape/go-git-url/apis/azureapi" + "github.com/kubescape/go-git-url/apis/bitbucketapi" "github.com/kubescape/go-git-url/apis/githubapi" "github.com/kubescape/go-git-url/apis/gitlabapi" azureparserv1 "github.com/kubescape/go-git-url/azureparser/v1" + bitbucketparserv1 "github.com/kubescape/go-git-url/bitbucketparser/v1" githubparserv1 "github.com/kubescape/go-git-url/githubparser/v1" gitlabparserv1 "github.com/kubescape/go-git-url/gitlabparser/v1" ) @@ -26,6 +28,9 @@ func NewGitURL(fullURL string) (IGitURL, error) { if azureparserv1.IsHostAzure(hostUrl) { return azureparserv1.NewAzureParserWithURL(fullURL) } + if bitbucketparserv1.IsHostBitBucket(hostUrl) { + return bitbucketparserv1.NewBitBucketParserWithURL(fullURL) + } if gitlabparserv1.IsHostGitLab(hostUrl) { return gitlabparserv1.NewGitLabParserWithURL(fullURL) } @@ -46,6 +51,8 @@ func NewGitAPI(fullURL string) (IGitAPI, error) { return gitlabparserv1.NewGitLabParserWithURL(fullURL) case azureapi.DEFAULT_HOST, azureapi.DEV_HOST: return azureparserv1.NewAzureParserWithURL(fullURL) + case bitbucketapi.DEFAULT_HOST: + return bitbucketparserv1.NewBitBucketParserWithURL(fullURL) default: return nil, fmt.Errorf("repository host '%s' not supported", hostUrl) } diff --git a/init_test.go b/init_test.go index 7e639a0..acdf69e 100644 --- a/init_test.go +++ b/init_test.go @@ -51,7 +51,28 @@ func TestNewGitURL(t *testing.T) { assert.Equal(t, "", az.GetPath()) assert.Equal(t, "https://dev.azure.com/dwertent/ks-testing-public/_git/ks-testing-public", az.GetURL().String()) } - + { // parse bitbucket https + az, err := NewGitURL("https://matthyx@bitbucket.org/matthyx/ks-testing-public.git") + assert.NoError(t, err) + assert.NoError(t, err) + assert.Equal(t, "bitbucket", az.GetProvider()) + assert.Equal(t, "matthyx", az.GetOwnerName()) + assert.Equal(t, "ks-testing-public", az.GetRepoName()) + assert.Equal(t, "", az.GetBranchName()) + assert.Equal(t, "", az.GetPath()) + assert.Equal(t, "https://bitbucket.org/matthyx/ks-testing-public", az.GetURL().String()) + } + { // parse bitbucket ssh + az, err := NewGitURL("git@bitbucket.org:matthyx/ks-testing-public.git") + assert.NoError(t, err) + assert.NoError(t, err) + assert.Equal(t, "bitbucket", az.GetProvider()) + assert.Equal(t, "matthyx", az.GetOwnerName()) + assert.Equal(t, "ks-testing-public", az.GetRepoName()) + assert.Equal(t, "", az.GetBranchName()) + assert.Equal(t, "", az.GetPath()) + assert.Equal(t, "https://bitbucket.org/matthyx/ks-testing-public", az.GetURL().String()) + } { // parse gitlab const gitlabURL = "https://gitlab.com/kubescape/testing" gl, err := NewGitURL(gitlabURL)