From c10d3b4a4632e06bb05f4b60267e05d531bc274b Mon Sep 17 00:00:00 2001 From: "prath.shenoy" Date: Mon, 9 Nov 2026 17:01:16 +0000 Subject: [PATCH] feat(stovepipe): Add project issue controller ## Summary **What**: - Add request-scoped issue/project lookup behavior with completion checks and pagination. **Why**: - Put the completion and pagination rules in one place before exposing the API. This keeps partially written associations hidden and lets both lookup directions read bounded pages without copying that logic into transport handlers. ## Test Plan - [x] Add unit tests. ## Revert Plan - Revert this PR. The read controller is not wired into the service yet, and no schemas or stored data change. ## Issue Links - [CODEM-632](https://linear.app/uber/issue/CODEM-632/projects-issues) --- stovepipe/controller/BUILD.bazel | 2 + stovepipe/controller/mock/BUILD.bazel | 1 + .../controller/mock/project_issues_mock.go | 72 +++++ stovepipe/controller/project_issues.go | 180 ++++++++++++ stovepipe/controller/project_issues_test.go | 270 ++++++++++++++++++ stovepipe/entity/BUILD.bazel | 1 + stovepipe/entity/project_issues.go | 54 ++++ 7 files changed, 580 insertions(+) create mode 100644 stovepipe/controller/mock/project_issues_mock.go create mode 100644 stovepipe/controller/project_issues.go create mode 100644 stovepipe/controller/project_issues_test.go create mode 100644 stovepipe/entity/project_issues.go 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 +}