Repository: runatlantis/atlantis (language: go)

Issue:
Support Forgejo / Gitea webhooks
### Community Note

- **Please vote on this issue** by adding a 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the original issue to help the community and maintainers prioritize this request. Searching for pre-existing feature requests helps us consolidate datapoints for identical requirements into a single place, thank you!
- Please do not leave "+1" or other comments that do not add relevant new information or questions, they generate extra noise for issue followers and do not help prioritize the request.
- If you are interested in working on this issue or have submitted a pull request, please leave a comment.

---

- [ ] I'd be willing to implement this feature ([contributing guide](https://github.com/runatlantis/atlantis/blob/main/CONTRIBUTING.md))

**Describe the user story**
<!--
A clear and concise description of what workflow is meant to be improved.
Example: "As a developer, I often want to do <something>, but I often face <problem>".
-->
As a devops engineer maintaining a fully Open Source stack for an Open Source service, I would like to use Atlantis & Terraform to manage our infrastructure, but I cannot do so because Atlantis does not support Forgejo / Gitea.

**Describe the solution you'd like**
Support the Forgejo / Gitea webhooks.

Compared to the current Github, Bitbucket, Gitlab & Azure solutions, Forgejo / Gitea is the only full, free and true Open Source product made by & for the community.

Since Atlantis says it is for the benefit of the community and that is the reason why it wants to be, and remain, an Open Source tool... I would like to request that Forgejo / Gitea webhooks be supported.

Forgejo / Gitea is a stable and feature rich, free open source alternative to the big four. It is increasingly popular and well known.

**Describe the drawbacks of your solution**
Maintenance overhead for a 5th forge. (possibly 4th, see comment below)

**Describe alternatives you've considered**
- Github:
  - not free,
  - expensive,
  - not open source,
  - legal concerns
- Gitlab:
  - not free,
  - open source,
  - roadmap concerns
- Bitbucket:
  - not free,
  - expensive,
  - not open source,
  - not a common platform for Open Source devs
- Azure DevOps:
  - not free,
  - expensive,
  - not open source,
  - not a common platform for Open Source devs,
  - legal concerns,
  - roadmap / continued existence concerns (considering remarks from MS rep stating GH is probably the way to go instead when asked about Azure DevOps future)
  
 (edit: layout)
Support Forgejo / Gitea webhooks
### Community Note

- **Please vote on this issue** by adding a 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the original issue to help the community and maintainers prioritize this request. Searching for pre-existing feature requests helps us consolidate datapoints for identical requirements into a single place, thank you!
- Please do not leave "+1" or other comments that do not add relevant new information or questions, they generate extra noise for issue followers and do not help prioritize the request.
- If you are interested in working on this issue or have submitted a pull request, please leave a comment.

---

- [ ] I'd be willing to implement this feature ([contributing guide](https://github.com/runatlantis/atlantis/blob/main/CONTRIBUTING.md))

**Describe the user story**
<!--
A clear and concise description of what workflow is meant to be improved.
Example: "As a developer, I often want to do <something>, but I often face <problem>".
-->
As a devops engineer maintaining a fully Open Source stack for an Open Source service, I would like to use Atlantis & Terraform to manage our infrastructure, but I cannot do so because Atlantis does not support Forgejo / Gitea.

**Describe the solution you'd like**
Support the Forgejo / Gitea webhooks.

Compared to the current Github, Bitbucket, Gitlab & Azure solutions, Forgejo / Gitea is the only full, free and true Open Source product made by & for the community.

Since Atlantis says it is for the benefit of the community and that is the reason why it wants to be, and remain, an Open Source tool... I would like to request that Forgejo / Gitea webhooks be supported.

Forgejo / Gitea is a stable and feature rich, free open source alternative to the big four. It is increasingly popular and well known.

**Describe the drawbacks of your solution**
Maintenance overhead for a 5th forge. (possibly 4th, see comment below)

**Describe alternatives you've considered**
- Github:
  - not free,
  - expensive,
  - not open source,
  - legal concerns
- Gitlab:
  - not free,
  - open source,
  - roadmap concerns
- Bitbucket:
  - not free,
  - expensive,
  - not open source,
  - not a common platform for Open Source devs
