@@ -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.
121124func (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+ }
0 commit comments