Skip to content

Commit 08706aa

Browse files
authored
Merge branch 'main' into manjari/batch-manager-gen-id
2 parents 2aae626 + 348975f commit 08706aa

35 files changed

Lines changed: 1754 additions & 289 deletions

CLAUDE.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,64 @@ make clean # Clean Bazel cache
184184
**Add new entity:**
185185
1. Create `entity/{domain}/{entity}.go` with test file and `BUILD.bazel`
186186

187+
**Add gomock for an extension interface:**
188+
189+
Mocks use the Bazel `gomock` rule from `@rules_go//extras:gomock.bzl` and are generated at build time (not checked in). See `extension/storage/mock/` for the canonical example.
190+
191+
To add a mock for a new interface file in an existing mock package (e.g., `extension/storage/new_store.go`):
192+
193+
1. Add a `//go:generate` directive to the interface file:
194+
```go
195+
//go:generate mockgen -source=new_store.go -destination=mock/new_store.go -package=mock
196+
```
197+
2. Add the file to `exports_files` in the parent `BUILD.bazel` with visibility to the mock package:
198+
```starlark
199+
exports_files(
200+
["new_store.go", ...],
201+
visibility = ["//extension/storage/mock:__pkg__"],
202+
)
203+
```
204+
3. Add a `gomock` rule in the mock `BUILD.bazel`:
205+
```starlark
206+
gomock(
207+
name = "mock_new_store_src",
208+
out = "new_store_mock.go",
209+
mockgen_tool = _MOCKGEN,
210+
package = "mock",
211+
source = "//extension/storage:new_store.go",
212+
source_importpath = "github.com/uber/submitqueue/extension/storage",
213+
)
214+
```
215+
4. Add the rule target to the `go_library` srcs in the same file.
216+
217+
To create a mock package for a new extension (e.g., `extension/queue/mock/`):
218+
219+
1. Create `extension/{ext}/mock/BUILD.bazel` with `gomock` rules, a `go_library`, and `# gazelle:ignore`.
220+
2. Add `exports_files` to `extension/{ext}/BUILD.bazel` for each interface file.
221+
3. Follow the same per-interface pattern as above.
222+
223+
Mock `BUILD.bazel` files use `# gazelle:ignore` so `make gazelle` will not update them — they must be maintained manually.
224+
225+
**Using mocks in tests:**
226+
```go
227+
import storagemock "github.com/uber/submitqueue/extension/storage/mock"
228+
229+
ctrl := gomock.NewController(t)
230+
mockStore := storagemock.NewMockRequestStore(ctrl)
231+
mockStore.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil)
232+
```
233+
234+
Test `BUILD.bazel` deps:
235+
```starlark
236+
deps = [
237+
"//extension/storage/mock",
238+
"@org_uber_go_mock//gomock",
239+
]
240+
```
241+
187242
### Testing
188243

244+
- **Table-driven tests** — prefer table-driven tests with `t.Run` subtests over individual test functions.
189245
- **Avoid asserting on error messages** — assert on error type or generic error.
190246
- **No change detector tests** — don't assert on default values, internal structure, or implementation details that can change without affecting behavior. Test what the code *does*, not how it's constructed.
191247
- **No `time.Sleep` for synchronization** — use channels, callbacks, condition variables.

entity/github/BUILD.bazel

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "github",
5+
srcs = ["change_id.go"],
6+
importpath = "github.com/uber/submitqueue/entity/github",
7+
visibility = ["//visibility:public"],
8+
)
9+
10+
go_test(
11+
name = "github_test",
12+
srcs = ["change_id_test.go"],
13+
embed = [":github"],
14+
deps = [
15+
"@com_github_stretchr_testify//assert",
16+
"@com_github_stretchr_testify//require",
17+
],
18+
)

