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). 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 diff --git a/README.md b/README.md index 915edfe..dbc3ad5 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,30 +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. - -Example: - -```sh -github-brain mcp -o my-org -``` - -| Argument | Variable | Description | -| :------- | :------------- | :------------------------------------------ | -| `-o` | `ORGANIZATION` | GitHub organization. **Required.** | -| `-m` | | Home directory. Default: `~/.github-brain` | - -### Additional Arguments +## MCP Server -**Version:** +Start the MCP server using the local database: ```sh -github-brain --version +github-brain mcp ``` -Displays the current version (commit hash and build date). +| Argument | Variable | Description | +| :------- | :------------- | :----------------------------------------- | +| `-o` | `ORGANIZATION` | GitHub organization. **Required.** | +| `-m` | | Home directory. Default: `~/.github-brain` | ## MCP Configuration diff --git a/main.go b/main.go index f49730c..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 == "" { @@ -4272,272 +4265,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("\nSubcommands:") 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++ { @@ -4618,12 +4371,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 +4400,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 +4414,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 } } @@ -5053,7 +4810,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 { @@ -5147,6 +4904,672 @@ 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") + + // 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 { + 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) // ============================================================================ @@ -5427,8 +5850,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() diff --git a/main.md b/main.md index 4d2d138..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` @@ -40,6 +116,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) @@ -131,6 +212,7 @@ The app uses a registered OAuth App for authentication: ``` 7. Save tokens (and organization if provided) to `.env` file: + ``` โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ GitHub ๐Ÿง  Login โ”‚ @@ -141,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: @@ -176,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 @@ -202,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 โ”‚ @@ -226,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 โ”‚ @@ -278,7 +347,7 @@ Console when first item completes: ``` โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ GitHub ๐Ÿง  pull โ”‚ +โ”‚ GitHub ๐Ÿง  Pull โ”‚ โ”‚ โ”‚ โ”‚ โœ… Repositories: 2,847 โ”‚ โ”‚ โ ™ Discussions: 156 โ”‚ @@ -302,7 +371,7 @@ Console when an error occurs: ``` โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ -โ”‚ GitHub ๐Ÿง  pull โ”‚ +โ”‚ GitHub ๐Ÿง  Pull โ”‚ โ”‚ โ”‚ โ”‚ โœ… Repositories: 2,847 โ”‚ โ”‚ โŒ Discussions: 156 (errors) โ”‚ @@ -324,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) @@ -1170,6 +1238,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 +1248,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