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

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
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
9 changes: 4 additions & 5 deletions runatlantis.io/docs/access-credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,10 @@ A new permission for `Actions` has been added, which is required for checking if
* Record the access token

### Bitbucket Cloud (bitbucket.org)

* 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
* Record the access token
- 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. If you want to enable "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
2 changes: 1 addition & 1 deletion runatlantis.io/docs/server-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -833,7 +833,7 @@ 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.
GitHub, GitLab and Bitbucket currently. This is not enabled by default. When using Github App, you need to set `--gh-app-slug` to enable this feature.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you detail that for Bitbucket Cloud, the comments are deleted rather than hidden.


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

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