Skip to content

Commit 146c3d3

Browse files
authored
Merge pull request #65 from codeGROOVE-dev/sprinkler
Add real-time notification support via sprinkler
2 parents be4fd99 + 9e4ba1a commit 146c3d3

File tree

7 files changed

+638
-15
lines changed

7 files changed

+638
-15
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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ func (app *App) initClients(ctx context.Context) error {
5353
turnClient.SetAuthToken(token)
5454
app.turnClient = turnClient
5555

56+
// Initialize sprinkler monitor for real-time events
57+
app.sprinklerMonitor = newSprinklerMonitor(app, token)
58+
5659
return nil
5760
}
5861

@@ -396,15 +399,33 @@ func (app *App) fetchPRsInternal(ctx context.Context) (incoming []PR, outgoing [
396399
// Categorize as incoming or outgoing
397400
// When viewing another user's PRs, we're looking at it from their perspective
398401
if issue.GetUser().GetLogin() == user {
402+
slog.Info("[GITHUB] Found outgoing PR", "repo", repo, "number", pr.Number, "author", pr.Author, "url", pr.URL)
399403
outgoing = append(outgoing, pr)
400404
} else {
405+
slog.Info("[GITHUB] Found incoming PR", "repo", repo, "number", pr.Number, "author", pr.Author, "url", pr.URL)
401406
incoming = append(incoming, pr)
402407
}
403408
}
404409

405410
// Only log summary, not individual PRs
406411
slog.Info("[GITHUB] GitHub PR summary", "incoming", len(incoming), "outgoing", len(outgoing))
407412

413+
// Update sprinkler monitor with discovered orgs
414+
app.mu.RLock()
415+
orgs := make([]string, 0, len(app.seenOrgs))
416+
for org := range app.seenOrgs {
417+
orgs = append(orgs, org)
418+
}
419+
app.mu.RUnlock()
420+
421+
if app.sprinklerMonitor != nil && len(orgs) > 0 {
422+
app.sprinklerMonitor.updateOrgs(orgs)
423+
// Start monitor if not already running
424+
if err := app.sprinklerMonitor.start(); err != nil {
425+
slog.Warn("[SPRINKLER] Failed to start monitor", "error", err)
426+
}
427+
}
428+
408429
// Fetch Turn API data
409430
// Always synchronous now for simplicity - Turn API calls are fast with caching
410431
app.fetchTurnDataSync(ctx, allIssues, user, &incoming, &outgoing)

cmd/goose/main.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ type App struct {
8282
hiddenOrgs map[string]bool
8383
seenOrgs map[string]bool
8484
turnClient *turn.Client
85+
sprinklerMonitor *sprinklerMonitor
8586
previousBlockedPRs map[string]bool
8687
authError string
8788
lastFetchError string
@@ -94,6 +95,7 @@ type App struct {
9495
consecutiveFailures int
9596
mu sync.RWMutex
9697
menuMutex sync.Mutex // Mutex to prevent concurrent menu rebuilds
98+
updateMutex sync.Mutex // Mutex to prevent concurrent PR updates
9799
enableAutoBrowser bool
98100
hideStaleIncoming bool
99101
hasPerformedInitialDiscovery bool // Track if we've done the first poll to distinguish from real state changes
@@ -283,6 +285,9 @@ func main() {
283285
systray.Run(func() { app.onReady(appCtx) }, func() {
284286
slog.Info("Shutting down application")
285287
cancel() // Cancel the context to stop goroutines
288+
if app.sprinklerMonitor != nil {
289+
app.sprinklerMonitor.stop()
290+
}
286291
app.cleanupOldCache()
287292
})
288293
}
@@ -504,6 +509,13 @@ func (app *App) updateLoop(ctx context.Context) {
504509
}
505510

506511
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+
507519
var incoming, outgoing []PR
508520
err := safeExecute("fetchPRs", func() error {
509521
var fetchErr error
@@ -683,6 +695,13 @@ func (app *App) updateMenu(ctx context.Context) {
683695

684696
// updatePRsWithWait fetches PRs and waits for Turn data before building initial menu.
685697
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+
686705
incoming, outgoing, err := app.fetchPRsInternal(ctx)
687706
if err != nil {
688707
slog.Error("Error fetching PRs", "error", err)

0 commit comments

Comments
 (0)