Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions cmd/goose/icons.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package main

import (
_ "embed"
"log/slog"
"os"
"path/filepath"
)

// Embed icon files at compile time for better distribution
//
//go:embed icons/goose.png
var iconGoose []byte

//go:embed icons/popper.png
var iconPopper []byte

//go:embed icons/smiling-face.png
var iconSmiling []byte

//go:embed icons/lock.png
var iconLock []byte

//go:embed icons/warning.png
var iconWarning []byte

// IconType represents different icon states

Check failure on line 27 in cmd/goose/icons.go

View workflow job for this annotation

GitHub Actions / golangci-lint

Comment should end in a period (godot)
type IconType int

const (
IconSmiling IconType = iota // No blocked PRs
IconGoose // Incoming PRs blocked
IconPopper // Outgoing PRs blocked
IconBoth // Both incoming and outgoing blocked
IconWarning // General error/warning
IconLock // Authentication error
)

// getIcon returns the icon bytes for the given type

Check failure on line 39 in cmd/goose/icons.go

View workflow job for this annotation

GitHub Actions / golangci-lint

Comment should end in a period (godot)
func getIcon(iconType IconType) []byte {
switch iconType {
case IconGoose:
return iconGoose
case IconPopper:
return iconPopper
case IconSmiling:
return iconSmiling
case IconWarning:
return iconWarning
case IconLock:
return iconLock
case IconBoth:
// For both, we'll use the goose icon as primary
return iconGoose
default:
return iconSmiling
}
}

// loadIconFromFile loads an icon from the filesystem (fallback if embed fails)

Check failure on line 60 in cmd/goose/icons.go

View workflow job for this annotation

GitHub Actions / golangci-lint

Comment should end in a period (godot)
func loadIconFromFile(filename string) []byte {
iconPath := filepath.Join("icons", filename)
data, err := os.ReadFile(iconPath)
if err != nil {
slog.Warn("Failed to load icon file", "path", iconPath, "error", err)
return nil
}
return data
}

// setTrayIcon updates the system tray icon based on PR counts

Check failure on line 71 in cmd/goose/icons.go

View workflow job for this annotation

GitHub Actions / golangci-lint

Comment should end in a period (godot)
func (app *App) setTrayIcon(iconType IconType) {
iconBytes := getIcon(iconType)
if iconBytes == nil || len(iconBytes) == 0 {
slog.Warn("Icon bytes are empty, skipping icon update", "type", iconType)
return
}

app.systrayInterface.SetIcon(iconBytes)
slog.Debug("[TRAY] Setting icon", "type", iconType)
}

Check failure on line 81 in cmd/goose/icons.go

View workflow job for this annotation

GitHub Actions / golangci-lint

File is not properly formatted (gofumpt)
Binary file added cmd/goose/icons/goose.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added cmd/goose/icons/lock.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added cmd/goose/icons/popper.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added cmd/goose/icons/smiling-face.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added cmd/goose/icons/warning.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 11 additions & 16 deletions cmd/goose/loginitem_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ func appPath() (string, error) {
}

// addLoginItemUI adds the login item menu option (macOS only).
func addLoginItemUI(ctx context.Context, _ *App) {
func addLoginItemUI(ctx context.Context, app *App) {
// Check if we're running from an app bundle
execPath, err := os.Executable()
if err != nil {
Expand All @@ -163,33 +163,28 @@ func addLoginItemUI(ctx context.Context, _ *App) {
return
}

loginItem := systray.AddMenuItem("Start at Login", "Automatically start when you log in")

// Set initial state
// Add text checkmark for consistency with other menu items
var loginText string
if isLoginItem(ctx) {
loginItem.Check()
loginText = "✓ Start at Login"
} else {
loginText = "Start at Login"
}
loginItem := systray.AddMenuItem(loginText, "Automatically start when you log in")

loginItem.Click(func() {
isEnabled := isLoginItem(ctx)
newState := !isEnabled

if err := setLoginItem(ctx, newState); err != nil {
slog.Error("Failed to set login item", "error", err)
// Revert the UI state on error
if isEnabled {
loginItem.Check()
} else {
loginItem.Uncheck()
}
return
}

// Update UI state
if newState {
loginItem.Check()
} else {
loginItem.Uncheck()
}
slog.Info("[SETTINGS] Start at Login toggled", "enabled", newState)

// Rebuild menu to update checkmark
app.rebuildMenu(ctx)
})
}
165 changes: 125 additions & 40 deletions cmd/goose/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"log/slog"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
Expand Down Expand Up @@ -90,6 +91,7 @@
updateInterval time.Duration
consecutiveFailures int
mu sync.RWMutex
menuMutex sync.Mutex // Mutex to prevent concurrent menu rebuilds
enableAutoBrowser bool
hideStaleIncoming bool
hasPerformedInitialDiscovery bool // Track if we've done the first poll to distinguish from real state changes
Expand Down Expand Up @@ -157,7 +159,7 @@
Level: logLevel,
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, opts)))
slog.Info("Starting GitHub PR Monitor", "version", version, "commit", commit, "date", date)
slog.Info("Starting Goose", "version", version, "commit", commit, "date", date)
slog.Info("Configuration", "update_interval", updateInterval, "max_retries", maxRetries, "max_delay", maxRetryDelay)
slog.Info("Browser auto-open configuration", "startup_delay", browserOpenDelay, "max_per_minute", maxBrowserOpensMinute, "max_per_day", maxBrowserOpensDay)

