Repository: runatlantis/atlantis (language: go)

Issue:
feat: Add Support For Hiding Previous Plan Comments on GitLab
<!--- Please keep this note for the community --->

### 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.

<!--- Thank you for keeping this note for the community --->

---

- [X] 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 user of Atlantis, I would like the option to hide previous plan comments on GitLab in the same manner as this is supported on GitHub when the `hide-prev-plan-comments` flag is set.

**Describe the solution you'd like**
<!--
A clear and concise description of what you want to happen. Consider that atlantis is used
by many people, and your particular use case might not make sense to implement in the core.
-->

GitLab does not have the ability to 'hide' a comment, but as a good alternative, I would like to see the previous comments updated with a summary header/footer as follows:

```html
<details><summary>Superseded Atlantis Plan</summary>
Original Plan Comment
</details>
```

This will cause the comments to be collapsed by default, but can be expanded for reference if required.

**Describe the drawbacks of your solution**
<!--
This section is important not only to identify future issues, but also for us to see whether
you thought through your request. When filling it, ask yourself what are the problems we could
have maintaining what you propose. How often will it break?
-->

Small drawback is that the maximum GitLab comment size needs to be reduced by 100 characters to allow the comment to be summarised.

**Describe alternatives you've considered**
<!--
A clear and concise description of any alternative solutions or features you've considered,
and why you think they wouldn't be good enough.
-->

None


Discussion / hints:

Fix patch:
diff --git a/runatlantis.io/docs/server-configuration.md b/runatlantis.io/docs/server-configuration.md
index 5a9271d314..fee0ad5f78 100644
--- a/runatlantis.io/docs/server-configuration.md
+++ b/runatlantis.io/docs/server-configuration.md
@@ -611,7 +611,7 @@ This is useful when you have many projects and want to keep the pull request cle
   ATLANTIS_HIDE_PREV_PLAN_COMMENTS=true
   ```
   Hide previous plan comments to declutter PRs. This is only supported in
-  GitHub currently. This is not enabled by default.
+  GitHub and GitLab currently. This is not enabled by default.
 
 ### `--locking-db-type`
   ```bash
diff --git a/server/events/vcs/gitlab_client.go b/server/events/vcs/gitlab_client.go
index c24e0ad357..463e774b04 100644
--- a/server/events/vcs/gitlab_client.go
+++ b/server/events/vcs/gitlab_client.go
@@ -34,8 +34,9 @@ import (
 )
 
 // gitlabMaxCommentLength is the maximum number of chars allowed by Gitlab in a
-// single comment.
-const gitlabMaxCommentLength = 1000000
+// single comment, reduced by 100 to allow comments to be hidden with a summary header
+// and footer.
+const gitlabMaxCommentLength = 1000000 - 100
 
 type GitlabClient struct {
 	Client *gitlab.Client
@@ -45,6 +46,8 @@ type GitlabClient struct {
 	PollingInterval time.Duration
 	// PollingInterval is the total duration for which to poll, where applicable.
 	PollingTimeout time.Duration
+	// logger
+	logger logging.SimpleLogging
 }
 
 // commonMarkSupported is a version constraint that is true when this version of
@@ -60,6 +63,7 @@ func NewGitlabClient(hostname string, token string, logger logging.SimpleLogging
 	client := &GitlabClient{
 		PollingInterval: time.Second,
 		PollingTimeout:  time.Second * 30,
+		logger:          logger,
 	}
 
 	// Create the client differently depending on the base URL.
@@ -185,6 +189,61 @@ func (g *GitlabClient) ReactToComment(repo models.Repo, commentID int64, reactio
 }
 
 func (g *GitlabClient) HidePrevCommandComments(repo models.Repo, pullNum int, command string) error {
+	var allComments []*gitlab.Note
+
+	nextPage := 0
+	for {
+		g.logger.Debug("/projects/%v/merge_requests/%d/notes", repo.FullName, pullNum)
+		comments, resp, err := g.Client.Notes.ListMergeRequestNotes(repo.FullName, pullNum,
+			&gitlab.ListMergeRequestNotesOptions{
+				Sort:        gitlab.String("asc"),
+				OrderBy:     gitlab.String("created_at"),
+				ListOptions: gitlab.ListOptions{Page: nextPage},
+			})
+		if err != nil {
+			return errors.Wrap(err, "listing comments")
+		}
+		allComments = append(allComments, comments...)
+		if resp.NextPage == 0 {
+			break
+		}
+		nextPage = resp.NextPage
+	}
+
+	currentUser, _, err := g.Client.Users.CurrentUser()
+	if err != nil {
+		return errors.Wrap(err, "error getting currentuser")
+	}
+
+	summaryHeader := fmt.Sprintf("<!--- +-Superseded Command-+ ---><details><summary>Superseded Atlantis %s</summary>", command)
+	summaryFooter := "</details>"
+	lineFeed := "\n"
+
+	for _, comment := range allComments {
+		// Only process non-system comments authored by the Atlantis user
+		if comment.System || (comment.Author.Username != "" && !strings.EqualFold(comment.Author.Username, currentUser.Username)) {
+			continue
+		}
+
+		body := strings.Split(comment.Body, "\n")
+		if len(body) == 0 {
+			continue
+		}
+		firstLine := strings.ToLower(body[0])
+		// Skip processing comments that don't contain the command or contain the summary header in the first line
+		if !strings.Contains(firstLine, strings.ToLower(command)) || firstLine == strings.ToLower(summaryHeader) {
+			continue
+		}
+
+		g.logger.Debug("Updating merge request note: Repo: '%s', MR: '%d', comment ID: '%d'", repo.FullName, pullNum, comment.ID)
+		supersededComment := summaryHeader + lineFeed + comment.Body + lineFeed + summaryFooter + lineFeed
+
+		if _, _, err := g.Client.Notes.UpdateMergeRequestNote(repo.FullName, pullNum, comment.ID,
+			&gitlab.UpdateMergeRequestNoteOptions{Body: &supersededComment}); err != nil {
+			return errors.Wrapf(err, "updating comment %d", comment.ID)
+		}
+	}
+
 	return nil
 }
 


Test patch:
diff --git a/server/events/vcs/gitlab_client_test.go b/server/events/vcs/gitlab_client_test.go
index 2206cfe392..674015c978 100644
--- a/server/events/vcs/gitlab_client_test.go
+++ b/server/events/vcs/gitlab_client_test.go
@@ -6,10 +6,13 @@ import (
 	"io"
 	"net/http"
 	"net/http/httptest"
+	"path"
+	"strings"
 	"testing"
 	"time"
 
 	version "github.com/hashicorp/go-version"
+	"github.com/runatlantis/atlantis/server/events/command"
 	"github.com/runatlantis/atlantis/server/events/models"
 	"github.com/runatlantis/atlantis/server/logging"
 	gitlab "github.com/xanzy/go-gitlab"
@@ -429,6 +432,123 @@ func TestGitlabClient_MarkdownPullLink(t *testing.T) {
 	Equals(t, exp, s)
 }
 
+func TestGitlabClient_HideOldComments(t *testing.T) {
+	type notePutCallDetails struct {
+		noteID  string
+		comment []string
+	}
+	type jsonBody struct {
+		Body string
+	}
+
+	authorID := 1
+	authorUserName := "pipin"
+	authorEmail := "admin@example.com"
+	pullNum := 123
+
+	userCommentIDs := [1]string{"1"}
+	planCommentIDs := [2]string{"3", "5"}
+	systemCommentIDs := [1]string{"4"}
+	summaryCommentIDs := [1]string{"2"}
+	planComments := [3]string{"plan comment 1", "plan comment 2", "plan comment 3"}
+	summaryHeader := fmt.Sprintf("<!--- +-Superseded Command-+ ---><details><summary>Superseded Atlantis %s</summary>",
+		command.Plan.TitleString())
+	summaryFooter := "</details>"
+	lineFeed := "\\n"
+
+	issueResp := "[" +
+		fmt.Sprintf(`{"id":%s,"body":"User comment","author":{"id": %d, "username":"%s", "email":"%s"},"system": false,"project_id": %d}`,
+			userCommentIDs[0], authorID, authorUserName, authorEmail, pullNum) + "," +
+		fmt.Sprintf(`{"id":%s,"body":"%s","author":{"id": %d, "username":"%s", "email":"%s"},"system": false,"project_id": %d}`,
+			summaryCommentIDs[0], summaryHeader+lineFeed+planComments[2]+lineFeed+summaryFooter, authorID, authorUserName, authorEmail, pullNum) + "," +
+		fmt.Sprintf(`{"id":%s,"body":"%s","author":{"id": %d, "username":"%s", "email":"%s"},"system": false,"project_id": %d}`,
+			planCommentIDs[0], planComments[0], authorID, authorUserName, authorEmail, pullNum) + "," +
+		fmt.Sprintf(`{"id":%s,"body":"System comment","author":{"id": %d, "username":"%s", "email":"%s"},"system": true,"project_id": %d}`,
+			systemCommentIDs[0], authorID, authorUserName, authorEmail, pullNum) + "," +
+		fmt.Sprintf(`{"id":%s,"body":"%s","author":{"id": %d, "username":"%s", "email":"%s"},"system": false,"project_id": %d}`,
+			planCommentIDs[1], planComments[1], authorID, authorUserName, authorEmail, pullNum) +
+		"]"
+
+	gitlabClientUnderTest = true
+	defer func() { gitlabClientUnderTest = false }()
+	gotNotePutCalls := make([]notePutCallDetails, 0, 1)
+	testServer := httptest.NewServer(
+		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			switch r.Method {
+			case "GET":
+				switch r.RequestURI {
+				case "/api/v4/user":
+					w.WriteHeader(http.StatusOK)
+					w.Header().Set("Content-Type", "application/json")
+					response := fmt.Sprintf(`{"id": %d,"username": "%s", "email": "%s"}`, authorID, authorUserName, authorEmail)
+					w.Write([]byte(response)) // nolint: errcheck
+				case fmt.Sprintf("/api/v4/projects/runatlantis%%2Fatlantis/merge_requests/%d/notes?order_by=created_at&sort=asc", pullNum):
+					w.WriteHeader(http.StatusOK)
+					response := issueResp
+					w.Write([]byte(response)) // nolint: errcheck
+				default:
+					t.Errorf("got unexpected request at %q", r.RequestURI)
+					http.Error(w, "not found", http.StatusNotFound)
+				}
+			case "PUT":
+				switch {
+				case strings.HasPrefix(r.RequestURI, fmt.Sprintf("/api/v4/projects/runatlantis%%2Fatlantis/merge_requests/%d/notes/", pullNum)):
+					w.WriteHeader(http.StatusOK)
+					var body jsonBody
+					json.NewDecoder(r.Body).Decode(&body)
+					notePutCallDetail := notePutCallDetails{
+						noteID:  path.Base(r.RequestURI),
+						comment: strings.Split(body.Body, "\n"),
+					}
+					gotNotePutCalls = append(gotNotePutCalls, notePutCallDetail)
+					response := "{}"
+					w.Write([]byte(response)) // nolint: errcheck
+				default:
+					t.Errorf("got unexpected request at %q", r.RequestURI)
+					http.Error(w, "not found", http.StatusNotFound)
+				}
+			default:
+				t.Errorf("got unexpected method at %q", r.Method)
+				http.Error(w, "not found", http.StatusNotFound)
+			}
+		}),
+	)
+
+	internalClient, err := gitlab.NewClient("token", gitlab.WithBaseURL(testServer.URL))
+	Ok(t, err)
+	client := &GitlabClient{
+		Client:  internalClient,
+		Version: nil,
+		logger:  logging.NewNoopLogger(t),
+	}
+
+	repo := models.Repo{
+		FullName: "runatlantis/atlantis",
+		Owner:    "runatlantis",
+		Name:     "atlantis",
+		VCSHost: models.VCSHost{
+			Type:     models.Gitlab,
+			Hostname: "gitlab.com",
+		},
+	}
+
+	err = client.HidePrevCommandComments(repo, pullNum, command.Plan.TitleString())
+	Ok(t, err)
+
+	// Check the correct number of plan comments have been processed
+	Equals(t, len(planCommentIDs), len(gotNotePutCalls))
+	// Check the first plan comment has been currectly summarised
+	Equals(t, planCommentIDs[0], gotNotePutCalls[0].noteID)
+	Equals(t, summaryHeader, gotNotePutCalls[0].comment[0])
+	Equals(t, planComments[0], gotNotePutCalls[0].comment[1])
+	Equals(t, summaryFooter, gotNotePutCalls[0].comment[2])
+	// Check the second plan comment has been currectly summarised
+	Equals(t, planCommentIDs[1], gotNotePutCalls[1].noteID)
+	Equals(t, summaryHeader, gotNotePutCalls[1].comment[0])
+	Equals(t, planComments[1], gotNotePutCalls[1].comment[1])
+	Equals(t, summaryFooter, gotNotePutCalls[1].comment[2])
+}
+
 var mergeSuccess = `{"id":22461274,"iid":13,"project_id":4580910,"title":"Update main.tf","description":"","state":"merged","created_at":"2019-01-15T18:27:29.375Z","updated_at":"2019-01-25T17:28:01.437Z","merged_by":{"id":1755902,"name":"Luke Kysow","username":"lkysow","state":"active","avatar_url":"https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80\u0026d=identicon","web_url":"https://gitlab.com/lkysow"},"merged_at":"2019-01-25T17:28:01.459Z","closed_by":null,"closed_at":null,"target_branch":"patch-1","source_branch":"patch-1-merger","upvotes":0,"downvotes":0,"author":{"id":1755902,"name":"Luke Kysow","username":"lkysow","state":"active","avatar_url":"https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80\u0026d=identicon","web_url":"https://gitlab.com/lkysow"},"assignee":null,"source_project_id":4580910,"target_project_id":4580910,"labels":[],"work_in_progress":false,"milestone":null,"merge_when_pipeline_succeeds":false,"merge_status":"can_be_merged","detailed_merge_status":"mergeable","sha":"cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0","merge_commit_sha":"c9b336f1c71d3e64810b8cfa2abcfab232d6bff6","user_notes_count":0,"discussion_locked":null,"should_remove_source_branch":null,"force_remove_source_branch":false,"web_url":"https://gitlab.com/lkysow/atlantis-example/merge_requests/13","time_stats":{"time_estimate":0,"total_time_spent":0,"human_time_estimate":null,"human_total_time_spent":null},"squash":false,"subscribed":true,"changes_count":"1","latest_build_started_at":null,"latest_build_finished_at":null,"first_deployed_to_production_at":null,"pipeline":null,"diff_refs":{"base_sha":"67cb91d3f6198189f433c045154a885784ba6977","head_sha":"cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0","start_sha":"67cb91d3f6198189f433c045154a885784ba6977"},"merge_error":null,"approvals_before_merge":null}`
 var pipelineSuccess = `{"id": 22461274,"iid": 13,"project_id": 4580910,"title": "Update main.tf","description": "","state": "opened","created_at": "2019-01-15T18:27:29.375Z","updated_at": "2019-01-25T17:28:01.437Z","merged_by": null,"merged_at": null,"closed_by": null,"closed_at": null,"target_branch": "patch-1","source_branch": "patch-1-merger","user_notes_count": 0,"upvotes": 0,"downvotes": 0,"author": {"id": 1755902,"name": "Luke Kysow","username": "lkysow","state": "active","avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80\u0026d=identicon","web_url": "https://gitlab.com/lkysow"},"assignee": null,"reviewers": [],"source_project_id": 4580910,"target_project_id": 4580910,"labels": [],"work_in_progress": false,"milestone": null,"merge_when_pipeline_succeeds": false,"merge_status": "can_be_merged","detailed_merge_status": "mergeable","sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0","merge_commit_sha": null,"squash_commit_sha": null,"discussion_locked": null,"should_remove_source_branch": null,"force_remove_source_branch": true,"reference": "!13","references": {"short": "!13","relative": "!13","full": "lkysow/atlantis-example!13"},"web_url": "https://gitlab.com/lkysow/atlantis-example/merge_requests/13","time_stats": {"time_estimate": 0,"total_time_spent": 0,"human_time_estimate": null,"human_total_time_spent": null},"squash": true,"task_completion_status": {"count": 0,"completed_count": 0},"has_conflicts": false,"blocking_discussions_resolved": true,"approvals_before_merge": null,"subscribed": false,"changes_count": "1","latest_build_started_at": "2019-01-15T18:27:29.375Z","latest_build_finished_at": "2019-01-25T17:28:01.437Z","first_deployed_to_production_at": null,"pipeline": {"id": 488598,"sha": "67cb91d3f6198189f433c045154a885784ba6977","ref": "patch-1-merger","status": "success","created_at": "2019-01-15T18:27:29.375Z","updated_at": "2019-01-25T17:28:01.437Z","web_url": "https://gitlab.com/lkysow/atlantis-example/-/pipelines/488598"},"head_pipeline": {"id": 488598,"sha": "67cb91d3f6198189f433c045154a885784ba6977","ref": "patch-1-merger","status": "success","created_at": "2019-01-15T18:27:29.375Z","updated_at": "2019-01-25T17:28:01.437Z","web_url": "https://gitlab.com/lkysow/atlantis-example/-/pipelines/488598","before_sha": "0000000000000000000000000000000000000000","tag": false,"yaml_errors": null,"user": {"id": 1755902,"name": "Luke Kysow","username": "lkysow","state": "active","avatar_url": "https://secure.gravatar.com/avatar/25fd57e71590fe28736624ff24d41c5f?s=80\u0026d=identicon","web_url": "https://gitlab.com/lkysow"},"started_at": "2019-01-15T18:27:29.375Z","finished_at": "2019-01-25T17:28:01.437Z","committed_at": null,"duration": 31,"coverage": null,"detailed_status": {"icon": "status_success","text": "passed","label": "passed","group": "success","tooltip": "passed","has_details": true,"details_path": "/lkysow/atlantis-example/-/pipelines/488598","illustration": null,"favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png"}},"diff_refs": {"base_sha": "67cb91d3f6198189f433c045154a885784ba6977","head_sha": "cb86d70f464632bdfbe1bb9bc0f2f9d847a774a0","start_sha": "67cb91d3f6198189f433c045154a885784ba6977"},"merge_error": null,"first_contribution": false,"user": {"can_merge": true}}`
 var projectSuccess = `{"id": 4580910,"description": "","name": "atlantis-example","name_with_namespace": "lkysow / atlantis-example","path": "atlantis-example","path_with_namespace": "lkysow/atlantis-example","created_at": "2018-04-30T13:44:28.367Z","default_branch": "patch-1","tag_list": [],"ssh_url_to_repo": "git@gitlab.com:lkysow/atlantis-example.git","http_url_to_repo": "https://gitlab.com/lkysow/atlantis-example.git","web_url": "https://gitlab.com/lkysow/atlantis-example","readme_url": "https://gitlab.com/lkysow/atlantis-example/-/blob/main/README.md","avatar_url": "https://gitlab.com/uploads/-/system/project/avatar/4580910/avatar.png","forks_count": 0,"star_count": 7,"last_activity_at": "2021-06-29T21:10:43.968Z","namespace": {"id": 1,"name": "lkysow","path": "lkysow","kind": "group","full_path": "lkysow","parent_id": 1,"avatar_url": "/uploads/-/system/group/avatar/1651/platform.png","web_url": "https://gitlab.com/groups/lkysow"},"_links": {"self": "https://gitlab.com/api/v4/projects/4580910","issues": "https://gitlab.com/api/v4/projects/4580910/issues","merge_requests": "https://gitlab.com/api/v4/projects/4580910/merge_requests","repo_branches": "https://gitlab.com/api/v4/projects/4580910/repository/branches","labels": "https://gitlab.com/api/v4/projects/4580910/labels","events": "https://gitlab.com/api/v4/projects/4580910/events","members": "https://gitlab.com/api/v4/projects/4580910/members"},"packages_enabled": false,"empty_repo": false,"archived": false,"visibility": "private","resolve_outdated_diff_discussions": false,"container_registry_enabled": false,"container_expiration_policy": {"cadence": "1d","enabled": false,"keep_n": 10,"older_than": "90d","name_regex": ".*","name_regex_keep": null,"next_run_at": "2021-05-01T13:44:28.397Z"},"issues_enabled": true,"merge_requests_enabled": true,"wiki_enabled": false,"jobs_enabled": true,"snippets_enabled": true,"service_desk_enabled": false,"service_desk_address": null,"can_create_merge_request_in": true,"issues_access_level": "private","repository_access_level": "enabled","merge_requests_access_level": "enabled","forking_access_level": "enabled","wiki_access_level": "disabled","builds_access_level": "enabled","snippets_access_level": "enabled","pages_access_level": "private","operations_access_level": "disabled","analytics_access_level": "enabled","emails_disabled": null,"shared_runners_enabled": true,"lfs_enabled": false,"creator_id": 818,"import_status": "none","import_error": null,"open_issues_count": 0,"runners_token": "1234456","ci_default_git_depth": 50,"ci_forward_deployment_enabled": true,"public_jobs": true,"build_git_strategy": "fetch","build_timeout": 3600,"auto_cancel_pending_pipelines": "enabled","build_coverage_regex": null,"ci_config_path": "","shared_with_groups": [],"only_allow_merge_if_pipeline_succeeds": true,"allow_merge_on_skipped_pipeline": false,"restrict_user_defined_variables": false,"request_access_enabled": true,"only_allow_merge_if_all_discussions_are_resolved": true,"remove_source_branch_after_merge": true,"printing_merge_request_link_enabled": true,"merge_method": "merge","suggestion_commit_message": "","auto_devops_enabled": false,"auto_devops_deploy_strategy": "continuous","autoclose_referenced_issues": true,"repository_storage": "default","approvals_before_merge": 0,"mirror": false,"external_authorization_classification_label": null,"marked_for_deletion_at": null,"marked_for_deletion_on": null,"requirements_enabled": false,"compliance_frameworks": [],"permissions": {"project_access": null,"group_access": {"access_level": 50,"notification_level": 3}}}`