diff --git a/pkg/integrations/circleci/circleci.go b/pkg/integrations/circleci/circleci.go index 4ffc6f57cb..5ba6e1b22d 100644 --- a/pkg/integrations/circleci/circleci.go +++ b/pkg/integrations/circleci/circleci.go @@ -159,6 +159,11 @@ func (c *CircleCI) ListResources(resourceType string, ctx core.ListResourcesCont func (c *CircleCI) Components() []core.Component { return []core.Component{ &RunPipeline{}, + &GetWorkflow{}, + &GetLastWorkflow{}, + &GetRecentWorkflowRuns{}, + &GetTestMetrics{}, + &GetFlakyTests{}, } } diff --git a/pkg/integrations/circleci/client.go b/pkg/integrations/circleci/client.go index c6a656aae4..4412b2c72d 100644 --- a/pkg/integrations/circleci/client.go +++ b/pkg/integrations/circleci/client.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "net/url" "github.com/superplanehq/superplane/pkg/core" ) @@ -386,3 +387,118 @@ func (c *Client) GetPipelineDefinitions(projectID string) ([]PipelineDefinitionR return response.Items, nil } + +func buildQueryString(params map[string]string) string { + if len(params) == 0 { + return "" + } + values := url.Values{} + for key, value := range params { + if value == "" { + continue + } + values.Set(key, value) + } + encoded := values.Encode() + if encoded == "" { + return "" + } + return "?" + encoded +} + +func (c *Client) GetWorkflowJobs(workflowID string) (map[string]any, error) { + path := fmt.Sprintf("%s/workflow/%s/job", baseURL, workflowID) + responseBody, err := c.execRequest("GET", path, nil) + if err != nil { + return nil, err + } + + var response map[string]any + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("error unmarshaling response: %v", err) + } + + return response, nil +} + +func (c *Client) GetLastWorkflow(projectSlug string, filters map[string]string) (map[string]any, error) { + pipelineURL := fmt.Sprintf("%s/project/%s/pipeline%s", baseURL, projectSlug, buildQueryString(filters)) + pipelineBody, err := c.execRequest("GET", pipelineURL, nil) + if err != nil { + return nil, err + } + + var pipelineResp map[string]any + if err := json.Unmarshal(pipelineBody, &pipelineResp); err != nil { + return nil, fmt.Errorf("error unmarshaling response: %v", err) + } + + items, ok := pipelineResp["items"].([]any) + if !ok || len(items) == 0 { + return map[string]any{ + "pipeline": pipelineResp, + "workflows": []any{}, + }, nil + } + + firstItem, ok := items[0].(map[string]any) + if !ok { + return nil, fmt.Errorf("unexpected pipeline response shape") + } + + pipelineID, _ := firstItem["id"].(string) + workflows, err := c.GetPipelineWorkflows(pipelineID) + if err != nil { + return nil, err + } + + return map[string]any{ + "pipeline": firstItem, + "workflows": workflows, + }, nil +} + +func (c *Client) GetWorkflowInsights(projectSlug string, filters map[string]string) (map[string]any, error) { + path := fmt.Sprintf("%s/insights/%s/workflows%s", baseURL, projectSlug, buildQueryString(filters)) + responseBody, err := c.execRequest("GET", path, nil) + if err != nil { + return nil, err + } + + var response map[string]any + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("error unmarshaling response: %v", err) + } + + return response, nil +} + +func (c *Client) GetWorkflowTestMetrics(projectSlug, workflowName string, filters map[string]string) (map[string]any, error) { + path := fmt.Sprintf("%s/insights/%s/workflows/%s/test-metrics%s", baseURL, projectSlug, workflowName, buildQueryString(filters)) + responseBody, err := c.execRequest("GET", path, nil) + if err != nil { + return nil, err + } + + var response map[string]any + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("error unmarshaling response: %v", err) + } + + return response, nil +} + +func (c *Client) GetFlakyTests(projectSlug string, filters map[string]string) (map[string]any, error) { + path := fmt.Sprintf("%s/insights/%s/flaky-tests%s", baseURL, projectSlug, buildQueryString(filters)) + responseBody, err := c.execRequest("GET", path, nil) + if err != nil { + return nil, err + } + + var response map[string]any + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("error unmarshaling response: %v", err) + } + + return response, nil +} diff --git a/pkg/integrations/circleci/get_flaky_tests.go b/pkg/integrations/circleci/get_flaky_tests.go new file mode 100644 index 0000000000..9978f637be --- /dev/null +++ b/pkg/integrations/circleci/get_flaky_tests.go @@ -0,0 +1,87 @@ +package circleci + +import ( + "fmt" + "net/http" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type GetFlakyTests struct{} + +type GetFlakyTestsConfiguration struct { + ProjectSlug string `json:"projectSlug" mapstructure:"projectSlug"` + Branch string `json:"branch" mapstructure:"branch"` +} + +func (c *GetFlakyTests) Name() string { return "circleci.getFlakyTests" } +func (c *GetFlakyTests) Label() string { return "Get Flaky Tests" } +func (c *GetFlakyTests) Description() string { return "Get flaky tests for a project" } +func (c *GetFlakyTests) Documentation() string { + return `Retrieves flaky test data for a CircleCI project.` +} +func (c *GetFlakyTests) Icon() string { return "workflow" } +func (c *GetFlakyTests) Color() string { return "gray" } +func (c *GetFlakyTests) ExampleOutput() map[string]any { return map[string]any{} } +func (c *GetFlakyTests) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} +func (c *GetFlakyTests) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "projectSlug", + Label: "Project slug", + Type: configuration.FieldTypeString, + Required: true, + Description: "CircleCI project slug (e.g. gh/org/repo)", + }, + { + Name: "branch", + Label: "Branch", + Type: configuration.FieldTypeString, + }, + } +} +func (c *GetFlakyTests) Setup(ctx core.SetupContext) error { + var config GetFlakyTestsConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + if config.ProjectSlug == "" { + return fmt.Errorf("projectSlug is required") + } + return nil +} +func (c *GetFlakyTests) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} +func (c *GetFlakyTests) Execute(ctx core.ExecutionContext) error { + var config GetFlakyTestsConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return err + } + filters := map[string]string{} + if config.Branch != "" { + filters["branch"] = config.Branch + } + + result, err := client.GetFlakyTests(config.ProjectSlug, filters) + if err != nil { + return fmt.Errorf("failed to get flaky tests: %w", err) + } + return ctx.ExecutionState.Emit(core.DefaultOutputChannel.Name, "circleci.flaky_tests", []any{result}) +} +func (c *GetFlakyTests) Actions() []core.Action { return []core.Action{} } +func (c *GetFlakyTests) HandleAction(ctx core.ActionContext) error { return nil } +func (c *GetFlakyTests) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return http.StatusOK, nil +} +func (c *GetFlakyTests) Cancel(ctx core.ExecutionContext) error { return nil } +func (c *GetFlakyTests) Cleanup(ctx core.SetupContext) error { return nil } diff --git a/pkg/integrations/circleci/get_last_workflow.go b/pkg/integrations/circleci/get_last_workflow.go new file mode 100644 index 0000000000..aab3bedd48 --- /dev/null +++ b/pkg/integrations/circleci/get_last_workflow.go @@ -0,0 +1,96 @@ +package circleci + +import ( + "fmt" + "net/http" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type GetLastWorkflow struct{} + +type GetLastWorkflowConfiguration struct { + ProjectSlug string `json:"projectSlug" mapstructure:"projectSlug"` + Branch string `json:"branch" mapstructure:"branch"` + Status string `json:"status" mapstructure:"status"` +} + +func (c *GetLastWorkflow) Name() string { return "circleci.getLastWorkflow" } +func (c *GetLastWorkflow) Label() string { return "Get Last Workflow" } +func (c *GetLastWorkflow) Description() string { return "Get the most recent workflow for a project" } +func (c *GetLastWorkflow) Documentation() string { + return `Retrieves the latest workflow for a CircleCI project, optionally filtered by branch or status.` +} +func (c *GetLastWorkflow) Icon() string { return "workflow" } +func (c *GetLastWorkflow) Color() string { return "gray" } +func (c *GetLastWorkflow) ExampleOutput() map[string]any { return map[string]any{} } +func (c *GetLastWorkflow) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} +func (c *GetLastWorkflow) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "projectSlug", + Label: "Project slug", + Type: configuration.FieldTypeString, + Required: true, + Description: "CircleCI project slug (e.g. gh/org/repo)", + }, + { + Name: "branch", + Label: "Branch", + Type: configuration.FieldTypeString, + }, + { + Name: "status", + Label: "Status", + Type: configuration.FieldTypeString, + }, + } +} +func (c *GetLastWorkflow) Setup(ctx core.SetupContext) error { + var config GetLastWorkflowConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + if config.ProjectSlug == "" { + return fmt.Errorf("projectSlug is required") + } + return nil +} +func (c *GetLastWorkflow) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} +func (c *GetLastWorkflow) Execute(ctx core.ExecutionContext) error { + var config GetLastWorkflowConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return err + } + filters := map[string]string{} + if config.Branch != "" { + filters["branch"] = config.Branch + } + if config.Status != "" { + filters["status"] = config.Status + } + + result, err := client.GetLastWorkflow(config.ProjectSlug, filters) + if err != nil { + return fmt.Errorf("failed to get last workflow: %w", err) + } + return ctx.ExecutionState.Emit(core.DefaultOutputChannel.Name, "circleci.workflow", []any{result}) +} +func (c *GetLastWorkflow) Actions() []core.Action { return []core.Action{} } +func (c *GetLastWorkflow) HandleAction(ctx core.ActionContext) error { return nil } +func (c *GetLastWorkflow) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return http.StatusOK, nil +} +func (c *GetLastWorkflow) Cancel(ctx core.ExecutionContext) error { return nil } +func (c *GetLastWorkflow) Cleanup(ctx core.SetupContext) error { return nil } diff --git a/pkg/integrations/circleci/get_recent_workflow_runs.go b/pkg/integrations/circleci/get_recent_workflow_runs.go new file mode 100644 index 0000000000..fd3c7de543 --- /dev/null +++ b/pkg/integrations/circleci/get_recent_workflow_runs.go @@ -0,0 +1,89 @@ +package circleci + +import ( + "fmt" + "net/http" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type GetRecentWorkflowRuns struct{} + +type GetRecentWorkflowRunsConfiguration struct { + ProjectSlug string `json:"projectSlug" mapstructure:"projectSlug"` + Branch string `json:"branch" mapstructure:"branch"` +} + +func (c *GetRecentWorkflowRuns) Name() string { return "circleci.getRecentWorkflowRuns" } +func (c *GetRecentWorkflowRuns) Label() string { return "Get Recent Workflow Runs" } +func (c *GetRecentWorkflowRuns) Description() string { + return "Get workflow run insights for a project" +} +func (c *GetRecentWorkflowRuns) Documentation() string { + return `Retrieves workflow insights for a CircleCI project, including success rate and duration metrics.` +} +func (c *GetRecentWorkflowRuns) Icon() string { return "workflow" } +func (c *GetRecentWorkflowRuns) Color() string { return "gray" } +func (c *GetRecentWorkflowRuns) ExampleOutput() map[string]any { return map[string]any{} } +func (c *GetRecentWorkflowRuns) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} +func (c *GetRecentWorkflowRuns) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "projectSlug", + Label: "Project slug", + Type: configuration.FieldTypeString, + Required: true, + Description: "CircleCI project slug (e.g. gh/org/repo)", + }, + { + Name: "branch", + Label: "Branch", + Type: configuration.FieldTypeString, + }, + } +} +func (c *GetRecentWorkflowRuns) Setup(ctx core.SetupContext) error { + var config GetRecentWorkflowRunsConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + if config.ProjectSlug == "" { + return fmt.Errorf("projectSlug is required") + } + return nil +} +func (c *GetRecentWorkflowRuns) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} +func (c *GetRecentWorkflowRuns) Execute(ctx core.ExecutionContext) error { + var config GetRecentWorkflowRunsConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return err + } + filters := map[string]string{} + if config.Branch != "" { + filters["branch"] = config.Branch + } + + result, err := client.GetWorkflowInsights(config.ProjectSlug, filters) + if err != nil { + return fmt.Errorf("failed to get workflow insights: %w", err) + } + return ctx.ExecutionState.Emit(core.DefaultOutputChannel.Name, "circleci.workflow_insights", []any{result}) +} +func (c *GetRecentWorkflowRuns) Actions() []core.Action { return []core.Action{} } +func (c *GetRecentWorkflowRuns) HandleAction(ctx core.ActionContext) error { return nil } +func (c *GetRecentWorkflowRuns) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return http.StatusOK, nil +} +func (c *GetRecentWorkflowRuns) Cancel(ctx core.ExecutionContext) error { return nil } +func (c *GetRecentWorkflowRuns) Cleanup(ctx core.SetupContext) error { return nil } diff --git a/pkg/integrations/circleci/get_test_metrics.go b/pkg/integrations/circleci/get_test_metrics.go new file mode 100644 index 0000000000..645ec7c2d0 --- /dev/null +++ b/pkg/integrations/circleci/get_test_metrics.go @@ -0,0 +1,95 @@ +package circleci + +import ( + "fmt" + "net/http" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type GetTestMetrics struct{} + +type GetTestMetricsConfiguration struct { + ProjectSlug string `json:"projectSlug" mapstructure:"projectSlug"` + WorkflowName string `json:"workflowName" mapstructure:"workflowName"` + Branch string `json:"branch" mapstructure:"branch"` +} + +func (c *GetTestMetrics) Name() string { return "circleci.getTestMetrics" } +func (c *GetTestMetrics) Label() string { return "Get Test Metrics" } +func (c *GetTestMetrics) Description() string { return "Get test metrics for a workflow" } +func (c *GetTestMetrics) Documentation() string { + return `Retrieves test metrics for a CircleCI workflow, including failure counts and durations.` +} +func (c *GetTestMetrics) Icon() string { return "workflow" } +func (c *GetTestMetrics) Color() string { return "gray" } +func (c *GetTestMetrics) ExampleOutput() map[string]any { return map[string]any{} } +func (c *GetTestMetrics) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} +func (c *GetTestMetrics) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "projectSlug", + Label: "Project slug", + Type: configuration.FieldTypeString, + Required: true, + Description: "CircleCI project slug (e.g. gh/org/repo)", + }, + { + Name: "workflowName", + Label: "Workflow name", + Type: configuration.FieldTypeString, + Required: true, + Description: "Workflow name as shown in CircleCI", + }, + { + Name: "branch", + Label: "Branch", + Type: configuration.FieldTypeString, + }, + } +} +func (c *GetTestMetrics) Setup(ctx core.SetupContext) error { + var config GetTestMetricsConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + if config.ProjectSlug == "" || config.WorkflowName == "" { + return fmt.Errorf("projectSlug and workflowName are required") + } + return nil +} +func (c *GetTestMetrics) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} +func (c *GetTestMetrics) Execute(ctx core.ExecutionContext) error { + var config GetTestMetricsConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return err + } + filters := map[string]string{} + if config.Branch != "" { + filters["branch"] = config.Branch + } + + result, err := client.GetWorkflowTestMetrics(config.ProjectSlug, config.WorkflowName, filters) + if err != nil { + return fmt.Errorf("failed to get test metrics: %w", err) + } + return ctx.ExecutionState.Emit(core.DefaultOutputChannel.Name, "circleci.test_metrics", []any{result}) +} +func (c *GetTestMetrics) Actions() []core.Action { return []core.Action{} } +func (c *GetTestMetrics) HandleAction(ctx core.ActionContext) error { return nil } +func (c *GetTestMetrics) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return http.StatusOK, nil +} +func (c *GetTestMetrics) Cancel(ctx core.ExecutionContext) error { return nil } +func (c *GetTestMetrics) Cleanup(ctx core.SetupContext) error { return nil } diff --git a/pkg/integrations/circleci/get_workflow.go b/pkg/integrations/circleci/get_workflow.go new file mode 100644 index 0000000000..ef0fd128d2 --- /dev/null +++ b/pkg/integrations/circleci/get_workflow.go @@ -0,0 +1,85 @@ +package circleci + +import ( + "fmt" + "net/http" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type GetWorkflow struct{} + +type GetWorkflowConfiguration struct { + WorkflowID string `json:"workflowId" mapstructure:"workflowId"` +} + +func (c *GetWorkflow) Name() string { return "circleci.getWorkflow" } +func (c *GetWorkflow) Label() string { return "Get Workflow" } +func (c *GetWorkflow) Description() string { return "Get CircleCI workflow details and jobs" } +func (c *GetWorkflow) Documentation() string { + return `Retrieves workflow details by ID and includes associated jobs.` +} +func (c *GetWorkflow) Icon() string { return "workflow" } +func (c *GetWorkflow) Color() string { return "gray" } +func (c *GetWorkflow) ExampleOutput() map[string]any { return map[string]any{} } +func (c *GetWorkflow) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} +func (c *GetWorkflow) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "workflowId", + Label: "Workflow ID", + Type: configuration.FieldTypeString, + Required: true, + Description: "CircleCI workflow ID", + }, + } +} +func (c *GetWorkflow) Setup(ctx core.SetupContext) error { + var config GetWorkflowConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + if config.WorkflowID == "" { + return fmt.Errorf("workflowId is required") + } + return nil +} +func (c *GetWorkflow) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} +func (c *GetWorkflow) Execute(ctx core.ExecutionContext) error { + var config GetWorkflowConfiguration + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return err + } + workflow, err := client.GetWorkflow(config.WorkflowID) + if err != nil { + return fmt.Errorf("failed to get workflow: %w", err) + } + jobs, err := client.GetWorkflowJobs(config.WorkflowID) + if err != nil { + return fmt.Errorf("failed to get workflow jobs: %w", err) + } + + output := map[string]any{ + "workflow": workflow, + "jobs": jobs, + } + return ctx.ExecutionState.Emit(core.DefaultOutputChannel.Name, "circleci.workflow", []any{output}) +} +func (c *GetWorkflow) Actions() []core.Action { return []core.Action{} } +func (c *GetWorkflow) HandleAction(ctx core.ActionContext) error { return nil } +func (c *GetWorkflow) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return http.StatusOK, nil +} +func (c *GetWorkflow) Cancel(ctx core.ExecutionContext) error { return nil } +func (c *GetWorkflow) Cleanup(ctx core.SetupContext) error { return nil }