Repository: runatlantis/atlantis (language: go)

Issue:
Don't post comment if it's too large
## what

TF_LOG=debug causes hundreds of PR comments.

## how

I (perhaps stupidly) wanted to test what vars TF had set internally so added TF_LOG=debug to the custom command that Atlantis runs. TIL that loading a PR with 300+ hefty comments brings a browser to a halt. 

Perhaps Atlantis should not honour TF_LOG and spit it out to PR comments. It writes to a log file already. Could it be passable via arg or PR comment, should someone wish to inflict pain on themselves?


Discussion / hints:
Oh no! 😢I don't think it makes sense to detect a special case for `TF_LOG` because not everyone's output would need 300 comments. I'm guessing you have a pretty large state?

Maybe we should detect if an output is going to need >20 comments and just disallow it from posting?
Note a huge state @lkysow but as we are moving things onto Atlantis in bulk, it picked up a lot of projects. Not a huge deal and it was totally self inflicted, but thought worth mentioning. Perhaps if it is going to be more than 100 comments, chances are your browser isn't gonna wanna show it anyway :)
I like the idea of:
* detecting if a comment is > 1,310,720 characters (30x65536)
* truncating to 30 comments and prepending to the first comment a warning that we're not printing the full output and you can find it on disk
* dump the full comment to disk in the temp folder
* during the dump, checking if there are other comment dumps and cleaning them up so users don't fill up their disks

I'm going to change the label on this because it's not a bug really.
security-wise, I would imagine that generally it would be good to not allow debug output to get put in a GitHub comment in any event?
The only way to set this is to add a custom command so you could redirect it to file instead if you'd like.

```
run: TF_LOG=debug terraform plan > /tmp/myfile 1>&2
```

