@@ -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
9696func 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.
573575func (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