diff --git a/pkg/integrations/sentry/client.go b/pkg/integrations/sentry/client.go new file mode 100644 index 0000000000..d2ff531b7a --- /dev/null +++ b/pkg/integrations/sentry/client.go @@ -0,0 +1,164 @@ +package sentry + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/superplanehq/superplane/pkg/core" +) + +type Client struct { + Token string + BaseURL string + http core.HTTPContext + OrgSlug string +} + +func NewClient(http core.HTTPContext, ctx core.IntegrationContext) (*Client, error) { + authToken, err := ctx.GetConfig("authToken") + if err != nil { + return nil, fmt.Errorf("error finding auth token: %v", err) + } + + orgSlug, err := ctx.GetConfig("orgSlug") + if err != nil { + return nil, fmt.Errorf("error finding organization slug: %v", err) + } + + baseURL, err := ctx.GetConfig("baseUrl") + if err != nil || baseURL == nil { + // Default to sentry.io + baseURL = []byte("https://sentry.io/api/0") + } else { + // Ensure URL has /api/0 suffix + if !bytes.HasSuffix(baseURL, []byte("/api/0")) { + baseURL = []byte(fmt.Sprintf("%s/api/0", string(baseURL))) + } + } + + return &Client{ + Token: string(authToken), + BaseURL: string(baseURL), + OrgSlug: string(orgSlug), + http: http, + }, nil +} + +func (c *Client) execRequest(method, url string, body io.Reader) ([]byte, error) { + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, fmt.Errorf("error building request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.Token)) + + res, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("error executing request: %v", err) + } + defer res.Body.Close() + + responseBody, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("error reading body: %v", err) + } + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, fmt.Errorf("request got %d code: %s", res.StatusCode, string(responseBody)) + } + + return responseBody, nil +} + +// CreateSentryApp creates a new internal Sentry integration (Sentry App) +func (c *Client) CreateSentryApp(name, webhookURL string, events []SentryAppEvent) (*SentryApp, error) { + apiURL := fmt.Sprintf("%s/sentry-apps/", c.BaseURL) + + app := SentryAppCreateRequest{ + Name: name, + IsInternal: true, + Organization: c.OrgSlug, + Scopes: []string{"org:read", "event:read", "event:write", "event:admin"}, + WebhookURL: webhookURL, + Events: events, + } + + body, err := json.Marshal(app) + if err != nil { + return nil, fmt.Errorf("error marshaling request: %v", err) + } + + responseBody, err := c.execRequest(http.MethodPost, apiURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + var sentryApp SentryApp + err = json.Unmarshal(responseBody, &sentryApp) + if err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + return &sentryApp, nil +} + +// DeleteSentryApp deletes a Sentry App by its slug +func (c *Client) DeleteSentryApp(slug string) error { + apiURL := fmt.Sprintf("%s/sentry-apps/%s/", c.BaseURL, slug) + _, err := c.execRequest(http.MethodDelete, apiURL, nil) + return err +} + +// UpdateIssue updates a Sentry issue (org-scoped per Sentry API) +func (c *Client) UpdateIssue(issueID string, update IssueUpdateRequest) (any, error) { + apiURL := fmt.Sprintf("%s/organizations/%s/issues/%s/", c.BaseURL, c.OrgSlug, issueID) + + body, err := json.Marshal(update) + if err != nil { + return nil, fmt.Errorf("error marshaling request: %v", err) + } + + responseBody, err := c.execRequest(http.MethodPut, apiURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + var response map[string]any + err = json.Unmarshal(responseBody, &response) + if err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + return response, nil +} + +// SentryApp represents a Sentry Internal Integration +type SentryApp struct { + Slug string `json:"slug"` + Name string `json:"name"` + ClientSecret string `json:"clientSecret"` + UUID string `json:"uuid"` +} + +type SentryAppCreateRequest struct { + Name string `json:"name"` + IsInternal bool `json:"isInternal"` + Organization string `json:"organization"` + Scopes []string `json:"scopes"` + WebhookURL string `json:"webhookUrl"` + Events []SentryAppEvent `json:"events"` +} + +type SentryAppEvent struct { + Type string `json:"type"` +} + +// IssueUpdateRequest represents the request to update an issue +type IssueUpdateRequest struct { + Status *string `json:"status,omitempty"` + AssignedTo *string `json:"assignedTo,omitempty"` +} diff --git a/pkg/integrations/sentry/common.go b/pkg/integrations/sentry/common.go new file mode 100644 index 0000000000..a485432bef --- /dev/null +++ b/pkg/integrations/sentry/common.go @@ -0,0 +1,8 @@ +package sentry + +type NodeMetadata struct{} + +type SentryAppMetadata struct { + Slug string `json:"slug"` + ClientSecret string `json:"clientSecret"` +} diff --git a/pkg/integrations/sentry/example.go b/pkg/integrations/sentry/example.go new file mode 100644 index 0000000000..02b79fbd84 --- /dev/null +++ b/pkg/integrations/sentry/example.go @@ -0,0 +1,28 @@ +package sentry + +import ( + _ "embed" + "sync" + + "github.com/superplanehq/superplane/pkg/utils" +) + +//go:embed example_output_update_issue.json +var exampleOutputUpdateIssueBytes []byte + +var exampleOutputUpdateIssueOnce sync.Once +var exampleOutputUpdateIssue map[string]any + +//go:embed example_data_on_issue.json +var exampleDataOnIssueBytes []byte + +var exampleDataOnIssueOnce sync.Once +var exampleDataOnIssue map[string]any + +func (c *UpdateIssue) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputUpdateIssueOnce, exampleOutputUpdateIssueBytes, &exampleOutputUpdateIssue) +} + +func (t *OnIssue) ExampleData() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleDataOnIssueOnce, exampleDataOnIssueBytes, &exampleDataOnIssue) +} diff --git a/pkg/integrations/sentry/example_data_on_issue.json b/pkg/integrations/sentry/example_data_on_issue.json new file mode 100644 index 0000000000..4e00702fda --- /dev/null +++ b/pkg/integrations/sentry/example_data_on_issue.json @@ -0,0 +1,24 @@ +{ + "type": "sentry.onIssue", + "data": { + "event": "issue.created", + "issue": { + "id": "1234567890", + "shortId": "MY-PROJECT-1", + "title": "TypeError: Cannot read property 'x' of undefined", + "level": "error", + "status": "unresolved", + "project": { + "id": "1", + "slug": "my-project", + "name": "My Project" + } + }, + "actionUser": { + "id": "1", + "username": "user@example.com", + "email": "user@example.com" + } + }, + "timestamp": "2026-02-16T12:00:00Z" +} diff --git a/pkg/integrations/sentry/example_output_update_issue.json b/pkg/integrations/sentry/example_output_update_issue.json new file mode 100644 index 0000000000..7a22b751bc --- /dev/null +++ b/pkg/integrations/sentry/example_output_update_issue.json @@ -0,0 +1,23 @@ +{ + "type": "sentry.issue", + "data": { + "issue": { + "id": "1234567890", + "shortId": "MY-PROJECT-1", + "title": "TypeError: Cannot read property 'x' of undefined", + "level": "error", + "status": "resolved", + "project": { + "id": "1", + "slug": "my-project", + "name": "My Project" + }, + "assigned": { + "id": "1", + "username": "user@example.com", + "email": "user@example.com" + } + } + }, + "timestamp": "2026-02-16T12:00:00Z" +} diff --git a/pkg/integrations/sentry/on_issue.go b/pkg/integrations/sentry/on_issue.go new file mode 100644 index 0000000000..0e28c4a6b5 --- /dev/null +++ b/pkg/integrations/sentry/on_issue.go @@ -0,0 +1,209 @@ +package sentry + +import ( + "encoding/json" + "fmt" + "net/http" + "slices" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/crypto" +) + +type OnIssue struct{} + +type OnIssueConfiguration struct { + Events []string `json:"events"` +} + +func (t *OnIssue) Name() string { + return "sentry.onIssue" +} + +func (t *OnIssue) Label() string { + return "On Issue" +} + +func (t *OnIssue) Description() string { + return "Listen to issue events from Sentry" +} + +func (t *OnIssue) Documentation() string { + return `The On Issue trigger starts a workflow execution when Sentry issue events occur. + +## Use Cases + +- **Issue automation**: Automate responses to issue events +- **Notification workflows**: Send notifications when issues are created or resolved +- **Integration workflows**: Sync issues with external systems +- **Assignment handling**: Handle issue assignments automatically + +## Configuration + +- **Events**: Select which issue events to listen for (created, resolved, assigned, ignored, unresolved) + +## Event Data + +Each issue event includes: +- **event**: Event type (issue.created, issue.resolved, issue.assigned, issue.ignored, issue.unresolved) +- **issue**: Complete issue information including title, status, assignee, project +- **actionUser**: User who triggered the event (if applicable) + +## Webhook Setup + +This trigger uses a Sentry Internal Integration to receive webhook events. The integration is managed by SuperPlane and will be cleaned up when the trigger is removed.` +} + +func (t *OnIssue) Icon() string { + return "alert-circle" +} + +func (t *OnIssue) Color() string { + return "purple" +} + +func (t *OnIssue) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "events", + Label: "Events", + Type: configuration.FieldTypeMultiSelect, + Required: true, + Default: []string{"issue.created"}, + TypeOptions: &configuration.TypeOptions{ + MultiSelect: &configuration.MultiSelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Created", Value: "issue.created"}, + {Label: "Resolved", Value: "issue.resolved"}, + {Label: "Assigned", Value: "issue.assigned"}, + {Label: "Ignored", Value: "issue.ignored"}, + {Label: "Unresolved", Value: "issue.unresolved"}, + }, + }, + }, + }, + } +} + +func (t *OnIssue) Setup(ctx core.TriggerContext) error { + metadata := NodeMetadata{} + err := mapstructure.Decode(ctx.Metadata.Get(), &metadata) + if err != nil { + return fmt.Errorf("failed to decode metadata: %v", err) + } + + // + // If metadata is already set, skip setup + // + return ctx.Integration.RequestWebhook(WebhookConfiguration{}) +} + +func (t *OnIssue) Actions() []core.Action { + return []core.Action{} +} + +func (t *OnIssue) HandleAction(ctx core.TriggerActionContext) (map[string]any, error) { + return nil, nil +} + +func (t *OnIssue) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + config := OnIssueConfiguration{} + err := mapstructure.Decode(ctx.Configuration, &config) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed to decode configuration: %w", err) + } + + // Verify signature using the client secret from the Sentry App + signature := ctx.Headers.Get("Sentry-Hook-Signature") + if signature == "" { + return http.StatusForbidden, fmt.Errorf("missing signature") + } + + // Get the client secret from integration secrets + secrets, err := ctx.Integration.GetSecrets() + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("error getting secrets: %v", err) + } + + var clientSecret []byte + for _, secret := range secrets { + if secret.Name == "sentryClientSecret" { + clientSecret = secret.Value + break + } + } + + if clientSecret == nil { + return http.StatusForbidden, fmt.Errorf("missing client secret") + } + + // Verify signature + if err := crypto.VerifySignature(clientSecret, ctx.Body, signature); err != nil { + return http.StatusForbidden, fmt.Errorf("invalid signature: %v", err) + } + + // Parse webhook payload + var webhook SentryWebhook + err = json.Unmarshal(ctx.Body, &webhook) + if err != nil { + return http.StatusBadRequest, fmt.Errorf("error parsing request body: %v", err) + } + + // + // Filter events by type - webhook may receive events for all configured issue types + // + if !slices.Contains(config.Events, webhook.Action) { + return http.StatusOK, nil + } + + err = ctx.Events.Emit( + fmt.Sprintf("sentry.%s", webhook.Action), + map[string]any{ + "event": webhook.Action, + "issue": webhook.Data.Issue, + "actionUser": webhook.Data.ActionUser, + }, + ) + + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("error emitting event: %v", err) + } + + return http.StatusOK, nil +} + +// SentryWebhook represents a Sentry webhook payload +type SentryWebhook struct { + Action string `json:"action"` // e.g., "created", "resolved", "assigned", "ignored", "unresolved" + Data SentryWebhookData `json:"data"` +} + +// SentryWebhookData contains the data from the webhook +type SentryWebhookData struct { + ActionUser *map[string]any `json:"actionUser,omitempty"` + Issue *IssueData `json:"issue,omitempty"` +} + +// IssueData represents a Sentry issue from the webhook +type IssueData struct { + ID string `json:"id"` + ShortID string `json:"shortId"` + Title string `json:"title"` + Level string `json:"level"` + Status string `json:"status"` + Assigned map[string]any `json:"assigned,omitempty"` + Project *ProjectRef `json:"project,omitempty"` +} + +// ProjectRef represents a Sentry project reference +type ProjectRef struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` +} + +func (t *OnIssue) Cleanup(ctx core.TriggerContext) error { + return nil +} diff --git a/pkg/integrations/sentry/sentry.go b/pkg/integrations/sentry/sentry.go new file mode 100644 index 0000000000..acc57223c5 --- /dev/null +++ b/pkg/integrations/sentry/sentry.go @@ -0,0 +1,209 @@ +package sentry + +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.RegisterIntegrationWithWebhookHandler("sentry", &Sentry{}, &SentryWebhookHandler{}) +} + +type Sentry struct{} + +const ( + AuthTypeAPIToken = "apiToken" +) + +type Configuration struct { + AuthToken string `json:"authToken"` + OrgSlug string `json:"orgSlug"` + BaseURL string `json:"baseUrl,omitempty"` +} + +type Metadata struct{} + +func (s *Sentry) Name() string { + return "sentry" +} + +func (s *Sentry) Label() string { + return "Sentry" +} + +func (s *Sentry) Icon() string { + return "alert-circle" +} + +func (s *Sentry) Description() string { + return "Manage and react to issues in Sentry" +} + +func (s *Sentry) Instructions() string { + return "" +} + +func (s *Sentry) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "authToken", + Label: "Auth Token", + Type: configuration.FieldTypeString, + Required: true, + Sensitive: true, + Description: "Sentry authentication token with org:admin scope", + }, + { + Name: "orgSlug", + Label: "Organization Slug", + Type: configuration.FieldTypeString, + Required: true, + Description: "Your Sentry organization slug (e.g., my-org)", + }, + { + Name: "baseUrl", + Label: "Base URL", + Type: configuration.FieldTypeString, + Required: false, + Description: "Custom Sentry base URL for self-hosted Sentry (e.g., https://sentry.example.com). Leave blank for sentry.io", + Placeholder: "https://sentry.example.com", + }, + } +} + +func (s *Sentry) Components() []core.Component { + return []core.Component{ + &UpdateIssue{}, + } +} + +func (s *Sentry) Triggers() []core.Trigger { + return []core.Trigger{ + &OnIssue{}, + } +} + +func (s *Sentry) Cleanup(ctx core.IntegrationCleanupContext) error { + // Delete the auto-created Sentry App if it exists + metadata := Metadata{} + err := mapstructure.Decode(ctx.Integration.GetMetadata(), &metadata) + if err != nil { + return fmt.Errorf("failed to decode metadata: %v", err) + } + + // Check if we have Sentry App metadata in secrets + secrets, err := ctx.Integration.GetSecrets() + if err != nil { + return nil + } + + var appSlug, clientSecret string + for _, secret := range secrets { + if secret.Name == "sentryAppSlug" { + appSlug = string(secret.Value) + } else if secret.Name == "sentryClientSecret" { + clientSecret = string(secret.Value) + } + } + + // Only delete if we have both the app slug and client secret + if appSlug != "" && clientSecret != "" { + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("error creating client: %v", err) + } + + err = client.DeleteSentryApp(appSlug) + if err != nil { + // Log error but don't fail cleanup - app may have been deleted manually + fmt.Printf("Warning: failed to delete Sentry app %s: %v\n", appSlug, err) + } + } + + return nil +} + +func (s *Sentry) Sync(ctx core.SyncContext) error { + configuration := Configuration{} + err := mapstructure.Decode(ctx.Configuration, &configuration) + if err != nil { + return fmt.Errorf("failed to decode config: %v", err) + } + + if configuration.AuthToken == "" { + return fmt.Errorf("authToken is required") + } + + if configuration.OrgSlug == "" { + return fmt.Errorf("orgSlug is required") + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("error creating client: %v", err) + } + + // Try to auto-create a Sentry App + appName := "SuperPlane Integration" + webhookURL := ctx.WebhooksBaseURL + + events := []SentryAppEvent{ + {Type: "issue.created"}, + {Type: "issue.resolved"}, + {Type: "issue.assigned"}, + {Type: "issue.ignored"}, + {Type: "issue.unresolved"}, + } + + sentryApp, err := client.CreateSentryApp(appName, webhookURL, events) + if err != nil { + // Auto-create failed - this could be due to insufficient permissions or self-hosted Sentry + // Fall back to browser action for manual setup + ctx.Integration.NewBrowserAction(core.BrowserAction{ + Description: fmt.Sprintf("To set up Sentry webhooks, please create a new Internal Integration manually:\n\n1. Go to Settings > Developer Settings > Internal Integrations in Sentry\n2. Create a new integration named 'SuperPlane'\n3. Set the webhook URL to: %s\n4. Enable issue events: created, resolved, assigned, ignored, unresolved\n5. After creating, provide the Client Secret and App Slug below", webhookURL), + URL: "https://sentry.io/settings/organizations/", + Method: "GET", + FormFields: map[string]string{ + "sentryClientSecret": "Client Secret", + "sentryAppSlug": "App Slug", + }, + }) + return nil + } + + // Auto-create succeeded - store the app details in secrets + err = ctx.Integration.SetSecret("sentryClientSecret", []byte(sentryApp.ClientSecret)) + if err != nil { + return fmt.Errorf("error setting client secret: %v", err) + } + + err = ctx.Integration.SetSecret("sentryAppSlug", []byte(sentryApp.Slug)) + if err != nil { + return fmt.Errorf("error setting app slug: %v", err) + } + + ctx.Integration.SetMetadata(Metadata{}) + ctx.Integration.Ready() + return nil +} + +func (s *Sentry) HandleRequest(ctx core.HTTPRequestContext) { + // no-op +} + +func (s *Sentry) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { + // No resource pickers in Sentry trigger/component; return empty list + return []core.IntegrationResource{}, nil +} + +func (s *Sentry) Actions() []core.Action { + return []core.Action{} +} + +func (s *Sentry) HandleAction(ctx core.IntegrationActionContext) error { + return nil +} diff --git a/pkg/integrations/sentry/update_issue.go b/pkg/integrations/sentry/update_issue.go new file mode 100644 index 0000000000..18158c2569 --- /dev/null +++ b/pkg/integrations/sentry/update_issue.go @@ -0,0 +1,178 @@ +package sentry + +import ( + "errors" + "fmt" + "net/http" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type UpdateIssue struct{} + +type UpdateIssueSpec struct { + IssueID string `json:"issueId"` + Status *string `json:"status"` + AssignedTo *string `json:"assignedTo"` +} + +func (c *UpdateIssue) Name() string { + return "sentry.updateIssue" +} + +func (c *UpdateIssue) Label() string { + return "Update Issue" +} + +func (c *UpdateIssue) Description() string { + return "Update an existing issue in Sentry" +} + +func (c *UpdateIssue) Documentation() string { + return `The Update Issue component modifies an existing Sentry issue. + +## Use Cases + +- **Status updates**: Update issue status (resolved, ignored, unresolved) +- **Assignment**: Assign issues to users + +## Configuration + +- **Issue ID**: The ID of the issue to update (e.g., 1234567890) +- **Status**: Update issue status (resolved, ignored, unresolved) +- **Assigned To**: Assign to a user ID or email + +## Output + +Returns the updated issue object with all current information.` +} + +func (c *UpdateIssue) Icon() string { + return "edit" +} + +func (c *UpdateIssue) Color() string { + return "purple" +} + +func (c *UpdateIssue) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *UpdateIssue) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "issueId", + Label: "Issue ID", + Type: configuration.FieldTypeString, + Required: true, + Description: "The ID of the issue to update (e.g., 1234567890)", + Placeholder: "e.g., 1234567890", + }, + { + Name: "status", + Label: "Status", + Type: configuration.FieldTypeSelect, + Required: false, + Description: "Update the issue status", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "Resolved", Value: "resolved"}, + {Label: "Ignored", Value: "ignored"}, + {Label: "Unresolved", Value: "unresolved"}, + }, + }, + }, + }, + { + Name: "assignedTo", + Label: "Assigned To", + Type: configuration.FieldTypeString, + Required: false, + Description: "Assign to a user ID or email (leave empty to unassign)", + Placeholder: "e.g., user@example.com or user_id", + }, + } +} + +func (c *UpdateIssue) Setup(ctx core.SetupContext) error { + spec := UpdateIssueSpec{} + err := mapstructure.Decode(ctx.Configuration, &spec) + if err != nil { + return fmt.Errorf("error decoding configuration: %v", err) + } + + if spec.IssueID == "" { + return errors.New("issueId is required") + } + + // Validate that at least one field to update is provided + if spec.Status == nil && spec.AssignedTo == nil { + return errors.New("at least one field to update must be provided (status or assignedTo)") + } + + // Store minimal metadata (no external API call needed for setup) + return ctx.Metadata.Set(NodeMetadata{}) +} + +func (c *UpdateIssue) Execute(ctx core.ExecutionContext) error { + spec := UpdateIssueSpec{} + err := mapstructure.Decode(ctx.Configuration, &spec) + if err != nil { + return fmt.Errorf("error decoding configuration: %v", err) + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("error creating client: %v", err) + } + + updateRequest := IssueUpdateRequest{} + + if spec.Status != nil { + updateRequest.Status = spec.Status + } + + if spec.AssignedTo != nil { + updateRequest.AssignedTo = spec.AssignedTo + } + + issue, err := client.UpdateIssue(spec.IssueID, updateRequest) + if err != nil { + return fmt.Errorf("failed to update issue: %v", err) + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "sentry.issue", + []any{issue}, + ) +} + +func (c *UpdateIssue) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *UpdateIssue) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *UpdateIssue) Actions() []core.Action { + return []core.Action{} +} + +func (c *UpdateIssue) HandleAction(ctx core.ActionContext) error { + return nil +} + +func (c *UpdateIssue) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return http.StatusOK, nil +} + +func (c *UpdateIssue) Cleanup(ctx core.SetupContext) error { + return nil +} diff --git a/pkg/integrations/sentry/webhook_handler.go b/pkg/integrations/sentry/webhook_handler.go new file mode 100644 index 0000000000..90e517e4ba --- /dev/null +++ b/pkg/integrations/sentry/webhook_handler.go @@ -0,0 +1,33 @@ +package sentry + +import ( + "github.com/superplanehq/superplane/pkg/core" +) + +type WebhookConfiguration struct{} + +type WebhookMetadata struct{} + +type SentryWebhookHandler struct{} + +func (h *SentryWebhookHandler) CompareConfig(a, b any) (bool, error) { + // All Sentry webhooks for this integration use the same app + // with the same events enabled, so configs are always compatible + return true, nil +} + +func (h *SentryWebhookHandler) Merge(current, requested any) (any, bool, error) { + return current, false, nil +} + +func (h *SentryWebhookHandler) Setup(ctx core.WebhookHandlerContext) (any, error) { + // Webhook is set up during the Sentry App creation in Sync() + // No additional setup needed here + return WebhookMetadata{}, nil +} + +func (h *SentryWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) error { + // Webhook cleanup happens in the main integration Cleanup() + // No additional cleanup needed here + return nil +} diff --git a/pkg/server/server.go b/pkg/server/server.go index f909652ba0..c7c4748860 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -53,6 +53,7 @@ import ( _ "github.com/superplanehq/superplane/pkg/integrations/rootly" _ "github.com/superplanehq/superplane/pkg/integrations/semaphore" _ "github.com/superplanehq/superplane/pkg/integrations/sendgrid" + _ "github.com/superplanehq/superplane/pkg/integrations/sentry" _ "github.com/superplanehq/superplane/pkg/integrations/slack" _ "github.com/superplanehq/superplane/pkg/integrations/smtp" _ "github.com/superplanehq/superplane/pkg/triggers/schedule" diff --git a/test/support/support.go b/test/support/support.go index 2bf66c101d..2ea85bd526 100644 --- a/test/support/support.go +++ b/test/support/support.go @@ -32,6 +32,7 @@ import ( _ "github.com/superplanehq/superplane/pkg/integrations/circleci" _ "github.com/superplanehq/superplane/pkg/integrations/github" _ "github.com/superplanehq/superplane/pkg/integrations/semaphore" + _ "github.com/superplanehq/superplane/pkg/integrations/sentry" _ "github.com/superplanehq/superplane/pkg/triggers/schedule" _ "github.com/superplanehq/superplane/pkg/triggers/start" _ "github.com/superplanehq/superplane/pkg/widgets/annotation" diff --git a/web_src/src/assets/icons/integrations/sentry.svg b/web_src/src/assets/icons/integrations/sentry.svg new file mode 100644 index 0000000000..6071828b43 --- /dev/null +++ b/web_src/src/assets/icons/integrations/sentry.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web_src/src/pages/workflowv2/mappers/index.ts b/web_src/src/pages/workflowv2/mappers/index.ts index 6b682b3bc0..37fb40d9de 100644 --- a/web_src/src/pages/workflowv2/mappers/index.ts +++ b/web_src/src/pages/workflowv2/mappers/index.ts @@ -19,6 +19,11 @@ import { triggerRenderers as semaphoreTriggerRenderers, eventStateRegistry as semaphoreEventStateRegistry, } from "./semaphore/index"; +import { + componentMappers as sentryComponentMappers, + triggerRenderers as sentryTriggerRenderers, + eventStateRegistry as sentryEventStateRegistry, +} from "./sentry/index"; import { componentMappers as githubComponentMappers, triggerRenderers as githubTriggerRenderers, @@ -158,6 +163,7 @@ const componentBaseMappers: Record = { const appMappers: Record> = { cloudflare: cloudflareComponentMappers, semaphore: semaphoreComponentMappers, + sentry: sentryComponentMappers, github: githubComponentMappers, gitlab: gitlabComponentMappers, pagerduty: pagerdutyComponentMappers, @@ -183,6 +189,7 @@ const appMappers: Record> = { const appTriggerRenderers: Record> = { cloudflare: cloudflareTriggerRenderers, semaphore: semaphoreTriggerRenderers, + sentry: sentryTriggerRenderers, github: githubTriggerRenderers, gitlab: gitlabTriggerRenderers, pagerduty: pagerdutyTriggerRenderers, @@ -207,6 +214,7 @@ const appTriggerRenderers: Record> = { const appEventStateRegistries: Record> = { cloudflare: cloudflareEventStateRegistry, semaphore: semaphoreEventStateRegistry, + sentry: sentryEventStateRegistry, github: githubEventStateRegistry, pagerduty: pagerdutyEventStateRegistry, dash0: dash0EventStateRegistry, diff --git a/web_src/src/pages/workflowv2/mappers/sentry/index.ts b/web_src/src/pages/workflowv2/mappers/sentry/index.ts new file mode 100644 index 0000000000..4dd052f601 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/sentry/index.ts @@ -0,0 +1,16 @@ +import { ComponentBaseMapper, EventStateRegistry, TriggerRenderer } from "../types"; +import { onIssueTriggerRenderer } from "./on_issue"; +import { updateIssueMapper } from "./update_issue"; +import { buildActionStateRegistry } from "../utils"; + +export const componentMappers: Record = { + updateIssue: updateIssueMapper, +}; + +export const triggerRenderers: Record = { + onIssue: onIssueTriggerRenderer, +}; + +export const eventStateRegistry: Record = { + updateIssue: buildActionStateRegistry("updated"), +}; diff --git a/web_src/src/pages/workflowv2/mappers/sentry/on_issue.ts b/web_src/src/pages/workflowv2/mappers/sentry/on_issue.ts new file mode 100644 index 0000000000..7d761049db --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/sentry/on_issue.ts @@ -0,0 +1,88 @@ +import { getBackgroundColorClass } from "@/utils/colors"; +import { formatTimeAgo } from "@/utils/date"; +import { TriggerEventContext, TriggerRenderer, TriggerRendererContext } from "../types"; +import { TriggerProps } from "@/ui/trigger"; +import sentryIcon from "@/assets/icons/integrations/sentry.svg"; +import { ActionUser, Issue, OnIssueEventData } from "./types"; + +interface OnIssueEventDataExtended extends OnIssueEventData { + event?: string; + issue?: Issue; + actionUser?: ActionUser; +} + +/** + * Renderer for "sentry.onIssue" trigger type + */ +export const onIssueTriggerRenderer: TriggerRenderer = { + getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string } => { + const eventData = context.event?.data?.data as OnIssueEventDataExtended; + const issue = eventData?.issue; + const contentParts = [issue?.level, issue?.status].filter(Boolean).join(" · "); + const subtitle = buildSubtitle(contentParts, context.event?.createdAt); + + return { + title: `${issue?.shortId || ""} - ${issue?.title || ""}`, + subtitle, + }; + }, + + getRootEventValues: (context: TriggerEventContext): Record => { + const eventData = context.event?.data?.data as OnIssueEventDataExtended; + const issue = eventData?.issue; + return { + Issue: issue?.shortId || "", + Title: issue?.title || "", + Status: issue?.status || "", + Level: issue?.level || "", + Project: issue?.project?.name || "", + ID: issue?.id || "", + }; + }, + + getTriggerProps: (context: TriggerRendererContext) => { + const { node, definition, lastEvent } = context; + const configuration = node.configuration as any; + const metadataItems = []; + + if (configuration.events) { + metadataItems.push({ + icon: "funnel", + label: `Events: ${configuration.events.join(", ")}`, + }); + } + + const props: TriggerProps = { + title: node.name || definition.label || "Unnamed trigger", + iconSrc: sentryIcon, + collapsedBackground: getBackgroundColorClass(definition.color), + metadata: metadataItems, + }; + + if (lastEvent) { + const eventData = lastEvent.data as OnIssueEventDataExtended; + const issue = eventData?.issue; + const contentParts = [issue?.level, issue?.status].filter(Boolean).join(" · "); + const subtitle = buildSubtitle(contentParts, lastEvent.createdAt); + + props.lastEventData = { + title: `${issue?.shortId || ""} - ${issue?.title || ""}`, + subtitle, + receivedAt: new Date(lastEvent.createdAt), + state: "triggered", + eventId: lastEvent.id, + }; + } + + return props; + }, +}; + +function buildSubtitle(content: string, createdAt?: string): string { + const timeAgo = createdAt ? formatTimeAgo(new Date(createdAt)) : ""; + if (content && timeAgo) { + return `${content} · ${timeAgo}`; + } + + return content || timeAgo; +} diff --git a/web_src/src/pages/workflowv2/mappers/sentry/types.ts b/web_src/src/pages/workflowv2/mappers/sentry/types.ts new file mode 100644 index 0000000000..a9e6a8afa5 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/sentry/types.ts @@ -0,0 +1,37 @@ +export interface BaseNodeMetadata {} + +export interface Issue { + id?: string; + shortId?: string; + title?: string; + level?: string; + status?: string; + project?: ProjectRef; + assigned?: { + id?: string; + username?: string; + email?: string; + }; +} + +export interface ProjectRef { + id?: string; + slug?: string; + name?: string; +} + +export interface ActionUser { + id?: string; + username?: string; + email?: string; +} + +export interface OnIssueEventData { + event?: string; + issue?: Issue; + actionUser?: ActionUser; +} + +export interface UpdateIssueResponse { + issue?: Issue; +} diff --git a/web_src/src/pages/workflowv2/mappers/sentry/update_issue.ts b/web_src/src/pages/workflowv2/mappers/sentry/update_issue.ts new file mode 100644 index 0000000000..e41f7cfdfb --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/sentry/update_issue.ts @@ -0,0 +1,95 @@ +import { ComponentBaseProps, EventSection } from "@/ui/componentBase"; +import { getBackgroundColorClass } from "@/utils/colors"; +import { getState, getStateMap } from ".."; +import { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + SubtitleContext, +} from "../types"; +import { MetadataItem } from "@/ui/metadataList"; +import sentryIcon from "@/assets/icons/integrations/sentry.svg"; +import { formatTimeAgo } from "@/utils/date"; + +export const updateIssueMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null; + const componentName = context.componentDefinition.name ?? "sentry"; + + return { + iconSrc: sentryIcon, + collapsedBackground: getBackgroundColorClass(context.componentDefinition.color), + collapsed: context.node.isCollapsed, + title: + context.node.name || + context.componentDefinition.label || + context.componentDefinition.name || + "Unnamed component", + eventSections: lastExecution ? baseEventSections(context.nodes, lastExecution, componentName) : undefined, + metadata: metadataList(context.node), + includeEmptyState: !lastExecution, + eventStateMap: getStateMap(componentName), + }; + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const data = context.execution.outputs?.["data"] as any; + const issue = data?.["issue"] as any; + const details: Record = {}; + + if (issue) { + details["Issue"] = issue.shortId || issue.id; + details["Title"] = issue.title; + details["Status"] = issue.status; + details["Level"] = issue.level; + if (issue.project) { + details["Project"] = issue.project.name; + } + } + + return details; + }, + + subtitle(context: SubtitleContext): string { + if (!context.execution.createdAt) return ""; + return formatTimeAgo(new Date(context.execution.createdAt)); + }, +}; + +function metadataList(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const configuration = node.configuration as any; + + if (configuration.issueId) { + metadata.push({ icon: "alert-circle", label: `Issue: ${configuration.issueId}` }); + } + + // Show which fields are being updated + const updates: string[] = []; + if (configuration.status) { + updates.push(`Status: ${configuration.status}`); + } + if (configuration.assignedTo) { + updates.push("Assigned"); + } + + if (updates.length > 0) { + metadata.push({ icon: "funnel", label: `Updating: ${updates.join(", ")}` }); + } + + return metadata; +} + +function baseEventSections(_nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] { + return [ + { + receivedAt: new Date(execution.createdAt!), + eventTitle: execution.rootEvent?.data?.issue?.title || "Issue Event", + eventSubtitle: formatTimeAgo(new Date(execution.createdAt!)), + eventState: getState(componentName)(execution), + eventId: execution.rootEvent!.id!, + }, + ]; +} diff --git a/web_src/src/ui/BuildingBlocksSidebar/index.tsx b/web_src/src/ui/BuildingBlocksSidebar/index.tsx index f7254cd022..0d2bb97d6c 100644 --- a/web_src/src/ui/BuildingBlocksSidebar/index.tsx +++ b/web_src/src/ui/BuildingBlocksSidebar/index.tsx @@ -38,6 +38,7 @@ import SemaphoreLogo from "@/assets/semaphore-logo-sign-black.svg"; import sendgridIcon from "@/assets/icons/integrations/sendgrid.svg"; import prometheusIcon from "@/assets/icons/integrations/prometheus.svg"; import renderIcon from "@/assets/icons/integrations/render.svg"; +import sentryIcon from "@/assets/icons/integrations/sentry.svg"; import dockerIcon from "@/assets/icons/integrations/docker.svg"; import hetznerIcon from "@/assets/icons/integrations/hetzner.svg"; @@ -420,6 +421,7 @@ function CategorySection({ sendgrid: sendgridIcon, prometheus: prometheusIcon, render: renderIcon, + sentry: sentryIcon, dockerhub: dockerIcon, aws: { codeArtifact: awsIcon, @@ -499,6 +501,7 @@ function CategorySection({ sendgrid: sendgridIcon, prometheus: prometheusIcon, render: renderIcon, + sentry: sentryIcon, dockerhub: dockerIcon, aws: { codeArtifact: awsCodeArtifactIcon, diff --git a/web_src/src/ui/componentSidebar/integrationIcons.tsx b/web_src/src/ui/componentSidebar/integrationIcons.tsx index 23f8305436..63813e4066 100644 --- a/web_src/src/ui/componentSidebar/integrationIcons.tsx +++ b/web_src/src/ui/componentSidebar/integrationIcons.tsx @@ -24,6 +24,7 @@ import SemaphoreLogo from "@/assets/semaphore-logo-sign-black.svg"; import sendgridIcon from "@/assets/icons/integrations/sendgrid.svg"; import prometheusIcon from "@/assets/icons/integrations/prometheus.svg"; import renderIcon from "@/assets/icons/integrations/render.svg"; +import sentryIcon from "@/assets/icons/integrations/sentry.svg"; import dockerIcon from "@/assets/icons/integrations/docker.svg"; import hetznerIcon from "@/assets/icons/integrations/hetzner.svg"; @@ -52,6 +53,7 @@ export const INTEGRATION_APP_LOGO_MAP: Record = { sendgrid: sendgridIcon, prometheus: prometheusIcon, render: renderIcon, + sentry: sentryIcon, dockerhub: dockerIcon, }; @@ -78,6 +80,7 @@ export const APP_LOGO_MAP: Record> = { sendgrid: sendgridIcon, prometheus: prometheusIcon, render: renderIcon, + sentry: sentryIcon, dockerhub: dockerIcon, aws: { cloudwatch: awsCloudwatchIcon,