Skip to content

Commit d720cdb

Browse files
committed
feat(stovepipe): Return named project results
**What**: - Return recorded validation results for requested projects in stable caller order. - Support paged project-result reads and report the newest represented result time. **Why**: - Allow consumers to inspect the project-scoped validation outcomes they requested. - Preserve the distinction between an absent result and a recorded successful result.
1 parent fdf0c51 commit d720cdb

5 files changed

Lines changed: 232 additions & 18 deletions

File tree

‎service/stovepipe/server/mapper/project_status.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,18 @@ func GetProjectStatusByURIResultToProto(result entity.GetProjectStatusByURIResul
4141
RequestState: string(result.RequestSummary.State),
4242
UpdatedAtMs: result.UpdatedAtMs,
4343
ProjectResultsComplete: result.ProjectResultsComplete,
44+
NextPageToken: result.NextPageToken,
4445
}
4546
if result.HasRepositoryValidationFact {
4647
response.RepositoryBreakageDegree = &result.RepositoryValidationFact.Degree
4748
}
49+
response.Projects = make([]*pb.ProjectValidation, 0, len(result.ProjectValidationFacts))
50+
for _, fact := range result.ProjectValidationFacts {
51+
degree := fact.Degree
52+
response.Projects = append(response.Projects, &pb.ProjectValidation{
53+
Project: fact.Project,
54+
BreakageDegree: &degree,
55+
})
56+
}
4857
return response
4958
}

‎service/stovepipe/server/mapper/project_status_test.go‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,17 @@ func TestProjectStatusRequestAndResponseMapping(t *testing.T) {
3535
RequestSummary: entity.RequestSummary{RequestID: "request/1", Queue: "queue", URI: "uri", BaseURI: "base", State: entity.RequestStateSucceeded},
3636
RepositoryValidationFact: entity.ValidationFact{Degree: degree},
3737
HasRepositoryValidationFact: true,
38+
ProjectValidationFacts: []entity.ValidationFact{{Project: "project-a", Degree: entity.DegreeBroken}},
39+
NextPageToken: "next-token",
3840
})
3941
assert.Equal(t, "request/1", response.GetRequestId())
4042
assert.Equal(t, "succeeded", response.GetRequestState())
4143
assert.Equal(t, &degree, response.RepositoryBreakageDegree)
4244
assert.False(t, response.GetProjectResultsComplete())
43-
assert.Empty(t, response.GetProjects())
45+
if !assert.Len(t, response.GetProjects(), 1) {
46+
return
47+
}
48+
assert.Equal(t, "project-a", response.GetProjects()[0].GetProject())
49+
assert.Equal(t, entity.DegreeBroken, response.GetProjects()[0].GetBreakageDegree())
50+
assert.Equal(t, "next-token", response.GetNextPageToken())
4451
}

‎stovepipe/controller/get_project_status_by_uri.go‎

Lines changed: 107 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@ package controller
1616

