Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ make clean # Clean Bazel cache

### Testing

- **Table-driven tests** — prefer table-driven tests with `t.Run` subtests over individual test functions.
- **Avoid asserting on error messages** — assert on error type or generic error.
- **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.
- **No `time.Sleep` for synchronization** — use channels, callbacks, condition variables.
Expand Down
18 changes: 18 additions & 0 deletions entity/github/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
load("@rules_go//go:def.bzl", "go_library", "go_test")

go_library(
name = "github",
srcs = ["change_id.go"],
importpath = "github.com/uber/submitqueue/entity/github",
visibility = ["//visibility:public"],
)

go_test(
name = "github_test",
srcs = ["change_id_test.go"],
embed = [":github"],
deps = [
"@com_github_stretchr_testify//assert",
"@com_github_stretchr_testify//require",
],
)
99 changes: 99 additions & 0 deletions entity/github/change_id.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package github

import (
"fmt"
"strconv"
"strings"
)

// changeIDFormat is the expected format for change IDs, included in error messages.
const changeIDFormat = "{scheme}://{owner}/{repo}/{pr_number}/{head_commit_sha}"

// ChangeID represents a parsed GitHub-family change identifier.
// Covers GitHub.com, GitHub Enterprise (GHE), and GitHub Enterprise Server (GHES)
// since they share the same pull request model.
// Format: {scheme}://{owner}/{repo}/{pr_number}/{head_commit_sha}
type ChangeID struct {
// Scheme captures the source variant (e.g., "github", "ghe", "ghes").
Scheme string
// Org is the organization or owner of the repository.
Org string
// Repo is the repository name.
Repo string
// PRNumber is the pull request number.
PRNumber int
// HeadCommitSHA is the head commit SHA at the time of request creation.
HeadCommitSHA string
}

// ParseChangeID parses a raw change ID string into a ChangeID.
// Expected format: {scheme}://{owner}/{repo}/{pr_number}/{head_commit_sha}
// The parser works from the end: SHA (last), PR number (second-to-last),
// and everything before is the repo path (split into owner and repo).
func ParseChangeID(raw string) (ChangeID, error) {
// Split on "://" to get scheme and path
schemeSplit := strings.SplitN(raw, "://", 2)
if len(schemeSplit) != 2 {
return ChangeID{}, fmt.Errorf("invalid change ID %q: missing '://' separator (expected format: %s)", raw, changeIDFormat)
}

scheme := schemeSplit[0]
if scheme == "" {
return ChangeID{}, fmt.Errorf("invalid change ID %q: empty scheme (expected format: %s)", raw, changeIDFormat)
}

path := schemeSplit[1]

// Split the path into segments and parse from the end.
segments := strings.Split(path, "/")
// Need at least 4 segments: {owner}/{repo}/{pr_number}/{sha}
if len(segments) < 4 {
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)
}

sha := segments[len(segments)-1]
prStr := segments[len(segments)-2]
repoSegments := segments[:len(segments)-2]

if sha == "" {
return ChangeID{}, fmt.Errorf("invalid change ID %q: empty head commit SHA (expected format: %s)", raw, changeIDFormat)
}

prNumber, err := strconv.Atoi(prStr)
if err != nil {
return ChangeID{}, fmt.Errorf("invalid change ID %q: PR number %q is not a valid integer (expected format: %s)", raw, prStr, changeIDFormat)
}

// Split repo path: last segment is repo name, everything before is the owner.
if len(repoSegments) < 2 {
return ChangeID{}, fmt.Errorf("invalid change ID %q: repo path must have at least owner/repo (expected format: %s)", raw, changeIDFormat)
}

repo := repoSegments[len(repoSegments)-1]
org := strings.Join(repoSegments[:len(repoSegments)-1], "/")

if org == "" {
return ChangeID{}, fmt.Errorf("invalid change ID %q: empty owner (expected format: %s)", raw, changeIDFormat)
}
if repo == "" {
return ChangeID{}, fmt.Errorf("invalid change ID %q: empty repo (expected format: %s)", raw, changeIDFormat)
}

return ChangeID{
Scheme: scheme,
Org: org,
Repo: repo,
PRNumber: prNumber,
HeadCommitSHA: sha,
}, nil
}

// String returns the string representation of the change ID.
func (c ChangeID) String() string {
return fmt.Sprintf("%s://%s/%s/%d/%s", c.Scheme, c.Org, c.Repo, c.PRNumber, c.HeadCommitSHA)
}

