From 722c4a93727ffdeef13e9b911e062321a0df19b7 Mon Sep 17 00:00:00 2001 From: Martijn van der Kleijn Date: Mon, 18 Mar 2024 15:21:45 +0100 Subject: [PATCH] feat: Add Gitea support (#4229) * Add initial Gitea client structure * Add various missing config flags * initial gitea support added * Fix some post-merge issues * Replace HidePrevCommandComments by version from @florianbeisel * Update mocks * feat: add Webhook Signature Verification This changes adds support for Gitea Webhook Signatures by wrapping the function from the Gitea SDK and calling it from `handleGiteaPost()`. * fix: use release version in go.mod 1.22 as in the previous go.mod is a development version. When referencing a minimum release version the correct format is 1.22.0 * Set default Gitea url to cloud.gitea.com * Fix and Add tests for Gitea * Fix missing copyright header * Changed comment to reflect no max comment length Apparently there's no max comment length in Gitea at this point in time. * Implement GetCloneURL() * Decode Base64 before passing on downloaded file content * Enable Gitea client as API Client * Remove unneded comments * Remove old redundant file * fix: invalid version number in go.mod * fix: remove unnecessary type conversions * fix: removed unused function * fix: remove unnecessary type conversion of decodedData * fix: fixes some tests * Correct gitea.com URL * Add Gitea to website docs * fix: TestPost_UnsupportedGiteaEvent * revert version downgrades * docs: add Gitea documentation to Guide section * docs: fix copy paste mistake * Update cmd/server_test.go Co-authored-by: nitrocode <7775707+nitrocode@users.noreply.github.com> * Clarify usage msg for --gitea-base-url * Apply suggestions from code review Co-authored-by: nitrocode <7775707+nitrocode@users.noreply.github.com> * Turn ebreak number into const with comments * Add --gitea-page-size server argument Defaults to 30 based on https://docs.gitea.com/1.18/advanced/config-cheat-sheet#api-api * Fix broken test * Fix event parser and comment parser * Add missing app permission to docs * Make Gitea client conform to updated interface * Update server/events/vcs/gitea/client.go Co-authored-by: Simon Heather <32168619+X-Guardian@users.noreply.github.com> * Remove no longer needed logger * Add extra logging statements for Gitea client * Add debug statements --------- Co-authored-by: Florian Beisel Co-authored-by: Florian Beisel Co-authored-by: PePe Amengual Co-authored-by: nitrocode <7775707+nitrocode@users.noreply.github.com> Co-authored-by: Rui Chen Co-authored-by: Simon Heather <32168619+X-Guardian@users.noreply.github.com> --- cmd/server.go | 66 ++- cmd/server_test.go | 104 +++- go.mod | 5 +- go.sum | 28 +- runatlantis.io/docs/access-credentials.md | 12 +- runatlantis.io/docs/configuring-webhooks.md | 20 + runatlantis.io/docs/deployment.md | 87 ++- runatlantis.io/docs/installation-guide.md | 2 +- runatlantis.io/docs/requirements.md | 1 + runatlantis.io/docs/server-configuration.md | 50 ++ runatlantis.io/guide/testing-locally.md | 52 ++ .../controllers/events/events_controller.go | 103 +++- .../events/events_controller_test.go | 38 +- server/events/command_runner.go | 19 + server/events/comment_parser.go | 6 +- server/events/comment_parser_test.go | 5 +- server/events/event_parser.go | 161 ++++++ server/events/mocks/mock_event_parsing.go | 172 ++++++ server/events/models/models.go | 5 + server/events/project_locker_test.go | 8 +- server/events/vcs/gitea/client.go | 517 ++++++++++++++++++ server/events/vcs/gitea/models.go | 30 + server/events/vcs/proxy.go | 6 +- server/server.go | 27 +- server/user_config.go | 5 + 25 files changed, 1495 insertions(+), 34 deletions(-) create mode 100644 server/events/vcs/gitea/client.go create mode 100644 server/events/vcs/gitea/models.go diff --git a/cmd/server.go b/cmd/server.go index 31ecfd393e..caa1351f7e 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -93,6 +93,11 @@ const ( GHOrganizationFlag = "gh-org" GHWebhookSecretFlag = "gh-webhook-secret" // nolint: gosec GHAllowMergeableBypassApply = "gh-allow-mergeable-bypass-apply" // nolint: gosec + GiteaBaseURLFlag = "gitea-base-url" + GiteaTokenFlag = "gitea-token" + GiteaUserFlag = "gitea-user" + GiteaWebhookSecretFlag = "gitea-webhook-secret" // nolint: gosec + GiteaPageSizeFlag = "gitea-page-size" GitlabHostnameFlag = "gitlab-hostname" GitlabTokenFlag = "gitlab-token" GitlabUserFlag = "gitlab-user" @@ -156,6 +161,8 @@ const ( DefaultExecutableName = "atlantis" DefaultMarkdownTemplateOverridesDir = "~/.markdown_templates" DefaultGHHostname = "github.com" + DefaultGiteaBaseURL = "https://gitea.com" + DefaultGiteaPageSize = 30 DefaultGitlabHostname = "gitlab.com" DefaultLockingDBType = "boltdb" DefaultLogLevel = "info" @@ -318,6 +325,22 @@ var stringFlags = map[string]stringFlag{ "This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. " + "Should be specified via the ATLANTIS_GH_WEBHOOK_SECRET environment variable.", }, + GiteaBaseURLFlag: { + description: "Base URL of Gitea server installation. Must include 'http://' or 'https://'.", + }, + GiteaUserFlag: { + description: "Gitea username of API user.", + defaultValue: "", + }, + GiteaTokenFlag: { + description: "Gitea token of API user. Can also be specified via the ATLANTIS_GITEA_TOKEN environment variable.", + }, + GiteaWebhookSecretFlag: { + description: "Optional secret used to validate Gitea webhooks." + + " SECURITY WARNING: If not specified, Atlantis won't be able to validate that the incoming webhook call came from Gitea. " + + "This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. " + + "Should be specified via the ATLANTIS_GITEA_WEBHOOK_SECRET environment variable.", + }, GitlabHostnameFlag: { description: "Hostname of your GitLab Enterprise installation. If using gitlab.com, no need to set.", defaultValue: DefaultGitlabHostname, @@ -568,6 +591,10 @@ var intFlags = map[string]intFlag{ " If merge base is further behind than this number of commits from any of branches heads, full fetch will be performed.", defaultValue: DefaultCheckoutDepth, }, + GiteaPageSizeFlag: { + description: "Optional value that specifies the number of results per page to expect from Gitea.", + defaultValue: DefaultGiteaPageSize, + }, ParallelPoolSize: { description: "Max size of the wait group that runs parallel plans and applies (if enabled).", defaultValue: DefaultParallelPoolSize, @@ -813,6 +840,12 @@ func (s *ServerCmd) setDefaults(c *server.UserConfig) { if c.GitlabHostname == "" { c.GitlabHostname = DefaultGitlabHostname } + if c.GiteaBaseURL == "" { + c.GiteaBaseURL = DefaultGiteaBaseURL + } + if c.GiteaPageSize == 0 { + c.GiteaPageSize = DefaultGiteaPageSize + } if c.BitbucketBaseURL == "" { c.BitbucketBaseURL = DefaultBitbucketBaseURL } @@ -885,12 +918,17 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error { // The following combinations are valid. // 1. github user and token set // 2. github app ID and (key file set or key set) - // 3. gitlab user and token set - // 4. bitbucket user and token set - // 5. azuredevops user and token set - // 6. any combination of the above - vcsErr := fmt.Errorf("--%s/--%s or --%s/--%s or --%s/--%s or --%s/--%s or --%s/--%s or --%s/--%s must be set", GHUserFlag, GHTokenFlag, GHAppIDFlag, GHAppKeyFileFlag, GHAppIDFlag, GHAppKeyFlag, GitlabUserFlag, GitlabTokenFlag, BitbucketUserFlag, BitbucketTokenFlag, ADUserFlag, ADTokenFlag) - if ((userConfig.GithubUser == "") != (userConfig.GithubToken == "")) || ((userConfig.GitlabUser == "") != (userConfig.GitlabToken == "")) || ((userConfig.BitbucketUser == "") != (userConfig.BitbucketToken == "")) || ((userConfig.AzureDevopsUser == "") != (userConfig.AzureDevopsToken == "")) { + // 3. gitea user and token set + // 4. gitlab user and token set + // 5. bitbucket user and token set + // 6. azuredevops user and token set + // 7. any combination of the above + vcsErr := fmt.Errorf("--%s/--%s or --%s/--%s or --%s/--%s or --%s/--%s or --%s/--%s or --%s/--%s or --%s/--%s must be set", GHUserFlag, GHTokenFlag, GHAppIDFlag, GHAppKeyFileFlag, GHAppIDFlag, GHAppKeyFlag, GiteaUserFlag, GiteaTokenFlag, GitlabUserFlag, GitlabTokenFlag, BitbucketUserFlag, BitbucketTokenFlag, ADUserFlag, ADTokenFlag) + if ((userConfig.GithubUser == "") != (userConfig.GithubToken == "")) || + ((userConfig.GiteaUser == "") != (userConfig.GiteaToken == "")) || + ((userConfig.GitlabUser == "") != (userConfig.GitlabToken == "")) || + ((userConfig.BitbucketUser == "") != (userConfig.BitbucketToken == "")) || + ((userConfig.AzureDevopsUser == "") != (userConfig.AzureDevopsToken == "")) { return vcsErr } if (userConfig.GithubAppID != 0) && ((userConfig.GithubAppKey == "") && (userConfig.GithubAppKeyFile == "")) { @@ -901,7 +939,7 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error { } // At this point, we know that there can't be a single user/token without // its partner, but we haven't checked if any user/token is set at all. - if userConfig.GithubAppID == 0 && userConfig.GithubUser == "" && userConfig.GitlabUser == "" && userConfig.BitbucketUser == "" && userConfig.AzureDevopsUser == "" { + if userConfig.GithubAppID == 0 && userConfig.GithubUser == "" && userConfig.GiteaUser == "" && userConfig.GitlabUser == "" && userConfig.BitbucketUser == "" && userConfig.AzureDevopsUser == "" { return vcsErr } @@ -924,6 +962,14 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error { return fmt.Errorf("--%s must have http:// or https://, got %q", BitbucketBaseURLFlag, userConfig.BitbucketBaseURL) } + parsed, err = url.Parse(userConfig.GiteaBaseURL) + if err != nil { + return fmt.Errorf("error parsing --%s flag value %q: %s", GiteaWebhookSecretFlag, userConfig.GiteaBaseURL, err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("--%s must have http:// or https://, got %q", GiteaBaseURLFlag, userConfig.GiteaBaseURL) + } + if userConfig.RepoConfig != "" && userConfig.RepoConfigJSON != "" { return fmt.Errorf("cannot use --%s and --%s at the same time", RepoConfigFlag, RepoConfigJSONFlag) } @@ -936,6 +982,8 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error { GitlabWebhookSecretFlag: userConfig.GitlabWebhookSecret, BitbucketTokenFlag: userConfig.BitbucketToken, BitbucketWebhookSecretFlag: userConfig.BitbucketWebhookSecret, + GiteaTokenFlag: userConfig.GiteaToken, + GiteaWebhookSecretFlag: userConfig.GiteaWebhookSecret, } { if strings.Contains(token, "\n") { s.Logger.Warn("--%s contains a newline which is usually unintentional", name) @@ -1029,6 +1077,7 @@ func (s *ServerCmd) setVarFileAllowlist(userConfig *server.UserConfig) { // trimAtSymbolFromUsers trims @ from the front of the github and gitlab usernames func (s *ServerCmd) trimAtSymbolFromUsers(userConfig *server.UserConfig) { userConfig.GithubUser = strings.TrimPrefix(userConfig.GithubUser, "@") + userConfig.GiteaUser = strings.TrimPrefix(userConfig.GiteaUser, "@") userConfig.GitlabUser = strings.TrimPrefix(userConfig.GitlabUser, "@") userConfig.BitbucketUser = strings.TrimPrefix(userConfig.BitbucketUser, "@") userConfig.AzureDevopsUser = strings.TrimPrefix(userConfig.AzureDevopsUser, "@") @@ -1038,6 +1087,9 @@ func (s *ServerCmd) securityWarnings(userConfig *server.UserConfig) { if userConfig.GithubUser != "" && userConfig.GithubWebhookSecret == "" && !s.SilenceOutput { s.Logger.Warn("no GitHub webhook secret set. This could allow attackers to spoof requests from GitHub") } + if userConfig.GiteaUser != "" && userConfig.GiteaWebhookSecret == "" && !s.SilenceOutput { + s.Logger.Warn("no Gitea webhook secret set. This could allow attackers to spoof requests from Gitea") + } if userConfig.GitlabUser != "" && userConfig.GitlabWebhookSecret == "" && !s.SilenceOutput { s.Logger.Warn("no GitLab webhook secret set. This could allow attackers to spoof requests from GitLab") } diff --git a/cmd/server_test.go b/cmd/server_test.go index 81b834151d..1d5ff3c77a 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -93,6 +93,11 @@ var testFlags = map[string]interface{}{ GHAppSlugFlag: "atlantis", GHOrganizationFlag: "", GHWebhookSecretFlag: "secret", + GiteaBaseURLFlag: "http://localhost", + GiteaTokenFlag: "gitea-token", + GiteaUserFlag: "gitea-user", + GiteaWebhookSecretFlag: "gitea-secret", + GiteaPageSizeFlag: 30, GitlabHostnameFlag: "gitlab-hostname", GitlabTokenFlag: "gitlab-token", GitlabUserFlag: "gitlab-user", @@ -156,6 +161,7 @@ func TestExecute_Defaults(t *testing.T) { c := setup(map[string]interface{}{ GHUserFlag: "user", GHTokenFlag: "token", + GiteaBaseURLFlag: "http://localhost", RepoAllowlistFlag: "*", }, t) err := c.Execute() @@ -174,6 +180,7 @@ func TestExecute_Defaults(t *testing.T) { strExceptions := map[string]string{ GHUserFlag: "user", GHTokenFlag: "token", + GiteaBaseURLFlag: "http://localhost", DataDirFlag: dataDir, MarkdownTemplateOverridesDirFlag: markdownTemplateOverridesDir, AtlantisURLFlag: "http://" + hostname + ":4141", @@ -422,7 +429,7 @@ func TestExecute_ValidateSSLConfig(t *testing.T) { } func TestExecute_ValidateVCSConfig(t *testing.T) { - expErr := "--gh-user/--gh-token or --gh-app-id/--gh-app-key-file or --gh-app-id/--gh-app-key or --gitlab-user/--gitlab-token or --bitbucket-user/--bitbucket-token or --azuredevops-user/--azuredevops-token must be set" + expErr := "--gh-user/--gh-token or --gh-app-id/--gh-app-key-file or --gh-app-id/--gh-app-key or --gitea-user/--gitea-token or --gitlab-user/--gitlab-token or --bitbucket-user/--bitbucket-token or --azuredevops-user/--azuredevops-token must be set" cases := []struct { description string flags map[string]interface{} @@ -440,6 +447,13 @@ func TestExecute_ValidateVCSConfig(t *testing.T) { }, true, }, + { + "just gitea token set", + map[string]interface{}{ + GiteaTokenFlag: "token", + }, + true, + }, { "just gitlab token set", map[string]interface{}{ @@ -468,6 +482,13 @@ func TestExecute_ValidateVCSConfig(t *testing.T) { }, true, }, + { + "just gitea user set", + map[string]interface{}{ + GiteaUserFlag: "user", + }, + true, + }, { "just github app set", map[string]interface{}{ @@ -534,6 +555,22 @@ func TestExecute_ValidateVCSConfig(t *testing.T) { }, true, }, + { + "github user and gitea token set", + map[string]interface{}{ + GHUserFlag: "user", + GiteaTokenFlag: "token", + }, + true, + }, + { + "gitea user and github token set", + map[string]interface{}{ + GiteaUserFlag: "user", + GHTokenFlag: "token", + }, + true, + }, { "github user and github token set and should be successful", map[string]interface{}{ @@ -542,6 +579,14 @@ func TestExecute_ValidateVCSConfig(t *testing.T) { }, false, }, + { + "gitea user and gitea token set and should be successful", + map[string]interface{}{ + GiteaUserFlag: "user", + GiteaTokenFlag: "token", + }, + false, + }, { "github app and key file set and should be successful", map[string]interface{}{ @@ -587,6 +632,8 @@ func TestExecute_ValidateVCSConfig(t *testing.T) { map[string]interface{}{ GHUserFlag: "user", GHTokenFlag: "token", + GiteaUserFlag: "user", + GiteaTokenFlag: "token", GitlabUserFlag: "user", GitlabTokenFlag: "token", BitbucketUserFlag: "user", @@ -699,6 +746,19 @@ func TestExecute_GithubApp(t *testing.T) { Equals(t, int64(1), passedConfig.GithubAppID) } +func TestExecute_GiteaUser(t *testing.T) { + t.Log("Should remove the @ from the gitea username if it's passed.") + c := setup(map[string]interface{}{ + GiteaUserFlag: "@user", + GiteaTokenFlag: "token", + RepoAllowlistFlag: "*", + }, t) + err := c.Execute() + Ok(t, err) + + Equals(t, "user", passedConfig.GiteaUser) +} + func TestExecute_GitlabUser(t *testing.T) { t.Log("Should remove the @ from the gitlab username if it's passed.") c := setup(map[string]interface{}{ @@ -934,3 +994,45 @@ func configVal(t *testing.T, u server.UserConfig, tag string) interface{} { t.Fatalf("no field with tag %q found", tag) return nil } + +// Gitea base URL must have a scheme. +func TestExecute_GiteaBaseURLScheme(t *testing.T) { + c := setup(map[string]interface{}{ + GiteaUserFlag: "user", + GiteaTokenFlag: "token", + RepoAllowlistFlag: "*", + GiteaBaseURLFlag: "mydomain.com", + }, t) + ErrEquals(t, "--gitea-base-url must have http:// or https://, got \"mydomain.com\"", c.Execute()) + + c = setup(map[string]interface{}{ + GiteaUserFlag: "user", + GiteaTokenFlag: "token", + RepoAllowlistFlag: "*", + GiteaBaseURLFlag: "://mydomain.com", + }, t) + ErrEquals(t, "error parsing --gitea-webhook-secret flag value \"://mydomain.com\": parse \"://mydomain.com\": missing protocol scheme", c.Execute()) +} + +func TestExecute_GiteaWithWebhookSecret(t *testing.T) { + c := setup(map[string]interface{}{ + GiteaUserFlag: "user", + GiteaTokenFlag: "token", + RepoAllowlistFlag: "*", + GiteaWebhookSecretFlag: "my secret", + }, t) + err := c.Execute() + Ok(t, err) +} + +// Port should be retained on base url. +func TestExecute_GiteaBaseURLPort(t *testing.T) { + c := setup(map[string]interface{}{ + GiteaUserFlag: "user", + GiteaTokenFlag: "token", + RepoAllowlistFlag: "*", + GiteaBaseURLFlag: "http://mydomain.com:7990", + }, t) + Ok(t, c.Execute()) + Equals(t, "http://mydomain.com:7990", passedConfig.GiteaBaseURL) +} diff --git a/go.mod b/go.mod index 9f032c2029..1816de9dc9 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/runatlantis/atlantis go 1.22.1 require ( + code.gitea.io/sdk/gitea v0.17.1 github.com/Masterminds/sprig/v3 v3.2.3 github.com/alicebob/miniredis/v2 v2.32.1 github.com/bradleyfalzon/ghinstallation/v2 v2.9.0 @@ -77,10 +78,12 @@ require ( github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/davidmz/go-pageant v1.0.2 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fatih/color v1.15.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-fed/httpsig v1.1.0 // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-cmp v0.6.0 // indirect @@ -97,7 +100,7 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect diff --git a/go.sum b/go.sum index 5fb3c4f717..015159aa40 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +code.gitea.io/sdk/gitea v0.17.1 h1:3jCPOG2ojbl8AcfaUCRYLT5MUcBMFwS0OSK2mA5Zok8= +code.gitea.io/sdk/gitea v0.17.1/go.mod h1:aCnBqhHpoEWA180gMbaCtdX9Pl6BWBAuuP2miadoTNM= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= @@ -92,6 +94,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= +github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -106,6 +110,8 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= +github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -287,8 +293,8 @@ github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3v github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= @@ -412,6 +418,7 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -464,8 +471,10 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200403201458-baeed622b8d8/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -537,6 +546,8 @@ golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -604,12 +615,22 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -620,6 +641,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -668,6 +691,7 @@ golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/runatlantis.io/docs/access-credentials.md b/runatlantis.io/docs/access-credentials.md index 9cd514fb70..da84e34064 100644 --- a/runatlantis.io/docs/access-credentials.md +++ b/runatlantis.io/docs/access-credentials.md @@ -1,5 +1,5 @@ # Git Host Access Credentials -This page describes how to create credentials for your Git host (GitHub, GitLab, Bitbucket, or Azure DevOps) +This page describes how to create credentials for your Git host (GitHub, GitLab, Gitea, Bitbucket, or Azure DevOps) that Atlantis will use to make API calls. [[toc]] @@ -19,6 +19,7 @@ generate an access token. Read on for the instructions for your specific Git hos * [GitHub](#github-user) * [GitHub app](#github-app) * [GitLab](#gitlab) +* [Gitea](#gitea) * [Bitbucket Cloud (bitbucket.org)](#bitbucket-cloud-bitbucket-org) * [Bitbucket Server (aka Stash)](#bitbucket-server-aka-stash) * [Azure DevOps](#azure-devops) @@ -109,6 +110,15 @@ Since v0.22.3, a new permission for `Members` has been added, which is required - Create a token with **api** scope - Record the access token +### Gitea +- Go to "Profile and Settings" > "Settings" in Gitea (top-right) +- Go to "Applications" under "User Settings" in Gitea +- Create a token under the "Manage Access Tokens" with the following permissions: + - issue: Read and Write + - repository: Read and Write + - user: Read +- Record the access token + ### Bitbucket Cloud (bitbucket.org) - Create an App Password by following [https://support.atlassian.com/bitbucket-cloud/docs/create-an-app-password/](https://support.atlassian.com/bitbucket-cloud/docs/create-an-app-password/) - Label the password "atlantis" diff --git a/runatlantis.io/docs/configuring-webhooks.md b/runatlantis.io/docs/configuring-webhooks.md index be285ef6bc..82a6e1d3c3 100644 --- a/runatlantis.io/docs/configuring-webhooks.md +++ b/runatlantis.io/docs/configuring-webhooks.md @@ -54,6 +54,26 @@ If you're using GitLab, navigate to your project's home page in GitLab - click **Add webhook** - See [Next Steps](#next-steps) +## Gitea +If you're using Gitea, navigate to your project's home page in Gitea +- Click **Settings > Webhooks** in the top- and then sidebar +- Click **Add webhook > Gitea** (Gitea webhooks are service specific, but this works) +- set **Target 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 the Webhook Secret you generated previously + - **NOTE** If you're adding a webhook to multiple repositories, each repository will need to use the **same** secret. +- Select **Custom Events...** +- Check the boxes + - **Repository events > Push** + - **Issue events > Issue Comment** + - **Pull Request events > Pull Request** + - **Pull Request events > Pull Request Comment** + - **Pull Request events > Pull Request Reviewed** + - **Pull Request events > Pull Request Synchronized** +- Leave **Active** checked +- Click **Add Webhook** +- See [Next Steps](#next-steps) + ## Bitbucket Cloud (bitbucket.org) - Go to your repo's home page - Click **Settings** in the sidebar diff --git a/runatlantis.io/docs/deployment.md b/runatlantis.io/docs/deployment.md index 05e91b5e70..a5f5499026 100644 --- a/runatlantis.io/docs/deployment.md +++ b/runatlantis.io/docs/deployment.md @@ -17,10 +17,10 @@ Atlantis [Docker image](https://ghcr.io/runatlantis/atlantis). ### Routing Atlantis and your Git host need to be able to route and communicate with one another. Your Git host needs to be able to send webhooks to Atlantis and Atlantis needs to be able to make API calls to your Git host. If you're using -a public Git host like github.com, gitlab.com, bitbucket.org, or dev.azure.com then you'll need to +a public Git host like github.com, gitlab.com, gitea.com, bitbucket.org, or dev.azure.com then you'll need to expose Atlantis to the internet. -If you're using a private Git host like GitHub Enterprise, GitLab Enterprise or +If you're using a private Git host like GitHub Enterprise, GitLab Enterprise, self-hosted Gitea or Bitbucket Server, then Atlantis needs to be routable from the private host and Atlantis will need to be able to route to the private host. ### Data @@ -111,16 +111,19 @@ up upgrading Atlantis by accident! for your Terraform repos. See [Repo Allowlist](server-configuration.html#repo-allowlist) for more details. 3. If you're using GitHub: 1. Replace `` with the username of your Atlantis GitHub user without the `@`. - 2. Delete all the `ATLANTIS_GITLAB_*`, `ATLANTIS_BITBUCKET_*`, and `ATLANTIS_AZUREDEVOPS_*` environment variables. + 2. Delete all the `ATLANTIS_GITLAB_*`, `ATLANTIS_GITEA_*`, `ATLANTIS_BITBUCKET_*`, and `ATLANTIS_AZUREDEVOPS_*` environment variables. 4. If you're using GitLab: 1. Replace `` with the username of your Atlantis GitLab user without the `@`. - 2. Delete all the `ATLANTIS_GH_*`, `ATLANTIS_BITBUCKET_*`, and `ATLANTIS_AZUREDEVOPS_*` environment variables. -5. If you're using Bitbucket: + 2. Delete all the `ATLANTIS_GH_*`, `ATLANTIS_GITEA_*`, `ATLANTIS_BITBUCKET_*`, and `ATLANTIS_AZUREDEVOPS_*` environment variables. +5. If you're using Gitea: + 1. Replace `` with the username of your Atlantis Gitea user without the `@`. + 2. Delete all the `ATLANTIS_GH_*`, `ATLANTIS_GITLAB_*`, `ATLANTIS_BITBUCKET_*`, and `ATLANTIS_AZUREDEVOPS_*` environment variables. +6. If you're using Bitbucket: 1. Replace `` with the username of your Atlantis Bitbucket user without the `@`. - 2. Delete all the `ATLANTIS_GH_*`, `ATLANTIS_GITLAB_*`, and `ATLANTIS_AZUREDEVOPS_*` environment variables. -6. If you're using Azure DevOps: + 2. Delete all the `ATLANTIS_GH_*`, `ATLANTIS_GITLAB_*`, `ATLANTIS_GITEA_*`, and `ATLANTIS_AZUREDEVOPS_*` environment variables. +7. If you're using Azure DevOps: 1. Replace `` with the username of your Atlantis Azure DevOps user without the `@`. - 2. Delete all the `ATLANTIS_GH_*`, `ATLANTIS_GITLAB_*`, and `ATLANTIS_BITBUCKET_*` environment variables. + 2. Delete all the `ATLANTIS_GH_*`, `ATLANTIS_GITLAB_*`, `ATLANTIS_GITEA_*`, and `ATLANTIS_BITBUCKET_*` environment variables. #### StatefulSet Manifest
@@ -185,6 +188,21 @@ spec: key: webhook-secret ### End GitLab Config ### + ### Gitea Config ### + - name: ATLANTIS_GITEA_USER + value: # 4i. If you're using Gitea replace with the username of your Atlantis Gitea user without the `@`. + - name: ATLANTIS_GITEA_TOKEN + valueFrom: + secretKeyRef: + name: atlantis-vcs + key: token + - name: ATLANTIS_GITEA_WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: atlantis-vcs + key: webhook-secret + ### End Gitea Config ### + ### Bitbucket Config ### - name: ATLANTIS_BITBUCKET_USER value: # 5i. If you're using Bitbucket replace with the username of your Atlantis Bitbucket user without the `@`. @@ -333,6 +351,21 @@ spec: key: webhook-secret ### End GitLab Config ### + ### Gitea Config ### + - name: ATLANTIS_GITEA_USER + value: # 4i. If you're using Gitea replace with the username of your Atlantis Gitea user without the `@`. + - name: ATLANTIS_GITEA_TOKEN + valueFrom: + secretKeyRef: + name: atlantis-vcs + key: token + - name: ATLANTIS_GITEA_WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: atlantis-vcs + key: webhook-secret + ### End Gitea Config ### + ### Bitbucket Config ### - name: ATLANTIS_BITBUCKET_USER value: # 5i. If you're using Bitbucket replace with the username of your Atlantis Bitbucket user without the `@`. @@ -481,6 +514,26 @@ containers: key: webhook-secret ``` +#### Gitea + +```yaml +containers: +- name: atlantis + env: + - name: ATLANTIS_GITEA_USER + value: # 4i. If you're using Gitea replace with the username of your Atlantis Gitea user without the `@`. + - name: ATLANTIS_GITEA_TOKEN + valueFrom: + secretKeyRef: + name: atlantis-vcs + key: token + - name: ATLANTIS_GITEA_WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: atlantis-vcs + key: webhook-secret +``` + #### GitHub ```yaml @@ -632,6 +685,17 @@ atlantis server \ --repo-allowlist="$REPO_ALLOWLIST" ``` +##### Gitea +```bash +atlantis server \ +--atlantis-url="$URL" \ +--gitea-user="$USERNAME" \ +--gitea-token="$TOKEN" \ +--gitea-webhook-secret="$SECRET" \ +--gitea-page-size=30 \ +--repo-allowlist="$REPO_ALLOWLIST" +``` + ##### Bitbucket Cloud (bitbucket.org) ```bash atlantis server \ @@ -671,17 +735,18 @@ atlantis server \ Where - `$URL` is the URL that Atlantis can be reached at -- `$USERNAME` is the GitHub/GitLab/Bitbucket/AzureDevops username you generated the token for +- `$USERNAME` is the GitHub/GitLab/Gitea/Bitbucket/AzureDevops username you generated the token for - `$TOKEN` is the access token you created. If you don't want this to be passed in as an argument for security reasons you can specify it in a config file (see [Configuration](/docs/server-configuration.html#environment-variables)) - or as an environment variable: `ATLANTIS_GH_TOKEN` or `ATLANTIS_GITLAB_TOKEN` + or as an environment variable: `ATLANTIS_GH_TOKEN` or `ATLANTIS_GITLAB_TOKEN` or `ATLANTIS_GITEA_TOKEN` or `ATLANTIS_BITBUCKET_TOKEN` or `ATLANTIS_AZUREDEVOPS_TOKEN` - `$SECRET` is the random key you used for the webhook secret. If you don't want this to be passed in as an argument for security reasons you can specify it in a config file (see [Configuration](/docs/server-configuration.html#environment-variables)) - or as an environment variable: `ATLANTIS_GH_WEBHOOK_SECRET` or `ATLANTIS_GITLAB_WEBHOOK_SECRET` + or as an environment variable: `ATLANTIS_GH_WEBHOOK_SECRET` or `ATLANTIS_GITLAB_WEBHOOK_SECRET` or + `ATLANTIS_GITEA_WEBHOOK_SECRET` - `$REPO_ALLOWLIST` is which repos Atlantis can run on, ex. `github.com/runatlantis/*` or `github.enterprise.corp.com/*`. See [Repo Allowlist](server-configuration.html#repo-allowlist) for more details. diff --git a/runatlantis.io/docs/installation-guide.md b/runatlantis.io/docs/installation-guide.md index fafa5d5b90..ec166f45f4 100644 --- a/runatlantis.io/docs/installation-guide.md +++ b/runatlantis.io/docs/installation-guide.md @@ -3,7 +3,7 @@ This guide is for installing a **production-ready** instance of Atlantis onto yo infrastructure: 1. First, ensure your Terraform setup meets the Atlantis **requirements** * See [Requirements](requirements.html) -1. Create **access credentials** for your Git host (GitHub, GitLab, Bitbucket, Azure DevOps) +1. Create **access credentials** for your Git host (GitHub, GitLab, Gitea, Bitbucket, Azure DevOps) * See [Generating Git Host Access Credentials](access-credentials.html) 1. Create a **webhook secret** so Atlantis can validate webhooks * See [Creating a Webhook Secret](webhook-secrets.html) diff --git a/runatlantis.io/docs/requirements.md b/runatlantis.io/docs/requirements.md index e300e63fe7..4630c05fb7 100644 --- a/runatlantis.io/docs/requirements.md +++ b/runatlantis.io/docs/requirements.md @@ -9,6 +9,7 @@ Atlantis integrates with the following Git hosts: * GitHub (public, private or enterprise) * GitLab (public, private or enterprise) +* Gitea (public, private and compatible forks like Forgejo) * Bitbucket Cloud aka bitbucket.org (public or private) * Bitbucket Server aka Stash * Azure DevOps diff --git a/runatlantis.io/docs/server-configuration.md b/runatlantis.io/docs/server-configuration.md index e2722f5478..c91be9e9f7 100644 --- a/runatlantis.io/docs/server-configuration.md +++ b/runatlantis.io/docs/server-configuration.md @@ -472,6 +472,56 @@ and set `--autoplan-modules` to `false`. Fail and do not run the requested Atlantis command if any of the pre workflow hooks error. +### `--gitea-base-url` + ```bash + atlantis server --gitea-base-url="http://your-gitea.corp:7990/basepath" + # or + ATLANTIS_GITEA_BASE_URL="http://your-gitea.corp:7990/basepath" + ``` + Base URL of Gitea installation. Must include `http://` or `https://`. Defaults to `https://gitea.com` if left empty/absent. + +### `--gitea-token` + ```bash + atlantis server --gitea-token="token" + # or (recommended) + ATLANTIS_GITEA_TOKEN="token" + ``` + Gitea app password of API user. + +### `--gitea-user` + ```bash + atlantis server --gitea-user="myuser" + # or + ATLANTIS_GITEA_USER="myuser" + ``` + Gitea username of API user. + +### `--gitea-webhook-secret` + ```bash + atlantis server --gitea-webhook-secret="secret" + # or (recommended) + ATLANTIS_GITEA_WEBHOOK_SECRET="secret" + ``` + Secret used to validate Gitea webhooks. + + ::: warning SECURITY WARNING + If not specified, Atlantis won't be able to validate that the incoming webhook call came from Gitea. + This means that an attacker could spoof calls to Atlantis and cause it to perform malicious actions. + ::: + +### `--gitea-page-size` + ```bash + atlantis server --gitea-page-size=30 + # or (recommended) + ATLANTIS_GITEA_PAGE_SIZE=30 + ``` + Number of items on a single page in Gitea paged responses. + + ::: warning Configuration dependent + The default value conforms to the Gitea server's standard config setting: DEFAULT_PAGING_NUM + The highest valid value depends on the Gitea server's config setting: MAX_RESPONSE_ITEMS + ::: + ### `--gh-allow-mergeable-bypass-apply` ```bash atlantis server --gh-allow-mergeable-bypass-apply diff --git a/runatlantis.io/guide/testing-locally.md b/runatlantis.io/guide/testing-locally.md index 054b0d9c2a..3c33d9b12e 100644 --- a/runatlantis.io/guide/testing-locally.md +++ b/runatlantis.io/guide/testing-locally.md @@ -140,6 +140,36 @@ Take the URL that ngrok output and create a webhook in your GitHub, GitLab or Bi
+### Gitea Webhook +
+ Expand +
    +
  • Click Settings > Webhooks in the top- and then sidebar
  • +
  • Click Add webhook > Gitea (Gitea webhooks are service specific, but this works)
  • +
  • set Target 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 the Webhook Secret you generated previously +
      +
    • NOTE If you're adding a webhook to multiple repositories, each repository will need to use the same secret.
    • +
    +
  • +
  • Select Custom Events...
  • +
  • Check the boxes +
      +
    • Repository events > Push
    • +
    • Issue events > Issue Comment
    • +
    • Pull Request events > Pull Request
    • +
    • Pull Request events > Pull Request Comment
    • +
    • Pull Request events > Pull Request Reviewed
    • +
    • Pull Request events > Pull Request Synchronized
    • +
    +
  • +
  • Leave Active checked
  • +
  • Click Add Webhook
  • +
  • See Next Steps
  • +
+
+ ## 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, @@ -183,6 +213,13 @@ TOKEN="{YOUR_TOKEN}" TOKEN="{YOUR_TOKEN}" ``` +### Gite Access Token +- Go to "Profile and Settings" > "Settings" in Gitea (top-right) +- Go to "Applications" under "User Settings" in Gitea +- Create a token under the "Manage Access Tokens" with the following permissions: + - issue: Read and Write + - repository: Read and Write +- Record the access token ## Start Atlantis You're almost ready to start Atlantis, just set two more variables: @@ -278,6 +315,21 @@ atlantis server \ --ssl-key-file=file.key ``` +### Gitea + +```bash +atlantis server \ +--atlantis-url="$URL" \ +--gitea-user="$ATLANTIS_GITEA_USER" \ +--gitea-token="$ATLANTIS_GITEA_TOKEN" \ +--gitea-webhook-secret="$ATLANTIS_GITEA_WEBHOOK_SECRET" \ +--gitea-base-url="$ATLANTIS_GITEA_BASE_URL" \ +--gitea-page-size="$ATLANTIS_GITEA_PAGE_SIZE" \ +--repo-allowlist="$REPO_ALLOWLIST" +--ssl-cert-file=file.crt +--ssl-key-file=file.key +``` + ## Create a pull request Create a pull request so you can test Atlantis. ::: tip diff --git a/server/controllers/events/events_controller.go b/server/controllers/events/events_controller.go index 2246a8f48b..91a7bf2592 100644 --- a/server/controllers/events/events_controller.go +++ b/server/controllers/events/events_controller.go @@ -14,6 +14,7 @@ package events import ( + "encoding/json" "fmt" "io" "net/http" @@ -28,6 +29,7 @@ import ( "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/events/vcs/gitea" "github.com/runatlantis/atlantis/server/logging" tally "github.com/uber-go/tally/v4" gitlab "github.com/xanzy/go-gitlab" @@ -37,6 +39,11 @@ const githubHeader = "X-Github-Event" const gitlabHeader = "X-Gitlab-Event" const azuredevopsHeader = "Request-Id" +const giteaHeader = "X-Gitea-Event" +const giteaEventTypeHeader = "X-Gitea-Event-Type" +const giteaSignatureHeader = "X-Gitea-Signature" +const giteaRequestIDHeader = "X-Gitea-Delivery" + // bitbucketEventTypeHeader is the same in both cloud and server. const bitbucketEventTypeHeader = "X-Event-Key" const bitbucketCloudRequestIDHeader = "X-Request-UUID" @@ -91,11 +98,20 @@ type VCSEventsController struct { // Azure DevOps Team Project. If empty, no request validation is done. AzureDevopsWebhookBasicPassword []byte AzureDevopsRequestValidator AzureDevopsRequestValidator + GiteaWebhookSecret []byte } // Post handles POST webhook requests. func (e *VCSEventsController) Post(w http.ResponseWriter, r *http.Request) { - if r.Header.Get(githubHeader) != "" { + if r.Header.Get(giteaHeader) != "" { + if !e.supportsHost(models.Gitea) { + e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request since not configured to support Gitea") + return + } + e.Logger.Debug("handling Gitea post") + e.handleGiteaPost(w, r) + return + } else if r.Header.Get(githubHeader) != "" { if !e.supportsHost(models.Github) { e.respond(w, logging.Debug, http.StatusBadRequest, "Ignoring request since not configured to support GitHub") return @@ -288,6 +304,91 @@ func (e *VCSEventsController) handleAzureDevopsPost(w http.ResponseWriter, r *ht } } +func (e *VCSEventsController) handleGiteaPost(w http.ResponseWriter, r *http.Request) { + signature := r.Header.Get(giteaSignatureHeader) + eventType := r.Header.Get(giteaEventTypeHeader) + reqID := r.Header.Get(giteaRequestIDHeader) + + defer r.Body.Close() // Ensure the request body is closed + + body, err := io.ReadAll(r.Body) + if err != nil { + e.respond(w, logging.Error, http.StatusBadRequest, "Unable to read body: %s %s=%s", err, "X-Gitea-Delivery", reqID) + return + } + + if len(e.GiteaWebhookSecret) > 0 { + if err := gitea.ValidateSignature(body, signature, e.GiteaWebhookSecret); err != nil { + e.respond(w, logging.Warn, http.StatusBadRequest, errors.Wrap(err, "request did not pass validation").Error()) + return + } + } + + // Log the event type for debugging purposes + e.Logger.Debug("Received Gitea event %s with ID %s", eventType, reqID) + + // Depending on the event type, handle the event appropriately + switch eventType { + case "pull_request_comment": + e.HandleGiteaPullRequestCommentEvent(w, body, reqID) + case "pull_request": + e.Logger.Debug("Handling as pull_request") + e.handleGiteaPullRequestEvent(w, body, reqID) + // Add other case handlers as necessary + default: + e.respond(w, logging.Debug, http.StatusOK, "Ignoring unsupported Gitea event type: %s %s=%s", eventType, "X-Gitea-Delivery", reqID) + } +} + +func (e *VCSEventsController) handleGiteaPullRequestEvent(w http.ResponseWriter, body []byte, reqID string) { + e.Logger.Debug("Entering handleGiteaPullRequestEvent") + // Attempt to unmarshal the incoming body into the Gitea PullRequest struct + var payload gitea.GiteaWebhookPayload + if err := json.Unmarshal(body, &payload); err != nil { + e.Logger.Err("Failed to unmarshal Gitea webhook payload: %v", err) + e.respond(w, logging.Error, http.StatusBadRequest, "Failed to parse request body") + return + } + + e.Logger.Debug("Successfully unmarshaled Gitea event") + + // Use the parser function to convert into Atlantis models + pull, pullEventType, baseRepo, headRepo, user, err := e.Parser.ParseGiteaPullRequestEvent(payload.PullRequest) + if err != nil { + e.Logger.Err("Failed to parse Gitea pull request event: %v", err) + e.respond(w, logging.Error, http.StatusInternalServerError, "Failed to process event") + return + } + + e.Logger.Debug("Parsed Gitea event into Atlantis models successfully") + + logger := e.Logger.With("gitea-request-id", reqID) + logger.Debug("Identified Gitea event as type", "type", pullEventType) + + // Call a generic handler for pull request events + response := e.handlePullRequestEvent(logger, baseRepo, headRepo, pull, user, pullEventType) + + e.respond(w, logging.Debug, http.StatusOK, response.body) +} + +// HandleGiteaCommentEvent handles comment events from Gitea where Atlantis commands can come from. +func (e *VCSEventsController) HandleGiteaPullRequestCommentEvent(w http.ResponseWriter, body []byte, reqID string) { + var event gitea.GiteaIssueCommentPayload + if err := json.Unmarshal(body, &event); err != nil { + e.Logger.Err("Failed to unmarshal Gitea comment payload: %v", err) + e.respond(w, logging.Error, http.StatusBadRequest, "Failed to parse request body") + return + } + e.Logger.Debug("Successfully unmarshaled Gitea comment event") + + baseRepo, user, pullNum, _ := e.Parser.ParseGiteaIssueCommentEvent(event) + // Since we're lacking headRepo and maybePull details, we'll pass nil + // This follows the same approach as the GitHub client for handling comment events without full PR details + response := e.handleCommentEvent(e.Logger, baseRepo, nil, nil, user, pullNum, event.Comment.Body, event.Comment.ID, models.Gitea) + + e.respond(w, logging.Debug, http.StatusOK, response.body) +} + // HandleGithubCommentEvent handles comment events from GitHub where Atlantis // commands can come from. It's exported to make testing easier. func (e *VCSEventsController) HandleGithubCommentEvent(event *github.IssueCommentEvent, githubReqID string, logger logging.SimpleLogging) HTTPResponse { diff --git a/server/controllers/events/events_controller_test.go b/server/controllers/events/events_controller_test.go index 183772df8e..bc1a1c66a0 100644 --- a/server/controllers/events/events_controller_test.go +++ b/server/controllers/events/events_controller_test.go @@ -42,6 +42,7 @@ import ( ) const githubHeader = "X-Github-Event" +const giteaHeader = "X-Gitea-Event" const gitlabHeader = "X-Gitlab-Event" const azuredevopsHeader = "Request-Id" @@ -68,6 +69,17 @@ func TestPost_UnsupportedVCSGithub(t *testing.T) { ResponseContains(t, w, http.StatusBadRequest, "Ignoring request since not configured to support GitHub") } +func TestPost_UnsupportedVCSGitea(t *testing.T) { + t.Log("when the request is for an unsupported vcs a 400 is returned") + e, _, _, _, _, _, _, _, _ := setup(t) + e.SupportedVCSHosts = nil + req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) + req.Header.Set(giteaHeader, "value") + w := httptest.NewRecorder() + e.Post(w, req) + ResponseContains(t, w, http.StatusBadRequest, "Ignoring request since not configured to support Gitea") +} + func TestPost_UnsupportedVCSGitlab(t *testing.T) { t.Log("when the request is for an unsupported vcs a 400 is returned") e, _, _, _, _, _, _, _, _ := setup(t) @@ -90,6 +102,17 @@ func TestPost_InvalidGithubSecret(t *testing.T) { ResponseContains(t, w, http.StatusBadRequest, "err") } +func TestPost_InvalidGiteaSecret(t *testing.T) { + t.Log("when the gitea payload can't be validated a 400 is returned") + e, v, _, _, _, _, _, _, _ := setup(t) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) + req.Header.Set(giteaHeader, "value") + When(v.Validate(req, secret)).ThenReturn(nil, errors.New("err")) + e.Post(w, req) + ResponseContains(t, w, http.StatusBadRequest, "request did not pass validation") +} + func TestPost_InvalidGitlabSecret(t *testing.T) { t.Log("when the gitlab payload can't be validated a 400 is returned") e, _, gl, _, _, _, _, _, _ := setup(t) @@ -112,6 +135,18 @@ func TestPost_UnsupportedGithubEvent(t *testing.T) { ResponseContains(t, w, http.StatusOK, "Ignoring unsupported event") } +func TestPost_UnsupportedGiteaEvent(t *testing.T) { + t.Log("when the event type is an unsupported gitea event we ignore it") + e, v, _, _, _, _, _, _, _ := setup(t) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) + req.Header.Set(giteaHeader, "value") + e.GiteaWebhookSecret = nil + When(v.Validate(req, nil)).ThenReturn([]byte(`{"not an event": ""}`), nil) + e.Post(w, req) + ResponseContains(t, w, http.StatusOK, "Ignoring unsupported Gitea event") +} + func TestPost_UnsupportedGitlabEvent(t *testing.T) { t.Log("when the event type is an unsupported gitlab event we ignore it") e, _, gl, _, _, _, _, _, _ := setup(t) @@ -976,7 +1011,8 @@ func setup(t *testing.T) (events_controllers.VCSEventsController, *mocks.MockGit CommandRunner: cr, PullCleaner: c, GithubWebhookSecret: secret, - SupportedVCSHosts: []models.VCSHostType{models.Github, models.Gitlab, models.AzureDevops}, + SupportedVCSHosts: []models.VCSHostType{models.Github, models.Gitlab, models.AzureDevops, models.Gitea}, + GiteaWebhookSecret: secret, GitlabWebhookSecret: secret, GitlabRequestParserValidator: gl, RepoAllowlistChecker: repoAllowlistChecker, diff --git a/server/events/command_runner.go b/server/events/command_runner.go index b08690d1ec..14cdbce146 100644 --- a/server/events/command_runner.go +++ b/server/events/command_runner.go @@ -24,6 +24,7 @@ import ( "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" + "github.com/runatlantis/atlantis/server/events/vcs/gitea" "github.com/runatlantis/atlantis/server/logging" "github.com/runatlantis/atlantis/server/metrics" "github.com/runatlantis/atlantis/server/recovery" @@ -97,6 +98,7 @@ type DefaultCommandRunner struct { GithubPullGetter GithubPullGetter AzureDevopsPullGetter AzureDevopsPullGetter GitlabMergeRequestGetter GitlabMergeRequestGetter + GiteaPullGetter *gitea.GiteaClient // User config option: Disables autoplan when a pull request is opened or updated. DisableAutoplan bool DisableAutoplanLabel string @@ -386,6 +388,21 @@ func (c *DefaultCommandRunner) getGithubData(logger logging.SimpleLogging, baseR return pull, headRepo, nil } +func (c *DefaultCommandRunner) getGiteaData(logger logging.SimpleLogging, baseRepo models.Repo, pullNum int) (models.PullRequest, models.Repo, error) { + if c.GiteaPullGetter == nil { + return models.PullRequest{}, models.Repo{}, errors.New("Atlantis not configured to support Gitea") + } + giteaPull, err := c.GiteaPullGetter.GetPullRequest(logger, baseRepo, pullNum) + if err != nil { + return models.PullRequest{}, models.Repo{}, errors.Wrap(err, "making pull request API call to Gitea") + } + pull, _, headRepo, err := c.EventParser.ParseGiteaPull(giteaPull) + if err != nil { + return pull, headRepo, errors.Wrap(err, "extracting required fields from comment data") + } + return pull, headRepo, nil +} + func (c *DefaultCommandRunner) getGitlabData(logger logging.SimpleLogging, baseRepo models.Repo, pullNum int) (models.PullRequest, error) { if c.GitlabMergeRequestGetter == nil { return models.PullRequest{}, errors.New("Atlantis not configured to support GitLab") @@ -446,6 +463,8 @@ func (c *DefaultCommandRunner) ensureValidRepoMetadata( pull = *maybePull case models.AzureDevops: pull, headRepo, err = c.getAzureDevopsData(log, baseRepo, pullNum) + case models.Gitea: + pull, headRepo, err = c.getGiteaData(log, baseRepo, pullNum) default: err = errors.New("Unknown VCS type–this is a bug") } diff --git a/server/events/comment_parser.go b/server/events/comment_parser.go index c4ec87bb6d..3b3d2d3b0a 100644 --- a/server/events/comment_parser.go +++ b/server/events/comment_parser.go @@ -79,6 +79,7 @@ type CommentBuilder interface { type CommentParser struct { GithubUser string GitlabUser string + GiteaUser string BitbucketUser string AzureDevopsUser string ExecutableName string @@ -86,7 +87,7 @@ type CommentParser struct { } // NewCommentParser returns a CommentParser -func NewCommentParser(githubUser, gitlabUser, bitbucketUser, azureDevopsUser, executableName string, allowCommands []command.Name) *CommentParser { +func NewCommentParser(githubUser, gitlabUser, giteaUser, bitbucketUser, azureDevopsUser, executableName string, allowCommands []command.Name) *CommentParser { var commentAllowCommands []command.Name for _, acceptableCommand := range command.AllCommentCommands { for _, allowCommand := range allowCommands { @@ -100,6 +101,7 @@ func NewCommentParser(githubUser, gitlabUser, bitbucketUser, azureDevopsUser, ex return &CommentParser{ GithubUser: githubUser, GitlabUser: gitlabUser, + GiteaUser: giteaUser, BitbucketUser: bitbucketUser, AzureDevopsUser: azureDevopsUser, ExecutableName: executableName, @@ -174,6 +176,8 @@ func (e *CommentParser) Parse(rawComment string, vcsHost models.VCSHostType) Com vcsUser = e.GithubUser case models.Gitlab: vcsUser = e.GitlabUser + case models.Gitea: + vcsUser = e.GiteaUser case models.BitbucketCloud, models.BitbucketServer: vcsUser = e.BitbucketUser case models.AzureDevops: diff --git a/server/events/comment_parser_test.go b/server/events/comment_parser_test.go index 9c4b19d4f5..45c22e7e5f 100644 --- a/server/events/comment_parser_test.go +++ b/server/events/comment_parser_test.go @@ -28,6 +28,7 @@ import ( var commentParser = events.CommentParser{ GithubUser: "github-user", GitlabUser: "gitlab-user", + GiteaUser: "gitea-user", ExecutableName: "atlantis", AllowCommands: command.AllCommentCommands, } @@ -36,6 +37,7 @@ func TestNewCommentParser(t *testing.T) { type args struct { githubUser string gitlabUser string + giteaUser string bitbucketUser string azureDevopsUser string executableName string @@ -68,7 +70,7 @@ func TestNewCommentParser(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equalf(t, tt.want, events.NewCommentParser(tt.args.githubUser, tt.args.gitlabUser, tt.args.bitbucketUser, tt.args.azureDevopsUser, tt.args.executableName, tt.args.allowCommands), "NewCommentParser(%v, %v, %v, %v, %v, %v)", tt.args.githubUser, tt.args.gitlabUser, tt.args.bitbucketUser, tt.args.azureDevopsUser, tt.args.executableName, tt.args.allowCommands) + assert.Equalf(t, tt.want, events.NewCommentParser(tt.args.githubUser, tt.args.gitlabUser, tt.args.giteaUser, tt.args.bitbucketUser, tt.args.azureDevopsUser, tt.args.executableName, tt.args.allowCommands), "NewCommentParser(%v, %v, %v, %v, %v, %v)", tt.args.githubUser, tt.args.gitlabUser, tt.args.bitbucketUser, tt.args.azureDevopsUser, tt.args.executableName, tt.args.allowCommands) }) } } @@ -266,6 +268,7 @@ func TestParse_InvalidCommand(t *testing.T) { cp := events.NewCommentParser( "github-user", "gitlab-user", + "gitea-user", "bitbucket-user", "azure-devops-user", "atlantis", diff --git a/server/events/event_parser.go b/server/events/event_parser.go index 988d051f27..54abcebb26 100644 --- a/server/events/event_parser.go +++ b/server/events/event_parser.go @@ -20,6 +20,8 @@ import ( "path" "strings" + giteasdk "code.gitea.io/sdk/gitea" + "github.com/go-playground/validator/v10" "github.com/google/go-github/v59/github" lru "github.com/hashicorp/golang-lru/v2" @@ -29,6 +31,7 @@ import ( "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/server/events/vcs/gitea" "github.com/runatlantis/atlantis/server/logging" "github.com/xanzy/go-gitlab" ) @@ -337,6 +340,14 @@ type EventParsing interface { // ParseAzureDevopsRepo parses the response from the Azure DevOps API endpoint that // returns a repo into the Atlantis model. ParseAzureDevopsRepo(adRepo *azuredevops.GitRepository) (models.Repo, error) + + ParseGiteaPullRequestEvent(event giteasdk.PullRequest) ( + pull models.PullRequest, pullEventType models.PullRequestEventType, + baseRepo models.Repo, headRepo models.Repo, user models.User, err error) + + ParseGiteaIssueCommentEvent(event gitea.GiteaIssueCommentPayload) (baseRepo models.Repo, user models.User, pullNum int, err error) + + ParseGiteaPull(pull *giteasdk.PullRequest) (pullModel models.PullRequest, baseRepo models.Repo, headRepo models.Repo, err error) } // EventParser parses VCS events. @@ -345,6 +356,8 @@ type EventParser struct { GithubToken string GitlabUser string GitlabToken string + GiteaUser string + GiteaToken string AllowDraftPRs bool BitbucketUser string BitbucketToken string @@ -357,6 +370,8 @@ func (e *EventParser) ParseAPIPlanRequest(vcsHostType models.VCSHostType, repoFu switch vcsHostType { case models.Github: return models.NewRepo(vcsHostType, repoFullName, cloneURL, e.GithubUser, e.GithubToken) + case models.Gitea: + return models.NewRepo(vcsHostType, repoFullName, cloneURL, e.GiteaUser, e.GiteaToken) case models.Gitlab: return models.NewRepo(vcsHostType, repoFullName, cloneURL, e.GitlabUser, e.GitlabToken) } @@ -611,6 +626,13 @@ func (e *EventParser) ParseGithubRepo(ghRepo *github.Repository) (models.Repo, e return models.NewRepo(models.Github, ghRepo.GetFullName(), ghRepo.GetCloneURL(), e.GithubUser, e.GithubToken) } +// ParseGiteaRepo parses the response from the Gitea API endpoint that +// returns a repo into the Atlantis model. +// See EventParsing for return value docs. +func (e *EventParser) ParseGiteaRepo(repo giteasdk.Repository) (models.Repo, error) { + return models.NewRepo(models.Gitea, repo.FullName, repo.CloneURL, e.GiteaUser, e.GiteaToken) +} + // ParseGitlabMergeRequestUpdateEvent dives deeper into Gitlab merge request update events func (e *EventParser) ParseGitlabMergeRequestUpdateEvent(event gitlab.MergeEvent) models.PullRequestEventType { // New commit to opened MR @@ -703,6 +725,27 @@ func (e *EventParser) ParseGitlabMergeRequestCommentEvent(event gitlab.MergeComm return } +func (e *EventParser) ParseGiteaIssueCommentEvent(comment gitea.GiteaIssueCommentPayload) (baseRepo models.Repo, user models.User, pullNum int, err error) { + baseRepo, err = e.ParseGiteaRepo(comment.Repository) + if err != nil { + return + } + if comment.Comment.Body == "" || comment.Comment.Poster.UserName == "" { + err = errors.New("comment.user.login is null") + return + } + commenterUsername := comment.Comment.Poster.UserName + user = models.User{ + Username: commenterUsername, + } + pullNum = int(comment.Issue.Index) + if pullNum == 0 { + err = errors.New("issue.number is null") + return + } + return +} + // ParseGitlabMergeRequest parses the merge requests and returns a pull request // model. We require passing in baseRepo because we can't get this information // from the merge request. The only caller of this function already has that @@ -989,3 +1032,121 @@ func (e *EventParser) ParseAzureDevopsRepo(adRepo *azuredevops.GitRepository) (m fullName := fmt.Sprintf("%s/%s/%s", owner, project, repo) return models.NewRepo(models.AzureDevops, fullName, cloneURL, e.AzureDevopsUser, e.AzureDevopsToken) } + +func (e *EventParser) ParseGiteaPullRequestEvent(event giteasdk.PullRequest) (models.PullRequest, models.PullRequestEventType, models.Repo, models.Repo, models.User, error) { + var pullEventType models.PullRequestEventType + + // Determine the event type based on the state of the pull request and whether it's merged. + switch { + case event.State == giteasdk.StateOpen: + pullEventType = models.OpenedPullEvent + case event.HasMerged: + pullEventType = models.ClosedPullEvent + default: + pullEventType = models.OtherPullEvent + } + + // Parse the base repository. + baseRepo, err := models.NewRepo( + models.Gitea, + event.Base.Repository.FullName, + event.Base.Repository.CloneURL, + e.GiteaUser, + e.GiteaToken, + ) + if err != nil { + return models.PullRequest{}, models.OtherPullEvent, models.Repo{}, models.Repo{}, models.User{}, err + } + + // Parse the head repository. + headRepo, err := models.NewRepo( + models.Gitea, + event.Head.Repository.FullName, + event.Head.Repository.CloneURL, + e.GiteaUser, + e.GiteaToken, + ) + if err != nil { + return models.PullRequest{}, models.OtherPullEvent, models.Repo{}, models.Repo{}, models.User{}, err + } + + // Construct the pull request model. + pull := models.PullRequest{ + Num: int(event.Index), + URL: event.HTMLURL, + HeadCommit: event.Head.Sha, + HeadBranch: (*event.Head).Ref, + BaseBranch: event.Base.Ref, + Author: event.Poster.UserName, + BaseRepo: baseRepo, + } + + // Parse the user who made the pull request. + user := models.User{ + Username: event.Poster.UserName, + } + return pull, pullEventType, baseRepo, headRepo, user, nil +} + +// ParseGithubPull parses the response from the GitHub API endpoint (not +// from a webhook) that returns a pull request. +// See EventParsing for return value docs. +func (e *EventParser) ParseGiteaPull(pull *giteasdk.PullRequest) (pullModel models.PullRequest, baseRepo models.Repo, headRepo models.Repo, err error) { + commit := pull.Head.Sha + if commit == "" { + err = errors.New("head.sha is null") + return + } + url := pull.HTMLURL + if url == "" { + err = errors.New("html_url is null") + return + } + headBranch := pull.Head.Ref + if headBranch == "" { + err = errors.New("head.ref is null") + return + } + baseBranch := pull.Base.Ref + if baseBranch == "" { + err = errors.New("base.ref is null") + return + } + + authorUsername := pull.Poster.UserName + if authorUsername == "" { + err = errors.New("user.login is null") + return + } + num := pull.Index + if num == 0 { + err = errors.New("number is null") + return + } + + baseRepo, err = e.ParseGiteaRepo(*pull.Base.Repository) + if err != nil { + return + } + headRepo, err = e.ParseGiteaRepo(*pull.Head.Repository) + if err != nil { + return + } + + pullState := models.ClosedPullState + if pull.State == "open" { + pullState = models.OpenPullState + } + + pullModel = models.PullRequest{ + Author: authorUsername, + HeadBranch: headBranch, + HeadCommit: commit, + URL: url, + Num: int(num), + State: pullState, + BaseRepo: baseRepo, + BaseBranch: baseBranch, + } + return +} diff --git a/server/events/mocks/mock_event_parsing.go b/server/events/mocks/mock_event_parsing.go index 22690d2e3e..ad7a75c252 100644 --- a/server/events/mocks/mock_event_parsing.go +++ b/server/events/mocks/mock_event_parsing.go @@ -4,6 +4,8 @@ package mocks import ( + gitea "code.gitea.io/sdk/gitea" + gitea0 "github.com/runatlantis/atlantis/server/events/vcs/gitea" github "github.com/google/go-github/v59/github" azuredevops "github.com/mcdafydd/go-azuredevops/azuredevops" pegomock "github.com/petergtz/pegomock/v4" @@ -291,6 +293,95 @@ func (mock *MockEventParsing) ParseBitbucketServerPullEvent(body []byte) (models return ret0, ret1, ret2, ret3, ret4 } +func (mock *MockEventParsing) ParseGiteaIssueCommentEvent(event gitea0.GiteaIssueCommentPayload) (models.Repo, models.User, int, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockEventParsing().") + } + params := []pegomock.Param{event} + result := pegomock.GetGenericMockFrom(mock).Invoke("ParseGiteaIssueCommentEvent", params, []reflect.Type{reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.User)(nil)).Elem(), reflect.TypeOf((*int)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 models.Repo + var ret1 models.User + var ret2 int + var ret3 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(models.Repo) + } + if result[1] != nil { + ret1 = result[1].(models.User) + } + if result[2] != nil { + ret2 = result[2].(int) + } + if result[3] != nil { + ret3 = result[3].(error) + } + } + return ret0, ret1, ret2, ret3 +} + +func (mock *MockEventParsing) ParseGiteaPull(pull *gitea.PullRequest) (models.PullRequest, models.Repo, models.Repo, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockEventParsing().") + } + params := []pegomock.Param{pull} + result := pegomock.GetGenericMockFrom(mock).Invoke("ParseGiteaPull", params, []reflect.Type{reflect.TypeOf((*models.PullRequest)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*models.Repo)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()}) + var ret0 models.PullRequest + var ret1 models.Repo + var ret2 models.Repo + var ret3 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].(error) + } + } + return ret0, ret1, ret2, ret3 +} + +func (mock *MockEventParsing) ParseGiteaPullRequestEvent(event gitea.PullRequest) (models.PullRequest, models.PullRequestEventType, models.Repo, models.Repo, models.User, error) { + if mock == nil { + panic("mock must not be nil. Use myMock := NewMockEventParsing().") + } + params := []pegomock.Param{event} + result := pegomock.GetGenericMockFrom(mock).Invoke("ParseGiteaPullRequestEvent", params, []reflect.Type{reflect.TypeOf((*models.PullRequest)(nil)).Elem(), reflect.TypeOf((*models.PullRequestEventType)(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.PullRequestEventType + var ret2 models.Repo + var ret3 models.Repo + var ret4 models.User + var ret5 error + if len(result) != 0 { + if result[0] != nil { + ret0 = result[0].(models.PullRequest) + } + if result[1] != nil { + ret1 = result[1].(models.PullRequestEventType) + } + if result[2] != nil { + ret2 = result[2].(models.Repo) + } + if result[3] != nil { + ret3 = result[3].(models.Repo) + } + if result[4] != nil { + ret4 = result[4].(models.User) + } + if result[5] != nil { + ret5 = result[5].(error) + } + } + return ret0, ret1, ret2, ret3, ret4, ret5 +} + func (mock *MockEventParsing) ParseGithubIssueCommentEvent(logger logging.SimpleLogging, comment *github.IssueCommentEvent) (models.Repo, models.User, int, error) { if mock == nil { panic("mock must not be nil. Use myMock := NewMockEventParsing().") @@ -818,6 +909,87 @@ func (c *MockEventParsing_ParseBitbucketServerPullEvent_OngoingVerification) Get return } +func (verifier *VerifierMockEventParsing) ParseGiteaIssueCommentEvent(event gitea0.GiteaIssueCommentPayload) *MockEventParsing_ParseGiteaIssueCommentEvent_OngoingVerification { + params := []pegomock.Param{event} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseGiteaIssueCommentEvent", params, verifier.timeout) + return &MockEventParsing_ParseGiteaIssueCommentEvent_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockEventParsing_ParseGiteaIssueCommentEvent_OngoingVerification struct { + mock *MockEventParsing + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockEventParsing_ParseGiteaIssueCommentEvent_OngoingVerification) GetCapturedArguments() gitea0.GiteaIssueCommentPayload { + event := c.GetAllCapturedArguments() + return event[len(event)-1] +} + +func (c *MockEventParsing_ParseGiteaIssueCommentEvent_OngoingVerification) GetAllCapturedArguments() (_param0 []gitea0.GiteaIssueCommentPayload) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]gitea0.GiteaIssueCommentPayload, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(gitea0.GiteaIssueCommentPayload) + } + } + return +} + +func (verifier *VerifierMockEventParsing) ParseGiteaPull(pull *gitea.PullRequest) *MockEventParsing_ParseGiteaPull_OngoingVerification { + params := []pegomock.Param{pull} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseGiteaPull", params, verifier.timeout) + return &MockEventParsing_ParseGiteaPull_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockEventParsing_ParseGiteaPull_OngoingVerification struct { + mock *MockEventParsing + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockEventParsing_ParseGiteaPull_OngoingVerification) GetCapturedArguments() *gitea.PullRequest { + pull := c.GetAllCapturedArguments() + return pull[len(pull)-1] +} + +func (c *MockEventParsing_ParseGiteaPull_OngoingVerification) GetAllCapturedArguments() (_param0 []*gitea.PullRequest) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]*gitea.PullRequest, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(*gitea.PullRequest) + } + } + return +} + +func (verifier *VerifierMockEventParsing) ParseGiteaPullRequestEvent(event gitea.PullRequest) *MockEventParsing_ParseGiteaPullRequestEvent_OngoingVerification { + params := []pegomock.Param{event} + methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseGiteaPullRequestEvent", params, verifier.timeout) + return &MockEventParsing_ParseGiteaPullRequestEvent_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations} +} + +type MockEventParsing_ParseGiteaPullRequestEvent_OngoingVerification struct { + mock *MockEventParsing + methodInvocations []pegomock.MethodInvocation +} + +func (c *MockEventParsing_ParseGiteaPullRequestEvent_OngoingVerification) GetCapturedArguments() gitea.PullRequest { + event := c.GetAllCapturedArguments() + return event[len(event)-1] +} + +func (c *MockEventParsing_ParseGiteaPullRequestEvent_OngoingVerification) GetAllCapturedArguments() (_param0 []gitea.PullRequest) { + params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations) + if len(params) > 0 { + _param0 = make([]gitea.PullRequest, len(c.methodInvocations)) + for u, param := range params[0] { + _param0[u] = param.(gitea.PullRequest) + } + } + return +} + func (verifier *VerifierMockEventParsing) ParseGithubIssueCommentEvent(logger logging.SimpleLogging, comment *github.IssueCommentEvent) *MockEventParsing_ParseGithubIssueCommentEvent_OngoingVerification { params := []pegomock.Param{logger, comment} methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "ParseGithubIssueCommentEvent", params, verifier.timeout) diff --git a/server/events/models/models.go b/server/events/models/models.go index b98d93e554..4ff5bc339d 100644 --- a/server/events/models/models.go +++ b/server/events/models/models.go @@ -304,6 +304,7 @@ const ( BitbucketCloud BitbucketServer AzureDevops + Gitea ) func (h VCSHostType) String() string { @@ -318,6 +319,8 @@ func (h VCSHostType) String() string { return "BitbucketServer" case AzureDevops: return "AzureDevops" + case Gitea: + return "Gitea" } return "" } @@ -334,6 +337,8 @@ func NewVCSHostType(t string) (VCSHostType, error) { return BitbucketServer, nil case "AzureDevops": return AzureDevops, nil + case "Gitea": + return Gitea, nil } return -1, fmt.Errorf("%q is not a valid type", t) diff --git a/server/events/project_locker_test.go b/server/events/project_locker_test.go index 62be1c40f9..268faf20ee 100644 --- a/server/events/project_locker_test.go +++ b/server/events/project_locker_test.go @@ -29,7 +29,7 @@ import ( func TestDefaultProjectLocker_TryLockWhenLocked(t *testing.T) { var githubClient *vcs.GithubClient - mockClient := vcs.NewClientProxy(githubClient, nil, nil, nil, nil) + mockClient := vcs.NewClientProxy(githubClient, nil, nil, nil, nil, nil) mockLocker := mocks.NewMockLocker() locker := events.DefaultProjectLocker{ Locker: mockLocker, @@ -65,7 +65,7 @@ func TestDefaultProjectLocker_TryLockWhenLocked(t *testing.T) { func TestDefaultProjectLocker_TryLockWhenLockedSamePull(t *testing.T) { RegisterMockTestingT(t) var githubClient *vcs.GithubClient - mockClient := vcs.NewClientProxy(githubClient, nil, nil, nil, nil) + mockClient := vcs.NewClientProxy(githubClient, nil, nil, nil, nil, nil) mockLocker := mocks.NewMockLocker() locker := events.DefaultProjectLocker{ Locker: mockLocker, @@ -104,7 +104,7 @@ func TestDefaultProjectLocker_TryLockWhenLockedSamePull(t *testing.T) { func TestDefaultProjectLocker_TryLockUnlocked(t *testing.T) { RegisterMockTestingT(t) var githubClient *vcs.GithubClient - mockClient := vcs.NewClientProxy(githubClient, nil, nil, nil, nil) + mockClient := vcs.NewClientProxy(githubClient, nil, nil, nil, nil, nil) mockLocker := mocks.NewMockLocker() locker := events.DefaultProjectLocker{ Locker: mockLocker, @@ -142,7 +142,7 @@ func TestDefaultProjectLocker_TryLockUnlocked(t *testing.T) { func TestDefaultProjectLocker_RepoLocking(t *testing.T) { var githubClient *vcs.GithubClient - mockClient := vcs.NewClientProxy(githubClient, nil, nil, nil, nil) + mockClient := vcs.NewClientProxy(githubClient, nil, nil, nil, nil, nil) expProject := models.Project{} expWorkspace := "default" expPull := models.PullRequest{Num: 2} diff --git a/server/events/vcs/gitea/client.go b/server/events/vcs/gitea/client.go new file mode 100644 index 0000000000..f9deb2cb74 --- /dev/null +++ b/server/events/vcs/gitea/client.go @@ -0,0 +1,517 @@ +// Copyright 2024 Martijn van der Kleijn & Florian Beisel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gitea + +import ( + "context" + "encoding/base64" + "fmt" + "strings" + "time" + + "code.gitea.io/sdk/gitea" + "github.com/pkg/errors" + "github.com/runatlantis/atlantis/server/events/models" + "github.com/runatlantis/atlantis/server/logging" +) + +// Emergency break for Gitea pagination (just in case) +// Set to 500 to prevent runaway situations +// Value chosen purposely high, though randomly. +const giteaPaginationEBreak = 500 + +type GiteaClient struct { + giteaClient *gitea.Client + username string + token string + pageSize int + ctx context.Context +} + +type GiteaPRReviewSummary struct { + Reviews []GiteaReview +} + +type GiteaReview struct { + ID int64 + Body string + Reviewer string + State gitea.ReviewStateType // e.g., "APPROVED", "PENDING", "REQUEST_CHANGES" + SubmittedAt time.Time +} + +type GiteaPullGetter interface { + GetPullRequest(repo models.Repo, pullNum int) (*gitea.PullRequest, error) +} + +// NewClient builds a client that makes API calls to Gitea. 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'. +func NewClient(baseURL string, username string, token string, pagesize int, logger logging.SimpleLogging) (*GiteaClient, error) { + logger.Debug("Creating new Gitea client for: %s", baseURL) + + giteaClient, err := gitea.NewClient(baseURL, + gitea.SetToken(token), + gitea.SetUserAgent("atlantis"), + ) + + if err != nil { + return nil, errors.Wrap(err, "creating gitea client") + } + + return &GiteaClient{ + giteaClient: giteaClient, + username: username, + token: token, + pageSize: pagesize, + ctx: context.Background(), + }, nil +} + +func (c *GiteaClient) GetPullRequest(logger logging.SimpleLogging, repo models.Repo, pullNum int) (*gitea.PullRequest, error) { + logger.Debug("Getting Gitea pull request %d", pullNum) + + pr, resp, err := c.giteaClient.GetPullRequest(repo.Owner, repo.Name, int64(pullNum)) + + if err != nil { + logger.Debug("GET /repos/%v/%v/pulls/%d returned: %v", repo.Owner, repo.Name, pullNum, resp.StatusCode) + return nil, err + } + + return pr, nil +} + +// GetModifiedFiles returns the names of files that were modified in the merge request +// relative to the repo root, e.g. parent/child/file.txt. +func (c *GiteaClient) GetModifiedFiles(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) { + logger.Debug("Getting modified files for Gitea pull request %d", pull.Num) + + changedFiles := make([]string, 0) + page := 0 + nextPage := 1 + listOptions := gitea.ListPullRequestFilesOptions{ + ListOptions: gitea.ListOptions{ + Page: 1, + PageSize: c.pageSize, + }, + } + + for page < nextPage { + page = +1 + listOptions.ListOptions.Page = page + files, resp, err := c.giteaClient.ListPullRequestFiles(repo.Owner, repo.Name, int64(pull.Num), listOptions) + if err != nil { + logger.Debug("[page %d] GET /repos/%v/%v/pulls/%d/files returned: %v", page, repo.Owner, repo.Name, pull.Num, resp.StatusCode) + return nil, err + } + + for _, file := range files { + changedFiles = append(changedFiles, file.Filename) + } + + nextPage = resp.NextPage + + // Emergency break after giteaPaginationEBreak pages + if page >= giteaPaginationEBreak { + break + } + } + + return changedFiles, nil +} + +// CreateComment creates a comment on the merge request. As far as we're aware, Gitea has no built in max comment length right now. +func (c *GiteaClient) CreateComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, comment string, command string) error { + logger.Debug("Creating comment on Gitea pull request %d", pullNum) + + opt := gitea.CreateIssueCommentOption{ + Body: comment, + } + + _, resp, err := c.giteaClient.CreateIssueComment(repo.Owner, repo.Name, int64(pullNum), opt) + + if err != nil { + logger.Debug("POST /repos/%v/%v/issues/%d/comments returned: %v", repo.Owner, repo.Name, pullNum, resp.StatusCode) + return err + } + + logger.Debug("Added comment to Gitea pull request %d: %s", pullNum, comment) + + return nil +} + +// ReactToComment adds a reaction to a comment. +func (c *GiteaClient) ReactToComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, commentID int64, reaction string) error { + logger.Debug("Adding reaction to Gitea pull request comment %d", commentID) + + _, resp, err := c.giteaClient.PostIssueCommentReaction(repo.Owner, repo.Name, commentID, reaction) + + if err != nil { + logger.Debug("POST /repos/%v/%v/issues/comments/%d/reactions returned: %v", repo.Owner, repo.Name, commentID, resp.StatusCode) + return err + } + + return nil +} + +// HidePrevCommandComments hides the previous command comments from the pull +// request. +func (c *GiteaClient) HidePrevCommandComments(logger logging.SimpleLogging, repo models.Repo, pullNum int, command string, dir string) error { + logger.Debug("Hiding previous command comments on Gitea pull request %d", pullNum) + + var allComments []*gitea.Comment + + nextPage := int(1) + for { + // Initialize ListIssueCommentOptions with the current page + opts := gitea.ListIssueCommentOptions{ + ListOptions: gitea.ListOptions{ + Page: nextPage, + PageSize: c.pageSize, + }, + } + + comments, resp, err := c.giteaClient.ListIssueComments(repo.Owner, repo.Name, int64(pullNum), opts) + if err != nil { + logger.Debug("GET /repos/%v/%v/issues/%d/comments returned: %v", repo.Owner, repo.Name, pullNum, resp.StatusCode) + return err + } + + allComments = append(allComments, comments...) + + // Break the loop if there are no more pages to fetch + if resp.NextPage == 0 { + break + } + nextPage = resp.NextPage + } + + currentUser, resp, err := c.giteaClient.GetMyUserInfo() + if err != nil { + logger.Debug("GET /user returned: %v", resp.StatusCode) + return err + } + + summaryHeader := fmt.Sprintf("
Superseded Atlantis %s", command) + summaryFooter := "
" + lineFeed := "\n" + + for _, comment := range allComments { + if comment.Poster == nil || comment.Poster.UserName != currentUser.UserName { + continue + } + + body := strings.Split(comment.Body, "\n") + if len(body) == 0 || (!strings.Contains(strings.ToLower(body[0]), strings.ToLower(command)) && dir != "" && !strings.Contains(strings.ToLower(body[0]), strings.ToLower(dir))) { + continue + } + + supersededComment := summaryHeader + lineFeed + comment.Body + lineFeed + summaryFooter + lineFeed + + logger.Debug("Hiding comment %s", comment.ID) + _, _, err := c.giteaClient.EditIssueComment(repo.Owner, repo.Name, comment.ID, gitea.EditIssueCommentOption{ + Body: supersededComment, + }) + if err != nil { + return err + } + } + + return nil +} + +// PullIsApproved returns ApprovalStatus with IsApproved set to true if the pull request has a review that approved the PR. +func (c *GiteaClient) PullIsApproved(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) (models.ApprovalStatus, error) { + logger.Debug("Checking if Gitea pull request %d is approved", pull.Num) + + page := 0 + nextPage := 1 + + approvalStatus := models.ApprovalStatus{ + IsApproved: false, + } + + listOptions := gitea.ListPullReviewsOptions{ + ListOptions: gitea.ListOptions{ + Page: 1, + PageSize: c.pageSize, + }, + } + + for page < nextPage { + page = +1 + listOptions.ListOptions.Page = page + pullReviews, resp, err := c.giteaClient.ListPullReviews(repo.Owner, repo.Name, int64(pull.Num), listOptions) + + if err != nil { + logger.Debug("GET /repos/%v/%v/pulls/%d/reviews returned: %v", repo.Owner, repo.Name, pull.Num, resp.StatusCode) + return approvalStatus, err + } + + for _, review := range pullReviews { + if review.State == gitea.ReviewStateApproved { + approvalStatus.IsApproved = true + approvalStatus.ApprovedBy = review.Reviewer.UserName + approvalStatus.Date = review.Submitted + + return approvalStatus, nil + } + } + + nextPage = resp.NextPage + + // Emergency break after giteaPaginationEBreak pages + if page >= giteaPaginationEBreak { + break + } + } + + return approvalStatus, nil +} + +// PullIsMergeable returns true if the pull request is mergeable +func (c *GiteaClient) PullIsMergeable(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, vcsstatusname string) (bool, error) { + logger.Debug("Checking if Gitea pull request %d is mergeable", pull.Num) + + pullRequest, _, err := c.giteaClient.GetPullRequest(repo.Owner, repo.Name, int64(pull.Num)) + + if err != nil { + return false, err + } + + logger.Debug("Gitea pull request is mergeable: %v (%v)", pullRequest.Mergeable, pull.Num) + + return pullRequest.Mergeable, nil +} + +// UpdateStatus updates the commit status to state for pull. src is the +// source of this status. This should be relatively static across runs, +// ex. atlantis/plan or atlantis/apply. +// description is a description of this particular status update and can +// change across runs. +// url is an optional link that users should click on for more information +// about this status. +func (c *GiteaClient) UpdateStatus(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, state models.CommitStatus, src string, description string, url string) error { + giteaState := gitea.StatusFailure + + switch state { + case models.PendingCommitStatus: + giteaState = gitea.StatusPending + case models.SuccessCommitStatus: + giteaState = gitea.StatusSuccess + case models.FailedCommitStatus: + giteaState = gitea.StatusFailure + } + + logger.Debug("Updating status on Gitea pull request %d for '%s' to '%s'", pull.Num, description, state) + + newStatusOption := gitea.CreateStatusOption{ + State: giteaState, + TargetURL: url, + Description: description, + } + + _, resp, err := c.giteaClient.CreateStatus(repo.Owner, repo.Name, pull.HeadCommit, newStatusOption) + + if err != nil { + logger.Debug("POST /repos/%v/%v/statuses/%s returned: %v", repo.Owner, repo.Name, pull.HeadCommit, resp.StatusCode) + return err + } + + logger.Debug("Gitea status for pull request updated: %v (%v)", state, pull.Num) + + return nil +} + +// DiscardReviews discards / dismisses all pull request reviews +func (c *GiteaClient) DiscardReviews(repo models.Repo, pull models.PullRequest) error { + page := 0 + nextPage := 1 + + dismissOptions := gitea.DismissPullReviewOptions{ + Message: "Dismissed by Atlantis", + } + + listOptions := gitea.ListPullReviewsOptions{ + ListOptions: gitea.ListOptions{ + Page: 1, + PageSize: c.pageSize, + }, + } + + for page < nextPage { + page = +1 + listOptions.ListOptions.Page = page + pullReviews, resp, err := c.giteaClient.ListPullReviews(repo.Owner, repo.Name, int64(pull.Num), listOptions) + + if err != nil { + return err + } + + for _, review := range pullReviews { + _, err := c.giteaClient.DismissPullReview(repo.Owner, repo.Name, int64(pull.Num), review.ID, dismissOptions) + + if err != nil { + return err + } + } + + nextPage = resp.NextPage + + // Emergency break after giteaPaginationEBreak pages + if page >= giteaPaginationEBreak { + break + } + } + + return nil +} + +func (c *GiteaClient) MergePull(logger logging.SimpleLogging, pull models.PullRequest, pullOptions models.PullRequestOptions) error { + logger.Debug("Merging Gitea pull request %d", pull.Num) + + mergeOptions := gitea.MergePullRequestOption{ + Style: gitea.MergeStyleMerge, + Title: "Atlantis merge", + Message: "Automatic merge by Atlantis", + DeleteBranchAfterMerge: pullOptions.DeleteSourceBranchOnMerge, + ForceMerge: false, + HeadCommitId: pull.HeadCommit, + MergeWhenChecksSucceed: false, + } + + succeeded, resp, err := c.giteaClient.MergePullRequest(pull.BaseRepo.Owner, pull.BaseRepo.Name, int64(pull.Num), mergeOptions) + + if err != nil { + logger.Debug("POST /repos/%v/%v/pulls/%d/merge returned: %v", pull.BaseRepo.Owner, pull.BaseRepo.Name, pull.Num, resp.StatusCode) + return err + } + + if !succeeded { + return fmt.Errorf("merge failed: %s", resp.Status) + } + + return nil +} + +// MarkdownPullLink specifies the string used in a pull request comment to reference another pull request. +func (c *GiteaClient) MarkdownPullLink(pull models.PullRequest) (string, error) { + return fmt.Sprintf("#%d", pull.Num), nil +} + +// GetTeamNamesForUser returns the names of the teams or groups that the user belongs to (in the organization the repository belongs to). +func (c *GiteaClient) GetTeamNamesForUser(repo models.Repo, user models.User) ([]string, error) { + // TODO: implement + return nil, errors.New("GetTeamNamesForUser not (yet) implemented for Gitea client") +} + +// GetFileContent a repository file content from VCS (which support fetch a single file from repository) +// The first return value indicates whether the repo contains a file or not +// if BaseRepo had a file, its content will placed on the second return value +func (c *GiteaClient) GetFileContent(logger logging.SimpleLogging, pull models.PullRequest, fileName string) (bool, []byte, error) { + logger.Debug("Getting file content for %s in Gitea pull request %d", fileName, pull.Num) + + content, resp, err := c.giteaClient.GetContents(pull.BaseRepo.Owner, pull.BaseRepo.Name, pull.HeadCommit, fileName) + + if err != nil { + logger.Debug("GET /repos/%v/%v/contents/%s?ref=%v returned: %v", pull.BaseRepo.Owner, pull.BaseRepo.Name, fileName, pull.HeadCommit, resp.StatusCode) + return false, nil, err + } + + if content.Type == "file" { + decodedData, err := base64.StdEncoding.DecodeString(*content.Content) + if err != nil { + return true, []byte{}, err + } + return true, decodedData, nil + } + + return false, nil, nil +} + +// SupportsSingleFileDownload returns true if the VCS supports downloading a single file +func (c *GiteaClient) SupportsSingleFileDownload(repo models.Repo) bool { + return true +} + +// GetCloneURL returns the clone URL of the repo +func (c *GiteaClient) GetCloneURL(logger logging.SimpleLogging, _ models.VCSHostType, repo string) (string, error) { + logger.Debug("Getting clone URL for %s", repo) + + parts := strings.Split(repo, "/") + if len(parts) < 2 { + return "", errors.New("invalid repo format, expected 'owner/repo'") + } + repository, _, err := c.giteaClient.GetRepo(parts[0], parts[1]) + if err != nil { + logger.Debug("GET /repos/%v/%v returned an error: %v", parts[0], parts[1], err) + return "", err + } + return repository.CloneURL, nil +} + +// GetPullLabels returns the labels of a pull request +func (c *GiteaClient) GetPullLabels(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) { + logger.Debug("Getting labels for Gitea pull request %d", pull.Num) + + page := 0 + nextPage := 1 + results := make([]string, 0) + + opts := gitea.ListLabelsOptions{ + ListOptions: gitea.ListOptions{ + Page: 0, + PageSize: c.pageSize, + }, + } + + for page < nextPage { + page = +1 + opts.ListOptions.Page = page + + labels, resp, err := c.giteaClient.GetIssueLabels(repo.Owner, repo.Name, int64(pull.Num), opts) + + if err != nil { + logger.Debug("GET /repos/%v/%v/issues/%d/labels?%v returned: %v", repo.Owner, repo.Name, pull.Num, "unknown", resp.StatusCode) + return nil, err + } + + for _, label := range labels { + results = append(results, label.Name) + } + + nextPage = resp.NextPage + + // Emergency break after giteaPaginationEBreak pages + if page >= giteaPaginationEBreak { + break + } + } + + return results, nil +} + +func ValidateSignature(payload []byte, signature string, secretKey []byte) error { + isValid, err := gitea.VerifyWebhookSignature(string(secretKey), signature, payload) + if err != nil { + return errors.New("signature verification internal error") + } + if !isValid { + return errors.New("invalid signature") + } + + return nil +} diff --git a/server/events/vcs/gitea/models.go b/server/events/vcs/gitea/models.go new file mode 100644 index 0000000000..e624578e24 --- /dev/null +++ b/server/events/vcs/gitea/models.go @@ -0,0 +1,30 @@ +// Copyright 2024 Florian Beisel +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gitea + +import "code.gitea.io/sdk/gitea" + +type GiteaWebhookPayload struct { + Action string `json:"action"` + Number int `json:"number"` + PullRequest gitea.PullRequest `json:"pull_request"` +} + +type GiteaIssueCommentPayload struct { + Action string `json:"action"` + Comment gitea.Comment `json:"comment"` + Repository gitea.Repository `json:"repository"` + Issue gitea.Issue `json:"issue"` +} diff --git a/server/events/vcs/proxy.go b/server/events/vcs/proxy.go index d3d60b03fb..cd67b84c90 100644 --- a/server/events/vcs/proxy.go +++ b/server/events/vcs/proxy.go @@ -26,7 +26,7 @@ type ClientProxy struct { clients map[models.VCSHostType]Client } -func NewClientProxy(githubClient Client, gitlabClient Client, bitbucketCloudClient Client, bitbucketServerClient Client, azuredevopsClient Client) *ClientProxy { +func NewClientProxy(githubClient Client, gitlabClient Client, bitbucketCloudClient Client, bitbucketServerClient Client, azuredevopsClient Client, giteaClient Client) *ClientProxy { if githubClient == nil { githubClient = &NotConfiguredVCSClient{} } @@ -42,6 +42,9 @@ func NewClientProxy(githubClient Client, gitlabClient Client, bitbucketCloudClie if azuredevopsClient == nil { azuredevopsClient = &NotConfiguredVCSClient{} } + if giteaClient == nil { + giteaClient = &NotConfiguredVCSClient{} + } return &ClientProxy{ clients: map[models.VCSHostType]Client{ models.Github: githubClient, @@ -49,6 +52,7 @@ func NewClientProxy(githubClient Client, gitlabClient Client, bitbucketCloudClie models.BitbucketCloud: bitbucketCloudClient, models.BitbucketServer: bitbucketServerClient, models.AzureDevops: azuredevopsClient, + models.Gitea: giteaClient, }, } } diff --git a/server/server.go b/server/server.go index eeab9d732e..14fea9c613 100644 --- a/server/server.go +++ b/server/server.go @@ -62,6 +62,7 @@ import ( "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/events/vcs/gitea" "github.com/runatlantis/atlantis/server/events/webhooks" "github.com/runatlantis/atlantis/server/logging" ) @@ -176,6 +177,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { var bitbucketCloudClient *bitbucketcloud.Client var bitbucketServerClient *bitbucketserver.Client var azuredevopsClient *vcs.AzureDevopsClient + var giteaClient *gitea.GiteaClient policyChecksEnabled := false if userConfig.EnablePolicyChecksFlag { @@ -300,6 +302,19 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { return nil, err } } + if userConfig.GiteaToken != "" { + supportedVCSHosts = append(supportedVCSHosts, models.Gitea) + + giteaClient, err = gitea.NewClient(userConfig.GiteaBaseURL, userConfig.GiteaUser, userConfig.GiteaToken, userConfig.GiteaPageSize, logger) + if err != nil { + fmt.Println("error setting up gitea client", "error", err) + return nil, errors.Wrapf(err, "setting up Gitea client") + } else { + logger.Info("gitea client configured successfully") + } + } + + logger.Info("Supported VCS Hosts", "hosts", supportedVCSHosts) home, err := homedir.Dir() if err != nil { @@ -333,6 +348,11 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { return nil, err } } + if userConfig.GiteaUser != "" { + if err := vcs.WriteGitCreds(userConfig.GiteaUser, userConfig.GiteaToken, userConfig.GiteaBaseURL, home, logger, false); err != nil { + return nil, err + } + } } // default the project files used to generate the module index to the autoplan-file-list if autoplan-modules is true @@ -356,7 +376,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { if err != nil { return nil, errors.Wrap(err, "initializing webhooks") } - vcsClient := vcs.NewClientProxy(githubClient, gitlabClient, bitbucketCloudClient, bitbucketServerClient, azuredevopsClient) + vcsClient := vcs.NewClientProxy(githubClient, gitlabClient, bitbucketCloudClient, bitbucketServerClient, azuredevopsClient, giteaClient) commitStatusUpdater := &events.DefaultCommitStatusUpdater{Client: vcsClient, StatusName: userConfig.VCSStatusName} binDir, err := mkSubDir(userConfig.DataDir, BinDirName) @@ -528,6 +548,8 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { GithubToken: userConfig.GithubToken, GitlabUser: userConfig.GitlabUser, GitlabToken: userConfig.GitlabToken, + GiteaUser: userConfig.GiteaUser, + GiteaToken: userConfig.GiteaToken, AllowDraftPRs: userConfig.PlanDrafts, BitbucketUser: userConfig.BitbucketUser, BitbucketToken: userConfig.BitbucketToken, @@ -538,6 +560,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { commentParser := events.NewCommentParser( userConfig.GithubUser, userConfig.GitlabUser, + userConfig.GiteaUser, userConfig.BitbucketUser, userConfig.AzureDevopsUser, userConfig.ExecutableName, @@ -798,6 +821,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { GithubPullGetter: githubClient, GitlabMergeRequestGetter: gitlabClient, AzureDevopsPullGetter: azuredevopsClient, + GiteaPullGetter: giteaClient, CommentCommandRunnerByCmd: commentCommandRunnerByCmd, EventParser: eventParser, FailOnPreWorkflowHookError: userConfig.FailOnPreWorkflowHookError, @@ -889,6 +913,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { AzureDevopsWebhookBasicUser: []byte(userConfig.AzureDevopsWebhookUser), AzureDevopsWebhookBasicPassword: []byte(userConfig.AzureDevopsWebhookPassword), AzureDevopsRequestValidator: &events_controllers.DefaultAzureDevopsRequestValidator{}, + GiteaWebhookSecret: []byte(userConfig.GiteaWebhookSecret), } githubAppController := &controllers.GithubAppController{ AtlantisURL: parsedURL, diff --git a/server/user_config.go b/server/user_config.go index 977b008610..8109a30277 100644 --- a/server/user_config.go +++ b/server/user_config.go @@ -58,6 +58,11 @@ type UserConfig struct { GithubAppKeyFile string `mapstructure:"gh-app-key-file"` GithubAppSlug string `mapstructure:"gh-app-slug"` GithubTeamAllowlist string `mapstructure:"gh-team-allowlist"` + GiteaBaseURL string `mapstructure:"gitea-base-url"` + GiteaToken string `mapstructure:"gitea-token"` + GiteaUser string `mapstructure:"gitea-user"` + GiteaWebhookSecret string `mapstructure:"gitea-webhook-secret"` + GiteaPageSize int `mapstructure:"gitea-page-size"` GitlabHostname string `mapstructure:"gitlab-hostname"` GitlabToken string `mapstructure:"gitlab-token"` GitlabUser string `mapstructure:"gitlab-user"`