diff --git a/apps/docs/content/docs/en/integrations/bitbucket.mdx b/apps/docs/content/docs/en/integrations/bitbucket.mdx index 8d7529ce0c8..eba02b0e083 100644 --- a/apps/docs/content/docs/en/integrations/bitbucket.mdx +++ b/apps/docs/content/docs/en/integrations/bitbucket.mdx @@ -12,7 +12,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" ## Usage Instructions -Connect Bitbucket Cloud to inspect repositories and source, collaborate on pull requests, and diagnose or control pipelines. This action integration uses OAuth and does not create webhooks or triggers. +Connect Bitbucket Cloud to inspect repositories and source, collaborate on pull requests, diagnose or control pipelines, and start workflows from repository and pull request events. OAuth is used for actions and automatic webhook management. @@ -1546,3 +1546,672 @@ Read a bounded UTF-8 tail of a pipeline step log | `totalBytes` | number | Full log byte size when reported | + +## Triggers + +A **Trigger** is a block that starts a workflow when an event happens in this service. + +### Bitbucket Build Status Created + +Trigger workflow when a build status is created for a Bitbucket commit + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `commitStatus` | json | Bitbucket commit status object | +| `commitHash` | string | Hash of the commit with this status | +| `statusKey` | string | Key identifying the build status | +| `statusState` | string | Current build status state | +| `statusName` | string | Display name of the build status | +| `statusUrl` | string | URL associated with the build status | + + +--- + +### Bitbucket Build Status Updated + +Trigger workflow when a build status is updated for a Bitbucket commit + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `commitStatus` | json | Bitbucket commit status object | +| `commitHash` | string | Hash of the commit with this status | +| `statusKey` | string | Key identifying the build status | +| `statusState` | string | Current build status state | +| `statusName` | string | Display name of the build status | +| `statusUrl` | string | URL associated with the build status | + + +--- + +### Bitbucket Commit Comment Created + +Trigger workflow when a comment is created on a Bitbucket commit + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `comment` | json | Bitbucket comment object | +| `commentId` | number | Repository-scoped comment ID | +| `commentContent` | string | Raw text content of the comment | +| `commit` | json | Commit the comment was created on | + + +--- + +### Bitbucket Pull Request Approval Removed + +Trigger workflow when approval is removed from a Bitbucket pull request + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | +| `approval` | json | Pull request approval details | + + +--- + +### Bitbucket Pull Request Approved + +Trigger workflow when a Bitbucket pull request is approved + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | +| `approval` | json | Pull request approval details | + + +--- + +### Bitbucket Pull Request Changes Request Removed + +Trigger workflow when a changes request is removed from a Bitbucket pull request + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | +| `changesRequest` | json | Pull request changes-request details | + + +--- + +### Bitbucket Pull Request Changes Requested + +Trigger workflow when changes are requested on a Bitbucket pull request + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | +| `changesRequest` | json | Pull request changes-request details | + + +--- + +### Bitbucket Pull Request Comment Created + +Trigger workflow when a comment is created on a Bitbucket pull request + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | +| `comment` | json | Bitbucket comment object | +| `commentId` | number | Repository-scoped comment ID | +| `commentContent` | string | Raw text content of the comment | + + +--- + +### Bitbucket Pull Request Comment Deleted + +Trigger workflow when a comment is deleted from a Bitbucket pull request + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | +| `comment` | json | Bitbucket comment object | +| `commentId` | number | Repository-scoped comment ID | +| `commentContent` | string | Raw text content of the comment | + + +--- + +### Bitbucket Pull Request Comment Reopened + +Trigger workflow when a resolved comment is reopened on a Bitbucket pull request + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | +| `comment` | json | Bitbucket comment object | +| `commentId` | number | Repository-scoped comment ID | +| `commentContent` | string | Raw text content of the comment | + + +--- + +### Bitbucket Pull Request Comment Resolved + +Trigger workflow when a comment is resolved on a Bitbucket pull request + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | +| `comment` | json | Bitbucket comment object | +| `commentId` | number | Repository-scoped comment ID | +| `commentContent` | string | Raw text content of the comment | + + +--- + +### Bitbucket Pull Request Comment Updated + +Trigger workflow when a comment is updated on a Bitbucket pull request + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | +| `comment` | json | Bitbucket comment object | +| `commentId` | number | Repository-scoped comment ID | +| `commentContent` | string | Raw text content of the comment | + + +--- + +### Bitbucket Pull Request Created + +Trigger workflow when a Bitbucket pull request is created + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | + + +--- + +### Bitbucket Pull Request Declined + +Trigger workflow when a Bitbucket pull request is declined + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | + + +--- + +### Bitbucket Pull Request Merged + +Trigger workflow when a Bitbucket pull request is merged + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | + + +--- + +### Bitbucket Pull Request Updated + +Trigger workflow when a Bitbucket pull request is updated + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `pullRequest` | json | Bitbucket pull request object | +| `pullRequestId` | number | Repository-scoped pull request ID | +| `pullRequestTitle` | string | Pull request title | +| `pullRequestState` | string | Pull request state | +| `sourceBranch` | string | Pull request source branch | +| `destinationBranch` | string | Pull request destination branch | + + +--- + +### Bitbucket Push + +Trigger workflow when commits are pushed to a Bitbucket repository + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `push` | json | Push details, including changes and commits | + + +--- + +### Bitbucket Repository Forked + +Trigger workflow when a Bitbucket repository is forked + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `fork` | json | Newly created repository fork | + + +--- + +### Bitbucket Repository Updated + +Trigger workflow when a Bitbucket repository is updated + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required. | +| `workspacePicker` | project-selector | Yes | The workspace containing the repository to monitor. | +| `workspaceSlugInput` | string | Yes | Enter the workspace slug directly instead of selecting a workspace. | +| `repositoryPicker` | project-selector | Yes | The repository where Sim creates the webhook. | +| `repositorySlugInput` | string | Yes | Enter the repository slug directly instead of selecting a repository. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | Bitbucket event key from X-Event-Key | +| `hookUuid` | string | UUID of the Bitbucket repository webhook | +| `requestUuid` | string | Bitbucket delivery request UUID | +| `attemptNumber` | number | Bitbucket delivery attempt number | +| `actor` | json | User or app that caused the event | +| `repository` | json | Repository where the event occurred | +| `payload` | json | Full parsed Bitbucket webhook payload | +| `changes` | json | Repository fields changed by the update | + diff --git a/apps/docs/content/docs/en/platform/self-hosting/integrations-oauth.mdx b/apps/docs/content/docs/en/platform/self-hosting/integrations-oauth.mdx index 3bf2b8a769b..761e59b2e7a 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/integrations-oauth.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/integrations-oauth.mdx @@ -150,9 +150,10 @@ The same variables also power "Sign in with Microsoft". For Bitbucket, create an OAuth consumer and register `https:///api/auth/oauth2/callback/bitbucket` as its callback URL. Bitbucket fixes permissions on the consumer instead of narrowing them per authorization request. Enable exactly -Account read, Repositories read/write, Pull requests read/write, and Pipelines read/write -(`account`, `repository`, `repository:write`, `pullrequest`, `pullrequest:write`, `pipeline`, and -`pipeline:write`). Webhook permission is not required for the integration-only release. +Account read, Repositories read/write, Pull requests read/write, Pipelines read/write, and Webhooks +read/write (`account`, `repository`, `repository:write`, `pullrequest`, `pullrequest:write`, +`pipeline`, `pipeline:write`, and `webhook`). The webhook permission is required for automatic +trigger subscription management. ### Services with a different flow diff --git a/apps/sim/blocks/blocks/bitbucket.test.ts b/apps/sim/blocks/blocks/bitbucket.test.ts deleted file mode 100644 index dca6705271e..00000000000 --- a/apps/sim/blocks/blocks/bitbucket.test.ts +++ /dev/null @@ -1,389 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it, vi } from 'vitest' -import { BitbucketBlock, BitbucketBlockMeta } from '@/blocks/blocks/bitbucket' -import { tools as toolRegistry } from '@/tools/registry' - -vi.unmock('@/tools/registry') - -const SOURCE_COMMIT_SHA = 'a'.repeat(40) -const PIPELINE_COMMIT_SHA = 'b'.repeat(40) -const TARGET_COMMIT_SHA = 'c'.repeat(40) - -const EXPECTED_TOOL_IDS = [ - 'bitbucket_list_workspaces', - 'bitbucket_list_repositories', - 'bitbucket_get_repository', - 'bitbucket_list_branches', - 'bitbucket_create_branch', - 'bitbucket_delete_branch', - 'bitbucket_list_commits', - 'bitbucket_get_commit', - 'bitbucket_list_directory', - 'bitbucket_get_file_metadata', - 'bitbucket_get_file', - 'bitbucket_list_pull_requests', - 'bitbucket_get_pull_request', - 'bitbucket_create_pull_request', - 'bitbucket_merge_pull_request', - 'bitbucket_get_pull_request_merge_task_status', - 'bitbucket_decline_pull_request', - 'bitbucket_approve_pull_request', - 'bitbucket_request_pull_request_changes', - 'bitbucket_get_pull_request_diff', - 'bitbucket_get_pull_request_diffstat', - 'bitbucket_list_pull_request_comments', - 'bitbucket_create_pull_request_comment', - 'bitbucket_list_pull_request_commit_statuses', - 'bitbucket_list_pipelines', - 'bitbucket_get_pipeline', - 'bitbucket_trigger_pipeline', - 'bitbucket_stop_pipeline', - 'bitbucket_list_pipeline_steps', - 'bitbucket_get_pipeline_step_log', -] as const - -const SAMPLE_VALUES: Record = { - oauthCredential: 'credential-1', - workspaceSlug: 'acme', - repoSlug: 'platform', - pageLen: '25', - nextUrl: 'https://api.bitbucket.org/2.0/example?page=2', - query: 'state = "OPEN"', - sort: '-updated_on', - administrator: 'true', - role: 'contributor', - branchName: 'feature/review', - target: SOURCE_COMMIT_SHA, - revision: SOURCE_COMMIT_SHA, - path: 'src/index.ts', - state: 'OPEN', - prId: '42', - title: 'Improve pipeline diagnostics', - sourceBranch: 'feature/review', - destinationBranch: 'main', - description: 'Adds bounded log diagnostics.', - reviewerAccountIds: '{reviewer-a}, {reviewer-b}', - createCloseSourceBranch: 'true', - mergeCloseSourceBranch: 'true', - draft: 'true', - mergeStrategy: 'squash', - message: 'Merge pull request 42', - taskId: 'task-1', - content: 'Looks good after the test fix.', - parentId: '7', - pipelineRefType: 'BRANCH', - pipelineRefName: 'main', - pipelineCommitHash: PIPELINE_COMMIT_SHA, - pipelineSelectorType: 'BRANCH', - pipelineSelectorPattern: 'main', - pipelineTriggerType: 'MANUAL', - pipelineStatus: 'FAILED', - pipelineUuid: '{pipeline-uuid}', - targetRef: 'main', - targetCommitHash: TARGET_COMMIT_SHA, - stepUuid: '{step-uuid}', - maxCharacters: '65536', -} - -type BitbucketOperation = (typeof EXPECTED_TOOL_IDS)[number] - -function mapBlockParams( - operation: BitbucketOperation, - overrides: Record = {} -): Record { - const mapper = BitbucketBlock.tools.config?.params - if (!mapper) throw new Error('Bitbucket block is missing tools.config.params') - return mapper({ operation, ...SAMPLE_VALUES, ...overrides }) as Record -} - -function buildRequestUrl( - operation: BitbucketOperation, - overrides: Record = {} -): string { - const toolId = BitbucketBlock.tools.config?.tool({ operation }) - if (!toolId) throw new Error(`Bitbucket operation ${operation} did not resolve to a tool`) - const url = toolRegistry[toolId].request.url - if (typeof url !== 'function') return url - return url({ ...mapBlockParams(operation, overrides), accessToken: 'oauth-token' } as never) -} - -function buildRequestBody( - operation: BitbucketOperation, - overrides: Record = {} -): unknown { - const toolId = BitbucketBlock.tools.config?.tool({ operation }) - if (!toolId) throw new Error(`Bitbucket operation ${operation} did not resolve to a tool`) - const body = toolRegistry[toolId].request.body - if (!body) throw new Error(`Bitbucket tool ${toolId} is missing request.body`) - return body({ ...mapBlockParams(operation, overrides), accessToken: 'oauth-token' } as never) -} - -describe('BitbucketBlock', () => { - it('exposes every Bitbucket action one-to-one through the operation dropdown', () => { - const operation = BitbucketBlock.subBlocks.find((subBlock) => subBlock.id === 'operation') - const options = - typeof operation?.options === 'function' ? operation.options() : operation?.options - - expect(options?.map((option) => option.id).sort()).toEqual([...EXPECTED_TOOL_IDS].sort()) - expect([...BitbucketBlock.tools.access].sort()).toEqual([...EXPECTED_TOOL_IDS].sort()) - expect(new Set(options?.map((option) => option.id)).size).toBe(EXPECTED_TOOL_IDS.length) - }) - - it.each(EXPECTED_TOOL_IDS)('%s resolves to a registered tool', (operation) => { - const toolId = BitbucketBlock.tools.config?.tool({ operation }) - expect(toolId).toBe(operation) - expect(toolRegistry[toolId]).toBeDefined() - }) - - it.each(EXPECTED_TOOL_IDS)('%s supplies every required user-facing tool param', (operation) => { - const toolId = BitbucketBlock.tools.config?.tool({ operation }) as string - const mapped = BitbucketBlock.tools.config?.params?.({ operation, ...SAMPLE_VALUES }) ?? {} - const required = Object.entries(toolRegistry[toolId].params ?? {}) - .filter(([id, config]) => config.required && id !== 'accessToken') - .map(([id]) => id) - - expect(required.filter((id) => mapped[id] === undefined || mapped[id] === '')).toEqual([]) - }) - - it.each(EXPECTED_TOOL_IDS)('%s maps every non-hidden tool parameter', (operation) => { - const toolId = BitbucketBlock.tools.config?.tool({ operation }) as string - const mapped = BitbucketBlock.tools.config?.params?.({ operation, ...SAMPLE_VALUES }) ?? {} - const exposed = Object.entries(toolRegistry[toolId].params ?? {}) - .filter(([id, config]) => id !== 'accessToken' && config.visibility !== 'hidden') - .map(([id]) => id) - - expect(exposed.filter((id) => mapped[id] === undefined)).toEqual([]) - }) - - it.each(EXPECTED_TOOL_IDS)('%s does not emit unsupported execution params', (operation) => { - const toolId = BitbucketBlock.tools.config?.tool({ operation }) as string - const mapped = BitbucketBlock.tools.config?.params?.({ operation, ...SAMPLE_VALUES }) ?? {} - const accepted = new Set([...Object.keys(toolRegistry[toolId].params ?? {}), 'oauthCredential']) - - expect(Object.keys(mapped).filter((key) => !accepted.has(key))).toEqual([]) - }) - - it.each(EXPECTED_TOOL_IDS)('%s exposes every tool output through the block', (operation) => { - const toolOutputs = Object.keys(toolRegistry[operation].outputs ?? {}) - const blockOutputs = new Set(Object.keys(BitbucketBlock.outputs)) - - expect(toolOutputs.filter((key) => !blockOutputs.has(key))).toEqual([]) - }) - - it('uses canonical picker/manual pairs without colliding with visual subblock IDs', () => { - const expectedPairs = { - oauthCredential: ['accountPicker', 'credentialIdInput'], - workspaceSlug: ['workspacePicker', 'workspaceSlugInput'], - repoSlug: ['repositoryPicker', 'repositorySlugInput'], - } - - for (const [canonicalParamId, ids] of Object.entries(expectedPairs)) { - const members = BitbucketBlock.subBlocks.filter( - (subBlock) => subBlock.canonicalParamId === canonicalParamId - ) - expect(members.map((member) => member.id).sort()).toEqual([...ids].sort()) - expect(members.filter((member) => member.mode === 'basic')).toHaveLength(1) - expect(members.filter((member) => member.mode === 'advanced')).toHaveLength(1) - expect(members[0].required).toEqual(members[1].required) - expect(members[0].condition).toEqual(members[1].condition) - expect(BitbucketBlock.subBlocks.some((member) => member.id === canonicalParamId)).toBe(false) - } - - expect( - BitbucketBlock.subBlocks.find((member) => member.id === 'workspacePicker')?.dependsOn - ).toEqual(['accountPicker']) - expect( - BitbucketBlock.subBlocks.find((member) => member.id === 'workspaceSlugInput')?.dependsOn - ).toEqual(['credentialIdInput']) - expect( - BitbucketBlock.subBlocks.find((member) => member.id === 'repositoryPicker')?.dependsOn - ).toEqual(['accountPicker', 'workspacePicker']) - expect( - BitbucketBlock.subBlocks.find((member) => member.id === 'repositorySlugInput')?.dependsOn - ).toEqual(['credentialIdInput', 'workspaceSlugInput']) - }) - - it('coerces execution values and fixes pipeline triggering to a branch ref target', () => { - expect( - BitbucketBlock.tools.config?.params?.({ - operation: 'bitbucket_create_pull_request', - ...SAMPLE_VALUES, - }) - ).toMatchObject({ - reviewerUuids: ['{reviewer-a}', '{reviewer-b}'], - closeSourceBranch: true, - }) - - expect( - BitbucketBlock.tools.config?.params?.({ - operation: 'bitbucket_trigger_pipeline', - ...SAMPLE_VALUES, - }) - ).toMatchObject({ refType: 'branch', refName: 'main' }) - - expect( - BitbucketBlock.tools.config?.params?.({ - operation: 'bitbucket_list_repositories', - ...SAMPLE_VALUES, - }) - ).toMatchObject({ pageLen: 25 }) - - expect( - BitbucketBlock.tools.config?.params?.({ - operation: 'bitbucket_get_pull_request_diff', - ...SAMPLE_VALUES, - }) - ).toMatchObject({ prId: 42, path: 'src/index.ts', maxCharacters: 65536 }) - - expect( - BitbucketBlock.tools.config?.params?.({ - operation: 'bitbucket_get_file_metadata', - ...SAMPLE_VALUES, - }) - ).toMatchObject({ commit: SOURCE_COMMIT_SHA, path: 'src/index.ts' }) - - expect( - BitbucketBlock.tools.config?.params?.({ - operation: 'bitbucket_get_pull_request_merge_task_status', - ...SAMPLE_VALUES, - }) - ).toMatchObject({ prId: 42, taskId: 'task-1' }) - }) - - it.each([undefined, null, '', ' '])( - 'treats %p as omission across optional execution value kinds', - (value) => { - expect( - mapBlockParams('bitbucket_create_pull_request_comment', { parentId: value }).parentId - ).toBeUndefined() - expect( - mapBlockParams('bitbucket_create_pull_request', { draft: value }).draft - ).toBeUndefined() - expect( - mapBlockParams('bitbucket_create_pull_request', { description: value }).description - ).toBeUndefined() - expect(mapBlockParams('bitbucket_list_branches', { query: value }).q).toBeUndefined() - expect( - mapBlockParams('bitbucket_create_pull_request', { reviewerAccountIds: value }).reviewerUuids - ).toBeUndefined() - } - ) - - it('coerces canonical positive integer strings and reaches the real tool URL', () => { - expect(buildRequestUrl('bitbucket_decline_pull_request', { prId: '42' })).toMatch( - /\/pullrequests\/42\/decline$/ - ) - }) - - it.each([ - true, - false, - [7], - { value: 7 }, - 0, - 1.5, - Number.MAX_SAFE_INTEGER + 1, - 'false', - ' 42 ', - '01', - '7.0', - '1e2', - '-1', - ])('rejects malformed PR ids before a mutation request is built: %p', (prId) => { - expect(() => buildRequestUrl('bitbucket_decline_pull_request', { prId })).toThrow(/prId/) - }) - - it('does not let a create-pull-request close setting reach a merge', () => { - const merged = buildRequestBody('bitbucket_merge_pull_request', { - createCloseSourceBranch: 'true', - mergeCloseSourceBranch: '', - }) as Record - expect(merged).not.toHaveProperty('close_source_branch') - - const created = buildRequestBody('bitbucket_create_pull_request', { - createCloseSourceBranch: '', - mergeCloseSourceBranch: 'true', - }) as Record - expect(created).not.toHaveProperty('close_source_branch') - }) - - it('does not turn a malformed optional parent ID into a top-level comment', () => { - expect(() => - buildRequestBody('bitbucket_create_pull_request_comment', { parentId: { id: 7 } }) - ).toThrow(/parentId/) - }) - - it('preserves explicit false booleans and rejects other present boolean shapes', () => { - expect( - buildRequestBody('bitbucket_create_pull_request', { - createCloseSourceBranch: false, - draft: 'false', - }) - ).toMatchObject({ close_source_branch: false, draft: false }) - - for (const draft of [0, 1, 'yes', ' false ', [], {}]) { - expect( - () => mapBlockParams('bitbucket_create_pull_request', { draft }), - String(draft) - ).toThrow(/draft/) - } - }) - - it('validates reviewer lists atomically instead of filtering malformed elements', () => { - expect( - mapBlockParams('bitbucket_create_pull_request', { - reviewerAccountIds: [' {reviewer-a} ', '{reviewer-b}'], - }).reviewerUuids - ).toEqual(['{reviewer-a}', '{reviewer-b}']) - expect( - mapBlockParams('bitbucket_create_pull_request', { reviewerAccountIds: [] }).reviewerUuids - ).toEqual([]) - - for (const reviewerAccountIds of [ - ['{reviewer-a}', 7], - ['{reviewer-a}', ' '], - '{reviewer-a},, {reviewer-b}', - { uuid: '{reviewer-a}' }, - ]) { - expect( - () => mapBlockParams('bitbucket_create_pull_request', { reviewerAccountIds }), - JSON.stringify(reviewerAccountIds) - ).toThrow(/reviewerAccountIds/) - } - }) - - it('rejects malformed present string values instead of silently omitting them', () => { - expect(() => mapBlockParams('bitbucket_list_branches', { query: false })).toThrow(/query/) - expect(() => mapBlockParams('bitbucket_create_pull_request', { description: {} })).toThrow( - /description/ - ) - }) - - it('preserves meaningful whitespace in repository paths and authored text', () => { - expect( - BitbucketBlock.tools.config?.params?.({ - operation: 'bitbucket_get_file', - ...SAMPLE_VALUES, - path: ' docs/release notes.md ', - }) - ).toMatchObject({ path: ' docs/release notes.md ' }) - - expect( - BitbucketBlock.tools.config?.params?.({ - operation: 'bitbucket_create_pull_request_comment', - ...SAMPLE_VALUES, - content: ' indented Markdown\n', - }) - ).toMatchObject({ content: ' indented Markdown\n' }) - }) - - it('covers every action with a canvas sentence and declares no trigger support', () => { - const sentences = BitbucketBlock.canvasPresentation?.sentences?.byOperation ?? {} - expect(Object.keys(sentences).sort()).toEqual([...EXPECTED_TOOL_IDS].sort()) - expect(BitbucketBlock.triggerAllowed).toBeUndefined() - expect(BitbucketBlock.triggers).toBeUndefined() - expect(BitbucketBlockMeta.tags).not.toContain('webhooks') - }) -}) diff --git a/apps/sim/blocks/blocks/bitbucket.ts b/apps/sim/blocks/blocks/bitbucket.ts index 3cadadaa148..efbdabc1d43 100644 --- a/apps/sim/blocks/blocks/bitbucket.ts +++ b/apps/sim/blocks/blocks/bitbucket.ts @@ -1,7 +1,7 @@ import { BitbucketIcon } from '@/components/icons' -import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { getTrigger } from '@/triggers' const WORKSPACE_FIELD = ['workspacePicker', 'workspaceSlugInput'] as const const REPOSITORY_FIELD = ['repositoryPicker', 'repositorySlugInput'] as const @@ -146,11 +146,12 @@ export const BitbucketBlock: BlockConfig = { name: 'Bitbucket', description: 'Work with Bitbucket Cloud repositories, pull requests, and pipelines', longDescription: - 'Connect Bitbucket Cloud to inspect repositories and source, collaborate on pull requests, and diagnose or control pipelines. This action integration uses OAuth and does not create webhooks or triggers.', + 'Connect Bitbucket Cloud to inspect repositories and source, collaborate on pull requests, diagnose or control pipelines, and start workflows from repository and pull request events. OAuth is used for actions and automatic webhook management.', docsLink: 'https://docs.sim.ai/integrations/bitbucket', category: 'tools', integrationType: IntegrationType.DevOps, authMode: AuthMode.OAuth, + triggerAllowed: true, bgColor: '#FFFFFF', iconColor: '#2684FF', icon: BitbucketIcon, @@ -317,7 +318,15 @@ export const BitbucketBlock: BlockConfig = { canonicalParamId: 'oauthCredential', mode: 'basic', serviceId: 'bitbucket', - requiredScopes: getScopesForService('bitbucket'), + requiredScopes: [ + 'account', + 'repository', + 'repository:write', + 'pullrequest', + 'pullrequest:write', + 'pipeline', + 'pipeline:write', + ], placeholder: 'Select Bitbucket account', required: true, }, @@ -811,6 +820,25 @@ export const BitbucketBlock: BlockConfig = { }, placeholder: 'Maximum bounded content to return', }, + ...getTrigger('bitbucket_push').subBlocks, + ...getTrigger('bitbucket_repository_forked').subBlocks, + ...getTrigger('bitbucket_repository_updated').subBlocks, + ...getTrigger('bitbucket_commit_comment_created').subBlocks, + ...getTrigger('bitbucket_build_status_created').subBlocks, + ...getTrigger('bitbucket_build_status_updated').subBlocks, + ...getTrigger('bitbucket_pull_request_created').subBlocks, + ...getTrigger('bitbucket_pull_request_updated').subBlocks, + ...getTrigger('bitbucket_pull_request_approved').subBlocks, + ...getTrigger('bitbucket_pull_request_approval_removed').subBlocks, + ...getTrigger('bitbucket_pull_request_changes_requested').subBlocks, + ...getTrigger('bitbucket_pull_request_changes_request_removed').subBlocks, + ...getTrigger('bitbucket_pull_request_merged').subBlocks, + ...getTrigger('bitbucket_pull_request_declined').subBlocks, + ...getTrigger('bitbucket_pull_request_comment_created').subBlocks, + ...getTrigger('bitbucket_pull_request_comment_updated').subBlocks, + ...getTrigger('bitbucket_pull_request_comment_deleted').subBlocks, + ...getTrigger('bitbucket_pull_request_comment_resolved').subBlocks, + ...getTrigger('bitbucket_pull_request_comment_reopened').subBlocks, ], tools: { // Keep this list literal so the documentation generator can statically discover every action. @@ -1122,6 +1150,30 @@ export const BitbucketBlock: BlockConfig = { log: { type: 'string', description: 'Bounded pipeline step log tail' }, totalBytes: { type: 'number', description: 'Full pipeline log byte size when reported' }, }, + triggers: { + enabled: true, + available: [ + 'bitbucket_push', + 'bitbucket_repository_forked', + 'bitbucket_repository_updated', + 'bitbucket_commit_comment_created', + 'bitbucket_build_status_created', + 'bitbucket_build_status_updated', + 'bitbucket_pull_request_created', + 'bitbucket_pull_request_updated', + 'bitbucket_pull_request_approved', + 'bitbucket_pull_request_approval_removed', + 'bitbucket_pull_request_changes_requested', + 'bitbucket_pull_request_changes_request_removed', + 'bitbucket_pull_request_merged', + 'bitbucket_pull_request_declined', + 'bitbucket_pull_request_comment_created', + 'bitbucket_pull_request_comment_updated', + 'bitbucket_pull_request_comment_deleted', + 'bitbucket_pull_request_comment_resolved', + 'bitbucket_pull_request_comment_reopened', + ], + }, } export const BitbucketBlockMeta = { diff --git a/apps/sim/connectors/bitbucket/bitbucket.test.ts b/apps/sim/connectors/bitbucket/bitbucket.test.ts index a40289405ad..8b184f8a1e8 100644 --- a/apps/sim/connectors/bitbucket/bitbucket.test.ts +++ b/apps/sim/connectors/bitbucket/bitbucket.test.ts @@ -31,7 +31,13 @@ const REPOSITORY = { } function fileEntry(path: string, size = 10, attributes: string[] = []) { - return { type: 'commit_file', path, size, attributes, commit: { hash: COMMIT } } + return { + type: 'commit_file', + path, + size, + attributes, + commit: { hash: COMMIT }, + } } function dirEntry(path: string) { @@ -43,11 +49,17 @@ function pullRequestFixture(id: number, overrides: Record = {}) id, title: `PR ${id}`, state: 'OPEN', - summary: { raw: `Body of ${id}`, markup: 'markdown', html: `

