Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 33 additions & 19 deletions cmd/pipeline/copy.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@ import (
)

type CopyCmd struct {
Pipeline string `arg:"" help:"Source pipeline to copy (slug or org/slug). Uses current pipeline if not specified." optional:""`
Org string `help:"Organization slug" name:"org"`
Target string `help:"Name for the new pipeline, or org/name to copy to a different organization" short:"t"`
ClusterUUID string `help:"Cluster UUID for the new pipeline" name:"cluster-uuid"`
ClusterName string `help:"Cluster name for the new pipeline (resolved to UUID)" name:"cluster-name"`
ClusterShorthand string `short:"c" hidden:"" name:"c" help:""`
DryRun bool `help:"Show what would be copied without creating the pipeline"`
Pipeline string `arg:"" help:"Source pipeline to copy (slug or org/slug). Uses current pipeline if not specified." optional:""`
Org string `help:"Organization slug" name:"org"`
Target string `help:"Name for the new pipeline, or org/name to copy to a different organization" short:"t"`
ClusterUUID string `help:"Cluster UUID for the new pipeline" name:"cluster-uuid"`
ClusterName string `help:"Cluster name for the new pipeline (resolved to UUID)" name:"cluster-name"`
ClusterShorthand string `short:"c" hidden:"" name:"c" help:""`
DryRun bool `help:"Show what would be copied without creating the pipeline"`
Teams map[string]string `name:"team" help:"Assign a destination team as SLUG=ACCESS_LEVEL (repeatable); access: read_only, build_and_read, manage_build_and_read"`
output.OutputFlags
}

Expand All @@ -49,13 +50,13 @@ func (c *CopyCmd) Validate() error {
if c.ClusterUUID != "" && c.ClusterName != "" {
return fmt.Errorf("only one of --cluster-uuid or --cluster-name can be specified")
}
return nil
return validateTeams(c.Teams)
}

