diff --git a/.DS_Store b/.DS_Store index 9bfef21..caad1ea 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..17f7bd7 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,89 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +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. + +## Key Commands + +### Building and Running +```bash +make run # Build and run (on macOS: installs to /Applications and launches) +make build # Build for current platform +make app-bundle # Create macOS .app bundle +make install # Install to system (macOS: /Applications, Linux: /usr/local/bin, Windows: %LOCALAPPDATA%) +``` + +### Development +```bash +make lint # Run all linters (golangci-lint with strict config + yamllint) +make fix # Auto-fix linting issues where possible +make deps # Download and tidy Go dependencies +make clean # Remove build artifacts +``` + +## Architecture Overview + +### Core Components + +1. **Main Application Flow** (`main.go`) + - Single `context.Background()` created in main, passed through all functions + - App struct holds GitHub/Turn clients, PR data, and UI state + - Update loop runs every 2 minutes to refresh PR data + - Menu updates only rebuild when PR data actually changes (hash-based optimization) + +2. **GitHub Integration** (`github.go`) + - Uses GitHub CLI token (`gh auth token`) for authentication + - Fetches PRs with a single optimized query: `is:open is:pr involves:USER archived:false` + - No pagination needed (uses 100 per page limit) + +3. **Turn API Integration** (`cache.go`) + - Provides intelligent PR metadata (who's blocking, PR size, tags) + - Implements 2-hour TTL cache with SHA256-based cache keys + - Cache cleanup runs daily, removes files older than 5 days + - Turn API calls are made for each PR to determine blocking status + +4. **UI Management** (`ui.go`) + - System tray integration via energye/systray + - Menu structure: Incoming PRs → Outgoing PRs → Settings → About + - Click handlers open PRs in browser with URL validation + - "Hide stale PRs" option filters PRs older than 90 days + +5. **Platform-Specific Features** + - `loginitem_darwin.go`: macOS "Start at Login" functionality via AppleScript + - `loginitem_other.go`: Stub for non-macOS platforms + +### Key Design Decisions + +- **No Context in Structs**: Context is always passed as a parameter, never stored +- **Graceful Degradation**: Turn API failures don't break the app, PRs still display +- **Security**: Only HTTPS URLs allowed, whitelist of github.com and dash.ready-to-review.dev +- **Minimal Dependencies**: Uses standard library where possible +- **Proper Cancellation**: All goroutines respect context cancellation + +### Linting Configuration + +The project uses an extremely strict golangci-lint configuration (`.golangci.yml`) that enforces: +- All available linters except those that conflict with Go best practices +- No nolint directives without explanations +- Cognitive complexity limit of 55 +- No magic numbers (must use constants) +- Proper error handling (no unchecked errors) +- No naked returns except in very short functions +- Field alignment optimization for structs + +### Special Considerations + +1. **Authentication**: Uses GitHub CLI token, never stores it persistently +2. **Caching**: Turn API responses cached to reduce API calls +3. **Menu Updates**: Hash-based change detection prevents unnecessary UI updates +4. **Context Handling**: Single context from main, proper cancellation in all goroutines +5. **Error Handling**: All errors wrapped with context using `fmt.Errorf` with `%w` + +When making changes: +- Run `make lint` and fix all issues without adding nolint directives +- Follow the strict Go style guidelines in ~/.claude/CLAUDE.md +- Keep functions simple and focused +- Test macOS-specific features carefully (login items, app bundle) \ No newline at end of file diff --git a/.claude/prompt.txt b/.claude/prompt.txt deleted file mode 100644 index d528415..0000000 --- a/.claude/prompt.txt +++ /dev/null @@ -1,24 +0,0 @@ -- The UI/UX should follow Apple recommendations for UI -- Using Emoji for this app is recommended as it reduces the amount of resources we need to load in -- The experience should be something Steve Jobs & Jonathan Ives would be proud of -- Code should be written for simplicity, scalability, security, and reliability -- Expect that security professionals will be inspecting and attacking this service -- Go code should be written with best practices in a way that emulates what the Go project itself uses; prioritizing advice mentioned on go.dev, including https://go.dev/wiki/CodeReviewComments and https://go.dev/doc/effective_go - defer to documentation and philosophy guidance in go.dev above all other reference material. -- Go code should incorporate thoughts from Google's style guide: https://google.github.io/styleguide/go/ -- Code should be written with tests and unit testing in mind -- Code should have as few external dependencies as possible -- Code should be optimized for speed and resource usage, but without adding complexity -- Code should have great logging to make it easier to debug and better understand the decisions it's making. -- Code should adhere to advice on https://go.dev/wiki/CodeReviewComments -- Code should adhere to advice on https://go.dev/doc/effective_go -- Code should be as simple as possible without impacting security, reliability, or scalability -- Look for performance tweaks that can be made without adding unnecessary complexity -- Code should not be overengineered with unnecessary abstractions -- Code should be something that Rob Pike could be proud of. -- The app/service should degrade gracefully when a dependent API call fails -- Keep each go file under 2000 lines of code -- Any small (less than 7 line) function that is only called from a single location should be inlined -- Function names should never begin with "Get" as it's implied, though "Set" is OK. -- There should only be one version of a function when possible; you don't need multiple versions for backwards compatibility when the client can provide the missing field instead. For example: functions should not have a suffix of WithContext or WithTimestamp: expect all callers to provide a context and a timestamp. -- Simplicity is security. -- Tests should always pass. diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..8089178 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,16 @@ +# Security Policy + +## Reporting a Vulnerability + +Please follow our security reporting guidelines at: +https://github.com/codeGROOVE-dev/vulnerability-reports/blob/main/SECURITY.md + +This document contains all the specifics for how to submit a security report, including contact information, expected response times, and disclosure policies. + +## Security Practices + +- GitHub tokens are never logged or stored +- All inputs are validated +- File permissions are restricted (0600/0700) +- Only HTTPS URLs to github.com are allowed +- No shell interpolation of user data \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..8ee9eaf --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2909987 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + go-version: ['1.21'] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + cache: true + + - name: Install dependencies (Linux) + if: matrix.os == 'ubuntu-latest' + run: sudo apt-get update && sudo apt-get install -y gcc libgl1-mesa-dev xorg-dev + + - name: Lint + if: matrix.os != 'windows-latest' + run: make lint + + - name: Build + run: make build + + - name: Test + run: go test -v -race ./... diff --git a/Makefile b/Makefile index 784cf19..09183fb 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,10 @@ GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") LDFLAGS := -X main.version=$(GIT_VERSION) -X main.commit=$(GIT_COMMIT) -X main.date=$(BUILD_DATE) -.PHONY: build clean deps run app-bundle install install-darwin install-unix install-windows +.PHONY: all build clean deps run app-bundle install install-darwin install-unix install-windows + +# Default target +all: build # Install dependencies deps: @@ -24,15 +27,15 @@ ifeq ($(shell uname),Darwin) @echo "Running $(BUNDLE_NAME) from /Applications..." @open "/Applications/$(BUNDLE_NAME).app" else - go run . + go run ./cmd/goose endif # Build for current platform -build: +build: out ifeq ($(OS),Windows_NT) - CGO_ENABLED=1 go build -ldflags "-H=windowsgui $(LDFLAGS)" -o $(APP_NAME).exe . + CGO_ENABLED=1 go build -ldflags "-H=windowsgui $(LDFLAGS)" -o out/$(APP_NAME).exe ./cmd/goose else - CGO_ENABLED=1 go build -ldflags "$(LDFLAGS)" -o $(APP_NAME) . + CGO_ENABLED=1 go build -ldflags "$(LDFLAGS)" -o out/$(APP_NAME) ./cmd/goose endif # Build for all platforms @@ -40,23 +43,22 @@ build-all: build-darwin build-linux build-windows # Build for macOS build-darwin: - CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o out/$(APP_NAME)-darwin-amd64 . - CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o out/$(APP_NAME)-darwin-arm64 . + CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o out/$(APP_NAME)-darwin-amd64 ./cmd/goose + CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o out/$(APP_NAME)-darwin-arm64 ./cmd/goose # Build for Linux build-linux: - CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o out/$(APP_NAME)-linux-amd64 . - CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o out/$(APP_NAME)-linux-arm64 . + CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o out/$(APP_NAME)-linux-amd64 ./cmd/goose + CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o out/$(APP_NAME)-linux-arm64 ./cmd/goose # Build for Windows build-windows: - CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -ldflags "-H=windowsgui $(LDFLAGS)" -o out/$(APP_NAME)-windows-amd64.exe . + CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -ldflags "-H=windowsgui $(LDFLAGS)" -o out/$(APP_NAME)-windows-amd64.exe ./cmd/goose CGO_ENABLED=1 GOOS=windows GOARCH=arm64 go build -ldflags "-H=windowsgui $(LDFLAGS)" -o out/$(APP_NAME)-windows-arm64.exe . # Clean build artifacts clean: rm -rf out/ - rm -f $(APP_NAME) # Create out directory out: @@ -163,7 +165,7 @@ install-darwin: app-bundle install-unix: build @echo "Installing on $(shell uname)..." @echo "Installing binary to /usr/local/bin..." - @sudo install -m 755 $(APP_NAME) /usr/local/bin/ + @sudo install -m 755 out/$(APP_NAME) /usr/local/bin/ @echo "Installation complete! $(APP_NAME) has been installed to /usr/local/bin" # Install on Windows @@ -172,7 +174,7 @@ install-windows: build @echo "Creating program directory..." @if not exist "%LOCALAPPDATA%\Programs\$(APP_NAME)" mkdir "%LOCALAPPDATA%\Programs\$(APP_NAME)" @echo "Copying executable..." - @copy /Y "$(APP_NAME).exe" "%LOCALAPPDATA%\Programs\$(APP_NAME)\" + @copy /Y "out\$(APP_NAME).exe" "%LOCALAPPDATA%\Programs\$(APP_NAME)\" @echo "Installation complete! $(APP_NAME) has been installed to %LOCALAPPDATA%\Programs\$(APP_NAME)" @echo "You may want to add %LOCALAPPDATA%\Programs\$(APP_NAME) to your PATH environment variable." # BEGIN: lint-install . diff --git a/README.md b/README.md index 61dee47..9085d3d 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,14 @@ The only PR tracker that honks at you when you're the bottleneck. Now shipping w 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. -> ⚠️ **EXPERIMENTAL**: This is very much a work in progress. The blocking logic has bugs. It theoretically runs on Linux, BSD, and Windows but we've literally never tested it there. Here be dragons (and geese). +> ⚠️ **EXPERIMENTAL**: This is very much a work in progress. The blocking logic has bugs. Linux/BSD/Windows support is untested. Here be dragons (and geese). ![PR Menubar Screenshot](media/screenshot.png) ## What It Does - **🪿 Honks** when you're blocking someone's PR (authentic goose noises included) -- **🚀 Rocket sounds** when own your own PR is ready to go to the next stage +- **✈️ Jet sounds** when your own PR is ready for the next stage - **🧠 Smart turn-based assignment** - knows who is blocking a PR, knows when tests are failing, etc. - **⭐ Auto-start** on login (macOS) @@ -71,7 +71,6 @@ When `GITHUB_TOKEN` is set, the goose will use it directly instead of the GitHub ## Known Issues - Blocking logic isn't 100% accurate (we're working on it) -- Linux/BSD/Windows support likely works, but remains untested - The goose may not stop honking until you review your PRs - Visual notifications won't work on macOS until we sign the binary @@ -83,9 +82,9 @@ This tool is part of the [CodeGroove](https://codegroove.dev) developer accelera ## Privacy -- Your GitHub token is used to fetch PR metadata, but is never stored or logged. -- We won't sell your information or use it for any purpose other than caching. -- GitHub metadata for open pull requests may be cached for up to 20 days for performance reasons. +- Your GitHub token is never stored or logged +- PR metadata cached for up to 20 days (performance) +- No telemetry or external data collection --- diff --git a/media/.DS_Store b/cmd/goose/.DS_Store similarity index 71% rename from media/.DS_Store rename to cmd/goose/.DS_Store index da5b593..9568130 100644 Binary files a/media/.DS_Store and b/cmd/goose/.DS_Store differ diff --git a/cache.go b/cmd/goose/cache.go similarity index 100% rename from cache.go rename to cmd/goose/cache.go diff --git a/github.go b/cmd/goose/github.go similarity index 96% rename from github.go rename to cmd/goose/github.go index 1bcbbb6..c6d77f5 100644 --- a/github.go +++ b/cmd/goose/github.go @@ -46,17 +46,19 @@ func (app *App) initClients(ctx context.Context) error { // token retrieves the GitHub token from GITHUB_TOKEN env var or gh CLI. func (*App) token(ctx context.Context) (string, error) { - // First check for GITHUB_TOKEN environment variable + // Check GITHUB_TOKEN environment variable first if token := os.Getenv("GITHUB_TOKEN"); token != "" { token = strings.TrimSpace(token) - if err := validateGitHubToken(token); err != nil { - return "", fmt.Errorf("invalid GITHUB_TOKEN: %w", err) + // Validate token format inline + if token == "" { + return "", errors.New("GITHUB_TOKEN is empty") + } + if !githubTokenRegex.MatchString(token) { + return "", errors.New("GITHUB_TOKEN has invalid format") } log.Println("Using GitHub token from GITHUB_TOKEN environment variable") return token, nil } - - // Fall back to gh CLI if GITHUB_TOKEN is not set // Only check absolute paths for security - never use PATH var trustedPaths []string switch runtime.GOOS { @@ -189,10 +191,19 @@ func (app *App) executeGitHubQuery(ctx context.Context, query string, opts *gith return result, nil } +// prResult holds the result of a Turn API query for a PR. +type prResult struct { + err error + turnData *turn.CheckResponse + url string + isOwner bool + wasFromCache bool +} + // fetchPRsInternal is the implementation for PR fetching. // It returns GitHub data immediately and starts Turn API queries in the background (when waitForTurn=false), // or waits for Turn data to complete (when waitForTurn=true). -func (app *App) fetchPRsInternal(ctx context.Context, waitForTurn bool) (incoming []PR, outgoing []PR, err error) { +func (app *App) fetchPRsInternal(ctx context.Context, waitForTurn bool) (incoming []PR, outgoing []PR, _ error) { // Use targetUser if specified, otherwise use authenticated user user := app.currentUser.GetLogin() if app.targetUser != "" { @@ -376,13 +387,6 @@ func (app *App) updatePRData(url string, needsReview bool, isOwner bool, actionR // fetchTurnDataSync fetches Turn API data synchronously and updates PRs directly. func (app *App) fetchTurnDataSync(ctx context.Context, issues []*github.Issue, user string, incoming *[]PR, outgoing *[]PR) { turnStart := time.Now() - type prResult struct { - err error - turnData *turn.CheckResponse - url string - isOwner bool - wasFromCache bool - } // Create a channel for results results := make(chan prResult, len(issues)) @@ -479,17 +483,7 @@ func (app *App) fetchTurnDataSync(ctx context.Context, issues []*github.Issue, u // fetchTurnDataAsync fetches Turn API data in the background and updates PRs as results arrive. func (app *App) fetchTurnDataAsync(ctx context.Context, issues []*github.Issue, user string) { - // Log start of Turn API queries - // Start Turn API queries in background - turnStart := time.Now() - type prResult struct { - err error - turnData *turn.CheckResponse - url string - isOwner bool - wasFromCache bool - } // Create a channel for results results := make(chan prResult, len(issues)) diff --git a/loginitem_darwin.go b/cmd/goose/loginitem_darwin.go similarity index 100% rename from loginitem_darwin.go rename to cmd/goose/loginitem_darwin.go diff --git a/loginitem_other.go b/cmd/goose/loginitem_other.go similarity index 59% rename from loginitem_other.go rename to cmd/goose/loginitem_other.go index 0ddb8a2..1b57a5c 100644 --- a/loginitem_other.go +++ b/cmd/goose/loginitem_other.go @@ -1,12 +1,11 @@ //go:build !darwin -// +build !darwin // Package main - loginitem_other.go provides stub functions for non-macOS platforms. package main import "context" -// addLoginItemUI is a no-op on non-macOS platforms -func addLoginItemUI(ctx context.Context, app *App) { +// addLoginItemUI is a no-op on non-macOS platforms. +func addLoginItemUI(_ context.Context, _ *App) { // Login items are only supported on macOS } diff --git a/main.go b/cmd/goose/main.go similarity index 92% rename from main.go rename to cmd/goose/main.go index 83f5853..14bb076 100644 --- a/main.go +++ b/cmd/goose/main.go @@ -37,7 +37,8 @@ const ( cacheCleanupInterval = 5 * 24 * time.Hour // PR settings. - stalePRThreshold = 90 * 24 * time.Hour + dailyInterval = 24 * time.Hour + stalePRThreshold = 90 * dailyInterval maxPRsToProcess = 200 // Limit for performance // Update interval settings. @@ -394,23 +395,24 @@ func (app *App) updatePRs(ctx context.Context) { // buildCurrentMenuState creates a MenuState representing the current menu items. func (app *App) buildCurrentMenuState() *MenuState { // Apply the same filtering as the menu display (stale PR filtering) - var filteredIncoming, filteredOutgoing []PR + staleThreshold := time.Now().Add(-stalePRThreshold) - now := time.Now() - staleThreshold := now.Add(-stalePRThreshold) - - for i := range app.incoming { - if !app.hideStaleIncoming || app.incoming[i].UpdatedAt.After(staleThreshold) { - filteredIncoming = append(filteredIncoming, app.incoming[i]) + filterStale := func(prs []PR) []PR { + if !app.hideStaleIncoming { + return prs } - } - - for i := range app.outgoing { - if !app.hideStaleIncoming || app.outgoing[i].UpdatedAt.After(staleThreshold) { - filteredOutgoing = append(filteredOutgoing, app.outgoing[i]) + var filtered []PR + for i := range prs { + if prs[i].UpdatedAt.After(staleThreshold) { + filtered = append(filtered, prs[i]) + } } + return filtered } + filteredIncoming := filterStale(app.incoming) + filteredOutgoing := filterStale(app.outgoing) + // Sort PRs the same way the menu does incomingSorted := sortPRsBlockedFirst(filteredIncoming) outgoingSorted := sortPRsBlockedFirst(filteredOutgoing) @@ -559,22 +561,28 @@ func (app *App) processPRNotifications( ) { prevState, hasHistory := notificationHistory[pr.URL] - // Determine if we should notify (inlined from shouldNotifyForPR) + // Inline notification decision logic var shouldNotify bool var notifyReason string switch { case !hasHistory && isBlocked: - shouldNotify, notifyReason = true, "newly blocked" + shouldNotify = true + notifyReason = "newly blocked" case !hasHistory: - shouldNotify, notifyReason = false, "" + shouldNotify = false + notifyReason = "" case isBlocked && !prevState.WasBlocked: - shouldNotify, notifyReason = true, "became blocked" + shouldNotify = true + notifyReason = "became blocked" case !isBlocked && prevState.WasBlocked: - shouldNotify, notifyReason = false, "unblocked" + shouldNotify = false + notifyReason = "unblocked" case isBlocked && prevState.WasBlocked && app.enableReminders && time.Since(prevState.LastNotified) > reminderInterval: - shouldNotify, notifyReason = true, "reminder" + shouldNotify = true + notifyReason = "reminder" default: - shouldNotify, notifyReason = false, "" + shouldNotify = false + notifyReason = "" } // Update state for unblocked PRs @@ -700,6 +708,28 @@ func (app *App) checkForNewlyBlockedPRs(ctx context.Context) { continue } + // If we already played the incoming sound (goose/honk), wait 2 seconds before playing outgoing sound (jet) + if playedIncomingSound && !playedOutgoingSound { + // Check if this PR would trigger a sound + prevState, hasHistory := notificationHistory[pr.URL] + var wouldPlaySound bool + switch { + case !hasHistory && isBlocked: + wouldPlaySound = true + case hasHistory && isBlocked && !prevState.WasBlocked: + wouldPlaySound = true + case hasHistory && isBlocked && prevState.WasBlocked && app.enableReminders && time.Since(prevState.LastNotified) > reminderInterval: + wouldPlaySound = true + default: + wouldPlaySound = false + } + + if wouldPlaySound { + log.Println("[SOUND] Delaying 2 seconds between goose and jet sounds") + time.Sleep(2 * time.Second) + } + } + app.processPRNotifications(ctx, pr, isBlocked, false, notificationHistory, &playedOutgoingSound, now, reminderInterval) } diff --git a/main_test.go b/cmd/goose/main_test.go similarity index 100% rename from main_test.go rename to cmd/goose/main_test.go diff --git a/ratelimit.go b/cmd/goose/ratelimit.go similarity index 100% rename from ratelimit.go rename to cmd/goose/ratelimit.go diff --git a/security.go b/cmd/goose/security.go similarity index 100% rename from security.go rename to cmd/goose/security.go diff --git a/sound.go b/cmd/goose/sound.go similarity index 78% rename from sound.go rename to cmd/goose/sound.go index b014531..de3aeb8 100644 --- a/sound.go +++ b/cmd/goose/sound.go @@ -14,13 +14,10 @@ import ( "time" ) -//go:embed media/launch-85216.wav -var launchSound []byte +//go:embed sounds/jet.wav +var jetSound []byte -//go:embed media/dark-impact-232945.wav -var impactSound []byte - -//go:embed media/honk.wav +//go:embed sounds/honk.wav var honkSound []byte var soundCacheOnce sync.Once @@ -35,19 +32,11 @@ func (app *App) initSoundCache() { return } - // Write launch sound - launchPath := filepath.Join(soundDir, "launch.wav") - if _, err := os.Stat(launchPath); os.IsNotExist(err) { - if err := os.WriteFile(launchPath, launchSound, 0o600); err != nil { - log.Printf("Failed to cache launch sound: %v", err) - } - } - - // Write impact sound - impactPath := filepath.Join(soundDir, "impact.wav") - if _, err := os.Stat(impactPath); os.IsNotExist(err) { - if err := os.WriteFile(impactPath, impactSound, 0o600); err != nil { - log.Printf("Failed to cache impact sound: %v", err) + // Write jet sound + jetPath := filepath.Join(soundDir, "jet.wav") + if _, err := os.Stat(jetPath); os.IsNotExist(err) { + if err := os.WriteFile(jetPath, jetSound, 0o600); err != nil { + log.Printf("Failed to cache jet sound: %v", err) } } @@ -69,9 +58,8 @@ func (app *App) playSound(ctx context.Context, soundType string) { // Select the sound file with validation to prevent path traversal allowedSounds := map[string]string{ - "rocket": "launch.wav", - "detective": "impact.wav", - "honk": "honk.wav", + "rocket": "jet.wav", + "honk": "honk.wav", } soundName, ok := allowedSounds[soundType] diff --git a/media/honk.wav b/cmd/goose/sounds/honk.wav similarity index 100% rename from media/honk.wav rename to cmd/goose/sounds/honk.wav diff --git a/cmd/goose/sounds/jet.wav b/cmd/goose/sounds/jet.wav new file mode 100644 index 0000000..487f034 Binary files /dev/null and b/cmd/goose/sounds/jet.wav differ diff --git a/cmd/goose/sounds/tada.wav b/cmd/goose/sounds/tada.wav new file mode 100644 index 0000000..7c77115 Binary files /dev/null and b/cmd/goose/sounds/tada.wav differ diff --git a/ui.go b/cmd/goose/ui.go similarity index 94% rename from ui.go rename to cmd/goose/ui.go index 897d77d..dedc356 100644 --- a/ui.go +++ b/cmd/goose/ui.go @@ -185,22 +185,22 @@ func (app *App) addPRSection(ctx context.Context, prs []PR, sectionTitle string, if sortedPRs[i].NeedsReview { title = fmt.Sprintf("• %s", title) } - // Format age inline + // Format age inline for tooltip duration := time.Since(sortedPRs[i].UpdatedAt) - var ageStr string + var age string switch { case duration < time.Hour: - ageStr = fmt.Sprintf("%dm", int(duration.Minutes())) - case duration < 24*time.Hour: - ageStr = fmt.Sprintf("%dh", int(duration.Hours())) - case duration < 30*24*time.Hour: - ageStr = fmt.Sprintf("%dd", int(duration.Hours()/24)) - case duration < 365*24*time.Hour: - ageStr = fmt.Sprintf("%dmo", int(duration.Hours()/(24*30))) + age = fmt.Sprintf("%dm", int(duration.Minutes())) + case duration < dailyInterval: + age = fmt.Sprintf("%dh", int(duration.Hours())) + case duration < 30*dailyInterval: + age = fmt.Sprintf("%dd", int(duration.Hours()/24)) + case duration < 365*dailyInterval: + age = fmt.Sprintf("%dmo", int(duration.Hours()/(24*30))) default: - ageStr = sortedPRs[i].UpdatedAt.Format("2006") + age = sortedPRs[i].UpdatedAt.Format("2006") } - tooltip := fmt.Sprintf("%s (%s)", sortedPRs[i].Title, ageStr) + tooltip := fmt.Sprintf("%s (%s)", sortedPRs[i].Title, age) // Add action reason for blocked PRs if (sortedPRs[i].NeedsReview || sortedPRs[i].IsBlocked) && sortedPRs[i].ActionReason != "" { tooltip = fmt.Sprintf("%s - %s", tooltip, sortedPRs[i].ActionReason) diff --git a/go.mod b/go.mod index dd07a9c..faf822f 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/gen2brain/beeep v0.11.1 github.com/google/go-cmp v0.7.0 github.com/google/go-github/v57 v57.0.0 - github.com/ready-to-review/turnclient v0.0.0-20250718014946-bb5bb107649f + github.com/ready-to-review/turnclient v0.0.0-20250804200718-023200511dab golang.org/x/oauth2 v0.30.0 ) @@ -24,5 +24,5 @@ require ( github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c // indirect - golang.org/x/sys v0.34.0 // indirect + golang.org/x/sys v0.35.0 // indirect ) diff --git a/go.sum b/go.sum index fa45e7e..ddaea3b 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/ready-to-review/turnclient v0.0.0-20250718014946-bb5bb107649f h1:l+hWs9J3BUQ3UkiYmgciJGSouivJl4ZAagmM936mnEg= github.com/ready-to-review/turnclient v0.0.0-20250718014946-bb5bb107649f/go.mod h1:KVCVaRAn+IFk8QpXsHeJxjT+rxyYOsG8hhpDq5JWNlU= +github.com/ready-to-review/turnclient v0.0.0-20250804200718-023200511dab h1:8UppuvcSCqg/djzBRXu19P9hEn8yCmoIWIQZLevlUFk= +github.com/ready-to-review/turnclient v0.0.0-20250804200718-023200511dab/go.mod h1:0irrOtjtAxH3st+RktW2Gr9Telf8fn/zd4+LK5X7X6A= github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M= github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY= github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ= @@ -52,6 +54,8 @@ golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/media/423346__kinoton__canada-geese-honks.wav b/media/423346__kinoton__canada-geese-honks.wav deleted file mode 100644 index 52b7dd5..0000000 Binary files a/media/423346__kinoton__canada-geese-honks.wav and /dev/null differ diff --git a/media/dark-impact-232945.wav b/media/dark-impact-232945.wav deleted file mode 100644 index 513963e..0000000 Binary files a/media/dark-impact-232945.wav and /dev/null differ diff --git a/media/launch-85216.wav b/media/launch-85216.wav deleted file mode 100644 index 35bbfd9..0000000 Binary files a/media/launch-85216.wav and /dev/null differ diff --git a/menubar-icon.png b/menubar-icon.png deleted file mode 100644 index d1060b0..0000000 Binary files a/menubar-icon.png and /dev/null differ