diff --git a/stovepipe/controller/BUILD.bazel b/stovepipe/controller/BUILD.bazel index 22db63425..a1f7b88f0 100644 --- a/stovepipe/controller/BUILD.bazel +++ b/stovepipe/controller/BUILD.bazel @@ -10,6 +10,7 @@ go_library( "list_open_issues.go", "list_pagination.go", "ping.go", + "project_issues.go", "project_status.go", "read_errors.go", "request_history.go", @@ -47,6 +48,7 @@ go_test( "list_pagination_test.go", "list_test.go", "ping_test.go", + "project_issues_test.go", "project_status_test.go", "request_history_test.go", ], diff --git a/stovepipe/controller/mock/BUILD.bazel b/stovepipe/controller/mock/BUILD.bazel index fef8cb127..2f86dc6eb 100644 --- a/stovepipe/controller/mock/BUILD.bazel +++ b/stovepipe/controller/mock/BUILD.bazel @@ -5,6 +5,7 @@ go_library( srcs = [ "get_issues_by_request_id_mock.go", "list_open_issues_mock.go", + "project_issues_mock.go", "project_status_mock.go", "request_history_mock.go", ], diff --git a/stovepipe/controller/mock/project_issues_mock.go b/stovepipe/controller/mock/project_issues_mock.go new file mode 100644 index 000000000..1a35315c8 --- /dev/null +++ b/stovepipe/controller/mock/project_issues_mock.go @@ -0,0 +1,72 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: project_issues.go +// +// Generated by this command: +// +// mockgen -source=project_issues.go -destination=mock/project_issues_mock.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + entity "github.com/uber/submitqueue/stovepipe/entity" + gomock "go.uber.org/mock/gomock" +) + +// MockProjectIssuesController is a mock of ProjectIssuesController interface. +type MockProjectIssuesController struct { + ctrl *gomock.Controller + recorder *MockProjectIssuesControllerMockRecorder + isgomock struct{} +} + +// MockProjectIssuesControllerMockRecorder is the mock recorder for MockProjectIssuesController. +type MockProjectIssuesControllerMockRecorder struct { + mock *MockProjectIssuesController +} + +// NewMockProjectIssuesController creates a new mock instance. +func NewMockProjectIssuesController(ctrl *gomock.Controller) *MockProjectIssuesController { + mock := &MockProjectIssuesController{ctrl: ctrl} + mock.recorder = &MockProjectIssuesControllerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockProjectIssuesController) EXPECT() *MockProjectIssuesControllerMockRecorder { + return m.recorder +} + +// ListRequestIssuesByProject mocks base method. +func (m *MockProjectIssuesController) ListRequestIssuesByProject(arg0 context.Context, arg1 entity.ListRequestIssuesByProjectRequest) (entity.ListRequestProjectIssuesResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListRequestIssuesByProject", arg0, arg1) + ret0, _ := ret[0].(entity.ListRequestProjectIssuesResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListRequestIssuesByProject indicates an expected call of ListRequestIssuesByProject. +func (mr *MockProjectIssuesControllerMockRecorder) ListRequestIssuesByProject(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRequestIssuesByProject", reflect.TypeOf((*MockProjectIssuesController)(nil).ListRequestIssuesByProject), arg0, arg1) +} + +// ListRequestProjectsByIssue mocks base method. +func (m *MockProjectIssuesController) ListRequestProjectsByIssue(arg0 context.Context, arg1 entity.ListRequestProjectsByIssueRequest) (entity.ListRequestProjectIssuesResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListRequestProjectsByIssue", arg0, arg1) + ret0, _ := ret[0].(entity.ListRequestProjectIssuesResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListRequestProjectsByIssue indicates an expected call of ListRequestProjectsByIssue. +func (mr *MockProjectIssuesControllerMockRecorder) ListRequestProjectsByIssue(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRequestProjectsByIssue", reflect.TypeOf((*MockProjectIssuesController)(nil).ListRequestProjectsByIssue), arg0, arg1) +} diff --git a/stovepipe/controller/project_issues.go b/stovepipe/controller/project_issues.go new file mode 100644 index 000000000..f61a2ef3f --- /dev/null +++ b/stovepipe/controller/project_issues.go @@ -0,0 +1,180 @@ +// 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 + +//go:generate mockgen -source=project_issues.go -destination=mock/project_issues_mock.go -package=mock + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + + "github.com/uber-go/tally" + "github.com/uber/submitqueue/platform/metrics" + "github.com/uber/submitqueue/stovepipe/entity" + "github.com/uber/submitqueue/stovepipe/extension/storage" + "go.uber.org/zap" +) + +const projectIssuesPageTokenVersion = 1 + +type projectIssueScope struct { + Queue string `json:"queue"` + RequestID string `json:"request_id"` + TaskID string `json:"task_id,omitempty"` + UnitID string `json:"unit_id,omitempty"` + Project string `json:"project,omitempty"` +} + +type projectIssuesPageToken struct { + Version int `json:"version"` + Scope projectIssueScope `json:"scope"` + After entity.ProjectIssueAttribution `json:"after"` +} + +type ProjectIssuesController interface { + ListRequestProjectsByIssue(context.Context, entity.ListRequestProjectsByIssueRequest) (entity.ListRequestProjectIssuesResult, error) + ListRequestIssuesByProject(context.Context, entity.ListRequestIssuesByProjectRequest) (entity.ListRequestProjectIssuesResult, error) +} + +type projectIssuesController struct { + logger *zap.SugaredLogger + scope tally.Scope + stores storage.Factory + configuredQueues map[string]struct{} +} + +func NewProjectIssuesController(logger *zap.SugaredLogger, scope tally.Scope, stores storage.Factory, configuredQueues []string) ProjectIssuesController { + queues := make(map[string]struct{}, len(configuredQueues)) + for _, queue := range configuredQueues { + queues[queue] = struct{}{} + } + return &projectIssuesController{logger: logger, scope: scope.SubScope("project_issues_controller"), stores: stores, configuredQueues: queues} +} + +func (c *projectIssuesController) ListRequestProjectsByIssue(ctx context.Context, req entity.ListRequestProjectsByIssueRequest) (entity.ListRequestProjectIssuesResult, error) { + if req.TaskID == "" || len(req.TaskID) > 255 { + return entity.ListRequestProjectIssuesResult{}, fmt.Errorf("task ID must be nonempty and at most 255 bytes: %w", ErrInvalidRequest) + } + if req.UnitID == "" || len(req.UnitID) > 1024 { + return entity.ListRequestProjectIssuesResult{}, fmt.Errorf("unit ID must be nonempty and at most 1024 bytes: %w", ErrInvalidRequest) + } + return c.list(ctx, projectIssueScope{Queue: req.Queue, RequestID: req.RequestID, TaskID: req.TaskID, UnitID: req.UnitID}, req.PageSize, req.PageToken) +} + +func (c *projectIssuesController) ListRequestIssuesByProject(ctx context.Context, req entity.ListRequestIssuesByProjectRequest) (entity.ListRequestProjectIssuesResult, error) { + if req.Project == "" || len(req.Project) > 1020 { + return entity.ListRequestProjectIssuesResult{}, fmt.Errorf("project must be nonempty and at most 1020 bytes: %w", ErrInvalidRequest) + } + return c.list(ctx, projectIssueScope{Queue: req.Queue, RequestID: req.RequestID, Project: req.Project}, req.PageSize, req.PageToken) +} + +func (c *projectIssuesController) list(ctx context.Context, scope projectIssueScope, pageSize int32, pageToken string) (result entity.ListRequestProjectIssuesResult, retErr error) { + op := metrics.Begin(c.scope, "list", metrics.StorageLatencyBuckets, metrics.TagsFromContext(ctx)...) + defer func() { op.Complete(retErr) }() + + if scope.Queue == "" || len(scope.Queue) > 255 { + return result, fmt.Errorf("queue must be nonempty and at most 255 bytes: %w", ErrInvalidRequest) + } + if _, ok := c.configuredQueues[scope.Queue]; !ok { + return result, fmt.Errorf("queue %q is not configured: %w", scope.Queue, ErrInvalidRequest) + } + if err := validateRequestID(scope.RequestID); err != nil { + return result, err + } + after, limit, err := resolveProjectIssuePage(scope, pageSize, pageToken) + if err != nil { + return result, err + } + store, err := c.stores.For(storage.Config{QueueName: scope.Queue}) + if err != nil { + return result, fmt.Errorf("resolve request attribution storage: %w", err) + } + history, err := store.GetRequestLogStore().List(ctx, scope.RequestID) + if err != nil { + return result, fmt.Errorf("read request attribution history: %w", err) + } + for _, log := range history { + if log.Event == entity.RequestEventProjectIssuesRecorded { + result.AttributionComplete = true + break + } + } + // Do not expose a changing set while recording is still adding associations. + if !result.AttributionComplete { + return result, nil + } + + var entries []entity.ProjectIssueAttribution + if scope.Project != "" { + entries, err = store.GetRequestIssuesByProjectStore().List(ctx, scope.RequestID, scope.Project, after, limit) + } else { + entries, err = store.GetRequestProjectsByIssueStore().ListByIssue(ctx, scope.RequestID, scope.TaskID, scope.UnitID, after.Project, limit) + } + if err != nil { + return entity.ListRequestProjectIssuesResult{}, fmt.Errorf("list request project issue associations: %w", err) + } + result.Attributions = entries[:min(len(entries), limit-1)] + if len(entries) > limit-1 { + last := result.Attributions[len(result.Attributions)-1] + token, err := json.Marshal(projectIssuesPageToken{Version: projectIssuesPageTokenVersion, Scope: scope, After: last}) + if err != nil { + return entity.ListRequestProjectIssuesResult{}, fmt.Errorf("encode request attribution continuation: %w", err) + } + result.NextPageToken = base64.RawURLEncoding.EncodeToString(token) + } + c.logger.Debugw("request attribution listed", "queue", scope.Queue, "request_id", scope.RequestID, "count", len(result.Attributions)) + return result, nil +} + +func resolveProjectIssuePage(scope projectIssueScope, pageSize int32, encoded string) (entity.ProjectIssueAttribution, int, error) { + var after entity.ProjectIssueAttribution + if pageSize < 0 || pageSize > maxListPageSize { + return after, 0, fmt.Errorf("page_size must be between 0 and %d: %w", maxListPageSize, ErrInvalidRequest) + } + if pageSize == 0 { + pageSize = defaultListPageSize + } + if encoded == "" { + return after, int(pageSize) + 1, nil + } + contents, err := base64.RawURLEncoding.Strict().DecodeString(encoded) + if err != nil { + return after, 0, fmt.Errorf("invalid page_token encoding: %w", ErrInvalidRequest) + } + var token projectIssuesPageToken + if err := json.Unmarshal(contents, &token); err != nil { + return after, 0, fmt.Errorf("invalid page_token contents: %w", ErrInvalidRequest) + } + if token.Version != projectIssuesPageTokenVersion || token.Scope != scope { + return after, 0, fmt.Errorf("page_token does not match the lookup: %w", ErrInvalidRequest) + } + after = token.After + if err := after.Validate(); err != nil { + return after, 0, fmt.Errorf("invalid page_token cursor: %w", ErrInvalidRequest) + } + if len(after.TaskID) > 255 || len(after.UnitID) > 1024 || len(after.Project) > 1020 { + return after, 0, fmt.Errorf("page_token cursor exceeds identifier limits: %w", ErrInvalidRequest) + } + if scope.Project != "" { + if after.Project != scope.Project { + return after, 0, fmt.Errorf("page_token cursor does not match the project: %w", ErrInvalidRequest) + } + } else if after.TaskID != scope.TaskID || after.UnitID != scope.UnitID { + return after, 0, fmt.Errorf("page_token cursor does not match the issue: %w", ErrInvalidRequest) + } + return after, int(pageSize) + 1, nil +} diff --git a/stovepipe/controller/project_issues_test.go b/stovepipe/controller/project_issues_test.go new file mode 100644 index 000000000..9d97a0072 --- /dev/null +++ b/stovepipe/controller/project_issues_test.go @@ -0,0 +1,270 @@ +// 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" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber-go/tally" + "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" +) + +var attributionScopes = []projectIssueScope{ + {Queue: "queue", RequestID: "7", TaskID: "test", UnitID: "unit"}, + {Queue: "queue", RequestID: "7", Project: "app"}, +} + +type projectIssuesFixture struct { + controller ProjectIssuesController + factory *storagemock.MockFactory + stores *storagemock.MockStorage + history *storagemock.MockRequestLogStore + source *storagemock.MockRequestProjectsByIssueStore + reverse *storagemock.MockRequestIssuesByProjectStore +} + +func newProjectIssuesFixture(t *testing.T) projectIssuesFixture { + t.Helper() + ctrl := gomock.NewController(t) + f := projectIssuesFixture{ + factory: storagemock.NewMockFactory(ctrl), stores: storagemock.NewMockStorage(ctrl), + history: storagemock.NewMockRequestLogStore(ctrl), source: storagemock.NewMockRequestProjectsByIssueStore(ctrl), + reverse: storagemock.NewMockRequestIssuesByProjectStore(ctrl), + } + f.controller = NewProjectIssuesController(zap.NewNop().Sugar(), tally.NoopScope, f.factory, []string{"queue", "other"}) + f.stores.EXPECT().GetRequestLogStore().Return(f.history).AnyTimes() + f.stores.EXPECT().GetRequestProjectsByIssueStore().Return(f.source).AnyTimes() + f.stores.EXPECT().GetRequestIssuesByProjectStore().Return(f.reverse).AnyTimes() + return f +} + +func (f projectIssuesFixture) list(scope projectIssueScope, size int32, token string) (entity.ListRequestProjectIssuesResult, error) { + if scope.Project != "" { + return f.controller.ListRequestIssuesByProject(context.Background(), entity.ListRequestIssuesByProjectRequest{ + Queue: scope.Queue, RequestID: scope.RequestID, Project: scope.Project, PageSize: size, PageToken: token, + }) + } + return f.controller.ListRequestProjectsByIssue(context.Background(), entity.ListRequestProjectsByIssueRequest{ + Queue: scope.Queue, RequestID: scope.RequestID, TaskID: scope.TaskID, UnitID: scope.UnitID, PageSize: size, PageToken: token, + }) +} + +func (f projectIssuesFixture) expectHistory(scope projectIssueScope, history []entity.RequestLog) { + f.factory.EXPECT().For(storage.Config{QueueName: scope.Queue}).Return(f.stores, nil) + f.history.EXPECT().List(gomock.Any(), scope.RequestID).Return(history, nil) +} + +func (f projectIssuesFixture) expectPage(scope projectIssueScope, after entity.ProjectIssueAttribution, limit int, rows []entity.ProjectIssueAttribution, err error) { + f.expectHistory(scope, []entity.RequestLog{{Event: entity.RequestEventProjectIssuesRecorded}}) + if scope.Project != "" { + f.reverse.EXPECT().List(gomock.Any(), scope.RequestID, scope.Project, after, limit).Return(rows, err) + } else { + f.source.EXPECT().ListByIssue(gomock.Any(), scope.RequestID, scope.TaskID, scope.UnitID, after.Project, limit).Return(rows, err) + } +} + +func TestProjectIssuesPages(t *testing.T) { + for _, scope := range attributionScopes { + for count := 0; count <= 3; count++ { + t.Run(fmt.Sprintf("%s/%s/%d", scope.TaskID, scope.Project, count), func(t *testing.T) { + f := newProjectIssuesFixture(t) + rows := make([]entity.ProjectIssueAttribution, 3) + for i := range rows { + rows[i] = entity.ProjectIssueAttribution{TaskID: scope.TaskID, UnitID: scope.UnitID, Project: string(rune('a' + i))} + if scope.Project != "" { + rows[i] = entity.ProjectIssueAttribution{TaskID: string(rune('a' + i)), UnitID: "unit", Project: scope.Project} + } + } + f.expectPage(scope, entity.ProjectIssueAttribution{}, 3, rows[:count], nil) + page, err := f.list(scope, 2, "") + require.NoError(t, err) + require.True(t, page.AttributionComplete) + require.Equal(t, rows[:min(count, 2)], page.Attributions) + if count <= 2 { + require.Empty(t, page.NextPageToken) + return + } + require.NotEmpty(t, page.NextPageToken) + f.expectPage(scope, rows[1], 2, rows[2:], nil) + last, err := f.list(scope, 1, page.NextPageToken) + require.NoError(t, err) + require.Equal(t, rows[2:], last.Attributions) + require.Empty(t, last.NextPageToken) + require.True(t, last.AttributionComplete) + }) + } + } +} + +func TestProjectIssuesWaitsForCompletion(t *testing.T) { + for _, scope := range attributionScopes { + for _, event := range []entity.RequestEvent{ + entity.RequestEventUnknown, entity.RequestEventBuildAnalysisCompleted, + entity.RequestEventProjectFactsRecorded, entity.RequestEventRecordAbandoned, + } { + t.Run(fmt.Sprintf("%s/%s/%s", scope.TaskID, scope.Project, event), func(t *testing.T) { + f := newProjectIssuesFixture(t) + f.expectHistory(scope, []entity.RequestLog{{Event: event}}) + got, err := f.list(scope, 1, "") + require.NoError(t, err) + require.Equal(t, entity.ListRequestProjectIssuesResult{}, got) + }) + } + } +} + +func TestProjectIssuesDefaultPageSize(t *testing.T) { + for _, scope := range attributionScopes { + f := newProjectIssuesFixture(t) + f.expectPage(scope, entity.ProjectIssueAttribution{}, 51, nil, nil) + got, err := f.list(scope, 0, "") + require.NoError(t, err) + require.True(t, got.AttributionComplete) + require.Empty(t, got.NextPageToken) + } +} + +func TestProjectIssuesFailures(t *testing.T) { + for _, scope := range attributionScopes { + for _, step := range []string{"factory", "history", "page", "missing request", "cancelled"} { + t.Run(fmt.Sprintf("%s/%s/%s", scope.TaskID, scope.Project, step), func(t *testing.T) { + f := newProjectIssuesFixture(t) + want := assert.AnError + switch step { + case "factory": + f.factory.EXPECT().For(storage.Config{QueueName: scope.Queue}).Return(nil, want) + case "page": + f.expectPage(scope, entity.ProjectIssueAttribution{}, 3, nil, want) + default: + if step == "missing request" { + want = storage.ErrNotFound + } + if step == "cancelled" { + want = context.Canceled + } + f.factory.EXPECT().For(storage.Config{QueueName: scope.Queue}).Return(f.stores, nil) + f.history.EXPECT().List(gomock.Any(), scope.RequestID).Return(nil, want) + } + got, err := f.list(scope, 2, "") + require.ErrorIs(t, err, want) + require.Empty(t, got) + }) + } + } +} + +func TestProjectIssuesInvalidRequests(t *testing.T) { + for _, scope := range attributionScopes { + for _, tt := range []struct { + name string + change func(*projectIssueScope) + size int32 + }{ + {name: "empty queue", change: func(s *projectIssueScope) { s.Queue = "" }}, + {name: "unknown queue", change: func(s *projectIssueScope) { s.Queue = "unknown" }}, + {name: "long queue", change: func(s *projectIssueScope) { s.Queue = strings.Repeat("q", 256) }}, + {name: "empty request", change: func(s *projectIssueScope) { s.RequestID = "" }}, + {name: "noncanonical request", change: func(s *projectIssueScope) { s.RequestID = "07" }}, + {name: "invalid request", change: func(s *projectIssueScope) { s.RequestID = "request" }}, + {name: "negative page", size: -1}, {name: "oversize page", size: 201}, + } { + t.Run(fmt.Sprintf("%s/%s/%s", scope.TaskID, scope.Project, tt.name), func(t *testing.T) { + f := newProjectIssuesFixture(t) + query := scope + if tt.change != nil { + tt.change(&query) + } + _, err := f.list(query, tt.size, "") + require.ErrorIs(t, err, ErrInvalidRequest) + }) + } + } + for _, req := range []entity.ListRequestProjectsByIssueRequest{ + {Queue: "queue", RequestID: "7", UnitID: "unit"}, + {Queue: "queue", RequestID: "7", TaskID: "test"}, + {Queue: "queue", RequestID: "7", TaskID: strings.Repeat("t", 256), UnitID: "unit"}, + {Queue: "queue", RequestID: "7", TaskID: "test", UnitID: strings.Repeat("u", 1025)}, + } { + f := newProjectIssuesFixture(t) + _, err := f.controller.ListRequestProjectsByIssue(context.Background(), req) + require.ErrorIs(t, err, ErrInvalidRequest) + } + for _, project := range []string{"", strings.Repeat("p", 1021)} { + f := newProjectIssuesFixture(t) + _, err := f.controller.ListRequestIssuesByProject(context.Background(), entity.ListRequestIssuesByProjectRequest{Queue: "queue", RequestID: "7", Project: project}) + require.ErrorIs(t, err, ErrInvalidRequest) + } +} + +func TestProjectIssuesRejectsInvalidTokens(t *testing.T) { + for _, scope := range attributionScopes { + after := entity.ProjectIssueAttribution{TaskID: scope.TaskID, UnitID: scope.UnitID, Project: "a"} + if scope.Project != "" { + after = entity.ProjectIssueAttribution{TaskID: "a", UnitID: "unit", Project: scope.Project} + } + for _, tt := range []struct { + name string + change func(*projectIssuesPageToken) + }{ + {"version", func(p *projectIssuesPageToken) { p.Version++ }}, + {"queue", func(p *projectIssuesPageToken) { p.Scope.Queue = "other" }}, + {"request", func(p *projectIssuesPageToken) { p.Scope.RequestID = "8" }}, + {"task", func(p *projectIssuesPageToken) { p.Scope.TaskID += "other" }}, + {"unit", func(p *projectIssuesPageToken) { p.Scope.UnitID += "other" }}, + {"project", func(p *projectIssuesPageToken) { p.Scope.Project += "other" }}, + {"direction", func(p *projectIssuesPageToken) { + p.Scope = attributionScopes[0] + if scope.Project == "" { + p.Scope = attributionScopes[1] + } + }}, + {"empty cursor", func(p *projectIssuesPageToken) { p.After = entity.ProjectIssueAttribution{} }}, + {"long cursor", func(p *projectIssuesPageToken) { p.After.Project = strings.Repeat("p", 1021) }}, + {"cursor scope", func(p *projectIssuesPageToken) { + if scope.Project != "" { + p.After.Project = "other" + } else { + p.After.UnitID = "other" + } + }}, + } { + t.Run(fmt.Sprintf("%s/%s/%s", scope.TaskID, scope.Project, tt.name), func(t *testing.T) { + token := projectIssuesPageToken{Version: 1, Scope: scope, After: after} + tt.change(&token) + contents, err := json.Marshal(token) + require.NoError(t, err) + f := newProjectIssuesFixture(t) + _, err = f.list(scope, 1, base64.RawURLEncoding.EncodeToString(contents)) + require.ErrorIs(t, err, ErrInvalidRequest) + }) + } + for _, token := range []string{"not base64!", base64.RawURLEncoding.EncodeToString([]byte("not json"))} { + f := newProjectIssuesFixture(t) + _, err := f.list(scope, 1, token) + require.ErrorIs(t, err, ErrInvalidRequest) + } + } +} diff --git a/stovepipe/entity/BUILD.bazel b/stovepipe/entity/BUILD.bazel index b23baa740..733541c97 100644 --- a/stovepipe/entity/BUILD.bazel +++ b/stovepipe/entity/BUILD.bazel @@ -16,6 +16,7 @@ go_library( "open_issue.go", "project_fact_by_degree.go", "project_issue_attribution.go", + "project_issues.go", "project_status.go", "queue.go", "queue_config.go", diff --git a/stovepipe/entity/project_issues.go b/stovepipe/entity/project_issues.go new file mode 100644 index 000000000..397cfd489 --- /dev/null +++ b/stovepipe/entity/project_issues.go @@ -0,0 +1,54 @@ +// 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 + +type ListRequestProjectsByIssueRequest struct { + // Queue identifies the configured validation queue. + Queue string + // RequestID is the queue-scoped canonical decimal request ID. + RequestID string + // TaskID identifies the validation task. + TaskID string + // UnitID identifies the failed unit and must be nonempty. + UnitID string + // PageSize bounds returned associations; zero selects 50, otherwise 1 through 200. + PageSize int32 + // PageToken continues the same request and issue lookup. + PageToken string +} + +type ListRequestIssuesByProjectRequest struct { + // Queue identifies the configured validation queue. + Queue string + // RequestID is the queue-scoped canonical decimal request ID. + RequestID string + // Project identifies the affected project. + Project string + // PageSize bounds returned associations; zero selects 50, otherwise 1 through 200. + PageSize int32 + // PageToken continues the same request and project lookup. + PageToken string +} + +type ListRequestProjectIssuesResult struct { + // Attributions contains immutable pairs, ordered by project for an issue lookup + // or by task/unit for a project lookup. Empty until attribution is complete. + Attributions []ProjectIssueAttribution + // AttributionComplete is true only after all request associations were recorded. + // False does not establish that the issue or project has no associations. + AttributionComplete bool + // NextPageToken continues after the last returned pair; empty ends the range. + NextPageToken string +}