Skip to content

Commit 2cab0ff

Browse files
authored
Merge pull request #30 from codeGROOVE-dev/auto-open
Add feature for auto-opening incoming PRs, remove reminder feature
2 parents c308cad + 00733fd commit 2cab0ff

File tree

7 files changed

+405
-46
lines changed

7 files changed

+405
-46
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Lives in your menubar like a tiny waterfowl of productivity shame, watching your
1818
- **✈️ Jet sounds** when your own PR is ready for the next stage
1919
- **🧠 Smart turn-based assignment** - knows who is blocking a PR, knows when tests are failing, etc.
2020
- **⭐ Auto-start** on login (macOS)
21+
- **🔔 Auto-open** incoming PRs in your browser (off by default, rate-limited)
2122

2223
You can also visit the web-based equivalent at https://dash.ready-to-review.dev/
2324

cmd/goose/browser_rate_limiter.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Package main implements browser rate limiting for PR auto-open feature.
2+
package main
3+
4+
import (
5+
"log"
6+
"sync"
7+
"time"
8+
)
9+
10+
// BrowserRateLimiter manages rate limiting for automatically opening browser windows.
11+
type BrowserRateLimiter struct {
12+
openedPRs map[string]bool
13+
openedLastMinute []time.Time
14+
openedToday []time.Time
15+
startupDelay time.Duration
16+
maxPerMinute int
17+
maxPerDay int
18+
mu sync.Mutex
19+
}
20+
21+
// NewBrowserRateLimiter creates a new browser rate limiter.
22+
func NewBrowserRateLimiter(startupDelay time.Duration, maxPerMinute, maxPerDay int) *BrowserRateLimiter {
23+
log.Printf("[BROWSER] Initializing rate limiter: startup_delay=%v, max_per_minute=%d, max_per_day=%d",
24+
startupDelay, maxPerMinute, maxPerDay)
25+
return &BrowserRateLimiter{
26+
openedLastMinute: make([]time.Time, 0),
27+
openedToday: make([]time.Time, 0),
28+
startupDelay: startupDelay,
29+
maxPerMinute: maxPerMinute,
30+
maxPerDay: maxPerDay,
31+
openedPRs: make(map[string]bool),
32+
}
33+
}
34+
35+
// CanOpen checks if we can open a browser window according to rate limits.
36+
func (b *BrowserRateLimiter) CanOpen(startTime time.Time, prURL string) bool {
37+
b.mu.Lock()
38+
defer b.mu.Unlock()
39+
40+
// Check if we've already opened this PR
41+
if b.openedPRs[prURL] {
42+
log.Printf("[BROWSER] Skipping auto-open: PR already opened - %s", prURL)
43+
return false
44+
}
45+
46+
// Check startup delay
47+
if time.Since(startTime) < b.startupDelay {
48+
log.Printf("[BROWSER] Skipping auto-open: within startup delay period (%v remaining)",
49+
b.startupDelay-time.Since(startTime))
50+
return false
51+
}
52+
53+
now := time.Now()
54+
55+
// Clean old entries
56+
b.cleanOldEntries(now)
57+
58+
// Check per-minute limit
59+
if len(b.openedLastMinute) >= b.maxPerMinute {
60+
log.Printf("[BROWSER] Rate limit: already opened %d PRs in the last minute (max: %d)",
61+
len(b.openedLastMinute), b.maxPerMinute)
62+
return false
63+
}
64+
65+
// Check per-day limit
66+
if len(b.openedToday) >= b.maxPerDay {
67+
log.Printf("[BROWSER] Rate limit: already opened %d PRs today (max: %d)",
68+
len(b.openedToday), b.maxPerDay)
69+
return false
70+
}
71+
72+
return true
73+
}
74+
75+
// RecordOpen records that a browser window was opened.
76+
func (b *BrowserRateLimiter) RecordOpen(prURL string) {
77+
b.mu.Lock()
78+
defer b.mu.Unlock()
79+
80+
now := time.Now()
81+
b.openedLastMinute = append(b.openedLastMinute, now)
82+
b.openedToday = append(b.openedToday, now)
83+
b.openedPRs[prURL] = true
84+
85+
log.Printf("[BROWSER] Recorded browser open for %s (minute: %d/%d, today: %d/%d)",
86+
prURL, len(b.openedLastMinute), b.maxPerMinute, len(b.openedToday), b.maxPerDay)
87+
}
88+
89+
// cleanOldEntries removes entries outside the time windows.
90+
func (b *BrowserRateLimiter) cleanOldEntries(now time.Time) {
91+
// Clean entries older than 1 minute
92+
oneMinuteAgo := now.Add(-1 * time.Minute)
93+
newLastMinute := make([]time.Time, 0, len(b.openedLastMinute))
94+
for _, t := range b.openedLastMinute {
95+
if t.After(oneMinuteAgo) {
96+
newLastMinute = append(newLastMinute, t)
97+
}
98+
}
99+
b.openedLastMinute = newLastMinute
100+
101+
// Clean entries older than 24 hours (1 day)
102+
oneDayAgo := now.Add(-24 * time.Hour)
103+
newToday := make([]time.Time, 0, len(b.openedToday))
104+
for _, t := range b.openedToday {
105+
if t.After(oneDayAgo) {
106+
newToday = append(newToday, t)
107+
}
108+
}
109+
b.openedToday = newToday
110+
}
111+
112+
// Reset clears the opened PRs tracking - useful when toggling the feature.
113+
func (b *BrowserRateLimiter) Reset() {
114+
b.mu.Lock()
115+
defer b.mu.Unlock()
116+
previousCount := len(b.openedPRs)
117+
b.openedPRs = make(map[string]bool)
118+
log.Printf("[BROWSER] Rate limiter reset: cleared %d tracked PRs", previousCount)
119+
}

