diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8002004d11..784f7836f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,7 +56,7 @@ atlantis server --gh-user --gh-token --repo-whiteli ``` ngrok http 4141 ``` -- Create a WebHook in your repo and use the `https` url that `ngrok` printed out after running `ngrok http 4141`. Be sure to append `/events` so your webhook url looks something like `https://efce3bcd.ngrok.io/events`. See [Add GitHub Webhook](https://github.com/runatlantis/atlantis#add-github-webhook). +- Create a Webhook in your repo and use the `https` url that `ngrok` printed out after running `ngrok http 4141`. Be sure to append `/events` so your webhook url looks something like `https://efce3bcd.ngrok.io/events`. See [Add GitHub Webhook](https://github.com/runatlantis/atlantis#add-github-webhook). - Create a pull request and type `atlantis help`. You should see the request in the `ngrok` and Atlantis logs and you should also see Atlantis comment back. ## Code Style diff --git a/cmd/server.go b/cmd/server.go index c3a88f1c9c..4495910fb9 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -15,6 +15,7 @@ package cmd import ( "fmt" + "net/url" "os" "path/filepath" "strings" @@ -22,6 +23,7 @@ import ( "github.com/mitchellh/go-homedir" "github.com/pkg/errors" "github.com/runatlantis/atlantis/server" + "github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -32,36 +34,37 @@ import ( // 3. Add your flag's description etc. to the stringFlags, intFlags, or boolFlags slices. const ( // Flag names. - AllowForkPRsFlag = "allow-fork-prs" - AllowRepoConfigFlag = "allow-repo-config" - AtlantisURLFlag = "atlantis-url" - BitbucketHostnameFlag = "bitbucket-hostname" - BitbucketTokenFlag = "bitbucket-token" - BitbucketUserFlag = "bitbucket-user" - ConfigFlag = "config" - DataDirFlag = "data-dir" - GHHostnameFlag = "gh-hostname" - GHTokenFlag = "gh-token" - GHUserFlag = "gh-user" - GHWebHookSecret = "gh-webhook-secret" // nolint: gosec - GitlabHostnameFlag = "gitlab-hostname" - GitlabTokenFlag = "gitlab-token" - GitlabUserFlag = "gitlab-user" - GitlabWebHookSecret = "gitlab-webhook-secret" // nolint: gosec - LogLevelFlag = "log-level" - PortFlag = "port" - RepoWhitelistFlag = "repo-whitelist" - RequireApprovalFlag = "require-approval" - SSLCertFileFlag = "ssl-cert-file" - SSLKeyFileFlag = "ssl-key-file" + AllowForkPRsFlag = "allow-fork-prs" + AllowRepoConfigFlag = "allow-repo-config" + AtlantisURLFlag = "atlantis-url" + BitbucketBaseURLFlag = "bitbucket-base-url" + BitbucketTokenFlag = "bitbucket-token" + BitbucketUserFlag = "bitbucket-user" + BitbucketWebhookSecretFlag = "bitbucket-webhook-secret" + ConfigFlag = "config" + DataDirFlag = "data-dir" + GHHostnameFlag = "gh-hostname" + GHTokenFlag = "gh-token" + GHUserFlag = "gh-user" + GHWebhookSecretFlag = "gh-webhook-secret" // nolint: gosec + GitlabHostnameFlag = "gitlab-hostname" + GitlabTokenFlag = "gitlab-token" + GitlabUserFlag = "gitlab-user" + GitlabWebhookSecretFlag = "gitlab-webhook-secret" // nolint: gosec + LogLevelFlag = "log-level" + PortFlag = "port" + RepoWhitelistFlag = "repo-whitelist" + RequireApprovalFlag = "require-approval" + SSLCertFileFlag = "ssl-cert-file" + SSLKeyFileFlag = "ssl-key-file" // Flag defaults. - DefaultBitbucketHostname = "bitbucket.org" - DefaultDataDir = "~/.atlantis" - DefaultGHHostname = "github.com" - DefaultGitlabHostname = "gitlab.com" - DefaultLogLevel = "info" - DefaultPort = 4141 + DefaultBitbucketBaseURL = bitbucketcloud.BaseURL + DefaultDataDir = "~/.atlantis" + DefaultGHHostname = "github.com" + DefaultGitlabHostname = "gitlab.com" + DefaultLogLevel = "info" + DefaultPort = 4141 ) const RedTermStart = "\033[31m" @@ -81,9 +84,18 @@ var stringFlags = []stringFlag{ description: "Bitbucket app password of API user. Can also be specified via the ATLANTIS_BITBUCKET_TOKEN environment variable.", }, { - name: BitbucketHostnameFlag, - description: "Currently not supported! We only support bitbucket cloud (aka bitbucket.org) at this time.", - defaultValue: DefaultBitbucketHostname, + name: BitbucketBaseURLFlag, + description: "Base URL of Bitbucket Server (aka Stash) installation." + + " Must include scheme, ex. 'http://bitbucket.corp:7990' or 'https://bitbucket.corp'." + + " If using Bitbucket Cloud (bitbucket.org), do not set.", + defaultValue: DefaultBitbucketBaseURL, + }, + { + name: BitbucketWebhookSecretFlag, + description: "Secret used to validate Bitbucket webhooks. Only Bitbucket Server supports webhook secrets." + + " SECURITY WARNING: If not specified, Atlantis won't be able to validate that the incoming webhook call came from Bitbucket. " + + "This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. " + + "Should be specified via the ATLANTIS_BITBUCKET_WEBHOOK_SECRET environment variable.", }, { name: ConfigFlag, @@ -108,7 +120,7 @@ var stringFlags = []stringFlag{ description: "GitHub token of API user. Can also be specified via the ATLANTIS_GH_TOKEN environment variable.", }, { - name: GHWebHookSecret, + name: GHWebhookSecretFlag, description: "Secret used to validate GitHub webhooks (see https://developer.github.com/webhooks/securing/)." + " SECURITY WARNING: If not specified, Atlantis won't be able to validate that the incoming webhook call came from GitHub. " + "This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. " + @@ -128,7 +140,7 @@ var stringFlags = []stringFlag{ description: "GitLab token of API user. Can also be specified via the ATLANTIS_GITLAB_TOKEN environment variable.", }, { - name: GitlabWebHookSecret, + name: GitlabWebhookSecretFlag, description: "Optional secret used to validate GitLab webhooks." + " SECURITY WARNING: If not specified, Atlantis won't be able to validate that the incoming webhook call came from GitLab. " + "This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. " + @@ -143,7 +155,8 @@ var stringFlags = []stringFlag{ name: RepoWhitelistFlag, description: "Comma separated list of repositories that Atlantis will operate on. " + "The format is {hostname}/{owner}/{repo}, ex. github.com/runatlantis/atlantis. '*' matches any characters until the next comma and can be used for example to whitelist " + - "all repos: '*' (not recommended), an entire hostname: 'internalgithub.com/*' or an organization: 'github.com/runatlantis/*'.", + "all repos: '*' (not recommended), an entire hostname: 'internalgithub.com/*' or an organization: 'github.com/runatlantis/*'." + + " For Bitbucket Server, {hostname} is the domain without scheme and port, {owner} is the name of the project (not the key), and {repo} is the repo name.", }, { name: SSLCertFileFlag, @@ -343,8 +356,8 @@ func (s *ServerCmd) setDefaults(c *server.UserConfig) { if c.GitlabHostname == "" { c.GitlabHostname = DefaultGitlabHostname } - if c.BitbucketHostname == "" { - c.BitbucketHostname = DefaultBitbucketHostname + if c.BitbucketBaseURL == "" { + c.BitbucketBaseURL = DefaultBitbucketBaseURL } if c.LogLevel == "" { c.LogLevel = DefaultLogLevel @@ -364,10 +377,6 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error { return fmt.Errorf("--%s and --%s are both required for ssl", SSLKeyFileFlag, SSLCertFileFlag) } - if userConfig.BitbucketHostname != DefaultBitbucketHostname { - return fmt.Errorf("--%s is currently not allowed because we only support bitbucket cloud", BitbucketHostnameFlag) - } - // The following combinations are valid. // 1. github user and token set // 2. gitlab user and token set @@ -390,6 +399,17 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error { return fmt.Errorf("--%s cannot contain ://, should be hostnames only", RepoWhitelistFlag) } + if userConfig.BitbucketBaseURL == DefaultBitbucketBaseURL && userConfig.BitbucketWebhookSecret != "" { + return fmt.Errorf("--%s cannot be specified for Bitbucket Cloud because it is not supported by Bitbucket", BitbucketWebhookSecretFlag) + } + + parsed, err := url.Parse(userConfig.BitbucketBaseURL) + if err != nil { + return fmt.Errorf("error parsing --%s flag value %q: %s", BitbucketWebhookSecretFlag, userConfig.BitbucketBaseURL, err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("--%s must have http:// or https://, got %q", BitbucketBaseURLFlag, userConfig.BitbucketBaseURL) + } return nil } @@ -437,13 +457,16 @@ func (s *ServerCmd) trimAtSymbolFromUsers(userConfig *server.UserConfig) { } func (s *ServerCmd) securityWarnings(userConfig *server.UserConfig) { - if userConfig.GithubUser != "" && userConfig.GithubWebHookSecret == "" && !s.SilenceOutput { + if userConfig.GithubUser != "" && userConfig.GithubWebhookSecret == "" && !s.SilenceOutput { fmt.Fprintf(os.Stderr, "%s[WARN] No GitHub webhook secret set. This could allow attackers to spoof requests from GitHub.%s\n", RedTermStart, RedTermEnd) } - if userConfig.GitlabUser != "" && userConfig.GitlabWebHookSecret == "" && !s.SilenceOutput { + if userConfig.GitlabUser != "" && userConfig.GitlabWebhookSecret == "" && !s.SilenceOutput { fmt.Fprintf(os.Stderr, "%s[WARN] No GitLab webhook secret set. This could allow attackers to spoof requests from GitLab.%s\n", RedTermStart, RedTermEnd) } - if userConfig.BitbucketUser != "" && userConfig.BitbucketHostname == DefaultBitbucketHostname && !s.SilenceOutput { + if userConfig.BitbucketUser != "" && userConfig.BitbucketBaseURL != DefaultBitbucketBaseURL && userConfig.BitbucketWebhookSecret == "" && !s.SilenceOutput { + fmt.Fprintf(os.Stderr, "%s[WARN] No Bitbucket webhook secret set. This could allow attackers to spoof requests from Bitbucket.%s\n", RedTermStart, RedTermEnd) + } + if userConfig.BitbucketUser != "" && userConfig.BitbucketBaseURL == DefaultBitbucketBaseURL && !s.SilenceOutput { fmt.Fprintf(os.Stderr, "%s[WARN] Bitbucket Cloud does not support webhook secrets. This could allow attackers to spoof requests from Bitbucket. Ensure you are whitelisting Bitbucket IPs.%s\n", RedTermStart, RedTermEnd) } } diff --git a/cmd/server_test.go b/cmd/server_test.go index 11116cd062..9397146fe5 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -305,19 +305,6 @@ func TestExecute_ValidateVCSConfig(t *testing.T) { } } -// Currently we only support bitbucket cloud so we shouldn't allow setting of -// the bitbucket hostname flag. -func TestExecute_BitbucketHostname(t *testing.T) { - c := setup(map[string]interface{}{ - cmd.BitbucketTokenFlag: "bitbucket-token", - cmd.BitbucketUserFlag: "bitbucket-token", - cmd.BitbucketHostnameFlag: "hostname", - cmd.RepoWhitelistFlag: "*", - }) - err := c.Execute() - ErrEquals(t, "--bitbucket-hostname is currently not allowed because we only support bitbucket cloud", err) -} - func TestExecute_Defaults(t *testing.T) { t.Log("Should set the defaults for all unspecified flags.") c := setup(map[string]interface{}{ @@ -347,14 +334,15 @@ func TestExecute_Defaults(t *testing.T) { Equals(t, "github.com", passedConfig.GithubHostname) Equals(t, "token", passedConfig.GithubToken) Equals(t, "user", passedConfig.GithubUser) - Equals(t, "", passedConfig.GithubWebHookSecret) + Equals(t, "", passedConfig.GithubWebhookSecret) Equals(t, "gitlab.com", passedConfig.GitlabHostname) Equals(t, "gitlab-token", passedConfig.GitlabToken) Equals(t, "gitlab-user", passedConfig.GitlabUser) - Equals(t, "", passedConfig.GitlabWebHookSecret) - Equals(t, "bitbucket.org", passedConfig.BitbucketHostname) + Equals(t, "", passedConfig.GitlabWebhookSecret) + Equals(t, "https://api.bitbucket.org", passedConfig.BitbucketBaseURL) Equals(t, "bitbucket-token", passedConfig.BitbucketToken) Equals(t, "bitbucket-user", passedConfig.BitbucketUser) + Equals(t, "", passedConfig.BitbucketWebhookSecret) Equals(t, "info", passedConfig.LogLevel) Equals(t, 4141, passedConfig.Port) Equals(t, false, passedConfig.RequireApproval) @@ -435,27 +423,28 @@ func TestExecute_BitbucketUser(t *testing.T) { func TestExecute_Flags(t *testing.T) { t.Log("Should use all flags that are set.") c := setup(map[string]interface{}{ - cmd.AtlantisURLFlag: "url", - cmd.AllowForkPRsFlag: true, - cmd.AllowRepoConfigFlag: true, - //cmd.BitbucketHostnameFlag: "ghhostname", - cmd.BitbucketTokenFlag: "bitbucket-token", - cmd.BitbucketUserFlag: "bitbucket-user", - cmd.DataDirFlag: "/path", - cmd.GHHostnameFlag: "ghhostname", - cmd.GHTokenFlag: "token", - cmd.GHUserFlag: "user", - cmd.GHWebHookSecret: "secret", - cmd.GitlabHostnameFlag: "gitlab-hostname", - cmd.GitlabTokenFlag: "gitlab-token", - cmd.GitlabUserFlag: "gitlab-user", - cmd.GitlabWebHookSecret: "gitlab-secret", - cmd.LogLevelFlag: "debug", - cmd.PortFlag: 8181, - cmd.RepoWhitelistFlag: "github.com/runatlantis/atlantis", - cmd.RequireApprovalFlag: true, - cmd.SSLCertFileFlag: "cert-file", - cmd.SSLKeyFileFlag: "key-file", + cmd.AtlantisURLFlag: "url", + cmd.AllowForkPRsFlag: true, + cmd.AllowRepoConfigFlag: true, + cmd.BitbucketBaseURLFlag: "https://bitbucket-base-url.com", + cmd.BitbucketTokenFlag: "bitbucket-token", + cmd.BitbucketUserFlag: "bitbucket-user", + cmd.BitbucketWebhookSecretFlag: "bitbucket-secret", + cmd.DataDirFlag: "/path", + cmd.GHHostnameFlag: "ghhostname", + cmd.GHTokenFlag: "token", + cmd.GHUserFlag: "user", + cmd.GHWebhookSecretFlag: "secret", + cmd.GitlabHostnameFlag: "gitlab-hostname", + cmd.GitlabTokenFlag: "gitlab-token", + cmd.GitlabUserFlag: "gitlab-user", + cmd.GitlabWebhookSecretFlag: "gitlab-secret", + cmd.LogLevelFlag: "debug", + cmd.PortFlag: 8181, + cmd.RepoWhitelistFlag: "github.com/runatlantis/atlantis", + cmd.RequireApprovalFlag: true, + cmd.SSLCertFileFlag: "cert-file", + cmd.SSLKeyFileFlag: "key-file", }) err := c.Execute() Ok(t, err) @@ -463,17 +452,19 @@ func TestExecute_Flags(t *testing.T) { Equals(t, "url", passedConfig.AtlantisURL) Equals(t, true, passedConfig.AllowForkPRs) Equals(t, true, passedConfig.AllowRepoConfig) + Equals(t, "https://bitbucket-base-url.com", passedConfig.BitbucketBaseURL) Equals(t, "bitbucket-token", passedConfig.BitbucketToken) Equals(t, "bitbucket-user", passedConfig.BitbucketUser) + Equals(t, "bitbucket-secret", passedConfig.BitbucketWebhookSecret) Equals(t, "/path", passedConfig.DataDir) Equals(t, "ghhostname", passedConfig.GithubHostname) Equals(t, "token", passedConfig.GithubToken) Equals(t, "user", passedConfig.GithubUser) - Equals(t, "secret", passedConfig.GithubWebHookSecret) + Equals(t, "secret", passedConfig.GithubWebhookSecret) Equals(t, "gitlab-hostname", passedConfig.GitlabHostname) Equals(t, "gitlab-token", passedConfig.GitlabToken) Equals(t, "gitlab-user", passedConfig.GitlabUser) - Equals(t, "gitlab-secret", passedConfig.GitlabWebHookSecret) + Equals(t, "gitlab-secret", passedConfig.GitlabWebhookSecret) Equals(t, "debug", passedConfig.LogLevel) Equals(t, 8181, passedConfig.Port) Equals(t, "github.com/runatlantis/atlantis", passedConfig.RepoWhitelist) @@ -488,8 +479,10 @@ func TestExecute_ConfigFile(t *testing.T) { atlantis-url: "url" allow-fork-prs: true allow-repo-config: true +bitbucket-base-url: "https://mydomain.com" bitbucket-token: "bitbucket-token" bitbucket-user: "bitbucket-user" +bitbucket-webhook-secret: "bitbucket-secret" data-dir: "/path" gh-hostname: "ghhostname" gh-token: "token" @@ -516,17 +509,19 @@ ssl-key-file: key-file Equals(t, "url", passedConfig.AtlantisURL) Equals(t, true, passedConfig.AllowForkPRs) Equals(t, true, passedConfig.AllowRepoConfig) + Equals(t, "https://mydomain.com", passedConfig.BitbucketBaseURL) Equals(t, "bitbucket-token", passedConfig.BitbucketToken) Equals(t, "bitbucket-user", passedConfig.BitbucketUser) + Equals(t, "bitbucket-secret", passedConfig.BitbucketWebhookSecret) Equals(t, "/path", passedConfig.DataDir) Equals(t, "ghhostname", passedConfig.GithubHostname) Equals(t, "token", passedConfig.GithubToken) Equals(t, "user", passedConfig.GithubUser) - Equals(t, "secret", passedConfig.GithubWebHookSecret) + Equals(t, "secret", passedConfig.GithubWebhookSecret) Equals(t, "gitlab-hostname", passedConfig.GitlabHostname) Equals(t, "gitlab-token", passedConfig.GitlabToken) Equals(t, "gitlab-user", passedConfig.GitlabUser) - Equals(t, "gitlab-secret", passedConfig.GitlabWebHookSecret) + Equals(t, "gitlab-secret", passedConfig.GitlabWebhookSecret) Equals(t, "debug", passedConfig.LogLevel) Equals(t, 8181, passedConfig.Port) Equals(t, "github.com/runatlantis/atlantis", passedConfig.RepoWhitelist) @@ -541,8 +536,10 @@ func TestExecute_EnvironmentOverride(t *testing.T) { atlantis-url: "url" allow-fork-prs: true allow-repo-config: true +bitbucket-base-url: "https://mydomain.com" bitbucket-token: "bitbucket-token" bitbucket-user: "bitbucket-user" +bitbucket-webhook-secret: "bitbucket-secret" data-dir: "/path" gh-hostname: "ghhostname" gh-token: "token" @@ -563,26 +560,28 @@ ssl-key-file: key-file // NOTE: We add the ATLANTIS_ prefix below. for name, value := range map[string]string{ - "ATLANTIS_URL": "override-url", - "ALLOW_FORK_PRS": "false", - "ALLOW_REPO_CONFIG": "false", - "BITBUCKET_TOKEN": "override-bitbucket-token", - "BITBUCKET_USER": "override-bitbucket-user", - "DATA_DIR": "/override-path", - "GH_HOSTNAME": "override-gh-hostname", - "GH_TOKEN": "override-gh-token", - "GH_USER": "override-gh-user", - "GH_WEBHOOK_SECRET": "override-gh-webhook-secret", - "GITLAB_HOSTNAME": "override-gitlab-hostname", - "GITLAB_TOKEN": "override-gitlab-token", - "GITLAB_USER": "override-gitlab-user", - "GITLAB_WEBHOOK_SECRET": "override-gitlab-webhook-secret", - "LOG_LEVEL": "info", - "PORT": "8282", - "REPO_WHITELIST": "override,override", - "REQUIRE_APPROVAL": "false", - "SSL_CERT_FILE": "override-cert-file", - "SSL_KEY_FILE": "override-key-file", + "ATLANTIS_URL": "override-url", + "ALLOW_FORK_PRS": "false", + "ALLOW_REPO_CONFIG": "false", + "BITBUCKET_BASE_URL": "https://override-bitbucket-base-url", + "BITBUCKET_TOKEN": "override-bitbucket-token", + "BITBUCKET_USER": "override-bitbucket-user", + "BITBUCKET_WEBHOOK_SECRET": "override-bitbucket-secret", + "DATA_DIR": "/override-path", + "GH_HOSTNAME": "override-gh-hostname", + "GH_TOKEN": "override-gh-token", + "GH_USER": "override-gh-user", + "GH_WEBHOOK_SECRET": "override-gh-webhook-secret", + "GITLAB_HOSTNAME": "override-gitlab-hostname", + "GITLAB_TOKEN": "override-gitlab-token", + "GITLAB_USER": "override-gitlab-user", + "GITLAB_WEBHOOK_SECRET": "override-gitlab-webhook-secret", + "LOG_LEVEL": "info", + "PORT": "8282", + "REPO_WHITELIST": "override,override", + "REQUIRE_APPROVAL": "false", + "SSL_CERT_FILE": "override-cert-file", + "SSL_KEY_FILE": "override-key-file", } { os.Setenv("ATLANTIS_"+name, value) // nolint: errcheck } @@ -594,17 +593,19 @@ ssl-key-file: key-file Equals(t, "override-url", passedConfig.AtlantisURL) Equals(t, false, passedConfig.AllowForkPRs) Equals(t, false, passedConfig.AllowRepoConfig) + Equals(t, "https://override-bitbucket-base-url", passedConfig.BitbucketBaseURL) Equals(t, "override-bitbucket-token", passedConfig.BitbucketToken) Equals(t, "override-bitbucket-user", passedConfig.BitbucketUser) + Equals(t, "override-bitbucket-secret", passedConfig.BitbucketWebhookSecret) Equals(t, "/override-path", passedConfig.DataDir) Equals(t, "override-gh-hostname", passedConfig.GithubHostname) Equals(t, "override-gh-token", passedConfig.GithubToken) Equals(t, "override-gh-user", passedConfig.GithubUser) - Equals(t, "override-gh-webhook-secret", passedConfig.GithubWebHookSecret) + Equals(t, "override-gh-webhook-secret", passedConfig.GithubWebhookSecret) Equals(t, "override-gitlab-hostname", passedConfig.GitlabHostname) Equals(t, "override-gitlab-token", passedConfig.GitlabToken) Equals(t, "override-gitlab-user", passedConfig.GitlabUser) - Equals(t, "override-gitlab-webhook-secret", passedConfig.GitlabWebHookSecret) + Equals(t, "override-gitlab-webhook-secret", passedConfig.GitlabWebhookSecret) Equals(t, "info", passedConfig.LogLevel) Equals(t, 8282, passedConfig.Port) Equals(t, "override,override", passedConfig.RepoWhitelist) @@ -619,8 +620,10 @@ func TestExecute_FlagConfigOverride(t *testing.T) { atlantis-url: "url" allow-fork-prs: true allow-repo-config: true +bitbucket-base-url: "https://bitbucket-base-url" bitbucket-token: "bitbucket-token" bitbucket-user: "bitbucket-user" +bitbucket-webhook-secret: "bitbucket-secret" data-dir: "/path" gh-hostname: "ghhostname" gh-token: "token" @@ -640,42 +643,46 @@ ssl-key-file: key-file defer os.Remove(tmpFile) // nolint: errcheck c := setup(map[string]interface{}{ - cmd.AtlantisURLFlag: "override-url", - cmd.AllowForkPRsFlag: false, - cmd.AllowRepoConfigFlag: false, - cmd.BitbucketTokenFlag: "override-bitbucket-token", - cmd.BitbucketUserFlag: "override-bitbucket-user", - cmd.DataDirFlag: "/override-path", - cmd.GHHostnameFlag: "override-gh-hostname", - cmd.GHTokenFlag: "override-gh-token", - cmd.GHUserFlag: "override-gh-user", - cmd.GHWebHookSecret: "override-gh-webhook-secret", - cmd.GitlabHostnameFlag: "override-gitlab-hostname", - cmd.GitlabTokenFlag: "override-gitlab-token", - cmd.GitlabUserFlag: "override-gitlab-user", - cmd.GitlabWebHookSecret: "override-gitlab-webhook-secret", - cmd.LogLevelFlag: "info", - cmd.PortFlag: 8282, - cmd.RepoWhitelistFlag: "override,override", - cmd.RequireApprovalFlag: false, - cmd.SSLCertFileFlag: "override-cert-file", - cmd.SSLKeyFileFlag: "override-key-file", + cmd.AtlantisURLFlag: "override-url", + cmd.AllowForkPRsFlag: false, + cmd.AllowRepoConfigFlag: false, + cmd.BitbucketBaseURLFlag: "https://override-bitbucket-base-url", + cmd.BitbucketTokenFlag: "override-bitbucket-token", + cmd.BitbucketUserFlag: "override-bitbucket-user", + cmd.BitbucketWebhookSecretFlag: "override-bitbucket-secret", + cmd.DataDirFlag: "/override-path", + cmd.GHHostnameFlag: "override-gh-hostname", + cmd.GHTokenFlag: "override-gh-token", + cmd.GHUserFlag: "override-gh-user", + cmd.GHWebhookSecretFlag: "override-gh-webhook-secret", + cmd.GitlabHostnameFlag: "override-gitlab-hostname", + cmd.GitlabTokenFlag: "override-gitlab-token", + cmd.GitlabUserFlag: "override-gitlab-user", + cmd.GitlabWebhookSecretFlag: "override-gitlab-webhook-secret", + cmd.LogLevelFlag: "info", + cmd.PortFlag: 8282, + cmd.RepoWhitelistFlag: "override,override", + cmd.RequireApprovalFlag: false, + cmd.SSLCertFileFlag: "override-cert-file", + cmd.SSLKeyFileFlag: "override-key-file", }) err := c.Execute() Ok(t, err) Equals(t, "override-url", passedConfig.AtlantisURL) Equals(t, false, passedConfig.AllowForkPRs) + Equals(t, "https://override-bitbucket-base-url", passedConfig.BitbucketBaseURL) Equals(t, "override-bitbucket-token", passedConfig.BitbucketToken) Equals(t, "override-bitbucket-user", passedConfig.BitbucketUser) + Equals(t, "override-bitbucket-secret", passedConfig.BitbucketWebhookSecret) Equals(t, "/override-path", passedConfig.DataDir) Equals(t, "override-gh-hostname", passedConfig.GithubHostname) Equals(t, "override-gh-token", passedConfig.GithubToken) Equals(t, "override-gh-user", passedConfig.GithubUser) - Equals(t, "override-gh-webhook-secret", passedConfig.GithubWebHookSecret) + Equals(t, "override-gh-webhook-secret", passedConfig.GithubWebhookSecret) Equals(t, "override-gitlab-hostname", passedConfig.GitlabHostname) Equals(t, "override-gitlab-token", passedConfig.GitlabToken) Equals(t, "override-gitlab-user", passedConfig.GitlabUser) - Equals(t, "override-gitlab-webhook-secret", passedConfig.GitlabWebHookSecret) + Equals(t, "override-gitlab-webhook-secret", passedConfig.GitlabWebhookSecret) Equals(t, "info", passedConfig.LogLevel) Equals(t, 8282, passedConfig.Port) Equals(t, "override,override", passedConfig.RepoWhitelist) @@ -687,52 +694,63 @@ ssl-key-file: key-file func TestExecute_FlagEnvVarOverride(t *testing.T) { t.Log("Flags should override environment variables.") - for name, value := range map[string]string{ - "ATLANTIS_URL": "url", - "ALLOW_FORK_PRS": "true", - "ALLOW_REPO_CONFIG": "true", - "BITBUCKET_TOKEN": "bitbucket-token", - "BITBUCKET_USER": "bitbucket-user", - "DATA_DIR": "/path", - "GH_HOSTNAME": "gh-hostname", - "GH_TOKEN": "gh-token", - "GH_USER": "gh-user", - "GH_WEBHOOK_SECRET": "gh-webhook-secret", - "GITLAB_HOSTNAME": "gitlab-hostname", - "GITLAB_TOKEN": "gitlab-token", - "GITLAB_USER": "gitlab-user", - "GITLAB_WEBHOOK_SECRET": "gitlab-webhook-secret", - "LOG_LEVEL": "debug", - "PORT": "8181", - "REPO_WHITELIST": "*", - "REQUIRE_APPROVAL": "true", - "SSL_CERT_FILE": "cert-file", - "SSL_KEY_FILE": "key-file", - } { + envVars := map[string]string{ + "ATLANTIS_URL": "url", + "ALLOW_FORK_PRS": "true", + "ALLOW_REPO_CONFIG": "true", + "BITBUCKET_BASE_URL": "https://bitbucket-base-url", + "BITBUCKET_TOKEN": "bitbucket-token", + "BITBUCKET_USER": "bitbucket-user", + "BITBUCKET_WEBHOOK_SECRET": "bitbucket-secret", + "DATA_DIR": "/path", + "GH_HOSTNAME": "gh-hostname", + "GH_TOKEN": "gh-token", + "GH_USER": "gh-user", + "GH_WEBHOOK_SECRET": "gh-webhook-secret", + "GITLAB_HOSTNAME": "gitlab-hostname", + "GITLAB_TOKEN": "gitlab-token", + "GITLAB_USER": "gitlab-user", + "GITLAB_WEBHOOK_SECRET": "gitlab-webhook-secret", + "LOG_LEVEL": "debug", + "PORT": "8181", + "REPO_WHITELIST": "*", + "REQUIRE_APPROVAL": "true", + "SSL_CERT_FILE": "cert-file", + "SSL_KEY_FILE": "key-file", + } + for name, value := range envVars { os.Setenv("ATLANTIS_"+name, value) // nolint: errcheck } + defer func() { + // Unset after this test finishes. + for name := range envVars { + os.Unsetenv("ATLANTIS_" + name) // nolint: errcheck + } + }() c := setup(map[string]interface{}{ - cmd.AtlantisURLFlag: "override-url", - cmd.AllowForkPRsFlag: false, - cmd.AllowRepoConfigFlag: false, - cmd.BitbucketTokenFlag: "override-bitbucket-token", - cmd.BitbucketUserFlag: "override-bitbucket-user", - cmd.DataDirFlag: "/override-path", - cmd.GHHostnameFlag: "override-gh-hostname", - cmd.GHTokenFlag: "override-gh-token", - cmd.GHUserFlag: "override-gh-user", - cmd.GHWebHookSecret: "override-gh-webhook-secret", - cmd.GitlabHostnameFlag: "override-gitlab-hostname", - cmd.GitlabTokenFlag: "override-gitlab-token", - cmd.GitlabUserFlag: "override-gitlab-user", - cmd.GitlabWebHookSecret: "override-gitlab-webhook-secret", - cmd.LogLevelFlag: "info", - cmd.PortFlag: 8282, - cmd.RepoWhitelistFlag: "override,override", - cmd.RequireApprovalFlag: false, - cmd.SSLCertFileFlag: "override-cert-file", - cmd.SSLKeyFileFlag: "override-key-file", + cmd.AtlantisURLFlag: "override-url", + cmd.AllowForkPRsFlag: false, + cmd.AllowRepoConfigFlag: false, + cmd.BitbucketBaseURLFlag: "https://override-bitbucket-base-url", + cmd.BitbucketTokenFlag: "override-bitbucket-token", + cmd.BitbucketUserFlag: "override-bitbucket-user", + cmd.BitbucketWebhookSecretFlag: "override-bitbucket-secret", + cmd.DataDirFlag: "/override-path", + cmd.GHHostnameFlag: "override-gh-hostname", + cmd.GHTokenFlag: "override-gh-token", + cmd.GHUserFlag: "override-gh-user", + cmd.GHWebhookSecretFlag: "override-gh-webhook-secret", + cmd.GitlabHostnameFlag: "override-gitlab-hostname", + cmd.GitlabTokenFlag: "override-gitlab-token", + cmd.GitlabUserFlag: "override-gitlab-user", + cmd.GitlabWebhookSecretFlag: "override-gitlab-webhook-secret", + cmd.LogLevelFlag: "info", + cmd.PortFlag: 8282, + cmd.RepoWhitelistFlag: "override,override", + cmd.RequireApprovalFlag: false, + cmd.SSLCertFileFlag: "override-cert-file", + cmd.SSLKeyFileFlag: "override-key-file", }) err := c.Execute() Ok(t, err) @@ -740,17 +758,19 @@ func TestExecute_FlagEnvVarOverride(t *testing.T) { Equals(t, "override-url", passedConfig.AtlantisURL) Equals(t, false, passedConfig.AllowForkPRs) Equals(t, false, passedConfig.AllowRepoConfig) + Equals(t, "https://override-bitbucket-base-url", passedConfig.BitbucketBaseURL) Equals(t, "override-bitbucket-token", passedConfig.BitbucketToken) Equals(t, "override-bitbucket-user", passedConfig.BitbucketUser) + Equals(t, "override-bitbucket-secret", passedConfig.BitbucketWebhookSecret) Equals(t, "/override-path", passedConfig.DataDir) Equals(t, "override-gh-hostname", passedConfig.GithubHostname) Equals(t, "override-gh-token", passedConfig.GithubToken) Equals(t, "override-gh-user", passedConfig.GithubUser) - Equals(t, "override-gh-webhook-secret", passedConfig.GithubWebHookSecret) + Equals(t, "override-gh-webhook-secret", passedConfig.GithubWebhookSecret) Equals(t, "override-gitlab-hostname", passedConfig.GitlabHostname) Equals(t, "override-gitlab-token", passedConfig.GitlabToken) Equals(t, "override-gitlab-user", passedConfig.GitlabUser) - Equals(t, "override-gitlab-webhook-secret", passedConfig.GitlabWebHookSecret) + Equals(t, "override-gitlab-webhook-secret", passedConfig.GitlabWebhookSecret) Equals(t, "info", passedConfig.LogLevel) Equals(t, 8282, passedConfig.Port) Equals(t, "override,override", passedConfig.RepoWhitelist) @@ -759,6 +779,49 @@ func TestExecute_FlagEnvVarOverride(t *testing.T) { Equals(t, "override-key-file", passedConfig.SSLKeyFile) } +// If using bitbucket cloud, webhook secrets are not supported. +func TestExecute_BitbucketCloudWithWebhookSecret(t *testing.T) { + c := setup(map[string]interface{}{ + cmd.BitbucketUserFlag: "user", + cmd.BitbucketTokenFlag: "token", + cmd.RepoWhitelistFlag: "*", + cmd.BitbucketWebhookSecretFlag: "my secret", + }) + err := c.Execute() + ErrEquals(t, "--bitbucket-webhook-secret cannot be specified for Bitbucket Cloud because it is not supported by Bitbucket", err) +} + +// Base URL must have a scheme. +func TestExecute_BitbucketServerBaseURLScheme(t *testing.T) { + c := setup(map[string]interface{}{ + cmd.BitbucketUserFlag: "user", + cmd.BitbucketTokenFlag: "token", + cmd.RepoWhitelistFlag: "*", + cmd.BitbucketBaseURLFlag: "mydomain.com", + }) + ErrEquals(t, "--bitbucket-base-url must have http:// or https://, got \"mydomain.com\"", c.Execute()) + + c = setup(map[string]interface{}{ + cmd.BitbucketUserFlag: "user", + cmd.BitbucketTokenFlag: "token", + cmd.RepoWhitelistFlag: "*", + cmd.BitbucketBaseURLFlag: "://mydomain.com", + }) + ErrEquals(t, "error parsing --bitbucket-webhook-secret flag value \"://mydomain.com\": parse ://mydomain.com: missing protocol scheme", c.Execute()) +} + +// Port should be retained on base url. +func TestExecute_BitbucketServerBaseURLPort(t *testing.T) { + c := setup(map[string]interface{}{ + cmd.BitbucketUserFlag: "user", + cmd.BitbucketTokenFlag: "token", + cmd.RepoWhitelistFlag: "*", + cmd.BitbucketBaseURLFlag: "http://mydomain.com:7990", + }) + Ok(t, c.Execute()) + Equals(t, "http://mydomain.com:7990", passedConfig.BitbucketBaseURL) +} + func setup(flags map[string]interface{}) *cobra.Command { vipr := viper.New() for k, v := range flags { diff --git a/runatlantis.io/docs/deployment.md b/runatlantis.io/docs/deployment.md index 8dda719eff..7d846b40c3 100644 --- a/runatlantis.io/docs/deployment.md +++ b/runatlantis.io/docs/deployment.md @@ -24,7 +24,7 @@ Once you've decided where to host Atlantis you need to add that URL as a webhook to your Git host so that Atlantis gets notified about pull request events. See the instructions for your specific provider below: -### GitHub Webhook +### GitHub/GitHub Enterprise Webhook If you already have a GitHub organization we recommend installing the webhook at the **organization level** rather than on each repository, however both methods will work. ::: tip @@ -78,6 +78,19 @@ If you're using GitLab, navigate to your project's home page in GitLab - Click **Save** Bitbucket Webhook +### Bitbucket Server (aka Stash) Webhook +- Go to your repo's home page +- Click **Settings** in the sidebar +- Click **Webhooks** under the **WORKFLOW** section +- Click **Create webhook** +- Enter "Atlantis" for **Name** +- set **URL** to `http://$URL/events` (or `https://$URL/events` if you're using SSL) where `$URL` is where Atlantis is hosted. **Be sure to add `/events`** +- Double-check you added `/events` to the end of your URL. +- Set **Secret** to a random key (https://www.random.org/strings/). You'll need to pass this value to the `--bitbucket-webhook-secret` flag when you start Atlantis +- Under **Repository** select **Push** +- Under **Pull Request**, select: Opened, Modified, Merged, Declined, Deleted and Comment added +- Click **Save**Bitbucket Webhook + ## Create an access token for Atlantis We recommend using a dedicated CI user or creating a new user named **@atlantis** that performs all API actions, however for testing, you can use your own user. Here we'll create the access token that Atlantis uses to comment on the pull request and @@ -101,10 +114,18 @@ set commit statuses. - Select **Pull requests**: **Read** and **Write** so that Atlantis can read your pull requests and write comments to them - copy the access token +### Create a Bitbucket Server (aka Stash) Personal Access Token +- Click on your avatar in the top right and select **Manage account** +- Click **Personal access tokens** in the sidebar +- Click **Create a token** +- Name the token **atlantis** +- Give the token **Read** Project permissions and **Write** Pull request permissions +- Click **Create** and copy the access token + ## Start Atlantis Now you're ready to start Atlantis! The exact command depends on your Git host: -### GitHub +### GitHub Command ```bash atlantis server \ --atlantis-url="$URL" \ @@ -114,7 +135,7 @@ atlantis server \ --repo-whitelist="$REPO_WHITELIST" ``` -### GitHub Enterprise +### GitHub Enterprise Command ```bash HOSTNAME=YOUR_GITHUB_ENTERPRISE_HOSTNAME # ex. github.runatlantis.io atlantis server \ @@ -126,7 +147,7 @@ atlantis server \ --repo-whitelist="$REPO_WHITELIST" ``` -### GitLab +### GitLab Command ```bash atlantis server \ --atlantis-url="$URL" \ @@ -136,7 +157,7 @@ atlantis server \ --repo-whitelist="$REPO_WHITELIST" ``` -### GitLab Enterprise +### GitLab Enterprise Command ```bash HOSTNAME=YOUR_GITLAB_ENTERPRISE_HOSTNAME # ex. gitlab.runatlantis.io atlantis server \ @@ -148,12 +169,24 @@ atlantis server \ --repo-whitelist="$REPO_WHITELIST" ``` -### Bitbucket Cloud (bitbucket.org) +### Bitbucket Cloud (bitbucket.org) Command +```bash +atlantis server \ +--atlantis-url="$URL" \ +--bitbucket-user="$USERNAME" \ +--bitbucket-token="$TOKEN" \ +--repo-whitelist="$REPO_WHITELIST" +``` + +### Bitbucket Server (aka Stash) Command ```bash +BASE_URL=YOUR_BITBUCKET_SERVER_URL # ex. http://bitbucket.mycorp:7990 atlantis server \ --atlantis-url="$URL" \ --bitbucket-user="$USERNAME" \ --bitbucket-token="$TOKEN" \ +--bitbucket-webhook-secret="$SECRET" \ +--bitbucket-base-url="$BASE_URL" \ --repo-whitelist="$REPO_WHITELIST" ``` diff --git a/runatlantis.io/guide/getting-started.md b/runatlantis.io/guide/getting-started.md index 059e12196a..355552ef28 100644 --- a/runatlantis.io/guide/getting-started.md +++ b/runatlantis.io/guide/getting-started.md @@ -1,5 +1,5 @@ # Getting Started -These instructions are for running Atlantis locally so you can test it out against +These instructions are for running Atlantis **locally on your own computer** so you can test it out against your own repositories before deciding whether to install it more permanently. ::: tip @@ -43,7 +43,7 @@ URL="https://{YOUR_HOSTNAME}.ngrok.io" GitHub and GitLab use webhook secrets so clients can verify that the webhooks came from them. ::: warning -Bitbucket Cloud (bitbucket.org) doesn't use webhook secrets so if you're using Bitbucket you can skip this +Bitbucket Cloud (bitbucket.org) doesn't use webhook secrets so if you're using Bitbucket Cloud you can skip this however you should whitelist Bitbucket IPs as a precaution. ::: Create a random string of any length (you can use [http://www.unit-conversion.info/texttools/random-string-generator/](http://www.unit-conversion.info/texttools/random-string-generator/)) @@ -55,59 +55,98 @@ SECRET="{YOUR_RANDOM_STRING}" ## Add Webhook Take the URL that ngrok output and create a webhook in your GitHub, GitLab or Bitbucket repo: -### GitHub -- Go to your repo's settings -- Select **Webhooks** or **Hooks** in the sidebar -- Click **Add webhook** -- set **Payload URL** to your ngrok url with `/events` at the end. Ex. `https://c5004d84.ngrok.io/events` -- double-check you added `/events` to the end of your URL. -- set **Content type** to `application/json` -- set **Secret** to your random string -- select **Let me select individual events** -- check the boxes - - **Pull request reviews** - - **Pushes** - - **Issue comments** - - **Pull requests** -- leave **Active** checked -- click **Add webhook** - -### GitLab -- Go to your repo's home page -- Click **Settings > Integrations** in the sidebar -- set **URL** to your ngrok url with `/events` at the end. Ex. `https://c5004d84.ngrok.io/events` -- double-check you added `/events` to the end of your URL. -- set **Secret Token** to your random string -- check the boxes - - **Push events** - - **Comments** - - **Merge Request events** -- leave **Enable SSL verification** checked -- click **Add webhook** - -### Bitbucket Cloud (bitbucket.org) -- Go to your repo's home page -- Click **Settings** in the sidebar -- Click **Webhooks** under the **WORKFLOW** section -- Click **Add webhook** -- Enter "Atlantis" for **Title** -- set **URL** to your ngrok url with `/events` at the end. Ex. `https://c5004d84.ngrok.io/events` -- double-check you added `/events` to the end of your URL. -- Keep **Status** as Active -- Don't check **Skip certificate validation** because NGROK has a valid cert. -- Select **Choose from a full list of triggers** -- Under **Repository** **un**check everything -- Under **Issues** leave everything **un**checked -- Under **Pull Request**, select: Created, Updated, Merged, Declined and Comment created -- Click **Save** -Bitbucket Webhook +### GitHub or GitHub Enterprise Webhook +
+ Expand +
    +
  • Go to your repo's settings
  • +
  • Select Webhooks or Hooks in the sidebar
  • +
  • Click Add webhook
  • +
  • set Payload URL to your ngrok url with /events at the end. Ex. https://c5004d84.ngrok.io/events
  • +
  • double-check you added /events to the end of your URL.
  • +
  • set Content type to application/json
  • +
  • set Secret to your random string
  • +
  • select Let me select individual events
  • +
  • check the boxes +
      +
    • Pull request reviews
    • +
    • Pushes
    • +
    • Issue comments
    • +
    • Pull requests
    • +
    +
  • +
  • leave Active checked
  • +
  • click Add webhook
  • +
+
+ +### GitLab or GitLab Enterprise Webhook +
+ Expand +
    +
  • Go to your repo's home page
  • +
  • Click Settings > Integrations in the sidebar
  • +
  • set URL to your ngrok url with /events at the end. Ex. https://c5004d84.ngrok.io/events
  • +
  • double-check you added /events to the end of your URL.
  • +
  • set Secret Token to your random string
  • +
  • check the boxes +
      +
    • Push events
    • +
    • Comments
    • +
    • Merge Request events
    • +
    +
  • +
  • leave Enable SSL verification checked
  • +
  • click Add webhook
  • +
+
+ +### Bitbucket Cloud (bitbucket.org) Webhook +
+ Expand +
    +
  • Go to your repo's home page
  • +
  • Click Settings in the sidebar
  • +
  • Click Webhooks under the WORKFLOW section
  • +
  • Click Add webhook
  • +
  • Enter "Atlantis" for Title
  • +
  • Set URL to your ngrok url with /events at the end. Ex. https://c5004d84.ngrok.io/events
  • +
  • Double-check you added /events to the end of your URL.
  • +
  • Keep Status as Active
  • +
  • Don't check Skip certificate validation because NGROK has a valid cert.
  • +
  • Select Choose from a full list of triggers
  • +
  • Under Repositoryuncheck everything
  • +
  • Under Issues leave everything unchecked
  • +
  • Under Pull Request, select: Created, Updated, Merged, Declined and Comment created
  • +
  • Click SaveBitbucket Webhook
  • +
+
+ +### Bitbucket Server (aka Stash) Webhook +
+ Expand +
    +
  • Go to your repo's home page
  • +
  • Click Settings in the sidebar
  • +
  • Click Webhooks under the WORKFLOW section
  • +
  • Click Create webhook
  • +
  • Enter "Atlantis" for Name
  • +
  • Set URL to your ngrok url with /events at the end. Ex. https://c5004d84.ngrok.io/events
  • +
  • Double-check you added /events to the end of your URL.
  • +
  • Set Secret to your random string
  • +
  • Under Repository select Push
  • +
  • Under Pull Request, select: Opened, Modified, Merged, Declined, Deleted and Comment added
  • +
  • Click SaveBitbucket Webhook
  • +
+
+ ## Create an access token for Atlantis We recommend using a dedicated CI user or creating a new user named **@atlantis** that performs all API actions, however for testing, you can use your own user. Here we'll create the access token that Atlantis uses to comment on the pull request and set commit statuses. -### GitHub +### GitHub or GitHub Enterprise Access Token - follow [https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/#creating-a-token](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/#creating-a-token) - create a token with **repo** scope - set the token as an environment variable @@ -115,7 +154,7 @@ set commit statuses. TOKEN="{YOUR_TOKEN}" ``` -### GitLab +### GitLab or GitLab Enterprise Access Token - follow [https://docs.gitlab.com/ce/user/profile/personal_access_tokens.html#creating-a-personal-access-token](https://docs.gitlab.com/ce/user/profile/personal_access_tokens.html#creating-a-personal-access-token) - create a token with **api** scope - set the token as an environment variable @@ -123,7 +162,7 @@ TOKEN="{YOUR_TOKEN}" TOKEN="{YOUR_TOKEN}" ``` -### Bitbucket Cloud (bitbucket.org) +### Bitbucket Cloud (bitbucket.org) Access Token - follow [https://confluence.atlassian.com/bitbucket/app-passwords-828781300.html#Apppasswords-Createanapppassword](https://confluence.atlassian.com/bitbucket/app-passwords-828781300.html#Apppasswords-Createanapppassword) - Label the password "atlantis" - Select **Pull requests**: **Read** and **Write** so that Atlantis can read your pull requests and write comments to them @@ -132,6 +171,17 @@ TOKEN="{YOUR_TOKEN}" TOKEN="{YOUR_TOKEN}" ``` +### Bitbucket Server (aka Stash) Access Token +- Click on your avatar in the top right and select **Manage account** +- Click **Personal access tokens** in the sidebar +- Click **Create a token** +- Name the token **atlantis** +- Give the token **Read** Project permissions and **Write** Pull request permissions +- Click **create** and set the token as an environment variable +``` +TOKEN="{YOUR_TOKEN}" +``` + ## Start Atlantis You're almost ready to start Atlantis, just set two more variables: @@ -140,10 +190,13 @@ You're almost ready to start Atlantis, just set two more variables: USERNAME="{the username of your GitHub, GitLab or Bitbucket user}" REPO_WHITELIST="$YOUR_GIT_HOST/$YOUR_USERNAME/$YOUR_REPO" # ex. REPO_WHITELIST="github.com/runatlantis/atlantis" +# If you're using Bitbucket Server, $YOUR_GIT_HOST will be the domain name of your +# server without scheme or port and $YOUR_USERNAME will be the name of the **project** the repo +# is under, **not the key** of the project. ``` Now you can start Atlantis. The exact command differs depending on your Git Host: -### GitHub +### GitHub Command ```bash atlantis server \ --atlantis-url="$URL" \ @@ -153,7 +206,7 @@ atlantis server \ --repo-whitelist="$REPO_WHITELIST" ``` -### GitHub Enterprise +### GitHub Enterprise Command ```bash HOSTNAME=YOUR_GITHUB_ENTERPRISE_HOSTNAME # ex. github.runatlantis.io atlantis server \ @@ -165,7 +218,7 @@ atlantis server \ --repo-whitelist="$REPO_WHITELIST" ``` -### GitLab +### GitLab Command ```bash atlantis server \ --atlantis-url="$URL" \ @@ -175,7 +228,7 @@ atlantis server \ --repo-whitelist="$REPO_WHITELIST" ``` -### GitLab Enterprise +### GitLab Enterprise Command ```bash HOSTNAME=YOUR_GITLAB_ENTERPRISE_HOSTNAME # ex. gitlab.runatlantis.io atlantis server \ @@ -187,12 +240,24 @@ atlantis server \ --repo-whitelist="$REPO_WHITELIST" ``` -### Bitbucket Cloud (bitbucket.org) +### Bitbucket Cloud (bitbucket.org) Command +```bash +atlantis server \ +--atlantis-url="$URL" \ +--bitbucket-user="$USERNAME" \ +--bitbucket-token="$TOKEN" \ +--repo-whitelist="$REPO_WHITELIST" +``` + +### Bitbucket Server (aka Stash) Command ```bash +BASE_URL=YOUR_BITBUCKET_SERVER_URL # ex. http://bitbucket.mycorp:7990 atlantis server \ --atlantis-url="$URL" \ --bitbucket-user="$USERNAME" \ --bitbucket-token="$TOKEN" \ +--bitbucket-webhook-secret="$SECRET" \ +--bitbucket-base-url="$BASE_URL" \ --repo-whitelist="$REPO_WHITELIST" ``` diff --git a/runatlantis.io/guide/images/bitbucket-server-webhook.png b/runatlantis.io/guide/images/bitbucket-server-webhook.png new file mode 100644 index 0000000000..d9167c0bba Binary files /dev/null and b/runatlantis.io/guide/images/bitbucket-server-webhook.png differ diff --git a/runatlantis.io/guide/requirements.md b/runatlantis.io/guide/requirements.md index b77a754cd5..c7db091686 100644 --- a/runatlantis.io/guide/requirements.md +++ b/runatlantis.io/guide/requirements.md @@ -6,7 +6,7 @@ * GitHub (public, private or enterprise) * GitLab (public, private or enterprise) * Bitbucket Cloud aka bitbucket.org (public or private) -* Bitbucket Server aka Stash (Coming soon: [https://github.com/runatlantis/atlantis/issues/190](https://github.com/runatlantis/atlantis/issues/190)) +* Bitbucket Server aka Stash ## Remote State Atlantis supports all remote state backends. It **does not** support local state diff --git a/server/events/command_runner.go b/server/events/command_runner.go index 7fa7a8af68..42db89f739 100644 --- a/server/events/command_runner.go +++ b/server/events/command_runner.go @@ -132,7 +132,7 @@ func (c *DefaultCommandRunner) RunCommentCommand(baseRepo models.Repo, maybeHead pull, headRepo, err = c.getGithubData(baseRepo, pullNum) case models.Gitlab: pull, err = c.getGitlabData(baseRepo, pullNum) - case models.Bitbucket: + case models.BitbucketCloud, models.BitbucketServer: if maybePull == nil { err = errors.New("pull request should not be nil, this is a bug!") } @@ -228,13 +228,17 @@ func (c *DefaultCommandRunner) buildLogger(repoFullName string, pullNum int) *lo func (c *DefaultCommandRunner) validateCtxAndComment(ctx *CommandContext) bool { if !c.AllowForkPRs && ctx.HeadRepo.Owner != ctx.BaseRepo.Owner { ctx.Log.Info("command was run on a fork pull request which is disallowed") - c.VCSClient.CreateComment(ctx.BaseRepo, ctx.Pull.Num, fmt.Sprintf("Atlantis commands can't be run on fork pull requests. To enable, set --%s", c.AllowForkPRsFlag)) // nolint: errcheck + if err := c.VCSClient.CreateComment(ctx.BaseRepo, ctx.Pull.Num, fmt.Sprintf("Atlantis commands can't be run on fork pull requests. To enable, set --%s", c.AllowForkPRsFlag)); err != nil { + ctx.Log.Err("unable to comment: %s", err) + } return false } if ctx.Pull.State != models.OpenPullState { ctx.Log.Info("command was run on closed pull request") - c.VCSClient.CreateComment(ctx.BaseRepo, ctx.Pull.Num, "Atlantis commands can't be run on closed pull requests") // nolint: errcheck + if err := c.VCSClient.CreateComment(ctx.BaseRepo, ctx.Pull.Num, "Atlantis commands can't be run on closed pull requests"); err != nil { + ctx.Log.Err("unable to comment: %s", err) + } return false } return true @@ -253,7 +257,9 @@ func (c *DefaultCommandRunner) updatePull(ctx *CommandContext, command CommandIn ctx.Log.Warn("unable to update commit status: %s", err) } comment := c.MarkdownRenderer.Render(res, command.CommandName(), ctx.Log.History.String(), command.IsVerbose()) - c.VCSClient.CreateComment(ctx.BaseRepo, ctx.Pull.Num, comment) // nolint: errcheck + if err := c.VCSClient.CreateComment(ctx.BaseRepo, ctx.Pull.Num, comment); err != nil { + ctx.Log.Err("unable to comment: %s", err) + } } // logPanics logs and creates a comment on the pull request for panics. diff --git a/server/events/event_parser.go b/server/events/event_parser.go index 40f0082d9c..e790ebdd89 100644 --- a/server/events/event_parser.go +++ b/server/events/event_parser.go @@ -24,7 +24,8 @@ import ( "github.com/lkysow/go-gitlab" "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/events/models" - "github.com/runatlantis/atlantis/server/events/vcs/bitbucket" + "github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud" + "github.com/runatlantis/atlantis/server/events/vcs/bitbucketserver" "gopkg.in/go-playground/validator.v9" ) @@ -123,47 +124,52 @@ type EventParsing interface { ParseGitlabMergeRequest(mr *gitlab.MergeRequest, baseRepo models.Repo) models.PullRequest ParseBitbucketCloudPullEvent(body []byte) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) ParseBitbucketCloudCommentEvent(body []byte) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, comment string, err error) - GetBitbucketEventType(eventTypeHeader string) models.PullRequestEventType + GetBitbucketCloudEventType(eventTypeHeader string) models.PullRequestEventType + ParseBitbucketServerPullEvent(body []byte) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) + ParseBitbucketServerCommentEvent(body []byte) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, comment string, err error) + GetBitbucketServerEventType(eventTypeHeader string) models.PullRequestEventType } type EventParser struct { - GithubUser string - GithubToken string - GitlabUser string - GitlabToken string - BitbucketUser string - BitbucketToken string + GithubUser string + GithubToken string + GitlabUser string + GitlabToken string + BitbucketUser string + BitbucketToken string + BitbucketServerURL string } -// GetBitbucketEventType translates the bitbucket header name into a pull +// GetBitbucketCloudEventType translates the bitbucket header name into a pull // request event type. -func (e *EventParser) GetBitbucketEventType(eventTypeHeader string) models.PullRequestEventType { +func (e *EventParser) GetBitbucketCloudEventType(eventTypeHeader string) models.PullRequestEventType { switch eventTypeHeader { - case "pullrequest:created": + case bitbucketcloud.PullCreatedHeader: return models.OpenedPullEvent - case "pullrequest:updated": + case bitbucketcloud.PullUpdatedHeader: return models.UpdatedPullEvent - case "pullrequest:fulfilled", "pullrequest:rejected": + case bitbucketcloud.PullFulfilledHeader, bitbucketcloud.PullRejectedHeader: return models.ClosedPullEvent } return models.OtherPullEvent } func (e *EventParser) ParseBitbucketCloudCommentEvent(body []byte) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, comment string, err error) { - var event bitbucket.CommentEvent + var event bitbucketcloud.CommentEvent if err = json.Unmarshal(body, &event); err != nil { err = errors.Wrap(err, "parsing json") return } if err = validator.New().Struct(event); err != nil { + err = errors.Wrapf(err, "API response %q was missing fields", string(body)) return } - pull, baseRepo, headRepo, user, err = e.parseCommonBitbucketEventData(event.CommonEventData) + pull, baseRepo, headRepo, user, err = e.parseCommonBitbucketCloudEventData(event.CommonEventData) comment = *event.Comment.Content.Raw return } -func (e *EventParser) parseCommonBitbucketEventData(event bitbucket.CommonEventData) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) { +func (e *EventParser) parseCommonBitbucketCloudEventData(event bitbucketcloud.CommonEventData) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) { var prState models.PullRequestState switch *event.PullRequest.State { case "OPEN": @@ -180,7 +186,7 @@ func (e *EventParser) parseCommonBitbucketEventData(event bitbucket.CommonEventD } headRepo, err = models.NewRepo( - models.Bitbucket, + models.BitbucketCloud, *event.PullRequest.Source.Repository.FullName, *event.PullRequest.Source.Repository.Links.HTML.HREF, e.BitbucketUser, @@ -189,7 +195,7 @@ func (e *EventParser) parseCommonBitbucketEventData(event bitbucket.CommonEventD return } baseRepo, err = models.NewRepo( - models.Bitbucket, + models.BitbucketCloud, *event.Repository.FullName, *event.Repository.Links.HTML.HREF, e.BitbucketUser, @@ -214,15 +220,16 @@ func (e *EventParser) parseCommonBitbucketEventData(event bitbucket.CommonEventD } func (e *EventParser) ParseBitbucketCloudPullEvent(body []byte) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) { - var event bitbucket.PullRequestEvent + var event bitbucketcloud.PullRequestEvent if err = json.Unmarshal(body, &event); err != nil { err = errors.Wrap(err, "parsing json") return } if err = validator.New().Struct(event); err != nil { + err = errors.Wrapf(err, "API response %q was missing fields", string(body)) return } - pull, baseRepo, headRepo, user, err = e.parseCommonBitbucketEventData(event.CommonEventData) + pull, baseRepo, headRepo, user, err = e.parseCommonBitbucketCloudEventData(event.CommonEventData) return } @@ -423,3 +430,97 @@ func (e *EventParser) ParseGitlabMergeRequest(mr *gitlab.MergeRequest, baseRepo BaseRepo: baseRepo, } } + +func (e *EventParser) GetBitbucketServerEventType(eventTypeHeader string) models.PullRequestEventType { + switch eventTypeHeader { + case bitbucketserver.PullCreatedHeader: + return models.OpenedPullEvent + case bitbucketserver.PullMergedHeader, bitbucketserver.PullDeclinedHeader: + return models.ClosedPullEvent + } + return models.OtherPullEvent +} + +func (e *EventParser) ParseBitbucketServerCommentEvent(body []byte) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, comment string, err error) { + var event bitbucketserver.CommentEvent + if err = json.Unmarshal(body, &event); err != nil { + err = errors.Wrap(err, "parsing json") + return + } + if err = validator.New().Struct(event); err != nil { + err = errors.Wrapf(err, "API response %q was missing fields", string(body)) + return + } + pull, baseRepo, headRepo, user, err = e.parseCommonBitbucketServerEventData(event.CommonEventData) + comment = *event.Comment.Text + return +} + +func (e *EventParser) parseCommonBitbucketServerEventData(event bitbucketserver.CommonEventData) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) { + var prState models.PullRequestState + switch *event.PullRequest.State { + case "OPEN": + prState = models.OpenPullState + case "MERGED": + prState = models.ClosedPullState + case "DECLINED": + prState = models.ClosedPullState + default: + err = fmt.Errorf("unable to determine pull request state from %q, this is a bug!", *event.PullRequest.State) + return + } + + headRepoSlug := *event.PullRequest.FromRef.Repository.Slug + headRepoFullname := fmt.Sprintf("%s/%s", *event.PullRequest.FromRef.Repository.Project.Name, headRepoSlug) + headRepoCloneURL := fmt.Sprintf("%s/scm/%s/%s.git", e.BitbucketServerURL, strings.ToLower(*event.PullRequest.FromRef.Repository.Project.Key), headRepoSlug) + headRepo, err = models.NewRepo( + models.BitbucketServer, + headRepoFullname, + headRepoCloneURL, + e.BitbucketUser, + e.BitbucketToken) + if err != nil { + return + } + + baseRepoSlug := *event.PullRequest.ToRef.Repository.Slug + baseRepoFullname := fmt.Sprintf("%s/%s", *event.PullRequest.ToRef.Repository.Project.Name, baseRepoSlug) + baseRepoCloneURL := fmt.Sprintf("%s/scm/%s/%s.git", e.BitbucketServerURL, strings.ToLower(*event.PullRequest.ToRef.Repository.Project.Key), baseRepoSlug) + baseRepo, err = models.NewRepo( + models.BitbucketServer, + baseRepoFullname, + baseRepoCloneURL, + e.BitbucketUser, + e.BitbucketToken) + if err != nil { + return + } + + pull = models.PullRequest{ + Num: *event.PullRequest.ID, + HeadCommit: *event.PullRequest.FromRef.LatestCommit, + URL: fmt.Sprintf("%s/projects/%s/repos/%s/pull-requests/%d", e.BitbucketServerURL, *event.PullRequest.ToRef.Repository.Project.Key, *event.PullRequest.ToRef.Repository.Slug, *event.PullRequest.ID), + Branch: *event.PullRequest.FromRef.DisplayID, + Author: *event.Actor.Username, + State: prState, + BaseRepo: baseRepo, + } + user = models.User{ + Username: *event.Actor.Username, + } + return +} + +func (e *EventParser) ParseBitbucketServerPullEvent(body []byte) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) { + var event bitbucketserver.PullRequestEvent + if err = json.Unmarshal(body, &event); err != nil { + err = errors.Wrap(err, "parsing json") + return + } + if err = validator.New().Struct(event); err != nil { + err = errors.Wrapf(err, "API response %q was missing fields", string(body)) + return + } + pull, baseRepo, headRepo, user, err = e.parseCommonBitbucketServerEventData(event.CommonEventData) + return +} diff --git a/server/events/event_parser_test.go b/server/events/event_parser_test.go index dabec68f1d..2829ff6794 100644 --- a/server/events/event_parser_test.go +++ b/server/events/event_parser_test.go @@ -31,12 +31,13 @@ import ( ) var parser = events.EventParser{ - GithubUser: "github-user", - GithubToken: "github-token", - GitlabUser: "gitlab-user", - GitlabToken: "gitlab-token", - BitbucketUser: "bitbucket-user", - BitbucketToken: "bitbucket-token", + GithubUser: "github-user", + GithubToken: "github-token", + GitlabUser: "gitlab-user", + GitlabToken: "gitlab-token", + BitbucketUser: "bitbucket-user", + BitbucketToken: "bitbucket-token", + BitbucketServerURL: "http://mycorp.com:7490", } func TestParseGithubRepo(t *testing.T) { @@ -526,7 +527,7 @@ func TestParseBitbucketCloudCommentEvent_EmptyString(t *testing.T) { func TestParseBitbucketCloudCommentEvent_EmptyObject(t *testing.T) { _, _, _, _, _, err := parser.ParseBitbucketCloudCommentEvent([]byte("{}")) - ErrEquals(t, "Key: 'CommentEvent.CommonEventData.Actor' Error:Field validation for 'Actor' failed on the 'required' tag\nKey: 'CommentEvent.CommonEventData.Repository' Error:Field validation for 'Repository' failed on the 'required' tag\nKey: 'CommentEvent.CommonEventData.PullRequest' Error:Field validation for 'PullRequest' failed on the 'required' tag\nKey: 'CommentEvent.Comment' Error:Field validation for 'Comment' failed on the 'required' tag", err) + ErrContains(t, "Key: 'CommentEvent.CommonEventData.Actor' Error:Field validation for 'Actor' failed on the 'required' tag\nKey: 'CommentEvent.CommonEventData.Repository' Error:Field validation for 'Repository' failed on the 'required' tag\nKey: 'CommentEvent.CommonEventData.PullRequest' Error:Field validation for 'PullRequest' failed on the 'required' tag\nKey: 'CommentEvent.Comment' Error:Field validation for 'Comment' failed on the 'required' tag", err) } func TestParseBitbucketCloudCommentEvent_CommitHashMissing(t *testing.T) { @@ -537,7 +538,7 @@ func TestParseBitbucketCloudCommentEvent_CommitHashMissing(t *testing.T) { } emptyCommitHash := strings.Replace(string(bytes), ` "hash": "e0624da46d3a",`, "", -1) _, _, _, _, _, err = parser.ParseBitbucketCloudCommentEvent([]byte(emptyCommitHash)) - ErrEquals(t, "Key: 'CommentEvent.CommonEventData.PullRequest.Source.Commit.Hash' Error:Field validation for 'Hash' failed on the 'required' tag", err) + ErrContains(t, "Key: 'CommentEvent.CommonEventData.PullRequest.Source.Commit.Hash' Error:Field validation for 'Hash' failed on the 'required' tag", err) } func TestParseBitbucketCloudCommentEvent_ValidEvent(t *testing.T) { @@ -556,7 +557,7 @@ func TestParseBitbucketCloudCommentEvent_ValidEvent(t *testing.T) { SanitizedCloneURL: "https://bitbucket.org/lkysow/atlantis-example.git", VCSHost: models.VCSHost{ Hostname: "bitbucket.org", - Type: models.Bitbucket, + Type: models.BitbucketCloud, }, } Equals(t, expBaseRepo, baseRepo) @@ -577,7 +578,7 @@ func TestParseBitbucketCloudCommentEvent_ValidEvent(t *testing.T) { SanitizedCloneURL: "https://bitbucket.org/lkysow-fork/atlantis-example.git", VCSHost: models.VCSHost{ Hostname: "bitbucket.org", - Type: models.Bitbucket, + Type: models.BitbucketCloud, }, }, headRepo) Equals(t, models.User{ @@ -641,7 +642,7 @@ func TestParseBitbucketCloudPullEvent_ValidEvent(t *testing.T) { SanitizedCloneURL: "https://bitbucket.org/lkysow/atlantis-example.git", VCSHost: models.VCSHost{ Hostname: "bitbucket.org", - Type: models.Bitbucket, + Type: models.BitbucketCloud, }, } Equals(t, expBaseRepo, baseRepo) @@ -662,7 +663,7 @@ func TestParseBitbucketCloudPullEvent_ValidEvent(t *testing.T) { SanitizedCloneURL: "https://bitbucket.org/lkysow-fork/atlantis-example.git", VCSHost: models.VCSHost{ Hostname: "bitbucket.org", - Type: models.Bitbucket, + Type: models.BitbucketCloud, }, }, headRepo) Equals(t, models.User{ @@ -670,7 +671,7 @@ func TestParseBitbucketCloudPullEvent_ValidEvent(t *testing.T) { }, user) } -func TestGetBitbucketEventType(t *testing.T) { +func TestGetBitbucketCloudEventType(t *testing.T) { cases := []struct { header string exp models.PullRequestEventType @@ -698,7 +699,184 @@ func TestGetBitbucketEventType(t *testing.T) { } for _, c := range cases { t.Run(c.header, func(t *testing.T) { - act := parser.GetBitbucketEventType(c.header) + act := parser.GetBitbucketCloudEventType(c.header) + Equals(t, c.exp, act) + }) + } +} + +func TestParseBitbucketServerCommentEvent_EmptyString(t *testing.T) { + _, _, _, _, _, err := parser.ParseBitbucketServerCommentEvent([]byte("")) + ErrEquals(t, "parsing json: unexpected end of JSON input", err) +} + +func TestParseBitbucketServerCommentEvent_EmptyObject(t *testing.T) { + _, _, _, _, _, err := parser.ParseBitbucketServerCommentEvent([]byte("{}")) + ErrContains(t, `API response "{}" was missing fields: Key: 'CommentEvent.CommonEventData.Actor' Error:Field validation for 'Actor' failed on the 'required' tag`, err) +} + +func TestParseBitbucketServerCommentEvent_CommitHashMissing(t *testing.T) { + path := filepath.Join("testdata", "bitbucket-server-comment-event.json") + bytes, err := ioutil.ReadFile(path) + if err != nil { + Ok(t, err) + } + emptyCommitHash := strings.Replace(string(bytes), `"latestCommit": "bfb1af1ba9c2a2fa84cd61af67e6e1b60a22e060",`, "", -1) + _, _, _, _, _, err = parser.ParseBitbucketServerCommentEvent([]byte(emptyCommitHash)) + ErrContains(t, "Key: 'CommentEvent.CommonEventData.PullRequest.FromRef.LatestCommit' Error:Field validation for 'LatestCommit' failed on the 'required' tag", err) +} + +func TestParseBitbucketServerCommentEvent_ValidEvent(t *testing.T) { + path := filepath.Join("testdata", "bitbucket-server-comment-event.json") + bytes, err := ioutil.ReadFile(path) + if err != nil { + Ok(t, err) + } + pull, baseRepo, headRepo, user, comment, err := parser.ParseBitbucketServerCommentEvent(bytes) + Ok(t, err) + expBaseRepo := models.Repo{ + FullName: "atlantis/atlantis-example", + Owner: "atlantis", + Name: "atlantis-example", + CloneURL: "http://bitbucket-user:bitbucket-token@mycorp.com:7490/scm/at/atlantis-example.git", + SanitizedCloneURL: "http://mycorp.com:7490/scm/at/atlantis-example.git", + VCSHost: models.VCSHost{ + Hostname: "mycorp.com", + Type: models.BitbucketServer, + }, + } + Equals(t, expBaseRepo, baseRepo) + Equals(t, models.PullRequest{ + Num: 1, + HeadCommit: "bfb1af1ba9c2a2fa84cd61af67e6e1b60a22e060", + URL: "http://mycorp.com:7490/projects/AT/repos/atlantis-example/pull-requests/1", + Branch: "branch", + Author: "lkysow", + State: models.OpenPullState, + BaseRepo: expBaseRepo, + }, pull) + Equals(t, models.Repo{ + FullName: "atlantis-fork/atlantis-example", + Owner: "atlantis-fork", + Name: "atlantis-example", + CloneURL: "http://bitbucket-user:bitbucket-token@mycorp.com:7490/scm/fk/atlantis-example.git", + SanitizedCloneURL: "http://mycorp.com:7490/scm/fk/atlantis-example.git", + VCSHost: models.VCSHost{ + Hostname: "mycorp.com", + Type: models.BitbucketServer, + }, + }, headRepo) + Equals(t, models.User{ + Username: "lkysow", + }, user) + Equals(t, "atlantis plan", comment) +} + +func TestParseBitbucketServerCommentEvent_MultipleStates(t *testing.T) { + path := filepath.Join("testdata", "bitbucket-server-comment-event.json") + bytes, err := ioutil.ReadFile(path) + if err != nil { + Ok(t, err) + } + + cases := []struct { + pullState string + exp models.PullRequestState + }{ + { + "OPEN", + models.OpenPullState, + }, + { + "MERGED", + models.ClosedPullState, + }, + { + "DECLINED", + models.ClosedPullState, + }, + } + + for _, c := range cases { + t.Run(c.pullState, func(t *testing.T) { + withState := strings.Replace(string(bytes), `"state": "OPEN"`, fmt.Sprintf(`"state": "%s"`, c.pullState), -1) + pull, _, _, _, _, err := parser.ParseBitbucketServerCommentEvent([]byte(withState)) + Ok(t, err) + Equals(t, c.exp, pull.State) + }) + } +} + +func TestParseBitbucketServerPullEvent_ValidEvent(t *testing.T) { + path := filepath.Join("testdata", "bitbucket-server-pull-event-merged.json") + bytes, err := ioutil.ReadFile(path) + if err != nil { + Ok(t, err) + } + pull, baseRepo, headRepo, user, err := parser.ParseBitbucketServerPullEvent(bytes) + Ok(t, err) + expBaseRepo := models.Repo{ + FullName: "atlantis/atlantis-example", + Owner: "atlantis", + Name: "atlantis-example", + CloneURL: "http://bitbucket-user:bitbucket-token@mycorp.com:7490/scm/at/atlantis-example.git", + SanitizedCloneURL: "http://mycorp.com:7490/scm/at/atlantis-example.git", + VCSHost: models.VCSHost{ + Hostname: "mycorp.com", + Type: models.BitbucketServer, + }, + } + Equals(t, expBaseRepo, baseRepo) + Equals(t, models.PullRequest{ + Num: 2, + HeadCommit: "86a574157f5a2dadaf595b9f06c70fdfdd039912", + URL: "http://mycorp.com:7490/projects/AT/repos/atlantis-example/pull-requests/2", + Branch: "branch", + Author: "lkysow", + State: models.ClosedPullState, + BaseRepo: expBaseRepo, + }, pull) + Equals(t, models.Repo{ + FullName: "atlantis-fork/atlantis-example", + Owner: "atlantis-fork", + Name: "atlantis-example", + CloneURL: "http://bitbucket-user:bitbucket-token@mycorp.com:7490/scm/fk/atlantis-example.git", + SanitizedCloneURL: "http://mycorp.com:7490/scm/fk/atlantis-example.git", + VCSHost: models.VCSHost{ + Hostname: "mycorp.com", + Type: models.BitbucketServer, + }, + }, headRepo) + Equals(t, models.User{ + Username: "lkysow", + }, user) +} + +func TestGetBitbucketServerEventType(t *testing.T) { + cases := []struct { + header string + exp models.PullRequestEventType + }{ + { + header: "pr:opened", + exp: models.OpenedPullEvent, + }, + { + header: "pr:merged", + exp: models.ClosedPullEvent, + }, + { + header: "pr:declined", + exp: models.ClosedPullEvent, + }, + { + header: "random", + exp: models.OtherPullEvent, + }, + } + for _, c := range cases { + t.Run(c.header, func(t *testing.T) { + act := parser.GetBitbucketServerEventType(c.header) Equals(t, c.exp, act) }) } diff --git a/server/events/mocks/mock_event_parsing.go b/server/events/mocks/mock_event_parsing.go index 4da2d4dee2..b31962f7d9 100644 --- a/server/events/mocks/mock_event_parsing.go +++ b/server/events/mocks/mock_event_parsing.go @@ -244,9 +244,81 @@ func (mock *MockEventParsing) ParseBitbucketCloudCommentEvent(body []byte) (mode return ret0, ret1, ret2, ret3, ret4, ret5 } -func (mock *MockEventParsing) GetBitbucketEventType(eventTypeHeader string) models.PullRequestEventType { +func (mock *MockEventParsing) GetBitbucketCloudEventType(eventTypeHeader string) models.PullRequestEventType { params := []pegomock.Param{eventTypeHeader} - result := pegomock.GetGenericMockFrom(mock).Invoke("GetBitbucketEventType", params, []reflect.Type{reflect.TypeOf((*models.PullRequestEventType)(nil)).Elem()}) + result := pegomock.GetGenericMockFrom(mock).Invoke("GetBitbucketCloudEventType", params, []reflect.Type{reflect.TypeOf((*models.PullRequestEventType)(nil)).Elem()}) + var ret0 models.PullRequestEventType + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(models.PullRequestEventType) + } + } + return ret0 +} + +func (mock *MockEventParsing) ParseBitbucketServerPullEvent(body []byte) (models.PullRequest, models.Repo, models.Repo, models.User, error) { + params := []pegomock.Param{body} + result := pegomock.GetGenericMockFrom(mock).Invoke("ParseBitbucketServerPullEvent", params, []reflect.Type{reflect.TypeOf((*models.PullRequest)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.User)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 models.PullRequest + var ret1 models.Repo + var ret2 models.Repo + var ret3 models.User + var ret4 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(models.PullRequest) + } + if result[1] != nil { + ret1 = result[1].(models.Repo) + } + if result[2] != nil { + ret2 = result[2].(models.Repo) + } + if result[3] != nil { + ret3 = result[3].(models.User) + } + if result[4] != nil { + ret4 = result[4].(error) + } + } + return ret0, ret1, ret2, ret3, ret4 +} + +func (mock *MockEventParsing) ParseBitbucketServerCommentEvent(body []byte) (models.PullRequest, models.Repo, models.Repo, models.User, string, error) { + params := []pegomock.Param{body} + result := pegomock.GetGenericMockFrom(mock).Invoke("ParseBitbucketServerCommentEvent", params, []reflect.Type{reflect.TypeOf((*models.PullRequest)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.User)(nil)).Elem(), reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 models.PullRequest + var ret1 models.Repo + var ret2 models.Repo + var ret3 models.User + var ret4 string + var ret5 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(models.PullRequest) + } + if result[1] != nil { + ret1 = result[1].(models.Repo) + } + if result[2] != nil { + ret2 = result[2].(models.Repo) + } + if result[3] != nil { + ret3 = result[3].(models.User) + } + if result[4] != nil { + ret4 = result[4].(string) + } + if result[5] != nil { + ret5 = result[5].(error) + } + } + return ret0, ret1, ret2, ret3, ret4, ret5 +} + +func (mock *MockEventParsing) GetBitbucketServerEventType(eventTypeHeader string) models.PullRequestEventType { + params := []pegomock.Param{eventTypeHeader} + result := pegomock.GetGenericMockFrom(mock).Invoke("GetBitbucketServerEventType", params, []reflect.Type{reflect.TypeOf((*models.PullRequestEventType)(nil)).Elem()}) var ret0 models.PullRequestEventType if len(result) != 0 { if result[0] != nil { @@ -521,23 +593,104 @@ func (c *EventParsing_ParseBitbucketCloudCommentEvent_OngoingVerification) GetAl return } -func (verifier *VerifierEventParsing) GetBitbucketEventType(eventTypeHeader string) *EventParsing_GetBitbucketEventType_OngoingVerification { +func (verifier *VerifierEventParsing) GetBitbucketCloudEventType(eventTypeHeader string) *EventParsing_GetBitbucketCloudEventType_OngoingVerification { + params := []pegomock.Param{eventTypeHeader} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetBitbucketCloudEventType", params) + return &EventParsing_GetBitbucketCloudEventType_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type EventParsing_GetBitbucketCloudEventType_OngoingVerification struct { + mock *MockEventParsing + methodInvocations []pegomock.MethodInvocation +} + +func (c *EventParsing_GetBitbucketCloudEventType_OngoingVerification) GetCapturedArguments() string { + eventTypeHeader := c.GetAllCapturedArguments() + return eventTypeHeader[len(eventTypeHeader)-1] +} + +func (c *EventParsing_GetBitbucketCloudEventType_OngoingVerification) GetAllCapturedArguments() (_param0 []string) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]string, len(params[0])) + for u, param := range params[0] { + _param0[u] = param.(string) + } + } + return +} + +func (verifier *VerifierEventParsing) ParseBitbucketServerPullEvent(body []byte) *EventParsing_ParseBitbucketServerPullEvent_OngoingVerification { + params := []pegomock.Param{body} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseBitbucketServerPullEvent", params) + return &EventParsing_ParseBitbucketServerPullEvent_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type EventParsing_ParseBitbucketServerPullEvent_OngoingVerification struct { + mock *MockEventParsing + methodInvocations []pegomock.MethodInvocation +} + +func (c *EventParsing_ParseBitbucketServerPullEvent_OngoingVerification) GetCapturedArguments() []byte { + body := c.GetAllCapturedArguments() + return body[len(body)-1] +} + +func (c *EventParsing_ParseBitbucketServerPullEvent_OngoingVerification) GetAllCapturedArguments() (_param0 [][]byte) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([][]byte, len(params[0])) + for u, param := range params[0] { + _param0[u] = param.([]byte) + } + } + return +} + +func (verifier *VerifierEventParsing) ParseBitbucketServerCommentEvent(body []byte) *EventParsing_ParseBitbucketServerCommentEvent_OngoingVerification { + params := []pegomock.Param{body} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseBitbucketServerCommentEvent", params) + return &EventParsing_ParseBitbucketServerCommentEvent_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type EventParsing_ParseBitbucketServerCommentEvent_OngoingVerification struct { + mock *MockEventParsing + methodInvocations []pegomock.MethodInvocation +} + +func (c *EventParsing_ParseBitbucketServerCommentEvent_OngoingVerification) GetCapturedArguments() []byte { + body := c.GetAllCapturedArguments() + return body[len(body)-1] +} + +func (c *EventParsing_ParseBitbucketServerCommentEvent_OngoingVerification) GetAllCapturedArguments() (_param0 [][]byte) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([][]byte, len(params[0])) + for u, param := range params[0] { + _param0[u] = param.([]byte) + } + } + return +} + +func (verifier *VerifierEventParsing) GetBitbucketServerEventType(eventTypeHeader string) *EventParsing_GetBitbucketServerEventType_OngoingVerification { params := []pegomock.Param{eventTypeHeader} - methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetBitbucketEventType", params) - return &EventParsing_GetBitbucketEventType_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetBitbucketServerEventType", params) + return &EventParsing_GetBitbucketServerEventType_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} } -type EventParsing_GetBitbucketEventType_OngoingVerification struct { +type EventParsing_GetBitbucketServerEventType_OngoingVerification struct { mock *MockEventParsing methodInvocations []pegomock.MethodInvocation } -func (c *EventParsing_GetBitbucketEventType_OngoingVerification) GetCapturedArguments() string { +func (c *EventParsing_GetBitbucketServerEventType_OngoingVerification) GetCapturedArguments() string { eventTypeHeader := c.GetAllCapturedArguments() return eventTypeHeader[len(eventTypeHeader)-1] } -func (c *EventParsing_GetBitbucketEventType_OngoingVerification) GetAllCapturedArguments() (_param0 []string) { +func (c *EventParsing_GetBitbucketServerEventType_OngoingVerification) GetAllCapturedArguments() (_param0 []string) { params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) if len(params) > 0 { _param0 = make([]string, len(params[0])) diff --git a/server/events/models/models.go b/server/events/models/models.go index e26e3fd068..668f26da3f 100644 --- a/server/events/models/models.go +++ b/server/events/models/models.go @@ -63,19 +63,30 @@ func NewRepo(vcsHostType VCSHostType, repoFullName string, cloneURL string, vcsU cloneURL += ".git" } - // Ensure the Clone URL is for the same repo to avoid something malicious. cloneURLParsed, err := url.Parse(cloneURL) if err != nil { return Repo{}, errors.Wrap(err, "invalid clone url") } - expClonePath := fmt.Sprintf("/%s.git", repoFullName) - if expClonePath != cloneURLParsed.Path { - return Repo{}, fmt.Errorf("expected clone url to have path %q but had %q", expClonePath, cloneURLParsed.Path) + + // Ensure the Clone URL is for the same repo to avoid something malicious. + // We skip this check for Bitbucket Server because its format is different + // and because the caller in that case actually constructs the clone url + // from the repo name and so there's no point checking if they match. + if vcsHostType != BitbucketServer { + expClonePath := fmt.Sprintf("/%s.git", repoFullName) + if expClonePath != cloneURLParsed.Path { + return Repo{}, fmt.Errorf("expected clone url to have path %q but had %q", expClonePath, cloneURLParsed.Path) + } } - // Construct clone urls with http auth. Need to do both https and http - // because in GitLab's docs they have some http urls. - auth := fmt.Sprintf("%s:%s@", vcsUser, vcsToken) + // We url encode because we're using them in a URL and weird characters can + // mess up git. + escapedVCSUser := url.QueryEscape(vcsUser) + escapedVCSToken := url.QueryEscape(vcsToken) + auth := fmt.Sprintf("%s:%s@", escapedVCSUser, escapedVCSToken) + + // Construct clone urls with http and https auth. Need to do both + // because Bitbucket supports http. authedCloneURL := strings.Replace(cloneURL, "https://", "https://"+auth, -1) authedCloneURL = strings.Replace(authedCloneURL, "http://", "http://"+auth, -1) @@ -108,9 +119,9 @@ type PullRequest struct { // Num is the pull request number or ID. Num int // HeadCommit is a sha256 that points to the head of the branch that is being - // pull requested into the base. If the pull request is from BitBucket, - // the string will only be 12 characters long because BitBucket truncates - // its commit IDs. + // pull requested into the base. If the pull request is from Bitbucket Cloud + // the string will only be 12 characters long because Bitbucket Cloud + // truncates its commit IDs. HeadCommit string // URL is the url of the pull request. // ex. "https://github.com/runatlantis/atlantis/pull/1" @@ -237,7 +248,8 @@ type VCSHostType int const ( Github VCSHostType = iota Gitlab - Bitbucket + BitbucketCloud + BitbucketServer ) func (h VCSHostType) String() string { @@ -246,8 +258,10 @@ func (h VCSHostType) String() string { return "Github" case Gitlab: return "Gitlab" - case Bitbucket: - return "Bitbucket" + case BitbucketCloud: + return "BitbucketCloud" + case BitbucketServer: + return "BitbucketServer" } return "" } diff --git a/server/events/models/models_test.go b/server/events/models/models_test.go index 712b990729..dee8b1f3f5 100644 --- a/server/events/models/models_test.go +++ b/server/events/models/models_test.go @@ -41,6 +41,24 @@ func TestNewRepo_CloneURLWrongRepo(t *testing.T) { ErrEquals(t, `expected clone url to have path "/owner/repo.git" but had "/notowner/repo.git"`, err) } +// For bitbucket server we don't validate the clone URL because the callers +// are actually constructing it. +func TestNewRepo_CloneURLBitbucketServer(t *testing.T) { + repo, err := models.NewRepo(models.BitbucketServer, "owner/repo", "http://mycorp.com:7990/scm/at/atlantis-example.git", "u", "p") + Ok(t, err) + Equals(t, models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + CloneURL: "http://u:p@mycorp.com:7990/scm/at/atlantis-example.git", + SanitizedCloneURL: "http://mycorp.com:7990/scm/at/atlantis-example.git", + VCSHost: models.VCSHost{ + Hostname: "mycorp.com", + Type: models.BitbucketServer, + }, + }, repo) +} + func TestNewRepo_FullNameWrongFormat(t *testing.T) { cases := []string{ "owner/repo/extra", @@ -61,7 +79,7 @@ func TestNewRepo_FullNameWrongFormat(t *testing.T) { // If the clone url doesn't end with .git it is appended func TestNewRepo_MissingDotGit(t *testing.T) { - repo, err := models.NewRepo(models.Bitbucket, "owner/repo", "https://bitbucket.org/owner/repo", "u", "p") + repo, err := models.NewRepo(models.BitbucketCloud, "owner/repo", "https://bitbucket.org/owner/repo", "u", "p") Ok(t, err) Equals(t, repo.CloneURL, "https://u:p@bitbucket.org/owner/repo.git") Equals(t, repo.SanitizedCloneURL, "https://bitbucket.org/owner/repo.git") @@ -107,3 +125,60 @@ func TestProject_String(t *testing.T) { Path: "my/path", }).String()) } + +func TestNewProject(t *testing.T) { + cases := []struct { + path string + expPath string + }{ + { + "/", + ".", + }, + { + "./another/path", + "another/path", + }, + { + ".", + ".", + }, + } + + for _, c := range cases { + t.Run(c.path, func(t *testing.T) { + p := models.NewProject("repo/owner", c.path) + Equals(t, c.expPath, p.Path) + }) + } +} + +func TestVCSHostType_ToString(t *testing.T) { + cases := []struct { + vcsType models.VCSHostType + exp string + }{ + { + models.Github, + "Github", + }, + { + models.Gitlab, + "Gitlab", + }, + { + models.BitbucketCloud, + "BitbucketCloud", + }, + { + models.BitbucketServer, + "BitbucketServer", + }, + } + + for _, c := range cases { + t.Run(c.exp, func(t *testing.T) { + Equals(t, c.exp, c.vcsType.String()) + }) + } +} diff --git a/server/events/testdata/bitbucket-cloud-pull-event-rejected.json b/server/events/testdata/bitbucket-cloud-pull-event-rejected.json new file mode 100644 index 0000000000..5ee1c603e2 --- /dev/null +++ b/server/events/testdata/bitbucket-cloud-pull-event-rejected.json @@ -0,0 +1,233 @@ +{ + "pullrequest": { + "merge_commit": null, + "description": "main.tf edited online with Bitbucket", + "links": { + "decline": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/3/decline" + }, + "commits": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/3/commits" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/3" + }, + "comments": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/3/comments" + }, + "merge": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/3/merge" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example/pull-requests/3" + }, + "activity": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/3/activity" + }, + "diff": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/3/diff" + }, + "approve": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/3/approve" + }, + "statuses": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/pullrequests/3/statuses" + } + }, + "title": "main.tf edited online with Bitbucket", + "close_source_branch": false, + "reviewers": [], + "destination": { + "commit": { + "hash": "c21506eeea5f", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/c21506eeea5f" + } + } + }, + "branch": { + "name": "master" + }, + "repository": { + "full_name": "lkysow/atlantis-example", + "type": "repository", + "name": "atlantis-example", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" + } + }, + "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" + } + }, + "comment_count": 1, + "closed_by": { + "username": "lkysow", + "display_name": "Luke", + "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/lkysow" + }, + "html": { + "href": "https://bitbucket.org/lkysow/" + }, + "avatar": { + "href": "https://bitbucket.org/account/lkysow/avatar/" + } + }, + "type": "user", + "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" + }, + "summary": { + "raw": "main.tf edited online with Bitbucket", + "markup": "markdown", + "html": "

main.tf edited online with Bitbucket

", + "type": "rendered" + }, + "source": { + "commit": { + "hash": "ff06f4002ff8", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/commit/ff06f4002ff8" + } + } + }, + "branch": { + "name": "lkysow/maintf-edited-online-with-bitbucket-1532029760658" + }, + "repository": { + "full_name": "lkysow/atlantis-example", + "type": "repository", + "name": "atlantis-example", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" + } + }, + "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" + } + }, + "created_on": "2018-07-19T19:49:24.172710+00:00", + "state": "DECLINED", + "task_count": 0, + "participants": [ + { + "type": "participant", + "user": { + "username": "lkysow", + "display_name": "Luke", + "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/lkysow" + }, + "html": { + "href": "https://bitbucket.org/lkysow/" + }, + "avatar": { + "href": "https://bitbucket.org/account/lkysow/avatar/" + } + }, + "type": "user", + "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" + }, + "role": "PARTICIPANT", + "approved": false, + "participated_on": "2018-07-19T19:49:29.565800+00:00" + } + ], + "reason": "Declined", + "updated_on": "2018-07-21T21:06:38.096401+00:00", + "author": { + "username": "lkysow", + "display_name": "Luke", + "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/lkysow" + }, + "html": { + "href": "https://bitbucket.org/lkysow/" + }, + "avatar": { + "href": "https://bitbucket.org/account/lkysow/avatar/" + } + }, + "type": "user", + "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" + }, + "type": "pullrequest", + "id": 3 + }, + "actor": { + "username": "lkysow", + "display_name": "Luke", + "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/lkysow" + }, + "html": { + "href": "https://bitbucket.org/lkysow/" + }, + "avatar": { + "href": "https://bitbucket.org/account/lkysow/avatar/" + } + }, + "type": "user", + "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" + }, + "repository": { + "scm": "git", + "website": "", + "name": "atlantis-example", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example" + }, + "html": { + "href": "https://bitbucket.org/lkysow/atlantis-example" + }, + "avatar": { + "href": "https://bytebucket.org/ravatar/%7B94189367-116b-436a-9f77-2314b97a6067%7D?ts=default" + } + }, + "full_name": "lkysow/atlantis-example", + "owner": { + "username": "lkysow", + "display_name": "Luke", + "account_id": "557058:dc3817de-68b5-45cd-b81c-5c39d2560090", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/users/lkysow" + }, + "html": { + "href": "https://bitbucket.org/lkysow/" + }, + "avatar": { + "href": "https://bitbucket.org/account/lkysow/avatar/" + } + }, + "type": "user", + "uuid": "{bf34a99b-8a11-452c-8fbc-bdffc340e584}" + }, + "type": "repository", + "is_private": false, + "uuid": "{94189367-116b-436a-9f77-2314b97a6067}" + } +} \ No newline at end of file diff --git a/server/events/testdata/bitbucket-server-comment-event.json b/server/events/testdata/bitbucket-server-comment-event.json new file mode 100644 index 0000000000..dc5fae6d91 --- /dev/null +++ b/server/events/testdata/bitbucket-server-comment-event.json @@ -0,0 +1,105 @@ +{ + "eventKey": "pr:comment:added", + "date": "2018-07-21T23:20:30+0200", + "actor": { + "name": "lkysow", + "emailAddress": "lkysow@gmail.com", + "id": 1, + "displayName": "Luke Kysow", + "active": true, + "slug": "lkysow", + "type": "NORMAL" + }, + "pullRequest": { + "id": 1, + "version": 0, + "title": "Null resource", + "state": "OPEN", + "open": true, + "closed": false, + "createdDate": 1532207977313, + "updatedDate": 1532207977313, + "fromRef": { + "id": "refs/heads/branch", + "displayId": "branch", + "latestCommit": "bfb1af1ba9c2a2fa84cd61af67e6e1b60a22e060", + "repository": { + "slug": "atlantis-example", + "id": 1, + "name": "atlantis-example", + "scmId": "git", + "state": "AVAILABLE", + "statusMessage": "Available", + "forkable": true, + "project": { + "key": "FK", + "id": 1, + "name": "atlantis-fork", + "public": false, + "type": "NORMAL" + }, + "public": false + } + }, + "toRef": { + "id": "refs/heads/master", + "displayId": "master", + "latestCommit": "3d1f26bc1c8eeb5ad94e247c92072717b9de6aa0", + "repository": { + "slug": "atlantis-example", + "id": 1, + "name": "atlantis-example", + "scmId": "git", + "state": "AVAILABLE", + "statusMessage": "Available", + "forkable": true, + "project": { + "key": "AT", + "id": 1, + "name": "atlantis", + "public": false, + "type": "NORMAL" + }, + "public": false + } + }, + "locked": false, + "author": { + "user": { + "name": "lkysow", + "emailAddress": "lkysow@gmail.com", + "id": 1, + "displayName": "Luke Kysow", + "active": true, + "slug": "lkysow", + "type": "NORMAL" + }, + "role": "AUTHOR", + "approved": false, + "status": "UNAPPROVED" + }, + "reviewers": [], + "participants": [] + }, + "comment": { + "properties": { + "repositoryId": 1 + }, + "id": 1, + "version": 0, + "text": "atlantis plan", + "author": { + "name": "lkysow", + "emailAddress": "lkysow@gmail.com", + "id": 1, + "displayName": "Luke Kysow", + "active": true, + "slug": "lkysow", + "type": "NORMAL" + }, + "createdDate": 1532208030682, + "updatedDate": 1532208030682, + "comments": [], + "tasks": [] + } +} \ No newline at end of file diff --git a/server/events/testdata/bitbucket-server-get-pull-changes.json b/server/events/testdata/bitbucket-server-get-pull-changes.json new file mode 100644 index 0000000000..72681f8883 --- /dev/null +++ b/server/events/testdata/bitbucket-server-get-pull-changes.json @@ -0,0 +1,66 @@ +{ + "fromHash": "bbc7b2a29344646ec8605be9603a0aa625a627ef", + "toHash": "737bfd254f39b36733ee2d5089db65c7369c7692", + "properties": { + "changeScope": "ALL" + }, + "values": [ + { + "contentId": "fdc4b3b5b37ba11f0cbec7a2744cddb35bab785b", + "fromContentId": "0000000000000000000000000000000000000000", + "path": { + "components": [ + "folder", + "another.tf" + ], + "parent": "folder", + "name": "another.tf", + "extension": "tf", + "toString": "folder/another.tf" + }, + "executable": false, + "percentUnchanged": -1, + "type": "ADD", + "nodeType": "FILE", + "links": { + "self": [ + null + ] + }, + "properties": { + "gitChangeType": "ADD" + } + }, + { + "contentId": "a44a3e1d545c78dc67236af92395b864a7035498", + "fromContentId": "dc333454be3243d54ff155489df0bc0e94807a35", + "path": { + "components": [ + "main.tf" + ], + "parent": "", + "name": "main.tf", + "extension": "tf", + "toString": "main.tf" + }, + "executable": false, + "percentUnchanged": -1, + "type": "MODIFY", + "nodeType": "FILE", + "srcExecutable": false, + "links": { + "self": [ + null + ] + }, + "properties": { + "gitChangeType": "MODIFY" + } + } + ], + "size": 2, + "isLastPage": true, + "start": 0, + "limit": 25, + "nextPageStart": null +} \ No newline at end of file diff --git a/server/events/testdata/bitbucket-server-get-pull.json b/server/events/testdata/bitbucket-server-get-pull.json new file mode 100644 index 0000000000..c713635884 --- /dev/null +++ b/server/events/testdata/bitbucket-server-get-pull.json @@ -0,0 +1,156 @@ +{ + "id": 3, + "version": 0, + "title": "main.tf edited online with Bitbucket", + "state": "OPEN", + "open": true, + "closed": false, + "createdDate": 1532350340104, + "updatedDate": 1532350340104, + "fromRef": { + "id": "refs/heads/lkysow/maintf-1532350335286", + "displayId": "lkysow/maintf-1532350335286", + "latestCommit": "43b60c668d138b2070bb6a746e09ef513e51a891", + "repository": { + "slug": "atlantis-example", + "id": 1, + "name": "atlantis-example", + "scmId": "git", + "state": "AVAILABLE", + "statusMessage": "Available", + "forkable": true, + "project": { + "key": "AT", + "id": 1, + "name": "atlantis", + "public": false, + "type": "NORMAL", + "links": { + "self": [ + { + "href": "http://localhost:7990/projects/AT" + } + ] + } + }, + "public": false, + "links": { + "clone": [ + { + "href": "http://localhost:7990/scm/at/atlantis-example.git", + "name": "http" + }, + { + "href": "ssh://git@localhost:7999/at/atlantis-example.git", + "name": "ssh" + } + ], + "self": [ + { + "href": "http://localhost:7990/projects/AT/repos/atlantis-example/browse" + } + ] + } + } + }, + "toRef": { + "id": "refs/heads/master", + "displayId": "master", + "latestCommit": "bbc7b2a29344646ec8605be9603a0aa625a627ef", + "repository": { + "slug": "atlantis-example", + "id": 1, + "name": "atlantis-example", + "scmId": "git", + "state": "AVAILABLE", + "statusMessage": "Available", + "forkable": true, + "project": { + "key": "AT", + "id": 1, + "name": "atlantis", + "public": false, + "type": "NORMAL", + "links": { + "self": [ + { + "href": "http://localhost:7990/projects/AT" + } + ] + } + }, + "public": false, + "links": { + "clone": [ + { + "href": "http://localhost:7990/scm/at/atlantis-example.git", + "name": "http" + }, + { + "href": "ssh://git@localhost:7999/at/atlantis-example.git", + "name": "ssh" + } + ], + "self": [ + { + "href": "http://localhost:7990/projects/AT/repos/atlantis-example/browse" + } + ] + } + } + }, + "locked": false, + "author": { + "user": { + "name": "lkysow", + "emailAddress": "lkysow@gmail.com", + "id": 1, + "displayName": "Luke Kysow", + "active": true, + "slug": "lkysow", + "type": "NORMAL", + "links": { + "self": [ + { + "href": "http://localhost:7990/users/lkysow" + } + ] + } + }, + "role": "AUTHOR", + "approved": false, + "status": "UNAPPROVED" + }, + "reviewers": [ + { + "user": { + "name": "another-user", + "emailAddress": "lkysow@gmail.com", + "id": 2, + "displayName": "another-user", + "active": true, + "slug": "another-user", + "type": "NORMAL", + "links": { + "self": [ + { + "href": "http://localhost:7990/users/another-user" + } + ] + } + }, + "lastReviewedCommit": "43b60c668d138b2070bb6a746e09ef513e51a891", + "role": "REVIEWER", + "approved": true, + "status": "APPROVED" + } + ], + "participants": [], + "links": { + "self": [ + { + "href": "http://localhost:7990/projects/AT/repos/atlantis-example/pull-requests/3" + } + ] + } +} \ No newline at end of file diff --git a/server/events/testdata/bitbucket-server-pull-event-created.json b/server/events/testdata/bitbucket-server-pull-event-created.json new file mode 100644 index 0000000000..1292c0a0db --- /dev/null +++ b/server/events/testdata/bitbucket-server-pull-event-created.json @@ -0,0 +1,84 @@ +{ + "eventKey": "pr:opened", + "date": "2018-07-21T23:19:37+0200", + "actor": { + "name": "lkysow", + "emailAddress": "lkysow@gmail.com", + "id": 1, + "displayName": "Luke Kysow", + "active": true, + "slug": "lkysow", + "type": "NORMAL" + }, + "pullRequest": { + "id": 1, + "version": 0, + "title": "Null resource", + "state": "OPEN", + "open": true, + "closed": false, + "createdDate": 1532207977313, + "updatedDate": 1532207977313, + "fromRef": { + "id": "refs/heads/branch", + "displayId": "branch", + "latestCommit": "bfb1af1ba9c2a2fa84cd61af67e6e1b60a22e060", + "repository": { + "slug": "atlantis-example", + "id": 1, + "name": "atlantis-example", + "scmId": "git", + "state": "AVAILABLE", + "statusMessage": "Available", + "forkable": true, + "project": { + "key": "AT", + "id": 1, + "name": "atlantis", + "public": false, + "type": "NORMAL" + }, + "public": false + } + }, + "toRef": { + "id": "refs/heads/master", + "displayId": "master", + "latestCommit": "3d1f26bc1c8eeb5ad94e247c92072717b9de6aa0", + "repository": { + "slug": "atlantis-example", + "id": 1, + "name": "atlantis-example", + "scmId": "git", + "state": "AVAILABLE", + "statusMessage": "Available", + "forkable": true, + "project": { + "key": "AT", + "id": 1, + "name": "atlantis", + "public": false, + "type": "NORMAL" + }, + "public": false + } + }, + "locked": false, + "author": { + "user": { + "name": "lkysow", + "emailAddress": "lkysow@gmail.com", + "id": 1, + "displayName": "Luke Kysow", + "active": true, + "slug": "lkysow", + "type": "NORMAL" + }, + "role": "AUTHOR", + "approved": false, + "status": "UNAPPROVED" + }, + "reviewers": [], + "participants": [] + } +} \ No newline at end of file diff --git a/server/events/testdata/bitbucket-server-pull-event-declined.json b/server/events/testdata/bitbucket-server-pull-event-declined.json new file mode 100644 index 0000000000..2ed88b9a93 --- /dev/null +++ b/server/events/testdata/bitbucket-server-pull-event-declined.json @@ -0,0 +1,85 @@ +{ + "eventKey": "pr:declined", + "date": "2018-07-23T13:59:48+0200", + "actor": { + "name": "lkysow", + "emailAddress": "lkysow@gmail.com", + "id": 1, + "displayName": "Luke Kysow", + "active": true, + "slug": "lkysow", + "type": "NORMAL" + }, + "pullRequest": { + "id": 1, + "version": 7, + "title": "Null resource2", + "state": "DECLINED", + "open": false, + "closed": true, + "createdDate": 1532207977313, + "updatedDate": 1532347188162, + "closedDate": 1532347188162, + "fromRef": { + "id": "refs/heads/branch", + "displayId": "branch", + "latestCommit": "46955afd9b6c5dfa8753727d0669925e057e69b1", + "repository": { + "slug": "atlantis-example", + "id": 1, + "name": "atlantis-example", + "scmId": "git", + "state": "AVAILABLE", + "statusMessage": "Available", + "forkable": true, + "project": { + "key": "AT", + "id": 1, + "name": "atlantis", + "public": false, + "type": "NORMAL" + }, + "public": false + } + }, + "toRef": { + "id": "refs/heads/master", + "displayId": "master", + "latestCommit": "120cff6e1452086c90689c810c15b534381ba61b", + "repository": { + "slug": "atlantis-example", + "id": 1, + "name": "atlantis-example", + "scmId": "git", + "state": "AVAILABLE", + "statusMessage": "Available", + "forkable": true, + "project": { + "key": "AT", + "id": 1, + "name": "atlantis", + "public": false, + "type": "NORMAL" + }, + "public": false + } + }, + "locked": false, + "author": { + "user": { + "name": "lkysow", + "emailAddress": "lkysow@gmail.com", + "id": 1, + "displayName": "Luke Kysow", + "active": true, + "slug": "lkysow", + "type": "NORMAL" + }, + "role": "AUTHOR", + "approved": false, + "status": "UNAPPROVED" + }, + "reviewers": [], + "participants": [] + } +} diff --git a/server/events/testdata/bitbucket-server-pull-event-merged.json b/server/events/testdata/bitbucket-server-pull-event-merged.json new file mode 100644 index 0000000000..ed22810b1a --- /dev/null +++ b/server/events/testdata/bitbucket-server-pull-event-merged.json @@ -0,0 +1,108 @@ +{ + "eventKey": "pr:merged", + "date": "2018-07-23T14:00:19+0200", + "actor": { + "name": "lkysow", + "emailAddress": "lkysow@gmail.com", + "id": 1, + "displayName": "Luke Kysow", + "active": true, + "slug": "lkysow", + "type": "NORMAL" + }, + "pullRequest": { + "id": 2, + "version": 2, + "title": "Branch", + "description": "* Null resource\r\n* main.tf edited online with Bitbucket\r\n* Update 2\r\n* main.tf edited online with Bitbucket\r\n* kkj\r\n* main.tf edited online with Bitbucket", + "state": "MERGED", + "open": false, + "closed": true, + "createdDate": 1532211497403, + "updatedDate": 1532347219220, + "closedDate": 1532347219220, + "fromRef": { + "id": "refs/heads/branch", + "displayId": "branch", + "latestCommit": "86a574157f5a2dadaf595b9f06c70fdfdd039912", + "repository": { + "slug": "atlantis-example", + "id": 2, + "name": "atlantis-example", + "scmId": "git", + "state": "AVAILABLE", + "statusMessage": "Available", + "forkable": true, + "origin": { + "slug": "atlantis-example", + "id": 1, + "name": "atlantis-example", + "scmId": "git", + "state": "AVAILABLE", + "statusMessage": "Available", + "forkable": true, + "project": { + "key": "FK", + "id": 1, + "name": "atlantis-fork", + "public": false, + "type": "NORMAL" + }, + "public": false + }, + "project": { + "key": "FK", + "id": 2, + "name": "atlantis-fork", + "type": "NORMAL" + }, + "public": false + } + }, + "toRef": { + "id": "refs/heads/master", + "displayId": "master", + "latestCommit": "120cff6e1452086c90689c810c15b534381ba61b", + "repository": { + "slug": "atlantis-example", + "id": 1, + "name": "atlantis-example", + "scmId": "git", + "state": "AVAILABLE", + "statusMessage": "Available", + "forkable": true, + "project": { + "key": "AT", + "id": 1, + "name": "atlantis", + "public": false, + "type": "NORMAL" + }, + "public": false + } + }, + "locked": false, + "author": { + "user": { + "name": "lkysow", + "emailAddress": "lkysow@gmail.com", + "id": 1, + "displayName": "Luke Kysow", + "active": true, + "slug": "lkysow", + "type": "NORMAL" + }, + "role": "AUTHOR", + "approved": false, + "status": "UNAPPROVED" + }, + "reviewers": [], + "participants": [], + "properties": { + "mergeCommit": { + "displayId": "bbc7b2a2934", + "id": "bbc7b2a29344646ec8605be9603a0aa625a627ef" + } + } + } +} diff --git a/server/events/vcs/bitbucketcloud/bitbucketcloud.go b/server/events/vcs/bitbucketcloud/bitbucketcloud.go new file mode 100644 index 0000000000..89dcd3ce51 --- /dev/null +++ b/server/events/vcs/bitbucketcloud/bitbucketcloud.go @@ -0,0 +1,6 @@ +// Package bitbucketcloud holds code for Bitbucket Cloud aka (bitbucket.org). +// It is separate from bitbucketserver because Bitbucket Server has different +// APIs. +package bitbucketcloud + +const BaseURL = "https://api.bitbucket.org" diff --git a/server/events/vcs/bitbucket/client.go b/server/events/vcs/bitbucketcloud/client.go similarity index 74% rename from server/events/vcs/bitbucket/client.go rename to server/events/vcs/bitbucketcloud/client.go index c4ddd9900e..0c7b23772a 100644 --- a/server/events/vcs/bitbucket/client.go +++ b/server/events/vcs/bitbucketcloud/client.go @@ -1,4 +1,4 @@ -package bitbucket +package bitbucketcloud import ( "bytes" @@ -7,18 +7,10 @@ import ( "io" "io/ioutil" "net/http" - "net/url" "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/events/models" -) - -const ( - // The comments API is only available in the 1.0 API right now. - commentsAPIPathFmt = "%s/1.0/repositories/%s/pullrequests/%d/comments" - diffStatAPIPathFmt = "%s/2.0/repositories/%s/pullrequests/%d/diffstat" - pullApprovedAPIPathFmt = "%s/2.0/repositories/%s/pullrequests/%d" - buildStatusAPIPathFmt = "%s/2.0/repositories/%s/commit/%s/statuses/build" + "gopkg.in/go-playground/validator.v9" ) type Client struct { @@ -29,31 +21,21 @@ type Client struct { AtlantisURL string } -// NewClient builds a bitbucket client. Returns an error if the baseURL is -// malformed. httpClient is the client to use to make the requests, username -// and password are used as basic auth in the requests, baseURL is the API's -// baseURL, ex. https://api.bitbucket.org. Don't include the API version, ex. -// '/1.0' since that changes based on the API call. atlantisURL is the +// NewClient builds a bitbucket cloud client. atlantisURL is the // URL for Atlantis that will be linked to from the build status icons. This // linking is annoying because we don't have anywhere good to link but a URL is // required. -func NewClient(httpClient *http.Client, username string, password string, baseURL string, atlantisURL string) (*Client, error) { +func NewClient(httpClient *http.Client, username string, password string, atlantisURL string) *Client { if httpClient == nil { httpClient = http.DefaultClient } - // Remove the trailing '/' from the URL. - parsedURL, err := url.Parse(baseURL) - if err != nil { - return nil, errors.Wrapf(err, "parsing %s", baseURL) - } - urlWithoutPath := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host) return &Client{ HttpClient: httpClient, Username: username, Password: password, - BaseURL: urlWithoutPath, + BaseURL: BaseURL, AtlantisURL: atlantisURL, - }, nil + } } // GetModifiedFiles returns the names of files that were modified in the merge request. @@ -61,9 +43,9 @@ func NewClient(httpClient *http.Client, username string, password string, baseUR func (b *Client) GetModifiedFiles(repo models.Repo, pull models.PullRequest) ([]string, error) { var files []string - nextPageURL := fmt.Sprintf(diffStatAPIPathFmt, b.BaseURL, repo.FullName, pull.Num) - // We'll only loop 100 times as a safety measure. - maxLoops := 100 + nextPageURL := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d/diffstat", b.BaseURL, repo.FullName, pull.Num) + // We'll only loop 1000 times as a safety measure. + maxLoops := 1000 for i := 0; i < maxLoops; i++ { resp, err := b.makeRequest("GET", nextPageURL, nil) if err != nil { @@ -71,7 +53,10 @@ func (b *Client) GetModifiedFiles(repo models.Repo, pull models.PullRequest) ([] } var diffStat DiffStat if err := json.Unmarshal(resp, &diffStat); err != nil { - return nil, err + return nil, errors.Wrapf(err, "Could not parse response %q", string(resp)) + } + if err := validator.New().Struct(diffStat); err != nil { + return nil, errors.Wrapf(err, "API response %q was missing fields", string(resp)) } for _, v := range diffStat.Values { if v.Old != nil { @@ -105,21 +90,24 @@ func (b *Client) CreateComment(repo models.Repo, pullNum int, comment string) er if err != nil { return errors.Wrap(err, "json encoding") } - path := fmt.Sprintf(commentsAPIPathFmt, b.BaseURL, repo.FullName, pullNum) + path := fmt.Sprintf("%s/1.0/repositories/%s/pullrequests/%d/comments", b.BaseURL, repo.FullName, pullNum) _, err = b.makeRequest("POST", path, bytes.NewBuffer(bodyBytes)) return err } // PullIsApproved returns true if the merge request was approved. func (b *Client) PullIsApproved(repo models.Repo, pull models.PullRequest) (bool, error) { - path := fmt.Sprintf(pullApprovedAPIPathFmt, b.BaseURL, repo.FullName, pull.Num) + path := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d", b.BaseURL, repo.FullName, pull.Num) resp, err := b.makeRequest("GET", path, nil) if err != nil { return false, err } var pullResp PullRequest if err := json.Unmarshal(resp, &pullResp); err != nil { - return false, err + return false, errors.Wrapf(err, "Could not parse response %q", string(resp)) + } + if err := validator.New().Struct(pullResp); err != nil { + return false, errors.Wrapf(err, "API response %q was missing fields", string(resp)) } for _, participant := range pullResp.Participants { if *participant.Approved { @@ -148,7 +136,7 @@ func (b *Client) UpdateStatus(repo models.Repo, pull models.PullRequest, status "description": description, }) - path := fmt.Sprintf(buildStatusAPIPathFmt, b.BaseURL, repo.FullName, pull.HeadCommit) + path := fmt.Sprintf("%s/2.0/repositories/%s/commit/%s/statuses/build", b.BaseURL, repo.FullName, pull.HeadCommit) if err != nil { return errors.Wrap(err, "json encoding") } diff --git a/server/events/vcs/bitbucket/client_test.go b/server/events/vcs/bitbucketcloud/client_test.go similarity index 90% rename from server/events/vcs/bitbucket/client_test.go rename to server/events/vcs/bitbucketcloud/client_test.go index 3464948019..3baa512f45 100644 --- a/server/events/vcs/bitbucket/client_test.go +++ b/server/events/vcs/bitbucketcloud/client_test.go @@ -1,4 +1,4 @@ -package bitbucket_test +package bitbucketcloud_test import ( "fmt" @@ -7,7 +7,7 @@ import ( "testing" "github.com/runatlantis/atlantis/server/events/models" - "github.com/runatlantis/atlantis/server/events/vcs/bitbucket" + "github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud" . "github.com/runatlantis/atlantis/testing" ) @@ -68,8 +68,8 @@ func TestClient_GetModifiedFilesPagination(t *testing.T) { defer testServer.Close() serverURL = testServer.URL - client, err := bitbucket.NewClient(http.DefaultClient, "user", "pass", serverURL, "runatlantis.io") - Ok(t, err) + client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io") + client.BaseURL = testServer.URL files, err := client.GetModifiedFiles(models.Repo{ FullName: "owner/repo", @@ -78,7 +78,7 @@ func TestClient_GetModifiedFilesPagination(t *testing.T) { CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ - Type: models.Bitbucket, + Type: models.BitbucketCloud, Hostname: "bitbucket.org", }, }, models.PullRequest{ @@ -129,8 +129,8 @@ func TestClient_GetModifiedFilesOldNil(t *testing.T) { })) defer testServer.Close() - client, err := bitbucket.NewClient(http.DefaultClient, "user", "pass", testServer.URL, "runatlantis.io") - Ok(t, err) + client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io") + client.BaseURL = testServer.URL files, err := client.GetModifiedFiles(models.Repo{ FullName: "owner/repo", @@ -139,7 +139,7 @@ func TestClient_GetModifiedFilesOldNil(t *testing.T) { CloneURL: "", SanitizedCloneURL: "", VCSHost: models.VCSHost{ - Type: models.Bitbucket, + Type: models.BitbucketCloud, Hostname: "bitbucket.org", }, }, models.PullRequest{ diff --git a/server/events/vcs/bitbucket/models.go b/server/events/vcs/bitbucketcloud/models.go similarity index 88% rename from server/events/vcs/bitbucket/models.go rename to server/events/vcs/bitbucketcloud/models.go index 83bf398df5..270cfca2b4 100644 --- a/server/events/vcs/bitbucket/models.go +++ b/server/events/vcs/bitbucketcloud/models.go @@ -1,4 +1,12 @@ -package bitbucket +package bitbucketcloud + +const ( + PullCreatedHeader = "pullrequest:created" + PullUpdatedHeader = "pullrequest:updated" + PullFulfilledHeader = "pullrequest:fulfilled" + PullRejectedHeader = "pullrequest:rejected" + PullCommentCreatedHeader = "pullrequest:comment_created" +) type CommentEvent struct { CommonEventData diff --git a/server/events/vcs/bitbucketserver/bitbucketserver.go b/server/events/vcs/bitbucketserver/bitbucketserver.go new file mode 100644 index 0000000000..3a706d7551 --- /dev/null +++ b/server/events/vcs/bitbucketserver/bitbucketserver.go @@ -0,0 +1 @@ +package bitbucketserver diff --git a/server/events/vcs/bitbucketserver/client.go b/server/events/vcs/bitbucketserver/client.go new file mode 100644 index 0000000000..d9a3668f01 --- /dev/null +++ b/server/events/vcs/bitbucketserver/client.go @@ -0,0 +1,222 @@ +package bitbucketserver + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/events/models" + "gopkg.in/go-playground/validator.v9" +) + +type Client struct { + HttpClient *http.Client + Username string + Password string + BaseURL string + AtlantisURL string +} + +// NewClient builds a bitbucket cloud client. Returns an error if the baseURL is +// malformed. httpClient is the client to use to make the requests, username +// and password are used as basic auth in the requests, baseURL is the API's +// baseURL, ex. https://corp.com:7990. Don't include the API version, ex. +// '/1.0' since that changes based on the API call. atlantisURL is the +// URL for Atlantis that will be linked to from the build status icons. This +// linking is annoying because we don't have anywhere good to link but a URL is +// required. +func NewClient(httpClient *http.Client, username string, password string, baseURL string, atlantisURL string) (*Client, error) { + if httpClient == nil { + httpClient = http.DefaultClient + } + // Remove the trailing '/' from the URL. + parsedURL, err := url.Parse(baseURL) + if err != nil { + return nil, errors.Wrapf(err, "parsing %s", baseURL) + } + if parsedURL.Scheme == "" { + return nil, fmt.Errorf("must have 'http://' or 'https://' in base url %q", baseURL) + } + urlWithoutPath := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host) + return &Client{ + HttpClient: httpClient, + Username: username, + Password: password, + BaseURL: urlWithoutPath, + AtlantisURL: atlantisURL, + }, nil +} + +// GetModifiedFiles returns the names of files that were modified in the merge request. +// The names include the path to the file from the repo root, ex. parent/child/file.txt. +func (b *Client) GetModifiedFiles(repo models.Repo, pull models.PullRequest) ([]string, error) { + var files []string + + projectKey, err := b.GetProjectKey(repo.Name, repo.SanitizedCloneURL) + if err != nil { + return nil, err + } + nextPageStart := "0" + baseURL := fmt.Sprintf("%s/rest/api/1.0/projects/%s/repos/%s/pull-requests/%d/changes", + b.BaseURL, projectKey, repo.Name, pull.Num) + // We'll only loop 1000 times as a safety measure. + maxLoops := 1000 + for i := 0; i < maxLoops; i++ { + resp, err := b.makeRequest("GET", fmt.Sprintf("%s?start=%s", baseURL, nextPageStart), nil) + if err != nil { + return nil, err + } + var changes Changes + if err := json.Unmarshal(resp, &changes); err != nil { + return nil, errors.Wrapf(err, "Could not parse response %q", string(resp)) + } + if err := validator.New().Struct(changes); err != nil { + return nil, errors.Wrapf(err, "API response %q was missing fields", string(resp)) + } + for _, v := range changes.Values { + files = append(files, *v.Path.ToString) + } + if *changes.IsLastPage { + break + } + nextPageStart = *changes.NextPageStart + } + + // Now ensure all files are unique. + hash := make(map[string]bool) + var unique []string + for _, f := range files { + if !hash[f] { + unique = append(unique, f) + hash[f] = true + } + } + return unique, nil +} + +func (b *Client) GetProjectKey(repoName string, cloneURL string) (string, error) { + // Get the project key out of the repo clone URL. + // Given http://bitbucket.corp:7990/scm/at/atlantis-example.git + // we want to get 'at'. + expr := fmt.Sprintf(".*/(.*?)/%s\\.git", repoName) + capture, err := regexp.Compile(expr) + if err != nil { + return "", errors.Wrapf(err, "constructing regex from %q", expr) + } + matches := capture.FindStringSubmatch(cloneURL) + if len(matches) != 2 { + return "", fmt.Errorf("could not extract project key from %q, regex returned %q", cloneURL, strings.Join(matches, ",")) + } + return matches[1], nil +} + +// CreateComment creates a comment on the merge request. +func (b *Client) CreateComment(repo models.Repo, pullNum int, comment string) error { + bodyBytes, err := json.Marshal(map[string]string{"text": comment}) + if err != nil { + return errors.Wrap(err, "json encoding") + } + projectKey, err := b.GetProjectKey(repo.Name, repo.SanitizedCloneURL) + if err != nil { + return err + } + path := fmt.Sprintf("%s/rest/api/1.0/projects/%s/repos/%s/pull-requests/%d/comments", b.BaseURL, projectKey, repo.Name, pullNum) + _, err = b.makeRequest("POST", path, bytes.NewBuffer(bodyBytes)) + return err +} + +// PullIsApproved returns true if the merge request was approved. +func (b *Client) PullIsApproved(repo models.Repo, pull models.PullRequest) (bool, error) { + projectKey, err := b.GetProjectKey(repo.Name, repo.SanitizedCloneURL) + if err != nil { + return false, err + } + path := fmt.Sprintf("%s/rest/api/1.0/projects/%s/repos/%s/pull-requests/%d", b.BaseURL, projectKey, repo.Name, pull.Num) + resp, err := b.makeRequest("GET", path, nil) + if err != nil { + return false, err + } + var pullResp PullRequest + if err := json.Unmarshal(resp, &pullResp); err != nil { + return false, errors.Wrapf(err, "Could not parse response %q", string(resp)) + } + if err := validator.New().Struct(pullResp); err != nil { + return false, errors.Wrapf(err, "API response %q was missing fields", string(resp)) + } + for _, reviewer := range pullResp.Reviewers { + if *reviewer.Approved { + return true, nil + } + } + return false, nil +} + +// UpdateStatus updates the status of a commit. +func (b *Client) UpdateStatus(repo models.Repo, pull models.PullRequest, status models.CommitStatus, description string) error { + bbState := "FAILED" + switch status { + case models.PendingCommitStatus: + bbState = "INPROGRESS" + case models.SuccessCommitStatus: + bbState = "SUCCESSFUL" + case models.FailedCommitStatus: + bbState = "FAILED" + } + + bodyBytes, err := json.Marshal(map[string]string{ + "key": "atlantis", + "url": b.AtlantisURL, + "state": bbState, + "description": description, + }) + + path := fmt.Sprintf("%s/rest/build-status/1.0/commits/%s", b.BaseURL, pull.HeadCommit) + if err != nil { + return errors.Wrap(err, "json encoding") + } + _, err = b.makeRequest("POST", path, bytes.NewBuffer(bodyBytes)) + return err +} + +// prepRequest adds the HTTP basic auth. +func (b *Client) prepRequest(method string, path string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequest(method, path, body) + if err != nil { + return nil, err + } + req.SetBasicAuth(b.Username, b.Password) + return req, nil +} + +func (b *Client) makeRequest(method string, path string, reqBody io.Reader) ([]byte, error) { + req, err := b.prepRequest(method, path, reqBody) + if err != nil { + return nil, errors.Wrap(err, "constructing request") + } + if reqBody != nil { + req.Header.Add("Content-Type", "application/json") + } + resp, err := b.HttpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() // nolint: errcheck + requestStr := fmt.Sprintf("%s %s", method, path) + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != 204 { + respBody, _ := ioutil.ReadAll(resp.Body) + return nil, fmt.Errorf("making request %q unexpected status code: %d, body: %s", requestStr, resp.StatusCode, string(respBody)) + } + respBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, errors.Wrapf(err, "reading response from request %q", requestStr) + } + return respBody, nil +} diff --git a/server/events/vcs/bitbucketserver/client_test.go b/server/events/vcs/bitbucketserver/client_test.go new file mode 100644 index 0000000000..810017854a --- /dev/null +++ b/server/events/vcs/bitbucketserver/client_test.go @@ -0,0 +1,141 @@ +package bitbucketserver_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud" + "github.com/runatlantis/atlantis/server/events/vcs/bitbucketserver" + . "github.com/runatlantis/atlantis/testing" +) + +// Should follow pagination properly. +func TestClient_GetModifiedFilesPagination(t *testing.T) { + respTemplate := ` +{ + "values": [ + { + "path": { + "toString": "%s" + } + }, + { + "path": { + "toString": "%s" + } + } + ], + "size": 2, + "isLastPage": true, + "start": 0, + "limit": 2, + "nextPageStart": null +} +` + firstResp := fmt.Sprintf(respTemplate, "file1.txt", "file2.txt") + secondResp := fmt.Sprintf(respTemplate, "file2.txt", "file3.txt") + var serverURL string + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + // The first request should hit this URL. + case "/rest/api/1.0/projects/ow/repos/repo/pull-requests/1/changes?start=0": + resp := strings.Replace(firstResp, `"isLastPage": true`, `"isLastPage": false`, -1) + resp = strings.Replace(resp, `"nextPageStart": null`, `"nextPageStart": "3"`, -1) + w.Write([]byte(resp)) // nolint: errcheck + return + // The second should hit this URL. + case "/rest/api/1.0/projects/ow/repos/repo/pull-requests/1/changes?start=3": + w.Write([]byte(secondResp)) // nolint: errcheck + default: + t.Errorf("got unexpected request at %q", r.RequestURI) + http.Error(w, "not found", http.StatusNotFound) + return + } + })) + defer testServer.Close() + + serverURL = testServer.URL + client, err := bitbucketserver.NewClient(http.DefaultClient, "user", "pass", serverURL, "runatlantis.io") + Ok(t, err) + + files, err := client.GetModifiedFiles(models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + SanitizedCloneURL: fmt.Sprintf("%s/scm/ow/repo.git", serverURL), + VCSHost: models.VCSHost{ + Type: models.BitbucketCloud, + Hostname: "bitbucket.org", + }, + }, models.PullRequest{ + Num: 1, + }) + Ok(t, err) + Equals(t, []string{"file1.txt", "file2.txt", "file3.txt"}, files) +} + +// If the "old" key in the list of files is nil we shouldn't error. +func TestClient_GetModifiedFilesOldNil(t *testing.T) { + resp := ` +{ + "pagelen": 500, + "values": [ + { + "status": "added", + "old": null, + "lines_removed": 0, + "lines_added": 2, + "new": { + "path": "parent/child/file1.txt", + "type": "commit_file", + "links": { + "self": { + "href": "https://api.bitbucket.org/2.0/repositories/lkysow/atlantis-example/src/1ed8205eec00dab4f1c0a8c486a4492c98c51f8e/main.tf" + } + } + }, + "type": "diffstat" + } + ], + "page": 1, + "size": 1 +}` + + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + // The first request should hit this URL. + case "/2.0/repositories/owner/repo/pullrequests/1/diffstat": + w.Write([]byte(resp)) // nolint: errcheck + return + default: + t.Errorf("got unexpected request at %q", r.RequestURI) + http.Error(w, "not found", http.StatusNotFound) + return + } + })) + defer testServer.Close() + + client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io") + client.BaseURL = testServer.URL + + files, err := client.GetModifiedFiles(models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + CloneURL: "", + SanitizedCloneURL: "", + VCSHost: models.VCSHost{ + Type: models.BitbucketCloud, + Hostname: "bitbucket.org", + }, + }, models.PullRequest{ + Num: 1, + }) + Ok(t, err) + Equals(t, []string{"parent/child/file1.txt"}, files) +} diff --git a/server/events/vcs/bitbucketserver/models.go b/server/events/vcs/bitbucketserver/models.go new file mode 100644 index 0000000000..d1d8333d62 --- /dev/null +++ b/server/events/vcs/bitbucketserver/models.go @@ -0,0 +1,66 @@ +package bitbucketserver + +const ( + PullCreatedHeader = "pr:opened" + PullMergedHeader = "pr:merged" + PullDeclinedHeader = "pr:declined" + PullCommentCreatedHeader = "pr:comment:added" +) + +type CommentEvent struct { + CommonEventData + Comment *Comment `json:"comment,omitempty" validate:"required"` +} + +type PullRequestEvent struct { + CommonEventData +} + +type CommonEventData struct { + Actor *Actor `json:"actor,omitempty" validate:"required"` + PullRequest *PullRequest `json:"pullRequest,omitempty" validate:"required"` +} + +type PullRequest struct { + ID *int `json:"id,omitempty" validate:"required"` + FromRef *Ref `json:"fromRef,omitempty" validate:"required"` + ToRef *Ref `json:"toRef,omitempty" validate:"required"` + State *string `json:"state,omitempty" validate:"required"` + Reviewers []struct { + Approved *bool `json:"approved,omitempty" validate:"required"` + } `json:"reviewers,omitempty" validate:"required"` +} + +type Ref struct { + Repository *Repository `json:"repository,omitempty" validate:"required"` + DisplayID *string `json:"displayId,omitempty" validate:"required"` + LatestCommit *string `json:"latestCommit,omitempty" validate:"required"` +} + +type Repository struct { + Slug *string `json:"slug,omitempty" validate:"required"` + Project *Project `json:"project,omitempty" validate:"required"` +} + +type Project struct { + Name *string `json:"name,omitempty" validate:"required"` + Key *string `json:"key,omitempty" validate:"required"` +} + +type Actor struct { + Username *string `json:"name,omitempty" validate:"required"` +} + +type Comment struct { + Text *string `json:"text,omitempty" validate:"required"` +} + +type Changes struct { + Values []struct { + Path struct { + ToString *string `json:"toString,omitempty" validate:"required"` + } `json:"path,omitempty" validate:"required"` + } `json:"values,omitempty" validate:"required"` + NextPageStart *string `json:"nextPageStart,omitempty"` + IsLastPage *bool `json:"isLastPage,omitempty" validate:"required"` +} diff --git a/server/events/vcs/bitbucketserver/request_validation.go b/server/events/vcs/bitbucketserver/request_validation.go new file mode 100644 index 0000000000..3c269a8cf8 --- /dev/null +++ b/server/events/vcs/bitbucketserver/request_validation.go @@ -0,0 +1,71 @@ +package bitbucketserver + +import ( + "crypto/hmac" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "fmt" + "hash" + "strings" + + "github.com/pkg/errors" +) + +// Attribution: This code is taken from https://github.com/google/go-github. + +func ValidateSignature(payload []byte, signature string, secretKey []byte) error { + messageMAC, hashFunc, err := messageMAC(signature) + if err != nil { + return err + } + if !checkMAC(payload, messageMAC, secretKey, hashFunc) { + return errors.New("payload signature check failed") + } + return nil +} + +// genMAC generates the HMAC signature for a message provided the secret key +// and hashFunc. +func genMAC(message, key []byte, hashFunc func() hash.Hash) []byte { + mac := hmac.New(hashFunc, key) + mac.Write(message) + return mac.Sum(nil) +} + +// checkMAC reports whether messageMAC is a valid HMAC tag for message. +func checkMAC(message, messageMAC, key []byte, hashFunc func() hash.Hash) bool { + expectedMAC := genMAC(message, key, hashFunc) + return hmac.Equal(messageMAC, expectedMAC) +} + +// messageMAC returns the hex-decoded HMAC tag from the signature and its +// corresponding hash function. +func messageMAC(signature string) ([]byte, func() hash.Hash, error) { + if signature == "" { + return nil, nil, errors.New("missing signature") + } + sigParts := strings.SplitN(signature, "=", 2) + if len(sigParts) != 2 { + return nil, nil, fmt.Errorf("error parsing signature %q", signature) + } + + var hashFunc func() hash.Hash + switch sigParts[0] { + case "sha1": + hashFunc = sha1.New + case "sha256": + hashFunc = sha256.New + case "sha512": + hashFunc = sha512.New + default: + return nil, nil, fmt.Errorf("unknown hash type prefix: %q", sigParts[0]) + } + + buf, err := hex.DecodeString(sigParts[1]) + if err != nil { + return nil, nil, fmt.Errorf("error decoding signature %q: %v", signature, err) + } + return buf, hashFunc, nil +} diff --git a/server/events/vcs/bitbucketserver/request_validation_test.go b/server/events/vcs/bitbucketserver/request_validation_test.go new file mode 100644 index 0000000000..9b9d5228a4 --- /dev/null +++ b/server/events/vcs/bitbucketserver/request_validation_test.go @@ -0,0 +1,24 @@ +package bitbucketserver_test + +import ( + "testing" + + "github.com/runatlantis/atlantis/server/events/vcs/bitbucketserver" + . "github.com/runatlantis/atlantis/testing" +) + +func TestValidateSignature(t *testing.T) { + body := `{"eventKey":"pr:comment:added","date":"2018-07-24T15:10:05+0200","actor":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"pullRequest":{"id":5,"version":0,"title":"another.tf edited online with Bitbucket","state":"OPEN","open":true,"closed":false,"createdDate":1532365427513,"updatedDate":1532365427513,"fromRef":{"id":"refs/heads/lkysow/anothertf-1532365422773","displayId":"lkysow/anothertf-1532365422773","latestCommit":"b52b8e254e956654dcdb394d0ccba9199f420427","repository":{"slug":"atlantis-example","id":1,"name":"atlantis-example","scmId":"git","state":"AVAILABLE","statusMessage":"Available","forkable":true,"project":{"key":"AT","id":1,"name":"atlantis","public":false,"type":"NORMAL"},"public":false}},"toRef":{"id":"refs/heads/master","displayId":"master","latestCommit":"0a338874369017deba7c22e99e6000932724282f","repository":{"slug":"atlantis-example","id":1,"name":"atlantis-example","scmId":"git","state":"AVAILABLE","statusMessage":"Available","forkable":true,"project":{"key":"AT","id":1,"name":"atlantis","public":false,"type":"NORMAL"},"public":false}},"locked":false,"author":{"user":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"role":"AUTHOR","approved":false,"status":"UNAPPROVED"},"reviewers":[],"participants":[]},"comment":{"properties":{"repositoryId":1},"id":65,"version":0,"text":"comment","author":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"createdDate":1532437805137,"updatedDate":1532437805137,"comments":[],"tasks":[]}}` + secret := "mysecret" + sig := `sha256=ed11f92d1565a4de586727fe8260558277d58009e8957a79eb4749a7009ce083` + err := bitbucketserver.ValidateSignature([]byte(body), sig, []byte(secret)) + Ok(t, err) +} + +func TestValidateSignature_Invalid(t *testing.T) { + body := `"eventKey":"pr:comment:added","date":"2018-07-24T15:10:05+0200","actor":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"pullRequest":{"id":5,"version":0,"title":"another.tf edited online with Bitbucket","state":"OPEN","open":true,"closed":false,"createdDate":1532365427513,"updatedDate":1532365427513,"fromRef":{"id":"refs/heads/lkysow/anothertf-1532365422773","displayId":"lkysow/anothertf-1532365422773","latestCommit":"b52b8e254e956654dcdb394d0ccba9199f420427","repository":{"slug":"atlantis-example","id":1,"name":"atlantis-example","scmId":"git","state":"AVAILABLE","statusMessage":"Available","forkable":true,"project":{"key":"AT","id":1,"name":"atlantis","public":false,"type":"NORMAL"},"public":false}},"toRef":{"id":"refs/heads/master","displayId":"master","latestCommit":"0a338874369017deba7c22e99e6000932724282f","repository":{"slug":"atlantis-example","id":1,"name":"atlantis-example","scmId":"git","state":"AVAILABLE","statusMessage":"Available","forkable":true,"project":{"key":"AT","id":1,"name":"atlantis","public":false,"type":"NORMAL"},"public":false}},"locked":false,"author":{"user":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"role":"AUTHOR","approved":false,"status":"UNAPPROVED"},"reviewers":[],"participants":[]},"comment":{"properties":{"repositoryId":1},"id":65,"version":0,"text":"comment","author":{"name":"lkysow","emailAddress":"lkysow@gmail.com","id":1,"displayName":"Luke Kysow","active":true,"slug":"lkysow","type":"NORMAL"},"createdDate":1532437805137,"updatedDate":1532437805137,"comments":[],"tasks":[]}}` + secret := "mysecret" + sig := `sha256=ed11f92d1565a4de586727fe8260558277d58009e8957a79eb4749a7009ce083` + err := bitbucketserver.ValidateSignature([]byte(body), sig, []byte(secret)) + ErrEquals(t, "payload signature check failed", err) +} diff --git a/server/events/vcs/proxy.go b/server/events/vcs/proxy.go index 01a2c5f483..4779ccd90b 100644 --- a/server/events/vcs/proxy.go +++ b/server/events/vcs/proxy.go @@ -14,7 +14,6 @@ package vcs import ( - "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/events/models" ) @@ -32,74 +31,46 @@ type ClientProxy interface { // DefaultClientProxy proxies calls to the correct VCS client depending on which // VCS host is required. type DefaultClientProxy struct { - GithubClient Client - GitlabClient Client - BitbucketClient Client + // clients maps from the vcs host type to the client that implements the + // api for that host type, ex. github -> github client. + clients map[models.VCSHostType]Client } -func NewDefaultClientProxy(githubClient Client, gitlabClient Client, bitbucketClient Client) *DefaultClientProxy { +func NewDefaultClientProxy(githubClient Client, gitlabClient Client, bitbucketCloudClient Client, bitbucketServerClient Client) *DefaultClientProxy { if githubClient == nil { githubClient = &NotConfiguredVCSClient{} } if gitlabClient == nil { gitlabClient = &NotConfiguredVCSClient{} } - if bitbucketClient == nil { - bitbucketClient = &NotConfiguredVCSClient{} + if bitbucketCloudClient == nil { + bitbucketCloudClient = &NotConfiguredVCSClient{} + } + if bitbucketServerClient == nil { + bitbucketServerClient = &NotConfiguredVCSClient{} } return &DefaultClientProxy{ - GitlabClient: gitlabClient, - GithubClient: githubClient, - BitbucketClient: bitbucketClient, + clients: map[models.VCSHostType]Client{ + models.Github: githubClient, + models.Gitlab: gitlabClient, + models.BitbucketCloud: bitbucketCloudClient, + models.BitbucketServer: bitbucketServerClient, + }, } } -var invalidVCSErr = errors.New("Invalid VCS Host. This is a bug!") - func (d *DefaultClientProxy) GetModifiedFiles(repo models.Repo, pull models.PullRequest) ([]string, error) { - switch repo.VCSHost.Type { - case models.Github: - return d.GithubClient.GetModifiedFiles(repo, pull) - case models.Gitlab: - return d.GitlabClient.GetModifiedFiles(repo, pull) - case models.Bitbucket: - return d.BitbucketClient.GetModifiedFiles(repo, pull) - } - return nil, invalidVCSErr + return d.clients[repo.VCSHost.Type].GetModifiedFiles(repo, pull) } func (d *DefaultClientProxy) CreateComment(repo models.Repo, pullNum int, comment string) error { - switch repo.VCSHost.Type { - case models.Github: - return d.GithubClient.CreateComment(repo, pullNum, comment) - case models.Gitlab: - return d.GitlabClient.CreateComment(repo, pullNum, comment) - case models.Bitbucket: - return d.BitbucketClient.CreateComment(repo, pullNum, comment) - } - return invalidVCSErr + return d.clients[repo.VCSHost.Type].CreateComment(repo, pullNum, comment) } func (d *DefaultClientProxy) PullIsApproved(repo models.Repo, pull models.PullRequest) (bool, error) { - switch repo.VCSHost.Type { - case models.Github: - return d.GithubClient.PullIsApproved(repo, pull) - case models.Gitlab: - return d.GitlabClient.PullIsApproved(repo, pull) - case models.Bitbucket: - return d.BitbucketClient.PullIsApproved(repo, pull) - } - return false, invalidVCSErr + return d.clients[repo.VCSHost.Type].PullIsApproved(repo, pull) } func (d *DefaultClientProxy) UpdateStatus(repo models.Repo, pull models.PullRequest, state models.CommitStatus, description string) error { - switch repo.VCSHost.Type { - case models.Github: - return d.GithubClient.UpdateStatus(repo, pull, state, description) - case models.Gitlab: - return d.GitlabClient.UpdateStatus(repo, pull, state, description) - case models.Bitbucket: - return d.BitbucketClient.UpdateStatus(repo, pull, state, description) - } - return invalidVCSErr + return d.clients[repo.VCSHost.Type].UpdateStatus(repo, pull, state, description) } diff --git a/server/events/vcs/vcs.go b/server/events/vcs/vcs.go new file mode 100644 index 0000000000..547b1da48b --- /dev/null +++ b/server/events/vcs/vcs.go @@ -0,0 +1 @@ +package vcs diff --git a/server/events_controller.go b/server/events_controller.go index 3a54cdeee9..5daa53e3b6 100644 --- a/server/events_controller.go +++ b/server/events_controller.go @@ -20,16 +20,23 @@ import ( "github.com/google/go-github/github" "github.com/lkysow/go-gitlab" + "github.com/pkg/errors" "github.com/runatlantis/atlantis/server/events" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" + "github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud" + "github.com/runatlantis/atlantis/server/events/vcs/bitbucketserver" "github.com/runatlantis/atlantis/server/logging" ) const githubHeader = "X-Github-Event" const gitlabHeader = "X-Gitlab-Event" + +// bitbucketEventTypeHeader is the same in both cloud and server. const bitbucketEventTypeHeader = "X-Event-Key" -const bitbucketRequestIDHeader = "X-Request-UUID" +const bitbucketCloudRequestIDHeader = "X-Request-UUID" +const bitbucketServerRequestIDHeader = "X-Request-ID" +const bitbucketServerSignatureHeader = "X-Hub-Signature" // EventsController handles all webhook requests which signify 'events' in the // VCS host, ex. GitHub. @@ -39,22 +46,26 @@ type EventsController struct { Logger *logging.SimpleLogger Parser events.EventParsing CommentParser events.CommentParsing - // GithubWebHookSecret is the secret added to this webhook via the GitHub + // GithubWebhookSecret is the secret added to this webhook via the GitHub // UI that identifies this call as coming from GitHub. If empty, no // request validation is done. - GithubWebHookSecret []byte + GithubWebhookSecret []byte GithubRequestValidator GithubRequestValidator GitlabRequestParserValidator GitlabRequestParserValidator - // GitlabWebHookSecret is the secret added to this webhook via the GitLab + // GitlabWebhookSecret is the secret added to this webhook via the GitLab // UI that identifies this call as coming from GitLab. If empty, no // request validation is done. - GitlabWebHookSecret []byte + GitlabWebhookSecret []byte RepoWhitelistChecker *events.RepoWhitelistChecker // SupportedVCSHosts is which VCS hosts Atlantis was configured upon // startup to support. SupportedVCSHosts []models.VCSHostType VCSClient vcs.ClientProxy TestingMode bool + // BitbucketWebhookSecret is the secret added to this webhook via the Bitbucket + // UI that identifies this call as coming from Bitbucket. If empty, no + // request validation is done. + BitbucketWebhookSecret []byte } // Post handles POST webhook requests. @@ -76,20 +87,32 @@ func (e *EventsController) Post(w http.ResponseWriter, r *http.Request) { e.handleGitlabPost(w, r) return } else if r.Header.Get(bitbucketEventTypeHeader) != "" { - if !e.supportsHost(models.Bitbucket) { - e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request since not configured to support Bitbucket") + // Bitbucket Cloud and Server use the same event type header but they + // use different request ID headers. + if r.Header.Get(bitbucketCloudRequestIDHeader) != "" { + if !e.supportsHost(models.BitbucketCloud) { + e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request since not configured to support Bitbucket Cloud") + return + } + e.Logger.Debug("handling Bitbucket Cloud post") + e.handleBitbucketCloudPost(w, r) + return + } else if r.Header.Get(bitbucketServerRequestIDHeader) != "" { + if !e.supportsHost(models.BitbucketServer) { + e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request since not configured to support Bitbucket Server") + return + } + e.Logger.Debug("handling Bitbucket Server post") + e.handleBitbucketServerPost(w, r) return } - e.Logger.Debug("handling Bitbucket post") - e.handleBitbucketPost(w, r) - return } e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request") } func (e *EventsController) handleGithubPost(w http.ResponseWriter, r *http.Request) { // Validate the request against the optional webhook secret. - payload, err := e.GithubRequestValidator.Validate(r, e.GithubWebHookSecret) + payload, err := e.GithubRequestValidator.Validate(r, e.GithubWebhookSecret) if err != nil { e.respond(w, logging.Warn, http.StatusBadRequest, err.Error()) return @@ -110,26 +133,56 @@ func (e *EventsController) handleGithubPost(w http.ResponseWriter, r *http.Reque } } -func (e *EventsController) handleBitbucketPost(w http.ResponseWriter, r *http.Request) { +func (e *EventsController) handleBitbucketCloudPost(w http.ResponseWriter, r *http.Request) { + eventType := r.Header.Get(bitbucketEventTypeHeader) + reqID := r.Header.Get(bitbucketCloudRequestIDHeader) + defer r.Body.Close() // nolint: errcheck + body, err := ioutil.ReadAll(r.Body) + if err != nil { + e.respond(w, logging.Error, http.StatusBadRequest, "Unable to read body: %s %s=%s", err, bitbucketCloudRequestIDHeader, reqID) + return + } + switch eventType { + case bitbucketcloud.PullCreatedHeader, bitbucketcloud.PullUpdatedHeader, bitbucketcloud.PullFulfilledHeader, bitbucketcloud.PullRejectedHeader: + e.Logger.Debug("handling as pull request state changed event") + e.HandleBitbucketCloudPullRequestEvent(w, eventType, body, reqID) + return + case bitbucketcloud.PullCommentCreatedHeader: + e.Logger.Debug("handling as comment created event") + e.HandleBitbucketCloudCommentEvent(w, body, reqID) + return + default: + e.respond(w, logging.Debug, http.StatusOK, "Ignoring unsupported event type %s %s=%s", eventType, bitbucketCloudRequestIDHeader, reqID) + } +} + +func (e *EventsController) handleBitbucketServerPost(w http.ResponseWriter, r *http.Request) { eventType := r.Header.Get(bitbucketEventTypeHeader) - reqID := r.Header.Get(bitbucketRequestIDHeader) + reqID := r.Header.Get(bitbucketServerRequestIDHeader) + sig := r.Header.Get(bitbucketServerSignatureHeader) defer r.Body.Close() // nolint: errcheck body, err := ioutil.ReadAll(r.Body) if err != nil { - e.respond(w, logging.Error, http.StatusBadRequest, "Unable to read body: %s %s=%s", err, bitbucketRequestIDHeader, reqID) + e.respond(w, logging.Error, http.StatusBadRequest, "Unable to read body: %s %s=%s", err, bitbucketServerRequestIDHeader, reqID) return } + if len(e.BitbucketWebhookSecret) > 0 { + if err := bitbucketserver.ValidateSignature(body, sig, e.BitbucketWebhookSecret); err != nil { + e.respond(w, logging.Warn, http.StatusBadRequest, errors.Wrap(err, "request did not pass validation").Error()) + return + } + } switch eventType { - case "pullrequest:created", "pullrequest:updated", "pullrequest:fulfilled", "pullrequest:rejected": + case bitbucketserver.PullCreatedHeader, bitbucketserver.PullMergedHeader, bitbucketserver.PullDeclinedHeader: e.Logger.Debug("handling as pull request state changed event") - e.HandleBitbucketPullRequestEvent(w, eventType, body, reqID) + e.HandleBitbucketServerPullRequestEvent(w, eventType, body, reqID) return - case "pullrequest:comment_created": + case bitbucketserver.PullCommentCreatedHeader: e.Logger.Debug("handling as comment created event") - e.HandleBitbucketCommentEvent(w, body, reqID) + e.HandleBitbucketServerCommentEvent(w, body, reqID) return default: - e.respond(w, logging.Debug, http.StatusOK, "Ignoring unsupported event type %s %s=%s", eventType, bitbucketRequestIDHeader, reqID) + e.respond(w, logging.Debug, http.StatusOK, "Ignoring unsupported event type %s %s=%s", eventType, bitbucketServerRequestIDHeader, reqID) } } @@ -152,23 +205,44 @@ func (e *EventsController) HandleGithubCommentEvent(w http.ResponseWriter, event e.handleCommentEvent(w, baseRepo, nil, nil, user, pullNum, event.Comment.GetBody(), models.Github) } -// HandleBitbucketCommentEvent handles comment events from Bitbucket. -func (e *EventsController) HandleBitbucketCommentEvent(w http.ResponseWriter, body []byte, reqID string) { +// HandleBitbucketCloudCommentEvent handles comment events from Bitbucket. +func (e *EventsController) HandleBitbucketCloudCommentEvent(w http.ResponseWriter, body []byte, reqID string) { pull, baseRepo, headRepo, user, comment, err := e.Parser.ParseBitbucketCloudCommentEvent(body) if err != nil { - e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing pull data: %s %s=%s", err, bitbucketRequestIDHeader, reqID) + e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing pull data: %s %s=%s", err, bitbucketCloudRequestIDHeader, reqID) return } - e.handleCommentEvent(w, baseRepo, &headRepo, &pull, user, pull.Num, comment, models.Bitbucket) + e.handleCommentEvent(w, baseRepo, &headRepo, &pull, user, pull.Num, comment, models.BitbucketCloud) } -func (e *EventsController) HandleBitbucketPullRequestEvent(w http.ResponseWriter, eventType string, body []byte, reqID string) { +// HandleBitbucketServerCommentEvent handles comment events from Bitbucket. +func (e *EventsController) HandleBitbucketServerCommentEvent(w http.ResponseWriter, body []byte, reqID string) { + pull, baseRepo, headRepo, user, comment, err := e.Parser.ParseBitbucketServerCommentEvent(body) + if err != nil { + e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing pull data: %s %s=%s", err, bitbucketCloudRequestIDHeader, reqID) + return + } + e.handleCommentEvent(w, baseRepo, &headRepo, &pull, user, pull.Num, comment, models.BitbucketCloud) +} + +func (e *EventsController) HandleBitbucketCloudPullRequestEvent(w http.ResponseWriter, eventType string, body []byte, reqID string) { pull, baseRepo, headRepo, user, err := e.Parser.ParseBitbucketCloudPullEvent(body) if err != nil { - e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing pull data: %s %s=%s", err, bitbucketRequestIDHeader, reqID) + e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing pull data: %s %s=%s", err, bitbucketCloudRequestIDHeader, reqID) + return + } + pullEventType := e.Parser.GetBitbucketCloudEventType(eventType) + e.Logger.Info("identified event as type %q", pullEventType.String()) + e.handlePullRequestEvent(w, baseRepo, headRepo, pull, user, pullEventType) +} + +func (e *EventsController) HandleBitbucketServerPullRequestEvent(w http.ResponseWriter, eventType string, body []byte, reqID string) { + pull, baseRepo, headRepo, user, err := e.Parser.ParseBitbucketServerPullEvent(body) + if err != nil { + e.respond(w, logging.Error, http.StatusBadRequest, "Error parsing pull data: %s %s=%s", err, bitbucketServerRequestIDHeader, reqID) return } - pullEventType := e.Parser.GetBitbucketEventType(eventType) + pullEventType := e.Parser.GetBitbucketServerEventType(eventType) e.Logger.Info("identified event as type %q", pullEventType.String()) e.handlePullRequestEvent(w, baseRepo, headRepo, pull, user, pullEventType) } @@ -233,7 +307,7 @@ func (e *EventsController) handlePullRequestEvent(w http.ResponseWriter, baseRep } func (e *EventsController) handleGitlabPost(w http.ResponseWriter, r *http.Request) { - event, err := e.GitlabRequestParserValidator.ParseAndValidate(r, e.GitlabWebHookSecret) + event, err := e.GitlabRequestParserValidator.ParseAndValidate(r, e.GitlabWebhookSecret) if err != nil { e.respond(w, logging.Warn, http.StatusBadRequest, err.Error()) return diff --git a/server/events_controller_e2e_test.go b/server/events_controller_e2e_test.go index 75f64fbbd2..a54926506b 100644 --- a/server/events_controller_e2e_test.go +++ b/server/events_controller_e2e_test.go @@ -301,12 +301,12 @@ func setupE2E(t *testing.T) (server.EventsController, *vcsmocks.MockClientProxy, Logger: logger, Parser: eventParser, CommentParser: commentParser, - GithubWebHookSecret: nil, + GithubWebhookSecret: nil, GithubRequestValidator: &server.DefaultGithubRequestValidator{}, GitlabRequestParserValidator: &server.DefaultGitlabRequestParserValidator{}, - GitlabWebHookSecret: nil, + GitlabWebhookSecret: nil, RepoWhitelistChecker: repoWhitelistChecker, - SupportedVCSHosts: []models.VCSHostType{models.Gitlab, models.Github, models.Bitbucket}, + SupportedVCSHosts: []models.VCSHostType{models.Gitlab, models.Github, models.BitbucketCloud}, VCSClient: e2eVCSClient, } return ctrl, e2eVCSClient, e2eGithubGetter, workingDir diff --git a/server/events_controller_test.go b/server/events_controller_test.go index a3bda4b43f..163116cee4 100644 --- a/server/events_controller_test.go +++ b/server/events_controller_test.go @@ -528,9 +528,9 @@ func setup(t *testing.T) (server.EventsController, *mocks.MockGithubRequestValid CommentParser: cp, CommandRunner: cr, PullCleaner: c, - GithubWebHookSecret: secret, + GithubWebhookSecret: secret, SupportedVCSHosts: []models.VCSHostType{models.Github, models.Gitlab}, - GitlabWebHookSecret: secret, + GitlabWebhookSecret: secret, GitlabRequestParserValidator: gl, RepoWhitelistChecker: repoWhitelistChecker, VCSClient: vcsmock, diff --git a/server/locks_controller.go b/server/locks_controller.go index 140cb30460..751cee31af 100644 --- a/server/locks_controller.go +++ b/server/locks_controller.go @@ -96,8 +96,10 @@ func (l *LocksController) DeleteLock(w http.ResponseWriter, r *http.Request) { l.Logger.Err("unable to obtain working dir lock when trying to delete old plans: %s", err) } else { defer unlock() - err = l.WorkingDir.DeleteForWorkspace(lock.Pull.BaseRepo, lock.Pull, lock.Workspace) - l.Logger.Err("unable to delete workspace: %s", err) + // nolint: vetshadow + if err := l.WorkingDir.DeleteForWorkspace(lock.Pull.BaseRepo, lock.Pull, lock.Workspace); err != nil { + l.Logger.Err("unable to delete workspace: %s", err) + } } // Once the lock has been deleted, comment back on the pull request. diff --git a/server/server.go b/server/server.go index 4637133ea3..ae48f0bc55 100644 --- a/server/server.go +++ b/server/server.go @@ -40,7 +40,8 @@ import ( "github.com/runatlantis/atlantis/server/events/runtime" "github.com/runatlantis/atlantis/server/events/terraform" "github.com/runatlantis/atlantis/server/events/vcs" - "github.com/runatlantis/atlantis/server/events/vcs/bitbucket" + "github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud" + "github.com/runatlantis/atlantis/server/events/vcs/bitbucketserver" "github.com/runatlantis/atlantis/server/events/webhooks" "github.com/runatlantis/atlantis/server/events/yaml" "github.com/runatlantis/atlantis/server/logging" @@ -81,24 +82,25 @@ type Server struct { // The mapstructure tags correspond to flags in cmd/server.go and are used when // the config is parsed from a YAML file. type UserConfig struct { - AllowForkPRs bool `mapstructure:"allow-fork-prs"` - AllowRepoConfig bool `mapstructure:"allow-repo-config"` - AtlantisURL string `mapstructure:"atlantis-url"` - BitbucketHostname string `mapstructure:"bitbucket-hostname"` - BitbucketToken string `mapstructure:"bitbucket-token"` - BitbucketUser string `mapstructure:"bitbucket-user"` - DataDir string `mapstructure:"data-dir"` - GithubHostname string `mapstructure:"gh-hostname"` - GithubToken string `mapstructure:"gh-token"` - GithubUser string `mapstructure:"gh-user"` - GithubWebHookSecret string `mapstructure:"gh-webhook-secret"` - GitlabHostname string `mapstructure:"gitlab-hostname"` - GitlabToken string `mapstructure:"gitlab-token"` - GitlabUser string `mapstructure:"gitlab-user"` - GitlabWebHookSecret string `mapstructure:"gitlab-webhook-secret"` - LogLevel string `mapstructure:"log-level"` - Port int `mapstructure:"port"` - RepoWhitelist string `mapstructure:"repo-whitelist"` + AllowForkPRs bool `mapstructure:"allow-fork-prs"` + AllowRepoConfig bool `mapstructure:"allow-repo-config"` + AtlantisURL string `mapstructure:"atlantis-url"` + BitbucketBaseURL string `mapstructure:"bitbucket-base-url"` + BitbucketToken string `mapstructure:"bitbucket-token"` + BitbucketUser string `mapstructure:"bitbucket-user"` + BitbucketWebhookSecret string `mapstructure:"bitbucket-webhook-secret"` + DataDir string `mapstructure:"data-dir"` + GithubHostname string `mapstructure:"gh-hostname"` + GithubToken string `mapstructure:"gh-token"` + GithubUser string `mapstructure:"gh-user"` + GithubWebhookSecret string `mapstructure:"gh-webhook-secret"` + GitlabHostname string `mapstructure:"gitlab-hostname"` + GitlabToken string `mapstructure:"gitlab-token"` + GitlabUser string `mapstructure:"gitlab-user"` + GitlabWebhookSecret string `mapstructure:"gitlab-webhook-secret"` + LogLevel string `mapstructure:"log-level"` + Port int `mapstructure:"port"` + RepoWhitelist string `mapstructure:"repo-whitelist"` // RequireApproval is whether to require pull request approval before // allowing terraform apply's to be run. RequireApproval bool `mapstructure:"require-approval"` @@ -137,7 +139,8 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { var supportedVCSHosts []models.VCSHostType var githubClient *vcs.GithubClient var gitlabClient *vcs.GitlabClient - var bitbucketClient *bitbucket.Client + var bitbucketCloudClient *bitbucketcloud.Client + var bitbucketServerClient *bitbucketserver.Client if userConfig.GithubUser != "" { supportedVCSHosts = append(supportedVCSHosts, models.Github) var err error @@ -168,17 +171,25 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { } } if userConfig.BitbucketUser != "" { - supportedVCSHosts = append(supportedVCSHosts, models.Bitbucket) - var err error - bitbucketClient, err = bitbucket.NewClient( - http.DefaultClient, - userConfig.BitbucketUser, - userConfig.BitbucketToken, - // todo: don't hardcode when we allow for bitbucket server - "https://api.bitbucket.org/", - userConfig.AtlantisURL) - if err != nil { - return nil, errors.Wrapf(err, "setting up Bitbucket client") + if userConfig.BitbucketBaseURL == bitbucketcloud.BaseURL { + supportedVCSHosts = append(supportedVCSHosts, models.BitbucketCloud) + bitbucketCloudClient = bitbucketcloud.NewClient( + http.DefaultClient, + userConfig.BitbucketUser, + userConfig.BitbucketToken, + userConfig.AtlantisURL) + } else { + supportedVCSHosts = append(supportedVCSHosts, models.BitbucketServer) + var err error + bitbucketServerClient, err = bitbucketserver.NewClient( + http.DefaultClient, + userConfig.BitbucketUser, + userConfig.BitbucketToken, + userConfig.BitbucketBaseURL, + userConfig.AtlantisURL) + if err != nil { + return nil, errors.Wrapf(err, "setting up Bitbucket Server client") + } } } @@ -196,7 +207,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { if err != nil { return nil, errors.Wrap(err, "initializing webhooks") } - vcsClient := vcs.NewDefaultClientProxy(githubClient, gitlabClient, bitbucketClient) + vcsClient := vcs.NewDefaultClientProxy(githubClient, gitlabClient, bitbucketCloudClient, bitbucketServerClient) commitStatusUpdater := &events.DefaultCommitStatusUpdater{Client: vcsClient} terraformClient, err := terraform.NewClient(userConfig.DataDir) // The flag.Lookup call is to detect if we're running in a unit test. If we @@ -232,12 +243,13 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { } logger := logging.NewSimpleLogger("server", nil, false, logging.ToLogLevel(userConfig.LogLevel)) eventParser := &events.EventParser{ - GithubUser: userConfig.GithubUser, - GithubToken: userConfig.GithubToken, - GitlabUser: userConfig.GitlabUser, - GitlabToken: userConfig.GitlabToken, - BitbucketUser: userConfig.BitbucketUser, - BitbucketToken: userConfig.BitbucketToken, + GithubUser: userConfig.GithubUser, + GithubToken: userConfig.GithubToken, + GitlabUser: userConfig.GitlabUser, + GitlabToken: userConfig.GitlabToken, + BitbucketUser: userConfig.BitbucketUser, + BitbucketToken: userConfig.BitbucketToken, + BitbucketServerURL: userConfig.BitbucketBaseURL, } commentParser := &events.CommentParser{ GithubUser: userConfig.GithubUser, @@ -308,13 +320,14 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { Parser: eventParser, CommentParser: commentParser, Logger: logger, - GithubWebHookSecret: []byte(userConfig.GithubWebHookSecret), + GithubWebhookSecret: []byte(userConfig.GithubWebhookSecret), GithubRequestValidator: &DefaultGithubRequestValidator{}, GitlabRequestParserValidator: &DefaultGitlabRequestParserValidator{}, - GitlabWebHookSecret: []byte(userConfig.GitlabWebHookSecret), + GitlabWebhookSecret: []byte(userConfig.GitlabWebhookSecret), RepoWhitelistChecker: repoWhitelist, SupportedVCSHosts: supportedVCSHosts, VCSClient: vcsClient, + BitbucketWebhookSecret: []byte(userConfig.BitbucketWebhookSecret), } return &Server{ AtlantisVersion: config.AtlantisVersion,