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) +} 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)) +} diff --git a/cmd/build/download.go b/cmd/build/download.go new file mode 100644 index 00000000..832a526e --- /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 --pipeline my-pipeline + + # Download most recent build + $ bk build download --pipeline my-pipeline + + # Download most recent build on a branch + $ 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 + + # 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/pkg/cmd/build/list.go b/cmd/build/list.go similarity index 55% rename from pkg/cmd/build/list.go rename to cmd/build/list.go index 5fe6bcaa..6004ead5 100644 --- a/pkg/cmd/build/list.go +++ b/cmd/build/list.go @@ -5,19 +5,21 @@ import ( "encoding/base64" "fmt" "net/mail" + "os" "strings" "time" - "github.com/MakeNowJust/heredoc" + "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/validation/scopes" + "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" - "github.com/spf13/cobra" ) const ( @@ -25,189 +27,129 @@ const ( 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 +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 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. +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. +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 +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 +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). - # Get more builds (automatically paginates) - $ bk build list --limit 500 +Examples: + # List recent builds (50 by default) + $ bk build list - # List builds from the last hour - $ bk build list --since 1h + # Get more builds (automatically paginates) + $ bk build list --limit 500 - # List failed builds - $ bk build list --state failed + # List builds from the last hour + $ bk build list --since 1h - # List builds on main branch - $ bk build list --branch main + # List failed builds + $ bk build list --state failed - # List builds by alice - $ bk build list --creator alice@company.com + # List builds on main branch + $ bk build list --branch main - # List builds that took longer than 20 minutes - $ bk build list --duration ">20m" + # List builds by alice + $ bk build list --creator alice@company.com - # List builds that finished in under 5 minutes - $ bk build list --duration "<5m" + # List builds that took longer than 20 minutes + $ bk build list --duration ">20m" - # 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 - } + # List builds that finished in under 5 minutes + $ bk build list --duration "<5m" - // Get pipeline from persistent flag - opts.pipeline, _ = cmd.Flags().GetString("pipeline") + # Combine filters: failed builds on main branch in the last 24 hours + $ bk build list --state failed --branch main --since 24h - 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) - } - } + # Find builds containing "deploy" in the message + $ bk build list --message deploy - 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") - } - } + # Complex filtering: slow builds (>30m) that failed on feature branches + $ bk build list --duration ">30m" --state failed --branch feature/` +} - listOpts, err := buildListOptionsFromFlags(&opts) - if err != nil { - return err - } +func (c *ListCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { + f, err := factory.New(version.Version) + 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) - } + f.SkipConfirm = globals.SkipConfirmation() + f.NoInput = globals.DisableInput() - if len(builds) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "No builds found matching the specified criteria.") - return nil - } + if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil { + return err + } - if format == output.FormatText { - return nil - } + ctx := context.Background() - return DisplayBuildsFunc(cmd, builds, format, false) - }, + 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) + } } - cmd.Annotations = map[string]string{ - "requiredScopes": string(scopes.ReadBuilds), + 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") + } } - // 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) + listOpts, err := c.buildListOptions() if err != nil { - return "", fmt.Errorf("failed to query user by email: %w", err) + return 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) + org := f.Config.OrganizationSlug() + builds, err := c.fetchBuilds(ctx, f, org, listOpts) + if err != nil { + return fmt.Errorf("failed to list builds: %w", err) } - // 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 len(builds) == 0 { + fmt.Println("No builds found matching the specified criteria.") + return nil } - if userUUID, found := strings.CutPrefix(string(decoded), "User---"); found { - return userUUID, nil + format := output.Format(c.Output) + if format == output.FormatText { + return nil } - return "", fmt.Errorf("unexpected user ID format") + return displayBuilds(builds, format, false) } -func buildListOptionsFromFlags(opts *buildListOptions) (*buildkite.BuildsListOptions, error) { +func (c *ListCmd) buildListOptions() (*buildkite.BuildsListOptions, error) { listOpts := &buildkite.BuildsListOptions{ ListOptions: buildkite.ListOptions{ PerPage: pageSize, @@ -215,38 +157,37 @@ func buildListOptionsFromFlags(opts *buildListOptions) (*buildkite.BuildsListOpt } now := time.Now() - if opts.since != "" { - d, err := time.ParseDuration(opts.since) + if c.Since != "" { + d, err := time.ParseDuration(c.Since) if err != nil { - return nil, fmt.Errorf("invalid since duration '%s': %w", opts.since, err) + return nil, fmt.Errorf("invalid since duration '%s': %w", c.Since, err) } listOpts.CreatedFrom = now.Add(-d) } - if opts.until != "" { - d, err := time.ParseDuration(opts.until) + if c.Until != "" { + d, err := time.ParseDuration(c.Until) if err != nil { - return nil, fmt.Errorf("invalid until duration '%s': %w", opts.until, err) + return nil, fmt.Errorf("invalid until duration '%s': %w", c.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 { + 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 = opts.branch - listOpts.Creator = opts.creator - listOpts.Commit = opts.commit + listOpts.Branch = c.Branch + listOpts.Creator = c.Creator + listOpts.Commit = c.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() +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) @@ -260,8 +201,10 @@ func fetchBuilds(cmd *cobra.Command, f *factory.Factory, org string, opts buildL rawSinceConfirm := 0 previousPageFirstBuildNumber := 0 + format := output.Format(c.Output) + for page := 1; ; page++ { - if !opts.noLimit && len(allBuilds) >= opts.limit { + if !c.NoLimit && len(allBuilds) >= c.Limit { break } @@ -271,14 +214,14 @@ func fetchBuilds(cmd *cobra.Command, f *factory.Factory, org string, opts buildL var err error spinnerMsg := "Loading builds (" - if opts.pipeline != "" { - spinnerMsg += fmt.Sprintf("pipeline %s, ", opts.pipeline) + if c.Pipeline != "" { + spinnerMsg += fmt.Sprintf("pipeline %s, ", c.Pipeline) } - filtersActive := opts.duration != "" || opts.message != "" + filtersActive := c.Duration != "" || c.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) + 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) } @@ -293,7 +236,7 @@ func fetchBuilds(cmd *cobra.Command, f *factory.Factory, org string, opts buildL ) } - confirmed, err := ConfirmFunc(f, prompt) + confirmed, err := io.Confirm(f, prompt) if err != nil { return nil, err } @@ -307,8 +250,8 @@ func fetchBuilds(cmd *cobra.Command, f *factory.Factory, org string, opts buildL } spinErr := io.SpinWhile(spinnerMsg, func() { - if opts.pipeline != "" { - builds, err = getBuildsByPipeline(ctx, f, org, opts.pipeline, listOpts) + if c.Pipeline != "" { + builds, err = c.getBuildsByPipeline(ctx, f, org, listOpts) } else { builds, _, err = f.RestAPIClient.Builds.ListByOrg(ctx, org, listOpts) } @@ -335,7 +278,7 @@ func fetchBuilds(cmd *cobra.Command, f *factory.Factory, org string, opts buildL 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 + return nil, fmt.Errorf("API returned duplicate results, stopping to prevent infinite loop") } } @@ -343,7 +286,7 @@ func fetchBuilds(cmd *cobra.Command, f *factory.Factory, org string, opts buildL previousPageFirstBuildNumber = builds[0].Number } - builds, err = applyClientSideFilters(builds, opts) + builds, err = c.applyClientSideFilters(builds) if err != nil { return nil, fmt.Errorf("failed to apply filters: %w", err) } @@ -351,9 +294,9 @@ func fetchBuilds(cmd *cobra.Command, f *factory.Factory, org string, opts buildL // 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 + if !c.NoLimit { + remaining := c.Limit - len(allBuilds) + if remaining <= 0 { break } if len(builds) > remaining { @@ -369,9 +312,9 @@ func fetchBuilds(cmd *cobra.Command, f *factory.Factory, org string, opts buildL } // 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 { + if format == output.FormatText && len(buildsToAdd) > 0 { showHeader := !printedAny - _ = DisplayBuildsFunc(cmd, buildsToAdd, format, showHeader) + _ = displayBuilds(buildsToAdd, format, showHeader) printedAny = true } @@ -386,9 +329,9 @@ func fetchBuilds(cmd *cobra.Command, f *factory.Factory, org string, opts buildL return allBuilds, nil } -func getBuildsByPipeline(ctx context.Context, f *factory.Factory, org, pipelineFlag string, listOpts *buildkite.BuildsListOptions) ([]buildkite.Build, error) { +func (c *ListCmd) getBuildsByPipeline(ctx context.Context, f *factory.Factory, org string, listOpts *buildkite.BuildsListOptions) ([]buildkite.Build, error) { pipelineRes := pipelineResolver.NewAggregateResolver( - pipelineResolver.ResolveFromFlag(pipelineFlag, f.Config), + pipelineResolver.ResolveFromFlag(c.Pipeline, f.Config), pipelineResolver.ResolveFromConfig(f.Config, pipelineResolver.PickOne), ) @@ -401,25 +344,25 @@ func getBuildsByPipeline(ctx context.Context, f *factory.Factory, org, pipelineF return builds, err } -func applyClientSideFilters(builds []buildkite.Build, opts buildListOptions) ([]buildkite.Build, error) { - if opts.duration == "" && opts.message == "" { +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 opts.duration != "" { + if c.Duration != "" { durationOp = ">=" - durationStr := opts.duration + durationStr := c.Duration switch { - case strings.HasPrefix(opts.duration, "<"): + case strings.HasPrefix(c.Duration, "<"): durationOp = "<" - durationStr = opts.duration[1:] - case strings.HasPrefix(opts.duration, ">"): + durationStr = c.Duration[1:] + case strings.HasPrefix(c.Duration, ">"): durationOp = ">" - durationStr = opts.duration[1:] + durationStr = c.Duration[1:] } d, err := time.ParseDuration(durationStr) @@ -430,13 +373,13 @@ func applyClientSideFilters(builds []buildkite.Build, opts buildListOptions) ([] } var messageFilter string - if opts.message != "" { - messageFilter = strings.ToLower(opts.message) + if c.Message != "" { + messageFilter = strings.ToLower(c.Message) } var result []buildkite.Build for _, build := range builds { - if opts.duration != "" { + if c.Duration != "" { if build.StartedAt == nil { continue } @@ -476,9 +419,43 @@ func applyClientSideFilters(builds []buildkite.Build, opts buildListOptions) ([] return result, nil } -func displayBuilds(cmd *cobra.Command, builds []buildkite.Build, format output.Format, withHeader bool) error { +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(cmd.OutOrStdout(), builds, format) + return output.Write(os.Stdout, builds, format) } const ( @@ -554,7 +531,7 @@ func displayBuilds(cmd *cobra.Command, builds []buildkite.Build, format output.F buf.WriteString("\n") } - fmt.Fprint(cmd.OutOrStdout(), buf.String()) + fmt.Print(buf.String()) return nil } diff --git a/cmd/build/list_test.go b/cmd/build/list_test.go new file mode 100644 index 00000000..216d39ce --- /dev/null +++ b/cmd/build/list_test.go @@ -0,0 +1,59 @@ +package build + +import ( + "testing" + "time" + + 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{ + { + 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/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/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)) +} 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 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/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 +} diff --git a/main.go b/main.go index ee9149b9..09d0dc2f 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,13 @@ 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 + 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."` + 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"` @@ -77,26 +90,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 +122,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 +171,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 +189,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 +203,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 +216,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 } @@ -186,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 79ee4a45..00000000 --- a/pkg/cmd/build/build.go +++ /dev/null @@ -1,46 +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}.") - - 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)) - - return &cmd -} - -func renderResult(result string) string { - return lipgloss.JoinVertical(lipgloss.Top, - lipgloss.NewStyle().Padding(0, 0).Render(result)) -} 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) -} 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 -} 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) - } -} 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) -} 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) -} 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 -} 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 -} 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)) 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 +}