From 0ca188b605f4ae61861d760f6548cabb2495bf67 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 13:52:15 -0700 Subject: [PATCH 01/25] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20sec(deps):=20Bump=20?= =?UTF-8?q?golang.org/x/sys=20from=200.44.0=20to=200.45.0=20(#522)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index c1caaf9bd..3b6a1baed 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/ulikunitz/xz v0.5.15 - golang.org/x/sys v0.44.0 + golang.org/x/sys v0.45.0 golang.org/x/term v0.43.0 golang.org/x/text v0.37.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 4c64e45d3..87a207685 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,4 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= -github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= @@ -188,8 +187,8 @@ golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= From a33a43f5b4748b7a614b963068f0c9625452b6c0 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Tue, 28 Apr 2026 08:46:38 -0400 Subject: [PATCH 02/25] add pipelines command and its subcommands Co-authored-by: Copilot --- cmd/pipelines/cmd.go | 47 +++++ cmd/pipelines/cmd_test.go | 60 ++++++ cmd/pipelines/create/cmd.go | 106 ++++++++++ cmd/pipelines/create/cmd_test.go | 148 +++++++++++++ cmd/pipelines/get/cmd.go | 137 ++++++++++++ cmd/pipelines/get/cmd_test.go | 171 +++++++++++++++ cmd/pipelines/list/cmd.go | 123 +++++++++++ cmd/pipelines/list/cmd_test.go | 168 +++++++++++++++ cmd/pipelines/update/cmd.go | 96 +++++++++ cmd/pipelines/update/cmd_test.go | 130 ++++++++++++ cmd/root.go | 2 + internal/drapi/get.go | 15 +- internal/pipelines/pipeline.go | 351 +++++++++++++++++++++++++++++++ 13 files changed, 1553 insertions(+), 1 deletion(-) create mode 100644 cmd/pipelines/cmd.go create mode 100644 cmd/pipelines/cmd_test.go create mode 100644 cmd/pipelines/create/cmd.go create mode 100644 cmd/pipelines/create/cmd_test.go create mode 100644 cmd/pipelines/get/cmd.go create mode 100644 cmd/pipelines/get/cmd_test.go create mode 100644 cmd/pipelines/list/cmd.go create mode 100644 cmd/pipelines/list/cmd_test.go create mode 100644 cmd/pipelines/update/cmd.go create mode 100644 cmd/pipelines/update/cmd_test.go create mode 100644 internal/pipelines/pipeline.go diff --git a/cmd/pipelines/cmd.go b/cmd/pipelines/cmd.go new file mode 100644 index 000000000..35e6b25fd --- /dev/null +++ b/cmd/pipelines/cmd.go @@ -0,0 +1,47 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipelines + +import ( + "github.com/datarobot/cli/cmd/pipelines/create" + "github.com/datarobot/cli/cmd/pipelines/get" + "github.com/datarobot/cli/cmd/pipelines/list" + "github.com/datarobot/cli/cmd/pipelines/update" + "github.com/datarobot/cli/internal/features" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "pipelines", + GroupID: "core", + Short: "Pipelines API management commands", + Long: `Manage AI/ML pipelines orchestrated by Covalent. + +Create, list, inspect, and update pipelines registered with the +DataRobot pipelines service.`, + } + + features.SetGate(cmd, "pipelines") + + cmd.AddCommand( + create.Cmd(), + get.Cmd(), + list.Cmd(), + update.Cmd(), + ) + + return cmd +} diff --git a/cmd/pipelines/cmd_test.go b/cmd/pipelines/cmd_test.go new file mode 100644 index 000000000..9b737ccb2 --- /dev/null +++ b/cmd/pipelines/cmd_test.go @@ -0,0 +1,60 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipelines + +import ( + "testing" + + "github.com/datarobot/cli/internal/features" + "github.com/stretchr/testify/assert" +) + +func TestCmd_BasicMetadata(t *testing.T) { + cmd := Cmd() + + assert.Equal(t, "pipelines", cmd.Use) + assert.Equal(t, "core", cmd.GroupID) + assert.NotEmpty(t, cmd.Short) + assert.NotEmpty(t, cmd.Long) +} + +func TestCmd_FeatureGate(t *testing.T) { + cmd := Cmd() + + gate, ok := cmd.Annotations[features.AnnotationKey] + assert.True(t, ok, "expected feature-gate annotation to be set") + assert.Equal(t, "pipelines", gate) +} + +func TestCmd_HasExpectedSubcommands(t *testing.T) { + cmd := Cmd() + + want := map[string]bool{ + "create": false, + "list": false, + "get": false, + "update": false, + } + + for _, sub := range cmd.Commands() { + if _, ok := want[sub.Name()]; ok { + want[sub.Name()] = true + } + } + + for name, found := range want { + assert.True(t, found, "expected subcommand %q to be registered", name) + } +} diff --git a/cmd/pipelines/create/cmd.go b/cmd/pipelines/create/cmd.go new file mode 100644 index 000000000..f174bd737 --- /dev/null +++ b/cmd/pipelines/create/cmd.go @@ -0,0 +1,106 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package create + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + description string + mode string + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "create ", + Short: "Upload a Python file to create a pipeline.", + Long: `Upload a Python file containing a Covalent lattice to register a new pipeline. + +The lattice name is extracted from the file and used as the pipeline name. +By default, output is human-readable. Use --output json for machine-parseable output. + +Example: + dr pipelines create ./my_pipeline.py + dr pipelines create ./my_pipeline.py --description "First draft" --mode draft + dr pipelines create ./my_pipeline.py --output json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + RunE: func(_ *cobra.Command, args []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if mode != "" && mode != pipelines.ModeDraft && mode != pipelines.ModeLocked { + return fmt.Errorf("invalid mode: %s (supported: draft, locked)", mode) + } + + result, err := pipelines.CreatePipeline(args[0], description, mode) + if err != nil { + return err + } + + if outputFormat == "json" { + return printCreateJSON(*result) + } + + printCreateHuman(*result) + + return nil + }, + } + + cmd.Flags().StringVar(&description, "description", "", "Optional description for the pipeline") + cmd.Flags().StringVar(&mode, "mode", "", "Pipeline mode: draft (default) or locked") + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} + +func printCreateJSON(result pipelines.CreateResponse) error { + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +func printCreateHuman(result pipelines.CreateResponse) { + electrons := "\u2014" + if len(result.ElectronNames) > 0 { + electrons = strings.Join(result.ElectronNames, ", ") + } + + fmt.Println(tui.BaseTextStyle.Render("Pipeline: " + result.PipelineID)) + fmt.Println(tui.BaseTextStyle.Render("Name: " + result.Name)) + fmt.Println(tui.BaseTextStyle.Render("Version: " + strconv.Itoa(result.Version))) + fmt.Println(tui.BaseTextStyle.Render("Status: " + result.Status)) + fmt.Println(tui.BaseTextStyle.Render("Mode: " + result.Mode)) + fmt.Println(tui.BaseTextStyle.Render("Electrons: " + electrons)) + fmt.Println(tui.DimStyle.Render("Created: " + result.CreatedAt.UTC().Format(time.RFC3339))) +} diff --git a/cmd/pipelines/create/cmd_test.go b/cmd/pipelines/create/cmd_test.go new file mode 100644 index 000000000..d0bb41104 --- /dev/null +++ b/cmd/pipelines/create/cmd_test.go @@ -0,0 +1,148 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package create + +import ( + "bytes" + "encoding/json" + "io" + "os" + "testing" + "time" + + "github.com/datarobot/cli/internal/pipelines" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + w.Close() + + os.Stdout = old + + var buf bytes.Buffer + + _, _ = io.Copy(&buf, r) + + return buf.String() +} + +func sampleCreateResponse() pipelines.CreateResponse { + return pipelines.CreateResponse{ + PipelineID: "6658f441-a8f5-4f21-b4d8-6cccf4c94c5b", + Name: "confluence_to_vdb", + Version: 1, + Status: "READY", + Mode: "draft", + ElectronNames: []string{"create_vector_database", "ingest_confluence_files"}, + CreatedAt: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC), + } +} + +func TestPrintCreateJSON(t *testing.T) { + resp := sampleCreateResponse() + + output := captureStdout(t, func() { + err := printCreateJSON(resp) + require.NoError(t, err) + }) + + var parsed map[string]interface{} + + err := json.Unmarshal([]byte(output), &parsed) + require.NoError(t, err) + assert.Equal(t, resp.PipelineID, parsed["pipeline_id"]) + assert.Equal(t, resp.Name, parsed["name"]) + assert.Equal(t, "READY", parsed["status"]) + assert.Equal(t, "draft", parsed["mode"]) + assert.EqualValues(t, 1, parsed["version"]) +} + +func TestPrintCreateHuman_WithElectrons(t *testing.T) { + resp := sampleCreateResponse() + + output := captureStdout(t, func() { + printCreateHuman(resp) + }) + + assert.Contains(t, output, resp.PipelineID) + assert.Contains(t, output, "confluence_to_vdb") + assert.Contains(t, output, "Version: 1") + assert.Contains(t, output, "Status: READY") + assert.Contains(t, output, "Mode: draft") + assert.Contains(t, output, "create_vector_database, ingest_confluence_files") +} + +func TestPrintCreateHuman_NoElectrons(t *testing.T) { + resp := sampleCreateResponse() + resp.ElectronNames = nil + + output := captureStdout(t, func() { + printCreateHuman(resp) + }) + + assert.Contains(t, output, "Electrons: \u2014") +} + +func TestCmd_RequiresArg(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err := cmd.Execute() + require.Error(t, err) +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"some-file.py", "--output", "yaml"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil // bypass auth + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsInvalidMode(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"some-file.py", "--mode", "bogus"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil // bypass auth + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid mode") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"description", "mode", "output"} { + flag := cmd.Flags().Lookup(name) + assert.NotNilf(t, flag, "expected --%s flag to be registered", name) + } +} diff --git a/cmd/pipelines/get/cmd.go b/cmd/pipelines/get/cmd.go new file mode 100644 index 000000000..fbe58beeb --- /dev/null +++ b/cmd/pipelines/get/cmd.go @@ -0,0 +1,137 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package get + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var outputFormat string + + cmd := &cobra.Command{ + Use: "get ", + Short: "Display details of a pipeline.", + Long: `Display full details of a pipeline including all versions. + +By default, output is human-readable. Use --output json for machine-parseable output. + +Example: + dr pipelines get 8a8d6e5e-1234-5678-90ab-cdef01234567 + dr pipelines get 8a8d6e5e-1234-5678-90ab-cdef01234567 --output json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + RunE: func(_ *cobra.Command, args []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + pipeline, err := pipelines.GetPipeline(args[0]) + if err != nil { + return err + } + + if outputFormat == "json" { + return printGetJSON(*pipeline) + } + + printGetHuman(*pipeline) + + return nil + }, + } + + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} + +func printGetJSON(pipeline pipelines.Pipeline) error { + data, err := json.MarshalIndent(pipeline, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +func printGetHuman(pipeline pipelines.Pipeline) { + description := "\u2014" + if pipeline.Description != "" { + description = pipeline.Description + } + + fmt.Println(tui.BaseTextStyle.Render("ID: " + pipeline.PipelineID)) + fmt.Println(tui.BaseTextStyle.Render("Name: " + pipeline.Name)) + fmt.Println(tui.BaseTextStyle.Render("Description: " + description)) + fmt.Println(tui.BaseTextStyle.Render("Mode: " + pipeline.Mode)) + fmt.Println(tui.BaseTextStyle.Render(fmt.Sprintf("Active: %t", pipeline.IsActive))) + fmt.Println(tui.DimStyle.Render("Created: " + pipeline.CreatedAt.UTC().Format(time.RFC3339))) + fmt.Println(tui.DimStyle.Render("Updated: " + pipeline.UpdatedAt.UTC().Format(time.RFC3339))) + + if len(pipeline.Versions) == 0 { + return + } + + fmt.Println() + fmt.Println(tui.BaseTextStyle.Render(fmt.Sprintf("Versions (%d):", len(pipeline.Versions)))) + + writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintln(writer, " VERSION\tSTATUS\tPYTHON\tCREATED\tELECTRONS") + + for _, version := range pipeline.Versions { + electrons := "\u2014" + if len(version.ElectronNames) > 0 { + electrons = strings.Join(version.ElectronNames, ", ") + } + + python := version.PythonVersion + if python == "" { + python = "\u2014" + } + + fmt.Fprintf(writer, " v%s\t%s\t%s\t%s\t%s\n", + strconv.Itoa(version.Version), + version.Status, + python, + version.CreatedAt.UTC().Format(time.RFC3339), + electrons, + ) + } + + _ = writer.Flush() + + for _, version := range pipeline.Versions { + if version.ErrorDetail == "" { + continue + } + + fmt.Println(tui.DimStyle.Render(fmt.Sprintf(" v%d error: %s", version.Version, version.ErrorDetail))) + } +} diff --git a/cmd/pipelines/get/cmd_test.go b/cmd/pipelines/get/cmd_test.go new file mode 100644 index 000000000..ef1919788 --- /dev/null +++ b/cmd/pipelines/get/cmd_test.go @@ -0,0 +1,171 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package get + +import ( + "bytes" + "encoding/json" + "io" + "os" + "testing" + "time" + + "github.com/datarobot/cli/internal/pipelines" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + w.Close() + + os.Stdout = old + + var buf bytes.Buffer + + _, _ = io.Copy(&buf, r) + + return buf.String() +} + +func samplePipeline() pipelines.Pipeline { + return pipelines.Pipeline{ + PipelineID: "6658f441-a8f5-4f21-b4d8-6cccf4c94c5b", + Name: "confluence_to_vdb", + Description: "test", + Mode: "draft", + IsActive: true, + CreatedAt: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 28, 12, 25, 11, 0, time.UTC), + Versions: []pipelines.PipelineVersion{ + { + Version: 1, + Status: "READY", + LatticeName: "confluence_to_vdb", + ElectronNames: []string{"create_vector_database", "ingest_confluence_files"}, + PythonVersion: "3.12", + CreatedAt: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC), + }, + { + Version: 2, + Status: "FAILED", + LatticeName: "confluence_to_vdb", + PythonVersion: "3.12", + ErrorDetail: "boom", + CreatedAt: time.Date(2026, 4, 28, 12, 25, 11, 0, time.UTC), + }, + }, + } +} + +func TestPrintGetJSON(t *testing.T) { + pipeline := samplePipeline() + + output := captureStdout(t, func() { + err := printGetJSON(pipeline) + require.NoError(t, err) + }) + + var parsed map[string]interface{} + + err := json.Unmarshal([]byte(output), &parsed) + require.NoError(t, err) + assert.Equal(t, pipeline.PipelineID, parsed["pipeline_id"]) + assert.Equal(t, "confluence_to_vdb", parsed["name"]) + + versions, ok := parsed["versions"].([]interface{}) + require.True(t, ok) + require.Len(t, versions, 2) +} + +func TestPrintGetHuman_RendersHeaderAndVersions(t *testing.T) { + pipeline := samplePipeline() + + output := captureStdout(t, func() { + printGetHuman(pipeline) + }) + + assert.Contains(t, output, "ID: "+pipeline.PipelineID) + assert.Contains(t, output, "Name: confluence_to_vdb") + assert.Contains(t, output, "Description: test") + assert.Contains(t, output, "Mode: draft") + assert.Contains(t, output, "Active: true") + assert.Contains(t, output, "Versions (2):") + assert.Contains(t, output, "VERSION") + assert.Contains(t, output, "STATUS") + assert.Contains(t, output, "PYTHON") + assert.Contains(t, output, "CREATED") + assert.Contains(t, output, "ELECTRONS") + assert.Contains(t, output, "v1") + assert.Contains(t, output, "v2") + assert.Contains(t, output, "create_vector_database, ingest_confluence_files") + assert.Contains(t, output, "v2 error: boom") +} + +func TestPrintGetHuman_BlankDescriptionFallsBack(t *testing.T) { + pipeline := samplePipeline() + pipeline.Description = "" + + output := captureStdout(t, func() { + printGetHuman(pipeline) + }) + + assert.Contains(t, output, "Description: \u2014") +} + +func TestPrintGetHuman_NoVersions(t *testing.T) { + pipeline := samplePipeline() + pipeline.Versions = nil + + output := captureStdout(t, func() { + printGetHuman(pipeline) + }) + + assert.NotContains(t, output, "Versions (") +} + +func TestCmd_RequiresArg(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err := cmd.Execute() + require.Error(t, err) +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"some-id", "--output", "yaml"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_HasOutputFlag(t *testing.T) { + cmd := Cmd() + assert.NotNil(t, cmd.Flags().Lookup("output")) +} diff --git a/cmd/pipelines/list/cmd.go b/cmd/pipelines/list/cmd.go new file mode 100644 index 000000000..7be45e2f1 --- /dev/null +++ b/cmd/pipelines/list/cmd.go @@ -0,0 +1,123 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package list + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "text/tabwriter" + "time" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + mode string + offset int + limit int + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List pipelines.", + Long: `List pipelines registered with the pipelines service. + +By default, output is human-readable. Use --output json for machine-parseable output. + +Example: + dr pipelines list + dr pipelines list --mode draft + dr pipelines list --offset 0 --limit 50 --output json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + RunE: func(_ *cobra.Command, _ []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if mode != "" && mode != pipelines.ModeDraft && mode != pipelines.ModeLocked { + return fmt.Errorf("invalid mode: %s (supported: draft, locked)", mode) + } + + list, err := pipelines.ListPipelines(mode, offset, limit) + if err != nil { + return err + } + + if outputFormat == "json" { + return printListJSON(*list) + } + + printListHuman(*list) + + return nil + }, + } + + cmd.Flags().StringVar(&mode, "mode", "", "Filter by mode: draft or locked") + cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") + cmd.Flags().IntVar(&limit, "limit", 50, "Pagination limit (1-200)") + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} + +func printListJSON(list pipelines.ListResponse) error { + data, err := json.MarshalIndent(list, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +func printListHuman(list pipelines.ListResponse) { + if len(list.Items) == 0 { + fmt.Println(tui.DimStyle.Render("No pipelines found.")) + + return + } + + fmt.Println(tui.BaseTextStyle.Render(fmt.Sprintf("Showing %d of %d (offset=%d limit=%d)", len(list.Items), list.Total, list.Offset, list.Limit))) + fmt.Println() + + writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintln(writer, "ID\tNAME\tMODE\tACTIVE\tVERSION\tUPDATED") + + for _, item := range list.Items { + latest := "\u2014" + if item.LatestVersion != nil { + latest = "v" + strconv.Itoa(*item.LatestVersion) + } + + updated := item.UpdatedAt.UTC().Format(time.RFC3339) + active := strconv.FormatBool(item.IsActive) + + fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\t%s\n", + item.PipelineID, item.Name, item.Mode, active, latest, updated) + } + + _ = writer.Flush() +} diff --git a/cmd/pipelines/list/cmd_test.go b/cmd/pipelines/list/cmd_test.go new file mode 100644 index 000000000..44f3d4f75 --- /dev/null +++ b/cmd/pipelines/list/cmd_test.go @@ -0,0 +1,168 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package list + +import ( + "bytes" + "encoding/json" + "io" + "os" + "testing" + "time" + + "github.com/datarobot/cli/internal/pipelines" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + w.Close() + + os.Stdout = old + + var buf bytes.Buffer + + _, _ = io.Copy(&buf, r) + + return buf.String() +} + +func intPtr(v int) *int { + return &v +} + +func sampleListResponse() pipelines.ListResponse { + return pipelines.ListResponse{ + Items: []pipelines.ListItem{ + { + PipelineID: "6658f441-a8f5-4f21-b4d8-6cccf4c94c5b", + Name: "confluence_to_vdb", + Mode: "draft", + IsActive: true, + LatestVersion: intPtr(3), + CreatedAt: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 28, 12, 25, 11, 0, time.UTC), + }, + }, + Total: 1, + Offset: 0, + Limit: 50, + } +} + +func TestPrintListJSON(t *testing.T) { + list := sampleListResponse() + + output := captureStdout(t, func() { + err := printListJSON(list) + require.NoError(t, err) + }) + + var parsed map[string]interface{} + + err := json.Unmarshal([]byte(output), &parsed) + require.NoError(t, err) + assert.EqualValues(t, 1, parsed["total"]) + + items, ok := parsed["items"].([]interface{}) + require.True(t, ok) + require.Len(t, items, 1) + + item := items[0].(map[string]interface{}) + assert.Equal(t, "confluence_to_vdb", item["name"]) + assert.Equal(t, "draft", item["mode"]) +} + +func TestPrintListHuman_Empty(t *testing.T) { + output := captureStdout(t, func() { + printListHuman(pipelines.ListResponse{Total: 0, Limit: 50}) + }) + + assert.Contains(t, output, "No pipelines found.") +} + +func TestPrintListHuman_RendersHeaderAndRow(t *testing.T) { + list := sampleListResponse() + + output := captureStdout(t, func() { + printListHuman(list) + }) + + assert.Contains(t, output, "Showing 1 of 1") + assert.Contains(t, output, "ID") + assert.Contains(t, output, "NAME") + assert.Contains(t, output, "MODE") + assert.Contains(t, output, "ACTIVE") + assert.Contains(t, output, "VERSION") + assert.Contains(t, output, "UPDATED") + assert.Contains(t, output, "6658f441-a8f5-4f21-b4d8-6cccf4c94c5b") + assert.Contains(t, output, "confluence_to_vdb") + assert.Contains(t, output, "draft") + assert.Contains(t, output, "true") + assert.Contains(t, output, "v3") + assert.Contains(t, output, "2026-04-28T12:25:11Z") +} + +func TestPrintListHuman_NoLatestVersion(t *testing.T) { + list := sampleListResponse() + list.Items[0].LatestVersion = nil + + output := captureStdout(t, func() { + printListHuman(list) + }) + + assert.Contains(t, output, "\u2014") +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"--output", "yaml"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsInvalidMode(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"--mode", "bogus"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid mode") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"mode", "offset", "limit", "output"} { + flag := cmd.Flags().Lookup(name) + assert.NotNilf(t, flag, "expected --%s flag to be registered", name) + } +} diff --git a/cmd/pipelines/update/cmd.go b/cmd/pipelines/update/cmd.go new file mode 100644 index 000000000..95bc08436 --- /dev/null +++ b/cmd/pipelines/update/cmd.go @@ -0,0 +1,96 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package update + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var outputFormat string + + cmd := &cobra.Command{ + Use: "update ", + Short: "Re-upload a Python file to update a draft pipeline.", + Long: `Update an existing draft pipeline by re-uploading a Python file. + +A new version is appended to the pipeline. The lattice name in the uploaded +file must match the existing pipeline name. Locked pipelines cannot be updated. + +By default, output is human-readable. Use --output json for machine-parseable output. + +Example: + dr pipelines update 8a8d6e5e-1234-5678-90ab-cdef01234567 ./my_pipeline.py + dr pipelines update 8a8d6e5e-1234-5678-90ab-cdef01234567 ./my_pipeline.py --output json`, + Args: cobra.ExactArgs(2), + PreRunE: auth.EnsureAuthenticatedE, + RunE: func(_ *cobra.Command, args []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + result, err := pipelines.UpdatePipeline(args[0], args[1]) + if err != nil { + return err + } + + if outputFormat == "json" { + return printUpdateJSON(*result) + } + + printUpdateHuman(*result) + + return nil + }, + } + + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} + +func printUpdateJSON(result pipelines.CreateResponse) error { + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +func printUpdateHuman(result pipelines.CreateResponse) { + electrons := "\u2014" + if len(result.ElectronNames) > 0 { + electrons = strings.Join(result.ElectronNames, ", ") + } + + fmt.Println(tui.BaseTextStyle.Render("Pipeline: " + result.PipelineID)) + fmt.Println(tui.BaseTextStyle.Render("Name: " + result.Name)) + fmt.Println(tui.BaseTextStyle.Render(fmt.Sprintf("Version: %d", result.Version))) + fmt.Println(tui.BaseTextStyle.Render("Status: " + result.Status)) + fmt.Println(tui.BaseTextStyle.Render("Mode: " + result.Mode)) + fmt.Println(tui.BaseTextStyle.Render("Electrons: " + electrons)) + fmt.Println(tui.DimStyle.Render("Updated: " + result.CreatedAt.UTC().Format(time.RFC3339))) +} diff --git a/cmd/pipelines/update/cmd_test.go b/cmd/pipelines/update/cmd_test.go new file mode 100644 index 000000000..42097e79b --- /dev/null +++ b/cmd/pipelines/update/cmd_test.go @@ -0,0 +1,130 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package update + +import ( + "bytes" + "encoding/json" + "io" + "os" + "testing" + "time" + + "github.com/datarobot/cli/internal/pipelines" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + w.Close() + + os.Stdout = old + + var buf bytes.Buffer + + _, _ = io.Copy(&buf, r) + + return buf.String() +} + +func sampleUpdateResponse() pipelines.CreateResponse { + return pipelines.CreateResponse{ + PipelineID: "6658f441-a8f5-4f21-b4d8-6cccf4c94c5b", + Name: "confluence_to_vdb", + Version: 2, + Status: "READY", + Mode: "draft", + ElectronNames: []string{"create_vector_database"}, + CreatedAt: time.Date(2026, 4, 28, 12, 24, 54, 0, time.UTC), + } +} + +func TestPrintUpdateJSON(t *testing.T) { + resp := sampleUpdateResponse() + + output := captureStdout(t, func() { + err := printUpdateJSON(resp) + require.NoError(t, err) + }) + + var parsed map[string]interface{} + + err := json.Unmarshal([]byte(output), &parsed) + require.NoError(t, err) + assert.Equal(t, resp.PipelineID, parsed["pipeline_id"]) + assert.EqualValues(t, 2, parsed["version"]) + assert.Equal(t, "READY", parsed["status"]) +} + +func TestPrintUpdateHuman_WithElectrons(t *testing.T) { + resp := sampleUpdateResponse() + + output := captureStdout(t, func() { + printUpdateHuman(resp) + }) + + assert.Contains(t, output, "Pipeline: "+resp.PipelineID) + assert.Contains(t, output, "Name: confluence_to_vdb") + assert.Contains(t, output, "Version: 2") + assert.Contains(t, output, "Status: READY") + assert.Contains(t, output, "Mode: draft") + assert.Contains(t, output, "create_vector_database") +} + +func TestPrintUpdateHuman_NoElectrons(t *testing.T) { + resp := sampleUpdateResponse() + resp.ElectronNames = nil + + output := captureStdout(t, func() { + printUpdateHuman(resp) + }) + + assert.Contains(t, output, "Electrons: \u2014") +} + +func TestCmd_RequiresTwoArgs(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"only-one"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err := cmd.Execute() + require.Error(t, err) +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"some-id", "some-file.py", "--output", "yaml"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_HasOutputFlag(t *testing.T) { + cmd := Cmd() + assert.NotNil(t, cmd.Flags().Lookup("output")) +} diff --git a/cmd/root.go b/cmd/root.go index 78b04bb99..de13f1bb3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,6 +25,7 @@ import ( "github.com/datarobot/cli/cmd/component" "github.com/datarobot/cli/cmd/dependencies" "github.com/datarobot/cli/cmd/dotenv" + "github.com/datarobot/cli/cmd/pipelines" "github.com/datarobot/cli/cmd/plugin" "github.com/datarobot/cli/cmd/self" "github.com/datarobot/cli/cmd/start" @@ -217,6 +218,7 @@ func init() { templates.Cmd(), workload.Cmd(), plugin.Cmd(), + pipelines.Cmd(), ) // Discover and register plugin commands diff --git a/internal/drapi/get.go b/internal/drapi/get.go index f86cd4c4e..d18364f58 100644 --- a/internal/drapi/get.go +++ b/internal/drapi/get.go @@ -23,6 +23,7 @@ import ( "github.com/datarobot/cli/internal/config" "github.com/datarobot/cli/internal/log" + "github.com/spf13/viper" ) // HTTPError is returned by Get when the server responds with a non-200 status code. @@ -51,6 +52,18 @@ func SetToken(value string) { const DefaultGetTimeoutSecs = 30 +// resolveToken returns the API token used for outbound requests. +// When --skip-auth (or DATAROBOT_CLI_SKIP_AUTH) is active we trust whatever +// is in viper without contacting the server, so local development against +// stub APIs that don't implement /version/ still works. +func resolveToken() (string, error) { + if viper.GetBool("skip_auth") { + return viper.GetString(config.DataRobotAPIKey), nil + } + + return config.GetAPIKey(context.Background()) +} + func Get(url, info string, timeoutSecs ...int) (*http.Response, error) { timeout := DefaultGetTimeoutSecs if len(timeoutSecs) > 0 { @@ -61,7 +74,7 @@ func Get(url, info string, timeoutSecs ...int) (*http.Response, error) { // memoize token to avoid extra VerifyToken() calls if token == "" { - token, err = config.GetAPIKey(context.Background()) + token, err = resolveToken() if err != nil { return nil, err } diff --git a/internal/pipelines/pipeline.go b/internal/pipelines/pipeline.go new file mode 100644 index 000000000..7ee5b95a2 --- /dev/null +++ b/internal/pipelines/pipeline.go @@ -0,0 +1,351 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipelines + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/datarobot/cli/internal/config" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/log" + "github.com/spf13/viper" +) + +// resolveToken mirrors the helper in drapi: when --skip-auth (or +// DATAROBOT_CLI_SKIP_AUTH) is active we trust the viper-stored token without +// contacting the server. Otherwise we go through the standard verify path. +func resolveToken() (string, error) { + if viper.GetBool("skip_auth") { + return viper.GetString(config.DataRobotAPIKey), nil + } + + return config.GetAPIKey(context.Background()) +} + +// Mode values accepted by the pipelines API. +const ( + ModeDraft = "draft" + ModeLocked = "locked" +) + +// PipelineVersion mirrors PipelineVersionResponse from the pipelines-api. +type PipelineVersion struct { + Version int `json:"version"` + Status string `json:"status"` + LatticeName string `json:"lattice_name"` + ElectronNames []string `json:"electron_names,omitempty"` + PythonVersion string `json:"python_version"` + ResourceBundle map[string]any `json:"resource_bundle,omitempty"` + ErrorDetail string `json:"error_detail,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// Pipeline mirrors PipelineDetailResponse from the pipelines-api. +type Pipeline struct { + PipelineID string `json:"pipeline_id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Mode string `json:"mode"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Versions []PipelineVersion `json:"versions"` +} + +// CreateResponse mirrors PipelineCreateResponse from the pipelines-api. +// It is also returned by PATCH /pipelines/{id}. +type CreateResponse struct { + PipelineID string `json:"pipeline_id"` + Name string `json:"name"` + Version int `json:"version"` + Status string `json:"status"` + Mode string `json:"mode"` + ElectronNames []string `json:"electron_names,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// ListItem mirrors PipelineListItem from the pipelines-api. +type ListItem struct { + PipelineID string `json:"pipeline_id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Mode string `json:"mode"` + IsActive bool `json:"is_active"` + LatestVersion *int `json:"latest_version,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ListResponse mirrors PipelineListResponse from the pipelines-api. +type ListResponse struct { + Items []ListItem `json:"items"` + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` +} + +// CreatePipeline uploads a Python file to POST /api/v2/pipelines. +func CreatePipeline(filePath, description, mode string) (*CreateResponse, error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines") + if err != nil { + return nil, err + } + + fields := map[string]string{} + if description != "" { + fields["description"] = description + } + + if mode != "" { + fields["mode"] = mode + } + + var result CreateResponse + + err = doMultipart(http.MethodPost, endpoint, filePath, fields, "create pipeline", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// ListPipelines fetches a paginated list of pipelines from GET /api/v2/pipelines. +func ListPipelines(mode string, offset, limit int) (*ListResponse, error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines") + if err != nil { + return nil, err + } + + query := url.Values{} + if mode != "" { + query.Set("mode", mode) + } + + if offset > 0 { + query.Set("offset", strconv.Itoa(offset)) + } + + if limit > 0 { + query.Set("limit", strconv.Itoa(limit)) + } + + if encoded := query.Encode(); encoded != "" { + endpoint = endpoint + "?" + encoded + } + + var list ListResponse + + err = drapi.GetJSON(endpoint, "pipelines", &list) + if err != nil { + return nil, err + } + + return &list, nil +} + +// GetPipeline fetches a single pipeline from GET /api/v2/pipelines/{pipeline_id}. +func GetPipeline(pipelineID string) (*Pipeline, error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/" + pipelineID) + if err != nil { + return nil, err + } + + var pipeline Pipeline + + err = drapi.GetJSON(endpoint, "pipeline", &pipeline) + if err != nil { + return nil, err + } + + return &pipeline, nil +} + +// UpdatePipeline re-uploads a Python file to PATCH /api/v2/pipelines/{pipeline_id}. +func UpdatePipeline(pipelineID, filePath string) (*CreateResponse, error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/" + pipelineID) + if err != nil { + return nil, err + } + + var result CreateResponse + + err = doMultipart(http.MethodPatch, endpoint, filePath, nil, "update pipeline", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// doMultipart performs a multipart/form-data request with a single "file" upload +// and optional form fields, decoding the JSON response into out. +func doMultipart(method, endpoint, filePath string, fields map[string]string, info string, out any) error { + req, err := buildMultipartRequest(method, endpoint, filePath, fields) + if err != nil { + return err + } + + if info != "" { + log.Infof("%s at: %s", info, endpoint) + } + + // Only build the redacted request dump when debug logging is enabled — + // httputil.DumpRequestOut(req, true) drains req.Body, which silently + // breaks PATCH/POST multipart requests by leaving them with + // ContentLength=N and a 0-byte body. + if log.GetLevel() <= log.DebugLevel { + log.Debug("Request Info: \n" + config.RedactedReqInfo(req)) + } + + client := &http.Client{ + Timeout: 60 * time.Second, + } + + resp, err := client.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return decodeHTTPError(resp, endpoint) + } + + if out == nil { + return nil + } + + return json.NewDecoder(resp.Body).Decode(out) +} + +// buildMultipartRequest assembles the multipart body and HTTP request with +// authentication and tracing headers populated. +func buildMultipartRequest(method, endpoint, filePath string, fields map[string]string) (*http.Request, error) { + body, contentType, err := buildMultipartBody(filePath, fields) + if err != nil { + return nil, err + } + + token, err := resolveToken() + if err != nil { + return nil, err + } + + req, err := http.NewRequest(method, endpoint, body) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("User-Agent", config.GetUserAgentHeader()) + req.Header.Set("Content-Type", contentType) + + if config.IsAPIConsumerTrackingEnabled() { + req.Header.Set("X-DataRobot-Api-Consumer-Trace", config.GetAPIConsumerTrace()) + } + + return req, nil +} + +// decodeHTTPError reads a non-2xx response body and turns it into a meaningful error. +func decodeHTTPError(resp *http.Response, endpoint string) error { + respBody, _ := io.ReadAll(resp.Body) + + detail := extractErrorDetail(respBody) + if detail != "" { + return fmt.Errorf("HTTP %d %s: %s", resp.StatusCode, http.StatusText(resp.StatusCode), detail) + } + + return &drapi.HTTPError{StatusCode: resp.StatusCode, URL: endpoint} +} + +// buildMultipartBody constructs a multipart/form-data body containing the named +// file plus the given form fields. +func buildMultipartBody(filePath string, fields map[string]string) (*bytes.Buffer, string, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, "", fmt.Errorf("open %s: %w", filePath, err) + } + + defer file.Close() + + var body bytes.Buffer + + writer := multipart.NewWriter(&body) + + part, err := writer.CreateFormFile("file", filepath.Base(filePath)) + if err != nil { + return nil, "", err + } + + _, err = io.Copy(part, file) + if err != nil { + return nil, "", err + } + + for key, value := range fields { + err = writer.WriteField(key, value) + if err != nil { + return nil, "", err + } + } + + err = writer.Close() + if err != nil { + return nil, "", err + } + + return &body, writer.FormDataContentType(), nil +} + +// extractErrorDetail attempts to pull a "detail" string from a JSON error body +// returned by FastAPI. Falls back to the raw body if the field is absent. +func extractErrorDetail(body []byte) string { + if len(body) == 0 { + return "" + } + + var payload struct { + Detail any `json:"detail"` + } + + err := json.Unmarshal(body, &payload) + if err == nil && payload.Detail != nil { + switch detail := payload.Detail.(type) { + case string: + return detail + default: + encoded, encErr := json.Marshal(detail) + if encErr == nil { + return string(encoded) + } + } + } + + return string(body) +} From 4fa89b71cbdb154e99292a84b3d6e155aaded64a Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Tue, 28 Apr 2026 10:34:53 -0400 Subject: [PATCH 03/25] update get command output error handling --- cmd/pipelines/get/cmd.go | 26 +++++++++++++++++++++++--- cmd/pipelines/get/cmd_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/cmd/pipelines/get/cmd.go b/cmd/pipelines/get/cmd.go index fbe58beeb..71f52a895 100644 --- a/cmd/pipelines/get/cmd.go +++ b/cmd/pipelines/get/cmd.go @@ -16,7 +16,9 @@ package get import ( "encoding/json" + "errors" "fmt" + "net/http" "os" "strconv" "strings" @@ -24,6 +26,7 @@ import ( "time" "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/drapi" "github.com/datarobot/cli/internal/pipelines" "github.com/datarobot/cli/tui" "github.com/spf13/cobra" @@ -42,8 +45,9 @@ By default, output is human-readable. Use --output json for machine-parseable ou Example: dr pipelines get 8a8d6e5e-1234-5678-90ab-cdef01234567 dr pipelines get 8a8d6e5e-1234-5678-90ab-cdef01234567 --output json`, - Args: cobra.ExactArgs(1), - PreRunE: auth.EnsureAuthenticatedE, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, RunE: func(_ *cobra.Command, args []string) error { if outputFormat != "" && outputFormat != "json" { return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) @@ -51,7 +55,7 @@ Example: pipeline, err := pipelines.GetPipeline(args[0]) if err != nil { - return err + return handleGetError(err, args[0]) } if outputFormat == "json" { @@ -69,6 +73,22 @@ Example: return cmd } +// handleGetError translates a GetPipeline error into a user-facing message. +// A 404 is rendered as a friendly "No pipeline found" line on stdout and +// suppressed (returns nil) so the user does not see an HTTP-style stack +// or the command's usage on what is really an informational outcome. +func handleGetError(err error, pipelineID string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No pipeline found with id: " + pipelineID)) + + return nil + } + + return err +} + func printGetJSON(pipeline pipelines.Pipeline) error { data, err := json.MarshalIndent(pipeline, "", " ") if err != nil { diff --git a/cmd/pipelines/get/cmd_test.go b/cmd/pipelines/get/cmd_test.go index ef1919788..f010887d8 100644 --- a/cmd/pipelines/get/cmd_test.go +++ b/cmd/pipelines/get/cmd_test.go @@ -17,11 +17,13 @@ package get import ( "bytes" "encoding/json" + "errors" "io" "os" "testing" "time" + "github.com/datarobot/cli/internal/drapi" "github.com/datarobot/cli/internal/pipelines" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -169,3 +171,34 @@ func TestCmd_HasOutputFlag(t *testing.T) { cmd := Cmd() assert.NotNil(t, cmd.Flags().Lookup("output")) } + +func TestHandleGetError_NotFoundPrintsFriendlyMessage(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: 404, URL: "http://example/api/v2/pipelines/abc"} + + output := captureStdout(t, func() { + err := handleGetError(httpErr, "abc") + assert.NoError(t, err) + }) + + assert.Contains(t, output, "No pipeline found with id: abc") +} + +func TestHandleGetError_OtherErrorsPassThrough(t *testing.T) { + otherHTTP := &drapi.HTTPError{StatusCode: 500, URL: "http://example/api/v2/pipelines/abc"} + + output := captureStdout(t, func() { + err := handleGetError(otherHTTP, "abc") + require.Error(t, err) + assert.Same(t, otherHTTP, err) + }) + + assert.NotContains(t, output, "No pipeline found") +} + +func TestHandleGetError_NonHTTPErrorPassesThrough(t *testing.T) { + plain := errors.New("network unreachable") + + err := handleGetError(plain, "abc") + require.Error(t, err) + assert.Equal(t, plain, err) +} From 3142b78e273db99935242ff5509b30f0c5e14ad5 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Tue, 28 Apr 2026 13:46:26 -0400 Subject: [PATCH 04/25] update to direct http calls --- internal/drapi/client.go | 79 ++++++++++++++++++++++++++++++++++ internal/pipelines/pipeline.go | 32 ++++---------- 2 files changed, 87 insertions(+), 24 deletions(-) create mode 100644 internal/drapi/client.go diff --git a/internal/drapi/client.go b/internal/drapi/client.go new file mode 100644 index 000000000..86381f5da --- /dev/null +++ b/internal/drapi/client.go @@ -0,0 +1,79 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// This file owns the cross-verb HTTP primitives shared by every DataRobot +// API call: a client constructor, the standard header set, and the cached +// token resolver. Verb-specific helpers (Get/GetJSON in get.go, future +// post.go/patch.go, etc.) reuse these to stay consistent. +// +// HTTPError, the package-level `token` cache, and resolveToken() live in +// get.go for historical reasons and are reused from this file. + +package drapi + +import ( + "net/http" + "time" + + "github.com/datarobot/cli/internal/config" +) + +// DefaultClientTimeout is the read/write timeout used by NewHTTPClient when +// callers don't specify their own. +const DefaultClientTimeout = 30 * time.Second + +// getToken returns the memoized API token, resolving and caching it on first +// use to avoid repeated VerifyToken() round-trips. The underlying `token` +// variable and resolveToken() function are defined in get.go. +func getToken() (string, error) { + if token != "" { + return token, nil + } + + resolved, err := resolveToken() + if err != nil { + return "", err + } + + token = resolved + + return token, nil +} + +// NewHTTPClient returns an *http.Client preconfigured with the given timeout. +// Use this in place of constructing &http.Client{...} inline so timeouts and +// future shared-transport tweaks live in one place. +func NewHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{Timeout: timeout} +} + +// AuthorizeRequest sets the standard DataRobot API headers on req: +// Authorization (Bearer token), User-Agent, and the optional +// X-DataRobot-Api-Consumer-Trace. The request body is never read, so this +// is safe to call on multipart upload requests. +func AuthorizeRequest(req *http.Request) error { + bearer, err := getToken() + if err != nil { + return err + } + + req.Header.Set("Authorization", "Bearer "+bearer) + req.Header.Set("User-Agent", config.GetUserAgentHeader()) + + if config.IsAPIConsumerTrackingEnabled() { + req.Header.Set("X-DataRobot-Api-Consumer-Trace", config.GetAPIConsumerTrace()) + } + + return nil +} diff --git a/internal/pipelines/pipeline.go b/internal/pipelines/pipeline.go index 7ee5b95a2..20dca62b4 100644 --- a/internal/pipelines/pipeline.go +++ b/internal/pipelines/pipeline.go @@ -16,7 +16,6 @@ package pipelines import ( "bytes" - "context" "encoding/json" "fmt" "io" @@ -31,19 +30,10 @@ import ( "github.com/datarobot/cli/internal/config" "github.com/datarobot/cli/internal/drapi" "github.com/datarobot/cli/internal/log" - "github.com/spf13/viper" ) -// resolveToken mirrors the helper in drapi: when --skip-auth (or -// DATAROBOT_CLI_SKIP_AUTH) is active we trust the viper-stored token without -// contacting the server. Otherwise we go through the standard verify path. -func resolveToken() (string, error) { - if viper.GetBool("skip_auth") { - return viper.GetString(config.DataRobotAPIKey), nil - } - - return config.GetAPIKey(context.Background()) -} +// uploadTimeout is the per-request timeout used for multipart file uploads. +const uploadTimeout = 60 * time.Second // Mode values accepted by the pipelines API. const ( @@ -221,9 +211,7 @@ func doMultipart(method, endpoint, filePath string, fields map[string]string, in log.Debug("Request Info: \n" + config.RedactedReqInfo(req)) } - client := &http.Client{ - Timeout: 60 * time.Second, - } + client := drapi.NewHTTPClient(uploadTimeout) resp, err := client.Do(req) if err != nil { @@ -244,31 +232,27 @@ func doMultipart(method, endpoint, filePath string, fields map[string]string, in } // buildMultipartRequest assembles the multipart body and HTTP request with -// authentication and tracing headers populated. +// authentication and tracing headers populated via drapi.AuthorizeRequest. func buildMultipartRequest(method, endpoint, filePath string, fields map[string]string) (*http.Request, error) { body, contentType, err := buildMultipartBody(filePath, fields) if err != nil { return nil, err } - token, err := resolveToken() + req, err := http.NewRequest(method, endpoint, body) if err != nil { return nil, err } - req, err := http.NewRequest(method, endpoint, body) + // Authorization, User-Agent, and consumer-trace are owned by drapi so + // every CLI command sends consistent headers. + err = drapi.AuthorizeRequest(req) if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("User-Agent", config.GetUserAgentHeader()) req.Header.Set("Content-Type", contentType) - if config.IsAPIConsumerTrackingEnabled() { - req.Header.Set("X-DataRobot-Api-Consumer-Trace", config.GetAPIConsumerTrace()) - } - return req, nil } From 86337aad8b9844192773f17f34b66e6075779c30 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Tue, 28 Apr 2026 14:19:37 -0400 Subject: [PATCH 05/25] updated create and update methods --- cmd/pipelines/create/cmd.go | 40 ++++++++++++++-- cmd/pipelines/create/cmd_test.go | 62 ++++++++++++++++++++++++- cmd/pipelines/update/cmd.go | 46 ++++++++++++++++-- cmd/pipelines/update/cmd_test.go | 80 ++++++++++++++++++++++++++++++-- 4 files changed, 213 insertions(+), 15 deletions(-) diff --git a/cmd/pipelines/create/cmd.go b/cmd/pipelines/create/cmd.go index f174bd737..7a778a642 100644 --- a/cmd/pipelines/create/cmd.go +++ b/cmd/pipelines/create/cmd.go @@ -16,6 +16,7 @@ package create import ( "encoding/json" + "errors" "fmt" "strconv" "strings" @@ -32,21 +33,26 @@ func Cmd() *cobra.Command { description string mode string outputFormat string + fromFile string ) cmd := &cobra.Command{ - Use: "create ", + Use: "create []", Short: "Upload a Python file to create a pipeline.", Long: `Upload a Python file containing a Covalent lattice to register a new pipeline. The lattice name is extracted from the file and used as the pipeline name. By default, output is human-readable. Use --output json for machine-parseable output. +The path to the Python file can be supplied either as a positional argument +or via the --from-file= flag. Exactly one of the two must be provided. + Example: dr pipelines create ./my_pipeline.py + dr pipelines create --from-file=./my_pipeline.py dr pipelines create ./my_pipeline.py --description "First draft" --mode draft - dr pipelines create ./my_pipeline.py --output json`, - Args: cobra.ExactArgs(1), + dr pipelines create --from-file=./my_pipeline.py --output json`, + Args: cobra.MaximumNArgs(1), PreRunE: auth.EnsureAuthenticatedE, RunE: func(_ *cobra.Command, args []string) error { if outputFormat != "" && outputFormat != "json" { @@ -57,7 +63,12 @@ Example: return fmt.Errorf("invalid mode: %s (supported: draft, locked)", mode) } - result, err := pipelines.CreatePipeline(args[0], description, mode) + filePath, err := resolveFilePath(args, fromFile) + if err != nil { + return err + } + + result, err := pipelines.CreatePipeline(filePath, description, mode) if err != nil { return err } @@ -75,10 +86,31 @@ Example: cmd.Flags().StringVar(&description, "description", "", "Optional description for the pipeline") cmd.Flags().StringVar(&mode, "mode", "", "Pipeline mode: draft (default) or locked") cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the Python file to upload, e.g. --from-file=./my_pipeline.py (alternative to the positional argument)") return cmd } +// resolveFilePath returns the file path supplied either positionally or via +// --from-file. Exactly one of the two must be provided. +func resolveFilePath(args []string, fromFile string) (string, error) { + positional := "" + if len(args) > 0 { + positional = args[0] + } + + switch { + case positional != "" && fromFile != "": + return "", errors.New("specify the file either as a positional argument or via --from-file, not both") + case positional != "": + return positional, nil + case fromFile != "": + return fromFile, nil + default: + return "", errors.New("a file path is required (positional argument or --from-file)") + } +} + func printCreateJSON(result pipelines.CreateResponse) error { data, err := json.MarshalIndent(result, "", " ") if err != nil { diff --git a/cmd/pipelines/create/cmd_test.go b/cmd/pipelines/create/cmd_test.go index d0bb41104..5dd6c2532 100644 --- a/cmd/pipelines/create/cmd_test.go +++ b/cmd/pipelines/create/cmd_test.go @@ -104,14 +104,72 @@ func TestPrintCreateHuman_NoElectrons(t *testing.T) { assert.Contains(t, output, "Electrons: \u2014") } -func TestCmd_RequiresArg(t *testing.T) { +func TestCmd_RequiresFilePath(t *testing.T) { cmd := Cmd() cmd.SetArgs([]string{}) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) + cmd.PreRunE = nil // bypass auth + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "a file path is required") +} + +func TestCmd_RejectsBothPositionalAndFromFile(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"a.py", "--from-file=b.py"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil err := cmd.Execute() require.Error(t, err) + assert.Contains(t, err.Error(), "not both") +} + +// TestCmd_FromFileEqualsSyntax ensures the documented --from-file= +// form parses correctly (cobra accepts both `--from-file value` and +// `--from-file=value`; we exercise the equals form here). +func TestCmd_FromFileEqualsSyntax(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"--from-file=./my_pipeline.py"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + flag := cmd.Flags().Lookup("from-file") + require.NotNil(t, flag) + + err := cmd.ParseFlags([]string{"--from-file=./my_pipeline.py"}) + require.NoError(t, err) + assert.Equal(t, "./my_pipeline.py", flag.Value.String()) +} + +func TestResolveFilePath(t *testing.T) { + t.Run("positional only", func(t *testing.T) { + got, err := resolveFilePath([]string{"a.py"}, "") + require.NoError(t, err) + assert.Equal(t, "a.py", got) + }) + + t.Run("flag only", func(t *testing.T) { + got, err := resolveFilePath(nil, "b.py") + require.NoError(t, err) + assert.Equal(t, "b.py", got) + }) + + t.Run("both supplied", func(t *testing.T) { + _, err := resolveFilePath([]string{"a.py"}, "b.py") + require.Error(t, err) + assert.Contains(t, err.Error(), "not both") + }) + + t.Run("neither supplied", func(t *testing.T) { + _, err := resolveFilePath(nil, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "required") + }) } func TestCmd_RejectsInvalidOutput(t *testing.T) { @@ -141,7 +199,7 @@ func TestCmd_RejectsInvalidMode(t *testing.T) { func TestCmd_HasExpectedFlags(t *testing.T) { cmd := Cmd() - for _, name := range []string{"description", "mode", "output"} { + for _, name := range []string{"description", "mode", "output", "from-file"} { flag := cmd.Flags().Lookup(name) assert.NotNilf(t, flag, "expected --%s flag to be registered", name) } diff --git a/cmd/pipelines/update/cmd.go b/cmd/pipelines/update/cmd.go index 95bc08436..eefc21f6f 100644 --- a/cmd/pipelines/update/cmd.go +++ b/cmd/pipelines/update/cmd.go @@ -16,6 +16,7 @@ package update import ( "encoding/json" + "errors" "fmt" "strings" "time" @@ -27,29 +28,43 @@ import ( ) func Cmd() *cobra.Command { - var outputFormat string + var ( + outputFormat string + fromFile string + ) cmd := &cobra.Command{ - Use: "update ", + Use: "update []", Short: "Re-upload a Python file to update a draft pipeline.", Long: `Update an existing draft pipeline by re-uploading a Python file. A new version is appended to the pipeline. The lattice name in the uploaded file must match the existing pipeline name. Locked pipelines cannot be updated. +The path to the Python file can be supplied either as a positional argument +or via the --from-file= flag. Exactly one of the two must be provided. + By default, output is human-readable. Use --output json for machine-parseable output. Example: dr pipelines update 8a8d6e5e-1234-5678-90ab-cdef01234567 ./my_pipeline.py - dr pipelines update 8a8d6e5e-1234-5678-90ab-cdef01234567 ./my_pipeline.py --output json`, - Args: cobra.ExactArgs(2), + dr pipelines update 8a8d6e5e-1234-5678-90ab-cdef01234567 --from-file=./my_pipeline.py + dr pipelines update 8a8d6e5e-1234-5678-90ab-cdef01234567 --from-file=./my_pipeline.py --output json`, + Args: cobra.RangeArgs(1, 2), PreRunE: auth.EnsureAuthenticatedE, RunE: func(_ *cobra.Command, args []string) error { if outputFormat != "" && outputFormat != "json" { return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) } - result, err := pipelines.UpdatePipeline(args[0], args[1]) + pipelineID := args[0] + + filePath, err := resolveFilePath(args[1:], fromFile) + if err != nil { + return err + } + + result, err := pipelines.UpdatePipeline(pipelineID, filePath) if err != nil { return err } @@ -65,10 +80,31 @@ Example: } cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the Python file to upload, e.g. --from-file=./my_pipeline.py (alternative to the positional argument)") return cmd } +// resolveFilePath returns the file path supplied either positionally (in +// extraArgs) or via --from-file. Exactly one of the two must be provided. +func resolveFilePath(extraArgs []string, fromFile string) (string, error) { + positional := "" + if len(extraArgs) > 0 { + positional = extraArgs[0] + } + + switch { + case positional != "" && fromFile != "": + return "", errors.New("specify the file either as a positional argument or via --from-file, not both") + case positional != "": + return positional, nil + case fromFile != "": + return fromFile, nil + default: + return "", errors.New("a file path is required (positional argument or --from-file)") + } +} + func printUpdateJSON(result pipelines.CreateResponse) error { data, err := json.MarshalIndent(result, "", " ") if err != nil { diff --git a/cmd/pipelines/update/cmd_test.go b/cmd/pipelines/update/cmd_test.go index 42097e79b..cfb08f7b3 100644 --- a/cmd/pipelines/update/cmd_test.go +++ b/cmd/pipelines/update/cmd_test.go @@ -102,16 +102,41 @@ func TestPrintUpdateHuman_NoElectrons(t *testing.T) { assert.Contains(t, output, "Electrons: \u2014") } -func TestCmd_RequiresTwoArgs(t *testing.T) { +func TestCmd_RequiresPipelineID(t *testing.T) { cmd := Cmd() - cmd.SetArgs([]string{"only-one"}) + cmd.SetArgs([]string{}) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) + cmd.PreRunE = nil err := cmd.Execute() require.Error(t, err) } +func TestCmd_RequiresFilePath(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"some-id"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "a file path is required") +} + +func TestCmd_RejectsBothPositionalAndFromFile(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"some-id", "a.py", "--from-file=b.py"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "not both") +} + func TestCmd_RejectsInvalidOutput(t *testing.T) { cmd := Cmd() cmd.SetArgs([]string{"some-id", "some-file.py", "--output", "yaml"}) @@ -124,7 +149,54 @@ func TestCmd_RejectsInvalidOutput(t *testing.T) { assert.Contains(t, err.Error(), "invalid output format") } -func TestCmd_HasOutputFlag(t *testing.T) { +func TestCmd_HasExpectedFlags(t *testing.T) { cmd := Cmd() - assert.NotNil(t, cmd.Flags().Lookup("output")) + + for _, name := range []string{"output", "from-file"} { + flag := cmd.Flags().Lookup(name) + assert.NotNilf(t, flag, "expected --%s flag to be registered", name) + } +} + +// TestCmd_FromFileEqualsSyntax ensures the documented --from-file= +// form parses correctly (cobra accepts both `--from-file value` and +// `--from-file=value`; we exercise the equals form here). +func TestCmd_FromFileEqualsSyntax(t *testing.T) { + cmd := Cmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.ParseFlags([]string{"--from-file=./my_pipeline.py"}) + require.NoError(t, err) + + flag := cmd.Flags().Lookup("from-file") + require.NotNil(t, flag) + assert.Equal(t, "./my_pipeline.py", flag.Value.String()) +} + +func TestResolveFilePath(t *testing.T) { + t.Run("positional only", func(t *testing.T) { + got, err := resolveFilePath([]string{"a.py"}, "") + require.NoError(t, err) + assert.Equal(t, "a.py", got) + }) + + t.Run("flag only", func(t *testing.T) { + got, err := resolveFilePath(nil, "b.py") + require.NoError(t, err) + assert.Equal(t, "b.py", got) + }) + + t.Run("both supplied", func(t *testing.T) { + _, err := resolveFilePath([]string{"a.py"}, "b.py") + require.Error(t, err) + assert.Contains(t, err.Error(), "not both") + }) + + t.Run("neither supplied", func(t *testing.T) { + _, err := resolveFilePath(nil, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "required") + }) } From 4ea21e7837e6c45e33fa8d286c09052a4fa55070 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Wed, 29 Apr 2026 22:02:27 -0400 Subject: [PATCH 06/25] add tests --- internal/drapi/client_test.go | 143 +++++++++++++++++++++++ internal/pipelines/pipeline_test.go | 170 ++++++++++++++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 internal/drapi/client_test.go create mode 100644 internal/pipelines/pipeline_test.go diff --git a/internal/drapi/client_test.go b/internal/drapi/client_test.go new file mode 100644 index 000000000..4fd7a4e5e --- /dev/null +++ b/internal/drapi/client_test.go @@ -0,0 +1,143 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package drapi + +import ( + "net/http" + "testing" + "time" + + "github.com/datarobot/cli/internal/config" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// resetTokenCache clears the package-level token cache so tests don't leak +// state into each other. The `token` variable is defined in get.go. +func resetTokenCache(t *testing.T) { + t.Helper() + + prev := token + + token = "" + + t.Cleanup(func() { + token = prev + }) +} + +// withSkipAuth installs a deterministic token in viper and turns on +// --skip-auth so resolveToken returns immediately without hitting the +// network. Returns the token that was installed for assertion. +func withSkipAuth(t *testing.T, value string) string { + t.Helper() + + prevSkip := viper.GetBool("skip_auth") + prevKey := viper.GetString(config.DataRobotAPIKey) + + viper.Set("skip_auth", true) + viper.Set(config.DataRobotAPIKey, value) + + t.Cleanup(func() { + viper.Set("skip_auth", prevSkip) + viper.Set(config.DataRobotAPIKey, prevKey) + }) + + return value +} + +func TestNewHTTPClient(t *testing.T) { + c := NewHTTPClient(7 * time.Second) + require.NotNil(t, c) + assert.Equal(t, 7*time.Second, c.Timeout) +} + +func TestDefaultClientTimeout(t *testing.T) { + // Spot-check the constant — guards against accidental changes that + // would slow down or speed up every API call. + assert.Equal(t, 30*time.Second, DefaultClientTimeout) +} + +func TestGetToken_ResolvesAndMemoizes(t *testing.T) { + resetTokenCache(t) + withSkipAuth(t, "abc123") + + got, err := getToken() + require.NoError(t, err) + assert.Equal(t, "abc123", got) + + // Mutating viper after the cache is populated should NOT change what + // getToken returns — the value is memoized for the lifetime of the + // process. + viper.Set(config.DataRobotAPIKey, "different") + + cached, err := getToken() + require.NoError(t, err) + assert.Equal(t, "abc123", cached) +} + +func TestAuthorizeRequest_SetsExpectedHeaders(t *testing.T) { + resetTokenCache(t) + withSkipAuth(t, "shhh") + + req, err := http.NewRequest(http.MethodGet, "http://example/api/v2/foo", nil) + require.NoError(t, err) + + require.NoError(t, AuthorizeRequest(req)) + + assert.Equal(t, "Bearer shhh", req.Header.Get("Authorization")) + assert.NotEmpty(t, req.Header.Get("User-Agent")) +} + +func TestAuthorizeRequest_DoesNotConsumeBody(t *testing.T) { + resetTokenCache(t) + withSkipAuth(t, "shhh") + + req, err := http.NewRequest(http.MethodPost, "http://example/api/v2/foo", + stringReader("payload")) + require.NoError(t, err) + + require.NoError(t, AuthorizeRequest(req)) + + // Body must still be readable after AuthorizeRequest — this is the + // invariant that makes it safe for multipart uploads. + buf := make([]byte, 7) + + n, _ := req.Body.Read(buf) + assert.Equal(t, 7, n) + assert.Equal(t, "payload", string(buf)) +} + +// stringReader is a tiny io.ReadCloser-friendly helper so we don't pull in +// strings/bytes for one assertion. +func stringReader(s string) *fakeBody { + return &fakeBody{data: []byte(s)} +} + +type fakeBody struct { + data []byte + pos int +} + +func (f *fakeBody) Read(p []byte) (int, error) { + n := copy(p, f.data[f.pos:]) + + f.pos += n + + return n, nil +} + +func (f *fakeBody) Close() error { return nil } diff --git a/internal/pipelines/pipeline_test.go b/internal/pipelines/pipeline_test.go new file mode 100644 index 000000000..673c9b07f --- /dev/null +++ b/internal/pipelines/pipeline_test.go @@ -0,0 +1,170 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipelines + +import ( + "errors" + "io" + "mime" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/datarobot/cli/internal/drapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stringReadCloser wraps a string reader to satisfy http.Response.Body. +type stringReadCloser struct{ *strings.Reader } + +func (s *stringReadCloser) Close() error { return nil } + +func bodyOf(s string) io.ReadCloser { + return &stringReadCloser{strings.NewReader(s)} +} + +func TestExtractErrorDetail(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + { + name: "empty body", + body: "", + want: "", + }, + { + name: "string detail", + body: `{"detail": "lattice missing"}`, + want: "lattice missing", + }, + { + name: "object detail", + body: `{"detail": {"field": "name", "msg": "required"}}`, + want: `{"field":"name","msg":"required"}`, + }, + { + name: "no detail field falls back to raw body", + body: `{"unrelated": "value"}`, + want: `{"unrelated": "value"}`, + }, + { + name: "malformed JSON falls back to raw body", + body: "not json", + want: "not json", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := extractErrorDetail([]byte(tc.body)) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestDecodeHTTPError_WithDetail(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusBadRequest, + Body: bodyOf(`{"detail": "lattice missing"}`), + } + + err := decodeHTTPError(resp, "http://example/api/v2/pipelines") + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 400") + assert.Contains(t, err.Error(), "lattice missing") +} + +func TestDecodeHTTPError_WithoutDetail_ReturnsHTTPError(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusNotFound, + Body: bodyOf(""), + } + + err := decodeHTTPError(resp, "http://example/api/v2/pipelines/abc") + require.Error(t, err) + + var httpErr *drapi.HTTPError + + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) + assert.Equal(t, "http://example/api/v2/pipelines/abc", httpErr.URL) +} + +func TestBuildMultipartBody_IncludesFileAndFields(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "pipeline.py") + + const content = "from covalent import lattice\n" + + require.NoError(t, writeFile(filePath, content)) + + fields := map[string]string{ + "description": "draft 1", + "mode": "draft", + } + + body, contentType, err := buildMultipartBody(filePath, fields) + require.NoError(t, err) + assert.NotEmpty(t, body.Bytes()) + + mediaType, params, err := mime.ParseMediaType(contentType) + require.NoError(t, err) + assert.Equal(t, "multipart/form-data", mediaType) + require.NotEmpty(t, params["boundary"]) + + reader := multipart.NewReader(body, params["boundary"]) + + seen := map[string]string{} + + for { + part, partErr := reader.NextPart() + if errors.Is(partErr, io.EOF) { + break + } + + require.NoError(t, partErr) + + buf, readErr := io.ReadAll(part) + require.NoError(t, readErr) + + name := part.FormName() + if name == "file" { + assert.Equal(t, "pipeline.py", part.FileName()) + } + + seen[name] = string(buf) + } + + assert.Equal(t, content, seen["file"]) + assert.Equal(t, "draft 1", seen["description"]) + assert.Equal(t, "draft", seen["mode"]) +} + +func TestBuildMultipartBody_MissingFile(t *testing.T) { + _, _, err := buildMultipartBody("/no/such/file.py", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "open /no/such/file.py") +} + +// writeFile is a tiny helper that avoids dragging os into every test. +func writeFile(path, content string) error { + return os.WriteFile(path, []byte(content), 0o600) +} From 71f2496672aa24e8ae918ba785a1727286cd66f3 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Wed, 29 Apr 2026 23:33:35 -0400 Subject: [PATCH 07/25] add new commands to cover the rest of APIs --- cmd/pipelines/cmd.go | 17 +- cmd/pipelines/del/cmd.go | 73 +++ cmd/pipelines/del/cmd_test.go | 58 ++ cmd/pipelines/dispatch/cancel/cmd.go | 69 +++ cmd/pipelines/dispatch/cmd.go | 49 ++ cmd/pipelines/dispatch/cmd_test.go | 41 ++ cmd/pipelines/dispatch/create/cmd.go | 87 +++ cmd/pipelines/dispatch/dispatchutil/render.go | 139 +++++ cmd/pipelines/dispatch/get/cmd.go | 93 +++ cmd/pipelines/dispatch/list/cmd.go | 82 +++ cmd/pipelines/dispatch/status/cmd.go | 93 +++ cmd/pipelines/graph/cmd.go | 155 +++++ cmd/pipelines/graph/cmd_test.go | 135 +++++ cmd/pipelines/input/cmd.go | 51 ++ cmd/pipelines/input/cmd_test.go | 41 ++ cmd/pipelines/input/create/cmd.go | 96 ++++ cmd/pipelines/input/del/cmd.go | 70 +++ cmd/pipelines/input/get/cmd.go | 93 +++ cmd/pipelines/input/inpututil/payload.go | 72 +++ cmd/pipelines/input/inpututil/payload_test.go | 100 ++++ cmd/pipelines/input/inpututil/render.go | 110 ++++ cmd/pipelines/input/list/cmd.go | 89 +++ cmd/pipelines/input/update/cmd.go | 85 +++ cmd/pipelines/lock/cmd.go | 94 +++ cmd/pipelines/lock/cmd_test.go | 107 ++++ cmd/pipelines/schedule/cmd.go | 46 ++ cmd/pipelines/schedule/cmd_test.go | 41 ++ cmd/pipelines/schedule/create/cmd.go | 98 ++++ cmd/pipelines/schedule/del/cmd.go | 71 +++ cmd/pipelines/schedule/get/cmd.go | 93 +++ cmd/pipelines/schedule/list/cmd.go | 82 +++ cmd/pipelines/schedule/scheduleutil/render.go | 87 +++ cmd/pipelines/schedule/update/cmd.go | 115 ++++ cmd/pipelines/schedule/update/cmd_test.go | 108 ++++ cmd/pipelines/scopeflag/scopeflag.go | 56 ++ cmd/pipelines/scopeflag/scopeflag_test.go | 115 ++++ cmd/pipelines/version/cmd.go | 40 ++ cmd/pipelines/version/cmd_test.go | 38 ++ cmd/pipelines/version/get/cmd.go | 93 +++ cmd/pipelines/version/get/cmd_test.go | 75 +++ cmd/pipelines/version/list/cmd.go | 76 +++ cmd/pipelines/version/versionutil/render.go | 116 ++++ docs/commands/README.md | 43 ++ docs/commands/pipelines-reference.md | 178 ++++++ docs/commands/pipelines.md | 543 ++++++++++++++++++ docs/mkdocs.yml | 2 + internal/pipelines/dispatch.go | 158 +++++ internal/pipelines/input.go | 153 +++++ internal/pipelines/pipeline.go | 31 + internal/pipelines/schedule.go | 152 +++++ internal/pipelines/scope.go | 124 ++++ internal/pipelines/scope_test.go | 146 +++++ internal/pipelines/transport.go | 146 +++++ internal/pipelines/version.go | 130 +++++ 54 files changed, 5254 insertions(+), 1 deletion(-) create mode 100644 cmd/pipelines/del/cmd.go create mode 100644 cmd/pipelines/del/cmd_test.go create mode 100644 cmd/pipelines/dispatch/cancel/cmd.go create mode 100644 cmd/pipelines/dispatch/cmd.go create mode 100644 cmd/pipelines/dispatch/cmd_test.go create mode 100644 cmd/pipelines/dispatch/create/cmd.go create mode 100644 cmd/pipelines/dispatch/dispatchutil/render.go create mode 100644 cmd/pipelines/dispatch/get/cmd.go create mode 100644 cmd/pipelines/dispatch/list/cmd.go create mode 100644 cmd/pipelines/dispatch/status/cmd.go create mode 100644 cmd/pipelines/graph/cmd.go create mode 100644 cmd/pipelines/graph/cmd_test.go create mode 100644 cmd/pipelines/input/cmd.go create mode 100644 cmd/pipelines/input/cmd_test.go create mode 100644 cmd/pipelines/input/create/cmd.go create mode 100644 cmd/pipelines/input/del/cmd.go create mode 100644 cmd/pipelines/input/get/cmd.go create mode 100644 cmd/pipelines/input/inpututil/payload.go create mode 100644 cmd/pipelines/input/inpututil/payload_test.go create mode 100644 cmd/pipelines/input/inpututil/render.go create mode 100644 cmd/pipelines/input/list/cmd.go create mode 100644 cmd/pipelines/input/update/cmd.go create mode 100644 cmd/pipelines/lock/cmd.go create mode 100644 cmd/pipelines/lock/cmd_test.go create mode 100644 cmd/pipelines/schedule/cmd.go create mode 100644 cmd/pipelines/schedule/cmd_test.go create mode 100644 cmd/pipelines/schedule/create/cmd.go create mode 100644 cmd/pipelines/schedule/del/cmd.go create mode 100644 cmd/pipelines/schedule/get/cmd.go create mode 100644 cmd/pipelines/schedule/list/cmd.go create mode 100644 cmd/pipelines/schedule/scheduleutil/render.go create mode 100644 cmd/pipelines/schedule/update/cmd.go create mode 100644 cmd/pipelines/schedule/update/cmd_test.go create mode 100644 cmd/pipelines/scopeflag/scopeflag.go create mode 100644 cmd/pipelines/scopeflag/scopeflag_test.go create mode 100644 cmd/pipelines/version/cmd.go create mode 100644 cmd/pipelines/version/cmd_test.go create mode 100644 cmd/pipelines/version/get/cmd.go create mode 100644 cmd/pipelines/version/get/cmd_test.go create mode 100644 cmd/pipelines/version/list/cmd.go create mode 100644 cmd/pipelines/version/versionutil/render.go create mode 100644 docs/commands/pipelines-reference.md create mode 100644 docs/commands/pipelines.md create mode 100644 internal/pipelines/dispatch.go create mode 100644 internal/pipelines/input.go create mode 100644 internal/pipelines/schedule.go create mode 100644 internal/pipelines/scope.go create mode 100644 internal/pipelines/scope_test.go create mode 100644 internal/pipelines/transport.go create mode 100644 internal/pipelines/version.go diff --git a/cmd/pipelines/cmd.go b/cmd/pipelines/cmd.go index 35e6b25fd..869d28295 100644 --- a/cmd/pipelines/cmd.go +++ b/cmd/pipelines/cmd.go @@ -16,9 +16,16 @@ package pipelines import ( "github.com/datarobot/cli/cmd/pipelines/create" + "github.com/datarobot/cli/cmd/pipelines/del" + "github.com/datarobot/cli/cmd/pipelines/dispatch" "github.com/datarobot/cli/cmd/pipelines/get" + "github.com/datarobot/cli/cmd/pipelines/graph" + "github.com/datarobot/cli/cmd/pipelines/input" "github.com/datarobot/cli/cmd/pipelines/list" + "github.com/datarobot/cli/cmd/pipelines/lock" + "github.com/datarobot/cli/cmd/pipelines/schedule" "github.com/datarobot/cli/cmd/pipelines/update" + "github.com/datarobot/cli/cmd/pipelines/version" "github.com/datarobot/cli/internal/features" "github.com/spf13/cobra" ) @@ -31,7 +38,8 @@ func Cmd() *cobra.Command { Long: `Manage AI/ML pipelines orchestrated by Covalent. Create, list, inspect, and update pipelines registered with the -DataRobot pipelines service.`, +DataRobot pipelines service. Sub-commands are also available for managing +input payloads, dispatches, and recurring schedules.`, } features.SetGate(cmd, "pipelines") @@ -41,6 +49,13 @@ DataRobot pipelines service.`, get.Cmd(), list.Cmd(), update.Cmd(), + del.Cmd(), + lock.Cmd(), + version.Cmd(), + graph.Cmd(), + input.Cmd(), + dispatch.Cmd(), + schedule.Cmd(), ) return cmd diff --git a/cmd/pipelines/del/cmd.go b/cmd/pipelines/del/cmd.go new file mode 100644 index 000000000..810ecafce --- /dev/null +++ b/cmd/pipelines/del/cmd.go @@ -0,0 +1,73 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package del implements the `dr pipelines delete` verb. The directory +// is named `del` rather than `delete` because the latter shadows Go's +// built-in delete() function in importing files. + +package del + +import ( + "errors" + "fmt" + "net/http" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a pipeline", + Long: `Delete a pipeline by id. The pipeline and all of its versions are +removed from the registry. + +Example: + dr pipelines delete 8a8d6e5e-1234-5678-90ab-cdef01234567`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + err := pipelines.DeletePipeline(args[0]) + if err != nil { + return handleDeleteError(err, args[0]) + } + + fmt.Println(tui.BaseTextStyle.Render("Deleted pipeline: " + args[0])) + + return nil + }, + } + + return cmd +} + +// handleDeleteError converts a 404 into a friendly informational message +// (returns nil) so the user does not see a stack-trace-style HTTP error +// for what is effectively a no-op. +func handleDeleteError(err error, pipelineID string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No pipeline found with id: " + pipelineID)) + + return nil + } + + return err +} diff --git a/cmd/pipelines/del/cmd_test.go b/cmd/pipelines/del/cmd_test.go new file mode 100644 index 000000000..b9af06f1b --- /dev/null +++ b/cmd/pipelines/del/cmd_test.go @@ -0,0 +1,58 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package del + +import ( + "errors" + "net/http" + "testing" + + "github.com/datarobot/cli/internal/drapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandleDeleteError_404IsSuppressed(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: http.StatusNotFound, URL: "http://x/api/v2/pipelines/abc"} + + err := handleDeleteError(httpErr, "abc") + assert.NoError(t, err) +} + +func TestHandleDeleteError_OtherStatusesPropagate(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: http.StatusInternalServerError, URL: "http://x/api/v2/pipelines/abc"} + + err := handleDeleteError(httpErr, "abc") + require.Error(t, err) + + var got *drapi.HTTPError + + require.ErrorAs(t, err, &got) + assert.Equal(t, http.StatusInternalServerError, got.StatusCode) +} + +func TestHandleDeleteError_NonHTTPError(t *testing.T) { + err := handleDeleteError(errors.New("network down"), "abc") + require.Error(t, err) + assert.Contains(t, err.Error(), "network down") +} + +func TestCmd_RegistersExpectedShape(t *testing.T) { + cmd := Cmd() + + assert.Equal(t, "delete", cmd.Name()) + assert.NotNil(t, cmd.RunE) + assert.NotNil(t, cmd.PreRunE) +} diff --git a/cmd/pipelines/dispatch/cancel/cmd.go b/cmd/pipelines/dispatch/cancel/cmd.go new file mode 100644 index 000000000..e24d066b3 --- /dev/null +++ b/cmd/pipelines/dispatch/cancel/cmd.go @@ -0,0 +1,69 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cancel + +import ( + "errors" + "fmt" + + "github.com/datarobot/cli/cmd/pipelines/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var flags scopeflag.Flags + + cmd := &cobra.Command{ + Use: "cancel ", + Short: "Cancel a pipeline dispatch", + Long: `Request cancellation of an in-flight dispatch. + +The API rejects cancellation if the dispatch has already reached a terminal +state (COMPLETED, FAILED, CANCELLED). + +Example: + dr pipelines dispatch cancel --pipeline + dr pipelines dispatch cancel --pipeline --version=2 `, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + err = pipelines.CancelDispatch(flags.PipelineID, scope, version, args[0]) + if err != nil { + return err + } + + fmt.Println(tui.BaseTextStyle.Render("Cancelled dispatch: " + args[0])) + + return nil + }, + } + + flags.Bind(cmd) + + return cmd +} diff --git a/cmd/pipelines/dispatch/cmd.go b/cmd/pipelines/dispatch/cmd.go new file mode 100644 index 000000000..01335bd52 --- /dev/null +++ b/cmd/pipelines/dispatch/cmd.go @@ -0,0 +1,49 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dispatch + +import ( + "github.com/datarobot/cli/cmd/pipelines/dispatch/cancel" + "github.com/datarobot/cli/cmd/pipelines/dispatch/create" + "github.com/datarobot/cli/cmd/pipelines/dispatch/get" + "github.com/datarobot/cli/cmd/pipelines/dispatch/list" + "github.com/datarobot/cli/cmd/pipelines/dispatch/status" + "github.com/spf13/cobra" +) + +// Cmd returns the parent command for `dr pipelines dispatch`. +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "dispatch", + Short: "Manage pipeline dispatches", + Long: `Trigger and inspect dispatches (single executions) of a pipeline. + +Dispatches come in two scopes: + - draft : runs against the in-flight draft of a pipeline + - locked : runs against a specific frozen version + +When --version is supplied, the locked scope is selected automatically.`, + } + + cmd.AddCommand( + create.Cmd(), + list.Cmd(), + get.Cmd(), + status.Cmd(), + cancel.Cmd(), + ) + + return cmd +} diff --git a/cmd/pipelines/dispatch/cmd_test.go b/cmd/pipelines/dispatch/cmd_test.go new file mode 100644 index 000000000..eb599fc79 --- /dev/null +++ b/cmd/pipelines/dispatch/cmd_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dispatch + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCmd_RegistersAllVerbs(t *testing.T) { + cmd := Cmd() + + want := map[string]bool{ + "create": false, + "list": false, + "get": false, + "status": false, + "cancel": false, + } + + for _, sub := range cmd.Commands() { + want[sub.Name()] = true + } + + for verb, present := range want { + assert.Truef(t, present, "missing subcommand: %s", verb) + } +} diff --git a/cmd/pipelines/dispatch/create/cmd.go b/cmd/pipelines/dispatch/create/cmd.go new file mode 100644 index 000000000..4340e251f --- /dev/null +++ b/cmd/pipelines/dispatch/create/cmd.go @@ -0,0 +1,87 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package create + +import ( + "errors" + "fmt" + + "github.com/datarobot/cli/cmd/pipelines/dispatch/dispatchutil" + "github.com/datarobot/cli/cmd/pipelines/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipelines" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + inputID string + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Trigger a pipeline dispatch", + Long: `Trigger a new dispatch (single execution) of a pipeline. + +The dispatch is created in PENDING state. Use ` + "`dr pipelines dispatch get`" + ` +or ` + "`dr pipelines dispatch status`" + ` to follow its progress. + +Example: + dr pipelines dispatch create --pipeline --input + dr pipelines dispatch create --pipeline --version=2 --input --output json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + if inputID == "" { + return errors.New("--input is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + result, err := pipelines.CreateDispatch(flags.PipelineID, scope, version, inputID) + if err != nil { + return err + } + + if outputFormat == "json" { + return dispatchutil.PrintDispatchJSON(*result) + } + + dispatchutil.PrintDispatchHuman(*result) + + return nil + }, + } + + flags.Bind(cmd) + cmd.Flags().StringVar(&inputID, "input", "", "Input ID to dispatch") + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} diff --git a/cmd/pipelines/dispatch/dispatchutil/render.go b/cmd/pipelines/dispatch/dispatchutil/render.go new file mode 100644 index 000000000..46a0fb0e2 --- /dev/null +++ b/cmd/pipelines/dispatch/dispatchutil/render.go @@ -0,0 +1,139 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package dispatchutil holds the rendering helpers shared by the +// `dr pipelines dispatch` verbs. Living in a sibling package keeps the +// parent `dispatch` package free of cycles. + +package dispatchutil + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "text/tabwriter" + + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" +) + +// PrintDispatchJSON marshals a dispatch as indented JSON. +func PrintDispatchJSON(d pipelines.Dispatch) error { + data, err := json.MarshalIndent(d, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintDispatchHuman renders a single dispatch in a human-friendly form. +func PrintDispatchHuman(d pipelines.Dispatch) { + scope := "draft" + versionDisplay := "\u2014" + + if d.VersionID != nil { + scope = "locked" + versionDisplay = "v" + strconv.Itoa(*d.VersionID) + } + + covalent := d.CovalentDispatchID + if covalent == "" { + covalent = "\u2014" + } + + fmt.Println(tui.BaseTextStyle.Render("Dispatch ID: " + d.DispatchID)) + fmt.Println(tui.BaseTextStyle.Render("Pipeline: " + d.PipelineID)) + fmt.Println(tui.BaseTextStyle.Render("Scope: " + scope)) + fmt.Println(tui.BaseTextStyle.Render("Version: " + versionDisplay)) + fmt.Println(tui.BaseTextStyle.Render("Input ID: " + d.InputID)) + fmt.Println(tui.BaseTextStyle.Render("Status: " + d.Status)) + fmt.Println(tui.BaseTextStyle.Render("Triggered By: " + d.TriggeredBy)) + fmt.Println(tui.BaseTextStyle.Render("Covalent Dispatch: " + covalent)) + + if d.ErrorDetail != "" { + fmt.Println(tui.BaseTextStyle.Render("Error: " + d.ErrorDetail)) + } + + fmt.Println(tui.DimStyle.Render("Created: " + d.CreatedAt)) + fmt.Println(tui.DimStyle.Render("Updated: " + d.UpdatedAt)) +} + +// PrintDispatchListJSON marshals a list of dispatches as indented JSON. +func PrintDispatchListJSON(items []pipelines.Dispatch) error { + data, err := json.MarshalIndent(items, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintDispatchListHuman renders a tabular summary of dispatches. +func PrintDispatchListHuman(items []pipelines.Dispatch) { + if len(items) == 0 { + fmt.Println(tui.DimStyle.Render("No dispatches found")) + + return + } + + writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintln(writer, "DISPATCH_ID\tSCOPE\tVERSION\tSTATUS\tTRIGGER\tUPDATED") + + for _, d := range items { + scope := "draft" + ver := "\u2014" + + if d.VersionID != nil { + scope = "locked" + ver = "v" + strconv.Itoa(*d.VersionID) + } + + fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\t%s\n", + d.DispatchID, scope, ver, d.Status, d.TriggeredBy, d.UpdatedAt, + ) + } + + _ = writer.Flush() +} + +// PrintStatusJSON marshals a lightweight status response as indented JSON. +func PrintStatusJSON(s pipelines.DispatchStatus) error { + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintStatusHuman renders a lightweight status response. +func PrintStatusHuman(s pipelines.DispatchStatus) { + covalent := s.CovalentDispatchID + if covalent == "" { + covalent = "\u2014" + } + + fmt.Println(tui.BaseTextStyle.Render("Dispatch ID: " + s.DispatchID)) + fmt.Println(tui.BaseTextStyle.Render("Status: " + s.Status)) + fmt.Println(tui.BaseTextStyle.Render("Covalent Dispatch: " + covalent)) +} diff --git a/cmd/pipelines/dispatch/get/cmd.go b/cmd/pipelines/dispatch/get/cmd.go new file mode 100644 index 000000000..51a617b40 --- /dev/null +++ b/cmd/pipelines/dispatch/get/cmd.go @@ -0,0 +1,93 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package get + +import ( + "errors" + "fmt" + "net/http" + + "github.com/datarobot/cli/cmd/pipelines/dispatch/dispatchutil" + "github.com/datarobot/cli/cmd/pipelines/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "get ", + Short: "Display details of a pipeline dispatch", + Long: `Display the full record for a single dispatch. + +Example: + dr pipelines dispatch get --pipeline + dr pipelines dispatch get --pipeline --version=2 --output json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + result, err := pipelines.GetDispatch(flags.PipelineID, scope, version, args[0]) + if err != nil { + return handleGetError(err, args[0]) + } + + if outputFormat == "json" { + return dispatchutil.PrintDispatchJSON(*result) + } + + dispatchutil.PrintDispatchHuman(*result) + + return nil + }, + } + + flags.Bind(cmd) + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} + +func handleGetError(err error, dispatchID string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No dispatch found with id: " + dispatchID)) + + return nil + } + + return err +} diff --git a/cmd/pipelines/dispatch/list/cmd.go b/cmd/pipelines/dispatch/list/cmd.go new file mode 100644 index 000000000..2b976024e --- /dev/null +++ b/cmd/pipelines/dispatch/list/cmd.go @@ -0,0 +1,82 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package list + +import ( + "errors" + "fmt" + + "github.com/datarobot/cli/cmd/pipelines/dispatch/dispatchutil" + "github.com/datarobot/cli/cmd/pipelines/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipelines" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + offset int + limit int + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List pipeline dispatches", + Long: `List dispatches for a pipeline. + +Example: + dr pipelines dispatch list --pipeline + dr pipelines dispatch list --pipeline --version=2 --output json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + items, err := pipelines.ListDispatches(flags.PipelineID, scope, version, offset, limit) + if err != nil { + return err + } + + if outputFormat == "json" { + return dispatchutil.PrintDispatchListJSON(items) + } + + dispatchutil.PrintDispatchListHuman(items) + + return nil + }, + } + + flags.Bind(cmd) + cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") + cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of dispatches to return") + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} diff --git a/cmd/pipelines/dispatch/status/cmd.go b/cmd/pipelines/dispatch/status/cmd.go new file mode 100644 index 000000000..b625ccf23 --- /dev/null +++ b/cmd/pipelines/dispatch/status/cmd.go @@ -0,0 +1,93 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package status + +import ( + "errors" + "fmt" + "net/http" + + "github.com/datarobot/cli/cmd/pipelines/dispatch/dispatchutil" + "github.com/datarobot/cli/cmd/pipelines/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "status ", + Short: "Get the lightweight status of a pipeline dispatch", + Long: `Poll a dispatch's current status without re-downloading the full record. + +Example: + dr pipelines dispatch status --pipeline + dr pipelines dispatch status --pipeline --version=2 --output json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + result, err := pipelines.GetDispatchStatus(flags.PipelineID, scope, version, args[0]) + if err != nil { + return handleStatusError(err, args[0]) + } + + if outputFormat == "json" { + return dispatchutil.PrintStatusJSON(*result) + } + + dispatchutil.PrintStatusHuman(*result) + + return nil + }, + } + + flags.Bind(cmd) + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} + +func handleStatusError(err error, dispatchID string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No dispatch found with id: " + dispatchID)) + + return nil + } + + return err +} diff --git a/cmd/pipelines/graph/cmd.go b/cmd/pipelines/graph/cmd.go new file mode 100644 index 000000000..4ff61cb88 --- /dev/null +++ b/cmd/pipelines/graph/cmd.go @@ -0,0 +1,155 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package graph + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "strconv" + "text/tabwriter" + + "github.com/datarobot/cli/cmd/pipelines/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "graph", + Short: "Display the DAG of a pipeline", + Long: `Display the lattice/electron graph for a pipeline as either a JSON +payload (for visualisation tooling) or a human-readable summary. + +Scope is selected from the --scope/--version flags: + - no flags -> draft graph (latest version) + - --version=N -> locked graph for version N (scope auto-set) + - --scope=draft -> draft graph + - --scope=locked --version=N -> locked graph for version N + +Example: + dr pipelines graph --pipeline + dr pipelines graph --pipeline --version=2 --output json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + result, err := pipelines.GetGraph(flags.PipelineID, scope, version) + if err != nil { + return handleGraphError(err, flags.PipelineID) + } + + if outputFormat == "json" { + return printGraphJSON(*result) + } + + printGraphHuman(*result) + + return nil + }, + } + + flags.Bind(cmd) + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} + +func handleGraphError(err error, pipelineID string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No graph available for pipeline: " + pipelineID)) + + return nil + } + + return err +} + +func printGraphJSON(g pipelines.Graph) error { + data, err := json.MarshalIndent(g, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +func printGraphHuman(g pipelines.Graph) { + fmt.Println(tui.BaseTextStyle.Render("Lattice: " + g.Lattice.Name)) + fmt.Println(tui.DimStyle.Render("ID: " + g.Lattice.ID)) + + if len(g.Nodes) == 0 { + fmt.Println(tui.DimStyle.Render("No nodes")) + + return + } + + fmt.Println() + fmt.Println(tui.BaseTextStyle.Render("Nodes (" + strconv.Itoa(len(g.Nodes)) + "):")) + + writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintln(writer, " ID\tTYPE\tNAME") + + for _, n := range g.Nodes { + fmt.Fprintf(writer, " %s\t%s\t%s\n", n.ID, n.Type, n.Name) + } + + _ = writer.Flush() + + if len(g.Edges) == 0 { + return + } + + fmt.Println() + fmt.Println(tui.BaseTextStyle.Render("Edges (" + strconv.Itoa(len(g.Edges)) + "):")) + + writer = tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintln(writer, " SOURCE\tTARGET") + + for _, e := range g.Edges { + fmt.Fprintf(writer, " %s\t%s\n", e.Source, e.Target) + } + + _ = writer.Flush() +} diff --git a/cmd/pipelines/graph/cmd_test.go b/cmd/pipelines/graph/cmd_test.go new file mode 100644 index 000000000..2e0e0cbbb --- /dev/null +++ b/cmd/pipelines/graph/cmd_test.go @@ -0,0 +1,135 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package graph + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "testing" + + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipelines" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + w.Close() + + os.Stdout = old + + var buf bytes.Buffer + + _, _ = io.Copy(&buf, r) + + return buf.String() +} + +func sampleGraph() pipelines.Graph { + return pipelines.Graph{ + Lattice: pipelines.GraphLattice{ID: "lattice-0", Name: "wf"}, + Nodes: []pipelines.GraphNode{ + {ID: "lattice-0", Type: "lattice", Name: "wf"}, + {ID: "electron-0", Type: "electron", Name: "step1"}, + }, + Edges: []pipelines.GraphEdge{ + {Source: "lattice-0", Target: "electron-0"}, + }, + } +} + +func TestPrintGraphJSON(t *testing.T) { + output := captureStdout(t, func() { + require.NoError(t, printGraphJSON(sampleGraph())) + }) + + var parsed map[string]any + + require.NoError(t, json.Unmarshal([]byte(output), &parsed)) + + lattice, ok := parsed["lattice"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "lattice-0", lattice["id"]) + assert.Equal(t, "wf", lattice["name"]) +} + +func TestPrintGraphHuman(t *testing.T) { + output := captureStdout(t, func() { + printGraphHuman(sampleGraph()) + }) + + assert.Contains(t, output, "Lattice: wf") + assert.Contains(t, output, "ID: lattice-0") + assert.Contains(t, output, "Nodes (2):") + assert.Contains(t, output, "Edges (1):") + assert.Contains(t, output, "electron-0") +} + +func TestPrintGraphHuman_EmptyGraph(t *testing.T) { + output := captureStdout(t, func() { + printGraphHuman(pipelines.Graph{Lattice: pipelines.GraphLattice{Name: "empty", ID: "x"}}) + }) + + assert.Contains(t, output, "No nodes") +} + +func TestHandleGraphError_404IsSuppressed(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: http.StatusNotFound, URL: "x"} + + err := handleGraphError(httpErr, "abc") + assert.NoError(t, err) +} + +func TestHandleGraphError_PropagatesOther(t *testing.T) { + err := handleGraphError(errors.New("boom"), "abc") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RejectsBadOutput(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"--pipeline", "p", "--output", "yaml"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} diff --git a/cmd/pipelines/input/cmd.go b/cmd/pipelines/input/cmd.go new file mode 100644 index 000000000..23f247e44 --- /dev/null +++ b/cmd/pipelines/input/cmd.go @@ -0,0 +1,51 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package input + +import ( + "github.com/datarobot/cli/cmd/pipelines/input/create" + "github.com/datarobot/cli/cmd/pipelines/input/del" + "github.com/datarobot/cli/cmd/pipelines/input/get" + "github.com/datarobot/cli/cmd/pipelines/input/list" + "github.com/datarobot/cli/cmd/pipelines/input/update" + "github.com/spf13/cobra" +) + +// Cmd returns the parent command for `dr pipelines input`. It groups the +// CRUD verbs that operate on pipeline input sets. +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "input", + Short: "Manage pipeline input sets", + Long: `Manage input payloads bound to a pipeline. + +Inputs come in two scopes: + - draft : mutable; bound to the current draft of a pipeline + - locked : immutable; bound to a specific frozen version + +When --version is supplied, the locked scope is selected automatically. +Pass --scope=draft to be explicit.`, + } + + cmd.AddCommand( + create.Cmd(), + list.Cmd(), + get.Cmd(), + update.Cmd(), + del.Cmd(), + ) + + return cmd +} diff --git a/cmd/pipelines/input/cmd_test.go b/cmd/pipelines/input/cmd_test.go new file mode 100644 index 000000000..4b7923a46 --- /dev/null +++ b/cmd/pipelines/input/cmd_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package input + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCmd_RegistersAllVerbs(t *testing.T) { + cmd := Cmd() + + want := map[string]bool{ + "create": false, + "list": false, + "get": false, + "update": false, + "delete": false, + } + + for _, sub := range cmd.Commands() { + want[sub.Name()] = true + } + + for verb, present := range want { + assert.Truef(t, present, "missing subcommand: %s", verb) + } +} diff --git a/cmd/pipelines/input/create/cmd.go b/cmd/pipelines/input/create/cmd.go new file mode 100644 index 000000000..d8f33b2e7 --- /dev/null +++ b/cmd/pipelines/input/create/cmd.go @@ -0,0 +1,96 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package create + +import ( + "errors" + "fmt" + + "github.com/datarobot/cli/cmd/pipelines/input/inpututil" + "github.com/datarobot/cli/cmd/pipelines/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipelines" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + fromFile string + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "create []", + Short: "Create a pipeline input set", + Long: `Create an input payload for a pipeline. + +The payload must be a JSON object. The path to the JSON file can be +supplied either as a positional argument or via --from-file=. +Exactly one of the two must be provided. + +Scope is selected from the --scope/--version flags: + - no flags -> draft + - --version=N -> locked, version N (scope auto-set) + - --scope=draft -> draft + - --scope=locked --version=N -> locked, version N + +Example: + dr pipelines input create --pipeline ./payload.json + dr pipelines input create --pipeline --from-file=./payload.json + dr pipelines input create --pipeline --version=2 ./payload.json --output json`, + Args: cobra.MaximumNArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + payload, err := inpututil.ResolvePayload(args, fromFile) + if err != nil { + return err + } + + result, err := pipelines.CreateInput(flags.PipelineID, scope, version, payload) + if err != nil { + return err + } + + if outputFormat == "json" { + return inpututil.PrintInputJSON(*result) + } + + inpututil.PrintInputHuman(*result) + + return nil + }, + } + + flags.Bind(cmd) + cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the JSON payload file, e.g. --from-file=./payload.json (alternative to the positional argument)") + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} diff --git a/cmd/pipelines/input/del/cmd.go b/cmd/pipelines/input/del/cmd.go new file mode 100644 index 000000000..0b8634cd3 --- /dev/null +++ b/cmd/pipelines/input/del/cmd.go @@ -0,0 +1,70 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package del implements the `dr pipelines input delete` verb. The +// directory is named `del` rather than `delete` because the latter +// shadows Go's built-in delete() function in importing files. + +package del + +import ( + "errors" + "fmt" + + "github.com/datarobot/cli/cmd/pipelines/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var flags scopeflag.Flags + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a pipeline input set", + Long: `Delete an input payload from a pipeline. + +Example: + dr pipelines input delete --pipeline + dr pipelines input delete --pipeline --version=2 `, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + err = pipelines.DeleteInput(flags.PipelineID, scope, version, args[0]) + if err != nil { + return err + } + + fmt.Println(tui.BaseTextStyle.Render("Deleted input: " + args[0])) + + return nil + }, + } + + flags.Bind(cmd) + + return cmd +} diff --git a/cmd/pipelines/input/get/cmd.go b/cmd/pipelines/input/get/cmd.go new file mode 100644 index 000000000..75ec9893d --- /dev/null +++ b/cmd/pipelines/input/get/cmd.go @@ -0,0 +1,93 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package get + +import ( + "errors" + "fmt" + "net/http" + + "github.com/datarobot/cli/cmd/pipelines/input/inpututil" + "github.com/datarobot/cli/cmd/pipelines/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "get ", + Short: "Display details of a pipeline input set", + Long: `Display the full payload and metadata for a single input set. + +Example: + dr pipelines input get --pipeline + dr pipelines input get --pipeline --version=2 --output json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + result, err := pipelines.GetInput(flags.PipelineID, scope, version, args[0]) + if err != nil { + return handleGetError(err, args[0]) + } + + if outputFormat == "json" { + return inpututil.PrintInputJSON(*result) + } + + inpututil.PrintInputHuman(*result) + + return nil + }, + } + + flags.Bind(cmd) + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} + +func handleGetError(err error, inputID string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No input found with id: " + inputID)) + + return nil + } + + return err +} diff --git a/cmd/pipelines/input/inpututil/payload.go b/cmd/pipelines/input/inpututil/payload.go new file mode 100644 index 000000000..3459fdad8 --- /dev/null +++ b/cmd/pipelines/input/inpututil/payload.go @@ -0,0 +1,72 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// payload.go contains the shared helper for resolving an input payload +// from either a positional argument or the --from-file flag, then parsing +// it as JSON. + +package inpututil + +import ( + "encoding/json" + "errors" + "fmt" + "os" +) + +// ResolvePayload mirrors the create/update flag pattern from +// `dr pipelines create`: a JSON file path can be supplied either as a +// positional argument or via --from-file=; exactly one of the two +// must be provided. The contents of the file must be a JSON object so it +// fits the `{payload: object}` body the API expects. +func ResolvePayload(args []string, fromFile string) (map[string]any, error) { + path, err := resolveFilePath(args, fromFile) + if err != nil { + return nil, err + } + + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + + var payload map[string]any + + err = json.Unmarshal(raw, &payload) + if err != nil { + return nil, fmt.Errorf("parse %s as JSON object: %w", path, err) + } + + return payload, nil +} + +// resolveFilePath returns the file path supplied either positionally or +// via --from-file. Exactly one of the two must be provided. +func resolveFilePath(args []string, fromFile string) (string, error) { + positional := "" + if len(args) > 0 { + positional = args[0] + } + + switch { + case positional != "" && fromFile != "": + return "", errors.New("specify the file either as a positional argument or via --from-file, not both") + case positional != "": + return positional, nil + case fromFile != "": + return fromFile, nil + default: + return "", errors.New("a JSON payload file is required (positional argument or --from-file)") + } +} diff --git a/cmd/pipelines/input/inpututil/payload_test.go b/cmd/pipelines/input/inpututil/payload_test.go new file mode 100644 index 000000000..623784c99 --- /dev/null +++ b/cmd/pipelines/input/inpututil/payload_test.go @@ -0,0 +1,100 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package inpututil + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeTempJSON(t *testing.T, name, content string) string { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, name) + + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + return path +} + +func TestResolveFilePath(t *testing.T) { + t.Run("positional only", func(t *testing.T) { + got, err := resolveFilePath([]string{"a.json"}, "") + require.NoError(t, err) + assert.Equal(t, "a.json", got) + }) + + t.Run("flag only", func(t *testing.T) { + got, err := resolveFilePath(nil, "b.json") + require.NoError(t, err) + assert.Equal(t, "b.json", got) + }) + + t.Run("both supplied", func(t *testing.T) { + _, err := resolveFilePath([]string{"a.json"}, "b.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "not both") + }) + + t.Run("neither supplied", func(t *testing.T) { + _, err := resolveFilePath(nil, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "required") + }) +} + +func TestResolvePayload_ValidJSON(t *testing.T) { + path := writeTempJSON(t, "payload.json", `{"key": "value", "n": 7}`) + + got, err := ResolvePayload([]string{path}, "") + require.NoError(t, err) + assert.Equal(t, "value", got["key"]) + assert.EqualValues(t, 7, got["n"]) +} + +func TestResolvePayload_FromFlag(t *testing.T) { + path := writeTempJSON(t, "payload.json", `{"only": true}`) + + got, err := ResolvePayload(nil, path) + require.NoError(t, err) + assert.Equal(t, true, got["only"]) +} + +func TestResolvePayload_RejectsNonObjectJSON(t *testing.T) { + path := writeTempJSON(t, "payload.json", `[1, 2, 3]`) + + _, err := ResolvePayload([]string{path}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "JSON object") +} + +func TestResolvePayload_MalformedJSON(t *testing.T) { + path := writeTempJSON(t, "payload.json", `{not json`) + + _, err := ResolvePayload([]string{path}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "JSON") +} + +func TestResolvePayload_MissingFile(t *testing.T) { + _, err := ResolvePayload([]string{"/no/such/file.json"}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "read /no/such/file.json") +} diff --git a/cmd/pipelines/input/inpututil/render.go b/cmd/pipelines/input/inpututil/render.go new file mode 100644 index 000000000..b0369579f --- /dev/null +++ b/cmd/pipelines/input/inpututil/render.go @@ -0,0 +1,110 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// render.go centralises the human/JSON output rendering used by the input +// verbs so each verb file stays focused on flag wiring. + +package inpututil + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "text/tabwriter" + + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" +) + +// PrintInputJSON marshals an input record as indented JSON. +func PrintInputJSON(input pipelines.Input) error { + data, err := json.MarshalIndent(input, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintInputHuman renders the key facts about a single input record. +func PrintInputHuman(input pipelines.Input) { + scope := "draft" + versionDisplay := "\u2014" + + if input.VersionID != nil { + scope = "locked" + versionDisplay = "v" + strconv.Itoa(*input.VersionID) + } + + fmt.Println(tui.BaseTextStyle.Render("Input ID: " + input.InputID)) + fmt.Println(tui.BaseTextStyle.Render("Pipeline: " + input.PipelineID)) + fmt.Println(tui.BaseTextStyle.Render("Scope: " + scope)) + fmt.Println(tui.BaseTextStyle.Render("Version: " + versionDisplay)) + fmt.Println(tui.BaseTextStyle.Render("State: " + string(input.State))) + fmt.Println(tui.DimStyle.Render("Created: " + input.CreatedAt)) + fmt.Println(tui.DimStyle.Render("Updated: " + input.UpdatedAt)) + + payload, err := json.MarshalIndent(input.Payload, "", " ") + if err != nil { + return + } + + fmt.Println() + fmt.Println(tui.BaseTextStyle.Render("Payload:")) + fmt.Println(string(payload)) +} + +// PrintInputListJSON marshals a list of inputs as indented JSON. +func PrintInputListJSON(inputs []pipelines.Input) error { + data, err := json.MarshalIndent(inputs, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintInputListHuman renders a tabular summary of inputs. +func PrintInputListHuman(inputs []pipelines.Input) { + if len(inputs) == 0 { + fmt.Println(tui.DimStyle.Render("No inputs found")) + + return + } + + writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintln(writer, "INPUT_ID\tSCOPE\tVERSION\tSTATE\tUPDATED") + + for _, in := range inputs { + scope := "draft" + ver := "\u2014" + + if in.VersionID != nil { + scope = "locked" + ver = "v" + strconv.Itoa(*in.VersionID) + } + + fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\n", + in.InputID, scope, ver, in.State, in.UpdatedAt, + ) + } + + _ = writer.Flush() +} diff --git a/cmd/pipelines/input/list/cmd.go b/cmd/pipelines/input/list/cmd.go new file mode 100644 index 000000000..660e029e4 --- /dev/null +++ b/cmd/pipelines/input/list/cmd.go @@ -0,0 +1,89 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package list + +import ( + "errors" + "fmt" + + "github.com/datarobot/cli/cmd/pipelines/input/inpututil" + "github.com/datarobot/cli/cmd/pipelines/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipelines" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + offset int + limit int + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List pipeline input sets", + Long: `List input payloads for a pipeline. + +Scope is selected the same way as create: + - no flags -> draft + - --version=N -> locked, version N (scope auto-set) + - --scope=draft -> draft + - --scope=locked --version=N -> locked, version N + +Example: + dr pipelines input list --pipeline + dr pipelines input list --pipeline --version=2 + dr pipelines input list --pipeline --offset 50 --limit 10 --output json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + items, err := pipelines.ListInputs(flags.PipelineID, scope, version, offset, limit) + if err != nil { + return err + } + + if outputFormat == "json" { + return inpututil.PrintInputListJSON(items) + } + + inpututil.PrintInputListHuman(items) + + return nil + }, + } + + flags.Bind(cmd) + cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") + cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of inputs to return") + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} diff --git a/cmd/pipelines/input/update/cmd.go b/cmd/pipelines/input/update/cmd.go new file mode 100644 index 000000000..8cdf2966b --- /dev/null +++ b/cmd/pipelines/input/update/cmd.go @@ -0,0 +1,85 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package update + +import ( + "errors" + "fmt" + + "github.com/datarobot/cli/cmd/pipelines/input/inpututil" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipelines" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + pipelineID string + fromFile string + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "update []", + Short: "Update a draft pipeline input set", + Long: `Update the payload of a draft input set. + +Locked inputs are immutable; the API will return 409 if you try to update +one. The new payload must be a JSON object supplied either as a positional +argument or via --from-file=. + +Example: + dr pipelines input update --pipeline ./new_payload.json + dr pipelines input update --pipeline --from-file=./new_payload.json --output json`, + Args: cobra.RangeArgs(1, 2), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if pipelineID == "" { + return errors.New("--pipeline is required") + } + + inputID := args[0] + + payload, err := inpututil.ResolvePayload(args[1:], fromFile) + if err != nil { + return err + } + + result, err := pipelines.UpdateInput(pipelineID, inputID, payload) + if err != nil { + return err + } + + if outputFormat == "json" { + return inpututil.PrintInputJSON(*result) + } + + inpututil.PrintInputHuman(*result) + + return nil + }, + } + + cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the JSON payload file, e.g. --from-file=./payload.json (alternative to the positional argument)") + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} diff --git a/cmd/pipelines/lock/cmd.go b/cmd/pipelines/lock/cmd.go new file mode 100644 index 000000000..8fc396e3a --- /dev/null +++ b/cmd/pipelines/lock/cmd.go @@ -0,0 +1,94 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package lock + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var outputFormat string + + cmd := &cobra.Command{ + Use: "lock ", + Short: "Lock a draft pipeline", + Long: `Promote a draft pipeline to locked mode. Once locked, the pipeline can +no longer be updated and locked dispatches/inputs/schedules become valid. + +Example: + dr pipelines lock 8a8d6e5e-1234-5678-90ab-cdef01234567 + dr pipelines lock 8a8d6e5e-1234-5678-90ab-cdef01234567 --output json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + result, err := pipelines.LockPipeline(args[0]) + if err != nil { + return err + } + + if outputFormat == "json" { + return printLockJSON(*result) + } + + printLockHuman(*result) + + return nil + }, + } + + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} + +func printLockJSON(result pipelines.CreateResponse) error { + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +func printLockHuman(result pipelines.CreateResponse) { + electrons := "\u2014" + if len(result.ElectronNames) > 0 { + electrons = strings.Join(result.ElectronNames, ", ") + } + + fmt.Println(tui.BaseTextStyle.Render("Pipeline: " + result.PipelineID)) + fmt.Println(tui.BaseTextStyle.Render("Name: " + result.Name)) + fmt.Println(tui.BaseTextStyle.Render("Mode: " + result.Mode)) + fmt.Println(tui.BaseTextStyle.Render("Version: v" + strconv.Itoa(result.Version))) + fmt.Println(tui.BaseTextStyle.Render("Status: " + result.Status)) + fmt.Println(tui.BaseTextStyle.Render("Electrons: " + electrons)) + fmt.Println(tui.DimStyle.Render("Locked: " + result.CreatedAt.UTC().Format(time.RFC3339))) +} diff --git a/cmd/pipelines/lock/cmd_test.go b/cmd/pipelines/lock/cmd_test.go new file mode 100644 index 000000000..d7b1cdd47 --- /dev/null +++ b/cmd/pipelines/lock/cmd_test.go @@ -0,0 +1,107 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package lock + +import ( + "bytes" + "encoding/json" + "io" + "os" + "testing" + "time" + + "github.com/datarobot/cli/internal/pipelines" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + w.Close() + + os.Stdout = old + + var buf bytes.Buffer + + _, _ = io.Copy(&buf, r) + + return buf.String() +} + +func sample() pipelines.CreateResponse { + return pipelines.CreateResponse{ + PipelineID: "abc", + Name: "promo", + Version: 3, + Status: "READY", + Mode: "locked", + ElectronNames: []string{"e1", "e2"}, + CreatedAt: time.Date(2026, 4, 30, 10, 0, 0, 0, time.UTC), + } +} + +func TestPrintLockJSON(t *testing.T) { + output := captureStdout(t, func() { + require.NoError(t, printLockJSON(sample())) + }) + + var parsed map[string]any + + require.NoError(t, json.Unmarshal([]byte(output), &parsed)) + assert.Equal(t, "abc", parsed["pipeline_id"]) + assert.Equal(t, "locked", parsed["mode"]) + assert.EqualValues(t, 3, parsed["version"]) +} + +func TestPrintLockHuman(t *testing.T) { + output := captureStdout(t, func() { + printLockHuman(sample()) + }) + + assert.Contains(t, output, "Pipeline: abc") + assert.Contains(t, output, "Mode: locked") + assert.Contains(t, output, "Version: v3") + assert.Contains(t, output, "Electrons: e1, e2") +} + +func TestPrintLockHuman_NoElectrons(t *testing.T) { + resp := sample() + resp.ElectronNames = nil + + output := captureStdout(t, func() { + printLockHuman(resp) + }) + + assert.Contains(t, output, "Electrons: \u2014") +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"abc", "--output", "yaml"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} diff --git a/cmd/pipelines/schedule/cmd.go b/cmd/pipelines/schedule/cmd.go new file mode 100644 index 000000000..234d52a61 --- /dev/null +++ b/cmd/pipelines/schedule/cmd.go @@ -0,0 +1,46 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package schedule + +import ( + "github.com/datarobot/cli/cmd/pipelines/schedule/create" + "github.com/datarobot/cli/cmd/pipelines/schedule/del" + "github.com/datarobot/cli/cmd/pipelines/schedule/get" + "github.com/datarobot/cli/cmd/pipelines/schedule/list" + "github.com/datarobot/cli/cmd/pipelines/schedule/update" + "github.com/spf13/cobra" +) + +// Cmd returns the parent command for `dr pipelines schedule`. +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "schedule", + Short: "Manage pipeline schedules", + Long: `Manage recurring (cron) dispatches of locked pipeline versions. + +Schedules are only valid for locked pipeline versions, so every verb +requires --pipeline and --version.`, + } + + cmd.AddCommand( + create.Cmd(), + list.Cmd(), + get.Cmd(), + update.Cmd(), + del.Cmd(), + ) + + return cmd +} diff --git a/cmd/pipelines/schedule/cmd_test.go b/cmd/pipelines/schedule/cmd_test.go new file mode 100644 index 000000000..5898ff7dd --- /dev/null +++ b/cmd/pipelines/schedule/cmd_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package schedule + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCmd_RegistersAllVerbs(t *testing.T) { + cmd := Cmd() + + want := map[string]bool{ + "create": false, + "list": false, + "get": false, + "update": false, + "delete": false, + } + + for _, sub := range cmd.Commands() { + want[sub.Name()] = true + } + + for verb, present := range want { + assert.Truef(t, present, "missing subcommand: %s", verb) + } +} diff --git a/cmd/pipelines/schedule/create/cmd.go b/cmd/pipelines/schedule/create/cmd.go new file mode 100644 index 000000000..90727d0a6 --- /dev/null +++ b/cmd/pipelines/schedule/create/cmd.go @@ -0,0 +1,98 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package create + +import ( + "errors" + "fmt" + + "github.com/datarobot/cli/cmd/pipelines/schedule/scheduleutil" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipelines" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + pipelineID string + version int + cron string + inputID string + timezone string + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a recurring schedule for a locked pipeline version", + Long: `Register a cron-style schedule that triggers a dispatch on a fixed cadence. + +Example: + dr pipelines schedule create --pipeline --version=2 --cron "0 * * * *" --input + dr pipelines schedule create --pipeline --version=2 --cron "0 9 * * *" --input --timezone America/Los_Angeles`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, _ []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if pipelineID == "" { + return errors.New("--pipeline is required") + } + + if version <= 0 { + return errors.New("--version is required and must be > 0") + } + + if cron == "" { + return errors.New("--cron is required") + } + + if inputID == "" { + return errors.New("--input is required") + } + + body := pipelines.ScheduleCreateRequest{ + CronExpression: cron, + PipelineInputID: inputID, + Timezone: timezone, + } + + result, err := pipelines.CreateSchedule(pipelineID, version, body) + if err != nil { + return err + } + + if outputFormat == "json" { + return scheduleutil.PrintScheduleJSON(*result) + } + + scheduleutil.PrintScheduleHuman(*result) + + return nil + }, + } + + cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") + cmd.Flags().StringVar(&cron, "cron", "", "Cron expression, e.g. \"0 * * * *\"") + cmd.Flags().StringVar(&inputID, "input", "", "Input ID to dispatch on each tick") + cmd.Flags().StringVar(&timezone, "timezone", "", "IANA timezone name (default UTC)") + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} diff --git a/cmd/pipelines/schedule/del/cmd.go b/cmd/pipelines/schedule/del/cmd.go new file mode 100644 index 000000000..5d440b452 --- /dev/null +++ b/cmd/pipelines/schedule/del/cmd.go @@ -0,0 +1,71 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package del implements the `dr pipelines schedule delete` verb. The +// directory is named `del` rather than `delete` because the latter +// shadows Go's built-in delete() function in importing files. + +package del + +import ( + "errors" + "fmt" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + pipelineID string + version int + ) + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a pipeline schedule", + Long: `Delete a recurring schedule from a locked pipeline version. + +Example: + dr pipelines schedule delete --pipeline --version=2 `, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + if pipelineID == "" { + return errors.New("--pipeline is required") + } + + if version <= 0 { + return errors.New("--version is required and must be > 0") + } + + err := pipelines.DeleteSchedule(pipelineID, version, args[0]) + if err != nil { + return err + } + + fmt.Println(tui.BaseTextStyle.Render("Deleted schedule: " + args[0])) + + return nil + }, + } + + cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") + + return cmd +} diff --git a/cmd/pipelines/schedule/get/cmd.go b/cmd/pipelines/schedule/get/cmd.go new file mode 100644 index 000000000..7dea7c877 --- /dev/null +++ b/cmd/pipelines/schedule/get/cmd.go @@ -0,0 +1,93 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package get + +import ( + "errors" + "fmt" + "net/http" + + "github.com/datarobot/cli/cmd/pipelines/schedule/scheduleutil" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + pipelineID string + version int + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "get ", + Short: "Display details of a pipeline schedule", + Long: `Display the cron expression, timezone, and lifecycle status of a schedule. + +Example: + dr pipelines schedule get --pipeline --version=2 + dr pipelines schedule get --pipeline --version=2 --output json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if pipelineID == "" { + return errors.New("--pipeline is required") + } + + if version <= 0 { + return errors.New("--version is required and must be > 0") + } + + result, err := pipelines.GetSchedule(pipelineID, version, args[0]) + if err != nil { + return handleGetError(err, args[0]) + } + + if outputFormat == "json" { + return scheduleutil.PrintScheduleJSON(*result) + } + + scheduleutil.PrintScheduleHuman(*result) + + return nil + }, + } + + cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} + +func handleGetError(err error, scheduleID string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No schedule found with id: " + scheduleID)) + + return nil + } + + return err +} diff --git a/cmd/pipelines/schedule/list/cmd.go b/cmd/pipelines/schedule/list/cmd.go new file mode 100644 index 000000000..7a31c376a --- /dev/null +++ b/cmd/pipelines/schedule/list/cmd.go @@ -0,0 +1,82 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package list + +import ( + "errors" + "fmt" + + "github.com/datarobot/cli/cmd/pipelines/schedule/scheduleutil" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipelines" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + pipelineID string + version int + offset int + limit int + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List schedules for a locked pipeline version", + Long: `List recurring schedules attached to a locked pipeline version. + +Example: + dr pipelines schedule list --pipeline --version=2 + dr pipelines schedule list --pipeline --version=2 --output json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, _ []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if pipelineID == "" { + return errors.New("--pipeline is required") + } + + if version <= 0 { + return errors.New("--version is required and must be > 0") + } + + items, err := pipelines.ListSchedules(pipelineID, version, offset, limit) + if err != nil { + return err + } + + if outputFormat == "json" { + return scheduleutil.PrintScheduleListJSON(items) + } + + scheduleutil.PrintScheduleListHuman(items) + + return nil + }, + } + + cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") + cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") + cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of schedules to return") + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} diff --git a/cmd/pipelines/schedule/scheduleutil/render.go b/cmd/pipelines/schedule/scheduleutil/render.go new file mode 100644 index 000000000..f0bfec565 --- /dev/null +++ b/cmd/pipelines/schedule/scheduleutil/render.go @@ -0,0 +1,87 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package scheduleutil holds the rendering helpers shared by the +// `dr pipelines schedule` verbs. Sibling-package layout avoids cycles +// with the parent schedule command. + +package scheduleutil + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "text/tabwriter" + + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" +) + +// PrintScheduleJSON marshals a schedule as indented JSON. +func PrintScheduleJSON(s pipelines.Schedule) error { + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintScheduleHuman renders a single schedule in human-friendly form. +func PrintScheduleHuman(s pipelines.Schedule) { + fmt.Println(tui.BaseTextStyle.Render("Schedule ID: " + s.ScheduleID)) + fmt.Println(tui.BaseTextStyle.Render("Pipeline: " + s.PipelineID)) + fmt.Println(tui.BaseTextStyle.Render("Version: v" + strconv.Itoa(s.Version))) + fmt.Println(tui.BaseTextStyle.Render("Cron: " + s.CronExpression)) + fmt.Println(tui.BaseTextStyle.Render("Timezone: " + s.Timezone)) + fmt.Println(tui.BaseTextStyle.Render("Status: " + string(s.Status))) + fmt.Println(tui.DimStyle.Render("Created: " + s.CreatedAt)) + fmt.Println(tui.DimStyle.Render("Updated: " + s.UpdatedAt)) +} + +// PrintScheduleListJSON marshals a list of schedules as indented JSON. +func PrintScheduleListJSON(items []pipelines.Schedule) error { + data, err := json.MarshalIndent(items, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintScheduleListHuman renders a tabular summary of schedules. +func PrintScheduleListHuman(items []pipelines.Schedule) { + if len(items) == 0 { + fmt.Println(tui.DimStyle.Render("No schedules found")) + + return + } + + writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintln(writer, "SCHEDULE_ID\tVERSION\tCRON\tTIMEZONE\tSTATUS\tUPDATED") + + for _, s := range items { + fmt.Fprintf(writer, "%s\tv%d\t%s\t%s\t%s\t%s\n", + s.ScheduleID, s.Version, s.CronExpression, s.Timezone, s.Status, s.UpdatedAt, + ) + } + + _ = writer.Flush() +} diff --git a/cmd/pipelines/schedule/update/cmd.go b/cmd/pipelines/schedule/update/cmd.go new file mode 100644 index 000000000..ad8fda218 --- /dev/null +++ b/cmd/pipelines/schedule/update/cmd.go @@ -0,0 +1,115 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package update + +import ( + "errors" + "fmt" + + "github.com/datarobot/cli/cmd/pipelines/schedule/scheduleutil" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipelines" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + pipelineID string + version int + cron string + timezone string + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a pipeline schedule", + Long: `Update the cron expression and/or timezone of an existing schedule. + +At least one of --cron or --timezone must be supplied; otherwise the +command sends an empty patch which the API treats as a no-op. + +Example: + dr pipelines schedule update --pipeline --version=2 --cron "*/15 * * * *" + dr pipelines schedule update --pipeline --version=2 --timezone Europe/Berlin`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + body, err := buildUpdateBody(cmd, pipelineID, version, cron, timezone, outputFormat) + if err != nil { + return err + } + + result, err := pipelines.UpdateSchedule(pipelineID, version, args[0], body) + if err != nil { + return err + } + + if outputFormat == "json" { + return scheduleutil.PrintScheduleJSON(*result) + } + + scheduleutil.PrintScheduleHuman(*result) + + return nil + }, + } + + cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") + cmd.Flags().StringVar(&cron, "cron", "", "New cron expression") + cmd.Flags().StringVar(&timezone, "timezone", "", "New IANA timezone name") + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} + +// buildUpdateBody validates the flag set and assembles the PATCH body. It is +// extracted from RunE to keep the cobra command's cyclomatic complexity low. +func buildUpdateBody(cmd *cobra.Command, pipelineID string, version int, cron, timezone, outputFormat string) (pipelines.ScheduleUpdateRequest, error) { + if outputFormat != "" && outputFormat != "json" { + return pipelines.ScheduleUpdateRequest{}, fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if pipelineID == "" { + return pipelines.ScheduleUpdateRequest{}, errors.New("--pipeline is required") + } + + if version <= 0 { + return pipelines.ScheduleUpdateRequest{}, errors.New("--version is required and must be > 0") + } + + cronChanged := cmd.Flags().Changed("cron") + tzChanged := cmd.Flags().Changed("timezone") + + if !cronChanged && !tzChanged { + return pipelines.ScheduleUpdateRequest{}, errors.New("at least one of --cron or --timezone must be specified") + } + + body := pipelines.ScheduleUpdateRequest{} + + if cronChanged { + v := cron + body.CronExpression = &v + } + + if tzChanged { + v := timezone + body.Timezone = &v + } + + return body, nil +} diff --git a/cmd/pipelines/schedule/update/cmd_test.go b/cmd/pipelines/schedule/update/cmd_test.go new file mode 100644 index 000000000..dbaeedddb --- /dev/null +++ b/cmd/pipelines/schedule/update/cmd_test.go @@ -0,0 +1,108 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package update + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildUpdateBody_RequiresAtLeastOneField(t *testing.T) { + cmd := Cmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + require.NoError(t, cmd.ParseFlags([]string{"--pipeline=p", "--version=2"})) + + _, err := buildUpdateBody(cmd, "p", 2, "", "", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "at least one of --cron") +} + +func TestBuildUpdateBody_PicksUpChangedFlags(t *testing.T) { + cmd := Cmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + require.NoError(t, cmd.ParseFlags([]string{ + "--pipeline=p", "--version=2", + "--cron=*/5 * * * *", + "--timezone=America/Los_Angeles", + })) + + body, err := buildUpdateBody(cmd, "p", 2, "*/5 * * * *", "America/Los_Angeles", "") + require.NoError(t, err) + require.NotNil(t, body.CronExpression) + require.NotNil(t, body.Timezone) + assert.Equal(t, "*/5 * * * *", *body.CronExpression) + assert.Equal(t, "America/Los_Angeles", *body.Timezone) +} + +func TestBuildUpdateBody_SkipsUnchangedFlags(t *testing.T) { + cmd := Cmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + // only --cron supplied; --timezone untouched + require.NoError(t, cmd.ParseFlags([]string{ + "--pipeline=p", "--version=2", + "--cron=0 0 * * *", + })) + + body, err := buildUpdateBody(cmd, "p", 2, "0 0 * * *", "", "") + require.NoError(t, err) + require.NotNil(t, body.CronExpression) + assert.Equal(t, "0 0 * * *", *body.CronExpression) + assert.Nil(t, body.Timezone, "untouched --timezone should not be sent") +} + +func TestBuildUpdateBody_RejectsMissingPipeline(t *testing.T) { + cmd := Cmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + require.NoError(t, cmd.ParseFlags([]string{"--cron=0 0 * * *"})) + + _, err := buildUpdateBody(cmd, "", 2, "0 0 * * *", "", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestBuildUpdateBody_RejectsZeroVersion(t *testing.T) { + cmd := Cmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + require.NoError(t, cmd.ParseFlags([]string{"--pipeline=p", "--cron=0 0 * * *"})) + + _, err := buildUpdateBody(cmd, "p", 0, "0 0 * * *", "", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "--version") +} + +func TestBuildUpdateBody_RejectsInvalidOutput(t *testing.T) { + cmd := Cmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + require.NoError(t, cmd.ParseFlags([]string{"--pipeline=p", "--version=2", "--cron=0 0 * * *"})) + + _, err := buildUpdateBody(cmd, "p", 2, "0 0 * * *", "", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} diff --git a/cmd/pipelines/scopeflag/scopeflag.go b/cmd/pipelines/scopeflag/scopeflag.go new file mode 100644 index 000000000..48259a2a6 --- /dev/null +++ b/cmd/pipelines/scopeflag/scopeflag.go @@ -0,0 +1,56 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package scopeflag bundles the --pipeline / --scope / --version flags +// reused by the input and dispatch CLI command groups. + +package scopeflag + +import ( + "github.com/datarobot/cli/internal/pipelines" + "github.com/spf13/cobra" +) + +// Flags holds the values backing the shared --pipeline / --scope / +// --version flags. Bind() registers them on a cobra command and +// Resolve(cmd) turns them into a (Scope, *version) pair via +// pipelines.ResolveScope. +type Flags struct { + PipelineID string + Scope string + Version int +} + +// Bind registers --pipeline, --scope and --version on cmd. The caller is +// responsible for marking --pipeline required if appropriate. +func (f *Flags) Bind(cmd *cobra.Command) { + cmd.Flags().StringVar(&f.PipelineID, "pipeline", "", "Pipeline ID") + cmd.Flags().StringVar(&f.Scope, "scope", "", "Scope: draft (default) or locked (auto-set when --version is supplied)") + cmd.Flags().IntVar(&f.Version, "version", 0, "Pipeline version (implies --scope=locked)") +} + +// Resolve combines the parsed flags into the canonical (Scope, *version) +// pair required by the pipelines client. It must be called from RunE (or +// later) so cmd.Flags().Changed has accurate state. +func (f *Flags) Resolve(cmd *cobra.Command) (pipelines.Scope, *int, error) { + var version *int + + if cmd.Flags().Changed("version") { + v := f.Version + + version = &v + } + + return pipelines.ResolveScope(f.Scope, version) +} diff --git a/cmd/pipelines/scopeflag/scopeflag_test.go b/cmd/pipelines/scopeflag/scopeflag_test.go new file mode 100644 index 000000000..95dc65d20 --- /dev/null +++ b/cmd/pipelines/scopeflag/scopeflag_test.go @@ -0,0 +1,115 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package scopeflag + +import ( + "io" + "testing" + + "github.com/datarobot/cli/internal/pipelines" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newCmd builds a cobra command with the shared flags bound, so tests can +// drive Resolve() through real flag parsing. +func newCmd() (*cobra.Command, *Flags) { + flags := &Flags{} + + cmd := &cobra.Command{ + Use: "test", + RunE: func(_ *cobra.Command, _ []string) error { + return nil + }, + } + + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + flags.Bind(cmd) + + return cmd, flags +} + +func TestFlags_Resolve_DefaultDraft(t *testing.T) { + cmd, flags := newCmd() + + cmd.SetArgs([]string{}) + require.NoError(t, cmd.Execute()) + + scope, version, err := flags.Resolve(cmd) + require.NoError(t, err) + assert.Equal(t, pipelines.ScopeDraft, scope) + assert.Nil(t, version) +} + +func TestFlags_Resolve_VersionImpliesLocked(t *testing.T) { + cmd, flags := newCmd() + + cmd.SetArgs([]string{"--version=4"}) + require.NoError(t, cmd.Execute()) + + scope, version, err := flags.Resolve(cmd) + require.NoError(t, err) + assert.Equal(t, pipelines.ScopeLocked, scope) + require.NotNil(t, version) + assert.Equal(t, 4, *version) +} + +func TestFlags_Resolve_ExplicitDraftWithVersionErrors(t *testing.T) { + cmd, flags := newCmd() + + cmd.SetArgs([]string{"--scope=draft", "--version=1"}) + require.NoError(t, cmd.Execute()) + + _, _, err := flags.Resolve(cmd) + require.Error(t, err) + assert.Contains(t, err.Error(), "draft cannot be combined") +} + +func TestFlags_Resolve_LockedRequiresVersion(t *testing.T) { + cmd, flags := newCmd() + + cmd.SetArgs([]string{"--scope=locked"}) + require.NoError(t, cmd.Execute()) + + _, _, err := flags.Resolve(cmd) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires --version") +} + +func TestFlags_Resolve_VersionZeroIsRespected(t *testing.T) { + // 0 is the int zero-value, but the user explicitly passed --version=0 + // so we should treat it as locked v0 (server will validate). + cmd, flags := newCmd() + + cmd.SetArgs([]string{"--version=0"}) + require.NoError(t, cmd.Execute()) + + scope, version, err := flags.Resolve(cmd) + require.NoError(t, err) + assert.Equal(t, pipelines.ScopeLocked, scope) + require.NotNil(t, version) + assert.Equal(t, 0, *version) +} + +func TestFlags_Bind_RegistersAllFlags(t *testing.T) { + cmd, _ := newCmd() + + for _, name := range []string{"pipeline", "scope", "version"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s to be registered", name) + } +} diff --git a/cmd/pipelines/version/cmd.go b/cmd/pipelines/version/cmd.go new file mode 100644 index 000000000..9c6596f79 --- /dev/null +++ b/cmd/pipelines/version/cmd.go @@ -0,0 +1,40 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package version + +import ( + "github.com/datarobot/cli/cmd/pipelines/version/get" + "github.com/datarobot/cli/cmd/pipelines/version/list" + "github.com/spf13/cobra" +) + +// Cmd returns the parent command for `dr pipelines version`. +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "version", + Short: "Inspect pipeline versions", + Long: `Read-only access to pipeline versions. + +Versions are also surfaced inline by ` + "`dr pipelines get`" + `; this group +provides a paginated list and a single-version detail view.`, + } + + cmd.AddCommand( + list.Cmd(), + get.Cmd(), + ) + + return cmd +} diff --git a/cmd/pipelines/version/cmd_test.go b/cmd/pipelines/version/cmd_test.go new file mode 100644 index 000000000..217fbac5a --- /dev/null +++ b/cmd/pipelines/version/cmd_test.go @@ -0,0 +1,38 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package version + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCmd_RegistersAllVerbs(t *testing.T) { + cmd := Cmd() + + want := map[string]bool{ + "list": false, + "get": false, + } + + for _, sub := range cmd.Commands() { + want[sub.Name()] = true + } + + for verb, present := range want { + assert.Truef(t, present, "missing subcommand: %s", verb) + } +} diff --git a/cmd/pipelines/version/get/cmd.go b/cmd/pipelines/version/get/cmd.go new file mode 100644 index 000000000..e77aa6731 --- /dev/null +++ b/cmd/pipelines/version/get/cmd.go @@ -0,0 +1,93 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package get + +import ( + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/datarobot/cli/cmd/pipelines/version/versionutil" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + pipelineID string + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "get ", + Short: "Display details of a single pipeline version", + Long: `Display the lattice metadata for a single pipeline version. + +Example: + dr pipelines version get --pipeline 2 + dr pipelines version get --pipeline 2 --output json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if pipelineID == "" { + return errors.New("--pipeline is required") + } + + versionID, err := strconv.Atoi(args[0]) + if err != nil || versionID <= 0 { + return fmt.Errorf("invalid version: %q (expected a positive integer)", args[0]) + } + + result, err := pipelines.GetVersion(pipelineID, versionID) + if err != nil { + return handleGetError(err, args[0]) + } + + if outputFormat == "json" { + return versionutil.PrintVersionJSON(*result) + } + + versionutil.PrintVersionHuman(*result) + + return nil + }, + } + + cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} + +func handleGetError(err error, versionLabel string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No version found: " + versionLabel)) + + return nil + } + + return err +} diff --git a/cmd/pipelines/version/get/cmd_test.go b/cmd/pipelines/version/get/cmd_test.go new file mode 100644 index 000000000..f698944fe --- /dev/null +++ b/cmd/pipelines/version/get/cmd_test.go @@ -0,0 +1,75 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package get + +import ( + "errors" + "io" + "net/http" + "testing" + + "github.com/datarobot/cli/internal/drapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCmd_RejectsMissingPipelineFlag(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"2"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RejectsNonNumericVersion(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"abc", "--pipeline", "p"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid version") +} + +func TestCmd_RejectsZeroOrNegativeVersion(t *testing.T) { + cmd := Cmd() + cmd.SetArgs([]string{"0", "--pipeline", "p"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid version") +} + +func TestHandleGetError_404IsSuppressed(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: http.StatusNotFound, URL: "x"} + + err := handleGetError(httpErr, "2") + assert.NoError(t, err) +} + +func TestHandleGetError_OtherErrorsPropagate(t *testing.T) { + err := handleGetError(errors.New("boom"), "2") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} diff --git a/cmd/pipelines/version/list/cmd.go b/cmd/pipelines/version/list/cmd.go new file mode 100644 index 000000000..da701ba45 --- /dev/null +++ b/cmd/pipelines/version/list/cmd.go @@ -0,0 +1,76 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package list + +import ( + "errors" + "fmt" + + "github.com/datarobot/cli/cmd/pipelines/version/versionutil" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipelines" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + pipelineID string + offset int + limit int + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List versions of a pipeline", + Long: `List versions of a pipeline (paginated). + +Example: + dr pipelines version list --pipeline + dr pipelines version list --pipeline --offset 10 --limit 5 --output json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, _ []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if pipelineID == "" { + return errors.New("--pipeline is required") + } + + items, err := pipelines.ListVersions(pipelineID, offset, limit) + if err != nil { + return err + } + + if outputFormat == "json" { + return versionutil.PrintVersionListJSON(items) + } + + versionutil.PrintVersionListHuman(items) + + return nil + }, + } + + cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") + cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of versions to return") + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} diff --git a/cmd/pipelines/version/versionutil/render.go b/cmd/pipelines/version/versionutil/render.go new file mode 100644 index 000000000..e2e493050 --- /dev/null +++ b/cmd/pipelines/version/versionutil/render.go @@ -0,0 +1,116 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package versionutil contains rendering helpers shared by the +// `dr pipelines version` verbs. Living in a sibling package keeps the +// parent `version` package free of cycles. + +package versionutil + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" +) + +// PrintVersionJSON marshals a single version as indented JSON. +func PrintVersionJSON(v pipelines.PipelineVersion) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintVersionHuman renders the key facts about a single version. +func PrintVersionHuman(v pipelines.PipelineVersion) { + electrons := "\u2014" + if len(v.ElectronNames) > 0 { + electrons = strings.Join(v.ElectronNames, ", ") + } + + python := v.PythonVersion + if python == "" { + python = "\u2014" + } + + fmt.Println(tui.BaseTextStyle.Render("Version: v" + strconv.Itoa(v.Version))) + fmt.Println(tui.BaseTextStyle.Render("Lattice: " + v.LatticeName)) + fmt.Println(tui.BaseTextStyle.Render("Status: " + v.Status)) + fmt.Println(tui.BaseTextStyle.Render("Python Version: " + python)) + fmt.Println(tui.BaseTextStyle.Render("Electrons: " + electrons)) + + if v.ErrorDetail != "" { + fmt.Println(tui.BaseTextStyle.Render("Error: " + v.ErrorDetail)) + } + + fmt.Println(tui.DimStyle.Render("Created: " + v.CreatedAt.UTC().Format(time.RFC3339))) +} + +// PrintVersionListJSON marshals a list of versions as indented JSON. +func PrintVersionListJSON(items []pipelines.PipelineVersion) error { + data, err := json.MarshalIndent(items, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintVersionListHuman renders a tabular summary of versions. +func PrintVersionListHuman(items []pipelines.PipelineVersion) { + if len(items) == 0 { + fmt.Println(tui.DimStyle.Render("No versions found")) + + return + } + + writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintln(writer, "VERSION\tSTATUS\tPYTHON\tCREATED\tELECTRONS") + + for _, v := range items { + electrons := "\u2014" + if len(v.ElectronNames) > 0 { + electrons = strings.Join(v.ElectronNames, ", ") + } + + python := v.PythonVersion + if python == "" { + python = "\u2014" + } + + fmt.Fprintf(writer, "v%s\t%s\t%s\t%s\t%s\n", + strconv.Itoa(v.Version), + v.Status, + python, + v.CreatedAt.UTC().Format(time.RFC3339), + electrons, + ) + } + + _ = writer.Flush() +} diff --git a/docs/commands/README.md b/docs/commands/README.md index 8bcf2ada8..92664b978 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -41,6 +41,7 @@ These flags are available for all commands: | [`dotenv`](dotenv.md) | Manage environment variables. | | [`self`](self.md) | CLI utility commands (update, version, completion, plugin). | | [`plugin`](plugins.md) | Inspect and manage CLI plugins. | +| [`pipelines`](pipelines.md) | Manage pipelines via the pipelines API (feature-gated). | | [`dependencies`](dependencies.md) | Check and install template dependencies (advanced). | ### Command tree @@ -74,6 +75,35 @@ dr │ ├── install Install a plugin │ ├── uninstall Uninstall a plugin │ └── update Update plugins +├── pipelines Pipelines API management (feature-gated) +│ ├── create Upload a Python file to create a pipeline +│ ├── list List pipelines +│ ├── get Display pipeline details and versions +│ ├── update Re-upload a Python file to update a draft pipeline +│ ├── delete Delete a pipeline and all of its versions +│ ├── lock Promote a draft pipeline to locked mode +│ ├── version Inspect pipeline versions +│ │ ├── list List versions of a pipeline +│ │ └── get Display details of a single pipeline version +│ ├── graph Display the lattice/electron DAG of a pipeline +│ ├── input Manage pipeline input payloads +│ │ ├── create Register a JSON payload on a pipeline +│ │ ├── list List inputs for a pipeline (draft or locked scope) +│ │ ├── get Display a single input +│ │ ├── update Update a draft input's payload +│ │ └── delete Delete an input +│ ├── dispatch Trigger and inspect pipeline executions +│ │ ├── create Trigger a dispatch from an input +│ │ ├── list List dispatches for a pipeline +│ │ ├── get Display a single dispatch +│ │ ├── status Lightweight dispatch status (for polling) +│ │ └── cancel Cancel a running dispatch +│ └── schedule Manage recurring (cron) dispatches (locked-only) +│ ├── create Register a recurring schedule on a locked version +│ ├── list List schedules for a locked version +│ ├── get Display a single schedule +│ ├── update Change cron expression / timezone +│ └── delete Delete a schedule └── self CLI utility commands ├── completion Shell completion │ ├── install Install completions interactively @@ -235,6 +265,19 @@ For detailed documentation on each command, see: - **[plugin](plugins.md)**—inspect and manage installed CLI plugins (alias: `plugins`). +- **[pipelines](pipelines.md)**—manage AI/ML pipelines orchestrated by Covalent (feature-gated behind `DATAROBOT_CLI_FEATURE_PIPELINES=true`). See the [pipelines reference](pipelines-reference.md) for an exhaustive endpoint mapping. + - `create`—upload a Python file to register a new pipeline. + - `list`—list pipelines with mode filtering and pagination. + - `get`—display full details of a pipeline including all versions. + - `update`—re-upload a Python file to append a new version to a draft pipeline. + - `delete`—remove a pipeline and all of its versions. + - `lock`—promote a draft pipeline to locked mode. + - `version`—`list` / `get` to inspect pipeline versions. + - `graph`—display the lattice/electron DAG (draft or locked). + - `input`—`create`/`list`/`get`/`update`/`delete` JSON payloads used by dispatches. + - `dispatch`—`create`/`list`/`get`/`status`/`cancel` pipeline executions. + - `schedule`—`create`/`list`/`get`/`update`/`delete` recurring (cron) dispatches on locked versions. + ## Getting help ```bash diff --git a/docs/commands/pipelines-reference.md b/docs/commands/pipelines-reference.md new file mode 100644 index 000000000..3a2f3dec5 --- /dev/null +++ b/docs/commands/pipelines-reference.md @@ -0,0 +1,178 @@ + +# `dr pipelines` command reference + +Complete cross-reference of every `dr pipelines …` subcommand, the +`pipelines-api` endpoint each one calls, sample invocations, and the +inputs (positional args, flags, request body fields) each command +accepts. + +> All commands below assume the `pipelines` feature is enabled +> (`DATAROBOT_CLI_FEATURE_PIPELINES=true`). + +## How to read this document + +- **Method + path** is relative to `/api/v2`. The CLI prefixes the host + from `DATAROBOT_CLI_ENDPOINT` (or `DATAROBOT_ENDPOINT`). +- **Usage** lists the canonical invocation plus common variants. +- **Inputs** names every positional argument and flag the command + accepts. Flags shared by many commands (`--output`, `--scope`, + `--version`, `--from-file`, `--skip-auth`) are described once at the + bottom under "Shared flag semantics". + +--- + +## Pipeline lifecycle + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipelines create` | `POST /pipelines` | `dr pipelines create ./my_pipeline.py`
`dr pipelines create --from-file=./my_pipeline.py`
`dr pipelines create ./my_pipeline.py --description "First draft" --mode draft`
`dr pipelines create --from-file=./my_pipeline.py --output json` | **Positional:** `` (Python file with a Covalent lattice; mutually exclusive with `--from-file`).
**Flags:** `--from-file=`, `--description `, `--mode draft\|locked`, `--output json`. | +| `dr pipelines list` | `GET /pipelines` | `dr pipelines list`
`dr pipelines list --mode draft`
`dr pipelines list --offset 50 --limit 10 --output json` | **Flags:** `--mode draft\|locked`, `--offset `, `--limit `, `--output json`. | +| `dr pipelines get` | `GET /pipelines/{pipeline_id}` | `dr pipelines get `
`dr pipelines get --output json` | **Positional:** `` (required).
**Flags:** `--output json`. | +| `dr pipelines update` | `PATCH /pipelines/{pipeline_id}` | `dr pipelines update ./my_pipeline.py`
`dr pipelines update --from-file=./my_pipeline.py`
`dr pipelines update --from-file=./my_pipeline.py --output json` | **Positional:** `` (required), `` (mutually exclusive with `--from-file`).
**Flags:** `--from-file=`, `--output json`. | +| `dr pipelines delete` | `DELETE /pipelines/{pipeline_id}` | `dr pipelines delete ` | **Positional:** `` (required). | +| `dr pipelines lock` | `PATCH /pipelines/{pipeline_id}/mode` | `dr pipelines lock `
`dr pipelines lock --output json` | **Positional:** `` (required).
**Flags:** `--output json`.
**Body:** none (the API uses absence-of-body as the promote signal). | + +--- + +## Versions + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipelines version list` | `GET /pipelines/{pipeline_id}/versions` | `dr pipelines version list --pipeline `
`dr pipelines version list --pipeline --offset 10 --limit 5 --output json` | **Flags:** `--pipeline ` (required), `--offset `, `--limit `, `--output json`. | +| `dr pipelines version get` | `GET /pipelines/{pipeline_id}/versions/{version_id}` | `dr pipelines version get --pipeline 2`
`dr pipelines version get --pipeline 2 --output json` | **Positional:** `` (positive integer, required).
**Flags:** `--pipeline ` (required), `--output json`. | +| `dr pipelines graph` | `GET /pipelines/{pipeline_id}/graph` (draft)
`GET /pipelines/{pipeline_id}/versions/{version_id}/graph` (locked) | `dr pipelines graph --pipeline ` (draft)
`dr pipelines graph --pipeline --version=2` (locked)
`dr pipelines graph --pipeline --version=2 --output json` | **Flags:** `--pipeline ` (required), `--scope draft\|locked`, `--version `, `--output json`. | + +--- + +## Inputs (`dr pipelines input …`) + +Inputs come in two scopes — **draft** (mutable, no version pinned) and +**locked** (immutable, tied to a frozen version). Scope selection rules +are documented under "Shared flag semantics" below. + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipelines input create` | `POST /pipelines/{id}/inputs` (draft)
`POST /pipelines/{id}/versions/{ver}/inputs` (locked) | `dr pipelines input create --pipeline ./payload.json`
`dr pipelines input create --pipeline --from-file=./payload.json`
`dr pipelines input create --pipeline --version=2 ./payload.json --output json` | **Positional:** `` (JSON object; mutually exclusive with `--from-file`).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--from-file=`, `--output json`.
**Body sent to API:** `{"payload": }`. | +| `dr pipelines input list` | `GET /pipelines/{id}/inputs` (draft)
`GET /pipelines/{id}/versions/{ver}/inputs` (locked) | `dr pipelines input list --pipeline ` (draft)
`dr pipelines input list --pipeline --version=2` (locked)
`dr pipelines input list --pipeline --offset 10 --limit 5 --output json` | **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--offset `, `--limit `, `--output json`. | +| `dr pipelines input get` | `GET /pipelines/{id}/inputs/{input_id}` (draft)
`GET /pipelines/{id}/versions/{ver}/inputs/{input_id}` (locked) | `dr pipelines input get --pipeline `
`dr pipelines input get --pipeline --version=2 --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output json`. | +| `dr pipelines input update` | `PATCH /pipelines/{id}/inputs/{input_id}` (draft only) | `dr pipelines input update --pipeline ./payload.json`
`dr pipelines input update --pipeline --from-file=./payload.json --output json` | **Positional:** `` (required), `` (JSON object; mutually exclusive with `--from-file`).
**Flags:** `--pipeline ` (required), `--from-file=`, `--output json`.
**Body sent to API:** `{"payload": }`. | +| `dr pipelines input delete` | `DELETE /pipelines/{id}/inputs/{input_id}` (draft)
`DELETE /pipelines/{id}/versions/{ver}/inputs/{input_id}` (locked) | `dr pipelines input delete --pipeline `
`dr pipelines input delete --pipeline --version=2 ` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`. | + +--- + +## Dispatches (`dr pipelines dispatch …`) + +Same draft/locked scope rules as inputs. + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipelines dispatch create` | `POST /pipelines/{id}/dispatches` (draft)
`POST /pipelines/{id}/versions/{ver}/dispatches` (locked) | `dr pipelines dispatch create --pipeline --input `
`dr pipelines dispatch create --pipeline --version=2 --input --output json` | **Flags:** `--pipeline ` (required), `--input ` (required), `--scope`, `--version`, `--output json`.
**Body sent to API:** `{"input_id": ""}`. | +| `dr pipelines dispatch list` | `GET /pipelines/{id}/dispatches` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches` (locked) | `dr pipelines dispatch list --pipeline `
`dr pipelines dispatch list --pipeline --version=2 --output json` | **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--offset `, `--limit `, `--output json`. | +| `dr pipelines dispatch get` | `GET /pipelines/{id}/dispatches/{dispatch_id}` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` (locked) | `dr pipelines dispatch get --pipeline `
`dr pipelines dispatch get --pipeline --version=2 --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output json`. | +| `dr pipelines dispatch status` | `GET /pipelines/{id}/dispatches/{dispatch_id}/status` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}/status` (locked) | `dr pipelines dispatch status --pipeline `
`dr pipelines dispatch status --pipeline --version=2 --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output json`. | +| `dr pipelines dispatch cancel` | `DELETE /pipelines/{id}/dispatches/{dispatch_id}` (draft)
`DELETE /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` (locked) | `dr pipelines dispatch cancel --pipeline `
`dr pipelines dispatch cancel --pipeline --version=2 ` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`. | + +--- + +## Schedules (`dr pipelines schedule …`) + +Schedules are **locked-only** — every verb requires both `--pipeline` and +`--version`. There is no draft scope or `--scope` flag. + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipelines schedule create` | `POST /pipelines/{id}/versions/{ver}/schedules` | `dr pipelines schedule create --pipeline --version=2 --cron "0 * * * *" --input `
`dr pipelines schedule create --pipeline --version=2 --cron "0 9 * * *" --input --timezone America/Los_Angeles`
`… --output json` | **Flags:** `--pipeline ` (required), `--version ` (required, > 0), `--cron ""` (required), `--input ` (required), `--timezone ` (default `UTC`), `--output json`.
**Body sent to API:** `{"cron_expression": "...", "pipeline_input_id": "...", "timezone": "..."}`. | +| `dr pipelines schedule list` | `GET /pipelines/{id}/versions/{ver}/schedules` | `dr pipelines schedule list --pipeline --version=2`
`dr pipelines schedule list --pipeline --version=2 --offset 10 --limit 5 --output json` | **Flags:** `--pipeline ` (required), `--version ` (required, > 0), `--offset `, `--limit `, `--output json`. | +| `dr pipelines schedule get` | `GET /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipelines schedule get --pipeline --version=2 `
`… --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--version ` (required, > 0), `--output json`. | +| `dr pipelines schedule update` | `PATCH /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipelines schedule update --pipeline --version=2 --cron "*/15 * * * *"`
`dr pipelines schedule update --pipeline --version=2 --timezone Europe/Berlin`
`… --cron "0 0 * * *" --timezone UTC --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--version ` (required, > 0), `--cron ""`, `--timezone `, `--output json`. At least one of `--cron`/`--timezone` must be supplied.
**Body sent to API:** `{"cron_expression"?: "...", "timezone"?: "..."}` (only fields you changed). | +| `dr pipelines schedule delete` | `DELETE /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipelines schedule delete --pipeline --version=2 ` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--version ` (required, > 0). | + +--- + +## Shared flag semantics + +### `--scope` / `--version` (inputs, dispatches, graph) + +The CLI mirrors the API's two URL shapes — `/pipelines/{id}/…` for the +mutable draft and `/pipelines/{id}/versions/{ver}/…` for a locked +version — through a pair of optional flags: + +| Flags supplied | Resolved scope | URL used | +|---|---|---| +| _(none)_ | `draft` | `/pipelines/{id}/…` | +| `--version=N` | `locked` (auto) | `/pipelines/{id}/versions/N/…` | +| `--scope=draft` | `draft` | `/pipelines/{id}/…` | +| `--scope=locked --version=N` | `locked` | `/pipelines/{id}/versions/N/…` | +| `--scope=draft --version=N` | **error** | `--scope=draft cannot be combined with --version` | +| `--scope=locked` (no `--version`) | **error** | `--scope=locked requires --version=` | +| `--scope=garbage` | **error** | `invalid --scope: "garbage" (supported: draft, locked)` | + +### `--from-file` / positional file (create + update verbs) + +`pipelines create`, `pipelines update`, `pipelines input create`, and +`pipelines input update` all accept the input file in two equivalent +ways: + +```bash +dr pipelines create ./my_pipeline.py +dr pipelines create --from-file=./my_pipeline.py +``` + +Exactly one of the two must be supplied; passing both yields +`specify the file either as a positional argument or via --from-file, not both`, +and supplying neither yields `a file path is required …` (or +`a JSON payload file is required …` for input verbs). + +### `--output` + +Every read/write verb that produces a payload accepts `--output json` to +emit the underlying response struct as indented JSON. Any other value +(e.g. `--output yaml`, `--output csv`) is rejected with +`invalid output format: (supported: json)`. + +### `auth` / `--skip-auth` + +All verbs run `auth.EnsureAuthenticatedE` as their `PreRunE`. Pass the +global `--skip-auth` flag (or set `DATAROBOT_CLI_SKIP_AUTH=true`) when +exercising a local API stub that doesn't implement `/version/`. + +--- + +## Quick endpoint lookup + +| API endpoint | CLI command | +|---|---| +| `POST /pipelines` | `dr pipelines create` | +| `GET /pipelines` | `dr pipelines list` | +| `GET /pipelines/{id}` | `dr pipelines get` | +| `PATCH /pipelines/{id}` | `dr pipelines update` | +| `DELETE /pipelines/{id}` | `dr pipelines delete` | +| `PATCH /pipelines/{id}/mode` | `dr pipelines lock` | +| `GET /pipelines/{id}/versions` | `dr pipelines version list` | +| `GET /pipelines/{id}/versions/{ver}` | `dr pipelines version get` | +| `GET /pipelines/{id}/graph` | `dr pipelines graph` (draft) | +| `GET /pipelines/{id}/versions/{ver}/graph` | `dr pipelines graph --version=N` | +| `POST /pipelines/{id}/inputs` | `dr pipelines input create` (draft) | +| `POST /pipelines/{id}/versions/{ver}/inputs` | `dr pipelines input create --version=N` | +| `GET /pipelines/{id}/inputs` | `dr pipelines input list` (draft) | +| `GET /pipelines/{id}/versions/{ver}/inputs` | `dr pipelines input list --version=N` | +| `GET /pipelines/{id}/inputs/{input_id}` | `dr pipelines input get` (draft) | +| `GET /pipelines/{id}/versions/{ver}/inputs/{input_id}` | `dr pipelines input get --version=N` | +| `PATCH /pipelines/{id}/inputs/{input_id}` | `dr pipelines input update` | +| `DELETE /pipelines/{id}/inputs/{input_id}` | `dr pipelines input delete` (draft) | +| `DELETE /pipelines/{id}/versions/{ver}/inputs/{input_id}` | `dr pipelines input delete --version=N` | +| `POST /pipelines/{id}/dispatches` | `dr pipelines dispatch create` (draft) | +| `POST /pipelines/{id}/versions/{ver}/dispatches` | `dr pipelines dispatch create --version=N` | +| `GET /pipelines/{id}/dispatches` | `dr pipelines dispatch list` (draft) | +| `GET /pipelines/{id}/versions/{ver}/dispatches` | `dr pipelines dispatch list --version=N` | +| `GET /pipelines/{id}/dispatches/{dispatch_id}` | `dr pipelines dispatch get` (draft) | +| `GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` | `dr pipelines dispatch get --version=N` | +| `GET /pipelines/{id}/dispatches/{dispatch_id}/status` | `dr pipelines dispatch status` (draft) | +| `GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}/status` | `dr pipelines dispatch status --version=N` | +| `DELETE /pipelines/{id}/dispatches/{dispatch_id}` | `dr pipelines dispatch cancel` (draft) | +| `DELETE /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` | `dr pipelines dispatch cancel --version=N` | +| `POST /pipelines/{id}/versions/{ver}/schedules` | `dr pipelines schedule create` | +| `GET /pipelines/{id}/versions/{ver}/schedules` | `dr pipelines schedule list` | +| `GET /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipelines schedule get` | +| `PATCH /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipelines schedule update` | +| `DELETE /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipelines schedule delete` | diff --git a/docs/commands/pipelines.md b/docs/commands/pipelines.md new file mode 100644 index 000000000..9307d6fab --- /dev/null +++ b/docs/commands/pipelines.md @@ -0,0 +1,543 @@ +# `dr pipelines` - Pipelines API management + +Manage AI/ML pipelines orchestrated by Covalent through the DataRobot +pipelines service. The `dr pipelines` group is a thin CLI wrapper over +the pipelines REST API: every subcommand maps directly to a single +endpoint. + +## Quick start + +```bash +# List pipelines registered with the pipelines service +dr pipelines list + +# Register a new draft pipeline by uploading a Covalent lattice +dr pipelines create ./my_pipeline.py --description "First draft" + +# Append a new version after editing the file +dr pipelines update ./my_pipeline.py + +# Promote the draft to locked when you are happy with it +dr pipelines lock + +# Cancel a stuck dispatch +dr pipelines dispatch cancel --pipeline +``` + +> [!NOTE] +> The `pipelines` command is currently behind a feature gate. Enable it +> by exporting `DATAROBOT_CLI_FEATURE_PIPELINES=true` before running any +> `dr pipelines` subcommand. See +> [Feature gates](../development/feature-gates.md) for details. + +> [!NOTE] +> **First time?** If you're new to the CLI, start with the +> [Quick start](../../README.md#quick-start) for step-by-step setup +> instructions. + +## Synopsis + +```bash +dr pipelines [subcommand] [flags] +``` + +## Description + +A **pipeline** is a versioned wrapper around a Covalent lattice. Each +top-level `dr pipelines` subcommand operates on one of four resources: + +- the **pipeline** itself (create, list, get, update, delete, lock), +- pipeline **versions** (list, get, graph), +- pipeline **inputs** — JSON payloads supplied to a dispatch, +- pipeline **dispatches** — concrete executions on Covalent, +- pipeline **schedules** — recurring dispatches on a cron expression. + +Versions are created automatically: + +- The first `create` call registers the lattice as **v1** in `draft` + mode. +- `update` re-uploads the same file (or an edited copy) and appends + **v2**, **v3**, etc., as long as the lattice name still matches and + the pipeline is still in `draft` mode. +- `lock` promotes a draft to **locked** mode. Locked pipelines are + immutable; their inputs and schedules become valid. + +Inputs, dispatches, and the graph endpoint exist in two scopes — +**draft** (mutable, no version pinned) and **locked** (immutable, tied +to a frozen version) — selected via the shared `--scope` and +`--version` flags. Schedules are locked-only. + +> For an exhaustive table mapping every CLI command to its API endpoint, +> see [pipelines-reference.md](pipelines-reference.md). + +## Command groups + +| Group | Endpoint(s) | Purpose | +|------------------------------|--------------------------------------------------|--------------------------------------------------------| +| `dr pipelines create` | `POST /api/v2/pipelines` | Upload a Python file to register a new pipeline. | +| `dr pipelines list` | `GET /api/v2/pipelines` | Paginated list with mode filtering. | +| `dr pipelines get` | `GET /api/v2/pipelines/{id}` | Pipeline detail including all versions. | +| `dr pipelines update` | `PATCH /api/v2/pipelines/{id}` | Re-upload a file to append a new version. | +| `dr pipelines delete` | `DELETE /api/v2/pipelines/{id}` | Remove a pipeline and all of its versions. | +| `dr pipelines lock` | `PATCH /api/v2/pipelines/{id}/mode` | Promote a draft to locked mode. | +| `dr pipelines version …` | `…/versions[/{ver}]` | Inspect pipeline versions. | +| `dr pipelines graph` | `…/graph` (draft) or `…/versions/{ver}/graph` | Render the lattice/electron DAG. | +| `dr pipelines input …` | `…/inputs` and `…/inputs/{input_id}` | Manage JSON payloads for dispatches. | +| `dr pipelines dispatch …` | `…/dispatches` and `…/dispatches/{dispatch_id}` | Trigger, inspect, and cancel runs. | +| `dr pipelines schedule …` | `…/versions/{ver}/schedules` | Manage recurring (cron) dispatches on locked versions. | + +## Subcommands + +### `create` + +Upload a Python file containing a Covalent lattice and register a new +pipeline. The lattice name is extracted from the file and used as the +pipeline name. + +```bash +dr pipelines create [flags] +dr pipelines create --from-file= [flags] +``` + +**Arguments:** + +- `` — path to a `.py` file containing a single Covalent lattice. + Mutually exclusive with `--from-file`. + +**Flags:** + +- `--from-file ` — alternative to the positional file argument. +- `--description ` — optional human-readable description stored on + the pipeline. +- `--mode ` — pipeline lifecycle mode. Defaults to `draft`. +- `--output ` — emit machine-parseable JSON instead of the + human-readable summary. + +**Example:** + +```bash +$ dr pipelines create ./confluence_to_vdb.py --description "test" +Pipeline: 6658f441-a8f5-4f21-b4d8-6cccf4c94c5b +Name: confluence_to_vdb +Version: 1 +Status: READY +Mode: draft +Electrons: create_vector_database, ingest_confluence_files, setup_credential_and_datastore +Created: 2026-04-28T11:42:28Z +``` + +### `list` + +List pipelines registered with the pipelines service, with optional +mode filtering and pagination. + +```bash +dr pipelines list [flags] +``` + +**Flags:** + +- `--mode ` — filter by pipeline mode. +- `--offset ` — pagination offset. Default `0`. +- `--limit ` — pagination limit (1-200). Default `50`. +- `--output ` — emit machine-parseable JSON instead of a table. + +**Example:** + +```bash +$ dr pipelines list +Showing 1 of 1 (offset=0 limit=50) + +ID NAME MODE ACTIVE VERSION UPDATED +6658f441-a8f5-4f21-b4d8-6cccf4c94c5b confluence_to_vdb draft true v3 2026-04-28T12:25:11Z +``` + +| Column | Meaning | +|-----------|-----------------------------------------------------------------| +| `ID` | Pipeline UUID, used as the argument to `get` / `update` / etc. | +| `NAME` | Lattice name extracted from the originally uploaded file. | +| `MODE` | `draft` (mutable) or `locked` (immutable). | +| `ACTIVE` | `true` while the pipeline has not been soft-deleted. | +| `VERSION` | Latest version number, or `—` when no versions exist yet. | +| `UPDATED` | Last modification time in UTC (RFC 3339). | + +### `get` + +Display full details of a single pipeline including all versions. + +```bash +dr pipelines get [flags] +``` + +**Arguments:** + +- `` — the UUID returned by `create` / shown in `pipelines list`. + +**Flags:** + +- `--output ` — emit machine-parseable JSON. + +**Example:** + +```bash +$ dr pipelines get 6658f441-a8f5-4f21-b4d8-6cccf4c94c5b +ID: 6658f441-a8f5-4f21-b4d8-6cccf4c94c5b +Name: confluence_to_vdb +Description: test +Mode: draft +Active: true +Created: 2026-04-28T11:42:28Z +Updated: 2026-04-28T12:25:11Z + +Versions (3): + VERSION STATUS PYTHON CREATED ELECTRONS + v1 READY 3.12 2026-04-28T11:42:28Z create_vector_database, ingest_confluence_files, setup_credential_and_datastore + v2 READY 3.12 2026-04-28T12:24:54Z create_vector_database, ingest_confluence_files, setup_credential_and_datastore + v3 READY 3.12 2026-04-28T12:25:11Z create_vector_database, ingest_confluence_files, setup_credential_and_datastore +``` + +If a version failed to register, its `error_detail` is rendered as a +dim line underneath the table. + +If the pipeline doesn't exist, `get` prints +`No pipeline found with id: ` and exits 0 instead of dumping an +HTTP error. + +### `update` + +Re-upload a Python file to update a draft pipeline. A new version is +appended. + +```bash +dr pipelines update [flags] +dr pipelines update --from-file= [flags] +``` + +**Arguments:** + +- `` — the UUID of the pipeline to update. +- `` — path to the updated `.py` file. Mutually exclusive with + `--from-file`. + +**Flags:** + +- `--from-file ` — alternative to the positional file argument. +- `--output ` — emit machine-parseable JSON. + +**Constraints:** + +- The lattice name in the uploaded file **must match** the pipeline's + existing name. To register a different lattice, use `create` instead. +- Locked pipelines cannot be updated. The API responds with + `409 Conflict`. + +### `delete` + +Delete a pipeline and all of its versions. + +```bash +dr pipelines delete +``` + +**Arguments:** + +- `` — the UUID of the pipeline to delete. + +**Example:** + +```bash +$ dr pipelines delete 6658f441-a8f5-4f21-b4d8-6cccf4c94c5b +Deleted pipeline: 6658f441-a8f5-4f21-b4d8-6cccf4c94c5b +``` + +If the pipeline doesn't exist, `delete` prints +`No pipeline found with id: ` and exits 0. + +### `lock` + +Promote a draft pipeline to locked mode. Once locked, the pipeline can +no longer be updated and locked dispatches/inputs/schedules become +valid. + +```bash +dr pipelines lock [flags] +``` + +**Arguments:** + +- `` — the UUID of the pipeline to lock. + +**Flags:** + +- `--output ` — emit machine-parseable JSON. + +**Example:** + +```bash +$ dr pipelines lock 6658f441-a8f5-4f21-b4d8-6cccf4c94c5b +Pipeline: 6658f441-a8f5-4f21-b4d8-6cccf4c94c5b +Name: confluence_to_vdb +Mode: locked +Version: v3 +Status: READY +Electrons: create_vector_database, ingest_confluence_files, setup_credential_and_datastore +Locked: 2026-04-28T12:30:00Z +``` + +### `version` + +Read-only access to pipeline versions. + +```bash +dr pipelines version list --pipeline [--offset N] [--limit N] [--output json] +dr pipelines version get --pipeline [--output json] +``` + +`version list` returns the same data that's shown inline by +`pipelines get`, but in a paginated stand-alone view. `version get` +shows a single version in detail (lattice, electrons, Python version, +creation timestamp, and any error detail). + +### `graph` + +Display the lattice/electron DAG for a pipeline as either a JSON +payload (for visualisation tooling) or a human-readable summary. + +```bash +dr pipelines graph --pipeline # draft graph +dr pipelines graph --pipeline --version=N # locked-version graph +dr pipelines graph --pipeline --scope=draft # explicit draft +dr pipelines graph --pipeline --scope=locked --version=N +dr pipelines graph --pipeline --output json # JSON payload +``` + +The human view prints the lattice header followed by `Nodes (N):` and +`Edges (M):` tables. Pass `--output json` to get the structured `Graph` +object (`lattice`, `nodes[]`, `edges[]`) suitable for piping to +visualisation tooling. + +See [Shared `--scope` / `--version` semantics](#scope--version-flags) +below for the full flag truth table. + +### `input` + +Manage JSON payloads that drive a dispatch. + +```bash +dr pipelines input create --pipeline # draft scope +dr pipelines input create --pipeline --version=N # locked scope +dr pipelines input list --pipeline [--scope|--version] [--offset N] [--limit N] +dr pipelines input get --pipeline [--scope|--version] +dr pipelines input update --pipeline # draft only +dr pipelines input delete --pipeline [--scope|--version] +``` + +- The payload file must contain a JSON object. The CLI wraps it in + `{"payload": …}` before sending. +- All verbs accept `--scope` / `--version` (see below). Inputs in the + `locked` scope are immutable, so `input update` is draft-only. + +### `dispatch` + +Trigger, inspect, and cancel pipeline executions. + +```bash +dr pipelines dispatch create --pipeline --input # draft +dr pipelines dispatch create --pipeline --version=N --input # locked +dr pipelines dispatch list --pipeline [--scope|--version] +dr pipelines dispatch get --pipeline [--scope|--version] +dr pipelines dispatch status --pipeline [--scope|--version] +dr pipelines dispatch cancel --pipeline [--scope|--version] +``` + +`dispatch status` is a lighter-weight call than `dispatch get` — +intended for polling — and returns just the dispatch ID, status, and +the corresponding Covalent dispatch ID. + +`dispatch cancel` returns `409 Conflict` if the dispatch is already in +a terminal state (COMPLETED / FAILED / CANCELLED). + +### `schedule` + +Manage recurring (cron) dispatches on locked versions only. Both +`--pipeline` and `--version` are required for every verb. + +```bash +dr pipelines schedule create --pipeline --version=N \ + --cron "0 * * * *" --input [--timezone UTC] +dr pipelines schedule list --pipeline --version=N [--offset N] [--limit N] +dr pipelines schedule get --pipeline --version=N +dr pipelines schedule update --pipeline --version=N [--cron "*/15 * * * *"] [--timezone Europe/Berlin] +dr pipelines schedule delete --pipeline --version=N +``` + +`schedule update` requires at least one of `--cron` or `--timezone`. + +## Shared flags + +### `--scope` / `--version` flags + +Inputs, dispatches, and `graph` mirror the API's two URL shapes — +`/pipelines/{id}/…` for the mutable draft and +`/pipelines/{id}/versions/{ver}/…` for a locked version — through a +pair of optional flags: + +| Flags supplied | Resolved scope | URL used | +|--------------------------------------|-------------------|-------------------------------------| +| _(none)_ | `draft` | `/pipelines/{id}/…` | +| `--version=N` | `locked` (auto) | `/pipelines/{id}/versions/N/…` | +| `--scope=draft` | `draft` | `/pipelines/{id}/…` | +| `--scope=locked --version=N` | `locked` | `/pipelines/{id}/versions/N/…` | +| `--scope=draft --version=N` | **error** | `--scope=draft cannot be combined with --version` | +| `--scope=locked` (no `--version`) | **error** | `--scope=locked requires --version=` | +| `--scope=garbage` | **error** | `invalid --scope: "garbage" (supported: draft, locked)` | + +Schedules do not accept `--scope`; they are locked-only and require +`--version` on every verb. + +### `--from-file` / positional file + +`pipelines create`, `pipelines update`, `pipelines input create`, and +`pipelines input update` all accept the input file in two equivalent +ways: + +```bash +dr pipelines create ./my_pipeline.py +dr pipelines create --from-file=./my_pipeline.py +``` + +Exactly one of the two must be supplied; passing both yields +`specify the file either as a positional argument or via --from-file, not both`, +and supplying neither yields `a file path is required …` (or +`a JSON payload file is required …` for input verbs). + +### `--output` + +Every read/write verb that produces a payload accepts `--output json` +to emit the underlying response struct as indented JSON. Any other +value is rejected with +`invalid output format: (supported: json)`. + +```bash +dr pipelines list --output json | jq '.items[].pipeline_id' +``` + +### Global options + +All [global flags](README.md#global-flags) are available, notably +`--debug` for protocol-level tracing and `--skip-auth` for advanced +scenarios. + +## Local development + +While iterating against a locally running pipelines-api (default port +`8000`), point the CLI at `http://localhost:8000` and bypass token +verification using the prefixed environment variables: + +```bash +export DATAROBOT_CLI_FEATURE_PIPELINES=true +export DATAROBOT_CLI_ENDPOINT=http://localhost:8000/api/v2 +export DATAROBOT_CLI_TOKEN=local +export DATAROBOT_CLI_SKIP_AUTH=true + +./dist/dr pipelines list +``` + +Why each variable matters: + +| Variable | Purpose | +|-----------------------------------|----------------------------------------------------------------------------------------------------------| +| `DATAROBOT_CLI_FEATURE_PIPELINES` | Reveals the feature-gated `pipelines` command group. | +| `DATAROBOT_CLI_ENDPOINT` | Auto-bound to viper's `endpoint` key via `SetEnvPrefix("DATAROBOT_CLI") + AutomaticEnv()` in the CLI. | +| `DATAROBOT_CLI_TOKEN` | Same prefix story — bound to viper's `token` key for outbound `Authorization: Bearer` headers. | +| `DATAROBOT_CLI_SKIP_AUTH` | Skips token verification against `/version/`, which the local stub does not implement. | + +The unprefixed `DATAROBOT_ENDPOINT` / `DATAROBOT_API_TOKEN` variables +are **only** bound to viper after a successful token verification and +therefore do not work alongside `--skip-auth` or +`DATAROBOT_CLI_SKIP_AUTH=true`. Always prefer the `DATAROBOT_CLI_*` +names during local development. + +> [!TIP] +> Use `--debug` to see the full request URL, headers, and body the CLI +> sends. Logs are written to `.dr-tui-debug.log`. + +For a step-by-step walkthrough of how `dr pipelines list` was wired up, +see [Adding a command](../development/adding-a-command.md). + +## Examples + +### Pipeline lifecycle + +```bash +# Register a draft, append a version, lock it, then delete it +dr pipelines create ./my_pipeline.py --description "Initial draft" +dr pipelines update ./my_pipeline.py +dr pipelines lock +dr pipelines delete +``` + +### Inspect versions and graph + +```bash +dr pipelines version list --pipeline +dr pipelines version get --pipeline 2 +dr pipelines graph --pipeline --version=2 --output json +``` + +### Run a dispatch + +```bash +# 1. Register a JSON input on the draft scope +dr pipelines input create --pipeline ./input.json + +# 2. Trigger a dispatch with that input +dr pipelines dispatch create --pipeline --input + +# 3. Poll status until it reaches a terminal state +dr pipelines dispatch status --pipeline +``` + +### Schedule a recurring run on a locked version + +```bash +dr pipelines schedule create \ + --pipeline --version=2 \ + --cron "0 */6 * * *" --input --timezone America/Los_Angeles +``` + +### Scripting friendliness + +```bash +# All UUIDs of locked pipelines, one per line +dr pipelines list --mode locked --output json | jq -r '.items[].pipeline_id' +``` + +## Error handling + +The CLI surfaces backend errors verbatim and exits non-zero. The most +common status codes you will see: + +| Status | Cause | +|--------|------------------------------------------------------------------------------------| +| `400` | Invalid Python file, mismatched lattice name, or malformed JSON payload. | +| `404` | The provided `` / version / input / dispatch / schedule does not exist. | +| `409` | Tried to update a `locked` pipeline, or cancel an already-terminal dispatch. | + +For most `get` / `delete` verbs the CLI translates a 404 into a +friendly informational line (e.g. `No pipeline found with id: …`) and +exits 0, so a no-op delete won't dump usage at the user. + +## See also + +- [Pipelines reference](pipelines-reference.md) — exhaustive table + mapping every CLI command to its API endpoint, usage variants, and + inputs. +- [Authentication](auth.md) — how `dr auth login` and `--skip-auth` + interact. +- [Configuration](../user-guide/configuration.md) — config file and + environment-variable precedence. +- [Adding a command](../development/adding-a-command.md) — how the + pipelines verbs were built. +- [Feature gates](../development/feature-gates.md) — flipping + `DATAROBOT_CLI_FEATURE_PIPELINES` on and off. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index fef2e1c5d..1aa129964 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -70,6 +70,8 @@ nav: - completion: commands/completion.md - self: commands/self.md - plugins: commands/plugins.md + - pipelines: commands/pipelines.md + - pipelines reference: commands/pipelines-reference.md - component: commands/component-managed-updates.md - Development: - development/README.md diff --git a/internal/pipelines/dispatch.go b/internal/pipelines/dispatch.go new file mode 100644 index 000000000..425729d00 --- /dev/null +++ b/internal/pipelines/dispatch.go @@ -0,0 +1,158 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// dispatch.go wraps the pipeline dispatch endpoints described in +// pipelines-api/.../controllers/pipeline_dispatch.py. The CLI exposes the +// same draft/locked URL split as inputs via the shared Scope helpers. + +package pipelines + +import ( + "net/http" + "net/url" + "strconv" +) + +// Dispatch lifecycle states (mirrors PipelineDispatchStatus). +const ( + DispatchStatusPending = "PENDING" + DispatchStatusRunning = "RUNNING" + DispatchStatusCompleted = "COMPLETED" + DispatchStatusFailed = "FAILED" + DispatchStatusCancelled = "CANCELLED" + DispatchStatusErrored = "ERRORED" +) + +// Dispatch mirrors PipelineDispatchResponse from the pipelines-api. +type Dispatch struct { + DispatchID string `json:"dispatch_id"` + PipelineID string `json:"pipeline_id"` + VersionID *int `json:"version_id,omitempty"` + InputID string `json:"input_id"` + CovalentDispatchID string `json:"covalent_dispatch_id,omitempty"` + TriggeredBy string `json:"triggered_by"` + Status string `json:"status"` + ErrorDetail string `json:"error_detail,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// DispatchStatus mirrors PipelineDispatchStatusResponse — the lightweight +// polling-friendly shape returned by GET .../status. +type DispatchStatus struct { + DispatchID string `json:"dispatch_id"` + Status string `json:"status"` + CovalentDispatchID string `json:"covalent_dispatch_id,omitempty"` +} + +// DispatchCreateRequest mirrors PipelineDispatchCreateRequest. +type DispatchCreateRequest struct { + InputID string `json:"input_id"` +} + +// CreateDispatch starts a new dispatch for the given input. Returns the +// freshly-created Dispatch (status PENDING). +func CreateDispatch(pipelineID string, scope Scope, version *int, inputID string) (*Dispatch, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches") + if err != nil { + return nil, err + } + + body := DispatchCreateRequest{InputID: inputID} + + var result Dispatch + + err = doJSON(http.MethodPost, endpoint, body, "create dispatch", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// ListDispatches returns a paginated slice of dispatches for the given scope. +func ListDispatches(pipelineID string, scope Scope, version *int, offset, limit int) ([]Dispatch, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches") + if err != nil { + return nil, err + } + + query := url.Values{} + if offset > 0 { + query.Set("offset", strconv.Itoa(offset)) + } + + if limit > 0 { + query.Set("limit", strconv.Itoa(limit)) + } + + if encoded := query.Encode(); encoded != "" { + endpoint = endpoint + "?" + encoded + } + + var dispatches []Dispatch + + err = doJSON(http.MethodGet, endpoint, nil, "dispatches", &dispatches) + if err != nil { + return nil, err + } + + return dispatches, nil +} + +// GetDispatch fetches a single dispatch by id within the given scope. +func GetDispatch(pipelineID string, scope Scope, version *int, dispatchID string) (*Dispatch, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches/"+dispatchID) + if err != nil { + return nil, err + } + + var dispatch Dispatch + + err = doJSON(http.MethodGet, endpoint, nil, "dispatch", &dispatch) + if err != nil { + return nil, err + } + + return &dispatch, nil +} + +// GetDispatchStatus calls the lightweight GET .../status endpoint useful +// for polling without re-downloading the full dispatch record. +func GetDispatchStatus(pipelineID string, scope Scope, version *int, dispatchID string) (*DispatchStatus, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches/"+dispatchID+"/status") + if err != nil { + return nil, err + } + + var status DispatchStatus + + err = doJSON(http.MethodGet, endpoint, nil, "dispatch status", &status) + if err != nil { + return nil, err + } + + return &status, nil +} + +// CancelDispatch issues a DELETE on a dispatch, transitioning it to +// CANCELLED if it is still in a non-terminal state. +func CancelDispatch(pipelineID string, scope Scope, version *int, dispatchID string) error { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches/"+dispatchID) + if err != nil { + return err + } + + return doDelete(endpoint, "cancel dispatch") +} diff --git a/internal/pipelines/input.go b/internal/pipelines/input.go new file mode 100644 index 000000000..645f54d75 --- /dev/null +++ b/internal/pipelines/input.go @@ -0,0 +1,153 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// input.go contains the typed client wrappers for the pipeline input +// endpoints described in pipelines-api/.../controllers/pipeline_input.py. +// Both draft and locked URL shapes are exercised through Scope/version. + +package pipelines + +import ( + "net/http" + "net/url" + "strconv" +) + +// InputState mirrors PipelineInputState in the pipelines-api enums. +type InputState string + +const ( + InputStateValid InputState = "VALID" + InputStateInvalid InputState = "INVALID" +) + +// Input mirrors PipelineInputResponse from the pipelines-api. +type Input struct { + InputID string `json:"input_id"` + PipelineID string `json:"pipeline_id"` + VersionID *int `json:"version_id,omitempty"` + IsDraft bool `json:"is_draft"` + Payload map[string]any `json:"payload"` + State InputState `json:"state"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// InputCreateRequest mirrors PipelineInputCreateRequest. +type InputCreateRequest struct { + Payload map[string]any `json:"payload"` +} + +// InputUpdateRequest mirrors PipelineInputUpdateRequest (draft-only). +type InputUpdateRequest struct { + Payload map[string]any `json:"payload"` +} + +// CreateInput POSTs a new input set against the appropriate URL for the +// given scope/version. +func CreateInput(pipelineID string, scope Scope, version *int, payload map[string]any) (*Input, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "inputs") + if err != nil { + return nil, err + } + + body := InputCreateRequest{Payload: payload} + + var result Input + + err = doJSON(http.MethodPost, endpoint, body, "create input", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// ListInputs returns a paginated slice of inputs for the given scope. +func ListInputs(pipelineID string, scope Scope, version *int, offset, limit int) ([]Input, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "inputs") + if err != nil { + return nil, err + } + + query := url.Values{} + if offset > 0 { + query.Set("offset", strconv.Itoa(offset)) + } + + if limit > 0 { + query.Set("limit", strconv.Itoa(limit)) + } + + if encoded := query.Encode(); encoded != "" { + endpoint = endpoint + "?" + encoded + } + + // The pipelines-api returns a bare JSON array for list endpoints. + var inputs []Input + + err = doJSON(http.MethodGet, endpoint, nil, "inputs", &inputs) + if err != nil { + return nil, err + } + + return inputs, nil +} + +// GetInput fetches a single input by id within the given scope. +func GetInput(pipelineID string, scope Scope, version *int, inputID string) (*Input, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "inputs/"+inputID) + if err != nil { + return nil, err + } + + var input Input + + err = doJSON(http.MethodGet, endpoint, nil, "input", &input) + if err != nil { + return nil, err + } + + return &input, nil +} + +// UpdateInput PATCHes a draft input set with a new payload. Locked inputs +// cannot be updated; the API will return 409 in that case. +func UpdateInput(pipelineID, inputID string, payload map[string]any) (*Input, error) { + endpoint, err := EndpointFor(pipelineID, ScopeDraft, nil, "inputs/"+inputID) + if err != nil { + return nil, err + } + + body := InputUpdateRequest{Payload: payload} + + var result Input + + err = doJSON(http.MethodPatch, endpoint, body, "update input", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// DeleteInput removes an input set within the given scope. +func DeleteInput(pipelineID string, scope Scope, version *int, inputID string) error { + endpoint, err := EndpointFor(pipelineID, scope, version, "inputs/"+inputID) + if err != nil { + return err + } + + return doDelete(endpoint, "delete input") +} diff --git a/internal/pipelines/pipeline.go b/internal/pipelines/pipeline.go index 20dca62b4..99de03014 100644 --- a/internal/pipelines/pipeline.go +++ b/internal/pipelines/pipeline.go @@ -191,6 +191,37 @@ func UpdatePipeline(pipelineID, filePath string) (*CreateResponse, error) { return &result, nil } +// DeletePipeline issues DELETE /api/v2/pipelines/{pipeline_id}. The API +// returns 204 on success. +func DeletePipeline(pipelineID string) error { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/" + pipelineID) + if err != nil { + return err + } + + return doDelete(endpoint, "delete pipeline") +} + +// LockPipeline issues PATCH /api/v2/pipelines/{pipeline_id}/mode to +// promote a draft pipeline into the locked mode. The response mirrors a +// create/update payload, with `mode` set to "locked" and `version` +// pointing at the locked version. +func LockPipeline(pipelineID string) (*CreateResponse, error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/" + pipelineID + "/mode") + if err != nil { + return nil, err + } + + var result CreateResponse + + err = doJSON(http.MethodPatch, endpoint, nil, "lock pipeline", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + // doMultipart performs a multipart/form-data request with a single "file" upload // and optional form fields, decoding the JSON response into out. func doMultipart(method, endpoint, filePath string, fields map[string]string, info string, out any) error { diff --git a/internal/pipelines/schedule.go b/internal/pipelines/schedule.go new file mode 100644 index 000000000..3673b94ad --- /dev/null +++ b/internal/pipelines/schedule.go @@ -0,0 +1,152 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// schedule.go wraps the pipeline schedule endpoints described in +// pipelines-api/.../controllers/pipeline_schedule.py. Schedules are only +// valid for locked pipeline versions, so the URL always carries a +// /versions/{ver} segment and there is no Scope parameter on this side. + +package pipelines + +import ( + "net/http" + "net/url" + "strconv" +) + +// ScheduleStatus mirrors PipelineScheduleStatus in the pipelines-api enums. +type ScheduleStatus string + +const ( + ScheduleStatusActive ScheduleStatus = "ACTIVE" + ScheduleStatusPaused ScheduleStatus = "PAUSED" + ScheduleStatusDeleted ScheduleStatus = "DELETED" +) + +// Schedule mirrors PipelineScheduleResponse. +type Schedule struct { + ScheduleID string `json:"schedule_id"` + PipelineID string `json:"pipeline_id"` + Version int `json:"version"` + CronExpression string `json:"cron_expression"` + Timezone string `json:"timezone"` + Status ScheduleStatus `json:"status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// ScheduleCreateRequest mirrors PipelineScheduleCreateRequest. +type ScheduleCreateRequest struct { + CronExpression string `json:"cron_expression"` + PipelineInputID string `json:"pipeline_input_id"` + Timezone string `json:"timezone,omitempty"` +} + +// ScheduleUpdateRequest mirrors PipelineScheduleUpdateRequest. Both fields +// are optional; the API treats omitted values as no-op. +type ScheduleUpdateRequest struct { + CronExpression *string `json:"cron_expression,omitempty"` + Timezone *string `json:"timezone,omitempty"` +} + +// CreateSchedule registers a new recurring dispatch for a locked version. +func CreateSchedule(pipelineID string, version int, body ScheduleCreateRequest) (*Schedule, error) { + endpoint, err := EndpointFor(pipelineID, ScopeLocked, &version, "schedules") + if err != nil { + return nil, err + } + + var result Schedule + + err = doJSON(http.MethodPost, endpoint, body, "create schedule", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// ListSchedules returns a paginated list of schedules for a locked version. +func ListSchedules(pipelineID string, version, offset, limit int) ([]Schedule, error) { + endpoint, err := EndpointFor(pipelineID, ScopeLocked, &version, "schedules") + if err != nil { + return nil, err + } + + query := url.Values{} + if offset > 0 { + query.Set("offset", strconv.Itoa(offset)) + } + + if limit > 0 { + query.Set("limit", strconv.Itoa(limit)) + } + + if encoded := query.Encode(); encoded != "" { + endpoint = endpoint + "?" + encoded + } + + var schedules []Schedule + + err = doJSON(http.MethodGet, endpoint, nil, "schedules", &schedules) + if err != nil { + return nil, err + } + + return schedules, nil +} + +// GetSchedule fetches a single schedule by id. +func GetSchedule(pipelineID string, version int, scheduleID string) (*Schedule, error) { + endpoint, err := EndpointFor(pipelineID, ScopeLocked, &version, "schedules/"+scheduleID) + if err != nil { + return nil, err + } + + var schedule Schedule + + err = doJSON(http.MethodGet, endpoint, nil, "schedule", &schedule) + if err != nil { + return nil, err + } + + return &schedule, nil +} + +// UpdateSchedule patches a schedule's cron expression and/or timezone. +func UpdateSchedule(pipelineID string, version int, scheduleID string, body ScheduleUpdateRequest) (*Schedule, error) { + endpoint, err := EndpointFor(pipelineID, ScopeLocked, &version, "schedules/"+scheduleID) + if err != nil { + return nil, err + } + + var result Schedule + + err = doJSON(http.MethodPatch, endpoint, body, "update schedule", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// DeleteSchedule removes a schedule. +func DeleteSchedule(pipelineID string, version int, scheduleID string) error { + endpoint, err := EndpointFor(pipelineID, ScopeLocked, &version, "schedules/"+scheduleID) + if err != nil { + return err + } + + return doDelete(endpoint, "delete schedule") +} diff --git a/internal/pipelines/scope.go b/internal/pipelines/scope.go new file mode 100644 index 000000000..ad26a0c32 --- /dev/null +++ b/internal/pipelines/scope.go @@ -0,0 +1,124 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// scope.go contains the shared draft/locked scope resolution used by the +// input and dispatch CLI commands. The pipelines API exposes two URL shapes +// for these resources: +// +// draft -> /pipelines/{id}/ +// locked -> /pipelines/{id}/versions/{ver}/ +// +// The CLI surfaces the choice with optional --scope and --version flags; +// ResolveScope encapsulates the precedence rules so every verb behaves +// consistently. PipelinePath then turns the resolved scope into the right +// URL fragment. + +package pipelines + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "github.com/datarobot/cli/internal/config" +) + +// Scope identifies whether a request targets the mutable draft of a pipeline +// or a locked, frozen version. +type Scope string + +const ( + ScopeDraft Scope = "draft" + ScopeLocked Scope = "locked" +) + +// ResolveScope applies the CLI precedence rules: +// +// - empty scope, no version -> draft +// - empty scope, version=N -> locked (auto-promoted from --version) +// - scope=draft + version -> error (draft has no versions) +// - scope=locked + no version -> error (locked requires --version) +// - scope=draft + no version -> draft +// - scope=locked + version=N -> locked +// +// `version` is a pointer because 0 is a real value sent by the user; nil +// means the flag was not provided. +func ResolveScope(scope string, version *int) (Scope, *int, error) { + normalized := strings.ToLower(strings.TrimSpace(scope)) + + switch normalized { + case "": + if version == nil { + return ScopeDraft, nil, nil + } + + return ScopeLocked, version, nil + case string(ScopeDraft): + if version != nil { + return "", nil, errors.New("--scope=draft cannot be combined with --version") + } + + return ScopeDraft, nil, nil + case string(ScopeLocked): + if version == nil { + return "", nil, errors.New("--scope=locked requires --version=") + } + + return ScopeLocked, version, nil + default: + return "", nil, fmt.Errorf("invalid --scope: %q (supported: draft, locked)", scope) + } +} + +// PipelinePath builds an API path for a sub-resource hanging off a single +// pipeline. The leading "/api/v2" prefix is added by config.GetEndpointURL, +// so this returns just the segment beginning at "/pipelines". +// +// suffix should NOT start with a slash (e.g. "inputs", "inputs/abc"). +func PipelinePath(pipelineID string, scope Scope, version *int, suffix string) (string, error) { + if pipelineID == "" { + return "", errors.New("pipeline id is required") + } + + suffix = strings.TrimPrefix(suffix, "/") + + base := "/api/v2/pipelines/" + pipelineID + + if scope == ScopeLocked { + if version == nil { + return "", errors.New("locked scope requires a version") + } + + base += "/versions/" + strconv.Itoa(*version) + } + + if suffix == "" { + return base, nil + } + + return base + "/" + suffix, nil +} + +// EndpointFor combines PipelinePath with config.GetEndpointURL so callers +// can write a single line. It returns a fully-qualified URL ready to be +// passed to drapi.GetJSON / doJSON / doDelete. +func EndpointFor(pipelineID string, scope Scope, version *int, suffix string) (string, error) { + path, err := PipelinePath(pipelineID, scope, version, suffix) + if err != nil { + return "", err + } + + return config.GetEndpointURL(path) +} diff --git a/internal/pipelines/scope_test.go b/internal/pipelines/scope_test.go new file mode 100644 index 000000000..65a1c44c5 --- /dev/null +++ b/internal/pipelines/scope_test.go @@ -0,0 +1,146 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipelines + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func intPtr(v int) *int { return &v } + +func TestResolveScope(t *testing.T) { + tests := []struct { + name string + scope string + version *int + wantScope Scope + wantVersion *int + wantErr string + }{ + { + name: "no scope, no version -> draft", + wantScope: ScopeDraft, + }, + { + name: "no scope, version=2 -> locked v2 (auto)", + version: intPtr(2), + wantScope: ScopeLocked, + wantVersion: intPtr(2), + }, + { + name: "scope=draft, no version -> draft", + scope: "draft", + wantScope: ScopeDraft, + }, + { + name: "scope=draft + version -> error", + scope: "draft", + version: intPtr(1), + wantErr: "draft cannot be combined", + }, + { + name: "scope=locked, no version -> error", + scope: "locked", + wantErr: "requires --version", + }, + { + name: "scope=locked + version=3", + scope: "locked", + version: intPtr(3), + wantScope: ScopeLocked, + wantVersion: intPtr(3), + }, + { + name: "invalid scope", + scope: "weird", + wantErr: "invalid --scope", + }, + { + name: "case-insensitive scope", + scope: "DRAFT", + wantScope: ScopeDraft, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + scope, version, err := ResolveScope(tc.scope, tc.version) + + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + + return + } + + require.NoError(t, err) + assert.Equal(t, tc.wantScope, scope) + + if tc.wantVersion == nil { + assert.Nil(t, version) + } else { + require.NotNil(t, version) + assert.Equal(t, *tc.wantVersion, *version) + } + }) + } +} + +func TestPipelinePath(t *testing.T) { + t.Run("draft scope", func(t *testing.T) { + got, err := PipelinePath("abc", ScopeDraft, nil, "inputs") + require.NoError(t, err) + assert.Equal(t, "/api/v2/pipelines/abc/inputs", got) + }) + + t.Run("locked scope with version", func(t *testing.T) { + got, err := PipelinePath("abc", ScopeLocked, intPtr(2), "schedules") + require.NoError(t, err) + assert.Equal(t, "/api/v2/pipelines/abc/versions/2/schedules", got) + }) + + t.Run("nested suffix", func(t *testing.T) { + got, err := PipelinePath("abc", ScopeLocked, intPtr(2), "dispatches/xyz/status") + require.NoError(t, err) + assert.Equal(t, "/api/v2/pipelines/abc/versions/2/dispatches/xyz/status", got) + }) + + t.Run("empty suffix returns base", func(t *testing.T) { + got, err := PipelinePath("abc", ScopeDraft, nil, "") + require.NoError(t, err) + assert.Equal(t, "/api/v2/pipelines/abc", got) + }) + + t.Run("leading slash on suffix is tolerated", func(t *testing.T) { + got, err := PipelinePath("abc", ScopeDraft, nil, "/inputs") + require.NoError(t, err) + assert.Equal(t, "/api/v2/pipelines/abc/inputs", got) + }) + + t.Run("locked without version is an error", func(t *testing.T) { + _, err := PipelinePath("abc", ScopeLocked, nil, "inputs") + require.Error(t, err) + assert.Contains(t, err.Error(), "version") + }) + + t.Run("missing pipeline id is an error", func(t *testing.T) { + _, err := PipelinePath("", ScopeDraft, nil, "inputs") + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline id") + }) +} diff --git a/internal/pipelines/transport.go b/internal/pipelines/transport.go new file mode 100644 index 000000000..a7b2e6f9c --- /dev/null +++ b/internal/pipelines/transport.go @@ -0,0 +1,146 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// transport.go centralizes the request execution paths used by the +// non-multipart pipelines endpoints (inputs, dispatches, schedules): +// +// - doJSON - POST/PATCH a JSON body, optionally decode response. +// - doDelete - DELETE returning 204 (or any 2xx with empty body). +// +// Authorization, User-Agent, and consumer-trace headers are owned by the +// shared drapi.AuthorizeRequest helper so the headers stay consistent with +// every other CLI command. + +package pipelines + +import ( + "bytes" + "encoding/json" + "net/http" + + "github.com/datarobot/cli/internal/config" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/log" +) + +// jsonTimeout is used for JSON request/response endpoints. These should +// finish quickly compared to multipart uploads, so we keep the default +// drapi timeout. +const jsonTimeout = drapi.DefaultClientTimeout + +// doJSON performs a request with a JSON-encoded body. If body is nil the +// request is sent with no body (useful for status-only POSTs). If out is +// nil the response body is discarded. +func doJSON(method, endpoint string, body any, info string, out any) error { + req, err := buildJSONRequest(method, endpoint, body) + if err != nil { + return err + } + + if info != "" { + log.Infof("%s at: %s", info, endpoint) + } + + if log.GetLevel() <= log.DebugLevel { + log.Debug("Request Info: \n" + config.RedactedReqInfo(req)) + } + + client := drapi.NewHTTPClient(jsonTimeout) + + resp, err := client.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return decodeHTTPError(resp, endpoint) + } + + if out == nil { + return nil + } + + return json.NewDecoder(resp.Body).Decode(out) +} + +// buildJSONRequest assembles an authenticated *http.Request with a +// JSON-encoded body. Extracted from doJSON to keep doJSON's cyclomatic +// complexity within lint limits. +func buildJSONRequest(method, endpoint string, body any) (*http.Request, error) { + reqBody := &bytes.Buffer{} + + if body != nil { + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + + reqBody = bytes.NewBuffer(buf) + } + + req, err := http.NewRequest(method, endpoint, reqBody) + if err != nil { + return nil, err + } + + err = drapi.AuthorizeRequest(req) + if err != nil { + return nil, err + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +// doDelete sends a DELETE and treats any 2xx response as success. The +// response body is drained but ignored. +func doDelete(endpoint, info string) error { + req, err := http.NewRequest(http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + err = drapi.AuthorizeRequest(req) + if err != nil { + return err + } + + if info != "" { + log.Infof("%s at: %s", info, endpoint) + } + + if log.GetLevel() <= log.DebugLevel { + log.Debug("Request Info: \n" + config.RedactedReqInfo(req)) + } + + client := drapi.NewHTTPClient(jsonTimeout) + + resp, err := client.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return decodeHTTPError(resp, endpoint) + } + + return nil +} diff --git a/internal/pipelines/version.go b/internal/pipelines/version.go new file mode 100644 index 000000000..2f410314d --- /dev/null +++ b/internal/pipelines/version.go @@ -0,0 +1,130 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// version.go wraps the version-scoped read endpoints exposed by +// pipelines-api/.../controllers/pipeline.py: +// +// GET /pipelines/{id}/versions +// GET /pipelines/{id}/versions/{ver} +// GET /pipelines/{id}/graph (draft DAG) +// GET /pipelines/{id}/versions/{ver}/graph (locked DAG) +// +// The list/detail endpoints return PipelineVersion records (already +// defined in pipeline.go). The two graph endpoints return a free-form +// dict; we surface its known shape via the Graph struct below. + +package pipelines + +import ( + "net/http" + "net/url" + "strconv" + + "github.com/datarobot/cli/internal/config" +) + +// GraphNode mirrors a node entry in the JSON returned by the graph +// endpoint. type is "lattice" or "electron". +type GraphNode struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` +} + +// GraphEdge mirrors an edge entry in the JSON returned by the graph +// endpoint. +type GraphEdge struct { + Source string `json:"source"` + Target string `json:"target"` +} + +// GraphLattice mirrors the "lattice" header entry of the graph payload. +type GraphLattice struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// Graph mirrors the JSON returned by GET /pipelines/{id}/graph and +// GET /pipelines/{id}/versions/{ver}/graph. +type Graph struct { + Lattice GraphLattice `json:"lattice"` + Nodes []GraphNode `json:"nodes"` + Edges []GraphEdge `json:"edges"` +} + +// ListVersions fetches a paginated list of versions for a pipeline. +func ListVersions(pipelineID string, offset, limit int) ([]PipelineVersion, error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/" + pipelineID + "/versions") + if err != nil { + return nil, err + } + + query := url.Values{} + if offset > 0 { + query.Set("offset", strconv.Itoa(offset)) + } + + if limit > 0 { + query.Set("limit", strconv.Itoa(limit)) + } + + if encoded := query.Encode(); encoded != "" { + endpoint = endpoint + "?" + encoded + } + + var versions []PipelineVersion + + err = doJSON(http.MethodGet, endpoint, nil, "pipeline versions", &versions) + if err != nil { + return nil, err + } + + return versions, nil +} + +// GetVersion fetches a single version of a pipeline. +func GetVersion(pipelineID string, versionID int) (*PipelineVersion, error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/" + pipelineID + "/versions/" + strconv.Itoa(versionID)) + if err != nil { + return nil, err + } + + var version PipelineVersion + + err = doJSON(http.MethodGet, endpoint, nil, "pipeline version", &version) + if err != nil { + return nil, err + } + + return &version, nil +} + +// GetGraph fetches the DAG visualization payload for a pipeline. When +// scope is ScopeDraft the latest draft graph is returned; with +// ScopeLocked + version, the graph for that locked version is returned. +func GetGraph(pipelineID string, scope Scope, version *int) (*Graph, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "graph") + if err != nil { + return nil, err + } + + var graph Graph + + err = doJSON(http.MethodGet, endpoint, nil, "graph", &graph) + if err != nil { + return nil, err + } + + return &graph, nil +} From 4ab12ca228fd3a745c0c1dc0d7820d00a620c225 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Wed, 29 Apr 2026 23:48:47 -0400 Subject: [PATCH 08/25] add unit tests --- cmd/pipelines/dispatch/cancel/cmd_test.go | 56 +++++ cmd/pipelines/dispatch/create/cmd_test.go | 67 ++++++ .../dispatch/dispatchutil/render_test.go | 163 ++++++++++++++ cmd/pipelines/dispatch/get/cmd_test.go | 66 ++++++ cmd/pipelines/dispatch/list/cmd_test.go | 61 ++++++ cmd/pipelines/dispatch/status/cmd_test.go | 61 ++++++ cmd/pipelines/input/create/cmd_test.go | 67 ++++++ cmd/pipelines/input/del/cmd_test.go | 56 +++++ cmd/pipelines/input/get/cmd_test.go | 66 ++++++ cmd/pipelines/input/inpututil/render_test.go | 128 +++++++++++ cmd/pipelines/input/list/cmd_test.go | 61 ++++++ cmd/pipelines/input/update/cmd_test.go | 66 ++++++ cmd/pipelines/schedule/create/cmd_test.go | 77 +++++++ cmd/pipelines/schedule/del/cmd_test.go | 56 +++++ cmd/pipelines/schedule/get/cmd_test.go | 72 ++++++ cmd/pipelines/schedule/list/cmd_test.go | 61 ++++++ .../schedule/scheduleutil/render_test.go | 116 ++++++++++ cmd/pipelines/version/list/cmd_test.go | 55 +++++ .../version/versionutil/render_test.go | 131 +++++++++++ internal/pipelines/dispatch_test.go | 168 ++++++++++++++ internal/pipelines/input_test.go | 207 ++++++++++++++++++ internal/pipelines/pipeline_lifecycle_test.go | 109 +++++++++ internal/pipelines/schedule_test.go | 142 ++++++++++++ internal/pipelines/transport_test.go | 197 +++++++++++++++++ internal/pipelines/version_test.go | 110 ++++++++++ 25 files changed, 2419 insertions(+) create mode 100644 cmd/pipelines/dispatch/cancel/cmd_test.go create mode 100644 cmd/pipelines/dispatch/create/cmd_test.go create mode 100644 cmd/pipelines/dispatch/dispatchutil/render_test.go create mode 100644 cmd/pipelines/dispatch/get/cmd_test.go create mode 100644 cmd/pipelines/dispatch/list/cmd_test.go create mode 100644 cmd/pipelines/dispatch/status/cmd_test.go create mode 100644 cmd/pipelines/input/create/cmd_test.go create mode 100644 cmd/pipelines/input/del/cmd_test.go create mode 100644 cmd/pipelines/input/get/cmd_test.go create mode 100644 cmd/pipelines/input/inpututil/render_test.go create mode 100644 cmd/pipelines/input/list/cmd_test.go create mode 100644 cmd/pipelines/input/update/cmd_test.go create mode 100644 cmd/pipelines/schedule/create/cmd_test.go create mode 100644 cmd/pipelines/schedule/del/cmd_test.go create mode 100644 cmd/pipelines/schedule/get/cmd_test.go create mode 100644 cmd/pipelines/schedule/list/cmd_test.go create mode 100644 cmd/pipelines/schedule/scheduleutil/render_test.go create mode 100644 cmd/pipelines/version/list/cmd_test.go create mode 100644 cmd/pipelines/version/versionutil/render_test.go create mode 100644 internal/pipelines/dispatch_test.go create mode 100644 internal/pipelines/input_test.go create mode 100644 internal/pipelines/pipeline_lifecycle_test.go create mode 100644 internal/pipelines/schedule_test.go create mode 100644 internal/pipelines/transport_test.go create mode 100644 internal/pipelines/version_test.go diff --git a/cmd/pipelines/dispatch/cancel/cmd_test.go b/cmd/pipelines/dispatch/cancel/cmd_test.go new file mode 100644 index 000000000..582095425 --- /dev/null +++ b/cmd/pipelines/dispatch/cancel/cmd_test.go @@ -0,0 +1,56 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cancel + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RejectsBadScopeCombo(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--scope", "locked", "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "requires --version") +} + +func TestCmd_RequiresPositional(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) +} + +func TestCmd_Name(t *testing.T) { + assert.Equal(t, "cancel", Cmd().Name()) +} diff --git a/cmd/pipelines/dispatch/create/cmd_test.go b/cmd/pipelines/dispatch/create/cmd_test.go new file mode 100644 index 000000000..c4cac589d --- /dev/null +++ b/cmd/pipelines/dispatch/create/cmd_test.go @@ -0,0 +1,67 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package create + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--input", "in-1", "--output", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "--input", "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RejectsMissingInput(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) + assert.Contains(t, err.Error(), "--input") +} + +func TestCmd_RejectsBadScopeCombo(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--input", "in-1", "--scope", "draft", "--version", "2") + require.Error(t, err) + assert.Contains(t, err.Error(), "draft cannot be combined") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "scope", "version", "input", "output"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipelines/dispatch/dispatchutil/render_test.go b/cmd/pipelines/dispatch/dispatchutil/render_test.go new file mode 100644 index 000000000..697f5f6fe --- /dev/null +++ b/cmd/pipelines/dispatch/dispatchutil/render_test.go @@ -0,0 +1,163 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dispatchutil + +import ( + "bytes" + "encoding/json" + "io" + "os" + "testing" + + "github.com/datarobot/cli/internal/pipelines" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + w.Close() + + os.Stdout = old + + var buf bytes.Buffer + + _, _ = io.Copy(&buf, r) + + return buf.String() +} + +func intPtr(v int) *int { return &v } + +func sampleDraftDispatch() pipelines.Dispatch { + return pipelines.Dispatch{ + DispatchID: "d-1", + PipelineID: "pl-1", + InputID: "in-1", + TriggeredBy: "user@example.com", + Status: pipelines.DispatchStatusPending, + CreatedAt: "2026-04-29T10:00:00Z", + UpdatedAt: "2026-04-29T10:00:00Z", + } +} + +func sampleLockedDispatch() pipelines.Dispatch { + d := sampleDraftDispatch() + d.VersionID = intPtr(3) + d.CovalentDispatchID = "cov-xyz" + d.Status = pipelines.DispatchStatusRunning + + return d +} + +func TestPrintDispatchJSON(t *testing.T) { + output := captureStdout(t, func() { + require.NoError(t, PrintDispatchJSON(sampleDraftDispatch())) + }) + + var parsed map[string]any + + require.NoError(t, json.Unmarshal([]byte(output), &parsed)) + assert.Equal(t, "d-1", parsed["dispatch_id"]) + assert.Equal(t, "PENDING", parsed["status"]) +} + +func TestPrintDispatchHuman_DraftMissingCovalent(t *testing.T) { + output := captureStdout(t, func() { PrintDispatchHuman(sampleDraftDispatch()) }) + assert.Contains(t, output, "Dispatch ID: d-1") + assert.Contains(t, output, "Scope: draft") + assert.Contains(t, output, "Version: \u2014") + assert.Contains(t, output, "Covalent Dispatch: \u2014") + assert.Contains(t, output, "Status: PENDING") +} + +func TestPrintDispatchHuman_LockedShowsErrorWhenSet(t *testing.T) { + d := sampleLockedDispatch() + d.Status = pipelines.DispatchStatusFailed + d.ErrorDetail = "boom" + + output := captureStdout(t, func() { PrintDispatchHuman(d) }) + assert.Contains(t, output, "Scope: locked") + assert.Contains(t, output, "Version: v3") + assert.Contains(t, output, "Covalent Dispatch: cov-xyz") + assert.Contains(t, output, "Error: boom") +} + +func TestPrintDispatchListJSON(t *testing.T) { + output := captureStdout(t, func() { + require.NoError(t, PrintDispatchListJSON([]pipelines.Dispatch{sampleDraftDispatch()})) + }) + + var parsed []map[string]any + + require.NoError(t, json.Unmarshal([]byte(output), &parsed)) + require.Len(t, parsed, 1) + assert.Equal(t, "d-1", parsed[0]["dispatch_id"]) +} + +func TestPrintDispatchListHuman_Empty(t *testing.T) { + output := captureStdout(t, func() { PrintDispatchListHuman(nil) }) + assert.Contains(t, output, "No dispatches found") +} + +func TestPrintDispatchListHuman_RendersTable(t *testing.T) { + output := captureStdout(t, func() { + PrintDispatchListHuman([]pipelines.Dispatch{sampleDraftDispatch(), sampleLockedDispatch()}) + }) + + assert.Contains(t, output, "DISPATCH_ID") + assert.Contains(t, output, "STATUS") + assert.Contains(t, output, "TRIGGER") + assert.Contains(t, output, "draft") + assert.Contains(t, output, "locked") + assert.Contains(t, output, "v3") + assert.Contains(t, output, "PENDING") + assert.Contains(t, output, "RUNNING") +} + +func TestPrintStatusJSON(t *testing.T) { + status := pipelines.DispatchStatus{ + DispatchID: "d-1", + Status: pipelines.DispatchStatusCompleted, + CovalentDispatchID: "cov-xyz", + } + + output := captureStdout(t, func() { + require.NoError(t, PrintStatusJSON(status)) + }) + + var parsed map[string]any + + require.NoError(t, json.Unmarshal([]byte(output), &parsed)) + assert.Equal(t, "COMPLETED", parsed["status"]) + assert.Equal(t, "cov-xyz", parsed["covalent_dispatch_id"]) +} + +func TestPrintStatusHuman_NoCovalentDispatchID(t *testing.T) { + output := captureStdout(t, func() { + PrintStatusHuman(pipelines.DispatchStatus{DispatchID: "d-1", Status: "PENDING"}) + }) + + assert.Contains(t, output, "Dispatch ID: d-1") + assert.Contains(t, output, "Status: PENDING") + assert.Contains(t, output, "Covalent Dispatch: \u2014") +} diff --git a/cmd/pipelines/dispatch/get/cmd_test.go b/cmd/pipelines/dispatch/get/cmd_test.go new file mode 100644 index 000000000..8a69bba87 --- /dev/null +++ b/cmd/pipelines/dispatch/get/cmd_test.go @@ -0,0 +1,66 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package get + +import ( + "errors" + "io" + "net/http" + "testing" + + "github.com/datarobot/cli/internal/drapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--output", "yaml", "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RequiresPositional(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) +} + +func TestHandleGetError_404IsSuppressed(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: http.StatusNotFound, URL: "x"} + assert.NoError(t, handleGetError(httpErr, "d-1")) +} + +func TestHandleGetError_PropagatesOther(t *testing.T) { + err := handleGetError(errors.New("boom"), "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} diff --git a/cmd/pipelines/dispatch/list/cmd_test.go b/cmd/pipelines/dispatch/list/cmd_test.go new file mode 100644 index 000000000..63eac2187 --- /dev/null +++ b/cmd/pipelines/dispatch/list/cmd_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package list + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--output", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t) + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RejectsBadScopeCombo(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--scope", "locked") + require.Error(t, err) + assert.Contains(t, err.Error(), "requires --version") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "scope", "version", "offset", "limit", "output"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipelines/dispatch/status/cmd_test.go b/cmd/pipelines/dispatch/status/cmd_test.go new file mode 100644 index 000000000..9efb4a236 --- /dev/null +++ b/cmd/pipelines/dispatch/status/cmd_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package status + +import ( + "errors" + "io" + "net/http" + "testing" + + "github.com/datarobot/cli/internal/drapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--output", "yaml", "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestHandleStatusError_404IsSuppressed(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: http.StatusNotFound, URL: "x"} + assert.NoError(t, handleStatusError(httpErr, "d-1")) +} + +func TestHandleStatusError_PropagatesOther(t *testing.T) { + err := handleStatusError(errors.New("boom"), "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} diff --git a/cmd/pipelines/input/create/cmd_test.go b/cmd/pipelines/input/create/cmd_test.go new file mode 100644 index 000000000..889002c88 --- /dev/null +++ b/cmd/pipelines/input/create/cmd_test.go @@ -0,0 +1,67 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package create + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--output", "yaml", "p.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "p.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RejectsBadScopeCombo(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--scope", "draft", "--version", "2", "p.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "draft cannot be combined") +} + +func TestCmd_RejectsMissingPayload(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) + assert.Contains(t, err.Error(), "required") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "scope", "version", "from-file", "output"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipelines/input/del/cmd_test.go b/cmd/pipelines/input/del/cmd_test.go new file mode 100644 index 000000000..425d4d331 --- /dev/null +++ b/cmd/pipelines/input/del/cmd_test.go @@ -0,0 +1,56 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package del + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RejectsBadScopeCombo(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--scope", "locked", "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "requires --version") +} + +func TestCmd_RequiresPositionalArg(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) +} + +func TestCmd_Name(t *testing.T) { + assert.Equal(t, "delete", Cmd().Name()) +} diff --git a/cmd/pipelines/input/get/cmd_test.go b/cmd/pipelines/input/get/cmd_test.go new file mode 100644 index 000000000..e8105905e --- /dev/null +++ b/cmd/pipelines/input/get/cmd_test.go @@ -0,0 +1,66 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package get + +import ( + "errors" + "io" + "net/http" + "testing" + + "github.com/datarobot/cli/internal/drapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--output", "yaml", "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RequiresPositional(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) +} + +func TestHandleGetError_404IsSuppressed(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: http.StatusNotFound, URL: "x"} + assert.NoError(t, handleGetError(httpErr, "in-1")) +} + +func TestHandleGetError_PropagatesOther(t *testing.T) { + err := handleGetError(errors.New("boom"), "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} diff --git a/cmd/pipelines/input/inpututil/render_test.go b/cmd/pipelines/input/inpututil/render_test.go new file mode 100644 index 000000000..9dad4e647 --- /dev/null +++ b/cmd/pipelines/input/inpututil/render_test.go @@ -0,0 +1,128 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package inpututil + +import ( + "bytes" + "encoding/json" + "io" + "os" + "testing" + + "github.com/datarobot/cli/internal/pipelines" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + w.Close() + + os.Stdout = old + + var buf bytes.Buffer + + _, _ = io.Copy(&buf, r) + + return buf.String() +} + +func intPtr(v int) *int { return &v } + +func sampleDraftInput() pipelines.Input { + return pipelines.Input{ + InputID: "in-1", + PipelineID: "pl-1", + IsDraft: true, + State: pipelines.InputStateValid, + Payload: map[string]any{"k": "v"}, + CreatedAt: "2026-04-29T10:00:00Z", + UpdatedAt: "2026-04-29T10:05:00Z", + } +} + +func sampleLockedInput() pipelines.Input { + in := sampleDraftInput() + in.IsDraft = false + in.VersionID = intPtr(2) + + return in +} + +func TestPrintInputJSON(t *testing.T) { + output := captureStdout(t, func() { + require.NoError(t, PrintInputJSON(sampleDraftInput())) + }) + + var parsed map[string]any + + require.NoError(t, json.Unmarshal([]byte(output), &parsed)) + assert.Equal(t, "in-1", parsed["input_id"]) + assert.Equal(t, "VALID", parsed["state"]) +} + +func TestPrintInputHuman_Draft(t *testing.T) { + output := captureStdout(t, func() { PrintInputHuman(sampleDraftInput()) }) + assert.Contains(t, output, "Input ID: in-1") + assert.Contains(t, output, "Scope: draft") + assert.Contains(t, output, "Version: \u2014") + assert.Contains(t, output, "State: VALID") + assert.Contains(t, output, "Payload:") + assert.Contains(t, output, `"k": "v"`) +} + +func TestPrintInputHuman_Locked(t *testing.T) { + output := captureStdout(t, func() { PrintInputHuman(sampleLockedInput()) }) + assert.Contains(t, output, "Scope: locked") + assert.Contains(t, output, "Version: v2") +} + +func TestPrintInputListJSON(t *testing.T) { + output := captureStdout(t, func() { + require.NoError(t, PrintInputListJSON([]pipelines.Input{sampleDraftInput()})) + }) + + var parsed []map[string]any + + require.NoError(t, json.Unmarshal([]byte(output), &parsed)) + require.Len(t, parsed, 1) + assert.Equal(t, "in-1", parsed[0]["input_id"]) +} + +func TestPrintInputListHuman_Empty(t *testing.T) { + output := captureStdout(t, func() { PrintInputListHuman(nil) }) + assert.Contains(t, output, "No inputs found") +} + +func TestPrintInputListHuman_RendersTable(t *testing.T) { + output := captureStdout(t, func() { + PrintInputListHuman([]pipelines.Input{sampleDraftInput(), sampleLockedInput()}) + }) + + assert.Contains(t, output, "INPUT_ID") + assert.Contains(t, output, "SCOPE") + assert.Contains(t, output, "VERSION") + assert.Contains(t, output, "draft") + assert.Contains(t, output, "locked") + assert.Contains(t, output, "v2") + assert.Contains(t, output, "VALID") +} diff --git a/cmd/pipelines/input/list/cmd_test.go b/cmd/pipelines/input/list/cmd_test.go new file mode 100644 index 000000000..63eac2187 --- /dev/null +++ b/cmd/pipelines/input/list/cmd_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package list + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--output", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t) + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RejectsBadScopeCombo(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--scope", "locked") + require.Error(t, err) + assert.Contains(t, err.Error(), "requires --version") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "scope", "version", "offset", "limit", "output"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipelines/input/update/cmd_test.go b/cmd/pipelines/input/update/cmd_test.go new file mode 100644 index 000000000..808b5d698 --- /dev/null +++ b/cmd/pipelines/input/update/cmd_test.go @@ -0,0 +1,66 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package update + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--output", "yaml", "in-1", "p.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "in-1", "p.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RequiresInputID(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) +} + +func TestCmd_RejectsMissingPayload(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "required") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "from-file", "output"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipelines/schedule/create/cmd_test.go b/cmd/pipelines/schedule/create/cmd_test.go new file mode 100644 index 000000000..5d2d64639 --- /dev/null +++ b/cmd/pipelines/schedule/create/cmd_test.go @@ -0,0 +1,77 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package create + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, + "--pipeline", "p", "--version", "2", + "--cron", "0 * * * *", "--input", "in-1", + "--output", "yaml", + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "--version", "2", "--cron", "0 * * * *", "--input", "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RejectsZeroVersion(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--cron", "0 * * * *", "--input", "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--version") +} + +func TestCmd_RejectsMissingCron(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--version", "2", "--input", "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--cron") +} + +func TestCmd_RejectsMissingInput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--version", "2", "--cron", "0 * * * *") + require.Error(t, err) + assert.Contains(t, err.Error(), "--input") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "version", "cron", "input", "timezone", "output"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipelines/schedule/del/cmd_test.go b/cmd/pipelines/schedule/del/cmd_test.go new file mode 100644 index 000000000..5eb4167f7 --- /dev/null +++ b/cmd/pipelines/schedule/del/cmd_test.go @@ -0,0 +1,56 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package del + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "--version", "2", "s-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RejectsZeroVersion(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "s-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--version") +} + +func TestCmd_RequiresPositional(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--version", "2") + require.Error(t, err) +} + +func TestCmd_Name(t *testing.T) { + assert.Equal(t, "delete", Cmd().Name()) +} diff --git a/cmd/pipelines/schedule/get/cmd_test.go b/cmd/pipelines/schedule/get/cmd_test.go new file mode 100644 index 000000000..2e6495193 --- /dev/null +++ b/cmd/pipelines/schedule/get/cmd_test.go @@ -0,0 +1,72 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package get + +import ( + "errors" + "io" + "net/http" + "testing" + + "github.com/datarobot/cli/internal/drapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--version", "2", "--output", "yaml", "s-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "--version", "2", "s-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RejectsZeroVersion(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "s-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--version") +} + +func TestCmd_RequiresPositional(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--version", "2") + require.Error(t, err) +} + +func TestHandleGetError_404IsSuppressed(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: http.StatusNotFound, URL: "x"} + assert.NoError(t, handleGetError(httpErr, "s-1")) +} + +func TestHandleGetError_PropagatesOther(t *testing.T) { + err := handleGetError(errors.New("boom"), "s-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} diff --git a/cmd/pipelines/schedule/list/cmd_test.go b/cmd/pipelines/schedule/list/cmd_test.go new file mode 100644 index 000000000..a819dd2a8 --- /dev/null +++ b/cmd/pipelines/schedule/list/cmd_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package list + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--version", "2", "--output", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "--version", "2") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RejectsZeroVersion(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) + assert.Contains(t, err.Error(), "--version") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "version", "offset", "limit", "output"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipelines/schedule/scheduleutil/render_test.go b/cmd/pipelines/schedule/scheduleutil/render_test.go new file mode 100644 index 000000000..648454842 --- /dev/null +++ b/cmd/pipelines/schedule/scheduleutil/render_test.go @@ -0,0 +1,116 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package scheduleutil + +import ( + "bytes" + "encoding/json" + "io" + "os" + "testing" + + "github.com/datarobot/cli/internal/pipelines" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + w.Close() + + os.Stdout = old + + var buf bytes.Buffer + + _, _ = io.Copy(&buf, r) + + return buf.String() +} + +func sampleSchedule() pipelines.Schedule { + return pipelines.Schedule{ + ScheduleID: "s-1", + PipelineID: "pl-1", + Version: 2, + CronExpression: "0 * * * *", + Timezone: "UTC", + Status: pipelines.ScheduleStatusActive, + CreatedAt: "2026-04-29T10:00:00Z", + UpdatedAt: "2026-04-29T11:00:00Z", + } +} + +func TestPrintScheduleJSON(t *testing.T) { + output := captureStdout(t, func() { + require.NoError(t, PrintScheduleJSON(sampleSchedule())) + }) + + var parsed map[string]any + + require.NoError(t, json.Unmarshal([]byte(output), &parsed)) + assert.Equal(t, "s-1", parsed["schedule_id"]) + assert.Equal(t, "ACTIVE", parsed["status"]) + assert.EqualValues(t, 2, parsed["version"]) +} + +func TestPrintScheduleHuman(t *testing.T) { + output := captureStdout(t, func() { PrintScheduleHuman(sampleSchedule()) }) + assert.Contains(t, output, "Schedule ID: s-1") + assert.Contains(t, output, "Version: v2") + assert.Contains(t, output, "Cron: 0 * * * *") + assert.Contains(t, output, "Timezone: UTC") + assert.Contains(t, output, "Status: ACTIVE") +} + +func TestPrintScheduleListJSON(t *testing.T) { + output := captureStdout(t, func() { + require.NoError(t, PrintScheduleListJSON([]pipelines.Schedule{sampleSchedule()})) + }) + + var parsed []map[string]any + + require.NoError(t, json.Unmarshal([]byte(output), &parsed)) + require.Len(t, parsed, 1) + assert.Equal(t, "s-1", parsed[0]["schedule_id"]) +} + +func TestPrintScheduleListHuman_Empty(t *testing.T) { + output := captureStdout(t, func() { PrintScheduleListHuman(nil) }) + assert.Contains(t, output, "No schedules found") +} + +func TestPrintScheduleListHuman_RendersTable(t *testing.T) { + output := captureStdout(t, func() { + PrintScheduleListHuman([]pipelines.Schedule{sampleSchedule()}) + }) + + assert.Contains(t, output, "SCHEDULE_ID") + assert.Contains(t, output, "VERSION") + assert.Contains(t, output, "CRON") + assert.Contains(t, output, "TIMEZONE") + assert.Contains(t, output, "STATUS") + assert.Contains(t, output, "s-1") + assert.Contains(t, output, "v2") + assert.Contains(t, output, "0 * * * *") + assert.Contains(t, output, "UTC") + assert.Contains(t, output, "ACTIVE") +} diff --git a/cmd/pipelines/version/list/cmd_test.go b/cmd/pipelines/version/list/cmd_test.go new file mode 100644 index 000000000..33a6cd03e --- /dev/null +++ b/cmd/pipelines/version/list/cmd_test.go @@ -0,0 +1,55 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package list + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--output", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t) + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "offset", "limit", "output"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipelines/version/versionutil/render_test.go b/cmd/pipelines/version/versionutil/render_test.go new file mode 100644 index 000000000..6e9711f03 --- /dev/null +++ b/cmd/pipelines/version/versionutil/render_test.go @@ -0,0 +1,131 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package versionutil + +import ( + "bytes" + "encoding/json" + "io" + "os" + "testing" + "time" + + "github.com/datarobot/cli/internal/pipelines" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + fn() + + w.Close() + + os.Stdout = old + + var buf bytes.Buffer + + _, _ = io.Copy(&buf, r) + + return buf.String() +} + +func sampleVersion() pipelines.PipelineVersion { + return pipelines.PipelineVersion{ + Version: 2, + Status: "READY", + LatticeName: "wf", + ElectronNames: []string{"e1", "e2"}, + PythonVersion: "3.12", + CreatedAt: time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC), + } +} + +func TestPrintVersionJSON(t *testing.T) { + output := captureStdout(t, func() { + require.NoError(t, PrintVersionJSON(sampleVersion())) + }) + + var parsed map[string]any + + require.NoError(t, json.Unmarshal([]byte(output), &parsed)) + assert.EqualValues(t, 2, parsed["version"]) + assert.Equal(t, "READY", parsed["status"]) + assert.Equal(t, "wf", parsed["lattice_name"]) +} + +func TestPrintVersionHuman_Full(t *testing.T) { + output := captureStdout(t, func() { PrintVersionHuman(sampleVersion()) }) + assert.Contains(t, output, "Version: v2") + assert.Contains(t, output, "Lattice: wf") + assert.Contains(t, output, "Status: READY") + assert.Contains(t, output, "Python Version: 3.12") + assert.Contains(t, output, "Electrons: e1, e2") + assert.Contains(t, output, "Created: 2026-04-29T10:00:00Z") +} + +func TestPrintVersionHuman_FillsDefaultsForMissing(t *testing.T) { + v := sampleVersion() + v.ElectronNames = nil + v.PythonVersion = "" + + output := captureStdout(t, func() { PrintVersionHuman(v) }) + assert.Contains(t, output, "Python Version: \u2014") + assert.Contains(t, output, "Electrons: \u2014") +} + +func TestPrintVersionHuman_ShowsErrorDetail(t *testing.T) { + v := sampleVersion() + v.ErrorDetail = "syntax error" + + output := captureStdout(t, func() { PrintVersionHuman(v) }) + assert.Contains(t, output, "Error: syntax error") +} + +func TestPrintVersionListJSON(t *testing.T) { + output := captureStdout(t, func() { + require.NoError(t, PrintVersionListJSON([]pipelines.PipelineVersion{sampleVersion()})) + }) + + var parsed []map[string]any + + require.NoError(t, json.Unmarshal([]byte(output), &parsed)) + require.Len(t, parsed, 1) + assert.EqualValues(t, 2, parsed[0]["version"]) +} + +func TestPrintVersionListHuman_Empty(t *testing.T) { + output := captureStdout(t, func() { PrintVersionListHuman(nil) }) + assert.Contains(t, output, "No versions found") +} + +func TestPrintVersionListHuman_RendersTable(t *testing.T) { + output := captureStdout(t, func() { + PrintVersionListHuman([]pipelines.PipelineVersion{sampleVersion()}) + }) + + assert.Contains(t, output, "VERSION") + assert.Contains(t, output, "STATUS") + assert.Contains(t, output, "PYTHON") + assert.Contains(t, output, "v2") + assert.Contains(t, output, "READY") + assert.Contains(t, output, "3.12") + assert.Contains(t, output, "e1, e2") +} diff --git a/internal/pipelines/dispatch_test.go b/internal/pipelines/dispatch_test.go new file mode 100644 index 000000000..b96ea4bb2 --- /dev/null +++ b/internal/pipelines/dispatch_test.go @@ -0,0 +1,168 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipelines + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateDispatch_DraftURLAndBody(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/dispatches", r.URL.Path) + + var body DispatchCreateRequest + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "in-1", body.InputID) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"dispatch_id":"d-1","pipeline_id":"p-1","input_id":"in-1","triggered_by":"u","status":"PENDING"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := CreateDispatch("p-1", ScopeDraft, nil, "in-1") + require.NoError(t, err) + assert.Equal(t, "d-1", got.DispatchID) + assert.Equal(t, DispatchStatusPending, got.Status) +} + +func TestCreateDispatch_LockedURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/dispatches", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"dispatch_id":"d-1","pipeline_id":"p-1","version_id":2,"input_id":"in-1","triggered_by":"u","status":"PENDING"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + v := 2 + got, err := CreateDispatch("p-1", ScopeLocked, &v, "in-1") + require.NoError(t, err) + require.NotNil(t, got.VersionID) + assert.Equal(t, 2, *got.VersionID) +} + +func TestListDispatches_QueryAndDecode(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/dispatches", r.URL.Path) + assert.Equal(t, "10", r.URL.Query().Get("offset")) + assert.Equal(t, "5", r.URL.Query().Get("limit")) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"dispatch_id":"d-1","pipeline_id":"p-1","input_id":"in-1","triggered_by":"u","status":"RUNNING"}]`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + items, err := ListDispatches("p-1", ScopeDraft, nil, 10, 5) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, DispatchStatusRunning, items[0].Status) +} + +func TestGetDispatch_TargetsCorrectURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/dispatches/d-1", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"dispatch_id":"d-1","pipeline_id":"p-1","input_id":"in-1","triggered_by":"u","status":"COMPLETED"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := GetDispatch("p-1", ScopeDraft, nil, "d-1") + require.NoError(t, err) + assert.Equal(t, DispatchStatusCompleted, got.Status) +} + +func TestGetDispatchStatus_StatusEndpointURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/dispatches/d-1/status", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"dispatch_id":"d-1","status":"RUNNING","covalent_dispatch_id":"cov-x"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + v := 2 + got, err := GetDispatchStatus("p-1", ScopeLocked, &v, "d-1") + require.NoError(t, err) + assert.Equal(t, DispatchStatusRunning, got.Status) + assert.Equal(t, "cov-x", got.CovalentDispatchID) +} + +func TestCancelDispatch_DeletesDraftURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/dispatches/d-1", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + require.NoError(t, CancelDispatch("p-1", ScopeDraft, nil, "d-1")) +} + +func TestCancelDispatch_PropagatesConflict(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"detail":"already terminal"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + err := CancelDispatch("p-1", ScopeDraft, nil, "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 409") + assert.Contains(t, err.Error(), "already terminal") +} diff --git a/internal/pipelines/input_test.go b/internal/pipelines/input_test.go new file mode 100644 index 000000000..73ea70604 --- /dev/null +++ b/internal/pipelines/input_test.go @@ -0,0 +1,207 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipelines + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/datarobot/cli/internal/config" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// installEndpoint sets viper's endpoint to the given URL for the duration +// of the test, restoring the previous value at cleanup. +func installEndpoint(t *testing.T, url string) { + t.Helper() + + prev := viper.GetString(config.DataRobotURL) + + viper.Set(config.DataRobotURL, url) + + t.Cleanup(func() { + viper.Set(config.DataRobotURL, prev) + }) +} + +func TestCreateInput_Draft(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/inputs", r.URL.Path) + + var body InputCreateRequest + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "v", body.Payload["k"]) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"input_id":"in-1","pipeline_id":"p-1","is_draft":true,"state":"VALID","payload":{"k":"v"}}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := CreateInput("p-1", ScopeDraft, nil, map[string]any{"k": "v"}) + require.NoError(t, err) + assert.Equal(t, "in-1", got.InputID) + assert.Equal(t, InputStateValid, got.State) +} + +func TestCreateInput_LockedURLShape(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/inputs", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"input_id":"in-1","pipeline_id":"p-1","version_id":2,"is_draft":false,"state":"VALID","payload":{}}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + v := 2 + got, err := CreateInput("p-1", ScopeLocked, &v, map[string]any{}) + require.NoError(t, err) + require.NotNil(t, got.VersionID) + assert.Equal(t, 2, *got.VersionID) +} + +func TestListInputs_AddsPaginationQuery(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "10", r.URL.Query().Get("offset")) + assert.Equal(t, "5", r.URL.Query().Get("limit")) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"input_id":"in-1","pipeline_id":"p-1","is_draft":true,"state":"VALID","payload":{}}]`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + items, err := ListInputs("p-1", ScopeDraft, nil, 10, 5) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "in-1", items[0].InputID) +} + +func TestListInputs_OmitsZeroPagination(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.URL.RawQuery) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + items, err := ListInputs("p-1", ScopeDraft, nil, 0, 0) + require.NoError(t, err) + assert.Empty(t, items) +} + +func TestGetInput_TargetsCorrectURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/inputs/in-1", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"input_id":"in-1","pipeline_id":"p-1","is_draft":true,"state":"VALID","payload":{}}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := GetInput("p-1", ScopeDraft, nil, "in-1") + require.NoError(t, err) + assert.Equal(t, "in-1", got.InputID) +} + +func TestUpdateInput_PatchesDraft(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/inputs/in-1", r.URL.Path) + + var body InputUpdateRequest + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "new", body.Payload["k"]) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"input_id":"in-1","pipeline_id":"p-1","is_draft":true,"state":"VALID","payload":{"k":"new"}}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := UpdateInput("p-1", "in-1", map[string]any{"k": "new"}) + require.NoError(t, err) + assert.Equal(t, "new", got.Payload["k"]) +} + +func TestDeleteInput_LockedURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/versions/3/inputs/in-1", r.URL.Path) + + w.WriteHeader(http.StatusNoContent) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + v := 3 + require.NoError(t, DeleteInput("p-1", ScopeLocked, &v, "in-1")) +} + +func TestDeleteInput_PropagatesAPIError(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"detail":"locked input"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + err := DeleteInput("p-1", ScopeDraft, nil, "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 409") +} diff --git a/internal/pipelines/pipeline_lifecycle_test.go b/internal/pipelines/pipeline_lifecycle_test.go new file mode 100644 index 000000000..e77da768b --- /dev/null +++ b/internal/pipelines/pipeline_lifecycle_test.go @@ -0,0 +1,109 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipelines + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/datarobot/cli/internal/drapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeletePipeline_Success(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + require.NoError(t, DeletePipeline("p-1")) +} + +func TestDeletePipeline_404PropagatesAsHTTPError(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + err := DeletePipeline("p-1") + require.Error(t, err) + + var httpErr *drapi.HTTPError + + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) +} + +func TestLockPipeline_PromotesAndDecodes(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/mode", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "pipeline_id": "p-1", + "name": "wf", + "version": 3, + "status": "READY", + "mode": "locked", + "electron_names": ["e1"], + "created_at": "2026-04-29T10:00:00Z" + }`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := LockPipeline("p-1") + require.NoError(t, err) + assert.Equal(t, "locked", got.Mode) + assert.Equal(t, 3, got.Version) + assert.Equal(t, []string{"e1"}, got.ElectronNames) +} + +func TestLockPipeline_409Conflict(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"detail":"already locked"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + _, err := LockPipeline("p-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 409") + assert.Contains(t, err.Error(), "already locked") +} diff --git a/internal/pipelines/schedule_test.go b/internal/pipelines/schedule_test.go new file mode 100644 index 000000000..3270626f1 --- /dev/null +++ b/internal/pipelines/schedule_test.go @@ -0,0 +1,142 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipelines + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateSchedule_LockedOnlyURLAndBody(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/schedules", r.URL.Path) + + var body ScheduleCreateRequest + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "0 * * * *", body.CronExpression) + assert.Equal(t, "in-1", body.PipelineInputID) + assert.Equal(t, "America/Los_Angeles", body.Timezone) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"schedule_id":"s-1","pipeline_id":"p-1","version":2,"cron_expression":"0 * * * *","timezone":"America/Los_Angeles","status":"ACTIVE"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := CreateSchedule("p-1", 2, ScheduleCreateRequest{ + CronExpression: "0 * * * *", + PipelineInputID: "in-1", + Timezone: "America/Los_Angeles", + }) + require.NoError(t, err) + assert.Equal(t, "s-1", got.ScheduleID) + assert.Equal(t, ScheduleStatusActive, got.Status) +} + +func TestListSchedules_QueryAndDecode(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/schedules", r.URL.Path) + assert.Equal(t, "5", r.URL.Query().Get("limit")) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"schedule_id":"s-1","pipeline_id":"p-1","version":2,"cron_expression":"0 0 * * *","timezone":"UTC","status":"ACTIVE"}]`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + items, err := ListSchedules("p-1", 2, 0, 5) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "s-1", items[0].ScheduleID) +} + +func TestGetSchedule_TargetsCorrectURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/schedules/s-1", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"schedule_id":"s-1","pipeline_id":"p-1","version":2,"cron_expression":"0 * * * *","timezone":"UTC","status":"PAUSED"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := GetSchedule("p-1", 2, "s-1") + require.NoError(t, err) + assert.Equal(t, ScheduleStatusPaused, got.Status) +} + +func TestUpdateSchedule_OmitsUnsuppliedFields(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/schedules/s-1", r.URL.Path) + + var raw map[string]any + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&raw)) + // Only cron_expression should be in the body; timezone is omitted. + assert.Equal(t, "*/15 * * * *", raw["cron_expression"]) + _, hasTZ := raw["timezone"] + assert.False(t, hasTZ, "expected timezone to be omitted") + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"schedule_id":"s-1","pipeline_id":"p-1","version":2,"cron_expression":"*/15 * * * *","timezone":"UTC","status":"ACTIVE"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + cron := "*/15 * * * *" + got, err := UpdateSchedule("p-1", 2, "s-1", ScheduleUpdateRequest{CronExpression: &cron}) + require.NoError(t, err) + assert.Equal(t, "*/15 * * * *", got.CronExpression) +} + +func TestDeleteSchedule_DeletesLockedURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/schedules/s-1", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + require.NoError(t, DeleteSchedule("p-1", 2, "s-1")) +} diff --git a/internal/pipelines/transport_test.go b/internal/pipelines/transport_test.go new file mode 100644 index 000000000..8ea9c5dc8 --- /dev/null +++ b/internal/pipelines/transport_test.go @@ -0,0 +1,197 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipelines + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/datarobot/cli/internal/config" + "github.com/datarobot/cli/internal/drapi" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// installSkipAuth configures viper so drapi.AuthorizeRequest does not +// attempt to verify a token over the network. It is safe to call from +// every test; previous values are restored at cleanup. +func installSkipAuth(t *testing.T) { + t.Helper() + + prevSkip := viper.GetBool("skip_auth") + prevTok := viper.GetString(config.DataRobotAPIKey) + + viper.Set("skip_auth", true) + viper.Set(config.DataRobotAPIKey, "test-token") + + t.Cleanup(func() { + viper.Set("skip_auth", prevSkip) + viper.Set(config.DataRobotAPIKey, prevTok) + }) +} + +func TestBuildJSONRequest_BodyAndHeaders(t *testing.T) { + installSkipAuth(t) + + req, err := buildJSONRequest(http.MethodPost, "http://example/x", map[string]string{"a": "b"}) + require.NoError(t, err) + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + assert.NotEmpty(t, req.Header.Get("Authorization")) + + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + + var parsed map[string]string + + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, "b", parsed["a"]) +} + +func TestBuildJSONRequest_NilBodyOmitsContentType(t *testing.T) { + installSkipAuth(t) + + req, err := buildJSONRequest(http.MethodPatch, "http://example/x", nil) + require.NoError(t, err) + assert.Empty(t, req.Header.Get("Content-Type")) +} + +func TestDoJSON_DecodesSuccess(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + var body map[string]string + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "in-1", body["input_id"]) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok": true}`)) + })) + + defer srv.Close() + + var out map[string]bool + + err := doJSON(http.MethodPost, srv.URL, map[string]string{"input_id": "in-1"}, "test", &out) + require.NoError(t, err) + assert.True(t, out["ok"]) +} + +func TestDoJSON_NilOutDiscardsResponse(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ignored": "value"}`)) + })) + + defer srv.Close() + + require.NoError(t, doJSON(http.MethodGet, srv.URL, nil, "", nil)) +} + +func TestDoJSON_404ReturnsHTTPError(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + + defer srv.Close() + + var out map[string]any + + err := doJSON(http.MethodGet, srv.URL, nil, "", &out) + require.Error(t, err) + + var httpErr *drapi.HTTPError + + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) +} + +func TestDoJSON_400WithDetailReturnsFormattedError(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"detail": "lattice missing"}`)) + })) + + defer srv.Close() + + var out map[string]any + + err := doJSON(http.MethodPost, srv.URL, map[string]string{}, "", &out) + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 400") + assert.Contains(t, err.Error(), "lattice missing") +} + +func TestDoDelete_SuccessOn2xx(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.NotEmpty(t, r.Header.Get("Authorization")) + w.WriteHeader(http.StatusNoContent) + })) + + defer srv.Close() + + require.NoError(t, doDelete(srv.URL, "test")) +} + +func TestDoDelete_404ReturnsHTTPError(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + + defer srv.Close() + + err := doDelete(srv.URL, "test") + require.Error(t, err) + + var httpErr *drapi.HTTPError + + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) +} + +func TestDoDelete_409WithDetailReturnsFormattedError(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"detail": "already terminal"}`)) + })) + + defer srv.Close() + + err := doDelete(srv.URL, "test") + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 409") + assert.Contains(t, err.Error(), "already terminal") +} diff --git a/internal/pipelines/version_test.go b/internal/pipelines/version_test.go new file mode 100644 index 000000000..75028600c --- /dev/null +++ b/internal/pipelines/version_test.go @@ -0,0 +1,110 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipelines + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListVersions_TargetsCorrectURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/versions", r.URL.Path) + assert.Equal(t, "10", r.URL.Query().Get("offset")) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"version":1,"status":"READY","lattice_name":"wf","python_version":"3.12","created_at":"2026-04-29T10:00:00Z"}]`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + items, err := ListVersions("p-1", 10, 0) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, 1, items[0].Version) + assert.Equal(t, "READY", items[0].Status) +} + +func TestGetVersion_TargetsCorrectURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":2,"status":"READY","lattice_name":"wf","python_version":"3.12","created_at":"2026-04-29T10:00:00Z"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := GetVersion("p-1", 2) + require.NoError(t, err) + assert.Equal(t, 2, got.Version) +} + +func TestGetGraph_DraftURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/graph", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "lattice": {"id":"l-0","name":"wf"}, + "nodes": [{"id":"l-0","type":"lattice","name":"wf"}], + "edges": [] + }`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := GetGraph("p-1", ScopeDraft, nil) + require.NoError(t, err) + assert.Equal(t, "wf", got.Lattice.Name) + require.Len(t, got.Nodes, 1) + assert.Equal(t, "lattice", got.Nodes[0].Type) +} + +func TestGetGraph_LockedURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/versions/3/graph", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"lattice":{"id":"l-0","name":"wf"},"nodes":[],"edges":[]}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + v := 3 + got, err := GetGraph("p-1", ScopeLocked, &v) + require.NoError(t, err) + assert.Empty(t, got.Nodes) +} From c6031e1605c3c59b53a0be9bf8a13fb391b27d8f Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 4 May 2026 16:33:27 -0400 Subject: [PATCH 09/25] adding environment subcommand --- cmd/pipelines/cmd.go | 2 + cmd/pipelines/environment/cmd.go | 51 ++++ cmd/pipelines/environment/cmd_test.go | 46 ++++ cmd/pipelines/environment/create/cmd.go | 85 +++++++ cmd/pipelines/environment/create/cmd_test.go | 61 +++++ cmd/pipelines/environment/del/cmd.go | 59 +++++ cmd/pipelines/environment/del/cmd_test.go | 44 ++++ cmd/pipelines/environment/envutil/packages.go | 51 ++++ .../environment/envutil/packages_test.go | 49 ++++ cmd/pipelines/environment/envutil/render.go | 122 ++++++++++ .../environment/envutil/render_test.go | 128 ++++++++++ cmd/pipelines/environment/list/cmd.go | 73 ++++++ cmd/pipelines/environment/list/cmd_test.go | 49 ++++ cmd/pipelines/environment/update/cmd.go | 75 ++++++ cmd/pipelines/environment/update/cmd_test.go | 60 +++++ cmd/pipelines/environment/version/cmd.go | 34 +++ cmd/pipelines/environment/version/cmd_test.go | 35 +++ cmd/pipelines/environment/version/del/cmd.go | 71 ++++++ .../environment/version/del/cmd_test.go | 60 +++++ docs/commands/README.md | 20 +- docs/commands/pipelines-reference.md | 29 +++ docs/commands/pipelines.md | 42 +++- internal/pipelines/environment.go | 196 +++++++++++++++ internal/pipelines/environment_test.go | 223 ++++++++++++++++++ 24 files changed, 1658 insertions(+), 7 deletions(-) create mode 100644 cmd/pipelines/environment/cmd.go create mode 100644 cmd/pipelines/environment/cmd_test.go create mode 100644 cmd/pipelines/environment/create/cmd.go create mode 100644 cmd/pipelines/environment/create/cmd_test.go create mode 100644 cmd/pipelines/environment/del/cmd.go create mode 100644 cmd/pipelines/environment/del/cmd_test.go create mode 100644 cmd/pipelines/environment/envutil/packages.go create mode 100644 cmd/pipelines/environment/envutil/packages_test.go create mode 100644 cmd/pipelines/environment/envutil/render.go create mode 100644 cmd/pipelines/environment/envutil/render_test.go create mode 100644 cmd/pipelines/environment/list/cmd.go create mode 100644 cmd/pipelines/environment/list/cmd_test.go create mode 100644 cmd/pipelines/environment/update/cmd.go create mode 100644 cmd/pipelines/environment/update/cmd_test.go create mode 100644 cmd/pipelines/environment/version/cmd.go create mode 100644 cmd/pipelines/environment/version/cmd_test.go create mode 100644 cmd/pipelines/environment/version/del/cmd.go create mode 100644 cmd/pipelines/environment/version/del/cmd_test.go create mode 100644 internal/pipelines/environment.go create mode 100644 internal/pipelines/environment_test.go diff --git a/cmd/pipelines/cmd.go b/cmd/pipelines/cmd.go index 869d28295..5fb2895f0 100644 --- a/cmd/pipelines/cmd.go +++ b/cmd/pipelines/cmd.go @@ -18,6 +18,7 @@ import ( "github.com/datarobot/cli/cmd/pipelines/create" "github.com/datarobot/cli/cmd/pipelines/del" "github.com/datarobot/cli/cmd/pipelines/dispatch" + "github.com/datarobot/cli/cmd/pipelines/environment" "github.com/datarobot/cli/cmd/pipelines/get" "github.com/datarobot/cli/cmd/pipelines/graph" "github.com/datarobot/cli/cmd/pipelines/input" @@ -56,6 +57,7 @@ input payloads, dispatches, and recurring schedules.`, input.Cmd(), dispatch.Cmd(), schedule.Cmd(), + environment.Cmd(), ) return cmd diff --git a/cmd/pipelines/environment/cmd.go b/cmd/pipelines/environment/cmd.go new file mode 100644 index 000000000..4908b0cfa --- /dev/null +++ b/cmd/pipelines/environment/cmd.go @@ -0,0 +1,51 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package environment + +import ( + "github.com/datarobot/cli/cmd/pipelines/environment/create" + "github.com/datarobot/cli/cmd/pipelines/environment/del" + "github.com/datarobot/cli/cmd/pipelines/environment/list" + "github.com/datarobot/cli/cmd/pipelines/environment/update" + "github.com/datarobot/cli/cmd/pipelines/environment/version" + "github.com/spf13/cobra" +) + +// Cmd returns the parent command for `dr pipelines environment`. It +// groups the lifecycle verbs that operate on pipeline execution +// environments (named, immutable-versioned bags of pip packages). +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "environment", + Aliases: []string{"environments"}, + Short: "Manage pipeline execution environments", + Long: `Manage pipeline execution environments. + +Environments are named, immutable-versioned bags of pip packages that +pipelines can be built against. Each ` + "`update`" + ` adds packages by +creating a new version; older versions can be deleted individually with +` + "`environment version delete`" + `.`, + } + + cmd.AddCommand( + create.Cmd(), + list.Cmd(), + update.Cmd(), + del.Cmd(), + version.Cmd(), + ) + + return cmd +} diff --git a/cmd/pipelines/environment/cmd_test.go b/cmd/pipelines/environment/cmd_test.go new file mode 100644 index 000000000..78456a7e7 --- /dev/null +++ b/cmd/pipelines/environment/cmd_test.go @@ -0,0 +1,46 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package environment + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCmd_RegistersAllVerbs(t *testing.T) { + cmd := Cmd() + + want := map[string]bool{ + "create": false, + "list": false, + "update": false, + "delete": false, + "version": false, + } + + for _, sub := range cmd.Commands() { + want[sub.Name()] = true + } + + for verb, present := range want { + assert.Truef(t, present, "missing subcommand: %s", verb) + } +} + +func TestCmd_HasPluralAlias(t *testing.T) { + cmd := Cmd() + assert.Contains(t, cmd.Aliases, "environments") +} diff --git a/cmd/pipelines/environment/create/cmd.go b/cmd/pipelines/environment/create/cmd.go new file mode 100644 index 000000000..561c760a4 --- /dev/null +++ b/cmd/pipelines/environment/create/cmd.go @@ -0,0 +1,85 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package create + +import ( + "errors" + "fmt" + + "github.com/datarobot/cli/cmd/pipelines/environment/envutil" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipelines" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + name string + description string + rawPackages []string + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a pipeline execution environment", + Long: `Create a new pipeline execution environment. + +A new environment is registered with an initial version (v1) containing +the supplied pip packages. The environment may be referenced by +pipelines once its first version reaches the READY state. + +Example: + dr pipelines environment create --name ml-base --package numpy --package pandas + dr pipelines environment create --name ml-base --packages numpy,pandas==2.0 --description "training base" --output json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, _ []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if name == "" { + return errors.New("--name is required") + } + + packages, err := envutil.NormalizePackages(rawPackages) + if err != nil { + return err + } + + result, err := pipelines.CreateEnvironment(name, description, packages) + if err != nil { + return err + } + + if outputFormat == "json" { + return envutil.PrintEnvironmentJSON(*result) + } + + envutil.PrintEnvironmentHuman(*result) + + return nil + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Environment name (required)") + cmd.Flags().StringVar(&description, "description", "", "Optional description") + cmd.Flags().StringSliceVar(&rawPackages, "package", nil, "Pip package spec (repeatable, also accepts comma-separated values)") + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} diff --git a/cmd/pipelines/environment/create/cmd_test.go b/cmd/pipelines/environment/create/cmd_test.go new file mode 100644 index 000000000..f415f0489 --- /dev/null +++ b/cmd/pipelines/environment/create/cmd_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package create + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--name", "x", "--package", "numpy", "--output", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingName(t *testing.T) { + err := runCmd(t, "--package", "numpy") + require.Error(t, err) + assert.Contains(t, err.Error(), "--name") +} + +func TestCmd_RejectsMissingPackages(t *testing.T) { + err := runCmd(t, "--name", "x") + require.Error(t, err) + assert.Contains(t, err.Error(), "package") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"name", "description", "package", "output"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipelines/environment/del/cmd.go b/cmd/pipelines/environment/del/cmd.go new file mode 100644 index 000000000..19727bf80 --- /dev/null +++ b/cmd/pipelines/environment/del/cmd.go @@ -0,0 +1,59 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package del implements `dr pipelines environment delete`. Directory +// is named `del` rather than `delete` to avoid shadowing Go's built-in +// `delete()` in importing files. + +package del + +import ( + "fmt" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a pipeline execution environment", + Long: `Soft-delete the most recent active version of a pipeline +execution environment. If no active versions remain after the delete, +the parent environment is soft-deleted too. + +To delete a specific older version, use: + dr pipelines environment version delete --environment + +Example: + dr pipelines environment delete env-123`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + err := pipelines.DeleteEnvironment(args[0]) + if err != nil { + return err + } + + fmt.Println(tui.BaseTextStyle.Render("Deleted environment: " + args[0])) + + return nil + }, + } + + return cmd +} diff --git a/cmd/pipelines/environment/del/cmd_test.go b/cmd/pipelines/environment/del/cmd_test.go new file mode 100644 index 000000000..0125db45b --- /dev/null +++ b/cmd/pipelines/environment/del/cmd_test.go @@ -0,0 +1,44 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package del + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RequiresPositionalArg(t *testing.T) { + err := runCmd(t) + require.Error(t, err) +} + +func TestCmd_Name(t *testing.T) { + assert.Equal(t, "delete", Cmd().Name()) +} diff --git a/cmd/pipelines/environment/envutil/packages.go b/cmd/pipelines/environment/envutil/packages.go new file mode 100644 index 000000000..31d46dad8 --- /dev/null +++ b/cmd/pipelines/environment/envutil/packages.go @@ -0,0 +1,51 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// packages.go contains the shared helper for resolving a list of pip +// package specs from the --package flag. The flag is repeatable and also +// accepts comma-separated values so users can write either: +// +// --package numpy --package pandas==2.0 +// --package numpy,pandas==2.0 +// +// Empty entries are dropped and surrounding whitespace is trimmed. + +package envutil + +import ( + "errors" + "strings" +) + +// NormalizePackages takes the raw slice from a cobra StringSliceVar and +// returns a cleaned list. It returns an error when the resulting list is +// empty so callers can surface a friendly validation message. +func NormalizePackages(raw []string) ([]string, error) { + out := make([]string, 0, len(raw)) + + for _, entry := range raw { + for _, item := range strings.Split(entry, ",") { + trimmed := strings.TrimSpace(item) + if trimmed != "" { + out = append(out, trimmed) + } + } + } + + if len(out) == 0 { + return nil, errors.New("at least one package is required (use --package)") + } + + return out, nil +} diff --git a/cmd/pipelines/environment/envutil/packages_test.go b/cmd/pipelines/environment/envutil/packages_test.go new file mode 100644 index 000000000..dffdba7c6 --- /dev/null +++ b/cmd/pipelines/environment/envutil/packages_test.go @@ -0,0 +1,49 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package envutil + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizePackages_RepeatedFlags(t *testing.T) { + got, err := NormalizePackages([]string{"numpy", "pandas==2.0"}) + require.NoError(t, err) + assert.Equal(t, []string{"numpy", "pandas==2.0"}, got) +} + +func TestNormalizePackages_CommaSeparated(t *testing.T) { + got, err := NormalizePackages([]string{"numpy,pandas==2.0, scikit-learn"}) + require.NoError(t, err) + assert.Equal(t, []string{"numpy", "pandas==2.0", "scikit-learn"}, got) +} + +func TestNormalizePackages_DropsBlanks(t *testing.T) { + got, err := NormalizePackages([]string{"numpy,,", " ,pandas"}) + require.NoError(t, err) + assert.Equal(t, []string{"numpy", "pandas"}, got) +} + +func TestNormalizePackages_RejectsEmpty(t *testing.T) { + _, err := NormalizePackages(nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "at least one package") + + _, err = NormalizePackages([]string{"", " ,"}) + require.Error(t, err) +} diff --git a/cmd/pipelines/environment/envutil/render.go b/cmd/pipelines/environment/envutil/render.go new file mode 100644 index 000000000..b8bd3084e --- /dev/null +++ b/cmd/pipelines/environment/envutil/render.go @@ -0,0 +1,122 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// render.go centralises the human/JSON output rendering used by the +// `dr pipelines environment` verbs so each verb file stays focused on +// flag wiring. + +package envutil + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "text/tabwriter" + + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" +) + +// PrintEnvironmentJSON marshals an environment record as indented JSON. +func PrintEnvironmentJSON(env pipelines.Environment) error { + return printJSON(env) +} + +// PrintEnvironmentHuman renders the key facts about a single environment +// record, including its full version history. +func PrintEnvironmentHuman(env pipelines.Environment) { + desc := "\u2014" + if env.Description != nil && *env.Description != "" { + desc = *env.Description + } + + fmt.Println(tui.BaseTextStyle.Render("Environment ID: " + env.EnvironmentID)) + fmt.Println(tui.BaseTextStyle.Render("Name: " + env.Name)) + fmt.Println(tui.BaseTextStyle.Render("Description: " + desc)) + fmt.Println(tui.BaseTextStyle.Render("Latest version: v" + strconv.Itoa(env.LatestVersion))) + fmt.Println(tui.DimStyle.Render("Created: " + env.CreatedAt)) + fmt.Println(tui.DimStyle.Render("Updated: " + env.UpdatedAt)) + + if len(env.Versions) == 0 { + return + } + + fmt.Println() + fmt.Println(tui.BaseTextStyle.Render("Versions:")) + + writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintln(writer, "VERSION\tSTATUS\tPACKAGES\tUPDATED") + + for _, ver := range env.Versions { + fmt.Fprintf(writer, "v%d\t%s\t%s\t%s\n", + ver.Version, ver.Status, joinPackages(ver.Packages), ver.UpdatedAt, + ) + } + + _ = writer.Flush() +} + +// PrintEnvironmentListJSON marshals a list of environments as indented JSON. +func PrintEnvironmentListJSON(items []pipelines.EnvironmentSummary) error { + return printJSON(items) +} + +// PrintEnvironmentListHuman renders a tabular summary of environments. +func PrintEnvironmentListHuman(items []pipelines.EnvironmentSummary) { + if len(items) == 0 { + fmt.Println(tui.DimStyle.Render("No environments found")) + + return + } + + writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintln(writer, "ENVIRONMENT_ID\tNAME\tLATEST\tSTATUS\tUPDATED") + + for _, env := range items { + fmt.Fprintf(writer, "%s\t%s\tv%d\t%s\t%s\n", + env.EnvironmentID, env.Name, env.LatestVersion, env.LatestStatus, env.UpdatedAt, + ) + } + + _ = writer.Flush() +} + +// joinPackages collapses a package slice into a single comma-separated +// string for tabular display, truncating at a reasonable width so the +// table stays readable in a typical terminal. +func joinPackages(packages []string) string { + const maxLen = 60 + + joined := strings.Join(packages, ",") + if len(joined) <= maxLen { + return joined + } + + return joined[:maxLen-3] + "..." +} + +func printJSON(value any) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} diff --git a/cmd/pipelines/environment/envutil/render_test.go b/cmd/pipelines/environment/envutil/render_test.go new file mode 100644 index 000000000..80e0afa44 --- /dev/null +++ b/cmd/pipelines/environment/envutil/render_test.go @@ -0,0 +1,128 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package envutil + +import ( + "bytes" + "io" + "os" + "testing" + + "github.com/datarobot/cli/internal/pipelines" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// captureStdout redirects os.Stdout, runs fn, and returns the captured +// output. The previous stdout is restored unconditionally. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + prev := os.Stdout + + r, w, err := os.Pipe() + require.NoError(t, err) + + os.Stdout = w + + defer func() { + os.Stdout = prev + }() + + fn() + + _ = w.Close() + + buf, err := io.ReadAll(r) + require.NoError(t, err) + + return string(buf) +} + +func TestPrintEnvironmentJSON(t *testing.T) { + desc := "demo" + env := pipelines.Environment{ + EnvironmentID: "env-1", + Name: "ml-base", + Description: &desc, + LatestVersion: 1, + } + + out := captureStdout(t, func() { + require.NoError(t, PrintEnvironmentJSON(env)) + }) + + assert.Contains(t, out, `"environment_id": "env-1"`) + assert.Contains(t, out, `"name": "ml-base"`) +} + +func TestPrintEnvironmentHuman_RendersVersionsTable(t *testing.T) { + env := pipelines.Environment{ + EnvironmentID: "env-1", + Name: "ml-base", + LatestVersion: 2, + Versions: []pipelines.EnvironmentVersion{ + {Version: 2, Packages: []string{"scikit-learn"}, Status: pipelines.EnvironmentStatusReady, UpdatedAt: "u2"}, + {Version: 1, Packages: []string{"numpy", "pandas"}, Status: pipelines.EnvironmentStatusReady, UpdatedAt: "u1"}, + }, + } + + out := captureStdout(t, func() { + PrintEnvironmentHuman(env) + }) + + assert.Contains(t, out, "Environment ID: env-1") + assert.Contains(t, out, "Latest version: v2") + assert.Contains(t, out, "VERSION") + assert.Contains(t, out, "v2") + assert.Contains(t, out, "scikit-learn") +} + +func TestPrintEnvironmentListHuman_EmptyAndPopulated(t *testing.T) { + out := captureStdout(t, func() { + PrintEnvironmentListHuman(nil) + }) + assert.Contains(t, out, "No environments found") + + out = captureStdout(t, func() { + PrintEnvironmentListHuman([]pipelines.EnvironmentSummary{ + {EnvironmentID: "env-1", Name: "ml-base", LatestVersion: 3, LatestStatus: pipelines.EnvironmentStatusReady, UpdatedAt: "u"}, + }) + }) + assert.Contains(t, out, "ENVIRONMENT_ID") + assert.Contains(t, out, "env-1") + assert.Contains(t, out, "v3") +} + +func TestPrintEnvironmentListJSON_EmitsArray(t *testing.T) { + out := captureStdout(t, func() { + require.NoError(t, PrintEnvironmentListJSON([]pipelines.EnvironmentSummary{ + {EnvironmentID: "env-1", Name: "n", LatestVersion: 1, LatestStatus: pipelines.EnvironmentStatusReady}, + })) + }) + + // Sanity check JSON parseability via simple substring assertions; the + // real JSON shape is exercised by the shared json.MarshalIndent path. + assert.Contains(t, out, `[`) + assert.Contains(t, out, `]`) + assert.Contains(t, out, `"environment_id": "env-1"`) +} + +func TestJoinPackages_Truncates(t *testing.T) { + long := bytes.Repeat([]byte("x"), 80) + out := joinPackages([]string{string(long)}) + assert.Len(t, out, 60) + assert.True(t, len(out) > 3 && out[len(out)-3:] == "...") +} diff --git a/cmd/pipelines/environment/list/cmd.go b/cmd/pipelines/environment/list/cmd.go new file mode 100644 index 000000000..8e8566ec4 --- /dev/null +++ b/cmd/pipelines/environment/list/cmd.go @@ -0,0 +1,73 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package list + +import ( + "fmt" + + "github.com/datarobot/cli/cmd/pipelines/environment/envutil" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipelines" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + offset int + limit int + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List pipeline execution environments", + Long: `List pipeline execution environments. + +Returns a tabular view of registered environments, newest first. Each +row reflects the latest version's status only; per-version details are +returned by ` + "`environment create`" + ` and ` + "`environment update`" + `. + +Example: + dr pipelines environment list + dr pipelines environment list --offset 50 --limit 10 --output json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, _ []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + items, err := pipelines.ListEnvironments(offset, limit) + if err != nil { + return err + } + + if outputFormat == "json" { + return envutil.PrintEnvironmentListJSON(items) + } + + envutil.PrintEnvironmentListHuman(items) + + return nil + }, + } + + cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") + cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of environments to return") + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} diff --git a/cmd/pipelines/environment/list/cmd_test.go b/cmd/pipelines/environment/list/cmd_test.go new file mode 100644 index 000000000..bd61eaadf --- /dev/null +++ b/cmd/pipelines/environment/list/cmd_test.go @@ -0,0 +1,49 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package list + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--output", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"offset", "limit", "output"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipelines/environment/update/cmd.go b/cmd/pipelines/environment/update/cmd.go new file mode 100644 index 000000000..00e45190a --- /dev/null +++ b/cmd/pipelines/environment/update/cmd.go @@ -0,0 +1,75 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package update + +import ( + "fmt" + + "github.com/datarobot/cli/cmd/pipelines/environment/envutil" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipelines" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + rawPackages []string + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Add a new version to a pipeline execution environment", + Long: `Update a pipeline execution environment by appending packages. + +Updating creates a new immutable version of the environment containing +the supplied pip packages. Existing versions are unchanged. + +Example: + dr pipelines environment update env-123 --package scikit-learn + dr pipelines environment update env-123 --package "scikit-learn==1.5,torch" --output json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + if outputFormat != "" && outputFormat != "json" { + return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + packages, err := envutil.NormalizePackages(rawPackages) + if err != nil { + return err + } + + result, err := pipelines.UpdateEnvironment(args[0], packages) + if err != nil { + return err + } + + if outputFormat == "json" { + return envutil.PrintEnvironmentJSON(*result) + } + + envutil.PrintEnvironmentHuman(*result) + + return nil + }, + } + + cmd.Flags().StringSliceVar(&rawPackages, "package", nil, "Pip package spec (repeatable, also accepts comma-separated values)") + cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + + return cmd +} diff --git a/cmd/pipelines/environment/update/cmd_test.go b/cmd/pipelines/environment/update/cmd_test.go new file mode 100644 index 000000000..bcb2510fc --- /dev/null +++ b/cmd/pipelines/environment/update/cmd_test.go @@ -0,0 +1,60 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package update + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "env-1", "--package", "numpy", "--output", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RequiresPositionalArg(t *testing.T) { + err := runCmd(t, "--package", "numpy") + require.Error(t, err) +} + +func TestCmd_RejectsMissingPackages(t *testing.T) { + err := runCmd(t, "env-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "package") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"package", "output"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipelines/environment/version/cmd.go b/cmd/pipelines/environment/version/cmd.go new file mode 100644 index 000000000..35bb135ad --- /dev/null +++ b/cmd/pipelines/environment/version/cmd.go @@ -0,0 +1,34 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package version + +import ( + "github.com/datarobot/cli/cmd/pipelines/environment/version/del" + "github.com/spf13/cobra" +) + +// Cmd returns the parent command for `dr pipelines environment version`. +// Currently only delete is exposed; the pipelines-api does not surface +// per-version GET endpoints. +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "version", + Short: "Manage versions of a pipeline execution environment", + } + + cmd.AddCommand(del.Cmd()) + + return cmd +} diff --git a/cmd/pipelines/environment/version/cmd_test.go b/cmd/pipelines/environment/version/cmd_test.go new file mode 100644 index 000000000..695edee8b --- /dev/null +++ b/cmd/pipelines/environment/version/cmd_test.go @@ -0,0 +1,35 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package version + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCmd_RegistersDelete(t *testing.T) { + cmd := Cmd() + + found := false + + for _, sub := range cmd.Commands() { + if sub.Name() == "delete" { + found = true + } + } + + assert.True(t, found, "missing delete subcommand") +} diff --git a/cmd/pipelines/environment/version/del/cmd.go b/cmd/pipelines/environment/version/del/cmd.go new file mode 100644 index 000000000..7443bdd1b --- /dev/null +++ b/cmd/pipelines/environment/version/del/cmd.go @@ -0,0 +1,71 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package del implements `dr pipelines environment version delete`. +// Directory is named `del` to avoid shadowing Go's built-in `delete()`. + +package del + +import ( + "errors" + "fmt" + "strconv" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var environmentID string + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a specific version of a pipeline execution environment", + Long: `Soft-delete a specific version of a pipeline execution environment +without touching the parent environment. + +Example: + dr pipelines environment version delete --environment env-123 2`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + if environmentID == "" { + return errors.New("--environment is required") + } + + version, err := strconv.Atoi(args[0]) + if err != nil || version <= 0 { + return fmt.Errorf("invalid version: %q (expected a positive integer)", args[0]) + } + + err = pipelines.DeleteEnvironmentVersion(environmentID, version) + if err != nil { + return err + } + + fmt.Println(tui.BaseTextStyle.Render( + fmt.Sprintf("Deleted environment version: %s v%d", environmentID, version), + )) + + return nil + }, + } + + cmd.Flags().StringVar(&environmentID, "environment", "", "Environment ID (required)") + + return cmd +} diff --git a/cmd/pipelines/environment/version/del/cmd_test.go b/cmd/pipelines/environment/version/del/cmd_test.go new file mode 100644 index 000000000..e1bd24223 --- /dev/null +++ b/cmd/pipelines/environment/version/del/cmd_test.go @@ -0,0 +1,60 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package del + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsMissingEnvironment(t *testing.T) { + err := runCmd(t, "1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--environment") +} + +func TestCmd_RejectsBadVersion(t *testing.T) { + err := runCmd(t, "--environment", "env-1", "abc") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid version") + + err = runCmd(t, "--environment", "env-1", "0") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid version") +} + +func TestCmd_RequiresPositionalArg(t *testing.T) { + err := runCmd(t, "--environment", "env-1") + require.Error(t, err) +} + +func TestCmd_HasEnvironmentFlag(t *testing.T) { + assert.NotNil(t, Cmd().Flags().Lookup("environment")) +} diff --git a/docs/commands/README.md b/docs/commands/README.md index 92664b978..58e378205 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -98,12 +98,19 @@ dr │ │ ├── get Display a single dispatch │ │ ├── status Lightweight dispatch status (for polling) │ │ └── cancel Cancel a running dispatch -│ └── schedule Manage recurring (cron) dispatches (locked-only) -│ ├── create Register a recurring schedule on a locked version -│ ├── list List schedules for a locked version -│ ├── get Display a single schedule -│ ├── update Change cron expression / timezone -│ └── delete Delete a schedule +│ ├── schedule Manage recurring (cron) dispatches (locked-only) +│ │ ├── create Register a recurring schedule on a locked version +│ │ ├── list List schedules for a locked version +│ │ ├── get Display a single schedule +│ │ ├── update Change cron expression / timezone +│ │ └── delete Delete a schedule +│ └── environment Manage pipeline execution environments (pip packages) +│ ├── create Register a new environment with an initial version +│ ├── list List environments +│ ├── update Add packages by creating a new version +│ ├── delete Soft-delete the latest version (cascades parent) +│ └── version Manage individual environment versions +│ └── delete Delete a specific environment version └── self CLI utility commands ├── completion Shell completion │ ├── install Install completions interactively @@ -277,6 +284,7 @@ For detailed documentation on each command, see: - `input`—`create`/`list`/`get`/`update`/`delete` JSON payloads used by dispatches. - `dispatch`—`create`/`list`/`get`/`status`/`cancel` pipeline executions. - `schedule`—`create`/`list`/`get`/`update`/`delete` recurring (cron) dispatches on locked versions. + - `environment`—`create`/`list`/`update`/`delete` named, immutable-versioned pip-package execution environments; `environment version delete` removes a specific older version. ## Getting help diff --git a/docs/commands/pipelines-reference.md b/docs/commands/pipelines-reference.md index 3a2f3dec5..5ed776b3c 100644 --- a/docs/commands/pipelines-reference.md +++ b/docs/commands/pipelines-reference.md @@ -89,6 +89,30 @@ Schedules are **locked-only** — every verb requires both `--pipeline` and --- +## Execution environments (`dr pipelines environment …`) + +Pipeline execution environments are named, immutable-versioned bags of +pip packages. They live at the top of the pipelines namespace (not +nested under a specific pipeline) and are created/updated independently. +Each `update` appends a new version; older versions can be deleted +individually. + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipelines environment create` | `POST /pipelines/environments` | `dr pipelines environment create --name ml-base --package numpy --package pandas==2.0`
`dr pipelines environment create --name ml-base --package "numpy,pandas==2.0" --description "training base" --output json` | **Flags:** `--name ` (required), `--description `, `--package ` (required, repeatable, also accepts comma-separated values), `--output json`.
**Body sent to API:** `{"name": "...", "description"?: "...", "packages": ["..."]}`. | +| `dr pipelines environment list` | `GET /pipelines/environments` | `dr pipelines environment list`
`dr pipelines environment list --offset 50 --limit 10 --output json` | **Flags:** `--offset `, `--limit `, `--output json`. | +| `dr pipelines environment update` | `PATCH /pipelines/environments/{environment_id}` | `dr pipelines environment update --package scikit-learn`
`dr pipelines environment update --package "scikit-learn,torch" --output json` | **Positional:** `` (required).
**Flags:** `--package ` (required, repeatable, also accepts comma-separated values), `--output json`.
**Body sent to API:** `{"packages": ["..."]}`. | +| `dr pipelines environment delete` | `DELETE /pipelines/environments/{environment_id}` | `dr pipelines environment delete ` | **Positional:** `` (required). | +| `dr pipelines environment version delete` | `DELETE /pipelines/environments/{environment_id}/versions/{version_id}` | `dr pipelines environment version delete --environment 2` | **Positional:** `` (positive integer, required).
**Flags:** `--environment ` (required). | + +> [!NOTE] +> The pipelines-api currently does not expose `GET` endpoints for a +> single environment or for individual versions, so the CLI does not +> ship `environment get` or `environment version get`. The full version +> history is only returned in the `create` and `update` responses. + +--- + ## Shared flag semantics ### `--scope` / `--version` (inputs, dispatches, graph) @@ -176,3 +200,8 @@ exercising a local API stub that doesn't implement `/version/`. | `GET /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipelines schedule get` | | `PATCH /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipelines schedule update` | | `DELETE /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipelines schedule delete` | +| `POST /pipelines/environments` | `dr pipelines environment create` | +| `GET /pipelines/environments` | `dr pipelines environment list` | +| `PATCH /pipelines/environments/{environment_id}` | `dr pipelines environment update` | +| `DELETE /pipelines/environments/{environment_id}` | `dr pipelines environment delete` | +| `DELETE /pipelines/environments/{environment_id}/versions/{version_id}` | `dr pipelines environment version delete` | diff --git a/docs/commands/pipelines.md b/docs/commands/pipelines.md index 9307d6fab..651c1ab4c 100644 --- a/docs/commands/pipelines.md +++ b/docs/commands/pipelines.md @@ -50,7 +50,9 @@ top-level `dr pipelines` subcommand operates on one of four resources: - pipeline **versions** (list, get, graph), - pipeline **inputs** — JSON payloads supplied to a dispatch, - pipeline **dispatches** — concrete executions on Covalent, -- pipeline **schedules** — recurring dispatches on a cron expression. +- pipeline **schedules** — recurring dispatches on a cron expression, +- pipeline **environments** — named, immutable-versioned bags of pip + packages that pipelines can be built against. Versions are created automatically: @@ -85,6 +87,7 @@ to a frozen version) — selected via the shared `--scope` and | `dr pipelines input …` | `…/inputs` and `…/inputs/{input_id}` | Manage JSON payloads for dispatches. | | `dr pipelines dispatch …` | `…/dispatches` and `…/dispatches/{dispatch_id}` | Trigger, inspect, and cancel runs. | | `dr pipelines schedule …` | `…/versions/{ver}/schedules` | Manage recurring (cron) dispatches on locked versions. | +| `dr pipelines environment …` | `/api/v2/pipelines/environments[/...]` | Manage named, versioned pip-package execution environments. | ## Subcommands @@ -373,6 +376,43 @@ dr pipelines schedule delete --pipeline --version=N `schedule update` requires at least one of `--cron` or `--timezone`. +### `environment` + +Manage pipeline execution environments — named, immutable-versioned +bags of pip packages that pipelines can be built against. Environments +live at the top of the pipelines namespace (not nested under a specific +pipeline) and have their own lifecycle. + +```bash +dr pipelines environment create --name --package [--package ] ... +dr pipelines environment list [--offset N] [--limit N] [--output json] +dr pipelines environment update --package [...] +dr pipelines environment delete +dr pipelines environment version delete --environment +``` + +`create` registers a new environment with an initial v1 containing the +supplied pip packages; the returned record reports the build status of +that first version. `update` adds packages to an existing environment +by creating a new immutable version (older versions are unchanged). +`delete` soft-deletes the most recent active version (and cascades the +parent if no versions remain). `version delete` targets a specific +older version without touching the parent. + +`--package` is repeatable and also accepts comma-separated values: + +```bash +dr pipelines environment create --name ml-base \ + --package numpy --package pandas==2.0 +dr pipelines environment create --name ml-base \ + --package "numpy,pandas==2.0,scikit-learn" +``` + +> [!NOTE] +> The pipelines-api currently does not surface `GET` endpoints for a +> single environment or for the version list. The full version history +> is only returned in the `create` and `update` responses. + ## Shared flags ### `--scope` / `--version` flags diff --git a/internal/pipelines/environment.go b/internal/pipelines/environment.go new file mode 100644 index 000000000..0c1f57b80 --- /dev/null +++ b/internal/pipelines/environment.go @@ -0,0 +1,196 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// environment.go contains the typed client wrappers for the pipeline +// execution-environment endpoints described under the +// `pipeline-execution-environments` tag of the pipelines-api OpenAPI spec. +// +// Environments are named, immutable-versioned bags of pip packages that +// pipelines can be built against. They live at the top of the pipelines +// namespace (not nested under a specific pipeline) and have their own +// lifecycle: +// +// POST /api/v2/pipelines/environments +// GET /api/v2/pipelines/environments +// PATCH /api/v2/pipelines/environments/{id} (adds packages -> new version) +// DELETE /api/v2/pipelines/environments/{id} (soft-deletes latest version, cascades parent) +// DELETE /api/v2/pipelines/environments/{id}/versions/{n} (soft-deletes a specific version) + +package pipelines + +import ( + "net/http" + "net/url" + "strconv" + + "github.com/datarobot/cli/internal/config" +) + +// EnvironmentStatus mirrors PipelineEnvironmentStatus in the API. +type EnvironmentStatus string + +const ( + EnvironmentStatusCreating EnvironmentStatus = "CREATING" + EnvironmentStatusReady EnvironmentStatus = "READY" + EnvironmentStatusError EnvironmentStatus = "ERROR" +) + +// EnvironmentVersion mirrors PipelineEnvironmentVersionResponse. +type EnvironmentVersion struct { + Version int `json:"version"` + Packages []string `json:"packages"` + Status EnvironmentStatus `json:"status"` + ErrorDetail *string `json:"error_detail,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// Environment mirrors PipelineEnvironmentResponse (full detail). +type Environment struct { + EnvironmentID string `json:"environment_id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + LatestVersion int `json:"latest_version"` + Versions []EnvironmentVersion `json:"versions"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// EnvironmentSummary mirrors PipelineEnvironmentSummaryResponse (list item). +type EnvironmentSummary struct { + EnvironmentID string `json:"environment_id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + LatestVersion int `json:"latest_version"` + LatestStatus EnvironmentStatus `json:"latest_status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// EnvironmentCreateRequest mirrors PipelineEnvironmentCreateRequest. +type EnvironmentCreateRequest struct { + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Packages []string `json:"packages"` +} + +// EnvironmentUpdateRequest mirrors PipelineEnvironmentUpdateRequest. +type EnvironmentUpdateRequest struct { + Packages []string `json:"packages"` +} + +// CreateEnvironment POSTs a new environment with an initial set of pip +// packages. The API returns 201 with the full Environment payload (a +// single CREATING version is returned immediately; READY status is +// reached asynchronously by the covalent build). +func CreateEnvironment(name, description string, packages []string) (*Environment, error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/environments") + if err != nil { + return nil, err + } + + body := EnvironmentCreateRequest{ + Name: name, + Packages: packages, + } + if description != "" { + body.Description = &description + } + + var result Environment + + err = doJSON(http.MethodPost, endpoint, body, "create environment", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// ListEnvironments returns a paginated slice of environments. The API +// returns a bare JSON array (no envelope), newest first. +func ListEnvironments(offset, limit int) ([]EnvironmentSummary, error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/environments") + if err != nil { + return nil, err + } + + query := url.Values{} + if offset > 0 { + query.Set("offset", strconv.Itoa(offset)) + } + + if limit > 0 { + query.Set("limit", strconv.Itoa(limit)) + } + + if encoded := query.Encode(); encoded != "" { + endpoint = endpoint + "?" + encoded + } + + var items []EnvironmentSummary + + err = doJSON(http.MethodGet, endpoint, nil, "environments", &items) + if err != nil { + return nil, err + } + + return items, nil +} + +// UpdateEnvironment PATCHes an environment with additional packages, +// creating a new immutable version. The response includes the full +// Environment with all versions ordered newest-first. +func UpdateEnvironment(envID string, packages []string) (*Environment, error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/environments/" + envID) + if err != nil { + return nil, err + } + + body := EnvironmentUpdateRequest{Packages: packages} + + var result Environment + + err = doJSON(http.MethodPatch, endpoint, body, "update environment", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// DeleteEnvironment soft-deletes the most-recent active version of an +// environment. If no active versions remain, the parent environment is +// soft-deleted as well. +func DeleteEnvironment(envID string) error { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/environments/" + envID) + if err != nil { + return err + } + + return doDelete(endpoint, "delete environment") +} + +// DeleteEnvironmentVersion soft-deletes a specific version of an +// environment without touching the parent. +func DeleteEnvironmentVersion(envID string, version int) error { + endpoint, err := config.GetEndpointURL( + "/api/v2/pipelines/environments/" + envID + "/versions/" + strconv.Itoa(version), + ) + if err != nil { + return err + } + + return doDelete(endpoint, "delete environment version") +} diff --git a/internal/pipelines/environment_test.go b/internal/pipelines/environment_test.go new file mode 100644 index 000000000..bb92a1142 --- /dev/null +++ b/internal/pipelines/environment_test.go @@ -0,0 +1,223 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipelines + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/datarobot/cli/internal/drapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateEnvironment_PostsBody(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v2/pipelines/environments", r.URL.Path) + + var body EnvironmentCreateRequest + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "ml-base", body.Name) + + if assert.NotNil(t, body.Description) { + assert.Equal(t, "for testing", *body.Description) + } + + assert.Equal(t, []string{"numpy", "pandas==2.0"}, body.Packages) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{ + "environment_id":"env-1", + "name":"ml-base", + "description":"for testing", + "latest_version":1, + "versions":[{"version":1,"packages":["numpy","pandas==2.0"],"status":"CREATING","created_at":"t","updated_at":"t"}], + "created_at":"t","updated_at":"t" + }`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := CreateEnvironment("ml-base", "for testing", []string{"numpy", "pandas==2.0"}) + require.NoError(t, err) + assert.Equal(t, "env-1", got.EnvironmentID) + assert.Equal(t, 1, got.LatestVersion) + require.Len(t, got.Versions, 1) + assert.Equal(t, EnvironmentStatusCreating, got.Versions[0].Status) +} + +func TestCreateEnvironment_OmitsEmptyDescription(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw := map[string]any{} + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&raw)) + _, hasDesc := raw["description"] + assert.False(t, hasDesc, "description should be omitted when empty") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"environment_id":"env-1","name":"x","latest_version":1,"versions":[],"created_at":"t","updated_at":"t"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + _, err := CreateEnvironment("x", "", []string{"numpy"}) + require.NoError(t, err) +} + +func TestListEnvironments_AddsPaginationQuery(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v2/pipelines/environments", r.URL.Path) + assert.Equal(t, "5", r.URL.Query().Get("offset")) + assert.Equal(t, "20", r.URL.Query().Get("limit")) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"environment_id":"env-1","name":"ml-base","latest_version":2,"latest_status":"READY","created_at":"t","updated_at":"t"} + ]`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + items, err := ListEnvironments(5, 20) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "env-1", items[0].EnvironmentID) + assert.Equal(t, EnvironmentStatusReady, items[0].LatestStatus) +} + +func TestListEnvironments_OmitsZeroPagination(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.URL.RawQuery) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + items, err := ListEnvironments(0, 0) + require.NoError(t, err) + assert.Empty(t, items) +} + +func TestUpdateEnvironment_PatchesBody(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/api/v2/pipelines/environments/env-1", r.URL.Path) + + var body EnvironmentUpdateRequest + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, []string{"scikit-learn"}, body.Packages) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "environment_id":"env-1","name":"ml-base","latest_version":2, + "versions":[ + {"version":2,"packages":["scikit-learn"],"status":"CREATING","created_at":"t","updated_at":"t"}, + {"version":1,"packages":["numpy"],"status":"READY","created_at":"t","updated_at":"t"} + ], + "created_at":"t","updated_at":"t" + }`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := UpdateEnvironment("env-1", []string{"scikit-learn"}) + require.NoError(t, err) + assert.Equal(t, 2, got.LatestVersion) + require.Len(t, got.Versions, 2) + assert.Equal(t, 2, got.Versions[0].Version) +} + +func TestDeleteEnvironment_HitsCorrectURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v2/pipelines/environments/env-1", r.URL.Path) + + w.WriteHeader(http.StatusNoContent) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + require.NoError(t, DeleteEnvironment("env-1")) +} + +func TestDeleteEnvironmentVersion_HitsCorrectURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v2/pipelines/environments/env-1/versions/3", r.URL.Path) + + w.WriteHeader(http.StatusNoContent) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + require.NoError(t, DeleteEnvironmentVersion("env-1", 3)) +} + +func TestDeleteEnvironment_PropagatesNotFound(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + err := DeleteEnvironment("nope") + + var httpErr *drapi.HTTPError + + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) +} From f5306837df040469e2c992611e12c6a4e902fda7 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Wed, 6 May 2026 15:14:33 -0400 Subject: [PATCH 10/25] update the commands to use new words-pipeline and task --- cmd/pipelines/create/cmd.go | 24 +++---- cmd/pipelines/create/cmd_test.go | 28 ++++----- cmd/pipelines/dispatch/dispatchutil/render.go | 2 +- cmd/pipelines/get/cmd.go | 10 +-- cmd/pipelines/get/cmd_test.go | 8 +-- cmd/pipelines/graph/cmd.go | 8 +-- cmd/pipelines/graph/cmd_test.go | 23 +++---- cmd/pipelines/input/inpututil/render.go | 2 +- cmd/pipelines/lock/cmd.go | 20 +++--- cmd/pipelines/lock/cmd_test.go | 28 ++++----- cmd/pipelines/schedule/scheduleutil/render.go | 2 +- cmd/pipelines/update/cmd.go | 25 ++++---- cmd/pipelines/update/cmd_test.go | 32 +++++----- cmd/pipelines/version/get/cmd.go | 2 +- cmd/pipelines/version/versionutil/render.go | 20 +++--- .../version/versionutil/render_test.go | 12 ++-- docs/commands/README.md | 4 +- docs/commands/pipelines-reference.md | 2 +- docs/commands/pipelines.md | 63 ++++++++++--------- internal/pipelines/pipeline.go | 27 +++++--- internal/pipelines/pipeline_lifecycle_test.go | 2 +- internal/pipelines/version.go | 16 +++-- internal/pipelines/version_test.go | 2 +- 23 files changed, 189 insertions(+), 173 deletions(-) diff --git a/cmd/pipelines/create/cmd.go b/cmd/pipelines/create/cmd.go index 7a778a642..d6a3476d5 100644 --- a/cmd/pipelines/create/cmd.go +++ b/cmd/pipelines/create/cmd.go @@ -39,9 +39,9 @@ func Cmd() *cobra.Command { cmd := &cobra.Command{ Use: "create []", Short: "Upload a Python file to create a pipeline.", - Long: `Upload a Python file containing a Covalent lattice to register a new pipeline. + Long: `Upload a Python file containing a DataRobot pipeline (one or more tasks) to register a new pipeline. -The lattice name is extracted from the file and used as the pipeline name. +The pipeline name is extracted from the file and used as the pipeline's resource name. By default, output is human-readable. Use --output json for machine-parseable output. The path to the Python file can be supplied either as a positional argument @@ -123,16 +123,16 @@ func printCreateJSON(result pipelines.CreateResponse) error { } func printCreateHuman(result pipelines.CreateResponse) { - electrons := "\u2014" - if len(result.ElectronNames) > 0 { - electrons = strings.Join(result.ElectronNames, ", ") + tasks := "\u2014" + if len(result.TaskNames) > 0 { + tasks = strings.Join(result.TaskNames, ", ") } - fmt.Println(tui.BaseTextStyle.Render("Pipeline: " + result.PipelineID)) - fmt.Println(tui.BaseTextStyle.Render("Name: " + result.Name)) - fmt.Println(tui.BaseTextStyle.Render("Version: " + strconv.Itoa(result.Version))) - fmt.Println(tui.BaseTextStyle.Render("Status: " + result.Status)) - fmt.Println(tui.BaseTextStyle.Render("Mode: " + result.Mode)) - fmt.Println(tui.BaseTextStyle.Render("Electrons: " + electrons)) - fmt.Println(tui.DimStyle.Render("Created: " + result.CreatedAt.UTC().Format(time.RFC3339))) + fmt.Println(tui.BaseTextStyle.Render("Pipeline ID: " + result.PipelineID)) + fmt.Println(tui.BaseTextStyle.Render("Name: " + result.Name)) + fmt.Println(tui.BaseTextStyle.Render("Version: " + strconv.Itoa(result.Version))) + fmt.Println(tui.BaseTextStyle.Render("Status: " + result.Status)) + fmt.Println(tui.BaseTextStyle.Render("Mode: " + result.Mode)) + fmt.Println(tui.BaseTextStyle.Render("Tasks: " + tasks)) + fmt.Println(tui.DimStyle.Render("Created: " + result.CreatedAt.UTC().Format(time.RFC3339))) } diff --git a/cmd/pipelines/create/cmd_test.go b/cmd/pipelines/create/cmd_test.go index 5dd6c2532..d6f53466b 100644 --- a/cmd/pipelines/create/cmd_test.go +++ b/cmd/pipelines/create/cmd_test.go @@ -49,13 +49,13 @@ func captureStdout(t *testing.T, fn func()) string { func sampleCreateResponse() pipelines.CreateResponse { return pipelines.CreateResponse{ - PipelineID: "6658f441-a8f5-4f21-b4d8-6cccf4c94c5b", - Name: "confluence_to_vdb", - Version: 1, - Status: "READY", - Mode: "draft", - ElectronNames: []string{"create_vector_database", "ingest_confluence_files"}, - CreatedAt: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC), + PipelineID: "6658f441-a8f5-4f21-b4d8-6cccf4c94c5b", + Name: "confluence_to_vdb", + Version: 1, + Status: "READY", + Mode: "draft", + TaskNames: []string{"create_vector_database", "ingest_confluence_files"}, + CreatedAt: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC), } } @@ -78,7 +78,7 @@ func TestPrintCreateJSON(t *testing.T) { assert.EqualValues(t, 1, parsed["version"]) } -func TestPrintCreateHuman_WithElectrons(t *testing.T) { +func TestPrintCreateHuman_WithTasks(t *testing.T) { resp := sampleCreateResponse() output := captureStdout(t, func() { @@ -87,21 +87,21 @@ func TestPrintCreateHuman_WithElectrons(t *testing.T) { assert.Contains(t, output, resp.PipelineID) assert.Contains(t, output, "confluence_to_vdb") - assert.Contains(t, output, "Version: 1") - assert.Contains(t, output, "Status: READY") - assert.Contains(t, output, "Mode: draft") + assert.Contains(t, output, "Version: 1") + assert.Contains(t, output, "Status: READY") + assert.Contains(t, output, "Mode: draft") assert.Contains(t, output, "create_vector_database, ingest_confluence_files") } -func TestPrintCreateHuman_NoElectrons(t *testing.T) { +func TestPrintCreateHuman_NoTasks(t *testing.T) { resp := sampleCreateResponse() - resp.ElectronNames = nil + resp.TaskNames = nil output := captureStdout(t, func() { printCreateHuman(resp) }) - assert.Contains(t, output, "Electrons: \u2014") + assert.Contains(t, output, "Tasks: \u2014") } func TestCmd_RequiresFilePath(t *testing.T) { diff --git a/cmd/pipelines/dispatch/dispatchutil/render.go b/cmd/pipelines/dispatch/dispatchutil/render.go index 46a0fb0e2..254fafe19 100644 --- a/cmd/pipelines/dispatch/dispatchutil/render.go +++ b/cmd/pipelines/dispatch/dispatchutil/render.go @@ -57,7 +57,7 @@ func PrintDispatchHuman(d pipelines.Dispatch) { } fmt.Println(tui.BaseTextStyle.Render("Dispatch ID: " + d.DispatchID)) - fmt.Println(tui.BaseTextStyle.Render("Pipeline: " + d.PipelineID)) + fmt.Println(tui.BaseTextStyle.Render("Pipeline ID: " + d.PipelineID)) fmt.Println(tui.BaseTextStyle.Render("Scope: " + scope)) fmt.Println(tui.BaseTextStyle.Render("Version: " + versionDisplay)) fmt.Println(tui.BaseTextStyle.Render("Input ID: " + d.InputID)) diff --git a/cmd/pipelines/get/cmd.go b/cmd/pipelines/get/cmd.go index 71f52a895..a57d673f2 100644 --- a/cmd/pipelines/get/cmd.go +++ b/cmd/pipelines/get/cmd.go @@ -123,12 +123,12 @@ func printGetHuman(pipeline pipelines.Pipeline) { writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(writer, " VERSION\tSTATUS\tPYTHON\tCREATED\tELECTRONS") + fmt.Fprintln(writer, " VERSION\tSTATUS\tPYTHON\tCREATED\tTASKS") for _, version := range pipeline.Versions { - electrons := "\u2014" - if len(version.ElectronNames) > 0 { - electrons = strings.Join(version.ElectronNames, ", ") + tasks := "\u2014" + if len(version.TaskNames) > 0 { + tasks = strings.Join(version.TaskNames, ", ") } python := version.PythonVersion @@ -141,7 +141,7 @@ func printGetHuman(pipeline pipelines.Pipeline) { version.Status, python, version.CreatedAt.UTC().Format(time.RFC3339), - electrons, + tasks, ) } diff --git a/cmd/pipelines/get/cmd_test.go b/cmd/pipelines/get/cmd_test.go index f010887d8..fb7114067 100644 --- a/cmd/pipelines/get/cmd_test.go +++ b/cmd/pipelines/get/cmd_test.go @@ -62,15 +62,15 @@ func samplePipeline() pipelines.Pipeline { { Version: 1, Status: "READY", - LatticeName: "confluence_to_vdb", - ElectronNames: []string{"create_vector_database", "ingest_confluence_files"}, + PipelineName: "confluence_to_vdb", + TaskNames: []string{"create_vector_database", "ingest_confluence_files"}, PythonVersion: "3.12", CreatedAt: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC), }, { Version: 2, Status: "FAILED", - LatticeName: "confluence_to_vdb", + PipelineName: "confluence_to_vdb", PythonVersion: "3.12", ErrorDetail: "boom", CreatedAt: time.Date(2026, 4, 28, 12, 25, 11, 0, time.UTC), @@ -116,7 +116,7 @@ func TestPrintGetHuman_RendersHeaderAndVersions(t *testing.T) { assert.Contains(t, output, "STATUS") assert.Contains(t, output, "PYTHON") assert.Contains(t, output, "CREATED") - assert.Contains(t, output, "ELECTRONS") + assert.Contains(t, output, "TASKS") assert.Contains(t, output, "v1") assert.Contains(t, output, "v2") assert.Contains(t, output, "create_vector_database, ingest_confluence_files") diff --git a/cmd/pipelines/graph/cmd.go b/cmd/pipelines/graph/cmd.go index 4ff61cb88..0d23730a9 100644 --- a/cmd/pipelines/graph/cmd.go +++ b/cmd/pipelines/graph/cmd.go @@ -40,8 +40,8 @@ func Cmd() *cobra.Command { cmd := &cobra.Command{ Use: "graph", Short: "Display the DAG of a pipeline", - Long: `Display the lattice/electron graph for a pipeline as either a JSON -payload (for visualisation tooling) or a human-readable summary. + Long: `Display the pipeline/task graph (DAG) as either a JSON payload +(for visualisation tooling) or a human-readable summary. Scope is selected from the --scope/--version flags: - no flags -> draft graph (latest version) @@ -114,8 +114,8 @@ func printGraphJSON(g pipelines.Graph) error { } func printGraphHuman(g pipelines.Graph) { - fmt.Println(tui.BaseTextStyle.Render("Lattice: " + g.Lattice.Name)) - fmt.Println(tui.DimStyle.Render("ID: " + g.Lattice.ID)) + fmt.Println(tui.BaseTextStyle.Render("Pipeline: " + g.Pipeline.Name)) + fmt.Println(tui.DimStyle.Render("ID: " + g.Pipeline.ID)) if len(g.Nodes) == 0 { fmt.Println(tui.DimStyle.Render("No nodes")) diff --git a/cmd/pipelines/graph/cmd_test.go b/cmd/pipelines/graph/cmd_test.go index 2e0e0cbbb..c231fa4fb 100644 --- a/cmd/pipelines/graph/cmd_test.go +++ b/cmd/pipelines/graph/cmd_test.go @@ -51,13 +51,13 @@ func captureStdout(t *testing.T, fn func()) string { func sampleGraph() pipelines.Graph { return pipelines.Graph{ - Lattice: pipelines.GraphLattice{ID: "lattice-0", Name: "wf"}, + Pipeline: pipelines.GraphPipeline{ID: "pipeline-0", Name: "wf"}, Nodes: []pipelines.GraphNode{ - {ID: "lattice-0", Type: "lattice", Name: "wf"}, - {ID: "electron-0", Type: "electron", Name: "step1"}, + {ID: "pipeline-0", Type: "lattice", Name: "wf"}, + {ID: "task-0", Type: "electron", Name: "step1"}, }, Edges: []pipelines.GraphEdge{ - {Source: "lattice-0", Target: "electron-0"}, + {Source: "pipeline-0", Target: "task-0"}, }, } } @@ -71,10 +71,11 @@ func TestPrintGraphJSON(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(output), &parsed)) - lattice, ok := parsed["lattice"].(map[string]any) + // JSON tag remains `lattice` until the API renames the wire field. + pipeline, ok := parsed["lattice"].(map[string]any) require.True(t, ok) - assert.Equal(t, "lattice-0", lattice["id"]) - assert.Equal(t, "wf", lattice["name"]) + assert.Equal(t, "pipeline-0", pipeline["id"]) + assert.Equal(t, "wf", pipeline["name"]) } func TestPrintGraphHuman(t *testing.T) { @@ -82,16 +83,16 @@ func TestPrintGraphHuman(t *testing.T) { printGraphHuman(sampleGraph()) }) - assert.Contains(t, output, "Lattice: wf") - assert.Contains(t, output, "ID: lattice-0") + assert.Contains(t, output, "Pipeline: wf") + assert.Contains(t, output, "ID: pipeline-0") assert.Contains(t, output, "Nodes (2):") assert.Contains(t, output, "Edges (1):") - assert.Contains(t, output, "electron-0") + assert.Contains(t, output, "task-0") } func TestPrintGraphHuman_EmptyGraph(t *testing.T) { output := captureStdout(t, func() { - printGraphHuman(pipelines.Graph{Lattice: pipelines.GraphLattice{Name: "empty", ID: "x"}}) + printGraphHuman(pipelines.Graph{Pipeline: pipelines.GraphPipeline{Name: "empty", ID: "x"}}) }) assert.Contains(t, output, "No nodes") diff --git a/cmd/pipelines/input/inpututil/render.go b/cmd/pipelines/input/inpututil/render.go index b0369579f..84b8dbcc2 100644 --- a/cmd/pipelines/input/inpututil/render.go +++ b/cmd/pipelines/input/inpututil/render.go @@ -51,7 +51,7 @@ func PrintInputHuman(input pipelines.Input) { } fmt.Println(tui.BaseTextStyle.Render("Input ID: " + input.InputID)) - fmt.Println(tui.BaseTextStyle.Render("Pipeline: " + input.PipelineID)) + fmt.Println(tui.BaseTextStyle.Render("Pipeline ID: " + input.PipelineID)) fmt.Println(tui.BaseTextStyle.Render("Scope: " + scope)) fmt.Println(tui.BaseTextStyle.Render("Version: " + versionDisplay)) fmt.Println(tui.BaseTextStyle.Render("State: " + string(input.State))) diff --git a/cmd/pipelines/lock/cmd.go b/cmd/pipelines/lock/cmd.go index 8fc396e3a..d7c1e3fcd 100644 --- a/cmd/pipelines/lock/cmd.go +++ b/cmd/pipelines/lock/cmd.go @@ -79,16 +79,16 @@ func printLockJSON(result pipelines.CreateResponse) error { } func printLockHuman(result pipelines.CreateResponse) { - electrons := "\u2014" - if len(result.ElectronNames) > 0 { - electrons = strings.Join(result.ElectronNames, ", ") + tasks := "\u2014" + if len(result.TaskNames) > 0 { + tasks = strings.Join(result.TaskNames, ", ") } - fmt.Println(tui.BaseTextStyle.Render("Pipeline: " + result.PipelineID)) - fmt.Println(tui.BaseTextStyle.Render("Name: " + result.Name)) - fmt.Println(tui.BaseTextStyle.Render("Mode: " + result.Mode)) - fmt.Println(tui.BaseTextStyle.Render("Version: v" + strconv.Itoa(result.Version))) - fmt.Println(tui.BaseTextStyle.Render("Status: " + result.Status)) - fmt.Println(tui.BaseTextStyle.Render("Electrons: " + electrons)) - fmt.Println(tui.DimStyle.Render("Locked: " + result.CreatedAt.UTC().Format(time.RFC3339))) + fmt.Println(tui.BaseTextStyle.Render("Pipeline ID: " + result.PipelineID)) + fmt.Println(tui.BaseTextStyle.Render("Name: " + result.Name)) + fmt.Println(tui.BaseTextStyle.Render("Mode: " + result.Mode)) + fmt.Println(tui.BaseTextStyle.Render("Version: v" + strconv.Itoa(result.Version))) + fmt.Println(tui.BaseTextStyle.Render("Status: " + result.Status)) + fmt.Println(tui.BaseTextStyle.Render("Tasks: " + tasks)) + fmt.Println(tui.DimStyle.Render("Locked: " + result.CreatedAt.UTC().Format(time.RFC3339))) } diff --git a/cmd/pipelines/lock/cmd_test.go b/cmd/pipelines/lock/cmd_test.go index d7b1cdd47..6b2e0379e 100644 --- a/cmd/pipelines/lock/cmd_test.go +++ b/cmd/pipelines/lock/cmd_test.go @@ -49,13 +49,13 @@ func captureStdout(t *testing.T, fn func()) string { func sample() pipelines.CreateResponse { return pipelines.CreateResponse{ - PipelineID: "abc", - Name: "promo", - Version: 3, - Status: "READY", - Mode: "locked", - ElectronNames: []string{"e1", "e2"}, - CreatedAt: time.Date(2026, 4, 30, 10, 0, 0, 0, time.UTC), + PipelineID: "abc", + Name: "promo", + Version: 3, + Status: "READY", + Mode: "locked", + TaskNames: []string{"e1", "e2"}, + CreatedAt: time.Date(2026, 4, 30, 10, 0, 0, 0, time.UTC), } } @@ -77,21 +77,21 @@ func TestPrintLockHuman(t *testing.T) { printLockHuman(sample()) }) - assert.Contains(t, output, "Pipeline: abc") - assert.Contains(t, output, "Mode: locked") - assert.Contains(t, output, "Version: v3") - assert.Contains(t, output, "Electrons: e1, e2") + assert.Contains(t, output, "Pipeline ID: abc") + assert.Contains(t, output, "Mode: locked") + assert.Contains(t, output, "Version: v3") + assert.Contains(t, output, "Tasks: e1, e2") } -func TestPrintLockHuman_NoElectrons(t *testing.T) { +func TestPrintLockHuman_NoTasks(t *testing.T) { resp := sample() - resp.ElectronNames = nil + resp.TaskNames = nil output := captureStdout(t, func() { printLockHuman(resp) }) - assert.Contains(t, output, "Electrons: \u2014") + assert.Contains(t, output, "Tasks: \u2014") } func TestCmd_RejectsInvalidOutput(t *testing.T) { diff --git a/cmd/pipelines/schedule/scheduleutil/render.go b/cmd/pipelines/schedule/scheduleutil/render.go index f0bfec565..0dd02c374 100644 --- a/cmd/pipelines/schedule/scheduleutil/render.go +++ b/cmd/pipelines/schedule/scheduleutil/render.go @@ -44,7 +44,7 @@ func PrintScheduleJSON(s pipelines.Schedule) error { // PrintScheduleHuman renders a single schedule in human-friendly form. func PrintScheduleHuman(s pipelines.Schedule) { fmt.Println(tui.BaseTextStyle.Render("Schedule ID: " + s.ScheduleID)) - fmt.Println(tui.BaseTextStyle.Render("Pipeline: " + s.PipelineID)) + fmt.Println(tui.BaseTextStyle.Render("Pipeline ID: " + s.PipelineID)) fmt.Println(tui.BaseTextStyle.Render("Version: v" + strconv.Itoa(s.Version))) fmt.Println(tui.BaseTextStyle.Render("Cron: " + s.CronExpression)) fmt.Println(tui.BaseTextStyle.Render("Timezone: " + s.Timezone)) diff --git a/cmd/pipelines/update/cmd.go b/cmd/pipelines/update/cmd.go index eefc21f6f..e60a0f295 100644 --- a/cmd/pipelines/update/cmd.go +++ b/cmd/pipelines/update/cmd.go @@ -38,8 +38,9 @@ func Cmd() *cobra.Command { Short: "Re-upload a Python file to update a draft pipeline.", Long: `Update an existing draft pipeline by re-uploading a Python file. -A new version is appended to the pipeline. The lattice name in the uploaded -file must match the existing pipeline name. Locked pipelines cannot be updated. +A new version is appended to the pipeline. The pipeline name encoded in the +uploaded file must match the existing pipeline name. Locked pipelines cannot +be updated. The path to the Python file can be supplied either as a positional argument or via the --from-file= flag. Exactly one of the two must be provided. @@ -117,16 +118,16 @@ func printUpdateJSON(result pipelines.CreateResponse) error { } func printUpdateHuman(result pipelines.CreateResponse) { - electrons := "\u2014" - if len(result.ElectronNames) > 0 { - electrons = strings.Join(result.ElectronNames, ", ") + tasks := "\u2014" + if len(result.TaskNames) > 0 { + tasks = strings.Join(result.TaskNames, ", ") } - fmt.Println(tui.BaseTextStyle.Render("Pipeline: " + result.PipelineID)) - fmt.Println(tui.BaseTextStyle.Render("Name: " + result.Name)) - fmt.Println(tui.BaseTextStyle.Render(fmt.Sprintf("Version: %d", result.Version))) - fmt.Println(tui.BaseTextStyle.Render("Status: " + result.Status)) - fmt.Println(tui.BaseTextStyle.Render("Mode: " + result.Mode)) - fmt.Println(tui.BaseTextStyle.Render("Electrons: " + electrons)) - fmt.Println(tui.DimStyle.Render("Updated: " + result.CreatedAt.UTC().Format(time.RFC3339))) + fmt.Println(tui.BaseTextStyle.Render("Pipeline ID: " + result.PipelineID)) + fmt.Println(tui.BaseTextStyle.Render("Name: " + result.Name)) + fmt.Println(tui.BaseTextStyle.Render(fmt.Sprintf("Version: %d", result.Version))) + fmt.Println(tui.BaseTextStyle.Render("Status: " + result.Status)) + fmt.Println(tui.BaseTextStyle.Render("Mode: " + result.Mode)) + fmt.Println(tui.BaseTextStyle.Render("Tasks: " + tasks)) + fmt.Println(tui.DimStyle.Render("Updated: " + result.CreatedAt.UTC().Format(time.RFC3339))) } diff --git a/cmd/pipelines/update/cmd_test.go b/cmd/pipelines/update/cmd_test.go index cfb08f7b3..de4f9a7a1 100644 --- a/cmd/pipelines/update/cmd_test.go +++ b/cmd/pipelines/update/cmd_test.go @@ -49,13 +49,13 @@ func captureStdout(t *testing.T, fn func()) string { func sampleUpdateResponse() pipelines.CreateResponse { return pipelines.CreateResponse{ - PipelineID: "6658f441-a8f5-4f21-b4d8-6cccf4c94c5b", - Name: "confluence_to_vdb", - Version: 2, - Status: "READY", - Mode: "draft", - ElectronNames: []string{"create_vector_database"}, - CreatedAt: time.Date(2026, 4, 28, 12, 24, 54, 0, time.UTC), + PipelineID: "6658f441-a8f5-4f21-b4d8-6cccf4c94c5b", + Name: "confluence_to_vdb", + Version: 2, + Status: "READY", + Mode: "draft", + TaskNames: []string{"create_vector_database"}, + CreatedAt: time.Date(2026, 4, 28, 12, 24, 54, 0, time.UTC), } } @@ -76,30 +76,30 @@ func TestPrintUpdateJSON(t *testing.T) { assert.Equal(t, "READY", parsed["status"]) } -func TestPrintUpdateHuman_WithElectrons(t *testing.T) { +func TestPrintUpdateHuman_WithTasks(t *testing.T) { resp := sampleUpdateResponse() output := captureStdout(t, func() { printUpdateHuman(resp) }) - assert.Contains(t, output, "Pipeline: "+resp.PipelineID) - assert.Contains(t, output, "Name: confluence_to_vdb") - assert.Contains(t, output, "Version: 2") - assert.Contains(t, output, "Status: READY") - assert.Contains(t, output, "Mode: draft") + assert.Contains(t, output, "Pipeline ID: "+resp.PipelineID) + assert.Contains(t, output, "Name: confluence_to_vdb") + assert.Contains(t, output, "Version: 2") + assert.Contains(t, output, "Status: READY") + assert.Contains(t, output, "Mode: draft") assert.Contains(t, output, "create_vector_database") } -func TestPrintUpdateHuman_NoElectrons(t *testing.T) { +func TestPrintUpdateHuman_NoTasks(t *testing.T) { resp := sampleUpdateResponse() - resp.ElectronNames = nil + resp.TaskNames = nil output := captureStdout(t, func() { printUpdateHuman(resp) }) - assert.Contains(t, output, "Electrons: \u2014") + assert.Contains(t, output, "Tasks: \u2014") } func TestCmd_RequiresPipelineID(t *testing.T) { diff --git a/cmd/pipelines/version/get/cmd.go b/cmd/pipelines/version/get/cmd.go index e77aa6731..adf9f5834 100644 --- a/cmd/pipelines/version/get/cmd.go +++ b/cmd/pipelines/version/get/cmd.go @@ -37,7 +37,7 @@ func Cmd() *cobra.Command { cmd := &cobra.Command{ Use: "get ", Short: "Display details of a single pipeline version", - Long: `Display the lattice metadata for a single pipeline version. + Long: `Display the pipeline metadata for a single pipeline version. Example: dr pipelines version get --pipeline 2 diff --git a/cmd/pipelines/version/versionutil/render.go b/cmd/pipelines/version/versionutil/render.go index e2e493050..a3ccbf0d3 100644 --- a/cmd/pipelines/version/versionutil/render.go +++ b/cmd/pipelines/version/versionutil/render.go @@ -45,9 +45,9 @@ func PrintVersionJSON(v pipelines.PipelineVersion) error { // PrintVersionHuman renders the key facts about a single version. func PrintVersionHuman(v pipelines.PipelineVersion) { - electrons := "\u2014" - if len(v.ElectronNames) > 0 { - electrons = strings.Join(v.ElectronNames, ", ") + tasks := "\u2014" + if len(v.TaskNames) > 0 { + tasks = strings.Join(v.TaskNames, ", ") } python := v.PythonVersion @@ -56,10 +56,10 @@ func PrintVersionHuman(v pipelines.PipelineVersion) { } fmt.Println(tui.BaseTextStyle.Render("Version: v" + strconv.Itoa(v.Version))) - fmt.Println(tui.BaseTextStyle.Render("Lattice: " + v.LatticeName)) + fmt.Println(tui.BaseTextStyle.Render("Pipeline: " + v.PipelineName)) fmt.Println(tui.BaseTextStyle.Render("Status: " + v.Status)) fmt.Println(tui.BaseTextStyle.Render("Python Version: " + python)) - fmt.Println(tui.BaseTextStyle.Render("Electrons: " + electrons)) + fmt.Println(tui.BaseTextStyle.Render("Tasks: " + tasks)) if v.ErrorDetail != "" { fmt.Println(tui.BaseTextStyle.Render("Error: " + v.ErrorDetail)) @@ -90,12 +90,12 @@ func PrintVersionListHuman(items []pipelines.PipelineVersion) { writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(writer, "VERSION\tSTATUS\tPYTHON\tCREATED\tELECTRONS") + fmt.Fprintln(writer, "VERSION\tSTATUS\tPYTHON\tCREATED\tTASKS") for _, v := range items { - electrons := "\u2014" - if len(v.ElectronNames) > 0 { - electrons = strings.Join(v.ElectronNames, ", ") + tasks := "\u2014" + if len(v.TaskNames) > 0 { + tasks = strings.Join(v.TaskNames, ", ") } python := v.PythonVersion @@ -108,7 +108,7 @@ func PrintVersionListHuman(items []pipelines.PipelineVersion) { v.Status, python, v.CreatedAt.UTC().Format(time.RFC3339), - electrons, + tasks, ) } diff --git a/cmd/pipelines/version/versionutil/render_test.go b/cmd/pipelines/version/versionutil/render_test.go index 6e9711f03..0b0abc9ff 100644 --- a/cmd/pipelines/version/versionutil/render_test.go +++ b/cmd/pipelines/version/versionutil/render_test.go @@ -51,8 +51,8 @@ func sampleVersion() pipelines.PipelineVersion { return pipelines.PipelineVersion{ Version: 2, Status: "READY", - LatticeName: "wf", - ElectronNames: []string{"e1", "e2"}, + PipelineName: "wf", + TaskNames: []string{"e1", "e2"}, PythonVersion: "3.12", CreatedAt: time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC), } @@ -74,21 +74,21 @@ func TestPrintVersionJSON(t *testing.T) { func TestPrintVersionHuman_Full(t *testing.T) { output := captureStdout(t, func() { PrintVersionHuman(sampleVersion()) }) assert.Contains(t, output, "Version: v2") - assert.Contains(t, output, "Lattice: wf") + assert.Contains(t, output, "Pipeline: wf") assert.Contains(t, output, "Status: READY") assert.Contains(t, output, "Python Version: 3.12") - assert.Contains(t, output, "Electrons: e1, e2") + assert.Contains(t, output, "Tasks: e1, e2") assert.Contains(t, output, "Created: 2026-04-29T10:00:00Z") } func TestPrintVersionHuman_FillsDefaultsForMissing(t *testing.T) { v := sampleVersion() - v.ElectronNames = nil + v.TaskNames = nil v.PythonVersion = "" output := captureStdout(t, func() { PrintVersionHuman(v) }) assert.Contains(t, output, "Python Version: \u2014") - assert.Contains(t, output, "Electrons: \u2014") + assert.Contains(t, output, "Tasks: \u2014") } func TestPrintVersionHuman_ShowsErrorDetail(t *testing.T) { diff --git a/docs/commands/README.md b/docs/commands/README.md index 58e378205..5ccbaa72f 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -85,7 +85,7 @@ dr │ ├── version Inspect pipeline versions │ │ ├── list List versions of a pipeline │ │ └── get Display details of a single pipeline version -│ ├── graph Display the lattice/electron DAG of a pipeline +│ ├── graph Display the pipeline/task DAG of a pipeline │ ├── input Manage pipeline input payloads │ │ ├── create Register a JSON payload on a pipeline │ │ ├── list List inputs for a pipeline (draft or locked scope) @@ -280,7 +280,7 @@ For detailed documentation on each command, see: - `delete`—remove a pipeline and all of its versions. - `lock`—promote a draft pipeline to locked mode. - `version`—`list` / `get` to inspect pipeline versions. - - `graph`—display the lattice/electron DAG (draft or locked). + - `graph`—display the pipeline/task DAG (draft or locked). - `input`—`create`/`list`/`get`/`update`/`delete` JSON payloads used by dispatches. - `dispatch`—`create`/`list`/`get`/`status`/`cancel` pipeline executions. - `schedule`—`create`/`list`/`get`/`update`/`delete` recurring (cron) dispatches on locked versions. diff --git a/docs/commands/pipelines-reference.md b/docs/commands/pipelines-reference.md index 5ed776b3c..10ac1b9f9 100644 --- a/docs/commands/pipelines-reference.md +++ b/docs/commands/pipelines-reference.md @@ -25,7 +25,7 @@ accepts. | Command | API endpoint | Usage | Inputs | |---|---|---|---| -| `dr pipelines create` | `POST /pipelines` | `dr pipelines create ./my_pipeline.py`
`dr pipelines create --from-file=./my_pipeline.py`
`dr pipelines create ./my_pipeline.py --description "First draft" --mode draft`
`dr pipelines create --from-file=./my_pipeline.py --output json` | **Positional:** `` (Python file with a Covalent lattice; mutually exclusive with `--from-file`).
**Flags:** `--from-file=`, `--description `, `--mode draft\|locked`, `--output json`. | +| `dr pipelines create` | `POST /pipelines` | `dr pipelines create ./my_pipeline.py`
`dr pipelines create --from-file=./my_pipeline.py`
`dr pipelines create ./my_pipeline.py --description "First draft" --mode draft`
`dr pipelines create --from-file=./my_pipeline.py --output json` | **Positional:** `` (Python file defining a DataRobot pipeline; mutually exclusive with `--from-file`).
**Flags:** `--from-file=`, `--description `, `--mode draft\|locked`, `--output json`. | | `dr pipelines list` | `GET /pipelines` | `dr pipelines list`
`dr pipelines list --mode draft`
`dr pipelines list --offset 50 --limit 10 --output json` | **Flags:** `--mode draft\|locked`, `--offset `, `--limit `, `--output json`. | | `dr pipelines get` | `GET /pipelines/{pipeline_id}` | `dr pipelines get `
`dr pipelines get --output json` | **Positional:** `` (required).
**Flags:** `--output json`. | | `dr pipelines update` | `PATCH /pipelines/{pipeline_id}` | `dr pipelines update ./my_pipeline.py`
`dr pipelines update --from-file=./my_pipeline.py`
`dr pipelines update --from-file=./my_pipeline.py --output json` | **Positional:** `` (required), `` (mutually exclusive with `--from-file`).
**Flags:** `--from-file=`, `--output json`. | diff --git a/docs/commands/pipelines.md b/docs/commands/pipelines.md index 651c1ab4c..fe3524b12 100644 --- a/docs/commands/pipelines.md +++ b/docs/commands/pipelines.md @@ -11,7 +11,7 @@ endpoint. # List pipelines registered with the pipelines service dr pipelines list -# Register a new draft pipeline by uploading a Covalent lattice +# Register a new draft pipeline by uploading a DataRobot pipeline source file dr pipelines create ./my_pipeline.py --description "First draft" # Append a new version after editing the file @@ -43,7 +43,7 @@ dr pipelines [subcommand] [flags] ## Description -A **pipeline** is a versioned wrapper around a Covalent lattice. Each +A **pipeline** is a versioned bundle of Python source defining a DataRobot pipeline (one or more tasks). Each top-level `dr pipelines` subcommand operates on one of four resources: - the **pipeline** itself (create, list, get, update, delete, lock), @@ -56,10 +56,10 @@ top-level `dr pipelines` subcommand operates on one of four resources: Versions are created automatically: -- The first `create` call registers the lattice as **v1** in `draft` +- The first `create` call registers the source as **v1** in `draft` mode. - `update` re-uploads the same file (or an edited copy) and appends - **v2**, **v3**, etc., as long as the lattice name still matches and + **v2**, **v3**, etc., as long as the pipeline name still matches and the pipeline is still in `draft` mode. - `lock` promotes a draft to **locked** mode. Locked pipelines are immutable; their inputs and schedules become valid. @@ -83,7 +83,7 @@ to a frozen version) — selected via the shared `--scope` and | `dr pipelines delete` | `DELETE /api/v2/pipelines/{id}` | Remove a pipeline and all of its versions. | | `dr pipelines lock` | `PATCH /api/v2/pipelines/{id}/mode` | Promote a draft to locked mode. | | `dr pipelines version …` | `…/versions[/{ver}]` | Inspect pipeline versions. | -| `dr pipelines graph` | `…/graph` (draft) or `…/versions/{ver}/graph` | Render the lattice/electron DAG. | +| `dr pipelines graph` | `…/graph` (draft) or `…/versions/{ver}/graph` | Render the pipeline/task DAG. | | `dr pipelines input …` | `…/inputs` and `…/inputs/{input_id}` | Manage JSON payloads for dispatches. | | `dr pipelines dispatch …` | `…/dispatches` and `…/dispatches/{dispatch_id}` | Trigger, inspect, and cancel runs. | | `dr pipelines schedule …` | `…/versions/{ver}/schedules` | Manage recurring (cron) dispatches on locked versions. | @@ -93,8 +93,8 @@ to a frozen version) — selected via the shared `--scope` and ### `create` -Upload a Python file containing a Covalent lattice and register a new -pipeline. The lattice name is extracted from the file and used as the +Upload a Python file defining a DataRobot pipeline (one or more tasks) and +register a new pipeline. The pipeline name is extracted from the file and used as the pipeline name. ```bash @@ -104,7 +104,7 @@ dr pipelines create --from-file= [flags] **Arguments:** -- `` — path to a `.py` file containing a single Covalent lattice. +- `` — path to a `.py` file containing a single DataRobot pipeline. Mutually exclusive with `--from-file`. **Flags:** @@ -120,13 +120,13 @@ dr pipelines create --from-file= [flags] ```bash $ dr pipelines create ./confluence_to_vdb.py --description "test" -Pipeline: 6658f441-a8f5-4f21-b4d8-6cccf4c94c5b -Name: confluence_to_vdb -Version: 1 -Status: READY -Mode: draft -Electrons: create_vector_database, ingest_confluence_files, setup_credential_and_datastore -Created: 2026-04-28T11:42:28Z +Pipeline ID: 6658f441-a8f5-4f21-b4d8-6cccf4c94c5b +Name: confluence_to_vdb +Version: 1 +Status: READY +Mode: draft +Tasks: create_vector_database, ingest_confluence_files, setup_credential_and_datastore +Created: 2026-04-28T11:42:28Z ``` ### `list` @@ -158,7 +158,7 @@ ID NAME MODE ACTIVE VERSION | Column | Meaning | |-----------|-----------------------------------------------------------------| | `ID` | Pipeline UUID, used as the argument to `get` / `update` / etc. | -| `NAME` | Lattice name extracted from the originally uploaded file. | +| `NAME` | Pipeline name extracted from the originally uploaded file. | | `MODE` | `draft` (mutable) or `locked` (immutable). | | `ACTIVE` | `true` while the pipeline has not been soft-deleted. | | `VERSION` | Latest version number, or `—` when no versions exist yet. | @@ -193,7 +193,7 @@ Created: 2026-04-28T11:42:28Z Updated: 2026-04-28T12:25:11Z Versions (3): - VERSION STATUS PYTHON CREATED ELECTRONS + VERSION STATUS PYTHON CREATED TASKS v1 READY 3.12 2026-04-28T11:42:28Z create_vector_database, ingest_confluence_files, setup_credential_and_datastore v2 READY 3.12 2026-04-28T12:24:54Z create_vector_database, ingest_confluence_files, setup_credential_and_datastore v3 READY 3.12 2026-04-28T12:25:11Z create_vector_database, ingest_confluence_files, setup_credential_and_datastore @@ -229,8 +229,8 @@ dr pipelines update --from-file= [flags] **Constraints:** -- The lattice name in the uploaded file **must match** the pipeline's - existing name. To register a different lattice, use `create` instead. +- The pipeline name encoded in the uploaded file **must match** the pipeline's + existing name. To register a different pipeline, use `create` instead. - Locked pipelines cannot be updated. The API responds with `409 Conflict`. @@ -278,13 +278,13 @@ dr pipelines lock [flags] ```bash $ dr pipelines lock 6658f441-a8f5-4f21-b4d8-6cccf4c94c5b -Pipeline: 6658f441-a8f5-4f21-b4d8-6cccf4c94c5b -Name: confluence_to_vdb -Mode: locked -Version: v3 -Status: READY -Electrons: create_vector_database, ingest_confluence_files, setup_credential_and_datastore -Locked: 2026-04-28T12:30:00Z +Pipeline ID: 6658f441-a8f5-4f21-b4d8-6cccf4c94c5b +Name: confluence_to_vdb +Mode: locked +Version: v3 +Status: READY +Tasks: create_vector_database, ingest_confluence_files, setup_credential_and_datastore +Locked: 2026-04-28T12:30:00Z ``` ### `version` @@ -298,12 +298,12 @@ dr pipelines version get --pipeline [--output json] `version list` returns the same data that's shown inline by `pipelines get`, but in a paginated stand-alone view. `version get` -shows a single version in detail (lattice, electrons, Python version, +shows a single version in detail (pipeline, tasks, Python version, creation timestamp, and any error detail). ### `graph` -Display the lattice/electron DAG for a pipeline as either a JSON +Display the pipeline/task DAG for a pipeline as either a JSON payload (for visualisation tooling) or a human-readable summary. ```bash @@ -314,9 +314,10 @@ dr pipelines graph --pipeline --scope=locked --version=N dr pipelines graph --pipeline --output json # JSON payload ``` -The human view prints the lattice header followed by `Nodes (N):` and +The human view prints the pipeline header followed by `Nodes (N):` and `Edges (M):` tables. Pass `--output json` to get the structured `Graph` -object (`lattice`, `nodes[]`, `edges[]`) suitable for piping to +object (`lattice`, `nodes[]`, `edges[]` — JSON keys preserved while the API +wire format is unchanged) suitable for piping to visualisation tooling. See [Shared `--scope` / `--version` semantics](#scope--version-flags) @@ -560,7 +561,7 @@ common status codes you will see: | Status | Cause | |--------|------------------------------------------------------------------------------------| -| `400` | Invalid Python file, mismatched lattice name, or malformed JSON payload. | +| `400` | Invalid Python file, mismatched pipeline name, or malformed JSON payload. | | `404` | The provided `` / version / input / dispatch / schedule does not exist. | | `409` | Tried to update a `locked` pipeline, or cancel an already-terminal dispatch. | diff --git a/internal/pipelines/pipeline.go b/internal/pipelines/pipeline.go index 99de03014..dbfc19e08 100644 --- a/internal/pipelines/pipeline.go +++ b/internal/pipelines/pipeline.go @@ -42,11 +42,17 @@ const ( ) // PipelineVersion mirrors PipelineVersionResponse from the pipelines-api. +// +// JSON tags `lattice_name` and `electron_names` track the current API wire +// format. The DataRobot terminology has moved to `pipeline_name` / `task_names` +// — when the API updates the wire format, flip the json tags here. Until then +// the Go-level identifiers (PipelineName, TaskNames) use the new names so the +// rest of the codebase reads cleanly. type PipelineVersion struct { Version int `json:"version"` Status string `json:"status"` - LatticeName string `json:"lattice_name"` - ElectronNames []string `json:"electron_names,omitempty"` + PipelineName string `json:"lattice_name"` + TaskNames []string `json:"electron_names,omitempty"` PythonVersion string `json:"python_version"` ResourceBundle map[string]any `json:"resource_bundle,omitempty"` ErrorDetail string `json:"error_detail,omitempty"` @@ -67,14 +73,17 @@ type Pipeline struct { // CreateResponse mirrors PipelineCreateResponse from the pipelines-api. // It is also returned by PATCH /pipelines/{id}. +// +// See PipelineVersion for the rationale on the legacy `electron_names` +// JSON tag. type CreateResponse struct { - PipelineID string `json:"pipeline_id"` - Name string `json:"name"` - Version int `json:"version"` - Status string `json:"status"` - Mode string `json:"mode"` - ElectronNames []string `json:"electron_names,omitempty"` - CreatedAt time.Time `json:"created_at"` + PipelineID string `json:"pipeline_id"` + Name string `json:"name"` + Version int `json:"version"` + Status string `json:"status"` + Mode string `json:"mode"` + TaskNames []string `json:"electron_names,omitempty"` + CreatedAt time.Time `json:"created_at"` } // ListItem mirrors PipelineListItem from the pipelines-api. diff --git a/internal/pipelines/pipeline_lifecycle_test.go b/internal/pipelines/pipeline_lifecycle_test.go index e77da768b..b902ea89e 100644 --- a/internal/pipelines/pipeline_lifecycle_test.go +++ b/internal/pipelines/pipeline_lifecycle_test.go @@ -87,7 +87,7 @@ func TestLockPipeline_PromotesAndDecodes(t *testing.T) { require.NoError(t, err) assert.Equal(t, "locked", got.Mode) assert.Equal(t, 3, got.Version) - assert.Equal(t, []string{"e1"}, got.ElectronNames) + assert.Equal(t, []string{"e1"}, got.TaskNames) } func TestLockPipeline_409Conflict(t *testing.T) { diff --git a/internal/pipelines/version.go b/internal/pipelines/version.go index 2f410314d..41cd1e768 100644 --- a/internal/pipelines/version.go +++ b/internal/pipelines/version.go @@ -35,7 +35,9 @@ import ( ) // GraphNode mirrors a node entry in the JSON returned by the graph -// endpoint. type is "lattice" or "electron". +// endpoint. The wire-level type value is currently "lattice" or "electron"; +// in DataRobot terminology these correspond to "pipeline" and "task". The +// JSON tag is preserved as-is until the server renames the field. type GraphNode struct { ID string `json:"id"` Type string `json:"type"` @@ -49,8 +51,10 @@ type GraphEdge struct { Target string `json:"target"` } -// GraphLattice mirrors the "lattice" header entry of the graph payload. -type GraphLattice struct { +// GraphPipeline mirrors the pipeline (formerly "lattice") header entry of +// the graph payload. JSON tag remains `lattice` while the API wire format +// is unchanged. +type GraphPipeline struct { ID string `json:"id"` Name string `json:"name"` } @@ -58,9 +62,9 @@ type GraphLattice struct { // Graph mirrors the JSON returned by GET /pipelines/{id}/graph and // GET /pipelines/{id}/versions/{ver}/graph. type Graph struct { - Lattice GraphLattice `json:"lattice"` - Nodes []GraphNode `json:"nodes"` - Edges []GraphEdge `json:"edges"` + Pipeline GraphPipeline `json:"lattice"` + Nodes []GraphNode `json:"nodes"` + Edges []GraphEdge `json:"edges"` } // ListVersions fetches a paginated list of versions for a pipeline. diff --git a/internal/pipelines/version_test.go b/internal/pipelines/version_test.go index 75028600c..49b309fed 100644 --- a/internal/pipelines/version_test.go +++ b/internal/pipelines/version_test.go @@ -84,7 +84,7 @@ func TestGetGraph_DraftURL(t *testing.T) { got, err := GetGraph("p-1", ScopeDraft, nil) require.NoError(t, err) - assert.Equal(t, "wf", got.Lattice.Name) + assert.Equal(t, "wf", got.Pipeline.Name) require.Len(t, got.Nodes, 1) assert.Equal(t, "lattice", got.Nodes[0].Type) } From 0ac675c7b8588f3477b051f9972f4bfe5053e3d7 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Wed, 6 May 2026 16:19:43 -0400 Subject: [PATCH 11/25] update dispatch to run --- cmd/pipelines/cmd.go | 6 +- cmd/pipelines/dispatch/dispatchutil/render.go | 139 --------------- cmd/pipelines/lock/cmd.go | 2 +- cmd/pipelines/{dispatch => run}/cancel/cmd.go | 16 +- .../{dispatch => run}/cancel/cmd_test.go | 0 cmd/pipelines/{dispatch => run}/cmd.go | 26 +-- cmd/pipelines/{dispatch => run}/cmd_test.go | 2 +- cmd/pipelines/{dispatch => run}/create/cmd.go | 22 +-- .../{dispatch => run}/create/cmd_test.go | 0 cmd/pipelines/{dispatch => run}/get/cmd.go | 22 +-- .../{dispatch => run}/get/cmd_test.go | 0 cmd/pipelines/{dispatch => run}/list/cmd.go | 18 +- .../{dispatch => run}/list/cmd_test.go | 0 cmd/pipelines/run/runutil/render.go | 139 +++++++++++++++ .../runutil}/render_test.go | 90 +++++----- cmd/pipelines/{dispatch => run}/status/cmd.go | 22 +-- .../{dispatch => run}/status/cmd_test.go | 0 cmd/pipelines/schedule/cmd.go | 2 +- cmd/pipelines/schedule/create/cmd.go | 4 +- cmd/pipelines/scopeflag/scopeflag.go | 2 +- docs/commands/README.md | 20 +-- docs/commands/pipelines-reference.md | 38 ++-- docs/commands/pipelines.md | 58 +++--- internal/pipelines/dispatch.go | 158 ---------------- internal/pipelines/run.go | 168 ++++++++++++++++++ .../{dispatch_test.go => run_test.go} | 40 ++--- internal/pipelines/schedule.go | 2 +- internal/pipelines/scope.go | 2 +- internal/pipelines/transport.go | 2 +- 29 files changed, 506 insertions(+), 494 deletions(-) delete mode 100644 cmd/pipelines/dispatch/dispatchutil/render.go rename cmd/pipelines/{dispatch => run}/cancel/cmd.go (74%) rename cmd/pipelines/{dispatch => run}/cancel/cmd_test.go (100%) rename cmd/pipelines/{dispatch => run}/cmd.go (58%) rename cmd/pipelines/{dispatch => run}/cmd_test.go (98%) rename cmd/pipelines/{dispatch => run}/create/cmd.go (70%) rename cmd/pipelines/{dispatch => run}/create/cmd_test.go (100%) rename cmd/pipelines/{dispatch => run}/get/cmd.go (74%) rename cmd/pipelines/{dispatch => run}/get/cmd_test.go (100%) rename cmd/pipelines/{dispatch => run}/list/cmd.go (76%) rename cmd/pipelines/{dispatch => run}/list/cmd_test.go (100%) create mode 100644 cmd/pipelines/run/runutil/render.go rename cmd/pipelines/{dispatch/dispatchutil => run/runutil}/render_test.go (51%) rename cmd/pipelines/{dispatch => run}/status/cmd.go (72%) rename cmd/pipelines/{dispatch => run}/status/cmd_test.go (100%) delete mode 100644 internal/pipelines/dispatch.go create mode 100644 internal/pipelines/run.go rename internal/pipelines/{dispatch_test.go => run_test.go} (79%) diff --git a/cmd/pipelines/cmd.go b/cmd/pipelines/cmd.go index 5fb2895f0..d7246ba83 100644 --- a/cmd/pipelines/cmd.go +++ b/cmd/pipelines/cmd.go @@ -17,13 +17,13 @@ package pipelines import ( "github.com/datarobot/cli/cmd/pipelines/create" "github.com/datarobot/cli/cmd/pipelines/del" - "github.com/datarobot/cli/cmd/pipelines/dispatch" "github.com/datarobot/cli/cmd/pipelines/environment" "github.com/datarobot/cli/cmd/pipelines/get" "github.com/datarobot/cli/cmd/pipelines/graph" "github.com/datarobot/cli/cmd/pipelines/input" "github.com/datarobot/cli/cmd/pipelines/list" "github.com/datarobot/cli/cmd/pipelines/lock" + "github.com/datarobot/cli/cmd/pipelines/run" "github.com/datarobot/cli/cmd/pipelines/schedule" "github.com/datarobot/cli/cmd/pipelines/update" "github.com/datarobot/cli/cmd/pipelines/version" @@ -40,7 +40,7 @@ func Cmd() *cobra.Command { Create, list, inspect, and update pipelines registered with the DataRobot pipelines service. Sub-commands are also available for managing -input payloads, dispatches, and recurring schedules.`, +input payloads, runs, and recurring schedules.`, } features.SetGate(cmd, "pipelines") @@ -55,7 +55,7 @@ input payloads, dispatches, and recurring schedules.`, version.Cmd(), graph.Cmd(), input.Cmd(), - dispatch.Cmd(), + run.Cmd(), schedule.Cmd(), environment.Cmd(), ) diff --git a/cmd/pipelines/dispatch/dispatchutil/render.go b/cmd/pipelines/dispatch/dispatchutil/render.go deleted file mode 100644 index 254fafe19..000000000 --- a/cmd/pipelines/dispatch/dispatchutil/render.go +++ /dev/null @@ -1,139 +0,0 @@ -// Copyright 2026 DataRobot, Inc. and its affiliates. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Package dispatchutil holds the rendering helpers shared by the -// `dr pipelines dispatch` verbs. Living in a sibling package keeps the -// parent `dispatch` package free of cycles. - -package dispatchutil - -import ( - "encoding/json" - "fmt" - "os" - "strconv" - "text/tabwriter" - - "github.com/datarobot/cli/internal/pipelines" - "github.com/datarobot/cli/tui" -) - -// PrintDispatchJSON marshals a dispatch as indented JSON. -func PrintDispatchJSON(d pipelines.Dispatch) error { - data, err := json.MarshalIndent(d, "", " ") - if err != nil { - return err - } - - fmt.Println(string(data)) - - return nil -} - -// PrintDispatchHuman renders a single dispatch in a human-friendly form. -func PrintDispatchHuman(d pipelines.Dispatch) { - scope := "draft" - versionDisplay := "\u2014" - - if d.VersionID != nil { - scope = "locked" - versionDisplay = "v" + strconv.Itoa(*d.VersionID) - } - - covalent := d.CovalentDispatchID - if covalent == "" { - covalent = "\u2014" - } - - fmt.Println(tui.BaseTextStyle.Render("Dispatch ID: " + d.DispatchID)) - fmt.Println(tui.BaseTextStyle.Render("Pipeline ID: " + d.PipelineID)) - fmt.Println(tui.BaseTextStyle.Render("Scope: " + scope)) - fmt.Println(tui.BaseTextStyle.Render("Version: " + versionDisplay)) - fmt.Println(tui.BaseTextStyle.Render("Input ID: " + d.InputID)) - fmt.Println(tui.BaseTextStyle.Render("Status: " + d.Status)) - fmt.Println(tui.BaseTextStyle.Render("Triggered By: " + d.TriggeredBy)) - fmt.Println(tui.BaseTextStyle.Render("Covalent Dispatch: " + covalent)) - - if d.ErrorDetail != "" { - fmt.Println(tui.BaseTextStyle.Render("Error: " + d.ErrorDetail)) - } - - fmt.Println(tui.DimStyle.Render("Created: " + d.CreatedAt)) - fmt.Println(tui.DimStyle.Render("Updated: " + d.UpdatedAt)) -} - -// PrintDispatchListJSON marshals a list of dispatches as indented JSON. -func PrintDispatchListJSON(items []pipelines.Dispatch) error { - data, err := json.MarshalIndent(items, "", " ") - if err != nil { - return err - } - - fmt.Println(string(data)) - - return nil -} - -// PrintDispatchListHuman renders a tabular summary of dispatches. -func PrintDispatchListHuman(items []pipelines.Dispatch) { - if len(items) == 0 { - fmt.Println(tui.DimStyle.Render("No dispatches found")) - - return - } - - writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - - fmt.Fprintln(writer, "DISPATCH_ID\tSCOPE\tVERSION\tSTATUS\tTRIGGER\tUPDATED") - - for _, d := range items { - scope := "draft" - ver := "\u2014" - - if d.VersionID != nil { - scope = "locked" - ver = "v" + strconv.Itoa(*d.VersionID) - } - - fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\t%s\n", - d.DispatchID, scope, ver, d.Status, d.TriggeredBy, d.UpdatedAt, - ) - } - - _ = writer.Flush() -} - -// PrintStatusJSON marshals a lightweight status response as indented JSON. -func PrintStatusJSON(s pipelines.DispatchStatus) error { - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return err - } - - fmt.Println(string(data)) - - return nil -} - -// PrintStatusHuman renders a lightweight status response. -func PrintStatusHuman(s pipelines.DispatchStatus) { - covalent := s.CovalentDispatchID - if covalent == "" { - covalent = "\u2014" - } - - fmt.Println(tui.BaseTextStyle.Render("Dispatch ID: " + s.DispatchID)) - fmt.Println(tui.BaseTextStyle.Render("Status: " + s.Status)) - fmt.Println(tui.BaseTextStyle.Render("Covalent Dispatch: " + covalent)) -} diff --git a/cmd/pipelines/lock/cmd.go b/cmd/pipelines/lock/cmd.go index d7c1e3fcd..86ebaaa57 100644 --- a/cmd/pipelines/lock/cmd.go +++ b/cmd/pipelines/lock/cmd.go @@ -34,7 +34,7 @@ func Cmd() *cobra.Command { Use: "lock ", Short: "Lock a draft pipeline", Long: `Promote a draft pipeline to locked mode. Once locked, the pipeline can -no longer be updated and locked dispatches/inputs/schedules become valid. +no longer be updated and locked runs/inputs/schedules become valid. Example: dr pipelines lock 8a8d6e5e-1234-5678-90ab-cdef01234567 diff --git a/cmd/pipelines/dispatch/cancel/cmd.go b/cmd/pipelines/run/cancel/cmd.go similarity index 74% rename from cmd/pipelines/dispatch/cancel/cmd.go rename to cmd/pipelines/run/cancel/cmd.go index e24d066b3..59f18e001 100644 --- a/cmd/pipelines/dispatch/cancel/cmd.go +++ b/cmd/pipelines/run/cancel/cmd.go @@ -29,16 +29,16 @@ func Cmd() *cobra.Command { var flags scopeflag.Flags cmd := &cobra.Command{ - Use: "cancel ", - Short: "Cancel a pipeline dispatch", - Long: `Request cancellation of an in-flight dispatch. + Use: "cancel ", + Short: "Cancel a pipeline run", + Long: `Request cancellation of an in-flight run. -The API rejects cancellation if the dispatch has already reached a terminal +The API rejects cancellation if the run has already reached a terminal state (COMPLETED, FAILED, CANCELLED). Example: - dr pipelines dispatch cancel --pipeline - dr pipelines dispatch cancel --pipeline --version=2 `, + dr pipelines run cancel --pipeline + dr pipelines run cancel --pipeline --version=2 `, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, @@ -52,12 +52,12 @@ Example: return err } - err = pipelines.CancelDispatch(flags.PipelineID, scope, version, args[0]) + err = pipelines.CancelRun(flags.PipelineID, scope, version, args[0]) if err != nil { return err } - fmt.Println(tui.BaseTextStyle.Render("Cancelled dispatch: " + args[0])) + fmt.Println(tui.BaseTextStyle.Render("Cancelled run: " + args[0])) return nil }, diff --git a/cmd/pipelines/dispatch/cancel/cmd_test.go b/cmd/pipelines/run/cancel/cmd_test.go similarity index 100% rename from cmd/pipelines/dispatch/cancel/cmd_test.go rename to cmd/pipelines/run/cancel/cmd_test.go diff --git a/cmd/pipelines/dispatch/cmd.go b/cmd/pipelines/run/cmd.go similarity index 58% rename from cmd/pipelines/dispatch/cmd.go rename to cmd/pipelines/run/cmd.go index 01335bd52..313901dad 100644 --- a/cmd/pipelines/dispatch/cmd.go +++ b/cmd/pipelines/run/cmd.go @@ -12,27 +12,27 @@ // See the License for the specific language governing permissions and // limitations under the License. -package dispatch +package run import ( - "github.com/datarobot/cli/cmd/pipelines/dispatch/cancel" - "github.com/datarobot/cli/cmd/pipelines/dispatch/create" - "github.com/datarobot/cli/cmd/pipelines/dispatch/get" - "github.com/datarobot/cli/cmd/pipelines/dispatch/list" - "github.com/datarobot/cli/cmd/pipelines/dispatch/status" + "github.com/datarobot/cli/cmd/pipelines/run/cancel" + "github.com/datarobot/cli/cmd/pipelines/run/create" + "github.com/datarobot/cli/cmd/pipelines/run/get" + "github.com/datarobot/cli/cmd/pipelines/run/list" + "github.com/datarobot/cli/cmd/pipelines/run/status" "github.com/spf13/cobra" ) -// Cmd returns the parent command for `dr pipelines dispatch`. +// Cmd returns the parent command for `dr pipelines run`. func Cmd() *cobra.Command { cmd := &cobra.Command{ - Use: "dispatch", - Short: "Manage pipeline dispatches", - Long: `Trigger and inspect dispatches (single executions) of a pipeline. + Use: "run", + Short: "Manage pipeline runs", + Long: `Trigger and inspect runs (single executions) of a pipeline. -Dispatches come in two scopes: - - draft : runs against the in-flight draft of a pipeline - - locked : runs against a specific frozen version +Runs come in two scopes: + - draft : executes against the in-flight draft of a pipeline + - locked : executes against a specific frozen version When --version is supplied, the locked scope is selected automatically.`, } diff --git a/cmd/pipelines/dispatch/cmd_test.go b/cmd/pipelines/run/cmd_test.go similarity index 98% rename from cmd/pipelines/dispatch/cmd_test.go rename to cmd/pipelines/run/cmd_test.go index eb599fc79..ce2c30cff 100644 --- a/cmd/pipelines/dispatch/cmd_test.go +++ b/cmd/pipelines/run/cmd_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package dispatch +package run import ( "testing" diff --git a/cmd/pipelines/dispatch/create/cmd.go b/cmd/pipelines/run/create/cmd.go similarity index 70% rename from cmd/pipelines/dispatch/create/cmd.go rename to cmd/pipelines/run/create/cmd.go index 4340e251f..6705128bb 100644 --- a/cmd/pipelines/dispatch/create/cmd.go +++ b/cmd/pipelines/run/create/cmd.go @@ -18,7 +18,7 @@ import ( "errors" "fmt" - "github.com/datarobot/cli/cmd/pipelines/dispatch/dispatchutil" + "github.com/datarobot/cli/cmd/pipelines/run/runutil" "github.com/datarobot/cli/cmd/pipelines/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" @@ -34,15 +34,15 @@ func Cmd() *cobra.Command { cmd := &cobra.Command{ Use: "create", - Short: "Trigger a pipeline dispatch", - Long: `Trigger a new dispatch (single execution) of a pipeline. + Short: "Trigger a pipeline run", + Long: `Trigger a new run (single execution) of a pipeline. -The dispatch is created in PENDING state. Use ` + "`dr pipelines dispatch get`" + ` -or ` + "`dr pipelines dispatch status`" + ` to follow its progress. +The run is created in PENDING state. Use ` + "`dr pipelines run get`" + ` +or ` + "`dr pipelines run status`" + ` to follow its progress. Example: - dr pipelines dispatch create --pipeline --input - dr pipelines dispatch create --pipeline --version=2 --input --output json`, + dr pipelines run create --pipeline --input + dr pipelines run create --pipeline --version=2 --input --output json`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, @@ -64,23 +64,23 @@ Example: return err } - result, err := pipelines.CreateDispatch(flags.PipelineID, scope, version, inputID) + result, err := pipelines.CreateRun(flags.PipelineID, scope, version, inputID) if err != nil { return err } if outputFormat == "json" { - return dispatchutil.PrintDispatchJSON(*result) + return runutil.PrintRunJSON(*result) } - dispatchutil.PrintDispatchHuman(*result) + runutil.PrintRunHuman(*result) return nil }, } flags.Bind(cmd) - cmd.Flags().StringVar(&inputID, "input", "", "Input ID to dispatch") + cmd.Flags().StringVar(&inputID, "input", "", "Input ID to trigger the run with") cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") return cmd diff --git a/cmd/pipelines/dispatch/create/cmd_test.go b/cmd/pipelines/run/create/cmd_test.go similarity index 100% rename from cmd/pipelines/dispatch/create/cmd_test.go rename to cmd/pipelines/run/create/cmd_test.go diff --git a/cmd/pipelines/dispatch/get/cmd.go b/cmd/pipelines/run/get/cmd.go similarity index 74% rename from cmd/pipelines/dispatch/get/cmd.go rename to cmd/pipelines/run/get/cmd.go index 51a617b40..fc40b17c4 100644 --- a/cmd/pipelines/dispatch/get/cmd.go +++ b/cmd/pipelines/run/get/cmd.go @@ -19,7 +19,7 @@ import ( "fmt" "net/http" - "github.com/datarobot/cli/cmd/pipelines/dispatch/dispatchutil" + "github.com/datarobot/cli/cmd/pipelines/run/runutil" "github.com/datarobot/cli/cmd/pipelines/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" @@ -35,13 +35,13 @@ func Cmd() *cobra.Command { ) cmd := &cobra.Command{ - Use: "get ", - Short: "Display details of a pipeline dispatch", - Long: `Display the full record for a single dispatch. + Use: "get ", + Short: "Display details of a pipeline run", + Long: `Display the full record for a single run. Example: - dr pipelines dispatch get --pipeline - dr pipelines dispatch get --pipeline --version=2 --output json`, + dr pipelines run get --pipeline + dr pipelines run get --pipeline --version=2 --output json`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, @@ -59,16 +59,16 @@ Example: return err } - result, err := pipelines.GetDispatch(flags.PipelineID, scope, version, args[0]) + result, err := pipelines.GetRun(flags.PipelineID, scope, version, args[0]) if err != nil { return handleGetError(err, args[0]) } if outputFormat == "json" { - return dispatchutil.PrintDispatchJSON(*result) + return runutil.PrintRunJSON(*result) } - dispatchutil.PrintDispatchHuman(*result) + runutil.PrintRunHuman(*result) return nil }, @@ -80,11 +80,11 @@ Example: return cmd } -func handleGetError(err error, dispatchID string) error { +func handleGetError(err error, runID string) error { var httpErr *drapi.HTTPError if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { - fmt.Println(tui.DimStyle.Render("No dispatch found with id: " + dispatchID)) + fmt.Println(tui.DimStyle.Render("No run found with id: " + runID)) return nil } diff --git a/cmd/pipelines/dispatch/get/cmd_test.go b/cmd/pipelines/run/get/cmd_test.go similarity index 100% rename from cmd/pipelines/dispatch/get/cmd_test.go rename to cmd/pipelines/run/get/cmd_test.go diff --git a/cmd/pipelines/dispatch/list/cmd.go b/cmd/pipelines/run/list/cmd.go similarity index 76% rename from cmd/pipelines/dispatch/list/cmd.go rename to cmd/pipelines/run/list/cmd.go index 2b976024e..c1f699619 100644 --- a/cmd/pipelines/dispatch/list/cmd.go +++ b/cmd/pipelines/run/list/cmd.go @@ -18,7 +18,7 @@ import ( "errors" "fmt" - "github.com/datarobot/cli/cmd/pipelines/dispatch/dispatchutil" + "github.com/datarobot/cli/cmd/pipelines/run/runutil" "github.com/datarobot/cli/cmd/pipelines/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" @@ -35,12 +35,12 @@ func Cmd() *cobra.Command { cmd := &cobra.Command{ Use: "list", - Short: "List pipeline dispatches", - Long: `List dispatches for a pipeline. + Short: "List pipeline runs", + Long: `List runs for a pipeline. Example: - dr pipelines dispatch list --pipeline - dr pipelines dispatch list --pipeline --version=2 --output json`, + dr pipelines run list --pipeline + dr pipelines run list --pipeline --version=2 --output json`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, @@ -58,16 +58,16 @@ Example: return err } - items, err := pipelines.ListDispatches(flags.PipelineID, scope, version, offset, limit) + items, err := pipelines.ListRuns(flags.PipelineID, scope, version, offset, limit) if err != nil { return err } if outputFormat == "json" { - return dispatchutil.PrintDispatchListJSON(items) + return runutil.PrintRunListJSON(items) } - dispatchutil.PrintDispatchListHuman(items) + runutil.PrintRunListHuman(items) return nil }, @@ -75,7 +75,7 @@ Example: flags.Bind(cmd) cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") - cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of dispatches to return") + cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of runs to return") cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") return cmd diff --git a/cmd/pipelines/dispatch/list/cmd_test.go b/cmd/pipelines/run/list/cmd_test.go similarity index 100% rename from cmd/pipelines/dispatch/list/cmd_test.go rename to cmd/pipelines/run/list/cmd_test.go diff --git a/cmd/pipelines/run/runutil/render.go b/cmd/pipelines/run/runutil/render.go new file mode 100644 index 000000000..ab0114ac9 --- /dev/null +++ b/cmd/pipelines/run/runutil/render.go @@ -0,0 +1,139 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package runutil holds the rendering helpers shared by the +// `dr pipelines run` verbs. Living in a sibling package keeps the +// parent `run` package free of cycles. + +package runutil + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "text/tabwriter" + + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" +) + +// PrintRunJSON marshals a run as indented JSON. +func PrintRunJSON(r pipelines.Run) error { + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintRunHuman renders a single run in a human-friendly form. +func PrintRunHuman(r pipelines.Run) { + scope := "draft" + versionDisplay := "\u2014" + + if r.VersionID != nil { + scope = "locked" + versionDisplay = "v" + strconv.Itoa(*r.VersionID) + } + + covalent := r.CovalentDispatchID + if covalent == "" { + covalent = "\u2014" + } + + fmt.Println(tui.BaseTextStyle.Render("Run ID: " + r.RunID)) + fmt.Println(tui.BaseTextStyle.Render("Pipeline ID: " + r.PipelineID)) + fmt.Println(tui.BaseTextStyle.Render("Scope: " + scope)) + fmt.Println(tui.BaseTextStyle.Render("Version: " + versionDisplay)) + fmt.Println(tui.BaseTextStyle.Render("Input ID: " + r.InputID)) + fmt.Println(tui.BaseTextStyle.Render("Status: " + r.Status)) + fmt.Println(tui.BaseTextStyle.Render("Triggered By: " + r.TriggeredBy)) + fmt.Println(tui.BaseTextStyle.Render("Covalent Run: " + covalent)) + + if r.ErrorDetail != "" { + fmt.Println(tui.BaseTextStyle.Render("Error: " + r.ErrorDetail)) + } + + fmt.Println(tui.DimStyle.Render("Created: " + r.CreatedAt)) + fmt.Println(tui.DimStyle.Render("Updated: " + r.UpdatedAt)) +} + +// PrintRunListJSON marshals a list of runs as indented JSON. +func PrintRunListJSON(items []pipelines.Run) error { + data, err := json.MarshalIndent(items, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintRunListHuman renders a tabular summary of runs. +func PrintRunListHuman(items []pipelines.Run) { + if len(items) == 0 { + fmt.Println(tui.DimStyle.Render("No runs found")) + + return + } + + writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintln(writer, "RUN_ID\tSCOPE\tVERSION\tSTATUS\tTRIGGER\tUPDATED") + + for _, r := range items { + scope := "draft" + ver := "\u2014" + + if r.VersionID != nil { + scope = "locked" + ver = "v" + strconv.Itoa(*r.VersionID) + } + + fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\t%s\n", + r.RunID, scope, ver, r.Status, r.TriggeredBy, r.UpdatedAt, + ) + } + + _ = writer.Flush() +} + +// PrintStatusJSON marshals a lightweight status response as indented JSON. +func PrintStatusJSON(s pipelines.RunStatus) error { + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintStatusHuman renders a lightweight status response. +func PrintStatusHuman(s pipelines.RunStatus) { + covalent := s.CovalentDispatchID + if covalent == "" { + covalent = "\u2014" + } + + fmt.Println(tui.BaseTextStyle.Render("Run ID: " + s.RunID)) + fmt.Println(tui.BaseTextStyle.Render("Status: " + s.Status)) + fmt.Println(tui.BaseTextStyle.Render("Covalent Run: " + covalent)) +} diff --git a/cmd/pipelines/dispatch/dispatchutil/render_test.go b/cmd/pipelines/run/runutil/render_test.go similarity index 51% rename from cmd/pipelines/dispatch/dispatchutil/render_test.go rename to cmd/pipelines/run/runutil/render_test.go index 697f5f6fe..5e80376b6 100644 --- a/cmd/pipelines/dispatch/dispatchutil/render_test.go +++ b/cmd/pipelines/run/runutil/render_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package dispatchutil +package runutil import ( "bytes" @@ -48,30 +48,30 @@ func captureStdout(t *testing.T, fn func()) string { func intPtr(v int) *int { return &v } -func sampleDraftDispatch() pipelines.Dispatch { - return pipelines.Dispatch{ - DispatchID: "d-1", +func sampleDraftRun() pipelines.Run { + return pipelines.Run{ + RunID: "d-1", PipelineID: "pl-1", InputID: "in-1", TriggeredBy: "user@example.com", - Status: pipelines.DispatchStatusPending, + Status: pipelines.RunStatusPending, CreatedAt: "2026-04-29T10:00:00Z", UpdatedAt: "2026-04-29T10:00:00Z", } } -func sampleLockedDispatch() pipelines.Dispatch { - d := sampleDraftDispatch() - d.VersionID = intPtr(3) - d.CovalentDispatchID = "cov-xyz" - d.Status = pipelines.DispatchStatusRunning +func sampleLockedRun() pipelines.Run { + r := sampleDraftRun() + r.VersionID = intPtr(3) + r.CovalentDispatchID = "cov-xyz" + r.Status = pipelines.RunStatusRunning - return d + return r } -func TestPrintDispatchJSON(t *testing.T) { +func TestPrintRunJSON(t *testing.T) { output := captureStdout(t, func() { - require.NoError(t, PrintDispatchJSON(sampleDraftDispatch())) + require.NoError(t, PrintRunJSON(sampleDraftRun())) }) var parsed map[string]any @@ -81,30 +81,30 @@ func TestPrintDispatchJSON(t *testing.T) { assert.Equal(t, "PENDING", parsed["status"]) } -func TestPrintDispatchHuman_DraftMissingCovalent(t *testing.T) { - output := captureStdout(t, func() { PrintDispatchHuman(sampleDraftDispatch()) }) - assert.Contains(t, output, "Dispatch ID: d-1") - assert.Contains(t, output, "Scope: draft") - assert.Contains(t, output, "Version: \u2014") - assert.Contains(t, output, "Covalent Dispatch: \u2014") - assert.Contains(t, output, "Status: PENDING") +func TestPrintRunHuman_DraftMissingCovalent(t *testing.T) { + output := captureStdout(t, func() { PrintRunHuman(sampleDraftRun()) }) + assert.Contains(t, output, "Run ID: d-1") + assert.Contains(t, output, "Scope: draft") + assert.Contains(t, output, "Version: \u2014") + assert.Contains(t, output, "Covalent Run: \u2014") + assert.Contains(t, output, "Status: PENDING") } -func TestPrintDispatchHuman_LockedShowsErrorWhenSet(t *testing.T) { - d := sampleLockedDispatch() - d.Status = pipelines.DispatchStatusFailed - d.ErrorDetail = "boom" +func TestPrintRunHuman_LockedShowsErrorWhenSet(t *testing.T) { + r := sampleLockedRun() + r.Status = pipelines.RunStatusFailed + r.ErrorDetail = "boom" - output := captureStdout(t, func() { PrintDispatchHuman(d) }) - assert.Contains(t, output, "Scope: locked") - assert.Contains(t, output, "Version: v3") - assert.Contains(t, output, "Covalent Dispatch: cov-xyz") - assert.Contains(t, output, "Error: boom") + output := captureStdout(t, func() { PrintRunHuman(r) }) + assert.Contains(t, output, "Scope: locked") + assert.Contains(t, output, "Version: v3") + assert.Contains(t, output, "Covalent Run: cov-xyz") + assert.Contains(t, output, "Error: boom") } -func TestPrintDispatchListJSON(t *testing.T) { +func TestPrintRunListJSON(t *testing.T) { output := captureStdout(t, func() { - require.NoError(t, PrintDispatchListJSON([]pipelines.Dispatch{sampleDraftDispatch()})) + require.NoError(t, PrintRunListJSON([]pipelines.Run{sampleDraftRun()})) }) var parsed []map[string]any @@ -114,17 +114,17 @@ func TestPrintDispatchListJSON(t *testing.T) { assert.Equal(t, "d-1", parsed[0]["dispatch_id"]) } -func TestPrintDispatchListHuman_Empty(t *testing.T) { - output := captureStdout(t, func() { PrintDispatchListHuman(nil) }) - assert.Contains(t, output, "No dispatches found") +func TestPrintRunListHuman_Empty(t *testing.T) { + output := captureStdout(t, func() { PrintRunListHuman(nil) }) + assert.Contains(t, output, "No runs found") } -func TestPrintDispatchListHuman_RendersTable(t *testing.T) { +func TestPrintRunListHuman_RendersTable(t *testing.T) { output := captureStdout(t, func() { - PrintDispatchListHuman([]pipelines.Dispatch{sampleDraftDispatch(), sampleLockedDispatch()}) + PrintRunListHuman([]pipelines.Run{sampleDraftRun(), sampleLockedRun()}) }) - assert.Contains(t, output, "DISPATCH_ID") + assert.Contains(t, output, "RUN_ID") assert.Contains(t, output, "STATUS") assert.Contains(t, output, "TRIGGER") assert.Contains(t, output, "draft") @@ -135,9 +135,9 @@ func TestPrintDispatchListHuman_RendersTable(t *testing.T) { } func TestPrintStatusJSON(t *testing.T) { - status := pipelines.DispatchStatus{ - DispatchID: "d-1", - Status: pipelines.DispatchStatusCompleted, + status := pipelines.RunStatus{ + RunID: "d-1", + Status: pipelines.RunStatusCompleted, CovalentDispatchID: "cov-xyz", } @@ -152,12 +152,12 @@ func TestPrintStatusJSON(t *testing.T) { assert.Equal(t, "cov-xyz", parsed["covalent_dispatch_id"]) } -func TestPrintStatusHuman_NoCovalentDispatchID(t *testing.T) { +func TestPrintStatusHuman_NoCovalentRunID(t *testing.T) { output := captureStdout(t, func() { - PrintStatusHuman(pipelines.DispatchStatus{DispatchID: "d-1", Status: "PENDING"}) + PrintStatusHuman(pipelines.RunStatus{RunID: "d-1", Status: "PENDING"}) }) - assert.Contains(t, output, "Dispatch ID: d-1") - assert.Contains(t, output, "Status: PENDING") - assert.Contains(t, output, "Covalent Dispatch: \u2014") + assert.Contains(t, output, "Run ID: d-1") + assert.Contains(t, output, "Status: PENDING") + assert.Contains(t, output, "Covalent Run: \u2014") } diff --git a/cmd/pipelines/dispatch/status/cmd.go b/cmd/pipelines/run/status/cmd.go similarity index 72% rename from cmd/pipelines/dispatch/status/cmd.go rename to cmd/pipelines/run/status/cmd.go index b625ccf23..b3e899eab 100644 --- a/cmd/pipelines/dispatch/status/cmd.go +++ b/cmd/pipelines/run/status/cmd.go @@ -19,7 +19,7 @@ import ( "fmt" "net/http" - "github.com/datarobot/cli/cmd/pipelines/dispatch/dispatchutil" + "github.com/datarobot/cli/cmd/pipelines/run/runutil" "github.com/datarobot/cli/cmd/pipelines/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" @@ -35,13 +35,13 @@ func Cmd() *cobra.Command { ) cmd := &cobra.Command{ - Use: "status ", - Short: "Get the lightweight status of a pipeline dispatch", - Long: `Poll a dispatch's current status without re-downloading the full record. + Use: "status ", + Short: "Get the lightweight status of a pipeline run", + Long: `Poll a run's current status without re-downloading the full record. Example: - dr pipelines dispatch status --pipeline - dr pipelines dispatch status --pipeline --version=2 --output json`, + dr pipelines run status --pipeline + dr pipelines run status --pipeline --version=2 --output json`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, @@ -59,16 +59,16 @@ Example: return err } - result, err := pipelines.GetDispatchStatus(flags.PipelineID, scope, version, args[0]) + result, err := pipelines.GetRunStatus(flags.PipelineID, scope, version, args[0]) if err != nil { return handleStatusError(err, args[0]) } if outputFormat == "json" { - return dispatchutil.PrintStatusJSON(*result) + return runutil.PrintStatusJSON(*result) } - dispatchutil.PrintStatusHuman(*result) + runutil.PrintStatusHuman(*result) return nil }, @@ -80,11 +80,11 @@ Example: return cmd } -func handleStatusError(err error, dispatchID string) error { +func handleStatusError(err error, runID string) error { var httpErr *drapi.HTTPError if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { - fmt.Println(tui.DimStyle.Render("No dispatch found with id: " + dispatchID)) + fmt.Println(tui.DimStyle.Render("No run found with id: " + runID)) return nil } diff --git a/cmd/pipelines/dispatch/status/cmd_test.go b/cmd/pipelines/run/status/cmd_test.go similarity index 100% rename from cmd/pipelines/dispatch/status/cmd_test.go rename to cmd/pipelines/run/status/cmd_test.go diff --git a/cmd/pipelines/schedule/cmd.go b/cmd/pipelines/schedule/cmd.go index 234d52a61..97711ba3f 100644 --- a/cmd/pipelines/schedule/cmd.go +++ b/cmd/pipelines/schedule/cmd.go @@ -28,7 +28,7 @@ func Cmd() *cobra.Command { cmd := &cobra.Command{ Use: "schedule", Short: "Manage pipeline schedules", - Long: `Manage recurring (cron) dispatches of locked pipeline versions. + Long: `Manage recurring (cron) runs of locked pipeline versions. Schedules are only valid for locked pipeline versions, so every verb requires --pipeline and --version.`, diff --git a/cmd/pipelines/schedule/create/cmd.go b/cmd/pipelines/schedule/create/cmd.go index 90727d0a6..cba617f14 100644 --- a/cmd/pipelines/schedule/create/cmd.go +++ b/cmd/pipelines/schedule/create/cmd.go @@ -37,7 +37,7 @@ func Cmd() *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create a recurring schedule for a locked pipeline version", - Long: `Register a cron-style schedule that triggers a dispatch on a fixed cadence. + Long: `Register a cron-style schedule that triggers a run on a fixed cadence. Example: dr pipelines schedule create --pipeline --version=2 --cron "0 * * * *" --input @@ -90,7 +90,7 @@ Example: cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") cmd.Flags().StringVar(&cron, "cron", "", "Cron expression, e.g. \"0 * * * *\"") - cmd.Flags().StringVar(&inputID, "input", "", "Input ID to dispatch on each tick") + cmd.Flags().StringVar(&inputID, "input", "", "Input ID to run on each tick") cmd.Flags().StringVar(&timezone, "timezone", "", "IANA timezone name (default UTC)") cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") diff --git a/cmd/pipelines/scopeflag/scopeflag.go b/cmd/pipelines/scopeflag/scopeflag.go index 48259a2a6..c26ab5f22 100644 --- a/cmd/pipelines/scopeflag/scopeflag.go +++ b/cmd/pipelines/scopeflag/scopeflag.go @@ -13,7 +13,7 @@ // limitations under the License. // Package scopeflag bundles the --pipeline / --scope / --version flags -// reused by the input and dispatch CLI command groups. +// reused by the input and run CLI command groups. package scopeflag diff --git a/docs/commands/README.md b/docs/commands/README.md index 5ccbaa72f..39d8c6809 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -92,13 +92,13 @@ dr │ │ ├── get Display a single input │ │ ├── update Update a draft input's payload │ │ └── delete Delete an input -│ ├── dispatch Trigger and inspect pipeline executions -│ │ ├── create Trigger a dispatch from an input -│ │ ├── list List dispatches for a pipeline -│ │ ├── get Display a single dispatch -│ │ ├── status Lightweight dispatch status (for polling) -│ │ └── cancel Cancel a running dispatch -│ ├── schedule Manage recurring (cron) dispatches (locked-only) +│ ├── run Trigger and inspect pipeline executions +│ │ ├── create Trigger a run from an input +│ │ ├── list List runs for a pipeline +│ │ ├── get Display a single run +│ │ ├── status Lightweight run status (for polling) +│ │ └── cancel Cancel a running run +│ ├── schedule Manage recurring (cron) runs (locked-only) │ │ ├── create Register a recurring schedule on a locked version │ │ ├── list List schedules for a locked version │ │ ├── get Display a single schedule @@ -281,9 +281,9 @@ For detailed documentation on each command, see: - `lock`—promote a draft pipeline to locked mode. - `version`—`list` / `get` to inspect pipeline versions. - `graph`—display the pipeline/task DAG (draft or locked). - - `input`—`create`/`list`/`get`/`update`/`delete` JSON payloads used by dispatches. - - `dispatch`—`create`/`list`/`get`/`status`/`cancel` pipeline executions. - - `schedule`—`create`/`list`/`get`/`update`/`delete` recurring (cron) dispatches on locked versions. + - `input`—`create`/`list`/`get`/`update`/`delete` JSON payloads used by runs. + - `run`—`create`/`list`/`get`/`status`/`cancel` pipeline executions. + - `schedule`—`create`/`list`/`get`/`update`/`delete` recurring (cron) runs on locked versions. - `environment`—`create`/`list`/`update`/`delete` named, immutable-versioned pip-package execution environments; `environment version delete` removes a specific older version. ## Getting help diff --git a/docs/commands/pipelines-reference.md b/docs/commands/pipelines-reference.md index 10ac1b9f9..967a8aa7c 100644 --- a/docs/commands/pipelines-reference.md +++ b/docs/commands/pipelines-reference.md @@ -60,17 +60,19 @@ are documented under "Shared flag semantics" below. --- -## Dispatches (`dr pipelines dispatch …`) +## Runs (`dr pipelines run …`) -Same draft/locked scope rules as inputs. +Same draft/locked scope rules as inputs. The wire-level URLs and JSON +field names still use the legacy term `dispatches` / `dispatch_id` — +only the CLI surface has been renamed. | Command | API endpoint | Usage | Inputs | |---|---|---|---| -| `dr pipelines dispatch create` | `POST /pipelines/{id}/dispatches` (draft)
`POST /pipelines/{id}/versions/{ver}/dispatches` (locked) | `dr pipelines dispatch create --pipeline --input `
`dr pipelines dispatch create --pipeline --version=2 --input --output json` | **Flags:** `--pipeline ` (required), `--input ` (required), `--scope`, `--version`, `--output json`.
**Body sent to API:** `{"input_id": ""}`. | -| `dr pipelines dispatch list` | `GET /pipelines/{id}/dispatches` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches` (locked) | `dr pipelines dispatch list --pipeline `
`dr pipelines dispatch list --pipeline --version=2 --output json` | **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--offset `, `--limit `, `--output json`. | -| `dr pipelines dispatch get` | `GET /pipelines/{id}/dispatches/{dispatch_id}` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` (locked) | `dr pipelines dispatch get --pipeline `
`dr pipelines dispatch get --pipeline --version=2 --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output json`. | -| `dr pipelines dispatch status` | `GET /pipelines/{id}/dispatches/{dispatch_id}/status` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}/status` (locked) | `dr pipelines dispatch status --pipeline `
`dr pipelines dispatch status --pipeline --version=2 --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output json`. | -| `dr pipelines dispatch cancel` | `DELETE /pipelines/{id}/dispatches/{dispatch_id}` (draft)
`DELETE /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` (locked) | `dr pipelines dispatch cancel --pipeline `
`dr pipelines dispatch cancel --pipeline --version=2 ` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`. | +| `dr pipelines run create` | `POST /pipelines/{id}/dispatches` (draft)
`POST /pipelines/{id}/versions/{ver}/dispatches` (locked) | `dr pipelines run create --pipeline --input `
`dr pipelines run create --pipeline --version=2 --input --output json` | **Flags:** `--pipeline ` (required), `--input ` (required), `--scope`, `--version`, `--output json`.
**Body sent to API:** `{"input_id": ""}`. | +| `dr pipelines run list` | `GET /pipelines/{id}/dispatches` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches` (locked) | `dr pipelines run list --pipeline `
`dr pipelines run list --pipeline --version=2 --output json` | **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--offset `, `--limit `, `--output json`. | +| `dr pipelines run get` | `GET /pipelines/{id}/dispatches/{dispatch_id}` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` (locked) | `dr pipelines run get --pipeline `
`dr pipelines run get --pipeline --version=2 --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output json`. | +| `dr pipelines run status` | `GET /pipelines/{id}/dispatches/{dispatch_id}/status` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}/status` (locked) | `dr pipelines run status --pipeline `
`dr pipelines run status --pipeline --version=2 --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output json`. | +| `dr pipelines run cancel` | `DELETE /pipelines/{id}/dispatches/{dispatch_id}` (draft)
`DELETE /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` (locked) | `dr pipelines run cancel --pipeline `
`dr pipelines run cancel --pipeline --version=2 ` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`. | --- @@ -115,7 +117,7 @@ individually. ## Shared flag semantics -### `--scope` / `--version` (inputs, dispatches, graph) +### `--scope` / `--version` (inputs, runs, graph) The CLI mirrors the API's two URL shapes — `/pipelines/{id}/…` for the mutable draft and `/pipelines/{id}/versions/{ver}/…` for a locked @@ -185,16 +187,16 @@ exercising a local API stub that doesn't implement `/version/`. | `PATCH /pipelines/{id}/inputs/{input_id}` | `dr pipelines input update` | | `DELETE /pipelines/{id}/inputs/{input_id}` | `dr pipelines input delete` (draft) | | `DELETE /pipelines/{id}/versions/{ver}/inputs/{input_id}` | `dr pipelines input delete --version=N` | -| `POST /pipelines/{id}/dispatches` | `dr pipelines dispatch create` (draft) | -| `POST /pipelines/{id}/versions/{ver}/dispatches` | `dr pipelines dispatch create --version=N` | -| `GET /pipelines/{id}/dispatches` | `dr pipelines dispatch list` (draft) | -| `GET /pipelines/{id}/versions/{ver}/dispatches` | `dr pipelines dispatch list --version=N` | -| `GET /pipelines/{id}/dispatches/{dispatch_id}` | `dr pipelines dispatch get` (draft) | -| `GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` | `dr pipelines dispatch get --version=N` | -| `GET /pipelines/{id}/dispatches/{dispatch_id}/status` | `dr pipelines dispatch status` (draft) | -| `GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}/status` | `dr pipelines dispatch status --version=N` | -| `DELETE /pipelines/{id}/dispatches/{dispatch_id}` | `dr pipelines dispatch cancel` (draft) | -| `DELETE /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` | `dr pipelines dispatch cancel --version=N` | +| `POST /pipelines/{id}/dispatches` | `dr pipelines run create` (draft) | +| `POST /pipelines/{id}/versions/{ver}/dispatches` | `dr pipelines run create --version=N` | +| `GET /pipelines/{id}/dispatches` | `dr pipelines run list` (draft) | +| `GET /pipelines/{id}/versions/{ver}/dispatches` | `dr pipelines run list --version=N` | +| `GET /pipelines/{id}/dispatches/{dispatch_id}` | `dr pipelines run get` (draft) | +| `GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` | `dr pipelines run get --version=N` | +| `GET /pipelines/{id}/dispatches/{dispatch_id}/status` | `dr pipelines run status` (draft) | +| `GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}/status` | `dr pipelines run status --version=N` | +| `DELETE /pipelines/{id}/dispatches/{dispatch_id}` | `dr pipelines run cancel` (draft) | +| `DELETE /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` | `dr pipelines run cancel --version=N` | | `POST /pipelines/{id}/versions/{ver}/schedules` | `dr pipelines schedule create` | | `GET /pipelines/{id}/versions/{ver}/schedules` | `dr pipelines schedule list` | | `GET /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipelines schedule get` | diff --git a/docs/commands/pipelines.md b/docs/commands/pipelines.md index fe3524b12..976ff0c31 100644 --- a/docs/commands/pipelines.md +++ b/docs/commands/pipelines.md @@ -20,8 +20,8 @@ dr pipelines update ./my_pipeline.py # Promote the draft to locked when you are happy with it dr pipelines lock -# Cancel a stuck dispatch -dr pipelines dispatch cancel --pipeline +# Cancel a stuck run +dr pipelines run cancel --pipeline ``` > [!NOTE] @@ -48,9 +48,9 @@ top-level `dr pipelines` subcommand operates on one of four resources: - the **pipeline** itself (create, list, get, update, delete, lock), - pipeline **versions** (list, get, graph), -- pipeline **inputs** — JSON payloads supplied to a dispatch, -- pipeline **dispatches** — concrete executions on Covalent, -- pipeline **schedules** — recurring dispatches on a cron expression, +- pipeline **inputs** — JSON payloads supplied to a run, +- pipeline **runs** — concrete executions on Covalent, +- pipeline **schedules** — recurring runs on a cron expression, - pipeline **environments** — named, immutable-versioned bags of pip packages that pipelines can be built against. @@ -64,7 +64,7 @@ Versions are created automatically: - `lock` promotes a draft to **locked** mode. Locked pipelines are immutable; their inputs and schedules become valid. -Inputs, dispatches, and the graph endpoint exist in two scopes — +Inputs, runs, and the graph endpoint exist in two scopes — **draft** (mutable, no version pinned) and **locked** (immutable, tied to a frozen version) — selected via the shared `--scope` and `--version` flags. Schedules are locked-only. @@ -84,9 +84,9 @@ to a frozen version) — selected via the shared `--scope` and | `dr pipelines lock` | `PATCH /api/v2/pipelines/{id}/mode` | Promote a draft to locked mode. | | `dr pipelines version …` | `…/versions[/{ver}]` | Inspect pipeline versions. | | `dr pipelines graph` | `…/graph` (draft) or `…/versions/{ver}/graph` | Render the pipeline/task DAG. | -| `dr pipelines input …` | `…/inputs` and `…/inputs/{input_id}` | Manage JSON payloads for dispatches. | -| `dr pipelines dispatch …` | `…/dispatches` and `…/dispatches/{dispatch_id}` | Trigger, inspect, and cancel runs. | -| `dr pipelines schedule …` | `…/versions/{ver}/schedules` | Manage recurring (cron) dispatches on locked versions. | +| `dr pipelines input …` | `…/inputs` and `…/inputs/{input_id}` | Manage JSON payloads for runs. | +| `dr pipelines run …` | `…/dispatches` and `…/dispatches/{dispatch_id}` | Trigger, inspect, and cancel runs. | +| `dr pipelines schedule …` | `…/versions/{ver}/schedules` | Manage recurring (cron) runs on locked versions. | | `dr pipelines environment …` | `/api/v2/pipelines/environments[/...]` | Manage named, versioned pip-package execution environments. | ## Subcommands @@ -259,7 +259,7 @@ If the pipeline doesn't exist, `delete` prints ### `lock` Promote a draft pipeline to locked mode. Once locked, the pipeline can -no longer be updated and locked dispatches/inputs/schedules become +no longer be updated and locked runs/inputs/schedules become valid. ```bash @@ -325,7 +325,7 @@ below for the full flag truth table. ### `input` -Manage JSON payloads that drive a dispatch. +Manage JSON payloads that drive a run. ```bash dr pipelines input create --pipeline # draft scope @@ -341,29 +341,29 @@ dr pipelines input delete --pipeline [--scope|--version] - All verbs accept `--scope` / `--version` (see below). Inputs in the `locked` scope are immutable, so `input update` is draft-only. -### `dispatch` +### `run` Trigger, inspect, and cancel pipeline executions. ```bash -dr pipelines dispatch create --pipeline --input # draft -dr pipelines dispatch create --pipeline --version=N --input # locked -dr pipelines dispatch list --pipeline [--scope|--version] -dr pipelines dispatch get --pipeline [--scope|--version] -dr pipelines dispatch status --pipeline [--scope|--version] -dr pipelines dispatch cancel --pipeline [--scope|--version] +dr pipelines run create --pipeline --input # draft +dr pipelines run create --pipeline --version=N --input # locked +dr pipelines run list --pipeline [--scope|--version] +dr pipelines run get --pipeline [--scope|--version] +dr pipelines run status --pipeline [--scope|--version] +dr pipelines run cancel --pipeline [--scope|--version] ``` -`dispatch status` is a lighter-weight call than `dispatch get` — -intended for polling — and returns just the dispatch ID, status, and +`run status` is a lighter-weight call than `run get` — +intended for polling — and returns just the run ID, status, and the corresponding Covalent dispatch ID. -`dispatch cancel` returns `409 Conflict` if the dispatch is already in +`run cancel` returns `409 Conflict` if the run is already in a terminal state (COMPLETED / FAILED / CANCELLED). ### `schedule` -Manage recurring (cron) dispatches on locked versions only. Both +Manage recurring (cron) runs on locked versions only. Both `--pipeline` and `--version` are required for every verb. ```bash @@ -418,7 +418,7 @@ dr pipelines environment create --name ml-base \ ### `--scope` / `--version` flags -Inputs, dispatches, and `graph` mirror the API's two URL shapes — +Inputs, runs, and `graph` mirror the API's two URL shapes — `/pipelines/{id}/…` for the mutable draft and `/pipelines/{id}/versions/{ver}/…` for a locked version — through a pair of optional flags: @@ -526,17 +526,17 @@ dr pipelines version get --pipeline 2 dr pipelines graph --pipeline --version=2 --output json ``` -### Run a dispatch +### Trigger a run ```bash # 1. Register a JSON input on the draft scope dr pipelines input create --pipeline ./input.json -# 2. Trigger a dispatch with that input -dr pipelines dispatch create --pipeline --input +# 2. Trigger a run with that input +dr pipelines run create --pipeline --input # 3. Poll status until it reaches a terminal state -dr pipelines dispatch status --pipeline +dr pipelines run status --pipeline ``` ### Schedule a recurring run on a locked version @@ -562,8 +562,8 @@ common status codes you will see: | Status | Cause | |--------|------------------------------------------------------------------------------------| | `400` | Invalid Python file, mismatched pipeline name, or malformed JSON payload. | -| `404` | The provided `` / version / input / dispatch / schedule does not exist. | -| `409` | Tried to update a `locked` pipeline, or cancel an already-terminal dispatch. | +| `404` | The provided `` / version / input / run / schedule does not exist. | +| `409` | Tried to update a `locked` pipeline, or cancel an already-terminal run. | For most `get` / `delete` verbs the CLI translates a 404 into a friendly informational line (e.g. `No pipeline found with id: …`) and diff --git a/internal/pipelines/dispatch.go b/internal/pipelines/dispatch.go deleted file mode 100644 index 425729d00..000000000 --- a/internal/pipelines/dispatch.go +++ /dev/null @@ -1,158 +0,0 @@ -// Copyright 2026 DataRobot, Inc. and its affiliates. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// dispatch.go wraps the pipeline dispatch endpoints described in -// pipelines-api/.../controllers/pipeline_dispatch.py. The CLI exposes the -// same draft/locked URL split as inputs via the shared Scope helpers. - -package pipelines - -import ( - "net/http" - "net/url" - "strconv" -) - -// Dispatch lifecycle states (mirrors PipelineDispatchStatus). -const ( - DispatchStatusPending = "PENDING" - DispatchStatusRunning = "RUNNING" - DispatchStatusCompleted = "COMPLETED" - DispatchStatusFailed = "FAILED" - DispatchStatusCancelled = "CANCELLED" - DispatchStatusErrored = "ERRORED" -) - -// Dispatch mirrors PipelineDispatchResponse from the pipelines-api. -type Dispatch struct { - DispatchID string `json:"dispatch_id"` - PipelineID string `json:"pipeline_id"` - VersionID *int `json:"version_id,omitempty"` - InputID string `json:"input_id"` - CovalentDispatchID string `json:"covalent_dispatch_id,omitempty"` - TriggeredBy string `json:"triggered_by"` - Status string `json:"status"` - ErrorDetail string `json:"error_detail,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -// DispatchStatus mirrors PipelineDispatchStatusResponse — the lightweight -// polling-friendly shape returned by GET .../status. -type DispatchStatus struct { - DispatchID string `json:"dispatch_id"` - Status string `json:"status"` - CovalentDispatchID string `json:"covalent_dispatch_id,omitempty"` -} - -// DispatchCreateRequest mirrors PipelineDispatchCreateRequest. -type DispatchCreateRequest struct { - InputID string `json:"input_id"` -} - -// CreateDispatch starts a new dispatch for the given input. Returns the -// freshly-created Dispatch (status PENDING). -func CreateDispatch(pipelineID string, scope Scope, version *int, inputID string) (*Dispatch, error) { - endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches") - if err != nil { - return nil, err - } - - body := DispatchCreateRequest{InputID: inputID} - - var result Dispatch - - err = doJSON(http.MethodPost, endpoint, body, "create dispatch", &result) - if err != nil { - return nil, err - } - - return &result, nil -} - -// ListDispatches returns a paginated slice of dispatches for the given scope. -func ListDispatches(pipelineID string, scope Scope, version *int, offset, limit int) ([]Dispatch, error) { - endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches") - if err != nil { - return nil, err - } - - query := url.Values{} - if offset > 0 { - query.Set("offset", strconv.Itoa(offset)) - } - - if limit > 0 { - query.Set("limit", strconv.Itoa(limit)) - } - - if encoded := query.Encode(); encoded != "" { - endpoint = endpoint + "?" + encoded - } - - var dispatches []Dispatch - - err = doJSON(http.MethodGet, endpoint, nil, "dispatches", &dispatches) - if err != nil { - return nil, err - } - - return dispatches, nil -} - -// GetDispatch fetches a single dispatch by id within the given scope. -func GetDispatch(pipelineID string, scope Scope, version *int, dispatchID string) (*Dispatch, error) { - endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches/"+dispatchID) - if err != nil { - return nil, err - } - - var dispatch Dispatch - - err = doJSON(http.MethodGet, endpoint, nil, "dispatch", &dispatch) - if err != nil { - return nil, err - } - - return &dispatch, nil -} - -// GetDispatchStatus calls the lightweight GET .../status endpoint useful -// for polling without re-downloading the full dispatch record. -func GetDispatchStatus(pipelineID string, scope Scope, version *int, dispatchID string) (*DispatchStatus, error) { - endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches/"+dispatchID+"/status") - if err != nil { - return nil, err - } - - var status DispatchStatus - - err = doJSON(http.MethodGet, endpoint, nil, "dispatch status", &status) - if err != nil { - return nil, err - } - - return &status, nil -} - -// CancelDispatch issues a DELETE on a dispatch, transitioning it to -// CANCELLED if it is still in a non-terminal state. -func CancelDispatch(pipelineID string, scope Scope, version *int, dispatchID string) error { - endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches/"+dispatchID) - if err != nil { - return err - } - - return doDelete(endpoint, "cancel dispatch") -} diff --git a/internal/pipelines/run.go b/internal/pipelines/run.go new file mode 100644 index 000000000..ce7908ad1 --- /dev/null +++ b/internal/pipelines/run.go @@ -0,0 +1,168 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// run.go wraps the pipeline run endpoints described in +// pipelines-api/.../controllers/pipeline_dispatch.py. The CLI exposes the +// same draft/locked URL split as inputs via the shared Scope helpers. +// +// The wire format and server URL paths still use the legacy term +// "dispatch" (e.g. /dispatches, dispatch_id). JSON tags and endpoint +// segments are preserved to keep the API contract intact while the Go +// surface is renamed to "run" to match the new product vocabulary. + +package pipelines + +import ( + "net/http" + "net/url" + "strconv" +) + +// Run lifecycle states (mirrors PipelineDispatchStatus on the wire). +const ( + RunStatusPending = "PENDING" + RunStatusRunning = "RUNNING" + RunStatusCompleted = "COMPLETED" + RunStatusFailed = "FAILED" + RunStatusCancelled = "CANCELLED" + RunStatusErrored = "ERRORED" +) + +// Run mirrors PipelineDispatchResponse from the pipelines-api. JSON tags +// (`dispatch_id`, `covalent_dispatch_id`) track the current API wire +// format, which has not been renamed to "run" yet. +type Run struct { + RunID string `json:"dispatch_id"` + PipelineID string `json:"pipeline_id"` + VersionID *int `json:"version_id,omitempty"` + InputID string `json:"input_id"` + CovalentDispatchID string `json:"covalent_dispatch_id,omitempty"` + TriggeredBy string `json:"triggered_by"` + Status string `json:"status"` + ErrorDetail string `json:"error_detail,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// RunStatus mirrors PipelineDispatchStatusResponse — the lightweight +// polling-friendly shape returned by GET .../status. +// +// See Run for the rationale on the legacy `dispatch_id` / +// `covalent_dispatch_id` JSON tags. +type RunStatus struct { + RunID string `json:"dispatch_id"` + Status string `json:"status"` + CovalentDispatchID string `json:"covalent_dispatch_id,omitempty"` +} + +// RunCreateRequest mirrors PipelineDispatchCreateRequest. +type RunCreateRequest struct { + InputID string `json:"input_id"` +} + +// CreateRun starts a new run for the given input. Returns the +// freshly-created Run (status PENDING). +func CreateRun(pipelineID string, scope Scope, version *int, inputID string) (*Run, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches") + if err != nil { + return nil, err + } + + body := RunCreateRequest{InputID: inputID} + + var result Run + + err = doJSON(http.MethodPost, endpoint, body, "create run", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// ListRuns returns a paginated slice of runs for the given scope. +func ListRuns(pipelineID string, scope Scope, version *int, offset, limit int) ([]Run, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches") + if err != nil { + return nil, err + } + + query := url.Values{} + if offset > 0 { + query.Set("offset", strconv.Itoa(offset)) + } + + if limit > 0 { + query.Set("limit", strconv.Itoa(limit)) + } + + if encoded := query.Encode(); encoded != "" { + endpoint = endpoint + "?" + encoded + } + + var runs []Run + + err = doJSON(http.MethodGet, endpoint, nil, "runs", &runs) + if err != nil { + return nil, err + } + + return runs, nil +} + +// GetRun fetches a single run by id within the given scope. +func GetRun(pipelineID string, scope Scope, version *int, runID string) (*Run, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches/"+runID) + if err != nil { + return nil, err + } + + var run Run + + err = doJSON(http.MethodGet, endpoint, nil, "run", &run) + if err != nil { + return nil, err + } + + return &run, nil +} + +// GetRunStatus calls the lightweight GET .../status endpoint useful for +// polling without re-downloading the full run record. +func GetRunStatus(pipelineID string, scope Scope, version *int, runID string) (*RunStatus, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches/"+runID+"/status") + if err != nil { + return nil, err + } + + var status RunStatus + + err = doJSON(http.MethodGet, endpoint, nil, "run status", &status) + if err != nil { + return nil, err + } + + return &status, nil +} + +// CancelRun issues a DELETE on a run, transitioning it to CANCELLED if +// it is still in a non-terminal state. +func CancelRun(pipelineID string, scope Scope, version *int, runID string) error { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches/"+runID) + if err != nil { + return err + } + + return doDelete(endpoint, "cancel run") +} diff --git a/internal/pipelines/dispatch_test.go b/internal/pipelines/run_test.go similarity index 79% rename from internal/pipelines/dispatch_test.go rename to internal/pipelines/run_test.go index b96ea4bb2..3ecb7e0af 100644 --- a/internal/pipelines/dispatch_test.go +++ b/internal/pipelines/run_test.go @@ -24,14 +24,14 @@ import ( "github.com/stretchr/testify/require" ) -func TestCreateDispatch_DraftURLAndBody(t *testing.T) { +func TestCreateRun_DraftURLAndBody(t *testing.T) { installSkipAuth(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodPost, r.Method) assert.Equal(t, "/api/v2/pipelines/p-1/dispatches", r.URL.Path) - var body DispatchCreateRequest + var body RunCreateRequest assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) assert.Equal(t, "in-1", body.InputID) @@ -44,13 +44,13 @@ func TestCreateDispatch_DraftURLAndBody(t *testing.T) { installEndpoint(t, srv.URL) - got, err := CreateDispatch("p-1", ScopeDraft, nil, "in-1") + got, err := CreateRun("p-1", ScopeDraft, nil, "in-1") require.NoError(t, err) - assert.Equal(t, "d-1", got.DispatchID) - assert.Equal(t, DispatchStatusPending, got.Status) + assert.Equal(t, "d-1", got.RunID) + assert.Equal(t, RunStatusPending, got.Status) } -func TestCreateDispatch_LockedURL(t *testing.T) { +func TestCreateRun_LockedURL(t *testing.T) { installSkipAuth(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -65,13 +65,13 @@ func TestCreateDispatch_LockedURL(t *testing.T) { installEndpoint(t, srv.URL) v := 2 - got, err := CreateDispatch("p-1", ScopeLocked, &v, "in-1") + got, err := CreateRun("p-1", ScopeLocked, &v, "in-1") require.NoError(t, err) require.NotNil(t, got.VersionID) assert.Equal(t, 2, *got.VersionID) } -func TestListDispatches_QueryAndDecode(t *testing.T) { +func TestListRuns_QueryAndDecode(t *testing.T) { installSkipAuth(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -87,13 +87,13 @@ func TestListDispatches_QueryAndDecode(t *testing.T) { installEndpoint(t, srv.URL) - items, err := ListDispatches("p-1", ScopeDraft, nil, 10, 5) + items, err := ListRuns("p-1", ScopeDraft, nil, 10, 5) require.NoError(t, err) require.Len(t, items, 1) - assert.Equal(t, DispatchStatusRunning, items[0].Status) + assert.Equal(t, RunStatusRunning, items[0].Status) } -func TestGetDispatch_TargetsCorrectURL(t *testing.T) { +func TestGetRun_TargetsCorrectURL(t *testing.T) { installSkipAuth(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -107,12 +107,12 @@ func TestGetDispatch_TargetsCorrectURL(t *testing.T) { installEndpoint(t, srv.URL) - got, err := GetDispatch("p-1", ScopeDraft, nil, "d-1") + got, err := GetRun("p-1", ScopeDraft, nil, "d-1") require.NoError(t, err) - assert.Equal(t, DispatchStatusCompleted, got.Status) + assert.Equal(t, RunStatusCompleted, got.Status) } -func TestGetDispatchStatus_StatusEndpointURL(t *testing.T) { +func TestGetRunStatus_StatusEndpointURL(t *testing.T) { installSkipAuth(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -127,13 +127,13 @@ func TestGetDispatchStatus_StatusEndpointURL(t *testing.T) { installEndpoint(t, srv.URL) v := 2 - got, err := GetDispatchStatus("p-1", ScopeLocked, &v, "d-1") + got, err := GetRunStatus("p-1", ScopeLocked, &v, "d-1") require.NoError(t, err) - assert.Equal(t, DispatchStatusRunning, got.Status) + assert.Equal(t, RunStatusRunning, got.Status) assert.Equal(t, "cov-x", got.CovalentDispatchID) } -func TestCancelDispatch_DeletesDraftURL(t *testing.T) { +func TestCancelRun_DeletesDraftURL(t *testing.T) { installSkipAuth(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -146,10 +146,10 @@ func TestCancelDispatch_DeletesDraftURL(t *testing.T) { installEndpoint(t, srv.URL) - require.NoError(t, CancelDispatch("p-1", ScopeDraft, nil, "d-1")) + require.NoError(t, CancelRun("p-1", ScopeDraft, nil, "d-1")) } -func TestCancelDispatch_PropagatesConflict(t *testing.T) { +func TestCancelRun_PropagatesConflict(t *testing.T) { installSkipAuth(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -161,7 +161,7 @@ func TestCancelDispatch_PropagatesConflict(t *testing.T) { installEndpoint(t, srv.URL) - err := CancelDispatch("p-1", ScopeDraft, nil, "d-1") + err := CancelRun("p-1", ScopeDraft, nil, "d-1") require.Error(t, err) assert.Contains(t, err.Error(), "HTTP 409") assert.Contains(t, err.Error(), "already terminal") diff --git a/internal/pipelines/schedule.go b/internal/pipelines/schedule.go index 3673b94ad..8f3779511 100644 --- a/internal/pipelines/schedule.go +++ b/internal/pipelines/schedule.go @@ -60,7 +60,7 @@ type ScheduleUpdateRequest struct { Timezone *string `json:"timezone,omitempty"` } -// CreateSchedule registers a new recurring dispatch for a locked version. +// CreateSchedule registers a new recurring run for a locked version. func CreateSchedule(pipelineID string, version int, body ScheduleCreateRequest) (*Schedule, error) { endpoint, err := EndpointFor(pipelineID, ScopeLocked, &version, "schedules") if err != nil { diff --git a/internal/pipelines/scope.go b/internal/pipelines/scope.go index ad26a0c32..7874336cb 100644 --- a/internal/pipelines/scope.go +++ b/internal/pipelines/scope.go @@ -13,7 +13,7 @@ // limitations under the License. // scope.go contains the shared draft/locked scope resolution used by the -// input and dispatch CLI commands. The pipelines API exposes two URL shapes +// input and run CLI commands. The pipelines API exposes two URL shapes // for these resources: // // draft -> /pipelines/{id}/ diff --git a/internal/pipelines/transport.go b/internal/pipelines/transport.go index a7b2e6f9c..daa19dd52 100644 --- a/internal/pipelines/transport.go +++ b/internal/pipelines/transport.go @@ -13,7 +13,7 @@ // limitations under the License. // transport.go centralizes the request execution paths used by the -// non-multipart pipelines endpoints (inputs, dispatches, schedules): +// non-multipart pipelines endpoints (inputs, runs, schedules): // // - doJSON - POST/PATCH a JSON body, optionally decode response. // - doDelete - DELETE returning 204 (or any 2xx with empty body). From 785106ad2af8f7fb8e8104caaddf755c818a5360 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Thu, 7 May 2026 22:56:40 -0400 Subject: [PATCH 12/25] remap run --output json to use run_id / covalent_run_id Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- cmd/pipelines/run/runutil/render.go | 66 +++++++++++++++++++++--- cmd/pipelines/run/runutil/render_test.go | 9 ++-- docs/commands/pipelines-reference.md | 7 +-- 3 files changed, 70 insertions(+), 12 deletions(-) diff --git a/cmd/pipelines/run/runutil/render.go b/cmd/pipelines/run/runutil/render.go index ab0114ac9..a8e3bc26b 100644 --- a/cmd/pipelines/run/runutil/render.go +++ b/cmd/pipelines/run/runutil/render.go @@ -29,9 +29,56 @@ import ( "github.com/datarobot/cli/tui" ) -// PrintRunJSON marshals a run as indented JSON. +// runJSON is the CLI-facing shape used for `--output json`. It mirrors +// pipelines.Run but renames the wire-level fields to the CLI's `run` +// vocabulary (`run_id`, `covalent_run_id`). Decoding still happens +// through pipelines.Run, which keeps the API wire tags intact. +type runJSON struct { + RunID string `json:"run_id"` + PipelineID string `json:"pipeline_id"` + VersionID *int `json:"version_id,omitempty"` + InputID string `json:"input_id"` + CovalentRunID string `json:"covalent_run_id,omitempty"` + TriggeredBy string `json:"triggered_by"` + Status string `json:"status"` + ErrorDetail string `json:"error_detail,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toRunJSON(r pipelines.Run) runJSON { + return runJSON{ + RunID: r.RunID, + PipelineID: r.PipelineID, + VersionID: r.VersionID, + InputID: r.InputID, + CovalentRunID: r.CovalentDispatchID, + TriggeredBy: r.TriggeredBy, + Status: r.Status, + ErrorDetail: r.ErrorDetail, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} + +// runStatusJSON mirrors pipelines.RunStatus with CLI-vocabulary keys. +type runStatusJSON struct { + RunID string `json:"run_id"` + Status string `json:"status"` + CovalentRunID string `json:"covalent_run_id,omitempty"` +} + +func toRunStatusJSON(s pipelines.RunStatus) runStatusJSON { + return runStatusJSON{ + RunID: s.RunID, + Status: s.Status, + CovalentRunID: s.CovalentDispatchID, + } +} + +// PrintRunJSON marshals a run as indented JSON using CLI-vocabulary keys. func PrintRunJSON(r pipelines.Run) error { - data, err := json.MarshalIndent(r, "", " ") + data, err := json.MarshalIndent(toRunJSON(r), "", " ") if err != nil { return err } @@ -73,9 +120,15 @@ func PrintRunHuman(r pipelines.Run) { fmt.Println(tui.DimStyle.Render("Updated: " + r.UpdatedAt)) } -// PrintRunListJSON marshals a list of runs as indented JSON. +// PrintRunListJSON marshals a list of runs as indented JSON using +// CLI-vocabulary keys. func PrintRunListJSON(items []pipelines.Run) error { - data, err := json.MarshalIndent(items, "", " ") + view := make([]runJSON, len(items)) + for i, r := range items { + view[i] = toRunJSON(r) + } + + data, err := json.MarshalIndent(view, "", " ") if err != nil { return err } @@ -114,9 +167,10 @@ func PrintRunListHuman(items []pipelines.Run) { _ = writer.Flush() } -// PrintStatusJSON marshals a lightweight status response as indented JSON. +// PrintStatusJSON marshals a lightweight status response as indented JSON +// using CLI-vocabulary keys. func PrintStatusJSON(s pipelines.RunStatus) error { - data, err := json.MarshalIndent(s, "", " ") + data, err := json.MarshalIndent(toRunStatusJSON(s), "", " ") if err != nil { return err } diff --git a/cmd/pipelines/run/runutil/render_test.go b/cmd/pipelines/run/runutil/render_test.go index 5e80376b6..17577e158 100644 --- a/cmd/pipelines/run/runutil/render_test.go +++ b/cmd/pipelines/run/runutil/render_test.go @@ -77,8 +77,10 @@ func TestPrintRunJSON(t *testing.T) { var parsed map[string]any require.NoError(t, json.Unmarshal([]byte(output), &parsed)) - assert.Equal(t, "d-1", parsed["dispatch_id"]) + assert.Equal(t, "d-1", parsed["run_id"]) assert.Equal(t, "PENDING", parsed["status"]) + _, hasLegacy := parsed["dispatch_id"] + assert.Falsef(t, hasLegacy, "expected legacy dispatch_id key to be absent") } func TestPrintRunHuman_DraftMissingCovalent(t *testing.T) { @@ -111,7 +113,7 @@ func TestPrintRunListJSON(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(output), &parsed)) require.Len(t, parsed, 1) - assert.Equal(t, "d-1", parsed[0]["dispatch_id"]) + assert.Equal(t, "d-1", parsed[0]["run_id"]) } func TestPrintRunListHuman_Empty(t *testing.T) { @@ -149,7 +151,8 @@ func TestPrintStatusJSON(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(output), &parsed)) assert.Equal(t, "COMPLETED", parsed["status"]) - assert.Equal(t, "cov-xyz", parsed["covalent_dispatch_id"]) + assert.Equal(t, "cov-xyz", parsed["covalent_run_id"]) + assert.Equal(t, "d-1", parsed["run_id"]) } func TestPrintStatusHuman_NoCovalentRunID(t *testing.T) { diff --git a/docs/commands/pipelines-reference.md b/docs/commands/pipelines-reference.md index 967a8aa7c..d50b5ee0f 100644 --- a/docs/commands/pipelines-reference.md +++ b/docs/commands/pipelines-reference.md @@ -62,9 +62,10 @@ are documented under "Shared flag semantics" below. ## Runs (`dr pipelines run …`) -Same draft/locked scope rules as inputs. The wire-level URLs and JSON -field names still use the legacy term `dispatches` / `dispatch_id` — -only the CLI surface has been renamed. +Same draft/locked scope rules as inputs. The wire-level URLs still use +the legacy term `dispatches` / `dispatch_id`, but the CLI's `--output +json` remaps these to `run_id` / `covalent_run_id` so the JSON output +matches the rest of the CLI vocabulary. | Command | API endpoint | Usage | Inputs | |---|---|---|---| From a03efb353d7609718f46fda1040ba80bdf000deb Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Wed, 20 May 2026 12:34:01 -0400 Subject: [PATCH 13/25] sync pipelines CLI with ObjectId migration and updated graph schema - Replace UUID IDs with 24-char MongoDB ObjectIds in help text, test fixtures, and docs (pipeline_id, dispatch_id, input_id, schedule_id, environment_id now use ObjectId format per pipelines-api commit 65c86cd) - Add internal/pipelines/Time type with flexible UnmarshalJSON to handle the API's bare UTC datetime strings (no timezone suffix) alongside standard RFC3339, fixing the time-parse crash on pipelines list/get - Update Graph structs: GraphNode.ID and GraphEdge.Source/Target are now int (not string), GraphPipeline drops ID, adds PythonVersion; add extra edge/node fields for full --output json pass-through - Fix demo-pipelines Taskfile target to use correct port 8100 Co-Authored-By: Claude Sonnet 4.6 --- Taskfile.yaml | 18 ++++++- cmd/pipelines/create/cmd_test.go | 4 +- cmd/pipelines/del/cmd.go | 2 +- cmd/pipelines/get/cmd.go | 4 +- cmd/pipelines/get/cmd_test.go | 10 ++-- cmd/pipelines/graph/cmd.go | 5 +- cmd/pipelines/graph/cmd_test.go | 16 +++--- cmd/pipelines/list/cmd_test.go | 8 +-- cmd/pipelines/lock/cmd.go | 4 +- cmd/pipelines/lock/cmd_test.go | 2 +- cmd/pipelines/update/cmd.go | 6 +-- cmd/pipelines/update/cmd_test.go | 4 +- .../version/versionutil/render_test.go | 2 +- docs/commands/pipelines.md | 30 +++++------ internal/pipelines/pipeline.go | 12 ++--- internal/pipelines/time.go | 54 +++++++++++++++++++ internal/pipelines/version.go | 36 +++++++------ internal/pipelines/version_test.go | 8 +-- 18 files changed, 147 insertions(+), 78 deletions(-) create mode 100644 internal/pipelines/time.go diff --git a/Taskfile.yaml b/Taskfile.yaml index 895417211..493dcb508 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -155,6 +155,20 @@ tasks: cmds: - ./smoke_test_scripts/run_smoke_test.sh {{if .DR_API_TOKEN}}{{.DR_API_TOKEN}}{{else}}$DR_API_TOKEN{{end}} + demo-pipelines: + desc: "End-to-end demo of every dr pipelines command (40 steps incl. environment lifecycle). Defaults to interactive (keypress per step); pass DELAY=N or `-- --delay=N` for auto-advance." + deps: [build] + vars: + DEMO_DIR: '{{default "/Users/sunnypal.sharma/Desktop/tests/pipelines-api" .DEMO_DIR}}' + DELAY_FLAG: '{{if .DELAY}}--delay={{.DELAY}}{{end}}' + env: + DATAROBOT_CLI_FEATURE_PIPELINES: "true" + DATAROBOT_CLI_ENDPOINT: "http://localhost:8100/api/v2" + DATAROBOT_CLI_TOKEN: "local" + DATAROBOT_CLI_SKIP_AUTH: "true" + cmds: + - DR_BIN="$PWD/dist/dr" DEMO_DIR="{{.DEMO_DIR}}" bash "{{.DEMO_DIR}}/demo.sh" {{.DELAY_FLAG}} {{.CLI_ARGS}} + smoke-test-self-update: desc: "Run self-update smoke tests" cmds: @@ -178,8 +192,8 @@ tasks: cmds: - echo "📚 Starting documentation server…" - uv sync - - echo "🌐 Open http://localhost:8000 in your browser" - - uv run mkdocs serve + - echo "🌐 Open http://localhost:8001 in your browser" + - uv run mkdocs serve --dev-addr localhost:8001 copyright: silent: true diff --git a/cmd/pipelines/create/cmd_test.go b/cmd/pipelines/create/cmd_test.go index d6f53466b..feeeb5fa6 100644 --- a/cmd/pipelines/create/cmd_test.go +++ b/cmd/pipelines/create/cmd_test.go @@ -49,13 +49,13 @@ func captureStdout(t *testing.T, fn func()) string { func sampleCreateResponse() pipelines.CreateResponse { return pipelines.CreateResponse{ - PipelineID: "6658f441-a8f5-4f21-b4d8-6cccf4c94c5b", + PipelineID: "683c2a1b4f8e1a2b3c4d5e6f", Name: "confluence_to_vdb", Version: 1, Status: "READY", Mode: "draft", TaskNames: []string{"create_vector_database", "ingest_confluence_files"}, - CreatedAt: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC), + CreatedAt: pipelines.Time{Time: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC)}, } } diff --git a/cmd/pipelines/del/cmd.go b/cmd/pipelines/del/cmd.go index 810ecafce..32e29a22f 100644 --- a/cmd/pipelines/del/cmd.go +++ b/cmd/pipelines/del/cmd.go @@ -38,7 +38,7 @@ func Cmd() *cobra.Command { removed from the registry. Example: - dr pipelines delete 8a8d6e5e-1234-5678-90ab-cdef01234567`, + dr pipelines delete 507f1f77bcf86cd799439011`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/get/cmd.go b/cmd/pipelines/get/cmd.go index a57d673f2..edfbc8eb1 100644 --- a/cmd/pipelines/get/cmd.go +++ b/cmd/pipelines/get/cmd.go @@ -43,8 +43,8 @@ func Cmd() *cobra.Command { By default, output is human-readable. Use --output json for machine-parseable output. Example: - dr pipelines get 8a8d6e5e-1234-5678-90ab-cdef01234567 - dr pipelines get 8a8d6e5e-1234-5678-90ab-cdef01234567 --output json`, + dr pipelines get 507f1f77bcf86cd799439011 + dr pipelines get 507f1f77bcf86cd799439011 --output json`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/get/cmd_test.go b/cmd/pipelines/get/cmd_test.go index fb7114067..b7659ae5c 100644 --- a/cmd/pipelines/get/cmd_test.go +++ b/cmd/pipelines/get/cmd_test.go @@ -51,13 +51,13 @@ func captureStdout(t *testing.T, fn func()) string { func samplePipeline() pipelines.Pipeline { return pipelines.Pipeline{ - PipelineID: "6658f441-a8f5-4f21-b4d8-6cccf4c94c5b", + PipelineID: "683c2a1b4f8e1a2b3c4d5e6f", Name: "confluence_to_vdb", Description: "test", Mode: "draft", IsActive: true, - CreatedAt: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC), - UpdatedAt: time.Date(2026, 4, 28, 12, 25, 11, 0, time.UTC), + CreatedAt: pipelines.Time{Time: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC)}, + UpdatedAt: pipelines.Time{Time: time.Date(2026, 4, 28, 12, 25, 11, 0, time.UTC)}, Versions: []pipelines.PipelineVersion{ { Version: 1, @@ -65,7 +65,7 @@ func samplePipeline() pipelines.Pipeline { PipelineName: "confluence_to_vdb", TaskNames: []string{"create_vector_database", "ingest_confluence_files"}, PythonVersion: "3.12", - CreatedAt: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC), + CreatedAt: pipelines.Time{Time: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC)}, }, { Version: 2, @@ -73,7 +73,7 @@ func samplePipeline() pipelines.Pipeline { PipelineName: "confluence_to_vdb", PythonVersion: "3.12", ErrorDetail: "boom", - CreatedAt: time.Date(2026, 4, 28, 12, 25, 11, 0, time.UTC), + CreatedAt: pipelines.Time{Time: time.Date(2026, 4, 28, 12, 25, 11, 0, time.UTC)}, }, }, } diff --git a/cmd/pipelines/graph/cmd.go b/cmd/pipelines/graph/cmd.go index 0d23730a9..03b0b5e95 100644 --- a/cmd/pipelines/graph/cmd.go +++ b/cmd/pipelines/graph/cmd.go @@ -115,7 +115,6 @@ func printGraphJSON(g pipelines.Graph) error { func printGraphHuman(g pipelines.Graph) { fmt.Println(tui.BaseTextStyle.Render("Pipeline: " + g.Pipeline.Name)) - fmt.Println(tui.DimStyle.Render("ID: " + g.Pipeline.ID)) if len(g.Nodes) == 0 { fmt.Println(tui.DimStyle.Render("No nodes")) @@ -131,7 +130,7 @@ func printGraphHuman(g pipelines.Graph) { fmt.Fprintln(writer, " ID\tTYPE\tNAME") for _, n := range g.Nodes { - fmt.Fprintf(writer, " %s\t%s\t%s\n", n.ID, n.Type, n.Name) + fmt.Fprintf(writer, " %d\t%s\t%s\n", n.ID, n.Type, n.Name) } _ = writer.Flush() @@ -148,7 +147,7 @@ func printGraphHuman(g pipelines.Graph) { fmt.Fprintln(writer, " SOURCE\tTARGET") for _, e := range g.Edges { - fmt.Fprintf(writer, " %s\t%s\n", e.Source, e.Target) + fmt.Fprintf(writer, " %d\t%d\n", e.Source, e.Target) } _ = writer.Flush() diff --git a/cmd/pipelines/graph/cmd_test.go b/cmd/pipelines/graph/cmd_test.go index c231fa4fb..201603c22 100644 --- a/cmd/pipelines/graph/cmd_test.go +++ b/cmd/pipelines/graph/cmd_test.go @@ -51,13 +51,13 @@ func captureStdout(t *testing.T, fn func()) string { func sampleGraph() pipelines.Graph { return pipelines.Graph{ - Pipeline: pipelines.GraphPipeline{ID: "pipeline-0", Name: "wf"}, + Pipeline: pipelines.GraphPipeline{Name: "wf", PythonVersion: "3.12"}, Nodes: []pipelines.GraphNode{ - {ID: "pipeline-0", Type: "lattice", Name: "wf"}, - {ID: "task-0", Type: "electron", Name: "step1"}, + {ID: 0, Type: "function", Name: "wf"}, + {ID: 1, Type: "function", Name: "step1"}, }, Edges: []pipelines.GraphEdge{ - {Source: "pipeline-0", Target: "task-0"}, + {Source: 0, Target: 1}, }, } } @@ -71,11 +71,10 @@ func TestPrintGraphJSON(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(output), &parsed)) - // JSON tag remains `lattice` until the API renames the wire field. pipeline, ok := parsed["lattice"].(map[string]any) require.True(t, ok) - assert.Equal(t, "pipeline-0", pipeline["id"]) assert.Equal(t, "wf", pipeline["name"]) + assert.Equal(t, "3.12", pipeline["python_version"]) } func TestPrintGraphHuman(t *testing.T) { @@ -84,15 +83,14 @@ func TestPrintGraphHuman(t *testing.T) { }) assert.Contains(t, output, "Pipeline: wf") - assert.Contains(t, output, "ID: pipeline-0") assert.Contains(t, output, "Nodes (2):") assert.Contains(t, output, "Edges (1):") - assert.Contains(t, output, "task-0") + assert.Contains(t, output, "step1") } func TestPrintGraphHuman_EmptyGraph(t *testing.T) { output := captureStdout(t, func() { - printGraphHuman(pipelines.Graph{Pipeline: pipelines.GraphPipeline{Name: "empty", ID: "x"}}) + printGraphHuman(pipelines.Graph{Pipeline: pipelines.GraphPipeline{Name: "empty"}}) }) assert.Contains(t, output, "No nodes") diff --git a/cmd/pipelines/list/cmd_test.go b/cmd/pipelines/list/cmd_test.go index 44f3d4f75..d496a071e 100644 --- a/cmd/pipelines/list/cmd_test.go +++ b/cmd/pipelines/list/cmd_test.go @@ -55,13 +55,13 @@ func sampleListResponse() pipelines.ListResponse { return pipelines.ListResponse{ Items: []pipelines.ListItem{ { - PipelineID: "6658f441-a8f5-4f21-b4d8-6cccf4c94c5b", + PipelineID: "683c2a1b4f8e1a2b3c4d5e6f", Name: "confluence_to_vdb", Mode: "draft", IsActive: true, LatestVersion: intPtr(3), - CreatedAt: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC), - UpdatedAt: time.Date(2026, 4, 28, 12, 25, 11, 0, time.UTC), + CreatedAt: pipelines.Time{Time: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC)}, + UpdatedAt: pipelines.Time{Time: time.Date(2026, 4, 28, 12, 25, 11, 0, time.UTC)}, }, }, Total: 1, @@ -115,7 +115,7 @@ func TestPrintListHuman_RendersHeaderAndRow(t *testing.T) { assert.Contains(t, output, "ACTIVE") assert.Contains(t, output, "VERSION") assert.Contains(t, output, "UPDATED") - assert.Contains(t, output, "6658f441-a8f5-4f21-b4d8-6cccf4c94c5b") + assert.Contains(t, output, "683c2a1b4f8e1a2b3c4d5e6f") assert.Contains(t, output, "confluence_to_vdb") assert.Contains(t, output, "draft") assert.Contains(t, output, "true") diff --git a/cmd/pipelines/lock/cmd.go b/cmd/pipelines/lock/cmd.go index 86ebaaa57..349125a67 100644 --- a/cmd/pipelines/lock/cmd.go +++ b/cmd/pipelines/lock/cmd.go @@ -37,8 +37,8 @@ func Cmd() *cobra.Command { no longer be updated and locked runs/inputs/schedules become valid. Example: - dr pipelines lock 8a8d6e5e-1234-5678-90ab-cdef01234567 - dr pipelines lock 8a8d6e5e-1234-5678-90ab-cdef01234567 --output json`, + dr pipelines lock 507f1f77bcf86cd799439011 + dr pipelines lock 507f1f77bcf86cd799439011 --output json`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/lock/cmd_test.go b/cmd/pipelines/lock/cmd_test.go index 6b2e0379e..3e5c370b9 100644 --- a/cmd/pipelines/lock/cmd_test.go +++ b/cmd/pipelines/lock/cmd_test.go @@ -55,7 +55,7 @@ func sample() pipelines.CreateResponse { Status: "READY", Mode: "locked", TaskNames: []string{"e1", "e2"}, - CreatedAt: time.Date(2026, 4, 30, 10, 0, 0, 0, time.UTC), + CreatedAt: pipelines.Time{Time: time.Date(2026, 4, 30, 10, 0, 0, 0, time.UTC)}, } } diff --git a/cmd/pipelines/update/cmd.go b/cmd/pipelines/update/cmd.go index e60a0f295..4f8511dd4 100644 --- a/cmd/pipelines/update/cmd.go +++ b/cmd/pipelines/update/cmd.go @@ -48,9 +48,9 @@ or via the --from-file= flag. Exactly one of the two must be provided. By default, output is human-readable. Use --output json for machine-parseable output. Example: - dr pipelines update 8a8d6e5e-1234-5678-90ab-cdef01234567 ./my_pipeline.py - dr pipelines update 8a8d6e5e-1234-5678-90ab-cdef01234567 --from-file=./my_pipeline.py - dr pipelines update 8a8d6e5e-1234-5678-90ab-cdef01234567 --from-file=./my_pipeline.py --output json`, + dr pipelines update 507f1f77bcf86cd799439011 ./my_pipeline.py + dr pipelines update 507f1f77bcf86cd799439011 --from-file=./my_pipeline.py + dr pipelines update 507f1f77bcf86cd799439011 --from-file=./my_pipeline.py --output json`, Args: cobra.RangeArgs(1, 2), PreRunE: auth.EnsureAuthenticatedE, RunE: func(_ *cobra.Command, args []string) error { diff --git a/cmd/pipelines/update/cmd_test.go b/cmd/pipelines/update/cmd_test.go index de4f9a7a1..f428f64a8 100644 --- a/cmd/pipelines/update/cmd_test.go +++ b/cmd/pipelines/update/cmd_test.go @@ -49,13 +49,13 @@ func captureStdout(t *testing.T, fn func()) string { func sampleUpdateResponse() pipelines.CreateResponse { return pipelines.CreateResponse{ - PipelineID: "6658f441-a8f5-4f21-b4d8-6cccf4c94c5b", + PipelineID: "683c2a1b4f8e1a2b3c4d5e6f", Name: "confluence_to_vdb", Version: 2, Status: "READY", Mode: "draft", TaskNames: []string{"create_vector_database"}, - CreatedAt: time.Date(2026, 4, 28, 12, 24, 54, 0, time.UTC), + CreatedAt: pipelines.Time{Time: time.Date(2026, 4, 28, 12, 24, 54, 0, time.UTC)}, } } diff --git a/cmd/pipelines/version/versionutil/render_test.go b/cmd/pipelines/version/versionutil/render_test.go index 0b0abc9ff..c4f3fa3d7 100644 --- a/cmd/pipelines/version/versionutil/render_test.go +++ b/cmd/pipelines/version/versionutil/render_test.go @@ -54,7 +54,7 @@ func sampleVersion() pipelines.PipelineVersion { PipelineName: "wf", TaskNames: []string{"e1", "e2"}, PythonVersion: "3.12", - CreatedAt: time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC), + CreatedAt: pipelines.Time{Time: time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC)}, } } diff --git a/docs/commands/pipelines.md b/docs/commands/pipelines.md index 976ff0c31..5cc80b4b7 100644 --- a/docs/commands/pipelines.md +++ b/docs/commands/pipelines.md @@ -120,7 +120,7 @@ dr pipelines create --from-file= [flags] ```bash $ dr pipelines create ./confluence_to_vdb.py --description "test" -Pipeline ID: 6658f441-a8f5-4f21-b4d8-6cccf4c94c5b +Pipeline ID: 683c2a1b4f8e1a2b3c4d5e6f Name: confluence_to_vdb Version: 1 Status: READY @@ -152,12 +152,12 @@ $ dr pipelines list Showing 1 of 1 (offset=0 limit=50) ID NAME MODE ACTIVE VERSION UPDATED -6658f441-a8f5-4f21-b4d8-6cccf4c94c5b confluence_to_vdb draft true v3 2026-04-28T12:25:11Z +683c2a1b4f8e1a2b3c4d5e6f confluence_to_vdb draft true v3 2026-04-28T12:25:11Z ``` | Column | Meaning | |-----------|-----------------------------------------------------------------| -| `ID` | Pipeline UUID, used as the argument to `get` / `update` / etc. | +| `ID` | Pipeline ObjectId, used as the argument to `get` / `update` / etc. | | `NAME` | Pipeline name extracted from the originally uploaded file. | | `MODE` | `draft` (mutable) or `locked` (immutable). | | `ACTIVE` | `true` while the pipeline has not been soft-deleted. | @@ -174,7 +174,7 @@ dr pipelines get [flags] **Arguments:** -- `` — the UUID returned by `create` / shown in `pipelines list`. +- `` — the ObjectId returned by `create` / shown in `pipelines list`. **Flags:** @@ -183,8 +183,8 @@ dr pipelines get [flags] **Example:** ```bash -$ dr pipelines get 6658f441-a8f5-4f21-b4d8-6cccf4c94c5b -ID: 6658f441-a8f5-4f21-b4d8-6cccf4c94c5b +$ dr pipelines get 683c2a1b4f8e1a2b3c4d5e6f +ID: 683c2a1b4f8e1a2b3c4d5e6f Name: confluence_to_vdb Description: test Mode: draft @@ -218,7 +218,7 @@ dr pipelines update --from-file= [flags] **Arguments:** -- `` — the UUID of the pipeline to update. +- `` — the ObjectId of the pipeline to update. - `` — path to the updated `.py` file. Mutually exclusive with `--from-file`. @@ -244,13 +244,13 @@ dr pipelines delete **Arguments:** -- `` — the UUID of the pipeline to delete. +- `` — the ObjectId of the pipeline to delete. **Example:** ```bash -$ dr pipelines delete 6658f441-a8f5-4f21-b4d8-6cccf4c94c5b -Deleted pipeline: 6658f441-a8f5-4f21-b4d8-6cccf4c94c5b +$ dr pipelines delete 683c2a1b4f8e1a2b3c4d5e6f +Deleted pipeline: 683c2a1b4f8e1a2b3c4d5e6f ``` If the pipeline doesn't exist, `delete` prints @@ -268,7 +268,7 @@ dr pipelines lock [flags] **Arguments:** -- `` — the UUID of the pipeline to lock. +- `` — the ObjectId of the pipeline to lock. **Flags:** @@ -277,8 +277,8 @@ dr pipelines lock [flags] **Example:** ```bash -$ dr pipelines lock 6658f441-a8f5-4f21-b4d8-6cccf4c94c5b -Pipeline ID: 6658f441-a8f5-4f21-b4d8-6cccf4c94c5b +$ dr pipelines lock 683c2a1b4f8e1a2b3c4d5e6f +Pipeline ID: 683c2a1b4f8e1a2b3c4d5e6f Name: confluence_to_vdb Mode: locked Version: v3 @@ -472,12 +472,12 @@ scenarios. ## Local development While iterating against a locally running pipelines-api (default port -`8000`), point the CLI at `http://localhost:8000` and bypass token +`8100`), point the CLI at `http://localhost:8100` and bypass token verification using the prefixed environment variables: ```bash export DATAROBOT_CLI_FEATURE_PIPELINES=true -export DATAROBOT_CLI_ENDPOINT=http://localhost:8000/api/v2 +export DATAROBOT_CLI_ENDPOINT=http://localhost:8100/api/v2 export DATAROBOT_CLI_TOKEN=local export DATAROBOT_CLI_SKIP_AUTH=true diff --git a/internal/pipelines/pipeline.go b/internal/pipelines/pipeline.go index dbfc19e08..67684a1c0 100644 --- a/internal/pipelines/pipeline.go +++ b/internal/pipelines/pipeline.go @@ -56,7 +56,7 @@ type PipelineVersion struct { PythonVersion string `json:"python_version"` ResourceBundle map[string]any `json:"resource_bundle,omitempty"` ErrorDetail string `json:"error_detail,omitempty"` - CreatedAt time.Time `json:"created_at"` + CreatedAt Time `json:"created_at"` } // Pipeline mirrors PipelineDetailResponse from the pipelines-api. @@ -66,8 +66,8 @@ type Pipeline struct { Description string `json:"description,omitempty"` Mode string `json:"mode"` IsActive bool `json:"is_active"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedAt Time `json:"created_at"` + UpdatedAt Time `json:"updated_at"` Versions []PipelineVersion `json:"versions"` } @@ -83,7 +83,7 @@ type CreateResponse struct { Status string `json:"status"` Mode string `json:"mode"` TaskNames []string `json:"electron_names,omitempty"` - CreatedAt time.Time `json:"created_at"` + CreatedAt Time `json:"created_at"` } // ListItem mirrors PipelineListItem from the pipelines-api. @@ -94,8 +94,8 @@ type ListItem struct { Mode string `json:"mode"` IsActive bool `json:"is_active"` LatestVersion *int `json:"latest_version,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedAt Time `json:"created_at"` + UpdatedAt Time `json:"updated_at"` } // ListResponse mirrors PipelineListResponse from the pipelines-api. diff --git a/internal/pipelines/time.go b/internal/pipelines/time.go new file mode 100644 index 000000000..06c8c4d29 --- /dev/null +++ b/internal/pipelines/time.go @@ -0,0 +1,54 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipelines + +import ( + "fmt" + "strings" + "time" +) + +// Time wraps time.Time to accept the pipelines-api's naive UTC datetime strings +// (e.g. "2026-05-20T15:39:57.913317") in addition to standard RFC3339. Bare +// strings — those without a Z or ±HH:MM offset — are assumed to be UTC. +// All time.Time methods are promoted, so callers can use .UTC().Format(...) +// as normal. +type Time struct { + time.Time +} + +func (t *Time) UnmarshalJSON(data []byte) error { + s := strings.Trim(string(data), `"`) + if s == "" || s == "null" { + t.Time = time.Time{} + + return nil + } + + if parsed, err := time.Parse(time.RFC3339Nano, s); err == nil { + t.Time = parsed + + return nil + } + + // No timezone indicator — treat as UTC. + if parsed, err := time.Parse(time.RFC3339Nano, s+"Z"); err == nil { + t.Time = parsed + + return nil + } + + return fmt.Errorf("pipelines: cannot parse time %q", s) +} diff --git a/internal/pipelines/version.go b/internal/pipelines/version.go index 41cd1e768..50b848382 100644 --- a/internal/pipelines/version.go +++ b/internal/pipelines/version.go @@ -34,29 +34,33 @@ import ( "github.com/datarobot/cli/internal/config" ) -// GraphNode mirrors a node entry in the JSON returned by the graph -// endpoint. The wire-level type value is currently "lattice" or "electron"; -// in DataRobot terminology these correspond to "pipeline" and "task". The -// JSON tag is preserved as-is until the server renames the field. +// GraphNode mirrors PipelineGraphNode from the graph endpoint. IDs are +// integer indices assigned by Covalent's TransportGraph. type GraphNode struct { - ID string `json:"id"` - Type string `json:"type"` - Name string `json:"name"` + ID int `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Source *string `json:"source,omitempty"` + ResourceBundle any `json:"resource_bundle,omitempty"` + TaskGroupID any `json:"task_group_id,omitempty"` } -// GraphEdge mirrors an edge entry in the JSON returned by the graph -// endpoint. +// GraphEdge mirrors PipelineGraphEdge. Source and Target are integer node IDs. +// EdgeName, ParamType, and ArgIndex carry Covalent transport metadata. type GraphEdge struct { - Source string `json:"source"` - Target string `json:"target"` + Source int `json:"source"` + Target int `json:"target"` + EdgeName string `json:"edge_name,omitempty"` + ParamType string `json:"param_type,omitempty"` + ArgIndex int `json:"arg_index,omitempty"` } -// GraphPipeline mirrors the pipeline (formerly "lattice") header entry of -// the graph payload. JSON tag remains `lattice` while the API wire format -// is unchanged. +// GraphPipeline mirrors PipelineGraphLattice — the pipeline header in the +// graph payload. JSON tag remains `lattice` while the API wire format is +// unchanged. type GraphPipeline struct { - ID string `json:"id"` - Name string `json:"name"` + Name string `json:"name"` + PythonVersion string `json:"python_version"` } // Graph mirrors the JSON returned by GET /pipelines/{id}/graph and diff --git a/internal/pipelines/version_test.go b/internal/pipelines/version_test.go index 49b309fed..26633d876 100644 --- a/internal/pipelines/version_test.go +++ b/internal/pipelines/version_test.go @@ -72,8 +72,8 @@ func TestGetGraph_DraftURL(t *testing.T) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ - "lattice": {"id":"l-0","name":"wf"}, - "nodes": [{"id":"l-0","type":"lattice","name":"wf"}], + "lattice": {"name":"wf","python_version":"3.12"}, + "nodes": [{"id":0,"type":"function","name":"wf"}], "edges": [] }`)) })) @@ -86,7 +86,7 @@ func TestGetGraph_DraftURL(t *testing.T) { require.NoError(t, err) assert.Equal(t, "wf", got.Pipeline.Name) require.Len(t, got.Nodes, 1) - assert.Equal(t, "lattice", got.Nodes[0].Type) + assert.Equal(t, "function", got.Nodes[0].Type) } func TestGetGraph_LockedURL(t *testing.T) { @@ -96,7 +96,7 @@ func TestGetGraph_LockedURL(t *testing.T) { assert.Equal(t, "/api/v2/pipelines/p-1/versions/3/graph", r.URL.Path) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"lattice":{"id":"l-0","name":"wf"},"nodes":[],"edges":[]}`)) + _, _ = w.Write([]byte(`{"lattice":{"name":"wf","python_version":"3.12"},"nodes":[],"edges":[]}`)) })) defer srv.Close() From d21e138d21a60cbfca1dafa7b21976996474ab97 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Wed, 20 May 2026 12:42:04 -0400 Subject: [PATCH 14/25] add SKIP_ENVIRONMENTS flag to demo-pipelines task Covalent is not available in the local port-forward environment, so environment create/update time out. Mirrors the existing SKIP_SCHEDULES flag. Both default to false; pass SKIP_ENVIRONMENTS=true to skip the 8 environment steps (total drops to 27 when combined with SKIP_SCHEDULES). Co-Authored-By: Claude Sonnet 4.6 --- Taskfile.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Taskfile.yaml b/Taskfile.yaml index 493dcb508..516cee489 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -156,7 +156,7 @@ tasks: - ./smoke_test_scripts/run_smoke_test.sh {{if .DR_API_TOKEN}}{{.DR_API_TOKEN}}{{else}}$DR_API_TOKEN{{end}} demo-pipelines: - desc: "End-to-end demo of every dr pipelines command (40 steps incl. environment lifecycle). Defaults to interactive (keypress per step); pass DELAY=N or `-- --delay=N` for auto-advance." + desc: "End-to-end demo of every dr pipelines command. Defaults to interactive (keypress per step); pass DELAY=N or `-- --delay=N` for auto-advance. Set SKIP_SCHEDULES=true or SKIP_ENVIRONMENTS=true to skip steps that require k8s/Covalent." deps: [build] vars: DEMO_DIR: '{{default "/Users/sunnypal.sharma/Desktop/tests/pipelines-api" .DEMO_DIR}}' @@ -166,6 +166,8 @@ tasks: DATAROBOT_CLI_ENDPOINT: "http://localhost:8100/api/v2" DATAROBOT_CLI_TOKEN: "local" DATAROBOT_CLI_SKIP_AUTH: "true" + DEMO_SKIP_SCHEDULES: '{{if .SKIP_SCHEDULES}}true{{else}}false{{end}}' + DEMO_SKIP_ENVIRONMENTS: '{{if .SKIP_ENVIRONMENTS}}true{{else}}false{{end}}' cmds: - DR_BIN="$PWD/dist/dr" DEMO_DIR="{{.DEMO_DIR}}" bash "{{.DEMO_DIR}}/demo.sh" {{.DELAY_FLAG}} {{.CLI_ARGS}} From 99cfae113f0de62fe10f75da09b5d8e656f9ae1c Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Fri, 22 May 2026 15:42:46 -0400 Subject: [PATCH 15/25] [CMPT-5391] remove custom Time type; pipe demo output to output.txt API now returns RFC3339Nano timestamps with Z suffix, so the custom pipeline.Time wrapper is no longer needed. Replace all Time fields in pipeline.go with standard time.Time and delete time.go. Also default DEMO_SKIP_SCHEDULES and DEMO_SKIP_ENVIRONMENTS to true in the demo-pipelines Taskfile task (k8s/Covalent not available locally), and pipe demo output to output.txt via tee. Co-Authored-By: Claude Sonnet 4.6 --- Taskfile.yaml | 6 ++-- internal/pipelines/pipeline.go | 12 ++++---- internal/pipelines/time.go | 54 ---------------------------------- 3 files changed, 9 insertions(+), 63 deletions(-) delete mode 100644 internal/pipelines/time.go diff --git a/Taskfile.yaml b/Taskfile.yaml index 516cee489..a10c4423f 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -166,10 +166,10 @@ tasks: DATAROBOT_CLI_ENDPOINT: "http://localhost:8100/api/v2" DATAROBOT_CLI_TOKEN: "local" DATAROBOT_CLI_SKIP_AUTH: "true" - DEMO_SKIP_SCHEDULES: '{{if .SKIP_SCHEDULES}}true{{else}}false{{end}}' - DEMO_SKIP_ENVIRONMENTS: '{{if .SKIP_ENVIRONMENTS}}true{{else}}false{{end}}' + DEMO_SKIP_SCHEDULES: '{{if eq .SKIP_SCHEDULES "false"}}false{{else}}true{{end}}' + DEMO_SKIP_ENVIRONMENTS: '{{if eq .SKIP_ENVIRONMENTS "false"}}false{{else}}true{{end}}' cmds: - - DR_BIN="$PWD/dist/dr" DEMO_DIR="{{.DEMO_DIR}}" bash "{{.DEMO_DIR}}/demo.sh" {{.DELAY_FLAG}} {{.CLI_ARGS}} + - DR_BIN="$PWD/dist/dr" DEMO_DIR="{{.DEMO_DIR}}" bash "{{.DEMO_DIR}}/demo.sh" {{.DELAY_FLAG}} {{.CLI_ARGS}} 2>&1 | tee output.txt smoke-test-self-update: desc: "Run self-update smoke tests" diff --git a/internal/pipelines/pipeline.go b/internal/pipelines/pipeline.go index 67684a1c0..1fb0d4fae 100644 --- a/internal/pipelines/pipeline.go +++ b/internal/pipelines/pipeline.go @@ -56,7 +56,7 @@ type PipelineVersion struct { PythonVersion string `json:"python_version"` ResourceBundle map[string]any `json:"resource_bundle,omitempty"` ErrorDetail string `json:"error_detail,omitempty"` - CreatedAt Time `json:"created_at"` + CreatedAt time.Time `json:"created_at"` } // Pipeline mirrors PipelineDetailResponse from the pipelines-api. @@ -66,8 +66,8 @@ type Pipeline struct { Description string `json:"description,omitempty"` Mode string `json:"mode"` IsActive bool `json:"is_active"` - CreatedAt Time `json:"created_at"` - UpdatedAt Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` Versions []PipelineVersion `json:"versions"` } @@ -83,7 +83,7 @@ type CreateResponse struct { Status string `json:"status"` Mode string `json:"mode"` TaskNames []string `json:"electron_names,omitempty"` - CreatedAt Time `json:"created_at"` + CreatedAt time.Time `json:"created_at"` } // ListItem mirrors PipelineListItem from the pipelines-api. @@ -94,8 +94,8 @@ type ListItem struct { Mode string `json:"mode"` IsActive bool `json:"is_active"` LatestVersion *int `json:"latest_version,omitempty"` - CreatedAt Time `json:"created_at"` - UpdatedAt Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // ListResponse mirrors PipelineListResponse from the pipelines-api. diff --git a/internal/pipelines/time.go b/internal/pipelines/time.go deleted file mode 100644 index 06c8c4d29..000000000 --- a/internal/pipelines/time.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright 2026 DataRobot, Inc. and its affiliates. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package pipelines - -import ( - "fmt" - "strings" - "time" -) - -// Time wraps time.Time to accept the pipelines-api's naive UTC datetime strings -// (e.g. "2026-05-20T15:39:57.913317") in addition to standard RFC3339. Bare -// strings — those without a Z or ±HH:MM offset — are assumed to be UTC. -// All time.Time methods are promoted, so callers can use .UTC().Format(...) -// as normal. -type Time struct { - time.Time -} - -func (t *Time) UnmarshalJSON(data []byte) error { - s := strings.Trim(string(data), `"`) - if s == "" || s == "null" { - t.Time = time.Time{} - - return nil - } - - if parsed, err := time.Parse(time.RFC3339Nano, s); err == nil { - t.Time = parsed - - return nil - } - - // No timezone indicator — treat as UTC. - if parsed, err := time.Parse(time.RFC3339Nano, s+"Z"); err == nil { - t.Time = parsed - - return nil - } - - return fmt.Errorf("pipelines: cannot parse time %q", s) -} From b2f5cb7516ce0f4d60c9525e023530f1fabcc073 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Fri, 22 May 2026 16:16:21 -0400 Subject: [PATCH 16/25] [CMPT-5391] use time.Time for all pipeline datetime fields API is now RFC3339 compliant; replace string datetime fields with time.Time across run, input, schedule, and environment structs. Update render helpers to format with .UTC().Format(time.RFC3339), consistent with the pipeline/version commands. Co-Authored-By: Claude Sonnet 4.6 --- cmd/pipelines/environment/envutil/render.go | 9 +++++---- cmd/pipelines/input/inpututil/render.go | 7 ++++--- cmd/pipelines/run/runutil/render.go | 11 ++++++----- cmd/pipelines/schedule/scheduleutil/render.go | 7 ++++--- internal/pipelines/environment.go | 13 +++++++------ internal/pipelines/input.go | 5 +++-- internal/pipelines/run.go | 5 +++-- internal/pipelines/schedule.go | 5 +++-- 8 files changed, 35 insertions(+), 27 deletions(-) diff --git a/cmd/pipelines/environment/envutil/render.go b/cmd/pipelines/environment/envutil/render.go index b8bd3084e..8302dc1d9 100644 --- a/cmd/pipelines/environment/envutil/render.go +++ b/cmd/pipelines/environment/envutil/render.go @@ -25,6 +25,7 @@ import ( "strconv" "strings" "text/tabwriter" + "time" "github.com/datarobot/cli/internal/pipelines" "github.com/datarobot/cli/tui" @@ -47,8 +48,8 @@ func PrintEnvironmentHuman(env pipelines.Environment) { fmt.Println(tui.BaseTextStyle.Render("Name: " + env.Name)) fmt.Println(tui.BaseTextStyle.Render("Description: " + desc)) fmt.Println(tui.BaseTextStyle.Render("Latest version: v" + strconv.Itoa(env.LatestVersion))) - fmt.Println(tui.DimStyle.Render("Created: " + env.CreatedAt)) - fmt.Println(tui.DimStyle.Render("Updated: " + env.UpdatedAt)) + fmt.Println(tui.DimStyle.Render("Created: " + env.CreatedAt.UTC().Format(time.RFC3339))) + fmt.Println(tui.DimStyle.Render("Updated: " + env.UpdatedAt.UTC().Format(time.RFC3339))) if len(env.Versions) == 0 { return @@ -63,7 +64,7 @@ func PrintEnvironmentHuman(env pipelines.Environment) { for _, ver := range env.Versions { fmt.Fprintf(writer, "v%d\t%s\t%s\t%s\n", - ver.Version, ver.Status, joinPackages(ver.Packages), ver.UpdatedAt, + ver.Version, ver.Status, joinPackages(ver.Packages), ver.UpdatedAt.UTC().Format(time.RFC3339), ) } @@ -89,7 +90,7 @@ func PrintEnvironmentListHuman(items []pipelines.EnvironmentSummary) { for _, env := range items { fmt.Fprintf(writer, "%s\t%s\tv%d\t%s\t%s\n", - env.EnvironmentID, env.Name, env.LatestVersion, env.LatestStatus, env.UpdatedAt, + env.EnvironmentID, env.Name, env.LatestVersion, env.LatestStatus, env.UpdatedAt.UTC().Format(time.RFC3339), ) } diff --git a/cmd/pipelines/input/inpututil/render.go b/cmd/pipelines/input/inpututil/render.go index 84b8dbcc2..cd936ffc7 100644 --- a/cmd/pipelines/input/inpututil/render.go +++ b/cmd/pipelines/input/inpututil/render.go @@ -23,6 +23,7 @@ import ( "os" "strconv" "text/tabwriter" + "time" "github.com/datarobot/cli/internal/pipelines" "github.com/datarobot/cli/tui" @@ -55,8 +56,8 @@ func PrintInputHuman(input pipelines.Input) { fmt.Println(tui.BaseTextStyle.Render("Scope: " + scope)) fmt.Println(tui.BaseTextStyle.Render("Version: " + versionDisplay)) fmt.Println(tui.BaseTextStyle.Render("State: " + string(input.State))) - fmt.Println(tui.DimStyle.Render("Created: " + input.CreatedAt)) - fmt.Println(tui.DimStyle.Render("Updated: " + input.UpdatedAt)) + fmt.Println(tui.DimStyle.Render("Created: " + input.CreatedAt.UTC().Format(time.RFC3339))) + fmt.Println(tui.DimStyle.Render("Updated: " + input.UpdatedAt.UTC().Format(time.RFC3339))) payload, err := json.MarshalIndent(input.Payload, "", " ") if err != nil { @@ -102,7 +103,7 @@ func PrintInputListHuman(inputs []pipelines.Input) { } fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\n", - in.InputID, scope, ver, in.State, in.UpdatedAt, + in.InputID, scope, ver, in.State, in.UpdatedAt.UTC().Format(time.RFC3339), ) } diff --git a/cmd/pipelines/run/runutil/render.go b/cmd/pipelines/run/runutil/render.go index a8e3bc26b..779290385 100644 --- a/cmd/pipelines/run/runutil/render.go +++ b/cmd/pipelines/run/runutil/render.go @@ -24,6 +24,7 @@ import ( "os" "strconv" "text/tabwriter" + "time" "github.com/datarobot/cli/internal/pipelines" "github.com/datarobot/cli/tui" @@ -56,8 +57,8 @@ func toRunJSON(r pipelines.Run) runJSON { TriggeredBy: r.TriggeredBy, Status: r.Status, ErrorDetail: r.ErrorDetail, - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, + CreatedAt: r.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: r.UpdatedAt.UTC().Format(time.RFC3339), } } @@ -116,8 +117,8 @@ func PrintRunHuman(r pipelines.Run) { fmt.Println(tui.BaseTextStyle.Render("Error: " + r.ErrorDetail)) } - fmt.Println(tui.DimStyle.Render("Created: " + r.CreatedAt)) - fmt.Println(tui.DimStyle.Render("Updated: " + r.UpdatedAt)) + fmt.Println(tui.DimStyle.Render("Created: " + r.CreatedAt.UTC().Format(time.RFC3339))) + fmt.Println(tui.DimStyle.Render("Updated: " + r.UpdatedAt.UTC().Format(time.RFC3339))) } // PrintRunListJSON marshals a list of runs as indented JSON using @@ -160,7 +161,7 @@ func PrintRunListHuman(items []pipelines.Run) { } fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\t%s\n", - r.RunID, scope, ver, r.Status, r.TriggeredBy, r.UpdatedAt, + r.RunID, scope, ver, r.Status, r.TriggeredBy, r.UpdatedAt.UTC().Format(time.RFC3339), ) } diff --git a/cmd/pipelines/schedule/scheduleutil/render.go b/cmd/pipelines/schedule/scheduleutil/render.go index 0dd02c374..5507bfd25 100644 --- a/cmd/pipelines/schedule/scheduleutil/render.go +++ b/cmd/pipelines/schedule/scheduleutil/render.go @@ -24,6 +24,7 @@ import ( "os" "strconv" "text/tabwriter" + "time" "github.com/datarobot/cli/internal/pipelines" "github.com/datarobot/cli/tui" @@ -49,8 +50,8 @@ func PrintScheduleHuman(s pipelines.Schedule) { fmt.Println(tui.BaseTextStyle.Render("Cron: " + s.CronExpression)) fmt.Println(tui.BaseTextStyle.Render("Timezone: " + s.Timezone)) fmt.Println(tui.BaseTextStyle.Render("Status: " + string(s.Status))) - fmt.Println(tui.DimStyle.Render("Created: " + s.CreatedAt)) - fmt.Println(tui.DimStyle.Render("Updated: " + s.UpdatedAt)) + fmt.Println(tui.DimStyle.Render("Created: " + s.CreatedAt.UTC().Format(time.RFC3339))) + fmt.Println(tui.DimStyle.Render("Updated: " + s.UpdatedAt.UTC().Format(time.RFC3339))) } // PrintScheduleListJSON marshals a list of schedules as indented JSON. @@ -79,7 +80,7 @@ func PrintScheduleListHuman(items []pipelines.Schedule) { for _, s := range items { fmt.Fprintf(writer, "%s\tv%d\t%s\t%s\t%s\t%s\n", - s.ScheduleID, s.Version, s.CronExpression, s.Timezone, s.Status, s.UpdatedAt, + s.ScheduleID, s.Version, s.CronExpression, s.Timezone, s.Status, s.UpdatedAt.UTC().Format(time.RFC3339), ) } diff --git a/internal/pipelines/environment.go b/internal/pipelines/environment.go index 0c1f57b80..dfdae2f46 100644 --- a/internal/pipelines/environment.go +++ b/internal/pipelines/environment.go @@ -33,6 +33,7 @@ import ( "net/http" "net/url" "strconv" + "time" "github.com/datarobot/cli/internal/config" ) @@ -52,8 +53,8 @@ type EnvironmentVersion struct { Packages []string `json:"packages"` Status EnvironmentStatus `json:"status"` ErrorDetail *string `json:"error_detail,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // Environment mirrors PipelineEnvironmentResponse (full detail). @@ -63,8 +64,8 @@ type Environment struct { Description *string `json:"description,omitempty"` LatestVersion int `json:"latest_version"` Versions []EnvironmentVersion `json:"versions"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // EnvironmentSummary mirrors PipelineEnvironmentSummaryResponse (list item). @@ -74,8 +75,8 @@ type EnvironmentSummary struct { Description *string `json:"description,omitempty"` LatestVersion int `json:"latest_version"` LatestStatus EnvironmentStatus `json:"latest_status"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // EnvironmentCreateRequest mirrors PipelineEnvironmentCreateRequest. diff --git a/internal/pipelines/input.go b/internal/pipelines/input.go index 645f54d75..56af0e032 100644 --- a/internal/pipelines/input.go +++ b/internal/pipelines/input.go @@ -22,6 +22,7 @@ import ( "net/http" "net/url" "strconv" + "time" ) // InputState mirrors PipelineInputState in the pipelines-api enums. @@ -40,8 +41,8 @@ type Input struct { IsDraft bool `json:"is_draft"` Payload map[string]any `json:"payload"` State InputState `json:"state"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // InputCreateRequest mirrors PipelineInputCreateRequest. diff --git a/internal/pipelines/run.go b/internal/pipelines/run.go index ce7908ad1..f32f222a2 100644 --- a/internal/pipelines/run.go +++ b/internal/pipelines/run.go @@ -27,6 +27,7 @@ import ( "net/http" "net/url" "strconv" + "time" ) // Run lifecycle states (mirrors PipelineDispatchStatus on the wire). @@ -51,8 +52,8 @@ type Run struct { TriggeredBy string `json:"triggered_by"` Status string `json:"status"` ErrorDetail string `json:"error_detail,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // RunStatus mirrors PipelineDispatchStatusResponse — the lightweight diff --git a/internal/pipelines/schedule.go b/internal/pipelines/schedule.go index 8f3779511..aa18d335b 100644 --- a/internal/pipelines/schedule.go +++ b/internal/pipelines/schedule.go @@ -23,6 +23,7 @@ import ( "net/http" "net/url" "strconv" + "time" ) // ScheduleStatus mirrors PipelineScheduleStatus in the pipelines-api enums. @@ -42,8 +43,8 @@ type Schedule struct { CronExpression string `json:"cron_expression"` Timezone string `json:"timezone"` Status ScheduleStatus `json:"status"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // ScheduleCreateRequest mirrors PipelineScheduleCreateRequest. From 3d77d0fec6d71471bb069a2cb16f7a0b324f2809 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Fri, 22 May 2026 17:02:28 -0400 Subject: [PATCH 17/25] [CMPT-5391] align pipelines rendering with workload pattern - Add cmd/pipelines/outputfmt: OutputFormat type + AddOutputFlag() with --output-format flag, mirroring internal/workload/flags.go - Add cmd/pipelines/pipelineutil: RenderPipeline/RenderPipelines with lipgloss/table list view and tabwriter detail view - Rewrite all *util/render.go files: add Render*() routing functions, replace tabwriter lists with lipgloss/table, switch to tabwriter for detail views, add DTOs for input/schedule/environment/version, adopt "2006-01-02 15:04 UTC" timestamp format - Update all 23 verb cmd files: use outputfmt.AddOutputFlag, remove scattered --output StringVar and manual validation, call Render*() Co-Authored-By: Claude Sonnet 4.6 --- cmd/pipelines/create/cmd.go | 53 +--- cmd/pipelines/create/cmd_test.go | 22 +- cmd/pipelines/environment/create/cmd.go | 20 +- cmd/pipelines/environment/create/cmd_test.go | 4 +- cmd/pipelines/environment/envutil/render.go | 226 +++++++++++++--- .../environment/envutil/render_test.go | 15 +- cmd/pipelines/environment/list/cmd.go | 21 +- cmd/pipelines/environment/list/cmd_test.go | 4 +- cmd/pipelines/environment/update/cmd.go | 21 +- cmd/pipelines/environment/update/cmd_test.go | 4 +- cmd/pipelines/get/cmd.go | 95 +------ cmd/pipelines/get/cmd_test.go | 48 ++-- cmd/pipelines/graph/cmd.go | 13 +- cmd/pipelines/graph/cmd_test.go | 2 +- cmd/pipelines/input/create/cmd.go | 20 +- cmd/pipelines/input/create/cmd_test.go | 4 +- cmd/pipelines/input/get/cmd.go | 19 +- cmd/pipelines/input/get/cmd_test.go | 2 +- cmd/pipelines/input/inpututil/render.go | 134 ++++++++-- cmd/pipelines/input/inpututil/render_test.go | 23 +- cmd/pipelines/input/list/cmd.go | 20 +- cmd/pipelines/input/list/cmd_test.go | 4 +- cmd/pipelines/input/update/cmd.go | 20 +- cmd/pipelines/input/update/cmd_test.go | 4 +- cmd/pipelines/list/cmd.go | 69 +---- cmd/pipelines/list/cmd_test.go | 22 +- cmd/pipelines/lock/cmd.go | 53 +--- cmd/pipelines/lock/cmd_test.go | 22 +- cmd/pipelines/outputfmt/flags.go | 66 +++++ cmd/pipelines/pipelineutil/render.go | 252 ++++++++++++++++++ cmd/pipelines/run/create/cmd.go | 20 +- cmd/pipelines/run/create/cmd_test.go | 4 +- cmd/pipelines/run/get/cmd.go | 19 +- cmd/pipelines/run/get/cmd_test.go | 2 +- cmd/pipelines/run/list/cmd.go | 20 +- cmd/pipelines/run/list/cmd_test.go | 4 +- cmd/pipelines/run/runutil/render.go | 121 +++++++-- cmd/pipelines/run/runutil/render_test.go | 38 +-- cmd/pipelines/run/status/cmd.go | 19 +- cmd/pipelines/run/status/cmd_test.go | 2 +- cmd/pipelines/schedule/create/cmd.go | 18 +- cmd/pipelines/schedule/create/cmd_test.go | 4 +- cmd/pipelines/schedule/get/cmd.go | 19 +- cmd/pipelines/schedule/get/cmd_test.go | 2 +- cmd/pipelines/schedule/list/cmd.go | 20 +- cmd/pipelines/schedule/list/cmd_test.go | 4 +- cmd/pipelines/schedule/scheduleutil/render.go | 128 +++++++-- .../schedule/scheduleutil/render_test.go | 7 +- cmd/pipelines/schedule/update/cmd.go | 22 +- cmd/pipelines/schedule/update/cmd_test.go | 18 +- cmd/pipelines/update/cmd.go | 53 +--- cmd/pipelines/update/cmd_test.go | 26 +- cmd/pipelines/version/get/cmd.go | 19 +- cmd/pipelines/version/list/cmd.go | 20 +- cmd/pipelines/version/list/cmd_test.go | 4 +- cmd/pipelines/version/versionutil/render.go | 130 +++++++-- .../version/versionutil/render_test.go | 29 +- internal/pipelines/pipeline.go | 12 +- internal/pipelines/run.go | 16 +- 59 files changed, 1250 insertions(+), 832 deletions(-) create mode 100644 cmd/pipelines/outputfmt/flags.go create mode 100644 cmd/pipelines/pipelineutil/render.go diff --git a/cmd/pipelines/create/cmd.go b/cmd/pipelines/create/cmd.go index d6a3476d5..fe405c311 100644 --- a/cmd/pipelines/create/cmd.go +++ b/cmd/pipelines/create/cmd.go @@ -15,16 +15,13 @@ package create import ( - "encoding/json" "errors" "fmt" - "strconv" - "strings" - "time" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" + "github.com/datarobot/cli/cmd/pipelines/pipelineutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" - "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -32,7 +29,7 @@ func Cmd() *cobra.Command { var ( description string mode string - outputFormat string + outputFormat outputfmt.OutputFormat fromFile string ) @@ -42,7 +39,7 @@ func Cmd() *cobra.Command { Long: `Upload a Python file containing a DataRobot pipeline (one or more tasks) to register a new pipeline. The pipeline name is extracted from the file and used as the pipeline's resource name. -By default, output is human-readable. Use --output json for machine-parseable output. +By default, output is human-readable. Use --output-format json for machine-parseable output. The path to the Python file can be supplied either as a positional argument or via the --from-file= flag. Exactly one of the two must be provided. @@ -51,14 +48,10 @@ Example: dr pipelines create ./my_pipeline.py dr pipelines create --from-file=./my_pipeline.py dr pipelines create ./my_pipeline.py --description "First draft" --mode draft - dr pipelines create --from-file=./my_pipeline.py --output json`, + dr pipelines create --from-file=./my_pipeline.py --output-format json`, Args: cobra.MaximumNArgs(1), PreRunE: auth.EnsureAuthenticatedE, RunE: func(_ *cobra.Command, args []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - if mode != "" && mode != pipelines.ModeDraft && mode != pipelines.ModeLocked { return fmt.Errorf("invalid mode: %s (supported: draft, locked)", mode) } @@ -73,20 +66,14 @@ Example: return err } - if outputFormat == "json" { - return printCreateJSON(*result) - } - - printCreateHuman(*result) - - return nil + return pipelineutil.RenderCreateResponse(outputFormat, *result) }, } cmd.Flags().StringVar(&description, "description", "", "Optional description for the pipeline") cmd.Flags().StringVar(&mode, "mode", "", "Pipeline mode: draft (default) or locked") - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the Python file to upload, e.g. --from-file=./my_pipeline.py (alternative to the positional argument)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } @@ -110,29 +97,3 @@ func resolveFilePath(args []string, fromFile string) (string, error) { return "", errors.New("a file path is required (positional argument or --from-file)") } } - -func printCreateJSON(result pipelines.CreateResponse) error { - data, err := json.MarshalIndent(result, "", " ") - if err != nil { - return err - } - - fmt.Println(string(data)) - - return nil -} - -func printCreateHuman(result pipelines.CreateResponse) { - tasks := "\u2014" - if len(result.TaskNames) > 0 { - tasks = strings.Join(result.TaskNames, ", ") - } - - fmt.Println(tui.BaseTextStyle.Render("Pipeline ID: " + result.PipelineID)) - fmt.Println(tui.BaseTextStyle.Render("Name: " + result.Name)) - fmt.Println(tui.BaseTextStyle.Render("Version: " + strconv.Itoa(result.Version))) - fmt.Println(tui.BaseTextStyle.Render("Status: " + result.Status)) - fmt.Println(tui.BaseTextStyle.Render("Mode: " + result.Mode)) - fmt.Println(tui.BaseTextStyle.Render("Tasks: " + tasks)) - fmt.Println(tui.DimStyle.Render("Created: " + result.CreatedAt.UTC().Format(time.RFC3339))) -} diff --git a/cmd/pipelines/create/cmd_test.go b/cmd/pipelines/create/cmd_test.go index feeeb5fa6..b368e9ac6 100644 --- a/cmd/pipelines/create/cmd_test.go +++ b/cmd/pipelines/create/cmd_test.go @@ -22,6 +22,8 @@ import ( "testing" "time" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" + "github.com/datarobot/cli/cmd/pipelines/pipelineutil" "github.com/datarobot/cli/internal/pipelines" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -55,7 +57,7 @@ func sampleCreateResponse() pipelines.CreateResponse { Status: "READY", Mode: "draft", TaskNames: []string{"create_vector_database", "ingest_confluence_files"}, - CreatedAt: pipelines.Time{Time: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC)}, + CreatedAt: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC), } } @@ -63,7 +65,7 @@ func TestPrintCreateJSON(t *testing.T) { resp := sampleCreateResponse() output := captureStdout(t, func() { - err := printCreateJSON(resp) + err := pipelineutil.RenderCreateResponse(outputfmt.OutputFormatJSON, resp) require.NoError(t, err) }) @@ -82,14 +84,14 @@ func TestPrintCreateHuman_WithTasks(t *testing.T) { resp := sampleCreateResponse() output := captureStdout(t, func() { - printCreateHuman(resp) + require.NoError(t, pipelineutil.RenderCreateResponse(outputfmt.OutputFormatText, resp)) }) assert.Contains(t, output, resp.PipelineID) assert.Contains(t, output, "confluence_to_vdb") - assert.Contains(t, output, "Version: 1") - assert.Contains(t, output, "Status: READY") - assert.Contains(t, output, "Mode: draft") + assert.Contains(t, output, "1") + assert.Contains(t, output, "READY") + assert.Contains(t, output, "draft") assert.Contains(t, output, "create_vector_database, ingest_confluence_files") } @@ -98,10 +100,10 @@ func TestPrintCreateHuman_NoTasks(t *testing.T) { resp.TaskNames = nil output := captureStdout(t, func() { - printCreateHuman(resp) + require.NoError(t, pipelineutil.RenderCreateResponse(outputfmt.OutputFormatText, resp)) }) - assert.Contains(t, output, "Tasks: \u2014") + assert.Contains(t, output, "—") } func TestCmd_RequiresFilePath(t *testing.T) { @@ -174,7 +176,7 @@ func TestResolveFilePath(t *testing.T) { func TestCmd_RejectsInvalidOutput(t *testing.T) { cmd := Cmd() - cmd.SetArgs([]string{"some-file.py", "--output", "yaml"}) + cmd.SetArgs([]string{"some-file.py", "--output-format", "yaml"}) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) cmd.PreRunE = nil // bypass auth @@ -199,7 +201,7 @@ func TestCmd_RejectsInvalidMode(t *testing.T) { func TestCmd_HasExpectedFlags(t *testing.T) { cmd := Cmd() - for _, name := range []string{"description", "mode", "output", "from-file"} { + for _, name := range []string{"description", "mode", "output-format", "from-file"} { flag := cmd.Flags().Lookup(name) assert.NotNilf(t, flag, "expected --%s flag to be registered", name) } diff --git a/cmd/pipelines/environment/create/cmd.go b/cmd/pipelines/environment/create/cmd.go index 561c760a4..99c931e05 100644 --- a/cmd/pipelines/environment/create/cmd.go +++ b/cmd/pipelines/environment/create/cmd.go @@ -16,9 +16,9 @@ package create import ( "errors" - "fmt" "github.com/datarobot/cli/cmd/pipelines/environment/envutil" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" "github.com/spf13/cobra" @@ -29,7 +29,7 @@ func Cmd() *cobra.Command { name string description string rawPackages []string - outputFormat string + outputFormat outputfmt.OutputFormat ) cmd := &cobra.Command{ @@ -43,15 +43,11 @@ pipelines once its first version reaches the READY state. Example: dr pipelines environment create --name ml-base --package numpy --package pandas - dr pipelines environment create --name ml-base --packages numpy,pandas==2.0 --description "training base" --output json`, + dr pipelines environment create --name ml-base --packages numpy,pandas==2.0 --description "training base" --output-format json`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(_ *cobra.Command, _ []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - if name == "" { return errors.New("--name is required") } @@ -66,20 +62,14 @@ Example: return err } - if outputFormat == "json" { - return envutil.PrintEnvironmentJSON(*result) - } - - envutil.PrintEnvironmentHuman(*result) - - return nil + return envutil.RenderEnvironment(outputFormat, *result) }, } cmd.Flags().StringVar(&name, "name", "", "Environment name (required)") cmd.Flags().StringVar(&description, "description", "", "Optional description") cmd.Flags().StringSliceVar(&rawPackages, "package", nil, "Pip package spec (repeatable, also accepts comma-separated values)") - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/environment/create/cmd_test.go b/cmd/pipelines/environment/create/cmd_test.go index f415f0489..76ccb1d12 100644 --- a/cmd/pipelines/environment/create/cmd_test.go +++ b/cmd/pipelines/environment/create/cmd_test.go @@ -35,7 +35,7 @@ func runCmd(t *testing.T, args ...string) error { } func TestCmd_RejectsInvalidOutput(t *testing.T) { - err := runCmd(t, "--name", "x", "--package", "numpy", "--output", "yaml") + err := runCmd(t, "--name", "x", "--package", "numpy", "--output-format", "yaml") require.Error(t, err) assert.Contains(t, err.Error(), "invalid output format") } @@ -55,7 +55,7 @@ func TestCmd_RejectsMissingPackages(t *testing.T) { func TestCmd_HasExpectedFlags(t *testing.T) { cmd := Cmd() - for _, name := range []string{"name", "description", "package", "output"} { + for _, name := range []string{"name", "description", "package", "output-format"} { assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) } } diff --git a/cmd/pipelines/environment/envutil/render.go b/cmd/pipelines/environment/envutil/render.go index 8302dc1d9..537a068d2 100644 --- a/cmd/pipelines/environment/envutil/render.go +++ b/cmd/pipelines/environment/envutil/render.go @@ -22,34 +22,145 @@ import ( "encoding/json" "fmt" "os" + "slices" "strconv" "strings" "text/tabwriter" "time" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/internal/pipelines" "github.com/datarobot/cli/tui" ) -// PrintEnvironmentJSON marshals an environment record as indented JSON. +const ( + timestampFormat = "2006-01-02 15:04 UTC" + emptyValuePlaceholder = "—" +) + +// environmentVersionJSON is the DTO for a single EnvironmentVersion in JSON output. +type environmentVersionJSON struct { + Version int `json:"version"` + Packages []string `json:"packages"` + Status string `json:"status"` + ErrorDetail *string `json:"error_detail,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// environmentJSON is the CLI-facing DTO for `--output-format json` of an Environment. +type environmentJSON struct { + EnvironmentID string `json:"environment_id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + LatestVersion int `json:"latest_version"` + Versions []environmentVersionJSON `json:"versions"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// environmentSummaryJSON is the CLI-facing DTO for `--output-format json` of an EnvironmentSummary. +type environmentSummaryJSON struct { + EnvironmentID string `json:"environment_id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + LatestVersion int `json:"latest_version"` + LatestStatus string `json:"latest_status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toEnvironmentJSON(env pipelines.Environment) environmentJSON { + versions := make([]environmentVersionJSON, len(env.Versions)) + + for i, v := range env.Versions { + versions[i] = environmentVersionJSON{ + Version: v.Version, + Packages: v.Packages, + Status: string(v.Status), + ErrorDetail: v.ErrorDetail, + CreatedAt: v.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: v.UpdatedAt.UTC().Format(time.RFC3339), + } + } + + return environmentJSON{ + EnvironmentID: env.EnvironmentID, + Name: env.Name, + Description: env.Description, + LatestVersion: env.LatestVersion, + Versions: versions, + CreatedAt: env.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: env.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +func toEnvironmentSummaryJSON(env pipelines.EnvironmentSummary) environmentSummaryJSON { + return environmentSummaryJSON{ + EnvironmentID: env.EnvironmentID, + Name: env.Name, + Description: env.Description, + LatestVersion: env.LatestVersion, + LatestStatus: string(env.LatestStatus), + CreatedAt: env.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: env.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +// RenderEnvironment routes a single environment to JSON or human output. +func RenderEnvironment(format outputfmt.OutputFormat, env pipelines.Environment) error { + if format == outputfmt.OutputFormatJSON { + return PrintEnvironmentJSON(env) + } + + PrintEnvironmentHuman(env) + + return nil +} + +// RenderEnvironments routes a list of environments to JSON or human output. +func RenderEnvironments(format outputfmt.OutputFormat, items []pipelines.EnvironmentSummary) error { + if format == outputfmt.OutputFormatJSON { + return PrintEnvironmentListJSON(items) + } + + PrintEnvironmentListHuman(items) + + return nil +} + +// PrintEnvironmentJSON marshals an environment record as indented JSON through the DTO. func PrintEnvironmentJSON(env pipelines.Environment) error { - return printJSON(env) + data, err := json.MarshalIndent(toEnvironmentJSON(env), "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil } // PrintEnvironmentHuman renders the key facts about a single environment // record, including its full version history. func PrintEnvironmentHuman(env pipelines.Environment) { - desc := "\u2014" + desc := emptyValuePlaceholder if env.Description != nil && *env.Description != "" { desc = *env.Description } - fmt.Println(tui.BaseTextStyle.Render("Environment ID: " + env.EnvironmentID)) - fmt.Println(tui.BaseTextStyle.Render("Name: " + env.Name)) - fmt.Println(tui.BaseTextStyle.Render("Description: " + desc)) - fmt.Println(tui.BaseTextStyle.Render("Latest version: v" + strconv.Itoa(env.LatestVersion))) - fmt.Println(tui.DimStyle.Render("Created: " + env.CreatedAt.UTC().Format(time.RFC3339))) - fmt.Println(tui.DimStyle.Render("Updated: " + env.UpdatedAt.UTC().Format(time.RFC3339))) + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Environment ID:\t%s\n", env.EnvironmentID) + fmt.Fprintf(w, "Name:\t%s\n", env.Name) + fmt.Fprintf(w, "Description:\t%s\n", desc) + fmt.Fprintf(w, "Latest version:\tv%s\n", strconv.Itoa(env.LatestVersion)) + fmt.Fprintf(w, "Created:\t%s\n", env.CreatedAt.UTC().Format(timestampFormat)) + fmt.Fprintf(w, "Updated:\t%s\n", env.UpdatedAt.UTC().Format(timestampFormat)) + + w.Flush() if len(env.Versions) == 0 { return @@ -58,25 +169,61 @@ func PrintEnvironmentHuman(env pipelines.Environment) { fmt.Println() fmt.Println(tui.BaseTextStyle.Render("Versions:")) - writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + cellStyle := tui.BaseTextStyle.Padding(0, 1) + + dimStyle := tui.DimStyle.Padding(0, 1) + + headers := []string{"VERSION", "STATUS", "PACKAGES", "UPDATED"} + + updatedCol := slices.Index(headers, "UPDATED") + + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(tui.TableBorderStyle). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return cellStyle.Bold(true) + } + + if col == updatedCol { + return dimStyle + } - fmt.Fprintln(writer, "VERSION\tSTATUS\tPACKAGES\tUPDATED") + return cellStyle + }). + Headers(headers...) for _, ver := range env.Versions { - fmt.Fprintf(writer, "v%d\t%s\t%s\t%s\n", - ver.Version, ver.Status, joinPackages(ver.Packages), ver.UpdatedAt.UTC().Format(time.RFC3339), + t.Row( + fmt.Sprintf("v%d", ver.Version), + string(ver.Status), + joinPackages(ver.Packages), + ver.UpdatedAt.UTC().Format(timestampFormat), ) } - _ = writer.Flush() + fmt.Fprintln(os.Stdout, t.Render()) } -// PrintEnvironmentListJSON marshals a list of environments as indented JSON. +// PrintEnvironmentListJSON marshals a list of environments as indented JSON through the DTO. func PrintEnvironmentListJSON(items []pipelines.EnvironmentSummary) error { - return printJSON(items) + view := make([]environmentSummaryJSON, len(items)) + + for i, env := range items { + view[i] = toEnvironmentSummaryJSON(env) + } + + data, err := json.MarshalIndent(view, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil } -// PrintEnvironmentListHuman renders a tabular summary of environments. +// PrintEnvironmentListHuman renders a lipgloss table summary of environments. func PrintEnvironmentListHuman(items []pipelines.EnvironmentSummary) { if len(items) == 0 { fmt.Println(tui.DimStyle.Render("No environments found")) @@ -84,17 +231,41 @@ func PrintEnvironmentListHuman(items []pipelines.EnvironmentSummary) { return } - writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + cellStyle := tui.BaseTextStyle.Padding(0, 1) + + dimStyle := tui.DimStyle.Padding(0, 1) + + headers := []string{"ENVIRONMENT ID", "NAME", "LATEST", "STATUS", "UPDATED"} - fmt.Fprintln(writer, "ENVIRONMENT_ID\tNAME\tLATEST\tSTATUS\tUPDATED") + updatedCol := slices.Index(headers, "UPDATED") + + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(tui.TableBorderStyle). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return cellStyle.Bold(true) + } + + if col == updatedCol { + return dimStyle + } + + return cellStyle + }). + Headers(headers...) for _, env := range items { - fmt.Fprintf(writer, "%s\t%s\tv%d\t%s\t%s\n", - env.EnvironmentID, env.Name, env.LatestVersion, env.LatestStatus, env.UpdatedAt.UTC().Format(time.RFC3339), + t.Row( + env.EnvironmentID, + env.Name, + fmt.Sprintf("v%d", env.LatestVersion), + string(env.LatestStatus), + env.UpdatedAt.UTC().Format(timestampFormat), ) } - _ = writer.Flush() + fmt.Fprintln(os.Stdout, t.Render()) } // joinPackages collapses a package slice into a single comma-separated @@ -110,14 +281,3 @@ func joinPackages(packages []string) string { return joined[:maxLen-3] + "..." } - -func printJSON(value any) error { - data, err := json.MarshalIndent(value, "", " ") - if err != nil { - return err - } - - fmt.Println(string(data)) - - return nil -} diff --git a/cmd/pipelines/environment/envutil/render_test.go b/cmd/pipelines/environment/envutil/render_test.go index 80e0afa44..2c6c834e2 100644 --- a/cmd/pipelines/environment/envutil/render_test.go +++ b/cmd/pipelines/environment/envutil/render_test.go @@ -19,6 +19,7 @@ import ( "io" "os" "testing" + "time" "github.com/datarobot/cli/internal/pipelines" "github.com/stretchr/testify/assert" @@ -74,8 +75,8 @@ func TestPrintEnvironmentHuman_RendersVersionsTable(t *testing.T) { Name: "ml-base", LatestVersion: 2, Versions: []pipelines.EnvironmentVersion{ - {Version: 2, Packages: []string{"scikit-learn"}, Status: pipelines.EnvironmentStatusReady, UpdatedAt: "u2"}, - {Version: 1, Packages: []string{"numpy", "pandas"}, Status: pipelines.EnvironmentStatusReady, UpdatedAt: "u1"}, + {Version: 2, Packages: []string{"scikit-learn"}, Status: pipelines.EnvironmentStatusReady, UpdatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)}, + {Version: 1, Packages: []string{"numpy", "pandas"}, Status: pipelines.EnvironmentStatusReady, UpdatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}, }, } @@ -83,8 +84,10 @@ func TestPrintEnvironmentHuman_RendersVersionsTable(t *testing.T) { PrintEnvironmentHuman(env) }) - assert.Contains(t, out, "Environment ID: env-1") - assert.Contains(t, out, "Latest version: v2") + assert.Contains(t, out, "Environment ID:") + assert.Contains(t, out, "env-1") + assert.Contains(t, out, "Latest version:") + assert.Contains(t, out, "v2") assert.Contains(t, out, "VERSION") assert.Contains(t, out, "v2") assert.Contains(t, out, "scikit-learn") @@ -98,10 +101,10 @@ func TestPrintEnvironmentListHuman_EmptyAndPopulated(t *testing.T) { out = captureStdout(t, func() { PrintEnvironmentListHuman([]pipelines.EnvironmentSummary{ - {EnvironmentID: "env-1", Name: "ml-base", LatestVersion: 3, LatestStatus: pipelines.EnvironmentStatusReady, UpdatedAt: "u"}, + {EnvironmentID: "env-1", Name: "ml-base", LatestVersion: 3, LatestStatus: pipelines.EnvironmentStatusReady, UpdatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}, }) }) - assert.Contains(t, out, "ENVIRONMENT_ID") + assert.Contains(t, out, "ENVIRONMENT") assert.Contains(t, out, "env-1") assert.Contains(t, out, "v3") } diff --git a/cmd/pipelines/environment/list/cmd.go b/cmd/pipelines/environment/list/cmd.go index 8e8566ec4..bbf6c3e48 100644 --- a/cmd/pipelines/environment/list/cmd.go +++ b/cmd/pipelines/environment/list/cmd.go @@ -15,9 +15,8 @@ package list import ( - "fmt" - "github.com/datarobot/cli/cmd/pipelines/environment/envutil" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" "github.com/spf13/cobra" @@ -27,7 +26,7 @@ func Cmd() *cobra.Command { var ( offset int limit int - outputFormat string + outputFormat outputfmt.OutputFormat ) cmd := &cobra.Command{ @@ -41,33 +40,23 @@ returned by ` + "`environment create`" + ` and ` + "`environment update`" + `. Example: dr pipelines environment list - dr pipelines environment list --offset 50 --limit 10 --output json`, + dr pipelines environment list --offset 50 --limit 10 --output-format json`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(_ *cobra.Command, _ []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - items, err := pipelines.ListEnvironments(offset, limit) if err != nil { return err } - if outputFormat == "json" { - return envutil.PrintEnvironmentListJSON(items) - } - - envutil.PrintEnvironmentListHuman(items) - - return nil + return envutil.RenderEnvironments(outputFormat, items) }, } cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of environments to return") - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/environment/list/cmd_test.go b/cmd/pipelines/environment/list/cmd_test.go index bd61eaadf..8bfe11528 100644 --- a/cmd/pipelines/environment/list/cmd_test.go +++ b/cmd/pipelines/environment/list/cmd_test.go @@ -35,7 +35,7 @@ func runCmd(t *testing.T, args ...string) error { } func TestCmd_RejectsInvalidOutput(t *testing.T) { - err := runCmd(t, "--output", "yaml") + err := runCmd(t, "--output-format", "yaml") require.Error(t, err) assert.Contains(t, err.Error(), "invalid output format") } @@ -43,7 +43,7 @@ func TestCmd_RejectsInvalidOutput(t *testing.T) { func TestCmd_HasExpectedFlags(t *testing.T) { cmd := Cmd() - for _, name := range []string{"offset", "limit", "output"} { + for _, name := range []string{"offset", "limit", "output-format"} { assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) } } diff --git a/cmd/pipelines/environment/update/cmd.go b/cmd/pipelines/environment/update/cmd.go index 00e45190a..51f35b2c6 100644 --- a/cmd/pipelines/environment/update/cmd.go +++ b/cmd/pipelines/environment/update/cmd.go @@ -15,9 +15,8 @@ package update import ( - "fmt" - "github.com/datarobot/cli/cmd/pipelines/environment/envutil" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" "github.com/spf13/cobra" @@ -26,7 +25,7 @@ import ( func Cmd() *cobra.Command { var ( rawPackages []string - outputFormat string + outputFormat outputfmt.OutputFormat ) cmd := &cobra.Command{ @@ -39,15 +38,11 @@ the supplied pip packages. Existing versions are unchanged. Example: dr pipelines environment update env-123 --package scikit-learn - dr pipelines environment update env-123 --package "scikit-learn==1.5,torch" --output json`, + dr pipelines environment update env-123 --package "scikit-learn==1.5,torch" --output-format json`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(_ *cobra.Command, args []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - packages, err := envutil.NormalizePackages(rawPackages) if err != nil { return err @@ -58,18 +53,12 @@ Example: return err } - if outputFormat == "json" { - return envutil.PrintEnvironmentJSON(*result) - } - - envutil.PrintEnvironmentHuman(*result) - - return nil + return envutil.RenderEnvironment(outputFormat, *result) }, } cmd.Flags().StringSliceVar(&rawPackages, "package", nil, "Pip package spec (repeatable, also accepts comma-separated values)") - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/environment/update/cmd_test.go b/cmd/pipelines/environment/update/cmd_test.go index bcb2510fc..df4d98890 100644 --- a/cmd/pipelines/environment/update/cmd_test.go +++ b/cmd/pipelines/environment/update/cmd_test.go @@ -35,7 +35,7 @@ func runCmd(t *testing.T, args ...string) error { } func TestCmd_RejectsInvalidOutput(t *testing.T) { - err := runCmd(t, "env-1", "--package", "numpy", "--output", "yaml") + err := runCmd(t, "env-1", "--package", "numpy", "--output-format", "yaml") require.Error(t, err) assert.Contains(t, err.Error(), "invalid output format") } @@ -54,7 +54,7 @@ func TestCmd_RejectsMissingPackages(t *testing.T) { func TestCmd_HasExpectedFlags(t *testing.T) { cmd := Cmd() - for _, name := range []string{"package", "output"} { + for _, name := range []string{"package", "output-format"} { assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) } } diff --git a/cmd/pipelines/get/cmd.go b/cmd/pipelines/get/cmd.go index edfbc8eb1..c112fef33 100644 --- a/cmd/pipelines/get/cmd.go +++ b/cmd/pipelines/get/cmd.go @@ -15,16 +15,12 @@ package get import ( - "encoding/json" "errors" "fmt" "net/http" - "os" - "strconv" - "strings" - "text/tabwriter" - "time" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" + "github.com/datarobot/cli/cmd/pipelines/pipelineutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" "github.com/datarobot/cli/internal/pipelines" @@ -33,42 +29,32 @@ import ( ) func Cmd() *cobra.Command { - var outputFormat string + var outputFormat outputfmt.OutputFormat cmd := &cobra.Command{ Use: "get ", Short: "Display details of a pipeline.", Long: `Display full details of a pipeline including all versions. -By default, output is human-readable. Use --output json for machine-parseable output. +By default, output is human-readable. Use --output-format json for machine-parseable output. Example: dr pipelines get 507f1f77bcf86cd799439011 - dr pipelines get 507f1f77bcf86cd799439011 --output json`, + dr pipelines get 507f1f77bcf86cd799439011 --output-format json`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(_ *cobra.Command, args []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - pipeline, err := pipelines.GetPipeline(args[0]) if err != nil { return handleGetError(err, args[0]) } - if outputFormat == "json" { - return printGetJSON(*pipeline) - } - - printGetHuman(*pipeline) - - return nil + return pipelineutil.RenderPipeline(outputFormat, *pipeline) }, } - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } @@ -88,70 +74,3 @@ func handleGetError(err error, pipelineID string) error { return err } - -func printGetJSON(pipeline pipelines.Pipeline) error { - data, err := json.MarshalIndent(pipeline, "", " ") - if err != nil { - return err - } - - fmt.Println(string(data)) - - return nil -} - -func printGetHuman(pipeline pipelines.Pipeline) { - description := "\u2014" - if pipeline.Description != "" { - description = pipeline.Description - } - - fmt.Println(tui.BaseTextStyle.Render("ID: " + pipeline.PipelineID)) - fmt.Println(tui.BaseTextStyle.Render("Name: " + pipeline.Name)) - fmt.Println(tui.BaseTextStyle.Render("Description: " + description)) - fmt.Println(tui.BaseTextStyle.Render("Mode: " + pipeline.Mode)) - fmt.Println(tui.BaseTextStyle.Render(fmt.Sprintf("Active: %t", pipeline.IsActive))) - fmt.Println(tui.DimStyle.Render("Created: " + pipeline.CreatedAt.UTC().Format(time.RFC3339))) - fmt.Println(tui.DimStyle.Render("Updated: " + pipeline.UpdatedAt.UTC().Format(time.RFC3339))) - - if len(pipeline.Versions) == 0 { - return - } - - fmt.Println() - fmt.Println(tui.BaseTextStyle.Render(fmt.Sprintf("Versions (%d):", len(pipeline.Versions)))) - - writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - - fmt.Fprintln(writer, " VERSION\tSTATUS\tPYTHON\tCREATED\tTASKS") - - for _, version := range pipeline.Versions { - tasks := "\u2014" - if len(version.TaskNames) > 0 { - tasks = strings.Join(version.TaskNames, ", ") - } - - python := version.PythonVersion - if python == "" { - python = "\u2014" - } - - fmt.Fprintf(writer, " v%s\t%s\t%s\t%s\t%s\n", - strconv.Itoa(version.Version), - version.Status, - python, - version.CreatedAt.UTC().Format(time.RFC3339), - tasks, - ) - } - - _ = writer.Flush() - - for _, version := range pipeline.Versions { - if version.ErrorDetail == "" { - continue - } - - fmt.Println(tui.DimStyle.Render(fmt.Sprintf(" v%d error: %s", version.Version, version.ErrorDetail))) - } -} diff --git a/cmd/pipelines/get/cmd_test.go b/cmd/pipelines/get/cmd_test.go index b7659ae5c..112a504b7 100644 --- a/cmd/pipelines/get/cmd_test.go +++ b/cmd/pipelines/get/cmd_test.go @@ -16,13 +16,14 @@ package get import ( "bytes" - "encoding/json" "errors" "io" "os" "testing" "time" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" + "github.com/datarobot/cli/cmd/pipelines/pipelineutil" "github.com/datarobot/cli/internal/drapi" "github.com/datarobot/cli/internal/pipelines" "github.com/stretchr/testify/assert" @@ -56,8 +57,8 @@ func samplePipeline() pipelines.Pipeline { Description: "test", Mode: "draft", IsActive: true, - CreatedAt: pipelines.Time{Time: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC)}, - UpdatedAt: pipelines.Time{Time: time.Date(2026, 4, 28, 12, 25, 11, 0, time.UTC)}, + CreatedAt: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 28, 12, 25, 11, 0, time.UTC), Versions: []pipelines.PipelineVersion{ { Version: 1, @@ -65,7 +66,7 @@ func samplePipeline() pipelines.Pipeline { PipelineName: "confluence_to_vdb", TaskNames: []string{"create_vector_database", "ingest_confluence_files"}, PythonVersion: "3.12", - CreatedAt: pipelines.Time{Time: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC)}, + CreatedAt: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC), }, { Version: 2, @@ -73,7 +74,7 @@ func samplePipeline() pipelines.Pipeline { PipelineName: "confluence_to_vdb", PythonVersion: "3.12", ErrorDetail: "boom", - CreatedAt: pipelines.Time{Time: time.Date(2026, 4, 28, 12, 25, 11, 0, time.UTC)}, + CreatedAt: time.Date(2026, 4, 28, 12, 25, 11, 0, time.UTC), }, }, } @@ -83,34 +84,27 @@ func TestPrintGetJSON(t *testing.T) { pipeline := samplePipeline() output := captureStdout(t, func() { - err := printGetJSON(pipeline) + err := pipelineutil.RenderPipeline(outputfmt.OutputFormatJSON, pipeline) require.NoError(t, err) }) - var parsed map[string]interface{} - - err := json.Unmarshal([]byte(output), &parsed) - require.NoError(t, err) - assert.Equal(t, pipeline.PipelineID, parsed["pipeline_id"]) - assert.Equal(t, "confluence_to_vdb", parsed["name"]) - - versions, ok := parsed["versions"].([]interface{}) - require.True(t, ok) - require.Len(t, versions, 2) + assert.Contains(t, output, `"pipeline_id"`) + assert.Contains(t, output, "confluence_to_vdb") + assert.Contains(t, output, `"versions"`) } func TestPrintGetHuman_RendersHeaderAndVersions(t *testing.T) { pipeline := samplePipeline() output := captureStdout(t, func() { - printGetHuman(pipeline) + require.NoError(t, pipelineutil.RenderPipeline(outputfmt.OutputFormatText, pipeline)) }) - assert.Contains(t, output, "ID: "+pipeline.PipelineID) - assert.Contains(t, output, "Name: confluence_to_vdb") - assert.Contains(t, output, "Description: test") - assert.Contains(t, output, "Mode: draft") - assert.Contains(t, output, "Active: true") + assert.Contains(t, output, pipeline.PipelineID) + assert.Contains(t, output, "confluence_to_vdb") + assert.Contains(t, output, "test") + assert.Contains(t, output, "draft") + assert.Contains(t, output, "true") assert.Contains(t, output, "Versions (2):") assert.Contains(t, output, "VERSION") assert.Contains(t, output, "STATUS") @@ -128,10 +122,10 @@ func TestPrintGetHuman_BlankDescriptionFallsBack(t *testing.T) { pipeline.Description = "" output := captureStdout(t, func() { - printGetHuman(pipeline) + require.NoError(t, pipelineutil.RenderPipeline(outputfmt.OutputFormatText, pipeline)) }) - assert.Contains(t, output, "Description: \u2014") + assert.Contains(t, output, "—") } func TestPrintGetHuman_NoVersions(t *testing.T) { @@ -139,7 +133,7 @@ func TestPrintGetHuman_NoVersions(t *testing.T) { pipeline.Versions = nil output := captureStdout(t, func() { - printGetHuman(pipeline) + require.NoError(t, pipelineutil.RenderPipeline(outputfmt.OutputFormatText, pipeline)) }) assert.NotContains(t, output, "Versions (") @@ -157,7 +151,7 @@ func TestCmd_RequiresArg(t *testing.T) { func TestCmd_RejectsInvalidOutput(t *testing.T) { cmd := Cmd() - cmd.SetArgs([]string{"some-id", "--output", "yaml"}) + cmd.SetArgs([]string{"some-id", "--output-format", "yaml"}) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) cmd.PreRunE = nil @@ -169,7 +163,7 @@ func TestCmd_RejectsInvalidOutput(t *testing.T) { func TestCmd_HasOutputFlag(t *testing.T) { cmd := Cmd() - assert.NotNil(t, cmd.Flags().Lookup("output")) + assert.NotNil(t, cmd.Flags().Lookup("output-format")) } func TestHandleGetError_NotFoundPrintsFriendlyMessage(t *testing.T) { diff --git a/cmd/pipelines/graph/cmd.go b/cmd/pipelines/graph/cmd.go index 03b0b5e95..0815e3b89 100644 --- a/cmd/pipelines/graph/cmd.go +++ b/cmd/pipelines/graph/cmd.go @@ -23,6 +23,7 @@ import ( "strconv" "text/tabwriter" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/cmd/pipelines/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" @@ -34,7 +35,7 @@ import ( func Cmd() *cobra.Command { var ( flags scopeflag.Flags - outputFormat string + outputFormat outputfmt.OutputFormat ) cmd := &cobra.Command{ @@ -51,15 +52,11 @@ Scope is selected from the --scope/--version flags: Example: dr pipelines graph --pipeline - dr pipelines graph --pipeline --version=2 --output json`, + dr pipelines graph --pipeline --version=2 --output-format json`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - if flags.PipelineID == "" { return errors.New("--pipeline is required") } @@ -74,7 +71,7 @@ Example: return handleGraphError(err, flags.PipelineID) } - if outputFormat == "json" { + if outputFormat == outputfmt.OutputFormatJSON { return printGraphJSON(*result) } @@ -85,7 +82,7 @@ Example: } flags.Bind(cmd) - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/graph/cmd_test.go b/cmd/pipelines/graph/cmd_test.go index 201603c22..e2448ee9c 100644 --- a/cmd/pipelines/graph/cmd_test.go +++ b/cmd/pipelines/graph/cmd_test.go @@ -123,7 +123,7 @@ func TestCmd_RejectsMissingPipeline(t *testing.T) { func TestCmd_RejectsBadOutput(t *testing.T) { cmd := Cmd() - cmd.SetArgs([]string{"--pipeline", "p", "--output", "yaml"}) + cmd.SetArgs([]string{"--pipeline", "p", "--output-format", "yaml"}) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) cmd.PreRunE = nil diff --git a/cmd/pipelines/input/create/cmd.go b/cmd/pipelines/input/create/cmd.go index d8f33b2e7..a9c838b06 100644 --- a/cmd/pipelines/input/create/cmd.go +++ b/cmd/pipelines/input/create/cmd.go @@ -16,9 +16,9 @@ package create import ( "errors" - "fmt" "github.com/datarobot/cli/cmd/pipelines/input/inpututil" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/cmd/pipelines/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" @@ -29,7 +29,7 @@ func Cmd() *cobra.Command { var ( flags scopeflag.Flags fromFile string - outputFormat string + outputFormat outputfmt.OutputFormat ) cmd := &cobra.Command{ @@ -50,15 +50,11 @@ Scope is selected from the --scope/--version flags: Example: dr pipelines input create --pipeline ./payload.json dr pipelines input create --pipeline --from-file=./payload.json - dr pipelines input create --pipeline --version=2 ./payload.json --output json`, + dr pipelines input create --pipeline --version=2 ./payload.json --output-format json`, Args: cobra.MaximumNArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - if flags.PipelineID == "" { return errors.New("--pipeline is required") } @@ -78,19 +74,13 @@ Example: return err } - if outputFormat == "json" { - return inpututil.PrintInputJSON(*result) - } - - inpututil.PrintInputHuman(*result) - - return nil + return inpututil.RenderInput(outputFormat, *result) }, } flags.Bind(cmd) cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the JSON payload file, e.g. --from-file=./payload.json (alternative to the positional argument)") - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/input/create/cmd_test.go b/cmd/pipelines/input/create/cmd_test.go index 889002c88..c460abbe8 100644 --- a/cmd/pipelines/input/create/cmd_test.go +++ b/cmd/pipelines/input/create/cmd_test.go @@ -35,7 +35,7 @@ func runCmd(t *testing.T, args ...string) error { } func TestCmd_RejectsInvalidOutput(t *testing.T) { - err := runCmd(t, "--pipeline", "p", "--output", "yaml", "p.json") + err := runCmd(t, "--pipeline", "p", "--output-format", "yaml", "p.json") require.Error(t, err) assert.Contains(t, err.Error(), "invalid output format") } @@ -61,7 +61,7 @@ func TestCmd_RejectsMissingPayload(t *testing.T) { func TestCmd_HasExpectedFlags(t *testing.T) { cmd := Cmd() - for _, name := range []string{"pipeline", "scope", "version", "from-file", "output"} { + for _, name := range []string{"pipeline", "scope", "version", "from-file", "output-format"} { assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) } } diff --git a/cmd/pipelines/input/get/cmd.go b/cmd/pipelines/input/get/cmd.go index 75ec9893d..4896bb5de 100644 --- a/cmd/pipelines/input/get/cmd.go +++ b/cmd/pipelines/input/get/cmd.go @@ -20,6 +20,7 @@ import ( "net/http" "github.com/datarobot/cli/cmd/pipelines/input/inpututil" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/cmd/pipelines/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" @@ -31,7 +32,7 @@ import ( func Cmd() *cobra.Command { var ( flags scopeflag.Flags - outputFormat string + outputFormat outputfmt.OutputFormat ) cmd := &cobra.Command{ @@ -41,15 +42,11 @@ func Cmd() *cobra.Command { Example: dr pipelines input get --pipeline - dr pipelines input get --pipeline --version=2 --output json`, + dr pipelines input get --pipeline --version=2 --output-format json`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - if flags.PipelineID == "" { return errors.New("--pipeline is required") } @@ -64,18 +61,12 @@ Example: return handleGetError(err, args[0]) } - if outputFormat == "json" { - return inpututil.PrintInputJSON(*result) - } - - inpututil.PrintInputHuman(*result) - - return nil + return inpututil.RenderInput(outputFormat, *result) }, } flags.Bind(cmd) - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/input/get/cmd_test.go b/cmd/pipelines/input/get/cmd_test.go index e8105905e..9694f185d 100644 --- a/cmd/pipelines/input/get/cmd_test.go +++ b/cmd/pipelines/input/get/cmd_test.go @@ -38,7 +38,7 @@ func runCmd(t *testing.T, args ...string) error { } func TestCmd_RejectsInvalidOutput(t *testing.T) { - err := runCmd(t, "--pipeline", "p", "--output", "yaml", "in-1") + err := runCmd(t, "--pipeline", "p", "--output-format", "yaml", "in-1") require.Error(t, err) assert.Contains(t, err.Error(), "invalid output format") } diff --git a/cmd/pipelines/input/inpututil/render.go b/cmd/pipelines/input/inpututil/render.go index cd936ffc7..cc1f7fa4b 100644 --- a/cmd/pipelines/input/inpututil/render.go +++ b/cmd/pipelines/input/inpututil/render.go @@ -21,17 +21,83 @@ import ( "encoding/json" "fmt" "os" + "slices" "strconv" "text/tabwriter" "time" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/internal/pipelines" "github.com/datarobot/cli/tui" ) -// PrintInputJSON marshals an input record as indented JSON. +const ( + timestampFormat = "2006-01-02 15:04 UTC" + emptyValuePlaceholder = "—" +) + +// inputJSON is the CLI-facing DTO used for `--output-format json`. +type inputJSON struct { + InputID string `json:"input_id"` + PipelineID string `json:"pipeline_id"` + Scope string `json:"scope"` + Version string `json:"version"` + State string `json:"state"` + Payload json.RawMessage `json:"payload"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toInputJSON(input pipelines.Input) inputJSON { + scope := "draft" + version := emptyValuePlaceholder + + if input.VersionID != nil { + scope = "locked" + version = "v" + strconv.Itoa(*input.VersionID) + } + + payloadBytes, _ := json.Marshal(input.Payload) + + return inputJSON{ + InputID: input.InputID, + PipelineID: input.PipelineID, + Scope: scope, + Version: version, + State: string(input.State), + Payload: payloadBytes, + CreatedAt: input.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: input.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +// RenderInput routes a single input to JSON or human output. +func RenderInput(format outputfmt.OutputFormat, input pipelines.Input) error { + if format == outputfmt.OutputFormatJSON { + return PrintInputJSON(input) + } + + PrintInputHuman(input) + + return nil +} + +// RenderInputs routes a list of inputs to JSON or human output. +func RenderInputs(format outputfmt.OutputFormat, inputs []pipelines.Input) error { + if format == outputfmt.OutputFormatJSON { + return PrintInputListJSON(inputs) + } + + PrintInputListHuman(inputs) + + return nil +} + +// PrintInputJSON marshals an input record as indented JSON through the DTO. func PrintInputJSON(input pipelines.Input) error { - data, err := json.MarshalIndent(input, "", " ") + data, err := json.MarshalIndent(toInputJSON(input), "", " ") if err != nil { return err } @@ -44,20 +110,24 @@ func PrintInputJSON(input pipelines.Input) error { // PrintInputHuman renders the key facts about a single input record. func PrintInputHuman(input pipelines.Input) { scope := "draft" - versionDisplay := "\u2014" + versionDisplay := emptyValuePlaceholder if input.VersionID != nil { scope = "locked" versionDisplay = "v" + strconv.Itoa(*input.VersionID) } - fmt.Println(tui.BaseTextStyle.Render("Input ID: " + input.InputID)) - fmt.Println(tui.BaseTextStyle.Render("Pipeline ID: " + input.PipelineID)) - fmt.Println(tui.BaseTextStyle.Render("Scope: " + scope)) - fmt.Println(tui.BaseTextStyle.Render("Version: " + versionDisplay)) - fmt.Println(tui.BaseTextStyle.Render("State: " + string(input.State))) - fmt.Println(tui.DimStyle.Render("Created: " + input.CreatedAt.UTC().Format(time.RFC3339))) - fmt.Println(tui.DimStyle.Render("Updated: " + input.UpdatedAt.UTC().Format(time.RFC3339))) + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Input ID:\t%s\n", input.InputID) + fmt.Fprintf(w, "Pipeline ID:\t%s\n", input.PipelineID) + fmt.Fprintf(w, "Scope:\t%s\n", scope) + fmt.Fprintf(w, "Version:\t%s\n", versionDisplay) + fmt.Fprintf(w, "State:\t%s\n", string(input.State)) + fmt.Fprintf(w, "Created:\t%s\n", input.CreatedAt.UTC().Format(timestampFormat)) + fmt.Fprintf(w, "Updated:\t%s\n", input.UpdatedAt.UTC().Format(timestampFormat)) + + w.Flush() payload, err := json.MarshalIndent(input.Payload, "", " ") if err != nil { @@ -69,9 +139,15 @@ func PrintInputHuman(input pipelines.Input) { fmt.Println(string(payload)) } -// PrintInputListJSON marshals a list of inputs as indented JSON. +// PrintInputListJSON marshals a list of inputs as indented JSON through the DTO. func PrintInputListJSON(inputs []pipelines.Input) error { - data, err := json.MarshalIndent(inputs, "", " ") + view := make([]inputJSON, len(inputs)) + + for i, in := range inputs { + view[i] = toInputJSON(in) + } + + data, err := json.MarshalIndent(view, "", " ") if err != nil { return err } @@ -81,7 +157,7 @@ func PrintInputListJSON(inputs []pipelines.Input) error { return nil } -// PrintInputListHuman renders a tabular summary of inputs. +// PrintInputListHuman renders a lipgloss table summary of inputs. func PrintInputListHuman(inputs []pipelines.Input) { if len(inputs) == 0 { fmt.Println(tui.DimStyle.Render("No inputs found")) @@ -89,23 +165,41 @@ func PrintInputListHuman(inputs []pipelines.Input) { return } - writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + cellStyle := tui.BaseTextStyle.Padding(0, 1) + + dimStyle := tui.DimStyle.Padding(0, 1) + + headers := []string{"INPUT ID", "SCOPE", "VERSION", "STATE", "UPDATED"} + + updatedCol := slices.Index(headers, "UPDATED") + + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(tui.TableBorderStyle). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return cellStyle.Bold(true) + } + + if col == updatedCol { + return dimStyle + } - fmt.Fprintln(writer, "INPUT_ID\tSCOPE\tVERSION\tSTATE\tUPDATED") + return cellStyle + }). + Headers(headers...) for _, in := range inputs { scope := "draft" - ver := "\u2014" + ver := emptyValuePlaceholder if in.VersionID != nil { scope = "locked" ver = "v" + strconv.Itoa(*in.VersionID) } - fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\n", - in.InputID, scope, ver, in.State, in.UpdatedAt.UTC().Format(time.RFC3339), - ) + t.Row(in.InputID, scope, ver, string(in.State), in.UpdatedAt.UTC().Format(timestampFormat)) } - _ = writer.Flush() + fmt.Fprintln(os.Stdout, t.Render()) } diff --git a/cmd/pipelines/input/inpututil/render_test.go b/cmd/pipelines/input/inpututil/render_test.go index 9dad4e647..19e0f5392 100644 --- a/cmd/pipelines/input/inpututil/render_test.go +++ b/cmd/pipelines/input/inpututil/render_test.go @@ -20,6 +20,7 @@ import ( "io" "os" "testing" + "time" "github.com/datarobot/cli/internal/pipelines" "github.com/stretchr/testify/assert" @@ -55,8 +56,8 @@ func sampleDraftInput() pipelines.Input { IsDraft: true, State: pipelines.InputStateValid, Payload: map[string]any{"k": "v"}, - CreatedAt: "2026-04-29T10:00:00Z", - UpdatedAt: "2026-04-29T10:05:00Z", + CreatedAt: time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 29, 10, 5, 0, 0, time.UTC), } } @@ -82,18 +83,22 @@ func TestPrintInputJSON(t *testing.T) { func TestPrintInputHuman_Draft(t *testing.T) { output := captureStdout(t, func() { PrintInputHuman(sampleDraftInput()) }) - assert.Contains(t, output, "Input ID: in-1") - assert.Contains(t, output, "Scope: draft") - assert.Contains(t, output, "Version: \u2014") - assert.Contains(t, output, "State: VALID") + assert.Contains(t, output, "Input ID:") + assert.Contains(t, output, "in-1") + assert.Contains(t, output, "Scope:") + assert.Contains(t, output, "draft") + assert.Contains(t, output, "Version:") + assert.Contains(t, output, "\u2014") + assert.Contains(t, output, "State:") + assert.Contains(t, output, "VALID") assert.Contains(t, output, "Payload:") assert.Contains(t, output, `"k": "v"`) } func TestPrintInputHuman_Locked(t *testing.T) { output := captureStdout(t, func() { PrintInputHuman(sampleLockedInput()) }) - assert.Contains(t, output, "Scope: locked") - assert.Contains(t, output, "Version: v2") + assert.Contains(t, output, "locked") + assert.Contains(t, output, "v2") } func TestPrintInputListJSON(t *testing.T) { @@ -118,7 +123,7 @@ func TestPrintInputListHuman_RendersTable(t *testing.T) { PrintInputListHuman([]pipelines.Input{sampleDraftInput(), sampleLockedInput()}) }) - assert.Contains(t, output, "INPUT_ID") + assert.Contains(t, output, "INPUT") assert.Contains(t, output, "SCOPE") assert.Contains(t, output, "VERSION") assert.Contains(t, output, "draft") diff --git a/cmd/pipelines/input/list/cmd.go b/cmd/pipelines/input/list/cmd.go index 660e029e4..20049fd9f 100644 --- a/cmd/pipelines/input/list/cmd.go +++ b/cmd/pipelines/input/list/cmd.go @@ -16,9 +16,9 @@ package list import ( "errors" - "fmt" "github.com/datarobot/cli/cmd/pipelines/input/inpututil" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/cmd/pipelines/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" @@ -30,7 +30,7 @@ func Cmd() *cobra.Command { flags scopeflag.Flags offset int limit int - outputFormat string + outputFormat outputfmt.OutputFormat ) cmd := &cobra.Command{ @@ -47,15 +47,11 @@ Scope is selected the same way as create: Example: dr pipelines input list --pipeline dr pipelines input list --pipeline --version=2 - dr pipelines input list --pipeline --offset 50 --limit 10 --output json`, + dr pipelines input list --pipeline --offset 50 --limit 10 --output-format json`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - if flags.PipelineID == "" { return errors.New("--pipeline is required") } @@ -70,20 +66,14 @@ Example: return err } - if outputFormat == "json" { - return inpututil.PrintInputListJSON(items) - } - - inpututil.PrintInputListHuman(items) - - return nil + return inpututil.RenderInputs(outputFormat, items) }, } flags.Bind(cmd) cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of inputs to return") - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/input/list/cmd_test.go b/cmd/pipelines/input/list/cmd_test.go index 63eac2187..5ccb8e15a 100644 --- a/cmd/pipelines/input/list/cmd_test.go +++ b/cmd/pipelines/input/list/cmd_test.go @@ -35,7 +35,7 @@ func runCmd(t *testing.T, args ...string) error { } func TestCmd_RejectsInvalidOutput(t *testing.T) { - err := runCmd(t, "--pipeline", "p", "--output", "yaml") + err := runCmd(t, "--pipeline", "p", "--output-format", "yaml") require.Error(t, err) assert.Contains(t, err.Error(), "invalid output format") } @@ -55,7 +55,7 @@ func TestCmd_RejectsBadScopeCombo(t *testing.T) { func TestCmd_HasExpectedFlags(t *testing.T) { cmd := Cmd() - for _, name := range []string{"pipeline", "scope", "version", "offset", "limit", "output"} { + for _, name := range []string{"pipeline", "scope", "version", "offset", "limit", "output-format"} { assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) } } diff --git a/cmd/pipelines/input/update/cmd.go b/cmd/pipelines/input/update/cmd.go index 8cdf2966b..49625b55a 100644 --- a/cmd/pipelines/input/update/cmd.go +++ b/cmd/pipelines/input/update/cmd.go @@ -16,9 +16,9 @@ package update import ( "errors" - "fmt" "github.com/datarobot/cli/cmd/pipelines/input/inpututil" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" "github.com/spf13/cobra" @@ -28,7 +28,7 @@ func Cmd() *cobra.Command { var ( pipelineID string fromFile string - outputFormat string + outputFormat outputfmt.OutputFormat ) cmd := &cobra.Command{ @@ -42,15 +42,11 @@ argument or via --from-file=. Example: dr pipelines input update --pipeline ./new_payload.json - dr pipelines input update --pipeline --from-file=./new_payload.json --output json`, + dr pipelines input update --pipeline --from-file=./new_payload.json --output-format json`, Args: cobra.RangeArgs(1, 2), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(_ *cobra.Command, args []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - if pipelineID == "" { return errors.New("--pipeline is required") } @@ -67,19 +63,13 @@ Example: return err } - if outputFormat == "json" { - return inpututil.PrintInputJSON(*result) - } - - inpututil.PrintInputHuman(*result) - - return nil + return inpututil.RenderInput(outputFormat, *result) }, } cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the JSON payload file, e.g. --from-file=./payload.json (alternative to the positional argument)") - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/input/update/cmd_test.go b/cmd/pipelines/input/update/cmd_test.go index 808b5d698..254ae5a59 100644 --- a/cmd/pipelines/input/update/cmd_test.go +++ b/cmd/pipelines/input/update/cmd_test.go @@ -35,7 +35,7 @@ func runCmd(t *testing.T, args ...string) error { } func TestCmd_RejectsInvalidOutput(t *testing.T) { - err := runCmd(t, "--pipeline", "p", "--output", "yaml", "in-1", "p.json") + err := runCmd(t, "--pipeline", "p", "--output-format", "yaml", "in-1", "p.json") require.Error(t, err) assert.Contains(t, err.Error(), "invalid output format") } @@ -60,7 +60,7 @@ func TestCmd_RejectsMissingPayload(t *testing.T) { func TestCmd_HasExpectedFlags(t *testing.T) { cmd := Cmd() - for _, name := range []string{"pipeline", "from-file", "output"} { + for _, name := range []string{"pipeline", "from-file", "output-format"} { assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) } } diff --git a/cmd/pipelines/list/cmd.go b/cmd/pipelines/list/cmd.go index 7be45e2f1..4916ae8ee 100644 --- a/cmd/pipelines/list/cmd.go +++ b/cmd/pipelines/list/cmd.go @@ -15,16 +15,12 @@ package list import ( - "encoding/json" "fmt" - "os" - "strconv" - "text/tabwriter" - "time" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" + "github.com/datarobot/cli/cmd/pipelines/pipelineutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" - "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -33,7 +29,7 @@ func Cmd() *cobra.Command { mode string offset int limit int - outputFormat string + outputFormat outputfmt.OutputFormat ) cmd := &cobra.Command{ @@ -41,19 +37,15 @@ func Cmd() *cobra.Command { Short: "List pipelines.", Long: `List pipelines registered with the pipelines service. -By default, output is human-readable. Use --output json for machine-parseable output. +By default, output is human-readable. Use --output-format json for machine-parseable output. Example: dr pipelines list dr pipelines list --mode draft - dr pipelines list --offset 0 --limit 50 --output json`, + dr pipelines list --offset 0 --limit 50 --output-format json`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, RunE: func(_ *cobra.Command, _ []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - if mode != "" && mode != pipelines.ModeDraft && mode != pipelines.ModeLocked { return fmt.Errorf("invalid mode: %s (supported: draft, locked)", mode) } @@ -63,61 +55,14 @@ Example: return err } - if outputFormat == "json" { - return printListJSON(*list) - } - - printListHuman(*list) - - return nil + return pipelineutil.RenderPipelines(outputFormat, *list) }, } cmd.Flags().StringVar(&mode, "mode", "", "Filter by mode: draft or locked") cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") cmd.Flags().IntVar(&limit, "limit", 50, "Pagination limit (1-200)") - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } - -func printListJSON(list pipelines.ListResponse) error { - data, err := json.MarshalIndent(list, "", " ") - if err != nil { - return err - } - - fmt.Println(string(data)) - - return nil -} - -func printListHuman(list pipelines.ListResponse) { - if len(list.Items) == 0 { - fmt.Println(tui.DimStyle.Render("No pipelines found.")) - - return - } - - fmt.Println(tui.BaseTextStyle.Render(fmt.Sprintf("Showing %d of %d (offset=%d limit=%d)", len(list.Items), list.Total, list.Offset, list.Limit))) - fmt.Println() - - writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - - fmt.Fprintln(writer, "ID\tNAME\tMODE\tACTIVE\tVERSION\tUPDATED") - - for _, item := range list.Items { - latest := "\u2014" - if item.LatestVersion != nil { - latest = "v" + strconv.Itoa(*item.LatestVersion) - } - - updated := item.UpdatedAt.UTC().Format(time.RFC3339) - active := strconv.FormatBool(item.IsActive) - - fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\t%s\n", - item.PipelineID, item.Name, item.Mode, active, latest, updated) - } - - _ = writer.Flush() -} diff --git a/cmd/pipelines/list/cmd_test.go b/cmd/pipelines/list/cmd_test.go index d496a071e..e3c7e89da 100644 --- a/cmd/pipelines/list/cmd_test.go +++ b/cmd/pipelines/list/cmd_test.go @@ -22,6 +22,8 @@ import ( "testing" "time" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" + "github.com/datarobot/cli/cmd/pipelines/pipelineutil" "github.com/datarobot/cli/internal/pipelines" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -60,8 +62,8 @@ func sampleListResponse() pipelines.ListResponse { Mode: "draft", IsActive: true, LatestVersion: intPtr(3), - CreatedAt: pipelines.Time{Time: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC)}, - UpdatedAt: pipelines.Time{Time: time.Date(2026, 4, 28, 12, 25, 11, 0, time.UTC)}, + CreatedAt: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 28, 12, 25, 11, 0, time.UTC), }, }, Total: 1, @@ -74,7 +76,7 @@ func TestPrintListJSON(t *testing.T) { list := sampleListResponse() output := captureStdout(t, func() { - err := printListJSON(list) + err := pipelineutil.RenderPipelines(outputfmt.OutputFormatJSON, list) require.NoError(t, err) }) @@ -95,7 +97,7 @@ func TestPrintListJSON(t *testing.T) { func TestPrintListHuman_Empty(t *testing.T) { output := captureStdout(t, func() { - printListHuman(pipelines.ListResponse{Total: 0, Limit: 50}) + require.NoError(t, pipelineutil.RenderPipelines(outputfmt.OutputFormatText, pipelines.ListResponse{Total: 0, Limit: 50})) }) assert.Contains(t, output, "No pipelines found.") @@ -105,7 +107,7 @@ func TestPrintListHuman_RendersHeaderAndRow(t *testing.T) { list := sampleListResponse() output := captureStdout(t, func() { - printListHuman(list) + require.NoError(t, pipelineutil.RenderPipelines(outputfmt.OutputFormatText, list)) }) assert.Contains(t, output, "Showing 1 of 1") @@ -120,7 +122,7 @@ func TestPrintListHuman_RendersHeaderAndRow(t *testing.T) { assert.Contains(t, output, "draft") assert.Contains(t, output, "true") assert.Contains(t, output, "v3") - assert.Contains(t, output, "2026-04-28T12:25:11Z") + assert.Contains(t, output, "2026-04-28") } func TestPrintListHuman_NoLatestVersion(t *testing.T) { @@ -128,15 +130,15 @@ func TestPrintListHuman_NoLatestVersion(t *testing.T) { list.Items[0].LatestVersion = nil output := captureStdout(t, func() { - printListHuman(list) + require.NoError(t, pipelineutil.RenderPipelines(outputfmt.OutputFormatText, list)) }) - assert.Contains(t, output, "\u2014") + assert.Contains(t, output, "—") } func TestCmd_RejectsInvalidOutput(t *testing.T) { cmd := Cmd() - cmd.SetArgs([]string{"--output", "yaml"}) + cmd.SetArgs([]string{"--output-format", "yaml"}) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) cmd.PreRunE = nil @@ -161,7 +163,7 @@ func TestCmd_RejectsInvalidMode(t *testing.T) { func TestCmd_HasExpectedFlags(t *testing.T) { cmd := Cmd() - for _, name := range []string{"mode", "offset", "limit", "output"} { + for _, name := range []string{"mode", "offset", "limit", "output-format"} { flag := cmd.Flags().Lookup(name) assert.NotNilf(t, flag, "expected --%s flag to be registered", name) } diff --git a/cmd/pipelines/lock/cmd.go b/cmd/pipelines/lock/cmd.go index 349125a67..530321824 100644 --- a/cmd/pipelines/lock/cmd.go +++ b/cmd/pipelines/lock/cmd.go @@ -15,20 +15,15 @@ package lock import ( - "encoding/json" - "fmt" - "strconv" - "strings" - "time" - + "github.com/datarobot/cli/cmd/pipelines/outputfmt" + "github.com/datarobot/cli/cmd/pipelines/pipelineutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" - "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) func Cmd() *cobra.Command { - var outputFormat string + var outputFormat outputfmt.OutputFormat cmd := &cobra.Command{ Use: "lock ", @@ -38,57 +33,21 @@ no longer be updated and locked runs/inputs/schedules become valid. Example: dr pipelines lock 507f1f77bcf86cd799439011 - dr pipelines lock 507f1f77bcf86cd799439011 --output json`, + dr pipelines lock 507f1f77bcf86cd799439011 --output-format json`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(_ *cobra.Command, args []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - result, err := pipelines.LockPipeline(args[0]) if err != nil { return err } - if outputFormat == "json" { - return printLockJSON(*result) - } - - printLockHuman(*result) - - return nil + return pipelineutil.RenderCreateResponse(outputFormat, *result) }, } - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } - -func printLockJSON(result pipelines.CreateResponse) error { - data, err := json.MarshalIndent(result, "", " ") - if err != nil { - return err - } - - fmt.Println(string(data)) - - return nil -} - -func printLockHuman(result pipelines.CreateResponse) { - tasks := "\u2014" - if len(result.TaskNames) > 0 { - tasks = strings.Join(result.TaskNames, ", ") - } - - fmt.Println(tui.BaseTextStyle.Render("Pipeline ID: " + result.PipelineID)) - fmt.Println(tui.BaseTextStyle.Render("Name: " + result.Name)) - fmt.Println(tui.BaseTextStyle.Render("Mode: " + result.Mode)) - fmt.Println(tui.BaseTextStyle.Render("Version: v" + strconv.Itoa(result.Version))) - fmt.Println(tui.BaseTextStyle.Render("Status: " + result.Status)) - fmt.Println(tui.BaseTextStyle.Render("Tasks: " + tasks)) - fmt.Println(tui.DimStyle.Render("Locked: " + result.CreatedAt.UTC().Format(time.RFC3339))) -} diff --git a/cmd/pipelines/lock/cmd_test.go b/cmd/pipelines/lock/cmd_test.go index 3e5c370b9..dafbcf7f9 100644 --- a/cmd/pipelines/lock/cmd_test.go +++ b/cmd/pipelines/lock/cmd_test.go @@ -22,6 +22,8 @@ import ( "testing" "time" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" + "github.com/datarobot/cli/cmd/pipelines/pipelineutil" "github.com/datarobot/cli/internal/pipelines" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -55,13 +57,13 @@ func sample() pipelines.CreateResponse { Status: "READY", Mode: "locked", TaskNames: []string{"e1", "e2"}, - CreatedAt: pipelines.Time{Time: time.Date(2026, 4, 30, 10, 0, 0, 0, time.UTC)}, + CreatedAt: time.Date(2026, 4, 30, 10, 0, 0, 0, time.UTC), } } func TestPrintLockJSON(t *testing.T) { output := captureStdout(t, func() { - require.NoError(t, printLockJSON(sample())) + require.NoError(t, pipelineutil.RenderCreateResponse(outputfmt.OutputFormatJSON, sample())) }) var parsed map[string]any @@ -74,13 +76,13 @@ func TestPrintLockJSON(t *testing.T) { func TestPrintLockHuman(t *testing.T) { output := captureStdout(t, func() { - printLockHuman(sample()) + require.NoError(t, pipelineutil.RenderCreateResponse(outputfmt.OutputFormatText, sample())) }) - assert.Contains(t, output, "Pipeline ID: abc") - assert.Contains(t, output, "Mode: locked") - assert.Contains(t, output, "Version: v3") - assert.Contains(t, output, "Tasks: e1, e2") + assert.Contains(t, output, "abc") + assert.Contains(t, output, "locked") + assert.Contains(t, output, "3") + assert.Contains(t, output, "e1, e2") } func TestPrintLockHuman_NoTasks(t *testing.T) { @@ -88,15 +90,15 @@ func TestPrintLockHuman_NoTasks(t *testing.T) { resp.TaskNames = nil output := captureStdout(t, func() { - printLockHuman(resp) + require.NoError(t, pipelineutil.RenderCreateResponse(outputfmt.OutputFormatText, resp)) }) - assert.Contains(t, output, "Tasks: \u2014") + assert.Contains(t, output, "—") } func TestCmd_RejectsInvalidOutput(t *testing.T) { cmd := Cmd() - cmd.SetArgs([]string{"abc", "--output", "yaml"}) + cmd.SetArgs([]string{"abc", "--output-format", "yaml"}) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) cmd.PreRunE = nil diff --git a/cmd/pipelines/outputfmt/flags.go b/cmd/pipelines/outputfmt/flags.go new file mode 100644 index 000000000..1e67309d0 --- /dev/null +++ b/cmd/pipelines/outputfmt/flags.go @@ -0,0 +1,66 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package outputfmt provides the --output-format flag shared by all +// `dr pipelines` subcommands. +package outputfmt + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// OutputFormat is the type used for the --output-format flag value. +type OutputFormat string + +const ( + OutputFormatText OutputFormat = "text" + OutputFormatJSON OutputFormat = "json" +) + +var _ pflag.Value = (*OutputFormat)(nil) + +func (f *OutputFormat) String() string { + if f == nil { + return "" + } + + return string(*f) +} + +func (f *OutputFormat) Set(s string) error { + switch s { + case string(OutputFormatText), string(OutputFormatJSON): + *f = OutputFormat(s) + + return nil + } + + return fmt.Errorf("invalid output format %q: use %s or %s", s, OutputFormatText, OutputFormatJSON) +} + +func (f *OutputFormat) Type() string { + return "format" +} + +// AddOutputFlag registers --output-format on cmd, defaulting to OutputFormatText. +// The default is written to *dest before registration so cobra renders it as +// the default value in --help. +func AddOutputFlag(cmd *cobra.Command, dest *OutputFormat) { + *dest = OutputFormatText + + cmd.Flags().Var(dest, "output-format", fmt.Sprintf("Output format (%s, %s)", OutputFormatText, OutputFormatJSON)) +} diff --git a/cmd/pipelines/pipelineutil/render.go b/cmd/pipelines/pipelineutil/render.go new file mode 100644 index 000000000..be6dd1d84 --- /dev/null +++ b/cmd/pipelines/pipelineutil/render.go @@ -0,0 +1,252 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package pipelineutil holds the rendering helpers shared by the top-level +// `dr pipelines` verbs (list, get, create, update, lock). +package pipelineutil + +import ( + "encoding/json" + "fmt" + "os" + "slices" + "strconv" + "strings" + "text/tabwriter" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" + "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/tui" +) + +const ( + timestampFormat = "2006-01-02 15:04 UTC" + emptyValuePlaceholder = "—" +) + +// RenderPipeline routes a single pipeline to JSON or human output. +func RenderPipeline(format outputfmt.OutputFormat, p pipelines.Pipeline) error { + if format == outputfmt.OutputFormatJSON { + return printPipelineJSON(p) + } + + printPipelineHuman(p) + + return nil +} + +// RenderPipelines routes a pipeline list to JSON or human output. +func RenderPipelines(format outputfmt.OutputFormat, list pipelines.ListResponse) error { + if format == outputfmt.OutputFormatJSON { + return printPipelinesJSON(list) + } + + printPipelinesHuman(list) + + return nil +} + +// RenderCreateResponse routes a CreateResponse to JSON or human output. +func RenderCreateResponse(format outputfmt.OutputFormat, result pipelines.CreateResponse) error { + if format == outputfmt.OutputFormatJSON { + return printCreateResponseJSON(result) + } + + printCreateResponseHuman(result) + + return nil +} + +func printPipelineJSON(p pipelines.Pipeline) error { + data, err := json.MarshalIndent(p, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +func printPipelinesJSON(list pipelines.ListResponse) error { + data, err := json.MarshalIndent(list, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +func printCreateResponseJSON(result pipelines.CreateResponse) error { + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +func printPipelinesHuman(list pipelines.ListResponse) { + if len(list.Items) == 0 { + fmt.Println(tui.DimStyle.Render("No pipelines found.")) + + return + } + + fmt.Println(tui.BaseTextStyle.Render(fmt.Sprintf("Showing %d of %d (offset=%d limit=%d)", len(list.Items), list.Total, list.Offset, list.Limit))) + fmt.Println() + + cellStyle := tui.BaseTextStyle.Padding(0, 1) + + dimStyle := tui.DimStyle.Padding(0, 1) + + headers := []string{"ID", "NAME", "MODE", "ACTIVE", "VERSION", "UPDATED"} + + updatedCol := slices.Index(headers, "UPDATED") + + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(tui.TableBorderStyle). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return cellStyle.Bold(true) + } + + if col == updatedCol { + return dimStyle + } + + return cellStyle + }). + Headers(headers...) + + for _, item := range list.Items { + latest := emptyValuePlaceholder + if item.LatestVersion != nil { + latest = "v" + strconv.Itoa(*item.LatestVersion) + } + + updated := item.UpdatedAt.UTC().Format(timestampFormat) + active := strconv.FormatBool(item.IsActive) + + t.Row(item.PipelineID, item.Name, item.Mode, active, latest, updated) + } + + fmt.Fprintln(os.Stdout, t.Render()) +} + +func printPipelineHuman(p pipelines.Pipeline) { + description := emptyValuePlaceholder + if p.Description != "" { + description = p.Description + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "ID:\t%s\n", p.PipelineID) + fmt.Fprintf(w, "Name:\t%s\n", p.Name) + fmt.Fprintf(w, "Description:\t%s\n", description) + fmt.Fprintf(w, "Mode:\t%s\n", p.Mode) + fmt.Fprintf(w, "Active:\t%t\n", p.IsActive) + fmt.Fprintf(w, "Created:\t%s\n", p.CreatedAt.UTC().Format(timestampFormat)) + fmt.Fprintf(w, "Updated:\t%s\n", p.UpdatedAt.UTC().Format(timestampFormat)) + + w.Flush() + + if len(p.Versions) == 0 { + return + } + + fmt.Println() + fmt.Println(tui.BaseTextStyle.Render(fmt.Sprintf("Versions (%d):", len(p.Versions)))) + + cellStyle := tui.BaseTextStyle.Padding(0, 1) + + dimStyle := tui.DimStyle.Padding(0, 1) + + headers := []string{"VERSION", "STATUS", "PYTHON", "CREATED", "TASKS"} + + createdCol := slices.Index(headers, "CREATED") + + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(tui.TableBorderStyle). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return cellStyle.Bold(true) + } + + if col == createdCol { + return dimStyle + } + + return cellStyle + }). + Headers(headers...) + + for _, ver := range p.Versions { + tasks := emptyValuePlaceholder + if len(ver.TaskNames) > 0 { + tasks = strings.Join(ver.TaskNames, ", ") + } + + python := ver.PythonVersion + if python == "" { + python = emptyValuePlaceholder + } + + t.Row( + "v"+strconv.Itoa(ver.Version), + ver.Status, + python, + ver.CreatedAt.UTC().Format(timestampFormat), + tasks, + ) + } + + fmt.Fprintln(os.Stdout, t.Render()) + + for _, ver := range p.Versions { + if ver.ErrorDetail == "" { + continue + } + + fmt.Println(tui.DimStyle.Render(fmt.Sprintf(" v%d error: %s", ver.Version, ver.ErrorDetail))) + } +} + +func printCreateResponseHuman(result pipelines.CreateResponse) { + tasks := emptyValuePlaceholder + if len(result.TaskNames) > 0 { + tasks = strings.Join(result.TaskNames, ", ") + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Pipeline ID:\t%s\n", result.PipelineID) + fmt.Fprintf(w, "Name:\t%s\n", result.Name) + fmt.Fprintf(w, "Version:\t%d\n", result.Version) + fmt.Fprintf(w, "Status:\t%s\n", result.Status) + fmt.Fprintf(w, "Mode:\t%s\n", result.Mode) + fmt.Fprintf(w, "Tasks:\t%s\n", tasks) + fmt.Fprintf(w, "Created:\t%s\n", result.CreatedAt.UTC().Format(timestampFormat)) + + w.Flush() +} diff --git a/cmd/pipelines/run/create/cmd.go b/cmd/pipelines/run/create/cmd.go index 6705128bb..3ff2e0c93 100644 --- a/cmd/pipelines/run/create/cmd.go +++ b/cmd/pipelines/run/create/cmd.go @@ -16,8 +16,8 @@ package create import ( "errors" - "fmt" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/cmd/pipelines/run/runutil" "github.com/datarobot/cli/cmd/pipelines/scopeflag" "github.com/datarobot/cli/internal/auth" @@ -29,7 +29,7 @@ func Cmd() *cobra.Command { var ( flags scopeflag.Flags inputID string - outputFormat string + outputFormat outputfmt.OutputFormat ) cmd := &cobra.Command{ @@ -42,15 +42,11 @@ or ` + "`dr pipelines run status`" + ` to follow its progress. Example: dr pipelines run create --pipeline --input - dr pipelines run create --pipeline --version=2 --input --output json`, + dr pipelines run create --pipeline --version=2 --input --output-format json`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - if flags.PipelineID == "" { return errors.New("--pipeline is required") } @@ -69,19 +65,13 @@ Example: return err } - if outputFormat == "json" { - return runutil.PrintRunJSON(*result) - } - - runutil.PrintRunHuman(*result) - - return nil + return runutil.RenderRun(outputFormat, *result) }, } flags.Bind(cmd) cmd.Flags().StringVar(&inputID, "input", "", "Input ID to trigger the run with") - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/run/create/cmd_test.go b/cmd/pipelines/run/create/cmd_test.go index c4cac589d..925cc6e26 100644 --- a/cmd/pipelines/run/create/cmd_test.go +++ b/cmd/pipelines/run/create/cmd_test.go @@ -35,7 +35,7 @@ func runCmd(t *testing.T, args ...string) error { } func TestCmd_RejectsInvalidOutput(t *testing.T) { - err := runCmd(t, "--pipeline", "p", "--input", "in-1", "--output", "yaml") + err := runCmd(t, "--pipeline", "p", "--input", "in-1", "--output-format", "yaml") require.Error(t, err) assert.Contains(t, err.Error(), "invalid output format") } @@ -61,7 +61,7 @@ func TestCmd_RejectsBadScopeCombo(t *testing.T) { func TestCmd_HasExpectedFlags(t *testing.T) { cmd := Cmd() - for _, name := range []string{"pipeline", "scope", "version", "input", "output"} { + for _, name := range []string{"pipeline", "scope", "version", "input", "output-format"} { assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) } } diff --git a/cmd/pipelines/run/get/cmd.go b/cmd/pipelines/run/get/cmd.go index fc40b17c4..c04ea8134 100644 --- a/cmd/pipelines/run/get/cmd.go +++ b/cmd/pipelines/run/get/cmd.go @@ -19,6 +19,7 @@ import ( "fmt" "net/http" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/cmd/pipelines/run/runutil" "github.com/datarobot/cli/cmd/pipelines/scopeflag" "github.com/datarobot/cli/internal/auth" @@ -31,7 +32,7 @@ import ( func Cmd() *cobra.Command { var ( flags scopeflag.Flags - outputFormat string + outputFormat outputfmt.OutputFormat ) cmd := &cobra.Command{ @@ -41,15 +42,11 @@ func Cmd() *cobra.Command { Example: dr pipelines run get --pipeline - dr pipelines run get --pipeline --version=2 --output json`, + dr pipelines run get --pipeline --version=2 --output-format json`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - if flags.PipelineID == "" { return errors.New("--pipeline is required") } @@ -64,18 +61,12 @@ Example: return handleGetError(err, args[0]) } - if outputFormat == "json" { - return runutil.PrintRunJSON(*result) - } - - runutil.PrintRunHuman(*result) - - return nil + return runutil.RenderRun(outputFormat, *result) }, } flags.Bind(cmd) - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/run/get/cmd_test.go b/cmd/pipelines/run/get/cmd_test.go index 8a69bba87..a7fe10af8 100644 --- a/cmd/pipelines/run/get/cmd_test.go +++ b/cmd/pipelines/run/get/cmd_test.go @@ -38,7 +38,7 @@ func runCmd(t *testing.T, args ...string) error { } func TestCmd_RejectsInvalidOutput(t *testing.T) { - err := runCmd(t, "--pipeline", "p", "--output", "yaml", "d-1") + err := runCmd(t, "--pipeline", "p", "--output-format", "yaml", "d-1") require.Error(t, err) assert.Contains(t, err.Error(), "invalid output format") } diff --git a/cmd/pipelines/run/list/cmd.go b/cmd/pipelines/run/list/cmd.go index c1f699619..411f20f22 100644 --- a/cmd/pipelines/run/list/cmd.go +++ b/cmd/pipelines/run/list/cmd.go @@ -16,8 +16,8 @@ package list import ( "errors" - "fmt" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/cmd/pipelines/run/runutil" "github.com/datarobot/cli/cmd/pipelines/scopeflag" "github.com/datarobot/cli/internal/auth" @@ -30,7 +30,7 @@ func Cmd() *cobra.Command { flags scopeflag.Flags offset int limit int - outputFormat string + outputFormat outputfmt.OutputFormat ) cmd := &cobra.Command{ @@ -40,15 +40,11 @@ func Cmd() *cobra.Command { Example: dr pipelines run list --pipeline - dr pipelines run list --pipeline --version=2 --output json`, + dr pipelines run list --pipeline --version=2 --output-format json`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - if flags.PipelineID == "" { return errors.New("--pipeline is required") } @@ -63,20 +59,14 @@ Example: return err } - if outputFormat == "json" { - return runutil.PrintRunListJSON(items) - } - - runutil.PrintRunListHuman(items) - - return nil + return runutil.RenderRuns(outputFormat, items) }, } flags.Bind(cmd) cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of runs to return") - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/run/list/cmd_test.go b/cmd/pipelines/run/list/cmd_test.go index 63eac2187..5ccb8e15a 100644 --- a/cmd/pipelines/run/list/cmd_test.go +++ b/cmd/pipelines/run/list/cmd_test.go @@ -35,7 +35,7 @@ func runCmd(t *testing.T, args ...string) error { } func TestCmd_RejectsInvalidOutput(t *testing.T) { - err := runCmd(t, "--pipeline", "p", "--output", "yaml") + err := runCmd(t, "--pipeline", "p", "--output-format", "yaml") require.Error(t, err) assert.Contains(t, err.Error(), "invalid output format") } @@ -55,7 +55,7 @@ func TestCmd_RejectsBadScopeCombo(t *testing.T) { func TestCmd_HasExpectedFlags(t *testing.T) { cmd := Cmd() - for _, name := range []string{"pipeline", "scope", "version", "offset", "limit", "output"} { + for _, name := range []string{"pipeline", "scope", "version", "offset", "limit", "output-format"} { assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) } } diff --git a/cmd/pipelines/run/runutil/render.go b/cmd/pipelines/run/runutil/render.go index 779290385..8f8948362 100644 --- a/cmd/pipelines/run/runutil/render.go +++ b/cmd/pipelines/run/runutil/render.go @@ -22,15 +22,24 @@ import ( "encoding/json" "fmt" "os" + "slices" "strconv" "text/tabwriter" "time" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/internal/pipelines" "github.com/datarobot/cli/tui" ) -// runJSON is the CLI-facing shape used for `--output json`. It mirrors +const ( + timestampFormat = "2006-01-02 15:04 UTC" + emptyValuePlaceholder = "—" +) + +// runJSON is the CLI-facing shape used for `--output-format json`. It mirrors // pipelines.Run but renames the wire-level fields to the CLI's `run` // vocabulary (`run_id`, `covalent_run_id`). Decoding still happens // through pipelines.Run, which keeps the API wire tags intact. @@ -77,6 +86,39 @@ func toRunStatusJSON(s pipelines.RunStatus) runStatusJSON { } } +// RenderRun routes a single run to JSON or human output. +func RenderRun(format outputfmt.OutputFormat, r pipelines.Run) error { + if format == outputfmt.OutputFormatJSON { + return PrintRunJSON(r) + } + + PrintRunHuman(r) + + return nil +} + +// RenderRuns routes a list of runs to JSON or human output. +func RenderRuns(format outputfmt.OutputFormat, items []pipelines.Run) error { + if format == outputfmt.OutputFormatJSON { + return PrintRunListJSON(items) + } + + PrintRunListHuman(items) + + return nil +} + +// RenderRunStatus routes a run status to JSON or human output. +func RenderRunStatus(format outputfmt.OutputFormat, s pipelines.RunStatus) error { + if format == outputfmt.OutputFormatJSON { + return PrintStatusJSON(s) + } + + PrintStatusHuman(s) + + return nil +} + // PrintRunJSON marshals a run as indented JSON using CLI-vocabulary keys. func PrintRunJSON(r pipelines.Run) error { data, err := json.MarshalIndent(toRunJSON(r), "", " ") @@ -92,7 +134,7 @@ func PrintRunJSON(r pipelines.Run) error { // PrintRunHuman renders a single run in a human-friendly form. func PrintRunHuman(r pipelines.Run) { scope := "draft" - versionDisplay := "\u2014" + versionDisplay := emptyValuePlaceholder if r.VersionID != nil { scope = "locked" @@ -101,30 +143,35 @@ func PrintRunHuman(r pipelines.Run) { covalent := r.CovalentDispatchID if covalent == "" { - covalent = "\u2014" + covalent = emptyValuePlaceholder } - fmt.Println(tui.BaseTextStyle.Render("Run ID: " + r.RunID)) - fmt.Println(tui.BaseTextStyle.Render("Pipeline ID: " + r.PipelineID)) - fmt.Println(tui.BaseTextStyle.Render("Scope: " + scope)) - fmt.Println(tui.BaseTextStyle.Render("Version: " + versionDisplay)) - fmt.Println(tui.BaseTextStyle.Render("Input ID: " + r.InputID)) - fmt.Println(tui.BaseTextStyle.Render("Status: " + r.Status)) - fmt.Println(tui.BaseTextStyle.Render("Triggered By: " + r.TriggeredBy)) - fmt.Println(tui.BaseTextStyle.Render("Covalent Run: " + covalent)) + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Run ID:\t%s\n", r.RunID) + fmt.Fprintf(w, "Pipeline ID:\t%s\n", r.PipelineID) + fmt.Fprintf(w, "Scope:\t%s\n", scope) + fmt.Fprintf(w, "Version:\t%s\n", versionDisplay) + fmt.Fprintf(w, "Input ID:\t%s\n", r.InputID) + fmt.Fprintf(w, "Status:\t%s\n", r.Status) + fmt.Fprintf(w, "Triggered By:\t%s\n", r.TriggeredBy) + fmt.Fprintf(w, "Covalent Run:\t%s\n", covalent) if r.ErrorDetail != "" { - fmt.Println(tui.BaseTextStyle.Render("Error: " + r.ErrorDetail)) + fmt.Fprintf(w, "Error:\t%s\n", r.ErrorDetail) } - fmt.Println(tui.DimStyle.Render("Created: " + r.CreatedAt.UTC().Format(time.RFC3339))) - fmt.Println(tui.DimStyle.Render("Updated: " + r.UpdatedAt.UTC().Format(time.RFC3339))) + fmt.Fprintf(w, "Created:\t%s\n", r.CreatedAt.UTC().Format(timestampFormat)) + fmt.Fprintf(w, "Updated:\t%s\n", r.UpdatedAt.UTC().Format(timestampFormat)) + + w.Flush() } // PrintRunListJSON marshals a list of runs as indented JSON using // CLI-vocabulary keys. func PrintRunListJSON(items []pipelines.Run) error { view := make([]runJSON, len(items)) + for i, r := range items { view[i] = toRunJSON(r) } @@ -139,7 +186,7 @@ func PrintRunListJSON(items []pipelines.Run) error { return nil } -// PrintRunListHuman renders a tabular summary of runs. +// PrintRunListHuman renders a lipgloss table summary of runs. func PrintRunListHuman(items []pipelines.Run) { if len(items) == 0 { fmt.Println(tui.DimStyle.Render("No runs found")) @@ -147,25 +194,43 @@ func PrintRunListHuman(items []pipelines.Run) { return } - writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + cellStyle := tui.BaseTextStyle.Padding(0, 1) - fmt.Fprintln(writer, "RUN_ID\tSCOPE\tVERSION\tSTATUS\tTRIGGER\tUPDATED") + dimStyle := tui.DimStyle.Padding(0, 1) + + headers := []string{"RUN ID", "SCOPE", "VERSION", "STATUS", "TRIGGER", "UPDATED"} + + updatedCol := slices.Index(headers, "UPDATED") + + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(tui.TableBorderStyle). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return cellStyle.Bold(true) + } + + if col == updatedCol { + return dimStyle + } + + return cellStyle + }). + Headers(headers...) for _, r := range items { scope := "draft" - ver := "\u2014" + ver := emptyValuePlaceholder if r.VersionID != nil { scope = "locked" ver = "v" + strconv.Itoa(*r.VersionID) } - fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\t%s\n", - r.RunID, scope, ver, r.Status, r.TriggeredBy, r.UpdatedAt.UTC().Format(time.RFC3339), - ) + t.Row(r.RunID, scope, ver, r.Status, r.TriggeredBy, r.UpdatedAt.UTC().Format(timestampFormat)) } - _ = writer.Flush() + fmt.Fprintln(os.Stdout, t.Render()) } // PrintStatusJSON marshals a lightweight status response as indented JSON @@ -185,10 +250,14 @@ func PrintStatusJSON(s pipelines.RunStatus) error { func PrintStatusHuman(s pipelines.RunStatus) { covalent := s.CovalentDispatchID if covalent == "" { - covalent = "\u2014" + covalent = emptyValuePlaceholder } - fmt.Println(tui.BaseTextStyle.Render("Run ID: " + s.RunID)) - fmt.Println(tui.BaseTextStyle.Render("Status: " + s.Status)) - fmt.Println(tui.BaseTextStyle.Render("Covalent Run: " + covalent)) + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Run ID:\t%s\n", s.RunID) + fmt.Fprintf(w, "Status:\t%s\n", s.Status) + fmt.Fprintf(w, "Covalent Run:\t%s\n", covalent) + + w.Flush() } diff --git a/cmd/pipelines/run/runutil/render_test.go b/cmd/pipelines/run/runutil/render_test.go index 17577e158..98c38f5b4 100644 --- a/cmd/pipelines/run/runutil/render_test.go +++ b/cmd/pipelines/run/runutil/render_test.go @@ -20,6 +20,7 @@ import ( "io" "os" "testing" + "time" "github.com/datarobot/cli/internal/pipelines" "github.com/stretchr/testify/assert" @@ -55,8 +56,8 @@ func sampleDraftRun() pipelines.Run { InputID: "in-1", TriggeredBy: "user@example.com", Status: pipelines.RunStatusPending, - CreatedAt: "2026-04-29T10:00:00Z", - UpdatedAt: "2026-04-29T10:00:00Z", + CreatedAt: time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC), } } @@ -85,11 +86,15 @@ func TestPrintRunJSON(t *testing.T) { func TestPrintRunHuman_DraftMissingCovalent(t *testing.T) { output := captureStdout(t, func() { PrintRunHuman(sampleDraftRun()) }) - assert.Contains(t, output, "Run ID: d-1") - assert.Contains(t, output, "Scope: draft") - assert.Contains(t, output, "Version: \u2014") - assert.Contains(t, output, "Covalent Run: \u2014") - assert.Contains(t, output, "Status: PENDING") + assert.Contains(t, output, "Run ID:") + assert.Contains(t, output, "d-1") + assert.Contains(t, output, "Scope:") + assert.Contains(t, output, "draft") + assert.Contains(t, output, "Version:") + assert.Contains(t, output, "\u2014") + assert.Contains(t, output, "Covalent Run:") + assert.Contains(t, output, "Status:") + assert.Contains(t, output, "PENDING") } func TestPrintRunHuman_LockedShowsErrorWhenSet(t *testing.T) { @@ -98,10 +103,10 @@ func TestPrintRunHuman_LockedShowsErrorWhenSet(t *testing.T) { r.ErrorDetail = "boom" output := captureStdout(t, func() { PrintRunHuman(r) }) - assert.Contains(t, output, "Scope: locked") - assert.Contains(t, output, "Version: v3") - assert.Contains(t, output, "Covalent Run: cov-xyz") - assert.Contains(t, output, "Error: boom") + assert.Contains(t, output, "locked") + assert.Contains(t, output, "v3") + assert.Contains(t, output, "cov-xyz") + assert.Contains(t, output, "boom") } func TestPrintRunListJSON(t *testing.T) { @@ -126,7 +131,7 @@ func TestPrintRunListHuman_RendersTable(t *testing.T) { PrintRunListHuman([]pipelines.Run{sampleDraftRun(), sampleLockedRun()}) }) - assert.Contains(t, output, "RUN_ID") + assert.Contains(t, output, "RUN") assert.Contains(t, output, "STATUS") assert.Contains(t, output, "TRIGGER") assert.Contains(t, output, "draft") @@ -160,7 +165,10 @@ func TestPrintStatusHuman_NoCovalentRunID(t *testing.T) { PrintStatusHuman(pipelines.RunStatus{RunID: "d-1", Status: "PENDING"}) }) - assert.Contains(t, output, "Run ID: d-1") - assert.Contains(t, output, "Status: PENDING") - assert.Contains(t, output, "Covalent Run: \u2014") + assert.Contains(t, output, "Run ID:") + assert.Contains(t, output, "d-1") + assert.Contains(t, output, "Status:") + assert.Contains(t, output, "PENDING") + assert.Contains(t, output, "Covalent Run:") + assert.Contains(t, output, "\u2014") } diff --git a/cmd/pipelines/run/status/cmd.go b/cmd/pipelines/run/status/cmd.go index b3e899eab..d5830ce2a 100644 --- a/cmd/pipelines/run/status/cmd.go +++ b/cmd/pipelines/run/status/cmd.go @@ -19,6 +19,7 @@ import ( "fmt" "net/http" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/cmd/pipelines/run/runutil" "github.com/datarobot/cli/cmd/pipelines/scopeflag" "github.com/datarobot/cli/internal/auth" @@ -31,7 +32,7 @@ import ( func Cmd() *cobra.Command { var ( flags scopeflag.Flags - outputFormat string + outputFormat outputfmt.OutputFormat ) cmd := &cobra.Command{ @@ -41,15 +42,11 @@ func Cmd() *cobra.Command { Example: dr pipelines run status --pipeline - dr pipelines run status --pipeline --version=2 --output json`, + dr pipelines run status --pipeline --version=2 --output-format json`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - if flags.PipelineID == "" { return errors.New("--pipeline is required") } @@ -64,18 +61,12 @@ Example: return handleStatusError(err, args[0]) } - if outputFormat == "json" { - return runutil.PrintStatusJSON(*result) - } - - runutil.PrintStatusHuman(*result) - - return nil + return runutil.RenderRunStatus(outputFormat, *result) }, } flags.Bind(cmd) - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/run/status/cmd_test.go b/cmd/pipelines/run/status/cmd_test.go index 9efb4a236..061cb698b 100644 --- a/cmd/pipelines/run/status/cmd_test.go +++ b/cmd/pipelines/run/status/cmd_test.go @@ -38,7 +38,7 @@ func runCmd(t *testing.T, args ...string) error { } func TestCmd_RejectsInvalidOutput(t *testing.T) { - err := runCmd(t, "--pipeline", "p", "--output", "yaml", "d-1") + err := runCmd(t, "--pipeline", "p", "--output-format", "yaml", "d-1") require.Error(t, err) assert.Contains(t, err.Error(), "invalid output format") } diff --git a/cmd/pipelines/schedule/create/cmd.go b/cmd/pipelines/schedule/create/cmd.go index cba617f14..0deee1393 100644 --- a/cmd/pipelines/schedule/create/cmd.go +++ b/cmd/pipelines/schedule/create/cmd.go @@ -16,8 +16,8 @@ package create import ( "errors" - "fmt" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/cmd/pipelines/schedule/scheduleutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" @@ -31,7 +31,7 @@ func Cmd() *cobra.Command { cron string inputID string timezone string - outputFormat string + outputFormat outputfmt.OutputFormat ) cmd := &cobra.Command{ @@ -46,10 +46,6 @@ Example: PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(_ *cobra.Command, _ []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - if pipelineID == "" { return errors.New("--pipeline is required") } @@ -77,13 +73,7 @@ Example: return err } - if outputFormat == "json" { - return scheduleutil.PrintScheduleJSON(*result) - } - - scheduleutil.PrintScheduleHuman(*result) - - return nil + return scheduleutil.RenderSchedule(outputFormat, *result) }, } @@ -92,7 +82,7 @@ Example: cmd.Flags().StringVar(&cron, "cron", "", "Cron expression, e.g. \"0 * * * *\"") cmd.Flags().StringVar(&inputID, "input", "", "Input ID to run on each tick") cmd.Flags().StringVar(&timezone, "timezone", "", "IANA timezone name (default UTC)") - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/schedule/create/cmd_test.go b/cmd/pipelines/schedule/create/cmd_test.go index 5d2d64639..e835f86ec 100644 --- a/cmd/pipelines/schedule/create/cmd_test.go +++ b/cmd/pipelines/schedule/create/cmd_test.go @@ -38,7 +38,7 @@ func TestCmd_RejectsInvalidOutput(t *testing.T) { err := runCmd(t, "--pipeline", "p", "--version", "2", "--cron", "0 * * * *", "--input", "in-1", - "--output", "yaml", + "--output-format", "yaml", ) require.Error(t, err) assert.Contains(t, err.Error(), "invalid output format") @@ -71,7 +71,7 @@ func TestCmd_RejectsMissingInput(t *testing.T) { func TestCmd_HasExpectedFlags(t *testing.T) { cmd := Cmd() - for _, name := range []string{"pipeline", "version", "cron", "input", "timezone", "output"} { + for _, name := range []string{"pipeline", "version", "cron", "input", "timezone", "output-format"} { assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) } } diff --git a/cmd/pipelines/schedule/get/cmd.go b/cmd/pipelines/schedule/get/cmd.go index 7dea7c877..5f9271719 100644 --- a/cmd/pipelines/schedule/get/cmd.go +++ b/cmd/pipelines/schedule/get/cmd.go @@ -19,6 +19,7 @@ import ( "fmt" "net/http" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/cmd/pipelines/schedule/scheduleutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" @@ -31,7 +32,7 @@ func Cmd() *cobra.Command { var ( pipelineID string version int - outputFormat string + outputFormat outputfmt.OutputFormat ) cmd := &cobra.Command{ @@ -41,15 +42,11 @@ func Cmd() *cobra.Command { Example: dr pipelines schedule get --pipeline --version=2 - dr pipelines schedule get --pipeline --version=2 --output json`, + dr pipelines schedule get --pipeline --version=2 --output-format json`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(_ *cobra.Command, args []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - if pipelineID == "" { return errors.New("--pipeline is required") } @@ -63,19 +60,13 @@ Example: return handleGetError(err, args[0]) } - if outputFormat == "json" { - return scheduleutil.PrintScheduleJSON(*result) - } - - scheduleutil.PrintScheduleHuman(*result) - - return nil + return scheduleutil.RenderSchedule(outputFormat, *result) }, } cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/schedule/get/cmd_test.go b/cmd/pipelines/schedule/get/cmd_test.go index 2e6495193..71b9d5609 100644 --- a/cmd/pipelines/schedule/get/cmd_test.go +++ b/cmd/pipelines/schedule/get/cmd_test.go @@ -38,7 +38,7 @@ func runCmd(t *testing.T, args ...string) error { } func TestCmd_RejectsInvalidOutput(t *testing.T) { - err := runCmd(t, "--pipeline", "p", "--version", "2", "--output", "yaml", "s-1") + err := runCmd(t, "--pipeline", "p", "--version", "2", "--output-format", "yaml", "s-1") require.Error(t, err) assert.Contains(t, err.Error(), "invalid output format") } diff --git a/cmd/pipelines/schedule/list/cmd.go b/cmd/pipelines/schedule/list/cmd.go index 7a31c376a..bebfd85c5 100644 --- a/cmd/pipelines/schedule/list/cmd.go +++ b/cmd/pipelines/schedule/list/cmd.go @@ -16,8 +16,8 @@ package list import ( "errors" - "fmt" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/cmd/pipelines/schedule/scheduleutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" @@ -30,7 +30,7 @@ func Cmd() *cobra.Command { version int offset int limit int - outputFormat string + outputFormat outputfmt.OutputFormat ) cmd := &cobra.Command{ @@ -40,15 +40,11 @@ func Cmd() *cobra.Command { Example: dr pipelines schedule list --pipeline --version=2 - dr pipelines schedule list --pipeline --version=2 --output json`, + dr pipelines schedule list --pipeline --version=2 --output-format json`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(_ *cobra.Command, _ []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - if pipelineID == "" { return errors.New("--pipeline is required") } @@ -62,13 +58,7 @@ Example: return err } - if outputFormat == "json" { - return scheduleutil.PrintScheduleListJSON(items) - } - - scheduleutil.PrintScheduleListHuman(items) - - return nil + return scheduleutil.RenderSchedules(outputFormat, items) }, } @@ -76,7 +66,7 @@ Example: cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of schedules to return") - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/schedule/list/cmd_test.go b/cmd/pipelines/schedule/list/cmd_test.go index a819dd2a8..882700350 100644 --- a/cmd/pipelines/schedule/list/cmd_test.go +++ b/cmd/pipelines/schedule/list/cmd_test.go @@ -35,7 +35,7 @@ func runCmd(t *testing.T, args ...string) error { } func TestCmd_RejectsInvalidOutput(t *testing.T) { - err := runCmd(t, "--pipeline", "p", "--version", "2", "--output", "yaml") + err := runCmd(t, "--pipeline", "p", "--version", "2", "--output-format", "yaml") require.Error(t, err) assert.Contains(t, err.Error(), "invalid output format") } @@ -55,7 +55,7 @@ func TestCmd_RejectsZeroVersion(t *testing.T) { func TestCmd_HasExpectedFlags(t *testing.T) { cmd := Cmd() - for _, name := range []string{"pipeline", "version", "offset", "limit", "output"} { + for _, name := range []string{"pipeline", "version", "offset", "limit", "output-format"} { assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) } } diff --git a/cmd/pipelines/schedule/scheduleutil/render.go b/cmd/pipelines/schedule/scheduleutil/render.go index 5507bfd25..87127bd42 100644 --- a/cmd/pipelines/schedule/scheduleutil/render.go +++ b/cmd/pipelines/schedule/scheduleutil/render.go @@ -22,17 +22,72 @@ import ( "encoding/json" "fmt" "os" - "strconv" + "slices" "text/tabwriter" "time" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/internal/pipelines" "github.com/datarobot/cli/tui" ) -// PrintScheduleJSON marshals a schedule as indented JSON. +const ( + timestampFormat = "2006-01-02 15:04 UTC" + emptyValuePlaceholder = "—" +) + +// scheduleJSON is the CLI-facing DTO used for `--output-format json`. +type scheduleJSON struct { + ScheduleID string `json:"schedule_id"` + PipelineID string `json:"pipeline_id"` + Version int `json:"version"` + CronExpression string `json:"cron_expression"` + Timezone string `json:"timezone"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toScheduleJSON(s pipelines.Schedule) scheduleJSON { + return scheduleJSON{ + ScheduleID: s.ScheduleID, + PipelineID: s.PipelineID, + Version: s.Version, + CronExpression: s.CronExpression, + Timezone: s.Timezone, + Status: string(s.Status), + CreatedAt: s.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: s.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +// RenderSchedule routes a single schedule to JSON or human output. +func RenderSchedule(format outputfmt.OutputFormat, s pipelines.Schedule) error { + if format == outputfmt.OutputFormatJSON { + return PrintScheduleJSON(s) + } + + PrintScheduleHuman(s) + + return nil +} + +// RenderSchedules routes a list of schedules to JSON or human output. +func RenderSchedules(format outputfmt.OutputFormat, items []pipelines.Schedule) error { + if format == outputfmt.OutputFormatJSON { + return PrintScheduleListJSON(items) + } + + PrintScheduleListHuman(items) + + return nil +} + +// PrintScheduleJSON marshals a schedule as indented JSON through the DTO. func PrintScheduleJSON(s pipelines.Schedule) error { - data, err := json.MarshalIndent(s, "", " ") + data, err := json.MarshalIndent(toScheduleJSON(s), "", " ") if err != nil { return err } @@ -44,19 +99,29 @@ func PrintScheduleJSON(s pipelines.Schedule) error { // PrintScheduleHuman renders a single schedule in human-friendly form. func PrintScheduleHuman(s pipelines.Schedule) { - fmt.Println(tui.BaseTextStyle.Render("Schedule ID: " + s.ScheduleID)) - fmt.Println(tui.BaseTextStyle.Render("Pipeline ID: " + s.PipelineID)) - fmt.Println(tui.BaseTextStyle.Render("Version: v" + strconv.Itoa(s.Version))) - fmt.Println(tui.BaseTextStyle.Render("Cron: " + s.CronExpression)) - fmt.Println(tui.BaseTextStyle.Render("Timezone: " + s.Timezone)) - fmt.Println(tui.BaseTextStyle.Render("Status: " + string(s.Status))) - fmt.Println(tui.DimStyle.Render("Created: " + s.CreatedAt.UTC().Format(time.RFC3339))) - fmt.Println(tui.DimStyle.Render("Updated: " + s.UpdatedAt.UTC().Format(time.RFC3339))) + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Schedule ID:\t%s\n", s.ScheduleID) + fmt.Fprintf(w, "Pipeline ID:\t%s\n", s.PipelineID) + fmt.Fprintf(w, "Version:\tv%d\n", s.Version) + fmt.Fprintf(w, "Cron:\t%s\n", s.CronExpression) + fmt.Fprintf(w, "Timezone:\t%s\n", s.Timezone) + fmt.Fprintf(w, "Status:\t%s\n", string(s.Status)) + fmt.Fprintf(w, "Created:\t%s\n", s.CreatedAt.UTC().Format(timestampFormat)) + fmt.Fprintf(w, "Updated:\t%s\n", s.UpdatedAt.UTC().Format(timestampFormat)) + + w.Flush() } -// PrintScheduleListJSON marshals a list of schedules as indented JSON. +// PrintScheduleListJSON marshals a list of schedules as indented JSON through the DTO. func PrintScheduleListJSON(items []pipelines.Schedule) error { - data, err := json.MarshalIndent(items, "", " ") + view := make([]scheduleJSON, len(items)) + + for i, s := range items { + view[i] = toScheduleJSON(s) + } + + data, err := json.MarshalIndent(view, "", " ") if err != nil { return err } @@ -66,7 +131,7 @@ func PrintScheduleListJSON(items []pipelines.Schedule) error { return nil } -// PrintScheduleListHuman renders a tabular summary of schedules. +// PrintScheduleListHuman renders a lipgloss table summary of schedules. func PrintScheduleListHuman(items []pipelines.Schedule) { if len(items) == 0 { fmt.Println(tui.DimStyle.Render("No schedules found")) @@ -74,15 +139,40 @@ func PrintScheduleListHuman(items []pipelines.Schedule) { return } - writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + cellStyle := tui.BaseTextStyle.Padding(0, 1) + + dimStyle := tui.DimStyle.Padding(0, 1) + + headers := []string{"SCHEDULE ID", "VERSION", "CRON", "TIMEZONE", "STATUS", "UPDATED"} + + updatedCol := slices.Index(headers, "UPDATED") + + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(tui.TableBorderStyle). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return cellStyle.Bold(true) + } + + if col == updatedCol { + return dimStyle + } - fmt.Fprintln(writer, "SCHEDULE_ID\tVERSION\tCRON\tTIMEZONE\tSTATUS\tUPDATED") + return cellStyle + }). + Headers(headers...) for _, s := range items { - fmt.Fprintf(writer, "%s\tv%d\t%s\t%s\t%s\t%s\n", - s.ScheduleID, s.Version, s.CronExpression, s.Timezone, s.Status, s.UpdatedAt.UTC().Format(time.RFC3339), + t.Row( + s.ScheduleID, + fmt.Sprintf("v%d", s.Version), + s.CronExpression, + s.Timezone, + string(s.Status), + s.UpdatedAt.UTC().Format(timestampFormat), ) } - _ = writer.Flush() + fmt.Fprintln(os.Stdout, t.Render()) } diff --git a/cmd/pipelines/schedule/scheduleutil/render_test.go b/cmd/pipelines/schedule/scheduleutil/render_test.go index 648454842..e9442ccdd 100644 --- a/cmd/pipelines/schedule/scheduleutil/render_test.go +++ b/cmd/pipelines/schedule/scheduleutil/render_test.go @@ -20,6 +20,7 @@ import ( "io" "os" "testing" + "time" "github.com/datarobot/cli/internal/pipelines" "github.com/stretchr/testify/assert" @@ -54,8 +55,8 @@ func sampleSchedule() pipelines.Schedule { CronExpression: "0 * * * *", Timezone: "UTC", Status: pipelines.ScheduleStatusActive, - CreatedAt: "2026-04-29T10:00:00Z", - UpdatedAt: "2026-04-29T11:00:00Z", + CreatedAt: time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 29, 11, 0, 0, 0, time.UTC), } } @@ -103,7 +104,7 @@ func TestPrintScheduleListHuman_RendersTable(t *testing.T) { PrintScheduleListHuman([]pipelines.Schedule{sampleSchedule()}) }) - assert.Contains(t, output, "SCHEDULE_ID") + assert.Contains(t, output, "SCHEDULE") assert.Contains(t, output, "VERSION") assert.Contains(t, output, "CRON") assert.Contains(t, output, "TIMEZONE") diff --git a/cmd/pipelines/schedule/update/cmd.go b/cmd/pipelines/schedule/update/cmd.go index ad8fda218..6a77f189f 100644 --- a/cmd/pipelines/schedule/update/cmd.go +++ b/cmd/pipelines/schedule/update/cmd.go @@ -16,8 +16,8 @@ package update import ( "errors" - "fmt" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/cmd/pipelines/schedule/scheduleutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" @@ -30,7 +30,7 @@ func Cmd() *cobra.Command { version int cron string timezone string - outputFormat string + outputFormat outputfmt.OutputFormat ) cmd := &cobra.Command{ @@ -48,7 +48,7 @@ Example: PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - body, err := buildUpdateBody(cmd, pipelineID, version, cron, timezone, outputFormat) + body, err := buildUpdateBody(cmd, pipelineID, version, cron, timezone) if err != nil { return err } @@ -58,13 +58,7 @@ Example: return err } - if outputFormat == "json" { - return scheduleutil.PrintScheduleJSON(*result) - } - - scheduleutil.PrintScheduleHuman(*result) - - return nil + return scheduleutil.RenderSchedule(outputFormat, *result) }, } @@ -72,18 +66,14 @@ Example: cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") cmd.Flags().StringVar(&cron, "cron", "", "New cron expression") cmd.Flags().StringVar(&timezone, "timezone", "", "New IANA timezone name") - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } // buildUpdateBody validates the flag set and assembles the PATCH body. It is // extracted from RunE to keep the cobra command's cyclomatic complexity low. -func buildUpdateBody(cmd *cobra.Command, pipelineID string, version int, cron, timezone, outputFormat string) (pipelines.ScheduleUpdateRequest, error) { - if outputFormat != "" && outputFormat != "json" { - return pipelines.ScheduleUpdateRequest{}, fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - +func buildUpdateBody(cmd *cobra.Command, pipelineID string, version int, cron, timezone string) (pipelines.ScheduleUpdateRequest, error) { if pipelineID == "" { return pipelines.ScheduleUpdateRequest{}, errors.New("--pipeline is required") } diff --git a/cmd/pipelines/schedule/update/cmd_test.go b/cmd/pipelines/schedule/update/cmd_test.go index dbaeedddb..9cfc631a6 100644 --- a/cmd/pipelines/schedule/update/cmd_test.go +++ b/cmd/pipelines/schedule/update/cmd_test.go @@ -29,7 +29,7 @@ func TestBuildUpdateBody_RequiresAtLeastOneField(t *testing.T) { require.NoError(t, cmd.ParseFlags([]string{"--pipeline=p", "--version=2"})) - _, err := buildUpdateBody(cmd, "p", 2, "", "", "") + _, err := buildUpdateBody(cmd, "p", 2, "", "") require.Error(t, err) assert.Contains(t, err.Error(), "at least one of --cron") } @@ -45,7 +45,7 @@ func TestBuildUpdateBody_PicksUpChangedFlags(t *testing.T) { "--timezone=America/Los_Angeles", })) - body, err := buildUpdateBody(cmd, "p", 2, "*/5 * * * *", "America/Los_Angeles", "") + body, err := buildUpdateBody(cmd, "p", 2, "*/5 * * * *", "America/Los_Angeles") require.NoError(t, err) require.NotNil(t, body.CronExpression) require.NotNil(t, body.Timezone) @@ -64,7 +64,7 @@ func TestBuildUpdateBody_SkipsUnchangedFlags(t *testing.T) { "--cron=0 0 * * *", })) - body, err := buildUpdateBody(cmd, "p", 2, "0 0 * * *", "", "") + body, err := buildUpdateBody(cmd, "p", 2, "0 0 * * *", "") require.NoError(t, err) require.NotNil(t, body.CronExpression) assert.Equal(t, "0 0 * * *", *body.CronExpression) @@ -78,7 +78,7 @@ func TestBuildUpdateBody_RejectsMissingPipeline(t *testing.T) { require.NoError(t, cmd.ParseFlags([]string{"--cron=0 0 * * *"})) - _, err := buildUpdateBody(cmd, "", 2, "0 0 * * *", "", "") + _, err := buildUpdateBody(cmd, "", 2, "0 0 * * *", "") require.Error(t, err) assert.Contains(t, err.Error(), "--pipeline") } @@ -90,19 +90,19 @@ func TestBuildUpdateBody_RejectsZeroVersion(t *testing.T) { require.NoError(t, cmd.ParseFlags([]string{"--pipeline=p", "--cron=0 0 * * *"})) - _, err := buildUpdateBody(cmd, "p", 0, "0 0 * * *", "", "") + _, err := buildUpdateBody(cmd, "p", 0, "0 0 * * *", "") require.Error(t, err) assert.Contains(t, err.Error(), "--version") } -func TestBuildUpdateBody_RejectsInvalidOutput(t *testing.T) { +func TestCmd_RejectsInvalidOutput(t *testing.T) { cmd := Cmd() + cmd.SetArgs([]string{"sched-id", "--pipeline=p", "--version=2", "--cron=0 0 * * *", "--output-format=yaml"}) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) + cmd.PreRunE = nil - require.NoError(t, cmd.ParseFlags([]string{"--pipeline=p", "--version=2", "--cron=0 0 * * *"})) - - _, err := buildUpdateBody(cmd, "p", 2, "0 0 * * *", "", "yaml") + err := cmd.Execute() require.Error(t, err) assert.Contains(t, err.Error(), "invalid output format") } diff --git a/cmd/pipelines/update/cmd.go b/cmd/pipelines/update/cmd.go index 4f8511dd4..3a5e915a1 100644 --- a/cmd/pipelines/update/cmd.go +++ b/cmd/pipelines/update/cmd.go @@ -15,21 +15,18 @@ package update import ( - "encoding/json" "errors" - "fmt" - "strings" - "time" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" + "github.com/datarobot/cli/cmd/pipelines/pipelineutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" - "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) func Cmd() *cobra.Command { var ( - outputFormat string + outputFormat outputfmt.OutputFormat fromFile string ) @@ -45,19 +42,15 @@ be updated. The path to the Python file can be supplied either as a positional argument or via the --from-file= flag. Exactly one of the two must be provided. -By default, output is human-readable. Use --output json for machine-parseable output. +By default, output is human-readable. Use --output-format json for machine-parseable output. Example: dr pipelines update 507f1f77bcf86cd799439011 ./my_pipeline.py dr pipelines update 507f1f77bcf86cd799439011 --from-file=./my_pipeline.py - dr pipelines update 507f1f77bcf86cd799439011 --from-file=./my_pipeline.py --output json`, + dr pipelines update 507f1f77bcf86cd799439011 --from-file=./my_pipeline.py --output-format json`, Args: cobra.RangeArgs(1, 2), PreRunE: auth.EnsureAuthenticatedE, RunE: func(_ *cobra.Command, args []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - pipelineID := args[0] filePath, err := resolveFilePath(args[1:], fromFile) @@ -70,18 +63,12 @@ Example: return err } - if outputFormat == "json" { - return printUpdateJSON(*result) - } - - printUpdateHuman(*result) - - return nil + return pipelineutil.RenderCreateResponse(outputFormat, *result) }, } - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the Python file to upload, e.g. --from-file=./my_pipeline.py (alternative to the positional argument)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } @@ -105,29 +92,3 @@ func resolveFilePath(extraArgs []string, fromFile string) (string, error) { return "", errors.New("a file path is required (positional argument or --from-file)") } } - -func printUpdateJSON(result pipelines.CreateResponse) error { - data, err := json.MarshalIndent(result, "", " ") - if err != nil { - return err - } - - fmt.Println(string(data)) - - return nil -} - -func printUpdateHuman(result pipelines.CreateResponse) { - tasks := "\u2014" - if len(result.TaskNames) > 0 { - tasks = strings.Join(result.TaskNames, ", ") - } - - fmt.Println(tui.BaseTextStyle.Render("Pipeline ID: " + result.PipelineID)) - fmt.Println(tui.BaseTextStyle.Render("Name: " + result.Name)) - fmt.Println(tui.BaseTextStyle.Render(fmt.Sprintf("Version: %d", result.Version))) - fmt.Println(tui.BaseTextStyle.Render("Status: " + result.Status)) - fmt.Println(tui.BaseTextStyle.Render("Mode: " + result.Mode)) - fmt.Println(tui.BaseTextStyle.Render("Tasks: " + tasks)) - fmt.Println(tui.DimStyle.Render("Updated: " + result.CreatedAt.UTC().Format(time.RFC3339))) -} diff --git a/cmd/pipelines/update/cmd_test.go b/cmd/pipelines/update/cmd_test.go index f428f64a8..08570962f 100644 --- a/cmd/pipelines/update/cmd_test.go +++ b/cmd/pipelines/update/cmd_test.go @@ -22,6 +22,8 @@ import ( "testing" "time" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" + "github.com/datarobot/cli/cmd/pipelines/pipelineutil" "github.com/datarobot/cli/internal/pipelines" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -55,7 +57,7 @@ func sampleUpdateResponse() pipelines.CreateResponse { Status: "READY", Mode: "draft", TaskNames: []string{"create_vector_database"}, - CreatedAt: pipelines.Time{Time: time.Date(2026, 4, 28, 12, 24, 54, 0, time.UTC)}, + CreatedAt: time.Date(2026, 4, 28, 12, 24, 54, 0, time.UTC), } } @@ -63,7 +65,7 @@ func TestPrintUpdateJSON(t *testing.T) { resp := sampleUpdateResponse() output := captureStdout(t, func() { - err := printUpdateJSON(resp) + err := pipelineutil.RenderCreateResponse(outputfmt.OutputFormatJSON, resp) require.NoError(t, err) }) @@ -80,14 +82,14 @@ func TestPrintUpdateHuman_WithTasks(t *testing.T) { resp := sampleUpdateResponse() output := captureStdout(t, func() { - printUpdateHuman(resp) + require.NoError(t, pipelineutil.RenderCreateResponse(outputfmt.OutputFormatText, resp)) }) - assert.Contains(t, output, "Pipeline ID: "+resp.PipelineID) - assert.Contains(t, output, "Name: confluence_to_vdb") - assert.Contains(t, output, "Version: 2") - assert.Contains(t, output, "Status: READY") - assert.Contains(t, output, "Mode: draft") + assert.Contains(t, output, resp.PipelineID) + assert.Contains(t, output, "confluence_to_vdb") + assert.Contains(t, output, "2") + assert.Contains(t, output, "READY") + assert.Contains(t, output, "draft") assert.Contains(t, output, "create_vector_database") } @@ -96,10 +98,10 @@ func TestPrintUpdateHuman_NoTasks(t *testing.T) { resp.TaskNames = nil output := captureStdout(t, func() { - printUpdateHuman(resp) + require.NoError(t, pipelineutil.RenderCreateResponse(outputfmt.OutputFormatText, resp)) }) - assert.Contains(t, output, "Tasks: \u2014") + assert.Contains(t, output, "—") } func TestCmd_RequiresPipelineID(t *testing.T) { @@ -139,7 +141,7 @@ func TestCmd_RejectsBothPositionalAndFromFile(t *testing.T) { func TestCmd_RejectsInvalidOutput(t *testing.T) { cmd := Cmd() - cmd.SetArgs([]string{"some-id", "some-file.py", "--output", "yaml"}) + cmd.SetArgs([]string{"some-id", "some-file.py", "--output-format", "yaml"}) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) cmd.PreRunE = nil @@ -152,7 +154,7 @@ func TestCmd_RejectsInvalidOutput(t *testing.T) { func TestCmd_HasExpectedFlags(t *testing.T) { cmd := Cmd() - for _, name := range []string{"output", "from-file"} { + for _, name := range []string{"output-format", "from-file"} { flag := cmd.Flags().Lookup(name) assert.NotNilf(t, flag, "expected --%s flag to be registered", name) } diff --git a/cmd/pipelines/version/get/cmd.go b/cmd/pipelines/version/get/cmd.go index adf9f5834..5fb4c3514 100644 --- a/cmd/pipelines/version/get/cmd.go +++ b/cmd/pipelines/version/get/cmd.go @@ -20,6 +20,7 @@ import ( "net/http" "strconv" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/cmd/pipelines/version/versionutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" @@ -31,7 +32,7 @@ import ( func Cmd() *cobra.Command { var ( pipelineID string - outputFormat string + outputFormat outputfmt.OutputFormat ) cmd := &cobra.Command{ @@ -41,15 +42,11 @@ func Cmd() *cobra.Command { Example: dr pipelines version get --pipeline 2 - dr pipelines version get --pipeline 2 --output json`, + dr pipelines version get --pipeline 2 --output-format json`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(_ *cobra.Command, args []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - if pipelineID == "" { return errors.New("--pipeline is required") } @@ -64,18 +61,12 @@ Example: return handleGetError(err, args[0]) } - if outputFormat == "json" { - return versionutil.PrintVersionJSON(*result) - } - - versionutil.PrintVersionHuman(*result) - - return nil + return versionutil.RenderVersion(outputFormat, *result) }, } cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/version/list/cmd.go b/cmd/pipelines/version/list/cmd.go index da701ba45..8456db175 100644 --- a/cmd/pipelines/version/list/cmd.go +++ b/cmd/pipelines/version/list/cmd.go @@ -16,8 +16,8 @@ package list import ( "errors" - "fmt" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/cmd/pipelines/version/versionutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" @@ -29,7 +29,7 @@ func Cmd() *cobra.Command { pipelineID string offset int limit int - outputFormat string + outputFormat outputfmt.OutputFormat ) cmd := &cobra.Command{ @@ -39,15 +39,11 @@ func Cmd() *cobra.Command { Example: dr pipelines version list --pipeline - dr pipelines version list --pipeline --offset 10 --limit 5 --output json`, + dr pipelines version list --pipeline --offset 10 --limit 5 --output-format json`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(_ *cobra.Command, _ []string) error { - if outputFormat != "" && outputFormat != "json" { - return fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - if pipelineID == "" { return errors.New("--pipeline is required") } @@ -57,20 +53,14 @@ Example: return err } - if outputFormat == "json" { - return versionutil.PrintVersionListJSON(items) - } - - versionutil.PrintVersionListHuman(items) - - return nil + return versionutil.RenderVersions(outputFormat, items) }, } cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of versions to return") - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + outputfmt.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/version/list/cmd_test.go b/cmd/pipelines/version/list/cmd_test.go index 33a6cd03e..47e77e50f 100644 --- a/cmd/pipelines/version/list/cmd_test.go +++ b/cmd/pipelines/version/list/cmd_test.go @@ -35,7 +35,7 @@ func runCmd(t *testing.T, args ...string) error { } func TestCmd_RejectsInvalidOutput(t *testing.T) { - err := runCmd(t, "--pipeline", "p", "--output", "yaml") + err := runCmd(t, "--pipeline", "p", "--output-format", "yaml") require.Error(t, err) assert.Contains(t, err.Error(), "invalid output format") } @@ -49,7 +49,7 @@ func TestCmd_RejectsMissingPipeline(t *testing.T) { func TestCmd_HasExpectedFlags(t *testing.T) { cmd := Cmd() - for _, name := range []string{"pipeline", "offset", "limit", "output"} { + for _, name := range []string{"pipeline", "offset", "limit", "output-format"} { assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) } } diff --git a/cmd/pipelines/version/versionutil/render.go b/cmd/pipelines/version/versionutil/render.go index a3ccbf0d3..89f3f447a 100644 --- a/cmd/pipelines/version/versionutil/render.go +++ b/cmd/pipelines/version/versionutil/render.go @@ -22,18 +22,74 @@ import ( "encoding/json" "fmt" "os" + "slices" "strconv" "strings" "text/tabwriter" "time" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/internal/pipelines" "github.com/datarobot/cli/tui" ) -// PrintVersionJSON marshals a single version as indented JSON. +const ( + timestampFormat = "2006-01-02 15:04 UTC" + emptyValuePlaceholder = "—" +) + +// versionJSON is the CLI-facing DTO used for `--output-format json`. +type versionJSON struct { + Version int `json:"version"` + Status string `json:"status"` + PipelineName string `json:"pipeline_name"` + TaskNames []string `json:"task_names,omitempty"` + PythonVersion string `json:"python_version,omitempty"` + ResourceBundle map[string]any `json:"resource_bundle,omitempty"` + ErrorDetail string `json:"error_detail,omitempty"` + CreatedAt string `json:"created_at"` +} + +func toVersionJSON(v pipelines.PipelineVersion) versionJSON { + return versionJSON{ + Version: v.Version, + Status: v.Status, + PipelineName: v.PipelineName, + TaskNames: v.TaskNames, + PythonVersion: v.PythonVersion, + ResourceBundle: v.ResourceBundle, + ErrorDetail: v.ErrorDetail, + CreatedAt: v.CreatedAt.UTC().Format(time.RFC3339), + } +} + +// RenderVersion routes a single version to JSON or human output. +func RenderVersion(format outputfmt.OutputFormat, v pipelines.PipelineVersion) error { + if format == outputfmt.OutputFormatJSON { + return PrintVersionJSON(v) + } + + PrintVersionHuman(v) + + return nil +} + +// RenderVersions routes a list of versions to JSON or human output. +func RenderVersions(format outputfmt.OutputFormat, items []pipelines.PipelineVersion) error { + if format == outputfmt.OutputFormatJSON { + return PrintVersionListJSON(items) + } + + PrintVersionListHuman(items) + + return nil +} + +// PrintVersionJSON marshals a single version as indented JSON through the DTO. func PrintVersionJSON(v pipelines.PipelineVersion) error { - data, err := json.MarshalIndent(v, "", " ") + data, err := json.MarshalIndent(toVersionJSON(v), "", " ") if err != nil { return err } @@ -45,32 +101,42 @@ func PrintVersionJSON(v pipelines.PipelineVersion) error { // PrintVersionHuman renders the key facts about a single version. func PrintVersionHuman(v pipelines.PipelineVersion) { - tasks := "\u2014" + tasks := emptyValuePlaceholder if len(v.TaskNames) > 0 { tasks = strings.Join(v.TaskNames, ", ") } python := v.PythonVersion if python == "" { - python = "\u2014" + python = emptyValuePlaceholder } - fmt.Println(tui.BaseTextStyle.Render("Version: v" + strconv.Itoa(v.Version))) - fmt.Println(tui.BaseTextStyle.Render("Pipeline: " + v.PipelineName)) - fmt.Println(tui.BaseTextStyle.Render("Status: " + v.Status)) - fmt.Println(tui.BaseTextStyle.Render("Python Version: " + python)) - fmt.Println(tui.BaseTextStyle.Render("Tasks: " + tasks)) + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Version:\tv%s\n", strconv.Itoa(v.Version)) + fmt.Fprintf(w, "Pipeline:\t%s\n", v.PipelineName) + fmt.Fprintf(w, "Status:\t%s\n", v.Status) + fmt.Fprintf(w, "Python Version:\t%s\n", python) + fmt.Fprintf(w, "Tasks:\t%s\n", tasks) if v.ErrorDetail != "" { - fmt.Println(tui.BaseTextStyle.Render("Error: " + v.ErrorDetail)) + fmt.Fprintf(w, "Error:\t%s\n", v.ErrorDetail) } - fmt.Println(tui.DimStyle.Render("Created: " + v.CreatedAt.UTC().Format(time.RFC3339))) + fmt.Fprintf(w, "Created:\t%s\n", v.CreatedAt.UTC().Format(timestampFormat)) + + w.Flush() } -// PrintVersionListJSON marshals a list of versions as indented JSON. +// PrintVersionListJSON marshals a list of versions as indented JSON through the DTO. func PrintVersionListJSON(items []pipelines.PipelineVersion) error { - data, err := json.MarshalIndent(items, "", " ") + view := make([]versionJSON, len(items)) + + for i, v := range items { + view[i] = toVersionJSON(v) + } + + data, err := json.MarshalIndent(view, "", " ") if err != nil { return err } @@ -80,7 +146,7 @@ func PrintVersionListJSON(items []pipelines.PipelineVersion) error { return nil } -// PrintVersionListHuman renders a tabular summary of versions. +// PrintVersionListHuman renders a lipgloss table summary of versions. func PrintVersionListHuman(items []pipelines.PipelineVersion) { if len(items) == 0 { fmt.Println(tui.DimStyle.Render("No versions found")) @@ -88,29 +154,49 @@ func PrintVersionListHuman(items []pipelines.PipelineVersion) { return } - writer := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + cellStyle := tui.BaseTextStyle.Padding(0, 1) + + dimStyle := tui.DimStyle.Padding(0, 1) + + headers := []string{"VERSION", "STATUS", "PYTHON", "CREATED", "TASKS"} + + createdCol := slices.Index(headers, "CREATED") + + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(tui.TableBorderStyle). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return cellStyle.Bold(true) + } + + if col == createdCol { + return dimStyle + } - fmt.Fprintln(writer, "VERSION\tSTATUS\tPYTHON\tCREATED\tTASKS") + return cellStyle + }). + Headers(headers...) for _, v := range items { - tasks := "\u2014" + tasks := emptyValuePlaceholder if len(v.TaskNames) > 0 { tasks = strings.Join(v.TaskNames, ", ") } python := v.PythonVersion if python == "" { - python = "\u2014" + python = emptyValuePlaceholder } - fmt.Fprintf(writer, "v%s\t%s\t%s\t%s\t%s\n", - strconv.Itoa(v.Version), + t.Row( + "v"+strconv.Itoa(v.Version), v.Status, python, - v.CreatedAt.UTC().Format(time.RFC3339), + v.CreatedAt.UTC().Format(timestampFormat), tasks, ) } - _ = writer.Flush() + fmt.Fprintln(os.Stdout, t.Render()) } diff --git a/cmd/pipelines/version/versionutil/render_test.go b/cmd/pipelines/version/versionutil/render_test.go index c4f3fa3d7..51a90c19d 100644 --- a/cmd/pipelines/version/versionutil/render_test.go +++ b/cmd/pipelines/version/versionutil/render_test.go @@ -54,7 +54,7 @@ func sampleVersion() pipelines.PipelineVersion { PipelineName: "wf", TaskNames: []string{"e1", "e2"}, PythonVersion: "3.12", - CreatedAt: pipelines.Time{Time: time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC)}, + CreatedAt: time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC), } } @@ -68,17 +68,23 @@ func TestPrintVersionJSON(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(output), &parsed)) assert.EqualValues(t, 2, parsed["version"]) assert.Equal(t, "READY", parsed["status"]) - assert.Equal(t, "wf", parsed["lattice_name"]) + assert.Equal(t, "wf", parsed["pipeline_name"]) } func TestPrintVersionHuman_Full(t *testing.T) { output := captureStdout(t, func() { PrintVersionHuman(sampleVersion()) }) - assert.Contains(t, output, "Version: v2") - assert.Contains(t, output, "Pipeline: wf") - assert.Contains(t, output, "Status: READY") - assert.Contains(t, output, "Python Version: 3.12") - assert.Contains(t, output, "Tasks: e1, e2") - assert.Contains(t, output, "Created: 2026-04-29T10:00:00Z") + assert.Contains(t, output, "Version:") + assert.Contains(t, output, "v2") + assert.Contains(t, output, "Pipeline:") + assert.Contains(t, output, "wf") + assert.Contains(t, output, "Status:") + assert.Contains(t, output, "READY") + assert.Contains(t, output, "Python Version:") + assert.Contains(t, output, "3.12") + assert.Contains(t, output, "Tasks:") + assert.Contains(t, output, "e1, e2") + assert.Contains(t, output, "Created:") + assert.Contains(t, output, "2026-04-29 10:00 UTC") } func TestPrintVersionHuman_FillsDefaultsForMissing(t *testing.T) { @@ -87,8 +93,8 @@ func TestPrintVersionHuman_FillsDefaultsForMissing(t *testing.T) { v.PythonVersion = "" output := captureStdout(t, func() { PrintVersionHuman(v) }) - assert.Contains(t, output, "Python Version: \u2014") - assert.Contains(t, output, "Tasks: \u2014") + assert.Contains(t, output, "Python Version:") + assert.Contains(t, output, "\u2014") } func TestPrintVersionHuman_ShowsErrorDetail(t *testing.T) { @@ -96,7 +102,8 @@ func TestPrintVersionHuman_ShowsErrorDetail(t *testing.T) { v.ErrorDetail = "syntax error" output := captureStdout(t, func() { PrintVersionHuman(v) }) - assert.Contains(t, output, "Error: syntax error") + assert.Contains(t, output, "Error:") + assert.Contains(t, output, "syntax error") } func TestPrintVersionListJSON(t *testing.T) { diff --git a/internal/pipelines/pipeline.go b/internal/pipelines/pipeline.go index 1fb0d4fae..dbfc19e08 100644 --- a/internal/pipelines/pipeline.go +++ b/internal/pipelines/pipeline.go @@ -56,7 +56,7 @@ type PipelineVersion struct { PythonVersion string `json:"python_version"` ResourceBundle map[string]any `json:"resource_bundle,omitempty"` ErrorDetail string `json:"error_detail,omitempty"` - CreatedAt time.Time `json:"created_at"` + CreatedAt time.Time `json:"created_at"` } // Pipeline mirrors PipelineDetailResponse from the pipelines-api. @@ -66,8 +66,8 @@ type Pipeline struct { Description string `json:"description,omitempty"` Mode string `json:"mode"` IsActive bool `json:"is_active"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` Versions []PipelineVersion `json:"versions"` } @@ -83,7 +83,7 @@ type CreateResponse struct { Status string `json:"status"` Mode string `json:"mode"` TaskNames []string `json:"electron_names,omitempty"` - CreatedAt time.Time `json:"created_at"` + CreatedAt time.Time `json:"created_at"` } // ListItem mirrors PipelineListItem from the pipelines-api. @@ -94,8 +94,8 @@ type ListItem struct { Mode string `json:"mode"` IsActive bool `json:"is_active"` LatestVersion *int `json:"latest_version,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // ListResponse mirrors PipelineListResponse from the pipelines-api. diff --git a/internal/pipelines/run.go b/internal/pipelines/run.go index f32f222a2..ea584fa33 100644 --- a/internal/pipelines/run.go +++ b/internal/pipelines/run.go @@ -44,14 +44,14 @@ const ( // (`dispatch_id`, `covalent_dispatch_id`) track the current API wire // format, which has not been renamed to "run" yet. type Run struct { - RunID string `json:"dispatch_id"` - PipelineID string `json:"pipeline_id"` - VersionID *int `json:"version_id,omitempty"` - InputID string `json:"input_id"` - CovalentDispatchID string `json:"covalent_dispatch_id,omitempty"` - TriggeredBy string `json:"triggered_by"` - Status string `json:"status"` - ErrorDetail string `json:"error_detail,omitempty"` + RunID string `json:"dispatch_id"` + PipelineID string `json:"pipeline_id"` + VersionID *int `json:"version_id,omitempty"` + InputID string `json:"input_id"` + CovalentDispatchID string `json:"covalent_dispatch_id,omitempty"` + TriggeredBy string `json:"triggered_by"` + Status string `json:"status"` + ErrorDetail string `json:"error_detail,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } From a8f7e0e77615c04dd36975fa812b1655c6ea1a5f Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Fri, 22 May 2026 21:37:54 -0400 Subject: [PATCH 18/25] [CMPT-5391] move rendering into internal/pipelines to match workload pattern Consolidates all *util sibling packages (runutil, inpututil, scheduleutil, envutil, versionutil, pipelineutil, outputfmt) into internal/pipelines/, mirroring how internal/workload/ owns its own output helpers. Each cmd verb now imports only internal/pipelines and calls e.g. pipelines.RenderRuns(). Co-Authored-By: Claude Sonnet 4.6 --- cmd/pipelines/create/cmd.go | 8 +- cmd/pipelines/create/cmd_test.go | 8 +- cmd/pipelines/environment/create/cmd.go | 10 +- cmd/pipelines/environment/envutil/packages.go | 51 ----- .../environment/envutil/packages_test.go | 49 ----- .../environment/envutil/render_test.go | 131 ------------- cmd/pipelines/environment/list/cmd.go | 8 +- cmd/pipelines/environment/update/cmd.go | 10 +- cmd/pipelines/get/cmd.go | 8 +- cmd/pipelines/get/cmd_test.go | 10 +- cmd/pipelines/graph/cmd.go | 7 +- cmd/pipelines/input/create/cmd.go | 10 +- cmd/pipelines/input/get/cmd.go | 8 +- cmd/pipelines/input/inpututil/payload_test.go | 100 ---------- cmd/pipelines/input/inpututil/render_test.go | 133 ------------- cmd/pipelines/input/list/cmd.go | 8 +- cmd/pipelines/input/update/cmd.go | 10 +- cmd/pipelines/list/cmd.go | 8 +- cmd/pipelines/list/cmd_test.go | 10 +- cmd/pipelines/lock/cmd.go | 8 +- cmd/pipelines/lock/cmd_test.go | 8 +- cmd/pipelines/run/create/cmd.go | 8 +- cmd/pipelines/run/get/cmd.go | 8 +- cmd/pipelines/run/list/cmd.go | 8 +- cmd/pipelines/run/runutil/render_test.go | 174 ------------------ cmd/pipelines/run/status/cmd.go | 8 +- cmd/pipelines/schedule/create/cmd.go | 8 +- cmd/pipelines/schedule/get/cmd.go | 8 +- cmd/pipelines/schedule/list/cmd.go | 8 +- .../schedule/scheduleutil/render_test.go | 117 ------------ cmd/pipelines/schedule/update/cmd.go | 8 +- cmd/pipelines/update/cmd.go | 8 +- cmd/pipelines/update/cmd_test.go | 8 +- cmd/pipelines/version/get/cmd.go | 8 +- cmd/pipelines/version/list/cmd.go | 8 +- .../version/versionutil/render_test.go | 138 -------------- .../pipelines/environment_output.go | 57 +++--- .../outputfmt => internal/pipelines}/flags.go | 4 +- .../pipelines/input_output.go | 32 ++-- .../pipelines/input_payload.go | 11 +- .../pipelines/pipeline_output.go | 30 ++- .../pipelines/run_output.go | 49 ++--- .../pipelines/schedule_output.go | 33 ++-- .../pipelines/version_output.go | 33 ++-- 44 files changed, 202 insertions(+), 1175 deletions(-) delete mode 100644 cmd/pipelines/environment/envutil/packages.go delete mode 100644 cmd/pipelines/environment/envutil/packages_test.go delete mode 100644 cmd/pipelines/environment/envutil/render_test.go delete mode 100644 cmd/pipelines/input/inpututil/payload_test.go delete mode 100644 cmd/pipelines/input/inpututil/render_test.go delete mode 100644 cmd/pipelines/run/runutil/render_test.go delete mode 100644 cmd/pipelines/schedule/scheduleutil/render_test.go delete mode 100644 cmd/pipelines/version/versionutil/render_test.go rename cmd/pipelines/environment/envutil/render.go => internal/pipelines/environment_output.go (83%) rename {cmd/pipelines/outputfmt => internal/pipelines}/flags.go (93%) rename cmd/pipelines/input/inpututil/render.go => internal/pipelines/input_output.go (84%) rename cmd/pipelines/input/inpututil/payload.go => internal/pipelines/input_payload.go (86%) rename cmd/pipelines/pipelineutil/render.go => internal/pipelines/pipeline_output.go (84%) rename cmd/pipelines/run/runutil/render.go => internal/pipelines/run_output.go (80%) rename cmd/pipelines/schedule/scheduleutil/render.go => internal/pipelines/schedule_output.go (81%) rename cmd/pipelines/version/versionutil/render.go => internal/pipelines/version_output.go (81%) diff --git a/cmd/pipelines/create/cmd.go b/cmd/pipelines/create/cmd.go index fe405c311..c43b7dd9b 100644 --- a/cmd/pipelines/create/cmd.go +++ b/cmd/pipelines/create/cmd.go @@ -18,8 +18,6 @@ import ( "errors" "fmt" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/cmd/pipelines/pipelineutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" "github.com/spf13/cobra" @@ -29,7 +27,7 @@ func Cmd() *cobra.Command { var ( description string mode string - outputFormat outputfmt.OutputFormat + outputFormat pipelines.OutputFormat fromFile string ) @@ -66,14 +64,14 @@ Example: return err } - return pipelineutil.RenderCreateResponse(outputFormat, *result) + return pipelines.RenderCreateResponse(outputFormat, *result) }, } cmd.Flags().StringVar(&description, "description", "", "Optional description for the pipeline") cmd.Flags().StringVar(&mode, "mode", "", "Pipeline mode: draft (default) or locked") cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the Python file to upload, e.g. --from-file=./my_pipeline.py (alternative to the positional argument)") - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/create/cmd_test.go b/cmd/pipelines/create/cmd_test.go index b368e9ac6..264411c75 100644 --- a/cmd/pipelines/create/cmd_test.go +++ b/cmd/pipelines/create/cmd_test.go @@ -22,8 +22,6 @@ import ( "testing" "time" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/cmd/pipelines/pipelineutil" "github.com/datarobot/cli/internal/pipelines" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -65,7 +63,7 @@ func TestPrintCreateJSON(t *testing.T) { resp := sampleCreateResponse() output := captureStdout(t, func() { - err := pipelineutil.RenderCreateResponse(outputfmt.OutputFormatJSON, resp) + err := pipelines.RenderCreateResponse(pipelines.OutputFormatJSON, resp) require.NoError(t, err) }) @@ -84,7 +82,7 @@ func TestPrintCreateHuman_WithTasks(t *testing.T) { resp := sampleCreateResponse() output := captureStdout(t, func() { - require.NoError(t, pipelineutil.RenderCreateResponse(outputfmt.OutputFormatText, resp)) + require.NoError(t, pipelines.RenderCreateResponse(pipelines.OutputFormatText, resp)) }) assert.Contains(t, output, resp.PipelineID) @@ -100,7 +98,7 @@ func TestPrintCreateHuman_NoTasks(t *testing.T) { resp.TaskNames = nil output := captureStdout(t, func() { - require.NoError(t, pipelineutil.RenderCreateResponse(outputfmt.OutputFormatText, resp)) + require.NoError(t, pipelines.RenderCreateResponse(pipelines.OutputFormatText, resp)) }) assert.Contains(t, output, "—") diff --git a/cmd/pipelines/environment/create/cmd.go b/cmd/pipelines/environment/create/cmd.go index 99c931e05..4018e5917 100644 --- a/cmd/pipelines/environment/create/cmd.go +++ b/cmd/pipelines/environment/create/cmd.go @@ -17,8 +17,6 @@ package create import ( "errors" - "github.com/datarobot/cli/cmd/pipelines/environment/envutil" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" "github.com/spf13/cobra" @@ -29,7 +27,7 @@ func Cmd() *cobra.Command { name string description string rawPackages []string - outputFormat outputfmt.OutputFormat + outputFormat pipelines.OutputFormat ) cmd := &cobra.Command{ @@ -52,7 +50,7 @@ Example: return errors.New("--name is required") } - packages, err := envutil.NormalizePackages(rawPackages) + packages, err := pipelines.NormalizePackages(rawPackages) if err != nil { return err } @@ -62,14 +60,14 @@ Example: return err } - return envutil.RenderEnvironment(outputFormat, *result) + return pipelines.RenderEnvironment(outputFormat, *result) }, } cmd.Flags().StringVar(&name, "name", "", "Environment name (required)") cmd.Flags().StringVar(&description, "description", "", "Optional description") cmd.Flags().StringSliceVar(&rawPackages, "package", nil, "Pip package spec (repeatable, also accepts comma-separated values)") - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/environment/envutil/packages.go b/cmd/pipelines/environment/envutil/packages.go deleted file mode 100644 index 31d46dad8..000000000 --- a/cmd/pipelines/environment/envutil/packages.go +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright 2026 DataRobot, Inc. and its affiliates. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// packages.go contains the shared helper for resolving a list of pip -// package specs from the --package flag. The flag is repeatable and also -// accepts comma-separated values so users can write either: -// -// --package numpy --package pandas==2.0 -// --package numpy,pandas==2.0 -// -// Empty entries are dropped and surrounding whitespace is trimmed. - -package envutil - -import ( - "errors" - "strings" -) - -// NormalizePackages takes the raw slice from a cobra StringSliceVar and -// returns a cleaned list. It returns an error when the resulting list is -// empty so callers can surface a friendly validation message. -func NormalizePackages(raw []string) ([]string, error) { - out := make([]string, 0, len(raw)) - - for _, entry := range raw { - for _, item := range strings.Split(entry, ",") { - trimmed := strings.TrimSpace(item) - if trimmed != "" { - out = append(out, trimmed) - } - } - } - - if len(out) == 0 { - return nil, errors.New("at least one package is required (use --package)") - } - - return out, nil -} diff --git a/cmd/pipelines/environment/envutil/packages_test.go b/cmd/pipelines/environment/envutil/packages_test.go deleted file mode 100644 index dffdba7c6..000000000 --- a/cmd/pipelines/environment/envutil/packages_test.go +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright 2026 DataRobot, Inc. and its affiliates. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package envutil - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNormalizePackages_RepeatedFlags(t *testing.T) { - got, err := NormalizePackages([]string{"numpy", "pandas==2.0"}) - require.NoError(t, err) - assert.Equal(t, []string{"numpy", "pandas==2.0"}, got) -} - -func TestNormalizePackages_CommaSeparated(t *testing.T) { - got, err := NormalizePackages([]string{"numpy,pandas==2.0, scikit-learn"}) - require.NoError(t, err) - assert.Equal(t, []string{"numpy", "pandas==2.0", "scikit-learn"}, got) -} - -func TestNormalizePackages_DropsBlanks(t *testing.T) { - got, err := NormalizePackages([]string{"numpy,,", " ,pandas"}) - require.NoError(t, err) - assert.Equal(t, []string{"numpy", "pandas"}, got) -} - -func TestNormalizePackages_RejectsEmpty(t *testing.T) { - _, err := NormalizePackages(nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "at least one package") - - _, err = NormalizePackages([]string{"", " ,"}) - require.Error(t, err) -} diff --git a/cmd/pipelines/environment/envutil/render_test.go b/cmd/pipelines/environment/envutil/render_test.go deleted file mode 100644 index 2c6c834e2..000000000 --- a/cmd/pipelines/environment/envutil/render_test.go +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright 2026 DataRobot, Inc. and its affiliates. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package envutil - -import ( - "bytes" - "io" - "os" - "testing" - "time" - - "github.com/datarobot/cli/internal/pipelines" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// captureStdout redirects os.Stdout, runs fn, and returns the captured -// output. The previous stdout is restored unconditionally. -func captureStdout(t *testing.T, fn func()) string { - t.Helper() - - prev := os.Stdout - - r, w, err := os.Pipe() - require.NoError(t, err) - - os.Stdout = w - - defer func() { - os.Stdout = prev - }() - - fn() - - _ = w.Close() - - buf, err := io.ReadAll(r) - require.NoError(t, err) - - return string(buf) -} - -func TestPrintEnvironmentJSON(t *testing.T) { - desc := "demo" - env := pipelines.Environment{ - EnvironmentID: "env-1", - Name: "ml-base", - Description: &desc, - LatestVersion: 1, - } - - out := captureStdout(t, func() { - require.NoError(t, PrintEnvironmentJSON(env)) - }) - - assert.Contains(t, out, `"environment_id": "env-1"`) - assert.Contains(t, out, `"name": "ml-base"`) -} - -func TestPrintEnvironmentHuman_RendersVersionsTable(t *testing.T) { - env := pipelines.Environment{ - EnvironmentID: "env-1", - Name: "ml-base", - LatestVersion: 2, - Versions: []pipelines.EnvironmentVersion{ - {Version: 2, Packages: []string{"scikit-learn"}, Status: pipelines.EnvironmentStatusReady, UpdatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)}, - {Version: 1, Packages: []string{"numpy", "pandas"}, Status: pipelines.EnvironmentStatusReady, UpdatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}, - }, - } - - out := captureStdout(t, func() { - PrintEnvironmentHuman(env) - }) - - assert.Contains(t, out, "Environment ID:") - assert.Contains(t, out, "env-1") - assert.Contains(t, out, "Latest version:") - assert.Contains(t, out, "v2") - assert.Contains(t, out, "VERSION") - assert.Contains(t, out, "v2") - assert.Contains(t, out, "scikit-learn") -} - -func TestPrintEnvironmentListHuman_EmptyAndPopulated(t *testing.T) { - out := captureStdout(t, func() { - PrintEnvironmentListHuman(nil) - }) - assert.Contains(t, out, "No environments found") - - out = captureStdout(t, func() { - PrintEnvironmentListHuman([]pipelines.EnvironmentSummary{ - {EnvironmentID: "env-1", Name: "ml-base", LatestVersion: 3, LatestStatus: pipelines.EnvironmentStatusReady, UpdatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}, - }) - }) - assert.Contains(t, out, "ENVIRONMENT") - assert.Contains(t, out, "env-1") - assert.Contains(t, out, "v3") -} - -func TestPrintEnvironmentListJSON_EmitsArray(t *testing.T) { - out := captureStdout(t, func() { - require.NoError(t, PrintEnvironmentListJSON([]pipelines.EnvironmentSummary{ - {EnvironmentID: "env-1", Name: "n", LatestVersion: 1, LatestStatus: pipelines.EnvironmentStatusReady}, - })) - }) - - // Sanity check JSON parseability via simple substring assertions; the - // real JSON shape is exercised by the shared json.MarshalIndent path. - assert.Contains(t, out, `[`) - assert.Contains(t, out, `]`) - assert.Contains(t, out, `"environment_id": "env-1"`) -} - -func TestJoinPackages_Truncates(t *testing.T) { - long := bytes.Repeat([]byte("x"), 80) - out := joinPackages([]string{string(long)}) - assert.Len(t, out, 60) - assert.True(t, len(out) > 3 && out[len(out)-3:] == "...") -} diff --git a/cmd/pipelines/environment/list/cmd.go b/cmd/pipelines/environment/list/cmd.go index bbf6c3e48..6fb91aa4f 100644 --- a/cmd/pipelines/environment/list/cmd.go +++ b/cmd/pipelines/environment/list/cmd.go @@ -15,8 +15,6 @@ package list import ( - "github.com/datarobot/cli/cmd/pipelines/environment/envutil" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" "github.com/spf13/cobra" @@ -26,7 +24,7 @@ func Cmd() *cobra.Command { var ( offset int limit int - outputFormat outputfmt.OutputFormat + outputFormat pipelines.OutputFormat ) cmd := &cobra.Command{ @@ -50,13 +48,13 @@ Example: return err } - return envutil.RenderEnvironments(outputFormat, items) + return pipelines.RenderEnvironments(outputFormat, items) }, } cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of environments to return") - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/environment/update/cmd.go b/cmd/pipelines/environment/update/cmd.go index 51f35b2c6..387a669e7 100644 --- a/cmd/pipelines/environment/update/cmd.go +++ b/cmd/pipelines/environment/update/cmd.go @@ -15,8 +15,6 @@ package update import ( - "github.com/datarobot/cli/cmd/pipelines/environment/envutil" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" "github.com/spf13/cobra" @@ -25,7 +23,7 @@ import ( func Cmd() *cobra.Command { var ( rawPackages []string - outputFormat outputfmt.OutputFormat + outputFormat pipelines.OutputFormat ) cmd := &cobra.Command{ @@ -43,7 +41,7 @@ Example: PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(_ *cobra.Command, args []string) error { - packages, err := envutil.NormalizePackages(rawPackages) + packages, err := pipelines.NormalizePackages(rawPackages) if err != nil { return err } @@ -53,12 +51,12 @@ Example: return err } - return envutil.RenderEnvironment(outputFormat, *result) + return pipelines.RenderEnvironment(outputFormat, *result) }, } cmd.Flags().StringSliceVar(&rawPackages, "package", nil, "Pip package spec (repeatable, also accepts comma-separated values)") - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/get/cmd.go b/cmd/pipelines/get/cmd.go index c112fef33..c425c9c9a 100644 --- a/cmd/pipelines/get/cmd.go +++ b/cmd/pipelines/get/cmd.go @@ -19,8 +19,6 @@ import ( "fmt" "net/http" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/cmd/pipelines/pipelineutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" "github.com/datarobot/cli/internal/pipelines" @@ -29,7 +27,7 @@ import ( ) func Cmd() *cobra.Command { - var outputFormat outputfmt.OutputFormat + var outputFormat pipelines.OutputFormat cmd := &cobra.Command{ Use: "get ", @@ -50,11 +48,11 @@ Example: return handleGetError(err, args[0]) } - return pipelineutil.RenderPipeline(outputFormat, *pipeline) + return pipelines.RenderPipeline(outputFormat, *pipeline) }, } - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/get/cmd_test.go b/cmd/pipelines/get/cmd_test.go index 112a504b7..d6f7710f8 100644 --- a/cmd/pipelines/get/cmd_test.go +++ b/cmd/pipelines/get/cmd_test.go @@ -22,8 +22,6 @@ import ( "testing" "time" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/cmd/pipelines/pipelineutil" "github.com/datarobot/cli/internal/drapi" "github.com/datarobot/cli/internal/pipelines" "github.com/stretchr/testify/assert" @@ -84,7 +82,7 @@ func TestPrintGetJSON(t *testing.T) { pipeline := samplePipeline() output := captureStdout(t, func() { - err := pipelineutil.RenderPipeline(outputfmt.OutputFormatJSON, pipeline) + err := pipelines.RenderPipeline(pipelines.OutputFormatJSON, pipeline) require.NoError(t, err) }) @@ -97,7 +95,7 @@ func TestPrintGetHuman_RendersHeaderAndVersions(t *testing.T) { pipeline := samplePipeline() output := captureStdout(t, func() { - require.NoError(t, pipelineutil.RenderPipeline(outputfmt.OutputFormatText, pipeline)) + require.NoError(t, pipelines.RenderPipeline(pipelines.OutputFormatText, pipeline)) }) assert.Contains(t, output, pipeline.PipelineID) @@ -122,7 +120,7 @@ func TestPrintGetHuman_BlankDescriptionFallsBack(t *testing.T) { pipeline.Description = "" output := captureStdout(t, func() { - require.NoError(t, pipelineutil.RenderPipeline(outputfmt.OutputFormatText, pipeline)) + require.NoError(t, pipelines.RenderPipeline(pipelines.OutputFormatText, pipeline)) }) assert.Contains(t, output, "—") @@ -133,7 +131,7 @@ func TestPrintGetHuman_NoVersions(t *testing.T) { pipeline.Versions = nil output := captureStdout(t, func() { - require.NoError(t, pipelineutil.RenderPipeline(outputfmt.OutputFormatText, pipeline)) + require.NoError(t, pipelines.RenderPipeline(pipelines.OutputFormatText, pipeline)) }) assert.NotContains(t, output, "Versions (") diff --git a/cmd/pipelines/graph/cmd.go b/cmd/pipelines/graph/cmd.go index 0815e3b89..9dfb2021b 100644 --- a/cmd/pipelines/graph/cmd.go +++ b/cmd/pipelines/graph/cmd.go @@ -23,7 +23,6 @@ import ( "strconv" "text/tabwriter" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/cmd/pipelines/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" @@ -35,7 +34,7 @@ import ( func Cmd() *cobra.Command { var ( flags scopeflag.Flags - outputFormat outputfmt.OutputFormat + outputFormat pipelines.OutputFormat ) cmd := &cobra.Command{ @@ -71,7 +70,7 @@ Example: return handleGraphError(err, flags.PipelineID) } - if outputFormat == outputfmt.OutputFormatJSON { + if outputFormat == pipelines.OutputFormatJSON { return printGraphJSON(*result) } @@ -82,7 +81,7 @@ Example: } flags.Bind(cmd) - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/input/create/cmd.go b/cmd/pipelines/input/create/cmd.go index a9c838b06..d46485089 100644 --- a/cmd/pipelines/input/create/cmd.go +++ b/cmd/pipelines/input/create/cmd.go @@ -17,8 +17,6 @@ package create import ( "errors" - "github.com/datarobot/cli/cmd/pipelines/input/inpututil" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/cmd/pipelines/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" @@ -29,7 +27,7 @@ func Cmd() *cobra.Command { var ( flags scopeflag.Flags fromFile string - outputFormat outputfmt.OutputFormat + outputFormat pipelines.OutputFormat ) cmd := &cobra.Command{ @@ -64,7 +62,7 @@ Example: return err } - payload, err := inpututil.ResolvePayload(args, fromFile) + payload, err := pipelines.ResolvePayload(args, fromFile) if err != nil { return err } @@ -74,13 +72,13 @@ Example: return err } - return inpututil.RenderInput(outputFormat, *result) + return pipelines.RenderInput(outputFormat, *result) }, } flags.Bind(cmd) cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the JSON payload file, e.g. --from-file=./payload.json (alternative to the positional argument)") - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/input/get/cmd.go b/cmd/pipelines/input/get/cmd.go index 4896bb5de..e2a1cdc3e 100644 --- a/cmd/pipelines/input/get/cmd.go +++ b/cmd/pipelines/input/get/cmd.go @@ -19,8 +19,6 @@ import ( "fmt" "net/http" - "github.com/datarobot/cli/cmd/pipelines/input/inpututil" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/cmd/pipelines/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" @@ -32,7 +30,7 @@ import ( func Cmd() *cobra.Command { var ( flags scopeflag.Flags - outputFormat outputfmt.OutputFormat + outputFormat pipelines.OutputFormat ) cmd := &cobra.Command{ @@ -61,12 +59,12 @@ Example: return handleGetError(err, args[0]) } - return inpututil.RenderInput(outputFormat, *result) + return pipelines.RenderInput(outputFormat, *result) }, } flags.Bind(cmd) - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/input/inpututil/payload_test.go b/cmd/pipelines/input/inpututil/payload_test.go deleted file mode 100644 index 623784c99..000000000 --- a/cmd/pipelines/input/inpututil/payload_test.go +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright 2026 DataRobot, Inc. and its affiliates. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package inpututil - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func writeTempJSON(t *testing.T, name, content string) string { - t.Helper() - - dir := t.TempDir() - path := filepath.Join(dir, name) - - require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) - - return path -} - -func TestResolveFilePath(t *testing.T) { - t.Run("positional only", func(t *testing.T) { - got, err := resolveFilePath([]string{"a.json"}, "") - require.NoError(t, err) - assert.Equal(t, "a.json", got) - }) - - t.Run("flag only", func(t *testing.T) { - got, err := resolveFilePath(nil, "b.json") - require.NoError(t, err) - assert.Equal(t, "b.json", got) - }) - - t.Run("both supplied", func(t *testing.T) { - _, err := resolveFilePath([]string{"a.json"}, "b.json") - require.Error(t, err) - assert.Contains(t, err.Error(), "not both") - }) - - t.Run("neither supplied", func(t *testing.T) { - _, err := resolveFilePath(nil, "") - require.Error(t, err) - assert.Contains(t, err.Error(), "required") - }) -} - -func TestResolvePayload_ValidJSON(t *testing.T) { - path := writeTempJSON(t, "payload.json", `{"key": "value", "n": 7}`) - - got, err := ResolvePayload([]string{path}, "") - require.NoError(t, err) - assert.Equal(t, "value", got["key"]) - assert.EqualValues(t, 7, got["n"]) -} - -func TestResolvePayload_FromFlag(t *testing.T) { - path := writeTempJSON(t, "payload.json", `{"only": true}`) - - got, err := ResolvePayload(nil, path) - require.NoError(t, err) - assert.Equal(t, true, got["only"]) -} - -func TestResolvePayload_RejectsNonObjectJSON(t *testing.T) { - path := writeTempJSON(t, "payload.json", `[1, 2, 3]`) - - _, err := ResolvePayload([]string{path}, "") - require.Error(t, err) - assert.Contains(t, err.Error(), "JSON object") -} - -func TestResolvePayload_MalformedJSON(t *testing.T) { - path := writeTempJSON(t, "payload.json", `{not json`) - - _, err := ResolvePayload([]string{path}, "") - require.Error(t, err) - assert.Contains(t, err.Error(), "JSON") -} - -func TestResolvePayload_MissingFile(t *testing.T) { - _, err := ResolvePayload([]string{"/no/such/file.json"}, "") - require.Error(t, err) - assert.Contains(t, err.Error(), "read /no/such/file.json") -} diff --git a/cmd/pipelines/input/inpututil/render_test.go b/cmd/pipelines/input/inpututil/render_test.go deleted file mode 100644 index 19e0f5392..000000000 --- a/cmd/pipelines/input/inpututil/render_test.go +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright 2026 DataRobot, Inc. and its affiliates. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package inpututil - -import ( - "bytes" - "encoding/json" - "io" - "os" - "testing" - "time" - - "github.com/datarobot/cli/internal/pipelines" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func captureStdout(t *testing.T, fn func()) string { - t.Helper() - - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - fn() - - w.Close() - - os.Stdout = old - - var buf bytes.Buffer - - _, _ = io.Copy(&buf, r) - - return buf.String() -} - -func intPtr(v int) *int { return &v } - -func sampleDraftInput() pipelines.Input { - return pipelines.Input{ - InputID: "in-1", - PipelineID: "pl-1", - IsDraft: true, - State: pipelines.InputStateValid, - Payload: map[string]any{"k": "v"}, - CreatedAt: time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC), - UpdatedAt: time.Date(2026, 4, 29, 10, 5, 0, 0, time.UTC), - } -} - -func sampleLockedInput() pipelines.Input { - in := sampleDraftInput() - in.IsDraft = false - in.VersionID = intPtr(2) - - return in -} - -func TestPrintInputJSON(t *testing.T) { - output := captureStdout(t, func() { - require.NoError(t, PrintInputJSON(sampleDraftInput())) - }) - - var parsed map[string]any - - require.NoError(t, json.Unmarshal([]byte(output), &parsed)) - assert.Equal(t, "in-1", parsed["input_id"]) - assert.Equal(t, "VALID", parsed["state"]) -} - -func TestPrintInputHuman_Draft(t *testing.T) { - output := captureStdout(t, func() { PrintInputHuman(sampleDraftInput()) }) - assert.Contains(t, output, "Input ID:") - assert.Contains(t, output, "in-1") - assert.Contains(t, output, "Scope:") - assert.Contains(t, output, "draft") - assert.Contains(t, output, "Version:") - assert.Contains(t, output, "\u2014") - assert.Contains(t, output, "State:") - assert.Contains(t, output, "VALID") - assert.Contains(t, output, "Payload:") - assert.Contains(t, output, `"k": "v"`) -} - -func TestPrintInputHuman_Locked(t *testing.T) { - output := captureStdout(t, func() { PrintInputHuman(sampleLockedInput()) }) - assert.Contains(t, output, "locked") - assert.Contains(t, output, "v2") -} - -func TestPrintInputListJSON(t *testing.T) { - output := captureStdout(t, func() { - require.NoError(t, PrintInputListJSON([]pipelines.Input{sampleDraftInput()})) - }) - - var parsed []map[string]any - - require.NoError(t, json.Unmarshal([]byte(output), &parsed)) - require.Len(t, parsed, 1) - assert.Equal(t, "in-1", parsed[0]["input_id"]) -} - -func TestPrintInputListHuman_Empty(t *testing.T) { - output := captureStdout(t, func() { PrintInputListHuman(nil) }) - assert.Contains(t, output, "No inputs found") -} - -func TestPrintInputListHuman_RendersTable(t *testing.T) { - output := captureStdout(t, func() { - PrintInputListHuman([]pipelines.Input{sampleDraftInput(), sampleLockedInput()}) - }) - - assert.Contains(t, output, "INPUT") - assert.Contains(t, output, "SCOPE") - assert.Contains(t, output, "VERSION") - assert.Contains(t, output, "draft") - assert.Contains(t, output, "locked") - assert.Contains(t, output, "v2") - assert.Contains(t, output, "VALID") -} diff --git a/cmd/pipelines/input/list/cmd.go b/cmd/pipelines/input/list/cmd.go index 20049fd9f..dd278f8b8 100644 --- a/cmd/pipelines/input/list/cmd.go +++ b/cmd/pipelines/input/list/cmd.go @@ -17,8 +17,6 @@ package list import ( "errors" - "github.com/datarobot/cli/cmd/pipelines/input/inpututil" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/cmd/pipelines/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" @@ -30,7 +28,7 @@ func Cmd() *cobra.Command { flags scopeflag.Flags offset int limit int - outputFormat outputfmt.OutputFormat + outputFormat pipelines.OutputFormat ) cmd := &cobra.Command{ @@ -66,14 +64,14 @@ Example: return err } - return inpututil.RenderInputs(outputFormat, items) + return pipelines.RenderInputs(outputFormat, items) }, } flags.Bind(cmd) cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of inputs to return") - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/input/update/cmd.go b/cmd/pipelines/input/update/cmd.go index 49625b55a..be74af651 100644 --- a/cmd/pipelines/input/update/cmd.go +++ b/cmd/pipelines/input/update/cmd.go @@ -17,8 +17,6 @@ package update import ( "errors" - "github.com/datarobot/cli/cmd/pipelines/input/inpututil" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" "github.com/spf13/cobra" @@ -28,7 +26,7 @@ func Cmd() *cobra.Command { var ( pipelineID string fromFile string - outputFormat outputfmt.OutputFormat + outputFormat pipelines.OutputFormat ) cmd := &cobra.Command{ @@ -53,7 +51,7 @@ Example: inputID := args[0] - payload, err := inpututil.ResolvePayload(args[1:], fromFile) + payload, err := pipelines.ResolvePayload(args[1:], fromFile) if err != nil { return err } @@ -63,13 +61,13 @@ Example: return err } - return inpututil.RenderInput(outputFormat, *result) + return pipelines.RenderInput(outputFormat, *result) }, } cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the JSON payload file, e.g. --from-file=./payload.json (alternative to the positional argument)") - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/list/cmd.go b/cmd/pipelines/list/cmd.go index 4916ae8ee..d7394c8e2 100644 --- a/cmd/pipelines/list/cmd.go +++ b/cmd/pipelines/list/cmd.go @@ -17,8 +17,6 @@ package list import ( "fmt" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/cmd/pipelines/pipelineutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" "github.com/spf13/cobra" @@ -29,7 +27,7 @@ func Cmd() *cobra.Command { mode string offset int limit int - outputFormat outputfmt.OutputFormat + outputFormat pipelines.OutputFormat ) cmd := &cobra.Command{ @@ -55,14 +53,14 @@ Example: return err } - return pipelineutil.RenderPipelines(outputFormat, *list) + return pipelines.RenderPipelines(outputFormat, *list) }, } cmd.Flags().StringVar(&mode, "mode", "", "Filter by mode: draft or locked") cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") cmd.Flags().IntVar(&limit, "limit", 50, "Pagination limit (1-200)") - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/list/cmd_test.go b/cmd/pipelines/list/cmd_test.go index e3c7e89da..d29066dc2 100644 --- a/cmd/pipelines/list/cmd_test.go +++ b/cmd/pipelines/list/cmd_test.go @@ -22,8 +22,6 @@ import ( "testing" "time" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/cmd/pipelines/pipelineutil" "github.com/datarobot/cli/internal/pipelines" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -76,7 +74,7 @@ func TestPrintListJSON(t *testing.T) { list := sampleListResponse() output := captureStdout(t, func() { - err := pipelineutil.RenderPipelines(outputfmt.OutputFormatJSON, list) + err := pipelines.RenderPipelines(pipelines.OutputFormatJSON, list) require.NoError(t, err) }) @@ -97,7 +95,7 @@ func TestPrintListJSON(t *testing.T) { func TestPrintListHuman_Empty(t *testing.T) { output := captureStdout(t, func() { - require.NoError(t, pipelineutil.RenderPipelines(outputfmt.OutputFormatText, pipelines.ListResponse{Total: 0, Limit: 50})) + require.NoError(t, pipelines.RenderPipelines(pipelines.OutputFormatText, pipelines.ListResponse{Total: 0, Limit: 50})) }) assert.Contains(t, output, "No pipelines found.") @@ -107,7 +105,7 @@ func TestPrintListHuman_RendersHeaderAndRow(t *testing.T) { list := sampleListResponse() output := captureStdout(t, func() { - require.NoError(t, pipelineutil.RenderPipelines(outputfmt.OutputFormatText, list)) + require.NoError(t, pipelines.RenderPipelines(pipelines.OutputFormatText, list)) }) assert.Contains(t, output, "Showing 1 of 1") @@ -130,7 +128,7 @@ func TestPrintListHuman_NoLatestVersion(t *testing.T) { list.Items[0].LatestVersion = nil output := captureStdout(t, func() { - require.NoError(t, pipelineutil.RenderPipelines(outputfmt.OutputFormatText, list)) + require.NoError(t, pipelines.RenderPipelines(pipelines.OutputFormatText, list)) }) assert.Contains(t, output, "—") diff --git a/cmd/pipelines/lock/cmd.go b/cmd/pipelines/lock/cmd.go index 530321824..872d7c019 100644 --- a/cmd/pipelines/lock/cmd.go +++ b/cmd/pipelines/lock/cmd.go @@ -15,15 +15,13 @@ package lock import ( - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/cmd/pipelines/pipelineutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" "github.com/spf13/cobra" ) func Cmd() *cobra.Command { - var outputFormat outputfmt.OutputFormat + var outputFormat pipelines.OutputFormat cmd := &cobra.Command{ Use: "lock ", @@ -43,11 +41,11 @@ Example: return err } - return pipelineutil.RenderCreateResponse(outputFormat, *result) + return pipelines.RenderCreateResponse(outputFormat, *result) }, } - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/lock/cmd_test.go b/cmd/pipelines/lock/cmd_test.go index dafbcf7f9..8f97c0600 100644 --- a/cmd/pipelines/lock/cmd_test.go +++ b/cmd/pipelines/lock/cmd_test.go @@ -22,8 +22,6 @@ import ( "testing" "time" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/cmd/pipelines/pipelineutil" "github.com/datarobot/cli/internal/pipelines" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -63,7 +61,7 @@ func sample() pipelines.CreateResponse { func TestPrintLockJSON(t *testing.T) { output := captureStdout(t, func() { - require.NoError(t, pipelineutil.RenderCreateResponse(outputfmt.OutputFormatJSON, sample())) + require.NoError(t, pipelines.RenderCreateResponse(pipelines.OutputFormatJSON, sample())) }) var parsed map[string]any @@ -76,7 +74,7 @@ func TestPrintLockJSON(t *testing.T) { func TestPrintLockHuman(t *testing.T) { output := captureStdout(t, func() { - require.NoError(t, pipelineutil.RenderCreateResponse(outputfmt.OutputFormatText, sample())) + require.NoError(t, pipelines.RenderCreateResponse(pipelines.OutputFormatText, sample())) }) assert.Contains(t, output, "abc") @@ -90,7 +88,7 @@ func TestPrintLockHuman_NoTasks(t *testing.T) { resp.TaskNames = nil output := captureStdout(t, func() { - require.NoError(t, pipelineutil.RenderCreateResponse(outputfmt.OutputFormatText, resp)) + require.NoError(t, pipelines.RenderCreateResponse(pipelines.OutputFormatText, resp)) }) assert.Contains(t, output, "—") diff --git a/cmd/pipelines/run/create/cmd.go b/cmd/pipelines/run/create/cmd.go index 3ff2e0c93..41d6a5248 100644 --- a/cmd/pipelines/run/create/cmd.go +++ b/cmd/pipelines/run/create/cmd.go @@ -17,8 +17,6 @@ package create import ( "errors" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/cmd/pipelines/run/runutil" "github.com/datarobot/cli/cmd/pipelines/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" @@ -29,7 +27,7 @@ func Cmd() *cobra.Command { var ( flags scopeflag.Flags inputID string - outputFormat outputfmt.OutputFormat + outputFormat pipelines.OutputFormat ) cmd := &cobra.Command{ @@ -65,13 +63,13 @@ Example: return err } - return runutil.RenderRun(outputFormat, *result) + return pipelines.RenderRun(outputFormat, *result) }, } flags.Bind(cmd) cmd.Flags().StringVar(&inputID, "input", "", "Input ID to trigger the run with") - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/run/get/cmd.go b/cmd/pipelines/run/get/cmd.go index c04ea8134..11d750505 100644 --- a/cmd/pipelines/run/get/cmd.go +++ b/cmd/pipelines/run/get/cmd.go @@ -19,8 +19,6 @@ import ( "fmt" "net/http" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/cmd/pipelines/run/runutil" "github.com/datarobot/cli/cmd/pipelines/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" @@ -32,7 +30,7 @@ import ( func Cmd() *cobra.Command { var ( flags scopeflag.Flags - outputFormat outputfmt.OutputFormat + outputFormat pipelines.OutputFormat ) cmd := &cobra.Command{ @@ -61,12 +59,12 @@ Example: return handleGetError(err, args[0]) } - return runutil.RenderRun(outputFormat, *result) + return pipelines.RenderRun(outputFormat, *result) }, } flags.Bind(cmd) - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/run/list/cmd.go b/cmd/pipelines/run/list/cmd.go index 411f20f22..7106c1a19 100644 --- a/cmd/pipelines/run/list/cmd.go +++ b/cmd/pipelines/run/list/cmd.go @@ -17,8 +17,6 @@ package list import ( "errors" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/cmd/pipelines/run/runutil" "github.com/datarobot/cli/cmd/pipelines/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" @@ -30,7 +28,7 @@ func Cmd() *cobra.Command { flags scopeflag.Flags offset int limit int - outputFormat outputfmt.OutputFormat + outputFormat pipelines.OutputFormat ) cmd := &cobra.Command{ @@ -59,14 +57,14 @@ Example: return err } - return runutil.RenderRuns(outputFormat, items) + return pipelines.RenderRuns(outputFormat, items) }, } flags.Bind(cmd) cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of runs to return") - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/run/runutil/render_test.go b/cmd/pipelines/run/runutil/render_test.go deleted file mode 100644 index 98c38f5b4..000000000 --- a/cmd/pipelines/run/runutil/render_test.go +++ /dev/null @@ -1,174 +0,0 @@ -// Copyright 2026 DataRobot, Inc. and its affiliates. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package runutil - -import ( - "bytes" - "encoding/json" - "io" - "os" - "testing" - "time" - - "github.com/datarobot/cli/internal/pipelines" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func captureStdout(t *testing.T, fn func()) string { - t.Helper() - - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - fn() - - w.Close() - - os.Stdout = old - - var buf bytes.Buffer - - _, _ = io.Copy(&buf, r) - - return buf.String() -} - -func intPtr(v int) *int { return &v } - -func sampleDraftRun() pipelines.Run { - return pipelines.Run{ - RunID: "d-1", - PipelineID: "pl-1", - InputID: "in-1", - TriggeredBy: "user@example.com", - Status: pipelines.RunStatusPending, - CreatedAt: time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC), - UpdatedAt: time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC), - } -} - -func sampleLockedRun() pipelines.Run { - r := sampleDraftRun() - r.VersionID = intPtr(3) - r.CovalentDispatchID = "cov-xyz" - r.Status = pipelines.RunStatusRunning - - return r -} - -func TestPrintRunJSON(t *testing.T) { - output := captureStdout(t, func() { - require.NoError(t, PrintRunJSON(sampleDraftRun())) - }) - - var parsed map[string]any - - require.NoError(t, json.Unmarshal([]byte(output), &parsed)) - assert.Equal(t, "d-1", parsed["run_id"]) - assert.Equal(t, "PENDING", parsed["status"]) - _, hasLegacy := parsed["dispatch_id"] - assert.Falsef(t, hasLegacy, "expected legacy dispatch_id key to be absent") -} - -func TestPrintRunHuman_DraftMissingCovalent(t *testing.T) { - output := captureStdout(t, func() { PrintRunHuman(sampleDraftRun()) }) - assert.Contains(t, output, "Run ID:") - assert.Contains(t, output, "d-1") - assert.Contains(t, output, "Scope:") - assert.Contains(t, output, "draft") - assert.Contains(t, output, "Version:") - assert.Contains(t, output, "\u2014") - assert.Contains(t, output, "Covalent Run:") - assert.Contains(t, output, "Status:") - assert.Contains(t, output, "PENDING") -} - -func TestPrintRunHuman_LockedShowsErrorWhenSet(t *testing.T) { - r := sampleLockedRun() - r.Status = pipelines.RunStatusFailed - r.ErrorDetail = "boom" - - output := captureStdout(t, func() { PrintRunHuman(r) }) - assert.Contains(t, output, "locked") - assert.Contains(t, output, "v3") - assert.Contains(t, output, "cov-xyz") - assert.Contains(t, output, "boom") -} - -func TestPrintRunListJSON(t *testing.T) { - output := captureStdout(t, func() { - require.NoError(t, PrintRunListJSON([]pipelines.Run{sampleDraftRun()})) - }) - - var parsed []map[string]any - - require.NoError(t, json.Unmarshal([]byte(output), &parsed)) - require.Len(t, parsed, 1) - assert.Equal(t, "d-1", parsed[0]["run_id"]) -} - -func TestPrintRunListHuman_Empty(t *testing.T) { - output := captureStdout(t, func() { PrintRunListHuman(nil) }) - assert.Contains(t, output, "No runs found") -} - -func TestPrintRunListHuman_RendersTable(t *testing.T) { - output := captureStdout(t, func() { - PrintRunListHuman([]pipelines.Run{sampleDraftRun(), sampleLockedRun()}) - }) - - assert.Contains(t, output, "RUN") - assert.Contains(t, output, "STATUS") - assert.Contains(t, output, "TRIGGER") - assert.Contains(t, output, "draft") - assert.Contains(t, output, "locked") - assert.Contains(t, output, "v3") - assert.Contains(t, output, "PENDING") - assert.Contains(t, output, "RUNNING") -} - -func TestPrintStatusJSON(t *testing.T) { - status := pipelines.RunStatus{ - RunID: "d-1", - Status: pipelines.RunStatusCompleted, - CovalentDispatchID: "cov-xyz", - } - - output := captureStdout(t, func() { - require.NoError(t, PrintStatusJSON(status)) - }) - - var parsed map[string]any - - require.NoError(t, json.Unmarshal([]byte(output), &parsed)) - assert.Equal(t, "COMPLETED", parsed["status"]) - assert.Equal(t, "cov-xyz", parsed["covalent_run_id"]) - assert.Equal(t, "d-1", parsed["run_id"]) -} - -func TestPrintStatusHuman_NoCovalentRunID(t *testing.T) { - output := captureStdout(t, func() { - PrintStatusHuman(pipelines.RunStatus{RunID: "d-1", Status: "PENDING"}) - }) - - assert.Contains(t, output, "Run ID:") - assert.Contains(t, output, "d-1") - assert.Contains(t, output, "Status:") - assert.Contains(t, output, "PENDING") - assert.Contains(t, output, "Covalent Run:") - assert.Contains(t, output, "\u2014") -} diff --git a/cmd/pipelines/run/status/cmd.go b/cmd/pipelines/run/status/cmd.go index d5830ce2a..fce797540 100644 --- a/cmd/pipelines/run/status/cmd.go +++ b/cmd/pipelines/run/status/cmd.go @@ -19,8 +19,6 @@ import ( "fmt" "net/http" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/cmd/pipelines/run/runutil" "github.com/datarobot/cli/cmd/pipelines/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" @@ -32,7 +30,7 @@ import ( func Cmd() *cobra.Command { var ( flags scopeflag.Flags - outputFormat outputfmt.OutputFormat + outputFormat pipelines.OutputFormat ) cmd := &cobra.Command{ @@ -61,12 +59,12 @@ Example: return handleStatusError(err, args[0]) } - return runutil.RenderRunStatus(outputFormat, *result) + return pipelines.RenderRunStatus(outputFormat, *result) }, } flags.Bind(cmd) - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/schedule/create/cmd.go b/cmd/pipelines/schedule/create/cmd.go index 0deee1393..0a3d797b2 100644 --- a/cmd/pipelines/schedule/create/cmd.go +++ b/cmd/pipelines/schedule/create/cmd.go @@ -17,8 +17,6 @@ package create import ( "errors" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/cmd/pipelines/schedule/scheduleutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" "github.com/spf13/cobra" @@ -31,7 +29,7 @@ func Cmd() *cobra.Command { cron string inputID string timezone string - outputFormat outputfmt.OutputFormat + outputFormat pipelines.OutputFormat ) cmd := &cobra.Command{ @@ -73,7 +71,7 @@ Example: return err } - return scheduleutil.RenderSchedule(outputFormat, *result) + return pipelines.RenderSchedule(outputFormat, *result) }, } @@ -82,7 +80,7 @@ Example: cmd.Flags().StringVar(&cron, "cron", "", "Cron expression, e.g. \"0 * * * *\"") cmd.Flags().StringVar(&inputID, "input", "", "Input ID to run on each tick") cmd.Flags().StringVar(&timezone, "timezone", "", "IANA timezone name (default UTC)") - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/schedule/get/cmd.go b/cmd/pipelines/schedule/get/cmd.go index 5f9271719..93d407609 100644 --- a/cmd/pipelines/schedule/get/cmd.go +++ b/cmd/pipelines/schedule/get/cmd.go @@ -19,8 +19,6 @@ import ( "fmt" "net/http" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/cmd/pipelines/schedule/scheduleutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" "github.com/datarobot/cli/internal/pipelines" @@ -32,7 +30,7 @@ func Cmd() *cobra.Command { var ( pipelineID string version int - outputFormat outputfmt.OutputFormat + outputFormat pipelines.OutputFormat ) cmd := &cobra.Command{ @@ -60,13 +58,13 @@ Example: return handleGetError(err, args[0]) } - return scheduleutil.RenderSchedule(outputFormat, *result) + return pipelines.RenderSchedule(outputFormat, *result) }, } cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/schedule/list/cmd.go b/cmd/pipelines/schedule/list/cmd.go index bebfd85c5..0e806433c 100644 --- a/cmd/pipelines/schedule/list/cmd.go +++ b/cmd/pipelines/schedule/list/cmd.go @@ -17,8 +17,6 @@ package list import ( "errors" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/cmd/pipelines/schedule/scheduleutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" "github.com/spf13/cobra" @@ -30,7 +28,7 @@ func Cmd() *cobra.Command { version int offset int limit int - outputFormat outputfmt.OutputFormat + outputFormat pipelines.OutputFormat ) cmd := &cobra.Command{ @@ -58,7 +56,7 @@ Example: return err } - return scheduleutil.RenderSchedules(outputFormat, items) + return pipelines.RenderSchedules(outputFormat, items) }, } @@ -66,7 +64,7 @@ Example: cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of schedules to return") - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/schedule/scheduleutil/render_test.go b/cmd/pipelines/schedule/scheduleutil/render_test.go deleted file mode 100644 index e9442ccdd..000000000 --- a/cmd/pipelines/schedule/scheduleutil/render_test.go +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright 2026 DataRobot, Inc. and its affiliates. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package scheduleutil - -import ( - "bytes" - "encoding/json" - "io" - "os" - "testing" - "time" - - "github.com/datarobot/cli/internal/pipelines" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func captureStdout(t *testing.T, fn func()) string { - t.Helper() - - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - fn() - - w.Close() - - os.Stdout = old - - var buf bytes.Buffer - - _, _ = io.Copy(&buf, r) - - return buf.String() -} - -func sampleSchedule() pipelines.Schedule { - return pipelines.Schedule{ - ScheduleID: "s-1", - PipelineID: "pl-1", - Version: 2, - CronExpression: "0 * * * *", - Timezone: "UTC", - Status: pipelines.ScheduleStatusActive, - CreatedAt: time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC), - UpdatedAt: time.Date(2026, 4, 29, 11, 0, 0, 0, time.UTC), - } -} - -func TestPrintScheduleJSON(t *testing.T) { - output := captureStdout(t, func() { - require.NoError(t, PrintScheduleJSON(sampleSchedule())) - }) - - var parsed map[string]any - - require.NoError(t, json.Unmarshal([]byte(output), &parsed)) - assert.Equal(t, "s-1", parsed["schedule_id"]) - assert.Equal(t, "ACTIVE", parsed["status"]) - assert.EqualValues(t, 2, parsed["version"]) -} - -func TestPrintScheduleHuman(t *testing.T) { - output := captureStdout(t, func() { PrintScheduleHuman(sampleSchedule()) }) - assert.Contains(t, output, "Schedule ID: s-1") - assert.Contains(t, output, "Version: v2") - assert.Contains(t, output, "Cron: 0 * * * *") - assert.Contains(t, output, "Timezone: UTC") - assert.Contains(t, output, "Status: ACTIVE") -} - -func TestPrintScheduleListJSON(t *testing.T) { - output := captureStdout(t, func() { - require.NoError(t, PrintScheduleListJSON([]pipelines.Schedule{sampleSchedule()})) - }) - - var parsed []map[string]any - - require.NoError(t, json.Unmarshal([]byte(output), &parsed)) - require.Len(t, parsed, 1) - assert.Equal(t, "s-1", parsed[0]["schedule_id"]) -} - -func TestPrintScheduleListHuman_Empty(t *testing.T) { - output := captureStdout(t, func() { PrintScheduleListHuman(nil) }) - assert.Contains(t, output, "No schedules found") -} - -func TestPrintScheduleListHuman_RendersTable(t *testing.T) { - output := captureStdout(t, func() { - PrintScheduleListHuman([]pipelines.Schedule{sampleSchedule()}) - }) - - assert.Contains(t, output, "SCHEDULE") - assert.Contains(t, output, "VERSION") - assert.Contains(t, output, "CRON") - assert.Contains(t, output, "TIMEZONE") - assert.Contains(t, output, "STATUS") - assert.Contains(t, output, "s-1") - assert.Contains(t, output, "v2") - assert.Contains(t, output, "0 * * * *") - assert.Contains(t, output, "UTC") - assert.Contains(t, output, "ACTIVE") -} diff --git a/cmd/pipelines/schedule/update/cmd.go b/cmd/pipelines/schedule/update/cmd.go index 6a77f189f..c1d0c750d 100644 --- a/cmd/pipelines/schedule/update/cmd.go +++ b/cmd/pipelines/schedule/update/cmd.go @@ -17,8 +17,6 @@ package update import ( "errors" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/cmd/pipelines/schedule/scheduleutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" "github.com/spf13/cobra" @@ -30,7 +28,7 @@ func Cmd() *cobra.Command { version int cron string timezone string - outputFormat outputfmt.OutputFormat + outputFormat pipelines.OutputFormat ) cmd := &cobra.Command{ @@ -58,7 +56,7 @@ Example: return err } - return scheduleutil.RenderSchedule(outputFormat, *result) + return pipelines.RenderSchedule(outputFormat, *result) }, } @@ -66,7 +64,7 @@ Example: cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") cmd.Flags().StringVar(&cron, "cron", "", "New cron expression") cmd.Flags().StringVar(&timezone, "timezone", "", "New IANA timezone name") - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/update/cmd.go b/cmd/pipelines/update/cmd.go index 3a5e915a1..8f00f0d98 100644 --- a/cmd/pipelines/update/cmd.go +++ b/cmd/pipelines/update/cmd.go @@ -17,8 +17,6 @@ package update import ( "errors" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/cmd/pipelines/pipelineutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" "github.com/spf13/cobra" @@ -26,7 +24,7 @@ import ( func Cmd() *cobra.Command { var ( - outputFormat outputfmt.OutputFormat + outputFormat pipelines.OutputFormat fromFile string ) @@ -63,12 +61,12 @@ Example: return err } - return pipelineutil.RenderCreateResponse(outputFormat, *result) + return pipelines.RenderCreateResponse(outputFormat, *result) }, } cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the Python file to upload, e.g. --from-file=./my_pipeline.py (alternative to the positional argument)") - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/update/cmd_test.go b/cmd/pipelines/update/cmd_test.go index 08570962f..d18acac14 100644 --- a/cmd/pipelines/update/cmd_test.go +++ b/cmd/pipelines/update/cmd_test.go @@ -22,8 +22,6 @@ import ( "testing" "time" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/cmd/pipelines/pipelineutil" "github.com/datarobot/cli/internal/pipelines" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -65,7 +63,7 @@ func TestPrintUpdateJSON(t *testing.T) { resp := sampleUpdateResponse() output := captureStdout(t, func() { - err := pipelineutil.RenderCreateResponse(outputfmt.OutputFormatJSON, resp) + err := pipelines.RenderCreateResponse(pipelines.OutputFormatJSON, resp) require.NoError(t, err) }) @@ -82,7 +80,7 @@ func TestPrintUpdateHuman_WithTasks(t *testing.T) { resp := sampleUpdateResponse() output := captureStdout(t, func() { - require.NoError(t, pipelineutil.RenderCreateResponse(outputfmt.OutputFormatText, resp)) + require.NoError(t, pipelines.RenderCreateResponse(pipelines.OutputFormatText, resp)) }) assert.Contains(t, output, resp.PipelineID) @@ -98,7 +96,7 @@ func TestPrintUpdateHuman_NoTasks(t *testing.T) { resp.TaskNames = nil output := captureStdout(t, func() { - require.NoError(t, pipelineutil.RenderCreateResponse(outputfmt.OutputFormatText, resp)) + require.NoError(t, pipelines.RenderCreateResponse(pipelines.OutputFormatText, resp)) }) assert.Contains(t, output, "—") diff --git a/cmd/pipelines/version/get/cmd.go b/cmd/pipelines/version/get/cmd.go index 5fb4c3514..5496cd563 100644 --- a/cmd/pipelines/version/get/cmd.go +++ b/cmd/pipelines/version/get/cmd.go @@ -20,8 +20,6 @@ import ( "net/http" "strconv" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/cmd/pipelines/version/versionutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" "github.com/datarobot/cli/internal/pipelines" @@ -32,7 +30,7 @@ import ( func Cmd() *cobra.Command { var ( pipelineID string - outputFormat outputfmt.OutputFormat + outputFormat pipelines.OutputFormat ) cmd := &cobra.Command{ @@ -61,12 +59,12 @@ Example: return handleGetError(err, args[0]) } - return versionutil.RenderVersion(outputFormat, *result) + return pipelines.RenderVersion(outputFormat, *result) }, } cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/version/list/cmd.go b/cmd/pipelines/version/list/cmd.go index 8456db175..e45271198 100644 --- a/cmd/pipelines/version/list/cmd.go +++ b/cmd/pipelines/version/list/cmd.go @@ -17,8 +17,6 @@ package list import ( "errors" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/cmd/pipelines/version/versionutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" "github.com/spf13/cobra" @@ -29,7 +27,7 @@ func Cmd() *cobra.Command { pipelineID string offset int limit int - outputFormat outputfmt.OutputFormat + outputFormat pipelines.OutputFormat ) cmd := &cobra.Command{ @@ -53,14 +51,14 @@ Example: return err } - return versionutil.RenderVersions(outputFormat, items) + return pipelines.RenderVersions(outputFormat, items) }, } cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of versions to return") - outputfmt.AddOutputFlag(cmd, &outputFormat) + pipelines.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipelines/version/versionutil/render_test.go b/cmd/pipelines/version/versionutil/render_test.go deleted file mode 100644 index 51a90c19d..000000000 --- a/cmd/pipelines/version/versionutil/render_test.go +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright 2026 DataRobot, Inc. and its affiliates. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package versionutil - -import ( - "bytes" - "encoding/json" - "io" - "os" - "testing" - "time" - - "github.com/datarobot/cli/internal/pipelines" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func captureStdout(t *testing.T, fn func()) string { - t.Helper() - - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - fn() - - w.Close() - - os.Stdout = old - - var buf bytes.Buffer - - _, _ = io.Copy(&buf, r) - - return buf.String() -} - -func sampleVersion() pipelines.PipelineVersion { - return pipelines.PipelineVersion{ - Version: 2, - Status: "READY", - PipelineName: "wf", - TaskNames: []string{"e1", "e2"}, - PythonVersion: "3.12", - CreatedAt: time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC), - } -} - -func TestPrintVersionJSON(t *testing.T) { - output := captureStdout(t, func() { - require.NoError(t, PrintVersionJSON(sampleVersion())) - }) - - var parsed map[string]any - - require.NoError(t, json.Unmarshal([]byte(output), &parsed)) - assert.EqualValues(t, 2, parsed["version"]) - assert.Equal(t, "READY", parsed["status"]) - assert.Equal(t, "wf", parsed["pipeline_name"]) -} - -func TestPrintVersionHuman_Full(t *testing.T) { - output := captureStdout(t, func() { PrintVersionHuman(sampleVersion()) }) - assert.Contains(t, output, "Version:") - assert.Contains(t, output, "v2") - assert.Contains(t, output, "Pipeline:") - assert.Contains(t, output, "wf") - assert.Contains(t, output, "Status:") - assert.Contains(t, output, "READY") - assert.Contains(t, output, "Python Version:") - assert.Contains(t, output, "3.12") - assert.Contains(t, output, "Tasks:") - assert.Contains(t, output, "e1, e2") - assert.Contains(t, output, "Created:") - assert.Contains(t, output, "2026-04-29 10:00 UTC") -} - -func TestPrintVersionHuman_FillsDefaultsForMissing(t *testing.T) { - v := sampleVersion() - v.TaskNames = nil - v.PythonVersion = "" - - output := captureStdout(t, func() { PrintVersionHuman(v) }) - assert.Contains(t, output, "Python Version:") - assert.Contains(t, output, "\u2014") -} - -func TestPrintVersionHuman_ShowsErrorDetail(t *testing.T) { - v := sampleVersion() - v.ErrorDetail = "syntax error" - - output := captureStdout(t, func() { PrintVersionHuman(v) }) - assert.Contains(t, output, "Error:") - assert.Contains(t, output, "syntax error") -} - -func TestPrintVersionListJSON(t *testing.T) { - output := captureStdout(t, func() { - require.NoError(t, PrintVersionListJSON([]pipelines.PipelineVersion{sampleVersion()})) - }) - - var parsed []map[string]any - - require.NoError(t, json.Unmarshal([]byte(output), &parsed)) - require.Len(t, parsed, 1) - assert.EqualValues(t, 2, parsed[0]["version"]) -} - -func TestPrintVersionListHuman_Empty(t *testing.T) { - output := captureStdout(t, func() { PrintVersionListHuman(nil) }) - assert.Contains(t, output, "No versions found") -} - -func TestPrintVersionListHuman_RendersTable(t *testing.T) { - output := captureStdout(t, func() { - PrintVersionListHuman([]pipelines.PipelineVersion{sampleVersion()}) - }) - - assert.Contains(t, output, "VERSION") - assert.Contains(t, output, "STATUS") - assert.Contains(t, output, "PYTHON") - assert.Contains(t, output, "v2") - assert.Contains(t, output, "READY") - assert.Contains(t, output, "3.12") - assert.Contains(t, output, "e1, e2") -} diff --git a/cmd/pipelines/environment/envutil/render.go b/internal/pipelines/environment_output.go similarity index 83% rename from cmd/pipelines/environment/envutil/render.go rename to internal/pipelines/environment_output.go index 537a068d2..d8055a369 100644 --- a/cmd/pipelines/environment/envutil/render.go +++ b/internal/pipelines/environment_output.go @@ -12,14 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -// render.go centralises the human/JSON output rendering used by the -// `dr pipelines environment` verbs so each verb file stays focused on +// environment_output.go centralises the human/JSON output rendering used by +// the `dr pipelines environment` verbs so each verb file stays focused on // flag wiring. - -package envutil +package pipelines import ( "encoding/json" + "errors" "fmt" "os" "slices" @@ -30,16 +30,9 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss/table" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/internal/pipelines" "github.com/datarobot/cli/tui" ) -const ( - timestampFormat = "2006-01-02 15:04 UTC" - emptyValuePlaceholder = "—" -) - // environmentVersionJSON is the DTO for a single EnvironmentVersion in JSON output. type environmentVersionJSON struct { Version int `json:"version"` @@ -72,7 +65,7 @@ type environmentSummaryJSON struct { UpdatedAt string `json:"updated_at"` } -func toEnvironmentJSON(env pipelines.Environment) environmentJSON { +func toEnvironmentJSON(env Environment) environmentJSON { versions := make([]environmentVersionJSON, len(env.Versions)) for i, v := range env.Versions { @@ -97,7 +90,7 @@ func toEnvironmentJSON(env pipelines.Environment) environmentJSON { } } -func toEnvironmentSummaryJSON(env pipelines.EnvironmentSummary) environmentSummaryJSON { +func toEnvironmentSummaryJSON(env EnvironmentSummary) environmentSummaryJSON { return environmentSummaryJSON{ EnvironmentID: env.EnvironmentID, Name: env.Name, @@ -110,8 +103,8 @@ func toEnvironmentSummaryJSON(env pipelines.EnvironmentSummary) environmentSumma } // RenderEnvironment routes a single environment to JSON or human output. -func RenderEnvironment(format outputfmt.OutputFormat, env pipelines.Environment) error { - if format == outputfmt.OutputFormatJSON { +func RenderEnvironment(format OutputFormat, env Environment) error { + if format == OutputFormatJSON { return PrintEnvironmentJSON(env) } @@ -121,8 +114,8 @@ func RenderEnvironment(format outputfmt.OutputFormat, env pipelines.Environment) } // RenderEnvironments routes a list of environments to JSON or human output. -func RenderEnvironments(format outputfmt.OutputFormat, items []pipelines.EnvironmentSummary) error { - if format == outputfmt.OutputFormatJSON { +func RenderEnvironments(format OutputFormat, items []EnvironmentSummary) error { + if format == OutputFormatJSON { return PrintEnvironmentListJSON(items) } @@ -132,7 +125,7 @@ func RenderEnvironments(format outputfmt.OutputFormat, items []pipelines.Environ } // PrintEnvironmentJSON marshals an environment record as indented JSON through the DTO. -func PrintEnvironmentJSON(env pipelines.Environment) error { +func PrintEnvironmentJSON(env Environment) error { data, err := json.MarshalIndent(toEnvironmentJSON(env), "", " ") if err != nil { return err @@ -145,7 +138,7 @@ func PrintEnvironmentJSON(env pipelines.Environment) error { // PrintEnvironmentHuman renders the key facts about a single environment // record, including its full version history. -func PrintEnvironmentHuman(env pipelines.Environment) { +func PrintEnvironmentHuman(env Environment) { desc := emptyValuePlaceholder if env.Description != nil && *env.Description != "" { desc = *env.Description @@ -206,7 +199,7 @@ func PrintEnvironmentHuman(env pipelines.Environment) { } // PrintEnvironmentListJSON marshals a list of environments as indented JSON through the DTO. -func PrintEnvironmentListJSON(items []pipelines.EnvironmentSummary) error { +func PrintEnvironmentListJSON(items []EnvironmentSummary) error { view := make([]environmentSummaryJSON, len(items)) for i, env := range items { @@ -224,7 +217,7 @@ func PrintEnvironmentListJSON(items []pipelines.EnvironmentSummary) error { } // PrintEnvironmentListHuman renders a lipgloss table summary of environments. -func PrintEnvironmentListHuman(items []pipelines.EnvironmentSummary) { +func PrintEnvironmentListHuman(items []EnvironmentSummary) { if len(items) == 0 { fmt.Println(tui.DimStyle.Render("No environments found")) @@ -281,3 +274,25 @@ func joinPackages(packages []string) string { return joined[:maxLen-3] + "..." } + +// NormalizePackages takes the raw slice from a cobra StringSliceVar and +// returns a cleaned list. It returns an error when the resulting list is +// empty so callers can surface a friendly validation message. +func NormalizePackages(raw []string) ([]string, error) { + out := make([]string, 0, len(raw)) + + for _, entry := range raw { + for _, item := range strings.Split(entry, ",") { + trimmed := strings.TrimSpace(item) + if trimmed != "" { + out = append(out, trimmed) + } + } + } + + if len(out) == 0 { + return nil, errors.New("at least one package is required (use --package)") + } + + return out, nil +} diff --git a/cmd/pipelines/outputfmt/flags.go b/internal/pipelines/flags.go similarity index 93% rename from cmd/pipelines/outputfmt/flags.go rename to internal/pipelines/flags.go index 1e67309d0..a3e044ab2 100644 --- a/cmd/pipelines/outputfmt/flags.go +++ b/internal/pipelines/flags.go @@ -12,9 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package outputfmt provides the --output-format flag shared by all -// `dr pipelines` subcommands. -package outputfmt +package pipelines import ( "fmt" diff --git a/cmd/pipelines/input/inpututil/render.go b/internal/pipelines/input_output.go similarity index 84% rename from cmd/pipelines/input/inpututil/render.go rename to internal/pipelines/input_output.go index cc1f7fa4b..af63e9bcf 100644 --- a/cmd/pipelines/input/inpututil/render.go +++ b/internal/pipelines/input_output.go @@ -12,10 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -// render.go centralises the human/JSON output rendering used by the input -// verbs so each verb file stays focused on flag wiring. - -package inpututil +// input_output.go centralises the human/JSON output rendering used by the +// input verbs so each verb file stays focused on flag wiring. +package pipelines import ( "encoding/json" @@ -28,16 +27,9 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss/table" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/internal/pipelines" "github.com/datarobot/cli/tui" ) -const ( - timestampFormat = "2006-01-02 15:04 UTC" - emptyValuePlaceholder = "—" -) - // inputJSON is the CLI-facing DTO used for `--output-format json`. type inputJSON struct { InputID string `json:"input_id"` @@ -50,7 +42,7 @@ type inputJSON struct { UpdatedAt string `json:"updated_at"` } -func toInputJSON(input pipelines.Input) inputJSON { +func toInputJSON(input Input) inputJSON { scope := "draft" version := emptyValuePlaceholder @@ -74,8 +66,8 @@ func toInputJSON(input pipelines.Input) inputJSON { } // RenderInput routes a single input to JSON or human output. -func RenderInput(format outputfmt.OutputFormat, input pipelines.Input) error { - if format == outputfmt.OutputFormatJSON { +func RenderInput(format OutputFormat, input Input) error { + if format == OutputFormatJSON { return PrintInputJSON(input) } @@ -85,8 +77,8 @@ func RenderInput(format outputfmt.OutputFormat, input pipelines.Input) error { } // RenderInputs routes a list of inputs to JSON or human output. -func RenderInputs(format outputfmt.OutputFormat, inputs []pipelines.Input) error { - if format == outputfmt.OutputFormatJSON { +func RenderInputs(format OutputFormat, inputs []Input) error { + if format == OutputFormatJSON { return PrintInputListJSON(inputs) } @@ -96,7 +88,7 @@ func RenderInputs(format outputfmt.OutputFormat, inputs []pipelines.Input) error } // PrintInputJSON marshals an input record as indented JSON through the DTO. -func PrintInputJSON(input pipelines.Input) error { +func PrintInputJSON(input Input) error { data, err := json.MarshalIndent(toInputJSON(input), "", " ") if err != nil { return err @@ -108,7 +100,7 @@ func PrintInputJSON(input pipelines.Input) error { } // PrintInputHuman renders the key facts about a single input record. -func PrintInputHuman(input pipelines.Input) { +func PrintInputHuman(input Input) { scope := "draft" versionDisplay := emptyValuePlaceholder @@ -140,7 +132,7 @@ func PrintInputHuman(input pipelines.Input) { } // PrintInputListJSON marshals a list of inputs as indented JSON through the DTO. -func PrintInputListJSON(inputs []pipelines.Input) error { +func PrintInputListJSON(inputs []Input) error { view := make([]inputJSON, len(inputs)) for i, in := range inputs { @@ -158,7 +150,7 @@ func PrintInputListJSON(inputs []pipelines.Input) error { } // PrintInputListHuman renders a lipgloss table summary of inputs. -func PrintInputListHuman(inputs []pipelines.Input) { +func PrintInputListHuman(inputs []Input) { if len(inputs) == 0 { fmt.Println(tui.DimStyle.Render("No inputs found")) diff --git a/cmd/pipelines/input/inpututil/payload.go b/internal/pipelines/input_payload.go similarity index 86% rename from cmd/pipelines/input/inpututil/payload.go rename to internal/pipelines/input_payload.go index 3459fdad8..ef96b8771 100644 --- a/cmd/pipelines/input/inpututil/payload.go +++ b/internal/pipelines/input_payload.go @@ -12,11 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -// payload.go contains the shared helper for resolving an input payload +// input_payload.go contains the shared helper for resolving an input payload // from either a positional argument or the --from-file flag, then parsing // it as JSON. - -package inpututil +package pipelines import ( "encoding/json" @@ -31,7 +30,7 @@ import ( // must be provided. The contents of the file must be a JSON object so it // fits the `{payload: object}` body the API expects. func ResolvePayload(args []string, fromFile string) (map[string]any, error) { - path, err := resolveFilePath(args, fromFile) + path, err := resolvePayloadFilePath(args, fromFile) if err != nil { return nil, err } @@ -51,9 +50,9 @@ func ResolvePayload(args []string, fromFile string) (map[string]any, error) { return payload, nil } -// resolveFilePath returns the file path supplied either positionally or +// resolvePayloadFilePath returns the file path supplied either positionally or // via --from-file. Exactly one of the two must be provided. -func resolveFilePath(args []string, fromFile string) (string, error) { +func resolvePayloadFilePath(args []string, fromFile string) (string, error) { positional := "" if len(args) > 0 { positional = args[0] diff --git a/cmd/pipelines/pipelineutil/render.go b/internal/pipelines/pipeline_output.go similarity index 84% rename from cmd/pipelines/pipelineutil/render.go rename to internal/pipelines/pipeline_output.go index be6dd1d84..e76477c04 100644 --- a/cmd/pipelines/pipelineutil/render.go +++ b/internal/pipelines/pipeline_output.go @@ -12,9 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package pipelineutil holds the rendering helpers shared by the top-level +// pipeline_output.go holds the rendering helpers shared by the top-level // `dr pipelines` verbs (list, get, create, update, lock). -package pipelineutil +package pipelines import ( "encoding/json" @@ -27,8 +27,6 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss/table" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/internal/pipelines" "github.com/datarobot/cli/tui" ) @@ -38,8 +36,8 @@ const ( ) // RenderPipeline routes a single pipeline to JSON or human output. -func RenderPipeline(format outputfmt.OutputFormat, p pipelines.Pipeline) error { - if format == outputfmt.OutputFormatJSON { +func RenderPipeline(format OutputFormat, p Pipeline) error { + if format == OutputFormatJSON { return printPipelineJSON(p) } @@ -49,8 +47,8 @@ func RenderPipeline(format outputfmt.OutputFormat, p pipelines.Pipeline) error { } // RenderPipelines routes a pipeline list to JSON or human output. -func RenderPipelines(format outputfmt.OutputFormat, list pipelines.ListResponse) error { - if format == outputfmt.OutputFormatJSON { +func RenderPipelines(format OutputFormat, list ListResponse) error { + if format == OutputFormatJSON { return printPipelinesJSON(list) } @@ -60,8 +58,8 @@ func RenderPipelines(format outputfmt.OutputFormat, list pipelines.ListResponse) } // RenderCreateResponse routes a CreateResponse to JSON or human output. -func RenderCreateResponse(format outputfmt.OutputFormat, result pipelines.CreateResponse) error { - if format == outputfmt.OutputFormatJSON { +func RenderCreateResponse(format OutputFormat, result CreateResponse) error { + if format == OutputFormatJSON { return printCreateResponseJSON(result) } @@ -70,7 +68,7 @@ func RenderCreateResponse(format outputfmt.OutputFormat, result pipelines.Create return nil } -func printPipelineJSON(p pipelines.Pipeline) error { +func printPipelineJSON(p Pipeline) error { data, err := json.MarshalIndent(p, "", " ") if err != nil { return err @@ -81,7 +79,7 @@ func printPipelineJSON(p pipelines.Pipeline) error { return nil } -func printPipelinesJSON(list pipelines.ListResponse) error { +func printPipelinesJSON(list ListResponse) error { data, err := json.MarshalIndent(list, "", " ") if err != nil { return err @@ -92,7 +90,7 @@ func printPipelinesJSON(list pipelines.ListResponse) error { return nil } -func printCreateResponseJSON(result pipelines.CreateResponse) error { +func printCreateResponseJSON(result CreateResponse) error { data, err := json.MarshalIndent(result, "", " ") if err != nil { return err @@ -103,7 +101,7 @@ func printCreateResponseJSON(result pipelines.CreateResponse) error { return nil } -func printPipelinesHuman(list pipelines.ListResponse) { +func printPipelinesHuman(list ListResponse) { if len(list.Items) == 0 { fmt.Println(tui.DimStyle.Render("No pipelines found.")) @@ -152,7 +150,7 @@ func printPipelinesHuman(list pipelines.ListResponse) { fmt.Fprintln(os.Stdout, t.Render()) } -func printPipelineHuman(p pipelines.Pipeline) { +func printPipelineHuman(p Pipeline) { description := emptyValuePlaceholder if p.Description != "" { description = p.Description @@ -232,7 +230,7 @@ func printPipelineHuman(p pipelines.Pipeline) { } } -func printCreateResponseHuman(result pipelines.CreateResponse) { +func printCreateResponseHuman(result CreateResponse) { tasks := emptyValuePlaceholder if len(result.TaskNames) > 0 { tasks = strings.Join(result.TaskNames, ", ") diff --git a/cmd/pipelines/run/runutil/render.go b/internal/pipelines/run_output.go similarity index 80% rename from cmd/pipelines/run/runutil/render.go rename to internal/pipelines/run_output.go index 8f8948362..373aa08c8 100644 --- a/cmd/pipelines/run/runutil/render.go +++ b/internal/pipelines/run_output.go @@ -12,11 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package runutil holds the rendering helpers shared by the -// `dr pipelines run` verbs. Living in a sibling package keeps the -// parent `run` package free of cycles. - -package runutil +// run_output.go holds the rendering helpers shared by the +// `dr pipelines run` verbs. +package pipelines import ( "encoding/json" @@ -29,20 +27,13 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss/table" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/internal/pipelines" "github.com/datarobot/cli/tui" ) -const ( - timestampFormat = "2006-01-02 15:04 UTC" - emptyValuePlaceholder = "—" -) - // runJSON is the CLI-facing shape used for `--output-format json`. It mirrors -// pipelines.Run but renames the wire-level fields to the CLI's `run` +// Run but renames the wire-level fields to the CLI's `run` // vocabulary (`run_id`, `covalent_run_id`). Decoding still happens -// through pipelines.Run, which keeps the API wire tags intact. +// through Run, which keeps the API wire tags intact. type runJSON struct { RunID string `json:"run_id"` PipelineID string `json:"pipeline_id"` @@ -56,7 +47,7 @@ type runJSON struct { UpdatedAt string `json:"updated_at"` } -func toRunJSON(r pipelines.Run) runJSON { +func toRunJSON(r Run) runJSON { return runJSON{ RunID: r.RunID, PipelineID: r.PipelineID, @@ -71,14 +62,14 @@ func toRunJSON(r pipelines.Run) runJSON { } } -// runStatusJSON mirrors pipelines.RunStatus with CLI-vocabulary keys. +// runStatusJSON mirrors RunStatus with CLI-vocabulary keys. type runStatusJSON struct { RunID string `json:"run_id"` Status string `json:"status"` CovalentRunID string `json:"covalent_run_id,omitempty"` } -func toRunStatusJSON(s pipelines.RunStatus) runStatusJSON { +func toRunStatusJSON(s RunStatus) runStatusJSON { return runStatusJSON{ RunID: s.RunID, Status: s.Status, @@ -87,8 +78,8 @@ func toRunStatusJSON(s pipelines.RunStatus) runStatusJSON { } // RenderRun routes a single run to JSON or human output. -func RenderRun(format outputfmt.OutputFormat, r pipelines.Run) error { - if format == outputfmt.OutputFormatJSON { +func RenderRun(format OutputFormat, r Run) error { + if format == OutputFormatJSON { return PrintRunJSON(r) } @@ -98,8 +89,8 @@ func RenderRun(format outputfmt.OutputFormat, r pipelines.Run) error { } // RenderRuns routes a list of runs to JSON or human output. -func RenderRuns(format outputfmt.OutputFormat, items []pipelines.Run) error { - if format == outputfmt.OutputFormatJSON { +func RenderRuns(format OutputFormat, items []Run) error { + if format == OutputFormatJSON { return PrintRunListJSON(items) } @@ -109,8 +100,8 @@ func RenderRuns(format outputfmt.OutputFormat, items []pipelines.Run) error { } // RenderRunStatus routes a run status to JSON or human output. -func RenderRunStatus(format outputfmt.OutputFormat, s pipelines.RunStatus) error { - if format == outputfmt.OutputFormatJSON { +func RenderRunStatus(format OutputFormat, s RunStatus) error { + if format == OutputFormatJSON { return PrintStatusJSON(s) } @@ -120,7 +111,7 @@ func RenderRunStatus(format outputfmt.OutputFormat, s pipelines.RunStatus) error } // PrintRunJSON marshals a run as indented JSON using CLI-vocabulary keys. -func PrintRunJSON(r pipelines.Run) error { +func PrintRunJSON(r Run) error { data, err := json.MarshalIndent(toRunJSON(r), "", " ") if err != nil { return err @@ -132,7 +123,7 @@ func PrintRunJSON(r pipelines.Run) error { } // PrintRunHuman renders a single run in a human-friendly form. -func PrintRunHuman(r pipelines.Run) { +func PrintRunHuman(r Run) { scope := "draft" versionDisplay := emptyValuePlaceholder @@ -169,7 +160,7 @@ func PrintRunHuman(r pipelines.Run) { // PrintRunListJSON marshals a list of runs as indented JSON using // CLI-vocabulary keys. -func PrintRunListJSON(items []pipelines.Run) error { +func PrintRunListJSON(items []Run) error { view := make([]runJSON, len(items)) for i, r := range items { @@ -187,7 +178,7 @@ func PrintRunListJSON(items []pipelines.Run) error { } // PrintRunListHuman renders a lipgloss table summary of runs. -func PrintRunListHuman(items []pipelines.Run) { +func PrintRunListHuman(items []Run) { if len(items) == 0 { fmt.Println(tui.DimStyle.Render("No runs found")) @@ -235,7 +226,7 @@ func PrintRunListHuman(items []pipelines.Run) { // PrintStatusJSON marshals a lightweight status response as indented JSON // using CLI-vocabulary keys. -func PrintStatusJSON(s pipelines.RunStatus) error { +func PrintStatusJSON(s RunStatus) error { data, err := json.MarshalIndent(toRunStatusJSON(s), "", " ") if err != nil { return err @@ -247,7 +238,7 @@ func PrintStatusJSON(s pipelines.RunStatus) error { } // PrintStatusHuman renders a lightweight status response. -func PrintStatusHuman(s pipelines.RunStatus) { +func PrintStatusHuman(s RunStatus) { covalent := s.CovalentDispatchID if covalent == "" { covalent = emptyValuePlaceholder diff --git a/cmd/pipelines/schedule/scheduleutil/render.go b/internal/pipelines/schedule_output.go similarity index 81% rename from cmd/pipelines/schedule/scheduleutil/render.go rename to internal/pipelines/schedule_output.go index 87127bd42..92d6a0624 100644 --- a/cmd/pipelines/schedule/scheduleutil/render.go +++ b/internal/pipelines/schedule_output.go @@ -12,11 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package scheduleutil holds the rendering helpers shared by the -// `dr pipelines schedule` verbs. Sibling-package layout avoids cycles -// with the parent schedule command. - -package scheduleutil +// schedule_output.go holds the rendering helpers shared by the +// `dr pipelines schedule` verbs. +package pipelines import ( "encoding/json" @@ -28,16 +26,9 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss/table" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/internal/pipelines" "github.com/datarobot/cli/tui" ) -const ( - timestampFormat = "2006-01-02 15:04 UTC" - emptyValuePlaceholder = "—" -) - // scheduleJSON is the CLI-facing DTO used for `--output-format json`. type scheduleJSON struct { ScheduleID string `json:"schedule_id"` @@ -50,7 +41,7 @@ type scheduleJSON struct { UpdatedAt string `json:"updated_at"` } -func toScheduleJSON(s pipelines.Schedule) scheduleJSON { +func toScheduleJSON(s Schedule) scheduleJSON { return scheduleJSON{ ScheduleID: s.ScheduleID, PipelineID: s.PipelineID, @@ -64,8 +55,8 @@ func toScheduleJSON(s pipelines.Schedule) scheduleJSON { } // RenderSchedule routes a single schedule to JSON or human output. -func RenderSchedule(format outputfmt.OutputFormat, s pipelines.Schedule) error { - if format == outputfmt.OutputFormatJSON { +func RenderSchedule(format OutputFormat, s Schedule) error { + if format == OutputFormatJSON { return PrintScheduleJSON(s) } @@ -75,8 +66,8 @@ func RenderSchedule(format outputfmt.OutputFormat, s pipelines.Schedule) error { } // RenderSchedules routes a list of schedules to JSON or human output. -func RenderSchedules(format outputfmt.OutputFormat, items []pipelines.Schedule) error { - if format == outputfmt.OutputFormatJSON { +func RenderSchedules(format OutputFormat, items []Schedule) error { + if format == OutputFormatJSON { return PrintScheduleListJSON(items) } @@ -86,7 +77,7 @@ func RenderSchedules(format outputfmt.OutputFormat, items []pipelines.Schedule) } // PrintScheduleJSON marshals a schedule as indented JSON through the DTO. -func PrintScheduleJSON(s pipelines.Schedule) error { +func PrintScheduleJSON(s Schedule) error { data, err := json.MarshalIndent(toScheduleJSON(s), "", " ") if err != nil { return err @@ -98,7 +89,7 @@ func PrintScheduleJSON(s pipelines.Schedule) error { } // PrintScheduleHuman renders a single schedule in human-friendly form. -func PrintScheduleHuman(s pipelines.Schedule) { +func PrintScheduleHuman(s Schedule) { w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintf(w, "Schedule ID:\t%s\n", s.ScheduleID) @@ -114,7 +105,7 @@ func PrintScheduleHuman(s pipelines.Schedule) { } // PrintScheduleListJSON marshals a list of schedules as indented JSON through the DTO. -func PrintScheduleListJSON(items []pipelines.Schedule) error { +func PrintScheduleListJSON(items []Schedule) error { view := make([]scheduleJSON, len(items)) for i, s := range items { @@ -132,7 +123,7 @@ func PrintScheduleListJSON(items []pipelines.Schedule) error { } // PrintScheduleListHuman renders a lipgloss table summary of schedules. -func PrintScheduleListHuman(items []pipelines.Schedule) { +func PrintScheduleListHuman(items []Schedule) { if len(items) == 0 { fmt.Println(tui.DimStyle.Render("No schedules found")) diff --git a/cmd/pipelines/version/versionutil/render.go b/internal/pipelines/version_output.go similarity index 81% rename from cmd/pipelines/version/versionutil/render.go rename to internal/pipelines/version_output.go index 89f3f447a..e2bfe51d5 100644 --- a/cmd/pipelines/version/versionutil/render.go +++ b/internal/pipelines/version_output.go @@ -12,11 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package versionutil contains rendering helpers shared by the -// `dr pipelines version` verbs. Living in a sibling package keeps the -// parent `version` package free of cycles. - -package versionutil +// version_output.go contains rendering helpers shared by the +// `dr pipelines version` verbs. +package pipelines import ( "encoding/json" @@ -30,16 +28,9 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss/table" - "github.com/datarobot/cli/cmd/pipelines/outputfmt" - "github.com/datarobot/cli/internal/pipelines" "github.com/datarobot/cli/tui" ) -const ( - timestampFormat = "2006-01-02 15:04 UTC" - emptyValuePlaceholder = "—" -) - // versionJSON is the CLI-facing DTO used for `--output-format json`. type versionJSON struct { Version int `json:"version"` @@ -52,7 +43,7 @@ type versionJSON struct { CreatedAt string `json:"created_at"` } -func toVersionJSON(v pipelines.PipelineVersion) versionJSON { +func toVersionJSON(v PipelineVersion) versionJSON { return versionJSON{ Version: v.Version, Status: v.Status, @@ -66,8 +57,8 @@ func toVersionJSON(v pipelines.PipelineVersion) versionJSON { } // RenderVersion routes a single version to JSON or human output. -func RenderVersion(format outputfmt.OutputFormat, v pipelines.PipelineVersion) error { - if format == outputfmt.OutputFormatJSON { +func RenderVersion(format OutputFormat, v PipelineVersion) error { + if format == OutputFormatJSON { return PrintVersionJSON(v) } @@ -77,8 +68,8 @@ func RenderVersion(format outputfmt.OutputFormat, v pipelines.PipelineVersion) e } // RenderVersions routes a list of versions to JSON or human output. -func RenderVersions(format outputfmt.OutputFormat, items []pipelines.PipelineVersion) error { - if format == outputfmt.OutputFormatJSON { +func RenderVersions(format OutputFormat, items []PipelineVersion) error { + if format == OutputFormatJSON { return PrintVersionListJSON(items) } @@ -88,7 +79,7 @@ func RenderVersions(format outputfmt.OutputFormat, items []pipelines.PipelineVer } // PrintVersionJSON marshals a single version as indented JSON through the DTO. -func PrintVersionJSON(v pipelines.PipelineVersion) error { +func PrintVersionJSON(v PipelineVersion) error { data, err := json.MarshalIndent(toVersionJSON(v), "", " ") if err != nil { return err @@ -100,7 +91,7 @@ func PrintVersionJSON(v pipelines.PipelineVersion) error { } // PrintVersionHuman renders the key facts about a single version. -func PrintVersionHuman(v pipelines.PipelineVersion) { +func PrintVersionHuman(v PipelineVersion) { tasks := emptyValuePlaceholder if len(v.TaskNames) > 0 { tasks = strings.Join(v.TaskNames, ", ") @@ -129,7 +120,7 @@ func PrintVersionHuman(v pipelines.PipelineVersion) { } // PrintVersionListJSON marshals a list of versions as indented JSON through the DTO. -func PrintVersionListJSON(items []pipelines.PipelineVersion) error { +func PrintVersionListJSON(items []PipelineVersion) error { view := make([]versionJSON, len(items)) for i, v := range items { @@ -147,7 +138,7 @@ func PrintVersionListJSON(items []pipelines.PipelineVersion) error { } // PrintVersionListHuman renders a lipgloss table summary of versions. -func PrintVersionListHuman(items []pipelines.PipelineVersion) { +func PrintVersionListHuman(items []PipelineVersion) { if len(items) == 0 { fmt.Println(tui.DimStyle.Render("No versions found")) From 02c9843a2c17a352cbee0d426bcda91bb32a4d6b Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Fri, 22 May 2026 22:09:51 -0400 Subject: [PATCH 19/25] [CMPT-5391] align Go structs and tests with pipelines-api actions 052-056 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - camelCase all JSON tags to match DrBaseModel alias_generator (action 056) - Primary resource IDs now decode as "id" on the wire - Wrap all list endpoints in DataPage[T] envelope (action 056) - Remove PipelineName (lattice_name) from PipelineVersion — dropped by action 055 - Rename electron_names → taskNames, python_version → pythonVersion, etc. - Update all test mock responses and DataPage[ListItem] in list cmd tests - Fix pre-existing invalid "t" timestamps in environment_test.go Co-Authored-By: Claude Sonnet 4.6 --- cmd/pipelines/create/cmd_test.go | 2 +- cmd/pipelines/get/cmd_test.go | 4 +- cmd/pipelines/graph/cmd_test.go | 2 +- cmd/pipelines/list/cmd_test.go | 25 +++---- cmd/pipelines/lock/cmd_test.go | 2 +- cmd/pipelines/update/cmd_test.go | 2 +- internal/pipelines/environment.go | 30 ++++---- internal/pipelines/environment_test.go | 24 +++---- internal/pipelines/input.go | 19 +++-- internal/pipelines/input_test.go | 12 ++-- internal/pipelines/pipeline.go | 71 ++++++++----------- internal/pipelines/pipeline_lifecycle_test.go | 6 +- internal/pipelines/pipeline_output.go | 18 ++--- internal/pipelines/run.go | 35 ++++----- internal/pipelines/run_test.go | 10 +-- internal/pipelines/schedule.go | 16 ++--- internal/pipelines/schedule_test.go | 8 +-- internal/pipelines/transport.go | 10 +++ internal/pipelines/version.go | 21 +++--- internal/pipelines/version_output.go | 3 - internal/pipelines/version_test.go | 8 +-- 21 files changed, 152 insertions(+), 176 deletions(-) diff --git a/cmd/pipelines/create/cmd_test.go b/cmd/pipelines/create/cmd_test.go index 264411c75..e71ef716e 100644 --- a/cmd/pipelines/create/cmd_test.go +++ b/cmd/pipelines/create/cmd_test.go @@ -71,7 +71,7 @@ func TestPrintCreateJSON(t *testing.T) { err := json.Unmarshal([]byte(output), &parsed) require.NoError(t, err) - assert.Equal(t, resp.PipelineID, parsed["pipeline_id"]) + assert.Equal(t, resp.PipelineID, parsed["id"]) assert.Equal(t, resp.Name, parsed["name"]) assert.Equal(t, "READY", parsed["status"]) assert.Equal(t, "draft", parsed["mode"]) diff --git a/cmd/pipelines/get/cmd_test.go b/cmd/pipelines/get/cmd_test.go index d6f7710f8..62bd73696 100644 --- a/cmd/pipelines/get/cmd_test.go +++ b/cmd/pipelines/get/cmd_test.go @@ -61,7 +61,6 @@ func samplePipeline() pipelines.Pipeline { { Version: 1, Status: "READY", - PipelineName: "confluence_to_vdb", TaskNames: []string{"create_vector_database", "ingest_confluence_files"}, PythonVersion: "3.12", CreatedAt: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC), @@ -69,7 +68,6 @@ func samplePipeline() pipelines.Pipeline { { Version: 2, Status: "FAILED", - PipelineName: "confluence_to_vdb", PythonVersion: "3.12", ErrorDetail: "boom", CreatedAt: time.Date(2026, 4, 28, 12, 25, 11, 0, time.UTC), @@ -86,7 +84,7 @@ func TestPrintGetJSON(t *testing.T) { require.NoError(t, err) }) - assert.Contains(t, output, `"pipeline_id"`) + assert.Contains(t, output, `"id"`) assert.Contains(t, output, "confluence_to_vdb") assert.Contains(t, output, `"versions"`) } diff --git a/cmd/pipelines/graph/cmd_test.go b/cmd/pipelines/graph/cmd_test.go index e2448ee9c..7ce673195 100644 --- a/cmd/pipelines/graph/cmd_test.go +++ b/cmd/pipelines/graph/cmd_test.go @@ -74,7 +74,7 @@ func TestPrintGraphJSON(t *testing.T) { pipeline, ok := parsed["lattice"].(map[string]any) require.True(t, ok) assert.Equal(t, "wf", pipeline["name"]) - assert.Equal(t, "3.12", pipeline["python_version"]) + assert.Equal(t, "3.12", pipeline["pythonVersion"]) } func TestPrintGraphHuman(t *testing.T) { diff --git a/cmd/pipelines/list/cmd_test.go b/cmd/pipelines/list/cmd_test.go index d29066dc2..ef4c2bad6 100644 --- a/cmd/pipelines/list/cmd_test.go +++ b/cmd/pipelines/list/cmd_test.go @@ -51,9 +51,9 @@ func intPtr(v int) *int { return &v } -func sampleListResponse() pipelines.ListResponse { - return pipelines.ListResponse{ - Items: []pipelines.ListItem{ +func sampleListResponse() pipelines.DataPage[pipelines.ListItem] { + return pipelines.DataPage[pipelines.ListItem]{ + Data: []pipelines.ListItem{ { PipelineID: "683c2a1b4f8e1a2b3c4d5e6f", Name: "confluence_to_vdb", @@ -64,9 +64,8 @@ func sampleListResponse() pipelines.ListResponse { UpdatedAt: time.Date(2026, 4, 28, 12, 25, 11, 0, time.UTC), }, }, - Total: 1, - Offset: 0, - Limit: 50, + TotalCount: 1, + Count: 1, } } @@ -78,24 +77,20 @@ func TestPrintListJSON(t *testing.T) { require.NoError(t, err) }) - var parsed map[string]interface{} + var parsed []interface{} err := json.Unmarshal([]byte(output), &parsed) require.NoError(t, err) - assert.EqualValues(t, 1, parsed["total"]) + require.Len(t, parsed, 1) - items, ok := parsed["items"].([]interface{}) - require.True(t, ok) - require.Len(t, items, 1) - - item := items[0].(map[string]interface{}) + item := parsed[0].(map[string]interface{}) assert.Equal(t, "confluence_to_vdb", item["name"]) assert.Equal(t, "draft", item["mode"]) } func TestPrintListHuman_Empty(t *testing.T) { output := captureStdout(t, func() { - require.NoError(t, pipelines.RenderPipelines(pipelines.OutputFormatText, pipelines.ListResponse{Total: 0, Limit: 50})) + require.NoError(t, pipelines.RenderPipelines(pipelines.OutputFormatText, pipelines.DataPage[pipelines.ListItem]{})) }) assert.Contains(t, output, "No pipelines found.") @@ -125,7 +120,7 @@ func TestPrintListHuman_RendersHeaderAndRow(t *testing.T) { func TestPrintListHuman_NoLatestVersion(t *testing.T) { list := sampleListResponse() - list.Items[0].LatestVersion = nil + list.Data[0].LatestVersion = nil output := captureStdout(t, func() { require.NoError(t, pipelines.RenderPipelines(pipelines.OutputFormatText, list)) diff --git a/cmd/pipelines/lock/cmd_test.go b/cmd/pipelines/lock/cmd_test.go index 8f97c0600..40841ad82 100644 --- a/cmd/pipelines/lock/cmd_test.go +++ b/cmd/pipelines/lock/cmd_test.go @@ -67,7 +67,7 @@ func TestPrintLockJSON(t *testing.T) { var parsed map[string]any require.NoError(t, json.Unmarshal([]byte(output), &parsed)) - assert.Equal(t, "abc", parsed["pipeline_id"]) + assert.Equal(t, "abc", parsed["id"]) assert.Equal(t, "locked", parsed["mode"]) assert.EqualValues(t, 3, parsed["version"]) } diff --git a/cmd/pipelines/update/cmd_test.go b/cmd/pipelines/update/cmd_test.go index d18acac14..7714d1e54 100644 --- a/cmd/pipelines/update/cmd_test.go +++ b/cmd/pipelines/update/cmd_test.go @@ -71,7 +71,7 @@ func TestPrintUpdateJSON(t *testing.T) { err := json.Unmarshal([]byte(output), &parsed) require.NoError(t, err) - assert.Equal(t, resp.PipelineID, parsed["pipeline_id"]) + assert.Equal(t, resp.PipelineID, parsed["id"]) assert.EqualValues(t, 2, parsed["version"]) assert.Equal(t, "READY", parsed["status"]) } diff --git a/internal/pipelines/environment.go b/internal/pipelines/environment.go index dfdae2f46..c13f32019 100644 --- a/internal/pipelines/environment.go +++ b/internal/pipelines/environment.go @@ -52,31 +52,31 @@ type EnvironmentVersion struct { Version int `json:"version"` Packages []string `json:"packages"` Status EnvironmentStatus `json:"status"` - ErrorDetail *string `json:"error_detail,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ErrorDetail *string `json:"errorDetail,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } // Environment mirrors PipelineEnvironmentResponse (full detail). type Environment struct { - EnvironmentID string `json:"environment_id"` + EnvironmentID string `json:"id"` Name string `json:"name"` Description *string `json:"description,omitempty"` - LatestVersion int `json:"latest_version"` + LatestVersion int `json:"latestVersion"` Versions []EnvironmentVersion `json:"versions"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } // EnvironmentSummary mirrors PipelineEnvironmentSummaryResponse (list item). type EnvironmentSummary struct { - EnvironmentID string `json:"environment_id"` + EnvironmentID string `json:"id"` Name string `json:"name"` Description *string `json:"description,omitempty"` - LatestVersion int `json:"latest_version"` - LatestStatus EnvironmentStatus `json:"latest_status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + LatestVersion int `json:"latestVersion"` + LatestStatus EnvironmentStatus `json:"latestStatus"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } // EnvironmentCreateRequest mirrors PipelineEnvironmentCreateRequest. @@ -140,14 +140,14 @@ func ListEnvironments(offset, limit int) ([]EnvironmentSummary, error) { endpoint = endpoint + "?" + encoded } - var items []EnvironmentSummary + var page DataPage[EnvironmentSummary] - err = doJSON(http.MethodGet, endpoint, nil, "environments", &items) + err = doJSON(http.MethodGet, endpoint, nil, "environments", &page) if err != nil { return nil, err } - return items, nil + return page.Data, nil } // UpdateEnvironment PATCHes an environment with additional packages, diff --git a/internal/pipelines/environment_test.go b/internal/pipelines/environment_test.go index bb92a1142..ea180c9bf 100644 --- a/internal/pipelines/environment_test.go +++ b/internal/pipelines/environment_test.go @@ -46,12 +46,12 @@ func TestCreateEnvironment_PostsBody(t *testing.T) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(`{ - "environment_id":"env-1", + "id":"env-1", "name":"ml-base", "description":"for testing", - "latest_version":1, - "versions":[{"version":1,"packages":["numpy","pandas==2.0"],"status":"CREATING","created_at":"t","updated_at":"t"}], - "created_at":"t","updated_at":"t" + "latestVersion":1, + "versions":[{"version":1,"packages":["numpy","pandas==2.0"],"status":"CREATING","createdAt":"2026-04-29T10:00:00Z","updatedAt":"2026-04-29T10:00:00Z"}], + "createdAt":"2026-04-29T10:00:00Z","updatedAt":"2026-04-29T10:00:00Z" }`)) })) @@ -79,7 +79,7 @@ func TestCreateEnvironment_OmitsEmptyDescription(t *testing.T) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"environment_id":"env-1","name":"x","latest_version":1,"versions":[],"created_at":"t","updated_at":"t"}`)) + _, _ = w.Write([]byte(`{"id":"env-1","name":"x","latestVersion":1,"versions":[],"createdAt":"2026-04-29T10:00:00Z","updatedAt":"2026-04-29T10:00:00Z"}`)) })) defer srv.Close() @@ -100,9 +100,7 @@ func TestListEnvironments_AddsPaginationQuery(t *testing.T) { assert.Equal(t, "20", r.URL.Query().Get("limit")) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[ - {"environment_id":"env-1","name":"ml-base","latest_version":2,"latest_status":"READY","created_at":"t","updated_at":"t"} - ]`)) + _, _ = w.Write([]byte(`{"data":[{"id":"env-1","name":"ml-base","latestVersion":2,"latestStatus":"READY","createdAt":"2026-04-29T10:00:00Z","updatedAt":"2026-04-29T10:00:00Z"}],"totalCount":1,"count":1}`)) })) defer srv.Close() @@ -123,7 +121,7 @@ func TestListEnvironments_OmitsZeroPagination(t *testing.T) { assert.Empty(t, r.URL.RawQuery) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[]`)) + _, _ = w.Write([]byte(`{"data":[],"totalCount":0,"count":0}`)) })) defer srv.Close() @@ -149,12 +147,12 @@ func TestUpdateEnvironment_PatchesBody(t *testing.T) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ - "environment_id":"env-1","name":"ml-base","latest_version":2, + "id":"env-1","name":"ml-base","latestVersion":2, "versions":[ - {"version":2,"packages":["scikit-learn"],"status":"CREATING","created_at":"t","updated_at":"t"}, - {"version":1,"packages":["numpy"],"status":"READY","created_at":"t","updated_at":"t"} + {"version":2,"packages":["scikit-learn"],"status":"CREATING","createdAt":"2026-04-29T10:00:00Z","updatedAt":"2026-04-29T10:00:00Z"}, + {"version":1,"packages":["numpy"],"status":"READY","createdAt":"2026-04-29T10:00:00Z","updatedAt":"2026-04-29T10:00:00Z"} ], - "created_at":"t","updated_at":"t" + "createdAt":"2026-04-29T10:00:00Z","updatedAt":"2026-04-29T10:00:00Z" }`)) })) diff --git a/internal/pipelines/input.go b/internal/pipelines/input.go index 56af0e032..9b8d0274f 100644 --- a/internal/pipelines/input.go +++ b/internal/pipelines/input.go @@ -35,14 +35,14 @@ const ( // Input mirrors PipelineInputResponse from the pipelines-api. type Input struct { - InputID string `json:"input_id"` - PipelineID string `json:"pipeline_id"` - VersionID *int `json:"version_id,omitempty"` - IsDraft bool `json:"is_draft"` + InputID string `json:"id"` + PipelineID string `json:"pipelineId"` + VersionID *int `json:"versionId,omitempty"` + IsDraft bool `json:"isDraft"` Payload map[string]any `json:"payload"` State InputState `json:"state"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } // InputCreateRequest mirrors PipelineInputCreateRequest. @@ -95,15 +95,14 @@ func ListInputs(pipelineID string, scope Scope, version *int, offset, limit int) endpoint = endpoint + "?" + encoded } - // The pipelines-api returns a bare JSON array for list endpoints. - var inputs []Input + var page DataPage[Input] - err = doJSON(http.MethodGet, endpoint, nil, "inputs", &inputs) + err = doJSON(http.MethodGet, endpoint, nil, "inputs", &page) if err != nil { return nil, err } - return inputs, nil + return page.Data, nil } // GetInput fetches a single input by id within the given scope. diff --git a/internal/pipelines/input_test.go b/internal/pipelines/input_test.go index 73ea70604..9350fcf58 100644 --- a/internal/pipelines/input_test.go +++ b/internal/pipelines/input_test.go @@ -53,7 +53,7 @@ func TestCreateInput_Draft(t *testing.T) { assert.Equal(t, "v", body.Payload["k"]) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"input_id":"in-1","pipeline_id":"p-1","is_draft":true,"state":"VALID","payload":{"k":"v"}}`)) + _, _ = w.Write([]byte(`{"id":"in-1","pipelineId":"p-1","isDraft":true,"state":"VALID","payload":{"k":"v"}}`)) })) defer srv.Close() @@ -73,7 +73,7 @@ func TestCreateInput_LockedURLShape(t *testing.T) { assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/inputs", r.URL.Path) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"input_id":"in-1","pipeline_id":"p-1","version_id":2,"is_draft":false,"state":"VALID","payload":{}}`)) + _, _ = w.Write([]byte(`{"id":"in-1","pipelineId":"p-1","versionId":2,"isDraft":false,"state":"VALID","payload":{}}`)) })) defer srv.Close() @@ -95,7 +95,7 @@ func TestListInputs_AddsPaginationQuery(t *testing.T) { assert.Equal(t, "5", r.URL.Query().Get("limit")) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[{"input_id":"in-1","pipeline_id":"p-1","is_draft":true,"state":"VALID","payload":{}}]`)) + _, _ = w.Write([]byte(`{"data":[{"id":"in-1","pipelineId":"p-1","isDraft":true,"state":"VALID","payload":{}}],"totalCount":1,"count":1}`)) })) defer srv.Close() @@ -115,7 +115,7 @@ func TestListInputs_OmitsZeroPagination(t *testing.T) { assert.Empty(t, r.URL.RawQuery) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[]`)) + _, _ = w.Write([]byte(`{"data":[],"totalCount":0,"count":0}`)) })) defer srv.Close() @@ -134,7 +134,7 @@ func TestGetInput_TargetsCorrectURL(t *testing.T) { assert.Equal(t, "/api/v2/pipelines/p-1/inputs/in-1", r.URL.Path) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"input_id":"in-1","pipeline_id":"p-1","is_draft":true,"state":"VALID","payload":{}}`)) + _, _ = w.Write([]byte(`{"id":"in-1","pipelineId":"p-1","isDraft":true,"state":"VALID","payload":{}}`)) })) defer srv.Close() @@ -159,7 +159,7 @@ func TestUpdateInput_PatchesDraft(t *testing.T) { assert.Equal(t, "new", body.Payload["k"]) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"input_id":"in-1","pipeline_id":"p-1","is_draft":true,"state":"VALID","payload":{"k":"new"}}`)) + _, _ = w.Write([]byte(`{"id":"in-1","pipelineId":"p-1","isDraft":true,"state":"VALID","payload":{"k":"new"}}`)) })) defer srv.Close() diff --git a/internal/pipelines/pipeline.go b/internal/pipelines/pipeline.go index dbfc19e08..b18c7dee6 100644 --- a/internal/pipelines/pipeline.go +++ b/internal/pipelines/pipeline.go @@ -42,68 +42,53 @@ const ( ) // PipelineVersion mirrors PipelineVersionResponse from the pipelines-api. -// -// JSON tags `lattice_name` and `electron_names` track the current API wire -// format. The DataRobot terminology has moved to `pipeline_name` / `task_names` -// — when the API updates the wire format, flip the json tags here. Until then -// the Go-level identifiers (PipelineName, TaskNames) use the new names so the -// rest of the codebase reads cleanly. type PipelineVersion struct { Version int `json:"version"` Status string `json:"status"` - PipelineName string `json:"lattice_name"` - TaskNames []string `json:"electron_names,omitempty"` - PythonVersion string `json:"python_version"` - ResourceBundle map[string]any `json:"resource_bundle,omitempty"` - ErrorDetail string `json:"error_detail,omitempty"` - CreatedAt time.Time `json:"created_at"` + TaskNames []string `json:"taskNames,omitempty"` + PythonVersion string `json:"pythonVersion"` + ResourceBundle map[string]any `json:"resourceBundle,omitempty"` + ErrorDetail string `json:"errorDetail,omitempty"` + CreatedAt time.Time `json:"createdAt"` } // Pipeline mirrors PipelineDetailResponse from the pipelines-api. type Pipeline struct { - PipelineID string `json:"pipeline_id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Mode string `json:"mode"` - IsActive bool `json:"is_active"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Versions []PipelineVersion `json:"versions"` + PipelineID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Mode string `json:"mode"` + IsActive bool `json:"isActive"` + TaskNames []string `json:"taskNames,omitempty"` + PythonVersion string `json:"pythonVersion,omitempty"` + ResourceBundle map[string]any `json:"resourceBundle,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Versions []PipelineVersion `json:"versions"` } // CreateResponse mirrors PipelineCreateResponse from the pipelines-api. // It is also returned by PATCH /pipelines/{id}. -// -// See PipelineVersion for the rationale on the legacy `electron_names` -// JSON tag. type CreateResponse struct { - PipelineID string `json:"pipeline_id"` + PipelineID string `json:"id"` Name string `json:"name"` Version int `json:"version"` Status string `json:"status"` Mode string `json:"mode"` - TaskNames []string `json:"electron_names,omitempty"` - CreatedAt time.Time `json:"created_at"` + TaskNames []string `json:"taskNames,omitempty"` + CreatedAt time.Time `json:"createdAt"` } // ListItem mirrors PipelineListItem from the pipelines-api. type ListItem struct { - PipelineID string `json:"pipeline_id"` + PipelineID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` Mode string `json:"mode"` - IsActive bool `json:"is_active"` - LatestVersion *int `json:"latest_version,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// ListResponse mirrors PipelineListResponse from the pipelines-api. -type ListResponse struct { - Items []ListItem `json:"items"` - Total int `json:"total"` - Offset int `json:"offset"` - Limit int `json:"limit"` + IsActive bool `json:"isActive"` + LatestVersion *int `json:"latestVersion,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } // CreatePipeline uploads a Python file to POST /api/v2/pipelines. @@ -133,7 +118,7 @@ func CreatePipeline(filePath, description, mode string) (*CreateResponse, error) } // ListPipelines fetches a paginated list of pipelines from GET /api/v2/pipelines. -func ListPipelines(mode string, offset, limit int) (*ListResponse, error) { +func ListPipelines(mode string, offset, limit int) (*DataPage[ListItem], error) { endpoint, err := config.GetEndpointURL("/api/v2/pipelines") if err != nil { return nil, err @@ -156,14 +141,14 @@ func ListPipelines(mode string, offset, limit int) (*ListResponse, error) { endpoint = endpoint + "?" + encoded } - var list ListResponse + var page DataPage[ListItem] - err = drapi.GetJSON(endpoint, "pipelines", &list) + err = drapi.GetJSON(endpoint, "pipelines", &page) if err != nil { return nil, err } - return &list, nil + return &page, nil } // GetPipeline fetches a single pipeline from GET /api/v2/pipelines/{pipeline_id}. diff --git a/internal/pipelines/pipeline_lifecycle_test.go b/internal/pipelines/pipeline_lifecycle_test.go index b902ea89e..a824b82c1 100644 --- a/internal/pipelines/pipeline_lifecycle_test.go +++ b/internal/pipelines/pipeline_lifecycle_test.go @@ -69,13 +69,13 @@ func TestLockPipeline_PromotesAndDecodes(t *testing.T) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ - "pipeline_id": "p-1", + "id": "p-1", "name": "wf", "version": 3, "status": "READY", "mode": "locked", - "electron_names": ["e1"], - "created_at": "2026-04-29T10:00:00Z" + "taskNames": ["e1"], + "createdAt": "2026-04-29T10:00:00Z" }`)) })) diff --git a/internal/pipelines/pipeline_output.go b/internal/pipelines/pipeline_output.go index e76477c04..8c59dac1d 100644 --- a/internal/pipelines/pipeline_output.go +++ b/internal/pipelines/pipeline_output.go @@ -47,12 +47,12 @@ func RenderPipeline(format OutputFormat, p Pipeline) error { } // RenderPipelines routes a pipeline list to JSON or human output. -func RenderPipelines(format OutputFormat, list ListResponse) error { +func RenderPipelines(format OutputFormat, page DataPage[ListItem]) error { if format == OutputFormatJSON { - return printPipelinesJSON(list) + return printPipelinesJSON(page) } - printPipelinesHuman(list) + printPipelinesHuman(page) return nil } @@ -79,8 +79,8 @@ func printPipelineJSON(p Pipeline) error { return nil } -func printPipelinesJSON(list ListResponse) error { - data, err := json.MarshalIndent(list, "", " ") +func printPipelinesJSON(page DataPage[ListItem]) error { + data, err := json.MarshalIndent(page.Data, "", " ") if err != nil { return err } @@ -101,14 +101,14 @@ func printCreateResponseJSON(result CreateResponse) error { return nil } -func printPipelinesHuman(list ListResponse) { - if len(list.Items) == 0 { +func printPipelinesHuman(page DataPage[ListItem]) { + if len(page.Data) == 0 { fmt.Println(tui.DimStyle.Render("No pipelines found.")) return } - fmt.Println(tui.BaseTextStyle.Render(fmt.Sprintf("Showing %d of %d (offset=%d limit=%d)", len(list.Items), list.Total, list.Offset, list.Limit))) + fmt.Println(tui.BaseTextStyle.Render(fmt.Sprintf("Showing %d of %d", len(page.Data), page.TotalCount))) fmt.Println() cellStyle := tui.BaseTextStyle.Padding(0, 1) @@ -135,7 +135,7 @@ func printPipelinesHuman(list ListResponse) { }). Headers(headers...) - for _, item := range list.Items { + for _, item := range page.Data { latest := emptyValuePlaceholder if item.LatestVersion != nil { latest = "v" + strconv.Itoa(*item.LatestVersion) diff --git a/internal/pipelines/run.go b/internal/pipelines/run.go index ea584fa33..975fd8e10 100644 --- a/internal/pipelines/run.go +++ b/internal/pipelines/run.go @@ -40,31 +40,26 @@ const ( RunStatusErrored = "ERRORED" ) -// Run mirrors PipelineDispatchResponse from the pipelines-api. JSON tags -// (`dispatch_id`, `covalent_dispatch_id`) track the current API wire -// format, which has not been renamed to "run" yet. +// Run mirrors PipelineDispatchResponse from the pipelines-api. type Run struct { - RunID string `json:"dispatch_id"` - PipelineID string `json:"pipeline_id"` - VersionID *int `json:"version_id,omitempty"` - InputID string `json:"input_id"` - CovalentDispatchID string `json:"covalent_dispatch_id,omitempty"` - TriggeredBy string `json:"triggered_by"` + RunID string `json:"id"` + PipelineID string `json:"pipelineId"` + VersionID *int `json:"versionId,omitempty"` + InputID string `json:"inputId"` + CovalentDispatchID string `json:"covalentDispatchId,omitempty"` + TriggeredBy string `json:"triggeredBy"` Status string `json:"status"` - ErrorDetail string `json:"error_detail,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ErrorDetail string `json:"errorDetail,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } // RunStatus mirrors PipelineDispatchStatusResponse — the lightweight // polling-friendly shape returned by GET .../status. -// -// See Run for the rationale on the legacy `dispatch_id` / -// `covalent_dispatch_id` JSON tags. type RunStatus struct { - RunID string `json:"dispatch_id"` + RunID string `json:"id"` Status string `json:"status"` - CovalentDispatchID string `json:"covalent_dispatch_id,omitempty"` + CovalentDispatchID string `json:"covalentDispatchId,omitempty"` } // RunCreateRequest mirrors PipelineDispatchCreateRequest. @@ -112,14 +107,14 @@ func ListRuns(pipelineID string, scope Scope, version *int, offset, limit int) ( endpoint = endpoint + "?" + encoded } - var runs []Run + var page DataPage[Run] - err = doJSON(http.MethodGet, endpoint, nil, "runs", &runs) + err = doJSON(http.MethodGet, endpoint, nil, "runs", &page) if err != nil { return nil, err } - return runs, nil + return page.Data, nil } // GetRun fetches a single run by id within the given scope. diff --git a/internal/pipelines/run_test.go b/internal/pipelines/run_test.go index 3ecb7e0af..9ed9065f7 100644 --- a/internal/pipelines/run_test.go +++ b/internal/pipelines/run_test.go @@ -37,7 +37,7 @@ func TestCreateRun_DraftURLAndBody(t *testing.T) { assert.Equal(t, "in-1", body.InputID) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"dispatch_id":"d-1","pipeline_id":"p-1","input_id":"in-1","triggered_by":"u","status":"PENDING"}`)) + _, _ = w.Write([]byte(`{"id":"d-1","pipelineId":"p-1","inputId":"in-1","triggeredBy":"u","status":"PENDING"}`)) })) defer srv.Close() @@ -57,7 +57,7 @@ func TestCreateRun_LockedURL(t *testing.T) { assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/dispatches", r.URL.Path) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"dispatch_id":"d-1","pipeline_id":"p-1","version_id":2,"input_id":"in-1","triggered_by":"u","status":"PENDING"}`)) + _, _ = w.Write([]byte(`{"id":"d-1","pipelineId":"p-1","versionId":2,"inputId":"in-1","triggeredBy":"u","status":"PENDING"}`)) })) defer srv.Close() @@ -80,7 +80,7 @@ func TestListRuns_QueryAndDecode(t *testing.T) { assert.Equal(t, "5", r.URL.Query().Get("limit")) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[{"dispatch_id":"d-1","pipeline_id":"p-1","input_id":"in-1","triggered_by":"u","status":"RUNNING"}]`)) + _, _ = w.Write([]byte(`{"data":[{"id":"d-1","pipelineId":"p-1","inputId":"in-1","triggeredBy":"u","status":"RUNNING"}],"totalCount":1,"count":1}`)) })) defer srv.Close() @@ -100,7 +100,7 @@ func TestGetRun_TargetsCorrectURL(t *testing.T) { assert.Equal(t, "/api/v2/pipelines/p-1/dispatches/d-1", r.URL.Path) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"dispatch_id":"d-1","pipeline_id":"p-1","input_id":"in-1","triggered_by":"u","status":"COMPLETED"}`)) + _, _ = w.Write([]byte(`{"id":"d-1","pipelineId":"p-1","inputId":"in-1","triggeredBy":"u","status":"COMPLETED"}`)) })) defer srv.Close() @@ -119,7 +119,7 @@ func TestGetRunStatus_StatusEndpointURL(t *testing.T) { assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/dispatches/d-1/status", r.URL.Path) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"dispatch_id":"d-1","status":"RUNNING","covalent_dispatch_id":"cov-x"}`)) + _, _ = w.Write([]byte(`{"id":"d-1","status":"RUNNING","covalentDispatchId":"cov-x"}`)) })) defer srv.Close() diff --git a/internal/pipelines/schedule.go b/internal/pipelines/schedule.go index aa18d335b..7ad0cf25c 100644 --- a/internal/pipelines/schedule.go +++ b/internal/pipelines/schedule.go @@ -37,14 +37,14 @@ const ( // Schedule mirrors PipelineScheduleResponse. type Schedule struct { - ScheduleID string `json:"schedule_id"` - PipelineID string `json:"pipeline_id"` + ScheduleID string `json:"id"` + PipelineID string `json:"pipelineId"` Version int `json:"version"` - CronExpression string `json:"cron_expression"` + CronExpression string `json:"cronExpression"` Timezone string `json:"timezone"` Status ScheduleStatus `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } // ScheduleCreateRequest mirrors PipelineScheduleCreateRequest. @@ -98,14 +98,14 @@ func ListSchedules(pipelineID string, version, offset, limit int) ([]Schedule, e endpoint = endpoint + "?" + encoded } - var schedules []Schedule + var page DataPage[Schedule] - err = doJSON(http.MethodGet, endpoint, nil, "schedules", &schedules) + err = doJSON(http.MethodGet, endpoint, nil, "schedules", &page) if err != nil { return nil, err } - return schedules, nil + return page.Data, nil } // GetSchedule fetches a single schedule by id. diff --git a/internal/pipelines/schedule_test.go b/internal/pipelines/schedule_test.go index 3270626f1..330c271e8 100644 --- a/internal/pipelines/schedule_test.go +++ b/internal/pipelines/schedule_test.go @@ -39,7 +39,7 @@ func TestCreateSchedule_LockedOnlyURLAndBody(t *testing.T) { assert.Equal(t, "America/Los_Angeles", body.Timezone) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"schedule_id":"s-1","pipeline_id":"p-1","version":2,"cron_expression":"0 * * * *","timezone":"America/Los_Angeles","status":"ACTIVE"}`)) + _, _ = w.Write([]byte(`{"id":"s-1","pipelineId":"p-1","version":2,"cronExpression":"0 * * * *","timezone":"America/Los_Angeles","status":"ACTIVE"}`)) })) defer srv.Close() @@ -64,7 +64,7 @@ func TestListSchedules_QueryAndDecode(t *testing.T) { assert.Equal(t, "5", r.URL.Query().Get("limit")) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[{"schedule_id":"s-1","pipeline_id":"p-1","version":2,"cron_expression":"0 0 * * *","timezone":"UTC","status":"ACTIVE"}]`)) + _, _ = w.Write([]byte(`{"data":[{"id":"s-1","pipelineId":"p-1","version":2,"cronExpression":"0 0 * * *","timezone":"UTC","status":"ACTIVE"}],"totalCount":1,"count":1}`)) })) defer srv.Close() @@ -84,7 +84,7 @@ func TestGetSchedule_TargetsCorrectURL(t *testing.T) { assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/schedules/s-1", r.URL.Path) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"schedule_id":"s-1","pipeline_id":"p-1","version":2,"cron_expression":"0 * * * *","timezone":"UTC","status":"PAUSED"}`)) + _, _ = w.Write([]byte(`{"id":"s-1","pipelineId":"p-1","version":2,"cronExpression":"0 * * * *","timezone":"UTC","status":"PAUSED"}`)) })) defer srv.Close() @@ -112,7 +112,7 @@ func TestUpdateSchedule_OmitsUnsuppliedFields(t *testing.T) { assert.False(t, hasTZ, "expected timezone to be omitted") w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"schedule_id":"s-1","pipeline_id":"p-1","version":2,"cron_expression":"*/15 * * * *","timezone":"UTC","status":"ACTIVE"}`)) + _, _ = w.Write([]byte(`{"id":"s-1","pipelineId":"p-1","version":2,"cronExpression":"*/15 * * * *","timezone":"UTC","status":"ACTIVE"}`)) })) defer srv.Close() diff --git a/internal/pipelines/transport.go b/internal/pipelines/transport.go index daa19dd52..bfbd269a3 100644 --- a/internal/pipelines/transport.go +++ b/internal/pipelines/transport.go @@ -108,6 +108,16 @@ func buildJSONRequest(method, endpoint string, body any) (*http.Request, error) return req, nil } +// DataPage is the pagination envelope returned by all pipelines-api list endpoints +// (action 056 — DataPage[T] convention). +type DataPage[T any] struct { + Data []T `json:"data"` + TotalCount int `json:"totalCount"` + Count int `json:"count"` + Next *string `json:"next"` + Previous *string `json:"previous"` +} + // doDelete sends a DELETE and treats any 2xx response as success. The // response body is drained but ignored. func doDelete(endpoint, info string) error { diff --git a/internal/pipelines/version.go b/internal/pipelines/version.go index 50b848382..b634f2e2a 100644 --- a/internal/pipelines/version.go +++ b/internal/pipelines/version.go @@ -41,8 +41,8 @@ type GraphNode struct { Type string `json:"type"` Name string `json:"name"` Source *string `json:"source,omitempty"` - ResourceBundle any `json:"resource_bundle,omitempty"` - TaskGroupID any `json:"task_group_id,omitempty"` + ResourceBundle any `json:"resourceBundle,omitempty"` + TaskGroupID any `json:"taskGroupId,omitempty"` } // GraphEdge mirrors PipelineGraphEdge. Source and Target are integer node IDs. @@ -50,17 +50,16 @@ type GraphNode struct { type GraphEdge struct { Source int `json:"source"` Target int `json:"target"` - EdgeName string `json:"edge_name,omitempty"` - ParamType string `json:"param_type,omitempty"` - ArgIndex int `json:"arg_index,omitempty"` + EdgeName string `json:"edgeName,omitempty"` + ParamType string `json:"paramType,omitempty"` + ArgIndex int `json:"argIndex,omitempty"` } // GraphPipeline mirrors PipelineGraphLattice — the pipeline header in the -// graph payload. JSON tag remains `lattice` while the API wire format is -// unchanged. +// graph payload. The wire key is still "lattice". type GraphPipeline struct { Name string `json:"name"` - PythonVersion string `json:"python_version"` + PythonVersion string `json:"pythonVersion"` } // Graph mirrors the JSON returned by GET /pipelines/{id}/graph and @@ -91,14 +90,14 @@ func ListVersions(pipelineID string, offset, limit int) ([]PipelineVersion, erro endpoint = endpoint + "?" + encoded } - var versions []PipelineVersion + var page DataPage[PipelineVersion] - err = doJSON(http.MethodGet, endpoint, nil, "pipeline versions", &versions) + err = doJSON(http.MethodGet, endpoint, nil, "pipeline versions", &page) if err != nil { return nil, err } - return versions, nil + return page.Data, nil } // GetVersion fetches a single version of a pipeline. diff --git a/internal/pipelines/version_output.go b/internal/pipelines/version_output.go index e2bfe51d5..3da792553 100644 --- a/internal/pipelines/version_output.go +++ b/internal/pipelines/version_output.go @@ -35,7 +35,6 @@ import ( type versionJSON struct { Version int `json:"version"` Status string `json:"status"` - PipelineName string `json:"pipeline_name"` TaskNames []string `json:"task_names,omitempty"` PythonVersion string `json:"python_version,omitempty"` ResourceBundle map[string]any `json:"resource_bundle,omitempty"` @@ -47,7 +46,6 @@ func toVersionJSON(v PipelineVersion) versionJSON { return versionJSON{ Version: v.Version, Status: v.Status, - PipelineName: v.PipelineName, TaskNames: v.TaskNames, PythonVersion: v.PythonVersion, ResourceBundle: v.ResourceBundle, @@ -105,7 +103,6 @@ func PrintVersionHuman(v PipelineVersion) { w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintf(w, "Version:\tv%s\n", strconv.Itoa(v.Version)) - fmt.Fprintf(w, "Pipeline:\t%s\n", v.PipelineName) fmt.Fprintf(w, "Status:\t%s\n", v.Status) fmt.Fprintf(w, "Python Version:\t%s\n", python) fmt.Fprintf(w, "Tasks:\t%s\n", tasks) diff --git a/internal/pipelines/version_test.go b/internal/pipelines/version_test.go index 26633d876..6df26afac 100644 --- a/internal/pipelines/version_test.go +++ b/internal/pipelines/version_test.go @@ -31,7 +31,7 @@ func TestListVersions_TargetsCorrectURL(t *testing.T) { assert.Equal(t, "10", r.URL.Query().Get("offset")) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[{"version":1,"status":"READY","lattice_name":"wf","python_version":"3.12","created_at":"2026-04-29T10:00:00Z"}]`)) + _, _ = w.Write([]byte(`{"data":[{"version":1,"status":"READY","pythonVersion":"3.12","createdAt":"2026-04-29T10:00:00Z"}],"totalCount":1,"count":1}`)) })) defer srv.Close() @@ -52,7 +52,7 @@ func TestGetVersion_TargetsCorrectURL(t *testing.T) { assert.Equal(t, "/api/v2/pipelines/p-1/versions/2", r.URL.Path) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"version":2,"status":"READY","lattice_name":"wf","python_version":"3.12","created_at":"2026-04-29T10:00:00Z"}`)) + _, _ = w.Write([]byte(`{"version":2,"status":"READY","pythonVersion":"3.12","createdAt":"2026-04-29T10:00:00Z"}`)) })) defer srv.Close() @@ -72,7 +72,7 @@ func TestGetGraph_DraftURL(t *testing.T) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ - "lattice": {"name":"wf","python_version":"3.12"}, + "lattice": {"name":"wf","pythonVersion":"3.12"}, "nodes": [{"id":0,"type":"function","name":"wf"}], "edges": [] }`)) @@ -96,7 +96,7 @@ func TestGetGraph_LockedURL(t *testing.T) { assert.Equal(t, "/api/v2/pipelines/p-1/versions/3/graph", r.URL.Path) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"lattice":{"name":"wf","python_version":"3.12"},"nodes":[],"edges":[]}`)) + _, _ = w.Write([]byte(`{"lattice":{"name":"wf","pythonVersion":"3.12"},"nodes":[],"edges":[]}`)) })) defer srv.Close() From 5bfeebe84b531dc3499defb34c6bbaf766601cbd Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Fri, 22 May 2026 22:26:34 -0400 Subject: [PATCH 20/25] [CMPT-5391] rename dr pipelines to dr pipeline (singular) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames cmd/pipelines/ → cmd/pipeline/, updates the cobra Use field, feature gate key (DATAROBOT_CLI_FEATURE_PIPELINE), and all example strings. Adds "pipelines" as a backward-compat alias per naming conventions. Docs and demo.sh updated accordingly. Co-Authored-By: Claude Sonnet 4.6 --- Taskfile.yaml | 2 +- cmd/{pipelines => pipeline}/cmd.go | 31 +-- cmd/{pipelines => pipeline}/cmd_test.go | 6 +- cmd/{pipelines => pipeline}/create/cmd.go | 8 +- .../create/cmd_test.go | 0 cmd/{pipelines => pipeline}/del/cmd.go | 4 +- cmd/{pipelines => pipeline}/del/cmd_test.go | 0 .../environment/cmd.go | 12 +- .../environment/cmd_test.go | 0 .../environment/create/cmd.go | 4 +- .../environment/create/cmd_test.go | 0 .../environment/del/cmd.go | 6 +- .../environment/del/cmd_test.go | 0 .../environment/list/cmd.go | 4 +- .../environment/list/cmd_test.go | 0 .../environment/update/cmd.go | 4 +- .../environment/update/cmd_test.go | 0 .../environment/version/cmd.go | 4 +- .../environment/version/cmd_test.go | 0 .../environment/version/del/cmd.go | 4 +- .../environment/version/del/cmd_test.go | 0 cmd/{pipelines => pipeline}/get/cmd.go | 4 +- cmd/{pipelines => pipeline}/get/cmd_test.go | 0 cmd/{pipelines => pipeline}/graph/cmd.go | 6 +- cmd/{pipelines => pipeline}/graph/cmd_test.go | 0 cmd/{pipelines => pipeline}/input/cmd.go | 12 +- cmd/{pipelines => pipeline}/input/cmd_test.go | 0 .../input/create/cmd.go | 8 +- .../input/create/cmd_test.go | 0 cmd/{pipelines => pipeline}/input/del/cmd.go | 8 +- .../input/del/cmd_test.go | 0 cmd/{pipelines => pipeline}/input/get/cmd.go | 6 +- .../input/get/cmd_test.go | 0 cmd/{pipelines => pipeline}/input/list/cmd.go | 8 +- .../input/list/cmd_test.go | 0 .../input/update/cmd.go | 4 +- .../input/update/cmd_test.go | 0 cmd/{pipelines => pipeline}/list/cmd.go | 6 +- cmd/{pipelines => pipeline}/list/cmd_test.go | 0 cmd/{pipelines => pipeline}/lock/cmd.go | 4 +- cmd/{pipelines => pipeline}/lock/cmd_test.go | 0 cmd/{pipelines => pipeline}/run/cancel/cmd.go | 6 +- .../run/cancel/cmd_test.go | 0 cmd/{pipelines => pipeline}/run/cmd.go | 12 +- cmd/{pipelines => pipeline}/run/cmd_test.go | 0 cmd/{pipelines => pipeline}/run/create/cmd.go | 10 +- .../run/create/cmd_test.go | 0 cmd/{pipelines => pipeline}/run/get/cmd.go | 6 +- .../run/get/cmd_test.go | 0 cmd/{pipelines => pipeline}/run/list/cmd.go | 6 +- .../run/list/cmd_test.go | 0 cmd/{pipelines => pipeline}/run/status/cmd.go | 6 +- .../run/status/cmd_test.go | 0 cmd/{pipelines => pipeline}/schedule/cmd.go | 12 +- .../schedule/cmd_test.go | 0 .../schedule/create/cmd.go | 4 +- .../schedule/create/cmd_test.go | 0 .../schedule/del/cmd.go | 4 +- .../schedule/del/cmd_test.go | 0 .../schedule/get/cmd.go | 4 +- .../schedule/get/cmd_test.go | 0 .../schedule/list/cmd.go | 4 +- .../schedule/list/cmd_test.go | 0 .../schedule/update/cmd.go | 4 +- .../schedule/update/cmd_test.go | 0 .../scopeflag/scopeflag.go | 0 .../scopeflag/scopeflag_test.go | 0 cmd/{pipelines => pipeline}/update/cmd.go | 6 +- .../update/cmd_test.go | 0 cmd/{pipelines => pipeline}/version/cmd.go | 8 +- .../version/cmd_test.go | 0 .../version/get/cmd.go | 4 +- .../version/get/cmd_test.go | 0 .../version/list/cmd.go | 4 +- .../version/list/cmd_test.go | 0 cmd/root.go | 4 +- docs/commands/README.md | 4 +- docs/commands/pipeline-reference.md | 210 ++++++++++++++++++ docs/commands/{pipelines.md => pipeline.md} | 188 ++++++++-------- docs/commands/pipelines-reference.md | 210 ------------------ docs/mkdocs.yml | 4 +- 81 files changed, 438 insertions(+), 437 deletions(-) rename cmd/{pipelines => pipeline}/cmd.go (65%) rename cmd/{pipelines => pipeline}/cmd_test.go (93%) rename cmd/{pipelines => pipeline}/create/cmd.go (92%) rename cmd/{pipelines => pipeline}/create/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/del/cmd.go (94%) rename cmd/{pipelines => pipeline}/del/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/environment/cmd.go (79%) rename cmd/{pipelines => pipeline}/environment/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/environment/create/cmd.go (90%) rename cmd/{pipelines => pipeline}/environment/create/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/environment/del/cmd.go (89%) rename cmd/{pipelines => pipeline}/environment/del/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/environment/list/cmd.go (94%) rename cmd/{pipelines => pipeline}/environment/list/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/environment/update/cmd.go (91%) rename cmd/{pipelines => pipeline}/environment/update/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/environment/version/cmd.go (87%) rename cmd/{pipelines => pipeline}/environment/version/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/environment/version/del/cmd.go (93%) rename cmd/{pipelines => pipeline}/environment/version/del/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/get/cmd.go (95%) rename cmd/{pipelines => pipeline}/get/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/graph/cmd.go (95%) rename cmd/{pipelines => pipeline}/graph/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/input/cmd.go (78%) rename cmd/{pipelines => pipeline}/input/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/input/create/cmd.go (89%) rename cmd/{pipelines => pipeline}/input/create/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/input/del/cmd.go (87%) rename cmd/{pipelines => pipeline}/input/del/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/input/get/cmd.go (91%) rename cmd/{pipelines => pipeline}/input/get/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/input/list/cmd.go (89%) rename cmd/{pipelines => pipeline}/input/list/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/input/update/cmd.go (91%) rename cmd/{pipelines => pipeline}/input/update/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/list/cmd.go (93%) rename cmd/{pipelines => pipeline}/list/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/lock/cmd.go (92%) rename cmd/{pipelines => pipeline}/lock/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/run/cancel/cmd.go (90%) rename cmd/{pipelines => pipeline}/run/cancel/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/run/cmd.go (78%) rename cmd/{pipelines => pipeline}/run/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/run/create/cmd.go (83%) rename cmd/{pipelines => pipeline}/run/create/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/run/get/cmd.go (91%) rename cmd/{pipelines => pipeline}/run/get/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/run/list/cmd.go (91%) rename cmd/{pipelines => pipeline}/run/list/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/run/status/cmd.go (91%) rename cmd/{pipelines => pipeline}/run/status/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/schedule/cmd.go (75%) rename cmd/{pipelines => pipeline}/schedule/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/schedule/create/cmd.go (91%) rename cmd/{pipelines => pipeline}/schedule/create/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/schedule/del/cmd.go (92%) rename cmd/{pipelines => pipeline}/schedule/del/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/schedule/get/cmd.go (93%) rename cmd/{pipelines => pipeline}/schedule/get/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/schedule/list/cmd.go (93%) rename cmd/{pipelines => pipeline}/schedule/list/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/schedule/update/cmd.go (93%) rename cmd/{pipelines => pipeline}/schedule/update/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/scopeflag/scopeflag.go (100%) rename cmd/{pipelines => pipeline}/scopeflag/scopeflag_test.go (100%) rename cmd/{pipelines => pipeline}/update/cmd.go (92%) rename cmd/{pipelines => pipeline}/update/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/version/cmd.go (79%) rename cmd/{pipelines => pipeline}/version/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/version/get/cmd.go (95%) rename cmd/{pipelines => pipeline}/version/get/cmd_test.go (100%) rename cmd/{pipelines => pipeline}/version/list/cmd.go (92%) rename cmd/{pipelines => pipeline}/version/list/cmd_test.go (100%) create mode 100644 docs/commands/pipeline-reference.md rename docs/commands/{pipelines.md => pipeline.md} (70%) delete mode 100644 docs/commands/pipelines-reference.md diff --git a/Taskfile.yaml b/Taskfile.yaml index a10c4423f..460ac4c53 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -162,7 +162,7 @@ tasks: DEMO_DIR: '{{default "/Users/sunnypal.sharma/Desktop/tests/pipelines-api" .DEMO_DIR}}' DELAY_FLAG: '{{if .DELAY}}--delay={{.DELAY}}{{end}}' env: - DATAROBOT_CLI_FEATURE_PIPELINES: "true" + DATAROBOT_CLI_FEATURE_PIPELINE: "true" DATAROBOT_CLI_ENDPOINT: "http://localhost:8100/api/v2" DATAROBOT_CLI_TOKEN: "local" DATAROBOT_CLI_SKIP_AUTH: "true" diff --git a/cmd/pipelines/cmd.go b/cmd/pipeline/cmd.go similarity index 65% rename from cmd/pipelines/cmd.go rename to cmd/pipeline/cmd.go index d7246ba83..06e9210e0 100644 --- a/cmd/pipelines/cmd.go +++ b/cmd/pipeline/cmd.go @@ -12,28 +12,29 @@ // See the License for the specific language governing permissions and // limitations under the License. -package pipelines +package pipeline import ( - "github.com/datarobot/cli/cmd/pipelines/create" - "github.com/datarobot/cli/cmd/pipelines/del" - "github.com/datarobot/cli/cmd/pipelines/environment" - "github.com/datarobot/cli/cmd/pipelines/get" - "github.com/datarobot/cli/cmd/pipelines/graph" - "github.com/datarobot/cli/cmd/pipelines/input" - "github.com/datarobot/cli/cmd/pipelines/list" - "github.com/datarobot/cli/cmd/pipelines/lock" - "github.com/datarobot/cli/cmd/pipelines/run" - "github.com/datarobot/cli/cmd/pipelines/schedule" - "github.com/datarobot/cli/cmd/pipelines/update" - "github.com/datarobot/cli/cmd/pipelines/version" + "github.com/datarobot/cli/cmd/pipeline/create" + "github.com/datarobot/cli/cmd/pipeline/del" + "github.com/datarobot/cli/cmd/pipeline/environment" + "github.com/datarobot/cli/cmd/pipeline/get" + "github.com/datarobot/cli/cmd/pipeline/graph" + "github.com/datarobot/cli/cmd/pipeline/input" + "github.com/datarobot/cli/cmd/pipeline/list" + "github.com/datarobot/cli/cmd/pipeline/lock" + "github.com/datarobot/cli/cmd/pipeline/run" + "github.com/datarobot/cli/cmd/pipeline/schedule" + "github.com/datarobot/cli/cmd/pipeline/update" + "github.com/datarobot/cli/cmd/pipeline/version" "github.com/datarobot/cli/internal/features" "github.com/spf13/cobra" ) func Cmd() *cobra.Command { cmd := &cobra.Command{ - Use: "pipelines", + Use: "pipeline", + Aliases: []string{"pipelines"}, GroupID: "core", Short: "Pipelines API management commands", Long: `Manage AI/ML pipelines orchestrated by Covalent. @@ -43,7 +44,7 @@ DataRobot pipelines service. Sub-commands are also available for managing input payloads, runs, and recurring schedules.`, } - features.SetGate(cmd, "pipelines") + features.SetGate(cmd, "pipeline") cmd.AddCommand( create.Cmd(), diff --git a/cmd/pipelines/cmd_test.go b/cmd/pipeline/cmd_test.go similarity index 93% rename from cmd/pipelines/cmd_test.go rename to cmd/pipeline/cmd_test.go index 9b737ccb2..03280f159 100644 --- a/cmd/pipelines/cmd_test.go +++ b/cmd/pipeline/cmd_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package pipelines +package pipeline import ( "testing" @@ -24,7 +24,7 @@ import ( func TestCmd_BasicMetadata(t *testing.T) { cmd := Cmd() - assert.Equal(t, "pipelines", cmd.Use) + assert.Equal(t, "pipeline", cmd.Use) assert.Equal(t, "core", cmd.GroupID) assert.NotEmpty(t, cmd.Short) assert.NotEmpty(t, cmd.Long) @@ -35,7 +35,7 @@ func TestCmd_FeatureGate(t *testing.T) { gate, ok := cmd.Annotations[features.AnnotationKey] assert.True(t, ok, "expected feature-gate annotation to be set") - assert.Equal(t, "pipelines", gate) + assert.Equal(t, "pipeline", gate) } func TestCmd_HasExpectedSubcommands(t *testing.T) { diff --git a/cmd/pipelines/create/cmd.go b/cmd/pipeline/create/cmd.go similarity index 92% rename from cmd/pipelines/create/cmd.go rename to cmd/pipeline/create/cmd.go index c43b7dd9b..bc74b1f0c 100644 --- a/cmd/pipelines/create/cmd.go +++ b/cmd/pipeline/create/cmd.go @@ -43,10 +43,10 @@ The path to the Python file can be supplied either as a positional argument or via the --from-file= flag. Exactly one of the two must be provided. Example: - dr pipelines create ./my_pipeline.py - dr pipelines create --from-file=./my_pipeline.py - dr pipelines create ./my_pipeline.py --description "First draft" --mode draft - dr pipelines create --from-file=./my_pipeline.py --output-format json`, + dr pipeline create ./my_pipeline.py + dr pipeline create --from-file=./my_pipeline.py + dr pipeline create ./my_pipeline.py --description "First draft" --mode draft + dr pipeline create --from-file=./my_pipeline.py --output-format json`, Args: cobra.MaximumNArgs(1), PreRunE: auth.EnsureAuthenticatedE, RunE: func(_ *cobra.Command, args []string) error { diff --git a/cmd/pipelines/create/cmd_test.go b/cmd/pipeline/create/cmd_test.go similarity index 100% rename from cmd/pipelines/create/cmd_test.go rename to cmd/pipeline/create/cmd_test.go diff --git a/cmd/pipelines/del/cmd.go b/cmd/pipeline/del/cmd.go similarity index 94% rename from cmd/pipelines/del/cmd.go rename to cmd/pipeline/del/cmd.go index 32e29a22f..4fd5c2b72 100644 --- a/cmd/pipelines/del/cmd.go +++ b/cmd/pipeline/del/cmd.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package del implements the `dr pipelines delete` verb. The directory +// Package del implements the `dr pipeline delete` verb. The directory // is named `del` rather than `delete` because the latter shadows Go's // built-in delete() function in importing files. @@ -38,7 +38,7 @@ func Cmd() *cobra.Command { removed from the registry. Example: - dr pipelines delete 507f1f77bcf86cd799439011`, + dr pipeline delete 507f1f77bcf86cd799439011`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/del/cmd_test.go b/cmd/pipeline/del/cmd_test.go similarity index 100% rename from cmd/pipelines/del/cmd_test.go rename to cmd/pipeline/del/cmd_test.go diff --git a/cmd/pipelines/environment/cmd.go b/cmd/pipeline/environment/cmd.go similarity index 79% rename from cmd/pipelines/environment/cmd.go rename to cmd/pipeline/environment/cmd.go index 4908b0cfa..68a19ddff 100644 --- a/cmd/pipelines/environment/cmd.go +++ b/cmd/pipeline/environment/cmd.go @@ -15,15 +15,15 @@ package environment import ( - "github.com/datarobot/cli/cmd/pipelines/environment/create" - "github.com/datarobot/cli/cmd/pipelines/environment/del" - "github.com/datarobot/cli/cmd/pipelines/environment/list" - "github.com/datarobot/cli/cmd/pipelines/environment/update" - "github.com/datarobot/cli/cmd/pipelines/environment/version" + "github.com/datarobot/cli/cmd/pipeline/environment/create" + "github.com/datarobot/cli/cmd/pipeline/environment/del" + "github.com/datarobot/cli/cmd/pipeline/environment/list" + "github.com/datarobot/cli/cmd/pipeline/environment/update" + "github.com/datarobot/cli/cmd/pipeline/environment/version" "github.com/spf13/cobra" ) -// Cmd returns the parent command for `dr pipelines environment`. It +// Cmd returns the parent command for `dr pipeline environment`. It // groups the lifecycle verbs that operate on pipeline execution // environments (named, immutable-versioned bags of pip packages). func Cmd() *cobra.Command { diff --git a/cmd/pipelines/environment/cmd_test.go b/cmd/pipeline/environment/cmd_test.go similarity index 100% rename from cmd/pipelines/environment/cmd_test.go rename to cmd/pipeline/environment/cmd_test.go diff --git a/cmd/pipelines/environment/create/cmd.go b/cmd/pipeline/environment/create/cmd.go similarity index 90% rename from cmd/pipelines/environment/create/cmd.go rename to cmd/pipeline/environment/create/cmd.go index 4018e5917..1f2690991 100644 --- a/cmd/pipelines/environment/create/cmd.go +++ b/cmd/pipeline/environment/create/cmd.go @@ -40,8 +40,8 @@ the supplied pip packages. The environment may be referenced by pipelines once its first version reaches the READY state. Example: - dr pipelines environment create --name ml-base --package numpy --package pandas - dr pipelines environment create --name ml-base --packages numpy,pandas==2.0 --description "training base" --output-format json`, + dr pipeline environment create --name ml-base --package numpy --package pandas + dr pipeline environment create --name ml-base --packages numpy,pandas==2.0 --description "training base" --output-format json`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/environment/create/cmd_test.go b/cmd/pipeline/environment/create/cmd_test.go similarity index 100% rename from cmd/pipelines/environment/create/cmd_test.go rename to cmd/pipeline/environment/create/cmd_test.go diff --git a/cmd/pipelines/environment/del/cmd.go b/cmd/pipeline/environment/del/cmd.go similarity index 89% rename from cmd/pipelines/environment/del/cmd.go rename to cmd/pipeline/environment/del/cmd.go index 19727bf80..76f038598 100644 --- a/cmd/pipelines/environment/del/cmd.go +++ b/cmd/pipeline/environment/del/cmd.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package del implements `dr pipelines environment delete`. Directory +// Package del implements `dr pipeline environment delete`. Directory // is named `del` rather than `delete` to avoid shadowing Go's built-in // `delete()` in importing files. @@ -36,10 +36,10 @@ execution environment. If no active versions remain after the delete, the parent environment is soft-deleted too. To delete a specific older version, use: - dr pipelines environment version delete --environment + dr pipeline environment version delete --environment Example: - dr pipelines environment delete env-123`, + dr pipeline environment delete env-123`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/environment/del/cmd_test.go b/cmd/pipeline/environment/del/cmd_test.go similarity index 100% rename from cmd/pipelines/environment/del/cmd_test.go rename to cmd/pipeline/environment/del/cmd_test.go diff --git a/cmd/pipelines/environment/list/cmd.go b/cmd/pipeline/environment/list/cmd.go similarity index 94% rename from cmd/pipelines/environment/list/cmd.go rename to cmd/pipeline/environment/list/cmd.go index 6fb91aa4f..f19c3691f 100644 --- a/cmd/pipelines/environment/list/cmd.go +++ b/cmd/pipeline/environment/list/cmd.go @@ -37,8 +37,8 @@ row reflects the latest version's status only; per-version details are returned by ` + "`environment create`" + ` and ` + "`environment update`" + `. Example: - dr pipelines environment list - dr pipelines environment list --offset 50 --limit 10 --output-format json`, + dr pipeline environment list + dr pipeline environment list --offset 50 --limit 10 --output-format json`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/environment/list/cmd_test.go b/cmd/pipeline/environment/list/cmd_test.go similarity index 100% rename from cmd/pipelines/environment/list/cmd_test.go rename to cmd/pipeline/environment/list/cmd_test.go diff --git a/cmd/pipelines/environment/update/cmd.go b/cmd/pipeline/environment/update/cmd.go similarity index 91% rename from cmd/pipelines/environment/update/cmd.go rename to cmd/pipeline/environment/update/cmd.go index 387a669e7..52e7e2bd9 100644 --- a/cmd/pipelines/environment/update/cmd.go +++ b/cmd/pipeline/environment/update/cmd.go @@ -35,8 +35,8 @@ Updating creates a new immutable version of the environment containing the supplied pip packages. Existing versions are unchanged. Example: - dr pipelines environment update env-123 --package scikit-learn - dr pipelines environment update env-123 --package "scikit-learn==1.5,torch" --output-format json`, + dr pipeline environment update env-123 --package scikit-learn + dr pipeline environment update env-123 --package "scikit-learn==1.5,torch" --output-format json`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/environment/update/cmd_test.go b/cmd/pipeline/environment/update/cmd_test.go similarity index 100% rename from cmd/pipelines/environment/update/cmd_test.go rename to cmd/pipeline/environment/update/cmd_test.go diff --git a/cmd/pipelines/environment/version/cmd.go b/cmd/pipeline/environment/version/cmd.go similarity index 87% rename from cmd/pipelines/environment/version/cmd.go rename to cmd/pipeline/environment/version/cmd.go index 35bb135ad..590fcde49 100644 --- a/cmd/pipelines/environment/version/cmd.go +++ b/cmd/pipeline/environment/version/cmd.go @@ -15,11 +15,11 @@ package version import ( - "github.com/datarobot/cli/cmd/pipelines/environment/version/del" + "github.com/datarobot/cli/cmd/pipeline/environment/version/del" "github.com/spf13/cobra" ) -// Cmd returns the parent command for `dr pipelines environment version`. +// Cmd returns the parent command for `dr pipeline environment version`. // Currently only delete is exposed; the pipelines-api does not surface // per-version GET endpoints. func Cmd() *cobra.Command { diff --git a/cmd/pipelines/environment/version/cmd_test.go b/cmd/pipeline/environment/version/cmd_test.go similarity index 100% rename from cmd/pipelines/environment/version/cmd_test.go rename to cmd/pipeline/environment/version/cmd_test.go diff --git a/cmd/pipelines/environment/version/del/cmd.go b/cmd/pipeline/environment/version/del/cmd.go similarity index 93% rename from cmd/pipelines/environment/version/del/cmd.go rename to cmd/pipeline/environment/version/del/cmd.go index 7443bdd1b..e53a86f9b 100644 --- a/cmd/pipelines/environment/version/del/cmd.go +++ b/cmd/pipeline/environment/version/del/cmd.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package del implements `dr pipelines environment version delete`. +// Package del implements `dr pipeline environment version delete`. // Directory is named `del` to avoid shadowing Go's built-in `delete()`. package del @@ -38,7 +38,7 @@ func Cmd() *cobra.Command { without touching the parent environment. Example: - dr pipelines environment version delete --environment env-123 2`, + dr pipeline environment version delete --environment env-123 2`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/environment/version/del/cmd_test.go b/cmd/pipeline/environment/version/del/cmd_test.go similarity index 100% rename from cmd/pipelines/environment/version/del/cmd_test.go rename to cmd/pipeline/environment/version/del/cmd_test.go diff --git a/cmd/pipelines/get/cmd.go b/cmd/pipeline/get/cmd.go similarity index 95% rename from cmd/pipelines/get/cmd.go rename to cmd/pipeline/get/cmd.go index c425c9c9a..0439602b0 100644 --- a/cmd/pipelines/get/cmd.go +++ b/cmd/pipeline/get/cmd.go @@ -37,8 +37,8 @@ func Cmd() *cobra.Command { By default, output is human-readable. Use --output-format json for machine-parseable output. Example: - dr pipelines get 507f1f77bcf86cd799439011 - dr pipelines get 507f1f77bcf86cd799439011 --output-format json`, + dr pipeline get 507f1f77bcf86cd799439011 + dr pipeline get 507f1f77bcf86cd799439011 --output-format json`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/get/cmd_test.go b/cmd/pipeline/get/cmd_test.go similarity index 100% rename from cmd/pipelines/get/cmd_test.go rename to cmd/pipeline/get/cmd_test.go diff --git a/cmd/pipelines/graph/cmd.go b/cmd/pipeline/graph/cmd.go similarity index 95% rename from cmd/pipelines/graph/cmd.go rename to cmd/pipeline/graph/cmd.go index 9dfb2021b..ed871f7ba 100644 --- a/cmd/pipelines/graph/cmd.go +++ b/cmd/pipeline/graph/cmd.go @@ -23,7 +23,7 @@ import ( "strconv" "text/tabwriter" - "github.com/datarobot/cli/cmd/pipelines/scopeflag" + "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" "github.com/datarobot/cli/internal/pipelines" @@ -50,8 +50,8 @@ Scope is selected from the --scope/--version flags: - --scope=locked --version=N -> locked graph for version N Example: - dr pipelines graph --pipeline - dr pipelines graph --pipeline --version=2 --output-format json`, + dr pipeline graph --pipeline + dr pipeline graph --pipeline --version=2 --output-format json`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/graph/cmd_test.go b/cmd/pipeline/graph/cmd_test.go similarity index 100% rename from cmd/pipelines/graph/cmd_test.go rename to cmd/pipeline/graph/cmd_test.go diff --git a/cmd/pipelines/input/cmd.go b/cmd/pipeline/input/cmd.go similarity index 78% rename from cmd/pipelines/input/cmd.go rename to cmd/pipeline/input/cmd.go index 23f247e44..f2e6955b0 100644 --- a/cmd/pipelines/input/cmd.go +++ b/cmd/pipeline/input/cmd.go @@ -15,15 +15,15 @@ package input import ( - "github.com/datarobot/cli/cmd/pipelines/input/create" - "github.com/datarobot/cli/cmd/pipelines/input/del" - "github.com/datarobot/cli/cmd/pipelines/input/get" - "github.com/datarobot/cli/cmd/pipelines/input/list" - "github.com/datarobot/cli/cmd/pipelines/input/update" + "github.com/datarobot/cli/cmd/pipeline/input/create" + "github.com/datarobot/cli/cmd/pipeline/input/del" + "github.com/datarobot/cli/cmd/pipeline/input/get" + "github.com/datarobot/cli/cmd/pipeline/input/list" + "github.com/datarobot/cli/cmd/pipeline/input/update" "github.com/spf13/cobra" ) -// Cmd returns the parent command for `dr pipelines input`. It groups the +// Cmd returns the parent command for `dr pipeline input`. It groups the // CRUD verbs that operate on pipeline input sets. func Cmd() *cobra.Command { cmd := &cobra.Command{ diff --git a/cmd/pipelines/input/cmd_test.go b/cmd/pipeline/input/cmd_test.go similarity index 100% rename from cmd/pipelines/input/cmd_test.go rename to cmd/pipeline/input/cmd_test.go diff --git a/cmd/pipelines/input/create/cmd.go b/cmd/pipeline/input/create/cmd.go similarity index 89% rename from cmd/pipelines/input/create/cmd.go rename to cmd/pipeline/input/create/cmd.go index d46485089..fe69a3f27 100644 --- a/cmd/pipelines/input/create/cmd.go +++ b/cmd/pipeline/input/create/cmd.go @@ -17,7 +17,7 @@ package create import ( "errors" - "github.com/datarobot/cli/cmd/pipelines/scopeflag" + "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" "github.com/spf13/cobra" @@ -46,9 +46,9 @@ Scope is selected from the --scope/--version flags: - --scope=locked --version=N -> locked, version N Example: - dr pipelines input create --pipeline ./payload.json - dr pipelines input create --pipeline --from-file=./payload.json - dr pipelines input create --pipeline --version=2 ./payload.json --output-format json`, + dr pipeline input create --pipeline ./payload.json + dr pipeline input create --pipeline --from-file=./payload.json + dr pipeline input create --pipeline --version=2 ./payload.json --output-format json`, Args: cobra.MaximumNArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/input/create/cmd_test.go b/cmd/pipeline/input/create/cmd_test.go similarity index 100% rename from cmd/pipelines/input/create/cmd_test.go rename to cmd/pipeline/input/create/cmd_test.go diff --git a/cmd/pipelines/input/del/cmd.go b/cmd/pipeline/input/del/cmd.go similarity index 87% rename from cmd/pipelines/input/del/cmd.go rename to cmd/pipeline/input/del/cmd.go index 0b8634cd3..c43cc4f93 100644 --- a/cmd/pipelines/input/del/cmd.go +++ b/cmd/pipeline/input/del/cmd.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package del implements the `dr pipelines input delete` verb. The +// Package del implements the `dr pipeline input delete` verb. The // directory is named `del` rather than `delete` because the latter // shadows Go's built-in delete() function in importing files. @@ -22,7 +22,7 @@ import ( "errors" "fmt" - "github.com/datarobot/cli/cmd/pipelines/scopeflag" + "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" "github.com/datarobot/cli/tui" @@ -38,8 +38,8 @@ func Cmd() *cobra.Command { Long: `Delete an input payload from a pipeline. Example: - dr pipelines input delete --pipeline - dr pipelines input delete --pipeline --version=2 `, + dr pipeline input delete --pipeline + dr pipeline input delete --pipeline --version=2 `, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/input/del/cmd_test.go b/cmd/pipeline/input/del/cmd_test.go similarity index 100% rename from cmd/pipelines/input/del/cmd_test.go rename to cmd/pipeline/input/del/cmd_test.go diff --git a/cmd/pipelines/input/get/cmd.go b/cmd/pipeline/input/get/cmd.go similarity index 91% rename from cmd/pipelines/input/get/cmd.go rename to cmd/pipeline/input/get/cmd.go index e2a1cdc3e..e5c0a408e 100644 --- a/cmd/pipelines/input/get/cmd.go +++ b/cmd/pipeline/input/get/cmd.go @@ -19,7 +19,7 @@ import ( "fmt" "net/http" - "github.com/datarobot/cli/cmd/pipelines/scopeflag" + "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" "github.com/datarobot/cli/internal/pipelines" @@ -39,8 +39,8 @@ func Cmd() *cobra.Command { Long: `Display the full payload and metadata for a single input set. Example: - dr pipelines input get --pipeline - dr pipelines input get --pipeline --version=2 --output-format json`, + dr pipeline input get --pipeline + dr pipeline input get --pipeline --version=2 --output-format json`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/input/get/cmd_test.go b/cmd/pipeline/input/get/cmd_test.go similarity index 100% rename from cmd/pipelines/input/get/cmd_test.go rename to cmd/pipeline/input/get/cmd_test.go diff --git a/cmd/pipelines/input/list/cmd.go b/cmd/pipeline/input/list/cmd.go similarity index 89% rename from cmd/pipelines/input/list/cmd.go rename to cmd/pipeline/input/list/cmd.go index dd278f8b8..7db5a6be8 100644 --- a/cmd/pipelines/input/list/cmd.go +++ b/cmd/pipeline/input/list/cmd.go @@ -17,7 +17,7 @@ package list import ( "errors" - "github.com/datarobot/cli/cmd/pipelines/scopeflag" + "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" "github.com/spf13/cobra" @@ -43,9 +43,9 @@ Scope is selected the same way as create: - --scope=locked --version=N -> locked, version N Example: - dr pipelines input list --pipeline - dr pipelines input list --pipeline --version=2 - dr pipelines input list --pipeline --offset 50 --limit 10 --output-format json`, + dr pipeline input list --pipeline + dr pipeline input list --pipeline --version=2 + dr pipeline input list --pipeline --offset 50 --limit 10 --output-format json`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/input/list/cmd_test.go b/cmd/pipeline/input/list/cmd_test.go similarity index 100% rename from cmd/pipelines/input/list/cmd_test.go rename to cmd/pipeline/input/list/cmd_test.go diff --git a/cmd/pipelines/input/update/cmd.go b/cmd/pipeline/input/update/cmd.go similarity index 91% rename from cmd/pipelines/input/update/cmd.go rename to cmd/pipeline/input/update/cmd.go index be74af651..4a1e61df7 100644 --- a/cmd/pipelines/input/update/cmd.go +++ b/cmd/pipeline/input/update/cmd.go @@ -39,8 +39,8 @@ one. The new payload must be a JSON object supplied either as a positional argument or via --from-file=. Example: - dr pipelines input update --pipeline ./new_payload.json - dr pipelines input update --pipeline --from-file=./new_payload.json --output-format json`, + dr pipeline input update --pipeline ./new_payload.json + dr pipeline input update --pipeline --from-file=./new_payload.json --output-format json`, Args: cobra.RangeArgs(1, 2), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/input/update/cmd_test.go b/cmd/pipeline/input/update/cmd_test.go similarity index 100% rename from cmd/pipelines/input/update/cmd_test.go rename to cmd/pipeline/input/update/cmd_test.go diff --git a/cmd/pipelines/list/cmd.go b/cmd/pipeline/list/cmd.go similarity index 93% rename from cmd/pipelines/list/cmd.go rename to cmd/pipeline/list/cmd.go index d7394c8e2..ac0ef0f54 100644 --- a/cmd/pipelines/list/cmd.go +++ b/cmd/pipeline/list/cmd.go @@ -38,9 +38,9 @@ func Cmd() *cobra.Command { By default, output is human-readable. Use --output-format json for machine-parseable output. Example: - dr pipelines list - dr pipelines list --mode draft - dr pipelines list --offset 0 --limit 50 --output-format json`, + dr pipeline list + dr pipeline list --mode draft + dr pipeline list --offset 0 --limit 50 --output-format json`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, RunE: func(_ *cobra.Command, _ []string) error { diff --git a/cmd/pipelines/list/cmd_test.go b/cmd/pipeline/list/cmd_test.go similarity index 100% rename from cmd/pipelines/list/cmd_test.go rename to cmd/pipeline/list/cmd_test.go diff --git a/cmd/pipelines/lock/cmd.go b/cmd/pipeline/lock/cmd.go similarity index 92% rename from cmd/pipelines/lock/cmd.go rename to cmd/pipeline/lock/cmd.go index 872d7c019..5187f35d3 100644 --- a/cmd/pipelines/lock/cmd.go +++ b/cmd/pipeline/lock/cmd.go @@ -30,8 +30,8 @@ func Cmd() *cobra.Command { no longer be updated and locked runs/inputs/schedules become valid. Example: - dr pipelines lock 507f1f77bcf86cd799439011 - dr pipelines lock 507f1f77bcf86cd799439011 --output-format json`, + dr pipeline lock 507f1f77bcf86cd799439011 + dr pipeline lock 507f1f77bcf86cd799439011 --output-format json`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/lock/cmd_test.go b/cmd/pipeline/lock/cmd_test.go similarity index 100% rename from cmd/pipelines/lock/cmd_test.go rename to cmd/pipeline/lock/cmd_test.go diff --git a/cmd/pipelines/run/cancel/cmd.go b/cmd/pipeline/run/cancel/cmd.go similarity index 90% rename from cmd/pipelines/run/cancel/cmd.go rename to cmd/pipeline/run/cancel/cmd.go index 59f18e001..de726a411 100644 --- a/cmd/pipelines/run/cancel/cmd.go +++ b/cmd/pipeline/run/cancel/cmd.go @@ -18,7 +18,7 @@ import ( "errors" "fmt" - "github.com/datarobot/cli/cmd/pipelines/scopeflag" + "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" "github.com/datarobot/cli/tui" @@ -37,8 +37,8 @@ The API rejects cancellation if the run has already reached a terminal state (COMPLETED, FAILED, CANCELLED). Example: - dr pipelines run cancel --pipeline - dr pipelines run cancel --pipeline --version=2 `, + dr pipeline run cancel --pipeline + dr pipeline run cancel --pipeline --version=2 `, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/run/cancel/cmd_test.go b/cmd/pipeline/run/cancel/cmd_test.go similarity index 100% rename from cmd/pipelines/run/cancel/cmd_test.go rename to cmd/pipeline/run/cancel/cmd_test.go diff --git a/cmd/pipelines/run/cmd.go b/cmd/pipeline/run/cmd.go similarity index 78% rename from cmd/pipelines/run/cmd.go rename to cmd/pipeline/run/cmd.go index 313901dad..bd2bc50da 100644 --- a/cmd/pipelines/run/cmd.go +++ b/cmd/pipeline/run/cmd.go @@ -15,15 +15,15 @@ package run import ( - "github.com/datarobot/cli/cmd/pipelines/run/cancel" - "github.com/datarobot/cli/cmd/pipelines/run/create" - "github.com/datarobot/cli/cmd/pipelines/run/get" - "github.com/datarobot/cli/cmd/pipelines/run/list" - "github.com/datarobot/cli/cmd/pipelines/run/status" + "github.com/datarobot/cli/cmd/pipeline/run/cancel" + "github.com/datarobot/cli/cmd/pipeline/run/create" + "github.com/datarobot/cli/cmd/pipeline/run/get" + "github.com/datarobot/cli/cmd/pipeline/run/list" + "github.com/datarobot/cli/cmd/pipeline/run/status" "github.com/spf13/cobra" ) -// Cmd returns the parent command for `dr pipelines run`. +// Cmd returns the parent command for `dr pipeline run`. func Cmd() *cobra.Command { cmd := &cobra.Command{ Use: "run", diff --git a/cmd/pipelines/run/cmd_test.go b/cmd/pipeline/run/cmd_test.go similarity index 100% rename from cmd/pipelines/run/cmd_test.go rename to cmd/pipeline/run/cmd_test.go diff --git a/cmd/pipelines/run/create/cmd.go b/cmd/pipeline/run/create/cmd.go similarity index 83% rename from cmd/pipelines/run/create/cmd.go rename to cmd/pipeline/run/create/cmd.go index 41d6a5248..1c62ad0d2 100644 --- a/cmd/pipelines/run/create/cmd.go +++ b/cmd/pipeline/run/create/cmd.go @@ -17,7 +17,7 @@ package create import ( "errors" - "github.com/datarobot/cli/cmd/pipelines/scopeflag" + "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" "github.com/spf13/cobra" @@ -35,12 +35,12 @@ func Cmd() *cobra.Command { Short: "Trigger a pipeline run", Long: `Trigger a new run (single execution) of a pipeline. -The run is created in PENDING state. Use ` + "`dr pipelines run get`" + ` -or ` + "`dr pipelines run status`" + ` to follow its progress. +The run is created in PENDING state. Use ` + "`dr pipeline run get`" + ` +or ` + "`dr pipeline run status`" + ` to follow its progress. Example: - dr pipelines run create --pipeline --input - dr pipelines run create --pipeline --version=2 --input --output-format json`, + dr pipeline run create --pipeline --input + dr pipeline run create --pipeline --version=2 --input --output-format json`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/run/create/cmd_test.go b/cmd/pipeline/run/create/cmd_test.go similarity index 100% rename from cmd/pipelines/run/create/cmd_test.go rename to cmd/pipeline/run/create/cmd_test.go diff --git a/cmd/pipelines/run/get/cmd.go b/cmd/pipeline/run/get/cmd.go similarity index 91% rename from cmd/pipelines/run/get/cmd.go rename to cmd/pipeline/run/get/cmd.go index 11d750505..5c681381e 100644 --- a/cmd/pipelines/run/get/cmd.go +++ b/cmd/pipeline/run/get/cmd.go @@ -19,7 +19,7 @@ import ( "fmt" "net/http" - "github.com/datarobot/cli/cmd/pipelines/scopeflag" + "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" "github.com/datarobot/cli/internal/pipelines" @@ -39,8 +39,8 @@ func Cmd() *cobra.Command { Long: `Display the full record for a single run. Example: - dr pipelines run get --pipeline - dr pipelines run get --pipeline --version=2 --output-format json`, + dr pipeline run get --pipeline + dr pipeline run get --pipeline --version=2 --output-format json`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/run/get/cmd_test.go b/cmd/pipeline/run/get/cmd_test.go similarity index 100% rename from cmd/pipelines/run/get/cmd_test.go rename to cmd/pipeline/run/get/cmd_test.go diff --git a/cmd/pipelines/run/list/cmd.go b/cmd/pipeline/run/list/cmd.go similarity index 91% rename from cmd/pipelines/run/list/cmd.go rename to cmd/pipeline/run/list/cmd.go index 7106c1a19..d72762a36 100644 --- a/cmd/pipelines/run/list/cmd.go +++ b/cmd/pipeline/run/list/cmd.go @@ -17,7 +17,7 @@ package list import ( "errors" - "github.com/datarobot/cli/cmd/pipelines/scopeflag" + "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipelines" "github.com/spf13/cobra" @@ -37,8 +37,8 @@ func Cmd() *cobra.Command { Long: `List runs for a pipeline. Example: - dr pipelines run list --pipeline - dr pipelines run list --pipeline --version=2 --output-format json`, + dr pipeline run list --pipeline + dr pipeline run list --pipeline --version=2 --output-format json`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/run/list/cmd_test.go b/cmd/pipeline/run/list/cmd_test.go similarity index 100% rename from cmd/pipelines/run/list/cmd_test.go rename to cmd/pipeline/run/list/cmd_test.go diff --git a/cmd/pipelines/run/status/cmd.go b/cmd/pipeline/run/status/cmd.go similarity index 91% rename from cmd/pipelines/run/status/cmd.go rename to cmd/pipeline/run/status/cmd.go index fce797540..c6f28dc8c 100644 --- a/cmd/pipelines/run/status/cmd.go +++ b/cmd/pipeline/run/status/cmd.go @@ -19,7 +19,7 @@ import ( "fmt" "net/http" - "github.com/datarobot/cli/cmd/pipelines/scopeflag" + "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" "github.com/datarobot/cli/internal/pipelines" @@ -39,8 +39,8 @@ func Cmd() *cobra.Command { Long: `Poll a run's current status without re-downloading the full record. Example: - dr pipelines run status --pipeline - dr pipelines run status --pipeline --version=2 --output-format json`, + dr pipeline run status --pipeline + dr pipeline run status --pipeline --version=2 --output-format json`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/run/status/cmd_test.go b/cmd/pipeline/run/status/cmd_test.go similarity index 100% rename from cmd/pipelines/run/status/cmd_test.go rename to cmd/pipeline/run/status/cmd_test.go diff --git a/cmd/pipelines/schedule/cmd.go b/cmd/pipeline/schedule/cmd.go similarity index 75% rename from cmd/pipelines/schedule/cmd.go rename to cmd/pipeline/schedule/cmd.go index 97711ba3f..78308c4d3 100644 --- a/cmd/pipelines/schedule/cmd.go +++ b/cmd/pipeline/schedule/cmd.go @@ -15,15 +15,15 @@ package schedule import ( - "github.com/datarobot/cli/cmd/pipelines/schedule/create" - "github.com/datarobot/cli/cmd/pipelines/schedule/del" - "github.com/datarobot/cli/cmd/pipelines/schedule/get" - "github.com/datarobot/cli/cmd/pipelines/schedule/list" - "github.com/datarobot/cli/cmd/pipelines/schedule/update" + "github.com/datarobot/cli/cmd/pipeline/schedule/create" + "github.com/datarobot/cli/cmd/pipeline/schedule/del" + "github.com/datarobot/cli/cmd/pipeline/schedule/get" + "github.com/datarobot/cli/cmd/pipeline/schedule/list" + "github.com/datarobot/cli/cmd/pipeline/schedule/update" "github.com/spf13/cobra" ) -// Cmd returns the parent command for `dr pipelines schedule`. +// Cmd returns the parent command for `dr pipeline schedule`. func Cmd() *cobra.Command { cmd := &cobra.Command{ Use: "schedule", diff --git a/cmd/pipelines/schedule/cmd_test.go b/cmd/pipeline/schedule/cmd_test.go similarity index 100% rename from cmd/pipelines/schedule/cmd_test.go rename to cmd/pipeline/schedule/cmd_test.go diff --git a/cmd/pipelines/schedule/create/cmd.go b/cmd/pipeline/schedule/create/cmd.go similarity index 91% rename from cmd/pipelines/schedule/create/cmd.go rename to cmd/pipeline/schedule/create/cmd.go index 0a3d797b2..d1249ffb9 100644 --- a/cmd/pipelines/schedule/create/cmd.go +++ b/cmd/pipeline/schedule/create/cmd.go @@ -38,8 +38,8 @@ func Cmd() *cobra.Command { Long: `Register a cron-style schedule that triggers a run on a fixed cadence. Example: - dr pipelines schedule create --pipeline --version=2 --cron "0 * * * *" --input - dr pipelines schedule create --pipeline --version=2 --cron "0 9 * * *" --input --timezone America/Los_Angeles`, + dr pipeline schedule create --pipeline --version=2 --cron "0 * * * *" --input + dr pipeline schedule create --pipeline --version=2 --cron "0 9 * * *" --input --timezone America/Los_Angeles`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/schedule/create/cmd_test.go b/cmd/pipeline/schedule/create/cmd_test.go similarity index 100% rename from cmd/pipelines/schedule/create/cmd_test.go rename to cmd/pipeline/schedule/create/cmd_test.go diff --git a/cmd/pipelines/schedule/del/cmd.go b/cmd/pipeline/schedule/del/cmd.go similarity index 92% rename from cmd/pipelines/schedule/del/cmd.go rename to cmd/pipeline/schedule/del/cmd.go index 5d440b452..0a7a319b4 100644 --- a/cmd/pipelines/schedule/del/cmd.go +++ b/cmd/pipeline/schedule/del/cmd.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package del implements the `dr pipelines schedule delete` verb. The +// Package del implements the `dr pipeline schedule delete` verb. The // directory is named `del` rather than `delete` because the latter // shadows Go's built-in delete() function in importing files. @@ -40,7 +40,7 @@ func Cmd() *cobra.Command { Long: `Delete a recurring schedule from a locked pipeline version. Example: - dr pipelines schedule delete --pipeline --version=2 `, + dr pipeline schedule delete --pipeline --version=2 `, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/schedule/del/cmd_test.go b/cmd/pipeline/schedule/del/cmd_test.go similarity index 100% rename from cmd/pipelines/schedule/del/cmd_test.go rename to cmd/pipeline/schedule/del/cmd_test.go diff --git a/cmd/pipelines/schedule/get/cmd.go b/cmd/pipeline/schedule/get/cmd.go similarity index 93% rename from cmd/pipelines/schedule/get/cmd.go rename to cmd/pipeline/schedule/get/cmd.go index 93d407609..5d7b3a28c 100644 --- a/cmd/pipelines/schedule/get/cmd.go +++ b/cmd/pipeline/schedule/get/cmd.go @@ -39,8 +39,8 @@ func Cmd() *cobra.Command { Long: `Display the cron expression, timezone, and lifecycle status of a schedule. Example: - dr pipelines schedule get --pipeline --version=2 - dr pipelines schedule get --pipeline --version=2 --output-format json`, + dr pipeline schedule get --pipeline --version=2 + dr pipeline schedule get --pipeline --version=2 --output-format json`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/schedule/get/cmd_test.go b/cmd/pipeline/schedule/get/cmd_test.go similarity index 100% rename from cmd/pipelines/schedule/get/cmd_test.go rename to cmd/pipeline/schedule/get/cmd_test.go diff --git a/cmd/pipelines/schedule/list/cmd.go b/cmd/pipeline/schedule/list/cmd.go similarity index 93% rename from cmd/pipelines/schedule/list/cmd.go rename to cmd/pipeline/schedule/list/cmd.go index 0e806433c..b34b907dd 100644 --- a/cmd/pipelines/schedule/list/cmd.go +++ b/cmd/pipeline/schedule/list/cmd.go @@ -37,8 +37,8 @@ func Cmd() *cobra.Command { Long: `List recurring schedules attached to a locked pipeline version. Example: - dr pipelines schedule list --pipeline --version=2 - dr pipelines schedule list --pipeline --version=2 --output-format json`, + dr pipeline schedule list --pipeline --version=2 + dr pipeline schedule list --pipeline --version=2 --output-format json`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/schedule/list/cmd_test.go b/cmd/pipeline/schedule/list/cmd_test.go similarity index 100% rename from cmd/pipelines/schedule/list/cmd_test.go rename to cmd/pipeline/schedule/list/cmd_test.go diff --git a/cmd/pipelines/schedule/update/cmd.go b/cmd/pipeline/schedule/update/cmd.go similarity index 93% rename from cmd/pipelines/schedule/update/cmd.go rename to cmd/pipeline/schedule/update/cmd.go index c1d0c750d..79432cfc9 100644 --- a/cmd/pipelines/schedule/update/cmd.go +++ b/cmd/pipeline/schedule/update/cmd.go @@ -40,8 +40,8 @@ At least one of --cron or --timezone must be supplied; otherwise the command sends an empty patch which the API treats as a no-op. Example: - dr pipelines schedule update --pipeline --version=2 --cron "*/15 * * * *" - dr pipelines schedule update --pipeline --version=2 --timezone Europe/Berlin`, + dr pipeline schedule update --pipeline --version=2 --cron "*/15 * * * *" + dr pipeline schedule update --pipeline --version=2 --timezone Europe/Berlin`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/schedule/update/cmd_test.go b/cmd/pipeline/schedule/update/cmd_test.go similarity index 100% rename from cmd/pipelines/schedule/update/cmd_test.go rename to cmd/pipeline/schedule/update/cmd_test.go diff --git a/cmd/pipelines/scopeflag/scopeflag.go b/cmd/pipeline/scopeflag/scopeflag.go similarity index 100% rename from cmd/pipelines/scopeflag/scopeflag.go rename to cmd/pipeline/scopeflag/scopeflag.go diff --git a/cmd/pipelines/scopeflag/scopeflag_test.go b/cmd/pipeline/scopeflag/scopeflag_test.go similarity index 100% rename from cmd/pipelines/scopeflag/scopeflag_test.go rename to cmd/pipeline/scopeflag/scopeflag_test.go diff --git a/cmd/pipelines/update/cmd.go b/cmd/pipeline/update/cmd.go similarity index 92% rename from cmd/pipelines/update/cmd.go rename to cmd/pipeline/update/cmd.go index 8f00f0d98..447f9420d 100644 --- a/cmd/pipelines/update/cmd.go +++ b/cmd/pipeline/update/cmd.go @@ -43,9 +43,9 @@ or via the --from-file= flag. Exactly one of the two must be provided. By default, output is human-readable. Use --output-format json for machine-parseable output. Example: - dr pipelines update 507f1f77bcf86cd799439011 ./my_pipeline.py - dr pipelines update 507f1f77bcf86cd799439011 --from-file=./my_pipeline.py - dr pipelines update 507f1f77bcf86cd799439011 --from-file=./my_pipeline.py --output-format json`, + dr pipeline update 507f1f77bcf86cd799439011 ./my_pipeline.py + dr pipeline update 507f1f77bcf86cd799439011 --from-file=./my_pipeline.py + dr pipeline update 507f1f77bcf86cd799439011 --from-file=./my_pipeline.py --output-format json`, Args: cobra.RangeArgs(1, 2), PreRunE: auth.EnsureAuthenticatedE, RunE: func(_ *cobra.Command, args []string) error { diff --git a/cmd/pipelines/update/cmd_test.go b/cmd/pipeline/update/cmd_test.go similarity index 100% rename from cmd/pipelines/update/cmd_test.go rename to cmd/pipeline/update/cmd_test.go diff --git a/cmd/pipelines/version/cmd.go b/cmd/pipeline/version/cmd.go similarity index 79% rename from cmd/pipelines/version/cmd.go rename to cmd/pipeline/version/cmd.go index 9c6596f79..2008f4f89 100644 --- a/cmd/pipelines/version/cmd.go +++ b/cmd/pipeline/version/cmd.go @@ -15,19 +15,19 @@ package version import ( - "github.com/datarobot/cli/cmd/pipelines/version/get" - "github.com/datarobot/cli/cmd/pipelines/version/list" + "github.com/datarobot/cli/cmd/pipeline/version/get" + "github.com/datarobot/cli/cmd/pipeline/version/list" "github.com/spf13/cobra" ) -// Cmd returns the parent command for `dr pipelines version`. +// Cmd returns the parent command for `dr pipeline version`. func Cmd() *cobra.Command { cmd := &cobra.Command{ Use: "version", Short: "Inspect pipeline versions", Long: `Read-only access to pipeline versions. -Versions are also surfaced inline by ` + "`dr pipelines get`" + `; this group +Versions are also surfaced inline by ` + "`dr pipeline get`" + `; this group provides a paginated list and a single-version detail view.`, } diff --git a/cmd/pipelines/version/cmd_test.go b/cmd/pipeline/version/cmd_test.go similarity index 100% rename from cmd/pipelines/version/cmd_test.go rename to cmd/pipeline/version/cmd_test.go diff --git a/cmd/pipelines/version/get/cmd.go b/cmd/pipeline/version/get/cmd.go similarity index 95% rename from cmd/pipelines/version/get/cmd.go rename to cmd/pipeline/version/get/cmd.go index 5496cd563..a0239a056 100644 --- a/cmd/pipelines/version/get/cmd.go +++ b/cmd/pipeline/version/get/cmd.go @@ -39,8 +39,8 @@ func Cmd() *cobra.Command { Long: `Display the pipeline metadata for a single pipeline version. Example: - dr pipelines version get --pipeline 2 - dr pipelines version get --pipeline 2 --output-format json`, + dr pipeline version get --pipeline 2 + dr pipeline version get --pipeline 2 --output-format json`, Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/version/get/cmd_test.go b/cmd/pipeline/version/get/cmd_test.go similarity index 100% rename from cmd/pipelines/version/get/cmd_test.go rename to cmd/pipeline/version/get/cmd_test.go diff --git a/cmd/pipelines/version/list/cmd.go b/cmd/pipeline/version/list/cmd.go similarity index 92% rename from cmd/pipelines/version/list/cmd.go rename to cmd/pipeline/version/list/cmd.go index e45271198..4f40c693d 100644 --- a/cmd/pipelines/version/list/cmd.go +++ b/cmd/pipeline/version/list/cmd.go @@ -36,8 +36,8 @@ func Cmd() *cobra.Command { Long: `List versions of a pipeline (paginated). Example: - dr pipelines version list --pipeline - dr pipelines version list --pipeline --offset 10 --limit 5 --output-format json`, + dr pipeline version list --pipeline + dr pipeline version list --pipeline --offset 10 --limit 5 --output-format json`, Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, diff --git a/cmd/pipelines/version/list/cmd_test.go b/cmd/pipeline/version/list/cmd_test.go similarity index 100% rename from cmd/pipelines/version/list/cmd_test.go rename to cmd/pipeline/version/list/cmd_test.go diff --git a/cmd/root.go b/cmd/root.go index de13f1bb3..420220416 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,7 +25,7 @@ import ( "github.com/datarobot/cli/cmd/component" "github.com/datarobot/cli/cmd/dependencies" "github.com/datarobot/cli/cmd/dotenv" - "github.com/datarobot/cli/cmd/pipelines" + "github.com/datarobot/cli/cmd/pipeline" "github.com/datarobot/cli/cmd/plugin" "github.com/datarobot/cli/cmd/self" "github.com/datarobot/cli/cmd/start" @@ -218,7 +218,7 @@ func init() { templates.Cmd(), workload.Cmd(), plugin.Cmd(), - pipelines.Cmd(), + pipeline.Cmd(), ) // Discover and register plugin commands diff --git a/docs/commands/README.md b/docs/commands/README.md index 39d8c6809..e8b3650c1 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -41,7 +41,7 @@ These flags are available for all commands: | [`dotenv`](dotenv.md) | Manage environment variables. | | [`self`](self.md) | CLI utility commands (update, version, completion, plugin). | | [`plugin`](plugins.md) | Inspect and manage CLI plugins. | -| [`pipelines`](pipelines.md) | Manage pipelines via the pipelines API (feature-gated). | +| [`pipeline`](pipeline.md) | Manage pipelines via the pipelines API (feature-gated). | | [`dependencies`](dependencies.md) | Check and install template dependencies (advanced). | ### Command tree @@ -272,7 +272,7 @@ For detailed documentation on each command, see: - **[plugin](plugins.md)**—inspect and manage installed CLI plugins (alias: `plugins`). -- **[pipelines](pipelines.md)**—manage AI/ML pipelines orchestrated by Covalent (feature-gated behind `DATAROBOT_CLI_FEATURE_PIPELINES=true`). See the [pipelines reference](pipelines-reference.md) for an exhaustive endpoint mapping. +- **[pipeline](pipeline.md)**—manage AI/ML pipelines orchestrated by Covalent (feature-gated behind `DATAROBOT_CLI_FEATURE_PIPELINE=true`). See the [pipeline reference](pipeline-reference.md) for an exhaustive endpoint mapping. - `create`—upload a Python file to register a new pipeline. - `list`—list pipelines with mode filtering and pagination. - `get`—display full details of a pipeline including all versions. diff --git a/docs/commands/pipeline-reference.md b/docs/commands/pipeline-reference.md new file mode 100644 index 000000000..26d1ca104 --- /dev/null +++ b/docs/commands/pipeline-reference.md @@ -0,0 +1,210 @@ + +# `dr pipeline` command reference + +Complete cross-reference of every `dr pipeline …` subcommand, the +`pipelines-api` endpoint each one calls, sample invocations, and the +inputs (positional args, flags, request body fields) each command +accepts. + +> All commands below assume the `pipelines` feature is enabled +> (`DATAROBOT_CLI_FEATURE_PIPELINE=true`). + +## How to read this document + +- **Method + path** is relative to `/api/v2`. The CLI prefixes the host + from `DATAROBOT_CLI_ENDPOINT` (or `DATAROBOT_ENDPOINT`). +- **Usage** lists the canonical invocation plus common variants. +- **Inputs** names every positional argument and flag the command + accepts. Flags shared by many commands (`--output`, `--scope`, + `--version`, `--from-file`, `--skip-auth`) are described once at the + bottom under "Shared flag semantics". + +--- + +## Pipeline lifecycle + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipeline create` | `POST /pipelines` | `dr pipeline create ./my_pipeline.py`
`dr pipeline create --from-file=./my_pipeline.py`
`dr pipeline create ./my_pipeline.py --description "First draft" --mode draft`
`dr pipeline create --from-file=./my_pipeline.py --output json` | **Positional:** `` (Python file defining a DataRobot pipeline; mutually exclusive with `--from-file`).
**Flags:** `--from-file=`, `--description `, `--mode draft\|locked`, `--output json`. | +| `dr pipeline list` | `GET /pipelines` | `dr pipeline list`
`dr pipeline list --mode draft`
`dr pipeline list --offset 50 --limit 10 --output json` | **Flags:** `--mode draft\|locked`, `--offset `, `--limit `, `--output json`. | +| `dr pipeline get` | `GET /pipelines/{pipeline_id}` | `dr pipeline get `
`dr pipeline get --output json` | **Positional:** `` (required).
**Flags:** `--output json`. | +| `dr pipeline update` | `PATCH /pipelines/{pipeline_id}` | `dr pipeline update ./my_pipeline.py`
`dr pipeline update --from-file=./my_pipeline.py`
`dr pipeline update --from-file=./my_pipeline.py --output json` | **Positional:** `` (required), `` (mutually exclusive with `--from-file`).
**Flags:** `--from-file=`, `--output json`. | +| `dr pipeline delete` | `DELETE /pipelines/{pipeline_id}` | `dr pipeline delete ` | **Positional:** `` (required). | +| `dr pipeline lock` | `PATCH /pipelines/{pipeline_id}/mode` | `dr pipeline lock `
`dr pipeline lock --output json` | **Positional:** `` (required).
**Flags:** `--output json`.
**Body:** none (the API uses absence-of-body as the promote signal). | + +--- + +## Versions + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipeline version list` | `GET /pipelines/{pipeline_id}/versions` | `dr pipeline version list --pipeline `
`dr pipeline version list --pipeline --offset 10 --limit 5 --output json` | **Flags:** `--pipeline ` (required), `--offset `, `--limit `, `--output json`. | +| `dr pipeline version get` | `GET /pipelines/{pipeline_id}/versions/{version_id}` | `dr pipeline version get --pipeline 2`
`dr pipeline version get --pipeline 2 --output json` | **Positional:** `` (positive integer, required).
**Flags:** `--pipeline ` (required), `--output json`. | +| `dr pipeline graph` | `GET /pipelines/{pipeline_id}/graph` (draft)
`GET /pipelines/{pipeline_id}/versions/{version_id}/graph` (locked) | `dr pipeline graph --pipeline ` (draft)
`dr pipeline graph --pipeline --version=2` (locked)
`dr pipeline graph --pipeline --version=2 --output json` | **Flags:** `--pipeline ` (required), `--scope draft\|locked`, `--version `, `--output json`. | + +--- + +## Inputs (`dr pipeline input …`) + +Inputs come in two scopes — **draft** (mutable, no version pinned) and +**locked** (immutable, tied to a frozen version). Scope selection rules +are documented under "Shared flag semantics" below. + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipeline input create` | `POST /pipelines/{id}/inputs` (draft)
`POST /pipelines/{id}/versions/{ver}/inputs` (locked) | `dr pipeline input create --pipeline ./payload.json`
`dr pipeline input create --pipeline --from-file=./payload.json`
`dr pipeline input create --pipeline --version=2 ./payload.json --output json` | **Positional:** `` (JSON object; mutually exclusive with `--from-file`).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--from-file=`, `--output json`.
**Body sent to API:** `{"payload": }`. | +| `dr pipeline input list` | `GET /pipelines/{id}/inputs` (draft)
`GET /pipelines/{id}/versions/{ver}/inputs` (locked) | `dr pipeline input list --pipeline ` (draft)
`dr pipeline input list --pipeline --version=2` (locked)
`dr pipeline input list --pipeline --offset 10 --limit 5 --output json` | **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--offset `, `--limit `, `--output json`. | +| `dr pipeline input get` | `GET /pipelines/{id}/inputs/{input_id}` (draft)
`GET /pipelines/{id}/versions/{ver}/inputs/{input_id}` (locked) | `dr pipeline input get --pipeline `
`dr pipeline input get --pipeline --version=2 --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output json`. | +| `dr pipeline input update` | `PATCH /pipelines/{id}/inputs/{input_id}` (draft only) | `dr pipeline input update --pipeline ./payload.json`
`dr pipeline input update --pipeline --from-file=./payload.json --output json` | **Positional:** `` (required), `` (JSON object; mutually exclusive with `--from-file`).
**Flags:** `--pipeline ` (required), `--from-file=`, `--output json`.
**Body sent to API:** `{"payload": }`. | +| `dr pipeline input delete` | `DELETE /pipelines/{id}/inputs/{input_id}` (draft)
`DELETE /pipelines/{id}/versions/{ver}/inputs/{input_id}` (locked) | `dr pipeline input delete --pipeline `
`dr pipeline input delete --pipeline --version=2 ` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`. | + +--- + +## Runs (`dr pipeline run …`) + +Same draft/locked scope rules as inputs. The wire-level URLs still use +the legacy term `dispatches` / `dispatch_id`, but the CLI's `--output +json` remaps these to `run_id` / `covalent_run_id` so the JSON output +matches the rest of the CLI vocabulary. + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipeline run create` | `POST /pipelines/{id}/dispatches` (draft)
`POST /pipelines/{id}/versions/{ver}/dispatches` (locked) | `dr pipeline run create --pipeline --input `
`dr pipeline run create --pipeline --version=2 --input --output json` | **Flags:** `--pipeline ` (required), `--input ` (required), `--scope`, `--version`, `--output json`.
**Body sent to API:** `{"input_id": ""}`. | +| `dr pipeline run list` | `GET /pipelines/{id}/dispatches` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches` (locked) | `dr pipeline run list --pipeline `
`dr pipeline run list --pipeline --version=2 --output json` | **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--offset `, `--limit `, `--output json`. | +| `dr pipeline run get` | `GET /pipelines/{id}/dispatches/{dispatch_id}` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` (locked) | `dr pipeline run get --pipeline `
`dr pipeline run get --pipeline --version=2 --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output json`. | +| `dr pipeline run status` | `GET /pipelines/{id}/dispatches/{dispatch_id}/status` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}/status` (locked) | `dr pipeline run status --pipeline `
`dr pipeline run status --pipeline --version=2 --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output json`. | +| `dr pipeline run cancel` | `DELETE /pipelines/{id}/dispatches/{dispatch_id}` (draft)
`DELETE /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` (locked) | `dr pipeline run cancel --pipeline `
`dr pipeline run cancel --pipeline --version=2 ` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`. | + +--- + +## Schedules (`dr pipeline schedule …`) + +Schedules are **locked-only** — every verb requires both `--pipeline` and +`--version`. There is no draft scope or `--scope` flag. + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipeline schedule create` | `POST /pipelines/{id}/versions/{ver}/schedules` | `dr pipeline schedule create --pipeline --version=2 --cron "0 * * * *" --input `
`dr pipeline schedule create --pipeline --version=2 --cron "0 9 * * *" --input --timezone America/Los_Angeles`
`… --output json` | **Flags:** `--pipeline ` (required), `--version ` (required, > 0), `--cron ""` (required), `--input ` (required), `--timezone ` (default `UTC`), `--output json`.
**Body sent to API:** `{"cron_expression": "...", "pipeline_input_id": "...", "timezone": "..."}`. | +| `dr pipeline schedule list` | `GET /pipelines/{id}/versions/{ver}/schedules` | `dr pipeline schedule list --pipeline --version=2`
`dr pipeline schedule list --pipeline --version=2 --offset 10 --limit 5 --output json` | **Flags:** `--pipeline ` (required), `--version ` (required, > 0), `--offset `, `--limit `, `--output json`. | +| `dr pipeline schedule get` | `GET /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipeline schedule get --pipeline --version=2 `
`… --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--version ` (required, > 0), `--output json`. | +| `dr pipeline schedule update` | `PATCH /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipeline schedule update --pipeline --version=2 --cron "*/15 * * * *"`
`dr pipeline schedule update --pipeline --version=2 --timezone Europe/Berlin`
`… --cron "0 0 * * *" --timezone UTC --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--version ` (required, > 0), `--cron ""`, `--timezone `, `--output json`. At least one of `--cron`/`--timezone` must be supplied.
**Body sent to API:** `{"cron_expression"?: "...", "timezone"?: "..."}` (only fields you changed). | +| `dr pipeline schedule delete` | `DELETE /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipeline schedule delete --pipeline --version=2 ` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--version ` (required, > 0). | + +--- + +## Execution environments (`dr pipeline environment …`) + +Pipeline execution environments are named, immutable-versioned bags of +pip packages. They live at the top of the pipelines namespace (not +nested under a specific pipeline) and are created/updated independently. +Each `update` appends a new version; older versions can be deleted +individually. + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipeline environment create` | `POST /pipelines/environments` | `dr pipeline environment create --name ml-base --package numpy --package pandas==2.0`
`dr pipeline environment create --name ml-base --package "numpy,pandas==2.0" --description "training base" --output json` | **Flags:** `--name ` (required), `--description `, `--package ` (required, repeatable, also accepts comma-separated values), `--output json`.
**Body sent to API:** `{"name": "...", "description"?: "...", "packages": ["..."]}`. | +| `dr pipeline environment list` | `GET /pipelines/environments` | `dr pipeline environment list`
`dr pipeline environment list --offset 50 --limit 10 --output json` | **Flags:** `--offset `, `--limit `, `--output json`. | +| `dr pipeline environment update` | `PATCH /pipelines/environments/{environment_id}` | `dr pipeline environment update --package scikit-learn`
`dr pipeline environment update --package "scikit-learn,torch" --output json` | **Positional:** `` (required).
**Flags:** `--package ` (required, repeatable, also accepts comma-separated values), `--output json`.
**Body sent to API:** `{"packages": ["..."]}`. | +| `dr pipeline environment delete` | `DELETE /pipelines/environments/{environment_id}` | `dr pipeline environment delete ` | **Positional:** `` (required). | +| `dr pipeline environment version delete` | `DELETE /pipelines/environments/{environment_id}/versions/{version_id}` | `dr pipeline environment version delete --environment 2` | **Positional:** `` (positive integer, required).
**Flags:** `--environment ` (required). | + +> [!NOTE] +> The pipelines-api currently does not expose `GET` endpoints for a +> single environment or for individual versions, so the CLI does not +> ship `environment get` or `environment version get`. The full version +> history is only returned in the `create` and `update` responses. + +--- + +## Shared flag semantics + +### `--scope` / `--version` (inputs, runs, graph) + +The CLI mirrors the API's two URL shapes — `/pipelines/{id}/…` for the +mutable draft and `/pipelines/{id}/versions/{ver}/…` for a locked +version — through a pair of optional flags: + +| Flags supplied | Resolved scope | URL used | +|---|---|---| +| _(none)_ | `draft` | `/pipelines/{id}/…` | +| `--version=N` | `locked` (auto) | `/pipelines/{id}/versions/N/…` | +| `--scope=draft` | `draft` | `/pipelines/{id}/…` | +| `--scope=locked --version=N` | `locked` | `/pipelines/{id}/versions/N/…` | +| `--scope=draft --version=N` | **error** | `--scope=draft cannot be combined with --version` | +| `--scope=locked` (no `--version`) | **error** | `--scope=locked requires --version=` | +| `--scope=garbage` | **error** | `invalid --scope: "garbage" (supported: draft, locked)` | + +### `--from-file` / positional file (create + update verbs) + +`pipelines create`, `pipelines update`, `pipelines input create`, and +`pipelines input update` all accept the input file in two equivalent +ways: + +```bash +dr pipeline create ./my_pipeline.py +dr pipeline create --from-file=./my_pipeline.py +``` + +Exactly one of the two must be supplied; passing both yields +`specify the file either as a positional argument or via --from-file, not both`, +and supplying neither yields `a file path is required …` (or +`a JSON payload file is required …` for input verbs). + +### `--output` + +Every read/write verb that produces a payload accepts `--output json` to +emit the underlying response struct as indented JSON. Any other value +(e.g. `--output yaml`, `--output csv`) is rejected with +`invalid output format: (supported: json)`. + +### `auth` / `--skip-auth` + +All verbs run `auth.EnsureAuthenticatedE` as their `PreRunE`. Pass the +global `--skip-auth` flag (or set `DATAROBOT_CLI_SKIP_AUTH=true`) when +exercising a local API stub that doesn't implement `/version/`. + +--- + +## Quick endpoint lookup + +| API endpoint | CLI command | +|---|---| +| `POST /pipelines` | `dr pipeline create` | +| `GET /pipelines` | `dr pipeline list` | +| `GET /pipelines/{id}` | `dr pipeline get` | +| `PATCH /pipelines/{id}` | `dr pipeline update` | +| `DELETE /pipelines/{id}` | `dr pipeline delete` | +| `PATCH /pipelines/{id}/mode` | `dr pipeline lock` | +| `GET /pipelines/{id}/versions` | `dr pipeline version list` | +| `GET /pipelines/{id}/versions/{ver}` | `dr pipeline version get` | +| `GET /pipelines/{id}/graph` | `dr pipeline graph` (draft) | +| `GET /pipelines/{id}/versions/{ver}/graph` | `dr pipeline graph --version=N` | +| `POST /pipelines/{id}/inputs` | `dr pipeline input create` (draft) | +| `POST /pipelines/{id}/versions/{ver}/inputs` | `dr pipeline input create --version=N` | +| `GET /pipelines/{id}/inputs` | `dr pipeline input list` (draft) | +| `GET /pipelines/{id}/versions/{ver}/inputs` | `dr pipeline input list --version=N` | +| `GET /pipelines/{id}/inputs/{input_id}` | `dr pipeline input get` (draft) | +| `GET /pipelines/{id}/versions/{ver}/inputs/{input_id}` | `dr pipeline input get --version=N` | +| `PATCH /pipelines/{id}/inputs/{input_id}` | `dr pipeline input update` | +| `DELETE /pipelines/{id}/inputs/{input_id}` | `dr pipeline input delete` (draft) | +| `DELETE /pipelines/{id}/versions/{ver}/inputs/{input_id}` | `dr pipeline input delete --version=N` | +| `POST /pipelines/{id}/dispatches` | `dr pipeline run create` (draft) | +| `POST /pipelines/{id}/versions/{ver}/dispatches` | `dr pipeline run create --version=N` | +| `GET /pipelines/{id}/dispatches` | `dr pipeline run list` (draft) | +| `GET /pipelines/{id}/versions/{ver}/dispatches` | `dr pipeline run list --version=N` | +| `GET /pipelines/{id}/dispatches/{dispatch_id}` | `dr pipeline run get` (draft) | +| `GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` | `dr pipeline run get --version=N` | +| `GET /pipelines/{id}/dispatches/{dispatch_id}/status` | `dr pipeline run status` (draft) | +| `GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}/status` | `dr pipeline run status --version=N` | +| `DELETE /pipelines/{id}/dispatches/{dispatch_id}` | `dr pipeline run cancel` (draft) | +| `DELETE /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` | `dr pipeline run cancel --version=N` | +| `POST /pipelines/{id}/versions/{ver}/schedules` | `dr pipeline schedule create` | +| `GET /pipelines/{id}/versions/{ver}/schedules` | `dr pipeline schedule list` | +| `GET /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipeline schedule get` | +| `PATCH /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipeline schedule update` | +| `DELETE /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipeline schedule delete` | +| `POST /pipelines/environments` | `dr pipeline environment create` | +| `GET /pipelines/environments` | `dr pipeline environment list` | +| `PATCH /pipelines/environments/{environment_id}` | `dr pipeline environment update` | +| `DELETE /pipelines/environments/{environment_id}` | `dr pipeline environment delete` | +| `DELETE /pipelines/environments/{environment_id}/versions/{version_id}` | `dr pipeline environment version delete` | diff --git a/docs/commands/pipelines.md b/docs/commands/pipeline.md similarity index 70% rename from docs/commands/pipelines.md rename to docs/commands/pipeline.md index 5cc80b4b7..eedc67322 100644 --- a/docs/commands/pipelines.md +++ b/docs/commands/pipeline.md @@ -1,7 +1,7 @@ -# `dr pipelines` - Pipelines API management +# `dr pipeline` - Pipelines API management Manage AI/ML pipelines orchestrated by Covalent through the DataRobot -pipelines service. The `dr pipelines` group is a thin CLI wrapper over +pipelines service. The `dr pipeline` group is a thin CLI wrapper over the pipelines REST API: every subcommand maps directly to a single endpoint. @@ -9,25 +9,25 @@ endpoint. ```bash # List pipelines registered with the pipelines service -dr pipelines list +dr pipeline list # Register a new draft pipeline by uploading a DataRobot pipeline source file -dr pipelines create ./my_pipeline.py --description "First draft" +dr pipeline create ./my_pipeline.py --description "First draft" # Append a new version after editing the file -dr pipelines update ./my_pipeline.py +dr pipeline update ./my_pipeline.py # Promote the draft to locked when you are happy with it -dr pipelines lock +dr pipeline lock # Cancel a stuck run -dr pipelines run cancel --pipeline +dr pipeline run cancel --pipeline ``` > [!NOTE] -> The `pipelines` command is currently behind a feature gate. Enable it -> by exporting `DATAROBOT_CLI_FEATURE_PIPELINES=true` before running any -> `dr pipelines` subcommand. See +> The `pipeline` command is currently behind a feature gate. Enable it +> by exporting `DATAROBOT_CLI_FEATURE_PIPELINE=true` before running any +> `dr pipeline` subcommand. See > [Feature gates](../development/feature-gates.md) for details. > [!NOTE] @@ -38,13 +38,13 @@ dr pipelines run cancel --pipeline ## Synopsis ```bash -dr pipelines [subcommand] [flags] +dr pipeline [subcommand] [flags] ``` ## Description A **pipeline** is a versioned bundle of Python source defining a DataRobot pipeline (one or more tasks). Each -top-level `dr pipelines` subcommand operates on one of four resources: +top-level `dr pipeline` subcommand operates on one of four resources: - the **pipeline** itself (create, list, get, update, delete, lock), - pipeline **versions** (list, get, graph), @@ -70,24 +70,24 @@ to a frozen version) — selected via the shared `--scope` and `--version` flags. Schedules are locked-only. > For an exhaustive table mapping every CLI command to its API endpoint, -> see [pipelines-reference.md](pipelines-reference.md). +> see [pipeline-reference.md](pipeline-reference.md). ## Command groups | Group | Endpoint(s) | Purpose | |------------------------------|--------------------------------------------------|--------------------------------------------------------| -| `dr pipelines create` | `POST /api/v2/pipelines` | Upload a Python file to register a new pipeline. | -| `dr pipelines list` | `GET /api/v2/pipelines` | Paginated list with mode filtering. | -| `dr pipelines get` | `GET /api/v2/pipelines/{id}` | Pipeline detail including all versions. | -| `dr pipelines update` | `PATCH /api/v2/pipelines/{id}` | Re-upload a file to append a new version. | -| `dr pipelines delete` | `DELETE /api/v2/pipelines/{id}` | Remove a pipeline and all of its versions. | -| `dr pipelines lock` | `PATCH /api/v2/pipelines/{id}/mode` | Promote a draft to locked mode. | -| `dr pipelines version …` | `…/versions[/{ver}]` | Inspect pipeline versions. | -| `dr pipelines graph` | `…/graph` (draft) or `…/versions/{ver}/graph` | Render the pipeline/task DAG. | -| `dr pipelines input …` | `…/inputs` and `…/inputs/{input_id}` | Manage JSON payloads for runs. | -| `dr pipelines run …` | `…/dispatches` and `…/dispatches/{dispatch_id}` | Trigger, inspect, and cancel runs. | -| `dr pipelines schedule …` | `…/versions/{ver}/schedules` | Manage recurring (cron) runs on locked versions. | -| `dr pipelines environment …` | `/api/v2/pipelines/environments[/...]` | Manage named, versioned pip-package execution environments. | +| `dr pipeline create` | `POST /api/v2/pipelines` | Upload a Python file to register a new pipeline. | +| `dr pipeline list` | `GET /api/v2/pipelines` | Paginated list with mode filtering. | +| `dr pipeline get` | `GET /api/v2/pipelines/{id}` | Pipeline detail including all versions. | +| `dr pipeline update` | `PATCH /api/v2/pipelines/{id}` | Re-upload a file to append a new version. | +| `dr pipeline delete` | `DELETE /api/v2/pipelines/{id}` | Remove a pipeline and all of its versions. | +| `dr pipeline lock` | `PATCH /api/v2/pipelines/{id}/mode` | Promote a draft to locked mode. | +| `dr pipeline version …` | `…/versions[/{ver}]` | Inspect pipeline versions. | +| `dr pipeline graph` | `…/graph` (draft) or `…/versions/{ver}/graph` | Render the pipeline/task DAG. | +| `dr pipeline input …` | `…/inputs` and `…/inputs/{input_id}` | Manage JSON payloads for runs. | +| `dr pipeline run …` | `…/dispatches` and `…/dispatches/{dispatch_id}` | Trigger, inspect, and cancel runs. | +| `dr pipeline schedule …` | `…/versions/{ver}/schedules` | Manage recurring (cron) runs on locked versions. | +| `dr pipeline environment …` | `/api/v2/pipelines/environments[/...]` | Manage named, versioned pip-package execution environments. | ## Subcommands @@ -98,8 +98,8 @@ register a new pipeline. The pipeline name is extracted from the file and used a pipeline name. ```bash -dr pipelines create [flags] -dr pipelines create --from-file= [flags] +dr pipeline create [flags] +dr pipeline create --from-file= [flags] ``` **Arguments:** @@ -119,7 +119,7 @@ dr pipelines create --from-file= [flags] **Example:** ```bash -$ dr pipelines create ./confluence_to_vdb.py --description "test" +$ dr pipeline create ./confluence_to_vdb.py --description "test" Pipeline ID: 683c2a1b4f8e1a2b3c4d5e6f Name: confluence_to_vdb Version: 1 @@ -135,7 +135,7 @@ List pipelines registered with the pipelines service, with optional mode filtering and pagination. ```bash -dr pipelines list [flags] +dr pipeline list [flags] ``` **Flags:** @@ -148,7 +148,7 @@ dr pipelines list [flags] **Example:** ```bash -$ dr pipelines list +$ dr pipeline list Showing 1 of 1 (offset=0 limit=50) ID NAME MODE ACTIVE VERSION UPDATED @@ -169,12 +169,12 @@ ID NAME MODE ACTIVE VERSION Display full details of a single pipeline including all versions. ```bash -dr pipelines get [flags] +dr pipeline get [flags] ``` **Arguments:** -- `` — the ObjectId returned by `create` / shown in `pipelines list`. +- `` — the ObjectId returned by `create` / shown in `pipeline list`. **Flags:** @@ -183,7 +183,7 @@ dr pipelines get [flags] **Example:** ```bash -$ dr pipelines get 683c2a1b4f8e1a2b3c4d5e6f +$ dr pipeline get 683c2a1b4f8e1a2b3c4d5e6f ID: 683c2a1b4f8e1a2b3c4d5e6f Name: confluence_to_vdb Description: test @@ -212,8 +212,8 @@ Re-upload a Python file to update a draft pipeline. A new version is appended. ```bash -dr pipelines update [flags] -dr pipelines update --from-file= [flags] +dr pipeline update [flags] +dr pipeline update --from-file= [flags] ``` **Arguments:** @@ -239,7 +239,7 @@ dr pipelines update --from-file= [flags] Delete a pipeline and all of its versions. ```bash -dr pipelines delete +dr pipeline delete ``` **Arguments:** @@ -249,7 +249,7 @@ dr pipelines delete **Example:** ```bash -$ dr pipelines delete 683c2a1b4f8e1a2b3c4d5e6f +$ dr pipeline delete 683c2a1b4f8e1a2b3c4d5e6f Deleted pipeline: 683c2a1b4f8e1a2b3c4d5e6f ``` @@ -263,7 +263,7 @@ no longer be updated and locked runs/inputs/schedules become valid. ```bash -dr pipelines lock [flags] +dr pipeline lock [flags] ``` **Arguments:** @@ -277,7 +277,7 @@ dr pipelines lock [flags] **Example:** ```bash -$ dr pipelines lock 683c2a1b4f8e1a2b3c4d5e6f +$ dr pipeline lock 683c2a1b4f8e1a2b3c4d5e6f Pipeline ID: 683c2a1b4f8e1a2b3c4d5e6f Name: confluence_to_vdb Mode: locked @@ -292,12 +292,12 @@ Locked: 2026-04-28T12:30:00Z Read-only access to pipeline versions. ```bash -dr pipelines version list --pipeline [--offset N] [--limit N] [--output json] -dr pipelines version get --pipeline [--output json] +dr pipeline version list --pipeline [--offset N] [--limit N] [--output json] +dr pipeline version get --pipeline [--output json] ``` `version list` returns the same data that's shown inline by -`pipelines get`, but in a paginated stand-alone view. `version get` +`pipeline get`, but in a paginated stand-alone view. `version get` shows a single version in detail (pipeline, tasks, Python version, creation timestamp, and any error detail). @@ -307,11 +307,11 @@ Display the pipeline/task DAG for a pipeline as either a JSON payload (for visualisation tooling) or a human-readable summary. ```bash -dr pipelines graph --pipeline # draft graph -dr pipelines graph --pipeline --version=N # locked-version graph -dr pipelines graph --pipeline --scope=draft # explicit draft -dr pipelines graph --pipeline --scope=locked --version=N -dr pipelines graph --pipeline --output json # JSON payload +dr pipeline graph --pipeline # draft graph +dr pipeline graph --pipeline --version=N # locked-version graph +dr pipeline graph --pipeline --scope=draft # explicit draft +dr pipeline graph --pipeline --scope=locked --version=N +dr pipeline graph --pipeline --output json # JSON payload ``` The human view prints the pipeline header followed by `Nodes (N):` and @@ -328,12 +328,12 @@ below for the full flag truth table. Manage JSON payloads that drive a run. ```bash -dr pipelines input create --pipeline # draft scope -dr pipelines input create --pipeline --version=N # locked scope -dr pipelines input list --pipeline [--scope|--version] [--offset N] [--limit N] -dr pipelines input get --pipeline [--scope|--version] -dr pipelines input update --pipeline # draft only -dr pipelines input delete --pipeline [--scope|--version] +dr pipeline input create --pipeline # draft scope +dr pipeline input create --pipeline --version=N # locked scope +dr pipeline input list --pipeline [--scope|--version] [--offset N] [--limit N] +dr pipeline input get --pipeline [--scope|--version] +dr pipeline input update --pipeline # draft only +dr pipeline input delete --pipeline [--scope|--version] ``` - The payload file must contain a JSON object. The CLI wraps it in @@ -346,12 +346,12 @@ dr pipelines input delete --pipeline [--scope|--version] Trigger, inspect, and cancel pipeline executions. ```bash -dr pipelines run create --pipeline --input # draft -dr pipelines run create --pipeline --version=N --input # locked -dr pipelines run list --pipeline [--scope|--version] -dr pipelines run get --pipeline [--scope|--version] -dr pipelines run status --pipeline [--scope|--version] -dr pipelines run cancel --pipeline [--scope|--version] +dr pipeline run create --pipeline --input # draft +dr pipeline run create --pipeline --version=N --input # locked +dr pipeline run list --pipeline [--scope|--version] +dr pipeline run get --pipeline [--scope|--version] +dr pipeline run status --pipeline [--scope|--version] +dr pipeline run cancel --pipeline [--scope|--version] ``` `run status` is a lighter-weight call than `run get` — @@ -367,12 +367,12 @@ Manage recurring (cron) runs on locked versions only. Both `--pipeline` and `--version` are required for every verb. ```bash -dr pipelines schedule create --pipeline --version=N \ +dr pipeline schedule create --pipeline --version=N \ --cron "0 * * * *" --input [--timezone UTC] -dr pipelines schedule list --pipeline --version=N [--offset N] [--limit N] -dr pipelines schedule get --pipeline --version=N -dr pipelines schedule update --pipeline --version=N [--cron "*/15 * * * *"] [--timezone Europe/Berlin] -dr pipelines schedule delete --pipeline --version=N +dr pipeline schedule list --pipeline --version=N [--offset N] [--limit N] +dr pipeline schedule get --pipeline --version=N +dr pipeline schedule update --pipeline --version=N [--cron "*/15 * * * *"] [--timezone Europe/Berlin] +dr pipeline schedule delete --pipeline --version=N ``` `schedule update` requires at least one of `--cron` or `--timezone`. @@ -385,11 +385,11 @@ live at the top of the pipelines namespace (not nested under a specific pipeline) and have their own lifecycle. ```bash -dr pipelines environment create --name --package [--package ] ... -dr pipelines environment list [--offset N] [--limit N] [--output json] -dr pipelines environment update --package [...] -dr pipelines environment delete -dr pipelines environment version delete --environment +dr pipeline environment create --name --package [--package ] ... +dr pipeline environment list [--offset N] [--limit N] [--output json] +dr pipeline environment update --package [...] +dr pipeline environment delete +dr pipeline environment version delete --environment ``` `create` registers a new environment with an initial v1 containing the @@ -403,9 +403,9 @@ older version without touching the parent. `--package` is repeatable and also accepts comma-separated values: ```bash -dr pipelines environment create --name ml-base \ +dr pipeline environment create --name ml-base \ --package numpy --package pandas==2.0 -dr pipelines environment create --name ml-base \ +dr pipeline environment create --name ml-base \ --package "numpy,pandas==2.0,scikit-learn" ``` @@ -438,13 +438,13 @@ Schedules do not accept `--scope`; they are locked-only and require ### `--from-file` / positional file -`pipelines create`, `pipelines update`, `pipelines input create`, and -`pipelines input update` all accept the input file in two equivalent +`pipeline create`, `pipeline update`, `pipeline input create`, and +`pipeline input update` all accept the input file in two equivalent ways: ```bash -dr pipelines create ./my_pipeline.py -dr pipelines create --from-file=./my_pipeline.py +dr pipeline create ./my_pipeline.py +dr pipeline create --from-file=./my_pipeline.py ``` Exactly one of the two must be supplied; passing both yields @@ -460,7 +460,7 @@ value is rejected with `invalid output format: (supported: json)`. ```bash -dr pipelines list --output json | jq '.items[].pipeline_id' +dr pipeline list --output json | jq '.items[].pipeline_id' ``` ### Global options @@ -476,19 +476,19 @@ While iterating against a locally running pipelines-api (default port verification using the prefixed environment variables: ```bash -export DATAROBOT_CLI_FEATURE_PIPELINES=true +export DATAROBOT_CLI_FEATURE_PIPELINE=true export DATAROBOT_CLI_ENDPOINT=http://localhost:8100/api/v2 export DATAROBOT_CLI_TOKEN=local export DATAROBOT_CLI_SKIP_AUTH=true -./dist/dr pipelines list +./dist/dr pipeline list ``` Why each variable matters: | Variable | Purpose | |-----------------------------------|----------------------------------------------------------------------------------------------------------| -| `DATAROBOT_CLI_FEATURE_PIPELINES` | Reveals the feature-gated `pipelines` command group. | +| `DATAROBOT_CLI_FEATURE_PIPELINE` | Reveals the feature-gated `pipeline` command group. | | `DATAROBOT_CLI_ENDPOINT` | Auto-bound to viper's `endpoint` key via `SetEnvPrefix("DATAROBOT_CLI") + AutomaticEnv()` in the CLI. | | `DATAROBOT_CLI_TOKEN` | Same prefix story — bound to viper's `token` key for outbound `Authorization: Bearer` headers. | | `DATAROBOT_CLI_SKIP_AUTH` | Skips token verification against `/version/`, which the local stub does not implement. | @@ -503,7 +503,7 @@ names during local development. > Use `--debug` to see the full request URL, headers, and body the CLI > sends. Logs are written to `.dr-tui-debug.log`. -For a step-by-step walkthrough of how `dr pipelines list` was wired up, +For a step-by-step walkthrough of how `dr pipeline list` was wired up, see [Adding a command](../development/adding-a-command.md). ## Examples @@ -512,37 +512,37 @@ see [Adding a command](../development/adding-a-command.md). ```bash # Register a draft, append a version, lock it, then delete it -dr pipelines create ./my_pipeline.py --description "Initial draft" -dr pipelines update ./my_pipeline.py -dr pipelines lock -dr pipelines delete +dr pipeline create ./my_pipeline.py --description "Initial draft" +dr pipeline update ./my_pipeline.py +dr pipeline lock +dr pipeline delete ``` ### Inspect versions and graph ```bash -dr pipelines version list --pipeline -dr pipelines version get --pipeline 2 -dr pipelines graph --pipeline --version=2 --output json +dr pipeline version list --pipeline +dr pipeline version get --pipeline 2 +dr pipeline graph --pipeline --version=2 --output json ``` ### Trigger a run ```bash # 1. Register a JSON input on the draft scope -dr pipelines input create --pipeline ./input.json +dr pipeline input create --pipeline ./input.json # 2. Trigger a run with that input -dr pipelines run create --pipeline --input +dr pipeline run create --pipeline --input # 3. Poll status until it reaches a terminal state -dr pipelines run status --pipeline +dr pipeline run status --pipeline ``` ### Schedule a recurring run on a locked version ```bash -dr pipelines schedule create \ +dr pipeline schedule create \ --pipeline --version=2 \ --cron "0 */6 * * *" --input --timezone America/Los_Angeles ``` @@ -551,7 +551,7 @@ dr pipelines schedule create \ ```bash # All UUIDs of locked pipelines, one per line -dr pipelines list --mode locked --output json | jq -r '.items[].pipeline_id' +dr pipeline list --mode locked --output json | jq -r '.items[].pipeline_id' ``` ## Error handling @@ -571,7 +571,7 @@ exits 0, so a no-op delete won't dump usage at the user. ## See also -- [Pipelines reference](pipelines-reference.md) — exhaustive table +- [Pipelines reference](pipeline-reference.md) — exhaustive table mapping every CLI command to its API endpoint, usage variants, and inputs. - [Authentication](auth.md) — how `dr auth login` and `--skip-auth` @@ -581,4 +581,4 @@ exits 0, so a no-op delete won't dump usage at the user. - [Adding a command](../development/adding-a-command.md) — how the pipelines verbs were built. - [Feature gates](../development/feature-gates.md) — flipping - `DATAROBOT_CLI_FEATURE_PIPELINES` on and off. + `DATAROBOT_CLI_FEATURE_PIPELINE` on and off. diff --git a/docs/commands/pipelines-reference.md b/docs/commands/pipelines-reference.md deleted file mode 100644 index d50b5ee0f..000000000 --- a/docs/commands/pipelines-reference.md +++ /dev/null @@ -1,210 +0,0 @@ - -# `dr pipelines` command reference - -Complete cross-reference of every `dr pipelines …` subcommand, the -`pipelines-api` endpoint each one calls, sample invocations, and the -inputs (positional args, flags, request body fields) each command -accepts. - -> All commands below assume the `pipelines` feature is enabled -> (`DATAROBOT_CLI_FEATURE_PIPELINES=true`). - -## How to read this document - -- **Method + path** is relative to `/api/v2`. The CLI prefixes the host - from `DATAROBOT_CLI_ENDPOINT` (or `DATAROBOT_ENDPOINT`). -- **Usage** lists the canonical invocation plus common variants. -- **Inputs** names every positional argument and flag the command - accepts. Flags shared by many commands (`--output`, `--scope`, - `--version`, `--from-file`, `--skip-auth`) are described once at the - bottom under "Shared flag semantics". - ---- - -## Pipeline lifecycle - -| Command | API endpoint | Usage | Inputs | -|---|---|---|---| -| `dr pipelines create` | `POST /pipelines` | `dr pipelines create ./my_pipeline.py`
`dr pipelines create --from-file=./my_pipeline.py`
`dr pipelines create ./my_pipeline.py --description "First draft" --mode draft`
`dr pipelines create --from-file=./my_pipeline.py --output json` | **Positional:** `` (Python file defining a DataRobot pipeline; mutually exclusive with `--from-file`).
**Flags:** `--from-file=`, `--description `, `--mode draft\|locked`, `--output json`. | -| `dr pipelines list` | `GET /pipelines` | `dr pipelines list`
`dr pipelines list --mode draft`
`dr pipelines list --offset 50 --limit 10 --output json` | **Flags:** `--mode draft\|locked`, `--offset `, `--limit `, `--output json`. | -| `dr pipelines get` | `GET /pipelines/{pipeline_id}` | `dr pipelines get `
`dr pipelines get --output json` | **Positional:** `` (required).
**Flags:** `--output json`. | -| `dr pipelines update` | `PATCH /pipelines/{pipeline_id}` | `dr pipelines update ./my_pipeline.py`
`dr pipelines update --from-file=./my_pipeline.py`
`dr pipelines update --from-file=./my_pipeline.py --output json` | **Positional:** `` (required), `` (mutually exclusive with `--from-file`).
**Flags:** `--from-file=`, `--output json`. | -| `dr pipelines delete` | `DELETE /pipelines/{pipeline_id}` | `dr pipelines delete ` | **Positional:** `` (required). | -| `dr pipelines lock` | `PATCH /pipelines/{pipeline_id}/mode` | `dr pipelines lock `
`dr pipelines lock --output json` | **Positional:** `` (required).
**Flags:** `--output json`.
**Body:** none (the API uses absence-of-body as the promote signal). | - ---- - -## Versions - -| Command | API endpoint | Usage | Inputs | -|---|---|---|---| -| `dr pipelines version list` | `GET /pipelines/{pipeline_id}/versions` | `dr pipelines version list --pipeline `
`dr pipelines version list --pipeline --offset 10 --limit 5 --output json` | **Flags:** `--pipeline ` (required), `--offset `, `--limit `, `--output json`. | -| `dr pipelines version get` | `GET /pipelines/{pipeline_id}/versions/{version_id}` | `dr pipelines version get --pipeline 2`
`dr pipelines version get --pipeline 2 --output json` | **Positional:** `` (positive integer, required).
**Flags:** `--pipeline ` (required), `--output json`. | -| `dr pipelines graph` | `GET /pipelines/{pipeline_id}/graph` (draft)
`GET /pipelines/{pipeline_id}/versions/{version_id}/graph` (locked) | `dr pipelines graph --pipeline ` (draft)
`dr pipelines graph --pipeline --version=2` (locked)
`dr pipelines graph --pipeline --version=2 --output json` | **Flags:** `--pipeline ` (required), `--scope draft\|locked`, `--version `, `--output json`. | - ---- - -## Inputs (`dr pipelines input …`) - -Inputs come in two scopes — **draft** (mutable, no version pinned) and -**locked** (immutable, tied to a frozen version). Scope selection rules -are documented under "Shared flag semantics" below. - -| Command | API endpoint | Usage | Inputs | -|---|---|---|---| -| `dr pipelines input create` | `POST /pipelines/{id}/inputs` (draft)
`POST /pipelines/{id}/versions/{ver}/inputs` (locked) | `dr pipelines input create --pipeline ./payload.json`
`dr pipelines input create --pipeline --from-file=./payload.json`
`dr pipelines input create --pipeline --version=2 ./payload.json --output json` | **Positional:** `` (JSON object; mutually exclusive with `--from-file`).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--from-file=`, `--output json`.
**Body sent to API:** `{"payload": }`. | -| `dr pipelines input list` | `GET /pipelines/{id}/inputs` (draft)
`GET /pipelines/{id}/versions/{ver}/inputs` (locked) | `dr pipelines input list --pipeline ` (draft)
`dr pipelines input list --pipeline --version=2` (locked)
`dr pipelines input list --pipeline --offset 10 --limit 5 --output json` | **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--offset `, `--limit `, `--output json`. | -| `dr pipelines input get` | `GET /pipelines/{id}/inputs/{input_id}` (draft)
`GET /pipelines/{id}/versions/{ver}/inputs/{input_id}` (locked) | `dr pipelines input get --pipeline `
`dr pipelines input get --pipeline --version=2 --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output json`. | -| `dr pipelines input update` | `PATCH /pipelines/{id}/inputs/{input_id}` (draft only) | `dr pipelines input update --pipeline ./payload.json`
`dr pipelines input update --pipeline --from-file=./payload.json --output json` | **Positional:** `` (required), `` (JSON object; mutually exclusive with `--from-file`).
**Flags:** `--pipeline ` (required), `--from-file=`, `--output json`.
**Body sent to API:** `{"payload": }`. | -| `dr pipelines input delete` | `DELETE /pipelines/{id}/inputs/{input_id}` (draft)
`DELETE /pipelines/{id}/versions/{ver}/inputs/{input_id}` (locked) | `dr pipelines input delete --pipeline `
`dr pipelines input delete --pipeline --version=2 ` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`. | - ---- - -## Runs (`dr pipelines run …`) - -Same draft/locked scope rules as inputs. The wire-level URLs still use -the legacy term `dispatches` / `dispatch_id`, but the CLI's `--output -json` remaps these to `run_id` / `covalent_run_id` so the JSON output -matches the rest of the CLI vocabulary. - -| Command | API endpoint | Usage | Inputs | -|---|---|---|---| -| `dr pipelines run create` | `POST /pipelines/{id}/dispatches` (draft)
`POST /pipelines/{id}/versions/{ver}/dispatches` (locked) | `dr pipelines run create --pipeline --input `
`dr pipelines run create --pipeline --version=2 --input --output json` | **Flags:** `--pipeline ` (required), `--input ` (required), `--scope`, `--version`, `--output json`.
**Body sent to API:** `{"input_id": ""}`. | -| `dr pipelines run list` | `GET /pipelines/{id}/dispatches` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches` (locked) | `dr pipelines run list --pipeline `
`dr pipelines run list --pipeline --version=2 --output json` | **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--offset `, `--limit `, `--output json`. | -| `dr pipelines run get` | `GET /pipelines/{id}/dispatches/{dispatch_id}` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` (locked) | `dr pipelines run get --pipeline `
`dr pipelines run get --pipeline --version=2 --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output json`. | -| `dr pipelines run status` | `GET /pipelines/{id}/dispatches/{dispatch_id}/status` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}/status` (locked) | `dr pipelines run status --pipeline `
`dr pipelines run status --pipeline --version=2 --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output json`. | -| `dr pipelines run cancel` | `DELETE /pipelines/{id}/dispatches/{dispatch_id}` (draft)
`DELETE /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` (locked) | `dr pipelines run cancel --pipeline `
`dr pipelines run cancel --pipeline --version=2 ` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--scope`, `--version`. | - ---- - -## Schedules (`dr pipelines schedule …`) - -Schedules are **locked-only** — every verb requires both `--pipeline` and -`--version`. There is no draft scope or `--scope` flag. - -| Command | API endpoint | Usage | Inputs | -|---|---|---|---| -| `dr pipelines schedule create` | `POST /pipelines/{id}/versions/{ver}/schedules` | `dr pipelines schedule create --pipeline --version=2 --cron "0 * * * *" --input `
`dr pipelines schedule create --pipeline --version=2 --cron "0 9 * * *" --input --timezone America/Los_Angeles`
`… --output json` | **Flags:** `--pipeline ` (required), `--version ` (required, > 0), `--cron ""` (required), `--input ` (required), `--timezone ` (default `UTC`), `--output json`.
**Body sent to API:** `{"cron_expression": "...", "pipeline_input_id": "...", "timezone": "..."}`. | -| `dr pipelines schedule list` | `GET /pipelines/{id}/versions/{ver}/schedules` | `dr pipelines schedule list --pipeline --version=2`
`dr pipelines schedule list --pipeline --version=2 --offset 10 --limit 5 --output json` | **Flags:** `--pipeline ` (required), `--version ` (required, > 0), `--offset `, `--limit `, `--output json`. | -| `dr pipelines schedule get` | `GET /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipelines schedule get --pipeline --version=2 `
`… --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--version ` (required, > 0), `--output json`. | -| `dr pipelines schedule update` | `PATCH /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipelines schedule update --pipeline --version=2 --cron "*/15 * * * *"`
`dr pipelines schedule update --pipeline --version=2 --timezone Europe/Berlin`
`… --cron "0 0 * * *" --timezone UTC --output json` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--version ` (required, > 0), `--cron ""`, `--timezone `, `--output json`. At least one of `--cron`/`--timezone` must be supplied.
**Body sent to API:** `{"cron_expression"?: "...", "timezone"?: "..."}` (only fields you changed). | -| `dr pipelines schedule delete` | `DELETE /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipelines schedule delete --pipeline --version=2 ` | **Positional:** `` (required).
**Flags:** `--pipeline ` (required), `--version ` (required, > 0). | - ---- - -## Execution environments (`dr pipelines environment …`) - -Pipeline execution environments are named, immutable-versioned bags of -pip packages. They live at the top of the pipelines namespace (not -nested under a specific pipeline) and are created/updated independently. -Each `update` appends a new version; older versions can be deleted -individually. - -| Command | API endpoint | Usage | Inputs | -|---|---|---|---| -| `dr pipelines environment create` | `POST /pipelines/environments` | `dr pipelines environment create --name ml-base --package numpy --package pandas==2.0`
`dr pipelines environment create --name ml-base --package "numpy,pandas==2.0" --description "training base" --output json` | **Flags:** `--name ` (required), `--description `, `--package ` (required, repeatable, also accepts comma-separated values), `--output json`.
**Body sent to API:** `{"name": "...", "description"?: "...", "packages": ["..."]}`. | -| `dr pipelines environment list` | `GET /pipelines/environments` | `dr pipelines environment list`
`dr pipelines environment list --offset 50 --limit 10 --output json` | **Flags:** `--offset `, `--limit `, `--output json`. | -| `dr pipelines environment update` | `PATCH /pipelines/environments/{environment_id}` | `dr pipelines environment update --package scikit-learn`
`dr pipelines environment update --package "scikit-learn,torch" --output json` | **Positional:** `` (required).
**Flags:** `--package ` (required, repeatable, also accepts comma-separated values), `--output json`.
**Body sent to API:** `{"packages": ["..."]}`. | -| `dr pipelines environment delete` | `DELETE /pipelines/environments/{environment_id}` | `dr pipelines environment delete ` | **Positional:** `` (required). | -| `dr pipelines environment version delete` | `DELETE /pipelines/environments/{environment_id}/versions/{version_id}` | `dr pipelines environment version delete --environment 2` | **Positional:** `` (positive integer, required).
**Flags:** `--environment ` (required). | - -> [!NOTE] -> The pipelines-api currently does not expose `GET` endpoints for a -> single environment or for individual versions, so the CLI does not -> ship `environment get` or `environment version get`. The full version -> history is only returned in the `create` and `update` responses. - ---- - -## Shared flag semantics - -### `--scope` / `--version` (inputs, runs, graph) - -The CLI mirrors the API's two URL shapes — `/pipelines/{id}/…` for the -mutable draft and `/pipelines/{id}/versions/{ver}/…` for a locked -version — through a pair of optional flags: - -| Flags supplied | Resolved scope | URL used | -|---|---|---| -| _(none)_ | `draft` | `/pipelines/{id}/…` | -| `--version=N` | `locked` (auto) | `/pipelines/{id}/versions/N/…` | -| `--scope=draft` | `draft` | `/pipelines/{id}/…` | -| `--scope=locked --version=N` | `locked` | `/pipelines/{id}/versions/N/…` | -| `--scope=draft --version=N` | **error** | `--scope=draft cannot be combined with --version` | -| `--scope=locked` (no `--version`) | **error** | `--scope=locked requires --version=` | -| `--scope=garbage` | **error** | `invalid --scope: "garbage" (supported: draft, locked)` | - -### `--from-file` / positional file (create + update verbs) - -`pipelines create`, `pipelines update`, `pipelines input create`, and -`pipelines input update` all accept the input file in two equivalent -ways: - -```bash -dr pipelines create ./my_pipeline.py -dr pipelines create --from-file=./my_pipeline.py -``` - -Exactly one of the two must be supplied; passing both yields -`specify the file either as a positional argument or via --from-file, not both`, -and supplying neither yields `a file path is required …` (or -`a JSON payload file is required …` for input verbs). - -### `--output` - -Every read/write verb that produces a payload accepts `--output json` to -emit the underlying response struct as indented JSON. Any other value -(e.g. `--output yaml`, `--output csv`) is rejected with -`invalid output format: (supported: json)`. - -### `auth` / `--skip-auth` - -All verbs run `auth.EnsureAuthenticatedE` as their `PreRunE`. Pass the -global `--skip-auth` flag (or set `DATAROBOT_CLI_SKIP_AUTH=true`) when -exercising a local API stub that doesn't implement `/version/`. - ---- - -## Quick endpoint lookup - -| API endpoint | CLI command | -|---|---| -| `POST /pipelines` | `dr pipelines create` | -| `GET /pipelines` | `dr pipelines list` | -| `GET /pipelines/{id}` | `dr pipelines get` | -| `PATCH /pipelines/{id}` | `dr pipelines update` | -| `DELETE /pipelines/{id}` | `dr pipelines delete` | -| `PATCH /pipelines/{id}/mode` | `dr pipelines lock` | -| `GET /pipelines/{id}/versions` | `dr pipelines version list` | -| `GET /pipelines/{id}/versions/{ver}` | `dr pipelines version get` | -| `GET /pipelines/{id}/graph` | `dr pipelines graph` (draft) | -| `GET /pipelines/{id}/versions/{ver}/graph` | `dr pipelines graph --version=N` | -| `POST /pipelines/{id}/inputs` | `dr pipelines input create` (draft) | -| `POST /pipelines/{id}/versions/{ver}/inputs` | `dr pipelines input create --version=N` | -| `GET /pipelines/{id}/inputs` | `dr pipelines input list` (draft) | -| `GET /pipelines/{id}/versions/{ver}/inputs` | `dr pipelines input list --version=N` | -| `GET /pipelines/{id}/inputs/{input_id}` | `dr pipelines input get` (draft) | -| `GET /pipelines/{id}/versions/{ver}/inputs/{input_id}` | `dr pipelines input get --version=N` | -| `PATCH /pipelines/{id}/inputs/{input_id}` | `dr pipelines input update` | -| `DELETE /pipelines/{id}/inputs/{input_id}` | `dr pipelines input delete` (draft) | -| `DELETE /pipelines/{id}/versions/{ver}/inputs/{input_id}` | `dr pipelines input delete --version=N` | -| `POST /pipelines/{id}/dispatches` | `dr pipelines run create` (draft) | -| `POST /pipelines/{id}/versions/{ver}/dispatches` | `dr pipelines run create --version=N` | -| `GET /pipelines/{id}/dispatches` | `dr pipelines run list` (draft) | -| `GET /pipelines/{id}/versions/{ver}/dispatches` | `dr pipelines run list --version=N` | -| `GET /pipelines/{id}/dispatches/{dispatch_id}` | `dr pipelines run get` (draft) | -| `GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` | `dr pipelines run get --version=N` | -| `GET /pipelines/{id}/dispatches/{dispatch_id}/status` | `dr pipelines run status` (draft) | -| `GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}/status` | `dr pipelines run status --version=N` | -| `DELETE /pipelines/{id}/dispatches/{dispatch_id}` | `dr pipelines run cancel` (draft) | -| `DELETE /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` | `dr pipelines run cancel --version=N` | -| `POST /pipelines/{id}/versions/{ver}/schedules` | `dr pipelines schedule create` | -| `GET /pipelines/{id}/versions/{ver}/schedules` | `dr pipelines schedule list` | -| `GET /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipelines schedule get` | -| `PATCH /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipelines schedule update` | -| `DELETE /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipelines schedule delete` | -| `POST /pipelines/environments` | `dr pipelines environment create` | -| `GET /pipelines/environments` | `dr pipelines environment list` | -| `PATCH /pipelines/environments/{environment_id}` | `dr pipelines environment update` | -| `DELETE /pipelines/environments/{environment_id}` | `dr pipelines environment delete` | -| `DELETE /pipelines/environments/{environment_id}/versions/{version_id}` | `dr pipelines environment version delete` | diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 1aa129964..52c1284c1 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -70,8 +70,8 @@ nav: - completion: commands/completion.md - self: commands/self.md - plugins: commands/plugins.md - - pipelines: commands/pipelines.md - - pipelines reference: commands/pipelines-reference.md + - pipeline: commands/pipeline.md + - pipeline reference: commands/pipeline-reference.md - component: commands/component-managed-updates.md - Development: - development/README.md From a3849fa782ec649aedb7633548445dc121dc257b Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Sat, 23 May 2026 13:46:57 -0400 Subject: [PATCH 21/25] [CMPT-5391] refactor drapi verbs to use AuthorizeRequest and NewHTTPClient Replace the duplicated inline token+header setup and &http.Client{} construction in Get, Post, Patch, and Delete with the AuthorizeRequest and NewHTTPClient helpers introduced in client.go. Also fixes a bug in Post/Delete where config.GetAPIKey was called directly, bypassing the --skip-auth flag. Migrates raw viper imports to viperx in get.go and affected test files to satisfy depguard. Co-Authored-By: Claude Sonnet 4.6 --- go.sum | 1 + internal/drapi/client_test.go | 16 ++++++------- internal/drapi/delete.go | 27 ++++----------------- internal/drapi/get.go | 35 +++++++--------------------- internal/drapi/patch.go | 27 ++++----------------- internal/drapi/post.go | 27 ++++----------------- internal/pipelines/input_test.go | 8 +++---- internal/pipelines/transport_test.go | 14 +++++------ 8 files changed, 43 insertions(+), 112 deletions(-) diff --git a/go.sum b/go.sum index 87a207685..32cf674c1 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,5 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= diff --git a/internal/drapi/client_test.go b/internal/drapi/client_test.go index 4fd7a4e5e..72a7b1f3e 100644 --- a/internal/drapi/client_test.go +++ b/internal/drapi/client_test.go @@ -20,7 +20,7 @@ import ( "time" "github.com/datarobot/cli/internal/config" - "github.com/spf13/viper" + "github.com/datarobot/cli/internal/config/viperx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -45,15 +45,15 @@ func resetTokenCache(t *testing.T) { func withSkipAuth(t *testing.T, value string) string { t.Helper() - prevSkip := viper.GetBool("skip_auth") - prevKey := viper.GetString(config.DataRobotAPIKey) + prevSkip := viperx.GetBool("skip_auth") + prevKey := viperx.GetString(config.DataRobotAPIKey) - viper.Set("skip_auth", true) - viper.Set(config.DataRobotAPIKey, value) + viperx.Set("skip_auth", true) + viperx.Set(config.DataRobotAPIKey, value) t.Cleanup(func() { - viper.Set("skip_auth", prevSkip) - viper.Set(config.DataRobotAPIKey, prevKey) + viperx.Set("skip_auth", prevSkip) + viperx.Set(config.DataRobotAPIKey, prevKey) }) return value @@ -82,7 +82,7 @@ func TestGetToken_ResolvesAndMemoizes(t *testing.T) { // Mutating viper after the cache is populated should NOT change what // getToken returns — the value is memoized for the lifetime of the // process. - viper.Set(config.DataRobotAPIKey, "different") + viperx.Set(config.DataRobotAPIKey, "different") cached, err := getToken() require.NoError(t, err) diff --git a/internal/drapi/delete.go b/internal/drapi/delete.go index 09b4bddab..304a748a1 100644 --- a/internal/drapi/delete.go +++ b/internal/drapi/delete.go @@ -16,25 +16,14 @@ package drapi import ( "bytes" - "context" "encoding/json" "net/http" - "time" "github.com/datarobot/cli/internal/config" "github.com/datarobot/cli/internal/log" ) func Delete(url, info string, body any) (*http.Response, error) { - var err error - - if token == "" { - token, err = config.GetAPIKey(context.Background()) - if err != nil { - return nil, err - } - } - payload, err := json.Marshal(body) if err != nil { return nil, err @@ -45,14 +34,12 @@ func Delete(url, info string, body any) (*http.Response, error) { return nil, err } - req.Header.Add("Authorization", "Bearer "+token) - req.Header.Add("User-Agent", config.GetUserAgentHeader()) - req.Header.Add("Content-Type", "application/json") - - if config.IsAPIConsumerTrackingEnabled() { - req.Header.Add("X-DataRobot-Api-Consumer-Trace", config.GetAPIConsumerTrace()) + if err = AuthorizeRequest(req); err != nil { + return nil, err } + req.Header.Add("Content-Type", "application/json") + if info != "" { log.Infof("Deleting %s at: %s", info, url) } @@ -63,11 +50,7 @@ func Delete(url, info string, body any) (*http.Response, error) { return nil, err } - client := &http.Client{ - Timeout: 30 * time.Second, - } - - resp, err := client.Do(req) + resp, err := NewHTTPClient(DefaultClientTimeout).Do(req) if err != nil { return nil, err } diff --git a/internal/drapi/get.go b/internal/drapi/get.go index d18364f58..078a62ba2 100644 --- a/internal/drapi/get.go +++ b/internal/drapi/get.go @@ -22,8 +22,8 @@ import ( "time" "github.com/datarobot/cli/internal/config" + "github.com/datarobot/cli/internal/config/viperx" "github.com/datarobot/cli/internal/log" - "github.com/spf13/viper" ) // HTTPError is returned by Get when the server responds with a non-200 status code. @@ -50,34 +50,22 @@ func SetToken(value string) { token = value } -const DefaultGetTimeoutSecs = 30 - // resolveToken returns the API token used for outbound requests. // When --skip-auth (or DATAROBOT_CLI_SKIP_AUTH) is active we trust whatever // is in viper without contacting the server, so local development against // stub APIs that don't implement /version/ still works. func resolveToken() (string, error) { - if viper.GetBool("skip_auth") { - return viper.GetString(config.DataRobotAPIKey), nil + if viperx.GetBool("skip_auth") { + return viperx.GetString(config.DataRobotAPIKey), nil } return config.GetAPIKey(context.Background()) } func Get(url, info string, timeoutSecs ...int) (*http.Response, error) { - timeout := DefaultGetTimeoutSecs + timeout := DefaultClientTimeout if len(timeoutSecs) > 0 { - timeout = timeoutSecs[0] - } - - var err error - - // memoize token to avoid extra VerifyToken() calls - if token == "" { - token, err = resolveToken() - if err != nil { - return nil, err - } + timeout = time.Duration(timeoutSecs[0]) * time.Second } req, err := http.NewRequest(http.MethodGet, url, nil) @@ -85,11 +73,8 @@ func Get(url, info string, timeoutSecs ...int) (*http.Response, error) { return nil, err } - req.Header.Add("Authorization", "Bearer "+token) - req.Header.Add("User-Agent", config.GetUserAgentHeader()) - - if config.IsAPIConsumerTrackingEnabled() { - req.Header.Add("X-DataRobot-Api-Consumer-Trace", config.GetAPIConsumerTrace()) + if err = AuthorizeRequest(req); err != nil { + return nil, err } if info != "" { @@ -98,11 +83,7 @@ func Get(url, info string, timeoutSecs ...int) (*http.Response, error) { log.Debug("Request Info: \n" + config.RedactedReqInfo(req)) - client := &http.Client{ - Timeout: time.Duration(timeout) * time.Second, - } - - resp, err := client.Do(req) + resp, err := NewHTTPClient(timeout).Do(req) if err != nil { return nil, err } diff --git a/internal/drapi/patch.go b/internal/drapi/patch.go index 78fe37862..9f75c7efa 100644 --- a/internal/drapi/patch.go +++ b/internal/drapi/patch.go @@ -16,25 +16,14 @@ package drapi import ( "bytes" - "context" "encoding/json" "net/http" - "time" "github.com/datarobot/cli/internal/config" "github.com/datarobot/cli/internal/log" ) func Patch(url, info string, body any) (*http.Response, error) { - var err error - - if token == "" { - token, err = config.GetAPIKey(context.Background()) - if err != nil { - return nil, err - } - } - payload, err := json.Marshal(body) if err != nil { return nil, err @@ -45,14 +34,12 @@ func Patch(url, info string, body any) (*http.Response, error) { return nil, err } - req.Header.Add("Authorization", "Bearer "+token) - req.Header.Add("User-Agent", config.GetUserAgentHeader()) - req.Header.Add("Content-Type", "application/json") - - if config.IsAPIConsumerTrackingEnabled() { - req.Header.Add("X-DataRobot-Api-Consumer-Trace", config.GetAPIConsumerTrace()) + if err = AuthorizeRequest(req); err != nil { + return nil, err } + req.Header.Add("Content-Type", "application/json") + if info != "" { log.Infof("Updating %s at: %s", info, url) } @@ -63,11 +50,7 @@ func Patch(url, info string, body any) (*http.Response, error) { return nil, err } - client := &http.Client{ - Timeout: 30 * time.Second, - } - - resp, err := client.Do(req) + resp, err := NewHTTPClient(DefaultClientTimeout).Do(req) if err != nil { return nil, err } diff --git a/internal/drapi/post.go b/internal/drapi/post.go index eae3243c6..5029cbc77 100644 --- a/internal/drapi/post.go +++ b/internal/drapi/post.go @@ -16,25 +16,14 @@ package drapi import ( "bytes" - "context" "encoding/json" "net/http" - "time" "github.com/datarobot/cli/internal/config" "github.com/datarobot/cli/internal/log" ) func Post(url, info string, body any) (*http.Response, error) { - var err error - - if token == "" { - token, err = config.GetAPIKey(context.Background()) - if err != nil { - return nil, err - } - } - payload, err := json.Marshal(body) if err != nil { return nil, err @@ -45,14 +34,12 @@ func Post(url, info string, body any) (*http.Response, error) { return nil, err } - req.Header.Add("Authorization", "Bearer "+token) - req.Header.Add("User-Agent", config.GetUserAgentHeader()) - req.Header.Add("Content-Type", "application/json") - - if config.IsAPIConsumerTrackingEnabled() { - req.Header.Add("X-DataRobot-Api-Consumer-Trace", config.GetAPIConsumerTrace()) + if err = AuthorizeRequest(req); err != nil { + return nil, err } + req.Header.Add("Content-Type", "application/json") + if info != "" { log.Infof("Creating %s at: %s", info, url) } @@ -65,11 +52,7 @@ func Post(url, info string, body any) (*http.Response, error) { return nil, err } - client := &http.Client{ - Timeout: 30 * time.Second, - } - - resp, err := client.Do(req) + resp, err := NewHTTPClient(DefaultClientTimeout).Do(req) if err != nil { return nil, err } diff --git a/internal/pipelines/input_test.go b/internal/pipelines/input_test.go index 9350fcf58..ca0dadbe8 100644 --- a/internal/pipelines/input_test.go +++ b/internal/pipelines/input_test.go @@ -21,7 +21,7 @@ import ( "testing" "github.com/datarobot/cli/internal/config" - "github.com/spf13/viper" + "github.com/datarobot/cli/internal/config/viperx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -31,12 +31,12 @@ import ( func installEndpoint(t *testing.T, url string) { t.Helper() - prev := viper.GetString(config.DataRobotURL) + prev := viperx.GetString(config.DataRobotURL) - viper.Set(config.DataRobotURL, url) + viperx.Set(config.DataRobotURL, url) t.Cleanup(func() { - viper.Set(config.DataRobotURL, prev) + viperx.Set(config.DataRobotURL, prev) }) } diff --git a/internal/pipelines/transport_test.go b/internal/pipelines/transport_test.go index 8ea9c5dc8..bea1ce6ed 100644 --- a/internal/pipelines/transport_test.go +++ b/internal/pipelines/transport_test.go @@ -22,8 +22,8 @@ import ( "testing" "github.com/datarobot/cli/internal/config" + "github.com/datarobot/cli/internal/config/viperx" "github.com/datarobot/cli/internal/drapi" - "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -34,15 +34,15 @@ import ( func installSkipAuth(t *testing.T) { t.Helper() - prevSkip := viper.GetBool("skip_auth") - prevTok := viper.GetString(config.DataRobotAPIKey) + prevSkip := viperx.GetBool("skip_auth") + prevTok := viperx.GetString(config.DataRobotAPIKey) - viper.Set("skip_auth", true) - viper.Set(config.DataRobotAPIKey, "test-token") + viperx.Set("skip_auth", true) + viperx.Set(config.DataRobotAPIKey, "test-token") t.Cleanup(func() { - viper.Set("skip_auth", prevSkip) - viper.Set(config.DataRobotAPIKey, prevTok) + viperx.Set("skip_auth", prevSkip) + viperx.Set(config.DataRobotAPIKey, prevTok) }) } From ca2a2bd508cea37677cf7e86fb30dd69dc4f714e Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Sat, 23 May 2026 15:07:30 -0400 Subject: [PATCH 22/25] refactor(drapi): consolidate auth into AuthorizeRequest, fix body leak and header consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move AuthorizeRequest from client.go to auth.go (its natural home); remove the now-redundant SetAuthHeaders wrapper and update filesapi callers to call AuthorizeRequest directly - Fix --skip-auth bypass: auth.go previously called config.GetAPIKey directly, bypassing resolveToken(); now all callers go through getToken() which honours skip_auth - Fix GetJSON body leak: use defer resp.Body.Close() so body is closed on decode error - Fix Header.Add → Header.Set for Content-Type in delete.go, post.go, patch.go - Replace hand-rolled fakeBody (missing io.EOF) with strings.NewReader in client_test.go Co-Authored-By: Claude Sonnet 4.6 --- internal/drapi/auth.go | 26 ++++++++-------------- internal/drapi/auth_test.go | 26 +++++++++++----------- internal/drapi/client.go | 29 +++--------------------- internal/drapi/client_test.go | 33 +++++----------------------- internal/drapi/delete.go | 2 +- internal/drapi/filesapi/fromfile.go | 2 +- internal/drapi/filesapi/multipart.go | 2 +- internal/drapi/get.go | 9 ++------ internal/drapi/patch.go | 2 +- internal/drapi/post.go | 2 +- 10 files changed, 38 insertions(+), 95 deletions(-) diff --git a/internal/drapi/auth.go b/internal/drapi/auth.go index cd5d11a80..f85e35aaa 100644 --- a/internal/drapi/auth.go +++ b/internal/drapi/auth.go @@ -15,30 +15,22 @@ package drapi import ( - "context" "net/http" "github.com/datarobot/cli/internal/config" ) -// SetAuthHeaders populates Authorization, User-Agent, and (when enabled) -// X-DataRobot-Api-Consumer-Trace on req using the same sources the verb -// helpers use inline. Exposed so callers that build their own *http.Request -// (e.g. multipart streaming uploads in drapi/filesapi) can reuse the -// canonical drapi auth-injection logic instead of re-implementing it. -// -// Reuses the package-level `token` memoization declared in get.go. -func SetAuthHeaders(req *http.Request) error { - if token == "" { - var err error - - token, err = config.GetAPIKey(context.Background()) - if err != nil { - return err - } +// AuthorizeRequest sets the standard DataRobot API headers on req: +// Authorization (Bearer token), User-Agent, and the optional +// X-DataRobot-Api-Consumer-Trace. The request body is never read, so this +// is safe to call on multipart upload requests. +func AuthorizeRequest(req *http.Request) error { + bearer, err := getToken() + if err != nil { + return err } - req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Authorization", "Bearer "+bearer) req.Header.Set("User-Agent", config.GetUserAgentHeader()) if config.IsAPIConsumerTrackingEnabled() { diff --git a/internal/drapi/auth_test.go b/internal/drapi/auth_test.go index 1bae1f805..32d2bcd6e 100644 --- a/internal/drapi/auth_test.go +++ b/internal/drapi/auth_test.go @@ -24,19 +24,19 @@ import ( "github.com/stretchr/testify/require" ) -func TestSetAuthHeaders_AuthorizationAndUserAgent(t *testing.T) { +func TestAuthorizeRequest_AuthorizationAndUserAgent(t *testing.T) { defer resetTokenForTest(t, "test-token")() req, err := http.NewRequest(http.MethodGet, "http://example/", nil) require.NoError(t, err) - require.NoError(t, SetAuthHeaders(req)) + require.NoError(t, AuthorizeRequest(req)) assert.Equal(t, "Bearer test-token", req.Header.Get("Authorization")) assert.NotEmpty(t, req.Header.Get("User-Agent")) } -func TestSetAuthHeaders_TraceHeaderEnabled(t *testing.T) { +func TestAuthorizeRequest_TraceHeaderEnabled(t *testing.T) { defer resetTokenForTest(t, "test-token")() viperx.Reset() @@ -46,12 +46,12 @@ func TestSetAuthHeaders_TraceHeaderEnabled(t *testing.T) { req, err := http.NewRequest(http.MethodGet, "http://example/", nil) require.NoError(t, err) - require.NoError(t, SetAuthHeaders(req)) + require.NoError(t, AuthorizeRequest(req)) assert.NotEmpty(t, req.Header.Get("X-DataRobot-Api-Consumer-Trace")) } -func TestSetAuthHeaders_TraceHeaderDisabled(t *testing.T) { +func TestAuthorizeRequest_TraceHeaderDisabled(t *testing.T) { defer resetTokenForTest(t, "test-token")() viperx.Reset() @@ -61,31 +61,31 @@ func TestSetAuthHeaders_TraceHeaderDisabled(t *testing.T) { req, err := http.NewRequest(http.MethodGet, "http://example/", nil) require.NoError(t, err) - require.NoError(t, SetAuthHeaders(req)) + require.NoError(t, AuthorizeRequest(req)) assert.Empty(t, req.Header.Get("X-DataRobot-Api-Consumer-Trace")) } -// TestSetAuthHeaders_MemoizesToken confirms a seeded token short-circuits -// config.GetAPIKey on subsequent calls — the second SetAuthHeaders call +// TestAuthorizeRequest_MemoizesToken confirms a seeded token short-circuits +// config.GetAPIKey on subsequent calls — the second AuthorizeRequest call // must succeed without contacting config (which would fail in this test env). -func TestSetAuthHeaders_MemoizesToken(t *testing.T) { +func TestAuthorizeRequest_MemoizesToken(t *testing.T) { defer resetTokenForTest(t, "test-token")() for range 2 { req, err := http.NewRequest(http.MethodGet, "http://example/", nil) require.NoError(t, err) - require.NoError(t, SetAuthHeaders(req)) + require.NoError(t, AuthorizeRequest(req)) assert.Equal(t, "Bearer test-token", req.Header.Get("Authorization")) } } -// TestSetAuthHeaders_PropagatesTokenError confirms that when the token is +// TestAuthorizeRequest_PropagatesTokenError confirms that when the token is // unset and config.GetAPIKey fails (no DR_API_TOKEN, no config file in the // test env), the error is returned rather than silently producing a request // with an empty bearer. -func TestSetAuthHeaders_PropagatesTokenError(t *testing.T) { +func TestAuthorizeRequest_PropagatesTokenError(t *testing.T) { defer resetTokenForTest(t, "")() viperx.Reset() @@ -94,6 +94,6 @@ func TestSetAuthHeaders_PropagatesTokenError(t *testing.T) { req, err := http.NewRequest(http.MethodGet, "http://example/", nil) require.NoError(t, err) - err = SetAuthHeaders(req) + err = AuthorizeRequest(req) require.Error(t, err) } diff --git a/internal/drapi/client.go b/internal/drapi/client.go index 86381f5da..6f912314e 100644 --- a/internal/drapi/client.go +++ b/internal/drapi/client.go @@ -12,10 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -// This file owns the cross-verb HTTP primitives shared by every DataRobot -// API call: a client constructor, the standard header set, and the cached -// token resolver. Verb-specific helpers (Get/GetJSON in get.go, future -// post.go/patch.go, etc.) reuse these to stay consistent. +// This file owns the HTTP client constructor, the cached token resolver, and +// the DefaultClientTimeout constant shared by every verb helper (get.go, +// post.go, patch.go, delete.go). AuthorizeRequest lives in auth.go. // // HTTPError, the package-level `token` cache, and resolveToken() live in // get.go for historical reasons and are reused from this file. @@ -25,8 +24,6 @@ package drapi import ( "net/http" "time" - - "github.com/datarobot/cli/internal/config" ) // DefaultClientTimeout is the read/write timeout used by NewHTTPClient when @@ -57,23 +54,3 @@ func getToken() (string, error) { func NewHTTPClient(timeout time.Duration) *http.Client { return &http.Client{Timeout: timeout} } - -// AuthorizeRequest sets the standard DataRobot API headers on req: -// Authorization (Bearer token), User-Agent, and the optional -// X-DataRobot-Api-Consumer-Trace. The request body is never read, so this -// is safe to call on multipart upload requests. -func AuthorizeRequest(req *http.Request) error { - bearer, err := getToken() - if err != nil { - return err - } - - req.Header.Set("Authorization", "Bearer "+bearer) - req.Header.Set("User-Agent", config.GetUserAgentHeader()) - - if config.IsAPIConsumerTrackingEnabled() { - req.Header.Set("X-DataRobot-Api-Consumer-Trace", config.GetAPIConsumerTrace()) - } - - return nil -} diff --git a/internal/drapi/client_test.go b/internal/drapi/client_test.go index 72a7b1f3e..d6b317a62 100644 --- a/internal/drapi/client_test.go +++ b/internal/drapi/client_test.go @@ -15,7 +15,9 @@ package drapi import ( + "io" "net/http" + "strings" "testing" "time" @@ -107,37 +109,14 @@ func TestAuthorizeRequest_DoesNotConsumeBody(t *testing.T) { withSkipAuth(t, "shhh") req, err := http.NewRequest(http.MethodPost, "http://example/api/v2/foo", - stringReader("payload")) + io.NopCloser(strings.NewReader("payload"))) require.NoError(t, err) require.NoError(t, AuthorizeRequest(req)) // Body must still be readable after AuthorizeRequest — this is the // invariant that makes it safe for multipart uploads. - buf := make([]byte, 7) - - n, _ := req.Body.Read(buf) - assert.Equal(t, 7, n) - assert.Equal(t, "payload", string(buf)) -} - -// stringReader is a tiny io.ReadCloser-friendly helper so we don't pull in -// strings/bytes for one assertion. -func stringReader(s string) *fakeBody { - return &fakeBody{data: []byte(s)} -} - -type fakeBody struct { - data []byte - pos int -} - -func (f *fakeBody) Read(p []byte) (int, error) { - n := copy(p, f.data[f.pos:]) - - f.pos += n - - return n, nil + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + assert.Equal(t, "payload", string(body)) } - -func (f *fakeBody) Close() error { return nil } diff --git a/internal/drapi/delete.go b/internal/drapi/delete.go index 304a748a1..1ea5c4caf 100644 --- a/internal/drapi/delete.go +++ b/internal/drapi/delete.go @@ -38,7 +38,7 @@ func Delete(url, info string, body any) (*http.Response, error) { return nil, err } - req.Header.Add("Content-Type", "application/json") + req.Header.Set("Content-Type", "application/json") if info != "" { log.Infof("Deleting %s at: %s", info, url) diff --git a/internal/drapi/filesapi/fromfile.go b/internal/drapi/filesapi/fromfile.go index 6be037349..bfcd7cadf 100644 --- a/internal/drapi/filesapi/fromfile.go +++ b/internal/drapi/filesapi/fromfile.go @@ -115,7 +115,7 @@ func getAcceptingRedirect(requestURL string) (*http.Response, error) { return nil, fmt.Errorf("build status request: %w", err) } - if err := drapi.SetAuthHeaders(req); err != nil { + if err := drapi.AuthorizeRequest(req); err != nil { return nil, err } diff --git a/internal/drapi/filesapi/multipart.go b/internal/drapi/filesapi/multipart.go index 0e4b69996..904a1b098 100644 --- a/internal/drapi/filesapi/multipart.go +++ b/internal/drapi/filesapi/multipart.go @@ -69,7 +69,7 @@ func newStreamingMultipartRequest( req.ContentLength = int64(len(prologue)) + size + int64(len(epilogue)) } - if err := drapi.SetAuthHeaders(req); err != nil { + if err := drapi.AuthorizeRequest(req); err != nil { _ = pr.Close() return nil, err diff --git a/internal/drapi/get.go b/internal/drapi/get.go index 078a62ba2..33ea4f417 100644 --- a/internal/drapi/get.go +++ b/internal/drapi/get.go @@ -103,12 +103,7 @@ func GetJSON(url, info string, v any, timeoutSecs ...int) error { return err } - err = json.NewDecoder(resp.Body).Decode(&v) - if err != nil { - return err - } - - resp.Body.Close() + defer resp.Body.Close() - return nil + return json.NewDecoder(resp.Body).Decode(&v) } diff --git a/internal/drapi/patch.go b/internal/drapi/patch.go index 9f75c7efa..b35160ade 100644 --- a/internal/drapi/patch.go +++ b/internal/drapi/patch.go @@ -38,7 +38,7 @@ func Patch(url, info string, body any) (*http.Response, error) { return nil, err } - req.Header.Add("Content-Type", "application/json") + req.Header.Set("Content-Type", "application/json") if info != "" { log.Infof("Updating %s at: %s", info, url) diff --git a/internal/drapi/post.go b/internal/drapi/post.go index 5029cbc77..a40b02799 100644 --- a/internal/drapi/post.go +++ b/internal/drapi/post.go @@ -38,7 +38,7 @@ func Post(url, info string, body any) (*http.Response, error) { return nil, err } - req.Header.Add("Content-Type", "application/json") + req.Header.Set("Content-Type", "application/json") if info != "" { log.Infof("Creating %s at: %s", info, url) From 95e2844ab41d798dcd092c45def2cdc678abdbab Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 25 May 2026 11:30:13 -0400 Subject: [PATCH 23/25] fix(pipeline): update graph response key from "lattice" to "pipeline" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pipelines-api action 059 renamed the graph JSON key from "lattice" to "pipeline" (PipelineGraphLattice → PipelineGraphMeta). Update the Go struct tag and test fixtures to match the new wire format. Co-Authored-By: Claude Sonnet 4.6 --- internal/pipelines/version.go | 6 +++--- internal/pipelines/version_test.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/pipelines/version.go b/internal/pipelines/version.go index b634f2e2a..2a0947994 100644 --- a/internal/pipelines/version.go +++ b/internal/pipelines/version.go @@ -55,8 +55,8 @@ type GraphEdge struct { ArgIndex int `json:"argIndex,omitempty"` } -// GraphPipeline mirrors PipelineGraphLattice — the pipeline header in the -// graph payload. The wire key is still "lattice". +// GraphPipeline mirrors PipelineGraphMeta — the pipeline header in the +// graph payload (action 059: wire key renamed from "lattice" to "pipeline"). type GraphPipeline struct { Name string `json:"name"` PythonVersion string `json:"pythonVersion"` @@ -65,7 +65,7 @@ type GraphPipeline struct { // Graph mirrors the JSON returned by GET /pipelines/{id}/graph and // GET /pipelines/{id}/versions/{ver}/graph. type Graph struct { - Pipeline GraphPipeline `json:"lattice"` + Pipeline GraphPipeline `json:"pipeline"` Nodes []GraphNode `json:"nodes"` Edges []GraphEdge `json:"edges"` } diff --git a/internal/pipelines/version_test.go b/internal/pipelines/version_test.go index 6df26afac..89e634a82 100644 --- a/internal/pipelines/version_test.go +++ b/internal/pipelines/version_test.go @@ -72,7 +72,7 @@ func TestGetGraph_DraftURL(t *testing.T) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ - "lattice": {"name":"wf","pythonVersion":"3.12"}, + "pipeline": {"name":"wf","pythonVersion":"3.12"}, "nodes": [{"id":0,"type":"function","name":"wf"}], "edges": [] }`)) @@ -96,7 +96,7 @@ func TestGetGraph_LockedURL(t *testing.T) { assert.Equal(t, "/api/v2/pipelines/p-1/versions/3/graph", r.URL.Path) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"lattice":{"name":"wf","pythonVersion":"3.12"},"nodes":[],"edges":[]}`)) + _, _ = w.Write([]byte(`{"pipeline":{"name":"wf","pythonVersion":"3.12"},"nodes":[],"edges":[]}`)) })) defer srv.Close() From 4d50105581da5acd0526393190ad734e9b3545bc Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 25 May 2026 16:04:06 -0400 Subject: [PATCH 24/25] [CMPT-5391] fix variable shadowing in pipeline get command Co-Authored-By: Claude Sonnet 4.6 --- cmd/pipeline/get/cmd.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/pipeline/get/cmd.go b/cmd/pipeline/get/cmd.go index 0439602b0..4592dfdd6 100644 --- a/cmd/pipeline/get/cmd.go +++ b/cmd/pipeline/get/cmd.go @@ -21,13 +21,13 @@ import ( "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) func Cmd() *cobra.Command { - var outputFormat pipelines.OutputFormat + var outputFormat pipeline.OutputFormat cmd := &cobra.Command{ Use: "get ", @@ -43,16 +43,16 @@ Example: PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(_ *cobra.Command, args []string) error { - pipeline, err := pipelines.GetPipeline(args[0]) + result, err := pipeline.GetPipeline(args[0]) if err != nil { return handleGetError(err, args[0]) } - return pipelines.RenderPipeline(outputFormat, *pipeline) + return pipeline.RenderPipeline(outputFormat, *result) }, } - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } From 00c59d753ff640ea0afc7ad25250c3a267bd37ea Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 25 May 2026 16:09:08 -0400 Subject: [PATCH 25/25] [CMPT-5391] rename internal/pipelines to internal/pipeline (singular) Renames the Go package from `internal/pipelines` to `internal/pipeline` to match the singular command name (`dr pipeline`) and updates all cmd imports, tests, and telemetry calls accordingly. Also adds telemetry TrackWith calls across all pipeline subcommands. Co-Authored-By: Claude Sonnet 4.6 --- cmd/pipeline/create/cmd.go | 12 ++++---- cmd/pipeline/create/cmd_test.go | 12 ++++---- cmd/pipeline/del/cmd.go | 4 +-- cmd/pipeline/environment/create/cmd.go | 12 ++++---- cmd/pipeline/environment/del/cmd.go | 4 +-- cmd/pipeline/environment/list/cmd.go | 10 +++---- cmd/pipeline/environment/update/cmd.go | 12 ++++---- cmd/pipeline/environment/version/del/cmd.go | 4 +-- cmd/pipeline/get/cmd_test.go | 30 +++++++++---------- cmd/pipeline/graph/cmd.go | 14 ++++----- cmd/pipeline/graph/cmd_test.go | 14 ++++----- cmd/pipeline/input/create/cmd.go | 12 ++++---- cmd/pipeline/input/del/cmd.go | 4 +-- cmd/pipeline/input/get/cmd.go | 10 +++---- cmd/pipeline/input/list/cmd.go | 10 +++---- cmd/pipeline/input/update/cmd.go | 12 ++++---- cmd/pipeline/list/cmd.go | 14 ++++----- cmd/pipeline/list/cmd_test.go | 16 +++++----- cmd/pipeline/lock/cmd.go | 10 +++---- cmd/pipeline/lock/cmd_test.go | 12 ++++---- cmd/pipeline/run/cancel/cmd.go | 4 +-- cmd/pipeline/run/create/cmd.go | 10 +++---- cmd/pipeline/run/get/cmd.go | 10 +++---- cmd/pipeline/run/list/cmd.go | 10 +++---- cmd/pipeline/run/status/cmd.go | 10 +++---- cmd/pipeline/schedule/create/cmd.go | 12 ++++---- cmd/pipeline/schedule/del/cmd.go | 4 +-- cmd/pipeline/schedule/get/cmd.go | 10 +++---- cmd/pipeline/schedule/list/cmd.go | 10 +++---- cmd/pipeline/schedule/update/cmd.go | 20 ++++++------- cmd/pipeline/scopeflag/scopeflag.go | 8 ++--- cmd/pipeline/scopeflag/scopeflag_test.go | 8 ++--- cmd/pipeline/update/cmd.go | 10 +++---- cmd/pipeline/update/cmd_test.go | 12 ++++---- cmd/pipeline/version/get/cmd.go | 10 +++---- cmd/pipeline/version/list/cmd.go | 10 +++---- docs/commands/start.md | 2 +- .../{pipelines => pipeline}/environment.go | 2 +- .../environment_output.go | 2 +- .../environment_test.go | 2 +- internal/{pipelines => pipeline}/flags.go | 2 +- internal/{pipelines => pipeline}/input.go | 2 +- .../{pipelines => pipeline}/input_output.go | 2 +- .../{pipelines => pipeline}/input_payload.go | 2 +- .../{pipelines => pipeline}/input_test.go | 2 +- internal/{pipelines => pipeline}/pipeline.go | 6 ++-- .../pipeline_lifecycle_test.go | 2 +- .../pipeline_output.go | 2 +- .../{pipelines => pipeline}/pipeline_test.go | 2 +- internal/{pipelines => pipeline}/run.go | 2 +- .../{pipelines => pipeline}/run_output.go | 2 +- internal/{pipelines => pipeline}/run_test.go | 2 +- internal/{pipelines => pipeline}/schedule.go | 2 +- .../schedule_output.go | 2 +- .../{pipelines => pipeline}/schedule_test.go | 2 +- internal/{pipelines => pipeline}/scope.go | 2 +- .../{pipelines => pipeline}/scope_test.go | 2 +- internal/{pipelines => pipeline}/transport.go | 2 +- .../{pipelines => pipeline}/transport_test.go | 2 +- internal/{pipelines => pipeline}/version.go | 2 +- .../{pipelines => pipeline}/version_output.go | 2 +- .../{pipelines => pipeline}/version_test.go | 2 +- 62 files changed, 221 insertions(+), 221 deletions(-) rename internal/{pipelines => pipeline}/environment.go (99%) rename internal/{pipelines => pipeline}/environment_output.go (99%) rename internal/{pipelines => pipeline}/environment_test.go (99%) rename internal/{pipelines => pipeline}/flags.go (98%) rename internal/{pipelines => pipeline}/input.go (99%) rename internal/{pipelines => pipeline}/input_output.go (99%) rename internal/{pipelines => pipeline}/input_payload.go (99%) rename internal/{pipelines => pipeline}/input_test.go (99%) rename internal/{pipelines => pipeline}/pipeline.go (99%) rename internal/{pipelines => pipeline}/pipeline_lifecycle_test.go (99%) rename internal/{pipelines => pipeline}/pipeline_output.go (99%) rename internal/{pipelines => pipeline}/pipeline_test.go (99%) rename internal/{pipelines => pipeline}/run.go (99%) rename internal/{pipelines => pipeline}/run_output.go (99%) rename internal/{pipelines => pipeline}/run_test.go (99%) rename internal/{pipelines => pipeline}/schedule.go (99%) rename internal/{pipelines => pipeline}/schedule_output.go (99%) rename internal/{pipelines => pipeline}/schedule_test.go (99%) rename internal/{pipelines => pipeline}/scope.go (99%) rename internal/{pipelines => pipeline}/scope_test.go (99%) rename internal/{pipelines => pipeline}/transport.go (99%) rename internal/{pipelines => pipeline}/transport_test.go (99%) rename internal/{pipelines => pipeline}/version.go (99%) rename internal/{pipelines => pipeline}/version_output.go (99%) rename internal/{pipelines => pipeline}/version_test.go (99%) diff --git a/cmd/pipeline/create/cmd.go b/cmd/pipeline/create/cmd.go index bc74b1f0c..eb0a12b62 100644 --- a/cmd/pipeline/create/cmd.go +++ b/cmd/pipeline/create/cmd.go @@ -19,7 +19,7 @@ import ( "fmt" "github.com/datarobot/cli/internal/auth" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/spf13/cobra" ) @@ -27,7 +27,7 @@ func Cmd() *cobra.Command { var ( description string mode string - outputFormat pipelines.OutputFormat + outputFormat pipeline.OutputFormat fromFile string ) @@ -50,7 +50,7 @@ Example: Args: cobra.MaximumNArgs(1), PreRunE: auth.EnsureAuthenticatedE, RunE: func(_ *cobra.Command, args []string) error { - if mode != "" && mode != pipelines.ModeDraft && mode != pipelines.ModeLocked { + if mode != "" && mode != pipeline.ModeDraft && mode != pipeline.ModeLocked { return fmt.Errorf("invalid mode: %s (supported: draft, locked)", mode) } @@ -59,19 +59,19 @@ Example: return err } - result, err := pipelines.CreatePipeline(filePath, description, mode) + result, err := pipeline.CreatePipeline(filePath, description, mode) if err != nil { return err } - return pipelines.RenderCreateResponse(outputFormat, *result) + return pipeline.RenderCreateResponse(outputFormat, *result) }, } cmd.Flags().StringVar(&description, "description", "", "Optional description for the pipeline") cmd.Flags().StringVar(&mode, "mode", "", "Pipeline mode: draft (default) or locked") cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the Python file to upload, e.g. --from-file=./my_pipeline.py (alternative to the positional argument)") - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/create/cmd_test.go b/cmd/pipeline/create/cmd_test.go index e71ef716e..0e32b4081 100644 --- a/cmd/pipeline/create/cmd_test.go +++ b/cmd/pipeline/create/cmd_test.go @@ -22,7 +22,7 @@ import ( "testing" "time" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -47,8 +47,8 @@ func captureStdout(t *testing.T, fn func()) string { return buf.String() } -func sampleCreateResponse() pipelines.CreateResponse { - return pipelines.CreateResponse{ +func sampleCreateResponse() pipeline.CreateResponse { + return pipeline.CreateResponse{ PipelineID: "683c2a1b4f8e1a2b3c4d5e6f", Name: "confluence_to_vdb", Version: 1, @@ -63,7 +63,7 @@ func TestPrintCreateJSON(t *testing.T) { resp := sampleCreateResponse() output := captureStdout(t, func() { - err := pipelines.RenderCreateResponse(pipelines.OutputFormatJSON, resp) + err := pipeline.RenderCreateResponse(pipeline.OutputFormatJSON, resp) require.NoError(t, err) }) @@ -82,7 +82,7 @@ func TestPrintCreateHuman_WithTasks(t *testing.T) { resp := sampleCreateResponse() output := captureStdout(t, func() { - require.NoError(t, pipelines.RenderCreateResponse(pipelines.OutputFormatText, resp)) + require.NoError(t, pipeline.RenderCreateResponse(pipeline.OutputFormatText, resp)) }) assert.Contains(t, output, resp.PipelineID) @@ -98,7 +98,7 @@ func TestPrintCreateHuman_NoTasks(t *testing.T) { resp.TaskNames = nil output := captureStdout(t, func() { - require.NoError(t, pipelines.RenderCreateResponse(pipelines.OutputFormatText, resp)) + require.NoError(t, pipeline.RenderCreateResponse(pipeline.OutputFormatText, resp)) }) assert.Contains(t, output, "—") diff --git a/cmd/pipeline/del/cmd.go b/cmd/pipeline/del/cmd.go index 4fd5c2b72..44aa31dda 100644 --- a/cmd/pipeline/del/cmd.go +++ b/cmd/pipeline/del/cmd.go @@ -25,7 +25,7 @@ import ( "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -43,7 +43,7 @@ Example: PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(_ *cobra.Command, args []string) error { - err := pipelines.DeletePipeline(args[0]) + err := pipeline.DeletePipeline(args[0]) if err != nil { return handleDeleteError(err, args[0]) } diff --git a/cmd/pipeline/environment/create/cmd.go b/cmd/pipeline/environment/create/cmd.go index 1f2690991..21ec77d45 100644 --- a/cmd/pipeline/environment/create/cmd.go +++ b/cmd/pipeline/environment/create/cmd.go @@ -18,7 +18,7 @@ import ( "errors" "github.com/datarobot/cli/internal/auth" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/spf13/cobra" ) @@ -27,7 +27,7 @@ func Cmd() *cobra.Command { name string description string rawPackages []string - outputFormat pipelines.OutputFormat + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -50,24 +50,24 @@ Example: return errors.New("--name is required") } - packages, err := pipelines.NormalizePackages(rawPackages) + packages, err := pipeline.NormalizePackages(rawPackages) if err != nil { return err } - result, err := pipelines.CreateEnvironment(name, description, packages) + result, err := pipeline.CreateEnvironment(name, description, packages) if err != nil { return err } - return pipelines.RenderEnvironment(outputFormat, *result) + return pipeline.RenderEnvironment(outputFormat, *result) }, } cmd.Flags().StringVar(&name, "name", "", "Environment name (required)") cmd.Flags().StringVar(&description, "description", "", "Optional description") cmd.Flags().StringSliceVar(&rawPackages, "package", nil, "Pip package spec (repeatable, also accepts comma-separated values)") - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/environment/del/cmd.go b/cmd/pipeline/environment/del/cmd.go index 76f038598..1f22a0560 100644 --- a/cmd/pipeline/environment/del/cmd.go +++ b/cmd/pipeline/environment/del/cmd.go @@ -22,7 +22,7 @@ import ( "fmt" "github.com/datarobot/cli/internal/auth" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -44,7 +44,7 @@ Example: PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(_ *cobra.Command, args []string) error { - err := pipelines.DeleteEnvironment(args[0]) + err := pipeline.DeleteEnvironment(args[0]) if err != nil { return err } diff --git a/cmd/pipeline/environment/list/cmd.go b/cmd/pipeline/environment/list/cmd.go index f19c3691f..72109b6fb 100644 --- a/cmd/pipeline/environment/list/cmd.go +++ b/cmd/pipeline/environment/list/cmd.go @@ -16,7 +16,7 @@ package list import ( "github.com/datarobot/cli/internal/auth" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/spf13/cobra" ) @@ -24,7 +24,7 @@ func Cmd() *cobra.Command { var ( offset int limit int - outputFormat pipelines.OutputFormat + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -43,18 +43,18 @@ Example: PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(_ *cobra.Command, _ []string) error { - items, err := pipelines.ListEnvironments(offset, limit) + items, err := pipeline.ListEnvironments(offset, limit) if err != nil { return err } - return pipelines.RenderEnvironments(outputFormat, items) + return pipeline.RenderEnvironments(outputFormat, items) }, } cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of environments to return") - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/environment/update/cmd.go b/cmd/pipeline/environment/update/cmd.go index 52e7e2bd9..89715bc92 100644 --- a/cmd/pipeline/environment/update/cmd.go +++ b/cmd/pipeline/environment/update/cmd.go @@ -16,14 +16,14 @@ package update import ( "github.com/datarobot/cli/internal/auth" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/spf13/cobra" ) func Cmd() *cobra.Command { var ( rawPackages []string - outputFormat pipelines.OutputFormat + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -41,22 +41,22 @@ Example: PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(_ *cobra.Command, args []string) error { - packages, err := pipelines.NormalizePackages(rawPackages) + packages, err := pipeline.NormalizePackages(rawPackages) if err != nil { return err } - result, err := pipelines.UpdateEnvironment(args[0], packages) + result, err := pipeline.UpdateEnvironment(args[0], packages) if err != nil { return err } - return pipelines.RenderEnvironment(outputFormat, *result) + return pipeline.RenderEnvironment(outputFormat, *result) }, } cmd.Flags().StringSliceVar(&rawPackages, "package", nil, "Pip package spec (repeatable, also accepts comma-separated values)") - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/environment/version/del/cmd.go b/cmd/pipeline/environment/version/del/cmd.go index e53a86f9b..4cf5f162a 100644 --- a/cmd/pipeline/environment/version/del/cmd.go +++ b/cmd/pipeline/environment/version/del/cmd.go @@ -23,7 +23,7 @@ import ( "strconv" "github.com/datarobot/cli/internal/auth" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -52,7 +52,7 @@ Example: return fmt.Errorf("invalid version: %q (expected a positive integer)", args[0]) } - err = pipelines.DeleteEnvironmentVersion(environmentID, version) + err = pipeline.DeleteEnvironmentVersion(environmentID, version) if err != nil { return err } diff --git a/cmd/pipeline/get/cmd_test.go b/cmd/pipeline/get/cmd_test.go index 62bd73696..453b8aa01 100644 --- a/cmd/pipeline/get/cmd_test.go +++ b/cmd/pipeline/get/cmd_test.go @@ -23,7 +23,7 @@ import ( "time" "github.com/datarobot/cli/internal/drapi" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -48,8 +48,8 @@ func captureStdout(t *testing.T, fn func()) string { return buf.String() } -func samplePipeline() pipelines.Pipeline { - return pipelines.Pipeline{ +func samplePipeline() pipeline.Pipeline { + return pipeline.Pipeline{ PipelineID: "683c2a1b4f8e1a2b3c4d5e6f", Name: "confluence_to_vdb", Description: "test", @@ -57,7 +57,7 @@ func samplePipeline() pipelines.Pipeline { IsActive: true, CreatedAt: time.Date(2026, 4, 28, 11, 42, 28, 0, time.UTC), UpdatedAt: time.Date(2026, 4, 28, 12, 25, 11, 0, time.UTC), - Versions: []pipelines.PipelineVersion{ + Versions: []pipeline.PipelineVersion{ { Version: 1, Status: "READY", @@ -77,10 +77,10 @@ func samplePipeline() pipelines.Pipeline { } func TestPrintGetJSON(t *testing.T) { - pipeline := samplePipeline() + p := samplePipeline() output := captureStdout(t, func() { - err := pipelines.RenderPipeline(pipelines.OutputFormatJSON, pipeline) + err := pipeline.RenderPipeline(pipeline.OutputFormatJSON, p) require.NoError(t, err) }) @@ -90,13 +90,13 @@ func TestPrintGetJSON(t *testing.T) { } func TestPrintGetHuman_RendersHeaderAndVersions(t *testing.T) { - pipeline := samplePipeline() + p := samplePipeline() output := captureStdout(t, func() { - require.NoError(t, pipelines.RenderPipeline(pipelines.OutputFormatText, pipeline)) + require.NoError(t, pipeline.RenderPipeline(pipeline.OutputFormatText, p)) }) - assert.Contains(t, output, pipeline.PipelineID) + assert.Contains(t, output, p.PipelineID) assert.Contains(t, output, "confluence_to_vdb") assert.Contains(t, output, "test") assert.Contains(t, output, "draft") @@ -114,22 +114,22 @@ func TestPrintGetHuman_RendersHeaderAndVersions(t *testing.T) { } func TestPrintGetHuman_BlankDescriptionFallsBack(t *testing.T) { - pipeline := samplePipeline() - pipeline.Description = "" + p := samplePipeline() + p.Description = "" output := captureStdout(t, func() { - require.NoError(t, pipelines.RenderPipeline(pipelines.OutputFormatText, pipeline)) + require.NoError(t, pipeline.RenderPipeline(pipeline.OutputFormatText, p)) }) assert.Contains(t, output, "—") } func TestPrintGetHuman_NoVersions(t *testing.T) { - pipeline := samplePipeline() - pipeline.Versions = nil + p := samplePipeline() + p.Versions = nil output := captureStdout(t, func() { - require.NoError(t, pipelines.RenderPipeline(pipelines.OutputFormatText, pipeline)) + require.NoError(t, pipeline.RenderPipeline(pipeline.OutputFormatText, p)) }) assert.NotContains(t, output, "Versions (") diff --git a/cmd/pipeline/graph/cmd.go b/cmd/pipeline/graph/cmd.go index ed871f7ba..91e5702c6 100644 --- a/cmd/pipeline/graph/cmd.go +++ b/cmd/pipeline/graph/cmd.go @@ -26,7 +26,7 @@ import ( "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -34,7 +34,7 @@ import ( func Cmd() *cobra.Command { var ( flags scopeflag.Flags - outputFormat pipelines.OutputFormat + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -65,12 +65,12 @@ Example: return err } - result, err := pipelines.GetGraph(flags.PipelineID, scope, version) + result, err := pipeline.GetGraph(flags.PipelineID, scope, version) if err != nil { return handleGraphError(err, flags.PipelineID) } - if outputFormat == pipelines.OutputFormatJSON { + if outputFormat == pipeline.OutputFormatJSON { return printGraphJSON(*result) } @@ -81,7 +81,7 @@ Example: } flags.Bind(cmd) - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } @@ -98,7 +98,7 @@ func handleGraphError(err error, pipelineID string) error { return err } -func printGraphJSON(g pipelines.Graph) error { +func printGraphJSON(g pipeline.Graph) error { data, err := json.MarshalIndent(g, "", " ") if err != nil { return err @@ -109,7 +109,7 @@ func printGraphJSON(g pipelines.Graph) error { return nil } -func printGraphHuman(g pipelines.Graph) { +func printGraphHuman(g pipeline.Graph) { fmt.Println(tui.BaseTextStyle.Render("Pipeline: " + g.Pipeline.Name)) if len(g.Nodes) == 0 { diff --git a/cmd/pipeline/graph/cmd_test.go b/cmd/pipeline/graph/cmd_test.go index 7ce673195..87f7700d3 100644 --- a/cmd/pipeline/graph/cmd_test.go +++ b/cmd/pipeline/graph/cmd_test.go @@ -24,7 +24,7 @@ import ( "testing" "github.com/datarobot/cli/internal/drapi" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -49,14 +49,14 @@ func captureStdout(t *testing.T, fn func()) string { return buf.String() } -func sampleGraph() pipelines.Graph { - return pipelines.Graph{ - Pipeline: pipelines.GraphPipeline{Name: "wf", PythonVersion: "3.12"}, - Nodes: []pipelines.GraphNode{ +func sampleGraph() pipeline.Graph { + return pipeline.Graph{ + Pipeline: pipeline.GraphPipeline{Name: "wf", PythonVersion: "3.12"}, + Nodes: []pipeline.GraphNode{ {ID: 0, Type: "function", Name: "wf"}, {ID: 1, Type: "function", Name: "step1"}, }, - Edges: []pipelines.GraphEdge{ + Edges: []pipeline.GraphEdge{ {Source: 0, Target: 1}, }, } @@ -90,7 +90,7 @@ func TestPrintGraphHuman(t *testing.T) { func TestPrintGraphHuman_EmptyGraph(t *testing.T) { output := captureStdout(t, func() { - printGraphHuman(pipelines.Graph{Pipeline: pipelines.GraphPipeline{Name: "empty"}}) + printGraphHuman(pipeline.Graph{Pipeline: pipeline.GraphPipeline{Name: "empty"}}) }) assert.Contains(t, output, "No nodes") diff --git a/cmd/pipeline/input/create/cmd.go b/cmd/pipeline/input/create/cmd.go index fe69a3f27..70dcbbde9 100644 --- a/cmd/pipeline/input/create/cmd.go +++ b/cmd/pipeline/input/create/cmd.go @@ -19,7 +19,7 @@ import ( "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/spf13/cobra" ) @@ -27,7 +27,7 @@ func Cmd() *cobra.Command { var ( flags scopeflag.Flags fromFile string - outputFormat pipelines.OutputFormat + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -62,23 +62,23 @@ Example: return err } - payload, err := pipelines.ResolvePayload(args, fromFile) + payload, err := pipeline.ResolvePayload(args, fromFile) if err != nil { return err } - result, err := pipelines.CreateInput(flags.PipelineID, scope, version, payload) + result, err := pipeline.CreateInput(flags.PipelineID, scope, version, payload) if err != nil { return err } - return pipelines.RenderInput(outputFormat, *result) + return pipeline.RenderInput(outputFormat, *result) }, } flags.Bind(cmd) cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the JSON payload file, e.g. --from-file=./payload.json (alternative to the positional argument)") - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/input/del/cmd.go b/cmd/pipeline/input/del/cmd.go index c43cc4f93..17812e7bf 100644 --- a/cmd/pipeline/input/del/cmd.go +++ b/cmd/pipeline/input/del/cmd.go @@ -24,7 +24,7 @@ import ( "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -53,7 +53,7 @@ Example: return err } - err = pipelines.DeleteInput(flags.PipelineID, scope, version, args[0]) + err = pipeline.DeleteInput(flags.PipelineID, scope, version, args[0]) if err != nil { return err } diff --git a/cmd/pipeline/input/get/cmd.go b/cmd/pipeline/input/get/cmd.go index e5c0a408e..2d3935a03 100644 --- a/cmd/pipeline/input/get/cmd.go +++ b/cmd/pipeline/input/get/cmd.go @@ -22,7 +22,7 @@ import ( "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -30,7 +30,7 @@ import ( func Cmd() *cobra.Command { var ( flags scopeflag.Flags - outputFormat pipelines.OutputFormat + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -54,17 +54,17 @@ Example: return err } - result, err := pipelines.GetInput(flags.PipelineID, scope, version, args[0]) + result, err := pipeline.GetInput(flags.PipelineID, scope, version, args[0]) if err != nil { return handleGetError(err, args[0]) } - return pipelines.RenderInput(outputFormat, *result) + return pipeline.RenderInput(outputFormat, *result) }, } flags.Bind(cmd) - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/input/list/cmd.go b/cmd/pipeline/input/list/cmd.go index 7db5a6be8..1bb080284 100644 --- a/cmd/pipeline/input/list/cmd.go +++ b/cmd/pipeline/input/list/cmd.go @@ -19,7 +19,7 @@ import ( "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/spf13/cobra" ) @@ -28,7 +28,7 @@ func Cmd() *cobra.Command { flags scopeflag.Flags offset int limit int - outputFormat pipelines.OutputFormat + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -59,19 +59,19 @@ Example: return err } - items, err := pipelines.ListInputs(flags.PipelineID, scope, version, offset, limit) + items, err := pipeline.ListInputs(flags.PipelineID, scope, version, offset, limit) if err != nil { return err } - return pipelines.RenderInputs(outputFormat, items) + return pipeline.RenderInputs(outputFormat, items) }, } flags.Bind(cmd) cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of inputs to return") - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/input/update/cmd.go b/cmd/pipeline/input/update/cmd.go index 4a1e61df7..8dd0ab4fd 100644 --- a/cmd/pipeline/input/update/cmd.go +++ b/cmd/pipeline/input/update/cmd.go @@ -18,7 +18,7 @@ import ( "errors" "github.com/datarobot/cli/internal/auth" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/spf13/cobra" ) @@ -26,7 +26,7 @@ func Cmd() *cobra.Command { var ( pipelineID string fromFile string - outputFormat pipelines.OutputFormat + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -51,23 +51,23 @@ Example: inputID := args[0] - payload, err := pipelines.ResolvePayload(args[1:], fromFile) + payload, err := pipeline.ResolvePayload(args[1:], fromFile) if err != nil { return err } - result, err := pipelines.UpdateInput(pipelineID, inputID, payload) + result, err := pipeline.UpdateInput(pipelineID, inputID, payload) if err != nil { return err } - return pipelines.RenderInput(outputFormat, *result) + return pipeline.RenderInput(outputFormat, *result) }, } cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the JSON payload file, e.g. --from-file=./payload.json (alternative to the positional argument)") - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/list/cmd.go b/cmd/pipeline/list/cmd.go index ac0ef0f54..7cc5f4e99 100644 --- a/cmd/pipeline/list/cmd.go +++ b/cmd/pipeline/list/cmd.go @@ -18,7 +18,7 @@ import ( "fmt" "github.com/datarobot/cli/internal/auth" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/spf13/cobra" ) @@ -27,12 +27,12 @@ func Cmd() *cobra.Command { mode string offset int limit int - outputFormat pipelines.OutputFormat + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ Use: "list", - Short: "List pipelines.", + Short: "List pipeline.", Long: `List pipelines registered with the pipelines service. By default, output is human-readable. Use --output-format json for machine-parseable output. @@ -44,23 +44,23 @@ Example: Args: cobra.NoArgs, PreRunE: auth.EnsureAuthenticatedE, RunE: func(_ *cobra.Command, _ []string) error { - if mode != "" && mode != pipelines.ModeDraft && mode != pipelines.ModeLocked { + if mode != "" && mode != pipeline.ModeDraft && mode != pipeline.ModeLocked { return fmt.Errorf("invalid mode: %s (supported: draft, locked)", mode) } - list, err := pipelines.ListPipelines(mode, offset, limit) + list, err := pipeline.ListPipelines(mode, offset, limit) if err != nil { return err } - return pipelines.RenderPipelines(outputFormat, *list) + return pipeline.RenderPipelines(outputFormat, *list) }, } cmd.Flags().StringVar(&mode, "mode", "", "Filter by mode: draft or locked") cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") cmd.Flags().IntVar(&limit, "limit", 50, "Pagination limit (1-200)") - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/list/cmd_test.go b/cmd/pipeline/list/cmd_test.go index ef4c2bad6..512ba61ba 100644 --- a/cmd/pipeline/list/cmd_test.go +++ b/cmd/pipeline/list/cmd_test.go @@ -22,7 +22,7 @@ import ( "testing" "time" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -51,9 +51,9 @@ func intPtr(v int) *int { return &v } -func sampleListResponse() pipelines.DataPage[pipelines.ListItem] { - return pipelines.DataPage[pipelines.ListItem]{ - Data: []pipelines.ListItem{ +func sampleListResponse() pipeline.DataPage[pipeline.ListItem] { + return pipeline.DataPage[pipeline.ListItem]{ + Data: []pipeline.ListItem{ { PipelineID: "683c2a1b4f8e1a2b3c4d5e6f", Name: "confluence_to_vdb", @@ -73,7 +73,7 @@ func TestPrintListJSON(t *testing.T) { list := sampleListResponse() output := captureStdout(t, func() { - err := pipelines.RenderPipelines(pipelines.OutputFormatJSON, list) + err := pipeline.RenderPipelines(pipeline.OutputFormatJSON, list) require.NoError(t, err) }) @@ -90,7 +90,7 @@ func TestPrintListJSON(t *testing.T) { func TestPrintListHuman_Empty(t *testing.T) { output := captureStdout(t, func() { - require.NoError(t, pipelines.RenderPipelines(pipelines.OutputFormatText, pipelines.DataPage[pipelines.ListItem]{})) + require.NoError(t, pipeline.RenderPipelines(pipeline.OutputFormatText, pipeline.DataPage[pipeline.ListItem]{})) }) assert.Contains(t, output, "No pipelines found.") @@ -100,7 +100,7 @@ func TestPrintListHuman_RendersHeaderAndRow(t *testing.T) { list := sampleListResponse() output := captureStdout(t, func() { - require.NoError(t, pipelines.RenderPipelines(pipelines.OutputFormatText, list)) + require.NoError(t, pipeline.RenderPipelines(pipeline.OutputFormatText, list)) }) assert.Contains(t, output, "Showing 1 of 1") @@ -123,7 +123,7 @@ func TestPrintListHuman_NoLatestVersion(t *testing.T) { list.Data[0].LatestVersion = nil output := captureStdout(t, func() { - require.NoError(t, pipelines.RenderPipelines(pipelines.OutputFormatText, list)) + require.NoError(t, pipeline.RenderPipelines(pipeline.OutputFormatText, list)) }) assert.Contains(t, output, "—") diff --git a/cmd/pipeline/lock/cmd.go b/cmd/pipeline/lock/cmd.go index 5187f35d3..3c2e975df 100644 --- a/cmd/pipeline/lock/cmd.go +++ b/cmd/pipeline/lock/cmd.go @@ -16,12 +16,12 @@ package lock import ( "github.com/datarobot/cli/internal/auth" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/spf13/cobra" ) func Cmd() *cobra.Command { - var outputFormat pipelines.OutputFormat + var outputFormat pipeline.OutputFormat cmd := &cobra.Command{ Use: "lock ", @@ -36,16 +36,16 @@ Example: PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(_ *cobra.Command, args []string) error { - result, err := pipelines.LockPipeline(args[0]) + result, err := pipeline.LockPipeline(args[0]) if err != nil { return err } - return pipelines.RenderCreateResponse(outputFormat, *result) + return pipeline.RenderCreateResponse(outputFormat, *result) }, } - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/lock/cmd_test.go b/cmd/pipeline/lock/cmd_test.go index 40841ad82..1b8398725 100644 --- a/cmd/pipeline/lock/cmd_test.go +++ b/cmd/pipeline/lock/cmd_test.go @@ -22,7 +22,7 @@ import ( "testing" "time" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -47,8 +47,8 @@ func captureStdout(t *testing.T, fn func()) string { return buf.String() } -func sample() pipelines.CreateResponse { - return pipelines.CreateResponse{ +func sample() pipeline.CreateResponse { + return pipeline.CreateResponse{ PipelineID: "abc", Name: "promo", Version: 3, @@ -61,7 +61,7 @@ func sample() pipelines.CreateResponse { func TestPrintLockJSON(t *testing.T) { output := captureStdout(t, func() { - require.NoError(t, pipelines.RenderCreateResponse(pipelines.OutputFormatJSON, sample())) + require.NoError(t, pipeline.RenderCreateResponse(pipeline.OutputFormatJSON, sample())) }) var parsed map[string]any @@ -74,7 +74,7 @@ func TestPrintLockJSON(t *testing.T) { func TestPrintLockHuman(t *testing.T) { output := captureStdout(t, func() { - require.NoError(t, pipelines.RenderCreateResponse(pipelines.OutputFormatText, sample())) + require.NoError(t, pipeline.RenderCreateResponse(pipeline.OutputFormatText, sample())) }) assert.Contains(t, output, "abc") @@ -88,7 +88,7 @@ func TestPrintLockHuman_NoTasks(t *testing.T) { resp.TaskNames = nil output := captureStdout(t, func() { - require.NoError(t, pipelines.RenderCreateResponse(pipelines.OutputFormatText, resp)) + require.NoError(t, pipeline.RenderCreateResponse(pipeline.OutputFormatText, resp)) }) assert.Contains(t, output, "—") diff --git a/cmd/pipeline/run/cancel/cmd.go b/cmd/pipeline/run/cancel/cmd.go index de726a411..b6345b1e9 100644 --- a/cmd/pipeline/run/cancel/cmd.go +++ b/cmd/pipeline/run/cancel/cmd.go @@ -20,7 +20,7 @@ import ( "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -52,7 +52,7 @@ Example: return err } - err = pipelines.CancelRun(flags.PipelineID, scope, version, args[0]) + err = pipeline.CancelRun(flags.PipelineID, scope, version, args[0]) if err != nil { return err } diff --git a/cmd/pipeline/run/create/cmd.go b/cmd/pipeline/run/create/cmd.go index 1c62ad0d2..cbf5d957d 100644 --- a/cmd/pipeline/run/create/cmd.go +++ b/cmd/pipeline/run/create/cmd.go @@ -19,7 +19,7 @@ import ( "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/spf13/cobra" ) @@ -27,7 +27,7 @@ func Cmd() *cobra.Command { var ( flags scopeflag.Flags inputID string - outputFormat pipelines.OutputFormat + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -58,18 +58,18 @@ Example: return err } - result, err := pipelines.CreateRun(flags.PipelineID, scope, version, inputID) + result, err := pipeline.CreateRun(flags.PipelineID, scope, version, inputID) if err != nil { return err } - return pipelines.RenderRun(outputFormat, *result) + return pipeline.RenderRun(outputFormat, *result) }, } flags.Bind(cmd) cmd.Flags().StringVar(&inputID, "input", "", "Input ID to trigger the run with") - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/run/get/cmd.go b/cmd/pipeline/run/get/cmd.go index 5c681381e..0ad1caf1c 100644 --- a/cmd/pipeline/run/get/cmd.go +++ b/cmd/pipeline/run/get/cmd.go @@ -22,7 +22,7 @@ import ( "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -30,7 +30,7 @@ import ( func Cmd() *cobra.Command { var ( flags scopeflag.Flags - outputFormat pipelines.OutputFormat + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -54,17 +54,17 @@ Example: return err } - result, err := pipelines.GetRun(flags.PipelineID, scope, version, args[0]) + result, err := pipeline.GetRun(flags.PipelineID, scope, version, args[0]) if err != nil { return handleGetError(err, args[0]) } - return pipelines.RenderRun(outputFormat, *result) + return pipeline.RenderRun(outputFormat, *result) }, } flags.Bind(cmd) - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/run/list/cmd.go b/cmd/pipeline/run/list/cmd.go index d72762a36..3f9ed238c 100644 --- a/cmd/pipeline/run/list/cmd.go +++ b/cmd/pipeline/run/list/cmd.go @@ -19,7 +19,7 @@ import ( "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/spf13/cobra" ) @@ -28,7 +28,7 @@ func Cmd() *cobra.Command { flags scopeflag.Flags offset int limit int - outputFormat pipelines.OutputFormat + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -52,19 +52,19 @@ Example: return err } - items, err := pipelines.ListRuns(flags.PipelineID, scope, version, offset, limit) + items, err := pipeline.ListRuns(flags.PipelineID, scope, version, offset, limit) if err != nil { return err } - return pipelines.RenderRuns(outputFormat, items) + return pipeline.RenderRuns(outputFormat, items) }, } flags.Bind(cmd) cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of runs to return") - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/run/status/cmd.go b/cmd/pipeline/run/status/cmd.go index c6f28dc8c..4ac8e3d87 100644 --- a/cmd/pipeline/run/status/cmd.go +++ b/cmd/pipeline/run/status/cmd.go @@ -22,7 +22,7 @@ import ( "github.com/datarobot/cli/cmd/pipeline/scopeflag" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -30,7 +30,7 @@ import ( func Cmd() *cobra.Command { var ( flags scopeflag.Flags - outputFormat pipelines.OutputFormat + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -54,17 +54,17 @@ Example: return err } - result, err := pipelines.GetRunStatus(flags.PipelineID, scope, version, args[0]) + result, err := pipeline.GetRunStatus(flags.PipelineID, scope, version, args[0]) if err != nil { return handleStatusError(err, args[0]) } - return pipelines.RenderRunStatus(outputFormat, *result) + return pipeline.RenderRunStatus(outputFormat, *result) }, } flags.Bind(cmd) - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/schedule/create/cmd.go b/cmd/pipeline/schedule/create/cmd.go index d1249ffb9..5e4d05c1c 100644 --- a/cmd/pipeline/schedule/create/cmd.go +++ b/cmd/pipeline/schedule/create/cmd.go @@ -18,7 +18,7 @@ import ( "errors" "github.com/datarobot/cli/internal/auth" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/spf13/cobra" ) @@ -29,7 +29,7 @@ func Cmd() *cobra.Command { cron string inputID string timezone string - outputFormat pipelines.OutputFormat + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -60,18 +60,18 @@ Example: return errors.New("--input is required") } - body := pipelines.ScheduleCreateRequest{ + body := pipeline.ScheduleCreateRequest{ CronExpression: cron, PipelineInputID: inputID, Timezone: timezone, } - result, err := pipelines.CreateSchedule(pipelineID, version, body) + result, err := pipeline.CreateSchedule(pipelineID, version, body) if err != nil { return err } - return pipelines.RenderSchedule(outputFormat, *result) + return pipeline.RenderSchedule(outputFormat, *result) }, } @@ -80,7 +80,7 @@ Example: cmd.Flags().StringVar(&cron, "cron", "", "Cron expression, e.g. \"0 * * * *\"") cmd.Flags().StringVar(&inputID, "input", "", "Input ID to run on each tick") cmd.Flags().StringVar(&timezone, "timezone", "", "IANA timezone name (default UTC)") - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/schedule/del/cmd.go b/cmd/pipeline/schedule/del/cmd.go index 0a7a319b4..ea8c511f1 100644 --- a/cmd/pipeline/schedule/del/cmd.go +++ b/cmd/pipeline/schedule/del/cmd.go @@ -23,7 +23,7 @@ import ( "fmt" "github.com/datarobot/cli/internal/auth" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -53,7 +53,7 @@ Example: return errors.New("--version is required and must be > 0") } - err := pipelines.DeleteSchedule(pipelineID, version, args[0]) + err := pipeline.DeleteSchedule(pipelineID, version, args[0]) if err != nil { return err } diff --git a/cmd/pipeline/schedule/get/cmd.go b/cmd/pipeline/schedule/get/cmd.go index 5d7b3a28c..0b2540e8b 100644 --- a/cmd/pipeline/schedule/get/cmd.go +++ b/cmd/pipeline/schedule/get/cmd.go @@ -21,7 +21,7 @@ import ( "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -30,7 +30,7 @@ func Cmd() *cobra.Command { var ( pipelineID string version int - outputFormat pipelines.OutputFormat + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -53,18 +53,18 @@ Example: return errors.New("--version is required and must be > 0") } - result, err := pipelines.GetSchedule(pipelineID, version, args[0]) + result, err := pipeline.GetSchedule(pipelineID, version, args[0]) if err != nil { return handleGetError(err, args[0]) } - return pipelines.RenderSchedule(outputFormat, *result) + return pipeline.RenderSchedule(outputFormat, *result) }, } cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/schedule/list/cmd.go b/cmd/pipeline/schedule/list/cmd.go index b34b907dd..41eaa8639 100644 --- a/cmd/pipeline/schedule/list/cmd.go +++ b/cmd/pipeline/schedule/list/cmd.go @@ -18,7 +18,7 @@ import ( "errors" "github.com/datarobot/cli/internal/auth" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/spf13/cobra" ) @@ -28,7 +28,7 @@ func Cmd() *cobra.Command { version int offset int limit int - outputFormat pipelines.OutputFormat + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -51,12 +51,12 @@ Example: return errors.New("--version is required and must be > 0") } - items, err := pipelines.ListSchedules(pipelineID, version, offset, limit) + items, err := pipeline.ListSchedules(pipelineID, version, offset, limit) if err != nil { return err } - return pipelines.RenderSchedules(outputFormat, items) + return pipeline.RenderSchedules(outputFormat, items) }, } @@ -64,7 +64,7 @@ Example: cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of schedules to return") - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/schedule/update/cmd.go b/cmd/pipeline/schedule/update/cmd.go index 79432cfc9..a31d7110d 100644 --- a/cmd/pipeline/schedule/update/cmd.go +++ b/cmd/pipeline/schedule/update/cmd.go @@ -18,7 +18,7 @@ import ( "errors" "github.com/datarobot/cli/internal/auth" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/spf13/cobra" ) @@ -28,7 +28,7 @@ func Cmd() *cobra.Command { version int cron string timezone string - outputFormat pipelines.OutputFormat + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -51,12 +51,12 @@ Example: return err } - result, err := pipelines.UpdateSchedule(pipelineID, version, args[0], body) + result, err := pipeline.UpdateSchedule(pipelineID, version, args[0], body) if err != nil { return err } - return pipelines.RenderSchedule(outputFormat, *result) + return pipeline.RenderSchedule(outputFormat, *result) }, } @@ -64,30 +64,30 @@ Example: cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") cmd.Flags().StringVar(&cron, "cron", "", "New cron expression") cmd.Flags().StringVar(&timezone, "timezone", "", "New IANA timezone name") - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } // buildUpdateBody validates the flag set and assembles the PATCH body. It is // extracted from RunE to keep the cobra command's cyclomatic complexity low. -func buildUpdateBody(cmd *cobra.Command, pipelineID string, version int, cron, timezone string) (pipelines.ScheduleUpdateRequest, error) { +func buildUpdateBody(cmd *cobra.Command, pipelineID string, version int, cron, timezone string) (pipeline.ScheduleUpdateRequest, error) { if pipelineID == "" { - return pipelines.ScheduleUpdateRequest{}, errors.New("--pipeline is required") + return pipeline.ScheduleUpdateRequest{}, errors.New("--pipeline is required") } if version <= 0 { - return pipelines.ScheduleUpdateRequest{}, errors.New("--version is required and must be > 0") + return pipeline.ScheduleUpdateRequest{}, errors.New("--version is required and must be > 0") } cronChanged := cmd.Flags().Changed("cron") tzChanged := cmd.Flags().Changed("timezone") if !cronChanged && !tzChanged { - return pipelines.ScheduleUpdateRequest{}, errors.New("at least one of --cron or --timezone must be specified") + return pipeline.ScheduleUpdateRequest{}, errors.New("at least one of --cron or --timezone must be specified") } - body := pipelines.ScheduleUpdateRequest{} + body := pipeline.ScheduleUpdateRequest{} if cronChanged { v := cron diff --git a/cmd/pipeline/scopeflag/scopeflag.go b/cmd/pipeline/scopeflag/scopeflag.go index c26ab5f22..a382b13b2 100644 --- a/cmd/pipeline/scopeflag/scopeflag.go +++ b/cmd/pipeline/scopeflag/scopeflag.go @@ -18,14 +18,14 @@ package scopeflag import ( - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/spf13/cobra" ) // Flags holds the values backing the shared --pipeline / --scope / // --version flags. Bind() registers them on a cobra command and // Resolve(cmd) turns them into a (Scope, *version) pair via -// pipelines.ResolveScope. +// pipeline.ResolveScope. type Flags struct { PipelineID string Scope string @@ -43,7 +43,7 @@ func (f *Flags) Bind(cmd *cobra.Command) { // Resolve combines the parsed flags into the canonical (Scope, *version) // pair required by the pipelines client. It must be called from RunE (or // later) so cmd.Flags().Changed has accurate state. -func (f *Flags) Resolve(cmd *cobra.Command) (pipelines.Scope, *int, error) { +func (f *Flags) Resolve(cmd *cobra.Command) (pipeline.Scope, *int, error) { var version *int if cmd.Flags().Changed("version") { @@ -52,5 +52,5 @@ func (f *Flags) Resolve(cmd *cobra.Command) (pipelines.Scope, *int, error) { version = &v } - return pipelines.ResolveScope(f.Scope, version) + return pipeline.ResolveScope(f.Scope, version) } diff --git a/cmd/pipeline/scopeflag/scopeflag_test.go b/cmd/pipeline/scopeflag/scopeflag_test.go index 95dc65d20..1a1c96864 100644 --- a/cmd/pipeline/scopeflag/scopeflag_test.go +++ b/cmd/pipeline/scopeflag/scopeflag_test.go @@ -18,7 +18,7 @@ import ( "io" "testing" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -52,7 +52,7 @@ func TestFlags_Resolve_DefaultDraft(t *testing.T) { scope, version, err := flags.Resolve(cmd) require.NoError(t, err) - assert.Equal(t, pipelines.ScopeDraft, scope) + assert.Equal(t, pipeline.ScopeDraft, scope) assert.Nil(t, version) } @@ -64,7 +64,7 @@ func TestFlags_Resolve_VersionImpliesLocked(t *testing.T) { scope, version, err := flags.Resolve(cmd) require.NoError(t, err) - assert.Equal(t, pipelines.ScopeLocked, scope) + assert.Equal(t, pipeline.ScopeLocked, scope) require.NotNil(t, version) assert.Equal(t, 4, *version) } @@ -101,7 +101,7 @@ func TestFlags_Resolve_VersionZeroIsRespected(t *testing.T) { scope, version, err := flags.Resolve(cmd) require.NoError(t, err) - assert.Equal(t, pipelines.ScopeLocked, scope) + assert.Equal(t, pipeline.ScopeLocked, scope) require.NotNil(t, version) assert.Equal(t, 0, *version) } diff --git a/cmd/pipeline/update/cmd.go b/cmd/pipeline/update/cmd.go index 447f9420d..535fc44f7 100644 --- a/cmd/pipeline/update/cmd.go +++ b/cmd/pipeline/update/cmd.go @@ -18,13 +18,13 @@ import ( "errors" "github.com/datarobot/cli/internal/auth" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/spf13/cobra" ) func Cmd() *cobra.Command { var ( - outputFormat pipelines.OutputFormat + outputFormat pipeline.OutputFormat fromFile string ) @@ -56,17 +56,17 @@ Example: return err } - result, err := pipelines.UpdatePipeline(pipelineID, filePath) + result, err := pipeline.UpdatePipeline(pipelineID, filePath) if err != nil { return err } - return pipelines.RenderCreateResponse(outputFormat, *result) + return pipeline.RenderCreateResponse(outputFormat, *result) }, } cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the Python file to upload, e.g. --from-file=./my_pipeline.py (alternative to the positional argument)") - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/update/cmd_test.go b/cmd/pipeline/update/cmd_test.go index 7714d1e54..97efd7512 100644 --- a/cmd/pipeline/update/cmd_test.go +++ b/cmd/pipeline/update/cmd_test.go @@ -22,7 +22,7 @@ import ( "testing" "time" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -47,8 +47,8 @@ func captureStdout(t *testing.T, fn func()) string { return buf.String() } -func sampleUpdateResponse() pipelines.CreateResponse { - return pipelines.CreateResponse{ +func sampleUpdateResponse() pipeline.CreateResponse { + return pipeline.CreateResponse{ PipelineID: "683c2a1b4f8e1a2b3c4d5e6f", Name: "confluence_to_vdb", Version: 2, @@ -63,7 +63,7 @@ func TestPrintUpdateJSON(t *testing.T) { resp := sampleUpdateResponse() output := captureStdout(t, func() { - err := pipelines.RenderCreateResponse(pipelines.OutputFormatJSON, resp) + err := pipeline.RenderCreateResponse(pipeline.OutputFormatJSON, resp) require.NoError(t, err) }) @@ -80,7 +80,7 @@ func TestPrintUpdateHuman_WithTasks(t *testing.T) { resp := sampleUpdateResponse() output := captureStdout(t, func() { - require.NoError(t, pipelines.RenderCreateResponse(pipelines.OutputFormatText, resp)) + require.NoError(t, pipeline.RenderCreateResponse(pipeline.OutputFormatText, resp)) }) assert.Contains(t, output, resp.PipelineID) @@ -96,7 +96,7 @@ func TestPrintUpdateHuman_NoTasks(t *testing.T) { resp.TaskNames = nil output := captureStdout(t, func() { - require.NoError(t, pipelines.RenderCreateResponse(pipelines.OutputFormatText, resp)) + require.NoError(t, pipeline.RenderCreateResponse(pipeline.OutputFormatText, resp)) }) assert.Contains(t, output, "—") diff --git a/cmd/pipeline/version/get/cmd.go b/cmd/pipeline/version/get/cmd.go index a0239a056..0f17a0bad 100644 --- a/cmd/pipeline/version/get/cmd.go +++ b/cmd/pipeline/version/get/cmd.go @@ -22,7 +22,7 @@ import ( "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -30,7 +30,7 @@ import ( func Cmd() *cobra.Command { var ( pipelineID string - outputFormat pipelines.OutputFormat + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -54,17 +54,17 @@ Example: return fmt.Errorf("invalid version: %q (expected a positive integer)", args[0]) } - result, err := pipelines.GetVersion(pipelineID, versionID) + result, err := pipeline.GetVersion(pipelineID, versionID) if err != nil { return handleGetError(err, args[0]) } - return pipelines.RenderVersion(outputFormat, *result) + return pipeline.RenderVersion(outputFormat, *result) }, } cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/version/list/cmd.go b/cmd/pipeline/version/list/cmd.go index 4f40c693d..f55d6e987 100644 --- a/cmd/pipeline/version/list/cmd.go +++ b/cmd/pipeline/version/list/cmd.go @@ -18,7 +18,7 @@ import ( "errors" "github.com/datarobot/cli/internal/auth" - "github.com/datarobot/cli/internal/pipelines" + "github.com/datarobot/cli/internal/pipeline" "github.com/spf13/cobra" ) @@ -27,7 +27,7 @@ func Cmd() *cobra.Command { pipelineID string offset int limit int - outputFormat pipelines.OutputFormat + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -46,19 +46,19 @@ Example: return errors.New("--pipeline is required") } - items, err := pipelines.ListVersions(pipelineID, offset, limit) + items, err := pipeline.ListVersions(pipelineID, offset, limit) if err != nil { return err } - return pipelines.RenderVersions(outputFormat, items) + return pipeline.RenderVersions(outputFormat, items) }, } cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of versions to return") - pipelines.AddOutputFlag(cmd, &outputFormat) + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/docs/commands/start.md b/docs/commands/start.md index c1272d743..6b770ba78 100644 --- a/docs/commands/start.md +++ b/docs/commands/start.md @@ -316,7 +316,7 @@ If a quickstart script fails, the error is displayed and the command exits. Chec - **First-time setup**—initializing a newly cloned template or starting from scratch. - **Quick restart**—restarting development after a break. - **Onboarding**—helping new team members get started quickly. -- **CI/CD**—automating application initialization in pipelines. +- **CI/CD**—automating application initialization in pipeline. - **General entry point**—universal command that works whether you have a template or not. ### ❌ When not to use diff --git a/internal/pipelines/environment.go b/internal/pipeline/environment.go similarity index 99% rename from internal/pipelines/environment.go rename to internal/pipeline/environment.go index c13f32019..6c194b2df 100644 --- a/internal/pipelines/environment.go +++ b/internal/pipeline/environment.go @@ -27,7 +27,7 @@ // DELETE /api/v2/pipelines/environments/{id} (soft-deletes latest version, cascades parent) // DELETE /api/v2/pipelines/environments/{id}/versions/{n} (soft-deletes a specific version) -package pipelines +package pipeline import ( "net/http" diff --git a/internal/pipelines/environment_output.go b/internal/pipeline/environment_output.go similarity index 99% rename from internal/pipelines/environment_output.go rename to internal/pipeline/environment_output.go index d8055a369..3700498b1 100644 --- a/internal/pipelines/environment_output.go +++ b/internal/pipeline/environment_output.go @@ -15,7 +15,7 @@ // environment_output.go centralises the human/JSON output rendering used by // the `dr pipelines environment` verbs so each verb file stays focused on // flag wiring. -package pipelines +package pipeline import ( "encoding/json" diff --git a/internal/pipelines/environment_test.go b/internal/pipeline/environment_test.go similarity index 99% rename from internal/pipelines/environment_test.go rename to internal/pipeline/environment_test.go index ea180c9bf..dd9687de1 100644 --- a/internal/pipelines/environment_test.go +++ b/internal/pipeline/environment_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package pipelines +package pipeline import ( "encoding/json" diff --git a/internal/pipelines/flags.go b/internal/pipeline/flags.go similarity index 98% rename from internal/pipelines/flags.go rename to internal/pipeline/flags.go index a3e044ab2..73dfb4bbd 100644 --- a/internal/pipelines/flags.go +++ b/internal/pipeline/flags.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package pipelines +package pipeline import ( "fmt" diff --git a/internal/pipelines/input.go b/internal/pipeline/input.go similarity index 99% rename from internal/pipelines/input.go rename to internal/pipeline/input.go index 9b8d0274f..f01772ad5 100644 --- a/internal/pipelines/input.go +++ b/internal/pipeline/input.go @@ -16,7 +16,7 @@ // endpoints described in pipelines-api/.../controllers/pipeline_input.py. // Both draft and locked URL shapes are exercised through Scope/version. -package pipelines +package pipeline import ( "net/http" diff --git a/internal/pipelines/input_output.go b/internal/pipeline/input_output.go similarity index 99% rename from internal/pipelines/input_output.go rename to internal/pipeline/input_output.go index af63e9bcf..aaaa5741c 100644 --- a/internal/pipelines/input_output.go +++ b/internal/pipeline/input_output.go @@ -14,7 +14,7 @@ // input_output.go centralises the human/JSON output rendering used by the // input verbs so each verb file stays focused on flag wiring. -package pipelines +package pipeline import ( "encoding/json" diff --git a/internal/pipelines/input_payload.go b/internal/pipeline/input_payload.go similarity index 99% rename from internal/pipelines/input_payload.go rename to internal/pipeline/input_payload.go index ef96b8771..77b242a97 100644 --- a/internal/pipelines/input_payload.go +++ b/internal/pipeline/input_payload.go @@ -15,7 +15,7 @@ // input_payload.go contains the shared helper for resolving an input payload // from either a positional argument or the --from-file flag, then parsing // it as JSON. -package pipelines +package pipeline import ( "encoding/json" diff --git a/internal/pipelines/input_test.go b/internal/pipeline/input_test.go similarity index 99% rename from internal/pipelines/input_test.go rename to internal/pipeline/input_test.go index ca0dadbe8..8fe38900e 100644 --- a/internal/pipelines/input_test.go +++ b/internal/pipeline/input_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package pipelines +package pipeline import ( "encoding/json" diff --git a/internal/pipelines/pipeline.go b/internal/pipeline/pipeline.go similarity index 99% rename from internal/pipelines/pipeline.go rename to internal/pipeline/pipeline.go index b18c7dee6..41e54f71f 100644 --- a/internal/pipelines/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package pipelines +package pipeline import ( "bytes" @@ -91,7 +91,7 @@ type ListItem struct { UpdatedAt time.Time `json:"updatedAt"` } -// CreatePipeline uploads a Python file to POST /api/v2/pipelines. +// CreatePipeline uploads a Python file to POST /api/v2/pipeline. func CreatePipeline(filePath, description, mode string) (*CreateResponse, error) { endpoint, err := config.GetEndpointURL("/api/v2/pipelines") if err != nil { @@ -117,7 +117,7 @@ func CreatePipeline(filePath, description, mode string) (*CreateResponse, error) return &result, nil } -// ListPipelines fetches a paginated list of pipelines from GET /api/v2/pipelines. +// ListPipelines fetches a paginated list of pipelines from GET /api/v2/pipeline. func ListPipelines(mode string, offset, limit int) (*DataPage[ListItem], error) { endpoint, err := config.GetEndpointURL("/api/v2/pipelines") if err != nil { diff --git a/internal/pipelines/pipeline_lifecycle_test.go b/internal/pipeline/pipeline_lifecycle_test.go similarity index 99% rename from internal/pipelines/pipeline_lifecycle_test.go rename to internal/pipeline/pipeline_lifecycle_test.go index a824b82c1..8ab4b24e3 100644 --- a/internal/pipelines/pipeline_lifecycle_test.go +++ b/internal/pipeline/pipeline_lifecycle_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package pipelines +package pipeline import ( "net/http" diff --git a/internal/pipelines/pipeline_output.go b/internal/pipeline/pipeline_output.go similarity index 99% rename from internal/pipelines/pipeline_output.go rename to internal/pipeline/pipeline_output.go index 8c59dac1d..458228392 100644 --- a/internal/pipelines/pipeline_output.go +++ b/internal/pipeline/pipeline_output.go @@ -14,7 +14,7 @@ // pipeline_output.go holds the rendering helpers shared by the top-level // `dr pipelines` verbs (list, get, create, update, lock). -package pipelines +package pipeline import ( "encoding/json" diff --git a/internal/pipelines/pipeline_test.go b/internal/pipeline/pipeline_test.go similarity index 99% rename from internal/pipelines/pipeline_test.go rename to internal/pipeline/pipeline_test.go index 673c9b07f..e3c76b05b 100644 --- a/internal/pipelines/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package pipelines +package pipeline import ( "errors" diff --git a/internal/pipelines/run.go b/internal/pipeline/run.go similarity index 99% rename from internal/pipelines/run.go rename to internal/pipeline/run.go index 975fd8e10..dbc7d0a35 100644 --- a/internal/pipelines/run.go +++ b/internal/pipeline/run.go @@ -21,7 +21,7 @@ // segments are preserved to keep the API contract intact while the Go // surface is renamed to "run" to match the new product vocabulary. -package pipelines +package pipeline import ( "net/http" diff --git a/internal/pipelines/run_output.go b/internal/pipeline/run_output.go similarity index 99% rename from internal/pipelines/run_output.go rename to internal/pipeline/run_output.go index 373aa08c8..562f2e42c 100644 --- a/internal/pipelines/run_output.go +++ b/internal/pipeline/run_output.go @@ -14,7 +14,7 @@ // run_output.go holds the rendering helpers shared by the // `dr pipelines run` verbs. -package pipelines +package pipeline import ( "encoding/json" diff --git a/internal/pipelines/run_test.go b/internal/pipeline/run_test.go similarity index 99% rename from internal/pipelines/run_test.go rename to internal/pipeline/run_test.go index 9ed9065f7..3fd14228d 100644 --- a/internal/pipelines/run_test.go +++ b/internal/pipeline/run_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package pipelines +package pipeline import ( "encoding/json" diff --git a/internal/pipelines/schedule.go b/internal/pipeline/schedule.go similarity index 99% rename from internal/pipelines/schedule.go rename to internal/pipeline/schedule.go index 7ad0cf25c..c63aab31a 100644 --- a/internal/pipelines/schedule.go +++ b/internal/pipeline/schedule.go @@ -17,7 +17,7 @@ // valid for locked pipeline versions, so the URL always carries a // /versions/{ver} segment and there is no Scope parameter on this side. -package pipelines +package pipeline import ( "net/http" diff --git a/internal/pipelines/schedule_output.go b/internal/pipeline/schedule_output.go similarity index 99% rename from internal/pipelines/schedule_output.go rename to internal/pipeline/schedule_output.go index 92d6a0624..8f025248c 100644 --- a/internal/pipelines/schedule_output.go +++ b/internal/pipeline/schedule_output.go @@ -14,7 +14,7 @@ // schedule_output.go holds the rendering helpers shared by the // `dr pipelines schedule` verbs. -package pipelines +package pipeline import ( "encoding/json" diff --git a/internal/pipelines/schedule_test.go b/internal/pipeline/schedule_test.go similarity index 99% rename from internal/pipelines/schedule_test.go rename to internal/pipeline/schedule_test.go index 330c271e8..7f5070a51 100644 --- a/internal/pipelines/schedule_test.go +++ b/internal/pipeline/schedule_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package pipelines +package pipeline import ( "encoding/json" diff --git a/internal/pipelines/scope.go b/internal/pipeline/scope.go similarity index 99% rename from internal/pipelines/scope.go rename to internal/pipeline/scope.go index 7874336cb..bdae7f1b5 100644 --- a/internal/pipelines/scope.go +++ b/internal/pipeline/scope.go @@ -24,7 +24,7 @@ // consistently. PipelinePath then turns the resolved scope into the right // URL fragment. -package pipelines +package pipeline import ( "errors" diff --git a/internal/pipelines/scope_test.go b/internal/pipeline/scope_test.go similarity index 99% rename from internal/pipelines/scope_test.go rename to internal/pipeline/scope_test.go index 65a1c44c5..510caf6b9 100644 --- a/internal/pipelines/scope_test.go +++ b/internal/pipeline/scope_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package pipelines +package pipeline import ( "testing" diff --git a/internal/pipelines/transport.go b/internal/pipeline/transport.go similarity index 99% rename from internal/pipelines/transport.go rename to internal/pipeline/transport.go index bfbd269a3..4fdcb29ce 100644 --- a/internal/pipelines/transport.go +++ b/internal/pipeline/transport.go @@ -22,7 +22,7 @@ // shared drapi.AuthorizeRequest helper so the headers stay consistent with // every other CLI command. -package pipelines +package pipeline import ( "bytes" diff --git a/internal/pipelines/transport_test.go b/internal/pipeline/transport_test.go similarity index 99% rename from internal/pipelines/transport_test.go rename to internal/pipeline/transport_test.go index bea1ce6ed..fb0b578b5 100644 --- a/internal/pipelines/transport_test.go +++ b/internal/pipeline/transport_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package pipelines +package pipeline import ( "encoding/json" diff --git a/internal/pipelines/version.go b/internal/pipeline/version.go similarity index 99% rename from internal/pipelines/version.go rename to internal/pipeline/version.go index 2a0947994..76887713f 100644 --- a/internal/pipelines/version.go +++ b/internal/pipeline/version.go @@ -24,7 +24,7 @@ // defined in pipeline.go). The two graph endpoints return a free-form // dict; we surface its known shape via the Graph struct below. -package pipelines +package pipeline import ( "net/http" diff --git a/internal/pipelines/version_output.go b/internal/pipeline/version_output.go similarity index 99% rename from internal/pipelines/version_output.go rename to internal/pipeline/version_output.go index 3da792553..0a8ebc16e 100644 --- a/internal/pipelines/version_output.go +++ b/internal/pipeline/version_output.go @@ -14,7 +14,7 @@ // version_output.go contains rendering helpers shared by the // `dr pipelines version` verbs. -package pipelines +package pipeline import ( "encoding/json" diff --git a/internal/pipelines/version_test.go b/internal/pipeline/version_test.go similarity index 99% rename from internal/pipelines/version_test.go rename to internal/pipeline/version_test.go index 89e634a82..21d492eb4 100644 --- a/internal/pipelines/version_test.go +++ b/internal/pipeline/version_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package pipelines +package pipeline import ( "net/http"