entity/github/change_id.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package github
2+
3+
import (
4+
"fmt"
5+
"strconv"
6+
"strings"
7+
)
8+
9+
// changeIDFormat is the expected format for change IDs, included in error messages.
10+
const changeIDFormat = "{scheme}://{owner}/{repo}/{pr_number}/{head_commit_sha}"
11+
12+
// ChangeID represents a parsed GitHub-family change identifier.
13+
// Covers GitHub.com, GitHub Enterprise (GHE), and GitHub Enterprise Server (GHES)
14+
// since they share the same pull request model.
15+
// Format: {scheme}://{owner}/{repo}/{pr_number}/{head_commit_sha}
16+
type ChangeID struct {
17+
// Scheme captures the source variant (e.g., "github", "ghe", "ghes").
18+
Scheme string
19+
// Org is the organization or owner of the repository.
20+
Org string
21+
// Repo is the repository name.
22+
Repo string
23+
// PRNumber is the pull request number.
24+
PRNumber int
25+
// HeadCommitSHA is the head commit SHA at the time of request creation.
26+
HeadCommitSHA string
27+
}
28+
29+
// ParseChangeID parses a raw change ID string into a ChangeID.
30+
// Expected format: {scheme}://{owner}/{repo}/{pr_number}/{head_commit_sha}
31+
// The parser works from the end: SHA (last), PR number (second-to-last),
32+
// and everything before is the repo path (split into owner and repo).
33+
func ParseChangeID(raw string) (ChangeID, error) {
34+
// Split on "://" to get scheme and path
35+
schemeSplit := strings.SplitN(raw, "://", 2)
36+
if len(schemeSplit) != 2 {
37+
return ChangeID{}, fmt.Errorf("invalid change ID %q: missing '://' separator (expected format: %s)", raw, changeIDFormat)
38+
}
39+
40+
scheme := schemeSplit[0]
41+
if scheme == "" {
42+
return ChangeID{}, fmt.Errorf("invalid change ID %q: empty scheme (expected format: %s)", raw, changeIDFormat)
43+
}
44+
45+
path := schemeSplit[1]
46+
47+
// Split the path into segments and parse from the end.
48+
segments := strings.Split(path, "/")
49+
// Need at least 4 segments: {owner}/{repo}/{pr_number}/{sha}
50+
if len(segments) < 4 {
51+
return ChangeID{}, fmt.Errorf("invalid change ID %q: need at least owner/repo/pr/sha, got %d segments (expected format: %s)", raw, len(segments), changeIDFormat)
52+
}
53+
54+
sha := segments[len(segments)-1]
55+
prStr := segments[len(segments)-2]
56+
repoSegments := segments[:len(segments)-2]
57+
58+
if sha == "" {
59+
return ChangeID{}, fmt.Errorf("invalid change ID %q: empty head commit SHA (expected format: %s)", raw, changeIDFormat)
60+
}
61+
62+
prNumber, err := strconv.Atoi(prStr)
63+
if err != nil {
64+
return ChangeID{}, fmt.Errorf("invalid change ID %q: PR number %q is not a valid integer (expected format: %s)", raw, prStr, changeIDFormat)
65+
}
66+
67+
// Split repo path: last segment is repo name, everything before is the owner.
68+
if len(repoSegments) < 2 {
69+
return ChangeID{}, fmt.Errorf("invalid change ID %q: repo path must have at least owner/repo (expected format: %s)", raw, changeIDFormat)
70+
}
71+
72+
repo := repoSegments[len(repoSegments)-1]
73+
org := strings.Join(repoSegments[:len(repoSegments)-1], "/")
74+
75+
if org == "" {
76+
return ChangeID{}, fmt.Errorf("invalid change ID %q: empty owner (expected format: %s)", raw, changeIDFormat)
77+
}
78+
if repo == "" {
79+
return ChangeID{}, fmt.Errorf("invalid change ID %q: empty repo (expected format: %s)", raw, changeIDFormat)
80+
}
81+
82+
return ChangeID{
83+
Scheme: scheme,
84+
Org: org,
85+
Repo: repo,
86+
PRNumber: prNumber,
87+
HeadCommitSHA: sha,
88+
}, nil
89+
}
90+
91+
// String returns the string representation of the change ID.
92+
func (c ChangeID) String() string {
93+
return fmt.Sprintf("%s://%s/%s/%d/%s", c.Scheme, c.Org, c.Repo, c.PRNumber, c.HeadCommitSHA)
94+
}
95+
96+
// OwnerRepo returns the "{org}/{repo}" string.
97+
func (c ChangeID) OwnerRepo() string {
98+
return fmt.Sprintf("%s/%s", c.Org, c.Repo)
99+
}

