Skip to content

Commit 78d48f8

Browse files
authored
feat: add a pipeline copy (cp) command (#600)
Signed-off-by: Ben McNicholl <[email protected]>
1 parent 0fee024 commit 78d48f8

File tree

3 files changed

+382
-0
lines changed

3 files changed

+382
-0
lines changed

cmd/pipeline/copy.go

Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
package pipeline
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"strings"
8+
9+
"github.com/alecthomas/kong"
10+
"github.com/buildkite/cli/v3/internal/cli"
11+
bkIO "github.com/buildkite/cli/v3/internal/io"
12+
"github.com/buildkite/cli/v3/internal/pipeline"
13+
"github.com/buildkite/cli/v3/internal/pipeline/resolver"
14+
"github.com/buildkite/cli/v3/pkg/cmd/factory"
15+
"github.com/buildkite/cli/v3/pkg/cmd/validation"
16+
"github.com/buildkite/cli/v3/pkg/output"
17+
buildkite "github.com/buildkite/go-buildkite/v4"
18+
)
19+
20+
type CopyCmd struct {
21+
Pipeline string `arg:"" help:"Source pipeline to copy (slug or org/slug). Uses current pipeline if not specified." optional:""`
22+
Target string `help:"Name for the new pipeline, or org/name to copy to a different organization" short:"t"`
23+
Cluster string `help:"Cluster name or ID for the new pipeline (required for cross-org copies if target org uses clusters)" short:"c"`
24+
DryRun bool `help:"Show what would be copied without creating the pipeline"`
25+
Output string `help:"Output format: json, yaml, text" short:"o" default:"text"`
26+
}
27+
28+
// we store the target organization and pipeline name for a future go-buildkite call
29+
type copyTarget struct {
30+
Org string
31+
Name string
32+
}
33+
34+
func (c *CopyCmd) Help() string {
35+
// returns the biggest help message ever seen
36+
return `Copy an existing pipeline's configuration to create a new pipeline.
37+
38+
This command copies all configuration from a source pipeline including:
39+
- Pipeline steps (YAML configuration)
40+
- Repository settings
41+
- Branch configuration
42+
- Build skipping/cancellation rules
43+
- Provider settings (trigger mode, PR builds, commit statuses, etc.)
44+
- Environment variables
45+
- Tags and visibility
46+
47+
When copying to a different organization, cluster configuration is skipped
48+
(clusters are organization-specific).
49+
50+
Examples:
51+
# Copy the current pipeline to a new pipeline
52+
$ bk pipeline cp --target "my-pipeline-v2"
53+
54+
# Copy a specific pipeline
55+
$ bk pipeline cp my-existing-pipeline --target "my-new-pipeline"
56+
57+
# Copy a pipeline from another org (if you have access)
58+
$ bk pipeline cp other-org/their-pipeline --target "my-copy"
59+
60+
# Copy to a different organization
61+
$ bk pipeline cp my-pipeline --target "other-org/my-pipeline" --cluster "8302f0b-9b99-4663-23f3-2d64f88s693e"
62+
63+
# Interactive mode - prompts for source and target
64+
$ bk pipeline cp
65+
66+
# Preview what would be copied without creating
67+
$ bk pipeline cp my-pipeline --target "copy" --dry-run
68+
69+
# Output the new pipeline details as JSON
70+
$ bk pipeline cp my-pipeline -t "new-pipeline" -o json
71+
`
72+
}
73+
74+
func (c *CopyCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error {
75+
f, err := factory.New()
76+
if err != nil {
77+
return err
78+
}
79+
80+
f.SkipConfirm = globals.SkipConfirmation()
81+
f.NoInput = globals.DisableInput()
82+
f.Quiet = globals.IsQuiet()
83+
84+
if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil {
85+
return err
86+
}
87+
88+
ctx := context.Background()
89+
90+
// Resolve source pipeline
91+
// looks at current project if no source provided, or tries to resolve it using the current selected org
92+
sourcePipeline, err := c.resolveSourcePipeline(ctx, f)
93+
if err != nil {
94+
return err
95+
}
96+
97+
// Get target org and name
98+
// Spoiler: we use `/` as an indicator for org/pipeline split
99+
target, err := c.resolveTarget(f, sourcePipeline.Name)
100+
if err != nil {
101+
return err
102+
}
103+
104+
source, err := c.fetchSourcePipeline(ctx, f, sourcePipeline.Org, sourcePipeline.Name)
105+
if err != nil {
106+
return err
107+
}
108+
109+
// Determine if this is a cross-org copy
110+
isCrossOrg := target.Org != sourcePipeline.Org
111+
112+
// Resolve cluster ID - required for cross-org copies
113+
clusterID, err := c.resolveCluster(f, source.ClusterID, isCrossOrg)
114+
if err != nil {
115+
return err
116+
}
117+
118+
if c.DryRun {
119+
return c.runDryRun(kongCtx, source, target, isCrossOrg, clusterID)
120+
}
121+
122+
return c.runCopy(kongCtx, f, source, target, isCrossOrg, clusterID)
123+
}
124+
125+
func (c *CopyCmd) resolveSourcePipeline(ctx context.Context, f *factory.Factory) (*pipeline.Pipeline, error) {
126+
var args []string
127+
if c.Pipeline != "" {
128+
args = []string{c.Pipeline}
129+
}
130+
131+
pipelineRes := resolver.NewAggregateResolver(
132+
resolver.ResolveFromPositionalArgument(args, 0, f.Config),
133+
resolver.ResolveFromConfig(f.Config, resolver.PickOne),
134+
resolver.ResolveFromRepository(f, resolver.CachedPicker(f.Config, resolver.PickOne, f.GitRepository != nil)),
135+
)
136+
137+
p, err := pipelineRes.Resolve(ctx)
138+
if err != nil {
139+
return nil, fmt.Errorf("could not resolve source pipeline, ensure correct config is in use (`bk org ls`): %w", err)
140+
}
141+
142+
return p, nil
143+
}
144+
145+
func (c *CopyCmd) resolveTarget(f *factory.Factory, sourceName string) (*copyTarget, error) {
146+
targetStr := c.Target
147+
if targetStr == "" {
148+
// Interactive prompt for target name
149+
defaultName := fmt.Sprintf("%s-copy", sourceName)
150+
var err error
151+
targetStr, err = bkIO.PromptForInput("Target pipeline (or org/pipeline)", defaultName, f.NoInput)
152+
if err != nil {
153+
return nil, err
154+
}
155+
}
156+
157+
// Parse target - could be "name" or "org/name"
158+
// we check to see if `/` is present for org name, if not we use the existing org selected
159+
return parseTarget(targetStr, f.Config.OrganizationSlug()), nil
160+
}
161+
162+
// parseTarget parses a target string into org and name components.
163+
// If no org is specified, defaultOrg is used which is the current selected org.
164+
func parseTarget(target, defaultOrg string) *copyTarget {
165+
if strings.Contains(target, "/") {
166+
parts := strings.SplitN(target, "/", 2)
167+
return &copyTarget{
168+
Org: parts[0],
169+
Name: parts[1],
170+
}
171+
}
172+
return &copyTarget{
173+
Org: defaultOrg,
174+
Name: target,
175+
}
176+
}
177+
178+
func (c *CopyCmd) resolveCluster(f *factory.Factory, sourceClusterID string, isCrossOrg bool) (string, error) {
179+
if c.Cluster != "" {
180+
return c.Cluster, nil
181+
}
182+
183+
if !isCrossOrg {
184+
return sourceClusterID, nil
185+
}
186+
187+
return bkIO.PromptForInput("Target cluster ID (required for cross-org copy)", "", f.NoInput)
188+
}
189+
190+
func (c *CopyCmd) fetchSourcePipeline(ctx context.Context, f *factory.Factory, org, slug string) (*buildkite.Pipeline, error) {
191+
var pipeline buildkite.Pipeline
192+
var resp *buildkite.Response
193+
var err error
194+
195+
spinErr := bkIO.SpinWhile(f, fmt.Sprintf("Fetching pipeline %s/%s", org, slug), func() {
196+
pipeline, resp, err = f.RestAPIClient.Pipelines.Get(ctx, org, slug)
197+
})
198+
199+
if spinErr != nil {
200+
return nil, spinErr
201+
}
202+
203+
if err != nil {
204+
if resp != nil && resp.StatusCode == http.StatusNotFound {
205+
return nil, fmt.Errorf("pipeline %s/%s not found", org, slug)
206+
}
207+
return nil, fmt.Errorf("failed to fetch pipeline: %w", err)
208+
}
209+
210+
return &pipeline, nil
211+
}
212+
213+
// runDryRun allows a user to validate what their changes will do, based on the current `--dry-run` flag in Create
214+
func (c *CopyCmd) runDryRun(kongCtx *kong.Context, source *buildkite.Pipeline, target *copyTarget, isCrossOrg bool, clusterID string) error {
215+
format := output.Format(c.Output)
216+
217+
createReq := c.buildCreatePipeline(source, target.Name, isCrossOrg, clusterID)
218+
219+
// For dry-run, default to JSON if text format requested
220+
if format == output.FormatText {
221+
format = output.FormatJSON
222+
}
223+
224+
return output.Write(kongCtx.Stdout, createReq, format)
225+
}
226+
227+
func (c *CopyCmd) runCopy(kongCtx *kong.Context, f *factory.Factory, source *buildkite.Pipeline, target *copyTarget, isCrossOrg bool, clusterID string) error {
228+
ctx := context.Background()
229+
format := output.Format(c.Output)
230+
231+
// For cross-org copies, we need a client authenticated for the target org
232+
targetClient := f.RestAPIClient
233+
if isCrossOrg {
234+
var err error
235+
targetClient, err = c.getClientForOrg(f, target.Org)
236+
if err != nil {
237+
return err
238+
}
239+
}
240+
241+
createReq := c.buildCreatePipeline(source, target.Name, isCrossOrg, clusterID)
242+
243+
var newPipeline buildkite.Pipeline
244+
var resp *buildkite.Response
245+
var err error
246+
247+
spinErr := bkIO.SpinWhile(f, fmt.Sprintf("Creating pipeline %s/%s", target.Org, target.Name), func() {
248+
newPipeline, resp, err = targetClient.Pipelines.Create(ctx, target.Org, createReq)
249+
})
250+
251+
if spinErr != nil {
252+
return spinErr
253+
}
254+
255+
if err != nil {
256+
if resp != nil && resp.StatusCode == http.StatusUnprocessableEntity {
257+
// Check if a pipeline with this name already exists and error out if it does (not fussed with adding -1, -2 etc)
258+
if existing := c.findPipelineByName(ctx, targetClient, target); existing != nil {
259+
return fmt.Errorf("a pipeline with the name '%s' already exists: %s", target.Name, existing.WebURL)
260+
}
261+
}
262+
return fmt.Errorf("failed to create pipeline: %w", err)
263+
}
264+
265+
if format != output.FormatText {
266+
return output.Write(kongCtx.Stdout, newPipeline, format)
267+
}
268+
269+
fmt.Printf("%s\n", newPipeline.WebURL)
270+
return nil
271+
}
272+
273+
// getClientForOrg creates a Buildkite client authenticated for the specified organization
274+
func (c *CopyCmd) getClientForOrg(f *factory.Factory, org string) (*buildkite.Client, error) {
275+
token := f.Config.GetTokenForOrg(org)
276+
if token == "" {
277+
return nil, fmt.Errorf("no API token configured for organization %q. Run 'bk configure' to add it", org)
278+
}
279+
280+
return buildkite.NewOpts(
281+
buildkite.WithBaseURL(f.Config.RESTAPIEndpoint()),
282+
buildkite.WithTokenAuth(token),
283+
)
284+
}
285+
286+
func (c *CopyCmd) buildCreatePipeline(source *buildkite.Pipeline, targetName string, isCrossOrg bool, clusterID string) buildkite.CreatePipeline {
287+
create := buildkite.CreatePipeline{
288+
Name: targetName,
289+
Repository: source.Repository,
290+
Configuration: source.Configuration,
291+
292+
// Branch and build settings
293+
DefaultBranch: source.DefaultBranch,
294+
Description: source.Description,
295+
BranchConfiguration: source.BranchConfiguration,
296+
SkipQueuedBranchBuilds: source.SkipQueuedBranchBuilds,
297+
SkipQueuedBranchBuildsFilter: source.SkipQueuedBranchBuildsFilter,
298+
CancelRunningBranchBuilds: source.CancelRunningBranchBuilds,
299+
CancelRunningBranchBuildsFilter: source.CancelRunningBranchBuildsFilter,
300+
301+
// Visibility and tags
302+
Visibility: source.Visibility,
303+
Tags: source.Tags,
304+
305+
// Provider settings (trigger mode, PR builds, commit statuses, etc.)
306+
ProviderSettings: source.Provider.Settings,
307+
}
308+
309+
// Use explicit cluster if provided, otherwise copy from source for same-org copies
310+
if clusterID != "" {
311+
create.ClusterID = clusterID
312+
} else if !isCrossOrg {
313+
create.ClusterID = source.ClusterID
314+
}
315+
316+
// Convert environment variables (map[string]any -> map[string]string)
317+
if len(source.Env) > 0 {
318+
create.Env = make(map[string]string, len(source.Env))
319+
for k, v := range source.Env {
320+
create.Env[k] = fmt.Sprintf("%v", v)
321+
}
322+
}
323+
324+
return create
325+
}
326+
327+
func (c *CopyCmd) findPipelineByName(ctx context.Context, client *buildkite.Client, target *copyTarget) *buildkite.Pipeline {
328+
opts := buildkite.PipelineListOptions{
329+
ListOptions: buildkite.ListOptions{
330+
PerPage: 100,
331+
},
332+
}
333+
334+
pipelines, _, err := client.Pipelines.List(ctx, target.Org, &opts)
335+
if err != nil {
336+
return nil
337+
}
338+
339+
for _, p := range pipelines {
340+
if p.Name == target.Name {
341+
return &p
342+
}
343+
}
344+
345+
return nil
346+
}

internal/io/prompt.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package io
33
import (
44
"fmt"
55
"strconv"
6+
"strings"
67
)
78

89
const (
@@ -50,3 +51,37 @@ func PromptForOne(resource string, options []string, noInput bool) (string, erro
5051

5152
return options[num-1], nil
5253
}
54+
55+
// PromptForInput prompts the user to enter a string value.
56+
// If a default value is provided, it will be shown in brackets and used if the user presses enter.
57+
// If noInput is true, it will return the default value or an error if no default is provided.
58+
func PromptForInput(prompt, defaultVal string, noInput bool) (string, error) {
59+
if noInput {
60+
if defaultVal != "" {
61+
return defaultVal, nil
62+
}
63+
return "", fmt.Errorf("interactive input required but --no-input flag is set")
64+
}
65+
66+
if defaultVal != "" {
67+
fmt.Printf("%s [%s]: ", prompt, defaultVal)
68+
} else {
69+
fmt.Printf("%s: ", prompt)
70+
}
71+
72+
response, err := ReadLine()
73+
if err != nil {
74+
return "", err
75+
}
76+
77+
response = strings.TrimSpace(response)
78+
if response == "" && defaultVal != "" {
79+
return defaultVal, nil
80+
}
81+
82+
if response == "" {
83+
return "", fmt.Errorf("no value provided for %s", prompt)
84+
}
85+
86+
return response, nil
87+
}

0 commit comments

Comments
 (0)