From 19320a8fd56e72ae5ae4cf893540cdc14b71c39a Mon Sep 17 00:00:00 2001 From: joelorzet Date: Fri, 24 Jul 2026 10:11:47 -0300 Subject: [PATCH] feat: link workflows to projects and tags kh project and kh tag could already create and list projects and tags, but kh workflow create/update had no way to associate a workflow with one, so workflows could not be organized into projects or labeled from the CLI. Add --project and --tag to workflow create and workflow update; on update an empty value sends an explicit null so the assignment is cleared, while an untouched flag is omitted. Add --project and --tag filters to workflow list. The update body is now a map so null (unassign) and omit are distinguishable. Covered by tests for the assign, unassign, and omit paths. --- cmd/workflow/create.go | 21 ++++- cmd/workflow/list.go | 31 ++++++- cmd/workflow/project_tag_test.go | 146 +++++++++++++++++++++++++++++++ cmd/workflow/update.go | 50 ++++++++--- 4 files changed, 233 insertions(+), 15 deletions(-) create mode 100644 cmd/workflow/project_tag_test.go diff --git a/cmd/workflow/create.go b/cmd/workflow/create.go index 28cbe12..bdf0471 100644 --- a/cmd/workflow/create.go +++ b/cmd/workflow/create.go @@ -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 { @@ -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 { @@ -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 @@ -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 } diff --git a/cmd/workflow/list.go b/cmd/workflow/list.go index d4abbb5..34accf9 100644 --- a/cmd/workflow/list.go +++ b/cmd/workflow/list.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "strconv" "github.com/jedib0t/go-pretty/v6/table" @@ -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 { @@ -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) } @@ -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 } diff --git a/cmd/workflow/project_tag_test.go b/cmd/workflow/project_tag_test.go new file mode 100644 index 0000000..2094cd6 --- /dev/null +++ b/cmd/workflow/project_tag_test.go @@ -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")) +} diff --git a/cmd/workflow/update.go b/cmd/workflow/update.go index d5532cb..5ddeb49 100644 --- a/cmd/workflow/update.go +++ b/cmd/workflow/update.go @@ -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 { @@ -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] @@ -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 != "" { @@ -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() @@ -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 }