mirror of
https://github.com/jesseduffield/lazygit.git
synced 2025-05-10 20:05:50 +02:00
Merge ab1d9e242f
into ef1da6f704
This commit is contained in:
commit
3d84b74ab5
2 changed files with 500 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -43,3 +43,4 @@ __debug_bin
|
|||
demo/output/*
|
||||
|
||||
coverage.out
|
||||
cmd/copilot/main
|
||||
|
|
499
cmd/copilot/main.go
Normal file
499
cmd/copilot/main.go
Normal file
|
@ -0,0 +1,499 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sanity-io/litter"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
const (
|
||||
KEYRING_SERVICE = "lazygit"
|
||||
KEYRING_USER = "github-copilot"
|
||||
)
|
||||
|
||||
// ICopilotChat defines the interface for chat operations
|
||||
type ICopilotChat interface {
|
||||
Authenticate() error
|
||||
IsAuthenticated() bool
|
||||
Chat(request Request) (string, error)
|
||||
}
|
||||
|
||||
var _ ICopilotChat = &CopilotChat{}
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleUser Role = "user"
|
||||
RoleAssistant Role = "assistant"
|
||||
RoleSystem Role = "system"
|
||||
)
|
||||
|
||||
type Model string
|
||||
|
||||
const (
|
||||
Gpt4o Model = "gpt-4o-2024-05-13"
|
||||
Gpt4 Model = "gpt-4"
|
||||
Gpt3_5Turbo Model = "gpt-3.5-turbo"
|
||||
O1Preview Model = "o1-preview-2024-09-12"
|
||||
O1Mini Model = "o1-mini-2024-09-12"
|
||||
Claude3_5Sonnet Model = "claude-3.5-sonnet"
|
||||
)
|
||||
|
||||
const (
|
||||
COPILOT_CHAT_COMPLETION_URL = "https://api.githubcopilot.com/chat/completions"
|
||||
COPILOT_CHAT_AUTH_URL = "https://api.github.com/copilot_internal/v2/token"
|
||||
EDITOR_VERSION = "Lazygit/0.44.0"
|
||||
COPILOT_INTEGRATION_ID = "vscode-chat"
|
||||
)
|
||||
const (
|
||||
CACHE_FILE_NAME = ".copilot_auth.json"
|
||||
)
|
||||
const (
|
||||
CHECK_INTERVAL = 30 * time.Second
|
||||
MAX_AUTH_TIME = 5 * time.Minute
|
||||
)
|
||||
const (
|
||||
GITHUB_CLIENT_ID = "Iv1.b507a08c87ecfe98"
|
||||
)
|
||||
|
||||
type ChatMessage struct {
|
||||
Role Role `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
Intent bool `json:"intent"`
|
||||
N int `json:"n"`
|
||||
Stream bool `json:"stream"`
|
||||
Temperature float32 `json:"temperature"`
|
||||
Model Model `json:"model"`
|
||||
Messages []ChatMessage `json:"messages"`
|
||||
}
|
||||
|
||||
type ContentFilterResult struct {
|
||||
Filtered bool `json:"filtered"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
type ContentFilterResults struct {
|
||||
Hate ContentFilterResult `json:"hate"`
|
||||
SelfHarm ContentFilterResult `json:"self_harm"`
|
||||
Sexual ContentFilterResult `json:"sexual"`
|
||||
Violence ContentFilterResult `json:"violence"`
|
||||
}
|
||||
|
||||
type ChatResponse struct {
|
||||
Choices []ResponseChoice `json:"choices"`
|
||||
Created int64 `json:"created"`
|
||||
ID string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
SystemFingerprint string `json:"system_fingerprint"`
|
||||
PromptFilterResults []PromptFilterResult `json:"prompt_filter_results"`
|
||||
Usage Usage `json:"usage"`
|
||||
}
|
||||
|
||||
type ResponseChoice struct {
|
||||
ContentFilterResults ContentFilterResults `json:"content_filter_results"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
Index int `json:"index"`
|
||||
Message ChatMessage `json:"message"`
|
||||
}
|
||||
|
||||
type PromptFilterResult struct {
|
||||
ContentFilterResults ContentFilterResults `json:"content_filter_results"`
|
||||
PromptIndex int `json:"prompt_index"`
|
||||
}
|
||||
|
||||
type Usage struct {
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
type ApiTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
type ApiToken struct {
|
||||
ApiKey string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
type CacheData struct {
|
||||
OAuthToken string `json:"oauth_token"`
|
||||
ApiKey string `json:"api_key"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
type DeviceCodeResponse 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"`
|
||||
}
|
||||
|
||||
type DeviceTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scope string `json:"scope"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type CopilotChat struct {
|
||||
OAuthToken string
|
||||
ApiToken *ApiToken
|
||||
Client *http.Client
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// TODO: import a library to count the number of tokens in a string
|
||||
func (m Model) MaxTokenCount() int {
|
||||
switch m {
|
||||
case Gpt4o:
|
||||
return 64000
|
||||
case Gpt4:
|
||||
return 32768
|
||||
case Gpt3_5Turbo:
|
||||
return 12288
|
||||
case O1Mini:
|
||||
return 20000
|
||||
case O1Preview:
|
||||
return 20000
|
||||
case Claude3_5Sonnet:
|
||||
return 200000
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func NewCopilotChat(client *http.Client) *CopilotChat {
|
||||
if client == nil {
|
||||
client = &http.Client{}
|
||||
}
|
||||
|
||||
chat := &CopilotChat{
|
||||
Client: client,
|
||||
}
|
||||
|
||||
if err := chat.loadFromKeyring(); err != nil {
|
||||
log.Printf("Warning: Failed to load from keyring: %v", err)
|
||||
}
|
||||
|
||||
return chat
|
||||
}
|
||||
|
||||
func (self *CopilotChat) saveToKeyring() error {
|
||||
data := CacheData{
|
||||
OAuthToken: self.OAuthToken,
|
||||
ApiKey: self.ApiToken.ApiKey,
|
||||
ExpiresAt: self.ApiToken.ExpiresAt,
|
||||
}
|
||||
|
||||
fileData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal keyring data: %v", err)
|
||||
}
|
||||
|
||||
if err := keyring.Set(KEYRING_SERVICE, KEYRING_USER, string(fileData)); err != nil {
|
||||
return fmt.Errorf("failed to save to keyring: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *CopilotChat) loadFromKeyring() error {
|
||||
jsonData, err := keyring.Get(KEYRING_SERVICE, KEYRING_USER)
|
||||
if err != nil {
|
||||
if err == keyring.ErrNotFound {
|
||||
return nil // No credentials stored yet
|
||||
}
|
||||
return fmt.Errorf("failed to get credentials from keyring: %v", err)
|
||||
}
|
||||
|
||||
var data CacheData
|
||||
if err := json.Unmarshal([]byte(jsonData), &data); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal Keyring data: %v", err)
|
||||
}
|
||||
|
||||
// Always load OAuth token if it exists
|
||||
if data.OAuthToken != "" {
|
||||
self.OAuthToken = data.OAuthToken
|
||||
}
|
||||
|
||||
// If we have a valid API key, use it
|
||||
if data.ApiKey != "" && data.ExpiresAt.After(time.Now()) {
|
||||
self.ApiToken = &ApiToken{
|
||||
ApiKey: data.ApiKey,
|
||||
ExpiresAt: data.ExpiresAt,
|
||||
}
|
||||
fmt.Println("Loaded valid API key from keyring")
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we have OAuth token but no valid API key, fetch a new one
|
||||
if self.OAuthToken != "" {
|
||||
fmt.Println("OAuth token found, fetching new API key...")
|
||||
if err := self.fetchNewApiToken(); err != nil {
|
||||
return fmt.Errorf("failed to fetch new API token: %v", err)
|
||||
}
|
||||
fmt.Println("Successfully fetched new API key")
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *CopilotChat) fetchNewApiToken() error {
|
||||
apiTokenReq, err := http.NewRequest(http.MethodGet, COPILOT_CHAT_AUTH_URL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create API token request: %v", err)
|
||||
}
|
||||
|
||||
apiTokenReq.Header.Set("Authorization", fmt.Sprintf("token %s", self.OAuthToken))
|
||||
setHeaders(apiTokenReq, "")
|
||||
|
||||
apiTokenResp, err := self.Client.Do(apiTokenReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API token: %v", err)
|
||||
}
|
||||
defer apiTokenResp.Body.Close()
|
||||
|
||||
var apiTokenResponse ApiTokenResponse
|
||||
if err := json.NewDecoder(apiTokenResp.Body).Decode(&apiTokenResponse); err != nil {
|
||||
return fmt.Errorf("failed to decode API token response: %v", err)
|
||||
}
|
||||
|
||||
self.ApiToken = &ApiToken{
|
||||
ApiKey: apiTokenResponse.Token,
|
||||
ExpiresAt: time.Unix(apiTokenResponse.ExpiresAt, 0),
|
||||
}
|
||||
|
||||
return self.saveToKeyring()
|
||||
}
|
||||
|
||||
func setHeaders(req *http.Request, contentType string) {
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
req.Header.Set("Editor-Version", EDITOR_VERSION)
|
||||
req.Header.Set("Copilot-Integration-Id", COPILOT_INTEGRATION_ID)
|
||||
}
|
||||
|
||||
func (self *CopilotChat) Authenticate() error {
|
||||
// Try to load from keyring first
|
||||
if err := self.loadFromKeyring(); err == nil && self.IsAuthenticated() {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.mu.Lock()
|
||||
defer self.mu.Unlock()
|
||||
|
||||
// Step 1: Request device and user codes
|
||||
deviceCodeReq, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://github.com/login/device/code",
|
||||
strings.NewReader(fmt.Sprintf(
|
||||
"client_id=%s&scope=copilot",
|
||||
GITHUB_CLIENT_ID,
|
||||
)),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create device code request: %v", err)
|
||||
}
|
||||
deviceCodeReq.Header.Set("Accept", "application/json")
|
||||
deviceCodeReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := self.Client.Do(deviceCodeReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get device code: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var deviceCode DeviceCodeResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&deviceCode); err != nil {
|
||||
return fmt.Errorf("failed to decode device code response: %v", err)
|
||||
}
|
||||
|
||||
// Step 2: Display user code and verification URL
|
||||
fmt.Printf("\nPlease visit: %s\n", deviceCode.VerificationUri)
|
||||
fmt.Printf("And enter code: %s\n\n", deviceCode.UserCode)
|
||||
|
||||
// Step 3: Poll for the access token with timeout
|
||||
startTime := time.Now()
|
||||
attempts := 0
|
||||
|
||||
// FIXME: There is probably a better way to do this
|
||||
for {
|
||||
if time.Since(startTime) >= MAX_AUTH_TIME {
|
||||
return fmt.Errorf("authentication timed out after 5 minutes")
|
||||
}
|
||||
|
||||
time.Sleep(CHECK_INTERVAL)
|
||||
attempts++
|
||||
fmt.Printf("Checking for authentication... attempt %d\n", attempts)
|
||||
|
||||
tokenReq, err := http.NewRequest(http.MethodPost, "https://github.com/login/oauth/access_token",
|
||||
strings.NewReader(fmt.Sprintf(
|
||||
"client_id=%s&device_code=%s&grant_type=urn:ietf:params:oauth:grant-type:device_code", GITHUB_CLIENT_ID,
|
||||
deviceCode.DeviceCode)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create token request: %v", err)
|
||||
}
|
||||
tokenReq.Header.Set("Accept", "application/json")
|
||||
tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
tokenResp, err := self.Client.Do(tokenReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get access token: %v", err)
|
||||
}
|
||||
|
||||
var tokenResponse DeviceTokenResponse
|
||||
if err := json.NewDecoder(tokenResp.Body).Decode(&tokenResponse); err != nil {
|
||||
tokenResp.Body.Close()
|
||||
return fmt.Errorf("failed to decode token response: %v", err)
|
||||
}
|
||||
tokenResp.Body.Close()
|
||||
|
||||
if tokenResponse.Error == "authorization_pending" {
|
||||
fmt.Println("Login not detected. Please visit the URL and enter the code.")
|
||||
continue
|
||||
}
|
||||
if tokenResponse.Error != "" {
|
||||
if time.Since(startTime) >= MAX_AUTH_TIME {
|
||||
return fmt.Errorf("authentication timed out after 5 minutes")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Successfully got the access token
|
||||
self.OAuthToken = tokenResponse.AccessToken
|
||||
|
||||
// Now get the Copilot API token using fetchNewApiToken
|
||||
if err := self.fetchNewApiToken(); err != nil {
|
||||
return fmt.Errorf("failed to fetch API token: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("Successfully authenticated!")
|
||||
// Save the new credentials to cache
|
||||
if err := self.saveToKeyring(); err != nil {
|
||||
log.Printf("Warning: Failed to save credentials to keyring: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (self *CopilotChat) IsAuthenticated() bool {
|
||||
if self.ApiToken == nil {
|
||||
return false
|
||||
}
|
||||
return self.ApiToken.ExpiresAt.After(time.Now())
|
||||
}
|
||||
|
||||
func (self *CopilotChat) Chat(request Request) (string, error) {
|
||||
fmt.Println("Chatting with Copilot...")
|
||||
|
||||
if !self.IsAuthenticated() {
|
||||
fmt.Println("Not authenticated with Copilot. Authenticating...")
|
||||
if err := self.Authenticate(); err != nil {
|
||||
return "", fmt.Errorf("authentication failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
apiKey := self.ApiToken.ApiKey
|
||||
fmt.Println("Authenticated with Copilot!")
|
||||
fmt.Println("API Key: ", apiKey)
|
||||
|
||||
litter.Dump(self)
|
||||
|
||||
requestBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Println("Mounting request body: ", string(requestBody))
|
||||
|
||||
self.mu.Lock()
|
||||
defer self.mu.Unlock()
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, COPILOT_CHAT_COMPLETION_URL, strings.NewReader(string(requestBody)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey))
|
||||
setHeaders(req, "")
|
||||
|
||||
response, err := self.Client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return "", fmt.Errorf("failed to get completion: %s", string(body))
|
||||
}
|
||||
|
||||
var chatResponse ChatResponse
|
||||
decoder := json.NewDecoder(response.Body)
|
||||
if err := decoder.Decode(&chatResponse); err != nil {
|
||||
return "", fmt.Errorf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if len(chatResponse.Choices) == 0 {
|
||||
return "", fmt.Errorf("no choices in response")
|
||||
}
|
||||
|
||||
return chatResponse.Choices[0].Message.Content, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
client := &http.Client{}
|
||||
fmt.Println("Starting...")
|
||||
copilotChat := NewCopilotChat(client)
|
||||
|
||||
fmt.Println("Chatting...")
|
||||
|
||||
err := copilotChat.Authenticate()
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "timed out") {
|
||||
log.Fatalf("Authentication process timed out. Please try again later.")
|
||||
}
|
||||
log.Fatalf("Error during authentication: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("Authenticated!")
|
||||
|
||||
messages := []ChatMessage{
|
||||
{
|
||||
Role: RoleUser,
|
||||
Content: "Describe what is Lazygit in one sentence",
|
||||
},
|
||||
}
|
||||
|
||||
request := Request{
|
||||
Intent: true,
|
||||
N: 1,
|
||||
Stream: false,
|
||||
Temperature: 0.1,
|
||||
Model: Gpt4o,
|
||||
Messages: messages,
|
||||
}
|
||||
|
||||
response, err := copilotChat.Chat(request)
|
||||
if err != nil {
|
||||
log.Fatalf("Error during chat: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println(response)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue