Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Enable hide-prev-plan-comments Feature for BitBucket Cloud #4495

Merged
merged 17 commits into from
Jan 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion runatlantis.io/docs/access-credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ A new permission for `Actions` has been added, which is required for checking if

* Create an App Password by following [BitBucket Cloud: Create an app password](https://support.atlassian.com/bitbucket-cloud/docs/create-an-app-password/)
* Label the password "atlantis"
* Select **Pull requests**: **Read** and **Write** so that Atlantis can read your pull requests and write comments to them
* 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.
* Record the access token

### Bitbucket Server (aka Stash)
Expand Down
11 changes: 8 additions & 3 deletions runatlantis.io/docs/server-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -863,9 +863,14 @@ based on the organization or user that triggered the webhook.
```

Hide previous plan comments to declutter PRs. This is only supported in
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.
For github, ensure the `--gh-user` is set appropriately or comments will not be hidden.

GitHub and GitLab and Bitbucket currently and is not enabled by default.

For Bitbucket, the comments are deleted rather than hidden as Bitbucket does not support hiding comments.

For GitHub, ensure the `--gh-user` is set appropriately or comments will not be hidden.

When using the GitHub App, you need to set `--gh-app-slug` to enable this feature.

### `--hide-unchanged-plan-comments`

```bash
Expand Down
89 changes: 87 additions & 2 deletions server/events/vcs/bitbucketcloud/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"strings"
"unicode/utf8"

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

var MY_UUID = ""

// 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 (b *Client) GetModifiedFiles(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) {
Expand Down Expand Up @@ -107,10 +110,92 @@ func (b *Client) ReactToComment(_ logging.SimpleLogging, _ models.Repo, _ int, _
return nil
}

func (b *Client) HidePrevCommandComments(_ logging.SimpleLogging, _ models.Repo, _ int, _ string, _ string) error {
func (b *Client) HidePrevCommandComments(logger logging.SimpleLogging, repo models.Repo, pullNum int, command string, _ string) error {
// there is no way to hide comment, so delete them instead
me, err := b.GetMyUUID()
if err != nil {
return errors.Wrapf(err, "Cannot get my uuid! Please check required scope of the auth token!")
}
logger.Debug("My bitbucket user UUID is: %s", me)

comments, err := b.GetPullRequestComments(repo, pullNum)
if err != nil {
return err
}

for _, c := range comments {
logger.Debug("Comment is %v", c.Content.Raw)
if strings.EqualFold(*c.User.UUID, me) {
// do the same crude filtering as github client does
body := strings.Split(c.Content.Raw, "\n")
logger.Debug("Body is %s", body)
if len(body) == 0 {
continue
}
firstLine := strings.ToLower(body[0])
if strings.Contains(firstLine, strings.ToLower(command)) {
// we found our old comment that references that command
logger.Debug("Deleting comment with id %s", *c.ID)
err = b.DeletePullRequestComment(repo, pullNum, *c.ID)
if err != nil {
return err
}
}
}
}
return nil
}

func (b *Client) DeletePullRequestComment(repo models.Repo, pullNum int, commentId int) error {
path := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d/comments/%d", b.BaseURL, repo.FullName, pullNum, commentId)
_, err := b.makeRequest("DELETE", path, nil)
if err != nil {
return err
}
return nil
}

func (b *Client) GetPullRequestComments(repo models.Repo, pullNum int) (comments []PullRequestComment, err error) {
path := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d/comments", b.BaseURL, repo.FullName, pullNum)
res, err := b.makeRequest("GET", path, nil)
if err != nil {
return comments, err
}

var pulls PullRequestComments
if err := json.Unmarshal(res, &pulls); err != nil {
return comments, errors.Wrapf(err, "Could not parse response %q", string(res))
}
return pulls.Values, nil
}

func (b *Client) GetMyUUID() (uuid string, err error) {
ragne marked this conversation as resolved.
Show resolved Hide resolved
if MY_UUID == "" {
path := fmt.Sprintf("%s/2.0/user", b.BaseURL)
resp, err := b.makeRequest("GET", path, nil)

if err != nil {
return uuid, err
}

var user User
if err := json.Unmarshal(resp, &user); err != nil {
return uuid, errors.Wrapf(err, "Could not parse response %q", string(resp))
}

if err := validator.New().Struct(user); err != nil {
return uuid, errors.Wrapf(err, "API response %q was missing a field", string(resp))
}

uuid = *user.UUID
MY_UUID = uuid

return uuid, nil
} else {
return MY_UUID, nil
}
}

// PullIsApproved returns true if the merge request was approved.
func (b *Client) PullIsApproved(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) (approvalStatus models.ApprovalStatus, err error) {
path := fmt.Sprintf("%s/2.0/repositories/%s/pullrequests/%d", b.BaseURL, repo.FullName, pull.Num)
Expand Down Expand Up @@ -254,7 +339,7 @@ func (b *Client) makeRequest(method string, path string, reqBody io.Reader) ([]b
defer resp.Body.Close() // nolint: errcheck
requestStr := fmt.Sprintf("%s %s", method, path)

if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("making request %q unexpected status code: %d, body: %s", requestStr, resp.StatusCode, string(respBody))
}
Expand Down
157 changes: 157 additions & 0 deletions server/events/vcs/bitbucketcloud/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/runatlantis/atlantis/server/events/models"
Expand Down Expand Up @@ -367,3 +368,159 @@ func TestClient_MarkdownPullLink(t *testing.T) {
exp := "#1"
Equals(t, exp, s)
}

func TestClient_GetMyUUID(t *testing.T) {
json, err := os.ReadFile(filepath.Join("testdata", "user.json"))
Ok(t, err)

testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.RequestURI {
case "/2.0/user":
w.Write(json) // nolint: errcheck
return
default:
t.Errorf("got unexpected request at %q", r.RequestURI)
http.Error(w, "not found", http.StatusNotFound)
return
}
}))
defer testServer.Close()

client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io")
client.BaseURL = testServer.URL
v, _ := client.GetMyUUID()
Equals(t, v, "{00000000-0000-0000-0000-000000000001}")
}

