diff --git a/.github/documentation-style-spec.md b/.github/documentation-style-spec.md index 0c2e93ed0..fc971ef11 100644 --- a/.github/documentation-style-spec.md +++ b/.github/documentation-style-spec.md @@ -262,7 +262,7 @@ Add periods to descriptions **Format** ```markdown -✅ **Note:** The CLI requires Go 1.26.3 or later. +✅ **Note:** The CLI requires Go 1.26.4 or later. ✅ **Important:** Never commit `.env` files to version control. diff --git a/.github/workflows/.build-matrix.yaml b/.github/workflows/.build-matrix.yaml index 0323ca1bf..3fdf97d83 100644 --- a/.github/workflows/.build-matrix.yaml +++ b/.github/workflows/.build-matrix.yaml @@ -12,7 +12,7 @@ on: description: 'Go version to use' required: false type: string - default: '1.26.3' + default: '1.26.4' upload-artifact: description: 'Whether to upload artifacts' required: false diff --git a/.github/workflows/.build-windows.yaml b/.github/workflows/.build-windows.yaml index c53f20042..b7d4bc505 100644 --- a/.github/workflows/.build-windows.yaml +++ b/.github/workflows/.build-windows.yaml @@ -7,7 +7,7 @@ on: description: 'Go version to use' required: false type: string - default: '1.26.3' + default: '1.26.4' artifact-name: description: 'Name for the artifact' required: false diff --git a/.github/workflows/.setup.yaml b/.github/workflows/.setup.yaml index 85584c0f3..6dc50217d 100644 --- a/.github/workflows/.setup.yaml +++ b/.github/workflows/.setup.yaml @@ -7,7 +7,7 @@ on: description: 'Go version to use' required: false type: string - default: '1.26.3' + default: '1.26.4' install-taskfile: description: 'Whether to install Taskfile' required: false diff --git a/.github/workflows/.smoke-tests-matrix.yaml b/.github/workflows/.smoke-tests-matrix.yaml index 4312a46cd..fe6e4d73d 100644 --- a/.github/workflows/.smoke-tests-matrix.yaml +++ b/.github/workflows/.smoke-tests-matrix.yaml @@ -12,7 +12,7 @@ on: description: 'Go version to use' required: false type: string - default: '1.26.3' + default: '1.26.4' ref: description: 'Git ref to checkout (for fork PRs)' required: false diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 86247e7f7..2da868a27 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -11,7 +11,7 @@ Builds the DR CLI binary across multiple operating systems (Linux, macOS, Window **Inputs:** - `os-matrix` (string, default: `["ubuntu-latest", "macos-latest", "windows-latest"]`) - OS matrix for builds -- `go-version` (string, default: `1.26.3`) - Go version to use +- `go-version` (string, default: `1.26.4`) - Go version to use - `upload-artifact` (boolean, default: `false`) - Whether to upload artifacts - `artifact-name-prefix` (string, default: `dr`) - Prefix for artifact names @@ -21,7 +21,7 @@ jobs: build: uses: ./.github/workflows/.build-matrix.yaml with: - go-version: '1.26.3' + go-version: '1.26.4' upload-artifact: true secrets: inherit ``` @@ -30,7 +30,7 @@ jobs: Builds Windows binary using GoReleaser (cross-compiled from Ubuntu). **Inputs:** -- `go-version` (string, default: `1.26.3`) - Go version to use +- `go-version` (string, default: `1.26.4`) - Go version to use - `artifact-name` (string, default: `dr-windows`) - Name for the artifact - `ref` (string, optional) - Git ref to checkout (useful for fork PRs) @@ -49,7 +49,7 @@ Runs smoke tests on Linux and macOS. **Inputs:** - `os-matrix` (string, default: `["ubuntu-latest", "macos-latest"]`) - OS matrix -- `go-version` (string, default: `1.26.3`) - Go version to use +- `go-version` (string, default: `1.26.4`) - Go version to use - `ref` (string, optional) - Git ref to checkout **Secrets (required):** @@ -62,7 +62,7 @@ jobs: smoke-test: uses: ./.github/workflows/.smoke-tests-matrix.yaml with: - go-version: '1.26.3' + go-version: '1.26.4' secrets: DR_API_TOKEN: ${{ secrets.DR_API_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -114,7 +114,7 @@ jobs: Basic environment setup (checkout, Go, Taskfile, caching). **Inputs:** -- `go-version` (string, default: `1.26.3`) - Go version to use +- `go-version` (string, default: `1.26.4`) - Go version to use - `install-taskfile` (boolean, default: `true`) - Whether to install Taskfile - `setup-cache` (boolean, default: `false`) - Whether to setup Go cache diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index cba9be0bd..a3a891bd4 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -68,7 +68,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.26.3' + go-version: '1.26.4' - name: golangci-lint uses: golangci/golangci-lint-action@v7 @@ -106,7 +106,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.26.3' + go-version: '1.26.4' - name: Install Taskfile uses: arduino/setup-task@v2 @@ -167,7 +167,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.26.3' + go-version: '1.26.4' - name: Install Envdoc run: | @@ -188,7 +188,7 @@ jobs: if: needs.detect-changes.outputs.go_code == 'true' || needs.detect-changes.outputs.completion == 'true' uses: ./.github/workflows/.build-matrix.yaml with: - go-version: '1.26.3' + go-version: '1.26.4' upload-artifact: true artifact-name-prefix: 'dr' secrets: inherit diff --git a/.github/workflows/fork-smoke-tests.yaml b/.github/workflows/fork-smoke-tests.yaml index 2f9b3176a..7de2e42d5 100644 --- a/.github/workflows/fork-smoke-tests.yaml +++ b/.github/workflows/fork-smoke-tests.yaml @@ -102,7 +102,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.26.3' + go-version: '1.26.4' - name: Install gosec run: go install github.com/securego/gosec/v2/cmd/gosec@latest @@ -115,7 +115,7 @@ jobs: needs: [resolve-pr, security-scan] uses: ./.github/workflows/.build-windows.yaml with: - go-version: '1.26.3' + go-version: '1.26.4' artifact-name: 'dr-windows-fork' ref: ${{ needs.resolve-pr.outputs.sha }} secrets: inherit @@ -170,7 +170,7 @@ jobs: needs: [resolve-pr, security-scan] uses: ./.github/workflows/.smoke-tests-matrix.yaml with: - go-version: '1.26.3' + go-version: '1.26.4' ref: ${{ needs.resolve-pr.outputs.sha }} secrets: inherit diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index f0f8722ba..6a69b1bda 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -22,7 +22,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.26.3' + go-version: '1.26.4' - name: Run goreleaser uses: goreleaser/goreleaser-action@v6 diff --git a/.github/workflows/security-scan.yaml b/.github/workflows/security-scan.yaml index 5e56b8d16..69134e382 100644 --- a/.github/workflows/security-scan.yaml +++ b/.github/workflows/security-scan.yaml @@ -59,7 +59,7 @@ jobs: # - name: Set up Go # uses: actions/setup-go@v5 # with: - # go-version: '1.26.3' + # go-version: '1.26.4' # - name: Install gosec # run: go install github.com/securego/gosec/v2/cmd/gosec@latest @@ -110,7 +110,7 @@ jobs: # - name: Set up Go # uses: actions/setup-go@v5 # with: - # go-version: '1.26.3' + # go-version: '1.26.4' # - name: Install govulncheck # run: go install golang.org/x/vuln/cmd/govulncheck@latest diff --git a/.github/workflows/smoke-tests-on-demand.yaml b/.github/workflows/smoke-tests-on-demand.yaml index c04855e52..df1702b65 100644 --- a/.github/workflows/smoke-tests-on-demand.yaml +++ b/.github/workflows/smoke-tests-on-demand.yaml @@ -77,7 +77,7 @@ jobs: if: needs.check-fork.outputs.is_fork == 'false' uses: ./.github/workflows/.build-windows.yaml with: - go-version: '1.26.3' + go-version: '1.26.4' artifact-name: 'dr-windows' secrets: inherit @@ -86,7 +86,7 @@ jobs: if: needs.check-fork.outputs.is_fork == 'false' uses: ./.github/workflows/.smoke-tests-matrix.yaml with: - go-version: '1.26.3' + go-version: '1.26.4' secrets: DR_API_TOKEN: ${{ secrets.DR_API_TOKEN }} diff --git a/.github/workflows/smoke-tests.yaml b/.github/workflows/smoke-tests.yaml index ea4f57e99..8391e4b2b 100644 --- a/.github/workflows/smoke-tests.yaml +++ b/.github/workflows/smoke-tests.yaml @@ -21,14 +21,14 @@ jobs: build-windows: uses: ./.github/workflows/.build-windows.yaml with: - go-version: '1.26.3' + go-version: '1.26.4' artifact-name: 'dr-windows' secrets: inherit smoke-test: uses: ./.github/workflows/.smoke-tests-matrix.yaml with: - go-version: '1.26.3' + go-version: '1.26.4' secrets: DR_API_TOKEN: ${{ secrets.DR_API_TOKEN }} diff --git a/.golangci.yaml b/.golangci.yaml index 060a04378..11022dda5 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -1,6 +1,6 @@ version: "2" run: - go: 1.26.3 + go: 1.26.4 linters: enable: - asasalint diff --git a/README.md b/README.md index 4a8345d6c..71c4c443e 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ If you would like to build and install from source, you can do so by following t #### Prerequisites -- Go 1.26.3 or later (for building from source) +- Go 1.26.4 or later (for building from source) - Git - [Task](https://taskfile.dev/) (for development and task running) diff --git a/cmd/auth/login/cmd.go b/cmd/auth/login/cmd.go index 34a290a38..69bf1abf5 100644 --- a/cmd/auth/login/cmd.go +++ b/cmd/auth/login/cmd.go @@ -24,6 +24,7 @@ import ( "github.com/datarobot/cli/internal/config" "github.com/datarobot/cli/internal/config/viperx" "github.com/datarobot/cli/internal/log" + "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -76,7 +77,17 @@ func RunE(cmd *cobra.Command, args []string) error { //nolint: cyclop // Clear existing token and get new one viperx.Set(config.DataRobotAPIKey, "") - key, err := auth.WaitForAPIKeyCallback(cmd.Context(), datarobotHost) + var key string + + auth.PrintAuthInstructions(auth.AuthCallbackURL(datarobotHost)) + + err = tui.RunWithSpinner("Waiting for browser authorization…", func() error { + var waitErr error + + key, waitErr = auth.WaitForAPIKeyCallback(cmd.Context(), datarobotHost) + + return waitErr + }) if err != nil { log.Error(err) diff --git a/cmd/component/shared/addModel.go b/cmd/component/shared/addModel.go index ae972c727..a66a9d95c 100644 --- a/cmd/component/shared/addModel.go +++ b/cmd/component/shared/addModel.go @@ -21,6 +21,7 @@ import ( "time" "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/datarobot/cli/internal/copier" @@ -46,6 +47,7 @@ const ( type AddModel struct { screen addScreens list list.Model + spinner spinner.Model width int height int errorMsg string @@ -53,8 +55,13 @@ type AddModel struct { } func NewAddModel() AddModel { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = tui.InfoStyle + return AddModel{ - screen: addLoadingScreen, + screen: addLoadingScreen, + spinner: s, } } @@ -149,7 +156,7 @@ func (am AddModel) loadComponents() tea.Cmd { } func (am AddModel) Init() tea.Cmd { - return tea.Batch(am.loadComponents(), tea.WindowSize()) + return tea.Batch(am.loadComponents(), am.spinner.Tick, tea.WindowSize()) } func (am AddModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint: cyclop @@ -159,6 +166,14 @@ func (am AddModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint: cyclop am.errorMsg = "" return am, nil } + case spinner.TickMsg: + if am.screen == addLoadingScreen { + var cmd tea.Cmd + + am.spinner, cmd = am.spinner.Update(msg) + + return am, cmd + } case tea.WindowSizeMsg: am.width = msg.Width am.height = msg.Height @@ -230,7 +245,7 @@ func (am AddModel) View() string { func (am AddModel) addLoadingScreenView() string { var sb strings.Builder - sb.WriteString("Loading components...") + sb.WriteString(tui.InfoStyle.Render(am.spinner.View()+" ") + "Loading components…") return sb.String() } diff --git a/cmd/component/shared/updateModel.go b/cmd/component/shared/updateModel.go index e23c6da9a..4bbad2bb3 100644 --- a/cmd/component/shared/updateModel.go +++ b/cmd/component/shared/updateModel.go @@ -22,6 +22,7 @@ import ( "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -104,9 +105,12 @@ type UpdateModel struct { infoMessage string screen screens list list.Model // This list holds the components + width int viewport viewport.Model help help.Model keys detailKeyMap + spinner spinner.Model + updating bool ready bool ExitMessage string updateFlags copier.UpdateFlags @@ -159,6 +163,8 @@ func (m UpdateModel) unselectComponent(itemToUnselect ListItem, err error) (Upda } if count <= 1 { + m.updating = false + return m, tea.Quit } @@ -193,16 +199,21 @@ func NewUpdateComponentModel(updateFlags copier.UpdateFlags) UpdateModel { h.Styles.ShortKey = lipgloss.NewStyle().Foreground(tui.DrPurple) h.Styles.ShortDesc = lipgloss.NewStyle().Foreground(tui.DimStyle.GetForeground()) + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = tui.InfoStyle + return UpdateModel{ screen: listScreen, help: h, keys: newDetailKeys(), updateFlags: updateFlags, + spinner: s, } } func (m UpdateModel) Init() tea.Cmd { - return tea.Batch(m.loadComponents(), tea.WindowSize()) + return tea.Batch(m.loadComponents(), m.spinner.Tick, tea.WindowSize()) } func (m UpdateModel) loadComponents() tea.Cmd { @@ -243,6 +254,17 @@ func (m UpdateModel) showComponentInfo() tea.Cmd { func (m UpdateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:cyclop switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + + case spinner.TickMsg: + if len(m.list.Items()) == 0 || m.updating { + var cmd tea.Cmd + + m.spinner, cmd = m.spinner.Update(msg) + + return m, cmd + } case componentsLoadedMsg: m.list = msg.list @@ -253,11 +275,33 @@ func (m UpdateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:cyclop return m, tea.WindowSize() case updateCompleteMsg: + if msg.err != nil { + m.infoMessage = "Failed to update " + msg.item.component.ComponentDetails.Name + } else { + m.infoMessage = "Updated " + msg.item.component.ComponentDetails.Name + } + return m.unselectComponent(msg.item, msg.err) } switch m.screen { case listScreen: + if m.updating { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + if len(m.list.Items()) > 0 { + newListModel, cmd := m.list.Update(msg) + m.list = newListModel + + return m, cmd + } + + return m, nil + } + + return m, nil + } + // IMPT: Since we're using a custom item & respective delegate // we need to account for filtering here and allow list to handle updating if m.list.FilterState() == list.Filtering { @@ -316,9 +360,12 @@ func (m UpdateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:cyclop cmdsToRun = append(cmdsToRun, updateComponent(listItem, m.updateFlags)) } + m.updating = true + m.infoMessage = "Updating selected components..." + cmd := tea.Sequence(cmdsToRun...) - return m, cmd + return m, tea.Batch(m.spinner.Tick, cmd) } default: // If we have an error allow any keypress to exit screen/quit @@ -419,6 +466,21 @@ func (m UpdateModel) viewListScreen() string { return sb.String() } + // Show spinner while components are loading + if len(m.list.Items()) == 0 { + sb.WriteString(tui.InfoStyle.Render(m.spinner.View()+" ") + "Loading components…") + + return sb.String() + } + + if m.updating { + sb.WriteString(tui.WelcomeStyle.Render("Available Components for Recipe Agent Template:")) + sb.WriteString("\n\n") + sb.WriteString(tui.RenderStatusBar(m.width, m.spinner, "Updating selected components...", true)) + + return sb.String() + } + sb.WriteString(tui.WelcomeStyle.Render("Available Components for Recipe Agent Template:")) sb.WriteString("\n\n") diff --git a/cmd/dotenv/promptModel.go b/cmd/dotenv/promptModel.go index b88ef1ede..a05271d5b 100644 --- a/cmd/dotenv/promptModel.go +++ b/cmd/dotenv/promptModel.go @@ -24,6 +24,7 @@ import ( "strings" "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -39,6 +40,8 @@ type promptModel struct { list list.Model Values []string successCmd tea.Cmd + loading bool + spinner spinner.Model } var ( @@ -56,8 +59,14 @@ const ( errMsgTimeout = "⏳ Request timed out. Please check your network connection and try again." errMsgNoLLMs = "🤷 No available LLMs found. Please contact support for assistance." errMsgContactSupport = "👥 Please try again or contact support if the issue persists." + loadingLLMsMsg = "Fetching available LLMs from DataRobot..." ) +type llmCatalogLoadedMsg struct { + llms *drapi.LLMList + err error +} + type item envbuilder.PromptOption func (i item) FilterValue() string { @@ -105,7 +114,7 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list func newPromptModel(prompt envbuilder.UserPrompt, successCmd tea.Cmd) (promptModel, tea.Cmd) { if prompt.Type == "llmgw_catalog" { - return newLLMListPrompt(prompt, successCmd) + return newLLMListPromptAsync(prompt, successCmd) } if len(prompt.Options) == 0 { @@ -115,6 +124,29 @@ func newPromptModel(prompt envbuilder.UserPrompt, successCmd tea.Cmd) (promptMod return newListPrompt(prompt, successCmd) } +func newLLMListPromptAsync(prompt envbuilder.UserPrompt, successCmd tea.Cmd) (promptModel, tea.Cmd) { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = tui.InfoStyle + + pm := promptModel{ + prompt: prompt, + successCmd: successCmd, + loading: true, + spinner: s, + } + + return pm, tea.Batch(pm.spinner.Tick, loadLLMCatalogCmd()) +} + +func loadLLMCatalogCmd() tea.Cmd { + return func() tea.Msg { + llms, err := drapi.GetLLMs() + + return llmCatalogLoadedMsg{llms: llms, err: err} + } +} + func newTextInputPrompt(prompt envbuilder.UserPrompt, successCmd tea.Cmd) (promptModel, tea.Cmd) { ti := textinput.New() ti.SetValue(prompt.Value) @@ -193,39 +225,54 @@ func llmsToPromptOptions(llms []drapi.LLM) []envbuilder.PromptOption { func newLLMListPrompt(prompt envbuilder.UserPrompt, successCmd tea.Cmd) (promptModel, tea.Cmd) { llms, err := drapi.GetLLMs() if err != nil { - log.Errorf("Error retrieving LLMs: %s", err.Error()) - - var ( - netErr net.Error - httpErr *drapi.HTTPError - statusCode int - ) - - helpMsg := errMsgPrefix - - // Check if the error is a network timeout or an HTTP error to provide more specific feedback - // Treat net.Error.Timeout() as an HTTP 408 Request Timeout for user-friendly messaging - if errors.As(err, &netErr) && netErr.Timeout() { - statusCode = http.StatusRequestTimeout - } else if errors.As(err, &httpErr) { - statusCode = httpErr.StatusCode - } + return llmErrorPrompt(prompt, successCmd, err), nil + } - switch statusCode { - case http.StatusUnauthorized, http.StatusForbidden: - helpMsg += errMsgAuthFailed - case http.StatusNotFound: - helpMsg += errMsgNotFound - case http.StatusRequestTimeout, http.StatusGatewayTimeout: - helpMsg += errMsgTimeout - default: - helpMsg += err.Error() + "\n\n" + errMsgContactSupport - } + return llmListPromptFromCatalog(prompt, successCmd, llms) +} + +func llmErrorPrompt(prompt envbuilder.UserPrompt, successCmd tea.Cmd, err error) promptModel { + log.Errorf("Error retrieving LLMs: %s", err.Error()) + + var ( + netErr net.Error + httpErr *drapi.HTTPError + statusCode int + ) + + helpMsg := errMsgPrefix + + // Check if the error is a network timeout or an HTTP error to provide more specific feedback + // Treat net.Error.Timeout() as an HTTP 408 Request Timeout for user-friendly messaging + if errors.As(err, &netErr) && netErr.Timeout() { + statusCode = http.StatusRequestTimeout + } else if errors.As(err, &httpErr) { + statusCode = httpErr.StatusCode + } + switch statusCode { + case http.StatusUnauthorized, http.StatusForbidden: + helpMsg += errMsgAuthFailed + case http.StatusNotFound: + helpMsg += errMsgNotFound + case http.StatusRequestTimeout, http.StatusGatewayTimeout: + helpMsg += errMsgTimeout + default: + helpMsg += err.Error() + "\n\n" + errMsgContactSupport + } + + errPrompt := prompt + errPrompt.Type = typeError + errPrompt.Help = helpMsg + + return promptModel{prompt: errPrompt, successCmd: successCmd} +} + +func llmListPromptFromCatalog(prompt envbuilder.UserPrompt, successCmd tea.Cmd, llms *drapi.LLMList) (promptModel, tea.Cmd) { + if llms == nil { errPrompt := prompt errPrompt.Type = typeError - - errPrompt.Help = helpMsg + errPrompt.Help = errMsgPrefix + errMsgContactSupport return promptModel{prompt: errPrompt, successCmd: successCmd}, nil } @@ -272,7 +319,32 @@ func (pm promptModel) GetValues() []string { return []string{current.FilterValue()} } -func (pm promptModel) Update(msg tea.Msg) (promptModel, tea.Cmd) { +func (pm promptModel) Update(msg tea.Msg) (promptModel, tea.Cmd) { //nolint:cyclop + if pm.loading { + switch msg := msg.(type) { + case llmCatalogLoadedMsg: + if msg.err != nil { + errorPrompt := llmErrorPrompt(pm.prompt, pm.successCmd, msg.err) + errorPrompt.loading = false + + return errorPrompt, nil + } + + nextPrompt, cmd := llmListPromptFromCatalog(pm.prompt, pm.successCmd, msg.llms) + nextPrompt.loading = false + + return nextPrompt, cmd + case spinner.TickMsg: + var cmd tea.Cmd + + pm.spinner, cmd = pm.spinner.Update(msg) + + return pm, cmd + } + + return pm, nil + } + if len(pm.prompt.Options) > 0 { switch msg := msg.(type) { case tea.KeyMsg: @@ -358,6 +430,14 @@ func (pm promptModel) View() string { sb.Write([]byte(tui.SubTitleStyle.Render(fmt.Sprintf("Variable: %v", pm.prompt.Env)))) sb.WriteString("\n\n") + if pm.loading { + sb.WriteString(tui.InfoStyle.Render(pm.spinner.View() + " " + loadingLLMsMsg)) + sb.WriteString("\n\n") + sb.WriteString(tui.DimStyle.Render("ctrl-p back to previous")) + + return sb.String() + } + if pm.prompt.Type.String() == typeError { sb.WriteString(tui.ErrorStyle.Render(pm.prompt.Help)) } else { diff --git a/cmd/dotenv/promptModel_test.go b/cmd/dotenv/promptModel_test.go index 7f63016c6..506882841 100644 --- a/cmd/dotenv/promptModel_test.go +++ b/cmd/dotenv/promptModel_test.go @@ -21,6 +21,7 @@ import ( "strings" "testing" + "github.com/charmbracelet/bubbles/spinner" "github.com/datarobot/cli/internal/config" "github.com/datarobot/cli/internal/config/viperx" "github.com/datarobot/cli/internal/drapi" @@ -171,6 +172,65 @@ func TestNewLLMListPrompt_Success(t *testing.T) { assert.Len(t, pm.list.Items(), 2) } +func TestNewLLMListPromptAsync_LoadingState(t *testing.T) { + prompt := envbuilder.UserPrompt{Type: "llmgw_catalog", Env: "LLM_VAR"} + + pm, cmd := newLLMListPromptAsync(prompt, nil) + + assert.True(t, pm.loading) + assert.NotNil(t, cmd) +} + +func TestPromptModel_UpdateLLMCatalogLoadedMsg_Success(t *testing.T) { + pm, _ := newLLMListPromptAsync(envbuilder.UserPrompt{Type: "llmgw_catalog", Env: "LLM_VAR"}, nil) + + updated, cmd := pm.Update(llmCatalogLoadedMsg{llms: &drapi.LLMList{ + LLMs: []drapi.LLM{ + {LlmID: "1", Name: "GPT-4o", Provider: "azure", Model: "gpt-4o", IsActive: true}, + }, + Count: 1, TotalCount: 1, + }}) + + assert.False(t, updated.loading) + assert.NotContains(t, updated.prompt.Type.String(), "error") + assert.Len(t, updated.list.Items(), 1) + assert.NotNil(t, cmd) +} + +func TestPromptModel_UpdateLLMCatalogLoadedMsg_Error(t *testing.T) { + pm, _ := newLLMListPromptAsync(envbuilder.UserPrompt{Type: "llmgw_catalog", Env: "LLM_VAR"}, nil) + + updated, cmd := pm.Update(llmCatalogLoadedMsg{err: &drapi.HTTPError{StatusCode: http.StatusUnauthorized}}) + + assert.False(t, updated.loading) + assert.Contains(t, updated.prompt.Type.String(), "error") + assert.Contains(t, updated.prompt.Help, "Authentication failed") + assert.Nil(t, cmd) +} + +func TestPromptModel_UpdateLLMCatalogSpinnerTick(t *testing.T) { + pm, _ := newLLMListPromptAsync(envbuilder.UserPrompt{Type: "llmgw_catalog", Env: "LLM_VAR"}, nil) + + updated, cmd := pm.Update(spinner.TickMsg{}) + + assert.True(t, updated.loading) + assert.NotNil(t, cmd) +} + +func TestPromptModelView_LoadingState(t *testing.T) { + pm := promptModel{ + prompt: envbuilder.UserPrompt{Type: "llmgw_catalog", Env: "LLM_VAR", Help: "help text"}, + loading: true, + spinner: spinner.New(), + } + + view := pm.View() + + assert.Contains(t, view, "LLM_VAR") + assert.Contains(t, view, "Fetching available LLMs from DataRobot") + assert.Contains(t, view, "ctrl-p back to previous") +} + // TestPromptModelView_ErrorType verifies that when the prompt type contains "error", // View renders the variable name, error help message, and back navigation hint. func TestPromptModelView_ErrorType(t *testing.T) { diff --git a/cmd/pipeline/cmd.go b/cmd/pipeline/cmd.go index 0ed096c58..6fafb543b 100644 --- a/cmd/pipeline/cmd.go +++ b/cmd/pipeline/cmd.go @@ -17,12 +17,14 @@ package pipeline import ( "github.com/datarobot/cli/cmd/pipeline/create" "github.com/datarobot/cli/cmd/pipeline/del" + "github.com/datarobot/cli/cmd/pipeline/environment" "github.com/datarobot/cli/cmd/pipeline/get" "github.com/datarobot/cli/cmd/pipeline/graph" "github.com/datarobot/cli/cmd/pipeline/input" "github.com/datarobot/cli/cmd/pipeline/list" "github.com/datarobot/cli/cmd/pipeline/lock" "github.com/datarobot/cli/cmd/pipeline/run" + "github.com/datarobot/cli/cmd/pipeline/schedule" "github.com/datarobot/cli/cmd/pipeline/update" "github.com/datarobot/cli/cmd/pipeline/version" "github.com/datarobot/cli/internal/features" @@ -55,6 +57,8 @@ input payloads, runs, and recurring schedules.`, graph.Cmd(), run.Cmd(), input.Cmd(), + schedule.Cmd(), + environment.Cmd(), ) return cmd diff --git a/cmd/pipeline/cmd_test.go b/cmd/pipeline/cmd_test.go index 586370e20..752896a10 100644 --- a/cmd/pipeline/cmd_test.go +++ b/cmd/pipeline/cmd_test.go @@ -55,16 +55,17 @@ 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, - "graph": false, - "run": false, - "input": false, + "create": false, + "get": false, + "list": false, + "update": false, + "delete": false, + "lock": false, + "version": false, + "graph": false, + "run": false, + "input": false, + "schedule": false, } for _, sub := range cmd.Commands() { diff --git a/cmd/pipeline/environment/cmd.go b/cmd/pipeline/environment/cmd.go new file mode 100644 index 000000000..68a19ddff --- /dev/null +++ b/cmd/pipeline/environment/cmd.go @@ -0,0 +1,51 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package environment + +import ( + "github.com/datarobot/cli/cmd/pipeline/environment/create" + "github.com/datarobot/cli/cmd/pipeline/environment/del" + "github.com/datarobot/cli/cmd/pipeline/environment/list" + "github.com/datarobot/cli/cmd/pipeline/environment/update" + "github.com/datarobot/cli/cmd/pipeline/environment/version" + "github.com/spf13/cobra" +) + +// Cmd returns the parent command for `dr pipeline environment`. It +// groups the lifecycle verbs that operate on pipeline execution +// environments (named, immutable-versioned bags of pip packages). +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "environment", + Aliases: []string{"environments"}, + Short: "Manage pipeline execution environments", + Long: `Manage pipeline execution environments. + +Environments are named, immutable-versioned bags of pip packages that +pipelines can be built against. Each ` + "`update`" + ` adds packages by +creating a new version; older versions can be deleted individually with +` + "`environment version delete`" + `.`, + } + + cmd.AddCommand( + create.Cmd(), + list.Cmd(), + update.Cmd(), + del.Cmd(), + version.Cmd(), + ) + + return cmd +} diff --git a/cmd/pipeline/environment/cmd_test.go b/cmd/pipeline/environment/cmd_test.go new file mode 100644 index 000000000..78456a7e7 --- /dev/null +++ b/cmd/pipeline/environment/cmd_test.go @@ -0,0 +1,46 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package environment + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCmd_RegistersAllVerbs(t *testing.T) { + cmd := Cmd() + + want := map[string]bool{ + "create": false, + "list": false, + "update": false, + "delete": false, + "version": false, + } + + for _, sub := range cmd.Commands() { + want[sub.Name()] = true + } + + for verb, present := range want { + assert.Truef(t, present, "missing subcommand: %s", verb) + } +} + +func TestCmd_HasPluralAlias(t *testing.T) { + cmd := Cmd() + assert.Contains(t, cmd.Aliases, "environments") +} diff --git a/cmd/pipeline/environment/create/cmd.go b/cmd/pipeline/environment/create/cmd.go new file mode 100644 index 000000000..ba0e07df6 --- /dev/null +++ b/cmd/pipeline/environment/create/cmd.go @@ -0,0 +1,81 @@ +// 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/internal/auth" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + var ( + name string + description string + rawPackages []string + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a pipeline execution environment", + Long: `Create a new pipeline execution environment. + +A new environment is registered with an initial version (v1) containing +the supplied pip packages. The environment may be referenced by +pipelines once its first version reaches the READY state. + +Example: + dr pipeline environment create --name ml-base --package numpy --package pandas + dr pipeline environment create --name ml-base --package numpy,pandas==2.0 --description "training base" --output-format json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, _ []string) error { + if name == "" { + return errors.New("--name is required") + } + + packages, err := pipeline.NormalizePackages(rawPackages) + if err != nil { + return err + } + + result, err := pipeline.CreateEnvironment(name, description, packages) + if err != nil { + return err + } + + return pipeline.RenderEnvironment(outputFormat, *result) + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Environment name (required)") + _ = cmd.MarkFlagRequired("name") + cmd.Flags().StringVar(&description, "description", "", "Optional description") + cmd.Flags().StringSliceVar(&rawPackages, "package", nil, "Pip package spec (repeatable, also accepts comma-separated values)") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { + return map[string]any{ + "output_format": string(outputFormat), + } + }) + + return cmd +} diff --git a/cmd/pipeline/environment/create/cmd_test.go b/cmd/pipeline/environment/create/cmd_test.go new file mode 100644 index 000000000..6944b3b4a --- /dev/null +++ b/cmd/pipeline/environment/create/cmd_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package create + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--name", "x", "--package", "numpy", "--output-format", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RejectsMissingName(t *testing.T) { + err := runCmd(t, "--package", "numpy") + require.Error(t, err) + assert.Contains(t, err.Error(), "name") +} + +func TestCmd_RejectsMissingPackages(t *testing.T) { + err := runCmd(t, "--name", "x") + require.Error(t, err) + assert.Contains(t, err.Error(), "package") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"name", "description", "package", "output-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/environment/del/cmd.go b/cmd/pipeline/environment/del/cmd.go new file mode 100644 index 000000000..4ec773308 --- /dev/null +++ b/cmd/pipeline/environment/del/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 del implements `dr pipeline environment delete`. Directory +// is named `del` rather than `delete` to avoid shadowing Go's built-in +// `delete()` in importing files. + +package del + +import ( + "errors" + "fmt" + "net/http" + + "github.com/datarobot/cli/internal/auth" + "github.com/datarobot/cli/internal/drapi" + "github.com/datarobot/cli/internal/pipeline" + "github.com/datarobot/cli/internal/telemetry" + "github.com/datarobot/cli/tui" + "github.com/spf13/cobra" +) + +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a pipeline execution environment", + Long: `Soft-delete the most recent active version of a pipeline +execution environment. If no active versions remain after the delete, +the parent environment is soft-deleted too. + +To delete a specific older version, use: + dr pipeline environment version delete --environment + +Example: + dr pipeline environment delete env-123`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + err := pipeline.DeleteEnvironment(args[0]) + if err != nil { + return handleDeleteError(err, args[0]) + } + + fmt.Println(tui.BaseTextStyle.Render("Deleted environment: " + args[0])) + + return nil + }, + } + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "environment_id": telemetry.FirstArg(args), + } + }) + + 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 environment found with id: " + id)) + + return nil + } + + return err +} diff --git a/cmd/pipeline/environment/del/cmd_test.go b/cmd/pipeline/environment/del/cmd_test.go new file mode 100644 index 000000000..0125db45b --- /dev/null +++ b/cmd/pipeline/environment/del/cmd_test.go @@ -0,0 +1,44 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package del + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RequiresPositionalArg(t *testing.T) { + err := runCmd(t) + require.Error(t, err) +} + +func TestCmd_Name(t *testing.T) { + assert.Equal(t, "delete", Cmd().Name()) +} diff --git a/cmd/pipeline/environment/list/cmd.go b/cmd/pipeline/environment/list/cmd.go new file mode 100644 index 000000000..38d35be84 --- /dev/null +++ b/cmd/pipeline/environment/list/cmd.go @@ -0,0 +1,69 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package list + +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 ( + offset int + limit int + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List pipeline execution environments", + Long: `List pipeline execution environments. + +Returns a tabular view of registered environments, newest first. Each +row reflects the latest version's status only; per-version details are +returned by ` + "`environment create`" + ` and ` + "`environment update`" + `. + +Example: + dr pipeline environment list + dr pipeline environment list --offset 50 --limit 10 --output-format json`, + Args: cobra.NoArgs, + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, _ []string) error { + items, err := pipeline.ListEnvironments(offset, limit) + if err != nil { + return err + } + + return pipeline.RenderEnvironments(outputFormat, items) + }, + } + + cmd.Flags().IntVar(&offset, "offset", 0, "Pagination offset") + cmd.Flags().IntVar(&limit, "limit", 100, "Maximum number of environments to return") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, _ []string) map[string]any { + return map[string]any{ + "offset": offset, + "limit": limit, + "output_format": string(outputFormat), + } + }) + + return cmd +} diff --git a/cmd/pipeline/environment/list/cmd_test.go b/cmd/pipeline/environment/list/cmd_test.go new file mode 100644 index 000000000..8bfe11528 --- /dev/null +++ b/cmd/pipeline/environment/list/cmd_test.go @@ -0,0 +1,49 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package list + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "--output-format", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"offset", "limit", "output-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/environment/update/cmd.go b/cmd/pipeline/environment/update/cmd.go new file mode 100644 index 000000000..289b2ed4e --- /dev/null +++ b/cmd/pipeline/environment/update/cmd.go @@ -0,0 +1,70 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package 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 ( + rawPackages []string + outputFormat pipeline.OutputFormat + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Add a new version to a pipeline execution environment", + Long: `Update a pipeline execution environment by appending packages. + +Updating creates a new immutable version of the environment containing +the supplied pip packages. Existing versions are unchanged. + +Example: + dr pipeline environment update env-123 --package scikit-learn + dr pipeline environment update env-123 --package "scikit-learn==1.5,torch" --output-format json`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + packages, err := pipeline.NormalizePackages(rawPackages) + if err != nil { + return err + } + + result, err := pipeline.UpdateEnvironment(args[0], packages) + if err != nil { + return err + } + + return pipeline.RenderEnvironment(outputFormat, *result) + }, + } + + cmd.Flags().StringSliceVar(&rawPackages, "package", nil, "Pip package spec (repeatable, also accepts comma-separated values)") + pipeline.AddOutputFlag(cmd, &outputFormat) + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "environment_id": telemetry.FirstArg(args), + "output_format": string(outputFormat), + } + }) + + return cmd +} diff --git a/cmd/pipeline/environment/update/cmd_test.go b/cmd/pipeline/environment/update/cmd_test.go new file mode 100644 index 000000000..df4d98890 --- /dev/null +++ b/cmd/pipeline/environment/update/cmd_test.go @@ -0,0 +1,60 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package update + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsInvalidOutput(t *testing.T) { + err := runCmd(t, "env-1", "--package", "numpy", "--output-format", "yaml") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} + +func TestCmd_RequiresPositionalArg(t *testing.T) { + err := runCmd(t, "--package", "numpy") + require.Error(t, err) +} + +func TestCmd_RejectsMissingPackages(t *testing.T) { + err := runCmd(t, "env-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "package") +} + +func TestCmd_HasExpectedFlags(t *testing.T) { + cmd := Cmd() + + for _, name := range []string{"package", "output-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/environment/version/cmd.go b/cmd/pipeline/environment/version/cmd.go new file mode 100644 index 000000000..590fcde49 --- /dev/null +++ b/cmd/pipeline/environment/version/cmd.go @@ -0,0 +1,34 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package version + +import ( + "github.com/datarobot/cli/cmd/pipeline/environment/version/del" + "github.com/spf13/cobra" +) + +// Cmd returns the parent command for `dr pipeline environment version`. +// Currently only delete is exposed; the pipelines-api does not surface +// per-version GET endpoints. +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "version", + Short: "Manage versions of a pipeline execution environment", + } + + cmd.AddCommand(del.Cmd()) + + return cmd +} diff --git a/cmd/pipeline/environment/version/cmd_test.go b/cmd/pipeline/environment/version/cmd_test.go new file mode 100644 index 000000000..695edee8b --- /dev/null +++ b/cmd/pipeline/environment/version/cmd_test.go @@ -0,0 +1,35 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package version + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCmd_RegistersDelete(t *testing.T) { + cmd := Cmd() + + found := false + + for _, sub := range cmd.Commands() { + if sub.Name() == "delete" { + found = true + } + } + + assert.True(t, found, "missing delete subcommand") +} diff --git a/cmd/pipeline/environment/version/del/cmd.go b/cmd/pipeline/environment/version/del/cmd.go new file mode 100644 index 000000000..b2e98686e --- /dev/null +++ b/cmd/pipeline/environment/version/del/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 del implements `dr pipeline environment version delete`. +// Directory is named `del` to avoid shadowing Go's built-in `delete()`. + +package del + +import ( + "errors" + "fmt" + "net/http" + "strconv" + + "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 environmentID string + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a specific version of a pipeline execution environment", + Long: `Soft-delete a specific version of a pipeline execution environment +without touching the parent environment. + +Example: + dr pipeline environment version delete --environment env-123 2`, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + version, err := strconv.Atoi(args[0]) + if err != nil || version <= 0 { + return fmt.Errorf("invalid version: %q (expected a positive integer)", args[0]) + } + + err = pipeline.DeleteEnvironmentVersion(environmentID, version) + if err != nil { + return handleDeleteError(err, environmentID, args[0]) + } + + fmt.Println(tui.BaseTextStyle.Render( + fmt.Sprintf("Deleted environment version: %s v%d", environmentID, version), + )) + + return nil + }, + } + + cmd.Flags().StringVar(&environmentID, "environment", "", "Environment ID (required)") + _ = cmd.MarkFlagRequired("environment") + + telemetry.TrackWith(cmd, func(_ *cobra.Command, args []string) map[string]any { + return map[string]any{ + "environment_id": environmentID, + "version": telemetry.FirstArg(args), + } + }) + + 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, environmentID, version string) error { + var httpErr *drapi.HTTPError + + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + fmt.Println(tui.DimStyle.Render( + fmt.Sprintf("No environment version found: %s v%s", environmentID, version), + )) + + return nil + } + + return err +} diff --git a/cmd/pipeline/environment/version/del/cmd_test.go b/cmd/pipeline/environment/version/del/cmd_test.go new file mode 100644 index 000000000..3c14c04e0 --- /dev/null +++ b/cmd/pipeline/environment/version/del/cmd_test.go @@ -0,0 +1,60 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package del + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runCmd(t *testing.T, args ...string) error { + t.Helper() + + cmd := Cmd() + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.PreRunE = nil + + return cmd.Execute() +} + +func TestCmd_RejectsMissingEnvironment(t *testing.T) { + err := runCmd(t, "1") + require.Error(t, err) + assert.Contains(t, err.Error(), "environment") +} + +func TestCmd_RejectsBadVersion(t *testing.T) { + err := runCmd(t, "--environment", "env-1", "abc") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid version") + + err = runCmd(t, "--environment", "env-1", "0") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid version") +} + +func TestCmd_RequiresPositionalArg(t *testing.T) { + err := runCmd(t, "--environment", "env-1") + require.Error(t, err) +} + +func TestCmd_HasEnvironmentFlag(t *testing.T) { + assert.NotNil(t, Cmd().Flags().Lookup("environment")) +} diff --git a/cmd/pipeline/schedule/cmd.go b/cmd/pipeline/schedule/cmd.go new file mode 100644 index 000000000..78308c4d3 --- /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 pipeline 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..9d190c4da --- /dev/null +++ b/cmd/pipeline/schedule/create/cmd.go @@ -0,0 +1,87 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package create + +import ( + "errors" + + "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 + version int + cron string + inputID string + timezone string + outputFormat pipeline.OutputFormat + ) + + 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 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 version <= 0 { + return errors.New("--version is required and must be > 0") + } + + body := pipeline.ScheduleCreateRequest{ + CronExpression: cron, + PipelineInputID: inputID, + Timezone: timezone, + } + + result, err := pipeline.CreateSchedule(pipelineID, version, body) + if err != nil { + return err + } + + return pipeline.RenderSchedule(outputFormat, *result) + }, + } + + 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)") + 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/create/cmd_test.go b/cmd/pipeline/schedule/create/cmd_test.go new file mode 100644 index 000000000..6eba56a4e --- /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-format", "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_RejectsMissingVersion(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-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 new file mode 100644 index 000000000..77cd034b7 --- /dev/null +++ b/cmd/pipeline/schedule/del/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 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. + +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" + "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 pipeline schedule delete --pipeline --version=2 `, + Args: cobra.ExactArgs(1), + PreRunE: auth.EnsureAuthenticatedE, + SilenceUsage: true, + RunE: func(_ *cobra.Command, args []string) error { + if version <= 0 { + return errors.New("--version is required and must be > 0") + } + + err := pipeline.DeleteSchedule(pipelineID, version, args[0]) + if err != nil { + return handleDeleteError(err, args[0]) + } + + fmt.Println(tui.BaseTextStyle.Render("Deleted schedule: " + args[0])) + + return nil + }, + } + + 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{ + "pipeline_id": pipelineID, + "schedule_id": telemetry.FirstArg(args), + "version": 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 schedule found with id: " + id)) + + return nil + } + + return err +} diff --git a/cmd/pipeline/schedule/del/cmd_test.go b/cmd/pipeline/schedule/del/cmd_test.go new file mode 100644 index 000000000..924933a7c --- /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_RejectsMissingVersion(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..764cf40e3 --- /dev/null +++ b/cmd/pipeline/schedule/get/cmd.go @@ -0,0 +1,90 @@ +// 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/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 ( + pipelineID string + version int + outputFormat pipeline.OutputFormat + ) + + 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 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 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]) + } + + return pipeline.RenderSchedule(outputFormat, *result) + }, + } + + 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 { + return map[string]any{ + "pipeline_id": pipelineID, + "schedule_id": telemetry.FirstArg(args), + "version": version, + "output_format": string(outputFormat), + } + }) + + 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..409c70fbd --- /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-format", "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_RejectsMissingVersion(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..b9c647d87 --- /dev/null +++ b/cmd/pipeline/schedule/list/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 list + +import ( + "errors" + + "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 + version int + offset int + limit int + outputFormat pipeline.OutputFormat + ) + + 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 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 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 + } + + return pipeline.RenderSchedules(outputFormat, items) + }, + } + + 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) + + 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/list/cmd_test.go b/cmd/pipeline/schedule/list/cmd_test.go new file mode 100644 index 000000000..460b9fcb2 --- /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-format", "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_RejectsMissingVersion(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-format"} { + assert.NotNilf(t, cmd.Flags().Lookup(name), "expected --%s flag", name) + } +} diff --git a/cmd/pipeline/schedule/update/cmd.go b/cmd/pipeline/schedule/update/cmd.go new file mode 100644 index 000000000..41e56ed43 --- /dev/null +++ b/cmd/pipeline/schedule/update/cmd.go @@ -0,0 +1,119 @@ +// 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" + + "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 + version int + cron string + timezone string + outputFormat pipeline.OutputFormat + ) + + 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 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) + if err != nil { + return err + } + + result, err := pipeline.UpdateSchedule(pipelineID, version, args[0], body) + if err != nil { + return err + } + + return pipeline.RenderSchedule(outputFormat, *result) + }, + } + + 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) + + 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 +} + +// 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, _ string, version int, cron, timezone string) (pipeline.ScheduleUpdateRequest, error) { + 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") + } + + if cronChanged && cron == "" { + 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 { + 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..d73ba6656 --- /dev/null +++ b/cmd/pipeline/schedule/update/cmd_test.go @@ -0,0 +1,120 @@ +// 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_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_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) + 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_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 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 + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid output format") +} diff --git a/cmd/plugin/discovery.go b/cmd/plugin/discovery.go index 501942f2c..4f319b5dc 100644 --- a/cmd/plugin/discovery.go +++ b/cmd/plugin/discovery.go @@ -206,11 +206,18 @@ func askYesNo() bool { // performPluginUpdate runs the backup → install → validate cycle for a plugin update. func performPluginUpdate(result *internalPlugin.UpdateCheckResult) { - shared.RunPluginUpdate( + err := shared.RunPluginUpdate( result.PluginName, result.InstalledVersion, result.RegistryPlugin, *result.LatestVersion, result.BaseURL, ) + if err != nil { + fmt.Println(tui.ErrorStyle.Render(fmt.Sprintf("✗ Failed to update %s: %v", result.PluginName, err))) + + return + } + + fmt.Println(tui.SuccessStyle.Render("✓ Updated " + result.PluginName + " to " + result.LatestVersion.Version)) } diff --git a/cmd/plugin/install/cmd.go b/cmd/plugin/install/cmd.go index d820aa59d..991743989 100644 --- a/cmd/plugin/install/cmd.go +++ b/cmd/plugin/install/cmd.go @@ -18,7 +18,6 @@ import ( "fmt" "github.com/datarobot/cli/cmd/plugin/shared" - "github.com/datarobot/cli/internal/config/viperx" "github.com/datarobot/cli/internal/plugin" "github.com/datarobot/cli/internal/telemetry" "github.com/datarobot/cli/tui" @@ -76,11 +75,22 @@ Use --version to specify a version constraint: func runInstall(_ *cobra.Command, args []string) error { finalRegistryURL := shared.NormalizeRegistryURL(registryURL) - if viperx.GetBool("verbose") { - fmt.Printf("Fetching plugin registry from %s...\n", finalRegistryURL) - } - registry, baseURL, err := plugin.FetchRegistry(finalRegistryURL) + var ( + registry *plugin.PluginRegistry + baseURL string + ) + + err := tui.RunWithSpinner( + fmt.Sprintf("Fetching plugin registry from %s…", finalRegistryURL), + func() error { + var fetchErr error + + registry, baseURL, fetchErr = plugin.FetchRegistry(finalRegistryURL) + + return fetchErr + }, + ) if err != nil { return fmt.Errorf("failed to fetch plugin registry: %w", err) } @@ -129,10 +139,10 @@ func runInstall(_ *cobra.Command, args []string) error { return fmt.Errorf("failed to resolve version: %w", err) } - fmt.Printf("Installing %s version %s...\n", pluginEntry.Name, version.Version) - fmt.Printf("Downloading from: %s/%s\n", baseURL, version.URL) - - if err := plugin.InstallPlugin(pluginEntry, *version, baseURL); err != nil { + if err := tui.RunWithSpinner( + fmt.Sprintf("Installing %s %s…", pluginEntry.Name, version.Version), + func() error { return plugin.InstallPlugin(pluginEntry, *version, baseURL) }, + ); err != nil { return fmt.Errorf("failed to install plugin: %w", err) } diff --git a/cmd/plugin/shared/helpers.go b/cmd/plugin/shared/helpers.go index 78a91ba26..e374fb4f5 100644 --- a/cmd/plugin/shared/helpers.go +++ b/cmd/plugin/shared/helpers.go @@ -15,10 +15,10 @@ package shared import ( + "errors" "fmt" "github.com/datarobot/cli/internal/plugin" - "github.com/datarobot/cli/tui" ) // NormalizeRegistryURL ensures the URL ends with index.json @@ -35,45 +35,48 @@ func NormalizeRegistryURL(url string) string { } // RunPluginUpdate performs the backup → install → validate → rollback cycle -// for upgrading a managed plugin. It prints styled status messages and returns -// true only when the update succeeds and validation passes. -func RunPluginUpdate(pluginName, fromVersion string, entry plugin.RegistryPlugin, version plugin.RegistryVersion, baseURL string) bool { - fmt.Printf("Updating %s from %s to %s...\n", pluginName, fromVersion, version.Version) - +// for upgrading a managed plugin. It returns nil only when the update succeeds +// and validation passes. +func RunPluginUpdate(pluginName, _ string, entry plugin.RegistryPlugin, version plugin.RegistryVersion, baseURL string) error { backupPath, err := plugin.BackupPlugin(pluginName) if err != nil { - fmt.Println(tui.ErrorStyle.Render(fmt.Sprintf("✗ Failed to backup %s: %v", pluginName, err))) - - return false + return fmt.Errorf("backup %s: %w", pluginName, err) } + defer plugin.CleanupBackup(backupPath) if err := plugin.InstallPlugin(entry, version, baseURL); err != nil { - fmt.Println(tui.ErrorStyle.Render(fmt.Sprintf("✗ Failed to update %s: %v", pluginName, err))) - rollbackPlugin(pluginName, backupPath) - - return false + rollbackErr := rollbackPlugin(pluginName, backupPath) + if rollbackErr != nil { + return errors.Join( + fmt.Errorf("install %s: %w", pluginName, err), + rollbackErr, + ) + } + + return fmt.Errorf("install %s: %w", pluginName, err) } if err := plugin.ValidatePlugin(pluginName); err != nil { - fmt.Println(tui.ErrorStyle.Render(fmt.Sprintf("✗ Plugin validation failed: %v", err))) - rollbackPlugin(pluginName, backupPath) - - return false + rollbackErr := rollbackPlugin(pluginName, backupPath) + if rollbackErr != nil { + return errors.Join( + fmt.Errorf("validate %s: %w", pluginName, err), + rollbackErr, + ) + } + + return fmt.Errorf("validate %s: %w", pluginName, err) } - fmt.Println(tui.SuccessStyle.Render("✓ Updated " + pluginName + " to " + version.Version)) - - return true + return nil } -// rollbackPlugin attempts to restore a plugin from its backup, printing the outcome. -func rollbackPlugin(pluginName, backupPath string) { - fmt.Println("Rolling back to previous version...") - +// rollbackPlugin attempts to restore a plugin from its backup. +func rollbackPlugin(pluginName, backupPath string) error { if restoreErr := plugin.RestorePlugin(pluginName, backupPath); restoreErr != nil { - fmt.Println(tui.ErrorStyle.Render(fmt.Sprintf("✗ Failed to restore backup: %v", restoreErr))) - } else { - fmt.Println(tui.SuccessStyle.Render("✓ Restored previous version")) + return fmt.Errorf("restore backup for %s: %w", pluginName, restoreErr) } + + return nil } diff --git a/cmd/plugin/update/cmd.go b/cmd/plugin/update/cmd.go index 3e0d1a4fc..4ab0b65e5 100644 --- a/cmd/plugin/update/cmd.go +++ b/cmd/plugin/update/cmd.go @@ -22,6 +22,7 @@ import ( "github.com/datarobot/cli/internal/plugin" "github.com/datarobot/cli/internal/state" "github.com/datarobot/cli/internal/telemetry" + "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -74,9 +75,21 @@ func runUpdate(_ *cobra.Command, args []string) error { finalRegistryURL := shared.NormalizeRegistryURL(registryURL) - fmt.Printf("Fetching plugin registry from %s...\n", finalRegistryURL) + var ( + registry *plugin.PluginRegistry + baseURL string + ) - registry, baseURL, err := plugin.FetchRegistry(finalRegistryURL) + err = tui.RunWithSpinner( + fmt.Sprintf("Fetching plugin registry from %s…", finalRegistryURL), + func() error { + var fetchErr error + + registry, baseURL, fetchErr = plugin.FetchRegistry(finalRegistryURL) + + return fetchErr + }, + ) if err != nil { return fmt.Errorf("failed to fetch plugin registry: %w", err) } @@ -153,10 +166,19 @@ func updateSinglePlugin(p plugin.InstalledPlugin, registry *plugin.PluginRegistr return false } - if !shared.RunPluginUpdate(p.Name, p.Version, pluginEntry, *latestVersion, baseURL) { + if err := tui.RunWithSpinner( + fmt.Sprintf("Updating %s from %s to %s…", p.Name, p.Version, latestVersion.Version), + func() error { + return shared.RunPluginUpdate(p.Name, p.Version, pluginEntry, *latestVersion, baseURL) + }, + ); err != nil { + fmt.Println(tui.ErrorStyle.Render(fmt.Sprintf("✗ Failed to update %s: %v", p.Name, err))) + return false } + fmt.Println(tui.SuccessStyle.Render("✓ Updated " + p.Name + " to " + latestVersion.Version)) + fmt.Println() return true diff --git a/cmd/workload/artifact/create/cmd.go b/cmd/workload/artifact/create/cmd.go index 32a5e3c43..a2dbe9393 100644 --- a/cmd/workload/artifact/create/cmd.go +++ b/cmd/workload/artifact/create/cmd.go @@ -23,6 +23,7 @@ import ( "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/telemetry" "github.com/datarobot/cli/internal/workload" + "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -100,8 +101,15 @@ Example: return err } - artifact, err := workload.CreateArtifact(payload) - if err != nil { + var artifact *workload.Artifact + + if err := tui.RunWithSpinner("Creating artifact…", func() error { + var createErr error + + artifact, createErr = workload.CreateArtifact(payload) + + return createErr + }); err != nil { return err } diff --git a/cmd/workload/artifact/get/cmd.go b/cmd/workload/artifact/get/cmd.go index c579be8a1..7911424a3 100644 --- a/cmd/workload/artifact/get/cmd.go +++ b/cmd/workload/artifact/get/cmd.go @@ -18,6 +18,7 @@ import ( "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/telemetry" "github.com/datarobot/cli/internal/workload" + "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -42,8 +43,15 @@ Example: Args: cobra.ExactArgs(1), PreRunE: auth.EnsureAuthenticatedE, RunE: func(_ *cobra.Command, args []string) error { - artifact, err := workload.GetArtifact(args[0]) - if err != nil { + var artifact *workload.Artifact + + if err := tui.RunWithSpinner("Fetching artifact…", func() error { + var getErr error + + artifact, getErr = workload.GetArtifact(args[0]) + + return getErr + }); err != nil { return err } diff --git a/cmd/workload/artifact/list/cmd.go b/cmd/workload/artifact/list/cmd.go index b6e7bc4cd..5d886ea93 100644 --- a/cmd/workload/artifact/list/cmd.go +++ b/cmd/workload/artifact/list/cmd.go @@ -20,6 +20,7 @@ import ( "github.com/datarobot/cli/internal/auth" "github.com/datarobot/cli/internal/telemetry" "github.com/datarobot/cli/internal/workload" + "github.com/datarobot/cli/tui" "github.com/spf13/cobra" ) @@ -54,8 +55,15 @@ Example: return fmt.Errorf("invalid --limit %d: must be positive", limit) } - artifacts, err := workload.ListArtifacts(limit, status) - if err != nil { + var artifacts []workload.Artifact + + if err := tui.RunWithSpinner("Fetching artifacts…", func() error { + var listErr error + + artifacts, listErr = workload.ListArtifacts(limit, status) + + return listErr + }); err != nil { return err } diff --git a/cmd/workload/code/checkout/checkout.go b/cmd/workload/code/checkout/checkout.go index be1d6d5f9..258fd4796 100644 --- a/cmd/workload/code/checkout/checkout.go +++ b/cmd/workload/code/checkout/checkout.go @@ -32,6 +32,7 @@ import ( "github.com/datarobot/cli/internal/workload/fileops" "github.com/datarobot/cli/internal/workload/sync" "github.com/datarobot/cli/internal/workload/wapi" + "github.com/datarobot/cli/tui" ) const ( @@ -76,7 +77,10 @@ func runDownload(out io.Writer, format workload.OutputFormat, dir, verArg string return err } - if err := stageAndInstall(deps.Files, pre, parent, files, totalSize, startedAt); err != nil { + if err := tui.RunWithSpinner( + fmt.Sprintf("Downloading %d file(s)…", len(files)), + func() error { return stageAndInstall(deps.Files, pre, parent, files, totalSize, startedAt) }, + ); err != nil { return err } diff --git a/docs/commands/README.md b/docs/commands/README.md index f2e7be109..9d6367610 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -92,12 +92,25 @@ 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 +│ └── environment Manage named, versioned pip-package environments +│ ├── create Register a new environment with an initial version +│ ├── list List registered environments +│ ├── update Append a new version to an environment +│ ├── delete Soft-delete the latest active version of an environment +│ └── version Manage environment versions +│ └── delete Delete a specific version └── self CLI utility commands ├── completion Shell completion │ ├── install Install completions interactively @@ -270,6 +283,8 @@ 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. + - `environment`—`create`/`list`/`update`/`delete` named pip-package environments; `version delete` removes a specific version. ## Getting help diff --git a/docs/commands/pipeline.md b/docs/commands/pipeline.md index 332bda465..ddb20d09f 100644 --- a/docs/commands/pipeline.md +++ b/docs/commands/pipeline.md @@ -80,6 +80,8 @@ 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. | +| `dr pipeline environment …` | `/pipelines/environments[/{id}]` | Manage named, versioned pip-package environments. | ## Subcommands @@ -317,6 +319,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. @@ -335,6 +352,25 @@ the run ID, status, and Covalent dispatch ID. `run cancel` returns `409 Conflict` if the run is already terminal. +### `environment` + +Manage pipeline execution environments — named, immutable-versioned bags of pip packages +that pipelines can be built against. Each `update` appends a new version; individual +older versions can be removed with `environment version delete`. + +```bash +dr pipeline environment create --name --package [--package …] [--description ] [--output-format json] +dr pipeline environment list [--offset N] [--limit N] [--output-format json] +dr pipeline environment update --package [--package …] [--output-format json] +dr pipeline environment delete +dr pipeline environment version delete --environment +``` + +`environment create` registers a new environment; `environment update` appends a new +immutable version. `environment delete` soft-deletes the latest active version (cascading +to the parent if no active versions remain). `environment version delete` targets a +specific version by its integer number. + ## Error handling | Status | Cause | diff --git a/docs/commands/pipelines-reference.md b/docs/commands/pipelines-reference.md index 5d55b9520..d6e34a04b 100644 --- a/docs/commands/pipelines-reference.md +++ b/docs/commands/pipelines-reference.md @@ -117,6 +117,32 @@ 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-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). | + +--- + +## Environments (`dr pipeline environment …`) + +| Command | API endpoint | Usage | Inputs | +|---|---|---|---| +| `dr pipeline environment create` | `POST /pipelines/environments` | `dr pipeline environment create --name ml-base --package numpy --package pandas`
`dr pipeline environment create --name ml-base --package numpy,pandas==2.0 --description "base" --output-format json` | **Flags:** `--name ` (required), `--package ` (repeatable / comma-separated), `--description `, `--output-format json`. | +| `dr pipeline environment list` | `GET /pipelines/environments` | `dr pipeline environment list`
`dr pipeline environment list --offset 50 --limit 10 --output-format json` | **Flags:** `--offset `, `--limit `, `--output-format json`. | +| `dr pipeline environment update` | `PATCH /pipelines/environments/{id}` | `dr pipeline environment update --package scikit-learn` | **Positional:** `` (required). **Flags:** `--package ` (repeatable / comma-separated), `--output-format json`. | +| `dr pipeline environment delete` | `DELETE /pipelines/environments/{id}` | `dr pipeline environment delete ` | **Positional:** `` (required). | +| `dr pipeline environment version delete` | `DELETE /pipelines/environments/{id}/versions/{n}` | `dr pipeline environment version delete --environment ` | **Positional:** `` (integer, required). **Flags:** `--environment ` (required). | + +--- + ## Quick endpoint lookup | API endpoint | CLI command | @@ -144,3 +170,13 @@ 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` | +| `POST /pipelines/environments` | `dr pipeline environment create` | +| `GET /pipelines/environments` | `dr pipeline environment list` | +| `PATCH /pipelines/environments/{id}` | `dr pipeline environment update` | +| `DELETE /pipelines/environments/{id}` | `dr pipeline environment delete` | +| `DELETE /pipelines/environments/{id}/versions/{n}` | `dr pipeline environment version delete` | diff --git a/docs/development/README.md b/docs/development/README.md index f976bb3e9..90f594e04 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -64,7 +64,7 @@ See [Building](building.md) for detailed workflow documentation. Before you begin development: -- **Go 1.26.3 or later**—required for building the CLI. +- **Go 1.26.4 or later**—required for building the CLI. - **Git**—version control. - **Task**—task runner for development commands. - **golangci-lint**—installed automatically via `task dev-init`. diff --git a/docs/development/building.md b/docs/development/building.md index d1678c9e8..6ec441623 100644 --- a/docs/development/building.md +++ b/docs/development/building.md @@ -18,7 +18,7 @@ This guide outlines how to build, test, and develop with the DataRobot CLI. ### Prerequisites -- [Go 1.26.3+](https://golang.org/dl/) +- [Go 1.26.4+](https://golang.org/dl/) - Git version control - [Task](https://taskfile.dev/installation/) (The task runner) diff --git a/docs/development/setup.md b/docs/development/setup.md index c4d799211..735d7f091 100644 --- a/docs/development/setup.md +++ b/docs/development/setup.md @@ -6,7 +6,7 @@ This page outlines how to set up your development environment to build and devel ## Prerequisites -- [Go 1.26.3](https://golang.org/dl/) +- [Go 1.26.4](https://golang.org/dl/) - Git for version control - [Task](https://taskfile.dev/installation/) (A task runner) diff --git a/docs/development/telemetry.md b/docs/development/telemetry.md index b76ebe43f..a03f3220e 100644 --- a/docs/development/telemetry.md +++ b/docs/development/telemetry.md @@ -98,7 +98,7 @@ These map to Amplitude's built-in fields and power native segmentation (version | -------------------- | ------------------------------------------------------------------------- | | `install_method` | Set at build time via ldflags (`release`, `source`, etc.) | | `os_arch` | CPU architecture from `runtime.GOARCH` | -| `go_version` | Go runtime version (e.g. `go1.26.3`) from `runtime.Version()` | +| `go_version` | Go runtime version (e.g. `go1.26.4`) from `runtime.Version()` | | `environment` | `US`, `EU`, `JP`, or `custom` — derived from endpoint URL | | `datarobot_instance` | Base URL of the configured DataRobot instance | | `template_name` | Best-effort from `.datarobot/answers/` in the current repo | diff --git a/docs/plugins/assist/assist-0.1.25.tar.xz b/docs/plugins/assist/assist-0.1.25.tar.xz new file mode 100644 index 000000000..c6730bfa5 Binary files /dev/null and b/docs/plugins/assist/assist-0.1.25.tar.xz differ diff --git a/docs/plugins/assist/assist-0.1.26.tar.xz b/docs/plugins/assist/assist-0.1.26.tar.xz new file mode 100644 index 000000000..7ac5eabe7 Binary files /dev/null and b/docs/plugins/assist/assist-0.1.26.tar.xz differ diff --git a/docs/plugins/index.json b/docs/plugins/index.json index 733b13196..580210936 100644 --- a/docs/plugins/index.json +++ b/docs/plugins/index.json @@ -5,6 +5,18 @@ "name": "assist", "description": "AI agent design, coding, and deployment assistant", "versions": [ + { + "version": "0.1.26", + "url": "assist/assist-0.1.26.tar.xz", + "sha256": "5cd52a0e8c2fb3fd9e3136e3adc5c52b5688d5dbdd71b908421fc77aeee1d68d", + "releaseDate": "2026-06-05" + }, + { + "version": "0.1.25", + "url": "assist/assist-0.1.25.tar.xz", + "sha256": "285c9f211fa7a49d0b65daebbfb6a788578c056528d8063cbfbad62479d3d3f1", + "releaseDate": "2026-06-04" + }, { "version": "0.1.24", "url": "assist/assist-0.1.24.tar.xz", diff --git a/go.mod b/go.mod index e6cecfcf3..fee254058 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/datarobot/cli -go 1.26.3 +go 1.26.4 require ( github.com/Masterminds/semver/v3 v3.5.0 diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 57a610113..622726f17 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -37,6 +37,20 @@ import ( // This can be overridden in tests to mock the browser-based authentication flow. var APIKeyCallbackFunc = WaitForAPIKeyCallback +// AuthCallbackURL returns the DataRobot URL the user must visit to authorize the CLI. +func AuthCallbackURL(datarobotHost string) string { + return datarobotHost + "/account/developer-tools?cliRedirect=true" +} + +// PrintAuthInstructions prints the message instructing the user to visit the +// given auth URL. It must be called before any TUI/bubbletea program starts +// rendering, otherwise the output will be garbled or hidden. +func PrintAuthInstructions(authURL string) { + fmt.Println("\n\nPlease visit this link to connect your DataRobot credentials to the CLI") + fmt.Println("(If you're prompted to log in, you may need to re-enter this URL):") + fmt.Printf("%s\n\n", authURL) +} + // ErrEnvCredentialsNotSet is returned when environment credentials are not fully configured. var ErrEnvCredentialsNotSet = errors.New("environment credentials not set") @@ -165,6 +179,8 @@ func EnsureAuthenticated(ctx context.Context) bool { //nolint: cyclop // Auto-retrieve new credentials without prompting viperx.Set(config.DataRobotAPIKey, "") + PrintAuthInstructions(AuthCallbackURL(datarobotHost)) + key, err := APIKeyCallbackFunc(ctx, datarobotHost) if err != nil { log.Error("Failed to retrieve API key.", "error", err) @@ -218,13 +234,9 @@ func WaitForAPIKeyCallback(ctx context.Context, datarobotHost string) (string, e } // Start the server in a goroutine - go func() { - authURL := datarobotHost + "/account/developer-tools?cliRedirect=true" - - fmt.Println("\n\nPlease visit this link to connect your DataRobot credentials to the CLI") - fmt.Println("(If you're prompted to log in, you may need to re-enter this URL):") - fmt.Printf("%s\n\n", authURL) + authURL := AuthCallbackURL(datarobotHost) + go func() { open.Open(authURL) err := server.Serve(listen) @@ -246,11 +258,11 @@ func WaitForAPIKeyCallback(ctx context.Context, datarobotHost string) (string, e return "", errors.New("Interrupt request received.") } - fmt.Println("Successfully consumed API key from API request") + log.Debug("Successfully consumed API key from API request") return apiKey, nil case <-ctx.Done(): - fmt.Println("\nCtrl-C received, exiting...") + log.Debug("Ctrl-C received, exiting auth wait") return "", errors.New("Interrupt request received.") } } diff --git a/internal/misc/reader/reader.go b/internal/misc/reader/reader.go index a08b06c81..a81c30469 100644 --- a/internal/misc/reader/reader.go +++ b/internal/misc/reader/reader.go @@ -79,3 +79,20 @@ func AskYesNo() bool { func IsStdinTerminal() bool { return term.IsTerminal(int(os.Stdin.Fd())) } + +// NonInteractiveEnv is the env var users set to force non-interactive mode +// (e.g. Agent Assist). It is also bound to the viper "yes" key in commands +// that support a --yes flag. +const NonInteractiveEnv = "DATAROBOT_CLI_NON_INTERACTIVE" + +// IsNonInteractive reports whether DATAROBOT_CLI_NON_INTERACTIVE is set to a +// truthy value. Callers should use this to skip animations, prompts, and other +// interactive UI when running under automation. +func IsNonInteractive() bool { + switch os.Getenv(NonInteractiveEnv) { + case "1", "t", "T", "true", "TRUE", "True", "y", "Y", "yes", "YES", "Yes": + return true + } + + return false +} diff --git a/internal/misc/reader/reader_test.go b/internal/misc/reader/reader_test.go new file mode 100644 index 000000000..f852303a6 --- /dev/null +++ b/internal/misc/reader/reader_test.go @@ -0,0 +1,42 @@ +// 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 reader + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsNonInteractive(t *testing.T) { + cases := map[string]bool{ + "": false, + "true": true, + "TRUE": true, + "True": true, + "1": true, + "yes": true, + "y": true, + "false": false, + "0": false, + "no": false, + "foo": false, + } + + for value, want := range cases { + t.Setenv(NonInteractiveEnv, value) + assert.Equalf(t, want, IsNonInteractive(), "value=%q", value) + } +} diff --git a/internal/pipeline/environment.go b/internal/pipeline/environment.go new file mode 100644 index 000000000..cfba431d1 --- /dev/null +++ b/internal/pipeline/environment.go @@ -0,0 +1,197 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// environment.go contains the typed client wrappers for the pipeline +// execution-environment endpoints described under the +// `pipeline-execution-environments` tag of the pipelines-api OpenAPI spec. +// +// Environments are named, immutable-versioned bags of pip packages that +// pipelines can be built against. They live at the top of the pipelines +// namespace (not nested under a specific pipeline) and have their own +// lifecycle: +// +// POST /api/v2/pipelines/environments +// GET /api/v2/pipelines/environments +// PATCH /api/v2/pipelines/environments/{id} (adds packages -> new version) +// DELETE /api/v2/pipelines/environments/{id} (soft-deletes latest version, cascades parent) +// DELETE /api/v2/pipelines/environments/{id}/versions/{n} (soft-deletes a specific version) + +package pipeline + +import ( + "net/http" + "net/url" + "strconv" + "time" + + "github.com/datarobot/cli/internal/config" +) + +// EnvironmentStatus mirrors PipelineEnvironmentStatus in the API. +type EnvironmentStatus string + +const ( + EnvironmentStatusCreating EnvironmentStatus = "CREATING" + EnvironmentStatusReady EnvironmentStatus = "READY" + EnvironmentStatusError EnvironmentStatus = "ERROR" +) + +// EnvironmentVersion mirrors PipelineEnvironmentVersionResponse. +type EnvironmentVersion struct { + Version int `json:"version"` + Packages []string `json:"packages"` + Status EnvironmentStatus `json:"status"` + ErrorDetail *string `json:"errorDetail,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// Environment mirrors PipelineEnvironmentResponse (full detail). +type Environment struct { + EnvironmentID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + LatestVersion int `json:"latestVersion"` + Versions []EnvironmentVersion `json:"versions"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// EnvironmentSummary mirrors PipelineEnvironmentSummaryResponse (list item). +type EnvironmentSummary struct { + EnvironmentID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + LatestVersion int `json:"latestVersion"` + LatestStatus EnvironmentStatus `json:"latestStatus"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// EnvironmentCreateRequest mirrors PipelineEnvironmentCreateRequest. +type EnvironmentCreateRequest struct { + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Packages []string `json:"packages"` +} + +// EnvironmentUpdateRequest mirrors PipelineEnvironmentUpdateRequest. +type EnvironmentUpdateRequest struct { + Packages []string `json:"packages"` +} + +// CreateEnvironment POSTs a new environment with an initial set of pip +// packages. The API returns 201 with the full Environment payload (a +// single CREATING version is returned immediately; READY status is +// reached asynchronously by the covalent build). +func CreateEnvironment(name, description string, packages []string) (*Environment, error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/environments") + if err != nil { + return nil, err + } + + body := EnvironmentCreateRequest{ + Name: name, + Packages: packages, + } + if description != "" { + body.Description = &description + } + + var result Environment + + err = doJSON(http.MethodPost, endpoint, body, "create environment", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// ListEnvironments returns a paginated slice of environments. The API +// returns a DataPage envelope; results are newest first. +func ListEnvironments(offset, limit int) ([]EnvironmentSummary, error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/environments") + if err != nil { + return nil, err + } + + query := url.Values{} + if offset > 0 { + query.Set("offset", strconv.Itoa(offset)) + } + + if limit > 0 { + query.Set("limit", strconv.Itoa(limit)) + } + + if encoded := query.Encode(); encoded != "" { + endpoint = endpoint + "?" + encoded + } + + var page DataPage[EnvironmentSummary] + + err = doJSON(http.MethodGet, endpoint, nil, "environments", &page) + if err != nil { + return nil, err + } + + return page.Data, nil +} + +// UpdateEnvironment PATCHes an environment with additional packages, +// creating a new immutable version. The response includes the full +// Environment with all versions ordered newest-first. +func UpdateEnvironment(envID string, packages []string) (*Environment, error) { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/environments/" + envID) + if err != nil { + return nil, err + } + + body := EnvironmentUpdateRequest{Packages: packages} + + var result Environment + + err = doJSON(http.MethodPatch, endpoint, body, "update environment", &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +// DeleteEnvironment soft-deletes the most-recent active version of an +// environment. If no active versions remain, the parent environment is +// soft-deleted as well. +func DeleteEnvironment(envID string) error { + endpoint, err := config.GetEndpointURL("/api/v2/pipelines/environments/" + envID) + if err != nil { + return err + } + + return doDelete(endpoint, "delete environment") +} + +// DeleteEnvironmentVersion soft-deletes a specific version of an +// environment without touching the parent. +func DeleteEnvironmentVersion(envID string, version int) error { + endpoint, err := config.GetEndpointURL( + "/api/v2/pipelines/environments/" + envID + "/versions/" + strconv.Itoa(version), + ) + if err != nil { + return err + } + + return doDelete(endpoint, "delete environment version") +} diff --git a/internal/pipeline/environment_output.go b/internal/pipeline/environment_output.go new file mode 100644 index 000000000..3700498b1 --- /dev/null +++ b/internal/pipeline/environment_output.go @@ -0,0 +1,298 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// environment_output.go centralises the human/JSON output rendering used by +// the `dr pipelines environment` verbs so each verb file stays focused on +// flag wiring. +package pipeline + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "slices" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/datarobot/cli/tui" +) + +// environmentVersionJSON is the DTO for a single EnvironmentVersion in JSON output. +type environmentVersionJSON struct { + Version int `json:"version"` + Packages []string `json:"packages"` + Status string `json:"status"` + ErrorDetail *string `json:"error_detail,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// environmentJSON is the CLI-facing DTO for `--output-format json` of an Environment. +type environmentJSON struct { + EnvironmentID string `json:"environment_id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + LatestVersion int `json:"latest_version"` + Versions []environmentVersionJSON `json:"versions"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// environmentSummaryJSON is the CLI-facing DTO for `--output-format json` of an EnvironmentSummary. +type environmentSummaryJSON struct { + EnvironmentID string `json:"environment_id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + LatestVersion int `json:"latest_version"` + LatestStatus string `json:"latest_status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toEnvironmentJSON(env Environment) environmentJSON { + versions := make([]environmentVersionJSON, len(env.Versions)) + + for i, v := range env.Versions { + versions[i] = environmentVersionJSON{ + Version: v.Version, + Packages: v.Packages, + Status: string(v.Status), + ErrorDetail: v.ErrorDetail, + CreatedAt: v.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: v.UpdatedAt.UTC().Format(time.RFC3339), + } + } + + return environmentJSON{ + EnvironmentID: env.EnvironmentID, + Name: env.Name, + Description: env.Description, + LatestVersion: env.LatestVersion, + Versions: versions, + CreatedAt: env.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: env.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +func toEnvironmentSummaryJSON(env EnvironmentSummary) environmentSummaryJSON { + return environmentSummaryJSON{ + EnvironmentID: env.EnvironmentID, + Name: env.Name, + Description: env.Description, + LatestVersion: env.LatestVersion, + LatestStatus: string(env.LatestStatus), + CreatedAt: env.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: env.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +// RenderEnvironment routes a single environment to JSON or human output. +func RenderEnvironment(format OutputFormat, env Environment) error { + if format == OutputFormatJSON { + return PrintEnvironmentJSON(env) + } + + PrintEnvironmentHuman(env) + + return nil +} + +// RenderEnvironments routes a list of environments to JSON or human output. +func RenderEnvironments(format OutputFormat, items []EnvironmentSummary) error { + if format == OutputFormatJSON { + return PrintEnvironmentListJSON(items) + } + + PrintEnvironmentListHuman(items) + + return nil +} + +// PrintEnvironmentJSON marshals an environment record as indented JSON through the DTO. +func PrintEnvironmentJSON(env Environment) error { + data, err := json.MarshalIndent(toEnvironmentJSON(env), "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintEnvironmentHuman renders the key facts about a single environment +// record, including its full version history. +func PrintEnvironmentHuman(env Environment) { + desc := emptyValuePlaceholder + if env.Description != nil && *env.Description != "" { + desc = *env.Description + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Environment ID:\t%s\n", env.EnvironmentID) + fmt.Fprintf(w, "Name:\t%s\n", env.Name) + fmt.Fprintf(w, "Description:\t%s\n", desc) + fmt.Fprintf(w, "Latest version:\tv%s\n", strconv.Itoa(env.LatestVersion)) + fmt.Fprintf(w, "Created:\t%s\n", env.CreatedAt.UTC().Format(timestampFormat)) + fmt.Fprintf(w, "Updated:\t%s\n", env.UpdatedAt.UTC().Format(timestampFormat)) + + w.Flush() + + if len(env.Versions) == 0 { + return + } + + fmt.Println() + fmt.Println(tui.BaseTextStyle.Render("Versions:")) + + cellStyle := tui.BaseTextStyle.Padding(0, 1) + + dimStyle := tui.DimStyle.Padding(0, 1) + + headers := []string{"VERSION", "STATUS", "PACKAGES", "UPDATED"} + + updatedCol := slices.Index(headers, "UPDATED") + + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(tui.TableBorderStyle). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return cellStyle.Bold(true) + } + + if col == updatedCol { + return dimStyle + } + + return cellStyle + }). + Headers(headers...) + + for _, ver := range env.Versions { + t.Row( + fmt.Sprintf("v%d", ver.Version), + string(ver.Status), + joinPackages(ver.Packages), + ver.UpdatedAt.UTC().Format(timestampFormat), + ) + } + + fmt.Fprintln(os.Stdout, t.Render()) +} + +// PrintEnvironmentListJSON marshals a list of environments as indented JSON through the DTO. +func PrintEnvironmentListJSON(items []EnvironmentSummary) error { + view := make([]environmentSummaryJSON, len(items)) + + for i, env := range items { + view[i] = toEnvironmentSummaryJSON(env) + } + + data, err := json.MarshalIndent(view, "", " ") + if err != nil { + return err + } + + fmt.Println(string(data)) + + return nil +} + +// PrintEnvironmentListHuman renders a lipgloss table summary of environments. +func PrintEnvironmentListHuman(items []EnvironmentSummary) { + if len(items) == 0 { + fmt.Println(tui.DimStyle.Render("No environments found")) + + return + } + + cellStyle := tui.BaseTextStyle.Padding(0, 1) + + dimStyle := tui.DimStyle.Padding(0, 1) + + headers := []string{"ENVIRONMENT ID", "NAME", "LATEST", "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 _, env := range items { + t.Row( + env.EnvironmentID, + env.Name, + fmt.Sprintf("v%d", env.LatestVersion), + string(env.LatestStatus), + env.UpdatedAt.UTC().Format(timestampFormat), + ) + } + + fmt.Fprintln(os.Stdout, t.Render()) +} + +// joinPackages collapses a package slice into a single comma-separated +// string for tabular display, truncating at a reasonable width so the +// table stays readable in a typical terminal. +func joinPackages(packages []string) string { + const maxLen = 60 + + joined := strings.Join(packages, ",") + if len(joined) <= maxLen { + return joined + } + + return joined[:maxLen-3] + "..." +} + +// NormalizePackages takes the raw slice from a cobra StringSliceVar and +// returns a cleaned list. It returns an error when the resulting list is +// empty so callers can surface a friendly validation message. +func NormalizePackages(raw []string) ([]string, error) { + out := make([]string, 0, len(raw)) + + for _, entry := range raw { + for _, item := range strings.Split(entry, ",") { + trimmed := strings.TrimSpace(item) + if trimmed != "" { + out = append(out, trimmed) + } + } + } + + if len(out) == 0 { + return nil, errors.New("at least one package is required (use --package)") + } + + return out, nil +} diff --git a/internal/pipeline/environment_test.go b/internal/pipeline/environment_test.go new file mode 100644 index 000000000..dd9687de1 --- /dev/null +++ b/internal/pipeline/environment_test.go @@ -0,0 +1,221 @@ +// 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/drapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateEnvironment_PostsBody(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/api/v2/pipelines/environments", r.URL.Path) + + var body EnvironmentCreateRequest + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "ml-base", body.Name) + + if assert.NotNil(t, body.Description) { + assert.Equal(t, "for testing", *body.Description) + } + + assert.Equal(t, []string{"numpy", "pandas==2.0"}, body.Packages) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{ + "id":"env-1", + "name":"ml-base", + "description":"for testing", + "latestVersion":1, + "versions":[{"version":1,"packages":["numpy","pandas==2.0"],"status":"CREATING","createdAt":"2026-04-29T10:00:00Z","updatedAt":"2026-04-29T10:00:00Z"}], + "createdAt":"2026-04-29T10:00:00Z","updatedAt":"2026-04-29T10:00:00Z" + }`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := CreateEnvironment("ml-base", "for testing", []string{"numpy", "pandas==2.0"}) + require.NoError(t, err) + assert.Equal(t, "env-1", got.EnvironmentID) + assert.Equal(t, 1, got.LatestVersion) + require.Len(t, got.Versions, 1) + assert.Equal(t, EnvironmentStatusCreating, got.Versions[0].Status) +} + +func TestCreateEnvironment_OmitsEmptyDescription(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw := map[string]any{} + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&raw)) + _, hasDesc := raw["description"] + assert.False(t, hasDesc, "description should be omitted when empty") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"env-1","name":"x","latestVersion":1,"versions":[],"createdAt":"2026-04-29T10:00:00Z","updatedAt":"2026-04-29T10:00:00Z"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + _, err := CreateEnvironment("x", "", []string{"numpy"}) + require.NoError(t, err) +} + +func TestListEnvironments_AddsPaginationQuery(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v2/pipelines/environments", r.URL.Path) + assert.Equal(t, "5", r.URL.Query().Get("offset")) + assert.Equal(t, "20", r.URL.Query().Get("limit")) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"env-1","name":"ml-base","latestVersion":2,"latestStatus":"READY","createdAt":"2026-04-29T10:00:00Z","updatedAt":"2026-04-29T10:00:00Z"}],"totalCount":1,"count":1}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + items, err := ListEnvironments(5, 20) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "env-1", items[0].EnvironmentID) + assert.Equal(t, EnvironmentStatusReady, items[0].LatestStatus) +} + +func TestListEnvironments_OmitsZeroPagination(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.URL.RawQuery) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[],"totalCount":0,"count":0}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + items, err := ListEnvironments(0, 0) + require.NoError(t, err) + assert.Empty(t, items) +} + +func TestUpdateEnvironment_PatchesBody(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/api/v2/pipelines/environments/env-1", r.URL.Path) + + var body EnvironmentUpdateRequest + + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, []string{"scikit-learn"}, body.Packages) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"env-1","name":"ml-base","latestVersion":2, + "versions":[ + {"version":2,"packages":["scikit-learn"],"status":"CREATING","createdAt":"2026-04-29T10:00:00Z","updatedAt":"2026-04-29T10:00:00Z"}, + {"version":1,"packages":["numpy"],"status":"READY","createdAt":"2026-04-29T10:00:00Z","updatedAt":"2026-04-29T10:00:00Z"} + ], + "createdAt":"2026-04-29T10:00:00Z","updatedAt":"2026-04-29T10:00:00Z" + }`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + got, err := UpdateEnvironment("env-1", []string{"scikit-learn"}) + require.NoError(t, err) + assert.Equal(t, 2, got.LatestVersion) + require.Len(t, got.Versions, 2) + assert.Equal(t, 2, got.Versions[0].Version) +} + +func TestDeleteEnvironment_HitsCorrectURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v2/pipelines/environments/env-1", r.URL.Path) + + w.WriteHeader(http.StatusNoContent) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + require.NoError(t, DeleteEnvironment("env-1")) +} + +func TestDeleteEnvironmentVersion_HitsCorrectURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v2/pipelines/environments/env-1/versions/3", r.URL.Path) + + w.WriteHeader(http.StatusNoContent) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + require.NoError(t, DeleteEnvironmentVersion("env-1", 3)) +} + +func TestDeleteEnvironment_PropagatesNotFound(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + err := DeleteEnvironment("nope") + + var httpErr *drapi.HTTPError + + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) +} diff --git a/internal/pipeline/schedule.go b/internal/pipeline/schedule.go new file mode 100644 index 000000000..c63aab31a --- /dev/null +++ b/internal/pipeline/schedule.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. + +// 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" + "time" +) + +// 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:"id"` + PipelineID string `json:"pipelineId"` + Version int `json:"version"` + CronExpression string `json:"cronExpression"` + Timezone string `json:"timezone"` + Status ScheduleStatus `json:"status"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// 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 page DataPage[Schedule] + + err = doJSON(http.MethodGet, endpoint, nil, "schedules", &page) + if err != nil { + return nil, err + } + + return page.Data, 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_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 new file mode 100644 index 000000000..7f5070a51 --- /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(`{"id":"s-1","pipelineId":"p-1","version":2,"cronExpression":"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(`{"data":[{"id":"s-1","pipelineId":"p-1","version":2,"cronExpression":"0 0 * * *","timezone":"UTC","status":"ACTIVE"}],"totalCount":1,"count":1}`)) + })) + + 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(`{"id":"s-1","pipelineId":"p-1","version":2,"cronExpression":"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(`{"id":"s-1","pipelineId":"p-1","version":2,"cronExpression":"*/15 * * * *","timezone":"UTC","status":"ACTIVE"}`)) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + cron := "*/15 * * * *" + got, err := UpdateSchedule("p-1", 2, "s-1", ScheduleUpdateRequest{CronExpression: &cron}) + require.NoError(t, err) + assert.Equal(t, "*/15 * * * *", got.CronExpression) +} + +func TestDeleteSchedule_DeletesLockedURL(t *testing.T) { + installSkipAuth(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/api/v2/pipelines/p-1/versions/2/schedules/s-1", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + + defer srv.Close() + + installEndpoint(t, srv.URL) + + require.NoError(t, DeleteSchedule("p-1", 2, "s-1")) +} diff --git a/tui/spinner.go b/tui/spinner.go new file mode 100644 index 000000000..d941ebf32 --- /dev/null +++ b/tui/spinner.go @@ -0,0 +1,104 @@ +// 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 tui + +import ( + "fmt" + "os" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/datarobot/cli/internal/misc/reader" +) + +type spinnerModel struct { + spinner spinner.Model + label string + fn func() error + done bool +} + +type spinnerDoneMsg struct{ err error } + +func (m spinnerModel) Init() tea.Cmd { + return tea.Batch( + m.spinner.Tick, + func() tea.Msg { + return spinnerDoneMsg{err: m.fn()} + }, + ) +} + +func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case spinnerDoneMsg: + m.done = true + + return m, tea.Quit + case spinner.TickMsg: + var cmd tea.Cmd + + m.spinner, cmd = m.spinner.Update(msg) + + return m, cmd + } + + return m, nil +} + +func (m spinnerModel) View() string { + if m.done { + return "" + } + + return InfoStyle.Render(m.spinner.View()+" ") + m.label + "\n" +} + +// RunWithSpinner runs fn in the background while showing an animated spinner +// with the given label. Returns the error from fn, if any. +func RunWithSpinner(label string, fn func() error) error { + if !reader.IsStdinTerminal() { + return fn() + } + + if reader.IsNonInteractive() { + fmt.Fprintln(os.Stderr, InfoStyle.Render("• ")+label) + + return fn() + } + + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = InfoStyle + + var fnErr error + + m := spinnerModel{ + spinner: s, + label: label, + fn: func() error { + fnErr = fn() + + return fnErr + }, + } + + _, err := Run(m) + if err != nil { + return err + } + + return fnErr +} diff --git a/tui/spinner_test.go b/tui/spinner_test.go new file mode 100644 index 000000000..706a13337 --- /dev/null +++ b/tui/spinner_test.go @@ -0,0 +1,206 @@ +// 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 tui + +import ( + "errors" + "testing" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSpinnerModel_ViewShowsLabelWhileRunning(t *testing.T) { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = InfoStyle + + m := spinnerModel{ + spinner: s, + label: "Loading…", + } + + view := m.View() + + assert.Contains(t, view, "Loading…") +} + +func TestSpinnerModel_ViewEmptyWhenDone(t *testing.T) { + s := spinner.New() + s.Spinner = spinner.Dot + + m := spinnerModel{ + spinner: s, + label: "Loading…", + done: true, + } + + assert.Empty(t, m.View()) +} + +func TestSpinnerModel_UpdateOnDoneMsg(t *testing.T) { + s := spinner.New() + s.Spinner = spinner.Dot + + m := spinnerModel{ + spinner: s, + label: "Loading…", + } + + updated, cmd := m.Update(spinnerDoneMsg{err: nil}) + + assert.True(t, updated.(spinnerModel).done) + assert.NotNil(t, cmd) // tea.Quit +} + +func TestSpinnerModel_UpdateOnDoneMsgWithError(t *testing.T) { + s := spinner.New() + s.Spinner = spinner.Dot + + m := spinnerModel{ + spinner: s, + label: "Loading…", + } + + sentErr := errors.New("something failed") + + updated, _ := m.Update(spinnerDoneMsg{err: sentErr}) + + assert.True(t, updated.(spinnerModel).done) +} + +func TestRunWithSpinner_SuccessPath(t *testing.T) { + called := false + + err := RunWithSpinner("test label", func() error { + called = true + + return nil + }) + + require.NoError(t, err) + assert.True(t, called) +} + +func TestRunWithSpinner_ErrorPath(t *testing.T) { + want := errors.New("fn error") + + err := RunWithSpinner("test label", func() error { + return want + }) + + assert.ErrorIs(t, err, want) +} + +func TestRunWithSpinner_NonInteractiveEnv_SkipsTUI(t *testing.T) { + t.Setenv("DATAROBOT_CLI_NON_INTERACTIVE", "true") + + called := false + + err := RunWithSpinner("test label", func() error { + called = true + + return nil + }) + + require.NoError(t, err) + assert.True(t, called) +} + +func TestRunWithSpinner_LabelRendered(t *testing.T) { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = InfoStyle + + m := spinnerModel{ + spinner: s, + label: "my special label", + } + + view := m.View() + + assert.Contains(t, view, "my special label") +} + +func TestSpinnerModel_InitReturnsBatch(t *testing.T) { + s := spinner.New() + s.Spinner = spinner.Dot + + m := spinnerModel{ + spinner: s, + label: "Loading…", + fn: func() error { return nil }, + } + + cmd := m.Init() + + assert.NotNil(t, cmd) + + // Execute the batch to drain msgs (just verifies it doesn't panic) + msgs := cmd() + _ = msgs +} + +func TestSpinnerModel_UpdateTickMsg(t *testing.T) { + s := spinner.New() + s.Spinner = spinner.Dot + + m := spinnerModel{ + spinner: s, + label: "Loading…", + } + + tickMsg := spinner.TickMsg{} + + updated, cmd := m.Update(tickMsg) + + assert.NotNil(t, updated) + assert.NotNil(t, cmd) // next tick +} + +func TestSpinnerModel_UpdateUnknownMsg(t *testing.T) { + s := spinner.New() + s.Spinner = spinner.Dot + + type unknownMsg struct{} + + m := spinnerModel{ + spinner: s, + label: "Loading…", + } + + updated, cmd := m.Update(unknownMsg{}) + + assert.NotNil(t, updated) + assert.Nil(t, cmd) +} + +func TestSpinnerModel_UpdateReturnsSelf(t *testing.T) { + s := spinner.New() + s.Spinner = spinner.Dot + + m := spinnerModel{ + spinner: s, + label: "Loading…", + } + + updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + + _, ok := updated.(spinnerModel) + + assert.True(t, ok) +}