Skip to content

Commit 4523ef2

Browse files
committed
Merge main into reorg - resolved conflicts and moved security files to cmd/goose
2 parents d994d02 + a962aff commit 4523ef2

File tree

9 files changed

+359
-54
lines changed

9 files changed

+359
-54
lines changed

.claude/CLAUDE.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
Ready to Review is a macOS/Linux/Windows menubar application that helps developers track GitHub pull requests. It shows a count of incoming/outgoing PRs and highlights when you're blocking someone's review. The app integrates with the Turn API to provide intelligent PR metadata about who's actually blocking progress.
8+
9+
## Key Commands
10+
11+
### Building and Running
12+
```bash
13+
make run # Build and run (on macOS: installs to /Applications and launches)
14+
make build # Build for current platform
15+
make app-bundle # Create macOS .app bundle
16+
make install # Install to system (macOS: /Applications, Linux: /usr/local/bin, Windows: %LOCALAPPDATA%)
17+
```
18+
19+
### Development
20+
```bash
21+
make lint # Run all linters (golangci-lint with strict config + yamllint)
22+
make fix # Auto-fix linting issues where possible
23+
make deps # Download and tidy Go dependencies
24+
make clean # Remove build artifacts
25+
```
26+
27+
## Architecture Overview
28+
29+
### Core Components
30+
31+
1. **Main Application Flow** (`main.go`)
32+
- Single `context.Background()` created in main, passed through all functions
33+
- App struct holds GitHub/Turn clients, PR data, and UI state
34+
- Update loop runs every 2 minutes to refresh PR data
35+
- Menu updates only rebuild when PR data actually changes (hash-based optimization)
36+
37+
2. **GitHub Integration** (`github.go`)
38+
- Uses GitHub CLI token (`gh auth token`) for authentication
39+
- Fetches PRs with a single optimized query: `is:open is:pr involves:USER archived:false`
40+
- No pagination needed (uses 100 per page limit)
41+
42+
3. **Turn API Integration** (`cache.go`)
43+
- Provides intelligent PR metadata (who's blocking, PR size, tags)
44+
- Implements 2-hour TTL cache with SHA256-based cache keys
45+
- Cache cleanup runs daily, removes files older than 5 days
46+
- Turn API calls are made for each PR to determine blocking status
47+
48+
4. **UI Management** (`ui.go`)
49+
- System tray integration via energye/systray
50+
- Menu structure: Incoming PRs → Outgoing PRs → Settings → About
51+
- Click handlers open PRs in browser with URL validation
52+
- "Hide stale PRs" option filters PRs older than 90 days
53+
54+
5. **Platform-Specific Features**
55+
- `loginitem_darwin.go`: macOS "Start at Login" functionality via AppleScript
56+
- `loginitem_other.go`: Stub for non-macOS platforms
57+
58+
### Key Design Decisions
59+
60+
- **No Context in Structs**: Context is always passed as a parameter, never stored
61+
- **Graceful Degradation**: Turn API failures don't break the app, PRs still display
62+
- **Security**: Only HTTPS URLs allowed, whitelist of github.com and dash.ready-to-review.dev
63+
- **Minimal Dependencies**: Uses standard library where possible
64+
- **Proper Cancellation**: All goroutines respect context cancellation
65+
66+
### Linting Configuration
67+
68+
The project uses an extremely strict golangci-lint configuration (`.golangci.yml`) that enforces:
69+
- All available linters except those that conflict with Go best practices
70+
- No nolint directives without explanations
71+
- Cognitive complexity limit of 55
72+
- No magic numbers (must use constants)
73+
- Proper error handling (no unchecked errors)
74+
- No naked returns except in very short functions
75+
- Field alignment optimization for structs
76+
77+
### Special Considerations
78+
79+
1. **Authentication**: Uses GitHub CLI token, never stores it persistently
80+
2. **Caching**: Turn API responses cached to reduce API calls
81+
3. **Menu Updates**: Hash-based change detection prevents unnecessary UI updates
82+
4. **Context Handling**: Single context from main, proper cancellation in all goroutines
83+
5. **Error Handling**: All errors wrapped with context using `fmt.Errorf` with `%w`
84+
85+
When making changes:
86+
- Run `make lint` and fix all issues without adding nolint directives
87+
- Follow the strict Go style guidelines in ~/.claude/CLAUDE.md
88+
- Keep functions simple and focused
89+
- Test macOS-specific features carefully (login items, app bundle)

README.md

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ You can also visit the web-based equivalent at https://dash.ready-to-review.dev/
2424

2525
## macOS Quick Start ⚡ (How to Get Honked At)
2626

27+
### Option 1: Using GitHub CLI (Default)
28+
2729
Install dependencies: the [GitHub CLI, aka "gh"](https://cli.github.com/) and [Go](https://go.dev/):
2830

2931
```bash
@@ -38,6 +40,34 @@ git clone https://github.com/ready-to-review/goose.git
3840
cd goose && make run
3941
```
4042

43+
### Option 2: Using a GitHub Token (More Control)
44+
45+
If you want more control over which repositories the goose can access, you can use a GitHub personal access token instead:
46+
47+
For maximum security, use a [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new):
48+
49+
1. Go to GitHub Settings → Developer settings → Personal access tokens → Fine-grained tokens
50+
2. Create a new token with:
51+
- **Expiration**: Set a short expiration (30-90 days recommended)
52+
- **Repository access**: Select only the specific repositories you want to monitor
53+
- **Permissions**:
54+
- Pull requests: Read
55+
- Metadata: Read
56+
3. Copy the token (starts with `github_pat_`)
57+
58+
If you need broader access, you can use a [classic token](https://github.com/settings/tokens):
59+
- Create with `repo` scope (grants full repository access - use with caution)
60+
61+
#### Using the Token
62+
63+
```bash
64+
export GITHUB_TOKEN=your_token_here
65+
git clone https://github.com/ready-to-review/goose.git
66+
cd goose && make run
67+
```
68+
69+
When `GITHUB_TOKEN` is set, the goose will use it directly instead of the GitHub CLI, giving you precise control over repository access. Fine-grained tokens are strongly recommended for better security.
70+
4171
## Known Issues
4272

4373
- Blocking logic isn't 100% accurate (we're working on it)
@@ -53,10 +83,12 @@ This tool is part of the [CodeGroove](https://codegroove.dev) developer accelera
5383

5484
## Privacy
5585

56-
Your GitHub token used to fetch PR metadata but we never store it anywhere. GitHub data is retained strictly for caching purposes with a 20-day maximum TTL.
86+
- Your GitHub token is used to fetch PR metadata, but is never stored or logged.
87+
- We won't sell your information or use it for any purpose other than caching.
88+
- GitHub metadata for open pull requests may be cached for up to 20 days for performance reasons.
5789

5890
---
5991

60-
Built with ❤️ and mild sleep deprivation by [CodeGroove](https://codegroove.dev/products/)
92+
Built with ❤️ by [CodeGroove](https://codegroove.dev/products/)
6193

6294
[Contribute](https://github.com/ready-to-review/goose) (PRs welcome, but the goose will judge you)

cmd/goose/cache.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ type cacheEntry struct {
2525

2626
// turnData fetches Turn API data with caching.
2727
func (app *App) turnData(ctx context.Context, url string, updatedAt time.Time) (*turn.CheckResponse, bool, error) {
28+
// Validate URL before processing
29+
if err := validateURL(url); err != nil {
30+
return nil, false, fmt.Errorf("invalid URL: %w", err)
31+
}
32+
2833
// Create cache key from URL and updated timestamp
2934
key := fmt.Sprintf("%s-%s", url, updatedAt.Format(time.RFC3339))
3035
hash := sha256.Sum256([]byte(key))
@@ -59,7 +64,7 @@ func (app *App) turnData(ctx context.Context, url string, updatedAt time.Time) (
5964
var data *turn.CheckResponse
6065
err := retry.Do(func() error {
6166
// Create timeout context for Turn API call
62-
turnCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
67+
turnCtx, cancel := context.WithTimeout(ctx, turnAPITimeout)
6368
defer cancel()
6469

6570
var retryErr error
@@ -93,11 +98,11 @@ func (app *App) turnData(ctx context.Context, url string, updatedAt time.Time) (
9398
if cacheData, marshalErr := json.Marshal(entry); marshalErr != nil {
9499
log.Printf("Failed to marshal cache data for %s: %v", url, marshalErr)
95100
} else {
96-
// Ensure cache directory exists
101+
// Ensure cache directory exists with secure permissions
97102
if dirErr := os.MkdirAll(filepath.Dir(cacheFile), 0o700); dirErr != nil {
98103
log.Printf("Failed to create cache directory: %v", dirErr)
99104
} else if writeErr := os.WriteFile(cacheFile, cacheData, 0o600); writeErr != nil {
100-
log.Printf("Failed to write cache file for %s: %v", url, writeErr)
105+
log.Printf("Failed to write cache file: %v", writeErr)
101106
}
102107
}
103108
}

cmd/goose/github.go

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"os"
1010
"os/exec"
1111
"path/filepath"
12-
"regexp"
1312
"runtime"
1413
"strings"
1514
"sync"
@@ -21,9 +20,6 @@ import (
2120
"golang.org/x/oauth2"
2221
)
2322

24-
// githubTokenRegex matches valid GitHub token formats.
25-
var githubTokenRegex = regexp.MustCompile(`^(ghp_[a-zA-Z0-9]{36}|gho_[a-zA-Z0-9]{36}|github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59})$`)
26-
2723
// initClients initializes GitHub and Turn API clients.
2824
func (app *App) initClients(ctx context.Context) error {
2925
token, err := app.token(ctx)
@@ -63,7 +59,6 @@ func (*App) token(ctx context.Context) (string, error) {
6359
log.Println("Using GitHub token from GITHUB_TOKEN environment variable")
6460
return token, nil
6561
}
66-
6762
// Only check absolute paths for security - never use PATH
6863
var trustedPaths []string
6964
switch runtime.GOOS {
@@ -103,8 +98,11 @@ func (*App) token(ctx context.Context) (string, error) {
10398
const executableMask = 0o111
10499
if info.Mode().IsRegular() && info.Mode()&executableMask != 0 {
105100
// Verify it's actually the gh binary by running version command
106-
cmd := exec.Command(path, "version") //nolint:noctx // Quick version check doesn't need context
101+
// Use timeout to prevent hanging
102+
versionCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
103+
cmd := exec.CommandContext(versionCtx, path, "version")
107104
output, err := cmd.Output()
105+
cancel() // Call cancel immediately after command execution
108106
if err == nil && strings.Contains(string(output), "gh version") {
109107
log.Printf("Found and verified gh at: %s", path)
110108
ghPath = path
@@ -115,25 +113,21 @@ func (*App) token(ctx context.Context) (string, error) {
115113
}
116114

117115
if ghPath == "" {
118-
return "", errors.New("gh cli not found in trusted locations")
116+
return "", errors.New("gh cli not found in trusted locations and GITHUB_TOKEN not set")
119117
}
120118

121119
log.Printf("Executing command: %s auth token", ghPath)
122120
cmd := exec.CommandContext(ctx, ghPath, "auth", "token")
123121
output, err := cmd.CombinedOutput()
124122
if err != nil {
125-
log.Printf("gh command failed with output: %s", string(output))
126-
return "", fmt.Errorf("exec 'gh auth token': %w (output: %s)", err, string(output))
123+
log.Printf("gh command failed: %v", err)
124+
return "", fmt.Errorf("exec 'gh auth token': %w", err)
127125
}
128126
token := strings.TrimSpace(string(output))
129-
if token == "" {
130-
return "", errors.New("empty github token")
127+
if err := validateGitHubToken(token); err != nil {
128+
return "", fmt.Errorf("invalid token from gh CLI: %w", err)
131129
}
132-
const minTokenLength = 20
133-
if len(token) < minTokenLength {
134-
return "", fmt.Errorf("invalid github token length: %d", len(token))
135-
}
136-
log.Println("Successfully obtained GitHub token")
130+
log.Println("Successfully obtained GitHub token from gh CLI")
137131
return token, nil
138132
}
139133

@@ -400,7 +394,10 @@ func (app *App) fetchTurnDataSync(ctx context.Context, issues []*github.Issue, u
400394
// Use a WaitGroup to track goroutines
401395
var wg sync.WaitGroup
402396

403-
// Process PRs in parallel
397+
// Create semaphore to limit concurrent Turn API calls
398+
sem := make(chan struct{}, maxConcurrentTurnAPICalls)
399+
400+
// Process PRs in parallel with concurrency limit
404401
for _, issue := range issues {
405402
if !issue.IsPullRequest() {
406403
continue
@@ -410,6 +407,10 @@ func (app *App) fetchTurnDataSync(ctx context.Context, issues []*github.Issue, u
410407
go func(issue *github.Issue) {
411408
defer wg.Done()
412409

410+
// Acquire semaphore
411+
sem <- struct{}{}
412+
defer func() { <-sem }()
413+
413414
url := issue.GetHTMLURL()
414415
updatedAt := issue.GetUpdatedAt().Time
415416

@@ -490,7 +491,10 @@ func (app *App) fetchTurnDataAsync(ctx context.Context, issues []*github.Issue,
490491
// Use a WaitGroup to track goroutines
491492
var wg sync.WaitGroup
492493

493-
// Process PRs in parallel
494+
// Create semaphore to limit concurrent Turn API calls
495+
sem := make(chan struct{}, maxConcurrentTurnAPICalls)
496+
497+
// Process PRs in parallel with concurrency limit
494498
for _, issue := range issues {
495499
if !issue.IsPullRequest() {
496500
continue
@@ -500,6 +504,10 @@ func (app *App) fetchTurnDataAsync(ctx context.Context, issues []*github.Issue,
500504
go func(issue *github.Issue) {
501505
defer wg.Done()
502506

507+
// Acquire semaphore
508+
sem <- struct{}{}
509+
defer func() { <-sem }()
510+
503511
url := issue.GetHTMLURL()
504512
updatedAt := issue.GetUpdatedAt().Time
505513

cmd/goose/main.go

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,19 @@ const (
4848
// Retry settings for external API calls - exponential backoff with jitter up to 2 minutes.
4949
maxRetryDelay = 2 * time.Minute
5050
maxRetries = 10 // Should reach 2 minutes with exponential backoff
51+
52+
// Failure thresholds.
53+
minorFailureThreshold = 3
54+
majorFailureThreshold = 10
55+
panicFailureIncrement = 10
56+
57+
// Notification settings.
58+
reminderInterval = 24 * time.Hour
59+
historyRetentionDays = 30
60+
61+
// Turn API settings.
62+
turnAPITimeout = 10 * time.Second
63+
maxConcurrentTurnAPICalls = 10
5164
)
5265

5366
// PR represents a pull request with metadata.
@@ -131,6 +144,13 @@ func main() {
131144
flag.DurationVar(&updateInterval, "interval", defaultUpdateInterval, "Update interval (e.g. 30s, 1m, 5m)")
132145
flag.Parse()
133146

147+
// Validate target user if provided
148+
if targetUser != "" {
149+
if err := validateGitHubUsername(targetUser); err != nil {
150+
log.Fatalf("Invalid target user: %v", err)
151+
}
152+
}
153+
134154
// Validate update interval
135155
if updateInterval < minUpdateInterval {
136156
log.Printf("Update interval %v too short, using minimum of %v", updateInterval, minUpdateInterval)
@@ -198,9 +218,9 @@ func main() {
198218
}
199219
app.currentUser = user
200220

201-
// Log if we're using a different target user
221+
// Log if we're using a different target user (sanitized)
202222
if app.targetUser != "" && app.targetUser != user.GetLogin() {
203-
log.Printf("Querying PRs for user '%s' instead of authenticated user '%s'", app.targetUser, user.GetLogin())
223+
log.Printf("Querying PRs for user '%s' instead of authenticated user", sanitizeForLog(app.targetUser))
204224
}
205225

206226
log.Println("Starting systray...")
@@ -264,7 +284,7 @@ func (app *App) updateLoop(ctx context.Context) {
264284

265285
// Update failure count
266286
app.mu.Lock()
267-
app.consecutiveFailures += 10 // Treat panic as critical failure
287+
app.consecutiveFailures += panicFailureIncrement // Treat panic as critical failure
268288
app.mu.Unlock()
269289

270290
// Signal app to quit after panic
@@ -302,10 +322,6 @@ func (app *App) updatePRs(ctx context.Context) {
302322
app.mu.Unlock()
303323

304324
// Progressive degradation based on failure count
305-
const (
306-
minorFailureThreshold = 3
307-
majorFailureThreshold = 10
308-
)
309325
var title, tooltip string
310326
switch {
311327
case failureCount == 1:
@@ -470,10 +486,6 @@ func (app *App) updatePRsWithWait(ctx context.Context) {
470486
app.mu.Unlock()
471487

472488
// Progressive degradation based on failure count
473-
const (
474-
minorFailureThreshold = 3
475-
majorFailureThreshold = 10
476-
)
477489
var title, tooltip string
478490
switch {
479491
case failureCount == 1:
@@ -658,8 +670,7 @@ func (app *App) checkForNewlyBlockedPRs(ctx context.Context) {
658670
now := time.Now()
659671
staleThreshold := now.Add(-stalePRThreshold)
660672

661-
// Reminder interval for re-notifications (24 hours)
662-
const reminderInterval = 24 * time.Hour
673+
// Use reminder interval constant from package level
663674

664675
currentBlockedPRs := make(map[string]bool)
665676
playedIncomingSound := false

0 commit comments

Comments
 (0)