Files
kandev/apps/backend/internal/github/factory.go
T
2026-07-26 19:07:46 +01:00

162 lines
4.8 KiB
Go

package github
import (
"context"
"fmt"
"os"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
)
// SecretProvider is the interface the factory uses to look up a GitHub PAT.
type SecretProvider interface {
List(ctx context.Context) ([]*SecretListItem, error)
Reveal(ctx context.Context, id string) (string, error)
}
// SecretListItem mirrors secrets.SecretListItem to avoid a direct import cycle.
type SecretListItem struct {
ID string `json:"id"`
Name string `json:"name"`
HasValue bool `json:"has_value"`
}
const legacyMockAuthMethod = "mock"
// NewClient creates a GitHub client using the best available auth method.
// It tries the gh CLI first, then falls back to a PAT from the secret store.
func NewClient(ctx context.Context, secrets SecretProvider, log *logger.Logger) (Client, string, error) {
client, method, _, err := newLegacyCredential(ctx, secrets, log, false)
return client, method, err
}
// newLegacyGitTransportCredential resolves the same ambient credential used by
// migrated legacy_shared API access and additionally returns its raw token for
// in-memory Git transport use. The token is never exposed through Client.
func newLegacyGitTransportCredential(
ctx context.Context,
secrets SecretProvider,
log *logger.Logger,
) (Client, string, string, error) {
return newLegacyCredential(ctx, secrets, log, true)
}
func newLegacyCredential(
ctx context.Context,
secrets SecretProvider,
log *logger.Logger,
includeTransportCredential bool,
) (Client, string, string, error) {
// Mock client for E2E testing
if os.Getenv("KANDEV_MOCK_GITHUB") == "true" {
log.Info("using mock client for GitHub integration")
credential := ""
if includeTransportCredential {
credential = legacyMockAuthMethod
}
return NewMockClient(), legacyMockAuthMethod, credential, nil
}
// gh inherits GH_TOKEN/GITHUB_TOKEN for API calls, while exact-account
// token resolution deliberately strips them. For Git transport, resolve
// an ambient token directly so the API and clone identities cannot diverge.
// GH_TOKEN follows gh's documented precedence over GITHUB_TOKEN.
if includeTransportCredential {
if token := legacyEnvironmentToken(); token != "" {
log.Info("using environment PAT for legacy GitHub Git transport")
return NewPATClient(token), AuthMethodPAT, token, nil
}
}
if client, method, credential, found, err := resolveLegacyGHCredential(
ctx, log, includeTransportCredential,
); found || err != nil {
return client, method, credential, err
}
// Fall back to PAT from environment variable
if token := legacyEnvironmentToken(); token != "" {
log.Info("using environment PAT for GitHub integration")
return NewPATClient(token), AuthMethodPAT, transportCredential(token, includeTransportCredential), nil
}
// Fall back to PAT from secrets store
if secrets != nil {
token, err := findGitHubPAT(ctx, secrets)
if err == nil && token != "" {
log.Info("using PAT from secrets store for GitHub integration")
return NewPATClient(token), AuthMethodPAT, transportCredential(token, includeTransportCredential), nil
}
if err != nil {
log.Debug("failed to find GitHub PAT in secrets", zap.Error(err))
}
}
return &NoopClient{}, AuthMethodNone, "", nil
}
func resolveLegacyGHCredential(
ctx context.Context,
log *logger.Logger,
includeTransportCredential bool,
) (Client, string, string, bool, error) {
if !GHAvailable() {
return nil, "", "", false, nil
}
ghClient := NewGHClient()
ok, err := ghClient.IsAuthenticated(ctx)
if err != nil || !ok {
log.Debug("gh CLI available but not authenticated", zap.Error(err))
return nil, "", "", false, nil
}
log.Info("using gh CLI for GitHub integration")
if !includeTransportCredential {
return ghClient, string(ConnectionSourceGHCLI), "", true, nil
}
login, err := ghClient.GetAuthenticatedUser(ctx)
if err != nil {
return nil, AuthMethodNone, "", false, err
}
token, err := ResolveGHAccountToken(ctx, defaultGitHubHost, login)
if err != nil {
return nil, AuthMethodNone, "", false, err
}
return ghClient, string(ConnectionSourceGHCLI), token, true, nil
}
func legacyEnvironmentToken() string {
if token := os.Getenv("GH_TOKEN"); token != "" {
return token
}
return os.Getenv("GITHUB_TOKEN")
}
func transportCredential(token string, include bool) string {
if include {
return token
}
return ""
}
// findGitHubPAT looks for a secret named "GITHUB_TOKEN" or "github_token".
func findGitHubPAT(ctx context.Context, secrets SecretProvider) (string, error) {
items, err := secrets.List(ctx)
if err != nil {
return "", fmt.Errorf("list secrets: %w", err)
}
for _, item := range items {
if !item.HasValue {
continue
}
name := item.Name
if name == "GITHUB_TOKEN" || name == "github_token" {
return secrets.Reveal(ctx, item.ID)
}
}
return "", nil
}