Skip to content

Commit bd3b89f

Browse files
authored
Merge pull request #49 from codeGROOVE-dev/auto-refresh
Auto-refresh on menu click
2 parents 7e05582 + 27204b5 commit bd3b89f

15 files changed

+987
-82
lines changed

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
1010
BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
1111
LDFLAGS := -X main.version=$(GIT_VERSION) -X main.commit=$(GIT_COMMIT) -X main.date=$(BUILD_DATE)
1212

13-
.PHONY: all build clean deps run app-bundle install install-darwin install-unix install-windows
13+
.PHONY: all build clean deps run app-bundle install install-darwin install-unix install-windows test
14+
15+
test:
16+
go test -race ./...
1417

1518
# Default target
1619
all: build

cmd/goose/click_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"sync"
6+
"testing"
7+
"time"
8+
)
9+
10+
// TestMenuClickRateLimit tests that menu clicks are properly rate limited.
11+
func TestMenuClickRateLimit(t *testing.T) {
12+
ctx := context.Background()
13+
14+
// Create app with initial state
15+
app := &App{
16+
mu: sync.RWMutex{},
17+
stateManager: NewPRStateManager(time.Now().Add(-35 * time.Second)),
18+
hiddenOrgs: make(map[string]bool),
19+
seenOrgs: make(map[string]bool),
20+
lastSearchAttempt: time.Now().Add(-15 * time.Second), // 15 seconds ago
21+
systrayInterface: &MockSystray{}, // Use mock systray to avoid panics
22+
}
23+
24+
// Simulate the click handler logic (without the actual UI interaction)
25+
testClick := func() (shouldRefresh bool, timeSinceLastSearch time.Duration) {
26+
app.mu.RLock()
27+
timeSince := time.Since(app.lastSearchAttempt)
28+
app.mu.RUnlock()
29+
30+
if timeSince >= minUpdateInterval {
31+
// Would trigger refresh
32+
app.mu.Lock()
33+
app.lastSearchAttempt = time.Now()
34+
app.mu.Unlock()
35+
return true, timeSince
36+
}
37+
return false, timeSince
38+
}
39+
40+
// Test 1: First click should allow refresh (last search was 15s ago)
41+
shouldRefresh, timeSince := testClick()
42+
if !shouldRefresh {
43+
t.Errorf("First click should allow refresh, last search was %v ago", timeSince)
44+
}
45+
46+
// Test 2: Immediate second click should be rate limited
47+
shouldRefresh2, timeSince2 := testClick()
48+
if shouldRefresh2 {
49+
t.Errorf("Second click should be rate limited, last search was %v ago", timeSince2)
50+
}
51+
52+
// Test 3: After waiting 10+ seconds, should allow refresh again
53+
app.mu.Lock()
54+
app.lastSearchAttempt = time.Now().Add(-11 * time.Second)
55+
app.mu.Unlock()
56+
57+
shouldRefresh3, timeSince3 := testClick()
58+
if !shouldRefresh3 {
59+
t.Errorf("Click after 11 seconds should allow refresh, last search was %v ago", timeSince3)
60+
}
61+
62+
_ = ctx // Keep context for potential future use
63+
}
64+
65+
// TestScheduledUpdateRateLimit tests that scheduled updates respect rate limiting.
66+
func TestScheduledUpdateRateLimit(t *testing.T) {
67+
app := &App{
68+
mu: sync.RWMutex{},
69+
stateManager: NewPRStateManager(time.Now().Add(-35 * time.Second)),
70+
hiddenOrgs: make(map[string]bool),
71+
seenOrgs: make(map[string]bool),
72+
lastSearchAttempt: time.Now().Add(-5 * time.Second), // 5 seconds ago
73+
systrayInterface: &MockSystray{}, // Use mock systray to avoid panics
74+
}
75+
76+
// Simulate the scheduled update logic
77+
testScheduledUpdate := func() (shouldUpdate bool, timeSinceLastSearch time.Duration) {
78+
app.mu.RLock()
79+
timeSince := time.Since(app.lastSearchAttempt)
80+
app.mu.RUnlock()
81+
82+
return timeSince >= minUpdateInterval, timeSince
83+
}
84+
85+
// Test 1: Scheduled update should be skipped (last search was only 5s ago)
86+
shouldUpdate, timeSince := testScheduledUpdate()
87+
if shouldUpdate {
88+
t.Errorf("Scheduled update should be skipped, last search was %v ago", timeSince)
89+
}
90+
91+
// Test 2: After waiting 10+ seconds, scheduled update should proceed
92+
app.mu.Lock()
93+
app.lastSearchAttempt = time.Now().Add(-12 * time.Second)
94+
app.mu.Unlock()
95+
96+
shouldUpdate2, timeSince2 := testScheduledUpdate()
97+
if !shouldUpdate2 {
98+
t.Errorf("Scheduled update after 12 seconds should proceed, last search was %v ago", timeSince2)
99+
}
100+
}

