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
94 changes: 94 additions & 0 deletions apps/workspace-engine/pkg/github/client.go
Original file line number Diff line number Diff line change
@@ -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
}
Comment on lines +48 to +54

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

github.NewClient(nil) uses the default HTTP client (no timeout). Since callers often pass a context without a deadline (e.g. async dispatch), GitHub API calls can hang indefinitely. Consider constructing a shared *http.Client with a sane Timeout and passing it into github.NewClient, and/or wrapping calls in context.WithTimeout inside this package.

Copilot uses AI. Check for mistakes.

// 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
}
Comment on lines +68 to +74

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

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

The installation-authenticated client is created with github.NewClient(nil) as well, inheriting http.DefaultClient without timeouts. To avoid indefinitely hung dispatches/token refreshes, pass a configured *http.Client (Timeout/Transport) into github.NewClient here too (and ideally reuse the same client as appClient).

Copilot uses AI. Check for mistakes.

// 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())
}
110 changes: 0 additions & 110 deletions apps/workspace-engine/pkg/githubclient/githubclient.go

This file was deleted.

100 changes: 3 additions & 97 deletions apps/workspace-engine/pkg/jobagents/github/workflow_dispatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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(
Expand All @@ -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
}
Loading