From 0651acfc998e778858f49ea958df5777126177ee Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Wed, 3 Jun 2026 16:44:14 -0400 Subject: [PATCH 01/14] [CMPT-5391] feat(pipelines): add dr pipeline input subcommands (#547) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cmd/pipeline/cmd.go | 2 + cmd/pipeline/cmd_test.go | 1 + cmd/pipeline/input/cmd.go | 51 ++++++ cmd/pipeline/input/cmd_test.go | 41 +++++ cmd/pipeline/input/create/cmd.go | 95 +++++++++++ cmd/pipeline/input/create/cmd_test.go | 67 ++++++++ cmd/pipeline/input/del/cmd.go | 94 +++++++++++ cmd/pipeline/input/del/cmd_test.go | 56 +++++++ cmd/pipeline/input/get/cmd.go | 94 +++++++++++ cmd/pipeline/input/get/cmd_test.go | 66 ++++++++ cmd/pipeline/input/list/cmd.go | 84 ++++++++++ cmd/pipeline/input/list/cmd_test.go | 61 +++++++ cmd/pipeline/input/update/cmd.go | 77 +++++++++ cmd/pipeline/input/update/cmd_test.go | 66 ++++++++ docs/commands/README.md | 19 ++- docs/commands/pipeline.md | 16 ++ docs/commands/pipelines-reference.md | 26 ++- internal/pipeline/input.go | 153 +++++++++++++++++ internal/pipeline/input_output.go | 193 ++++++++++++++++++++++ internal/pipeline/input_output_test.go | 141 ++++++++++++++++ internal/pipeline/input_payload.go | 71 ++++++++ internal/pipeline/input_payload_test.go | 134 +++++++++++++++ internal/pipeline/input_test.go | 209 ++++++++++++++++++++++++ internal/pipeline/transport_test.go | 14 -- 24 files changed, 1810 insertions(+), 21 deletions(-) create mode 100644 cmd/pipeline/input/cmd.go create mode 100644 cmd/pipeline/input/cmd_test.go create mode 100644 cmd/pipeline/input/create/cmd.go create mode 100644 cmd/pipeline/input/create/cmd_test.go create mode 100644 cmd/pipeline/input/del/cmd.go create mode 100644 cmd/pipeline/input/del/cmd_test.go create mode 100644 cmd/pipeline/input/get/cmd.go create mode 100644 cmd/pipeline/input/get/cmd_test.go create mode 100644 cmd/pipeline/input/list/cmd.go create mode 100644 cmd/pipeline/input/list/cmd_test.go create mode 100644 cmd/pipeline/input/update/cmd.go create mode 100644 cmd/pipeline/input/update/cmd_test.go create mode 100644 internal/pipeline/input.go create mode 100644 internal/pipeline/input_output.go create mode 100644 internal/pipeline/input_output_test.go create mode 100644 internal/pipeline/input_payload.go create mode 100644 internal/pipeline/input_payload_test.go create mode 100644 internal/pipeline/input_test.go diff --git a/cmd/pipeline/cmd.go b/cmd/pipeline/cmd.go index 2c650b8d8..0ed096c58 100644 --- a/cmd/pipeline/cmd.go +++ b/cmd/pipeline/cmd.go @@ -19,6 +19,7 @@ import ( "github.com/datarobot/cli/cmd/pipeline/del" "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" @@ -53,6 +54,7 @@ input payloads, runs, and recurring schedules.`, version.Cmd(), graph.Cmd(), run.Cmd(), + input.Cmd(), ) return cmd diff --git a/cmd/pipeline/cmd_test.go b/cmd/pipeline/cmd_test.go index db8a31203..586370e20 100644 --- a/cmd/pipeline/cmd_test.go +++ b/cmd/pipeline/cmd_test.go @@ -64,6 +64,7 @@ func TestCmd_HasExpectedSubcommands(t *testing.T) { "version": false, "graph": false, "run": false, + "input": false, } for _, sub := range cmd.Commands() { diff --git a/cmd/pipeline/input/cmd.go b/cmd/pipeline/input/cmd.go new file mode 100644 index 000000000..f2e6955b0 --- /dev/null +++ b/cmd/pipeline/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/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 pipeline 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/pipeline/input/cmd_test.go b/cmd/pipeline/input/cmd_test.go new file mode 100644 index 000000000..4b7923a46 --- /dev/null +++ b/cmd/pipeline/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/pipeline/input/create/cmd.go b/cmd/pipeline/input/create/cmd.go new file mode 100644 index 000000000..26e688a6d --- /dev/null +++ b/cmd/pipeline/input/create/cmd.go @@ -0,0 +1,95 @@ +// 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" + + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + fromFile string + outputFormat pipeline.OutputFormat + ) + + 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 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, + 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 + } + + payload, err := pipeline.ResolvePayload(args, fromFile) + if err != nil { + return err + } + + result, err := pipeline.CreateInput(flags.PipelineID, scope, version, payload) + if err != nil { + return err + } + + return pipeline.RenderInput(outputFormat, *result) + }, + } + + flags.Bind(cmd) + _ = cmd.MarkFlagRequired("pipeline") + cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the JSON payload file, e.g. --from-file=./payload.json (alternative to the positional argument)") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "scope": flags.Scope, + "version": flags.Version, + "output_format": string(outputFormat), + } + }) + + return cmd +} diff --git a/cmd/pipeline/input/create/cmd_test.go b/cmd/pipeline/input/create/cmd_test.go new file mode 100644 index 000000000..3fa401fb1 --- /dev/null +++ b/cmd/pipeline/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-format", "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-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/input/del/cmd.go b/cmd/pipeline/input/del/cmd.go new file mode 100644 index 000000000..9e051c06a --- /dev/null +++ b/cmd/pipeline/input/del/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 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. + +package del + +import ( + "errors" + "fmt" + "net/http" + + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "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 pipeline input delete --pipeline + dr pipeline input delete --pipeline --version=2 `, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + err = pipeline.DeleteInput(flags.PipelineID, scope, version, args[0]) + if err != nil { + return handleDeleteError(err, args[0]) + } + + fmt.Println(tui.BaseTextStyle.Render("Deleted input: " + args[0])) + + return nil + }, + } + + flags.Bind(cmd) + _ = cmd.MarkFlagRequired("pipeline") + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "input_id": telemetry.FirstArg(args), + "scope": flags.Scope, + "version": flags.Version, + } + }) + + 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, id 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: " + id)) + + return nil + } + + return err +} diff --git a/cmd/pipeline/input/del/cmd_test.go b/cmd/pipeline/input/del/cmd_test.go new file mode 100644 index 000000000..3c6a1ffe8 --- /dev/null +++ b/cmd/pipeline/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/pipeline/input/get/cmd.go b/cmd/pipeline/input/get/cmd.go new file mode 100644 index 000000000..bc28f872c --- /dev/null +++ b/cmd/pipeline/input/get/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 get + +import ( + "errors" + "fmt" + "net/http" + + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + outputFormat pipeline.OutputFormat + ) + + 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 pipeline input get --pipeline + dr pipeline 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 flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + result, err := pipeline.GetInput(flags.PipelineID, scope, version, args[0]) + if err != nil { + return handleGetError(err, args[0]) + } + + return pipeline.RenderInput(outputFormat, *result) + }, + } + + flags.Bind(cmd) + _ = cmd.MarkFlagRequired("pipeline") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "input_id": telemetry.FirstArg(args), + "scope": flags.Scope, + "version": flags.Version, + "output_format": string(outputFormat), + } + }) + + 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/pipeline/input/get/cmd_test.go b/cmd/pipeline/input/get/cmd_test.go new file mode 100644 index 000000000..c0e25f65b --- /dev/null +++ b/cmd/pipeline/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-format", "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/pipeline/input/list/cmd.go b/cmd/pipeline/input/list/cmd.go new file mode 100644 index 000000000..0140dddbd --- /dev/null +++ b/cmd/pipeline/input/list/cmd.go @@ -0,0 +1,84 @@ +// 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 ( + "github.com/datarobot/cli/cmd/pipeline/scopeflag" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + flags scopeflag.Flags + offset int + limit int + outputFormat pipeline.OutputFormat + ) + + 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 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, + RunE: func(cmd *cobra.Command, _ []string) error { + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + items, err := pipeline.ListInputs(flags.PipelineID, scope, version, offset, limit) + if err != nil { + return err + } + + return pipeline.RenderInputs(outputFormat, items) + }, + } + + flags.Bind(cmd) + _ = cmd.MarkFlagRequired("pipeline") + cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") + cmd.Flags().IntVar(&limit, "limit", 100, "Maximum number of inputs to return") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "scope": flags.Scope, + "version": flags.Version, + "offset": offset, + "limit": limit, + "output_format": string(outputFormat), + } + }) + + return cmd +} diff --git a/cmd/pipeline/input/list/cmd_test.go b/cmd/pipeline/input/list/cmd_test.go new file mode 100644 index 000000000..c39ace176 --- /dev/null +++ b/cmd/pipeline/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-format", "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-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/input/update/cmd.go b/cmd/pipeline/input/update/cmd.go new file mode 100644 index 000000000..1351f7836 --- /dev/null +++ b/cmd/pipeline/input/update/cmd.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 update + +import ( + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + pipelineID string + fromFile string + outputFormat pipeline.OutputFormat + ) + + 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 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, + RunE: func(_ *cobra.Command, args []string) error { + inputID := args[0] + + payload, err := pipeline.ResolvePayload(args[1:], fromFile) + if err != nil { + return err + } + + result, err := pipeline.UpdateInput(pipelineID, inputID, payload) + if err != nil { + return err + } + + return pipeline.RenderInput(outputFormat, *result) + }, + } + + cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + _ = cmd.MarkFlagRequired("pipeline") + cmd.Flags().StringVar(&fromFile, "from-file", "", "Path to the JSON payload file, e.g. --from-file=./payload.json (alternative to the positional argument)") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": pipelineID, + "input_id": telemetry.FirstArg(args), + "output_format": string(outputFormat), + } + }) + + return cmd +} diff --git a/cmd/pipeline/input/update/cmd_test.go b/cmd/pipeline/input/update/cmd_test.go new file mode 100644 index 000000000..8e7bd2584 --- /dev/null +++ b/cmd/pipeline/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-format", "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-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/docs/commands/README.md b/docs/commands/README.md index 4a4e0692a..f2e7be109 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -86,12 +86,18 @@ dr │ │ ├── list List versions of a pipeline │ │ └── get Display details of a single pipeline version │ ├── graph Display the pipeline/task DAG of a pipeline -│ └── 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 +│ ├── 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 +│ └── 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 └── self CLI utility commands ├── completion Shell completion │ ├── install Install completions interactively @@ -263,6 +269,7 @@ For detailed documentation on each command, see: - `version`—`list` / `get` to inspect pipeline versions. - `graph`—display the pipeline/task DAG (draft or locked). - `run`—`create`/`list`/`get`/`status`/`cancel` pipeline executions. + - `input`—`create`/`list`/`get`/`update`/`delete` JSON payloads used by runs. ## Getting help diff --git a/docs/commands/pipeline.md b/docs/commands/pipeline.md index 226ac32a4..332bda465 100644 --- a/docs/commands/pipeline.md +++ b/docs/commands/pipeline.md @@ -79,6 +79,7 @@ dr pipeline lock | `dr pipeline version …` | `…/versions[/{ver}]` | Inspect pipeline versions. | | `dr pipeline graph` | `…/graph` (draft or locked) | Render the pipeline/task DAG. | | `dr pipeline run …` | `…/dispatches` and `…/{id}` | Trigger, inspect, and cancel runs. | +| `dr pipeline input …` | `…/inputs` and `…/inputs/{input_id}` | Manage JSON payloads for runs. | ## Subcommands @@ -301,6 +302,21 @@ dr pipeline version get --pipeline 2 dr pipeline graph --pipeline --version=2 --output-format json ``` +### `input` + +Manage JSON payloads that drive a run. + +```bash +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 `{"payload": …}` before sending. + ### `run` Trigger, inspect, and cancel pipeline executions. diff --git a/docs/commands/pipelines-reference.md b/docs/commands/pipelines-reference.md index b4a37394a..5d55b9520 100644 --- a/docs/commands/pipelines-reference.md +++ b/docs/commands/pipelines-reference.md @@ -45,7 +45,7 @@ accepts. ## Shared flag semantics -### `--scope` / `--version` (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 @@ -86,6 +86,21 @@ exercising a local API stub that doesn't implement `/version/`. --- +## Inputs (`dr pipeline input …`) + +Inputs exist in two scopes — **draft** and **locked** — selected via `--scope` / `--version`. + +| 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 --version=2 ./payload.json --output-format json` | **Positional:** `` (JSON object; mutually exclusive with `--from-file`).
**Flags:** `--pipeline ` (required), `--scope`, `--version`, `--from-file=`, `--output-format json`. | +| `dr pipeline input list` | `GET /pipelines/{id}/inputs` (draft)
`GET /pipelines/{id}/versions/{ver}/inputs` (locked) | `dr pipeline input list --pipeline ` | **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--offset `, `--limit `, `--output-format 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 ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output-format json`. | +| `dr pipeline input update` | `PATCH /pipelines/{id}/inputs/{input_id}` (draft only) | `dr pipeline input update --pipeline ./payload.json` | **Positional:** `` (required), ``. **Flags:** `--pipeline ` (required), `--from-file=`, `--output-format json`. | +| `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 ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--scope`, `--version`. | + +--- + + ## Runs (`dr pipeline run …`) Same draft/locked scope rules as graph. The wire-level URLs still use the legacy @@ -120,3 +135,12 @@ term `dispatches` / `dispatch_id`, but the CLI's `--output-format json` remaps t | `GET /pipelines/{id}/dispatches` | `dr pipeline run list` (draft) | | `GET /pipelines/{id}/dispatches/{dispatch_id}` | `dr pipeline run get` (draft) | | `DELETE /pipelines/{id}/dispatches/{dispatch_id}` | `dr pipeline run cancel` (draft) | +| `POST /pipelines/{id}/inputs` | `dr pipeline input create` (draft) | +| `POST /pipelines/{id}/versions/{ver}/inputs` | `dr pipeline input create` (locked) | +| `GET /pipelines/{id}/inputs` | `dr pipeline input list` (draft) | +| `GET /pipelines/{id}/versions/{ver}/inputs` | `dr pipeline input list` (locked) | +| `GET /pipelines/{id}/inputs/{input_id}` | `dr pipeline input get` (draft) | +| `GET /pipelines/{id}/versions/{ver}/inputs/{input_id}` | `dr pipeline input get` (locked) | +| `PATCH /pipelines/{id}/inputs/{input_id}` | `dr pipeline input update` (draft) | +| `DELETE /pipelines/{id}/inputs/{input_id}` | `dr pipeline input delete` (draft) | +| `DELETE /pipelines/{id}/versions/{ver}/inputs/{input_id}` | `dr pipeline input delete` (locked) | diff --git a/internal/pipeline/input.go b/internal/pipeline/input.go new file mode 100644 index 000000000..f01772ad5 --- /dev/null +++ b/internal/pipeline/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 pipeline + +import ( + "net/http" + "net/url" + "strconv" + "time" +) + +// 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:"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:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// 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 + } + + var page DataPage[Input] + + err = doJSON(http.MethodGet, endpoint, nil, "inputs", &page) + if err != nil { + return nil, err + } + + return page.Data, 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/pipeline/input_output.go b/internal/pipeline/input_output.go new file mode 100644 index 000000000..6d8f9158b --- /dev/null +++ b/internal/pipeline/input_output.go @@ -0,0 +1,193 @@ +// 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_output.go centralises the human/JSON output rendering used by the +// input verbs so each verb file stays focused on flag wiring. +package pipeline + +import ( + "encoding/json" + "fmt" + "os" + "slices" + "strconv" + "text/tabwriter" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/datarobot/cli/tui" +) + +// 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"` + VersionID *int `json:"version_id,omitempty"` + State string `json:"state"` + Payload map[string]any `json:"payload"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toInputJSON(input Input) inputJSON { + scope := "draft" + + if input.VersionID != nil { + scope = "locked" + } + + return inputJSON{ + InputID: input.InputID, + PipelineID: input.PipelineID, + Scope: scope, + VersionID: input.VersionID, + State: string(input.State), + Payload: input.Payload, + 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 OutputFormat, input Input) error { + if format == OutputFormatJSON { + return PrintInputJSON(input) + } + + PrintInputHuman(input) + + return nil +} + +// RenderInputs routes a list of inputs to JSON or human output. +func RenderInputs(format OutputFormat, inputs []Input) error { + if format == OutputFormatJSON { + return PrintInputListJSON(inputs) + } + + PrintInputListHuman(inputs) + + return nil +} + +// PrintInputJSON marshals an input record as indented JSON through the DTO. +func PrintInputJSON(input Input) error { + data, err := json.MarshalIndent(toInputJSON(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 Input) { + scope := "draft" + versionDisplay := emptyValuePlaceholder + + if input.VersionID != nil { + scope = "locked" + versionDisplay = strconv.Itoa(*input.VersionID) + } + + 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 { + return + } + + fmt.Println() + fmt.Println(tui.BaseTextStyle.Render("Payload:")) + fmt.Println(string(payload)) +} + +// PrintInputListJSON marshals a list of inputs as indented JSON through the DTO. +func PrintInputListJSON(inputs []Input) error { + view := make([]inputJSON, len(inputs)) + + for i, in := range inputs { + view[i] = toInputJSON(in) + } + + data, err := json.MarshalIndent(view, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintInputListHuman renders a lipgloss table summary of inputs. +func PrintInputListHuman(inputs []Input) { + if len(inputs) == 0 { + fmt.Println(tui.DimStyle.Render("No inputs found")) + + return + } + + 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 + } + + return cellStyle + }). + Headers(headers...) + + for _, in := range inputs { + scope := "draft" + ver := emptyValuePlaceholder + + if in.VersionID != nil { + scope = "locked" + ver = strconv.Itoa(*in.VersionID) + } + + t.Row(in.InputID, scope, ver, string(in.State), in.UpdatedAt.UTC().Format(timestampFormat)) + } + + fmt.Fprintln(os.Stdout, t.Render()) +} diff --git a/internal/pipeline/input_output_test.go b/internal/pipeline/input_output_test.go new file mode 100644 index 000000000..c92174541 --- /dev/null +++ b/internal/pipeline/input_output_test.go @@ -0,0 +1,141 @@ +// 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 pipeline + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func sampleInput() Input { + ver := 3 + + return Input{ + InputID: "in-1", + PipelineID: "p-1", + VersionID: &ver, + IsDraft: false, + Payload: map[string]any{"key": "value"}, + State: InputStateValid, + CreatedAt: time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 4, 29, 10, 5, 0, 0, time.UTC), + } +} + +// ── toInputJSON remapping ──────────────────────────────────────────────────── + +func TestToInputJSON_RemapsWireFields(t *testing.T) { + j := toInputJSON(sampleInput()) + + assert.Equal(t, "in-1", j.InputID) + assert.Equal(t, "p-1", j.PipelineID) + assert.Equal(t, "locked", j.Scope) + require.NotNil(t, j.VersionID) + assert.Equal(t, 3, *j.VersionID) + assert.Equal(t, string(InputStateValid), j.State) + assert.Equal(t, map[string]any{"key": "value"}, j.Payload) +} + +func TestToInputJSON_DraftScope(t *testing.T) { + in := sampleInput() + in.VersionID = nil + + j := toInputJSON(in) + + assert.Equal(t, "draft", j.Scope) + assert.Nil(t, j.VersionID) +} + +func TestToInputJSON_FormatsTimestampsAsRFC3339(t *testing.T) { + j := toInputJSON(sampleInput()) + + assert.Equal(t, "2026-04-29T10:00:00Z", j.CreatedAt) + assert.Equal(t, "2026-04-29T10:05:00Z", j.UpdatedAt) +} + +func TestToInputJSON_JSONKeysUseCliVocabulary(t *testing.T) { + data, err := json.Marshal(toInputJSON(sampleInput())) + require.NoError(t, err) + + var raw map[string]any + + require.NoError(t, json.Unmarshal(data, &raw)) + + assert.Contains(t, raw, "input_id", "wire 'id' must be remapped to 'input_id'") + assert.Contains(t, raw, "pipeline_id") + assert.Contains(t, raw, "scope") + assert.Contains(t, raw, "version_id") + assert.NotContains(t, raw, "id", "raw wire key 'id' must not appear in CLI output") + assert.NotContains(t, raw, "pipelineId") + assert.NotContains(t, raw, "versionId") +} + +// ── RenderInput ────────────────────────────────────────────────────────────── + +func TestRenderInput_JSON(t *testing.T) { + out := captureStdout(t, func() { + require.NoError(t, RenderInput(OutputFormatJSON, sampleInput())) + }) + + var parsed map[string]any + + require.NoError(t, json.Unmarshal([]byte(out), &parsed)) + assert.Equal(t, "in-1", parsed["input_id"]) + assert.Equal(t, "locked", parsed["scope"]) + assert.Equal(t, "2026-04-29T10:00:00Z", parsed["created_at"]) +} + +func TestRenderInput_Human(t *testing.T) { + out := captureStdout(t, func() { PrintInputHuman(sampleInput()) }) + + assert.Contains(t, out, "in-1") + assert.Contains(t, out, "locked") + assert.Contains(t, out, string(InputStateValid)) +} + +// ── PrintInputListJSON ─────────────────────────────────────────────────────── + +func TestPrintInputListJSON_RemapsFields(t *testing.T) { + out := captureStdout(t, func() { + require.NoError(t, PrintInputListJSON([]Input{sampleInput()})) + }) + + var parsed []map[string]any + + require.NoError(t, json.Unmarshal([]byte(out), &parsed)) + require.Len(t, parsed, 1) + assert.Equal(t, "in-1", parsed[0]["input_id"]) + assert.Equal(t, "locked", parsed[0]["scope"]) +} + +// ── PrintInputListHuman ────────────────────────────────────────────────────── + +func TestPrintInputListHuman_Empty(t *testing.T) { + out := captureStdout(t, func() { PrintInputListHuman(nil) }) + assert.Contains(t, out, "No inputs found") +} + +func TestPrintInputListHuman_RendersTable(t *testing.T) { + out := captureStdout(t, func() { PrintInputListHuman([]Input{sampleInput()}) }) + + assert.Contains(t, out, "INPUT ID") + assert.Contains(t, out, "in-1") + assert.Contains(t, out, "locked") + assert.Contains(t, out, string(InputStateValid)) +} diff --git a/internal/pipeline/input_payload.go b/internal/pipeline/input_payload.go new file mode 100644 index 000000000..dc92530d5 --- /dev/null +++ b/internal/pipeline/input_payload.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. + +// 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 pipeline + +import ( + "encoding/json" + "errors" + "fmt" + "os" +) + +// ResolvePayload mirrors the create/update flag pattern from +// `dr pipeline 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 := resolvePayloadFilePath(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 +} + +// resolvePayloadFilePath returns the file path supplied either positionally or +// via --from-file. Exactly one of the two must be provided. +func resolvePayloadFilePath(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/internal/pipeline/input_payload_test.go b/internal/pipeline/input_payload_test.go new file mode 100644 index 000000000..a1f594826 --- /dev/null +++ b/internal/pipeline/input_payload_test.go @@ -0,0 +1,134 @@ +// 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 pipeline + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeTemp(t *testing.T, content string) string { + t.Helper() + + f, err := os.CreateTemp(t.TempDir(), "payload-*.json") + + require.NoError(t, err) + + _, err = f.WriteString(content) + + require.NoError(t, err) + require.NoError(t, f.Close()) + + return f.Name() +} + +// ── resolvePayloadFilePath ─────────────────────────────────────────────────── + +func TestResolvePayloadFilePath_Positional(t *testing.T) { + path, err := resolvePayloadFilePath([]string{"/some/file.json"}, "") + + require.NoError(t, err) + assert.Equal(t, "/some/file.json", path) +} + +func TestResolvePayloadFilePath_FromFile(t *testing.T) { + path, err := resolvePayloadFilePath(nil, "/some/file.json") + + require.NoError(t, err) + assert.Equal(t, "/some/file.json", path) +} + +func TestResolvePayloadFilePath_BothProvided(t *testing.T) { + _, err := resolvePayloadFilePath([]string{"/a.json"}, "/b.json") + + require.Error(t, err) + assert.Contains(t, err.Error(), "not both") +} + +func TestResolvePayloadFilePath_NeitherProvided(t *testing.T) { + _, err := resolvePayloadFilePath(nil, "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "required") +} + +// ── ResolvePayload ─────────────────────────────────────────────────────────── + +func TestResolvePayload_PositionalArg(t *testing.T) { + path := writeTemp(t, `{"key":"value"}`) + + got, err := ResolvePayload([]string{path}, "") + + require.NoError(t, err) + assert.Equal(t, map[string]any{"key": "value"}, got) +} + +func TestResolvePayload_FromFile(t *testing.T) { + path := writeTemp(t, `{"count":42}`) + + got, err := ResolvePayload(nil, path) + + require.NoError(t, err) + assert.Equal(t, map[string]any{"count": float64(42)}, got) +} + +func TestResolvePayload_BothProvided(t *testing.T) { + path := writeTemp(t, `{}`) + + _, err := ResolvePayload([]string{path}, path) + + require.Error(t, err) + assert.Contains(t, err.Error(), "not both") +} + +func TestResolvePayload_NeitherProvided(t *testing.T) { + _, err := ResolvePayload(nil, "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "required") +} + +func TestResolvePayload_FileNotFound(t *testing.T) { + missing := filepath.Join(t.TempDir(), "does-not-exist.json") + + _, err := ResolvePayload([]string{missing}, "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "read") +} + +func TestResolvePayload_InvalidJSON(t *testing.T) { + path := writeTemp(t, `not json`) + + _, err := ResolvePayload([]string{path}, "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "parse") + assert.Contains(t, err.Error(), "JSON object") +} + +func TestResolvePayload_NonObjectJSON(t *testing.T) { + path := writeTemp(t, `[1, 2, 3]`) + + _, err := ResolvePayload([]string{path}, "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "parse") + assert.Contains(t, err.Error(), "JSON object") +} diff --git a/internal/pipeline/input_test.go b/internal/pipeline/input_test.go new file mode 100644 index 000000000..b96574ea4 --- /dev/null +++ b/internal/pipeline/input_test.go @@ -0,0 +1,209 @@ +// 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 pipeline + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/datarobot/cli/internal/config" + "github.com/datarobot/cli/internal/config/viperx" + "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 := viperx.GetString(config.DataRobotURL) + + viperx.Set(config.DataRobotURL, url) + + t.Cleanup(func() { + viperx.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(`{"id":"in-1","pipelineId":"p-1","isDraft":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(`{"id":"in-1","pipelineId":"p-1","versionId":2,"isDraft":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, http.MethodGet, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/inputs", 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(`{"data":[{"id":"in-1","pipelineId":"p-1","isDraft":true,"state":"VALID","payload":{}}],"totalCount":1,"count":1}`)) + })) + + 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(`{"data":[],"totalCount":0,"count":0}`)) + })) + + 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(`{"id":"in-1","pipelineId":"p-1","isDraft":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(`{"id":"in-1","pipelineId":"p-1","isDraft":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/pipeline/transport_test.go b/internal/pipeline/transport_test.go index b8a051bab..fb0b578b5 100644 --- a/internal/pipeline/transport_test.go +++ b/internal/pipeline/transport_test.go @@ -46,20 +46,6 @@ func installSkipAuth(t *testing.T) { }) } -// installEndpoint temporarily sets the DataRobot URL viper key to url, -// restoring the previous value at test cleanup. -func installEndpoint(t *testing.T, url string) { - t.Helper() - - prev := viperx.GetString(config.DataRobotURL) - - viperx.Set(config.DataRobotURL, url) - - t.Cleanup(func() { - viperx.Set(config.DataRobotURL, prev) - }) -} - func TestBuildJSONRequest_BodyAndHeaders(t *testing.T) { installSkipAuth(t) From 18b827b145845841ca168090a0ac95e9a5bc4362 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 25 May 2026 13:04:29 -0400 Subject: [PATCH 02/14] sync drapi and pipelines infrastructure with sunny/pipelines Co-Authored-By: Claude Sonnet 4.6 From 29e048402dd5a58b64248f13944f53b0a96d2dcd Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 25 May 2026 13:04:29 -0400 Subject: [PATCH 03/14] sync drapi and pipelines infrastructure with sunny/pipelines Co-Authored-By: Claude Sonnet 4.6 From 1ed9d285fb4bc19bd3d8d600e8fc374d5adea35e Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 25 May 2026 13:10:52 -0400 Subject: [PATCH 04/14] sync run structs, output, and commands with sunny/pipelines Co-Authored-By: Claude Sonnet 4.6 --- internal/pipeline/run_output.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/pipeline/run_output.go b/internal/pipeline/run_output.go index c75e056f9..c0d1cae4a 100644 --- a/internal/pipeline/run_output.go +++ b/internal/pipeline/run_output.go @@ -129,7 +129,7 @@ func PrintRunHuman(r Run) { if r.VersionID != nil { scope = "locked" - versionDisplay = strconv.Itoa(*r.VersionID) + versionDisplay = "v" + strconv.Itoa(*r.VersionID) } covalent := r.CovalentDispatchID @@ -215,7 +215,7 @@ func PrintRunListHuman(items []Run) { if r.VersionID != nil { scope = "locked" - ver = strconv.Itoa(*r.VersionID) + ver = "v" + strconv.Itoa(*r.VersionID) } t.Row(r.RunID, scope, ver, r.Status, r.TriggeredBy, r.UpdatedAt.UTC().Format(timestampFormat)) From 41f569dface6fa5329454f66070080fa3ccfac05 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 25 May 2026 16:19:21 -0400 Subject: [PATCH 05/14] [CMPT-5391] address PR feedback: MarkFlagRequired, 404 del suppression, limit defaults, version prefix Co-Authored-By: Claude Sonnet 4.6 --- internal/pipeline/run_output.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/pipeline/run_output.go b/internal/pipeline/run_output.go index c0d1cae4a..c75e056f9 100644 --- a/internal/pipeline/run_output.go +++ b/internal/pipeline/run_output.go @@ -129,7 +129,7 @@ func PrintRunHuman(r Run) { if r.VersionID != nil { scope = "locked" - versionDisplay = "v" + strconv.Itoa(*r.VersionID) + versionDisplay = strconv.Itoa(*r.VersionID) } covalent := r.CovalentDispatchID @@ -215,7 +215,7 @@ func PrintRunListHuman(items []Run) { if r.VersionID != nil { scope = "locked" - ver = "v" + strconv.Itoa(*r.VersionID) + ver = strconv.Itoa(*r.VersionID) } t.Row(r.RunID, scope, ver, r.Status, r.TriggeredBy, r.UpdatedAt.UTC().Format(timestampFormat)) From 0a4fb36f0a5de57cde4876c8279ed40df78b4396 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Wed, 20 May 2026 14:16:13 -0400 Subject: [PATCH 06/14] [CMPT-5391] add dr pipelines schedule subcommand group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the schedule subcommand group for managing recurring (cron) runs on locked pipeline versions (create/list/get/update/delete). - internal/pipelines/schedule.go: CreateSchedule, ListSchedules, GetSchedule, UpdateSchedule, DeleteSchedule - cmd/pipelines/schedule/: schedule create/list/get/update/delete + scheduleutil - docs: schedule section in pipelines.md, schedule endpoints in pipelines-reference.md Schedules are locked-only — every verb requires both --pipeline and --version flags. Co-Authored-By: Claude Sonnet 4.6 --- cmd/pipeline/cmd.go | 2 + cmd/pipeline/cmd_test.go | 7 +- cmd/pipeline/schedule/cmd.go | 46 ++++++ cmd/pipeline/schedule/cmd_test.go | 41 +++++ cmd/pipeline/schedule/create/cmd.go | 98 +++++++++++ cmd/pipeline/schedule/create/cmd_test.go | 77 +++++++++ cmd/pipeline/schedule/del/cmd.go | 71 ++++++++ cmd/pipeline/schedule/del/cmd_test.go | 56 +++++++ cmd/pipeline/schedule/get/cmd.go | 93 +++++++++++ cmd/pipeline/schedule/get/cmd_test.go | 72 +++++++++ cmd/pipeline/schedule/list/cmd.go | 82 ++++++++++ cmd/pipeline/schedule/list/cmd_test.go | 61 +++++++ cmd/pipeline/schedule/scheduleutil/render.go | 87 ++++++++++ .../schedule/scheduleutil/render_test.go | 116 +++++++++++++ cmd/pipeline/schedule/update/cmd.go | 115 +++++++++++++ cmd/pipeline/schedule/update/cmd_test.go | 108 +++++++++++++ docs/commands/README.md | 19 ++- docs/commands/pipeline.md | 16 ++ docs/commands/pipelines-reference.md | 19 +++ internal/pipeline/schedule.go | 152 ++++++++++++++++++ internal/pipeline/schedule_test.go | 142 ++++++++++++++++ 21 files changed, 1471 insertions(+), 9 deletions(-) create mode 100644 cmd/pipeline/schedule/cmd.go create mode 100644 cmd/pipeline/schedule/cmd_test.go create mode 100644 cmd/pipeline/schedule/create/cmd.go create mode 100644 cmd/pipeline/schedule/create/cmd_test.go create mode 100644 cmd/pipeline/schedule/del/cmd.go create mode 100644 cmd/pipeline/schedule/del/cmd_test.go create mode 100644 cmd/pipeline/schedule/get/cmd.go create mode 100644 cmd/pipeline/schedule/get/cmd_test.go create mode 100644 cmd/pipeline/schedule/list/cmd.go create mode 100644 cmd/pipeline/schedule/list/cmd_test.go create mode 100644 cmd/pipeline/schedule/scheduleutil/render.go create mode 100644 cmd/pipeline/schedule/scheduleutil/render_test.go create mode 100644 cmd/pipeline/schedule/update/cmd.go create mode 100644 cmd/pipeline/schedule/update/cmd_test.go create mode 100644 internal/pipeline/schedule.go create mode 100644 internal/pipeline/schedule_test.go diff --git a/cmd/pipeline/cmd.go b/cmd/pipeline/cmd.go index 0ed096c58..a9af0aaa7 100644 --- a/cmd/pipeline/cmd.go +++ b/cmd/pipeline/cmd.go @@ -23,6 +23,7 @@ import ( "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" @@ -55,6 +56,7 @@ input payloads, runs, and recurring schedules.`, graph.Cmd(), run.Cmd(), input.Cmd(), + schedule.Cmd(), ) return cmd diff --git a/cmd/pipeline/cmd_test.go b/cmd/pipeline/cmd_test.go index 586370e20..ee356f466 100644 --- a/cmd/pipeline/cmd_test.go +++ b/cmd/pipeline/cmd_test.go @@ -62,9 +62,10 @@ func TestCmd_HasExpectedSubcommands(t *testing.T) { "delete": false, "lock": false, "version": false, - "graph": false, - "run": false, - "input": false, + "graph": false, + "run": false, + "input": false, + "schedule": false, } for _, sub := range cmd.Commands() { diff --git a/cmd/pipeline/schedule/cmd.go b/cmd/pipeline/schedule/cmd.go new file mode 100644 index 000000000..5cb94c5b2 --- /dev/null +++ b/cmd/pipeline/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/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`. +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "schedule", + Short: "Manage pipeline schedules", + Long: `Manage recurring (cron) runs 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/pipeline/schedule/cmd_test.go b/cmd/pipeline/schedule/cmd_test.go new file mode 100644 index 000000000..5898ff7dd --- /dev/null +++ b/cmd/pipeline/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/pipeline/schedule/create/cmd.go b/cmd/pipeline/schedule/create/cmd.go new file mode 100644 index 000000000..fdbf9f9b8 --- /dev/null +++ b/cmd/pipeline/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/pipeline/schedule/scheduleutil" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "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 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`, + 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 := pipeline.ScheduleCreateRequest{ + CronExpression: cron, + PipelineInputID: inputID, + Timezone: timezone, + } + + result, err := pipeline.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 run 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/pipeline/schedule/create/cmd_test.go b/cmd/pipeline/schedule/create/cmd_test.go new file mode 100644 index 000000000..5d2d64639 --- /dev/null +++ b/cmd/pipeline/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/pipeline/schedule/del/cmd.go b/cmd/pipeline/schedule/del/cmd.go new file mode 100644 index 000000000..e4b65cc0a --- /dev/null +++ b/cmd/pipeline/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/pipeline" + "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 := pipeline.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/pipeline/schedule/del/cmd_test.go b/cmd/pipeline/schedule/del/cmd_test.go new file mode 100644 index 000000000..5eb4167f7 --- /dev/null +++ b/cmd/pipeline/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/pipeline/schedule/get/cmd.go b/cmd/pipeline/schedule/get/cmd.go new file mode 100644 index 000000000..4bb2dd586 --- /dev/null +++ b/cmd/pipeline/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/pipeline/schedule/scheduleutil" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipeline" + "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 := pipeline.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/pipeline/schedule/get/cmd_test.go b/cmd/pipeline/schedule/get/cmd_test.go new file mode 100644 index 000000000..2e6495193 --- /dev/null +++ b/cmd/pipeline/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/pipeline/schedule/list/cmd.go b/cmd/pipeline/schedule/list/cmd.go new file mode 100644 index 000000000..6cecd9a97 --- /dev/null +++ b/cmd/pipeline/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/pipeline/schedule/scheduleutil" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "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 := pipeline.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/pipeline/schedule/list/cmd_test.go b/cmd/pipeline/schedule/list/cmd_test.go new file mode 100644 index 000000000..a819dd2a8 --- /dev/null +++ b/cmd/pipeline/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/pipeline/schedule/scheduleutil/render.go b/cmd/pipeline/schedule/scheduleutil/render.go new file mode 100644 index 000000000..f69c6b4ac --- /dev/null +++ b/cmd/pipeline/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/pipeline" + "github.com/datarobot/cli/tui" +) + +// PrintScheduleJSON marshals a schedule as indented JSON. +func PrintScheduleJSON(s pipeline.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 pipeline.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)) + fmt.Println(tui.DimStyle.Render("Updated: " + s.UpdatedAt)) +} + +// PrintScheduleListJSON marshals a list of schedules as indented JSON. +func PrintScheduleListJSON(items []pipeline.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 []pipeline.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/pipeline/schedule/scheduleutil/render_test.go b/cmd/pipeline/schedule/scheduleutil/render_test.go new file mode 100644 index 000000000..c4ce80d3f --- /dev/null +++ b/cmd/pipeline/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/pipeline" + "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() pipeline.Schedule { + return pipeline.Schedule{ + ScheduleID: "s-1", + PipelineID: "pl-1", + Version: 2, + CronExpression: "0 * * * *", + Timezone: "UTC", + Status: pipeline.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([]pipeline.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([]pipeline.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/pipeline/schedule/update/cmd.go b/cmd/pipeline/schedule/update/cmd.go new file mode 100644 index 000000000..469355023 --- /dev/null +++ b/cmd/pipeline/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/pipeline/schedule/scheduleutil" + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "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 := pipeline.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) (pipeline.ScheduleUpdateRequest, error) { + if outputFormat != "" && outputFormat != "json" { + return pipeline.ScheduleUpdateRequest{}, fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) + } + + if pipelineID == "" { + return pipeline.ScheduleUpdateRequest{}, errors.New("--pipeline is required") + } + + if version <= 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 pipeline.ScheduleUpdateRequest{}, errors.New("at least one of --cron or --timezone must be specified") + } + + body := pipeline.ScheduleUpdateRequest{} + + if cronChanged { + v := cron + body.CronExpression = &v + } + + if tzChanged { + v := timezone + body.Timezone = &v + } + + return body, nil +} diff --git a/cmd/pipeline/schedule/update/cmd_test.go b/cmd/pipeline/schedule/update/cmd_test.go new file mode 100644 index 000000000..dbaeedddb --- /dev/null +++ b/cmd/pipeline/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/docs/commands/README.md b/docs/commands/README.md index f2e7be109..5ecbcf45e 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -92,12 +92,18 @@ dr │ │ ├── get Display a single run │ │ ├── status Lightweight run status (for polling) │ │ └── cancel Cancel a running run -│ └── 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 +│ ├── 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 +│ └── 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 +│ ├── update Change cron expression / timezone +│ └── delete Delete a schedule └── self CLI utility commands ├── completion Shell completion │ ├── install Install completions interactively @@ -270,6 +276,7 @@ For detailed documentation on each command, see: - `graph`—display the pipeline/task DAG (draft or locked). - `run`—`create`/`list`/`get`/`status`/`cancel` pipeline executions. - `input`—`create`/`list`/`get`/`update`/`delete` JSON payloads used by runs. + - `schedule`—`create`/`list`/`get`/`update`/`delete` recurring (cron) runs on locked versions. ## Getting help diff --git a/docs/commands/pipeline.md b/docs/commands/pipeline.md index 332bda465..e90d03a75 100644 --- a/docs/commands/pipeline.md +++ b/docs/commands/pipeline.md @@ -80,6 +80,7 @@ dr pipeline lock | `dr pipeline graph` | `…/graph` (draft or locked) | Render the pipeline/task DAG. | | `dr pipeline run …` | `…/dispatches` and `…/{id}` | Trigger, inspect, and cancel runs. | | `dr pipeline input …` | `…/inputs` and `…/inputs/{input_id}` | Manage JSON payloads for runs. | +| `dr pipeline schedule …` | `…/versions/{ver}/schedules` | Manage recurring (cron) runs on locked versions. | ## Subcommands @@ -317,6 +318,21 @@ dr pipeline input delete --pipeline [--scope|--version] The payload file must contain a JSON object. The CLI wraps it in `{"payload": …}` before sending. +### `schedule` + +Manage recurring (cron) runs on locked versions only. Both `--pipeline` and `--version` are +required for every verb. + +```bash +dr pipeline schedule create --pipeline --version=N \ + --cron "0 * * * *" --input [--timezone UTC] +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 * * * *" +dr pipeline schedule delete --pipeline --version=N +``` + +`schedule update` requires at least one of `--cron` or `--timezone`. ### `run` Trigger, inspect, and cancel pipeline executions. diff --git a/docs/commands/pipelines-reference.md b/docs/commands/pipelines-reference.md index 5d55b9520..7fb043df9 100644 --- a/docs/commands/pipelines-reference.md +++ b/docs/commands/pipelines-reference.md @@ -117,6 +117,20 @@ term `dispatches` / `dispatch_id`, but the CLI's `--output-format json` remaps t --- +## Schedules (`dr pipeline schedule …`) + +Schedules are **locked-only** — every verb requires both `--pipeline` and `--version`. + +| 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 ` | **Flags:** `--pipeline ` (required), `--version ` (required), `--cron ""` (required), `--input ` (required), `--timezone ` (default `UTC`), `--output json`. | +| `dr pipeline schedule list` | `GET /pipelines/{id}/versions/{ver}/schedules` | `dr pipeline schedule list --pipeline --version=2` | **Flags:** `--pipeline ` (required), `--version ` (required), `--offset `, `--limit `, `--output json`. | +| `dr pipeline schedule get` | `GET /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipeline schedule get --pipeline --version=2 ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--version ` (required), `--output json`. | +| `dr pipeline schedule update` | `PATCH /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipeline schedule update --pipeline --version=2 --cron "*/15 * * * *"` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--version ` (required), `--cron ""`, `--timezone `. At least one required. | +| `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). | + +--- + ## Quick endpoint lookup | API endpoint | CLI command | @@ -144,3 +158,8 @@ term `dispatches` / `dispatch_id`, but the CLI's `--output-format json` remaps t | `PATCH /pipelines/{id}/inputs/{input_id}` | `dr pipeline input update` (draft) | | `DELETE /pipelines/{id}/inputs/{input_id}` | `dr pipeline input delete` (draft) | | `DELETE /pipelines/{id}/versions/{ver}/inputs/{input_id}` | `dr pipeline input delete` (locked) | +| `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/{id}` | `dr pipeline schedule get` | +| `PATCH /pipelines/{id}/versions/{ver}/schedules/{id}` | `dr pipeline schedule update` | +| `DELETE /pipelines/{id}/versions/{ver}/schedules/{id}` | `dr pipeline schedule delete` | diff --git a/internal/pipeline/schedule.go b/internal/pipeline/schedule.go new file mode 100644 index 000000000..19de6f4b7 --- /dev/null +++ b/internal/pipeline/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 pipeline + +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 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 { + 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/pipeline/schedule_test.go b/internal/pipeline/schedule_test.go new file mode 100644 index 000000000..c0fcaad37 --- /dev/null +++ b/internal/pipeline/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 pipeline + +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")) +} From 62e7afca9ed630c1922d1baad1eb6c2434f955f5 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 25 May 2026 13:14:32 -0400 Subject: [PATCH 07/14] sync schedule structs, output, and commands with sunny/pipelines Co-Authored-By: Claude Sonnet 4.6 --- cmd/pipeline/cmd_test.go | 14 +- cmd/pipeline/schedule/cmd.go | 2 +- cmd/pipeline/schedule/create/cmd_test.go | 4 +- cmd/pipeline/schedule/del/cmd.go | 4 +- cmd/pipeline/schedule/get/cmd_test.go | 2 +- cmd/pipeline/schedule/list/cmd_test.go | 4 +- cmd/pipeline/schedule/scheduleutil/render.go | 87 --------- .../schedule/scheduleutil/render_test.go | 116 ------------ cmd/pipeline/schedule/update/cmd_test.go | 18 +- internal/pipeline/schedule.go | 17 +- internal/pipeline/schedule_output.go | 169 ++++++++++++++++++ internal/pipeline/schedule_test.go | 8 +- 12 files changed, 206 insertions(+), 239 deletions(-) delete mode 100644 cmd/pipeline/schedule/scheduleutil/render.go delete mode 100644 cmd/pipeline/schedule/scheduleutil/render_test.go create mode 100644 internal/pipeline/schedule_output.go diff --git a/cmd/pipeline/cmd_test.go b/cmd/pipeline/cmd_test.go index ee356f466..752896a10 100644 --- a/cmd/pipeline/cmd_test.go +++ b/cmd/pipeline/cmd_test.go @@ -55,13 +55,13 @@ func TestCmd_HasExpectedSubcommands(t *testing.T) { cmd := Cmd() want := map[string]bool{ - "create": false, - "get": false, - "list": false, - "update": false, - "delete": false, - "lock": false, - "version": false, + "create": false, + "get": false, + "list": false, + "update": false, + "delete": false, + "lock": false, + "version": false, "graph": false, "run": false, "input": false, diff --git a/cmd/pipeline/schedule/cmd.go b/cmd/pipeline/schedule/cmd.go index 5cb94c5b2..78308c4d3 100644 --- a/cmd/pipeline/schedule/cmd.go +++ b/cmd/pipeline/schedule/cmd.go @@ -23,7 +23,7 @@ import ( "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/pipeline/schedule/create/cmd_test.go b/cmd/pipeline/schedule/create/cmd_test.go index 5d2d64639..e835f86ec 100644 --- a/cmd/pipeline/schedule/create/cmd_test.go +++ b/cmd/pipeline/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/pipeline/schedule/del/cmd.go b/cmd/pipeline/schedule/del/cmd.go index e4b65cc0a..ea8c511f1 100644 --- a/cmd/pipeline/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/pipeline/schedule/get/cmd_test.go b/cmd/pipeline/schedule/get/cmd_test.go index 2e6495193..71b9d5609 100644 --- a/cmd/pipeline/schedule/get/cmd_test.go +++ b/cmd/pipeline/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/pipeline/schedule/list/cmd_test.go b/cmd/pipeline/schedule/list/cmd_test.go index a819dd2a8..882700350 100644 --- a/cmd/pipeline/schedule/list/cmd_test.go +++ b/cmd/pipeline/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/pipeline/schedule/scheduleutil/render.go b/cmd/pipeline/schedule/scheduleutil/render.go deleted file mode 100644 index f69c6b4ac..000000000 --- a/cmd/pipeline/schedule/scheduleutil/render.go +++ /dev/null @@ -1,87 +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 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/pipeline" - "github.com/datarobot/cli/tui" -) - -// PrintScheduleJSON marshals a schedule as indented JSON. -func PrintScheduleJSON(s pipeline.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 pipeline.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)) - fmt.Println(tui.DimStyle.Render("Updated: " + s.UpdatedAt)) -} - -// PrintScheduleListJSON marshals a list of schedules as indented JSON. -func PrintScheduleListJSON(items []pipeline.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 []pipeline.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/pipeline/schedule/scheduleutil/render_test.go b/cmd/pipeline/schedule/scheduleutil/render_test.go deleted file mode 100644 index c4ce80d3f..000000000 --- a/cmd/pipeline/schedule/scheduleutil/render_test.go +++ /dev/null @@ -1,116 +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" - - "github.com/datarobot/cli/internal/pipeline" - "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() pipeline.Schedule { - return pipeline.Schedule{ - ScheduleID: "s-1", - PipelineID: "pl-1", - Version: 2, - CronExpression: "0 * * * *", - Timezone: "UTC", - Status: pipeline.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([]pipeline.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([]pipeline.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/pipeline/schedule/update/cmd_test.go b/cmd/pipeline/schedule/update/cmd_test.go index dbaeedddb..9cfc631a6 100644 --- a/cmd/pipeline/schedule/update/cmd_test.go +++ b/cmd/pipeline/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/internal/pipeline/schedule.go b/internal/pipeline/schedule.go index 19de6f4b7..c63aab31a 100644 --- a/internal/pipeline/schedule.go +++ b/internal/pipeline/schedule.go @@ -23,6 +23,7 @@ import ( "net/http" "net/url" "strconv" + "time" ) // ScheduleStatus mirrors PipelineScheduleStatus in the pipelines-api enums. @@ -36,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 string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } // ScheduleCreateRequest mirrors PipelineScheduleCreateRequest. @@ -97,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/pipeline/schedule_output.go b/internal/pipeline/schedule_output.go new file mode 100644 index 000000000..8f025248c --- /dev/null +++ b/internal/pipeline/schedule_output.go @@ -0,0 +1,169 @@ +// 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_output.go holds the rendering helpers shared by the +// `dr pipelines schedule` verbs. +package pipeline + +import ( + "encoding/json" + "fmt" + "os" + "slices" + "text/tabwriter" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/datarobot/cli/tui" +) + +// 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 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 OutputFormat, s Schedule) error { + if format == OutputFormatJSON { + return PrintScheduleJSON(s) + } + + PrintScheduleHuman(s) + + return nil +} + +// RenderSchedules routes a list of schedules to JSON or human output. +func RenderSchedules(format OutputFormat, items []Schedule) error { + if format == OutputFormatJSON { + return PrintScheduleListJSON(items) + } + + PrintScheduleListHuman(items) + + return nil +} + +// PrintScheduleJSON marshals a schedule as indented JSON through the DTO. +func PrintScheduleJSON(s Schedule) error { + data, err := json.MarshalIndent(toScheduleJSON(s), "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintScheduleHuman renders a single schedule in human-friendly form. +func PrintScheduleHuman(s Schedule) { + 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 through the DTO. +func PrintScheduleListJSON(items []Schedule) error { + view := make([]scheduleJSON, len(items)) + + for i, s := range items { + view[i] = toScheduleJSON(s) + } + + data, err := json.MarshalIndent(view, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintScheduleListHuman renders a lipgloss table summary of schedules. +func PrintScheduleListHuman(items []Schedule) { + if len(items) == 0 { + fmt.Println(tui.DimStyle.Render("No schedules found")) + + return + } + + 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 + } + + return cellStyle + }). + Headers(headers...) + + for _, s := range items { + t.Row( + s.ScheduleID, + fmt.Sprintf("v%d", s.Version), + s.CronExpression, + s.Timezone, + string(s.Status), + s.UpdatedAt.UTC().Format(timestampFormat), + ) + } + + fmt.Fprintln(os.Stdout, t.Render()) +} diff --git a/internal/pipeline/schedule_test.go b/internal/pipeline/schedule_test.go index c0fcaad37..7f5070a51 100644 --- a/internal/pipeline/schedule_test.go +++ b/internal/pipeline/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() From 1c2c3bc83fc949abaa6affdacab059432805073b Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 25 May 2026 14:06:25 -0400 Subject: [PATCH 08/14] fix internal/pipeline import paths in cmd/pipeline/schedule subcommands Apply sunny/pipelines versions of schedule subcommand files and update all import paths to use internal/pipeline (singular) after rename. Co-Authored-By: Claude Sonnet 4.6 --- cmd/pipeline/schedule/create/cmd.go | 22 +++++----------------- cmd/pipeline/schedule/get/cmd.go | 21 +++++---------------- cmd/pipeline/schedule/list/cmd.go | 22 +++++----------------- cmd/pipeline/schedule/update/cmd.go | 26 +++++++------------------- 4 files changed, 22 insertions(+), 69 deletions(-) diff --git a/cmd/pipeline/schedule/create/cmd.go b/cmd/pipeline/schedule/create/cmd.go index fdbf9f9b8..5e4d05c1c 100644 --- a/cmd/pipeline/schedule/create/cmd.go +++ b/cmd/pipeline/schedule/create/cmd.go @@ -16,9 +16,7 @@ package create import ( "errors" - "fmt" - "github.com/datarobot/cli/cmd/pipeline/schedule/scheduleutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipeline" "github.com/spf13/cobra" @@ -31,7 +29,7 @@ func Cmd() *cobra.Command { cron string inputID string timezone string - outputFormat string + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -40,16 +38,12 @@ 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, 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 +71,7 @@ Example: return err } - if outputFormat == "json" { - return scheduleutil.PrintScheduleJSON(*result) - } - - scheduleutil.PrintScheduleHuman(*result) - - return nil + return pipeline.RenderSchedule(outputFormat, *result) }, } @@ -92,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)") - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/schedule/get/cmd.go b/cmd/pipeline/schedule/get/cmd.go index 4bb2dd586..0b2540e8b 100644 --- a/cmd/pipeline/schedule/get/cmd.go +++ b/cmd/pipeline/schedule/get/cmd.go @@ -19,7 +19,6 @@ import ( "fmt" "net/http" - "github.com/datarobot/cli/cmd/pipeline/schedule/scheduleutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" "github.com/datarobot/cli/internal/pipeline" @@ -31,7 +30,7 @@ func Cmd() *cobra.Command { var ( pipelineID string version int - outputFormat string + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -40,16 +39,12 @@ 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 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, 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 +58,13 @@ Example: return handleGetError(err, args[0]) } - if outputFormat == "json" { - return scheduleutil.PrintScheduleJSON(*result) - } - - scheduleutil.PrintScheduleHuman(*result) - - return nil + return pipeline.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)") + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/schedule/list/cmd.go b/cmd/pipeline/schedule/list/cmd.go index 6cecd9a97..41eaa8639 100644 --- a/cmd/pipeline/schedule/list/cmd.go +++ b/cmd/pipeline/schedule/list/cmd.go @@ -16,9 +16,7 @@ package list import ( "errors" - "fmt" - "github.com/datarobot/cli/cmd/pipeline/schedule/scheduleutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipeline" "github.com/spf13/cobra" @@ -30,7 +28,7 @@ func Cmd() *cobra.Command { version int offset int limit int - outputFormat string + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -39,16 +37,12 @@ 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 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, 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 +56,7 @@ Example: return err } - if outputFormat == "json" { - return scheduleutil.PrintScheduleListJSON(items) - } - - scheduleutil.PrintScheduleListHuman(items) - - return nil + return pipeline.RenderSchedules(outputFormat, items) }, } @@ -76,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") - cmd.Flags().StringVar(&outputFormat, "output", "", "Output format (json)") + pipeline.AddOutputFlag(cmd, &outputFormat) return cmd } diff --git a/cmd/pipeline/schedule/update/cmd.go b/cmd/pipeline/schedule/update/cmd.go index 469355023..a31d7110d 100644 --- a/cmd/pipeline/schedule/update/cmd.go +++ b/cmd/pipeline/schedule/update/cmd.go @@ -16,9 +16,7 @@ package update import ( "errors" - "fmt" - "github.com/datarobot/cli/cmd/pipeline/schedule/scheduleutil" "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipeline" "github.com/spf13/cobra" @@ -30,7 +28,7 @@ func Cmd() *cobra.Command { version int cron string timezone string - outputFormat string + outputFormat pipeline.OutputFormat ) cmd := &cobra.Command{ @@ -42,13 +40,13 @@ 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, 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 +56,7 @@ Example: return err } - if outputFormat == "json" { - return scheduleutil.PrintScheduleJSON(*result) - } - - scheduleutil.PrintScheduleHuman(*result) - - return nil + return pipeline.RenderSchedule(outputFormat, *result) }, } @@ -72,18 +64,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)") + 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, outputFormat string) (pipeline.ScheduleUpdateRequest, error) { - if outputFormat != "" && outputFormat != "json" { - return pipeline.ScheduleUpdateRequest{}, fmt.Errorf("invalid output format: %s (supported: json)", outputFormat) - } - +func buildUpdateBody(cmd *cobra.Command, pipelineID string, version int, cron, timezone string) (pipeline.ScheduleUpdateRequest, error) { if pipelineID == "" { return pipeline.ScheduleUpdateRequest{}, errors.New("--pipeline is required") } From 60e0cb7f7212a62dc1730e8caf724c07bc27ce5d Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 25 May 2026 14:52:04 -0400 Subject: [PATCH 09/14] [CMPT-5391] add telemetry to schedule commands Co-Authored-By: Claude Sonnet 4.6 --- cmd/pipeline/schedule/create/cmd.go | 9 +++++++++ cmd/pipeline/schedule/del/cmd.go | 9 +++++++++ cmd/pipeline/schedule/get/cmd.go | 10 ++++++++++ cmd/pipeline/schedule/list/cmd.go | 11 +++++++++++ cmd/pipeline/schedule/update/cmd.go | 10 ++++++++++ 5 files changed, 49 insertions(+) diff --git a/cmd/pipeline/schedule/create/cmd.go b/cmd/pipeline/schedule/create/cmd.go index 5e4d05c1c..f8020ce0d 100644 --- a/cmd/pipeline/schedule/create/cmd.go +++ b/cmd/pipeline/schedule/create/cmd.go @@ -19,6 +19,7 @@ import ( "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" "github.com/spf13/cobra" ) @@ -82,5 +83,13 @@ Example: cmd.Flags().StringVar(&timezone, "timezone", "", "IANA timezone name (default UTC)") pipeline.AddOutputFlag(cmd, &outputFormat) + telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { + return map[string]any{ + "pipeline_id": pipelineID, + "version": version, + "output_format": string(outputFormat), + } + }) + return cmd } diff --git a/cmd/pipeline/schedule/del/cmd.go b/cmd/pipeline/schedule/del/cmd.go index ea8c511f1..c376a14fe 100644 --- a/cmd/pipeline/schedule/del/cmd.go +++ b/cmd/pipeline/schedule/del/cmd.go @@ -24,6 +24,7 @@ import ( "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -67,5 +68,13 @@ Example: cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": pipelineID, + "schedule_id": telemetry.FirstArg(args), + "version": version, + } + }) + return cmd } diff --git a/cmd/pipeline/schedule/get/cmd.go b/cmd/pipeline/schedule/get/cmd.go index 0b2540e8b..ed83e700e 100644 --- a/cmd/pipeline/schedule/get/cmd.go +++ b/cmd/pipeline/schedule/get/cmd.go @@ -22,6 +22,7 @@ import ( "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/drapi" "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -66,6 +67,15 @@ Example: cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") pipeline.AddOutputFlag(cmd, &outputFormat) + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": pipelineID, + "schedule_id": telemetry.FirstArg(args), + "version": version, + "output_format": string(outputFormat), + } + }) + return cmd } diff --git a/cmd/pipeline/schedule/list/cmd.go b/cmd/pipeline/schedule/list/cmd.go index 41eaa8639..4fec77856 100644 --- a/cmd/pipeline/schedule/list/cmd.go +++ b/cmd/pipeline/schedule/list/cmd.go @@ -19,6 +19,7 @@ import ( "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" "github.com/spf13/cobra" ) @@ -66,5 +67,15 @@ Example: cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of schedules to return") pipeline.AddOutputFlag(cmd, &outputFormat) + telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { + return map[string]any{ + "pipeline_id": pipelineID, + "version": version, + "offset": offset, + "limit": limit, + "output_format": string(outputFormat), + } + }) + return cmd } diff --git a/cmd/pipeline/schedule/update/cmd.go b/cmd/pipeline/schedule/update/cmd.go index a31d7110d..b4d2ec1ab 100644 --- a/cmd/pipeline/schedule/update/cmd.go +++ b/cmd/pipeline/schedule/update/cmd.go @@ -19,6 +19,7 @@ import ( "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" "github.com/spf13/cobra" ) @@ -66,6 +67,15 @@ Example: cmd.Flags().StringVar(&timezone, "timezone", "", "New IANA timezone name") pipeline.AddOutputFlag(cmd, &outputFormat) + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": pipelineID, + "schedule_id": telemetry.FirstArg(args), + "version": version, + "output_format": string(outputFormat), + } + }) + return cmd } From 5183b37b86bca93ccbad7972381fb3eb5830de5c Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 25 May 2026 16:22:04 -0400 Subject: [PATCH 10/14] [CMPT-5391] address PR feedback: MarkFlagRequired, 404 del suppression, limit defaults, version prefix Co-Authored-By: Claude Sonnet 4.6 --- cmd/pipeline/schedule/create/cmd.go | 10 ++-------- cmd/pipeline/schedule/del/cmd.go | 24 +++++++++++++++++++----- cmd/pipeline/schedule/get/cmd.go | 5 +---- cmd/pipeline/schedule/list/cmd.go | 7 ++----- cmd/pipeline/schedule/update/cmd.go | 5 +---- 5 files changed, 25 insertions(+), 26 deletions(-) diff --git a/cmd/pipeline/schedule/create/cmd.go b/cmd/pipeline/schedule/create/cmd.go index f8020ce0d..f5c628034 100644 --- a/cmd/pipeline/schedule/create/cmd.go +++ b/cmd/pipeline/schedule/create/cmd.go @@ -45,10 +45,6 @@ Example: PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(_ *cobra.Command, _ []string) error { - if pipelineID == "" { - return errors.New("--pipeline is required") - } - if version <= 0 { return errors.New("--version is required and must be > 0") } @@ -57,10 +53,6 @@ Example: return errors.New("--cron is required") } - if inputID == "" { - return errors.New("--input is required") - } - body := pipeline.ScheduleCreateRequest{ CronExpression: cron, PipelineInputID: inputID, @@ -77,9 +69,11 @@ Example: } cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + _ = cmd.MarkFlagRequired("pipeline") 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 run on each tick") + _ = cmd.MarkFlagRequired("input") cmd.Flags().StringVar(&timezone, "timezone", "", "IANA timezone name (default UTC)") pipeline.AddOutputFlag(cmd, &outputFormat) diff --git a/cmd/pipeline/schedule/del/cmd.go b/cmd/pipeline/schedule/del/cmd.go index c376a14fe..0b49a3d97 100644 --- a/cmd/pipeline/schedule/del/cmd.go +++ b/cmd/pipeline/schedule/del/cmd.go @@ -21,8 +21,10 @@ package del import ( "errors" "fmt" + "net/http" "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/drapi" "github.com/datarobot/cli/internal/pipeline" "github.com/datarobot/cli/internal/telemetry" "github.com/datarobot/cli/tui" @@ -46,17 +48,13 @@ Example: 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 := pipeline.DeleteSchedule(pipelineID, version, args[0]) if err != nil { - return err + return handleDeleteError(err, args[0]) } fmt.Println(tui.BaseTextStyle.Render("Deleted schedule: " + args[0])) @@ -66,6 +64,7 @@ Example: } cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + _ = cmd.MarkFlagRequired("pipeline") cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { @@ -78,3 +77,18 @@ Example: 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, id 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: " + id)) + + return nil + } + + return err +} diff --git a/cmd/pipeline/schedule/get/cmd.go b/cmd/pipeline/schedule/get/cmd.go index ed83e700e..2dd275b79 100644 --- a/cmd/pipeline/schedule/get/cmd.go +++ b/cmd/pipeline/schedule/get/cmd.go @@ -46,10 +46,6 @@ Example: 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") } @@ -64,6 +60,7 @@ Example: } cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + _ = cmd.MarkFlagRequired("pipeline") cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") pipeline.AddOutputFlag(cmd, &outputFormat) diff --git a/cmd/pipeline/schedule/list/cmd.go b/cmd/pipeline/schedule/list/cmd.go index 4fec77856..1b40fdc48 100644 --- a/cmd/pipeline/schedule/list/cmd.go +++ b/cmd/pipeline/schedule/list/cmd.go @@ -44,10 +44,6 @@ Example: PreRunE: auth.EnsureAuthenticatedE, SilenceUsage: true, RunE: func(_ *cobra.Command, _ []string) error { - if pipelineID == "" { - return errors.New("--pipeline is required") - } - if version <= 0 { return errors.New("--version is required and must be > 0") } @@ -62,9 +58,10 @@ Example: } cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + _ = cmd.MarkFlagRequired("pipeline") 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().IntVar(&limit, "limit", 100, "Maximum number of schedules to return") pipeline.AddOutputFlag(cmd, &outputFormat) telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { diff --git a/cmd/pipeline/schedule/update/cmd.go b/cmd/pipeline/schedule/update/cmd.go index b4d2ec1ab..8e6c149a0 100644 --- a/cmd/pipeline/schedule/update/cmd.go +++ b/cmd/pipeline/schedule/update/cmd.go @@ -62,6 +62,7 @@ Example: } cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") + _ = cmd.MarkFlagRequired("pipeline") 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") @@ -82,10 +83,6 @@ Example: // 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) (pipeline.ScheduleUpdateRequest, error) { - if pipelineID == "" { - return pipeline.ScheduleUpdateRequest{}, errors.New("--pipeline is required") - } - if version <= 0 { return pipeline.ScheduleUpdateRequest{}, errors.New("--version is required and must be > 0") } From 9bb404d49b64309ba8b84aeb956af29b1d979d23 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 25 May 2026 16:28:24 -0400 Subject: [PATCH 11/14] [CMPT-5391] fix test assertions for MarkFlagRequired error format Co-Authored-By: Claude Sonnet 4.6 --- cmd/pipeline/schedule/create/cmd_test.go | 4 ++-- cmd/pipeline/schedule/del/cmd_test.go | 2 +- cmd/pipeline/schedule/get/cmd_test.go | 2 +- cmd/pipeline/schedule/list/cmd_test.go | 2 +- cmd/pipeline/schedule/update/cmd_test.go | 12 ------------ 5 files changed, 5 insertions(+), 17 deletions(-) diff --git a/cmd/pipeline/schedule/create/cmd_test.go b/cmd/pipeline/schedule/create/cmd_test.go index e835f86ec..47c205fa5 100644 --- a/cmd/pipeline/schedule/create/cmd_test.go +++ b/cmd/pipeline/schedule/create/cmd_test.go @@ -47,7 +47,7 @@ func TestCmd_RejectsInvalidOutput(t *testing.T) { 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") + assert.Contains(t, err.Error(), "pipeline") } func TestCmd_RejectsZeroVersion(t *testing.T) { @@ -65,7 +65,7 @@ func TestCmd_RejectsMissingCron(t *testing.T) { 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") + assert.Contains(t, err.Error(), "input") } func TestCmd_HasExpectedFlags(t *testing.T) { diff --git a/cmd/pipeline/schedule/del/cmd_test.go b/cmd/pipeline/schedule/del/cmd_test.go index 5eb4167f7..0741861a8 100644 --- a/cmd/pipeline/schedule/del/cmd_test.go +++ b/cmd/pipeline/schedule/del/cmd_test.go @@ -37,7 +37,7 @@ func runCmd(t *testing.T, args ...string) error { func TestCmd_RejectsMissingPipeline(t *testing.T) { err := runCmd(t, "--version", "2", "s-1") require.Error(t, err) - assert.Contains(t, err.Error(), "--pipeline") + assert.Contains(t, err.Error(), "pipeline") } func TestCmd_RejectsZeroVersion(t *testing.T) { diff --git a/cmd/pipeline/schedule/get/cmd_test.go b/cmd/pipeline/schedule/get/cmd_test.go index 71b9d5609..6b61e3fff 100644 --- a/cmd/pipeline/schedule/get/cmd_test.go +++ b/cmd/pipeline/schedule/get/cmd_test.go @@ -46,7 +46,7 @@ func TestCmd_RejectsInvalidOutput(t *testing.T) { func TestCmd_RejectsMissingPipeline(t *testing.T) { err := runCmd(t, "--version", "2", "s-1") require.Error(t, err) - assert.Contains(t, err.Error(), "--pipeline") + assert.Contains(t, err.Error(), "pipeline") } func TestCmd_RejectsZeroVersion(t *testing.T) { diff --git a/cmd/pipeline/schedule/list/cmd_test.go b/cmd/pipeline/schedule/list/cmd_test.go index 882700350..4521aa6cb 100644 --- a/cmd/pipeline/schedule/list/cmd_test.go +++ b/cmd/pipeline/schedule/list/cmd_test.go @@ -43,7 +43,7 @@ func TestCmd_RejectsInvalidOutput(t *testing.T) { func TestCmd_RejectsMissingPipeline(t *testing.T) { err := runCmd(t, "--version", "2") require.Error(t, err) - assert.Contains(t, err.Error(), "--pipeline") + assert.Contains(t, err.Error(), "pipeline") } func TestCmd_RejectsZeroVersion(t *testing.T) { diff --git a/cmd/pipeline/schedule/update/cmd_test.go b/cmd/pipeline/schedule/update/cmd_test.go index 9cfc631a6..a358140fa 100644 --- a/cmd/pipeline/schedule/update/cmd_test.go +++ b/cmd/pipeline/schedule/update/cmd_test.go @@ -71,18 +71,6 @@ func TestBuildUpdateBody_SkipsUnchangedFlags(t *testing.T) { 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) From 0ac9d449ba0746e8a530837f8d7950d4658597d1 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 1 Jun 2026 11:03:01 -0400 Subject: [PATCH 12/14] [CMPT-5391] fix schedule docs --output-format and dedup cmd_test map keys Co-Authored-By: Claude Sonnet 4.6 --- docs/commands/pipelines-reference.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/commands/pipelines-reference.md b/docs/commands/pipelines-reference.md index 7fb043df9..84789720d 100644 --- a/docs/commands/pipelines-reference.md +++ b/docs/commands/pipelines-reference.md @@ -123,9 +123,9 @@ Schedules are **locked-only** — every verb requires both `--pipeline` and `--v | 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 ` | **Flags:** `--pipeline ` (required), `--version ` (required), `--cron ""` (required), `--input ` (required), `--timezone ` (default `UTC`), `--output json`. | -| `dr pipeline schedule list` | `GET /pipelines/{id}/versions/{ver}/schedules` | `dr pipeline schedule list --pipeline --version=2` | **Flags:** `--pipeline ` (required), `--version ` (required), `--offset `, `--limit `, `--output json`. | -| `dr pipeline schedule get` | `GET /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipeline schedule get --pipeline --version=2 ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--version ` (required), `--output json`. | +| `dr pipeline schedule create` | `POST /pipelines/{id}/versions/{ver}/schedules` | `dr pipeline schedule create --pipeline --version=2 --cron "0 * * * *" --input ` | **Flags:** `--pipeline ` (required), `--version ` (required), `--cron ""` (required), `--input ` (required), `--timezone ` (default `UTC`), `--output-format json`. | +| `dr pipeline schedule list` | `GET /pipelines/{id}/versions/{ver}/schedules` | `dr pipeline schedule list --pipeline --version=2` | **Flags:** `--pipeline ` (required), `--version ` (required), `--offset `, `--limit `, `--output-format json`. | +| `dr pipeline schedule get` | `GET /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipeline schedule get --pipeline --version=2 ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--version ` (required), `--output-format json`. | | `dr pipeline schedule update` | `PATCH /pipelines/{id}/versions/{ver}/schedules/{schedule_id}` | `dr pipeline schedule update --pipeline --version=2 --cron "*/15 * * * *"` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--version ` (required), `--cron ""`, `--timezone `. At least one required. | | `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). | From f48591aba74c188cece6fdcb2cd395d08e27caa1 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Wed, 3 Jun 2026 16:52:26 -0400 Subject: [PATCH 13/14] [CMPT-5391] address PR feedback: MarkFlagRequired version/cron, reject empty cron on update - Add MarkFlagRequired("version") to all 5 schedule subcommands; remove manual "is required" RunE check for the missing-flag case (cobra now handles it) - Add MarkFlagRequired("cron") to schedule create; remove manual empty check - In schedule update buildUpdateBody: reject --cron="" explicitly to prevent patching an empty cron_expression (set-but-empty was treated as a real change) - Rename TestCmd_RejectsZeroVersion -> TestCmd_RejectsMissingVersion and update assertions from "--version"/"--cron" to "version"/"cron" to match cobra error format - Add TestBuildUpdateBody_RejectsEmptyCron Co-Authored-By: Claude Sonnet 4.6 --- cmd/pipeline/schedule/create/cmd.go | 8 +++----- cmd/pipeline/schedule/create/cmd_test.go | 6 +++--- cmd/pipeline/schedule/del/cmd.go | 1 + cmd/pipeline/schedule/del/cmd_test.go | 4 ++-- cmd/pipeline/schedule/get/cmd.go | 1 + cmd/pipeline/schedule/get/cmd_test.go | 4 ++-- cmd/pipeline/schedule/list/cmd.go | 1 + cmd/pipeline/schedule/list/cmd_test.go | 4 ++-- cmd/pipeline/schedule/update/cmd.go | 5 +++++ cmd/pipeline/schedule/update/cmd_test.go | 12 ++++++++++++ 10 files changed, 32 insertions(+), 14 deletions(-) diff --git a/cmd/pipeline/schedule/create/cmd.go b/cmd/pipeline/schedule/create/cmd.go index f5c628034..0bafbe6ba 100644 --- a/cmd/pipeline/schedule/create/cmd.go +++ b/cmd/pipeline/schedule/create/cmd.go @@ -46,11 +46,7 @@ Example: SilenceUsage: true, RunE: func(_ *cobra.Command, _ []string) error { if version <= 0 { - return errors.New("--version is required and must be > 0") - } - - if cron == "" { - return errors.New("--cron is required") + return errors.New("--version must be > 0") } body := pipeline.ScheduleCreateRequest{ @@ -71,7 +67,9 @@ Example: cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") _ = cmd.MarkFlagRequired("pipeline") cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") + _ = cmd.MarkFlagRequired("version") cmd.Flags().StringVar(&cron, "cron", "", "Cron expression, e.g. \"0 * * * *\"") + _ = cmd.MarkFlagRequired("cron") cmd.Flags().StringVar(&inputID, "input", "", "Input ID to run on each tick") _ = cmd.MarkFlagRequired("input") cmd.Flags().StringVar(&timezone, "timezone", "", "IANA timezone name (default UTC)") diff --git a/cmd/pipeline/schedule/create/cmd_test.go b/cmd/pipeline/schedule/create/cmd_test.go index 47c205fa5..6eba56a4e 100644 --- a/cmd/pipeline/schedule/create/cmd_test.go +++ b/cmd/pipeline/schedule/create/cmd_test.go @@ -50,16 +50,16 @@ func TestCmd_RejectsMissingPipeline(t *testing.T) { assert.Contains(t, err.Error(), "pipeline") } -func TestCmd_RejectsZeroVersion(t *testing.T) { +func TestCmd_RejectsMissingVersion(t *testing.T) { err := runCmd(t, "--pipeline", "p", "--cron", "0 * * * *", "--input", "in-1") require.Error(t, err) - assert.Contains(t, err.Error(), "--version") + 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") + assert.Contains(t, err.Error(), "cron") } func TestCmd_RejectsMissingInput(t *testing.T) { diff --git a/cmd/pipeline/schedule/del/cmd.go b/cmd/pipeline/schedule/del/cmd.go index 0b49a3d97..77cd034b7 100644 --- a/cmd/pipeline/schedule/del/cmd.go +++ b/cmd/pipeline/schedule/del/cmd.go @@ -66,6 +66,7 @@ Example: cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") _ = cmd.MarkFlagRequired("pipeline") cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") + _ = cmd.MarkFlagRequired("version") telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { return map[string]any{ diff --git a/cmd/pipeline/schedule/del/cmd_test.go b/cmd/pipeline/schedule/del/cmd_test.go index 0741861a8..924933a7c 100644 --- a/cmd/pipeline/schedule/del/cmd_test.go +++ b/cmd/pipeline/schedule/del/cmd_test.go @@ -40,10 +40,10 @@ func TestCmd_RejectsMissingPipeline(t *testing.T) { assert.Contains(t, err.Error(), "pipeline") } -func TestCmd_RejectsZeroVersion(t *testing.T) { +func TestCmd_RejectsMissingVersion(t *testing.T) { err := runCmd(t, "--pipeline", "p", "s-1") require.Error(t, err) - assert.Contains(t, err.Error(), "--version") + assert.Contains(t, err.Error(), "version") } func TestCmd_RequiresPositional(t *testing.T) { diff --git a/cmd/pipeline/schedule/get/cmd.go b/cmd/pipeline/schedule/get/cmd.go index 2dd275b79..764cf40e3 100644 --- a/cmd/pipeline/schedule/get/cmd.go +++ b/cmd/pipeline/schedule/get/cmd.go @@ -62,6 +62,7 @@ Example: cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") _ = cmd.MarkFlagRequired("pipeline") cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") + _ = cmd.MarkFlagRequired("version") pipeline.AddOutputFlag(cmd, &outputFormat) telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { diff --git a/cmd/pipeline/schedule/get/cmd_test.go b/cmd/pipeline/schedule/get/cmd_test.go index 6b61e3fff..409c70fbd 100644 --- a/cmd/pipeline/schedule/get/cmd_test.go +++ b/cmd/pipeline/schedule/get/cmd_test.go @@ -49,10 +49,10 @@ func TestCmd_RejectsMissingPipeline(t *testing.T) { assert.Contains(t, err.Error(), "pipeline") } -func TestCmd_RejectsZeroVersion(t *testing.T) { +func TestCmd_RejectsMissingVersion(t *testing.T) { err := runCmd(t, "--pipeline", "p", "s-1") require.Error(t, err) - assert.Contains(t, err.Error(), "--version") + assert.Contains(t, err.Error(), "version") } func TestCmd_RequiresPositional(t *testing.T) { diff --git a/cmd/pipeline/schedule/list/cmd.go b/cmd/pipeline/schedule/list/cmd.go index 1b40fdc48..b9c647d87 100644 --- a/cmd/pipeline/schedule/list/cmd.go +++ b/cmd/pipeline/schedule/list/cmd.go @@ -60,6 +60,7 @@ Example: cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") _ = cmd.MarkFlagRequired("pipeline") cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") + _ = cmd.MarkFlagRequired("version") cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") cmd.Flags().IntVar(&limit, "limit", 100, "Maximum number of schedules to return") pipeline.AddOutputFlag(cmd, &outputFormat) diff --git a/cmd/pipeline/schedule/list/cmd_test.go b/cmd/pipeline/schedule/list/cmd_test.go index 4521aa6cb..460b9fcb2 100644 --- a/cmd/pipeline/schedule/list/cmd_test.go +++ b/cmd/pipeline/schedule/list/cmd_test.go @@ -46,10 +46,10 @@ func TestCmd_RejectsMissingPipeline(t *testing.T) { assert.Contains(t, err.Error(), "pipeline") } -func TestCmd_RejectsZeroVersion(t *testing.T) { +func TestCmd_RejectsMissingVersion(t *testing.T) { err := runCmd(t, "--pipeline", "p") require.Error(t, err) - assert.Contains(t, err.Error(), "--version") + assert.Contains(t, err.Error(), "version") } func TestCmd_HasExpectedFlags(t *testing.T) { diff --git a/cmd/pipeline/schedule/update/cmd.go b/cmd/pipeline/schedule/update/cmd.go index 8e6c149a0..296173e00 100644 --- a/cmd/pipeline/schedule/update/cmd.go +++ b/cmd/pipeline/schedule/update/cmd.go @@ -64,6 +64,7 @@ Example: cmd.Flags().StringVar(&pipelineID, "pipeline", "", "Pipeline ID") _ = cmd.MarkFlagRequired("pipeline") cmd.Flags().IntVar(&version, "version", 0, "Locked pipeline version") + _ = cmd.MarkFlagRequired("version") cmd.Flags().StringVar(&cron, "cron", "", "New cron expression") cmd.Flags().StringVar(&timezone, "timezone", "", "New IANA timezone name") pipeline.AddOutputFlag(cmd, &outputFormat) @@ -94,6 +95,10 @@ func buildUpdateBody(cmd *cobra.Command, pipelineID string, version int, cron, t return pipeline.ScheduleUpdateRequest{}, errors.New("at least one of --cron or --timezone must be specified") } + if cronChanged && cron == "" { + return pipeline.ScheduleUpdateRequest{}, errors.New("--cron must not be empty") + } + body := pipeline.ScheduleUpdateRequest{} if cronChanged { diff --git a/cmd/pipeline/schedule/update/cmd_test.go b/cmd/pipeline/schedule/update/cmd_test.go index a358140fa..418f4f6c0 100644 --- a/cmd/pipeline/schedule/update/cmd_test.go +++ b/cmd/pipeline/schedule/update/cmd_test.go @@ -22,6 +22,18 @@ import ( "github.com/stretchr/testify/require" ) +func TestBuildUpdateBody_RejectsEmptyCron(t *testing.T) { + cmd := Cmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + require.NoError(t, cmd.ParseFlags([]string{"--pipeline=p", "--version=2", "--cron="})) + + _, err := buildUpdateBody(cmd, "p", 2, "", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "--cron must not be empty") +} + func TestBuildUpdateBody_RequiresAtLeastOneField(t *testing.T) { cmd := Cmd() cmd.SetOut(io.Discard) From 6bf056b99dd49f3a167ee84fcd31a53aac9374fa Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Wed, 3 Jun 2026 17:08:47 -0400 Subject: [PATCH 14/14] [CMPT-5391] address PR feedback: consistent version error, empty timezone guard, drop unused pipelineID param - create/cmd.go: align --version error message with other verbs ("is required and must be > 0") - update/cmd.go: reject --timezone="" the same way --cron="" is rejected; rename unused pipelineID param to _ in buildUpdateBody signature - update/cmd_test.go: add TestBuildUpdateBody_RejectsEmptyTimezone Co-Authored-By: Claude Sonnet 4.6 --- cmd/pipeline/schedule/create/cmd.go | 2 +- cmd/pipeline/schedule/update/cmd.go | 6 +++++- cmd/pipeline/schedule/update/cmd_test.go | 12 ++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/cmd/pipeline/schedule/create/cmd.go b/cmd/pipeline/schedule/create/cmd.go index 0bafbe6ba..9d190c4da 100644 --- a/cmd/pipeline/schedule/create/cmd.go +++ b/cmd/pipeline/schedule/create/cmd.go @@ -46,7 +46,7 @@ Example: SilenceUsage: true, RunE: func(_ *cobra.Command, _ []string) error { if version <= 0 { - return errors.New("--version must be > 0") + return errors.New("--version is required and must be > 0") } body := pipeline.ScheduleCreateRequest{ diff --git a/cmd/pipeline/schedule/update/cmd.go b/cmd/pipeline/schedule/update/cmd.go index 296173e00..41e56ed43 100644 --- a/cmd/pipeline/schedule/update/cmd.go +++ b/cmd/pipeline/schedule/update/cmd.go @@ -83,7 +83,7 @@ Example: // 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) (pipeline.ScheduleUpdateRequest, error) { +func buildUpdateBody(cmd *cobra.Command, _ string, version int, cron, timezone string) (pipeline.ScheduleUpdateRequest, error) { if version <= 0 { return pipeline.ScheduleUpdateRequest{}, errors.New("--version is required and must be > 0") } @@ -99,6 +99,10 @@ func buildUpdateBody(cmd *cobra.Command, pipelineID string, version int, cron, t return pipeline.ScheduleUpdateRequest{}, errors.New("--cron must not be empty") } + if tzChanged && timezone == "" { + return pipeline.ScheduleUpdateRequest{}, errors.New("--timezone must not be empty") + } + body := pipeline.ScheduleUpdateRequest{} if cronChanged { diff --git a/cmd/pipeline/schedule/update/cmd_test.go b/cmd/pipeline/schedule/update/cmd_test.go index 418f4f6c0..d73ba6656 100644 --- a/cmd/pipeline/schedule/update/cmd_test.go +++ b/cmd/pipeline/schedule/update/cmd_test.go @@ -34,6 +34,18 @@ func TestBuildUpdateBody_RejectsEmptyCron(t *testing.T) { assert.Contains(t, err.Error(), "--cron must not be empty") } +func TestBuildUpdateBody_RejectsEmptyTimezone(t *testing.T) { + cmd := Cmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + require.NoError(t, cmd.ParseFlags([]string{"--pipeline=p", "--version=2", "--timezone="})) + + _, err := buildUpdateBody(cmd, "p", 2, "", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "--timezone must not be empty") +} + func TestBuildUpdateBody_RequiresAtLeastOneField(t *testing.T) { cmd := Cmd() cmd.SetOut(io.Discard)