I wouldn't want Atlantis to try and do anything special and detect that env var is set. It's just as easy for someone to do
```
run: echo $AWS_CREDENTIALS
```
So protecting against this special case doesn't make sense to me.
We are hitting an issue specifically [where the elasticsearch provider is causing 30+ pages of plan output](https://github.com/phillbaker/terraform-provider-elasticsearch/issues/222) and is blocked by an upstream provider issue (https://github.com/phillbaker/terraform-provider-elasticsearch/issues/93) We would like some way to truncate or stop plan output that is especially large.

I think this issue was opened before atlantis had an interface for exploring plans and applys directly in the UI from the details link.  If we are okay with having to read the details to see the full plan, I would love this toggle to be added to atlantis to just truncate the plan and require the user to click the link to read the full plan.
Just ran into this with the effect that GitHub ends up rate limiting us for the next hour! Definitely would like to limit the number of comments per run.  (Perhaps giant things could be put in a Gist?)

Fix patch:
diff --git a/cmd/server.go b/cmd/server.go
index 950c17243c..0bb589411e 100644
--- a/cmd/server.go
+++ b/cmd/server.go
@@ -110,6 +110,7 @@ const (
 	LockingDBType                    = "locking-db-type"
 	LogLevelFlag                     = "log-level"
 	MarkdownTemplateOverridesDirFlag = "markdown-template-overrides-dir"
+	MaxCommentsPerCommand            = "max-comments-per-command"
 	ParallelPoolSize                 = "parallel-pool-size"
 	StatsNamespace                   = "stats-namespace"
 	AllowDraftPRs                    = "allow-draft-prs"
@@ -167,6 +168,7 @@ const (
 	DefaultGitlabHostname               = "gitlab.com"
 	DefaultLockingDBType                = "boltdb"
 	DefaultLogLevel                     = "info"
+	DefaultMaxCommentsPerCommand        = 100
 	DefaultParallelPoolSize             = 15
 	DefaultStatsNamespace               = "atlantis"
 	DefaultPort                         = 4141
@@ -593,6 +595,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,
 	},
+	MaxCommentsPerCommand: {
+		description:  "If non-zero, the maximum number of comments to split command output into before truncating.",
+		defaultValue: DefaultMaxCommentsPerCommand,
+	},
 	GiteaPageSizeFlag: {
 		description:  "Optional value that specifies the number of results per page to expect from Gitea.",
 		defaultValue: DefaultGiteaPageSize,
@@ -783,7 +789,7 @@ func (s *ServerCmd) run() error {
 	if err := s.Viper.Unmarshal(&userConfig); err != nil {
 		return err
 	}
-	s.setDefaults(&userConfig)
+	s.setDefaults(&userConfig, s.Viper)
 
 	// Now that we've parsed the config we can set our local logger to the
 	// right level.
@@ -824,7 +830,7 @@ func (s *ServerCmd) run() error {
 	return server.Start()
 }
 
-func (s *ServerCmd) setDefaults(c *server.UserConfig) {
+func (s *ServerCmd) setDefaults(c *server.UserConfig, v *viper.Viper) {
 	if c.AzureDevOpsHostname == "" {
 		c.AzureDevOpsHostname = DefaultADHostname
 	}
@@ -873,6 +879,9 @@ func (s *ServerCmd) setDefaults(c *server.UserConfig) {
 	if c.MarkdownTemplateOverridesDir == "" {
 		c.MarkdownTemplateOverridesDir = DefaultMarkdownTemplateOverridesDir
 	}
+	if !v.IsSet("max-comments-per-command") {
+		c.MaxCommentsPerCommand = DefaultMaxCommentsPerCommand
+	}
 	if c.ParallelPoolSize == 0 {
 		c.ParallelPoolSize = DefaultParallelPoolSize
 	}
diff --git a/runatlantis.io/docs/server-configuration.md b/runatlantis.io/docs/server-configuration.md
index 6e421c0f3d..90947f5693 100644
--- a/runatlantis.io/docs/server-configuration.md
+++ b/runatlantis.io/docs/server-configuration.md
@@ -903,6 +903,14 @@ This is useful when you have many projects and want to keep the pull request cle
 
   Defaults to the atlantis home directory `/home/atlantis/.markdown_templates/` in `/$HOME/.markdown_templates`.
 
+### `--max-comments-per-command`
+  ```bash
+  atlantis server --max-comments-per-command=100
+  # or
+  ATLANTIS_MAX_COMMENTS_PER_COMMAND=100
+  ```
+  Limit the number of comments published after a command is executed, to prevent spamming your VCS and Atlantis to get throttled as a result. Defaults to `100`. Set this option to `0` to disable log truncation. Note that the truncation will happen on the top of the command output, to preserve the most important parts of the output, often displayed at the end.
+
 ### `--parallel-apply`
 
   ```bash
diff --git a/server/controllers/github_app_controller.go b/server/controllers/github_app_controller.go
index 823c82928e..5b175d1336 100644
--- a/server/controllers/github_app_controller.go
+++ b/server/controllers/github_app_controller.go
@@ -56,7 +56,8 @@ func (g *GithubAppController) ExchangeCode(w http.ResponseWriter, r *http.Reques
 	g.Logger.Debug("Exchanging GitHub app code for app credentials")
 	creds := &vcs.GithubAnonymousCredentials{}
 	config := vcs.GithubConfig{}
-	client, err := vcs.NewGithubClient(g.GithubHostname, creds, config, g.Logger)
+	// This client does not post comments, so we don't need to configure it with maxCommentsPerCommand.
+	client, err := vcs.NewGithubClient(g.GithubHostname, creds, config, 0, g.Logger)
 	if err != nil {
 		g.respond(w, logging.Error, http.StatusInternalServerError, "Failed to exchange code for github app: %s", err)
 		return
diff --git a/server/events/vcs/azuredevops_client.go b/server/events/vcs/azuredevops_client.go
index cd2ebe52fe..1d115d28e9 100644
--- a/server/events/vcs/azuredevops_client.go
+++ b/server/events/vcs/azuredevops_client.go
@@ -107,7 +107,7 @@ func (g *AzureDevopsClient) CreateComment(logger logging.SimpleLogging, repo mod
 	// or tested limit in Azure DevOps.
 	const maxCommentLength = 150000
 
-	comments := common.SplitComment(comment, maxCommentLength, sepEnd, sepStart)
+	comments := common.SplitComment(comment, maxCommentLength, sepEnd, sepStart, 0, "")
 	owner, project, repoName := SplitAzureDevopsRepoFullName(repo.FullName)
 
 	for i := range comments {
diff --git a/server/events/vcs/bitbucketserver/client.go b/server/events/vcs/bitbucketserver/client.go
index 0c741faaa6..69dbbde518 100644
--- a/server/events/vcs/bitbucketserver/client.go
+++ b/server/events/vcs/bitbucketserver/client.go
@@ -137,7 +137,7 @@ func (b *Client) GetProjectKey(repoName string, cloneURL string) (string, error)
 func (b *Client) CreateComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, comment string, _ string) error {
 	sepEnd := "\n```\n**Warning**: Output length greater than max comment size. Continued in next comment."
 	sepStart := "Continued from previous comment.\n```diff\n"
-	comments := common.SplitComment(comment, maxCommentLength, sepEnd, sepStart)
+	comments := common.SplitComment(comment, maxCommentLength, sepEnd, sepStart, 0, "")
 	for _, c := range comments {
 		if err := b.postComment(repo, pullNum, c); err != nil {
 			return err
diff --git a/server/events/vcs/common/common.go b/server/events/vcs/common/common.go
index bb5004ed08..b7c1028ac1 100644
--- a/server/events/vcs/common/common.go
+++ b/server/events/vcs/common/common.go
@@ -12,34 +12,45 @@ func AutomergeCommitMsg(pullNum int) string {
 	return fmt.Sprintf("[Atlantis] Automatically merging after successful apply: PR #%d", pullNum)
 }
 
-// SplitComment splits comment into a slice of comments that are under maxSize.
-// It appends sepEnd to all comments that have a following comment.
-// It prepends sepStart to all comments that have a preceding comment.
-func SplitComment(comment string, maxSize int, sepEnd string, sepStart string) []string {
+/*
+SplitComment splits comment into a slice of comments that are under maxSize.
+- It appends sepEnd to all comments that have a following comment.
+- It prepends sepStart to all comments that have a preceding comment.
+- If maxCommentsPerCommand is non-zero, it never returns more than maxCommentsPerCommand
+comments, and it truncates the beginning of the comment to preserve the end of the comment string,
+which usually contains more important information, such as warnings, errors, and the plan summary.
+- SplitComment appends the truncationHeader to the first comment if it would have produced more comments.
+*/
+func SplitComment(comment string, maxSize int, sepEnd string, sepStart string, maxCommentsPerCommand int, truncationHeader string) []string {
 	if len(comment) <= maxSize {
 		return []string{comment}
 	}
 
-	maxWithSep := maxSize - len(sepEnd) - len(sepStart)
+	// No comment contains both sepEnd and truncationHeader, so we only have to count their max.
+	maxWithSep := maxSize - max(len(sepEnd), len(truncationHeader)) - len(sepStart)
 	var comments []string
-	numComments := int(math.Ceil(float64(len(comment)) / float64(maxWithSep)))
-	for i := 0; i < numComments; i++ {
-		upTo := min(len(comment), (i+1)*maxWithSep)
-		portion := comment[i*maxWithSep : upTo]
-		if i < numComments-1 {
-			portion += sepEnd
-		}
-		if i > 0 {
+	numPotentialComments := int(math.Ceil(float64(len(comment)) / float64(maxWithSep)))
+	var numComments int
+	if maxCommentsPerCommand == 0 {
+		numComments = numPotentialComments
+	} else {
+		numComments = min(numPotentialComments, maxCommentsPerCommand)
+	}
+	isTruncated := numComments < numPotentialComments
+	upTo := len(comment)
+	for len(comments) < numComments {
+		downFrom := max(0, upTo-maxWithSep)
+		portion := comment[downFrom:upTo]
+		if len(comments)+1 != numComments {
 			portion = sepStart + portion
+		} else if len(comments)+1 == numComments && isTruncated {
+			portion = truncationHeader + portion
+		}
+		if len(comments) != 0 {
+			portion = portion + sepEnd
 		}
-		comments = append(comments, portion)
+		comments = append([]string{portion}, comments...)
+		upTo = downFrom
 	}
 	return comments
 }
-
-func min(a, b int) int {
-	if a < b {
-		return a
-	}
-	return b
-}
diff --git a/server/events/vcs/github_client.go b/server/events/vcs/github_client.go
index 8ee5c34385..a561833e6f 100644
--- a/server/events/vcs/github_client.go
+++ b/server/events/vcs/github_client.go
@@ -41,11 +41,12 @@ var (
 
 // GithubClient is used to perform GitHub actions.
 type GithubClient struct {
-	user     string
-	client   *github.Client
-	v4Client *githubv4.Client
-	ctx      context.Context
-	config   GithubConfig
+	user                  string
+	client                *github.Client
+	v4Client              *githubv4.Client
+	ctx                   context.Context
+	config                GithubConfig
+	maxCommentsPerCommand int
 }
 
 // GithubAppTemporarySecrets holds app credentials obtained from github after creation.
@@ -76,7 +77,8 @@ type GithubPRReviewSummary struct {
 }
 
 // NewGithubClient returns a valid GitHub client.
-func NewGithubClient(hostname string, credentials GithubCredentials, config GithubConfig, logger logging.SimpleLogging) (*GithubClient, error) {
+
+func NewGithubClient(hostname string, credentials GithubCredentials, config GithubConfig, maxCommentsPerCommand int, logger logging.SimpleLogging) (*GithubClient, error) {
 	logger.Debug("Creating new GitHub client for host: %s", hostname)
 	transport, err := credentials.Client()
 	if err != nil {
@@ -108,11 +110,12 @@ func NewGithubClient(hostname string, credentials GithubCredentials, config Gith
 		return nil, errors.Wrap(err, "getting user")
 	}
 	return &GithubClient{
-		user:     user,
-		client:   client,
-		v4Client: v4Client,
-		ctx:      context.Background(),
-		config:   config,
+		user:                  user,
+		client:                client,
+		v4Client:              v4Client,
+		ctx:                   context.Background(),
+		config:                config,
+		maxCommentsPerCommand: maxCommentsPerCommand,
 	}, nil
 }
 
@@ -191,7 +194,10 @@ func (g *GithubClient) CreateComment(logger logging.SimpleLogging, repo models.R
 			"```diff\n"
 	}
 
-	comments := common.SplitComment(comment, maxCommentLength, sepEnd, sepStart)
+	truncationHeader := "\n```\n</details>" +
+		"\n<br>\n\n**Warning**: Command output is larger than the maximum number of comments per command. Output truncated.\n\n[..]\n"
+
+	comments := common.SplitComment(comment, maxCommentLength, sepEnd, sepStart, g.maxCommentsPerCommand, truncationHeader)
 	for i := range comments {
 		_, resp, err := g.client.Issues.CreateComment(g.ctx, repo.Owner, repo.Name, pullNum, &github.IssueComment{Body: &comments[i]})
 		if resp != nil {
diff --git a/server/events/vcs/gitlab_client.go b/server/events/vcs/gitlab_client.go
index 14d63069ad..16ffa38421 100644
--- a/server/events/vcs/gitlab_client.go
+++ b/server/events/vcs/gitlab_client.go
@@ -177,7 +177,7 @@ func (g *GitlabClient) CreateComment(logger logging.SimpleLogging, repo models.R
 		"\n<br>\n\n**Warning**: Output length greater than max comment size. Continued in next comment."
 	sepStart := "Continued from previous comment.\n<details><summary>Show Output</summary>\n\n" +
 		"```diff\n"
-	comments := common.SplitComment(comment, gitlabMaxCommentLength, sepEnd, sepStart)
+	comments := common.SplitComment(comment, gitlabMaxCommentLength, sepEnd, sepStart, 0, "")
 	for _, c := range comments {
 		_, resp, err := g.Client.Notes.CreateMergeRequestNote(repo.FullName, pullNum, &gitlab.CreateMergeRequestNoteOptions{Body: gitlab.Ptr(c)})
 		if resp != nil {
diff --git a/server/server.go b/server/server.go
index 521643df40..4a0e5aa738 100644
--- a/server/server.go
+++ b/server/server.go
@@ -258,7 +258,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
 		}
 
 		var err error
-		rawGithubClient, err := vcs.NewGithubClient(userConfig.GithubHostname, githubCredentials, githubConfig, logger)
+		rawGithubClient, err := vcs.NewGithubClient(userConfig.GithubHostname, githubCredentials, githubConfig, userConfig.MaxCommentsPerCommand, logger)
 		if err != nil {
 			return nil, err
 		}
diff --git a/server/user_config.go b/server/user_config.go
index 31b8271efa..91ed090ac6 100644
--- a/server/user_config.go
+++ b/server/user_config.go
@@ -74,6 +74,7 @@ type UserConfig struct {
 	LockingDBType                   string `mapstructure:"locking-db-type"`
 	LogLevel                        string `mapstructure:"log-level"`
 	MarkdownTemplateOverridesDir    string `mapstructure:"markdown-template-overrides-dir"`
+	MaxCommentsPerCommand           int    `mapstructure:"max-comments-per-command"`
 	ParallelPoolSize                int    `mapstructure:"parallel-pool-size"`
 	ParallelPlan                    bool   `mapstructure:"parallel-plan"`
 	ParallelApply                   bool   `mapstructure:"parallel-apply"`


Test patch:
diff --git a/cmd/server_test.go b/cmd/server_test.go
index 96e174c970..5402b28ce5 100644
--- a/cmd/server_test.go
+++ b/cmd/server_test.go
@@ -109,6 +109,7 @@ var testFlags = map[string]interface{}{
 	LockingDBType:                    "boltdb",
 	LogLevelFlag:                     "debug",
 	MarkdownTemplateOverridesDirFlag: "/path2",
+	MaxCommentsPerCommand:            10,
 	StatsNamespace:                   "atlantis",
 	AllowDraftPRs:                    true,
 	PortFlag:                         8181,
diff --git a/server/events/vcs/common/common_test.go b/server/events/vcs/common/common_test.go
index 246bd49855..1f9d8e9d00 100644
--- a/server/events/vcs/common/common_test.go
+++ b/server/events/vcs/common/common_test.go
@@ -24,7 +24,7 @@ import (
 // If under the maximum number of chars, we shouldn't split the comments.
 func TestSplitComment_UnderMax(t *testing.T) {
 	comment := "comment under max size"
-	split := common.SplitComment(comment, len(comment)+1, "sepEnd", "sepStart")
+	split := common.SplitComment(comment, len(comment)+1, "sepEnd", "sepStart", 0, "")
 	Equals(t, []string{comment}, split)
 }
 
@@ -34,11 +34,11 @@ func TestSplitComment_TwoComments(t *testing.T) {
 	comment := strings.Repeat("a", 1000)
 	sepEnd := "-sepEnd"
 	sepStart := "-sepStart"
-	split := common.SplitComment(comment, len(comment)-1, sepEnd, sepStart)
+	split := common.SplitComment(comment, len(comment)-1, sepEnd, sepStart, 0, "")
 
 	expCommentLen := len(comment) - len(sepEnd) - len(sepStart) - 1
-	expFirstComment := comment[:expCommentLen]
-	expSecondComment := comment[expCommentLen:]
+	expFirstComment := comment[:len(comment)-expCommentLen]
+	expSecondComment := comment[len(comment)-expCommentLen:]
 	Equals(t, 2, len(split))
 	Equals(t, expFirstComment+sepEnd, split[0])
 	Equals(t, sepStart+expSecondComment, split[1])
@@ -51,14 +51,31 @@ func TestSplitComment_FourComments(t *testing.T) {
 	sepEnd := "-sepEnd"
 	sepStart := "-sepStart"
 	max := (len(comment) / 4) + len(sepEnd) + len(sepStart)
-	split := common.SplitComment(comment, max, sepEnd, sepStart)
+	split := common.SplitComment(comment, max, sepEnd, sepStart, 0, "")
 
 	expMax := len(comment) / 4
 	Equals(t, []string{
-		comment[:expMax] + sepEnd,
-		sepStart + comment[expMax:expMax*2] + sepEnd,
-		sepStart + comment[expMax*2:expMax*3] + sepEnd,
-		sepStart + comment[expMax*3:]}, split)
+		comment[:len(comment)-expMax*3] + sepEnd,
+		sepStart + comment[len(comment)-expMax*3:len(comment)-expMax*2] + sepEnd,
+		sepStart + comment[len(comment)-expMax*2:len(comment)-expMax] + sepEnd,
+		sepStart + comment[len(comment)-expMax:]}, split)
+}
+
+func TestSplitComment_Limited(t *testing.T) {
+	comment := strings.Repeat("a", 1000)
+	sepEnd := "-sepEnd"
+	sepStart := "-sepStart"
+	truncationHeader := "truncated-"
+	max := (len(comment) / 8) + max(len(sepEnd), len(truncationHeader)) + len(sepStart)
+	split := common.SplitComment(comment, max, sepEnd, sepStart, 5, truncationHeader)
+
+	expMax := len(comment) / 8
+	Equals(t, []string{
+		truncationHeader + comment[len(comment)-expMax*5:len(comment)-expMax*4] + sepEnd,
+		sepStart + comment[len(comment)-expMax*4:len(comment)-expMax*3] + sepEnd,
+		sepStart + comment[len(comment)-expMax*3:len(comment)-expMax*2] + sepEnd,
+		sepStart + comment[len(comment)-expMax*2:len(comment)-expMax] + sepEnd,
+		sepStart + comment[len(comment)-expMax:]}, split)
 }
 
 func TestAutomergeCommitMsg(t *testing.T) {
diff --git a/server/events/vcs/gh_app_creds_rotator_test.go b/server/events/vcs/gh_app_creds_rotator_test.go
index fd0536f206..8aa65c707a 100644
--- a/server/events/vcs/gh_app_creds_rotator_test.go
+++ b/server/events/vcs/gh_app_creds_rotator_test.go
@@ -20,7 +20,7 @@ func Test_githubAppTokenRotator_GenerateJob(t *testing.T) {
 	Ok(t, err)
 
 	anonCreds := &vcs.GithubAnonymousCredentials{}
-	anonClient, err := vcs.NewGithubClient(testServer, anonCreds, vcs.GithubConfig{}, logging.NewNoopLogger(t))
+	anonClient, err := vcs.NewGithubClient(testServer, anonCreds, vcs.GithubConfig{}, 0, logging.NewNoopLogger(t))
 	Ok(t, err)
 	tempSecrets, err := anonClient.ExchangeCode(logger, "good-code")
 	Ok(t, err)
diff --git a/server/events/vcs/github_client_internal_test.go b/server/events/vcs/github_client_internal_test.go
index 8809b0a44a..798264d269 100644
--- a/server/events/vcs/github_client_internal_test.go
+++ b/server/events/vcs/github_client_internal_test.go
@@ -22,14 +22,14 @@ import (
 
 // If the hostname is github.com, should use normal BaseURL.
 func TestNewGithubClient_GithubCom(t *testing.T) {
-	client, err := NewGithubClient("github.com", &GithubUserCredentials{"user", "pass"}, GithubConfig{}, logging.NewNoopLogger(t))
+	client, err := NewGithubClient("github.com", &GithubUserCredentials{"user", "pass"}, GithubConfig{}, 0, logging.NewNoopLogger(t))
 	Ok(t, err)
 	Equals(t, "https://api.github.com/", client.client.BaseURL.String())
 }
 
 // If the hostname is a non-github hostname should use the right BaseURL.
 func TestNewGithubClient_NonGithub(t *testing.T) {
-	client, err := NewGithubClient("example.com", &GithubUserCredentials{"user", "pass"}, GithubConfig{}, logging.NewNoopLogger(t))
+	client, err := NewGithubClient("example.com", &GithubUserCredentials{"user", "pass"}, GithubConfig{}, 0, logging.NewNoopLogger(t))
 	Ok(t, err)
 	Equals(t, "https://example.com/api/v3/", client.client.BaseURL.String())
 	// If possible in the future, test the GraphQL client's URL as well. But at the
diff --git a/server/events/vcs/github_client_test.go b/server/events/vcs/github_client_test.go
index e4cad22359..44ad33a1c4 100644
--- a/server/events/vcs/github_client_test.go
+++ b/server/events/vcs/github_client_test.go
@@ -63,7 +63,7 @@ func TestGithubClient_GetModifiedFiles(t *testing.T) {
 
 	testServerURL, err := url.Parse(testServer.URL)
 	Ok(t, err)
-	client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logger)
+	client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, 0, logger)
 	Ok(t, err)
 	defer disableSSLVerification()()
 
@@ -121,7 +121,7 @@ func TestGithubClient_GetModifiedFilesMovedFile(t *testing.T) {
 
 	testServerURL, err := url.Parse(testServer.URL)
 	Ok(t, err)
-	client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
+	client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, 0, logging.NewNoopLogger(t))
 	Ok(t, err)
 	defer disableSSLVerification()()
 
@@ -218,7 +218,7 @@ func TestGithubClient_PaginatesComments(t *testing.T) {
 	testServerURL, err := url.Parse(testServer.URL)
 	Ok(t, err)
 
-	client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
+	client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, 0, logging.NewNoopLogger(t))
 	Ok(t, err)
 	defer disableSSLVerification()()
 
@@ -334,7 +334,7 @@ func TestGithubClient_HideOldComments(t *testing.T) {
 			testServerURL, err := url.Parse(testServer.URL)
 			Ok(t, err)
 
-			client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{atlantisUser, "pass"}, vcs.GithubConfig{},
+			client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{atlantisUser, "pass"}, vcs.GithubConfig{}, 0,
 				logging.NewNoopLogger(t))
 			Ok(t, err)
 			defer disableSSLVerification()()
@@ -407,7 +407,7 @@ func TestGithubClient_UpdateStatus(t *testing.T) {
 
 			testServerURL, err := url.Parse(testServer.URL)
 			Ok(t, err)
-			client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
+			client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, 0, logging.NewNoopLogger(t))
 			Ok(t, err)
 			defer disableSSLVerification()()
 
@@ -496,7 +496,7 @@ func TestGithubClient_PullIsApproved(t *testing.T) {
 
 	testServerURL, err := url.Parse(testServer.URL)
 	Ok(t, err)
-	client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
+	client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, 0, logging.NewNoopLogger(t))
 	Ok(t, err)
 	defer disableSSLVerification()()
 
@@ -614,7 +614,7 @@ func TestGithubClient_PullIsMergeable(t *testing.T) {
 				}))
 			testServerURL, err := url.Parse(testServer.URL)
 			Ok(t, err)
-			client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
+			client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, 0, logging.NewNoopLogger(t))
 			Ok(t, err)
 			defer disableSSLVerification()()
 
@@ -768,7 +768,7 @@ func TestGithubClient_PullIsMergeableWithAllowMergeableBypassApply(t *testing.T)
 				}))
 			testServerURL, err := url.Parse(testServer.URL)
 			Ok(t, err)
-			client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{AllowMergeableBypassApply: true}, logging.NewNoopLogger(t))
+			client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{AllowMergeableBypassApply: true}, 0, logging.NewNoopLogger(t))
 			Ok(t, err)
 			defer disableSSLVerification()()
 
@@ -879,7 +879,7 @@ func TestGithubClient_PullIsMergeableWithAllowMergeableBypassApplyButWithNoBranc
 				}))
 			testServerURL, err := url.Parse(testServer.URL)
 			Ok(t, err)
-			client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{AllowMergeableBypassApply: true}, logging.NewNoopLogger(t))
+			client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{AllowMergeableBypassApply: true}, 0, logging.NewNoopLogger(t))
 			Ok(t, err)
 			defer disableSSLVerification()()
 
@@ -964,7 +964,7 @@ func TestGithubClient_MergePullHandlesError(t *testing.T) {
 
 			testServerURL, err := url.Parse(testServer.URL)
 			Ok(t, err)
-			client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
+			client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, 0, logging.NewNoopLogger(t))
 			Ok(t, err)
 			defer disableSSLVerification()()
 
@@ -1089,7 +1089,7 @@ func TestGithubClient_MergePullCorrectMethod(t *testing.T) {
 
 			testServerURL, err := url.Parse(testServer.URL)
 			Ok(t, err)
-			client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
+			client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, 0, logging.NewNoopLogger(t))
 			Ok(t, err)
 			defer disableSSLVerification()()
 
@@ -1118,7 +1118,7 @@ func TestGithubClient_MergePullCorrectMethod(t *testing.T) {
 }
 
 func TestGithubClient_MarkdownPullLink(t *testing.T) {
-	client, err := vcs.NewGithubClient("hostname", &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
+	client, err := vcs.NewGithubClient("hostname", &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, 0, logging.NewNoopLogger(t))
 	Ok(t, err)
 	pull := models.PullRequest{Num: 1}
 	s, _ := client.MarkdownPullLink(pull)
@@ -1174,7 +1174,7 @@ func TestGithubClient_SplitComments(t *testing.T) {
 
 	testServerURL, err := url.Parse(testServer.URL)
 	Ok(t, err)
-	client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
+	client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, 0, logging.NewNoopLogger(t))
 	Ok(t, err)
 	defer disableSSLVerification()()
 	pull := models.PullRequest{Num: 1}
@@ -1233,7 +1233,7 @@ func TestGithubClient_Retry404(t *testing.T) {
 
 	testServerURL, err := url.Parse(testServer.URL)
 	Ok(t, err)
-	client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
+	client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, 0, logging.NewNoopLogger(t))
 	Ok(t, err)
 	defer disableSSLVerification()()
 	repo := models.Repo{
@@ -1279,7 +1279,7 @@ func TestGithubClient_Retry404Files(t *testing.T) {
 
 	testServerURL, err := url.Parse(testServer.URL)
 	Ok(t, err)
-	client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
+	client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, 0, logging.NewNoopLogger(t))
 	Ok(t, err)
 	defer disableSSLVerification()()
 	repo := models.Repo{
@@ -1332,7 +1332,7 @@ func TestGithubClient_GetTeamNamesForUser(t *testing.T) {
 		}))
 	testServerURL, err := url.Parse(testServer.URL)
 	Ok(t, err)
-	client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logger)
+	client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, 0, logger)
 	Ok(t, err)
 	defer disableSSLVerification()()
 
@@ -1530,7 +1530,7 @@ func TestGithubClient_DiscardReviews(t *testing.T) {
 				}))
 			testServerURL, err := url.Parse(testServer.URL)
 			Ok(t, err)
-			client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
+			client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, 0, logging.NewNoopLogger(t))
 			Ok(t, err)
 			defer disableSSLVerification()()
 			if err := client.DiscardReviews(tt.args.repo, tt.args.pull); (err != nil) != tt.wantErr {
@@ -1599,7 +1599,7 @@ func TestGithubClient_GetPullLabels(t *testing.T) {
 		}))
 	testServerURL, err := url.Parse(testServer.URL)
 	Ok(t, err)
-	client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logger)
+	client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, 0, logger)
 	Ok(t, err)
 	defer disableSSLVerification()()
 
@@ -1636,7 +1636,7 @@ func TestGithubClient_GetPullLabels_EmptyResponse(t *testing.T) {
 		}))
 	testServerURL, err := url.Parse(testServer.URL)
 	Ok(t, err)
-	client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logger)
+	client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, 0, logger)
 	Ok(t, err)
 	defer disableSSLVerification()()
 
diff --git a/server/events/vcs/github_credentials_test.go b/server/events/vcs/github_credentials_test.go
index f5a4d33f0a..e1a0f67e8c 100644
--- a/server/events/vcs/github_credentials_test.go
+++ b/server/events/vcs/github_credentials_test.go
@@ -16,7 +16,7 @@ func TestGithubClient_GetUser_AppSlug(t *testing.T) {
 	Ok(t, err)
 
 	anonCreds := &vcs.GithubAnonymousCredentials{}
-	anonClient, err := vcs.NewGithubClient(testServer, anonCreds, vcs.GithubConfig{}, logging.NewNoopLogger(t))
+	anonClient, err := vcs.NewGithubClient(testServer, anonCreds, vcs.GithubConfig{}, 0, logging.NewNoopLogger(t))
 	Ok(t, err)
 	tempSecrets, err := anonClient.ExchangeCode(logger, "good-code")
 	Ok(t, err)
@@ -41,7 +41,7 @@ func TestGithubClient_AppAuthentication(t *testing.T) {
 	Ok(t, err)
 
 	anonCreds := &vcs.GithubAnonymousCredentials{}
-	anonClient, err := vcs.NewGithubClient(testServer, anonCreds, vcs.GithubConfig{}, logging.NewNoopLogger(t))
+	anonClient, err := vcs.NewGithubClient(testServer, anonCreds, vcs.GithubConfig{}, 0, logging.NewNoopLogger(t))
 	Ok(t, err)
 	tempSecrets, err := anonClient.ExchangeCode(logger, "good-code")
 	Ok(t, err)
@@ -51,7 +51,7 @@ func TestGithubClient_AppAuthentication(t *testing.T) {
 		Key:      []byte(testdata.GithubPrivateKey),
 		Hostname: testServer,
 	}
-	_, err = vcs.NewGithubClient(testServer, appCreds, vcs.GithubConfig{}, logging.NewNoopLogger(t))
+	_, err = vcs.NewGithubClient(testServer, appCreds, vcs.GithubConfig{}, 0, logging.NewNoopLogger(t))
 	Ok(t, err)
 
 	token, err := appCreds.GetToken()
@@ -77,7 +77,7 @@ func TestGithubClient_MultipleAppAuthentication(t *testing.T) {
 	Ok(t, err)
 
 	anonCreds := &vcs.GithubAnonymousCredentials{}
-	anonClient, err := vcs.NewGithubClient(testServer, anonCreds, vcs.GithubConfig{}, logging.NewNoopLogger(t))
+	anonClient, err := vcs.NewGithubClient(testServer, anonCreds, vcs.GithubConfig{}, 0, logging.NewNoopLogger(t))
 	Ok(t, err)
 	tempSecrets, err := anonClient.ExchangeCode(logger, "good-code")
 	Ok(t, err)
@@ -88,7 +88,7 @@ func TestGithubClient_MultipleAppAuthentication(t *testing.T) {
 		Key:            []byte(testdata.GithubPrivateKey),
 		Hostname:       testServer,
 	}
-	_, err = vcs.NewGithubClient(testServer, appCreds, vcs.GithubConfig{}, logging.NewNoopLogger(t))
+	_, err = vcs.NewGithubClient(testServer, appCreds, vcs.GithubConfig{}, 0, logging.NewNoopLogger(t))
 	Ok(t, err)
 
 	token, err := appCreds.GetToken()