Skip to content

Commit f7b3eba

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 9adffc9 commit f7b3eba

22 files changed

Lines changed: 1280 additions & 0 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "fakemarker",
5+
srcs = ["fakemarker.go"],
6+
importpath = "github.com/uber/submitqueue/submitqueue/core/fakemarker",
7+
visibility = ["//visibility:public"],
8+
deps = ["//submitqueue/entity"],
9+
)
10+
11+
go_test(
12+
name = "fakemarker_test",
13+
srcs = ["fakemarker_test.go"],
14+
embed = [":fakemarker"],
15+
deps = [
16+
"//submitqueue/entity",
17+
"@com_github_stretchr_testify//assert",
18+
],
19+
)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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 fakemarker holds the shared "sq-fake=<token>" change-URI marker
16+
// convention used by the extension fakes to inject failures from a request
17+
// payload. Each fake recognizes its own tokens (e.g. "build-fail", "push-error");
18+
// this package only locates a token within change URIs so the parsing lives in
19+
// one place instead of being copied into every fake. It is intended for examples
20+
// and tests only, never production.
21+
package fakemarker
22+
23+
import (
24+
"strings"
25+
26+
"github.com/uber/submitqueue/submitqueue/entity"
27+
)
28+
29+
// Prefix introduces a marker token in a change URI: "sq-fake=<token>".
30+
const Prefix = "sq-fake="
31+
32+
// Token returns the marker token embedded in the first URI that carries one, or
33+
// "" if none do. The token ends at the first "&" or "#" delimiter, so a marker
34+
// may sit among other query parameters or a fragment (e.g.
35+
// "github://o/r/pull/1/a?sq-fake=build-fail&attempt=2").
36+
func Token(uris []string) string {
37+
for _, u := range uris {
38+
if i := strings.Index(u, Prefix); i >= 0 {
39+
rest := u[i+len(Prefix):]
40+
if j := strings.IndexAny(rest, "&#"); j >= 0 {
41+
rest = rest[:j]
42+
}
43+
return rest
44+
}
45+
}
46+
return ""
47+
}
48+
49+
// TokenInChanges returns the first marker token found across all changes' URIs,
50+
// or "" if none carry one.
51+
func TokenInChanges(changes []entity.Change) string {
52+
for _, c := range changes {
53+
if tok := Token(c.URIs); tok != "" {
54+
return tok
55+
}
56+
}
57+
return ""
58+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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 fakemarker
16+
17+
import (
18+
"testing"
19+
20+
"github.com/stretchr/testify/assert"
21+
"github.com/uber/submitqueue/submitqueue/entity"
22+
)
23+
24+
func TestToken(t *testing.T) {
25+
tests := []struct {
26+
name string
27+
uris []string
28+
want string
29+
}{
30+
{
31+
name: "no uris",
32+
uris: nil,
33+
want: "",
34+
},
35+
{
36+
name: "no marker",
37+
uris: []string{"github://o/r/pull/1/a"},
38+
want: "",
39+
},
40+
{
41+
name: "marker at end of uri",
42+
uris: []string{"github://o/r/pull/1/a?sq-fake=build-fail"},
43+
want: "build-fail",
44+
},
45+
{
46+
name: "marker trimmed at & delimiter",
47+
uris: []string{"github://o/r/pull/1/a?sq-fake=build-fail&attempt=2"},
48+
want: "build-fail",
49+
},
50+
{
51+
name: "marker trimmed at # delimiter",
52+
uris: []string{"github://o/r/pull/1/a?sq-fake=build-fail#frag"},
53+
want: "build-fail",
54+
},
55+
{
56+
name: "marker before a query param it precedes",
57+
uris: []string{"github://o/r/pull/1/a?sq-fake=push-error&foo=bar#frag"},
58+
want: "push-error",
59+
},
60+
{
61+
name: "marker on a later uri",
62+
uris: []string{"github://o/r/pull/1/a", "github://o/r/pull/2/b?sq-fake=conflict"},
63+
want: "conflict",
64+
},
65+
{
66+
name: "first marker wins",
67+
uris: []string{"github://o/r/pull/1/a?sq-fake=first", "github://o/r/pull/2/b?sq-fake=second"},
68+
want: "first",
69+
},
70+
}
71+
72+
for _, tt := range tests {
73+
t.Run(tt.name, func(t *testing.T) {
74+
assert.Equal(t, tt.want, Token(tt.uris))
75+
})
76+
}
77+
}
78+
79+
func TestTokenInChanges(t *testing.T) {
80+
tests := []struct {
81+
name string
82+
changes []entity.Change
83+
want string
84+
}{
85+
{
86+
name: "no changes",
87+
changes: nil,
88+
want: "",
89+
},
90+
{
91+
name: "no marker",
92+
changes: []entity.Change{{URIs: []string{"github://o/r/pull/1/a"}}},
93+
want: "",
94+
},
95+
{
96+
name: "marker on first change",
97+
changes: []entity.Change{{URIs: []string{"github://o/r/pull/1/a?sq-fake=build-fail&attempt=2"}}},
98+
want: "build-fail",
99+
},
100+
{
101+
name: "marker on later change",
102+
changes: []entity.Change{
103+
{URIs: []string{"github://o/r/pull/1/a"}},
104+
{URIs: []string{"github://o/r/pull/2/b?sq-fake=push-error"}},
105+
},
106+
want: "push-error",
107+
},
108+
}
109+
110+
for _, tt := range tests {
111+
t.Run(tt.name, func(t *testing.T) {
112+
assert.Equal(t, tt.want, TokenInChanges(tt.changes))
113+
})
114+
}
115+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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/core/fakemarker",
10+
"//submitqueue/entity",
11+
"//submitqueue/extension/buildrunner",
12+
],
13+
)
14+
15+
go_test(
16+
name = "fake_test",
17+
srcs = ["fake_test.go"],
18+
embed = [":fake"],
19+
deps = [
20+
"//submitqueue/entity",
21+
"//submitqueue/extension/buildrunner",
22+
"@com_github_stretchr_testify//assert",
23+
"@com_github_stretchr_testify//require",
24+
],
25+
)
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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+
"crypto/rand"
36+
"encoding/hex"
37+
"fmt"
38+
"strings"
39+
40+
"github.com/uber/submitqueue/submitqueue/core/fakemarker"
41+
"github.com/uber/submitqueue/submitqueue/entity"
42+
"github.com/uber/submitqueue/submitqueue/extension/buildrunner"
43+
)
44+
45+
// Recognized marker tokens. See the package doc for the convention.
46+
const (
47+
tokenTriggerError = "trigger-error"
48+
tokenFail = "build-fail"
49+
tokenError = "build-error"
50+
)
51+
52+
// outcomeOK is the BuildID outcome segment for a build that should succeed.
53+
const outcomeOK = "ok"
54+
55+
// runner is a buildrunner.BuildRunner that reports every build as succeeded
56+
// unless a marker token in a head change URI requests otherwise. It holds no
57+
// per-build state: the outcome is encoded in the BuildID at Trigger and read
58+
// back out at Status. Uniqueness comes from a random suffix per ID, so it needs
59+
// no shared counter and never collides across instances or processes.
60+
type runner struct{}
61+
62+
// New returns a buildrunner.BuildRunner that defaults to succeeding and honors
63+
// marker tokens embedded in head change URIs.
64+
func New() buildrunner.BuildRunner {
65+
return &runner{}
66+
}
67+
68+
// Trigger fails when a head change URI carries the trigger-error marker;
69+
// otherwise it returns a unique BuildID that encodes the terminal outcome the
70+
// build should report at Status time (decided from the head marker). The base
71+
// changes and metadata are ignored.
72+
func (r *runner) Trigger(_ context.Context, _ []entity.Change, head []entity.Change, _ entity.BuildMetadata) (entity.BuildID, error) {
73+
outcome := outcomeOK
74+
switch fakemarker.TokenInChanges(head) {
75+
case tokenTriggerError:
76+
return entity.BuildID{}, fmt.Errorf("fake: marked trigger error")
77+
case tokenFail:
78+
outcome = tokenFail
79+
case tokenError:
80+
outcome = tokenError
81+
}
82+
83+
// Encode the outcome in the ID (e.g. "fake-build-fail-a1b2c3d4") so Status is
84+
// stateless. The random suffix keeps IDs globally unique across instances and
85+
// processes — the BuildID uniqueness contract — without any shared state.
86+
suffix, err := randomSuffix()
87+
if err != nil {
88+
return entity.BuildID{}, fmt.Errorf("fake: generating build id: %w", err)
89+
}
90+
id := fmt.Sprintf("fake-%s-%s", outcome, suffix)
91+
return entity.BuildID{ID: id}, nil
92+
}
93+
94+
// randomSuffix returns a short random hex string used to keep fake BuildIDs
95+
// globally unique. Hex digits never spell the outcome marker tokens, so the
96+
// suffix cannot interfere with Status decoding the outcome via substring match.
97+
func randomSuffix() (string, error) {
98+
var b [4]byte
99+
if _, err := rand.Read(b[:]); err != nil {
100+
return "", err
101+
}
102+
return hex.EncodeToString(b[:]), nil
103+
}
104+
105+
// Status decides the result purely from the BuildID's encoded outcome. IDs that
106+
// carry no recognized outcome (including those not minted by this fake) default
107+
// to succeeded, keeping the runner best-case.
108+
func (r *runner) Status(_ context.Context, buildID entity.BuildID) (entity.BuildStatus, entity.BuildMetadata, error) {
109+
switch {
110+
case strings.Contains(buildID.ID, tokenError):
111+
return entity.BuildStatusUnknown, nil, fmt.Errorf("fake: marked build error")
112+
case strings.Contains(buildID.ID, tokenFail):
113+
return entity.BuildStatusFailed, nil, nil
114+
default:
115+
return entity.BuildStatusSucceeded, nil, nil
116+
}
117+
}
118+
119+
// Cancel is a no-op and always succeeds.
120+
func (r *runner) Cancel(_ context.Context, _ entity.BuildID) error {
121+
return nil
122+
}

0 commit comments

Comments
 (0)