[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)
+}