diff --git a/docs/components/Buildkite.mdx b/docs/components/Buildkite.mdx new file mode 100644 index 0000000000..aa9cf1a3c3 --- /dev/null +++ b/docs/components/Buildkite.mdx @@ -0,0 +1,150 @@ +--- +title: "Buildkite" +--- + +Trigger and react to your Buildkite builds + +## Triggers + + + + + +import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + +## Actions + + + + + +## Instructions + +To create new Buildkite API key, open [Personal Settings > API Access Tokens](https://buildkite.com/user/api-access-tokens/new). + + + +## On Build Finished + +The On Build Finished trigger starts a workflow execution when a Buildkite build completes. + +### Use Cases + +- **CI/CD pipeline chaining**: Trigger downstream workflows when builds complete +- **Build monitoring**: Monitor build results and trigger alerts or notifications +- **Deployment orchestration**: Start deployment workflows only after successful builds +- **Build result processing**: Process build artifacts or results based on build outcome + +### Configuration + +- **Pipeline**: Select the Buildkite pipeline to monitor +- **Branch** (optional): Filter to specific branch (exact match) + +### Event Data + +Each build finished event includes: +- **build**: Build information including ID, state, result, commit, branch +- **pipeline**: Pipeline information including ID and name +- **organization**: Organization information +- **sender**: User who triggered the build + +### Webhook Setup + +This trigger requires setting up a webhook in Buildkite to receive build events: + +1. When you configure this trigger, SuperPlane generates a unique webhook URL and token +2. A browser action will guide you to the Buildkite webhook settings page +3. In Buildkite, create a new webhook with: + - **Webhook URL**: The URL provided by SuperPlane + - **Webhook Token**: The token provided by SuperPlane + - **Events**: Select "build.finished" + - **Pipelines**: Select the specific pipeline(s) you want to monitor + +### Event Processing + +SuperPlane automatically: +1. Receives webhook events at the trigger-specific webhook URL +2. Authenticates requests using the webhook token +3. Filters events by pipeline and branch (if configured) +4. Emits buildkite.build.finished events to start workflow executions + +### Example Data + +```json +{ + "build": { + "blocked": false, + "branch": "main", + "commit": "a1b2c3d4e5f6789012345678901234567890abcd", + "id": "12345678-1234-1234-1234-123456789012", + "message": "Fix: Update dependencies", + "number": 123, + "state": "passed", + "web_url": "https://buildkite.com/example-org/example-pipeline/builds/123" + }, + "event": "build.finished", + "organization": { + "id": "example-org", + "name": "Example Organization" + }, + "pipeline": { + "id": "example-pipeline", + "name": "Example Pipeline" + }, + "sender": { + "email": "john@example.com", + "id": "user-123", + "name": "John Doe" + } +} +``` + + + +## Trigger Build + +Trigger a Buildkite build and wait for completion using polling mechanism. + +### How It Works + +1. Triggers a build via Buildkite API +2. Monitors build status via polling +3. Emits to success/failure channels when build finishes + +### Configuration + +- **Pipeline**: Select pipeline to trigger +- **Branch**: Git branch to build +- **Commit**: Git commit SHA (optional, defaults to HEAD) +- **Message**: Optional build message +- **Environment Variables**: Optional env vars for the build +- **Metadata**: Optional metadata for the build + +### Output Channels + +- **Passed**: Build completed successfully +- **Failed**: Build failed, was cancelled, or was blocked + +### Example Output + +```json +{ + "build": { + "blocked": false, + "branch": "main", + "commit": "a1b2c3d4e5f678901234567890abcd", + "id": "12345678-1234-1234-123456789012", + "message": "Triggered by SuperPlane", + "number": 123, + "state": "passed", + "web_url": "https://buildkite.com/example-org/example-pipeline/builds/123" + }, + "organization": { + "id": "example-org" + }, + "pipeline": { + "id": "example-pipeline" + } +} +``` + diff --git a/pkg/integrations/buildkite/buildkite.go b/pkg/integrations/buildkite/buildkite.go new file mode 100644 index 0000000000..d9ef620deb --- /dev/null +++ b/pkg/integrations/buildkite/buildkite.go @@ -0,0 +1,212 @@ +// Package buildkite implements the Buildkite integration for SuperPlane. +package buildkite + +import ( + "fmt" + "net/http" + "regexp" + "strings" + + "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("buildkite", &Buildkite{}) +} + +type Buildkite struct{} + +type Configuration struct { + Organization string `json:"organization"` + APIToken string `json:"apiToken"` +} + +type Metadata struct { + Organizations []string `json:"organizations"` + SetupComplete bool `json:"setupComplete"` + OrgSlug string `json:"orgSlug"` +} + +func extractOrgSlug(orgInput string) (string, error) { + if orgInput == "" { + return "", fmt.Errorf("organization input is empty") + } + + urlPattern := regexp.MustCompile(`(?:https?://)?(?:www\.)?buildkite\.com/(?:organizations/)?([^/]+)`) + if matches := urlPattern.FindStringSubmatch(strings.TrimSpace(orgInput)); len(matches) > 1 { + return matches[1], nil + } + + // Just the slug (validate it looks like a valid org slug) + slugPattern := regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*[a-zA-Z0-9]$`) + if slugPattern.MatchString(strings.TrimSpace(orgInput)) { + return strings.TrimSpace(orgInput), nil + } + + return "", fmt.Errorf("invalid organization format: %s. Expected format: https://buildkite.com/my-org or just 'my-org'", orgInput) +} + +func (b *Buildkite) createTokenSetupAction(ctx core.SyncContext) { + ctx.Integration.NewBrowserAction(core.BrowserAction{ + Description: "Generate API token for triggering builds. Required permissions: `read_organizations`, `read_user`, `read_pipelines`, `read_builds`, `write_builds`.", + URL: "https://buildkite.com/user/api-access-tokens", + Method: "GET", + }) +} + +func (b *Buildkite) Name() string { + return "buildkite" +} + +func (b *Buildkite) Label() string { + return "Buildkite" +} + +func (b *Buildkite) Icon() string { + return "workflow" +} + +func (b *Buildkite) Description() string { + return "Trigger and react to your Buildkite builds" +} + +func (b *Buildkite) Instructions() string { + return "To create new Buildkite API key, open [Personal Settings > API Access Tokens](https://buildkite.com/user/api-access-tokens/new)." +} + +func (b *Buildkite) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "organization", + Label: "Organization URL", + Type: configuration.FieldTypeString, + Description: "Buildkite organization URL (e.g. https://buildkite.com/my-org or just my-org)", + Placeholder: "e.g. https://buildkite.com/my-org or my-org", + Required: true, + }, + { + Name: "apiToken", + Label: "API Token", + Type: configuration.FieldTypeString, + Sensitive: true, + Description: "Buildkite API token with permissions: read_organizations, read_user, read_pipelines, read_builds, write_builds", + Placeholder: "e.g. bkua_...", + Required: true, + }, + } +} + +func (b *Buildkite) Cleanup(ctx core.IntegrationCleanupContext) error { + return nil +} + +func (b *Buildkite) Sync(ctx core.SyncContext) error { + config := Configuration{} + err := mapstructure.Decode(ctx.Configuration, &config) + if err != nil { + return fmt.Errorf("Failed to decode configuration: %v", err) + } + + if config.Organization == "" { + return fmt.Errorf("Organization is required") + } + + orgSlug, err := extractOrgSlug(config.Organization) + if err != nil { + return fmt.Errorf("Invalid organization format: %v", err) + } + + // Prompt user to create API token + if config.APIToken == "" { + b.createTokenSetupAction(ctx) + return nil + } + + // Validate the API token + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create client: %v", err) + } + _, err = client.ValidateToken() + if err != nil { + return fmt.Errorf("Invalid API token: %v", err) + } + + // Update metadata to track setup completion + metadata := Metadata{} + if err := mapstructure.Decode(ctx.Integration.GetMetadata(), &metadata); err != nil { + metadata = Metadata{} + } + + metadata.OrgSlug = orgSlug + metadata.SetupComplete = true + ctx.Integration.SetMetadata(metadata) + + ctx.Integration.Ready() + return nil +} + +func (b *Buildkite) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, fmt.Errorf("error creating client: %v", err) + } + + switch resourceType { + + case "pipeline": + orgConfig, err := ctx.Integration.GetConfig("organization") + if err != nil { + return nil, fmt.Errorf("failed to get organization from integration config: %w", err) + } + orgSlug, err := extractOrgSlug(string(orgConfig)) + if err != nil { + return nil, fmt.Errorf("failed to extract organization slug: %w", err) + } + + pipelines, err := client.ListPipelines(orgSlug) + if err != nil { + return nil, fmt.Errorf("error listing pipelines: %v", err) + } + + resources := make([]core.IntegrationResource, len(pipelines)) + for i, pipeline := range pipelines { + resources[i] = core.IntegrationResource{ + Type: "pipeline", + ID: pipeline.Slug, + Name: pipeline.Name, + } + } + return resources, nil + + default: + return nil, fmt.Errorf("unsupported resource type: %s", resourceType) + } +} + +func (b *Buildkite) Actions() []core.Action { + return []core.Action{} +} + +func (b *Buildkite) HandleAction(ctx core.IntegrationActionContext) error { + return nil +} + +func (b *Buildkite) HandleRequest(ctx core.HTTPRequestContext) { + ctx.Response.WriteHeader(http.StatusNotFound) +} + +func (b *Buildkite) Components() []core.Component { + return []core.Component{ + &TriggerBuild{}, + } +} + +func (b *Buildkite) Triggers() []core.Trigger { + return []core.Trigger{ + &OnBuildFinished{}, + } +} diff --git a/pkg/integrations/buildkite/client.go b/pkg/integrations/buildkite/client.go new file mode 100644 index 0000000000..3eaa0f92e5 --- /dev/null +++ b/pkg/integrations/buildkite/client.go @@ -0,0 +1,174 @@ +package buildkite + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/superplanehq/superplane/pkg/core" +) + +const BaseURL = "https://api.buildkite.com/v2" + +type Client struct { + http core.HTTPContext + apiToken string +} + +func NewClient(httpCtx core.HTTPContext, ctx core.IntegrationContext) (*Client, error) { + if ctx == nil { + return nil, fmt.Errorf("no integration context") + } + + apiToken, err := ctx.GetConfig("apiToken") + if err != nil { + return nil, err + } + + return &Client{ + http: httpCtx, + apiToken: string(apiToken), + }, nil +} + +func (c *Client) makeRequest(method, endpoint string, body any) (*http.Response, error) { + var bodyReader io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + bodyReader = bytes.NewReader(jsonBody) + } + + req, err := http.NewRequest(method, BaseURL+endpoint, bodyReader) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+c.apiToken) + req.Header.Set("Content-Type", "application/json") + + return c.http.Do(req) +} + +type Pipeline struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + WebURL string `json:"web_url"` +} + +func (c *Client) ListPipelines(orgSlug string) ([]Pipeline, error) { + resp, err := c.makeRequest("GET", fmt.Sprintf("/organizations/%s/pipelines", orgSlug), nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API request failed with status %d", resp.StatusCode) + } + + var pipelines []Pipeline + err = json.NewDecoder(resp.Body).Decode(&pipelines) + if err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return pipelines, nil +} + +type CreateBuildRequest struct { + Commit string `json:"commit"` + Branch string `json:"branch"` + Message string `json:"message,omitempty"` + Env map[string]string `json:"env,omitempty"` + Metadata map[string]string `json:"meta_data,omitempty"` +} + +type Build struct { + ID string `json:"id"` + Number int `json:"number"` + State string `json:"state"` + WebURL string `json:"web_url"` + Commit string `json:"commit"` + Branch string `json:"branch"` + Message string `json:"message"` + Blocked bool `json:"blocked"` + StartedAt string `json:"started_at"` + FinishedAt string `json:"finished_at"` +} + +func (c *Client) CreateBuild(orgSlug, pipelineSlug string, req CreateBuildRequest) (*Build, error) { + resp, err := c.makeRequest("POST", fmt.Sprintf("/organizations/%s/pipelines/%s/builds", orgSlug, pipelineSlug), req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("API request failed with status %d", resp.StatusCode) + } + + var build Build + err = json.NewDecoder(resp.Body).Decode(&build) + if err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &build, nil +} + +func (c *Client) GetBuild(orgSlug, pipelineSlug string, buildNumber int) (*Build, error) { + resp, err := c.makeRequest("GET", fmt.Sprintf("/organizations/%s/pipelines/%s/builds/%d", orgSlug, pipelineSlug, buildNumber), nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API request failed with status %d", resp.StatusCode) + } + + var build Build + err = json.NewDecoder(resp.Body).Decode(&build) + if err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &build, nil +} + +type AccessToken struct { + UUID string `json:"uuid"` + Scopes []string `json:"scopes"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` + User struct { + Email string `json:"email"` + Name string `json:"name"` + } `json:"user"` +} + +func (c *Client) ValidateToken() (*AccessToken, error) { + resp, err := c.makeRequest("GET", "/access-token", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API token validation failed with status %d", resp.StatusCode) + } + + var token AccessToken + err = json.NewDecoder(resp.Body).Decode(&token) + if err != nil { + return nil, fmt.Errorf("failed to decode access token response: %w", err) + } + + return &token, nil +} diff --git a/pkg/integrations/buildkite/on_build_finished.go b/pkg/integrations/buildkite/on_build_finished.go new file mode 100644 index 0000000000..c8727ac46a --- /dev/null +++ b/pkg/integrations/buildkite/on_build_finished.go @@ -0,0 +1,279 @@ +package buildkite + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type OnBuildFinished struct{} + +type OnBuildFinishedMetadata struct { + Pipeline string `json:"pipeline"` + Branch string `json:"branch,omitempty"` + WebhookURL string `json:"webhookUrl"` + WebhookToken string `json:"webhookToken"` + OrgSlug string `json:"orgSlug"` +} + +type OnBuildFinishedConfiguration struct { + Pipeline string `json:"pipeline"` + Branch string `json:"branch,omitempty"` +} + +func (t *OnBuildFinished) Name() string { + return "buildkite.onBuildFinished" +} + +func (t *OnBuildFinished) Label() string { + return "On Build Finished" +} + +func (t *OnBuildFinished) Description() string { + return "Listen to Buildkite build completion events" +} + +func (t *OnBuildFinished) Documentation() string { + return `The On Build Finished trigger starts a workflow execution when a Buildkite build completes. + +## Use Cases + +- **CI/CD pipeline chaining**: Trigger downstream workflows when builds complete +- **Build monitoring**: Monitor build results and trigger alerts or notifications +- **Deployment orchestration**: Start deployment workflows only after successful builds +- **Build result processing**: Process build artifacts or results based on build outcome + +## Configuration + +- **Pipeline**: Select the Buildkite pipeline to monitor +- **Branch** (optional): Filter to specific branch (exact match) + +## Event Data + +Each build finished event includes: +- **build**: Build information including ID, state, result, commit, branch +- **pipeline**: Pipeline information including ID and name +- **organization**: Organization information +- **sender**: User who triggered the build + +## Webhook Setup + +This trigger requires setting up a webhook in Buildkite to receive build events: + +1. When you configure this trigger, SuperPlane generates a unique webhook URL and token +2. A browser action will guide you to the Buildkite webhook settings page +3. In Buildkite, create a new webhook with: + - **Webhook URL**: The URL provided by SuperPlane + - **Webhook Token**: The token provided by SuperPlane + - **Events**: Select "build.finished" + - **Pipelines**: Select the specific pipeline(s) you want to monitor + +## Event Processing + +SuperPlane automatically: +1. Receives webhook events at the trigger-specific webhook URL +2. Authenticates requests using the webhook token +3. Filters events by pipeline and branch (if configured) +4. Emits buildkite.build.finished events to start workflow executions` +} + +func (t *OnBuildFinished) Icon() string { + return "workflow" +} + +func (t *OnBuildFinished) Color() string { + return "gray" +} + +func (t *OnBuildFinished) ExampleData() map[string]any { + return map[string]any{ + "event": "build.finished", + "build": map[string]any{ + "id": "12345678-1234-1234-1234-123456789012", + "number": 123, + "state": "passed", + "web_url": "https://buildkite.com/example-org/example-pipeline/builds/123", + "commit": "a1b2c3d4e5f6789012345678901234567890abcd", + "branch": "main", + "message": "Fix: Update dependencies", + "blocked": false, + }, + "pipeline": map[string]any{ + "id": "example-pipeline", + "name": "Example Pipeline", + }, + "organization": map[string]any{ + "id": "example-org", + "name": "Example Organization", + }, + "sender": map[string]any{ + "id": "user-123", + "name": "John Doe", + "email": "john@example.com", + }, + } +} + +func (t *OnBuildFinished) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "pipeline", + Label: "Pipeline", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "pipeline", + }, + }, + }, + { + Name: "branch", + Label: "Branch", + Type: configuration.FieldTypeString, + Description: "Optional: Filter to specific branch (exact match)", + Placeholder: "e.g. main, develop", + }, + } +} + +func (t *OnBuildFinished) Setup(ctx core.TriggerContext) error { + metadata := OnBuildFinishedMetadata{} + if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil { + return fmt.Errorf("failed to decode metadata: %w", err) + } + + config := OnBuildFinishedConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + if config.Pipeline == "" { + return fmt.Errorf("pipeline is required") + } + + // If webhook is already set up for this pipeline, nothing to do + if metadata.Pipeline == config.Pipeline && + metadata.Branch == config.Branch && + metadata.WebhookURL != "" && + metadata.WebhookToken != "" { + return nil + } + + // Get orgSlug from integration config + orgConfig, err := ctx.Integration.GetConfig("organization") + if err != nil { + return fmt.Errorf("failed to get organization from integration config: %w", err) + } + orgSlug, err := extractOrgSlug(string(orgConfig)) + if err != nil { + return fmt.Errorf("failed to extract organization slug: %w", err) + } + + var webhookSecret []byte + webhookURL := metadata.WebhookURL + + if webhookURL == "" { + webhookURL, err = ctx.Webhook.Setup() + if err != nil { + return fmt.Errorf("failed to setup webhook: %w", err) + } + webhookSecret, err = ctx.Webhook.GetSecret() + if err != nil { + return fmt.Errorf("failed to get webhook secret: %w", err) + } + } else { + webhookSecret = []byte(metadata.WebhookToken) + } + + return ctx.Metadata.Set(OnBuildFinishedMetadata{ + Pipeline: config.Pipeline, + Branch: config.Branch, + WebhookURL: webhookURL, + WebhookToken: string(webhookSecret), + OrgSlug: orgSlug, + }) +} + +func (t *OnBuildFinished) Actions() []core.Action { + return []core.Action{} +} + +func (t *OnBuildFinished) HandleAction(ctx core.TriggerActionContext) (map[string]any, error) { + return nil, nil +} + +func (t *OnBuildFinished) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + config := OnBuildFinishedConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed to decode configuration: %w", err) + } + + metadata := OnBuildFinishedMetadata{} + if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed to decode metadata: %w", err) + } + + // Verify webhook signature/token + if err := VerifyWebhook(ctx.Headers, ctx.Body, []byte(metadata.WebhookToken)); err != nil { + ctx.Logger.WithError(err).Warn("webhook verification failed") + return http.StatusForbidden, fmt.Errorf("webhook verification failed: %w", err) + } + + // Parse the payload + var payload map[string]any + if err := json.Unmarshal(ctx.Body, &payload); err != nil { + return http.StatusBadRequest, fmt.Errorf("error parsing request body: %w", err) + } + + // Verify this is a build.finished event + eventType := ctx.Headers.Get("X-Buildkite-Event") + if eventType == "" { + if event, ok := payload["event"].(string); ok { + eventType = event + } + } + + if eventType != "build.finished" { + return http.StatusOK, nil // Silently ignore non-build.finished events + } + + // Filter by pipeline if configured + if config.Pipeline != "" && config.Pipeline != "*" { + if pipeline, ok := payload["pipeline"].(map[string]any); ok { + if pipelineSlug, ok := pipeline["slug"].(string); ok { + if config.Pipeline != pipelineSlug { + ctx.Logger.Infof("Ignoring event for pipeline %s", pipelineSlug) + return http.StatusOK, nil + } + } + } + } + + // Filter by branch if configured + if config.Branch != "" { + if build, ok := payload["build"].(map[string]any); ok { + if branch, ok := build["branch"].(string); ok { + if config.Branch != branch { + ctx.Logger.Infof("Ignoring event for branch %s", branch) + return http.StatusOK, nil + } + } + } + } + + // Emit event to trigger workflow execution + if err := ctx.Events.Emit("buildkite.build.finished", payload); err != nil { + return http.StatusInternalServerError, fmt.Errorf("error emitting event: %w", err) + } + + return http.StatusOK, nil +} + +func (t *OnBuildFinished) Cleanup(ctx core.TriggerContext) error { + return nil +} diff --git a/pkg/integrations/buildkite/trigger_build.go b/pkg/integrations/buildkite/trigger_build.go new file mode 100644 index 0000000000..abafcf3913 --- /dev/null +++ b/pkg/integrations/buildkite/trigger_build.go @@ -0,0 +1,467 @@ +package buildkite + +import ( + "fmt" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +const ( + PassedOutputChannel = "passed" + FailedOutputChannel = "failed" + PayloadType = "buildkite.build.finished" + PollInterval = 30 * time.Second + + BuildStatePassed = "passed" + BuildStateFailed = "failed" + BuildStateBlocked = "blocked" + BuildStateCanceled = "canceled" + BuildStateSkipped = "skipped" + BuildStateNotRun = "not_run" +) + +var TerminalStates = map[string]bool{ + BuildStatePassed: true, + BuildStateFailed: true, + BuildStateBlocked: true, + BuildStateCanceled: true, + BuildStateSkipped: true, + BuildStateNotRun: true, +} + +type TriggerBuild struct{} + +type TriggerBuildNodeMetadata struct { + Pipeline string `json:"pipeline"` +} + +type TriggerBuildExecutionMetadata struct { + BuildID string `json:"build_id" mapstructure:"build_id"` + BuildNumber int `json:"build_number" mapstructure:"build_number"` + WebURL string `json:"web_url" mapstructure:"web_url"` + Organization string `json:"organization" mapstructure:"organization"` + Pipeline string `json:"pipeline" mapstructure:"pipeline"` + State string `json:"state" mapstructure:"state"` + Blocked bool `json:"blocked" mapstructure:"blocked"` + Extra map[string]any `json:"extra,omitempty" mapstructure:"extra,omitempty"` +} + +type KeyValuePair struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type TriggerBuildSpec struct { + Pipeline string `json:"pipeline"` + Branch string `json:"branch"` + Commit string `json:"commit"` + Message string `json:"message,omitempty"` + Env []KeyValuePair `json:"env,omitempty" mapstructure:"env"` + Metadata []KeyValuePair `json:"meta_data,omitempty" mapstructure:"meta_data"` +} + +func (r *TriggerBuild) Name() string { + return "buildkite.triggerBuild" +} + +func (r *TriggerBuild) Label() string { + return "Trigger Build" +} + +func (r *TriggerBuild) Description() string { + return "Trigger a Buildkite build and wait for completion." +} + +func (r *TriggerBuild) Icon() string { + return "workflow" +} + +func (r *TriggerBuild) Color() string { + return "gray" +} + +func (r *TriggerBuild) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{ + { + Name: PassedOutputChannel, + Label: "Passed", + }, + { + Name: FailedOutputChannel, + Label: "Failed", + }, + } +} + +func (r *TriggerBuild) ExampleOutput() map[string]any { + return map[string]any{ + "build": map[string]any{ + "id": "12345678-1234-1234-123456789012", + "number": 123, + "state": "passed", + "web_url": "https://buildkite.com/example-org/example-pipeline/builds/123", + "commit": "a1b2c3d4e5f678901234567890abcd", + "branch": "main", + "message": "Triggered by SuperPlane", + "blocked": false, + }, + "pipeline": map[string]any{ + "id": "example-pipeline", + }, + "organization": map[string]any{ + "id": "example-org", + }, + } +} + +func (r *TriggerBuild) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "pipeline", + Label: "Pipeline", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "pipeline", + }, + }, + }, + { + Name: "branch", + Label: "Branch", + Type: configuration.FieldTypeString, + Required: true, + Description: "Git branch to run build on", + Placeholder: "e.g. main, develop", + }, + { + Name: "commit", + Label: "Commit", + Type: configuration.FieldTypeString, + Description: "Git commit SHA to build (optional, defaults to HEAD)", + Placeholder: "e.g. a1b2c3d4e5f678901234567890abcd", + }, + { + Name: "message", + Label: "Message", + Type: configuration.FieldTypeString, + Description: "Optional build message", + Placeholder: "e.g. Triggered by SuperPlane workflow", + }, + { + Name: "env", + Label: "Environment Variables", + Type: configuration.FieldTypeList, + TypeOptions: &configuration.TypeOptions{ + List: &configuration.ListTypeOptions{ + ItemLabel: "Environment Variable", + ItemDefinition: &configuration.ListItemDefinition{ + Type: configuration.FieldTypeObject, + Schema: []configuration.Field{ + { + Name: "name", + Type: configuration.FieldTypeString, + Required: true, + }, + { + Name: "value", + Type: configuration.FieldTypeString, + Required: true, + }, + }, + }, + }, + }, + }, + { + Name: "meta_data", + Label: "Metadata", + Type: configuration.FieldTypeList, + TypeOptions: &configuration.TypeOptions{ + List: &configuration.ListTypeOptions{ + ItemLabel: "Metadata Item", + ItemDefinition: &configuration.ListItemDefinition{ + Type: configuration.FieldTypeObject, + Schema: []configuration.Field{ + { + Name: "name", + Type: configuration.FieldTypeString, + Required: true, + }, + { + Name: "value", + Type: configuration.FieldTypeString, + Required: true, + }, + }, + }, + }, + }, + }, + } +} + +func (r *TriggerBuild) Setup(ctx core.SetupContext) error { + config := TriggerBuildSpec{} + err := mapstructure.Decode(ctx.Configuration, &config) + if err != nil { + return err + } + + if config.Pipeline == "" { + return fmt.Errorf("pipeline is required") + } + + metadata := TriggerBuildNodeMetadata{} + err = mapstructure.Decode(ctx.Metadata.Get(), &metadata) + if err != nil { + return err + } + + if metadata.Pipeline == config.Pipeline { + return nil + } + + err = ctx.Metadata.Set(TriggerBuildNodeMetadata{ + Pipeline: config.Pipeline, + }) + if err != nil { + return err + } + + return nil +} + +func (r *TriggerBuild) Execute(ctx core.ExecutionContext) error { + spec := TriggerBuildSpec{} + err := mapstructure.Decode(ctx.Configuration, &spec) + if err != nil { + return err + } + + commit := spec.Commit + if commit == "" { + commit = "HEAD" + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return err + } + + orgConfig, err := ctx.Integration.GetConfig("organization") + if err != nil { + return fmt.Errorf("failed to get organization from integration config: %w", err) + } + orgSlug, err := extractOrgSlug(string(orgConfig)) + if err != nil { + return fmt.Errorf("failed to extract organization slug: %w", err) + } + + envMap := make(map[string]string, len(spec.Env)) + for _, e := range spec.Env { + envMap[e.Name] = e.Value + } + + metadataMap := make(map[string]string, len(spec.Metadata)+2) + for _, m := range spec.Metadata { + metadataMap[m.Name] = m.Value + } + metadataMap["superplane_execution_id"] = ctx.ID.String() + metadataMap["superplane_workflow_id"] = ctx.WorkflowID + + buildReq := CreateBuildRequest{ + Commit: commit, + Branch: spec.Branch, + Message: spec.Message, + Env: envMap, + Metadata: metadataMap, + } + + build, err := client.CreateBuild(orgSlug, spec.Pipeline, buildReq) + if err != nil { + return fmt.Errorf("error creating build: %v", err) + } + + err = ctx.Metadata.Set(TriggerBuildExecutionMetadata{ + BuildID: build.ID, + BuildNumber: build.Number, + WebURL: build.WebURL, + Organization: orgSlug, + Pipeline: spec.Pipeline, + State: build.State, + Blocked: build.Blocked, + }) + if err != nil { + return err + } + + return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, PollInterval) +} + +func (r *TriggerBuild) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (r *TriggerBuild) Actions() []core.Action { + return []core.Action{ + { + Name: "poll", + UserAccessible: false, + }, + { + Name: "finish", + UserAccessible: true, + Parameters: []configuration.Field{ + { + Name: "data", + Type: configuration.FieldTypeObject, + Required: false, + Default: map[string]any{}, + }, + }, + }, + } +} + +func (r *TriggerBuild) HandleAction(ctx core.ActionContext) error { + switch ctx.Name { + case "poll": + return r.poll(ctx) + case "finish": + return r.finish(ctx) + } + + return fmt.Errorf("unknown action: %s", ctx.Name) +} + +func (r *TriggerBuild) poll(ctx core.ActionContext) error { + spec := TriggerBuildSpec{} + err := mapstructure.Decode(ctx.Configuration, &spec) + if err != nil { + return err + } + + if ctx.ExecutionState.IsFinished() { + return nil + } + + metadata := TriggerBuildExecutionMetadata{} + err = mapstructure.Decode(ctx.Metadata.Get(), &metadata) + if err != nil { + return err + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return err + } + + targetBuild, err := client.GetBuild(metadata.Organization, metadata.Pipeline, metadata.BuildNumber) + if err != nil { + return err + } + + if targetBuild.ID != metadata.BuildID { + return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, PollInterval) + } + + if TerminalStates[targetBuild.State] { + payload := map[string]any{ + "build": map[string]any{ + "id": targetBuild.ID, + "number": targetBuild.Number, + "state": targetBuild.State, + "web_url": targetBuild.WebURL, + "commit": targetBuild.Commit, + "branch": targetBuild.Branch, + "message": targetBuild.Message, + "blocked": targetBuild.Blocked, + "started_at": targetBuild.StartedAt, + "finished_at": targetBuild.FinishedAt, + }, + "pipeline": map[string]any{ + "id": metadata.Pipeline, + }, + "organization": map[string]any{ + "id": metadata.Organization, + }, + } + + metadata.State = targetBuild.State + metadata.Blocked = targetBuild.Blocked + if err := ctx.Metadata.Set(metadata); err != nil { + return err + } + + isSuccess := targetBuild.State == BuildStatePassed && !targetBuild.Blocked + if isSuccess { + return ctx.ExecutionState.Emit(PassedOutputChannel, PayloadType, []any{payload}) + } + + return ctx.ExecutionState.Emit(FailedOutputChannel, PayloadType, []any{payload}) + } + + return ctx.Requests.ScheduleActionCall("poll", map[string]any{}, PollInterval) +} + +func (r *TriggerBuild) finish(ctx core.ActionContext) error { + data, _ := ctx.Parameters["data"] + dataMap, _ := data.(map[string]any) + + metadata := TriggerBuildExecutionMetadata{} + if err := mapstructure.Decode(ctx.Metadata.Get(), &metadata); err != nil { + return err + } + + if dataMap != nil { + metadata.Extra = dataMap + if err := ctx.Metadata.Set(metadata); err != nil { + return err + } + } + + return nil +} + +func (r *TriggerBuild) Cleanup(ctx core.SetupContext) error { + return nil +} + +func (r *TriggerBuild) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (r *TriggerBuild) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return http.StatusOK, nil +} + +func (r *TriggerBuild) Documentation() string { + return `Trigger a Buildkite build and wait for completion using polling mechanism. + +## How It Works + +1. Triggers a build via Buildkite API +2. Monitors build status via polling +3. Emits to success/failure channels when build finishes + +## Configuration + +- **Pipeline**: Select pipeline to trigger +- **Branch**: Git branch to build +- **Commit**: Git commit SHA (optional, defaults to HEAD) +- **Message**: Optional build message +- **Environment Variables**: Optional env vars for the build +- **Metadata**: Optional metadata for the build + +## Output Channels + +- **Passed**: Build completed successfully +- **Failed**: Build failed, was cancelled, or was blocked` +} diff --git a/pkg/integrations/buildkite/trigger_build_test.go b/pkg/integrations/buildkite/trigger_build_test.go new file mode 100644 index 0000000000..994aa3937f --- /dev/null +++ b/pkg/integrations/buildkite/trigger_build_test.go @@ -0,0 +1,53 @@ +package buildkite + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/superplanehq/superplane/pkg/core" + contexts "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test_TriggerBuild_HandleWebhook_Deprecated(t *testing.T) { + component := &TriggerBuild{} + + webhookCtx := &contexts.WebhookContext{Secret: "test-secret"} + eventCtx := &contexts.EventContext{} + + ctx := core.WebhookRequestContext{ + Headers: http.Header{ + "X-Buildkite-Event": []string{"build.finished"}, + }, + Body: createTriggerBuildPayload("build-123", "passed", false), + Events: eventCtx, + Webhook: webhookCtx, + } + + statusCode, err := component.HandleWebhook(ctx) + + assert.Equal(t, http.StatusOK, statusCode) + assert.NoError(t, err) + + assert.Equal(t, 0, eventCtx.Count()) +} + +func createTriggerBuildPayload(buildID, state string, blocked bool) []byte { + payload := map[string]any{ + "event": "build.finished", + "build": map[string]any{ + "id": buildID, + "state": state, + "blocked": blocked, + }, + "pipeline": map[string]any{ + "slug": "test-pipeline", + }, + "organization": map[string]any{ + "slug": "test-org", + }, + } + data, _ := json.Marshal(payload) + return data +} diff --git a/pkg/integrations/buildkite/webhook_verify.go b/pkg/integrations/buildkite/webhook_verify.go new file mode 100644 index 0000000000..4c9ff918f6 --- /dev/null +++ b/pkg/integrations/buildkite/webhook_verify.go @@ -0,0 +1,86 @@ +package buildkite + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +const ( + SignatureHeader = "X-Buildkite-Signature" + TokenHeader = "X-Buildkite-Token" + MaxReplayAge = 5 * time.Minute +) + +func VerifyWebhook(headers http.Header, body []byte, secret []byte) error { + if signature := headers.Get(SignatureHeader); signature != "" { + return verifySignature(signature, body, secret) + } + + if token := headers.Get(TokenHeader); token != "" { + return verifyToken(token, secret) + } + + return fmt.Errorf("no verification header found: expected %s or %s", SignatureHeader, TokenHeader) +} + +func verifySignature(signatureHeader string, body []byte, secret []byte) error { + parts := strings.Split(signatureHeader, ",") + if len(parts) != 2 { + return fmt.Errorf("invalid signature format") + } + + var timestampStr, signature string + for _, part := range parts { + kv := strings.Split(strings.TrimSpace(part), "=") + if len(kv) != 2 { + return fmt.Errorf("invalid signature part format: %s", part) + } + switch kv[0] { + case "timestamp": + timestampStr = kv[1] + case "signature": + signature = kv[1] + } + } + + if timestampStr == "" || signature == "" { + return fmt.Errorf("missing timestamp or signature") + } + + timestamp, err := strconv.ParseInt(timestampStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid timestamp: %v", err) + } + + sigTime := time.Unix(timestamp, 0) + if time.Since(sigTime) > MaxReplayAge { + return fmt.Errorf("timestamp too old: max age is %v", MaxReplayAge) + } + + expectedMAC := hmac.New(sha256.New, secret) + expectedMAC.Write([]byte(fmt.Sprintf("%s.%s", timestampStr, string(body)))) + expectedSignature := hex.EncodeToString(expectedMAC.Sum(nil)) + + if !hmac.Equal([]byte(signature), []byte(expectedSignature)) { + return fmt.Errorf("signature mismatch") + } + + return nil +} + +func verifyToken(token string, secret []byte) error { + tokenBytes := []byte(token) + + if subtle.ConstantTimeCompare(tokenBytes, secret) != 1 { + return fmt.Errorf("token mismatch") + } + + return nil +} diff --git a/pkg/integrations/buildkite/webhook_verify_test.go b/pkg/integrations/buildkite/webhook_verify_test.go new file mode 100644 index 0000000000..9502579cd6 --- /dev/null +++ b/pkg/integrations/buildkite/webhook_verify_test.go @@ -0,0 +1,157 @@ +package buildkite + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "testing" + "time" +) + +func Test_VerifyWebhook_ValidSignature(t *testing.T) { + secret := []byte("test-secret") + body := []byte(`{"event": "build.finished", "build": {"id": "test"}}`) + ts := time.Now().Unix() + + mac := hmac.New(sha256.New, secret) + mac.Write([]byte(fmt.Sprintf("%d.%s", ts, body))) + expectedSignature := hex.EncodeToString(mac.Sum(nil)) + + headers := http.Header{} + headers.Set(SignatureHeader, fmt.Sprintf("timestamp=%d,signature=%s", ts, expectedSignature)) + + err := VerifyWebhook(headers, body, secret) + if err != nil { + t.Errorf("Expected valid signature to pass, got error: %v", err) + } +} + +func Test_VerifyWebhook_InvalidSignature(t *testing.T) { + secret := []byte("test-secret") + body := []byte(`{"event": "build.finished"}`) + + headers := http.Header{} + headers.Set(SignatureHeader, "timestamp=123,signature=invalid_signature") + + err := VerifyWebhook(headers, body, secret) + if err == nil { + t.Error("Expected invalid signature to fail") + } +} + +func Test_VerifyWebhook_MissingSignatureParts(t *testing.T) { + secret := []byte("test-secret") + body := []byte(`{"event": "build.finished"}`) + + headers := http.Header{} + headers.Set(SignatureHeader, "timestamp=123") + + err := VerifyWebhook(headers, body, secret) + if err == nil { + t.Error("Expected missing signature part to fail") + } +} + +func Test_VerifyWebhook_ReplayWindow(t *testing.T) { + secret := []byte("test-secret") + body := []byte(`{"event": "build.finished"}`) + + ts := time.Now().Add(-10 * time.Minute).Unix() + mac := hmac.New(sha256.New, secret) + mac.Write([]byte(fmt.Sprintf("%d.%s", ts, body))) + expectedSignature := hex.EncodeToString(mac.Sum(nil)) + + headers := http.Header{} + headers.Set(SignatureHeader, fmt.Sprintf("timestamp=%d,signature=%s", ts, expectedSignature)) + + err := VerifyWebhook(headers, body, secret) + if err == nil { + t.Error("Expected old timestamp to fail replay window check") + } +} + +func Test_VerifyWebhook_ValidToken(t *testing.T) { + secret := []byte("test-token") + body := []byte(`{"event": "build.finished"}`) + + headers := http.Header{} + headers.Set(TokenHeader, "test-token") + + err := VerifyWebhook(headers, body, secret) + if err != nil { + t.Errorf("Expected valid token to pass, got error: %v", err) + } +} + +func Test_VerifyWebhook_InvalidToken(t *testing.T) { + secret := []byte("test-secret") + body := []byte(`{"event": "build.finished"}`) + + headers := http.Header{} + headers.Set(TokenHeader, "wrong-token") + + err := VerifyWebhook(headers, body, secret) + if err == nil { + t.Error("Expected invalid token to fail") + } +} + +func Test_VerifyWebhook_NoVerificationHeader(t *testing.T) { + secret := []byte("test-secret") + body := []byte(`{"event": "build.finished"}`) + + headers := http.Header{} + + err := VerifyWebhook(headers, body, secret) + if err == nil { + t.Error("Expected missing verification headers to fail") + } +} + +func Test_VerifyWebhook_SignaturePreferredOverToken(t *testing.T) { + secret := []byte("test-secret") + body := []byte(`{"event": "build.finished"}`) + ts := time.Now().Unix() + + mac := hmac.New(sha256.New, secret) + mac.Write([]byte(fmt.Sprintf("%d.%s", ts, body))) + expectedSignature := hex.EncodeToString(mac.Sum(nil)) + + headers := http.Header{} + headers.Set(SignatureHeader, fmt.Sprintf("timestamp=%d,signature=%s", ts, expectedSignature)) + headers.Set(TokenHeader, "wrong-token") + + err := VerifyWebhook(headers, body, secret) + if err != nil { + t.Errorf("Expected signature verification to succeed even with wrong token, got error: %v", err) + } +} + +func Test_verifySignature_InvalidTimestamp(t *testing.T) { + secret := []byte("test-secret") + body := []byte(`{"event": "build.finished"}`) + + headers := http.Header{} + headers.Set(SignatureHeader, "timestamp=invalid,signature=abc123") + + err := verifySignature(headers.Get(SignatureHeader), body, secret) + if err == nil { + t.Error("Expected invalid timestamp to fail") + } +} + +func Test_verifyToken_ConstantTimeComparison(t *testing.T) { + secret := []byte("test-secret-token") + + err := verifyToken("test-secret-token", secret) + if err != nil { + t.Errorf("Expected matching token to pass, got error: %v", err) + } + + err = verifyToken("wrong-token", secret) + if err == nil { + t.Error("Expected wrong token to fail") + } +} diff --git a/pkg/server/server.go b/pkg/server/server.go index fdc996af82..db7c13518f 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -34,6 +34,7 @@ import ( _ "github.com/superplanehq/superplane/pkg/components/wait" _ "github.com/superplanehq/superplane/pkg/integrations/aws" _ "github.com/superplanehq/superplane/pkg/integrations/bitbucket" + _ "github.com/superplanehq/superplane/pkg/integrations/buildkite" _ "github.com/superplanehq/superplane/pkg/integrations/circleci" _ "github.com/superplanehq/superplane/pkg/integrations/claude" _ "github.com/superplanehq/superplane/pkg/integrations/cloudflare" diff --git a/web_src/src/assets/buildkite-logo.svg b/web_src/src/assets/buildkite-logo.svg new file mode 100644 index 0000000000..79eacdf465 --- /dev/null +++ b/web_src/src/assets/buildkite-logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web_src/src/pages/workflowv2/mappers/buildkite/index.ts b/web_src/src/pages/workflowv2/mappers/buildkite/index.ts new file mode 100644 index 0000000000..8e66973d90 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/buildkite/index.ts @@ -0,0 +1,19 @@ +import { ComponentBaseMapper, CustomFieldRenderer, EventStateRegistry, TriggerRenderer } from "../types"; +import { onBuildFinishedCustomFieldRenderer, onBuildFinishedTriggerRenderer } from "./on_build_finished"; +import { TRIGGER_BUILD_STATE_REGISTRY, triggerBuildMapper } from "./trigger_build"; + +export const componentMappers: Record = { + triggerBuild: triggerBuildMapper, +}; + +export const triggerRenderers: Record = { + onBuildFinished: onBuildFinishedTriggerRenderer, +}; + +export const eventStateRegistry: Record = { + triggerBuild: TRIGGER_BUILD_STATE_REGISTRY, +}; + +export const customFieldRenderers: Record = { + onBuildFinished: onBuildFinishedCustomFieldRenderer, +}; diff --git a/web_src/src/pages/workflowv2/mappers/buildkite/on_build_finished.tsx b/web_src/src/pages/workflowv2/mappers/buildkite/on_build_finished.tsx new file mode 100644 index 0000000000..8f9e796832 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/buildkite/on_build_finished.tsx @@ -0,0 +1,252 @@ +import { useState } from "react"; +import { getColorClass, getBackgroundColorClass } from "@/utils/colors"; +import { CustomFieldRenderer, NodeInfo, TriggerEventContext, TriggerRenderer, TriggerRendererContext } from "../types"; +import { TriggerProps } from "@/ui/trigger"; +import BuildkiteLogo from "@/assets/buildkite-logo.svg"; +import { formatTimeAgo } from "@/utils/date"; +import { Button } from "@/components/ui/button"; +import { Copy, Check, ExternalLink } from "lucide-react"; +import { showErrorToast } from "@/utils/toast"; + +interface OnBuildFinishedMetadata { + organization?: string; + pipeline?: string; + branch?: string; + appSubscriptionID?: string; + webhookUrl?: string; + webhookToken?: string; + orgSlug?: string; +} + +interface OnBuildFinishedEventData { + build?: { + id: string; + state: string; + result?: string; + web_url?: string; + number?: number; + commit?: string; + branch?: string; + message?: string; + blocked?: boolean; + started_at?: string; + finished_at?: string; + }; + pipeline?: { + id: string; + slug: string; + name: string; + }; + organization?: { + id: string; + slug: string; + name: string; + }; + sender?: { + id: string; + name: string; + email: string; + }; +} + +/** + * Renderer for the "buildkite.onBuildFinished" trigger type + */ +export const onBuildFinishedTriggerRenderer: TriggerRenderer = { + getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string } => { + const eventData = context.event?.data as OnBuildFinishedEventData; + const build = eventData?.build; + const state = build?.state || ""; + const result = build?.blocked ? "blocked" : state; + const timeAgo = context.event?.createdAt ? formatTimeAgo(new Date(context.event?.createdAt)) : ""; + const subtitle = result && timeAgo ? `${result} · ${timeAgo}` : result || timeAgo; + + return { + title: eventData?.pipeline?.name || eventData?.build?.web_url?.split("/").pop() || "Unknown Pipeline", + subtitle, + }; + }, + + getRootEventValues: (context: TriggerEventContext): Record => { + const eventData = context.event?.data as OnBuildFinishedEventData; + const build = eventData?.build; + const pipeline = eventData?.pipeline; + const sender = eventData?.sender; + + const startedAt = build?.started_at ? new Date(build.started_at).toLocaleString() : ""; + const finishedAt = build?.finished_at ? new Date(build.finished_at).toLocaleString() : ""; + const buildUrl = build?.web_url || ""; + + return { + "Started At": startedAt, + "Finished At": finishedAt, + "Build State": build?.state || "", + Pipeline: pipeline?.name || "", + "Pipeline URL": buildUrl, + Branch: build?.branch || "", + Commit: build?.commit || "", + Message: build?.message || "", + "Triggered By": sender?.name || "", + }; + }, + + getTriggerProps: (context: TriggerRendererContext) => { + const { node, definition, lastEvent } = context; + const metadata = node.metadata as unknown as OnBuildFinishedMetadata; + const metadataItems = []; + + if (metadata?.pipeline) { + metadataItems.push({ + icon: "layers", + label: metadata.pipeline, + }); + } + + if (metadata?.branch) { + metadataItems.push({ + icon: "git-branch", + label: metadata.branch, + }); + } + + const props: TriggerProps = { + title: node.name || definition.label || "Unnamed trigger", + iconSrc: BuildkiteLogo, + iconColor: getColorClass(definition.color), + collapsedBackground: getBackgroundColorClass(definition.color), + metadata: metadataItems, + }; + + if (lastEvent) { + const eventData = lastEvent.data as OnBuildFinishedEventData; + const build = eventData?.build; + const state = build?.state || ""; + const result = build?.blocked ? "blocked" : state; + const timeAgo = lastEvent.createdAt ? formatTimeAgo(new Date(lastEvent.createdAt)) : ""; + const subtitle = result && timeAgo ? `${result} · ${timeAgo}` : result || timeAgo; + + props.lastEventData = { + title: eventData?.pipeline?.name || "Unknown Pipeline", + subtitle, + receivedAt: new Date(lastEvent.createdAt), + state: "triggered", + eventId: lastEvent.id, + }; + } + + return props; + }, +}; + +export const onBuildFinishedCustomFieldRenderer: CustomFieldRenderer = { + render: (node: NodeInfo) => { + const metadata = node.metadata as OnBuildFinishedMetadata | undefined; + const webhookUrl = metadata?.webhookUrl || " "; + const webhookToken = metadata?.webhookToken || " "; + const orgSlug = metadata?.orgSlug; + + const disabled = !orgSlug; + + const handleOpenBuildkite = () => { + if (orgSlug) { + window.open(`https://buildkite.com/organizations/${orgSlug}/services/webhook/new`, "_blank"); + } + }; + + const CopyButton: React.FC<{ code: string; disabled?: boolean }> = ({ code, disabled }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + if (disabled) return; + try { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (_err) { + showErrorToast("Failed to copy text"); + } + }; + + return ( + + ); + }; + + const FieldLabel = ({ children, disabled }: { children: React.ReactNode; disabled?: boolean }) => ( + + {children} + + ); + + const FieldValue = ({ children, disabled }: { children: React.ReactNode; disabled?: boolean }) => ( +
+        {children}
+      
+ ); + + return ( +
+
+
+ Buildkite Webhook Setup +
+
    +
  1. + Save the trigger to generate the webhook URL and token. +
  2. +
  3. Click the button below to create Buildkite webhook.
  4. +
  5. Enter provided webhook URL and token.
  6. +
  7. Select "build.finished" as the event and choose your pipeline.
  8. +
+
+ +
+
+
+ Webhook URL + +
+ {webhookUrl} +
+
+
+ Webhook Token + +
+ {webhookToken} +
+
+
+
+
+ ); + }, +}; diff --git a/web_src/src/pages/workflowv2/mappers/buildkite/trigger_build.ts b/web_src/src/pages/workflowv2/mappers/buildkite/trigger_build.ts new file mode 100644 index 0000000000..2da599ef4d --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/buildkite/trigger_build.ts @@ -0,0 +1,374 @@ +import { + ComponentBaseContext, + ComponentBaseMapper, + EventStateRegistry, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + OutputPayload, + StateFunction, + SubtitleContext, +} from "../types"; +import { + ComponentBaseProps, + ComponentBaseSpec, + DEFAULT_EVENT_STATE_MAP, + EventSection, + EventState, + EventStateMap, +} from "@/ui/componentBase"; +import { getBackgroundColorClass, getColorClass } from "@/utils/colors"; +import { MetadataItem } from "@/ui/metadataList"; +import { formatTimeAgo } from "@/utils/date"; +import BuildkiteLogo from "@/assets/buildkite-logo.svg"; +import { CanvasesCanvasNodeExecution } from "@/api-client"; +import { getTriggerRenderer } from ".."; + +interface BuildkiteBuildInfo { + id?: string; + number?: number; + web_url?: string; + state?: string; + result?: string; + blocked?: boolean; + branch?: string; + commit?: string; + message?: string; + started_at?: string; + finished_at?: string; +} + +interface BuildkitePipelineInfo { + id?: string; + slug?: string; + name?: string; +} + +interface BuildkiteOrganizationInfo { + id?: string; + slug?: string; + name?: string; +} + +interface BuildkiteSenderInfo { + id?: string; + name?: string; + email?: string; +} + +type BuildkiteEventData = { + build?: BuildkiteBuildInfo; + pipeline?: BuildkitePipelineInfo; + organization?: BuildkiteOrganizationInfo; + sender?: BuildkiteSenderInfo; +}; + +interface TriggerBuildExecutionMetadata { + extra?: BuildkiteEventData; + blocked?: boolean; + build_id?: string; + build_number?: number; + organization?: string; + pipeline?: string; + state?: string; + web_url?: string; +} + +interface TriggerBuildNodeMetadataValue { + name?: string; +} + +interface TriggerBuildNodeMetadata { + organization?: TriggerBuildNodeMetadataValue; + pipeline?: TriggerBuildNodeMetadataValue; +} + +interface BuildkiteEnvironmentVariable { + name?: string; + value?: string; +} + +interface BuildkiteMetadataItem { + name?: string; + value?: string; +} + +interface TriggerBuildConfiguration { + organization?: string; + pipeline?: string; + branch?: string; + commit?: string; + message?: string; + env?: BuildkiteEnvironmentVariable[]; + meta_data?: BuildkiteMetadataItem[]; +} + +type OutputPayloadMap = { + passed?: OutputPayload[]; + failed?: OutputPayload[]; + default?: OutputPayload[]; +}; + +type BuildkiteExecutionPayload = BuildkiteEventData | { data?: BuildkiteEventData }; + +export const TRIGGER_BUILD_STATE_MAP: EventStateMap = { + ...DEFAULT_EVENT_STATE_MAP, + running: { + icon: "loader-circle", + textColor: "text-gray-800", + backgroundColor: "bg-blue-100", + badgeColor: "bg-blue-500", + }, + passed: { + icon: "circle-check", + textColor: "text-gray-800", + backgroundColor: "bg-green-100", + badgeColor: "bg-emerald-500", + }, + failed: { + icon: "circle-x", + textColor: "text-gray-800", + backgroundColor: "bg-red-100", + badgeColor: "bg-red-400", + }, +}; + +/** + * Buildkite-specific state logic function + */ +export const triggerBuildStateFunction: StateFunction = (execution: CanvasesCanvasNodeExecution): EventState => { + if (!execution) return "neutral"; + + if ( + execution.resultMessage && + (execution.resultReason === "RESULT_REASON_ERROR" || + (execution.result === "RESULT_FAILED" && execution.resultReason !== "RESULT_REASON_ERROR_RESOLVED")) + ) { + return "error"; + } + + if (execution.result === "RESULT_CANCELLED") { + return "cancelled"; + } + + if (execution.state === "STATE_PENDING" || execution.state === "STATE_STARTED") { + return "running"; + } + + const metadata = execution.metadata as TriggerBuildExecutionMetadata; + const buildState = metadata?.state; + + if (buildState === "passed") { + return "passed"; + } + + return "failed"; +}; + +/** + * Buildkite-specific state registry + */ +export const TRIGGER_BUILD_STATE_REGISTRY: EventStateRegistry = { + stateMap: TRIGGER_BUILD_STATE_MAP, + getState: triggerBuildStateFunction, +}; + +export const triggerBuildMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null; + + return { + title: + context.node.name || + context.componentDefinition.label || + context.componentDefinition.name || + "Unnamed component", + iconSrc: BuildkiteLogo, + iconColor: getColorClass(context.componentDefinition?.color || "gray"), + collapsedBackground: getBackgroundColorClass("white"), + eventSections: lastExecution ? triggerBuildEventSections(context.nodes, lastExecution) : undefined, + includeEmptyState: !lastExecution, + metadata: triggerBuildMetadataList(context.node), + specs: triggerBuildSpecs(context.node), + eventStateMap: TRIGGER_BUILD_STATE_MAP, + }; + }, + subtitle(context: SubtitleContext): string { + const timestamp = context.execution.updatedAt || context.execution.createdAt; + return timestamp ? formatTimeAgo(new Date(timestamp)) : ""; + }, + getExecutionDetails(context: ExecutionDetailsContext): Record { + const details: Record = {}; + const outputs = context.execution.outputs as OutputPayloadMap | undefined; + const payload = + (outputs?.passed?.[0]?.data as BuildkiteExecutionPayload | undefined) ?? + (outputs?.failed?.[0]?.data as BuildkiteExecutionPayload | undefined) ?? + (outputs?.default?.[0]?.data as BuildkiteExecutionPayload | undefined); + const payloadData = unwrapBuildkiteEventData(payload); + const metadata = context.execution.metadata as TriggerBuildExecutionMetadata | undefined; + const sourceData = payloadData ?? metadata?.extra; + + if (!sourceData) { + return details; + } + + const build = sourceData.build; + + const addDetail = (key: string, value?: string) => { + if (value) { + details[key] = value; + } + }; + + addDetail("Started At", build?.started_at ? new Date(build.started_at).toLocaleString() : ""); + addDetail("Completed At", build?.finished_at ? new Date(build.finished_at).toLocaleString() : ""); + addDetail("Build URL", build?.web_url ?? metadata?.web_url); + + const blocked = build?.blocked ?? metadata?.blocked; + if (blocked === true) { + addDetail("Blocked", "Yes"); + } + + return details; + }, +}; + +function triggerBuildMetadataList(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const configuration = node.configuration as TriggerBuildConfiguration | undefined; + const nodeMetadata = node.metadata as TriggerBuildNodeMetadata | undefined; + + const pipelineName = nodeMetadata?.pipeline?.name; + + if (pipelineName) { + metadata.push({ icon: "layers", label: pipelineName }); + } else if (configuration?.pipeline) { + metadata.push({ icon: "layers", label: configuration.pipeline }); + } + + if (configuration?.branch) { + metadata.push({ icon: "git-branch", label: configuration.branch }); + } + + if (configuration?.commit) { + metadata.push({ icon: "git-commit", label: configuration.commit.slice(0, 7) }); + } + + return metadata; +} + +function triggerBuildSpecs(node: NodeInfo): ComponentBaseSpec[] { + const specs: ComponentBaseSpec[] = []; + const configuration = node.configuration as TriggerBuildConfiguration | undefined; + + if (configuration?.message) { + specs.push({ + title: "message", + iconSlug: "message-square", + values: [ + { + badges: [ + { + label: configuration.message, + bgColor: "bg-gray-100", + textColor: "text-gray-800", + }, + ], + }, + ], + }); + } + + // Environment variables + if (Array.isArray(configuration?.env) && configuration.env.length > 0) { + specs.push({ + title: "variable", + iconSlug: "globe", + values: configuration.env.map((env) => ({ + badges: [ + { + label: env.name ?? "", + bgColor: "bg-purple-100", + textColor: "text-purple-800", + }, + { + label: env.value ?? "", + bgColor: "bg-gray-100", + textColor: "text-gray-800", + }, + ], + })), + }); + } + + // Metadata + if (Array.isArray(configuration?.meta_data) && configuration.meta_data.length > 0) { + specs.push({ + title: "metadata", + iconSlug: "tag", + values: configuration.meta_data.map((meta) => ({ + badges: [ + { + label: meta.name ?? "", + bgColor: "bg-blue-100", + textColor: "text-blue-800", + }, + { + label: meta.value ?? "", + bgColor: "bg-gray-100", + textColor: "text-gray-800", + }, + ], + })), + }); + } + + return specs; +} + +function isPayloadWithData(payload: BuildkiteExecutionPayload): payload is { data?: BuildkiteEventData } { + return typeof payload === "object" && payload !== null && "data" in payload; +} + +function unwrapBuildkiteEventData(payload?: BuildkiteExecutionPayload): BuildkiteEventData | undefined { + if (!payload) { + return undefined; + } + + if (isPayloadWithData(payload)) { + return payload.data; + } + + return payload; +} + +function triggerBuildEventSections(nodes: NodeInfo[], execution: ExecutionInfo): EventSection[] | undefined { + if (!execution) { + return undefined; + } + + const rootEvent = execution.rootEvent; + const rootTriggerNode = rootEvent ? nodes.find((n) => n.id === rootEvent.nodeId) : undefined; + const renderer = rootTriggerNode ? getTriggerRenderer(rootTriggerNode.componentName) : undefined; + const title = renderer && rootEvent ? renderer.getTitleAndSubtitle({ event: rootEvent }).title : "Unknown"; + const executionState = triggerBuildStateFunction(execution); + const subtitleTimestamp = + executionState === "running" ? execution.createdAt : execution.updatedAt || execution.createdAt; + const eventSubtitle = subtitleTimestamp ? formatTimeAgo(new Date(subtitleTimestamp)) : undefined; + const eventId = rootEvent?.id || execution.id; + const receivedAt = execution.createdAt ? new Date(execution.createdAt) : undefined; + + if (!eventId) { + return undefined; + } + + return [ + { + receivedAt, + eventTitle: title, + eventSubtitle, + eventState: executionState, + eventId, + }, + ]; +} diff --git a/web_src/src/pages/workflowv2/mappers/index.ts b/web_src/src/pages/workflowv2/mappers/index.ts index d996d130bc..c797d9147f 100644 --- a/web_src/src/pages/workflowv2/mappers/index.ts +++ b/web_src/src/pages/workflowv2/mappers/index.ts @@ -119,6 +119,12 @@ import { triggerRenderers as cursorTriggerRenderers, eventStateRegistry as cursorEventStateRegistry, } from "./cursor/index"; +import { + componentMappers as buildkiteComponentMappers, + customFieldRenderers as buildkiteCustomFieldRenderers, + triggerRenderers as buildkiteTriggerRenderers, + eventStateRegistry as buildkiteEventStateRegistry, +} from "./buildkite/index"; import { componentMappers as dockerhubComponentMappers, customFieldRenderers as dockerhubCustomFieldRenderers, @@ -175,6 +181,7 @@ const appMappers: Record> = { openai: openaiComponentMappers, circleci: circleCIComponentMappers, claude: claudeComponentMappers, + buildkite: buildkiteComponentMappers, prometheus: prometheusComponentMappers, cursor: cursorComponentMappers, hetzner: hetznerComponentMappers, @@ -200,6 +207,7 @@ const appTriggerRenderers: Record> = { openai: openaiTriggerRenderers, circleci: circleCITriggerRenderers, claude: claudeTriggerRenderers, + buildkite: buildkiteTriggerRenderers, bitbucket: bitbucketTriggerRenderers, prometheus: prometheusTriggerRenderers, cursor: cursorTriggerRenderers, @@ -224,6 +232,7 @@ const appEventStateRegistries: Record circleci: circleCIEventStateRegistry, claude: claudeEventStateRegistry, aws: awsEventStateRegistry, + buildkite: buildkiteEventStateRegistry, prometheus: prometheusEventStateRegistry, cursor: cursorEventStateRegistry, gitlab: gitlabEventStateRegistry, @@ -255,6 +264,7 @@ const appCustomFieldRenderers: Record = { pagerduty: pagerDutyIcon, rootly: rootlyIcon, semaphore: SemaphoreLogo, + buildkite: BuildkiteLogo, slack: slackIcon, smtp: smtpIcon, sendgrid: sendgridIcon, @@ -79,6 +81,7 @@ export const APP_LOGO_MAP: Record> = { pagerduty: pagerDutyIcon, rootly: rootlyIcon, semaphore: SemaphoreLogo, + buildkite: BuildkiteLogo, slack: slackIcon, sendgrid: sendgridIcon, prometheus: prometheusIcon,