Skip to content
Closed
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
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/changeprovider",
"//extension/changeprovider/github",
"//extension/counter",
"//extension/counter/mysql",
"//extension/mergechecker",
Expand Down
60 changes: 58 additions & 2 deletions example/server/orchestrator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import (
_ "github.com/go-sql-driver/mysql"
"github.com/uber-go/tally/v4"
"github.com/uber/submitqueue/core/consumer"
"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 @@ -188,8 +190,11 @@ func run() error {
// Create merge checker
mc := newMergeChecker(logger, scope)

// Create change provider
cp := newChangeProvider(logger, scope)

// 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 @@ -374,7 +379,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 @@ -393,6 +398,7 @@ func registerControllers(c consumer.Consumer, logger *zap.SugaredLogger, scope t
store,
registry,
mc,
cp,
consumer.TopicKeyValidate,
"orchestrator-validate",
)
Expand Down Expand Up @@ -499,6 +505,36 @@ 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
}

// buildGitHubHTTPClient creates an http.Client configured for GitHub API calls.
// Configures timeout and optional bearer token authentication.
func buildGitHubHTTPClient(token string, timeout time.Duration) *http.Client {
httpClient := &http.Client{Timeout: timeout}
if token != "" {
httpClient.Transport = &bearerTransport{token: token}
}
return httpClient
}

// 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 @@ -524,6 +560,26 @@ 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.
// Uses pure dependency injection - creates http.Client with auth configured in Transport.
func newChangeProvider(logger *zap.Logger, scope tally.Scope) changeprovider.ChangeProvider {
// 1. Read configuration from environment
baseURL := getEnv("GITHUB_BASE_URL", "https://api.github.com")
token := os.Getenv("GITHUB_TOKEN")
timeout := parseTimeout(os.Getenv("GITHUB_TIMEOUT"), 30*time.Second)

// 2. Build HTTP client with caller-controlled config (auth + timeout)
httpClient := buildGitHubHTTPClient(token, timeout)

// 3. Inject into provider
return githubprovider.NewProvider(githubprovider.Params{
Client: githubprovider.NewClient(httpClient, baseURL),
Logger: logger.Sugar(),
MetricsScope: scope.SubScope("changeprovider"),
})
}

// bearerTransport is an http.RoundTripper that adds a Bearer token to requests.
type bearerTransport struct {
token string
Expand Down
10 changes: 6 additions & 4 deletions extension/changeprovider/change_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@ type ChangedFile struct {

// 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 +57,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)
}
37 changes: 37 additions & 0 deletions extension/changeprovider/github/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
load("@rules_go//go:def.bzl", "go_library", "go_test")

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

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

import (
"net/http"
)

// Client is a GitHub API client that encapsulates connection details and authentication.
// The client is protocol-agnostic - it provides helpers for both GraphQL and REST endpoints.
type Client struct {
httpClient *http.Client
baseURL string
}
Comment on lines +9 to +12
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason we need it? httpClient should be enough, it can have baseURL in it, i think we can get rid of config file completely.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I created this config file in response to this previous comment - #98 (comment)

If we are ok to pass in the base url as a param to provider struct, I can revert to the previous version. By itself I dont think the httpCleint has facility to configure a baseURL.


// NewClient creates a new GitHub API client with a pre-configured HTTP client.
// The caller is responsible for configuring authentication in the HTTP client's Transport.
//
// Parameters:
// - httpClient: Configured HTTP client (with auth, timeout, transport, etc.)
// - baseURL: GitHub instance base URL (e.g., "https://api.github.com" or "https://ghe.company.com/api")
func NewClient(httpClient *http.Client, baseURL string) *Client {
return &Client{
httpClient: httpClient,
baseURL: baseURL,
}
}

// HTTPClient returns the configured HTTP client.
func (c *Client) HTTPClient() *http.Client {
return c.httpClient
}

// BaseURL returns the configured GitHub base URL.
func (c *Client) BaseURL() string {
return c.baseURL
}

// GraphQLURL returns the GitHub GraphQL endpoint URL.
// Constructs the URL by appending "/graphql" to the base URL.
func (c *Client) GraphQLURL() string {
return c.baseURL + "/graphql"
}

// RESTURL constructs a GitHub REST API endpoint URL.
// The path should start with "/" (e.g., "/repos/uber/submitqueue/pulls/123").
func (c *Client) RESTURL(path string) string {
return c.baseURL + path
}
97 changes: 97 additions & 0 deletions extension/changeprovider/github/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package github

import (
"net/http"
"testing"
"time"

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

func TestNewClient(t *testing.T) {
httpClient := &http.Client{Timeout: 30 * time.Second}
baseURL := "https://api.github.com"

client := NewClient(httpClient, baseURL)

assert.NotNil(t, client)
assert.Equal(t, httpClient, client.HTTPClient())
assert.Equal(t, baseURL, client.BaseURL())
}

func TestClient_GraphQLURL(t *testing.T) {
tests := []struct {
name string
baseURL string
expected string
}{
{
name: "standard github",
baseURL: "https://api.github.com",
expected: "https://api.github.com/graphql",
},
{
name: "github enterprise",
baseURL: "https://ghe.example.com/api",
expected: "https://ghe.example.com/api/graphql",
},
{
name: "localhost",
baseURL: "http://localhost:8080",
expected: "http://localhost:8080/graphql",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := NewClient(&http.Client{}, tt.baseURL)
assert.Equal(t, tt.expected, client.GraphQLURL())
})
}
}

func TestClient_BaseURL(t *testing.T) {
tests := []struct {
name string
baseURL string
}{
{name: "standard github", baseURL: "https://api.github.com"},
{name: "github enterprise", baseURL: "https://ghe.example.com/api"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := NewClient(&http.Client{}, tt.baseURL)
assert.Equal(t, tt.baseURL, client.BaseURL())
})
}
}

func TestClient_RESTURL(t *testing.T) {
tests := []struct {
name string
baseURL string
path string
expected string
}{
{
name: "repos endpoint",
baseURL: "https://api.github.com",
path: "/repos/uber/submitqueue/pulls/123",
expected: "https://api.github.com/repos/uber/submitqueue/pulls/123",
},
{
name: "enterprise repos endpoint",
baseURL: "https://ghe.example.com/api",
path: "/repos/myorg/myrepo/pulls/456",
expected: "https://ghe.example.com/api/repos/myorg/myrepo/pulls/456",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := NewClient(&http.Client{}, tt.baseURL)
assert.Equal(t, tt.expected, client.RESTURL(tt.path))
})
}
}
42 changes: 42 additions & 0 deletions extension/changeprovider/github/convert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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 {
linesModified := 0
if file.Additions > 0 && file.Deletions > 0 {
linesModified = min(file.Additions, file.Deletions)
}

changedFiles = append(changedFiles, changeprovider.ChangedFile{
Path: file.Path,
Patch: file.Patch,
LinesAdded: file.Additions,
LinesDeleted: file.Deletions,
LinesModified: linesModified,
})
}

return changedFiles
}
Loading