Skip to content

Commit be60b16

Browse files
committed
feat(stovepipe): serve repository validation status
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. --- <sub>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</sub>
1 parent b3d1fab commit be60b16

11 files changed

Lines changed: 829 additions & 8 deletions

service/stovepipe/server/BUILD.bazel

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ go_library(
3838
"@com_github_go_sql_driver_mysql//:go_default_library",
3939
"@com_github_uber_go_tally//:go_default_library",
4040
"@org_golang_google_grpc//:go_default_library",
41+
"@org_golang_google_grpc//codes:go_default_library",
4142
"@org_golang_google_grpc//reflection:go_default_library",
43+
"@org_golang_google_grpc//status:go_default_library",
4244
"@org_uber_go_zap//:go_default_library",
4345
],
4446
)
@@ -78,10 +80,14 @@ go_test(
7880
deps = [
7981
"//api/base/hook:go_default_library",
8082
"//platform/consumer:go_default_library",
83+
"//platform/errs:go_default_library",
84+
"//stovepipe/controller:go_default_library",
8185
"//stovepipe/controller/dlq:go_default_library",
8286
"@com_github_stretchr_testify//assert:go_default_library",
8387
"@com_github_stretchr_testify//require:go_default_library",
8488
"@com_github_uber_go_tally//:go_default_library",
89+
"@org_golang_google_grpc//codes:go_default_library",
90+
"@org_golang_google_grpc//status:go_default_library",
8591
"@org_uber_go_zap//zaptest:go_default_library",
8692
],
8793
)

service/stovepipe/server/main.go

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,17 @@ import (
5959
storageMySQL "github.com/uber/submitqueue/stovepipe/extension/storage/mysql"
6060
"go.uber.org/zap"
6161
"google.golang.org/grpc"
62+
"google.golang.org/grpc/codes"
6263
"google.golang.org/grpc/reflection"
64+
"google.golang.org/grpc/status"
6365
)
6466

6567
// StovepipeServer wraps the controllers and implements the gRPC service interface.
6668
type StovepipeServer struct {
6769
pb.UnimplementedStovepipeServer
68-
pingController *controller.PingController
69-
ingestController *controller.IngestController
70+
pingController *controller.PingController
71+
ingestController *controller.IngestController
72+
getProjectStatusByURIController *controller.GetProjectStatusByURIController
7073
}
7174

7275
// Ping delegates to the controller.
@@ -84,6 +87,34 @@ func (s *StovepipeServer) Ingest(ctx context.Context, req *pb.IngestRequest) (*p
8487
return mapper.IngestResultToProto(result), nil
8588
}
8689

90+
// GetProjectStatusByURI returns current repository validation for an exact commit URI.
91+
func (s *StovepipeServer) GetProjectStatusByURI(ctx context.Context, req *pb.GetProjectStatusByURIRequest) (*pb.GetProjectStatusByURIResponse, error) {
92+
result, err := s.getProjectStatusByURIController.GetProjectStatusByURI(ctx, mapper.ProtoToGetProjectStatusByURIRequest(req))
93+
if err != nil {
94+
return nil, err
95+
}
96+
return mapper.GetProjectStatusByURIResultToProto(result), nil
97+
}
98+
99+
func stovepipeStatusError(err error) error {
100+
switch {
101+
case errors.Is(err, context.Canceled):
102+
return status.Error(codes.Canceled, err.Error())
103+
case errors.Is(err, context.DeadlineExceeded):
104+
return status.Error(codes.DeadlineExceeded, err.Error())
105+
case controller.IsProjectStatusNotFound(err):
106+
return status.Error(codes.NotFound, err.Error())
107+
case controller.IsProjectStatusConsistency(err):
108+
return status.Error(codes.Internal, err.Error())
109+
case controller.IsInvalidRequest(err):
110+
return status.Error(codes.InvalidArgument, err.Error())
111+
case errs.IsRetryable(err):
112+
return status.Error(codes.Unavailable, err.Error())
113+
default:
114+
return err
115+
}
116+
}
117+
87118
// inMemoryCounter is a minimal, process-local counter.Counter used to wire the example
88119
// server. It is not durable; a real deployment supplies a persistent implementation
89120
// (e.g. platform/extension/counter/mysql).
@@ -323,8 +354,16 @@ func run() error {
323354
}
324355
logger.Info("consumers started")
325356

