diff --git a/docs/components/Cloudsmith.mdx b/docs/components/Cloudsmith.mdx new file mode 100644 index 000000000..2a0e838f7 --- /dev/null +++ b/docs/components/Cloudsmith.mdx @@ -0,0 +1,109 @@ +--- +title: "Cloudsmith" +--- + +Manage and react to Cloudsmith package repositories + +import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + +## Triggers + + + + + +## Actions + + + + + +## Instructions + +To generate a Cloudsmith API token: +- Go to **Cloudsmith** → **Account Settings** → **API Key** +- Copy the API key and enter it below, along with your organization or user workspace (slug) + + + +## On Package Event + +The On Package Event trigger starts a workflow execution when a package event occurs in a Cloudsmith repository. + +### Use Cases + +- **Build pipelines**: Trigger downstream workflows when a new package is synchronized +- **Release workflows**: Automate promotion or notification on package publish +- **Security automation**: React to quarantined or failed packages + +### Configuration + +- **Repository**: Cloudsmith repository in the format `namespace/repo` +- **Events**: Package events to listen for (e.g. `package.synced`, `package.deleted`) + +### Webhook Setup + +This trigger creates a webhook in Cloudsmith automatically when the canvas is saved. + +### Example Data + +```json +{ + "data": { + "event": "package.synced", + "package": { + "cdn_url": "https://dl.cloudsmith.io/basic/my-org/my-repo/python/simple/my-library/", + "checksum_sha256": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1", + "format": "python", + "name": "my-library", + "size": 45678, + "slug_perm": "Wcpkg1234abcd", + "status_str": "Synchronised", + "version": "1.2.3" + }, + "repository": { + "name": "my-repo", + "namespace": "my-org", + "slug": "my-repo" + } + }, + "timestamp": "2026-02-03T12:00:00Z", + "type": "cloudsmith.package.event" +} +``` + + + +## Get Package + +The Get Package component retrieves metadata for a Cloudsmith package. + +### Use Cases + +- **Release automation**: Fetch package details for downstream deployments +- **Audit trails**: Resolve package metadata for traceability +- **Insights**: Inspect package sizes, checksums, and status + +### Configuration + +- **Repository**: Cloudsmith repository in the format `namespace/repo` +- **Identifier**: Package identifier or slug (for example: `Wklm1a2b`) + +### Example Output + +```json +{ + "data": { + "cdn_url": "https://dl.cloudsmith.io/basic/my-org/my-repo/python/simple/my-library/", + "checksum_sha256": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1", + "format": "python", + "name": "my-library", + "size": 45678, + "status_str": "Synchronised", + "version": "1.2.3" + }, + "timestamp": "2026-02-03T12:00:00Z", + "type": "cloudsmith.package" +} +``` + diff --git a/pkg/integrations/cloudsmith/client.go b/pkg/integrations/cloudsmith/client.go new file mode 100644 index 000000000..f045c02be --- /dev/null +++ b/pkg/integrations/cloudsmith/client.go @@ -0,0 +1,206 @@ +package cloudsmith + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/superplanehq/superplane/pkg/core" +) + +const ( + defaultBaseURL = "https://api.cloudsmith.io" +) + +type Client struct { + APIKey string + Workspace string + BaseURL string + http core.HTTPContext +} + +func NewClient(httpClient core.HTTPContext, integration core.IntegrationContext) (*Client, error) { + if integration == nil { + return nil, fmt.Errorf("no integration context") + } + + apiKey, err := integration.GetConfig("apiKey") + if err != nil { + return nil, fmt.Errorf("API key not configured: %w", err) + } + + key := strings.TrimSpace(string(apiKey)) + if key == "" { + return nil, fmt.Errorf("API key is required") + } + + workspace, err := integration.GetConfig("workspace") + if err != nil { + return nil, fmt.Errorf("workspace not configured: %w", err) + } + + ws := strings.TrimSpace(string(workspace)) + + return &Client{ + APIKey: key, + Workspace: ws, + BaseURL: defaultBaseURL, + http: httpClient, + }, nil +} + +func (c *Client) doRequest(method, path string, body io.Reader) (*http.Response, []byte, error) { + finalURL := path + if !strings.HasPrefix(path, "http") { + finalURL = c.BaseURL + path + } + + req, err := http.NewRequest(method, finalURL, body) + if err != nil { + return nil, nil, fmt.Errorf("failed to build request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Api-Key", c.APIKey) + + res, err := c.http.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("request failed: %w", err) + } + defer res.Body.Close() + + responseBody, err := io.ReadAll(res.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + + if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices { + return nil, nil, fmt.Errorf("request failed with %d: %s", res.StatusCode, string(responseBody)) + } + + return res, responseBody, nil +} + +func (c *Client) ValidateCredentials() error { + _, _, err := c.doRequest(http.MethodGet, "/user/self/", nil) + return err +} + +type Repository struct { + Name string `json:"name"` + Slug string `json:"slug"` +} + +func (c *Client) ListRepositories(namespace string) ([]Repository, error) { + namespace = strings.TrimSpace(namespace) + if namespace == "" { + return nil, fmt.Errorf("namespace is required") + } + + path := fmt.Sprintf("/repos/%s/", namespace) + _, responseBody, err := c.doRequest(http.MethodGet, path, nil) + if err != nil { + return nil, err + } + + var repositories []Repository + if err := json.Unmarshal(responseBody, &repositories); err != nil { + return nil, fmt.Errorf("failed to parse repositories response: %w", err) + } + + return repositories, nil +} + +type PackageInfo struct { + Name string `json:"name"` + Version string `json:"version"` + Format string `json:"format"` + Size int64 `json:"size"` + ChecksumSHA256 string `json:"checksum_sha256"` + CDNURL string `json:"cdn_url"` + Status string `json:"status_str"` +} + +func (c *Client) GetPackage(namespace, repo, identifier string) (*PackageInfo, error) { + if namespace == "" || repo == "" || identifier == "" { + return nil, fmt.Errorf("namespace, repo, and identifier are required") + } + + path := fmt.Sprintf("/packages/%s/%s/%s/", namespace, repo, identifier) + _, responseBody, err := c.doRequest(http.MethodGet, path, nil) + if err != nil { + return nil, err + } + + var pkg PackageInfo + if err := json.Unmarshal(responseBody, &pkg); err != nil { + return nil, fmt.Errorf("failed to parse package response: %w", err) + } + + return &pkg, nil +} + +type WebhookTemplate struct { + Event string `json:"event"` + Template string `json:"template"` +} + +type CreateWebhookRequest struct { + TargetURL string `json:"target_url"` + Events []string `json:"events"` + IsActive bool `json:"is_active"` + Templates []WebhookTemplate `json:"templates"` +} + +type CreateWebhookResponse struct { + SlugPerm string `json:"slug_perm"` +} + +func (c *Client) CreateWebhook(namespace, repo, targetURL string, events []string) (string, error) { + if namespace == "" || repo == "" || targetURL == "" { + return "", fmt.Errorf("namespace, repo, and targetURL are required") + } + + // Cloudsmith requires a templates entry per subscribed event. The Template field + // holds a Handlebars template string for custom payload formatting; an empty string + // uses the default JSON payload (request_body_format = 0). + templates := make([]WebhookTemplate, 0, len(events)) + for _, event := range events { + templates = append(templates, WebhookTemplate{Event: event, Template: ""}) + } + + payload, err := json.Marshal(CreateWebhookRequest{ + TargetURL: targetURL, + Events: events, + IsActive: true, + Templates: templates, + }) + if err != nil { + return "", fmt.Errorf("failed to marshal webhook request: %w", err) + } + + path := fmt.Sprintf("/webhooks/%s/%s/", namespace, repo) + _, responseBody, err := c.doRequest(http.MethodPost, path, strings.NewReader(string(payload))) + if err != nil { + return "", err + } + + var response CreateWebhookResponse + if err := json.Unmarshal(responseBody, &response); err != nil { + return "", fmt.Errorf("failed to parse webhook response: %w", err) + } + + return response.SlugPerm, nil +} + +func (c *Client) DeleteWebhook(namespace, repo, slugPerm string) error { + if namespace == "" || repo == "" || slugPerm == "" { + return fmt.Errorf("namespace, repo, and slugPerm are required") + } + + path := fmt.Sprintf("/webhooks/%s/%s/%s/", namespace, repo, slugPerm) + _, _, err := c.doRequest(http.MethodDelete, path, nil) + return err +} diff --git a/pkg/integrations/cloudsmith/client_test.go b/pkg/integrations/cloudsmith/client_test.go new file mode 100644 index 000000000..4f68653bf --- /dev/null +++ b/pkg/integrations/cloudsmith/client_test.go @@ -0,0 +1,66 @@ +package cloudsmith + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__Cloudsmith__NewClient(t *testing.T) { + t.Run("missing API key -> error", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{} + + _, err := NewClient(&contexts.HTTPContext{}, integrationCtx) + require.Error(t, err) + assert.Contains(t, err.Error(), "API key not configured") + }) + + t.Run("valid configuration -> client created", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiKey": "test-api-key", + "workspace": "my-org", + }, + } + + client, err := NewClient(&contexts.HTTPContext{}, integrationCtx) + require.NoError(t, err) + assert.Equal(t, "test-api-key", client.APIKey) + assert.Equal(t, "my-org", client.Workspace) + }) +} + +func Test__Cloudsmith__ListRepositories(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`[{"name":"my-repo","slug":"my-repo"}]`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiKey": "test-api-key", + "workspace": "my-org", + }, + } + + client, err := NewClient(httpCtx, integrationCtx) + require.NoError(t, err) + + repos, err := client.ListRepositories("my-org") + require.NoError(t, err) + require.Len(t, repos, 1) + assert.Equal(t, "my-repo", repos[0].Name) + + require.Len(t, httpCtx.Requests, 1) + assert.Contains(t, httpCtx.Requests[0].URL.String(), "/repos/my-org/") + assert.Equal(t, "test-api-key", httpCtx.Requests[0].Header.Get("X-Api-Key")) +} diff --git a/pkg/integrations/cloudsmith/cloudsmith.go b/pkg/integrations/cloudsmith/cloudsmith.go new file mode 100644 index 000000000..0e880ee3a --- /dev/null +++ b/pkg/integrations/cloudsmith/cloudsmith.go @@ -0,0 +1,110 @@ +package cloudsmith + +import ( + "fmt" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/registry" +) + +func init() { + registry.RegisterIntegration("cloudsmith", &Cloudsmith{}) +} + +type Cloudsmith struct{} + +type Configuration struct { + APIKey string `json:"apiKey"` + Workspace string `json:"workspace"` +} + +func (c *Cloudsmith) Name() string { + return "cloudsmith" +} + +func (c *Cloudsmith) Label() string { + return "Cloudsmith" +} + +func (c *Cloudsmith) Icon() string { + return "cloudsmith" +} + +func (c *Cloudsmith) Description() string { + return "Manage and react to Cloudsmith package repositories" +} + +func (c *Cloudsmith) Instructions() string { + return ` +To generate a Cloudsmith API token: +- Go to **Cloudsmith** → **Account Settings** → **API Key** +- Copy the API key and enter it below, along with your organization or user workspace (slug) +` +} + +func (c *Cloudsmith) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "apiKey", + Label: "API Token", + Type: configuration.FieldTypeString, + Required: true, + Sensitive: true, + Description: "Cloudsmith API token", + }, + { + Name: "workspace", + Label: "Workspace", + Type: configuration.FieldTypeString, + Required: true, + Description: "Cloudsmith organization or user workspace (slug)", + }, + } +} + +func (c *Cloudsmith) Components() []core.Component { + return []core.Component{&GetPackage{}} +} + +func (c *Cloudsmith) Triggers() []core.Trigger { + return []core.Trigger{&OnPackageEvent{}} +} + +func (c *Cloudsmith) Cleanup(ctx core.IntegrationCleanupContext) error { + return nil +} + +func (c *Cloudsmith) Sync(ctx core.SyncContext) error { + config := Configuration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + if err := client.ValidateCredentials(); err != nil { + return fmt.Errorf("failed to validate credentials: %w", err) + } + + ctx.Integration.Ready() + return nil +} + +func (c *Cloudsmith) HandleRequest(ctx core.HTTPRequestContext) {} + +func (c *Cloudsmith) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { + return listCloudsmithResources(resourceType, ctx) +} + +func (c *Cloudsmith) Actions() []core.Action { + return []core.Action{} +} + +func (c *Cloudsmith) HandleAction(ctx core.IntegrationActionContext) error { + return fmt.Errorf("unknown action: %s", ctx.Name) +} diff --git a/pkg/integrations/cloudsmith/example.go b/pkg/integrations/cloudsmith/example.go new file mode 100644 index 000000000..afbc1d485 --- /dev/null +++ b/pkg/integrations/cloudsmith/example.go @@ -0,0 +1,28 @@ +package cloudsmith + +import ( + _ "embed" + "sync" + + "github.com/superplanehq/superplane/pkg/utils" +) + +//go:embed examples/package_synced.json +var exampleDataOnPackageEventBytes []byte + +//go:embed examples/get_package_output.json +var exampleOutputGetPackageBytes []byte + +var exampleDataOnPackageEventOnce sync.Once +var exampleDataOnPackageEvent map[string]any + +var exampleOutputGetPackageOnce sync.Once +var exampleOutputGetPackage map[string]any + +func onPackageEventExampleData() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleDataOnPackageEventOnce, exampleDataOnPackageEventBytes, &exampleDataOnPackageEvent) +} + +func getPackageExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputGetPackageOnce, exampleOutputGetPackageBytes, &exampleOutputGetPackage) +} diff --git a/pkg/integrations/cloudsmith/examples/get_package_output.json b/pkg/integrations/cloudsmith/examples/get_package_output.json new file mode 100644 index 000000000..052ff53b3 --- /dev/null +++ b/pkg/integrations/cloudsmith/examples/get_package_output.json @@ -0,0 +1,13 @@ +{ + "timestamp": "2026-02-03T12:00:00Z", + "type": "cloudsmith.package", + "data": { + "name": "my-library", + "version": "1.2.3", + "format": "python", + "size": 45678, + "checksum_sha256": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1", + "cdn_url": "https://dl.cloudsmith.io/basic/my-org/my-repo/python/simple/my-library/", + "status_str": "Synchronised" + } +} diff --git a/pkg/integrations/cloudsmith/examples/package_synced.json b/pkg/integrations/cloudsmith/examples/package_synced.json new file mode 100644 index 000000000..57359e894 --- /dev/null +++ b/pkg/integrations/cloudsmith/examples/package_synced.json @@ -0,0 +1,22 @@ +{ + "timestamp": "2026-02-03T12:00:00Z", + "type": "cloudsmith.package.event", + "data": { + "event": "package.synced", + "package": { + "name": "my-library", + "version": "1.2.3", + "slug_perm": "Wcpkg1234abcd", + "format": "python", + "cdn_url": "https://dl.cloudsmith.io/basic/my-org/my-repo/python/simple/my-library/", + "size": 45678, + "checksum_sha256": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1", + "status_str": "Synchronised" + }, + "repository": { + "name": "my-repo", + "slug": "my-repo", + "namespace": "my-org" + } + } +} diff --git a/pkg/integrations/cloudsmith/get_package.go b/pkg/integrations/cloudsmith/get_package.go new file mode 100644 index 000000000..98ebbd9c4 --- /dev/null +++ b/pkg/integrations/cloudsmith/get_package.go @@ -0,0 +1,175 @@ +package cloudsmith + +import ( + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type GetPackage struct{} + +type GetPackageConfiguration struct { + Repository string `json:"repository" mapstructure:"repository"` + Identifier string `json:"identifier" mapstructure:"identifier"` +} + +func (c *GetPackage) Name() string { + return "cloudsmith.getPackage" +} + +func (c *GetPackage) Label() string { + return "Get Package" +} + +func (c *GetPackage) Description() string { + return "Fetch details of a Cloudsmith package" +} + +func (c *GetPackage) Documentation() string { + return `The Get Package component retrieves metadata for a Cloudsmith package. + +## Use Cases + +- **Release automation**: Fetch package details for downstream deployments +- **Audit trails**: Resolve package metadata for traceability +- **Insights**: Inspect package sizes, checksums, and status + +## Configuration + +- **Repository**: Cloudsmith repository in the format ` + "`namespace/repo`" + ` +- **Identifier**: Package identifier or slug (for example: ` + "`Wklm1a2b`" + `) +` +} + +func (c *GetPackage) Icon() string { + return "package" +} + +func (c *GetPackage) Color() string { + return "gray" +} + +func (c *GetPackage) ExampleOutput() map[string]any { + return getPackageExampleOutput() +} + +func (c *GetPackage) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *GetPackage) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "repository", + Label: "Repository", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: ResourceTypeRepository, + }, + }, + }, + { + Name: "identifier", + Label: "Identifier", + Type: configuration.FieldTypeString, + Required: true, + Placeholder: "Wklm1a2b", + Description: "Package identifier or slug", + }, + } +} + +func (c *GetPackage) Setup(ctx core.SetupContext) error { + var config GetPackageConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + repository := strings.TrimSpace(config.Repository) + if repository == "" { + return fmt.Errorf("repository is required") + } + + identifier := strings.TrimSpace(config.Identifier) + if identifier == "" { + return fmt.Errorf("identifier is required") + } + + return nil +} + +func (c *GetPackage) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *GetPackage) Execute(ctx core.ExecutionContext) error { + var config GetPackageConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + repository := strings.TrimSpace(config.Repository) + if repository == "" { + return fmt.Errorf("repository is required") + } + + identifier := strings.TrimSpace(config.Identifier) + if identifier == "" { + return fmt.Errorf("identifier is required") + } + + parts := strings.Split(repository, "/") + if len(parts) != 2 { + return fmt.Errorf("repository must be in the format of namespace/repo") + } + + namespace := strings.TrimSpace(parts[0]) + repoSlug := strings.TrimSpace(parts[1]) + + if namespace == "" || repoSlug == "" { + return fmt.Errorf("repository must be in the format of namespace/repo") + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + pkg, err := client.GetPackage(namespace, repoSlug, identifier) + if err != nil { + return fmt.Errorf("failed to fetch package: %w", err) + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "cloudsmith.package", + []any{pkg}, + ) +} + +func (c *GetPackage) Actions() []core.Action { + return []core.Action{} +} + +func (c *GetPackage) HandleAction(ctx core.ActionContext) error { + return nil +} + +func (c *GetPackage) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return http.StatusOK, nil +} + +func (c *GetPackage) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *GetPackage) Cleanup(ctx core.SetupContext) error { + return nil +} diff --git a/pkg/integrations/cloudsmith/get_package_test.go b/pkg/integrations/cloudsmith/get_package_test.go new file mode 100644 index 000000000..4746a849b --- /dev/null +++ b/pkg/integrations/cloudsmith/get_package_test.go @@ -0,0 +1,95 @@ +package cloudsmith + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__GetPackage__Setup(t *testing.T) { + component := &GetPackage{} + + t.Run("invalid configuration -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Integration: &contexts.IntegrationContext{}, + Metadata: &contexts.MetadataContext{}, + Configuration: "invalid", + }) + + require.ErrorContains(t, err, "failed to decode configuration") + }) + + t.Run("missing repository -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Integration: &contexts.IntegrationContext{}, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"identifier": "Wklm1a2b"}, + }) + + require.ErrorContains(t, err, "repository is required") + }) + + t.Run("missing identifier -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Integration: &contexts.IntegrationContext{}, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"repository": "my-org/my-repo"}, + }) + + require.ErrorContains(t, err, "identifier is required") + }) + + t.Run("valid configuration -> no error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + HTTP: &contexts.HTTPContext{}, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{ + "repository": "my-org/my-repo", + "identifier": "Wklm1a2b", + }, + }) + + require.NoError(t, err) + }) +} + +func Test__GetPackage__Execute(t *testing.T) { + component := &GetPackage{} + + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"name":"my-lib","version":"1.0.0","format":"python"}`)), + }, + }, + } + + execState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + + err := component.Execute(core.ExecutionContext{ + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiKey": "test-api-key", + "workspace": "my-org", + }, + }, + HTTP: httpCtx, + ExecutionState: execState, + Configuration: map[string]any{ + "repository": "my-org/my-repo", + "identifier": "Wklm1a2b", + }, + }) + + require.NoError(t, err) + assert.Equal(t, core.DefaultOutputChannel.Name, execState.Channel) + assert.Equal(t, "cloudsmith.package", execState.Type) + require.Len(t, execState.Payloads, 1) +} diff --git a/pkg/integrations/cloudsmith/list_resources.go b/pkg/integrations/cloudsmith/list_resources.go new file mode 100644 index 000000000..ffdb4d319 --- /dev/null +++ b/pkg/integrations/cloudsmith/list_resources.go @@ -0,0 +1,50 @@ +package cloudsmith + +import ( + "fmt" + + "github.com/superplanehq/superplane/pkg/core" +) + +const ( + ResourceTypeRepository = "cloudsmith.repository" +) + +func listCloudsmithResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { + switch resourceType { + case ResourceTypeRepository: + return listCloudsmithRepositories(ctx) + + default: + return []core.IntegrationResource{}, nil + } +} + +func listCloudsmithRepositories(ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { + workspace, err := ctx.Integration.GetConfig("workspace") + if err != nil { + return nil, fmt.Errorf("integration workspace is required: %w", err) + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, err + } + + repositories, err := client.ListRepositories(string(workspace)) + if err != nil { + return nil, fmt.Errorf("failed to list repositories: %w", err) + } + + resources := make([]core.IntegrationResource, 0, len(repositories)) + for _, repository := range repositories { + value := string(workspace) + "/" + repository.Slug + resources = append(resources, core.IntegrationResource{ + Type: ResourceTypeRepository, + Name: value, + ID: value, + }) + } + + return resources, nil +} diff --git a/pkg/integrations/cloudsmith/on_package_event.go b/pkg/integrations/cloudsmith/on_package_event.go new file mode 100644 index 000000000..869abdc56 --- /dev/null +++ b/pkg/integrations/cloudsmith/on_package_event.go @@ -0,0 +1,240 @@ +package cloudsmith + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type OnPackageEvent struct{} + +type OnPackageEventSpec struct { + Repository string `json:"repository" mapstructure:"repository"` + Events []string `json:"events" mapstructure:"events"` +} + +type OnPackageEventMetadata struct { + Repository string `json:"repository"` + WebhookSlug string `json:"webhookSlug"` + WebhookURL string `json:"webhookUrl"` +} + +type PackageEventPayload struct { + Meta struct { + EventID string `json:"event_id"` + } `json:"meta"` + Data map[string]any `json:"data"` +} + +func (p *OnPackageEvent) Name() string { + return "cloudsmith.onPackageEvent" +} + +func (p *OnPackageEvent) Label() string { + return "On Package Event" +} + +func (p *OnPackageEvent) Description() string { + return "Triggers when a package event occurs in a Cloudsmith repository" +} + +func (p *OnPackageEvent) Documentation() string { + return `The On Package Event trigger starts a workflow execution when a package event occurs in a Cloudsmith repository. + +## Use Cases + +- **Build pipelines**: Trigger downstream workflows when a new package is synchronized +- **Release workflows**: Automate promotion or notification on package publish +- **Security automation**: React to quarantined or failed packages + +## Configuration + +- **Repository**: Cloudsmith repository in the format ` + "`namespace/repo`" + ` +- **Events**: Package events to listen for (e.g. ` + "`package.synced`" + `, ` + "`package.deleted`" + `) + +## Webhook Setup + +This trigger creates a webhook in Cloudsmith automatically when the canvas is saved.` +} + +func (p *OnPackageEvent) Icon() string { + return "package" +} + +func (p *OnPackageEvent) Color() string { + return "gray" +} + +func (p *OnPackageEvent) ExampleData() map[string]any { + return onPackageEventExampleData() +} + +func (p *OnPackageEvent) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "repository", + Label: "Repository", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: ResourceTypeRepository, + Multi: false, + }, + }, + }, + { + Name: "events", + Label: "Events", + Type: configuration.FieldTypeMultiSelect, + Required: true, + Default: []string{"package.synced"}, + TypeOptions: &configuration.TypeOptions{ + MultiSelect: &configuration.MultiSelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Package Synced", Value: "package.synced"}, + {Label: "Package Deleted", Value: "package.deleted"}, + {Label: "Package Quarantined", Value: "package.quarantined"}, + {Label: "Package Failed", Value: "package.failed"}, + }, + }, + }, + }, + } +} + +func (p *OnPackageEvent) Setup(ctx core.TriggerContext) error { + metadata := OnPackageEventMetadata{} + if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil { + return fmt.Errorf("failed to decode metadata: %w", err) + } + + spec := OnPackageEventSpec{} + if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + repository := strings.TrimSpace(spec.Repository) + if repository == "" { + return fmt.Errorf("repository is required") + } + + parts := strings.Split(repository, "/") + if len(parts) != 2 { + return fmt.Errorf("repository must be in the format of namespace/repo") + } + + namespace := parts[0] + repoSlug := parts[1] + + if metadata.Repository == repository && metadata.WebhookSlug != "" { + return nil + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + webhookURL, err := ctx.Webhook.Setup() + if err != nil { + return fmt.Errorf("failed to setup webhook: %w", err) + } + + events := spec.Events + if len(events) == 0 { + events = []string{"package.synced"} + } + + slugPerm, err := client.CreateWebhook(namespace, repoSlug, webhookURL, events) + if err != nil { + return fmt.Errorf("failed to create webhook: %w", err) + } + + return ctx.Metadata.Set(OnPackageEventMetadata{ + Repository: repository, + WebhookSlug: slugPerm, + WebhookURL: webhookURL, + }) +} + +func (p *OnPackageEvent) Actions() []core.Action { + return []core.Action{} +} + +func (p *OnPackageEvent) HandleAction(ctx core.TriggerActionContext) (map[string]any, error) { + return nil, nil +} + +func (p *OnPackageEvent) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + spec := OnPackageEventSpec{} + if err := mapstructure.Decode(ctx.Configuration, &spec); err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed to decode configuration: %w", err) + } + + var payload PackageEventPayload + if err := json.Unmarshal(ctx.Body, &payload); err != nil { + return http.StatusBadRequest, fmt.Errorf("error parsing request body: %w", err) + } + + eventID := payload.Meta.EventID + + if len(spec.Events) > 0 { + matched := false + for _, e := range spec.Events { + if e == eventID { + matched = true + break + } + } + + if !matched { + ctx.Logger.Infof("Ignoring event type %s", eventID) + return http.StatusOK, nil + } + } + + // Emit a normalized event: include event type alongside package data so the + // frontend mapper can display it without knowing the raw Cloudsmith envelope. + eventData := map[string]any{ + "event": eventID, + "package": payload.Data, + } + + if err := ctx.Events.Emit("cloudsmith.package.event", eventData); err != nil { + return http.StatusInternalServerError, fmt.Errorf("error emitting event: %w", err) + } + + return http.StatusOK, nil +} + +func (p *OnPackageEvent) Cleanup(ctx core.TriggerContext) error { + metadata := OnPackageEventMetadata{} + if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil { + return fmt.Errorf("failed to decode metadata: %w", err) + } + + if metadata.WebhookSlug == "" { + return nil + } + + parts := strings.Split(metadata.Repository, "/") + if len(parts) != 2 { + return nil + } + + namespace := parts[0] + repoSlug := parts[1] + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + return client.DeleteWebhook(namespace, repoSlug, metadata.WebhookSlug) +} diff --git a/pkg/integrations/cloudsmith/on_package_event_test.go b/pkg/integrations/cloudsmith/on_package_event_test.go new file mode 100644 index 000000000..974af254f --- /dev/null +++ b/pkg/integrations/cloudsmith/on_package_event_test.go @@ -0,0 +1,197 @@ +package cloudsmith + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__OnPackageEvent__Setup(t *testing.T) { + trigger := &OnPackageEvent{} + + t.Run("repository is required", func(t *testing.T) { + err := trigger.Setup(core.TriggerContext{ + Integration: &contexts.IntegrationContext{}, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"repository": ""}, + }) + + require.ErrorContains(t, err, "repository is required") + }) + + t.Run("invalid repository format -> error", func(t *testing.T) { + err := trigger.Setup(core.TriggerContext{ + Integration: &contexts.IntegrationContext{}, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{"repository": "invalid"}, + }) + + require.ErrorContains(t, err, "repository must be in the format of namespace/repo") + }) + + t.Run("valid configuration -> creates webhook and stores metadata", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusCreated, + Body: io.NopCloser(strings.NewReader(`{"slug_perm":"abc123"}`)), + }, + }, + } + + metadata := &contexts.MetadataContext{} + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiKey": "test-api-key", + "workspace": "my-org", + }, + } + + err := trigger.Setup(core.TriggerContext{ + HTTP: httpCtx, + Integration: integrationCtx, + Metadata: metadata, + Webhook: &contexts.NodeWebhookContext{}, + Configuration: map[string]any{ + "repository": "my-org/my-repo", + "events": []string{"package.synced"}, + }, + }) + + require.NoError(t, err) + stored, ok := metadata.Metadata.(OnPackageEventMetadata) + require.True(t, ok) + assert.Equal(t, "my-org/my-repo", stored.Repository) + assert.Equal(t, "abc123", stored.WebhookSlug) + assert.NotEmpty(t, stored.WebhookURL) + + // Verify the HTTP request used the correct "target_url" field (not "webhook_url") + // and includes the required "templates" field per the Cloudsmith API spec + require.Len(t, httpCtx.Requests, 1) + bodyBytes, err := io.ReadAll(httpCtx.Requests[0].Body) + require.NoError(t, err) + var reqBody map[string]any + require.NoError(t, json.Unmarshal(bodyBytes, &reqBody)) + assert.Contains(t, reqBody, "target_url", "request body must use 'target_url' per the Cloudsmith API spec") + assert.NotContains(t, reqBody, "webhook_url", "request body must not use 'webhook_url'") + assert.Contains(t, reqBody, "templates", "request body must include 'templates' field (required by Cloudsmith API)") + templates, ok := reqBody["templates"].([]any) + require.True(t, ok, "templates must be an array") + require.Len(t, templates, 1, "one template per event") + tmpl, ok := templates[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "package.synced", tmpl["event"]) + }) + + t.Run("already configured -> skips setup", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{} + metadata := &contexts.MetadataContext{ + Metadata: OnPackageEventMetadata{ + Repository: "my-org/my-repo", + WebhookSlug: "existing-slug", + WebhookURL: "https://example.com/hook", + }, + } + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiKey": "test-api-key", + "workspace": "my-org", + }, + } + + err := trigger.Setup(core.TriggerContext{ + HTTP: httpCtx, + Integration: integrationCtx, + Metadata: metadata, + Webhook: &contexts.NodeWebhookContext{}, + Configuration: map[string]any{ + "repository": "my-org/my-repo", + "events": []string{"package.synced"}, + }, + }) + + require.NoError(t, err) + // No HTTP requests should have been made + assert.Len(t, httpCtx.Requests, 0) + }) +} + +func Test__OnPackageEvent__HandleWebhook(t *testing.T) { + trigger := &OnPackageEvent{} + + t.Run("invalid JSON -> 400", func(t *testing.T) { + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: []byte(`invalid`), + Events: &contexts.EventContext{}, + Configuration: map[string]any{"repository": "my-org/my-repo", "events": []string{"package.synced"}}, + Metadata: &contexts.MetadataContext{}, + Logger: log.NewEntry(log.New()), + }) + + assert.Equal(t, http.StatusBadRequest, code) + assert.ErrorContains(t, err, "error parsing request body") + }) + + t.Run("event filter mismatch -> ignored", func(t *testing.T) { + body := []byte(`{"meta":{"event_id":"package.deleted"},"data":{}}`) + events := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Events: events, + Logger: log.NewEntry(log.New()), + Metadata: &contexts.MetadataContext{ + Metadata: OnPackageEventMetadata{ + Repository: "my-org/my-repo", + }, + }, + Configuration: map[string]any{ + "repository": "my-org/my-repo", + "events": []string{"package.synced"}, + }, + }) + + assert.Equal(t, http.StatusOK, code) + require.NoError(t, err) + assert.Equal(t, 0, events.Count()) + }) + + t.Run("matching event -> event emitted", func(t *testing.T) { + body := []byte(`{"meta":{"event_id":"package.synced"},"data":{"name":"my-lib","version":"1.0.0","namespace":"my-org","repository":"my-repo"}}`) + events := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Body: body, + Events: events, + Logger: log.NewEntry(log.New()), + Metadata: &contexts.MetadataContext{ + Metadata: OnPackageEventMetadata{ + Repository: "my-org/my-repo", + }, + }, + Configuration: map[string]any{ + "repository": "my-org/my-repo", + "events": []string{"package.synced"}, + }, + }) + + assert.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Equal(t, 1, events.Count()) + assert.Equal(t, "cloudsmith.package.event", events.Payloads[0].Type) + // Verify normalized event data structure + data, ok := events.Payloads[0].Data.(map[string]any) + require.True(t, ok) + assert.Equal(t, "package.synced", data["event"]) + pkg, ok := data["package"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "my-lib", pkg["name"]) + assert.Equal(t, "1.0.0", pkg["version"]) + }) +} diff --git a/pkg/server/server.go b/pkg/server/server.go index f80114af3..f805e3088 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -41,6 +41,7 @@ import ( _ "github.com/superplanehq/superplane/pkg/integrations/circleci" _ "github.com/superplanehq/superplane/pkg/integrations/claude" _ "github.com/superplanehq/superplane/pkg/integrations/cloudflare" + _ "github.com/superplanehq/superplane/pkg/integrations/cloudsmith" _ "github.com/superplanehq/superplane/pkg/integrations/cursor" _ "github.com/superplanehq/superplane/pkg/integrations/dash0" _ "github.com/superplanehq/superplane/pkg/integrations/datadog" diff --git a/web_src/src/assets/icons/integrations/cloudsmith.svg b/web_src/src/assets/icons/integrations/cloudsmith.svg new file mode 100644 index 000000000..25a27b0a3 --- /dev/null +++ b/web_src/src/assets/icons/integrations/cloudsmith.svg @@ -0,0 +1 @@ +Cloudsmith diff --git a/web_src/src/pages/workflowv2/mappers/cloudsmith/get_package.ts b/web_src/src/pages/workflowv2/mappers/cloudsmith/get_package.ts new file mode 100644 index 000000000..6db896f5a --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/cloudsmith/get_package.ts @@ -0,0 +1,98 @@ +import { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../types"; +import { ComponentBaseProps, EventSection } from "@/ui/componentBase"; +import { getBackgroundColorClass, getColorClass } from "@/utils/colors"; +import { getState, getStateMap, getTriggerRenderer } from ".."; +import { formatTimeAgo } from "@/utils/date"; +import { MetadataItem } from "@/ui/metadataList"; +import { PackageInfo } from "./types"; +import { formatBytes, stringOrDash } from "../utils"; +import cloudsmithIcon from "@/assets/icons/integrations/cloudsmith.svg"; + +interface GetPackageConfiguration { + repository?: string; + identifier?: string; +} + +export const getPackageMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null; + const componentName = context.componentDefinition.name || "unknown"; + + return { + title: context.node.name || context.componentDefinition.label || "Unnamed component", + iconSrc: cloudsmithIcon, + iconColor: getColorClass(context.componentDefinition.color), + collapsedBackground: getBackgroundColorClass(context.componentDefinition.color), + collapsed: context.node.isCollapsed, + eventSections: lastExecution ? getPackageEventSections(context.nodes, lastExecution, componentName) : undefined, + includeEmptyState: !lastExecution, + metadata: getPackageMetadataList(context.node), + eventStateMap: getStateMap(componentName), + }; + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const result = outputs?.default?.[0]?.data as PackageInfo | undefined; + + if (!result) { + return {}; + } + + return { + Name: stringOrDash(result.name), + Version: stringOrDash(result.version), + Format: stringOrDash(result.format), + Size: formatBytes(result.size), + Status: stringOrDash(result.status_str), + "CDN URL": stringOrDash(result.cdn_url), + "SHA-256": stringOrDash(result.checksum_sha256), + }; + }, + + subtitle(context: SubtitleContext): string { + if (!context.execution.createdAt) { + return ""; + } + return formatTimeAgo(new Date(context.execution.createdAt)); + }, +}; + +function getPackageMetadataList(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const configuration = node.configuration as GetPackageConfiguration | undefined; + + if (configuration?.repository) { + metadata.push({ icon: "package", label: configuration.repository }); + } + + if (configuration?.identifier) { + metadata.push({ icon: "tag", label: configuration.identifier }); + } + + return metadata; +} + +function getPackageEventSections(nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] { + const rootTriggerNode = nodes.find((n) => n.id === execution.rootEvent?.nodeId); + const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode?.componentName!); + const { title } = rootTriggerRenderer.getTitleAndSubtitle({ event: execution.rootEvent }); + + return [ + { + receivedAt: new Date(execution.createdAt!), + eventTitle: title, + eventSubtitle: formatTimeAgo(new Date(execution.createdAt!)), + eventState: getState(componentName)(execution), + eventId: execution.rootEvent!.id!, + }, + ]; +} diff --git a/web_src/src/pages/workflowv2/mappers/cloudsmith/index.ts b/web_src/src/pages/workflowv2/mappers/cloudsmith/index.ts new file mode 100644 index 000000000..b0993c324 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/cloudsmith/index.ts @@ -0,0 +1,20 @@ +import { ComponentBaseMapper, CustomFieldRenderer, EventStateRegistry, TriggerRenderer } from "../types"; +import { getPackageMapper } from "./get_package"; +import { onPackageEventTriggerRenderer, onPackageEventCustomFieldRenderer } from "./on_package_event"; +import { buildActionStateRegistry } from "../utils"; + +export const componentMappers: Record = { + getPackage: getPackageMapper, +}; + +export const triggerRenderers: Record = { + onPackageEvent: onPackageEventTriggerRenderer, +}; + +export const customFieldRenderers: Record = { + onPackageEvent: onPackageEventCustomFieldRenderer, +}; + +export const eventStateRegistry: Record = { + getPackage: buildActionStateRegistry("retrieved"), +}; diff --git a/web_src/src/pages/workflowv2/mappers/cloudsmith/on_package_event.tsx b/web_src/src/pages/workflowv2/mappers/cloudsmith/on_package_event.tsx new file mode 100644 index 000000000..674383ac1 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/cloudsmith/on_package_event.tsx @@ -0,0 +1,127 @@ +import { getBackgroundColorClass } from "@/utils/colors"; +import { CustomFieldRenderer, NodeInfo, TriggerEventContext, TriggerRenderer, TriggerRendererContext } from "../types"; +import { TriggerProps } from "@/ui/trigger"; +import { formatTimeAgo } from "@/utils/date"; +import { MetadataItem } from "@/ui/metadataList"; +import { OnPackageEventMetadata } from "./types"; +import { stringOrDash } from "../utils"; +import cloudsmithIcon from "@/assets/icons/integrations/cloudsmith.svg"; + +interface OnPackageEventConfiguration { + repository?: string; + events?: string[]; +} + +interface PackageEventData { + event?: string; + package?: { + name?: string; + version?: string; + format?: string; + namespace?: string; + repository?: string; + }; +} + +/** + * Renderer for the "cloudsmith.onPackageEvent" trigger + */ +export const onPackageEventTriggerRenderer: TriggerRenderer = { + getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string } => { + const eventData = context.event?.data as PackageEventData; + const pkgName = eventData?.package?.name; + const version = eventData?.package?.version; + const eventType = eventData?.event; + + const title = pkgName ? `${pkgName}${version ? `@${version}` : ""}` : eventType || "Package event"; + const subtitle = context.event?.createdAt ? formatTimeAgo(new Date(context.event.createdAt)) : ""; + + return { title, subtitle }; + }, + + getRootEventValues: (context: TriggerEventContext): Record => { + const eventData = context.event?.data as PackageEventData; + const pkg = eventData?.package; + + return { + Event: stringOrDash(eventData?.event), + Package: stringOrDash(pkg?.name), + Version: stringOrDash(pkg?.version), + Format: stringOrDash(pkg?.format), + Repository: pkg?.namespace ? `${pkg.namespace}/${pkg.repository}` : stringOrDash(pkg?.repository), + }; + }, + + getTriggerProps: (context: TriggerRendererContext) => { + const { node, definition, lastEvent } = context; + const metadata = node.metadata as OnPackageEventMetadata | undefined; + const configuration = node.configuration as OnPackageEventConfiguration | undefined; + const metadataItems: MetadataItem[] = []; + + if (metadata?.repository) { + metadataItems.push({ + icon: "package", + label: metadata.repository, + }); + } + + if (configuration?.events?.length) { + metadataItems.push({ + icon: "zap", + label: configuration.events.join(", "), + }); + } + + const props: TriggerProps = { + title: node.name || definition.label || "Unnamed trigger", + iconSrc: cloudsmithIcon, + collapsedBackground: getBackgroundColorClass(definition.color), + metadata: metadataItems, + }; + + if (lastEvent) { + const { title, subtitle } = onPackageEventTriggerRenderer.getTitleAndSubtitle({ event: lastEvent }); + props.lastEventData = { + title, + subtitle, + receivedAt: new Date(lastEvent.createdAt!), + state: "triggered", + eventId: lastEvent.id!, + }; + } + + return props; + }, +}; + +export const onPackageEventCustomFieldRenderer: CustomFieldRenderer = { + render: (node: NodeInfo) => { + const metadata = node.metadata as OnPackageEventMetadata | undefined; + const repository = metadata?.repository || "[REPOSITORY]"; + const webhookUrl = metadata?.webhookUrl || "[URL GENERATED ONCE THE CANVAS IS SAVED]"; + + return ( +
+
+
+ Cloudsmith Webhook +
+

