Skip to content

Commit 9e4ba1a

Browse files
Thomas StrombergThomas Stromberg
authored andcommitted
Improve reliability of real-time notifications
1 parent 770d0bc commit 9e4ba1a

File tree

5 files changed

+132
-14
lines changed

5 files changed

+132
-14
lines changed

cmd/goose/cache.go

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ type cacheEntry struct {
2525

2626
// turnData fetches Turn API data with caching.
2727
func (app *App) turnData(ctx context.Context, url string, updatedAt time.Time) (*turn.CheckResponse, bool, error) {
28-
prAge := time.Since(updatedAt)
2928
hasRunningTests := false
3029
// Validate URL before processing
3130
if err := validateURL(url); err != nil {
@@ -57,13 +56,16 @@ func (app *App) turnData(ctx context.Context, url string, updatedAt time.Time) (
5756
}
5857
} else if time.Since(entry.CachedAt) < cacheTTL && entry.UpdatedAt.Equal(updatedAt) {
5958
// Check if cache is still valid (10 day TTL, but PR UpdatedAt is primary check)
60-
// But invalidate cache for PRs with running tests if they're fresh (< 90 minutes old)
61-
if entry.Data != nil && entry.Data.PullRequest.TestState == "running" && prAge < runningTestsCacheBypass {
59+
// But invalidate cache for PRs with incomplete tests if cache entry is fresh (< 90 minutes old)
60+
cacheAge := time.Since(entry.CachedAt)
61+
testState := entry.Data.PullRequest.TestState
62+
isTestIncomplete := testState == "running" || testState == "queued" || testState == "pending"
63+
if entry.Data != nil && isTestIncomplete && cacheAge < runningTestsCacheBypass {
6264
hasRunningTests = true
63-
slog.Debug("[CACHE] Cache invalidated - PR has running tests and is fresh",
65+
slog.Debug("[CACHE] Cache invalidated - tests incomplete and cache entry is fresh",
6466
"url", url,
65-
"test_state", entry.Data.PullRequest.TestState,
66-
"pr_age", prAge.Round(time.Minute),
67+
"test_state", testState,
68+
"cache_age", cacheAge.Round(time.Minute),
6769
"cached_at", entry.CachedAt.Format(time.RFC3339))
6870
// Don't return cached data - fall through to fetch fresh data with current time
6971
} else {
@@ -160,18 +162,21 @@ func (app *App) turnData(ctx context.Context, url string, updatedAt time.Time) (
160162
}
161163

162164
// Save to cache (don't fail if caching fails) - skip if --no-cache is set
163-
// Also skip caching if tests are running and PR is fresh (updated in last 90 minutes)
165+
// Don't cache when tests are incomplete - always re-poll to catch completion
164166
if !app.noCache {
165167
shouldCache := true
166-
prAge := time.Since(updatedAt)
167168

168-
// Don't cache PRs with running tests unless they're older than 90 minutes
169-
if data != nil && data.PullRequest.TestState == "running" && prAge < runningTestsCacheBypass {
169+
// Never cache PRs with incomplete tests - we want fresh data on every poll
170+
testState := ""
171+
if data != nil {
172+
testState = data.PullRequest.TestState
173+
}
174+
isTestIncomplete := testState == "running" || testState == "queued" || testState == "pending"
175+
if data != nil && isTestIncomplete {
170176
shouldCache = false
171-
slog.Debug("[CACHE] Skipping cache for PR with running tests",
177+
slog.Debug("[CACHE] Skipping cache for PR with incomplete tests",
172178
"url", url,
173-
"test_state", data.PullRequest.TestState,
174-
"pr_age", prAge.Round(time.Minute),
179+
"test_state", testState,
175180
"pending_checks", len(data.PullRequest.CheckSummary.PendingStatuses))
176181
}
177182

cmd/goose/github.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,8 +399,10 @@ func (app *App) fetchPRsInternal(ctx context.Context) (incoming []PR, outgoing [
399399
// Categorize as incoming or outgoing
400400
// When viewing another user's PRs, we're looking at it from their perspective
401401
if issue.GetUser().GetLogin() == user {
402+
slog.Info("[GITHUB] Found outgoing PR", "repo", repo, "number", pr.Number, "author", pr.Author, "url", pr.URL)
402403
outgoing = append(outgoing, pr)
403404
} else {
405+
slog.Info("[GITHUB] Found incoming PR", "repo", repo, "number", pr.Number, "author", pr.Author, "url", pr.URL)
404406
incoming = append(incoming, pr)
405407
}
406408
}

cmd/goose/main.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ type App struct {
9595
consecutiveFailures int
9696
mu sync.RWMutex
9797
menuMutex sync.Mutex // Mutex to prevent concurrent menu rebuilds
98+
updateMutex sync.Mutex // Mutex to prevent concurrent PR updates
9899
enableAutoBrowser bool
99100
hideStaleIncoming bool
100101
hasPerformedInitialDiscovery bool // Track if we've done the first poll to distinguish from real state changes
@@ -508,6 +509,13 @@ func (app *App) updateLoop(ctx context.Context) {
508509
}
509510

510511
func (app *App) updatePRs(ctx context.Context) {
512+
// Prevent concurrent updates
513+
if !app.updateMutex.TryLock() {
514+
slog.Debug("[UPDATE] Update already in progress, skipping")
515+
return
516+
}
517+
defer app.updateMutex.Unlock()
518+
511519
var incoming, outgoing []PR
512520
err := safeExecute("fetchPRs", func() error {
513521
var fetchErr error
@@ -687,6 +695,13 @@ func (app *App) updateMenu(ctx context.Context) {
687695

688696
// updatePRsWithWait fetches PRs and waits for Turn data before building initial menu.
689697
func (app *App) updatePRsWithWait(ctx context.Context) {
698+
// Prevent concurrent updates
699+
if !app.updateMutex.TryLock() {
700+
slog.Debug("[UPDATE] Update already in progress, skipping")
701+
return
702+
}
703+
defer app.updateMutex.Unlock()
704+
690705
incoming, outgoing, err := app.fetchPRsInternal(ctx)
691706
if err != nil {
692707
slog.Error("Error fetching PRs", "error", err)

cmd/goose/sprinkler.go

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,68 @@ func (sm *sprinklerMonitor) checkAndNotify(prURL string) {
339339
return
340340
}
341341

342+
// Log Turn API response details
343+
prState := ""
344+
prIsMerged := false
345+
if turnData != nil {
346+
prState = turnData.PullRequest.State
347+
prIsMerged = turnData.PullRequest.Merged
348+
}
349+
350+
slog.Info("[SPRINKLER] Turn API response",
351+
"repo", repo,
352+
"number", number,
353+
"cached", wasFromCache,
354+
"state", prState,
355+
"merged", prIsMerged,
356+
"has_data", turnData != nil,
357+
"has_analysis", turnData != nil && turnData.Analysis.NextAction != nil)
358+
359+
// Skip closed/merged PRs and remove from lists immediately
360+
if prState == "closed" || prIsMerged {
361+
slog.Info("[SPRINKLER] PR closed/merged, removing from lists",
362+
"repo", repo,
363+
"number", number,
364+
"state", prState,
365+
"merged", prIsMerged,
366+
"url", prURL)
367+
368+
// Remove from in-memory lists immediately
369+
sm.app.mu.Lock()
370+
originalIncoming := len(sm.app.incoming)
371+
originalOutgoing := len(sm.app.outgoing)
372+
373+
// Filter out this PR from incoming
374+
filteredIncoming := make([]PR, 0, len(sm.app.incoming))
375+
for _, pr := range sm.app.incoming {
376+
if pr.URL != prURL {
377+
filteredIncoming = append(filteredIncoming, pr)
378+
}
379+
}
380+
sm.app.incoming = filteredIncoming
381+
382+
// Filter out this PR from outgoing
383+
filteredOutgoing := make([]PR, 0, len(sm.app.outgoing))
384+
for _, pr := range sm.app.outgoing {
385+
if pr.URL != prURL {
386+
filteredOutgoing = append(filteredOutgoing, pr)
387+
}
388+
}
389+
sm.app.outgoing = filteredOutgoing
390+
sm.app.mu.Unlock()
391+
392+
slog.Info("[SPRINKLER] Removed PR from lists",
393+
"url", prURL,
394+
"incoming_before", originalIncoming,
395+
"incoming_after", len(sm.app.incoming),
396+
"outgoing_before", originalOutgoing,
397+
"outgoing_after", len(sm.app.outgoing))
398+
399+
// Update UI to reflect removal
400+
sm.app.updateMenu(sm.ctx)
401+
return
402+
}
403+
342404
if turnData == nil || turnData.Analysis.NextAction == nil {
343405
slog.Debug("[SPRINKLER] No turn data available",
344406
"repo", repo,
@@ -353,7 +415,8 @@ func (sm *sprinklerMonitor) checkAndNotify(prURL string) {
353415
slog.Debug("[SPRINKLER] No action required for user",
354416
"repo", repo,
355417
"number", number,
356-
"user", user)
418+
"user", user,
419+
"state", prState)
357420
return
358421
}
359422

@@ -366,6 +429,36 @@ func (sm *sprinklerMonitor) checkAndNotify(prURL string) {
366429
return
367430
}
368431

432+
// Check if PR exists in our lists
433+
sm.app.mu.RLock()
434+
foundIncoming := false
435+
foundOutgoing := false
436+
for i := range sm.app.incoming {
437+
if sm.app.incoming[i].URL == prURL {
438+
foundIncoming = true
439+
break
440+
}
441+
}
442+
if !foundIncoming {
443+
for i := range sm.app.outgoing {
444+
if sm.app.outgoing[i].URL == prURL {
445+
foundOutgoing = true
446+
break
447+
}
448+
}
449+
}
450+
sm.app.mu.RUnlock()
451+
452+
// If PR not found in our lists, trigger a refresh to fetch it
453+
if !foundIncoming && !foundOutgoing {
454+
slog.Info("[SPRINKLER] New PR detected, triggering refresh",
455+
"repo", repo,
456+
"number", number,
457+
"action", action.Kind)
458+
go sm.app.updatePRs(sm.ctx)
459+
return // Let the refresh handle everything
460+
}
461+
369462
slog.Info("[SPRINKLER] Blocking PR detected via event",
370463
"repo", repo,
371464
"number", number,

cmd/goose/ui.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,9 @@ func (app *App) generatePRSectionTitles(prs []PR, sectionTitle string, hiddenOrg
501501
// Add action code if present
502502
if sortedPRs[prIndex].ActionKind != "" {
503503
title = fmt.Sprintf("%s — %s", title, sortedPRs[prIndex].ActionKind)
504+
} else if sortedPRs[prIndex].TestState == "running" {
505+
// Show "tests running" as a fallback when no specific action is available
506+
title = fmt.Sprintf("%s — tests running...", title)
504507
}
505508

506509
// Add bullet point or emoji for blocked PRs (same logic as in addPRSection)

0 commit comments

Comments
 (0)