From 36a6b6966139af9ce895726701bdc0fe0ca680c8 Mon Sep 17 00:00:00 2001 From: Preetam Dwivedi Date: Thu, 4 Jun 2026 13:29:24 -0700 Subject: [PATCH] feat(extensions): fake implementations with error injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary ### Why? Extensions over external systems (changeprovider, buildrunner, pusher, mergechecker) had no runnable stub to exercise their success — and especially failure — paths without standing up the real backend (GitHub/CI/git). scorer and conflict had deterministic stubs (heuristic, composite, all, none) but no way to inject errors. These fakes let tests drive both happy and error paths end-to-end from a land request. ### What? - buildrunner/fake, changeprovider/fake, pusher/fake, mergechecker/fake: best-case by default; inject failures via an `sq-fake=` marker in a change URI (e.g. build-fail, conflict, unmergeable, provider-error). - scorer/fake, conflict/fake: decorators that wrap an existing impl (the "pick") and overlay error injection — scorer via the URI marker (score-error) over entity.BatchChanges, analyzer via a predicate, since Analyze sees batches, not change URIs. - Each fake exposes only New(...) (decorator constructors for scorer/ conflict). No factory implementations live in extension/* — those belong in the wiring layer. - pusher/README.md documents the fake. These packages are test/example stubs, never production. They are wired into the example orchestrator in a follow-up PR. ## Test Plan - ✅ `make test` — fake package tests - ✅ `bazel build //...` --- submitqueue/core/fakemarker/BUILD.bazel | 19 +++ submitqueue/core/fakemarker/fakemarker.go | 58 +++++++++ .../core/fakemarker/fakemarker_test.go | 115 ++++++++++++++++ .../extension/buildrunner/fake/BUILD.bazel | 25 ++++ .../extension/buildrunner/fake/fake.go | 106 +++++++++++++++ .../extension/buildrunner/fake/fake_test.go | 123 ++++++++++++++++++ .../extension/changeprovider/fake/BUILD.bazel | 25 ++++ .../extension/changeprovider/fake/fake.go | 62 +++++++++ .../changeprovider/fake/fake_test.go | 66 ++++++++++ .../extension/conflict/fake/BUILD.bazel | 26 ++++ submitqueue/extension/conflict/fake/fake.go | 62 +++++++++ .../extension/conflict/fake/fake_test.go | 68 ++++++++++ .../extension/mergechecker/fake/BUILD.bazel | 25 ++++ .../extension/mergechecker/fake/fake.go | 65 +++++++++ .../extension/mergechecker/fake/fake_test.go | 78 +++++++++++ submitqueue/extension/pusher/README.md | 4 + submitqueue/extension/pusher/fake/BUILD.bazel | 25 ++++ submitqueue/extension/pusher/fake/fake.go | 79 +++++++++++ .../extension/pusher/fake/fake_test.go | 69 ++++++++++ submitqueue/extension/scorer/fake/BUILD.bazel | 27 ++++ submitqueue/extension/scorer/fake/fake.go | 68 ++++++++++ .../extension/scorer/fake/fake_test.go | 62 +++++++++ 22 files changed, 1257 insertions(+) create mode 100644 submitqueue/core/fakemarker/BUILD.bazel create mode 100644 submitqueue/core/fakemarker/fakemarker.go create mode 100644 submitqueue/core/fakemarker/fakemarker_test.go create mode 100644 submitqueue/extension/buildrunner/fake/BUILD.bazel create mode 100644 submitqueue/extension/buildrunner/fake/fake.go create mode 100644 submitqueue/extension/buildrunner/fake/fake_test.go create mode 100644 submitqueue/extension/changeprovider/fake/BUILD.bazel create mode 100644 submitqueue/extension/changeprovider/fake/fake.go create mode 100644 submitqueue/extension/changeprovider/fake/fake_test.go create mode 100644 submitqueue/extension/conflict/fake/BUILD.bazel create mode 100644 submitqueue/extension/conflict/fake/fake.go create mode 100644 submitqueue/extension/conflict/fake/fake_test.go create mode 100644 submitqueue/extension/mergechecker/fake/BUILD.bazel create mode 100644 submitqueue/extension/mergechecker/fake/fake.go create mode 100644 submitqueue/extension/mergechecker/fake/fake_test.go create mode 100644 submitqueue/extension/pusher/fake/BUILD.bazel create mode 100644 submitqueue/extension/pusher/fake/fake.go create mode 100644 submitqueue/extension/pusher/fake/fake_test.go create mode 100644 submitqueue/extension/scorer/fake/BUILD.bazel create mode 100644 submitqueue/extension/scorer/fake/fake.go create mode 100644 submitqueue/extension/scorer/fake/fake_test.go diff --git a/submitqueue/core/fakemarker/BUILD.bazel b/submitqueue/core/fakemarker/BUILD.bazel new file mode 100644 index 00000000..4cb5176b --- /dev/null +++ b/submitqueue/core/fakemarker/BUILD.bazel @@ -0,0 +1,19 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "fakemarker", + srcs = ["fakemarker.go"], + importpath = "github.com/uber/submitqueue/submitqueue/core/fakemarker", + visibility = ["//visibility:public"], + deps = ["//submitqueue/entity"], +) + +go_test( + name = "fakemarker_test", + srcs = ["fakemarker_test.go"], + embed = [":fakemarker"], + deps = [ + "//submitqueue/entity", + "@com_github_stretchr_testify//assert", + ], +) diff --git a/submitqueue/core/fakemarker/fakemarker.go b/submitqueue/core/fakemarker/fakemarker.go new file mode 100644 index 00000000..3bb3efc6 --- /dev/null +++ b/submitqueue/core/fakemarker/fakemarker.go @@ -0,0 +1,58 @@ +// Copyright (c) 2025 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 fakemarker holds the shared "sq-fake=" change-URI marker +// convention used by the extension fakes to inject failures from a request +// payload. Each fake recognizes its own tokens (e.g. "build-fail", "push-error"); +// this package only locates a token within change URIs so the parsing lives in +// one place instead of being copied into every fake. It is intended for examples +// and tests only, never production. +package fakemarker + +import ( + "strings" + + "github.com/uber/submitqueue/submitqueue/entity" +) + +// Prefix introduces a marker token in a change URI: "sq-fake=". +const Prefix = "sq-fake=" + +// Token returns the marker token embedded in the first URI that carries one, or +// "" if none do. The token ends at the first "&" or "#" delimiter, so a marker +// may sit among other query parameters or a fragment (e.g. +// "github://o/r/pull/1/a?sq-fake=build-fail&attempt=2"). +func Token(uris []string) string { + for _, u := range uris { + if i := strings.Index(u, Prefix); i >= 0 { + rest := u[i+len(Prefix):] + if j := strings.IndexAny(rest, "&#"); j >= 0 { + rest = rest[:j] + } + return rest + } + } + return "" +} + +// TokenInChanges returns the first marker token found across all changes' URIs, +// or "" if none carry one. +func TokenInChanges(changes []entity.Change) string { + for _, c := range changes { + if tok := Token(c.URIs); tok != "" { + return tok + } + } + return "" +} diff --git a/submitqueue/core/fakemarker/fakemarker_test.go b/submitqueue/core/fakemarker/fakemarker_test.go new file mode 100644 index 00000000..36f81b43 --- /dev/null +++ b/submitqueue/core/fakemarker/fakemarker_test.go @@ -0,0 +1,115 @@ +// Copyright (c) 2025 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 fakemarker + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/uber/submitqueue/submitqueue/entity" +) + +func TestToken(t *testing.T) { + tests := []struct { + name string + uris []string + want string + }{ + { + name: "no uris", + uris: nil, + want: "", + }, + { + name: "no marker", + uris: []string{"github://o/r/pull/1/a"}, + want: "", + }, + { + name: "marker at end of uri", + uris: []string{"github://o/r/pull/1/a?sq-fake=build-fail"}, + want: "build-fail", + }, + { + name: "marker trimmed at & delimiter", + uris: []string{"github://o/r/pull/1/a?sq-fake=build-fail&attempt=2"}, + want: "build-fail", + }, + { + name: "marker trimmed at # delimiter", + uris: []string{"github://o/r/pull/1/a?sq-fake=build-fail#frag"}, + want: "build-fail", + }, + { + name: "marker before a query param it precedes", + uris: []string{"github://o/r/pull/1/a?sq-fake=push-error&foo=bar#frag"}, + want: "push-error", + }, + { + name: "marker on a later uri", + uris: []string{"github://o/r/pull/1/a", "github://o/r/pull/2/b?sq-fake=conflict"}, + want: "conflict", + }, + { + name: "first marker wins", + uris: []string{"github://o/r/pull/1/a?sq-fake=first", "github://o/r/pull/2/b?sq-fake=second"}, + want: "first", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, Token(tt.uris)) + }) + } +} + +func TestTokenInChanges(t *testing.T) { + tests := []struct { + name string + changes []entity.Change + want string + }{ + { + name: "no changes", + changes: nil, + want: "", + }, + { + name: "no marker", + changes: []entity.Change{{URIs: []string{"github://o/r/pull/1/a"}}}, + want: "", + }, + { + name: "marker on first change", + changes: []entity.Change{{URIs: []string{"github://o/r/pull/1/a?sq-fake=build-fail&attempt=2"}}}, + want: "build-fail", + }, + { + name: "marker on later change", + changes: []entity.Change{ + {URIs: []string{"github://o/r/pull/1/a"}}, + {URIs: []string{"github://o/r/pull/2/b?sq-fake=push-error"}}, + }, + want: "push-error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, TokenInChanges(tt.changes)) + }) + } +} diff --git a/submitqueue/extension/buildrunner/fake/BUILD.bazel b/submitqueue/extension/buildrunner/fake/BUILD.bazel new file mode 100644 index 00000000..dcd30362 --- /dev/null +++ b/submitqueue/extension/buildrunner/fake/BUILD.bazel @@ -0,0 +1,25 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "fake", + srcs = ["fake.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/buildrunner/fake", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/core/fakemarker", + "//submitqueue/entity", + "//submitqueue/extension/buildrunner", + ], +) + +go_test( + name = "fake_test", + srcs = ["fake_test.go"], + embed = [":fake"], + deps = [ + "//submitqueue/entity", + "//submitqueue/extension/buildrunner", + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + ], +) diff --git a/submitqueue/extension/buildrunner/fake/fake.go b/submitqueue/extension/buildrunner/fake/fake.go new file mode 100644 index 00000000..45b9d938 --- /dev/null +++ b/submitqueue/extension/buildrunner/fake/fake.go @@ -0,0 +1,106 @@ +// Copyright (c) 2025 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 fake provides a buildrunner.BuildRunner whose outcome is driven by the +// triggered changes. With no marker every build immediately succeeds, behaving +// as a best-case stub for wiring and baselines. Failures are injected by +// embedding a marker token in a head change URI of the form "sq-fake=": +// +// sq-fake=trigger-error -> Trigger returns a non-nil error +// sq-fake=build-fail -> Status reports BuildStatusFailed +// sq-fake=build-error -> Status returns a non-nil error +// +// The runner is stateless: Trigger encodes the desired terminal outcome into the +// returned BuildID, and Status decides the result purely from the BuildID it is +// given — no per-build bookkeeping. This means any runner instance can answer +// Status for an ID minted by any other (Trigger and Status can even live in +// different controllers/processes), and a single running stack can exercise the +// negative paths purely by varying request payloads. It is intended for examples +// and tests only, never production. +package fake + +import ( + "context" + "fmt" + "strings" + "sync/atomic" + + "github.com/uber/submitqueue/submitqueue/core/fakemarker" + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/buildrunner" +) + +// Recognized marker tokens. See the package doc for the convention. +const ( + tokenTriggerError = "trigger-error" + tokenFail = "build-fail" + tokenError = "build-error" +) + +// outcomeOK is the BuildID outcome segment for a build that should succeed. +const outcomeOK = "ok" + +// runner is a buildrunner.BuildRunner that reports every build as succeeded +// unless a marker token in a head change URI requests otherwise. It holds no +// per-build state: the outcome is encoded in the BuildID at Trigger and read +// back out at Status. The atomic counter only hands out unique IDs. +type runner struct { + counter atomic.Uint64 +} + +// New returns a buildrunner.BuildRunner that defaults to succeeding and honors +// marker tokens embedded in head change URIs. +func New() buildrunner.BuildRunner { + return &runner{} +} + +// Trigger fails when a head change URI carries the trigger-error marker; +// otherwise it returns a unique BuildID that encodes the terminal outcome the +// build should report at Status time (decided from the head marker). The base +// changes and metadata are ignored. +func (r *runner) Trigger(_ context.Context, _ []entity.Change, head []entity.Change, _ entity.BuildMetadata) (entity.BuildID, error) { + outcome := outcomeOK + switch fakemarker.TokenInChanges(head) { + case tokenTriggerError: + return entity.BuildID{}, fmt.Errorf("fake: marked trigger error") + case tokenFail: + outcome = tokenFail + case tokenError: + outcome = tokenError + } + + // Encode the outcome in the ID (e.g. "fake-build-fail-7") so Status is + // stateless. The counter just keeps IDs unique. + id := fmt.Sprintf("fake-%s-%d", outcome, r.counter.Add(1)) + return entity.BuildID{ID: id}, nil +} + +// Status decides the result purely from the BuildID's encoded outcome. IDs that +// carry no recognized outcome (including those not minted by this fake) default +// to succeeded, keeping the runner best-case. +func (r *runner) Status(_ context.Context, buildID entity.BuildID) (entity.BuildStatus, entity.BuildMetadata, error) { + switch { + case strings.Contains(buildID.ID, tokenError): + return entity.BuildStatusUnknown, nil, fmt.Errorf("fake: marked build error") + case strings.Contains(buildID.ID, tokenFail): + return entity.BuildStatusFailed, nil, nil + default: + return entity.BuildStatusSucceeded, nil, nil + } +} + +// Cancel is a no-op and always succeeds. +func (r *runner) Cancel(_ context.Context, _ entity.BuildID) error { + return nil +} diff --git a/submitqueue/extension/buildrunner/fake/fake_test.go b/submitqueue/extension/buildrunner/fake/fake_test.go new file mode 100644 index 00000000..0a3004d6 --- /dev/null +++ b/submitqueue/extension/buildrunner/fake/fake_test.go @@ -0,0 +1,123 @@ +// Copyright (c) 2025 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 fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/buildrunner" +) + +func TestNew_ImplementsInterface(t *testing.T) { + var _ buildrunner.BuildRunner = New() +} + +func TestRunner_Trigger_UniqueIDs(t *testing.T) { + r := New() + ctx := context.Background() + + id1, err := r.Trigger(ctx, nil, []entity.Change{{URIs: []string{"github://o/r/pull/1/a"}}}, nil) + require.NoError(t, err) + assert.NotEmpty(t, id1.ID) + + id2, err := r.Trigger(ctx, nil, nil, nil) + require.NoError(t, err) + assert.NotEqual(t, id1, id2) +} + +func TestRunner_TriggerError(t *testing.T) { + r := New() + _, err := r.Trigger(context.Background(), nil, + []entity.Change{{URIs: []string{"github://o/r/pull/1/a?sq-fake=trigger-error"}}}, nil) + require.Error(t, err) +} + +func TestRunner_Status(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + headURIs []string + wantStatus entity.BuildStatus + wantErr bool + }{ + { + name: "no marker succeeds", + headURIs: []string{"github://o/r/pull/1/a"}, + wantStatus: entity.BuildStatusSucceeded, + }, + { + name: "build-fail marker fails", + headURIs: []string{"github://o/r/pull/1/a?sq-fake=build-fail"}, + wantStatus: entity.BuildStatusFailed, + }, + { + name: "build-fail marker among other query params", + headURIs: []string{"github://o/r/pull/1/a?ref=main&sq-fake=build-fail&attempt=2"}, + wantStatus: entity.BuildStatusFailed, + }, + { + name: "build-error marker errors", + headURIs: []string{"github://o/r/pull/1/a?sq-fake=build-error"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := New() + id, err := r.Trigger(ctx, nil, []entity.Change{{URIs: tt.headURIs}}, nil) + require.NoError(t, err) + + status, _, err := r.Status(ctx, id) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, status) + }) + } +} + +func TestRunner_Status_UnknownBuildSucceeds(t *testing.T) { + r := New() + status, _, err := r.Status(context.Background(), entity.BuildID{ID: "never-triggered"}) + require.NoError(t, err) + assert.Equal(t, entity.BuildStatusSucceeded, status) +} + +// TestStatus_StatelessAcrossInstances proves the outcome is carried by the +// BuildID, not by per-instance state: a build triggered by one runner is read +// back correctly by a different runner instance. +func TestStatus_StatelessAcrossInstances(t *testing.T) { + ctx := context.Background() + id, err := New().Trigger(ctx, nil, + []entity.Change{{URIs: []string{"github://o/r/pull/1/a?sq-fake=build-fail"}}}, nil) + require.NoError(t, err) + + status, _, err := New().Status(ctx, id) + require.NoError(t, err) + assert.Equal(t, entity.BuildStatusFailed, status) +} + +func TestRunner_Cancel(t *testing.T) { + r := New() + assert.NoError(t, r.Cancel(context.Background(), entity.BuildID{ID: "any"})) +} diff --git a/submitqueue/extension/changeprovider/fake/BUILD.bazel b/submitqueue/extension/changeprovider/fake/BUILD.bazel new file mode 100644 index 00000000..552512c6 --- /dev/null +++ b/submitqueue/extension/changeprovider/fake/BUILD.bazel @@ -0,0 +1,25 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "fake", + srcs = ["fake.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/changeprovider/fake", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/core/fakemarker", + "//submitqueue/entity", + "//submitqueue/extension/changeprovider", + ], +) + +go_test( + name = "fake_test", + srcs = ["fake_test.go"], + embed = [":fake"], + deps = [ + "//submitqueue/entity", + "//submitqueue/extension/changeprovider", + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + ], +) diff --git a/submitqueue/extension/changeprovider/fake/fake.go b/submitqueue/extension/changeprovider/fake/fake.go new file mode 100644 index 00000000..6d829c3a --- /dev/null +++ b/submitqueue/extension/changeprovider/fake/fake.go @@ -0,0 +1,62 @@ +// Copyright (c) 2025 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 fake provides a changeprovider.ChangeProvider whose outcome is driven +// by the input change. With no marker it returns one empty ChangeInfo per URI, +// behaving as a best-case stub for wiring and baselines. A failure can be +// injected end-to-end (e.g. from an e2e land request) by embedding a marker +// token in a change URI of the form "sq-fake=": +// +// sq-fake=provider-error -> non-nil error +// +// This lets a single running stack exercise negative paths purely by varying +// request payloads. It is intended for examples and tests only, never +// production. +package fake + +import ( + "context" + "fmt" + + "github.com/uber/submitqueue/submitqueue/core/fakemarker" + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/changeprovider" +) + +// Recognized marker tokens. See the package doc for the convention. +const tokenError = "provider-error" + +// provider is a changeprovider.ChangeProvider that returns empty change info +// unless a marker token in a change URI requests a failure. +type provider struct{} + +// New returns a changeprovider.ChangeProvider that defaults to returning one +// empty ChangeInfo per URI and honors marker tokens embedded in change URIs. +func New() changeprovider.ChangeProvider { + return provider{} +} + +// Get returns one ChangeInfo per URI in the change, unless a recognized marker +// token requests a failure. The "one ChangeInfo per URI" contract is preserved. +func (provider) Get(_ context.Context, change entity.Change) ([]entity.ChangeInfo, error) { + if fakemarker.Token(change.URIs) == tokenError { + return nil, fmt.Errorf("fake: marked provider error") + } + + infos := make([]entity.ChangeInfo, 0, len(change.URIs)) + for _, uri := range change.URIs { + infos = append(infos, entity.ChangeInfo{URI: uri}) + } + return infos, nil +} diff --git a/submitqueue/extension/changeprovider/fake/fake_test.go b/submitqueue/extension/changeprovider/fake/fake_test.go new file mode 100644 index 00000000..bb7c40db --- /dev/null +++ b/submitqueue/extension/changeprovider/fake/fake_test.go @@ -0,0 +1,66 @@ +// Copyright (c) 2025 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 fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/changeprovider" +) + +func TestNew_ImplementsInterface(t *testing.T) { + var _ changeprovider.ChangeProvider = New() +} + +func TestProvider_Get_OnePerURI(t *testing.T) { + tests := []struct { + name string + uris []string + }{ + {name: "nil URIs", uris: nil}, + {name: "single URI", uris: []string{"github://owner/repo/pull/1/abc"}}, + { + name: "multiple URIs (stack)", + uris: []string{ + "github://owner/repo/pull/1/abc", + "github://owner/repo/pull/2/def", + }, + }, + } + + p := New() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + infos, err := p.Get(context.Background(), entity.Change{URIs: tt.uris}) + require.NoError(t, err) + require.Len(t, infos, len(tt.uris)) + for i, uri := range tt.uris { + assert.Equal(t, uri, infos[i].URI) + } + }) + } +} + +func TestProvider_Get_ErrorMarker(t *testing.T) { + p := New() + _, err := p.Get(context.Background(), entity.Change{ + URIs: []string{"github://owner/repo/pull/1/abc?sq-fake=provider-error"}, + }) + require.Error(t, err) +} diff --git a/submitqueue/extension/conflict/fake/BUILD.bazel b/submitqueue/extension/conflict/fake/BUILD.bazel new file mode 100644 index 00000000..34919e4a --- /dev/null +++ b/submitqueue/extension/conflict/fake/BUILD.bazel @@ -0,0 +1,26 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "fake", + srcs = ["fake.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/conflict/fake", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/entity", + "//submitqueue/extension/conflict", + ], +) + +go_test( + name = "fake_test", + srcs = ["fake_test.go"], + embed = [":fake"], + deps = [ + "//submitqueue/entity", + "//submitqueue/extension/conflict", + "//submitqueue/extension/conflict/all", + "//submitqueue/extension/conflict/none", + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + ], +) diff --git a/submitqueue/extension/conflict/fake/fake.go b/submitqueue/extension/conflict/fake/fake.go new file mode 100644 index 00000000..0f5c0398 --- /dev/null +++ b/submitqueue/extension/conflict/fake/fake.go @@ -0,0 +1,62 @@ +// Copyright (c) 2025 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 fake provides a conflict.Analyzer that decorates an existing analyzer: +// it delegates to the wrapped implementation for the happy path, but injects an +// error when a caller-supplied predicate matches. +// +// Unlike the change-facing fakes, Analyze operates on batches — it never sees +// change URIs — so error injection is predicate-driven rather than marker-driven. +// To exercise the analyzer's error path in e2e, route a queue to an analyzer +// built with a failing predicate (e.g. FailAlways) via the queue wiring. It is +// intended for examples and tests only, never production. +package fake + +import ( + "context" + "fmt" + + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/conflict" +) + +// FailOn decides whether Analyze should inject an error for the given inputs. +type FailOn func(batch entity.Batch, inFlight []entity.Batch) bool + +// FailAlways is a FailOn that injects an error on every Analyze call. +func FailAlways(entity.Batch, []entity.Batch) bool { return true } + +// analyzerFake decorates a delegate Analyzer, injecting an error when failOn +// reports true. +type analyzerFake struct { + delegate conflict.Analyzer + failOn FailOn +} + +// New returns a conflict.Analyzer that delegates to the given analyzer but +// returns an error when failOn reports true for the call's inputs. The delegate +// is the existing analyzer implementation to wrap (e.g. all or none). A nil +// failOn never injects an error (pure passthrough). +func New(delegate conflict.Analyzer, failOn FailOn) conflict.Analyzer { + return analyzerFake{delegate: delegate, failOn: failOn} +} + +// Analyze returns an error when failOn reports true; otherwise it delegates to +// the wrapped analyzer. +func (a analyzerFake) Analyze(ctx context.Context, batch entity.Batch, inFlight []entity.Batch) ([]conflict.Conflict, error) { + if a.failOn != nil && a.failOn(batch, inFlight) { + return nil, fmt.Errorf("fake: injected analyze error for batch %q", batch.ID) + } + return a.delegate.Analyze(ctx, batch, inFlight) +} diff --git a/submitqueue/extension/conflict/fake/fake_test.go b/submitqueue/extension/conflict/fake/fake_test.go new file mode 100644 index 00000000..9004766a --- /dev/null +++ b/submitqueue/extension/conflict/fake/fake_test.go @@ -0,0 +1,68 @@ +// Copyright (c) 2025 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 fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/conflict" + "github.com/uber/submitqueue/submitqueue/extension/conflict/all" + "github.com/uber/submitqueue/submitqueue/extension/conflict/none" +) + +func TestNew_ImplementsInterface(t *testing.T) { + var _ conflict.Analyzer = New(none.New(), nil) +} + +func TestAnalyze_DelegatesWhenNoFailOn(t *testing.T) { + // Delegate to "all": one conflict per in-flight batch. nil failOn -> passthrough. + a := New(all.New(), nil) + got, err := a.Analyze(context.Background(), + entity.Batch{ID: "q/batch/1"}, + []entity.Batch{{ID: "q/batch/2"}, {ID: "q/batch/3"}}) + require.NoError(t, err) + assert.Len(t, got, 2) +} + +func TestAnalyze_DelegatesWhenFailOnFalse(t *testing.T) { + a := New(none.New(), func(entity.Batch, []entity.Batch) bool { return false }) + got, err := a.Analyze(context.Background(), entity.Batch{ID: "q/batch/1"}, nil) + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestAnalyze_FailAlways(t *testing.T) { + a := New(none.New(), FailAlways) + _, err := a.Analyze(context.Background(), entity.Batch{ID: "q/batch/1"}, nil) + require.Error(t, err) +} + +func TestAnalyze_FailOnPredicate(t *testing.T) { + // Inject an error only for a specific batch ID. + a := New(none.New(), func(b entity.Batch, _ []entity.Batch) bool { + return b.ID == "q/batch/bad" + }) + + _, err := a.Analyze(context.Background(), entity.Batch{ID: "q/batch/bad"}, nil) + require.Error(t, err) + + got, err := a.Analyze(context.Background(), entity.Batch{ID: "q/batch/ok"}, nil) + require.NoError(t, err) + assert.Empty(t, got) +} diff --git a/submitqueue/extension/mergechecker/fake/BUILD.bazel b/submitqueue/extension/mergechecker/fake/BUILD.bazel new file mode 100644 index 00000000..cac27c7d --- /dev/null +++ b/submitqueue/extension/mergechecker/fake/BUILD.bazel @@ -0,0 +1,25 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "fake", + srcs = ["fake.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/mergechecker/fake", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/core/fakemarker", + "//submitqueue/entity", + "//submitqueue/extension/mergechecker", + ], +) + +go_test( + name = "fake_test", + srcs = ["fake_test.go"], + embed = [":fake"], + deps = [ + "//submitqueue/entity", + "//submitqueue/extension/mergechecker", + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + ], +) diff --git a/submitqueue/extension/mergechecker/fake/fake.go b/submitqueue/extension/mergechecker/fake/fake.go new file mode 100644 index 00000000..1fd6867f --- /dev/null +++ b/submitqueue/extension/mergechecker/fake/fake.go @@ -0,0 +1,65 @@ +// Copyright (c) 2025 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 fake provides a mergechecker.MergeChecker whose outcome is driven by +// the input change. With no marker it reports every change as mergeable, +// behaving as a best-case stub for wiring and baselines. A failure can be +// injected end-to-end (e.g. from an e2e land request) by embedding a marker +// token in a change URI of the form "sq-fake=": +// +// sq-fake=unmergeable -> Result{Mergeable: false} +// sq-fake=mergecheck-error -> non-nil error +// +// This lets a single running stack exercise negative paths purely by varying +// request payloads. It is intended for examples and tests only, never +// production. +package fake + +import ( + "context" + "fmt" + + "github.com/uber/submitqueue/submitqueue/core/fakemarker" + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/mergechecker" +) + +// Recognized marker tokens. See the package doc for the convention. +const ( + tokenUnmergeable = "unmergeable" + tokenError = "mergecheck-error" +) + +// checker is a mergechecker.MergeChecker that reports changes as mergeable +// unless a marker token in a change URI requests otherwise. +type checker struct{} + +// New returns a mergechecker.MergeChecker that defaults to mergeable and honors +// marker tokens embedded in change URIs. +func New() mergechecker.MergeChecker { + return checker{} +} + +// Check reports the change as mergeable unless a recognized marker token is +// present in one of its URIs. +func (checker) Check(_ context.Context, change entity.Change) (mergechecker.Result, error) { + switch fakemarker.Token(change.URIs) { + case tokenUnmergeable: + return mergechecker.Result{Mergeable: false, Reason: "fake: marked unmergeable"}, nil + case tokenError: + return mergechecker.Result{}, fmt.Errorf("fake: marked merge-check error") + default: + return mergechecker.Result{Mergeable: true}, nil + } +} diff --git a/submitqueue/extension/mergechecker/fake/fake_test.go b/submitqueue/extension/mergechecker/fake/fake_test.go new file mode 100644 index 00000000..f55a9d17 --- /dev/null +++ b/submitqueue/extension/mergechecker/fake/fake_test.go @@ -0,0 +1,78 @@ +// Copyright (c) 2025 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 fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/mergechecker" +) + +func TestNew_ImplementsInterface(t *testing.T) { + var _ mergechecker.MergeChecker = New() +} + +func TestChecker_Check(t *testing.T) { + tests := []struct { + name string + uris []string + wantMergeable bool + wantErr bool + }{ + { + name: "no marker is mergeable", + uris: []string{"github://owner/repo/pull/1/abc"}, + wantMergeable: true, + }, + { + name: "no URIs is mergeable", + uris: nil, + wantMergeable: true, + }, + { + name: "unmergeable marker", + uris: []string{"github://owner/repo/pull/1/abc?sq-fake=unmergeable"}, + }, + { + name: "error marker", + uris: []string{"github://owner/repo/pull/1/abc?sq-fake=mergecheck-error"}, + wantErr: true, + }, + { + name: "marker on second uri", + uris: []string{ + "github://owner/repo/pull/1/abc", + "github://owner/repo/pull/2/def?sq-fake=unmergeable", + }, + }, + } + + c := New() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := c.Check(context.Background(), entity.Change{URIs: tt.uris}) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantMergeable, res.Mergeable) + }) + } +} diff --git a/submitqueue/extension/pusher/README.md b/submitqueue/extension/pusher/README.md index 0703cfd0..bb16ac84 100644 --- a/submitqueue/extension/pusher/README.md +++ b/submitqueue/extension/pusher/README.md @@ -33,6 +33,10 @@ order. Each outcome reports either: cherry-pick`, then `git push`. Construction takes the path to the checkout, the remote name, and the target branch; the implementation owns that working tree and serializes concurrent invocations. +- [`fake/`](fake/) — test/example stub. Reports every change as committed + unless a change URI carries a failure marker (`sq-fake=conflict` → + `ErrConflict`, `sq-fake=push-error` → error), letting a single running + stack exercise negative paths from request payloads. Not for production. ## Adding a new backend diff --git a/submitqueue/extension/pusher/fake/BUILD.bazel b/submitqueue/extension/pusher/fake/BUILD.bazel new file mode 100644 index 00000000..b5bd7d1d --- /dev/null +++ b/submitqueue/extension/pusher/fake/BUILD.bazel @@ -0,0 +1,25 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "fake", + srcs = ["fake.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/pusher/fake", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/core/fakemarker", + "//submitqueue/entity", + "//submitqueue/extension/pusher", + ], +) + +go_test( + name = "fake_test", + srcs = ["fake_test.go"], + embed = [":fake"], + deps = [ + "//submitqueue/entity", + "//submitqueue/extension/pusher", + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + ], +) diff --git a/submitqueue/extension/pusher/fake/fake.go b/submitqueue/extension/pusher/fake/fake.go new file mode 100644 index 00000000..3db1c19f --- /dev/null +++ b/submitqueue/extension/pusher/fake/fake.go @@ -0,0 +1,79 @@ +// Copyright (c) 2025 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 fake provides a pusher.Pusher whose outcome is driven by the input +// changes. With no marker every change is reported as committed with a synthetic +// commit SHA, behaving as a best-case stub for wiring and baselines. A failure +// can be injected end-to-end (e.g. from an e2e land request) by embedding a +// marker token in a change URI of the form "sq-fake=": +// +// sq-fake=conflict -> pusher.ErrConflict +// sq-fake=push-error -> non-nil error +// +// Both failure markers honor the atomicity contract: on error nothing is +// "pushed". This lets a single running stack exercise negative paths purely by +// varying request payloads. It is intended for examples and tests only, never +// production. +package fake + +import ( + "context" + "fmt" + "sync/atomic" + + "github.com/uber/submitqueue/submitqueue/core/fakemarker" + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/pusher" +) + +// Recognized marker tokens. See the package doc for the convention. +const ( + tokenConflict = "conflict" + tokenError = "push-error" +) + +// fakePusher is a pusher.Pusher that reports every change as committed unless a +// marker token in a change URI requests a failure. The atomic counter hands out +// unique synthetic commit SHAs and makes the type safe for concurrent use. +type fakePusher struct { + counter atomic.Uint64 +} + +// New returns a pusher.Pusher that defaults to committing every change and +// honors marker tokens embedded in change URIs. +func New() pusher.Pusher { + return &fakePusher{} +} + +// Push reports every change as committed with a synthetic commit SHA, unless a +// recognized marker token in one of the changes requests a failure. +func (p *fakePusher) Push(_ context.Context, changes []entity.Change) (pusher.Result, error) { + switch fakemarker.TokenInChanges(changes) { + case tokenConflict: + return pusher.Result{}, pusher.ErrConflict + case tokenError: + return pusher.Result{}, fmt.Errorf("fake: marked push error") + } + + outcomes := make([]pusher.ChangeOutcome, 0, len(changes)) + for _, change := range changes { + sha := fmt.Sprintf("fake-%d", p.counter.Add(1)) + outcomes = append(outcomes, pusher.ChangeOutcome{ + Change: change, + Status: pusher.OutcomeStatusCommitted, + CommitSHAs: []string{sha}, + }) + } + return pusher.Result{Outcomes: outcomes}, nil +} diff --git a/submitqueue/extension/pusher/fake/fake_test.go b/submitqueue/extension/pusher/fake/fake_test.go new file mode 100644 index 00000000..df46aa6f --- /dev/null +++ b/submitqueue/extension/pusher/fake/fake_test.go @@ -0,0 +1,69 @@ +// Copyright (c) 2025 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 fake + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/pusher" +) + +func TestNew_ImplementsInterface(t *testing.T) { + var _ pusher.Pusher = New() +} + +func TestPusher_Push_Committed(t *testing.T) { + p := New() + changes := []entity.Change{ + {URIs: []string{"github://owner/repo/pull/1/abc"}}, + {URIs: []string{"github://owner/repo/pull/2/def"}}, + } + + res, err := p.Push(context.Background(), changes) + require.NoError(t, err) + require.Len(t, res.Outcomes, len(changes)) + + seen := map[string]bool{} + for i, out := range res.Outcomes { + assert.Equal(t, changes[i], out.Change) + assert.Equal(t, pusher.OutcomeStatusCommitted, out.Status) + require.Len(t, out.CommitSHAs, 1) + assert.False(t, seen[out.CommitSHAs[0]], "commit SHAs must be unique") + seen[out.CommitSHAs[0]] = true + } +} + +func TestPusher_Push_ConflictMarker(t *testing.T) { + p := New() + _, err := p.Push(context.Background(), []entity.Change{ + {URIs: []string{"github://owner/repo/pull/1/abc?sq-fake=conflict"}}, + }) + assert.True(t, errors.Is(err, pusher.ErrConflict)) +} + +func TestPusher_Push_ErrorMarker(t *testing.T) { + p := New() + res, err := p.Push(context.Background(), []entity.Change{ + {URIs: []string{"github://owner/repo/pull/1/abc?sq-fake=push-error"}}, + }) + require.Error(t, err) + // Atomicity: on error no outcomes are reported. + assert.Empty(t, res.Outcomes) +} diff --git a/submitqueue/extension/scorer/fake/BUILD.bazel b/submitqueue/extension/scorer/fake/BUILD.bazel new file mode 100644 index 00000000..38d5ad71 --- /dev/null +++ b/submitqueue/extension/scorer/fake/BUILD.bazel @@ -0,0 +1,27 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "fake", + srcs = ["fake.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/scorer/fake", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/core/fakemarker", + "//submitqueue/entity", + "//submitqueue/extension/scorer", + ], +) + +go_test( + name = "fake_test", + srcs = ["fake_test.go"], + embed = [":fake"], + deps = [ + "//submitqueue/entity", + "//submitqueue/extension/scorer", + "//submitqueue/extension/scorer/heuristic", + "@com_github_stretchr_testify//assert", + "@com_github_stretchr_testify//require", + "@com_github_uber_go_tally_v4//:tally", + ], +) diff --git a/submitqueue/extension/scorer/fake/fake.go b/submitqueue/extension/scorer/fake/fake.go new file mode 100644 index 00000000..fcd6219e --- /dev/null +++ b/submitqueue/extension/scorer/fake/fake.go @@ -0,0 +1,68 @@ +// Copyright (c) 2025 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 fake provides a scorer.Scorer that decorates an existing scorer: it +// delegates to the wrapped implementation for the happy path, but injects an +// error when a change URI carries a failure marker of the form "sq-fake=": +// +// sq-fake=score-error -> non-nil error (the delegate is not called) +// +// This lets tests exercise scorer error paths end-to-end (driven from a land +// request) while preserving real scoring behavior for unmarked changes. It is +// intended for examples and tests only, never production. +package fake + +import ( + "context" + "fmt" + + "github.com/uber/submitqueue/submitqueue/core/fakemarker" + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/scorer" +) + +// Recognized marker token. See the package doc for the convention. +const tokenError = "score-error" + +// scorerFake decorates a delegate Scorer, injecting an error when a change URI +// carries the failure marker. +type scorerFake struct { + delegate scorer.Scorer +} + +// New returns a scorer.Scorer that delegates to the given scorer but returns an +// error when a change URI carries the "sq-fake=score-error" marker. The delegate +// is the existing scorer implementation to wrap (e.g. heuristic or composite). +func New(delegate scorer.Scorer) scorer.Scorer { + return scorerFake{delegate: delegate} +} + +// Score returns an error when a change URI carries the failure marker; otherwise +// it delegates to the wrapped scorer. +func (s scorerFake) Score(ctx context.Context, changes entity.BatchChanges) (float64, error) { + if markerToken(changes) == tokenError { + return 0, fmt.Errorf("fake: marked score error") + } + return s.delegate.Score(ctx, changes) +} + +// markerToken returns the marker token embedded in the first change URI that +// carries one, or "" if none do. +func markerToken(changes entity.BatchChanges) string { + uris := make([]string, 0, len(changes.Changes)) + for _, c := range changes.Changes { + uris = append(uris, c.URI) + } + return fakemarker.Token(uris) +} diff --git a/submitqueue/extension/scorer/fake/fake_test.go b/submitqueue/extension/scorer/fake/fake_test.go new file mode 100644 index 00000000..228abf36 --- /dev/null +++ b/submitqueue/extension/scorer/fake/fake_test.go @@ -0,0 +1,62 @@ +// Copyright (c) 2025 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 fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber-go/tally/v4" + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/scorer" + "github.com/uber/submitqueue/submitqueue/extension/scorer/heuristic" +) + +func TestNew_ImplementsInterface(t *testing.T) { + var _ scorer.Scorer = New(nil) +} + +// delegate returns a heuristic scorer that scores every batch at want. +func delegate(t *testing.T, want float64) scorer.Scorer { + t.Helper() + return heuristic.New( + []heuristic.Bucket{{Min: 0, Max: 1<<31 - 1, Score: want}}, + func(_ context.Context, c entity.BatchChanges) (int, error) { return len(c.Changes), nil }, + tally.NoopScope, + ) +} + +func batch(uris ...string) entity.BatchChanges { + changes := make([]entity.ChangeInfo, 0, len(uris)) + for _, u := range uris { + changes = append(changes, entity.ChangeInfo{URI: u}) + } + return entity.BatchChanges{BatchID: "q/batch/1", Queue: "q", Changes: changes} +} + +func TestScore_DelegatesWhenUnmarked(t *testing.T) { + s := New(delegate(t, 0.7)) + got, err := s.Score(context.Background(), batch("github://o/r/pull/1/a")) + require.NoError(t, err) + assert.Equal(t, 0.7, got) +} + +func TestScore_ErrorMarker(t *testing.T) { + s := New(delegate(t, 0.7)) + _, err := s.Score(context.Background(), batch("github://o/r/pull/1/a?sq-fake=score-error")) + require.Error(t, err) +}