From 1f1785259a193b5a8d5ab5ec596e9306cd6071f0 Mon Sep 17 00:00:00 2001 From: Mihai Chitic Date: Wed, 11 Mar 2026 15:13:29 +0200 Subject: [PATCH 1/8] [ND-7649] - update bunnyshell/sdk version and adapt code to changes --- go.mod | 2 +- go.sum | 4 ++-- .../environment/action_edit_configuration.go | 4 ++-- pkg/api/environment/action_genesis_source.go | 22 ++++--------------- 4 files changed, 9 insertions(+), 23 deletions(-) diff --git a/go.mod b/go.mod index a2909f5..c312d05 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ replace github.com/imdario/mergo => github.com/imdario/mergo v0.3.16 require ( bunnyshell.com/dev v0.7.2 - bunnyshell.com/sdk v0.20.4 + bunnyshell.com/sdk v0.22.2 github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc v1.0.0 github.com/avast/retry-go/v4 v4.6.0 diff --git a/go.sum b/go.sum index 94ecb77..eb715b0 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ bunnyshell.com/dev v0.7.2 h1:fa0ZvnIAXLVJINCJqo7uenjHmjPrlHmY18Zc8ypo/6E= bunnyshell.com/dev v0.7.2/go.mod h1:+Xk46UXX9AW0nHrFMdO/IwpUPfALrck1/qI+LIXsDmE= -bunnyshell.com/sdk v0.20.4 h1:Na2e4xKdtbnZZ/+ACpjzM7fvBFriJWC3bVgNFSmqcJs= -bunnyshell.com/sdk v0.20.4/go.mod h1:RfgfUzZ4WHZGCkToUfu2/hoQS6XsQc8IdPTVAlpS138= +bunnyshell.com/sdk v0.22.2 h1:LGJePQTdrBC6YvM282K+1/bw4ohu0XWFQdJN4Drg6l8= +bunnyshell.com/sdk v0.22.2/go.mod h1:RfgfUzZ4WHZGCkToUfu2/hoQS6XsQc8IdPTVAlpS138= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= diff --git a/pkg/api/environment/action_edit_configuration.go b/pkg/api/environment/action_edit_configuration.go index e30e258..550fa81 100644 --- a/pkg/api/environment/action_edit_configuration.go +++ b/pkg/api/environment/action_edit_configuration.go @@ -27,7 +27,7 @@ type EditConfigurationData struct { } func NewEditConfigurationOptions(environment string) *EditConfigurationOptions { - environmentEditConfiguration := sdk.NewEnvironmentEditConfiguration() + environmentEditConfiguration := sdk.NewEnvironmentEditConfigurationWithDefaults() return &EditConfigurationOptions{ DeployOptions: *NewDeployOptions(environment), @@ -62,7 +62,7 @@ func (eco *EditConfigurationOptions) AttachGenesis() error { return err } - eco.Configuration = &sdk.EnvironmentEditConfigurationConfiguration{ + eco.Configuration = sdk.EnvironmentEditConfigurationConfiguration{ FromGit: fromGit, FromGitSpec: fromGitSpec, FromTemplate: fromTemplate, diff --git a/pkg/api/environment/action_genesis_source.go b/pkg/api/environment/action_genesis_source.go index 6840c75..5a81e72 100644 --- a/pkg/api/environment/action_genesis_source.go +++ b/pkg/api/environment/action_genesis_source.go @@ -135,39 +135,25 @@ func (gs *GenesisSourceOptions) getGenesis() (*sdk.FromGit, *sdk.FromGitSpec, *s } func (gs *GenesisSourceOptions) getFromGit() *sdk.FromGit { - fromGit := sdk.NewFromGit() - fromGit.Url = &gs.GitRepo - fromGit.Branch = &gs.GitBranch - fromGit.YamlPath = &gs.GitPath - - return fromGit - + return sdk.NewFromGit(gs.GitRepo, gs.GitBranch, gs.GitPath) } func (gs *GenesisSourceOptions) getFromGitSpec() *sdk.FromGitSpec { - fromGitSpec := sdk.NewFromGitSpec() - fromGitSpec.Spec = &gs.Git - - return fromGitSpec + return sdk.NewFromGitSpec(gs.Git) } func (gs *GenesisSourceOptions) getFromString() (*sdk.FromString, error) { - fromString := sdk.NewFromString() - bytes, err := readFile(gs.YamlPath) if err != nil { return nil, err } content := string(bytes) - fromString.Yaml = &content - - return fromString, nil + return sdk.NewFromString(content), nil } func (gs *GenesisSourceOptions) getFromTemplate() (*sdk.FromTemplate, error) { - fromTemplate := sdk.NewFromTemplate() - fromTemplate.Template = &gs.TemplateID + fromTemplate := sdk.NewFromTemplate(gs.TemplateID) if len(gs.TemplateVariablePairs) > 0 { templateVariablesSchema, schemaError := getTemplateVariableSchema(gs.TemplateID) From 1bef09fd491ad141a9df4638822f0fd5a6325697 Mon Sep 17 00:00:00 2001 From: Mihai Chitic Date: Wed, 11 Mar 2026 15:54:34 +0200 Subject: [PATCH 2/8] [ND-7649] - add pipeline jobs command --- cmd/pipeline/jobs.go | 40 ++++++++++++++++ pkg/api/workflow_job/list.go | 68 +++++++++++++++++++++++++++ pkg/formatter/stylish.go | 2 + pkg/formatter/stylish.workflow_job.go | 56 ++++++++++++++++++++++ 4 files changed, 166 insertions(+) create mode 100644 cmd/pipeline/jobs.go create mode 100644 pkg/api/workflow_job/list.go create mode 100644 pkg/formatter/stylish.workflow_job.go diff --git a/cmd/pipeline/jobs.go b/cmd/pipeline/jobs.go new file mode 100644 index 0000000..153ec8d --- /dev/null +++ b/cmd/pipeline/jobs.go @@ -0,0 +1,40 @@ +package pipeline + +import ( + "bunnyshell.com/cli/pkg/api/workflow_job" + "bunnyshell.com/cli/pkg/lib" + "github.com/spf13/cobra" +) + +func init() { + listOptions := workflow_job.NewListOptions() + + var pipelineID string + var jobStatuses []string + + command := &cobra.Command{ + Use: "jobs", + + Short: "List jobs in a pipeline", + + ValidArgsFunction: cobra.NoFileCompletions, + + RunE: func(cmd *cobra.Command, args []string) error { + listOptions.Workflow = pipelineID + listOptions.Status = jobStatuses + + return lib.ShowCollection(cmd, listOptions, func() (lib.ModelWithPagination, error) { + return workflow_job.List(listOptions) + }) + }, + } + + flags := command.Flags() + + flags.AddFlag(getIDOption(&pipelineID).GetRequiredFlag("id")) + flags.StringArrayVar(&jobStatuses, "jobStatus", jobStatuses, "Filter by Job Status (repeatable)") + + listOptions.UpdateFlagSet(flags) + + mainCmd.AddCommand(command) +} diff --git a/pkg/api/workflow_job/list.go b/pkg/api/workflow_job/list.go new file mode 100644 index 0000000..0ffeba0 --- /dev/null +++ b/pkg/api/workflow_job/list.go @@ -0,0 +1,68 @@ +package workflow_job + +import ( + "net/http" + + "bunnyshell.com/cli/pkg/api" + "bunnyshell.com/cli/pkg/api/common" + "bunnyshell.com/cli/pkg/lib" + "bunnyshell.com/sdk" + "github.com/spf13/pflag" +) + +type ListOptions struct { + common.ListOptions + + Workflow string + Status []string +} + +func NewListOptions() *ListOptions { + return &ListOptions{ + ListOptions: *common.NewListOptions(), + } +} + +func (lo *ListOptions) UpdateFlagSet(flags *pflag.FlagSet) { + lo.ListOptions.UpdateFlagSet(flags) +} + +func List(options *ListOptions) (*sdk.PaginatedWorkflowJobCollection, error) { + model, resp, err := ListRaw(options) + if err != nil { + return nil, api.ParseError(resp, err) + } + + return model, nil +} + +func ListRaw(options *ListOptions) (*sdk.PaginatedWorkflowJobCollection, *http.Response, error) { + profile := options.GetProfile() + + ctx, cancel := lib.GetContextFromProfile(profile) + defer cancel() + + request := lib.GetAPIFromProfile(profile).WorkflowJobAPI.WorkflowJobList(ctx) + + return applyOptions(request, options).Execute() +} + +func applyOptions(request sdk.ApiWorkflowJobListRequest, options *ListOptions) sdk.ApiWorkflowJobListRequest { + if options == nil { + return request + } + + if options.Workflow != "" { + request = request.Workflow(options.Workflow) + } + + if options.Page > 1 { + request = request.Page(options.Page) + } + + if len(options.Status) > 0 { + request = request.Status(options.Status) + } + + return request +} diff --git a/pkg/formatter/stylish.go b/pkg/formatter/stylish.go index 6c3f0f0..5b9a66a 100644 --- a/pkg/formatter/stylish.go +++ b/pkg/formatter/stylish.go @@ -38,6 +38,8 @@ func stylish(data interface{}) ([]byte, error) { tabulateKubernetesCollection(writer, dataType) case *sdk.PaginatedPipelineCollection: tabulatePipelineCollection(writer, dataType) + case *sdk.PaginatedWorkflowJobCollection: + tabulateWorkflowJobCollection(writer, dataType) case *sdk.PaginatedComponentGitCollection: tabulateComponentGitCollection(writer, dataType) case []sdk.ComponentGitCollection: diff --git a/pkg/formatter/stylish.workflow_job.go b/pkg/formatter/stylish.workflow_job.go new file mode 100644 index 0000000..57065e0 --- /dev/null +++ b/pkg/formatter/stylish.workflow_job.go @@ -0,0 +1,56 @@ +package formatter + +import ( + "fmt" + "text/tabwriter" + "time" + + "bunnyshell.com/sdk" +) + +func tabulateWorkflowJobCollection(writer *tabwriter.Writer, data *sdk.PaginatedWorkflowJobCollection) { + fmt.Fprintf( + writer, + "%v\t %v\t %v\t %v\t %v\t %v\t %v\t %v\n", + "JobID", + "PipelineID", + "Name", + "Type", + "Status", + "StartedAt", + "Duration", + "AllowedToFail", + ) + + if data.Embedded != nil { + for _, item := range data.Embedded.Item { + duration := "" + if value, ok := item.GetDurationOk(); ok && value != nil { + duration = (time.Duration(*value) * time.Second).String() + } + + allowedToFail := "" + if value, ok := item.GetAllowedToFailOk(); ok && value != nil { + allowedToFail = fmt.Sprintf("%t", *value) + } + + startedAt := "" + if value, ok := item.GetStartedAtOk(); ok && value != nil { + startedAt = value.Format(time.RFC3339) + } + + fmt.Fprintf( + writer, + "%v\t %v\t %v\t %v\t %v\t %v\t %v\t %v\n", + item.GetId(), + item.GetWorkflow(), + item.GetName(), + item.GetType(), + item.GetStatus(), + startedAt, + duration, + allowedToFail, + ) + } + } +} From b8145f77c59b82ad897bbd15c6ac08731f7d1d1c Mon Sep 17 00:00:00 2001 From: Mihai Chitic Date: Thu, 12 Mar 2026 17:21:42 +0200 Subject: [PATCH 3/8] [ND-7649] - add pipeline logs command --- AGENTS.md | 372 +++++++++++++++++++++ CLAUDE.md | 100 ++++++ cmd/pipeline/jobs.go | 12 +- cmd/pipeline/logs.go | 99 ++++++ pkg/api/workflow_job/list.go | 21 ++ pkg/api/workflow_job/logs.go | 56 ++++ pkg/api/workflow_job/status/status.go | 36 ++ pkg/config/vars.go | 2 + pkg/formatter/formatter.go | 2 + pkg/formatter/raw.go | 14 + pkg/formatter/raw.workflow_job_logs.go | 64 ++++ pkg/formatter/stylish.go | 2 + pkg/formatter/stylish.workflow_job_logs.go | 206 ++++++++++++ 13 files changed, 985 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md create mode 100644 cmd/pipeline/logs.go create mode 100644 pkg/api/workflow_job/logs.go create mode 100644 pkg/api/workflow_job/status/status.go create mode 100644 pkg/formatter/raw.go create mode 100644 pkg/formatter/raw.workflow_job_logs.go create mode 100644 pkg/formatter/stylish.workflow_job_logs.go diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8413bbd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,372 @@ +# AI Agent Development Guide + +This document provides instructions for AI assistants working on the Bunnyshell CLI codebase. It is maintained in an AI-agnostic format for use with any AI development assistant. + +## Quick Start + +**Project:** Bunnyshell CLI (`bns`) +**Language:** Go 1.23 +**Development Environment:** Docker-based (required) + +## Development Environment Setup + +### Using Docker (Required for Go Commands) + +All Go-related commands MUST be executed inside the Docker development container. + +**1. Check if container is running:** +```bash +docker ps --filter "name=bunnyshell-cli" +``` + +**2. Start container if needed:** +```bash +cd .dev && docker-compose up -d +``` + +**3. Execute commands in container:** +```bash +docker exec -it bunnyshell-cli +``` + +**Common commands:** +```bash +# Build the project +docker exec -it bunnyshell-cli make build-local + +# Run go mod tidy +docker exec -it bunnyshell-cli go mod tidy + +# Run tests +docker exec -it bunnyshell-cli go test ./... + +# Access container shell +docker exec -it bunnyshell-cli /bin/bash +``` + +### Build Success Criteria + +A build is considered **successful** when: +- ✅ Linux binary is produced: `dist/bns_linux_amd64_v1/bns` +- ✅ Darwin binary is produced: `dist/bns_darwin_arm64/bns` or `dist/bns_darwin_amd64_v1/bns` + +A build may show errors for: +- ❌ Docker image building (no Docker-in-Docker in dev container) - **THIS IS EXPECTED AND OK** + +### Testing Builds + +**From container:** +```bash +./dist/bns_linux_amd64_v1/bns --help +``` + +**From host (macOS):** +```bash +./dist/bns_darwin_arm64/bns --help +# or +./dist/bns_darwin_amd64_v1/bns --help +``` + +### Important Notes + +- **DO NOT** rely on host machine Go installation +- **DO NOT** run `go` commands directly on the host +- **ALWAYS** use the Docker container for Go commands +- The host may not have Go installed or may have a different version + +## Project Structure + +``` +/ +├── .dev/ # Docker development environment +│ ├── docker-compose.yaml # Container setup +│ ├── Dockerfile.dev # golang:1.23 with goreleaser +│ └── Readme.md # Quick reference +├── cmd/ # Command implementations +│ └── [resource]/ # Command groups (environments, components, etc.) +│ ├── root.go # Main command +│ ├── list.go # List subcommand +│ ├── show.go # Show subcommand +│ └── action/ # Action subcommands (create, delete, etc.) +├── pkg/ # Core packages +│ ├── api/ # API client wrappers +│ ├── config/ # Configuration management +│ ├── formatter/ # Output formatters (stylish, JSON, YAML) +│ ├── interactive/ # Interactive prompts +│ └── ... # Other core packages +├── main.go # Application entry point +├── go.mod / go.sum # Go dependencies +├── Makefile # Build targets +├── .goreleaser.yaml # Release configuration +└── AGENTS.md # This file +``` + +## Adding a New Command + +Follow this pattern when adding new commands: + +### 1. Create API Layer + +Location: `pkg/api/[resource]/` + +```go +// pkg/api/[resource]/list.go +package resource + +import ( + "bunnyshell.com/cli/pkg/api" + "bunnyshell.com/cli/pkg/api/common" + "bunnyshell.com/cli/pkg/lib" + "bunnyshell.com/sdk" +) + +type ListOptions struct { + common.ListOptions + // Add your filters here +} + +func NewListOptions() *ListOptions { + return &ListOptions{ + ListOptions: *common.NewListOptions(), + } +} + +func List(options *ListOptions) (*sdk.PaginatedResourceCollection, error) { + model, resp, err := ListRaw(options) + if err != nil { + return nil, api.ParseError(resp, err) + } + return model, nil +} + +func ListRaw(options *ListOptions) (*sdk.PaginatedResourceCollection, *http.Response, error) { + profile := options.GetProfile() + ctx, cancel := lib.GetContextFromProfile(profile) + defer cancel() + + request := lib.GetAPIFromProfile(profile).ResourceAPI.ResourceList(ctx) + return applyOptions(request, options).Execute() +} + +func applyOptions(request sdk.ApiResourceListRequest, options *ListOptions) sdk.ApiResourceListRequest { + if options == nil { + return request + } + + if options.Page > 1 { + request = request.Page(options.Page) + } + + // Add your filters here + + return request +} +``` + +### 2. Create Command Implementation + +Location: `cmd/[resource]/` + +```go +// cmd/[resource]/list.go +package resource + +import ( + "bunnyshell.com/cli/pkg/api/resource" + "bunnyshell.com/cli/pkg/lib" + "github.com/spf13/cobra" +) + +func init() { + listOptions := resource.NewListOptions() + + command := &cobra.Command{ + Use: "list", + Short: "List resources", + ValidArgsFunction: cobra.NoFileCompletions, + + RunE: func(cmd *cobra.Command, args []string) error { + return lib.ShowCollection(cmd, listOptions, func() (lib.ModelWithPagination, error) { + return resource.List(listOptions) + }) + }, + } + + flags := command.Flags() + // Add your flags here + listOptions.UpdateFlagSet(flags) + + mainCmd.AddCommand(command) +} +``` + +### 3. Add Formatter (if needed) + +Location: `pkg/formatter/` + +```go +// pkg/formatter/stylish.resource.go +package formatter + +import ( + "fmt" + "text/tabwriter" + "bunnyshell.com/sdk" +) + +func tabulateResourceCollection(writer *tabwriter.Writer, data *sdk.PaginatedResourceCollection) { + fmt.Fprintf(writer, "%v\t %v\t %v\n", "ID", "Name", "Status") + + if data.Embedded != nil { + for _, item := range data.Embedded.Item { + fmt.Fprintf(writer, "%v\t %v\t %v\n", + item.GetId(), + item.GetName(), + item.GetStatus(), + ) + } + } +} +``` + +Then add the case to `pkg/formatter/stylish.go`: + +```go +case *sdk.PaginatedResourceCollection: + tabulateResourceCollection(writer, dataType) +``` + +## Common Patterns + +### Flag Conflicts to Avoid + +These global flags are already registered: +- `-t` = `--timeout` +- `-d` = `--debug` +- `-v` = `--verbose` +- `-o` = `--output` + +Do not use these shorthands for command-specific flags. + +### Repeatable Flags + +Use `StringArrayVar` for repeatable flags: + +```go +var statuses []string +flags.StringArrayVar(&statuses, "status", statuses, "Filter by status (repeatable)") +``` + +### Required Flags + +```go +flags.AddFlag(option.GetRequiredFlag("id")) +``` + +### Optional Context-aware Flags + +```go +flags.AddFlag(options.Organization.GetFlag("organization")) +``` + +## Testing Your Changes + +### 1. Build the project + +```bash +docker exec -it bunnyshell-cli make build-local +``` + +### 2. Verify build succeeded + +Check for: +- `dist/bns_linux_amd64_v1/bns` (Linux) +- `dist/bns_darwin_arm64/bns` (macOS ARM) +- `dist/bns_darwin_amd64_v1/bns` (macOS Intel) + +### 3. Test the binary + +From host (macOS): +```bash +./dist/bns_darwin_arm64/bns [your-command] --help +``` + +From container (Linux): +```bash +./dist/bns_linux_amd64_v1/bns [your-command] --help +``` + +## SDK Dependencies + +The project depends on: +- `bunnyshell.com/sdk` - Official Bunnyshell API client +- `bunnyshell.com/dev` - Development utilities + +If you need to test with local SDK changes: + +1. Add to `go.mod`: + ```go + replace bunnyshell.com/dev v0.7.0 => ../bunnyshellosi-dev/ + ``` + +2. Ensure the path exists in both container and host (for IDE support) + +3. The docker-compose.yaml already mounts this path + +## Code Quality + +### Before committing: + +```bash +# Format code +docker exec -it bunnyshell-cli go fmt ./... + +# Tidy dependencies +docker exec -it bunnyshell-ci go mod tidy + +# Run tests +docker exec -it bunnyshell-cli go test ./... + +# Build to verify +docker exec -it bunnyshell-cli make build-local +``` + +## Architecture Principles + +1. **Separation of Concerns:** + - `cmd/` = CLI interface and command handling + - `pkg/api/` = Business logic and API interaction + - `pkg/formatter/` = Output formatting + - `pkg/lib/` = Shared utilities + +2. **Configuration Management:** + - Support multiple profiles + - Store context (org, project, env, component) + - Allow flag overrides + - Interactive prompts for missing values + +3. **User Experience:** + - Provide interactive mode for missing parameters + - Support non-interactive mode for automation + - Multiple output formats (stylish, JSON, YAML) + - Progress indicators for long operations + +4. **Error Handling:** + - Use domain-specific error types + - Parse and format API errors + - Provide helpful error messages + +## Documentation + +When adding features, update: +- **AGENTS.md** (this file) - For AI-agnostic instructions +- **CLAUDE.md** - For detailed codebase overview +- **README.md** - For user-facing documentation +- `.dev/Readme.md` - For development quick reference + +## Getting Help + +- Check **CLAUDE.md** for comprehensive codebase documentation +- Look at existing commands for patterns (e.g., `cmd/pipeline/jobs.go`) +- Examine API packages for SDK usage (e.g., `pkg/api/workflow_job/list.go`) +- Review formatters for output patterns (e.g., `pkg/formatter/stylish.workflow_job.go`) diff --git a/CLAUDE.md b/CLAUDE.md index bd16270..f381edc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,12 +9,70 @@ This document provides a comprehensive overview of the Bunnyshell CLI codebase t **Lines of Code:** ~17,000 (13,940 in `pkg/` + 3,093 in `cmd/`) **Purpose:** Command-line tool for managing Bunnyshell environments, components, and remote development workflows. +## Development Environment + +### Docker-based Development (Recommended) + +The project uses a Docker-based development environment for consistent builds and testing. + +**Container Setup:** +- Location: `.dev/` directory +- Container name: `bunnyshell-cli` +- Base image: `golang:1.23` with goreleaser pre-installed +- Working directory: `/usr/src/app` (mounted from project root) + +**Starting the container:** +```bash +cd .dev +docker-compose up -d +``` + +**Accessing the container:** +```bash +docker exec -it bunnyshell-cli /bin/bash +``` + +**Building inside the container:** +```bash +# Inside the container +make build-local + +# Successful build produces: +# - dist/bns_linux_amd64_v1/bns (Linux binary) +# - dist/bns_darwin_arm64/bns (macOS ARM binary) +# - dist/bns_darwin_amd64_v1/bns (macOS Intel binary) +``` + +**Testing the build:** +- From container: `./dist/bns_linux_amd64_v1/bns --help` +- From host (macOS): `./dist/bns_darwin_arm64/bns --help` or `./dist/bns_darwin_amd64_v1/bns --help` + +**Notes:** +- Docker image building will fail in the dev container (no Docker-in-Docker) - this is expected and OK for development +- The build is considered successful if Linux and/or Darwin binaries are produced +- Host machine Go installation is NOT recommended - use the container for all Go commands + +### Local Development with SDK Changes + +If you need to test changes to the Bunnyshell SDK (`bunnyshell.com/dev`): + +1. Add to `go.mod`: + ```go + replace bunnyshell.com/dev v0.7.0 => ../bunnyshellosi-dev/ + ``` +2. Ensure the path works both in container and on host for IDE support +3. The path is already mounted in docker-compose.yaml + ## Repository Structure ``` /cli ├── main.go # Entry point with panic recovery ├── go.mod / go.sum # Go module dependencies +├── .dev/ # Docker development environment +│ ├── docker-compose.yaml # Container orchestration +│ ├── Dockerfile.dev # Development container image +│ └── Readme.md # Development quick start ├── cmd/ # Command implementations (~3,093 LOC) │ ├── root.go # Root command setup with Cobra │ ├── environment/ # Environment management commands @@ -595,6 +653,40 @@ See `cmd/component/action/exec.go` and `cmd/component/action/ssh.go` for referen ## Notes for AI Assistants +### Development Workflow for AI Assistants + +**IMPORTANT: Always use the Docker container for Go commands** + +When you need to run Go commands (build, test, mod tidy, etc.): + +1. **Check if the container is running:** + ```bash + docker ps --filter "name=bunnyshell-cli" + ``` + +2. **Start container if not running:** + ```bash + cd .dev && docker-compose up -d + ``` + +3. **Execute Go commands inside the container:** + ```bash + docker exec -it bunnyshell-cli + # Example: docker exec -it bunnyshell-cli make build-local + ``` + +4. **Build success criteria:** + - Build is considered successful if Linux and/or Darwin binaries are produced + - Docker image building will fail (no Docker-in-Docker) - this is EXPECTED and OK + - Look for: `dist/bns_linux_amd64_v1/bns` and/or `dist/bns_darwin_arm64/bns` + +5. **DO NOT rely on host machine Go:** + - Host may not have Go installed + - Host Go version may differ + - Container provides consistent environment + +### General Development Patterns + - The codebase follows clear separation of concerns: CLI commands, business logic, and utilities - Commands are structured hierarchically using Cobra - API layer wraps SDK calls with domain-specific logic @@ -606,3 +698,11 @@ See `cmd/component/action/exec.go` and `cmd/component/action/ssh.go` for referen - When adding new component actions, follow the pattern in `cmd/component/action/ssh.go` - Always check for flag conflicts with global flags before adding shorthand flags - Positional arguments are preferred for primary identifiers (e.g., `bns exec ` instead of `--id` flag) + +### AI-Agnostic Documentation + +This project maintains AI-agnostic documentation for use across different AI assistants: +- **AGENTS.md** - General instructions for all AI agents (to be created/maintained) +- **CLAUDE.md** - This file (Claude-specific but should contain general knowledge) + +When updating documentation, consider whether the knowledge should be in AI-agnostic format in AGENTS.md. diff --git a/cmd/pipeline/jobs.go b/cmd/pipeline/jobs.go index 153ec8d..d4ece64 100644 --- a/cmd/pipeline/jobs.go +++ b/cmd/pipeline/jobs.go @@ -1,7 +1,11 @@ package pipeline import ( + "fmt" + "strings" + "bunnyshell.com/cli/pkg/api/workflow_job" + wfstatus "bunnyshell.com/cli/pkg/api/workflow_job/status" "bunnyshell.com/cli/pkg/lib" "github.com/spf13/cobra" ) @@ -31,8 +35,14 @@ func init() { flags := command.Flags() + jobStatusValues := strings.Join([]string{ + wfstatus.JobPending, wfstatus.JobQueued, wfstatus.JobInProgress, + wfstatus.JobFailed, wfstatus.JobAbortFailed, wfstatus.JobSuccess, + wfstatus.JobSkipped, wfstatus.JobAborting, wfstatus.JobAborted, + }, ", ") + flags.AddFlag(getIDOption(&pipelineID).GetRequiredFlag("id")) - flags.StringArrayVar(&jobStatuses, "jobStatus", jobStatuses, "Filter by Job Status (repeatable)") + flags.StringArrayVar(&jobStatuses, "jobStatus", jobStatuses, fmt.Sprintf("Filter by job status (repeatable); possible values: %s", jobStatusValues)) listOptions.UpdateFlagSet(flags) diff --git a/cmd/pipeline/logs.go b/cmd/pipeline/logs.go new file mode 100644 index 0000000..ec2c17d --- /dev/null +++ b/cmd/pipeline/logs.go @@ -0,0 +1,99 @@ +package pipeline + +import ( + "fmt" + "strings" + + "bunnyshell.com/cli/pkg/api/workflow_job" + wfstatus "bunnyshell.com/cli/pkg/api/workflow_job/status" + "bunnyshell.com/cli/pkg/formatter" + "bunnyshell.com/cli/pkg/lib" + "github.com/spf13/cobra" +) + +func init() { + var pipelineID string + var jobs []string + var jobStatuses []string + var stepStatuses []string + + command := &cobra.Command{ + Use: "logs", + + Short: "View logs from pipeline jobs", + Long: "View logs from pipeline jobs and job steps with optional filtering by job and step status", + + ValidArgsFunction: cobra.NoFileCompletions, + + RunE: func(cmd *cobra.Command, args []string) error { + // If specific jobs are requested, use those directly; otherwise fetch all jobs in the pipeline + var jobsToFetch []string + if len(jobs) > 0 { + jobsToFetch = jobs + } else { + listOptions := workflow_job.NewListOptions() + listOptions.Workflow = pipelineID + if len(jobStatuses) > 0 { + listOptions.Status = jobStatuses + } + + allJobs, err := workflow_job.AllJobs(listOptions) + if err != nil { + return fmt.Errorf("failed to list jobs: %w", err) + } + + for _, job := range allJobs { + if id, ok := job.GetIdOk(); ok && id != nil { + jobsToFetch = append(jobsToFetch, *id) + } + } + } + + if len(jobsToFetch) == 0 { + return lib.FormatCommandData(cmd, []formatter.WorkflowJobLogsResult{}) + } + + // Fetch logs for each job + var allLogs []formatter.WorkflowJobLogsResult + for _, jobID := range jobsToFetch { + logsOptions := workflow_job.NewLogsOptions(jobID) + if len(stepStatuses) > 0 { + logsOptions.StepStatus = stepStatuses + } + + logs, err := workflow_job.Logs(logsOptions) + if err != nil { + return fmt.Errorf("failed to fetch logs for job %s: %w", jobID, err) + } + + allLogs = append(allLogs, formatter.WorkflowJobLogsResult{ + JobID: jobID, + Logs: logs, + }) + } + + // Display the logs + return lib.FormatCommandData(cmd, allLogs) + }, + } + + flags := command.Flags() + + flags.AddFlag(getIDOption(&pipelineID).GetRequiredFlag("id")) + + jobStatusValues := strings.Join([]string{ + wfstatus.JobPending, wfstatus.JobQueued, wfstatus.JobInProgress, + wfstatus.JobFailed, wfstatus.JobAbortFailed, wfstatus.JobSuccess, + wfstatus.JobSkipped, wfstatus.JobAborting, wfstatus.JobAborted, + }, ", ") + + stepStatusValues := strings.Join([]string{ + wfstatus.StepFailed, wfstatus.StepSuccess, + }, ", ") + + flags.StringArrayVar(&jobs, "job", jobs, "Filter to specific job ID(s) (repeatable)") + flags.StringArrayVar(&jobStatuses, "jobStatus", jobStatuses, fmt.Sprintf("Filter by job status (repeatable); possible values: %s", jobStatusValues)) + flags.StringArrayVar(&stepStatuses, "stepStatus", stepStatuses, fmt.Sprintf("Filter by step status (repeatable); possible values: %s", stepStatusValues)) + + mainCmd.AddCommand(command) +} diff --git a/pkg/api/workflow_job/list.go b/pkg/api/workflow_job/list.go index 0ffeba0..4a34d45 100644 --- a/pkg/api/workflow_job/list.go +++ b/pkg/api/workflow_job/list.go @@ -36,6 +36,27 @@ func List(options *ListOptions) (*sdk.PaginatedWorkflowJobCollection, error) { return model, nil } +func AllJobs(options *ListOptions) ([]sdk.WorkflowJobCollection, error) { + var result []sdk.WorkflowJobCollection + + for { + model, err := List(options) + if err != nil { + return nil, err + } + + if model.Embedded != nil { + result = append(result, model.Embedded.Item...) + } + + if !model.HasLinks() || !model.Links.HasNext() { + return result, nil + } + + options.Page++ + } +} + func ListRaw(options *ListOptions) (*sdk.PaginatedWorkflowJobCollection, *http.Response, error) { profile := options.GetProfile() diff --git a/pkg/api/workflow_job/logs.go b/pkg/api/workflow_job/logs.go new file mode 100644 index 0000000..064dea5 --- /dev/null +++ b/pkg/api/workflow_job/logs.go @@ -0,0 +1,56 @@ +package workflow_job + +import ( + "net/http" + + "bunnyshell.com/cli/pkg/api" + "bunnyshell.com/cli/pkg/api/common" + "bunnyshell.com/cli/pkg/lib" + "bunnyshell.com/sdk" +) + +type LogsOptions struct { + common.Options + + JobID string + StepStatus []string +} + +func NewLogsOptions(jobID string) *LogsOptions { + return &LogsOptions{ + Options: *common.NewOptions(), + JobID: jobID, + } +} + +func Logs(options *LogsOptions) (*sdk.WorkflowJobWorkflowJobLogsOutputItem, error) { + model, resp, err := LogsRaw(options) + if err != nil { + return nil, api.ParseError(resp, err) + } + + return model, nil +} + +func LogsRaw(options *LogsOptions) (*sdk.WorkflowJobWorkflowJobLogsOutputItem, *http.Response, error) { + profile := options.GetProfile() + + ctx, cancel := lib.GetContextFromProfile(profile) + defer cancel() + + request := lib.GetAPIFromProfile(profile).WorkflowJobAPI.WorkflowJobLogs(ctx, options.JobID) + + return applyLogsOptions(request, options).Execute() +} + +func applyLogsOptions(request sdk.ApiWorkflowJobLogsRequest, options *LogsOptions) sdk.ApiWorkflowJobLogsRequest { + if options == nil { + return request + } + + if len(options.StepStatus) > 0 { + request = request.StepStatus(options.StepStatus) + } + + return request +} diff --git a/pkg/api/workflow_job/status/status.go b/pkg/api/workflow_job/status/status.go new file mode 100644 index 0000000..671df04 --- /dev/null +++ b/pkg/api/workflow_job/status/status.go @@ -0,0 +1,36 @@ +package status + +// Workflow status values. +const ( + WorkflowQueued = "queued" + WorkflowThrottled = "throttled" + WorkflowInProgress = "in_progress" + WorkflowSuccess = "success" + + WorkflowFailing = "failing" + WorkflowFailed = "failed" + + WorkflowAborting = "aborting" + WorkflowAborted = "aborted" +) + +// Job status values. +const ( + JobPending = "pending" + JobQueued = "queued" + JobInProgress = "in_progress" + + JobFailed = "failed" + JobAbortFailed = "abort_failed" + JobSuccess = "success" + + JobSkipped = "skipped" + JobAborting = "aborting" + JobAborted = "aborted" +) + +// Step status values. +const ( + StepFailed = "failed" + StepSuccess = "success" +) diff --git a/pkg/config/vars.go b/pkg/config/vars.go index e34eeb0..9f34864 100644 --- a/pkg/config/vars.go +++ b/pkg/config/vars.go @@ -20,11 +20,13 @@ var ( "stylish", "json", "yaml", + "raw", } FormatDescriptions = []string{ "stylish\tOutput format for human consumption", "json\tOutput in JSON", "yaml\tOutput in YAML", + "raw\tPlain text output without colors or formatting", } ErrConfigExists = errors.New("configFile already exists") diff --git a/pkg/formatter/formatter.go b/pkg/formatter/formatter.go index c6591ca..ef347f7 100644 --- a/pkg/formatter/formatter.go +++ b/pkg/formatter/formatter.go @@ -20,6 +20,8 @@ func Formatter(data interface{}, format string) ([]byte, error) { return JSONFormatter(data) case "yaml", "yml": return YAMLFormatter(data) + case "raw": + return raw(data) } return nil, fmt.Errorf("%w: %s", errUnknownFormat, format) diff --git a/pkg/formatter/raw.go b/pkg/formatter/raw.go new file mode 100644 index 0000000..229762f --- /dev/null +++ b/pkg/formatter/raw.go @@ -0,0 +1,14 @@ +package formatter + +import "errors" + +var errRawUnsupported = errors.New("raw output is not supported for this command") + +func raw(data interface{}) ([]byte, error) { + switch dataType := data.(type) { + case []WorkflowJobLogsResult: + return rawWorkflowJobLogs(dataType), nil + } + + return nil, errRawUnsupported +} diff --git a/pkg/formatter/raw.workflow_job_logs.go b/pkg/formatter/raw.workflow_job_logs.go new file mode 100644 index 0000000..702d694 --- /dev/null +++ b/pkg/formatter/raw.workflow_job_logs.go @@ -0,0 +1,64 @@ +package formatter + +import ( + "bytes" + "fmt" +) + +func rawWorkflowJobLogs(data []WorkflowJobLogsResult) []byte { + var buf bytes.Buffer + + for i, result := range data { + if result.Logs == nil { + continue + } + + if i > 0 { + fmt.Fprintf(&buf, "\n\n") + } + + // Job header + jobName := result.JobID + if job, ok := result.Logs.GetWorkflowJobOk(); ok && job != nil { + jobName = getJobDisplayName(job, result.JobID) + } + fmt.Fprintf(&buf, "Job: %s\n", jobName) + + // Steps + if steps, ok := result.Logs.GetStepsOk(); ok && steps != nil { + for j, step := range steps { + if j > 0 { + fmt.Fprintf(&buf, "\n") + } + + stepName := "Unknown" + if name, ok := step.GetNameOk(); ok && name != nil { + stepName = *name + } + fmt.Fprintf(&buf, "Step: %s\n", stepName) + + if logs, ok := step.GetLogsOk(); ok && logs != nil { + for _, logEntry := range logs { + timestamp := "" + if t, ok := logEntry.GetTimeOk(); ok && t != nil { + timestamp = t.Format("15:04:05.000") + } + + message := "" + if msg, ok := logEntry.GetLogOk(); ok && msg != nil { + message = *msg + } + + if timestamp != "" { + fmt.Fprintf(&buf, "%s %s\n", timestamp, message) + } else { + fmt.Fprintf(&buf, "%s\n", message) + } + } + } + } + } + } + + return buf.Bytes() +} diff --git a/pkg/formatter/stylish.go b/pkg/formatter/stylish.go index 5b9a66a..bade35a 100644 --- a/pkg/formatter/stylish.go +++ b/pkg/formatter/stylish.go @@ -40,6 +40,8 @@ func stylish(data interface{}) ([]byte, error) { tabulatePipelineCollection(writer, dataType) case *sdk.PaginatedWorkflowJobCollection: tabulateWorkflowJobCollection(writer, dataType) + case []WorkflowJobLogsResult: + tabulateWorkflowJobLogs(writer, dataType) case *sdk.PaginatedComponentGitCollection: tabulateComponentGitCollection(writer, dataType) case []sdk.ComponentGitCollection: diff --git a/pkg/formatter/stylish.workflow_job_logs.go b/pkg/formatter/stylish.workflow_job_logs.go new file mode 100644 index 0000000..53a4156 --- /dev/null +++ b/pkg/formatter/stylish.workflow_job_logs.go @@ -0,0 +1,206 @@ +package formatter + +import ( + "fmt" + "strings" + "text/tabwriter" + "time" + + wfstatus "bunnyshell.com/cli/pkg/api/workflow_job/status" + "bunnyshell.com/sdk" + "github.com/fatih/color" +) + +// WorkflowJobLogsResult represents logs for a single job +type WorkflowJobLogsResult struct { + JobID string + Logs *sdk.WorkflowJobWorkflowJobLogsOutputItem +} + +const separatorWidth = 80 + +func statusIcon(status string) string { + switch status { + case wfstatus.JobSuccess: // same value as StepSuccess + return color.New(color.FgGreen).Sprint("✓") + case wfstatus.JobFailed, wfstatus.JobAbortFailed: // JobFailed same value as StepFailed + return color.New(color.FgRed).Sprint("✘") + case wfstatus.JobAborting, wfstatus.JobAborted: + return color.New(color.FgRed).Sprint("⊘") + case wfstatus.JobInProgress: + return color.New(color.FgCyan).Sprint("▶︎") + case wfstatus.JobPending, wfstatus.JobQueued: + return color.New(color.FgWhite).Sprint("⋯") + case wfstatus.JobSkipped: + return color.New(color.FgHiBlack).Sprint("»") + default: + return color.New(color.FgWhite).Sprint("?") + } +} + +func statusNameColor(status string) *color.Color { + switch status { + case wfstatus.JobSuccess: // same value as StepSuccess + return color.New(color.FgGreen, color.Bold) + case wfstatus.JobFailed, wfstatus.JobAbortFailed: // JobFailed same value as StepFailed + return color.New(color.FgRed, color.Bold) + case wfstatus.JobAborting, wfstatus.JobAborted: + return color.New(color.FgRed) + case wfstatus.JobInProgress: + return color.New(color.FgCyan, color.Bold) + case wfstatus.JobPending, wfstatus.JobQueued: + return color.New(color.FgWhite, color.Bold) + case wfstatus.JobSkipped: + return color.New(color.FgHiBlack, color.Bold) + default: + return color.New(color.FgWhite, color.Bold) + } +} + +func formatDateTime(t time.Time) string { + return t.Format("2006-01-02 15:04:05Z07:00") +} + +func isStepFailed(status string) bool { + return status == wfstatus.StepFailed || status == wfstatus.JobAbortFailed +} + +func tabulateWorkflowJobLogs(writer *tabwriter.Writer, data []WorkflowJobLogsResult) { + dim := color.New(color.FgHiBlack) + + for _, result := range data { + if result.Logs == nil { + continue + } + + // Job header + if job, ok := result.Logs.GetWorkflowJobOk(); ok && job != nil { + jobName := getJobDisplayName(job, result.JobID) + status := "" + if s, ok := job.GetStatusOk(); ok && s != nil { + status = *s + } + + jobSep := strings.Repeat("═", separatorWidth+4) // 4 for the step indentation below + fmt.Fprintf(writer, "\n%s\n", jobSep) + fmt.Fprintf(writer, " %s Job: %s\n", statusIcon(status), statusNameColor(status).Sprint(jobName)) + + // Row 1: Status + JobId + var row1 []string + if status != "" { + row1 = append(row1, fmt.Sprintf("Status: %s", status)) + } + if id, ok := job.GetIdOk(); ok && id != nil { + row1 = append(row1, fmt.Sprintf("JobId: %s", *id)) + } + if len(row1) > 0 { + fmt.Fprintf(writer, " %s\n", dim.Sprint(strings.Join(row1, " "))) + } + + // Row 2: Type + AllowedToFail + var row2 []string + if jobType, ok := job.GetTypeOk(); ok && jobType != nil { + row2 = append(row2, fmt.Sprintf("Type: %s", *jobType)) + } + if allowedToFail, ok := job.GetAllowedToFailOk(); ok && allowedToFail != nil { + row2 = append(row2, fmt.Sprintf("AllowedToFail: %v", *allowedToFail)) + } + if len(row2) > 0 { + fmt.Fprintf(writer, " %s\n", dim.Sprint(strings.Join(row2, " "))) + } + + // Row 3: StartedAt + Duration + var row3 []string + if startedAt, ok := job.GetStartedAtOk(); ok && startedAt != nil { + row3 = append(row3, fmt.Sprintf("StartedAt: %s", formatDateTime(*startedAt))) + } + if duration, ok := job.GetDurationOk(); ok && duration != nil { + row3 = append(row3, fmt.Sprintf("Duration: %ds", *duration)) + } + if len(row3) > 0 { + fmt.Fprintf(writer, " %s\n", dim.Sprint(strings.Join(row3, " "))) + } + + fmt.Fprintf(writer, "%s\n", jobSep) + } + + // Steps (indented inside the job) + if steps, ok := result.Logs.GetStepsOk(); ok && steps != nil { + for _, step := range steps { + stepName := "Unknown" + if name, ok := step.GetNameOk(); ok && name != nil { + stepName = *name + } + + stepStatus := "" + if s, ok := step.GetStatusOk(); ok && s != nil { + stepStatus = *s + } + + // Step header + stepSep := strings.Repeat("━", separatorWidth) + fmt.Fprintf(writer, "\n %s\n", stepSep) + + var stepHeader strings.Builder + stepHeader.WriteString(fmt.Sprintf(" %s Step: %s", statusIcon(stepStatus), statusNameColor(stepStatus).Sprint(stepName))) + if stepStatus != wfstatus.StepSuccess { + stepHeader.WriteString(fmt.Sprintf(" %s", stepStatus)) + if exitCode, ok := step.GetExitCodeOk(); ok && exitCode != nil { + stepHeader.WriteString(fmt.Sprintf(" (exit: %d)", *exitCode)) + } + } + fmt.Fprintln(writer, stepHeader.String()) + + fmt.Fprintf(writer, " %s\n", stepSep) + + // Log lines: timestamp (white or red for failed) + two spaces + message + tsColor := color.New(color.FgWhite, color.Bold) + if isStepFailed(stepStatus) { + tsColor = color.New(color.FgRed) + } + + // Continuation indent aligns with the message column: + // 4 (step indent) + 12 (timestamp "15:04:05.000") + 2 (spaces) = 18 + const logIndent = " " + const logContinuation = " " // 18 spaces + + if logs, ok := step.GetLogsOk(); ok && logs != nil && len(logs) > 0 { + for _, logEntry := range logs { + message := "" + if msg, ok := logEntry.GetLogOk(); ok && msg != nil { + message = *msg + } + + if t, ok := logEntry.GetTimeOk(); ok && t != nil { + message = strings.ReplaceAll(message, "\n", "\n"+logContinuation) + fmt.Fprintf(writer, "%s%s %s\n", logIndent, tsColor.Sprint(t.Format("15:04:05.000")), message) + } else { + message = strings.ReplaceAll(message, "\n", "\n"+logIndent) + fmt.Fprintf(writer, "%s%s\n", logIndent, message) + } + } + } else { + fmt.Fprintf(writer, "%s%s\n", logIndent, dim.Sprint("(no logs)")) + } + } + } else { + fmt.Fprintf(writer, "\n %s\n", dim.Sprint("No steps found")) + } + + fmt.Fprintf(writer, "\n") + } +} + +func getJobDisplayName(job interface{}, fallbackID string) string { + if j, ok := job.(interface{ GetName() string }); ok { + if name := j.GetName(); name != "" { + return name + } + } + if j, ok := job.(interface{ GetId() string }); ok { + if id := j.GetId(); id != "" { + return id + } + } + return fallbackID +} From d32972b1af9eb6508faefc08926749aa57f51137 Mon Sep 17 00:00:00 2001 From: Mihai Chitic Date: Thu, 12 Mar 2026 19:22:39 +0200 Subject: [PATCH 4/8] [ND-7649] - make pipeline commands to use the API workflow endpoints --- pkg/api/pipeline/item.go | 6 +-- pkg/api/pipeline/list.go | 8 +-- pkg/formatter/stylish.go | 8 +-- pkg/formatter/stylish.pipeline.go | 39 +++++++------- pkg/progress/event.go | 4 +- pkg/progress/pipeline.go | 2 +- pkg/progress/progress.pipeline.go | 87 ++++++------------------------- 7 files changed, 47 insertions(+), 107 deletions(-) diff --git a/pkg/api/pipeline/item.go b/pkg/api/pipeline/item.go index 1694825..82c01d4 100644 --- a/pkg/api/pipeline/item.go +++ b/pkg/api/pipeline/item.go @@ -13,7 +13,7 @@ func NewItemOptions(id string) *common.ItemOptions { return common.NewItemOptions(id) } -func Get(options *common.ItemOptions) (*sdk.PipelineItem, error) { +func Get(options *common.ItemOptions) (*sdk.WorkflowItem, error) { model, resp, err := GetRaw(options) if err != nil { return nil, api.ParseError(resp, err) @@ -22,13 +22,13 @@ func Get(options *common.ItemOptions) (*sdk.PipelineItem, error) { return model, nil } -func GetRaw(options *common.ItemOptions) (*sdk.PipelineItem, *http.Response, error) { +func GetRaw(options *common.ItemOptions) (*sdk.WorkflowItem, *http.Response, error) { profile := options.GetProfile() ctx, cancel := lib.GetContextFromProfile(profile) defer cancel() - request := lib.GetAPIFromProfile(profile).PipelineAPI.PipelineView(ctx, options.ID) + request := lib.GetAPIFromProfile(profile).WorkflowAPI.WorkflowView(ctx, options.ID) return request.Execute() } diff --git a/pkg/api/pipeline/list.go b/pkg/api/pipeline/list.go index 1487812..31e4d79 100644 --- a/pkg/api/pipeline/list.go +++ b/pkg/api/pipeline/list.go @@ -33,7 +33,7 @@ func (lo *ListOptions) UpdateFlagSet(flags *pflag.FlagSet) { lo.ListOptions.UpdateFlagSet(flags) } -func List(options *ListOptions) (*sdk.PaginatedPipelineCollection, error) { +func List(options *ListOptions) (*sdk.PaginatedWorkflowCollection, error) { model, resp, err := ListRaw(options) if err != nil { return nil, api.ParseError(resp, err) @@ -42,18 +42,18 @@ func List(options *ListOptions) (*sdk.PaginatedPipelineCollection, error) { return model, nil } -func ListRaw(options *ListOptions) (*sdk.PaginatedPipelineCollection, *http.Response, error) { +func ListRaw(options *ListOptions) (*sdk.PaginatedWorkflowCollection, *http.Response, error) { profile := options.GetProfile() ctx, cancel := lib.GetContextFromProfile(profile) defer cancel() - request := lib.GetAPIFromProfile(profile).PipelineAPI.PipelineList(ctx) + request := lib.GetAPIFromProfile(profile).WorkflowAPI.WorkflowList(ctx) return applyOptions(request, options).Execute() } -func applyOptions(request sdk.ApiPipelineListRequest, options *ListOptions) sdk.ApiPipelineListRequest { +func applyOptions(request sdk.ApiWorkflowListRequest, options *ListOptions) sdk.ApiWorkflowListRequest { if options == nil { return request } diff --git a/pkg/formatter/stylish.go b/pkg/formatter/stylish.go index bade35a..b29eb90 100644 --- a/pkg/formatter/stylish.go +++ b/pkg/formatter/stylish.go @@ -36,8 +36,8 @@ func stylish(data interface{}) ([]byte, error) { tabulateProjectVariableCollection(writer, dataType) case *sdk.PaginatedKubernetesIntegrationCollection: tabulateKubernetesCollection(writer, dataType) - case *sdk.PaginatedPipelineCollection: - tabulatePipelineCollection(writer, dataType) + case *sdk.PaginatedWorkflowCollection: + tabulateWorkflowCollection(writer, dataType) case *sdk.PaginatedWorkflowJobCollection: tabulateWorkflowJobCollection(writer, dataType) case []WorkflowJobLogsResult: @@ -78,8 +78,8 @@ func stylish(data interface{}) ([]byte, error) { tabulateKubernetesItem(writer, dataType) case *sdk.RegistryIntegrationItem: tabulateRegistryIntegrationItem(writer, dataType) - case *sdk.PipelineItem: - tabulatePipelineItem(writer, dataType) + case *sdk.WorkflowItem: + tabulateWorkflowItem(writer, dataType) case *sdk.ComponentGitItem: tabulateComponentGitItem(writer, dataType) case *sdk.SecretDecryptedItem: diff --git a/pkg/formatter/stylish.pipeline.go b/pkg/formatter/stylish.pipeline.go index eea07dd..d39a05c 100644 --- a/pkg/formatter/stylish.pipeline.go +++ b/pkg/formatter/stylish.pipeline.go @@ -8,51 +8,48 @@ import ( "bunnyshell.com/sdk" ) -func tabulatePipelineCollection(writer *tabwriter.Writer, data *sdk.PaginatedPipelineCollection) { - fmt.Fprintf(writer, "%v\t %v\t %v\t %v\t %v\n", "PipelineID", "EnvironmentID", "OrganizationID", "Description", "Status") +func formatStartedAt(t time.Time) string { + if t.IsZero() { + return "" + } + + return t.UTC().Format(time.RFC3339) +} + +func tabulateWorkflowCollection(writer *tabwriter.Writer, data *sdk.PaginatedWorkflowCollection) { + fmt.Fprintf(writer, "%v\t %v\t %v\t %v\t %v\t %v\t %v\n", "PipelineID", "EventID", "EnvironmentID", "OrganizationID", "Description", "Status", "StartedAt") if data.Embedded != nil { for _, item := range data.Embedded.Item { fmt.Fprintf( writer, - "%v\t %v\t %v\t %v\t %v\n", + "%v\t %v\t %v\t %v\t %v\t %v\t %v\n", item.GetId(), + item.GetEvent(), item.GetEnvironment(), item.GetOrganization(), item.GetDescription(), item.GetStatus(), + formatStartedAt(item.GetStartedAt()), ) } } } -func tabulatePipelineItem(writer *tabwriter.Writer, item *sdk.PipelineItem) { +func tabulateWorkflowItem(writer *tabwriter.Writer, item *sdk.WorkflowItem) { hasWebUrl := item.GetWebUrl() != "" fmt.Fprintf(writer, "%v\t %v\n", "PipelineID", item.GetId()) fmt.Fprintf(writer, "%v\t %v\n", "EnvironmentID", item.GetEnvironment()) fmt.Fprintf(writer, "%v\t %v\n", "OrganizationID", item.GetOrganization()) + fmt.Fprintf(writer, "%v\t %v\n", "EventID", item.GetEvent()) fmt.Fprintf(writer, "%v\t %v\n", "Description", item.GetDescription()) fmt.Fprintf(writer, "%v\t %v\n", "Status", item.GetStatus()) + fmt.Fprintf(writer, "%v\t %v\n", "StartedAt", formatStartedAt(item.GetStartedAt())) + fmt.Fprintf(writer, "%v\t %v\n", "Jobs", fmt.Sprintf("%d/%d completed", item.GetCompletedJobsCount(), item.GetJobsCount())) + fmt.Fprintf(writer, "%v\t %v\n", "Duration", time.Duration(item.GetDuration())*time.Second) if hasWebUrl { fmt.Fprintf(writer, "%v\t %v\n", "URL", item.GetWebUrl()) } - - for index, stage := range item.GetStages() { - if index == 0 { - fmt.Fprintf(writer, "\n") - fmt.Fprintf(writer, "%v\t %v\t %v\t %v\t %v\t %v\n", "Stages", "Name", "Duration", "Jobs", "JobsDone", "Status") - } - - fmt.Fprintf( - writer, - "\t %v\t %v\t %v\t %v\t %v\n", - stage.GetName(), - time.Duration(stage.GetDuration())*time.Second, - stage.GetJobsCount(), - stage.GetCompletedJobsCount(), - stage.GetStatus(), - ) - } } diff --git a/pkg/progress/event.go b/pkg/progress/event.go index 7f90633..9942859 100644 --- a/pkg/progress/event.go +++ b/pkg/progress/event.go @@ -8,7 +8,7 @@ import ( "bunnyshell.com/sdk" ) -func EventToPipeline(event *sdk.EventItem, options *Options) (*sdk.PipelineItem, error) { +func EventToPipeline(event *sdk.EventItem, options *Options) (*sdk.WorkflowItem, error) { resume := net.PauseSpinner() defer resume() @@ -25,7 +25,7 @@ func EventToPipeline(event *sdk.EventItem, options *Options) (*sdk.PipelineItem, return handlePipeline(event, options) } -func handlePipeline(event *sdk.EventItem, options *Options) (*sdk.PipelineItem, error) { +func handlePipeline(event *sdk.EventItem, options *Options) (*sdk.WorkflowItem, error) { listOptions := pipeline.NewListOptions() listOptions.Event = event.GetId() diff --git a/pkg/progress/pipeline.go b/pkg/progress/pipeline.go index b062f59..283b3dc 100644 --- a/pkg/progress/pipeline.go +++ b/pkg/progress/pipeline.go @@ -29,7 +29,7 @@ func progress(options Options, generate PipelineSyncer) error { func generatorFromID(id string) PipelineSyncer { itemOptions := pipeline.NewItemOptions(id) - return func() (*sdk.PipelineItem, error) { + return func() (*sdk.WorkflowItem, error) { return pipeline.Get(itemOptions) } } diff --git a/pkg/progress/progress.pipeline.go b/pkg/progress/progress.pipeline.go index 487bdd5..9cc15c3 100644 --- a/pkg/progress/progress.pipeline.go +++ b/pkg/progress/progress.pipeline.go @@ -8,14 +8,12 @@ import ( "github.com/briandowns/spinner" ) -type PipelineSyncer func() (*sdk.PipelineItem, error) +type PipelineSyncer func() (*sdk.WorkflowItem, error) type Progress struct { Options Options spinner *spinner.Spinner - - stages map[string]bool } type Options struct { @@ -39,18 +37,17 @@ func NewPipeline(options Options) *Progress { Options: options, spinner: spinner, - stages: map[string]bool{}, } } func (p *Progress) Update(pipelineSync PipelineSyncer) error { for { - pipeline, err := pipelineSync() + workflow, err := pipelineSync() if err != nil { return err } - waiting, err := p.UpdatePipeline(pipeline) + waiting, err := p.UpdatePipeline(workflow) if err != nil { return err } @@ -63,25 +60,19 @@ func (p *Progress) Update(pipelineSync PipelineSyncer) error { } } -func (p *Progress) UpdatePipeline(pipeline *sdk.PipelineItem) (bool, error) { - if pipeline == nil { +func (p *Progress) UpdatePipeline(workflow *sdk.WorkflowItem) (bool, error) { + if workflow == nil { return false, nil } - p.spinner.Prefix = "Processing Pipeline " - - for _, stage := range pipeline.GetStages() { - switch p.setStage(stage) { - case Success: - continue - case Failed: - return false, ErrPipeline - case Synced: - return true, nil - } - } + p.spinner.Prefix = fmt.Sprintf( + "%s Processing... %d/%d jobs completed ", + statusMap[p.getState(workflow.GetStatus())], + workflow.GetCompletedJobsCount(), + workflow.GetJobsCount(), + ) - switch pipeline.GetStatus() { + switch workflow.GetStatus() { case StatusInProgress, StatusPending: return true, nil case StatusSuccess: @@ -89,7 +80,7 @@ func (p *Progress) UpdatePipeline(pipeline *sdk.PipelineItem) (bool, error) { case StatusFailed: return false, ErrPipeline default: - return false, fmt.Errorf("%w: unknown status %s", ErrPipeline, pipeline.GetStatus()) + return false, fmt.Errorf("%w: unknown status %s", ErrPipeline, workflow.GetStatus()) } } @@ -101,56 +92,8 @@ func (p *Progress) Stop() { p.spinner.Stop() } -func (p *Progress) setStage(stage sdk.StageItem) UpdateStatus { - if stage.GetStatus() == StatusFailed { - p.finishStage(stage) - - return Failed - } - - if stage.GetStatus() == StatusSuccess { - p.finishStage(stage) - - return Success - } - - p.syncStage(stage) - - return Synced -} - -func (p *Progress) finishStage(stage sdk.StageItem) { - if p.stages[stage.GetId()] { - return - } - - p.stages[stage.GetId()] = true - - p.spinner.FinalMSG = fmt.Sprintf( - "%s %s finished %d jobs in %s\n", - statusMap[p.getState(stage)], - stage.GetName(), - stage.GetJobsCount(), - time.Duration(stage.GetDuration())*time.Second, - ) - - p.spinner.Restart() - - p.spinner.FinalMSG = "" -} - -func (p *Progress) syncStage(stage sdk.StageItem) { - p.spinner.Prefix = fmt.Sprintf( - "%s %s... %d/%d jobs completed ", - statusMap[p.getState(stage)], - stage.GetName(), - stage.GetCompletedJobsCount(), - stage.GetJobsCount(), - ) -} - -func (p *Progress) getState(stage sdk.StageItem) PipelineStatus { - switch stage.GetStatus() { +func (p *Progress) getState(status string) PipelineStatus { + switch status { case StatusSuccess: return PipelineFinished case StatusInProgress, StatusPending: From 39efd50b4b28256c46b693411c9ce5ef98ef80bb Mon Sep 17 00:00:00 2001 From: Mihai Chitic Date: Fri, 13 Mar 2026 10:52:20 +0200 Subject: [PATCH 5/8] [ND-7649] - add --sort for pipelines and events listing --- cmd/pipeline/logs.go | 11 ++++++++++- pkg/api/event/list.go | 35 +++++++++++++++++++++++++++++++---- pkg/api/pipeline/list.go | 35 +++++++++++++++++++++++++++++++---- 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/cmd/pipeline/logs.go b/cmd/pipeline/logs.go index ec2c17d..2877a30 100644 --- a/cmd/pipeline/logs.go +++ b/cmd/pipeline/logs.go @@ -22,6 +22,15 @@ func init() { Short: "View logs from pipeline jobs", Long: "View logs from pipeline jobs and job steps with optional filtering by job and step status", + Example: ` # Logs by explicit pipeline ID + bns pipeline logs --id + + # Logs for the latest pipeline in an environment + bns pipeline logs --id "$(bns pipeline list --environment --sort=createdAt:desc -o json | jq -r '._embedded.item[0].id')" + + # Logs for the failed jobs and steps in a pipeline + bns pipeline logs --id --job-status failed --step-status failed +`, ValidArgsFunction: cobra.NoFileCompletions, @@ -80,7 +89,7 @@ func init() { flags := command.Flags() flags.AddFlag(getIDOption(&pipelineID).GetRequiredFlag("id")) - + jobStatusValues := strings.Join([]string{ wfstatus.JobPending, wfstatus.JobQueued, wfstatus.JobInProgress, wfstatus.JobFailed, wfstatus.JobAbortFailed, wfstatus.JobSuccess, diff --git a/pkg/api/event/list.go b/pkg/api/event/list.go index 009fcaa..72c3e5e 100644 --- a/pkg/api/event/list.go +++ b/pkg/api/event/list.go @@ -1,7 +1,9 @@ package event import ( + "fmt" "net/http" + "strings" "bunnyshell.com/cli/pkg/api" "bunnyshell.com/cli/pkg/api/common" @@ -18,6 +20,7 @@ type ListOptions struct { Type string Status string + Sort []string } func NewListOptions() *ListOptions { @@ -29,6 +32,7 @@ func NewListOptions() *ListOptions { func (lo *ListOptions) UpdateFlagSet(flags *pflag.FlagSet) { flags.StringVar(&lo.Type, "type", lo.Type, "Filter by Type") flags.StringVar(&lo.Status, "status", lo.Status, "Filter by Status") + flags.StringArrayVar(&lo.Sort, "sort", lo.Sort, "Sort by field and direction (repeatable); format: createdAt:asc|desc") lo.ListOptions.UpdateFlagSet(flags) } @@ -50,12 +54,17 @@ func ListRaw(options *ListOptions) (*sdk.PaginatedEventCollection, *http.Respons request := lib.GetAPIFromProfile(profile).EventAPI.EventList(ctx) - return applyOptions(request, options).Execute() + request, err := applyOptions(request, options) + if err != nil { + return nil, nil, err + } + + return request.Execute() } -func applyOptions(request sdk.ApiEventListRequest, options *ListOptions) sdk.ApiEventListRequest { +func applyOptions(request sdk.ApiEventListRequest, options *ListOptions) (sdk.ApiEventListRequest, error) { if options == nil { - return request + return request, nil } if options.Page > 1 { @@ -78,5 +87,23 @@ func applyOptions(request sdk.ApiEventListRequest, options *ListOptions) sdk.Api request = request.Status(options.Status) } - return request + for _, sortValue := range options.Sort { + field, direction, found := strings.Cut(sortValue, ":") + if !found || field == "" || direction == "" { + return request, fmt.Errorf(`invalid sort value %q, expected format "createdAt:asc|desc"`, sortValue) + } + + if field != "createdAt" { + return request, fmt.Errorf(`unsupported sort field %q, supported fields: createdAt`, field) + } + + switch strings.ToLower(direction) { + case "asc", "desc": + request = request.OrderCreatedAt(strings.ToLower(direction)) + default: + return request, fmt.Errorf(`unsupported sort direction %q, supported directions: asc, desc`, direction) + } + } + + return request, nil } diff --git a/pkg/api/pipeline/list.go b/pkg/api/pipeline/list.go index 31e4d79..f3fa5c7 100644 --- a/pkg/api/pipeline/list.go +++ b/pkg/api/pipeline/list.go @@ -1,7 +1,9 @@ package pipeline import ( + "fmt" "net/http" + "strings" "bunnyshell.com/cli/pkg/api" "bunnyshell.com/cli/pkg/api/common" @@ -18,6 +20,7 @@ type ListOptions struct { Event string Status string + Sort []string } func NewListOptions() *ListOptions { @@ -29,6 +32,7 @@ func NewListOptions() *ListOptions { func (lo *ListOptions) UpdateFlagSet(flags *pflag.FlagSet) { flags.StringVar(&lo.Event, "event", lo.Event, "Filter by EventID") flags.StringVar(&lo.Status, "status", lo.Status, "Filter by Status") + flags.StringArrayVar(&lo.Sort, "sort", lo.Sort, "Sort by field and direction (repeatable); format: createdAt:asc|desc") lo.ListOptions.UpdateFlagSet(flags) } @@ -50,12 +54,17 @@ func ListRaw(options *ListOptions) (*sdk.PaginatedWorkflowCollection, *http.Resp request := lib.GetAPIFromProfile(profile).WorkflowAPI.WorkflowList(ctx) - return applyOptions(request, options).Execute() + request, err := applyOptions(request, options) + if err != nil { + return nil, nil, err + } + + return request.Execute() } -func applyOptions(request sdk.ApiWorkflowListRequest, options *ListOptions) sdk.ApiWorkflowListRequest { +func applyOptions(request sdk.ApiWorkflowListRequest, options *ListOptions) (sdk.ApiWorkflowListRequest, error) { if options == nil { - return request + return request, nil } if options.Page > 1 { @@ -78,5 +87,23 @@ func applyOptions(request sdk.ApiWorkflowListRequest, options *ListOptions) sdk. request = request.Status(options.Status) } - return request + for _, sortValue := range options.Sort { + field, direction, found := strings.Cut(sortValue, ":") + if !found || field == "" || direction == "" { + return request, fmt.Errorf(`invalid sort value %q, expected format "createdAt:asc|desc"`, sortValue) + } + + if field != "createdAt" { + return request, fmt.Errorf(`unsupported sort field %q, supported fields: createdAt`, field) + } + + switch strings.ToLower(direction) { + case "asc", "desc": + request = request.OrderCreatedAt(strings.ToLower(direction)) + default: + return request, fmt.Errorf(`unsupported sort direction %q, supported directions: asc, desc`, direction) + } + } + + return request, nil } From 71827c095161ea98245cb17549f5dccc3b9cf6c8 Mon Sep 17 00:00:00 2001 From: Mihai Chitic Date: Fri, 13 Mar 2026 11:57:05 +0200 Subject: [PATCH 6/8] [ND-7649] - fix pipeline monitor when staus is aborted --- pkg/api/pipeline/status/status.go | 15 +++++++++++++++ pkg/api/workflow_job/status/status.go | 20 +++----------------- pkg/formatter/stylish.workflow_job_logs.go | 2 +- pkg/progress/progress.pipeline.go | 17 +++++++++++------ pkg/progress/vars.go | 10 +++------- 5 files changed, 33 insertions(+), 31 deletions(-) create mode 100644 pkg/api/pipeline/status/status.go diff --git a/pkg/api/pipeline/status/status.go b/pkg/api/pipeline/status/status.go new file mode 100644 index 0000000..046fc0f --- /dev/null +++ b/pkg/api/pipeline/status/status.go @@ -0,0 +1,15 @@ +package status + +// Workflow status values. +const ( + WorkflowQueued = "queued" + WorkflowThrottled = "throttled" + WorkflowInProgress = "in_progress" + WorkflowSuccess = "success" + + WorkflowFailing = "failing" + WorkflowFailed = "failed" + + WorkflowAborting = "aborting" + WorkflowAborted = "aborted" +) diff --git a/pkg/api/workflow_job/status/status.go b/pkg/api/workflow_job/status/status.go index 671df04..5b734aa 100644 --- a/pkg/api/workflow_job/status/status.go +++ b/pkg/api/workflow_job/status/status.go @@ -1,24 +1,10 @@ package status -// Workflow status values. -const ( - WorkflowQueued = "queued" - WorkflowThrottled = "throttled" - WorkflowInProgress = "in_progress" - WorkflowSuccess = "success" - - WorkflowFailing = "failing" - WorkflowFailed = "failed" - - WorkflowAborting = "aborting" - WorkflowAborted = "aborted" -) - // Job status values. const ( - JobPending = "pending" - JobQueued = "queued" - JobInProgress = "in_progress" + JobPending = "pending" + JobQueued = "queued" + JobInProgress = "in_progress" JobFailed = "failed" JobAbortFailed = "abort_failed" diff --git a/pkg/formatter/stylish.workflow_job_logs.go b/pkg/formatter/stylish.workflow_job_logs.go index 53a4156..8f0566e 100644 --- a/pkg/formatter/stylish.workflow_job_logs.go +++ b/pkg/formatter/stylish.workflow_job_logs.go @@ -26,7 +26,7 @@ func statusIcon(status string) string { case wfstatus.JobFailed, wfstatus.JobAbortFailed: // JobFailed same value as StepFailed return color.New(color.FgRed).Sprint("✘") case wfstatus.JobAborting, wfstatus.JobAborted: - return color.New(color.FgRed).Sprint("⊘") + return color.New(color.FgRed, color.Bold).Sprint("⊘") case wfstatus.JobInProgress: return color.New(color.FgCyan).Sprint("▶︎") case wfstatus.JobPending, wfstatus.JobQueued: diff --git a/pkg/progress/progress.pipeline.go b/pkg/progress/progress.pipeline.go index 9cc15c3..699756d 100644 --- a/pkg/progress/progress.pipeline.go +++ b/pkg/progress/progress.pipeline.go @@ -4,6 +4,7 @@ import ( "fmt" "time" + pstatus "bunnyshell.com/cli/pkg/api/pipeline/status" "bunnyshell.com/sdk" "github.com/briandowns/spinner" ) @@ -73,12 +74,14 @@ func (p *Progress) UpdatePipeline(workflow *sdk.WorkflowItem) (bool, error) { ) switch workflow.GetStatus() { - case StatusInProgress, StatusPending: + case pstatus.WorkflowInProgress, pstatus.WorkflowAborting, pstatus.WorkflowFailing, pstatus.WorkflowThrottled, pstatus.WorkflowQueued: return true, nil - case StatusSuccess: + case pstatus.WorkflowSuccess: return false, nil - case StatusFailed: + case pstatus.WorkflowFailed: return false, ErrPipeline + case pstatus.WorkflowAborted: + return false, ErrPipelineAborted default: return false, fmt.Errorf("%w: unknown status %s", ErrPipeline, workflow.GetStatus()) } @@ -94,12 +97,14 @@ func (p *Progress) Stop() { func (p *Progress) getState(status string) PipelineStatus { switch status { - case StatusSuccess: + case pstatus.WorkflowSuccess: return PipelineFinished - case StatusInProgress, StatusPending: + case pstatus.WorkflowInProgress, pstatus.WorkflowAborting, pstatus.WorkflowFailing, pstatus.WorkflowThrottled, pstatus.WorkflowQueued: return PipelineWorking - case StatusFailed: + case pstatus.WorkflowFailed: return PipelineFailed + case pstatus.WorkflowAborted: + return PipelineAborted } return PipelineUnknownState diff --git a/pkg/progress/vars.go b/pkg/progress/vars.go index 4f1dde4..e558c56 100644 --- a/pkg/progress/vars.go +++ b/pkg/progress/vars.go @@ -24,16 +24,10 @@ const ( PipelineWorking PipelineStatus = iota PipelineFinished PipelineFailed + PipelineAborted PipelineUnknownState ) -const ( - StatusSuccess = "success" - StatusFailed = "failed" - StatusInProgress = "in_progress" - StatusPending = "pending" -) - const ( defaultSpinnerUpdate = 2000 * time.Millisecond defaultProgressSet = 69 // ∙∙● @@ -43,7 +37,9 @@ var statusMap = map[PipelineStatus]string{ PipelineWorking: color.New(color.FgCyan).Sprintf("»"), PipelineFinished: color.New(color.FgGreen).Sprintf("✔"), PipelineFailed: color.New(color.FgRed).Sprintf("✘"), + PipelineAborted: color.New(color.FgRed, color.Bold).Sprintf("⊘"), PipelineUnknownState: color.New(color.FgYellow).Sprintf("?"), } var ErrPipeline = errors.New("pipeline has encountered an error") +var ErrPipelineAborted = errors.New("pipeline was aborted") From 8e5aaade26ee5a7069ddf70ad522e6bed35d9b03 Mon Sep 17 00:00:00 2001 From: Mihai Chitic Date: Fri, 13 Mar 2026 12:11:29 +0200 Subject: [PATCH 7/8] [ND-7649] - revise commands aliases --- cmd/component/root.go | 2 +- cmd/component/variable/root.go | 2 +- cmd/configure/root.go | 3 ++- cmd/environment/root.go | 2 +- cmd/event/root.go | 3 ++- cmd/organization/root.go | 2 +- cmd/pipeline/root.go | 4 ++-- cmd/project/root.go | 2 +- cmd/project_variable/root.go | 2 +- cmd/registry_integration/root.go | 2 +- cmd/secret/root.go | 2 +- cmd/template/root.go | 2 +- cmd/variable/root.go | 2 +- 13 files changed, 16 insertions(+), 14 deletions(-) diff --git a/cmd/component/root.go b/cmd/component/root.go index 8a75266..50d1eee 100644 --- a/cmd/component/root.go +++ b/cmd/component/root.go @@ -10,7 +10,7 @@ import ( var mainCmd = &cobra.Command{ Use: "components", - Aliases: []string{"comp"}, + Aliases: []string{"component", "comp", "comps"}, Short: "Components", Long: "Bunnyshell Components", diff --git a/cmd/component/variable/root.go b/cmd/component/variable/root.go index 413d2c8..1df2094 100644 --- a/cmd/component/variable/root.go +++ b/cmd/component/variable/root.go @@ -9,7 +9,7 @@ import ( var mainCmd = &cobra.Command{ Use: "variables", - Aliases: []string{"vars"}, + Aliases: []string{"variable", "var", "vars"}, Short: "Component Variables", } diff --git a/cmd/configure/root.go b/cmd/configure/root.go index d456a4a..440485a 100644 --- a/cmd/configure/root.go +++ b/cmd/configure/root.go @@ -6,7 +6,8 @@ import ( ) var mainCmd = &cobra.Command{ - Use: "configure", + Use: "configure", + Aliases: []string{"config"}, Short: "Configure CLI settings", } diff --git a/cmd/environment/root.go b/cmd/environment/root.go index 33314d5..7650df7 100644 --- a/cmd/environment/root.go +++ b/cmd/environment/root.go @@ -14,7 +14,7 @@ var mainGroup = &cobra.Group{ var mainCmd = &cobra.Command{ Use: "environments", - Aliases: []string{"env"}, + Aliases: []string{"environment", "env", "envs"}, Short: "Environments", Long: "Bunnyshell Environments", diff --git a/cmd/event/root.go b/cmd/event/root.go index 1132001..9e0922d 100644 --- a/cmd/event/root.go +++ b/cmd/event/root.go @@ -6,7 +6,8 @@ import ( ) var mainCmd = &cobra.Command{ - Use: "events", + Use: "events", + Aliases: []string{"event"}, Short: "Events", Long: "Bunnyshell Events", diff --git a/cmd/organization/root.go b/cmd/organization/root.go index b223311..9744b3f 100644 --- a/cmd/organization/root.go +++ b/cmd/organization/root.go @@ -7,7 +7,7 @@ import ( var mainCmd = &cobra.Command{ Use: "organizations", - Aliases: []string{"org"}, + Aliases: []string{"organization", "org", "orgs"}, Short: "Organizations", Long: "Bunnyshell Organizations", diff --git a/cmd/pipeline/root.go b/cmd/pipeline/root.go index ea2423b..44aacdf 100644 --- a/cmd/pipeline/root.go +++ b/cmd/pipeline/root.go @@ -10,8 +10,8 @@ import ( ) var mainCmd = &cobra.Command{ - Use: "pipeline", - Aliases: []string{"pipe"}, + Use: "pipelines", + Aliases: []string{"pipeline", "pipe", "pipes"}, Short: "Pipeline", Long: "Bunnyshell Pipeline", diff --git a/cmd/project/root.go b/cmd/project/root.go index f07b9bf..707b613 100644 --- a/cmd/project/root.go +++ b/cmd/project/root.go @@ -9,7 +9,7 @@ import ( var mainCmd = &cobra.Command{ Use: "projects", - Aliases: []string{"proj"}, + Aliases: []string{"project", "proj", "projs"}, Short: "Projects", Long: "Bunnyshell Projects", diff --git a/cmd/project_variable/root.go b/cmd/project_variable/root.go index 49980da..a704d1c 100644 --- a/cmd/project_variable/root.go +++ b/cmd/project_variable/root.go @@ -9,7 +9,7 @@ import ( var mainCmd = &cobra.Command{ Use: "project-variables", - Aliases: []string{"pvar"}, + Aliases: []string{"project-variable", "pvar", "pvars"}, Short: "Project Variables", Long: "Bunnyshell Project Variables", diff --git a/cmd/registry_integration/root.go b/cmd/registry_integration/root.go index 4200735..e988add 100644 --- a/cmd/registry_integration/root.go +++ b/cmd/registry_integration/root.go @@ -7,7 +7,7 @@ import ( var mainCmd = &cobra.Command{ Use: "container-registries", - Aliases: []string{"creg"}, + Aliases: []string{"container-registry", "creg", "cregs"}, Short: "Container Registry Integrations", Long: "Bunnyshell Container Registry Integrations", diff --git a/cmd/secret/root.go b/cmd/secret/root.go index 1770b3d..8e91177 100644 --- a/cmd/secret/root.go +++ b/cmd/secret/root.go @@ -7,7 +7,7 @@ import ( var mainCmd = &cobra.Command{ Use: "secrets", - Aliases: []string{"sec"}, + Aliases: []string{"secret", "sec"}, Short: "Secrets", Long: "Bunnyshell Secrets", diff --git a/cmd/template/root.go b/cmd/template/root.go index d394952..4bf7cc9 100644 --- a/cmd/template/root.go +++ b/cmd/template/root.go @@ -13,7 +13,7 @@ import ( var mainCmd = &cobra.Command{ Use: "templates", - Aliases: []string{"tpl"}, + Aliases: []string{"template", "tpl", "tpls"}, Short: "Template", Long: "Bunnyshell Template", diff --git a/cmd/variable/root.go b/cmd/variable/root.go index 8e3626b..be28af1 100644 --- a/cmd/variable/root.go +++ b/cmd/variable/root.go @@ -9,7 +9,7 @@ import ( var mainCmd = &cobra.Command{ Use: "variables", - Aliases: []string{"var"}, + Aliases: []string{"variable", "var", "vars"}, Short: "Environment Variables", Long: "Bunnyshell Environment Variables", From 935d984ed0e92980352942488191dd385d1846b6 Mon Sep 17 00:00:00 2001 From: Mihai Chitic Date: Fri, 13 Mar 2026 12:54:54 +0200 Subject: [PATCH 8/8] [ND-7649][ND-7648] - implement env abort --- cmd/environment/action/abort.go | 51 +++++++++++++++++++++++++++++ pkg/api/environment/action_abort.go | 44 +++++++++++++++++++++++++ pkg/formatter/stylish.go | 13 ++++++-- 3 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 cmd/environment/action/abort.go create mode 100644 pkg/api/environment/action_abort.go diff --git a/cmd/environment/action/abort.go b/cmd/environment/action/abort.go new file mode 100644 index 0000000..5d80b2f --- /dev/null +++ b/cmd/environment/action/abort.go @@ -0,0 +1,51 @@ +package action + +import ( + "net/http" + + "bunnyshell.com/cli/pkg/api/environment" + "bunnyshell.com/cli/pkg/config" + "bunnyshell.com/cli/pkg/lib" + "github.com/spf13/cobra" +) + +func init() { + options := config.GetOptions() + settings := config.GetSettings() + + abortOptions := environment.NewAbortOptions("") + + command := &cobra.Command{ + Use: "abort", + + ValidArgsFunction: cobra.NoFileCompletions, + + RunE: func(cmd *cobra.Command, args []string) error { + abortOptions.ID = settings.Profile.Context.Environment + + event, err := environment.Abort(abortOptions) + if err != nil { + return lib.FormatCommandError(cmd, err) + } + + if event != nil { + return lib.FormatCommandData(cmd, event) + } + + if settings.IsStylish() { + cmd.Println("Nothing to abort") + return nil + } + + return lib.FormatCommandData(cmd, map[string]interface{}{ + "status": http.StatusNoContent, + "detail": "Nothing to abort", + }) + }, + } + + flags := command.Flags() + flags.AddFlag(options.Environment.GetRequiredFlag("id")) + + mainCmd.AddCommand(command) +} diff --git a/pkg/api/environment/action_abort.go b/pkg/api/environment/action_abort.go new file mode 100644 index 0000000..4110a5e --- /dev/null +++ b/pkg/api/environment/action_abort.go @@ -0,0 +1,44 @@ +package environment + +import ( + "net/http" + + "bunnyshell.com/cli/pkg/api" + "bunnyshell.com/cli/pkg/api/common" + "bunnyshell.com/cli/pkg/lib" + "bunnyshell.com/sdk" +) + +type AbortOptions struct { + common.ItemOptions +} + +func NewAbortOptions(id string) *AbortOptions { + return &AbortOptions{ + ItemOptions: *common.NewItemOptions(id), + } +} + +func Abort(options *AbortOptions) (*sdk.EventItem, error) { + model, resp, err := AbortRaw(options) + if resp != nil && resp.StatusCode == http.StatusNoContent { + return nil, nil + } + + if err != nil { + return nil, api.ParseError(resp, err) + } + + return model, nil +} + +func AbortRaw(options *AbortOptions) (*sdk.EventItem, *http.Response, error) { + profile := options.GetProfile() + + ctx, cancel := lib.GetContextFromProfile(profile) + defer cancel() + + request := lib.GetAPIFromProfile(profile).EnvironmentAPI.EnvironmentAbort(ctx, options.ID) + + return request.Execute() +} diff --git a/pkg/formatter/stylish.go b/pkg/formatter/stylish.go index b29eb90..c2c738c 100644 --- a/pkg/formatter/stylish.go +++ b/pkg/formatter/stylish.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "text/tabwriter" + "time" "bunnyshell.com/cli/pkg/api" "bunnyshell.com/sdk" @@ -323,8 +324,8 @@ func tabulateEventItem(w *tabwriter.Writer, item *sdk.EventItem) { fmt.Fprintf(w, "%v\t %v\n", "OrganizationID", item.GetOrganization()) fmt.Fprintf(w, "%v\t %v\n", "Status", item.GetStatus()) fmt.Fprintf(w, "%v\t %v\n", "Type", item.GetType()) - fmt.Fprintf(w, "%v\t %v\n", "CreatedAt", item.GetCreatedAt()) - fmt.Fprintf(w, "%v\t %v\n", "UpdatedAt", item.GetUpdatedAt()) + fmt.Fprintf(w, "%v\t %v\n", "CreatedAt", formatStylishTimestamp(item.GetCreatedAt())) + fmt.Fprintf(w, "%v\t %v\n", "UpdatedAt", formatStylishTimestamp(item.GetUpdatedAt())) } func tabulateEnvironmentVariableItem(w *tabwriter.Writer, item *sdk.EnvironmentVariableItem) { @@ -380,3 +381,11 @@ func writeJSON(writer *tabwriter.Writer, data any) error { return err } + +func formatStylishTimestamp(value time.Time) string { + if value.IsZero() { + return "" + } + + return value.Format(time.RFC3339) +}