diff --git a/README.md b/README.md index 657ee53..bd11d2d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/cmd/goose/main.go b/cmd/goose/main.go index 21c5d4c..3a83af6 100644 --- a/cmd/goose/main.go +++ b/cmd/goose/main.go @@ -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 @@ -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) { @@ -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, @@ -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 } @@ -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) + } } } } @@ -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() } diff --git a/cmd/goose/security.go b/cmd/goose/security.go index 058ce9a..04fc557 100644 --- a/cmd/goose/security.go +++ b/cmd/goose/security.go @@ -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") diff --git a/cmd/goose/security_test.go b/cmd/goose/security_test.go index 321424a..0840805 100644 --- a/cmd/goose/security_test.go +++ b/cmd/goose/security_test.go @@ -105,4 +105,4 @@ func TestValidateGitHubPRURL(t *testing.T) { } }) } -} \ No newline at end of file +} diff --git a/cmd/goose/ui.go b/cmd/goose/ui.go index 11741b0..c7d96c4 100644 --- a/cmd/goose/ui.go +++ b/cmd/goose/ui.go @@ -201,9 +201,14 @@ func (app *App) addPRSection(ctx context.Context, prs []PR, sectionTitle string, } 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) diff --git a/media/logo.png b/media/logo.png index 61b6ec5..8063b2b 100644 Binary files a/media/logo.png and b/media/logo.png differ