Skip to content

Commit af93c2a

Browse files
javoireclaude
andauthored
fix(github): replace bulk PR fetch with parallel per-branch fetches (#64)
On large repos (500+ open PRs), `gh pr list --limit 500` times out with 502 Bad Gateway (~55s). Replace `GetAllPRs()` with `GetPRsForBranches()` which fetches PRs individually in parallel for only the branches in the current stack. For a 3-branch stack: ~3s vs 55s+ timeout. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fdc7189 commit af93c2a

11 files changed

Lines changed: 82 additions & 209 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ go test ./...
5656

5757
**Merged PR Detection** (`cmd/sync.go:runSync`):
5858

59-
- Fetches all PRs upfront for performance (cached in single API call)
59+
- Fetches PRs for relevant branches in parallel
6060
- If parent PR is merged, updates child's parent to grandparent
6161
- If branch's own PR is merged, removes from stack tracking
6262

cmd/prune.go

Lines changed: 7 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package cmd
33
import (
44
"fmt"
55
"os"
6-
"sync"
76

87
"github.com/javoire/stackinator/internal/git"
98
"github.com/javoire/stackinator/internal/github"
@@ -70,25 +69,13 @@ func runPrune(gitClient git.GitClient, githubClient github.GitHubClient) error {
7069
// Get base branch to exclude it from pruning
7170
baseBranch := stack.GetBaseBranch(gitClient)
7271

73-
// Start PR fetch in parallel with branch loading (PR fetch is the slowest operation)
74-
var wg sync.WaitGroup
75-
var prCache map[string]*github.PRInfo
76-
var prErr error
77-
78-
wg.Add(1)
79-
go func() {
80-
defer wg.Done()
81-
prCache, prErr = githubClient.GetAllPRs()
82-
}()
83-
84-
// Get branches to check (runs in parallel with PR fetch)
72+
// Get branches to check
8573
var branchNames []string
86-
var branchErr error
8774
if pruneAll {
8875
// Check all local branches
76+
var branchErr error
8977
branchNames, branchErr = gitClient.ListBranches()
9078
if branchErr != nil {
91-
wg.Wait() // Wait for PR fetch before returning
9279
return fmt.Errorf("failed to get branches: %w", branchErr)
9380
}
9481

@@ -102,10 +89,8 @@ func runPrune(gitClient git.GitClient, githubClient github.GitHubClient) error {
10289
branchNames = filtered
10390
} else {
10491
// Check only stack branches
105-
var stackBranches []stack.StackBranch
106-
stackBranches, branchErr = stack.GetStackBranches(gitClient)
92+
stackBranches, branchErr := stack.GetStackBranches(gitClient)
10793
if branchErr != nil {
108-
wg.Wait() // Wait for PR fetch before returning
10994
return fmt.Errorf("failed to get stack branches: %w", branchErr)
11095
}
11196

@@ -115,7 +100,6 @@ func runPrune(gitClient git.GitClient, githubClient github.GitHubClient) error {
115100
}
116101

117102
if len(branchNames) == 0 {
118-
wg.Wait() // Wait for PR fetch before returning
119103
if pruneAll {
120104
fmt.Println("No branches found to check.")
121105
} else {
@@ -124,19 +108,15 @@ func runPrune(gitClient git.GitClient, githubClient github.GitHubClient) error {
124108
return nil
125109
}
126110

127-
// Wait for PR fetch to complete
128-
if err := spinner.WrapWithSuccess("Loading branches and fetching PRs...", "Loaded branches and PRs", func() error {
129-
wg.Wait()
111+
// Fetch PRs for the branches we need to check (parallel individual fetches)
112+
var prCache map[string]*github.PRInfo
113+
if err := spinner.WrapWithSuccess("Fetching PRs...", "Fetched PRs", func() error {
114+
prCache = githubClient.GetPRsForBranches(branchNames)
130115
return nil
131116
}); err != nil {
132117
return err
133118
}
134119

135-
// Check for PR fetch errors
136-
if prErr != nil {
137-
return fmt.Errorf("failed to fetch PRs: %w", prErr)
138-
}
139-
140120
// Find branches with merged PRs
141121
var mergedBranches []string
142122
for _, branchName := range branchNames {

cmd/status.go

Lines changed: 12 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -63,28 +63,15 @@ func runStatus(gitClient git.GitClient, githubClient github.GitHubClient) error
6363
var tree *stack.TreeNode
6464
var allTreeBranches []string
6565

66-
// Start fetch and PR loading in parallel with stack tree building (if not --no-pr)
67-
// These are the slowest operations and can run while we build the tree
68-
var wg sync.WaitGroup
66+
// Start fetch in parallel with stack tree building (if not --no-pr)
67+
var fetchWg sync.WaitGroup
6968
var prCache map[string]*github.PRInfo
70-
var prErr error
7169
fetchDone := false
7270

7371
if !noPR {
74-
wg.Add(2)
72+
fetchWg.Add(1)
7573
go func() {
76-
defer wg.Done()
77-
prCache, prErr = githubClient.GetAllPRs()
78-
if prErr != nil {
79-
if verbose {
80-
fmt.Printf(" [gh] Error fetching PRs: %v\n", prErr)
81-
}
82-
// If fetching fails, fall back to empty cache
83-
prCache = make(map[string]*github.PRInfo)
84-
}
85-
}()
86-
go func() {
87-
defer wg.Done()
74+
defer fetchWg.Done()
8875
// Fetch latest changes from origin (needed for sync issue detection)
8976
_ = gitClient.Fetch()
9077
fetchDone = true
@@ -93,7 +80,7 @@ func runStatus(gitClient git.GitClient, githubClient github.GitHubClient) error
9380
prCache = make(map[string]*github.PRInfo)
9481
}
9582

96-
// Build the stack tree AND wait for PR fetch (runs in parallel)
83+
// Build the stack tree, then fetch PRs for tree branches only
9784
if err := spinner.WrapWithAutoDelay("Loading stack...", 300*time.Millisecond, func() error {
9885
// Get current branch
9986
var err error
@@ -126,42 +113,9 @@ func runStatus(gitClient git.GitClient, githubClient github.GitHubClient) error
126113
// Get ALL branch names in the tree (including intermediate branches without stackparent)
127114
allTreeBranches = getAllBranchNamesFromTree(tree)
128115

129-
// Wait for PR fetch to complete (if running)
116+
// Fetch PRs for stack branches only (parallel individual fetches)
130117
if !noPR {
131-
wg.Wait()
132-
133-
// GetAllPRs only fetches open PRs (to avoid 502 timeouts on large repos).
134-
// For branches in our stack that aren't in the cache, check individually
135-
// to detect merged PRs that need special handling.
136-
// OPTIMIZATION: Only check branches in the current tree, not all stack branches.
137-
branchSet := make(map[string]bool)
138-
for _, name := range allTreeBranches {
139-
branchSet[name] = true
140-
}
141-
baseBranch := stack.GetBaseBranch(gitClient)
142-
143-
for _, branch := range stackBranches {
144-
// Skip branches not in the current tree
145-
if !branchSet[branch.Name] {
146-
continue
147-
}
148-
// Skip if already in cache (has open PR)
149-
if _, exists := prCache[branch.Name]; exists {
150-
continue
151-
}
152-
// Fetch PR info for this branch (might be merged or non-existent)
153-
if pr, err := githubClient.GetPRForBranch(branch.Name); err == nil && pr != nil {
154-
prCache[branch.Name] = pr
155-
}
156-
// Also check parent if not in cache and not base branch
157-
if branch.Parent != baseBranch {
158-
if _, exists := prCache[branch.Parent]; !exists {
159-
if pr, err := githubClient.GetPRForBranch(branch.Parent); err == nil && pr != nil {
160-
prCache[branch.Parent] = pr
161-
}
162-
}
163-
}
164-
}
118+
prCache = githubClient.GetPRsForBranches(allTreeBranches)
165119
}
166120

167121
return nil
@@ -170,8 +124,8 @@ func runStatus(gitClient git.GitClient, githubClient github.GitHubClient) error
170124
}
171125

172126
if len(stackBranches) == 0 {
173-
// Wait for PR fetch to complete before returning
174-
wg.Wait()
127+
// Wait for fetch to complete before returning
128+
fetchWg.Wait()
175129
fmt.Println("No stack branches found.")
176130
fmt.Printf("Current branch: %s\n", ui.Branch(currentBranch))
177131
fmt.Printf("\nUse '%s' to create a new stack branch.\n", ui.Command("stack new <branch-name>"))
@@ -220,6 +174,9 @@ func runStatus(gitClient git.GitClient, githubClient github.GitHubClient) error
220174

221175
// Check for sync issues (skip if --no-pr)
222176
if !noPR {
177+
// Wait for git fetch to complete (needed for sync issue detection)
178+
fetchWg.Wait()
179+
223180
// Filter stackBranches to only include branches in the current tree
224181
branchSet := make(map[string]bool)
225182
for _, name := range allTreeBranches {

cmd/status_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func TestRunStatus(t *testing.T) {
3131
// Get base branch
3232
mockGit.On("GetConfig", "stack.baseBranch").Return("")
3333
mockGit.On("GetDefaultBranch").Return("main")
34-
// Note: GetAllPRs is NOT called because noPR is true
34+
// Note: GetPRsForBranches is NOT called because noPR is true
3535
},
3636
expectError: false,
3737
},

cmd/sync.go

Lines changed: 16 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -272,22 +272,15 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error {
272272
fmt.Println(ui.Success(fmt.Sprintf("Added '%s' to stack with parent '%s'", ui.Branch(originalBranch), ui.Branch(baseBranch))))
273273
}
274274

275-
// Start parallel fetch operations (git fetch and GitHub PR fetch)
276-
// These are the slowest operations and have no dependencies between them
275+
// Start git fetch in parallel (the slowest network operation)
277276
var wg sync.WaitGroup
278277
var fetchErr error
279-
var prCache map[string]*github.PRInfo
280-
var prErr error
281278

282-
wg.Add(2)
279+
wg.Add(1)
283280
go func() {
284281
defer wg.Done()
285282
fetchErr = gitClient.Fetch()
286283
}()
287-
go func() {
288-
defer wg.Done()
289-
prCache, prErr = githubClient.GetAllPRs()
290-
}()
291284

292285
// While network operations run in background, do local work
293286
// Get only branches in the current branch's stack
@@ -413,9 +406,22 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error {
413406
}
414407
}
415408

416-
// Wait for parallel network operations to complete
409+
// Collect all branches we need PR info for (stack branches + their parents)
410+
prBranchSet := make(map[string]bool)
411+
for _, branch := range sorted {
412+
prBranchSet[branch.Name] = true
413+
prBranchSet[branch.Parent] = true
414+
}
415+
var prBranches []string
416+
for b := range prBranchSet {
417+
prBranches = append(prBranches, b)
418+
}
419+
420+
// Wait for git fetch and fetch PRs in parallel for stack branches only
421+
var prCache map[string]*github.PRInfo
417422
if err := spinner.WrapWithSuccess("Fetching from origin and loading PRs...", "Fetched from origin and loaded PRs", func() error {
418423
wg.Wait()
424+
prCache = githubClient.GetPRsForBranches(prBranches)
419425
return nil
420426
}); err != nil {
421427
return err
@@ -426,31 +432,6 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error {
426432
return fmt.Errorf("failed to fetch: %w", fetchErr)
427433
}
428434

429-
// Handle PR fetch errors gracefully
430-
if prErr != nil {
431-
prCache = make(map[string]*github.PRInfo)
432-
}
433-
434-
// GetAllPRs only fetches open PRs (to avoid 502 timeouts on large repos).
435-
// For branches in our stack that aren't in the cache, check individually
436-
// to detect merged PRs that need special handling.
437-
for _, branch := range sorted {
438-
// Skip if already in cache (has open PR)
439-
if _, exists := prCache[branch.Name]; exists {
440-
continue
441-
}
442-
// Fetch PR info for this branch (might be merged or non-existent)
443-
if pr, err := githubClient.GetPRForBranch(branch.Name); err == nil && pr != nil {
444-
prCache[branch.Name] = pr
445-
}
446-
// Also check parent if not in cache
447-
if _, exists := prCache[branch.Parent]; !exists {
448-
if pr, err := githubClient.GetPRForBranch(branch.Parent); err == nil && pr != nil {
449-
prCache[branch.Parent] = pr
450-
}
451-
}
452-
}
453-
454435
// Get all remote branches in one call (more efficient than checking each branch individually)
455436
remoteBranches := gitClient.GetRemoteBranchesSet()
456437

0 commit comments

Comments
 (0)