Skip to content

Commit 3203ee1

Browse files
glasserjamengualbenoittoulme
authored andcommitted
feat: Add --max-comments-per-command configuration (runatlantis#3905)
Co-authored-by: PePe Amengual <[email protected]> Co-authored-by: Benoit Toulme <[email protected]>
1 parent a646d30 commit 3203ee1

16 files changed

+130
-76
lines changed

cmd/server.go

+11-2
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ const (
110110
LockingDBType = "locking-db-type"
111111
LogLevelFlag = "log-level"
112112
MarkdownTemplateOverridesDirFlag = "markdown-template-overrides-dir"
113+
MaxCommentsPerCommand = "max-comments-per-command"
113114
ParallelPoolSize = "parallel-pool-size"
114115
StatsNamespace = "stats-namespace"
115116
AllowDraftPRs = "allow-draft-prs"
@@ -167,6 +168,7 @@ const (
167168
DefaultGitlabHostname = "gitlab.com"
168169
DefaultLockingDBType = "boltdb"
169170
DefaultLogLevel = "info"
171+
DefaultMaxCommentsPerCommand = 100
170172
DefaultParallelPoolSize = 15
171173
DefaultStatsNamespace = "atlantis"
172174
DefaultPort = 4141
@@ -593,6 +595,10 @@ var intFlags = map[string]intFlag{
593595
" If merge base is further behind than this number of commits from any of branches heads, full fetch will be performed.",
594596
defaultValue: DefaultCheckoutDepth,
595597
},
598+
MaxCommentsPerCommand: {
599+
description: "If non-zero, the maximum number of comments to split command output into before truncating.",
600+
defaultValue: DefaultMaxCommentsPerCommand,
601+
},
596602
GiteaPageSizeFlag: {
597603
description: "Optional value that specifies the number of results per page to expect from Gitea.",
598604
defaultValue: DefaultGiteaPageSize,
@@ -783,7 +789,7 @@ func (s *ServerCmd) run() error {
783789
if err := s.Viper.Unmarshal(&userConfig); err != nil {
784790
return err
785791
}
786-
s.setDefaults(&userConfig)
792+
s.setDefaults(&userConfig, s.Viper)
787793

788794
// Now that we've parsed the config we can set our local logger to the
789795
// right level.
@@ -824,7 +830,7 @@ func (s *ServerCmd) run() error {
824830
return server.Start()
825831
}
826832

827-
func (s *ServerCmd) setDefaults(c *server.UserConfig) {
833+
func (s *ServerCmd) setDefaults(c *server.UserConfig, v *viper.Viper) {
828834
if c.AzureDevOpsHostname == "" {
829835
c.AzureDevOpsHostname = DefaultADHostname
830836
}
@@ -873,6 +879,9 @@ func (s *ServerCmd) setDefaults(c *server.UserConfig) {
873879
if c.MarkdownTemplateOverridesDir == "" {
874880
c.MarkdownTemplateOverridesDir = DefaultMarkdownTemplateOverridesDir
875881
}
882+
if !v.IsSet("max-comments-per-command") {
883+
c.MaxCommentsPerCommand = DefaultMaxCommentsPerCommand
884+
}
876885
if c.ParallelPoolSize == 0 {
877886
c.ParallelPoolSize = DefaultParallelPoolSize
878887
}

cmd/server_test.go

+1
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ var testFlags = map[string]interface{}{
109109
LockingDBType: "boltdb",
110110
LogLevelFlag: "debug",
111111
MarkdownTemplateOverridesDirFlag: "/path2",
112+
MaxCommentsPerCommand: 10,
112113
StatsNamespace: "atlantis",
113114
AllowDraftPRs: true,
114115
PortFlag: 8181,

runatlantis.io/docs/server-configuration.md

+8
Original file line numberDiff line numberDiff line change
@@ -903,6 +903,14 @@ This is useful when you have many projects and want to keep the pull request cle
903903

904904
Defaults to the atlantis home directory `/home/atlantis/.markdown_templates/` in `/$HOME/.markdown_templates`.
905905

906+
### `--max-comments-per-command`
907+
```bash
908+
atlantis server --max-comments-per-command=100
909+
# or
910+
ATLANTIS_MAX_COMMENTS_PER_COMMAND=100
911+
```
912+
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.
913+
906914
### `--parallel-apply`
907915

908916
```bash

server/controllers/github_app_controller.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ func (g *GithubAppController) ExchangeCode(w http.ResponseWriter, r *http.Reques
5656
g.Logger.Debug("Exchanging GitHub app code for app credentials")
5757
creds := &vcs.GithubAnonymousCredentials{}
5858
config := vcs.GithubConfig{}
59-
client, err := vcs.NewGithubClient(g.GithubHostname, creds, config, g.Logger)
59+
// This client does not post comments, so we don't need to configure it with maxCommentsPerCommand.
60+
client, err := vcs.NewGithubClient(g.GithubHostname, creds, config, 0, g.Logger)
6061
if err != nil {
6162
g.respond(w, logging.Error, http.StatusInternalServerError, "Failed to exchange code for github app: %s", err)
6263
return

server/events/vcs/azuredevops_client.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ func (g *AzureDevopsClient) CreateComment(logger logging.SimpleLogging, repo mod
107107
// or tested limit in Azure DevOps.
108108
const maxCommentLength = 150000
109109

110-
comments := common.SplitComment(comment, maxCommentLength, sepEnd, sepStart)
110+
comments := common.SplitComment(comment, maxCommentLength, sepEnd, sepStart, 0, "")
111111
owner, project, repoName := SplitAzureDevopsRepoFullName(repo.FullName)
112112

113113
for i := range comments {

server/events/vcs/bitbucketserver/client.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ func (b *Client) GetProjectKey(repoName string, cloneURL string) (string, error)
137137
func (b *Client) CreateComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, comment string, _ string) error {
138138
sepEnd := "\n```\n**Warning**: Output length greater than max comment size. Continued in next comment."
139139
sepStart := "Continued from previous comment.\n```diff\n"
140-
comments := common.SplitComment(comment, maxCommentLength, sepEnd, sepStart)
140+
comments := common.SplitComment(comment, maxCommentLength, sepEnd, sepStart, 0, "")
141141
for _, c := range comments {
142142
if err := b.postComment(repo, pullNum, c); err != nil {
143143
return err

server/events/vcs/common/common.go

+32-21
Original file line numberDiff line numberDiff line change
@@ -12,34 +12,45 @@ func AutomergeCommitMsg(pullNum int) string {
1212
return fmt.Sprintf("[Atlantis] Automatically merging after successful apply: PR #%d", pullNum)
1313
}
1414

15-
// SplitComment splits comment into a slice of comments that are under maxSize.
16-
// It appends sepEnd to all comments that have a following comment.
17-
// It prepends sepStart to all comments that have a preceding comment.
18-
func SplitComment(comment string, maxSize int, sepEnd string, sepStart string) []string {
15+
/*
16+
SplitComment splits comment into a slice of comments that are under maxSize.
17+
- It appends sepEnd to all comments that have a following comment.
18+
- It prepends sepStart to all comments that have a preceding comment.
19+
- If maxCommentsPerCommand is non-zero, it never returns more than maxCommentsPerCommand
20+
comments, and it truncates the beginning of the comment to preserve the end of the comment string,
21+
which usually contains more important information, such as warnings, errors, and the plan summary.
22+
- SplitComment appends the truncationHeader to the first comment if it would have produced more comments.
23+
*/
24+
func SplitComment(comment string, maxSize int, sepEnd string, sepStart string, maxCommentsPerCommand int, truncationHeader string) []string {
1925
if len(comment) <= maxSize {
2026
return []string{comment}
2127
}
2228

23-
maxWithSep := maxSize - len(sepEnd) - len(sepStart)
29+
// No comment contains both sepEnd and truncationHeader, so we only have to count their max.
30+
maxWithSep := maxSize - max(len(sepEnd), len(truncationHeader)) - len(sepStart)
2431
var comments []string
25-
numComments := int(math.Ceil(float64(len(comment)) / float64(maxWithSep)))
26-
for i := 0; i < numComments; i++ {
27-
upTo := min(len(comment), (i+1)*maxWithSep)
28-
portion := comment[i*maxWithSep : upTo]
29-
if i < numComments-1 {
30-
portion += sepEnd
31-
}
32-
if i > 0 {
32+
numPotentialComments := int(math.Ceil(float64(len(comment)) / float64(maxWithSep)))
33+
var numComments int
34+
if maxCommentsPerCommand == 0 {
35+
numComments = numPotentialComments
36+
} else {
37+
numComments = min(numPotentialComments, maxCommentsPerCommand)
38+
}
39+
isTruncated := numComments < numPotentialComments
40+
upTo := len(comment)
41+
for len(comments) < numComments {
42+
downFrom := max(0, upTo-maxWithSep)
43+
portion := comment[downFrom:upTo]
44+
if len(comments)+1 != numComments {
3345
portion = sepStart + portion
46+
} else if len(comments)+1 == numComments && isTruncated {
47+
portion = truncationHeader + portion
48+
}
49+
if len(comments) != 0 {
50+
portion = portion + sepEnd
3451
}
35-
comments = append(comments, portion)
52+
comments = append([]string{portion}, comments...)
53+
upTo = downFrom
3654
}
3755
return comments
3856
}
39-
40-
func min(a, b int) int {
41-
if a < b {
42-
return a
43-
}
44-
return b
45-
}

server/events/vcs/common/common_test.go

+26-9
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import (
2424
// If under the maximum number of chars, we shouldn't split the comments.
2525
func TestSplitComment_UnderMax(t *testing.T) {
2626
comment := "comment under max size"
27-
split := common.SplitComment(comment, len(comment)+1, "sepEnd", "sepStart")
27+
split := common.SplitComment(comment, len(comment)+1, "sepEnd", "sepStart", 0, "")
2828
Equals(t, []string{comment}, split)
2929
}
3030

@@ -34,11 +34,11 @@ func TestSplitComment_TwoComments(t *testing.T) {
3434
comment := strings.Repeat("a", 1000)
3535
sepEnd := "-sepEnd"
3636
sepStart := "-sepStart"
37-
split := common.SplitComment(comment, len(comment)-1, sepEnd, sepStart)
37+
split := common.SplitComment(comment, len(comment)-1, sepEnd, sepStart, 0, "")
3838

3939
expCommentLen := len(comment) - len(sepEnd) - len(sepStart) - 1
40-
expFirstComment := comment[:expCommentLen]
41-
expSecondComment := comment[expCommentLen:]
40+
expFirstComment := comment[:len(comment)-expCommentLen]
41+
expSecondComment := comment[len(comment)-expCommentLen:]
4242
Equals(t, 2, len(split))
4343
Equals(t, expFirstComment+sepEnd, split[0])
4444
Equals(t, sepStart+expSecondComment, split[1])
@@ -51,14 +51,31 @@ func TestSplitComment_FourComments(t *testing.T) {
5151
sepEnd := "-sepEnd"
5252
sepStart := "-sepStart"
5353
max := (len(comment) / 4) + len(sepEnd) + len(sepStart)
54-
split := common.SplitComment(comment, max, sepEnd, sepStart)
54+
split := common.SplitComment(comment, max, sepEnd, sepStart, 0, "")
5555

5656
expMax := len(comment) / 4
5757
Equals(t, []string{
58-
comment[:expMax] + sepEnd,
59-
sepStart + comment[expMax:expMax*2] + sepEnd,
60-
sepStart + comment[expMax*2:expMax*3] + sepEnd,
61-
sepStart + comment[expMax*3:]}, split)
58+
comment[:len(comment)-expMax*3] + sepEnd,
59+
sepStart + comment[len(comment)-expMax*3:len(comment)-expMax*2] + sepEnd,
60+
sepStart + comment[len(comment)-expMax*2:len(comment)-expMax] + sepEnd,
61+
sepStart + comment[len(comment)-expMax:]}, split)
62+
}
63+
64+
func TestSplitComment_Limited(t *testing.T) {
65+
comment := strings.Repeat("a", 1000)
66+
sepEnd := "-sepEnd"
67+
sepStart := "-sepStart"
68+
truncationHeader := "truncated-"
69+
max := (len(comment) / 8) + max(len(sepEnd), len(truncationHeader)) + len(sepStart)
70+
split := common.SplitComment(comment, max, sepEnd, sepStart, 5, truncationHeader)
71+
72+
expMax := len(comment) / 8
73+
Equals(t, []string{
74+
truncationHeader + comment[len(comment)-expMax*5:len(comment)-expMax*4] + sepEnd,
75+
sepStart + comment[len(comment)-expMax*4:len(comment)-expMax*3] + sepEnd,
76+
sepStart + comment[len(comment)-expMax*3:len(comment)-expMax*2] + sepEnd,
77+
sepStart + comment[len(comment)-expMax*2:len(comment)-expMax] + sepEnd,
78+
sepStart + comment[len(comment)-expMax:]}, split)
6279
}
6380

6481
func TestAutomergeCommitMsg(t *testing.T) {

server/events/vcs/gh_app_creds_rotator_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ func Test_githubAppTokenRotator_GenerateJob(t *testing.T) {
2020
Ok(t, err)
2121

2222
anonCreds := &vcs.GithubAnonymousCredentials{}
23-
anonClient, err := vcs.NewGithubClient(testServer, anonCreds, vcs.GithubConfig{}, logging.NewNoopLogger(t))
23+
anonClient, err := vcs.NewGithubClient(testServer, anonCreds, vcs.GithubConfig{}, 0, logging.NewNoopLogger(t))
2424
Ok(t, err)
2525
tempSecrets, err := anonClient.ExchangeCode(logger, "good-code")
2626
Ok(t, err)

server/events/vcs/github_client.go

+18-12
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,12 @@ var (
4141

4242
// GithubClient is used to perform GitHub actions.
4343
type GithubClient struct {
44-
user string
45-
client *github.Client
46-
v4Client *githubv4.Client
47-
ctx context.Context
48-
config GithubConfig
44+
user string
45+
client *github.Client
46+
v4Client *githubv4.Client
47+
ctx context.Context
48+
config GithubConfig
49+
maxCommentsPerCommand int
4950
}
5051

5152
// GithubAppTemporarySecrets holds app credentials obtained from github after creation.
@@ -76,7 +77,8 @@ type GithubPRReviewSummary struct {
7677
}
7778

7879
// NewGithubClient returns a valid GitHub client.
79-
func NewGithubClient(hostname string, credentials GithubCredentials, config GithubConfig, logger logging.SimpleLogging) (*GithubClient, error) {
80+
81+
func NewGithubClient(hostname string, credentials GithubCredentials, config GithubConfig, maxCommentsPerCommand int, logger logging.SimpleLogging) (*GithubClient, error) {
8082
logger.Debug("Creating new GitHub client for host: %s", hostname)
8183
transport, err := credentials.Client()
8284
if err != nil {
@@ -108,11 +110,12 @@ func NewGithubClient(hostname string, credentials GithubCredentials, config Gith
108110
return nil, errors.Wrap(err, "getting user")
109111
}
110112
return &GithubClient{
111-
user: user,
112-
client: client,
113-
v4Client: v4Client,
114-
ctx: context.Background(),
115-
config: config,
113+
user: user,
114+
client: client,
115+
v4Client: v4Client,
116+
ctx: context.Background(),
117+
config: config,
118+
maxCommentsPerCommand: maxCommentsPerCommand,
116119
}, nil
117120
}
118121

@@ -191,7 +194,10 @@ func (g *GithubClient) CreateComment(logger logging.SimpleLogging, repo models.R
191194
"```diff\n"
192195
}
193196

194-
comments := common.SplitComment(comment, maxCommentLength, sepEnd, sepStart)
197+
truncationHeader := "\n```\n</details>" +
198+
"\n<br>\n\n**Warning**: Command output is larger than the maximum number of comments per command. Output truncated.\n\n[..]\n"
199+
200+
comments := common.SplitComment(comment, maxCommentLength, sepEnd, sepStart, g.maxCommentsPerCommand, truncationHeader)
195201
for i := range comments {
196202
_, resp, err := g.client.Issues.CreateComment(g.ctx, repo.Owner, repo.Name, pullNum, &github.IssueComment{Body: &comments[i]})
197203
if resp != nil {

server/events/vcs/github_client_internal_test.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@ import (
2222

2323
// If the hostname is github.com, should use normal BaseURL.
2424
func TestNewGithubClient_GithubCom(t *testing.T) {
25-
client, err := NewGithubClient("github.com", &GithubUserCredentials{"user", "pass"}, GithubConfig{}, logging.NewNoopLogger(t))
25+
client, err := NewGithubClient("github.com", &GithubUserCredentials{"user", "pass"}, GithubConfig{}, 0, logging.NewNoopLogger(t))
2626
Ok(t, err)
2727
Equals(t, "https://api.github.com/", client.client.BaseURL.String())
2828
}
2929

3030
// If the hostname is a non-github hostname should use the right BaseURL.
3131
func TestNewGithubClient_NonGithub(t *testing.T) {
32-
client, err := NewGithubClient("example.com", &GithubUserCredentials{"user", "pass"}, GithubConfig{}, logging.NewNoopLogger(t))
32+
client, err := NewGithubClient("example.com", &GithubUserCredentials{"user", "pass"}, GithubConfig{}, 0, logging.NewNoopLogger(t))
3333
Ok(t, err)
3434
Equals(t, "https://example.com/api/v3/", client.client.BaseURL.String())
3535
// If possible in the future, test the GraphQL client's URL as well. But at the

0 commit comments

Comments
 (0)