Skip to content
Merged
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
21 changes: 20 additions & 1 deletion cmd/workflow/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ type createRequest struct {
Description string `json:"description,omitempty"`
Nodes []interface{} `json:"nodes"`
Edges []interface{} `json:"edges"`
ProjectID string `json:"projectId,omitempty"`
TagID string `json:"tagId,omitempty"`
}

type createResponse struct {
Expand All @@ -40,7 +42,10 @@ func NewCreateCmd(f *cmdutil.Factory) *cobra.Command {
kh wf create --name "DeFi Monitor" --nodes-file workflow.json

# Create with inline JSON nodes
kh wf create --name "Test" --nodes '[{"id":"t1","type":"trigger","position":{"x":0,"y":0},"data":{"type":"trigger","config":{"triggerType":"Manual"}}}]'`,
kh wf create --name "Test" --nodes '[{"id":"t1","type":"trigger","position":{"x":0,"y":0},"data":{"type":"trigger","config":{"triggerType":"Manual"}}}]'

# Create inside a project and label it with a tag
kh wf create --name "Payouts" --project proj_123 --tag tag_456`,
RunE: func(cmd *cobra.Command, args []string) error {
name, err := cmd.Flags().GetString("name")
if err != nil {
Expand Down Expand Up @@ -70,11 +75,23 @@ func NewCreateCmd(f *cmdutil.Factory) *cobra.Command {
return err
}

project, err := cmd.Flags().GetString("project")
if err != nil {
return err
}

tag, err := cmd.Flags().GetString("tag")
if err != nil {
return err
}

body := createRequest{
Name: name,
Description: description,
Nodes: []interface{}{},
Edges: []interface{}{},
ProjectID: project,
TagID: tag,
}

// Load nodes/edges from file if provided
Expand Down Expand Up @@ -166,6 +183,8 @@ func NewCreateCmd(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().String("nodes-file", "", "Path to JSON file with nodes and edges")
cmd.Flags().String("nodes", "", "Inline JSON array of nodes")
cmd.Flags().String("edges", "", "Inline JSON array of edges")
cmd.Flags().String("project", "", "Project ID to assign the workflow to")
cmd.Flags().String("tag", "", "Tag ID to label the workflow")

return cmd
}
31 changes: 28 additions & 3 deletions cmd/workflow/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"

"github.com/jedib0t/go-pretty/v6/table"
Expand Down Expand Up @@ -41,7 +42,11 @@ func NewListCmd(f *cmdutil.Factory) *cobra.Command {
kh wf ls

# List with a higher limit
kh wf ls --limit 5`,
kh wf ls --limit 5

# List workflows in a project or with a tag
kh wf ls --project proj_123
kh wf ls --tag tag_456`,
RunE: func(cmd *cobra.Command, args []string) error {
client, err := f.HTTPClient()
if err != nil {
Expand All @@ -58,10 +63,28 @@ func NewListCmd(f *cmdutil.Factory) *cobra.Command {
return err
}

project, err := cmd.Flags().GetString("project")
if err != nil {
return err
}

tag, err := cmd.Flags().GetString("tag")
if err != nil {
return err
}

host := cmdutil.ResolveHost(cmd, cfg)
url := khhttp.BuildBaseURL(host) + "/api/workflows?limit=" + strconv.Itoa(limit)
query := url.Values{}
query.Set("limit", strconv.Itoa(limit))
if project != "" {
query.Set("projectId", project)
}
if tag != "" {
query.Set("tagId", tag)
}
reqURL := khhttp.BuildBaseURL(host) + "/api/workflows?" + query.Encode()

req, err := client.NewRequest(http.MethodGet, url, nil)
req, err := client.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
return fmt.Errorf("building request: %w", err)
}
Expand Down Expand Up @@ -110,6 +133,8 @@ func NewListCmd(f *cmdutil.Factory) *cobra.Command {
}

cmd.Flags().Int("limit", 30, "Maximum number of workflows to list")
cmd.Flags().String("project", "", "Filter workflows by project ID")
cmd.Flags().String("tag", "", "Filter workflows by tag ID")

return cmd
}
146 changes: 146 additions & 0 deletions cmd/workflow/project_tag_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package workflow_test

import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"

"github.com/keeperhub/cli/cmd/workflow"
"github.com/keeperhub/cli/pkg/cmdutil"
"github.com/keeperhub/cli/pkg/iostreams"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func runWorkflowSubcmd(f *cmdutil.Factory, args []string) error {
parent := workflow.NewWorkflowCmd(f)
parent.SetArgs(args)
return parent.Execute()
}

func TestCreateSendsProjectAndTag(t *testing.T) {
var body map[string]interface{}
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":"wf-1","name":"P","createdAt":"2026-01-01"}`))
}))
defer svr.Close()

ios, _, _, _ := iostreams.Test()
f := newGoLiveFactory(ios, svr)

err := runWorkflowSubcmd(f, []string{"create", "--name", "P", "--project", "proj_123", "--tag", "tag_456"})

require.NoError(t, err)
assert.Equal(t, "proj_123", body["projectId"])
assert.Equal(t, "tag_456", body["tagId"])
}

func TestCreateOmitsProjectAndTagWhenUnset(t *testing.T) {
var body map[string]interface{}
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":"wf-1","name":"P","createdAt":"2026-01-01"}`))
}))
defer svr.Close()

ios, _, _, _ := iostreams.Test()
f := newGoLiveFactory(ios, svr)

