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
3 changes: 2 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ func newRootCommand() *cobra.Command {
spinner.Update("Loading user context...")
}
if err := runtimeContext.AttachTenantContext(cmd.Context()); err != nil {
runtimeContext.Logger.Warn().Err(err).Msg("failed to load user context — context.yaml not available")
runtimeContext.Logger.Warn().Err(err).Msg("failed to load user context")
}

// Check if organization is ungated for commands that require it
Expand Down Expand Up @@ -470,6 +470,7 @@ func isLoadSettings(cmd *cobra.Command) bool {
"cre workflow limits": {},
"cre workflow limits export": {},
"cre workflow build": {},
"cre workflow list": {},
"cre account": {},
"cre secrets": {},
"cre templates": {},
Expand Down
99 changes: 99 additions & 0 deletions cmd/workflow/list/list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package list

import (
"context"
"fmt"

"github.com/spf13/cobra"

"github.com/smartcontractkit/cre-cli/internal/client/graphqlclient"
"github.com/smartcontractkit/cre-cli/internal/credentials"
"github.com/smartcontractkit/cre-cli/internal/runtime"
"github.com/smartcontractkit/cre-cli/internal/tenantctx"
"github.com/smartcontractkit/cre-cli/internal/ui"
)

// Handler loads workflows via the list client and prints them.
type Handler struct {
credentials *credentials.Credentials
tenantCtx *tenantctx.EnvironmentContext
gql Executor
}

// NewHandler builds a handler with the real GraphQL executor.
func NewHandler(ctx *runtime.Context) *Handler {
return NewHandlerWithClient(ctx, nil)
}

// NewHandlerWithClient builds a handler with an optional GraphQL executor (nil uses graphqlclient.New).
func NewHandlerWithClient(ctx *runtime.Context, gql Executor) *Handler {
if gql == nil {
gql = graphqlclient.New(ctx.Credentials, ctx.EnvironmentSet, ctx.Logger)
}
Comment on lines +30 to +32
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Both storage and privateregistry clients have gql as their dependency. I think it would be good to have the same consistent way to structure such clients.

return &Handler{
credentials: ctx.Credentials,
tenantCtx: ctx.TenantContext,
gql: gql,
}
}

// Execute lists workflows, optionally filtering by registry ID from user context.
// Deleted workflows are omitted unless includeDeleted is true.
func (h *Handler) Execute(ctx context.Context, registryFilter string, includeDeleted bool) error {
if h.tenantCtx == nil {
return fmt.Errorf("user context not available — run `cre login` and retry")
}

if h.credentials == nil {
return fmt.Errorf("credentials not available — run `cre login` and retry")
}

if registryFilter != "" {
if findRegistry(h.tenantCtx.Registries, registryFilter) == nil {
return fmt.Errorf("registry %q not found in user context; available: [%s]",
registryFilter, availableRegistryIDs(h.tenantCtx.Registries))
}
}

spinner := ui.NewSpinner()
spinner.Start("Listing workflows...")
rows, err := ListAll(ctx, h.gql, DefaultPageSize)
spinner.Stop()
if err != nil {
return err
}

if registryFilter != "" {
reg := findRegistry(h.tenantCtx.Registries, registryFilter)
rows = filterRowsByRegistry(rows, reg, h.tenantCtx.Registries)
}

afterRegistryFilter := len(rows)
if !includeDeleted {
rows = omitDeleted(rows)
}

printWorkflowTable(rows, h.tenantCtx.Registries, afterRegistryFilter, includeDeleted)
return nil
}

// New returns the cobra command.
func New(runtimeContext *runtime.Context) *cobra.Command {
var registryID string
var includeDeleted bool

cmd := &cobra.Command{
Use: "list",
Short: "Lists workflows deployed for your organization",
Long: `Lists workflows across registries in your organization. Requires authentication and user context. Deleted workflows are hidden by default.`,
Example: "cre workflow list\n cre workflow list --registry private\n cre workflow list --include-deleted",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return NewHandler(runtimeContext).Execute(cmd.Context(), registryID, includeDeleted)
},
}

cmd.Flags().StringVar(&registryID, "registry", "", "Filter by registry ID from user context")
cmd.Flags().BoolVar(&includeDeleted, "include-deleted", false, "Include workflows in DELETED status")
return cmd
}
100 changes: 100 additions & 0 deletions cmd/workflow/list/list_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package list

import (
"context"
"fmt"

"github.com/machinebox/graphql"
)

const DefaultPageSize = 100

// Workflow is a workflow row from the platform list API, decoupled from transport JSON.
type Workflow struct {
Name string
WorkflowID string
OwnerAddress string
Status string
WorkflowSource string
}

const listWorkflowsQuery = `
query ListWorkflows($input: WorkflowsInput!) {
workflows(input: $input) {
data {
name
workflowId
ownerAddress
status
workflowSource
}
count
}
}
`

