Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pkg/integrations/circleci/circleci.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{},
}
}

Expand Down
116 changes: 116 additions & 0 deletions pkg/integrations/circleci/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"

"github.com/superplanehq/superplane/pkg/core"
)
Expand Down Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent pipeline field shape across code paths

Medium Severity

When no pipelines are found, the pipeline field is set to pipelineResp — the full paginated list response (containing items, next_page_token, etc.). When pipelines are found, pipeline is set to firstItem — a single pipeline object (containing id, number, state, etc.). Downstream consumers processing result["pipeline"] will encounter a completely different schema depending on whether pipelines exist, likely causing failures when accessing expected fields like id.

Additional Locations (1)

Fix in Cursor Fix in Web

}

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)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing validation after pipeline ID type assertion

Low Severity

After the type assertion pipelineID, _ := firstItem["id"].(string), the code doesn't check if pipelineID is empty before using it. If the assertion fails or the ID is missing, an empty string gets passed to GetPipelineWorkflows, causing a confusing HTTP error instead of a clear error message. The existing pattern in run_pipeline.go lines 387-390 checks for empty strings after similar type assertions.

Fix in Cursor Fix in Web

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))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Workflow name not URL encoded in path

High Severity

The workflowName parameter is directly interpolated into the URL path without URL encoding. Workflow names containing spaces, slashes, or special characters will result in malformed URLs and API request failures. The url.PathEscape function wraps path parameters elsewhere in the codebase (see AWS Lambda and Dash0 integrations).

Fix in Cursor Fix in Web

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
}
87 changes: 87 additions & 0 deletions pkg/integrations/circleci/get_flaky_tests.go
Original file line number Diff line number Diff line change
@@ -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 }
96 changes: 96 additions & 0 deletions pkg/integrations/circleci/get_last_workflow.go
Original file line number Diff line number Diff line change
@@ -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 }
Loading