Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
# Ready-to-Review Goose 🪿
# Review Goose 🪿

![Experimental](https://img.shields.io/badge/status-beta-orange)
![Beta](https://img.shields.io/badge/status-beta-orange)
![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20BSD%20%7C%20Windows-blue)
![Goose Noises](https://img.shields.io/badge/goose%20noises-100%25%20more-green)
[![GitHub](https://img.shields.io/github/stars/ready-to-review/goose?style=social)](https://github.com/ready-to-review/goose)

![Review Goose Logo](media/logo-small.png)

The only PR tracker that honks at you when you're the bottleneck. Now shipping with 100% more goose noises!

Lives in your menubar like a tiny waterfowl of productivity shame, watching your GitHub PRs and making aggressive bird sounds when you're blocking someone's code from seeing the light of production.


![PR Menubar Screenshot](media/screenshot.png)
![Review Goose Screenshot](media/screenshot.png)

## What It Does

Expand Down Expand Up @@ -67,8 +68,8 @@ We don't yet try to persist fine-grained tokens to disk - PR's welcome!
## Pricing

The Goose is part of the [codeGROOVE](https://codegroove.dev) developer acceleration platform:
- **FREE forever** for open-source repositories
- Low-cost fee TBD for access to private repos (the goose needs to eat!)
- **FREE forever** for open-source or public repositories
- GitHub Sponsors gain access to private repos ($2.56/mo recommended)

## Privacy

Expand Down
79 changes: 62 additions & 17 deletions cmd/goose/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const (
type PR struct {
UpdatedAt time.Time
TurnDataAppliedAt time.Time
FirstBlockedAt time.Time // When this PR was first detected as blocked
Title string
URL string
Repository string
Expand All @@ -72,29 +73,30 @@ type TurnResult struct {
// App holds the application state.
type App struct {
lastSuccessfulFetch time.Time
startTime time.Time
client *github.Client
turnClient *turn.Client
currentUser *github.User
previousBlockedPRs map[string]bool
blockedPRTimes map[string]time.Time
browserRateLimiter *BrowserRateLimiter
targetUser string
cacheDir string
authError string
pendingTurnResults []TurnResult
lastMenuTitles []string
incoming []PR
outgoing []PR
lastMenuTitles []string
pendingTurnResults []TurnResult
updateInterval time.Duration
consecutiveFailures int
mu sync.RWMutex
noCache bool
hideStaleIncoming bool
loadingTurnData bool
menuInitialized bool
initialLoadComplete bool
enableAudioCues bool
enableAutoBrowser bool
browserRateLimiter *BrowserRateLimiter
startTime time.Time
hideStaleIncoming bool
noCache bool
}

func loadCurrentUser(ctx context.Context, app *App) {
Expand Down Expand Up @@ -208,6 +210,7 @@ func main() {
cacheDir: cacheDir,
hideStaleIncoming: true,
previousBlockedPRs: make(map[string]bool),
blockedPRTimes: make(map[string]time.Time),
targetUser: targetUser,
noCache: noCache,
updateInterval: updateInterval,
Expand Down Expand Up @@ -557,6 +560,7 @@ func (app *App) notifyWithSound(ctx context.Context, pr PR, isIncoming bool, pla

// Play sound only once per refresh cycle
if !*playedSound {
log.Printf("[SOUND] Playing %s sound for PR: %s #%d - %s", soundType, pr.Repository, pr.Number, pr.Title)
app.playSound(ctx, soundType)
*playedSound = true
}
Expand All @@ -568,22 +572,43 @@ func (app *App) checkForNewlyBlockedPRs(ctx context.Context) {
incoming := app.incoming
outgoing := app.outgoing
previousBlocked := app.previousBlockedPRs
blockedTimes := app.blockedPRTimes
autoBrowserEnabled := app.enableAutoBrowser
startTime := app.startTime
hideStaleIncoming := app.hideStaleIncoming
app.mu.RUnlock()

currentBlocked := make(map[string]bool)
newBlockedTimes := make(map[string]time.Time)
playedHonk := false
playedJet := false
now := time.Now()
staleThreshold := now.Add(-stalePRThreshold)

// Check incoming PRs
for i := range incoming {
if incoming[i].NeedsReview {
currentBlocked[incoming[i].URL] = true
// Notify if newly blocked
if !previousBlocked[incoming[i].URL] {
app.notifyWithSound(ctx, incoming[i], true, &playedHonk)
app.tryAutoOpenPR(ctx, incoming[i], autoBrowserEnabled, startTime)
// Track when first blocked
if blockedTime, exists := blockedTimes[incoming[i].URL]; exists {
newBlockedTimes[incoming[i].URL] = blockedTime
incoming[i].FirstBlockedAt = blockedTime
} else if !previousBlocked[incoming[i].URL] {
// Newly blocked PR
newBlockedTimes[incoming[i].URL] = now
incoming[i].FirstBlockedAt = now

// Skip sound and auto-open for stale PRs when hideStaleIncoming is enabled
isStale := incoming[i].UpdatedAt.Before(staleThreshold)
if hideStaleIncoming && isStale {
log.Printf("[BLOCKED] New incoming PR blocked (stale, skipping): %s #%d - %s",
incoming[i].Repository, incoming[i].Number, incoming[i].Title)
} else {
log.Printf("[BLOCKED] New incoming PR blocked: %s #%d - %s",
incoming[i].Repository, incoming[i].Number, incoming[i].Title)
app.notifyWithSound(ctx, incoming[i], true, &playedHonk)
app.tryAutoOpenPR(ctx, incoming[i], autoBrowserEnabled, startTime)
}
}
}
}
Expand All @@ -592,19 +617,39 @@ func (app *App) checkForNewlyBlockedPRs(ctx context.Context) {
for i := range outgoing {
if outgoing[i].IsBlocked {
currentBlocked[outgoing[i].URL] = true
// Notify if newly blocked
if !previousBlocked[outgoing[i].URL] {
// Add delay if we already played honk sound
if playedHonk && !playedJet {
time.Sleep(2 * time.Second)
// Track when first blocked
if blockedTime, exists := blockedTimes[outgoing[i].URL]; exists {
newBlockedTimes[outgoing[i].URL] = blockedTime
outgoing[i].FirstBlockedAt = blockedTime
} else if !previousBlocked[outgoing[i].URL] {
// Newly blocked PR
newBlockedTimes[outgoing[i].URL] = now
outgoing[i].FirstBlockedAt = now

// Skip sound and auto-open for stale PRs when hideStaleIncoming is enabled
isStale := outgoing[i].UpdatedAt.Before(staleThreshold)
if hideStaleIncoming && isStale {
log.Printf("[BLOCKED] New outgoing PR blocked (stale, skipping): %s #%d - %s",
outgoing[i].Repository, outgoing[i].Number, outgoing[i].Title)
} else {
// Add delay if we already played honk sound
if playedHonk && !playedJet {
time.Sleep(2 * time.Second)
}
log.Printf("[BLOCKED] New outgoing PR blocked: %s #%d - %s",
outgoing[i].Repository, outgoing[i].Number, outgoing[i].Title)
app.notifyWithSound(ctx, outgoing[i], false, &playedJet)
app.tryAutoOpenPR(ctx, outgoing[i], autoBrowserEnabled, startTime)
}
app.notifyWithSound(ctx, outgoing[i], false, &playedJet)
app.tryAutoOpenPR(ctx, outgoing[i], autoBrowserEnabled, startTime)
}
}
}

app.mu.Lock()
app.previousBlockedPRs = currentBlocked
app.blockedPRTimes = newBlockedTimes
// Update the PR lists with FirstBlockedAt times
app.incoming = incoming
app.outgoing = outgoing
app.mu.Unlock()
}
2 changes: 1 addition & 1 deletion cmd/goose/security.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ func validateGitHubPRURL(rawURL string) error {
return errors.New("URL contains @ character")
}

// Reject URLs with URL encoding (could hide malicious content)
// Reject URLs with URL encoding (could hide malicious content)
// Exception: %3D which is = in URL encoding, only as part of ?goose=1
if strings.Contains(rawURL, "%") && !strings.HasSuffix(rawURL, "?goose%3D1") {
return errors.New("URL contains encoded characters")
Expand Down
2 changes: 1 addition & 1 deletion cmd/goose/security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,4 @@ func TestValidateGitHubPRURL(t *testing.T) {
}
})
}
}
}
11 changes: 8 additions & 3 deletions cmd/goose/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@
sortedPRs := sortPRsBlockedFirst(prs)

// Add PR items in sorted order
for i := range sortedPRs {

Check failure on line 196 in cmd/goose/ui.go

View workflow job for this annotation

GitHub Actions / golangci-lint

variable name 'i' is too short for the scope of its usage (varnamelen)
// Apply filters
// Skip stale PRs if configured
if app.hideStaleIncoming && sortedPRs[i].UpdatedAt.Before(time.Now().Add(-stalePRThreshold)) {
Expand All @@ -201,9 +201,14 @@
}

title := fmt.Sprintf("%s #%d", sortedPRs[i].Repository, sortedPRs[i].Number)
// Add bullet point for PRs where user is blocking
if sortedPRs[i].NeedsReview {
title = fmt.Sprintf("• %s", title)
// Add bullet point or goose for blocked PRs
if sortedPRs[i].NeedsReview || sortedPRs[i].IsBlocked {
// Show goose emoji for PRs blocked within the last hour
if !sortedPRs[i].FirstBlockedAt.IsZero() && time.Since(sortedPRs[i].FirstBlockedAt) < time.Hour {
title = fmt.Sprintf("🪿 %s", title)
} else {
title = fmt.Sprintf("• %s", title)
}
}
// Format age inline for tooltip
duration := time.Since(sortedPRs[i].UpdatedAt)
Expand Down
Binary file modified media/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading