Skip to content

Commit c864248

Browse files
authored
feat: Enable hide-prev-plan-comments Feature for BitBucket Cloud (#4495)
Signed-off-by: Simon Heather <[email protected]>
1 parent 5e4a35b commit c864248

File tree

7 files changed

+586
-6
lines changed

7 files changed

+586
-6
lines changed

runatlantis.io/docs/access-credentials.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ A new permission for `Actions` has been added, which is required for checking if
132132

133133
* Create an App Password by following [BitBucket Cloud: Create an app password](https://support.atlassian.com/bitbucket-cloud/docs/create-an-app-password/)
134134
* Label the password "atlantis"
135-
* Select **Pull requests**: **Read** and **Write** so that Atlantis can read your pull requests and write comments to them
135+
* Select **Pull requests**: **Read** and **Write** so that Atlantis can read your pull requests and write comments to them. If you want to enable the [hide-prev-plan-comments](./server-configuration#hide-prev-plan-comments) feature and thus delete old comments, please add **Account**: **Read** as well.
136136
* Record the access token
137137

138138
### Bitbucket Server (aka Stash)

runatlantis.io/docs/server-configuration.md

+8-3
Original file line numberDiff line numberDiff line change
@@ -863,9 +863,14 @@ based on the organization or user that triggered the webhook.
863863
```
864864

865865
Hide previous plan comments to declutter PRs. This is only supported in
866-
GitHub and GitLab currently. This is not enabled by default. When using Github App, you need to set `--gh-app-slug` to enable this feature.
867-
For github, ensure the `--gh-user` is set appropriately or comments will not be hidden.
868-
866+
GitHub and GitLab and Bitbucket currently and is not enabled by default.
867+
868+
For Bitbucket, the comments are deleted rather than hidden as Bitbucket does not support hiding comments.
869+
870+
For GitHub, ensure the `--gh-user` is set appropriately or comments will not be hidden.
871+
872+
When using the GitHub App, you need to set `--gh-app-slug` to enable this feature.
873+
869874
### `--hide-unchanged-plan-comments`
870875

871876
```bash

server/events/vcs/bitbucketcloud/client.go

+87-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"io"
88
"net/http"
9+
"strings"
910
"unicode/utf8"
1011

1112
validator "github.com/go-playground/validator/v10"
@@ -39,6 +40,8 @@ func NewClient(httpClient *http.Client, username string, password string, atlant
3940
}
4041
}
4142

43+
var MY_UUID = ""
44+
4245
// GetModifiedFiles returns the names of files that were modified in the merge request
4346
// relative to the repo root, e.g. parent/child/file.txt.
4447
func (b *Client) GetModifiedFiles(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) {
@@ -107,10 +110,92 @@ func (b *Client) ReactToComment(_ logging.SimpleLogging, _ models.Repo, _ int, _
107110
return nil
108111
}
109112

110-
func (b *Client) HidePrevCommandComments(_ logging.SimpleLogging, _ models.Repo, _ int, _ string, _ string) error {
113+
func (b *Client) HidePrevCommandComments(logger logging.SimpleLogging, repo models.Repo, pullNum int, command string, _ string) error {
114+
// there is no way to hide comment, so delete them instead
115+
me, err := b.GetMyUUID()
116+
if err != nil {
117+
return errors.Wrapf(err, "Cannot get my uuid! Please check required scope of the auth token!")
118+
}
119+
logger.Debug("My bitbucket user UUID is: %s", me)
120+
121+
comments, err := b.GetPullRequestComments(repo, pullNum)
122+
if err != nil {
123+
return err
124+
}
125+
126+
for _, c := range comments {
127+
logger.Debug("Comment is %v", c.Content.Raw)
128+
if strings.EqualFold(*c.User.UUID, me) {
129+
// do the same crude filtering as github client does
130+
body := strings.Split(c.Content.Raw, "\n")
131+
logger.Debug("Body is %s", body)
132+
if len(body) == 0 {
133+
continue
134+
}
135+
firstLine := strings.ToLower(body[0])
136+
if strings.Contains(firstLine, strings.ToLower(command)) {
137+
// we found our old comment that references that command
138+
logger.Debug("Deleting comment with id %s", *c.ID)
139+
err = b.DeletePullRequestComment(repo, pullNum, *c.ID)
140+
if err != nil {
141+
return err
142+
}
143+
}
144+
}
145+
}
146+
return nil
147+
}
148+
149+
func (b *Client) DeletePullRequestComment(repo models.Repo, pullNum int, commentId int) error {
150+
path := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d/comments/%d", b.BaseURL, repo.FullName, pullNum, commentId)
151+
_, err := b.makeRequest("DELETE", path, nil)
152+
if err != nil {
153+
return err
154+
}
111155
return nil
112156
}
113157

158+
func (b *Client) GetPullRequestComments(repo models.Repo, pullNum int) (comments []PullRequestComment, err error) {
159+
path := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d/comments", b.BaseURL, repo.FullName, pullNum)
160+
res, err := b.makeRequest("GET", path, nil)
161+
if err != nil {
162+
return comments, err
163+
}
164+
165+
var pulls PullRequestComments
166+
if err := json.Unmarshal(res, &pulls); err != nil {
167+
return comments, errors.Wrapf(err, "Could not parse response %q", string(res))
168+
}
169+
return pulls.Values, nil
170+
}
171+
172+
func (b *Client) GetMyUUID() (uuid string, err error) {
173+
if MY_UUID == "" {
174+
path := fmt.Sprintf("%s/2.0/user", b.BaseURL)
175+
resp, err := b.makeRequest("GET", path, nil)
176+
177+
if err != nil {
178+
return uuid, err
179+
}
180+
181+
var user User
182+
if err := json.Unmarshal(resp, &user); err != nil {
183+
return uuid, errors.Wrapf(err, "Could not parse response %q", string(resp))
184+
}
185+
186+
if err := validator.New().Struct(user); err != nil {
187+
return uuid, errors.Wrapf(err, "API response %q was missing a field", string(resp))
188+
}
189+
190+
uuid = *user.UUID
191+
MY_UUID = uuid
192+
193+
return uuid, nil
194+
} else {
195+
return MY_UUID, nil
196+
}
197+
}
198+
114199
// PullIsApproved returns true if the merge request was approved.
115200
func (b *Client) PullIsApproved(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) (approvalStatus models.ApprovalStatus, err error) {
116201
path := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d", b.BaseURL, repo.FullName, pull.Num)
@@ -254,7 +339,7 @@ func (b *Client) makeRequest(method string, path string, reqBody io.Reader) ([]b
254339
defer resp.Body.Close() // nolint: errcheck
255340
requestStr := fmt.Sprintf("%s %s", method, path)
256341

257-
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
342+
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
258343
respBody, _ := io.ReadAll(resp.Body)
259344
return nil, fmt.Errorf("making request %q unexpected status code: %d, body: %s", requestStr, resp.StatusCode, string(respBody))
260345
}

server/events/vcs/bitbucketcloud/client_test.go

+157
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net/http/httptest"
77
"os"
88
"path/filepath"
9+
"strings"
910
"testing"
1011

1112
"github.com/runatlantis/atlantis/server/events/models"
@@ -367,3 +368,159 @@ func TestClient_MarkdownPullLink(t *testing.T) {
367368
exp := "#1"
368369
Equals(t, exp, s)
369370
}
371+
372+
func TestClient_GetMyUUID(t *testing.T) {
373+
json, err := os.ReadFile(filepath.Join("testdata", "user.json"))
374+
Ok(t, err)
375+
376+
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
377+
switch r.RequestURI {
378+
case "/2.0/user":
379+
w.Write(json) // nolint: errcheck
380+
return
381+
default:
382+
t.Errorf("got unexpected request at %q", r.RequestURI)
383+
http.Error(w, "not found", http.StatusNotFound)
384+
return
385+
}
386+
}))
387+
defer testServer.Close()
388+
389+
client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io")
390+
client.BaseURL = testServer.URL
391+
v, _ := client.GetMyUUID()
392+
Equals(t, v, "{00000000-0000-0000-0000-000000000001}")
393+
}
394+
395+
func TestClient_GetComment(t *testing.T) {
396+
json, err := os.ReadFile(filepath.Join("testdata", "comments.json"))
397+
Ok(t, err)
398+
399+
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
400+
switch r.RequestURI {
401+
case "/2.0/repositories/myorg/myrepo/pullrequests/5/comments":
402+
w.Write(json) // nolint: errcheck
403+
return
404+
default:
405+
t.Errorf("got unexpected request at %q", r.RequestURI)
406+
http.Error(w, "not found", http.StatusNotFound)
407+
return
408+
}
409+
}))
410+
defer testServer.Close()
411+
412+
client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io")
413+
client.BaseURL = testServer.URL
414+
v, _ := client.GetPullRequestComments(
415+
models.Repo{
416+
FullName: "myorg/myrepo",
417+
Owner: "owner",
418+
Name: "myrepo",
419+
CloneURL: "",
420+
SanitizedCloneURL: "",
421+
VCSHost: models.VCSHost{
422+
Type: models.BitbucketCloud,
423+
Hostname: "bitbucket.org",
424+
},
425+
}, 5)
426+
427+
Equals(t, len(v), 5)
428+
exp := "Plan"
429+
Assert(t, strings.Contains(v[1].Content.Raw, exp), "Comment should contain word \"%s\", has \"%s\"", exp, v[1].Content.Raw)
430+
}
431+
432+
func TestClient_DeleteComment(t *testing.T) {
433+
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
434+
switch r.RequestURI {
435+
case "/2.0/repositories/myorg/myrepo/pullrequests/5/comments/1":
436+
if r.Method == "DELETE" {
437+
w.WriteHeader(http.StatusNoContent)
438+
}
439+
return
440+
default:
441+
t.Errorf("got unexpected request at %q", r.RequestURI)
442+
http.Error(w, "not found", http.StatusNotFound)
443+
return
444+
}
445+
}))
446+
defer testServer.Close()
447+
448+
client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io")
449+
client.BaseURL = testServer.URL
450+
err := client.DeletePullRequestComment(
451+
models.Repo{
452+
FullName: "myorg/myrepo",
453+
Owner: "owner",
454+
Name: "myrepo",
455+
CloneURL: "",
456+
SanitizedCloneURL: "",
457+
VCSHost: models.VCSHost{
458+
Type: models.BitbucketCloud,
459+
Hostname: "bitbucket.org",
460+
},
461+
}, 5, 1)
462+
Ok(t, err)
463+
}
464+
465+
func TestClient_HidePRComments(t *testing.T) {
466+
logger := logging.NewNoopLogger(t)
467+
comments, err := os.ReadFile(filepath.Join("testdata", "comments.json"))
468+
Ok(t, err)
469+
json, err := os.ReadFile(filepath.Join("testdata", "user.json"))
470+
Ok(t, err)
471+
472+
called := 0
473+
474+
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
475+
switch r.RequestURI {
476+
// we have two comments in the test file
477+
// The code is going to delete them all and then create a new one
478+
case "/2.0/repositories/myorg/myrepo/pullrequests/5/comments/498931882":
479+
if r.Method == "DELETE" {
480+
w.WriteHeader(http.StatusNoContent)
481+
}
482+
w.Write([]byte("")) // nolint: errcheck
483+
called += 1
484+
return
485+
// This is the second one
486+
case "/2.0/repositories/myorg/myrepo/pullrequests/5/comments/498931784":
487+
if r.Method == "DELETE" {
488+
http.Error(w, "", http.StatusNoContent)
489+
}
490+
w.Write([]byte("")) // nolint: errcheck
491+
called += 1
492+
return
493+
case "/2.0/repositories/myorg/myrepo/pullrequests/5/comments/49893111":
494+
Assert(t, r.Method != "DELETE", "Shouldn't delete this one")
495+
return
496+
case "/2.0/repositories/myorg/myrepo/pullrequests/5/comments":
497+
w.Write(comments) // nolint: errcheck
498+
return
499+
case "/2.0/user":
500+
w.Write(json) // nolint: errcheck
501+
return
502+
default:
503+
t.Errorf("got unexpected request at %q", r.RequestURI)
504+
http.Error(w, "not found", http.StatusNotFound)
505+
return
506+
}
507+
}))
508+
defer testServer.Close()
509+
510+
client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io")
511+
client.BaseURL = testServer.URL
512+
err = client.HidePrevCommandComments(logger,
513+
models.Repo{
514+
FullName: "myorg/myrepo",
515+
Owner: "owner",
516+
Name: "myrepo",
517+
CloneURL: "",
518+
SanitizedCloneURL: "",
519+
VCSHost: models.VCSHost{
520+
Type: models.BitbucketCloud,
521+
Hostname: "bitbucket.org",
522+
},
523+
}, 5, "plan", "")
524+
Ok(t, err)
525+
Equals(t, 2, called)
526+
}

server/events/vcs/bitbucketcloud/models.go

+28
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,34 @@ type Repository struct {
4545
FullName *string `json:"full_name,omitempty" validate:"required"`
4646
Links Links `json:"links,omitempty" validate:"required"`
4747
}
48+
49+
type User struct {
50+
Type *string `json:"type,omitempty" validate:"required"`
51+
CreateOn *string `json:"created_on" validate:"required"`
52+
DisplayName *string `json:"display_name" validate:"required"`
53+
Username *string `json:"username" validate:"required"`
54+
UUID *string `json:"uuid" validate:"required"`
55+
}
56+
57+
type UserInComment struct {
58+
Type *string `json:"type,omitempty" validate:"required"`
59+
Nickname *string `json:"nickname" validate:"required"`
60+
DisplayName *string `json:"display_name" validate:"required"`
61+
UUID *string `json:"uuid" validate:"required"`
62+
}
63+
64+
type PullRequestComment struct {
65+
ID *int `json:"id,omitempty" validate:"required"`
66+
User *UserInComment `json:"user" validate:"required"`
67+
Content *struct {
68+
Raw string `json:"raw"`
69+
} `json:"content" validate:"required"`
70+
}
71+
72+
type PullRequestComments struct {
73+
Values []PullRequestComment `json:"values,omitempty"`
74+
}
75+
4876
type PullRequest struct {
4977
ID *int `json:"id,omitempty" validate:"required"`
5078
Source *BranchMeta `json:"source,omitempty" validate:"required"`

0 commit comments

Comments
 (0)