Body of ${id}

` }, + summary: { + raw: `Body of ${id}`, + markup: 'markdown', + html: `

Body of ${id}

`, + }, author: { display_name: 'Ada Lovelace', nickname: 'ada' }, created_on: '2026-01-01T00:00:00.000000+00:00', updated_on: '2026-02-01T00:00:00.000000+00:00', - links: { html: { href: `https://bitbucket.org/acme/widgets/pull-requests/${id}` } }, + links: { + html: { href: `https://bitbucket.org/acme/widgets/pull-requests/${id}` }, + }, ...overrides, } } @@ -77,6 +89,14 @@ afterEach(() => { }) describe('bitbucket repository file listing', () => { + it('uses the least OAuth scope that covers both pull requests and repository reads', () => { + expect(bitbucketConnector.auth).toMatchObject({ + mode: 'oauth', + provider: 'bitbucket', + requiredScopes: ['pullrequest'], + }) + }) + it('lists text files as deferred stubs and drops binary, symlink, and submodule entries', async () => { mockApi([ [ @@ -171,7 +191,10 @@ describe('bitbucket repository file listing', () => { mockApi([ [ /\/src\/[a-f0-9]+\/\?/, - () => jsonResponse({ values: [dirEntry('a/b/c/d/e'), fileEntry('root.md')] }), + () => + jsonResponse({ + values: [dirEntry('a/b/c/d/e'), fileEntry('root.md')], + }), ], [ /\/src\/[a-f0-9]+\/a\/b\/c\/d\/e\/\?/, @@ -201,8 +224,7 @@ describe('bitbucket repository file listing', () => { }) it('replays the opaque next link verbatim instead of rebuilding it', async () => { - const nextUrl = - 'https://api.bitbucket.org/2.0/repositories/acme/widgets/src/abc/?page=2&opaque=xyz' + const nextUrl = `https://api.bitbucket.org/2.0/repositories/acme/widgets/src/${COMMIT}/?page=2&opaque=xyz` mockApi([ [/page=2/, () => jsonResponse({ values: [fileEntry('second.md')] })], [/\/src\//, () => jsonResponse({ values: [fileEntry('first.md')], next: nextUrl })], @@ -226,7 +248,11 @@ describe('bitbucket repository file listing', () => { mockApi([ [ /\/src\//, - () => jsonResponse({ values: [], next: 'https://evil.example.com/2.0/repositories/x' }), + () => + jsonResponse({ + values: [], + next: 'https://evil.example.com/2.0/repositories/x', + }), ], ]) @@ -242,6 +268,55 @@ describe('bitbucket repository file listing', () => { bitbucketConnector.listDocuments(ACCESS_TOKEN, CONFIG, first.nextCursor, syncContext) ).rejects.toThrow(/Bitbucket Cloud API/) }) + + it('rejects a next cursor for another Bitbucket repository', async () => { + mockApi([ + [ + /\/src\//, + () => + jsonResponse({ + values: [], + next: `https://api.bitbucket.org/2.0/repositories/other/repo/src/${COMMIT}/?page=2`, + }), + ], + ]) + + const syncContext: Record = {} + const first = await bitbucketConnector.listDocuments( + ACCESS_TOKEN, + CONFIG, + undefined, + syncContext + ) + + await expect( + bitbucketConnector.listDocuments(ACCESS_TOKEN, CONFIG, first.nextCursor, syncContext) + ).rejects.toThrow(/does not belong/) + }) + + it('rejects a malformed source pagination envelope', async () => { + mockApi([[/\/src\//, () => jsonResponse({ next: null })]]) + + await expect( + bitbucketConnector.listDocuments(ACCESS_TOKEN, CONFIG, undefined, {}) + ).rejects.toThrow(/values array/) + }) + + it('blocks reconciliation when a source entry lacks its documented path', async () => { + mockApi([[/\/src\//, () => jsonResponse({ values: [{ type: 'commit_file' }] })]]) + + const syncContext: Record = {} + const result = await bitbucketConnector.listDocuments( + ACCESS_TOKEN, + CONFIG, + undefined, + syncContext + ) + + expect(result.documents).toEqual([]) + expect(syncContext.listingCapped).toBe(true) + expect(syncContext.listingTruncated).toBe(true) + }) }) describe('bitbucket source listing timeouts', () => { @@ -278,13 +353,13 @@ describe('bitbucket source listing timeouts', () => { ) expect(syncContext.listingCapped).toBe(true) + expect(syncContext.listingTruncated).toBe(true) expect(result.documents).toEqual([]) expect(requestedUrls(/\/src\//)).toHaveLength(2) }) it('flags the listing capped when a replayed next cursor times out, without re-cutting it', async () => { - const nextUrl = - 'https://api.bitbucket.org/2.0/repositories/acme/widgets/src/abc/?page=2&max_depth=5' + const nextUrl = `https://api.bitbucket.org/2.0/repositories/acme/widgets/src/${COMMIT}/?page=2&max_depth=5` mockApi([ [/page=2/, () => new Response('timeout', { status: 555 })], [/\/src\//, () => jsonResponse({ values: [fileEntry('first.md')], next: nextUrl })], @@ -305,12 +380,13 @@ describe('bitbucket source listing timeouts', () => { ) expect(syncContext.listingCapped).toBe(true) + expect(syncContext.listingTruncated).toBe(true) expect(second.documents).toEqual([]) /** Exactly one attempt: an opaque cursor is never rebuilt at another depth. */ expect(requestedUrls(/page=2/)).toHaveLength(1) }) - it('leaves the listing reconcilable when a directory is simply absent', async () => { + it('blocks reconciliation when a pinned source directory is unexpectedly absent', async () => { mockApi([[/\/src\//, () => jsonResponse({ type: 'error' }, 404)]]) const syncContext: Record = {} @@ -321,17 +397,17 @@ describe('bitbucket source listing timeouts', () => { syncContext ) - expect(syncContext.listingCapped).toBeUndefined() + expect(syncContext.listingCapped).toBe(true) + expect(syncContext.listingTruncated).toBe(true) expect(result.hasMore).toBe(false) }) - it('flags the listing capped when the token stops working mid-walk', async () => { + it('fails the sync when the token stops working mid-walk', async () => { mockApi([[/\/src\//, () => jsonResponse({ type: 'error' }, 403)]]) - const syncContext: Record = {} - await bitbucketConnector.listDocuments(ACCESS_TOKEN, CONFIG, undefined, syncContext) - - expect(syncContext.listingCapped).toBe(true) + await expect( + bitbucketConnector.listDocuments(ACCESS_TOKEN, CONFIG, undefined, {}) + ).rejects.toThrow(/authorization failed.*403/i) }) }) @@ -340,7 +416,10 @@ describe('bitbucket maxItems cap', () => { mockApi([ [ /\/src\//, - () => jsonResponse({ values: [fileEntry('a.md'), fileEntry('b.md'), fileEntry('c.md')] }), + () => + jsonResponse({ + values: [fileEntry('a.md'), fileEntry('b.md'), fileEntry('c.md')], + }), ], ]) @@ -395,7 +474,7 @@ describe('bitbucket maxItems cap', () => { () => jsonResponse({ values: [fileEntry('a.md'), fileEntry('b.md')], - next: 'https://api.bitbucket.org/2.0/repositories/acme/widgets/src/abc/?page=2', + next: `https://api.bitbucket.org/2.0/repositories/acme/widgets/src/${COMMIT}/?page=2`, }), ], ]) @@ -463,7 +542,7 @@ describe('bitbucket pull request listing', () => { const url = requestedUrls(/\/pullrequests/)[0] expect(url).toContain('pagelen=50') /** `+` must reach Bitbucket percent-encoded or it is read as a space. */ - expect(url).toContain('fields=%2Bvalues.summary') + expect(url).toContain('fields=%2Bvalues.rendered.description') expect(url).toContain('sort=-id') expect(url).toContain('state=OPEN') }) @@ -500,17 +579,20 @@ describe('bitbucket pull request listing', () => { expect(url.searchParams.get('q')).toBe('updated_on > 2026-02-03T04:05:06.007+00:00') }) - it('maps the author-typed body and falls back to the rendered description', async () => { + it('prefers the rendered description and falls back to the summary', async () => { mockApi([ [ /\/pullrequests/, () => jsonResponse({ values: [ - pullRequestFixture(7), + pullRequestFixture(7, { + summary: { raw: 'Summary value' }, + rendered: { description: { raw: 'Rendered pull request body' } }, + }), pullRequestFixture(8, { - summary: undefined, - rendered: { description: { raw: 'Rendered body' } }, + summary: { raw: 'Summary fallback' }, + rendered: undefined, }), ], }), @@ -519,12 +601,28 @@ describe('bitbucket pull request listing', () => { const result = await bitbucketConnector.listDocuments(ACCESS_TOKEN, PR_CONFIG, undefined, {}) - expect(result.documents[0].content).toBe('PR 7\n\nBody of 7') + expect(result.documents[0].content).toBe('PR 7\n\nRendered pull request body') expect(result.documents[0].contentDeferred).toBe(false) expect(result.documents[0].contentHash).toBe( 'bitbucket:pr:acme/widgets:7:2026-02-01T00:00:00.000000+00:00' ) - expect(result.documents[1].content).toBe('PR 8\n\nRendered body') + expect(result.documents[1].content).toBe('PR 8\n\nSummary fallback') + }) + + it('blocks reconciliation when a pull request record has no documented ID', async () => { + mockApi([[/\/pullrequests/, () => jsonResponse({ values: [{ title: 'Malformed PR' }] })]]) + + const syncContext: Record = {} + const result = await bitbucketConnector.listDocuments( + ACCESS_TOKEN, + PR_CONFIG, + undefined, + syncContext + ) + + expect(result.documents).toEqual([]) + expect(syncContext.listingCapped).toBe(true) + expect(syncContext.listingTruncated).toBe(true) }) it('walks from the code phase into the pull request phase for combined content', async () => { @@ -599,6 +697,19 @@ describe('bitbucket getDocument', () => { expect(doc?.content).toBe('') }) + it('does not misclassify an undocumented redirect as Git LFS', async () => { + mockApi([ + [ + /\/src\/[a-f0-9]+\/moved\.txt$/, + () => new Response(null, { status: 302, headers: { Location: '/unexpected' } }), + ], + ]) + + await expect( + bitbucketConnector.getDocument(ACCESS_TOKEN, CONFIG, 'file:moved.txt', {}) + ).rejects.toThrow(/302/) + }) + it('surfaces a file with a NUL byte as a skipped binary', async () => { mockApi([ [ @@ -612,6 +723,20 @@ describe('bitbucket getDocument', () => { expect(doc?.skippedReason).toMatch(/Binary/) }) + it('surfaces non-UTF-8 source as skipped instead of indexing replacement characters', async () => { + mockApi([ + [ + /\/src\/[a-f0-9]+\/latin1\.txt$/, + () => new Response(Buffer.from([0x63, 0x61, 0x66, 0xe9]), { status: 200 }), + ], + ]) + + const doc = await bitbucketConnector.getDocument(ACCESS_TOKEN, CONFIG, 'file:latin1.txt', {}) + + expect(doc?.skippedReason).toMatch(/Non-UTF-8/) + expect(doc?.content).toBe('') + }) + it('returns null for a file the ref no longer carries', async () => { mockApi([[/\/src\/[a-f0-9]+\/gone\.md$/, () => jsonResponse({ type: 'error' }, 404)]]) @@ -634,6 +759,14 @@ describe('bitbucket getDocument', () => { expect(await bitbucketConnector.getDocument(ACCESS_TOKEN, CONFIG, 'pr:9', {})).toBeNull() }) + it('throws when a successful pull request response lacks its documented ID', async () => { + mockApi([[/\/pullrequests\/9$/, () => jsonResponse({ title: 'Malformed PR' })]]) + + await expect(bitbucketConnector.getDocument(ACCESS_TOKEN, CONFIG, 'pr:9', {})).rejects.toThrow( + /response was malformed/ + ) + }) + it('rejects an externalId with no recognized resource prefix', async () => { mockApi([]) @@ -704,7 +837,10 @@ describe('bitbucket validateConfig', () => { ]) expect( - await bitbucketConnector.validateConfig(ACCESS_TOKEN, { ...CONFIG, ref: 'v1.0' }) + await bitbucketConnector.validateConfig(ACCESS_TOKEN, { + ...CONFIG, + ref: 'v1.0', + }) ).toEqual({ valid: true }) }) @@ -712,7 +848,21 @@ describe('bitbucket validateConfig', () => { mockApi([]) expect( - await bitbucketConnector.validateConfig(ACCESS_TOKEN, { ...CONFIG, maxItems: '0' }) - ).toEqual({ valid: false, error: 'Max items must be a positive number' }) + await bitbucketConnector.validateConfig(ACCESS_TOKEN, { + ...CONFIG, + maxItems: '0', + }) + ).toEqual({ valid: false, error: 'Max items must be a positive integer' }) + }) + + it('rejects a fractional maxItems value', async () => { + mockApi([]) + + expect( + await bitbucketConnector.validateConfig(ACCESS_TOKEN, { + ...CONFIG, + maxItems: '1.5', + }) + ).toEqual({ valid: false, error: 'Max items must be a positive integer' }) }) }) diff --git a/apps/sim/connectors/bitbucket/bitbucket.ts b/apps/sim/connectors/bitbucket/bitbucket.ts index 686392662ca..484fbdcaf47 100644 --- a/apps/sim/connectors/bitbucket/bitbucket.ts +++ b/apps/sim/connectors/bitbucket/bitbucket.ts @@ -14,9 +14,10 @@ import { } from '@/connectors/utils' import { BITBUCKET_API_BASE, + bitbucketApiUrl, + bitbucketRepositoryPath, encodeBitbucketRepositoryPath, encodeBitbucketSegment, - validateBitbucketOpaqueUrl, } from '@/tools/bitbucket/utils' const logger = createLogger('BitbucketConnector') @@ -49,10 +50,10 @@ const PULL_REQUEST_PAGE_SIZE = 50 * enumerates `reviewers`/`participants` as such. The pull request body is exactly * the kind of field that carve-out covers and the reference never promises it on * the collection, so rather than assume, the connector asks for it explicitly: - * `+` additive syntax keeps every default field and adds `values.summary` on top. - * Without it a listing that omits the body would index title-only documents. + * `+` additive syntax keeps every default field and adds the rendered description + * on top. `summary` is retained only as a fallback. */ -const PULL_REQUEST_LIST_FIELDS = '+values.summary' +const PULL_REQUEST_LIST_FIELDS = '+values.rendered.description' /** * Sort key for the pull request collection. * @@ -78,11 +79,11 @@ const BINARY_SNIFF_BYTES = 8000 */ const MAX_TREE_DEPTH = 5 const BINARY_SKIP_REASON = 'Binary file was not indexed' +const NON_UTF8_SKIP_REASON = 'Non-UTF-8 file was not indexed' /** * Bitbucket answers a raw read of an LFS-managed file with a 301 to Atlassian's - * media services platform, which the connector does not follow — the redirect is - * cross-origin, so the bearer token would be stripped and the request would fail - * anyway. The file surfaces once as a skipped row instead of being dropped. + * media services platform. The connector deliberately surfaces the file as + * unsupported instead of following an unvalidated cross-origin location. */ const LFS_SKIP_REASON = 'Git LFS file was not indexed' @@ -114,11 +115,35 @@ interface BitbucketTreeEntry { } interface BitbucketPagedResponse { - values?: T[] + values: T[] /** Opaque absolute URL for the next page; absent on the last page. */ next?: string } +/** + * Validates Bitbucket's guaranteed pagination envelope before reconciliation can + * interpret it as a complete page. Treating a malformed response as an empty list + * would make the sync engine hard-delete documents that still exist upstream. + */ +function parsePagedResponse(value: unknown, resource: string): BitbucketPagedResponse { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`Bitbucket ${resource} response must be an object`) + } + + const record = value as Record + if (!Array.isArray(record.values)) { + throw new Error(`Bitbucket ${resource} response must include a values array`) + } + if (record.next !== undefined && typeof record.next !== 'string') { + throw new Error(`Bitbucket ${resource} response next cursor must be a URL`) + } + + return { + values: record.values as T[], + next: record.next as string | undefined, + } +} + interface BitbucketRenderedText { raw?: string markup?: string @@ -198,6 +223,17 @@ function readSlug(value: unknown): string { return typeof value === 'string' ? value.trim() : '' } +/** Reads an optional positive, finite integer document cap. */ +function readMaxItems(value: unknown): number { + if (value === undefined || value === null) return 0 + if (typeof value === 'string' && !value.trim()) return 0 + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error('Max items must be a positive integer') + } + return parsed +} + /** * Reads the parsed content-type choice from sourceConfig (defaults to 'code'). */ @@ -439,10 +475,9 @@ function fileToStub( /** * Builds a pull request document from either the collection or the `self` record. * - * The body is read from `summary.raw` — the text as the author typed it — which the - * listing requests explicitly via {@link PULL_REQUEST_LIST_FIELDS}, so no per-document - * fetch is needed. `rendered.description.raw` is the same text under the endpoint's - * rendered-markup element and stands in on the rare record that carries only that. + * The body is read from `rendered.description.raw`, the documented rendered-markup + * representation of the user-provided description. `summary.raw` is a fallback + * when the rendered description is absent. */ function pullRequestToDocument( repository: ResolvedRepository, @@ -453,7 +488,7 @@ function pullRequestToDocument( const title = pullRequest.title?.trim() || `Pull request #${id}` const description = - pullRequest.summary?.raw?.trim() || pullRequest.rendered?.description?.raw?.trim() || '' + pullRequest.rendered?.description?.raw?.trim() || pullRequest.summary?.raw?.trim() || '' const body = composeBody(title, description) if (!body.trim()) return null @@ -637,12 +672,17 @@ async function fetchSourceListing( accessToken: string, shallowUrl?: string ): Promise<{ response: Response; depth: number }> { - const response = await fetchWithRetry(url, { method: 'GET', headers: authHeaders(accessToken) }) + const response = await fetchWithRetry(url, { + method: 'GET', + headers: authHeaders(accessToken), + }) if (response.status !== 555 || !shallowUrl) { return { response, depth: requestedDepth } } - logger.warn('Bitbucket source listing timed out; retrying at depth 1', { url }) + logger.warn('Bitbucket source listing timed out; retrying at depth 1', { + url, + }) const shallow = await fetchWithRetry(shallowUrl, { method: 'GET', headers: authHeaders(accessToken), @@ -710,6 +750,17 @@ function pendingDirectories(syncContext: Record | undefined): s return syncContext.pendingDirs as string[] } +/** + * Marks a provider-truncated listing as unsafe for deletion reconciliation, even + * during a forced full sync. Configured max-item caps use only `listingCapped`; + * transient provider omissions must additionally use the engine's absolute gate. + */ +function markListingIncomplete(syncContext: Record | undefined): void { + if (!syncContext) return + syncContext.listingCapped = true + syncContext.listingTruncated = true +} + /** * Applies the optional maxItems cap to a page, tracking the running total in * syncContext and flagging `listingCapped` when the cap truncates the listing. @@ -774,7 +825,7 @@ export const bitbucketConnector: ConnectorConfig = { const phases = activePhases(choice) if (phases.length === 0) return { documents: [], hasMore: false } - const maxItems = sourceConfig.maxItems ? Number(sourceConfig.maxItems) : 0 + const maxItems = readMaxItems(sourceConfig.maxItems) const repository = await resolveRepository(syncContext, workspaceSlug, repoSlug, accessToken) let state = decodeCursor(cursor, phases[0]) @@ -819,14 +870,28 @@ export const bitbucketConnector: ConnectorConfig = { /** Depth-1 retry target, offered only for URLs this connector built itself. */ let shallowUrl: string | undefined if (state.nextUrl) { - url = validateBitbucketOpaqueUrl(state.nextUrl) dir = state.dir ?? '' requestedDepth = state.depth ?? MAX_TREE_DEPTH + const encodedDir = encodeBitbucketRepositoryPath(dir, true) + const repositoryPath = bitbucketRepositoryPath(workspaceSlug, repoSlug) + url = bitbucketApiUrl( + `${repositoryPath}/src/${encodeBitbucketSegment(commit, 'commit')}/${encodedDir}`, + { + nextUrl: state.nextUrl, + nextPathPrefix: `${repositoryPath}/src`, + nextPathSuffix: encodedDir, + nextRevision: commit, + } + ) } else { const nextDir = frontier.shift() if (nextDir === undefined) { const adv = advance('code') - return { documents: [], nextCursor: adv.nextCursor, hasMore: adv.hasMore } + return { + documents: [], + nextCursor: adv.nextCursor, + hasMore: adv.hasMore, + } } dir = nextDir requestedDepth = MAX_TREE_DEPTH @@ -860,31 +925,36 @@ export const bitbucketConnector: ConnectorConfig = { repository: repository.fullName, dir, }) - if (syncContext) syncContext.listingCapped = true + markListingIncomplete(syncContext) const skipped = continueCode(frontier) - return { documents: [], nextCursor: skipped.nextCursor, hasMore: skipped.hasMore } + return { + documents: [], + nextCursor: skipped.nextCursor, + hasMore: skipped.hasMore, + } } if (response.status === 404) { /** - * `resolveRepository` already proved the repository is readable, so a 404 - * here can only mean the directory (or an empty repository's root) is - * absent. Reconciliation is left enabled so its files delete normally. + * Every directory was discovered at the same pinned commit, so a later 404 + * is an inconsistent partial listing rather than evidence that documents + * were deleted. Keep walking, but block deletion reconciliation. */ logger.warn('Bitbucket source path not found; skipping', { repository: repository.fullName, dir, }) + markListingIncomplete(syncContext) const skipped = continueCode(frontier) - return { documents: [], nextCursor: skipped.nextCursor, hasMore: skipped.hasMore } + return { + documents: [], + nextCursor: skipped.nextCursor, + hasMore: skipped.hasMore, + } } if (response.status === 401 || response.status === 403) { - /** - * The token stopped working mid-sync. Flag the listing as incomplete so - * deletion reconciliation does not hard-delete previously synced files. - */ - if (syncContext) syncContext.listingCapped = true - const adv = advance('code') - return { documents: [], nextCursor: adv.nextCursor, hasMore: adv.hasMore } + throw new Error( + `Bitbucket authorization failed while listing repository files: ${response.status}` + ) } const errorText = await response.text().catch(() => '') logger.error('Failed to list Bitbucket repository files', { @@ -894,14 +964,29 @@ export const bitbucketConnector: ConnectorConfig = { throw new Error(`Failed to list Bitbucket repository files: ${response.status}`) } - const page = (await response.json()) as BitbucketPagedResponse - const entries = Array.isArray(page.values) ? page.values : [] + const page = parsePagedResponse(await response.json(), 'source listing') + const entries = page.values const dirDepth = pathDepth(dir) const documents: ExternalDocument[] = [] for (const entry of entries) { + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { + logger.warn('Skipping malformed Bitbucket source entry', { + repository: repository.fullName, + dir, + }) + markListingIncomplete(syncContext) + continue + } const path = typeof entry.path === 'string' ? entry.path : '' - if (!path) continue + if (!path) { + logger.warn('Skipping Bitbucket source entry without a path', { + repository: repository.fullName, + dir, + }) + markListingIncomplete(syncContext) + continue + } if (entry.type === 'commit_directory') { /** @@ -916,7 +1001,15 @@ export const bitbucketConnector: ConnectorConfig = { continue } - if (entry.type !== 'commit_file') continue + if (entry.type !== 'commit_file') { + logger.warn('Skipping Bitbucket source entry with an unknown type', { + repository: repository.fullName, + dir, + type: entry.type, + }) + markListingIncomplete(syncContext) + continue + } const attributes = entryAttributes(entry) if (attributes.has('binary') || attributes.has('link') || attributes.has('subrepository')) { @@ -945,17 +1038,28 @@ export const bitbucketConnector: ConnectorConfig = { if (page.next) { return { documents: capped, - nextCursor: encodeCursor({ phase: 'code', nextUrl: page.next, dir, depth }), + nextCursor: encodeCursor({ + phase: 'code', + nextUrl: page.next, + dir, + depth, + }), hasMore: true, } } const cont = continueCode(frontier) - return { documents: capped, nextCursor: cont.nextCursor, hasMore: cont.hasMore } + return { + documents: capped, + nextCursor: cont.nextCursor, + hasMore: cont.hasMore, + } } let url: string if (state.nextUrl) { - url = validateBitbucketOpaqueUrl(state.nextUrl) + url = bitbucketApiUrl(`${bitbucketRepositoryPath(workspaceSlug, repoSlug)}/pullrequests`, { + nextUrl: state.nextUrl, + }) } else { const prUrl = new URL(repositoryUrl(workspaceSlug, repoSlug, '/pullrequests')) prUrl.searchParams.set('pagelen', String(PULL_REQUEST_PAGE_SIZE)) @@ -968,8 +1072,8 @@ export const bitbucketConnector: ConnectorConfig = { /** * The filtering reference is explicit that the paginated envelope's * `values.` prefix must not appear in a query field, so the field is - * named bare even though the partial response above addresses it as - * `values.summary`. + * named bare even though the partial response above addresses nested + * fields under `values`. */ prUrl.searchParams.set('q', `updated_on > ${bbqlDateTime(lastSyncAt)}`) } @@ -996,11 +1100,21 @@ export const bitbucketConnector: ConnectorConfig = { throw new Error(`Failed to list Bitbucket pull requests: ${response.status}`) } - const page = (await response.json()) as BitbucketPagedResponse + const page = parsePagedResponse( + await response.json(), + 'pull request listing' + ) const documents: ExternalDocument[] = [] - for (const pullRequest of Array.isArray(page.values) ? page.values : []) { + for (const pullRequest of page.values) { const doc = pullRequestToDocument(repository, pullRequest) - if (doc) documents.push(doc) + if (doc) { + documents.push(doc) + } else { + logger.warn('Skipping malformed Bitbucket pull request', { + repository: repository.fullName, + }) + markListingIncomplete(syncContext) + } } const { documents: capped, capped: hitLimit } = applyMaxItemsCap( @@ -1047,7 +1161,14 @@ export const bitbucketConnector: ConnectorConfig = { if (response.status === 404) return null throw new Error(`Failed to fetch Bitbucket pull request: ${response.status}`) } - return pullRequestToDocument(repository, (await response.json()) as BitbucketPullRequest) + const document = pullRequestToDocument( + repository, + (await response.json()) as BitbucketPullRequest + ) + if (!document) { + throw new Error(`Bitbucket pull request ${id} response was malformed`) + } + return document } if (!externalId.startsWith(FILE_PREFIX)) return null @@ -1078,7 +1199,7 @@ export const bitbucketConnector: ConnectorConfig = { const stub = fileToStub(repository, ref, commit, path, undefined) - if (response.status >= 300 && response.status < 400) { + if (response.status === 301) { logger.info('Skipping Bitbucket LFS-managed file', { path }) return markSkipped(stub, LFS_SKIP_REASON) } @@ -1088,7 +1209,10 @@ export const bitbucketConnector: ConnectorConfig = { const buffer = await readBodyWithLimit(response, MAX_FILE_SIZE) if (buffer === null) { - logger.info('Skipping oversized Bitbucket file', { path, limit: MAX_FILE_SIZE }) + logger.info('Skipping oversized Bitbucket file', { + path, + limit: MAX_FILE_SIZE, + }) return markSkipped(stub, sizeLimitSkipReason(MAX_FILE_SIZE)) } if (isBinaryBuffer(buffer)) { @@ -1096,7 +1220,15 @@ export const bitbucketConnector: ConnectorConfig = { return markSkipped(stub, BINARY_SKIP_REASON) } - const body = composeBody(stub.title, buffer.toString('utf8')) + let text: string + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(buffer) + } catch { + logger.info('Skipping non-UTF-8 Bitbucket file', { path }) + return markSkipped(stub, NON_UTF8_SKIP_REASON) + } + + const body = composeBody(stub.title, text) if (!body.trim()) return null return { @@ -1130,9 +1262,13 @@ export const bitbucketConnector: ConnectorConfig = { const repoSlug = readSlug(sourceConfig.repoSlug) if (!repoSlug) return { valid: false, error: 'Repository is required' } - const maxItems = sourceConfig.maxItems as string | undefined - if (maxItems && (Number.isNaN(Number(maxItems)) || Number(maxItems) <= 0)) { - return { valid: false, error: 'Max items must be a positive number' } + try { + readMaxItems(sourceConfig.maxItems) + } catch (error) { + return { + valid: false, + error: getErrorMessage(error, 'Max items must be a positive integer'), + } } const choice = getContentTypeChoice(sourceConfig) @@ -1152,10 +1288,16 @@ export const bitbucketConnector: ConnectorConfig = { } } if (response.status === 401 || response.status === 403) { - return { valid: false, error: 'Invalid credential or insufficient permissions' } + return { + valid: false, + error: 'Invalid credential or insufficient permissions', + } } if (!response.ok) { - return { valid: false, error: `Cannot access repository: ${response.status}` } + return { + valid: false, + error: `Cannot access repository: ${response.status}`, + } } const record = (await response.json()) as BitbucketRepositoryRecord @@ -1187,7 +1329,10 @@ export const bitbucketConnector: ConnectorConfig = { return { valid: true } } catch (error) { - return { valid: false, error: getErrorMessage(error, 'Failed to validate configuration') } + return { + valid: false, + error: getErrorMessage(error, 'Failed to validate configuration'), + } } }, diff --git a/apps/sim/connectors/bitbucket/meta.ts b/apps/sim/connectors/bitbucket/meta.ts index c250d59743a..6f599b8f314 100644 --- a/apps/sim/connectors/bitbucket/meta.ts +++ b/apps/sim/connectors/bitbucket/meta.ts @@ -31,7 +31,7 @@ export const bitbucketConnectorMeta: ConnectorMeta = { auth: { mode: 'oauth', provider: 'bitbucket', - requiredScopes: ['repository', 'pullrequest'], + requiredScopes: ['pullrequest'], }, configFields: [ @@ -88,7 +88,7 @@ export const bitbucketConnectorMeta: ConnectorMeta = { ], placeholder: 'Code (repository files) only', description: - 'Which content to index. Defaults to repository files when left unset. Bitbucket Cloud wikis and the issue tracker are not indexed — see the connector notes.', + 'Which content to index. Defaults to repository files when left unset. Git LFS-managed files, Bitbucket Cloud wikis, and the issue tracker are not indexed.', }, { id: 'ref', diff --git a/apps/sim/lib/oauth/oauth.test.ts b/apps/sim/lib/oauth/oauth.test.ts index 7bb0a750676..582ac55ff01 100644 --- a/apps/sim/lib/oauth/oauth.test.ts +++ b/apps/sim/lib/oauth/oauth.test.ts @@ -128,6 +128,7 @@ describe('Bitbucket OAuth Connector', () => { 'pullrequest:write', 'pipeline', 'pipeline:write', + 'webhook', ], responseType: 'code', pkce: false, @@ -150,6 +151,7 @@ describe('Bitbucket OAuth Connector', () => { 'pullrequest:write', 'pipeline', 'pipeline:write', + 'webhook', ] const mockFetch = createMockFetch({ json: { @@ -188,6 +190,29 @@ describe('Bitbucket OAuth Connector', () => { }) }) + it('normalizes Bitbucket’s singular scope response field', async () => { + const getToken = getBitbucketConnector().getToken + if (!getToken) throw new Error('Bitbucket connector must define getToken') + + const tokens = await withMockFetch( + createMockFetch({ + json: { + access_token: 'bitbucket_access_token', + refresh_token: 'bitbucket_refresh_token', + scope: 'account repository pullrequest webhook', + token_type: 'bearer', + }, + }), + () => + getToken({ + code: 'authorization_code', + redirectURI: 'http://localhost:3000/api/auth/oauth2/callback/bitbucket', + }) + ) + + expect(tokens.scopes).toEqual(['account', 'repository', 'pullrequest', 'webhook']) + }) + it('uses account_id before uuid and always synthesizes an internal email', async () => { const connector = getBitbucketConnector() const getUserInfo = connector.getUserInfo diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index e2641809be0..2dd43e6999d 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -731,6 +731,7 @@ export const OAUTH_PROVIDERS: Record = { 'pullrequest:write', 'pipeline', 'pipeline:write', + 'webhook', ], }, }, diff --git a/apps/sim/lib/oauth/utils.test.ts b/apps/sim/lib/oauth/utils.test.ts index 760b91ec51d..2b6a3116c8b 100644 --- a/apps/sim/lib/oauth/utils.test.ts +++ b/apps/sim/lib/oauth/utils.test.ts @@ -391,6 +391,7 @@ describe('getScopeDescription', () => { 'View your Bitbucket account and workspace memberships' ) expect(getScopeDescription('pipeline:write', 'bitbucket')).toBe('Run and stop pipelines') + expect(getScopeDescription('webhook', 'bitbucket')).toBe('Manage repository webhooks') }) it.concurrent('preserves the existing Reddit meaning of the account scope', () => { @@ -664,6 +665,19 @@ describe('getScopesForService', () => { expect(scopes).not.toContain('Calendars.ReadWrite.Shared') }) + it.concurrent('should include webhook management in Bitbucket consent scopes', () => { + expect(getScopesForService('bitbucket')).toEqual([ + 'account', + 'repository', + 'repository:write', + 'pullrequest', + 'pullrequest:write', + 'pipeline', + 'pipeline:write', + 'webhook', + ]) + }) + it.concurrent('should return empty array for empty string', () => { const scopes = getScopesForService('') diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts index 367f6ae829a..449ff8754ec 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -484,6 +484,7 @@ const PROVIDER_SCOPE_DESCRIPTIONS: Readonly ({ + mockGetCredentialOwner: vi.fn(), + mockRefreshAccessTokenIfNeeded: vi.fn(), +})) + +vi.mock('@/lib/oauth/credential-service', () => ({ + refreshAccessTokenIfNeeded: mockRefreshAccessTokenIfNeeded, +})) + +vi.mock('@/lib/webhooks/provider-subscription-utils', () => ({ + getProviderConfig: (webhook: { providerConfig?: Record }) => + webhook.providerConfig || {}, + getNotificationUrl: () => 'https://app.example.com/api/webhooks/trigger/bitbucket-path', + getCredentialOwner: mockGetCredentialOwner, +})) + +import { IdempotencyService } from '@/lib/core/idempotency/service' +import { bitbucketHandler } from '@/lib/webhooks/providers/bitbucket' +import { getProviderHandler } from '@/lib/webhooks/providers/registry' +import { + BITBUCKET_TRIGGER_EVENT_MAP, + type BitbucketTriggerId, + buildBitbucketOutputs, +} from '@/triggers/bitbucket/utils' + +const fetchMock = vi.fn() +const CALLBACK_URL = 'https://app.example.com/api/webhooks/trigger/bitbucket-path' +const CANDIDATE_DESCRIPTION = 'Sim workflow trigger (bitbucket_push) [sim:webhook-1]' + +const BASE_OUTPUT_KEYS = [ + 'actor', + 'attemptNumber', + 'eventType', + 'hookUuid', + 'payload', + 'repository', + 'requestUuid', +] + +const PULL_REQUEST_OUTPUT_KEYS = [ + ...BASE_OUTPUT_KEYS, + 'destinationBranch', + 'pullRequest', + 'pullRequestId', + 'pullRequestState', + 'pullRequestTitle', + 'sourceBranch', +].sort() + +function requestWithHeaders(headers: Record): NextRequest { + return new NextRequest('http://localhost/test', { headers }) +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function emptyResponse(status: number): Response { + return new Response(null, { status }) +} + +function subscriptionContext( + providerConfig: Record, + webhookOverrides: Record = {} +) { + return { + webhook: { + id: 'webhook-1', + path: 'bitbucket-path', + registrationStatus: 'candidate', + providerConfig, + ...webhookOverrides, + }, + workflow: {}, + userId: 'user-1', + requestId: 'request-1', + request: requestWithHeaders({}), + } as never +} + +function deliveryContext( + triggerId: string, + body: Record, + headers: Record = {} +) { + return { + webhook: { providerConfig: { triggerId } }, + workflow: { id: 'workflow-1', userId: 'user-1' }, + body, + headers: { + 'x-event-key': BITBUCKET_TRIGGER_EVENT_MAP[triggerId], + 'x-hook-uuid': '{hook-1}', + 'x-request-uuid': '{request-uuid-1}', + 'x-attempt-number': '2', + ...headers, + }, + query: {}, + method: 'POST', + requestId: 'request-1', + } as never +} + +describe('Bitbucket webhook provider', () => { + beforeEach(() => { + vi.clearAllMocks() + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + mockGetCredentialOwner.mockResolvedValue({ accountId: 'account-1', userId: 'user-1' }) + mockRefreshAccessTokenIfNeeded.mockResolvedValue('oauth-token') + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('is registered under the Bitbucket provider key', () => { + expect(getProviderHandler('bitbucket')).toBe(bitbucketHandler) + }) + + describe('verifyAuth', () => { + function authContext( + headers: Record, + rawBody: string, + providerConfig: Record + ) { + return { + request: requestWithHeaders(headers), + rawBody, + requestId: 'request-1', + providerConfig, + webhook: {}, + workflow: {}, + } as never + } + + it('accepts Atlassian’s published HMAC-SHA256 test vector', async () => { + const response = await bitbucketHandler.verifyAuth!( + authContext( + { + 'X-Hub-Signature': + 'sha256=a4771c39fbe90f317c7824e83ddef3caae9cb3d976c214ace1f2937e133263c9', + }, + 'Hello World!', + { webhookSecret: "It's a Secret to Everybody" } + ) + ) + + expect(response).toBeNull() + }) + + it('fails closed without a configured secret', async () => { + const response = await bitbucketHandler.verifyAuth!(authContext({}, '{}', {})) + expect(response?.status).toBe(401) + }) + + it('rejects a missing signature', async () => { + const response = await bitbucketHandler.verifyAuth!( + authContext({}, '{}', { webhookSecret: 'secret' }) + ) + expect(response?.status).toBe(401) + }) + + it.each([ + ['an unsupported algorithm', `sha1=${'a'.repeat(40)}`], + ['a malformed digest', 'sha256=not-hex'], + ['an incorrect digest', `sha256=${'0'.repeat(64)}`], + ])('rejects %s', async (_label, signature) => { + const response = await bitbucketHandler.verifyAuth!( + authContext({ 'X-Hub-Signature': signature }, '{}', { webhookSecret: 'secret' }) + ) + expect(response?.status).toBe(401) + }) + }) + + describe('matchEvent', () => { + it.each(Object.entries(BITBUCKET_TRIGGER_EVENT_MAP))( + 'matches %s to %s', + async (triggerId, eventKey) => { + const result = await bitbucketHandler.matchEvent!({ + webhook: {}, + workflow: {}, + body: {}, + request: requestWithHeaders({ 'X-Event-Key': eventKey }), + requestId: 'request-1', + providerConfig: { triggerId }, + }) + expect(result).toBe(true) + } + ) + + it('rejects a mismatched event key', async () => { + const result = await bitbucketHandler.matchEvent!({ + webhook: {}, + workflow: {}, + body: {}, + request: requestWithHeaders({ 'X-Event-Key': 'pullrequest:created' }), + requestId: 'request-1', + providerConfig: { triggerId: 'bitbucket_push' }, + }) + expect(result).toBe(false) + }) + + it.each([ + [{ triggerId: 'bitbucket_push' }, {}], + [{}, { 'X-Event-Key': 'repo:push' }], + [{ triggerId: 'unknown_bitbucket_trigger' }, { 'X-Event-Key': 'repo:push' }], + ])('fails closed for missing or unknown routing data', async (providerConfig, headers) => { + const result = await bitbucketHandler.matchEvent!({ + webhook: {}, + workflow: {}, + body: {}, + request: requestWithHeaders(headers), + requestId: 'request-1', + providerConfig, + }) + expect(result).toBe(false) + }) + }) + + describe('idempotency', () => { + function enrich(requestUuid: string | undefined, attemptNumber: string) { + const headers: Record = { 'x-attempt-number': attemptNumber } + if (requestUuid !== undefined) headers['x-request-uuid'] = requestUuid + bitbucketHandler.enrichHeaders!({} as never, headers) + return headers + } + + it.each([ + ['the same request UUID across attempts', '{request-1}', '1', '{request-1}', '2', true], + ['different request UUIDs', '{request-1}', '1', '{request-2}', '1', false], + ])( + '%s produce the expected key relationship', + (_label, firstUuid, firstAttempt, secondUuid, secondAttempt, shouldMatch) => { + const payload = { repository: { uuid: '{repo-1}' }, changes: { name: {} } } + const first = IdempotencyService.createWebhookIdempotencyKey( + 'webhook-1', + enrich(firstUuid, firstAttempt), + payload, + 'bitbucket' + ) + const second = IdempotencyService.createWebhookIdempotencyKey( + 'webhook-1', + enrich(secondUuid, secondAttempt), + payload, + 'bitbucket' + ) + + expect(first === second).toBe(shouldMatch) + } + ) + + it.each([undefined, '', ' '])( + 'does not inject a key for a missing or blank request UUID: %j', + (requestUuid) => { + expect(enrich(requestUuid, '1')).not.toHaveProperty('x-sim-idempotency-key') + } + ) + }) + + describe('formatInput', () => { + const actor = { display_name: 'Ada' } + const repository = { uuid: '{repo-1}', name: 'sim' } + + it.each(Object.keys(BITBUCKET_TRIGGER_EVENT_MAP))( + 'aligns the runtime keys for %s with its trigger output contract', + async (triggerId) => { + const body = { + actor, + repository, + push: { changes: [] }, + fork: { uuid: '{fork-1}' }, + changes: { name: { old: 'old', new: 'new' } }, + commit: { hash: 'abc123' }, + commit_status: { + key: 'ci/build', + state: 'SUCCESSFUL', + name: 'CI', + url: 'https://ci.example.com/build/1', + links: { + commit: { + href: 'https://api.bitbucket.org/2.0/repositories/acme/sim/commit/abc123', + }, + }, + }, + pullrequest: { + id: 42, + title: 'Add Bitbucket triggers', + state: 'OPEN', + source: { branch: { name: 'feature' } }, + destination: { branch: { name: 'staging' } }, + }, + approval: { date: '2026-08-21T00:00:00Z' }, + changes_request: { date: '2026-08-21T00:00:00Z' }, + comment: { id: 99, content: { raw: 'Please update this' } }, + } + const { input } = await bitbucketHandler.formatInput!(deliveryContext(triggerId, body)) + expect(Object.keys(input as Record).sort()).toEqual( + Object.keys(buildBitbucketOutputs(triggerId as BitbucketTriggerId)).sort() + ) + } + ) + + it.each(Object.keys(BITBUCKET_TRIGGER_EVENT_MAP))( + 'keeps output alignment and nulls event data for a malformed %s payload', + async (triggerId) => { + const { input } = await bitbucketHandler.formatInput!(deliveryContext(triggerId, {})) + const formatted = input as Record + const outputKeys = Object.keys(buildBitbucketOutputs(triggerId as BitbucketTriggerId)) + + expect(Object.keys(formatted).sort()).toEqual(outputKeys.sort()) + for (const key of outputKeys) { + if (['eventType', 'hookUuid', 'requestUuid', 'attemptNumber', 'payload'].includes(key)) { + continue + } + expect(formatted[key]).toBeNull() + } + } + ) + + it('formats push events with common delivery metadata and the documented push object', async () => { + const body = { actor, repository, push: { changes: [] } } + const { input } = await bitbucketHandler.formatInput!(deliveryContext('bitbucket_push', body)) + const formatted = input as Record + + expect(Object.keys(formatted).sort()).toEqual([...BASE_OUTPUT_KEYS, 'push'].sort()) + expect(formatted).toMatchObject({ + eventType: 'repo:push', + hookUuid: '{hook-1}', + requestUuid: '{request-uuid-1}', + attemptNumber: 2, + actor, + repository, + push: body.push, + }) + expect(formatted.payload).toBe(body) + }) + + it.each([ + ['bitbucket_repository_forked', 'fork'], + ['bitbucket_repository_updated', 'changes'], + ])('formats %s with only its documented event object', async (triggerId, field) => { + const eventObject = { value: 'documented' } + const { input } = await bitbucketHandler.formatInput!( + deliveryContext(triggerId, { actor, repository, [field]: eventObject }) + ) + const formatted = input as Record + expect(Object.keys(formatted).sort()).toEqual([...BASE_OUTPUT_KEYS, field].sort()) + expect(formatted[field]).toBe(eventObject) + }) + + it('formats commit comments and documented comment scalars', async () => { + const comment = { id: 17, content: { raw: 'Looks good' } } + const commit = { hash: 'abc123' } + const { input } = await bitbucketHandler.formatInput!( + deliveryContext('bitbucket_commit_comment_created', { + actor, + repository, + comment, + commit, + }) + ) + expect(Object.keys(input as Record).sort()).toEqual( + [...BASE_OUTPUT_KEYS, 'comment', 'commentId', 'commentContent', 'commit'].sort() + ) + expect(input).toMatchObject({ comment, commentId: 17, commentContent: 'Looks good', commit }) + }) + + it.each(['bitbucket_build_status_created', 'bitbucket_build_status_updated'])( + 'formats %s without assuming an undocumented commit object', + async (triggerId) => { + const commitStatus = { + key: 'ci/build', + state: 'SUCCESSFUL', + name: 'CI', + url: 'https://ci.example.com/build/1', + links: { + commit: { + href: 'https://api.bitbucket.org/2.0/repositories/acme/sim/commit/abc123', + }, + }, + } + const { input } = await bitbucketHandler.formatInput!( + deliveryContext(triggerId, { actor, repository, commit_status: commitStatus }) + ) + expect(Object.keys(input as Record).sort()).toEqual( + [ + ...BASE_OUTPUT_KEYS, + 'commitStatus', + 'commitHash', + 'statusKey', + 'statusState', + 'statusName', + 'statusUrl', + ].sort() + ) + expect(input).toMatchObject({ + commitStatus, + commitHash: 'abc123', + statusKey: 'ci/build', + statusState: 'SUCCESSFUL', + statusName: 'CI', + statusUrl: 'https://ci.example.com/build/1', + }) + } + ) + + const pullRequest = { + id: 42, + title: 'Add Bitbucket triggers', + state: 'OPEN', + source: { branch: { name: 'feature' } }, + destination: { branch: { name: 'staging' } }, + } + + it.each([ + 'bitbucket_pull_request_created', + 'bitbucket_pull_request_updated', + 'bitbucket_pull_request_merged', + 'bitbucket_pull_request_declined', + ])('formats the documented pull-request contract for %s', async (triggerId) => { + const { input } = await bitbucketHandler.formatInput!( + deliveryContext(triggerId, { actor, repository, pullrequest: pullRequest }) + ) + expect(Object.keys(input as Record).sort()).toEqual(PULL_REQUEST_OUTPUT_KEYS) + expect(input).toMatchObject({ + pullRequest, + pullRequestId: 42, + pullRequestTitle: 'Add Bitbucket triggers', + pullRequestState: 'OPEN', + sourceBranch: 'feature', + destinationBranch: 'staging', + }) + }) + + it.each(['bitbucket_pull_request_approved', 'bitbucket_pull_request_approval_removed'])( + 'adds the documented approval object for %s', + async (triggerId) => { + const approval = { date: '2026-08-21T00:00:00Z' } + const { input } = await bitbucketHandler.formatInput!( + deliveryContext(triggerId, { actor, repository, pullrequest: pullRequest, approval }) + ) + expect(Object.keys(input as Record).sort()).toEqual( + [...PULL_REQUEST_OUTPUT_KEYS, 'approval'].sort() + ) + expect((input as Record).approval).toBe(approval) + } + ) + + it.each([ + 'bitbucket_pull_request_changes_requested', + 'bitbucket_pull_request_changes_request_removed', + ])('adds the documented changes-request object for %s', async (triggerId) => { + const changesRequest = { date: '2026-08-21T00:00:00Z' } + const { input } = await bitbucketHandler.formatInput!( + deliveryContext(triggerId, { + actor, + repository, + pullrequest: pullRequest, + changes_request: changesRequest, + }) + ) + expect(Object.keys(input as Record).sort()).toEqual( + [...PULL_REQUEST_OUTPUT_KEYS, 'changesRequest'].sort() + ) + expect((input as Record).changesRequest).toBe(changesRequest) + }) + + it.each([ + 'bitbucket_pull_request_comment_created', + 'bitbucket_pull_request_comment_updated', + 'bitbucket_pull_request_comment_deleted', + 'bitbucket_pull_request_comment_resolved', + 'bitbucket_pull_request_comment_reopened', + ])('adds the documented comment object and scalars for %s', async (triggerId) => { + const comment = { id: 99, content: { raw: 'Please update this' } } + const { input } = await bitbucketHandler.formatInput!( + deliveryContext(triggerId, { + actor, + repository, + pullrequest: pullRequest, + comment, + }) + ) + expect(Object.keys(input as Record).sort()).toEqual( + [...PULL_REQUEST_OUTPUT_KEYS, 'comment', 'commentId', 'commentContent'].sort() + ) + expect(input).toMatchObject({ + comment, + commentId: 99, + commentContent: 'Please update this', + }) + }) + + it('uses null for absent or malformed optional fields', async () => { + const { input } = await bitbucketHandler.formatInput!( + deliveryContext( + 'bitbucket_build_status_created', + {}, + { 'x-attempt-number': 'not-a-number', 'x-hook-uuid': '', 'x-request-uuid': '' } + ) + ) + expect(input).toEqual({ + eventType: 'repo:commit_status_created', + hookUuid: null, + requestUuid: null, + attemptNumber: null, + actor: null, + repository: null, + payload: {}, + commitStatus: null, + commitHash: null, + statusKey: null, + statusState: null, + statusName: null, + statusUrl: null, + }) + }) + }) + + describe('createSubscription', () => { + const validConfig = { + triggerId: 'bitbucket_push', + credentialId: 'credential-1', + workspaceSlug: 'team / blue', + repoSlug: 'repo?admin=true', + } + const hooksUrl = + 'https://api.bitbucket.org/2.0/repositories/team%20%2F%20blue/repo%3Fadmin%3Dtrue/hooks' + const activeHook = { + uuid: '{active-hook}', + description: 'Sim workflow trigger (bitbucket_push) [sim:active-webhook]', + url: CALLBACK_URL, + } + const candidateHook = { + uuid: '{candidate-hook}', + description: CANDIDATE_DESCRIPTION, + url: CALLBACK_URL, + active: true, + events: ['repo:push'], + secret_set: true, + } + + function hooksResponse(values: Array>): Response { + return jsonResponse(200, { values }) + } + + it('creates one signed repository hook for the selected event', async () => { + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout') + fetchMock + .mockResolvedValueOnce(hooksResponse([])) + .mockResolvedValueOnce(jsonResponse(201, { uuid: '{hook-uuid}' })) + + const result = await bitbucketHandler.createSubscription!(subscriptionContext(validConfig)) + + expect(mockGetCredentialOwner).toHaveBeenCalledWith('credential-1', 'request-1') + expect(mockRefreshAccessTokenIfNeeded).toHaveBeenCalledWith( + 'account-1', + 'user-1', + 'request-1' + ) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[0][0]).toBe(`${hooksUrl}?pagelen=100`) + const [url, init] = fetchMock.mock.calls[1] as [string, RequestInit] + expect(url).toBe(hooksUrl) + expect(init.method).toBe('POST') + expect(init.headers).toMatchObject({ + Accept: 'application/json', + Authorization: 'Bearer oauth-token', + 'Content-Type': 'application/json', + }) + const sentBody = JSON.parse(String(init.body)) as Record + expect(sentBody).toEqual({ + description: CANDIDATE_DESCRIPTION, + url: CALLBACK_URL, + active: true, + secret: expect.any(String), + events: ['repo:push'], + }) + expect(result?.providerConfigUpdates).toEqual({ + externalId: '{hook-uuid}', + webhookSecret: sentBody.secret, + eventTypes: ['repo:push'], + }) + expect(timeoutSpy).toHaveBeenCalledTimes(2) + expect(timeoutSpy).toHaveBeenNthCalledWith(1, 15_000) + expect(timeoutSpy).toHaveBeenNthCalledWith(2, 15_000) + const firstSignal = (fetchMock.mock.calls[0][1] as RequestInit).signal + const secondSignal = (fetchMock.mock.calls[1][1] as RequestInit).signal + expect(firstSignal).not.toBe(secondSignal) + }) + + it.each([ + [401, /reconnect/i], + [403, /repository administrator.*webhook scope/i], + [404, /repository not found/i], + [429, /rate limited/i], + ])('maps Bitbucket HTTP %s to an actionable error', async (status, message) => { + fetchMock + .mockResolvedValueOnce(hooksResponse([])) + .mockResolvedValueOnce(jsonResponse(status, { error: { message: 'provider detail' } })) + await expect( + bitbucketHandler.createSubscription!(subscriptionContext(validConfig)) + ).rejects.toThrow(message) + }) + + it('surfaces a Bitbucket webhook-limit rejection detail', async () => { + fetchMock + .mockResolvedValueOnce(hooksResponse([])) + .mockResolvedValueOnce( + jsonResponse(400, { error: { message: 'Repository webhook limit exceeded' } }) + ) + await expect( + bitbucketHandler.createSubscription!(subscriptionContext(validConfig)) + ).rejects.toThrow(/webhook limit exceeded/i) + }) + + it.each([ + [{ ...validConfig, credentialId: undefined }, /account connection required/i], + [{ ...validConfig, workspaceSlug: '' }, /workspace is required/i], + [{ ...validConfig, repoSlug: '' }, /repository is required/i], + [{ ...validConfig, triggerId: 'bitbucket_unknown' }, /unknown bitbucket trigger/i], + ])('validates required subscription configuration', async (config, message) => { + await expect( + bitbucketHandler.createSubscription!(subscriptionContext(config)) + ).rejects.toThrow(message) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it.each([undefined, null, '', ' '])( + 'requires a nonblank webhook ID before managing a subscription', + async (webhookId) => { + await expect( + bitbucketHandler.createSubscription!(subscriptionContext(validConfig, { id: webhookId })) + ).rejects.toThrow(/webhook ID is required/i) + + expect(fetchMock).not.toHaveBeenCalled() + expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled() + } + ) + + it.each([{}, { uuid: ' ' }])( + 'rolls back only the proven redeploy candidate when a successful response has no usable UUID', + async (createResponse) => { + fetchMock + .mockResolvedValueOnce(hooksResponse([activeHook])) + .mockResolvedValueOnce(jsonResponse(201, createResponse)) + .mockResolvedValueOnce(hooksResponse([activeHook, candidateHook])) + .mockResolvedValueOnce(emptyResponse(204)) + + await expect( + bitbucketHandler.createSubscription!(subscriptionContext(validConfig)) + ).rejects.toThrow(/no hook UUID/i) + + expect(fetchMock).toHaveBeenCalledTimes(4) + expect(fetchMock.mock.calls[2][1]).toMatchObject({ + headers: { Authorization: 'Bearer oauth-token' }, + }) + expect(fetchMock.mock.calls[3][0]).toBe(`${hooksUrl}/%7Bcandidate-hook%7D`) + expect(fetchMock.mock.calls[3][0]).not.toContain('active-hook') + expect(fetchMock.mock.calls[3][1]).toMatchObject({ method: 'DELETE' }) + } + ) + + it('rolls back only the proven candidate after a lost create response', async () => { + const timeoutError = new DOMException('The operation timed out', 'TimeoutError') + const controllers = Array.from({ length: 4 }, () => new AbortController()) + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout') + for (const controller of controllers) timeoutSpy.mockReturnValueOnce(controller.signal) + fetchMock + .mockResolvedValueOnce(hooksResponse([activeHook])) + .mockImplementationOnce((_url, init: RequestInit) => { + const signal = init.signal as AbortSignal + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + controllers[1].abort(timeoutError) + }) + }) + .mockResolvedValueOnce(hooksResponse([activeHook, candidateHook])) + .mockResolvedValueOnce(emptyResponse(204)) + + await expect( + bitbucketHandler.createSubscription!(subscriptionContext(validConfig)) + ).rejects.toMatchObject({ name: 'TimeoutError' }) + + expect(fetchMock).toHaveBeenCalledTimes(4) + expect(fetchMock.mock.calls[3][0]).toBe(`${hooksUrl}/%7Bcandidate-hook%7D`) + expect(fetchMock.mock.calls[3][0]).not.toContain('active-hook') + expect(timeoutSpy).toHaveBeenCalledTimes(4) + expect(timeoutSpy).toHaveBeenCalledWith(15_000) + }) + + it.each([408, 503])( + 'reconciles an ambiguous HTTP %s create outcome without touching the active hook', + async (status) => { + fetchMock + .mockResolvedValueOnce(hooksResponse([activeHook])) + .mockResolvedValueOnce( + jsonResponse(status, { error: { message: 'temporarily unavailable' } }) + ) + .mockResolvedValueOnce(hooksResponse([activeHook, candidateHook])) + .mockResolvedValueOnce(emptyResponse(204)) + + await expect( + bitbucketHandler.createSubscription!(subscriptionContext(validConfig)) + ).rejects.toThrow(new RegExp(`failed to create Bitbucket webhook: ${status}`, 'i')) + + expect(fetchMock.mock.calls[3][0]).toBe(`${hooksUrl}/%7Bcandidate-hook%7D`) + expect(fetchMock.mock.calls[3][0]).not.toContain('active-hook') + } + ) + + it('leaves hooks untouched when an ambiguous response has multiple candidate matches', async () => { + fetchMock + .mockResolvedValueOnce(hooksResponse([activeHook])) + .mockResolvedValueOnce(jsonResponse(201, {})) + .mockResolvedValueOnce( + hooksResponse([ + activeHook, + candidateHook, + { ...candidateHook, uuid: '{second-candidate-hook}' }, + ]) + ) + + await expect( + bitbucketHandler.createSubscription!(subscriptionContext(validConfig)) + ).rejects.toThrow(/no hook UUID/i) + + expect(fetchMock).toHaveBeenCalledTimes(3) + expect( + fetchMock.mock.calls.some( + ([, init]) => (init as RequestInit | undefined)?.method === 'DELETE' + ) + ).toBe(false) + }) + + it.each([ + ['no candidate matches', [activeHook]], + [ + 'the only candidate match has no usable UUID', + [activeHook, { ...candidateHook, uuid: ' ' }], + ], + ])('leaves hooks untouched when %s', async (_case, reconciliationHooks) => { + fetchMock + .mockResolvedValueOnce(hooksResponse([activeHook])) + .mockResolvedValueOnce(jsonResponse(201, {})) + .mockResolvedValueOnce(hooksResponse(reconciliationHooks)) + + await expect( + bitbucketHandler.createSubscription!(subscriptionContext(validConfig)) + ).rejects.toThrow(/no hook UUID/i) + + expect(fetchMock).toHaveBeenCalledTimes(3) + expect( + fetchMock.mock.calls.some( + ([, init]) => (init as RequestInit | undefined)?.method === 'DELETE' + ) + ).toBe(false) + }) + + it('leaves hooks untouched when candidate lookup fails after an ambiguous response', async () => { + fetchMock + .mockResolvedValueOnce(hooksResponse([activeHook])) + .mockResolvedValueOnce(jsonResponse(201, {})) + .mockResolvedValueOnce(jsonResponse(500, { error: { message: 'list failed' } })) + + await expect( + bitbucketHandler.createSubscription!(subscriptionContext(validConfig)) + ).rejects.toThrow(/no hook UUID/i) + + expect(fetchMock).toHaveBeenCalledTimes(3) + expect( + fetchMock.mock.calls.some( + ([, init]) => (init as RequestInit | undefined)?.method === 'DELETE' + ) + ).toBe(false) + }) + + it('leaves hooks untouched when post-create reconciliation returns a malformed list', async () => { + fetchMock + .mockResolvedValueOnce(hooksResponse([activeHook])) + .mockResolvedValueOnce(jsonResponse(201, {})) + .mockResolvedValueOnce(jsonResponse(200, { unexpected: [] })) + + await expect( + bitbucketHandler.createSubscription!(subscriptionContext(validConfig)) + ).rejects.toThrow(/no hook UUID/i) + + expect(fetchMock).toHaveBeenCalledTimes(3) + expect( + fetchMock.mock.calls.some( + ([, init]) => (init as RequestInit | undefined)?.method === 'DELETE' + ) + ).toBe(false) + }) + + it('leaves hooks untouched when post-create reconciliation times out', async () => { + fetchMock + .mockResolvedValueOnce(hooksResponse([activeHook])) + .mockResolvedValueOnce(jsonResponse(201, {})) + .mockRejectedValueOnce(new DOMException('The operation timed out', 'TimeoutError')) + + await expect( + bitbucketHandler.createSubscription!(subscriptionContext(validConfig)) + ).rejects.toThrow(/no hook UUID/i) + + expect(fetchMock).toHaveBeenCalledTimes(3) + expect( + fetchMock.mock.calls.some( + ([, init]) => (init as RequestInit | undefined)?.method === 'DELETE' + ) + ).toBe(false) + }) + + it('does not retry or touch the active hook when proven-candidate deletion times out', async () => { + fetchMock + .mockResolvedValueOnce(hooksResponse([activeHook])) + .mockResolvedValueOnce(jsonResponse(201, {})) + .mockResolvedValueOnce(hooksResponse([activeHook, candidateHook])) + .mockRejectedValueOnce(new DOMException('The operation timed out', 'TimeoutError')) + + await expect( + bitbucketHandler.createSubscription!(subscriptionContext(validConfig)) + ).rejects.toThrow(/no hook UUID/i) + + expect(fetchMock).toHaveBeenCalledTimes(4) + expect(fetchMock.mock.calls[3][0]).toBe(`${hooksUrl}/%7Bcandidate-hook%7D`) + expect(fetchMock.mock.calls[3][0]).not.toContain('active-hook') + }) + + it('does not retry or touch the active hook when proven-candidate deletion is non-OK', async () => { + fetchMock + .mockResolvedValueOnce(hooksResponse([activeHook])) + .mockResolvedValueOnce(jsonResponse(201, {})) + .mockResolvedValueOnce(hooksResponse([activeHook, candidateHook])) + .mockResolvedValueOnce( + jsonResponse(503, { error: { message: 'candidate deletion unavailable' } }) + ) + + await expect( + bitbucketHandler.createSubscription!(subscriptionContext(validConfig)) + ).rejects.toThrow(/no hook UUID/i) + + expect(fetchMock).toHaveBeenCalledTimes(4) + expect(fetchMock.mock.calls[3][0]).toBe(`${hooksUrl}/%7Bcandidate-hook%7D`) + expect(fetchMock.mock.calls[3][0]).not.toContain('active-hook') + expect(fetchMock.mock.calls[3][1]).toMatchObject({ method: 'DELETE' }) + }) + + it('removes one stale uncheckpointed candidate before retrying creation', async () => { + fetchMock + .mockResolvedValueOnce(hooksResponse([activeHook, candidateHook])) + .mockResolvedValueOnce(emptyResponse(204)) + .mockResolvedValueOnce(jsonResponse(201, { uuid: '{new-candidate-hook}' })) + + const result = await bitbucketHandler.createSubscription!(subscriptionContext(validConfig)) + + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(fetchMock.mock.calls[1][0]).toBe(`${hooksUrl}/%7Bcandidate-hook%7D`) + expect(fetchMock.mock.calls[1][1]).toMatchObject({ method: 'DELETE' }) + expect(fetchMock.mock.calls[2][1]).toMatchObject({ method: 'POST' }) + expect(result?.providerConfigUpdates).toMatchObject({ externalId: '{new-candidate-hook}' }) + }) + + it('continues creation when a stale candidate was already deleted', async () => { + fetchMock + .mockResolvedValueOnce(hooksResponse([activeHook, candidateHook])) + .mockResolvedValueOnce(emptyResponse(404)) + .mockResolvedValueOnce(jsonResponse(201, { uuid: '{new-candidate-hook}' })) + + const result = await bitbucketHandler.createSubscription!(subscriptionContext(validConfig)) + + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(fetchMock.mock.calls[1][1]).toMatchObject({ method: 'DELETE' }) + expect(fetchMock.mock.calls[2][1]).toMatchObject({ method: 'POST' }) + expect(result?.providerConfigUpdates).toMatchObject({ externalId: '{new-candidate-hook}' }) + }) + + it('reuses a matching checkpointed candidate without creating or deleting another hook', async () => { + fetchMock.mockResolvedValueOnce(hooksResponse([activeHook, candidateHook])) + + const result = await bitbucketHandler.createSubscription!( + subscriptionContext({ + ...validConfig, + externalId: '{candidate-hook}', + webhookSecret: 'checkpointed-secret', + }) + ) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(result?.providerConfigUpdates).toEqual({ + externalId: '{candidate-hook}', + webhookSecret: 'checkpointed-secret', + eventTypes: ['repo:push'], + }) + }) + + it.each([ + ['a mismatched UUID', { externalId: '{different-hook}', webhookSecret: 'old-secret' }], + ['a missing secret', { externalId: '{candidate-hook}' }], + ])('does not reuse a checkpoint with %s', async (_case, checkpoint) => { + fetchMock + .mockResolvedValueOnce(hooksResponse([activeHook, candidateHook])) + .mockResolvedValueOnce(emptyResponse(204)) + .mockResolvedValueOnce(jsonResponse(201, { uuid: '{new-candidate-hook}' })) + + const result = await bitbucketHandler.createSubscription!( + subscriptionContext({ ...validConfig, ...checkpoint }) + ) + + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(fetchMock.mock.calls[1][1]).toMatchObject({ method: 'DELETE' }) + expect(fetchMock.mock.calls[2][1]).toMatchObject({ method: 'POST' }) + expect(result?.providerConfigUpdates).toMatchObject({ externalId: '{new-candidate-hook}' }) + }) + + it.each([ + ['disabled', { active: false }], + ['missing its secret', { secret_set: false }], + ['subscribed to the wrong event', { events: ['pullrequest:created'] }], + ['subscribed to additional events', { events: ['repo:push', 'pullrequest:created'] }], + ])('replaces a checkpointed candidate that is %s', async (_case, hookOverride) => { + fetchMock + .mockResolvedValueOnce(hooksResponse([activeHook, { ...candidateHook, ...hookOverride }])) + .mockResolvedValueOnce(emptyResponse(204)) + .mockResolvedValueOnce(jsonResponse(201, { uuid: '{new-candidate-hook}' })) + + const result = await bitbucketHandler.createSubscription!( + subscriptionContext({ + ...validConfig, + externalId: '{candidate-hook}', + webhookSecret: 'checkpointed-secret', + }) + ) + + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(fetchMock.mock.calls[1][1]).toMatchObject({ method: 'DELETE' }) + expect(fetchMock.mock.calls[2][1]).toMatchObject({ method: 'POST' }) + expect(result?.providerConfigUpdates).toMatchObject({ externalId: '{new-candidate-hook}' }) + }) + + it('aborts before creation when candidate preflight cannot establish a unique state', async () => { + fetchMock.mockResolvedValueOnce( + hooksResponse([candidateHook, { ...candidateHook, uuid: '{second-candidate-hook}' }]) + ) + + await expect( + bitbucketHandler.createSubscription!(subscriptionContext(validConfig)) + ).rejects.toThrow(/found 2 matching hooks/i) + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('does not delete by ID-based description for legacy non-candidate creation', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(201, {})) + + await expect( + bitbucketHandler.createSubscription!( + subscriptionContext(validConfig, { registrationStatus: undefined }) + ) + ).rejects.toThrow(/no hook UUID/i) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock.mock.calls[0][1]).toMatchObject({ method: 'POST' }) + }) + + it('does not create when candidate preflight times out', async () => { + const timeoutError = new DOMException('The operation timed out', 'TimeoutError') + fetchMock.mockRejectedValueOnce(timeoutError) + + await expect( + bitbucketHandler.createSubscription!(subscriptionContext(validConfig)) + ).rejects.toMatchObject({ name: 'TimeoutError' }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + }) + + describe('deleteSubscription', () => { + const validConfig = { + credentialId: 'credential-1', + workspaceSlug: 'acme', + repoSlug: 'sim', + externalId: '{hook-uuid}', + } + + it.each([204, 404])('treats HTTP %s as successful cleanup', async (status) => { + fetchMock.mockResolvedValueOnce(emptyResponse(status)) + + await expect( + bitbucketHandler.deleteSubscription!(subscriptionContext(validConfig)) + ).resolves.toBeUndefined() + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.bitbucket.org/2.0/repositories/acme/sim/hooks/%7Bhook-uuid%7D', + expect.objectContaining({ method: 'DELETE' }) + ) + }) + + it('normalizes manually entered repository coordinates before cleanup', async () => { + fetchMock.mockResolvedValueOnce(emptyResponse(204)) + + await bitbucketHandler.deleteSubscription!( + subscriptionContext({ + ...validConfig, + workspaceSlug: ' acme ', + repoSlug: ' sim ', + externalId: ' {hook-uuid} ', + }) + ) + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.bitbucket.org/2.0/repositories/acme/sim/hooks/%7Bhook-uuid%7D', + expect.objectContaining({ method: 'DELETE' }) + ) + }) + + it('logs an ordinary deletion failure without rejecting', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(500, { error: { message: 'temporary provider failure' } }) + ) + await expect( + bitbucketHandler.deleteSubscription!(subscriptionContext(validConfig)) + ).resolves.toBeUndefined() + }) + + it('throws a deletion failure during strict outbox cleanup', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(500, { error: { message: 'temporary provider failure' } }) + ) + await expect( + bitbucketHandler.deleteSubscription!({ + ...subscriptionContext(validConfig), + strict: true, + }) + ).rejects.toThrow(/failed to delete Bitbucket webhook: 500/i) + }) + + it('handles management-request timeouts according to cleanup strictness', async () => { + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout') + fetchMock + .mockRejectedValueOnce(new DOMException('The operation timed out', 'TimeoutError')) + .mockRejectedValueOnce(new DOMException('The operation timed out', 'TimeoutError')) + + await expect( + bitbucketHandler.deleteSubscription!(subscriptionContext(validConfig)) + ).resolves.toBeUndefined() + await expect( + bitbucketHandler.deleteSubscription!({ + ...subscriptionContext(validConfig), + strict: true, + }) + ).rejects.toMatchObject({ name: 'TimeoutError' }) + + expect(timeoutSpy).toHaveBeenCalledTimes(2) + expect(timeoutSpy).toHaveBeenNthCalledWith(1, 15_000) + expect(timeoutSpy).toHaveBeenNthCalledWith(2, 15_000) + expect((fetchMock.mock.calls[0][1] as RequestInit).signal).not.toBe( + (fetchMock.mock.calls[1][1] as RequestInit).signal + ) + }) + + it('handles missing cleanup configuration according to strictness', async () => { + await expect( + bitbucketHandler.deleteSubscription!(subscriptionContext({})) + ).resolves.toBeUndefined() + await expect( + bitbucketHandler.deleteSubscription!({ + ...subscriptionContext({}), + strict: true, + }) + ).rejects.toThrow(/missing Bitbucket credential/i) + expect(fetchMock).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/lib/webhooks/providers/bitbucket.ts b/apps/sim/lib/webhooks/providers/bitbucket.ts new file mode 100644 index 00000000000..589c81ede5a --- /dev/null +++ b/apps/sim/lib/webhooks/providers/bitbucket.ts @@ -0,0 +1,666 @@ +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' +import { getErrorMessage, toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { toRecord, toRecordOrNull } from '@sim/utils/object' +import { truncate } from '@sim/utils/string' +import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' +import { + getCredentialOwner, + getNotificationUrl, + getProviderConfig, +} from '@/lib/webhooks/provider-subscription-utils' +import type { + DeleteSubscriptionContext, + EventMatchContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' +import { + BITBUCKET_API_BASE, + bitbucketHeaders, + bitbucketRepositoryPath, + encodeBitbucketSegment, +} from '@/tools/bitbucket/utils' + +const logger = createLogger('WebhookProvider:Bitbucket') +const BITBUCKET_MANAGEMENT_REQUEST_TIMEOUT_MS = 15_000 + +const PULL_REQUEST_TRIGGER_IDS = new Set([ + 'bitbucket_pull_request_created', + 'bitbucket_pull_request_updated', + 'bitbucket_pull_request_approved', + 'bitbucket_pull_request_approval_removed', + 'bitbucket_pull_request_changes_requested', + 'bitbucket_pull_request_changes_request_removed', + 'bitbucket_pull_request_merged', + 'bitbucket_pull_request_declined', + 'bitbucket_pull_request_comment_created', + 'bitbucket_pull_request_comment_updated', + 'bitbucket_pull_request_comment_deleted', + 'bitbucket_pull_request_comment_resolved', + 'bitbucket_pull_request_comment_reopened', +]) + +const PULL_REQUEST_APPROVAL_TRIGGER_IDS = new Set([ + 'bitbucket_pull_request_approved', + 'bitbucket_pull_request_approval_removed', +]) + +const PULL_REQUEST_CHANGES_REQUEST_TRIGGER_IDS = new Set([ + 'bitbucket_pull_request_changes_requested', + 'bitbucket_pull_request_changes_request_removed', +]) + +const PULL_REQUEST_COMMENT_TRIGGER_IDS = new Set([ + 'bitbucket_pull_request_comment_created', + 'bitbucket_pull_request_comment_updated', + 'bitbucket_pull_request_comment_deleted', + 'bitbucket_pull_request_comment_resolved', + 'bitbucket_pull_request_comment_reopened', +]) + +function bitbucketHooksUrl(workspaceSlug: string, repoSlug: string): string { + return `${BITBUCKET_API_BASE}${bitbucketRepositoryPath(workspaceSlug, repoSlug)}/hooks` +} + +function fetchBitbucketManagement(url: string, init: RequestInit = {}): Promise { + return fetch(url, { + ...init, + signal: AbortSignal.timeout(BITBUCKET_MANAGEMENT_REQUEST_TIMEOUT_MS), + }) +} + +function validateBitbucketSignature(secret: string, signature: string, body: string): boolean { + const match = /^sha256=([a-f\d]{64})$/i.exec(signature.trim()) + if (!secret || !match) return false + + const expected = hmacSha256Hex(body, secret) + return safeCompare(expected, match[1].toLowerCase()) +} + +async function resolveBitbucketAccessToken( + credentialId: string | undefined, + requestId: string +): Promise { + if (!credentialId) { + throw new Error( + 'Bitbucket account connection required. Connect or reconnect Bitbucket in the trigger configuration and try again.' + ) + } + + const owner = await getCredentialOwner(credentialId, requestId) + const accessToken = owner + ? await refreshAccessTokenIfNeeded(owner.accountId, owner.userId, requestId) + : null + + if (!accessToken) { + throw new Error( + 'Bitbucket account connection required. Connect or reconnect Bitbucket in the trigger configuration and try again.' + ) + } + + return accessToken +} + +function readRequiredConfigString( + config: Record, + key: 'workspaceSlug' | 'repoSlug', + label: string +): string { + const value = config[key] + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`Bitbucket ${label} is required to manage the repository webhook.`) + } + return value.trim() +} + +async function readBitbucketErrorDetail(response: Response): Promise { + const payload = toRecord(await response.json().catch(() => null)) + const error = toRecord(payload.error) + const message = error.message + return typeof message === 'string' && message.trim() ? truncate(message.trim(), 300) : '' +} + +function createBitbucketApiError(status: number, action: 'create' | 'delete', detail = ''): Error { + const operation = action === 'create' ? 'create' : 'delete' + if (status === 401) { + return new Error( + `Bitbucket authentication failed while trying to ${operation} the webhook. Reconnect the Bitbucket account and try again.` + ) + } + if (status === 403) { + return new Error( + `Bitbucket denied permission to ${operation} the webhook. The connected account must be a repository administrator and grant the webhook scope.` + ) + } + if (status === 404) { + return new Error( + `Bitbucket repository not found while trying to ${operation} the webhook. Verify the workspace and repository selections.` + ) + } + if (status === 429) { + return new Error(`Bitbucket rate limited the webhook ${operation} request. Try again shortly.`) + } + if (status === 400 && action === 'create') { + return new Error( + detail + ? `Bitbucket rejected the webhook creation request: ${detail}` + : 'Bitbucket rejected the webhook creation request. Check the repository webhook limit and trigger configuration.' + ) + } + return new Error( + `Failed to ${operation} Bitbucket webhook: ${status}${detail ? ` (${detail})` : ''}` + ) +} + +type BitbucketCandidateLookup = + | { kind: 'absent' } + | { + kind: 'found' + externalId: string + active: boolean + events: string[] + secretSet: boolean + } + | { kind: 'ambiguous'; matchCount: number } + | { kind: 'unavailable'; error: Error } + +function bitbucketHookDescription(triggerId: string, webhookId: string): string { + return `Sim workflow trigger (${triggerId}) [sim:${webhookId}]` +} + +async function findBitbucketCandidateHook( + workspaceSlug: string, + repoSlug: string, + accessToken: string, + callbackUrl: string, + description: string, + requestId: string +): Promise { + const hooksUrl = bitbucketHooksUrl(workspaceSlug, repoSlug) + let response: Response + try { + response = await fetchBitbucketManagement(`${hooksUrl}?pagelen=100`, { + headers: bitbucketHeaders(accessToken), + }) + } catch (error) { + logger.warn(`[${requestId}] Could not inspect Bitbucket hooks for candidate reconciliation`, { + error: getErrorMessage(error), + }) + return { + kind: 'unavailable', + error: toError(error), + } + } + + if (!response.ok) { + const detail = await readBitbucketErrorDetail(response) + return { + kind: 'unavailable', + error: createBitbucketApiError(response.status, 'create', detail), + } + } + + const parsed = await response.json().catch(() => null) + const payload = toRecord(parsed) + if (!Array.isArray(payload.values)) { + return { + kind: 'unavailable', + error: new Error('Bitbucket returned a malformed webhook-list response.'), + } + } + + const matchingHooks = payload.values.filter((value) => { + const hook = toRecord(value) + return hook.url === callbackUrl && hook.description === description + }) + + if (matchingHooks.length === 0) return { kind: 'absent' } + if (matchingHooks.length !== 1) { + return { kind: 'ambiguous', matchCount: matchingHooks.length } + } + + const matchedHook = toRecord(matchingHooks[0]) + const externalId = nullableString(matchedHook.uuid)?.trim() + return externalId + ? { + kind: 'found', + externalId, + active: matchedHook.active === true, + events: Array.isArray(matchedHook.events) + ? matchedHook.events.filter((event): event is string => typeof event === 'string') + : [], + secretSet: matchedHook.secret_set === true, + } + : { kind: 'ambiguous', matchCount: matchingHooks.length } +} + +async function deleteProvenBitbucketCandidate( + workspaceSlug: string, + repoSlug: string, + accessToken: string, + externalId: string, + requestId: string +): Promise { + const hooksUrl = bitbucketHooksUrl(workspaceSlug, repoSlug) + let response: Response + try { + response = await fetchBitbucketManagement( + `${hooksUrl}/${encodeBitbucketSegment(externalId, 'externalId')}`, + { + method: 'DELETE', + headers: bitbucketHeaders(accessToken), + } + ) + } catch (error) { + logger.warn(`[${requestId}] Failed to delete proven Bitbucket candidate ${externalId}`, { + error: getErrorMessage(error), + }) + return false + } + + if (!response.ok && response.status !== 404) { + logger.warn( + `[${requestId}] Failed to delete proven Bitbucket candidate ${externalId} (${response.status})` + ) + return false + } + + return true +} + +async function rollbackAmbiguousBitbucketCandidate( + workspaceSlug: string, + repoSlug: string, + accessToken: string, + callbackUrl: string, + description: string, + requestId: string +): Promise { + const candidate = await findBitbucketCandidateHook( + workspaceSlug, + repoSlug, + accessToken, + callbackUrl, + description, + requestId + ) + + if (candidate.kind !== 'found') { + logger.warn( + `[${requestId}] Bitbucket candidate cleanup skipped because identity was not unique`, + { + lookup: candidate.kind, + matchCount: candidate.kind === 'ambiguous' ? candidate.matchCount : undefined, + } + ) + return + } + + await deleteProvenBitbucketCandidate( + workspaceSlug, + repoSlug, + accessToken, + candidate.externalId, + requestId + ) +} + +function nullableString(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +function nullableNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function headerValue(headers: Record, name: string): string | null { + const value = headers[name.toLowerCase()] + return typeof value === 'string' && value ? value : null +} + +function attemptNumber(headers: Record): number | null { + const raw = headerValue(headers, 'x-attempt-number') + if (!raw) return null + const parsed = Number(raw) + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null +} + +function commentFields(body: Record): Record { + const comment = toRecordOrNull(body.comment) + const content = toRecord(comment?.content) + return { + comment, + commentId: nullableNumber(comment?.id), + commentContent: nullableString(content.raw), + } +} + +function pullRequestFields(body: Record): Record { + const pullRequest = toRecordOrNull(body.pullrequest) + const source = toRecord(pullRequest?.source) + const destination = toRecord(pullRequest?.destination) + const sourceBranch = toRecord(source.branch) + const destinationBranch = toRecord(destination.branch) + + return { + pullRequest, + pullRequestId: nullableNumber(pullRequest?.id), + pullRequestTitle: nullableString(pullRequest?.title), + pullRequestState: nullableString(pullRequest?.state), + sourceBranch: nullableString(sourceBranch.name), + destinationBranch: nullableString(destinationBranch.name), + } +} + +function commitHashFromStatus(commitStatus: Record | null): string | null { + const links = toRecord(commitStatus?.links) + const commit = toRecord(links.commit) + const href = nullableString(commit.href) + if (!href) return null + + try { + const pathSegments = new URL(href).pathname.split('/').filter(Boolean) + const encodedHash = pathSegments.at(-1) + return encodedHash ? decodeURIComponent(encodedHash) : null + } catch { + return null + } +} + +function formatBitbucketInput( + triggerId: string | undefined, + body: unknown, + headers: Record +): Record { + const b = toRecord(body) + const base: Record = { + eventType: headerValue(headers, 'x-event-key'), + hookUuid: headerValue(headers, 'x-hook-uuid'), + requestUuid: headerValue(headers, 'x-request-uuid'), + attemptNumber: attemptNumber(headers), + actor: toRecordOrNull(b.actor), + repository: toRecordOrNull(b.repository), + payload: body ?? null, + } + + if (triggerId === 'bitbucket_push') return { ...base, push: toRecordOrNull(b.push) } + if (triggerId === 'bitbucket_repository_forked') { + return { ...base, fork: toRecordOrNull(b.fork) } + } + if (triggerId === 'bitbucket_repository_updated') { + return { ...base, changes: toRecordOrNull(b.changes) } + } + if (triggerId === 'bitbucket_commit_comment_created') { + return { ...base, ...commentFields(b), commit: toRecordOrNull(b.commit) } + } + if ( + triggerId === 'bitbucket_build_status_created' || + triggerId === 'bitbucket_build_status_updated' + ) { + const commitStatus = toRecordOrNull(b.commit_status) + return { + ...base, + commitStatus, + commitHash: commitHashFromStatus(commitStatus), + statusKey: nullableString(commitStatus?.key), + statusState: nullableString(commitStatus?.state), + statusName: nullableString(commitStatus?.name), + statusUrl: nullableString(commitStatus?.url), + } + } + if (triggerId && PULL_REQUEST_TRIGGER_IDS.has(triggerId)) { + const input = { ...base, ...pullRequestFields(b) } + if (PULL_REQUEST_APPROVAL_TRIGGER_IDS.has(triggerId)) { + return { ...input, approval: toRecordOrNull(b.approval) } + } + if (PULL_REQUEST_CHANGES_REQUEST_TRIGGER_IDS.has(triggerId)) { + return { ...input, changesRequest: toRecordOrNull(b.changes_request) } + } + if (PULL_REQUEST_COMMENT_TRIGGER_IDS.has(triggerId)) { + return { ...input, ...commentFields(b) } + } + return input + } + + return base +} + +export const bitbucketHandler: WebhookProviderHandler = { + /** + * Bitbucket supplies a request UUID alongside its attempt number. Preserve that provider-owned + * identifier for deduplication instead of fingerprinting payloads, since two legitimate events + * can have identical bodies. The processor strips untrusted Sim idempotency headers before this + * hook runs. + */ + enrichHeaders(_ctx, headers) { + const requestUuid = headers['x-request-uuid']?.trim() + if (requestUuid) headers['x-sim-idempotency-key'] = requestUuid + }, + + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-Hub-Signature', + validateFn: validateBitbucketSignature, + providerLabel: 'Bitbucket', + requireSecret: true, + }), + + async matchEvent({ request, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const eventKey = request.headers.get('X-Event-Key') || '' + const { isBitbucketEventMatch } = await import('@/triggers/bitbucket/utils') + + if (!triggerId || !isBitbucketEventMatch(triggerId, eventKey)) { + logger.debug( + `[${requestId}] Bitbucket event '${eventKey}' does not match trigger '${triggerId || 'unknown'}', skipping` + ) + return false + } + return true + }, + + async formatInput({ body, headers, webhook }: FormatInputContext): Promise { + const config = getProviderConfig(webhook) + return { + input: formatBitbucketInput(config.triggerId as string | undefined, body, headers), + } + }, + + async createSubscription(ctx: SubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const triggerId = config.triggerId as string | undefined + const credentialId = config.credentialId as string | undefined + const workspaceSlug = readRequiredConfigString(config, 'workspaceSlug', 'workspace') + const repoSlug = readRequiredConfigString(config, 'repoSlug', 'repository') + const webhookId = nullableString(ctx.webhook.id)?.trim() + if (!webhookId) { + throw new Error('Bitbucket webhook ID is required to manage the repository webhook.') + } + + const { getBitbucketEventForTrigger } = await import('@/triggers/bitbucket/utils') + const eventKey = triggerId ? getBitbucketEventForTrigger(triggerId) : undefined + if (!triggerId || !eventKey) { + throw new Error(`Unknown Bitbucket trigger type: ${triggerId || 'missing'}`) + } + + const accessToken = await resolveBitbucketAccessToken(credentialId, ctx.requestId) + const callbackUrl = getNotificationUrl(ctx.webhook) + const description = bitbucketHookDescription(triggerId, webhookId) + const hooksUrl = bitbucketHooksUrl(workspaceSlug, repoSlug) + const isStableCandidate = ctx.webhook.registrationStatus === 'candidate' + + if (isStableCandidate) { + const existingCandidate = await findBitbucketCandidateHook( + workspaceSlug, + repoSlug, + accessToken, + callbackUrl, + description, + ctx.requestId + ) + + if (existingCandidate.kind === 'unavailable') throw existingCandidate.error + if (existingCandidate.kind === 'ambiguous') { + throw new Error( + `Bitbucket candidate reconciliation found ${existingCandidate.matchCount} matching hooks; no hook was deleted.` + ) + } + if (existingCandidate.kind === 'found') { + const checkpointedExternalId = nullableString(config.externalId)?.trim() + const checkpointedSecret = nullableString(config.webhookSecret)?.trim() + const candidateMatchesCheckpoint = + checkpointedExternalId === existingCandidate.externalId && + Boolean(checkpointedSecret) && + existingCandidate.active && + existingCandidate.secretSet && + existingCandidate.events.length === 1 && + existingCandidate.events[0] === eventKey + if (candidateMatchesCheckpoint && checkpointedSecret) { + logger.info( + `[${ctx.requestId}] Reusing checkpointed Bitbucket candidate ${existingCandidate.externalId}` + ) + return { + providerConfigUpdates: { + externalId: existingCandidate.externalId, + webhookSecret: checkpointedSecret, + eventTypes: [eventKey], + }, + } + } + + const deleted = await deleteProvenBitbucketCandidate( + workspaceSlug, + repoSlug, + accessToken, + existingCandidate.externalId, + ctx.requestId + ) + if (!deleted) { + throw new Error( + 'Could not safely remove a stale Bitbucket candidate webhook; no new hook was created.' + ) + } + } + } + + const webhookSecret = generateId() + let response: Response + try { + response = await fetchBitbucketManagement(hooksUrl, { + method: 'POST', + headers: bitbucketHeaders(accessToken, { json: true }), + body: JSON.stringify({ + description, + url: callbackUrl, + active: true, + secret: webhookSecret, + events: [eventKey], + }), + }) + } catch (error) { + if (isStableCandidate) { + await rollbackAmbiguousBitbucketCandidate( + workspaceSlug, + repoSlug, + accessToken, + callbackUrl, + description, + ctx.requestId + ) + } + throw error + } + + if (!response.ok) { + const detail = await readBitbucketErrorDetail(response) + logger.error(`[${ctx.requestId}] Failed to create Bitbucket webhook (${response.status})`, { + detail, + workspaceSlug, + repoSlug, + triggerId, + }) + const apiError = createBitbucketApiError(response.status, 'create', detail) + if (isStableCandidate && (response.status === 408 || response.status >= 500)) { + await rollbackAmbiguousBitbucketCandidate( + workspaceSlug, + repoSlug, + accessToken, + callbackUrl, + description, + ctx.requestId + ) + } + throw apiError + } + + const created = toRecord(await response.json().catch(() => null)) + const externalId = nullableString(created.uuid)?.trim() || null + if (!externalId) { + if (isStableCandidate) { + await rollbackAmbiguousBitbucketCandidate( + workspaceSlug, + repoSlug, + accessToken, + callbackUrl, + description, + ctx.requestId + ) + } + throw new Error('Bitbucket webhook was created but no hook UUID was returned.') + } + + logger.info( + `[${ctx.requestId}] Created Bitbucket webhook ${externalId} for ${workspaceSlug}/${repoSlug}` + ) + return { + providerConfigUpdates: { externalId, webhookSecret, eventTypes: [eventKey] }, + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(ctx.webhook) + const credentialId = config.credentialId as string | undefined + const externalId = + typeof config.externalId === 'string' ? config.externalId.trim() : undefined + const workspaceSlug = + typeof config.workspaceSlug === 'string' ? config.workspaceSlug.trim() : undefined + const repoSlug = typeof config.repoSlug === 'string' ? config.repoSlug.trim() : undefined + + if (!credentialId || !externalId || !workspaceSlug || !repoSlug) { + const message = + 'Missing Bitbucket credential, workspace, repository, or hook UUID for webhook deletion.' + if (ctx.strict) throw new Error(message) + logger.warn(`[${ctx.requestId}] ${message}`) + return + } + + const accessToken = await resolveBitbucketAccessToken(credentialId, ctx.requestId) + const hooksUrl = bitbucketHooksUrl(workspaceSlug, repoSlug) + const response = await fetchBitbucketManagement( + `${hooksUrl}/${encodeBitbucketSegment(externalId, 'externalId')}`, + { + method: 'DELETE', + headers: bitbucketHeaders(accessToken), + } + ) + + if (!response.ok && response.status !== 404) { + const detail = await readBitbucketErrorDetail(response) + throw createBitbucketApiError(response.status, 'delete', detail) + } + + logger.info(`[${ctx.requestId}] Deleted Bitbucket webhook ${externalId}`) + } catch (error) { + logger.warn(`[${ctx.requestId}] Error deleting Bitbucket webhook (non-fatal)`, error) + if (ctx.strict) throw error + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index ee248e187aa..0cad20b8d12 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -4,6 +4,7 @@ import { airtableHandler } from '@/lib/webhooks/providers/airtable' import { ashbyHandler } from '@/lib/webhooks/providers/ashby' import { attioHandler } from '@/lib/webhooks/providers/attio' import { azureDevOpsHandler } from '@/lib/webhooks/providers/azure-devops' +import { bitbucketHandler } from '@/lib/webhooks/providers/bitbucket' import { calcomHandler } from '@/lib/webhooks/providers/calcom' import { calendlyHandler } from '@/lib/webhooks/providers/calendly' import { circlebackHandler } from '@/lib/webhooks/providers/circleback' @@ -70,6 +71,7 @@ const PROVIDER_HANDLERS: Record = { ashby: ashbyHandler, attio: attioHandler, azure_devops: azureDevOpsHandler, + bitbucket: bitbucketHandler, calendly: calendlyHandler, calcom: calcomHandler, circleback: circlebackHandler, diff --git a/apps/sim/lib/workflows/subblocks/context.test.ts b/apps/sim/lib/workflows/subblocks/context.test.ts index 03272ce272e..554605d084d 100644 --- a/apps/sim/lib/workflows/subblocks/context.test.ts +++ b/apps/sim/lib/workflows/subblocks/context.test.ts @@ -7,6 +7,7 @@ vi.unmock('@/blocks/registry') import * as blocksBarrel from '@/blocks' import { getAllBlocks, getBlock as getRealBlock } from '@/blocks/registry' +import { bitbucketSelectors } from '@/hooks/selectors/providers/bitbucket/selectors' import { buildSelectorContextFromBlock, getSelectorContextSubBlocks, @@ -230,6 +231,36 @@ describe('buildSelectorContextFromBlock', () => { expect(ctx.oauthCredential).toBeUndefined() }) + it('exposes a trigger workspace slug to the Bitbucket repository selector', () => { + const context = buildSelectorContextFromBlock( + 'bitbucket', + { + selectedTriggerId: { + id: 'selectedTriggerId', + type: 'dropdown', + value: 'bitbucket_push', + }, + triggerCredentials: { + id: 'triggerCredentials', + type: 'oauth-input', + value: 'credential-1', + }, + workspacePicker: { + id: 'workspacePicker', + type: 'project-selector', + value: 'acme-platform', + }, + }, + { triggerMode: true } + ) + + expect(context).toMatchObject({ + oauthCredential: 'credential-1', + workspaceSlug: 'acme-platform', + }) + expect(bitbucketSelectors['bitbucket.repositories'].enabled({ context } as never)).toBe(true) + }) + it('should ignore subblock keys not in SELECTOR_CONTEXT_FIELDS', () => { const ctx = buildSelectorContextFromBlock('knowledge', { operation: { id: 'operation', type: 'dropdown', value: 'search' }, diff --git a/apps/sim/triggers/bitbucket/build_status_created.ts b/apps/sim/triggers/bitbucket/build_status_created.ts new file mode 100644 index 00000000000..c274ae4efce --- /dev/null +++ b/apps/sim/triggers/bitbucket/build_status_created.ts @@ -0,0 +1,34 @@ +import { BitbucketIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + BITBUCKET_TRIGGER_EVENT_MAP, + bitbucketSetupInstructions, + bitbucketTriggerOptions, + buildBitbucketExtraFields, + buildBitbucketOutputs, +} from '@/triggers/bitbucket/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const bitbucketBuildStatusCreatedTrigger: TriggerConfig = { + id: 'bitbucket_build_status_created', + name: 'Bitbucket Build Status Created', + provider: 'bitbucket', + description: 'Trigger workflow when a build status is created for a Bitbucket commit', + version: '1.0.0', + icon: BitbucketIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'bitbucket_build_status_created', + triggerOptions: [...bitbucketTriggerOptions], + setupInstructions: bitbucketSetupInstructions('Build Status Created'), + extraFields: buildBitbucketExtraFields('bitbucket_build_status_created'), + }), + outputs: buildBitbucketOutputs('bitbucket_build_status_created'), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Event-Key': BITBUCKET_TRIGGER_EVENT_MAP.bitbucket_build_status_created, + 'X-Hub-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/bitbucket/build_status_updated.ts b/apps/sim/triggers/bitbucket/build_status_updated.ts new file mode 100644 index 00000000000..60a6e57861c --- /dev/null +++ b/apps/sim/triggers/bitbucket/build_status_updated.ts @@ -0,0 +1,34 @@ +import { BitbucketIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + BITBUCKET_TRIGGER_EVENT_MAP, + bitbucketSetupInstructions, + bitbucketTriggerOptions, + buildBitbucketExtraFields, + buildBitbucketOutputs, +} from '@/triggers/bitbucket/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const bitbucketBuildStatusUpdatedTrigger: TriggerConfig = { + id: 'bitbucket_build_status_updated', + name: 'Bitbucket Build Status Updated', + provider: 'bitbucket', + description: 'Trigger workflow when a build status is updated for a Bitbucket commit', + version: '1.0.0', + icon: BitbucketIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'bitbucket_build_status_updated', + triggerOptions: [...bitbucketTriggerOptions], + setupInstructions: bitbucketSetupInstructions('Build Status Updated'), + extraFields: buildBitbucketExtraFields('bitbucket_build_status_updated'), + }), + outputs: buildBitbucketOutputs('bitbucket_build_status_updated'), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Event-Key': BITBUCKET_TRIGGER_EVENT_MAP.bitbucket_build_status_updated, + 'X-Hub-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/bitbucket/commit_comment_created.ts b/apps/sim/triggers/bitbucket/commit_comment_created.ts new file mode 100644 index 00000000000..8da85a46a30 --- /dev/null +++ b/apps/sim/triggers/bitbucket/commit_comment_created.ts @@ -0,0 +1,34 @@ +import { BitbucketIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + BITBUCKET_TRIGGER_EVENT_MAP, + bitbucketSetupInstructions, + bitbucketTriggerOptions, + buildBitbucketExtraFields, + buildBitbucketOutputs, +} from '@/triggers/bitbucket/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const bitbucketCommitCommentCreatedTrigger: TriggerConfig = { + id: 'bitbucket_commit_comment_created', + name: 'Bitbucket Commit Comment Created', + provider: 'bitbucket', + description: 'Trigger workflow when a comment is created on a Bitbucket commit', + version: '1.0.0', + icon: BitbucketIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'bitbucket_commit_comment_created', + triggerOptions: [...bitbucketTriggerOptions], + setupInstructions: bitbucketSetupInstructions('Commit Comment Created'), + extraFields: buildBitbucketExtraFields('bitbucket_commit_comment_created'), + }), + outputs: buildBitbucketOutputs('bitbucket_commit_comment_created'), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Event-Key': BITBUCKET_TRIGGER_EVENT_MAP.bitbucket_commit_comment_created, + 'X-Hub-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/bitbucket/index.ts b/apps/sim/triggers/bitbucket/index.ts new file mode 100644 index 00000000000..6dad358dbea --- /dev/null +++ b/apps/sim/triggers/bitbucket/index.ts @@ -0,0 +1,19 @@ +export { bitbucketBuildStatusCreatedTrigger } from '@/triggers/bitbucket/build_status_created' +export { bitbucketBuildStatusUpdatedTrigger } from '@/triggers/bitbucket/build_status_updated' +export { bitbucketCommitCommentCreatedTrigger } from '@/triggers/bitbucket/commit_comment_created' +export { bitbucketPullRequestApprovalRemovedTrigger } from '@/triggers/bitbucket/pull_request_approval_removed' +export { bitbucketPullRequestApprovedTrigger } from '@/triggers/bitbucket/pull_request_approved' +export { bitbucketPullRequestChangesRequestRemovedTrigger } from '@/triggers/bitbucket/pull_request_changes_request_removed' +export { bitbucketPullRequestChangesRequestedTrigger } from '@/triggers/bitbucket/pull_request_changes_requested' +export { bitbucketPullRequestCommentCreatedTrigger } from '@/triggers/bitbucket/pull_request_comment_created' +export { bitbucketPullRequestCommentDeletedTrigger } from '@/triggers/bitbucket/pull_request_comment_deleted' +export { bitbucketPullRequestCommentReopenedTrigger } from '@/triggers/bitbucket/pull_request_comment_reopened' +export { bitbucketPullRequestCommentResolvedTrigger } from '@/triggers/bitbucket/pull_request_comment_resolved' +export { bitbucketPullRequestCommentUpdatedTrigger } from '@/triggers/bitbucket/pull_request_comment_updated' +export { bitbucketPullRequestCreatedTrigger } from '@/triggers/bitbucket/pull_request_created' +export { bitbucketPullRequestDeclinedTrigger } from '@/triggers/bitbucket/pull_request_declined' +export { bitbucketPullRequestMergedTrigger } from '@/triggers/bitbucket/pull_request_merged' +export { bitbucketPullRequestUpdatedTrigger } from '@/triggers/bitbucket/pull_request_updated' +export { bitbucketPushTrigger } from '@/triggers/bitbucket/push' +export { bitbucketRepositoryForkedTrigger } from '@/triggers/bitbucket/repository_forked' +export { bitbucketRepositoryUpdatedTrigger } from '@/triggers/bitbucket/repository_updated' diff --git a/apps/sim/triggers/bitbucket/pull_request_approval_removed.ts b/apps/sim/triggers/bitbucket/pull_request_approval_removed.ts new file mode 100644 index 00000000000..6093dc26494 --- /dev/null +++ b/apps/sim/triggers/bitbucket/pull_request_approval_removed.ts @@ -0,0 +1,34 @@ +import { BitbucketIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + BITBUCKET_TRIGGER_EVENT_MAP, + bitbucketSetupInstructions, + bitbucketTriggerOptions, + buildBitbucketExtraFields, + buildBitbucketOutputs, +} from '@/triggers/bitbucket/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const bitbucketPullRequestApprovalRemovedTrigger: TriggerConfig = { + id: 'bitbucket_pull_request_approval_removed', + name: 'Bitbucket Pull Request Approval Removed', + provider: 'bitbucket', + description: 'Trigger workflow when approval is removed from a Bitbucket pull request', + version: '1.0.0', + icon: BitbucketIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'bitbucket_pull_request_approval_removed', + triggerOptions: [...bitbucketTriggerOptions], + setupInstructions: bitbucketSetupInstructions('Pull Request Approval Removed'), + extraFields: buildBitbucketExtraFields('bitbucket_pull_request_approval_removed'), + }), + outputs: buildBitbucketOutputs('bitbucket_pull_request_approval_removed'), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Event-Key': BITBUCKET_TRIGGER_EVENT_MAP.bitbucket_pull_request_approval_removed, + 'X-Hub-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/bitbucket/pull_request_approved.ts b/apps/sim/triggers/bitbucket/pull_request_approved.ts new file mode 100644 index 00000000000..2c8f4d18ad0 --- /dev/null +++ b/apps/sim/triggers/bitbucket/pull_request_approved.ts @@ -0,0 +1,34 @@ +import { BitbucketIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + BITBUCKET_TRIGGER_EVENT_MAP, + bitbucketSetupInstructions, + bitbucketTriggerOptions, + buildBitbucketExtraFields, + buildBitbucketOutputs, +} from '@/triggers/bitbucket/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const bitbucketPullRequestApprovedTrigger: TriggerConfig = { + id: 'bitbucket_pull_request_approved', + name: 'Bitbucket Pull Request Approved', + provider: 'bitbucket', + description: 'Trigger workflow when a Bitbucket pull request is approved', + version: '1.0.0', + icon: BitbucketIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'bitbucket_pull_request_approved', + triggerOptions: [...bitbucketTriggerOptions], + setupInstructions: bitbucketSetupInstructions('Pull Request Approved'), + extraFields: buildBitbucketExtraFields('bitbucket_pull_request_approved'), + }), + outputs: buildBitbucketOutputs('bitbucket_pull_request_approved'), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Event-Key': BITBUCKET_TRIGGER_EVENT_MAP.bitbucket_pull_request_approved, + 'X-Hub-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/bitbucket/pull_request_changes_request_removed.ts b/apps/sim/triggers/bitbucket/pull_request_changes_request_removed.ts new file mode 100644 index 00000000000..59e63ddb80c --- /dev/null +++ b/apps/sim/triggers/bitbucket/pull_request_changes_request_removed.ts @@ -0,0 +1,34 @@ +import { BitbucketIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + BITBUCKET_TRIGGER_EVENT_MAP, + bitbucketSetupInstructions, + bitbucketTriggerOptions, + buildBitbucketExtraFields, + buildBitbucketOutputs, +} from '@/triggers/bitbucket/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const bitbucketPullRequestChangesRequestRemovedTrigger: TriggerConfig = { + id: 'bitbucket_pull_request_changes_request_removed', + name: 'Bitbucket Pull Request Changes Request Removed', + provider: 'bitbucket', + description: 'Trigger workflow when a changes request is removed from a Bitbucket pull request', + version: '1.0.0', + icon: BitbucketIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'bitbucket_pull_request_changes_request_removed', + triggerOptions: [...bitbucketTriggerOptions], + setupInstructions: bitbucketSetupInstructions('Pull Request Changes Request Removed'), + extraFields: buildBitbucketExtraFields('bitbucket_pull_request_changes_request_removed'), + }), + outputs: buildBitbucketOutputs('bitbucket_pull_request_changes_request_removed'), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Event-Key': BITBUCKET_TRIGGER_EVENT_MAP.bitbucket_pull_request_changes_request_removed, + 'X-Hub-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/bitbucket/pull_request_changes_requested.ts b/apps/sim/triggers/bitbucket/pull_request_changes_requested.ts new file mode 100644 index 00000000000..79fa0c36ede --- /dev/null +++ b/apps/sim/triggers/bitbucket/pull_request_changes_requested.ts @@ -0,0 +1,34 @@ +import { BitbucketIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + BITBUCKET_TRIGGER_EVENT_MAP, + bitbucketSetupInstructions, + bitbucketTriggerOptions, + buildBitbucketExtraFields, + buildBitbucketOutputs, +} from '@/triggers/bitbucket/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const bitbucketPullRequestChangesRequestedTrigger: TriggerConfig = { + id: 'bitbucket_pull_request_changes_requested', + name: 'Bitbucket Pull Request Changes Requested', + provider: 'bitbucket', + description: 'Trigger workflow when changes are requested on a Bitbucket pull request', + version: '1.0.0', + icon: BitbucketIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'bitbucket_pull_request_changes_requested', + triggerOptions: [...bitbucketTriggerOptions], + setupInstructions: bitbucketSetupInstructions('Pull Request Changes Requested'), + extraFields: buildBitbucketExtraFields('bitbucket_pull_request_changes_requested'), + }), + outputs: buildBitbucketOutputs('bitbucket_pull_request_changes_requested'), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Event-Key': BITBUCKET_TRIGGER_EVENT_MAP.bitbucket_pull_request_changes_requested, + 'X-Hub-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/bitbucket/pull_request_comment_created.ts b/apps/sim/triggers/bitbucket/pull_request_comment_created.ts new file mode 100644 index 00000000000..f29bb116054 --- /dev/null +++ b/apps/sim/triggers/bitbucket/pull_request_comment_created.ts @@ -0,0 +1,34 @@ +import { BitbucketIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + BITBUCKET_TRIGGER_EVENT_MAP, + bitbucketSetupInstructions, + bitbucketTriggerOptions, + buildBitbucketExtraFields, + buildBitbucketOutputs, +} from '@/triggers/bitbucket/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const bitbucketPullRequestCommentCreatedTrigger: TriggerConfig = { + id: 'bitbucket_pull_request_comment_created', + name: 'Bitbucket Pull Request Comment Created', + provider: 'bitbucket', + description: 'Trigger workflow when a comment is created on a Bitbucket pull request', + version: '1.0.0', + icon: BitbucketIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'bitbucket_pull_request_comment_created', + triggerOptions: [...bitbucketTriggerOptions], + setupInstructions: bitbucketSetupInstructions('Pull Request Comment Created'), + extraFields: buildBitbucketExtraFields('bitbucket_pull_request_comment_created'), + }), + outputs: buildBitbucketOutputs('bitbucket_pull_request_comment_created'), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Event-Key': BITBUCKET_TRIGGER_EVENT_MAP.bitbucket_pull_request_comment_created, + 'X-Hub-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/bitbucket/pull_request_comment_deleted.ts b/apps/sim/triggers/bitbucket/pull_request_comment_deleted.ts new file mode 100644 index 00000000000..538cdd5388d --- /dev/null +++ b/apps/sim/triggers/bitbucket/pull_request_comment_deleted.ts @@ -0,0 +1,34 @@ +import { BitbucketIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + BITBUCKET_TRIGGER_EVENT_MAP, + bitbucketSetupInstructions, + bitbucketTriggerOptions, + buildBitbucketExtraFields, + buildBitbucketOutputs, +} from '@/triggers/bitbucket/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const bitbucketPullRequestCommentDeletedTrigger: TriggerConfig = { + id: 'bitbucket_pull_request_comment_deleted', + name: 'Bitbucket Pull Request Comment Deleted', + provider: 'bitbucket', + description: 'Trigger workflow when a comment is deleted from a Bitbucket pull request', + version: '1.0.0', + icon: BitbucketIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'bitbucket_pull_request_comment_deleted', + triggerOptions: [...bitbucketTriggerOptions], + setupInstructions: bitbucketSetupInstructions('Pull Request Comment Deleted'), + extraFields: buildBitbucketExtraFields('bitbucket_pull_request_comment_deleted'), + }), + outputs: buildBitbucketOutputs('bitbucket_pull_request_comment_deleted'), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Event-Key': BITBUCKET_TRIGGER_EVENT_MAP.bitbucket_pull_request_comment_deleted, + 'X-Hub-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/bitbucket/pull_request_comment_reopened.ts b/apps/sim/triggers/bitbucket/pull_request_comment_reopened.ts new file mode 100644 index 00000000000..db9819d77cd --- /dev/null +++ b/apps/sim/triggers/bitbucket/pull_request_comment_reopened.ts @@ -0,0 +1,34 @@ +import { BitbucketIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + BITBUCKET_TRIGGER_EVENT_MAP, + bitbucketSetupInstructions, + bitbucketTriggerOptions, + buildBitbucketExtraFields, + buildBitbucketOutputs, +} from '@/triggers/bitbucket/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const bitbucketPullRequestCommentReopenedTrigger: TriggerConfig = { + id: 'bitbucket_pull_request_comment_reopened', + name: 'Bitbucket Pull Request Comment Reopened', + provider: 'bitbucket', + description: 'Trigger workflow when a resolved comment is reopened on a Bitbucket pull request', + version: '1.0.0', + icon: BitbucketIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'bitbucket_pull_request_comment_reopened', + triggerOptions: [...bitbucketTriggerOptions], + setupInstructions: bitbucketSetupInstructions('Pull Request Comment Reopened'), + extraFields: buildBitbucketExtraFields('bitbucket_pull_request_comment_reopened'), + }), + outputs: buildBitbucketOutputs('bitbucket_pull_request_comment_reopened'), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Event-Key': BITBUCKET_TRIGGER_EVENT_MAP.bitbucket_pull_request_comment_reopened, + 'X-Hub-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/bitbucket/pull_request_comment_resolved.ts b/apps/sim/triggers/bitbucket/pull_request_comment_resolved.ts new file mode 100644 index 00000000000..7d74a3f8e6c --- /dev/null +++ b/apps/sim/triggers/bitbucket/pull_request_comment_resolved.ts @@ -0,0 +1,34 @@ +import { BitbucketIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + BITBUCKET_TRIGGER_EVENT_MAP, + bitbucketSetupInstructions, + bitbucketTriggerOptions, + buildBitbucketExtraFields, + buildBitbucketOutputs, +} from '@/triggers/bitbucket/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const bitbucketPullRequestCommentResolvedTrigger: TriggerConfig = { + id: 'bitbucket_pull_request_comment_resolved', + name: 'Bitbucket Pull Request Comment Resolved', + provider: 'bitbucket', + description: 'Trigger workflow when a comment is resolved on a Bitbucket pull request', + version: '1.0.0', + icon: BitbucketIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'bitbucket_pull_request_comment_resolved', + triggerOptions: [...bitbucketTriggerOptions], + setupInstructions: bitbucketSetupInstructions('Pull Request Comment Resolved'), + extraFields: buildBitbucketExtraFields('bitbucket_pull_request_comment_resolved'), + }), + outputs: buildBitbucketOutputs('bitbucket_pull_request_comment_resolved'), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Event-Key': BITBUCKET_TRIGGER_EVENT_MAP.bitbucket_pull_request_comment_resolved, + 'X-Hub-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/bitbucket/pull_request_comment_updated.ts b/apps/sim/triggers/bitbucket/pull_request_comment_updated.ts new file mode 100644 index 00000000000..d20408bbc1e --- /dev/null +++ b/apps/sim/triggers/bitbucket/pull_request_comment_updated.ts @@ -0,0 +1,34 @@ +import { BitbucketIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + BITBUCKET_TRIGGER_EVENT_MAP, + bitbucketSetupInstructions, + bitbucketTriggerOptions, + buildBitbucketExtraFields, + buildBitbucketOutputs, +} from '@/triggers/bitbucket/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const bitbucketPullRequestCommentUpdatedTrigger: TriggerConfig = { + id: 'bitbucket_pull_request_comment_updated', + name: 'Bitbucket Pull Request Comment Updated', + provider: 'bitbucket', + description: 'Trigger workflow when a comment is updated on a Bitbucket pull request', + version: '1.0.0', + icon: BitbucketIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'bitbucket_pull_request_comment_updated', + triggerOptions: [...bitbucketTriggerOptions], + setupInstructions: bitbucketSetupInstructions('Pull Request Comment Updated'), + extraFields: buildBitbucketExtraFields('bitbucket_pull_request_comment_updated'), + }), + outputs: buildBitbucketOutputs('bitbucket_pull_request_comment_updated'), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Event-Key': BITBUCKET_TRIGGER_EVENT_MAP.bitbucket_pull_request_comment_updated, + 'X-Hub-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/bitbucket/pull_request_created.ts b/apps/sim/triggers/bitbucket/pull_request_created.ts new file mode 100644 index 00000000000..1ab673efc1b --- /dev/null +++ b/apps/sim/triggers/bitbucket/pull_request_created.ts @@ -0,0 +1,34 @@ +import { BitbucketIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + BITBUCKET_TRIGGER_EVENT_MAP, + bitbucketSetupInstructions, + bitbucketTriggerOptions, + buildBitbucketExtraFields, + buildBitbucketOutputs, +} from '@/triggers/bitbucket/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const bitbucketPullRequestCreatedTrigger: TriggerConfig = { + id: 'bitbucket_pull_request_created', + name: 'Bitbucket Pull Request Created', + provider: 'bitbucket', + description: 'Trigger workflow when a Bitbucket pull request is created', + version: '1.0.0', + icon: BitbucketIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'bitbucket_pull_request_created', + triggerOptions: [...bitbucketTriggerOptions], + setupInstructions: bitbucketSetupInstructions('Pull Request Created'), + extraFields: buildBitbucketExtraFields('bitbucket_pull_request_created'), + }), + outputs: buildBitbucketOutputs('bitbucket_pull_request_created'), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Event-Key': BITBUCKET_TRIGGER_EVENT_MAP.bitbucket_pull_request_created, + 'X-Hub-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/bitbucket/pull_request_declined.ts b/apps/sim/triggers/bitbucket/pull_request_declined.ts new file mode 100644 index 00000000000..8e7532e6d40 --- /dev/null +++ b/apps/sim/triggers/bitbucket/pull_request_declined.ts @@ -0,0 +1,34 @@ +import { BitbucketIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + BITBUCKET_TRIGGER_EVENT_MAP, + bitbucketSetupInstructions, + bitbucketTriggerOptions, + buildBitbucketExtraFields, + buildBitbucketOutputs, +} from '@/triggers/bitbucket/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const bitbucketPullRequestDeclinedTrigger: TriggerConfig = { + id: 'bitbucket_pull_request_declined', + name: 'Bitbucket Pull Request Declined', + provider: 'bitbucket', + description: 'Trigger workflow when a Bitbucket pull request is declined', + version: '1.0.0', + icon: BitbucketIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'bitbucket_pull_request_declined', + triggerOptions: [...bitbucketTriggerOptions], + setupInstructions: bitbucketSetupInstructions('Pull Request Declined'), + extraFields: buildBitbucketExtraFields('bitbucket_pull_request_declined'), + }), + outputs: buildBitbucketOutputs('bitbucket_pull_request_declined'), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Event-Key': BITBUCKET_TRIGGER_EVENT_MAP.bitbucket_pull_request_declined, + 'X-Hub-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/bitbucket/pull_request_merged.ts b/apps/sim/triggers/bitbucket/pull_request_merged.ts new file mode 100644 index 00000000000..38b2a232eaf --- /dev/null +++ b/apps/sim/triggers/bitbucket/pull_request_merged.ts @@ -0,0 +1,34 @@ +import { BitbucketIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + BITBUCKET_TRIGGER_EVENT_MAP, + bitbucketSetupInstructions, + bitbucketTriggerOptions, + buildBitbucketExtraFields, + buildBitbucketOutputs, +} from '@/triggers/bitbucket/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const bitbucketPullRequestMergedTrigger: TriggerConfig = { + id: 'bitbucket_pull_request_merged', + name: 'Bitbucket Pull Request Merged', + provider: 'bitbucket', + description: 'Trigger workflow when a Bitbucket pull request is merged', + version: '1.0.0', + icon: BitbucketIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'bitbucket_pull_request_merged', + triggerOptions: [...bitbucketTriggerOptions], + setupInstructions: bitbucketSetupInstructions('Pull Request Merged'), + extraFields: buildBitbucketExtraFields('bitbucket_pull_request_merged'), + }), + outputs: buildBitbucketOutputs('bitbucket_pull_request_merged'), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Event-Key': BITBUCKET_TRIGGER_EVENT_MAP.bitbucket_pull_request_merged, + 'X-Hub-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/bitbucket/pull_request_updated.ts b/apps/sim/triggers/bitbucket/pull_request_updated.ts new file mode 100644 index 00000000000..445690e1ee7 --- /dev/null +++ b/apps/sim/triggers/bitbucket/pull_request_updated.ts @@ -0,0 +1,34 @@ +import { BitbucketIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + BITBUCKET_TRIGGER_EVENT_MAP, + bitbucketSetupInstructions, + bitbucketTriggerOptions, + buildBitbucketExtraFields, + buildBitbucketOutputs, +} from '@/triggers/bitbucket/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const bitbucketPullRequestUpdatedTrigger: TriggerConfig = { + id: 'bitbucket_pull_request_updated', + name: 'Bitbucket Pull Request Updated', + provider: 'bitbucket', + description: 'Trigger workflow when a Bitbucket pull request is updated', + version: '1.0.0', + icon: BitbucketIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'bitbucket_pull_request_updated', + triggerOptions: [...bitbucketTriggerOptions], + setupInstructions: bitbucketSetupInstructions('Pull Request Updated'), + extraFields: buildBitbucketExtraFields('bitbucket_pull_request_updated'), + }), + outputs: buildBitbucketOutputs('bitbucket_pull_request_updated'), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Event-Key': BITBUCKET_TRIGGER_EVENT_MAP.bitbucket_pull_request_updated, + 'X-Hub-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/bitbucket/push.ts b/apps/sim/triggers/bitbucket/push.ts new file mode 100644 index 00000000000..ecb4e0aa871 --- /dev/null +++ b/apps/sim/triggers/bitbucket/push.ts @@ -0,0 +1,35 @@ +import { BitbucketIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + BITBUCKET_TRIGGER_EVENT_MAP, + bitbucketSetupInstructions, + bitbucketTriggerOptions, + buildBitbucketExtraFields, + buildBitbucketOutputs, +} from '@/triggers/bitbucket/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const bitbucketPushTrigger: TriggerConfig = { + id: 'bitbucket_push', + name: 'Bitbucket Push', + provider: 'bitbucket', + description: 'Trigger workflow when commits are pushed to a Bitbucket repository', + version: '1.0.0', + icon: BitbucketIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'bitbucket_push', + triggerOptions: [...bitbucketTriggerOptions], + includeDropdown: true, + setupInstructions: bitbucketSetupInstructions('Push'), + extraFields: buildBitbucketExtraFields('bitbucket_push'), + }), + outputs: buildBitbucketOutputs('bitbucket_push'), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Event-Key': BITBUCKET_TRIGGER_EVENT_MAP.bitbucket_push, + 'X-Hub-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/bitbucket/repository_forked.ts b/apps/sim/triggers/bitbucket/repository_forked.ts new file mode 100644 index 00000000000..1847af179f9 --- /dev/null +++ b/apps/sim/triggers/bitbucket/repository_forked.ts @@ -0,0 +1,34 @@ +import { BitbucketIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + BITBUCKET_TRIGGER_EVENT_MAP, + bitbucketSetupInstructions, + bitbucketTriggerOptions, + buildBitbucketExtraFields, + buildBitbucketOutputs, +} from '@/triggers/bitbucket/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const bitbucketRepositoryForkedTrigger: TriggerConfig = { + id: 'bitbucket_repository_forked', + name: 'Bitbucket Repository Forked', + provider: 'bitbucket', + description: 'Trigger workflow when a Bitbucket repository is forked', + version: '1.0.0', + icon: BitbucketIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'bitbucket_repository_forked', + triggerOptions: [...bitbucketTriggerOptions], + setupInstructions: bitbucketSetupInstructions('Repository Forked'), + extraFields: buildBitbucketExtraFields('bitbucket_repository_forked'), + }), + outputs: buildBitbucketOutputs('bitbucket_repository_forked'), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Event-Key': BITBUCKET_TRIGGER_EVENT_MAP.bitbucket_repository_forked, + 'X-Hub-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/bitbucket/repository_updated.ts b/apps/sim/triggers/bitbucket/repository_updated.ts new file mode 100644 index 00000000000..1e1f28c6802 --- /dev/null +++ b/apps/sim/triggers/bitbucket/repository_updated.ts @@ -0,0 +1,34 @@ +import { BitbucketIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + BITBUCKET_TRIGGER_EVENT_MAP, + bitbucketSetupInstructions, + bitbucketTriggerOptions, + buildBitbucketExtraFields, + buildBitbucketOutputs, +} from '@/triggers/bitbucket/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const bitbucketRepositoryUpdatedTrigger: TriggerConfig = { + id: 'bitbucket_repository_updated', + name: 'Bitbucket Repository Updated', + provider: 'bitbucket', + description: 'Trigger workflow when a Bitbucket repository is updated', + version: '1.0.0', + icon: BitbucketIcon, + subBlocks: buildTriggerSubBlocks({ + triggerId: 'bitbucket_repository_updated', + triggerOptions: [...bitbucketTriggerOptions], + setupInstructions: bitbucketSetupInstructions('Repository Updated'), + extraFields: buildBitbucketExtraFields('bitbucket_repository_updated'), + }), + outputs: buildBitbucketOutputs('bitbucket_repository_updated'), + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Event-Key': BITBUCKET_TRIGGER_EVENT_MAP.bitbucket_repository_updated, + 'X-Hub-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/bitbucket/utils.test.ts b/apps/sim/triggers/bitbucket/utils.test.ts new file mode 100644 index 00000000000..d8ab14b4537 --- /dev/null +++ b/apps/sim/triggers/bitbucket/utils.test.ts @@ -0,0 +1,198 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + bitbucketBuildStatusCreatedTrigger, + bitbucketBuildStatusUpdatedTrigger, + bitbucketCommitCommentCreatedTrigger, + bitbucketPullRequestApprovalRemovedTrigger, + bitbucketPullRequestApprovedTrigger, + bitbucketPullRequestChangesRequestedTrigger, + bitbucketPullRequestChangesRequestRemovedTrigger, + bitbucketPullRequestCommentCreatedTrigger, + bitbucketPullRequestCommentDeletedTrigger, + bitbucketPullRequestCommentReopenedTrigger, + bitbucketPullRequestCommentResolvedTrigger, + bitbucketPullRequestCommentUpdatedTrigger, + bitbucketPullRequestCreatedTrigger, + bitbucketPullRequestDeclinedTrigger, + bitbucketPullRequestMergedTrigger, + bitbucketPullRequestUpdatedTrigger, + bitbucketPushTrigger, + bitbucketRepositoryForkedTrigger, + bitbucketRepositoryUpdatedTrigger, +} from '@/triggers/bitbucket' +import { + BITBUCKET_TRIGGER_EVENT_MAP, + bitbucketTriggerOptions, + getBitbucketEventForTrigger, + isBitbucketEventMatch, +} from '@/triggers/bitbucket/utils' +import type { TriggerConfig } from '@/triggers/types' + +const triggers: TriggerConfig[] = [ + bitbucketPushTrigger, + bitbucketRepositoryForkedTrigger, + bitbucketRepositoryUpdatedTrigger, + bitbucketCommitCommentCreatedTrigger, + bitbucketBuildStatusCreatedTrigger, + bitbucketBuildStatusUpdatedTrigger, + bitbucketPullRequestCreatedTrigger, + bitbucketPullRequestUpdatedTrigger, + bitbucketPullRequestApprovedTrigger, + bitbucketPullRequestApprovalRemovedTrigger, + bitbucketPullRequestChangesRequestedTrigger, + bitbucketPullRequestChangesRequestRemovedTrigger, + bitbucketPullRequestMergedTrigger, + bitbucketPullRequestDeclinedTrigger, + bitbucketPullRequestCommentCreatedTrigger, + bitbucketPullRequestCommentUpdatedTrigger, + bitbucketPullRequestCommentDeletedTrigger, + bitbucketPullRequestCommentResolvedTrigger, + bitbucketPullRequestCommentReopenedTrigger, +] + +const commonOutputKeys = [ + 'actor', + 'attemptNumber', + 'eventType', + 'hookUuid', + 'payload', + 'repository', + 'requestUuid', +] + +describe('Bitbucket trigger definitions', () => { + it('keeps options, event mappings, and trigger configs aligned', () => { + const triggerIds = triggers.map((trigger) => trigger.id) + + expect(triggerIds).toEqual(bitbucketTriggerOptions.map((option) => option.id)) + expect(triggerIds).toEqual(Object.keys(BITBUCKET_TRIGGER_EVENT_MAP)) + expect(new Set(triggerIds)).toHaveLength(19) + + for (const trigger of triggers) { + const eventKey = getBitbucketEventForTrigger(trigger.id) + expect(eventKey).toBeDefined() + expect(trigger.provider).toBe('bitbucket') + expect(trigger.version).toBe('1.0.0') + expect(trigger.webhook?.headers?.['X-Event-Key']).toBe(eventKey) + expect(isBitbucketEventMatch(trigger.id, eventKey)).toBe(true) + expect(isBitbucketEventMatch(trigger.id, 'unrelated:event')).toBe(false) + } + + expect(getBitbucketEventForTrigger('bitbucket_unknown')).toBeUndefined() + expect(isBitbucketEventMatch('bitbucket_unknown', 'repo:push')).toBe(false) + }) + + it('includes the trigger dropdown only on the primary push trigger', () => { + const dropdownOwners = triggers + .filter((trigger) => + trigger.subBlocks.some((subBlock) => subBlock.id === 'selectedTriggerId') + ) + .map((trigger) => trigger.id) + + expect(dropdownOwners).toEqual(['bitbucket_push']) + }) + + it('uses the canonical OAuth, workspace, and repository configuration', () => { + for (const trigger of triggers) { + const credential = trigger.subBlocks.find((subBlock) => subBlock.id === 'triggerCredentials') + const workspace = trigger.subBlocks.find((subBlock) => subBlock.id === 'workspacePicker') + const manualWorkspace = trigger.subBlocks.find( + (subBlock) => subBlock.id === 'workspaceSlugInput' + ) + const repository = trigger.subBlocks.find((subBlock) => subBlock.id === 'repositoryPicker') + const manualRepository = trigger.subBlocks.find( + (subBlock) => subBlock.id === 'repositorySlugInput' + ) + + expect(credential).toMatchObject({ + type: 'oauth-input', + canonicalParamId: 'oauthCredential', + serviceId: 'bitbucket', + requiredScopes: ['account', 'repository', 'pullrequest', 'webhook'], + required: true, + }) + expect(workspace).toMatchObject({ + canonicalParamId: 'workspaceSlug', + selectorKey: 'bitbucket.workspaces', + dependsOn: ['triggerCredentials'], + required: true, + }) + expect(manualWorkspace).toMatchObject({ + canonicalParamId: 'workspaceSlug', + mode: 'trigger-advanced', + required: true, + }) + expect(repository).toMatchObject({ + canonicalParamId: 'repoSlug', + selectorKey: 'bitbucket.repositories', + dependsOn: ['triggerCredentials', 'workspacePicker'], + required: true, + }) + expect(manualRepository).toMatchObject({ + canonicalParamId: 'repoSlug', + mode: 'trigger-advanced', + required: true, + }) + } + }) + + it('declares the common outputs on every event and only its documented family outputs', () => { + const expectedFamilyOutputs: Record = { + bitbucket_push: ['push'], + bitbucket_repository_forked: ['fork'], + bitbucket_repository_updated: ['changes'], + bitbucket_commit_comment_created: ['comment', 'commentContent', 'commentId', 'commit'], + bitbucket_build_status_created: [ + 'commitHash', + 'commitStatus', + 'statusKey', + 'statusName', + 'statusState', + 'statusUrl', + ], + bitbucket_build_status_updated: [ + 'commitHash', + 'commitStatus', + 'statusKey', + 'statusName', + 'statusState', + 'statusUrl', + ], + } + const pullRequestOutputs = [ + 'destinationBranch', + 'pullRequest', + 'pullRequestId', + 'pullRequestState', + 'pullRequestTitle', + 'sourceBranch', + ] + + for (const trigger of triggers) { + let familyOutputs = expectedFamilyOutputs[trigger.id] + if (!familyOutputs) { + familyOutputs = [...pullRequestOutputs] + if ( + trigger.id === 'bitbucket_pull_request_approved' || + trigger.id === 'bitbucket_pull_request_approval_removed' + ) { + familyOutputs.push('approval') + } else if ( + trigger.id === 'bitbucket_pull_request_changes_requested' || + trigger.id === 'bitbucket_pull_request_changes_request_removed' + ) { + familyOutputs.push('changesRequest') + } else if (trigger.id.startsWith('bitbucket_pull_request_comment_')) { + familyOutputs.push('comment', 'commentContent', 'commentId') + } + } + + expect(Object.keys(trigger.outputs).sort()).toEqual( + [...commonOutputKeys, ...familyOutputs].sort() + ) + } + }) +}) diff --git a/apps/sim/triggers/bitbucket/utils.ts b/apps/sim/triggers/bitbucket/utils.ts new file mode 100644 index 00000000000..f8ff0b7a9f3 --- /dev/null +++ b/apps/sim/triggers/bitbucket/utils.ts @@ -0,0 +1,359 @@ +import type { SubBlockConfig } from '@/blocks/types' +import type { TriggerOutput } from '@/triggers/types' + +export const bitbucketTriggerOptions = [ + { label: 'Push', id: 'bitbucket_push' }, + { label: 'Repository Forked', id: 'bitbucket_repository_forked' }, + { label: 'Repository Updated', id: 'bitbucket_repository_updated' }, + { label: 'Commit Comment Created', id: 'bitbucket_commit_comment_created' }, + { label: 'Build Status Created', id: 'bitbucket_build_status_created' }, + { label: 'Build Status Updated', id: 'bitbucket_build_status_updated' }, + { label: 'Pull Request Created', id: 'bitbucket_pull_request_created' }, + { label: 'Pull Request Updated', id: 'bitbucket_pull_request_updated' }, + { label: 'Pull Request Approved', id: 'bitbucket_pull_request_approved' }, + { + label: 'Pull Request Approval Removed', + id: 'bitbucket_pull_request_approval_removed', + }, + { + label: 'Pull Request Changes Requested', + id: 'bitbucket_pull_request_changes_requested', + }, + { + label: 'Pull Request Changes Request Removed', + id: 'bitbucket_pull_request_changes_request_removed', + }, + { label: 'Pull Request Merged', id: 'bitbucket_pull_request_merged' }, + { label: 'Pull Request Declined', id: 'bitbucket_pull_request_declined' }, + { + label: 'Pull Request Comment Created', + id: 'bitbucket_pull_request_comment_created', + }, + { + label: 'Pull Request Comment Updated', + id: 'bitbucket_pull_request_comment_updated', + }, + { + label: 'Pull Request Comment Deleted', + id: 'bitbucket_pull_request_comment_deleted', + }, + { + label: 'Pull Request Comment Resolved', + id: 'bitbucket_pull_request_comment_resolved', + }, + { + label: 'Pull Request Comment Reopened', + id: 'bitbucket_pull_request_comment_reopened', + }, +] as const + +/** Maps Sim trigger IDs to the exact Bitbucket Cloud repository-hook event keys. */ +export const BITBUCKET_TRIGGER_EVENT_MAP = { + bitbucket_push: 'repo:push', + bitbucket_repository_forked: 'repo:fork', + bitbucket_repository_updated: 'repo:updated', + bitbucket_commit_comment_created: 'repo:commit_comment_created', + bitbucket_build_status_created: 'repo:commit_status_created', + bitbucket_build_status_updated: 'repo:commit_status_updated', + bitbucket_pull_request_created: 'pullrequest:created', + bitbucket_pull_request_updated: 'pullrequest:updated', + bitbucket_pull_request_approved: 'pullrequest:approved', + bitbucket_pull_request_approval_removed: 'pullrequest:unapproved', + bitbucket_pull_request_changes_requested: 'pullrequest:changes_request_created', + bitbucket_pull_request_changes_request_removed: 'pullrequest:changes_request_removed', + bitbucket_pull_request_merged: 'pullrequest:fulfilled', + bitbucket_pull_request_declined: 'pullrequest:rejected', + bitbucket_pull_request_comment_created: 'pullrequest:comment_created', + bitbucket_pull_request_comment_updated: 'pullrequest:comment_updated', + bitbucket_pull_request_comment_deleted: 'pullrequest:comment_deleted', + bitbucket_pull_request_comment_resolved: 'pullrequest:comment_resolved', + bitbucket_pull_request_comment_reopened: 'pullrequest:comment_reopened', +} as const satisfies Record + +export type BitbucketTriggerId = keyof typeof BITBUCKET_TRIGGER_EVENT_MAP + +export function getBitbucketEventForTrigger(triggerId: string): string | undefined { + return BITBUCKET_TRIGGER_EVENT_MAP[triggerId as BitbucketTriggerId] +} + +export function isBitbucketEventMatch(triggerId: string, eventKey: string | undefined): boolean { + const expectedEvent = getBitbucketEventForTrigger(triggerId) + return Boolean(expectedEvent && eventKey && expectedEvent === eventKey) +} + +export function bitbucketSetupInstructions(eventLabel: string): string { + const instructions = [ + 'Connect the Bitbucket account that administers the repository.', + 'Select the workspace and repository to monitor.', + `Deploy the workflow. Sim automatically creates a signed webhook for ${eventLabel} events.`, + 'Bitbucket allows up to 50 webhooks per repository; each deployed Sim trigger uses one.', + 'Undeploying the workflow automatically removes the webhook from Bitbucket.', + ] + + return instructions + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} + +/** Fields shared by every automatically managed Bitbucket repository trigger. */ +export function buildBitbucketExtraFields(triggerId: BitbucketTriggerId): SubBlockConfig[] { + const condition = { field: 'selectedTriggerId', value: triggerId } + + return [ + { + id: 'triggerCredentials', + title: 'Bitbucket Account', + type: 'oauth-input', + canonicalParamId: 'oauthCredential', + serviceId: 'bitbucket', + requiredScopes: ['account', 'repository', 'pullrequest', 'webhook'], + placeholder: 'Select Bitbucket account', + description: + 'The Bitbucket account used to create and remove the repository webhook. Repository administrator access is required.', + required: true, + mode: 'trigger', + condition, + }, + { + id: 'workspacePicker', + title: 'Workspace', + canvasNoun: 'a workspace', + type: 'project-selector', + canonicalParamId: 'workspaceSlug', + serviceId: 'bitbucket', + selectorKey: 'bitbucket.workspaces', + dependsOn: ['triggerCredentials'], + placeholder: 'Select Bitbucket workspace', + description: 'The workspace containing the repository to monitor.', + required: true, + mode: 'trigger', + condition, + }, + { + id: 'workspaceSlugInput', + title: 'Workspace Slug', + type: 'short-input', + canonicalParamId: 'workspaceSlug', + placeholder: 'Enter workspace slug', + description: 'Enter the workspace slug directly instead of selecting a workspace.', + required: true, + mode: 'trigger-advanced', + condition, + }, + { + id: 'repositoryPicker', + title: 'Repository', + canvasNoun: 'a repository', + type: 'project-selector', + canonicalParamId: 'repoSlug', + serviceId: 'bitbucket', + selectorKey: 'bitbucket.repositories', + dependsOn: ['triggerCredentials', 'workspacePicker'], + placeholder: 'Select Bitbucket repository', + description: 'The repository where Sim creates the webhook.', + required: true, + mode: 'trigger', + condition, + }, + { + id: 'repositorySlugInput', + title: 'Repository Slug', + type: 'short-input', + canonicalParamId: 'repoSlug', + placeholder: 'Enter repository slug', + description: 'Enter the repository slug directly instead of selecting a repository.', + required: true, + mode: 'trigger-advanced', + condition, + }, + ] +} + +function buildCommonOutputs(): Record { + return { + eventType: { + type: 'string', + description: 'Bitbucket event key from X-Event-Key', + }, + hookUuid: { + type: 'string', + description: 'UUID of the Bitbucket repository webhook', + }, + requestUuid: { + type: 'string', + description: 'Bitbucket delivery request UUID', + }, + attemptNumber: { + type: 'number', + description: 'Bitbucket delivery attempt number', + }, + actor: { type: 'json', description: 'User or app that caused the event' }, + repository: { + type: 'json', + description: 'Repository where the event occurred', + }, + payload: { + type: 'json', + description: 'Full parsed Bitbucket webhook payload', + }, + } +} + +function buildCommentOutputs(): Record { + return { + comment: { type: 'json', description: 'Bitbucket comment object' }, + commentId: { type: 'number', description: 'Repository-scoped comment ID' }, + commentContent: { + type: 'string', + description: 'Raw text content of the comment', + }, + } +} + +function buildPullRequestOutputs(): Record { + return { + pullRequest: { type: 'json', description: 'Bitbucket pull request object' }, + pullRequestId: { + type: 'number', + description: 'Repository-scoped pull request ID', + }, + pullRequestTitle: { type: 'string', description: 'Pull request title' }, + pullRequestState: { type: 'string', description: 'Pull request state' }, + sourceBranch: { type: 'string', description: 'Pull request source branch' }, + destinationBranch: { + type: 'string', + description: 'Pull request destination branch', + }, + } +} + +export function buildBitbucketPushOutputs(): Record { + return { + ...buildCommonOutputs(), + push: { + type: 'json', + description: 'Push details, including changes and commits', + }, + } +} + +export function buildBitbucketForkOutputs(): Record { + return { + ...buildCommonOutputs(), + fork: { type: 'json', description: 'Newly created repository fork' }, + } +} + +export function buildBitbucketRepositoryUpdatedOutputs(): Record { + return { + ...buildCommonOutputs(), + changes: { + type: 'json', + description: 'Repository fields changed by the update', + }, + } +} + +export function buildBitbucketCommitCommentOutputs(): Record { + return { + ...buildCommonOutputs(), + ...buildCommentOutputs(), + commit: { type: 'json', description: 'Commit the comment was created on' }, + } +} + +export function buildBitbucketBuildStatusOutputs(): Record { + return { + ...buildCommonOutputs(), + commitStatus: { + type: 'json', + description: 'Bitbucket commit status object', + }, + commitHash: { + type: 'string', + description: 'Hash of the commit with this status', + }, + statusKey: { + type: 'string', + description: 'Key identifying the build status', + }, + statusState: { type: 'string', description: 'Current build status state' }, + statusName: { + type: 'string', + description: 'Display name of the build status', + }, + statusUrl: { + type: 'string', + description: 'URL associated with the build status', + }, + } +} + +export function buildBitbucketPullRequestOutputs(): Record { + return { + ...buildCommonOutputs(), + ...buildPullRequestOutputs(), + } +} + +export function buildBitbucketPullRequestApprovalOutputs(): Record { + return { + ...buildBitbucketPullRequestOutputs(), + approval: { type: 'json', description: 'Pull request approval details' }, + } +} + +export function buildBitbucketPullRequestChangesRequestOutputs(): Record { + return { + ...buildBitbucketPullRequestOutputs(), + changesRequest: { + type: 'json', + description: 'Pull request changes-request details', + }, + } +} + +export function buildBitbucketPullRequestCommentOutputs(): Record { + return { + ...buildBitbucketPullRequestOutputs(), + ...buildCommentOutputs(), + } +} + +export function buildBitbucketOutputs( + triggerId: BitbucketTriggerId +): Record { + switch (triggerId) { + case 'bitbucket_push': + return buildBitbucketPushOutputs() + case 'bitbucket_repository_forked': + return buildBitbucketForkOutputs() + case 'bitbucket_repository_updated': + return buildBitbucketRepositoryUpdatedOutputs() + case 'bitbucket_commit_comment_created': + return buildBitbucketCommitCommentOutputs() + case 'bitbucket_build_status_created': + case 'bitbucket_build_status_updated': + return buildBitbucketBuildStatusOutputs() + case 'bitbucket_pull_request_approved': + case 'bitbucket_pull_request_approval_removed': + return buildBitbucketPullRequestApprovalOutputs() + case 'bitbucket_pull_request_changes_requested': + case 'bitbucket_pull_request_changes_request_removed': + return buildBitbucketPullRequestChangesRequestOutputs() + case 'bitbucket_pull_request_comment_created': + case 'bitbucket_pull_request_comment_updated': + case 'bitbucket_pull_request_comment_deleted': + case 'bitbucket_pull_request_comment_resolved': + case 'bitbucket_pull_request_comment_reopened': + return buildBitbucketPullRequestCommentOutputs() + case 'bitbucket_pull_request_created': + case 'bitbucket_pull_request_updated': + case 'bitbucket_pull_request_merged': + case 'bitbucket_pull_request_declined': + return buildBitbucketPullRequestOutputs() + } +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index f08a0902749..d00028aab4a 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -36,6 +36,27 @@ import { azureDevOpsWebhookTrigger, azureDevOpsWorkItemCreatedTrigger, } from '@/triggers/azure_devops' +import { + bitbucketBuildStatusCreatedTrigger, + bitbucketBuildStatusUpdatedTrigger, + bitbucketCommitCommentCreatedTrigger, + bitbucketPullRequestApprovalRemovedTrigger, + bitbucketPullRequestApprovedTrigger, + bitbucketPullRequestChangesRequestedTrigger, + bitbucketPullRequestChangesRequestRemovedTrigger, + bitbucketPullRequestCommentCreatedTrigger, + bitbucketPullRequestCommentDeletedTrigger, + bitbucketPullRequestCommentReopenedTrigger, + bitbucketPullRequestCommentResolvedTrigger, + bitbucketPullRequestCommentUpdatedTrigger, + bitbucketPullRequestCreatedTrigger, + bitbucketPullRequestDeclinedTrigger, + bitbucketPullRequestMergedTrigger, + bitbucketPullRequestUpdatedTrigger, + bitbucketPushTrigger, + bitbucketRepositoryForkedTrigger, + bitbucketRepositoryUpdatedTrigger, +} from '@/triggers/bitbucket' import { calcomBookingCancelledTrigger, calcomBookingCreatedTrigger, @@ -513,6 +534,25 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { attio_list_updated: attioListUpdatedTrigger, attio_list_deleted: attioListDeletedTrigger, attio_workspace_member_created: attioWorkspaceMemberCreatedTrigger, + bitbucket_push: bitbucketPushTrigger, + bitbucket_repository_forked: bitbucketRepositoryForkedTrigger, + bitbucket_repository_updated: bitbucketRepositoryUpdatedTrigger, + bitbucket_commit_comment_created: bitbucketCommitCommentCreatedTrigger, + bitbucket_build_status_created: bitbucketBuildStatusCreatedTrigger, + bitbucket_build_status_updated: bitbucketBuildStatusUpdatedTrigger, + bitbucket_pull_request_created: bitbucketPullRequestCreatedTrigger, + bitbucket_pull_request_updated: bitbucketPullRequestUpdatedTrigger, + bitbucket_pull_request_approved: bitbucketPullRequestApprovedTrigger, + bitbucket_pull_request_approval_removed: bitbucketPullRequestApprovalRemovedTrigger, + bitbucket_pull_request_changes_requested: bitbucketPullRequestChangesRequestedTrigger, + bitbucket_pull_request_changes_request_removed: bitbucketPullRequestChangesRequestRemovedTrigger, + bitbucket_pull_request_merged: bitbucketPullRequestMergedTrigger, + bitbucket_pull_request_declined: bitbucketPullRequestDeclinedTrigger, + bitbucket_pull_request_comment_created: bitbucketPullRequestCommentCreatedTrigger, + bitbucket_pull_request_comment_updated: bitbucketPullRequestCommentUpdatedTrigger, + bitbucket_pull_request_comment_deleted: bitbucketPullRequestCommentDeletedTrigger, + bitbucket_pull_request_comment_resolved: bitbucketPullRequestCommentResolvedTrigger, + bitbucket_pull_request_comment_reopened: bitbucketPullRequestCommentReopenedTrigger, calendly_webhook: calendlyWebhookTrigger, calendly_invitee_created: calendlyInviteeCreatedTrigger, calendly_invitee_canceled: calendlyInviteeCanceledTrigger, diff --git a/packages/deployment-config/src/integrations.json b/packages/deployment-config/src/integrations.json index adfa0cfaf64..3f7b9e96b43 100644 --- a/packages/deployment-config/src/integrations.json +++ b/packages/deployment-config/src/integrations.json @@ -2807,7 +2807,7 @@ "slug": "bitbucket", "name": "Bitbucket", "description": "Work with Bitbucket Cloud repositories, pull requests, and pipelines", - "longDescription": "Connect Bitbucket Cloud to inspect repositories and source, collaborate on pull requests, and diagnose or control pipelines. This action integration uses OAuth and does not create webhooks or triggers.", + "longDescription": "Connect Bitbucket Cloud to inspect repositories and source, collaborate on pull requests, diagnose or control pipelines, and start workflows from repository and pull request events. OAuth is used for actions and automatic webhook management.", "bgColor": "#FFFFFF", "iconName": "BitbucketIcon", "docsUrl": "https://docs.sim.ai/integrations/bitbucket", @@ -2934,8 +2934,104 @@ } ], "operationCount": 30, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "bitbucket_push", + "name": "Bitbucket Push", + "description": "Trigger workflow when commits are pushed to a Bitbucket repository" + }, + { + "id": "bitbucket_repository_forked", + "name": "Bitbucket Repository Forked", + "description": "Trigger workflow when a Bitbucket repository is forked" + }, + { + "id": "bitbucket_repository_updated", + "name": "Bitbucket Repository Updated", + "description": "Trigger workflow when a Bitbucket repository is updated" + }, + { + "id": "bitbucket_commit_comment_created", + "name": "Bitbucket Commit Comment Created", + "description": "Trigger workflow when a comment is created on a Bitbucket commit" + }, + { + "id": "bitbucket_build_status_created", + "name": "Bitbucket Build Status Created", + "description": "Trigger workflow when a build status is created for a Bitbucket commit" + }, + { + "id": "bitbucket_build_status_updated", + "name": "Bitbucket Build Status Updated", + "description": "Trigger workflow when a build status is updated for a Bitbucket commit" + }, + { + "id": "bitbucket_pull_request_created", + "name": "Bitbucket Pull Request Created", + "description": "Trigger workflow when a Bitbucket pull request is created" + }, + { + "id": "bitbucket_pull_request_updated", + "name": "Bitbucket Pull Request Updated", + "description": "Trigger workflow when a Bitbucket pull request is updated" + }, + { + "id": "bitbucket_pull_request_approved", + "name": "Bitbucket Pull Request Approved", + "description": "Trigger workflow when a Bitbucket pull request is approved" + }, + { + "id": "bitbucket_pull_request_approval_removed", + "name": "Bitbucket Pull Request Approval Removed", + "description": "Trigger workflow when approval is removed from a Bitbucket pull request" + }, + { + "id": "bitbucket_pull_request_changes_requested", + "name": "Bitbucket Pull Request Changes Requested", + "description": "Trigger workflow when changes are requested on a Bitbucket pull request" + }, + { + "id": "bitbucket_pull_request_changes_request_removed", + "name": "Bitbucket Pull Request Changes Request Removed", + "description": "Trigger workflow when a changes request is removed from a Bitbucket pull request" + }, + { + "id": "bitbucket_pull_request_merged", + "name": "Bitbucket Pull Request Merged", + "description": "Trigger workflow when a Bitbucket pull request is merged" + }, + { + "id": "bitbucket_pull_request_declined", + "name": "Bitbucket Pull Request Declined", + "description": "Trigger workflow when a Bitbucket pull request is declined" + }, + { + "id": "bitbucket_pull_request_comment_created", + "name": "Bitbucket Pull Request Comment Created", + "description": "Trigger workflow when a comment is created on a Bitbucket pull request" + }, + { + "id": "bitbucket_pull_request_comment_updated", + "name": "Bitbucket Pull Request Comment Updated", + "description": "Trigger workflow when a comment is updated on a Bitbucket pull request" + }, + { + "id": "bitbucket_pull_request_comment_deleted", + "name": "Bitbucket Pull Request Comment Deleted", + "description": "Trigger workflow when a comment is deleted from a Bitbucket pull request" + }, + { + "id": "bitbucket_pull_request_comment_resolved", + "name": "Bitbucket Pull Request Comment Resolved", + "description": "Trigger workflow when a comment is resolved on a Bitbucket pull request" + }, + { + "id": "bitbucket_pull_request_comment_reopened", + "name": "Bitbucket Pull Request Comment Reopened", + "description": "Trigger workflow when a resolved comment is reopened on a Bitbucket pull request" + } + ], + "triggerCount": 19, "authType": "oauth", "oauthServiceId": "bitbucket", "category": "tools", diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index 55a080b7f42..ded2d08310d 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -10,29 +10,29 @@ "gateways": {} }, "app/workspace/[workspaceId]/chat/[chatId]/page.tsx": { - "modules": 3050, + "modules": 3073, "gateways": { "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1383, "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1033, "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 894, "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 891, - "apps/sim/triggers/registry.ts": 452, + "apps/sim/triggers/registry.ts": 472, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 334, "apps/sim/blocks/registry.ts": 315, - "apps/sim/lib/auth/index.ts": 228 + "apps/sim/lib/auth/index.ts": 230 } }, "app/workspace/[workspaceId]/files/[fileId]/page.tsx": { - "modules": 2007, + "modules": 2030, "gateways": { - "apps/sim/triggers/registry.ts": 452, + "apps/sim/triggers/registry.ts": 472, "apps/sim/blocks/registry.ts": 341, "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 273, - "apps/sim/lib/auth/index.ts": 234, + "apps/sim/lib/auth/index.ts": 236, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 151, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 134, "apps/sim/lib/api/contracts/index.ts": 109, - "apps/sim/lib/webhooks/providers/index.ts": 107 + "apps/sim/lib/webhooks/providers/index.ts": 109 } }, "app/workspace/[workspaceId]/files/[fileId]/view/page.tsx": { @@ -43,16 +43,16 @@ } }, "app/workspace/[workspaceId]/files/page.tsx": { - "modules": 2007, + "modules": 2030, "gateways": { - "apps/sim/triggers/registry.ts": 452, + "apps/sim/triggers/registry.ts": 472, "apps/sim/blocks/registry.ts": 341, "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 275, - "apps/sim/lib/auth/index.ts": 234, + "apps/sim/lib/auth/index.ts": 236, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 151, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 134, "apps/sim/lib/api/contracts/index.ts": 109, - "apps/sim/lib/webhooks/providers/index.ts": 107 + "apps/sim/lib/webhooks/providers/index.ts": 109 } }, "app/workspace/[workspaceId]/home/layout.tsx": { @@ -60,23 +60,23 @@ "gateways": {} }, "app/workspace/[workspaceId]/home/page.tsx": { - "modules": 3050, + "modules": 3073, "gateways": { "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1383, "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1033, "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 894, "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 891, - "apps/sim/triggers/registry.ts": 452, + "apps/sim/triggers/registry.ts": 472, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 334, "apps/sim/blocks/registry.ts": 315, - "apps/sim/lib/auth/index.ts": 228 + "apps/sim/lib/auth/index.ts": 230 } }, "app/workspace/[workspaceId]/integrations/[block]/page.tsx": { - "modules": 1329, + "modules": 1350, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1303, - "apps/sim/triggers/index.ts": 488, + "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1324, + "apps/sim/triggers/index.ts": 509, "apps/sim/blocks/registry.ts": 447, "apps/sim/lib/api/contracts/index.ts": 129, "apps/sim/blocks/blocks/credential-group.ts": 106, @@ -86,10 +86,10 @@ } }, "app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx": { - "modules": 1307, + "modules": 1328, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1306, - "apps/sim/triggers/registry.ts": 487, + "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1327, + "apps/sim/triggers/registry.ts": 508, "apps/sim/blocks/registry.ts": 348, "apps/sim/lib/api/contracts/index.ts": 135, "apps/sim/stores/workflows/registry/store.ts": 77, @@ -99,11 +99,11 @@ } }, "app/workspace/[workspaceId]/integrations/page.tsx": { - "modules": 1314, + "modules": 1335, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 1024, - "apps/sim/blocks/registry.ts": 940, - "apps/sim/triggers/index.ts": 488, + "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 1045, + "apps/sim/blocks/registry.ts": 961, + "apps/sim/triggers/index.ts": 509, "apps/sim/lib/api/contracts/index.ts": 131, "apps/sim/blocks/blocks/credential-group.ts": 107, "apps/sim/stores/workflows/registry/store.ts": 81, @@ -112,10 +112,10 @@ } }, "app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx": { - "modules": 1509, + "modules": 1530, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1216, - "apps/sim/triggers/registry.ts": 487, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1237, + "apps/sim/triggers/registry.ts": 508, "apps/sim/blocks/registry.ts": 344, "apps/sim/blocks/registry-maps.ts": 341, "apps/sim/lib/api/contracts/index.ts": 129, @@ -125,10 +125,10 @@ } }, "app/workspace/[workspaceId]/knowledge/[id]/page.tsx": { - "modules": 1521, + "modules": 1542, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1227, - "apps/sim/triggers/registry.ts": 487, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1248, + "apps/sim/triggers/registry.ts": 508, "apps/sim/blocks/registry.ts": 349, "apps/sim/blocks/registry-maps.ts": 346, "apps/sim/lib/api/contracts/index.ts": 129, @@ -138,36 +138,36 @@ } }, "app/workspace/[workspaceId]/knowledge/page.tsx": { - "modules": 2231, + "modules": 2253, "gateways": { - "apps/sim/triggers/registry.ts": 452, + "apps/sim/triggers/registry.ts": 472, "apps/sim/blocks/registry.ts": 339, - "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 291, - "apps/sim/lib/knowledge/application/knowledge-bases.ts": 235, - "apps/sim/lib/auth/index.ts": 182, + "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 290, + "apps/sim/lib/knowledge/application/knowledge-bases.ts": 234, + "apps/sim/lib/auth/index.ts": 183, "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 154, - "apps/sim/lib/knowledge/orchestration/index.ts": 148, - "apps/sim/lib/knowledge/orchestration/connectors.ts": 142 + "apps/sim/lib/knowledge/orchestration/index.ts": 147, + "apps/sim/lib/knowledge/orchestration/connectors.ts": 141 } }, "app/workspace/[workspaceId]/layout.tsx": { - "modules": 2083, + "modules": 2106, "gateways": { - "apps/sim/triggers/registry.ts": 452, + "apps/sim/triggers/registry.ts": 472, "apps/sim/blocks/registry.ts": 340, + "apps/sim/lib/auth/index.ts": 307, "apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts": 305, - "apps/sim/lib/auth/index.ts": 305, "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 297, "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 205, "apps/sim/lib/api/contracts/index.ts": 109, - "apps/sim/lib/webhooks/providers/index.ts": 107 + "apps/sim/lib/webhooks/providers/index.ts": 109 } }, "app/workspace/[workspaceId]/logs/page.tsx": { - "modules": 1748, + "modules": 1769, "gateways": { - "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1457, - "apps/sim/triggers/registry.ts": 487, + "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1478, + "apps/sim/triggers/registry.ts": 508, "apps/sim/app/workspace/[workspaceId]/logs/components/index.ts": 399, "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts": 345, "apps/sim/blocks/registry.ts": 335, @@ -181,19 +181,19 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/[section]/layout.tsx": { - "modules": 4, + "modules": 11, "gateways": {} }, "app/workspace/[workspaceId]/settings/[section]/page.tsx": { - "modules": 2102, + "modules": 2125, "gateways": { - "apps/sim/triggers/registry.ts": 452, + "apps/sim/triggers/registry.ts": 472, "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 445, "apps/sim/blocks/registry.ts": 339, - "apps/sim/lib/auth/index.ts": 314, + "apps/sim/lib/auth/index.ts": 316, + "apps/sim/lib/webhooks/providers/index.ts": 109, "apps/sim/lib/api/contracts/index.ts": 107, - "apps/sim/lib/webhooks/providers/index.ts": 107, - "apps/sim/lib/webhooks/providers/registry.ts": 105, + "apps/sim/lib/webhooks/providers/registry.ts": 107, "apps/sim/lib/api/contracts/tools/index.ts": 59 } }, @@ -202,12 +202,12 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx": { - "modules": 1664, + "modules": 1687, "gateways": { - "apps/sim/lib/auth/index.ts": 1527, + "apps/sim/lib/auth/index.ts": 1550, "apps/sim/blocks/registry.ts": 616, "apps/sim/blocks/registry-maps.ts": 613, - "apps/sim/triggers/index.ts": 453, + "apps/sim/triggers/index.ts": 473, "apps/sim/blocks/blocks/credential-group.ts": 271, "apps/sim/stores/workflows/registry/store.ts": 253, "apps/sim/hooks/queries/deployments.ts": 245, @@ -223,9 +223,9 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx": { - "modules": 1354, + "modules": 1375, "gateways": { - "apps/sim/triggers/registry.ts": 487, + "apps/sim/triggers/registry.ts": 508, "apps/sim/blocks/registry.ts": 350, "apps/sim/blocks/registry-maps.ts": 347, "apps/sim/lib/api/contracts/index.ts": 136, @@ -236,10 +236,10 @@ } }, "app/workspace/[workspaceId]/skills/[skillId]/page.tsx": { - "modules": 1430, + "modules": 1451, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1429, - "apps/sim/triggers/registry.ts": 487, + "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1450, + "apps/sim/triggers/registry.ts": 508, "apps/sim/blocks/registry.ts": 349, "apps/sim/blocks/registry-maps.ts": 347, "apps/sim/lib/api/contracts/index.ts": 127, @@ -249,10 +249,10 @@ } }, "app/workspace/[workspaceId]/skills/new/page.tsx": { - "modules": 1428, + "modules": 1449, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1427, - "apps/sim/triggers/registry.ts": 487, + "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1448, + "apps/sim/triggers/registry.ts": 508, "apps/sim/blocks/registry.ts": 349, "apps/sim/blocks/registry-maps.ts": 347, "apps/sim/lib/api/contracts/index.ts": 127, @@ -262,24 +262,24 @@ } }, "app/workspace/[workspaceId]/skills/page.tsx": { - "modules": 1297, + "modules": 1318, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 1007, - "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 995, - "apps/sim/blocks/registry.ts": 983, - "apps/sim/blocks/registry-maps.ts": 981, - "apps/sim/triggers/index.ts": 488, + "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 1028, + "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 1016, + "apps/sim/blocks/registry.ts": 1004, + "apps/sim/blocks/registry-maps.ts": 1002, + "apps/sim/triggers/index.ts": 509, "apps/sim/lib/api/contracts/index.ts": 136, "apps/sim/blocks/blocks/credential-group.ts": 120, "apps/sim/stores/workflows/registry/store.ts": 92 } }, "app/workspace/[workspaceId]/tables/[tableId]/page.tsx": { - "modules": 2275, + "modules": 2298, "gateways": { "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 564, - "apps/sim/triggers/registry.ts": 452, - "apps/sim/lib/auth/index.ts": 336, + "apps/sim/triggers/registry.ts": 472, + "apps/sim/lib/auth/index.ts": 338, "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 315, "apps/sim/blocks/registry.ts": 315, "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 273, @@ -288,15 +288,15 @@ } }, "app/workspace/[workspaceId]/tables/page.tsx": { - "modules": 1852, + "modules": 1875, "gateways": { - "apps/sim/triggers/registry.ts": 452, + "apps/sim/triggers/registry.ts": 472, "apps/sim/blocks/registry.ts": 340, - "apps/sim/lib/auth/index.ts": 325, + "apps/sim/lib/auth/index.ts": 327, "apps/sim/lib/api/contracts/index.ts": 116, - "apps/sim/lib/webhooks/providers/index.ts": 107, + "apps/sim/lib/webhooks/providers/index.ts": 109, + "apps/sim/lib/webhooks/providers/registry.ts": 107, "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 106, - "apps/sim/lib/webhooks/providers/registry.ts": 105, "apps/sim/lib/api/contracts/tools/index.ts": 60 } }, @@ -314,11 +314,11 @@ } }, "app/workspace/[workspaceId]/w/[workflowId]/layout.tsx": { - "modules": 2242, + "modules": 2263, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 2241, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 2262, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 548, - "apps/sim/triggers/registry.ts": 487, + "apps/sim/triggers/registry.ts": 508, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 465, "apps/sim/blocks/registry.ts": 335, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 290, @@ -327,10 +327,10 @@ } }, "app/workspace/[workspaceId]/w/[workflowId]/page.tsx": { - "modules": 2272, + "modules": 2293, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2271, - "apps/sim/triggers/registry.ts": 487, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2292, + "apps/sim/triggers/registry.ts": 508, "apps/sim/blocks/registry.ts": 335, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 309, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 271, @@ -340,10 +340,10 @@ } }, "app/workspace/[workspaceId]/w/page.tsx": { - "modules": 2242, + "modules": 2263, "gateways": { "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 955, - "apps/sim/triggers/registry.ts": 487, + "apps/sim/triggers/registry.ts": 508, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 465, "apps/sim/blocks/registry.ts": 335, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 290, @@ -353,10 +353,10 @@ } }, "app/workspace/layout.tsx": { - "modules": 1242, + "modules": 1263, "gateways": { - "apps/sim/app/workspace/providers/socket-provider.tsx": 1232, - "apps/sim/triggers/registry.ts": 487, + "apps/sim/app/workspace/providers/socket-provider.tsx": 1253, + "apps/sim/triggers/registry.ts": 508, "apps/sim/blocks/registry.ts": 350, "apps/sim/blocks/registry-maps.ts": 347, "apps/sim/stores/workflows/registry/store.ts": 275, @@ -366,10 +366,10 @@ } }, "app/workspace/page.tsx": { - "modules": 1237, + "modules": 1258, "gateways": { - "apps/sim/lib/auth/stale-session-recovery.ts": 1000, - "apps/sim/triggers/index.ts": 488, + "apps/sim/lib/auth/stale-session-recovery.ts": 1021, + "apps/sim/triggers/index.ts": 509, "apps/sim/blocks/registry.ts": 350, "apps/sim/blocks/registry-maps.ts": 347, "apps/sim/lib/api/contracts/index.ts": 139,