diff --git a/.github/workflows/merged-pr.yml b/.github/workflows/merged-pr.yml new file mode 100644 index 000000000..0424b8283 --- /dev/null +++ b/.github/workflows/merged-pr.yml @@ -0,0 +1,24 @@ +name: Merged Pull Request +permissions: + pull-requests: write + +# only trigger on pull request closed events +on: + pull_request_target: + types: [ closed ] + +jobs: + merge_job: + # this job will only run if the PR has been merged + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v5 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: "Reminder to the contributor that merged this PR: if your changes have added important functionality or fixed a relevant bug, open a follow-up PR to update CHANGELOG.md with a note on your changes." + }) diff --git a/comment.go b/comment.go new file mode 100644 index 000000000..0f1a069a8 --- /dev/null +++ b/comment.go @@ -0,0 +1,129 @@ +package tfe + +import ( + "context" + "fmt" + "net/url" +) + +// Compile-time proof of interface implementation. +var _ Comments = (*comments)(nil) + +// Comments describes all the comment related methods that the +// Terraform Enterprise API supports. +// +// TFE API docs: +// https://www.terraform.io/docs/cloud/api/comments.html +type Comments interface { + // List all comments of the given run. + List(ctx context.Context, runID string) (*CommentList, error) + + // Read a comment by its ID. + Read(ctx context.Context, commentID string) (*Comment, error) + + // Create a new comment with the given options. + Create(ctx context.Context, runID string, options CommentCreateOptions) (*Comment, error) +} + +// Comments implements Comments. +type comments struct { + client *Client +} + +// CommentList represents a list of comments. +type CommentList struct { + *Pagination + Items []*Comment +} + +// Comment represents a Terraform Enterprise comment. +type Comment struct { + ID string `jsonapi:"primary,comments"` + Body string `jsonapi:"attr,body"` +} + +type CommentCreateOptions struct { + // Type is a public field utilized by JSON:API to + // set the resource type via the field tag. + // It is not a user-defined value and does not need to be set. + // https://jsonapi.org/format/#crud-creating + Type string `jsonapi:"primary,comments"` + + // Required: Body of the comment. + Body string `jsonapi:"attr,body"` +} + +// List all comments of the given run. +func (s *comments) List(ctx context.Context, runID string) (*CommentList, error) { + if !validStringID(&runID) { + return nil, ErrInvalidRunID + } + + u := fmt.Sprintf("runs/%s/comments", url.QueryEscape(runID)) + req, err := s.client.newRequest("GET", u, nil) + if err != nil { + return nil, err + } + + cl := &CommentList{} + err = s.client.do(ctx, req, cl) + if err != nil { + return nil, err + } + + return cl, nil +} + +// Create a new comment with the given options. +func (s *comments) Create(ctx context.Context, runID string, options CommentCreateOptions) (*Comment, error) { + if err := options.valid(); err != nil { + return nil, err + } + + if !validStringID(&runID) { + return nil, ErrInvalidRunID + } + + u := fmt.Sprintf("runs/%s/comments", url.QueryEscape(runID)) + req, err := s.client.newRequest("POST", u, &options) + if err != nil { + return nil, err + } + + comm := &Comment{} + err = s.client.do(ctx, req, comm) + if err != nil { + return nil, err + } + + return comm, err +} + +// Read a comment by its ID. +func (s *comments) Read(ctx context.Context, commentID string) (*Comment, error) { + if !validStringID(&commentID) { + return nil, ErrInvalidCommentID + } + + u := fmt.Sprintf("comments/%s", url.QueryEscape(commentID)) + req, err := s.client.newRequest("GET", u, nil) + if err != nil { + return nil, err + } + + comm := &Comment{} + err = s.client.do(ctx, req, comm) + if err != nil { + return nil, err + } + + return comm, nil +} + +func (o CommentCreateOptions) valid() error { + if !validString(&o.Body) { + return ErrInvalidCommentBody + } + + return nil +} diff --git a/comment_integration_test.go b/comment_integration_test.go new file mode 100644 index 000000000..35aa56d30 --- /dev/null +++ b/comment_integration_test.go @@ -0,0 +1,77 @@ +//go:build integration +// +build integration + +package tfe + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCommentsList(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + defer orgTestCleanup() + + wTest1, wTest1Cleanup := createWorkspace(t, client, orgTest) + defer wTest1Cleanup() + + rTest, rTest1Cleanup := createRun(t, client, wTest1) + defer rTest1Cleanup() + commentBody1 := "1st comment test" + commentBody2 := "2nd comment test" + + t.Run("without comments", func(t *testing.T) { + _, err := client.Comments.List(ctx, rTest.ID) + require.NoError(t, err) + }) + + t.Run("without a valid run", func(t *testing.T) { + cl, err := client.Comments.List(ctx, badIdentifier) + assert.Nil(t, cl) + assert.EqualError(t, err, ErrInvalidRunID.Error()) + }) + + t.Run("create a comment", func(t *testing.T) { + options := CommentCreateOptions{ + Body: commentBody1, + } + cl, err := client.Comments.Create(ctx, rTest.ID, options) + require.NoError(t, err) + assert.Equal(t, commentBody1, cl.Body) + }) + + t.Run("create 2nd comment", func(t *testing.T) { + options := CommentCreateOptions{ + Body: commentBody2, + } + cl, err := client.Comments.Create(ctx, rTest.ID, options) + require.NoError(t, err) + assert.Equal(t, commentBody2, cl.Body) + }) + + t.Run("list comments", func(t *testing.T) { + commentsList, err := client.Comments.List(ctx, rTest.ID) + require.NoError(t, err) + assert.Len(t, commentsList.Items, 2) + assert.Equal(t, true, commentItemsContainsBody(commentsList.Items, commentBody1)) + assert.Equal(t, true, commentItemsContainsBody(commentsList.Items, commentBody2)) + }) +} + +func commentItemsContainsBody(items []*Comment, body string) bool { + hasBody := false + for _, item := range items { + if item.Body == body { + hasBody = true + break + } + } + + return hasBody +} diff --git a/errors.go b/errors.go index d466518e3..46cb387b4 100644 --- a/errors.go +++ b/errors.go @@ -147,6 +147,10 @@ var ( ErrInvalidNotificationTrigger = errors.New("invalid value for notification trigger") ErrInvalidVariableSetID = errors.New("invalid variable set ID") + + ErrInvalidCommentID = errors.New("invalid value for comment ID") + + ErrInvalidCommentBody = errors.New("invalid value for comment body") ) // Missing values for required field/option @@ -254,4 +258,6 @@ var ( ErrRequiredGlobalFlag = errors.New("global flag is required") ErrRequiredWorkspacesList = errors.New("no workspaces list provided") + + ErrCommentBody = errors.New("comment body is required") ) diff --git a/run.go b/run.go index 58fd87c36..796d74ff8 100644 --- a/run.go +++ b/run.go @@ -119,6 +119,7 @@ type Run struct { PolicyChecks []*PolicyCheck `jsonapi:"relation,policy-checks"` TaskStages []*TaskStage `jsonapi:"relation,task-stages,omitempty"` Workspace *Workspace `jsonapi:"relation,workspace"` + Comments []*Comment `jsonapi:"relation,comments"` } // RunActions represents the run actions. diff --git a/tfe.go b/tfe.go index 080ac0a50..cbc88459f 100644 --- a/tfe.go +++ b/tfe.go @@ -110,6 +110,7 @@ type Client struct { AgentPools AgentPools AgentTokens AgentTokens Applies Applies + Comments Comments ConfigurationVersions ConfigurationVersions CostEstimates CostEstimates NotificationConfigurations NotificationConfigurations @@ -253,6 +254,7 @@ func NewClient(cfg *Config) (*Client, error) { client.AgentPools = &agentPools{client: client} client.AgentTokens = &agentTokens{client: client} client.Applies = &applies{client: client} + client.Comments = &comments{client: client} client.ConfigurationVersions = &configurationVersions{client: client} client.CostEstimates = &costEstimates{client: client} client.NotificationConfigurations = ¬ificationConfigurations{client: client}