@@ -3,6 +3,7 @@ package main
33import (
44 "log/slog"
55 "maps"
6+ "slices"
67 "sync"
78 "time"
89)
@@ -18,18 +19,18 @@ type PRState struct {
1819
1920// PRStateManager manages all PR states with proper synchronization.
2021type PRStateManager struct {
21- startTime time.Time
22- states map [string ]* PRState
23- gracePeriodSeconds int
24- mu sync.RWMutex
22+ startTime time.Time
23+ states map [string ]* PRState
24+ gracePeriod time. Duration
25+ mu sync.RWMutex
2526}
2627
2728// NewPRStateManager creates a new PR state manager.
2829func NewPRStateManager (startTime time.Time ) * PRStateManager {
2930 return & PRStateManager {
30- states : make (map [string ]* PRState ),
31- startTime : startTime ,
32- gracePeriodSeconds : 30 ,
31+ states : make (map [string ]* PRState ),
32+ startTime : startTime ,
33+ gracePeriod : 30 * time . Second ,
3334 }
3435}
3536
@@ -41,7 +42,7 @@ func (m *PRStateManager) UpdatePRs(incoming, outgoing []PR, hiddenOrgs map[strin
4142 defer m .mu .Unlock ()
4243
4344 now := time .Now ()
44- inGracePeriod := time .Since (m .startTime ) < time . Duration ( m . gracePeriodSeconds ) * time . Second
45+ inGracePeriod := time .Since (m .startTime ) < m . gracePeriod
4546
4647 slog .Debug ("[STATE] UpdatePRs called" ,
4748 "incoming" , len (incoming ), "outgoing" , len (outgoing ),
@@ -51,9 +52,7 @@ func (m *PRStateManager) UpdatePRs(incoming, outgoing []PR, hiddenOrgs map[strin
5152 currentlyBlocked := make (map [string ]bool )
5253
5354 // Process all PRs (both incoming and outgoing)
54- allPRs := make ([]PR , 0 , len (incoming )+ len (outgoing ))
55- allPRs = append (allPRs , incoming ... )
56- allPRs = append (allPRs , outgoing ... )
55+ allPRs := slices .Concat (incoming , outgoing )
5756
5857 for i := range allPRs {
5958 pr := allPRs [i ]
@@ -64,14 +63,14 @@ func (m *PRStateManager) UpdatePRs(incoming, outgoing []PR, hiddenOrgs map[strin
6463 }
6564
6665 // Check if PR is blocked
67- isBlocked := pr .NeedsReview || pr .IsBlocked
68- if ! isBlocked {
66+ blocked := pr .NeedsReview || pr .IsBlocked
67+ if ! blocked {
6968 // PR is not blocked - remove from tracking if it was
70- if state , exists := m .states [pr .URL ]; exists && state != nil {
69+ if st , ok := m .states [pr .URL ]; ok {
7170 slog .Info ("[STATE] State transition: blocked -> unblocked" ,
7271 "repo" , pr .Repository , "number" , pr .Number , "url" , pr .URL ,
73- "was_blocked_since" , state .FirstBlockedAt .Format (time .RFC3339 ),
74- "blocked_duration" , time .Since (state .FirstBlockedAt ).Round (time .Second ))
72+ "was_blocked_since" , st .FirstBlockedAt .Format (time .RFC3339 ),
73+ "blocked_duration" , time .Since (st .FirstBlockedAt ).Round (time .Second ))
7574 delete (m .states , pr .URL )
7675 }
7776 continue
@@ -121,55 +120,56 @@ func (m *PRStateManager) UpdatePRs(incoming, outgoing []PR, hiddenOrgs map[strin
121120
122121 // Should we notify for actual state transitions?
123122 if ! inGracePeriod && ! state .HasNotified {
124- slog .Debug ("[STATE] Will notify for newly blocked PR" , "repo" , pr .Repository , "number" , pr .Number )
125- toNotify = append (toNotify , pr )
126- state .HasNotified = true
123+ if isPRFreshEnoughForNotification (& pr , time .Since (m .startTime ), nil ) {
124+ slog .Debug ("[STATE] Will notify for newly blocked PR" , "repo" , pr .Repository , "number" , pr .Number )
125+ toNotify = append (toNotify , pr )
126+ state .HasNotified = true
127+ }
127128 } else if inGracePeriod {
128129 slog .Debug ("[STATE] In grace period, not notifying" , "repo" , pr .Repository , "number" , pr .Number )
129130 }
130131 }
131132 } else {
132- // PR was already blocked in our state - just update data, preserve FirstBlockedAt
133- originalFirstBlocked := state .FirstBlockedAt
133+ // PR was already blocked in our state - update data, preserve FirstBlockedAt
134134 state .LastSeenBlocked = now
135- state .PR = pr // Update PR data
135+ state .PR = pr
136136
137137 slog .Debug ("[STATE] State transition: blocked -> blocked (no change)" ,
138138 "repo" , pr .Repository , "number" , pr .Number , "url" , pr .URL ,
139- "original_first_blocked" , originalFirstBlocked .Format (time .RFC3339 ),
140- "time_since_first_blocked" , time .Since (originalFirstBlocked ).Round (time .Second ),
139+ "original_first_blocked" , state . FirstBlockedAt .Format (time .RFC3339 ),
140+ "time_since_first_blocked" , time .Since (state . FirstBlockedAt ).Round (time .Second ),
141141 "has_notified" , state .HasNotified )
142142
143143 // If we haven't notified yet and we're past grace period, notify now
144144 // But don't notify for initial discovery PRs
145145 if ! state .HasNotified && ! inGracePeriod && ! state .IsInitialDiscovery {
146- slog .Info ("[STATE] Past grace period, notifying for previously blocked PR" ,
147- "repo" , pr .Repository , "number" , pr .Number )
148- toNotify = append (toNotify , pr )
149- state .HasNotified = true
146+ if isPRFreshEnoughForNotification (& pr , time .Since (m .startTime ), state ) {
147+ slog .Info ("[STATE] Past grace period, notifying for previously blocked PR" ,
148+ "repo" , pr .Repository , "number" , pr .Number )
149+ toNotify = append (toNotify , pr )
150+ state .HasNotified = true
151+ }
150152 }
151153 }
152154 }
153155
154156 // Clean up states for PRs that are no longer in our lists
155- // Add more conservative cleanup with logging
156- removedCount := 0
157- for url , state := range m .states {
157+ removed := 0
158+ for url , st := range m .states {
158159 if ! currentlyBlocked [url ] {
159- timeSinceLastSeen := time .Since (state .LastSeenBlocked )
160160 slog .Info ("[STATE] Removing stale PR state (no longer blocked)" ,
161- "url" , url , "repo" , state .PR .Repository , "number" , state .PR .Number ,
162- "first_blocked_at" , state .FirstBlockedAt .Format (time .RFC3339 ),
163- "last_seen_blocked" , state .LastSeenBlocked .Format (time .RFC3339 ),
164- "time_since_last_seen" , timeSinceLastSeen .Round (time .Second ),
165- "was_notified" , state .HasNotified )
161+ "url" , url , "repo" , st .PR .Repository , "number" , st .PR .Number ,
162+ "first_blocked_at" , st .FirstBlockedAt .Format (time .RFC3339 ),
163+ "last_seen_blocked" , st .LastSeenBlocked .Format (time .RFC3339 ),
164+ "time_since_last_seen" , time . Since ( st . LastSeenBlocked ) .Round (time .Second ),
165+ "was_notified" , st .HasNotified )
166166 delete (m .states , url )
167- removedCount ++
167+ removed ++
168168 }
169169 }
170170
171- if removedCount > 0 {
172- slog .Info ("[STATE] State cleanup completed" , "removed_states" , removedCount , "remaining_states" , len (m .states ))
171+ if removed > 0 {
172+ slog .Info ("[STATE] State cleanup completed" , "removed_states" , removed , "remaining_states" , len (m .states ))
173173 }
174174
175175 return toNotify
@@ -204,3 +204,67 @@ func (m *PRStateManager) ResetNotifications() {
204204 }
205205 slog .Info ("[STATE] Reset notification flags" , "prCount" , len (m .states ))
206206}
207+
208+ // isPRFreshEnoughForNotification checks if a PR has recent enough activity to warrant a notification.
209+ // This is a safety check to catch logic bugs that might resurrect ancient PRs.
210+ func isPRFreshEnoughForNotification (pr * PR , uptime time.Duration , prev * PRState ) bool {
211+ // Prefer LastActivityAt (from Turn API, includes test completions), fall back to UpdatedAt
212+ recent := pr .LastActivityAt
213+ src := "last_activity_at"
214+ if recent .IsZero () {
215+ recent = pr .UpdatedAt
216+ src = "updated_at"
217+ }
218+
219+ age := time .Since (recent )
220+
221+ slog .Info ("[STATE] PR activity check for notification" ,
222+ "repo" , pr .Repository ,
223+ "number" , pr .Number ,
224+ "most_recent_activity" , recent .Format (time .RFC3339 ),
225+ "activity_source" , src ,
226+ "time_since_activity" , age .Round (time .Second ),
227+ "updated_at" , pr .UpdatedAt .Format (time .RFC3339 ),
228+ "last_activity_at" , pr .LastActivityAt .Format (time .RFC3339 ))
229+
230+ if age <= ancientPRThreshold {
231+ return true
232+ }
233+
234+ // PR is stale - log detailed debug info for resurrection investigation
235+ if prev == nil {
236+ slog .Error ("[STATE] REFUSING TO NOTIFY: PR has no recent activity - possible logic bug resurrecting ancient PR" ,
237+ "repo" , pr .Repository ,
238+ "number" , pr .Number ,
239+ "url" , pr .URL ,
240+ "most_recent_activity" , recent .Format (time .RFC3339 ),
241+ "activity_source" , src ,
242+ "time_since_activity" , age .Round (time .Hour ),
243+ "threshold" , ancientPRThreshold ,
244+ "updated_at" , pr .UpdatedAt .Format (time .RFC3339 ),
245+ "last_activity_at" , pr .LastActivityAt .Format (time .RFC3339 ),
246+ "app_uptime" , uptime .Round (time .Second ),
247+ "transition_type" , "new_blocked" ,
248+ "previously_tracked" , false )
249+ } else {
250+ slog .Error ("[STATE] REFUSING TO NOTIFY: PR has no recent activity - possible logic bug resurrecting ancient PR" ,
251+ "repo" , pr .Repository ,
252+ "number" , pr .Number ,
253+ "url" , pr .URL ,
254+ "most_recent_activity" , recent .Format (time .RFC3339 ),
255+ "activity_source" , src ,
256+ "time_since_activity" , age .Round (time .Hour ),
257+ "threshold" , ancientPRThreshold ,
258+ "updated_at" , pr .UpdatedAt .Format (time .RFC3339 ),
259+ "last_activity_at" , pr .LastActivityAt .Format (time .RFC3339 ),
260+ "app_uptime" , uptime .Round (time .Second ),
261+ "transition_type" , "existing_blocked" ,
262+ "previously_tracked" , true ,
263+ "prev_first_blocked_at" , prev .FirstBlockedAt .Format (time .RFC3339 ),
264+ "prev_last_seen_blocked" , prev .LastSeenBlocked .Format (time .RFC3339 ),
265+ "prev_was_notified" , prev .HasNotified ,
266+ "time_since_first_blocked" , time .Since (prev .FirstBlockedAt ).Round (time .Second ),
267+ "time_since_last_seen" , time .Since (prev .LastSeenBlocked ).Round (time .Second ))
268+ }
269+ return false
270+ }
0 commit comments