diff --git a/apps/workspace-engine/pkg/github/client.go b/apps/workspace-engine/pkg/github/client.go new file mode 100644 index 000000000..2fba34d30 --- /dev/null +++ b/apps/workspace-engine/pkg/github/client.go @@ -0,0 +1,94 @@ +package github + +import ( + "context" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/google/go-github/v66/github" + "workspace-engine/pkg/config" +) + +var httpClient = &http.Client{Timeout: 30 * time.Second} + +func generateJWT() (string, error) { + appIDStr := config.Global.GithubBotAppID + privateKey := config.Global.GithubBotPrivateKey + + if appIDStr == "" || privateKey == "" { + return "", fmt.Errorf( + "GitHub bot not configured: missing GITHUB_BOT_APP_ID or GITHUB_BOT_PRIVATE_KEY", + ) + } + + appID, err := strconv.ParseInt(appIDStr, 10, 64) + if err != nil { + return "", fmt.Errorf("invalid GITHUB_BOT_APP_ID: %w", err) + } + + key, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(privateKey)) + if err != nil { + return "", fmt.Errorf("parse private key: %w", err) + } + + now := time.Now() + claims := jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now.Add(-60 * time.Second)), + ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), + Issuer: strconv.FormatInt(appID, 10), + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + return token.SignedString(key) +} + +func appClient() (*github.Client, error) { + jwtStr, err := generateJWT() + if err != nil { + return nil, err + } + return github.NewClient(httpClient).WithAuthToken(jwtStr), nil +} + +// CreateClientForInstallation returns a GitHub client authenticated as the +// given installation. Use this when the installation ID is already known +// (e.g. from a job agent config). +func CreateClientForInstallation( + ctx context.Context, + installationID int64, +) (*github.Client, error) { + app, err := appClient() + if err != nil { + return nil, err + } + + token, _, err := app.Apps.CreateInstallationToken(ctx, installationID, nil) + if err != nil { + return nil, fmt.Errorf("create installation token: %w", err) + } + + return github.NewClient(httpClient).WithAuthToken(token.GetToken()), nil +} + +// CreateClientForRepo returns a GitHub client authenticated for the +// installation that covers owner/repo. It discovers the installation via +// the GitHub API. Returns (nil, nil) if the GitHub bot is not configured. +func CreateClientForRepo(ctx context.Context, owner, repo string) (*github.Client, error) { + app, err := appClient() + if err != nil { + if config.Global.GithubBotAppID == "" || config.Global.GithubBotPrivateKey == "" { + return nil, nil + } + return nil, err + } + + installation, _, err := app.Apps.FindRepositoryInstallation(ctx, owner, repo) + if err != nil { + return nil, fmt.Errorf("find installation for %s/%s: %w", owner, repo, err) + } + + return CreateClientForInstallation(ctx, installation.GetID()) +} diff --git a/apps/workspace-engine/pkg/githubclient/githubclient.go b/apps/workspace-engine/pkg/githubclient/githubclient.go deleted file mode 100644 index 564723edd..000000000 --- a/apps/workspace-engine/pkg/githubclient/githubclient.go +++ /dev/null @@ -1,110 +0,0 @@ -package githubclient - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "strconv" - "time" - - "github.com/golang-jwt/jwt/v4" - "github.com/google/go-github/v66/github" - "workspace-engine/pkg/config" - "workspace-engine/pkg/oapi" -) - -func generateJWT(appID int64, privateKey []byte) (string, error) { - // Parse the private key - key, err := jwt.ParseRSAPrivateKeyFromPEM(privateKey) - if err != nil { - return "", fmt.Errorf("failed to parse private key: %w", err) - } - - // Create the JWT claims (issued at time and expiration) - now := time.Now() - claims := jwt.RegisteredClaims{ - IssuedAt: jwt.NewNumericDate( - now.Add(-60 * time.Second), - ), // 60 seconds in the past to allow for clock drift - ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), // Max 10 minutes - Issuer: strconv.FormatInt(appID, 10), - } - - // Create and sign the token - token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) - signedToken, err := token.SignedString(key) - if err != nil { - return "", fmt.Errorf("failed to sign JWT: %w", err) - } - - return signedToken, nil -} - -// getInstallationToken exchanges JWT for an installation access token -// This matches what Node.js octokit.auth() does. -func getInstallationToken(jwtToken string, installationID int) (string, error) { - url := fmt.Sprintf("https://api.github.com/app/installations/%d/access_tokens", installationID) - - req, err := http.NewRequest(http.MethodPost, url, nil) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Authorization", "Bearer "+jwtToken) - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("X-GitHub-Api-Version", "2022-11-28") - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("failed to get installation token: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf( - "failed to get installation token: %s - %s", - resp.Status, - string(body), - ) - } - - var result struct { - Token string `json:"token"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", fmt.Errorf("failed to decode token response: %w", err) - } - - return result.Token, nil -} - -func CreateGithubClient(ghEntity *oapi.GithubEntity) (*github.Client, error) { - appIDStr := config.Global.GithubBotAppID - privateKey := config.Global.GithubBotPrivateKey - - if appIDStr == "" || privateKey == "" { - return nil, fmt.Errorf( - "GitHub bot not configured: missing GITHUB_BOT_APP_ID or GITHUB_BOT_PRIVATE_KEY", - ) - } - - appID, err := strconv.ParseInt(appIDStr, 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid GITHUB_BOT_APP_ID: %w", err) - } - - jwtToken, err := generateJWT(appID, []byte(privateKey)) - if err != nil { - return nil, fmt.Errorf("failed to generate JWT: %w", err) - } - - installationToken, err := getInstallationToken(jwtToken, ghEntity.InstallationId) - if err != nil { - return nil, fmt.Errorf("failed to get installation token: %w", err) - } - - return github.NewClient(nil).WithAuthToken(installationToken), nil -} diff --git a/apps/workspace-engine/pkg/jobagents/github/workflow_dispatcher.go b/apps/workspace-engine/pkg/jobagents/github/workflow_dispatcher.go index 71058e1f7..378ba6ffc 100644 --- a/apps/workspace-engine/pkg/jobagents/github/workflow_dispatcher.go +++ b/apps/workspace-engine/pkg/jobagents/github/workflow_dispatcher.go @@ -2,16 +2,10 @@ package github import ( "context" - "encoding/json" "fmt" - "io" - "net/http" - "strconv" - "time" - "github.com/golang-jwt/jwt/v4" "github.com/google/go-github/v66/github" - "workspace-engine/pkg/config" + gh "workspace-engine/pkg/github" "workspace-engine/pkg/oapi" ) @@ -25,9 +19,9 @@ func (d *GoGitHubWorkflowDispatcher) DispatchWorkflow( ref string, inputs map[string]any, ) error { - client, err := createGithubClient(&cfg) + client, err := gh.CreateClientForInstallation(ctx, int64(cfg.InstallationId)) if err != nil { - return fmt.Errorf("failed to create github client: %w", err) + return fmt.Errorf("create github client: %w", err) } if _, err := client.Actions.CreateWorkflowDispatchEventByID( @@ -45,91 +39,3 @@ func (d *GoGitHubWorkflowDispatcher) DispatchWorkflow( return nil } - -func createGithubClient(cfg *oapi.GithubJobAgentConfig) (*github.Client, error) { - appIDStr := config.Global.GithubBotAppID - privateKey := config.Global.GithubBotPrivateKey - - if appIDStr == "" || privateKey == "" { - return nil, fmt.Errorf( - "GitHub bot not configured: missing GITHUB_BOT_APP_ID or GITHUB_BOT_PRIVATE_KEY", - ) - } - - appID, err := strconv.ParseInt(appIDStr, 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid GITHUB_BOT_APP_ID: %w", err) - } - - jwtToken, err := generateJWT(appID, []byte(privateKey)) - if err != nil { - return nil, fmt.Errorf("failed to generate JWT: %w", err) - } - - installationToken, err := getInstallationToken(jwtToken, cfg.InstallationId) - if err != nil { - return nil, fmt.Errorf("failed to get installation token: %w", err) - } - - return github.NewClient(nil).WithAuthToken(installationToken), nil -} - -func generateJWT(appID int64, privateKey []byte) (string, error) { - key, err := jwt.ParseRSAPrivateKeyFromPEM(privateKey) - if err != nil { - return "", fmt.Errorf("failed to parse private key: %w", err) - } - - now := time.Now() - claims := jwt.RegisteredClaims{ - IssuedAt: jwt.NewNumericDate(now.Add(-60 * time.Second)), - ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), - Issuer: strconv.FormatInt(appID, 10), - } - - token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) - signedToken, err := token.SignedString(key) - if err != nil { - return "", fmt.Errorf("failed to sign JWT: %w", err) - } - - return signedToken, nil -} - -func getInstallationToken(jwtToken string, installationID int) (string, error) { - url := fmt.Sprintf("https://api.github.com/app/installations/%d/access_tokens", installationID) - - req, err := http.NewRequest(http.MethodPost, url, nil) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Authorization", "Bearer "+jwtToken) - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("X-GitHub-Api-Version", "2022-11-28") - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("failed to get installation token: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf( - "failed to get installation token: %s - %s", - resp.Status, - string(body), - ) - } - - var result struct { - Token string `json:"token"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", fmt.Errorf("failed to decode token response: %w", err) - } - - return result.Token, nil -}