+ A webhook for {repository} will be created in Cloudsmith automatically when the canvas + is saved. +

+
+ Webhook URL +
+
+                    {webhookUrl}
+                  
+
+
+
+
+
+
+ ); + }, +}; diff --git a/web_src/src/pages/workflowv2/mappers/cloudsmith/types.ts b/web_src/src/pages/workflowv2/mappers/cloudsmith/types.ts new file mode 100644 index 000000000..3d3f092ae --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/cloudsmith/types.ts @@ -0,0 +1,15 @@ +export interface OnPackageEventMetadata { + repository?: string; + webhookSlug?: string; + webhookUrl?: string; +} + +export interface PackageInfo { + name?: string; + version?: string; + format?: string; + size?: number; + checksum_sha256?: string; + cdn_url?: string; + status_str?: string; +} diff --git a/web_src/src/pages/workflowv2/mappers/index.ts b/web_src/src/pages/workflowv2/mappers/index.ts index da0e44232..40461eb47 100644 --- a/web_src/src/pages/workflowv2/mappers/index.ts +++ b/web_src/src/pages/workflowv2/mappers/index.ts @@ -180,6 +180,12 @@ import { triggerRenderers as statuspageTriggerRenderers, eventStateRegistry as statuspageEventStateRegistry, } from "./statuspage"; +import { + componentMappers as cloudsmithComponentMappers, + customFieldRenderers as cloudsmithCustomFieldRenderers, + triggerRenderers as cloudsmithTriggerRenderers, + eventStateRegistry as cloudsmithEventStateRegistry, +} from "./cloudsmith"; import { componentMappers as dockerhubComponentMappers, customFieldRenderers as dockerhubCustomFieldRenderers, @@ -273,6 +279,7 @@ const appMappers: Record> = { hetzner: hetznerComponentMappers, jfrogArtifactory: jfrogArtifactoryComponentMappers, statuspage: statuspageComponentMappers, + cloudsmith: cloudsmithComponentMappers, dockerhub: dockerhubComponentMappers, honeycomb: honeycombComponentMappers, harness: harnessComponentMappers, @@ -312,6 +319,7 @@ const appTriggerRenderers: Record> = { cursor: cursorTriggerRenderers, jfrogArtifactory: jfrogArtifactoryTriggerRenderers, statuspage: statuspageTriggerRenderers, + cloudsmith: cloudsmithTriggerRenderers, dockerhub: dockerhubTriggerRenderers, honeycomb: honeycombTriggerRenderers, harness: harnessTriggerRenderers, @@ -350,6 +358,7 @@ const appEventStateRegistries: Record cursor: cursorEventStateRegistry, gitlab: gitlabEventStateRegistry, jfrogArtifactory: jfrogArtifactoryEventStateRegistry, + cloudsmith: cloudsmithEventStateRegistry, dockerhub: dockerhubEventStateRegistry, honeycomb: honeycombEventStateRegistry, harness: harnessEventStateRegistry, @@ -381,6 +390,7 @@ const appCustomFieldRenderers: Record> = { + bitbucket: bitbucketIcon, + circleci: circleciIcon, + cloudflare: cloudflareIcon, + cloudsmith: cloudsmithIcon, + dash0: dash0Icon, + datadog: datadogIcon, + daytona: daytonaIcon, + digitalocean: digitaloceanIcon, + discord: discordIcon, + firehydrant: firehydrantIcon, + github: githubIcon, + gitlab: gitlabIcon, + hetzner: hetznerIcon, + jfrogArtifactory: jfrogArtifactoryIcon, + grafana: grafanaIcon, + jira: jiraIcon, + openai: openAiIcon, + "open-ai": openAiIcon, + claude: claudeIcon, + cursor: cursorIcon, + pagerduty: pagerDutyIcon, + rootly: rootlyIcon, + incident: incidentIcon, + launchdarkly: launchdarklyIcon, + semaphore: SemaphoreLogo, + slack: slackIcon, + telegram: telegramIcon, + sendgrid: sendgridIcon, + prometheus: prometheusIcon, + render: renderIcon, + dockerhub: dockerIcon, + harness: harnessIcon, + octopus: octopusIcon, + servicenow: servicenowIcon, + statuspage: statuspageIcon, + aws: { + ec2: awsEc2Icon, + codeArtifact: awsIcon, + cloudwatch: awsCloudwatchIcon, + lambda: awsLambdaIcon, + ecr: awsEcrIcon, + sqs: awsSqsIcon, + route53: awsRoute53Icon, + ecs: awsEcsIcon, + sns: awsSnsIcon, + }, + honeycomb: honeycombIcon, + gcp: gcpIcon, + }; + // Get integration name from first block if available, or match category name const firstBlock = allBlocks[0]; const integrationName = firstBlock?.integrationName || category.name.toLowerCase(); @@ -1128,6 +1227,59 @@ function CategorySection({ const iconSlug = block.type === "blueprint" ? "component" : nameParts[0] === "smtp" ? "mail" : block.icon || "zap"; + // Use SVG icons for application components/triggers (SMTP uses resolveIcon("mail"), same as core) + const appLogoMap: Record> = { + bitbucket: bitbucketIcon, + circleci: circleciIcon, + cloudflare: cloudflareIcon, + cloudsmith: cloudsmithIcon, + dash0: dash0Icon, + daytona: daytonaIcon, + datadog: datadogIcon, + digitalocean: digitaloceanIcon, + discord: discordIcon, + firehydrant: firehydrantIcon, + github: githubIcon, + gitlab: gitlabIcon, + hetzner: hetznerIcon, + jfrogArtifactory: jfrogArtifactoryIcon, + grafana: grafanaIcon, + openai: openAiIcon, + "open-ai": openAiIcon, + claude: claudeIcon, + cursor: cursorIcon, + pagerduty: pagerDutyIcon, + rootly: rootlyIcon, + incident: incidentIcon, + launchdarkly: launchdarklyIcon, + semaphore: SemaphoreLogo, + slack: slackIcon, + telegram: telegramIcon, + sendgrid: sendgridIcon, + prometheus: prometheusIcon, + render: renderIcon, + dockerhub: dockerIcon, + harness: harnessIcon, + octopus: octopusIcon, + servicenow: servicenowIcon, + statuspage: statuspageIcon, + aws: { + codeArtifact: awsCodeArtifactIcon, + codepipeline: awsCodePipelineIcon, + cloudwatch: awsCloudwatchIcon, + ecr: awsEcrIcon, + ec2: awsEc2Icon, + lambda: awsLambdaIcon, + sqs: awsSqsIcon, + route53: awsRoute53Icon, + ecs: awsEcsIcon, + sns: awsSnsIcon, + }, + honeycomb: honeycombIcon, + gcp: gcpIcon, + }; + const appLogo = nameParts[0] ? appLogoMap[nameParts[0]] : undefined; + const appIconSrc = typeof appLogo === "string" ? appLogo : nameParts[1] ? appLogo?.[nameParts[1]] : undefined; const appIconSrc = getHeaderIconSrc(block.name); const IconComponent = resolveIcon(iconSlug); diff --git a/web_src/src/ui/componentSidebar/integrationIcons.tsx b/web_src/src/ui/componentSidebar/integrationIcons.tsx index a4a446f3d..2612a5d09 100644 --- a/web_src/src/ui/componentSidebar/integrationIcons.tsx +++ b/web_src/src/ui/componentSidebar/integrationIcons.tsx @@ -14,6 +14,7 @@ import awsEc2Icon from "@/assets/icons/integrations/aws.ec2.svg"; import awsEcrIcon from "@/assets/icons/integrations/aws.ecr.svg"; import awsCodeArtifactIcon from "@/assets/icons/integrations/aws.codeartifact.svg"; import cloudflareIcon from "@/assets/icons/integrations/cloudflare.svg"; +import cloudsmithIcon from "@/assets/icons/integrations/cloudsmith.svg"; import dash0Icon from "@/assets/icons/integrations/dash0.svg"; import datadogIcon from "@/assets/icons/integrations/datadog.svg"; import daytonaIcon from "@/assets/icons/integrations/daytona.svg"; @@ -57,6 +58,7 @@ export const INTEGRATION_APP_LOGO_MAP: Record = { bitbucket: bitbucketIcon, circleci: circleciIcon, cloudflare: cloudflareIcon, + cloudsmith: cloudsmithIcon, dash0: dash0Icon, datadog: datadogIcon, daytona: daytonaIcon, @@ -99,6 +101,7 @@ export const APP_LOGO_MAP: Record> = { bitbucket: bitbucketIcon, circleci: circleciIcon, cloudflare: cloudflareIcon, + cloudsmith: cloudsmithIcon, dash0: dash0Icon, datadog: datadogIcon, daytona: daytonaIcon, diff --git a/web_src/src/ui/configurationFieldRenderer/IntegrationResourceFieldRenderer.tsx b/web_src/src/ui/configurationFieldRenderer/IntegrationResourceFieldRenderer.tsx index bedf6aea7..712419216 100644 --- a/web_src/src/ui/configurationFieldRenderer/IntegrationResourceFieldRenderer.tsx +++ b/web_src/src/ui/configurationFieldRenderer/IntegrationResourceFieldRenderer.tsx @@ -8,6 +8,7 @@ import { ConfigurationField } from "../../api-client"; import { useIntegrationResources } from "@/hooks/useIntegrations"; import { toTestId } from "@/utils/testID"; import { type RefObject, useEffect, useMemo, useState } from "react"; +import { RefreshCw } from "lucide-react"; interface IntegrationResourceFieldRendererProps { field: ConfigurationField; @@ -107,7 +108,9 @@ export const IntegrationResourceFieldRenderer = ({ const { data: resources, isLoading: isLoadingResources, + isFetching, error: resourcesError, + refetch, } = useIntegrationResources(organizationId ?? "", integrationId ?? "", resourceType ?? "", additionalQueryParameters); // All hooks must be called before any early returns @@ -256,14 +259,43 @@ export const IntegrationResourceFieldRenderer = ({
{tabsInLabelRow ??
{tabsList}
} - {picker} + +
+
{picker}
+ +
+
{expressionInput}
); } - return
{picker}
; + return ( +
+
{picker}
+ +
+ ); } // Multi-select mode @@ -291,17 +323,28 @@ export const IntegrationResourceFieldRenderer = ({ }; return ( -
- - options={multiSelectOptions} - displayValue={(option) => option.label} - placeholder={`Select ${resourceType}...`} - value={selectedOptions} - onChange={handleChange} - showButton={false} +
+
+ + options={multiSelectOptions} + displayValue={(option) => option.label} + placeholder={`Select ${resourceType}...`} + value={selectedOptions} + onChange={handleChange} + showButton={false} + > + {(option) => {option.label}} + +
+
); };