Skip to content

Commit 6e4910f

Browse files
authored
Merge pull request #13 from ready-to-review/readme
Add more HONK to notifications system
2 parents 96fee33 + 21af74e commit 6e4910f

File tree

11 files changed

+268
-231
lines changed

11 files changed

+268
-231
lines changed

Makefile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ app-bundle: out build-darwin install-appify
126126
/usr/libexec/PlistBuddy -c "Set :LSUIElement true" "out/$(BUNDLE_NAME).app/Contents/Info.plist"
127127
@/usr/libexec/PlistBuddy -c "Add :CFBundleDevelopmentRegion string en" "out/$(BUNDLE_NAME).app/Contents/Info.plist" 2>/dev/null || \
128128
/usr/libexec/PlistBuddy -c "Set :CFBundleDevelopmentRegion en" "out/$(BUNDLE_NAME).app/Contents/Info.plist"
129+
@/usr/libexec/PlistBuddy -c "Add :NSUserNotificationAlertStyle string alert" "out/$(BUNDLE_NAME).app/Contents/Info.plist" 2>/dev/null || \
130+
/usr/libexec/PlistBuddy -c "Set :NSUserNotificationAlertStyle alert" "out/$(BUNDLE_NAME).app/Contents/Info.plist"
129131

130132
# Remove extended attributes and code sign the app bundle
131133
@echo "Preparing app bundle for signing..."

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,18 @@ git clone https://github.com/ready-to-review/pr-menubar.git
1616
cd pr-menubar && make run
1717
```
1818

19-
The app appears in your menubar showing: `incoming / outgoing` PRs
19+
The app appears in your menubar showing: 🪿 (incoming blocked on you) or 🎉 (outgoing blocked)
2020

2121
## Features
2222

23-
- **Smart Notifications**: Only alerts when YOU are the blocker (not just assigned)
24-
- **Sound Effects**: Audio cues for important PR events 🔊
23+
- **Smart Notifications**: Desktop alerts + sounds when PRs become blocked (🪿 honk for incoming, 🚀 rocket for outgoing)
24+
- **Comprehensive Coverage**: Tracks PRs you're involved in + PRs in your repos needing reviewers
2525
- **Detailed Tooltips**: Hover to see why you're blocking and what's needed
2626
- **Test-Aware**: Waits for CI to pass before notifying
2727
- **Zero Noise**: No pings for PRs that aren't actually blocked on you
2828
- **One-Click Access**: Open any PR instantly from the menubar
2929
- **Multi-User Support**: Track PRs for different GitHub accounts with `--user`
30+
- **Auto-Start**: macOS "Start at Login" option (when running from /Applications)
3031

3132
## Installation
3233

@@ -36,7 +37,7 @@ make install # Traditional install for your OS
3637
make build # Build only
3738
```
3839

39-
**Requirements**: GitHub CLI (`gh`) authenticated, Go 1.21+ (for building)
40+
**Requirements**: GitHub CLI (`gh`) authenticated, Go 1.23+ (for building)
4041

4142
## Privacy
4243

github.go

Lines changed: 111 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -119,32 +119,12 @@ func (*App) githubToken(ctx context.Context) (string, error) {
119119
return token, nil
120120
}
121121

