From be60b16fa55ff921655403a3c868fe88aef5746f 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/BUILD.bazel | 6 + service/stovepipe/server/main.go | 53 ++- service/stovepipe/server/main_test.go | 29 ++ service/stovepipe/server/mapper/BUILD.bazel | 10 +- .../mapper/get_project_status_by_uri.go | 60 ++++ .../mapper/get_project_status_by_uri_test.go | 67 ++++ stovepipe/controller/BUILD.bazel | 3 + .../controller/get_project_status_by_uri.go | 215 ++++++++++++ .../get_project_status_by_uri_test.go | 308 ++++++++++++++++++ stovepipe/entity/BUILD.bazel | 1 + stovepipe/entity/get_project_status_by_uri.go | 85 +++++ 11 files changed, 829 insertions(+), 8 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/BUILD.bazel b/service/stovepipe/server/BUILD.bazel index 63d9ec5a1..6bc83ead5 100644 --- a/service/stovepipe/server/BUILD.bazel +++ b/service/stovepipe/server/BUILD.bazel @@ -38,7 +38,9 @@ go_library( "@com_github_go_sql_driver_mysql//:go_default_library", "@com_github_uber_go_tally//:go_default_library", "@org_golang_google_grpc//:go_default_library", + "@org_golang_google_grpc//codes:go_default_library", "@org_golang_google_grpc//reflection:go_default_library", + "@org_golang_google_grpc//status:go_default_library", "@org_uber_go_zap//:go_default_library", ], ) @@ -78,10 +80,14 @@ go_test( deps = [ "//api/base/hook:go_default_library", "//platform/consumer:go_default_library", + "//platform/errs:go_default_library", + "//stovepipe/controller:go_default_library", "//stovepipe/controller/dlq:go_default_library", "@com_github_stretchr_testify//assert:go_default_library", "@com_github_stretchr_testify//require:go_default_library", "@com_github_uber_go_tally//:go_default_library", + "@org_golang_google_grpc//codes:go_default_library", + "@org_golang_google_grpc//status:go_default_library", "@org_uber_go_zap//zaptest:go_default_library", ], ) diff --git a/service/stovepipe/server/main.go b/service/stovepipe/server/main.go index 06c932904..d95a9e982 100644 --- a/service/stovepipe/server/main.go +++ b/service/stovepipe/server/main.go @@ -59,14 +59,17 @@ import ( storageMySQL "github.com/uber/submitqueue/stovepipe/extension/storage/mysql" "go.uber.org/zap" "google.golang.org/grpc" + "google.golang.org/grpc/codes" "google.golang.org/grpc/reflection" + "google.golang.org/grpc/status" ) // 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 +87,34 @@ 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), nil +} + +func stovepipeStatusError(err error) error { + switch { + case errors.Is(err, context.Canceled): + return status.Error(codes.Canceled, err.Error()) + case errors.Is(err, context.DeadlineExceeded): + return status.Error(codes.DeadlineExceeded, err.Error()) + case controller.IsProjectStatusNotFound(err): + return status.Error(codes.NotFound, err.Error()) + case controller.IsProjectStatusConsistency(err): + return status.Error(codes.Internal, err.Error()) + case controller.IsInvalidRequest(err): + return status.Error(codes.InvalidArgument, err.Error()) + case errs.IsRetryable(err): + return status.Error(codes.Unavailable, err.Error()) + default: + return err + } +} + // 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). @@ -323,8 +354,16 @@ func run() error { } logger.Info("consumers started") - // Create gRPC server - grpcServer := grpc.NewServer() + // Create gRPC server with stable transport codes for controller outcomes. + grpcServer := grpc.NewServer(grpc.UnaryInterceptor( + func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + resp, err := handler(ctx, req) + if err != nil { + return nil, stovepipeStatusError(err) + } + return resp, nil + }, + )) // Create controllers and wrap them for gRPC pingController := controller.NewPingController(logger, scope) @@ -336,9 +375,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/main_test.go b/service/stovepipe/server/main_test.go index 05262ef67..434f14a37 100644 --- a/service/stovepipe/server/main_test.go +++ b/service/stovepipe/server/main_test.go @@ -16,6 +16,7 @@ package main import ( "context" + "errors" "strings" "testing" @@ -24,10 +25,38 @@ import ( "github.com/uber-go/tally" basehook "github.com/uber/submitqueue/api/base/hook" "github.com/uber/submitqueue/platform/consumer" + "github.com/uber/submitqueue/platform/errs" + "github.com/uber/submitqueue/stovepipe/controller" "github.com/uber/submitqueue/stovepipe/controller/dlq" "go.uber.org/zap/zaptest" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) +func TestStovepipeStatusError(t *testing.T) { + tests := []struct { + name string + err error + code codes.Code + }{ + {name: "invalid request", err: controller.ErrInvalidRequest, code: codes.InvalidArgument}, + {name: "not found", err: &controller.ProjectStatusNotFoundError{Queue: "q", ChangeURI: "uri"}, code: codes.NotFound}, + {name: "inconsistent records", err: &controller.ProjectStatusConsistencyError{Message: "inconsistent"}, code: codes.Internal}, + {name: "retryable", err: errs.NewRetryableError(errors.New("try again")), code: codes.Unavailable}, + {name: "canceled", err: errors.Join(errs.NewRetryableError(errors.New("try again")), context.Canceled), code: codes.Canceled}, + {name: "deadline exceeded", err: context.DeadlineExceeded, code: codes.DeadlineExceeded}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.code, status.Code(stovepipeStatusError(tt.err))) + }) + } + + infrastructureErr := errors.New("storage unavailable") + assert.Equal(t, infrastructureErr, stovepipeStatusError(infrastructureErr)) +} + // recordingConsumer captures what the host registers instead of subscribing. type recordingConsumer struct { controllers []consumer.Controller diff --git a/service/stovepipe/server/mapper/BUILD.bazel b/service/stovepipe/server/mapper/BUILD.bazel index 88d60b597..d03cb9474 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,7 +16,10 @@ 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", 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..011dcdf44 --- /dev/null +++ b/service/stovepipe/server/mapper/get_project_status_by_uri.go @@ -0,0 +1,60 @@ +// 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 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 { + response := &pb.GetProjectStatusByURIResponse{ + RequestId: result.RequestID, + Queue: result.Queue, + ChangeUri: result.ChangeURI, + BaseUri: result.BaseURI, + RequestState: string(result.RequestState), + ProjectResultsComplete: result.ProjectResultsComplete, + Projects: make([]*pb.ProjectValidation, 0, len(result.Projects)), + NextPageToken: result.NextPageToken, + } + if result.HasRepositoryBreakageDegree { + response.RepositoryBreakageDegree = &result.RepositoryBreakageDegree + } + for _, project := range result.Projects { + mapped := &pb.ProjectValidation{Project: project.Project} + if project.HasBreakageDegree { + mapped.BreakageDegree = &project.BreakageDegree + } + response.Projects = append(response.Projects, mapped) + } + return response +} 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..ee9c3e160 --- /dev/null +++ b/service/stovepipe/server/mapper/get_project_status_by_uri_test.go @@ -0,0 +1,67 @@ +// 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 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{ + RequestID: "request/monorepo/main/7", Queue: "monorepo/main", ChangeURI: "git://commit", + BaseURI: "git://base", RequestState: entity.ProjectStatusRequestStateSucceeded, + RepositoryBreakageDegree: entity.DegreeGreen, HasRepositoryBreakageDegree: true, + Projects: []entity.ProjectValidation{ + {Project: "//green", BreakageDegree: entity.DegreeGreen, HasBreakageDegree: true}, + {Project: "//pending"}, + }, + } + + got := GetProjectStatusByURIResultToProto(result) + + assert.NotNil(t, got.RepositoryBreakageDegree) + assert.Equal(t, entity.DegreeGreen, got.GetRepositoryBreakageDegree()) + assert.Equal(t, "succeeded", got.GetRequestState()) + assert.Len(t, got.Projects, 2) + assert.NotNil(t, got.Projects[0].BreakageDegree) + assert.Nil(t, got.Projects[1].BreakageDegree) + }) + + t.Run("keeps missing repository fact absent", func(t *testing.T) { + got := GetProjectStatusByURIResultToProto(entity.GetProjectStatusByURIResult{}) + + assert.Nil(t, got.RepositoryBreakageDegree) + assert.Empty(t, got.Projects) + }) +} 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..18e7db333 --- /dev/null +++ b/stovepipe/controller/get_project_status_by_uri.go @@ -0,0 +1,215 @@ +// 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) + } + + requestID, err := store.GetRequestURIStore().GetIDByURI(ctx, req.ChangeURI) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return entity.GetProjectStatusByURIResult{}, &ProjectStatusNotFoundError{Queue: req.Queue, ChangeURI: req.ChangeURI} + } + return entity.GetProjectStatusByURIResult{}, 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) { + return entity.GetProjectStatusByURIResult{}, 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.GetProjectStatusByURIResult{}, fmt.Errorf("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: 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, + )} + } + requestState, ok := projectStatusRequestState(request.State) + if !ok { + return entity.GetProjectStatusByURIResult{}, &ProjectStatusConsistencyError{Message: fmt.Sprintf("request %q has unrecognized state %q", request.ID, request.State)} + } + + if req.HasProject { + return entity.GetProjectStatusByURIResult{}, &ProjectStatusNotFoundError{Queue: req.Queue, ChangeURI: req.ChangeURI, Project: req.Project} + } + + result = entity.GetProjectStatusByURIResult{ + RequestID: request.ID, + Queue: request.Queue, + ChangeURI: request.URI, + BaseURI: request.BaseURI, + RequestState: requestState, + Projects: []entity.ProjectValidation{}, + } + + fact, err := store.GetValidationFactStore().Get(ctx, req.ChangeURI, "") + if err != nil { + if !errors.Is(err, storage.ErrNotFound) { + return entity.GetProjectStatusByURIResult{}, fmt.Errorf("failed to load repository validation fact for request %q: %w", request.ID, err) + } + } else { + if fact.URI != req.ChangeURI || fact.Project != "" || fact.RequestID != request.ID { + return entity.GetProjectStatusByURIResult{}, &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 entity.GetProjectStatusByURIResult{}, &ProjectStatusConsistencyError{Message: fmt.Sprintf("repository validation fact for request %q has degree %v outside [%v, %v]", request.ID, fact.Degree, entity.DegreeGreen, entity.DegreeBroken)} + } + result.RepositoryBreakageDegree = fact.Degree + result.HasRepositoryBreakageDegree = true + } + + c.logger.Debugw("repository validation status retrieved", "request_id", result.RequestID, "queue", result.Queue, "change_uri", result.ChangeURI, "has_repository_result", result.HasRepositoryBreakageDegree) + return result, nil +} + +func projectStatusRequestState(state entity.RequestState) (entity.ProjectStatusRequestState, bool) { + switch state { + case entity.RequestStateAccepted: + return entity.ProjectStatusRequestStateAccepted, true + case entity.RequestStateProcessing: + return entity.ProjectStatusRequestStateProcessing, true + case entity.RequestStateSucceeded: + return entity.ProjectStatusRequestStateSucceeded, true + case entity.RequestStateFailed: + return entity.ProjectStatusRequestStateFailed, true + case entity.RequestStateCancelled: + return entity.ProjectStatusRequestStateCancelled, true + case entity.RequestStateSuperseded: + return entity.ProjectStatusRequestStateSuperseded, true + default: + return entity.ProjectStatusRequestStateUnknown, false + } +} + +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..6f0e710a1 --- /dev/null +++ b/stovepipe/controller/get_project_status_by_uri_test.go @@ -0,0 +1,308 @@ +// 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, testProjectStatusRequestID, result.RequestID) + assert.Equal(t, testQueue, result.Queue) + assert.Equal(t, testURI, result.ChangeURI) + assert.Equal(t, request.BaseURI, result.BaseURI) + assert.Equal(t, entity.ProjectStatusRequestStateProcessing, result.RequestState) + assert.True(t, result.HasRepositoryBreakageDegree) + assert.Equal(t, entity.DegreeGreen, result.RepositoryBreakageDegree) + assert.False(t, result.ProjectResultsComplete) + assert.Empty(t, result.Projects) + assert.Empty(t, result.NextPageToken) + }) + + 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.HasRepositoryBreakageDegree) + assert.Empty(t, result.Projects) + }) + + 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 TestProjectStatusRequestState(t *testing.T) { + tests := []struct { + name string + state entity.RequestState + want entity.ProjectStatusRequestState + wantValid bool + }{ + {name: "accepted", state: entity.RequestStateAccepted, want: entity.ProjectStatusRequestStateAccepted, wantValid: true}, + {name: "processing", state: entity.RequestStateProcessing, want: entity.ProjectStatusRequestStateProcessing, wantValid: true}, + {name: "succeeded", state: entity.RequestStateSucceeded, want: entity.ProjectStatusRequestStateSucceeded, wantValid: true}, + {name: "failed", state: entity.RequestStateFailed, want: entity.ProjectStatusRequestStateFailed, wantValid: true}, + {name: "cancelled", state: entity.RequestStateCancelled, want: entity.ProjectStatusRequestStateCancelled, wantValid: true}, + {name: "superseded", state: entity.RequestStateSuperseded, want: entity.ProjectStatusRequestStateSuperseded, wantValid: true}, + {name: "unknown", state: entity.RequestStateUnknown, want: entity.ProjectStatusRequestStateUnknown}, + {name: "unrecognized", state: "future", want: entity.ProjectStatusRequestStateUnknown}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, valid := projectStatusRequestState(tt.state) + + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.wantValid, valid) + }) + } +} + +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 }()}, + {name: "request state", request: func() entity.Request { r := validStoredProjectStatusRequest(); r.State = "future"; 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..d1d783838 --- /dev/null +++ b/stovepipe/entity/get_project_status_by_uri.go @@ -0,0 +1,85 @@ +// 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 + +// ProjectStatusRequestState is the stable lifecycle vocabulary exposed by project status reads. +type ProjectStatusRequestState string + +const ( + // ProjectStatusRequestStateUnknown is the zero value and is never returned by a successful read. + ProjectStatusRequestStateUnknown ProjectStatusRequestState = "" + // ProjectStatusRequestStateAccepted means validation has not started. + ProjectStatusRequestStateAccepted ProjectStatusRequestState = "accepted" + // ProjectStatusRequestStateProcessing means validation is in progress. + ProjectStatusRequestStateProcessing ProjectStatusRequestState = "processing" + // ProjectStatusRequestStateSucceeded means validation completed successfully. + ProjectStatusRequestStateSucceeded ProjectStatusRequestState = "succeeded" + // ProjectStatusRequestStateFailed means validation completed unsuccessfully. + ProjectStatusRequestStateFailed ProjectStatusRequestState = "failed" + // ProjectStatusRequestStateCancelled means validation was cancelled before reaching a verdict. + ProjectStatusRequestStateCancelled ProjectStatusRequestState = "cancelled" + // ProjectStatusRequestStateSuperseded means a newer head replaced the request before it ran. + ProjectStatusRequestStateSuperseded ProjectStatusRequestState = "superseded" +) + +// 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 { + // RequestID is the globally unique identifier of the authoritative request. + RequestID string + // Queue is the queue containing the request. + Queue string + // ChangeURI is the commit validated by the request. + ChangeURI string + // BaseURI is the baseline for incremental validation. It is empty for a full build. + BaseURI string + // RequestState is the request's current lifecycle state. + RequestState ProjectStatusRequestState + // RepositoryBreakageDegree is the whole-repository degree when HasRepositoryBreakageDegree is true. + RepositoryBreakageDegree float64 + // HasRepositoryBreakageDegree distinguishes a missing fact from a recorded green result. + HasRepositoryBreakageDegree bool + // ProjectResultsComplete reports whether every planned project result is durably recorded. + ProjectResultsComplete bool + // Projects is the selected page of planned project validations. + Projects []ProjectValidation + // NextPageToken continues the project list. Empty means the current page is final. + NextPageToken string +} + +// ProjectValidation contains one planned project and any recorded result. +type ProjectValidation struct { + // Project is the stable project identifier. + Project string + // BreakageDegree is the project degree when HasBreakageDegree is true. + BreakageDegree float64 + // HasBreakageDegree distinguishes a missing fact from a recorded green result. + HasBreakageDegree bool +}