cmd/goose/deadlock_test.go

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package main
2+
3+
import (
4+
"sync"
5+
"testing"
6+
"time"
7+
)
8+
9+
// TestConcurrentMenuOperations tests that concurrent menu operations don't cause deadlocks
10+
func TestConcurrentMenuOperations(t *testing.T) {
11+
app := &App{
12+
mu: sync.RWMutex{},
13+
stateManager: NewPRStateManager(time.Now()),
14+
hiddenOrgs: make(map[string]bool),
15+
seenOrgs: make(map[string]bool),
16+
blockedPRTimes: make(map[string]time.Time),
17+
browserRateLimiter: NewBrowserRateLimiter(30*time.Second, 5, defaultMaxBrowserOpensDay),
18+
systrayInterface: &MockSystray{},
19+
incoming: []PR{
20+
{Repository: "org1/repo1", Number: 1, Title: "Fix bug", URL: "https://github.com/org1/repo1/pull/1"},
21+
},
22+
outgoing: []PR{
23+
{Repository: "org2/repo2", Number: 2, Title: "Add feature", URL: "https://github.com/org2/repo2/pull/2"},
24+
},
25+
}
26+
27+
// Use a WaitGroup to coordinate goroutines
28+
var wg sync.WaitGroup
29+
30+
// Use a channel to detect if we've deadlocked
31+
done := make(chan bool, 1)
32+
33+
// Number of concurrent operations to test
34+
concurrentOps := 10
35+
36+
wg.Add(concurrentOps * 3) // 3 types of operations
37+
38+
// Start a goroutine that will signal completion
39+
go func() {
40+
wg.Wait()
41+
done <- true
42+
}()
43+
44+
// Simulate concurrent menu clicks (write lock operations)
45+
for range concurrentOps {
46+
go func() {
47+
defer wg.Done()
48+
49+
// This simulates the click handler storing menu titles
50+
menuTitles := app.generateMenuTitles()
51+
app.mu.Lock()
52+
app.lastMenuTitles = menuTitles
53+
app.mu.Unlock()
54+
}()
55+
}
56+
57+
// Simulate concurrent menu generation (read lock operations)
58+
for range concurrentOps {
59+
go func() {
60+
defer wg.Done()
61+
62+
// This simulates generating menu titles
63+
_ = app.generateMenuTitles()
64+
}()
65+
}
66+
67+
// Simulate concurrent PR updates (write lock operations)
68+
for i := range concurrentOps {
69+
go func(iteration int) {
70+
defer wg.Done()
71+
72+
app.mu.Lock()
73+
// Simulate updating PR data
74+
if iteration%2 == 0 {
75+
app.incoming = append(app.incoming, PR{
76+
Repository: "test/repo",
77+
Number: iteration,
78+
Title: "Test PR",
79+
URL: "https://github.com/test/repo/pull/1",
80+
})
81+
}
82+
app.mu.Unlock()
83+
}(i)
84+
}
85+
86+
// Wait for operations to complete or timeout
87+
select {
88+
case <-done:
89+
// Success - all operations completed without deadlock
90+
t.Log("All concurrent operations completed successfully")
91+
case <-time.After(5 * time.Second):
92+
t.Fatal("Deadlock detected: operations did not complete within 5 seconds")
93+
}
94+
}
95+
96+
// TestMenuClickDeadlockScenario specifically tests the deadlock scenario that was fixed
97+
func TestMenuClickDeadlockScenario(t *testing.T) {
98+
app := &App{
99+
mu: sync.RWMutex{},
100+
stateManager: NewPRStateManager(time.Now()),
101+
hiddenOrgs: make(map[string]bool),
102+
seenOrgs: make(map[string]bool),
103+
blockedPRTimes: make(map[string]time.Time),
104+
browserRateLimiter: NewBrowserRateLimiter(30*time.Second, 5, defaultMaxBrowserOpensDay),
105+
systrayInterface: &MockSystray{},
106+
incoming: []PR{
107+
{Repository: "org1/repo1", Number: 1, Title: "Test PR", URL: "https://github.com/org1/repo1/pull/1"},
108+
},
109+
}
110+
111+
// This exact sequence previously caused a deadlock:
112+
// 1. Click handler acquires write lock
113+
// 2. Click handler calls generateMenuTitles while holding lock
114+
// 3. generateMenuTitles tries to acquire read lock
115+
// 4. Deadlock!
116+
117+
// The fix ensures we don't hold the lock when calling generateMenuTitles
118+
done := make(chan bool, 1)
119+
120+
go func() {
121+
// Simulate the fixed click handler behavior
122+
menuTitles := app.generateMenuTitles() // Called WITHOUT holding lock
123+
app.mu.Lock()
124+
app.lastMenuTitles = menuTitles
125+
app.mu.Unlock()
126+
done <- true
127+
}()
128+
129+
select {
130+
case <-done:
131+
t.Log("Click handler completed without deadlock")
132+
case <-time.After(1 * time.Second):
133+
t.Fatal("Click handler deadlocked")
134+
}
135+
}
136+
137+
// TestRapidMenuClicks tests that rapid menu clicks don't cause issues
138+
func TestRapidMenuClicks(t *testing.T) {
139+
app := &App{
140+
mu: sync.RWMutex{},
141+
stateManager: NewPRStateManager(time.Now()),
142+
hiddenOrgs: make(map[string]bool),
143+
seenOrgs: make(map[string]bool),
144+
blockedPRTimes: make(map[string]time.Time),
145+
browserRateLimiter: NewBrowserRateLimiter(30*time.Second, 5, defaultMaxBrowserOpensDay),
146+
systrayInterface: &MockSystray{},
147+
lastSearchAttempt: time.Now().Add(-15 * time.Second), // Allow first click
148+
incoming: []PR{
149+
{Repository: "org1/repo1", Number: 1, Title: "Test", URL: "https://github.com/org1/repo1/pull/1"},
150+
},
151+
}
152+
153+
// Simulate 20 rapid clicks
154+
clickCount := 20
155+
successfulClicks := 0
156+
157+
for range clickCount {
158+
// Check if enough time has passed for rate limiting
159+
app.mu.RLock()
160+
timeSince := time.Since(app.lastSearchAttempt)
161+
app.mu.RUnlock()
162+
163+
if timeSince >= minUpdateInterval {
164+
// This click would trigger a refresh
165+
app.mu.Lock()
166+
app.lastSearchAttempt = time.Now()
167+
app.mu.Unlock()
168+
successfulClicks++
169+
170+
// Also update menu titles as the real handler would
171+
menuTitles := app.generateMenuTitles()
172+
app.mu.Lock()
173+
app.lastMenuTitles = menuTitles
174+
app.mu.Unlock()
175+
}
176+
177+
// Small delay between clicks to simulate human clicking
178+
time.Sleep(10 * time.Millisecond)
179+
}
180+
181+
// Due to rate limiting, we should only have 1-2 successful clicks
182+
if successfulClicks > 3 {
183+
t.Errorf("Rate limiting not working: %d clicks succeeded out of %d rapid clicks", successfulClicks, clickCount)
184+
}
185+
186+
t.Logf("Rate limiting working correctly: %d clicks succeeded out of %d rapid clicks", successfulClicks, clickCount)
187+
}