err := runWorkflowSubcmd(f, []string{"create", "--name", "P"})

require.NoError(t, err)
_, hasProject := body["projectId"]
_, hasTag := body["tagId"]
assert.False(t, hasProject, "projectId must be omitted when --project is unset")
assert.False(t, hasTag, "tagId must be omitted when --tag is unset")
}

func TestUpdateAssignsProjectAndTag(t *testing.T) {
var body map[string]interface{}
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":"wf-1"}`))
}))
defer svr.Close()

ios, _, _, _ := iostreams.Test()
f := newGoLiveFactory(ios, svr)

err := runWorkflowSubcmd(f, []string{"update", "wf-1", "--project", "proj_123", "--tag", "tag_456"})

require.NoError(t, err)
assert.Equal(t, "proj_123", body["projectId"])
assert.Equal(t, "tag_456", body["tagId"])
}

func TestUpdateUnassignsWithEmptyValue(t *testing.T) {
var body map[string]interface{}
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":"wf-1"}`))
}))
defer svr.Close()

ios, _, _, _ := iostreams.Test()
f := newGoLiveFactory(ios, svr)

err := runWorkflowSubcmd(f, []string{"update", "wf-1", "--project", ""})

require.NoError(t, err)
val, has := body["projectId"]
assert.True(t, has, "projectId key must be present to send an explicit null")
assert.Nil(t, val, "an empty --project must serialize to JSON null (unassign)")
}

func TestUpdateOmitsProjectAndTagWhenUnset(t *testing.T) {
var body map[string]interface{}
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":"wf-1"}`))
}))
defer svr.Close()

ios, _, _, _ := iostreams.Test()
f := newGoLiveFactory(ios, svr)

err := runWorkflowSubcmd(f, []string{"update", "wf-1", "--name", "Renamed"})

require.NoError(t, err)
_, hasProject := body["projectId"]
_, hasTag := body["tagId"]
assert.False(t, hasProject, "an unrelated update must not touch projectId")
assert.False(t, hasTag, "an unrelated update must not touch tagId")
}

func TestListFiltersByProjectAndTag(t *testing.T) {
var query url.Values
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
query = r.URL.Query()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`[]`))
}))
defer svr.Close()

ios, _, _, _ := iostreams.Test()
f := newGoLiveFactory(ios, svr)

err := runWorkflowSubcmd(f, []string{"list", "--project", "proj_123", "--tag", "tag_456"})

require.NoError(t, err)
assert.Equal(t, "proj_123", query.Get("projectId"))
assert.Equal(t, "tag_456", query.Get("tagId"))
}
50 changes: 39 additions & 11 deletions cmd/workflow/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ import (
"github.com/spf13/cobra"
)

type updateRequest struct {
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Nodes []interface{} `json:"nodes,omitempty"`
Edges []interface{} `json:"edges,omitempty"`
// nullableID returns nil for an empty id so the JSON body carries an
// explicit null (unassign); otherwise it returns the id unchanged.
func nullableID(id string) interface{} {
if id == "" {
return nil
}
return id
}

func NewUpdateCmd(f *cmdutil.Factory) *cobra.Command {
Expand All @@ -30,7 +32,13 @@ func NewUpdateCmd(f *cmdutil.Factory) *cobra.Command {
kh wf update abc123 --name "New Name"

# Update nodes from file
kh wf update abc123 --nodes-file workflow.json`,
kh wf update abc123 --nodes-file workflow.json

# Assign the workflow to a project and tag
kh wf update abc123 --project proj_123 --tag tag_456

# Remove the workflow from its project (pass an empty value)
kh wf update abc123 --project ""`,
RunE: func(cmd *cobra.Command, args []string) error {
workflowID := args[0]

Expand All @@ -47,13 +55,15 @@ func NewUpdateCmd(f *cmdutil.Factory) *cobra.Command {
return err
}

body := updateRequest{}
// A map lets a changed --project/--tag flag send an explicit
// null (unassign) that a struct with omitempty could not express.
body := map[string]interface{}{}

if name != "" {
body.Name = name
body["name"] = name
}
if description != "" {
body.Description = description
body["description"] = description
}

if nodesFile != "" {
Expand All @@ -69,8 +79,24 @@ func NewUpdateCmd(f *cmdutil.Factory) *cobra.Command {
if unmarshalErr := json.Unmarshal(fileData, &fileContent); unmarshalErr != nil {
return fmt.Errorf("parsing nodes file: %w", unmarshalErr)
}
body.Nodes = fileContent.Nodes
body.Edges = fileContent.Edges
body["nodes"] = fileContent.Nodes
body["edges"] = fileContent.Edges
}

if cmd.Flags().Changed("project") {
project, projectErr := cmd.Flags().GetString("project")
if projectErr != nil {
return projectErr
}
body["projectId"] = nullableID(project)
}

if cmd.Flags().Changed("tag") {
tag, tagErr := cmd.Flags().GetString("tag")
if tagErr != nil {
return tagErr
}
body["tagId"] = nullableID(tag)
}

client, err := f.HTTPClient()
Expand Down Expand Up @@ -124,6 +150,8 @@ func NewUpdateCmd(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().String("name", "", "New workflow name")
cmd.Flags().String("description", "", "New workflow description")
cmd.Flags().String("nodes-file", "", "Path to JSON file with nodes and edges")
cmd.Flags().String("project", "", "Project ID to assign (empty value unassigns)")
cmd.Flags().String("tag", "", "Tag ID to assign (empty value unassigns)")

return cmd
}
Loading