Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion doc/rfc/stovepipe/list-api.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
6 changes: 5 additions & 1 deletion service/stovepipe/server/handler/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
Expand Down
94 changes: 94 additions & 0 deletions service/stovepipe/server/handler/list_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
}
}
12 changes: 12 additions & 0 deletions service/stovepipe/server/handler/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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
}
3 changes: 2 additions & 1 deletion service/stovepipe/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions service/stovepipe/server/mapper/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go_library(
name = "go_default_library",
srcs = [
"ingest.go",
"list.go",
"project_status.go",
"request_history.go",
],
Expand All @@ -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",
],
Expand All @@ -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",
],
)
56 changes: 56 additions & 0 deletions service/stovepipe/server/mapper/list.go
Original file line number Diff line number Diff line change
@@ -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),
}
}
125 changes: 125 additions & 0 deletions service/stovepipe/server/mapper/list_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
}
}
Loading