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 MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ use_repo(
"com_github_uber_go_tally_v4",
"org_golang_google_grpc",
"org_golang_google_protobuf",
"org_golang_x_oauth2",
"org_uber_go_fx",
"org_uber_go_mock",
"org_uber_go_yarpc",
Expand Down
18 changes: 18 additions & 0 deletions core/httpclient/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 = "httpclient",
srcs = ["transport.go"],
importpath = "github.com/uber/submitqueue/core/httpclient",
visibility = ["//visibility:public"],
)

go_test(
name = "httpclient_test",
srcs = ["transport_test.go"],
embed = [":httpclient"],
deps = [
"@com_github_stretchr_testify//assert",
"@com_github_stretchr_testify//require",
],
)
52 changes: 52 additions & 0 deletions core/httpclient/transport.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package httpclient

import (
"net/http"
"net/url"
"strings"
)

// BaseURLTransport is an http.RoundTripper that rewrites every request URL
// to resolve against a fixed base URL. This allows callers to make requests
// with relative paths (e.g. "/graphql") and have the transport prepend the
// configured base URL transparently.
type BaseURLTransport struct {
// BaseURL is the API base URL (e.g. "https://api.github.com").
BaseURL *url.URL
// Next is the underlying RoundTripper. Defaults to http.DefaultTransport if nil.
Next http.RoundTripper
}

// RoundTrip rewrites req.URL to resolve against BaseURL, then delegates to Next.
// The base URL path and request path are joined explicitly so that base URLs
// with a path component (e.g. "https://ghe.example.com/api") are handled
// correctly regardless of whether the request path starts with "/".
func (t *BaseURLTransport) RoundTrip(req *http.Request) (*http.Response, error) {
newReq := req.Clone(req.Context())

merged := *t.BaseURL
merged.Path = strings.TrimRight(t.BaseURL.Path, "/") + "/" + strings.TrimLeft(req.URL.Path, "/")
merged.RawQuery = req.URL.RawQuery
newReq.URL = &merged

next := t.Next
if next == nil {
next = http.DefaultTransport
}
return next.RoundTrip(newReq)
}

// NewClient builds an *http.Client with BaseURLTransport configured.
// Callers are responsible for layering additional transports (e.g. auth) and
// setting Timeout on the returned client.
func NewClient(rawBaseURL string) (*http.Client, error) {
u, err := url.Parse(rawBaseURL)
if err != nil {
return nil, err
Comment thread
behinddwalls marked this conversation as resolved.
}

return &http.Client{Transport: &BaseURLTransport{
BaseURL: u,
Next: http.DefaultTransport,
}}, nil
}
88 changes: 88 additions & 0 deletions core/httpclient/transport_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package httpclient

import (
"net/http"
"net/url"
"testing"

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

// roundTripFunc is a test helper that implements http.RoundTripper via a function.
type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}

func TestBaseURLTransport_RewritesURL(t *testing.T) {
tests := []struct {
name string
baseURL string
requestPath string
wantURL string
}{
{
name: "relative path resolved against base",
baseURL: "https://api.github.com",
requestPath: "/graphql",
wantURL: "https://api.github.com/graphql",
},
{
name: "enterprise base URL",
baseURL: "https://ghe.example.com/api",
requestPath: "/graphql",
wantURL: "https://ghe.example.com/api/graphql",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var capturedURL string
transport := &BaseURLTransport{
BaseURL: mustParseURL(t, tt.baseURL),
Next: roundTripFunc(func(req *http.Request) (*http.Response, error) {
capturedURL = req.URL.String()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
}),
}

req, err := http.NewRequest(http.MethodGet, tt.requestPath, nil)
require.NoError(t, err)

_, err = transport.RoundTrip(req)
require.NoError(t, err)
assert.Equal(t, tt.wantURL, capturedURL)
})
}
}

func TestBaseURLTransport_DoesNotMutateOriginalRequest(t *testing.T) {
transport := &BaseURLTransport{
BaseURL: mustParseURL(t, "https://api.github.com"),
Next: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
}),
}

req, err := http.NewRequest(http.MethodGet, "/graphql", nil)
require.NoError(t, err)
originalURL := req.URL.String()

_, err = transport.RoundTrip(req)
require.NoError(t, err)
assert.Equal(t, originalURL, req.URL.String())
}

