Skip to content

Commit bc194f2

Browse files
committed
Add feature for auto-opening incoming PRs
1 parent c308cad commit bc194f2

File tree

4 files changed

+177
-46
lines changed

4 files changed

+177
-46
lines changed

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+
}

cmd/goose/settings.go

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ import (
1010

1111
// Settings represents persistent user settings.
1212
type Settings struct {
13-
EnableAudioCues bool `json:"enable_audio_cues"`
14-
EnableReminders bool `json:"enable_reminders"`
15-
HideStale bool `json:"hide_stale"`
13+
EnableAudioCues bool `json:"enable_audio_cues"`
14+
HideStale bool `json:"hide_stale"`
15+
EnableAutoBrowser bool `json:"enable_auto_browser"`
1616
}
1717

18-
// getSettingsDir returns the configuration directory for settings.
19-
func getSettingsDir() (string, error) {
18+
// settingsDir returns the configuration directory for settings.
19+
func settingsDir() (string, error) {
2020
configDir, err := os.UserConfigDir()
2121
if err != nil {
2222
return "", err
@@ -26,13 +26,13 @@ func getSettingsDir() (string, error) {
2626

2727
// loadSettings loads settings from disk or returns defaults.
2828
func (app *App) loadSettings() {
29-
settingsDir, err := getSettingsDir()
29+
settingsDir, err := settingsDir()
3030
if err != nil {
3131
log.Printf("Failed to get settings directory: %v", err)
3232
// Use defaults
3333
app.enableAudioCues = true
34-
app.enableReminders = true
3534
app.hideStaleIncoming = true
35+
app.enableAutoBrowser = false
3636
return
3737
}
3838

@@ -45,8 +45,8 @@ func (app *App) loadSettings() {
4545
}
4646
// Use defaults
4747
app.enableAudioCues = true
48-
app.enableReminders = true
4948
app.hideStaleIncoming = true
49+
app.enableAutoBrowser = false
5050
return
5151
}
5252

@@ -55,31 +55,31 @@ func (app *App) loadSettings() {
5555
log.Printf("Failed to parse settings: %v", err)
5656
// Use defaults
5757
app.enableAudioCues = true
58-
app.enableReminders = true
5958
app.hideStaleIncoming = true
59+
app.enableAutoBrowser = false
6060
return
6161
}
6262

6363
app.enableAudioCues = settings.EnableAudioCues
64-
app.enableReminders = settings.EnableReminders
6564
app.hideStaleIncoming = settings.HideStale
66-
log.Printf("Loaded settings: audio_cues=%v, reminders=%v, hide_stale=%v",
67-
app.enableAudioCues, app.enableReminders, app.hideStaleIncoming)
65+
app.enableAutoBrowser = settings.EnableAutoBrowser
66+
log.Printf("Loaded settings: audio_cues=%v, hide_stale=%v, auto_browser=%v",
67+
app.enableAudioCues, app.hideStaleIncoming, app.enableAutoBrowser)
6868
}
6969

7070
// saveSettings saves current settings to disk.
7171
func (app *App) saveSettings() {
72-
settingsDir, err := getSettingsDir()
72+
settingsDir, err := settingsDir()
7373
if err != nil {
7474
log.Printf("Failed to get settings directory: %v", err)
7575
return
7676
}
7777

7878
app.mu.RLock()
7979
settings := Settings{
80-
EnableAudioCues: app.enableAudioCues,
81-
EnableReminders: app.enableReminders,
82-
HideStale: app.hideStaleIncoming,
80+
EnableAudioCues: app.enableAudioCues,
81+
HideStale: app.hideStaleIncoming,
82+
EnableAutoBrowser: app.enableAutoBrowser,
8383
}
8484
app.mu.RUnlock()
8585

@@ -102,6 +102,6 @@ func (app *App) saveSettings() {
102102
return
103103
}
104104

105-
log.Printf("Saved settings: audio_cues=%v, reminders=%v, hide_stale=%v",
106-
settings.EnableAudioCues, settings.EnableReminders, settings.HideStale)
105+
log.Printf("Saved settings: audio_cues=%v, hide_stale=%v, auto_browser=%v",
106+
settings.EnableAudioCues, settings.HideStale, settings.EnableAutoBrowser)
107107
}

cmd/goose/ui.go

Lines changed: 50 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@ import (
1515
"github.com/energye/systray"
1616
)
1717

18+
// openURLAutoStrict safely opens a URL in the default browser with strict validation for auto-opening.
19+
// This function is used for auto-opening PRs and enforces stricter URL patterns.
20+
func openURLAutoStrict(ctx context.Context, rawURL string) error {
21+
// Validate against strict GitHub PR URL pattern
22+
if err := validateGitHubPRURL(rawURL); err != nil {
23+
return fmt.Errorf("strict validation failed: %w", err)
24+
}
25+
26+
// Use the regular openURL after strict validation passes
27+
return openURL(ctx, rawURL)
28+
}
29+
1830
// openURL safely opens a URL in the default browser after validation.
1931
func openURL(ctx context.Context, rawURL string) error {
2032
// Parse and validate the URL
@@ -41,6 +53,14 @@ func openURL(ctx context.Context, rawURL string) error {
4153
return errors.New("URLs with user info are not allowed")
4254
}
4355

56+
// Add goose=1 parameter to track source for GitHub and dash URLs
57+
if u.Host == "github.com" || u.Host == "www.github.com" || u.Host == "dash.ready-to-review.dev" {
58+
q := u.Query()
59+
q.Set("goose", "1")
60+
u.RawQuery = q.Encode()
61+
rawURL = u.String()
62+
}
63+
4464
// Execute the open command based on OS
4565
// Use safer methods that don't invoke shell interpretation
4666
var cmd *exec.Cmd
@@ -350,32 +370,6 @@ func (app *App) addStaticMenuItems(ctx context.Context) {
350370
app.rebuildMenu(ctx)
351371
})
352372

353-
// Daily reminders
354-
// Add 'Daily reminders' option
355-
reminderItem := systray.AddMenuItem("Daily reminders", "Send reminder notifications for blocked PRs every 24 hours")
356-
app.mu.RLock()
357-
if app.enableReminders {
358-
reminderItem.Check()
359-
}
360-
app.mu.RUnlock()
361-
reminderItem.Click(func() {
362-
app.mu.Lock()
363-
app.enableReminders = !app.enableReminders
364-
enabled := app.enableReminders
365-
app.mu.Unlock()
366-
367-
if enabled {
368-
reminderItem.Check()
369-
log.Println("[SETTINGS] Daily reminders enabled")
370-
} else {
371-
reminderItem.Uncheck()
372-
log.Println("[SETTINGS] Daily reminders disabled")
373-
}
374-
375-
// Save settings to disk
376-
app.saveSettings()
377-
})
378-
379373
// Add login item option (macOS only)
380374
addLoginItemUI(ctx, app)
381375

@@ -405,6 +399,36 @@ func (app *App) addStaticMenuItems(ctx context.Context) {
405399
app.saveSettings()
406400
})
407401

402+
// Auto-open blocked PRs in browser
403+
// Add 'Auto-open PRs' option
404+
autoOpenItem := systray.AddMenuItem("Auto-open incoming PRs", "Automatically open newly blocked PRs in browser (rate limited)")
405+
app.mu.RLock()
406+
if app.enableAutoBrowser {
407+
autoOpenItem.Check()
408+
}
409+
app.mu.RUnlock()
410+
autoOpenItem.Click(func() {
411+
app.mu.Lock()
412+
app.enableAutoBrowser = !app.enableAutoBrowser
413+
enabled := app.enableAutoBrowser
414+
// Reset rate limiter when toggling the feature
415+
if !enabled {
416+
app.browserRateLimiter.Reset()
417+
}
418+
app.mu.Unlock()
419+
420+
if enabled {
421+
autoOpenItem.Check()
422+
log.Println("[SETTINGS] Auto-open blocked PRs enabled")
423+
} else {
424+
autoOpenItem.Uncheck()
425+
log.Println("[SETTINGS] Auto-open blocked PRs disabled")
426+
}
427+
428+
// Save settings to disk
429+
app.saveSettings()
430+
})
431+
408432
// Quit
409433
// Add 'Quit' option
410434
quitItem := systray.AddMenuItem("Quit", "")

0 commit comments

Comments
 (0)