From 873f04780b0a15e23aff5686187f3e60f2eb65cf Mon Sep 17 00:00:00 2001 From: Ben McNicholl Date: Fri, 13 Sep 2024 15:15:08 +1000 Subject: [PATCH] SUP-2582 | watch command (#356) * Rudamentary watch command & expand picker * Add job output --- internal/io/prompt.go | 20 ++++- internal/pipeline/resolver/picker.go | 2 +- pkg/cmd/build/build.go | 1 + pkg/cmd/build/watch.go | 120 +++++++++++++++++++++++++++ pkg/cmd/use/use.go | 2 +- 5 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 pkg/cmd/build/watch.go diff --git a/internal/io/prompt.go b/internal/io/prompt.go index 0d3c0ecc..8f25ae22 100644 --- a/internal/io/prompt.go +++ b/internal/io/prompt.go @@ -2,15 +2,29 @@ package io import "github.com/charmbracelet/huh" +const ( + typeOrganizationMessage = "Pick an organization" + typePipelineMessage = "Select a pipeline" +) + // PromptForOne will show the list of options to the user, allowing them to select one to return. // It's possible for them to choose none or cancel the selection, resulting in an error. -func PromptForOne(options []string) (string, error) { +func PromptForOne(resource string, options []string) (string, error) { + var message string + switch resource { + case "pipeline": + message = typePipelineMessage + case "organization": + message = typeOrganizationMessage + default: + message = "Please select one of the options below" + } selected := new(string) err := huh.NewForm(huh.NewGroup( huh.NewSelect[string](). - Title("Pick an organization"). + Title(message). Options( - huh.NewOptions[string](options...)..., + huh.NewOptions(options...)..., ).Value(selected), ), ).Run() diff --git a/internal/pipeline/resolver/picker.go b/internal/pipeline/resolver/picker.go index 15d0f666..e125fda7 100644 --- a/internal/pipeline/resolver/picker.go +++ b/internal/pipeline/resolver/picker.go @@ -32,7 +32,7 @@ func PickOne(pipelines []pipeline.Pipeline) *pipeline.Pipeline { names[i] = p.Name } - chosen, err := io.PromptForOne(names) + chosen, err := io.PromptForOne("pipeline", names) if err != nil { return nil } diff --git a/pkg/cmd/build/build.go b/pkg/cmd/build/build.go index 8a79d48e..4998ad63 100644 --- a/pkg/cmd/build/build.go +++ b/pkg/cmd/build/build.go @@ -35,6 +35,7 @@ func NewCmdBuild(f *factory.Factory) *cobra.Command { cmd.AddCommand(NewCmdBuildNew(f)) cmd.AddCommand(NewCmdBuildRebuild(f)) cmd.AddCommand(NewCmdBuildView(f)) + cmd.AddCommand(NewCmdBuildWatch(f)) return &cmd } diff --git a/pkg/cmd/build/watch.go b/pkg/cmd/build/watch.go new file mode 100644 index 00000000..9dea9117 --- /dev/null +++ b/pkg/cmd/build/watch.go @@ -0,0 +1,120 @@ +package build + +import ( + "fmt" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/buildkite/cli/v3/internal/build" + buildResolver "github.com/buildkite/cli/v3/internal/build/resolver" + "github.com/buildkite/cli/v3/internal/build/resolver/options" + "github.com/buildkite/cli/v3/internal/job" + pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" + "github.com/buildkite/cli/v3/pkg/cmd/factory" + "github.com/buildkite/go-buildkite/v3/buildkite" + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" +) + +func NewCmdBuildWatch(f *factory.Factory) *cobra.Command { + var pipeline, branch string + var intervalSeconds int + + cmd := cobra.Command{ + Use: "watch [number] [flags]", + Short: "Watch a build's progress in real-time", + Args: cobra.MaximumNArgs(1), + Long: heredoc.Doc(` + Watch a build's progress in real-time. + + You can pass an optional build number to watch. If omitted, the most recent build on the current branch will be watched. + `), + Example: heredoc.Doc(` + # Watch the most recent build for the current branch + $ bk build watch + + # Watch a specific build + $ bk build watch 429 + + # Watch the most recent build on a specific branch + $ bk build watch -b feature-x + + # Watch a build on a specific pipeline + $ bk build watch -p my-pipeline + + # Set a custom polling interval (in seconds) + $ bk build watch --interval 5 + `), + RunE: func(cmd *cobra.Command, args []string) error { + pipelineRes := pipelineResolver.NewAggregateResolver( + pipelineResolver.ResolveFromFlag(pipeline, f.Config), + pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOne), + pipelineResolver.ResolveFromRepository(f, pipelineResolver.CachedPicker(f.Config, pipelineResolver.PickOne)), + ) + + optionsResolver := options.AggregateResolver{ + options.ResolveBranchFromFlag(branch), + options.ResolveBranchFromRepository(f.GitRepository), + } + + buildRes := buildResolver.NewAggregateResolver( + buildResolver.ResolveFromPositionalArgument(args, 0, pipelineRes.Resolve, f.Config), + buildResolver.ResolveBuildWithOpts(f, pipelineRes.Resolve, optionsResolver...), + ) + + bld, err := buildRes.Resolve(cmd.Context()) + if err != nil { + return err + } + if bld == nil { + return fmt.Errorf("no running builds found") + } + + fmt.Fprintf(cmd.OutOrStdout(), "Watching build %d on %s/%s\n", bld.BuildNumber, bld.Organization, bld.Pipeline) + + ticker := time.NewTicker(time.Duration(intervalSeconds) * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + b, _, err := f.RestAPIClient.Builds.Get(bld.Organization, bld.Pipeline, fmt.Sprint(bld.BuildNumber), &buildkite.BuildsListOptions{}) + if err != nil { + return err + } + + summary := buildSummaryWithJobs(b) + fmt.Fprintf(cmd.OutOrStdout(), "\033[2J\033[H%s\n", summary) // Clear screen and move cursor to top-left + + if b.FinishedAt != nil { + return nil + } + case <-cmd.Context().Done(): + return nil + } + } + }, + } + + cmd.Flags().StringVarP(&pipeline, "pipeline", "p", "", "The pipeline to watch. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}.") + cmd.Flags().StringVarP(&branch, "branch", "b", "", "The branch to watch builds for.") + cmd.Flags().IntVar(&intervalSeconds, "interval", 1, "Polling interval in seconds") + + return &cmd +} + +func buildSummaryWithJobs(b *buildkite.Build) string { + summary := build.BuildSummary(b) + + if len(b.Jobs) > 0 { + summary += lipgloss.NewStyle().Bold(true).Padding(0, 1).Underline(true).Render("\nJobs") + for _, j := range b.Jobs { + bkJob := *j + if *bkJob.Type == "script" { + summary += job.JobSummary(job.Job(bkJob)) + } + } + } + + return summary +} diff --git a/pkg/cmd/use/use.go b/pkg/cmd/use/use.go index 0c2699ed..137ef624 100644 --- a/pkg/cmd/use/use.go +++ b/pkg/cmd/use/use.go @@ -34,7 +34,7 @@ func useRun(org *string, conf *config.Config) error { // prompt to choose from configured orgs if one is not already selected if org == nil { var err error - selected, err = io.PromptForOne(conf.ConfiguredOrganizations()) + selected, err = io.PromptForOne("organization", conf.ConfiguredOrganizations()) if err != nil { return err }