122-
// fetchPRsInternal is the implementation for PR fetching.
123-
// It returns GitHub data immediately and starts Turn API queries in the background (when waitForTurn=false),
124-
// or waits for Turn data to complete (when waitForTurn=true).
125-
func (app *App) fetchPRsInternal(ctx context.Context, waitForTurn bool) (incoming []PR, outgoing []PR, err error) {
126-
// Use targetUser if specified, otherwise use authenticated user
127-
user := app.currentUser.GetLogin()
128-
if app.targetUser != "" {
129-
user = app.targetUser
130-
}
131-
132-
// Single query to get all PRs involving the user
133-
query := fmt.Sprintf("is:open is:pr involves:%s archived:false", user)
134-
135-
const perPage = 100
136-
opts := &github.SearchOptions{
137-
ListOptions: github.ListOptions{PerPage: perPage},
138-
Sort: "updated",
139-
Order: "desc",
140-
}
141-
142-
log.Printf("Searching for PRs with query: %s", query)
143-
searchStart := time.Now()
144-
122+
// executeGitHubQuery executes a single GitHub search query with retry logic.
123+
func (app *App) executeGitHubQuery(ctx context.Context, query string, opts *github.SearchOptions) (*github.IssuesSearchResult, error) {
145124
var result *github.IssuesSearchResult
146125
var resp *github.Response
147-
err = retry.Do(func() error {
126+
127+
err := retry.Do(func() error {
148128
// Create timeout context for GitHub API call
149129
githubCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
150130
defer cancel()
@@ -194,19 +174,97 @@ func (app *App) fetchPRsInternal(ctx context.Context, waitForTurn bool) (incomin
194174
retry.Context(ctx),
195175
)
196176
if err != nil {
197-
return nil, nil, fmt.Errorf("search PRs after %d retries: %w", maxRetries, err)
177+
return nil, err
198178
}
179+
return result, nil
180+
}
199181

200-
log.Printf("GitHub search completed in %v, found %d PRs", time.Since(searchStart), len(result.Issues))
182+
// fetchPRsInternal is the implementation for PR fetching.
183+
// It returns GitHub data immediately and starts Turn API queries in the background (when waitForTurn=false),
184+
// or waits for Turn data to complete (when waitForTurn=true).
185+
func (app *App) fetchPRsInternal(ctx context.Context, waitForTurn bool) (incoming []PR, outgoing []PR, err error) {
186+
// Use targetUser if specified, otherwise use authenticated user
187+
user := app.currentUser.GetLogin()
188+
if app.targetUser != "" {
189+
user = app.targetUser
190+
}
191+
192+
const perPage = 100
193+
opts := &github.SearchOptions{
194+
ListOptions: github.ListOptions{PerPage: perPage},
195+
Sort: "updated",
196+
Order: "desc",
197+
}
198+
199+
searchStart := time.Now()
200+
201+
// Run both queries in parallel
202+
type queryResult struct {
203+
query string
204+
issues []*github.Issue
205+
err error
206+
}
207+
208+
queryResults := make(chan queryResult, 2)
209+
210+
// Query 1: PRs involving the user
211+
go func() {
212+
query := fmt.Sprintf("is:open is:pr involves:%s archived:false", user)
213+
log.Printf("[GITHUB] Searching for PRs with query: %s", query)
214+
215+
result, err := app.executeGitHubQuery(ctx, query, opts)
216+
if err != nil {
217+
queryResults <- queryResult{err: err, query: query}
218+
} else {
219+
queryResults <- queryResult{issues: result.Issues, query: query}
220+
}
221+
}()
222+
223+
// Query 2: PRs in user-owned repos with no reviewers
224+
go func() {
225+
query := fmt.Sprintf("is:open is:pr user:%s review:none archived:false", user)
226+
log.Printf("[GITHUB] Searching for PRs with query: %s", query)
227+
228+
result, err := app.executeGitHubQuery(ctx, query, opts)
229+
if err != nil {
230+
queryResults <- queryResult{err: err, query: query}
231+
} else {
232+
queryResults <- queryResult{issues: result.Issues, query: query}
233+
}
234+
}()
235+
236+
// Collect results from both queries
237+
var allIssues []*github.Issue
238+
seenURLs := make(map[string]bool)
239+
240+
for range 2 {
241+
result := <-queryResults
242+
if result.err != nil {
243+
log.Printf("[GITHUB] Query failed: %s - %v", result.query, result.err)
244+
// Continue processing other query results even if one fails
245+
continue
246+
}
247+
log.Printf("[GITHUB] Query completed: %s - found %d PRs", result.query, len(result.issues))
248+
249+
// Deduplicate PRs based on URL
250+
for _, issue := range result.issues {
251+
url := issue.GetHTMLURL()
252+
if !seenURLs[url] {
253+
seenURLs[url] = true
254+
allIssues = append(allIssues, issue)
255+
}
256+
}
257+
}
258+
log.Printf("[GITHUB] Both searches completed in %v, found %d unique PRs", time.Since(searchStart), len(allIssues))
201259

202260
// Limit PRs for performance
203-
if len(result.Issues) > maxPRsToProcess {
204-
log.Printf("Limiting to %d PRs for performance (total: %d)", maxPRsToProcess, len(result.Issues))
205-
result.Issues = result.Issues[:maxPRsToProcess]
261+
if len(allIssues) > maxPRsToProcess {
262+
log.Printf("Limiting to %d PRs for performance (total: %d)", maxPRsToProcess, len(allIssues))
263+
allIssues = allIssues[:maxPRsToProcess]
206264
}
207265

208266
// Process GitHub results immediately
209-
for _, issue := range result.Issues {
267+
for _, issue := range allIssues {
210268
if !issue.IsPullRequest() {
211269
continue
212270
}
@@ -229,26 +287,21 @@ func (app *App) fetchPRsInternal(ctx context.Context, waitForTurn bool) (incomin
229287
}
230288
}
231289

290+
// Only log summary, not individual PRs
232291
log.Printf("[GITHUB] Found %d incoming, %d outgoing PRs from GitHub", len(incoming), len(outgoing))
233-
for i := range incoming {
234-
log.Printf("[GITHUB] Incoming PR: %s", incoming[i].URL)
235-
}
236-
for i := range outgoing {
237-
log.Printf("[GITHUB] Outgoing PR: %s", outgoing[i].URL)
238-
}
239292

240293
// Fetch Turn API data
241294
if waitForTurn {
242295
// Synchronous - wait for Turn data
243-
log.Println("[TURN] Fetching Turn API data synchronously before building menu...")
244-
app.fetchTurnDataSync(ctx, result.Issues, user, &incoming, &outgoing)
296+
// Fetch Turn API data synchronously before building menu
297+
app.fetchTurnDataSync(ctx, allIssues, user, &incoming, &outgoing)
245298
} else {
246299
// Asynchronous - start in background
247300
app.mu.Lock()
248301
app.loadingTurnData = true
249302
app.pendingTurnResults = make([]TurnResult, 0) // Reset buffer
250303
app.mu.Unlock()
251-
go app.fetchTurnDataAsync(ctx, result.Issues, user)
304+
go app.fetchTurnDataAsync(ctx, allIssues, user)
252305
}
253306

254307
return incoming, outgoing, nil
@@ -366,7 +419,10 @@ func (app *App) fetchTurnDataSync(ctx context.Context, issues []*github.Issue, u
366419
if action, exists := result.turnData.PRState.UnblockAction[user]; exists {
367420
needsReview = true
368421
actionReason = action.Reason
369-
log.Printf("[TURN] UnblockAction for %s: Reason=%q, Kind=%q", result.url, action.Reason, action.Kind)
422+
// Only log fresh API calls
423+
if !result.wasFromCache {
424+
log.Printf("[TURN] UnblockAction for %s: Reason=%q, Kind=%q", result.url, action.Reason, action.Kind)
425+
}
370426
}
371427

372428
// Update the PR in the slices directly
@@ -400,7 +456,7 @@ func (app *App) fetchTurnDataSync(ctx context.Context, issues []*github.Issue, u
400456
// fetchTurnDataAsync fetches Turn API data in the background and updates PRs as results arrive.
401457
func (app *App) fetchTurnDataAsync(ctx context.Context, issues []*github.Issue, user string) {
402458
// Log start of Turn API queries
403-
log.Print("[TURN] Starting Turn API queries in background")
459+
// Start Turn API queries in background
404460

405461
turnStart := time.Now()
406462
type prResult struct {
@@ -466,10 +522,10 @@ func (app *App) fetchTurnDataAsync(ctx context.Context, issues []*github.Issue,
466522
if action, exists := result.turnData.PRState.UnblockAction[user]; exists {
467523
needsReview = true
468524
actionReason = action.Reason
469-
log.Printf("[TURN] UnblockAction for %s: Reason=%q, Kind=%q", result.url, action.Reason, action.Kind)
470-
} else if !result.wasFromCache {
471-
// Only log "no action" for fresh API results, not cached ones
472-
log.Printf("[TURN] No UnblockAction found for user %s on %s", user, result.url)
525+
// Only log blocked PRs from fresh API calls
526+
if !result.wasFromCache {
527+
log.Printf("[TURN] UnblockAction for %s: Reason=%q, Kind=%q", result.url, action.Reason, action.Kind)
528+
}
473529
}
474530

475531
// Buffer the Turn result instead of applying immediately
@@ -486,13 +542,9 @@ func (app *App) fetchTurnDataAsync(ctx context.Context, issues []*github.Issue,
486542
app.mu.Unlock()
487543

488544
updatesApplied++
489-
// Reduce verbosity - only log if not from cache or if blocked
490-
if !result.wasFromCache || needsReview {
491-
cacheStatus := "fresh"
492-
if result.wasFromCache {
493-
cacheStatus = "cached"
494-
}
495-
log.Printf("[TURN] %s data for %s (needsReview=%v, actionReason=%q)", cacheStatus, result.url, needsReview, actionReason)
545+
// Only log fresh API calls (not cached)
546+
if !result.wasFromCache {
547+
log.Printf("[TURN] Fresh API data for %s (needsReview=%v)", result.url, needsReview)
496548
}
497549
} else if result.err != nil {
498550
turnFailures++
@@ -519,7 +571,10 @@ func (app *App) fetchTurnDataAsync(ctx context.Context, issues []*github.Issue,
519571
}
520572
}
521573

522-
log.Printf("[TURN] Applying %d buffered Turn results (%d from cache, %d fresh)", len(pendingResults), cacheHits, freshResults)
574+
// Only log if we have fresh results
575+
if freshResults > 0 {
576+
log.Printf("[TURN] Applying %d buffered Turn results (%d from cache, %d fresh)", len(pendingResults), cacheHits, freshResults)
577+
}
523578

524579
// Track how many PRs actually changed
525580
var actualChanges int
@@ -530,15 +585,15 @@ func (app *App) fetchTurnDataAsync(ctx context.Context, issues []*github.Issue,
530585
}
531586
}
532587

588+
// Check for newly blocked PRs after Turn data is applied
589+
app.checkForNewlyBlockedPRs(ctx)
590+
533591
// Update tray title and menu with final Turn data if menu is already initialized
534592
app.setTrayTitle()
535593
if app.menuInitialized {
536594
// Only trigger menu update if PR data actually changed
537595
if actualChanges > 0 {
538-
log.Printf("[TURN] Turn data applied - %d PRs actually changed, checking if menu needs update", actualChanges)
539596
app.updateMenuIfChanged(ctx)
540-
} else {
541-
log.Print("[TURN] Turn data applied - no PR changes detected (cached data unchanged), skipping menu update")
542597
}
543598
}
544599
}

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
module ready-to-review
1+
module github.com/ready-to-review/pr-menubar
22

33
go 1.23.4
44

55
require (
66
github.com/codeGROOVE-dev/retry v1.2.0
77
github.com/energye/systray v1.0.2
88
github.com/gen2brain/beeep v0.11.1
9+
github.com/google/go-cmp v0.7.0
910
github.com/google/go-github/v57 v57.0.0
1011
github.com/ready-to-review/turnclient v0.0.0-20250718014946-bb5bb107649f
1112
golang.org/x/oauth2 v0.30.0
@@ -16,7 +17,6 @@ require (
1617
github.com/esiqveland/notify v0.13.3 // indirect
1718
github.com/go-ole/go-ole v1.3.0 // indirect
1819
github.com/godbus/dbus/v5 v5.1.0 // indirect
19-
github.com/google/go-cmp v0.7.0 // indirect
2020
github.com/google/go-querystring v1.1.0 // indirect
2121
github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
2222
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect

go.sum

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x
1717
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
1818
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
1919
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
20-
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
21-
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
2220
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
2321
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
2422
github.com/google/go-github/v57 v57.0.0 h1:L+Y3UPTY8ALM8x+TV0lg+IEBI+upibemtBD8Q9u7zHs=

loginitem_darwin.go

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -113,24 +113,6 @@ func setLoginItem(ctx context.Context, enable bool) error {
113113
return nil
114114
}
115115

116-
// isRunningFromAppBundle checks if the app is running from a .app bundle.
117-
func isRunningFromAppBundle() bool {
118-
execPath, err := os.Executable()
119-
if err != nil {
120-
return false
121-
}
122-
123-
// Resolve any symlinks
124-
execPath, err = filepath.EvalSymlinks(execPath)
125-
if err != nil {
126-
return false
127-
}
128-
129-
// Check if we're running from an app bundle
130-
// App bundles have the structure: /path/to/App.app/Contents/MacOS/executable
131-
return strings.Contains(execPath, ".app/Contents/MacOS/")
132-
}
133-
134116
// appPath returns the path to the application bundle.
135117
func appPath() (string, error) {
136118
// Get the executable path
@@ -161,8 +143,22 @@ func appPath() (string, error) {
161143

162144
// addLoginItemUI adds the login item menu option (macOS only).
163145
func addLoginItemUI(ctx context.Context, _ *App) {
164-
// Only show login item menu if running from an app bundle
165-
if !isRunningFromAppBundle() {
146+
// Check if we're running from an app bundle
147+
execPath, err := os.Executable()
148+
if err != nil {
149+
log.Println("Hiding 'Start at Login' menu item - could not get executable path")
150+
return
151+
}
152+
153+
// Resolve any symlinks
154+
execPath, err = filepath.EvalSymlinks(execPath)
155+
if err != nil {
156+
log.Println("Hiding 'Start at Login' menu item - could not resolve symlinks")
157+
return
158+
}
159+
160+
// App bundles have the structure: /path/to/App.app/Contents/MacOS/executable
161+
if !strings.Contains(execPath, ".app/Contents/MacOS/") {
166162
log.Println("Hiding 'Start at Login' menu item - not running from app bundle")
167163
return
168164
}

0 commit comments

Comments
 (0)