Skip to content

Commit 9fa6b42

Browse files
committed
Improve rendering performance & reliability
1 parent 826908d commit 9fa6b42

File tree

7 files changed

+570
-94
lines changed

7 files changed

+570
-94
lines changed

cache.go

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,18 @@ func (app *App) turnData(ctx context.Context, url string, updatedAt time.Time) (
2929
hash := sha256.Sum256([]byte(key))
3030
cacheFile := filepath.Join(app.cacheDir, hex.EncodeToString(hash[:])[:16]+".json")
3131

32-
// Try to read from cache
33-
if data, err := os.ReadFile(cacheFile); err == nil {
32+
// Try to read from cache (gracefully handle all cache errors)
33+
if data, readErr := os.ReadFile(cacheFile); readErr == nil {
3434
var entry cacheEntry
35-
if err := json.Unmarshal(data, &entry); err == nil {
36-
// Check if cache is still valid (2 hour TTL)
37-
if time.Since(entry.CachedAt) < cacheTTL && entry.UpdatedAt.Equal(updatedAt) {
38-
return entry.Data, nil
35+
if unmarshalErr := json.Unmarshal(data, &entry); unmarshalErr != nil {
36+
log.Printf("Failed to unmarshal cache data for %s: %v", url, unmarshalErr)
37+
// Remove corrupted cache file
38+
if removeErr := os.Remove(cacheFile); removeErr != nil {
39+
log.Printf("Failed to remove corrupted cache file: %v", removeErr)
3940
}
41+
} else if time.Since(entry.CachedAt) < cacheTTL && entry.UpdatedAt.Equal(updatedAt) {
42+
// Check if cache is still valid (2 hour TTL)
43+
return entry.Data, nil
4044
}
4145
}
4246

@@ -53,16 +57,20 @@ func (app *App) turnData(ctx context.Context, url string, updatedAt time.Time) (
5357
return nil, err
5458
}
5559

56-
// Save to cache
60+
// Save to cache (don't fail if caching fails)
5761
entry := cacheEntry{
5862
Data: data,
5963
CachedAt: time.Now(),
6064
UpdatedAt: updatedAt,
6165
}
62-
cacheData, err := json.Marshal(entry)
63-
if err == nil {
64-
if err := os.WriteFile(cacheFile, cacheData, 0o600); err != nil {
65-
log.Printf("Failed to write cache for %s: %v", url, err)
66+
if cacheData, marshalErr := json.Marshal(entry); marshalErr != nil {
67+
log.Printf("Failed to marshal cache data for %s: %v", url, marshalErr)
68+
} else {
69+
// Ensure cache directory exists
70+
if dirErr := os.MkdirAll(filepath.Dir(cacheFile), 0o700); dirErr != nil {
71+
log.Printf("Failed to create cache directory: %v", dirErr)
72+
} else if writeErr := os.WriteFile(cacheFile, cacheData, 0o600); writeErr != nil {
73+
log.Printf("Failed to write cache file for %s: %v", url, writeErr)
6674
}
6775
}
6876

@@ -73,24 +81,36 @@ func (app *App) turnData(ctx context.Context, url string, updatedAt time.Time) (
7381
func (app *App) cleanupOldCache() {
7482
entries, err := os.ReadDir(app.cacheDir)
7583
if err != nil {
84+
log.Printf("Failed to read cache directory for cleanup: %v", err)
7685
return
7786
}
7887

88+
var cleanupCount, errorCount int
7989
for _, entry := range entries {
8090
if !strings.HasSuffix(entry.Name(), ".json") {
8191
continue
8292
}
8393

8494
info, err := entry.Info()
8595
if err != nil {
96+
log.Printf("Failed to get file info for cache entry %s: %v", entry.Name(), err)
97+
errorCount++
8698
continue
8799
}
88100

89101
// Remove cache files older than 5 days
90102
if time.Since(info.ModTime()) > cacheCleanupInterval {
91-
if err := os.Remove(filepath.Join(app.cacheDir, entry.Name())); err != nil {
92-
log.Printf("Failed to remove old cache: %v", err)
103+
filePath := filepath.Join(app.cacheDir, entry.Name())
104+
if removeErr := os.Remove(filePath); removeErr != nil {
105+
log.Printf("Failed to remove old cache file %s: %v", filePath, removeErr)
106+
errorCount++
107+
} else {
108+
cleanupCount++
93109
}
94110
}
95111
}
112+
113+
if cleanupCount > 0 || errorCount > 0 {
114+
log.Printf("Cache cleanup completed: %d files removed, %d errors", cleanupCount, errorCount)
115+
}
96116
}

github.go

Lines changed: 211 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import (
1111
"path/filepath"
1212
"runtime"
1313
"strings"
14+
"sync"
1415
"time"
1516

17+
"github.com/codeGROOVE-dev/retry"
1618
"github.com/google/go-github/v57/github"
1719
"github.com/ready-to-review/turnclient/pkg/turn"
1820
"golang.org/x/oauth2"
@@ -118,6 +120,7 @@ func (*App) githubToken(ctx context.Context) (string, error) {
118120
}
119121

120122
// fetchPRs retrieves all PRs involving the current user.
123+
// It returns GitHub data immediately and starts Turn API queries in the background.
121124
func (app *App) fetchPRs(ctx context.Context) (incoming []PR, outgoing []PR, err error) {
122125
// Use targetUser if specified, otherwise use authenticated user
123126
user := app.currentUser.GetLogin()
@@ -138,13 +141,40 @@ func (app *App) fetchPRs(ctx context.Context) (incoming []PR, outgoing []PR, err
138141
log.Printf("Searching for PRs with query: %s", query)
139142
searchStart := time.Now()
140143

141-
// Just try once - GitHub API is reliable enough
142-
result, resp, err := app.client.Search.Issues(ctx, query, opts)
144+
// Create timeout context for GitHub API call
145+
githubCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
146+
defer cancel()
147+
148+
result, resp, err := app.client.Search.Issues(githubCtx, query, opts)
143149
if err != nil {
144-
// Check for rate limit
145-
const httpStatusForbidden = 403
146-
if resp != nil && resp.StatusCode == httpStatusForbidden {
147-
log.Print("GitHub API rate limited")
150+
// Enhanced error handling with specific cases
151+
if resp != nil {
152+
const (
153+
httpStatusUnauthorized = 401
154+
httpStatusForbidden = 403
155+
httpStatusUnprocessable = 422
156+
)
157+
switch resp.StatusCode {
158+
case httpStatusForbidden:
159+
if resp.Header.Get("X-Ratelimit-Remaining") == "0" {
160+
resetTime := resp.Header.Get("X-Ratelimit-Reset")
161+
log.Printf("GitHub API rate limited, reset at: %s", resetTime)
162+
return nil, nil, fmt.Errorf("github API rate limited, try again later: %w", err)
163+
}
164+
log.Print("GitHub API access forbidden (check token permissions)")
165+
return nil, nil, fmt.Errorf("github API access forbidden: %w", err)
166+
case httpStatusUnauthorized:
167+
log.Print("GitHub API authentication failed (check token)")
168+
return nil, nil, fmt.Errorf("github API authentication failed: %w", err)
169+
case httpStatusUnprocessable:
170+
log.Printf("GitHub API query invalid: %s", query)
171+
return nil, nil, fmt.Errorf("github API query invalid: %w", err)
172+
default:
173+
log.Printf("GitHub API error (status %d): %v", resp.StatusCode, err)
174+
}
175+
} else {
176+
// Likely network error
177+
log.Printf("GitHub API network error: %v", err)
148178
}
149179
return nil, nil, fmt.Errorf("search PRs: %w", err)
150180
}
@@ -157,10 +187,7 @@ func (app *App) fetchPRs(ctx context.Context) (incoming []PR, outgoing []PR, err
157187
result.Issues = result.Issues[:maxPRsToProcess]
158188
}
159189

160-
// Process results
161-
turnSuccesses := 0
162-
turnFailures := 0
163-
190+
// Process GitHub results immediately
164191
for _, issue := range result.Issues {
165192
if !issue.IsPullRequest() {
166193
continue
@@ -175,28 +202,188 @@ func (app *App) fetchPRs(ctx context.Context) (incoming []PR, outgoing []PR, err
175202
UpdatedAt: issue.GetUpdatedAt().Time,
176203
}
177204

178-
// Get Turn API data with caching
179-
turnData, err := app.turnData(ctx, issue.GetHTMLURL(), issue.GetUpdatedAt().Time)
180-
if err == nil && turnData != nil && turnData.PRState.UnblockAction != nil {
181-
turnSuccesses++
182-
if _, exists := turnData.PRState.UnblockAction[user]; exists {
183-
pr.NeedsReview = true
184-
}
185-
} else if err != nil {
186-
turnFailures++
187-
}
188-
189205
// Categorize as incoming or outgoing
190206
// When viewing another user's PRs, we're looking at it from their perspective
191207
if issue.GetUser().GetLogin() == user {
192-
pr.IsBlocked = pr.NeedsReview
193208
outgoing = append(outgoing, pr)
194209
} else {
195210
incoming = append(incoming, pr)
196211
}
197212
}
198213

199-
log.Printf("Found %d incoming, %d outgoing PRs (Turn API: %d/%d succeeded)",
200-
len(incoming), len(outgoing), turnSuccesses, turnSuccesses+turnFailures)
214+
log.Printf("[GITHUB] Found %d incoming, %d outgoing PRs from GitHub", len(incoming), len(outgoing))
215+
for _, pr := range incoming {
216+
log.Printf("[GITHUB] Incoming PR: %s", pr.URL)
217+
}
218+
for _, pr := range outgoing {
219+
log.Printf("[GITHUB] Outgoing PR: %s", pr.URL)
220+
}
221+
222+
// Start Turn API queries in background
223+
go app.fetchTurnDataAsync(ctx, result.Issues, user)
224+
201225
return incoming, outgoing, nil
202226
}
227+
228+
// updatePRData updates PR data with Turn API results.
229+
func (app *App) updatePRData(url string, needsReview bool, isOwner bool) *PR {
230+
app.mu.Lock()
231+
defer app.mu.Unlock()
232+
233+
if isOwner {
234+
// Update outgoing PRs
235+
for i := range app.outgoing {
236+
if app.outgoing[i].URL == url {
237+
app.outgoing[i].NeedsReview = needsReview
238+
app.outgoing[i].IsBlocked = needsReview
239+
return &app.outgoing[i]
240+
}
241+
}
242+
} else {
243+
// Update incoming PRs
244+
for i := range app.incoming {
245+
if app.incoming[i].URL == url {
246+
app.incoming[i].NeedsReview = needsReview
247+
return &app.incoming[i]
248+
}
249+
}
250+
}
251+
return nil
252+
}
253+
254+
// fetchTurnDataAsync fetches Turn API data in the background and updates PRs as results arrive.
255+
func (app *App) fetchTurnDataAsync(ctx context.Context, issues []*github.Issue, user string) {
256+
// Set loading state
257+
app.mu.Lock()
258+
app.turnDataLoading = true
259+
app.mu.Unlock()
260+
261+
// Update section headers to show loading state
262+
log.Print("[TURN] Starting Turn API queries, updating section headers to show loading state")
263+
app.updateSectionHeaders()
264+
265+
turnStart := time.Now()
266+
type prResult struct {
267+
err error
268+
turnData *turn.CheckResponse
269+
url string
270+
isOwner bool
271+
}
272+
273+
// Create a channel for results
274+
results := make(chan prResult, len(issues))
275+
276+
// Use a WaitGroup to track goroutines
277+
var wg sync.WaitGroup
278+
279+
// Process PRs in parallel
280+
for _, issue := range issues {
281+
if !issue.IsPullRequest() {
282+
continue
283+
}
284+
285+
wg.Add(1)
286+
go func(issue *github.Issue) {
287+
defer wg.Done()
288+
289+
// Retry logic for Turn API with exponential backoff and jitter
290+
var turnData *turn.CheckResponse
291+
var err error
292+
293+
turnData, err = retry.DoWithData(
294+
func() (*turn.CheckResponse, error) {
295+
data, apiErr := app.turnData(ctx, issue.GetHTMLURL(), issue.GetUpdatedAt().Time)
296+
if apiErr != nil {
297+
log.Printf("Turn API attempt failed for %s: %v", issue.GetHTMLURL(), apiErr)
298+
}
299+
return data, apiErr
300+
},
301+
retry.Context(ctx),
302+
retry.Attempts(5), // 5 attempts max
303+
retry.Delay(500*time.Millisecond), // Start with 500ms
304+
retry.MaxDelay(30*time.Second), // Cap at 30 seconds
305+
retry.DelayType(retry.FullJitterBackoffDelay), // Exponential backoff with jitter
306+
retry.OnRetry(func(attempt uint, err error) {
307+
log.Printf("Turn API retry attempt %d for %s: %v", attempt, issue.GetHTMLURL(), err)
308+
}),
309+
)
310+
if err != nil {
311+
log.Printf("Turn API failed after all retries for %s: %v", issue.GetHTMLURL(), err)
312+
}
313+
314+
results <- prResult{
315+
url: issue.GetHTMLURL(),
316+
turnData: turnData,
317+
err: err,
318+
isOwner: issue.GetUser().GetLogin() == user,
319+
}
320+
}(issue)
321+
}
322+
323+
// Close the results channel when all goroutines are done
324+
go func() {
325+
wg.Wait()
326+
close(results)
327+
}()
328+
329+
// Collect results and update PRs incrementally
330+
turnSuccesses := 0
331+
turnFailures := 0
332+
updatesApplied := 0
333+
334+
// Batch updates to reduce menu rebuilds
335+
updateBatch := 0
336+
const batchSize = 10
337+
lastUpdateTime := time.Now()
338+
const minUpdateInterval = 500 * time.Millisecond
339+
340+
for result := range results {
341+
if result.err == nil && result.turnData != nil && result.turnData.PRState.UnblockAction != nil {
342+
turnSuccesses++
343+
344+
// Check if user needs to review
345+
needsReview := false
346+
if _, exists := result.turnData.PRState.UnblockAction[user]; exists {
347+
needsReview = true
348+
}
349+
350+
// Update the PR in our lists
351+
pr := app.updatePRData(result.url, needsReview, result.isOwner)
352+
353+
if pr != nil {
354+
updatesApplied++
355+
updateBatch++
356+
log.Printf("[TURN] Turn data received for %s (needsReview=%v)", result.url, needsReview)
357+
// Update the specific menu item immediately
358+
app.updatePRMenuItem(*pr)
359+
360+
// Periodically update section headers and tray title
361+
if updateBatch >= batchSize || time.Since(lastUpdateTime) >= minUpdateInterval {
362+
log.Printf("[TURN] Batch update threshold reached (%d updates), updating headers and title", updateBatch)
363+
app.updateSectionHeaders()
364+
app.setTrayTitle()
365+
updateBatch = 0
366+
lastUpdateTime = time.Now()
367+
}
368+
}
369+
} else if result.err != nil {
370+
turnFailures++
371+
}
372+
}
373+
374+
// Clear loading state
375+
app.mu.Lock()
376+
app.turnDataLoading = false
377+
app.turnDataLoaded = true
378+
app.mu.Unlock()
379+
380+
log.Printf("[TURN] Turn API queries completed in %v (%d/%d succeeded, %d PRs updated)",
381+
time.Since(turnStart), turnSuccesses, turnSuccesses+turnFailures, updatesApplied)
382+
383+
// Update section headers with final counts
384+
log.Print("[TURN] Updating section headers and tray title with final counts")
385+
app.updateSectionHeaders()
386+
387+
// Update tray title
388+
app.setTrayTitle()
389+
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module ready-to-review
33
go 1.23.4
44

55
require (
6+
github.com/codeGROOVE-dev/retry v1.2.0
67
github.com/energye/systray v1.0.2
78
github.com/gen2brain/beeep v0.11.1
89
github.com/google/go-github/v57 v57.0.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE=
22
git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo=
3+
github.com/codeGROOVE-dev/retry v1.2.0 h1:xYpYPX2PQZmdHwuiQAGGzsBm392xIMl4nfMEFApQnu8=
4+
github.com/codeGROOVE-dev/retry v1.2.0/go.mod h1:8OgefgV1XP7lzX2PdKlCXILsYKuz6b4ZpHa/20iLi8E=
35
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
46
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
57
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

0 commit comments

Comments
 (0)