cmd/goose/filtering_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ func TestCountPRsWithHiddenOrgs(t *testing.T) {
2121
"org2": true, // Hide org2
2222
},
2323
hideStaleIncoming: false,
24+
systrayInterface: &MockSystray{}, // Use mock systray to avoid panics
2425
}
2526

2627
counts := app.countPRs()
@@ -57,7 +58,8 @@ func TestCountPRsWithStalePRs(t *testing.T) {
5758
{Repository: "org1/repo5", IsBlocked: true, UpdatedAt: recentTime},
5859
},
5960
hiddenOrgs: map[string]bool{},
60-
hideStaleIncoming: true, // Hide stale PRs
61+
hideStaleIncoming: true, // Hide stale PRs
62+
systrayInterface: &MockSystray{}, // Use mock systray to avoid panics
6163
}
6264

6365
counts := app.countPRs()
@@ -99,6 +101,7 @@ func TestCountPRsWithBothFilters(t *testing.T) {
99101
"org2": true,
100102
},
101103
hideStaleIncoming: true,
104+
systrayInterface: &MockSystray{}, // Use mock systray to avoid panics
102105
}
103106

104107
counts := app.countPRs()

cmd/goose/github.go

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -138,16 +138,39 @@ func (*App) token(ctx context.Context) (string, error) {
138138
}
139139

