Files
Memoh/internal/copilot/client.go

177 lines
4.8 KiB
Go

package copilot
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
const (
GitHubOAuthClientID = "Iv1.b507a08c87ecfe98"
GitHubOAuthScope = "read:user user:email"
DefaultAPIBaseURL = "https://api.githubcopilot.com"
copilotTokenURL = "https://api.github.com/copilot_internal/v2/token" //nolint:gosec // Fixed GitHub API endpoint, not a credential.
copilotEditorVersion = "vscode/1.110.1"
copilotPluginVersion = "copilot-chat/0.38.2"
copilotUserAgent = "GitHubCopilotChat/0.38.2"
copilotAPIVersion = "2025-10-01"
copilotIntegrationID = "vscode-chat"
copilotTokenRefreshSkew = time.Minute
defaultHTTPClientTimeout = 15 * time.Second
)
type cachedToken struct {
Token string
ExpiresAt time.Time
}
var tokenCache = struct {
mu sync.Mutex
entries map[string]cachedToken
}{
entries: map[string]cachedToken{},
}
type tokenResponse struct {
Token string `json:"token"`
ExpiresAt int64 `json:"expires_at"`
}
func ResolveToken(ctx context.Context, githubToken string) (string, error) {
githubToken = strings.TrimSpace(githubToken)
if githubToken == "" {
return "", errors.New("github token is required")
}
if token, ok := loadCachedToken(githubToken); ok {
return token, nil
}
token, expiresAt, err := FetchCopilotToken(ctx, githubToken)
if err != nil {
return "", err
}
storeCachedToken(githubToken, token, expiresAt)
return token, nil
}
func FetchCopilotToken(ctx context.Context, githubToken string) (string, time.Time, error) {
githubToken = strings.TrimSpace(githubToken)
if githubToken == "" {
return "", time.Time{}, errors.New("github token is required")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, copilotTokenURL, nil)
if err != nil {
return "", time.Time{}, fmt.Errorf("create copilot token request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "token "+githubToken)
req.Header.Set("Editor-Version", copilotEditorVersion)
req.Header.Set("Editor-Plugin-Version", copilotPluginVersion)
req.Header.Set("User-Agent", copilotUserAgent)
req.Header.Set("X-GitHub-Api-Version", copilotAPIVersion)
resp, err := defaultHTTPClient(nil).Do(req) //nolint:gosec // Request targets a fixed GitHub API endpoint.
if err != nil {
return "", time.Time{}, fmt.Errorf("fetch copilot token: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", time.Time{}, fmt.Errorf("read copilot token response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", time.Time{}, fmt.Errorf("copilot token request failed: %s", strings.TrimSpace(string(body)))
}
var parsed tokenResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return "", time.Time{}, fmt.Errorf("decode copilot token response: %w", err)
}
if strings.TrimSpace(parsed.Token) == "" {
return "", time.Time{}, errors.New("copilot token response did not include a token")
}
var expiresAt time.Time
if parsed.ExpiresAt > 0 {
expiresAt = time.Unix(parsed.ExpiresAt, 0).UTC()
}
return parsed.Token, expiresAt, nil
}
func NewHTTPClient(base *http.Client) *http.Client {
client := defaultHTTPClient(base)
client.Transport = &headerRoundTripper{
base: client.Transport,
headers: map[string]string{
"Copilot-Integration-Id": copilotIntegrationID,
"Editor-Version": copilotEditorVersion,
"Editor-Plugin-Version": copilotPluginVersion,
"User-Agent": copilotUserAgent,
"X-GitHub-Api-Version": copilotAPIVersion,
},
}
return client
}
type headerRoundTripper struct {
base http.RoundTripper
headers map[string]string
}
func (rt *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
clone := req.Clone(req.Context())
clone.Header = req.Header.Clone()
for key, value := range rt.headers {
clone.Header.Set(key, value)
}
if rt.base == nil {
rt.base = http.DefaultTransport
}
return rt.base.RoundTrip(clone)
}
func loadCachedToken(githubToken string) (string, bool) {
tokenCache.mu.Lock()
defer tokenCache.mu.Unlock()
entry, ok := tokenCache.entries[githubToken]
if !ok {
return "", false
}
if !entry.ExpiresAt.IsZero() && !time.Now().Add(copilotTokenRefreshSkew).Before(entry.ExpiresAt) {
delete(tokenCache.entries, githubToken)
return "", false
}
return entry.Token, true
}
func storeCachedToken(githubToken, token string, expiresAt time.Time) {
tokenCache.mu.Lock()
defer tokenCache.mu.Unlock()
tokenCache.entries[githubToken] = cachedToken{
Token: token,
ExpiresAt: expiresAt,
}
}
func defaultHTTPClient(base *http.Client) *http.Client {
if base != nil {
clone := *base
if clone.Timeout == 0 {
clone.Timeout = defaultHTTPClientTimeout
}
return &clone
}
return &http.Client{Timeout: defaultHTTPClientTimeout}
}