From e09392870dcb84a835ce2cbc43f35def1e615877 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Wed, 15 Apr 2026 13:11:29 -0700 Subject: [PATCH 1/2] refactor: extract gh client generation into gh package --- apps/workspace-engine/pkg/github/client.go | 91 +++++++++++++++ .../pkg/githubclient/githubclient.go | 110 ------------------ .../jobagents/github/workflow_dispatcher.go | 100 +--------------- 3 files changed, 94 insertions(+), 207 deletions(-) create mode 100644 apps/workspace-engine/pkg/github/client.go delete mode 100644 apps/workspace-engine/pkg/githubclient/githubclient.go diff --git a/apps/workspace-engine/pkg/github/client.go b/apps/workspace-engine/pkg/github/client.go new file mode 100644 index 000000000..1f20374ad --- /dev/null +++ b/apps/workspace-engine/pkg/github/client.go @@ -0,0 +1,91 @@ +package github + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/google/go-github/v66/github" + "workspace-engine/pkg/config" +) + +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(nil).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(nil).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 -} From fce9035d89f9c35ced98ffd2cad8fd3211cec5f0 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Wed, 15 Apr 2026 13:21:28 -0700 Subject: [PATCH 2/2] safer client --- apps/workspace-engine/pkg/github/client.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/workspace-engine/pkg/github/client.go b/apps/workspace-engine/pkg/github/client.go index 1f20374ad..2fba34d30 100644 --- a/apps/workspace-engine/pkg/github/client.go +++ b/apps/workspace-engine/pkg/github/client.go @@ -3,6 +3,7 @@ package github import ( "context" "fmt" + "net/http" "strconv" "time" @@ -11,6 +12,8 @@ import ( "workspace-engine/pkg/config" ) +var httpClient = &http.Client{Timeout: 30 * time.Second} + func generateJWT() (string, error) { appIDStr := config.Global.GithubBotAppID privateKey := config.Global.GithubBotPrivateKey @@ -47,7 +50,7 @@ func appClient() (*github.Client, error) { if err != nil { return nil, err } - return github.NewClient(nil).WithAuthToken(jwtStr), nil + return github.NewClient(httpClient).WithAuthToken(jwtStr), nil } // CreateClientForInstallation returns a GitHub client authenticated as the @@ -67,7 +70,7 @@ func CreateClientForInstallation( return nil, fmt.Errorf("create installation token: %w", err) } - return github.NewClient(nil).WithAuthToken(token.GetToken()), nil + return github.NewClient(httpClient).WithAuthToken(token.GetToken()), nil } // CreateClientForRepo returns a GitHub client authenticated for the