Skip to content

Commit

Permalink
feat(tmc): add support for bitbucket pipelines.
Browse files Browse the repository at this point in the history
Signed-off-by: i4k <[email protected]>
  • Loading branch information
i4ki committed Dec 17, 2024
1 parent a9acbb7 commit 6d90efb
Show file tree
Hide file tree
Showing 12 changed files with 692 additions and 15 deletions.
18 changes: 18 additions & 0 deletions bitbucket-pipelines.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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

45 changes: 45 additions & 0 deletions ci/ci.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
24 changes: 24 additions & 0 deletions cloud/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ type (
GitMetadata
GithubMetadata
GitlabMetadata
BitbucketMetadata
}

// GitMetadata are the git related metadata.
Expand Down Expand Up @@ -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_source_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"`
Expand Down
233 changes: 233 additions & 0 deletions cmd/terramate/cli/bitbucket/bitbucket.go
Original file line number Diff line number Diff line change
@@ -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
}
53 changes: 53 additions & 0 deletions cmd/terramate/cli/bitbucket/bitbucket_smoke_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading

0 comments on commit 6d90efb

Please sign in to comment.