Skip to content

Commit 02aef62

Browse files
authored
Merge pull request #66 from codeGROOVE-dev/sprinkler
More sprinkler reliability tuning
2 parents 146c3d3 + 1c3a2af commit 02aef62

File tree

14 files changed

+229
-91
lines changed

14 files changed

+229
-91
lines changed

cmd/goose/browser_rate_limiter.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,20 @@ func (b *BrowserRateLimiter) CanOpen(startTime time.Time, prURL string) bool {
3737
b.mu.Lock()
3838
defer b.mu.Unlock()
3939

40+
slog.Info("[BROWSER] CanOpen check",
41+
"url", prURL,
42+
"time_since_start", time.Since(startTime).Round(time.Second),
43+
"startup_delay", b.startupDelay)
44+
4045
// Check if we've already opened this PR
4146
if b.openedPRs[prURL] {
42-
slog.Debug("[BROWSER] Skipping auto-open: PR already opened", "url", prURL)
47+
slog.Info("[BROWSER] Skipping auto-open: PR already opened", "url", prURL)
4348
return false
4449
}
4550

4651
// Check startup delay
4752
if time.Since(startTime) < b.startupDelay {
48-
slog.Debug("[BROWSER] Skipping auto-open: within startup delay period",
53+
slog.Info("[BROWSER] Skipping auto-open: within startup delay period",
4954
"remaining", b.startupDelay-time.Since(startTime))
5055
return false
5156
}
@@ -57,18 +62,19 @@ func (b *BrowserRateLimiter) CanOpen(startTime time.Time, prURL string) bool {
5762

5863
// Check per-minute limit
5964
if len(b.openedLastMinute) >= b.maxPerMinute {
60-
slog.Debug("[BROWSER] Rate limit: per-minute limit reached",
65+
slog.Info("[BROWSER] Rate limit: per-minute limit reached",
6166
"opened", len(b.openedLastMinute), "max", b.maxPerMinute)
6267
return false
6368
}
6469

6570
// Check per-day limit
6671
if len(b.openedToday) >= b.maxPerDay {
67-
slog.Debug("[BROWSER] Rate limit: daily limit reached",
72+
slog.Info("[BROWSER] Rate limit: daily limit reached",
6873
"opened", len(b.openedToday), "max", b.maxPerDay)
6974
return false
7075
}
7176

77+
slog.Info("[BROWSER] CanOpen returning true", "url", prURL)
7278
return true
7379
}
7480

cmd/goose/cache.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,16 @@ func (app *App) turnData(ctx context.Context, url string, updatedAt time.Time) (
161161
app.healthMonitor.recordAPICall(true)
162162
}
163163

164+
// Log Turn API response for debugging
165+
if data != nil {
166+
slog.Info("[TURN] API response details",
167+
"url", url,
168+
"test_state", data.PullRequest.TestState,
169+
"state", data.PullRequest.State,
170+
"merged", data.PullRequest.Merged,
171+
"pending_checks", len(data.PullRequest.CheckSummary.Pending))
172+
}
173+
164174
// Save to cache (don't fail if caching fails) - skip if --no-cache is set
165175
// Don't cache when tests are incomplete - always re-poll to catch completion
166176
if !app.noCache {
@@ -177,7 +187,7 @@ func (app *App) turnData(ctx context.Context, url string, updatedAt time.Time) (
177187
slog.Debug("[CACHE] Skipping cache for PR with incomplete tests",
178188
"url", url,
179189
"test_state", testState,
180-
"pending_checks", len(data.PullRequest.CheckSummary.PendingStatuses))
190+
"pending_checks", len(data.PullRequest.CheckSummary.Pending))
181191
}
182192

183193
if shouldCache {

cmd/goose/github.go

Lines changed: 85 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,91 @@ func (app *App) initClients(ctx context.Context) error {
5959
return nil
6060
}
6161

62+
// initSprinklerOrgs fetches the user's organizations and starts sprinkler monitoring.
63+
func (app *App) initSprinklerOrgs(ctx context.Context) error {
64+
if app.client == nil || app.sprinklerMonitor == nil {
65+
return fmt.Errorf("client or sprinkler not initialized")
66+
}
67+
68+
// Get current user
69+
user := ""
70+
if app.currentUser != nil {
71+
user = app.currentUser.GetLogin()
72+
}
73+
if app.targetUser != "" {
74+
user = app.targetUser
75+
}
76+
if user == "" {
77+
return fmt.Errorf("no user configured")
78+
}
79+
80+
slog.Info("[SPRINKLER] Fetching user's organizations", "user", user)
81+
82+
// Fetch all orgs the user is a member of with retry
83+
opts := &github.ListOptions{PerPage: 100}
84+
var allOrgs []string
85+
86+
for {
87+
var orgs []*github.Organization
88+
var resp *github.Response
89+
90+
err := retry.Do(func() error {
91+
// Create timeout context for API call
92+
apiCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
93+
defer cancel()
94+
95+
var retryErr error
96+
orgs, resp, retryErr = app.client.Organizations.List(apiCtx, user, opts)
97+
if retryErr != nil {
98+
slog.Debug("[SPRINKLER] Organizations.List failed (will retry)", "error", retryErr, "page", opts.Page)
99+
return retryErr
100+
}
101+
return nil
102+
},
103+
retry.Attempts(maxRetries),
104+
retry.DelayType(retry.CombineDelay(retry.BackOffDelay, retry.RandomDelay)),
105+
retry.MaxDelay(maxRetryDelay),
106+
retry.OnRetry(func(n uint, err error) {
107+
slog.Warn("[SPRINKLER] Organizations.List retry", "attempt", n+1, "error", err, "page", opts.Page)
108+
}),
109+
retry.Context(ctx),
110+
)
111+
if err != nil {
112+
// Gracefully degrade - continue without sprinkler if org fetch fails
113+
slog.Warn("[SPRINKLER] Failed to fetch organizations after retries, sprinkler will not start",
114+
"error", err,
115+
"maxRetries", maxRetries)
116+
return nil // Return nil to avoid blocking startup
117+
}
118+
119+
for _, org := range orgs {
120+
if org.Login != nil {
121+
allOrgs = append(allOrgs, *org.Login)
122+
}
123+
}
124+
125+
if resp.NextPage == 0 {
126+
break
127+
}
128+
opts.Page = resp.NextPage
129+
}
130+
131+
slog.Info("[SPRINKLER] Discovered user organizations",
132+
"user", user,
133+
"orgs", allOrgs,
134+
"count", len(allOrgs))
135+
136+
// Update sprinkler with all orgs at once
137+
if len(allOrgs) > 0 {
138+
app.sprinklerMonitor.updateOrgs(allOrgs)
139+
if err := app.sprinklerMonitor.start(); err != nil {
140+
return fmt.Errorf("start sprinkler: %w", err)
141+
}
142+
}
143+
144+
return nil
145+
}
146+
62147
// token retrieves the GitHub token from GITHUB_TOKEN env var or gh CLI.
63148
func (*App) token(ctx context.Context) (string, error) {
64149
// Check GITHUB_TOKEN environment variable first
@@ -410,22 +495,6 @@ func (app *App) fetchPRsInternal(ctx context.Context) (incoming []PR, outgoing [
410495
// Only log summary, not individual PRs
411496
slog.Info("[GITHUB] GitHub PR summary", "incoming", len(incoming), "outgoing", len(outgoing))
412497

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-
429498
// Fetch Turn API data
430499
// Always synchronous now for simplicity - Turn API calls are fast with caching
431500
app.fetchTurnDataSync(ctx, allIssues, user, &incoming, &outgoing)

cmd/goose/icons.go

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ package main
22

33
import (
44
"log/slog"
5-
"os"
6-
"path/filepath"
75
)
86

97
// Icon variables are defined in platform-specific files:
@@ -14,12 +12,13 @@ import (
1412
type IconType int
1513

1614
const (
17-
IconSmiling IconType = iota // No blocked PRs
18-
IconGoose // Incoming PRs blocked
19-
IconPopper // Outgoing PRs blocked
20-
IconBoth // Both incoming and outgoing blocked
21-
IconWarning // General error/warning
22-
IconLock // Authentication error
15+
IconSmiling IconType = iota // No blocked PRs
16+
IconGoose // Incoming PRs blocked
17+
IconPopper // Outgoing PRs blocked
18+
IconCockroach // Outgoing PRs blocked (fix_tests only)
19+
IconBoth // Both incoming and outgoing blocked
20+
IconWarning // General error/warning
21+
IconLock // Authentication error
2322
)
2423

2524
// getIcon returns the icon bytes for the given type.
@@ -29,6 +28,8 @@ func getIcon(iconType IconType) []byte {
2928
return iconGoose
3029
case IconPopper:
3130
return iconPopper
31+
case IconCockroach:
32+
return iconCockroach
3233
case IconSmiling:
3334
return iconSmiling
3435
case IconWarning:
@@ -43,17 +44,6 @@ func getIcon(iconType IconType) []byte {
4344
}
4445
}
4546

46-
// loadIconFromFile loads an icon from the filesystem (fallback if embed fails).
47-
func loadIconFromFile(filename string) []byte {
48-
iconPath := filepath.Join("icons", filename)
49-
data, err := os.ReadFile(iconPath)
50-
if err != nil {
51-
slog.Warn("Failed to load icon file", "path", iconPath, "error", err)
52-
return nil
53-
}
54-
return data
55-
}
56-
5747
// setTrayIcon updates the system tray icon based on PR counts.
5848
func (app *App) setTrayIcon(iconType IconType) {
5949
iconBytes := getIcon(iconType)

cmd/goose/icons/cockroach.ico

36.9 KB
Binary file not shown.

cmd/goose/icons/cockroach.png

33.2 KB
Loading

cmd/goose/icons_unix.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,6 @@ var iconLock []byte
2222

2323
//go:embed icons/warning.png
2424
var iconWarning []byte
25+
26+
//go:embed icons/cockroach.png
27+
var iconCockroach []byte

cmd/goose/icons_windows.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,8 @@ var iconSmiling []byte
2020
//go:embed icons/warning.ico
2121
var iconWarning []byte
2222

23+
//go:embed icons/cockroach.ico
24+
var iconCockroach []byte
25+
2326
// lock.ico not yet created, using warning as fallback
24-
var iconLock = iconWarning
27+
var iconLock = iconWarning

cmd/goose/main.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,9 @@ func main() {
227227
githubCircuit: newCircuitBreaker("github", 5, 2*time.Minute),
228228
}
229229

230+
// Set app reference in health monitor for sprinkler status
231+
app.healthMonitor.app = app
232+
230233
// Load saved settings
231234
app.loadSettings()
232235

@@ -270,6 +273,13 @@ func main() {
270273
if app.targetUser != "" && app.targetUser != user.GetLogin() {
271274
slog.Info("Querying PRs for different user", "targetUser", sanitizeForLog(app.targetUser))
272275
}
276+
277+
// Initialize sprinkler with user's organizations now that we have the user
278+
go func() {
279+
if err := app.initSprinklerOrgs(ctx); err != nil {
280+
slog.Warn("[SPRINKLER] Failed to initialize organizations", "error", err)
281+
}
282+
}()
273283
} else {
274284
slog.Warn("GitHub API returned nil user")
275285
}
@@ -822,7 +832,14 @@ func (app *App) updatePRsWithWait(ctx context.Context) {
822832

823833
// tryAutoOpenPR attempts to open a PR in the browser if enabled and rate limits allow.
824834
func (app *App) tryAutoOpenPR(ctx context.Context, pr PR, autoBrowserEnabled bool, startTime time.Time) {
835+
slog.Debug("[BROWSER] tryAutoOpenPR called",
836+
"repo", pr.Repository,
837+
"number", pr.Number,
838+
"enabled", autoBrowserEnabled,
839+
"time_since_start", time.Since(startTime).Round(time.Second))
840+
825841
if !autoBrowserEnabled {
842+
slog.Debug("[BROWSER] Auto-open disabled, skipping")
826843
return
827844
}
828845

cmd/goose/reliability.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ type healthMonitor struct {
114114
apiErrors int64
115115
cacheHits int64
116116
cacheMisses int64
117+
app *App // Reference to app for accessing sprinkler status
117118
}
118119

119120
func newHealthMonitor() *healthMonitor {
@@ -174,10 +175,24 @@ func (hm *healthMonitor) getMetrics() map[string]interface{} {
174175

175176
func (hm *healthMonitor) logMetrics() {
176177
metrics := hm.getMetrics()
178+
179+
// Get sprinkler connection status
180+
sprinklerConnected := false
181+
sprinklerLastConnected := ""
182+
if hm.app.sprinklerMonitor != nil {
183+
connected, lastConnectedAt := hm.app.sprinklerMonitor.connectionStatus()
184+
sprinklerConnected = connected
185+
if !lastConnectedAt.IsZero() {
186+
sprinklerLastConnected = time.Since(lastConnectedAt).Round(time.Second).String() + " ago"
187+
}
188+
}
189+
177190
slog.Info("[HEALTH] Application metrics",
178191
"uptime", metrics["uptime"],
179192
"api_calls", metrics["api_calls"],
180193
"api_errors", metrics["api_errors"],
181194
"error_rate_pct", fmt.Sprintf("%.1f", metrics["error_rate"]),
182-
"cache_hit_rate_pct", fmt.Sprintf("%.1f", metrics["cache_hit_rate"]))
195+
"cache_hit_rate_pct", fmt.Sprintf("%.1f", metrics["cache_hit_rate"]),
196+
"sprinkler_connected", sprinklerConnected,
197+
"sprinkler_last_connected", sprinklerLastConnected)
183198
}

0 commit comments

Comments
 (0)