From 8e8d79c206fdb9f1b572f89f88f215f37771c00b Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 15:14:08 +0000 Subject: [PATCH 01/27] [skip ci] feat: adding global flags interface for Kong --- internal/cli/context.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 internal/cli/context.go diff --git a/internal/cli/context.go b/internal/cli/context.go new file mode 100644 index 00000000..cbb8340c --- /dev/null +++ b/internal/cli/context.go @@ -0,0 +1,19 @@ +package cli + +type GlobalFlags interface { + SkipConfirmation() bool + DisableInput() bool +} + +type Globals struct { + Yes bool + NoInput bool +} + +func (g Globals) SkipConfirmation() bool { + return g.Yes +} + +func (g Globals) DisableInput() bool { + return g.NoInput +} From dd3732d0af044b146ad809163a5442bb1e9e612d Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 15:15:50 +0000 Subject: [PATCH 02/27] [skip ci] feat: add `CheckValidConfiguration` Kong implementation Allows us to do the same checks on tokens requirements, and if api token/org slug are missing in Kong like we do with Cobra --- pkg/cmd/validation/config.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/pkg/cmd/validation/config.go b/pkg/cmd/validation/config.go index fadb797e..1c0c8644 100644 --- a/pkg/cmd/validation/config.go +++ b/pkg/cmd/validation/config.go @@ -14,6 +14,7 @@ var CommandsNotRequiringToken = []string{ "pipeline migrate", // The pipeline migrate command uses a public migration API } +// TODO: This can be deleted once we've moved over to Kong entirely, this is native and can be handles by passing context directly into the run methods // getCommandPath returns the full path of a command // e.g., "bk pipeline validate" func getCommandPath(cmd *cobra.Command) string { @@ -37,6 +38,7 @@ func getCommandPath(cmd *cobra.Command) string { return strings.Join(path, " ") } +// TODO: This can be deleted once we've moved entirely over to Kong, as we've implemented the same functionality in ValidateConfiguration func // CheckValidConfiguration returns a function that checks the viper configuration is valid to execute the command func CheckValidConfiguration(conf *config.Config) func(cmd *cobra.Command, args []string) error { missingToken := conf.APIToken() == "" @@ -66,3 +68,28 @@ func CheckValidConfiguration(conf *config.Config) func(cmd *cobra.Command, args return err } } + +// CheckValidConfiguration checks that the viper configuration is valid to execute the command (Kong version) +func ValidateConfiguration(conf *config.Config, commandPath string) error { + missingToken := conf.APIToken() == "" + missingOrg := conf.OrganizationSlug() == "" + + // Skip token check for commands that don't need it + for _, exemptCmd := range CommandsNotRequiringToken { + // Check if the command path ends with the exempt command pattern + if strings.HasSuffix(commandPath, exemptCmd) { + return nil // Skip validation for exempt commands + } + } + + switch { + case missingToken && missingOrg: + return errors.New("you must set a valid API token and organization slug. Run `bk configure` or `bk use`, or set the environment variables `BUILDKITE_API_TOKEN` and `BUILDKITE_ORGANIZATION_SLUG`") + case missingToken: + return errors.New("you must set a valid API token. Run `bk configure`, or set the environment variable `BUILDKITE_API_TOKEN`") + case missingOrg: + return errors.New("you must set a valid organization slug. Run `bk use`, or set the environment variable `BUILDKITE_ORGANIZATION_SLUG`") + } + + return nil +} From f6bd2174d8b15891ae74ce12469d1b5a793101db Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 15:18:05 +0000 Subject: [PATCH 03/27] [skip ci] feat: update main.go for new Kobra create.go --- main.go | 96 +++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 73 insertions(+), 23 deletions(-) diff --git a/main.go b/main.go index ee9149b9..3e4d002d 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,8 @@ import ( "os" "github.com/alecthomas/kong" + "github.com/buildkite/cli/v3/cmd/build" + "github.com/buildkite/cli/v3/internal/cli" bkErrors "github.com/buildkite/cli/v3/internal/errors" "github.com/buildkite/cli/v3/internal/version" "github.com/buildkite/cli/v3/pkg/cmd/factory" @@ -14,6 +16,11 @@ import ( // Kong CLI structure, with base commands defined as additional commands are defined in their respective files type CLI struct { + // Global flags + Yes bool `help:"Skip all confirmation prompts" short:"y"` + NoInput bool `help:"Disable all interactive prompts" name:"no-input"` + // Verbose bool `help:"Enable verbose error output" short:"V"` // TODO: Implement this, atm this is just a skeleton flag + Agent AgentCmd `cmd:"" help:"Manage agents"` Api ApiCmd `cmd:"" help:"Interact with the Buildkite API"` Artifacts ArtifactsCmd `cmd:"" help:"Manage pipeline build artifacts"` @@ -42,7 +49,7 @@ type ( Args []string `arg:"" optional:"" passthrough:"all"` } BuildCmd struct { - Args []string `arg:"" optional:"" passthrough:"all"` + Create build.CreateCmd `cmd:"" aliases:"new" help:"Create a new build."` // Aliasing "new" because we've renamed this to "create", but we need to support backwards compatibility } ClusterCmd struct { Args []string `arg:"" optional:"" passthrough:"all"` @@ -77,26 +84,31 @@ type ( ) // Delegation methods, we should delete when native Kong implementations ready -func (v *VersionCmd) Run(*CLI) error { return delegateToCobraSystem("version", v.Args) } -func (a *AgentCmd) Run(*CLI) error { return delegateToCobraSystem("agent", a.Args) } -func (a *ArtifactsCmd) Run(*CLI) error { return delegateToCobraSystem("artifacts", a.Args) } -func (b *BuildCmd) Run(*CLI) error { return delegateToCobraSystem("build", b.Args) } -func (c *ClusterCmd) Run(*CLI) error { return delegateToCobraSystem("cluster", c.Args) } -func (j *JobCmd) Run(*CLI) error { return delegateToCobraSystem("job", j.Args) } -func (p *PackageCmd) Run(*CLI) error { return delegateToCobraSystem("package", p.Args) } -func (p *PipelineCmd) Run(*CLI) error { return delegateToCobraSystem("pipeline", p.Args) } -func (u *UserCmd) Run(*CLI) error { return delegateToCobraSystem("user", u.Args) } -func (a *ApiCmd) Run(*CLI) error { return delegateToCobraSystem("api", a.Args) } -func (c *ConfigureCmd) Run(*CLI) error { return delegateToCobraSystem("configure", c.Args) } -func (i *InitCmd) Run(*CLI) error { return delegateToCobraSystem("init", i.Args) } -func (u *UseCmd) Run(*CLI) error { return delegateToCobraSystem("use", u.Args) } -func (w *WhoamiCmd) Run(*CLI) error { return delegateToCobraSystem("whoami", w.Args) } - -func delegateToCobraSystem(command string, args []string) error { +func (v *VersionCmd) Run(cli *CLI) error { return cli.delegateToCobraSystem("version", v.Args) } +func (a *AgentCmd) Run(cli *CLI) error { return cli.delegateToCobraSystem("agent", a.Args) } +func (a *ArtifactsCmd) Run(cli *CLI) error { return cli.delegateToCobraSystem("artifacts", a.Args) } +func (c *ClusterCmd) Run(cli *CLI) error { return cli.delegateToCobraSystem("cluster", c.Args) } +func (j *JobCmd) Run(cli *CLI) error { return cli.delegateToCobraSystem("job", j.Args) } +func (p *PackageCmd) Run(cli *CLI) error { return cli.delegateToCobraSystem("package", p.Args) } +func (p *PipelineCmd) Run(cli *CLI) error { return cli.delegateToCobraSystem("pipeline", p.Args) } +func (u *UserCmd) Run(cli *CLI) error { return cli.delegateToCobraSystem("user", u.Args) } +func (a *ApiCmd) Run(cli *CLI) error { return cli.delegateToCobraSystem("api", a.Args) } +func (c *ConfigureCmd) Run(cli *CLI) error { return cli.delegateToCobraSystem("configure", c.Args) } +func (i *InitCmd) Run(cli *CLI) error { return cli.delegateToCobraSystem("init", i.Args) } +func (u *UseCmd) Run(cli *CLI) error { return cli.delegateToCobraSystem("use", u.Args) } +func (w *WhoamiCmd) Run(cli *CLI) error { return cli.delegateToCobraSystem("whoami", w.Args) } + +// delegateToCobraSystem delegates execution to the legacy Cobra command system. +// This is a temporary bridge during the Kong migration that ensures backwards compatibility +// by reconstructing global flags that Kong has already parsed. +func (cli *CLI) delegateToCobraSystem(command string, args []string) error { + // Preserve and restore original args for safety originalArgs := os.Args defer func() { os.Args = originalArgs }() - os.Args = append([]string{os.Args[0], command}, args...) + // Reconstruct command args with global flags for Cobra compatibility + reconstructedArgs := cli.buildCobraArgs(command, args) + os.Args = reconstructedArgs if code := runCobraSystem(); code != 0 { os.Exit(code) @@ -104,6 +116,28 @@ func delegateToCobraSystem(command string, args []string) error { return nil } +// buildCobraArgs constructs the argument slice for Cobra, including global flags. +// Kong parses and consumes global flags before delegation, so we need to reconstruct +// them to maintain backwards compatibility with Cobra commands. +func (cli *CLI) buildCobraArgs(command string, passthroughArgs []string) []string { + args := []string{os.Args[0], command} + + if cli.Yes { + args = append(args, "--yes") + } + if cli.NoInput { + args = append(args, "--no-input") + } + // TODO: Add verbose flag reconstruction when implemented + // if cli.Verbose { + // args = append(args, "--verbose") + // } + + args = append(args, passthroughArgs...) + + return args +} + func runCobraSystem() int { f, err := factory.New(version.Version) if err != nil { @@ -131,6 +165,15 @@ func handleError(err error) { bkErrors.NewHandler().Handle(err) } +func newKongParser(cli *CLI) (*kong.Kong, error) { + return kong.New( + cli, + kong.Name("bk"), + kong.Description("Work with Buildkite from the command line."), + kong.UsageOnError(), + ) +} + func main() { os.Exit(run()) } @@ -140,8 +183,7 @@ func run() int { // This addresses the Kong limitation described in https://github.com/alecthomas/kong/issues/33 if len(os.Args) <= 1 { cli := &CLI{} - parser, err := kong.New(cli, - kong.Description("Buildkite CLI")) + parser, err := newKongParser(cli) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) return 1 @@ -155,8 +197,9 @@ func run() int { return runCobraSystem() } - cli := &CLI{} - parser, err := kong.New(cli, kong.Description("Buildkite CLI"), kong.UsageOnError()) + cliInstance := &CLI{} + + parser, err := newKongParser(cliInstance) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) return 1 @@ -167,7 +210,14 @@ func run() int { return 1 } - if err := ctx.Run(cli); err != nil { + globals := cli.Globals{ + Yes: cliInstance.Yes, + NoInput: cliInstance.NoInput, + } + + ctx.BindTo(cli.GlobalFlags(globals), (*cli.GlobalFlags)(nil)) + + if err := ctx.Run(cliInstance); err != nil { handleError(err) return 1 } From 15c94e33e9ae78638301dc781a4e2e3fa79880d9 Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 15:21:52 +0000 Subject: [PATCH 04/27] [skip cli] new.go > cmd/build/create.go --- cmd/build/create.go | 240 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 cmd/build/create.go diff --git a/cmd/build/create.go b/cmd/build/create.go new file mode 100644 index 00000000..1ac45d5f --- /dev/null +++ b/cmd/build/create.go @@ -0,0 +1,240 @@ +package build + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "github.com/alecthomas/kong" + "github.com/buildkite/cli/v3/internal/cli" + bkErrors "github.com/buildkite/cli/v3/internal/errors" + bk_io "github.com/buildkite/cli/v3/internal/io" + "github.com/buildkite/cli/v3/internal/pipeline/resolver" + "github.com/buildkite/cli/v3/internal/util" + "github.com/buildkite/cli/v3/internal/version" + "github.com/buildkite/cli/v3/pkg/cmd/factory" + "github.com/buildkite/cli/v3/pkg/cmd/validation" + buildkite "github.com/buildkite/go-buildkite/v4" + "github.com/charmbracelet/lipgloss" +) + +type CreateCmd struct { + Message string `help:"Description of the build. If left blank, the commit message will be used once the build starts." short:"m"` + Commit string `help:"The commit to build." short:"c" default:"HEAD"` + Branch string `help:"The branch to build. Defaults to the default branch of the pipeline." short:"b"` + Author string `help:"Author of the build. Supports: \"Name \", \"email@domain.com\", \"Full Name\", or \"username\"" short:"a"` + Web bool `help:"Open the build in a web browser after it has been created." short:"w"` + Pipeline string `help:"The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}." short:"p"` + Env []string `help:"Set environment variables for the build (KEY=VALUE)" short:"e"` + Metadata []string `help:"Set metadata for the build (KEY=VALUE)" short:"M"` + IgnoreBranchFilters bool `help:"Ignore branch filters for the pipeline" short:"i"` + EnvFile string `help:"Set the environment variables for the build via an environment file" short:"f"` +} + +func (c *CreateCmd) Help() string { + return `The web URL to the build will be printed to stdout. + +Examples: + # Create a new build + $ bk build create + + # Create a new build with environment variables set + $ bk build create -e "FOO=BAR" -e "BAR=BAZ" + + # Create a new build with metadata + $ bk build create -M "key=value" -M "foo=bar"` +} + +func (c *CreateCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { + // Initialize factory + f, err := factory.New(version.Version) + if err != nil { + return bkErrors.NewInternalError(err, "failed to initialize CLI", "This is likely a bug", "Report to Buildkite") + } + + f.SkipConfirm = globals.SkipConfirmation() + f.NoInput = globals.DisableInput() + + if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { + return err + } + + ctx := context.Background() + + resolvers := resolver.NewAggregateResolver( + resolver.ResolveFromFlag(c.Pipeline, f.Config), + resolver.ResolveFromConfig(f.Config, resolver.PickOne), + resolver.ResolveFromRepository(f, resolver.CachedPicker(f.Config, resolver.PickOne, f.GitRepository != nil)), + ) + + resolvedPipeline, err := resolvers.Resolve(ctx) + if err != nil { + return err // Already wrapped by resolver + } + if resolvedPipeline == nil { + return bkErrors.NewResourceNotFoundError( + nil, + "could not resolve a pipeline", + "Specify a pipeline with --pipeline (-p)", + "Run 'bk pipeline list' to see available pipelines", + ) + } + + confirmed, err := bk_io.Confirm(f, fmt.Sprintf("Create new build on %s?", resolvedPipeline.Name)) + if err != nil { + return bkErrors.NewUserAbortedError(err, "confirmation canceled") + } + + if !confirmed { + fmt.Println("Build creation canceled") + return nil + } + + // Process environment variables + envMap := make(map[string]string) + for _, e := range c.Env { + key, value, _ := strings.Cut(e, "=") + envMap[key] = value + } + + // Process metadata variables + metaDataMap := make(map[string]string) + for _, m := range c.Metadata { + key, value, _ := strings.Cut(m, "=") + metaDataMap[key] = value + } + + // Process environment file if specified + if c.EnvFile != "" { + file, err := os.Open(c.EnvFile) + if err != nil { + return bkErrors.NewValidationError( + err, + fmt.Sprintf("could not open environment file: %s", c.EnvFile), + "Check that the file exists and is readable", + ) + } + defer file.Close() + + content := bufio.NewScanner(file) + for content.Scan() { + key, value, _ := strings.Cut(content.Text(), "=") + envMap[key] = value + } + + if err := content.Err(); err != nil { + return bkErrors.NewValidationError( + err, + "error reading environment file", + "Ensure the file contains valid environment variables in KEY=VALUE format", + ) + } + } + + return createBuild(ctx, resolvedPipeline.Org, resolvedPipeline.Name, f, c.Message, c.Commit, c.Branch, c.Web, envMap, metaDataMap, c.IgnoreBranchFilters, c.Author) +} + +func parseAuthor(author string) buildkite.Author { + if author == "" { + return buildkite.Author{} + } + + // Check for Git-style format: "Name " + if strings.Contains(author, "<") && strings.Contains(author, ">") { + parts := strings.Split(author, "<") + if len(parts) == 2 { + name := strings.TrimSpace(parts[0]) + email := strings.TrimSpace(strings.Trim(parts[1], ">")) + if name != "" && email != "" { + return buildkite.Author{Name: name, Email: email} + } + } + } + + // Check for email-only format + if strings.Contains(author, "@") && strings.Contains(author, ".") && !strings.Contains(author, " ") { + return buildkite.Author{Email: author} + } + + // Check for name format (contains spaces but no email) + if strings.Contains(author, " ") { + return buildkite.Author{Name: author} + } + + // Default to username + return buildkite.Author{Username: author} +} + +func createBuild(ctx context.Context, org string, pipeline string, f *factory.Factory, message string, commit string, branch string, web bool, env map[string]string, metaData map[string]string, ignoreBranchFilters bool, author string) error { + var actionErr error + var build buildkite.Build + spinErr := bk_io.SpinWhile(fmt.Sprintf("Starting new build for %s", pipeline), func() { + branch = strings.TrimSpace(branch) + if len(branch) == 0 { + p, _, err := f.RestAPIClient.Pipelines.Get(ctx, org, pipeline) + if err != nil { + actionErr = bkErrors.WrapAPIError(err, "fetching pipeline information") + return + } + + // Check if the pipeline has a default branch set + if p.DefaultBranch == "" { + actionErr = bkErrors.NewValidationError( + nil, + fmt.Sprintf("No default branch set for pipeline %s", pipeline), + "Please specify a branch using --branch (-b)", + "Set a default branch in your pipeline settings on Buildkite", + ) + return + } + branch = p.DefaultBranch + } + + newBuild := buildkite.CreateBuild{ + Message: message, + Commit: commit, + Branch: branch, + Author: parseAuthor(author), + Env: env, + MetaData: metaData, + IgnorePipelineBranchFilters: ignoreBranchFilters, + } + + var err error + build, _, err = f.RestAPIClient.Builds.Create(ctx, org, pipeline, newBuild) + if err != nil { + actionErr = bkErrors.WrapAPIError(err, "creating build") + return + } + }) + if spinErr != nil { + return bkErrors.NewInternalError(spinErr, "error in spinner UI") + } + + if actionErr != nil { + return actionErr + } + + if build.WebURL == "" { + return bkErrors.NewAPIError( + nil, + "build was created but no URL was returned", + "This may be due to an API version mismatch", + ) + } + + fmt.Printf("%s\n", renderResult(fmt.Sprintf("Build created: %s", build.WebURL))) + + if err := util.OpenInWebBrowser(web, build.WebURL); err != nil { + return bkErrors.NewInternalError(err, "failed to open web browser") + } + + return nil +} + +func renderResult(result string) string { + return lipgloss.JoinVertical(lipgloss.Top, + lipgloss.NewStyle().Padding(0, 0).Render(result)) +} From df5f1ab77051f17d49251176d558d5dd8cd3ee3a Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 15:22:44 +0000 Subject: [PATCH 05/27] Remove new.go --- pkg/cmd/build/new.go | 260 ------------------------------------------- 1 file changed, 260 deletions(-) delete mode 100644 pkg/cmd/build/new.go diff --git a/pkg/cmd/build/new.go b/pkg/cmd/build/new.go deleted file mode 100644 index 38277293..00000000 --- a/pkg/cmd/build/new.go +++ /dev/null @@ -1,260 +0,0 @@ -package build - -import ( - "bufio" - "context" - "fmt" - "os" - "strings" - - "github.com/MakeNowJust/heredoc" - bkErrors "github.com/buildkite/cli/v3/internal/errors" - bk_io "github.com/buildkite/cli/v3/internal/io" - "github.com/buildkite/cli/v3/internal/pipeline/resolver" - "github.com/buildkite/cli/v3/internal/util" - "github.com/buildkite/cli/v3/internal/validation/scopes" - "github.com/buildkite/cli/v3/pkg/cmd/factory" - buildkite "github.com/buildkite/go-buildkite/v4" - "github.com/spf13/cobra" - "github.com/spf13/pflag" -) - -func NewCmdBuildNew(f *factory.Factory) *cobra.Command { - var branch string - var commit string - var message string - var pipeline string - var web bool - var ignoreBranchFilters bool - var env []string - var metaData []string - envMap := make(map[string]string) - metaDataMap := make(map[string]string) - var envFile string - var author string - - cmd := cobra.Command{ - DisableFlagsInUseLine: true, - Use: "new [flags]", - Short: "Create a new build", - Args: cobra.NoArgs, - Long: heredoc.Doc(` - Create a new build on a pipeline. - The web URL to the build will be printed to stdout. - - ## To create a new build - $ bk build new - - ## To create a new build with environment variables set - $ bk build new -e "FOO=BAR" -e "BAR=BAZ" - - ## To create a new build with metadata - $ bk build new -M "key=value" -M "foo=bar" - `), - PreRunE: bkErrors.WrapRunE(func(cmd *cobra.Command, args []string) error { - f.SetGlobalFlags(cmd) - return nil - }), - RunE: bkErrors.WrapRunE(func(cmd *cobra.Command, args []string) error { - // Get pipeline from persistent flag - pipeline, _ = cmd.Flags().GetString("pipeline") - - resolvers := resolver.NewAggregateResolver( - resolver.ResolveFromFlag(pipeline, f.Config), - resolver.ResolveFromConfig(f.Config, resolver.PickOne), - resolver.ResolveFromRepository(f, resolver.CachedPicker(f.Config, resolver.PickOne, f.GitRepository != nil)), - ) - - resolvedPipeline, err := resolvers.Resolve(cmd.Context()) - if err != nil { - return err // Already wrapped by resolver - } - if resolvedPipeline == nil { - return bkErrors.NewResourceNotFoundError( - nil, - "could not resolve a pipeline", - "Specify a pipeline with --pipeline (-p)", - "Run 'bk pipeline list' to see available pipelines", - ) - } - - confirmed, err := bk_io.Confirm(f, fmt.Sprintf("Create new build on %s?", resolvedPipeline.Name)) - if err != nil { - return bkErrors.NewUserAbortedError(err, "confirmation canceled") - } - - if confirmed { - // Process environment variables - for _, e := range env { - key, value, _ := strings.Cut(e, "=") - envMap[key] = value - } - - // Process metadata variables - for _, m := range metaData { - key, value, _ := strings.Cut(m, "=") - metaDataMap[key] = value - } - - // Process environment file if specified - if envFile != "" { - file, err := os.Open(envFile) - if err != nil { - return bkErrors.NewValidationError( - err, - fmt.Sprintf("could not open environment file: %s", envFile), - "Check that the file exists and is readable", - ) - } - defer file.Close() - - content := bufio.NewScanner(file) - for content.Scan() { - key, value, _ := strings.Cut(content.Text(), "=") - envMap[key] = value - } - - if err := content.Err(); err != nil { - return bkErrors.NewValidationError( - err, - "error reading environment file", - "Ensure the file contains valid environment variables in KEY=VALUE format", - ) - } - } - - return newBuild(cmd.Context(), resolvedPipeline.Org, resolvedPipeline.Name, f, message, commit, branch, web, envMap, metaDataMap, ignoreBranchFilters, author) - } else { - // User chose not to proceed - provide feedback - fmt.Fprintf(cmd.OutOrStdout(), "Build creation canceled\n") - return nil - } - }), - } - - cmd.Annotations = map[string]string{ - "requiredScopes": string(scopes.WriteBuilds), - } - - cmd.Flags().StringVarP(&message, "message", "m", "", "Description of the build. If left blank, the commit message will be used once the build starts.") - cmd.Flags().StringVarP(&commit, "commit", "c", "HEAD", "The commit to build.") - cmd.Flags().StringVarP(&branch, "branch", "b", "", "The branch to build. Defaults to the default branch of the pipeline.") - cmd.Flags().StringVarP(&author, "author", "a", "", "Author of the build. Supports: \"Name \", \"email@domain.com\", \"Full Name\", or \"username\"") - cmd.Flags().BoolVarP(&web, "web", "w", false, "Open the build in a web browser after it has been created.") - // Pipeline flag now inherited from parent command - cmd.Flags().StringArrayVarP(&env, "env", "e", []string{}, "Set environment variables for the build") - cmd.Flags().StringArrayVarP(&metaData, "metadata", "M", []string{}, "Set metadata for the build (KEY=VALUE)") - cmd.Flags().BoolVarP(&ignoreBranchFilters, "ignore-branch-filters", "i", false, "Ignore branch filters for the pipeline") - cmd.Flags().StringVarP(&envFile, "env-file", "f", "", "Set the environment variables for the build via an environment file") - cmd.Flags().StringVarP(&envFile, "envFile", "", "", "Set the environment variables for the build via an environment file") - _ = cmd.Flags().MarkDeprecated("envFile", "use --env-file instead") - cmd.Flags().SetNormalizeFunc(normaliseFlags) - cmd.Flags().SortFlags = false - return &cmd -} - -func parseAuthor(author string) buildkite.Author { - if author == "" { - return buildkite.Author{} - } - - // Check for Git-style format: "Name " - if strings.Contains(author, "<") && strings.Contains(author, ">") { - parts := strings.Split(author, "<") - if len(parts) == 2 { - name := strings.TrimSpace(parts[0]) - email := strings.TrimSpace(strings.Trim(parts[1], ">")) - if name != "" && email != "" { - return buildkite.Author{Name: name, Email: email} - } - } - } - - // Check for email-only format - if strings.Contains(author, "@") && strings.Contains(author, ".") && !strings.Contains(author, " ") { - return buildkite.Author{Email: author} - } - - // Check for name format (contains spaces but no email) - if strings.Contains(author, " ") { - return buildkite.Author{Name: author} - } - - // Default to username - return buildkite.Author{Username: author} -} - -func newBuild(ctx context.Context, org string, pipeline string, f *factory.Factory, message string, commit string, branch string, web bool, env map[string]string, metaData map[string]string, ignoreBranchFilters bool, author string) error { - var actionErr error - var build buildkite.Build - spinErr := bk_io.SpinWhile(fmt.Sprintf("Starting new build for %s", pipeline), func() { - branch = strings.TrimSpace(branch) - if len(branch) == 0 { - p, _, err := f.RestAPIClient.Pipelines.Get(ctx, org, pipeline) - if err != nil { - actionErr = bkErrors.WrapAPIError(err, "fetching pipeline information") - return - } - - // Check if the pipeline has a default branch set - if p.DefaultBranch == "" { - actionErr = bkErrors.NewValidationError( - nil, - fmt.Sprintf("No default branch set for pipeline %s", pipeline), - "Please specify a branch using --branch (-b)", - "Set a default branch in your pipeline settings on Buildkite", - ) - return - } - branch = p.DefaultBranch - } - - newBuild := buildkite.CreateBuild{ - Message: message, - Commit: commit, - Branch: branch, - Author: parseAuthor(author), - Env: env, - MetaData: metaData, - IgnorePipelineBranchFilters: ignoreBranchFilters, - } - - var err error - build, _, err = f.RestAPIClient.Builds.Create(ctx, org, pipeline, newBuild) - if err != nil { - actionErr = bkErrors.WrapAPIError(err, "creating build") - return - } - }) - if spinErr != nil { - return bkErrors.NewInternalError(spinErr, "error in spinner UI") - } - - if actionErr != nil { - return actionErr - } - - if build.WebURL == "" { - return bkErrors.NewAPIError( - nil, - "build was created but no URL was returned", - "This may be due to an API version mismatch", - ) - } - - fmt.Printf("%s\n", renderResult(fmt.Sprintf("Build created: %s", build.WebURL))) - - if err := util.OpenInWebBrowser(web, build.WebURL); err != nil { - return bkErrors.NewInternalError(err, "failed to open web browser") - } - - return nil -} - -func normaliseFlags(pf *pflag.FlagSet, name string) pflag.NormalizedName { - switch name { - case "envFile": - name = "env-file" - } - return pflag.NormalizedName(name) -} From 7505168d3450fa7c60a29d862106e5b3d6d86839 Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 15:25:01 +0000 Subject: [PATCH 06/27] Remove old reference to new.go --- pkg/cmd/build/build.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/cmd/build/build.go b/pkg/cmd/build/build.go index 79ee4a45..00b49b1a 100644 --- a/pkg/cmd/build/build.go +++ b/pkg/cmd/build/build.go @@ -32,7 +32,6 @@ func NewCmdBuild(f *factory.Factory) *cobra.Command { cmd.AddCommand(NewCmdBuildCancel(f)) cmd.AddCommand(NewCmdBuildDownload(f)) cmd.AddCommand(NewCmdBuildList(f)) - cmd.AddCommand(NewCmdBuildNew(f)) cmd.AddCommand(NewCmdBuildRebuild(f)) cmd.AddCommand(NewCmdBuildView(f)) cmd.AddCommand(NewCmdBuildWatch(f)) From 156999a1fe5bf7fce66810f634bbd49c47bf5a7a Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 15:49:31 +0000 Subject: [PATCH 07/27] [skip ci] Add Kong cancel.go --- cmd/build/cancel.go | 94 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 cmd/build/cancel.go diff --git a/cmd/build/cancel.go b/cmd/build/cancel.go new file mode 100644 index 00000000..3bccee56 --- /dev/null +++ b/cmd/build/cancel.go @@ -0,0 +1,94 @@ +package build + +import ( + "context" + "fmt" + + "github.com/alecthomas/kong" + buildResolver "github.com/buildkite/cli/v3/internal/build/resolver" + "github.com/buildkite/cli/v3/internal/cli" + bk_io "github.com/buildkite/cli/v3/internal/io" + pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" + "github.com/buildkite/cli/v3/internal/util" + "github.com/buildkite/cli/v3/internal/version" + "github.com/buildkite/cli/v3/pkg/cmd/factory" + "github.com/buildkite/cli/v3/pkg/cmd/validation" + buildkite "github.com/buildkite/go-buildkite/v4" +) + +type CancelCmd struct { + BuildNumber string `arg:"" help:"Build number to cancel"` + Pipeline string `help:"The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}." short:"p"` + Web bool `help:"Open the build in a web browser after it has been cancelled." short:"w"` +} + +func (c *CancelCmd) Help() string { + return ` +Examples: + # Cancel a build by number + $ bk build cancel 123 --pipeline my-pipeline + + # Cancel a build and open in browser + $ bk build cancel 123 -pipeline my-pipeline --web` +} + +func (c *CancelCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { + f, err := factory.New(version.Version) + if err != nil { + return err + } + + f.SkipConfirm = globals.SkipConfirmation() + f.NoInput = globals.DisableInput() + + if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { + return err + } + + ctx := context.Background() + + pipelineRes := pipelineResolver.NewAggregateResolver( + pipelineResolver.ResolveFromFlag(c.Pipeline, f.Config), + pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOne), + pipelineResolver.ResolveFromRepository(f, pipelineResolver.CachedPicker(f.Config, pipelineResolver.PickOne, f.GitRepository != nil)), + ) + + args := []string{c.BuildNumber} + buildRes := buildResolver.NewAggregateResolver( + buildResolver.ResolveFromPositionalArgument(args, 0, pipelineRes.Resolve, f.Config), + ) + + bld, err := buildRes.Resolve(ctx) + if err != nil { + return err + } + + confirmed, err := bk_io.Confirm(f, fmt.Sprintf("Cancel build #%d on %s", bld.BuildNumber, bld.Pipeline)) + if err != nil { + return err + } + + if !confirmed { + return nil + } + + return cancelBuild(ctx, bld.Organization, bld.Pipeline, fmt.Sprint(bld.BuildNumber), c.Web, f) +} + +func cancelBuild(ctx context.Context, org string, pipeline string, buildId string, web bool, f *factory.Factory) error { + var err error + var build buildkite.Build + spinErr := bk_io.SpinWhile(fmt.Sprintf("Cancelling build #%s from pipeline %s", buildId, pipeline), func() { + build, err = f.RestAPIClient.Builds.Cancel(ctx, org, pipeline, buildId) + }) + if spinErr != nil { + return spinErr + } + if err != nil { + return err + } + + fmt.Printf("%s\n", renderResult(fmt.Sprintf("Build canceled: %s", build.WebURL))) + + return util.OpenInWebBrowser(web, build.WebURL) +} From 83200103342ea98ad354e0f0e13cd42e84c48ce2 Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 15:49:51 +0000 Subject: [PATCH 08/27] [skip ci] Add cancel to main.go --- main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/main.go b/main.go index 3e4d002d..a0cf6330 100644 --- a/main.go +++ b/main.go @@ -50,6 +50,7 @@ type ( } BuildCmd struct { Create build.CreateCmd `cmd:"" aliases:"new" help:"Create a new build."` // Aliasing "new" because we've renamed this to "create", but we need to support backwards compatibility + Cancel build.CancelCmd `cmd:"" help:"Cancel a build."` } ClusterCmd struct { Args []string `arg:"" optional:"" passthrough:"all"` From 16c3b86a1b802512663c1eced53190323f8be35e Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 15:50:06 +0000 Subject: [PATCH 09/27] [skip ci] Remove old cancel.go from build.go --- pkg/cmd/build/build.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/cmd/build/build.go b/pkg/cmd/build/build.go index 00b49b1a..a37128bb 100644 --- a/pkg/cmd/build/build.go +++ b/pkg/cmd/build/build.go @@ -29,7 +29,6 @@ func NewCmdBuild(f *factory.Factory) *cobra.Command { cmd.PersistentFlags().StringP("pipeline", "p", "", "The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}.") - cmd.AddCommand(NewCmdBuildCancel(f)) cmd.AddCommand(NewCmdBuildDownload(f)) cmd.AddCommand(NewCmdBuildList(f)) cmd.AddCommand(NewCmdBuildRebuild(f)) From c58d656e6e5f9e0dcd1efea3e880f69a5f19cf23 Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 15:50:33 +0000 Subject: [PATCH 10/27] Saying bye to cobra build cancel --- pkg/cmd/build/cancel.go | 93 ----------------------------------------- 1 file changed, 93 deletions(-) delete mode 100644 pkg/cmd/build/cancel.go diff --git a/pkg/cmd/build/cancel.go b/pkg/cmd/build/cancel.go deleted file mode 100644 index 2e121f38..00000000 --- a/pkg/cmd/build/cancel.go +++ /dev/null @@ -1,93 +0,0 @@ -package build - -import ( - "context" - "fmt" - - "github.com/MakeNowJust/heredoc" - buildResolver "github.com/buildkite/cli/v3/internal/build/resolver" - bk_io "github.com/buildkite/cli/v3/internal/io" - pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" - "github.com/buildkite/cli/v3/internal/util" - "github.com/buildkite/cli/v3/internal/validation/scopes" - "github.com/buildkite/cli/v3/pkg/cmd/factory" - buildkite "github.com/buildkite/go-buildkite/v4" - "github.com/spf13/cobra" -) - -func NewCmdBuildCancel(f *factory.Factory) *cobra.Command { - var web bool - var pipeline string - - cmd := cobra.Command{ - DisableFlagsInUseLine: true, - Use: "cancel [flags]", - Args: cobra.ExactArgs(1), - Short: "Cancel a build.", - Long: heredoc.Doc(` - Cancel the given build. - `), - PreRunE: func(cmd *cobra.Command, args []string) error { - f.SetGlobalFlags(cmd) - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { - // Get pipeline from persistent flag - pipeline, _ = cmd.Flags().GetString("pipeline") - - pipelineRes := pipelineResolver.NewAggregateResolver( - pipelineResolver.ResolveFromFlag(pipeline, f.Config), - pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOne), - pipelineResolver.ResolveFromRepository(f, pipelineResolver.CachedPicker(f.Config, pipelineResolver.PickOne, f.GitRepository != nil)), - ) - - buildRes := buildResolver.NewAggregateResolver( - buildResolver.ResolveFromPositionalArgument(args, 0, pipelineRes.Resolve, f.Config), - ) - - bld, err := buildRes.Resolve(cmd.Context()) - if err != nil { - return err - } - - confirmed, err := bk_io.Confirm(f, fmt.Sprintf("Cancel build #%d on %s", bld.BuildNumber, bld.Pipeline)) - if err != nil { - return err - } - - if confirmed { - return cancelBuild(cmd.Context(), bld.Organization, bld.Pipeline, fmt.Sprint(bld.BuildNumber), web, f) - } - - return nil - }, - } - - cmd.Annotations = map[string]string{ - "requiredScopes": string(scopes.WriteBuilds), - } - - cmd.Flags().BoolVarP(&web, "web", "w", false, "Open the build in a web browser after it has been cancelled.") - // Pipeline flag now inherited from parent command - cmd.Flags().SortFlags = false - - return &cmd -} - -func cancelBuild(ctx context.Context, org string, pipeline string, buildId string, web bool, f *factory.Factory) error { - var err error - var build buildkite.Build - spinErr := bk_io.SpinWhile(fmt.Sprintf("Cancelling build #%s from pipeline %s", buildId, pipeline), func() { - build, err = f.RestAPIClient.Builds.Cancel(ctx, org, pipeline, buildId) - }) - if spinErr != nil { - return spinErr - } - if err != nil { - return err - } - - fmt.Printf("%s\n", renderResult(fmt.Sprintf("Build canceled: %s", build.WebURL))) - - return util.OpenInWebBrowser(web, build.WebURL) -} From 03a5c134f1f7199e0d0629333adbad130384d395 Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 16:10:46 +0000 Subject: [PATCH 11/27] [skip ci] Kongified view.go Had to change a few bits here to add the --output flag as it was a bit weird on this one. For some reason it expected --format? Anyway, sorted. --- cmd/build/view.go | 208 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 cmd/build/view.go diff --git a/cmd/build/view.go b/cmd/build/view.go new file mode 100644 index 00000000..b2a4bf29 --- /dev/null +++ b/cmd/build/view.go @@ -0,0 +1,208 @@ +package build + +import ( + "context" + "fmt" + "os" + "sync" + + "github.com/alecthomas/kong" + "github.com/buildkite/cli/v3/internal/build/models" + buildResolver "github.com/buildkite/cli/v3/internal/build/resolver" + "github.com/buildkite/cli/v3/internal/build/resolver/options" + "github.com/buildkite/cli/v3/internal/build/view" + "github.com/buildkite/cli/v3/internal/cli" + bk_io "github.com/buildkite/cli/v3/internal/io" + pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" + "github.com/buildkite/cli/v3/internal/version" + "github.com/buildkite/cli/v3/pkg/cmd/factory" + "github.com/buildkite/cli/v3/pkg/cmd/validation" + "github.com/buildkite/cli/v3/pkg/output" + buildkite "github.com/buildkite/go-buildkite/v4" + "github.com/pkg/browser" +) + +type ViewCmd struct { + BuildNumber string `arg:"" optional:"" help:"Build number to view (omit for most recent build)"` + Pipeline string `help:"The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}." short:"p"` + Branch string `help:"Filter builds to this branch." short:"b"` + User string `help:"Filter builds to this user. You can use name or email." short:"u" xor:"userfilter"` + Mine bool `help:"Filter builds to only my user." xor:"userfilter"` + Web bool `help:"Open the build in a web browser." short:"w"` + Output string `help:"Output format. One of: json, yaml, text" short:"o" default:"json"` +} + +func (c *ViewCmd) Help() string { + return `You can pass an optional build number to view. If omitted, the most recent build on the current branch will be resolved. + +Examples: + # By default, the most recent build for the current branch is shown + $ bk build view + + # If not inside a repository or to use a specific pipeline, pass -p + $ bk build view -p monolith + + # To view a specific build + $ bk build view 429 + + # Add -w to any command to open the build in your web browser instead + $ bk build view -w 429 + + # To view the most recent build on feature-x branch + $ bk build view -b feature-y + + # You can filter by a user name or id + $ bk build view -u "alice" + + # A shortcut to view your builds is --mine + $ bk build view --mine + + # You can combine most of these flags + # To view most recent build by greg on the deploy-pipeline + $ bk build view -p deploy-pipeline -u "greg"` +} + +func (c *ViewCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { + f, err := factory.New(version.Version) + if err != nil { + return err + } + + f.SkipConfirm = globals.SkipConfirmation() + f.NoInput = globals.DisableInput() + + if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { + return err + } + + ctx := context.Background() + + var opts view.ViewOptions + opts.Pipeline = c.Pipeline + opts.Web = c.Web + + // Resolve pipeline first + pipelineRes := pipelineResolver.NewAggregateResolver( + pipelineResolver.ResolveFromFlag(opts.Pipeline, f.Config), + pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOne), + pipelineResolver.ResolveFromRepository(f, pipelineResolver.CachedPicker(f.Config, pipelineResolver.PickOne, f.GitRepository != nil)), + ) + + // Resolve build options + optionsResolver := options.AggregateResolver{ + options.ResolveBranchFromFlag(c.Branch), + options.ResolveBranchFromRepository(f.GitRepository), + }.WithResolverWhen( + c.User != "", + options.ResolveUserFromFlag(c.User), + ).WithResolverWhen( + c.Mine || c.User == "", + options.ResolveCurrentUser(ctx, f), + ) + + // Resolve build + args := []string{} + if c.BuildNumber != "" { + args = []string{c.BuildNumber} + } + buildRes := buildResolver.NewAggregateResolver( + buildResolver.ResolveFromPositionalArgument(args, 0, pipelineRes.Resolve, f.Config), + buildResolver.ResolveBuildWithOpts(f, pipelineRes.Resolve, optionsResolver...), + ) + + bld, err := buildRes.Resolve(ctx) + if err != nil { + return err + } + if bld == nil { + fmt.Println("No build found.") + return nil + } + + opts.Organization = bld.Organization + opts.Pipeline = bld.Pipeline + opts.BuildNumber = bld.BuildNumber + + if err := opts.Validate(); err != nil { + return err + } + + if opts.Web { + buildURL := fmt.Sprintf("https://buildkite.com/%s/%s/builds/%d", + opts.Organization, opts.Pipeline, opts.BuildNumber) + fmt.Printf("Opening %s in your browser\n", buildURL) + return browser.OpenURL(buildURL) + } + + var build buildkite.Build + var artifacts []buildkite.Artifact + var annotations []buildkite.Annotation + var wg sync.WaitGroup + var mu sync.Mutex + + spinErr := bk_io.SpinWhile("Loading build information", func() { + wg.Add(3) + go func() { + defer wg.Done() + var apiErr error + build, _, apiErr = f.RestAPIClient.Builds.Get( + ctx, + opts.Organization, + opts.Pipeline, + fmt.Sprint(opts.BuildNumber), + nil, + ) + if apiErr != nil { + mu.Lock() + err = apiErr + mu.Unlock() + } + }() + + go func() { + defer wg.Done() + var apiErr error + artifacts, _, apiErr = f.RestAPIClient.Artifacts.ListByBuild( + ctx, + opts.Organization, + opts.Pipeline, + fmt.Sprint(opts.BuildNumber), + nil, + ) + if apiErr != nil { + mu.Lock() + err = apiErr + mu.Unlock() + } + }() + + go func() { + defer wg.Done() + var apiErr error + annotations, _, apiErr = f.RestAPIClient.Annotations.ListByBuild( + ctx, + opts.Organization, + opts.Pipeline, + fmt.Sprint(opts.BuildNumber), + nil, + ) + if apiErr != nil { + mu.Lock() + err = apiErr + mu.Unlock() + } + }() + + wg.Wait() + }) + if spinErr != nil { + return spinErr + } + if err != nil { + return err + } + + buildView := models.NewBuildView(&build, artifacts, annotations) + + return output.Write(os.Stdout, buildView, output.Format(c.Output)) +} From 181ef9f317f9c2ef640fa21a5f482b0bbe6ab6db Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 16:11:11 +0000 Subject: [PATCH 12/27] [skip ci] Add main.go entry for view.go --- main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/main.go b/main.go index a0cf6330..7e65df8c 100644 --- a/main.go +++ b/main.go @@ -51,6 +51,7 @@ type ( BuildCmd struct { Create build.CreateCmd `cmd:"" aliases:"new" help:"Create a new build."` // Aliasing "new" because we've renamed this to "create", but we need to support backwards compatibility Cancel build.CancelCmd `cmd:"" help:"Cancel a build."` + View build.ViewCmd `cmd:"" help:"View build information."` } ClusterCmd struct { Args []string `arg:"" optional:"" passthrough:"all"` From 3018c0aef60d2083afe6b23d31c55283129ec34d Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 16:11:27 +0000 Subject: [PATCH 13/27] [skip ci] Remove old view.go entry on build.go --- pkg/cmd/build/build.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/cmd/build/build.go b/pkg/cmd/build/build.go index a37128bb..22a613b3 100644 --- a/pkg/cmd/build/build.go +++ b/pkg/cmd/build/build.go @@ -32,7 +32,6 @@ func NewCmdBuild(f *factory.Factory) *cobra.Command { cmd.AddCommand(NewCmdBuildDownload(f)) cmd.AddCommand(NewCmdBuildList(f)) cmd.AddCommand(NewCmdBuildRebuild(f)) - cmd.AddCommand(NewCmdBuildView(f)) cmd.AddCommand(NewCmdBuildWatch(f)) return &cmd From 9e8cb5c4a2e6c392a494c3feaa56d4f80e1e572d Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 16:11:46 +0000 Subject: [PATCH 14/27] Remove old view.go --- pkg/cmd/build/view.go | 198 ------------------------------------------ 1 file changed, 198 deletions(-) delete mode 100644 pkg/cmd/build/view.go diff --git a/pkg/cmd/build/view.go b/pkg/cmd/build/view.go deleted file mode 100644 index 3bab89d5..00000000 --- a/pkg/cmd/build/view.go +++ /dev/null @@ -1,198 +0,0 @@ -package build - -import ( - "fmt" - "sync" - - "github.com/MakeNowJust/heredoc" - "github.com/buildkite/cli/v3/internal/build/models" - buildResolver "github.com/buildkite/cli/v3/internal/build/resolver" - "github.com/buildkite/cli/v3/internal/build/resolver/options" - "github.com/buildkite/cli/v3/internal/build/view" - bk_io "github.com/buildkite/cli/v3/internal/io" - pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" - "github.com/buildkite/cli/v3/pkg/cmd/factory" - "github.com/buildkite/cli/v3/pkg/output" - buildkite "github.com/buildkite/go-buildkite/v4" - "github.com/pkg/browser" - "github.com/spf13/cobra" -) - -func NewCmdBuildView(f *factory.Factory) *cobra.Command { - var opts view.ViewOptions - var mine bool - var branch, user string - - cmd := &cobra.Command{ - DisableFlagsInUseLine: true, - Use: "view [number] [flags]", - Short: "View build information", - Args: cobra.MaximumNArgs(1), - Long: heredoc.Doc(` - View a build's information. - You can pass an optional build number to view. If omitted, the most recent build on the current branch will be resolved. - `), - Example: heredoc.Doc(` - # by default, the most recent build for the current branch is shown - $ bk build view - # if not inside a repository or to use a specific pipeline, pass -p - $ bk build view -p monolith - # to view a specific build - $ bk build view 429 - # add -w to any command to open the build in your web browser instead - $ bk build view -w 429 - # to view the most recent build on feature-x branch - $ bk build view -b feature-y - # you can filter by a user name or id - $ bk build view -u "alice" - # a shortcut to view your builds is --mine - $ bk build view --mine - # you can combine most of these flags - # to view most recent build by greg on the deploy-pipeline - $ bk build view -p deploy-pipeline -u "greg" - `), - RunE: func(cmd *cobra.Command, args []string) error { - format, err := output.GetFormat(cmd.Flags()) - if err != nil { - return err - } - - // Get pipeline from persistent flag - opts.Pipeline, _ = cmd.Flags().GetString("pipeline") - - // Resolve pipeline first - pipelineRes := pipelineResolver.NewAggregateResolver( - pipelineResolver.ResolveFromFlag(opts.Pipeline, f.Config), - pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOne), - pipelineResolver.ResolveFromRepository(f, pipelineResolver.CachedPicker(f.Config, pipelineResolver.PickOne, f.GitRepository != nil)), - ) - - // Resolve build options - optionsResolver := options.AggregateResolver{ - options.ResolveBranchFromFlag(branch), - options.ResolveBranchFromRepository(f.GitRepository), - }.WithResolverWhen( - user != "", - options.ResolveUserFromFlag(user), - ).WithResolverWhen( - mine || user == "", - options.ResolveCurrentUser(cmd.Context(), f), - ) - - // Resolve build - 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 { - fmt.Fprintf(cmd.OutOrStdout(), "No build found.\n") - return nil - } - - opts.Organization = bld.Organization - opts.Pipeline = bld.Pipeline - opts.BuildNumber = bld.BuildNumber - - if err := opts.Validate(); err != nil { - return err - } - - if opts.Web { - buildURL := fmt.Sprintf("https://buildkite.com/%s/%s/builds/%d", - opts.Organization, opts.Pipeline, opts.BuildNumber) - fmt.Printf("Opening %s in your browser\n", buildURL) - return browser.OpenURL(buildURL) - } - - var build buildkite.Build - var artifacts []buildkite.Artifact - var annotations []buildkite.Annotation - var wg sync.WaitGroup - var mu sync.Mutex - - spinErr := bk_io.SpinWhile("Loading build information", func() { - wg.Add(3) - go func() { - defer wg.Done() - var apiErr error - build, _, apiErr = f.RestAPIClient.Builds.Get( - cmd.Context(), - opts.Organization, - opts.Pipeline, - fmt.Sprint(opts.BuildNumber), - nil, - ) - if apiErr != nil { - mu.Lock() - err = apiErr - mu.Unlock() - } - }() - - go func() { - defer wg.Done() - var apiErr error - artifacts, _, apiErr = f.RestAPIClient.Artifacts.ListByBuild( - cmd.Context(), - opts.Organization, - opts.Pipeline, - fmt.Sprint(opts.BuildNumber), - nil, - ) - if apiErr != nil { - mu.Lock() - err = apiErr - mu.Unlock() - } - }() - - go func() { - defer wg.Done() - var apiErr error - annotations, _, apiErr = f.RestAPIClient.Annotations.ListByBuild( - cmd.Context(), - opts.Organization, - opts.Pipeline, - fmt.Sprint(opts.BuildNumber), - nil, - ) - if apiErr != nil { - mu.Lock() - err = apiErr - mu.Unlock() - } - }() - - wg.Wait() - }) - if spinErr != nil { - return spinErr - } - if err != nil { - return err - } - - // Create structured view for output using models package - buildView := models.NewBuildView(&build, artifacts, annotations) - - return output.Write(cmd.OutOrStdout(), buildView, format) - }, - } - - cmd.Flags().BoolVar(&mine, "mine", false, "Filter builds to only my user.") - cmd.Flags().BoolVar(&opts.Web, "web", false, "Open the build in a web browser.") - cmd.Flags().StringVar(&branch, "branch", "", "Filter builds to this branch.") - cmd.Flags().StringVar(&user, "user", "", "Filter builds to this user. You can use name or email.") - - // can only supply --user or --mine - cmd.MarkFlagsMutuallyExclusive("user", "mine") - - output.AddFlags(cmd.Flags()) - cmd.Flags().SortFlags = false - return cmd -} From dd75ee0c21307f76270ec67f063151764f0ab5d9 Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 16:24:19 +0000 Subject: [PATCH 15/27] [skip ci] Kongified list.go --- cmd/build/list.go | 567 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 567 insertions(+) create mode 100644 cmd/build/list.go diff --git a/cmd/build/list.go b/cmd/build/list.go new file mode 100644 index 00000000..6004ead5 --- /dev/null +++ b/cmd/build/list.go @@ -0,0 +1,567 @@ +package build + +import ( + "context" + "encoding/base64" + "fmt" + "net/mail" + "os" + "strings" + "time" + + "github.com/alecthomas/kong" + "github.com/buildkite/cli/v3/internal/cli" + "github.com/buildkite/cli/v3/internal/graphql" + "github.com/buildkite/cli/v3/internal/io" + pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" + "github.com/buildkite/cli/v3/internal/version" + "github.com/buildkite/cli/v3/pkg/cmd/factory" + "github.com/buildkite/cli/v3/pkg/cmd/validation" + "github.com/buildkite/cli/v3/pkg/output" + buildkite "github.com/buildkite/go-buildkite/v4" + "github.com/charmbracelet/lipgloss" +) + +const ( + maxBuildLimit = 5000 + pageSize = 100 +) + +type ListCmd struct { + Pipeline string `help:"The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}." short:"p"` + Since string `help:"Filter builds created since this time (e.g. 1h, 30m)"` + Until string `help:"Filter builds created before this time (e.g. 1h, 30m)"` + Duration string `help:"Filter by duration (e.g. >5m, <10m, 20m) - supports >, <, >=, <= operators"` + State []string `help:"Filter by build state"` + Branch []string `help:"Filter by branch name"` + Creator string `help:"Filter by creator (email address or user ID)"` + Commit string `help:"Filter by commit SHA"` + Message string `help:"Filter by message content"` + Limit int `help:"Maximum number of builds to return" default:"50"` + NoLimit bool `help:"Fetch all builds (overrides --limit)"` + Output string `help:"Output format. One of: json, yaml, text" short:"o" default:"json"` +} + +func (c *ListCmd) Help() string { + return `List builds with optional filtering. + +This command supports both server-side filtering (fast) and client-side filtering. +Server-side filters are applied by the Buildkite API, while client-side filters +are applied after fetching results and may require loading more builds. + +Client-side filters: --duration, --message +Server-side filters: --pipeline, --since, --until, --state, --branch, --creator, --commit + +Builds can be filtered by their duration, message content, and other attributes. +When filtering by duration, you can use operators like >, <, >=, and <= to specify your criteria. +Supported duration units are seconds (s), minutes (m), and hours (h). + +Examples: + # List recent builds (50 by default) + $ bk build list + + # Get more builds (automatically paginates) + $ bk build list --limit 500 + + # List builds from the last hour + $ bk build list --since 1h + + # List failed builds + $ bk build list --state failed + + # List builds on main branch + $ bk build list --branch main + + # List builds by alice + $ bk build list --creator alice@company.com + + # List builds that took longer than 20 minutes + $ bk build list --duration ">20m" + + # List builds that finished in under 5 minutes + $ bk build list --duration "<5m" + + # Combine filters: failed builds on main branch in the last 24 hours + $ bk build list --state failed --branch main --since 24h + + # Find builds containing "deploy" in the message + $ bk build list --message deploy + + # Complex filtering: slow builds (>30m) that failed on feature branches + $ bk build list --duration ">30m" --state failed --branch feature/` +} + +func (c *ListCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { + f, err := factory.New(version.Version) + if err != nil { + return err + } + + f.SkipConfirm = globals.SkipConfirmation() + f.NoInput = globals.DisableInput() + + if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { + return err + } + + ctx := context.Background() + + if !c.NoLimit { + if c.Limit > maxBuildLimit { + return fmt.Errorf("limit cannot exceed %d builds (requested: %d); if you need more, use --no-limit", maxBuildLimit, c.Limit) + } + } + + if c.Creator != "" && isValidEmail(c.Creator) { + originalEmail := c.Creator + err = io.SpinWhile("Looking up user", func() { + c.Creator, err = resolveCreatorEmailToUserID(ctx, f, originalEmail) + }) + if err != nil { + return fmt.Errorf("failed to resolve creator email: %w", err) + } + if c.Creator == "" { + return fmt.Errorf("failed to resolve creator email: no user found") + } + } + + listOpts, err := c.buildListOptions() + if err != nil { + return err + } + + org := f.Config.OrganizationSlug() + builds, err := c.fetchBuilds(ctx, f, org, listOpts) + if err != nil { + return fmt.Errorf("failed to list builds: %w", err) + } + + if len(builds) == 0 { + fmt.Println("No builds found matching the specified criteria.") + return nil + } + + format := output.Format(c.Output) + if format == output.FormatText { + return nil + } + + return displayBuilds(builds, format, false) +} + +func (c *ListCmd) buildListOptions() (*buildkite.BuildsListOptions, error) { + listOpts := &buildkite.BuildsListOptions{ + ListOptions: buildkite.ListOptions{ + PerPage: pageSize, + }, + } + + now := time.Now() + if c.Since != "" { + d, err := time.ParseDuration(c.Since) + if err != nil { + return nil, fmt.Errorf("invalid since duration '%s': %w", c.Since, err) + } + listOpts.CreatedFrom = now.Add(-d) + } + + if c.Until != "" { + d, err := time.ParseDuration(c.Until) + if err != nil { + return nil, fmt.Errorf("invalid until duration '%s': %w", c.Until, err) + } + listOpts.CreatedTo = now.Add(-d) + } + + if len(c.State) > 0 { + listOpts.State = make([]string, len(c.State)) + for i, state := range c.State { + listOpts.State[i] = strings.ToLower(state) + } + } + + listOpts.Branch = c.Branch + listOpts.Creator = c.Creator + listOpts.Commit = c.Commit + + return listOpts, nil +} + +func (c *ListCmd) fetchBuilds(ctx context.Context, f *factory.Factory, org string, listOpts *buildkite.BuildsListOptions) ([]buildkite.Build, error) { + var allBuilds []buildkite.Build + + // Track whether we've displayed any builds yet (for header logic) + printedAny := false + + // filtered builds added since last confirm (used when --no-limit) + filteredSinceConfirm := 0 + + // raw (unfiltered) build counters so progress messaging makes sense when client-side filters are active + rawTotalFetched := 0 + rawSinceConfirm := 0 + previousPageFirstBuildNumber := 0 + + format := output.Format(c.Output) + + for page := 1; ; page++ { + if !c.NoLimit && len(allBuilds) >= c.Limit { + break + } + + listOpts.Page = page + + var builds []buildkite.Build + var err error + + spinnerMsg := "Loading builds (" + if c.Pipeline != "" { + spinnerMsg += fmt.Sprintf("pipeline %s, ", c.Pipeline) + } + filtersActive := c.Duration != "" || c.Message != "" + + // Show matching (filtered) counts and raw counts independently + if !c.NoLimit && c.Limit > 0 { + spinnerMsg += fmt.Sprintf("%d/%d matching, %d raw fetched", len(allBuilds), c.Limit, rawTotalFetched) + } else { + spinnerMsg += fmt.Sprintf("%d matching, %d raw fetched", len(allBuilds), rawTotalFetched) + } + spinnerMsg += ")" + + if format == output.FormatText && rawSinceConfirm >= maxBuildLimit { + prompt := fmt.Sprintf("Fetched %d more builds (%d total). Continue?", rawSinceConfirm, rawTotalFetched) + if filtersActive { + prompt = fmt.Sprintf( + "Fetched %d raw builds (%d matching, %d matching total). Continue?", + rawSinceConfirm, filteredSinceConfirm, len(allBuilds), + ) + } + + confirmed, err := io.Confirm(f, prompt) + if err != nil { + return nil, err + } + + if !confirmed { + return allBuilds, nil + } + + filteredSinceConfirm = 0 + rawSinceConfirm = 0 + } + + spinErr := io.SpinWhile(spinnerMsg, func() { + if c.Pipeline != "" { + builds, err = c.getBuildsByPipeline(ctx, f, org, listOpts) + } else { + builds, _, err = f.RestAPIClient.Builds.ListByOrg(ctx, org, listOpts) + } + }) + + if spinErr != nil { + return nil, spinErr + } + + if err != nil { + return nil, err + } + + if len(builds) == 0 { + break + } + + // Track raw builds fetched before applying client-side filters + rawCountThisPage := len(builds) + rawTotalFetched += rawCountThisPage + rawSinceConfirm += rawCountThisPage + + // Detect duplicate first build number between pages to prevent infinite loop + if page > 1 && len(builds) > 0 { + currentPageFirstBuildNumber := builds[0].Number + if currentPageFirstBuildNumber == previousPageFirstBuildNumber { + return nil, fmt.Errorf("API returned duplicate results, stopping to prevent infinite loop") + } + } + + if len(builds) > 0 { + previousPageFirstBuildNumber = builds[0].Number + } + + builds, err = c.applyClientSideFilters(builds) + if err != nil { + return nil, fmt.Errorf("failed to apply filters: %w", err) + } + + // Decide which builds will actually be added (respect limit) + var buildsToAdd []buildkite.Build + addedThisPage := 0 + if !c.NoLimit { + remaining := c.Limit - len(allBuilds) + if remaining <= 0 { + break + } + if len(builds) > remaining { + buildsToAdd = builds[:remaining] + addedThisPage = remaining + } else { + buildsToAdd = builds + addedThisPage = len(builds) + } + } else { + buildsToAdd = builds + addedThisPage = len(builds) + } + + // Stream only the builds we are about to add; header only once we actually print something + if format == output.FormatText && len(buildsToAdd) > 0 { + showHeader := !printedAny + _ = displayBuilds(buildsToAdd, format, showHeader) + printedAny = true + } + + allBuilds = append(allBuilds, buildsToAdd...) + filteredSinceConfirm += addedThisPage + + if rawCountThisPage < listOpts.PerPage { + break + } + } + + return allBuilds, nil +} + +func (c *ListCmd) getBuildsByPipeline(ctx context.Context, f *factory.Factory, org string, listOpts *buildkite.BuildsListOptions) ([]buildkite.Build, error) { + pipelineRes := pipelineResolver.NewAggregateResolver( + pipelineResolver.ResolveFromFlag(c.Pipeline, f.Config), + pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOne), + ) + + pipeline, err := pipelineRes.Resolve(ctx) + if err != nil { + return nil, err + } + + builds, _, err := f.RestAPIClient.Builds.ListByPipeline(ctx, org, pipeline.Name, listOpts) + return builds, err +} + +func (c *ListCmd) applyClientSideFilters(builds []buildkite.Build) ([]buildkite.Build, error) { + if c.Duration == "" && c.Message == "" { + return builds, nil + } + + var durationOp string + var durationThreshold time.Duration + + if c.Duration != "" { + durationOp = ">=" + durationStr := c.Duration + + switch { + case strings.HasPrefix(c.Duration, "<"): + durationOp = "<" + durationStr = c.Duration[1:] + case strings.HasPrefix(c.Duration, ">"): + durationOp = ">" + durationStr = c.Duration[1:] + } + + d, err := time.ParseDuration(durationStr) + if err != nil { + return nil, fmt.Errorf("invalid duration format: %w", err) + } + durationThreshold = d + } + + var messageFilter string + if c.Message != "" { + messageFilter = strings.ToLower(c.Message) + } + + var result []buildkite.Build + for _, build := range builds { + if c.Duration != "" { + if build.StartedAt == nil { + continue + } + + var elapsed time.Duration + if build.FinishedAt != nil { + elapsed = build.FinishedAt.Sub(build.StartedAt.Time) + } else { + elapsed = time.Since(build.StartedAt.Time) + } + + switch durationOp { + case "<": + if elapsed >= durationThreshold { + continue + } + case ">": + if elapsed <= durationThreshold { + continue + } + default: + if elapsed < durationThreshold { + continue + } + } + } + + if messageFilter != "" { + if !strings.Contains(strings.ToLower(build.Message), messageFilter) { + continue + } + } + + result = append(result, build) + } + + return result, nil +} + +func isValidEmail(s string) bool { + _, err := mail.ParseAddress(s) + return err == nil +} + +func resolveCreatorEmailToUserID(ctx context.Context, f *factory.Factory, email string) (string, error) { + org := f.Config.OrganizationSlug() + resp, err := graphql.FindUserByEmail(ctx, f.GraphQLClient, org, email) + if err != nil { + return "", fmt.Errorf("failed to query user by email: %w", err) + } + + if resp.Organization == nil || resp.Organization.Members == nil || len(resp.Organization.Members.Edges) == 0 { + return "", fmt.Errorf("no user found with email: %s", email) + } + + member := resp.Organization.Members.Edges[0].Node + if member == nil { + return "", fmt.Errorf("invalid user data for email: %s", email) + } + + // Decode GraphQL ID and extract UUID + decoded, err := base64.StdEncoding.DecodeString(member.User.Id) + if err != nil { + return "", fmt.Errorf("failed to decode user ID: %w", err) + } + + if userUUID, found := strings.CutPrefix(string(decoded), "User---"); found { + return userUUID, nil + } + + return "", fmt.Errorf("unexpected user ID format") +} + +func displayBuilds(builds []buildkite.Build, format output.Format, withHeader bool) error { + if format != output.FormatText { + return output.Write(os.Stdout, builds, format) + } + + const ( + maxMessageLength = 22 + truncatedLength = 19 + timeFormat = "2006-01-02T15:04:05Z" + numberWidth = 8 + stateWidth = 12 + messageWidth = 25 + timeWidth = 20 + durationWidth = 12 + columnSpacing = 6 + ) + + var buf strings.Builder + + if withHeader { + header := lipgloss.NewStyle().Bold(true).Underline(true).Render("Builds") + buf.WriteString(header) + buf.WriteString("\n\n") + + headerRow := fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %-*s %s", + numberWidth, "Number", + stateWidth, "State", + messageWidth, "Message", + timeWidth, "Started (UTC)", + timeWidth, "Finished (UTC)", + durationWidth, "Duration", + "URL") + buf.WriteString(lipgloss.NewStyle().Bold(true).Render(headerRow)) + buf.WriteString("\n") + totalWidth := numberWidth + stateWidth + messageWidth + timeWidth*2 + durationWidth + columnSpacing + buf.WriteString(strings.Repeat("-", totalWidth)) + buf.WriteString("\n") + } + + for _, build := range builds { + message := build.Message + if len(message) > maxMessageLength { + message = message[:truncatedLength] + "..." + } + + startedAt := "-" + if build.StartedAt != nil { + startedAt = build.StartedAt.Format(timeFormat) + } + + finishedAt := "-" + duration := "-" + if build.FinishedAt != nil { + finishedAt = build.FinishedAt.Format(timeFormat) + if build.StartedAt != nil { + dur := build.FinishedAt.Sub(build.StartedAt.Time) + duration = formatDuration(dur) + } + } else if build.StartedAt != nil { + dur := time.Since(build.StartedAt.Time) + duration = formatDuration(dur) + " (running)" + } + + stateColor := getStateColor(build.State) + coloredState := stateColor.Render(build.State) + + row := fmt.Sprintf("%-*d %-*s %-*s %-*s %-*s %-*s %s", + numberWidth, build.Number, + stateWidth, coloredState, + messageWidth, message, + timeWidth, startedAt, + timeWidth, finishedAt, + durationWidth, duration, + build.WebURL) + buf.WriteString(row) + buf.WriteString("\n") + } + + fmt.Print(buf.String()) + return nil +} + +func formatDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%.0fs", d.Seconds()) + } + if d < time.Hour { + minutes := d / time.Minute + seconds := (d % time.Minute) / time.Second + return fmt.Sprintf("%dm%ds", minutes, seconds) + } + hours := d / time.Hour + minutes := (d % time.Hour) / time.Minute + return fmt.Sprintf("%dh%dm", hours, minutes) +} + +func getStateColor(state string) lipgloss.Style { + switch state { + case "passed": + return lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // Green + case "failed": + return lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // Red + case "running": + return lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // Yellow + case "canceled", "cancelled": + return lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Gray + case "scheduled": + return lipgloss.NewStyle().Foreground(lipgloss.Color("4")) // Blue + default: + return lipgloss.NewStyle() + } +} From dccafc3fd28c74acc996e389d9f4d11da4717d35 Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 16:24:38 +0000 Subject: [PATCH 16/27] [skip ci] adding list subcommand to main.go --- main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/main.go b/main.go index 7e65df8c..451d8a3a 100644 --- a/main.go +++ b/main.go @@ -52,6 +52,7 @@ type ( Create build.CreateCmd `cmd:"" aliases:"new" help:"Create a new build."` // Aliasing "new" because we've renamed this to "create", but we need to support backwards compatibility Cancel build.CancelCmd `cmd:"" help:"Cancel a build."` View build.ViewCmd `cmd:"" help:"View build information."` + List build.ListCmd `cmd:"" help:"List builds."` } ClusterCmd struct { Args []string `arg:"" optional:"" passthrough:"all"` From 64c6d6738df37a8b7a0b0e234e097a238865e735 Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 16:24:55 +0000 Subject: [PATCH 17/27] [skip ci] Removing list subcommand from build.go --- pkg/cmd/build/build.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/cmd/build/build.go b/pkg/cmd/build/build.go index 22a613b3..a892e48a 100644 --- a/pkg/cmd/build/build.go +++ b/pkg/cmd/build/build.go @@ -30,7 +30,6 @@ func NewCmdBuild(f *factory.Factory) *cobra.Command { cmd.PersistentFlags().StringP("pipeline", "p", "", "The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}.") cmd.AddCommand(NewCmdBuildDownload(f)) - cmd.AddCommand(NewCmdBuildList(f)) cmd.AddCommand(NewCmdBuildRebuild(f)) cmd.AddCommand(NewCmdBuildWatch(f)) From af9daf6456d911683d9b38e59d6d7b519754f313 Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 16:25:10 +0000 Subject: [PATCH 18/27] Deleting old list.go --- pkg/cmd/build/list.go | 590 ------------------------------------------ 1 file changed, 590 deletions(-) delete mode 100644 pkg/cmd/build/list.go diff --git a/pkg/cmd/build/list.go b/pkg/cmd/build/list.go deleted file mode 100644 index 5fe6bcaa..00000000 --- a/pkg/cmd/build/list.go +++ /dev/null @@ -1,590 +0,0 @@ -package build - -import ( - "context" - "encoding/base64" - "fmt" - "net/mail" - "strings" - "time" - - "github.com/MakeNowJust/heredoc" - "github.com/buildkite/cli/v3/internal/graphql" - "github.com/buildkite/cli/v3/internal/io" - pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" - "github.com/buildkite/cli/v3/internal/validation/scopes" - "github.com/buildkite/cli/v3/pkg/cmd/factory" - "github.com/buildkite/cli/v3/pkg/output" - buildkite "github.com/buildkite/go-buildkite/v4" - "github.com/charmbracelet/lipgloss" - "github.com/spf13/cobra" -) - -const ( - maxBuildLimit = 5000 - pageSize = 100 -) - -var ( - DisplayBuildsFunc = displayBuilds - ConfirmFunc = io.Confirm -) - -type buildListOptions struct { - pipeline string - since string - until string - duration string - state []string - branch []string - creator string - commit string - message string - limit int - noLimit bool -} - -func NewCmdBuildList(f *factory.Factory) *cobra.Command { - var opts buildListOptions - - cmd := cobra.Command{ - DisableFlagsInUseLine: true, - Use: "list [flags]", - Short: "List builds", - Long: heredoc.Doc(` - List builds with optional filtering. - - This command supports both server-side filtering (fast) and client-side filtering. - Server-side filters are applied by the Buildkite API, while client-side filters - are applied after fetching results and may require loading more builds. - - Client-side filters: --duration, --message - Server-side filters: --pipeline, --since, --until, --state, --branch, --creator, --commit - - Builds can be filtered by their duration, message content, and other attributes. - When filtering by duration, you can use operators like >, <, >=, and <= to specify your criteria. - Supported duration units are seconds (s), minutes (m), and hours (h). - `), - Example: heredoc.Doc(` - # List recent builds (50 by default) - $ bk build list - - # Get more builds (automatically paginates) - $ bk build list --limit 500 - - # List builds from the last hour - $ bk build list --since 1h - - # List failed builds - $ bk build list --state failed - - # List builds on main branch - $ bk build list --branch main - - # List builds by alice - $ bk build list --creator alice@company.com - - # List builds that took longer than 20 minutes - $ bk build list --duration ">20m" - - # List builds that finished in under 5 minutes - $ bk build list --duration "<5m" - - # Combine filters: failed builds on main branch in the last 24 hours - $ bk build list --state failed --branch main --since 24h - - # Find builds containing "deploy" in the message - $ bk build list --message deploy - - # Complex filtering: slow builds (>30m) that failed on feature branches - $ bk build list --duration ">30m" --state failed --branch feature/ - `), - RunE: func(cmd *cobra.Command, args []string) error { - format, err := output.GetFormat(cmd.Flags()) - if err != nil { - return err - } - - // Get pipeline from persistent flag - opts.pipeline, _ = cmd.Flags().GetString("pipeline") - - if !opts.noLimit { - if opts.limit > maxBuildLimit { - return fmt.Errorf("limit cannot exceed %d builds (requested: %d); if you need more, use --no-limit", maxBuildLimit, opts.limit) - } - } - - if opts.creator != "" && isValidEmail(opts.creator) { - originalEmail := opts.creator - err = io.SpinWhile("Looking up user", func() { - opts.creator, err = resolveCreatorEmailToUserID(cmd.Context(), f, originalEmail) - }) - if err != nil { - return fmt.Errorf("failed to resolve creator email: %w", err) - } - if opts.creator == "" { - return fmt.Errorf("failed to resolve creator email: no user found") - } - } - - listOpts, err := buildListOptionsFromFlags(&opts) - if err != nil { - return err - } - - org := f.Config.OrganizationSlug() - builds, err := fetchBuilds(cmd, f, org, opts, listOpts, format) - if err != nil { - return fmt.Errorf("failed to list builds: %w", err) - } - - if len(builds) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "No builds found matching the specified criteria.") - return nil - } - - if format == output.FormatText { - return nil - } - - return DisplayBuildsFunc(cmd, builds, format, false) - }, - } - - cmd.Annotations = map[string]string{ - "requiredScopes": string(scopes.ReadBuilds), - } - - // Pipeline flag now inherited from parent command - cmd.Flags().StringVar(&opts.since, "since", "", "Filter builds created since this time (e.g. 1h, 30m)") - cmd.Flags().StringVar(&opts.until, "until", "", "Filter builds created before this time (e.g. 1h, 30m)") - cmd.Flags().StringVar(&opts.duration, "duration", "", "Filter by duration (e.g. >5m, <10m, 20m) - supports >, <, >=, <= operators") - cmd.Flags().StringSliceVar(&opts.state, "state", []string{}, "Filter by build state") - cmd.Flags().StringSliceVar(&opts.branch, "branch", []string{}, "Filter by branch name") - cmd.Flags().StringVar(&opts.creator, "creator", "", "Filter by creator (email address or user ID)") - cmd.Flags().StringVar(&opts.commit, "commit", "", "Filter by commit SHA") - cmd.Flags().StringVar(&opts.message, "message", "", "Filter by message content") - cmd.Flags().IntVar(&opts.limit, "limit", 50, fmt.Sprintf("Maximum number of builds to return (max: %d)", maxBuildLimit)) - cmd.Flags().BoolVar(&opts.noLimit, "no-limit", false, "Fetch all builds (overrides --limit)") - - output.AddFlags(cmd.Flags()) - cmd.Flags().SortFlags = false - - return &cmd -} - -func isValidEmail(s string) bool { - _, err := mail.ParseAddress(s) - return err == nil -} - -func resolveCreatorEmailToUserID(ctx context.Context, f *factory.Factory, email string) (string, error) { - org := f.Config.OrganizationSlug() - resp, err := graphql.FindUserByEmail(ctx, f.GraphQLClient, org, email) - if err != nil { - return "", fmt.Errorf("failed to query user by email: %w", err) - } - - if resp.Organization == nil || resp.Organization.Members == nil || len(resp.Organization.Members.Edges) == 0 { - return "", fmt.Errorf("no user found with email: %s", email) - } - - member := resp.Organization.Members.Edges[0].Node - if member == nil { - return "", fmt.Errorf("invalid user data for email: %s", email) - } - - // Decode GraphQL ID and extract UUID - decoded, err := base64.StdEncoding.DecodeString(member.User.Id) - if err != nil { - return "", fmt.Errorf("failed to decode user ID: %w", err) - } - - if userUUID, found := strings.CutPrefix(string(decoded), "User---"); found { - return userUUID, nil - } - - return "", fmt.Errorf("unexpected user ID format") -} - -func buildListOptionsFromFlags(opts *buildListOptions) (*buildkite.BuildsListOptions, error) { - listOpts := &buildkite.BuildsListOptions{ - ListOptions: buildkite.ListOptions{ - PerPage: pageSize, - }, - } - - now := time.Now() - if opts.since != "" { - d, err := time.ParseDuration(opts.since) - if err != nil { - return nil, fmt.Errorf("invalid since duration '%s': %w", opts.since, err) - } - listOpts.CreatedFrom = now.Add(-d) - } - - if opts.until != "" { - d, err := time.ParseDuration(opts.until) - if err != nil { - return nil, fmt.Errorf("invalid until duration '%s': %w", opts.until, err) - } - listOpts.CreatedTo = now.Add(-d) - } - - if len(opts.state) > 0 { - listOpts.State = make([]string, len(opts.state)) - for i, state := range opts.state { - listOpts.State[i] = strings.ToLower(state) - } - } - - listOpts.Branch = opts.branch - listOpts.Creator = opts.creator - listOpts.Commit = opts.commit - - return listOpts, nil -} - -func fetchBuilds(cmd *cobra.Command, f *factory.Factory, org string, opts buildListOptions, listOpts *buildkite.BuildsListOptions, format output.Format) ([]buildkite.Build, error) { - ctx := cmd.Context() - var allBuilds []buildkite.Build - - // Track whether we've displayed any builds yet (for header logic) - printedAny := false - - // filtered builds added since last confirm (used when --no-limit) - filteredSinceConfirm := 0 - - // raw (unfiltered) build counters so progress messaging makes sense when client-side filters are active - rawTotalFetched := 0 - rawSinceConfirm := 0 - previousPageFirstBuildNumber := 0 - - for page := 1; ; page++ { - if !opts.noLimit && len(allBuilds) >= opts.limit { - break - } - - listOpts.Page = page - - var builds []buildkite.Build - var err error - - spinnerMsg := "Loading builds (" - if opts.pipeline != "" { - spinnerMsg += fmt.Sprintf("pipeline %s, ", opts.pipeline) - } - filtersActive := opts.duration != "" || opts.message != "" - - // Show matching (filtered) counts and raw counts independently - if !opts.noLimit && opts.limit > 0 { - spinnerMsg += fmt.Sprintf("%d/%d matching, %d raw fetched", len(allBuilds), opts.limit, rawTotalFetched) - } else { - spinnerMsg += fmt.Sprintf("%d matching, %d raw fetched", len(allBuilds), rawTotalFetched) - } - spinnerMsg += ")" - - if format == output.FormatText && rawSinceConfirm >= maxBuildLimit { - prompt := fmt.Sprintf("Fetched %d more builds (%d total). Continue?", rawSinceConfirm, rawTotalFetched) - if filtersActive { - prompt = fmt.Sprintf( - "Fetched %d raw builds (%d matching, %d matching total). Continue?", - rawSinceConfirm, filteredSinceConfirm, len(allBuilds), - ) - } - - confirmed, err := ConfirmFunc(f, prompt) - if err != nil { - return nil, err - } - - if !confirmed { - return allBuilds, nil - } - - filteredSinceConfirm = 0 - rawSinceConfirm = 0 - } - - spinErr := io.SpinWhile(spinnerMsg, func() { - if opts.pipeline != "" { - builds, err = getBuildsByPipeline(ctx, f, org, opts.pipeline, listOpts) - } else { - builds, _, err = f.RestAPIClient.Builds.ListByOrg(ctx, org, listOpts) - } - }) - - if spinErr != nil { - return nil, spinErr - } - - if err != nil { - return nil, err - } - - if len(builds) == 0 { - break - } - - // Track raw builds fetched before applying client-side filters - rawCountThisPage := len(builds) - rawTotalFetched += rawCountThisPage - rawSinceConfirm += rawCountThisPage - - // Detect duplicate first build number between pages to prevent infinite loop - if page > 1 && len(builds) > 0 { - currentPageFirstBuildNumber := builds[0].Number - if currentPageFirstBuildNumber == previousPageFirstBuildNumber { - return nil, fmt.Errorf("API returned duplicate results, stopping to prevent infinite loop") // We should never get here - } - } - - if len(builds) > 0 { - previousPageFirstBuildNumber = builds[0].Number - } - - builds, err = applyClientSideFilters(builds, opts) - if err != nil { - return nil, fmt.Errorf("failed to apply filters: %w", err) - } - - // Decide which builds will actually be added (respect limit) - var buildsToAdd []buildkite.Build - addedThisPage := 0 - if !opts.noLimit { - remaining := opts.limit - len(allBuilds) - if remaining <= 0 { // safety, though we check at loop top - break - } - if len(builds) > remaining { - buildsToAdd = builds[:remaining] - addedThisPage = remaining - } else { - buildsToAdd = builds - addedThisPage = len(builds) - } - } else { - buildsToAdd = builds - addedThisPage = len(builds) - } - - // Stream only the builds we are about to add; header only once we actually print something - if format == output.FormatText && DisplayBuildsFunc != nil && len(buildsToAdd) > 0 { - showHeader := !printedAny - _ = DisplayBuildsFunc(cmd, buildsToAdd, format, showHeader) - printedAny = true - } - - allBuilds = append(allBuilds, buildsToAdd...) - filteredSinceConfirm += addedThisPage - - if rawCountThisPage < listOpts.PerPage { - break - } - } - - return allBuilds, nil -} - -func getBuildsByPipeline(ctx context.Context, f *factory.Factory, org, pipelineFlag string, listOpts *buildkite.BuildsListOptions) ([]buildkite.Build, error) { - pipelineRes := pipelineResolver.NewAggregateResolver( - pipelineResolver.ResolveFromFlag(pipelineFlag, f.Config), - pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOne), - ) - - pipeline, err := pipelineRes.Resolve(ctx) - if err != nil { - return nil, err - } - - builds, _, err := f.RestAPIClient.Builds.ListByPipeline(ctx, org, pipeline.Name, listOpts) - return builds, err -} - -func applyClientSideFilters(builds []buildkite.Build, opts buildListOptions) ([]buildkite.Build, error) { - if opts.duration == "" && opts.message == "" { - return builds, nil - } - - var durationOp string - var durationThreshold time.Duration - - if opts.duration != "" { - durationOp = ">=" - durationStr := opts.duration - - switch { - case strings.HasPrefix(opts.duration, "<"): - durationOp = "<" - durationStr = opts.duration[1:] - case strings.HasPrefix(opts.duration, ">"): - durationOp = ">" - durationStr = opts.duration[1:] - } - - d, err := time.ParseDuration(durationStr) - if err != nil { - return nil, fmt.Errorf("invalid duration format: %w", err) - } - durationThreshold = d - } - - var messageFilter string - if opts.message != "" { - messageFilter = strings.ToLower(opts.message) - } - - var result []buildkite.Build - for _, build := range builds { - if opts.duration != "" { - if build.StartedAt == nil { - continue - } - - var elapsed time.Duration - if build.FinishedAt != nil { - elapsed = build.FinishedAt.Sub(build.StartedAt.Time) - } else { - elapsed = time.Since(build.StartedAt.Time) - } - - switch durationOp { - case "<": - if elapsed >= durationThreshold { - continue - } - case ">": - if elapsed <= durationThreshold { - continue - } - default: - if elapsed < durationThreshold { - continue - } - } - } - - if messageFilter != "" { - if !strings.Contains(strings.ToLower(build.Message), messageFilter) { - continue - } - } - - result = append(result, build) - } - - return result, nil -} - -func displayBuilds(cmd *cobra.Command, builds []buildkite.Build, format output.Format, withHeader bool) error { - if format != output.FormatText { - return output.Write(cmd.OutOrStdout(), builds, format) - } - - const ( - maxMessageLength = 22 - truncatedLength = 19 - timeFormat = "2006-01-02T15:04:05Z" - numberWidth = 8 - stateWidth = 12 - messageWidth = 25 - timeWidth = 20 - durationWidth = 12 - columnSpacing = 6 - ) - - var buf strings.Builder - - if withHeader { - header := lipgloss.NewStyle().Bold(true).Underline(true).Render("Builds") - buf.WriteString(header) - buf.WriteString("\n\n") - - headerRow := fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %-*s %s", - numberWidth, "Number", - stateWidth, "State", - messageWidth, "Message", - timeWidth, "Started (UTC)", - timeWidth, "Finished (UTC)", - durationWidth, "Duration", - "URL") - buf.WriteString(lipgloss.NewStyle().Bold(true).Render(headerRow)) - buf.WriteString("\n") - totalWidth := numberWidth + stateWidth + messageWidth + timeWidth*2 + durationWidth + columnSpacing - buf.WriteString(strings.Repeat("-", totalWidth)) - buf.WriteString("\n") - } - - for _, build := range builds { - message := build.Message - if len(message) > maxMessageLength { - message = message[:truncatedLength] + "..." - } - - startedAt := "-" - if build.StartedAt != nil { - startedAt = build.StartedAt.Format(timeFormat) - } - - finishedAt := "-" - duration := "-" - if build.FinishedAt != nil { - finishedAt = build.FinishedAt.Format(timeFormat) - if build.StartedAt != nil { - dur := build.FinishedAt.Sub(build.StartedAt.Time) - duration = formatDuration(dur) - } - } else if build.StartedAt != nil { - dur := time.Since(build.StartedAt.Time) - duration = formatDuration(dur) + " (running)" - } - - stateColor := getStateColor(build.State) - coloredState := stateColor.Render(build.State) - - row := fmt.Sprintf("%-*d %-*s %-*s %-*s %-*s %-*s %s", - numberWidth, build.Number, - stateWidth, coloredState, - messageWidth, message, - timeWidth, startedAt, - timeWidth, finishedAt, - durationWidth, duration, - build.WebURL) - buf.WriteString(row) - buf.WriteString("\n") - } - - fmt.Fprint(cmd.OutOrStdout(), buf.String()) - return nil -} - -func formatDuration(d time.Duration) string { - if d < time.Minute { - return fmt.Sprintf("%.0fs", d.Seconds()) - } - if d < time.Hour { - minutes := d / time.Minute - seconds := (d % time.Minute) / time.Second - return fmt.Sprintf("%dm%ds", minutes, seconds) - } - hours := d / time.Hour - minutes := (d % time.Hour) / time.Minute - return fmt.Sprintf("%dh%dm", hours, minutes) -} - -func getStateColor(state string) lipgloss.Style { - switch state { - case "passed": - return lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // Green - case "failed": - return lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // Red - case "running": - return lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // Yellow - case "canceled", "cancelled": - return lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Gray - case "scheduled": - return lipgloss.NewStyle().Foreground(lipgloss.Color("4")) // Blue - default: - return lipgloss.NewStyle() - } -} From a37b820fcb83cf22bd158df7787549bba4e41989 Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 16:41:34 +0000 Subject: [PATCH 19/27] Remove move list test to new dir and fix cobra refs Add some helper functions to well, help... too... I guess? --- cmd/build/list.go | 23 +++ cmd/build/list_test.go | 46 ++++++ pkg/cmd/build/list_test.go | 279 ------------------------------------- 3 files changed, 69 insertions(+), 279 deletions(-) create mode 100644 cmd/build/list_test.go delete mode 100644 pkg/cmd/build/list_test.go diff --git a/cmd/build/list.go b/cmd/build/list.go index 6004ead5..c674910b 100644 --- a/cmd/build/list.go +++ b/cmd/build/list.go @@ -27,6 +27,20 @@ const ( pageSize = 100 ) +type buildListOptions struct { + pipeline string + since string + until string + duration string + state []string + branch []string + creator string + commit string + message string + limit int + noLimit bool +} + type ListCmd struct { Pipeline string `help:"The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}." short:"p"` Since string `help:"Filter builds created since this time (e.g. 1h, 30m)"` @@ -565,3 +579,12 @@ func getStateColor(state string) lipgloss.Style { return lipgloss.NewStyle() } } + +// Test helpers - exported for testing only +func applyClientSideFilters(builds []buildkite.Build, opts buildListOptions) ([]buildkite.Build, error) { + cmd := &ListCmd{ + Duration: opts.duration, + Message: opts.message, + } + return cmd.applyClientSideFilters(builds) +} diff --git a/cmd/build/list_test.go b/cmd/build/list_test.go new file mode 100644 index 00000000..7a813553 --- /dev/null +++ b/cmd/build/list_test.go @@ -0,0 +1,46 @@ +package build + +import ( + "testing" + "time" + + buildkite "github.com/buildkite/go-buildkite/v4" +) + +func TestFilterBuilds(t *testing.T) { + now := time.Now() + builds := []buildkite.Build{ + { + Number: 1, + Message: "Fast build", + StartedAt: &buildkite.Timestamp{Time: now.Add(-5 * time.Minute)}, + FinishedAt: &buildkite.Timestamp{Time: now.Add(-4 * time.Minute)}, // 1 minute + }, + { + Number: 2, + Message: "Long build", + StartedAt: &buildkite.Timestamp{Time: now.Add(-30 * time.Minute)}, + FinishedAt: &buildkite.Timestamp{Time: now.Add(-10 * time.Minute)}, // 20 minutes + }, + } + + opts := buildListOptions{duration: "10m"} + filtered, err := applyClientSideFilters(builds, opts) + if err != nil { + t.Fatalf("applyClientSideFilters failed: %v", err) + } + + if len(filtered) != 1 { + t.Errorf("Expected 1 build >= 10m, got %d", len(filtered)) + } + + opts = buildListOptions{message: "Fast"} + filtered, err = applyClientSideFilters(builds, opts) + if err != nil { + t.Fatalf("applyClientSideFilters failed: %v", err) + } + + if len(filtered) != 1 { + t.Errorf("Expected 1 build with 'Fast', got %d", len(filtered)) + } +} diff --git a/pkg/cmd/build/list_test.go b/pkg/cmd/build/list_test.go deleted file mode 100644 index 39bcc99b..00000000 --- a/pkg/cmd/build/list_test.go +++ /dev/null @@ -1,279 +0,0 @@ -package build - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "testing" - "time" - - "github.com/buildkite/cli/v3/internal/config" - "github.com/buildkite/cli/v3/pkg/cmd/factory" - "github.com/buildkite/cli/v3/pkg/output" - buildkite "github.com/buildkite/go-buildkite/v4" - "github.com/spf13/afero" - "github.com/spf13/cobra" -) - -func TestFilterBuilds(t *testing.T) { - now := time.Now() - builds := []buildkite.Build{ - { - Number: 1, - Message: "Fast build", - StartedAt: &buildkite.Timestamp{Time: now.Add(-5 * time.Minute)}, - FinishedAt: &buildkite.Timestamp{Time: now.Add(-4 * time.Minute)}, // 1 minute - }, - { - Number: 2, - Message: "Long build", - StartedAt: &buildkite.Timestamp{Time: now.Add(-30 * time.Minute)}, - FinishedAt: &buildkite.Timestamp{Time: now.Add(-10 * time.Minute)}, // 20 minutes - }, - } - - opts := buildListOptions{duration: "10m"} - filtered, err := applyClientSideFilters(builds, opts) - if err != nil { - t.Fatalf("applyClientSideFilters failed: %v", err) - } - - if len(filtered) != 1 { - t.Errorf("Expected 1 build >= 10m, got %d", len(filtered)) - } - - opts = buildListOptions{message: "Fast"} - filtered, err = applyClientSideFilters(builds, opts) - if err != nil { - t.Fatalf("applyClientSideFilters failed: %v", err) - } - - if len(filtered) != 1 { - t.Errorf("Expected 1 build with 'Fast', got %d", len(filtered)) - } -} - -type fakeTransport struct { - pages [][]buildkite.Build - perPageRequested []int -} - -func (ft *fakeTransport) RoundTrip(req *http.Request) (*http.Response, error) { - q := req.URL.Query() - page := 1 - if p := q.Get("page"); p != "" { - fmt.Sscanf(p, "%d", &page) - } - perPage := 0 - if pp := q.Get("per_page"); pp != "" { - fmt.Sscanf(pp, "%d", &perPage) - } - ft.perPageRequested = append(ft.perPageRequested, perPage) - idx := page - 1 - var data []byte - if idx < 0 || idx >= len(ft.pages) { - data = []byte("[]") - } else { - b, _ := json.Marshal(ft.pages[idx]) - data = b - } - resp := &http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewReader(data)), - Header: make(http.Header), - Request: req, - } - resp.Header.Set("Content-Type", "application/json") - return resp, nil -} - -func TestFetchBuildsLimitSlicing(t *testing.T) { - origDisplay := DisplayBuildsFunc - defer func() { DisplayBuildsFunc = origDisplay }() - type call struct { - buildCount int - withHeader bool - } - var calls []call - DisplayBuildsFunc = func(cmd *cobra.Command, builds []buildkite.Build, format output.Format, withHeader bool) error { - calls = append(calls, call{buildCount: len(builds), withHeader: withHeader}) - return nil - } - all := make([]buildkite.Build, 0, 250) - for i := 0; i < 250; i++ { - all = append(all, buildkite.Build{Number: i + 1}) - } - perPage := 100 - pages := [][]buildkite.Build{ - all[0:perPage], - all[perPage : 2*perPage], - all[2*perPage:], - } - - ft := &fakeTransport{pages: pages} - - apiClient, err := buildkite.NewOpts( - buildkite.WithBaseURL("https://api.example.buildkite"), - buildkite.WithHTTPClient(&http.Client{Transport: ft}), - ) - if err != nil { - t.Fatalf("failed to create api client: %v", err) - } - - fs := afero.NewMemMapFs() - conf := config.New(fs, nil) - conf.SelectOrganization("uber", true) - - f := &factory.Factory{Config: conf, RestAPIClient: apiClient} - - opts := buildListOptions{pipeline: "my-pipeline", limit: 230} - listOpts := &buildkite.BuildsListOptions{ListOptions: buildkite.ListOptions{PerPage: perPage}} - - cmd := &cobra.Command{} - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - cmd.SetContext(ctx) - builds, err := fetchBuilds(cmd, f, conf.OrganizationSlug(), opts, listOpts, output.FormatText) - if err != nil { - t.Fatalf("fetchBuilds returned error: %v", err) - } - if len(builds) != 230 { - t.Fatalf("expected %d builds, got %d", 230, len(builds)) - } - if builds[len(builds)-1].Number != 230 { - t.Fatalf("expected last build number 230, got %d", builds[len(builds)-1].Number) - } - - for i, page := range ft.pages { - if i < len(ft.pages)-1 && len(page) != perPage { - t.Errorf("page %d: expected %d items, got %d", i+1, perPage, len(page)) - } - } - - for i, pp := range ft.perPageRequested { - if pp != perPage { - t.Errorf("request %d: expected per_page=%d, got %d", i+1, perPage, pp) - } - } - - if len(calls) != len(pages) { - // In text format, display is called per page loaded - // Accept that last page may have been truncated by slicing limit - // So allow len(calls) to equal number of pages actually iterated (ft.perPageRequested) - if len(calls) != len(ft.perPageRequested) { - // fallback strict failure - t.Fatalf("expected display calls %d or %d, got %d", len(pages), len(ft.perPageRequested), len(calls)) - } - } - if len(calls) > 0 && !calls[0].withHeader { - t.Errorf("expected first display call to have header") - } - for i := 1; i < len(calls); i++ { - if calls[i].withHeader { - t.Errorf("expected only first display call to have header, call %d had header", i+1) - } - } -} - -func setupConfirmationTestEnv(t *testing.T) (*factory.Factory, *cobra.Command, *config.Config) { - t.Helper() - totalBuilds := maxBuildLimit*2 + 100 - all := make([]buildkite.Build, 0, totalBuilds) - for i := 0; i < totalBuilds; i++ { - all = append(all, buildkite.Build{Number: i + 1}) - } - pages := [][]buildkite.Build{} - for i := 0; i < len(all); i += pageSize { - end := i + pageSize - if end > len(all) { - end = len(all) - } - pages = append(pages, all[i:end]) - } - ft := &fakeTransport{pages: pages} - apiClient, err := buildkite.NewOpts( - buildkite.WithBaseURL("https://api.example.buildkite"), - buildkite.WithHTTPClient(&http.Client{Transport: ft}), - ) - if err != nil { - t.Fatalf("failed to create api client: %v", err) - } - fs := afero.NewMemMapFs() - conf := config.New(fs, nil) - conf.SelectOrganization("uber", true) - f := &factory.Factory{Config: conf, RestAPIClient: apiClient} - cmd := &cobra.Command{} - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - cmd.SetContext(ctx) - return f, cmd, conf -} - -func TestFetchBuildsConfirmationDeclineFirst(t *testing.T) { - origDisplay := DisplayBuildsFunc - DisplayBuildsFunc = func(cmd *cobra.Command, builds []buildkite.Build, format output.Format, withHeader bool) error { - return nil - } - t.Cleanup(func() { DisplayBuildsFunc = origDisplay }) - - f, cmd, conf := setupConfirmationTestEnv(t) - opts := buildListOptions{pipeline: "my-pipeline", noLimit: true} - listOpts := &buildkite.BuildsListOptions{ListOptions: buildkite.ListOptions{PerPage: pageSize}} - - confirmCalls := 0 - origConfirm := ConfirmFunc - ConfirmFunc = func(f *factory.Factory, prompt string) (bool, error) { - confirmCalls++ - return false, nil - } - t.Cleanup(func() { ConfirmFunc = origConfirm }) - - builds, err := fetchBuilds(cmd, f, conf.OrganizationSlug(), opts, listOpts, output.FormatText) - if err != nil { - t.Fatalf("fetchBuilds returned error: %v", err) - } - if confirmCalls != 1 { - t.Fatalf("expected 1 confirmation call, got %d", confirmCalls) - } - if len(builds) != maxBuildLimit { - t.Fatalf("expected %d builds when declined, got %d", maxBuildLimit, len(builds)) - } -} - -func TestFetchBuildsConfirmationAcceptThenDecline(t *testing.T) { - origDisplay := DisplayBuildsFunc - DisplayBuildsFunc = func(cmd *cobra.Command, builds []buildkite.Build, format output.Format, withHeader bool) error { - return nil - } - t.Cleanup(func() { DisplayBuildsFunc = origDisplay }) - - f, cmd, conf := setupConfirmationTestEnv(t) - opts := buildListOptions{pipeline: "my-pipeline", noLimit: true} - listOpts := &buildkite.BuildsListOptions{ListOptions: buildkite.ListOptions{PerPage: pageSize}} - - confirmCalls := 0 - origConfirm := ConfirmFunc - ConfirmFunc = func(f *factory.Factory, prompt string) (bool, error) { - confirmCalls++ - if confirmCalls == 1 { - return true, nil - } - return false, nil - } - t.Cleanup(func() { ConfirmFunc = origConfirm }) - - builds, err := fetchBuilds(cmd, f, conf.OrganizationSlug(), opts, listOpts, output.FormatText) - if err != nil { - t.Fatalf("fetchBuilds returned error: %v", err) - } - expected := maxBuildLimit * 2 - if len(builds) != expected { - t.Fatalf("expected %d builds after accepting once then declining, got %d", expected, len(builds)) - } - if confirmCalls != 2 { - t.Fatalf("expected 2 confirmation calls, got %d", confirmCalls) - } -} From 654c7409d5a701b833b24e93d04130d74009be75 Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 16:42:10 +0000 Subject: [PATCH 20/27] Moving view_test.go --- {pkg/cmd => cmd}/build/view_test.go | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {pkg/cmd => cmd}/build/view_test.go (100%) diff --git a/pkg/cmd/build/view_test.go b/cmd/build/view_test.go similarity index 100% rename from pkg/cmd/build/view_test.go rename to cmd/build/view_test.go From 9895cf9e94bbc929c326becf7004dc70f167d6d5 Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 16:43:41 +0000 Subject: [PATCH 21/27] No lint comment as it is used elsewhere --- cmd/build/list.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/build/list.go b/cmd/build/list.go index c674910b..5b35ca04 100644 --- a/cmd/build/list.go +++ b/cmd/build/list.go @@ -27,6 +27,7 @@ const ( pageSize = 100 ) +//nolint:unused // exported for testing type buildListOptions struct { pipeline string since string From 3c3e4619cd67f5d165e876e211b9a5afabebfad8 Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 16:45:14 +0000 Subject: [PATCH 22/27] Moving helper functions directly into tests, cleaner --- cmd/build/list.go | 24 ------------------------ cmd/build/list_test.go | 13 +++++++++++++ 2 files changed, 13 insertions(+), 24 deletions(-) diff --git a/cmd/build/list.go b/cmd/build/list.go index 5b35ca04..6004ead5 100644 --- a/cmd/build/list.go +++ b/cmd/build/list.go @@ -27,21 +27,6 @@ const ( pageSize = 100 ) -//nolint:unused // exported for testing -type buildListOptions struct { - pipeline string - since string - until string - duration string - state []string - branch []string - creator string - commit string - message string - limit int - noLimit bool -} - type ListCmd struct { Pipeline string `help:"The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}." short:"p"` Since string `help:"Filter builds created since this time (e.g. 1h, 30m)"` @@ -580,12 +565,3 @@ func getStateColor(state string) lipgloss.Style { return lipgloss.NewStyle() } } - -// Test helpers - exported for testing only -func applyClientSideFilters(builds []buildkite.Build, opts buildListOptions) ([]buildkite.Build, error) { - cmd := &ListCmd{ - Duration: opts.duration, - Message: opts.message, - } - return cmd.applyClientSideFilters(builds) -} diff --git a/cmd/build/list_test.go b/cmd/build/list_test.go index 7a813553..216d39ce 100644 --- a/cmd/build/list_test.go +++ b/cmd/build/list_test.go @@ -7,6 +7,19 @@ import ( buildkite "github.com/buildkite/go-buildkite/v4" ) +type buildListOptions struct { + duration string + message string +} + +func applyClientSideFilters(builds []buildkite.Build, opts buildListOptions) ([]buildkite.Build, error) { + cmd := &ListCmd{ + Duration: opts.duration, + Message: opts.message, + } + return cmd.applyClientSideFilters(builds) +} + func TestFilterBuilds(t *testing.T) { now := time.Now() builds := []buildkite.Build{ From eeee159d5b4e4dc6efc5849ab613348a87175d6d Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 16:51:44 +0000 Subject: [PATCH 23/27] Kongified download.go --- cmd/build/download.go | 187 ++++++++++++++++++++++++++++++++++++++ main.go | 9 +- pkg/cmd/build/build.go | 1 - pkg/cmd/build/download.go | 168 ---------------------------------- 4 files changed, 192 insertions(+), 173 deletions(-) create mode 100644 cmd/build/download.go delete mode 100644 pkg/cmd/build/download.go diff --git a/cmd/build/download.go b/cmd/build/download.go new file mode 100644 index 00000000..7f1465fb --- /dev/null +++ b/cmd/build/download.go @@ -0,0 +1,187 @@ +package build + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/alecthomas/kong" + "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/cli" + bk_io "github.com/buildkite/cli/v3/internal/io" + pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" + "github.com/buildkite/cli/v3/internal/version" + "github.com/buildkite/cli/v3/pkg/cmd/factory" + "github.com/buildkite/cli/v3/pkg/cmd/validation" +) + +type DownloadCmd struct { + BuildNumber string `arg:"" optional:"" help:"Build number to download (omit for most recent build)"` + Pipeline string `help:"The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}." short:"p"` + Branch string `help:"Filter builds to this branch." short:"b"` + User string `help:"Filter builds to this user. You can use name or email." short:"u" xor:"userfilter"` + Mine bool `help:"Filter builds to only my user." short:"m" xor:"userfilter"` +} + +func (c *DownloadCmd) Help() string { + return ` +Examples: + # Download build 123 + $ bk build download 123 -p my-pipeline + + # Download most recent build + $ bk build download -p my-pipeline + + # Download most recent build on a branch + $ bk build download -b main -p my-pipeline + + # Download most recent build by a user + $ bk build download --pipeline my-pipeline -u alice@hello.com + + # Download most recent build by yourself + $ bk build download --pipeline my-pipeline --mine` +} + +func (c *DownloadCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { + f, err := factory.New(version.Version) + if err != nil { + return err + } + + f.SkipConfirm = globals.SkipConfirmation() + f.NoInput = globals.DisableInput() + + if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { + return err + } + + ctx := context.Background() + + // we find the pipeline based on the following rules: + // 1. an explicit flag is passed + // 2. a configured pipeline for this directory + // 3. find pipelines matching the current repository from the API + pipelineRes := pipelineResolver.NewAggregateResolver( + pipelineResolver.ResolveFromFlag(c.Pipeline, f.Config), + pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOne), + pipelineResolver.ResolveFromRepository(f, pipelineResolver.CachedPicker(f.Config, pipelineResolver.PickOne, f.GitRepository != nil)), + ) + + // we resolve a build based on the following rules: + // 1. an optional argument + // 2. resolve from API using some context + // a. filter by branch if --branch or use current repo + // b. filter by user if --user or --mine given + optionsResolver := options.AggregateResolver{ + options.ResolveBranchFromFlag(c.Branch), + options.ResolveBranchFromRepository(f.GitRepository), + }.WithResolverWhen( + c.User != "", + options.ResolveUserFromFlag(c.User), + ).WithResolverWhen( + c.Mine || c.User == "", + options.ResolveCurrentUser(ctx, f), + ) + + args := []string{} + if c.BuildNumber != "" { + args = []string{c.BuildNumber} + } + buildRes := buildResolver.NewAggregateResolver( + buildResolver.ResolveFromPositionalArgument(args, 0, pipelineRes.Resolve, f.Config), + buildResolver.ResolveBuildWithOpts(f, pipelineRes.Resolve, optionsResolver...), + ) + + bld, err := buildRes.Resolve(ctx) + if err != nil { + return err + } + if bld == nil { + fmt.Println("No build found.") + return nil + } + + var dir string + spinErr := bk_io.SpinWhile("Downloading build resources", func() { + dir, err = download(ctx, bld, f) + }) + if spinErr != nil { + return spinErr + } + + fmt.Printf("Downloaded build to: %s\n", dir) + + return err +} + +func download(ctx context.Context, build *build.Build, f *factory.Factory) (string, error) { + var wg sync.WaitGroup + var mu sync.Mutex + b, _, err := f.RestAPIClient.Builds.Get(ctx, build.Organization, build.Pipeline, fmt.Sprint(build.BuildNumber), nil) + if err != nil { + return "", err + } + + directory := fmt.Sprintf("build-%s", b.ID) + err = os.MkdirAll(directory, os.ModePerm) + if err != nil { + return "", err + } + + for _, job := range b.Jobs { + // only script (command) jobs will have logs + if job.Type != "script" { + continue + } + + go func() { + defer wg.Done() + wg.Add(1) + log, _, apiErr := f.RestAPIClient.Jobs.GetJobLog(ctx, build.Organization, build.Pipeline, b.ID, job.ID) + if err != nil { + mu.Lock() + err = apiErr + mu.Unlock() + return + } + + fileErr := os.WriteFile(filepath.Join(directory, job.ID), []byte(log.Content), 0o644) + if fileErr != nil { + mu.Lock() + err = fileErr + mu.Unlock() + } + }() + } + + artifacts, _, err := f.RestAPIClient.Artifacts.ListByBuild(ctx, build.Organization, build.Pipeline, fmt.Sprint(build.BuildNumber), nil) + if err != nil { + return "", err + } + + for _, artifact := range artifacts { + go func() { + defer wg.Done() + wg.Add(1) + out, fileErr := os.Create(filepath.Join(directory, fmt.Sprintf("artifact-%s-%s", artifact.ID, artifact.Filename))) + if err != nil { + err = fileErr + } + _, apiErr := f.RestAPIClient.Artifacts.DownloadArtifactByURL(ctx, artifact.DownloadURL, out) + if err != nil { + err = apiErr + } + }() + } + + wg.Wait() + if err != nil { + return "", err + } + + return directory, nil +} diff --git a/main.go b/main.go index 451d8a3a..5b4a2b99 100644 --- a/main.go +++ b/main.go @@ -49,10 +49,11 @@ type ( Args []string `arg:"" optional:"" passthrough:"all"` } BuildCmd struct { - Create build.CreateCmd `cmd:"" aliases:"new" help:"Create a new build."` // Aliasing "new" because we've renamed this to "create", but we need to support backwards compatibility - Cancel build.CancelCmd `cmd:"" help:"Cancel a build."` - View build.ViewCmd `cmd:"" help:"View build information."` - List build.ListCmd `cmd:"" help:"List builds."` + Create build.CreateCmd `cmd:"" aliases:"new" help:"Create a new build."` // Aliasing "new" because we've renamed this to "create", but we need to support backwards compatibility + Cancel build.CancelCmd `cmd:"" help:"Cancel a build."` + View build.ViewCmd `cmd:"" help:"View build information."` + List build.ListCmd `cmd:"" help:"List builds."` + Download build.DownloadCmd `cmd:"" help:"Download resources for a build."` } ClusterCmd struct { Args []string `arg:"" optional:"" passthrough:"all"` diff --git a/pkg/cmd/build/build.go b/pkg/cmd/build/build.go index a892e48a..b7b6eaa1 100644 --- a/pkg/cmd/build/build.go +++ b/pkg/cmd/build/build.go @@ -29,7 +29,6 @@ func NewCmdBuild(f *factory.Factory) *cobra.Command { cmd.PersistentFlags().StringP("pipeline", "p", "", "The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}.") - cmd.AddCommand(NewCmdBuildDownload(f)) cmd.AddCommand(NewCmdBuildRebuild(f)) cmd.AddCommand(NewCmdBuildWatch(f)) diff --git a/pkg/cmd/build/download.go b/pkg/cmd/build/download.go deleted file mode 100644 index 87826ff5..00000000 --- a/pkg/cmd/build/download.go +++ /dev/null @@ -1,168 +0,0 @@ -package build - -import ( - "context" - "fmt" - "os" - "path/filepath" - "sync" - - "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" - bk_io "github.com/buildkite/cli/v3/internal/io" - pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" - "github.com/buildkite/cli/v3/internal/validation/scopes" - "github.com/buildkite/cli/v3/pkg/cmd/factory" - "github.com/spf13/cobra" -) - -func NewCmdBuildDownload(f *factory.Factory) *cobra.Command { - var mine bool - var branch, pipeline, user string - - cmd := cobra.Command{ - DisableFlagsInUseLine: true, - Use: "download [number] [flags]", - Short: "Download resources for a build", - Long: "Download allows you to download resources for a build.", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - // Get pipeline from persistent flag - pipeline, _ = cmd.Flags().GetString("pipeline") - - // we find the pipeline based on the following rules: - // 1. an explicit flag is passed - // 2. a configured pipeline for this directory - // 3. find pipelines matching the current repository from the API - pipelineRes := pipelineResolver.NewAggregateResolver( - pipelineResolver.ResolveFromFlag(pipeline, f.Config), - pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOne), - pipelineResolver.ResolveFromRepository(f, pipelineResolver.CachedPicker(f.Config, pipelineResolver.PickOne, f.GitRepository != nil)), - ) - - // we resolve a build based on the following rules: - // 1. an optional argument - // 2. resolve from API using some context - // a. filter by branch if --branch or use current repo - // b. filter by user if --user or --mine given - optionsResolver := options.AggregateResolver{ - options.ResolveBranchFromFlag(branch), - options.ResolveBranchFromRepository(f.GitRepository), - }.WithResolverWhen( - user != "", - options.ResolveUserFromFlag(user), - ).WithResolverWhen( - mine || user == "", - options.ResolveCurrentUser(cmd.Context(), f), - ) - 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 { - fmt.Fprintf(cmd.OutOrStdout(), "No build found.\n") - return nil - } - - var dir string - spinErr := bk_io.SpinWhile("Downloading build resources", func() { - dir, err = download(cmd.Context(), bld, f) - }) - if spinErr != nil { - return spinErr - } - - fmt.Fprintf(cmd.OutOrStdout(), "Downloaded build to: %s\n", dir) - - return err - }, - } - - cmd.Annotations = map[string]string{ - "requiredScopes": scopes.NewScopes(scopes.ReadBuilds, scopes.ReadArtifacts, scopes.ReadBuildLogs).String(), - } - - cmd.Flags().BoolVarP(&mine, "mine", "m", false, "Filter builds to only my user.") - cmd.Flags().StringVarP(&branch, "branch", "b", "", "Filter builds to this branch.") - cmd.Flags().StringVarP(&user, "user", "u", "", "Filter builds to this user. You can use name or email.") - // Pipeline flag now inherited from parent command - // can only supply --user or --mine - cmd.MarkFlagsMutuallyExclusive("mine", "user") - cmd.Flags().SortFlags = false - - return &cmd -} - -func download(ctx context.Context, build *build.Build, f *factory.Factory) (string, error) { - var wg sync.WaitGroup - var mu sync.Mutex - b, _, err := f.RestAPIClient.Builds.Get(ctx, build.Organization, build.Pipeline, fmt.Sprint(build.BuildNumber), nil) - if err != nil { - return "", err - } - - directory := fmt.Sprintf("build-%s", b.ID) - err = os.MkdirAll(directory, os.ModePerm) - if err != nil { - return "", err - } - - for _, job := range b.Jobs { - // only script (command) jobs will have logs - if job.Type != "script" { - continue - } - - go func() { - defer wg.Done() - wg.Add(1) - log, _, apiErr := f.RestAPIClient.Jobs.GetJobLog(ctx, build.Organization, build.Pipeline, b.ID, job.ID) - if err != nil { - mu.Lock() - err = apiErr - mu.Unlock() - return - } - - fileErr := os.WriteFile(filepath.Join(directory, job.ID), []byte(log.Content), 0o644) - if fileErr != nil { - mu.Lock() - err = fileErr - mu.Unlock() - } - }() - } - - artifacts, _, err := f.RestAPIClient.Artifacts.ListByBuild(ctx, build.Organization, build.Pipeline, fmt.Sprint(build.BuildNumber), nil) - if err != nil { - return "", err - } - - for _, artifact := range artifacts { - go func() { - defer wg.Done() - wg.Add(1) - out, fileErr := os.Create(filepath.Join(directory, fmt.Sprintf("artifact-%s-%s", artifact.ID, artifact.Filename))) - if err != nil { - err = fileErr - } - _, apiErr := f.RestAPIClient.Artifacts.DownloadArtifactByURL(ctx, artifact.DownloadURL, out) - if err != nil { - err = apiErr - } - }() - } - - wg.Wait() - if err != nil { - return "", err - } - - return directory, nil -} From e7b2664586b6b4335a2755579269063779953328 Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 16:55:28 +0000 Subject: [PATCH 24/27] Fix examples for download.go --- cmd/build/download.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/build/download.go b/cmd/build/download.go index 7f1465fb..832a526e 100644 --- a/cmd/build/download.go +++ b/cmd/build/download.go @@ -31,13 +31,13 @@ func (c *DownloadCmd) Help() string { return ` Examples: # Download build 123 - $ bk build download 123 -p my-pipeline + $ bk build download 123 --pipeline my-pipeline # Download most recent build - $ bk build download -p my-pipeline + $ bk build download --pipeline my-pipeline # Download most recent build on a branch - $ bk build download -b main -p my-pipeline + $ bk build download -b main --pipeline my-pipeline # Download most recent build by a user $ bk build download --pipeline my-pipeline -u alice@hello.com From 87051db608f84d2cd0aa07664a54beb6f44e19fa Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 16:56:41 +0000 Subject: [PATCH 25/27] Kongified rebuild.go --- cmd/build/rebuild.go | 129 +++++++++++++++++++++++++++++++++++++++ main.go | 1 + pkg/cmd/build/build.go | 1 - pkg/cmd/build/rebuild.go | 110 --------------------------------- 4 files changed, 130 insertions(+), 111 deletions(-) create mode 100644 cmd/build/rebuild.go delete mode 100644 pkg/cmd/build/rebuild.go diff --git a/cmd/build/rebuild.go b/cmd/build/rebuild.go new file mode 100644 index 00000000..866bc5d5 --- /dev/null +++ b/cmd/build/rebuild.go @@ -0,0 +1,129 @@ +package build + +import ( + "context" + "fmt" + + "github.com/alecthomas/kong" + buildResolver "github.com/buildkite/cli/v3/internal/build/resolver" + "github.com/buildkite/cli/v3/internal/build/resolver/options" + "github.com/buildkite/cli/v3/internal/cli" + bk_io "github.com/buildkite/cli/v3/internal/io" + pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" + "github.com/buildkite/cli/v3/internal/util" + "github.com/buildkite/cli/v3/internal/version" + "github.com/buildkite/cli/v3/pkg/cmd/factory" + "github.com/buildkite/cli/v3/pkg/cmd/validation" + buildkite "github.com/buildkite/go-buildkite/v4" +) + +type RebuildCmd struct { + BuildNumber string `arg:"" optional:"" help:"Build number to rebuild (omit for most recent build)"` + Pipeline string `help:"The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}." short:"p"` + Branch string `help:"Filter builds to this branch." short:"b"` + User string `help:"Filter builds to this user. You can use name or email." short:"u" xor:"userfilter"` + Mine bool `help:"Filter builds to only my user." short:"m" xor:"userfilter"` + Web bool `help:"Open the build in a web browser after it has been created." short:"w"` +} + +func (c *RebuildCmd) Help() string { + return ` +Examples: + # Rebuild a specific build by number + $ bk build rebuild 123 + + # Rebuild most recent build + $ bk build rebuild + + # Rebuild and open in browser + $ bk build rebuild 123 --web + + # Rebuild most recent build on a branch + $ bk build rebuild -b main + + # Rebuild most recent build by a user + $ bk build rebuild -u alice + + # Rebuild most recent build by yourself + $ bk build rebuild --mine` +} + +func (c *RebuildCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { + f, err := factory.New(version.Version) + if err != nil { + return err + } + + f.SkipConfirm = globals.SkipConfirmation() + f.NoInput = globals.DisableInput() + + if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { + return err + } + + ctx := context.Background() + + // we find the pipeline based on the following rules: + // 1. an explicit flag is passed + // 2. a configured pipeline for this directory + // 3. find pipelines matching the current repository from the API + pipelineRes := pipelineResolver.NewAggregateResolver( + pipelineResolver.ResolveFromFlag(c.Pipeline, f.Config), + pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOne), + pipelineResolver.ResolveFromRepository(f, pipelineResolver.CachedPicker(f.Config, pipelineResolver.PickOne, f.GitRepository != nil)), + ) + + // we resolve a build based on the following rules: + // 1. an optional argument + // 2. resolve from API using some context + // a. filter by branch if --branch or use current repo + // b. filter by user if --user or --mine given + optionsResolver := options.AggregateResolver{ + options.ResolveBranchFromFlag(c.Branch), + options.ResolveBranchFromRepository(f.GitRepository), + }.WithResolverWhen( + c.User != "", + options.ResolveUserFromFlag(c.User), + ).WithResolverWhen( + c.Mine || c.User == "", + options.ResolveCurrentUser(ctx, f), + ) + + args := []string{} + if c.BuildNumber != "" { + args = []string{c.BuildNumber} + } + buildRes := buildResolver.NewAggregateResolver( + buildResolver.ResolveFromPositionalArgument(args, 0, pipelineRes.Resolve, f.Config), + buildResolver.ResolveBuildWithOpts(f, pipelineRes.Resolve, optionsResolver...), + ) + + bld, err := buildRes.Resolve(ctx) + if err != nil { + return err + } + if bld == nil { + fmt.Println("No build found.") + return nil + } + + return rebuild(ctx, bld.Organization, bld.Pipeline, fmt.Sprint(bld.BuildNumber), c.Web, f) +} + +func rebuild(ctx context.Context, org string, pipeline string, buildId string, web bool, f *factory.Factory) error { + var err error + var build buildkite.Build + spinErr := bk_io.SpinWhile(fmt.Sprintf("Rerunning build #%s for pipeline %s", buildId, pipeline), func() { + build, err = f.RestAPIClient.Builds.Rebuild(ctx, org, pipeline, buildId) + }) + if spinErr != nil { + return spinErr + } + if err != nil { + return err + } + + fmt.Printf("%s\n", renderResult(fmt.Sprintf("Build created: %s", build.WebURL))) + + return util.OpenInWebBrowser(web, build.WebURL) +} diff --git a/main.go b/main.go index 5b4a2b99..856c9a38 100644 --- a/main.go +++ b/main.go @@ -54,6 +54,7 @@ type ( View build.ViewCmd `cmd:"" help:"View build information."` List build.ListCmd `cmd:"" help:"List builds."` Download build.DownloadCmd `cmd:"" help:"Download resources for a build."` + Rebuild build.RebuildCmd `cmd:"" help:"Rebuild a build."` } ClusterCmd struct { Args []string `arg:"" optional:"" passthrough:"all"` diff --git a/pkg/cmd/build/build.go b/pkg/cmd/build/build.go index b7b6eaa1..789f8594 100644 --- a/pkg/cmd/build/build.go +++ b/pkg/cmd/build/build.go @@ -29,7 +29,6 @@ func NewCmdBuild(f *factory.Factory) *cobra.Command { cmd.PersistentFlags().StringP("pipeline", "p", "", "The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}.") - cmd.AddCommand(NewCmdBuildRebuild(f)) cmd.AddCommand(NewCmdBuildWatch(f)) return &cmd diff --git a/pkg/cmd/build/rebuild.go b/pkg/cmd/build/rebuild.go deleted file mode 100644 index f3e01879..00000000 --- a/pkg/cmd/build/rebuild.go +++ /dev/null @@ -1,110 +0,0 @@ -package build - -import ( - "context" - "fmt" - - "github.com/MakeNowJust/heredoc" - buildResolver "github.com/buildkite/cli/v3/internal/build/resolver" - "github.com/buildkite/cli/v3/internal/build/resolver/options" - bk_io "github.com/buildkite/cli/v3/internal/io" - pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" - "github.com/buildkite/cli/v3/internal/util" - "github.com/buildkite/cli/v3/internal/validation/scopes" - "github.com/buildkite/cli/v3/pkg/cmd/factory" - buildkite "github.com/buildkite/go-buildkite/v4" - "github.com/spf13/cobra" -) - -func NewCmdBuildRebuild(f *factory.Factory) *cobra.Command { - var web, mine bool - var branch, pipeline, user string - - cmd := cobra.Command{ - DisableFlagsInUseLine: true, - Use: "rebuild [number] [flags]", - Short: "Rebuild a build.", - Args: cobra.MaximumNArgs(1), - Long: heredoc.Doc(` - Rebuild a build. - The web URL to the build will be printed to stdout. - `), - RunE: func(cmd *cobra.Command, args []string) error { - // Get pipeline from persistent flag - pipeline, _ = cmd.Flags().GetString("pipeline") - - // we find the pipeline based on the following rules: - // 1. an explicit flag is passed - // 2. a configured pipeline for this directory - // 3. find pipelines matching the current repository from the API - pipelineRes := pipelineResolver.NewAggregateResolver( - pipelineResolver.ResolveFromFlag(pipeline, f.Config), - pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOne), - pipelineResolver.ResolveFromRepository(f, pipelineResolver.CachedPicker(f.Config, pipelineResolver.PickOne, f.GitRepository != nil)), - ) - - // we resolve a build based on the following rules: - // 1. an optional argument - // 2. resolve from API using some context - // a. filter by branch if --branch or use current repo - // b. filter by user if --user or --mine given - optionsResolver := options.AggregateResolver{ - options.ResolveBranchFromFlag(branch), - options.ResolveBranchFromRepository(f.GitRepository), - }.WithResolverWhen( - user != "", - options.ResolveUserFromFlag(user), - ).WithResolverWhen( - mine || user == "", - options.ResolveCurrentUser(cmd.Context(), f), - ) - 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 { - fmt.Fprintf(cmd.OutOrStdout(), "No build found.\n") - return nil - } - - return rebuild(cmd.Context(), bld.Organization, bld.Pipeline, fmt.Sprint(bld.BuildNumber), web, f) - }, - } - - cmd.Annotations = map[string]string{ - "requiredScopes": string(scopes.WriteBuilds), - } - - cmd.Flags().BoolVarP(&mine, "mine", "m", false, "Filter build to only my user.") - cmd.Flags().BoolVarP(&web, "web", "w", false, "Open the build in a web browser after it has been created.") - cmd.Flags().StringVarP(&branch, "branch", "b", "", "Filter builds to this branch.") - cmd.Flags().StringVarP(&user, "user", "u", "", "Filter builds to this user. You can use name or email.") - // Pipeline flag now inherited from parent command - // can only supply --user or --mine - cmd.MarkFlagsMutuallyExclusive("mine", "user") - cmd.Flags().SortFlags = false - return &cmd -} - -func rebuild(ctx context.Context, org string, pipeline string, buildId string, web bool, f *factory.Factory) error { - var err error - var build buildkite.Build - spinErr := bk_io.SpinWhile(fmt.Sprintf("Rerunning build #%s for pipeline %s", buildId, pipeline), func() { - build, err = f.RestAPIClient.Builds.Rebuild(ctx, org, pipeline, buildId) - }) - if spinErr != nil { - return spinErr - } - if err != nil { - return err - } - - fmt.Printf("%s\n", renderResult(fmt.Sprintf("Build created: %s", build.WebURL))) - - return util.OpenInWebBrowser(web, build.WebURL) -} From 792765f0ff346eb651a4b3630d6a391fd202b593 Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 17:03:20 +0000 Subject: [PATCH 26/27] Kongify watch.go --- cmd/build/watch.go | 125 ++++++++++++++++++++++++++++++++++++ main.go | 1 + pkg/cmd/build/build.go | 2 - pkg/cmd/build/watch.go | 139 ----------------------------------------- 4 files changed, 126 insertions(+), 141 deletions(-) create mode 100644 cmd/build/watch.go delete mode 100644 pkg/cmd/build/watch.go diff --git a/cmd/build/watch.go b/cmd/build/watch.go new file mode 100644 index 00000000..df3052de --- /dev/null +++ b/cmd/build/watch.go @@ -0,0 +1,125 @@ +package build + +import ( + "context" + "fmt" + "time" + + "github.com/alecthomas/kong" + buildResolver "github.com/buildkite/cli/v3/internal/build/resolver" + "github.com/buildkite/cli/v3/internal/build/resolver/options" + "github.com/buildkite/cli/v3/internal/build/view/shared" + "github.com/buildkite/cli/v3/internal/cli" + pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" + "github.com/buildkite/cli/v3/internal/validation" + "github.com/buildkite/cli/v3/internal/version" + "github.com/buildkite/cli/v3/pkg/cmd/factory" + pkgValidation "github.com/buildkite/cli/v3/pkg/cmd/validation" +) + +type WatchCmd struct { + BuildNumber string `arg:"" optional:"" help:"Build number to watch (omit for most recent build)"` + Pipeline string `help:"The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}." short:"p"` + Branch string `help:"The branch to watch builds for." short:"b"` + Interval int `help:"Polling interval in seconds" default:"1"` +} + +func (c *WatchCmd) Help() string { + return ` +Examples: + # Watch the most recent build for the current branch + $ bk build watch --pipeline my-pipeline + + # Watch a specific build + $ bk build watch 429 --pipeline my-pipeline + + # Watch the most recent build on a specific branch + $ bk build watch -b feature-x --pipeline my-pipeline + + # Watch a build on a specific pipeline + $ bk build watch --pipeline my-pipeline + + # Set a custom polling interval (in seconds) + $ bk build watch --interval 5 --pipeline my-pipeline` +} + +func (c *WatchCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { + f, err := factory.New(version.Version) + if err != nil { + return err + } + + f.SkipConfirm = globals.SkipConfirmation() + f.NoInput = globals.DisableInput() + + if err := pkgValidation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { + return err + } + + // Validate command options + v := validation.New() + v.AddRule("Interval", validation.MinValue(1)) + if c.Pipeline != "" { + v.AddRule("Pipeline", validation.Slug) + } + if err := v.Validate(map[string]interface{}{ + "Pipeline": c.Pipeline, + "Interval": c.Interval, + }); err != nil { + return err + } + + ctx := context.Background() + + pipelineRes := pipelineResolver.NewAggregateResolver( + pipelineResolver.ResolveFromFlag(c.Pipeline, f.Config), + pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOne), + pipelineResolver.ResolveFromRepository(f, pipelineResolver.CachedPicker(f.Config, pipelineResolver.PickOne, f.GitRepository != nil)), + ) + + optionsResolver := options.AggregateResolver{ + options.ResolveBranchFromFlag(c.Branch), + options.ResolveBranchFromRepository(f.GitRepository), + } + + args := []string{} + if c.BuildNumber != "" { + args = []string{c.BuildNumber} + } + buildRes := buildResolver.NewAggregateResolver( + buildResolver.ResolveFromPositionalArgument(args, 0, pipelineRes.Resolve, f.Config), + buildResolver.ResolveBuildWithOpts(f, pipelineRes.Resolve, optionsResolver...), + ) + + bld, err := buildRes.Resolve(ctx) + if err != nil { + return err + } + if bld == nil { + return fmt.Errorf("no running builds found") + } + + fmt.Printf("Watching build %d on %s/%s\n", bld.BuildNumber, bld.Organization, bld.Pipeline) + + ticker := time.NewTicker(time.Duration(c.Interval) * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + b, _, err := f.RestAPIClient.Builds.Get(ctx, bld.Organization, bld.Pipeline, fmt.Sprint(bld.BuildNumber), nil) + if err != nil { + return err + } + + summary := shared.BuildSummaryWithJobs(&b) + fmt.Printf("\033[2J\033[H%s\n", summary) // Clear screen and move cursor to top-left + + if b.FinishedAt != nil { + return nil + } + case <-ctx.Done(): + return nil + } + } +} diff --git a/main.go b/main.go index 856c9a38..ae5e42c8 100644 --- a/main.go +++ b/main.go @@ -55,6 +55,7 @@ type ( List build.ListCmd `cmd:"" help:"List builds."` Download build.DownloadCmd `cmd:"" help:"Download resources for a build."` Rebuild build.RebuildCmd `cmd:"" help:"Rebuild a build."` + Watch build.WatchCmd `cmd:"" help:"Watch a build's progress in real-time."` } ClusterCmd struct { Args []string `arg:"" optional:"" passthrough:"all"` diff --git a/pkg/cmd/build/build.go b/pkg/cmd/build/build.go index 789f8594..f9f87ea9 100644 --- a/pkg/cmd/build/build.go +++ b/pkg/cmd/build/build.go @@ -29,8 +29,6 @@ func NewCmdBuild(f *factory.Factory) *cobra.Command { cmd.PersistentFlags().StringP("pipeline", "p", "", "The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}.") - cmd.AddCommand(NewCmdBuildWatch(f)) - return &cmd } diff --git a/pkg/cmd/build/watch.go b/pkg/cmd/build/watch.go deleted file mode 100644 index 6a884c19..00000000 --- a/pkg/cmd/build/watch.go +++ /dev/null @@ -1,139 +0,0 @@ -package build - -import ( - "fmt" - "time" - - "github.com/MakeNowJust/heredoc" - buildResolver "github.com/buildkite/cli/v3/internal/build/resolver" - "github.com/buildkite/cli/v3/internal/build/resolver/options" - "github.com/buildkite/cli/v3/internal/build/view/shared" - pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" - "github.com/buildkite/cli/v3/internal/validation" - "github.com/buildkite/cli/v3/internal/validation/scopes" - "github.com/buildkite/cli/v3/pkg/cmd/factory" - "github.com/spf13/cobra" -) - -type WatchOptions struct { - Pipeline string - Branch string - IntervalSeconds int -} - -func (o *WatchOptions) Validate() error { - v := validation.New() - v.AddRule("IntervalSeconds", validation.MinValue(1)) - - if o.Pipeline != "" { - v.AddRule("Pipeline", validation.Slug) - } - - return v.Validate(map[string]interface{}{ - "Pipeline": o.Pipeline, - "IntervalSeconds": o.IntervalSeconds, - }) -} - -func NewCmdBuildWatch(f *factory.Factory) *cobra.Command { - opts := &WatchOptions{ - IntervalSeconds: 1, // default value - } - - 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 - `), - PreRunE: func(cmd *cobra.Command, args []string) error { - // Validate command options - if err := opts.Validate(); err != nil { - return err - } - - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { - // Get pipeline from persistent flag - opts.Pipeline, _ = cmd.Flags().GetString("pipeline") - - pipelineRes := pipelineResolver.NewAggregateResolver( - pipelineResolver.ResolveFromFlag(opts.Pipeline, f.Config), - pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOne), - pipelineResolver.ResolveFromRepository(f, pipelineResolver.CachedPicker(f.Config, pipelineResolver.PickOne, f.GitRepository != nil)), - ) - - optionsResolver := options.AggregateResolver{ - options.ResolveBranchFromFlag(opts.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(opts.IntervalSeconds) * time.Second) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - b, _, err := f.RestAPIClient.Builds.Get(cmd.Context(), bld.Organization, bld.Pipeline, fmt.Sprint(bld.BuildNumber), nil) - if err != nil { - return err - } - - summary := shared.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.Annotations = map[string]string{ - "requiredScopes": string(scopes.ReadBuilds), - } - - // Pipeline flag now inherited from parent command - cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "The branch to watch builds for.") - cmd.Flags().IntVar(&opts.IntervalSeconds, "interval", opts.IntervalSeconds, "Polling interval in seconds") - - return &cmd -} From f9a01822113fa334d1e79d400fcfa6fc46668b1c Mon Sep 17 00:00:00 2001 From: Joe Coleman Date: Fri, 28 Nov 2025 17:12:34 +0000 Subject: [PATCH 27/27] Removing old build.go, removing refs to it from root.go, allowing Kong to handle build subcommands now --- main.go | 5 +++++ pkg/cmd/build/build.go | 38 -------------------------------------- pkg/cmd/root/root.go | 2 -- 3 files changed, 5 insertions(+), 40 deletions(-) delete mode 100644 pkg/cmd/build/build.go diff --git a/main.go b/main.go index ae5e42c8..09d0dc2f 100644 --- a/main.go +++ b/main.go @@ -242,6 +242,11 @@ func isHelpRequest() bool { return false } + // Let Kong handle build subcommand help since build is fully migrated + if len(os.Args) >= 2 && os.Args[1] == "build" { + return false + } + // Subcommand help, e.g. bk agent --help - delegate to Cobra if len(os.Args) == 3 && (os.Args[2] == "-h" || os.Args[2] == "--help") { return true diff --git a/pkg/cmd/build/build.go b/pkg/cmd/build/build.go deleted file mode 100644 index f9f87ea9..00000000 --- a/pkg/cmd/build/build.go +++ /dev/null @@ -1,38 +0,0 @@ -package build - -import ( - "github.com/MakeNowJust/heredoc" - "github.com/buildkite/cli/v3/pkg/cmd/factory" - "github.com/buildkite/cli/v3/pkg/cmd/validation" - "github.com/charmbracelet/lipgloss" - "github.com/spf13/cobra" -) - -func NewCmdBuild(f *factory.Factory) *cobra.Command { - cmd := cobra.Command{ - Use: "build ", - Short: "Manage pipeline builds", - Long: "Work with Buildkite pipeline builds.", - Example: heredoc.Doc(` - # To create a new build - $ bk build new -m "Build from cli" -c "HEAD" -b "main" - `), - PersistentPreRunE: validation.CheckValidConfiguration(f.Config), - Annotations: map[string]string{ - "help:arguments": heredoc.Doc(` - A pipeline is passed as an argument. It can be supplied in any of the following formats: - - "PIPELINE_SLUG" - - "ORGANIZATION_SLUG/PIPELINE_SLUG" - `), - }, - } - - cmd.PersistentFlags().StringP("pipeline", "p", "", "The pipeline to use. This can be a {pipeline slug} or in the format {org slug}/{pipeline slug}.") - - return &cmd -} - -func renderResult(result string) string { - return lipgloss.JoinVertical(lipgloss.Top, - lipgloss.NewStyle().Padding(0, 0).Render(result)) -} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 662ac2a6..610819f5 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -7,7 +7,6 @@ import ( agentCmd "github.com/buildkite/cli/v3/pkg/cmd/agent" apiCmd "github.com/buildkite/cli/v3/pkg/cmd/api" artifactsCmd "github.com/buildkite/cli/v3/pkg/cmd/artifacts" - buildCmd "github.com/buildkite/cli/v3/pkg/cmd/build" clusterCmd "github.com/buildkite/cli/v3/pkg/cmd/cluster" configureCmd "github.com/buildkite/cli/v3/pkg/cmd/configure" "github.com/buildkite/cli/v3/pkg/cmd/factory" @@ -59,7 +58,6 @@ func NewCmdRoot(f *factory.Factory) (*cobra.Command, error) { cmd.AddCommand(agentCmd.NewCmdAgent(f)) cmd.AddCommand(apiCmd.NewCmdAPI(f)) cmd.AddCommand(artifactsCmd.NewCmdArtifacts(f)) - cmd.AddCommand(buildCmd.NewCmdBuild(f)) cmd.AddCommand(clusterCmd.NewCmdCluster(f)) cmd.AddCommand(configureCmd.NewCmdConfigure(f)) cmd.AddCommand(initCmd.NewCmdInit(f))