diff --git a/cmd/goose/cache.go b/cmd/goose/cache.go index bc7630a..574b2a5 100644 --- a/cmd/goose/cache.go +++ b/cmd/goose/cache.go @@ -25,7 +25,6 @@ type cacheEntry struct { // turnData fetches Turn API data with caching. func (app *App) turnData(ctx context.Context, url string, updatedAt time.Time) (*turn.CheckResponse, bool, error) { - prAge := time.Since(updatedAt) hasRunningTests := false // Validate URL before processing if err := validateURL(url); err != nil { @@ -57,13 +56,16 @@ func (app *App) turnData(ctx context.Context, url string, updatedAt time.Time) ( } } else if time.Since(entry.CachedAt) < cacheTTL && entry.UpdatedAt.Equal(updatedAt) { // Check if cache is still valid (10 day TTL, but PR UpdatedAt is primary check) - // But invalidate cache for PRs with running tests if they're fresh (< 90 minutes old) - if entry.Data != nil && entry.Data.PullRequest.TestState == "running" && prAge < runningTestsCacheBypass { + // But invalidate cache for PRs with incomplete tests if cache entry is fresh (< 90 minutes old) + cacheAge := time.Since(entry.CachedAt) + testState := entry.Data.PullRequest.TestState + isTestIncomplete := testState == "running" || testState == "queued" || testState == "pending" + if entry.Data != nil && isTestIncomplete && cacheAge < runningTestsCacheBypass { hasRunningTests = true - slog.Debug("[CACHE] Cache invalidated - PR has running tests and is fresh", + slog.Debug("[CACHE] Cache invalidated - tests incomplete and cache entry is fresh", "url", url, - "test_state", entry.Data.PullRequest.TestState, - "pr_age", prAge.Round(time.Minute), + "test_state", testState, + "cache_age", cacheAge.Round(time.Minute), "cached_at", entry.CachedAt.Format(time.RFC3339)) // Don't return cached data - fall through to fetch fresh data with current time } else { @@ -160,18 +162,21 @@ func (app *App) turnData(ctx context.Context, url string, updatedAt time.Time) ( } // Save to cache (don't fail if caching fails) - skip if --no-cache is set - // Also skip caching if tests are running and PR is fresh (updated in last 90 minutes) + // Don't cache when tests are incomplete - always re-poll to catch completion if !app.noCache { shouldCache := true - prAge := time.Since(updatedAt) - // Don't cache PRs with running tests unless they're older than 90 minutes - if data != nil && data.PullRequest.TestState == "running" && prAge < runningTestsCacheBypass { + // Never cache PRs with incomplete tests - we want fresh data on every poll + testState := "" + if data != nil { + testState = data.PullRequest.TestState + } + isTestIncomplete := testState == "running" || testState == "queued" || testState == "pending" + if data != nil && isTestIncomplete { shouldCache = false - slog.Debug("[CACHE] Skipping cache for PR with running tests", + slog.Debug("[CACHE] Skipping cache for PR with incomplete tests", "url", url, - "test_state", data.PullRequest.TestState, - "pr_age", prAge.Round(time.Minute), + "test_state", testState, "pending_checks", len(data.PullRequest.CheckSummary.PendingStatuses)) } diff --git a/cmd/goose/github.go b/cmd/goose/github.go index 55f5034..6f8023b 100644 --- a/cmd/goose/github.go +++ b/cmd/goose/github.go @@ -53,6 +53,9 @@ func (app *App) initClients(ctx context.Context) error { turnClient.SetAuthToken(token) app.turnClient = turnClient + // Initialize sprinkler monitor for real-time events + app.sprinklerMonitor = newSprinklerMonitor(app, token) + return nil } @@ -396,8 +399,10 @@ func (app *App) fetchPRsInternal(ctx context.Context) (incoming []PR, outgoing [ // Categorize as incoming or outgoing // When viewing another user's PRs, we're looking at it from their perspective if issue.GetUser().GetLogin() == user { + slog.Info("[GITHUB] Found outgoing PR", "repo", repo, "number", pr.Number, "author", pr.Author, "url", pr.URL) outgoing = append(outgoing, pr) } else { + slog.Info("[GITHUB] Found incoming PR", "repo", repo, "number", pr.Number, "author", pr.Author, "url", pr.URL) incoming = append(incoming, pr) } } @@ -405,6 +410,22 @@ func (app *App) fetchPRsInternal(ctx context.Context) (incoming []PR, outgoing [ // Only log summary, not individual PRs slog.Info("[GITHUB] GitHub PR summary", "incoming", len(incoming), "outgoing", len(outgoing)) + // Update sprinkler monitor with discovered orgs + app.mu.RLock() + orgs := make([]string, 0, len(app.seenOrgs)) + for org := range app.seenOrgs { + orgs = append(orgs, org) + } + app.mu.RUnlock() + + if app.sprinklerMonitor != nil && len(orgs) > 0 { + app.sprinklerMonitor.updateOrgs(orgs) + // Start monitor if not already running + if err := app.sprinklerMonitor.start(); err != nil { + slog.Warn("[SPRINKLER] Failed to start monitor", "error", err) + } + } + // Fetch Turn API data // Always synchronous now for simplicity - Turn API calls are fast with caching app.fetchTurnDataSync(ctx, allIssues, user, &incoming, &outgoing) diff --git a/cmd/goose/main.go b/cmd/goose/main.go index b7b0601..06bc848 100644 --- a/cmd/goose/main.go +++ b/cmd/goose/main.go @@ -82,6 +82,7 @@ type App struct { hiddenOrgs map[string]bool seenOrgs map[string]bool turnClient *turn.Client + sprinklerMonitor *sprinklerMonitor previousBlockedPRs map[string]bool authError string lastFetchError string @@ -94,6 +95,7 @@ type App struct { consecutiveFailures int mu sync.RWMutex menuMutex sync.Mutex // Mutex to prevent concurrent menu rebuilds + updateMutex sync.Mutex // Mutex to prevent concurrent PR updates enableAutoBrowser bool hideStaleIncoming bool hasPerformedInitialDiscovery bool // Track if we've done the first poll to distinguish from real state changes @@ -283,6 +285,9 @@ func main() { systray.Run(func() { app.onReady(appCtx) }, func() { slog.Info("Shutting down application") cancel() // Cancel the context to stop goroutines + if app.sprinklerMonitor != nil { + app.sprinklerMonitor.stop() + } app.cleanupOldCache() }) } @@ -504,6 +509,13 @@ func (app *App) updateLoop(ctx context.Context) { } func (app *App) updatePRs(ctx context.Context) { + // Prevent concurrent updates + if !app.updateMutex.TryLock() { + slog.Debug("[UPDATE] Update already in progress, skipping") + return + } + defer app.updateMutex.Unlock() + var incoming, outgoing []PR err := safeExecute("fetchPRs", func() error { var fetchErr error @@ -683,6 +695,13 @@ func (app *App) updateMenu(ctx context.Context) { // updatePRsWithWait fetches PRs and waits for Turn data before building initial menu. func (app *App) updatePRsWithWait(ctx context.Context) { + // Prevent concurrent updates + if !app.updateMutex.TryLock() { + slog.Debug("[UPDATE] Update already in progress, skipping") + return + } + defer app.updateMutex.Unlock() + incoming, outgoing, err := app.fetchPRsInternal(ctx) if err != nil { slog.Error("Error fetching PRs", "error", err) diff --git a/cmd/goose/sprinkler.go b/cmd/goose/sprinkler.go new file mode 100644 index 0000000..2c8dd90 --- /dev/null +++ b/cmd/goose/sprinkler.go @@ -0,0 +1,571 @@ +// Package main - sprinkler.go contains real-time event monitoring via WebSocket. +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + "github.com/codeGROOVE-dev/retry" + "github.com/codeGROOVE-dev/sprinkler/pkg/client" + "github.com/codeGROOVE-dev/turnclient/pkg/turn" + "github.com/gen2brain/beeep" +) + +const ( + eventChannelSize = 100 // Buffer size for event channel + eventDedupWindow = 5 * time.Second // Time window for deduplicating events + eventMapMaxSize = 1000 // Maximum entries in event dedup map + eventMapCleanupAge = 1 * time.Hour // Age threshold for cleaning up old entries + sprinklerMaxRetries = 3 // Max retries for Turn API calls + sprinklerMaxDelay = 10 * time.Second // Max delay between retries +) + +// sprinklerMonitor manages WebSocket event subscriptions for all user orgs. +type sprinklerMonitor struct { + app *App + client *client.Client + cancel context.CancelFunc + eventChan chan string // Channel for PR URLs that need checking + lastEventMap map[string]time.Time // Track last event per URL to dedupe + token string + orgs []string + ctx context.Context + mu sync.RWMutex + isRunning bool +} + +// newSprinklerMonitor creates a new sprinkler monitor for real-time PR events. +func newSprinklerMonitor(app *App, token string) *sprinklerMonitor { + ctx, cancel := context.WithCancel(context.Background()) + return &sprinklerMonitor{ + app: app, + token: token, + orgs: make([]string, 0), + ctx: ctx, + cancel: cancel, + eventChan: make(chan string, eventChannelSize), + lastEventMap: make(map[string]time.Time), + } +} + +// updateOrgs updates the list of organizations to monitor. +func (sm *sprinklerMonitor) updateOrgs(orgs []string) { + sm.mu.Lock() + defer sm.mu.Unlock() + + // Check if orgs changed + if len(orgs) == len(sm.orgs) { + same := true + for i := range orgs { + if orgs[i] != sm.orgs[i] { + same = false + break + } + } + if same { + return // No change + } + } + + slog.Info("[SPRINKLER] Updating monitored organizations", "orgs", orgs) + sm.orgs = make([]string, len(orgs)) + copy(sm.orgs, orgs) + + // Restart if running + if sm.isRunning { + slog.Info("[SPRINKLER] Restarting monitor with new org list") + sm.stop() + sm.ctx, sm.cancel = context.WithCancel(context.Background()) + if err := sm.start(); err != nil { + slog.Error("[SPRINKLER] Failed to restart", "error", err) + } + } +} + +// start begins monitoring for PR events across all user orgs. +func (sm *sprinklerMonitor) start() error { + sm.mu.Lock() + defer sm.mu.Unlock() + + if sm.isRunning { + slog.Debug("[SPRINKLER] Monitor already running") + return nil // Already running + } + + if len(sm.orgs) == 0 { + slog.Debug("[SPRINKLER] No organizations to monitor, skipping start") + return nil + } + + slog.Info("[SPRINKLER] Starting event monitor", + "orgs", sm.orgs, + "org_count", len(sm.orgs)) + + // Create logger that discards output unless debug mode + var sprinklerLogger *slog.Logger + if slog.Default().Enabled(sm.ctx, slog.LevelDebug) { + sprinklerLogger = slog.Default() + } else { + // Use a handler that discards all logs + sprinklerLogger = slog.New(slog.NewTextHandler(nil, &slog.HandlerOptions{ + Level: slog.LevelError + 1, // Level higher than any log level to discard all + })) + } + + config := client.Config{ + ServerURL: "wss://" + client.DefaultServerAddress + "/ws", + Token: sm.token, + Organization: "*", // Monitor all orgs + EventTypes: []string{"pull_request"}, + UserEventsOnly: false, + Verbose: false, + NoReconnect: false, + Logger: sprinklerLogger, + OnConnect: func() { + slog.Info("[SPRINKLER] WebSocket connected") + }, + OnDisconnect: func(err error) { + if err != nil && !errors.Is(err, context.Canceled) { + slog.Warn("[SPRINKLER] WebSocket disconnected", "error", err) + } + }, + OnEvent: func(event client.Event) { + sm.handleEvent(event) + }, + } + + wsClient, err := client.New(config) + if err != nil { + slog.Error("[SPRINKLER] Failed to create WebSocket client", "error", err) + return fmt.Errorf("create sprinkler client: %w", err) + } + + sm.client = wsClient + sm.isRunning = true + + slog.Info("[SPRINKLER] Starting event processor goroutine") + // Start event processor + go sm.processEvents() + + slog.Info("[SPRINKLER] Starting WebSocket client goroutine") + // Start WebSocket client with error recovery + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("[SPRINKLER] WebSocket goroutine panic", + "panic", r) + sm.mu.Lock() + sm.isRunning = false + sm.mu.Unlock() + } + }() + + startTime := time.Now() + if err := wsClient.Start(sm.ctx); err != nil && !errors.Is(err, context.Canceled) { + slog.Error("[SPRINKLER] WebSocket client error", + "error", err, + "uptime", time.Since(startTime).Round(time.Second)) + sm.mu.Lock() + sm.isRunning = false + sm.mu.Unlock() + } else { + slog.Info("[SPRINKLER] WebSocket client stopped gracefully", + "uptime", time.Since(startTime).Round(time.Second)) + } + }() + + slog.Info("[SPRINKLER] Event monitor started successfully") + return nil +} + +// handleEvent processes incoming PR events. +func (sm *sprinklerMonitor) handleEvent(event client.Event) { + // Filter by event type + if event.Type != "pull_request" { + slog.Debug("[SPRINKLER] Ignoring non-PR event", "type", event.Type) + return + } + + if event.URL == "" { + slog.Warn("[SPRINKLER] Received PR event with empty URL", "type", event.Type) + return + } + + // Extract org from URL (format: https://github.com/org/repo/pull/123) + parts := strings.Split(event.URL, "/") + const minParts = 5 + if len(parts) < minParts || parts[2] != "github.com" { + slog.Warn("[SPRINKLER] Failed to extract org from URL", "url", event.URL) + return + } + org := parts[3] + + // Check if this org is in our monitored list + sm.mu.RLock() + monitored := false + for _, o := range sm.orgs { + if o == org { + monitored = true + break + } + } + orgCount := len(sm.orgs) + sm.mu.RUnlock() + + if !monitored { + slog.Debug("[SPRINKLER] Event from unmonitored org", + "org", org, + "url", event.URL, + "monitored_orgs", orgCount) + return + } + + // Dedupe events - only process if we haven't seen this URL recently + sm.mu.Lock() + lastSeen, exists := sm.lastEventMap[event.URL] + now := time.Now() + if exists && now.Sub(lastSeen) < eventDedupWindow { + sm.mu.Unlock() + slog.Debug("[SPRINKLER] Skipping duplicate event", + "url", event.URL, + "last_seen", now.Sub(lastSeen).Round(time.Millisecond)) + return + } + sm.lastEventMap[event.URL] = now + + // Clean up old entries to prevent memory leak + if len(sm.lastEventMap) > eventMapMaxSize { + // Remove entries older than the cleanup age threshold + cutoff := now.Add(-eventMapCleanupAge) + for url, timestamp := range sm.lastEventMap { + if timestamp.Before(cutoff) { + delete(sm.lastEventMap, url) + } + } + slog.Debug("[SPRINKLER] Cleaned up event map", + "entries_remaining", len(sm.lastEventMap)) + } + sm.mu.Unlock() + + slog.Info("[SPRINKLER] PR event received", + "url", event.URL, + "org", org) + + // Send to event channel for processing (non-blocking) + select { + case sm.eventChan <- event.URL: + slog.Debug("[SPRINKLER] Event queued for processing", "url", event.URL) + default: + slog.Warn("[SPRINKLER] Event channel full, dropping event", + "url", event.URL, + "channel_size", cap(sm.eventChan)) + } +} + +// processEvents handles PR events by checking if they're blocking and notifying. +func (sm *sprinklerMonitor) processEvents() { + for { + select { + case <-sm.ctx.Done(): + return + case prURL := <-sm.eventChan: + sm.checkAndNotify(prURL) + } + } +} + +// checkAndNotify checks if a PR is blocking and sends notification if needed. +func (sm *sprinklerMonitor) checkAndNotify(prURL string) { + startTime := time.Now() + + // Get current user + user := "" + if sm.app.currentUser != nil { + user = sm.app.currentUser.GetLogin() + } + if sm.app.targetUser != "" { + user = sm.app.targetUser + } + if user == "" { + slog.Debug("[SPRINKLER] Skipping check - no user configured", "url", prURL) + return + } + + // Extract repo and number early for better logging + repo, number := parseRepoAndNumberFromURL(prURL) + if repo == "" || number == 0 { + slog.Warn("[SPRINKLER] Failed to parse PR URL", "url", prURL) + return + } + + // Check Turn server for PR status with retry logic + var turnData *turn.CheckResponse + var wasFromCache bool + + err := retry.Do(func() error { + var retryErr error + turnData, wasFromCache, retryErr = sm.app.turnData(sm.ctx, prURL, time.Now()) + if retryErr != nil { + slog.Debug("[SPRINKLER] Turn API call failed (will retry)", + "repo", repo, "number", number, "error", retryErr) + return retryErr + } + return nil + }, + retry.Attempts(sprinklerMaxRetries), + retry.DelayType(retry.CombineDelay(retry.BackOffDelay, retry.RandomDelay)), + retry.MaxDelay(sprinklerMaxDelay), + retry.OnRetry(func(n uint, err error) { + slog.Warn("[SPRINKLER] Retrying Turn API call", + "attempt", n+1, + "repo", repo, + "number", number, + "error", err) + }), + retry.Context(sm.ctx), + ) + if err != nil { + // Log error but don't block - the next polling cycle will catch it + slog.Warn("[SPRINKLER] Failed to get turn data after retries", + "repo", repo, + "number", number, + "elapsed", time.Since(startTime).Round(time.Millisecond), + "error", err) + return + } + + // Log Turn API response details + prState := "" + prIsMerged := false + if turnData != nil { + prState = turnData.PullRequest.State + prIsMerged = turnData.PullRequest.Merged + } + + slog.Info("[SPRINKLER] Turn API response", + "repo", repo, + "number", number, + "cached", wasFromCache, + "state", prState, + "merged", prIsMerged, + "has_data", turnData != nil, + "has_analysis", turnData != nil && turnData.Analysis.NextAction != nil) + + // Skip closed/merged PRs and remove from lists immediately + if prState == "closed" || prIsMerged { + slog.Info("[SPRINKLER] PR closed/merged, removing from lists", + "repo", repo, + "number", number, + "state", prState, + "merged", prIsMerged, + "url", prURL) + + // Remove from in-memory lists immediately + sm.app.mu.Lock() + originalIncoming := len(sm.app.incoming) + originalOutgoing := len(sm.app.outgoing) + + // Filter out this PR from incoming + filteredIncoming := make([]PR, 0, len(sm.app.incoming)) + for _, pr := range sm.app.incoming { + if pr.URL != prURL { + filteredIncoming = append(filteredIncoming, pr) + } + } + sm.app.incoming = filteredIncoming + + // Filter out this PR from outgoing + filteredOutgoing := make([]PR, 0, len(sm.app.outgoing)) + for _, pr := range sm.app.outgoing { + if pr.URL != prURL { + filteredOutgoing = append(filteredOutgoing, pr) + } + } + sm.app.outgoing = filteredOutgoing + sm.app.mu.Unlock() + + slog.Info("[SPRINKLER] Removed PR from lists", + "url", prURL, + "incoming_before", originalIncoming, + "incoming_after", len(sm.app.incoming), + "outgoing_before", originalOutgoing, + "outgoing_after", len(sm.app.outgoing)) + + // Update UI to reflect removal + sm.app.updateMenu(sm.ctx) + return + } + + if turnData == nil || turnData.Analysis.NextAction == nil { + slog.Debug("[SPRINKLER] No turn data available", + "repo", repo, + "number", number, + "cached", wasFromCache) + return + } + + // Check if user needs to take action + action, exists := turnData.Analysis.NextAction[user] + if !exists { + slog.Debug("[SPRINKLER] No action required for user", + "repo", repo, + "number", number, + "user", user, + "state", prState) + return + } + + if !action.Critical { + slog.Debug("[SPRINKLER] Non-critical action, skipping notification", + "repo", repo, + "number", number, + "action", action.Kind, + "critical", action.Critical) + return + } + + // Check if PR exists in our lists + sm.app.mu.RLock() + foundIncoming := false + foundOutgoing := false + for i := range sm.app.incoming { + if sm.app.incoming[i].URL == prURL { + foundIncoming = true + break + } + } + if !foundIncoming { + for i := range sm.app.outgoing { + if sm.app.outgoing[i].URL == prURL { + foundOutgoing = true + break + } + } + } + sm.app.mu.RUnlock() + + // If PR not found in our lists, trigger a refresh to fetch it + if !foundIncoming && !foundOutgoing { + slog.Info("[SPRINKLER] New PR detected, triggering refresh", + "repo", repo, + "number", number, + "action", action.Kind) + go sm.app.updatePRs(sm.ctx) + return // Let the refresh handle everything + } + + slog.Info("[SPRINKLER] Blocking PR detected via event", + "repo", repo, + "number", number, + "action", action.Kind, + "reason", action.Reason, + "elapsed", time.Since(startTime).Round(time.Millisecond)) + + // Check if we already know about this PR being blocked + sm.app.mu.RLock() + found := false + for i := range sm.app.incoming { + if sm.app.incoming[i].URL == prURL && sm.app.incoming[i].IsBlocked { + found = true + slog.Debug("[SPRINKLER] Found in incoming blocked PRs", "repo", repo, "number", number) + break + } + } + if !found { + for i := range sm.app.outgoing { + if sm.app.outgoing[i].URL == prURL && sm.app.outgoing[i].IsBlocked { + found = true + slog.Debug("[SPRINKLER] Found in outgoing blocked PRs", "repo", repo, "number", number) + break + } + } + } + sm.app.mu.RUnlock() + + if found { + slog.Debug("[SPRINKLER] Already tracking as blocked, skipping notification", + "repo", repo, + "number", number) + return + } + + // Send notification + title := fmt.Sprintf("PR Event: #%d needs %s", number, action.Kind) + message := fmt.Sprintf("%s #%d - %s", repo, number, action.Reason) + + // Send desktop notification + go func() { + if err := beeep.Notify(title, message, ""); err != nil { + slog.Warn("[SPRINKLER] Failed to send desktop notification", + "repo", repo, + "number", number, + "error", err) + } else { + slog.Info("[SPRINKLER] Sent desktop notification", + "repo", repo, + "number", number) + } + }() + + // Play sound if enabled + if sm.app.enableAudioCues && time.Since(sm.app.startTime) > startupGracePeriod { + slog.Debug("[SPRINKLER] Playing notification sound", + "repo", repo, + "number", number, + "soundType", "honk") + sm.app.playSound(sm.ctx, "honk") + } + + // Try auto-open if enabled + if sm.app.enableAutoBrowser { + slog.Debug("[SPRINKLER] Attempting auto-open", + "repo", repo, + "number", number) + sm.app.tryAutoOpenPR(sm.ctx, PR{ + URL: prURL, + Repository: repo, + Number: number, + IsBlocked: true, + ActionKind: string(action.Kind), + }, sm.app.enableAutoBrowser, sm.app.startTime) + } +} + +// stop stops the sprinkler monitor. +func (sm *sprinklerMonitor) stop() { + sm.mu.Lock() + defer sm.mu.Unlock() + + if !sm.isRunning { + return + } + + slog.Info("[SPRINKLER] Stopping event monitor") + sm.cancel() + sm.isRunning = false +} + +// parseRepoAndNumberFromURL extracts repo and PR number from URL. +func parseRepoAndNumberFromURL(url string) (repo string, number int) { + // URL format: https://github.com/org/repo/pull/123 + const minParts = 7 + parts := strings.Split(url, "/") + if len(parts) < minParts || parts[2] != "github.com" { + return "", 0 + } + + repo = fmt.Sprintf("%s/%s", parts[3], parts[4]) + + var n int + _, err := fmt.Sscanf(parts[6], "%d", &n) + if err != nil { + return "", 0 + } + + return repo, n +} diff --git a/cmd/goose/ui.go b/cmd/goose/ui.go index c59f470..dc76ef2 100644 --- a/cmd/goose/ui.go +++ b/cmd/goose/ui.go @@ -501,6 +501,9 @@ func (app *App) generatePRSectionTitles(prs []PR, sectionTitle string, hiddenOrg // Add action code if present if sortedPRs[prIndex].ActionKind != "" { title = fmt.Sprintf("%s — %s", title, sortedPRs[prIndex].ActionKind) + } else if sortedPRs[prIndex].TestState == "running" { + // Show "tests running" as a fallback when no specific action is available + title = fmt.Sprintf("%s — tests running...", title) } // Add bullet point or emoji for blocked PRs (same logic as in addPRSection) diff --git a/go.mod b/go.mod index 4ca5221..5aba8e4 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.0 require ( github.com/codeGROOVE-dev/retry v1.2.0 + github.com/codeGROOVE-dev/sprinkler v0.0.0-20251001125233-5fa6f0ff4582 github.com/codeGROOVE-dev/turnclient v0.0.0-20250922145707-664c2dcdf5b8 github.com/energye/systray v1.0.2 github.com/gen2brain/beeep v0.11.1 @@ -25,5 +26,6 @@ require ( github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c // indirect + golang.org/x/net v0.44.0 // indirect golang.org/x/sys v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index 94ffa72..6151a8d 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,11 @@ git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE= git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo= -github.com/codeGROOVE-dev/prx v0.0.0-20250908203157-0711b3ec5471 h1:CbUa70O+iNC9rPk5aoZGs/RZbpmPyfNydv5ncKLdOvk= -github.com/codeGROOVE-dev/prx v0.0.0-20250908203157-0711b3ec5471/go.mod h1:7qLbi18baOyS8yO/6/64SBIqtyzSzLFdsDST15NPH3w= github.com/codeGROOVE-dev/prx v0.0.0-20250923100916-d2b60be50274 h1:9eLzQdOaQEn30279ai3YjNdJOM/efbcYanWC9juAJ+M= github.com/codeGROOVE-dev/prx v0.0.0-20250923100916-d2b60be50274/go.mod h1:7qLbi18baOyS8yO/6/64SBIqtyzSzLFdsDST15NPH3w= github.com/codeGROOVE-dev/retry v1.2.0 h1:xYpYPX2PQZmdHwuiQAGGzsBm392xIMl4nfMEFApQnu8= github.com/codeGROOVE-dev/retry v1.2.0/go.mod h1:8OgefgV1XP7lzX2PdKlCXILsYKuz6b4ZpHa/20iLi8E= +github.com/codeGROOVE-dev/sprinkler v0.0.0-20251001125233-5fa6f0ff4582 h1:IPCaNGRWdyMZKyjnjv+wdSmPmOZtKFD6SVaha5DuCqk= +github.com/codeGROOVE-dev/sprinkler v0.0.0-20251001125233-5fa6f0ff4582/go.mod h1:RZ/Te7HkY5upHQlnmf3kV4GHVM0R8AK3U+yPItCZAoQ= github.com/codeGROOVE-dev/turnclient v0.0.0-20250922145707-664c2dcdf5b8 h1:3088TLJGgxzjM/bR1gafKQ609NMkBNlZe1Fd5SnRrrY= github.com/codeGROOVE-dev/turnclient v0.0.0-20250922145707-664c2dcdf5b8/go.mod h1:7lBF4vS6T+D1rNjmJ+CNVrXALQvdwNfBVEy7vhIQtYk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -50,6 +50,8 @@ github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG0 github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c h1:coVla7zpsycc+kA9NXpcvv2E4I7+ii6L5hZO2S6C3kw= github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c/go.mod h1:qc66Pna1RiIsPa7O4Egxxs9OqkuxDX55zznh9K07Tzg= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=