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,