Skip to content

Commit 00c50e7

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 5cd710a commit 00c50e7

19 files changed

Lines changed: 1156 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: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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. Failures are injected by
18+
// embedding a marker token in a head change URI of the form "sq-fake=<token>":
19+
//
20+
// sq-fake=trigger-error -> Trigger returns a non-nil error
21+
// sq-fake=build-fail -> Status reports BuildStatusFailed
22+
// sq-fake=build-error -> Status returns a non-nil error
23+
//
24+
// The runner is stateless: Trigger encodes the desired terminal outcome into the
25+
// returned BuildID, and Status decides the result purely from the BuildID it is
26+
// given — no per-build bookkeeping. This means any runner instance can answer
27+
// Status for an ID minted by any other (Trigger and Status can even live in
28+
// different controllers/processes), and a single running stack can exercise the
29+
// negative paths purely by varying request payloads. It is intended for examples
30+
// and tests only, never production.
31+
package fake
32+
33+
import (
34+
"context"
35+
"fmt"
36+
"strings"
37+
"sync/atomic"
38+
39+
"github.com/uber/submitqueue/submitqueue/entity"
40+
"github.com/uber/submitqueue/submitqueue/extension/buildrunner"
41+
)
42+
43+
// Recognized marker tokens. See the package doc for the convention.
44+
const (
45+
tokenTriggerError = "trigger-error"
46+
tokenFail = "build-fail"
47+
tokenError = "build-error"
48+
)
49+
50+
// outcomeOK is the BuildID outcome segment for a build that should succeed.
51+
const outcomeOK = "ok"
52+
53+
// runner is a buildrunner.BuildRunner that reports every build as succeeded
54+
// unless a marker token in a head change URI requests otherwise. It holds no
55+
// per-build state: the outcome is encoded in the BuildID at Trigger and read
56+
// back out at Status. The atomic counter only hands out unique IDs.
57+
type runner struct {
58+
counter atomic.Uint64
59+
}
60+
61+
// New returns a buildrunner.BuildRunner that defaults to succeeding and honors
62+
// marker tokens embedded in head change URIs.
63+
func New() buildrunner.BuildRunner {
64+
return &runner{}
65+
}
66+
67+
// Trigger fails when a head change URI carries the trigger-error marker;
68+
// otherwise it returns a unique BuildID that encodes the terminal outcome the
69+
// build should report at Status time (decided from the head marker). The base
70+
// changes and metadata are ignored.
71+
func (r *runner) Trigger(_ context.Context, _ []entity.Change, head []entity.Change, _ entity.BuildMetadata) (entity.BuildID, error) {
72+
outcome := outcomeOK
73+
switch markerToken(head) {
74+
case tokenTriggerError:
75+
return entity.BuildID{}, fmt.Errorf("fake: marked trigger error")
76+
case tokenFail:
77+
outcome = tokenFail
78+
case tokenError:
79+
outcome = tokenError
80+
}
81+
82+
// Encode the outcome in the ID (e.g. "fake-build-fail-7") so Status is
83+
// stateless. The counter just keeps IDs unique.
84+
id := fmt.Sprintf("fake-%s-%d", outcome, r.counter.Add(1))
85+
return entity.BuildID{ID: id}, nil
86+
}
87+
88+
// Status decides the result purely from the BuildID's encoded outcome. IDs that
89+
// carry no recognized outcome (including those not minted by this fake) default
90+
// to succeeded, keeping the runner best-case.
91+
func (r *runner) Status(_ context.Context, buildID entity.BuildID) (entity.BuildStatus, entity.BuildMetadata, error) {
92+
switch {
93+
case strings.Contains(buildID.ID, tokenError):
94+
return entity.BuildStatusUnknown, nil, fmt.Errorf("fake: marked build error")
95+
case strings.Contains(buildID.ID, tokenFail):
96+
return entity.BuildStatusFailed, nil, nil
97+
default:
98+
return entity.BuildStatusSucceeded, nil, nil
99+
}
100+
}
101+
102+
// Cancel is a no-op and always succeeds.
103+
func (r *runner) Cancel(_ context.Context, _ entity.BuildID) error {
104+
return nil
105+
}
106+
107+
// markerToken returns the first marker token found across all changes' URIs,
108+
// or "" if none carry one.
109+
func markerToken(changes []entity.Change) string {
110+
for _, change := range changes {
111+
if tok := fakeToken(change.URIs); tok != "" {
112+
return tok
113+
}
114+
}
115+
return ""
116+
}
117+
118+
// markerPrefix introduces a marker token in a change URI: "sq-fake=<token>".
119+
const markerPrefix = "sq-fake="
120+
121+
// fakeToken returns the marker token embedded in the first URI that carries
122+
// one, or "" if none do. The token ends at the first "&" or "#" delimiter.
123+
func fakeToken(uris []string) string {
124+
for _, u := range uris {
125+
if i := strings.Index(u, markerPrefix); i >= 0 {
126+
rest := u[i+len(markerPrefix):]
127+
if j := strings.IndexAny(rest, "&#"); j >= 0 {
128+
rest = rest[:j]
129+
}
130+
return rest
131+
}
132+
}
133+
return ""
134+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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_TriggerError(t *testing.T) {
45+
r := New()
46+
_, err := r.Trigger(context.Background(), nil,
47+
[]entity.Change{{URIs: []string{"github://o/r/pull/1/a?sq-fake=trigger-error"}}}, nil)
48+
require.Error(t, err)
49+
}
50+
51+
func TestRunner_Status(t *testing.T) {
52+
ctx := context.Background()
53+
54+
tests := []struct {
55+
name string
56+
headURIs []string
57+
wantStatus entity.BuildStatus
58+
wantErr bool
59+
}{
60+
{
61+
name: "no marker succeeds",
62+
headURIs: []string{"github://o/r/pull/1/a"},
63+
wantStatus: entity.BuildStatusSucceeded,
64+
},
65+
{
66+
name: "build-fail marker fails",
67+
headURIs: []string{"github://o/r/pull/1/a?sq-fake=build-fail"},
68+
wantStatus: entity.BuildStatusFailed,
69+
},
70+
{
71+
name: "build-error marker errors",
72+
headURIs: []string{"github://o/r/pull/1/a?sq-fake=build-error"},
73+
wantErr: true,
74+
},
75+
}
76+
77+
for _, tt := range tests {
78+
t.Run(tt.name, func(t *testing.T) {
79+
r := New()
80+
id, err := r.Trigger(ctx, nil, []entity.Change{{URIs: tt.headURIs}}, nil)
81+
require.NoError(t, err)
82+
83+
status, _, err := r.Status(ctx, id)
84+
if tt.wantErr {
85+
require.Error(t, err)
86+
return
87+
}
88+
require.NoError(t, err)
89+
assert.Equal(t, tt.wantStatus, status)
90+
})
91+
}
92+
}
93+
94+
func TestRunner_Status_UnknownBuildSucceeds(t *testing.T) {
95+
r := New()
96+
status, _, err := r.Status(context.Background(), entity.BuildID{ID: "never-triggered"})
97+
require.NoError(t, err)
98+
assert.Equal(t, entity.BuildStatusSucceeded, status)
99+
}
100+
101+
// TestStatus_StatelessAcrossInstances proves the outcome is carried by the
102+
// BuildID, not by per-instance state: a build triggered by one runner is read
103+
// back correctly by a different runner instance.
104+
func TestStatus_StatelessAcrossInstances(t *testing.T) {
105+
ctx := context.Background()
106+
id, err := New().Trigger(ctx, nil,
107+
[]entity.Change{{URIs: []string{"github://o/r/pull/1/a?sq-fake=build-fail"}}}, nil)
108+
require.NoError(t, err)
109+
110+
status, _, err := New().Status(ctx, id)
111+
require.NoError(t, err)
112+
assert.Equal(t, entity.BuildStatusFailed, status)
113+
}
114+
115+
func TestRunner_Cancel(t *testing.T) {
116+
r := New()
117+
assert.NoError(t, r.Cancel(context.Background(), entity.BuildID{ID: "any"}))
118+
}
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)