From ab057fdf04affbe4454d35741684277362a92095 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Tue, 16 Dec 2025 16:46:20 -0300 Subject: [PATCH] feat: add github copilot support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mehmet Arif Çelik Co-authored-by: Mikhail Lukianchenko <42915+mikluko@users.noreply.github.com> --- go.mod | 4 +- go.sum | 4 +- internal/agent/agent.go | 12 +- internal/cmd/login.go | 85 +++++++++++++- internal/config/config.go | 38 ++++-- internal/config/copilot.go | 43 +++++++ internal/config/load.go | 14 ++- internal/oauth/copilot/disk.go | 36 ++++++ internal/oauth/copilot/http.go | 17 +++ internal/oauth/copilot/oauth.go | 199 ++++++++++++++++++++++++++++++++ internal/oauth/token.go | 5 + 11 files changed, 440 insertions(+), 17 deletions(-) create mode 100644 internal/config/copilot.go create mode 100644 internal/oauth/copilot/disk.go create mode 100644 internal/oauth/copilot/http.go create mode 100644 internal/oauth/copilot/oauth.go diff --git a/go.mod b/go.mod index a8ef92e75e00e7108055c457685752f4495d6807..8cdfbfd733c017827c884699b7d1a639fac8325b 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/aymanbagabas/go-udiff v0.3.1 github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/charlievieth/fastwalk v1.0.14 - github.com/charmbracelet/catwalk v0.10.2 + github.com/charmbracelet/catwalk v0.11.0 github.com/charmbracelet/colorprofile v0.4.1 github.com/charmbracelet/fang v0.4.4 github.com/charmbracelet/ultraviolet v0.0.0-20251212194010-b927aa605560 @@ -53,6 +53,7 @@ require ( github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef github.com/stretchr/testify v1.11.1 + github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/zeebo/xxh3 v1.0.2 golang.org/x/mod v0.31.0 @@ -145,7 +146,6 @@ require ( github.com/sourcegraph/jsonrpc2 v0.2.1 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/tetratelabs/wazero v1.10.1 // indirect - github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/u-root/u-root v0.14.1-0.20250807200646-5e7721023dc7 // indirect diff --git a/go.sum b/go.sum index 8d17bc6ef2ec5a3c6b03717e7b4d76674f8e6b65..d107ae21a26cdfe2061d697813ad7a018f86177b 100644 --- a/go.sum +++ b/go.sum @@ -92,8 +92,8 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904 h1:rwLdEpG9wE6kL69KkEKDiWprO8pQOZHZXeod6+9K+mw= github.com/charmbracelet/anthropic-sdk-go v0.0.0-20251024181547-21d6f3d9a904/go.mod h1:8TIYxZxsuCqqeJ0lga/b91tBwrbjoHDC66Sq5t8N2R4= -github.com/charmbracelet/catwalk v0.10.2 h1:Ps6IeGu0ArKE3l3OYv+HwIwbnzZrAl1C3AuwXiOf1G0= -github.com/charmbracelet/catwalk v0.10.2/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ= +github.com/charmbracelet/catwalk v0.11.0 h1:PU3rkc4h4YVJEn9Iyb/1rQAaF4hEd04fuG4tj3vv4dg= +github.com/charmbracelet/catwalk v0.11.0/go.mod h1:qg+Yl9oaZTkTvRscqbxfttzOFQ4v0pOT5XwC7b5O0NQ= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 0707ec98abed9fdf1599a13b081b5958551c83ed..62025b1943af245e94da6da744036e8040029c65 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -461,7 +461,17 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy link := lipgloss.NewStyle().Hyperlink(url, "id=hyper").Render(url) currentAssistant.AddFinish(message.FinishReasonError, "No credits", "You're out of credits. Add more at "+link) } else if errors.As(err, &providerErr) { - currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message) + if providerErr.Message == "The requested model is not supported." { + url := "https://github.com/settings/copilot/features" + link := lipgloss.NewStyle().Hyperlink(url, "id=hyper").Render(url) + currentAssistant.AddFinish( + message.FinishReasonError, + "Copilot model not enabled", + fmt.Sprintf("%q is not enabled in Copilot. Go to the following page to enable it. Then, wait a minute before trying again. %s", a.largeModel.CatwalkCfg.Name, link), + ) + } else { + currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message) + } } else if errors.As(err, &fantasyErr) { currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(fantasyErr.Title), defaultTitle), fantasyErr.Message) } else { diff --git a/internal/cmd/login.go b/internal/cmd/login.go index 22fde94985917999f5664696d81a4aa205503640..899ff4ff503a916e44ebe66dd0dca90917465713 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -12,7 +12,9 @@ import ( "github.com/atotto/clipboard" hyperp "github.com/charmbracelet/crush/internal/agent/hyper" "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/oauth" "github.com/charmbracelet/crush/internal/oauth/claude" + "github.com/charmbracelet/crush/internal/oauth/copilot" "github.com/charmbracelet/crush/internal/oauth/hyper" "github.com/pkg/browser" "github.com/spf13/cobra" @@ -24,7 +26,7 @@ var loginCmd = &cobra.Command{ Short: "Login Crush to a platform", Long: `Login Crush to a specified platform. The platform should be provided as an argument. -Available platforms are: hyper, claude.`, +Available platforms are: hyper, claude, copilot.`, Example: ` # Authenticate with Charm Hyper crush login @@ -36,6 +38,9 @@ crush login claude "hyper", "claude", "anthropic", + "copilot", + "github", + "github-copilot", }, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -54,6 +59,8 @@ crush login claude return loginHyper() case "anthropic", "claude": return loginClaude() + case "copilot", "github", "github-copilot": + return loginCopilot() default: return fmt.Errorf("unknown platform: %s", args[0]) } @@ -125,8 +132,13 @@ func loginHyper() error { } func loginClaude() error { - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill) - defer cancel() + ctx := getLoginContext() + + cfg := config.Get() + if cfg.HasConfigField("providers.anthropic.oauth") { + fmt.Println("You are already logged in to Claude.") + return nil + } verifier, challenge, err := claude.GetChallenge() if err != nil { @@ -161,7 +173,6 @@ func loginClaude() error { return err } - cfg := config.Get() if err := cmp.Or( cfg.SetConfigField("providers.anthropic.api_key", token.AccessToken), cfg.SetConfigField("providers.anthropic.oauth", token), @@ -174,6 +185,72 @@ func loginClaude() error { return nil } +func loginCopilot() error { + ctx := getLoginContext() + + cfg := config.Get() + if cfg.HasConfigField("providers.copilot.oauth") { + fmt.Println("You are already logged in to GitHub Copilot.") + return nil + } + + diskToken, hasDiskToken := copilot.RefreshTokenFromDisk() + var token *oauth.Token + + switch { + case hasDiskToken: + fmt.Println("Found existing GitHub Copilot token on disk. Using it to authenticate...") + + t, err := copilot.RefreshToken(ctx, diskToken) + if err != nil { + return fmt.Errorf("unable to refresh token from disk: %w", err) + } + token = t + default: + fmt.Println("Requesting device code from GitHub...") + dc, err := copilot.RequestDeviceCode(ctx) + if err != nil { + return err + } + + fmt.Println() + fmt.Println("Open the following URL and follow the instructions to authenticate with GitHub Copilot:") + fmt.Println() + fmt.Println(lipgloss.NewStyle().Hyperlink(dc.VerificationURI, "id=copilot").Render(dc.VerificationURI)) + fmt.Println() + fmt.Println("Code:", lipgloss.NewStyle().Bold(true).Render(dc.UserCode)) + fmt.Println() + fmt.Println("Waiting for authorization...") + + t, err := copilot.PollForToken(ctx, dc) + if err != nil { + return err + } + token = t + } + + if err := cmp.Or( + cfg.SetConfigField("providers.copilot.api_key", token.AccessToken), + cfg.SetConfigField("providers.copilot.oauth", token), + ); err != nil { + return err + } + + fmt.Println() + fmt.Println("You're now authenticated with GitHub Copilot!") + return nil +} + +func getLoginContext() context.Context { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill) + go func() { + <-ctx.Done() + cancel() + os.Exit(1) + }() + return ctx +} + func waitEnter() { _, _ = fmt.Scanln() } diff --git a/internal/config/config.go b/internal/config/config.go index 495c8b32394f37eac43fa4f497c7e69f2df63515..41ef5ab9f24aa975f4473d634ae5e49f4faf8c31 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "log/slog" + "maps" "net/http" "net/url" "os" @@ -18,8 +19,10 @@ import ( "github.com/charmbracelet/crush/internal/env" "github.com/charmbracelet/crush/internal/oauth" "github.com/charmbracelet/crush/internal/oauth/claude" + "github.com/charmbracelet/crush/internal/oauth/copilot" "github.com/charmbracelet/crush/internal/oauth/hyper" "github.com/invopop/jsonschema" + "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -136,6 +139,10 @@ func (pc *ProviderConfig) SetupClaudeCode() { pc.ExtraHeaders["anthropic-beta"] = value } +func (pc *ProviderConfig) SetupGitHubCopilot() { + maps.Copy(pc.ExtraHeaders, copilot.Headers()) +} + type MCPType string const ( @@ -452,6 +459,14 @@ func (c *Config) UpdatePreferredModel(modelType SelectedModelType, model Selecte return nil } +func (c *Config) HasConfigField(key string) bool { + data, err := os.ReadFile(c.dataConfigDir) + if err != nil { + return false + } + return gjson.Get(string(data), key).Exists() +} + func (c *Config) SetConfigField(key string, value any) error { // read the data data, err := os.ReadFile(c.dataConfigDir) @@ -485,23 +500,32 @@ func (c *Config) RefreshOAuthToken(ctx context.Context, providerID string) error } var newToken *oauth.Token - var err error + var refreshErr error switch providerID { case string(catwalk.InferenceProviderAnthropic): - newToken, err = claude.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken) + newToken, refreshErr = claude.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken) + case string(catwalk.InferenceProviderCopilot): + newToken, refreshErr = copilot.RefreshToken(ctx, providerConfig.OAuthToken.RefreshToken) case hyperp.Name: - newToken, err = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken) + newToken, refreshErr = hyper.ExchangeToken(ctx, providerConfig.OAuthToken.RefreshToken) default: return fmt.Errorf("OAuth refresh not supported for provider %s", providerID) } - if err != nil { - return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, err) + if refreshErr != nil { + return fmt.Errorf("failed to refresh OAuth token for provider %s: %w", providerID, refreshErr) } slog.Info("Successfully refreshed OAuth token", "provider", providerID) providerConfig.OAuthToken = newToken - providerConfig.APIKey = fmt.Sprintf("Bearer %s", newToken.AccessToken) - providerConfig.SetupClaudeCode() + + switch providerID { + case string(catwalk.InferenceProviderAnthropic): + providerConfig.APIKey = fmt.Sprintf("Bearer %s", newToken.AccessToken) + providerConfig.SetupClaudeCode() + case string(catwalk.InferenceProviderCopilot): + providerConfig.APIKey = newToken.AccessToken + providerConfig.SetupGitHubCopilot() + } c.Providers.Set(providerID, providerConfig) diff --git a/internal/config/copilot.go b/internal/config/copilot.go new file mode 100644 index 0000000000000000000000000000000000000000..f9ebc2f4fbddf602c67ae6fc81f5e6ca02d57b27 --- /dev/null +++ b/internal/config/copilot.go @@ -0,0 +1,43 @@ +package config + +import ( + "cmp" + "context" + "log/slog" + "testing" + + "github.com/charmbracelet/crush/internal/oauth" + "github.com/charmbracelet/crush/internal/oauth/copilot" +) + +func (c *Config) importCopilot() (*oauth.Token, bool) { + if testing.Testing() { + return nil, false + } + + if c.HasConfigField("providers.copilot.api_key") || c.HasConfigField("providers.copilot.oauth") { + return nil, false + } + + diskToken, hasDiskToken := copilot.RefreshTokenFromDisk() + if !hasDiskToken { + return nil, false + } + + slog.Info("Found existing GitHub Copilot token on disk. Authenticating...") + token, err := copilot.RefreshToken(context.TODO(), diskToken) + if err != nil { + slog.Error("Unable to import GitHub Copilot token", "error", err) + return nil, false + } + + if err := cmp.Or( + c.SetConfigField("providers.copilot.api_key", token.AccessToken), + c.SetConfigField("providers.copilot.oauth", token), + ); err != nil { + slog.Error("Unable to save GitHub Copilot token to disk", "error", err) + } + + slog.Info("GitHub Copilot successfully imported") + return token, true +} diff --git a/internal/config/load.go b/internal/config/load.go index 5a26104a4f0894652efd8d8172ea208b09424f67..059f9da2a39833b70b3635f230b292bb49d5edc6 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -132,6 +132,8 @@ func PushPopCrushEnv() func() { } func (c *Config) configureProviders(env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error { + c.importCopilot() + knownProviderNames := make(map[string]bool) restore := PushPopCrushEnv() defer restore() @@ -199,8 +201,18 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know Models: p.Models, } - if p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil { + switch { + case p.ID == catwalk.InferenceProviderAnthropic && config.OAuthToken != nil: prepared.SetupClaudeCode() + case p.ID == catwalk.InferenceProviderCopilot: + if config.OAuthToken != nil { + if token, ok := c.importCopilot(); ok { + prepared.OAuthToken = token + } + } + if config.OAuthToken != nil { + prepared.SetupGitHubCopilot() + } } switch p.ID { diff --git a/internal/oauth/copilot/disk.go b/internal/oauth/copilot/disk.go new file mode 100644 index 0000000000000000000000000000000000000000..bbb4957804767828c9a999062501983abf74216c --- /dev/null +++ b/internal/oauth/copilot/disk.go @@ -0,0 +1,36 @@ +package copilot + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" +) + +func RefreshTokenFromDisk() (string, bool) { + data, err := os.ReadFile(tokenFilePath()) + if err != nil { + return "", false + } + var content map[string]struct { + User string `json:"user"` + OAuthToken string `json:"oauth_token"` + GitHubAppID string `json:"githubAppId"` + } + if err := json.Unmarshal(data, &content); err != nil { + return "", false + } + if app, ok := content["github.com:Iv1.b507a08c87ecfe98"]; ok { + return app.OAuthToken, true + } + return "", false +} + +func tokenFilePath() string { + switch runtime.GOOS { + case "windows": + return filepath.Join(os.Getenv("LOCALAPPDATA"), "github-copilot/apps.json") + default: + return filepath.Join(os.Getenv("HOME"), ".config/github-copilot/apps.json") + } +} diff --git a/internal/oauth/copilot/http.go b/internal/oauth/copilot/http.go new file mode 100644 index 0000000000000000000000000000000000000000..482d9a4cd4819a586cbb7fc66c6a4f0b1d431ffb --- /dev/null +++ b/internal/oauth/copilot/http.go @@ -0,0 +1,17 @@ +package copilot + +const ( + userAgent = "GitHubCopilotChat/0.32.4" + editorVersion = "vscode/1.105.1" + editorPluginVersion = "copilot-chat/0.32.4" + integrationID = "vscode-chat" +) + +func Headers() map[string]string { + return map[string]string{ + "User-Agent": userAgent, + "Editor-Version": editorVersion, + "Editor-Plugin-Version": editorPluginVersion, + "Copilot-Integration-Id": integrationID, + } +} diff --git a/internal/oauth/copilot/oauth.go b/internal/oauth/copilot/oauth.go new file mode 100644 index 0000000000000000000000000000000000000000..2563357beccfbd8ef273c450a66308b080799e5e --- /dev/null +++ b/internal/oauth/copilot/oauth.go @@ -0,0 +1,199 @@ +package copilot + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/charmbracelet/crush/internal/oauth" +) + +const ( + clientID = "Iv1.b507a08c87ecfe98" + + deviceCodeURL = "https://github.com/login/device/code" + accessTokenURL = "https://github.com/login/oauth/access_token" + copilotTokenURL = "https://api.github.com/copilot_internal/v2/token" +) + +type DeviceCode struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +// RequestDeviceCode initiates the device code flow with GitHub. +func RequestDeviceCode(ctx context.Context) (*DeviceCode, error) { + data := url.Values{} + data.Set("client_id", clientID) + data.Set("scope", "read:user") + + req, err := http.NewRequestWithContext(ctx, "POST", deviceCodeURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", userAgent) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("device code request failed: %s - %s", resp.Status, string(body)) + } + + var dc DeviceCode + if err := json.NewDecoder(resp.Body).Decode(&dc); err != nil { + return nil, err + } + return &dc, nil +} + +// PollForToken polls GitHub for the access token after user authorization. +func PollForToken(ctx context.Context, dc *DeviceCode) (*oauth.Token, error) { + interval := max(dc.Interval, 5) + deadline := time.Now().Add(time.Duration(dc.ExpiresIn) * time.Second) + ticker := time.NewTicker(time.Duration(interval) * time.Second) + defer ticker.Stop() + + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + } + + token, err := tryGetToken(ctx, dc.DeviceCode) + if err == errPending { + continue + } + if err == errSlowDown { + interval += 5 + ticker.Reset(time.Duration(interval) * time.Second) + continue + } + if err != nil { + return nil, err + } + return token, nil + } + + return nil, fmt.Errorf("authorization timed out") +} + +var ( + errPending = fmt.Errorf("pending") + errSlowDown = fmt.Errorf("slow_down") +) + +func tryGetToken(ctx context.Context, deviceCode string) (*oauth.Token, error) { + data := url.Values{} + data.Set("client_id", clientID) + data.Set("device_code", deviceCode) + data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + + req, err := http.NewRequestWithContext(ctx, "POST", accessTokenURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", userAgent) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + AccessToken string `json:"access_token"` + Error string `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + switch result.Error { + case "": + if result.AccessToken == "" { + return nil, errPending + } + return getCopilotToken(ctx, result.AccessToken) + case "authorization_pending": + return nil, errPending + case "slow_down": + return nil, errSlowDown + default: + return nil, fmt.Errorf("authorization failed: %s", result.Error) + } +} + +func getCopilotToken(ctx context.Context, githubToken string) (*oauth.Token, error) { + req, err := http.NewRequestWithContext(ctx, "GET", copilotTokenURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", githubToken)) + for k, v := range Headers() { + req.Header.Set(k, v) + } + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("copilot not available for this account\n\n" + + "Please ensure you have GitHub Copilot enabled at:\n" + + "https://github.com/settings/copilot") + } + return nil, fmt.Errorf("copilot token request failed: %s - %s", resp.Status, string(body)) + } + + var result struct { + Token string `json:"token"` + ExpiresAt int64 `json:"expires_at"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, err + } + + copilotToken := &oauth.Token{ + AccessToken: result.Token, + RefreshToken: githubToken, + ExpiresAt: result.ExpiresAt, + } + copilotToken.SetExpiresIn() + + return copilotToken, nil +} + +// RefreshToken refreshes the Copilot token using the GitHub token. +func RefreshToken(ctx context.Context, githubToken string) (*oauth.Token, error) { + return getCopilotToken(ctx, githubToken) +} diff --git a/internal/oauth/token.go b/internal/oauth/token.go index 7c06ee35bd98b5bc7968766aecda46fd617a2521..381eb3e3110d01db1944fcd98659d87ac7055e2a 100644 --- a/internal/oauth/token.go +++ b/internal/oauth/token.go @@ -21,3 +21,8 @@ func (t *Token) SetExpiresAt() { func (t *Token) IsExpired() bool { return time.Now().Unix() >= (t.ExpiresAt - int64(t.ExpiresIn)/10) } + +// SetExpiresIn calculates and sets the ExpiresIn field based on the ExpiresAt field. +func (t *Token) SetExpiresIn() { + t.ExpiresIn = int(time.Until(time.Unix(t.ExpiresAt, 0)).Seconds()) +}