entity/github/change_id_test.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package github
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestParseChangeID(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
raw string
14+
want ChangeID
15+
wantErr bool
16+
}{
17+
{
18+
name: "valid github scheme",
19+
raw: "github://uber/submitqueue/123/abc123def",
20+
want: ChangeID{
21+
Scheme: "github",
22+
Org: "uber",
23+
Repo: "submitqueue",
24+
PRNumber: 123,
25+
HeadCommitSHA: "abc123def",
26+
},
27+
},
28+
{
29+
name: "valid ghe scheme",
30+
raw: "ghe://uber/monorepo/456/deadbeef",
31+
want: ChangeID{
32+
Scheme: "ghe",
33+
Org: "uber",
34+
Repo: "monorepo",
35+
PRNumber: 456,
36+
HeadCommitSHA: "deadbeef",
37+
},
38+
},
39+
{
40+
name: "valid ghes scheme",
41+
raw: "ghes://org/repo/1/sha1",
42+
want: ChangeID{
43+
Scheme: "ghes",
44+
Org: "org",
45+
Repo: "repo",
46+
PRNumber: 1,
47+
HeadCommitSHA: "sha1",
48+
},
49+
},
50+
{
51+
name: "nested org path",
52+
raw: "github://uber/frontend/webapp/42/abc123",
53+
want: ChangeID{
54+
Scheme: "github",
55+
Org: "uber/frontend",
56+
Repo: "webapp",
57+
PRNumber: 42,
58+
HeadCommitSHA: "abc123",
59+
},
60+
},
61+
{
62+
name: "missing separator",
63+
raw: "github/uber/submitqueue/123/abc123",
64+
wantErr: true,
65+
},
66+
{
67+
name: "empty scheme",
68+
raw: "://uber/submitqueue/123/abc123",
69+
wantErr: true,
70+
},
71+
{
72+
name: "too few segments",
73+
raw: "github://uber/123/abc123",
74+
wantErr: true,
75+
},
76+
{
77+
name: "only one segment",
78+
raw: "github://abc123",
79+
wantErr: true,
80+
},
81+
{
82+
name: "empty owner",
83+
raw: "github:///submitqueue/123/abc123",
84+
wantErr: true,
85+
},
86+
{
87+
name: "empty repo",
88+
raw: "github://uber//123/abc123",
89+
wantErr: true,
90+
},
91+
{
92+
name: "non-numeric PR number",
93+
raw: "github://uber/submitqueue/abc/abc123",
94+
wantErr: true,
95+
},
96+
{
97+
name: "empty SHA",
98+
raw: "github://uber/submitqueue/123/",
99+
wantErr: true,
100+
},
101+
{
102+
name: "empty string",
103+
raw: "",
104+
wantErr: true,
105+
},
106+
}
107+
108+
for _, tt := range tests {
109+
t.Run(tt.name, func(t *testing.T) {
110+
got, err := ParseChangeID(tt.raw)
111+
if tt.wantErr {
112+
require.Error(t, err)
113+
return
114+
}
115+
require.NoError(t, err)
116+
assert.Equal(t, tt.want, got)
117+
})
118+
}
119+
}
120+
121+
func TestChangeID_String(t *testing.T) {
122+
tests := []struct {
123+
name string
124+
id ChangeID
125+
want string
126+
}{
127+
{
128+
name: "github",
129+
id: ChangeID{
130+
Scheme: "github",
131+
Org: "uber",
132+
Repo: "submitqueue",
133+
PRNumber: 123,
134+
HeadCommitSHA: "abc123",
135+
},
136+
want: "github://uber/submitqueue/123/abc123",
137+
},
138+
{
139+
name: "ghe",
140+
id: ChangeID{
141+
Scheme: "ghe",
142+
Org: "corp",
143+
Repo: "app",
144+
PRNumber: 99,
145+
HeadCommitSHA: "deadbeef",
146+
},
147+
want: "ghe://corp/app/99/deadbeef",
148+
},
149+
{
150+
name: "ghes",
151+
id: ChangeID{
152+
Scheme: "ghes",
153+
Org: "org",
154+
Repo: "repo",
155+
PRNumber: 1,
156+
HeadCommitSHA: "sha1",
157+
},
158+
want: "ghes://org/repo/1/sha1",
159+
},
160+
}
161+
162+
for _, tt := range tests {
163+
t.Run(tt.name, func(t *testing.T) {
164+
assert.Equal(t, tt.want, tt.id.String())
165+
})
166+
}
167+
}
168+
169+
func TestChangeID_OwnerRepo(t *testing.T) {
170+
id := ChangeID{
171+
Scheme: "github",
172+
Org: "uber",
173+
Repo: "submitqueue",
174+
PRNumber: 1,
175+
HeadCommitSHA: "abc",
176+
}
177+
assert.Equal(t, "uber/submitqueue", id.OwnerRepo())
178+
}
179+
180+
func TestParseChangeID_RoundTrip(t *testing.T) {
181+
originals := []string{
182+
"github://uber/submitqueue/123/abc123def456",
183+
"ghe://corp/monorepo/99/deadbeef01234567",
184+
"ghes://org/repo/1/a1b2c3",
185+
}
186+
187+
for _, raw := range originals {
188+
t.Run(raw, func(t *testing.T) {
189+
parsed, err := ParseChangeID(raw)
190+
require.NoError(t, err)
191+
assert.Equal(t, raw, parsed.String())
192+
})
193+
}
194+
}

entity/queue_config.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,19 @@ type QueueConfig struct {
3333
// - Buildkite: "buildkite.com/uber/submitqueue-ci"
3434
// - Jenkins: "jenkins.example.com/job/submitqueue-verify"
3535
BuildRunner string `json:"build_runner" yaml:"build_runner"`
36+
37+
// ChangeProvider identifies the change provider implementation for this queue.
38+
// Opaque to the system; meaningful only to the change provider extension implementation.
39+
// Examples: "github", "gitlab", "phabricator"
40+
ChangeProvider string `json:"change_provider" yaml:"change_provider"`
41+
42+
// MergeChecker identifies the merge checker implementation for this queue.
43+
// Opaque to the system; meaningful only to the merge checker extension implementation.
44+
// Examples: "github", "gitlab"
45+
MergeChecker string `json:"merge_checker" yaml:"merge_checker"`
46+
47+
// LandProvider identifies the land provider implementation for this queue.
48+
// Opaque to the system; meaningful only to the land provider extension implementation.
49+
// Examples: "github", "gitlab"
50+
LandProvider string `json:"land_provider" yaml:"land_provider"`
3651
}

0 commit comments

Comments
 (0)