diff --git a/service/stovepipe/server/main.go b/service/stovepipe/server/main.go index ce5d2dcd..7783697d 100644 --- a/service/stovepipe/server/main.go +++ b/service/stovepipe/server/main.go @@ -71,6 +71,7 @@ type StovepipeServer struct { pingController *controller.PingController ingestController *controller.IngestController requestHistoryController controller.RequestHistoryController + projectStatusController *controller.GetProjectStatusByURIController } // Ping delegates to the controller. @@ -106,6 +107,15 @@ func (s *StovepipeServer) GetRequestHistoryByURI(ctx context.Context, req *pb.Ge return &pb.GetRequestHistoryByURIResponse{Histories: mapper.RequestHistoriesToProto(histories)}, nil } +// GetProjectStatusByURI returns the current repository validation status for a commit. +func (s *StovepipeServer) GetProjectStatusByURI(ctx context.Context, req *pb.GetProjectStatusByURIRequest) (*pb.GetProjectStatusByURIResponse, error) { + result, err := s.projectStatusController.GetProjectStatusByURI(ctx, mapper.ProtoToGetProjectStatusByURIRequest(req)) + if err != nil { + return nil, err + } + return mapper.GetProjectStatusByURIResultToProto(result), nil +} + // 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). @@ -372,10 +382,12 @@ func run() error { tenants, ) requestHistoryController := controller.NewRequestHistoryController(logger.Sugar(), scope, storageFty) + projectStatusController := controller.NewGetProjectStatusByURIController(logger.Sugar(), scope, storageFty) srv := &StovepipeServer{ pingController: pingController, ingestController: ingestController, requestHistoryController: requestHistoryController, + projectStatusController: projectStatusController, } pb.RegisterStovepipeServer(grpcServer, srv) diff --git a/service/stovepipe/server/mapper/BUILD.bazel b/service/stovepipe/server/mapper/BUILD.bazel index e78b6a4d..992eca5a 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", + "project_status.go", "request_history.go", ], importpath = "github.com/uber/submitqueue/service/stovepipe/server/mapper", @@ -18,6 +19,7 @@ go_test( name = "go_default_test", srcs = [ "ingest_test.go", + "project_status_test.go", "request_history_test.go", ], embed = [":go_default_library"], diff --git a/service/stovepipe/server/mapper/project_status.go b/service/stovepipe/server/mapper/project_status.go new file mode 100644 index 00000000..2dbba534 --- /dev/null +++ b/service/stovepipe/server/mapper/project_status.go @@ -0,0 +1,49 @@ +// 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" +) + +// ProtoToGetProjectStatusByURIRequest maps a 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(), + } + result.Projects = req.GetProjects() + return result +} + +// GetProjectStatusByURIResultToProto maps a domain status projection to its wire response. +func GetProjectStatusByURIResultToProto(result entity.GetProjectStatusByURIResult) *pb.GetProjectStatusByURIResponse { + response := &pb.GetProjectStatusByURIResponse{ + RequestId: result.Request.ID, + Queue: result.Request.Queue, + ChangeUri: result.Request.URI, + BaseUri: result.Request.BaseURI, + RequestState: string(result.Request.State), + UpdatedAtMs: result.UpdatedAtMs, + ProjectResultsComplete: result.ProjectResultsComplete, + } + if result.HasRepositoryValidationFact { + response.RepositoryBreakageDegree = &result.RepositoryValidationFact.Degree + } + return response +} diff --git a/service/stovepipe/server/mapper/project_status_test.go b/service/stovepipe/server/mapper/project_status_test.go new file mode 100644 index 00000000..fb4e6939 --- /dev/null +++ b/service/stovepipe/server/mapper/project_status_test.go @@ -0,0 +1,44 @@ +// 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" + pb "github.com/uber/submitqueue/api/stovepipe/protopb" + "github.com/uber/submitqueue/stovepipe/entity" +) + +func TestProjectStatusRequestAndResponseMapping(t *testing.T) { + request := &pb.GetProjectStatusByURIRequest{ + Queue: "queue", ChangeUri: "uri", Projects: []string{"project-a", "project-b"}, PageSize: 25, PageToken: "token", + } + assert.Equal(t, entity.GetProjectStatusByURIRequest{ + Queue: "queue", ChangeURI: "uri", Projects: []string{"project-a", "project-b"}, PageSize: 25, PageToken: "token", + }, ProtoToGetProjectStatusByURIRequest(request)) + + degree := entity.DegreeGreen + response := GetProjectStatusByURIResultToProto(entity.GetProjectStatusByURIResult{ + Request: entity.Request{ID: "request/1", Queue: "queue", URI: "uri", BaseURI: "base", State: entity.RequestStateSucceeded}, + RepositoryValidationFact: entity.ValidationFact{Degree: degree}, + HasRepositoryValidationFact: true, + }) + assert.Equal(t, "request/1", response.GetRequestId()) + assert.Equal(t, "succeeded", response.GetRequestState()) + assert.Equal(t, °ree, response.RepositoryBreakageDegree) + assert.False(t, response.GetProjectResultsComplete()) + assert.Empty(t, response.GetProjects()) +} diff --git a/stovepipe/controller/BUILD.bazel b/stovepipe/controller/BUILD.bazel index ffa83e23..9a0a3f2e 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", "read_errors.go", @@ -30,6 +31,7 @@ go_library( go_test( name = "go_default_test", srcs = [ + "get_project_status_by_uri_test.go", "ingest_test.go", "ping_test.go", "request_history_test.go", 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 00000000..8dc30102 --- /dev/null +++ b/stovepipe/controller/get_project_status_by_uri.go @@ -0,0 +1,190 @@ +// 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 maxProjectStatusPageSize = 200 + +// ProjectStatusNotFoundError indicates that no validation request matches a lookup selector. +type ProjectStatusNotFoundError struct { + // Queue is the queue in the selector. + Queue string + // ChangeURI is the commit URI in the selector. + ChangeURI string +} + +func (e *ProjectStatusNotFoundError) Error() string { + return fmt.Sprintf("project status not found for queue %q and change URI %q", e.Queue, e.ChangeURI) +} + +// IsProjectStatusNotFound reports whether err represents an unknown validation request. +func IsProjectStatusNotFound(err error) bool { + var target *ProjectStatusNotFoundError + return errors.As(err, &target) +} + +// ProjectStatusConsistencyError indicates that persisted records disagree about a request. +type ProjectStatusConsistencyError struct { + message string +} + +func (e *ProjectStatusConsistencyError) Error() string { return e.message } + +// IsProjectStatusConsistency reports whether err represents inconsistent persisted state. +func IsProjectStatusConsistency(err error) bool { + var target *ProjectStatusConsistencyError + return errors.As(err, &target) +} + +// GetProjectStatusByURIController reads the durable repository-level validation projection. +// Project result reads are added when planned-project storage exists. +type GetProjectStatusByURIController struct { + logger *zap.SugaredLogger + metricsScope tally.Scope + stores storage.Factory +} + +// NewGetProjectStatusByURIController creates a controller for validation-status lookups. +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 selected request and its repository validation fact, if recorded. +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, metrics.TagsFromContext(ctx)...) + 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("GetProjectStatusByURI failed to resolve storage for queue %q: %w", req.Queue, err) + } + + requestID, err := store.GetRequestURIStore().GetIDByURI(ctx, req.ChangeURI) + if err != nil { + if storage.IsNotFound(err) { + return entity.GetProjectStatusByURIResult{}, errs.NewUserError(&ProjectStatusNotFoundError{Queue: req.Queue, ChangeURI: req.ChangeURI}) + } + return entity.GetProjectStatusByURIResult{}, fmt.Errorf("GetProjectStatusByURI failed to resolve request for URI %q: %w", req.ChangeURI, err) + } + + request, err := store.GetRequestStore().Get(ctx, requestID) + if err != nil { + if storage.IsNotFound(err) { + // The URI mapping is created before the request, so this gap is retryable. + return entity.GetProjectStatusByURIResult{}, errs.NewRetryableError(fmt.Errorf("GetProjectStatusByURI request %q is not visible yet", requestID)) + } + return entity.GetProjectStatusByURIResult{}, fmt.Errorf("GetProjectStatusByURI failed to load request %q: %w", requestID, err) + } + if request.ID != requestID || request.Queue != req.Queue || request.URI != req.ChangeURI { + return entity.GetProjectStatusByURIResult{}, &ProjectStatusConsistencyError{message: "request URI mapping disagrees with stored request"} + } + result.Request = request + logs, err := store.GetRequestLogStore().List(ctx, request.ID) + if storage.IsNotFound(err) { + return entity.GetProjectStatusByURIResult{}, errs.NewRetryableError(fmt.Errorf("GetProjectStatusByURI request %q has no visible lifecycle record yet", request.ID)) + } + if err != nil { + return entity.GetProjectStatusByURIResult{}, fmt.Errorf("GetProjectStatusByURI failed to load request history for %q: %w", request.ID, err) + } + stateRecorded := false + for _, log := range logs { + if log.TimestampMs > result.UpdatedAtMs { + result.UpdatedAtMs = log.TimestampMs + } + if log.State == request.State && log.RequestVersion == request.Version { + stateRecorded = true + } + } + if !stateRecorded { + return entity.GetProjectStatusByURIResult{}, errs.NewRetryableError(fmt.Errorf("GetProjectStatusByURI request %q lifecycle record is not current", request.ID)) + } + fact, err := store.GetValidationFactStore().Get(ctx, request.URI, "") + if err != nil { + if storage.IsNotFound(err) { + return result, nil + } + return entity.GetProjectStatusByURIResult{}, fmt.Errorf("GetProjectStatusByURI failed to load repository fact for request %q: %w", request.ID, err) + } + if err := validateRepositoryFact(fact, request); err != nil { + return entity.GetProjectStatusByURIResult{}, err + } + result.RepositoryValidationFact = fact + result.HasRepositoryValidationFact = true + if fact.CreatedAt > result.UpdatedAtMs { + result.UpdatedAtMs = fact.CreatedAt + } + + c.logger.Debugw("project status retrieved", "request_id", request.ID, "queue", request.Queue, "change_uri", request.URI, "has_repository_result", true) + return result, nil +} + +func validateProjectStatusRequest(req entity.GetProjectStatusByURIRequest) error { + if err := validateHistoryIdentifier("queue", req.Queue); err != nil { + return fmt.Errorf("GetProjectStatusByURI invalid queue=%q: %w", req.Queue, err) + } + if err := validateHistoryIdentifier("change URI", req.ChangeURI); err != nil { + return fmt.Errorf("GetProjectStatusByURI invalid change_uri=%q: %w", req.ChangeURI, err) + } + if len(req.Projects) == 0 { + return fmt.Errorf("GetProjectStatusByURI projects must be non-empty: %w", ErrInvalidRequest) + } + seen := make(map[string]struct{}, len(req.Projects)) + for _, project := range req.Projects { + if err := validateHistoryIdentifier("project", project); err != nil { + return fmt.Errorf("GetProjectStatusByURI invalid project=%q: %w", project, err) + } + if _, ok := seen[project]; ok { + return fmt.Errorf("GetProjectStatusByURI project %q is duplicated: %w", project, ErrInvalidRequest) + } + seen[project] = struct{}{} + } + if req.PageSize < 0 || req.PageSize > maxProjectStatusPageSize { + return fmt.Errorf("GetProjectStatusByURI page_size must be between 0 and %d: %w", maxProjectStatusPageSize, ErrInvalidRequest) + } + if req.PageToken != "" { + return fmt.Errorf("GetProjectStatusByURI page_token is unsupported until project results are available: %w", ErrInvalidRequest) + } + return nil +} + +func validateRepositoryFact(fact entity.ValidationFact, request entity.Request) error { + if fact.URI != request.URI || fact.Project != "" || fact.RequestID != request.ID { + return &ProjectStatusConsistencyError{message: "repository validation fact disagrees with stored request"} + } + if math.IsNaN(fact.Degree) || fact.Degree < entity.DegreeGreen || fact.Degree > entity.DegreeBroken { + return &ProjectStatusConsistencyError{message: "repository validation fact has an invalid degree"} + } + 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 00000000..acb90415 --- /dev/null +++ b/stovepipe/controller/get_project_status_by_uri_test.go @@ -0,0 +1,156 @@ +// 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" + "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 ( + projectStatusQueue = "monorepo/main" + projectStatusURI = "git://monorepo/main/abc" + projectStatusID = "request/monorepo/main/7" +) + +func TestGetProjectStatusByURI(t *testing.T) { + request := entity.Request{ID: projectStatusID, Queue: projectStatusQueue, URI: projectStatusURI, State: entity.RequestStateProcessing, Version: 1} + tests := []struct { + name string + request entity.GetProjectStatusByURIRequest + uriErr error + requestErr error + fact entity.ValidationFact + factErr error + wantFact bool + wantNotFound bool + wantRetryable bool + wantInvalid bool + wantConsistency bool + }{ + {name: "in progress without fact", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, Projects: []string{"project-a"}}, factErr: storage.ErrNotFound}, + {name: "recorded green fact", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, Projects: []string{"project-a"}}, fact: entity.ValidationFact{URI: projectStatusURI, RequestID: projectStatusID, Degree: entity.DegreeGreen}, wantFact: true}, + {name: "missing uri", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, Projects: []string{"project-a"}}, uriErr: storage.ErrNotFound, wantNotFound: true}, + {name: "request visibility gap", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, Projects: []string{"project-a"}}, requestErr: storage.ErrNotFound, wantRetryable: true}, + {name: "empty queue", request: entity.GetProjectStatusByURIRequest{ChangeURI: projectStatusURI}, wantInvalid: true}, + {name: "empty projects", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI}, wantInvalid: true}, + {name: "empty project", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, Projects: []string{""}}, wantInvalid: true}, + {name: "duplicate projects", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, Projects: []string{"project-a", "project-a"}}, wantInvalid: true}, + {name: "invalid page size", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, PageSize: maxProjectStatusPageSize + 1}, wantInvalid: true}, + {name: "page token before project results", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, PageToken: "token"}, wantInvalid: true}, + {name: "invalid fact degree", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, Projects: []string{"project-a"}}, fact: entity.ValidationFact{URI: projectStatusURI, RequestID: projectStatusID, Degree: math.NaN()}, wantConsistency: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockCtrl := gomock.NewController(t) + factory := storagemock.NewMockFactory(mockCtrl) + store := storagemock.NewMockStorage(mockCtrl) + uriStore := storagemock.NewMockRequestURIStore(mockCtrl) + requestStore := storagemock.NewMockRequestStore(mockCtrl) + logStore := storagemock.NewMockRequestLogStore(mockCtrl) + factStore := storagemock.NewMockValidationFactStore(mockCtrl) + if !tt.wantInvalid { + factory.EXPECT().For(storage.Config{QueueName: projectStatusQueue}).Return(store, nil) + store.EXPECT().GetRequestURIStore().Return(uriStore) + uriStore.EXPECT().GetIDByURI(gomock.Any(), projectStatusURI).Return(projectStatusID, tt.uriErr) + if tt.uriErr == nil { + store.EXPECT().GetRequestStore().Return(requestStore) + requestStore.EXPECT().Get(gomock.Any(), projectStatusID).Return(request, tt.requestErr) + if tt.requestErr == nil { + store.EXPECT().GetRequestLogStore().Return(logStore) + logStore.EXPECT().List(gomock.Any(), projectStatusID).Return([]entity.RequestLog{{State: request.State, RequestVersion: request.Version, TimestampMs: 1}}, nil) + store.EXPECT().GetValidationFactStore().Return(factStore) + factStore.EXPECT().Get(gomock.Any(), projectStatusURI, "").Return(tt.fact, tt.factErr) + } + } + } + + controller := NewGetProjectStatusByURIController(zap.NewNop().Sugar(), tally.NoopScope, factory) + got, err := controller.GetProjectStatusByURI(context.Background(), tt.request) + + if tt.wantInvalid { + assert.True(t, IsInvalidRequest(err)) + } + assert.Equal(t, tt.wantNotFound, IsProjectStatusNotFound(err)) + assert.Equal(t, tt.wantRetryable, errs.IsRetryable(err)) + assert.Equal(t, tt.wantConsistency, IsProjectStatusConsistency(err)) + if tt.wantInvalid || tt.wantNotFound || tt.wantRetryable || tt.wantConsistency { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, request, got.Request) + assert.Equal(t, tt.wantFact, got.HasRepositoryValidationFact) + assert.False(t, got.ProjectResultsComplete) + if tt.wantFact { + assert.Equal(t, tt.fact, got.RepositoryValidationFact) + } + }) + } +} + +func TestGetProjectStatusByURIRequiresCurrentLifecycleRecord(t *testing.T) { + request := entity.Request{ID: projectStatusID, Queue: projectStatusQueue, URI: projectStatusURI, State: entity.RequestStateProcessing, Version: 2} + mockCtrl := gomock.NewController(t) + factory := storagemock.NewMockFactory(mockCtrl) + store := storagemock.NewMockStorage(mockCtrl) + uriStore := storagemock.NewMockRequestURIStore(mockCtrl) + requestStore := storagemock.NewMockRequestStore(mockCtrl) + logStore := storagemock.NewMockRequestLogStore(mockCtrl) + factory.EXPECT().For(storage.Config{QueueName: projectStatusQueue}).Return(store, nil) + store.EXPECT().GetRequestURIStore().Return(uriStore) + uriStore.EXPECT().GetIDByURI(gomock.Any(), projectStatusURI).Return(projectStatusID, nil) + store.EXPECT().GetRequestStore().Return(requestStore) + requestStore.EXPECT().Get(gomock.Any(), projectStatusID).Return(request, nil) + store.EXPECT().GetRequestLogStore().Return(logStore) + logStore.EXPECT().List(gomock.Any(), projectStatusID).Return([]entity.RequestLog{{State: entity.RequestStateAccepted, RequestVersion: 1, TimestampMs: 1}}, nil) + + controller := NewGetProjectStatusByURIController(zap.NewNop().Sugar(), tally.NoopScope, factory) + _, err := controller.GetProjectStatusByURI(context.Background(), entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, Projects: []string{"project-a"}}) + require.Error(t, err) + assert.True(t, errs.IsRetryable(err)) +} + +func TestGetProjectStatusByURIRejectsInconsistentRequest(t *testing.T) { + mockCtrl := gomock.NewController(t) + factory := storagemock.NewMockFactory(mockCtrl) + store := storagemock.NewMockStorage(mockCtrl) + uriStore := storagemock.NewMockRequestURIStore(mockCtrl) + requestStore := storagemock.NewMockRequestStore(mockCtrl) + factory.EXPECT().For(storage.Config{QueueName: projectStatusQueue}).Return(store, nil) + store.EXPECT().GetRequestURIStore().Return(uriStore) + uriStore.EXPECT().GetIDByURI(gomock.Any(), projectStatusURI).Return(projectStatusID, nil) + store.EXPECT().GetRequestStore().Return(requestStore) + requestStore.EXPECT().Get(gomock.Any(), projectStatusID).Return(entity.Request{ID: projectStatusID, Queue: projectStatusQueue, URI: "other"}, nil) + + controller := NewGetProjectStatusByURIController(zap.NewNop().Sugar(), tally.NoopScope, factory) + _, err := controller.GetProjectStatusByURI(context.Background(), entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, Projects: []string{"project-a"}}) + require.Error(t, err) + assert.True(t, IsProjectStatusConsistency(err)) + assert.False(t, errors.Is(err, storage.ErrNotFound)) +} diff --git a/stovepipe/entity/BUILD.bazel b/stovepipe/entity/BUILD.bazel index d4798469..9671b8fd 100644 --- a/stovepipe/entity/BUILD.bazel +++ b/stovepipe/entity/BUILD.bazel @@ -5,6 +5,7 @@ go_library( srcs = [ "build.go", "ingest.go", + "project_status.go", "queue.go", "queue_config.go", "request.go", diff --git a/stovepipe/entity/project_status.go b/stovepipe/entity/project_status.go new file mode 100644 index 00000000..09e067eb --- /dev/null +++ b/stovepipe/entity/project_status.go @@ -0,0 +1,43 @@ +// 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 a validation request by queue and commit URI. +type GetProjectStatusByURIRequest struct { + // Queue identifies the queue containing the validation request. + Queue string + // ChangeURI identifies the exact commit under validation. + ChangeURI string + // Projects limits results to the supplied consumer-defined project IDs. + Projects []string + // PageSize is the requested maximum number of full-result projects. + PageSize int32 + // PageToken is an opaque continuation token for full project-result pagination. + PageToken string +} + +// GetProjectStatusByURIResult is the current validation projection for one request. +type GetProjectStatusByURIResult struct { + // Request is the authoritative validation request selected by the lookup. + Request Request + // RepositoryValidationFact is the repository result when HasRepositoryValidationFact is true. + RepositoryValidationFact ValidationFact + // HasRepositoryValidationFact distinguishes a missing fact from a recorded green result. + HasRepositoryValidationFact bool + // ProjectResultsComplete reports whether the implementation has finished its project-result set. + ProjectResultsComplete bool + // UpdatedAtMs is the newest durable lifecycle or repository-result timestamp. + UpdatedAtMs int64 +}