From d65abefb392a4d7c7e5d32049321a764aa366fb1 Mon Sep 17 00:00:00 2001 From: Lizette Rabuya <115472349+lizrabuya@users.noreply.github.com> Date: Tue, 22 Sep 2026 05:44:27 +0000 Subject: [PATCH 1/5] Preserve team assignments when copying pipelines Copy source team UUIDs and access levels for same-organization copies, and allow explicit --team UUID=ACCESS_LEVEL assignments on copy and create. Include teams in the initial REST creation request so non-admin users can create pipelines in Teams-enabled organizations. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-0cf40a11-b530-4f27-adf3-ff60561b5fea --- cmd/pipeline/copy.go | 49 ++++--- cmd/pipeline/create.go | 32 +++-- cmd/pipeline/teams.go | 60 +++++++++ cmd/pipeline/teams.graphql | 18 +++ cmd/pipeline/teams_test.go | 235 ++++++++++++++++++++++++++++++++++ internal/graphql/generated.go | 192 +++++++++++++++++++++++++++ 6 files changed, 558 insertions(+), 28 deletions(-) create mode 100644 cmd/pipeline/teams.go create mode 100644 cmd/pipeline/teams.graphql create mode 100644 cmd/pipeline/teams_test.go diff --git a/cmd/pipeline/copy.go b/cmd/pipeline/copy.go index aa58ef9a..afe36c37 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:"Replace source team assignments with UUID=ACCESS_LEVEL (repeatable); access: read_only, build_and_read, manage_build_and_read"` output.OutputFlags } @@ -49,7 +50,7 @@ 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 { @@ -63,9 +64,16 @@ This command copies all configuration from a source pipeline including: - Provider settings (trigger mode, PR builds, commit statuses, etc.) - Environment variables - Tags and visibility +- Team assignments and their access levels (within the same organization) + +Use --team UUID=ACCESS_LEVEL to replace the source team assignments. Repeat the flag +to assign multiple teams. Access levels are read_only, build_and_read, and +manage_build_and_read. Automatic team copying requires a token with GraphQL access; +explicit --team assignments avoid that lookup. -When copying to a different organization, cluster configuration is skipped -(clusters are organization-specific). +When copying to a different organization, cluster and team assignments are not +copied because they are organization-specific. Use --team with destination team +UUIDs; non-admin users in organizations with Teams enabled must assign a team. Examples: # Copy the current pipeline to a new pipeline @@ -74,6 +82,9 @@ Examples: # Copy a specific pipeline $ bk pipeline cp my-existing-pipeline --target "my-new-pipeline" + # Copy with explicit team access instead of the source assignments + $ bk pipeline cp my-pipeline --target "my-copy" --team "14e9501c-69fe-4cda-ae07-daea9ca3afd3=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 +149,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, sourcePipeline.Name, isCrossOrg) + 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 +257,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 +268,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 +282,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..b3276c7b 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 UUID=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,11 @@ 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 UUID=ACCESS_LEVEL for each team assignment. Access levels are read_only, +build_and_read, and manage_build_and_read. Team UUIDs are available from bk team list +--output json or the team's Settings page. 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 +71,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 "14e9501c-69fe-4cda-ae07-daea9ca3afd3=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" @@ -160,6 +169,7 @@ func (c *CreateCmd) createPipeline(ctx context.Context, f *factory.Factory) (*bu Repository: repoURL, Description: c.Description, ClusterID: clusterID, + Teams: c.Teams, Configuration: "steps:\n - label: \":pipeline:\"\n command: buildkite-agent pipeline upload", } @@ -236,6 +246,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 { @@ -267,6 +278,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 = c.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..c0340f8a --- /dev/null +++ b/cmd/pipeline/teams.go @@ -0,0 +1,60 @@ +package pipeline + +import ( + "context" + "fmt" + "strings" + + "github.com/buildkite/cli/v3/internal/graphql" + "github.com/buildkite/cli/v3/pkg/cmd/factory" + "github.com/google/uuid" +) + +func validateTeams(teams map[string]string) error { + for id, access := range teams { + if _, err := uuid.Parse(id); err != nil { + return fmt.Errorf("invalid --team UUID %q: %w", id, err) + } + 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 (c *CopyCmd) resolveTeams(ctx context.Context, f *factory.Factory, org, slug string, isCrossOrg bool) (map[string]string, error) { + if len(c.Teams) > 0 || isCrossOrg { + return c.Teams, nil + } + + teams := make(map[string]string) + var cursor *string + for { + result, err := graphql.PipelineTeams(ctx, f.GraphQLClient, org+"/"+slug, cursor) + if err != nil { + return nil, fmt.Errorf("could not read source team assignments (use --team UUID=ACCESS_LEVEL to set them explicitly): %w", err) + } + if result.Pipeline == nil { + return nil, fmt.Errorf("could not read team assignments for pipeline %s/%s; use --team UUID=ACCESS_LEVEL to set them explicitly", org, slug) + } + connection := result.Pipeline.Teams + if connection == nil { + return teams, nil + } + for _, edge := range connection.Edges { + if edge == nil || edge.Node == nil || edge.Node.Team == nil { + return nil, fmt.Errorf("source team assignment is not accessible; use --team UUID=ACCESS_LEVEL to set teams explicitly") + } + teams[edge.Node.Team.Uuid] = strings.ToLower(string(edge.Node.AccessLevel)) + } + if connection.PageInfo == nil || !connection.PageInfo.HasNextPage { + return teams, nil + } + if connection.PageInfo.EndCursor == nil || (cursor != nil && *cursor == *connection.PageInfo.EndCursor) { + return nil, fmt.Errorf("could not paginate source team assignments; use --team UUID=ACCESS_LEVEL to set teams explicitly") + } + cursor = connection.PageInfo.EndCursor + } +} diff --git a/cmd/pipeline/teams.graphql b/cmd/pipeline/teams.graphql new file mode 100644 index 00000000..a7db28f8 --- /dev/null +++ b/cmd/pipeline/teams.graphql @@ -0,0 +1,18 @@ +query PipelineTeams($slug: ID!, $after: String) { + pipeline(slug: $slug) { + teams(first: 100, after: $after) { + edges { + node { + accessLevel + team { + uuid + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +} diff --git a/cmd/pipeline/teams_test.go b/cmd/pipeline/teams_test.go new file mode 100644 index 00000000..0c4f1ba4 --- /dev/null +++ b/cmd/pipeline/teams_test.go @@ -0,0 +1,235 @@ +package pipeline + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Khan/genqlient/graphql" + "github.com/alecthomas/kong" + "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/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", readerTeam + "=read_only", "--team", ownerTeam + "=manage_build_and_read"}, false}, + {"invalid UUID", []string{"--team", "my-team=read_only"}, true}, + {"invalid access", []string{"--team", readerTeam + "=admin"}, true}, + {"missing access", []string{"--team", readerTeam}, 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{readerTeam: "read_only", ownerTeam: "manage_build_and_read"}) { + t.Fatalf("unexpected assignments: %v", teams) + } + }) + } + } +} + +func TestCopyTeamsPaginationAndCreation(t *testing.T) { + want := map[string]string{readerTeam: "read_only", ownerTeam: "manage_build_and_read"} + pages, posts := 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 == "/graphql" { + var req struct { + Variables struct { + Slug string + After *string + } + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Error(err) + } + if req.Variables.Slug != "source-org/source" { + t.Errorf("wrong source: %s", req.Variables.Slug) + } + pages++ + if pages == 1 { + if req.Variables.After != nil { + t.Error("first page should not have a cursor") + } + fmt.Fprintf(w, `{"data":{"pipeline":{"teams":{"edges":[{"node":{"accessLevel":"READ_ONLY","team":{"uuid":%q}}}],"pageInfo":{"hasNextPage":true,"endCursor":"next"}}}}}`, readerTeam) + } else { + if req.Variables.After == nil || *req.Variables.After != "next" { + t.Error("missing next-page cursor") + } + fmt.Fprintf(w, `{"data":{"pipeline":{"teams":{"edges":[{"node":{"accessLevel":"MANAGE_BUILD_AND_READ","team":{"uuid":%q}}}],"pageInfo":{"hasNextPage":false}}}}}`, ownerTeam) + } + return + } + if r.Method != http.MethodPost || r.URL.Path != "/v2/organizations/source-org/pipelines" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + 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 all teams with original access: %v", body.Teams) + w.WriteHeader(http.StatusUnprocessableEntity) + } + fmt.Fprint(w, `{"name":"copy"}`) + })) + defer s.Close() + client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) + if err != nil { + t.Fatal(err) + } + f := &factory.Factory{Config: &config.Config{}, RestAPIClient: client, GraphQLClient: graphql.NewClient(s.URL+"/graphql", s.Client())} + c := CopyCmd{OutputFlags: output.OutputFlags{Output: "json"}} + request := c.buildCreatePipeline(&buildkite.Pipeline{Repository: "git@example.com:repo.git", Configuration: "steps: []"}, "copy", false, "cluster") + request.Teams, err = c.resolveTeams(context.Background(), f, "source-org", "source", false) + if err != nil { + t.Fatal(err) + } + var preview bytes.Buffer + parser, err := kong.New(&c, kong.Writers(&preview, &preview), kong.Vars{"output_default_format": "json"}) + if err != nil { + t.Fatal(err) + } + kongCtx, err := parser.Parse(nil) + if err != nil { + t.Fatal(err) + } + if err := c.runDryRun(kongCtx, f, request); err != nil { + t.Fatal(err) + } + var dry struct { + Teams map[string]string `json:"teams"` + } + if err := json.Unmarshal(preview.Bytes(), &dry); err != nil { + t.Fatal(err) + } + if !maps.Equal(dry.Teams, want) || posts != 0 { + t.Fatalf("dry-run teams %v, writes %d", dry.Teams, posts) + } + if err := c.runCopy(kongCtx, f, ©Target{Org: "source-org", Name: "copy"}, false, request); err != nil { + t.Fatal(err) + } + if pages != 2 || posts != 1 { + t.Fatalf("pages=%d posts=%d", pages, posts) + } +} + +func TestCopyTeamOverridesAndCrossOrg(t *testing.T) { + for _, crossOrg := range []bool{false, true} { + c := CopyCmd{Teams: map[string]string{readerTeam: "build_and_read"}} + // No GraphQL client: explicit teams must bypass source lookups. + teams, err := c.resolveTeams(context.Background(), &factory.Factory{}, "org", "pipeline", crossOrg) + if err != nil || !maps.Equal(teams, c.Teams) { + t.Fatalf("teams=%v err=%v", teams, err) + } + } + c := CopyCmd{} + teams, err := c.resolveTeams(context.Background(), &factory.Factory{}, "org", "pipeline", true) + if err != nil || len(teams) != 0 { + t.Fatalf("cross-org copy inherited teams: %v, %v", teams, err) + } +} + +func TestCopyTeamLookupFailures(t *testing.T) { + for _, response := range []string{ + `{"errors":[{"message":"Forbidden"}]}`, + `{"data":{"pipeline":null}}`, + `{"data":{"pipeline":{"teams":{"edges":[{"node":{"team":null}}]}}}}`, + } { + t.Run(response, func(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, response) + })) + defer s.Close() + c := CopyCmd{} + teams, err := c.resolveTeams(context.Background(), &factory.Factory{GraphQLClient: graphql.NewClient(s.URL, s.Client())}, "org", "pipeline", false) + if err == nil || !strings.Contains(err.Error(), "--team") || teams != nil { + t.Fatalf("teams=%v err=%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 { + 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: want} + 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) + } +} diff --git a/internal/graphql/generated.go b/internal/graphql/generated.go index 3213c030..feef2e13 100644 --- a/internal/graphql/generated.go +++ b/internal/graphql/generated.go @@ -3727,6 +3727,24 @@ func (v *ListJobsByStateResponse) GetOrganization() *ListJobsByStateOrganization return v.Organization } +// The access levels that can be assigned to a pipeline +type PipelineAccessLevels string + +const ( + // Allows builds and read only + PipelineAccessLevelsBuildAndRead PipelineAccessLevels = "BUILD_AND_READ" + // Allows edits, builds and reads + PipelineAccessLevelsManageBuildAndRead PipelineAccessLevels = "MANAGE_BUILD_AND_READ" + // Read only - no builds or edits + PipelineAccessLevelsReadOnly PipelineAccessLevels = "READ_ONLY" +) + +var AllPipelineAccessLevels = []PipelineAccessLevels{ + PipelineAccessLevelsBuildAndRead, + PipelineAccessLevelsManageBuildAndRead, + PipelineAccessLevelsReadOnly, +} + // PipelineCreateWebhookPipelineCreateWebhookPipelineCreateWebhookPayload includes the requested fields of the GraphQL type PipelineCreateWebhookPayload. // The GraphQL type's documentation follows. // @@ -3765,6 +3783,119 @@ func (v *PipelineCreateWebhookResponse) GetPipelineCreateWebhook() *PipelineCrea return v.PipelineCreateWebhook } +// PipelineTeamsPipeline includes the requested fields of the GraphQL type Pipeline. +// The GraphQL type's documentation follows. +// +// A pipeline +type PipelineTeamsPipeline struct { + // Teams associated with this pipeline + Teams *PipelineTeamsPipelineTeamsTeamPipelineConnection `json:"teams"` +} + +// GetTeams returns PipelineTeamsPipeline.Teams, and is useful for accessing the field via an interface. +func (v *PipelineTeamsPipeline) GetTeams() *PipelineTeamsPipelineTeamsTeamPipelineConnection { + return v.Teams +} + +// PipelineTeamsPipelineTeamsTeamPipelineConnection includes the requested fields of the GraphQL type TeamPipelineConnection. +// The GraphQL type's documentation follows. +// +// The connection type for TeamPipeline. +type PipelineTeamsPipelineTeamsTeamPipelineConnection struct { + // A list of edges. + Edges []*PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdge `json:"edges"` + PageInfo *PipelineTeamsPipelineTeamsTeamPipelineConnectionPageInfo `json:"pageInfo"` +} + +// GetEdges returns PipelineTeamsPipelineTeamsTeamPipelineConnection.Edges, and is useful for accessing the field via an interface. +func (v *PipelineTeamsPipelineTeamsTeamPipelineConnection) GetEdges() []*PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdge { + return v.Edges +} + +// GetPageInfo returns PipelineTeamsPipelineTeamsTeamPipelineConnection.PageInfo, and is useful for accessing the field via an interface. +func (v *PipelineTeamsPipelineTeamsTeamPipelineConnection) GetPageInfo() *PipelineTeamsPipelineTeamsTeamPipelineConnectionPageInfo { + return v.PageInfo +} + +// PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdge includes the requested fields of the GraphQL type TeamPipelineEdge. +// The GraphQL type's documentation follows. +// +// An edge in a connection. +type PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdge struct { + // The item at the end of the edge. + Node *PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipeline `json:"node"` +} + +// GetNode returns PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdge.Node, and is useful for accessing the field via an interface. +func (v *PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdge) GetNode() *PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipeline { + return v.Node +} + +// PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipeline includes the requested fields of the GraphQL type TeamPipeline. +// The GraphQL type's documentation follows. +// +// An pipeline that's been assigned to a team +type PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipeline struct { + // The access level users have to this pipeline + AccessLevel PipelineAccessLevels `json:"accessLevel"` + // The team associated with this team member + Team *PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipelineTeam `json:"team"` +} + +// GetAccessLevel returns PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipeline.AccessLevel, and is useful for accessing the field via an interface. +func (v *PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipeline) GetAccessLevel() PipelineAccessLevels { + return v.AccessLevel +} + +// GetTeam returns PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipeline.Team, and is useful for accessing the field via an interface. +func (v *PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipeline) GetTeam() *PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipelineTeam { + return v.Team +} + +// PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipelineTeam includes the requested fields of the GraphQL type Team. +// The GraphQL type's documentation follows. +// +// An organization team +type PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipelineTeam struct { + // The public UUID for this team + Uuid string `json:"uuid"` +} + +// GetUuid returns PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipelineTeam.Uuid, and is useful for accessing the field via an interface. +func (v *PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipelineTeam) GetUuid() string { + return v.Uuid +} + +// PipelineTeamsPipelineTeamsTeamPipelineConnectionPageInfo includes the requested fields of the GraphQL type PageInfo. +// The GraphQL type's documentation follows. +// +// Information about pagination in a connection. +type PipelineTeamsPipelineTeamsTeamPipelineConnectionPageInfo struct { + // When paginating forwards, are there more items? + HasNextPage bool `json:"hasNextPage"` + // When paginating forwards, the cursor to continue. + EndCursor *string `json:"endCursor"` +} + +// GetHasNextPage returns PipelineTeamsPipelineTeamsTeamPipelineConnectionPageInfo.HasNextPage, and is useful for accessing the field via an interface. +func (v *PipelineTeamsPipelineTeamsTeamPipelineConnectionPageInfo) GetHasNextPage() bool { + return v.HasNextPage +} + +// GetEndCursor returns PipelineTeamsPipelineTeamsTeamPipelineConnectionPageInfo.EndCursor, and is useful for accessing the field via an interface. +func (v *PipelineTeamsPipelineTeamsTeamPipelineConnectionPageInfo) GetEndCursor() *string { + return v.EndCursor +} + +// PipelineTeamsResponse is returned by PipelineTeams on success. +type PipelineTeamsResponse struct { + // Find a pipeline + Pipeline *PipelineTeamsPipeline `json:"pipeline"` +} + +// GetPipeline returns PipelineTeamsResponse.Pipeline, and is useful for accessing the field via an interface. +func (v *PipelineTeamsResponse) GetPipeline() *PipelineTeamsPipeline { return v.Pipeline } + // UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayload includes the requested fields of the GraphQL type JobTypeBlockUnblockPayload. // The GraphQL type's documentation follows. // @@ -4001,6 +4132,18 @@ type __PipelineCreateWebhookInput struct { // GetId returns __PipelineCreateWebhookInput.Id, and is useful for accessing the field via an interface. func (v *__PipelineCreateWebhookInput) GetId() string { return v.Id } +// __PipelineTeamsInput is used internally by genqlient +type __PipelineTeamsInput struct { + Slug string `json:"slug"` + After *string `json:"after"` +} + +// GetSlug returns __PipelineTeamsInput.Slug, and is useful for accessing the field via an interface. +func (v *__PipelineTeamsInput) GetSlug() string { return v.Slug } + +// GetAfter returns __PipelineTeamsInput.After, and is useful for accessing the field via an interface. +func (v *__PipelineTeamsInput) GetAfter() *string { return v.After } + // __UnblockJobInput is used internally by genqlient type __UnblockJobInput struct { Id string `json:"id"` @@ -4623,6 +4766,55 @@ func PipelineCreateWebhook( return data_, err_ } +// The query executed by PipelineTeams. +const PipelineTeams_Operation = ` +query PipelineTeams ($slug: ID!, $after: String) { + pipeline(slug: $slug) { + teams(first: 100, after: $after) { + edges { + node { + accessLevel + team { + uuid + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +} +` + +func PipelineTeams( + ctx_ context.Context, + client_ graphql.Client, + slug string, + after *string, +) (data_ *PipelineTeamsResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "PipelineTeams", + Query: PipelineTeams_Operation, + Variables: &__PipelineTeamsInput{ + Slug: slug, + After: after, + }, + } + + data_ = &PipelineTeamsResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The mutation executed by UnblockJob. const UnblockJob_Operation = ` mutation UnblockJob ($id: ID!, $fields: JSON) { From a47b17b8db508d39557244da301ed6ac41a72a11 Mon Sep 17 00:00:00 2001 From: Lizette Rabuya <115472349+lizrabuya@users.noreply.github.com> Date: Tue, 22 Sep 2026 05:54:39 +0000 Subject: [PATCH 2/5] Resolve pipeline team assignments by team name Amp-Thread-ID: https://ampcode.com/threads/T-0cf40a11-b530-4f27-adf3-ff60561b5fea Co-authored-by: Amp --- cmd/pipeline/copy.go | 14 ++--- cmd/pipeline/create.go | 24 ++++++--- cmd/pipeline/teams.go | 69 +++++++++++++++++++++---- cmd/pipeline/teams_test.go | 102 ++++++++++++++++++++++++++++++++----- 4 files changed, 171 insertions(+), 38 deletions(-) diff --git a/cmd/pipeline/copy.go b/cmd/pipeline/copy.go index afe36c37..5930b17f 100644 --- a/cmd/pipeline/copy.go +++ b/cmd/pipeline/copy.go @@ -25,7 +25,7 @@ type CopyCmd struct { 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:"Replace source team assignments with UUID=ACCESS_LEVEL (repeatable); access: read_only, build_and_read, manage_build_and_read"` + Teams map[string]string `name:"team" help:"Replace source team assignments with NAME=ACCESS_LEVEL (repeatable); access: read_only, build_and_read, manage_build_and_read"` output.OutputFlags } @@ -66,14 +66,16 @@ This command copies all configuration from a source pipeline including: - Tags and visibility - Team assignments and their access levels (within the same organization) -Use --team UUID=ACCESS_LEVEL to replace the source team assignments. Repeat the flag -to assign multiple teams. Access levels are read_only, build_and_read, and +Use --team "Team Name=ACCESS_LEVEL" to replace the source team assignments. Repeat +the flag to assign multiple teams. Names 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. Automatic team copying requires a token with GraphQL access; explicit --team assignments avoid that lookup. When copying to a different organization, cluster and team assignments are not copied because they are organization-specific. Use --team with destination team -UUIDs; non-admin users in organizations with Teams enabled must assign a team. +names; non-admin users in organizations with Teams enabled must assign a team. Examples: # Copy the current pipeline to a new pipeline @@ -83,7 +85,7 @@ Examples: $ bk pipeline cp my-existing-pipeline --target "my-new-pipeline" # Copy with explicit team access instead of the source assignments - $ bk pipeline cp my-pipeline --target "my-copy" --team "14e9501c-69fe-4cda-ae07-daea9ca3afd3=build_and_read" + $ 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" @@ -150,7 +152,7 @@ func (c *CopyCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { } createReq := c.buildCreatePipeline(source, target.Name, isCrossOrg, clusterID) - createReq.Teams, err = c.resolveTeams(ctx, f, sourcePipeline.Org, sourcePipeline.Name, isCrossOrg) + createReq.Teams, err = c.resolveTeams(ctx, f, sourcePipeline.Org, sourcePipeline.Name, target.Org) if err != nil { return err } diff --git a/cmd/pipeline/create.go b/cmd/pipeline/create.go index b3276c7b..12dce3af 100644 --- a/cmd/pipeline/create.go +++ b/cmd/pipeline/create.go @@ -29,7 +29,7 @@ type CreateCmd struct { 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 UUID=ACCESS_LEVEL (repeatable); access: read_only, build_and_read, manage_build_and_read"` + Teams map[string]string `name:"team" help:"Team assignment as NAME=ACCESS_LEVEL (repeatable); access: read_only, build_and_read, manage_build_and_read"` output.OutputFlags } @@ -59,10 +59,10 @@ 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 UUID=ACCESS_LEVEL for each team assignment. Access levels are read_only, -build_and_read, and manage_build_and_read. Team UUIDs are available from bk team list ---output json or the team's Settings page. Non-admin users in organizations with -Teams enabled must assign a team when creating a pipeline. +Use --team "Team Name=ACCESS_LEVEL" for each team assignment. Names 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. Non-admin +users in organizations with Teams enabled must assign a team when creating a pipeline. Examples: # Create a new pipeline @@ -72,7 +72,7 @@ Examples: $ 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 "14e9501c-69fe-4cda-ae07-daea9ca3afd3=build_and_read" + $ 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" @@ -157,6 +157,10 @@ func (c *CreateCmd) createPipeline(ctx context.Context, f *factory.Factory) (*bu if err != nil { return nil, err } + teams, err := resolveTeamNames(ctx, f.RestAPIClient, c.orgSlug(f.Config), c.Teams) + if err != nil { + return nil, err + } repoURL := getRepositoryURL(f, c.Repository) @@ -169,7 +173,7 @@ func (c *CreateCmd) createPipeline(ctx context.Context, f *factory.Factory) (*bu Repository: repoURL, Description: c.Description, ClusterID: clusterID, - Teams: c.Teams, + Teams: teams, Configuration: "steps:\n - label: \":pipeline:\"\n command: buildkite-agent pipeline upload", } @@ -270,6 +274,10 @@ func (c *CreateCmd) createPipelineDryRun(ctx context.Context, f *factory.Factory } orgSlug := c.orgSlug(f.Config) + teams, err := resolveTeamNames(ctx, f.RestAPIClient, orgSlug, c.Teams) + if err != nil { + return nil, err + } pipeline := initialisePipelineDryRun() pipeline.ID = "00000000-0000-0000-0000-000000000000" @@ -278,7 +286,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 = c.Teams + 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 index c0340f8a..2aeb8c26 100644 --- a/cmd/pipeline/teams.go +++ b/cmd/pipeline/teams.go @@ -7,13 +7,13 @@ import ( "github.com/buildkite/cli/v3/internal/graphql" "github.com/buildkite/cli/v3/pkg/cmd/factory" - "github.com/google/uuid" + buildkite "github.com/buildkite/go-buildkite/v5" ) func validateTeams(teams map[string]string) error { - for id, access := range teams { - if _, err := uuid.Parse(id); err != nil { - return fmt.Errorf("invalid --team UUID %q: %w", id, err) + for name, access := range teams { + if strings.TrimSpace(name) == "" { + return fmt.Errorf("--team requires a non-empty team name") } switch access { case "read_only", "build_and_read", "manage_build_and_read": @@ -24,9 +24,56 @@ func validateTeams(teams map[string]string) error { return nil } -func (c *CopyCmd) resolveTeams(ctx context.Context, f *factory.Factory, org, slug string, isCrossOrg bool) (map[string]string, error) { - if len(c.Teams) > 0 || isCrossOrg { - return c.Teams, nil +func resolveTeamNames(ctx context.Context, client *buildkite.Client, org string, names map[string]string) (map[string]string, error) { + if len(names) == 0 { + return nil, nil + } + assignments := make(map[string]string, len(names)) + matched := make(map[string]string, len(names)) + 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 names in organization %q: %w", org, err) + } + for _, team := range teams { + access, requested := names[team.Name] + if !requested { + continue + } + if id, exists := matched[team.Name]; exists && id != team.ID { + return nil, fmt.Errorf("team name %q is ambiguous in organization %q", team.Name, org) + } + matched[team.Name] = team.ID + assignments[team.ID] = access + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + for name := range names { + if _, exists := matched[name]; !exists { + return nil, fmt.Errorf("team %q not found in organization %q; use the exact team name from bk team list", name, org) + } + } + return assignments, nil +} + +func (c *CopyCmd) resolveTeams(ctx context.Context, f *factory.Factory, org, slug, targetOrg string) (map[string]string, error) { + if len(c.Teams) > 0 { + client := f.RestAPIClient + if targetOrg != org { + var err error + client, err = c.getClientForOrg(f, targetOrg) + if err != nil { + return nil, err + } + } + return resolveTeamNames(ctx, client, targetOrg, c.Teams) + } + if targetOrg != org { + return nil, nil } teams := make(map[string]string) @@ -34,10 +81,10 @@ func (c *CopyCmd) resolveTeams(ctx context.Context, f *factory.Factory, org, slu for { result, err := graphql.PipelineTeams(ctx, f.GraphQLClient, org+"/"+slug, cursor) if err != nil { - return nil, fmt.Errorf("could not read source team assignments (use --team UUID=ACCESS_LEVEL to set them explicitly): %w", err) + return nil, fmt.Errorf("could not read source team assignments (use --team NAME=ACCESS_LEVEL to set them explicitly): %w", err) } if result.Pipeline == nil { - return nil, fmt.Errorf("could not read team assignments for pipeline %s/%s; use --team UUID=ACCESS_LEVEL to set them explicitly", org, slug) + return nil, fmt.Errorf("could not read team assignments for pipeline %s/%s; use --team NAME=ACCESS_LEVEL to set them explicitly", org, slug) } connection := result.Pipeline.Teams if connection == nil { @@ -45,7 +92,7 @@ func (c *CopyCmd) resolveTeams(ctx context.Context, f *factory.Factory, org, slu } for _, edge := range connection.Edges { if edge == nil || edge.Node == nil || edge.Node.Team == nil { - return nil, fmt.Errorf("source team assignment is not accessible; use --team UUID=ACCESS_LEVEL to set teams explicitly") + return nil, fmt.Errorf("source team assignment is not accessible; use --team NAME=ACCESS_LEVEL to set teams explicitly") } teams[edge.Node.Team.Uuid] = strings.ToLower(string(edge.Node.AccessLevel)) } @@ -53,7 +100,7 @@ func (c *CopyCmd) resolveTeams(ctx context.Context, f *factory.Factory, org, slu return teams, nil } if connection.PageInfo.EndCursor == nil || (cursor != nil && *cursor == *connection.PageInfo.EndCursor) { - return nil, fmt.Errorf("could not paginate source team assignments; use --team UUID=ACCESS_LEVEL to set teams explicitly") + return nil, fmt.Errorf("could not paginate source team assignments; use --team NAME=ACCESS_LEVEL to set teams explicitly") } cursor = connection.PageInfo.EndCursor } diff --git a/cmd/pipeline/teams_test.go b/cmd/pipeline/teams_test.go index 0c4f1ba4..36addbb3 100644 --- a/cmd/pipeline/teams_test.go +++ b/cmd/pipeline/teams_test.go @@ -31,10 +31,10 @@ func TestTeamFlags(t *testing.T) { args []string wantErr bool }{ - {"multiple assignments", []string{"--team", readerTeam + "=read_only", "--team", ownerTeam + "=manage_build_and_read"}, false}, - {"invalid UUID", []string{"--team", "my-team=read_only"}, true}, - {"invalid access", []string{"--team", readerTeam + "=admin"}, true}, - {"missing access", []string{"--team", readerTeam}, true}, + {"multiple assignments", []string{"--team", "Readers=read_only", "--team", "Platform Engineering=manage_build_and_read"}, false}, + {"empty name", []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 { @@ -56,7 +56,7 @@ func TestTeamFlags(t *testing.T) { if command == "cp" { teams = cli.Cp.Teams } - if !maps.Equal(teams, map[string]string{readerTeam: "read_only", ownerTeam: "manage_build_and_read"}) { + if !maps.Equal(teams, map[string]string{"Readers": "read_only", "Platform Engineering": "manage_build_and_read"}) { t.Fatalf("unexpected assignments: %v", teams) } }) @@ -120,7 +120,7 @@ func TestCopyTeamsPaginationAndCreation(t *testing.T) { f := &factory.Factory{Config: &config.Config{}, RestAPIClient: client, GraphQLClient: graphql.NewClient(s.URL+"/graphql", s.Client())} c := CopyCmd{OutputFlags: output.OutputFlags{Output: "json"}} request := c.buildCreatePipeline(&buildkite.Pipeline{Repository: "git@example.com:repo.git", Configuration: "steps: []"}, "copy", false, "cluster") - request.Teams, err = c.resolveTeams(context.Background(), f, "source-org", "source", false) + request.Teams, err = c.resolveTeams(context.Background(), f, "source-org", "source", "source-org") if err != nil { t.Fatal(err) } @@ -154,16 +154,30 @@ func TestCopyTeamsPaginationAndCreation(t *testing.T) { } func TestCopyTeamOverridesAndCrossOrg(t *testing.T) { - for _, crossOrg := range []bool{false, true} { - c := CopyCmd{Teams: map[string]string{readerTeam: "build_and_read"}} + 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"}]`, 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{}, "org", "pipeline", crossOrg) - if err != nil || !maps.Equal(teams, c.Teams) { + teams, err := c.resolveTeams(context.Background(), &factory.Factory{RestAPIClient: client, Config: &config.Config{}}, "org", "pipeline", 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", "pipeline", true) + teams, err := c.resolveTeams(context.Background(), &factory.Factory{}, "org", "pipeline", "destination") if err != nil || len(teams) != 0 { t.Fatalf("cross-org copy inherited teams: %v, %v", teams, err) } @@ -182,7 +196,7 @@ func TestCopyTeamLookupFailures(t *testing.T) { })) defer s.Close() c := CopyCmd{} - teams, err := c.resolveTeams(context.Background(), &factory.Factory{GraphQLClient: graphql.NewClient(s.URL, s.Client())}, "org", "pipeline", false) + teams, err := c.resolveTeams(context.Background(), &factory.Factory{GraphQLClient: graphql.NewClient(s.URL, s.Client())}, "org", "pipeline", "org") if err == nil || !strings.Contains(err.Error(), "--team") || teams != nil { t.Fatalf("teams=%v err=%v", teams, err) } @@ -195,6 +209,10 @@ func TestCreateTeamsRequestAndDryRun(t *testing.T) { 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"}]`, readerTeam) + return + } if r.Method == http.MethodGet { w.WriteHeader(http.StatusNotFound) fmt.Fprint(w, `{}`) @@ -218,7 +236,7 @@ func TestCreateTeamsRequestAndDryRun(t *testing.T) { t.Fatal(err) } f := &factory.Factory{RestAPIClient: client} - c := CreateCmd{Name: "new", Org: "org", Repository: "git@example.com:repo.git", Teams: want} + 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) @@ -233,3 +251,61 @@ func TestCreateTeamsRequestAndDryRun(t *testing.T) { t.Fatalf("creation writes=%d", posts) } } + +func TestResolveTeamNames(t *testing.T) { + for _, tc := range []struct { + name string + names map[string]string + secondName string + status int + wantErr string + }{ + {"paginated names", map[string]string{"Readers": "read_only", "Platform Engineering": "manage_build_and_read"}, "Platform Engineering", 200, ""}, + {"ambiguous name on next page", map[string]string{"Readers": "read_only"}, "Readers", 200, "ambiguous"}, + {"case mismatch", map[string]string{"readers": "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"}]`, readerTeam) + } else { + if r.URL.Query().Get("page") != "2" { + t.Error("missing page 2") + } + fmt.Fprintf(w, `[{"id":%q,"name":%q}]`, ownerTeam, tc.secondName) + } + })) + defer s.Close() + client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) + if err != nil { + t.Fatal(err) + } + teams, err := resolveTeamNames(context.Background(), client, "destination", tc.names) + 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 := resolveTeamNames(context.Background(), nil, "org", nil); err != nil || teams != nil { + t.Fatalf("empty assignment should not perform a lookup: %v, %v", teams, err) + } +} From 1348a27b8497b2c5e969c5cd35f5e02d8e88bd5c Mon Sep 17 00:00:00 2001 From: Lizette Rabuya <115472349+lizrabuya@users.noreply.github.com> Date: Tue, 22 Sep 2026 05:59:49 +0000 Subject: [PATCH 3/5] Resolve pipeline team assignments by slug Amp-Thread-ID: https://ampcode.com/threads/T-0cf40a11-b530-4f27-adf3-ff60561b5fea Co-authored-by: Amp --- cmd/pipeline/copy.go | 10 +++++----- cmd/pipeline/create.go | 10 +++++----- cmd/pipeline/teams.go | 39 ++++++++++++++++++-------------------- cmd/pipeline/teams_test.go | 39 +++++++++++++++++++------------------- 4 files changed, 48 insertions(+), 50 deletions(-) diff --git a/cmd/pipeline/copy.go b/cmd/pipeline/copy.go index 5930b17f..d927ccac 100644 --- a/cmd/pipeline/copy.go +++ b/cmd/pipeline/copy.go @@ -25,7 +25,7 @@ type CopyCmd struct { 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:"Replace source team assignments with NAME=ACCESS_LEVEL (repeatable); access: read_only, build_and_read, manage_build_and_read"` + Teams map[string]string `name:"team" help:"Replace source team assignments with SLUG=ACCESS_LEVEL (repeatable); access: read_only, build_and_read, manage_build_and_read"` output.OutputFlags } @@ -66,8 +66,8 @@ This command copies all configuration from a source pipeline including: - Tags and visibility - Team assignments and their access levels (within the same organization) -Use --team "Team Name=ACCESS_LEVEL" to replace the source team assignments. Repeat -the flag to assign multiple teams. Names must match exactly in the destination +Use --team SLUG=ACCESS_LEVEL to replace the source team assignments. Repeat +the flag to assign 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. Automatic team copying requires a token with GraphQL access; @@ -75,7 +75,7 @@ explicit --team assignments avoid that lookup. When copying to a different organization, cluster and team assignments are not copied because they are organization-specific. Use --team with destination team -names; non-admin users in organizations with Teams enabled must assign a team. +slugs; non-admin users in organizations with Teams enabled must assign a team. Examples: # Copy the current pipeline to a new pipeline @@ -85,7 +85,7 @@ Examples: $ bk pipeline cp my-existing-pipeline --target "my-new-pipeline" # Copy with explicit team access instead of the source assignments - $ bk pipeline cp my-pipeline --target "my-copy" --team "Platform Engineering=build_and_read" + $ 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" diff --git a/cmd/pipeline/create.go b/cmd/pipeline/create.go index 12dce3af..ed30c790 100644 --- a/cmd/pipeline/create.go +++ b/cmd/pipeline/create.go @@ -29,7 +29,7 @@ type CreateCmd struct { 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 NAME=ACCESS_LEVEL (repeatable); access: read_only, build_and_read, manage_build_and_read"` + 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 } @@ -59,7 +59,7 @@ 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 "Team Name=ACCESS_LEVEL" for each team assignment. Names must match +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. Non-admin users in organizations with Teams enabled must assign a team when creating a pipeline. @@ -72,7 +72,7 @@ Examples: $ 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" + $ 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" @@ -157,7 +157,7 @@ func (c *CreateCmd) createPipeline(ctx context.Context, f *factory.Factory) (*bu if err != nil { return nil, err } - teams, err := resolveTeamNames(ctx, f.RestAPIClient, c.orgSlug(f.Config), c.Teams) + teams, err := resolveTeamSlugs(ctx, f.RestAPIClient, c.orgSlug(f.Config), c.Teams) if err != nil { return nil, err } @@ -274,7 +274,7 @@ func (c *CreateCmd) createPipelineDryRun(ctx context.Context, f *factory.Factory } orgSlug := c.orgSlug(f.Config) - teams, err := resolveTeamNames(ctx, f.RestAPIClient, orgSlug, c.Teams) + teams, err := resolveTeamSlugs(ctx, f.RestAPIClient, orgSlug, c.Teams) if err != nil { return nil, err } diff --git a/cmd/pipeline/teams.go b/cmd/pipeline/teams.go index 2aeb8c26..4ea4d4a6 100644 --- a/cmd/pipeline/teams.go +++ b/cmd/pipeline/teams.go @@ -11,9 +11,9 @@ import ( ) func validateTeams(teams map[string]string) error { - for name, access := range teams { - if strings.TrimSpace(name) == "" { - return fmt.Errorf("--team requires a non-empty team name") + 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": @@ -24,27 +24,24 @@ func validateTeams(teams map[string]string) error { return nil } -func resolveTeamNames(ctx context.Context, client *buildkite.Client, org string, names map[string]string) (map[string]string, error) { - if len(names) == 0 { +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(names)) - matched := make(map[string]string, len(names)) + 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 names in organization %q: %w", org, err) + return nil, fmt.Errorf("could not resolve team slugs in organization %q: %w", org, err) } for _, team := range teams { - access, requested := names[team.Name] + access, requested := slugs[team.Slug] if !requested { continue } - if id, exists := matched[team.Name]; exists && id != team.ID { - return nil, fmt.Errorf("team name %q is ambiguous in organization %q", team.Name, org) - } - matched[team.Name] = team.ID + matched[team.Slug] = true assignments[team.ID] = access } if resp.NextPage == 0 { @@ -52,9 +49,9 @@ func resolveTeamNames(ctx context.Context, client *buildkite.Client, org string, } opts.Page = resp.NextPage } - for name := range names { - if _, exists := matched[name]; !exists { - return nil, fmt.Errorf("team %q not found in organization %q; use the exact team name from bk team list", name, org) + 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 @@ -70,7 +67,7 @@ func (c *CopyCmd) resolveTeams(ctx context.Context, f *factory.Factory, org, slu return nil, err } } - return resolveTeamNames(ctx, client, targetOrg, c.Teams) + return resolveTeamSlugs(ctx, client, targetOrg, c.Teams) } if targetOrg != org { return nil, nil @@ -81,10 +78,10 @@ func (c *CopyCmd) resolveTeams(ctx context.Context, f *factory.Factory, org, slu for { result, err := graphql.PipelineTeams(ctx, f.GraphQLClient, org+"/"+slug, cursor) if err != nil { - return nil, fmt.Errorf("could not read source team assignments (use --team NAME=ACCESS_LEVEL to set them explicitly): %w", err) + return nil, fmt.Errorf("could not read source team assignments (use --team SLUG=ACCESS_LEVEL to set them explicitly): %w", err) } if result.Pipeline == nil { - return nil, fmt.Errorf("could not read team assignments for pipeline %s/%s; use --team NAME=ACCESS_LEVEL to set them explicitly", org, slug) + return nil, fmt.Errorf("could not read team assignments for pipeline %s/%s; use --team SLUG=ACCESS_LEVEL to set them explicitly", org, slug) } connection := result.Pipeline.Teams if connection == nil { @@ -92,7 +89,7 @@ func (c *CopyCmd) resolveTeams(ctx context.Context, f *factory.Factory, org, slu } for _, edge := range connection.Edges { if edge == nil || edge.Node == nil || edge.Node.Team == nil { - return nil, fmt.Errorf("source team assignment is not accessible; use --team NAME=ACCESS_LEVEL to set teams explicitly") + return nil, fmt.Errorf("source team assignment is not accessible; use --team SLUG=ACCESS_LEVEL to set teams explicitly") } teams[edge.Node.Team.Uuid] = strings.ToLower(string(edge.Node.AccessLevel)) } @@ -100,7 +97,7 @@ func (c *CopyCmd) resolveTeams(ctx context.Context, f *factory.Factory, org, slu return teams, nil } if connection.PageInfo.EndCursor == nil || (cursor != nil && *cursor == *connection.PageInfo.EndCursor) { - return nil, fmt.Errorf("could not paginate source team assignments; use --team NAME=ACCESS_LEVEL to set teams explicitly") + return nil, fmt.Errorf("could not paginate source team assignments; use --team SLUG=ACCESS_LEVEL to set teams explicitly") } cursor = connection.PageInfo.EndCursor } diff --git a/cmd/pipeline/teams_test.go b/cmd/pipeline/teams_test.go index 36addbb3..fef5fac5 100644 --- a/cmd/pipeline/teams_test.go +++ b/cmd/pipeline/teams_test.go @@ -31,10 +31,10 @@ func TestTeamFlags(t *testing.T) { args []string wantErr bool }{ - {"multiple assignments", []string{"--team", "Readers=read_only", "--team", "Platform Engineering=manage_build_and_read"}, false}, - {"empty name", []string{"--team", " =read_only"}, true}, - {"invalid access", []string{"--team", "Readers=admin"}, true}, - {"missing access", []string{"--team", "Readers"}, true}, + {"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 { @@ -56,7 +56,7 @@ func TestTeamFlags(t *testing.T) { if command == "cp" { teams = cli.Cp.Teams } - if !maps.Equal(teams, map[string]string{"Readers": "read_only", "Platform Engineering": "manage_build_and_read"}) { + if !maps.Equal(teams, map[string]string{"readers": "read_only", "platform-engineering": "manage_build_and_read"}) { t.Fatalf("unexpected assignments: %v", teams) } }) @@ -160,7 +160,7 @@ func TestCopyTeamOverridesAndCrossOrg(t *testing.T) { 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"}]`, ownerTeam) + fmt.Fprintf(w, `[{"id":%q,"name":"Platform Engineering","slug":"platform-engineering"}]`, ownerTeam) })) t.Cleanup(s.Close) t.Setenv("BUILDKITE_REST_API_ENDPOINT", s.URL) @@ -169,7 +169,7 @@ func TestCopyTeamOverridesAndCrossOrg(t *testing.T) { if err != nil { t.Fatal(err) } - c := CopyCmd{Teams: map[string]string{"Platform Engineering": "build_and_read"}} + 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", "pipeline", targetOrg) if err != nil || !maps.Equal(teams, map[string]string{ownerTeam: "build_and_read"}) { @@ -210,7 +210,7 @@ func TestCreateTeamsRequestAndDryRun(t *testing.T) { 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"}]`, readerTeam) + fmt.Fprintf(w, `[{"id":%q,"name":"Readers","slug":"readers"}]`, readerTeam) return } if r.Method == http.MethodGet { @@ -236,7 +236,7 @@ func TestCreateTeamsRequestAndDryRun(t *testing.T) { 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"}} + 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) @@ -252,18 +252,19 @@ func TestCreateTeamsRequestAndDryRun(t *testing.T) { } } -func TestResolveTeamNames(t *testing.T) { +func TestResolveTeamSlugs(t *testing.T) { for _, tc := range []struct { name string - names map[string]string + slugs map[string]string secondName string status int wantErr string }{ - {"paginated names", map[string]string{"Readers": "read_only", "Platform Engineering": "manage_build_and_read"}, "Platform Engineering", 200, ""}, - {"ambiguous name on next page", map[string]string{"Readers": "read_only"}, "Readers", 200, "ambiguous"}, - {"case mismatch", map[string]string{"readers": "read_only"}, "Platform Engineering", 200, "not found"}, - {"no team read permission", map[string]string{"Readers": "read_only"}, "", 403, "could not resolve"}, + {"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 @@ -280,12 +281,12 @@ func TestResolveTeamNames(t *testing.T) { } if calls == 1 { w.Header().Set("Link", fmt.Sprintf(`; rel="next"`, r.Host)) - fmt.Fprintf(w, `[{"id":%q,"name":"Readers"}]`, readerTeam) + 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}]`, ownerTeam, tc.secondName) + fmt.Fprintf(w, `[{"id":%q,"name":%q,"slug":"platform-engineering"}]`, ownerTeam, tc.secondName) } })) defer s.Close() @@ -293,7 +294,7 @@ func TestResolveTeamNames(t *testing.T) { if err != nil { t.Fatal(err) } - teams, err := resolveTeamNames(context.Background(), client, "destination", tc.names) + 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) @@ -305,7 +306,7 @@ func TestResolveTeamNames(t *testing.T) { } }) } - if teams, err := resolveTeamNames(context.Background(), nil, "org", nil); err != nil || teams != nil { + 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) } } From cc1b5ff6a7eab1a466c5422de82a4f9a3f5f4863 Mon Sep 17 00:00:00 2001 From: Lizette Rabuya <115472349+lizrabuya@users.noreply.github.com> Date: Tue, 22 Sep 2026 06:37:14 +0000 Subject: [PATCH 4/5] Test pipeline create and copy without team assignments Amp-Thread-ID: https://ampcode.com/threads/T-0cf40a11-b530-4f27-adf3-ff60561b5fea Co-authored-by: Amp --- cmd/pipeline/teams_test.go | 135 +++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/cmd/pipeline/teams_test.go b/cmd/pipeline/teams_test.go index fef5fac5..e85c305e 100644 --- a/cmd/pipeline/teams_test.go +++ b/cmd/pipeline/teams_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "maps" "net/http" @@ -13,6 +14,7 @@ import ( "github.com/Khan/genqlient/graphql" "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" "github.com/buildkite/cli/v3/pkg/output" @@ -310,3 +312,136 @@ func TestResolveTeamSlugs(t *testing.T) { 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 TestCopySourceWithoutTeams(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") + 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/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 == "/graphql": + lookups++ + fmt.Fprint(w, `{"data":{"pipeline":{"teams":{"edges":[],"pageInfo":{"hasNextPage":false}}}}}`) + 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 of a source without teams must omit 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: + 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 lookups != 1 || posts != wantPosts { + t.Fatalf("lookups=%d posts=%d, want 1 and %d", lookups, posts, wantPosts) + } + }) + } +} From 8ddd8a687598159acc591e2db144a04e7f5a5b01 Mon Sep 17 00:00:00 2001 From: Lizette Rabuya <115472349+lizrabuya@users.noreply.github.com> Date: Thu, 24 Sep 2026 04:49:47 +0000 Subject: [PATCH 5/5] Require explicit team assignments when copying pipelines Amp-Thread-ID: https://ampcode.com/threads/T-0cf40a11-b530-4f27-adf3-ff60561b5fea Co-authored-by: Amp --- cmd/pipeline/copy.go | 27 +++-- cmd/pipeline/create.go | 5 +- cmd/pipeline/teams.go | 48 ++------- cmd/pipeline/teams.graphql | 18 ---- cmd/pipeline/teams_test.go | 167 +++++++++++------------------ internal/graphql/generated.go | 192 ---------------------------------- 6 files changed, 85 insertions(+), 372 deletions(-) delete mode 100644 cmd/pipeline/teams.graphql diff --git a/cmd/pipeline/copy.go b/cmd/pipeline/copy.go index d927ccac..a34e18e4 100644 --- a/cmd/pipeline/copy.go +++ b/cmd/pipeline/copy.go @@ -25,7 +25,7 @@ type CopyCmd struct { 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:"Replace source team assignments with SLUG=ACCESS_LEVEL (repeatable); access: read_only, build_and_read, manage_build_and_read"` + 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 } @@ -56,7 +56,7 @@ func (c *CopyCmd) Validate() error { 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,18 +64,17 @@ This command copies all configuration from a source pipeline including: - Provider settings (trigger mode, PR builds, commit statuses, etc.) - Environment variables - Tags and visibility -- Team assignments and their access levels (within the same organization) -Use --team SLUG=ACCESS_LEVEL to replace the source team assignments. Repeat -the flag to assign 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. Automatic team copying requires a token with GraphQL access; -explicit --team assignments avoid that lookup. +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, cluster and team assignments are not -copied because they are organization-specific. Use --team with destination team -slugs; non-admin users in organizations with Teams enabled must 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 @@ -84,7 +83,7 @@ Examples: # Copy a specific pipeline $ bk pipeline cp my-existing-pipeline --target "my-new-pipeline" - # Copy with explicit team access instead of the source assignments + # 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) @@ -152,7 +151,7 @@ func (c *CopyCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error { } createReq := c.buildCreatePipeline(source, target.Name, isCrossOrg, clusterID) - createReq.Teams, err = c.resolveTeams(ctx, f, sourcePipeline.Org, sourcePipeline.Name, target.Org) + createReq.Teams, err = c.resolveTeams(ctx, f, sourcePipeline.Org, target.Org) if err != nil { return err } diff --git a/cmd/pipeline/create.go b/cmd/pipeline/create.go index ed30c790..fe9a40c4 100644 --- a/cmd/pipeline/create.go +++ b/cmd/pipeline/create.go @@ -61,8 +61,9 @@ 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. Non-admin -users in organizations with Teams enabled must assign a team when creating a pipeline. +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 diff --git a/cmd/pipeline/teams.go b/cmd/pipeline/teams.go index 4ea4d4a6..84bdd059 100644 --- a/cmd/pipeline/teams.go +++ b/cmd/pipeline/teams.go @@ -5,7 +5,6 @@ import ( "fmt" "strings" - "github.com/buildkite/cli/v3/internal/graphql" "github.com/buildkite/cli/v3/pkg/cmd/factory" buildkite "github.com/buildkite/go-buildkite/v5" ) @@ -57,48 +56,17 @@ func resolveTeamSlugs(ctx context.Context, client *buildkite.Client, org string, return assignments, nil } -func (c *CopyCmd) resolveTeams(ctx context.Context, f *factory.Factory, org, slug, targetOrg string) (map[string]string, error) { - if len(c.Teams) > 0 { - client := f.RestAPIClient - if targetOrg != org { - var err error - client, err = c.getClientForOrg(f, targetOrg) - if err != nil { - return nil, err - } - } - return resolveTeamSlugs(ctx, client, targetOrg, c.Teams) - } - if targetOrg != org { +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 } - - teams := make(map[string]string) - var cursor *string - for { - result, err := graphql.PipelineTeams(ctx, f.GraphQLClient, org+"/"+slug, cursor) + client := f.RestAPIClient + if targetOrg != sourceOrg { + var err error + client, err = c.getClientForOrg(f, targetOrg) if err != nil { - return nil, fmt.Errorf("could not read source team assignments (use --team SLUG=ACCESS_LEVEL to set them explicitly): %w", err) - } - if result.Pipeline == nil { - return nil, fmt.Errorf("could not read team assignments for pipeline %s/%s; use --team SLUG=ACCESS_LEVEL to set them explicitly", org, slug) - } - connection := result.Pipeline.Teams - if connection == nil { - return teams, nil - } - for _, edge := range connection.Edges { - if edge == nil || edge.Node == nil || edge.Node.Team == nil { - return nil, fmt.Errorf("source team assignment is not accessible; use --team SLUG=ACCESS_LEVEL to set teams explicitly") - } - teams[edge.Node.Team.Uuid] = strings.ToLower(string(edge.Node.AccessLevel)) - } - if connection.PageInfo == nil || !connection.PageInfo.HasNextPage { - return teams, nil - } - if connection.PageInfo.EndCursor == nil || (cursor != nil && *cursor == *connection.PageInfo.EndCursor) { - return nil, fmt.Errorf("could not paginate source team assignments; use --team SLUG=ACCESS_LEVEL to set teams explicitly") + return nil, err } - cursor = connection.PageInfo.EndCursor } + return resolveTeamSlugs(ctx, client, targetOrg, c.Teams) } diff --git a/cmd/pipeline/teams.graphql b/cmd/pipeline/teams.graphql deleted file mode 100644 index a7db28f8..00000000 --- a/cmd/pipeline/teams.graphql +++ /dev/null @@ -1,18 +0,0 @@ -query PipelineTeams($slug: ID!, $after: String) { - pipeline(slug: $slug) { - teams(first: 100, after: $after) { - edges { - node { - accessLevel - team { - uuid - } - } - } - pageInfo { - hasNextPage - endCursor - } - } - } -} diff --git a/cmd/pipeline/teams_test.go b/cmd/pipeline/teams_test.go index e85c305e..5125e858 100644 --- a/cmd/pipeline/teams_test.go +++ b/cmd/pipeline/teams_test.go @@ -12,12 +12,10 @@ import ( "strings" "testing" - "github.com/Khan/genqlient/graphql" "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" - "github.com/buildkite/cli/v3/pkg/output" buildkite "github.com/buildkite/go-buildkite/v5" ) @@ -66,92 +64,72 @@ func TestTeamFlags(t *testing.T) { } } -func TestCopyTeamsPaginationAndCreation(t *testing.T) { +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"} - pages, posts := 0, 0 + lookups, posts := 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 == "/graphql" { - var req struct { - Variables struct { - Slug string - After *string - } + 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(&req); err != nil { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Error(err) } - if req.Variables.Slug != "source-org/source" { - t.Errorf("wrong source: %s", req.Variables.Slug) - } - pages++ - if pages == 1 { - if req.Variables.After != nil { - t.Error("first page should not have a cursor") - } - fmt.Fprintf(w, `{"data":{"pipeline":{"teams":{"edges":[{"node":{"accessLevel":"READ_ONLY","team":{"uuid":%q}}}],"pageInfo":{"hasNextPage":true,"endCursor":"next"}}}}}`, readerTeam) - } else { - if req.Variables.After == nil || *req.Variables.After != "next" { - t.Error("missing next-page cursor") - } - fmt.Fprintf(w, `{"data":{"pipeline":{"teams":{"edges":[{"node":{"accessLevel":"MANAGE_BUILD_AND_READ","team":{"uuid":%q}}}],"pageInfo":{"hasNextPage":false}}}}}`, ownerTeam) + if !maps.Equal(body.Teams, want) { + t.Errorf("creation must include explicitly selected teams: %v", body.Teams) } - return - } - if r.Method != http.MethodPost || r.URL.Path != "/v2/organizations/source-org/pipelines" { + 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) } - posts++ - var body struct { - Teams map[string]string `json:"teams"` + })) + 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) } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Error(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") } - if !maps.Equal(body.Teams, want) { - t.Errorf("creation must include all teams with original access: %v", body.Teams) - w.WriteHeader(http.StatusUnprocessableEntity) + 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) + } } - fmt.Fprint(w, `{"name":"copy"}`) - })) - defer s.Close() - client, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL)) - if err != nil { - t.Fatal(err) - } - f := &factory.Factory{Config: &config.Config{}, RestAPIClient: client, GraphQLClient: graphql.NewClient(s.URL+"/graphql", s.Client())} - c := CopyCmd{OutputFlags: output.OutputFlags{Output: "json"}} - request := c.buildCreatePipeline(&buildkite.Pipeline{Repository: "git@example.com:repo.git", Configuration: "steps: []"}, "copy", false, "cluster") - request.Teams, err = c.resolveTeams(context.Background(), f, "source-org", "source", "source-org") - if err != nil { - t.Fatal(err) - } - var preview bytes.Buffer - parser, err := kong.New(&c, kong.Writers(&preview, &preview), kong.Vars{"output_default_format": "json"}) - if err != nil { - t.Fatal(err) - } - kongCtx, err := parser.Parse(nil) - if err != nil { - t.Fatal(err) - } - if err := c.runDryRun(kongCtx, f, request); err != nil { - t.Fatal(err) - } - var dry struct { - Teams map[string]string `json:"teams"` - } - if err := json.Unmarshal(preview.Bytes(), &dry); err != nil { - t.Fatal(err) - } - if !maps.Equal(dry.Teams, want) || posts != 0 { - t.Fatalf("dry-run teams %v, writes %d", dry.Teams, posts) - } - if err := c.runCopy(kongCtx, f, ©Target{Org: "source-org", Name: "copy"}, false, request); err != nil { - t.Fatal(err) } - if pages != 2 || posts != 1 { - t.Fatalf("pages=%d posts=%d", pages, posts) + if lookups != 2 || posts != 1 { + t.Fatalf("lookups=%d posts=%d", lookups, posts) } } @@ -173,39 +151,18 @@ func TestCopyTeamOverridesAndCrossOrg(t *testing.T) { } 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", "pipeline", targetOrg) + 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", "pipeline", "destination") + 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 TestCopyTeamLookupFailures(t *testing.T) { - for _, response := range []string{ - `{"errors":[{"message":"Forbidden"}]}`, - `{"data":{"pipeline":null}}`, - `{"data":{"pipeline":{"teams":{"edges":[{"node":{"team":null}}]}}}}`, - } { - t.Run(response, func(t *testing.T) { - s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, response) - })) - defer s.Close() - c := CopyCmd{} - teams, err := c.resolveTeams(context.Background(), &factory.Factory{GraphQLClient: graphql.NewClient(s.URL, s.Client())}, "org", "pipeline", "org") - if err == nil || !strings.Contains(err.Error(), "--team") || teams != nil { - t.Fatalf("teams=%v err=%v", teams, err) - } - }) - } -} - func TestCreateTeamsRequestAndDryRun(t *testing.T) { want := map[string]string{readerTeam: "build_and_read"} posts := 0 @@ -370,21 +327,18 @@ func TestCreateWithoutTeamsReturnsValidationError(t *testing.T) { } } -func TestCopySourceWithoutTeams(t *testing.T) { +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") - lookups, posts := 0, 0 + 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 == "/graphql": - lookups++ - fmt.Fprint(w, `{"data":{"pipeline":{"teams":{"edges":[],"pageInfo":{"hasNextPage":false}}}}}`) case r.Method == http.MethodPost && r.URL.Path == "/v2/organizations/org/pipelines": posts++ var body map[string]json.RawMessage @@ -392,7 +346,7 @@ func TestCopySourceWithoutTeams(t *testing.T) { t.Error(err) } if _, present := body["teams"]; present { - t.Error("copy of a source without teams must omit teams") + 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) @@ -401,6 +355,7 @@ func TestCopySourceWithoutTeams(t *testing.T) { 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) } @@ -439,8 +394,8 @@ func TestCopySourceWithoutTeams(t *testing.T) { if dryRun { wantPosts = 0 } - if lookups != 1 || posts != wantPosts { - t.Fatalf("lookups=%d posts=%d, want 1 and %d", lookups, posts, wantPosts) + if posts != wantPosts { + t.Fatalf("posts=%d, want %d", posts, wantPosts) } }) } diff --git a/internal/graphql/generated.go b/internal/graphql/generated.go index feef2e13..3213c030 100644 --- a/internal/graphql/generated.go +++ b/internal/graphql/generated.go @@ -3727,24 +3727,6 @@ func (v *ListJobsByStateResponse) GetOrganization() *ListJobsByStateOrganization return v.Organization } -// The access levels that can be assigned to a pipeline -type PipelineAccessLevels string - -const ( - // Allows builds and read only - PipelineAccessLevelsBuildAndRead PipelineAccessLevels = "BUILD_AND_READ" - // Allows edits, builds and reads - PipelineAccessLevelsManageBuildAndRead PipelineAccessLevels = "MANAGE_BUILD_AND_READ" - // Read only - no builds or edits - PipelineAccessLevelsReadOnly PipelineAccessLevels = "READ_ONLY" -) - -var AllPipelineAccessLevels = []PipelineAccessLevels{ - PipelineAccessLevelsBuildAndRead, - PipelineAccessLevelsManageBuildAndRead, - PipelineAccessLevelsReadOnly, -} - // PipelineCreateWebhookPipelineCreateWebhookPipelineCreateWebhookPayload includes the requested fields of the GraphQL type PipelineCreateWebhookPayload. // The GraphQL type's documentation follows. // @@ -3783,119 +3765,6 @@ func (v *PipelineCreateWebhookResponse) GetPipelineCreateWebhook() *PipelineCrea return v.PipelineCreateWebhook } -// PipelineTeamsPipeline includes the requested fields of the GraphQL type Pipeline. -// The GraphQL type's documentation follows. -// -// A pipeline -type PipelineTeamsPipeline struct { - // Teams associated with this pipeline - Teams *PipelineTeamsPipelineTeamsTeamPipelineConnection `json:"teams"` -} - -// GetTeams returns PipelineTeamsPipeline.Teams, and is useful for accessing the field via an interface. -func (v *PipelineTeamsPipeline) GetTeams() *PipelineTeamsPipelineTeamsTeamPipelineConnection { - return v.Teams -} - -// PipelineTeamsPipelineTeamsTeamPipelineConnection includes the requested fields of the GraphQL type TeamPipelineConnection. -// The GraphQL type's documentation follows. -// -// The connection type for TeamPipeline. -type PipelineTeamsPipelineTeamsTeamPipelineConnection struct { - // A list of edges. - Edges []*PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdge `json:"edges"` - PageInfo *PipelineTeamsPipelineTeamsTeamPipelineConnectionPageInfo `json:"pageInfo"` -} - -// GetEdges returns PipelineTeamsPipelineTeamsTeamPipelineConnection.Edges, and is useful for accessing the field via an interface. -func (v *PipelineTeamsPipelineTeamsTeamPipelineConnection) GetEdges() []*PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdge { - return v.Edges -} - -// GetPageInfo returns PipelineTeamsPipelineTeamsTeamPipelineConnection.PageInfo, and is useful for accessing the field via an interface. -func (v *PipelineTeamsPipelineTeamsTeamPipelineConnection) GetPageInfo() *PipelineTeamsPipelineTeamsTeamPipelineConnectionPageInfo { - return v.PageInfo -} - -// PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdge includes the requested fields of the GraphQL type TeamPipelineEdge. -// The GraphQL type's documentation follows. -// -// An edge in a connection. -type PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdge struct { - // The item at the end of the edge. - Node *PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipeline `json:"node"` -} - -// GetNode returns PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdge.Node, and is useful for accessing the field via an interface. -func (v *PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdge) GetNode() *PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipeline { - return v.Node -} - -// PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipeline includes the requested fields of the GraphQL type TeamPipeline. -// The GraphQL type's documentation follows. -// -// An pipeline that's been assigned to a team -type PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipeline struct { - // The access level users have to this pipeline - AccessLevel PipelineAccessLevels `json:"accessLevel"` - // The team associated with this team member - Team *PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipelineTeam `json:"team"` -} - -// GetAccessLevel returns PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipeline.AccessLevel, and is useful for accessing the field via an interface. -func (v *PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipeline) GetAccessLevel() PipelineAccessLevels { - return v.AccessLevel -} - -// GetTeam returns PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipeline.Team, and is useful for accessing the field via an interface. -func (v *PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipeline) GetTeam() *PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipelineTeam { - return v.Team -} - -// PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipelineTeam includes the requested fields of the GraphQL type Team. -// The GraphQL type's documentation follows. -// -// An organization team -type PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipelineTeam struct { - // The public UUID for this team - Uuid string `json:"uuid"` -} - -// GetUuid returns PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipelineTeam.Uuid, and is useful for accessing the field via an interface. -func (v *PipelineTeamsPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdgeNodeTeamPipelineTeam) GetUuid() string { - return v.Uuid -} - -// PipelineTeamsPipelineTeamsTeamPipelineConnectionPageInfo includes the requested fields of the GraphQL type PageInfo. -// The GraphQL type's documentation follows. -// -// Information about pagination in a connection. -type PipelineTeamsPipelineTeamsTeamPipelineConnectionPageInfo struct { - // When paginating forwards, are there more items? - HasNextPage bool `json:"hasNextPage"` - // When paginating forwards, the cursor to continue. - EndCursor *string `json:"endCursor"` -} - -// GetHasNextPage returns PipelineTeamsPipelineTeamsTeamPipelineConnectionPageInfo.HasNextPage, and is useful for accessing the field via an interface. -func (v *PipelineTeamsPipelineTeamsTeamPipelineConnectionPageInfo) GetHasNextPage() bool { - return v.HasNextPage -} - -// GetEndCursor returns PipelineTeamsPipelineTeamsTeamPipelineConnectionPageInfo.EndCursor, and is useful for accessing the field via an interface. -func (v *PipelineTeamsPipelineTeamsTeamPipelineConnectionPageInfo) GetEndCursor() *string { - return v.EndCursor -} - -// PipelineTeamsResponse is returned by PipelineTeams on success. -type PipelineTeamsResponse struct { - // Find a pipeline - Pipeline *PipelineTeamsPipeline `json:"pipeline"` -} - -// GetPipeline returns PipelineTeamsResponse.Pipeline, and is useful for accessing the field via an interface. -func (v *PipelineTeamsResponse) GetPipeline() *PipelineTeamsPipeline { return v.Pipeline } - // UnblockJobJobTypeBlockUnblockJobTypeBlockUnblockPayload includes the requested fields of the GraphQL type JobTypeBlockUnblockPayload. // The GraphQL type's documentation follows. // @@ -4132,18 +4001,6 @@ type __PipelineCreateWebhookInput struct { // GetId returns __PipelineCreateWebhookInput.Id, and is useful for accessing the field via an interface. func (v *__PipelineCreateWebhookInput) GetId() string { return v.Id } -// __PipelineTeamsInput is used internally by genqlient -type __PipelineTeamsInput struct { - Slug string `json:"slug"` - After *string `json:"after"` -} - -// GetSlug returns __PipelineTeamsInput.Slug, and is useful for accessing the field via an interface. -func (v *__PipelineTeamsInput) GetSlug() string { return v.Slug } - -// GetAfter returns __PipelineTeamsInput.After, and is useful for accessing the field via an interface. -func (v *__PipelineTeamsInput) GetAfter() *string { return v.After } - // __UnblockJobInput is used internally by genqlient type __UnblockJobInput struct { Id string `json:"id"` @@ -4766,55 +4623,6 @@ func PipelineCreateWebhook( return data_, err_ } -// The query executed by PipelineTeams. -const PipelineTeams_Operation = ` -query PipelineTeams ($slug: ID!, $after: String) { - pipeline(slug: $slug) { - teams(first: 100, after: $after) { - edges { - node { - accessLevel - team { - uuid - } - } - } - pageInfo { - hasNextPage - endCursor - } - } - } -} -` - -func PipelineTeams( - ctx_ context.Context, - client_ graphql.Client, - slug string, - after *string, -) (data_ *PipelineTeamsResponse, err_ error) { - req_ := &graphql.Request{ - OpName: "PipelineTeams", - Query: PipelineTeams_Operation, - Variables: &__PipelineTeamsInput{ - Slug: slug, - After: after, - }, - } - - data_ = &PipelineTeamsResponse{} - resp_ := &graphql.Response{Data: data_} - - err_ = client_.MakeRequest( - ctx_, - req_, - resp_, - ) - - return data_, err_ -} - // The mutation executed by UnblockJob. const UnblockJob_Operation = ` mutation UnblockJob ($id: ID!, $fields: JSON) {