1717
import (
1818
"context"
19+
"encoding/base64"
20+
"encoding/json"
1921
"errors"
2022
"fmt"
2123
"math"
24+
"reflect"
2225

2326
"github.com/uber-go/tally"
2427
"github.com/uber/submitqueue/platform/errs"
@@ -28,7 +31,16 @@ import (
2831
"go.uber.org/zap"
2932
)
3033

31-
const maxProjectStatusPageSize = 200
34+
const (
35+
defaultProjectStatusPageSize = 50
36+
maxProjectStatusPageSize = 200
37+
)
38+
39+
type projectStatusPageToken struct {
40+
RequestID string `json:"request_id"`
41+
Projects []string `json:"projects"`
42+
NextIndex int `json:"next_index"`
43+
}
3244

3345
// ProjectStatusNotFoundError indicates that no validation request matches a lookup selector.
3446
type ProjectStatusNotFoundError struct {
@@ -111,23 +123,45 @@ func (c *GetProjectStatusByURIController) GetProjectStatusByURI(ctx context.Cont
111123
}
112124
result.RequestSummary = requestSummary
113125
result.UpdatedAtMs = requestSummary.StateTimestampMs
114-
fact, err := store.GetValidationFactStore().Get(ctx, requestSummary.URI, "")
126+
factStore := store.GetValidationFactStore()
127+
fact, err := factStore.Get(ctx, requestSummary.URI, "")
115128
if err != nil {
116-
if storage.IsNotFound(err) {
117-
return result, nil
129+
if !storage.IsNotFound(err) {
130+
return entity.GetProjectStatusByURIResult{}, fmt.Errorf("GetProjectStatusByURI failed to load repository fact for request %q: %w", requestSummary.RequestID, err)
131+
}
132+
} else {
133+
if err := validateRepositoryFact(fact, requestSummary); err != nil {
134+
return entity.GetProjectStatusByURIResult{}, err
135+
}
136+
result.RepositoryValidationFact = fact
137+
result.HasRepositoryValidationFact = true
138+
if fact.CreatedAt > result.UpdatedAtMs {
139+
result.UpdatedAtMs = fact.CreatedAt
118140
}
119-
return entity.GetProjectStatusByURIResult{}, fmt.Errorf("GetProjectStatusByURI failed to load repository fact for request %q: %w", requestSummary.RequestID, err)
120141
}
121-
if err := validateRepositoryFact(fact, requestSummary); err != nil {
142+
page, err := selectProjectStatusPage(req, requestSummary.RequestID)
143+
if err != nil {
122144
return entity.GetProjectStatusByURIResult{}, err
123145
}
124-
result.RepositoryValidationFact = fact
125-
result.HasRepositoryValidationFact = true
126-
if fact.CreatedAt > result.UpdatedAtMs {
127-
result.UpdatedAtMs = fact.CreatedAt
146+
for _, project := range page.projects {
147+
projectFact, err := factStore.Get(ctx, requestSummary.URI, project)
148+
if err != nil {
149+
if storage.IsNotFound(err) {
150+
continue
151+
}
152+
return entity.GetProjectStatusByURIResult{}, fmt.Errorf("GetProjectStatusByURI failed to load project fact for request %q and project %q: %w", requestSummary.RequestID, project, err)
153+
}
154+
if err := validateProjectFact(projectFact, requestSummary, project); err != nil {
155+
return entity.GetProjectStatusByURIResult{}, err
156+
}
157+
result.ProjectValidationFacts = append(result.ProjectValidationFacts, projectFact)
158+
if projectFact.CreatedAt > result.UpdatedAtMs {
159+
result.UpdatedAtMs = projectFact.CreatedAt
160+
}
128161
}
162+
result.NextPageToken = page.nextToken
129163

130-
c.logger.Debugw("project status retrieved", "request_id", requestSummary.RequestID, "queue", requestSummary.Queue, "change_uri", requestSummary.URI, "has_repository_result", true)
164+
c.logger.Debugw("project status retrieved", "request_id", requestSummary.RequestID, "queue", requestSummary.Queue, "change_uri", requestSummary.URI, "has_repository_result", result.HasRepositoryValidationFact, "project_results", len(result.ProjectValidationFacts))
131165
return result, nil
132166
}
133167

@@ -154,10 +188,60 @@ func validateProjectStatusRequest(req entity.GetProjectStatusByURIRequest) error
154188
if req.PageSize < 0 || req.PageSize > maxProjectStatusPageSize {
155189
return fmt.Errorf("GetProjectStatusByURI page_size must be between 0 and %d: %w", maxProjectStatusPageSize, ErrInvalidRequest)
156190
}
191+
return nil
192+
}
193+
194+
type projectStatusPage struct {
195+
projects []string
196+
nextToken string
197+
}
198+
199+
func selectProjectStatusPage(req entity.GetProjectStatusByURIRequest, requestID string) (projectStatusPage, error) {
200+
start := 0
157201
if req.PageToken != "" {
158-
return fmt.Errorf("GetProjectStatusByURI page_token is unsupported until project results are available: %w", ErrInvalidRequest)
202+
token, err := decodeProjectStatusPageToken(req.PageToken)
203+
if err != nil {
204+
return projectStatusPage{}, err
205+
}
206+
if token.RequestID != requestID || !reflect.DeepEqual(token.Projects, req.Projects) || token.NextIndex <= 0 || token.NextIndex >= len(req.Projects) {
207+
return projectStatusPage{}, fmt.Errorf("GetProjectStatusByURI page_token does not match the selected request and projects: %w", ErrInvalidRequest)
208+
}
209+
start = token.NextIndex
159210
}
160-
return nil
211+
pageSize := int(req.PageSize)
212+
if pageSize == 0 {
213+
pageSize = defaultProjectStatusPageSize
214+
}
215+
end := min(start+pageSize, len(req.Projects))
216+
page := projectStatusPage{projects: req.Projects[start:end]}
217+
if end < len(req.Projects) {
218+
token, err := encodeProjectStatusPageToken(projectStatusPageToken{RequestID: requestID, Projects: req.Projects, NextIndex: end})
219+
if err != nil {
220+
return projectStatusPage{}, fmt.Errorf("GetProjectStatusByURI failed to create page token: %w", err)
221+
}
222+
page.nextToken = token
223+
}
224+
return page, nil
225+
}
226+
227+
func encodeProjectStatusPageToken(token projectStatusPageToken) (string, error) {
228+
contents, err := json.Marshal(token)
229+
if err != nil {
230+
return "", err
231+
}
232+
return base64.RawURLEncoding.EncodeToString(contents), nil
233+
}
234+
235+
func decodeProjectStatusPageToken(encoded string) (projectStatusPageToken, error) {
236+
contents, err := base64.RawURLEncoding.DecodeString(encoded)
237+
if err != nil {
238+
return projectStatusPageToken{}, fmt.Errorf("GetProjectStatusByURI invalid page_token: %w", ErrInvalidRequest)
239+
}
240+
var token projectStatusPageToken
241+
if err := json.Unmarshal(contents, &token); err != nil {
242+
return projectStatusPageToken{}, fmt.Errorf("GetProjectStatusByURI invalid page_token: %w", ErrInvalidRequest)
243+
}
244+
return token, nil
161245
}
162246

163247
func validateRepositoryFact(fact entity.ValidationFact, requestSummary entity.RequestSummary) error {
@@ -169,3 +253,13 @@ func validateRepositoryFact(fact entity.ValidationFact, requestSummary entity.Re
169253
}
170254
return nil
171255
}
256+
257+
func validateProjectFact(fact entity.ValidationFact, requestSummary entity.RequestSummary, project string) error {
258+
if fact.URI != requestSummary.URI || fact.Project != project || fact.RequestID != requestSummary.RequestID {
259+
return &ProjectStatusConsistencyError{message: "project validation fact disagrees with stored request"}
260+
}
261+
if math.IsNaN(fact.Degree) || fact.Degree < entity.DegreeGreen || fact.Degree > entity.DegreeBroken {
262+
return &ProjectStatusConsistencyError{message: "project validation fact has an invalid degree"}
263+
}
264+
return nil
265+
}

‎stovepipe/controller/get_project_status_by_uri_test.go‎

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ func TestGetProjectStatusByURI(t *testing.T) {
5050
wantNotFound bool
5151
wantRetryable bool
5252
wantInvalid bool
53+
noProjectLookup bool
5354
wantConsistency bool
5455
}{
5556
{name: "in progress without fact", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, Projects: []string{"project-a"}}, factErr: storage.ErrNotFound},
@@ -61,7 +62,7 @@ func TestGetProjectStatusByURI(t *testing.T) {
6162
{name: "empty project", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, Projects: []string{""}}, wantInvalid: true},
6263
{name: "duplicate projects", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, Projects: []string{"project-a", "project-a"}}, wantInvalid: true},
6364
{name: "invalid page size", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, PageSize: maxProjectStatusPageSize + 1}, wantInvalid: true},
64-
{name: "page token before project results", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, PageToken: "token"}, wantInvalid: true},
65+
{name: "malformed page token", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, Projects: []string{"project-a"}, PageToken: "token"}, factErr: storage.ErrNotFound, wantInvalid: true, noProjectLookup: true},
6566
{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},
6667
}
6768

