diff --git a/cmd/goose/filtering_test.go b/cmd/goose/filtering_test.go new file mode 100644 index 0000000..c1066ba --- /dev/null +++ b/cmd/goose/filtering_test.go @@ -0,0 +1,157 @@ +package main + +import ( + "testing" + "time" +) + +// TestCountPRsWithHiddenOrgs tests that PRs from hidden orgs are not counted +func TestCountPRsWithHiddenOrgs(t *testing.T) { + app := &App{ + incoming: []PR{ + {Repository: "org1/repo1", NeedsReview: true, UpdatedAt: time.Now()}, + {Repository: "org2/repo2", NeedsReview: true, UpdatedAt: time.Now()}, + {Repository: "org3/repo3", NeedsReview: true, UpdatedAt: time.Now()}, + }, + outgoing: []PR{ + {Repository: "org1/repo4", IsBlocked: true, UpdatedAt: time.Now()}, + {Repository: "org2/repo5", IsBlocked: true, UpdatedAt: time.Now()}, + }, + hiddenOrgs: map[string]bool{ + "org2": true, // Hide org2 + }, + hideStaleIncoming: false, + } + + counts := app.countPRs() + + // Should only count PRs from org1 and org3, not org2 + if counts.IncomingTotal != 2 { + t.Errorf("IncomingTotal = %d, want 2 (org2 should be hidden)", counts.IncomingTotal) + } + if counts.IncomingBlocked != 2 { + t.Errorf("IncomingBlocked = %d, want 2 (org2 should be hidden)", counts.IncomingBlocked) + } + if counts.OutgoingTotal != 1 { + t.Errorf("OutgoingTotal = %d, want 1 (org2 should be hidden)", counts.OutgoingTotal) + } + if counts.OutgoingBlocked != 1 { + t.Errorf("OutgoingBlocked = %d, want 1 (org2 should be hidden)", counts.OutgoingBlocked) + } +} + +// TestCountPRsWithStalePRs tests that stale PRs are not counted when hideStaleIncoming is true +func TestCountPRsWithStalePRs(t *testing.T) { + now := time.Now() + staleTime := now.Add(-100 * 24 * time.Hour) // 100 days ago + recentTime := now.Add(-1 * time.Hour) // 1 hour ago + + app := &App{ + incoming: []PR{ + {Repository: "org1/repo1", NeedsReview: true, UpdatedAt: staleTime}, + {Repository: "org1/repo2", NeedsReview: true, UpdatedAt: recentTime}, + {Repository: "org2/repo3", NeedsReview: false, UpdatedAt: staleTime}, + }, + outgoing: []PR{ + {Repository: "org1/repo4", IsBlocked: true, UpdatedAt: staleTime}, + {Repository: "org1/repo5", IsBlocked: true, UpdatedAt: recentTime}, + }, + hiddenOrgs: map[string]bool{}, + hideStaleIncoming: true, // Hide stale PRs + } + + counts := app.countPRs() + + // Should only count recent PRs + if counts.IncomingTotal != 1 { + t.Errorf("IncomingTotal = %d, want 1 (stale PRs should be hidden)", counts.IncomingTotal) + } + if counts.IncomingBlocked != 1 { + t.Errorf("IncomingBlocked = %d, want 1 (stale PRs should be hidden)", counts.IncomingBlocked) + } + if counts.OutgoingTotal != 1 { + t.Errorf("OutgoingTotal = %d, want 1 (stale PRs should be hidden)", counts.OutgoingTotal) + } + if counts.OutgoingBlocked != 1 { + t.Errorf("OutgoingBlocked = %d, want 1 (stale PRs should be hidden)", counts.OutgoingBlocked) + } +} + +// TestCountPRsWithBothFilters tests that both filters work together +func TestCountPRsWithBothFilters(t *testing.T) { + now := time.Now() + staleTime := now.Add(-100 * 24 * time.Hour) + recentTime := now.Add(-1 * time.Hour) + + app := &App{ + incoming: []PR{ + {Repository: "org1/repo1", NeedsReview: true, UpdatedAt: recentTime}, // Should be counted + {Repository: "org2/repo2", NeedsReview: true, UpdatedAt: recentTime}, // Hidden org + {Repository: "org3/repo3", NeedsReview: true, UpdatedAt: staleTime}, // Stale + {Repository: "org1/repo4", NeedsReview: false, UpdatedAt: recentTime}, // Not blocked + }, + outgoing: []PR{ + {Repository: "org1/repo5", IsBlocked: true, UpdatedAt: recentTime}, // Should be counted + {Repository: "org2/repo6", IsBlocked: true, UpdatedAt: recentTime}, // Hidden org + {Repository: "org3/repo7", IsBlocked: true, UpdatedAt: staleTime}, // Stale + }, + hiddenOrgs: map[string]bool{ + "org2": true, + }, + hideStaleIncoming: true, + } + + counts := app.countPRs() + + // Should only count org1/repo1 (incoming) and org1/repo5 (outgoing) + if counts.IncomingTotal != 2 { + t.Errorf("IncomingTotal = %d, want 2", counts.IncomingTotal) + } + if counts.IncomingBlocked != 1 { + t.Errorf("IncomingBlocked = %d, want 1", counts.IncomingBlocked) + } + if counts.OutgoingTotal != 1 { + t.Errorf("OutgoingTotal = %d, want 1", counts.OutgoingTotal) + } + if counts.OutgoingBlocked != 1 { + t.Errorf("OutgoingBlocked = %d, want 1", counts.OutgoingBlocked) + } +} + +// TestExtractOrgFromRepo tests the org extraction function +func TestExtractOrgFromRepo(t *testing.T) { + tests := []struct { + repo string + name string + want string + }{ + { + name: "standard repo path", + repo: "microsoft/vscode", + want: "microsoft", + }, + { + name: "single segment", + repo: "justarepo", + want: "justarepo", + }, + { + name: "empty string", + repo: "", + want: "", + }, + { + name: "nested path", + repo: "org/repo/subpath", + want: "org", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := extractOrgFromRepo(tt.repo); got != tt.want { + t.Errorf("extractOrgFromRepo(%q) = %q, want %q", tt.repo, got, tt.want) + } + }) + } +} diff --git a/cmd/goose/github.go b/cmd/goose/github.go index 93192a5..4caafb3 100644 --- a/cmd/goose/github.go +++ b/cmd/goose/github.go @@ -20,6 +20,15 @@ import ( "golang.org/x/oauth2" ) +// extractOrgFromRepo extracts the organization name from a repository path like "org/repo". +func extractOrgFromRepo(repo string) string { + parts := strings.Split(repo, "/") + if len(parts) >= 1 { + return parts[0] + } + return "" +} + // initClients initializes GitHub and Turn API clients. func (app *App) initClients(ctx context.Context) error { token, err := app.token(ctx) @@ -321,6 +330,17 @@ func (app *App) fetchPRsInternal(ctx context.Context, waitForTurn bool) (incomin } repo := strings.TrimPrefix(issue.GetRepositoryURL(), "https://api.github.com/repos/") + // Extract org and track it (but don't filter here) + org := extractOrgFromRepo(repo) + if org != "" { + app.mu.Lock() + if !app.seenOrgs[org] { + log.Printf("[ORG] Discovered new organization: %s", org) + } + app.seenOrgs[org] = true + app.mu.Unlock() + } + pr := PR{ Title: issue.GetTitle(), URL: issue.GetHTMLURL(), diff --git a/cmd/goose/main.go b/cmd/goose/main.go index 8b9668d..dbae8c4 100644 --- a/cmd/goose/main.go +++ b/cmd/goose/main.go @@ -97,6 +97,8 @@ type App struct { enableAutoBrowser bool hideStaleIncoming bool noCache bool + hiddenOrgs map[string]bool + seenOrgs map[string]bool } func loadCurrentUser(ctx context.Context, app *App) { @@ -219,6 +221,8 @@ func main() { enableAutoBrowser: false, // Default to false for safety browserRateLimiter: NewBrowserRateLimiter(browserOpenDelay, maxBrowserOpensMinute, maxBrowserOpensDay), startTime: time.Now(), + seenOrgs: make(map[string]bool), + hiddenOrgs: make(map[string]bool), } // Load saved settings @@ -626,6 +630,12 @@ func (app *App) checkForNewlyBlockedPRs(ctx context.Context) { } } + // Get hidden orgs for filtering + hiddenOrgs := make(map[string]bool) + for org, hidden := range app.hiddenOrgs { + hiddenOrgs[org] = hidden + } + // Log any removed entries removedCount := 0 for url := range app.blockedPRTimes { @@ -655,6 +665,12 @@ func (app *App) checkForNewlyBlockedPRs(ctx context.Context) { // Check incoming PRs for i := range incoming { + // Skip PRs from hidden orgs for notifications + org := extractOrgFromRepo(incoming[i].Repository) + if org != "" && hiddenOrgs[org] { + continue + } + if incoming[i].NeedsReview { currentBlocked[incoming[i].URL] = true // Track when first blocked @@ -685,6 +701,12 @@ func (app *App) checkForNewlyBlockedPRs(ctx context.Context) { // Check outgoing PRs for i := range outgoing { + // Skip PRs from hidden orgs for notifications + org := extractOrgFromRepo(outgoing[i].Repository) + if org != "" && hiddenOrgs[org] { + continue + } + if outgoing[i].IsBlocked { currentBlocked[outgoing[i].URL] = true // Track when first blocked diff --git a/cmd/goose/settings.go b/cmd/goose/settings.go index d3c1788..e21c336 100644 --- a/cmd/goose/settings.go +++ b/cmd/goose/settings.go @@ -10,9 +10,10 @@ import ( // Settings represents persistent user settings. type Settings struct { - EnableAudioCues bool `json:"enable_audio_cues"` - HideStale bool `json:"hide_stale"` - EnableAutoBrowser bool `json:"enable_auto_browser"` + EnableAudioCues bool `json:"enable_audio_cues"` + HideStale bool `json:"hide_stale"` + EnableAutoBrowser bool `json:"enable_auto_browser"` + HiddenOrgs map[string]bool `json:"hidden_orgs"` } // settingsDir returns the configuration directory for settings. @@ -33,6 +34,7 @@ func (app *App) loadSettings() { app.enableAudioCues = true app.hideStaleIncoming = true app.enableAutoBrowser = false + app.hiddenOrgs = make(map[string]bool) return } @@ -47,6 +49,7 @@ func (app *App) loadSettings() { app.enableAudioCues = true app.hideStaleIncoming = true app.enableAutoBrowser = false + app.hiddenOrgs = make(map[string]bool) return } @@ -57,14 +60,20 @@ func (app *App) loadSettings() { app.enableAudioCues = true app.hideStaleIncoming = true app.enableAutoBrowser = false + app.hiddenOrgs = make(map[string]bool) return } app.enableAudioCues = settings.EnableAudioCues app.hideStaleIncoming = settings.HideStale app.enableAutoBrowser = settings.EnableAutoBrowser - log.Printf("Loaded settings: audio_cues=%v, hide_stale=%v, auto_browser=%v", - app.enableAudioCues, app.hideStaleIncoming, app.enableAutoBrowser) + if settings.HiddenOrgs != nil { + app.hiddenOrgs = settings.HiddenOrgs + } else { + app.hiddenOrgs = make(map[string]bool) + } + log.Printf("Loaded settings: audio_cues=%v, hide_stale=%v, auto_browser=%v, hidden_orgs=%d", + app.enableAudioCues, app.hideStaleIncoming, app.enableAutoBrowser, len(app.hiddenOrgs)) } // saveSettings saves current settings to disk. @@ -80,6 +89,7 @@ func (app *App) saveSettings() { EnableAudioCues: app.enableAudioCues, HideStale: app.hideStaleIncoming, EnableAutoBrowser: app.enableAutoBrowser, + HiddenOrgs: app.hiddenOrgs, } app.mu.RUnlock() @@ -102,6 +112,6 @@ func (app *App) saveSettings() { return } - log.Printf("Saved settings: audio_cues=%v, hide_stale=%v, auto_browser=%v", - settings.EnableAudioCues, settings.HideStale, settings.EnableAutoBrowser) + log.Printf("Saved settings: audio_cues=%v, hide_stale=%v, auto_browser=%v, hidden_orgs=%d", + settings.EnableAudioCues, settings.HideStale, settings.EnableAutoBrowser, len(settings.HiddenOrgs)) } diff --git a/cmd/goose/ui.go b/cmd/goose/ui.go index cd27a37..a0cb117 100644 --- a/cmd/goose/ui.go +++ b/cmd/goose/ui.go @@ -113,6 +113,12 @@ func (app *App) countPRs() PRCounts { staleThreshold := now.Add(-stalePRThreshold) for i := range app.incoming { + // Check if org is hidden + org := extractOrgFromRepo(app.incoming[i].Repository) + if org != "" && app.hiddenOrgs[org] { + continue + } + if !app.hideStaleIncoming || app.incoming[i].UpdatedAt.After(staleThreshold) { incomingCount++ if app.incoming[i].NeedsReview { @@ -122,6 +128,12 @@ func (app *App) countPRs() PRCounts { } for i := range app.outgoing { + // Check if org is hidden + org := extractOrgFromRepo(app.outgoing[i].Repository) + if org != "" && app.hiddenOrgs[org] { + continue + } + if !app.hideStaleIncoming || app.outgoing[i].UpdatedAt.After(staleThreshold) { outgoingCount++ if app.outgoing[i].IsBlocked { @@ -198,11 +210,26 @@ func (app *App) addPRSection(ctx context.Context, prs []PR, sectionTitle string, // Sort PRs with blocked ones first sortedPRs := sortPRsBlockedFirst(prs) + // Get hidden orgs with proper locking + app.mu.RLock() + hiddenOrgs := make(map[string]bool) + for org, hidden := range app.hiddenOrgs { + hiddenOrgs[org] = hidden + } + hideStale := app.hideStaleIncoming + app.mu.RUnlock() + // Add PR items in sorted order for i := range sortedPRs { // Apply filters + // Skip PRs from hidden orgs + org := extractOrgFromRepo(sortedPRs[i].Repository) + if org != "" && hiddenOrgs[org] { + continue + } + // Skip stale PRs if configured - if app.hideStaleIncoming && sortedPRs[i].UpdatedAt.Before(time.Now().Add(-stalePRThreshold)) { + if hideStale && sortedPRs[i].UpdatedAt.Before(time.Now().Add(-stalePRThreshold)) { continue } @@ -362,6 +389,72 @@ func (app *App) addStaticMenuItems(ctx context.Context) { systray.AddSeparator() + // Hide orgs submenu + // Add 'Hide orgs' submenu + hideOrgsMenu := systray.AddMenuItem("Hide orgs", "Select organizations to hide PRs from") + + // Get combined list of seen orgs and hidden orgs + app.mu.RLock() + orgSet := make(map[string]bool) + // Add all seen orgs + for org := range app.seenOrgs { + orgSet[org] = true + } + // Add all hidden orgs (in case they're not in seenOrgs yet) + for org := range app.hiddenOrgs { + orgSet[org] = true + } + // Convert to sorted slice + orgs := make([]string, 0, len(orgSet)) + for org := range orgSet { + orgs = append(orgs, org) + } + hiddenOrgs := make(map[string]bool) + for org, hidden := range app.hiddenOrgs { + hiddenOrgs[org] = hidden + } + app.mu.RUnlock() + + sort.Strings(orgs) + + if len(orgs) == 0 { + noOrgsItem := hideOrgsMenu.AddSubMenuItem("No organizations found", "") + noOrgsItem.Disable() + } else { + // Add checkbox items for each org + for _, org := range orgs { + orgName := org // Capture for closure + orgItem := hideOrgsMenu.AddSubMenuItem(orgName, "") + + // Check if org is currently hidden + if hiddenOrgs[orgName] { + orgItem.Check() + } + + orgItem.Click(func() { + app.mu.Lock() + if app.hiddenOrgs[orgName] { + delete(app.hiddenOrgs, orgName) + orgItem.Uncheck() + log.Printf("[SETTINGS] Unhiding org: %s", orgName) + } else { + app.hiddenOrgs[orgName] = true + orgItem.Check() + log.Printf("[SETTINGS] Hiding org: %s", orgName) + } + // Clear menu titles to force rebuild + app.lastMenuTitles = nil + app.mu.Unlock() + + // Save settings + app.saveSettings() + + // Rebuild menu to reflect changes + app.rebuildMenu(ctx) + }) + } + } + // Hide stale PRs // Add 'Hide stale PRs' option hideStaleItem := systray.AddMenuItem("Hide stale PRs (>90 days)", "")