cmd/goose/main.go

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const (
4444
panicFailureIncrement = 10
4545
turnAPITimeout = 10 * time.Second
4646
maxConcurrentTurnAPICalls = 10
47+
defaultMaxBrowserOpensDay = 20
4748
)
4849

4950
// PR represents a pull request with metadata.
@@ -90,8 +91,10 @@ type App struct {
9091
loadingTurnData bool
9192
menuInitialized bool
9293
initialLoadComplete bool
93-
enableReminders bool
9494
enableAudioCues bool
95+
enableAutoBrowser bool
96+
browserRateLimiter *BrowserRateLimiter
97+
startTime time.Time
9598
}
9699

97100
func loadCurrentUser(ctx context.Context, app *App) {
@@ -145,9 +148,15 @@ func main() {
145148
var targetUser string
146149
var noCache bool
147150
var updateInterval time.Duration
151+
var browserOpenDelay time.Duration
152+
var maxBrowserOpensMinute int
153+
var maxBrowserOpensDay int
148154
flag.StringVar(&targetUser, "user", "", "GitHub user to query PRs for (defaults to authenticated user)")
149155
flag.BoolVar(&noCache, "no-cache", false, "Bypass cache for debugging")
150156
flag.DurationVar(&updateInterval, "interval", defaultUpdateInterval, "Update interval (e.g. 30s, 1m, 5m)")
157+
flag.DurationVar(&browserOpenDelay, "browser-delay", 1*time.Minute, "Minimum delay before opening PRs in browser after startup")
158+
flag.IntVar(&maxBrowserOpensMinute, "browser-max-per-minute", 2, "Maximum browser windows to open per minute")
159+
flag.IntVar(&maxBrowserOpensDay, "browser-max-per-day", defaultMaxBrowserOpensDay, "Maximum browser windows to open per day")
151160
flag.Parse()
152161

153162
// Validate target user if provided
@@ -163,9 +172,25 @@ func main() {
163172
updateInterval = minUpdateInterval
164173
}
165174

175+
// Validate browser rate limit parameters
176+
if maxBrowserOpensMinute < 0 {
177+
log.Printf("Invalid browser-max-per-minute %d, using default of 2", maxBrowserOpensMinute)
178+
maxBrowserOpensMinute = 2
179+
}
180+
if maxBrowserOpensDay < 0 {
181+
log.Printf("Invalid browser-max-per-day %d, using default of %d", maxBrowserOpensDay, defaultMaxBrowserOpensDay)
182+
maxBrowserOpensDay = defaultMaxBrowserOpensDay
183+
}
184+
if browserOpenDelay < 0 {
185+
log.Printf("Invalid browser-delay %v, using default of 1 minute", browserOpenDelay)
186+
browserOpenDelay = 1 * time.Minute
187+
}
188+
166189
log.SetFlags(log.LstdFlags | log.Lshortfile)
167190
log.Printf("Starting GitHub PR Monitor (version=%s, commit=%s, date=%s)", version, commit, date)
168191
log.Printf("Configuration: update_interval=%v, max_retries=%d, max_delay=%v", updateInterval, maxRetries, maxRetryDelay)
192+
log.Printf("Browser auto-open: startup_delay=%v, max_per_minute=%d, max_per_day=%d",
193+
browserOpenDelay, maxBrowserOpensMinute, maxBrowserOpensDay)
169194

170195
ctx := context.Background()
171196

@@ -187,8 +212,10 @@ func main() {
187212
noCache: noCache,
188213
updateInterval: updateInterval,
189214
pendingTurnResults: make([]TurnResult, 0),
190-
enableReminders: true,
191215
enableAudioCues: true,
216+
enableAutoBrowser: false, // Default to false for safety
217+
browserRateLimiter: NewBrowserRateLimiter(browserOpenDelay, maxBrowserOpensMinute, maxBrowserOpensDay),
218+
startTime: time.Now(),
192219
}
193220

194221
// Load saved settings
@@ -492,6 +519,26 @@ func (app *App) updatePRsWithWait(ctx context.Context) {
492519
app.checkForNewlyBlockedPRs(ctx)
493520
}
494521

522+
// tryAutoOpenPR attempts to open a PR in the browser if enabled and rate limits allow.
523+
func (app *App) tryAutoOpenPR(ctx context.Context, pr PR, autoBrowserEnabled bool, startTime time.Time) {
524+
if !autoBrowserEnabled {
525+
return
526+
}
527+
528+
if app.browserRateLimiter.CanOpen(startTime, pr.URL) {
529+
log.Printf("[BROWSER] Auto-opening newly blocked PR: %s #%d - %s",
530+
pr.Repository, pr.Number, pr.URL)
531+
// Use strict validation for auto-opened URLs
532+
if err := openURLAutoStrict(ctx, pr.URL); err != nil {
533+
log.Printf("[BROWSER] Failed to auto-open PR %s: %v", pr.URL, err)
534+
} else {
535+
app.browserRateLimiter.RecordOpen(pr.URL)
536+
log.Printf("[BROWSER] Successfully opened PR %s #%d in browser",
537+
pr.Repository, pr.Number)
538+
}
539+
}
540+
}
541+
495542
// notifyWithSound sends a notification and plays sound only once per cycle.
496543
func (app *App) notifyWithSound(ctx context.Context, pr PR, isIncoming bool, playedSound *bool) {
497544
var title, soundType string
@@ -521,6 +568,8 @@ func (app *App) checkForNewlyBlockedPRs(ctx context.Context) {
521568
incoming := app.incoming
522569
outgoing := app.outgoing
523570
previousBlocked := app.previousBlockedPRs
571+
autoBrowserEnabled := app.enableAutoBrowser
572+
startTime := app.startTime
524573
app.mu.RUnlock()
525574

526575
currentBlocked := make(map[string]bool)
@@ -534,6 +583,7 @@ func (app *App) checkForNewlyBlockedPRs(ctx context.Context) {
534583
// Notify if newly blocked
535584
if !previousBlocked[incoming[i].URL] {
536585
app.notifyWithSound(ctx, incoming[i], true, &playedHonk)
586+
app.tryAutoOpenPR(ctx, incoming[i], autoBrowserEnabled, startTime)
537587
}
538588
}
539589
}
@@ -549,6 +599,7 @@ func (app *App) checkForNewlyBlockedPRs(ctx context.Context) {
549599
time.Sleep(2 * time.Second)
550600
}
551601
app.notifyWithSound(ctx, outgoing[i], false, &playedJet)
602+
app.tryAutoOpenPR(ctx, outgoing[i], autoBrowserEnabled, startTime)
552603
}
553604
}
554605
}

