Skip to content

Commit 3471964

Browse files
committed
feat(extensions): fake implementations with error injection
## 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=<token>` 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 //...`
1 parent 7f2a754 commit 3471964

19 files changed

Lines changed: 1144 additions & 0 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "fake",
5+
srcs = ["fake.go"],
6+
importpath = "github.com/uber/submitqueue/submitqueue/extension/buildrunner/fake",
7+
visibility = ["//visibility:public"],
8+
deps = [
9+
"//submitqueue/entity",
10+
"//submitqueue/extension/buildrunner",
11+
],
12+
)
13+
14+
go_test(
15+
name = "fake_test",
16+
srcs = ["fake_test.go"],
17+
embed = [":fake"],
18+
deps = [
19+
"//submitqueue/entity",
20+
"//submitqueue/extension/buildrunner",
21+
"@com_github_stretchr_testify//assert",
22+
"@com_github_stretchr_testify//require",
23+
],
24+
)
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Copyright (c) 2025 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package fake provides a buildrunner.BuildRunner whose outcome is driven by the
16+
// triggered changes. With no marker every build immediately succeeds, behaving
17+
// as a best-case stub for wiring and baselines. A failure can be injected
18+
// end-to-end (e.g. from an e2e land request) by embedding a marker token in a
19+
// head change URI of the form "sq-fake=<token>":
20+
//
21+
// sq-fake=build-fail -> Status reports BuildStatusFailed
22+
// sq-fake=build-error -> Status returns a non-nil error
23+
//
24+
// Because the contract reports a build's result via Status (which receives only
25+
// a BuildID), the runner records the desired outcome per build ID at Trigger
26+
// time and looks it up at Status time. This lets a single running stack exercise
27+
// negative paths purely by varying request payloads. It is intended for examples
28+
// and tests only, never production.
29+
package fake
30+
31+
import (
32+
"context"
33+
"fmt"
34+
"strings"
35+
"sync"
36+
"sync/atomic"
37+
38+
"github.com/uber/submitqueue/submitqueue/entity"
39+
"github.com/uber/submitqueue/submitqueue/extension/buildrunner"
40+
)
41+
42+
// Recognized marker tokens. See the package doc for the convention.
43+
const (
44+
tokenFail = "build-fail"
45+
tokenError = "build-error"
46+
)
47+
48+
// outcome is the result a build will report at Status time, decided from the
49+
// head changes at Trigger time.
50+
type outcome int
51+
52+
const (
53+
outcomeSucceeded outcome = iota
54+
outcomeFailed
55+
outcomeError
56+
)
57+
58+
// runner is a buildrunner.BuildRunner that reports every build as succeeded
59+
// unless a marker token in a head change URI requests otherwise. The desired
60+
// outcome is recorded per build ID at Trigger and read back at Status. The
61+
// atomic counter hands out unique build IDs; the mutex guards the outcome map.
62+
type runner struct {
63+
counter atomic.Uint64
64+
mu sync.Mutex
65+
outcomes map[string]outcome
66+
}
67+
68+
// New returns a buildrunner.BuildRunner that defaults to succeeding and honors
69+
// marker tokens embedded in head change URIs.
70+
func New() buildrunner.BuildRunner {
71+
return &runner{outcomes: make(map[string]outcome)}
72+
}
73+
74+
// Trigger records the desired outcome for a new build (decided from a marker
75+
// token in the head changes) and returns its unique build ID. The base changes
76+
// and metadata are ignored.
77+
func (r *runner) Trigger(_ context.Context, _ []entity.Change, head []entity.Change, _ entity.BuildMetadata) (entity.BuildID, error) {
78+
o := outcomeSucceeded
79+
switch markerToken(head) {
80+
case tokenFail:
81+
o = outcomeFailed
82+
case tokenError:
83+
o = outcomeError
84+
}
85+
86+
id := fmt.Sprintf("fake-%d", r.counter.Add(1))
87+
r.mu.Lock()
88+
r.outcomes[id] = o
89+
r.mu.Unlock()
90+
return entity.BuildID{ID: id}, nil
91+
}
92+
93+
// Status returns the outcome recorded for the build at Trigger time. Unknown
94+
// build IDs default to succeeded, keeping the runner best-case for builds it did
95+
// not create.
96+
func (r *runner) Status(_ context.Context, buildID entity.BuildID) (entity.BuildStatus, entity.BuildMetadata, error) {
97+
r.mu.Lock()
98+
o := r.outcomes[buildID.ID]
99+
r.mu.Unlock()
100+
101+
switch o {
102+
case outcomeFailed:
103+
return entity.BuildStatusFailed, nil, nil
104+
case outcomeError:
105+
return entity.BuildStatusUnknown, nil, fmt.Errorf("fake: marked build error")
106+
default:
107+
return entity.BuildStatusSucceeded, nil, nil
108+
}
109+
}
110+
111+
// Cancel is a no-op and always succeeds.
112+
func (r *runner) Cancel(_ context.Context, _ entity.BuildID) error {
113+
return nil
114+
}
115+
116+
// markerToken returns the first marker token found across all changes' URIs,
117+
// or "" if none carry one.
118+
func markerToken(changes []entity.Change) string {
119+
for _, change := range changes {
120+
if tok := fakeToken(change.URIs); tok != "" {
121+
return tok
122+
}
123+
}
124+
return ""
125+
}
126+
127+
// markerPrefix introduces a marker token in a change URI: "sq-fake=<token>".
128+
const markerPrefix = "sq-fake="
129+
130+
// fakeToken returns the marker token embedded in the first URI that carries
131+
// one, or "" if none do. The token ends at the first "&" or "#" delimiter.
132+
func fakeToken(uris []string) string {
133+
for _, u := range uris {
134+
if i := strings.Index(u, markerPrefix); i >= 0 {
135+
rest := u[i+len(markerPrefix):]
136+
if j := strings.IndexAny(rest, "&#"); j >= 0 {
137+
rest = rest[:j]
138+
}
139+
return rest
140+
}
141+
}
142+
return ""
143+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) 2025 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package fake
16+
17+
import (
18+
"context"
19+
"testing"
20+
21+
"github.com/stretchr/testify/assert"
22+
"github.com/stretchr/testify/require"
23+
"github.com/uber/submitqueue/submitqueue/entity"
24+
"github.com/uber/submitqueue/submitqueue/extension/buildrunner"
25+
)
26+
27+
func TestNew_ImplementsInterface(t *testing.T) {
28+
var _ buildrunner.BuildRunner = New()
29+
}
30+
31+
func TestRunner_Trigger_UniqueIDs(t *testing.T) {
32+
r := New()
33+
ctx := context.Background()
34+
35+
id1, err := r.Trigger(ctx, nil, []entity.Change{{URIs: []string{"github://o/r/pull/1/a"}}}, nil)
36+
require.NoError(t, err)
37+
assert.NotEmpty(t, id1.ID)
38+
39+
id2, err := r.Trigger(ctx, nil, nil, nil)
40+
require.NoError(t, err)
41+
assert.NotEqual(t, id1, id2)
42+
}
43+
44+
func TestRunner_Status(t *testing.T) {
45+
ctx := context.Background()
46+
47+
tests := []struct {
48+
name string
49+
headURIs []string
50+
wantStatus entity.BuildStatus
51+
wantErr bool
52+
}{
53+
{
54+
name: "no marker succeeds",
55+
headURIs: []string{"github://o/r/pull/1/a"},
56+
wantStatus: entity.BuildStatusSucceeded,
57+
},
58+
{
59+
name: "build-fail marker fails",
60+
headURIs: []string{"github://o/r/pull/1/a?sq-fake=build-fail"},
61+
wantStatus: entity.BuildStatusFailed,
62+
},
63+
{
64+
name: "build-error marker errors",
65+
headURIs: []string{"github://o/r/pull/1/a?sq-fake=build-error"},
66+
wantErr: true,
67+
},
68+
}
69+
70+
for _, tt := range tests {
71+
t.Run(tt.name, func(t *testing.T) {
72+
r := New()
73+
id, err := r.Trigger(ctx, nil, []entity.Change{{URIs: tt.headURIs}}, nil)
74+
require.NoError(t, err)
75+
76+
status, _, err := r.Status(ctx, id)
77+
if tt.wantErr {
78+
require.Error(t, err)
79+
return
80+
}
81+
require.NoError(t, err)
82+
assert.Equal(t, tt.wantStatus, status)
83+
})
84+
}
85+
}
86+
87+
func TestRunner_Status_UnknownBuildSucceeds(t *testing.T) {
88+
r := New()
89+
status, _, err := r.Status(context.Background(), entity.BuildID{ID: "never-triggered"})
90+
require.NoError(t, err)
91+
assert.Equal(t, entity.BuildStatusSucceeded, status)
92+
}
93+
94+
func TestRunner_Cancel(t *testing.T) {
95+
r := New()
96+
assert.NoError(t, r.Cancel(context.Background(), entity.BuildID{ID: "any"}))
97+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "fake",
5+
srcs = ["fake.go"],
6+
importpath = "github.com/uber/submitqueue/submitqueue/extension/changeprovider/fake",
7+
visibility = ["//visibility:public"],
8+
deps = [
9+
"//submitqueue/entity",
10+
"//submitqueue/extension/changeprovider",
11+
],
12+
)
13+
14+
go_test(
15+
name = "fake_test",
16+
srcs = ["fake_test.go"],
17+
embed = [":fake"],
18+
deps = [
19+
"//submitqueue/entity",
20+
"//submitqueue/extension/changeprovider",
21+
"@com_github_stretchr_testify//assert",
22+
"@com_github_stretchr_testify//require",
23+
],
24+
)
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (c) 2025 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package fake provides a changeprovider.ChangeProvider whose outcome is driven
16+
// by the input change. With no marker it returns one empty ChangeInfo per URI,
17+
// behaving as a best-case stub for wiring and baselines. A failure can be
18+
// injected end-to-end (e.g. from an e2e land request) by embedding a marker
19+
// token in a change URI of the form "sq-fake=<token>":
20+
//
21+
// sq-fake=provider-error -> non-nil error
22+
//
23+
// This lets a single running stack exercise negative paths purely by varying
24+
// request payloads. It is intended for examples and tests only, never
25+
// production.
26+
package fake
27+
28+
import (
29+
"context"
30+
"fmt"
31+
"strings"
32+
33+
"github.com/uber/submitqueue/submitqueue/entity"
34+
"github.com/uber/submitqueue/submitqueue/extension/changeprovider"
35+
)
36+
37+
// Recognized marker tokens. See the package doc for the convention.
38+
const tokenError = "provider-error"
39+
40+
// provider is a changeprovider.ChangeProvider that returns empty change info
41+
// unless a marker token in a change URI requests a failure.
42+
type provider struct{}
43+
44+
// New returns a changeprovider.ChangeProvider that defaults to returning one
45+
// empty ChangeInfo per URI and honors marker tokens embedded in change URIs.
46+
func New() changeprovider.ChangeProvider {
47+
return provider{}
48+
}
49+
50+
// Get returns one ChangeInfo per URI in the change, unless a recognized marker
51+
// token requests a failure. The "one ChangeInfo per URI" contract is preserved.
52+
func (provider) Get(_ context.Context, change entity.Change) ([]entity.ChangeInfo, error) {
53+
if fakeToken(change.URIs) == tokenError {
54+
return nil, fmt.Errorf("fake: marked provider error")
55+
}
56+
57+
infos := make([]entity.ChangeInfo, 0, len(change.URIs))
58+
for _, uri := range change.URIs {
59+
infos = append(infos, entity.ChangeInfo{URI: uri})
60+
}
61+
return infos, nil
62+
}
63+
64+
// markerPrefix introduces a marker token in a change URI: "sq-fake=<token>".
65+
const markerPrefix = "sq-fake="
66+
67+
// fakeToken returns the marker token embedded in the first URI that carries
68+
// one, or "" if none do. The token ends at the first "&" or "#" delimiter.
69+
func fakeToken(uris []string) string {
70+
for _, u := range uris {
71+
if i := strings.Index(u, markerPrefix); i >= 0 {
72+
rest := u[i+len(markerPrefix):]
73+
if j := strings.IndexAny(rest, "&#"); j >= 0 {
74+
rest = rest[:j]
75+
}
76+
return rest
77+
}
78+
}
79+
return ""
80+
}

0 commit comments

Comments
 (0)