// OwnerRepo returns the "{org}/{repo}" string.
func (c ChangeID) OwnerRepo() string {
return fmt.Sprintf("%s/%s", c.Org, c.Repo)
}
194 changes: 194 additions & 0 deletions entity/github/change_id_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package github

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestParseChangeID(t *testing.T) {
tests := []struct {
name string
raw string
want ChangeID
wantErr bool
}{
{
name: "valid github scheme",
raw: "github://uber/submitqueue/123/abc123def",
want: ChangeID{
Scheme: "github",
Org: "uber",
Repo: "submitqueue",
PRNumber: 123,
HeadCommitSHA: "abc123def",
},
},
{
name: "valid ghe scheme",
raw: "ghe://uber/monorepo/456/deadbeef",
want: ChangeID{
Scheme: "ghe",
Org: "uber",
Repo: "monorepo",
PRNumber: 456,
HeadCommitSHA: "deadbeef",
},
},
{
name: "valid ghes scheme",
raw: "ghes://org/repo/1/sha1",
want: ChangeID{
Scheme: "ghes",
Org: "org",
Repo: "repo",
PRNumber: 1,
HeadCommitSHA: "sha1",
},
},
{
name: "nested org path",
raw: "github://uber/frontend/webapp/42/abc123",
want: ChangeID{
Scheme: "github",
Org: "uber/frontend",
Repo: "webapp",
PRNumber: 42,
HeadCommitSHA: "abc123",
},
},
{
name: "missing separator",
raw: "github/uber/submitqueue/123/abc123",
wantErr: true,
},
{
name: "empty scheme",
raw: "://uber/submitqueue/123/abc123",
wantErr: true,
},
{
name: "too few segments",
raw: "github://uber/123/abc123",
wantErr: true,
},
{
name: "only one segment",
raw: "github://abc123",
wantErr: true,
},
{
name: "empty owner",
raw: "github:///submitqueue/123/abc123",
wantErr: true,
},
{
name: "empty repo",
raw: "github://uber//123/abc123",
wantErr: true,
},
{
name: "non-numeric PR number",
raw: "github://uber/submitqueue/abc/abc123",
wantErr: true,
},
{
name: "empty SHA",
raw: "github://uber/submitqueue/123/",
wantErr: true,
},
{
name: "empty string",
raw: "",
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseChangeID(tt.raw)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}

func TestChangeID_String(t *testing.T) {
tests := []struct {
name string
id ChangeID
want string
}{
{
name: "github",
id: ChangeID{
Scheme: "github",
Org: "uber",
Repo: "submitqueue",
PRNumber: 123,
HeadCommitSHA: "abc123",
},
want: "github://uber/submitqueue/123/abc123",
},
{
name: "ghe",
id: ChangeID{
Scheme: "ghe",
Org: "corp",
Repo: "app",
PRNumber: 99,
HeadCommitSHA: "deadbeef",
},
want: "ghe://corp/app/99/deadbeef",
},
{
name: "ghes",
id: ChangeID{
Scheme: "ghes",
Org: "org",
Repo: "repo",
PRNumber: 1,
HeadCommitSHA: "sha1",
},
want: "ghes://org/repo/1/sha1",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, tt.id.String())
})
}
}

func TestChangeID_OwnerRepo(t *testing.T) {
id := ChangeID{
Scheme: "github",
Org: "uber",
Repo: "submitqueue",
PRNumber: 1,
HeadCommitSHA: "abc",
}
assert.Equal(t, "uber/submitqueue", id.OwnerRepo())
}

func TestParseChangeID_RoundTrip(t *testing.T) {
originals := []string{
"github://uber/submitqueue/123/abc123def456",
"ghe://corp/monorepo/99/deadbeef01234567",
"ghes://org/repo/1/a1b2c3",
}

for _, raw := range originals {
t.Run(raw, func(t *testing.T) {
parsed, err := ParseChangeID(raw)
require.NoError(t, err)
assert.Equal(t, raw, parsed.String())
})
}
}
2 changes: 2 additions & 0 deletions example/server/orchestrator/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ go_library(
visibility = ["//visibility:private"],
deps = [
"//core/consumer",
"//extension/mergechecker",
"//extension/mergechecker/github",
"//extension/queue",
"//extension/queue/mysql",
"//orchestrator/controller",
Expand Down
Loading