Skip to content

Commit 81053e4

Browse files
committed
merge up
2 parents 3f7afa9 + 5bad27f commit 81053e4

File tree

11 files changed

+864
-151
lines changed

11 files changed

+864
-151
lines changed

cmd/goose/cache.go

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ func (app *App) turnData(ctx context.Context, url string, updatedAt time.Time) (
3535
hash := sha256.Sum256([]byte(key))
3636
cacheFile := filepath.Join(app.cacheDir, hex.EncodeToString(hash[:])[:16]+".json")
3737

38+
// Log the cache key details
39+
slog.Debug("[CACHE] Checking cache",
40+
"url", url,
41+
"updated_at", updatedAt.Format(time.RFC3339),
42+
"cache_key", key,
43+
"cache_file", filepath.Base(cacheFile))
44+
3845
// Skip cache if --no-cache flag is set
3946
if !app.noCache {
4047
// Try to read from cache (gracefully handle all cache errors)
@@ -47,17 +54,46 @@ func (app *App) turnData(ctx context.Context, url string, updatedAt time.Time) (
4754
slog.Error("Failed to remove corrupted cache file", "error", removeErr)
4855
}
4956
} else if time.Since(entry.CachedAt) < cacheTTL && entry.UpdatedAt.Equal(updatedAt) {
50-
// Check if cache is still valid (2 hour TTL)
57+
// Check if cache is still valid (10 day TTL, but PR UpdatedAt is primary check)
58+
slog.Debug("[CACHE] Cache hit",
59+
"url", url,
60+
"cached_at", entry.CachedAt.Format(time.RFC3339),
61+
"cache_age", time.Since(entry.CachedAt).Round(time.Second),
62+
"pr_updated_at", entry.UpdatedAt.Format(time.RFC3339))
63+
if app.healthMonitor != nil {
64+
app.healthMonitor.recordCacheAccess(true)
65+
}
5166
return entry.Data, true, nil
67+
} else {
68+
// Log why cache was invalid
69+
if !entry.UpdatedAt.Equal(updatedAt) {
70+
slog.Debug("[CACHE] Cache miss - PR updated",
71+
"url", url,
72+
"cached_pr_time", entry.UpdatedAt.Format(time.RFC3339),
73+
"current_pr_time", updatedAt.Format(time.RFC3339))
74+
} else if time.Since(entry.CachedAt) >= cacheTTL {
75+
slog.Debug("[CACHE] Cache miss - TTL expired",
76+
"url", url,
77+
"cached_at", entry.CachedAt.Format(time.RFC3339),
78+
"cache_age", time.Since(entry.CachedAt).Round(time.Second),
79+
"ttl", cacheTTL)
80+
}
5281
}
82+
} else if !os.IsNotExist(readErr) {
83+
slog.Debug("[CACHE] Cache file read error", "url", url, "error", readErr)
5384
}
5485
}
5586

5687
// Cache miss, fetch from API
5788
if app.noCache {
5889
slog.Debug("Cache bypassed (--no-cache), fetching from Turn API", "url", url)
5990
} else {
60-
slog.Debug("Cache miss, fetching from Turn API", "url", url)
91+
slog.Info("[CACHE] Cache miss, fetching from Turn API",
92+
"url", url,
93+
"pr_updated_at", updatedAt.Format(time.RFC3339))
94+
if app.healthMonitor != nil {
95+
app.healthMonitor.recordCacheAccess(false)
96+
}
6197
}
6298

6399
// Use exponential backoff with jitter for Turn API calls
@@ -68,11 +104,16 @@ func (app *App) turnData(ctx context.Context, url string, updatedAt time.Time) (
68104
defer cancel()
69105

70106
var retryErr error
107+
slog.Debug("[TURN] Making API call",
108+
"url", url,
109+
"user", app.currentUser.GetLogin(),
110+
"pr_updated_at", updatedAt.Format(time.RFC3339))
71111
data, retryErr = app.turnClient.Check(turnCtx, url, app.currentUser.GetLogin(), updatedAt)
72112
if retryErr != nil {
73113
slog.Warn("Turn API error (will retry)", "error", retryErr)
74114
return retryErr
75115
}
116+
slog.Debug("[TURN] API call successful", "url", url)
76117
return nil
77118
},
78119
retry.Attempts(maxRetries),
@@ -85,9 +126,16 @@ func (app *App) turnData(ctx context.Context, url string, updatedAt time.Time) (
85126
)
86127
if err != nil {
87128
slog.Error("Turn API error after retries (will use PR without metadata)", "maxRetries", maxRetries, "error", err)
129+
if app.healthMonitor != nil {
130+
app.healthMonitor.recordAPICall(false)
131+
}
88132
return nil, false, err
89133
}
90134

