Skip to content

Commit 7e05582

Browse files
committed
Make notifications and state transitions less buggy
1 parent 8659366 commit 7e05582

File tree

7 files changed

+1106
-244
lines changed

7 files changed

+1106
-244
lines changed

cmd/goose/main.go

Lines changed: 46 additions & 238 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,12 @@ import (
1111
"log"
1212
"os"
1313
"path/filepath"
14-
"reflect"
1514
"strings"
1615
"sync"
1716
"time"
1817

1918
"github.com/codeGROOVE-dev/retry"
2019
"github.com/energye/systray"
21-
"github.com/gen2brain/beeep"
2220
"github.com/google/go-github/v57/github"
2321
"github.com/ready-to-review/turnclient/pkg/turn"
2422
)
@@ -37,7 +35,7 @@ const (
3735
maxPRsToProcess = 200
3836
minUpdateInterval = 10 * time.Second
3937
defaultUpdateInterval = 1 * time.Minute
40-
blockedPRIconDuration = 1 * time.Minute
38+
blockedPRIconDuration = 5 * time.Minute
4139
maxRetryDelay = 2 * time.Minute
4240
maxRetries = 10
4341
minorFailureThreshold = 3
@@ -46,6 +44,7 @@ const (
4644
turnAPITimeout = 10 * time.Second
4745
maxConcurrentTurnAPICalls = 20
4846
defaultMaxBrowserOpensDay = 20
47+
startupGracePeriod = 30 * time.Second // Don't play sounds or auto-open for first 30 seconds
4948
)
5049

5150
// PR represents a pull request with metadata.
@@ -69,20 +68,16 @@ type App struct {
6968
client *github.Client
7069
turnClient *turn.Client
7170
currentUser *github.User
72-
previousBlockedPRs map[string]bool
73-
blockedPRTimes map[string]time.Time
71+
stateManager *PRStateManager // NEW: Centralized state management
7472
browserRateLimiter *BrowserRateLimiter
7573
targetUser string
7674
cacheDir string
7775
authError string
78-
// Removed pendingTurnResults - simplified to synchronous approach
79-
// Removed lastMenuTitles and lastMenuRebuild - simplified to always rebuild
8076
incoming []PR
8177
outgoing []PR
8278
updateInterval time.Duration
8379
consecutiveFailures int
8480
mu sync.RWMutex
85-
// Removed loadingTurnData - simplified to synchronous approach
8681
menuInitialized bool
8782
initialLoadComplete bool
8883
enableAudioCues bool
@@ -91,6 +86,11 @@ type App struct {
9186
noCache bool
9287
hiddenOrgs map[string]bool
9388
seenOrgs map[string]bool
89+
90+
// Deprecated: These fields are kept for backward compatibility with tests
91+
// The actual state is managed by stateManager
92+
previousBlockedPRs map[string]bool // Deprecated: use stateManager
93+
blockedPRTimes map[string]time.Time // Deprecated: use stateManager
9494
}
9595

9696
func loadCurrentUser(ctx context.Context, app *App) {
@@ -200,21 +200,23 @@ func main() {
200200
log.Fatalf("Failed to create cache directory: %v", err)
201201
}
202202

203+
startTime := time.Now()
203204
app := &App{
204205
cacheDir: cacheDir,
205206
hideStaleIncoming: true,
206-
previousBlockedPRs: make(map[string]bool),
207-
blockedPRTimes: make(map[string]time.Time),
207+
stateManager: NewPRStateManager(startTime), // NEW: Simplified state tracking
208208
targetUser: targetUser,
209209
noCache: noCache,
210210
updateInterval: updateInterval,
211-
// Removed pendingTurnResults initialization - simplified to synchronous
212211
enableAudioCues: true,
213212
enableAutoBrowser: false, // Default to false for safety
214213
browserRateLimiter: NewBrowserRateLimiter(browserOpenDelay, maxBrowserOpensMinute, maxBrowserOpensDay),
215-
startTime: time.Now(),
214+
startTime: startTime,
216215
seenOrgs: make(map[string]bool),
217216
hiddenOrgs: make(map[string]bool),
217+
// Deprecated fields for test compatibility
218+
previousBlockedPRs: make(map[string]bool),
219+
blockedPRTimes: make(map[string]time.Time),
218220
}
219221

220222
// Load saved settings
@@ -434,10 +436,12 @@ func (app *App) updatePRs(ctx context.Context) {
434436
}
435437
app.mu.Unlock()
436438

437-
// Don't check for newly blocked PRs here - wait for Turn data
438-
// Turn data will be applied asynchronously and will trigger the check
439-
440439
app.updateMenu(ctx)
440+
441+
// Process notifications using the simplified state manager
442+
log.Print("[DEBUG] Processing PR state updates and notifications")
443+
app.updatePRStatesAndNotify(ctx)
444+
log.Print("[DEBUG] Completed PR state updates and notifications")
441445
}
442446

443447
// updateMenu rebuilds the menu every time - simple and reliable.
@@ -497,6 +501,25 @@ func (app *App) updatePRsWithWait(ctx context.Context) {
497501
app.mu.Lock()
498502
app.incoming = incoming
499503
app.outgoing = outgoing
504+
505+
// Debug logging to track PR states
506+
blockedIncoming := 0
507+
for i := range incoming {
508+
if incoming[i].NeedsReview {
509+
blockedIncoming++
510+
}
511+
}
512+
blockedOutgoing := 0
513+
for i := range outgoing {
514+
if outgoing[i].IsBlocked {
515+
blockedOutgoing++
516+
log.Printf("[DEBUG] Blocked outgoing PR: %s #%d (URL: %s)",
517+
outgoing[i].Repository, outgoing[i].Number, outgoing[i].URL)
518+
}
519+
}
520+
log.Printf("[DEBUG] updatePRsInternal: Setting app state with %d incoming (%d blocked), %d outgoing (%d blocked)",
521+
len(incoming), blockedIncoming, len(outgoing), blockedOutgoing)
522+
500523
app.mu.Unlock()
501524

502525
// Create initial menu after first successful data load
@@ -510,14 +533,16 @@ func (app *App) updatePRsWithWait(ctx context.Context) {
510533
app.updateMenu(ctx)
511534
}
512535

536+
// Process notifications using the simplified state manager
537+
log.Print("[DEBUG] Processing PR state updates and notifications")
538+
app.updatePRStatesAndNotify(ctx)
539+
log.Print("[DEBUG] Completed PR state updates and notifications")
513540
// Mark initial load as complete after first successful update
514541
if !app.initialLoadComplete {
515542
app.mu.Lock()
516543
app.initialLoadComplete = true
517544
app.mu.Unlock()
518545
}
519-
// Check for newly blocked PRs
520-
app.checkForNewlyBlockedPRs(ctx)
521546
}
522547

523548
// tryAutoOpenPR attempts to open a PR in the browser if enabled and rate limits allow.
@@ -545,226 +570,9 @@ func (app *App) tryAutoOpenPR(ctx context.Context, pr PR, autoBrowserEnabled boo
545570
}
546571
}
547572

548-
// notifyWithSound sends a notification and plays sound only once per cycle.
549-
func (app *App) notifyWithSound(ctx context.Context, pr PR, isIncoming bool, playedSound *bool) {
550-
var title, soundType string
551-
if isIncoming {
552-
title = "PR Blocked on You 🪿"
553-
soundType = "honk"
554-
} else {
555-
title = "Your PR is Blocked 🚀"
556-
soundType = "rocket"
557-
}
558-
559-
message := fmt.Sprintf("%s #%d: %s", pr.Repository, pr.Number, pr.Title)
560-
if err := beeep.Notify(title, message, ""); err != nil {
561-
log.Printf("Failed to send notification for %s: %v", pr.URL, err)
562-
}
563-
564-
// Play sound only once per refresh cycle
565-
if !*playedSound {
566-
log.Printf("[SOUND] Playing %s sound for PR: %s #%d - %s", soundType, pr.Repository, pr.Number, pr.Title)
567-
app.playSound(ctx, soundType)
568-
*playedSound = true
569-
}
570-
}
571-
572-
// checkForNewlyBlockedPRs sends notifications for blocked PRs.
573+
// checkForNewlyBlockedPRs provides backward compatibility for tests
574+
// while using the new state manager internally.
573575
func (app *App) checkForNewlyBlockedPRs(ctx context.Context) {
574-
// Check for context cancellation early
575-
select {
576-
case <-ctx.Done():
577-
log.Print("[BLOCKED] Context cancelled, skipping newly blocked PR check")
578-
return
579-
default:
580-
}
581-
582-
app.mu.Lock()
583-
// Make deep copies to work with while holding the lock
584-
incoming := make([]PR, len(app.incoming))
585-
copy(incoming, app.incoming)
586-
outgoing := make([]PR, len(app.outgoing))
587-
copy(outgoing, app.outgoing)
588-
previousBlocked := app.previousBlockedPRs
589-
590-
// Clean up blockedPRTimes first to remove stale entries
591-
// Only keep blocked times for PRs that are actually in the current lists
592-
cleanedBlockedTimes := make(map[string]time.Time)
593-
for i := range app.incoming {
594-
if blockTime, exists := app.blockedPRTimes[app.incoming[i].URL]; exists {
595-
cleanedBlockedTimes[app.incoming[i].URL] = blockTime
596-
}
597-
}
598-
for i := range app.outgoing {
599-
if blockTime, exists := app.blockedPRTimes[app.outgoing[i].URL]; exists {
600-
cleanedBlockedTimes[app.outgoing[i].URL] = blockTime
601-
}
602-
}
603-
604-
// Get hidden orgs for filtering
605-
hiddenOrgs := make(map[string]bool)
606-
for org, hidden := range app.hiddenOrgs {
607-
hiddenOrgs[org] = hidden
608-
}
609-
610-
// Log any removed entries
611-
removedCount := 0
612-
for url := range app.blockedPRTimes {
613-
if _, exists := cleanedBlockedTimes[url]; !exists {
614-
log.Printf("[BLOCKED] Removing stale blocked time for PR no longer in list: %s", url)
615-
removedCount++
616-
}
617-
}
618-
if removedCount > 0 {
619-
log.Printf("[BLOCKED] Cleaned up %d stale blocked PR times", removedCount)
620-
}
621-
622-
// Update the app's blockedPRTimes to the cleaned version to prevent memory growth
623-
app.blockedPRTimes = cleanedBlockedTimes
624-
blockedTimes := cleanedBlockedTimes
625-
autoBrowserEnabled := app.enableAutoBrowser
626-
startTime := app.startTime
627-
hideStaleIncoming := app.hideStaleIncoming
628-
app.mu.Unlock()
629-
630-
currentBlocked := make(map[string]bool)
631-
newBlockedTimes := make(map[string]time.Time)
632-
playedHonk := false
633-
playedJet := false
634-
now := time.Now()
635-
staleThreshold := now.Add(-stalePRThreshold)
636-
637-
// Check incoming PRs
638-
for i := range incoming {
639-
// Skip PRs from hidden orgs for notifications
640-
org := extractOrgFromRepo(incoming[i].Repository)
641-
if org != "" && hiddenOrgs[org] {
642-
continue
643-
}
644-
645-
if !incoming[i].NeedsReview {
646-
continue
647-
}
648-
649-
pr := &incoming[i]
650-
currentBlocked[pr.URL] = true
651-
652-
if previousBlocked[pr.URL] {
653-
// PR was blocked before and is still blocked - preserve timestamp
654-
if blockedTime, exists := blockedTimes[pr.URL]; exists {
655-
newBlockedTimes[pr.URL] = blockedTime
656-
pr.FirstBlockedAt = blockedTime
657-
log.Printf("[BLOCKED] Preserving FirstBlockedAt for still-blocked incoming PR: %s #%d (blocked since %v, %v ago)",
658-
pr.Repository, pr.Number, blockedTime, time.Since(blockedTime))
659-
} else {
660-
// Edge case: PR was marked as blocked but timestamp is missing
661-
log.Printf("[BLOCKED] WARNING: Missing timestamp for previously blocked incoming PR: %s #%d - setting new timestamp",
662-
pr.Repository, pr.Number)
663-
newBlockedTimes[pr.URL] = now
664-
pr.FirstBlockedAt = now
665-
}
666-
} else {
667-
// PR is newly blocked (wasn't blocked in previous check)
668-
newBlockedTimes[pr.URL] = now
669-
pr.FirstBlockedAt = now
670-
log.Printf("[BLOCKED] Setting FirstBlockedAt for incoming PR: %s #%d at %v",
671-
pr.Repository, pr.Number, now)
672-
673-
// Skip sound and auto-open for stale PRs when hideStaleIncoming is enabled
674-
isStale := pr.UpdatedAt.Before(staleThreshold)
675-
if hideStaleIncoming && isStale {
676-
log.Printf("[BLOCKED] New incoming PR blocked (stale, skipping): %s #%d - %s",
677-
pr.Repository, pr.Number, pr.Title)
678-
} else {
679-
log.Printf("[BLOCKED] New incoming PR blocked: %s #%d - %s",
680-
pr.Repository, pr.Number, pr.Title)
681-
app.notifyWithSound(ctx, *pr, true, &playedHonk)
682-
app.tryAutoOpenPR(ctx, *pr, autoBrowserEnabled, startTime)
683-
}
684-
}
685-
}
686-
687-
// Check outgoing PRs
688-
for i := range outgoing {
689-
// Skip PRs from hidden orgs for notifications
690-
org := extractOrgFromRepo(outgoing[i].Repository)
691-
if org != "" && hiddenOrgs[org] {
692-
continue
693-
}
694-
695-
if !outgoing[i].IsBlocked {
696-
continue
697-
}
698-
699-
pr := &outgoing[i]
700-
currentBlocked[pr.URL] = true
701-
702-
if previousBlocked[pr.URL] {
703-
// PR was blocked before and is still blocked - preserve timestamp
704-
if blockedTime, exists := blockedTimes[pr.URL]; exists {
705-
newBlockedTimes[pr.URL] = blockedTime
706-
pr.FirstBlockedAt = blockedTime
707-
log.Printf("[BLOCKED] Preserving FirstBlockedAt for still-blocked outgoing PR: %s #%d (blocked since %v, %v ago)",
708-
pr.Repository, pr.Number, blockedTime, time.Since(blockedTime))
709-
} else {
710-
// Edge case: PR was marked as blocked but timestamp is missing
711-
log.Printf("[BLOCKED] WARNING: Missing timestamp for previously blocked outgoing PR: %s #%d - setting new timestamp",
712-
pr.Repository, pr.Number)
713-
newBlockedTimes[pr.URL] = now
714-
pr.FirstBlockedAt = now
715-
}
716-
} else {
717-
// PR is newly blocked (wasn't blocked in previous check)
718-
newBlockedTimes[pr.URL] = now
719-
pr.FirstBlockedAt = now
720-
log.Printf("[BLOCKED] Setting FirstBlockedAt for outgoing PR: %s #%d at %v",
721-
pr.Repository, pr.Number, now)
722-
723-
// Skip sound and auto-open for stale PRs when hideStaleIncoming is enabled
724-
isStale := pr.UpdatedAt.Before(staleThreshold)
725-
if hideStaleIncoming && isStale {
726-
log.Printf("[BLOCKED] New outgoing PR blocked (stale, skipping): %s #%d - %s",
727-
pr.Repository, pr.Number, pr.Title)
728-
} else {
729-
// Add delay if we already played honk sound
730-
if playedHonk && !playedJet {
731-
time.Sleep(2 * time.Second)
732-
}
733-
log.Printf("[BLOCKED] New outgoing PR blocked: %s #%d - %s",
734-
pr.Repository, pr.Number, pr.Title)
735-
app.notifyWithSound(ctx, *pr, false, &playedJet)
736-
app.tryAutoOpenPR(ctx, *pr, autoBrowserEnabled, startTime)
737-
}
738-
}
739-
}
740-
741-
// Update state with a lock
742-
app.mu.Lock()
743-
app.previousBlockedPRs = currentBlocked
744-
app.blockedPRTimes = newBlockedTimes
745-
// Update the PR lists with FirstBlockedAt times
746-
app.incoming = incoming
747-
app.outgoing = outgoing
748-
menuInitialized := app.menuInitialized
749-
app.mu.Unlock()
750-
751-
// Update UI after releasing the lock
752-
// Check if the set of blocked PRs has changed
753-
blockedPRsChanged := !reflect.DeepEqual(currentBlocked, previousBlocked)
754-
755-
// Update UI if blocked PRs changed or if we cleaned up stale entries
756-
if menuInitialized && (blockedPRsChanged || removedCount > 0) {
757-
switch {
758-
case len(currentBlocked) > len(previousBlocked):
759-
log.Print("[BLOCKED] Updating UI for newly blocked PRs")
760-
case len(currentBlocked) < len(previousBlocked):
761-
log.Print("[BLOCKED] Updating UI - blocked PRs were removed")
762-
case blockedPRsChanged:
763-
log.Print("[BLOCKED] Updating UI - blocked PRs changed (same count)")
764-
default:
765-
log.Printf("[BLOCKED] Updating UI after cleaning up %d stale entries", removedCount)
766-
}
767-
// updateMenu will call setTrayTitle via rebuildMenu
768-
app.updateMenu(ctx)
769-
}
576+
// Simply delegate to the new implementation
577+
app.updatePRStatesAndNotify(ctx)
770578
}

0 commit comments

Comments
 (0)