99 "io"
1010 "log"
1111 "net/http"
12+ "net/url"
1213 "os/exec"
1314 "strings"
1415 "time"
@@ -94,11 +95,11 @@ func newGitHubClient(ctx context.Context) (*GitHubClient, error) {
9495}
9596
9697// makeRequest makes an HTTP request to the GitHub API with retry logic.
97- func (c * GitHubClient ) makeRequest (ctx context.Context , method , url string , body any ) (* http.Response , error ) {
98- log .Printf ("[HTTP] %s %s" , method , url )
98+ func (c * GitHubClient ) makeRequest (ctx context.Context , method , apiURL string , body any ) (* http.Response , error ) {
99+ log .Printf ("[HTTP] %s %s" , method , apiURL )
99100
100101 var resp * http.Response
101- err := retryWithBackoff (ctx , fmt .Sprintf ("%s %s" , method , url ), func () error {
102+ err := retryWithBackoff (ctx , fmt .Sprintf ("%s %s" , method , apiURL ), func () error {
102103 var bodyReader io.Reader
103104 if body != nil {
104105 bodyBytes , err := json .Marshal (body )
@@ -108,7 +109,7 @@ func (c *GitHubClient) makeRequest(ctx context.Context, method, url string, body
108109 bodyReader = bytes .NewReader (bodyBytes )
109110 }
110111
111- req , err := http .NewRequestWithContext (ctx , method , url , bodyReader )
112+ req , err := http .NewRequestWithContext (ctx , method , apiURL , bodyReader )
112113 if err != nil {
113114 return fmt .Errorf ("failed to create request: %w" , err )
114115 }
@@ -149,7 +150,7 @@ func (c *GitHubClient) makeRequest(ctx context.Context, method, url string, body
149150 return nil , err
150151 }
151152
152- log .Printf ("[HTTP] %s %s - Status: %d" , method , url , resp .StatusCode )
153+ log .Printf ("[HTTP] %s %s - Status: %d" , method , apiURL , resp .StatusCode )
153154 return resp , nil
154155}
155156
@@ -168,8 +169,8 @@ func (c *GitHubClient) pullRequestWithUpdatedAt(
168169 }
169170
170171 log .Printf ("[API] Fetching PR details for %s/%s#%d to get title, state, author, assignees, reviewers, and metadata" , owner , repo , prNumber )
171- url := fmt .Sprintf ("https://api.github.com/repos/%s/%s/pulls/%d" , owner , repo , prNumber )
172- resp , err := c .makeRequest (ctx , "GET" , url , nil )
172+ apiURL := fmt .Sprintf ("https://api.github.com/repos/%s/%s/pulls/%d" , owner , repo , prNumber )
173+ resp , err := c .makeRequest (ctx , "GET" , apiURL , nil )
173174 if err != nil {
174175 return nil , err
175176 }
@@ -282,11 +283,11 @@ func (c *GitHubClient) openPullRequests(ctx context.Context, owner, repo string)
282283
283284 for {
284285 log .Printf ("[API] Requesting page %d of open PRs for %s/%s (pagination)" , page , owner , repo )
285- url := fmt .Sprintf ("https://api.github.com/repos/%s/%s/pulls?state=open&per_page=100&page=%d" , owner , repo , page )
286+ apiURL := fmt .Sprintf ("https://api.github.com/repos/%s/%s/pulls?state=open&per_page=100&page=%d" , owner , repo , page )
286287
287288 // Extract API call to avoid defer in loop
288289 prs , shouldBreak , err := func () ([]json.RawMessage , bool , error ) {
289- resp , err := c .makeRequest (ctx , "GET" , url , nil )
290+ resp , err := c .makeRequest (ctx , "GET" , apiURL , nil )
290291 if err != nil {
291292 return nil , false , err
292293 }
@@ -344,8 +345,8 @@ func (c *GitHubClient) openPullRequests(ctx context.Context, owner, repo string)
344345// changedFiles fetches the list of changed files in a PR.
345346func (c * GitHubClient ) changedFiles (ctx context.Context , owner , repo string , prNumber int ) ([]ChangedFile , error ) {
346347 log .Printf ("[API] Fetching changed files for PR %s/%s#%d to determine modified files for reviewer expertise matching" , owner , repo , prNumber )
347- url := fmt .Sprintf ("https://api.github.com/repos/%s/%s/pulls/%d/files?per_page=100" , owner , repo , prNumber )
348- resp , err := c .makeRequest (ctx , "GET" , url , nil )
348+ apiURL := fmt .Sprintf ("https://api.github.com/repos/%s/%s/pulls/%d/files?per_page=100" , owner , repo , prNumber )
349+ resp , err := c .makeRequest (ctx , "GET" , apiURL , nil )
349350 if err != nil {
350351 return nil , err
351352 }
@@ -382,8 +383,8 @@ func (c *GitHubClient) changedFiles(ctx context.Context, owner, repo string, prN
382383// lastCommitTime returns the timestamp of the last commit.
383384func (c * GitHubClient ) lastCommitTime (ctx context.Context , owner , repo , sha string ) (time.Time , error ) {
384385 log .Printf ("[API] Fetching commit details for %s/%s@%s to get last commit timestamp for PR staleness analysis" , owner , repo , sha )
385- url := fmt .Sprintf ("https://api.github.com/repos/%s/%s/commits/%s" , owner , repo , sha )
386- resp , err := c .makeRequest (ctx , "GET" , url , nil )
386+ apiURL := fmt .Sprintf ("https://api.github.com/repos/%s/%s/commits/%s" , owner , repo , sha )
387+ resp , err := c .makeRequest (ctx , "GET" , apiURL , nil )
387388 if err != nil {
388389 return time.Time {}, err
389390 }
@@ -411,8 +412,8 @@ func (c *GitHubClient) lastCommitTime(ctx context.Context, owner, repo, sha stri
411412// lastReviewTime returns the timestamp of the last review.
412413func (c * GitHubClient ) lastReviewTime (ctx context.Context , owner , repo string , prNumber int ) (time.Time , error ) {
413414 log .Printf ("[API] Fetching review history for PR %s/%s#%d to determine last review timestamp for staleness detection" , owner , repo , prNumber )
414- url := fmt .Sprintf ("https://api.github.com/repos/%s/%s/pulls/%d/reviews" , owner , repo , prNumber )
415- resp , err := c .makeRequest (ctx , "GET" , url , nil )
415+ apiURL := fmt .Sprintf ("https://api.github.com/repos/%s/%s/pulls/%d/reviews" , owner , repo , prNumber )
416+ resp , err := c .makeRequest (ctx , "GET" , apiURL , nil )
416417 if err != nil {
417418 return time.Time {}, err
418419 }
@@ -564,3 +565,102 @@ func (*ReviewerFinder) hasWriteAccess(ctx context.Context, _ string, _ string, _
564565 // In production, this would check collaborator status
565566 return true
566567}
568+
569+ // openPRCount returns the number of open PRs assigned to or requested for review by a user in an organization.
570+ func (c * GitHubClient ) openPRCount (ctx context.Context , org , user string , cacheTTL time.Duration ) (int , error ) {
571+ // Check cache first for successful results
572+ cacheKey := makeCacheKey ("pr-count" , org , user )
573+ if cached , found := c .cache .value (cacheKey ); found {
574+ if count , ok := cached .(int ); ok {
575+ log .Printf (" [CACHE] User %s has %d non-stale open PRs in org %s (cached)" , user , count , org )
576+ return count , nil
577+ }
578+ }
579+
580+ // Check if we recently failed to get PR count for this user to avoid repeated failures
581+ failureKey := makeCacheKey ("pr-count-failure" , org , user )
582+ if _ , found := c .cache .value (failureKey ); found {
583+ return 0 , errors .New ("recently failed to get PR count (cached failure)" )
584+ }
585+
586+ // Validate that the organization and user are not empty
587+ if org == "" || user == "" {
588+ return 0 , fmt .Errorf ("invalid organization (%s) or user (%s)" , org , user )
589+ }
590+
591+ log .Printf (" [API] Fetching open PR count for user %s in org %s" , user , org )
592+
593+ // Create a context with shorter timeout for PR count queries to avoid hanging
594+ timeoutCtx , cancel := context .WithTimeout (ctx , 30 * time .Second )
595+ defer cancel ()
596+
597+ // Calculate the cutoff date for non-stale PRs (90 days ago)
598+ cutoffDate := time .Now ().AddDate (0 , 0 , - prStaleDaysThreshold ).Format ("2006-01-02" )
599+
600+ // Use two separate queries as they are simpler and more reliable
601+ // Only count PRs updated within the last 90 days to exclude stale PRs
602+ // First, search for PRs where user is assigned
603+ assignedQuery := fmt .Sprintf ("is:pr is:open org:%s assignee:%s updated:>=%s" , org , user , cutoffDate )
604+ log .Printf (" [DEBUG] Searching assigned PRs for %s (updated since %s)" , user , cutoffDate )
605+ assignedCount , err := c .searchPRCount (timeoutCtx , assignedQuery )
606+ if err != nil {
607+ // Cache the failure to avoid repeated attempts
608+ c .cache .setWithTTL (failureKey , true , prCountFailureCacheTTL )
609+ return 0 , fmt .Errorf ("failed to get assigned PR count: %w" , err )
610+ }
611+ log .Printf (" [DEBUG] Found %d non-stale assigned PRs for %s" , assignedCount , user )
612+
613+ // Second, search for PRs where user is requested as reviewer
614+ reviewQuery := fmt .Sprintf ("is:pr is:open org:%s review-requested:%s updated:>=%s" , org , user , cutoffDate )
615+ log .Printf (" [DEBUG] Searching review-requested PRs for %s (updated since %s)" , user , cutoffDate )
616+ reviewCount , err := c .searchPRCount (timeoutCtx , reviewQuery )
617+ if err != nil {
618+ // Cache the failure to avoid repeated attempts
619+ c .cache .setWithTTL (failureKey , true , prCountFailureCacheTTL )
620+ return 0 , fmt .Errorf ("failed to get review-requested PR count: %w" , err )
621+ }
622+ log .Printf (" [DEBUG] Found %d non-stale review-requested PRs for %s" , reviewCount , user )
623+
624+ total := assignedCount + reviewCount
625+
626+ log .Printf (" 📊 User %s has %d non-stale open PRs in org %s (%d assigned, %d for review)" , user , total , org , assignedCount , reviewCount )
627+
628+ // Cache the successful result
629+ c .cache .setWithTTL (cacheKey , total , cacheTTL )
630+
631+ return total , nil
632+ }
633+
634+ // searchPRCount searches for PRs matching a query and returns the count.
635+ func (c * GitHubClient ) searchPRCount (ctx context.Context , query string ) (int , error ) {
636+ encodedQuery := url .QueryEscape (query )
637+ apiURL := fmt .Sprintf ("https://api.github.com/search/issues?q=%s&per_page=1" , encodedQuery )
638+ log .Printf (" [DEBUG] Search query: %s" , query )
639+ log .Printf (" [DEBUG] Full URL: %s" , apiURL )
640+ resp , err := c .makeRequest (ctx , "GET" , apiURL , nil )
641+ if err != nil {
642+ return 0 , err
643+ }
644+ defer func () {
645+ if err := resp .Body .Close (); err != nil {
646+ log .Printf ("[WARN] Failed to close response body: %v" , err )
647+ }
648+ }()
649+
650+ if resp .StatusCode == http .StatusForbidden {
651+ return 0 , fmt .Errorf ("search API rate limit exceeded (status %d)" , resp .StatusCode )
652+ }
653+ if resp .StatusCode != http .StatusOK {
654+ return 0 , fmt .Errorf ("search failed (status %d)" , resp .StatusCode )
655+ }
656+
657+ var searchResult struct {
658+ TotalCount int `json:"total_count"`
659+ }
660+
661+ if err := json .NewDecoder (resp .Body ).Decode (& searchResult ); err != nil {
662+ return 0 , fmt .Errorf ("failed to decode search result: %w" , err )
663+ }
664+
665+ return searchResult .TotalCount , nil
666+ }
0 commit comments