// Executor runs a GraphQL request (e.g. graphqlclient.Client).
type Executor interface {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think more natural name would be something like WorkflowDataClient. Also, it could be externalized simialr as privateregistryclient or storageclient. In the future we might want to replace it with CRE SDK, to avoid writing gql queries manually.

Execute(ctx context.Context, req *graphql.Request, resp any) error
}

type gqlWorkflow struct {
Name string `json:"name"`
WorkflowID string `json:"workflowId"`
OwnerAddress string `json:"ownerAddress"`
Status string `json:"status"`
WorkflowSource string `json:"workflowSource"`
}

type listWorkflowsEnvelope struct {
Workflows struct {
Data []gqlWorkflow `json:"data"`
Count int `json:"count"`
} `json:"workflows"`
}

// ListAll pages through ListWorkflows and returns the aggregated workflows.
func ListAll(ctx context.Context, exec Executor, pageSize int) ([]Workflow, error) {
if pageSize <= 0 {
pageSize = DefaultPageSize
}

var total int
all := make([]Workflow, 0)

for pageNum := 0; ; pageNum++ {
req := graphql.NewRequest(listWorkflowsQuery)
req.Var("input", map[string]any{
"page": map[string]any{
"number": pageNum,
"size": pageSize,
},
})

var env listWorkflowsEnvelope
if err := exec.Execute(ctx, req, &env); err != nil {
return nil, fmt.Errorf("list workflows: %w", err)
}

if pageNum == 0 {
total = env.Workflows.Count
}

batch := env.Workflows.Data
for _, g := range batch {
all = append(all, Workflow{

Check failure on line 85 in cmd/workflow/list/list_client.go

View workflow job for this annotation

GitHub Actions / ci-lint

S1016: should convert g (type gqlWorkflow) to Workflow instead of using struct literal (staticcheck)
Name: g.Name,
WorkflowID: g.WorkflowID,
OwnerAddress: g.OwnerAddress,
Status: g.Status,
WorkflowSource: g.WorkflowSource,
})
}

if len(all) >= total || len(batch) == 0 {
break
}
}

return all, nil
}
83 changes: 83 additions & 0 deletions cmd/workflow/list/list_client_test.go
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What about using httptest.NewServer for those client tests? We use it in other clients.

Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package list_test

import (
"context"
"encoding/json"
"testing"

"github.com/machinebox/graphql"

cmdlist "github.com/smartcontractkit/cre-cli/cmd/workflow/list"
)

type listAllSeqExecutor struct {
call int
}

func (s *listAllSeqExecutor) Execute(ctx context.Context, req *graphql.Request, resp any) error {
s.call++
var body []byte
var err error
switch s.call {
case 1:
data := make([]map[string]string, cmdlist.DefaultPageSize)
for i := range data {
data[i] = map[string]string{
"name": "mock-wf-page",
"workflowId": "a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0",
"ownerAddress": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0",
"status": "ACTIVE",
"workflowSource": "contract:77766655544433322211:0xfeedface00000000000000000000000000c0ffee",
}
}
body, err = json.Marshal(map[string]any{
"workflows": map[string]any{
"count": cmdlist.DefaultPageSize + 1,
"data": data,
},
})
case 2:
body, err = json.Marshal(map[string]any{
"workflows": map[string]any{
"count": cmdlist.DefaultPageSize + 1,
"data": []map[string]string{
{
"name": "mock-wf-last",
"workflowId": "c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0",
"ownerAddress": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0",
"status": "ACTIVE",
"workflowSource": "private",
},
},
},
})
default:
body, err = json.Marshal(map[string]any{
"workflows": map[string]any{"count": cmdlist.DefaultPageSize + 1, "data": []any{}},
})
}
if err != nil {
return err
}
return json.Unmarshal(body, resp)
}

func TestListAll_PaginatesAndMapsRows(t *testing.T) {
ex := &listAllSeqExecutor{}
got, err := cmdlist.ListAll(context.Background(), ex, cmdlist.DefaultPageSize)
if err != nil {
t.Fatal(err)
}
if len(got) != cmdlist.DefaultPageSize+1 {
t.Fatalf("got %d workflows, want %d", len(got), cmdlist.DefaultPageSize+1)
}
if got[0].WorkflowSource != "contract:77766655544433322211:0xfeedface00000000000000000000000000c0ffee" {
t.Errorf("first row source: %q", got[0].WorkflowSource)
}
if got[len(got)-1].Name != "mock-wf-last" || got[len(got)-1].WorkflowSource != "private" {
t.Errorf("last row: %+v", got[len(got)-1])
}
if ex.call != 2 {
t.Errorf("executor calls = %d, want 2", ex.call)
}
}
Loading
Loading