135+
if app.healthMonitor != nil {
136+
app.healthMonitor.recordAPICall(true)
137+
}
138+
91139
// Save to cache (don't fail if caching fails) - skip if --no-cache is set
92140
if !app.noCache {
93141
entry := cacheEntry{
@@ -103,14 +151,20 @@ func (app *App) turnData(ctx context.Context, url string, updatedAt time.Time) (
103151
slog.Error("Failed to create cache directory", "error", dirErr)
104152
} else if writeErr := os.WriteFile(cacheFile, cacheData, 0o600); writeErr != nil {
105153
slog.Error("Failed to write cache file", "error", writeErr)
154+
} else {
155+
slog.Debug("[CACHE] Saved to cache",
156+
"url", url,
157+
"cached_at", entry.CachedAt.Format(time.RFC3339),
158+
"pr_updated_at", entry.UpdatedAt.Format(time.RFC3339),
159+
"cache_file", filepath.Base(cacheFile))
106160
}
107161
}
108162
}
109163

110164
return data, false, nil
111165
}
112166

113-
// cleanupOldCache removes cache files older than 5 days.
167+
// cleanupOldCache removes cache files older than the cleanup interval (15 days).
114168
func (app *App) cleanupOldCache() {
115169
entries, err := os.ReadDir(app.cacheDir)
116170
if err != nil {
@@ -131,7 +185,7 @@ func (app *App) cleanupOldCache() {
131185
continue
132186
}
133187

134-
// Remove cache files older than 5 days
188+
// Remove cache files older than cleanup interval (15 days)
135189
if time.Since(info.ModTime()) > cacheCleanupInterval {
136190
filePath := filepath.Join(app.cacheDir, entry.Name())
137191
if removeErr := os.Remove(filePath); removeErr != nil {

cmd/goose/deadlock_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ func TestConcurrentMenuOperations(t *testing.T) {
1414
hiddenOrgs: make(map[string]bool),
1515
seenOrgs: make(map[string]bool),
1616
blockedPRTimes: make(map[string]time.Time),
17-
browserRateLimiter: NewBrowserRateLimiter(30*time.Second, 5, defaultMaxBrowserOpensDay),
17+
browserRateLimiter: NewBrowserRateLimiter(startupGracePeriod, 5, defaultMaxBrowserOpensDay),
1818
systrayInterface: &MockSystray{},
1919
incoming: []PR{
2020
{Repository: "org1/repo1", Number: 1, Title: "Fix bug", URL: "https://github.com/org1/repo1/pull/1"},
@@ -101,7 +101,7 @@ func TestMenuClickDeadlockScenario(t *testing.T) {
101101
hiddenOrgs: make(map[string]bool),
102102
seenOrgs: make(map[string]bool),
103103
blockedPRTimes: make(map[string]time.Time),
104-
browserRateLimiter: NewBrowserRateLimiter(30*time.Second, 5, defaultMaxBrowserOpensDay),
104+
browserRateLimiter: NewBrowserRateLimiter(startupGracePeriod, 5, defaultMaxBrowserOpensDay),
105105
systrayInterface: &MockSystray{},
106106
incoming: []PR{
107107
{Repository: "org1/repo1", Number: 1, Title: "Test PR", URL: "https://github.com/org1/repo1/pull/1"},
@@ -142,7 +142,7 @@ func TestRapidMenuClicks(t *testing.T) {
142142
hiddenOrgs: make(map[string]bool),
143143
seenOrgs: make(map[string]bool),
144144
blockedPRTimes: make(map[string]time.Time),
145-
browserRateLimiter: NewBrowserRateLimiter(30*time.Second, 5, defaultMaxBrowserOpensDay),
145+
browserRateLimiter: NewBrowserRateLimiter(startupGracePeriod, 5, defaultMaxBrowserOpensDay),
146146
systrayInterface: &MockSystray{},
147147
lastSearchAttempt: time.Now().Add(-15 * time.Second), // Allow first click
148148
incoming: []PR{

cmd/goose/github.go

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,14 @@ import (
2222

2323
// extractOrgFromRepo extracts the organization name from a repository path like "org/repo".
2424
func extractOrgFromRepo(repo string) string {
25-
parts := strings.Split(repo, "/")
26-
if len(parts) >= 1 {
27-
return parts[0]
25+
idx := strings.Index(repo, "/")
26+
if idx > 0 {
27+
return repo[:idx]
2828
}
29-
return ""
29+
if idx == 0 {
30+
return "" // Invalid: starts with "/"
31+
}
32+
return repo // No slash: return as-is (single segment or empty)
3033
}
3134

3235
// initClients initializes GitHub and Turn API clients.
@@ -180,25 +183,42 @@ func (app *App) executeGitHubQuery(ctx context.Context, query string, opts *gith
180183
var result *github.IssuesSearchResult
181184
var resp *github.Response
182185

183-
err := retry.Do(func() error {
186+
// Use circuit breaker if available
187+
if app.githubCircuit != nil {
188+
err := app.githubCircuit.call(func() error {
189+
return app.executeGitHubQueryInternal(ctx, query, opts, &result, &resp)
190+
})
191+
if err != nil {
192+
return nil, err
193+
}
194+
return result, nil
195+
}
196+
197+
// Fallback to direct execution
198+
err := app.executeGitHubQueryInternal(ctx, query, opts, &result, &resp)
199+
return result, err
200+
}
201+
202+
func (app *App) executeGitHubQueryInternal(ctx context.Context, query string, opts *github.SearchOptions, result **github.IssuesSearchResult, resp **github.Response) error {
203+
return retry.Do(func() error {
184204
// Create timeout context for GitHub API call
185205
githubCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
186206
defer cancel()
187207

188208
var retryErr error
189-
result, resp, retryErr = app.client.Search.Issues(githubCtx, query, opts)
209+
*result, *resp, retryErr = app.client.Search.Issues(githubCtx, query, opts)
190210
if retryErr != nil {
191211
// Enhanced error handling with specific cases
192-
if resp != nil {
212+
if *resp != nil {
193213
const (
194214
httpStatusUnauthorized = 401
195215
httpStatusForbidden = 403
196216
httpStatusUnprocessable = 422
197217
)
198-
switch resp.StatusCode {
218+
switch (*resp).StatusCode {
199219
case httpStatusForbidden:
200-
if resp.Header.Get("X-Ratelimit-Remaining") == "0" {
201-
resetTime := resp.Header.Get("X-Ratelimit-Reset")
220+
if (*resp).Header.Get("X-Ratelimit-Remaining") == "0" {
221+
resetTime := (*resp).Header.Get("X-Ratelimit-Reset")
202222
slog.Warn("GitHub API rate limited (will retry)", "resetTime", resetTime)
203223
return retryErr // Retry on rate limit
204224
}
@@ -211,7 +231,7 @@ func (app *App) executeGitHubQuery(ctx context.Context, query string, opts *gith
211231
slog.Error("GitHub API query invalid", "query", query)
212232
return retry.Unrecoverable(fmt.Errorf("github API query invalid: %w", retryErr))
213233
default:
214-
slog.Warn("GitHub API error (will retry)", "statusCode", resp.StatusCode, "error", retryErr)
234+
slog.Warn("GitHub API error (will retry)", "statusCode", (*resp).StatusCode, "error", retryErr)
215235
}
216236
} else {
217237
// Likely network error - retry these
@@ -229,10 +249,6 @@ func (app *App) executeGitHubQuery(ctx context.Context, query string, opts *gith
229249
}),
230250
retry.Context(ctx),
231251
)
232-
if err != nil {
233-
return nil, err
234-
}
235-
return result, nil
236252
}
237253

238254
// prResult holds the result of a Turn API query for a PR.
@@ -371,8 +387,10 @@ func (app *App) fetchPRsInternal(ctx context.Context) (incoming []PR, outgoing [
371387
Title: issue.GetTitle(),
372388
URL: issue.GetHTMLURL(),
373389
Repository: repo,
390+
Author: issue.GetUser().GetLogin(),
374391
Number: issue.GetNumber(),
375392
UpdatedAt: issue.GetUpdatedAt().Time,
393+
IsDraft: issue.GetDraft(),
376394
}
377395

378396
// Categorize as incoming or outgoing
@@ -477,22 +495,24 @@ func (app *App) fetchTurnDataSync(ctx context.Context, issues []*github.Issue, u
477495
// Update the PR in the slices directly
478496
if result.isOwner {
479497
for i := range *outgoing {
480-
if (*outgoing)[i].URL == result.url {
481-
(*outgoing)[i].NeedsReview = needsReview
482-
(*outgoing)[i].IsBlocked = isBlocked
483-
(*outgoing)[i].ActionReason = actionReason
484-
(*outgoing)[i].ActionKind = actionKind
485-
break
498+
if (*outgoing)[i].URL != result.url {
499+
continue
486500
}
501+
(*outgoing)[i].NeedsReview = needsReview
502+
(*outgoing)[i].IsBlocked = isBlocked
503+
(*outgoing)[i].ActionReason = actionReason
504+
(*outgoing)[i].ActionKind = actionKind
505+
break
487506
}
488507
} else {
489508
for i := range *incoming {
490-
if (*incoming)[i].URL == result.url {
491-
(*incoming)[i].NeedsReview = needsReview
492-
(*incoming)[i].ActionReason = actionReason
493-
(*incoming)[i].ActionKind = actionKind
494-
break
509+
if (*incoming)[i].URL != result.url {
510+
continue
495511
}
512+
(*incoming)[i].NeedsReview = needsReview
513+
(*incoming)[i].ActionReason = actionReason
514+
(*incoming)[i].ActionKind = actionKind
515+
break
496516
}
497517
}
498518
} else if result.err != nil {

0 commit comments

Comments
 (0)