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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions service/stovepipe/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ type StovepipeServer struct {
pingController *controller.PingController
ingestController *controller.IngestController
requestHistoryController controller.RequestHistoryController
projectStatusController *controller.GetProjectStatusByURIController
}

// Ping delegates to the controller.
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions service/stovepipe/server/mapper/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go_library(
name = "go_default_library",
srcs = [
"ingest.go",
"project_status.go",
"request_history.go",
],
importpath = "github.com/uber/submitqueue/service/stovepipe/server/mapper",
Expand All @@ -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"],
Expand Down
52 changes: 52 additions & 0 deletions service/stovepipe/server/mapper/project_status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// 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(),
}
if req.Project != nil {
result.Project = req.GetProject()
result.HasProject = true
}
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
}
45 changes: 45 additions & 0 deletions service/stovepipe/server/mapper/project_status_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// 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) {
project := "project-a"
request := &pb.GetProjectStatusByURIRequest{
Queue: "queue", ChangeUri: "uri", Project: &project, PageSize: 25, PageToken: "token",
}
assert.Equal(t, entity.GetProjectStatusByURIRequest{
Queue: "queue", ChangeURI: "uri", Project: project, HasProject: true, 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, &degree, response.RepositoryBreakageDegree)
assert.False(t, response.GetProjectResultsComplete())
assert.Empty(t, response.GetProjects())
}
2 changes: 2 additions & 0 deletions stovepipe/controller/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
195 changes: 195 additions & 0 deletions stovepipe/controller/get_project_status_by_uri.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// 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
// Project is the optional exact project selector.
Project string
}

func (e *ProjectStatusNotFoundError) Error() string {
if e.Project != "" {
return fmt.Sprintf("project status not found for queue %q, change URI %q, and project %q", e.Queue, e.ChangeURI, e.Project)
}
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"}
}
if req.HasProject {
return entity.GetProjectStatusByURIResult{}, errs.NewUserError(&ProjectStatusNotFoundError{Queue: req.Queue, ChangeURI: req.ChangeURI, Project: req.Project})
}

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 req.HasProject && req.Project == "" {
return fmt.Errorf("GetProjectStatusByURI project must be non-empty when present: %w", ErrInvalidRequest)
}
if req.HasProject && len(req.Project) > maxHistoryIdentifierBytes {
return fmt.Errorf("GetProjectStatusByURI project exceeds %d bytes: %w", maxHistoryIdentifierBytes, ErrInvalidRequest)
}
if req.HasProject && (req.PageSize != 0 || req.PageToken != "") {
return fmt.Errorf("GetProjectStatusByURI page fields are invalid with project: %w", ErrInvalidRequest)
}
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
}
Loading