@@ -73,7 +74,7 @@ func TestGetProjectStatusByURI(t *testing.T) {
7374
uriStore := storagemock.NewMockRequestURIStore(mockCtrl)
7475
summaryStore := storagemock.NewMockRequestSummaryStore(mockCtrl)
7576
factStore := storagemock.NewMockValidationFactStore(mockCtrl)
76-
if !tt.wantInvalid {
77+
if !(tt.wantInvalid && tt.request.PageToken == "") {
7778
factory.EXPECT().For(storage.Config{QueueName: projectStatusQueue}).Return(store, nil)
7879
store.EXPECT().GetRequestURIStore().Return(uriStore)
7980
uriStore.EXPECT().GetIDByURI(gomock.Any(), projectStatusURI).Return(projectStatusID, tt.uriErr)
@@ -83,6 +84,11 @@ func TestGetProjectStatusByURI(t *testing.T) {
8384
if tt.summaryErr == nil {
8485
store.EXPECT().GetValidationFactStore().Return(factStore)
8586
factStore.EXPECT().Get(gomock.Any(), projectStatusURI, "").Return(tt.fact, tt.factErr)
87+
if !tt.noProjectLookup && !(tt.factErr == nil && tt.wantConsistency) {
88+
for _, project := range tt.request.Projects {
89+
factStore.EXPECT().Get(gomock.Any(), projectStatusURI, project).Return(entity.ValidationFact{}, storage.ErrNotFound)
90+
}
91+
}
8692
}
8793
}
8894
}
@@ -126,13 +132,107 @@ func TestGetProjectStatusByURIUsesRequestSummaryTimestamp(t *testing.T) {
126132
summaryStore.EXPECT().Get(gomock.Any(), projectStatusID).Return(requestSummary, nil)
127133
store.EXPECT().GetValidationFactStore().Return(factStore)
128134
factStore.EXPECT().Get(gomock.Any(), projectStatusURI, "").Return(entity.ValidationFact{URI: projectStatusURI, RequestID: projectStatusID, CreatedAt: 20}, nil)
135+
factStore.EXPECT().Get(gomock.Any(), projectStatusURI, "project-a").Return(entity.ValidationFact{}, storage.ErrNotFound)
129136

130137
controller := NewGetProjectStatusByURIController(zap.NewNop().Sugar(), tally.NoopScope, factory)
131138
got, err := controller.GetProjectStatusByURI(context.Background(), entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, Projects: []string{"project-a"}})
132139
require.NoError(t, err)
133140
assert.Equal(t, int64(20), got.UpdatedAtMs)
134141
}
135142

143+
func TestGetProjectStatusByURIReturnsProjectFactsInRequestedOrder(t *testing.T) {
144+
requestSummary := entity.RequestSummary{RequestID: projectStatusID, Queue: projectStatusQueue, URI: projectStatusURI, StateTimestampMs: 10}
145+
mockCtrl := gomock.NewController(t)
146+
factory := storagemock.NewMockFactory(mockCtrl)
147+
store := storagemock.NewMockStorage(mockCtrl)
148+
uriStore := storagemock.NewMockRequestURIStore(mockCtrl)
149+
summaryStore := storagemock.NewMockRequestSummaryStore(mockCtrl)
150+
factStore := storagemock.NewMockValidationFactStore(mockCtrl)
151+
factory.EXPECT().For(storage.Config{QueueName: projectStatusQueue}).Return(store, nil)
152+
store.EXPECT().GetRequestURIStore().Return(uriStore)
153+
uriStore.EXPECT().GetIDByURI(gomock.Any(), projectStatusURI).Return(projectStatusID, nil)
154+
store.EXPECT().GetRequestSummaryStore().Return(summaryStore)
155+
summaryStore.EXPECT().Get(gomock.Any(), projectStatusID).Return(requestSummary, nil)
156+
store.EXPECT().GetValidationFactStore().Return(factStore)
157+
factStore.EXPECT().Get(gomock.Any(), projectStatusURI, "").Return(entity.ValidationFact{}, storage.ErrNotFound)
158+
factStore.EXPECT().Get(gomock.Any(), projectStatusURI, "project-b").Return(entity.ValidationFact{URI: projectStatusURI, RequestID: projectStatusID, Project: "project-b", Degree: entity.DegreeBroken, CreatedAt: 20}, nil)
159+
factStore.EXPECT().Get(gomock.Any(), projectStatusURI, "missing").Return(entity.ValidationFact{}, storage.ErrNotFound)
160+
factStore.EXPECT().Get(gomock.Any(), projectStatusURI, "project-a").Return(entity.ValidationFact{URI: projectStatusURI, RequestID: projectStatusID, Project: "project-a", Degree: entity.DegreeGreen, CreatedAt: 30}, nil)
161+
162+
controller := NewGetProjectStatusByURIController(zap.NewNop().Sugar(), tally.NoopScope, factory)
163+
got, err := controller.GetProjectStatusByURI(context.Background(), entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, Projects: []string{"project-b", "missing", "project-a"}})
164+
require.NoError(t, err)
165+
require.Len(t, got.ProjectValidationFacts, 2)
166+
assert.Equal(t, []string{"project-b", "project-a"}, []string{got.ProjectValidationFacts[0].Project, got.ProjectValidationFacts[1].Project})
167+
assert.Equal(t, int64(30), got.UpdatedAtMs)
168+
assert.Empty(t, got.NextPageToken)
169+
assert.False(t, got.ProjectResultsComplete)
170+
}
171+
172+
func TestGetProjectStatusByURIPaginatesProjectFacts(t *testing.T) {
173+
requestSummary := entity.RequestSummary{RequestID: projectStatusID, Queue: projectStatusQueue, URI: projectStatusURI, StateTimestampMs: 10}
174+
projects := []string{"project-a", "project-b"}
175+
176+
firstPage := func(t *testing.T) string {
177+
mockCtrl := gomock.NewController(t)
178+
factory := storagemock.NewMockFactory(mockCtrl)
179+
store := storagemock.NewMockStorage(mockCtrl)
180+
uriStore := storagemock.NewMockRequestURIStore(mockCtrl)
181+
summaryStore := storagemock.NewMockRequestSummaryStore(mockCtrl)
182+
factStore := storagemock.NewMockValidationFactStore(mockCtrl)
183+
factory.EXPECT().For(storage.Config{QueueName: projectStatusQueue}).Return(store, nil)
184+
store.EXPECT().GetRequestURIStore().Return(uriStore)
185+
uriStore.EXPECT().GetIDByURI(gomock.Any(), projectStatusURI).Return(projectStatusID, nil)
186+
store.EXPECT().GetRequestSummaryStore().Return(summaryStore)
187+
summaryStore.EXPECT().Get(gomock.Any(), projectStatusID).Return(requestSummary, nil)
188+
store.EXPECT().GetValidationFactStore().Return(factStore)
189+
factStore.EXPECT().Get(gomock.Any(), projectStatusURI, "").Return(entity.ValidationFact{}, storage.ErrNotFound)
190+
factStore.EXPECT().Get(gomock.Any(), projectStatusURI, "project-a").Return(entity.ValidationFact{URI: projectStatusURI, RequestID: projectStatusID, Project: "project-a", Degree: entity.DegreeGreen}, nil)
191+
192+
got, err := NewGetProjectStatusByURIController(zap.NewNop().Sugar(), tally.NoopScope, factory).GetProjectStatusByURI(context.Background(), entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, Projects: projects, PageSize: 1})
193+
require.NoError(t, err)
194+
require.Len(t, got.ProjectValidationFacts, 1)
195+
assert.Equal(t, "project-a", got.ProjectValidationFacts[0].Project)
196+
require.NotEmpty(t, got.NextPageToken)
197+
return got.NextPageToken
198+
}(t)
199+
200+
mockCtrl := gomock.NewController(t)
201+
factory := storagemock.NewMockFactory(mockCtrl)
202+
store := storagemock.NewMockStorage(mockCtrl)
203+
uriStore := storagemock.NewMockRequestURIStore(mockCtrl)
204+
summaryStore := storagemock.NewMockRequestSummaryStore(mockCtrl)
205+
factStore := storagemock.NewMockValidationFactStore(mockCtrl)
206+
factory.EXPECT().For(storage.Config{QueueName: projectStatusQueue}).Return(store, nil)
207+
store.EXPECT().GetRequestURIStore().Return(uriStore)
208+
uriStore.EXPECT().GetIDByURI(gomock.Any(), projectStatusURI).Return(projectStatusID, nil)
209+
store.EXPECT().GetRequestSummaryStore().Return(summaryStore)
210+
summaryStore.EXPECT().Get(gomock.Any(), projectStatusID).Return(requestSummary, nil)
211+
store.EXPECT().GetValidationFactStore().Return(factStore)
212+
factStore.EXPECT().Get(gomock.Any(), projectStatusURI, "").Return(entity.ValidationFact{}, storage.ErrNotFound)
213+
factStore.EXPECT().Get(gomock.Any(), projectStatusURI, "project-b").Return(entity.ValidationFact{URI: projectStatusURI, RequestID: projectStatusID, Project: "project-b", Degree: entity.DegreeBroken}, nil)
214+
got, err := NewGetProjectStatusByURIController(zap.NewNop().Sugar(), tally.NoopScope, factory).GetProjectStatusByURI(context.Background(), entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, Projects: projects, PageSize: 1, PageToken: firstPage})
215+
require.NoError(t, err)
216+
require.Len(t, got.ProjectValidationFacts, 1)
217+
assert.Equal(t, "project-b", got.ProjectValidationFacts[0].Project)
218+
assert.Empty(t, got.NextPageToken)
219+
}
220+
221+
func TestSelectProjectStatusPageRejectsMismatchedToken(t *testing.T) {
222+
token, err := encodeProjectStatusPageToken(projectStatusPageToken{RequestID: projectStatusID, Projects: []string{"project-a", "project-b"}, NextIndex: 1})
223+
require.NoError(t, err)
224+
_, err = selectProjectStatusPage(entity.GetProjectStatusByURIRequest{Projects: []string{"project-b", "project-a"}, PageToken: token}, projectStatusID)
225+
require.Error(t, err)
226+
assert.True(t, IsInvalidRequest(err))
227+
}
228+
229+
func TestValidateProjectFact(t *testing.T) {
230+
requestSummary := entity.RequestSummary{RequestID: projectStatusID, URI: projectStatusURI}
231+
assert.NoError(t, validateProjectFact(entity.ValidationFact{URI: projectStatusURI, RequestID: projectStatusID, Project: "project-a", Degree: entity.DegreeGreen}, requestSummary, "project-a"))
232+
assert.True(t, IsProjectStatusConsistency(validateProjectFact(entity.ValidationFact{URI: projectStatusURI, RequestID: projectStatusID, Project: "project-b", Degree: entity.DegreeGreen}, requestSummary, "project-a")))
233+
assert.True(t, IsProjectStatusConsistency(validateProjectFact(entity.ValidationFact{URI: projectStatusURI, RequestID: projectStatusID, Project: "project-a", Degree: math.NaN()}, requestSummary, "project-a")))
234+
}
235+
136236
func TestGetProjectStatusByURIRejectsInconsistentRequest(t *testing.T) {
137237
mockCtrl := gomock.NewController(t)
138238
factory := storagemock.NewMockFactory(mockCtrl)

0 commit comments

Comments
 (0)