Skip to content

Commit b4b48ca

Browse files
committed
Add PR ressurection safety check
1 parent d857f1c commit b4b48ca

3 files changed

Lines changed: 109 additions & 40 deletions

File tree

cmd/review-goose/github.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,7 @@ func (app *App) fetchTurnDataSync(ctx context.Context, issues []*github.Issue, u
589589

590590
// Update the PR in the slices directly
591591
authorBot := result.turnData.PullRequest.AuthorBot
592+
lastActivityAt := result.turnData.Analysis.LastActivity.Timestamp
592593
if result.isOwner {
593594
for i := range *outgoing {
594595
if (*outgoing)[i].URL != result.url {
@@ -601,6 +602,7 @@ func (app *App) fetchTurnDataSync(ctx context.Context, issues []*github.Issue, u
601602
(*outgoing)[i].TestState = testState
602603
(*outgoing)[i].WorkflowState = workflowState
603604
(*outgoing)[i].AuthorBot = authorBot
605+
(*outgoing)[i].LastActivityAt = lastActivityAt
604606
break
605607
}
606608
} else {
@@ -615,6 +617,7 @@ func (app *App) fetchTurnDataSync(ctx context.Context, issues []*github.Issue, u
615617
(*incoming)[i].TestState = testState
616618
(*incoming)[i].WorkflowState = workflowState
617619
(*incoming)[i].AuthorBot = authorBot
620+
(*incoming)[i].LastActivityAt = lastActivityAt
618621
break
619622
}
620623
}

cmd/review-goose/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ const (
7171
defaultMaxBrowserOpensDay = 20
7272
startupGracePeriod = 1 * time.Minute // Don't play sounds or auto-open for first minute
7373
authRetryInterval = 2 * time.Minute // Retry authentication periodically when in error state
74+
ancientPRThreshold = 24 * time.Hour // Refuse to notify for PRs with no activity in this long (safety check)
7475
)
7576

7677
// PR represents a pull request with metadata.
@@ -79,6 +80,7 @@ type PR struct {
7980
CreatedAt time.Time
8081
TurnDataAppliedAt time.Time
8182
FirstBlockedAt time.Time // When this PR was first detected as blocked
83+
LastActivityAt time.Time // Most recent activity timestamp from Turn API (includes test completions)
8284
Title string
8385
URL string
8486
Repository string

cmd/review-goose/pr_state.go

Lines changed: 104 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
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.
2021
type 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.
2829
func 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

Comments
 (0)