Expand Down Expand Up @@ -253,7 +255,7 @@
}),
retry.Context(ctx),
)
if err != nil {

Check failure on line 258 in cmd/goose/main.go

View workflow job for this annotation

GitHub Actions / golangci-lint

ifElseChain: rewrite if-else to switch statement (gocritic)
slog.Warn("Failed to load current user after retries", "maxRetries", maxRetries, "error", err)
if app.authError == "" {
app.authError = fmt.Sprintf("Failed to load user: %v", err)
Expand Down Expand Up @@ -283,26 +285,114 @@
})
}

func (app *App) onReady(ctx context.Context) {

Check failure on line 288 in cmd/goose/main.go

View workflow job for this annotation

GitHub Actions / golangci-lint

cognitive complexity 59 of func `(*App).onReady` is high (> 55) (gocognit)
slog.Info("System tray ready")

// On Linux, immediately build a minimal menu to ensure it's visible
if runtime.GOOS == "linux" {
slog.Info("[LINUX] Building initial minimal menu")
app.systrayInterface.ResetMenu()
placeholderItem := app.systrayInterface.AddMenuItem("Loading...", "Goose is starting up")
if placeholderItem != nil {
placeholderItem.Disable()
}
app.systrayInterface.AddSeparator()
quitItem := app.systrayInterface.AddMenuItem("Quit", "Quit Goose")
if quitItem != nil {
quitItem.Click(func() {
slog.Info("Quit clicked")
systray.Quit()
})
}
}

// Set up click handlers first (needed for both success and error states)
systray.SetOnClick(func(menu systray.IMenu) {
slog.Debug("Icon clicked")

// Check if we can perform a forced refresh (rate limited to every 10 seconds)
// Check if we're in auth error state and should retry
app.mu.RLock()
timeSinceLastSearch := time.Since(app.lastSearchAttempt)
hasAuthError := app.authError != ""
app.mu.RUnlock()

if timeSinceLastSearch >= minUpdateInterval {
slog.Info("[CLICK] Forcing search refresh", "lastSearchAgo", timeSinceLastSearch)
if hasAuthError {
slog.Info("[CLICK] Auth error detected, attempting to re-authenticate")
go func() {
app.updatePRs(ctx)
// Try to reinitialize clients which will attempt to get token via gh auth token
if err := app.initClients(ctx); err != nil {
slog.Warn("[CLICK] Re-authentication failed", "error", err)
app.mu.Lock()
app.authError = err.Error()
app.mu.Unlock()
} else {
// Success! Clear auth error and reload user
slog.Info("[CLICK] Re-authentication successful")
app.mu.Lock()
app.authError = ""
app.mu.Unlock()

// Load current user
if app.client != nil {
var user *github.User
err := retry.Do(func() error {
var retryErr error
user, _, retryErr = app.client.Users.Get(ctx, "")
if retryErr != nil {
slog.Warn("GitHub Users.Get failed (will retry)", "error", retryErr)
return retryErr
}
return nil
},
retry.Attempts(maxRetries),
retry.DelayType(retry.CombineDelay(retry.BackOffDelay, retry.RandomDelay)),
retry.MaxDelay(maxRetryDelay),
retry.OnRetry(func(n uint, err error) {
slog.Debug("[RETRY] Retrying GitHub API call", "attempt", n, "error", err)
}),
)
if err == nil && user != nil {
if app.targetUser == "" {
app.targetUser = user.GetLogin()
slog.Info("Set target user to current user", "user", app.targetUser)
}
}
}

// Update tooltip
tooltip := "Goose - Loading PRs..."
if app.targetUser != "" {
tooltip = fmt.Sprintf("Goose - Loading PRs... (@%s)", app.targetUser)
}
systray.SetTooltip(tooltip)

// Rebuild menu to remove error state
app.rebuildMenu(ctx)

// Start update loop if not already running
if !app.menuInitialized {
app.menuInitialized = true
go app.updateLoop(ctx)
} else {
// Just do a single update to refresh data
go app.updatePRs(ctx)
}
}
}()
} else {
remainingTime := minUpdateInterval - timeSinceLastSearch
slog.Debug("[CLICK] Rate limited", "lastSearchAgo", timeSinceLastSearch, "remaining", remainingTime)
// Normal operation - check if we can perform a forced refresh
app.mu.RLock()
timeSinceLastSearch := time.Since(app.lastSearchAttempt)
app.mu.RUnlock()

if timeSinceLastSearch >= minUpdateInterval {
slog.Info("[CLICK] Forcing search refresh", "lastSearchAgo", timeSinceLastSearch)
go func() {
app.updatePRs(ctx)
}()
} else {
remainingTime := minUpdateInterval - timeSinceLastSearch
slog.Debug("[CLICK] Rate limited", "lastSearchAgo", timeSinceLastSearch, "remaining", remainingTime)
}
}

if menu != nil {
Expand All @@ -323,21 +413,23 @@

// Check if we have an auth error
if app.authError != "" {
systray.SetTitle("⚠️")
systray.SetTooltip("GitHub PR Monitor - Authentication Error")
systray.SetTitle("")
app.setTrayIcon(IconLock)
systray.SetTooltip("Goose - Authentication Error")
// Create initial error menu
app.rebuildMenu(ctx)
// Clean old cache on startup
app.cleanupOldCache()
return
}

systray.SetTitle("Loading PRs...")
systray.SetTitle("")
app.setTrayIcon(IconSmiling) // Start with smiling icon while loading

// Set tooltip based on whether we're using a custom user
tooltip := "GitHub PR Monitor"
tooltip := "Goose - Loading PRs..."
if app.targetUser != "" {
tooltip = fmt.Sprintf("GitHub PR Monitor - @%s", app.targetUser)
tooltip = fmt.Sprintf("Goose - Loading PRs... (@%s)", app.targetUser)
}
systray.SetTooltip(tooltip)

Expand All @@ -355,8 +447,9 @@
slog.Error("PANIC in update loop", "panic", r)

// Set error state in UI
systray.SetTitle("💥")
systray.SetTooltip("GitHub PR Monitor - Critical error")
systray.SetTitle("")
app.setTrayIcon(IconWarning)
systray.SetTooltip("Goose - Critical error")

// Update failure count
app.mu.Lock()
Expand Down Expand Up @@ -424,23 +517,19 @@
app.mu.Unlock()

// Progressive degradation based on failure count
var title, tooltip string
var tooltip string
var iconType IconType
switch {
case failureCount == 1:
title = "⚠️"
tooltip = "GitHub PR Monitor - Temporary error, retrying..."
case failureCount <= minorFailureThreshold:
title = "⚠️"
tooltip = fmt.Sprintf("GitHub PR Monitor - %d consecutive failures", failureCount)
case failureCount <= majorFailureThreshold:
title = "❌"
tooltip = "GitHub PR Monitor - Multiple failures, check connection"
iconType = IconWarning
tooltip = fmt.Sprintf("Goose - %d consecutive failures", failureCount)
default:
title = "💀"
tooltip = "GitHub PR Monitor - Service degraded, check authentication"
iconType = IconWarning
tooltip = "Goose - Connection failures, check network/auth"
}

systray.SetTitle(title)
systray.SetTitle("")
app.setTrayIcon(iconType)

// Include time since last success and user info
timeSinceSuccess := "never"
Expand Down Expand Up @@ -524,7 +613,7 @@
"outgoing_count", len(outgoing))
// Log ALL outgoing PRs for debugging
slog.Debug("[UPDATE] Listing ALL outgoing PRs for debugging")
for i, pr := range outgoing {

Check failure on line 616 in cmd/goose/main.go

View workflow job for this annotation

GitHub Actions / golangci-lint

rangeValCopy: each iteration copies 184 bytes (consider pointers or indexing) (gocritic)
slog.Debug("[UPDATE] Outgoing PR details",
"index", i,
"repo", pr.Repository,
Expand Down Expand Up @@ -602,23 +691,19 @@
app.mu.Unlock()

// Progressive degradation based on failure count
var title, tooltip string
var tooltip string
var iconType IconType
switch {
case failureCount == 1:
title = "⚠️"
tooltip = "GitHub PR Monitor - Temporary error, retrying..."
case failureCount <= minorFailureThreshold:
title = "⚠️"
tooltip = fmt.Sprintf("GitHub PR Monitor - %d consecutive failures", failureCount)
case failureCount <= majorFailureThreshold:
title = "❌"
tooltip = "GitHub PR Monitor - Multiple failures, check connection"
iconType = IconWarning
tooltip = fmt.Sprintf("Goose - %d consecutive failures", failureCount)
default:
title = "💀"
tooltip = "GitHub PR Monitor - Service degraded, check authentication"
iconType = IconWarning
tooltip = "Goose - Connection failures, check network/auth"
}

systray.SetTitle(title)
systray.SetTitle("")
app.setTrayIcon(iconType)
systray.SetTooltip(tooltip)

// Create or update menu to show error state
Expand Down Expand Up @@ -740,7 +825,7 @@
slog.Warn("Auto-open strict validation failed", "url", sanitizeForLog(pr.URL), "error", err)
return
}
if err := openURL(ctx, pr.URL, pr.ActionKind); err != nil {
if err := openURL(ctx, pr.URL); err != nil {
slog.Error("[BROWSER] Failed to auto-open PR", "url", pr.URL, "error", err)
} else {
app.browserRateLimiter.RecordOpen(pr.URL)
Expand Down
Loading
Loading