From 10fb54e90c05172a350e4f42268572e968a1411f Mon Sep 17 00:00:00 2001 From: mnoah1 Date: Fri, 28 Aug 2026 14:21:46 +0000 Subject: [PATCH] feat(stovepipe): serve repository validation status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: This PR builds on #637, which defines the GetProjectStatusByURI contract and rollout. Intent: - Expose the authoritative repository validation for an exact queue and commit URI. - Deliver the repository-only rollout before project-list persistence is available. Changes: - Resolve URI mappings through queue-bound storage and verify request and fact identity. - Preserve the distinction between a missing repository fact and a recorded green result. - Project internal lifecycle states into a stable public request-state vocabulary. - Return project results as empty and incomplete until project persistence is implemented. - Translate defined controller outcomes into stable gRPC status codes. --- Generated by the 🪄 [pr-create](https://sg.uberinternal.com/code.uber.internal/uber-code/devexp-agent-marketplace/-/blob/claude-code/plugins/dev/uber-dev/skills/pr-create/SKILL.md) skill in devexp-agent-marketplace --- service/stovepipe/server/main.go | 20 +- service/stovepipe/server/mapper/BUILD.bazel | 11 +- .../mapper/get_project_status_by_uri.go | 75 +++++ .../mapper/get_project_status_by_uri_test.go | 115 ++++++++ stovepipe/controller/BUILD.bazel | 3 + .../controller/get_project_status_by_uri.go | 217 ++++++++++++++ .../get_project_status_by_uri_test.go | 273 ++++++++++++++++++ stovepipe/entity/BUILD.bazel | 1 + stovepipe/entity/get_project_status_by_uri.go | 41 +++ 9 files changed, 750 insertions(+), 6 deletions(-) create mode 100644 service/stovepipe/server/mapper/get_project_status_by_uri.go create mode 100644 service/stovepipe/server/mapper/get_project_status_by_uri_test.go create mode 100644 stovepipe/controller/get_project_status_by_uri.go create mode 100644 stovepipe/controller/get_project_status_by_uri_test.go create mode 100644 stovepipe/entity/get_project_status_by_uri.go diff --git a/service/stovepipe/server/main.go b/service/stovepipe/server/main.go index 06c932904..3ebd73d5a 100644 --- a/service/stovepipe/server/main.go +++ b/service/stovepipe/server/main.go @@ -65,8 +65,9 @@ import ( // StovepipeServer wraps the controllers and implements the gRPC service interface. type StovepipeServer struct { pb.UnimplementedStovepipeServer - pingController *controller.PingController - ingestController *controller.IngestController + pingController *controller.PingController + ingestController *controller.IngestController + getProjectStatusByURIController *controller.GetProjectStatusByURIController } // Ping delegates to the controller. @@ -84,6 +85,15 @@ func (s *StovepipeServer) Ingest(ctx context.Context, req *pb.IngestRequest) (*p return mapper.IngestResultToProto(result), nil } +// GetProjectStatusByURI returns current repository validation for an exact commit URI. +func (s *StovepipeServer) GetProjectStatusByURI(ctx context.Context, req *pb.GetProjectStatusByURIRequest) (*pb.GetProjectStatusByURIResponse, error) { + result, err := s.getProjectStatusByURIController.GetProjectStatusByURI(ctx, mapper.ProtoToGetProjectStatusByURIRequest(req)) + if err != nil { + return nil, err + } + return mapper.GetProjectStatusByURIResultToProto(result) +} + // inMemoryCounter is a minimal, process-local counter.Counter used to wire the example // server. It is not durable; a real deployment supplies a persistent implementation // (e.g. platform/extension/counter/mysql). @@ -336,9 +346,11 @@ func run() error { storageFty, registry, ) + getProjectStatusByURIController := controller.NewGetProjectStatusByURIController(logger.Sugar(), scope, storageFty) srv := &StovepipeServer{ - pingController: pingController, - ingestController: ingestController, + pingController: pingController, + ingestController: ingestController, + getProjectStatusByURIController: getProjectStatusByURIController, } pb.RegisterStovepipeServer(grpcServer, srv) diff --git a/service/stovepipe/server/mapper/BUILD.bazel b/service/stovepipe/server/mapper/BUILD.bazel index 88d60b597..a4fc5fa82 100644 --- a/service/stovepipe/server/mapper/BUILD.bazel +++ b/service/stovepipe/server/mapper/BUILD.bazel @@ -2,7 +2,10 @@ load("@rules_go//go:def.bzl", "go_library", "go_test") go_library( name = "go_default_library", - srcs = ["ingest.go"], + srcs = [ + "get_project_status_by_uri.go", + "ingest.go", + ], importpath = "github.com/uber/submitqueue/service/stovepipe/server/mapper", visibility = ["//visibility:public"], deps = [ @@ -13,11 +16,15 @@ go_library( go_test( name = "go_default_test", - srcs = ["ingest_test.go"], + srcs = [ + "get_project_status_by_uri_test.go", + "ingest_test.go", + ], embed = [":go_default_library"], deps = [ "//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/get_project_status_by_uri.go b/service/stovepipe/server/mapper/get_project_status_by_uri.go new file mode 100644 index 000000000..cc25911ff --- /dev/null +++ b/service/stovepipe/server/mapper/get_project_status_by_uri.go @@ -0,0 +1,75 @@ +// 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 ( + "fmt" + + pb "github.com/uber/submitqueue/api/stovepipe/protopb" + "github.com/uber/submitqueue/stovepipe/entity" +) + +// ProtoToGetProjectStatusByURIRequest maps the wire selector to its domain form. +func ProtoToGetProjectStatusByURIRequest(req *pb.GetProjectStatusByURIRequest) entity.GetProjectStatusByURIRequest { + result := entity.GetProjectStatusByURIRequest{ + Queue: req.GetQueue(), + ChangeURI: req.GetChangeUri(), + PageSize: req.GetPageSize(), + PageToken: req.GetPageToken(), + } + if req.Project != nil { + result.Project = req.GetProject() + result.HasProject = true + } + return result +} + +// GetProjectStatusByURIResultToProto maps a domain projection to the wire response. +func GetProjectStatusByURIResultToProto(result entity.GetProjectStatusByURIResult) (*pb.GetProjectStatusByURIResponse, error) { + requestState, err := projectStatusRequestStateToProto(result.Request.State) + if err != nil { + return nil, err + } + response := &pb.GetProjectStatusByURIResponse{ + RequestId: result.Request.ID, + Queue: result.Request.Queue, + ChangeUri: result.Request.URI, + BaseUri: result.Request.BaseURI, + RequestState: requestState, + } + if result.HasRepositoryValidationFact { + response.RepositoryBreakageDegree = &result.RepositoryValidationFact.Degree + } + return response, nil +} + +func projectStatusRequestStateToProto(state entity.RequestState) (pb.RequestState, error) { + switch state { + case entity.RequestStateAccepted: + return pb.RequestState_REQUEST_STATE_ACCEPTED, nil + case entity.RequestStateProcessing: + return pb.RequestState_REQUEST_STATE_PROCESSING, nil + case entity.RequestStateSucceeded: + return pb.RequestState_REQUEST_STATE_SUCCEEDED, nil + case entity.RequestStateFailed: + return pb.RequestState_REQUEST_STATE_FAILED, nil + case entity.RequestStateCancelled: + return pb.RequestState_REQUEST_STATE_CANCELLED, nil + case entity.RequestStateSuperseded: + return pb.RequestState_REQUEST_STATE_SUPERSEDED, nil + default: + return pb.RequestState_REQUEST_STATE_UNSPECIFIED, fmt.Errorf("request state %q cannot be represented by GetProjectStatusByURI", state) + } +} diff --git a/service/stovepipe/server/mapper/get_project_status_by_uri_test.go b/service/stovepipe/server/mapper/get_project_status_by_uri_test.go new file mode 100644 index 000000000..9a9f7bbc4 --- /dev/null +++ b/service/stovepipe/server/mapper/get_project_status_by_uri_test.go @@ -0,0 +1,115 @@ +// 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 TestProtoToGetProjectStatusByURIRequest(t *testing.T) { + project := "" + got := ProtoToGetProjectStatusByURIRequest(&pb.GetProjectStatusByURIRequest{ + Queue: "monorepo/main", ChangeUri: "git://commit", Project: &project, PageSize: 10, PageToken: "token", + }) + + assert.Equal(t, entity.GetProjectStatusByURIRequest{ + Queue: "monorepo/main", ChangeURI: "git://commit", Project: "", HasProject: true, PageSize: 10, PageToken: "token", + }, got) + + omitted := ProtoToGetProjectStatusByURIRequest(&pb.GetProjectStatusByURIRequest{}) + assert.False(t, omitted.HasProject) +} + +func TestGetProjectStatusByURIResultToProto(t *testing.T) { + t.Run("preserves optional field presence", func(t *testing.T) { + result := entity.GetProjectStatusByURIResult{ + Request: entity.Request{ + ID: "request/monorepo/main/7", Queue: "monorepo/main", URI: "git://commit", + BaseURI: "git://base", State: entity.RequestStateSucceeded, + }, + RepositoryValidationFact: entity.ValidationFact{Degree: entity.DegreeGreen}, + HasRepositoryValidationFact: true, + } + + got, err := GetProjectStatusByURIResultToProto(result) + + require.NoError(t, err) + assert.Equal(t, result.Request.ID, got.GetRequestId()) + assert.Equal(t, result.Request.Queue, got.GetQueue()) + assert.Equal(t, result.Request.URI, got.GetChangeUri()) + assert.Equal(t, result.Request.BaseURI, got.GetBaseUri()) + assert.NotNil(t, got.RepositoryBreakageDegree) + assert.Equal(t, entity.DegreeGreen, got.GetRepositoryBreakageDegree()) + assert.Equal(t, pb.RequestState_REQUEST_STATE_SUCCEEDED, got.GetRequestState()) + assert.Empty(t, got.Projects) + assert.False(t, got.ProjectResultsComplete) + assert.Empty(t, got.NextPageToken) + }) + + t.Run("keeps missing repository fact absent", func(t *testing.T) { + got, err := GetProjectStatusByURIResultToProto(entity.GetProjectStatusByURIResult{ + Request: entity.Request{State: entity.RequestStateAccepted}, + }) + + require.NoError(t, err) + assert.Nil(t, got.RepositoryBreakageDegree) + assert.Empty(t, got.Projects) + }) + + t.Run("rejects an unrecognized request state", func(t *testing.T) { + got, err := GetProjectStatusByURIResultToProto(entity.GetProjectStatusByURIResult{ + Request: entity.Request{State: "future"}, + }) + + require.Error(t, err) + assert.Nil(t, got) + }) +} + +func TestProjectStatusRequestStateToProto(t *testing.T) { + tests := []struct { + name string + state entity.RequestState + want pb.RequestState + wantError bool + }{ + {name: "accepted", state: entity.RequestStateAccepted, want: pb.RequestState_REQUEST_STATE_ACCEPTED}, + {name: "processing", state: entity.RequestStateProcessing, want: pb.RequestState_REQUEST_STATE_PROCESSING}, + {name: "succeeded", state: entity.RequestStateSucceeded, want: pb.RequestState_REQUEST_STATE_SUCCEEDED}, + {name: "failed", state: entity.RequestStateFailed, want: pb.RequestState_REQUEST_STATE_FAILED}, + {name: "cancelled", state: entity.RequestStateCancelled, want: pb.RequestState_REQUEST_STATE_CANCELLED}, + {name: "superseded", state: entity.RequestStateSuperseded, want: pb.RequestState_REQUEST_STATE_SUPERSEDED}, + {name: "unknown", state: entity.RequestStateUnknown, wantError: true}, + {name: "unrecognized", state: "future", wantError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := projectStatusRequestStateToProto(tt.state) + + if tt.wantError { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/stovepipe/controller/BUILD.bazel b/stovepipe/controller/BUILD.bazel index 4bbd1cffe..de940e252 100644 --- a/stovepipe/controller/BUILD.bazel +++ b/stovepipe/controller/BUILD.bazel @@ -3,6 +3,7 @@ load("@rules_go//go:def.bzl", "go_library", "go_test") go_library( name = "go_default_library", srcs = [ + "get_project_status_by_uri.go", "ingest.go", "ping.go", ], @@ -27,6 +28,7 @@ go_library( go_test( name = "go_default_test", srcs = [ + "get_project_status_by_uri_test.go", "ingest_test.go", "ping_test.go", ], @@ -34,6 +36,7 @@ go_test( deps = [ "//api/stovepipe/protopb:go_default_library", "//platform/consumer:go_default_library", + "//platform/errs:go_default_library", "//platform/extension/counter:go_default_library", "//platform/extension/counter/mock:go_default_library", "//platform/extension/messagequeue/mock:go_default_library", diff --git a/stovepipe/controller/get_project_status_by_uri.go b/stovepipe/controller/get_project_status_by_uri.go new file mode 100644 index 000000000..ef4565c14 --- /dev/null +++ b/stovepipe/controller/get_project_status_by_uri.go @@ -0,0 +1,217 @@ +// 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 controller + +import ( + "context" + "errors" + "fmt" + "math" + + "github.com/uber-go/tally" + "github.com/uber/submitqueue/platform/errs" + "github.com/uber/submitqueue/platform/metrics" + "github.com/uber/submitqueue/stovepipe/entity" + "github.com/uber/submitqueue/stovepipe/extension/storage" + "go.uber.org/zap" +) + +const ( + maxProjectStatusIdentifierBytes = 255 + maxProjectStatusPageSize = 200 +) + +// ProjectStatusNotFoundError indicates that the selected request or project does not exist. +type ProjectStatusNotFoundError struct { + Queue string + ChangeURI string + Project string +} + +// Error implements the error interface. +func (e *ProjectStatusNotFoundError) Error() string { + if e.Project != "" { + return fmt.Sprintf("project %q not found for queue %q and change URI %q", e.Project, e.Queue, e.ChangeURI) + } + return fmt.Sprintf("request not found for queue %q and change URI %q", e.Queue, e.ChangeURI) +} + +// IsProjectStatusNotFound returns true for a ProjectStatusNotFoundError in the error chain. +func IsProjectStatusNotFound(err error) bool { + var target *ProjectStatusNotFoundError + return errors.As(err, &target) +} + +// ProjectStatusConsistencyError indicates that records for the selected projection disagree. +type ProjectStatusConsistencyError struct { + Message string +} + +// Error implements the error interface. +func (e *ProjectStatusConsistencyError) Error() string { + return e.Message +} + +// IsProjectStatusConsistency returns true for a ProjectStatusConsistencyError in the error chain. +func IsProjectStatusConsistency(err error) bool { + var target *ProjectStatusConsistencyError + return errors.As(err, &target) +} + +// GetProjectStatusByURIController serves current repository validation status by commit URI. +type GetProjectStatusByURIController struct { + logger *zap.SugaredLogger + metricsScope tally.Scope + stores storage.Factory +} + +// NewGetProjectStatusByURIController creates a repository status lookup controller. +func NewGetProjectStatusByURIController(logger *zap.SugaredLogger, scope tally.Scope, stores storage.Factory) *GetProjectStatusByURIController { + return &GetProjectStatusByURIController{ + logger: logger, + metricsScope: scope.SubScope("get_project_status_by_uri_controller"), + stores: stores, + } +} + +// GetProjectStatusByURI returns the authoritative request and any whole-repository fact for a commit URI. +func (c *GetProjectStatusByURIController) GetProjectStatusByURI(ctx context.Context, req entity.GetProjectStatusByURIRequest) (result entity.GetProjectStatusByURIResult, retErr error) { + op := metrics.Begin(c.metricsScope, "get_project_status_by_uri", metrics.StorageLatencyBuckets) + defer func() { op.Complete(retErr) }() + + if err := validateProjectStatusRequest(req); err != nil { + return entity.GetProjectStatusByURIResult{}, err + } + + store, err := c.stores.For(storage.Config{QueueName: req.Queue}) + if err != nil { + return entity.GetProjectStatusByURIResult{}, fmt.Errorf("failed to resolve storage for queue %q: %w", req.Queue, err) + } + + request, err := loadProjectStatusRequest(ctx, store, req) + if err != nil { + return entity.GetProjectStatusByURIResult{}, err + } + + // Exact lookup needs the persisted project list to distinguish absent projects. + if req.HasProject { + return entity.GetProjectStatusByURIResult{}, &ProjectStatusNotFoundError{Queue: req.Queue, ChangeURI: req.ChangeURI, Project: req.Project} + } + + repositoryFact, hasRepositoryFact, err := loadRepositoryValidationFact(ctx, store.GetValidationFactStore(), request) + if err != nil { + return entity.GetProjectStatusByURIResult{}, err + } + result = entity.GetProjectStatusByURIResult{ + Request: request, + RepositoryValidationFact: repositoryFact, + HasRepositoryValidationFact: hasRepositoryFact, + } + + c.logger.Debugw( + "repository validation status retrieved", + "request_id", result.Request.ID, + "queue", result.Request.Queue, + "change_uri", result.Request.URI, + "has_repository_result", result.HasRepositoryValidationFact, + ) + return result, nil +} + +func loadProjectStatusRequest(ctx context.Context, store storage.Storage, req entity.GetProjectStatusByURIRequest) (entity.Request, error) { + requestID, err := store.GetRequestURIStore().GetIDByURI(ctx, req.ChangeURI) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return entity.Request{}, &ProjectStatusNotFoundError{Queue: req.Queue, ChangeURI: req.ChangeURI} + } + return entity.Request{}, fmt.Errorf("failed to resolve request for queue %q and change URI %q: %w", req.Queue, req.ChangeURI, err) + } + + request, err := store.GetRequestStore().Get(ctx, requestID) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + // Ingest persists the mapping before the Request, so this visibility gap is retryable. + return entity.Request{}, errs.NewRetryableError(fmt.Errorf("request %q mapped from queue %q and change URI %q is not visible yet", requestID, req.Queue, req.ChangeURI)) + } + return entity.Request{}, fmt.Errorf("failed to load request %q: %w", requestID, err) + } + if request.ID != requestID || request.Queue != req.Queue || request.URI != req.ChangeURI { + return entity.Request{}, &ProjectStatusConsistencyError{Message: fmt.Sprintf( + "request mapping disagrees with request: selected id=%q queue=%q change_uri=%q, loaded id=%q queue=%q change_uri=%q", + requestID, req.Queue, req.ChangeURI, request.ID, request.Queue, request.URI, + )} + } + return request, nil +} + +func loadRepositoryValidationFact(ctx context.Context, factStore storage.ValidationFactStore, request entity.Request) (entity.ValidationFact, bool, error) { + fact, err := factStore.Get(ctx, request.URI, "") + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return entity.ValidationFact{}, false, nil + } + return entity.ValidationFact{}, false, fmt.Errorf("failed to load repository validation fact for request %q: %w", request.ID, err) + } + if err := validateRepositoryValidationFact(fact, request); err != nil { + return entity.ValidationFact{}, false, err + } + return fact, true, nil +} + +func validateRepositoryValidationFact(fact entity.ValidationFact, request entity.Request) error { + if fact.URI != request.URI || fact.Project != "" || fact.RequestID != request.ID { + return &ProjectStatusConsistencyError{Message: fmt.Sprintf( + "repository validation fact disagrees with request %q: uri=%q project=%q request_id=%q", + request.ID, fact.URI, fact.Project, fact.RequestID, + )} + } + if math.IsNaN(fact.Degree) || fact.Degree < entity.DegreeGreen || fact.Degree > entity.DegreeBroken { + return &ProjectStatusConsistencyError{Message: fmt.Sprintf("repository validation fact for request %q has degree %v outside [%v, %v]", request.ID, fact.Degree, entity.DegreeGreen, entity.DegreeBroken)} + } + return nil +} + +func validateProjectStatusRequest(req entity.GetProjectStatusByURIRequest) error { + if req.Queue == "" { + return fmt.Errorf("queue must be non-empty: %w", ErrInvalidRequest) + } + if len(req.Queue) > maxProjectStatusIdentifierBytes { + return fmt.Errorf("queue exceeds %d bytes: %w", maxProjectStatusIdentifierBytes, ErrInvalidRequest) + } + if req.ChangeURI == "" { + return fmt.Errorf("change_uri must be non-empty: %w", ErrInvalidRequest) + } + if len(req.ChangeURI) > maxProjectStatusIdentifierBytes { + return fmt.Errorf("change_uri exceeds %d bytes: %w", maxProjectStatusIdentifierBytes, ErrInvalidRequest) + } + if req.HasProject { + if req.Project == "" { + return fmt.Errorf("project must be non-empty when present: %w", ErrInvalidRequest) + } + if len(req.Project) > maxProjectStatusIdentifierBytes { + return fmt.Errorf("project exceeds %d bytes: %w", maxProjectStatusIdentifierBytes, ErrInvalidRequest) + } + if req.PageSize != 0 || req.PageToken != "" { + return fmt.Errorf("page_size and page_token must be empty when project is present: %w", ErrInvalidRequest) + } + } + if req.PageSize < 0 || req.PageSize > maxProjectStatusPageSize { + return fmt.Errorf("page_size must be between 0 and %d: %w", maxProjectStatusPageSize, ErrInvalidRequest) + } + if req.PageToken != "" { + return fmt.Errorf("page_token is not valid before project results are available: %w", ErrInvalidRequest) + } + return nil +} diff --git a/stovepipe/controller/get_project_status_by_uri_test.go b/stovepipe/controller/get_project_status_by_uri_test.go new file mode 100644 index 000000000..1ed4fda9d --- /dev/null +++ b/stovepipe/controller/get_project_status_by_uri_test.go @@ -0,0 +1,273 @@ +// 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 controller + +import ( + "context" + "errors" + "math" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber-go/tally" + "github.com/uber/submitqueue/platform/errs" + "github.com/uber/submitqueue/stovepipe/entity" + "github.com/uber/submitqueue/stovepipe/extension/storage" + storagemock "github.com/uber/submitqueue/stovepipe/extension/storage/mock" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +const testProjectStatusRequestID = "request/monorepo/main/7" + +type projectStatusMocks struct { + factory *storagemock.MockFactory + store *storagemock.MockStorage + uriStore *storagemock.MockRequestURIStore + reqStore *storagemock.MockRequestStore + factStore *storagemock.MockValidationFactStore +} + +func newProjectStatusController(t *testing.T) (*GetProjectStatusByURIController, projectStatusMocks) { + t.Helper() + ctrl := gomock.NewController(t) + m := projectStatusMocks{ + factory: storagemock.NewMockFactory(ctrl), + store: storagemock.NewMockStorage(ctrl), + uriStore: storagemock.NewMockRequestURIStore(ctrl), + reqStore: storagemock.NewMockRequestStore(ctrl), + factStore: storagemock.NewMockValidationFactStore(ctrl), + } + m.store.EXPECT().GetRequestURIStore().Return(m.uriStore).AnyTimes() + m.store.EXPECT().GetRequestStore().Return(m.reqStore).AnyTimes() + m.store.EXPECT().GetValidationFactStore().Return(m.factStore).AnyTimes() + return NewGetProjectStatusByURIController(zap.NewNop().Sugar(), tally.NoopScope, m.factory), m +} + +func validProjectStatusRequest() entity.GetProjectStatusByURIRequest { + return entity.GetProjectStatusByURIRequest{Queue: testQueue, ChangeURI: testURI} +} + +func validStoredProjectStatusRequest() entity.Request { + return entity.Request{ + ID: testProjectStatusRequestID, + Queue: testQueue, + URI: testURI, + BaseURI: "git://repo/monorepo/main/base", + State: entity.RequestStateProcessing, + } +} + +func expectProjectStatusRequestLoaded(m projectStatusMocks, request entity.Request) { + m.factory.EXPECT().For(storage.Config{QueueName: testQueue}).Return(m.store, nil) + m.uriStore.EXPECT().GetIDByURI(gomock.Any(), testURI).Return(testProjectStatusRequestID, nil) + m.reqStore.EXPECT().Get(gomock.Any(), testProjectStatusRequestID).Return(request, nil) +} + +func TestGetProjectStatusByURIController_GetProjectStatusByURI(t *testing.T) { + t.Run("returns request and recorded green repository fact", func(t *testing.T) { + controller, m := newProjectStatusController(t) + request := validStoredProjectStatusRequest() + expectProjectStatusRequestLoaded(m, request) + m.factStore.EXPECT().Get(gomock.Any(), testURI, "").Return(entity.ValidationFact{ + URI: testURI, RequestID: testProjectStatusRequestID, Degree: entity.DegreeGreen, + }, nil) + + result, err := controller.GetProjectStatusByURI(context.Background(), validProjectStatusRequest()) + + require.NoError(t, err) + assert.Equal(t, request, result.Request) + assert.True(t, result.HasRepositoryValidationFact) + assert.Equal(t, entity.DegreeGreen, result.RepositoryValidationFact.Degree) + }) + + t.Run("leaves repository degree absent when fact is missing", func(t *testing.T) { + controller, m := newProjectStatusController(t) + expectProjectStatusRequestLoaded(m, validStoredProjectStatusRequest()) + m.factStore.EXPECT().Get(gomock.Any(), testURI, "").Return(entity.ValidationFact{}, storage.ErrNotFound) + + result, err := controller.GetProjectStatusByURI(context.Background(), validProjectStatusRequest()) + + require.NoError(t, err) + assert.False(t, result.HasRepositoryValidationFact) + assert.Equal(t, entity.ValidationFact{}, result.RepositoryValidationFact) + }) + + t.Run("reports missing URI mapping as not found", func(t *testing.T) { + controller, m := newProjectStatusController(t) + m.factory.EXPECT().For(storage.Config{QueueName: testQueue}).Return(m.store, nil) + m.uriStore.EXPECT().GetIDByURI(gomock.Any(), testURI).Return("", storage.ErrNotFound) + + _, err := controller.GetProjectStatusByURI(context.Background(), validProjectStatusRequest()) + + require.Error(t, err) + assert.True(t, IsProjectStatusNotFound(err)) + }) + + t.Run("reports mapping without request as retryable", func(t *testing.T) { + controller, m := newProjectStatusController(t) + m.factory.EXPECT().For(storage.Config{QueueName: testQueue}).Return(m.store, nil) + m.uriStore.EXPECT().GetIDByURI(gomock.Any(), testURI).Return(testProjectStatusRequestID, nil) + m.reqStore.EXPECT().Get(gomock.Any(), testProjectStatusRequestID).Return(entity.Request{}, storage.ErrNotFound) + + _, err := controller.GetProjectStatusByURI(context.Background(), validProjectStatusRequest()) + + require.Error(t, err) + assert.True(t, errs.IsRetryable(err)) + }) + + t.Run("reports unsupported exact project as not found", func(t *testing.T) { + controller, m := newProjectStatusController(t) + expectProjectStatusRequestLoaded(m, validStoredProjectStatusRequest()) + req := validProjectStatusRequest() + req.HasProject = true + req.Project = "//project" + + _, err := controller.GetProjectStatusByURI(context.Background(), req) + + require.Error(t, err) + assert.True(t, IsProjectStatusNotFound(err)) + }) +} + +func TestGetProjectStatusByURIController_RejectsInvalidRequest(t *testing.T) { + tests := []struct { + name string + request entity.GetProjectStatusByURIRequest + }{ + {name: "empty queue", request: entity.GetProjectStatusByURIRequest{ChangeURI: testURI}}, + {name: "oversized queue", request: entity.GetProjectStatusByURIRequest{Queue: strings.Repeat("q", 256), ChangeURI: testURI}}, + {name: "empty change URI", request: entity.GetProjectStatusByURIRequest{Queue: testQueue}}, + {name: "oversized change URI", request: entity.GetProjectStatusByURIRequest{Queue: testQueue, ChangeURI: strings.Repeat("u", 256)}}, + {name: "explicit empty project", request: entity.GetProjectStatusByURIRequest{Queue: testQueue, ChangeURI: testURI, HasProject: true}}, + {name: "oversized project", request: entity.GetProjectStatusByURIRequest{Queue: testQueue, ChangeURI: testURI, HasProject: true, Project: strings.Repeat("p", 256)}}, + {name: "project with page size", request: entity.GetProjectStatusByURIRequest{Queue: testQueue, ChangeURI: testURI, HasProject: true, Project: "//project", PageSize: 1}}, + {name: "project with page token", request: entity.GetProjectStatusByURIRequest{Queue: testQueue, ChangeURI: testURI, HasProject: true, Project: "//project", PageToken: "token"}}, + {name: "negative page size", request: entity.GetProjectStatusByURIRequest{Queue: testQueue, ChangeURI: testURI, PageSize: -1}}, + {name: "page size above maximum", request: entity.GetProjectStatusByURIRequest{Queue: testQueue, ChangeURI: testURI, PageSize: 201}}, + {name: "page token before project list", request: entity.GetProjectStatusByURIRequest{Queue: testQueue, ChangeURI: testURI, PageToken: "token"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + controller, _ := newProjectStatusController(t) + + _, err := controller.GetProjectStatusByURI(context.Background(), tt.request) + + require.Error(t, err) + assert.True(t, IsInvalidRequest(err)) + }) + } +} + +func TestGetProjectStatusByURIController_RejectsInconsistentRecords(t *testing.T) { + requestMismatchTests := []struct { + name string + request entity.Request + }{ + {name: "request ID", request: func() entity.Request { r := validStoredProjectStatusRequest(); r.ID = "other"; return r }()}, + {name: "request queue", request: func() entity.Request { r := validStoredProjectStatusRequest(); r.Queue = "other"; return r }()}, + {name: "request URI", request: func() entity.Request { r := validStoredProjectStatusRequest(); r.URI = "other"; return r }()}, + } + for _, tt := range requestMismatchTests { + t.Run(tt.name, func(t *testing.T) { + controller, m := newProjectStatusController(t) + expectProjectStatusRequestLoaded(m, tt.request) + + _, err := controller.GetProjectStatusByURI(context.Background(), validProjectStatusRequest()) + + require.Error(t, err) + assert.True(t, IsProjectStatusConsistency(err)) + }) + } + + factMismatchTests := []struct { + name string + fact entity.ValidationFact + }{ + {name: "fact URI", fact: entity.ValidationFact{URI: "other", RequestID: testProjectStatusRequestID}}, + {name: "fact project", fact: entity.ValidationFact{URI: testURI, Project: "//project", RequestID: testProjectStatusRequestID}}, + {name: "fact request ID", fact: entity.ValidationFact{URI: testURI, RequestID: "other"}}, + {name: "fact degree below range", fact: entity.ValidationFact{URI: testURI, RequestID: testProjectStatusRequestID, Degree: -0.1}}, + {name: "fact degree above range", fact: entity.ValidationFact{URI: testURI, RequestID: testProjectStatusRequestID, Degree: 1.1}}, + {name: "fact degree NaN", fact: entity.ValidationFact{URI: testURI, RequestID: testProjectStatusRequestID, Degree: math.NaN()}}, + } + for _, tt := range factMismatchTests { + t.Run(tt.name, func(t *testing.T) { + controller, m := newProjectStatusController(t) + expectProjectStatusRequestLoaded(m, validStoredProjectStatusRequest()) + m.factStore.EXPECT().Get(gomock.Any(), testURI, "").Return(tt.fact, nil) + + _, err := controller.GetProjectStatusByURI(context.Background(), validProjectStatusRequest()) + + require.Error(t, err) + assert.True(t, IsProjectStatusConsistency(err)) + }) + } +} + +func TestGetProjectStatusByURIController_PropagatesInfrastructureErrors(t *testing.T) { + infrastructureErr := errors.New("storage unavailable") + tests := []struct { + name string + setup func(projectStatusMocks) + }{ + { + name: "storage factory", + setup: func(m projectStatusMocks) { + m.factory.EXPECT().For(storage.Config{QueueName: testQueue}).Return(nil, infrastructureErr) + }, + }, + { + name: "URI mapping", + setup: func(m projectStatusMocks) { + m.factory.EXPECT().For(storage.Config{QueueName: testQueue}).Return(m.store, nil) + m.uriStore.EXPECT().GetIDByURI(gomock.Any(), testURI).Return("", infrastructureErr) + }, + }, + { + name: "request", + setup: func(m projectStatusMocks) { + m.factory.EXPECT().For(storage.Config{QueueName: testQueue}).Return(m.store, nil) + m.uriStore.EXPECT().GetIDByURI(gomock.Any(), testURI).Return(testProjectStatusRequestID, nil) + m.reqStore.EXPECT().Get(gomock.Any(), testProjectStatusRequestID).Return(entity.Request{}, infrastructureErr) + }, + }, + { + name: "repository fact", + setup: func(m projectStatusMocks) { + expectProjectStatusRequestLoaded(m, validStoredProjectStatusRequest()) + m.factStore.EXPECT().Get(gomock.Any(), testURI, "").Return(entity.ValidationFact{}, infrastructureErr) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + controller, m := newProjectStatusController(t) + tt.setup(m) + + _, err := controller.GetProjectStatusByURI(context.Background(), validProjectStatusRequest()) + + require.Error(t, err) + assert.ErrorIs(t, err, infrastructureErr) + assert.False(t, IsInvalidRequest(err)) + assert.False(t, IsProjectStatusNotFound(err)) + assert.False(t, IsProjectStatusConsistency(err)) + }) + } +} diff --git a/stovepipe/entity/BUILD.bazel b/stovepipe/entity/BUILD.bazel index 18eeeb9c7..edb5630b5 100644 --- a/stovepipe/entity/BUILD.bazel +++ b/stovepipe/entity/BUILD.bazel @@ -4,6 +4,7 @@ go_library( name = "go_default_library", srcs = [ "build.go", + "get_project_status_by_uri.go", "ingest.go", "queue.go", "queue_config.go", diff --git a/stovepipe/entity/get_project_status_by_uri.go b/stovepipe/entity/get_project_status_by_uri.go new file mode 100644 index 000000000..e9d940636 --- /dev/null +++ b/stovepipe/entity/get_project_status_by_uri.go @@ -0,0 +1,41 @@ +// 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 entity + +// GetProjectStatusByURIRequest selects the authoritative validation request for a commit. +type GetProjectStatusByURIRequest struct { + // Queue is the exact queue containing the request. + Queue string + // ChangeURI is the exact commit URI whose request is selected. + ChangeURI string + // Project is the exact project selector when HasProject is true. + Project string + // HasProject distinguishes an omitted project from an explicitly empty project. + HasProject bool + // PageSize is the maximum number of projects to return. Zero selects the server default. + PageSize int32 + // PageToken is an opaque continuation token from a previous result. + PageToken string +} + +// GetProjectStatusByURIResult contains the authoritative request's current validation projection. +type GetProjectStatusByURIResult struct { + // Request is the authoritative request selected by queue and commit URI. + Request Request + // RepositoryValidationFact is the whole-repository result when HasRepositoryValidationFact is true. + RepositoryValidationFact ValidationFact + // HasRepositoryValidationFact distinguishes a missing fact from a recorded green result. + HasRepositoryValidationFact bool +}