cmd/goose/security.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ var (
2929
// New tokens: ghp_ (personal), ghs_ (server), ghr_ (refresh), gho_ (OAuth), ghu_ (user-to-server) followed by base62 chars.
3030
// Fine-grained tokens: github_pat_ followed by base62 chars.
3131
githubTokenRegex = regexp.MustCompile(`^[a-f0-9]{40}$|^gh[psoru]_[A-Za-z0-9]{36,251}$|^github_pat_[A-Za-z0-9]{82}$`)
32+
33+
// githubPRURLRegex validates strict GitHub PR URL format for auto-opening.
34+
// Must match: https://github.com/{owner}/{repo}/pull/{number}
35+
// Owner and repo follow GitHub naming rules, number is digits only.
36+
githubPRURLRegex = regexp.MustCompile(`^https://github\.com/[a-zA-Z0-9][a-zA-Z0-9-]{0,38}/[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}/pull/[1-9][0-9]{0,9}$`)
3237
)
3338

3439
// validateGitHubUsername validates a GitHub username.
@@ -115,3 +120,54 @@ func validateURL(rawURL string) error {
115120

116121
return nil
117122
}
123+
124+
// validateGitHubPRURL performs strict validation for GitHub PR URLs used in auto-opening.
125+
// This ensures the URL follows the exact pattern: https://github.com/{owner}/{repo}/pull/{number}
126+
// with no additional path segments, fragments, or suspicious characters.
127+
// The URL may optionally have ?goose=1 parameter which we add for tracking.
128+
func validateGitHubPRURL(rawURL string) error {
129+
// First do basic URL validation
130+
if err := validateURL(rawURL); err != nil {
131+
return err
132+
}
133+
134+
// Strip the ?goose=1 parameter if present for pattern validation
135+
urlToValidate := rawURL
136+
if strings.HasSuffix(rawURL, "?goose=1") {
137+
urlToValidate = strings.TrimSuffix(rawURL, "?goose=1")
138+
}
139+
140+
// Check against strict GitHub PR URL pattern
141+
if !githubPRURLRegex.MatchString(urlToValidate) {
142+
return fmt.Errorf("URL does not match GitHub PR pattern: %s", urlToValidate)
143+
}
144+
145+
// Additional security checks
146+
// Reject URLs with @ (potential credential injection)
147+
if strings.Contains(rawURL, "@") {
148+
return errors.New("URL contains @ character")
149+
}
150+
151+
// Reject URLs with URL encoding (could hide malicious content)
152+
// Exception: %3D which is = in URL encoding, only as part of ?goose=1
153+
if strings.Contains(rawURL, "%") && !strings.HasSuffix(rawURL, "?goose%3D1") {
154+
return errors.New("URL contains encoded characters")
155+
}
156+
157+
// Reject URLs with fragments
158+
if strings.Contains(rawURL, "#") {
159+
return errors.New("URL contains fragments")
160+
}
161+
162+
// Allow only ?goose=1 query parameter, nothing else
163+
if strings.Contains(rawURL, "?") && !strings.HasSuffix(rawURL, "?goose=1") && !strings.HasSuffix(rawURL, "?goose%3D1") {
164+
return errors.New("URL contains unexpected query parameters")
165+
}
166+
167+
// Reject URLs with double slashes (except after https:)
168+
if strings.Contains(rawURL[8:], "//") {
169+
return errors.New("URL contains double slashes")
170+
}
171+
172+
return nil
173+
}

0 commit comments

Comments
 (0)