Skip to content

Commit a6c1ed5

Browse files
committed
feat(stovepipe): Serve repository status
**What**: - Return the current validation lifecycle and repository result for a commit. - Distinguish unknown, pending, completed, and inconsistent validation records. **Why**: - Give consumers an authoritative status lookup for a validation run. - Establish the read path before project-level results are available.
1 parent b3ef201 commit a6c1ed5

10 files changed

Lines changed: 470 additions & 5 deletions

File tree

service/stovepipe/server/main.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ type StovepipeServer struct {
7171
pingController *controller.PingController
7272
ingestController *controller.IngestController
7373
requestHistoryController controller.RequestHistoryController
74+
projectStatusController *controller.GetProjectStatusByURIController
7475
}
7576

7677
// Ping delegates to the controller.
@@ -106,6 +107,15 @@ func (s *StovepipeServer) GetRequestHistoryByURI(ctx context.Context, req *pb.Ge
106107
return &pb.GetRequestHistoryByURIResponse{Histories: mapper.RequestHistoriesToProto(histories)}, nil
107108
}
108109

110+
// GetProjectStatusByURI returns the current repository validation status for a commit.
111+
func (s *StovepipeServer) GetProjectStatusByURI(ctx context.Context, req *pb.GetProjectStatusByURIRequest) (*pb.GetProjectStatusByURIResponse, error) {
112+
result, err := s.projectStatusController.GetProjectStatusByURI(ctx, mapper.ProtoToGetProjectStatusByURIRequest(req))
113+
if err != nil {
114+
return nil, err
115+
}
116+
return mapper.GetProjectStatusByURIResultToProto(result), nil
117+
}
118+
109119
// inMemoryCounter is a minimal, process-local counter.Counter used to wire the example
110120
// server. It is not durable; a real deployment supplies a persistent implementation
111121
// (e.g. platform/extension/counter/mysql).
@@ -372,10 +382,12 @@ func run() error {
372382
tenants,
373383
)
374384
requestHistoryController := controller.NewRequestHistoryController(logger.Sugar(), scope, storageFty)
385+
projectStatusController := controller.NewGetProjectStatusByURIController(logger.Sugar(), scope, storageFty)
375386
srv := &StovepipeServer{
376387
pingController: pingController,
377388
ingestController: ingestController,
378389
requestHistoryController: requestHistoryController,
390+
projectStatusController: projectStatusController,
379391
}
380392
pb.RegisterStovepipeServer(grpcServer, srv)
381393

