diff --git a/MODULE.bazel b/MODULE.bazel index 70ce092f..1ad48edc 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -33,10 +33,12 @@ use_repo( "com_github_data_dog_go_sqlmock", "com_github_go_sql_driver_mysql", "com_github_gogo_protobuf", + "com_github_golang_jwt_jwt_v5", "com_github_stretchr_testify", "com_github_testcontainers_testcontainers_go", "com_github_testcontainers_testcontainers_go_modules_mysql", "com_github_uber_go_tally_v4", + "in_gopkg_yaml_v3", "org_golang_google_grpc", "org_golang_google_protobuf", "org_uber_go_fx", diff --git a/config/.gitignore b/config/.gitignore new file mode 100644 index 00000000..238defb8 --- /dev/null +++ b/config/.gitignore @@ -0,0 +1,8 @@ +# Ignore actual config files, only keep examples +*.yaml +*.json +!*.example.yaml +!*.example.json + +# Ignore private keys +*.pem diff --git a/config/github-app.example.yaml b/config/github-app.example.yaml new file mode 100644 index 00000000..9e7449f1 --- /dev/null +++ b/config/github-app.example.yaml @@ -0,0 +1,10 @@ +app_id: 123456 +installation_id: 789012 +owner: your-org +repo: your-repo +private_key: | + -----BEGIN RSA PRIVATE KEY----- + MIIEpAIBAAKCAQEA... + YOUR_PRIVATE_KEY_CONTENTS_HERE + ... + -----END RSA PRIVATE KEY----- diff --git a/example/server/gateway/main.go b/example/server/gateway/main.go index 0d21bd88..ad4bde0b 100644 --- a/example/server/gateway/main.go +++ b/example/server/gateway/main.go @@ -13,6 +13,8 @@ import ( _ "github.com/go-sql-driver/mysql" "github.com/uber-go/tally/v4" + changeprovider "github.com/uber/submitqueue/extension/change_provider" + "github.com/uber/submitqueue/extension/change_provider/github" mysqlcounter "github.com/uber/submitqueue/extension/counter/mysql" queueSQL "github.com/uber/submitqueue/extension/queue/sql" "github.com/uber/submitqueue/extension/storage/mysql" @@ -140,6 +142,38 @@ func run() error { logger.Info("queue initialized", zap.String("dsn", queueDSN)) + // Initialize GitHub change provider (optional) + var changeProvider changeprovider.ChangeProvider + githubConfigPath := os.Getenv("GITHUB_APP_CONFIG_PATH") + if githubConfigPath == "" { + githubConfigPath = "config/github-app.yaml" + } + + githubConfig, err := github.LoadConfigFromFile(githubConfigPath) + if err != nil { + logger.Warn("GitHub change provider disabled", zap.String("config_path", githubConfigPath), zap.Error(err)) + } else { + httpClient, err := github.NewHTTPClientFromConfig(githubConfig) + if err != nil { + return fmt.Errorf("failed to create GitHub HTTP client: %w", err) + } + + changeProvider = github.NewChangeProvider(github.Params{ + HTTPClient: httpClient, + Owner: githubConfig.Owner, + Repo: githubConfig.Repo, + }) + + logger.Info("GitHub change provider initialized", + zap.Int64("app_id", githubConfig.AppID), + zap.String("owner", githubConfig.Owner), + zap.String("repo", githubConfig.Repo), + ) + } + + // Suppress unused variable warning until changeProvider is used by a controller + _ = changeProvider + // Land controller requires queue publisher landController := controller.NewLandController(logger.Sugar(), scope, store, cnt, q.Publisher()) gatewayServer := &GatewayServer{ diff --git a/extension/change_provider/BUILD.bazel b/extension/change_provider/BUILD.bazel new file mode 100644 index 00000000..ec7534fe --- /dev/null +++ b/extension/change_provider/BUILD.bazel @@ -0,0 +1,8 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "change_provider", + srcs = ["change_provider.go"], + importpath = "github.com/uber/submitqueue/extension/change_provider", + visibility = ["//visibility:public"], +) diff --git a/extension/change_provider/change_provider.go b/extension/change_provider/change_provider.go new file mode 100644 index 00000000..2b08df9f --- /dev/null +++ b/extension/change_provider/change_provider.go @@ -0,0 +1,14 @@ +package changeprovider + +import "context" + +// ChangeProvider integrates with external code review and version control systems +// to check for merge conflicts and perform merges. +type ChangeProvider interface { + // HasMergeConflicts checks whether the head SHA has merge conflicts with the base SHA. + // Returns true if conflicts exist. + HasMergeConflicts(ctx context.Context, baseSHA string, headSHA string, PR string) (bool, error) + + // Merge merges the head SHA into the base SHA. + Merge(ctx context.Context, baseSHA string, headSHA string) error +} diff --git a/extension/change_provider/github/BUILD.bazel b/extension/change_provider/github/BUILD.bazel new file mode 100644 index 00000000..435413c7 --- /dev/null +++ b/extension/change_provider/github/BUILD.bazel @@ -0,0 +1,17 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "github", + srcs = [ + "change_provider.go", + "client.go", + "transport.go", + ], + importpath = "github.com/uber/submitqueue/extension/change_provider/github", + visibility = ["//visibility:public"], + deps = [ + "//extension/change_provider", + "@com_github_golang_jwt_jwt_v5//:jwt", + "@in_gopkg_yaml_v3//:yaml_v3", + ], +) diff --git a/extension/change_provider/github/change_provider.go b/extension/change_provider/github/change_provider.go new file mode 100644 index 00000000..2cb6f12d --- /dev/null +++ b/extension/change_provider/github/change_provider.go @@ -0,0 +1,57 @@ +package github + +import ( + "context" + "fmt" + "net/http" + + changeprovider "github.com/uber/submitqueue/extension/change_provider" +) + +// Params holds dependencies for the GitHub change provider. +type Params struct { + // HTTPClient is a pre-configured HTTP client with authentication. + // The caller is responsible for setting up GitHub App or token auth. + HTTPClient *http.Client + + // Owner is the repository owner (user or organization). + Owner string + + // Repo is the repository name. + Repo string + + // BaseURL is the GitHub API base URL. Defaults to https://api.github.com. + BaseURL string +} + +type githubChangeProvider struct { + client *githubClient +} + +// NewChangeProvider creates a new GitHub-backed ChangeProvider. +func NewChangeProvider(params Params) changeprovider.ChangeProvider { + return &githubChangeProvider{ + client: newClient(params), + } +} + +// HasMergeConflicts checks whether the head SHA has merge conflicts with the base SHA. +// Returns true if there are conflicts, false otherwise. +// The MergeableState from GitHub (e.g., "dirty", "blocked", "behind") is available +// in the error message when relevant. +func (p *githubChangeProvider) HasMergeConflicts(ctx context.Context, baseSHA string, headSHA string, PR string) (bool, error) { + hasConflicts, mergeableState, err := p.client.hasMergeConflicts(ctx, baseSHA, headSHA, PR) + if err != nil { + // Include mergeableState in error context when available + if mergeableState != "" { + return false, fmt.Errorf("%w (state: %s)", err, mergeableState) + } + return false, err + } + return hasConflicts, nil +} + +// Merge merges the head SHA into the base SHA. +func (p *githubChangeProvider) Merge(ctx context.Context, baseSHA string, headSHA string) error { + return p.client.merge(ctx, baseSHA, headSHA) +} diff --git a/extension/change_provider/github/client.go b/extension/change_provider/github/client.go new file mode 100644 index 00000000..3a77a457 --- /dev/null +++ b/extension/change_provider/github/client.go @@ -0,0 +1,178 @@ +package github + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" +) + +const defaultBaseURL = "https://api.github.com" + +// Sentinel errors for GitHub API operations. +var ( + ErrMergeConflict = errors.New("merge conflict") + ErrMergeStatusPending = errors.New("merge status pending") + ErrNotFound = errors.New("resource not found") +) + +type githubClient struct { + httpClient *http.Client + baseURL string + owner string + repo string +} + +func newClient(params Params) *githubClient { + baseURL := params.BaseURL + if baseURL == "" { + baseURL = defaultBaseURL + } + + httpClient := params.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + + return &githubClient{ + httpClient: httpClient, + baseURL: baseURL, + owner: params.Owner, + repo: params.Repo, + } +} + +// doRequest executes an HTTP request and decodes the JSON response. +// If result is nil, the response body is not decoded. +func (c *githubClient) doRequest(ctx context.Context, method, url string, body interface{}, result interface{}) error { + var bodyReader io.Reader + if body != nil { + bodyBytes, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + bodyReader = bytes.NewReader(bodyBytes) + } + + req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) + if err != nil { + return fmt.Errorf("failed to create HTTP request: %w", err) + } + c.setHeaders(req) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to execute HTTP request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return ErrNotFound + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, respBody) + } + + if result != nil { + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + } + + return nil +} + +// PullRequestStatus contains the mergeable status of a PR. +type PullRequestStatus struct { + Mergeable *bool + MergeableState string +} + +// hasMergeConflicts checks if the PR has merge conflicts using the GitHub PR API. +// GET /repos/{owner}/{repo}/pulls/{pull_number} +// +// Note: baseSHA and headSHA are currently unused. We use the PR number to get +// mergeability status from GitHub. However, if someone changes the head after +// submitting the request, that state is invalid. Logic to validate head/base SHA +// will be added later in the merge service layer. +func (c *githubClient) hasMergeConflicts(ctx context.Context, baseSHA string, headSHA string, pr string) (bool, string, error) { + url := fmt.Sprintf("%s/repos/%s/%s/pulls/%s", c.baseURL, c.owner, c.repo, pr) + + var result struct { + Mergeable *bool `json:"mergeable"` + MergeableState string `json:"mergeable_state"` + } + + if err := c.doRequest(ctx, http.MethodGet, url, nil, &result); err != nil { + return false, "", err + } + + // mergeable is null while GitHub is computing merge status + if result.Mergeable == nil { + return false, result.MergeableState, ErrMergeStatusPending + } + + // mergeable=false means there are conflicts + return !*result.Mergeable, result.MergeableState, nil +} + +// mergeRequest represents the request body for the GitHub merge API. +type mergeRequest struct { + Base string `json:"base"` + Head string `json:"head"` + CommitMessage string `json:"commit_message,omitempty"` +} + +// merge merges the head SHA into the base SHA using the GitHub merges API. +// POST /repos/{owner}/{repo}/merges +func (c *githubClient) merge(ctx context.Context, baseSHA string, headSHA string) error { + url := fmt.Sprintf("%s/repos/%s/%s/merges", c.baseURL, c.owner, c.repo) + + var bodyReader io.Reader + bodyBytes, err := json.Marshal(mergeRequest{ + Base: baseSHA, + Head: headSHA, + }) + if err != nil { + return fmt.Errorf("failed to marshal merge request: %w", err) + } + bodyReader = bytes.NewReader(bodyBytes) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bodyReader) + if err != nil { + return fmt.Errorf("failed to create HTTP request: %w", err) + } + c.setHeaders(req) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to execute merge request: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusCreated, http.StatusNoContent: + return nil + case http.StatusConflict: + return fmt.Errorf("%w: cannot merge %s into %s", ErrMergeConflict, headSHA, baseSHA) + case http.StatusNotFound: + return fmt.Errorf("%w: base or head SHA not found", ErrNotFound) + default: + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("merge request failed with status %d: %s", resp.StatusCode, respBody) + } +} + +// setHeaders sets common headers for GitHub API requests. +func (c *githubClient) setHeaders(req *http.Request) { + req.Header.Set("Accept", "application/vnd.github+json") +} diff --git a/extension/change_provider/github/transport.go b/extension/change_provider/github/transport.go new file mode 100644 index 00000000..a8f6423d --- /dev/null +++ b/extension/change_provider/github/transport.go @@ -0,0 +1,193 @@ +package github + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net/http" + "os" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + "gopkg.in/yaml.v3" +) + +// AppConfig holds GitHub App configuration for authentication. +type AppConfig struct { + // AppID is the GitHub App ID. + AppID int64 `yaml:"app_id"` + + // InstallationID is the GitHub App installation ID for the target repo/org. + InstallationID int64 `yaml:"installation_id"` + + // Owner is the repository owner (user or organization). + Owner string `yaml:"owner"` + + // Repo is the repository name. + Repo string `yaml:"repo"` + + // PrivateKey is the PEM-encoded private key contents. + PrivateKey string `yaml:"private_key"` +} + +// LoadConfigFromFile loads AppConfig from a YAML file. +func LoadConfigFromFile(path string) (AppConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return AppConfig{}, fmt.Errorf("failed to read config file %s: %w", path, err) + } + + var config AppConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return AppConfig{}, fmt.Errorf("failed to parse config file %s: %w", path, err) + } + + return config, nil +} + +// NewHTTPClientFromConfig creates an HTTP client configured with GitHub App authentication. +// The client automatically handles JWT generation and installation token refresh. +func NewHTTPClientFromConfig(config AppConfig) (*http.Client, error) { + if config.PrivateKey == "" { + return nil, fmt.Errorf("private_key is required") + } + + privateKey, err := parsePrivateKey([]byte(config.PrivateKey)) + if err != nil { + return nil, fmt.Errorf("failed to parse private key: %w", err) + } + + transport := &appTransport{ + appID: config.AppID, + installationID: config.InstallationID, + privateKey: privateKey, + baseURL: defaultBaseURL, + base: http.DefaultTransport, + } + + return &http.Client{Transport: transport}, nil +} + +// appTransport is an http.RoundTripper that authenticates requests using GitHub App installation tokens. +type appTransport struct { + appID int64 + installationID int64 + privateKey *rsa.PrivateKey + baseURL string + base http.RoundTripper + + mu sync.Mutex + token string + exp time.Time +} + +// RoundTrip implements http.RoundTripper. +func (t *appTransport) RoundTrip(req *http.Request) (*http.Response, error) { + token, err := t.getToken() + if err != nil { + return nil, fmt.Errorf("failed to get installation token: %w", err) + } + + req2 := req.Clone(req.Context()) + req2.Header.Set("Authorization", "Bearer "+token) + + return t.base.RoundTrip(req2) +} + +// getToken returns a valid installation access token, refreshing if needed. +func (t *appTransport) getToken() (string, error) { + t.mu.Lock() + defer t.mu.Unlock() + + if t.token != "" && time.Now().Before(t.exp.Add(-time.Minute)) { + return t.token, nil + } + + token, exp, err := t.refreshToken() + if err != nil { + return "", err + } + + t.token = token + t.exp = exp + return t.token, nil +} + +// refreshToken exchanges a JWT for an installation access token. +func (t *appTransport) refreshToken() (string, time.Time, error) { + jwtToken, err := t.generateJWT() + if err != nil { + return "", time.Time{}, err + } + + url := fmt.Sprintf("%s/app/installations/%d/access_tokens", t.baseURL, t.installationID) + req, err := http.NewRequest(http.MethodPost, url, nil) + if err != nil { + return "", time.Time{}, fmt.Errorf("failed to create token request: %w", err) + } + + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Authorization", "Bearer "+jwtToken) + + resp, err := t.base.RoundTrip(req) + if err != nil { + return "", time.Time{}, fmt.Errorf("failed to request installation token: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return "", time.Time{}, fmt.Errorf("installation token request failed with status %d: %s", resp.StatusCode, body) + } + + var tokenResp struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + } + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return "", time.Time{}, fmt.Errorf("failed to decode token response: %w", err) + } + + return tokenResp.Token, tokenResp.ExpiresAt, nil +} + +// generateJWT creates a signed JWT for GitHub App authentication. +func (t *appTransport) generateJWT() (string, error) { + now := time.Now() + claims := jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now.Add(-time.Minute)), + ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), + Issuer: fmt.Sprintf("%d", t.appID), + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + return token.SignedString(t.privateKey) +} + +// parsePrivateKey parses a PEM-encoded RSA private key. +func parsePrivateKey(pemData []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(pemData) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM block") + } + + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return key, nil + } + + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse private key: %w", err) + } + + rsaKey, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("private key is not RSA") + } + + return rsaKey, nil +} diff --git a/extension/change_provider/phab/BUILD.bazel b/extension/change_provider/phab/BUILD.bazel new file mode 100644 index 00000000..96031e9c --- /dev/null +++ b/extension/change_provider/phab/BUILD.bazel @@ -0,0 +1,11 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "phab", + srcs = ["change_provider.go"], + importpath = "github.com/uber/submitqueue/extension/change_provider/phab", + visibility = ["//visibility:public"], + deps = [ + "//extension/change_provider", + ], +) diff --git a/extension/change_provider/phab/change_provider.go b/extension/change_provider/phab/change_provider.go new file mode 100644 index 00000000..41158d3c --- /dev/null +++ b/extension/change_provider/phab/change_provider.go @@ -0,0 +1,24 @@ +package phab + +import ( + "context" + + changeprovider "github.com/uber/submitqueue/extension/change_provider" +) + +type phabChangeProvider struct{} + +// NewChangeProvider creates a new Phabricator-backed ChangeProvider. +func NewChangeProvider() changeprovider.ChangeProvider { + return &phabChangeProvider{} +} + +// HasMergeConflicts checks whether the head SHA has merge conflicts with the base SHA. +func (p *phabChangeProvider) HasMergeConflicts(ctx context.Context, baseSHA string, headSHA string, PR string) (bool, error) { + return false, nil +} + +// Merge merges the head SHA into the base SHA. +func (p *phabChangeProvider) Merge(ctx context.Context, baseSHA string, headSHA string) error { + return nil +} diff --git a/go.mod b/go.mod index e6eb7e7a..da053664 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/go-sql-driver/mysql v1.9.3 github.com/gogo/protobuf v1.3.2 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.40.0 github.com/testcontainers/testcontainers-go/modules/mysql v0.40.0 diff --git a/go.sum b/go.sum index 6a49fdc7..7c9e10ca 100644 --- a/go.sum +++ b/go.sum @@ -86,6 +86,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/status v1.1.0 h1:+eIkrewn5q6b30y+g/BJINVVdi2xH7je5MPJ3ZPK3JA= github.com/gogo/status v1.1.0/go.mod h1:BFv9nrluPLmrS0EmGVvLaPNmRosr9KapBYd5/hpY1WM= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U= github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=