diff --git a/.github/skills/testing/SKILL.md b/.github/skills/testing/SKILL.md index 89087ad..7c9c2c3 100644 --- a/.github/skills/testing/SKILL.md +++ b/.github/skills/testing/SKILL.md @@ -18,9 +18,10 @@ Use this skill: ## 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` +- Run `scripts/run --test` where the user would normally run `github-brain` + - `scripts/run --test pull` equivalently runs `github-brain pull` + - `scripts/run --test mcp` equivalently runs `github-brain mcp` + - The `--test` flag runs `go vet` before building to catch issues early - 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. diff --git a/main.go b/main.go index 7873cdf..d6461dd 100644 --- a/main.go +++ b/main.go @@ -4960,7 +4960,7 @@ type authCheckResultMsg struct { organization string } -func newMainMenuModel(homeDir string) mainMenuModel { +func newMainMenuModel(homeDir string, cursor int) mainMenuModel { return mainMenuModel{ homeDir: homeDir, choices: []menuChoice{ @@ -4968,7 +4968,7 @@ func newMainMenuModel(homeDir string) mainMenuModel { {icon: "๐Ÿ”ง", name: "Setup", description: "Configure GitHub username and organization"}, {icon: "๐Ÿšช", name: "Exit", description: "Ctrl+C"}, }, - cursor: 0, + cursor: cursor, status: "Checking authentication...", width: 80, height: 24, @@ -5116,8 +5116,9 @@ func RunMainTUI(homeDir string) error { return fmt.Errorf("failed to create home directory: %w", err) } + cursor := 0 // Remember cursor position across menu returns for { - m := newMainMenuModel(homeDir) + m := newMainMenuModel(homeDir, cursor) p := tea.NewProgram(m, tea.WithAltScreen()) finalModel, err := p.Run() @@ -5130,12 +5131,17 @@ func RunMainTUI(homeDir string) error { return fmt.Errorf("unexpected model type") } + cursor = mm.cursor // Remember cursor position + if mm.quitting { return nil } if mm.runSetup { if err := RunSetupMenu(homeDir, mm.username, mm.organization); err != nil { + if err.Error() == "quit" { + return nil // Exit app cleanly + } // Log error but continue to menu slog.Error("Setup failed", "error", err) } @@ -5554,19 +5560,21 @@ type AccessTokenResponse struct { // loginModel is the Bubble Tea model for the login UI type loginModel struct { - spinner spinner.Model - textInput textinput.Model - userCode string - verificationURI string - status string // "waiting", "org_input", "success", "error" - errorMsg string - username string - token string - organization string - homeDir string - width int - height int - done bool + spinner spinner.Model + textInput textinput.Model + userCode string + verificationURI string + status string // "waiting", "org_input", "success", "error" + errorMsg string + username string + token string + organization string + homeDir string + width int + height int + done bool + currentUsername string // current logged-in username for title bar + currentOrg string // current organization for title bar } // Login message types @@ -5584,7 +5592,7 @@ type ( loginOrgSubmittedMsg struct{} ) -func newLoginModel(homeDir string) loginModel { +func newLoginModel(homeDir, currentUsername, currentOrg string) loginModel { s := spinner.New() s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) @@ -5597,12 +5605,14 @@ func newLoginModel(homeDir string) loginModel { ti.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) return loginModel{ - spinner: s, - textInput: ti, - status: "waiting", - homeDir: homeDir, - width: 80, - height: 24, + spinner: s, + textInput: ti, + status: "waiting", + homeDir: homeDir, + width: 80, + height: 24, + currentUsername: currentUsername, + currentOrg: currentOrg, } } @@ -5617,9 +5627,19 @@ func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "ctrl+c": + m.status = "quit" + m.done = true + return m, tea.Quit + case "esc": + m.status = "cancelled" m.done = true return m, tea.Quit case "enter": + if m.status == "waiting" { + m.status = "cancelled" + m.done = true + return m, tea.Quit + } if m.status == "org_input" { m.organization = strings.TrimSpace(m.textInput.Value()) return m, func() tea.Msg { return loginOrgSubmittedMsg{} } @@ -5728,35 +5748,40 @@ func (m loginModel) renderWaitingView() string { } innerWidth := maxContentWidth - 2 - b.WriteString(renderTitleBar("๐Ÿ”ง Setup", "", "", innerWidth) + "\n") - b.WriteString("\n") - b.WriteString("๐Ÿ” GitHub Authentication (OAuth)\n") + b.WriteString(renderTitleBar("๐Ÿ”ง Setup / โœจ Login with device", m.currentUsername, m.currentOrg, innerWidth) + "\n") b.WriteString("\n") if m.userCode == "" { b.WriteString(m.spinner.View() + " Requesting device code...\n") } else { - b.WriteString("1. Opening browser to: github.com/login/device\n") + b.WriteString("1. Opening browser to https://github.com/login/device\n") b.WriteString("\n") b.WriteString("2. Enter this code:\n") b.WriteString("\n") - // Code box with margin for alignment + // Code box with double border - gold/yellow stands out against purple codeStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("12")). - Padding(0, 3). + Border(lipgloss.DoubleBorder()). + BorderForeground(lipgloss.Color("220")). + Foreground(lipgloss.Color("220")). + Padding(0, 4). Bold(true). MarginLeft(3) b.WriteString(codeStyle.Render(m.userCode) + "\n") b.WriteString("\n") + b.WriteString("3. Grant access to the organizations you are planning to use with GitHub Brain\n") + b.WriteString("\n") b.WriteString(m.spinner.View() + " Waiting for authorization...\n") } b.WriteString("\n") - b.WriteString("Press Ctrl+C to cancel\n") - b.WriteString("\n") + + // Back menu item - always selected, same format as Setup screen + selectorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("12")) + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true) + paddedName := fmt.Sprintf("%-4s", "Back") + b.WriteString(selectorStyle.Render("โ–ถ") + " โ† " + titleStyle.Render(paddedName) + " " + selectedStyle.Render("Esc")) return b.String() } @@ -5833,14 +5858,14 @@ func (m loginModel) renderErrorView() string { } // RunLogin runs the OAuth device flow login -func RunLogin(homeDir string) error { +func RunLogin(homeDir, currentUsername, currentOrg string) error { // Ensure home directory exists if err := os.MkdirAll(homeDir, 0755); err != nil { return fmt.Errorf("failed to create home directory: %w", err) } // Create the Bubble Tea model - m := newLoginModel(homeDir) + m := newLoginModel(homeDir, currentUsername, currentOrg) p := tea.NewProgram(m, tea.WithAltScreen()) // Run the device flow in a goroutine @@ -5854,9 +5879,15 @@ func RunLogin(homeDir string) error { // Check if login was successful if lm, ok := finalModel.(loginModel); ok { + if lm.status == "quit" { + return fmt.Errorf("quit") + } if lm.status == "error" { return fmt.Errorf("%s", lm.errorMsg) } + if lm.status == "cancelled" { + return nil // Go back without error + } if lm.status != "success" { return fmt.Errorf("login cancelled") } @@ -5885,18 +5916,18 @@ type setupMenuModel struct { goBack bool } -func newSetupMenuModel(homeDir, username, organization string) setupMenuModel { +func newSetupMenuModel(homeDir, username, organization string, cursor int) setupMenuModel { return setupMenuModel{ homeDir: homeDir, username: username, organization: organization, choices: []menuChoice{ - {icon: "โœจ", name: "Login with code", description: "Recommended for organization owners"}, + {icon: "โœจ", name: "Login with device", description: "Recommended for organization owners"}, {icon: "๐Ÿ”‘", name: "Login with PAT", description: "Works without organization ownership"}, {icon: "๐Ÿ“", name: "Advanced", description: "Edit configuration file"}, - {icon: "๐Ÿ”™", name: "Back", description: "Esc"}, + {icon: "โ†", name: "Back", description: "Esc"}, }, - cursor: 0, + cursor: cursor, width: 80, height: 24, } @@ -5968,6 +5999,15 @@ func (m setupMenuModel) View() string { // Menu items - same format as Home screen selectorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("12")) // Blue selector + + // Find the longest name for alignment + maxNameWidth := 0 + for _, choice := range m.choices { + if len(choice.name) > maxNameWidth { + maxNameWidth = len(choice.name) + } + } + for i, choice := range m.choices { cursor := " " descStyle := dimStyle @@ -5975,10 +6015,12 @@ func (m setupMenuModel) View() string { cursor = selectorStyle.Render("โ–ถ") + " " descStyle = selectedStyle } - // Pad name to 15 characters for alignment - paddedName := fmt.Sprintf("%-15s", choice.name) + // Pad icon to 2 characters (emoji width) and name for alignment + iconWidth := lipgloss.Width(choice.icon) + iconPadding := strings.Repeat(" ", 2-iconWidth) + paddedName := fmt.Sprintf("%-*s", maxNameWidth, choice.name) // Name is always bold (titleStyle), description uses current selection style - b.WriteString(fmt.Sprintf("%s%s %s %s", cursor, choice.icon, titleStyle.Render(paddedName), descStyle.Render(choice.description))) + b.WriteString(fmt.Sprintf("%s%s%s %s %s", cursor, choice.icon, iconPadding, titleStyle.Render(paddedName), descStyle.Render(choice.description))) if i < len(m.choices)-1 { b.WriteString("\n\n") } @@ -5996,8 +6038,9 @@ func (m setupMenuModel) View() string { // RunSetupMenu runs the setup submenu func RunSetupMenu(homeDir, username, organization string) error { + cursor := 0 // Remember cursor position across menu returns for { - m := newSetupMenuModel(homeDir, username, organization) + m := newSetupMenuModel(homeDir, username, organization, cursor) p := tea.NewProgram(m, tea.WithAltScreen()) finalModel, err := p.Run() @@ -6010,12 +6053,21 @@ func RunSetupMenu(homeDir, username, organization string) error { return fmt.Errorf("unexpected model type") } - if sm.quitting || sm.goBack { + cursor = sm.cursor // Remember cursor position + + if sm.quitting { + return fmt.Errorf("quit") + } + + if sm.goBack { return nil } if sm.runOAuth { - if err := RunLogin(homeDir); err != nil { + if err := RunLogin(homeDir, username, organization); err != nil { + if err.Error() == "quit" { + return err // Propagate quit to exit app + } slog.Error("OAuth login failed", "error", err) } // Reload .env after login @@ -6026,6 +6078,9 @@ func RunSetupMenu(homeDir, username, organization string) error { if sm.runPAT { if err := RunPATLogin(homeDir); err != nil { + if err.Error() == "quit" { + return err // Propagate quit to exit app + } slog.Error("PAT login failed", "error", err) } // Reload .env after login @@ -6138,9 +6193,11 @@ func (m patLoginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "ctrl+c": + m.status = "quit" m.done = true return m, tea.Quit case "esc": + m.status = "cancelled" m.done = true return m, tea.Quit case "enter": @@ -6359,9 +6416,15 @@ func RunPATLogin(homeDir string) error { // Check if login was successful if pm, ok := finalModel.(patLoginModel); ok { + if pm.status == "quit" { + return fmt.Errorf("quit") + } if pm.status == "error" { return fmt.Errorf("%s", pm.errorMsg) } + if pm.status == "cancelled" { + return nil // Go back without error + } if pm.status != "success" { return fmt.Errorf("login cancelled") } diff --git a/main.md b/main.md index 4c38bb6..9124426 100644 --- a/main.md +++ b/main.md @@ -82,19 +82,21 @@ Right side components (shown only when available): - Use arrow keys (โ†‘/โ†“) or j/k to navigate - Press Enter to select -- Press Esc to go back (in submenus) -- Press Ctrl+C to quit +- Press Esc to go back one screen (in submenus and dialogs) +- Press Ctrl+C to exit the app from any screen - Highlight current selection with `โ–ถ` (blue) +- When going back, remember and restore the previous cursor position ### Menu Items -1. **๏ฟฝ Pull** - Runs the pull operation (see [pull](#pull) section) +1. **๐Ÿ”„ Pull** - Runs the pull operation (see [pull](#pull) section) 2. **๐Ÿ”ง Setup** - Opens the setup submenu (see [Setup Menu](#setup-menu) section) 3. **๐Ÿšช Exit** - Exit the application (Ctrl+C) ### Default Selection -- Always start with **Pull** selected (the first item) +- Start with **Pull** selected on first launch +- When returning from a submenu, restore the previous selection ### Flow @@ -113,19 +115,19 @@ The Setup submenu provides authentication and configuration options: โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ GitHub Brain / ๐Ÿ”ง Setup ๐Ÿ‘ค Not logged in โ”‚ โ”‚ โ”‚ -โ”‚ โ–ถ โœจ Login with code Recommended for organization owners โ”‚ +โ”‚ โ–ถ โœจ Login with device Recommended for organization owners โ”‚ โ”‚ ๐Ÿ”‘ Login with PAT Works without organization ownership โ”‚ โ”‚ ๐Ÿ“ Advanced Edit configuration file โ”‚ -โ”‚ โ†ฉ๏ธ Back Esc โ”‚ +โ”‚ โ† Back Esc โ”‚ โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ ``` ### Setup Menu Items -1. **โœจ Login with code** - Recommended for organization owners. Runs the OAuth device flow (see [OAuth Login](#oauth-login) section) +1. **โœจ Login with device** - Recommended for organization owners. Runs the OAuth device flow (see [OAuth Login](#oauth-login) section) 2. **๐Ÿ”‘ Login with PAT** - Works without organization ownership. Manually enter a PAT (see [PAT Login](#pat-login) section) 3. **๐Ÿ“ Advanced** - Edit configuration file `{HomeDir}/.env` -4. **โ†ฉ๏ธ Back** - Return to main menu (Esc) +4. **โ† Back** - Return to main menu (Esc) ### Open Configuration File (Advanced) @@ -199,21 +201,46 @@ The app uses a registered OAuth App for authentication: ``` โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ - โ”‚ GitHub ๐Ÿง  Login โ”‚ + โ”‚ GitHub Brain / ๐Ÿ”ง Setup / โœจ Login with device 1.0.0 โ”‚ + โ”‚ โ”‚ + โ”‚ 1. Opening browser to https://github.com/login/device โ”‚ + โ”‚ โ”‚ + โ”‚ 2. Enter this code: โ”‚ + โ”‚ โ”‚ + โ”‚ โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ + โ”‚ โ”‚ F934-7E83 โ”‚ โ”‚ + โ”‚ โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ โ”‚ + โ”‚ โ”‚ + โ”‚ 3. Grant access to the organizations you are planning to use โ”‚ + โ”‚ with GitHub Brain โ”‚ + โ”‚ โ”‚ + โ”‚ โ ‹ Waiting for authorization... โ”‚ โ”‚ โ”‚ - โ”‚ ๐Ÿ” GitHub Authentication (OAuth) โ”‚ + โ”‚ Press Esc to cancel โ”‚ โ”‚ โ”‚ - โ”‚ 1. Opening browser to: github.com/login/device โ”‚ + โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + ``` + + With user logged in (and organization configured): + + ``` + โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ + โ”‚ GitHub Brain / ๐Ÿ”ง Setup / โœจ Login with device ๐Ÿ‘ค @wham ยท ๐Ÿข my-org ยท 1.0.0 โ”‚ + โ”‚ โ”‚ + โ”‚ 1. Opening browser to https://github.com/login/device โ”‚ โ”‚ โ”‚ โ”‚ 2. Enter this code: โ”‚ โ”‚ โ”‚ - โ”‚ โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ - โ”‚ โ”‚ ABCD-1234 โ”‚ โ”‚ - โ”‚ โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ โ”‚ + โ”‚ โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”‚ + โ”‚ โ”‚ F934-7E83 โ”‚ โ”‚ + โ”‚ โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ โ”‚ + โ”‚ โ”‚ + โ”‚ 3. Grant access to the organizations you are planning to use โ”‚ + โ”‚ with GitHub Brain โ”‚ โ”‚ โ”‚ โ”‚ โ ‹ Waiting for authorization... โ”‚ โ”‚ โ”‚ - โ”‚ Press Ctrl+C to cancel โ”‚ + โ”‚ Press Esc to cancel โ”‚ โ”‚ โ”‚ โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ ``` diff --git a/scripts/run b/scripts/run index 2eb8312..47cd0bf 100755 --- a/scripts/run +++ b/scripts/run @@ -2,9 +2,22 @@ set -e cd "$(dirname "$0")/.." -# Run go vet (non-blocking in development) -echo "Running go vet..." -go vet ./... || echo "Warning: go vet found issues (non-blocking in development)" +# Check for --test flag +TEST_MODE=false +ARGS=() +for arg in "$@"; do + if [ "$arg" = "--test" ]; then + TEST_MODE=true + else + ARGS+=("$arg") + fi +done + +# Run go vet only in test mode +if [ "$TEST_MODE" = true ]; then + echo "Running go vet..." + go vet ./... || echo "Warning: go vet found issues" +fi # Build with FTS5 support enabled CGO_ENABLED=1 CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" CGO_LDFLAGS="-Wl,-no_warn_duplicate_libraries" go build -gcflags="all=-N -l" -o ./build/github-brain . @@ -14,7 +27,7 @@ CHECKOUT_DIR="$(pwd)" # Check if -m flag is already provided HAS_M_FLAG=false -for arg in "$@"; do +for arg in "${ARGS[@]}"; do if [ "$arg" = "-m" ]; then HAS_M_FLAG=true break @@ -23,7 +36,7 @@ done # Add -m flag only if not already provided if [ "$HAS_M_FLAG" = false ]; then - ./build/github-brain "$@" -m "$CHECKOUT_DIR" + ./build/github-brain "${ARGS[@]}" -m "$CHECKOUT_DIR" else - ./build/github-brain "$@" + ./build/github-brain "${ARGS[@]}" fi \ No newline at end of file