-
Notifications
You must be signed in to change notification settings - Fork 106
feat(circleci): add read/workflow insights components #3235
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing validation after pipeline ID type assertionLow Severity After the type assertion |
||
| 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)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Workflow name not URL encoded in pathHigh Severity The |
||
| 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 | ||
| } | ||
| 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 } |
| 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 } |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Inconsistent
pipelinefield shape across code pathsMedium Severity
When no pipelines are found, the
pipelinefield is set topipelineResp— the full paginated list response (containingitems,next_page_token, etc.). When pipelines are found,pipelineis set tofirstItem— a single pipeline object (containingid,number,state, etc.). Downstream consumers processingresult["pipeline"]will encounter a completely different schema depending on whether pipelines exist, likely causing failures when accessing expected fields likeid.Additional Locations (1)
pkg/integrations/circleci/client.go#L454-L458