diff --git a/doc/rfc/stovepipe/list-api.md b/doc/rfc/stovepipe/list-api.md index cbaf27f1a..fef8dc003 100644 --- a/doc/rfc/stovepipe/list-api.md +++ b/doc/rfc/stovepipe/list-api.md @@ -1,6 +1,6 @@ # Stovepipe List API -The [protobuf contract](../../../api/stovepipe/proto/stovepipe.proto) defines the approved API. Summary fields, acceptance-mapping storage, and the List controller are implemented; RPC/server wiring and end-to-end coverage remain. +The [protobuf contract](../../../api/stovepipe/proto/stovepipe.proto) defines the approved API. Summary fields, acceptance-mapping storage, the List controller, and RPC/server wiring are implemented; end-to-end coverage remains. ## Proposal diff --git a/service/stovepipe/server/handler/BUILD.bazel b/service/stovepipe/server/handler/BUILD.bazel index cbe97ba5f..ce8429cc7 100644 --- a/service/stovepipe/server/handler/BUILD.bazel +++ b/service/stovepipe/server/handler/BUILD.bazel @@ -14,12 +14,16 @@ go_library( go_test( name = "go_default_test", - srcs = ["server_test.go"], + srcs = [ + "list_test.go", + "server_test.go", + ], embed = [":go_default_library"], deps = [ "//api/stovepipe/protopb:go_default_library", "//stovepipe/controller:go_default_library", "//stovepipe/entity:go_default_library", + "//stovepipe/extension/storage:go_default_library", "@com_github_stretchr_testify//assert:go_default_library", "@com_github_stretchr_testify//require:go_default_library", ], diff --git a/service/stovepipe/server/handler/list_test.go b/service/stovepipe/server/handler/list_test.go new file mode 100644 index 000000000..76e195c5f --- /dev/null +++ b/service/stovepipe/server/handler/list_test.go @@ -0,0 +1,94 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// 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 handler + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + pb "github.com/uber/submitqueue/api/stovepipe/protopb" + "github.com/uber/submitqueue/stovepipe/controller" + "github.com/uber/submitqueue/stovepipe/entity" + "github.com/uber/submitqueue/stovepipe/extension/storage" +) + +type fakeListController struct { + list func(context.Context, entity.ListRequest) (entity.ListResult, error) +} + +var _ controller.ListController = (*fakeListController)(nil) + +func (f *fakeListController) List(ctx context.Context, req entity.ListRequest) (entity.ListResult, error) { + return f.list(ctx, req) +} + +func TestList(t *testing.T) { + page := entity.ListResult{ + Requests: []entity.RequestSummary{{RequestID: "7", Queue: "queue", State: entity.RequestStateAccepted}}, + NextPageToken: "next-token", + } + consistencyErr := &controller.ListConsistencyError{ + Queue: "queue", RequestID: "7", Reason: "acceptance mapping has no summary", Err: storage.ErrNotFound, + } + for _, tt := range []struct { + name string + result entity.ListResult + err error + }{ + {name: "page with continuation", result: page}, + {name: "empty page"}, + {name: "validation error", result: page, err: controller.ErrInvalidRequest}, + {name: "consistency error", result: page, err: consistencyErr}, + {name: "storage failure", err: errors.New("storage failed")}, + {name: "cancellation", err: context.Canceled}, + } { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + fake := &fakeListController{list: func(gotCtx context.Context, req entity.ListRequest) (entity.ListResult, error) { + assert.Same(t, ctx, gotCtx) + assert.Equal(t, entity.ListRequest{ + Queue: "queue", PageSize: 25, PageToken: "token", + HasAcceptedAtOrAfterMs: true, AcceptedBeforeMs: 1000, HasAcceptedBeforeMs: true, + }, req) + return tt.result, tt.err + }} + srv := NewStovepipeServer(nil, nil, nil, nil, fake) + response, err := srv.List(ctx, &pb.ListRequest{ + Queue: "queue", PageSize: 25, PageToken: "token", + AcceptedLowerBound: &pb.ListRequest_AcceptedAtOrAfterMs{}, + AcceptedUpperBound: &pb.ListRequest_AcceptedBeforeMs{AcceptedBeforeMs: 1000}, + }) + if tt.err != nil { + require.ErrorIs(t, err, tt.err) + assert.Same(t, tt.err, err) + assert.Nil(t, response) + return + } + require.NoError(t, err) + require.NotNil(t, response) + require.Len(t, response.GetRequests(), len(tt.result.Requests)) + if len(tt.result.Requests) > 0 { + assert.Equal(t, "7", response.GetRequests()[0].GetRequestId()) + assert.Equal(t, "queue", response.GetRequests()[0].GetQueue()) + assert.Equal(t, "accepted", response.GetRequests()[0].GetRequestState()) + } + assert.Equal(t, tt.result.NextPageToken, response.GetNextPageToken()) + }) + } +} diff --git a/service/stovepipe/server/handler/server.go b/service/stovepipe/server/handler/server.go index d0de4a8ac..37f31608c 100644 --- a/service/stovepipe/server/handler/server.go +++ b/service/stovepipe/server/handler/server.go @@ -30,6 +30,7 @@ type StovepipeServer struct { ingestController *controller.IngestController requestHistoryController controller.RequestHistoryController projectStatusController *controller.GetProjectStatusByURIController + listController controller.ListController } // NewStovepipeServer creates a gRPC service handler from Stovepipe controllers. @@ -38,12 +39,14 @@ func NewStovepipeServer( ingestController *controller.IngestController, requestHistoryController controller.RequestHistoryController, projectStatusController *controller.GetProjectStatusByURIController, + listController controller.ListController, ) *StovepipeServer { return &StovepipeServer{ pingController: pingController, ingestController: ingestController, requestHistoryController: requestHistoryController, projectStatusController: projectStatusController, + listController: listController, } } @@ -88,3 +91,12 @@ func (s *StovepipeServer) GetProjectStatusByURI(ctx context.Context, req *pb.Get } return mapper.GetProjectStatusByURIResultToProto(result), nil } + +// List returns one queue's current request summaries in acceptance-time order. +func (s *StovepipeServer) List(ctx context.Context, req *pb.ListRequest) (*pb.ListResponse, error) { + result, err := s.listController.List(ctx, mapper.ProtoToListRequest(req)) + if err != nil { + return nil, err + } + return mapper.ListResultToProto(result), nil +} diff --git a/service/stovepipe/server/main.go b/service/stovepipe/server/main.go index dee404ef9..c7055dd96 100644 --- a/service/stovepipe/server/main.go +++ b/service/stovepipe/server/main.go @@ -286,7 +286,8 @@ func run() error { ) requestHistoryController := controller.NewRequestHistoryController(logger.Sugar(), scope, storageFty) projectStatusController := controller.NewGetProjectStatusByURIController(logger.Sugar(), scope, storageFty) - srv := handler.NewStovepipeServer(pingController, ingestController, requestHistoryController, projectStatusController) + listController := controller.NewListController(logger.Sugar(), scope, storageFty, tenants) + srv := handler.NewStovepipeServer(pingController, ingestController, requestHistoryController, projectStatusController, listController) pb.RegisterStovepipeServer(grpcServer, srv) // Register reflection service for debugging with grpcurl diff --git a/service/stovepipe/server/mapper/BUILD.bazel b/service/stovepipe/server/mapper/BUILD.bazel index 992eca5a5..dbe7f1baa 100644 --- a/service/stovepipe/server/mapper/BUILD.bazel +++ b/service/stovepipe/server/mapper/BUILD.bazel @@ -4,6 +4,7 @@ go_library( name = "go_default_library", srcs = [ "ingest.go", + "list.go", "project_status.go", "request_history.go", ], @@ -19,6 +20,7 @@ go_test( name = "go_default_test", srcs = [ "ingest_test.go", + "list_test.go", "project_status_test.go", "request_history_test.go", ], @@ -27,5 +29,6 @@ go_test( "//api/stovepipe/protopb:go_default_library", "//stovepipe/entity:go_default_library", "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", ], ) diff --git a/service/stovepipe/server/mapper/list.go b/service/stovepipe/server/mapper/list.go new file mode 100644 index 000000000..693b9260f --- /dev/null +++ b/service/stovepipe/server/mapper/list.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// 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 mapper + +import ( + pb "github.com/uber/submitqueue/api/stovepipe/protopb" + "github.com/uber/submitqueue/stovepipe/entity" +) + +// ProtoToListRequest preserves time-bound presence, leaving defaults and validation to the controller. +func ProtoToListRequest(req *pb.ListRequest) entity.ListRequest { + return entity.ListRequest{ + Queue: req.GetQueue(), + AcceptedAtOrAfterMs: req.GetAcceptedAtOrAfterMs(), + HasAcceptedAtOrAfterMs: req.GetAcceptedLowerBound() != nil, + AcceptedBeforeMs: req.GetAcceptedBeforeMs(), + HasAcceptedBeforeMs: req.GetAcceptedUpperBound() != nil, + PageSize: req.GetPageSize(), + PageToken: req.GetPageToken(), + } +} + +// ListResultToProto preserves request order and the continuation token. +func ListResultToProto(result entity.ListResult) *pb.ListResponse { + requests := make([]*pb.RequestSummary, len(result.Requests)) + for i, summary := range result.Requests { + requests[i] = RequestSummaryToProto(summary) + } + return &pb.ListResponse{Requests: requests, NextPageToken: result.NextPageToken} +} + +// RequestSummaryToProto exposes public summary fields, omitting internal storage versions. +func RequestSummaryToProto(summary entity.RequestSummary) *pb.RequestSummary { + return &pb.RequestSummary{ + RequestId: summary.RequestID, + Queue: summary.Queue, + ChangeUri: summary.URI, + BaseUri: summary.BaseURI, + RequestState: string(summary.State), + StateUpdatedAtMs: summary.StateTimestampMs, + AcceptedAtMs: summary.AcceptedAtMs, + OutcomeReason: string(summary.OutcomeReason), + } +} diff --git a/service/stovepipe/server/mapper/list_test.go b/service/stovepipe/server/mapper/list_test.go new file mode 100644 index 000000000..19f4a3ec9 --- /dev/null +++ b/service/stovepipe/server/mapper/list_test.go @@ -0,0 +1,125 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// 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 mapper + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + pb "github.com/uber/submitqueue/api/stovepipe/protopb" + "github.com/uber/submitqueue/stovepipe/entity" +) + +func TestProtoToListRequest(t *testing.T) { + for _, tt := range []struct { + name string + req *pb.ListRequest + want entity.ListRequest + }{ + {name: "nil request"}, + {name: "omitted bounds", req: &pb.ListRequest{Queue: "queue"}, want: entity.ListRequest{Queue: "queue"}}, + { + name: "explicit zero lower", + req: &pb.ListRequest{AcceptedLowerBound: &pb.ListRequest_AcceptedAtOrAfterMs{}}, + want: entity.ListRequest{HasAcceptedAtOrAfterMs: true}, + }, + { + name: "explicit zero upper", + req: &pb.ListRequest{AcceptedUpperBound: &pb.ListRequest_AcceptedBeforeMs{}}, + want: entity.ListRequest{HasAcceptedBeforeMs: true}, + }, + { + name: "explicit window and pagination", + req: &pb.ListRequest{ + Queue: "queue", PageSize: 25, PageToken: "token", + AcceptedLowerBound: &pb.ListRequest_AcceptedAtOrAfterMs{AcceptedAtOrAfterMs: 100}, + AcceptedUpperBound: &pb.ListRequest_AcceptedBeforeMs{AcceptedBeforeMs: 1000}, + }, + want: entity.ListRequest{ + Queue: "queue", PageSize: 25, PageToken: "token", + AcceptedAtOrAfterMs: 100, HasAcceptedAtOrAfterMs: true, + AcceptedBeforeMs: 1000, HasAcceptedBeforeMs: true, + }, + }, + { + name: "continuation with omitted bounds", + req: &pb.ListRequest{Queue: "queue", PageSize: 10, PageToken: "token"}, + want: entity.ListRequest{Queue: "queue", PageSize: 10, PageToken: "token"}, + }, + { + name: "invalid values reach controller unchanged", + req: &pb.ListRequest{ + PageSize: -1, AcceptedLowerBound: &pb.ListRequest_AcceptedAtOrAfterMs{AcceptedAtOrAfterMs: -100}, + }, + want: entity.ListRequest{PageSize: -1, AcceptedAtOrAfterMs: -100, HasAcceptedAtOrAfterMs: true}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, ProtoToListRequest(tt.req)) + }) + } +} + +func TestRequestSummaryToProto(t *testing.T) { + for _, tt := range []struct { + name string + state entity.RequestState + reason entity.RequestOutcomeReason + }{ + {name: "known vocabulary", state: entity.RequestStateFailed, reason: entity.RequestOutcomeReasonBuildFailed}, + {name: "unknown vocabulary"}, + {name: "future vocabulary", state: "future_state", reason: "future_reason"}, + } { + t.Run(tt.name, func(t *testing.T) { + response := RequestSummaryToProto(entity.RequestSummary{ + RequestID: "7", Queue: "queue", URI: "git://repo/change", BaseURI: "git://repo/base", + State: tt.state, StateTimestampMs: 2000, AcceptedAtMs: 1000, OutcomeReason: tt.reason, + }) + assert.Equal(t, &pb.RequestSummary{ + RequestId: "7", Queue: "queue", ChangeUri: "git://repo/change", BaseUri: "git://repo/base", + RequestState: string(tt.state), StateUpdatedAtMs: 2000, AcceptedAtMs: 1000, OutcomeReason: string(tt.reason), + }, response) + }) + } +} + +func TestListResultToProto(t *testing.T) { + for _, tt := range []struct { + name string + result entity.ListResult + ids []string + }{ + {name: "empty page", ids: []string{}}, + { + name: "preserves order and continuation", + result: entity.ListResult{ + Requests: []entity.RequestSummary{{RequestID: "9"}, {RequestID: "10"}}, NextPageToken: "next-token", + }, + ids: []string{"9", "10"}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + response := ListResultToProto(tt.result) + require.NotNil(t, response) + ids := make([]string, 0, len(response.GetRequests())) + for _, summary := range response.GetRequests() { + ids = append(ids, summary.GetRequestId()) + } + assert.Equal(t, tt.ids, ids) + assert.Equal(t, tt.result.NextPageToken, response.GetNextPageToken()) + }) + } +}