diff --git a/internal/build/resolver/current_user.go b/internal/build/resolver/current_user.go new file mode 100644 index 00000000..aa45a79a --- /dev/null +++ b/internal/build/resolver/current_user.go @@ -0,0 +1,24 @@ +package resolver + +import ( + "context" + + "github.com/buildkite/cli/v3/internal/build" + pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" + "github.com/buildkite/cli/v3/pkg/cmd/factory" + "github.com/buildkite/go-buildkite/v3/buildkite" +) + +// ResolveBuildForCurrentUser Finds the most recent build for the current user and branch +func ResolveBuildForCurrentUser(branch string, pipelineResolver pipelineResolver.PipelineResolverFn, f *factory.Factory) BuildResolverFn { + return func(ctx context.Context) (*build.Build, error) { + var user *buildkite.User + + user, _, err := f.RestAPIClient.User.Get() + if err != nil { + return nil, err + } + + return ResolveBuildForUser(ctx, *user.Email, branch, pipelineResolver, f) + } +} diff --git a/internal/build/resolver/current_user_test.go b/internal/build/resolver/current_user_test.go new file mode 100644 index 00000000..7dccbd45 --- /dev/null +++ b/internal/build/resolver/current_user_test.go @@ -0,0 +1,145 @@ +package resolver_test + +import ( + "bytes" + "context" + "io" + "net/http" + "os" + "strings" + "testing" + + "github.com/buildkite/cli/v3/internal/build/resolver" + "github.com/buildkite/cli/v3/internal/config" + "github.com/buildkite/cli/v3/internal/pipeline" + "github.com/buildkite/cli/v3/pkg/cmd/factory" + "github.com/buildkite/go-buildkite/v3/buildkite" + "github.com/spf13/afero" +) + +func TestResolveBuildForCurrentUser(t *testing.T) { + t.Parallel() + + pipelineResolver := func(context.Context) (*pipeline.Pipeline, error) { + return &pipeline.Pipeline{ + Name: "testing", + Org: "test org", + }, nil + } + + t.Run("Errors if user cannot be found", func(t *testing.T) { + t.Parallel() + + // mock a failed repsonse + transport := roundTripperFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNotFound, + }, nil + }) + client := &http.Client{Transport: transport} + f := &factory.Factory{ + RestAPIClient: buildkite.NewClient(client), + } + + r := resolver.ResolveBuildForCurrentUser("main", pipelineResolver, f) + _, err := r(context.Background()) + + if err == nil { + t.Fatal("Resolver should return error if user not found") + } + }) + + t.Run("Returns first build found", func(t *testing.T) { + t.Parallel() + + in, _ := os.ReadFile("../../../fixtures/build.json") + callIndex := 0 + responses := []http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "id": "abc123-4567-8910-...", + "graphql_id": "VXNlci0tLWU1N2ZiYTBmLWFiMTQtNGNjMC1iYjViLTY5NTc3NGZmYmZiZQ==", + "name": "John Smith", + "email": "john.smith@example.com", + "avatar_url": "https://www.gravatar.com/avatar/abc123...", + "created_at": "2012-03-04T06:07:08.910Z" + } + `)), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(in)), + }, + } + // mock a failed repsonse + transport := roundTripperFunc(func(r *http.Request) (*http.Response, error) { + resp := responses[callIndex] + callIndex++ + return &resp, nil + }) + client := &http.Client{Transport: transport} + fs := afero.NewMemMapFs() + f := &factory.Factory{ + RestAPIClient: buildkite.NewClient(client), + Config: config.New(fs, nil), + } + + r := resolver.ResolveBuildForCurrentUser("main", pipelineResolver, f) + build, err := r(context.Background()) + if err != nil { + t.Fatal(err) + } + + if build.BuildNumber != 584 { + t.Fatalf("Expected build 584, got %d", build.BuildNumber) + } + }) + + t.Run("Errors if no matching builds found", func(t *testing.T) { + t.Parallel() + + callIndex := 0 + responses := []http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "id": "abc123-4567-8910-...", + "graphql_id": "VXNlci0tLWU1N2ZiYTBmLWFiMTQtNGNjMC1iYjViLTY5NTc3NGZmYmZiZQ==", + "name": "John Smith", + "email": "john.smith@example.com", + "avatar_url": "https://www.gravatar.com/avatar/abc123...", + "created_at": "2012-03-04T06:07:08.910Z" + } + `)), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("[]")), + }, + } + // mock a failed repsonse + transport := roundTripperFunc(func(r *http.Request) (*http.Response, error) { + resp := responses[callIndex] + callIndex++ + return &resp, nil + }) + client := &http.Client{Transport: transport} + fs := afero.NewMemMapFs() + f := &factory.Factory{ + RestAPIClient: buildkite.NewClient(client), + Config: config.New(fs, nil), + } + + r := resolver.ResolveBuildForCurrentUser("main", pipelineResolver, f) + build, err := r(context.Background()) + + if err == nil { + t.Fatal("Should return an error when no build is found") + } + + if build != nil { + t.Fatal("Expected no build to be found") + } + }) +} diff --git a/internal/build/resolver/user_builds.go b/internal/build/resolver/user_builds.go index 16924852..7f715d27 100644 --- a/internal/build/resolver/user_builds.go +++ b/internal/build/resolver/user_builds.go @@ -5,64 +5,47 @@ import ( "fmt" "github.com/buildkite/cli/v3/internal/build" - "github.com/buildkite/cli/v3/internal/pipeline" pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/go-buildkite/v3/buildkite" - "golang.org/x/sync/errgroup" ) -// ResolveBuildFromCurrentBranch Finds the most recent build for the branch in the current working directory -func ResolveBuildForCurrentUser(branch string, pipelineResolver pipelineResolver.PipelineResolverFn, f *factory.Factory) BuildResolverFn { - return func(ctx context.Context) (*build.Build, error) { - var pipeline *pipeline.Pipeline - var user *buildkite.User +// ResolveBuildForUser Finds the most recent build for the user and branch +func ResolveBuildForUser(ctx context.Context, userInfo string, branch string, pipelineResolver pipelineResolver.PipelineResolverFn, f *factory.Factory) (*build.Build, error) { - // use an errgroup so a few API calls can be done in parallel - // and then we check for any errors that occurred - g, _ := errgroup.WithContext(ctx) - g.Go(func() error { - p, e := pipelineResolver(ctx) - if p != nil { - pipeline = p - } - return e - }) - g.Go(func() error { - u, _, e := f.RestAPIClient.User.Get() - if u != nil { - user = u - } - return e - }) - err := g.Wait() - if err != nil { - return nil, err - } - if pipeline == nil { - return nil, fmt.Errorf("failed to resolve a pipeline to query builds on.") - } + pipeline, err := pipelineResolver(ctx) + if err != nil { + return nil, err + } + if pipeline == nil { + return nil, fmt.Errorf("failed to resolve a pipeline to query builds on") + } + + opt := &buildkite.BuildsListOptions{ + Creator: userInfo, + ListOptions: buildkite.ListOptions{ + PerPage: 1, + }, + } - builds, _, err := f.RestAPIClient.Builds.ListByPipeline(f.Config.OrganizationSlug(), pipeline.Name, &buildkite.BuildsListOptions{ - Creator: *user.Email, - Branch: []string{branch}, - ListOptions: buildkite.ListOptions{ - PerPage: 1, - }, - }) - if err != nil { - return nil, err - } - if len(builds) == 0 { - // we error here because this resolver is explicitly used so any case where it doesn't resolve a build is a - // problem - return nil, fmt.Errorf("failed to find a build for current user (email: %s)", *user.Email) - } + if len(branch) > 0 { + opt.Branch = []string{branch} + } + + builds, _, err := f.RestAPIClient.Builds.ListByPipeline(f.Config.OrganizationSlug(), pipeline.Name, opt) - return &build.Build{ - Organization: f.Config.OrganizationSlug(), - Pipeline: pipeline.Name, - BuildNumber: *builds[0].Number, - }, nil + if err != nil { + return nil, err } + if len(builds) == 0 { + // we error here because this resolver is explicitly used so any case where it doesn't resolve a build is a + // problem + return nil, fmt.Errorf("failed to find a build for current user (%s)", userInfo) + } + + return &build.Build{ + Organization: f.Config.OrganizationSlug(), + Pipeline: pipeline.Name, + BuildNumber: *builds[0].Number, + }, nil } diff --git a/internal/build/resolver/user_builds_test.go b/internal/build/resolver/user_builds_test.go index 9d58e5f7..96e60cdc 100644 --- a/internal/build/resolver/user_builds_test.go +++ b/internal/build/resolver/user_builds_test.go @@ -1,24 +1,20 @@ package resolver_test import ( - "bytes" "context" "errors" "io" "net/http" - "os" "strings" "testing" "github.com/buildkite/cli/v3/internal/build/resolver" - "github.com/buildkite/cli/v3/internal/config" "github.com/buildkite/cli/v3/internal/pipeline" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/go-buildkite/v3/buildkite" - "github.com/spf13/afero" ) -func TestResolveBuildForCurrentUser(t *testing.T) { +func TestResolveBuildFromUserId(t *testing.T) { t.Parallel() nilPipelineResolver := func(context.Context) (*pipeline.Pipeline, error) { @@ -27,12 +23,6 @@ func TestResolveBuildForCurrentUser(t *testing.T) { errorPipelineResolver := func(context.Context) (*pipeline.Pipeline, error) { return nil, errors.New("") } - pipelineResolver := func(context.Context) (*pipeline.Pipeline, error) { - return &pipeline.Pipeline{ - Name: "testing", - Org: "test org", - }, nil - } transport := roundTripperFunc(func(r *http.Request) (*http.Response, error) { return &http.Response{ @@ -48,8 +38,7 @@ func TestResolveBuildForCurrentUser(t *testing.T) { t.Run("Errors if pipeline cannot be resolved", func(t *testing.T) { t.Parallel() - r := resolver.ResolveBuildForCurrentUser("main", nilPipelineResolver, f) - _, err := r(context.Background()) + _, err := resolver.ResolveBuildForUser(context.Background(), "", "", nilPipelineResolver, f) if err == nil { t.Fatal("Resolver should return error if no pipeline resolved") @@ -59,129 +48,12 @@ func TestResolveBuildForCurrentUser(t *testing.T) { t.Run("Errors if pipeline resolver errors", func(t *testing.T) { t.Parallel() - r := resolver.ResolveBuildForCurrentUser("main", errorPipelineResolver, f) - _, err := r(context.Background()) + _, err := resolver.ResolveBuildForUser(context.Background(), "", "", errorPipelineResolver, f) if err == nil { t.Fatal("Resolver should return error if no pipeline resolved") } }) - - t.Run("Errors if user cannot be found", func(t *testing.T) { - t.Parallel() - - // mock a failed repsonse - transport := roundTripperFunc(func(r *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusNotFound, - }, nil - }) - client := &http.Client{Transport: transport} - f := &factory.Factory{ - RestAPIClient: buildkite.NewClient(client), - } - - r := resolver.ResolveBuildForCurrentUser("main", pipelineResolver, f) - _, err := r(context.Background()) - - if err == nil { - t.Fatal("Resolver should return error if user not found") - } - }) - - t.Run("Returns first build found", func(t *testing.T) { - t.Parallel() - - in, _ := os.ReadFile("../../../fixtures/build.json") - callIndex := 0 - responses := []http.Response{ - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{ - "id": "abc123-4567-8910-...", - "graphql_id": "VXNlci0tLWU1N2ZiYTBmLWFiMTQtNGNjMC1iYjViLTY5NTc3NGZmYmZiZQ==", - "name": "John Smith", - "email": "john.smith@example.com", - "avatar_url": "https://www.gravatar.com/avatar/abc123...", - "created_at": "2012-03-04T06:07:08.910Z" - } - `)), - }, - { - StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewReader(in)), - }, - } - // mock a failed repsonse - transport := roundTripperFunc(func(r *http.Request) (*http.Response, error) { - resp := responses[callIndex] - callIndex++ - return &resp, nil - }) - client := &http.Client{Transport: transport} - fs := afero.NewMemMapFs() - f := &factory.Factory{ - RestAPIClient: buildkite.NewClient(client), - Config: config.New(fs, nil), - } - - r := resolver.ResolveBuildForCurrentUser("main", pipelineResolver, f) - build, err := r(context.Background()) - if err != nil { - t.Fatal(err) - } - - if build.BuildNumber != 584 { - t.Fatalf("Expected build 584, got %d", build.BuildNumber) - } - }) - - t.Run("Errors if no matching builds found", func(t *testing.T) { - t.Parallel() - - callIndex := 0 - responses := []http.Response{ - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{ - "id": "abc123-4567-8910-...", - "graphql_id": "VXNlci0tLWU1N2ZiYTBmLWFiMTQtNGNjMC1iYjViLTY5NTc3NGZmYmZiZQ==", - "name": "John Smith", - "email": "john.smith@example.com", - "avatar_url": "https://www.gravatar.com/avatar/abc123...", - "created_at": "2012-03-04T06:07:08.910Z" - } - `)), - }, - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader("[]")), - }, - } - // mock a failed repsonse - transport := roundTripperFunc(func(r *http.Request) (*http.Response, error) { - resp := responses[callIndex] - callIndex++ - return &resp, nil - }) - client := &http.Client{Transport: transport} - fs := afero.NewMemMapFs() - f := &factory.Factory{ - RestAPIClient: buildkite.NewClient(client), - Config: config.New(fs, nil), - } - - r := resolver.ResolveBuildForCurrentUser("main", pipelineResolver, f) - build, err := r(context.Background()) - - if err == nil { - t.Fatal("Should return an error when no build is found") - } - - if build != nil { - t.Fatal("Expected no build to be found") - } - }) } type roundTripperFunc func(*http.Request) (*http.Response, error) diff --git a/internal/build/resolver/userid.go b/internal/build/resolver/userid.go new file mode 100644 index 00000000..8d10bf5a --- /dev/null +++ b/internal/build/resolver/userid.go @@ -0,0 +1,17 @@ +package resolver + +import ( + "context" + + "github.com/buildkite/cli/v3/internal/build" + pipelineResolver "github.com/buildkite/cli/v3/internal/pipeline/resolver" + "github.com/buildkite/cli/v3/pkg/cmd/factory" +) + +// ResolveBuildForUserID Finds the most recent build for the user based on the user's UUID +func ResolveBuildForUserID(uuid string, pipelineResolver pipelineResolver.PipelineResolverFn, f *factory.Factory) BuildResolverFn { + return func(ctx context.Context) (*build.Build, error) { + + return ResolveBuildForUser(ctx, uuid, "", pipelineResolver, f) + } +} diff --git a/internal/build/resolver/userid_test.go b/internal/build/resolver/userid_test.go new file mode 100644 index 00000000..0522e067 --- /dev/null +++ b/internal/build/resolver/userid_test.go @@ -0,0 +1,99 @@ +package resolver_test + +import ( + "bytes" + "context" + "io" + "net/http" + "os" + "strings" + "testing" + + "github.com/buildkite/cli/v3/internal/build/resolver" + "github.com/buildkite/cli/v3/internal/config" + "github.com/buildkite/cli/v3/internal/pipeline" + "github.com/buildkite/cli/v3/pkg/cmd/factory" + "github.com/buildkite/go-buildkite/v3/buildkite" + "github.com/spf13/afero" +) + +func TestResolveBuildFromUserUUID(t *testing.T) { + t.Parallel() + + pipelineResolver := func(context.Context) (*pipeline.Pipeline, error) { + return &pipeline.Pipeline{ + Name: "testing", + Org: "test org", + }, nil + } + + t.Run("Errors when user id is not a member of the organization", func(t *testing.T) { + t.Parallel() + // mock a failed repsonse + transport := roundTripperFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNotFound, + }, nil + }) + client := &http.Client{Transport: transport} + fs := afero.NewMemMapFs() + f := &factory.Factory{ + RestAPIClient: buildkite.NewClient(client), + Config: config.New(fs, nil), + } + + r := resolver.ResolveBuildForUserID("1234", pipelineResolver, f) + _, err := r(context.Background()) + + if err == nil { + t.Fatal("Resolver should return error if user not found") + } + }) + + t.Run("Returns first build found", func(t *testing.T) { + t.Parallel() + + in, _ := os.ReadFile("../../../fixtures/build.json") + callIndex := 0 + responses := []http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`[{ + "id": "abc123-4567-8910-...", + "number": 584, + "creator": { + "id": "0183c4e6-c88c-xxxx-b15e-7801077a9181", + "graphql_id": "VXNlci0tLTAxODNjNGU2LWM4OGxxxxxxxxxiMTVlLTc4MDEwNzdhOTE4MQ==" + } + }]`)), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(in)), + }, + } + // mock a failed repsonse + transport := roundTripperFunc(func(r *http.Request) (*http.Response, error) { + resp := responses[callIndex] + callIndex++ + return &resp, nil + }) + + client := &http.Client{Transport: transport} + fs := afero.NewMemMapFs() + f := &factory.Factory{ + RestAPIClient: buildkite.NewClient(client), + Config: config.New(fs, nil), + } + + r := resolver.ResolveBuildForUserID("0183c4e6-c88c-xxxx-b15e-7801077a9181", pipelineResolver, f) + build, err := r(context.Background()) + if err != nil { + t.Fatal(err) + } + + if build.BuildNumber != 584 { + t.Fatalf("Expected build 584, got %d", build.BuildNumber) + } + }) +}