func TestNewClient_InvalidURL(t *testing.T) {
_, err := NewClient("://invalid")
require.Error(t, err)
}

func mustParseURL(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
require.NoError(t, err)
return u
}
4 changes: 4 additions & 0 deletions example/server/orchestrator/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ go_library(
visibility = ["//visibility:private"],
deps = [
"//core/consumer",
"//core/httpclient",
"//entity",
"//extension/changeprovider",
"//extension/changeprovider/github",
"//extension/counter",
"//extension/counter/mysql",
"//extension/mergechecker",
Expand All @@ -38,6 +41,7 @@ go_library(
"@com_github_uber_go_tally_v4//:tally",
"@org_golang_google_grpc//:grpc",
"@org_golang_google_grpc//reflection",
"@org_golang_x_oauth2//:oauth2",
"@org_uber_go_zap//:zap",
],
)
Expand Down
58 changes: 56 additions & 2 deletions example/server/orchestrator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,14 @@ import (
"time"

_ "github.com/go-sql-driver/mysql"
"golang.org/x/oauth2"

"github.com/uber-go/tally/v4"
"github.com/uber/submitqueue/core/consumer"
"github.com/uber/submitqueue/core/httpclient"
"github.com/uber/submitqueue/entity"
"github.com/uber/submitqueue/extension/changeprovider"
githubprovider "github.com/uber/submitqueue/extension/changeprovider/github"
"github.com/uber/submitqueue/extension/counter"
mysqlcounter "github.com/uber/submitqueue/extension/counter/mysql"
"github.com/uber/submitqueue/extension/mergechecker"
Expand Down Expand Up @@ -190,8 +195,14 @@ func run() error {
// Create merge checker
mc := newMergeChecker(logger, scope)

// Create change provider
cp, err := newChangeProvider(logger, scope)
if err != nil {
return fmt.Errorf("failed to create change provider: %w", err)
}

// Register controllers
if err := registerControllers(c, logger.Sugar(), scope, registry, mc, cnt, store); err != nil {
if err := registerControllers(c, logger.Sugar(), scope, registry, mc, cp, cnt, store); err != nil {
return err
}

Expand Down Expand Up @@ -376,7 +387,7 @@ func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRe
// │ │ │
// └────────┴────────────────────────┘

func registerControllers(c consumer.Consumer, logger *zap.SugaredLogger, scope tally.Scope, registry consumer.TopicRegistry, mc mergechecker.MergeChecker, cnt counter.Counter, store storage.Storage) error {
func registerControllers(c consumer.Consumer, logger *zap.SugaredLogger, scope tally.Scope, registry consumer.TopicRegistry, mc mergechecker.MergeChecker, cp changeprovider.ChangeProvider, cnt counter.Counter, store storage.Storage) error {
requestController := start.NewController(
logger,
scope,
Expand All @@ -395,6 +406,7 @@ func registerControllers(c consumer.Consumer, logger *zap.SugaredLogger, scope t
store,
registry,
mc,
cp,
consumer.TopicKeyValidate,
"orchestrator-validate",
)
Expand Down Expand Up @@ -514,6 +526,26 @@ func registerControllers(c consumer.Consumer, logger *zap.SugaredLogger, scope t
return nil
}

// getEnv returns environment variable value or default if not set.
func getEnv(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}

// parseTimeout parses a duration from environment variable with fallback to default.
// Returns defaultVal if envVal is empty or cannot be parsed.
func parseTimeout(envVal string, defaultVal time.Duration) time.Duration {
if envVal == "" {
return defaultVal
}
if d, err := time.ParseDuration(envVal); err == nil {
return d
}
return defaultVal
}

// newMergeChecker creates a MergeChecker for GitHub (github.com).
// Configured via GITHUB_TOKEN and GITHUB_GRAPHQL_URL environment variables.
func newMergeChecker(logger *zap.Logger, scope tally.Scope) mergechecker.MergeChecker {
Expand All @@ -539,6 +571,28 @@ func newMergeChecker(logger *zap.Logger, scope tally.Scope) mergechecker.MergeCh
})
}

// newChangeProvider creates a ChangeProvider for GitHub (github.com).
// Configured via GITHUB_BASE_URL, GITHUB_TOKEN, and GITHUB_TIMEOUT environment variables.
func newChangeProvider(logger *zap.Logger, scope tally.Scope) (changeprovider.ChangeProvider, error) {
client, err := httpclient.NewClient(getEnv("GITHUB_BASE_URL", "https://api.github.com"))
if err != nil {
return nil, fmt.Errorf("failed to build GitHub HTTP client: %w", err)
}

if token := os.Getenv("GITHUB_TOKEN"); token != "" {
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
client.Transport = &oauth2.Transport{Source: ts, Base: client.Transport}
}

client.Timeout = parseTimeout(os.Getenv("GITHUB_TIMEOUT"), 30*time.Second)

return githubprovider.NewProvider(githubprovider.Params{
HTTPClient: client,
Logger: logger.Sugar(),
MetricsScope: scope.SubScope("changeprovider"),
}), nil
}

// bearerTransport is an http.RoundTripper that adds a Bearer token to requests.
type bearerTransport struct {
token string
Expand Down
12 changes: 6 additions & 6 deletions extension/changeprovider/change_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,13 @@ type ChangedFile struct {
LinesAdded int
// LinesDeleted is the number of lines deleted in this file.
LinesDeleted int
// LinesModified is the number of lines modified in this file.
LinesModified int
}

// ChangeInfo contains metadata and file changes for a code change.
type ChangeInfo struct {
// ID is the change identifier (e.g., "PR: uber-code/go-code/1" or "diff: uber-code/go-code/D1").
ID string
// URI is the full change URI for correlation with the input request
// (e.g., "github://uber/repo/98/abc123sha" or "phab://D123/xyz789").
URI string
// User is the author of the change.
User User
// ChangedFiles is the list of files modified in this change. Order is unspecified.
Expand All @@ -56,6 +55,7 @@ type ChangeInfo struct {
// Each implementation is configured for a specific provider (GitHub, GitLab, Phabricator).
type ChangeProvider interface {
// Get retrieves change information for the provided Change.
// Returns the change info containing metadata and file changes.
Get(ctx context.Context, change entity.Change) (ChangeInfo, error)
// For a Change with multiple URIs (e.g., stacked PRs), returns one ChangeInfo per URI.
// Returns a slice of ChangeInfo, one for each change in the stack.
Get(ctx context.Context, change entity.Change) ([]ChangeInfo, error)
}
41 changes: 41 additions & 0 deletions extension/changeprovider/github/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
load("@rules_go//go:def.bzl", "go_library", "go_test")

go_library(
name = "github",
srcs = [
"convert.go",
"graphql.go",
"provider.go",
"validate.go",
],
importpath = "github.com/uber/submitqueue/extension/changeprovider/github",
visibility = ["//visibility:public"],
deps = [
"//core/metrics",
"//entity",
"//entity/github",
"//extension/changeprovider",
"@com_github_uber_go_tally_v4//:tally",
"@org_uber_go_zap//:zap",
],
)

go_test(
name = "github_test",
srcs = [
"graphql_test.go",
"provider_test.go",
"validate_test.go",
],
embed = [":github"],
deps = [
"//core/httpclient",
"//entity",
"//entity/github",
"//extension/changeprovider",
"@com_github_stretchr_testify//assert",
"@com_github_stretchr_testify//require",
"@com_github_uber_go_tally_v4//:tally",
"@org_uber_go_zap//zaptest",
],
)
36 changes: 36 additions & 0 deletions extension/changeprovider/github/convert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package github

import (
entitygithub "github.com/uber/submitqueue/entity/github"
"github.com/uber/submitqueue/extension/changeprovider"
)

// convertToChangeInfo converts GitHub PR data to ChangeInfo.
func convertToChangeInfo(parsed entitygithub.ChangeID, prData *pullRequestData) changeprovider.ChangeInfo {
changedFiles := convertFiles(prData.Files.Nodes)

return changeprovider.ChangeInfo{
URI: parsed.String(),
User: changeprovider.User{
Name: prData.Author.Name,
Email: prData.Author.Email,
},
ChangedFiles: changedFiles,
}
}

// convertFiles converts GitHub file nodes to ChangedFile structs.
func convertFiles(nodes []fileNode) []changeprovider.ChangedFile {
changedFiles := make([]changeprovider.ChangedFile, 0, len(nodes))

for _, file := range nodes {
changedFiles = append(changedFiles, changeprovider.ChangedFile{
Path: file.Path,
Patch: file.Patch,
LinesAdded: file.Additions,
LinesDeleted: file.Deletions,
})
}

return changedFiles
}
Loading
Loading