diff --git a/CLAUDE.md b/CLAUDE.md index bd16270..36a2c52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,22 @@ + +# OpenSpec Instructions + +These instructions are for AI assistants working in this project. + +Always open `@/openspec/AGENTS.md` when the request: +- Mentions planning or proposals (words like proposal, spec, change, plan) +- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work +- Sounds ambiguous and you need the authoritative spec before coding + +Use `@/openspec/AGENTS.md` to learn: +- How to create and apply change proposals +- Spec format and conventions +- Project structure and guidelines + +Keep this managed block so 'openspec update' can refresh the instructions. + + + # Bunnyshell CLI (bns) - Developer Guide This document provides a comprehensive overview of the Bunnyshell CLI codebase to help developers and AI assistants understand the project structure, architecture, and development patterns. diff --git a/cmd/pipeline/jobs.go b/cmd/pipeline/jobs.go new file mode 100644 index 0000000..28c76d0 --- /dev/null +++ b/cmd/pipeline/jobs.go @@ -0,0 +1,110 @@ +package pipeline + +import ( + "encoding/json" + "fmt" + "os" + "text/tabwriter" + "time" + + "bunnyshell.com/cli/pkg/api/workflow_job" + "bunnyshell.com/cli/pkg/config" + "bunnyshell.com/cli/pkg/util" + "bunnyshell.com/sdk" + "github.com/spf13/cobra" +) + +func init() { + var pipelineID string + var outputFormat string + + command := &cobra.Command{ + Use: "jobs", + + Short: "List jobs in a pipeline", + Long: `List all jobs within a pipeline, showing their ID, name, type, status, and duration. + +Use the job IDs from this output with 'bns pipeline logs --job JOB_ID' to view logs for a specific job. + +Examples: + # List jobs in a pipeline + bns pipeline jobs --id PIPELINE_ID + + # JSON output + bns pipeline jobs --id PIPELINE_ID --output json`, + + ValidArgsFunction: cobra.NoFileCompletions, + + RunE: func(cmd *cobra.Command, args []string) error { + profile := config.GetSettings().Profile + + spinner := util.MakeSpinner(" Fetching pipeline jobs...") + spinner.Start() + + jobIDs, err := getWorkflowJobs(pipelineID, profile) + if err != nil { + spinner.Stop() + return fmt.Errorf("failed to get jobs for pipeline %s: %w", pipelineID, err) + } + + var jobs []sdk.WorkflowJobItem + for _, jobID := range jobIDs { + info, err := workflow_job.GetJobInfo(profile, jobID) + if err != nil { + spinner.Stop() + return fmt.Errorf("failed to get info for job %s: %w", jobID, err) + } + jobs = append(jobs, *info) + } + + spinner.Stop() + + model := &workflow_job.WorkflowJobList{ + PipelineID: pipelineID, + Jobs: jobs, + } + + return outputJobList(model, outputFormat) + }, + } + + flags := command.Flags() + flags.AddFlag(getIDOption(&pipelineID).GetRequiredFlag("id")) + flags.StringVarP(&outputFormat, "output", "o", "stylish", "Output format: stylish, json") + + config.MainManager.CommandWithGlobalOptions(command) + + mainCmd.AddCommand(command) +} + +func outputJobList(data *workflow_job.WorkflowJobList, format string) error { + switch format { + case "json": + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(data) + case "stylish": + writer := tabwriter.NewWriter(os.Stdout, 1, 1, 2, ' ', 0) + + fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\n", "JobID", "Name", "Type", "Status", "Duration") + + for _, job := range data.Jobs { + duration := "-" + if job.HasDuration() { + duration = (time.Duration(job.GetDuration()) * time.Second).String() + } + + fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\n", + job.GetId(), + job.GetName(), + job.GetType(), + job.GetStatus(), + duration, + ) + } + + return writer.Flush() + default: + return fmt.Errorf("unknown output format: %s (use: stylish, json)", format) + } +} diff --git a/cmd/pipeline/logs.go b/cmd/pipeline/logs.go new file mode 100644 index 0000000..cb37416 --- /dev/null +++ b/cmd/pipeline/logs.go @@ -0,0 +1,384 @@ +package pipeline + +import ( + "fmt" + "os" + + "bunnyshell.com/cli/pkg/api/workflow" + "bunnyshell.com/cli/pkg/api/workflow_job" + "bunnyshell.com/cli/pkg/config" + "bunnyshell.com/cli/pkg/formatter/pipeline_logs" + "bunnyshell.com/cli/pkg/util" + "bunnyshell.com/sdk" + "github.com/spf13/cobra" +) + +func init() { + options := NewLogsOptions() + + command := &cobra.Command{ + Use: "logs", + Aliases: []string{"log"}, + + Short: "View pipeline logs for an environment", + Long: `View and stream logs from pipeline executions (build jobs, deployment steps). + +This command fetches logs from all jobs and displays them in a structured format. +Use --follow to stream logs in real-time for active pipelines. + +Examples: + # View latest pipeline logs (all jobs) + bns pipeline logs --environment ENV_ID + + # View only failed jobs + bns pipeline logs --environment ENV_ID --failed + + # View a specific pipeline (use 'bns pipeline list' to find IDs) + bns pipeline logs --id PIPELINE_ID + + # View a specific job within a pipeline + bns pipeline logs --job JOB_ID + + # Follow active pipeline logs + bns pipeline logs --environment ENV_ID --follow + + # View only specific step + bns pipeline logs --environment ENV_ID --step build + + # Show last 50 lines + bns pipeline logs --environment ENV_ID --tail 50 + + # JSON output for parsing + bns pipeline logs --environment ENV_ID --output json`, + + PreRunE: func(cmd *cobra.Command, args []string) error { + // Fall back to context if --environment not provided + if options.EnvironmentID == "" { + if ctx := config.GetSettings().Profile.Context; ctx.Environment != "" { + options.EnvironmentID = ctx.Environment + } + } + + // Environment ID not required when --job or --id is specified directly + if options.EnvironmentID == "" && options.JobID == "" && options.WorkflowID == "" { + return fmt.Errorf("environment required: provide --environment or set context with 'bns configure set-context --environment ID'") + } + + return nil + }, + + RunE: func(cmd *cobra.Command, args []string) error { + return runLogs(options) + }, + } + + // Add flags + command.Flags().StringVar(&options.EnvironmentID, "environment", "", "Environment ID") + command.Flags().BoolVarP(&options.Follow, "follow", "f", false, "Follow log output (stream in real-time)") + command.Flags().BoolVar(&options.Failed, "failed", false, "Show only failed jobs") + command.Flags().IntVar(&options.Tail, "tail", 0, "Show last N lines") + command.Flags().StringVar(&options.Step, "step", "", "Filter logs by step name") + command.Flags().StringVar(&options.JobID, "job", "", "Specific job ID (shows only that job)") + command.Flags().StringVar(&options.WorkflowID, "id", "", "Pipeline ID (use 'bns pipeline list' to find IDs)") + command.Flags().StringVarP(&options.OutputFormat, "output", "o", "stylish", "Output format: stylish, json, yaml, raw") + + // Add global options + config.MainManager.CommandWithGlobalOptions(command) + + mainCmd.AddCommand(command) +} + +type LogsOptions struct { + EnvironmentID string + WorkflowID string + JobID string + Follow bool + Failed bool + Tail int + Step string + OutputFormat string + + Profile config.Profile +} + +func NewLogsOptions() *LogsOptions { + return &LogsOptions{ + OutputFormat: "stylish", + } +} + +type workflowInfo struct { + WorkflowID string + JobIDs []string +} + +func runLogs(options *LogsOptions) error { + options.Profile = config.GetSettings().Profile + + // If a specific job ID is given, fetch only that job's logs (legacy single-job mode) + if options.JobID != "" { + return runSingleJobLogs(options) + } + + // Resolve which workflow to use + wfInfo, err := resolveWorkflow(options) + if err != nil { + return err + } + + // Fetch job metadata for all jobs in the workflow + spinner := util.MakeSpinner(" Fetching pipeline info...") + spinner.Start() + + var jobs []*sdk.WorkflowJobItem + for _, jobID := range wfInfo.JobIDs { + info, err := workflow_job.GetJobInfo(options.Profile, jobID) + if err != nil { + spinner.Stop() + return fmt.Errorf("failed to get info for job %s: %w", jobID, err) + } + jobs = append(jobs, info) + } + spinner.Stop() + + // Filter by --failed + if options.Failed { + var filtered []*sdk.WorkflowJobItem + for _, job := range jobs { + if job.GetStatus() == "failed" { + filtered = append(filtered, job) + } + } + if len(filtered) == 0 { + fmt.Fprintln(os.Stderr, "No failed jobs in this pipeline.") + fmt.Fprintf(os.Stderr, "Jobs: ") + for i, job := range jobs { + if i > 0 { + fmt.Fprintf(os.Stderr, ", ") + } + fmt.Fprintf(os.Stderr, "%s [%s]", job.GetName(), job.GetStatus()) + } + fmt.Fprintln(os.Stderr) + return nil + } + jobs = filtered + } + + // Fetch logs for each job + spinner = util.MakeSpinner(fmt.Sprintf(" Fetching logs for %d job(s)...", len(jobs))) + spinner.Start() + + pipelineLogs := &workflow_job.PipelineLogs{ + WorkflowID: wfInfo.WorkflowID, + } + + for _, job := range jobs { + logs, err := fetchJobLogs(options, job.GetId()) + if err != nil { + spinner.Stop() + return fmt.Errorf("failed to fetch logs for job %s (%s): %w", job.GetId(), job.GetName(), err) + } + logs.JobName = job.GetName() + + // Apply step filter per job + if options.Step != "" { + logs = filterByStep(logs, options.Step) + } + + pipelineLogs.Jobs = append(pipelineLogs.Jobs, *logs) + } + + spinner.Stop() + + // Apply tail across all jobs + if options.Tail > 0 { + pipelineLogs = tailPipelineLogs(pipelineLogs, options.Tail) + } + + return outputPipelineLogs(pipelineLogs, options.OutputFormat) +} + +// runSingleJobLogs handles the --job flag (single job mode) +func runSingleJobLogs(options *LogsOptions) error { + // Get job info for the name + info, err := workflow_job.GetJobInfo(options.Profile, options.JobID) + jobName := options.JobID + if err == nil { + jobName = info.GetName() + } + + logs, err := fetchJobLogs(options, options.JobID) + if err != nil { + return err + } + logs.JobName = jobName + + if options.Step != "" { + logs = filterByStep(logs, options.Step) + } + + pipelineLogs := &workflow_job.PipelineLogs{ + Jobs: []workflow_job.WorkflowJobLogs{*logs}, + } + + if options.Tail > 0 { + pipelineLogs = tailPipelineLogs(pipelineLogs, options.Tail) + } + + return outputPipelineLogs(pipelineLogs, options.OutputFormat) +} + +// resolveWorkflow determines which workflow to use and returns all its job IDs +func resolveWorkflow(options *LogsOptions) (*workflowInfo, error) { + if options.WorkflowID != "" { + // Use explicitly provided workflow ID + jobIDs, err := getWorkflowJobs(options.WorkflowID, options.Profile) + if err != nil { + return nil, fmt.Errorf("failed to get jobs for pipeline %s: %w", options.WorkflowID, err) + } + return &workflowInfo{WorkflowID: options.WorkflowID, JobIDs: jobIDs}, nil + } + + // Auto-detect: find latest workflow for environment + return getLatestWorkflowForEnvironment(options.EnvironmentID, options.Profile) +} + +// getLatestWorkflowForEnvironment finds the latest workflow and returns all its job IDs +func getLatestWorkflowForEnvironment(environmentID string, profile config.Profile) (*workflowInfo, error) { + collection, err := workflow.List(&workflow.ListOptions{ + Environment: environmentID, + Profile: profile, + }) + if err != nil { + return nil, fmt.Errorf("failed to fetch pipelines: %w", err) + } + + if collection.Embedded == nil || len(collection.Embedded.Item) == 0 { + return nil, fmt.Errorf("no pipelines found for environment %s", environmentID) + } + + workflowID := collection.Embedded.Item[0].GetId() + jobIDs, err := getWorkflowJobs(workflowID, profile) + if err != nil { + return nil, err + } + + return &workflowInfo{WorkflowID: workflowID, JobIDs: jobIDs}, nil +} + +// getWorkflowJobs fetches a workflow by ID and returns its job IDs +func getWorkflowJobs(workflowID string, profile config.Profile) ([]string, error) { + wf, err := workflow.Get(profile, workflowID) + if err != nil { + return nil, fmt.Errorf("failed to fetch pipeline: %w", err) + } + + jobs := wf.GetJobs() + if len(jobs) == 0 { + return nil, fmt.Errorf("pipeline %s has no jobs", workflowID) + } + + return jobs, nil +} + +// fetchJobLogs fetches all pages of logs for a single job +func fetchJobLogs(options *LogsOptions, jobID string) (*workflow_job.WorkflowJobLogs, error) { + if options.Follow { + fmt.Fprintln(os.Stderr, "Warning: Follow mode not yet fully implemented, showing current logs...") + } + + return workflow_job.FetchAllPages(&workflow_job.LogsOptions{ + Profile: options.Profile, + JobID: jobID, + Offset: 0, + Limit: 1000, + }) +} + +// filterByStep filters logs to only show specific step +func filterByStep(logs *workflow_job.WorkflowJobLogs, stepName string) *workflow_job.WorkflowJobLogs { + filtered := &workflow_job.WorkflowJobLogs{ + WorkflowJobID: logs.WorkflowJobID, + JobName: logs.JobName, + Status: logs.Status, + Steps: []workflow_job.LogStep{}, + Pagination: logs.Pagination, + } + + for _, step := range logs.Steps { + if step.Name == stepName { + filtered.Steps = append(filtered.Steps, step) + } + } + + return filtered +} + +// tailPipelineLogs limits output to last N lines across all jobs +func tailPipelineLogs(pl *workflow_job.PipelineLogs, n int) *workflow_job.PipelineLogs { + // Count total logs across all jobs + totalLogs := 0 + for _, job := range pl.Jobs { + for _, step := range job.Steps { + totalLogs += len(step.Logs) + } + } + + if totalLogs <= n { + return pl + } + + toSkip := totalLogs - n + + result := &workflow_job.PipelineLogs{ + WorkflowID: pl.WorkflowID, + } + + skipped := 0 + for _, job := range pl.Jobs { + newJob := workflow_job.WorkflowJobLogs{ + WorkflowJobID: job.WorkflowJobID, + JobName: job.JobName, + Status: job.Status, + Pagination: job.Pagination, + } + + for _, step := range job.Steps { + if skipped+len(step.Logs) <= toSkip { + skipped += len(step.Logs) + continue + } + + newStep := step + startIdx := toSkip - skipped + if startIdx < 0 { + startIdx = 0 + } + newStep.Logs = step.Logs[startIdx:] + newJob.Steps = append(newJob.Steps, newStep) + skipped += len(step.Logs) + } + + if len(newJob.Steps) > 0 { + result.Jobs = append(result.Jobs, newJob) + } + } + + return result +} + +// outputPipelineLogs formats and outputs pipeline logs +func outputPipelineLogs(logs *workflow_job.PipelineLogs, format string) error { + switch format { + case "stylish": + return pipeline_logs.NewStylishFormatter().Format(logs, os.Stdout) + case "json": + return pipeline_logs.NewJSONFormatter().Format(logs, os.Stdout) + case "yaml": + return pipeline_logs.NewYAMLFormatter().Format(logs, os.Stdout) + case "raw": + return pipeline_logs.NewRawFormatter().Format(logs, os.Stdout) + default: + return fmt.Errorf("unknown output format: %s (use: stylish, json, yaml, raw)", format) + } +} diff --git a/go.mod b/go.mod index a2909f5..d778f2b 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,8 @@ toolchain go1.23.2 replace github.com/imdario/mergo => github.com/imdario/mergo v0.3.16 +replace bunnyshell.com/sdk => ../neo-sdk-generator/go/sdk-local + require ( bunnyshell.com/dev v0.7.2 bunnyshell.com/sdk v0.20.4 diff --git a/go.sum b/go.sum index 94ecb77..506feba 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ 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= 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/workflow/item.go b/pkg/api/workflow/item.go new file mode 100644 index 0000000..30a734b --- /dev/null +++ b/pkg/api/workflow/item.go @@ -0,0 +1,27 @@ +package workflow + +import ( + "net/http" + + "bunnyshell.com/cli/pkg/api" + "bunnyshell.com/cli/pkg/config" + "bunnyshell.com/cli/pkg/lib" + "bunnyshell.com/sdk" +) + +func Get(profile config.Profile, id string) (*sdk.WorkflowItem, error) { + model, resp, err := GetRaw(profile, id) + if err != nil { + return nil, api.ParseError(resp, err) + } + return model, nil +} + +func GetRaw(profile config.Profile, id string) (*sdk.WorkflowItem, *http.Response, error) { + ctx, cancel := lib.GetContextFromProfile(profile) + defer cancel() + + request := lib.GetAPIFromProfile(profile).WorkflowAPI.WorkflowView(ctx, id) + + return request.Execute() +} diff --git a/pkg/api/workflow/list.go b/pkg/api/workflow/list.go new file mode 100644 index 0000000..a878692 --- /dev/null +++ b/pkg/api/workflow/list.go @@ -0,0 +1,45 @@ +package workflow + +import ( + "net/http" + + "bunnyshell.com/cli/pkg/api" + "bunnyshell.com/cli/pkg/lib" + "bunnyshell.com/cli/pkg/config" + "bunnyshell.com/sdk" +) + +type ListOptions struct { + Environment string + Organization string + Status string + + Profile config.Profile +} + +func List(options *ListOptions) (*sdk.PaginatedWorkflowCollection, error) { + model, resp, err := ListRaw(options) + if err != nil { + return nil, api.ParseError(resp, err) + } + return model, nil +} + +func ListRaw(options *ListOptions) (*sdk.PaginatedWorkflowCollection, *http.Response, error) { + ctx, cancel := lib.GetContextFromProfile(options.Profile) + defer cancel() + + request := lib.GetAPIFromProfile(options.Profile).WorkflowAPI.WorkflowList(ctx) + + if options.Environment != "" { + request = request.Environment(options.Environment) + } + if options.Organization != "" { + request = request.Organization(options.Organization) + } + if options.Status != "" { + request = request.Status(options.Status) + } + + return request.Execute() +} diff --git a/pkg/api/workflow_job/logs.go b/pkg/api/workflow_job/logs.go new file mode 100644 index 0000000..917f2eb --- /dev/null +++ b/pkg/api/workflow_job/logs.go @@ -0,0 +1,178 @@ +package workflow_job + +import ( + "bunnyshell.com/cli/pkg/api" + "bunnyshell.com/cli/pkg/config" + "bunnyshell.com/cli/pkg/lib" + "bunnyshell.com/sdk" +) + +// PipelineLogs wraps logs from all jobs in a workflow +type PipelineLogs struct { + WorkflowID string `json:"workflowId"` + Jobs []WorkflowJobLogs `json:"jobs"` +} + +// WorkflowJobList wraps a list of jobs for a pipeline +type WorkflowJobList struct { + PipelineID string `json:"pipelineId"` + Jobs []sdk.WorkflowJobItem `json:"jobs"` +} + +// WorkflowJobLogs represents the structure of workflow job logs +type WorkflowJobLogs struct { + WorkflowJobID string `json:"workflowJobId"` + JobName string `json:"jobName,omitempty"` + Status string `json:"status"` + Steps []LogStep `json:"steps"` + Pagination Pagination `json:"pagination"` +} + +// LogStep represents a single step in the workflow +type LogStep struct { + Name string `json:"name"` + Status string `json:"status"` + ExitCode int `json:"exitCode"` + StartedAt string `json:"startedAt"` + Logs []LogMessage `json:"logs"` +} + +// LogMessage represents a single log message +type LogMessage struct { + Timestamp string `json:"timestamp"` + Message string `json:"message"` +} + +// Pagination contains pagination metadata +type Pagination struct { + Offset int `json:"offset"` + Limit int `json:"limit"` + Total int `json:"total"` + HasMore bool `json:"hasMore"` +} + +// LogsOptions contains options for fetching workflow job logs +type LogsOptions struct { + Profile config.Profile + JobID string + Offset int + Limit int +} + +// GetJobInfo fetches metadata for a workflow job via the SDK +func GetJobInfo(profile config.Profile, jobID string) (*sdk.WorkflowJobItem, error) { + ctx, cancel := lib.GetContextFromProfile(profile) + defer cancel() + + model, resp, err := lib.GetAPIFromProfile(profile).WorkflowJobAPI.WorkflowJobView(ctx, jobID).Execute() + if err != nil { + return nil, api.ParseError(resp, err) + } + + return model, nil +} + +// GetLogs fetches workflow job logs from the API via the SDK +func GetLogs(options *LogsOptions) (*WorkflowJobLogs, error) { + ctx, cancel := lib.GetContextFromProfile(options.Profile) + defer cancel() + + request := lib.GetAPIFromProfile(options.Profile).WorkflowJobAPI.WorkflowJobLogsList(ctx, options.JobID) + request = request.Offset(int32(options.Offset)) + request = request.Limit(int32(options.Limit)) + + sdkLogs, resp, err := request.Execute() + if err != nil { + return nil, api.ParseError(resp, err) + } + + return fromSDKLogs(sdkLogs), nil +} + +// FetchAllPages fetches all pages of logs automatically +func FetchAllPages(options *LogsOptions) (*WorkflowJobLogs, error) { + var allLogs *WorkflowJobLogs + var allSteps []LogStep + + offset := options.Offset + limit := options.Limit + + for { + logs, err := GetLogs(&LogsOptions{ + Profile: options.Profile, + JobID: options.JobID, + Offset: offset, + Limit: limit, + }) + if err != nil { + return nil, err + } + + if allLogs == nil { + allLogs = logs + allSteps = logs.Steps + } else { + allSteps = mergeSteps(allSteps, logs.Steps) + } + + if !logs.Pagination.HasMore { + break + } + + offset += limit + } + + if allLogs != nil { + allLogs.Steps = allSteps + allLogs.Pagination.HasMore = false + } + + return allLogs, nil +} + +// fromSDKLogs converts SDK response to CLI types +func fromSDKLogs(sdkLogs *sdk.WorkflowJobLogsResponse) *WorkflowJobLogs { + logs := &WorkflowJobLogs{ + WorkflowJobID: sdkLogs.WorkflowJobID, + Status: sdkLogs.Status, + Pagination: Pagination{ + Offset: sdkLogs.Pagination.Offset, + Limit: sdkLogs.Pagination.Limit, + Total: sdkLogs.Pagination.Total, + HasMore: sdkLogs.Pagination.HasMore, + }, + } + + for _, sdkStep := range sdkLogs.Steps { + step := LogStep{ + Name: sdkStep.Name, + Status: sdkStep.Status, + ExitCode: sdkStep.ExitCode, + StartedAt: sdkStep.StartedAt, + } + for _, sdkLog := range sdkStep.Logs { + step.Logs = append(step.Logs, LogMessage{ + Timestamp: sdkLog.Timestamp, + Message: sdkLog.Message, + }) + } + logs.Steps = append(logs.Steps, step) + } + + return logs +} + +// mergeSteps merges log steps, combining logs from the same step +func mergeSteps(existing, new []LogStep) []LogStep { + if len(existing) > 0 && len(new) > 0 { + lastExisting := &existing[len(existing)-1] + firstNew := new[0] + + if lastExisting.Name == firstNew.Name { + lastExisting.Logs = append(lastExisting.Logs, firstNew.Logs...) + return append(existing, new[1:]...) + } + } + + return append(existing, new...) +} diff --git a/pkg/formatter/pipeline_logs/json.go b/pkg/formatter/pipeline_logs/json.go new file mode 100644 index 0000000..65dd982 --- /dev/null +++ b/pkg/formatter/pipeline_logs/json.go @@ -0,0 +1,23 @@ +package pipeline_logs + +import ( + "encoding/json" + "io" + + "bunnyshell.com/cli/pkg/api/workflow_job" +) + +// JSONFormatter formats logs as JSON +type JSONFormatter struct{} + +// NewJSONFormatter creates a new JSON formatter +func NewJSONFormatter() *JSONFormatter { + return &JSONFormatter{} +} + +// Format outputs pipeline logs in JSON format +func (f *JSONFormatter) Format(logs *workflow_job.PipelineLogs, w io.Writer) error { + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(logs) +} diff --git a/pkg/formatter/pipeline_logs/raw.go b/pkg/formatter/pipeline_logs/raw.go new file mode 100644 index 0000000..afddcdb --- /dev/null +++ b/pkg/formatter/pipeline_logs/raw.go @@ -0,0 +1,29 @@ +package pipeline_logs + +import ( + "fmt" + "io" + + "bunnyshell.com/cli/pkg/api/workflow_job" +) + +// RawFormatter formats logs as plain text (messages only) +type RawFormatter struct{} + +// NewRawFormatter creates a new raw formatter +func NewRawFormatter() *RawFormatter { + return &RawFormatter{} +} + +// Format outputs pipeline logs in raw format (just messages, no formatting) +func (f *RawFormatter) Format(logs *workflow_job.PipelineLogs, w io.Writer) error { + for _, job := range logs.Jobs { + for _, step := range job.Steps { + for _, log := range step.Logs { + fmt.Fprintln(w, log.Message) + } + } + } + + return nil +} diff --git a/pkg/formatter/pipeline_logs/stylish.go b/pkg/formatter/pipeline_logs/stylish.go new file mode 100644 index 0000000..89d7239 --- /dev/null +++ b/pkg/formatter/pipeline_logs/stylish.go @@ -0,0 +1,176 @@ +package pipeline_logs + +import ( + "fmt" + "io" + "strings" + "time" + + "bunnyshell.com/cli/pkg/api/workflow_job" + "github.com/fatih/color" +) + +// StylishFormatter formats logs with colors and visual hierarchy +type StylishFormatter struct { + colorEnabled bool +} + +// NewStylishFormatter creates a new stylish formatter +func NewStylishFormatter() *StylishFormatter { + return &StylishFormatter{ + colorEnabled: true, + } +} + +// Format outputs pipeline logs in stylish format +func (f *StylishFormatter) Format(logs *workflow_job.PipelineLogs, w io.Writer) error { + if logs.WorkflowID != "" { + fmt.Fprintf(w, "\nWorkflow: %s\n", logs.WorkflowID) + } + + for i, job := range logs.Jobs { + if i > 0 { + fmt.Fprintln(w) + } + f.printJobHeader(w, &job) + + for _, step := range job.Steps { + f.printStep(w, &step) + } + } + + f.printSummary(w, logs) + + return nil +} + +// printJobHeader prints a header for each workflow job +func (f *StylishFormatter) printJobHeader(w io.Writer, job *workflow_job.WorkflowJobLogs) { + separator := strings.Repeat("═", 70) + fmt.Fprintf(w, "\n%s\n", color.New(color.FgCyan, color.Bold).Sprint(separator)) + + statusIcon := f.getStatusIcon(job.Status) + jobLabel := job.WorkflowJobID + if job.JobName != "" { + jobLabel = job.JobName + } + + header := fmt.Sprintf("%s Job: %s", statusIcon, jobLabel) + + switch job.Status { + case "success": + fmt.Fprintln(w, color.New(color.FgCyan, color.Bold).Sprint(header)) + case "failed": + fmt.Fprintln(w, color.New(color.FgRed, color.Bold).Sprint(header)) + case "running": + fmt.Fprintln(w, color.New(color.FgYellow, color.Bold).Sprint(header)) + default: + fmt.Fprintln(w, color.New(color.Bold).Sprint(header)) + } + + fmt.Fprintf(w, "%s %s\n", color.New(color.Faint).Sprintf(" ID: %s", job.WorkflowJobID), + color.New(color.Faint).Sprintf("Status: %s", f.colorizeStatus(job.Status))) + fmt.Fprintf(w, "%s\n", color.New(color.FgCyan, color.Bold).Sprint(separator)) +} + +// printStep prints a single step with its logs +func (f *StylishFormatter) printStep(w io.Writer, step *workflow_job.LogStep) { + separator := strings.Repeat("━", 70) + fmt.Fprintf(w, "%s\n", color.New(color.Faint).Sprint(separator)) + + statusIcon := f.getStatusIcon(step.Status) + stepHeader := fmt.Sprintf("%s Step: %s", statusIcon, step.Name) + + if step.Status == "success" { + fmt.Fprintln(w, color.GreenString(stepHeader)) + } else if step.Status == "failed" { + fmt.Fprintln(w, color.RedString(stepHeader)) + } else if step.Status == "running" { + fmt.Fprintln(w, color.YellowString(stepHeader)) + } else { + fmt.Fprintln(w, stepHeader) + } + + fmt.Fprintf(w, "%s\n\n", color.New(color.Faint).Sprint(separator)) + + for _, log := range step.Logs { + f.printLogMessage(w, &log) + } + + fmt.Fprintln(w) +} + +// printLogMessage prints a single log message +func (f *StylishFormatter) printLogMessage(w io.Writer, log *workflow_job.LogMessage) { + timestamp := f.formatTimestamp(log.Timestamp) + timestampStr := color.New(color.Faint).Sprintf(" %s", timestamp) + + message := fmt.Sprintf(" %s", log.Message) + + fmt.Fprintf(w, "%s %s\n", timestampStr, message) +} + +// printSummary prints summary information +func (f *StylishFormatter) printSummary(w io.Writer, logs *workflow_job.PipelineLogs) { + separator := strings.Repeat("━", 70) + fmt.Fprintf(w, "%s\n\n", color.New(color.Faint).Sprint(separator)) + + totalLogs := 0 + totalJobs := len(logs.Jobs) + failedJobs := 0 + for _, job := range logs.Jobs { + if job.Status == "failed" { + failedJobs++ + } + for _, step := range job.Steps { + totalLogs += len(step.Logs) + } + } + + fmt.Fprintf(w, "Jobs: %d", totalJobs) + if failedJobs > 0 { + fmt.Fprintf(w, " (%s)", color.RedString("%d failed", failedJobs)) + } + fmt.Fprintln(w) + fmt.Fprintf(w, "Total log lines: %d\n", totalLogs) +} + +// getStatusIcon returns an icon for the status +func (f *StylishFormatter) getStatusIcon(status string) string { + switch status { + case "success": + return "✓" + case "failed": + return "✗" + case "running": + return "⟳" + case "pending": + return "○" + default: + return "•" + } +} + +// colorizeStatus returns a colorized status string +func (f *StylishFormatter) colorizeStatus(status string) string { + switch status { + case "success", "completed": + return color.GreenString(status) + case "failed": + return color.RedString(status) + case "running": + return color.YellowString(status) + default: + return status + } +} + +// formatTimestamp formats ISO timestamp to HH:MM:SS +func (f *StylishFormatter) formatTimestamp(timestamp string) string { + t, err := time.Parse(time.RFC3339, timestamp) + if err != nil { + return timestamp + } + + return t.Format("15:04:05") +} diff --git a/pkg/formatter/pipeline_logs/yaml.go b/pkg/formatter/pipeline_logs/yaml.go new file mode 100644 index 0000000..1f7fffb --- /dev/null +++ b/pkg/formatter/pipeline_logs/yaml.go @@ -0,0 +1,25 @@ +package pipeline_logs + +import ( + "io" + + "bunnyshell.com/cli/pkg/api/workflow_job" + "gopkg.in/yaml.v3" +) + +// YAMLFormatter formats logs as YAML +type YAMLFormatter struct{} + +// NewYAMLFormatter creates a new YAML formatter +func NewYAMLFormatter() *YAMLFormatter { + return &YAMLFormatter{} +} + +// Format outputs pipeline logs in YAML format +func (f *YAMLFormatter) Format(logs *workflow_job.PipelineLogs, w io.Writer) error { + encoder := yaml.NewEncoder(w) + encoder.SetIndent(2) + defer encoder.Close() + + return encoder.Encode(logs) +} diff --git a/pkg/formatter/stylish.pipeline.go b/pkg/formatter/stylish.pipeline.go index eea07dd..b56b076 100644 --- a/pkg/formatter/stylish.pipeline.go +++ b/pkg/formatter/stylish.pipeline.go @@ -9,18 +9,24 @@ import ( ) 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") + fmt.Fprintf(writer, "%v\t %v\t %v\t %v\t %v\t %v\n", "PipelineID", "EnvironmentID", "OrganizationID", "Description", "Status", "CreatedAt") if data.Embedded != nil { for _, item := range data.Embedded.Item { + createdAt := "" + if item.HasCreatedAt() { + createdAt = item.GetCreatedAt().Local().Format("2006-01-02 15:04:05") + } + fmt.Fprintf( writer, - "%v\t %v\t %v\t %v\t %v\n", + "%v\t %v\t %v\t %v\t %v\t %v\n", item.GetId(), item.GetEnvironment(), item.GetOrganization(), item.GetDescription(), item.GetStatus(), + createdAt, ) } }