@@ -89,12 +89,19 @@ type TurnResult struct {
8989 WasFromCache bool // Track if this result came from cache
9090}
9191
92+ // NotificationState tracks the last known state and notification time for a PR.
93+ type NotificationState struct {
94+ LastNotified time.Time
95+ WasBlocked bool
96+ }
97+
9298// App holds the application state.
9399type App struct {
94100 lastSuccessfulFetch time.Time
95101 turnClient * turn.Client
96102 currentUser * github.User
97103 previousBlockedPRs map [string ]bool
104+ notificationHistory map [string ]NotificationState // Track state and notification time per PR
98105 client * github.Client
99106 lastMenuState * MenuState
100107 targetUser string
@@ -110,6 +117,7 @@ type App struct {
110117 noCache bool
111118 hideStaleIncoming bool
112119 loadingTurnData bool
120+ enableReminders bool // Whether to send daily reminder notifications
113121}
114122
115123func main () {
@@ -145,13 +153,15 @@ func main() {
145153 }
146154
147155 app := & App {
148- cacheDir : cacheDir ,
149- hideStaleIncoming : true ,
150- previousBlockedPRs : make (map [string ]bool ),
151- targetUser : targetUser ,
152- noCache : noCache ,
153- updateInterval : updateInterval ,
154- pendingTurnResults : make ([]TurnResult , 0 ),
156+ cacheDir : cacheDir ,
157+ hideStaleIncoming : true ,
158+ previousBlockedPRs : make (map [string ]bool ),
159+ notificationHistory : make (map [string ]NotificationState ),
160+ targetUser : targetUser ,
161+ noCache : noCache ,
162+ updateInterval : updateInterval ,
163+ pendingTurnResults : make ([]TurnResult , 0 ),
164+ enableReminders : true ,
155165 }
156166
157167 log .Println ("Initializing GitHub clients..." )
@@ -524,96 +534,188 @@ func (app *App) updatePRsWithWait(ctx context.Context) {
524534 app .checkForNewlyBlockedPRs (ctx )
525535}
526536
537+ // shouldNotifyForPR determines if we should send a notification for a PR.
538+ func shouldNotifyForPR (
539+ _ string ,
540+ isBlocked bool ,
541+ prevState NotificationState ,
542+ hasHistory bool ,
543+ reminderInterval time.Duration ,
544+ enableReminders bool ,
545+ ) (shouldNotify bool , reason string ) {
546+ if ! hasHistory && isBlocked {
547+ return true , "newly blocked"
548+ }
549+
550+ if ! hasHistory {
551+ return false , ""
552+ }
553+
554+ switch {
555+ case isBlocked && ! prevState .WasBlocked :
556+ return true , "became blocked"
557+ case ! isBlocked && prevState .WasBlocked :
558+ return false , "unblocked"
559+ case isBlocked && prevState .WasBlocked && enableReminders && time .Since (prevState .LastNotified ) > reminderInterval :
560+ return true , "reminder"
561+ default :
562+ return false , ""
563+ }
564+ }
565+
566+ // processPRNotifications handles notification logic for a single PR.
567+ func (app * App ) processPRNotifications (
568+ ctx context.Context ,
569+ pr PR ,
570+ isBlocked bool ,
571+ isIncoming bool ,
572+ notificationHistory map [string ]NotificationState ,
573+ playedSound * bool ,
574+ now time.Time ,
575+ reminderInterval time.Duration ,
576+ ) {
577+ prevState , hasHistory := notificationHistory [pr .URL ]
578+ shouldNotify , notifyReason := shouldNotifyForPR (pr .URL , isBlocked , prevState , hasHistory , reminderInterval , app .enableReminders )
579+
580+ // Update state for unblocked PRs
581+ if notifyReason == "unblocked" {
582+ notificationHistory [pr .URL ] = NotificationState {
583+ LastNotified : prevState .LastNotified ,
584+ WasBlocked : false ,
585+ }
586+ return
587+ }
588+
589+ if ! shouldNotify || ! isBlocked {
590+ return
591+ }
592+
593+ // Send notification
594+ var title , soundType string
595+ if isIncoming {
596+ title = "PR Blocked on You 🪿"
597+ soundType = "honk"
598+ if notifyReason == "reminder" {
599+ log .Printf ("[NOTIFY] Incoming PR reminder (24hr): %s #%d - %s (reason: %s)" ,
600+ pr .Repository , pr .Number , pr .Title , pr .ActionReason )
601+ } else {
602+ log .Printf ("[NOTIFY] Incoming PR notification (%s): %s #%d - %s (reason: %s)" ,
603+ notifyReason , pr .Repository , pr .Number , pr .Title , pr .ActionReason )
604+ }
605+ } else {
606+ title = "Your PR is Blocked 🚀"
607+ soundType = "rocket"
608+ if notifyReason == "reminder" {
609+ log .Printf ("[NOTIFY] Outgoing PR reminder (24hr): %s #%d - %s (reason: %s)" ,
610+ pr .Repository , pr .Number , pr .Title , pr .ActionReason )
611+ } else {
612+ log .Printf ("[NOTIFY] Outgoing PR notification (%s): %s #%d - %s (reason: %s)" ,
613+ notifyReason , pr .Repository , pr .Number , pr .Title , pr .ActionReason )
614+ }
615+ }
616+
617+ message := fmt .Sprintf ("%s #%d: %s" , pr .Repository , pr .Number , pr .Title )
618+ if err := beeep .Notify (title , message , "" ); err != nil {
619+ log .Printf ("[NOTIFY] Failed to send desktop notification for %s: %v" , pr .URL , err )
620+ } else {
621+ log .Printf ("[NOTIFY] Desktop notification sent for %s" , pr .URL )
622+ notificationHistory [pr .URL ] = NotificationState {
623+ LastNotified : now ,
624+ WasBlocked : true ,
625+ }
626+ }
627+
628+ // Play sound once per polling period
629+ if ! * playedSound {
630+ if notifyReason == "reminder" {
631+ log .Printf ("[SOUND] Playing %s sound for daily reminder" , soundType )
632+ }
633+ app .playSound (ctx , soundType )
634+ * playedSound = true
635+ }
636+ }
637+
527638// checkForNewlyBlockedPRs checks for PRs that have become blocked and sends notifications.
528639func (app * App ) checkForNewlyBlockedPRs (ctx context.Context ) {
529640 app .mu .Lock ()
530- oldBlockedPRs := app .previousBlockedPRs
531- if oldBlockedPRs == nil {
532- oldBlockedPRs = make (map [string ]bool )
641+ notificationHistory := app .notificationHistory
642+ if notificationHistory == nil {
643+ notificationHistory = make (map [string ]NotificationState )
644+ app .notificationHistory = notificationHistory
533645 }
534646 initialLoadComplete := app .initialLoadComplete
535647 hideStaleIncoming := app .hideStaleIncoming
536648 incoming := make ([]PR , len (app .incoming ))
537649 copy (incoming , app .incoming )
538650 outgoing := make ([]PR , len (app .outgoing ))
539651 copy (outgoing , app .outgoing )
652+ hasValidData := len (incoming ) > 0 || len (outgoing ) > 0
540653 app .mu .Unlock ()
541654
542- // Only log when we're checking after initial load is complete
543- if initialLoadComplete && len (oldBlockedPRs ) > 0 {
544- log .Printf ("[NOTIFY] Checking for newly blocked PRs (oldBlockedCount=%d)" , len (oldBlockedPRs ))
655+ // Skip if this looks like a transient GitHub API failure
656+ if ! hasValidData && initialLoadComplete {
657+ log .Print ("[NOTIFY] Skipping notification check - no PR data (likely transient API failure)" )
658+ return
545659 }
546660
547661 // Calculate stale threshold
548662 now := time .Now ()
549663 staleThreshold := now .Add (- stalePRThreshold )
550664
665+ // Reminder interval for re-notifications (24 hours)
666+ const reminderInterval = 24 * time .Hour
667+
551668 currentBlockedPRs := make (map [string ]bool )
552669 playedIncomingSound := false
553670 playedOutgoingSound := false
554671
555- // Check incoming PRs for newly blocked ones
556- for i := range incoming {
557- if incoming [i ]. NeedsReview {
558- currentBlockedPRs [ incoming [ i ]. URL ] = true
672+ // Check incoming PRs for state changes
673+ for idx := range incoming {
674+ pr := incoming [idx ]
675+ isBlocked := pr . NeedsReview
559676
560- // Skip stale PRs for notifications if hideStaleIncoming is enabled
561- if hideStaleIncoming && incoming [i ].UpdatedAt .Before (staleThreshold ) {
562- continue
563- }
677+ if isBlocked {
678+ currentBlockedPRs [pr .URL ] = true
679+ }
564680
565- // Send notification and play sound if PR wasn't blocked before
566- if ! oldBlockedPRs [incoming [i ].URL ] {
567- log .Printf ("[NOTIFY] New blocked incoming PR: %s #%d - %s (reason: %s)" ,
568- incoming [i ].Repository , incoming [i ].Number , incoming [i ].Title , incoming [i ].ActionReason )
569- title := "PR Blocked on You 🪿"
570- message := fmt .Sprintf ("%s #%d: %s" , incoming [i ].Repository , incoming [i ].Number , incoming [i ].Title )
571- if err := beeep .Notify (title , message , "" ); err != nil {
572- log .Printf ("[NOTIFY] Failed to send desktop notification for %s: %v" , incoming [i ].URL , err )
573- } else {
574- log .Printf ("[NOTIFY] Desktop notification sent for %s" , incoming [i ].URL )
575- }
576- // Only play sound once per polling period
577- if ! playedIncomingSound {
578- app .playSound (ctx , "honk" )
579- playedIncomingSound = true
580- }
581- }
681+ // Skip stale PRs for notifications if hideStaleIncoming is enabled
682+ if hideStaleIncoming && pr .UpdatedAt .Before (staleThreshold ) {
683+ continue
582684 }
685+
686+ app .processPRNotifications (ctx , pr , isBlocked , true , notificationHistory ,
687+ & playedIncomingSound , now , reminderInterval )
583688 }
584689
585- // Check outgoing PRs for newly blocked ones
586- for i := range outgoing {
587- if outgoing [i ]. IsBlocked {
588- currentBlockedPRs [ outgoing [ i ]. URL ] = true
690+ // Check outgoing PRs for state changes
691+ for idx := range outgoing {
692+ pr := outgoing [idx ]
693+ isBlocked := pr . IsBlocked
589694
590- // Skip stale PRs for notifications if hideStaleIncoming is enabled
591- if hideStaleIncoming && outgoing [i ].UpdatedAt .Before (staleThreshold ) {
592- continue
593- }
695+ if isBlocked {
696+ currentBlockedPRs [pr .URL ] = true
697+ }
594698
595- // Send notification and play sound if PR wasn't blocked before
596- if ! oldBlockedPRs [outgoing [i ].URL ] {
597- log .Printf ("[NOTIFY] New blocked outgoing PR: %s #%d - %s (reason: %s)" ,
598- outgoing [i ].Repository , outgoing [i ].Number , outgoing [i ].Title , outgoing [i ].ActionReason )
599- title := "Your PR is Blocked 🚀"
600- message := fmt .Sprintf ("%s #%d: %s" , outgoing [i ].Repository , outgoing [i ].Number , outgoing [i ].Title )
601- if err := beeep .Notify (title , message , "" ); err != nil {
602- log .Printf ("[NOTIFY] Failed to send desktop notification for %s: %v" , outgoing [i ].URL , err )
603- } else {
604- log .Printf ("[NOTIFY] Desktop notification sent for %s" , outgoing [i ].URL )
605- }
606- // Only play sound once per polling period
607- if ! playedOutgoingSound {
608- app .playSound (ctx , "rocket" )
609- playedOutgoingSound = true
610- }
611- }
699+ // Skip stale PRs for notifications if hideStaleIncoming is enabled
700+ if hideStaleIncoming && pr .UpdatedAt .Before (staleThreshold ) {
701+ continue
702+ }
703+
704+ app .processPRNotifications (ctx , pr , isBlocked , false , notificationHistory ,
705+ & playedOutgoingSound , now , reminderInterval )
706+ }
707+
708+ // Clean up old entries from notification history (older than 7 days)
709+ const historyRetentionDays = 7
710+ for url , state := range notificationHistory {
711+ if time .Since (state .LastNotified ) > historyRetentionDays * 24 * time .Hour {
712+ delete (notificationHistory , url )
612713 }
613714 }
614715
615- // Update the previous blocked PRs map
716+ // Update the notification history
616717 app .mu .Lock ()
718+ app .notificationHistory = notificationHistory
617719 app .previousBlockedPRs = currentBlockedPRs
618720 app .mu .Unlock ()
619721}
0 commit comments