service/stovepipe/server/mapper/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go_library(
44
name = "go_default_library",
55
srcs = [
66
"ingest.go",
7+
"project_status.go",
78
"request_history.go",
89
],
910
importpath = "github.com/uber/submitqueue/service/stovepipe/server/mapper",
@@ -18,6 +19,7 @@ go_test(
1819
name = "go_default_test",
1920
srcs = [
2021
"ingest_test.go",
22+
"project_status_test.go",
2123
"request_history_test.go",
2224
],
2325
embed = [":go_default_library"],
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright (c) 2026 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package mapper
16+
17+
import (
18+
pb "github.com/uber/submitqueue/api/stovepipe/protopb"
19+
"github.com/uber/submitqueue/stovepipe/entity"
20+
)
21+
22+
// ProtoToGetProjectStatusByURIRequest maps a wire selector to its domain form.
23+
func ProtoToGetProjectStatusByURIRequest(req *pb.GetProjectStatusByURIRequest) entity.GetProjectStatusByURIRequest {
24+
result := entity.GetProjectStatusByURIRequest{
25+
Queue: req.GetQueue(),
26+
ChangeURI: req.GetChangeUri(),
27+
PageSize: req.GetPageSize(),
28+
PageToken: req.GetPageToken(),
29+
}
30+
if req.Project != nil {
31+
result.Project = req.GetProject()
32+
result.HasProject = true
33+
}
34+
return result
35+
}
36+
37+
// GetProjectStatusByURIResultToProto maps a domain status projection to its wire response.
38+
func GetProjectStatusByURIResultToProto(result entity.GetProjectStatusByURIResult) *pb.GetProjectStatusByURIResponse {
39+
response := &pb.GetProjectStatusByURIResponse{
40+
RequestId: result.Request.ID,
41+
Queue: result.Request.Queue,
42+
ChangeUri: result.Request.URI,
43+
BaseUri: result.Request.BaseURI,
44+
RequestState: string(result.Request.State),
45+
UpdatedAtMs: result.UpdatedAtMs,
46+
ProjectResultsComplete: result.ProjectResultsComplete,
47+
}
48+
if result.HasRepositoryValidationFact {
49+
response.RepositoryBreakageDegree = &result.RepositoryValidationFact.Degree
50+
}
51+
return response
52+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (c) 2026 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package mapper
16+
17+
import (
18+
"testing"
19+
20+
"github.com/stretchr/testify/assert"
21+
pb "github.com/uber/submitqueue/api/stovepipe/protopb"
22+
"github.com/uber/submitqueue/stovepipe/entity"
23+
)
24+
25+
func TestProjectStatusRequestAndResponseMapping(t *testing.T) {
26+
project := "project-a"
27+
request := &pb.GetProjectStatusByURIRequest{
28+
Queue: "queue", ChangeUri: "uri", Project: &project, PageSize: 25, PageToken: "token",
29+
}
30+
assert.Equal(t, entity.GetProjectStatusByURIRequest{
31+
Queue: "queue", ChangeURI: "uri", Project: project, HasProject: true, PageSize: 25, PageToken: "token",
32+
}, ProtoToGetProjectStatusByURIRequest(request))
33+
34+
degree := entity.DegreeGreen
35+
response := GetProjectStatusByURIResultToProto(entity.GetProjectStatusByURIResult{
36+
Request: entity.Request{ID: "request/1", Queue: "queue", URI: "uri", BaseURI: "base", State: entity.RequestStateSucceeded},
37+
RepositoryValidationFact: entity.ValidationFact{Degree: degree},
38+
HasRepositoryValidationFact: true,
39+
})
40+
assert.Equal(t, "request/1", response.GetRequestId())
41+
assert.Equal(t, "succeeded", response.GetRequestState())
42+
assert.Equal(t, &degree, response.RepositoryBreakageDegree)
43+
assert.False(t, response.GetProjectResultsComplete())
44+
assert.Empty(t, response.GetProjects())
45+
}

stovepipe/controller/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ load("@rules_go//go:def.bzl", "go_library", "go_test")
33
go_library(
44
name = "go_default_library",
55
srcs = [
6+
"get_project_status_by_uri.go",
67
"ingest.go",
78
"ping.go",
89
"read_errors.go",
@@ -30,6 +31,7 @@ go_library(
3031
go_test(
3132
name = "go_default_test",
3233
srcs = [
34+
"get_project_status_by_uri_test.go",
3335
"ingest_test.go",
3436
"ping_test.go",
3537
"request_history_test.go",
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// Copyright (c) 2026 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package controller
16+
17+
import (
18+
"context"
19+
"errors"
20+
"fmt"
21+
"math"
22+
23+
"github.com/uber-go/tally"
24+
"github.com/uber/submitqueue/platform/errs"
25+
"github.com/uber/submitqueue/platform/metrics"
26+
"github.com/uber/submitqueue/stovepipe/entity"
27+
"github.com/uber/submitqueue/stovepipe/extension/storage"
28+
"go.uber.org/zap"
29+
)
30+
31+
const maxProjectStatusPageSize = 200
32+
33+
// ProjectStatusNotFoundError indicates that no validation request matches a lookup selector.
34+
type ProjectStatusNotFoundError struct {
35+
// Queue is the queue in the selector.
36+
Queue string
37+
// ChangeURI is the commit URI in the selector.
38+
ChangeURI string
39+
}
40+
41+
func (e *ProjectStatusNotFoundError) Error() string {
42+
return fmt.Sprintf("project status not found for queue %q and change URI %q", e.Queue, e.ChangeURI)
43+
}
44+
45+
// IsProjectStatusNotFound reports whether err represents an unknown validation request.
46+
func IsProjectStatusNotFound(err error) bool {
47+
var target *ProjectStatusNotFoundError
48+
return errors.As(err, &target)
49+
}
50+
51+
// ProjectStatusConsistencyError indicates that persisted records disagree about a request.
52+
type ProjectStatusConsistencyError struct {
53+
message string
54+
}
55+
56+
func (e *ProjectStatusConsistencyError) Error() string { return e.message }
57+
58+
// IsProjectStatusConsistency reports whether err represents inconsistent persisted state.
59+
func IsProjectStatusConsistency(err error) bool {
60+
var target *ProjectStatusConsistencyError
61+
return errors.As(err, &target)
62+
}
63+
64+
// GetProjectStatusByURIController reads the durable repository-level validation projection.
65+
// Project result reads are added when planned-project storage exists.
66+
type GetProjectStatusByURIController struct {
67+
logger *zap.SugaredLogger
68+
metricsScope tally.Scope
69+
stores storage.Factory
70+
}
71+
72+
// NewGetProjectStatusByURIController creates a controller for validation-status lookups.
73+
func NewGetProjectStatusByURIController(logger *zap.SugaredLogger, scope tally.Scope, stores storage.Factory) *GetProjectStatusByURIController {
74+
return &GetProjectStatusByURIController{
75+
logger: logger,
76+
metricsScope: scope.SubScope("get_project_status_by_uri_controller"),
77+
stores: stores,
78+
}
79+
}
80+
81+
// GetProjectStatusByURI returns the selected request and its repository validation fact, if recorded.
82+
func (c *GetProjectStatusByURIController) GetProjectStatusByURI(ctx context.Context, req entity.GetProjectStatusByURIRequest) (result entity.GetProjectStatusByURIResult, retErr error) {
83+
op := metrics.Begin(c.metricsScope, "get_project_status_by_uri", metrics.StorageLatencyBuckets, metrics.TagsFromContext(ctx)...)
84+
defer func() { op.Complete(retErr) }()
85+
86+
if err := validateProjectStatusRequest(req); err != nil {
87+
return entity.GetProjectStatusByURIResult{}, err
88+
}
89+
store, err := c.stores.For(storage.Config{QueueName: req.Queue})
90+
if err != nil {
91+
return entity.GetProjectStatusByURIResult{}, fmt.Errorf("GetProjectStatusByURI failed to resolve storage for queue %q: %w", req.Queue, err)
92+
}
93+
94+
requestID, err := store.GetRequestURIStore().GetIDByURI(ctx, req.ChangeURI)
95+
if err != nil {
96+
if storage.IsNotFound(err) {
97+
return entity.GetProjectStatusByURIResult{}, errs.NewUserError(&ProjectStatusNotFoundError{Queue: req.Queue, ChangeURI: req.ChangeURI})
98+
}
99+
return entity.GetProjectStatusByURIResult{}, fmt.Errorf("GetProjectStatusByURI failed to resolve request for URI %q: %w", req.ChangeURI, err)
100+
}
101+
102+
request, err := store.GetRequestStore().Get(ctx, requestID)
103+
if err != nil {
104+
if storage.IsNotFound(err) {
105+
// The URI mapping is created before the request, so this gap is retryable.
106+
return entity.GetProjectStatusByURIResult{}, errs.NewRetryableError(fmt.Errorf("GetProjectStatusByURI request %q is not visible yet", requestID))
107+
}
108+
return entity.GetProjectStatusByURIResult{}, fmt.Errorf("GetProjectStatusByURI failed to load request %q: %w", requestID, err)
109+
}
110+
if request.ID != requestID || request.Queue != req.Queue || request.URI != req.ChangeURI {
111+
return entity.GetProjectStatusByURIResult{}, &ProjectStatusConsistencyError{message: "request URI mapping disagrees with stored request"}
112+
}
113+
114+
result.Request = request
115+
logs, err := store.GetRequestLogStore().List(ctx, request.ID)
116+
if err != nil && !storage.IsNotFound(err) {
117+
return entity.GetProjectStatusByURIResult{}, fmt.Errorf("GetProjectStatusByURI failed to load request history for %q: %w", request.ID, err)
118+
}
119+
for _, log := range logs {
120+
if log.TimestampMs > result.UpdatedAtMs {
121+
result.UpdatedAtMs = log.TimestampMs
122+
}
123+
}
124+
fact, err := store.GetValidationFactStore().Get(ctx, request.URI, "")
125+
if err != nil {
126+
if storage.IsNotFound(err) {
127+
return result, nil
128+
}
129+
return entity.GetProjectStatusByURIResult{}, fmt.Errorf("GetProjectStatusByURI failed to load repository fact for request %q: %w", request.ID, err)
130+
}
131+
if err := validateRepositoryFact(fact, request); err != nil {
132+
return entity.GetProjectStatusByURIResult{}, err
133+
}
134+
result.RepositoryValidationFact = fact
135+
result.HasRepositoryValidationFact = true
136+
if fact.CreatedAt > result.UpdatedAtMs {
137+
result.UpdatedAtMs = fact.CreatedAt
138+
}
139+
140+
c.logger.Debugw("project status retrieved", "request_id", request.ID, "queue", request.Queue, "change_uri", request.URI, "has_repository_result", true)
141+
return result, nil
142+
}
143+
144+
func validateProjectStatusRequest(req entity.GetProjectStatusByURIRequest) error {
145+
if err := validateHistoryIdentifier("queue", req.Queue); err != nil {
146+
return fmt.Errorf("GetProjectStatusByURI invalid queue=%q: %w", req.Queue, err)
147+
}
148+
if err := validateHistoryIdentifier("change URI", req.ChangeURI); err != nil {
149+
return fmt.Errorf("GetProjectStatusByURI invalid change_uri=%q: %w", req.ChangeURI, err)
150+
}
151+
if req.HasProject && req.Project == "" {
152+
return fmt.Errorf("GetProjectStatusByURI project must be non-empty when present: %w", ErrInvalidRequest)
153+
}
154+
if req.HasProject && len(req.Project) > maxHistoryIdentifierBytes {
155+
return fmt.Errorf("GetProjectStatusByURI project exceeds %d bytes: %w", maxHistoryIdentifierBytes, ErrInvalidRequest)
156+
}
157+
if req.HasProject && (req.PageSize != 0 || req.PageToken != "") {
158+
return fmt.Errorf("GetProjectStatusByURI page fields are invalid with project: %w", ErrInvalidRequest)
159+
}
160+
if req.PageSize < 0 || req.PageSize > maxProjectStatusPageSize {
161+
return fmt.Errorf("GetProjectStatusByURI page_size must be between 0 and %d: %w", maxProjectStatusPageSize, ErrInvalidRequest)
162+
}
163+
return nil
164+
}
165+
166+
func validateRepositoryFact(fact entity.ValidationFact, request entity.Request) error {
167+
if fact.URI != request.URI || fact.Project != "" || fact.RequestID != request.ID {
168+
return &ProjectStatusConsistencyError{message: "repository validation fact disagrees with stored request"}
169+
}
170+
if math.IsNaN(fact.Degree) || fact.Degree < entity.DegreeGreen || fact.Degree > entity.DegreeBroken {
171+
return &ProjectStatusConsistencyError{message: "repository validation fact has an invalid degree"}
172+
}
173+
return nil
174+
}

0 commit comments

Comments
 (0)