diff --git a/cmd/pipeline/cmd.go b/cmd/pipeline/cmd.go index 7eb787d95..2c650b8d8 100644 --- a/cmd/pipeline/cmd.go +++ b/cmd/pipeline/cmd.go @@ -21,6 +21,7 @@ import ( "github.com/datarobot/cli/cmd/pipeline/graph" "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/update" "github.com/datarobot/cli/cmd/pipeline/version" "github.com/datarobot/cli/internal/features" @@ -51,6 +52,7 @@ input payloads, runs, and recurring schedules.`, lock.Cmd(), version.Cmd(), graph.Cmd(), + run.Cmd(), ) return cmd diff --git a/cmd/pipeline/cmd_test.go b/cmd/pipeline/cmd_test.go index bf4e5c022..db8a31203 100644 --- a/cmd/pipeline/cmd_test.go +++ b/cmd/pipeline/cmd_test.go @@ -63,6 +63,7 @@ func TestCmd_HasExpectedSubcommands(t *testing.T) { "lock": false, "version": false, "graph": false, + "run": false, } for _, sub := range cmd.Commands() { diff --git a/cmd/pipeline/run/cancel/cmd.go b/cmd/pipeline/run/cancel/cmd.go new file mode 100644 index 000000000..890797011 --- /dev/null +++ b/cmd/pipeline/run/cancel/cmd.go @@ -0,0 +1,79 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cancel + +import ( + "errors" + "fmt" + + "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/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var flags scopeflag.Flags + + cmd := &cobra.Command{ + Use: "cancel ", + Short: "Cancel a pipeline run", + Long: `Request cancellation of an in-flight run. + +The API rejects cancellation if the run has already reached a terminal +state (COMPLETED, FAILED, CANCELLED). + +Example: + dr pipeline run cancel --pipeline + dr pipeline run cancel --pipeline --version=2 `, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + err = pipeline.CancelRun(flags.PipelineID, scope, version, args[0]) + if err != nil { + return err + } + + fmt.Println(tui.BaseTextStyle.Render("Cancelled run: " + args[0])) + + return nil + }, + } + + flags.Bind(cmd) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "run_id": telemetry.FirstArg(args), + "scope": flags.Scope, + "version": flags.Version, + } + }) + + return cmd +} diff --git a/cmd/pipeline/run/cancel/cmd_test.go b/cmd/pipeline/run/cancel/cmd_test.go new file mode 100644 index 000000000..582095425 --- /dev/null +++ b/cmd/pipeline/run/cancel/cmd_test.go @@ -0,0 +1,56 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cancel + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RejectsBadScopeCombo(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--scope", "locked", "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "requires --version") +} + +func TestCmd_RequiresPositional(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) +} + +func TestCmd_Name(t *testing.T) { + assert.Equal(t, "cancel", Cmd().Name()) +} diff --git a/cmd/pipeline/run/cmd.go b/cmd/pipeline/run/cmd.go new file mode 100644 index 000000000..bd2bc50da --- /dev/null +++ b/cmd/pipeline/run/cmd.go @@ -0,0 +1,49 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package run + +import ( + "github.com/datarobot/cli/cmd/pipeline/run/cancel" + "github.com/datarobot/cli/cmd/pipeline/run/create" + "github.com/datarobot/cli/cmd/pipeline/run/get" + "github.com/datarobot/cli/cmd/pipeline/run/list" + "github.com/datarobot/cli/cmd/pipeline/run/status" + "github.com/spf13/cobra" +) + +// Cmd returns the parent command for `dr pipeline run`. +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "run", + Short: "Manage pipeline runs", + Long: `Trigger and inspect runs (single executions) of a pipeline. + +Runs come in two scopes: + - draft : executes against the in-flight draft of a pipeline + - locked : executes against a specific frozen version + +When --version is supplied, the locked scope is selected automatically.`, + } + + cmd.AddCommand( + create.Cmd(), + list.Cmd(), + get.Cmd(), + status.Cmd(), + cancel.Cmd(), + ) + + return cmd +} diff --git a/cmd/pipeline/run/cmd_test.go b/cmd/pipeline/run/cmd_test.go new file mode 100644 index 000000000..ce2c30cff --- /dev/null +++ b/cmd/pipeline/run/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 run + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCmd_RegistersAllVerbs(t *testing.T) { + cmd := Cmd() + + want := map[string]bool{ + "create": false, + "list": false, + "get": false, + "status": false, + "cancel": false, + } + + for _, sub := range cmd.Commands() { + want[sub.Name()] = true + } + + for verb, present := range want { + assert.Truef(t, present, "missing subcommand: %s", verb) + } +} diff --git a/cmd/pipeline/run/create/cmd.go b/cmd/pipeline/run/create/cmd.go new file mode 100644 index 000000000..d452e1772 --- /dev/null +++ b/cmd/pipeline/run/create/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 create + +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 + inputID string + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Trigger a pipeline run", + Long: `Trigger a new run (single execution) of a pipeline. + +The run is created in PENDING state. Use ` + "`dr pipeline run get`" + ` +or ` + "`dr pipeline run status`" + ` to follow its progress. + +Example: + dr pipeline run create --pipeline --input + dr pipeline run create --pipeline --version=2 --input --output-format json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + result, err := pipeline.CreateRun(flags.PipelineID, scope, version, inputID) + if err != nil { + return err + } + + return pipeline.RenderRun(outputFormat, *result) + }, + } + + flags.Bind(cmd) + _ = cmd.MarkFlagRequired("pipeline") + cmd.Flags().StringVar(&inputID, "input", "", "Input ID to trigger the run with") + _ = cmd.MarkFlagRequired("input") + 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/run/create/cmd_test.go b/cmd/pipeline/run/create/cmd_test.go new file mode 100644 index 000000000..1b65852bb --- /dev/null +++ b/cmd/pipeline/run/create/cmd_test.go @@ -0,0 +1,67 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package create + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--input", "in-1", "--output-format", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "--input", "in-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline") +} + +func TestCmd_RejectsMissingInput(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) + assert.Contains(t, err.Error(), "input") +} + +func TestCmd_RejectsBadScopeCombo(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--input", "in-1", "--scope", "draft", "--version", "2") + require.Error(t, err) + assert.Contains(t, err.Error(), "draft cannot be combined") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"pipeline", "scope", "version", "input", "output-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/run/get/cmd.go b/cmd/pipeline/run/get/cmd.go new file mode 100644 index 000000000..9a1d03e73 --- /dev/null +++ b/cmd/pipeline/run/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/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 run", + Long: `Display the full record for a single run. + +Example: + dr pipeline run get --pipeline + dr pipeline run get --pipeline --version=2 --output-format json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + result, err := pipeline.GetRun(flags.PipelineID, scope, version, args[0]) + if err != nil { + return handleGetError(err, args[0]) + } + + return pipeline.RenderRun(outputFormat, *result) + }, + } + + flags.Bind(cmd) + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "run_id": telemetry.FirstArg(args), + "scope": flags.Scope, + "version": flags.Version, + "output_format": string(outputFormat), + } + }) + + return cmd +} + +func handleGetError(err error, runID string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No run found with id: " + runID)) + + return nil + } + + return err +} diff --git a/cmd/pipeline/run/get/cmd_test.go b/cmd/pipeline/run/get/cmd_test.go new file mode 100644 index 000000000..a7fe10af8 --- /dev/null +++ b/cmd/pipeline/run/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", "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestCmd_RequiresPositional(t *testing.T) { + err := runCmd(t, "--pipeline", "p") + require.Error(t, err) +} + +func TestHandleGetError_404IsSuppressed(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: http.StatusNotFound, URL: "x"} + assert.NoError(t, handleGetError(httpErr, "d-1")) +} + +func TestHandleGetError_PropagatesOther(t *testing.T) { + err := handleGetError(errors.New("boom"), "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} diff --git a/cmd/pipeline/run/list/cmd.go b/cmd/pipeline/run/list/cmd.go new file mode 100644 index 000000000..c67178072 --- /dev/null +++ b/cmd/pipeline/run/list/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 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 runs", + Long: `List runs for a pipeline. + +Example: + dr pipeline run list --pipeline + dr pipeline run list --pipeline --version=2 --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.ListRuns(flags.PipelineID, scope, version, offset, limit) + if err != nil { + return err + } + + return pipeline.RenderRuns(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 runs 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/run/list/cmd_test.go b/cmd/pipeline/run/list/cmd_test.go new file mode 100644 index 000000000..c39ace176 --- /dev/null +++ b/cmd/pipeline/run/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/run/status/cmd.go b/cmd/pipeline/run/status/cmd.go new file mode 100644 index 000000000..f0a1de70d --- /dev/null +++ b/cmd/pipeline/run/status/cmd.go @@ -0,0 +1,93 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package status + +import ( + "errors" + "fmt" + "net/http" + + "github.com/datarobot/cli/cmd/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: "status ", + Short: "Get the lightweight status of a pipeline run", + Long: `Poll a run's current status without re-downloading the full record. + +Example: + dr pipeline run status --pipeline + dr pipeline run status --pipeline --version=2 --output-format json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if flags.PipelineID == "" { + return errors.New("--pipeline is required") + } + + scope, version, err := flags.Resolve(cmd) + if err != nil { + return err + } + + result, err := pipeline.GetRunStatus(flags.PipelineID, scope, version, args[0]) + if err != nil { + return handleStatusError(err, args[0]) + } + + return pipeline.RenderRunStatus(outputFormat, *result) + }, + } + + flags.Bind(cmd) + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "pipeline_id": flags.PipelineID, + "run_id": telemetry.FirstArg(args), + "scope": flags.Scope, + "version": flags.Version, + "output_format": string(outputFormat), + } + }) + + return cmd +} + +func handleStatusError(err error, runID string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render("No run found with id: " + runID)) + + return nil + } + + return err +} diff --git a/cmd/pipeline/run/status/cmd_test.go b/cmd/pipeline/run/status/cmd_test.go new file mode 100644 index 000000000..061cb698b --- /dev/null +++ b/cmd/pipeline/run/status/cmd_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package status + +import ( + "errors" + "io" + "net/http" + "testing" + + "github.com/datarobot/cli/internal/drapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--pipeline", "p", "--output-format", "yaml", "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingPipeline(t *testing.T) { + err := runCmd(t, "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pipeline") +} + +func TestHandleStatusError_404IsSuppressed(t *testing.T) { + httpErr := &drapi.HTTPError{StatusCode: http.StatusNotFound, URL: "x"} + assert.NoError(t, handleStatusError(httpErr, "d-1")) +} + +func TestHandleStatusError_PropagatesOther(t *testing.T) { + err := handleStatusError(errors.New("boom"), "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} diff --git a/docs/commands/README.md b/docs/commands/README.md index f91e74838..4a4e0692a 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -85,7 +85,13 @@ dr │ ├── version Inspect pipeline versions │ │ ├── list List versions of a pipeline │ │ └── get Display details of a single pipeline version -│ └── graph Display the pipeline/task DAG of a pipeline +│ ├── 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 └── self CLI utility commands ├── completion Shell completion │ ├── install Install completions interactively @@ -256,6 +262,7 @@ For detailed documentation on each command, see: - `lock`—promote a draft pipeline to locked mode. - `version`—`list` / `get` to inspect pipeline versions. - `graph`—display the pipeline/task DAG (draft or locked). + - `run`—`create`/`list`/`get`/`status`/`cancel` pipeline executions. ## Getting help diff --git a/docs/commands/pipeline.md b/docs/commands/pipeline.md index eff859b1f..35515ee0a 100644 --- a/docs/commands/pipeline.md +++ b/docs/commands/pipeline.md @@ -78,6 +78,7 @@ dr pipeline lock | `dr pipeline lock` | `PATCH /api/v2/pipelines/{id}/mode` | Promote a draft to locked mode. | | `dr pipeline version …` | `…/versions[/{ver}]` | Inspect pipeline versions. | | `dr pipeline graph` | `…/graph` (draft or locked) | Render the pipeline/task DAG. | +| `dr pipeline run …` | `…/dispatches` and `…/{id}` | Trigger, inspect, and cancel runs. | ## Subcommands @@ -300,13 +301,31 @@ dr pipeline version get --pipeline 2 dr pipeline graph --pipeline --version=2 --output-format json ``` +### `run` + +Trigger, inspect, and cancel pipeline executions. + +```bash +dr pipelines run create --pipeline --input # draft +dr pipelines run create --pipeline --version=N --input # locked +dr pipelines run list --pipeline [--scope|--version] +dr pipelines run get --pipeline [--scope|--version] +dr pipelines run status --pipeline [--scope|--version] +dr pipelines run cancel --pipeline [--scope|--version] +``` + +`run status` is a lighter-weight call intended for polling — returns just +the run ID, status, and Covalent dispatch ID. + +`run cancel` returns `409 Conflict` if the run is already terminal. + ## Error handling | Status | Cause | |--------|--------------------------------------------------------------------------------| | `400` | Invalid Python file or mismatched pipeline name. | -| `404` | The provided `` or version does not exist. | -| `409` | Tried to update a `locked` pipeline. | +| `404` | The provided ``, version, or run does not exist. | +| `409` | Tried to update a `locked` pipeline, or cancel an already-terminal run. | ## See also diff --git a/docs/commands/pipelines-reference.md b/docs/commands/pipelines-reference.md index b783eab14..5de2e1720 100644 --- a/docs/commands/pipelines-reference.md +++ b/docs/commands/pipelines-reference.md @@ -86,6 +86,22 @@ exercising a local API stub that doesn't implement `/version/`. --- +## Runs (`dr pipelines run …`) + +Same draft/locked scope rules as graph. The wire-level URLs still use the legacy +term `dispatches` / `dispatch_id`, but the CLI's `--output-format json` remaps these to +`run_id` / `covalent_run_id`. + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipelines run create` | `POST /pipelines/{id}/dispatches` (draft)
`POST /pipelines/{id}/versions/{ver}/dispatches` (locked) | `dr pipelines run create --pipeline --input `
`dr pipelines run create --pipeline --version=2 --input --output-format json` | **Flags:** `--pipeline ` (required), `--input ` (required), `--scope`, `--version`, `--output-format json`. | +| `dr pipelines run list` | `GET /pipelines/{id}/dispatches` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches` (locked) | `dr pipelines run list --pipeline `
`dr pipelines run list --pipeline --version=2 --output-format json` | **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--offset `, `--limit `, `--output-format json`. | +| `dr pipelines run get` | `GET /pipelines/{id}/dispatches/{dispatch_id}` (draft)
`GET /pipelines/{id}/versions/{ver}/dispatches/{dispatch_id}` (locked) | `dr pipelines run get --pipeline ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output-format json`. | +| `dr pipelines run status` | `GET /pipelines/{id}/dispatches/{dispatch_id}/status` | `dr pipelines run status --pipeline ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--scope`, `--version`, `--output-format json`. | +| `dr pipelines run cancel` | `DELETE /pipelines/{id}/dispatches/{dispatch_id}` | `dr pipelines run cancel --pipeline ` | **Positional:** `` (required). **Flags:** `--pipeline ` (required), `--scope`, `--version`. | + +--- + ## Quick endpoint lookup | API endpoint | CLI command | @@ -100,3 +116,7 @@ exercising a local API stub that doesn't implement `/version/`. | `GET /pipelines/{id}/versions/{ver}` | `dr pipeline version get` | | `GET /pipelines/{id}/graph` | `dr pipeline graph` (draft) | | `GET /pipelines/{id}/versions/{ver}/graph` | `dr pipeline graph` (locked) | +| `POST /pipelines/{id}/dispatches` | `dr pipeline run create` (draft) | +| `GET /pipelines/{id}/dispatches` | `dr pipeline run list` (draft) | +| `GET /pipelines/{id}/dispatches/{run_id}` | `dr pipeline run get` (draft) | +| `DELETE /pipelines/{id}/dispatches/{run_id}` | `dr pipeline run cancel` (draft) | diff --git a/internal/pipeline/run.go b/internal/pipeline/run.go new file mode 100644 index 000000000..dbc7d0a35 --- /dev/null +++ b/internal/pipeline/run.go @@ -0,0 +1,164 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// run.go wraps the pipeline run endpoints described in +// pipelines-api/.../controllers/pipeline_dispatch.py. The CLI exposes the +// same draft/locked URL split as inputs via the shared Scope helpers. +// +// The wire format and server URL paths still use the legacy term +// "dispatch" (e.g. /dispatches, dispatch_id). JSON tags and endpoint +// segments are preserved to keep the API contract intact while the Go +// surface is renamed to "run" to match the new product vocabulary. + +package pipeline + +import ( + "net/http" + "net/url" + "strconv" + "time" +) + +// Run lifecycle states (mirrors PipelineDispatchStatus on the wire). +const ( + RunStatusPending = "PENDING" + RunStatusRunning = "RUNNING" + RunStatusCompleted = "COMPLETED" + RunStatusFailed = "FAILED" + RunStatusCancelled = "CANCELLED" + RunStatusErrored = "ERRORED" +) + +// Run mirrors PipelineDispatchResponse from the pipelines-api. +type Run struct { + RunID string `json:"id"` + PipelineID string `json:"pipelineId"` + VersionID *int `json:"versionId,omitempty"` + InputID string `json:"inputId"` + CovalentDispatchID string `json:"covalentDispatchId,omitempty"` + TriggeredBy string `json:"triggeredBy"` + Status string `json:"status"` + ErrorDetail string `json:"errorDetail,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// RunStatus mirrors PipelineDispatchStatusResponse — the lightweight +// polling-friendly shape returned by GET .../status. +type RunStatus struct { + RunID string `json:"id"` + Status string `json:"status"` + CovalentDispatchID string `json:"covalentDispatchId,omitempty"` +} + +// RunCreateRequest mirrors PipelineDispatchCreateRequest. +type RunCreateRequest struct { + InputID string `json:"input_id"` +} + +// CreateRun starts a new run for the given input. Returns the +// freshly-created Run (status PENDING). +func CreateRun(pipelineID string, scope Scope, version *int, inputID string) (*Run, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches") + if err != nil { + return nil, err + } + + body := RunCreateRequest{InputID: inputID} + + var result Run + + err = doJSON(http.MethodPost, endpoint, body, "create run", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// ListRuns returns a paginated slice of runs for the given scope. +func ListRuns(pipelineID string, scope Scope, version *int, offset, limit int) ([]Run, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches") + if err != nil { + return nil, err + } + + query := url.Values{} + if offset > 0 { + query.Set("offset", strconv.Itoa(offset)) + } + + if limit > 0 { + query.Set("limit", strconv.Itoa(limit)) + } + + if encoded := query.Encode(); encoded != "" { + endpoint = endpoint + "?" + encoded + } + + var page DataPage[Run] + + err = doJSON(http.MethodGet, endpoint, nil, "runs", &page) + if err != nil { + return nil, err + } + + return page.Data, nil +} + +// GetRun fetches a single run by id within the given scope. +func GetRun(pipelineID string, scope Scope, version *int, runID string) (*Run, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches/"+runID) + if err != nil { + return nil, err + } + + var run Run + + err = doJSON(http.MethodGet, endpoint, nil, "run", &run) + if err != nil { + return nil, err + } + + return &run, nil +} + +// GetRunStatus calls the lightweight GET .../status endpoint useful for +// polling without re-downloading the full run record. +func GetRunStatus(pipelineID string, scope Scope, version *int, runID string) (*RunStatus, error) { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches/"+runID+"/status") + if err != nil { + return nil, err + } + + var status RunStatus + + err = doJSON(http.MethodGet, endpoint, nil, "run status", &status) + if err != nil { + return nil, err + } + + return &status, nil +} + +// CancelRun issues a DELETE on a run, transitioning it to CANCELLED if +// it is still in a non-terminal state. +func CancelRun(pipelineID string, scope Scope, version *int, runID string) error { + endpoint, err := EndpointFor(pipelineID, scope, version, "dispatches/"+runID) + if err != nil { + return err + } + + return doDelete(endpoint, "cancel run") +} diff --git a/internal/pipeline/run_output.go b/internal/pipeline/run_output.go new file mode 100644 index 000000000..ab094c92d --- /dev/null +++ b/internal/pipeline/run_output.go @@ -0,0 +1,254 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// run_output.go holds the rendering helpers shared by the +// `dr pipelines run` verbs. +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" +) + +// runJSON is the CLI-facing shape used for `--output-format json`. It mirrors +// Run but renames the wire-level fields to the CLI's `run` +// vocabulary (`run_id`, `covalent_run_id`). Decoding still happens +// through Run, which keeps the API wire tags intact. +type runJSON struct { + RunID string `json:"run_id"` + PipelineID string `json:"pipeline_id"` + VersionID *int `json:"version_id,omitempty"` + InputID string `json:"input_id"` + CovalentRunID string `json:"covalent_run_id,omitempty"` + TriggeredBy string `json:"triggered_by"` + Status string `json:"status"` + ErrorDetail string `json:"error_detail,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toRunJSON(r Run) runJSON { + return runJSON{ + RunID: r.RunID, + PipelineID: r.PipelineID, + VersionID: r.VersionID, + InputID: r.InputID, + CovalentRunID: r.CovalentDispatchID, + TriggeredBy: r.TriggeredBy, + Status: r.Status, + ErrorDetail: r.ErrorDetail, + CreatedAt: r.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: r.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +// runStatusJSON mirrors RunStatus with CLI-vocabulary keys. +type runStatusJSON struct { + RunID string `json:"run_id"` + Status string `json:"status"` + CovalentRunID string `json:"covalent_run_id,omitempty"` +} + +func toRunStatusJSON(s RunStatus) runStatusJSON { + return runStatusJSON{ + RunID: s.RunID, + Status: s.Status, + CovalentRunID: s.CovalentDispatchID, + } +} + +// RenderRun routes a single run to JSON or human output. +func RenderRun(format OutputFormat, r Run) error { + if format == OutputFormatJSON { + return PrintRunJSON(r) + } + + PrintRunHuman(r) + + return nil +} + +// RenderRuns routes a list of runs to JSON or human output. +func RenderRuns(format OutputFormat, items []Run) error { + if format == OutputFormatJSON { + return PrintRunListJSON(items) + } + + PrintRunListHuman(items) + + return nil +} + +// RenderRunStatus routes a run status to JSON or human output. +func RenderRunStatus(format OutputFormat, s RunStatus) error { + if format == OutputFormatJSON { + return PrintStatusJSON(s) + } + + PrintStatusHuman(s) + + return nil +} + +// PrintRunJSON marshals a run as indented JSON using CLI-vocabulary keys. +func PrintRunJSON(r Run) error { + data, err := json.MarshalIndent(toRunJSON(r), "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintRunHuman renders a single run in a human-friendly form. +func PrintRunHuman(r Run) { + scope := "draft" + versionDisplay := emptyValuePlaceholder + + if r.VersionID != nil { + scope = "locked" + versionDisplay = strconv.Itoa(*r.VersionID) + } + + covalent := r.CovalentDispatchID + if covalent == "" { + covalent = emptyValuePlaceholder + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Run ID:\t%s\n", r.RunID) + fmt.Fprintf(w, "Pipeline ID:\t%s\n", r.PipelineID) + fmt.Fprintf(w, "Scope:\t%s\n", scope) + fmt.Fprintf(w, "Version:\t%s\n", versionDisplay) + fmt.Fprintf(w, "Input ID:\t%s\n", r.InputID) + fmt.Fprintf(w, "Status:\t%s\n", r.Status) + fmt.Fprintf(w, "Triggered By:\t%s\n", r.TriggeredBy) + fmt.Fprintf(w, "Covalent Run:\t%s\n", covalent) + + if r.ErrorDetail != "" { + fmt.Fprintf(w, "Error:\t%s\n", r.ErrorDetail) + } + + fmt.Fprintf(w, "Created:\t%s\n", r.CreatedAt.UTC().Format(timestampFormat)) + fmt.Fprintf(w, "Updated:\t%s\n", r.UpdatedAt.UTC().Format(timestampFormat)) + + w.Flush() +} + +// PrintRunListJSON marshals a list of runs as indented JSON using +// CLI-vocabulary keys. +func PrintRunListJSON(items []Run) error { + view := make([]runJSON, len(items)) + + for i, r := range items { + view[i] = toRunJSON(r) + } + + data, err := json.MarshalIndent(view, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintRunListHuman renders a lipgloss table summary of runs. +func PrintRunListHuman(items []Run) { + if len(items) == 0 { + fmt.Println(tui.DimStyle.Render("No runs found")) + + return + } + + cellStyle := tui.BaseTextStyle.Padding(0, 1) + + dimStyle := tui.DimStyle.Padding(0, 1) + + headers := []string{"RUN ID", "SCOPE", "VERSION", "STATUS", "TRIGGER", "UPDATED"} + + updatedCol := slices.Index(headers, "UPDATED") + + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(tui.TableBorderStyle). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return cellStyle.Bold(true) + } + + if col == updatedCol { + return dimStyle + } + + return cellStyle + }). + Headers(headers...) + + for _, r := range items { + scope := "draft" + ver := emptyValuePlaceholder + + if r.VersionID != nil { + scope = "locked" + ver = strconv.Itoa(*r.VersionID) + } + + t.Row(r.RunID, scope, ver, r.Status, r.TriggeredBy, r.UpdatedAt.UTC().Format(timestampFormat)) + } + + fmt.Fprintln(os.Stdout, t.Render()) +} + +// PrintStatusJSON marshals a lightweight status response as indented JSON +// using CLI-vocabulary keys. +func PrintStatusJSON(s RunStatus) error { + data, err := json.MarshalIndent(toRunStatusJSON(s), "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintStatusHuman renders a lightweight status response. +func PrintStatusHuman(s RunStatus) { + covalent := s.CovalentDispatchID + if covalent == "" { + covalent = emptyValuePlaceholder + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Run ID:\t%s\n", s.RunID) + fmt.Fprintf(w, "Status:\t%s\n", s.Status) + fmt.Fprintf(w, "Covalent Run:\t%s\n", covalent) + + w.Flush() +} diff --git a/internal/pipeline/run_test.go b/internal/pipeline/run_test.go new file mode 100644 index 000000000..3fd14228d --- /dev/null +++ b/internal/pipeline/run_test.go @@ -0,0 +1,168 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipeline + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateRun_DraftURLAndBody(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/dispatches", r.URL.Path) + + var body RunCreateRequest + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "in-1", body.InputID) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"d-1","pipelineId":"p-1","inputId":"in-1","triggeredBy":"u","status":"PENDING"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := CreateRun("p-1", ScopeDraft, nil, "in-1") + require.NoError(t, err) + assert.Equal(t, "d-1", got.RunID) + assert.Equal(t, RunStatusPending, got.Status) +} + +func TestCreateRun_LockedURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/dispatches", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"d-1","pipelineId":"p-1","versionId":2,"inputId":"in-1","triggeredBy":"u","status":"PENDING"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + v := 2 + got, err := CreateRun("p-1", ScopeLocked, &v, "in-1") + require.NoError(t, err) + require.NotNil(t, got.VersionID) + assert.Equal(t, 2, *got.VersionID) +} + +func TestListRuns_QueryAndDecode(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/dispatches", r.URL.Path) + assert.Equal(t, "10", r.URL.Query().Get("offset")) + assert.Equal(t, "5", r.URL.Query().Get("limit")) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"d-1","pipelineId":"p-1","inputId":"in-1","triggeredBy":"u","status":"RUNNING"}],"totalCount":1,"count":1}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + items, err := ListRuns("p-1", ScopeDraft, nil, 10, 5) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, RunStatusRunning, items[0].Status) +} + +func TestGetRun_TargetsCorrectURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/dispatches/d-1", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"d-1","pipelineId":"p-1","inputId":"in-1","triggeredBy":"u","status":"COMPLETED"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := GetRun("p-1", ScopeDraft, nil, "d-1") + require.NoError(t, err) + assert.Equal(t, RunStatusCompleted, got.Status) +} + +func TestGetRunStatus_StatusEndpointURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/dispatches/d-1/status", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"d-1","status":"RUNNING","covalentDispatchId":"cov-x"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + v := 2 + got, err := GetRunStatus("p-1", ScopeLocked, &v, "d-1") + require.NoError(t, err) + assert.Equal(t, RunStatusRunning, got.Status) + assert.Equal(t, "cov-x", got.CovalentDispatchID) +} + +func TestCancelRun_DeletesDraftURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/dispatches/d-1", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + require.NoError(t, CancelRun("p-1", ScopeDraft, nil, "d-1")) +} + +func TestCancelRun_PropagatesConflict(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"detail":"already terminal"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + err := CancelRun("p-1", ScopeDraft, nil, "d-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 409") + assert.Contains(t, err.Error(), "already terminal") +}