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
14 changes: 10 additions & 4 deletions cmd/goose/browser_rate_limiter.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,20 @@ func (b *BrowserRateLimiter) CanOpen(startTime time.Time, prURL string) bool {
b.mu.Lock()
defer b.mu.Unlock()

slog.Info("[BROWSER] CanOpen check",
"url", prURL,
"time_since_start", time.Since(startTime).Round(time.Second),
"startup_delay", b.startupDelay)

// Check if we've already opened this PR
if b.openedPRs[prURL] {
slog.Debug("[BROWSER] Skipping auto-open: PR already opened", "url", prURL)
slog.Info("[BROWSER] Skipping auto-open: PR already opened", "url", prURL)
return false
}

// Check startup delay
if time.Since(startTime) < b.startupDelay {
slog.Debug("[BROWSER] Skipping auto-open: within startup delay period",
slog.Info("[BROWSER] Skipping auto-open: within startup delay period",
"remaining", b.startupDelay-time.Since(startTime))
return false
}
Expand All @@ -57,18 +62,19 @@ func (b *BrowserRateLimiter) CanOpen(startTime time.Time, prURL string) bool {

// Check per-minute limit
if len(b.openedLastMinute) >= b.maxPerMinute {
slog.Debug("[BROWSER] Rate limit: per-minute limit reached",
slog.Info("[BROWSER] Rate limit: per-minute limit reached",
"opened", len(b.openedLastMinute), "max", b.maxPerMinute)
return false
}

// Check per-day limit
if len(b.openedToday) >= b.maxPerDay {
slog.Debug("[BROWSER] Rate limit: daily limit reached",
slog.Info("[BROWSER] Rate limit: daily limit reached",
"opened", len(b.openedToday), "max", b.maxPerDay)
return false
}

slog.Info("[BROWSER] CanOpen returning true", "url", prURL)
return true
}

Expand Down
12 changes: 11 additions & 1 deletion cmd/goose/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
}

// turnData fetches Turn API data with caching.
func (app *App) turnData(ctx context.Context, url string, updatedAt time.Time) (*turn.CheckResponse, bool, error) {

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

View workflow job for this annotation

GitHub Actions / golangci-lint

cognitive complexity 60 of func `(*App).turnData` is high (> 55) (gocognit)
hasRunningTests := false
// Validate URL before processing
if err := validateURL(url); err != nil {
Expand Down Expand Up @@ -161,6 +161,16 @@
app.healthMonitor.recordAPICall(true)
}

// Log Turn API response for debugging
if data != nil {
slog.Info("[TURN] API response details",
"url", url,
"test_state", data.PullRequest.TestState,
"state", data.PullRequest.State,
"merged", data.PullRequest.Merged,
"pending_checks", len(data.PullRequest.CheckSummary.Pending))
}

// Save to cache (don't fail if caching fails) - skip if --no-cache is set
// Don't cache when tests are incomplete - always re-poll to catch completion
if !app.noCache {
Expand All @@ -177,7 +187,7 @@
slog.Debug("[CACHE] Skipping cache for PR with incomplete tests",
"url", url,
"test_state", testState,
"pending_checks", len(data.PullRequest.CheckSummary.PendingStatuses))
"pending_checks", len(data.PullRequest.CheckSummary.Pending))
}

if shouldCache {
Expand Down
101 changes: 85 additions & 16 deletions cmd/goose/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,91 @@ func (app *App) initClients(ctx context.Context) error {
return nil
}

// initSprinklerOrgs fetches the user's organizations and starts sprinkler monitoring.
func (app *App) initSprinklerOrgs(ctx context.Context) error {
if app.client == nil || app.sprinklerMonitor == nil {
return fmt.Errorf("client or sprinkler not initialized")
}

// Get current user
user := ""
if app.currentUser != nil {
user = app.currentUser.GetLogin()
}
if app.targetUser != "" {
user = app.targetUser
}
if user == "" {
return fmt.Errorf("no user configured")
}

slog.Info("[SPRINKLER] Fetching user's organizations", "user", user)

// Fetch all orgs the user is a member of with retry
opts := &github.ListOptions{PerPage: 100}
var allOrgs []string

for {
var orgs []*github.Organization
var resp *github.Response

err := retry.Do(func() error {
// Create timeout context for API call
apiCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

var retryErr error
orgs, resp, retryErr = app.client.Organizations.List(apiCtx, user, opts)
if retryErr != nil {
slog.Debug("[SPRINKLER] Organizations.List failed (will retry)", "error", retryErr, "page", opts.Page)
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.Warn("[SPRINKLER] Organizations.List retry", "attempt", n+1, "error", err, "page", opts.Page)
}),
retry.Context(ctx),
)
if err != nil {
// Gracefully degrade - continue without sprinkler if org fetch fails
slog.Warn("[SPRINKLER] Failed to fetch organizations after retries, sprinkler will not start",
"error", err,
"maxRetries", maxRetries)
return nil // Return nil to avoid blocking startup
}

for _, org := range orgs {
if org.Login != nil {
allOrgs = append(allOrgs, *org.Login)
}
}

if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}

slog.Info("[SPRINKLER] Discovered user organizations",
"user", user,
"orgs", allOrgs,
"count", len(allOrgs))

// Update sprinkler with all orgs at once
if len(allOrgs) > 0 {
app.sprinklerMonitor.updateOrgs(allOrgs)
if err := app.sprinklerMonitor.start(); err != nil {
return fmt.Errorf("start sprinkler: %w", err)
}
}

return nil
}

// token retrieves the GitHub token from GITHUB_TOKEN env var or gh CLI.
func (*App) token(ctx context.Context) (string, error) {
// Check GITHUB_TOKEN environment variable first
Expand Down Expand Up @@ -410,22 +495,6 @@ func (app *App) fetchPRsInternal(ctx context.Context) (incoming []PR, outgoing [
// Only log summary, not individual PRs
slog.Info("[GITHUB] GitHub PR summary", "incoming", len(incoming), "outgoing", len(outgoing))

// Update sprinkler monitor with discovered orgs
app.mu.RLock()
orgs := make([]string, 0, len(app.seenOrgs))
for org := range app.seenOrgs {
orgs = append(orgs, org)
}
app.mu.RUnlock()

if app.sprinklerMonitor != nil && len(orgs) > 0 {
app.sprinklerMonitor.updateOrgs(orgs)
// Start monitor if not already running
if err := app.sprinklerMonitor.start(); err != nil {
slog.Warn("[SPRINKLER] Failed to start monitor", "error", err)
}
}

// Fetch Turn API data
// Always synchronous now for simplicity - Turn API calls are fast with caching
app.fetchTurnDataSync(ctx, allIssues, user, &incoming, &outgoing)
Expand Down
28 changes: 9 additions & 19 deletions cmd/goose/icons.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package main

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

// Icon variables are defined in platform-specific files:
Expand All @@ -14,12 +12,13 @@ import (
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
IconSmiling IconType = iota // No blocked PRs
IconGoose // Incoming PRs blocked
IconPopper // Outgoing PRs blocked
IconCockroach // Outgoing PRs blocked (fix_tests only)
IconBoth // Both incoming and outgoing blocked
IconWarning // General error/warning
IconLock // Authentication error
)

// getIcon returns the icon bytes for the given type.
Expand All @@ -29,6 +28,8 @@ func getIcon(iconType IconType) []byte {
return iconGoose
case IconPopper:
return iconPopper
case IconCockroach:
return iconCockroach
case IconSmiling:
return iconSmiling
case IconWarning:
Expand All @@ -43,17 +44,6 @@ func getIcon(iconType IconType) []byte {
}
}

// loadIconFromFile loads an icon from the filesystem (fallback if embed fails).
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.
func (app *App) setTrayIcon(iconType IconType) {
iconBytes := getIcon(iconType)
Expand Down
Binary file added cmd/goose/icons/cockroach.ico
Binary file not shown.
Binary file added cmd/goose/icons/cockroach.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions cmd/goose/icons_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ var iconLock []byte

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

//go:embed icons/cockroach.png
var iconCockroach []byte
5 changes: 4 additions & 1 deletion cmd/goose/icons_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,8 @@ var iconSmiling []byte
//go:embed icons/warning.ico
var iconWarning []byte

//go:embed icons/cockroach.ico
var iconCockroach []byte

// lock.ico not yet created, using warning as fallback
var iconLock = iconWarning
var iconLock = iconWarning
17 changes: 17 additions & 0 deletions cmd/goose/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,9 @@
githubCircuit: newCircuitBreaker("github", 5, 2*time.Minute),
}

// Set app reference in health monitor for sprinkler status
app.healthMonitor.app = app

// Load saved settings
app.loadSettings()

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

Check failure on line 265 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 All @@ -270,6 +273,13 @@
if app.targetUser != "" && app.targetUser != user.GetLogin() {
slog.Info("Querying PRs for different user", "targetUser", sanitizeForLog(app.targetUser))
}

// Initialize sprinkler with user's organizations now that we have the user
go func() {
if err := app.initSprinklerOrgs(ctx); err != nil {
slog.Warn("[SPRINKLER] Failed to initialize organizations", "error", err)
}
}()
} else {
slog.Warn("GitHub API returned nil user")
}
Expand All @@ -292,7 +302,7 @@
})
}

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

Check failure on line 305 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
Expand Down Expand Up @@ -627,7 +637,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 640 in cmd/goose/main.go

View workflow job for this annotation

GitHub Actions / golangci-lint

rangeValCopy: each iteration copies 200 bytes (consider pointers or indexing) (gocritic)
slog.Debug("[UPDATE] Outgoing PR details",
"index", i,
"repo", pr.Repository,
Expand Down Expand Up @@ -821,8 +831,15 @@
}

// tryAutoOpenPR attempts to open a PR in the browser if enabled and rate limits allow.
func (app *App) tryAutoOpenPR(ctx context.Context, pr PR, autoBrowserEnabled bool, startTime time.Time) {

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

View workflow job for this annotation

GitHub Actions / golangci-lint

hugeParam: pr is heavy (200 bytes); consider passing it by pointer (gocritic)
slog.Debug("[BROWSER] tryAutoOpenPR called",
"repo", pr.Repository,
"number", pr.Number,
"enabled", autoBrowserEnabled,
"time_since_start", time.Since(startTime).Round(time.Second))

if !autoBrowserEnabled {
slog.Debug("[BROWSER] Auto-open disabled, skipping")
return
}

Expand Down
17 changes: 16 additions & 1 deletion cmd/goose/reliability.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ type healthMonitor struct {
apiErrors int64
cacheHits int64
cacheMisses int64
app *App // Reference to app for accessing sprinkler status
}

func newHealthMonitor() *healthMonitor {
Expand Down Expand Up @@ -174,10 +175,24 @@ func (hm *healthMonitor) getMetrics() map[string]interface{} {

func (hm *healthMonitor) logMetrics() {
metrics := hm.getMetrics()

// Get sprinkler connection status
sprinklerConnected := false
sprinklerLastConnected := ""
if hm.app.sprinklerMonitor != nil {
connected, lastConnectedAt := hm.app.sprinklerMonitor.connectionStatus()
sprinklerConnected = connected
if !lastConnectedAt.IsZero() {
sprinklerLastConnected = time.Since(lastConnectedAt).Round(time.Second).String() + " ago"
}
}

slog.Info("[HEALTH] Application metrics",
"uptime", metrics["uptime"],
"api_calls", metrics["api_calls"],
"api_errors", metrics["api_errors"],
"error_rate_pct", fmt.Sprintf("%.1f", metrics["error_rate"]),
"cache_hit_rate_pct", fmt.Sprintf("%.1f", metrics["cache_hit_rate"]))
"cache_hit_rate_pct", fmt.Sprintf("%.1f", metrics["cache_hit_rate"]),
"sprinkler_connected", sprinklerConnected,
"sprinkler_last_connected", sprinklerLastConnected)
}
Loading
Loading