326-
// Create gRPC server
327-
grpcServer := grpc.NewServer()
357+
// Create gRPC server with stable transport codes for controller outcomes.
358+
grpcServer := grpc.NewServer(grpc.UnaryInterceptor(
359+
func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
360+
resp, err := handler(ctx, req)
361+
if err != nil {
362+
return nil, stovepipeStatusError(err)
363+
}
364+
return resp, nil
365+
},
366+
))
328367

329368
// Create controllers and wrap them for gRPC
330369
pingController := controller.NewPingController(logger, scope)
@@ -336,9 +375,11 @@ func run() error {
336375
storageFty,
337376
registry,
338377
)
378+
getProjectStatusByURIController := controller.NewGetProjectStatusByURIController(logger.Sugar(), scope, storageFty)
339379
srv := &StovepipeServer{
340-
pingController: pingController,
341-
ingestController: ingestController,
380+
pingController: pingController,
381+
ingestController: ingestController,
382+
getProjectStatusByURIController: getProjectStatusByURIController,
342383
}
343384
pb.RegisterStovepipeServer(grpcServer, srv)
344385

service/stovepipe/server/main_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package main
1616

1717
import (
1818
"context"
19+
"errors"
1920
"strings"
2021
"testing"
2122

@@ -24,10 +25,38 @@ import (
2425
"github.com/uber-go/tally"
2526
basehook "github.com/uber/submitqueue/api/base/hook"
2627
"github.com/uber/submitqueue/platform/consumer"
28+
"github.com/uber/submitqueue/platform/errs"
29+
"github.com/uber/submitqueue/stovepipe/controller"
2730
"github.com/uber/submitqueue/stovepipe/controller/dlq"
2831
"go.uber.org/zap/zaptest"
32+
"google.golang.org/grpc/codes"
33+
"google.golang.org/grpc/status"
2934
)
3035

36+
func TestStovepipeStatusError(t *testing.T) {
37+
tests := []struct {
38+
name string
39+
err error
40+
code codes.Code
41+
}{
42+
{name: "invalid request", err: controller.ErrInvalidRequest, code: codes.InvalidArgument},
43+
{name: "not found", err: &controller.ProjectStatusNotFoundError{Queue: "q", ChangeURI: "uri"}, code: codes.NotFound},
44+
{name: "inconsistent records", err: &controller.ProjectStatusConsistencyError{Message: "inconsistent"}, code: codes.Internal},
45+
{name: "retryable", err: errs.NewRetryableError(errors.New("try again")), code: codes.Unavailable},
46+
{name: "canceled", err: errors.Join(errs.NewRetryableError(errors.New("try again")), context.Canceled), code: codes.Canceled},
47+
{name: "deadline exceeded", err: context.DeadlineExceeded, code: codes.DeadlineExceeded},
48+
}
49+
50+
for _, tt := range tests {
51+
t.Run(tt.name, func(t *testing.T) {
52+
assert.Equal(t, tt.code, status.Code(stovepipeStatusError(tt.err)))
53+
})
54+
}
55+
56+
infrastructureErr := errors.New("storage unavailable")
57+
assert.Equal(t, infrastructureErr, stovepipeStatusError(infrastructureErr))
58+
}
59+
3160
// recordingConsumer captures what the host registers instead of subscribing.
3261
type recordingConsumer struct {
3362
controllers []consumer.Controller

service/stovepipe/server/mapper/BUILD.bazel

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ load("@rules_go//go:def.bzl", "go_library", "go_test")
22

33
go_library(
44
name = "go_default_library",
5-
srcs = ["ingest.go"],
5+
srcs = [
6+
"get_project_status_by_uri.go",
7+
"ingest.go",
8+
],
69
importpath = "github.com/uber/submitqueue/service/stovepipe/server/mapper",
710
visibility = ["//visibility:public"],
811
deps = [
@@ -13,7 +16,10 @@ go_library(
1316

1417
go_test(
1518
name = "go_default_test",
16-
srcs = ["ingest_test.go"],
19+
srcs = [
20+
"get_project_status_by_uri_test.go",
21+
"ingest_test.go",
22+
],
1723
embed = [":go_default_library"],
1824
deps = [
1925
"//api/stovepipe/protopb:go_default_library",
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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 the 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 projection to the wire response.
38+
func GetProjectStatusByURIResultToProto(result entity.GetProjectStatusByURIResult) *pb.GetProjectStatusByURIResponse {
39+
response := &pb.GetProjectStatusByURIResponse{
40+
RequestId: result.RequestID,
41+
Queue: result.Queue,
42+
ChangeUri: result.ChangeURI,
43+
BaseUri: result.BaseURI,
44+
RequestState: string(result.RequestState),
45+
ProjectResultsComplete: result.ProjectResultsComplete,
46+
Projects: make([]*pb.ProjectValidation, 0, len(result.Projects)),
47+
NextPageToken: result.NextPageToken,
48+
}
49+
if result.HasRepositoryBreakageDegree {
50+
response.RepositoryBreakageDegree = &result.RepositoryBreakageDegree
51+
}
52+
for _, project := range result.Projects {
53+
mapped := &pb.ProjectValidation{Project: project.Project}
54+
if project.HasBreakageDegree {
55+
mapped.BreakageDegree = &project.BreakageDegree
56+
}
57+
response.Projects = append(response.Projects, mapped)
58+
}
59+
return response
60+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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 TestProtoToGetProjectStatusByURIRequest(t *testing.T) {
26+
project := ""
27+
got := ProtoToGetProjectStatusByURIRequest(&pb.GetProjectStatusByURIRequest{
28+
Queue: "monorepo/main", ChangeUri: "git://commit", Project: &project, PageSize: 10, PageToken: "token",
29+
})
30+
31+
assert.Equal(t, entity.GetProjectStatusByURIRequest{
32+
Queue: "monorepo/main", ChangeURI: "git://commit", Project: "", HasProject: true, PageSize: 10, PageToken: "token",
33+
}, got)
34+
35+
omitted := ProtoToGetProjectStatusByURIRequest(&pb.GetProjectStatusByURIRequest{})
36+
assert.False(t, omitted.HasProject)
37+
}
38+
39+
func TestGetProjectStatusByURIResultToProto(t *testing.T) {
40+
t.Run("preserves optional field presence", func(t *testing.T) {
41+
result := entity.GetProjectStatusByURIResult{
42+
RequestID: "request/monorepo/main/7", Queue: "monorepo/main", ChangeURI: "git://commit",
43+
BaseURI: "git://base", RequestState: entity.ProjectStatusRequestStateSucceeded,
44+
RepositoryBreakageDegree: entity.DegreeGreen, HasRepositoryBreakageDegree: true,
45+
Projects: []entity.ProjectValidation{
46+
{Project: "//green", BreakageDegree: entity.DegreeGreen, HasBreakageDegree: true},
47+
{Project: "//pending"},
48+
},
49+
}
50+
51+
got := GetProjectStatusByURIResultToProto(result)
52+
53+
assert.NotNil(t, got.RepositoryBreakageDegree)
54+
assert.Equal(t, entity.DegreeGreen, got.GetRepositoryBreakageDegree())
55+
assert.Equal(t, "succeeded", got.GetRequestState())
56+
assert.Len(t, got.Projects, 2)
57+
assert.NotNil(t, got.Projects[0].BreakageDegree)
58+
assert.Nil(t, got.Projects[1].BreakageDegree)
59+
})
60+
61+
t.Run("keeps missing repository fact absent", func(t *testing.T) {
62+
got := GetProjectStatusByURIResultToProto(entity.GetProjectStatusByURIResult{})
63+
64+
assert.Nil(t, got.RepositoryBreakageDegree)
65+
assert.Empty(t, got.Projects)
66+
})
67+
}

stovepipe/controller/BUILD.bazel

Lines changed: 3 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
],
@@ -27,13 +28,15 @@ go_library(
2728
go_test(
2829
name = "go_default_test",
2930
srcs = [
31+
"get_project_status_by_uri_test.go",
3032
"ingest_test.go",
3133
"ping_test.go",
3234
],
3335
embed = [":go_default_library"],
3436
deps = [
3537
"//api/stovepipe/protopb:go_default_library",
3638
"//platform/consumer:go_default_library",
39+
"//platform/errs:go_default_library",
3740
"//platform/extension/counter:go_default_library",
3841
"//platform/extension/counter/mock:go_default_library",
3942
"//platform/extension/messagequeue/mock:go_default_library",

0 commit comments

Comments
 (0)