diff --git a/cmd/pipeline/copy.go b/cmd/pipeline/copy.go index aa58ef9a..a34e18e4 100644 --- a/cmd/pipeline/copy.go +++ b/cmd/pipeline/copy.go @@ -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 } @@ -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 @@ -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 @@ -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" @@ -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) { @@ -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 @@ -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()) @@ -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 diff --git a/cmd/pipeline/create.go b/cmd/pipeline/create.go index ab48d8dd..fe9a40c4 100644 --- a/cmd/pipeline/create.go +++ b/cmd/pipeline/create.go @@ -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 } @@ -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 { @@ -58,6 +59,12 @@ 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" @@ -65,6 +72,9 @@ Examples: # 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" @@ -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) @@ -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", } @@ -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 { @@ -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" @@ -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) diff --git a/cmd/pipeline/teams.go b/cmd/pipeline/teams.go new file mode 100644 index 00000000..84bdd059 --- /dev/null +++ b/cmd/pipeline/teams.go @@ -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 + } + 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) +} diff --git a/cmd/pipeline/teams_test.go b/cmd/pipeline/teams_test.go new file mode 100644 index 00000000..5125e858 --- /dev/null +++ b/cmd/pipeline/teams_test.go @@ -0,0 +1,402 @@ +package pipeline + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/alecthomas/kong" + "github.com/buildkite/cli/v3/internal/cli" + "github.com/buildkite/cli/v3/internal/config" + "github.com/buildkite/cli/v3/pkg/cmd/factory" + buildkite "github.com/buildkite/go-buildkite/v5" +) + +const ( + readerTeam = "14e9501c-69fe-4cda-ae07-daea9ca3afd3" + ownerTeam = "3f195bcd-28f2-4e1a-bcff-09f3543e5abf" +) + +func TestTeamFlags(t *testing.T) { + for _, command := range []string{"create", "cp"} { + for _, tc := range []struct { + name string + args []string + wantErr bool + }{ + {"multiple assignments", []string{"--team", "readers=read_only", "--team", "platform-engineering=manage_build_and_read"}, false}, + {"empty slug", []string{"--team", " =read_only"}, true}, + {"invalid access", []string{"--team", "readers=admin"}, true}, + {"missing access", []string{"--team", "readers"}, true}, + } { + t.Run(command+"/"+tc.name, func(t *testing.T) { + var cli struct { + Create CreateCmd `cmd:""` + Cp CopyCmd `cmd:""` + } + parser, err := kong.New(&cli, kong.Vars{"output_default_format": ""}) + if err != nil { + t.Fatal(err) + } + _, err = parser.Parse(append([]string{command, "pipeline"}, tc.args...)) + if (err != nil) != tc.wantErr { + t.Fatalf("error = %v, wantErr %v", err, tc.wantErr) + } + if tc.wantErr { + return + } + teams := cli.Create.Teams + if command == "cp" { + teams = cli.Cp.Teams + } + if !maps.Equal(teams, map[string]string{"readers": "read_only", "platform-engineering": "manage_build_and_read"}) { + t.Fatalf("unexpected assignments: %v", teams) + } + }) + } + } +} + +func TestCopyExplicitTeamsAndCreation(t *testing.T) { + t.Chdir(t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("BUILDKITE_API_TOKEN", "test-token") + want := map[string]string{readerTeam: "read_only", ownerTeam: "manage_build_and_read"} + lookups, posts := 0, 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v2/organizations/source-org/pipelines/source": + fmt.Fprint(w, `{"name":"Source","slug":"source","repository":"git@example.com:repo.git","configuration":"steps: []","cluster_id":"cluster"}`) + case r.Method == http.MethodGet && r.URL.Path == "/v2/organizations/source-org/teams": + lookups++ + fmt.Fprintf(w, `[{"id":%q,"slug":"readers"},{"id":%q,"slug":"owners"}]`, readerTeam, ownerTeam) + case r.Method == http.MethodPost && r.URL.Path == "/v2/organizations/source-org/pipelines": + posts++ + var body struct { + Teams map[string]string `json:"teams"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if !maps.Equal(body.Teams, want) { + t.Errorf("creation must include explicitly selected teams: %v", body.Teams) + } + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"name":"copy"}`) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer s.Close() + t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) + for _, dryRun := range []bool{true, false} { + var c CopyCmd + var stdout bytes.Buffer + parser, err := kong.New(&c, kong.Writers(&stdout, &stdout), kong.Vars{"output_default_format": "json"}) + if err != nil { + t.Fatal(err) + } + args := []string{"source-org/source", "--org", "source-org", "--target", "source-org/copy", "--team", "readers=read_only", "--team", "owners=manage_build_and_read"} + if dryRun { + args = append(args, "--dry-run") + } + kongCtx, err := parser.Parse(args) + if err != nil { + t.Fatal(err) + } + if err := c.Run(kongCtx, cli.Globals{NoInput: true, Quiet: true}); err != nil { + t.Fatal(err) + } + if dryRun { + var preview struct { + Teams map[string]string `json:"teams"` + } + if err := json.Unmarshal(stdout.Bytes(), &preview); err != nil { + t.Fatal(err) + } + if !maps.Equal(preview.Teams, want) || posts != 0 { + t.Fatalf("dry-run teams=%v writes=%d", preview.Teams, posts) + } + } + } + if lookups != 2 || posts != 1 { + t.Fatalf("lookups=%d posts=%d", lookups, posts) + } +} + +func TestCopyTeamOverridesAndCrossOrg(t *testing.T) { + for _, targetOrg := range []string{"org", "destination"} { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v2/organizations/"+targetOrg+"/teams" { + t.Errorf("wrong lookup: %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `[{"id":%q,"name":"Platform Engineering","slug":"platform-engineering"}]`, ownerTeam) + })) + t.Cleanup(s.Close) + t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) + t.Setenv("BUILDKITE_API_TOKEN", "test-token") + client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) + if err != nil { + t.Fatal(err) + } + c := CopyCmd{Teams: map[string]string{"platform-engineering": "build_and_read"}} + // No GraphQL client: explicit teams must bypass source lookups. + teams, err := c.resolveTeams(context.Background(), &factory.Factory{RestAPIClient: client, Config: &config.Config{}}, "org", targetOrg) + if err != nil || !maps.Equal(teams, map[string]string{ownerTeam: "build_and_read"}) { + t.Fatalf("teams=%v err=%v", teams, err) + } + } + c := CopyCmd{} + teams, err := c.resolveTeams(context.Background(), &factory.Factory{}, "org", "destination") + if err != nil || len(teams) != 0 { + t.Fatalf("cross-org copy inherited teams: %v, %v", teams, err) + } +} + +func TestCreateTeamsRequestAndDryRun(t *testing.T) { + want := map[string]string{readerTeam: "build_and_read"} + posts := 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet && r.URL.Path == "/v2/organizations/org/teams" { + fmt.Fprintf(w, `[{"id":%q,"name":"Readers","slug":"readers"}]`, readerTeam) + return + } + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{}`) + return + } + posts++ + var body struct { + Teams map[string]string `json:"teams"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if !maps.Equal(body.Teams, want) { + t.Errorf("teams missing from creation: %v", body.Teams) + } + fmt.Fprint(w, `{"name":"new"}`) + })) + defer s.Close() + client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) + if err != nil { + t.Fatal(err) + } + f := &factory.Factory{RestAPIClient: client} + c := CreateCmd{Name: "new", Org: "org", Repository: "git@example.com:repo.git", Teams: map[string]string{"readers": "build_and_read"}} + preview, err := c.createPipelineDryRun(context.Background(), f) + if err != nil { + t.Fatal(err) + } + if !maps.Equal(preview.Teams, want) || posts != 0 { + t.Fatalf("dry-run teams=%v writes=%d", preview.Teams, posts) + } + if _, err := c.createPipeline(context.Background(), f); err != nil { + t.Fatal(err) + } + if posts != 1 { + t.Fatalf("creation writes=%d", posts) + } +} + +func TestResolveTeamSlugs(t *testing.T) { + for _, tc := range []struct { + name string + slugs map[string]string + secondName string + status int + wantErr string + }{ + {"paginated slugs", map[string]string{"readers": "read_only", "platform-engineering": "manage_build_and_read"}, "Platform Engineering", 200, ""}, + {"same names with distinct slugs", map[string]string{"readers": "read_only", "platform-engineering": "manage_build_and_read"}, "Readers", 200, ""}, + {"display name is not a slug", map[string]string{"Readers": "read_only"}, "Platform Engineering", 200, "not found"}, + {"UUID is not a slug", map[string]string{readerTeam: "read_only"}, "Platform Engineering", 200, "not found"}, + {"no team read permission", map[string]string{"readers": "read_only"}, "", 403, "could not resolve"}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Method != http.MethodGet || r.URL.Path != "/v2/organizations/destination/teams" { + t.Errorf("unexpected lookup: %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + if tc.status != 200 { + w.WriteHeader(tc.status) + fmt.Fprint(w, `{"message":"Forbidden"}`) + return + } + if calls == 1 { + w.Header().Set("Link", fmt.Sprintf(`; rel="next"`, r.Host)) + fmt.Fprintf(w, `[{"id":%q,"name":"Readers","slug":"readers"}]`, readerTeam) + } else { + if r.URL.Query().Get("page") != "2" { + t.Error("missing page 2") + } + fmt.Fprintf(w, `[{"id":%q,"name":%q,"slug":"platform-engineering"}]`, ownerTeam, tc.secondName) + } + })) + defer s.Close() + client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) + if err != nil { + t.Fatal(err) + } + teams, err := resolveTeamSlugs(context.Background(), client, "destination", tc.slugs) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) || teams != nil { + t.Fatalf("teams=%v err=%v", teams, err) + } + return + } + if err != nil || !maps.Equal(teams, map[string]string{readerTeam: "read_only", ownerTeam: "manage_build_and_read"}) || calls != 2 { + t.Fatalf("teams=%v calls=%d err=%v", teams, calls, err) + } + }) + } + if teams, err := resolveTeamSlugs(context.Background(), nil, "org", nil); err != nil || teams != nil { + t.Fatalf("empty assignment should not perform a lookup: %v, %v", teams, err) + } +} + +func TestCreateWithoutTeamsReturnsValidationError(t *testing.T) { + t.Chdir(t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("BUILDKITE_API_TOKEN", "test-token") + posts, lists := 0, 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path != "/v2/organizations/org/pipelines" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + switch r.Method { + case http.MethodPost: + posts++ + var body map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if _, present := body["teams"]; present { + t.Error("creation without --team must omit teams") + } + // Model a non-admin creation rejected by a Teams-enabled organization. + w.WriteHeader(http.StatusUnprocessableEntity) + fmt.Fprint(w, `{"message":"Team assignments are required"}`) + case http.MethodGet: + lists++ + // The duplicate-name check must not mask the original validation error. + fmt.Fprint(w, `[]`) + default: + t.Errorf("unexpected method: %s", r.Method) + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer s.Close() + t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) + t.Setenv("BUILDKITE_GRAPHQL_ENDPOINT", s.URL+"/graphql") + var c CreateCmd + var stdout bytes.Buffer + parser, err := kong.New(&c, kong.Writers(&stdout, &stdout), kong.Vars{"output_default_format": "json"}) + if err != nil { + t.Fatal(err) + } + kongCtx, err := parser.Parse([]string{"new", "--org", "org", "--repository", "git@example.com:repo.git", "--cluster-uuid", "cluster"}) + if err != nil { + t.Fatal(err) + } + err = c.Run(kongCtx, cli.Globals{NoInput: true, Quiet: true}) + var apiErr *buildkite.ErrorResponse + if !errors.As(err, &apiErr) || apiErr.Response.StatusCode != http.StatusUnprocessableEntity || apiErr.Message != "Team assignments are required" { + t.Fatalf("expected original missing-teams 422, got %v", err) + } + if posts != 1 || lists != 1 || stdout.Len() != 0 { + t.Fatalf("posts=%d lists=%d output=%q", posts, lists, stdout.String()) + } +} + +func TestCopyWithoutTeamFlagsOmitsTeams(t *testing.T) { + for _, dryRun := range []bool{true, false} { + t.Run(fmt.Sprintf("dry-run=%t", dryRun), func(t *testing.T) { + t.Chdir(t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("BUILDKITE_API_TOKEN", "test-token") + posts := 0 + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v2/organizations/org/pipelines/source": + fmt.Fprint(w, `{"name":"Source","slug":"source","repository":"git@example.com:repo.git","configuration":"steps: []","cluster_id":"cluster"}`) + case r.Method == http.MethodPost && r.URL.Path == "/v2/organizations/org/pipelines": + posts++ + var body map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if _, present := body["teams"]; present { + t.Error("copy without --team must omit teams even if the source has teams") + } + if string(body["name"]) != `"copy"` || string(body["cluster_id"]) != `"cluster"` { + t.Errorf("unexpected copy payload: %v", body) + } + // Model an authorized caller that may create without team assignments. + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"name":"copy","cluster_id":"cluster"}`) + default: + // Source assignments (possibly including invisible teams) must never be queried. + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer s.Close() + t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) + t.Setenv("BUILDKITE_GRAPHQL_ENDPOINT", s.URL+"/graphql") + var c CopyCmd + var stdout bytes.Buffer + parser, err := kong.New(&c, kong.Writers(&stdout, &stdout), kong.Vars{"output_default_format": "json"}) + if err != nil { + t.Fatal(err) + } + args := []string{"org/source", "--org", "org", "--target", "org/copy"} + if dryRun { + args = append(args, "--dry-run") + } + kongCtx, err := parser.Parse(args) + if err != nil { + t.Fatal(err) + } + if err := c.Run(kongCtx, cli.Globals{NoInput: true, Quiet: true}); err != nil { + t.Fatal(err) + } + var result map[string]json.RawMessage + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatal(err) + } + if _, present := result["teams"]; present { + t.Error("output should not invent team assignments") + } + if string(result["name"]) != `"copy"` || string(result["cluster_id"]) != `"cluster"` { + t.Fatalf("unexpected output: %s", stdout.String()) + } + wantPosts := 1 + if dryRun { + wantPosts = 0 + } + if posts != wantPosts { + t.Fatalf("posts=%d, want %d", posts, wantPosts) + } + }) + } +}