func (c *CopyCmd) Help() string {
return `Copy an existing pipeline's configuration to create a new pipeline.

This command copies all configuration from a source pipeline including:
This command copies configuration from a source pipeline including:
- Pipeline steps (YAML configuration)
- Repository settings
- Branch configuration
Expand All @@ -64,8 +65,16 @@ This command copies all configuration from a source pipeline including:
- Environment variables
- Tags and visibility

When copying to a different organization, cluster configuration is skipped
(clusters are organization-specific).
Team assignments are not copied automatically. Use --team SLUG=ACCESS_LEVEL
to assign destination teams explicitly; repeat the flag for multiple teams.
Slugs must match exactly in the destination organization and require read_teams
API access to resolve. Access levels are read_only, build_and_read, and
manage_build_and_read. Without --team, no team assignments are sent, even when
the source has teams. Non-admin users in Teams-enabled organizations may receive
a 422 from the API if they do not assign a team.

When copying to a different organization, the cluster is not copied because it
is organization-specific. Use --team with destination team slugs.

Examples:
# Copy the current pipeline to a new pipeline
Expand All @@ -74,6 +83,9 @@ Examples:
# Copy a specific pipeline
$ bk pipeline cp my-existing-pipeline --target "my-new-pipeline"

# Copy with explicit team access
$ bk pipeline cp my-pipeline --target "my-copy" --team platform-engineering=build_and_read

# Copy a pipeline from another org (if you have access)
$ bk pipeline cp other-org/their-pipeline --target "my-copy"

Expand Down Expand Up @@ -138,11 +150,17 @@ func (c *CopyCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error {
return err
}

createReq := c.buildCreatePipeline(source, target.Name, isCrossOrg, clusterID)
createReq.Teams, err = c.resolveTeams(ctx, f, sourcePipeline.Org, target.Org)
if err != nil {
return err
}

if c.DryRun {
return c.runDryRun(kongCtx, f, source, target, isCrossOrg, clusterID)
return c.runDryRun(kongCtx, f, createReq)
}

return c.runCopy(kongCtx, f, source, target, isCrossOrg, clusterID)
return c.runCopy(kongCtx, f, target, isCrossOrg, createReq)
}

func (c *CopyCmd) resolveSourcePipeline(ctx context.Context, f *factory.Factory) (*pipeline.Pipeline, error) {
Expand Down Expand Up @@ -240,11 +258,9 @@ func (c *CopyCmd) fetchSourcePipeline(ctx context.Context, f *factory.Factory, o
}

// runDryRun allows a user to validate what their changes will do, based on the current `--dry-run` flag in Create
func (c *CopyCmd) runDryRun(kongCtx *kong.Context, f *factory.Factory, source *buildkite.Pipeline, target *copyTarget, isCrossOrg bool, clusterID string) error {
func (c *CopyCmd) runDryRun(kongCtx *kong.Context, f *factory.Factory, createReq buildkite.CreatePipeline) error {
format := output.ResolveFormat(c.Output, f.Config.OutputFormat())

createReq := c.buildCreatePipeline(source, target.Name, isCrossOrg, clusterID)

// For dry-run, default to JSON if text format requested
if format == output.FormatText {
format = output.FormatJSON
Expand All @@ -253,7 +269,7 @@ func (c *CopyCmd) runDryRun(kongCtx *kong.Context, f *factory.Factory, source *b
return output.Write(kongCtx.Stdout, createReq, format)
}

func (c *CopyCmd) runCopy(kongCtx *kong.Context, f *factory.Factory, source *buildkite.Pipeline, target *copyTarget, isCrossOrg bool, clusterID string) error {
func (c *CopyCmd) runCopy(kongCtx *kong.Context, f *factory.Factory, target *copyTarget, isCrossOrg bool, createReq buildkite.CreatePipeline) error {
ctx := context.Background()
format := output.ResolveFormat(c.Output, f.Config.OutputFormat())

Expand All @@ -267,8 +283,6 @@ func (c *CopyCmd) runCopy(kongCtx *kong.Context, f *factory.Factory, source *bui
}
}

createReq := c.buildCreatePipeline(source, target.Name, isCrossOrg, clusterID)

var newPipeline buildkite.Pipeline
var resp *buildkite.Response
var err error
Expand Down
41 changes: 31 additions & 10 deletions cmd/pipeline/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,16 @@ import (
)

type CreateCmd struct {
Name string `arg:"" help:"Name of the pipeline" required:""`
Org string `help:"Organization slug." name:"org"`
Description string `help:"Description of the pipeline" short:"d"`
Repository string `help:"Repository URL" short:"r"`
ClusterUUID string `help:"Cluster UUID to assign the pipeline to" name:"cluster-uuid"`
ClusterName string `help:"Cluster name to assign the pipeline to (resolved to UUID)" name:"cluster-name"`
ClusterShorthand string `short:"c" hidden:"" name:"c" help:""`
CreateWebhook bool `help:"Create an SCM webhook for the pipeline (GitHub and GitHub Enterprise only)" short:"W"`
DryRun bool `help:"Simulate pipeline creation without actually creating it"`
Name string `arg:"" help:"Name of the pipeline" required:""`
Org string `help:"Organization slug." name:"org"`
Description string `help:"Description of the pipeline" short:"d"`
Repository string `help:"Repository URL" short:"r"`
ClusterUUID string `help:"Cluster UUID to assign the pipeline to" name:"cluster-uuid"`
ClusterName string `help:"Cluster name to assign the pipeline to (resolved to UUID)" name:"cluster-name"`
ClusterShorthand string `short:"c" hidden:"" name:"c" help:""`
CreateWebhook bool `help:"Create an SCM webhook for the pipeline (GitHub and GitHub Enterprise only)" short:"W"`
DryRun bool `help:"Simulate pipeline creation without actually creating it"`
Teams map[string]string `name:"team" help:"Team assignment as SLUG=ACCESS_LEVEL (repeatable); access: read_only, build_and_read, manage_build_and_read"`
output.OutputFlags
}

Expand All @@ -46,7 +47,7 @@ func (c *CreateCmd) Validate() error {
if c.ClusterUUID != "" && c.ClusterName != "" {
return fmt.Errorf("only one of --cluster-uuid or --cluster-name can be specified")
}
return nil
return validateTeams(c.Teams)
}

func (c *CreateCmd) Help() string {
Expand All @@ -58,13 +59,22 @@ actually creating it. This outputs a JSON representation of the pipeline to be c
Use --cluster-uuid to assign a pipeline to a cluster by UUID, or --cluster-name to
assign by name (the name will be resolved to the corresponding UUID).

Use --team SLUG=ACCESS_LEVEL for each team assignment. Slugs must match
exactly in the destination organization and require read_teams API access to resolve.
Access levels are read_only, build_and_read, and manage_build_and_read. Without
--team, no team assignments are sent or inferred. Non-admin users in organizations
with Teams enabled must assign a team when creating a pipeline.

Examples:
# Create a new pipeline
$ bk pipeline create "My Pipeline" --description "My pipeline description" --repository "git@github.com:org/repo.git"

# Create a new pipeline and view the created pipeline in JSON format
$ bk pipeline create "My Pipeline" --description "My pipeline description" --repository "git@github.com:org/repo.git" --output json

# Create a pipeline with team access
$ bk pipeline create "My Pipeline" -r "git@github.com:org/repo.git" --team platform-engineering=build_and_read

# Create a pipeline with a cluster (by UUID)
$ bk pipeline create "My Pipeline" -d "Description" -r "git@github.com:org/repo.git" --cluster-uuid "cluster-uuid-123"

Expand Down Expand Up @@ -148,6 +158,10 @@ func (c *CreateCmd) createPipeline(ctx context.Context, f *factory.Factory) (*bu
if err != nil {
return nil, err
}
teams, err := resolveTeamSlugs(ctx, f.RestAPIClient, c.orgSlug(f.Config), c.Teams)
if err != nil {
return nil, err
}

repoURL := getRepositoryURL(f, c.Repository)

Expand All @@ -160,6 +174,7 @@ func (c *CreateCmd) createPipeline(ctx context.Context, f *factory.Factory) (*bu
Repository: repoURL,
Description: c.Description,
ClusterID: clusterID,
Teams: teams,
Configuration: "steps:\n - label: \":pipeline:\"\n command: buildkite-agent pipeline upload",
}

Expand Down Expand Up @@ -236,6 +251,7 @@ type PipelineDryRun struct {
Emoji *string `json:"emoji"`
Color *string `json:"color"`
CreatedBy *buildkite.User `json:"created_by"`
Teams map[string]string `json:"teams,omitempty"`
}

func initialisePipelineDryRun() PipelineDryRun {
Expand All @@ -259,6 +275,10 @@ func (c *CreateCmd) createPipelineDryRun(ctx context.Context, f *factory.Factory
}

orgSlug := c.orgSlug(f.Config)
teams, err := resolveTeamSlugs(ctx, f.RestAPIClient, orgSlug, c.Teams)
if err != nil {
return nil, err
}
pipeline := initialisePipelineDryRun()

pipeline.ID = "00000000-0000-0000-0000-000000000000"
Expand All @@ -267,6 +287,7 @@ func (c *CreateCmd) createPipelineDryRun(ctx context.Context, f *factory.Factory
pipeline.WebURL = fmt.Sprintf("https://buildkite.com/%s/%s", orgSlug, pipelineSlug)
pipeline.Name = c.Name
pipeline.Description = c.Description
pipeline.Teams = teams
pipeline.Slug = pipelineSlug
pipeline.Repository = c.Repository
clusterUUID, _ := c.resolveClusterUUID(ctx, f)
Expand Down
72 changes: 72 additions & 0 deletions cmd/pipeline/teams.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package pipeline

import (
"context"
"fmt"
"strings"

"github.com/buildkite/cli/v3/pkg/cmd/factory"
buildkite "github.com/buildkite/go-buildkite/v5"
)

func validateTeams(teams map[string]string) error {
for slug, access := range teams {
if strings.TrimSpace(slug) == "" {
return fmt.Errorf("--team requires a non-empty team slug")
}
switch access {
case "read_only", "build_and_read", "manage_build_and_read":
default:
return fmt.Errorf("invalid --team access level %q: use read_only, build_and_read, or manage_build_and_read", access)
}
}
return nil
}

func resolveTeamSlugs(ctx context.Context, client *buildkite.Client, org string, slugs map[string]string) (map[string]string, error) {
if len(slugs) == 0 {
return nil, nil
}
assignments := make(map[string]string, len(slugs))
matched := make(map[string]bool, len(slugs))
opts := &buildkite.TeamsListOptions{ListOptions: buildkite.ListOptions{Page: 1, PerPage: 100}}
for {
teams, resp, err := client.Teams.List(ctx, org, opts)
if err != nil {
return nil, fmt.Errorf("could not resolve team slugs in organization %q: %w", org, err)
}
for _, team := range teams {
access, requested := slugs[team.Slug]
if !requested {
continue
}
matched[team.Slug] = true
assignments[team.ID] = access
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
Comment on lines +33 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we stop paginating once all requested slugs have been found? Currently, even if every requested team is on page 1, this continues through every remaining page. That adds unnecessary API calls and means a later page failing or being rate-limited can prevent pipeline creation despite all assignments already being resolved. Checking len(matched) == len(slugs) before following resp.NextPage would avoid that.

for slug := range slugs {
if !matched[slug] {
return nil, fmt.Errorf("team slug %q not found in organization %q; use the exact team slug from bk team list", slug, org)
}
}
return assignments, nil
}

func (c *CopyCmd) resolveTeams(ctx context.Context, f *factory.Factory, sourceOrg, targetOrg string) (map[string]string, error) {
if len(c.Teams) == 0 {
return nil, nil
}
client := f.RestAPIClient
if targetOrg != sourceOrg {
var err error
client, err = c.getClientForOrg(f, targetOrg)
if err != nil {
return nil, err
}
}
return resolveTeamSlugs(ctx, client, targetOrg, c.Teams)
}
Loading