diff --git a/docs/components/Bitbucket.mdx b/docs/components/Bitbucket.mdx new file mode 100644 index 000000000..e9b920f82 --- /dev/null +++ b/docs/components/Bitbucket.mdx @@ -0,0 +1,151 @@ +--- +title: "Bitbucket" +--- + +React to events in your Bitbucket repositories + +## Triggers + + + + + +## Instructions + +To configure Bitbucket with SuperPlane: + +- **API Token mode**: + - Go to **Atlassian Settings → Security → Create API token**. + - Select **Bitbucket** App. + - Create a token with admin:workspace:bitbucket scope. + +- **Workspace Access Token mode**: + - Go to **Bitbucket Workspace Settings → Security → Access tokens**. + - Create a workspace access token. + +- **Copy the token** and your workspace slug (for example: `my-workspace`) below. + + + +## On Push + +The On Push trigger starts a workflow execution when code is pushed to a Bitbucket repository. + +### Use Cases + +- **CI/CD automation**: Trigger builds and deployments on code pushes +- **Code quality checks**: Run linting and tests on every push +- **Notification workflows**: Send notifications when code is pushed + +### Configuration + +- **Repository**: Select the Bitbucket repository to monitor +- **Refs**: Configure which branches to monitor (e.g., `refs/heads/main`) + +### Event Data + +Each push event includes: +- **repository**: Repository information +- **push.changes**: Array of reference changes with new/old commit details +- **actor**: Information about who pushed + +### Webhook Setup + +This trigger automatically sets up a Bitbucket webhook when configured. The webhook is managed by SuperPlane and will be cleaned up when the trigger is removed. + +### Example Data + +```json +{ + "actor": { + "display_name": "John Doe", + "links": { + "avatar": { + "href": "https://bitbucket.org/account/johndoe/avatar/" + }, + "html": { + "href": "https://bitbucket.org/johndoe/" + } + }, + "nickname": "johndoe", + "type": "user", + "uuid": "{d301aafa-d676-4ee0-a3f1-8b94c681feaa}" + }, + "push": { + "changes": [ + { + "closed": false, + "commits": [ + { + "author": { + "raw": "John Doe \u003cjohn@example.com\u003e", + "type": "author" + }, + "hash": "709d658dc5b6d6afcd46049c2f332ee3f515a67d", + "links": { + "html": { + "href": "https://bitbucket.org/my-workspace/my-repo/commits/709d658dc5b6d6afcd46049c2f332ee3f515a67d" + } + }, + "message": "Add new feature\n", + "type": "commit" + } + ], + "created": false, + "forced": false, + "new": { + "name": "main", + "target": { + "author": { + "raw": "John Doe \u003cjohn@example.com\u003e", + "type": "author", + "user": { + "display_name": "John Doe", + "type": "user", + "uuid": "{d301aafa-d676-4ee0-a3f1-8b94c681feaa}" + } + }, + "date": "2024-01-15T10:30:00+00:00", + "hash": "709d658dc5b6d6afcd46049c2f332ee3f515a67d", + "links": { + "html": { + "href": "https://bitbucket.org/my-workspace/my-repo/commits/709d658dc5b6d6afcd46049c2f332ee3f515a67d" + } + }, + "message": "Add new feature\n", + "type": "commit" + }, + "type": "branch" + }, + "old": { + "name": "main", + "target": { + "author": { + "raw": "John Doe \u003cjohn@example.com\u003e", + "type": "author" + }, + "date": "2024-01-14T15:00:00+00:00", + "hash": "1e65c05c1d5171631d92438a13901ca7dae9618c", + "message": "Previous commit\n", + "type": "commit" + }, + "type": "branch" + }, + "truncated": false + } + ] + }, + "repository": { + "full_name": "my-workspace/my-repo", + "links": { + "html": { + "href": "https://bitbucket.org/my-workspace/my-repo" + } + }, + "name": "my-repo", + "type": "repository", + "uuid": "{b7f10c3a-2a1e-4c36-af54-7e818f3b6e1d}" + } +} +``` + diff --git a/pkg/integrations/bitbucket/bitbucket.go b/pkg/integrations/bitbucket/bitbucket.go new file mode 100644 index 000000000..44b3d0fa1 --- /dev/null +++ b/pkg/integrations/bitbucket/bitbucket.go @@ -0,0 +1,188 @@ +package bitbucket + +import ( + "fmt" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/registry" +) + +const ( + AuthTypeAPIToken = "apiToken" + AuthTypeWorkspaceAccessToken = "workspaceAccessToken" + + installationInstructions = ` +To configure Bitbucket with SuperPlane: + +- **API Token mode**: + - Go to **Atlassian Settings → Security → Create API token**. + - Select **Bitbucket** App. + - Create a token with admin:workspace:bitbucket scope. + +- **Workspace Access Token mode**: + - Go to **Bitbucket Workspace Settings → Security → Access tokens**. + - Create a workspace access token. + +- **Copy the token** and your workspace slug (for example: ` + "`my-workspace`" + `) below. +` +) + +func init() { + registry.RegisterIntegrationWithWebhookHandler("bitbucket", &Bitbucket{}, &BitbucketWebhookHandler{}) +} + +type Bitbucket struct{} + +type Configuration struct { + Workspace string `json:"workspace"` + AuthType string `json:"authType"` + Token *string `json:"token"` + Email *string `json:"email"` +} + +type Metadata struct { + AuthType string `json:"authType" mapstructure:"authType"` + Workspace *WorkspaceMetadata `json:"workspace,omitempty" mapstructure:"workspace,omitempty"` +} + +type WorkspaceMetadata struct { + UUID string `json:"uuid" mapstructure:"uuid"` + Name string `json:"name" mapstructure:"name"` + Slug string `json:"slug" mapstructure:"slug"` +} + +func (b *Bitbucket) Name() string { + return "bitbucket" +} + +func (b *Bitbucket) Label() string { + return "Bitbucket" +} + +func (b *Bitbucket) Icon() string { + return "bitbucket" +} + +func (b *Bitbucket) Description() string { + return "React to events in your Bitbucket repositories" +} + +func (b *Bitbucket) Instructions() string { + return installationInstructions +} + +func (b *Bitbucket) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "workspace", + Label: "Workspace", + Type: configuration.FieldTypeString, + Description: "Bitbucket workspace slug", + Placeholder: "e.g. my-workspace", + Required: true, + }, + { + Name: "authType", + Label: "Authentication Type", + Type: configuration.FieldTypeSelect, + Required: true, + Description: "Bitbucket authentication type", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "API Token", Value: AuthTypeAPIToken}, + {Label: "Workspace Access Token", Value: AuthTypeWorkspaceAccessToken}, + }, + }, + }, + }, + { + Name: "token", + Label: "Token", + Type: configuration.FieldTypeString, + Sensitive: true, + Description: "The API token or workspace access token to use for authentication", + Required: true, + }, + { + Name: "email", + Label: "Email", + Type: configuration.FieldTypeString, + Description: "Atlassian account email", + Required: true, + VisibilityConditions: []configuration.VisibilityCondition{ + {Field: "authType", Values: []string{AuthTypeAPIToken}}, + }, + }, + } +} + +func (b *Bitbucket) Components() []core.Component { + return []core.Component{} +} + +func (b *Bitbucket) Triggers() []core.Trigger { + return []core.Trigger{ + &OnPush{}, + } +} + +func (b *Bitbucket) Cleanup(ctx core.IntegrationCleanupContext) error { + return nil +} + +func (b *Bitbucket) Sync(ctx core.SyncContext) error { + config := Configuration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + if config.Workspace == "" { + return fmt.Errorf("workspace is required") + } + + if config.AuthType == "" { + return fmt.Errorf("authType is required") + } + + if config.AuthType != AuthTypeAPIToken && config.AuthType != AuthTypeWorkspaceAccessToken { + return fmt.Errorf("authType %s is not supported", config.AuthType) + } + + client, err := NewClient(config.AuthType, ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("error creating client: %w", err) + } + + workspace, err := client.GetWorkspace(config.Workspace) + if err != nil { + return fmt.Errorf("error getting workspace: %w", err) + } + + ctx.Integration.SetMetadata(Metadata{ + AuthType: config.AuthType, + Workspace: &WorkspaceMetadata{ + UUID: workspace.UUID, + Name: workspace.Name, + Slug: workspace.Slug, + }, + }) + + ctx.Integration.Ready() + + return nil +} + +func (b *Bitbucket) HandleRequest(ctx core.HTTPRequestContext) { + // no-op +} + +func (b *Bitbucket) Actions() []core.Action { + return []core.Action{} +} + +func (b *Bitbucket) HandleAction(ctx core.IntegrationActionContext) error { + return nil +} diff --git a/pkg/integrations/bitbucket/bitbucket_test.go b/pkg/integrations/bitbucket/bitbucket_test.go new file mode 100644 index 000000000..afb8e9bb0 --- /dev/null +++ b/pkg/integrations/bitbucket/bitbucket_test.go @@ -0,0 +1,108 @@ +package bitbucket + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + contexts "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__Bitbucket__Sync(t *testing.T) { + b := &Bitbucket{} + + t.Run("workspace is required", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "token": "token", + }, + } + + err := b.Sync(core.SyncContext{ + HTTP: &contexts.HTTPContext{}, + Integration: integrationCtx, + Configuration: map[string]any{ + "authType": AuthTypeWorkspaceAccessToken, + }, + }) + + require.ErrorContains(t, err, "workspace is required") + }) + + t.Run("authType is required", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "token": "token", + }, + } + + err := b.Sync(core.SyncContext{ + HTTP: &contexts.HTTPContext{}, + Integration: integrationCtx, + Configuration: map[string]any{ + "workspace": "superplane", + }, + }) + + require.ErrorContains(t, err, "authType is required") + }) + + t.Run("unsupported authType returns error", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "token": "token", + }, + } + + err := b.Sync(core.SyncContext{ + HTTP: &contexts.HTTPContext{}, + Integration: integrationCtx, + Configuration: map[string]any{ + "workspace": "superplane", + "authType": "unsupported", + }, + }) + + require.ErrorContains(t, err, "authType unsupported is not supported") + }) + + t.Run("workspace metadata is set and integration is ready", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"uuid":"{workspace-uuid}","name":"SuperPlane","slug":"superplane"}`)), + }, + }, + } + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "token": "token", + }, + } + + err := b.Sync(core.SyncContext{ + HTTP: httpCtx, + Integration: integrationCtx, + Configuration: map[string]any{ + "workspace": "superplane", + "authType": AuthTypeWorkspaceAccessToken, + }, + }) + require.NoError(t, err) + + require.NotNil(t, integrationCtx.Metadata) + metadata, ok := integrationCtx.Metadata.(Metadata) + require.True(t, ok) + assert.Equal(t, AuthTypeWorkspaceAccessToken, metadata.AuthType) + require.NotNil(t, metadata.Workspace) + assert.Equal(t, "{workspace-uuid}", metadata.Workspace.UUID) + assert.Equal(t, "SuperPlane", metadata.Workspace.Name) + assert.Equal(t, "superplane", metadata.Workspace.Slug) + assert.Equal(t, "ready", integrationCtx.State) + }) +} diff --git a/pkg/integrations/bitbucket/client.go b/pkg/integrations/bitbucket/client.go new file mode 100644 index 000000000..6477e2607 --- /dev/null +++ b/pkg/integrations/bitbucket/client.go @@ -0,0 +1,253 @@ +package bitbucket + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/superplanehq/superplane/pkg/core" +) + +const baseURL = "https://api.bitbucket.org/2.0" + +type Client struct { + AuthType string + Email string + Token string + HTTP core.HTTPContext +} + +type RepositoryResponse struct { + Values []Repository `json:"values"` + Next string `json:"next"` +} + +type Repository struct { + UUID string `json:"uuid" mapstructure:"uuid"` + Name string `json:"name" mapstructure:"name"` + FullName string `json:"full_name" mapstructure:"full_name"` + Slug string `json:"slug" mapstructure:"slug"` + Links RepositoryLink `json:"links" mapstructure:"links"` +} + +type RepositoryLink struct { + HTML struct { + Href string `json:"href" mapstructure:"href"` + } `json:"html" mapstructure:"html"` +} + +func NewClient(authType string, httpContext core.HTTPContext, integration core.IntegrationContext) (*Client, error) { + switch authType { + case AuthTypeAPIToken: + token, err := integration.GetConfig("token") + if err != nil { + return nil, fmt.Errorf("error getting token config: %w", err) + } + + email, err := integration.GetConfig("email") + if err != nil { + return nil, fmt.Errorf("error getting email config: %w", err) + } + + return &Client{ + AuthType: AuthTypeAPIToken, + Email: string(email), + Token: string(token), + HTTP: httpContext, + }, nil + + case AuthTypeWorkspaceAccessToken: + token, err := integration.GetConfig("token") + if err != nil { + return nil, fmt.Errorf("error getting token config: %w", err) + } + return &Client{ + AuthType: AuthTypeWorkspaceAccessToken, + Token: string(token), + HTTP: httpContext, + }, nil + } + + return nil, fmt.Errorf("unknown auth type %s", authType) +} + +func (c *Client) setAuthHeaders(req *http.Request) { + if c.AuthType == AuthTypeAPIToken { + req.SetBasicAuth(c.Email, c.Token) + } else { + req.Header.Set("Authorization", "Bearer "+c.Token) + } +} + +type Workspace struct { + UUID string `json:"uuid" mapstructure:"uuid"` + Name string `json:"name" mapstructure:"name"` + Slug string `json:"slug" mapstructure:"slug"` +} + +func (c *Client) GetWorkspace(workspaceSlug string) (*Workspace, error) { + url := fmt.Sprintf("%s/workspaces/%s", baseURL, workspaceSlug) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + c.setAuthHeaders(req) + req.Header.Set("Accept", "application/json") + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body)) + } + + var workspace Workspace + err = json.Unmarshal(body, &workspace) + if err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + + return &workspace, nil +} + +func (c *Client) ListRepositories(workspace string) ([]Repository, error) { + url := fmt.Sprintf("%s/repositories/%s?pagelen=100", baseURL, workspace) + repositories := []Repository{} + + for url != "" { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + c.setAuthHeaders(req) + req.Header.Set("Accept", "application/json") + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body)) + } + + var repoResponse RepositoryResponse + err = json.Unmarshal(body, &repoResponse) + if err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + + repositories = append(repositories, repoResponse.Values...) + url = repoResponse.Next + } + + return repositories, nil +} + +type BitbucketHookRequest struct { + Description string `json:"description"` + URL string `json:"url"` + Active bool `json:"active"` + Secret string `json:"secret,omitempty"` + Events []string `json:"events"` +} + +type BitbucketHookResponse struct { + UUID string `json:"uuid"` + URL string `json:"url"` + Active bool `json:"active"` +} + +func (c *Client) CreateWebhook(workspace, repoSlug, webhookURL, secret string, events []string) (*BitbucketHookResponse, error) { + url := fmt.Sprintf("%s/repositories/%s/%s/hooks", baseURL, workspace, repoSlug) + + hookReq := BitbucketHookRequest{ + Description: "SuperPlane", + URL: webhookURL, + Active: true, + Secret: secret, + Events: events, + } + + body, err := json.Marshal(hookReq) + if err != nil { + return nil, fmt.Errorf("error marshaling webhook request: %w", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + c.setAuthHeaders(req) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + + if resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(respBody)) + } + + var hookResp BitbucketHookResponse + err = json.Unmarshal(respBody, &hookResp) + if err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + + return &hookResp, nil +} + +func (c *Client) DeleteWebhook(workspace, repoSlug, webhookUID string) error { + url := fmt.Sprintf("%s/repositories/%s/%s/hooks/%s", baseURL, workspace, repoSlug, webhookUID) + + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return fmt.Errorf("error creating request: %w", err) + } + + c.setAuthHeaders(req) + + resp, err := c.HTTP.Do(req) + if err != nil { + return fmt.Errorf("error executing request: %w", err) + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body)) + } + + return nil +} diff --git a/pkg/integrations/bitbucket/common.go b/pkg/integrations/bitbucket/common.go new file mode 100644 index 000000000..65afc213d --- /dev/null +++ b/pkg/integrations/bitbucket/common.go @@ -0,0 +1,75 @@ +package bitbucket + +import ( + "fmt" + "slices" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/core" +) + +type NodeMetadata struct { + Repository *RepositoryMetadata `json:"repository" mapstructure:"repository"` +} + +type RepositoryMetadata struct { + UUID string `json:"uuid" mapstructure:"uuid"` + Name string `json:"name" mapstructure:"name"` + FullName string `json:"full_name" mapstructure:"full_name"` + Slug string `json:"slug" mapstructure:"slug"` +} + +func ensureRepoInMetadata(http core.HTTPContext, ctx core.MetadataContext, integration core.IntegrationContext, repository string) (*RepositoryMetadata, error) { + if repository == "" { + return nil, fmt.Errorf("repository is required") + } + + var nodeMetadata NodeMetadata + if err := mapstructure.Decode(ctx.Get(), &nodeMetadata); err != nil { + return nil, fmt.Errorf("failed to decode node metadata: %w", err) + } + + if nodeMetadata.Repository != nil && repositoryMetadataMatches(*nodeMetadata.Repository, repository) { + return nodeMetadata.Repository, nil + } + + var integrationMetadata Metadata + if err := mapstructure.Decode(integration.GetMetadata(), &integrationMetadata); err != nil { + return nil, fmt.Errorf("failed to decode integration metadata: %w", err) + } + + client, err := NewClient(integrationMetadata.AuthType, http, integration) + if err != nil { + return nil, fmt.Errorf("failed to create client: %w", err) + } + + repositories, err := client.ListRepositories(integrationMetadata.Workspace.Slug) + if err != nil { + return nil, fmt.Errorf("failed to list repositories: %w", err) + } + + repoIndex := slices.IndexFunc(repositories, func(r Repository) bool { + return repositoryMatches(r, repository) + }) + + if repoIndex == -1 { + return nil, fmt.Errorf("repository %s is not accessible to workspace", repository) + } + + repoMetadata := &RepositoryMetadata{ + UUID: repositories[repoIndex].UUID, + Name: repositories[repoIndex].Name, + FullName: repositories[repoIndex].FullName, + Slug: repositories[repoIndex].Slug, + } + + return repoMetadata, ctx.Set(NodeMetadata{Repository: repoMetadata}) +} + +func repositoryMetadataMatches(repo RepositoryMetadata, repository string) bool { + return repo.FullName == repository || repo.Name == repository || repo.Slug == repository || repo.UUID == repository +} + +func repositoryMatches(repo Repository, repository string) bool { + return repo.FullName == repository || repo.Name == repository || repo.Slug == repository || repo.UUID == repository +} diff --git a/pkg/integrations/bitbucket/example.go b/pkg/integrations/bitbucket/example.go new file mode 100644 index 000000000..e6415057e --- /dev/null +++ b/pkg/integrations/bitbucket/example.go @@ -0,0 +1,18 @@ +package bitbucket + +import ( + _ "embed" + "sync" + + "github.com/superplanehq/superplane/pkg/utils" +) + +//go:embed example_data_on_push.json +var exampleDataOnPushBytes []byte + +var exampleDataOnPushOnce sync.Once +var exampleDataOnPush map[string]any + +func (t *OnPush) ExampleData() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleDataOnPushOnce, exampleDataOnPushBytes, &exampleDataOnPush) +} diff --git a/pkg/integrations/bitbucket/example_data_on_push.json b/pkg/integrations/bitbucket/example_data_on_push.json new file mode 100644 index 000000000..46aa7a26d --- /dev/null +++ b/pkg/integrations/bitbucket/example_data_on_push.json @@ -0,0 +1,91 @@ +{ + "actor": { + "display_name": "John Doe", + "uuid": "{d301aafa-d676-4ee0-a3f1-8b94c681feaa}", + "type": "user", + "nickname": "johndoe", + "links": { + "html": { + "href": "https://bitbucket.org/johndoe/" + }, + "avatar": { + "href": "https://bitbucket.org/account/johndoe/avatar/" + } + } + }, + "repository": { + "type": "repository", + "full_name": "my-workspace/my-repo", + "name": "my-repo", + "uuid": "{b7f10c3a-2a1e-4c36-af54-7e818f3b6e1d}", + "links": { + "html": { + "href": "https://bitbucket.org/my-workspace/my-repo" + } + } + }, + "push": { + "changes": [ + { + "new": { + "type": "branch", + "name": "main", + "target": { + "type": "commit", + "hash": "709d658dc5b6d6afcd46049c2f332ee3f515a67d", + "author": { + "type": "author", + "raw": "John Doe ", + "user": { + "display_name": "John Doe", + "uuid": "{d301aafa-d676-4ee0-a3f1-8b94c681feaa}", + "type": "user" + } + }, + "message": "Add new feature\n", + "date": "2024-01-15T10:30:00+00:00", + "links": { + "html": { + "href": "https://bitbucket.org/my-workspace/my-repo/commits/709d658dc5b6d6afcd46049c2f332ee3f515a67d" + } + } + } + }, + "old": { + "type": "branch", + "name": "main", + "target": { + "type": "commit", + "hash": "1e65c05c1d5171631d92438a13901ca7dae9618c", + "author": { + "type": "author", + "raw": "John Doe " + }, + "message": "Previous commit\n", + "date": "2024-01-14T15:00:00+00:00" + } + }, + "created": false, + "forced": false, + "closed": false, + "commits": [ + { + "hash": "709d658dc5b6d6afcd46049c2f332ee3f515a67d", + "type": "commit", + "message": "Add new feature\n", + "author": { + "type": "author", + "raw": "John Doe " + }, + "links": { + "html": { + "href": "https://bitbucket.org/my-workspace/my-repo/commits/709d658dc5b6d6afcd46049c2f332ee3f515a67d" + } + } + } + ], + "truncated": false + } + ] + } +} diff --git a/pkg/integrations/bitbucket/list_resources.go b/pkg/integrations/bitbucket/list_resources.go new file mode 100644 index 000000000..c905cdc88 --- /dev/null +++ b/pkg/integrations/bitbucket/list_resources.go @@ -0,0 +1,40 @@ +package bitbucket + +import ( + "fmt" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/core" +) + +func (b *Bitbucket) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { + if resourceType != "repository" { + return []core.IntegrationResource{}, nil + } + + metadata := Metadata{} + if err := mapstructure.Decode(ctx.Integration.GetMetadata(), &metadata); err != nil { + return nil, fmt.Errorf("failed to decode integration metadata: %w", err) + } + + client, err := NewClient(metadata.AuthType, ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("failed to create client: %w", err) + } + + repositories, err := client.ListRepositories(metadata.Workspace.Slug) + if err != nil { + return nil, fmt.Errorf("failed to list repositories: %w", err) + } + + resources := make([]core.IntegrationResource, 0, len(repositories)) + for _, repo := range repositories { + resources = append(resources, core.IntegrationResource{ + Type: resourceType, + Name: repo.FullName, + ID: repo.UUID, + }) + } + + return resources, nil +} diff --git a/pkg/integrations/bitbucket/on_push.go b/pkg/integrations/bitbucket/on_push.go new file mode 100644 index 000000000..220774b8e --- /dev/null +++ b/pkg/integrations/bitbucket/on_push.go @@ -0,0 +1,239 @@ +package bitbucket + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/crypto" +) + +type OnPush struct{} + +type OnPushConfiguration struct { + Repository string `json:"repository" mapstructure:"repository"` + Refs []configuration.Predicate `json:"refs" mapstructure:"refs"` +} + +func (p *OnPush) Name() string { + return "bitbucket.onPush" +} + +func (p *OnPush) Label() string { + return "On Push" +} + +func (p *OnPush) Description() string { + return "Listen to Bitbucket push events" +} + +func (p *OnPush) Documentation() string { + return `The On Push trigger starts a workflow execution when code is pushed to a Bitbucket repository. + +## Use Cases + +- **CI/CD automation**: Trigger builds and deployments on code pushes +- **Code quality checks**: Run linting and tests on every push +- **Notification workflows**: Send notifications when code is pushed + +## Configuration + +- **Repository**: Select the Bitbucket repository to monitor +- **Refs**: Configure which branches to monitor (e.g., ` + "`refs/heads/main`" + `) + +## Event Data + +Each push event includes: +- **repository**: Repository information +- **push.changes**: Array of reference changes with new/old commit details +- **actor**: Information about who pushed + +## Webhook Setup + +This trigger automatically sets up a Bitbucket webhook when configured. The webhook is managed by SuperPlane and will be cleaned up when the trigger is removed.` +} + +func (p *OnPush) Icon() string { + return "bitbucket" +} + +func (p *OnPush) Color() string { + return "blue" +} + +func (p *OnPush) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "repository", + Label: "Repository", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "repository", + UseNameAsValue: true, + }, + }, + }, + { + Name: "refs", + Label: "Refs", + Type: configuration.FieldTypeAnyPredicateList, + Required: true, + Default: []map[string]any{ + { + "type": configuration.PredicateTypeEquals, + "value": "refs/heads/main", + }, + }, + TypeOptions: &configuration.TypeOptions{ + AnyPredicateList: &configuration.AnyPredicateListTypeOptions{ + Operators: configuration.AllPredicateOperators, + }, + }, + }, + } +} + +func (p *OnPush) Setup(ctx core.TriggerContext) error { + config := OnPushConfiguration{} + err := mapstructure.Decode(ctx.Configuration, &config) + if err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + repo, err := ensureRepoInMetadata(ctx.HTTP, ctx.Metadata, ctx.Integration, config.Repository) + if err != nil { + return err + } + + return ctx.Integration.RequestWebhook(WebhookConfiguration{ + EventTypes: []string{"repo:push"}, + RepositorySlug: repo.Slug, + }) +} + +func (p *OnPush) Actions() []core.Action { + return []core.Action{} +} + +func (p *OnPush) HandleAction(ctx core.TriggerActionContext) (map[string]any, error) { + return nil, nil +} + +func (p *OnPush) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + // + // Verify the event type. + // + eventKey := ctx.Headers.Get("X-Event-Key") + if eventKey == "" { + return http.StatusBadRequest, fmt.Errorf("missing X-Event-Key header") + } + + if eventKey != "repo:push" { + return http.StatusOK, nil + } + + // + // Verify the webhook signature. + // + signature := ctx.Headers.Get("X-Hub-Signature") + if signature == "" { + return http.StatusForbidden, fmt.Errorf("missing X-Hub-Signature header") + } + + signature = strings.TrimPrefix(signature, "sha256=") + if signature == "" { + return http.StatusForbidden, fmt.Errorf("invalid signature format") + } + + secret, err := ctx.Webhook.GetSecret() + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("error getting webhook secret") + } + + if err := crypto.VerifySignature(secret, ctx.Body, signature); err != nil { + return http.StatusForbidden, fmt.Errorf("invalid signature") + } + + // + // Parse the webhook payload. + // + data := map[string]any{} + err = json.Unmarshal(ctx.Body, &data) + if err != nil { + return http.StatusBadRequest, fmt.Errorf("error parsing request body: %v", err) + } + + // + // Extract the ref from the push changes and filter. + // + config := OnPushConfiguration{} + err = mapstructure.Decode(ctx.Configuration, &config) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed to decode configuration: %w", err) + } + + ref := extractRef(data) + if ref == "" { + return http.StatusOK, nil + } + + if !configuration.MatchesAnyPredicate(config.Refs, ref) { + return http.StatusOK, nil + } + + err = ctx.Events.Emit("bitbucket.push", data) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("error emitting event: %v", err) + } + + return http.StatusOK, nil +} + +func (p *OnPush) Cleanup(ctx core.TriggerContext) error { + return nil +} + +// extractRef extracts the ref name from a Bitbucket push payload. +func extractRef(data map[string]any) string { + push, ok := data["push"].(map[string]any) + if !ok { + return "" + } + + changes, ok := push["changes"].([]any) + if !ok || len(changes) == 0 { + return "" + } + + firstChange, ok := changes[0].(map[string]any) + if !ok { + return "" + } + + newRef, ok := firstChange["new"].(map[string]any) + if !ok { + return "" + } + + refType, _ := newRef["type"].(string) + refName, _ := newRef["name"].(string) + + if refName == "" { + return "" + } + + switch refType { + case "branch": + return fmt.Sprintf("refs/heads/%s", refName) + case "tag": + return fmt.Sprintf("refs/tags/%s", refName) + default: + return fmt.Sprintf("refs/heads/%s", refName) + } +} diff --git a/pkg/integrations/bitbucket/on_push_test.go b/pkg/integrations/bitbucket/on_push_test.go new file mode 100644 index 000000000..f35b3d0e2 --- /dev/null +++ b/pkg/integrations/bitbucket/on_push_test.go @@ -0,0 +1,319 @@ +package bitbucket + +import ( + "crypto/hmac" + "crypto/sha256" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + contexts "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__OnPush__Setup(t *testing.T) { + trigger := OnPush{} + metadata := Metadata{ + AuthType: AuthTypeWorkspaceAccessToken, + Workspace: &WorkspaceMetadata{ + Slug: "superplane", + }, + } + + t.Run("repository is required", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{"token": "token"}, + Metadata: metadata, + } + + err := trigger.Setup(core.TriggerContext{ + HTTP: &contexts.HTTPContext{}, + Integration: integrationCtx, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"repository": ""}, + }) + + require.ErrorContains(t, err, "repository is required") + }) + + t.Run("repository is not accessible", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader( + `{"values":[{"uuid":"{hello}","name":"hello","full_name":"superplane/hello","slug":"hello"}]}`, + )), + }, + }, + } + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{"token": "token"}, + Metadata: metadata, + } + + err := trigger.Setup(core.TriggerContext{ + HTTP: httpCtx, + Integration: integrationCtx, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{ + "repository": "world", + }, + }) + + require.ErrorContains(t, err, "repository world is not accessible to workspace") + }) + + t.Run("metadata is set and webhook is requested", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader( + `{"values":[{"uuid":"{hello}","name":"hello","full_name":"superplane/hello","slug":"hello"}]}`, + )), + }, + }, + } + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{"token": "token"}, + Metadata: metadata, + } + nodeMetadataCtx := &contexts.MetadataContext{} + + require.NoError(t, trigger.Setup(core.TriggerContext{ + HTTP: httpCtx, + Integration: integrationCtx, + Metadata: nodeMetadataCtx, + Configuration: map[string]any{ + "repository": "hello", + }, + })) + + require.Len(t, integrationCtx.WebhookRequests, 1) + webhookRequest, ok := integrationCtx.WebhookRequests[0].(WebhookConfiguration) + require.True(t, ok) + assert.Equal(t, []string{"repo:push"}, webhookRequest.EventTypes) + assert.Equal(t, "hello", webhookRequest.RepositorySlug) + + require.NotNil(t, nodeMetadataCtx.Metadata) + nodeMetadata, ok := nodeMetadataCtx.Metadata.(NodeMetadata) + require.True(t, ok) + require.NotNil(t, nodeMetadata.Repository) + assert.Equal(t, "{hello}", nodeMetadata.Repository.UUID) + assert.Equal(t, "hello", nodeMetadata.Repository.Name) + assert.Equal(t, "superplane/hello", nodeMetadata.Repository.FullName) + assert.Equal(t, "hello", nodeMetadata.Repository.Slug) + }) +} + +func Test__OnPush__HandleWebhook(t *testing.T) { + trigger := &OnPush{} + + t.Run("no X-Event-Key -> 400", func(t *testing.T) { + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Headers: http.Header{}, + }) + + assert.Equal(t, http.StatusBadRequest, code) + assert.ErrorContains(t, err, "missing X-Event-Key header") + }) + + t.Run("event is not repo:push -> 200", func(t *testing.T) { + headers := http.Header{} + headers.Set("X-Event-Key", "repo:fork") + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Headers: headers, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Zero(t, eventContext.Count()) + }) + + t.Run("missing signature -> 403", func(t *testing.T) { + headers := http.Header{} + headers.Set("X-Event-Key", "repo:push") + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Headers: headers, + }) + + assert.Equal(t, http.StatusForbidden, code) + assert.ErrorContains(t, err, "missing X-Hub-Signature header") + }) + + t.Run("invalid signature format -> 403", func(t *testing.T) { + headers := http.Header{} + headers.Set("X-Event-Key", "repo:push") + headers.Set("X-Hub-Signature", "sha256=") + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Headers: headers, + }) + + assert.Equal(t, http.StatusForbidden, code) + assert.ErrorContains(t, err, "invalid signature format") + }) + + t.Run("invalid signature -> 403", func(t *testing.T) { + headers := http.Header{} + headers.Set("X-Event-Key", "repo:push") + headers.Set("X-Hub-Signature", "sha256=invalid") + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: []byte(`{}`), + Headers: headers, + Webhook: &contexts.WebhookContext{Secret: "test-secret"}, + }) + + assert.Equal(t, http.StatusForbidden, code) + assert.ErrorContains(t, err, "invalid signature") + }) + + t.Run("invalid body -> 400", func(t *testing.T) { + body := []byte("{") + signature := signBitbucketPayload("test-secret", body) + + headers := http.Header{} + headers.Set("X-Event-Key", "repo:push") + headers.Set("X-Hub-Signature", "sha256="+signature) + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Webhook: &contexts.WebhookContext{Secret: "test-secret"}, + Configuration: map[string]any{ + "repository": "hello", + "refs": []configuration.Predicate{ + {Type: configuration.PredicateTypeEquals, Value: "refs/heads/main"}, + }, + }, + Events: &contexts.EventContext{}, + }) + + assert.Equal(t, http.StatusBadRequest, code) + assert.ErrorContains(t, err, "error parsing request body") + }) + + t.Run("ref does not match -> event is not emitted", func(t *testing.T) { + body := []byte(`{"push":{"changes":[{"new":{"type":"branch","name":"feature-1"}}]}}`) + signature := signBitbucketPayload("test-secret", body) + + headers := http.Header{} + headers.Set("X-Event-Key", "repo:push") + headers.Set("X-Hub-Signature", "sha256="+signature) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Webhook: &contexts.WebhookContext{Secret: "test-secret"}, + Configuration: map[string]any{ + "repository": "hello", + "refs": []configuration.Predicate{ + {Type: configuration.PredicateTypeEquals, Value: "refs/heads/main"}, + }, + }, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Zero(t, eventContext.Count()) + }) + + t.Run("ref matches -> event is emitted", func(t *testing.T) { + body := []byte(`{"push":{"changes":[{"new":{"type":"branch","name":"main"}}]}}`) + signature := signBitbucketPayload("test-secret", body) + + headers := http.Header{} + headers.Set("X-Event-Key", "repo:push") + headers.Set("X-Hub-Signature", "sha256="+signature) + + eventContext := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Headers: headers, + Webhook: &contexts.WebhookContext{Secret: "test-secret"}, + Configuration: map[string]any{ + "repository": "hello", + "refs": []configuration.Predicate{ + {Type: configuration.PredicateTypeEquals, Value: "refs/heads/main"}, + }, + }, + Events: eventContext, + }) + + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + require.Equal(t, 1, eventContext.Count()) + assert.Equal(t, "bitbucket.push", eventContext.Payloads[0].Type) + }) +} + +func Test__ExtractRef(t *testing.T) { + t.Run("extracts branch ref", func(t *testing.T) { + ref := extractRef(map[string]any{ + "push": map[string]any{ + "changes": []any{ + map[string]any{ + "new": map[string]any{ + "type": "branch", + "name": "main", + }, + }, + }, + }, + }) + + assert.Equal(t, "refs/heads/main", ref) + }) + + t.Run("extracts tag ref", func(t *testing.T) { + ref := extractRef(map[string]any{ + "push": map[string]any{ + "changes": []any{ + map[string]any{ + "new": map[string]any{ + "type": "tag", + "name": "v1.2.3", + }, + }, + }, + }, + }) + + assert.Equal(t, "refs/tags/v1.2.3", ref) + }) + + t.Run("missing ref returns empty string", func(t *testing.T) { + ref := extractRef(map[string]any{ + "push": map[string]any{ + "changes": []any{ + map[string]any{ + "new": map[string]any{ + "type": "branch", + }, + }, + }, + }, + }) + + assert.Empty(t, ref) + }) +} + +func signBitbucketPayload(secret string, body []byte) string { + h := hmac.New(sha256.New, []byte(secret)) + h.Write(body) + return fmt.Sprintf("%x", h.Sum(nil)) +} diff --git a/pkg/integrations/bitbucket/webhook_handler.go b/pkg/integrations/bitbucket/webhook_handler.go new file mode 100644 index 000000000..268c0e960 --- /dev/null +++ b/pkg/integrations/bitbucket/webhook_handler.go @@ -0,0 +1,132 @@ +package bitbucket + +import ( + "fmt" + "slices" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/core" +) + +type WebhookConfiguration struct { + EventTypes []string `json:"eventTypes"` + RepositorySlug string `json:"repositorySlug"` +} + +type BitbucketWebhook struct { + UUID string `json:"uuid"` +} + +type BitbucketWebhookHandler struct{} + +func (h *BitbucketWebhookHandler) CompareConfig(a, b any) (bool, error) { + configA := WebhookConfiguration{} + configB := WebhookConfiguration{} + + err := mapstructure.Decode(a, &configA) + if err != nil { + return false, err + } + + err = mapstructure.Decode(b, &configB) + if err != nil { + return false, err + } + + if configA.RepositorySlug != configB.RepositorySlug { + return false, nil + } + + // Check if A contains all events from B (A is superset of B) + // This allows webhook sharing when existing webhook has more events than needed + for _, eventB := range configB.EventTypes { + if !slices.Contains(configA.EventTypes, eventB) { + return false, nil + } + } + + return true, nil +} + +func (h *BitbucketWebhookHandler) Merge(current, requested any) (any, bool, error) { + return current, false, nil +} + +func (h *BitbucketWebhookHandler) Setup(ctx core.WebhookHandlerContext) (any, error) { + metadata := Metadata{} + err := mapstructure.Decode(ctx.Integration.GetMetadata(), &metadata) + if err != nil { + return nil, fmt.Errorf("failed to decode integration metadata: %w", err) + } + + client, err := NewClient(metadata.AuthType, ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("failed to create client: %w", err) + } + + config := WebhookConfiguration{} + err = mapstructure.Decode(ctx.Webhook.GetConfiguration(), &config) + if err != nil { + return nil, fmt.Errorf("failed to decode webhook configuration: %w", err) + } + + if config.RepositorySlug == "" { + return nil, fmt.Errorf("repository is required") + } + + secret, err := ctx.Webhook.GetSecret() + if err != nil { + return nil, fmt.Errorf("error getting webhook secret: %w", err) + } + + hook, err := client.CreateWebhook( + metadata.Workspace.Slug, + config.RepositorySlug, + ctx.Webhook.GetURL(), + string(secret), + config.EventTypes, + ) + + if err != nil { + return nil, fmt.Errorf("error creating webhook: %w", err) + } + + return &BitbucketWebhook{UUID: hook.UUID}, nil +} + +func (h *BitbucketWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) error { + metadata := Metadata{} + err := mapstructure.Decode(ctx.Integration.GetMetadata(), &metadata) + if err != nil { + return fmt.Errorf("failed to decode integration metadata: %w", err) + } + + client, err := NewClient(metadata.AuthType, ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + webhook := BitbucketWebhook{} + err = mapstructure.Decode(ctx.Webhook.GetMetadata(), &webhook) + if err != nil { + return fmt.Errorf("failed to decode webhook metadata: %w", err) + } + + // If the webhook was never created (Setup failed), there's nothing to clean up. + if webhook.UUID == "" { + return nil + } + + config := WebhookConfiguration{} + err = mapstructure.Decode(ctx.Webhook.GetConfiguration(), &config) + if err != nil { + return fmt.Errorf("failed to decode webhook configuration: %w", err) + } + + err = client.DeleteWebhook(metadata.Workspace.Slug, config.RepositorySlug, webhook.UUID) + if err != nil { + return fmt.Errorf("error deleting webhook: %w", err) + } + + return nil +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 26efbc90c..fdc996af8 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -33,6 +33,7 @@ import ( _ "github.com/superplanehq/superplane/pkg/components/timegate" _ "github.com/superplanehq/superplane/pkg/components/wait" _ "github.com/superplanehq/superplane/pkg/integrations/aws" + _ "github.com/superplanehq/superplane/pkg/integrations/bitbucket" _ "github.com/superplanehq/superplane/pkg/integrations/circleci" _ "github.com/superplanehq/superplane/pkg/integrations/claude" _ "github.com/superplanehq/superplane/pkg/integrations/cloudflare" diff --git a/web_src/src/assets/icons/integrations/bitbucket.svg b/web_src/src/assets/icons/integrations/bitbucket.svg new file mode 100644 index 000000000..f4e8d5b35 --- /dev/null +++ b/web_src/src/assets/icons/integrations/bitbucket.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/web_src/src/pages/workflowv2/mappers/bitbucket/index.ts b/web_src/src/pages/workflowv2/mappers/bitbucket/index.ts new file mode 100644 index 000000000..391a82cf2 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/bitbucket/index.ts @@ -0,0 +1,6 @@ +import { TriggerRenderer } from "../types"; +import { onPushTriggerRenderer } from "./on_push"; + +export const triggerRenderers: Record = { + onPush: onPushTriggerRenderer, +}; diff --git a/web_src/src/pages/workflowv2/mappers/bitbucket/on_push.ts b/web_src/src/pages/workflowv2/mappers/bitbucket/on_push.ts new file mode 100644 index 000000000..a5e9e7ba3 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/bitbucket/on_push.ts @@ -0,0 +1,163 @@ +import { getColorClass, getBackgroundColorClass } from "@/utils/colors"; +import { TriggerEventContext, TriggerRenderer, TriggerRendererContext } from "../types"; +import bitbucketIcon from "@/assets/icons/integrations/bitbucket.svg"; +import { TriggerProps } from "@/ui/trigger"; +import { NodeMetadata } from "./types"; +import { Predicate, formatPredicate } from "../utils"; +import { formatTimeAgo } from "@/utils/date"; + +export interface OnPushConfiguration { + repository?: string; + refs: Predicate[]; +} + +export interface BitbucketPush { + actor?: { + display_name?: string; + uuid?: string; + nickname?: string; + }; + repository?: { + full_name?: string; + name?: string; + uuid?: string; + links?: { + html?: { + href?: string; + }; + }; + }; + push?: { + changes?: BitbucketChange[]; + }; +} + +export interface BitbucketChange { + new?: { + type?: string; + name?: string; + target?: { + hash?: string; + message?: string; + date?: string; + author?: { + raw?: string; + user?: { + display_name?: string; + uuid?: string; + }; + }; + links?: { + html?: { + href?: string; + }; + }; + }; + }; + old?: { + type?: string; + name?: string; + }; + created?: boolean; + forced?: boolean; + closed?: boolean; + commits?: BitbucketCommit[]; + truncated?: boolean; +} + +export interface BitbucketCommit { + hash?: string; + message?: string; + author?: { + raw?: string; + }; + links?: { + html?: { + href?: string; + }; + }; +} + +function buildBitbucketSubtitle(shortSha: string, createdAt?: string): string { + const trimmedSha = shortSha.trim(); + const timeAgo = createdAt ? formatTimeAgo(new Date(createdAt)) : ""; + + if (trimmedSha && timeAgo) { + return `${trimmedSha} · ${timeAgo}`; + } + return trimmedSha || timeAgo; +} + +/** + * Renderer for the "bitbucket.onPush" trigger + */ +export const onPushTriggerRenderer: TriggerRenderer = { + getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string } => { + const eventData = context.event?.data as BitbucketPush; + const firstChange = eventData?.push?.changes?.[0]; + const commitMessage = firstChange?.new?.target?.message?.trim() || ""; + const shortSha = firstChange?.new?.target?.hash?.slice(0, 7) || ""; + + return { + title: commitMessage, + subtitle: buildBitbucketSubtitle(shortSha, context.event?.createdAt), + }; + }, + + getRootEventValues: (context: TriggerEventContext): Record => { + const eventData = context.event?.data as BitbucketPush; + const firstChange = eventData?.push?.changes?.[0]; + + return { + Branch: firstChange?.new?.name || "", + Commit: firstChange?.new?.target?.message?.trim() || "", + SHA: firstChange?.new?.target?.hash || "", + Author: eventData?.actor?.display_name || "", + }; + }, + + getTriggerProps: (context: TriggerRendererContext) => { + const { node, definition, lastEvent } = context; + const metadata = node.metadata as unknown as NodeMetadata; + const configuration = node.configuration as unknown as OnPushConfiguration; + const metadataItems = []; + + if (metadata?.repository) { + metadataItems.push({ + icon: "book", + label: metadata.repository.full_name || metadata.repository.name || "", + }); + } + + if (configuration?.refs && configuration.refs.length > 0) { + metadataItems.push({ + icon: "funnel", + label: configuration.refs.map(formatPredicate).join(", "), + }); + } + + const props: TriggerProps = { + title: node.name || definition.label || "Unnamed trigger", + iconSrc: bitbucketIcon, + iconColor: getColorClass(definition.color), + collapsedBackground: getBackgroundColorClass(definition.color), + metadata: metadataItems, + }; + + if (lastEvent) { + const eventData = lastEvent.data as BitbucketPush; + const firstChange = eventData?.push?.changes?.[0]; + const shortSha = firstChange?.new?.target?.hash?.slice(0, 7) || ""; + + props.lastEventData = { + title: firstChange?.new?.target?.message?.trim() || "", + subtitle: buildBitbucketSubtitle(shortSha, lastEvent.createdAt), + receivedAt: new Date(lastEvent.createdAt!), + state: "triggered", + eventId: lastEvent.id!, + }; + } + + return props; + }, +}; diff --git a/web_src/src/pages/workflowv2/mappers/bitbucket/types.ts b/web_src/src/pages/workflowv2/mappers/bitbucket/types.ts new file mode 100644 index 000000000..9b49452e0 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/bitbucket/types.ts @@ -0,0 +1,8 @@ +export interface NodeMetadata { + repository?: { + uuid?: string; + name?: string; + full_name?: string; + slug?: string; + }; +} diff --git a/web_src/src/pages/workflowv2/mappers/index.ts b/web_src/src/pages/workflowv2/mappers/index.ts index 6b682b3bc..d996d130b 100644 --- a/web_src/src/pages/workflowv2/mappers/index.ts +++ b/web_src/src/pages/workflowv2/mappers/index.ts @@ -107,6 +107,7 @@ import { triggerRenderers as claudeTriggerRenderers, eventStateRegistry as claudeEventStateRegistry, } from "./claude/index"; +import { triggerRenderers as bitbucketTriggerRenderers } from "./bitbucket/index"; import { componentMappers as prometheusComponentMappers, customFieldRenderers as prometheusCustomFieldRenderers, @@ -199,6 +200,7 @@ const appTriggerRenderers: Record> = { openai: openaiTriggerRenderers, circleci: circleCITriggerRenderers, claude: claudeTriggerRenderers, + bitbucket: bitbucketTriggerRenderers, prometheus: prometheusTriggerRenderers, cursor: cursorTriggerRenderers, dockerhub: dockerhubTriggerRenderers, diff --git a/web_src/src/ui/BuildingBlocksSidebar/index.tsx b/web_src/src/ui/BuildingBlocksSidebar/index.tsx index 100134e8a..fef909b7a 100644 --- a/web_src/src/ui/BuildingBlocksSidebar/index.tsx +++ b/web_src/src/ui/BuildingBlocksSidebar/index.tsx @@ -15,6 +15,7 @@ import { COMPONENT_SIDEBAR_WIDTH_STORAGE_KEY } from "../CanvasPage"; import { ComponentBase } from "../componentBase"; import circleciIcon from "@/assets/icons/integrations/circleci.svg"; import cloudflareIcon from "@/assets/icons/integrations/cloudflare.svg"; +import bitbucketIcon from "@/assets/icons/integrations/bitbucket.svg"; import dash0Icon from "@/assets/icons/integrations/dash0.svg"; import daytonaIcon from "@/assets/icons/integrations/daytona.svg"; import datadogIcon from "@/assets/icons/integrations/datadog.svg"; @@ -401,6 +402,7 @@ function CategorySection({ // Determine category icon const appLogoMap: Record> = { + bitbucket: bitbucketIcon, circleci: circleciIcon, cloudflare: cloudflareIcon, dash0: dash0Icon, @@ -483,6 +485,7 @@ function CategorySection({ // Use SVG icons for application components/triggers (SMTP uses resolveIcon("mail"), same as core) const appLogoMap: Record> = { + bitbucket: bitbucketIcon, circleci: circleciIcon, cloudflare: cloudflareIcon, dash0: dash0Icon, diff --git a/web_src/src/ui/componentSidebar/integrationIcons.tsx b/web_src/src/ui/componentSidebar/integrationIcons.tsx index aa6f4bbd1..412ca378f 100644 --- a/web_src/src/ui/componentSidebar/integrationIcons.tsx +++ b/web_src/src/ui/componentSidebar/integrationIcons.tsx @@ -2,6 +2,7 @@ import { resolveIcon } from "@/lib/utils"; import React from "react"; import awsIcon from "@/assets/icons/integrations/aws.svg"; import awsLambdaIcon from "@/assets/icons/integrations/aws.lambda.svg"; +import bitbucketIcon from "@/assets/icons/integrations/bitbucket.svg"; import awsEcsIcon from "@/assets/icons/integrations/aws.ecs.svg"; import circleciIcon from "@/assets/icons/integrations/circleci.svg"; import awsCloudwatchIcon from "@/assets/icons/integrations/aws.cloudwatch.svg"; @@ -32,6 +33,7 @@ import hetznerIcon from "@/assets/icons/integrations/hetzner.svg"; /** Integration type name (e.g. "github") → logo src. Used for Settings tab and header. */ export const INTEGRATION_APP_LOGO_MAP: Record = { aws: awsIcon, + bitbucket: bitbucketIcon, circleci: circleciIcon, cloudflare: cloudflareIcon, dash0: dash0Icon, @@ -59,6 +61,7 @@ export const INTEGRATION_APP_LOGO_MAP: Record = { /** Block name first part (e.g. "github") or compound (e.g. aws.lambda) → logo src for header. */ export const APP_LOGO_MAP: Record> = { + bitbucket: bitbucketIcon, circleci: circleciIcon, cloudflare: cloudflareIcon, dash0: dash0Icon,