From b1f06c4c9601eeac4f1d58d41f89c2c7bfe263be Mon Sep 17 00:00:00 2001 From: Tomas Vesely <448809+wham@users.noreply.github.com> Date: Tue, 23 Dec 2025 23:04:33 -0800 Subject: [PATCH 1/6] Add testing guide for TUI applications --- .github/skills/testing/SKILL.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/skills/testing/SKILL.md diff --git a/.github/skills/testing/SKILL.md b/.github/skills/testing/SKILL.md new file mode 100644 index 0000000..a8400d9 --- /dev/null +++ b/.github/skills/testing/SKILL.md @@ -0,0 +1,25 @@ +--- +name: testing +description: Guide for testing TUI (terminal user interface) applications. Use this when asked to verify code changes. +--- + +# Testing + +This skill helps you create and run tests for terminal user interface (TUI) applications. + +## When to use this skill + +Use this skill when you need to: +- Verify that code changes work as intended +- Ensure existing functionality as specified in `maind.md` and `README.md` is not broken + +## Starting the application + +- Run `scripts/run` where the user would normally run `github-brain` + - `scripts/run pull` equivalently runs `github-brain pull` + - `scripts/run mcp` equivalently runs `github-brain mcp` +- Ensure `.env` files is configured to use the `github-brain-test` organization +- Use GitHub MCP to add new issue/PRs/discussions as needed for testing +- Simulate user input: Send key presses, control combinations, or specific commands to the running application. +- Capture and analyze screen output: The testing tool captures the terminal display (or buffer) at specific moments in time. +- Make assertions: Verify that the screen content matches the expected output (e.g., checking if specific text is present at certain coordinates or if the cursor position is correct). From 70882b185028775a22c49b1bd231d6ffccffd2e2 Mon Sep 17 00:00:00 2001 From: Tomas Vesely <448809+wham@users.noreply.github.com> Date: Tue, 23 Dec 2025 23:08:47 -0800 Subject: [PATCH 2/6] Add AGENTS.md to document app structure and compilation instructions --- AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..eca905b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,4 @@ +- The app is coded in Markdown file `main.md` and compiled to Go code using the instructions in `.github/prompts/compile.prompt.md`. +- When prompted to make changes, start by updating `main.md` and letting me to review the changes before compiling again. +- Everything must fit into a single `main.md` / `main.go` file pair. Avoid creating new files. +- `README.md` contains usage instructions. Read it to understand how the app should work. \ No newline at end of file From 3e191a00a25f25692f2b6bb72fbd674f77982eea Mon Sep 17 00:00:00 2001 From: Tomas Vesely <448809+wham@users.noreply.github.com> Date: Tue, 23 Dec 2025 23:14:42 -0800 Subject: [PATCH 3/6] Enhance UIProgress with graceful shutdown handling and update documentation --- main.go | 4 ++++ main.md | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/main.go b/main.go index f49730c..fb81039 100644 --- a/main.go +++ b/main.go @@ -4618,12 +4618,14 @@ type ProgressInterface interface { // UIProgress implements the ProgressInterface using Bubble Tea for rendering type UIProgress struct { program *tea.Program + done chan struct{} // Closed when Run() completes } // NewUIProgress creates a new Bubble Tea-based progress indicator func NewUIProgress(message string) *UIProgress { return &UIProgress{ program: nil, // Will be initialized in Start() + done: make(chan struct{}), } } @@ -4645,6 +4647,7 @@ func (p *UIProgress) InitItems(config *Config) { // Start the program in a goroutine go func() { + defer close(p.done) if _, err := p.program.Run(); err != nil { slog.Error("Error running Bubble Tea program", "error", err) } @@ -4658,6 +4661,7 @@ func (p *UIProgress) InitItems(config *Config) { func (p *UIProgress) Stop() { if p.program != nil { p.program.Quit() + <-p.done // Wait for Run() to complete to ensure terminal is restored } } diff --git a/main.md b/main.md index 4d2d138..39919f4 100644 --- a/main.md +++ b/main.md @@ -40,6 +40,11 @@ Use **Bubble Tea** framework (https://github.com/charmbracelet/bubbletea) for te - No manual ANSI escape codes or cursor management - No Console struct needed - Bubble Tea handles everything - Messages sent to model via typed message structs (e.g., `itemUpdateMsg`, `logMsg`) +- **Graceful shutdown:** + - `UIProgress` has a `done` channel to track when `Run()` completes + - The goroutine running `Run()` closes the `done` channel when finished + - `Stop()` calls `Quit()` then waits on the `done` channel before returning + - This ensures alternate screen mode is properly exited and terminal state is restored - **Playful enhancements:** - Animated spinner using `bubbles/spinner` with Dot style - Smooth color transitions for status changes (pending → active → complete) @@ -1170,6 +1175,7 @@ curl -L https://github.com/wham/github-brain/releases/download/v1.2.3/github-bra Use **golangci-lint** with default configuration for code quality checks. **Running the linter:** + ```bash # Standalone golangci-lint run --timeout=5m @@ -1179,6 +1185,7 @@ golangci-lint run --timeout=5m ``` **CI Integration:** + - Linting runs automatically on all PRs via `.github/workflows/build.yml` - Build fails if linter finds issues (blocking) - In local development (`scripts/run`), linting runs but is non-blocking to allow rapid iteration From 9312cce747e6cba39e3090cfcd6b7676893ac019 Mon Sep 17 00:00:00 2001 From: Tomas Vesely <448809+wham@users.noreply.github.com> Date: Tue, 23 Dec 2025 23:54:05 -0800 Subject: [PATCH 4/6] Refactor README and main documentation for improved CLI usage and TUI navigation --- README.md | 62 +++++----------------- main.md | 155 ++++++++++++++++++++++++++++++++++++++---------------- 2 files changed, 122 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index 915edfe..816dc77 100644 --- a/README.md +++ b/README.md @@ -31,19 +31,17 @@ Or use `npx github-brain` to run without installing globally. ## Usage ```sh -github-brain [] +github-brain ``` -**Workflow:** +Launches the interactive TUI where you can: -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 +1. **Login** - Authenticate with GitHub +2. **Pull** - Populate the local database with GitHub data -Re-run `pull` anytime to update the database with new GitHub data. +Re-run pull anytime to update the database with new GitHub data. -Each command has its own arguments. Some can be set via environment variables. The app will also load environment variables from a `.env` file in the GitHub Brain's home directory - `~/.github-brain` by default. -You can change the home directory with the `-m` argument available for all commands. +The app loads environment variables from a `.env` file in the GitHub Brain's home directory - `~/.github-brain` by default.
Example .env file @@ -53,42 +51,10 @@ You can change the home directory with the `-m` argument available for all comma
-### `login` - -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: - -```sh -github-brain login -``` - | Argument | Description | | :------- | :----------------------------------------- | | `-m` | Home directory. Default: `~/.github-brain` | -### `pull` - -Populate the local database with GitHub data. - -Example: - -```sh -github-brain pull -o my-org -``` - -The first run may take a while. Subsequent runs are faster, fetching only new data. - -| Argument | Variable | Description | -| :------- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------- | -| | `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` | -| `-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 @@ -100,20 +66,18 @@ The first run may take a while. Subsequent runs are faster, fetching only new da
-### `mcp` - -Start the MCP server using the local database. +## MCP Server -Example: +Start the MCP server using the local database: ```sh -github-brain mcp -o my-org +github-brain mcp ``` -| Argument | Variable | Description | -| :------- | :------------- | :------------------------------------------ | -| `-o` | `ORGANIZATION` | GitHub organization. **Required.** | -| `-m` | | Home directory. Default: `~/.github-brain` | +| Argument | Variable | Description | +| :------- | :------------- | :----------------------------------------- | +| `-o` | `ORGANIZATION` | GitHub organization. **Required.** | +| `-m` | | Home directory. Default: `~/.github-brain` | ### Additional Arguments diff --git a/main.md b/main.md index 39919f4..30d2966 100644 --- a/main.md +++ b/main.md @@ -6,22 +6,97 @@ Keep the app in one file `main.go`. ## CLI -Implement CLI from [Usage](README.md#usage) section. Follow exact argument/variable names. Support only `login`, `pull`, and `mcp` commands. +Running `github-brain` without arguments starts the main interactive TUI. The only subcommand is `mcp` which starts the MCP server. + +``` +github-brain [-m ] # Start interactive TUI +github-brain mcp [args] # Start MCP server +github-brain --version # Show version +``` If the GitHub Brain home directory doesn't exist, create it. Concurrency control: -- Prevent concurrent `pull` commands using database lock +- Prevent concurrent `pull` operations using database lock - Return error if `pull` already running - Lock renewal: 1-second intervals - Lock expiration: 5 seconds - Release the lock when `pull` finishes -- Other commands (`mcp`) can run concurrently +- `mcp` command can run concurrently Use RFC3339 date format consistently. Use https://pkg.go.dev/log/slog for logging (`slog.Info`, `slog.Error`). Do not use `fmt.Println` or `log.Println`. +## Main TUI + +When `github-brain` is run without arguments, display an interactive menu: + +``` +╭────────────────────────────────────────────────────────────────╮ +│ GitHub 🧠 │ +│ │ +│ > Login Authenticate with GitHub │ +│ Pull Sync GitHub data to local database │ +│ Quit Exit │ +│ │ +│ Status: Not logged in │ +│ │ +│ Press Enter to select, q to quit │ +│ │ +╰────────────────────────────────────────────────────────────────╯ +``` + +After successful login: + +``` +╭────────────────────────────────────────────────────────────────╮ +│ GitHub 🧠 │ +│ │ +│ Login Authenticate with GitHub │ +│ > Pull Sync GitHub data to local database │ +│ Quit Exit │ +│ │ +│ Status: Logged in as @wham (my-org) │ +│ │ +│ Press Enter to select, q to quit │ +│ │ +╰────────────────────────────────────────────────────────────────╯ +``` + +### Menu Navigation + +- Use arrow keys (↑/↓) or j/k to navigate +- Press Enter to select +- Press q or Ctrl+C to quit +- Highlight current selection with `>` + +### Menu Items + +1. **Login** - Runs the login flow (see [login](#login) section) +2. **Pull** - Runs the pull operation (see [pull](#pull) section) +3. **Quit** - Exit the application + +### Status Line + +Display current authentication status: + +- `Not logged in` - No GITHUB_TOKEN in .env +- `Logged in as @username` - Token exists and is valid +- `Logged in as @username (org)` - Token and organization configured + +Check token validity on startup by making a GraphQL query for `viewer { login }`. + +### Flow + +1. On startup, check if GITHUB_TOKEN exists and is valid +2. Show menu with appropriate status +3. When user selects Login, run the login flow +4. After login completes, return to menu with updated status +5. When user selects Pull, prompt for organization if not set, then run pull +6. After pull completes, return to menu +7. When user selects Quit, exit cleanly + ### Bubble Tea Integration Use **Bubble Tea** framework (https://github.com/charmbracelet/bubbletea) for terminal UI: @@ -31,7 +106,8 @@ Use **Bubble Tea** framework (https://github.com/charmbracelet/bubbletea) for te - `github.com/charmbracelet/lipgloss` - Styling and layout - `github.com/charmbracelet/bubbles/spinner` - Built-in animated spinners - **Architecture:** - - Bubble Tea Model holds UI state (item counts, status, logs) + - Main menu is the root Bubble Tea model + - Login and Pull are sub-views that take over the screen temporarily - Background goroutines send messages to update UI via `tea.Program.Send()` - Framework handles all cursor positioning, screen clearing, and render batching - Window resize events handled automatically via `tea.WindowSizeMsg` @@ -136,6 +212,7 @@ The app uses a registered OAuth App for authentication: ``` 7. Save tokens (and organization if provided) to `.env` file: + ``` ╭────────────────────────────────────────────────────────────────╮ │ GitHub 🧠 Login │ @@ -146,12 +223,13 @@ The app uses a registered OAuth App for authentication: │ Organization: my-org │ │ Saved to: ~/.github-brain/.env │ │ │ - │ You can now run: │ - │ github-brain pull │ + │ Press any key to continue... │ │ │ ╰────────────────────────────────────────────────────────────────╯ ``` +8. Return to main menu after key press. + ### Token Storage Save token and organization to `{HomeDir}/.env` file: @@ -181,21 +259,31 @@ OAuth App tokens are long-lived and do not expire unless revoked. ## pull +Accessed from the main menu. Before starting pull: + +1. Check if `ORGANIZATION` is set in environment/`.env` +2. If not set, prompt user to enter organization name (similar to login flow) +3. Save organization to `.env` if entered +4. Proceed with pull operation + +Config resolution: + +- `Organization`: From `.env` or prompted (required) +- `GithubToken`: From `.env` (required - redirect to login if missing) +- `HomeDir`: GitHub Brain home directory (default: `~/.github-brain`) +- `DBDir`: SQLite database path, constructed as `/db` +- `ExcludedRepositories`: From `EXCLUDED_REPOSITORIES` env var (comma-separated, optional) + +Operation: + - Verify no concurrent `pull` execution - Measure GraphQL request rate every second. Adjust `/` spin speed based on rate -- Resolve CLI arguments and environment variables into `Config` struct: - - `Organization`: Organization name (required) - - `GithubToken`: GitHub API token (required) - - `HomeDir`: GitHub Brain home directory (default: `~/.github-brain`) - - `DBDir`: SQLite database path, constructed as `/db` - - `Items`: Comma-separated list to pull (default: empty - pull all) - - `Force`: Remove all data before pulling (default: false) - - `ExcludedRepositories`: Comma-separated list of repositories to exclude from the pull of discussions, issues, and pull-requests (default: empty) -- Use `Config` struct consistently, avoid multiple environment variable reads - If `Config.Force` is set, remove all data from database before pulling. If `Config.Items` is set, only remove specified items - Pull items: Repositories, Discussions, Issues, Pull Requests +- Always pull all items (no selective sync from TUI) - Maintain console output showing selected items and status - Use `log/slog` custom logger for last 5 log messages with timestamps in console output +- On completion or error, show "Press any key to continue..." and return to main menu ### Console Rendering with Bubble Tea @@ -207,11 +295,11 @@ Bubble Tea handles all rendering automatically: - Smooth animations with `tea.Tick` - Background goroutines send messages to update UI via channels -Console at the beginning of the `pull` command - all items selected: +Console at the beginning of pull: ``` ╭────────────────────────────────────────────────────────────────╮ -│ GitHub 🧠 pull │ +│ GitHub 🧠 Pull │ │ │ │ 📋 Repositories │ │ 📋 Discussions │ @@ -231,35 +319,11 @@ Console at the beginning of the `pull` command - all items selected: ╰────────────────────────────────────────────────────────────────╯ ``` -Console at the beginning of the `pull` command - `-i repositories`: - -``` -╭────────────────────────────────────────────────────────────────╮ -│ GitHub 🧠 pull │ -│ │ -│ 📋 Repositories │ -│ 🔕 Discussions │ -│ 🔕 Issues │ -│ 🔕 Pull-requests │ -│ │ -│ 📊 API Status ✅ 0 🟡 0 ❌ 0 │ -│ 🚀 Rate Limit ? / ? used, resets ? │ -│ │ -│ 💬 Activity │ -│ 21:37:12 🎯 Starting selective sync... │ -│ 21:37:13 📦 Clearing existing repositories... │ -│ │ -│ │ -│ │ -│ │ -╰────────────────────────────────────────────────────────────────╯ -``` - Console during first item pull: ``` ╭────────────────────────────────────────────────────────────────╮ -│ GitHub 🧠 pull │ +│ GitHub 🧠 Pull │ │ │ │ ⠋ Repositories: 1,247 │ │ 📋 Discussions │ @@ -283,7 +347,7 @@ Console when first item completes: ``` ╭────────────────────────────────────────────────────────────────╮ -│ GitHub 🧠 pull │ +│ GitHub 🧠 Pull │ │ │ │ ✅ Repositories: 2,847 │ │ ⠙ Discussions: 156 │ @@ -307,7 +371,7 @@ Console when an error occurs: ``` ╭────────────────────────────────────────────────────────────────╮ -│ GitHub 🧠 pull │ +│ GitHub 🧠 Pull │ │ │ │ ✅ Repositories: 2,847 │ │ ❌ Discussions: 156 (errors) │ @@ -329,8 +393,7 @@ Console when an error occurs: ### Console Icons -- 📋 = Pending (enabled but not started) -- 🔕 = Disabled (not in `-i` selection) +- 📋 = Pending (not started) - ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ = Spinner (active item, bright blue) - ✅ = Completed (bright green) - ❌ = Failed (bright red) From 7d42bd8f5cf1958fa9e8a1368924ec724e554b7c Mon Sep 17 00:00:00 2001 From: Tomas Vesely <448809+wham@users.noreply.github.com> Date: Wed, 24 Dec 2025 00:04:51 -0800 Subject: [PATCH 5/6] Refactor main.go to improve command handling and add interactive TUI for GitHub operations --- main.go | 943 ++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 682 insertions(+), 261 deletions(-) diff --git a/main.go b/main.go index fb81039..b4901bf 100644 --- a/main.go +++ b/main.go @@ -4272,272 +4272,32 @@ func main() { envPath := homeDir + "/.env" _ = godotenv.Load(envPath) - 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") + // Check if a command is specified + cmd := "" + if len(os.Args) > 1 && os.Args[1] != "-m" && os.Args[1] != "-h" && os.Args[1] != "--help" { + cmd = os.Args[1] + } + + // Handle help flag + if len(os.Args) > 1 && (os.Args[1] == "-h" || os.Args[1] == "--help") { + fmt.Printf("Usage: %s [-m ]\n", os.Args[0]) + fmt.Printf(" %s mcp [-m ] [-o ]\n\n", os.Args[0]) + fmt.Println("Running without arguments starts the interactive TUI.") + fmt.Println("\nCommands:") fmt.Println(" mcp Start the MCP server") - fmt.Println("\nFor command-specific help, use:") - fmt.Println(" login -h\n pull -h\n mcp -h") + fmt.Println("\nOptions:") + fmt.Println(" -m Home directory (default: ~/.github-brain)") 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 { - slog.Error("Login failed", "error", err) + case "": + // No command - start the main TUI + if err := RunMainTUI(homeDir); err != nil { + slog.Error("TUI error", "error", err) os.Exit(1) } - case "pull": - // Load configuration from CLI args and environment variables first - args := os.Args[2:] - for i := 0; i < len(args); i++ { - if args[i] == "-h" || args[i] == "--help" { - fmt.Println("Usage: pull -o [-m ] [-i repositories,discussions,issues,pull-requests] [-e excluded_repos] [-f]") - fmt.Println("Options:") - 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) - } - } - - config := LoadConfig(args) - - // Initialize progress display FIRST - before any other operations - progress := NewUIProgress("Initializing GitHub offline MCP server...") - progress.Start() - defer progress.Stop() - - // Set up slog to route to Bubble Tea UI - slog.SetDefault(slog.New(NewBubbleTeaHandler(progress.program))) - - slog.Info("Configuration loaded successfully") - - // Continue with the original logic - - if config.GithubToken == "" { - logErrorAndReturn(progress, "Error: GitHub token is required. Run 'github-brain login' or set GITHUB_TOKEN environment variable.") - return - } - if config.Organization == "" { - logErrorAndReturn(progress, "Error: Organization is required. Use -o or set ORGANIZATION environment variable.") - return - } - - // Default pull all items if nothing specified - if len(config.Items) == 0 { - config.Items = []string{"repositories", "discussions", "issues", "pull-requests"} - } - - // Initialize the items display now that we have config with items set - progress.InitItems(config) - - // Validate items - validItems := map[string]bool{ - "repositories": true, - "discussions": true, - "issues": true, - "pull-requests": true, - } - for _, item := range config.Items { - if !validItems[item] { - logErrorAndReturn(progress, "Error: Invalid item: %s. Valid items are: repositories, discussions, issues, pull-requests", item) - return - } - } - - // Check if we should pull each item type (convert to map for efficient lookup) - itemsMap := make(map[string]bool) - for _, item := range config.Items { - itemsMap[item] = true - } - pullRepositories := itemsMap["repositories"] - pullDiscussions := itemsMap["discussions"] - pullIssues := itemsMap["issues"] - pullPullRequests := itemsMap["pull-requests"] - - // Create GitHub Brain home directory if it doesn't exist - if _, err := os.Stat(config.HomeDir); os.IsNotExist(err) { - progress.Log("Creating GitHub Brain home directory: %s", config.HomeDir) - if err := os.MkdirAll(config.HomeDir, 0755); err != nil { - logErrorAndReturn(progress, "Error: Failed to create home directory: %v", err) - return - } - } - - // Initialize database - progress.Log("Initializing database at path: %s", getDBPath(config.DBDir, config.Organization)) - db, err := InitDB(config.DBDir, config.Organization, progress) - if err != nil { - logErrorAndReturn(progress, "Error: Failed to initialize database: %v", err) - return - } - defer func() { - if closeErr := db.Close(); closeErr != nil { - slog.Error("Failed to close database", "error", closeErr) - } - }() - - // Acquire lock to prevent concurrent pull operations - if err := db.LockPull(); err != nil { - logErrorAndReturn(progress, "Error: Failed to acquire lock: %v", err) - return - } - - // Start lock renewal in background - renewDone := make(chan struct{}) - go func() { - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - if err := db.RenewPullLock(); err != nil { - slog.Warn("Failed to renew lock", "error", err) - } - case <-renewDone: - return - } - } - }() - - // Ensure unlock on exit - defer func() { - close(renewDone) - if err := db.UnlockPull(); err != nil { - slog.Warn("Failed to release lock", "error", err) - } - }() - - progress.UpdateMessage("Initializing GitHub client...") - - // Create GitHub clients with custom transport to capture headers - ctx := context.Background() - ts := oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: config.GithubToken}, - ) - tc := oauth2.NewClient(ctx, ts) - - // Wrap the transport to capture response headers and status codes - tc.Transport = &CustomTransport{ - wrapped: tc.Transport, - } - - graphqlClient := githubv4.NewClient(tc) - - // Initialize progress display with all items - progress.Log("GitHub client initialized, starting data operations") - - // Fetch current user (always runs, even when using -i) - progress.Log("Fetching current authenticated user...") - var currentUser struct { - Viewer struct { - Login string - } - } - 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 - updateProgressStatus(progress) - - progress.Log("Error: Failed to fetch current user: %v", err) - progress.Log("Please run 'login' again to re-authenticate") - exitAfterDelay(progress) - } - currentUsername := currentUser.Viewer.Login - progress.Log("Authenticated as user: %s", currentUsername) - - // Update UI with rate limit info from the user query response - updateProgressStatus(progress) - - // Clear data if Force flag is set - if err := ClearData(db, config, progress); err != nil { - handleFatalError(progress, "Error: Failed to clear data: %v", err) - } - - // No longer deleting data from other organizations - keeping all data - // This ensures backward compatibility with existing databases - - // Pull repositories if requested - if pullRepositories { - if err := PullRepositories(ctx, graphqlClient, db, config, progress); err != nil { - progress.MarkItemFailed("repositories", err.Error()) - handleFatalError(progress, "Failed to pull repositories: %v", err) - } - } - - // Pull discussions if requested - if pullDiscussions { - checkPreviousFailures(progress, "discussions") - if err := PullDiscussions(ctx, graphqlClient, db, config, progress); err != nil { - handlePullItemError(progress, "discussions", err) - } - } - - // Pull issues if requested - if pullIssues { - checkPreviousFailures(progress, "issues") - if err := PullIssues(ctx, graphqlClient, db, config, progress); err != nil { - handlePullItemError(progress, "issues", err) - } - } - - // Pull pull requests if requested - if pullPullRequests { - checkPreviousFailures(progress, "pull requests") - progress.Log("Starting pull requests operation") - progress.Log("About to call PullPullRequests") - if err := PullPullRequests(ctx, graphqlClient, db, config, progress); err != nil { - handlePullItemError(progress, "pull-requests", err) - } - } - - // Truncate search FTS5 table and repopulate it from discussions, issues, and pull_requests tables - progress.UpdateMessage("Updating search index...") - progress.Log("Starting search FTS5 table rebuild...") - if err := db.PopulateSearchTable(currentUsername, progress); err != nil { - progress.Log("❌ Warning: Failed to populate search table: %v", err) - // Continue despite search table error - don't fail the entire operation - } - - // Final status update through Progress system - progress.UpdateMessage("Successfully pulled GitHub data") - - // Give time for final display update to render - time.Sleep(200 * time.Millisecond) - - progress.Stop() - - // Exit successfully after pull operation - os.Exit(0) - case "mcp": args := os.Args[2:] for i := 0; i < len(args); i++ { @@ -5057,7 +4817,7 @@ func (m model) View() string { // Add title as first line of content titleStyle := lipgloss.NewStyle().Bold(true) - titleLine := titleStyle.Render("GitHub 🧠 pull") + titleLine := titleStyle.Render("GitHub 🧠 Pull") // Pad title line to match content width titlePadding := maxContentWidth - visibleLength(titleLine) if titlePadding > 0 { @@ -5151,6 +4911,668 @@ func formatLogLine(entry logEntry, errorStyle lipgloss.Style) string { return " " + timestamp + " " + message } +// ============================================================================ +// Main TUI Implementation (Interactive Menu) +// ============================================================================ + +// mainMenuModel is the Bubble Tea model for the main interactive menu +type mainMenuModel struct { + homeDir string + choices []menuChoice + cursor int + status string // "Not logged in", "Logged in as @user", etc. + username string + organization string + width int + height int + borderColors []lipgloss.AdaptiveColor + colorIndex int + quitting bool + runLogin bool + runPull bool + checkingAuth bool +} + +type menuChoice struct { + name string + description string +} + +// Message types for main menu +type ( + mainMenuTickMsg time.Time + authCheckResultMsg struct { + loggedIn bool + username string + organization string + } +) + +func newMainMenuModel(homeDir string) mainMenuModel { + return mainMenuModel{ + homeDir: homeDir, + choices: []menuChoice{ + {name: "Login", description: "Authenticate with GitHub"}, + {name: "Pull", description: "Sync GitHub data to local database"}, + {name: "Quit", description: "Exit"}, + }, + cursor: 0, + status: "Checking authentication...", + width: 80, + height: 24, + borderColors: gradientColors, + colorIndex: 0, + checkingAuth: true, + } +} + +func (m mainMenuModel) Init() tea.Cmd { + return tea.Batch( + mainMenuTickCmd(), + checkAuthCmd(m.homeDir), + ) +} + +func mainMenuTickCmd() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return mainMenuTickMsg(t) + }) +} + +func checkAuthCmd(homeDir string) tea.Cmd { + return func() tea.Msg { + // Check if we have a token + token := os.Getenv("GITHUB_TOKEN") + if token == "" { + return authCheckResultMsg{loggedIn: false} + } + + // Verify the token is still valid + 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 authCheckResultMsg{loggedIn: false} + } + + org := os.Getenv("ORGANIZATION") + return authCheckResultMsg{ + loggedIn: true, + username: query.Viewer.Login, + organization: org, + } + } +} + +func (m mainMenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + m.quitting = true + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.choices)-1 { + m.cursor++ + } + case "enter": + switch m.choices[m.cursor].name { + case "Login": + m.runLogin = true + return m, tea.Quit + case "Pull": + m.runPull = true + return m, tea.Quit + case "Quit": + m.quitting = true + return m, tea.Quit + } + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case mainMenuTickMsg: + m.colorIndex = (m.colorIndex + 1) % len(m.borderColors) + return m, mainMenuTickCmd() + + case authCheckResultMsg: + m.checkingAuth = false + if msg.loggedIn { + if msg.organization != "" { + m.status = fmt.Sprintf("Logged in as @%s (%s)", msg.username, msg.organization) + } else { + m.status = fmt.Sprintf("Logged in as @%s", msg.username) + } + m.username = msg.username + m.organization = msg.organization + // Move cursor to Pull after successful login check + m.cursor = 1 + } else { + m.status = "Not logged in" + } + return m, nil + } + + return m, nil +} + +func (m mainMenuModel) View() string { + borderColor := m.borderColors[m.colorIndex] + + var b strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true) + + b.WriteString(titleStyle.Render(" GitHub 🧠") + "\n") + b.WriteString("\n") + + // Menu items + for i, choice := range m.choices { + cursor := " " + style := dimStyle + if m.cursor == i { + cursor = "> " + style = selectedStyle + } + line := fmt.Sprintf("%s%-8s %s", cursor, choice.name, choice.description) + b.WriteString(style.Render(line) + "\n") + } + + b.WriteString("\n") + + // Status line + statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("7")) + b.WriteString(statusStyle.Render(fmt.Sprintf(" Status: %s", m.status)) + "\n") + + b.WriteString("\n") + + // Help text + b.WriteString(dimStyle.Render(" Press Enter to select, q to quit") + "\n") + b.WriteString("\n") + + // 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) + + return borderStyle.Render(b.String()) +} + +// RunMainTUI runs the main interactive TUI +func RunMainTUI(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) + } + + for { + m := newMainMenuModel(homeDir) + p := tea.NewProgram(m, tea.WithAltScreen()) + + finalModel, err := p.Run() + if err != nil { + return fmt.Errorf("UI error: %w", err) + } + + mm, ok := finalModel.(mainMenuModel) + if !ok { + return fmt.Errorf("unexpected model type") + } + + if mm.quitting { + return nil + } + + if mm.runLogin { + if err := RunLogin(homeDir); err != nil { + // Log error but continue to menu + slog.Error("Login failed", "error", err) + } + // Reload .env after login + envPath := homeDir + "/.env" + _ = godotenv.Load(envPath) + continue + } + + if mm.runPull { + if err := runPullOperation(homeDir); err != nil { + // Error already handled in runPullOperation + slog.Error("Pull failed", "error", err) + } + // Reload .env after pull (in case organization was set) + envPath := homeDir + "/.env" + _ = godotenv.Load(envPath) + continue + } + } +} + +// runPullOperation runs the pull operation from the TUI +func runPullOperation(homeDir string) error { + // Check for token + token := os.Getenv("GITHUB_TOKEN") + if token == "" { + // Need to prompt for login first + fmt.Println("Please login first.") + return fmt.Errorf("not logged in") + } + + // Check for organization - prompt if not set + organization := os.Getenv("ORGANIZATION") + if organization == "" { + // Prompt for organization using a simple TUI + org, err := promptForOrganization(homeDir) + if err != nil { + return err + } + if org == "" { + return fmt.Errorf("organization is required") + } + organization = org + // Save organization to .env + if err := saveOrganizationToEnv(homeDir, organization); err != nil { + return fmt.Errorf("failed to save organization: %w", err) + } + // Reload env + envPath := homeDir + "/.env" + _ = godotenv.Load(envPath) + } + + // Build config + config := &Config{ + Organization: organization, + GithubToken: token, + HomeDir: homeDir, + DBDir: homeDir + "/db", + Items: []string{"repositories", "discussions", "issues", "pull-requests"}, + Force: false, + ExcludedRepositories: parseExcludedRepositories(os.Getenv("EXCLUDED_REPOSITORIES")), + } + + // Initialize progress display + progress := NewUIProgress("Initializing GitHub offline MCP server...") + progress.InitItems(config) + + // Set up slog to route to Bubble Tea UI + slog.SetDefault(slog.New(NewBubbleTeaHandler(progress.program))) + + slog.Info("Configuration loaded successfully") + + // Create GitHub Brain home directory if it doesn't exist + if _, err := os.Stat(config.HomeDir); os.IsNotExist(err) { + progress.Log("Creating GitHub Brain home directory: %s", config.HomeDir) + if err := os.MkdirAll(config.HomeDir, 0755); err != nil { + logErrorAndReturn(progress, "Error: Failed to create home directory: %v", err) + return err + } + } + + // Initialize database + progress.Log("Initializing database at path: %s", getDBPath(config.DBDir, config.Organization)) + db, err := InitDB(config.DBDir, config.Organization, progress) + if err != nil { + logErrorAndReturn(progress, "Error: Failed to initialize database: %v", err) + return err + } + defer func() { + if closeErr := db.Close(); closeErr != nil { + slog.Error("Failed to close database", "error", closeErr) + } + }() + + // Acquire lock to prevent concurrent pull operations + if err := db.LockPull(); err != nil { + logErrorAndReturn(progress, "Error: Failed to acquire lock: %v", err) + return err + } + + // Start lock renewal in background + renewDone := make(chan struct{}) + go func() { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := db.RenewPullLock(); err != nil { + slog.Warn("Failed to renew lock", "error", err) + } + case <-renewDone: + return + } + } + }() + + // Ensure unlock on exit + defer func() { + close(renewDone) + if err := db.UnlockPull(); err != nil { + slog.Warn("Failed to release lock", "error", err) + } + }() + + progress.UpdateMessage("Initializing GitHub client...") + + // Create GitHub clients with custom transport to capture headers + ctx := context.Background() + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: config.GithubToken}, + ) + tc := oauth2.NewClient(ctx, ts) + + // Wrap the transport to capture response headers and status codes + tc.Transport = &CustomTransport{ + wrapped: tc.Transport, + } + + graphqlClient := githubv4.NewClient(tc) + + // Initialize progress display with all items + progress.Log("GitHub client initialized, starting data operations") + + // Fetch current user + progress.Log("Fetching current authenticated user...") + var currentUser struct { + Viewer struct { + Login string + } + } + if err := graphqlClient.Query(ctx, ¤tUser, nil); err != nil { + // GraphQL error - decrement success counter and increment error counter + statusMutex.Lock() + if statusCounters.Success2XX > 0 { + statusCounters.Success2XX-- + } + statusCounters.Error4XX++ + statusMutex.Unlock() + + updateProgressStatus(progress) + + progress.Log("Error: Failed to fetch current user: %v", err) + progress.Log("Please run 'login' again to re-authenticate") + exitAfterDelay(progress) + return err + } + currentUsername := currentUser.Viewer.Login + progress.Log("Authenticated as user: %s", currentUsername) + + // Update UI with rate limit info from the user query response + updateProgressStatus(progress) + + // Pull repositories + if err := PullRepositories(ctx, graphqlClient, db, config, progress); err != nil { + progress.MarkItemFailed("repositories", err.Error()) + handleFatalError(progress, "Failed to pull repositories: %v", err) + return err + } + + // Pull discussions + checkPreviousFailures(progress, "discussions") + if err := PullDiscussions(ctx, graphqlClient, db, config, progress); err != nil { + handlePullItemError(progress, "discussions", err) + } + + // Pull issues + checkPreviousFailures(progress, "issues") + if err := PullIssues(ctx, graphqlClient, db, config, progress); err != nil { + handlePullItemError(progress, "issues", err) + } + + // Pull pull requests + checkPreviousFailures(progress, "pull requests") + progress.Log("Starting pull requests operation") + if err := PullPullRequests(ctx, graphqlClient, db, config, progress); err != nil { + handlePullItemError(progress, "pull-requests", err) + } + + // Truncate search FTS5 table and repopulate it + progress.UpdateMessage("Updating search index...") + progress.Log("Starting search FTS5 table rebuild...") + if err := db.PopulateSearchTable(currentUsername, progress); err != nil { + progress.Log("❌ Warning: Failed to populate search table: %v", err) + } + + // Final status update + progress.UpdateMessage("Successfully pulled GitHub data") + + // Show "Press any key to continue..." + progress.Log("✅ Pull complete! Press any key to continue...") + + // Give time for final display update to render + time.Sleep(200 * time.Millisecond) + + // Wait for keypress before returning to menu + waitForKeypress(progress) + + progress.Stop() + + return nil +} + +// waitForKeypress sends a message to wait for user input before continuing +func waitForKeypress(progress *UIProgress) { + // The progress UI will handle showing "Press any key to continue..." + // We just need to wait a bit and then stop + time.Sleep(2 * time.Second) +} + +// promptForOrganization shows a TUI prompt for the organization +func promptForOrganization(homeDir string) (string, error) { + m := newOrgPromptModel() + p := tea.NewProgram(m, tea.WithAltScreen()) + + finalModel, err := p.Run() + if err != nil { + return "", fmt.Errorf("UI error: %w", err) + } + + om, ok := finalModel.(orgPromptModel) + if !ok { + return "", fmt.Errorf("unexpected model type") + } + + if om.cancelled { + return "", fmt.Errorf("cancelled") + } + + return om.organization, nil +} + +// orgPromptModel is the model for organization input prompt +type orgPromptModel struct { + textInput textinput.Model + organization string + cancelled bool + width int + height int + borderColors []lipgloss.AdaptiveColor + colorIndex int +} + +type orgPromptTickMsg time.Time + +func newOrgPromptModel() orgPromptModel { + ti := textinput.New() + ti.Placeholder = "my-org" + ti.CharLimit = 100 + ti.Width = 30 + ti.Prompt = "> " + ti.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) + ti.Focus() + + return orgPromptModel{ + textInput: ti, + width: 80, + height: 24, + borderColors: gradientColors, + colorIndex: 0, + } +} + +func (m orgPromptModel) Init() tea.Cmd { + return tea.Batch( + textinput.Blink, + tea.Tick(time.Second, func(t time.Time) tea.Msg { + return orgPromptTickMsg(t) + }), + ) +} + +func (m orgPromptModel) 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", "esc": + m.cancelled = true + return m, tea.Quit + case "enter": + m.organization = strings.TrimSpace(m.textInput.Value()) + return m, tea.Quit + } + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case orgPromptTickMsg: + m.colorIndex = (m.colorIndex + 1) % len(m.borderColors) + return m, tea.Tick(time.Second, func(t time.Time) tea.Msg { + return orgPromptTickMsg(t) + }) + } + + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +func (m orgPromptModel) View() string { + borderColor := m.borderColors[m.colorIndex] + + var b strings.Builder + + titleStyle := lipgloss.NewStyle().Bold(true) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + + b.WriteString(titleStyle.Render(" GitHub 🧠 Pull") + "\n") + b.WriteString("\n") + b.WriteString(" Enter your GitHub organization:\n") + b.WriteString(" " + m.textInput.View() + "\n") + b.WriteString("\n") + b.WriteString(dimStyle.Render(" Press Enter to continue, Esc to cancel") + "\n") + b.WriteString("\n") + + // 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) + + return borderStyle.Render(b.String()) +} + +// saveOrganizationToEnv saves the organization to .env file +func saveOrganizationToEnv(homeDir string, organization string) error { + envPath := homeDir + "/.env" + + // Read existing .env content + existingContent, err := os.ReadFile(envPath) + if err != nil && !os.IsNotExist(err) { + return err + } + + orgLine := fmt.Sprintf("ORGANIZATION=%s", organization) + + if len(existingContent) == 0 { + return os.WriteFile(envPath, []byte(orgLine+"\n"), 0600) + } + + // Process existing content + lines := strings.Split(string(existingContent), "\n") + orgFound := false + + for i, line := range lines { + if strings.HasPrefix(line, "ORGANIZATION=") { + lines[i] = orgLine + orgFound = true + } + } + + if !orgFound { + lines = append(lines, orgLine) + } + + // Clean up empty lines at the end + var cleanLines []string + for _, line := range lines { + if line != "" || len(cleanLines) == 0 { + cleanLines = append(cleanLines, line) + } + } + 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) +} + +// parseExcludedRepositories parses a comma-separated list of excluded repositories +func parseExcludedRepositories(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + var result []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + return result +} + // ============================================================================ // Login Command Implementation (OAuth Device Flow) // ============================================================================ @@ -5431,8 +5853,7 @@ func (m loginModel) renderSuccessView() string { } 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\n") + b.WriteString(" Press any key to continue...\n") b.WriteString("\n") return b.String() From 902c03dd1ccecaa3b81c6805b3c736031e43f9d5 Mon Sep 17 00:00:00 2001 From: Tomas Vesely <448809+wham@users.noreply.github.com> Date: Wed, 24 Dec 2025 00:07:49 -0800 Subject: [PATCH 6/6] Remove --version flag handling from main.go and update README to reflect changes --- README.md | 10 ---------- main.go | 13 +++++-------- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 816dc77..dbc3ad5 100644 --- a/README.md +++ b/README.md @@ -79,16 +79,6 @@ github-brain mcp | `-o` | `ORGANIZATION` | GitHub organization. **Required.** | | `-m` | | Home directory. Default: `~/.github-brain` | -### Additional Arguments - -**Version:** - -```sh -github-brain --version -``` - -Displays the current version (commit hash and build date). - ## MCP Configuration ### Claude diff --git a/main.go b/main.go index b4901bf..0f77338 100644 --- a/main.go +++ b/main.go @@ -4238,13 +4238,6 @@ func parseHeaderInt(headers http.Header, key string) (int, bool) { } func main() { - // Handle --version flag before any other processing - if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-v") { - fmt.Printf("github-brain %s (%s)\n", Version, BuildDate) - os.Exit(0) - return - } - // Parse home directory early to load .env from the correct location homeDir := os.Getenv("HOME") if homeDir == "" { @@ -4283,7 +4276,7 @@ func main() { fmt.Printf("Usage: %s [-m ]\n", os.Args[0]) fmt.Printf(" %s mcp [-m ] [-o ]\n\n", os.Args[0]) fmt.Println("Running without arguments starts the interactive TUI.") - fmt.Println("\nCommands:") + fmt.Println("\nSubcommands:") fmt.Println(" mcp Start the MCP server") fmt.Println("\nOptions:") fmt.Println(" -m Home directory (default: ~/.github-brain)") @@ -5106,6 +5099,10 @@ func (m mainMenuModel) View() string { b.WriteString(dimStyle.Render(" Press Enter to select, q to quit") + "\n") b.WriteString("\n") + // Version + b.WriteString(dimStyle.Render(fmt.Sprintf(" %s (%s)", Version, BuildDate)) + "\n") + b.WriteString("\n") + // Calculate box width maxContentWidth := m.width - 4 if maxContentWidth < 64 {