func TestClient_GetComment(t *testing.T) {
json, err := os.ReadFile(filepath.Join("testdata", "comments.json"))
Ok(t, err)

testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.RequestURI {
case "/2.0/repositories/myorg/myrepo/pullrequests/5/comments":
w.Write(json) // nolint: errcheck
return
default:
t.Errorf("got unexpected request at %q", r.RequestURI)
http.Error(w, "not found", http.StatusNotFound)
return
}
}))
defer testServer.Close()

client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io")
client.BaseURL = testServer.URL
v, _ := client.GetPullRequestComments(
models.Repo{
FullName: "myorg/myrepo",
Owner: "owner",
Name: "myrepo",
CloneURL: "",
SanitizedCloneURL: "",
VCSHost: models.VCSHost{
Type: models.BitbucketCloud,
Hostname: "bitbucket.org",
},
}, 5)

Equals(t, len(v), 5)
exp := "Plan"
Assert(t, strings.Contains(v[1].Content.Raw, exp), "Comment should contain word \"%s\", has \"%s\"", exp, v[1].Content.Raw)
}

func TestClient_DeleteComment(t *testing.T) {
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.RequestURI {
case "/2.0/repositories/myorg/myrepo/pullrequests/5/comments/1":
if r.Method == "DELETE" {
w.WriteHeader(http.StatusNoContent)
}
return
default:
t.Errorf("got unexpected request at %q", r.RequestURI)
http.Error(w, "not found", http.StatusNotFound)
return
}
}))
defer testServer.Close()

client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io")
client.BaseURL = testServer.URL
err := client.DeletePullRequestComment(
models.Repo{
FullName: "myorg/myrepo",
Owner: "owner",
Name: "myrepo",
CloneURL: "",
SanitizedCloneURL: "",
VCSHost: models.VCSHost{
Type: models.BitbucketCloud,
Hostname: "bitbucket.org",
},
}, 5, 1)
Ok(t, err)
}

func TestClient_HidePRComments(t *testing.T) {
logger := logging.NewNoopLogger(t)
comments, err := os.ReadFile(filepath.Join("testdata", "comments.json"))
Ok(t, err)
json, err := os.ReadFile(filepath.Join("testdata", "user.json"))
Ok(t, err)

called := 0

testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.RequestURI {
// we have two comments in the test file
// The code is going to delete them all and then create a new one
case "/2.0/repositories/myorg/myrepo/pullrequests/5/comments/498931882":
if r.Method == "DELETE" {
w.WriteHeader(http.StatusNoContent)
}
w.Write([]byte("")) // nolint: errcheck
called += 1
return
// This is the second one
case "/2.0/repositories/myorg/myrepo/pullrequests/5/comments/498931784":
if r.Method == "DELETE" {
http.Error(w, "", http.StatusNoContent)
}
w.Write([]byte("")) // nolint: errcheck
called += 1
return
case "/2.0/repositories/myorg/myrepo/pullrequests/5/comments/49893111":
Assert(t, r.Method != "DELETE", "Shouldn't delete this one")
return
case "/2.0/repositories/myorg/myrepo/pullrequests/5/comments":
w.Write(comments) // nolint: errcheck
return
case "/2.0/user":
w.Write(json) // nolint: errcheck
return
default:
t.Errorf("got unexpected request at %q", r.RequestURI)
http.Error(w, "not found", http.StatusNotFound)
return
}
}))
defer testServer.Close()

client := bitbucketcloud.NewClient(http.DefaultClient, "user", "pass", "runatlantis.io")
client.BaseURL = testServer.URL
err = client.HidePrevCommandComments(logger,
models.Repo{
FullName: "myorg/myrepo",
Owner: "owner",
Name: "myrepo",
CloneURL: "",
SanitizedCloneURL: "",
VCSHost: models.VCSHost{
Type: models.BitbucketCloud,
Hostname: "bitbucket.org",
},
}, 5, "plan", "")
Ok(t, err)
Equals(t, 2, called)
}
28 changes: 28 additions & 0 deletions server/events/vcs/bitbucketcloud/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,34 @@ type Repository struct {
FullName *string `json:"full_name,omitempty" validate:"required"`
Links Links `json:"links,omitempty" validate:"required"`
}

type User struct {
Type *string `json:"type,omitempty" validate:"required"`
CreateOn *string `json:"created_on" validate:"required"`
DisplayName *string `json:"display_name" validate:"required"`
Username *string `json:"username" validate:"required"`
UUID *string `json:"uuid" validate:"required"`
}

type UserInComment struct {
Type *string `json:"type,omitempty" validate:"required"`
Nickname *string `json:"nickname" validate:"required"`
DisplayName *string `json:"display_name" validate:"required"`
UUID *string `json:"uuid" validate:"required"`
}

type PullRequestComment struct {
ID *int `json:"id,omitempty" validate:"required"`
User *UserInComment `json:"user" validate:"required"`
Content *struct {
Raw string `json:"raw"`
} `json:"content" validate:"required"`
}

type PullRequestComments struct {
Values []PullRequestComment `json:"values,omitempty"`
}

type PullRequest struct {
ID *int `json:"id,omitempty" validate:"required"`
Source *BranchMeta `json:"source,omitempty" validate:"required"`
Expand Down
Loading
Loading