140140
log.Printf("Executing command: %s auth token", ghPath)
141-
cmd := exec.CommandContext(ctx, ghPath, "auth", "token")
142-
output, err := cmd.CombinedOutput()
143-
if err != nil {
144-
log.Printf("gh command failed: %v", err)
145-
return "", fmt.Errorf("exec 'gh auth token': %w", err)
146-
}
147-
token := strings.TrimSpace(string(output))
148-
if err := validateGitHubToken(token); err != nil {
149-
return "", fmt.Errorf("invalid token from gh CLI: %w", err)
141+
142+
// Use retry logic for gh CLI command as it may fail temporarily
143+
var token string
144+
retryErr := retry.Do(func() error {
145+
// Create timeout context for gh CLI call
146+
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
147+
defer cancel()
148+
149+
cmd := exec.CommandContext(cmdCtx, ghPath, "auth", "token")
150+
output, cmdErr := cmd.CombinedOutput()
151+
if cmdErr != nil {
152+
log.Printf("gh command failed (will retry): %v", cmdErr)
153+
return fmt.Errorf("exec 'gh auth token': %w", cmdErr)
154+
}
155+
156+
token = strings.TrimSpace(string(output))
157+
if validateErr := validateGitHubToken(token); validateErr != nil {
158+
// Don't retry on invalid token - it won't get better
159+
return retry.Unrecoverable(fmt.Errorf("invalid token from gh CLI: %w", validateErr))
160+
}
161+
return nil
162+
},
163+
retry.Attempts(3), // Fewer attempts for local command
164+
retry.Delay(time.Second),
165+
retry.OnRetry(func(n uint, err error) {
166+
log.Printf("[GH CLI] Retry %d/3: %v", n+1, err)
167+
}),
168+
retry.Context(ctx),
169+
)
170+
if retryErr != nil {
171+
return "", retryErr
150172
}
173+
151174
log.Println("Successfully obtained GitHub token from gh CLI")
152175
return token, nil
153176
}
@@ -223,6 +246,11 @@ type prResult struct {
223246

224247
// fetchPRsInternal fetches PRs and Turn data synchronously for simplicity.
225248
func (app *App) fetchPRsInternal(ctx context.Context) (incoming []PR, outgoing []PR, _ error) {
249+
// Update search attempt time for rate limiting
250+
app.mu.Lock()
251+
app.lastSearchAttempt = time.Now()
252+
app.mu.Unlock()
253+
226254
// Check if we have a client
227255
if app.client == nil {
228256
return nil, nil, fmt.Errorf("no GitHub client available: %s", app.authError)

0 commit comments

Comments
 (0)