- Azure DevOps:
  - not free,
  - expensive,
  - not open source,
  - not a common platform for Open Source devs,
  - legal concerns,
  - roadmap / continued existence concerns (considering remarks from MS rep stating GH is probably the way to go instead when asked about Azure DevOps future)
  
 (edit: layout)


Discussion / hints:
I managed to get Atlantis to work with Gitea by changing two lines, by forcing atlantis to use Gitea as if it were a Github Enterprise instance, although the changes were pretty botchy and i haven't tested it **at all**. 

Gitea aims to be 100% compatible with Github, although Gitea api V1 is compatible with Github api v3. So this is the first point of change: https://github.com/runatlantis/atlantis/blob/main/server/events/vcs/github_credentials.go#L196

But even with that change into v1, the api call still adds another api/v3/ in front of it. That's because the go-github library adds that suffix, so i had to change this line too: https://github.com/google/go-github/blob/v53.2.0/github/github.go#L389

To make this simple integration work officially, there could be at least three possible solutions: 
- Up the Gitea api version to v3;
- Fork the go-github repo and make changes on it and on Atlantis;
- Write the Gitea handler in Atlantis, completely separate of the implementation for Github.

The first one is clearly the one with least effort, and the one which impacts Atlantis the less, but depends on a lot of discussion and major decisions over on the Gitea project.

Also, I could make it work using basic auth, as Gitea's and Github's token auth is slightly different.

Either way, depending on the chosen solution this integration could spill over into Gitea backlog more than on Atlantis.

PR used for tests:
https://try.gitea.io/kirstus/tst/pulls/3

