diff --git a/bitbucket-pipelines.yml b/bitbucket-pipelines.yml new file mode 100644 index 000000000..3bcb7e6fa --- /dev/null +++ b/bitbucket-pipelines.yml @@ -0,0 +1,30 @@ +image: golang + +pipelines: + pull-requests: + '**': + - step: + name: 'build Terramate' + script: + - make build + - go install github.com/hashicorp/hc-install/cmd/hc-install@latest + - hc-install install -version 1.6.0 terraform + - cp ./terraform /usr/local/bin/terraform + - export TMC_API_HOST=api.stg.terramate.io + - export TMC_API_DEBUG=1 + - export TM_CLOUD_ORGANIZATION=test + - ./bin/terramate script run --tags golang --target linux-go-packages --parallel 12 preview + - ./bin/terramate script run --tags e2etests --target linux-e2e --parallel 12 preview + branches: + main: + - step: + name: 'build Terramate' + script: + - make build + - go install github.com/hashicorp/hc-install/cmd/hc-install@latest + - hc-install install -version 1.6.0 terraform + - cp ./terraform /usr/local/bin/terraform + - export TMC_API_HOST=api.stg.terramate.io + - export TMC_API_DEBUG=1 + - export TM_CLOUD_ORGANIZATION=test + - ./bin/terramate script run --tags golang --target linux-go-packages --parallel 12 deploy diff --git a/ci/ci.go b/ci/ci.go index 28b0f3d10..2ee3fbd62 100644 --- a/ci/ci.go +++ b/ci/ci.go @@ -79,6 +79,51 @@ func DetectPlatformFromEnv() PlatformType { return typ } +func (plat PlatformType) String() string { + switch plat { + case PlatformLocal: + return "local" + case PlatformGithub: + return "github" + case PlatformGitlab: + return "gitlab" + case PlatformGenericCI: + return "generic" + case PlatformAppVeyor: + return "appveyor" + case PlatformAzureDevops: + return "azuredevops" + case PlatformBamboo: + return "bamboo" + case PlatformBitBucket: + return "bitbucket" + case PlatformBuddyWorks: + return "buddyworks" + case PlatformBuildKite: + return "buildkite" + case PlatformCircleCI: + return "circleci" + case PlatformCirrus: + return "cirrus" + case PlatformCodeBuild: + return "codebuild" + case PlatformHeroku: + return "heroku" + case PlatformHudson: + return "hudson" + case PlatformJenkins: + return "jenkins" + case PlatformMyGet: + return "myget" + case PlatformTeamCity: + return "teamcity" + case PlatformTravis: + return "travis" + default: + return "unknown" + } +} + func isEnvVarSet(key string) bool { val := os.Getenv(key) return val != "" && val != "0" && val != "false" diff --git a/cloud/types.go b/cloud/types.go index 050948863..05b052e3e 100644 --- a/cloud/types.go +++ b/cloud/types.go @@ -163,6 +163,7 @@ type ( GitMetadata GithubMetadata GitlabMetadata + BitbucketMetadata } // GitMetadata are the git related metadata. @@ -277,6 +278,29 @@ type ( GitlabCICDMergeRequestApproved *bool `json:"gitlab_cicd_merge_request_approved,omitempty"` // CI_MERGE_REQUEST_APPROVED } + // BitbucketMetadata holds the Bitbucket specific metadata. + BitbucketMetadata struct { + BitbucketPipelinesBuildNumber string `json:"bitbucket_pipelines_build_number,omitempty"` + BitbucketPipelinesPipelineUUID string `json:"bitbucket_pipelines_pipeline_uuid,omitempty"` + BitbucketPipelinesCommit string `json:"bitbucket_pipelines_commit,omitempty"` + BitbucketPipelinesWorkspace string `json:"bitbucket_pipelines_workspace,omitempty"` + BitbucketPipelinesRepoSlug string `json:"bitbucket_pipelines_repo_slug,omitempty"` + BitbucketPipelinesRepoUUID string `json:"bitbucket_pipelines_repo_uuid,omitempty"` + BitbucketPipelinesRepoFullName string `json:"bitbucket_pipelines_repo_full_name,omitempty"` + BitbucketPipelinesBranch string `json:"bitbucket_pipelines_branch,omitempty"` + BitbucketPipelinesDestinationBranch string `json:"bitbucket_pipelines_destination_branch,omitempty"` + BitbucketPipelinesTag string `json:"bitbucket_pipelines_tag,omitempty"` // only available in tag events. + BitbucketPipelinesStepTriggererUUID string `json:"bitbucket_pipelines_step_triggerer_uuid,omitempty"` + BitbucketPipelinesParallelStep string `json:"bitbucket_pipelines_parallel_step,omitempty"` + BitbucketPipelinesParallelStepCount string `json:"bitbucket_pipelines_parallel_step_count,omitempty"` + BitbucketPipelinesPRID string `json:"bitbucket_pipelines_pr_id,omitempty"` // only available in PR events. + BitbucketPipelinesStepUUID string `json:"bitbucket_pipelines_step_uuid,omitempty"` + BitbucketPipelinesDeploymentEnvironment string `json:"bitbucket_pipelines_deployment_environment,omitempty"` + BitbucketPipelinesDeploymentEnvironmentUUID string `json:"bitbucket_pipelines_deployment_environment_uuid,omitempty"` + BitbucketPipelinesProjectKey string `json:"bitbucket_pipelines_project_key,omitempty"` + BitbucketPipelinesProjectUUID string `json:"bitbucket_pipelines_project_uuid,omitempty"` + } + // ReviewRequest is the review_request object. ReviewRequest struct { Platform string `json:"platform"` @@ -299,7 +323,7 @@ type ( ChecksSuccessCount int `json:"checks_success_count"` CreatedAt *time.Time `json:"created_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"` - PushedAt *time.Time `json:"pushed_at,omitempty"` + PushedAt any `json:"pushed_at,omitempty"` Branch string `json:"branch"` BaseBranch string `json:"base_branch"` } diff --git a/cmd/terramate/cli/bitbucket/bitbucket.go b/cmd/terramate/cli/bitbucket/bitbucket.go new file mode 100644 index 000000000..1cfdb2f12 --- /dev/null +++ b/cmd/terramate/cli/bitbucket/bitbucket.go @@ -0,0 +1,233 @@ +// Copyright 2024 Terramate GmbH +// SPDX-License-Identifier: MPL-2.0 + +package bitbucket + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/terramate-io/terramate/errors" +) + +type ( + // Client is a Bitbucket Cloud client. + Client struct { + // BaseURL is the base URL used to construct the final URL of endpoints. + // For Bitbucket Cloud, it should be https://api.bitbucket.org/2.0 + BaseURL string + + // HTTPClient is the HTTP client used to make requests. + // if not set, a new http.Client is used on each request. + HTTPClient *http.Client + + // Token is the Bitbucket Cloud token. + Token string + + // Workspace is the Bitbucket Cloud workspace. + Workspace string + + // RepoSlug is the Bitbucket Cloud repository slug. + RepoSlug string + } + + // PRs is a list of Bitbucket Pull Requests. + PRs []PR + + // RenderedContent is the rendered version of the PR content. + RenderedContent struct { + Raw string `json:"raw"` + Markup string `json:"markup"` + HTML string `json:"html"` + } + + // Rendered is the rendered version of the PR metadata. + Rendered struct { + Title RenderedContent `json:"title"` + Description RenderedContent `json:"description"` + Reason RenderedContent `json:"reason"` + } + + // Summary is a Bitbucket Pull Request summary. + Summary RenderedContent + + // Actor is a Bitbucket actor. + Actor struct { + Type string `json:"type"` + } + + // Commit is a Bitbucket commit. + Commit struct { + Hash string `json:"hash"` + } + + // Branch is a Bitbucket branch. + Branch struct { + Name string `json:"name"` + MergeStrategies []string `json:"merge_strategies"` + DefaultMergeStrategy string `json:"default_merge_strategy"` + } + + // TargetBranch is the source or destination branch of a pull request. + TargetBranch struct { + Repository struct { + Type string `json:"type"` + } + Branch Branch `json:"branch"` + Commit Commit `json:"commit"` + } + + // PR is a Bitbucket Pull Request. + PR struct { + Type string `json:"type"` + ID int `json:"id"` + Title string `json:"title"` + Rendered Rendered `json:"rendered"` + Summary Summary `json:"summary"` + State string `json:"state"` + Author Actor `json:"author"` + Source TargetBranch `json:"source"` + Destination TargetBranch `json:"destination"` + MergeCommit Commit `json:"merge_commit"` + CommentCount int `json:"comment_count"` + TaskCount int `json:"task_count"` + CloseSourceBranch bool `json:"close_source_branch"` + ClosedBy *Actor `json:"closed_by,omitempty"` + Reason string `json:"reason"` + CreatedOn string `json:"created_on"` + UpdatedOn string `json:"updated_on"` + Reviewers []Actor `json:"reviewers"` + Participants []Actor `json:"participants"` + + Links struct { + HTML struct { + Href string `json:"href"` + } `json:"html"` + } + } + + // PullRequestResponse is the response of a pull request list. + PullRequestResponse struct { + Size int `json:"size"` + Page int `json:"page"` + PageLen int `json:"pagelen"` + Next string `json:"next"` + Previous string `json:"previous"` + Values []PR `json:"values"` + } +) + +// GetPullRequestsByCommit fetches a list of pull requests that contain the given commit. +// TODO: implement pagination. +func (c *Client) GetPullRequestsByCommit(ctx context.Context, commit string) (prs []PR, err error) { + url := fmt.Sprintf("%s/repositories/%s/%s/commit/%s/pullrequests", + c.baseURL(), c.Workspace, c.RepoSlug, commit) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if c.HTTPClient == nil { + c.HTTPClient = &http.Client{} + } + + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + + defer func() { + err = errors.L(err, resp.Body.Close()).AsError() + }() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errors.E(err, "reading response body") + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d (%s)", resp.StatusCode, data) + } + + var prResp PullRequestResponse + err = json.Unmarshal(data, &prResp) + if err != nil { + return nil, errors.E(err, "unmarshaling PR list") + } + + // fetch each PR details. + for i, pr := range prResp.Values { + pr, err := c.GetPullRequest(pr.ID) + if err != nil { + return nil, fmt.Errorf("failed to get PR details: %w", err) + } + prResp.Values[i] = pr + } + + return prResp.Values, nil +} + +// GetPullRequest fetches a pull request by its ID. +func (c *Client) GetPullRequest(id int) (pr PR, err error) { + url := fmt.Sprintf("%s/repositories/%s/%s/pullrequests/%d", + c.baseURL(), c.Workspace, c.RepoSlug, id) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return PR{}, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + if c.HTTPClient == nil { + c.HTTPClient = &http.Client{} + } + + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return PR{}, fmt.Errorf("failed to execute request: %w", err) + } + + defer func() { + err = errors.L(err, resp.Body.Close()).AsError() + }() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return PR{}, errors.E(err, "reading response body") + } + + if resp.StatusCode != http.StatusOK { + return PR{}, fmt.Errorf("unexpected status code: %d (%s)", resp.StatusCode, data) + } + + err = json.Unmarshal(data, &pr) + if err != nil { + return PR{}, errors.E(err, "unmarshaling PR") + } + + return pr, nil +} + +func (c *Client) baseURL() string { + if c.BaseURL != "" { + return c.BaseURL + } + + c.BaseURL = "https://api.bitbucket.org/2.0" + return c.BaseURL +} diff --git a/cmd/terramate/cli/bitbucket/bitbucket_smoke_test.go b/cmd/terramate/cli/bitbucket/bitbucket_smoke_test.go new file mode 100644 index 000000000..3bfe1ea4f --- /dev/null +++ b/cmd/terramate/cli/bitbucket/bitbucket_smoke_test.go @@ -0,0 +1,53 @@ +// Copyright 2024 Terramate GmbH +// SPDX-License-Identifier: MPL-2.0 + +package bitbucket_test + +import ( + "context" + "os" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/terramate-io/terramate/cmd/terramate/cli/bitbucket" +) + +func TestBitbucketPRSmoke(t *testing.T) { + if os.Getenv("BITBUCKET_BUILD_NUMBER") == "" { + t.Skip("skipping test; not running in Bitbucket pipeline") + } + client := bitbucket.Client{ + BaseURL: "https://api.bitbucket.org/2.0", + Workspace: "terramate", + RepoSlug: "terramate", + Token: os.Getenv("TM_TEST_BITBUCKET_TOKEN"), + } + // Replace with actual workspace, repo, and commit values + prs, err := client.GetPullRequestsByCommit( + context.TODO(), + "944fab3a920d9af4190577ee1e4a94b028038ef6", + ) + if err != nil { + t.Fatal(err) + } + + if len(prs) == 0 { + t.Fatal("no pull requests found") + } + + pr := prs[0] + expected := bitbucket.PR{ + Type: "pullrequest", + ID: 1, + Title: "chore: add basic bitbucket pipeline.", + MergeCommit: bitbucket.Commit{ + Hash: "944fab3a920d9af4190577ee1e4a94b028038ef6", + }, + CreatedOn: "2024-03-14T00:30:37.308Z", + UpdatedOn: "2024-03-14T11:36:22.029Z", + } + + if diff := cmp.Diff(expected, pr); diff != "" { + t.Fatal(diff) + } +} diff --git a/cmd/terramate/cli/bitbucket/bitbucket_test.go b/cmd/terramate/cli/bitbucket/bitbucket_test.go new file mode 100644 index 000000000..95625a69d --- /dev/null +++ b/cmd/terramate/cli/bitbucket/bitbucket_test.go @@ -0,0 +1,153 @@ +// Copyright 2024 Terramate GmbH +// SPDX-License-Identifier: MPL-2.0 + +package bitbucket_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/terramate-io/terramate/cmd/terramate/cli/bitbucket" +) + +func TestGetPullRequestsByCommit(t *testing.T) { + // Mock response data + mockPR := bitbucket.PR{ + Type: "pullrequest", + ID: 108, + Title: "Feature: Add new functionality", + Rendered: bitbucket.Rendered{ + Title: bitbucket.RenderedContent{ + Raw: "Feature: Add new functionality", + Markup: "markdown", + HTML: "
Implements new feature XYZ
", + }, + Reason: bitbucket.RenderedContent{ + Raw: "Ready for review", + Markup: "markdown", + HTML: "Ready for review
", + }, + }, + Summary: bitbucket.Summary{ + Raw: "Feature implementation summary", + Markup: "markdown", + HTML: "Feature implementation summary
", + }, + State: "OPEN", + Author: bitbucket.Actor{ + Type: "user", + }, + Source: bitbucket.TargetBranch{Branch: bitbucket.Branch{Name: "feature-branch"}}, + Destination: bitbucket.TargetBranch{Branch: bitbucket.Branch{Name: "main"}}, + MergeCommit: bitbucket.Commit{ + Hash: "abc123def456", + }, + CommentCount: 5, + TaskCount: 3, + CloseSourceBranch: true, + ClosedBy: &bitbucket.Actor{Type: "user"}, + Reason: "Ready for review", + CreatedOn: "2024-03-14T10:00:00Z", + UpdatedOn: "2024-03-14T11:30:00Z", + Reviewers: []bitbucket.Actor{ + {Type: "user"}, + {Type: "user"}, + }, + Participants: []bitbucket.Actor{ + {Type: "user"}, + {Type: "user"}, + }, + } + + mockResponse := struct { + Values []bitbucket.PR `json:"values"` + }{ + Values: []bitbucket.PR{mockPR}, + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/repositories/workspace/repo/commit/") { + assert.Equal(t, "/repositories/workspace/repo/commit/abc123/pullrequests", r.URL.Path) + assert.Equal(t, http.MethodGet, r.Method) + + w.Header().Set("Content-Type", "application/json") + assert.NoError(t, json.NewEncoder(w).Encode(mockResponse)) + } else if strings.HasPrefix(r.URL.Path, "/repositories/workspace/repo/pullrequests/") { + assert.Equal(t, "/repositories/workspace/repo/pullrequests/108", r.URL.Path) + assert.Equal(t, http.MethodGet, r.Method) + + w.Header().Set("Content-Type", "application/json") + assert.NoError(t, json.NewEncoder(w).Encode(mockPR)) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + // Test cases + tests := []struct { + name string + workspace string + repoSlug string + commit string + wantErr bool + }{ + { + name: "successful request", + workspace: "workspace", + repoSlug: "repo", + commit: "abc123", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := bitbucket.Client{ + BaseURL: ts.URL, + Workspace: tt.workspace, + RepoSlug: tt.repoSlug, + } + prs, err := client.GetPullRequestsByCommit(context.TODO(), tt.commit) + + if tt.wantErr { + assert.Error(t, err) + assert.Nil(t, prs) + return + } + + assert.NoError(t, err) + assert.Len(t, prs, 1) + assert.Equal(t, mockPR.ID, prs[0].ID) + assert.Equal(t, mockPR.Title, prs[0].Title) + assert.Equal(t, mockPR.State, prs[0].State) + }) + } +} + +func TestGetPullRequestsByCommitError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer ts.Close() + + client := bitbucket.Client{ + BaseURL: ts.URL, + Workspace: "workspace", + RepoSlug: "repo", + } + prs, err := client.GetPullRequestsByCommit(context.TODO(), "abc123") + + assert.Error(t, err) + assert.Nil(t, prs) +} diff --git a/cmd/terramate/cli/bitbucket/doc.go b/cmd/terramate/cli/bitbucket/doc.go new file mode 100644 index 000000000..231f7eb1e --- /dev/null +++ b/cmd/terramate/cli/bitbucket/doc.go @@ -0,0 +1,5 @@ +// Copyright 2024 Terramate GmbH +// SPDX-License-Identifier: MPL-2.0 + +// Package bitbucket implements the client for Bitbucket Cloud. +package bitbucket diff --git a/cmd/terramate/cli/cloud.go b/cmd/terramate/cli/cloud.go index b17137819..e148c3400 100644 --- a/cmd/terramate/cli/cloud.go +++ b/cmd/terramate/cli/cloud.go @@ -9,7 +9,7 @@ import ( stdfmt "fmt" "net/url" "os" - "strconv" + "strings" "time" @@ -19,15 +19,18 @@ import ( "github.com/hashicorp/go-uuid" "github.com/rs/zerolog/log" githubql "github.com/shurcooL/githubv4" + "github.com/terramate-io/terramate/ci" "github.com/terramate-io/terramate/cloud" "github.com/terramate-io/terramate/cloud/deployment" "github.com/terramate-io/terramate/cloud/drift" "github.com/terramate-io/terramate/cloud/preview" "github.com/terramate-io/terramate/cloud/stack" + "github.com/terramate-io/terramate/cmd/terramate/cli/bitbucket" "github.com/terramate-io/terramate/cmd/terramate/cli/clitest" tmgithub "github.com/terramate-io/terramate/cmd/terramate/cli/github" "github.com/terramate-io/terramate/cmd/terramate/cli/gitlab" tel "github.com/terramate-io/terramate/cmd/terramate/cli/telemetry" + "github.com/terramate-io/terramate/strconv" "golang.org/x/oauth2" @@ -41,10 +44,11 @@ import ( ) const ( - defaultCloudTimeout = 60 * time.Second - defaultGoogleTimeout = defaultCloudTimeout - defaultGithubTimeout = defaultCloudTimeout - defaultGitlabTimeout = defaultCloudTimeout + defaultCloudTimeout = 60 * time.Second + defaultGoogleTimeout = defaultCloudTimeout + defaultGithubTimeout = defaultCloudTimeout + defaultGitlabTimeout = defaultCloudTimeout + defaultBitbucketTimeout = defaultCloudTimeout ) const ( @@ -56,6 +60,7 @@ const ( const githubDomain = "github.com" const gitlabDomain = "gitlab.com" +const bitbucketDomain = "bitbucket.org" const ( githubErrNotFound errors.Kind = "resource not found (HTTP Status: 404)" @@ -127,7 +132,7 @@ type cloudRunState struct { stackMeta2PreviewIDs map[string]string reviewRequest *cloud.ReviewRequest rrEvent struct { - pushedAt *time.Time + pushedAt *int64 commitSHA string } metadata *cloud.DeploymentMetadata @@ -637,13 +642,25 @@ func (c *cli) detectCloudMetadata() { printer.Stderr.WarnWithDetails("skipping fetch of review_request information", err) return } - switch r.Host { - case githubDomain: + switch c.prj.ciPlatform() { + case ci.PlatformGithub: c.detectGithubMetadata(r.Owner, r.Name) - case gitlabDomain: + case ci.PlatformGitlab: c.detectGitlabMetadata(r.Owner, r.Name) + case ci.PlatformBitBucket: + c.detectBitbucketMetadata(r.Owner, r.Name) + case ci.PlatformLocal: + // in case of running locally, we collect the metadata based on the repository host. + switch r.Host { + case githubDomain: + c.detectGithubMetadata(r.Owner, r.Name) + case gitlabDomain: + c.detectGitlabMetadata(r.Owner, r.Name) + case bitbucketDomain: + c.detectBitbucketMetadata(r.Owner, r.Name) + } default: - logger.Debug().Msgf("Skipping metadata collection for git provider: %s", r.Host) + logger.Debug().Msgf("Skipping metadata collection for ci provider: %s", c.prj.ciPlatform()) } } @@ -681,7 +698,11 @@ func (c *cli) detectGithubMetadata(owner, reponame string) { } else { logger.Debug().Msg("got pull_request details from GITHUB_EVENT_PATH") pushedAt := prFromEvent.GetHead().GetRepo().GetPushedAt() - c.cloud.run.rrEvent.pushedAt = pushedAt.GetTime() + var pushedInt int64 + if t := pushedAt.GetTime(); t != nil { + pushedInt = t.Unix() + } + c.cloud.run.rrEvent.pushedAt = &pushedInt c.cloud.run.rrEvent.commitSHA = prFromEvent.GetHead().GetSHA() prNumber = prFromEvent.GetNumber() } @@ -787,7 +808,7 @@ func (c *cli) detectGitlabMetadata(group string, projectName string) { } if idStr := os.Getenv("CI_PROJECT_ID"); idStr != "" { - client.ProjectID, _ = strconv.Atoi(idStr) + client.ProjectID, _ = strconv.Atoi64(idStr) } if gitlabAPIURL := os.Getenv("TM_GITLAB_API_URL"); gitlabAPIURL != "" { @@ -841,13 +862,91 @@ func (c *cli) detectGitlabMetadata(group string, projectName string) { } } if !pushedAt.IsZero() { - c.cloud.run.rrEvent.pushedAt = &pushedAt + pushedAtInt := pushedAt.Unix() + c.cloud.run.rrEvent.pushedAt = &pushedAtInt } c.cloud.run.rrEvent.commitSHA = mr.SHA c.cloud.run.reviewRequest = c.newGitlabReviewRequest(mr) } +func (c *cli) detectBitbucketMetadata(owner, reponame string) { + logger := log.With(). + Str("normalized_repository", c.prj.prettyRepo()). + Str("head_commit", c.prj.headCommit()). + Str("action", "detectBitbucketMetadata"). + Logger() + + md := c.cloud.run.metadata + + c.setBitbucketPipelinesMetadata(md) + + if md.BitbucketPipelinesBuildNumber == "" { + printer.Stderr.Warn("No Bitbucket CI build number detected. Skipping metadata collection.") + return + } + + token := os.Getenv("BITBUCKET_TOKEN") + if token == "" { + printer.Stderr.WarnWithDetails( + "Export BITBUCKET_TOKEN with your Bitbucket access token for enabling metadata collection", + errors.E("No Bitbucket token detected. Some relevant data cannot be collected."), + ) + } + + client := bitbucket.Client{ + HTTPClient: &c.httpClient, + Workspace: owner, + RepoSlug: reponame, + Token: token, + } + + ctx, cancel := context.WithTimeout(context.Background(), defaultBitbucketTimeout) + defer cancel() + prs, err := client.GetPullRequestsByCommit(ctx, md.BitbucketPipelinesCommit) + if err != nil { + printer.Stderr.WarnWithDetails( + "failed to retrieve pull requests associated with commit. "+ + "Note that Bitbucket requires enabling the Pull Requests API in the UI. "+ + "Check our Bitbucket documentation page at https://terramate.io/docs/cli/automation/bitbucket-pipelines/", + err) + + return + } + + if len(prs) == 0 { + printer.Stderr.Warn("No pull requests associated with commit") + return + } + + // check the right PR based on source and destination branches + var pullRequest *bitbucket.PR + for _, pr := range prs { + pr := pr + if pr.Source.Branch.Name == md.BitbucketPipelinesBranch && pr.Destination.Branch.Name == md.BitbucketPipelinesDestinationBranch { + pullRequest = &pr + break + } + } + + if pullRequest == nil { + printer.Stderr.Warn("No pull request found with matching source and destination branches") + return + } + + buildNumber, err := strconv.Atoi64(md.BitbucketPipelinesBuildNumber) + if err != nil { + printer.Stderr.WarnWithDetails("failed to parse Bitbucket CI build number", err) + return + } + + c.cloud.run.rrEvent.pushedAt = &buildNumber + c.cloud.run.rrEvent.commitSHA = md.BitbucketPipelinesCommit + c.cloud.run.reviewRequest = c.newBitbucketReviewRequest(pullRequest) + + logger.Debug().Msg("Bitbucket metadata detected") +} + func (c *cli) setGitlabCIMetadata(md *cloud.DeploymentMetadata) { envBool := func(name string) bool { val := os.Getenv(name) @@ -875,7 +974,8 @@ func (c *cli) setGitlabCIMetadata(md *cloud.DeploymentMetadata) { if err != nil { printer.Stderr.WarnWithDetails("failed to parse CI_PIPELINE_CREATED_AT time", err) } else { - c.cloud.run.rrEvent.pushedAt = &createdAt + createdAtInt := createdAt.Unix() + c.cloud.run.rrEvent.pushedAt = &createdAtInt } var mrApproved *bool if str := os.Getenv("CI_MERGE_REQUEST_APPROVED"); str != "" { @@ -885,6 +985,57 @@ func (c *cli) setGitlabCIMetadata(md *cloud.DeploymentMetadata) { md.GitlabCICDMergeRequestApproved = mrApproved } +func (c *cli) setBitbucketPipelinesMetadata(md *cloud.DeploymentMetadata) { + md.BitbucketPipelinesBuildNumber = os.Getenv("BITBUCKET_BUILD_NUMBER") + md.BitbucketPipelinesPipelineUUID = os.Getenv("BITBUCKET_PIPELINE_UUID") + md.BitbucketPipelinesCommit = os.Getenv("BITBUCKET_COMMIT") + md.BitbucketPipelinesWorkspace = os.Getenv("BITBUCKET_WORKSPACE") + md.BitbucketPipelinesRepoSlug = os.Getenv("BITBUCKET_REPO_SLUG") + md.BitbucketPipelinesRepoUUID = os.Getenv("BITBUCKET_REPO_UUID") + md.BitbucketPipelinesRepoFullName = os.Getenv("BITBUCKET_REPO_FULL_NAME") + md.BitbucketPipelinesBranch = os.Getenv("BITBUCKET_BRANCH") + md.BitbucketPipelinesTag = os.Getenv("BITBUCKET_TAG") + md.BitbucketPipelinesParallelStep = os.Getenv("BITBUCKET_PARALLEL_STEP") + md.BitbucketPipelinesParallelStepCount = os.Getenv("BITBUCKET_PARALLEL_STEP_COUNT") + md.BitbucketPipelinesPRID = os.Getenv("BITBUCKET_PR_ID") + md.BitbucketPipelinesDestinationBranch = os.Getenv("BITBUCKET_PR_DESTINATION_BRANCH") + md.BitbucketPipelinesStepUUID = os.Getenv("BITBUCKET_STEP_UUID") + md.BitbucketPipelinesDeploymentEnvironment = os.Getenv("BITBUCKET_DEPLOYMENT_ENVIRONMENT") + md.BitbucketPipelinesDeploymentEnvironmentUUID = os.Getenv("BITBUCKET_DEPLOYMENT_ENVIRONMENT_UUID") + md.BitbucketPipelinesProjectKey = os.Getenv("BITBUCKET_PROJECT_KEY") + md.BitbucketPipelinesProjectUUID = os.Getenv("BITBUCKET_PROJECT_UUID") + md.BitbucketPipelinesStepTriggererUUID = os.Getenv("BITBUCKET_STEP_TRIGGERER_UUID") +} + +func (c *cli) newBitbucketReviewRequest(pr *bitbucket.PR) *cloud.ReviewRequest { + createdAt, err := time.Parse(time.RFC3339, pr.CreatedOn) + if err != nil { + printer.Stderr.WarnWithDetails("failed to parse PR created_on time", err) + } + updatedAt, err := time.Parse(time.RFC3339, pr.UpdatedOn) + if err != nil { + printer.Stderr.WarnWithDetails("failed to parse PR updated_on time", err) + } + rr := &cloud.ReviewRequest{ + Platform: "bitbucket", + Repository: c.prj.prettyRepo(), + URL: pr.Links.HTML.Href, + Number: pr.ID, + Title: pr.Title, + Description: pr.Summary.Raw, + CommitSHA: c.prj.headCommit(), + Draft: false, // Bitbucket Cloud does not support draft PRs. + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + Status: pr.State, + Author: cloud.Author{}, // TODO(i4k): still not implemented. + Branch: c.cloud.run.metadata.BitbucketPipelinesBranch, + BaseBranch: c.cloud.run.metadata.BitbucketPipelinesDestinationBranch, + // Note(i4k): PushedAt will be set only in previews. + } + return rr +} + func getGithubPRByNumberOrCommit(githubClient *github.Client, ghToken, owner, repo string, number int, commit string) (*github.PullRequest, error) { logger := log.With(). Str("github_repository", owner+"/"+repo). @@ -1232,9 +1383,6 @@ func (c *cli) newGitlabReviewRequest(mr gitlab.MR) *cloud.ReviewRequest { mrUpdatedAt, err := time.Parse(time.RFC3339, mr.UpdatedAt) if err != nil { printer.Stderr.WarnWithDetails("failed to parse MR.updated_at field", err) - - t := *c.cloud.run.rrEvent.pushedAt - mrUpdatedAt = t } var mrCreatedAt *time.Time if mrCreatedAtVal, err := time.Parse(time.RFC3339, mr.CreatedAt); err != nil { diff --git a/cmd/terramate/cli/gitlab/mr.go b/cmd/terramate/cli/gitlab/mr.go index 69bd24953..52f33f23d 100644 --- a/cmd/terramate/cli/gitlab/mr.go +++ b/cmd/terramate/cli/gitlab/mr.go @@ -35,7 +35,7 @@ type ( // Token is the Gitlab token (usually provided by the GITLAB_TOKEN environment variable. Token string - ProjectID int + ProjectID int64 Group string Project string } diff --git a/cmd/terramate/cli/gitlab/mr_test.go b/cmd/terramate/cli/gitlab/mr_test.go index 66db3a22d..c8c0238a9 100644 --- a/cmd/terramate/cli/gitlab/mr_test.go +++ b/cmd/terramate/cli/gitlab/mr_test.go @@ -92,7 +92,7 @@ func checkMethod(t *testing.T, method string, r *http.Request) { assert.EqualStrings(t, method, r.Method) } -func setup(t *testing.T, projectID int) (*http.ServeMux, *gitlab.Client) { +func setup(t *testing.T, projectID int64) (*http.ServeMux, *gitlab.Client) { mux := http.NewServeMux() // server is a test HTTP server used to provide mock API responses. diff --git a/cmd/terramate/cli/run.go b/cmd/terramate/cli/run.go index 2508c1cde..b050d5071 100644 --- a/cmd/terramate/cli/run.go +++ b/cmd/terramate/cli/run.go @@ -45,7 +45,7 @@ const ( // ErrRunCommandNotExecuted represents the error when the command was not executed for whatever reason. ErrRunCommandNotExecuted errors.Kind = "command not found" - cloudSyncPreviewCICDWarning = "--sync-preview is only supported in GitHub Actions workflows or Gitlab CICD pipelines" + cloudSyncPreviewCICDWarning = "--sync-preview is only supported in GitHub Actions workflows, Gitlab CICD pipelines or Bitbucket Cloud Pipelines" ) // stackRun contains a list of tasks to be run per stack. @@ -202,7 +202,7 @@ func (c *cli) runOnStacks() { c.detectCloudMetadata() } - isCICD := os.Getenv("GITHUB_ACTIONS") != "" || os.Getenv("GITLAB_CI") != "" + isCICD := os.Getenv("GITHUB_ACTIONS") != "" || os.Getenv("GITLAB_CI") != "" || os.Getenv("BITBUCKET_BUILD_NUMBER") != "" if c.parsedArgs.Run.SyncPreview && !isCICD { printer.Stderr.Warn(cloudSyncPreviewCICDWarning) c.disableCloudFeatures(errors.E(cloudSyncPreviewCICDWarning)) @@ -816,7 +816,7 @@ func (c *cli) createCloudPreview(runs []stackCloudRun, target, fromTarget string Runs: previewRuns, AffectedStacks: affectedStacksMap, OrgUUID: c.cloud.run.orgUUID, - PushedAt: c.cloud.run.rrEvent.pushedAt.Unix(), + PushedAt: *c.cloud.run.rrEvent.pushedAt, CommitSHA: c.cloud.run.rrEvent.commitSHA, Technology: technology, TechnologyLayer: technologyLayer, diff --git a/cmd/terramate/cli/script_run.go b/cmd/terramate/cli/script_run.go index 585bb2063..7e072669d 100644 --- a/cmd/terramate/cli/script_run.go +++ b/cmd/terramate/cli/script_run.go @@ -197,7 +197,7 @@ func (c *cli) prepareScriptForCloudSync(runs []stackRun) { feats = append(feats, cloudFeatScriptSyncPreview) } - isCI := os.Getenv("GITHUB_ACTIONS") != "" || os.Getenv("GITLAB_CI") != "" + isCI := os.Getenv("GITHUB_ACTIONS") != "" || os.Getenv("GITLAB_CI") != "" || os.Getenv("BITBUCKET_BUILD_NUMBER") != "" if len(previewRuns) > 0 && !isCI { printer.Stderr.Warn(cloudSyncPreviewCICDWarning) c.disableCloudFeatures(errors.E(cloudSyncPreviewCICDWarning))