diff --git a/cmd/other.go b/cmd/other.go index b2f5ef08..6f1041fb 100644 --- a/cmd/other.go +++ b/cmd/other.go @@ -33,11 +33,13 @@ func getToken(flag *flag.FlagSet) (string, error) { token = ght } else if ght := os.Getenv("BITBUCKET_SERVER_TOKEN"); ght != "" { token = ght + } else if ght := os.Getenv("BITBUCKET_CLOUD_APP_PASSWORD"); ght != "" { + token = ght } } if token == "" { - return "", errors.New("either the --token flag or the GITHUB_TOKEN/GITLAB_TOKEN/GITEA_TOKEN/BITBUCKET_SERVER_TOKEN environment variable has to be set") + return "", errors.New("either the --token flag or the GITHUB_TOKEN/GITLAB_TOKEN/GITEA_TOKEN/BITBUCKET_SERVER_TOKEN/BITBUCKET_CLOUD_APP_PASSWORD environment variable has to be set") } return token, nil diff --git a/cmd/platform.go b/cmd/platform.go index fb64cd34..9f6d1df9 100644 --- a/cmd/platform.go +++ b/cmd/platform.go @@ -7,6 +7,7 @@ import ( "github.com/lindell/multi-gitter/internal/http" "github.com/lindell/multi-gitter/internal/multigitter" + "github.com/lindell/multi-gitter/internal/scm/bitbucketcloud" "github.com/lindell/multi-gitter/internal/scm/bitbucketserver" "github.com/lindell/multi-gitter/internal/scm/gitea" "github.com/lindell/multi-gitter/internal/scm/github" @@ -22,7 +23,7 @@ func configurePlatform(cmd *cobra.Command) { flags.StringP("base-url", "g", "", "Base URL of the target platform, needs to be changed for GitHub enterprise, a self-hosted GitLab instance, Gitea or BitBucket.") flags.BoolP("insecure", "", false, "Insecure controls whether a client verifies the server certificate chain and host name. Used only for Bitbucket server.") flags.StringP("username", "u", "", "The Bitbucket server username.") - flags.StringP("token", "T", "", "The personal access token for the targeting platform. Can also be set using the GITHUB_TOKEN/GITLAB_TOKEN/GITEA_TOKEN/BITBUCKET_SERVER_TOKEN environment variable.") + flags.StringP("token", "T", "", "The personal access token for the targeting platform. Can also be set using the GITHUB_TOKEN/GITLAB_TOKEN/GITEA_TOKEN/BITBUCKET_SERVER_TOKEN/BITBUCKET_CLOUD_APP_PASSWORD environment variable.") flags.StringSliceP("org", "O", nil, "The name of a GitHub organization. All repositories in that organization will be used.") flags.StringSliceP("group", "G", nil, "The name of a GitLab organization. All repositories in that group will be used.") @@ -36,9 +37,9 @@ func configurePlatform(cmd *cobra.Command) { flags.BoolP("ssh-auth", "", false, `Use SSH cloning URL instead of HTTPS + token. This requires that a setup with ssh keys that have access to all repos and that the server is already in known_hosts.`) flags.BoolP("skip-forks", "", false, `Skip repositories which are forks.`) - flags.StringP("platform", "p", "github", "The platform that is used. Available values: github, gitlab, gitea, bitbucket_server.") + flags.StringP("platform", "p", "github", "The platform that is used. Available values: github, gitlab, gitea, bitbucket_server, bitbucket_cloud. Note: bitbucket_cloud is in Beta") _ = cmd.RegisterFlagCompletionFunc("platform", func(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { - return []string{"github", "gitlab", "gitea", "bitbucket_server"}, cobra.ShellCompDirectiveDefault + return []string{"github", "gitlab", "gitea", "bitbucket_server", "bitbucket_cloud"}, cobra.ShellCompDirectiveDefault }) // Autocompletion for organizations @@ -114,6 +115,8 @@ func getVersionController(flag *flag.FlagSet, verifyFlags bool, readOnly bool) ( return createGiteaClient(flag, verifyFlags) case "bitbucket_server": return createBitbucketServerClient(flag, verifyFlags) + case "bitbucket_cloud": + return createBitbucketCloudClient(flag, verifyFlags) default: return nil, fmt.Errorf("unknown platform: %s", platform) } @@ -283,6 +286,36 @@ func createGiteaClient(flag *flag.FlagSet, verifyFlags bool) (multigitter.Versio return vc, nil } +func createBitbucketCloudClient(flag *flag.FlagSet, verifyFlags bool) (multigitter.VersionController, error) { + workspaces, _ := flag.GetStringSlice("org") + users, _ := flag.GetStringSlice("user") + repos, _ := flag.GetStringSlice("repo") + username, _ := flag.GetString("username") + sshAuth, _ := flag.GetBool("ssh-auth") + fork, _ := flag.GetBool("fork") + newOwner, _ := flag.GetString("fork-owner") + + if verifyFlags && len(workspaces) == 0 && len(users) == 0 && len(repos) == 0 { + return nil, errors.New("no workspace, user or repository set") + } + + if username == "" { + return nil, errors.New("no username set") + } + + token, err := getToken(flag) + if err != nil { + return nil, err + } + + vc, err := bitbucketcloud.New(username, token, repos, workspaces, users, fork, sshAuth, newOwner) + if err != nil { + return nil, err + } + + return vc, nil +} + func createBitbucketServerClient(flag *flag.FlagSet, verifyFlags bool) (multigitter.VersionController, error) { bitbucketServerBaseURL, _ := flag.GetString("base-url") projects, _ := flag.GetStringSlice("org") diff --git a/docs/README.template.md b/docs/README.template.md index 32b29799..7e123310 100755 --- a/docs/README.template.md +++ b/docs/README.template.md @@ -132,3 +132,36 @@ All configuration in multi-gitter can be done through command line flags, config {{end}}{{end}} Do you have a nice script that might be useful to others? Please create a PR that adds it to the [examples folder](/examples). + + +
+ + Bitbucket Cloud + +_note: bitbucket cloud support is currently in Beta_ + +In order to use bitbucket cloud you will need to create and use an [App Password](https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/). The app password you create needs sufficient permissions so ensure you grant it Read and Write access to projects, repositories and pull requests and at least Read access to your account and workspace membership. + +You will need to configure the bitbucket workspace using the `org` option for multi-gitter for the repositories you want to make changes to e.g. `multi-gitter run examples/go/upgrade-go-version.sh -u your_username --org "your_workspace"` + +### Example +Here is an example of using the command line options to run a script from the `examples/` directory and make pull-requests for a few repositories in a specified workspace. +```shell +export BITBUCKET_CLOUD_APP_PASSWORD="your_app_password" +multi-gitter run examples/go/upgrade-go-version.sh -u your_username --org "your_workspace" --repo "your_first_repository,your_second_repository" --platform bitbucket_cloud -m "your_commit_message" -B your_branch_name +``` + +### Bitbucket Cloud Limitations +Currently, we add the repositories default reviewers as a reviewer for any pull-request you create. If you want to specify specific reviewers, you will need to add them using their `UUID` instead of their username since bitbucket does not allow us to look up a `UUID` using their username. [This article has more information about where you can get a users UUID.](https://community.atlassian.com/t5/Bitbucket-articles/Retrieve-the-Atlassian-Account-ID-AAID-in-bitbucket-org/ba-p/2471787) + +We don't support specifying specific projects for bitbucket cloud yet, you should still be able to make changes to the repositories you want but certain functionality, like forking, does not work as well until we implement that feature. +Using `fork: true` is currently experimental within multi-gitter for Bitbucket Cloud, and will be addressed in future updates. +Here are the known limitations: +- The forked repository will appear in the user-provided workspace/orgName, with the given repo name, but will appear in a random project within that workspace. +- Using `git-type: cmd` is required for Bitbucket Cloud forking for now until better support is added, as `git-type: go` causes inconsistent behavior(intermittent unauthorized errors). + +We also only support modifying a single workspace, any additional workspaces passed into the multi-gitter `org` option will be ignored after the first value. + +We also have noticed the performance is slower with larger workspaces and we expect to resolve this when we add support for projects to make filtering repositories by project faster. + +
\ No newline at end of file diff --git a/go.mod b/go.mod index b7e316b4..7f104aa7 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/gfleury/go-bitbucket-v1 v0.0.0-20240131155556-0b41d7863037 github.com/go-git/go-git/v5 v5.12.0 github.com/google/go-github/v59 v59.0.0 + github.com/ktrysmt/go-bitbucket v0.9.80 github.com/mitchellh/mapstructure v1.5.0 github.com/pkg/errors v0.9.1 github.com/sirupsen/logrus v1.9.3 diff --git a/go.sum b/go.sum index c4477492..4e0323cf 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,5 @@ cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= code.gitea.io/sdk/gitea v0.19.0 h1:8I6s1s4RHgzxiPHhOQdgim1RWIRcr0LVMbHBjBFXq4Y= code.gitea.io/sdk/gitea v0.19.0/go.mod h1:IG9xZJoltDNeDSW0qiF2Vqx5orMWa7OhVWrjvrd5NpI= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= @@ -18,6 +19,7 @@ github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vc github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -58,6 +60,7 @@ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github/v59 v59.0.0 h1:7h6bgpF5as0YQLLkEiVqpgtJqjimMYhBkD4jT5aN3VA= @@ -80,19 +83,27 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= +github.com/k0kubun/pp v3.0.1+incompatible/go.mod h1:GWse8YhT0p8pT4ir3ZgBbfZild3tgzSScAn6HmfYukg= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/ktrysmt/go-bitbucket v0.9.80 h1:S+vZTXKx/VG5yCaX4I3Bmwo8lxWr4ifvuHdTboHTMMc= +github.com/ktrysmt/go-bitbucket v0.9.80/go.mod h1:b8ogWEGxQMWoeFnT1ZE4aHIPGindI+9z/zAW/OVFjk0= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= @@ -104,11 +115,14 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6 github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= @@ -168,6 +182,8 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= @@ -188,9 +204,12 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -209,12 +228,15 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -224,6 +246,8 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -235,6 +259,7 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= @@ -249,9 +274,11 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= diff --git a/internal/scm/bitbucketcloud/bitbucket_cloud.go b/internal/scm/bitbucketcloud/bitbucket_cloud.go new file mode 100644 index 00000000..48f01048 --- /dev/null +++ b/internal/scm/bitbucketcloud/bitbucket_cloud.go @@ -0,0 +1,374 @@ +package bitbucketcloud + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "slices" + "strings" + + internalHTTP "github.com/lindell/multi-gitter/internal/http" + + "github.com/ktrysmt/go-bitbucket" + "github.com/lindell/multi-gitter/internal/scm" + "github.com/pkg/errors" +) + +type BitbucketCloud struct { + repositories []string + workspaces []string + users []string + fork bool + username string + token string + sshAuth bool + newOwner string + httpClient *http.Client + bbClient *bitbucket.Client +} + +func New(username string, token string, repositories []string, workspaces []string, users []string, fork bool, sshAuth bool, + newOwner string) (*BitbucketCloud, error) { + if strings.TrimSpace(token) == "" { + return nil, errors.New("bearer token is empty") + } + + bitbucketCloud := &BitbucketCloud{} + bitbucketCloud.repositories = repositories + bitbucketCloud.workspaces = workspaces + bitbucketCloud.users = users + bitbucketCloud.fork = fork + bitbucketCloud.username = username + bitbucketCloud.token = token + bitbucketCloud.sshAuth = sshAuth + bitbucketCloud.newOwner = newOwner + bitbucketCloud.httpClient = &http.Client{ + Transport: internalHTTP.LoggingRoundTripper{}, + } + bitbucketCloud.bbClient = bitbucket.NewBasicAuth(username, token) + + return bitbucketCloud, nil +} + +func (bbc *BitbucketCloud) CreatePullRequest(_ context.Context, _ scm.Repository, prRepo scm.Repository, newPR scm.NewPullRequest) (scm.PullRequest, error) { + bbcRepo := prRepo.(repository) + + repoOptions := &bitbucket.RepositoryOptions{ + Owner: bbc.workspaces[0], + RepoSlug: bbcRepo.name, + } + currentUser, err := bbc.bbClient.User.Profile() + if err != nil { + return nil, err + } + defaultReviewers, err := bbc.bbClient.Repositories.Repository.ListDefaultReviewers(repoOptions) + if err != nil { + return nil, err + } + for _, reviewer := range defaultReviewers.DefaultReviewers { + if currentUser.Uuid != reviewer.Uuid { + newPR.Reviewers = append(newPR.Reviewers, reviewer.Uuid) + } + } + + if bbc.newOwner == "" { + bbc.newOwner = bbc.username + } + + prOptions := &bitbucket.PullRequestsOptions{ + Owner: bbc.workspaces[0], + RepoSlug: bbcRepo.name, + SourceBranch: newPR.Head, + DestinationBranch: newPR.Base, + Title: newPR.Title, + CloseSourceBranch: true, + Reviewers: newPR.Reviewers, + } + + // If we are performing a fork, set the source repository. + // We do not if we are just creating a PR within the same repo, as it will cause issues + if bbc.fork { + prOptions.SourceRepository = fmt.Sprintf("%s/%s", bbc.newOwner, repoOptions.RepoSlug) + } + + resp, err := bbc.bbClient.Repositories.PullRequests.Create(prOptions) + if err != nil { + return nil, err + } + createBytes, err := json.Marshal(resp) + if err != nil { + return nil, err + } + r := newPrResponse{} + err = json.Unmarshal(createBytes, &r) + if err != nil { + return nil, err + } + // Were currently using scm.PullRequestStatusSuccess here for simplicity + // We could eventually look it up using bbc.pullRequestStatus but we will need to refactor it to support passing in the needed variable + return &pullRequest{ + number: r.ID, + guiURL: r.Links.HTML.Href, + project: bbcRepo.project, + repoName: bbcRepo.name, + branchName: newPR.Head, + prProject: bbcRepo.project, + prRepoName: bbcRepo.name, + status: scm.PullRequestStatusSuccess, + }, nil +} + +func (bbc *BitbucketCloud) UpdatePullRequest(_ context.Context, _ scm.Repository, pullReq scm.PullRequest, updatedPR scm.NewPullRequest) (scm.PullRequest, error) { + bbcPR := pullReq.(pullRequest) + + // Note the specs of the bitbucket client here, reviewers field must be UUID of the reviewers, not their usernames + prOptions := &bitbucket.PullRequestsOptions{ + ID: fmt.Sprintf("%d", bbcPR.number), + Owner: bbc.workspaces[0], + RepoSlug: bbcPR.repoName, + Title: updatedPR.Title, + Description: updatedPR.Body, + CloseSourceBranch: true, + SourceBranch: updatedPR.Head, + DestinationBranch: updatedPR.Base, + Reviewers: updatedPR.Reviewers, + } + _, err := bbc.bbClient.Repositories.PullRequests.Update(prOptions) + if err != nil { + return nil, err + } + + return &pullRequest{ + number: bbcPR.number, + guiURL: bbcPR.guiURL, + project: bbcPR.project, + repoName: bbcPR.repoName, + branchName: updatedPR.Head, + prProject: bbcPR.prProject, + prRepoName: bbcPR.prRepoName, + status: scm.PullRequestStatusSuccess, + }, nil +} + +func (bbc *BitbucketCloud) GetPullRequests(ctx context.Context, branchName string) ([]scm.PullRequest, error) { + var responsePRs []scm.PullRequest + repositories, err := bbc.GetRepositories(ctx) + if err != nil { + return nil, err + } + for _, repo := range repositories { + bbcRepo := repo.(repository) + prs, err := bbc.bbClient.Repositories.PullRequests.Gets(&bitbucket.PullRequestsOptions{Owner: bbc.workspaces[0], RepoSlug: bbcRepo.name, SourceBranch: branchName}) + if err != nil { + return nil, err + } + prBytes, err := json.Marshal(prs) + if err != nil { + return nil, err + } + bbPullRequests := &bitbucketPullRequests{} + err = json.Unmarshal(prBytes, bbPullRequests) + if err != nil { + return nil, err + } + for _, pr := range bbPullRequests.Values { + convertedPr := bbc.convertPullRequest(bbc.workspaces[0], bbcRepo.name, &pr) + responsePRs = append(responsePRs, convertedPr) + } + } + return responsePRs, nil +} + +func (bbc *BitbucketCloud) getPullRequests(_ context.Context, repoName string) ([]pullRequest, error) { + var repoPRs []pullRequest + prs, err := bbc.bbClient.Repositories.PullRequests.Gets(&bitbucket.PullRequestsOptions{Owner: bbc.workspaces[0], RepoSlug: repoName}) + if err != nil { + return nil, err + } + prBytes, err := json.Marshal(prs) + if err != nil { + return nil, err + } + bbPullRequests := &bitbucketPullRequests{} + err = json.Unmarshal(prBytes, bbPullRequests) + if err != nil { + return nil, err + } + for _, pr := range bbPullRequests.Values { + convertedPr := bbc.convertPullRequest(bbc.workspaces[0], repoName, &pr) + repoPRs = append(repoPRs, convertedPr) + } + return repoPRs, nil +} + +func (bbc *BitbucketCloud) convertPullRequest(project, repoName string, pr *bbPullRequest) pullRequest { + status := bbc.pullRequestStatus(pr) + + return pullRequest{ + repoName: repoName, + project: project, + branchName: pr.Source.Branch.Name, + + prProject: pr.Source.Repository.Project.Key, + prRepoName: pr.Source.Repository.Slug, + number: pr.ID, + + guiURL: pr.Links.HTML.Href, + status: status, + } +} + +func (bbc *BitbucketCloud) pullRequestStatus(pr *bbPullRequest) scm.PullRequestStatus { + switch pr.State { + case stateMerged: + return scm.PullRequestStatusMerged + case stateDeclined: + return scm.PullRequestStatusClosed + } + + return scm.PullRequestStatusSuccess +} + +func extractRepoSlug(bbcPR pullRequest) string { + repoSlug := strings.Split(bbcPR.guiURL, "/")[4] + return repoSlug +} + +func (bbc *BitbucketCloud) GetOpenPullRequest(ctx context.Context, repo scm.Repository, branchName string) (scm.PullRequest, error) { + bbcRepo := repo.(repository) + repoPRs, err := bbc.getPullRequests(ctx, bbcRepo.name) + if err != nil { + return nil, err + } + for _, repoPR := range repoPRs { + pr := repoPR + if pr.branchName == branchName && pr.status == scm.PullRequestStatusSuccess { + return repoPR, nil + } + } + return nil, nil +} + +func (bbc *BitbucketCloud) MergePullRequest(_ context.Context, pr scm.PullRequest) error { + bbcPR := pr.(pullRequest) + repoSlug := extractRepoSlug(bbcPR) + prOptions := &bitbucket.PullRequestsOptions{ + ID: fmt.Sprintf("%d", bbcPR.number), + SourceBranch: bbcPR.branchName, + RepoSlug: repoSlug, + Owner: bbc.workspaces[0], + } + _, err := bbc.bbClient.Repositories.PullRequests.Merge(prOptions) + return err +} + +func (bbc *BitbucketCloud) ClosePullRequest(_ context.Context, pr scm.PullRequest) error { + bbcPR := pr.(pullRequest) + repoSlug := extractRepoSlug(bbcPR) + prOptions := &bitbucket.PullRequestsOptions{ + ID: fmt.Sprintf("%d", bbcPR.number), + SourceBranch: bbcPR.branchName, + RepoSlug: repoSlug, + Owner: bbc.workspaces[0], + } + _, err := bbc.bbClient.Repositories.PullRequests.Decline(prOptions) + return err +} + +func (bbc *BitbucketCloud) GetRepositories(_ context.Context) ([]scm.Repository, error) { + repoOptions := &bitbucket.RepositoriesOptions{ + Role: "member", + Owner: bbc.workspaces[0], + } + + repos, err := bbc.bbClient.Repositories.ListForAccount(repoOptions) + + if err != nil { + return nil, err + } + + repositories := make([]scm.Repository, 0, len(repos.Items)) + for _, repo := range repos.Items { + if slices.Contains(bbc.repositories, repo.Name) { + converted, err := bbc.convertRepository(repo) + if err != nil { + return nil, err + } + repositories = append(repositories, *converted) + } + } + + return repositories, nil +} + +func (bbc *BitbucketCloud) ForkRepository(_ context.Context, repo scm.Repository, newOwner string) (scm.Repository, error) { + bbcRepo := repo.(repository) + if newOwner == "" { + newOwner = bbc.username + } + options := &bitbucket.RepositoryForkOptions{ + FromOwner: bbc.workspaces[0], + FromSlug: bbcRepo.name, + Owner: newOwner, + Name: bbcRepo.name, + } + + resp, err := bbc.bbClient.Repositories.Repository.Fork(options) + if err != nil { + return nil, err + } + res, err := bbc.convertRepository(*resp) + if err != nil { + return nil, err + } + return *res, nil +} + +func (bbc *BitbucketCloud) convertRepository(repo bitbucket.Repository) (*repository, error) { + var cloneURL string + + rLinks := &repoLinks{} + linkBytes, err := json.Marshal(repo.Links) + if err != nil { + return nil, err + } + _ = json.Unmarshal(linkBytes, rLinks) + + if bbc.sshAuth { + cloneURL, err = findLinkType(rLinks.Clone, cloneSSHType, repo.Name) + if err != nil { + return nil, err + } + } else { + httpURL, err := findLinkType(rLinks.Clone, cloneHTTPType, repo.Name) + if err != nil { + return nil, err + } + parsedURL, err := url.Parse(httpURL) + if err != nil { + return nil, err + } + + parsedURL.User = url.UserPassword(bbc.username, bbc.token) + cloneURL = parsedURL.String() + } + + return &repository{ + name: repo.Slug, + project: repo.Project.Name, + defaultBranch: repo.Mainbranch.Name, + cloneURL: cloneURL, + }, nil +} + +func findLinkType(cloneLinks []hrefLink, cloneType string, repoName string) (string, error) { + for _, clone := range cloneLinks { + if strings.EqualFold(clone.Name, cloneType) { + return clone.Href, nil + } + } + + return "", errors.Errorf("unable to find clone url for repository %s using clone type %s", repoName, cloneType) +} diff --git a/internal/scm/bitbucketcloud/custom_structs.go b/internal/scm/bitbucketcloud/custom_structs.go new file mode 100644 index 00000000..41eb9072 --- /dev/null +++ b/internal/scm/bitbucketcloud/custom_structs.go @@ -0,0 +1,114 @@ +package bitbucketcloud + +import ( + "fmt" + + "github.com/ktrysmt/go-bitbucket" + "github.com/lindell/multi-gitter/internal/scm" +) + +const ( + cloneHTTPType = "https" + cloneSSHType = "ssh" + stateMerged = "MERGED" + stateDeclined = "DECLINED" +) + +type newPrResponse struct { + ID int `json:"id"` + Links links `json:"links"` +} + +type bitbucketPullRequests struct { + Next string `json:"next"` + Page int `json:"page"` + PageLen int `json:"pagelen"` + Previous string `json:"previous"` + Size int `json:"size"` + Values []bbPullRequest `json:"values"` +} + +type bbPullRequest struct { + State string `json:"state"` + Source pullRequestRef `json:"source"` + Destination pullRequestRef `json:"destination"` + Links links `json:"links"` + Title string `json:"title"` + Type string `json:"type"` + ID int `json:"id"` +} + +type pullRequestRef struct { + Branch branch `json:"branch"` + Commit commit `json:"Commit"` + Repository bitbucket.Repository `json:"repository"` +} + +type branch struct { + Name string `json:"name"` +} + +type commit struct { + Hash string `json:"hash"` + Type string `json:"type"` + Links links `json:"links"` +} + +type links struct { + Self hrefLink `json:"self,omitempty"` + HTML hrefLink `json:"html,omitempty"` +} + +type repoLinks struct { + Clone []hrefLink `json:"clone,omitempty"` + Self []hrefLink `json:"self,omitempty"` + HTML []hrefLink `json:"html,omitempty"` +} + +type hrefLink struct { + Href string `json:"href"` + Name string `json:"name"` +} + +type pullRequest struct { + project string + repoName string + branchName string + prProject string + prRepoName string + number int + guiURL string + status scm.PullRequestStatus +} + +func (pr pullRequest) String() string { + return fmt.Sprintf("%s/%s #%d", pr.project, pr.repoName, pr.number) +} + +func (pr pullRequest) Status() scm.PullRequestStatus { + return pr.status +} + +func (pr pullRequest) URL() string { + return pr.guiURL +} + +// repository contains information about a bitbucket repository +type repository struct { + name string + project string + defaultBranch string + cloneURL string +} + +func (r repository) CloneURL() string { + return r.cloneURL +} + +func (r repository) DefaultBranch() string { + return r.defaultBranch +} + +func (r repository) FullName() string { + return r.project + "/" + r.name +}