From 8bb86b5ca79975287db97e654d8853ce882ad1d5 Mon Sep 17 00:00:00 2001 From: Tomas Vesely <448809+wham@users.noreply.github.com> Date: Thu, 18 Dec 2025 22:45:26 -0800 Subject: [PATCH 1/7] Add GitHub login command with OAuth device flow and update documentation --- README.md | 60 +++++-- go.mod | 1 + go.sum | 3 + main.go | 516 +++++++++++++++++++++++++++++++++++++++++++++++++++++- main.md | 115 +++++++++++- 5 files changed, 674 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index eb9f594..cb9e1ea 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,6 @@ GitHub Brain also includes a web-based UI for ultra-fast search: And a Raycast extension: - - ![](./docs/raycast.png) GitHub Brain is [programmed in Markdown](https://github.blog/ai-and-ml/generative-ai/spec-driven-development-using-markdown-as-a-programming-language-when-building-with-ai/). @@ -46,8 +44,9 @@ github-brain [] **Workflow:** -1. Use `pull` to populate the local database -2. Use `mcp` to start the MCP server +1. Use `login` to authenticate with GitHub (or set `GITHUB_TOKEN` manually) +2. Use `pull` to populate the local database +3. Use `mcp` to start the MCP server Re-run `pull` anytime to update the database with new GitHub data. @@ -62,6 +61,29 @@ You can change the home directory with the `-m` argument available for all comma +### `login` + +Authenticate with GitHub using OAuth device flow. Opens your browser to authorize GitHub Brain and stores the token in the `.env` file. + +Example: + +```sh +github-brain login +``` + +| Argument | Description | +| :------- | :------------------------------------------------------------------------------------------- | +| `-m` | Home directory. Default: `~/.github-brain` (or checkout directory if run via `scripts/run`). | + +The login flow: + +1. Displays a one-time code +2. Opens `github.com/login/device` in your browser +3. You enter the code and authorize the app +4. Token is saved to `~/.github-brain/.env` + +After login, you can run `pull` without the `-t` argument. + ### `pull` Populate the local database with GitHub data. @@ -74,14 +96,14 @@ github-brain pull -o my-org The first run may take a while. Subsequent runs are faster, fetching only new data. -| Argument | Variable | Description | -| :------- | :---------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `-t` | `GITHUB_TOKEN` | Your GitHub [personal token](https://github.com/settings/personal-access-tokens) to access the API. **Required.** | -| `-o` | `ORGANIZATION` | The GitHub organization to pull data from. **Required.** | -| `-m` | | Home directory. Default: `~/.github-brain` (or checkout directory if run via `scripts/run`). | -| `-i` | | Pull only selected entities: `repositories`, `discussions`, `issues`, `pull-requests` (comma-separated). | -| `-f` | | Remove all data before pulling. With `-i`, removes only specified items. | -| `-e` | `EXCLUDED_REPOSITORIES` | Repositories to exclude (comma-separated). Useful for large repos not relevant to your analysis. | +| Argument | Variable | Description | +| :------- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------- | +| `-t` | `GITHUB_TOKEN` | Your GitHub token. Use `login` command or create a [personal token](https://github.com/settings/personal-access-tokens). **Required.** | +| `-o` | `ORGANIZATION` | The GitHub organization to pull data from. **Required.** | +| `-m` | | Home directory. Default: `~/.github-brain` (or checkout directory if run via `scripts/run`). | +| `-i` | | Pull only selected entities: `repositories`, `discussions`, `issues`, `pull-requests` (comma-separated). | +| `-f` | | Remove all data before pulling. With `-i`, removes only specified items. | +| `-e` | `EXCLUDED_REPOSITORIES` | Repositories to exclude (comma-separated). Useful for large repos not relevant to your analysis. |
Personal access token scopes @@ -104,9 +126,9 @@ Example: github-brain mcp -o my-org ``` -| Argument | Variable | Description | -| :------- | :------------- | :-------------------------------------------------------------------------------------------------------------------- | -| `-o` | `ORGANIZATION` | GitHub organization. **Required.** | +| Argument | Variable | Description | +| :------- | :------------- | :------------------------------------------------------------------------------------------- | +| `-o` | `ORGANIZATION` | GitHub organization. **Required.** | | `-m` | | Home directory. Default: `~/.github-brain` (or checkout directory if run via `scripts/run`). | ### `ui` @@ -117,11 +139,11 @@ Start the web UI for quick searches (alternative to MCP). github-brain ui -o my-org ``` -| Argument | Variable | Description | -| :------- | :------------- | :-------------------------------------------------------------------------------------------------------------------- | -| `-o` | `ORGANIZATION` | GitHub organization. **Required.** | +| Argument | Variable | Description | +| :------- | :------------- | :------------------------------------------------------------------------------------------- | +| `-o` | `ORGANIZATION` | GitHub organization. **Required.** | | `-m` | | Home directory. Default: `~/.github-brain` (or checkout directory if run via `scripts/run`). | -| `-p` | `UI_PORT` | Port. Default: `8080`. | +| `-p` | `UI_PORT` | Port. Default: `8080`. | ### Additional Arguments diff --git a/go.mod b/go.mod index 666006f..f9e206d 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect diff --git a/go.sum b/go.sum index 4babb39..cdccd34 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -56,6 +58,7 @@ golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/main.go b/main.go index ee695fa..abba9e2 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "log/slog" "math/rand" "net/http" + "net/url" "os" "strconv" "strings" @@ -25,6 +26,7 @@ import ( "github.com/joho/godotenv" _ "github.com/mattn/go-sqlite3" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/pkg/browser" "github.com/shurcooL/githubv4" "golang.org/x/oauth2" ) @@ -39,6 +41,11 @@ var htmxJS []byte // Database schema version GUID - change this on any schema modification const SCHEMA_GUID = "b8f3c2a1-9e7d-4f6b-8c5a-3d2e1f0a9b8c" +// GitHub App Client ID (public, safe to embed) +// Permissions are configured in the GitHub App settings, not via scopes +// Register at: https://github.com/settings/apps +const GitHubClientID = "Iv23ctenJiPpElznxKwY" + // Version information (set via ldflags at build time) var ( Version = "dev" @@ -4350,17 +4357,34 @@ func main() { if len(os.Args) < 2 || os.Args[1] == "-h" || os.Args[1] == "--help" { fmt.Printf("Usage: %s []\n\n", os.Args[0]) fmt.Println("Commands:") + fmt.Println(" login Authenticate with GitHub") fmt.Println(" pull Pull GitHub repositories and discussions") fmt.Println(" mcp Start the MCP server") fmt.Println(" ui Start the web UI server") fmt.Println("\nFor command-specific help, use:") - fmt.Println(" pull -h\n mcp -h\n ui -h") + fmt.Println(" login -h\n pull -h\n mcp -h\n ui -h") os.Exit(0) } cmd := os.Args[1] switch cmd { + case "login": + args := os.Args[2:] + for i := 0; i < len(args); i++ { + if args[i] == "-h" || args[i] == "--help" { + fmt.Println("Usage: login [-m ]") + fmt.Println("Options:") + fmt.Println(" -m Home directory (default: ~/.github-brain)") + os.Exit(0) + } + } + + if err := RunLogin(homeDir); err != nil { + fmt.Fprintf(os.Stderr, "Login failed: %v\n", err) + os.Exit(1) + } + case "pull": // Load configuration from CLI args and environment variables first args := os.Args[2:] @@ -5351,4 +5375,494 @@ func formatLogLine(entry logEntry, errorStyle lipgloss.Style) string { return " " + timestamp + " " + errorStyle.Render(message) } return " " + timestamp + " " + message +} + +// ============================================================================ +// Login Command Implementation (OAuth Device Flow) +// ============================================================================ + +// DeviceCodeResponse represents the response from GitHub's device code endpoint +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"` +} + +// AccessTokenResponse represents the response from GitHub's access token endpoint +type AccessTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` +} + +// loginModel is the Bubble Tea model for the login UI +type loginModel struct { + spinner spinner.Model + userCode string + verificationURI string + status string // "waiting", "success", "error" + errorMsg string + username string + homeDir string + width int + height int + borderColors []lipgloss.AdaptiveColor + colorIndex int + done bool +} + +// Login message types +type ( + loginTickMsg time.Time + loginSuccessMsg struct{ username string } + loginErrorMsg struct{ err error } + loginDeviceCodeMsg struct { + userCode string + verificationURI string + } +) + +func newLoginModel(homeDir string) loginModel { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) + + gradientColors := []lipgloss.AdaptiveColor{ + {Light: "#874BFD", Dark: "#7D56F4"}, + {Light: "#7D56F4", Dark: "#6B4FD8"}, + {Light: "#5B4FE0", Dark: "#5948C8"}, + {Light: "#4F7BD8", Dark: "#4B6FD0"}, + {Light: "#48A8D8", Dark: "#45A0D0"}, + {Light: "#48D8D0", Dark: "#45D0C8"}, + } + + return loginModel{ + spinner: s, + status: "waiting", + homeDir: homeDir, + width: 80, + height: 24, + borderColors: gradientColors, + colorIndex: 0, + } +} + +func (m loginModel) Init() tea.Cmd { + return tea.Batch( + m.spinner.Tick, + loginTickCmd(), + ) +} + +func loginTickCmd() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return loginTickMsg(t) + }) +} + +func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + m.done = true + return m, tea.Quit + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case loginTickMsg: + m.colorIndex = (m.colorIndex + 1) % len(m.borderColors) + return m, loginTickCmd() + + case loginDeviceCodeMsg: + m.userCode = msg.userCode + m.verificationURI = msg.verificationURI + return m, nil + + case loginSuccessMsg: + m.status = "success" + m.username = msg.username + m.done = true + return m, nil + + case loginErrorMsg: + m.status = "error" + m.errorMsg = msg.err.Error() + m.done = true + return m, nil + + case spinner.TickMsg: + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + + return m, nil +} + +func (m loginModel) View() string { + borderColor := m.borderColors[m.colorIndex] + + var content string + + switch m.status { + case "waiting": + content = m.renderWaitingView() + case "success": + content = m.renderSuccessView() + case "error": + content = m.renderErrorView() + } + + // Calculate box width + maxContentWidth := m.width - 4 + if maxContentWidth < 64 { + maxContentWidth = 64 + } + + // Create border style + borderStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Padding(0, 1). + Width(maxContentWidth) + + // Title + title := " GitHub ๐Ÿง  Login " + titleStyle := lipgloss.NewStyle().Bold(true) + + box := borderStyle.Render(content) + + // Replace top border with title + lines := strings.Split(box, "\n") + if len(lines) > 0 { + topBorder := lines[0] + titlePos := 2 + if titlePos+len(title) < len(topBorder) { + runes := []rune(topBorder) + titleRunes := []rune(titleStyle.Render(title)) + copy(runes[titlePos:], titleRunes) + lines[0] = string(runes) + } + box = strings.Join(lines, "\n") + } + + return box +} + +func (m loginModel) renderWaitingView() string { + var b strings.Builder + + b.WriteString("\n") + b.WriteString(" ๐Ÿ” GitHub Authentication\n") + b.WriteString("\n") + + if m.userCode == "" { + b.WriteString(" " + m.spinner.View() + " Requesting device code...\n") + } else { + b.WriteString(" 1. Opening browser to: github.com/login/device\n") + b.WriteString("\n") + b.WriteString(" 2. Enter this code:\n") + b.WriteString("\n") + + // Code box + codeStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("12")). + Padding(0, 2). + Bold(true) + + b.WriteString(" " + codeStyle.Render(" "+m.userCode+" ") + "\n") + b.WriteString("\n") + b.WriteString(" " + m.spinner.View() + " Waiting for authorization...\n") + } + + b.WriteString("\n") + b.WriteString(" Press Ctrl+C to cancel\n") + b.WriteString("\n") + + return b.String() +} + +func (m loginModel) renderSuccessView() string { + var b strings.Builder + + successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) + + b.WriteString("\n") + b.WriteString(" " + successStyle.Render("โœ… Successfully authenticated!") + "\n") + b.WriteString("\n") + b.WriteString(fmt.Sprintf(" Logged in as: @%s\n", m.username)) + b.WriteString(fmt.Sprintf(" Token saved to: %s/.env\n", m.homeDir)) + b.WriteString("\n") + b.WriteString(" You can now run:\n") + b.WriteString(" github-brain pull -o \n") + b.WriteString("\n") + + return b.String() +} + +func (m loginModel) renderErrorView() string { + var b strings.Builder + + errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + + b.WriteString("\n") + b.WriteString(" " + errorStyle.Render("โŒ Authentication failed") + "\n") + b.WriteString("\n") + b.WriteString(fmt.Sprintf(" Error: %s\n", m.errorMsg)) + b.WriteString("\n") + b.WriteString(" Please try again.\n") + b.WriteString("\n") + + return b.String() +} + +// RunLogin runs the OAuth device flow login +func RunLogin(homeDir string) error { + // Ensure home directory exists + if err := os.MkdirAll(homeDir, 0755); err != nil { + return fmt.Errorf("failed to create home directory: %w", err) + } + + // Create the Bubble Tea model + m := newLoginModel(homeDir) + p := tea.NewProgram(m, tea.WithAltScreen()) + + // Run the device flow in a goroutine + go runDeviceFlow(p, homeDir) + + // Run the Bubble Tea program + finalModel, err := p.Run() + if err != nil { + return fmt.Errorf("UI error: %w", err) + } + + // Check if login was successful + if lm, ok := finalModel.(loginModel); ok { + if lm.status == "error" { + return fmt.Errorf("%s", lm.errorMsg) + } + if lm.status != "success" { + return fmt.Errorf("login cancelled") + } + } + + return nil +} + +func runDeviceFlow(p *tea.Program, homeDir string) { + // Step 1: Request device code + deviceCode, err := requestDeviceCode() + if err != nil { + p.Send(loginErrorMsg{err: err}) + return + } + + // Send device code info to UI + p.Send(loginDeviceCodeMsg{ + userCode: deviceCode.UserCode, + verificationURI: deviceCode.VerificationURI, + }) + + // Open browser + _ = browser.OpenURL(deviceCode.VerificationURI) + + // Step 2: Poll for access token + token, err := pollForAccessToken(deviceCode) + if err != nil { + p.Send(loginErrorMsg{err: err}) + return + } + + // Step 3: Verify token and get username + username, err := verifyTokenAndGetUsername(token) + if err != nil { + p.Send(loginErrorMsg{err: fmt.Errorf("token verification failed: %w", err)}) + return + } + + // Step 4: Save token to .env file + if err := saveTokenToEnv(homeDir, token); err != nil { + p.Send(loginErrorMsg{err: fmt.Errorf("failed to save token: %w", err)}) + return + } + + // Success! + p.Send(loginSuccessMsg{username: username}) + + // Give user time to see success message before quitting + time.Sleep(2 * time.Second) + p.Quit() +} + +func requestDeviceCode() (*DeviceCodeResponse, error) { + data := url.Values{} + data.Set("client_id", GitHubClientID) + // Note: GitHub Apps don't use scopes - permissions are defined in app settings + + req, err := http.NewRequest("POST", "https://github.com/login/device/code", strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + 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 + } + + var deviceCode DeviceCodeResponse + if err := json.Unmarshal(body, &deviceCode); err != nil { + return nil, fmt.Errorf("failed to parse device code response: %w", err) + } + + if deviceCode.DeviceCode == "" { + return nil, fmt.Errorf("no device code in response: %s", string(body)) + } + + return &deviceCode, nil +} + +func pollForAccessToken(deviceCode *DeviceCodeResponse) (string, error) { + interval := time.Duration(deviceCode.Interval) * time.Second + if interval < 5*time.Second { + interval = 5 * time.Second + } + + expiresAt := time.Now().Add(time.Duration(deviceCode.ExpiresIn) * time.Second) + + for time.Now().Before(expiresAt) { + time.Sleep(interval) + + data := url.Values{} + data.Set("client_id", GitHubClientID) + data.Set("device_code", deviceCode.DeviceCode) + data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + + req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", strings.NewReader(data.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + continue // Retry on network errors + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + continue + } + + var tokenResp AccessTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + continue + } + + switch tokenResp.Error { + case "": + // Success! + if tokenResp.AccessToken != "" { + return tokenResp.AccessToken, nil + } + case "authorization_pending": + // Keep polling + continue + case "slow_down": + // Increase interval by 5 seconds + interval += 5 * time.Second + continue + case "expired_token": + return "", fmt.Errorf("device code expired, please try again") + case "access_denied": + return "", fmt.Errorf("access denied by user") + default: + return "", fmt.Errorf("%s: %s", tokenResp.Error, tokenResp.ErrorDesc) + } + } + + return "", fmt.Errorf("timeout waiting for authorization") +} + +func verifyTokenAndGetUsername(token string) (string, error) { + src := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + httpClient := oauth2.NewClient(context.Background(), src) + client := githubv4.NewClient(httpClient) + + var query struct { + Viewer struct { + Login string + } + } + + if err := client.Query(context.Background(), &query, nil); err != nil { + return "", err + } + + return query.Viewer.Login, nil +} + +func saveTokenToEnv(homeDir string, token string) error { + envPath := homeDir + "/.env" + + // Read existing .env content + existingContent, err := os.ReadFile(envPath) + if err != nil && !os.IsNotExist(err) { + return err + } + + var newContent string + tokenLine := fmt.Sprintf("GITHUB_TOKEN=%s", token) + + if len(existingContent) == 0 { + // File doesn't exist or is empty + newContent = tokenLine + "\n" + } else { + // Check if GITHUB_TOKEN already exists + lines := strings.Split(string(existingContent), "\n") + found := false + for i, line := range lines { + if strings.HasPrefix(line, "GITHUB_TOKEN=") { + lines[i] = tokenLine + found = true + break + } + } + if !found { + // Append token line + if !strings.HasSuffix(string(existingContent), "\n") { + lines = append(lines, "") + } + lines = append(lines, tokenLine) + } + newContent = strings.Join(lines, "\n") + // Ensure file ends with newline + if !strings.HasSuffix(newContent, "\n") { + newContent += "\n" + } + } + + return os.WriteFile(envPath, []byte(newContent), 0600) } \ No newline at end of file diff --git a/main.md b/main.md index 1730f28..150e7b3 100644 --- a/main.md +++ b/main.md @@ -6,7 +6,7 @@ Keep the app in one file `main.go`. ## CLI -Implement CLI from [Usage](README.md#usage) section. Follow exact argument/variable names. Support only `pull`, `mcp`, and `ui` commands. +Implement CLI from [Usage](README.md#usage) section. Follow exact argument/variable names. Support only `login`, `pull`, `mcp`, and `ui` commands. If the GitHub Brain home directory doesn't exist, create it. @@ -47,6 +47,119 @@ Use **Bubble Tea** framework (https://github.com/charmbracelet/bubbletea) for te - Gradient animated borders (purple โ†’ blue โ†’ cyan) updated every second - Right-aligned comma-formatted counters +## login + +Interactive GitHub authentication using OAuth Device Flow. Stores the resulting token in the `.env` file. + +### GitHub App + +The app uses a registered GitHub App for authentication: + +- **Client ID**: Embedded in the binary (public, safe to commit) +- **Client Secret**: Not required for device flow (public clients) +- **Permissions**: Configured in GitHub App settings (not OAuth scopes) + - Repository: Read access to code, discussions, issues, metadata, pull requests + - Organization: Read access to members + +**Why GitHub App instead of OAuth App?** + +- GitHub Apps can be installed per-organization, bypassing org-wide OAuth restrictions +- Higher rate limits (15,000 requests/hour vs 5,000) +- Fine-grained permissions instead of broad OAuth scopes + +### Device Flow + +1. Request device code from GitHub: + + ``` + POST https://github.com/login/device/code + client_id= + ``` + + Note: No `scope` parameter - GitHub Apps use permissions defined in app settings. + +2. GitHub returns: + + - `device_code`: Secret code for polling + - `user_code`: Code user enters (e.g., `ABCD-1234`) + - `verification_uri`: `https://github.com/login/device` + - `expires_in`: Code expiration (usually 900 seconds) + - `interval`: Polling interval (usually 5 seconds) + +3. Display the code and open browser: + + ``` + โ•ญโ”€ GitHub ๐Ÿง  Login โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ + โ”‚ โ”‚ + โ”‚ ๐Ÿ” GitHub Authentication โ”‚ + โ”‚ โ”‚ + โ”‚ 1. Opening browser to: github.com/login/device โ”‚ + โ”‚ โ”‚ + โ”‚ 2. Enter this code: โ”‚ + โ”‚ โ”‚ + โ”‚ โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ + โ”‚ โ”‚ ABCD-1234 โ”‚ โ”‚ + โ”‚ โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ โ”‚ + โ”‚ โ”‚ + โ”‚ โ ‹ Waiting for authorization... โ”‚ + โ”‚ โ”‚ + โ”‚ Press Ctrl+C to cancel โ”‚ + โ”‚ โ”‚ + โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + ``` + +4. Poll for access token: + + ``` + POST https://github.com/login/oauth/access_token + client_id=&device_code=&grant_type=urn:ietf:params:oauth:grant-type:device_code + ``` + +5. Handle poll responses: + + - `authorization_pending`: Keep polling + - `slow_down`: Increase interval by 5 seconds + - `expired_token`: Code expired, start over + - `access_denied`: User denied, show error + - Success: Returns `access_token` (format: `ghu_xxxx`) + +6. On success, save token to `.env` file: + ``` + โ•ญโ”€ GitHub ๐Ÿง  Login โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ + โ”‚ โ”‚ + โ”‚ โœ… Successfully authenticated! โ”‚ + โ”‚ โ”‚ + โ”‚ Logged in as: @wham โ”‚ + โ”‚ Token saved to: ~/.github-brain/.env โ”‚ + โ”‚ โ”‚ + โ”‚ You can now run: โ”‚ + โ”‚ github-brain pull -o โ”‚ + โ”‚ โ”‚ + โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + ``` + +### Token Storage + +Save the OAuth token to `{HomeDir}/.env` file: + +- If `.env` exists and has `GITHUB_TOKEN`, replace it +- If `.env` exists without `GITHUB_TOKEN`, append it +- If `.env` doesn't exist, create it with `GITHUB_TOKEN=` + +Format: + +``` +GITHUB_TOKEN=ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +### Implementation Notes + +- Use Bubble Tea for the interactive UI (consistent with `pull` command) +- Use `github.com/pkg/browser` to open the verification URL +- Poll interval: Start with GitHub's `interval` value (usually 5 seconds) +- Timeout: Code expires after `expires_in` seconds (usually 15 minutes) +- After saving token, verify it works by fetching `viewer { login }` + ## pull - Verify no concurrent `pull` execution From 68a1aa7c3ac58968092218368517433d35efb18c Mon Sep 17 00:00:00 2001 From: Tomas Vesely <448809+wham@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:15:51 -0800 Subject: [PATCH 2/7] Update README and main.go to remove -t flag and clarify GitHub token usage --- README.md | 2 +- main.go | 11 +++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index cb9e1ea..8c7d06d 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ The first run may take a while. Subsequent runs are faster, fetching only new da | Argument | Variable | Description | | :------- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------- | -| `-t` | `GITHUB_TOKEN` | Your GitHub token. Use `login` command or create a [personal token](https://github.com/settings/personal-access-tokens). **Required.** | +| | `GITHUB_TOKEN` | Your GitHub token. Use `login` command or create a [personal token](https://github.com/settings/personal-access-tokens). **Required.** | | `-o` | `ORGANIZATION` | The GitHub organization to pull data from. **Required.** | | `-m` | | Home directory. Default: `~/.github-brain` (or checkout directory if run via `scripts/run`). | | `-i` | | Pull only selected entities: `repositories`, `discussions`, `issues`, `pull-requests` (comma-separated). | diff --git a/main.go b/main.go index abba9e2..d8fd320 100644 --- a/main.go +++ b/main.go @@ -339,11 +339,6 @@ func LoadConfig(args []string) *Config { // Command line args override environment variables for i := 0; i < len(args); i++ { switch args[i] { - case "-t": - if i+1 < len(args) { - config.GithubToken = args[i+1] - i++ - } case "-o": if i+1 < len(args) { config.Organization = args[i+1] @@ -4390,14 +4385,14 @@ func main() { args := os.Args[2:] for i := 0; i < len(args); i++ { if args[i] == "-h" || args[i] == "--help" { - fmt.Println("Usage: pull -t -o [-m ] [-i repositories,discussions,issues,pull-requests] [-e excluded_repos] [-f]") + fmt.Println("Usage: pull -o [-m ] [-i repositories,discussions,issues,pull-requests] [-e excluded_repos] [-f]") fmt.Println("Options:") - fmt.Println(" -t GitHub token (or set GITHUB_TOKEN)") fmt.Println(" -o GitHub organization (or set ORGANIZATION)") fmt.Println(" -m Home directory (default: ~/.github-brain)") fmt.Println(" -i Items to pull (default: all)") fmt.Println(" -e Excluded repositories (comma-separated)") fmt.Println(" -f Force: clear data before pulling") + fmt.Println("\nAuthentication: Run 'login' first or set GITHUB_TOKEN environment variable.") os.Exit(0) } } @@ -4417,7 +4412,7 @@ func main() { // Continue with the original logic if config.GithubToken == "" { - progress.Log("Error: GitHub token is required. Use -t or set GITHUB_TOKEN environment variable.") + progress.Log("Error: GitHub token is required. Run 'github-brain login' or set GITHUB_TOKEN environment variable.") // Give console time to display the error before exiting time.Sleep(3 * time.Second) return From eff4af61adffa80f81897d3bf148aefee9bfbbdd Mon Sep 17 00:00:00 2001 From: Tomas Vesely <448809+wham@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:34:30 -0800 Subject: [PATCH 3/7] Update login section in README to clarify OAuth flow and optional organization storage --- README.md | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 8c7d06d..0ba4a01 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,8 @@ You can change the home directory with the `-m` argument available for all comma ### `login` -Authenticate with GitHub using OAuth device flow. Opens your browser to authorize GitHub Brain and stores the token in the `.env` file. +Opens your browser to authorize _GitHub Brain_ app and stores resulting `GITHUB_TOKEN` in the `.env` file. +Optionally, you can also specify `ORGANIZATION` to store in the same file. Example: @@ -71,18 +72,9 @@ Example: github-brain login ``` -| Argument | Description | -| :------- | :------------------------------------------------------------------------------------------- | -| `-m` | Home directory. Default: `~/.github-brain` (or checkout directory if run via `scripts/run`). | - -The login flow: - -1. Displays a one-time code -2. Opens `github.com/login/device` in your browser -3. You enter the code and authorize the app -4. Token is saved to `~/.github-brain/.env` - -After login, you can run `pull` without the `-t` argument. +| Argument | Description | +| :------- | :----------------------------------------- | +| `-m` | Home directory. Default: `~/.github-brain` | ### `pull` @@ -205,4 +197,4 @@ The extension uses the MCP server to search GitHub data. ## Development -`scripts/run` builds and runs `github-brain` with the checkout directory as home (database in `db/`, config in `.env`). +`scripts/run` builds and runs `github-brain` with the checkout directory as home `-m` (database in `db/`, config in `.env`). From 3bad31a4d28526d5843ab6ca148e14093d2ec137 Mon Sep 17 00:00:00 2001 From: Tomas Vesely <448809+wham@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:44:41 -0800 Subject: [PATCH 4/7] Enhance login flow to prompt for GitHub organization after authentication and update documentation for token and organization storage --- README.md | 18 +++--- go.mod | 3 +- go.sum | 2 + main.go | 187 +++++++++++++++++++++++++++++++++++++++--------------- main.md | 31 +++++++-- 5 files changed, 175 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 0ba4a01..277f4ce 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ The first run may take a while. Subsequent runs are faster, fetching only new da | :------- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------- | | | `GITHUB_TOKEN` | Your GitHub token. Use `login` command or create a [personal token](https://github.com/settings/personal-access-tokens). **Required.** | | `-o` | `ORGANIZATION` | The GitHub organization to pull data from. **Required.** | -| `-m` | | Home directory. Default: `~/.github-brain` (or checkout directory if run via `scripts/run`). | +| `-m` | | Home directory. Default: `~/.github-brain` | | `-i` | | Pull only selected entities: `repositories`, `discussions`, `issues`, `pull-requests` (comma-separated). | | `-f` | | Remove all data before pulling. With `-i`, removes only specified items. | | `-e` | `EXCLUDED_REPOSITORIES` | Repositories to exclude (comma-separated). Useful for large repos not relevant to your analysis. | @@ -118,10 +118,10 @@ Example: github-brain mcp -o my-org ``` -| Argument | Variable | Description | -| :------- | :------------- | :------------------------------------------------------------------------------------------- | +| Argument | Variable | Description | +| :------- | :------------- | :------------------------------------------ | | `-o` | `ORGANIZATION` | GitHub organization. **Required.** | -| `-m` | | Home directory. Default: `~/.github-brain` (or checkout directory if run via `scripts/run`). | +| `-m` | | Home directory. Default: `~/.github-brain` | ### `ui` @@ -131,11 +131,11 @@ Start the web UI for quick searches (alternative to MCP). github-brain ui -o my-org ``` -| Argument | Variable | Description | -| :------- | :------------- | :------------------------------------------------------------------------------------------- | -| `-o` | `ORGANIZATION` | GitHub organization. **Required.** | -| `-m` | | Home directory. Default: `~/.github-brain` (or checkout directory if run via `scripts/run`). | -| `-p` | `UI_PORT` | Port. Default: `8080`. | +| Argument | Variable | Description | +| :------- | :------------- | :------------------------------------------| +| `-o` | `ORGANIZATION` | GitHub organization. **Required.** | +| `-m` | | Home directory. Default: `~/.github-brain` | +| `-p` | `UI_PORT` | Port. Default: `8080`. | ### Additional Arguments diff --git a/go.mod b/go.mod index f9e206d..24ffd5e 100644 --- a/go.mod +++ b/go.mod @@ -11,11 +11,13 @@ require ( github.com/joho/godotenv v1.5.1 github.com/mattn/go-sqlite3 v1.14.28 github.com/modelcontextprotocol/go-sdk v1.1.0 + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/shurcooL/githubv4 v0.0.0-20230704064427-599ae7bbf278 golang.org/x/oauth2 v0.30.0 ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect @@ -30,7 +32,6 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect - github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect diff --git a/go.sum b/go.sum index cdccd34..a16adc6 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= diff --git a/main.go b/main.go index d8fd320..363ae87 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ import ( "time" "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/joho/godotenv" @@ -5396,29 +5397,37 @@ type AccessTokenResponse struct { // loginModel is the Bubble Tea model for the login UI type loginModel struct { - spinner spinner.Model - userCode string + spinner spinner.Model + textInput textinput.Model + userCode string verificationURI string - status string // "waiting", "success", "error" - errorMsg string - username string - homeDir string - width int - height int - borderColors []lipgloss.AdaptiveColor - colorIndex int - done bool + status string // "waiting", "org_input", "success", "error" + errorMsg string + username string + token string + organization string + homeDir string + width int + height int + borderColors []lipgloss.AdaptiveColor + colorIndex int + done bool } // Login message types type ( loginTickMsg time.Time - loginSuccessMsg struct{ username string } + loginSuccessMsg struct{} loginErrorMsg struct{ err error } loginDeviceCodeMsg struct { userCode string verificationURI string } + loginAuthenticatedMsg struct { + username string + token string + } + loginOrgSubmittedMsg struct{} ) func newLoginModel(homeDir string) loginModel { @@ -5426,6 +5435,13 @@ func newLoginModel(homeDir string) loginModel { s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) + ti := textinput.New() + ti.Placeholder = "my-org" + ti.CharLimit = 100 + ti.Width = 30 + ti.Prompt = "> " + ti.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) + gradientColors := []lipgloss.AdaptiveColor{ {Light: "#874BFD", Dark: "#7D56F4"}, {Light: "#7D56F4", Dark: "#6B4FD8"}, @@ -5437,6 +5453,7 @@ func newLoginModel(homeDir string) loginModel { return loginModel{ spinner: s, + textInput: ti, status: "waiting", homeDir: homeDir, width: 80, @@ -5465,9 +5482,19 @@ func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { - case "ctrl+c", "q": + case "ctrl+c": m.done = true return m, tea.Quit + case "enter": + if m.status == "org_input" { + m.organization = strings.TrimSpace(m.textInput.Value()) + return m, func() tea.Msg { return loginOrgSubmittedMsg{} } + } + } + // Pass key messages to textinput when in org_input mode + if m.status == "org_input" { + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd } case tea.WindowSizeMsg: @@ -5484,9 +5511,30 @@ func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.verificationURI = msg.verificationURI return m, nil + case loginAuthenticatedMsg: + // User has authenticated, now prompt for organization + m.status = "org_input" + m.username = msg.username + m.token = msg.token + m.textInput.Focus() + return m, textinput.Blink + + case loginOrgSubmittedMsg: + // Save token and organization to .env + if err := saveTokenToEnv(m.homeDir, m.token, m.organization); err != nil { + m.status = "error" + m.errorMsg = fmt.Sprintf("failed to save token: %v", err) + m.done = true + return m, nil + } + m.status = "success" + m.done = true + return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { + return tea.Quit() + }) + case loginSuccessMsg: m.status = "success" - m.username = msg.username m.done = true return m, nil @@ -5512,6 +5560,8 @@ func (m loginModel) View() string { switch m.status { case "waiting": content = m.renderWaitingView() + case "org_input": + content = m.renderOrgInputView() case "success": content = m.renderSuccessView() case "error": @@ -5588,19 +5638,39 @@ func (m loginModel) renderWaitingView() string { return b.String() } +func (m loginModel) renderOrgInputView() string { + var b strings.Builder + + successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) + + b.WriteString("\n") + b.WriteString(" " + successStyle.Render(fmt.Sprintf("โœ… Successfully authenticated as @%s", m.username)) + "\n") + b.WriteString("\n") + b.WriteString(" Enter your GitHub organization (optional):\n") + b.WriteString(" " + m.textInput.View() + "\n") + b.WriteString("\n") + b.WriteString(" Press Enter to skip, or type organization name\n") + b.WriteString("\n") + + return b.String() +} + func (m loginModel) renderSuccessView() string { var b strings.Builder successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) b.WriteString("\n") - b.WriteString(" " + successStyle.Render("โœ… Successfully authenticated!") + "\n") + b.WriteString(" " + successStyle.Render("โœ… Setup complete!") + "\n") b.WriteString("\n") b.WriteString(fmt.Sprintf(" Logged in as: @%s\n", m.username)) - b.WriteString(fmt.Sprintf(" Token saved to: %s/.env\n", m.homeDir)) + if m.organization != "" { + b.WriteString(fmt.Sprintf(" Organization: %s\n", m.organization)) + } + b.WriteString(fmt.Sprintf(" Saved to: %s/.env\n", m.homeDir)) b.WriteString("\n") b.WriteString(" You can now run:\n") - b.WriteString(" github-brain pull -o \n") + b.WriteString(" github-brain pull\n") b.WriteString("\n") return b.String() @@ -5686,18 +5756,9 @@ func runDeviceFlow(p *tea.Program, homeDir string) { return } - // Step 4: Save token to .env file - if err := saveTokenToEnv(homeDir, token); err != nil { - p.Send(loginErrorMsg{err: fmt.Errorf("failed to save token: %w", err)}) - return - } - - // Success! - p.Send(loginSuccessMsg{username: username}) - - // Give user time to see success message before quitting - time.Sleep(2 * time.Second) - p.Quit() + // Step 4: Prompt for organization (handled by UI) + // Token is passed via message to the UI + p.Send(loginAuthenticatedMsg{username: username, token: token}) } func requestDeviceCode() (*DeviceCodeResponse, error) { @@ -5819,7 +5880,7 @@ func verifyTokenAndGetUsername(token string) (string, error) { return query.Viewer.Login, nil } -func saveTokenToEnv(homeDir string, token string) error { +func saveTokenToEnv(homeDir string, token string, organization string) error { envPath := homeDir + "/.env" // Read existing .env content @@ -5828,36 +5889,62 @@ func saveTokenToEnv(homeDir string, token string) error { return err } - var newContent string tokenLine := fmt.Sprintf("GITHUB_TOKEN=%s", token) + orgLine := fmt.Sprintf("ORGANIZATION=%s", organization) if len(existingContent) == 0 { // File doesn't exist or is empty + var newContent string newContent = tokenLine + "\n" - } else { - // Check if GITHUB_TOKEN already exists - lines := strings.Split(string(existingContent), "\n") - found := false - for i, line := range lines { - if strings.HasPrefix(line, "GITHUB_TOKEN=") { - lines[i] = tokenLine - found = true - break - } + if organization != "" { + newContent += orgLine + "\n" } - if !found { - // Append token line - if !strings.HasSuffix(string(existingContent), "\n") { - lines = append(lines, "") + return os.WriteFile(envPath, []byte(newContent), 0600) + } + + // Process existing content + lines := strings.Split(string(existingContent), "\n") + tokenFound := false + orgFound := false + + for i, line := range lines { + if strings.HasPrefix(line, "GITHUB_TOKEN=") { + lines[i] = tokenLine + tokenFound = true + } else if strings.HasPrefix(line, "ORGANIZATION=") { + if organization != "" { + lines[i] = orgLine + } else { + // Remove org line if organization is empty + lines[i] = "" } - lines = append(lines, tokenLine) + orgFound = true } - newContent = strings.Join(lines, "\n") - // Ensure file ends with newline - if !strings.HasSuffix(newContent, "\n") { - newContent += "\n" + } + + if !tokenFound { + lines = append(lines, tokenLine) + } + if !orgFound && organization != "" { + lines = append(lines, orgLine) + } + + // Clean up empty lines at the end and rebuild + var cleanLines []string + for _, line := range lines { + if line != "" || len(cleanLines) == 0 { + cleanLines = append(cleanLines, line) } } + // Remove trailing empty strings + for len(cleanLines) > 0 && cleanLines[len(cleanLines)-1] == "" { + cleanLines = cleanLines[:len(cleanLines)-1] + } + + newContent := strings.Join(cleanLines, "\n") + if !strings.HasSuffix(newContent, "\n") { + newContent += "\n" + } return os.WriteFile(envPath, []byte(newContent), 0600) } \ No newline at end of file diff --git a/main.md b/main.md index 150e7b3..ce1b759 100644 --- a/main.md +++ b/main.md @@ -123,39 +123,58 @@ The app uses a registered GitHub App for authentication: - `access_denied`: User denied, show error - Success: Returns `access_token` (format: `ghu_xxxx`) -6. On success, save token to `.env` file: +6. On success, prompt for organization: + + ``` + โ•ญโ”€ GitHub ๐Ÿง  Login โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ + โ”‚ โ”‚ + โ”‚ โœ… Successfully authenticated as @wham โ”‚ + โ”‚ โ”‚ + โ”‚ Enter your GitHub organization (optional): โ”‚ + โ”‚ > my-orgโ–ˆ โ”‚ + โ”‚ โ”‚ + โ”‚ Press Enter to skip, or type organization name โ”‚ + โ”‚ โ”‚ + โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + ``` + +7. Save token (and organization if provided) to `.env` file: ``` โ•ญโ”€ GitHub ๐Ÿง  Login โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ โ”‚ - โ”‚ โœ… Successfully authenticated! โ”‚ + โ”‚ โœ… Setup complete! โ”‚ โ”‚ โ”‚ โ”‚ Logged in as: @wham โ”‚ - โ”‚ Token saved to: ~/.github-brain/.env โ”‚ + โ”‚ Organization: my-org โ”‚ + โ”‚ Saved to: ~/.github-brain/.env โ”‚ โ”‚ โ”‚ โ”‚ You can now run: โ”‚ - โ”‚ github-brain pull -o โ”‚ + โ”‚ github-brain pull โ”‚ โ”‚ โ”‚ โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ ``` ### Token Storage -Save the OAuth token to `{HomeDir}/.env` file: +Save the OAuth token and organization to `{HomeDir}/.env` file: - If `.env` exists and has `GITHUB_TOKEN`, replace it - If `.env` exists without `GITHUB_TOKEN`, append it -- If `.env` doesn't exist, create it with `GITHUB_TOKEN=` +- If `.env` doesn't exist, create it +- Same logic for `ORGANIZATION` if provided Format: ``` GITHUB_TOKEN=ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +ORGANIZATION=my-org ``` ### Implementation Notes - Use Bubble Tea for the interactive UI (consistent with `pull` command) - Use `github.com/pkg/browser` to open the verification URL +- Use `github.com/charmbracelet/bubbles/textinput` for organization input - Poll interval: Start with GitHub's `interval` value (usually 5 seconds) - Timeout: Code expires after `expires_in` seconds (usually 15 minutes) - After saving token, verify it works by fetching `viewer { login }` From 8e92094bf24414b736097506d0124773d3bfa3e0 Mon Sep 17 00:00:00 2001 From: Tomas Vesely <448809+wham@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:43:47 -0800 Subject: [PATCH 5/7] Refactor code box styling in waiting view for improved alignment and readability --- main.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/main.go b/main.go index 363ae87..0a741a5 100644 --- a/main.go +++ b/main.go @@ -5619,14 +5619,15 @@ func (m loginModel) renderWaitingView() string { b.WriteString(" 2. Enter this code:\n") b.WriteString("\n") - // Code box + // Code box with margin for alignment codeStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("12")). - Padding(0, 2). - Bold(true) + Padding(0, 3). + Bold(true). + MarginLeft(5) - b.WriteString(" " + codeStyle.Render(" "+m.userCode+" ") + "\n") + b.WriteString(codeStyle.Render(m.userCode) + "\n") b.WriteString("\n") b.WriteString(" " + m.spinner.View() + " Waiting for authorization...\n") } From 212b484180d2ed3e82476008415967907bf2bfd2 Mon Sep 17 00:00:00 2001 From: Tomas Vesely <448809+wham@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:54:20 -0800 Subject: [PATCH 6/7] Add refresh token support and update documentation for token storage and refresh logic --- main.go | 215 ++++++++++++++++++++++++++++++++++++++++++++------------ main.md | 27 +++++-- 2 files changed, 195 insertions(+), 47 deletions(-) diff --git a/main.go b/main.go index 0a741a5..4a53ce4 100644 --- a/main.go +++ b/main.go @@ -307,6 +307,7 @@ func init() { // Config holds all application configuration type Config struct { GithubToken string + RefreshToken string Organization string HomeDir string // GitHub Brain home directory (default: ~/.github-brain) DBDir string // SQLite database path, constructed as /db @@ -331,6 +332,7 @@ func LoadConfig(args []string) *Config { // Load from environment variables first config.GithubToken = os.Getenv("GITHUB_TOKEN") + config.RefreshToken = os.Getenv("GITHUB_REFRESH_TOKEN") config.Organization = os.Getenv("ORGANIZATION") if excludedRepos := os.Getenv("EXCLUDED_REPOSITORIES"); excludedRepos != "" { @@ -4548,30 +4550,72 @@ func main() { } } if err := graphqlClient.Query(ctx, ¤tUser, nil); err != nil { - // GraphQL error - decrement success counter and increment error counter - // since GraphQL returns HTTP 200 even for errors - statusMutex.Lock() - if statusCounters.Success2XX > 0 { - statusCounters.Success2XX-- - } - statusCounters.Error4XX++ - statusMutex.Unlock() - - // Even on error, update UI with any rate limit info we captured - rateLimitInfoMutex.RLock() - progress.UpdateRateLimit(currentRateLimit.Used, currentRateLimit.Limit, currentRateLimit.Reset) - rateLimitInfoMutex.RUnlock() + // Check if this might be an auth error (token expired) + errStr := err.Error() + isAuthError := strings.Contains(errStr, "401") || + strings.Contains(errStr, "Bad credentials") || + strings.Contains(errStr, "Unauthorized") - statusMutex.Lock() - progress.UpdateAPIStatus(statusCounters.Success2XX, statusCounters.Error4XX, statusCounters.Error5XX) - statusMutex.Unlock() - - progress.Log("Error: Failed to fetch current user: %v", err) - progress.Log("Please check your GitHub token and network connection") - // Give user time to see the error before stopping - time.Sleep(3 * time.Second) - progress.Stop() - os.Exit(1) + if isAuthError && config.RefreshToken != "" { + progress.Log("Token may have expired, attempting refresh...") + newToken, refreshErr := tryRefreshToken(config) + if refreshErr != nil { + progress.Log("Error: %v", refreshErr) + time.Sleep(3 * time.Second) + progress.Stop() + os.Exit(1) + } + + progress.Log("Token refreshed successfully, retrying...") + + // Recreate the client with new token + ts = oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: newToken}, + ) + tc = oauth2.NewClient(ctx, ts) + tc.Transport = &CustomTransport{ + wrapped: tc.Transport, + } + graphqlClient = githubv4.NewClient(tc) + + // Retry the query + if err := graphqlClient.Query(ctx, ¤tUser, nil); err != nil { + progress.Log("Error: Failed to fetch current user after token refresh: %v", err) + progress.Log("Please run 'login' again to re-authenticate") + time.Sleep(3 * time.Second) + progress.Stop() + os.Exit(1) + } + } else { + // GraphQL error - decrement success counter and increment error counter + // since GraphQL returns HTTP 200 even for errors + statusMutex.Lock() + if statusCounters.Success2XX > 0 { + statusCounters.Success2XX-- + } + statusCounters.Error4XX++ + statusMutex.Unlock() + + // Even on error, update UI with any rate limit info we captured + rateLimitInfoMutex.RLock() + progress.UpdateRateLimit(currentRateLimit.Used, currentRateLimit.Limit, currentRateLimit.Reset) + rateLimitInfoMutex.RUnlock() + + statusMutex.Lock() + progress.UpdateAPIStatus(statusCounters.Success2XX, statusCounters.Error4XX, statusCounters.Error5XX) + statusMutex.Unlock() + + progress.Log("Error: Failed to fetch current user: %v", err) + if config.RefreshToken == "" { + progress.Log("No refresh token available. Please run 'login' again") + } else { + progress.Log("Please check your GitHub token and network connection") + } + // Give user time to see the error before stopping + time.Sleep(3 * time.Second) + progress.Stop() + os.Exit(1) + } } currentUsername := currentUser.Viewer.Login progress.Log("Authenticated as user: %s", currentUsername) @@ -5388,11 +5432,13 @@ type DeviceCodeResponse struct { // AccessTokenResponse represents the response from GitHub's access token endpoint type AccessTokenResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - Scope string `json:"scope"` - Error string `json:"error"` - ErrorDesc string `json:"error_description"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` } // loginModel is the Bubble Tea model for the login UI @@ -5405,6 +5451,7 @@ type loginModel struct { errorMsg string username string token string + refreshToken string organization string homeDir string width int @@ -5424,8 +5471,9 @@ type ( verificationURI string } loginAuthenticatedMsg struct { - username string - token string + username string + token string + refreshToken string } loginOrgSubmittedMsg struct{} ) @@ -5516,12 +5564,13 @@ func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.status = "org_input" m.username = msg.username m.token = msg.token + m.refreshToken = msg.refreshToken m.textInput.Focus() return m, textinput.Blink case loginOrgSubmittedMsg: - // Save token and organization to .env - if err := saveTokenToEnv(m.homeDir, m.token, m.organization); err != nil { + // Save tokens and organization to .env + if err := saveTokenToEnv(m.homeDir, m.token, m.refreshToken, m.organization); err != nil { m.status = "error" m.errorMsg = fmt.Sprintf("failed to save token: %v", err) m.done = true @@ -5744,7 +5793,7 @@ func runDeviceFlow(p *tea.Program, homeDir string) { _ = browser.OpenURL(deviceCode.VerificationURI) // Step 2: Poll for access token - token, err := pollForAccessToken(deviceCode) + token, refreshToken, err := pollForAccessToken(deviceCode) if err != nil { p.Send(loginErrorMsg{err: err}) return @@ -5758,8 +5807,8 @@ func runDeviceFlow(p *tea.Program, homeDir string) { } // Step 4: Prompt for organization (handled by UI) - // Token is passed via message to the UI - p.Send(loginAuthenticatedMsg{username: username, token: token}) + // Tokens are passed via message to the UI + p.Send(loginAuthenticatedMsg{username: username, token: token, refreshToken: refreshToken}) } func requestDeviceCode() (*DeviceCodeResponse, error) { @@ -5798,7 +5847,7 @@ func requestDeviceCode() (*DeviceCodeResponse, error) { return &deviceCode, nil } -func pollForAccessToken(deviceCode *DeviceCodeResponse) (string, error) { +func pollForAccessToken(deviceCode *DeviceCodeResponse) (accessToken string, refreshToken string, err error) { interval := time.Duration(deviceCode.Interval) * time.Second if interval < 5*time.Second { interval = 5 * time.Second @@ -5816,7 +5865,7 @@ func pollForAccessToken(deviceCode *DeviceCodeResponse) (string, error) { req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", strings.NewReader(data.Encode())) if err != nil { - return "", err + return "", "", err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") @@ -5842,7 +5891,7 @@ func pollForAccessToken(deviceCode *DeviceCodeResponse) (string, error) { case "": // Success! if tokenResp.AccessToken != "" { - return tokenResp.AccessToken, nil + return tokenResp.AccessToken, tokenResp.RefreshToken, nil } case "authorization_pending": // Keep polling @@ -5852,15 +5901,15 @@ func pollForAccessToken(deviceCode *DeviceCodeResponse) (string, error) { interval += 5 * time.Second continue case "expired_token": - return "", fmt.Errorf("device code expired, please try again") + return "", "", fmt.Errorf("device code expired, please try again") case "access_denied": - return "", fmt.Errorf("access denied by user") + return "", "", fmt.Errorf("access denied by user") default: - return "", fmt.Errorf("%s: %s", tokenResp.Error, tokenResp.ErrorDesc) + return "", "", fmt.Errorf("%s: %s", tokenResp.Error, tokenResp.ErrorDesc) } } - return "", fmt.Errorf("timeout waiting for authorization") + return "", "", fmt.Errorf("timeout waiting for authorization") } func verifyTokenAndGetUsername(token string) (string, error) { @@ -5881,7 +5930,72 @@ func verifyTokenAndGetUsername(token string) (string, error) { return query.Viewer.Login, nil } -func saveTokenToEnv(homeDir string, token string, organization string) error { +// refreshAccessToken uses the refresh token to get a new access token +func refreshAccessToken(refreshToken string) (newAccessToken string, newRefreshToken string, err error) { + data := url.Values{} + data.Set("client_id", GitHubClientID) + data.Set("grant_type", "refresh_token") + data.Set("refresh_token", refreshToken) + + req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", strings.NewReader(data.Encode())) + if err != nil { + return "", "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", err + } + + var tokenResp AccessTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return "", "", fmt.Errorf("failed to parse token response: %w", err) + } + + if tokenResp.Error != "" { + return "", "", fmt.Errorf("%s: %s", tokenResp.Error, tokenResp.ErrorDesc) + } + + if tokenResp.AccessToken == "" { + return "", "", fmt.Errorf("no access token in response") + } + + return tokenResp.AccessToken, tokenResp.RefreshToken, nil +} + +// tryRefreshToken attempts to refresh the access token and update the .env file +func tryRefreshToken(config *Config) (string, error) { + if config.RefreshToken == "" { + return "", fmt.Errorf("no refresh token available, please run 'login' again") + } + + newToken, newRefreshToken, err := refreshAccessToken(config.RefreshToken) + if err != nil { + return "", fmt.Errorf("failed to refresh token: %w - please run 'login' again", err) + } + + // Update .env file with new tokens + if err := saveTokenToEnv(config.HomeDir, newToken, newRefreshToken, config.Organization); err != nil { + return "", fmt.Errorf("failed to save refreshed token: %w", err) + } + + // Update config with new tokens + config.GithubToken = newToken + config.RefreshToken = newRefreshToken + + return newToken, nil +} + +func saveTokenToEnv(homeDir string, token string, refreshToken string, organization string) error { envPath := homeDir + "/.env" // Read existing .env content @@ -5891,12 +6005,16 @@ func saveTokenToEnv(homeDir string, token string, organization string) error { } tokenLine := fmt.Sprintf("GITHUB_TOKEN=%s", token) + refreshLine := fmt.Sprintf("GITHUB_REFRESH_TOKEN=%s", refreshToken) orgLine := fmt.Sprintf("ORGANIZATION=%s", organization) if len(existingContent) == 0 { // File doesn't exist or is empty var newContent string newContent = tokenLine + "\n" + if refreshToken != "" { + newContent += refreshLine + "\n" + } if organization != "" { newContent += orgLine + "\n" } @@ -5906,12 +6024,20 @@ func saveTokenToEnv(homeDir string, token string, organization string) error { // Process existing content lines := strings.Split(string(existingContent), "\n") tokenFound := false + refreshFound := false orgFound := false for i, line := range lines { if strings.HasPrefix(line, "GITHUB_TOKEN=") { lines[i] = tokenLine tokenFound = true + } else if strings.HasPrefix(line, "GITHUB_REFRESH_TOKEN=") { + if refreshToken != "" { + lines[i] = refreshLine + } else { + lines[i] = "" + } + refreshFound = true } else if strings.HasPrefix(line, "ORGANIZATION=") { if organization != "" { lines[i] = orgLine @@ -5926,6 +6052,9 @@ func saveTokenToEnv(homeDir string, token string, organization string) error { if !tokenFound { lines = append(lines, tokenLine) } + if !refreshFound && refreshToken != "" { + lines = append(lines, refreshLine) + } if !orgFound && organization != "" { lines = append(lines, orgLine) } diff --git a/main.md b/main.md index ce1b759..14615be 100644 --- a/main.md +++ b/main.md @@ -121,7 +121,7 @@ The app uses a registered GitHub App for authentication: - `slow_down`: Increase interval by 5 seconds - `expired_token`: Code expired, start over - `access_denied`: User denied, show error - - Success: Returns `access_token` (format: `ghu_xxxx`) + - Success: Returns `access_token`, `refresh_token`, and `expires_in` 6. On success, prompt for organization: @@ -138,7 +138,7 @@ The app uses a registered GitHub App for authentication: โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ ``` -7. Save token (and organization if provided) to `.env` file: +7. Save tokens (and organization if provided) to `.env` file: ``` โ•ญโ”€ GitHub ๐Ÿง  Login โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ โ”‚ @@ -156,20 +156,39 @@ The app uses a registered GitHub App for authentication: ### Token Storage -Save the OAuth token and organization to `{HomeDir}/.env` file: +Save tokens and organization to `{HomeDir}/.env` file: - If `.env` exists and has `GITHUB_TOKEN`, replace it - If `.env` exists without `GITHUB_TOKEN`, append it - If `.env` doesn't exist, create it -- Same logic for `ORGANIZATION` if provided +- Same logic for `GITHUB_REFRESH_TOKEN` and `ORGANIZATION` Format: ``` GITHUB_TOKEN=ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +GITHUB_REFRESH_TOKEN=ghr_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ORGANIZATION=my-org ``` +### Token Refresh + +GitHub App user access tokens expire after 8 hours. Refresh tokens are valid for 6 months. + +**Auto-refresh in `pull` command:** + +Before making API calls, check if token needs refresh: + +1. Try API call with current token +2. If 401 Unauthorized and refresh token exists: + ``` + POST https://github.com/login/oauth/access_token + client_id=&grant_type=refresh_token&refresh_token= + ``` +3. On success: Update `GITHUB_TOKEN` and `GITHUB_REFRESH_TOKEN` in `.env` +4. Retry the API call with new token +5. If refresh fails: Show error message asking user to run `login` again + ### Implementation Notes - Use Bubble Tea for the interactive UI (consistent with `pull` command) From 9e5807ca82d36bcb28eed66567783a3b12e803d8 Mon Sep 17 00:00:00 2001 From: Tomas Vesely <448809+wham@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:59:40 -0800 Subject: [PATCH 7/7] Refactor authentication to use OAuth App instead of GitHub App, removing refresh token logic and updating documentation for token storage and usage. --- main.go | 214 ++++++++++++-------------------------------------------- main.md | 45 +++--------- 2 files changed, 53 insertions(+), 206 deletions(-) diff --git a/main.go b/main.go index 4a53ce4..a1c5f28 100644 --- a/main.go +++ b/main.go @@ -42,10 +42,10 @@ var htmxJS []byte // Database schema version GUID - change this on any schema modification const SCHEMA_GUID = "b8f3c2a1-9e7d-4f6b-8c5a-3d2e1f0a9b8c" -// GitHub App Client ID (public, safe to embed) -// Permissions are configured in the GitHub App settings, not via scopes -// Register at: https://github.com/settings/apps -const GitHubClientID = "Iv23ctenJiPpElznxKwY" +// OAuth App Client ID (public, safe to embed) +// Scopes: read:org repo +// Register at: https://github.com/settings/developers +const GitHubClientID = "Ov23ctgXe80Z1KsXE3vJ" // Version information (set via ldflags at build time) var ( @@ -307,7 +307,6 @@ func init() { // Config holds all application configuration type Config struct { GithubToken string - RefreshToken string Organization string HomeDir string // GitHub Brain home directory (default: ~/.github-brain) DBDir string // SQLite database path, constructed as /db @@ -332,7 +331,6 @@ func LoadConfig(args []string) *Config { // Load from environment variables first config.GithubToken = os.Getenv("GITHUB_TOKEN") - config.RefreshToken = os.Getenv("GITHUB_REFRESH_TOKEN") config.Organization = os.Getenv("ORGANIZATION") if excludedRepos := os.Getenv("EXCLUDED_REPOSITORIES"); excludedRepos != "" { @@ -4550,72 +4548,30 @@ func main() { } } if err := graphqlClient.Query(ctx, ¤tUser, nil); err != nil { - // Check if this might be an auth error (token expired) - errStr := err.Error() - isAuthError := strings.Contains(errStr, "401") || - strings.Contains(errStr, "Bad credentials") || - strings.Contains(errStr, "Unauthorized") - - if isAuthError && config.RefreshToken != "" { - progress.Log("Token may have expired, attempting refresh...") - newToken, refreshErr := tryRefreshToken(config) - if refreshErr != nil { - progress.Log("Error: %v", refreshErr) - time.Sleep(3 * time.Second) - progress.Stop() - os.Exit(1) - } - - progress.Log("Token refreshed successfully, retrying...") - - // Recreate the client with new token - ts = oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: newToken}, - ) - tc = oauth2.NewClient(ctx, ts) - tc.Transport = &CustomTransport{ - wrapped: tc.Transport, - } - graphqlClient = githubv4.NewClient(tc) - - // Retry the query - if err := graphqlClient.Query(ctx, ¤tUser, nil); err != nil { - progress.Log("Error: Failed to fetch current user after token refresh: %v", err) - progress.Log("Please run 'login' again to re-authenticate") - time.Sleep(3 * time.Second) - progress.Stop() - os.Exit(1) - } - } else { - // GraphQL error - decrement success counter and increment error counter - // since GraphQL returns HTTP 200 even for errors - statusMutex.Lock() - if statusCounters.Success2XX > 0 { - statusCounters.Success2XX-- - } - statusCounters.Error4XX++ - statusMutex.Unlock() - - // Even on error, update UI with any rate limit info we captured - rateLimitInfoMutex.RLock() - progress.UpdateRateLimit(currentRateLimit.Used, currentRateLimit.Limit, currentRateLimit.Reset) - rateLimitInfoMutex.RUnlock() - - statusMutex.Lock() - progress.UpdateAPIStatus(statusCounters.Success2XX, statusCounters.Error4XX, statusCounters.Error5XX) - statusMutex.Unlock() - - progress.Log("Error: Failed to fetch current user: %v", err) - if config.RefreshToken == "" { - progress.Log("No refresh token available. Please run 'login' again") - } else { - progress.Log("Please check your GitHub token and network connection") - } - // Give user time to see the error before stopping - time.Sleep(3 * time.Second) - progress.Stop() - os.Exit(1) + // GraphQL error - decrement success counter and increment error counter + // since GraphQL returns HTTP 200 even for errors + statusMutex.Lock() + if statusCounters.Success2XX > 0 { + statusCounters.Success2XX-- } + statusCounters.Error4XX++ + statusMutex.Unlock() + + // Even on error, update UI with any rate limit info we captured + rateLimitInfoMutex.RLock() + progress.UpdateRateLimit(currentRateLimit.Used, currentRateLimit.Limit, currentRateLimit.Reset) + rateLimitInfoMutex.RUnlock() + + statusMutex.Lock() + progress.UpdateAPIStatus(statusCounters.Success2XX, statusCounters.Error4XX, statusCounters.Error5XX) + statusMutex.Unlock() + + progress.Log("Error: Failed to fetch current user: %v", err) + progress.Log("Please run 'login' again to re-authenticate") + // Give user time to see the error before stopping + time.Sleep(3 * time.Second) + progress.Stop() + os.Exit(1) } currentUsername := currentUser.Viewer.Login progress.Log("Authenticated as user: %s", currentUsername) @@ -5433,8 +5389,6 @@ type DeviceCodeResponse struct { // AccessTokenResponse represents the response from GitHub's access token endpoint type AccessTokenResponse struct { AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` TokenType string `json:"token_type"` Scope string `json:"scope"` Error string `json:"error"` @@ -5451,7 +5405,6 @@ type loginModel struct { errorMsg string username string token string - refreshToken string organization string homeDir string width int @@ -5473,7 +5426,6 @@ type ( loginAuthenticatedMsg struct { username string token string - refreshToken string } loginOrgSubmittedMsg struct{} ) @@ -5564,13 +5516,12 @@ func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.status = "org_input" m.username = msg.username m.token = msg.token - m.refreshToken = msg.refreshToken m.textInput.Focus() return m, textinput.Blink case loginOrgSubmittedMsg: - // Save tokens and organization to .env - if err := saveTokenToEnv(m.homeDir, m.token, m.refreshToken, m.organization); err != nil { + // Save token and organization to .env + if err := saveTokenToEnv(m.homeDir, m.token, m.organization); err != nil { m.status = "error" m.errorMsg = fmt.Sprintf("failed to save token: %v", err) m.done = true @@ -5793,7 +5744,7 @@ func runDeviceFlow(p *tea.Program, homeDir string) { _ = browser.OpenURL(deviceCode.VerificationURI) // Step 2: Poll for access token - token, refreshToken, err := pollForAccessToken(deviceCode) + token, err := pollForAccessToken(deviceCode) if err != nil { p.Send(loginErrorMsg{err: err}) return @@ -5807,14 +5758,14 @@ func runDeviceFlow(p *tea.Program, homeDir string) { } // Step 4: Prompt for organization (handled by UI) - // Tokens are passed via message to the UI - p.Send(loginAuthenticatedMsg{username: username, token: token, refreshToken: refreshToken}) + // Token is passed via message to the UI + p.Send(loginAuthenticatedMsg{username: username, token: token}) } func requestDeviceCode() (*DeviceCodeResponse, error) { data := url.Values{} data.Set("client_id", GitHubClientID) - // Note: GitHub Apps don't use scopes - permissions are defined in app settings + data.Set("scope", "read:org repo") // OAuth App scopes for org and repo access req, err := http.NewRequest("POST", "https://github.com/login/device/code", strings.NewReader(data.Encode())) if err != nil { @@ -5847,7 +5798,7 @@ func requestDeviceCode() (*DeviceCodeResponse, error) { return &deviceCode, nil } -func pollForAccessToken(deviceCode *DeviceCodeResponse) (accessToken string, refreshToken string, err error) { +func pollForAccessToken(deviceCode *DeviceCodeResponse) (accessToken string, err error) { interval := time.Duration(deviceCode.Interval) * time.Second if interval < 5*time.Second { interval = 5 * time.Second @@ -5865,7 +5816,7 @@ func pollForAccessToken(deviceCode *DeviceCodeResponse) (accessToken string, ref req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", strings.NewReader(data.Encode())) if err != nil { - return "", "", err + return "", err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") @@ -5889,9 +5840,9 @@ func pollForAccessToken(deviceCode *DeviceCodeResponse) (accessToken string, ref switch tokenResp.Error { case "": - // Success! + // Success! OAuth App tokens are long-lived if tokenResp.AccessToken != "" { - return tokenResp.AccessToken, tokenResp.RefreshToken, nil + return tokenResp.AccessToken, nil } case "authorization_pending": // Keep polling @@ -5901,15 +5852,15 @@ func pollForAccessToken(deviceCode *DeviceCodeResponse) (accessToken string, ref interval += 5 * time.Second continue case "expired_token": - return "", "", fmt.Errorf("device code expired, please try again") + return "", fmt.Errorf("device code expired, please try again") case "access_denied": - return "", "", fmt.Errorf("access denied by user") + return "", fmt.Errorf("access denied by user") default: - return "", "", fmt.Errorf("%s: %s", tokenResp.Error, tokenResp.ErrorDesc) + return "", fmt.Errorf("%s: %s", tokenResp.Error, tokenResp.ErrorDesc) } } - return "", "", fmt.Errorf("timeout waiting for authorization") + return "", fmt.Errorf("timeout waiting for authorization") } func verifyTokenAndGetUsername(token string) (string, error) { @@ -5930,72 +5881,7 @@ func verifyTokenAndGetUsername(token string) (string, error) { return query.Viewer.Login, nil } -// refreshAccessToken uses the refresh token to get a new access token -func refreshAccessToken(refreshToken string) (newAccessToken string, newRefreshToken string, err error) { - data := url.Values{} - data.Set("client_id", GitHubClientID) - data.Set("grant_type", "refresh_token") - data.Set("refresh_token", refreshToken) - - req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", strings.NewReader(data.Encode())) - if err != nil { - return "", "", err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "application/json") - - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) - if err != nil { - return "", "", err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", "", err - } - - var tokenResp AccessTokenResponse - if err := json.Unmarshal(body, &tokenResp); err != nil { - return "", "", fmt.Errorf("failed to parse token response: %w", err) - } - - if tokenResp.Error != "" { - return "", "", fmt.Errorf("%s: %s", tokenResp.Error, tokenResp.ErrorDesc) - } - - if tokenResp.AccessToken == "" { - return "", "", fmt.Errorf("no access token in response") - } - - return tokenResp.AccessToken, tokenResp.RefreshToken, nil -} - -// tryRefreshToken attempts to refresh the access token and update the .env file -func tryRefreshToken(config *Config) (string, error) { - if config.RefreshToken == "" { - return "", fmt.Errorf("no refresh token available, please run 'login' again") - } - - newToken, newRefreshToken, err := refreshAccessToken(config.RefreshToken) - if err != nil { - return "", fmt.Errorf("failed to refresh token: %w - please run 'login' again", err) - } - - // Update .env file with new tokens - if err := saveTokenToEnv(config.HomeDir, newToken, newRefreshToken, config.Organization); err != nil { - return "", fmt.Errorf("failed to save refreshed token: %w", err) - } - - // Update config with new tokens - config.GithubToken = newToken - config.RefreshToken = newRefreshToken - - return newToken, nil -} - -func saveTokenToEnv(homeDir string, token string, refreshToken string, organization string) error { +func saveTokenToEnv(homeDir string, token string, organization string) error { envPath := homeDir + "/.env" // Read existing .env content @@ -6005,16 +5891,12 @@ func saveTokenToEnv(homeDir string, token string, refreshToken string, organizat } tokenLine := fmt.Sprintf("GITHUB_TOKEN=%s", token) - refreshLine := fmt.Sprintf("GITHUB_REFRESH_TOKEN=%s", refreshToken) orgLine := fmt.Sprintf("ORGANIZATION=%s", organization) if len(existingContent) == 0 { // File doesn't exist or is empty var newContent string newContent = tokenLine + "\n" - if refreshToken != "" { - newContent += refreshLine + "\n" - } if organization != "" { newContent += orgLine + "\n" } @@ -6024,7 +5906,6 @@ func saveTokenToEnv(homeDir string, token string, refreshToken string, organizat // Process existing content lines := strings.Split(string(existingContent), "\n") tokenFound := false - refreshFound := false orgFound := false for i, line := range lines { @@ -6032,12 +5913,8 @@ func saveTokenToEnv(homeDir string, token string, refreshToken string, organizat lines[i] = tokenLine tokenFound = true } else if strings.HasPrefix(line, "GITHUB_REFRESH_TOKEN=") { - if refreshToken != "" { - lines[i] = refreshLine - } else { - lines[i] = "" - } - refreshFound = true + // Remove old refresh token line (no longer used with OAuth Apps) + lines[i] = "" } else if strings.HasPrefix(line, "ORGANIZATION=") { if organization != "" { lines[i] = orgLine @@ -6052,9 +5929,6 @@ func saveTokenToEnv(homeDir string, token string, refreshToken string, organizat if !tokenFound { lines = append(lines, tokenLine) } - if !refreshFound && refreshToken != "" { - lines = append(lines, refreshLine) - } if !orgFound && organization != "" { lines = append(lines, orgLine) } diff --git a/main.md b/main.md index 14615be..a7d1da6 100644 --- a/main.md +++ b/main.md @@ -51,21 +51,13 @@ Use **Bubble Tea** framework (https://github.com/charmbracelet/bubbletea) for te Interactive GitHub authentication using OAuth Device Flow. Stores the resulting token in the `.env` file. -### GitHub App +### OAuth App -The app uses a registered GitHub App for authentication: +The app uses a registered OAuth App for authentication: - **Client ID**: Embedded in the binary (public, safe to commit) - **Client Secret**: Not required for device flow (public clients) -- **Permissions**: Configured in GitHub App settings (not OAuth scopes) - - Repository: Read access to code, discussions, issues, metadata, pull requests - - Organization: Read access to members - -**Why GitHub App instead of OAuth App?** - -- GitHub Apps can be installed per-organization, bypassing org-wide OAuth restrictions -- Higher rate limits (15,000 requests/hour vs 5,000) -- Fine-grained permissions instead of broad OAuth scopes +- **Scopes**: `read:org repo` (read organization data and full repository access) ### Device Flow @@ -73,11 +65,9 @@ The app uses a registered GitHub App for authentication: ``` POST https://github.com/login/device/code - client_id= + client_id=&scope=read:org repo ``` - Note: No `scope` parameter - GitHub Apps use permissions defined in app settings. - 2. GitHub returns: - `device_code`: Secret code for polling @@ -121,7 +111,7 @@ The app uses a registered GitHub App for authentication: - `slow_down`: Increase interval by 5 seconds - `expired_token`: Code expired, start over - `access_denied`: User denied, show error - - Success: Returns `access_token`, `refresh_token`, and `expires_in` + - Success: Returns `access_token` (long-lived, does not expire) 6. On success, prompt for organization: @@ -156,38 +146,21 @@ The app uses a registered GitHub App for authentication: ### Token Storage -Save tokens and organization to `{HomeDir}/.env` file: +Save token and organization to `{HomeDir}/.env` file: - If `.env` exists and has `GITHUB_TOKEN`, replace it - If `.env` exists without `GITHUB_TOKEN`, append it - If `.env` doesn't exist, create it -- Same logic for `GITHUB_REFRESH_TOKEN` and `ORGANIZATION` +- Same logic for `ORGANIZATION` Format: ``` -GITHUB_TOKEN=ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -GITHUB_REFRESH_TOKEN=ghr_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +GITHUB_TOKEN=gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ORGANIZATION=my-org ``` -### Token Refresh - -GitHub App user access tokens expire after 8 hours. Refresh tokens are valid for 6 months. - -**Auto-refresh in `pull` command:** - -Before making API calls, check if token needs refresh: - -1. Try API call with current token -2. If 401 Unauthorized and refresh token exists: - ``` - POST https://github.com/login/oauth/access_token - client_id=&grant_type=refresh_token&refresh_token= - ``` -3. On success: Update `GITHUB_TOKEN` and `GITHUB_REFRESH_TOKEN` in `.env` -4. Retry the API call with new token -5. If refresh fails: Show error message asking user to run `login` again +OAuth App tokens are long-lived and do not expire unless revoked. ### Implementation Notes