![atlantislog](https://github.com/runatlantis/atlantis/assets/20912512/58603e8a-37c8-4ce8-879f-386dc53905cf)

⚠️**I haven't actually applied anything, so tests are definitely still required and very welcome using Gitea, Gogs, Forgego and other open alternatives.**
I agree, I think the first approach is the best, we do not want to use a forked go-library in the project so if Gitea go library gets updated then you can add the new VCS for Gitea to atlantis.
We are back looking at this, since we are wanting to move on from gitlab since it no longer will support kubernetes fully. If go-github lib was able to get a PR in place to allow setting `"api/v3/"` via some sort of env var or something, would atlantis be OK with doing the same thing here: https://github.com/runatlantis/atlantis/blob/main/server/events/vcs/github_credentials.go#L196 

Trying to avoid having to fork both repo's.
as long as we default it to the current value, we will be open to do that
So... As far as I can tell, nobody is actively working on this right? How likely do we think it is that Google / the maintainers of go-github will make the necessary changes?

(I'm submitting an issue anyway)
Ooookay... I was about to hit submit to an issue on go-github when I noticed this line in the doc:

```
// Note that NewEnterpriseClient is a convenience helper only;
// its behavior is equivalent to using NewClient, followed by setting
// the BaseURL and UploadURL fields.
```

Translation: we don't need a change in the go-github library if the Atlantis project would be willing to create a single convenience function itself instead of relying on the one provided by go-github.

It would be equivalent to using the NewClient function instead of the NewEnterpriseClient function but with the added benefit of allowing easy support for Gitea. Its not a complex function anyway so maintenance costs would be low. (see code snippet at the bottom)

What's your take on this @jamengual ?

```
// code taken from go-github

func NewEnterpriseClient(baseURL, uploadURL string, httpClient *http.Client) (*Client, error) {
	baseEndpoint, err := url.Parse(baseURL)
	if err != nil {
		return nil, err
	}

	if !strings.HasSuffix(baseEndpoint.Path, "/") {
		baseEndpoint.Path += "/"
	}
	if !strings.HasSuffix(baseEndpoint.Path, "/api/v3/") &&
		!strings.HasPrefix(baseEndpoint.Host, "api.") &&
		!strings.Contains(baseEndpoint.Host, ".api.") {
		baseEndpoint.Path += "api/v3/"
	}

	uploadEndpoint, err := url.Parse(uploadURL)
	if err != nil {
		return nil, err
	}

	if !strings.HasSuffix(uploadEndpoint.Path, "/") {
		uploadEndpoint.Path += "/"
	}
	if !strings.HasSuffix(uploadEndpoint.Path, "/api/uploads/") &&
		!strings.HasPrefix(uploadEndpoint.Host, "api.") &&
		!strings.Contains(uploadEndpoint.Host, ".api.") {
		uploadEndpoint.Path += "api/uploads/"
	}

	c := NewClient(httpClient)
	c.BaseURL = baseEndpoint
	c.UploadURL = uploadEndpoint
	return c, nil
}
```
I think we need @GenPage @lukemassa @nitrocode @chenrui333 @X-Guardian eyes here
@kirstus @jseiser @mvdkleijn 

After reviewing, it makes sense to me that we can get away with supporting Gitea without overloading the existing GHE client interface. We can always refactor down the road if there is an issue to track in the upstream go-github library when official Gitea support becomes available.
So if I understand you correctly @GenPage:

a PR that adds a wrapper function to atlantis a la go-github's NewEnterpriseClient() would be an acceptable way to work around the hardcoded versions?

If so, I'll try to add something next week-ish.
@mvdkleijn Correct, I wouldn't be opposed to additional logic in a wrapper function to compensate for the lack of support for Gitea in go-github. I would envision an entirely separate client. (gitea_client.go). The function can be in there or abstracted in a higher interface, whatever makes sense.
> @mvdkleijn Correct, I wouldn't be opposed to additional logic in a wrapper function to compensate for the lack of support for Gitea in go-github. I would envision an entirely separate client. (gitea_client.go). The function can be in there or abstracted in a higher interface, whatever makes sense.

An entirely separate client makes sense and would indicate the project's intention to support more than proprietary SaaS solutions, which would be a nice bonus.

I'll see if I can scrounge some time in the next couple of weeks to work on this. Though I would definitely not mind if someone beats me to it.
Despite the similarities, I would not recommend using GitHub's SDK to communicate with Gitea instance.

Instead, here is the official SDK from Gitea: https://gitea.com/gitea/go-sdk
Documentation: https://pkg.go.dev/code.gitea.io/sdk/gitea
> Despite the similarities, I would not recommend using GitHub's SDK to communicate with Gitea instance.
> 
> Instead, here is the official SDK from Gitea: https://gitea.com/gitea/go-sdk
> Documentation: https://pkg.go.dev/code.gitea.io/sdk/gitea

Honestly, I didn't even realize that there was a SDK for Gitea. It'll probably take a little more work to do a full Gitea client but that's obviously the best.
FYI, I've started a branch on my fork based on the Gitea SDK. It might take a while for me to get a draft PR in due to family life but we'll see. Planning to create a draft PR as soon as I've implemented the interface, regardless of whether I had time to add tests / try it out.
I managed to get Atlantis to work with Gitea by changing two lines, by forcing atlantis to use Gitea as if it were a Github Enterprise instance, although the changes were pretty botchy and i haven't tested it **at all**. 

Gitea aims to be 100% compatible with Github, although Gitea api V1 is compatible with Github api v3. So this is the first point of change: https://github.com/runatlantis/atlantis/blob/main/server/events/vcs/github_credentials.go#L196

But even with that change into v1, the api call still adds another api/v3/ in front of it. That's because the go-github library adds that suffix, so i had to change this line too: https://github.com/google/go-github/blob/v53.2.0/github/github.go#L389

To make this simple integration work officially, there could be at least three possible solutions: 
- Up the Gitea api version to v3;
- Fork the go-github repo and make changes on it and on Atlantis;
- Write the Gitea handler in Atlantis, completely separate of the implementation for Github.

The first one is clearly the one with least effort, and the one which impacts Atlantis the less, but depends on a lot of discussion and major decisions over on the Gitea project.

Also, I could make it work using basic auth, as Gitea's and Github's token auth is slightly different.

Either way, depending on the chosen solution this integration could spill over into Gitea backlog more than on Atlantis.

PR used for tests:
https://try.gitea.io/kirstus/tst/pulls/3

![atlantislog](https://github.com/runatlantis/atlantis/assets/20912512/58603e8a-37c8-4ce8-879f-386dc53905cf)

⚠️**I haven't actually applied anything, so tests are definitely still required and very welcome using Gitea, Gogs, Forgego and other open alternatives.**
I agree, I think the first approach is the best, we do not want to use a forked go-library in the project so if Gitea go library gets updated then you can add the new VCS for Gitea to atlantis.
We are back looking at this, since we are wanting to move on from gitlab since it no longer will support kubernetes fully. If go-github lib was able to get a PR in place to allow setting `"api/v3/"` via some sort of env var or something, would atlantis be OK with doing the same thing here: https://github.com/runatlantis/atlantis/blob/main/server/events/vcs/github_credentials.go#L196 

Trying to avoid having to fork both repo's.
as long as we default it to the current value, we will be open to do that
So... As far as I can tell, nobody is actively working on this right? How likely do we think it is that Google / the maintainers of go-github will make the necessary changes?

(I'm submitting an issue anyway)
Ooookay... I was about to hit submit to an issue on go-github when I noticed this line in the doc:

```
// Note that NewEnterpriseClient is a convenience helper only;
// its behavior is equivalent to using NewClient, followed by setting
// the BaseURL and UploadURL fields.
```

Translation: we don't need a change in the go-github library if the Atlantis project would be willing to create a single convenience function itself instead of relying on the one provided by go-github.

It would be equivalent to using the NewClient function instead of the NewEnterpriseClient function but with the added benefit of allowing easy support for Gitea. Its not a complex function anyway so maintenance costs would be low. (see code snippet at the bottom)

What's your take on this @jamengual ?

```
// code taken from go-github

func NewEnterpriseClient(baseURL, uploadURL string, httpClient *http.Client) (*Client, error) {
	baseEndpoint, err := url.Parse(baseURL)
	if err != nil {
		return nil, err
	}

	if !strings.HasSuffix(baseEndpoint.Path, "/") {
		baseEndpoint.Path += "/"
	}
	if !strings.HasSuffix(baseEndpoint.Path, "/api/v3/") &&
		!strings.HasPrefix(baseEndpoint.Host, "api.") &&
		!strings.Contains(baseEndpoint.Host, ".api.") {
		baseEndpoint.Path += "api/v3/"
	}

	uploadEndpoint, err := url.Parse(uploadURL)
	if err != nil {
		return nil, err
	}

	if !strings.HasSuffix(uploadEndpoint.Path, "/") {
		uploadEndpoint.Path += "/"
	}
	if !strings.HasSuffix(uploadEndpoint.Path, "/api/uploads/") &&
		!strings.HasPrefix(uploadEndpoint.Host, "api.") &&
		!strings.Contains(uploadEndpoint.Host, ".api.") {
		uploadEndpoint.Path += "api/uploads/"
	}

	c := NewClient(httpClient)
	c.BaseURL = baseEndpoint
	c.UploadURL = uploadEndpoint
	return c, nil
}
```
I think we need @GenPage @lukemassa @nitrocode @chenrui333 @X-Guardian eyes here
@kirstus @jseiser @mvdkleijn 

After reviewing, it makes sense to me that we can get away with supporting Gitea without overloading the existing GHE client interface. We can always refactor down the road if there is an issue to track in the upstream go-github library when official Gitea support becomes available.
So if I understand you correctly @GenPage:

a PR that adds a wrapper function to atlantis a la go-github's NewEnterpriseClient() would be an acceptable way to work around the hardcoded versions?

If so, I'll try to add something next week-ish.
@mvdkleijn Correct, I wouldn't be opposed to additional logic in a wrapper function to compensate for the lack of support for Gitea in go-github. I would envision an entirely separate client. (gitea_client.go). The function can be in there or abstracted in a higher interface, whatever makes sense.
> @mvdkleijn Correct, I wouldn't be opposed to additional logic in a wrapper function to compensate for the lack of support for Gitea in go-github. I would envision an entirely separate client. (gitea_client.go). The function can be in there or abstracted in a higher interface, whatever makes sense.

An entirely separate client makes sense and would indicate the project's intention to support more than proprietary SaaS solutions, which would be a nice bonus.

I'll see if I can scrounge some time in the next couple of weeks to work on this. Though I would definitely not mind if someone beats me to it.
Despite the similarities, I would not recommend using GitHub's SDK to communicate with Gitea instance.

Instead, here is the official SDK from Gitea: https://gitea.com/gitea/go-sdk
Documentation: https://pkg.go.dev/code.gitea.io/sdk/gitea
> Despite the similarities, I would not recommend using GitHub's SDK to communicate with Gitea instance.
> 
> Instead, here is the official SDK from Gitea: https://gitea.com/gitea/go-sdk
> Documentation: https://pkg.go.dev/code.gitea.io/sdk/gitea

Honestly, I didn't even realize that there was a SDK for Gitea. It'll probably take a little more work to do a full Gitea client but that's obviously the best.
FYI, I've started a branch on my fork based on the Gitea SDK. It might take a while for me to get a draft PR in due to family life but we'll see. Planning to create a draft PR as soon as I've implemented the interface, regardless of whether I had time to add tests / try it out.

Fix patch:
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/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 `<YOUR_GITHUB_USER>` 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 `<YOUR_GITLAB_USER>` 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 `<YOUR_GITEA_USER>` 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 `<YOUR_BITBUCKET_USER>` 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 `<YOUR_AZUREDEVOPS_USER>` 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
 <details>
@@ -185,6 +188,21 @@ spec:
               key: webhook-secret
         ### End GitLab Config ###
 
+        ### Gitea Config ###
+        - name: ATLANTIS_GITEA_USER
+          value: <YOUR_GITEA_USER> # 4i. If you're using Gitea replace <YOUR_GITEA_USER> 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: <YOUR_BITBUCKET_USER> # 5i. If you're using Bitbucket replace <YOUR_BITBUCKET_USER> 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: <YOUR_GITEA_USER> # 4i. If you're using Gitea replace <YOUR_GITEA_USER> 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: <YOUR_BITBUCKET_USER> # 5i. If you're using Bitbucket replace <YOUR_BITBUCKET_USER> 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: <YOUR_GITEA_USER> # 4i. If you're using Gitea replace <YOUR_GITEA_USER> 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/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/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/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 "<missing String() implementation>"
 }
@@ -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/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 Command-+ ---><details><summary>Superseded Atlantis %s</summary>", command)
+	summaryFooter := "</details>"
+	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"`


Test patch:
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/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
     </ul>
 </details>
 
+### Gitea Webhook
+<details>
+    <summary>Expand</summary>
+    <ul>
+        <li>Click <strong>Settings &gt; Webhooks</strong> in the top- and then sidebar</li>
+        <li>Click <strong>Add webhook &gt; Gitea</strong> (Gitea webhooks are service specific, but this works)</li>
+        <li>set <strong>Target URL</strong> to <code>http://$URL/events</code> (or <code>https://$URL/events</code> if you're using SSL) where <code>$URL</code> is where Atlantis is hosted. <strong>Be sure to add <code>/events</code></strong></li>
+        <li>double-check you added <code>/events</code> to the end of your URL.</li>
+        <li>set <strong>Secret</strong> to the Webhook Secret you generated previously 
+        <ul>
+            <li><strong>NOTE</strong> If you're adding a webhook to multiple repositories, each repository will need to use the <strong>same</strong> secret.</li>
+        </ul>
+        </li>
+        <li>Select <strong>Custom Events...</strong></li>
+        <li>Check the boxes 
+            <ul>
+                <li><strong>Repository events &gt; Push</strong></li>
+                <li><strong>Issue events &gt; Issue Comment</strong></li>
+                <li><strong>Pull Request events &gt; Pull Request</strong></li>
+                <li><strong>Pull Request events &gt; Pull Request Comment</strong></li>
+                <li><strong>Pull Request events &gt; Pull Request Reviewed</strong></li>
+                <li><strong>Pull Request events &gt; Pull Request Synchronized</strong></li>
+            </ul>
+        </li>
+        <li>Leave <strong>Active</strong> checked</li>
+        <li>Click <strong>Add Webhook</strong></li>
+        <li>See <a href="#next-steps">Next Steps</a></li>
+    </ul>
+</details>
+
 
 ## 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_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/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/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}