[milo] implement QueryRecentBuilds
R=mwarton, nqmtuan
Bug: 1218206
Change-Id: I50ab7706419a99b7e8f4010cdf013ba31b392cd4
Reviewed-on: https://chromium-review.googlesource.com/c/infra/luci/luci-go/+/3233702
Commit-Queue: Weiwei Lin <weiweilin@google.com>
Reviewed-by: Matthew Warton <mwarton@google.com>
diff --git a/milo/backend/query_recent_builds.go b/milo/backend/query_recent_builds.go
index b8e8139..86c6131 100644
--- a/milo/backend/query_recent_builds.go
+++ b/milo/backend/query_recent_builds.go
@@ -1,4 +1,4 @@
-// Copyright 2020 The LUCI Authors.
+// Copyright 2021 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -16,14 +16,113 @@
import (
"context"
+ "strconv"
+ "go.chromium.org/luci/auth/identity"
+ buildbucketpb "go.chromium.org/luci/buildbucket/proto"
+ "go.chromium.org/luci/common/errors"
+ "go.chromium.org/luci/gae/service/datastore"
"go.chromium.org/luci/grpc/appstatus"
milopb "go.chromium.org/luci/milo/api/service/v1"
+ "go.chromium.org/luci/milo/common"
+ "go.chromium.org/luci/milo/common/model"
+ "go.chromium.org/luci/server/auth"
"google.golang.org/grpc/codes"
+ "google.golang.org/protobuf/types/known/timestamppb"
)
+var queryRecentBuildsPageSize = PageSizeLimiter{
+ Max: 100,
+ Default: 25,
+}
+
// QueryRecentBuilds implements milopb.MiloInternal service
func (s *MiloInternalService) QueryRecentBuilds(ctx context.Context, req *milopb.QueryRecentBuildsRequest) (_ *milopb.QueryRecentBuildsResponse, err error) {
defer func() { err = appstatus.GRPCifyAndLog(ctx, err) }()
- return nil, appstatus.Error(codes.Unimplemented, "unimplemented")
+
+ allowed, err := common.IsAllowed(ctx, req.GetBuilder().GetProject())
+ if err != nil {
+ return nil, err
+ }
+ if !allowed {
+ if auth.CurrentIdentity(ctx) == identity.AnonymousIdentity {
+ return nil, appstatus.Error(codes.Unauthenticated, "not logged in")
+ }
+ return nil, appstatus.Error(codes.PermissionDenied, "no access to the project")
+ }
+
+ err = validatesQueryRecentBuildsRequest(req)
+ if err != nil {
+ return nil, appstatus.BadRequest(err)
+ }
+
+ cur, err := decodeCursor(ctx, req.PageToken)
+ if err != nil {
+ return nil, appstatus.Error(codes.InvalidArgument, "invalid page token")
+ }
+
+ pageSize := int(queryRecentBuildsPageSize.Adjust(req.PageSize))
+
+ legacyBuilderID := common.LegacyBuilderIDString(req.Builder)
+ q := datastore.NewQuery("BuildSummary").
+ Eq("BuilderID", legacyBuilderID).
+ Order("-Created").
+ Start(cur)
+
+ recentBuilds := make([]*buildbucketpb.Build, 0, pageSize)
+ nextPageToken := ""
+ err = datastore.Run(ctx, q, func(b *model.BuildSummary, getCursor datastore.CursorCB) error {
+ if !b.Summary.Status.Terminal() {
+ return nil
+ }
+
+ var buildID int64 = 0
+ _, buildNum, err := common.ParseLegacyBuildID(b.BuildID)
+ if err != nil {
+ // If the BuildID is not the legacy build ID, trying parsing it as
+ // the new build ID.
+ buildID, err = strconv.ParseInt(b.BuildID, 10, 64)
+ if err != nil {
+ return err
+ }
+ }
+
+ recentBuilds = append(recentBuilds, &buildbucketpb.Build{
+ Id: buildID,
+ Number: buildNum,
+ Builder: req.Builder,
+ Status: b.Summary.Status.ToBuildbucket(),
+ CreateTime: timestamppb.New(b.Created),
+ })
+
+ if len(recentBuilds) == pageSize {
+ cursor, err := getCursor()
+ if err != nil {
+ return err
+ }
+ nextPageToken = cursor.String()
+
+ return datastore.Stop
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return &milopb.QueryRecentBuildsResponse{
+ Builds: recentBuilds,
+ NextPageToken: nextPageToken,
+ }, nil
+}
+
+func validatesQueryRecentBuildsRequest(req *milopb.QueryRecentBuildsRequest) error {
+ switch {
+ case req.PageSize < 0:
+ return errors.Reason("page_size can not be negative").Err()
+ case req.Builder == nil || req.Builder.Project == "" || req.Builder.Bucket == "" || req.Builder.Builder == "":
+ return errors.Reason("builder_id is required").Err()
+ default:
+ return nil
+ }
}
diff --git a/milo/backend/query_recent_builds_test.go b/milo/backend/query_recent_builds_test.go
new file mode 100644
index 0000000..1ecaf39
--- /dev/null
+++ b/milo/backend/query_recent_builds_test.go
@@ -0,0 +1,145 @@
+// Copyright 2021 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package backend
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ . "github.com/smartystreets/goconvey/convey"
+ "go.chromium.org/luci/auth/identity"
+ buildbucketpb "go.chromium.org/luci/buildbucket/proto"
+ "go.chromium.org/luci/gae/impl/memory"
+ "go.chromium.org/luci/gae/service/datastore"
+ milopb "go.chromium.org/luci/milo/api/service/v1"
+ "go.chromium.org/luci/milo/common"
+ "go.chromium.org/luci/milo/common/model"
+ "go.chromium.org/luci/milo/common/model/milostatus"
+ "go.chromium.org/luci/server/auth"
+ "go.chromium.org/luci/server/auth/authtest"
+ "google.golang.org/protobuf/types/known/timestamppb"
+)
+
+func TestQueryRecentBuilds(t *testing.T) {
+ t.Parallel()
+ Convey(`TestQueryRecentBuilds`, t, func() {
+ ctx := memory.Use(context.Background())
+ datastore.GetTestable(ctx).AddIndexes(&datastore.IndexDefinition{
+ Kind: "BuildSummary",
+ SortBy: []datastore.IndexColumn{
+ {Property: "BuilderID"},
+ {Property: "Created", Descending: true},
+ },
+ })
+ datastore.GetTestable(ctx).Consistent(true)
+ srv := &MiloInternalService{}
+
+ builder1 := &buildbucketpb.BuilderID{
+ Project: "fake_project",
+ Bucket: "fake_bucket",
+ Builder: "fake_builder1",
+ }
+ builder2 := &buildbucketpb.BuilderID{
+ Project: "fake_project",
+ Bucket: "fake_bucket",
+ Builder: "fake_builder2",
+ }
+
+ createFakeBuild := func(builder *buildbucketpb.BuilderID, buildNum int, createdAt time.Time, status milostatus.Status) *model.BuildSummary {
+ builderID := common.LegacyBuilderIDString(builder)
+ buildID := fmt.Sprintf("%s/%d", builderID, buildNum)
+ return &model.BuildSummary{
+ BuildKey: datastore.MakeKey(ctx, "build", buildID),
+ ProjectID: builder.Project,
+ BuilderID: builderID,
+ BuildID: buildID,
+ Summary: model.Summary{
+ Status: status,
+ },
+ Created: createdAt,
+ }
+ }
+
+ baseTime := time.Date(2020, 0, 0, 0, 0, 0, 0, time.UTC)
+ builds := []*model.BuildSummary{
+ createFakeBuild(builder1, 1, baseTime.AddDate(0, 0, -5), milostatus.Running),
+ createFakeBuild(builder1, 2, baseTime.AddDate(0, 0, -4), milostatus.Success),
+ createFakeBuild(builder2, 1, baseTime.AddDate(0, 0, -3), milostatus.Success),
+ createFakeBuild(builder1, 3, baseTime.AddDate(0, 0, -2), milostatus.Failure),
+ createFakeBuild(builder1, 4, baseTime.AddDate(0, 0, -1), milostatus.InfraFailure),
+ }
+
+ err := datastore.Put(ctx, builds)
+ So(err, ShouldBeNil)
+
+ err = datastore.Put(ctx, &common.Project{
+ ID: "fake_project",
+ ACL: common.ACL{Identities: []identity.Identity{"user"}},
+ LogoURL: "https://logo.com",
+ })
+ So(err, ShouldBeNil)
+
+ Convey(`get all recent builds`, func() {
+ c := auth.WithState(ctx, &authtest.FakeState{Identity: "user"})
+
+ res, err := srv.QueryRecentBuilds(c, &milopb.QueryRecentBuildsRequest{
+ Builder: builder1,
+ PageSize: 2,
+ })
+ So(err, ShouldBeNil)
+ So(res.Builds, ShouldResemble, []*buildbucketpb.Build{
+ {
+ Builder: builder1,
+ Number: 4,
+ Status: buildbucketpb.Status_INFRA_FAILURE,
+ CreateTime: timestamppb.New(builds[4].Created),
+ },
+ {
+ Builder: builder1,
+ Number: 3,
+ Status: buildbucketpb.Status_FAILURE,
+ CreateTime: timestamppb.New(builds[3].Created),
+ },
+ })
+ So(res.NextPageToken, ShouldNotBeEmpty)
+
+ res, err = srv.QueryRecentBuilds(c, &milopb.QueryRecentBuildsRequest{
+ Builder: builder1,
+ PageSize: 2,
+ PageToken: res.NextPageToken,
+ })
+ So(err, ShouldBeNil)
+ So(res.Builds, ShouldResemble, []*buildbucketpb.Build{
+ {
+ Builder: builder1,
+ Number: 2,
+ Status: buildbucketpb.Status_SUCCESS,
+ CreateTime: timestamppb.New(builds[1].Created),
+ },
+ })
+ So(res.NextPageToken, ShouldBeEmpty)
+ })
+
+ Convey(`reject users with no access`, func() {
+ _, err := srv.QueryRecentBuilds(ctx, &milopb.QueryRecentBuildsRequest{
+ Builder: builder1,
+ PageSize: 2,
+ })
+ So(err, ShouldNotBeNil)
+ })
+ })
+}
diff --git a/milo/backend/utils.go b/milo/backend/utils.go
index dd471f3..417adc5 100644
--- a/milo/backend/utils.go
+++ b/milo/backend/utils.go
@@ -14,6 +14,12 @@
package backend
+import (
+ "context"
+
+ "go.chromium.org/luci/gae/service/datastore"
+)
+
type PageSizeLimiter struct {
Max int32
Default int32
@@ -31,3 +37,12 @@
return psl.Default
}
}
+
+// decodeCursor is a wrapper around datastore.DecodeCursor. It treats empty
+// page token as nil Cursor.
+func decodeCursor(ctx context.Context, pageToken string) (datastore.Cursor, error) {
+ if pageToken == "" {
+ return nil, nil
+ }
+ return datastore.DecodeCursor(ctx, pageToken)
+}