├── cmd └── review-goose │ ├── VERSION │ ├── .DS_Store │ ├── icons │ ├── lock.png │ ├── goose.ico │ ├── goose.png │ ├── popper.ico │ ├── popper.png │ ├── cockroach.ico │ ├── cockroach.png │ ├── warning.ico │ ├── warning.png │ ├── smiling-face.ico │ └── smiling-face.png │ ├── sounds │ ├── jet.wav │ ├── honk.wav │ └── tada.wav │ ├── loginitem_other.go │ ├── icons_darwin.go │ ├── ratelimit.go │ ├── x11tray │ ├── tray_other.go │ └── tray_unix.go │ ├── icons.go │ ├── multihandler.go │ ├── icons_badge.go │ ├── systray_interface.go │ ├── settings.go │ ├── menu_item_interface.go │ ├── security.go │ ├── click_test.go │ ├── sound.go │ ├── browser_rate_limiter.go │ ├── notifications.go │ ├── reliability.go │ ├── deadlock_test.go │ ├── loginitem_darwin.go │ ├── security_test.go │ ├── menu_change_detection_test.go │ ├── pr_state.go │ ├── cache.go │ ├── pr_state_test.go │ ├── filtering_test.go │ └── sprinkler.go ├── media ├── logo.png ├── logo-small.png └── screenshot.png ├── .github ├── dependabot.yml ├── SECURITY.md └── workflows │ ├── golangci-lint.yml │ └── ci.yml ├── .yamllint ├── .gitignore ├── go.mod ├── pkg ├── icon │ ├── icon_test.go │ └── icon.go └── safebrowse │ ├── safebrowse.go │ └── safebrowse_test.go ├── README.md ├── go.sum ├── .golangci.yml └── Makefile /cmd/review-goose/VERSION: -------------------------------------------------------------------------------- 1 | v0.9.3 2 | -------------------------------------------------------------------------------- /media/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeGROOVE-dev/goose/HEAD/media/logo.png -------------------------------------------------------------------------------- /media/logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeGROOVE-dev/goose/HEAD/media/logo-small.png -------------------------------------------------------------------------------- /media/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeGROOVE-dev/goose/HEAD/media/screenshot.png -------------------------------------------------------------------------------- /cmd/review-goose/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeGROOVE-dev/goose/HEAD/cmd/review-goose/.DS_Store -------------------------------------------------------------------------------- /cmd/review-goose/icons/lock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeGROOVE-dev/goose/HEAD/cmd/review-goose/icons/lock.png -------------------------------------------------------------------------------- /cmd/review-goose/sounds/jet.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeGROOVE-dev/goose/HEAD/cmd/review-goose/sounds/jet.wav -------------------------------------------------------------------------------- /cmd/review-goose/icons/goose.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeGROOVE-dev/goose/HEAD/cmd/review-goose/icons/goose.ico -------------------------------------------------------------------------------- /cmd/review-goose/icons/goose.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeGROOVE-dev/goose/HEAD/cmd/review-goose/icons/goose.png -------------------------------------------------------------------------------- /cmd/review-goose/icons/popper.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeGROOVE-dev/goose/HEAD/cmd/review-goose/icons/popper.ico -------------------------------------------------------------------------------- /cmd/review-goose/icons/popper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeGROOVE-dev/goose/HEAD/cmd/review-goose/icons/popper.png -------------------------------------------------------------------------------- /cmd/review-goose/sounds/honk.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeGROOVE-dev/goose/HEAD/cmd/review-goose/sounds/honk.wav -------------------------------------------------------------------------------- /cmd/review-goose/sounds/tada.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeGROOVE-dev/goose/HEAD/cmd/review-goose/sounds/tada.wav -------------------------------------------------------------------------------- /cmd/review-goose/icons/cockroach.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeGROOVE-dev/goose/HEAD/cmd/review-goose/icons/cockroach.ico -------------------------------------------------------------------------------- /cmd/review-goose/icons/cockroach.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeGROOVE-dev/goose/HEAD/cmd/review-goose/icons/cockroach.png -------------------------------------------------------------------------------- /cmd/review-goose/icons/warning.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeGROOVE-dev/goose/HEAD/cmd/review-goose/icons/warning.ico -------------------------------------------------------------------------------- /cmd/review-goose/icons/warning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeGROOVE-dev/goose/HEAD/cmd/review-goose/icons/warning.png -------------------------------------------------------------------------------- /cmd/review-goose/icons/smiling-face.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeGROOVE-dev/goose/HEAD/cmd/review-goose/icons/smiling-face.ico -------------------------------------------------------------------------------- /cmd/review-goose/icons/smiling-face.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeGROOVE-dev/goose/HEAD/cmd/review-goose/icons/smiling-face.png -------------------------------------------------------------------------------- /cmd/review-goose/loginitem_other.go: -------------------------------------------------------------------------------- 1 | //go:build !darwin 2 | 3 | package main 4 | 5 | import "context" 6 | 7 | // addLoginItemUI is a no-op on non-macOS platforms. 8 | func addLoginItemUI(_ context.Context, _ *App) { 9 | // Login items are only supported on macOS 10 | } 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | open-pull-requests-limit: 5 8 | 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | open-pull-requests-limit: 5 14 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | extends: default 3 | 4 | rules: 5 | braces: 6 | max-spaces-inside: 1 7 | brackets: 8 | max-spaces-inside: 1 9 | comments: disable 10 | comments-indentation: disable 11 | document-start: disable 12 | line-length: 13 | level: warning 14 | max: 160 15 | allow-non-breakable-inline-mappings: true 16 | truthy: disable 17 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Please follow our security reporting guidelines at: 6 | https://github.com/codeGROOVE-dev/vulnerability-reports/blob/main/SECURITY.md 7 | 8 | This document contains all the specifics for how to submit a security report, including contact information, expected response times, and disclosure policies. 9 | 10 | ## Security Practices 11 | 12 | - GitHub tokens are never logged or stored 13 | - All inputs are validated 14 | - File permissions are restricted (0600/0700) 15 | - Only HTTPS URLs to github.com are allowed 16 | - No shell interpolation of user data -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - 'main' 7 | 8 | permissions: {} 9 | 10 | jobs: 11 | golangci-lint: 12 | runs-on: ubuntu-latest 13 | 14 | permissions: 15 | contents: read 16 | 17 | steps: 18 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 19 | with: 20 | persist-credentials: false 21 | - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 22 | with: 23 | go-version: '1.24' 24 | check-latest: true 25 | 26 | - name: golangci-lint 27 | run: | 28 | make lint 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Code coverage profiles and other test artifacts 15 | *.out 16 | coverage.* 17 | *.coverprofile 18 | profile.cov 19 | 20 | # Dependency directories (remove the comment below to include it) 21 | # vendor/ 22 | 23 | # Go workspace file 24 | go.work 25 | go.work.sum 26 | 27 | # env file 28 | .env 29 | 30 | /goose 31 | /review-goose 32 | 33 | # Editor/IDE 34 | # .idea/ 35 | # .vscode/ 36 | 37 | # Project binaries 38 | ready-to-review 39 | out/ 40 | -------------------------------------------------------------------------------- /cmd/review-goose/icons_darwin.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | 3 | package main 4 | 5 | import _ "embed" 6 | 7 | // macOS displays counts in the title bar, so icons remain static. 8 | 9 | //go:embed icons/goose.png 10 | var iconGoose []byte 11 | 12 | //go:embed icons/popper.png 13 | var iconPopper []byte 14 | 15 | //go:embed icons/smiling-face.png 16 | var iconSmiling []byte 17 | 18 | //go:embed icons/lock.png 19 | var iconLock []byte 20 | 21 | //go:embed icons/warning.png 22 | var iconWarning []byte 23 | 24 | //go:embed icons/cockroach.png 25 | var iconCockroach []byte 26 | 27 | func getIcon(iconType IconType, _ PRCounts) []byte { 28 | switch iconType { 29 | case IconGoose, IconBoth: 30 | return iconGoose 31 | case IconPopper: 32 | return iconPopper 33 | case IconCockroach: 34 | return iconCockroach 35 | case IconWarning: 36 | return iconWarning 37 | case IconLock: 38 | return iconLock 39 | default: 40 | return iconSmiling 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | permissions: {} 12 | 13 | jobs: 14 | test: 15 | name: Test 16 | 17 | runs-on: ${{ matrix.os }} 18 | 19 | permissions: 20 | contents: read 21 | 22 | strategy: 23 | matrix: 24 | os: 25 | - ubuntu-latest 26 | - macos-latest 27 | - windows-latest 28 | 29 | steps: 30 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 31 | with: 32 | persist-credentials: false 33 | 34 | - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 35 | with: 36 | go-version: 'stable' 37 | check-latest: 'true' 38 | cache: 'true' 39 | 40 | - name: Install dependencies (Linux) 41 | if: ${{ matrix.os == 'ubuntu-latest' }} 42 | run: sudo apt-get update && sudo apt-get install -y gcc libgl1-mesa-dev xorg-dev 43 | 44 | - name: Build 45 | run: make build 46 | 47 | - name: Test 48 | run: go test -v -race ./... 49 | -------------------------------------------------------------------------------- /cmd/review-goose/ratelimit.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | // RateLimiter implements a simple token bucket rate limiter. 9 | type RateLimiter struct { 10 | lastRefill time.Time 11 | mu sync.Mutex 12 | refillRate time.Duration 13 | tokens int 14 | maxTokens int 15 | } 16 | 17 | // NewRateLimiter creates a new rate limiter. 18 | func NewRateLimiter(maxTokens int, refillRate time.Duration) *RateLimiter { 19 | return &RateLimiter{ 20 | tokens: maxTokens, 21 | maxTokens: maxTokens, 22 | refillRate: refillRate, 23 | lastRefill: time.Now(), 24 | } 25 | } 26 | 27 | // Allow checks if an operation is allowed under the rate limit. 28 | func (r *RateLimiter) Allow() bool { 29 | r.mu.Lock() 30 | defer r.mu.Unlock() 31 | 32 | // Refill tokens based on elapsed time 33 | now := time.Now() 34 | elapsed := now.Sub(r.lastRefill) 35 | tokensToAdd := int(elapsed / r.refillRate) 36 | 37 | if tokensToAdd > 0 { 38 | r.tokens += tokensToAdd 39 | if r.tokens > r.maxTokens { 40 | r.tokens = r.maxTokens 41 | } 42 | r.lastRefill = now 43 | } 44 | 45 | // Check if we have tokens available 46 | if r.tokens > 0 { 47 | r.tokens-- 48 | return true 49 | } 50 | 51 | return false 52 | } 53 | -------------------------------------------------------------------------------- /cmd/review-goose/x11tray/tray_other.go: -------------------------------------------------------------------------------- 1 | //go:build !linux && !freebsd && !openbsd && !netbsd && !dragonfly && !solaris && !illumos && !aix 2 | 3 | // Package x11tray provides system tray proxy support for Unix-like systems. 4 | // On non-Unix platforms (macOS, Windows), the systray library handles tray functionality natively. 5 | package x11tray 6 | 7 | import ( 8 | "context" 9 | ) 10 | 11 | // HealthCheck always returns nil on non-Unix platforms where system tray 12 | // availability is handled differently by the OS. 13 | func HealthCheck() error { 14 | return nil 15 | } 16 | 17 | // ProxyProcess represents a running proxy process (not used on non-Unix platforms). 18 | type ProxyProcess struct{} 19 | 20 | // Stop is a no-op on non-Unix platforms. 21 | func (*ProxyProcess) Stop() error { 22 | return nil 23 | } 24 | 25 | // TryProxy is not needed on non-Unix platforms. 26 | func TryProxy(_ context.Context) (*ProxyProcess, error) { 27 | return &ProxyProcess{}, nil 28 | } 29 | 30 | // EnsureTray always succeeds on non-Unix platforms. 31 | func EnsureTray(_ context.Context) (*ProxyProcess, error) { 32 | return &ProxyProcess{}, nil 33 | } 34 | 35 | // ShowContextMenu is a no-op on non-Unix platforms. 36 | // On macOS and Windows, the systray library handles menu display natively. 37 | func ShowContextMenu() { 38 | // No-op - menu display is handled by the systray library on these platforms 39 | } 40 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/codeGROOVE-dev/goose 2 | 3 | go 1.25.4 4 | 5 | require ( 6 | github.com/codeGROOVE-dev/retry v1.3.0 7 | github.com/codeGROOVE-dev/sprinkler v0.0.0-20251113030909-5962af625370 8 | github.com/codeGROOVE-dev/turnclient v0.0.0-20251210023051-bbb7e1943ebd 9 | github.com/energye/systray v1.0.2 10 | github.com/gen2brain/beeep v0.11.2 11 | github.com/godbus/dbus/v5 v5.2.0 12 | github.com/google/go-github/v57 v57.0.0 13 | golang.org/x/image v0.34.0 14 | golang.org/x/oauth2 v0.34.0 15 | ) 16 | 17 | require ( 18 | git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect 19 | github.com/codeGROOVE-dev/prx v0.0.0-20251210004018-f65fe8849ded // indirect 20 | github.com/codeGROOVE-dev/sfcache v1.4.2 // indirect 21 | github.com/codeGROOVE-dev/sfcache/pkg/store/localfs v1.4.2 // indirect 22 | github.com/esiqveland/notify v0.13.3 // indirect 23 | github.com/go-ole/go-ole v1.3.0 // indirect 24 | github.com/google/go-querystring v1.1.0 // indirect 25 | github.com/jackmordaunt/icns/v3 v3.0.1 // indirect 26 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect 27 | github.com/sergeymakinen/go-bmp v1.0.0 // indirect 28 | github.com/sergeymakinen/go-ico v1.0.0 // indirect 29 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect 30 | github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c // indirect 31 | golang.org/x/net v0.48.0 // indirect 32 | golang.org/x/sys v0.39.0 // indirect 33 | golang.org/x/text v0.32.0 // indirect 34 | ) 35 | -------------------------------------------------------------------------------- /cmd/review-goose/icons.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log/slog" 5 | ) 6 | 7 | // Icon implementations are in platform-specific files: 8 | // - icons_darwin.go: macOS (static PNG icons, counts shown in title) 9 | // - icons_badge.go: Linux/BSD/Windows (dynamic circle badges with counts) 10 | 11 | // IconType represents different icon states. 12 | type IconType int 13 | 14 | const ( 15 | IconSmiling IconType = iota // No blocked PRs 16 | IconGoose // Incoming PRs blocked 17 | IconPopper // Outgoing PRs blocked 18 | IconCockroach // Outgoing PRs blocked (fix_tests only) 19 | IconBoth // Both incoming and outgoing blocked 20 | IconWarning // General error/warning 21 | IconLock // Authentication error 22 | ) 23 | 24 | // getIcon returns icon bytes for the given type and counts. 25 | // Implementation is platform-specific: 26 | // - macOS: returns static icons (counts displayed in title bar) 27 | // - Linux/Windows: generates dynamic badges with embedded counts. 28 | // Implemented in icons_darwin.go and icons_badge.go. 29 | 30 | // setTrayIcon updates the system tray icon. 31 | func (app *App) setTrayIcon(iconType IconType, counts PRCounts) { 32 | iconBytes := getIcon(iconType, counts) 33 | if len(iconBytes) == 0 { 34 | slog.Warn("icon bytes empty, skipping update", "type", iconType) 35 | return 36 | } 37 | 38 | app.systrayInterface.SetIcon(iconBytes) 39 | slog.Debug("tray icon updated", "type", iconType, "incoming", counts.IncomingBlocked, "outgoing", counts.OutgoingBlocked) 40 | } 41 | -------------------------------------------------------------------------------- /cmd/review-goose/multihandler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | ) 7 | 8 | // MultiHandler implements slog.Handler to write logs to multiple destinations. 9 | type MultiHandler struct { 10 | handlers []slog.Handler 11 | } 12 | 13 | // Enabled returns true if at least one handler is enabled. 14 | func (h *MultiHandler) Enabled(ctx context.Context, level slog.Level) bool { 15 | for _, handler := range h.handlers { 16 | if handler.Enabled(ctx, level) { 17 | return true 18 | } 19 | } 20 | return false 21 | } 22 | 23 | // Handle writes the record to all handlers. 24 | // 25 | //nolint:gocritic // record is an interface parameter, cannot change to pointer 26 | func (h *MultiHandler) Handle(ctx context.Context, record slog.Record) error { 27 | for _, handler := range h.handlers { 28 | if handler.Enabled(ctx, record.Level) { 29 | if err := handler.Handle(ctx, record); err != nil { 30 | // Continue logging to other destinations even if one fails 31 | _ = err 32 | } 33 | } 34 | } 35 | return nil 36 | } 37 | 38 | // WithAttrs returns a new handler with additional attributes. 39 | func (h *MultiHandler) WithAttrs(attrs []slog.Attr) slog.Handler { 40 | handlers := make([]slog.Handler, len(h.handlers)) 41 | for i, handler := range h.handlers { 42 | handlers[i] = handler.WithAttrs(attrs) 43 | } 44 | return &MultiHandler{handlers: handlers} 45 | } 46 | 47 | // WithGroup returns a new handler with a group name. 48 | func (h *MultiHandler) WithGroup(name string) slog.Handler { 49 | handlers := make([]slog.Handler, len(h.handlers)) 50 | for i, handler := range h.handlers { 51 | handlers[i] = handler.WithGroup(name) 52 | } 53 | return &MultiHandler{handlers: handlers} 54 | } 55 | -------------------------------------------------------------------------------- /cmd/review-goose/icons_badge.go: -------------------------------------------------------------------------------- 1 | //go:build (linux || freebsd || openbsd || netbsd || dragonfly || solaris || illumos || aix || windows) && !darwin 2 | 3 | package main 4 | 5 | import ( 6 | _ "embed" 7 | "log/slog" 8 | "sync" 9 | 10 | "github.com/codeGROOVE-dev/goose/pkg/icon" 11 | ) 12 | 13 | // Linux, BSD, and Windows use dynamic circle badges since they don't support title text. 14 | 15 | //go:embed icons/smiling-face.png 16 | var iconSmilingSource []byte 17 | 18 | //go:embed icons/warning.png 19 | var iconWarning []byte 20 | 21 | //go:embed icons/lock.png 22 | var iconLock []byte 23 | 24 | var ( 25 | cache = icon.NewCache() 26 | 27 | smiling []byte 28 | smilingOnce sync.Once 29 | ) 30 | 31 | func getIcon(iconType IconType, counts PRCounts) []byte { 32 | // Static icons for error states 33 | if iconType == IconWarning { 34 | return iconWarning 35 | } 36 | if iconType == IconLock { 37 | return iconLock 38 | } 39 | 40 | incoming := counts.IncomingBlocked 41 | outgoing := counts.OutgoingBlocked 42 | 43 | // Happy face when nothing is blocked 44 | if incoming == 0 && outgoing == 0 { 45 | smilingOnce.Do(func() { 46 | scaled, err := icon.Scale(iconSmilingSource) 47 | if err != nil { 48 | slog.Error("failed to scale happy face icon", "error", err) 49 | smiling = iconSmilingSource 50 | return 51 | } 52 | smiling = scaled 53 | }) 54 | return smiling 55 | } 56 | 57 | // Check cache 58 | if cached, ok := cache.Get(incoming, outgoing); ok { 59 | return cached 60 | } 61 | 62 | // Generate badge 63 | badge, err := icon.Badge(incoming, outgoing) 64 | if err != nil { 65 | slog.Error("failed to generate badge", "error", err, "incoming", incoming, "outgoing", outgoing) 66 | return smiling 67 | } 68 | 69 | cache.Put(incoming, outgoing, badge) 70 | return badge 71 | } 72 | -------------------------------------------------------------------------------- /pkg/icon/icon_test.go: -------------------------------------------------------------------------------- 1 | package icon 2 | 3 | import ( 4 | "bytes" 5 | "image/png" 6 | "testing" 7 | ) 8 | 9 | func TestBadge(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | incoming int 13 | outgoing int 14 | wantNil bool 15 | }{ 16 | {"no PRs", 0, 0, true}, 17 | {"incoming only", 3, 0, false}, 18 | {"outgoing only", 0, 5, false}, 19 | {"both", 2, 1, false}, 20 | {"large numbers", 150, 200, false}, 21 | } 22 | 23 | for _, tt := range tests { 24 | t.Run(tt.name, func(t *testing.T) { 25 | data, err := Badge(tt.incoming, tt.outgoing) 26 | if err != nil { 27 | t.Fatalf("Badge() error = %v", err) 28 | } 29 | 30 | if tt.wantNil { 31 | if data != nil { 32 | t.Error("Badge() should return nil for 0/0") 33 | } 34 | return 35 | } 36 | 37 | if data == nil { 38 | t.Fatal("Badge() returned nil when badge expected") 39 | } 40 | 41 | // Verify it's valid PNG 42 | img, err := png.Decode(bytes.NewReader(data)) 43 | if err != nil { 44 | t.Fatalf("invalid PNG: %v", err) 45 | } 46 | 47 | // Verify dimensions 48 | bounds := img.Bounds() 49 | if bounds.Dx() != Size || bounds.Dy() != Size { 50 | t.Errorf("wrong dimensions: got %dx%d, want %dx%d", 51 | bounds.Dx(), bounds.Dy(), Size, Size) 52 | } 53 | }) 54 | } 55 | } 56 | 57 | func TestCache(t *testing.T) { 58 | c := NewCache() 59 | 60 | // Cache miss 61 | if _, ok := c.Get(1, 2); ok { 62 | t.Error("expected cache miss") 63 | } 64 | 65 | // Cache hit 66 | data := []byte("test") 67 | c.Put(1, 2, data) 68 | got, ok := c.Get(1, 2) 69 | if !ok { 70 | t.Error("expected cache hit") 71 | } 72 | if !bytes.Equal(got, data) { 73 | t.Error("cached data mismatch") 74 | } 75 | } 76 | 77 | func TestFormat(t *testing.T) { 78 | tests := []struct { 79 | input int 80 | want string 81 | }{ 82 | {0, "0"}, 83 | {1, "1"}, 84 | {9, "9"}, 85 | {10, "+"}, 86 | {99, "+"}, 87 | {100, "+"}, 88 | } 89 | 90 | for _, tt := range tests { 91 | got := format(tt.input) 92 | if got != tt.want { 93 | t.Errorf("format(%d) = %q, want %q", tt.input, got, tt.want) 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /cmd/review-goose/systray_interface.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log/slog" 5 | "sync" 6 | 7 | "github.com/energye/systray" 8 | ) 9 | 10 | // SystrayInterface abstracts systray operations for testing. 11 | type SystrayInterface interface { 12 | ResetMenu() 13 | AddMenuItem(title, tooltip string) MenuItem 14 | AddSeparator() 15 | SetTitle(title string) 16 | SetIcon(iconBytes []byte) 17 | SetOnClick(fn func(menu systray.IMenu)) 18 | Quit() 19 | } 20 | 21 | // RealSystray implements SystrayInterface using the actual systray library. 22 | type RealSystray struct{} 23 | 24 | func (*RealSystray) ResetMenu() { 25 | slog.Debug("[SYSTRAY] ResetMenu called") 26 | systray.ResetMenu() 27 | } 28 | 29 | func (*RealSystray) AddMenuItem(title, tooltip string) MenuItem { 30 | slog.Debug("[SYSTRAY] AddMenuItem called", "title", title) 31 | item := systray.AddMenuItem(title, tooltip) 32 | return &RealMenuItem{MenuItem: item} 33 | } 34 | 35 | func (*RealSystray) AddSeparator() { 36 | systray.AddSeparator() 37 | } 38 | 39 | func (*RealSystray) SetTitle(title string) { 40 | slog.Info("[SYSTRAY] SetTitle called", "title", title, "len", len(title)) 41 | systray.SetTitle(title) 42 | } 43 | 44 | func (*RealSystray) SetIcon(iconBytes []byte) { 45 | systray.SetIcon(iconBytes) 46 | } 47 | 48 | func (*RealSystray) SetOnClick(fn func(menu systray.IMenu)) { 49 | systray.SetOnClick(fn) 50 | } 51 | 52 | func (*RealSystray) Quit() { 53 | systray.Quit() 54 | } 55 | 56 | // MockSystray implements SystrayInterface for testing. 57 | type MockSystray struct { 58 | title string 59 | menuItems []string 60 | mu sync.Mutex 61 | } 62 | 63 | func (m *MockSystray) ResetMenu() { 64 | m.mu.Lock() 65 | defer m.mu.Unlock() 66 | m.menuItems = nil 67 | } 68 | 69 | func (m *MockSystray) AddMenuItem(title, tooltip string) MenuItem { 70 | m.mu.Lock() 71 | defer m.mu.Unlock() 72 | m.menuItems = append(m.menuItems, title) 73 | // Return a MockMenuItem that won't panic when methods are called 74 | return &MockMenuItem{ 75 | title: title, 76 | tooltip: tooltip, 77 | } 78 | } 79 | 80 | func (m *MockSystray) AddSeparator() { 81 | m.mu.Lock() 82 | defer m.mu.Unlock() 83 | m.menuItems = append(m.menuItems, "---") 84 | } 85 | 86 | func (m *MockSystray) SetTitle(title string) { 87 | m.mu.Lock() 88 | defer m.mu.Unlock() 89 | m.title = title 90 | } 91 | 92 | func (*MockSystray) SetIcon(_ []byte) { 93 | // No-op for testing 94 | } 95 | 96 | func (*MockSystray) SetOnClick(_ func(menu systray.IMenu)) { 97 | // No-op for testing 98 | } 99 | 100 | func (*MockSystray) Quit() { 101 | // No-op for testing 102 | } 103 | -------------------------------------------------------------------------------- /cmd/review-goose/settings.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "log/slog" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | // Settings represents persistent user settings. 11 | type Settings struct { 12 | HiddenOrgs map[string]bool `json:"hidden_orgs"` 13 | EnableAudioCues bool `json:"enable_audio_cues"` 14 | HideStale bool `json:"hide_stale"` 15 | EnableAutoBrowser bool `json:"enable_auto_browser"` 16 | } 17 | 18 | // loadSettings loads settings from disk or returns defaults. 19 | func (app *App) loadSettings() { 20 | // Set defaults first 21 | app.enableAudioCues = true 22 | app.hideStaleIncoming = true 23 | app.enableAutoBrowser = true 24 | app.hiddenOrgs = make(map[string]bool) 25 | 26 | configDir, err := os.UserConfigDir() 27 | if err != nil { 28 | slog.Error("Failed to get settings directory", "error", err) 29 | return 30 | } 31 | 32 | settingsPath := filepath.Join(configDir, "review-goose", "settings.json") 33 | data, err := os.ReadFile(settingsPath) 34 | if err != nil { 35 | if !os.IsNotExist(err) { 36 | slog.Debug("Failed to read settings", "error", err) 37 | } 38 | return 39 | } 40 | 41 | var settings Settings 42 | if err := json.Unmarshal(data, &settings); err != nil { 43 | slog.Error("Failed to parse settings", "error", err) 44 | return 45 | } 46 | 47 | // Override defaults with loaded values 48 | app.enableAudioCues = settings.EnableAudioCues 49 | app.hideStaleIncoming = settings.HideStale 50 | app.enableAutoBrowser = settings.EnableAutoBrowser 51 | if settings.HiddenOrgs != nil { 52 | app.hiddenOrgs = settings.HiddenOrgs 53 | } 54 | 55 | slog.Info("Loaded settings", 56 | "audio_cues", app.enableAudioCues, 57 | "hide_stale", app.hideStaleIncoming, 58 | "auto_browser", app.enableAutoBrowser, 59 | "hidden_orgs", len(app.hiddenOrgs)) 60 | } 61 | 62 | // saveSettings saves current settings to disk. 63 | func (app *App) saveSettings() { 64 | configDir, err := os.UserConfigDir() 65 | if err != nil { 66 | slog.Error("Failed to get settings directory", "error", err) 67 | return 68 | } 69 | settingsDir := filepath.Join(configDir, "review-goose") 70 | 71 | app.mu.RLock() 72 | settings := Settings{ 73 | EnableAudioCues: app.enableAudioCues, 74 | HideStale: app.hideStaleIncoming, 75 | EnableAutoBrowser: app.enableAutoBrowser, 76 | HiddenOrgs: app.hiddenOrgs, 77 | } 78 | app.mu.RUnlock() 79 | 80 | // Ensure directory exists 81 | if err := os.MkdirAll(settingsDir, 0o700); err != nil { 82 | slog.Error("Failed to create settings directory", "error", err) 83 | return 84 | } 85 | 86 | settingsPath := filepath.Join(settingsDir, "settings.json") 87 | 88 | data, err := json.MarshalIndent(settings, "", " ") 89 | if err != nil { 90 | slog.Error("Failed to marshal settings", "error", err) 91 | return 92 | } 93 | 94 | if err := os.WriteFile(settingsPath, data, 0o600); err != nil { 95 | slog.Error("Failed to save settings", "error", err) 96 | return 97 | } 98 | 99 | slog.Info("Saved settings", 100 | "audio_cues", settings.EnableAudioCues, 101 | "hide_stale", settings.HideStale, 102 | "auto_browser", settings.EnableAutoBrowser, 103 | "hidden_orgs", len(settings.HiddenOrgs)) 104 | } 105 | -------------------------------------------------------------------------------- /cmd/review-goose/menu_item_interface.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/energye/systray" 4 | 5 | // MenuItem is an interface for menu items that can be implemented by both 6 | // real systray menu items and mock menu items for testing. 7 | type MenuItem interface { 8 | Disable() 9 | Enable() 10 | Check() 11 | Uncheck() 12 | SetTitle(string) 13 | SetTooltip(string) 14 | Click(func()) 15 | AddSubMenuItem(title, tooltip string) MenuItem 16 | } 17 | 18 | // RealMenuItem wraps a real systray.MenuItem to implement our MenuItem interface. 19 | type RealMenuItem struct { 20 | *systray.MenuItem 21 | } 22 | 23 | // Ensure RealMenuItem implements MenuItem interface. 24 | var _ MenuItem = (*RealMenuItem)(nil) 25 | 26 | // Disable disables the menu item. 27 | func (r *RealMenuItem) Disable() { 28 | r.MenuItem.Disable() 29 | } 30 | 31 | // Enable enables the menu item. 32 | func (r *RealMenuItem) Enable() { 33 | r.MenuItem.Enable() 34 | } 35 | 36 | // Check checks the menu item. 37 | func (r *RealMenuItem) Check() { 38 | r.MenuItem.Check() 39 | } 40 | 41 | // Uncheck unchecks the menu item. 42 | func (r *RealMenuItem) Uncheck() { 43 | r.MenuItem.Uncheck() 44 | } 45 | 46 | // SetTitle sets the menu item title. 47 | func (r *RealMenuItem) SetTitle(title string) { 48 | r.MenuItem.SetTitle(title) 49 | } 50 | 51 | // SetTooltip sets the menu item tooltip. 52 | func (r *RealMenuItem) SetTooltip(tooltip string) { 53 | r.MenuItem.SetTooltip(tooltip) 54 | } 55 | 56 | // Click sets the click handler. 57 | func (r *RealMenuItem) Click(handler func()) { 58 | r.MenuItem.Click(handler) 59 | } 60 | 61 | // AddSubMenuItem adds a sub menu item and returns it wrapped in our interface. 62 | func (r *RealMenuItem) AddSubMenuItem(title, tooltip string) MenuItem { 63 | subItem := r.MenuItem.AddSubMenuItem(title, tooltip) 64 | return &RealMenuItem{MenuItem: subItem} 65 | } 66 | 67 | // MockMenuItem implements MenuItem for testing without calling systray functions. 68 | type MockMenuItem struct { 69 | clickHandler func() 70 | title string 71 | tooltip string 72 | subItems []MenuItem 73 | disabled bool 74 | checked bool 75 | } 76 | 77 | // Ensure MockMenuItem implements MenuItem interface. 78 | var _ MenuItem = (*MockMenuItem)(nil) 79 | 80 | // Disable marks the item as disabled. 81 | func (m *MockMenuItem) Disable() { 82 | m.disabled = true 83 | } 84 | 85 | // Enable marks the item as enabled. 86 | func (m *MockMenuItem) Enable() { 87 | m.disabled = false 88 | } 89 | 90 | // Check marks the item as checked. 91 | func (m *MockMenuItem) Check() { 92 | m.checked = true 93 | } 94 | 95 | // Uncheck marks the item as unchecked. 96 | func (m *MockMenuItem) Uncheck() { 97 | m.checked = false 98 | } 99 | 100 | // SetTitle sets the title. 101 | func (m *MockMenuItem) SetTitle(title string) { 102 | m.title = title 103 | } 104 | 105 | // SetTooltip sets the tooltip. 106 | func (m *MockMenuItem) SetTooltip(tooltip string) { 107 | m.tooltip = tooltip 108 | } 109 | 110 | // Click sets the click handler. 111 | func (m *MockMenuItem) Click(handler func()) { 112 | m.clickHandler = handler 113 | } 114 | 115 | // AddSubMenuItem adds a sub menu item (mock). 116 | func (m *MockMenuItem) AddSubMenuItem(title, tooltip string) MenuItem { 117 | subItem := &MockMenuItem{ 118 | title: title, 119 | tooltip: tooltip, 120 | } 121 | m.subItems = append(m.subItems, subItem) 122 | return subItem 123 | } 124 | -------------------------------------------------------------------------------- /cmd/review-goose/security.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | const ( 11 | // Security constants. 12 | minTokenLength = 40 // GitHub tokens are at least 40 chars 13 | maxTokenLength = 255 // Reasonable upper bound 14 | maxUsernameLen = 39 // GitHub username max length 15 | maxURLLength = 2048 // Maximum URL length 16 | minPrintableChar = 0x20 // Minimum printable character 17 | deleteChar = 0x7F // Delete character 18 | ) 19 | 20 | var ( 21 | // githubUsernameRegex validates GitHub usernames. 22 | // GitHub usernames can only contain alphanumeric characters and hyphens, 23 | // cannot start or end with hyphen, and max 39 characters. 24 | githubUsernameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]{0,37}[a-zA-Z0-9]$|^[a-zA-Z0-9]$`) 25 | 26 | // githubTokenRegex validates GitHub token format. 27 | // Classic tokens: 40 hex chars. 28 | // New tokens: ghp_ (personal), ghs_ (server), ghr_ (refresh), gho_ (OAuth), ghu_ (user-to-server) followed by base62 chars. 29 | // Fine-grained tokens: github_pat_ followed by base62 chars. 30 | githubTokenRegex = regexp.MustCompile(`^[a-f0-9]{40}$|^gh[psoru]_[A-Za-z0-9]{36,251}$|^github_pat_[A-Za-z0-9]{82}$`) 31 | ) 32 | 33 | // validateGitHubUsername validates a GitHub username. 34 | func validateGitHubUsername(username string) error { 35 | if username == "" { 36 | return errors.New("username cannot be empty") 37 | } 38 | if len(username) > maxUsernameLen { 39 | return fmt.Errorf("username too long: %d > %d", len(username), maxUsernameLen) 40 | } 41 | if !githubUsernameRegex.MatchString(username) { 42 | return fmt.Errorf("invalid GitHub username format: %s", username) 43 | } 44 | return nil 45 | } 46 | 47 | // validateGitHubToken performs basic validation on a GitHub token. 48 | func validateGitHubToken(token string) error { 49 | if token == "" { 50 | return errors.New("token cannot be empty") 51 | } 52 | 53 | tokenLen := len(token) 54 | if tokenLen < minTokenLength { 55 | return fmt.Errorf("token too short: %d < %d", tokenLen, minTokenLength) 56 | } 57 | if tokenLen > maxTokenLength { 58 | return fmt.Errorf("token too long: %d > %d", tokenLen, maxTokenLength) 59 | } 60 | 61 | // Check for common placeholder values 62 | if strings.Contains(strings.ToLower(token), "your_token") || 63 | strings.Contains(strings.ToLower(token), "xxx") || 64 | strings.Contains(token, "...") { 65 | return errors.New("token appears to be a placeholder") 66 | } 67 | 68 | if !githubTokenRegex.MatchString(token) { 69 | return errors.New("token does not match expected GitHub token format") 70 | } 71 | 72 | return nil 73 | } 74 | 75 | // sanitizeForLog removes sensitive information from strings before logging. 76 | func sanitizeForLog(s string) string { 77 | // Redact tokens (both classic 40-char hex and new format) 78 | // Classic tokens 79 | s = regexp.MustCompile(`\b[a-f0-9]{40}\b`).ReplaceAllString(s, "[REDACTED-TOKEN]") 80 | // New format tokens (ghp_, ghs_, ghr_, gho_, ghu_) 81 | s = regexp.MustCompile(`\bgh[psoru]_[A-Za-z0-9]{36,251}\b`).ReplaceAllString(s, "[REDACTED-TOKEN]") 82 | // Fine-grained personal access tokens 83 | s = regexp.MustCompile(`\bgithub_pat_[A-Za-z0-9]{82}\b`).ReplaceAllString(s, "[REDACTED-TOKEN]") 84 | // Bearer tokens in headers 85 | s = regexp.MustCompile(`Bearer [A-Za-z0-9_\-.]+`).ReplaceAllString(s, "Bearer [REDACTED]") 86 | // Authorization headers 87 | s = regexp.MustCompile(`Authorization: \S+`).ReplaceAllString(s, "Authorization: [REDACTED]") 88 | 89 | return s 90 | } 91 | -------------------------------------------------------------------------------- /cmd/review-goose/click_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | // TestMenuClickRateLimit tests that menu clicks are properly rate limited. 11 | func TestMenuClickRateLimit(t *testing.T) { 12 | ctx := context.Background() 13 | 14 | // Create app with initial state 15 | app := &App{ 16 | mu: sync.RWMutex{}, 17 | stateManager: NewPRStateManager(time.Now().Add(-35 * time.Second)), 18 | hiddenOrgs: make(map[string]bool), 19 | seenOrgs: make(map[string]bool), 20 | lastSearchAttempt: time.Now().Add(-15 * time.Second), // 15 seconds ago 21 | systrayInterface: &MockSystray{}, // Use mock systray to avoid panics 22 | } 23 | 24 | // Simulate the click handler logic (without the actual UI interaction) 25 | testClick := func() (shouldRefresh bool, timeSinceLastSearch time.Duration) { 26 | app.mu.RLock() 27 | timeSince := time.Since(app.lastSearchAttempt) 28 | app.mu.RUnlock() 29 | 30 | if timeSince >= minUpdateInterval { 31 | // Would trigger refresh 32 | app.mu.Lock() 33 | app.lastSearchAttempt = time.Now() 34 | app.mu.Unlock() 35 | return true, timeSince 36 | } 37 | return false, timeSince 38 | } 39 | 40 | // Test 1: First click should allow refresh (last search was 15s ago) 41 | shouldRefresh, timeSince := testClick() 42 | if !shouldRefresh { 43 | t.Errorf("First click should allow refresh, last search was %v ago", timeSince) 44 | } 45 | 46 | // Test 2: Immediate second click should be rate limited 47 | shouldRefresh2, timeSince2 := testClick() 48 | if shouldRefresh2 { 49 | t.Errorf("Second click should be rate limited, last search was %v ago", timeSince2) 50 | } 51 | 52 | // Test 3: After waiting 10+ seconds, should allow refresh again 53 | app.mu.Lock() 54 | app.lastSearchAttempt = time.Now().Add(-11 * time.Second) 55 | app.mu.Unlock() 56 | 57 | shouldRefresh3, timeSince3 := testClick() 58 | if !shouldRefresh3 { 59 | t.Errorf("Click after 11 seconds should allow refresh, last search was %v ago", timeSince3) 60 | } 61 | 62 | _ = ctx // Keep context for potential future use 63 | } 64 | 65 | // TestScheduledUpdateRateLimit tests that scheduled updates respect rate limiting. 66 | func TestScheduledUpdateRateLimit(t *testing.T) { 67 | app := &App{ 68 | mu: sync.RWMutex{}, 69 | stateManager: NewPRStateManager(time.Now().Add(-35 * time.Second)), 70 | hiddenOrgs: make(map[string]bool), 71 | seenOrgs: make(map[string]bool), 72 | lastSearchAttempt: time.Now().Add(-5 * time.Second), // 5 seconds ago 73 | systrayInterface: &MockSystray{}, // Use mock systray to avoid panics 74 | } 75 | 76 | // Simulate the scheduled update logic 77 | testScheduledUpdate := func() (shouldUpdate bool, timeSinceLastSearch time.Duration) { 78 | app.mu.RLock() 79 | timeSince := time.Since(app.lastSearchAttempt) 80 | app.mu.RUnlock() 81 | 82 | return timeSince >= minUpdateInterval, timeSince 83 | } 84 | 85 | // Test 1: Scheduled update should be skipped (last search was only 5s ago) 86 | shouldUpdate, timeSince := testScheduledUpdate() 87 | if shouldUpdate { 88 | t.Errorf("Scheduled update should be skipped, last search was %v ago", timeSince) 89 | } 90 | 91 | // Test 2: After waiting 10+ seconds, scheduled update should proceed 92 | app.mu.Lock() 93 | app.lastSearchAttempt = time.Now().Add(-12 * time.Second) 94 | app.mu.Unlock() 95 | 96 | shouldUpdate2, timeSince2 := testScheduledUpdate() 97 | if !shouldUpdate2 { 98 | t.Errorf("Scheduled update after 12 seconds should proceed, last search was %v ago", timeSince2) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Review Goose 🪿 2 | 3 | ![Beta](https://img.shields.io/badge/status-beta-orange) 4 | ![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20BSD%20%7C%20Windows-blue) 5 | ![Goose Noises](https://img.shields.io/badge/goose%20noises-100%25%20more-green) 6 | [![GitHub](https://img.shields.io/github/stars/ready-to-review/goose?style=social)](https://github.com/ready-to-review/goose) 7 | 8 | ![Review Goose Logo](media/logo-small.png) 9 | 10 | The only PR tracker that honks at you when you're the bottleneck. Now shipping with 100% more goose noises! 11 | 12 | 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. 13 | 14 | ![Review Goose Screenshot](media/screenshot.png) 15 | 16 | ## macOS Quick Start ⚡ (Get Honked At) 17 | 18 | Homebrew users can get the party started quickly: 19 | 20 | ```shell 21 | 22 | brew install --cask codeGROOVE-dev/homebrew-tap/review-goose 23 | gh auth status || gh auth login 24 | ``` 25 | 26 | Open `/Applications/Review Goose.app`. To be persistently annoyed every time you login, click the `Start at Login` menu item. 27 | 28 | ## Homebrew on Linux Quick Start ⚡ 29 | 30 | On a progressive Linux distribution that includes Homebrew, such as [Bluefin](https://projectbluefin.io/)? You are just a cut and paste away from excitement: 31 | 32 | ```shell 33 | brew install codeGROOVE-dev/homebrew-tap/review-goose 34 | gh auth status || gh auth login 35 | ``` 36 | 37 | ## Linux/BSD/Windows Medium Start 38 | 39 | 1. Install the [GitHub CLI](https://cli.github.com/) and [Go](https://go.dev/dl/) via your platforms recommended methods 40 | 2. Install Review Goose: 41 | 42 | ```bash 43 | go install github.com/codeGROOVE-dev/goose/cmd/review-goose@latest 44 | ``` 45 | 46 | 3. Copy goose from $HOME/go/bin to wherever you prefer 47 | 4. Add goose to your auto-login so you never forget about PRs again 48 | 49 | ## Using a fine-grained access token 50 | 51 | If you want more control over which repositories the goose can access - for example, only access to public repositories, you can use a [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new) with the following permissions: 52 | 53 | - **Pull requests**: Read 54 | - **Metadata**: Read 55 | 56 | You can then use the token like so: 57 | 58 | ```bash 59 | env GITHUB_TOKEN=your_token_here review-goose 60 | ``` 61 | 62 | ## Usage 63 | 64 | - **macOS/Windows**: Click the tray icon to show the menu 65 | - **Linux/BSD**: Right-click the tray icon to show the menu (left-click refreshes PRs) 66 | 67 | ## Known Issues 68 | 69 | - Visual notifications won't work reliably on macOS until we release signed binaries. 70 | - Tray icons on GNOME require [snixembed](https://git.sr.ht/~steef/snixembed) and enabling the [Legacy Tray extension](https://www.omgubuntu.co.uk/2024/08/gnome-official-status-icons-extension). Goose will automatically launch snixembed if needed, but you must install it first (e.g., `apt install snixembed` or `yay -S snixembed`). 71 | 72 | ## Pricing 73 | 74 | - Free forever for public open-source repositories ❤️ 75 | - Starting in 2026, private repository access will require sponsorship or [Ready to Review](https://github.com/apps/ready-to-review-beta) subscription ($2/mo) 76 | 77 | ## Privacy 78 | 79 | - Your GitHub token is used to authenticate against GitHub and codeGROOVE's API for state-machine & natural-language processing 80 | - Your GitHub token is never stored or logged. 81 | - PR metadata may be cached locally & remotely for up to 20 days 82 | - No data is resold to anyone. We don't even want it. 83 | - No telemetry is collected 84 | 85 | ## License 86 | 87 | This project is licensed under the GPL-3.0 License - see the [LICENSE](LICENSE) file for details. 88 | 89 | --- 90 | 91 | Built with 🪿 by [codeGROOVE](https://codegroove.dev/) - PRs welcome! 92 | -------------------------------------------------------------------------------- /cmd/review-goose/sound.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "log/slog" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "runtime" 11 | "strings" 12 | "sync" 13 | "time" 14 | ) 15 | 16 | //go:embed sounds/jet.wav 17 | var jetSound []byte 18 | 19 | //go:embed sounds/honk.wav 20 | var honkSound []byte 21 | 22 | var soundCacheOnce sync.Once 23 | 24 | // initSoundCache writes embedded sounds to cache directory once. 25 | func (app *App) initSoundCache() { 26 | soundCacheOnce.Do(func() { 27 | // Create sounds subdirectory in cache 28 | soundDir := filepath.Join(app.cacheDir, "sounds") 29 | if err := os.MkdirAll(soundDir, 0o700); err != nil { 30 | slog.Error("Failed to create sound cache dir", "error", err) 31 | return 32 | } 33 | 34 | // Write jet sound 35 | jetPath := filepath.Join(soundDir, "jet.wav") 36 | if _, err := os.Stat(jetPath); os.IsNotExist(err) { 37 | if err := os.WriteFile(jetPath, jetSound, 0o600); err != nil { 38 | slog.Error("Failed to cache jet sound", "error", err) 39 | } 40 | } 41 | 42 | // Write honk sound 43 | honkPath := filepath.Join(soundDir, "honk.wav") 44 | if _, err := os.Stat(honkPath); os.IsNotExist(err) { 45 | if err := os.WriteFile(honkPath, honkSound, 0o600); err != nil { 46 | slog.Error("Failed to cache honk sound", "error", err) 47 | } 48 | } 49 | }) 50 | } 51 | 52 | // playSound plays a cached sound file using platform-specific commands. 53 | func (app *App) playSound(ctx context.Context, soundType string) { 54 | // Check if audio cues are enabled 55 | app.mu.RLock() 56 | audioEnabled := app.enableAudioCues 57 | app.mu.RUnlock() 58 | 59 | if !audioEnabled { 60 | slog.Debug("[SOUND] Sound playback skipped (audio cues disabled)", "soundType", soundType) 61 | return 62 | } 63 | 64 | slog.Debug("[SOUND] Playing sound", "soundType", soundType) 65 | // Ensure sounds are cached 66 | app.initSoundCache() 67 | 68 | // Select the sound file with validation to prevent path traversal 69 | allowedSounds := map[string]string{ 70 | "rocket": "jet.wav", 71 | "honk": "honk.wav", 72 | } 73 | 74 | soundName, ok := allowedSounds[soundType] 75 | if !ok { 76 | slog.Error("Invalid sound type requested", "soundType", soundType) 77 | return 78 | } 79 | 80 | // Double-check the sound name contains no path separators 81 | if strings.Contains(soundName, "/") || strings.Contains(soundName, "\\") || strings.Contains(soundName, "..") { 82 | slog.Error("Sound name contains invalid characters", "soundName", soundName) 83 | return 84 | } 85 | 86 | soundPath := filepath.Join(app.cacheDir, "sounds", soundName) 87 | 88 | // Check if file exists 89 | if _, err := os.Stat(soundPath); os.IsNotExist(err) { 90 | slog.Error("Sound file not found in cache", "soundPath", soundPath) 91 | return 92 | } 93 | 94 | // Check if we're in test mode (environment variable set by tests) 95 | if os.Getenv("GOOSE_TEST_MODE") == "1" { 96 | slog.Debug("[SOUND] Test mode - skipping actual sound playback", "soundPath", soundPath) 97 | return 98 | } 99 | 100 | // Play sound in background 101 | go func() { 102 | // Use a timeout context for sound playback 103 | soundCtx, cancel := context.WithTimeout(ctx, 30*time.Second) 104 | defer cancel() 105 | 106 | var cmd *exec.Cmd 107 | switch runtime.GOOS { 108 | case "darwin": 109 | cmd = exec.CommandContext(soundCtx, "afplay", soundPath) 110 | case "windows": 111 | // Use Windows Media Player API via rundll32 to avoid PowerShell script injection 112 | // This is safer than constructing PowerShell scripts with user paths 113 | cmd = exec.CommandContext(soundCtx, "cmd", "/c", "start", "/min", "", soundPath) 114 | case "linux": 115 | // Try paplay first (PulseAudio), then aplay (ALSA) 116 | cmd = exec.CommandContext(soundCtx, "paplay", soundPath) 117 | if err := cmd.Run(); err != nil { 118 | cmd = exec.CommandContext(soundCtx, "aplay", "-q", soundPath) 119 | } 120 | default: 121 | return 122 | } 123 | 124 | if cmd != nil { 125 | if err := cmd.Run(); err != nil { 126 | slog.Error("Failed to play sound", "error", err) 127 | } 128 | } 129 | }() 130 | } 131 | -------------------------------------------------------------------------------- /cmd/review-goose/browser_rate_limiter.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log/slog" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | // BrowserRateLimiter manages rate limiting for automatically opening browser windows. 10 | type BrowserRateLimiter struct { 11 | openedPRs map[string]bool 12 | openedLastMinute []time.Time 13 | openedToday []time.Time 14 | startupDelay time.Duration 15 | maxPerMinute int 16 | maxPerDay int 17 | mu sync.Mutex 18 | } 19 | 20 | // NewBrowserRateLimiter creates a new browser rate limiter. 21 | func NewBrowserRateLimiter(startupDelay time.Duration, maxPerMinute, maxPerDay int) *BrowserRateLimiter { 22 | slog.Info("[BROWSER] Initializing rate limiter", 23 | "startup_delay", startupDelay, "max_per_minute", maxPerMinute, "max_per_day", maxPerDay) 24 | return &BrowserRateLimiter{ 25 | openedLastMinute: make([]time.Time, 0), 26 | openedToday: make([]time.Time, 0), 27 | startupDelay: startupDelay, 28 | maxPerMinute: maxPerMinute, 29 | maxPerDay: maxPerDay, 30 | openedPRs: make(map[string]bool), 31 | } 32 | } 33 | 34 | // CanOpen checks if we can open a browser window according to rate limits. 35 | func (b *BrowserRateLimiter) CanOpen(startTime time.Time, prURL string) bool { 36 | b.mu.Lock() 37 | defer b.mu.Unlock() 38 | 39 | slog.Info("[BROWSER] CanOpen check", 40 | "url", prURL, 41 | "time_since_start", time.Since(startTime).Round(time.Second), 42 | "startup_delay", b.startupDelay) 43 | 44 | // Check if we've already opened this PR 45 | if b.openedPRs[prURL] { 46 | slog.Info("[BROWSER] Skipping auto-open: PR already opened", "url", prURL) 47 | return false 48 | } 49 | 50 | // Check startup delay 51 | if time.Since(startTime) < b.startupDelay { 52 | slog.Info("[BROWSER] Skipping auto-open: within startup delay period", 53 | "remaining", b.startupDelay-time.Since(startTime)) 54 | return false 55 | } 56 | 57 | now := time.Now() 58 | 59 | // Clean old entries 60 | b.cleanOldEntries(now) 61 | 62 | // Check per-minute limit 63 | if len(b.openedLastMinute) >= b.maxPerMinute { 64 | slog.Info("[BROWSER] Rate limit: per-minute limit reached", 65 | "opened", len(b.openedLastMinute), "max", b.maxPerMinute) 66 | return false 67 | } 68 | 69 | // Check per-day limit 70 | if len(b.openedToday) >= b.maxPerDay { 71 | slog.Info("[BROWSER] Rate limit: daily limit reached", 72 | "opened", len(b.openedToday), "max", b.maxPerDay) 73 | return false 74 | } 75 | 76 | slog.Info("[BROWSER] CanOpen returning true", "url", prURL) 77 | return true 78 | } 79 | 80 | // RecordOpen records that a browser window was opened. 81 | func (b *BrowserRateLimiter) RecordOpen(prURL string) { 82 | b.mu.Lock() 83 | defer b.mu.Unlock() 84 | 85 | now := time.Now() 86 | b.openedLastMinute = append(b.openedLastMinute, now) 87 | b.openedToday = append(b.openedToday, now) 88 | b.openedPRs[prURL] = true 89 | 90 | slog.Info("[BROWSER] Recorded browser open", 91 | "url", prURL, "minuteCount", len(b.openedLastMinute), "minuteMax", b.maxPerMinute, 92 | "todayCount", len(b.openedToday), "todayMax", b.maxPerDay) 93 | } 94 | 95 | // cleanOldEntries removes entries outside the time windows. 96 | func (b *BrowserRateLimiter) cleanOldEntries(now time.Time) { 97 | // Clean entries older than 1 minute 98 | oneMinuteAgo := now.Add(-1 * time.Minute) 99 | newLastMinute := make([]time.Time, 0, len(b.openedLastMinute)) 100 | for _, t := range b.openedLastMinute { 101 | if t.After(oneMinuteAgo) { 102 | newLastMinute = append(newLastMinute, t) 103 | } 104 | } 105 | b.openedLastMinute = newLastMinute 106 | 107 | // Clean entries older than 24 hours (1 day) 108 | oneDayAgo := now.Add(-24 * time.Hour) 109 | newToday := make([]time.Time, 0, len(b.openedToday)) 110 | for _, t := range b.openedToday { 111 | if t.After(oneDayAgo) { 112 | newToday = append(newToday, t) 113 | } 114 | } 115 | b.openedToday = newToday 116 | } 117 | 118 | // Reset clears the opened PRs tracking - useful when toggling the feature. 119 | func (b *BrowserRateLimiter) Reset() { 120 | b.mu.Lock() 121 | defer b.mu.Unlock() 122 | previousCount := len(b.openedPRs) 123 | b.openedPRs = make(map[string]bool) 124 | slog.Info("[BROWSER] Rate limiter reset", "clearedPRs", previousCount) 125 | } 126 | -------------------------------------------------------------------------------- /cmd/review-goose/notifications.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "maps" 8 | "time" 9 | 10 | "github.com/gen2brain/beeep" 11 | ) 12 | 13 | // processNotifications handles notifications for newly blocked PRs using the state manager. 14 | func (app *App) processNotifications(ctx context.Context) { 15 | slog.Debug("[NOTIFY] Processing notifications...") 16 | 17 | // Get the list of PRs that need notifications 18 | app.mu.RLock() 19 | hiddenOrgs := make(map[string]bool) 20 | maps.Copy(hiddenOrgs, app.hiddenOrgs) 21 | incoming := make([]PR, len(app.incoming)) 22 | copy(incoming, app.incoming) 23 | outgoing := make([]PR, len(app.outgoing)) 24 | copy(outgoing, app.outgoing) 25 | app.mu.RUnlock() 26 | 27 | // Determine if this is the initial discovery 28 | isInitialDiscovery := !app.hasPerformedInitialDiscovery 29 | 30 | // Let the state manager figure out what needs notifications 31 | toNotify := app.stateManager.UpdatePRs(incoming, outgoing, hiddenOrgs, isInitialDiscovery) 32 | 33 | // Mark that we've performed initial discovery 34 | if isInitialDiscovery { 35 | app.hasPerformedInitialDiscovery = true 36 | slog.Info("[STATE] Initial discovery completed", "incoming_count", len(incoming), "outgoing_count", len(outgoing)) 37 | } 38 | 39 | // Update deprecated fields for test compatibility 40 | app.mu.Lock() 41 | app.previousBlockedPRs = make(map[string]bool) 42 | app.blockedPRTimes = make(map[string]time.Time) 43 | states := app.stateManager.BlockedPRs() 44 | for url, state := range states { 45 | app.previousBlockedPRs[url] = true 46 | app.blockedPRTimes[url] = state.FirstBlockedAt 47 | } 48 | app.mu.Unlock() 49 | 50 | if len(toNotify) == 0 { 51 | slog.Debug("[NOTIFY] No PRs need notifications") 52 | return 53 | } 54 | 55 | slog.Info("[NOTIFY] PRs need notifications", "count", len(toNotify)) 56 | 57 | // Process notifications in a goroutine to avoid blocking the UI thread 58 | go func() { 59 | // Send notifications for each PR 60 | playedHonk := false 61 | playedRocket := false 62 | 63 | for i := range toNotify { 64 | pr := toNotify[i] 65 | isIncoming := false 66 | // Check if it's in the incoming list 67 | for j := range incoming { 68 | if incoming[j].URL == pr.URL { 69 | isIncoming = true 70 | break 71 | } 72 | } 73 | 74 | // Send notification 75 | if isIncoming { 76 | app.sendPRNotification(ctx, &pr, "PR Blocked on You 🪿", "honk", &playedHonk) 77 | } else { 78 | // Add delay between different sound types in goroutine to avoid blocking 79 | if playedHonk && !playedRocket { 80 | time.Sleep(2 * time.Second) 81 | } 82 | app.sendPRNotification(ctx, &pr, "Your PR is Blocked 🚀", "rocket", &playedRocket) 83 | } 84 | 85 | // Auto-open if enabled 86 | if app.enableAutoBrowser && time.Since(app.startTime) > startupGracePeriod { 87 | app.tryAutoOpenPR(ctx, &pr, app.enableAutoBrowser, app.startTime) 88 | } 89 | } 90 | }() 91 | 92 | // Update menu immediately after sending notifications 93 | // This needs to happen in the main thread to show the party popper emoji 94 | if len(toNotify) > 0 { 95 | slog.Info("[FLOW] Updating menu after sending notifications", "notified_count", len(toNotify)) 96 | app.updateMenu(ctx) 97 | slog.Info("[FLOW] Menu update after notifications completed") 98 | } 99 | } 100 | 101 | // sendPRNotification sends a notification for a single PR. 102 | func (app *App) sendPRNotification(ctx context.Context, pr *PR, title string, soundType string, playedSound *bool) { 103 | message := fmt.Sprintf("%s #%d: %s", pr.Repository, pr.Number, pr.Title) 104 | 105 | // Send desktop notification in a goroutine to avoid blocking 106 | go func() { 107 | if err := beeep.Notify(title, message, ""); err != nil { 108 | slog.Error("[NOTIFY] Failed to send notification", "url", pr.URL, "error", err) 109 | } 110 | }() 111 | 112 | // Play sound (only once per type per cycle) - already async in playSound 113 | if !*playedSound { 114 | slog.Debug("[NOTIFY] Playing sound for PR", "soundType", soundType, "repo", pr.Repository, "number", pr.Number) 115 | app.playSound(ctx, soundType) 116 | *playedSound = true 117 | } 118 | } 119 | 120 | // updatePRStatesAndNotify is the simplified replacement for checkForNewlyBlockedPRs. 121 | func (app *App) updatePRStatesAndNotify(ctx context.Context) { 122 | // Simple and clear: just process notifications 123 | app.processNotifications(ctx) 124 | } 125 | -------------------------------------------------------------------------------- /cmd/review-goose/reliability.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "runtime/debug" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | // safeExecute runs a function with panic recovery and logging. 12 | func safeExecute(operation string, fn func() error) (err error) { 13 | defer func() { 14 | if r := recover(); r != nil { 15 | stack := debug.Stack() 16 | err = fmt.Errorf("panic in %s: %v\nStack: %s", operation, r, stack) 17 | slog.Error("[RELIABILITY] Panic recovered", 18 | "operation", operation, 19 | "panic", r, 20 | "stack", string(stack)) 21 | } 22 | }() 23 | 24 | start := time.Now() 25 | err = fn() 26 | duration := time.Since(start) 27 | 28 | if err != nil { 29 | slog.Error("[RELIABILITY] Operation failed", 30 | "operation", operation, 31 | "error", err, 32 | "duration", duration) 33 | } else if duration > 5*time.Second { 34 | slog.Warn("[RELIABILITY] Slow operation", 35 | "operation", operation, 36 | "duration", duration) 37 | } 38 | 39 | return err 40 | } 41 | 42 | // circuitBreaker provides circuit breaker pattern for external API calls. 43 | type circuitBreaker struct { 44 | lastFailureTime time.Time 45 | name string 46 | state string 47 | timeout time.Duration 48 | failures int 49 | threshold int 50 | mu sync.RWMutex 51 | } 52 | 53 | func newCircuitBreaker(name string, threshold int, timeout time.Duration) *circuitBreaker { 54 | return &circuitBreaker{ 55 | name: name, 56 | threshold: threshold, 57 | timeout: timeout, 58 | state: "closed", 59 | } 60 | } 61 | 62 | func (cb *circuitBreaker) call(fn func() error) error { 63 | cb.mu.Lock() 64 | defer cb.mu.Unlock() 65 | 66 | // Check if circuit is open 67 | if cb.state == "open" { 68 | if time.Since(cb.lastFailureTime) <= cb.timeout { 69 | return fmt.Errorf("circuit breaker open for %s", cb.name) 70 | } 71 | cb.state = "half-open" 72 | slog.Info("[CIRCUIT] Circuit breaker transitioning to half-open", 73 | "name", cb.name) 74 | } 75 | 76 | // Execute the function 77 | err := fn() 78 | if err != nil { 79 | cb.failures++ 80 | cb.lastFailureTime = time.Now() 81 | 82 | if cb.failures >= cb.threshold { 83 | cb.state = "open" 84 | slog.Error("[CIRCUIT] Circuit breaker opened", 85 | "name", cb.name, 86 | "failures", cb.failures, 87 | "threshold", cb.threshold) 88 | } 89 | 90 | return err 91 | } 92 | 93 | // Success - reset on half-open or reduce failure count 94 | if cb.state == "half-open" { 95 | cb.state = "closed" 96 | cb.failures = 0 97 | slog.Info("[CIRCUIT] Circuit breaker closed after successful call", 98 | "name", cb.name) 99 | } else if cb.failures > 0 { 100 | cb.failures-- 101 | } 102 | 103 | return nil 104 | } 105 | 106 | // healthMonitor tracks application health metrics. 107 | type healthMonitor struct { 108 | lastCheckTime time.Time 109 | uptime time.Time 110 | app *App 111 | apiCalls int64 112 | apiErrors int64 113 | cacheHits int64 114 | cacheMisses int64 115 | mu sync.RWMutex 116 | } 117 | 118 | func newHealthMonitor() *healthMonitor { 119 | return &healthMonitor{ 120 | uptime: time.Now(), 121 | lastCheckTime: time.Now(), 122 | } 123 | } 124 | 125 | func (hm *healthMonitor) recordAPICall(success bool) { 126 | hm.mu.Lock() 127 | defer hm.mu.Unlock() 128 | 129 | hm.apiCalls++ 130 | if !success { 131 | hm.apiErrors++ 132 | } 133 | hm.lastCheckTime = time.Now() 134 | } 135 | 136 | func (hm *healthMonitor) recordCacheAccess(hit bool) { 137 | hm.mu.Lock() 138 | defer hm.mu.Unlock() 139 | 140 | if hit { 141 | hm.cacheHits++ 142 | } else { 143 | hm.cacheMisses++ 144 | } 145 | } 146 | 147 | func (hm *healthMonitor) getMetrics() map[string]any { 148 | hm.mu.RLock() 149 | defer hm.mu.RUnlock() 150 | 151 | errorRate := float64(0) 152 | if hm.apiCalls > 0 { 153 | errorRate = float64(hm.apiErrors) / float64(hm.apiCalls) * 100 154 | } 155 | 156 | cacheHitRate := float64(0) 157 | totalCacheAccess := hm.cacheHits + hm.cacheMisses 158 | if totalCacheAccess > 0 { 159 | cacheHitRate = float64(hm.cacheHits) / float64(totalCacheAccess) * 100 160 | } 161 | 162 | return map[string]any{ 163 | "uptime": time.Since(hm.uptime), 164 | "api_calls": hm.apiCalls, 165 | "api_errors": hm.apiErrors, 166 | "error_rate": errorRate, 167 | "cache_hits": hm.cacheHits, 168 | "cache_misses": hm.cacheMisses, 169 | "cache_hit_rate": cacheHitRate, 170 | "last_check": hm.lastCheckTime, 171 | } 172 | } 173 | 174 | func (hm *healthMonitor) logMetrics() { 175 | metrics := hm.getMetrics() 176 | 177 | // Get sprinkler connection status 178 | sprinklerConnected := false 179 | sprinklerLastConnected := "" 180 | if hm.app.sprinklerMonitor != nil { 181 | connected, lastConnectedAt := hm.app.sprinklerMonitor.connectionStatus() 182 | sprinklerConnected = connected 183 | if !lastConnectedAt.IsZero() { 184 | sprinklerLastConnected = time.Since(lastConnectedAt).Round(time.Second).String() + " ago" 185 | } 186 | } 187 | 188 | slog.Info("[HEALTH] Application metrics", 189 | "uptime", metrics["uptime"], 190 | "api_calls", metrics["api_calls"], 191 | "api_errors", metrics["api_errors"], 192 | "error_rate_pct", fmt.Sprintf("%.1f", metrics["error_rate"]), 193 | "cache_hit_rate_pct", fmt.Sprintf("%.1f", metrics["cache_hit_rate"]), 194 | "sprinkler_connected", sprinklerConnected, 195 | "sprinkler_last_connected", sprinklerLastConnected) 196 | } 197 | -------------------------------------------------------------------------------- /cmd/review-goose/deadlock_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | // TestConcurrentMenuOperations tests that concurrent menu operations don't cause deadlocks 10 | func TestConcurrentMenuOperations(t *testing.T) { 11 | app := &App{ 12 | mu: sync.RWMutex{}, 13 | stateManager: NewPRStateManager(time.Now()), 14 | hiddenOrgs: make(map[string]bool), 15 | seenOrgs: make(map[string]bool), 16 | blockedPRTimes: make(map[string]time.Time), 17 | browserRateLimiter: NewBrowserRateLimiter(startupGracePeriod, 5, defaultMaxBrowserOpensDay), 18 | systrayInterface: &MockSystray{}, 19 | incoming: []PR{ 20 | {Repository: "org1/repo1", Number: 1, Title: "Fix bug", URL: "https://github.com/org1/repo1/pull/1"}, 21 | }, 22 | outgoing: []PR{ 23 | {Repository: "org2/repo2", Number: 2, Title: "Add feature", URL: "https://github.com/org2/repo2/pull/2"}, 24 | }, 25 | } 26 | 27 | // Use a WaitGroup to coordinate goroutines 28 | var wg sync.WaitGroup 29 | 30 | // Use a channel to detect if we've deadlocked 31 | done := make(chan bool, 1) 32 | 33 | // Number of concurrent operations to test 34 | concurrentOps := 10 35 | 36 | wg.Add(concurrentOps * 3) // 3 types of operations 37 | 38 | // Start a goroutine that will signal completion 39 | go func() { 40 | wg.Wait() 41 | done <- true 42 | }() 43 | 44 | // Simulate concurrent menu clicks (write lock operations) 45 | for range concurrentOps { 46 | go func() { 47 | defer wg.Done() 48 | 49 | // This simulates the click handler storing menu titles 50 | menuTitles := app.generateMenuTitles() 51 | app.mu.Lock() 52 | app.lastMenuTitles = menuTitles 53 | app.mu.Unlock() 54 | }() 55 | } 56 | 57 | // Simulate concurrent menu generation (read lock operations) 58 | for range concurrentOps { 59 | go func() { 60 | defer wg.Done() 61 | 62 | // This simulates generating menu titles 63 | _ = app.generateMenuTitles() 64 | }() 65 | } 66 | 67 | // Simulate concurrent PR updates (write lock operations) 68 | for i := range concurrentOps { 69 | go func(iteration int) { 70 | defer wg.Done() 71 | 72 | app.mu.Lock() 73 | // Simulate updating PR data 74 | if iteration%2 == 0 { 75 | app.incoming = append(app.incoming, PR{ 76 | Repository: "test/repo", 77 | Number: iteration, 78 | Title: "Test PR", 79 | URL: "https://github.com/test/repo/pull/1", 80 | }) 81 | } 82 | app.mu.Unlock() 83 | }(i) 84 | } 85 | 86 | // Wait for operations to complete or timeout 87 | select { 88 | case <-done: 89 | // Success - all operations completed without deadlock 90 | t.Log("All concurrent operations completed successfully") 91 | case <-time.After(5 * time.Second): 92 | t.Fatal("Deadlock detected: operations did not complete within 5 seconds") 93 | } 94 | } 95 | 96 | // TestMenuClickDeadlockScenario specifically tests the deadlock scenario that was fixed 97 | func TestMenuClickDeadlockScenario(t *testing.T) { 98 | app := &App{ 99 | mu: sync.RWMutex{}, 100 | stateManager: NewPRStateManager(time.Now()), 101 | hiddenOrgs: make(map[string]bool), 102 | seenOrgs: make(map[string]bool), 103 | blockedPRTimes: make(map[string]time.Time), 104 | browserRateLimiter: NewBrowserRateLimiter(startupGracePeriod, 5, defaultMaxBrowserOpensDay), 105 | systrayInterface: &MockSystray{}, 106 | incoming: []PR{ 107 | {Repository: "org1/repo1", Number: 1, Title: "Test PR", URL: "https://github.com/org1/repo1/pull/1"}, 108 | }, 109 | } 110 | 111 | // This exact sequence previously caused a deadlock: 112 | // 1. Click handler acquires write lock 113 | // 2. Click handler calls generateMenuTitles while holding lock 114 | // 3. generateMenuTitles tries to acquire read lock 115 | // 4. Deadlock! 116 | 117 | // The fix ensures we don't hold the lock when calling generateMenuTitles 118 | done := make(chan bool, 1) 119 | 120 | go func() { 121 | // Simulate the fixed click handler behavior 122 | menuTitles := app.generateMenuTitles() // Called WITHOUT holding lock 123 | app.mu.Lock() 124 | app.lastMenuTitles = menuTitles 125 | app.mu.Unlock() 126 | done <- true 127 | }() 128 | 129 | select { 130 | case <-done: 131 | t.Log("Click handler completed without deadlock") 132 | case <-time.After(1 * time.Second): 133 | t.Fatal("Click handler deadlocked") 134 | } 135 | } 136 | 137 | // TestRapidMenuClicks tests that rapid menu clicks don't cause issues 138 | func TestRapidMenuClicks(t *testing.T) { 139 | app := &App{ 140 | mu: sync.RWMutex{}, 141 | stateManager: NewPRStateManager(time.Now()), 142 | hiddenOrgs: make(map[string]bool), 143 | seenOrgs: make(map[string]bool), 144 | blockedPRTimes: make(map[string]time.Time), 145 | browserRateLimiter: NewBrowserRateLimiter(startupGracePeriod, 5, defaultMaxBrowserOpensDay), 146 | systrayInterface: &MockSystray{}, 147 | lastSearchAttempt: time.Now().Add(-15 * time.Second), // Allow first click 148 | incoming: []PR{ 149 | {Repository: "org1/repo1", Number: 1, Title: "Test", URL: "https://github.com/org1/repo1/pull/1"}, 150 | }, 151 | } 152 | 153 | // Simulate 20 rapid clicks 154 | clickCount := 20 155 | successfulClicks := 0 156 | 157 | for range clickCount { 158 | // Check if enough time has passed for rate limiting 159 | app.mu.RLock() 160 | timeSince := time.Since(app.lastSearchAttempt) 161 | app.mu.RUnlock() 162 | 163 | if timeSince >= minUpdateInterval { 164 | // This click would trigger a refresh 165 | app.mu.Lock() 166 | app.lastSearchAttempt = time.Now() 167 | app.mu.Unlock() 168 | successfulClicks++ 169 | 170 | // Also update menu titles as the real handler would 171 | menuTitles := app.generateMenuTitles() 172 | app.mu.Lock() 173 | app.lastMenuTitles = menuTitles 174 | app.mu.Unlock() 175 | } 176 | 177 | // Small delay between clicks to simulate human clicking 178 | time.Sleep(10 * time.Millisecond) 179 | } 180 | 181 | // Due to rate limiting, we should only have 1-2 successful clicks 182 | if successfulClicks > 3 { 183 | t.Errorf("Rate limiting not working: %d clicks succeeded out of %d rapid clicks", successfulClicks, clickCount) 184 | } 185 | 186 | t.Logf("Rate limiting working correctly: %d clicks succeeded out of %d rapid clicks", successfulClicks, clickCount) 187 | } 188 | -------------------------------------------------------------------------------- /cmd/review-goose/loginitem_darwin.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | "fmt" 9 | "log/slog" 10 | "os" 11 | "os/exec" 12 | "path/filepath" 13 | "strings" 14 | 15 | "github.com/energye/systray" 16 | ) 17 | 18 | // validateAndEscapePathForAppleScript validates and escapes a path for safe use in AppleScript. 19 | // Returns empty string if path contains invalid characters. 20 | func validateAndEscapePathForAppleScript(path string) string { 21 | // Validate path contains only safe characters 22 | for _, r := range path { 23 | if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && 24 | (r < '0' || r > '9') && r != ' ' && r != '.' && 25 | r != '/' && r != '-' && r != '_' { 26 | slog.Error("Path contains invalid character for AppleScript", "char", string(r), "path", path) 27 | return "" 28 | } 29 | } 30 | // Escape backslashes first then quotes 31 | path = strings.ReplaceAll(path, `\`, `\\`) 32 | path = strings.ReplaceAll(path, `"`, `\"`) 33 | return path 34 | } 35 | 36 | // isLoginItem checks if the app is set to start at login. 37 | func isLoginItem(ctx context.Context) bool { 38 | appPath, err := appPath() 39 | if err != nil { 40 | slog.Error("Failed to get app path", "error", err) 41 | return false 42 | } 43 | 44 | // Use osascript to check login items 45 | escapedPath := validateAndEscapePathForAppleScript(appPath) 46 | if escapedPath == "" { 47 | slog.Error("Invalid app path for AppleScript", "path", appPath) 48 | return false 49 | } 50 | // We use %s here because the string is already validated and escaped 51 | //nolint:gocritic // already escaped 52 | script := fmt.Sprintf( 53 | `tell application "System Events" to get the name of every login item where path is "%s"`, 54 | escapedPath) 55 | slog.Debug("Executing command", "command", "osascript", "script", script) 56 | cmd := exec.CommandContext(ctx, "osascript", "-e", script) 57 | output, err := cmd.CombinedOutput() 58 | if err != nil { 59 | slog.Error("Failed to check login items", "error", err) 60 | return false 61 | } 62 | 63 | result := strings.TrimSpace(string(output)) 64 | return result != "" 65 | } 66 | 67 | // setLoginItem adds or removes the app from login items. 68 | func setLoginItem(ctx context.Context, enable bool) error { 69 | appPath, err := appPath() 70 | if err != nil { 71 | return fmt.Errorf("get app path: %w", err) 72 | } 73 | 74 | if enable { 75 | // Add to login items 76 | escapedPath := validateAndEscapePathForAppleScript(appPath) 77 | if escapedPath == "" { 78 | return fmt.Errorf("invalid app path for AppleScript: %s", appPath) 79 | } 80 | // We use %s here because the string is already validated and escaped 81 | //nolint:gocritic // already escaped 82 | script := fmt.Sprintf( 83 | `tell application "System Events" to make login item at end with properties {path:"%s", hidden:false}`, 84 | escapedPath) 85 | slog.Debug("Executing command", "command", "osascript", "script", script) 86 | cmd := exec.CommandContext(ctx, "osascript", "-e", script) 87 | if output, err := cmd.CombinedOutput(); err != nil { 88 | return fmt.Errorf("add login item: %w (output: %s)", err, string(output)) 89 | } 90 | slog.Info("Added to login items", "path", appPath) 91 | } else { 92 | // Remove from login items 93 | appName := filepath.Base(appPath) 94 | appName = strings.TrimSuffix(appName, ".app") 95 | escapedName := validateAndEscapePathForAppleScript(appName) 96 | if escapedName == "" { 97 | return fmt.Errorf("invalid app name for AppleScript: %s", appName) 98 | } 99 | // We use %s here because the string is already validated and escaped 100 | script := fmt.Sprintf(`tell application "System Events" to delete login item "%s"`, escapedName) //nolint:gocritic // already escaped 101 | slog.Debug("Executing command", "command", "osascript", "script", script) 102 | cmd := exec.CommandContext(ctx, "osascript", "-e", script) 103 | if output, err := cmd.CombinedOutput(); err != nil { 104 | // Ignore error if item doesn't exist 105 | if !strings.Contains(string(output), "Can't get login item") { 106 | return fmt.Errorf("remove login item: %w (output: %s)", err, string(output)) 107 | } 108 | } 109 | slog.Info("Removed from login items", "app", appName) 110 | } 111 | 112 | return nil 113 | } 114 | 115 | // appPath returns the path to the application bundle. 116 | func appPath() (string, error) { 117 | // Get the executable path 118 | execPath, err := os.Executable() 119 | if err != nil { 120 | return "", fmt.Errorf("get executable: %w", err) 121 | } 122 | 123 | // Resolve any symlinks 124 | execPath, err = filepath.EvalSymlinks(execPath) 125 | if err != nil { 126 | return "", fmt.Errorf("eval symlinks: %w", err) 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 | if strings.Contains(execPath, ".app/Contents/MacOS/") { 132 | // Extract the .app path 133 | parts := strings.Split(execPath, ".app/Contents/MacOS/") 134 | if len(parts) >= 2 { 135 | return parts[0] + ".app", nil 136 | } 137 | } 138 | 139 | // Not running from an app bundle, return empty string to indicate this 140 | return "", errors.New("not running from app bundle") 141 | } 142 | 143 | // addLoginItemUI adds the login item menu option (macOS only). 144 | func addLoginItemUI(ctx context.Context, app *App) { 145 | // Check if we're running from an app bundle 146 | execPath, err := os.Executable() 147 | if err != nil { 148 | slog.Debug("Hiding 'Start at Login' menu item - could not get executable path") 149 | return 150 | } 151 | 152 | // Resolve any symlinks 153 | execPath, err = filepath.EvalSymlinks(execPath) 154 | if err != nil { 155 | slog.Debug("Hiding 'Start at Login' menu item - could not resolve symlinks") 156 | return 157 | } 158 | 159 | // App bundles have the structure: /path/to/App.app/Contents/MacOS/executable 160 | if !strings.Contains(execPath, ".app/Contents/MacOS/") { 161 | slog.Debug("Hiding 'Start at Login' menu item - not running from app bundle") 162 | return 163 | } 164 | 165 | // Add text checkmark for consistency with other menu items 166 | var loginText string 167 | if isLoginItem(ctx) { 168 | loginText = "✓ Start at Login" 169 | } else { 170 | loginText = "Start at Login" 171 | } 172 | loginItem := systray.AddMenuItem(loginText, "Automatically start when you log in") 173 | 174 | loginItem.Click(func() { 175 | isEnabled := isLoginItem(ctx) 176 | newState := !isEnabled 177 | 178 | if err := setLoginItem(ctx, newState); err != nil { 179 | slog.Error("Failed to set login item", "error", err) 180 | return 181 | } 182 | 183 | // Update UI state 184 | slog.Info("[SETTINGS] Start at Login toggled", "enabled", newState) 185 | 186 | // Rebuild menu to update checkmark 187 | app.rebuildMenu(ctx) 188 | }) 189 | } 190 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE= 2 | git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo= 3 | github.com/codeGROOVE-dev/prx v0.0.0-20251210004018-f65fe8849ded h1:d5pL5z1e/N0FFTPvgkkqwMposglQkNCBPi40XQ7CrWs= 4 | github.com/codeGROOVE-dev/prx v0.0.0-20251210004018-f65fe8849ded/go.mod h1:tBh77XZQxCyzMUB+6yjlayMbE9g6JRpBjGbAntfzk0M= 5 | github.com/codeGROOVE-dev/retry v1.3.0 h1:/+ipAWRJLL6y1R1vprYo0FSjSBvH6fE5j9LKXjpD54g= 6 | github.com/codeGROOVE-dev/retry v1.3.0/go.mod h1:8OgefgV1XP7lzX2PdKlCXILsYKuz6b4ZpHa/20iLi8E= 7 | github.com/codeGROOVE-dev/sfcache v1.4.2 h1:F1fXicO/B2O2N7KuJRkaHqJCHgxYXls7aQtSH8wUxH0= 8 | github.com/codeGROOVE-dev/sfcache v1.4.2/go.mod h1:ksV5Y1RwKmOPZZiV0zXpsBOENGUCgO0fVgr/P8f/DJM= 9 | github.com/codeGROOVE-dev/sfcache/pkg/store/localfs v1.4.2 h1:EF0RxIee8DvugvTmwinSqt5OHFEykol3kZcxzcS62uE= 10 | github.com/codeGROOVE-dev/sfcache/pkg/store/localfs v1.4.2/go.mod h1:D/Y1sYT1m3VXHZRkQjHjFwtKQOsb587QZP/GtfJEDSE= 11 | github.com/codeGROOVE-dev/sprinkler v0.0.0-20251113030909-5962af625370 h1:uYXBDnaRRf4q6X/IWOm6O/cOj57tVkpjfVvwn+SfHp0= 12 | github.com/codeGROOVE-dev/sprinkler v0.0.0-20251113030909-5962af625370/go.mod h1:sOvKRad1kRPAOIUm7spNR3aeVQjtk9VoS8uo6NL4kus= 13 | github.com/codeGROOVE-dev/turnclient v0.0.0-20251210023051-bbb7e1943ebd h1:Baps/A2EaaMlZlTseAO9/ig0oWFmqUGLtNRJm9bEDXA= 14 | github.com/codeGROOVE-dev/turnclient v0.0.0-20251210023051-bbb7e1943ebd/go.mod h1:B2rJBMHZ+rI7v629vSF2rYb669OUY+/5tGeWdUSI65A= 15 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 17 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/energye/systray v1.0.2 h1:63R4prQkANtpM2CIA4UrDCuwZFt+FiygG77JYCsNmXc= 19 | github.com/energye/systray v1.0.2/go.mod h1:sp7Q/q/I4/w5ebvpSuJVep71s9Bg7L9ZVp69gBASehM= 20 | github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o= 21 | github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE= 22 | github.com/gen2brain/beeep v0.11.2 h1:+KfiKQBbQCuhfJFPANZuJ+oxsSKAYNe88hIpJuyKWDA= 23 | github.com/gen2brain/beeep v0.11.2/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc= 24 | github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= 25 | github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= 26 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 27 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 28 | github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8= 29 | github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= 30 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 31 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 32 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 33 | github.com/google/go-github/v57 v57.0.0 h1:L+Y3UPTY8ALM8x+TV0lg+IEBI+upibemtBD8Q9u7zHs= 34 | github.com/google/go-github/v57 v57.0.0/go.mod h1:s0omdnye0hvK/ecLvpsGfJMiRt85PimQh4oygmLIxHw= 35 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 36 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 37 | github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o= 38 | github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ= 39 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= 40 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 41 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 42 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 43 | github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M= 44 | github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY= 45 | github.com/sergeymakinen/go-ico v1.0.0 h1:uL3khgvKkY6WfAetA+RqsguClBuu7HpvBB/nq/Jvr80= 46 | github.com/sergeymakinen/go-ico v1.0.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk= 47 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 48 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 49 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 50 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 51 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 52 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 53 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 54 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= 55 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= 56 | github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c h1:coVla7zpsycc+kA9NXpcvv2E4I7+ii6L5hZO2S6C3kw= 57 | github.com/tevino/abool v0.0.0-20220530134649-2bfc934cb23c/go.mod h1:qc66Pna1RiIsPa7O4Egxxs9OqkuxDX55zznh9K07Tzg= 58 | golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= 59 | golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= 60 | golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= 61 | golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= 62 | golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= 63 | golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 64 | golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 65 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 66 | golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 67 | golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 68 | golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= 69 | golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 70 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 71 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 72 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 73 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 74 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 75 | -------------------------------------------------------------------------------- /cmd/review-goose/security_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | // URL validation tests are in pkg/safebrowse/safebrowse_test.go 9 | // This file only contains tests for goose-specific security functions 10 | 11 | func TestValidateGitHubUsername(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | username string 15 | wantErr bool 16 | }{ 17 | // Valid usernames 18 | { 19 | name: "simple username", 20 | username: "user", 21 | wantErr: false, 22 | }, 23 | { 24 | name: "username with hyphen", 25 | username: "user-name", 26 | wantErr: false, 27 | }, 28 | { 29 | name: "username with numbers", 30 | username: "user123", 31 | wantErr: false, 32 | }, 33 | { 34 | name: "single character", 35 | username: "a", 36 | wantErr: false, 37 | }, 38 | { 39 | name: "max length username", 40 | username: strings.Repeat("a", 39), 41 | wantErr: false, 42 | }, 43 | 44 | // Invalid usernames 45 | { 46 | name: "empty string", 47 | username: "", 48 | wantErr: true, 49 | }, 50 | { 51 | name: "username starting with hyphen", 52 | username: "-user", 53 | wantErr: true, 54 | }, 55 | { 56 | name: "username ending with hyphen", 57 | username: "user-", 58 | wantErr: true, 59 | }, 60 | { 61 | name: "username with double hyphen", 62 | username: "user--name", 63 | wantErr: false, // GitHub allows this 64 | }, 65 | { 66 | name: "username too long", 67 | username: strings.Repeat("a", 40), 68 | wantErr: true, 69 | }, 70 | { 71 | name: "username with underscore", 72 | username: "user_name", 73 | wantErr: true, 74 | }, 75 | { 76 | name: "username with dot", 77 | username: "user.name", 78 | wantErr: true, 79 | }, 80 | { 81 | name: "username with space", 82 | username: "user name", 83 | wantErr: true, 84 | }, 85 | { 86 | name: "username with special chars", 87 | username: "user@name", 88 | wantErr: true, 89 | }, 90 | } 91 | 92 | for _, tt := range tests { 93 | t.Run(tt.name, func(t *testing.T) { 94 | err := validateGitHubUsername(tt.username) 95 | if (err != nil) != tt.wantErr { 96 | t.Errorf("validateGitHubUsername() error = %v, wantErr %v", err, tt.wantErr) 97 | } 98 | }) 99 | } 100 | } 101 | 102 | func TestValidateGitHubToken(t *testing.T) { 103 | tests := []struct { 104 | name string 105 | token string 106 | wantErr bool 107 | }{ 108 | // Valid tokens 109 | { 110 | name: "classic token (40 hex chars)", 111 | token: "abcdef0123456789abcdef0123456789abcdef01", 112 | wantErr: false, 113 | }, 114 | { 115 | name: "personal access token (ghp_)", 116 | token: "ghp_" + strings.Repeat("a", 36), 117 | wantErr: false, 118 | }, 119 | { 120 | name: "server token (ghs_)", 121 | token: "ghs_" + strings.Repeat("A", 36), 122 | wantErr: false, 123 | }, 124 | { 125 | name: "refresh token (ghr_)", 126 | token: "ghr_" + strings.Repeat("1", 36), 127 | wantErr: false, 128 | }, 129 | { 130 | name: "OAuth token (gho_)", 131 | token: "gho_" + strings.Repeat("z", 36), 132 | wantErr: false, 133 | }, 134 | { 135 | name: "user-to-server token (ghu_)", 136 | token: "ghu_" + strings.Repeat("Z", 36), 137 | wantErr: false, 138 | }, 139 | { 140 | name: "fine-grained PAT", 141 | token: "github_pat_" + strings.Repeat("a", 82), 142 | wantErr: false, 143 | }, 144 | 145 | // Invalid tokens 146 | { 147 | name: "empty string", 148 | token: "", 149 | wantErr: true, 150 | }, 151 | { 152 | name: "too short", 153 | token: "short", 154 | wantErr: true, 155 | }, 156 | { 157 | name: "too long", 158 | token: strings.Repeat("a", 300), 159 | wantErr: true, 160 | }, 161 | { 162 | name: "placeholder: your_token", 163 | token: "your_token_here_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 164 | wantErr: true, 165 | }, 166 | { 167 | name: "placeholder: xxx", 168 | token: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 169 | wantErr: true, 170 | }, 171 | { 172 | name: "placeholder with dots", 173 | token: "...aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 174 | wantErr: true, 175 | }, 176 | { 177 | name: "invalid format", 178 | token: "invalid_format_token_here_aaaaaaaaaaaaaaaa", 179 | wantErr: true, 180 | }, 181 | { 182 | name: "classic token too short", 183 | token: "abcdef0123456789abcdef0123456789abcdef0", 184 | wantErr: true, 185 | }, 186 | { 187 | name: "ghp_ token too short", 188 | token: "ghp_" + strings.Repeat("a", 35), 189 | wantErr: true, 190 | }, 191 | { 192 | name: "fine-grained PAT too short", 193 | token: "github_pat_" + strings.Repeat("a", 81), 194 | wantErr: true, 195 | }, 196 | } 197 | 198 | for _, tt := range tests { 199 | t.Run(tt.name, func(t *testing.T) { 200 | err := validateGitHubToken(tt.token) 201 | if (err != nil) != tt.wantErr { 202 | t.Errorf("validateGitHubToken() error = %v, wantErr %v", err, tt.wantErr) 203 | } 204 | }) 205 | } 206 | } 207 | 208 | func TestSanitizeForLog(t *testing.T) { 209 | tests := []struct { 210 | name string 211 | input string 212 | wantHide bool // true if sensitive data should be redacted 213 | }{ 214 | { 215 | name: "classic token redacted", 216 | input: "token: abcdef0123456789abcdef0123456789abcdef01", 217 | wantHide: true, 218 | }, 219 | { 220 | name: "ghp_ token redacted", 221 | input: "Authorization: ghp_" + strings.Repeat("a", 36), 222 | wantHide: true, 223 | }, 224 | { 225 | name: "fine-grained PAT redacted", 226 | input: "token=github_pat_" + strings.Repeat("b", 82), 227 | wantHide: true, 228 | }, 229 | { 230 | name: "bearer token redacted", 231 | input: "Bearer abc123xyz", 232 | wantHide: true, 233 | }, 234 | { 235 | name: "authorization header redacted", 236 | input: "Authorization: Bearer token123", 237 | wantHide: true, 238 | }, 239 | { 240 | name: "normal text not redacted", 241 | input: "This is just a normal log message", 242 | wantHide: false, 243 | }, 244 | { 245 | name: "URL not redacted", 246 | input: "https://github.com/owner/repo/pull/123", 247 | wantHide: false, 248 | }, 249 | } 250 | 251 | for _, tt := range tests { 252 | t.Run(tt.name, func(t *testing.T) { 253 | result := sanitizeForLog(tt.input) 254 | 255 | if tt.wantHide { 256 | // Should contain REDACTED marker 257 | if !strings.Contains(result, "[REDACTED") { 258 | t.Errorf("sanitizeForLog() = %v, should contain redaction marker", result) 259 | } 260 | // Should not contain original sensitive data patterns 261 | if strings.Contains(result, "ghp_") || strings.Contains(result, "github_pat_") { 262 | t.Errorf("sanitizeForLog() = %v, still contains sensitive pattern", result) 263 | } 264 | } else if result != tt.input { 265 | // Should be unchanged 266 | t.Errorf("sanitizeForLog() = %v, want %v", result, tt.input) 267 | } 268 | }) 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /pkg/icon/icon.go: -------------------------------------------------------------------------------- 1 | // Package icon generates system tray icons for the Goose application. 2 | // 3 | // On platforms that don't support dynamic title text (Linux, Windows), 4 | // icons are rendered as colored circle badges with white numbers: 5 | // - Red circle: incoming PRs needing review 6 | // - Green circle: outgoing PRs blocked 7 | // - Both: red (top-left) + green (bottom-right) 8 | // 9 | // Generated icons are 48×48 pixels for optimal display on KDE and GNOME. 10 | package icon 11 | 12 | import ( 13 | "bytes" 14 | "fmt" 15 | "image" 16 | "image/color" 17 | "image/png" 18 | "math" 19 | "strconv" 20 | "sync" 21 | 22 | "golang.org/x/image/draw" 23 | "golang.org/x/image/font" 24 | "golang.org/x/image/font/gofont/gomonobold" 25 | "golang.org/x/image/font/opentype" 26 | "golang.org/x/image/math/fixed" 27 | ) 28 | 29 | // Size is the standard system tray icon size (48×48 for KDE/GNOME). 30 | const Size = 48 31 | 32 | // Color scheme for PR state indicators. 33 | var ( 34 | red = color.RGBA{220, 53, 69, 255} // Incoming PRs (needs attention) 35 | green = color.RGBA{40, 167, 69, 255} // Outgoing PRs (in progress) 36 | white = color.RGBA{255, 255, 255, 255} // Text color 37 | ) 38 | 39 | // Badge generates a badge icon showing PR counts. 40 | // 41 | // Visual design for accessibility: 42 | // - Incoming only: Red CIRCLE (helps color-blind users distinguish) 43 | // - Outgoing only: Green SQUARE 44 | // - Both: Diagonal split (red top-left, green bottom-right) 45 | // 46 | // Returns nil if both counts are zero (caller should use happy face icon). 47 | // Numbers are capped at 99 for display purposes. 48 | func Badge(incoming, outgoing int) ([]byte, error) { 49 | if incoming == 0 && outgoing == 0 { 50 | return nil, nil 51 | } 52 | 53 | img := image.NewRGBA(image.Rect(0, 0, Size, Size)) 54 | 55 | switch { 56 | case incoming > 0 && outgoing > 0: 57 | // Both: diagonal split with bold numbers 58 | drawDiagonalSplit(img, format(incoming), format(outgoing)) 59 | case incoming > 0: 60 | // Incoming only: large red circle with bold number 61 | drawCircle(img, red, format(incoming)) 62 | default: 63 | // Outgoing only: large green square with bold number 64 | drawSquare(img, green, format(outgoing)) 65 | } 66 | 67 | var buf bytes.Buffer 68 | if err := png.Encode(&buf, img); err != nil { 69 | return nil, fmt.Errorf("encode png: %w", err) 70 | } 71 | return buf.Bytes(), nil 72 | } 73 | 74 | // Scale resizes an icon to the standard tray size. 75 | func Scale(iconData []byte) ([]byte, error) { 76 | src, err := png.Decode(bytes.NewReader(iconData)) 77 | if err != nil { 78 | return nil, fmt.Errorf("decode png: %w", err) 79 | } 80 | 81 | dst := image.NewRGBA(image.Rect(0, 0, Size, Size)) 82 | draw.NearestNeighbor.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) 83 | 84 | var buf bytes.Buffer 85 | if err := png.Encode(&buf, dst); err != nil { 86 | return nil, fmt.Errorf("encode png: %w", err) 87 | } 88 | return buf.Bytes(), nil 89 | } 90 | 91 | // drawCircle renders a large filled circle with bold centered text. 92 | func drawCircle(img *image.RGBA, fill color.RGBA, text string) { 93 | radius := float64(Size) / 2 94 | cx := radius 95 | cy := radius 96 | 97 | // Draw filled circle 98 | for py := range Size { 99 | for px := range Size { 100 | dx := float64(px) - cx + 0.5 101 | dy := float64(py) - cy + 0.5 102 | dist := math.Sqrt(dx*dx + dy*dy) 103 | 104 | if dist <= radius { 105 | img.Set(px, py, fill) 106 | } 107 | } 108 | } 109 | 110 | // Draw large bold centered text 111 | drawBoldText(img, text, Size/2, Size/2) 112 | } 113 | 114 | // drawSquare renders a solid square with bold centered text. 115 | func drawSquare(img *image.RGBA, fill color.RGBA, text string) { 116 | // Fill entire image with color 117 | for py := range Size { 118 | for px := range Size { 119 | img.Set(px, py, fill) 120 | } 121 | } 122 | 123 | // Draw large bold centered text 124 | drawBoldText(img, text, Size/2, Size/2) 125 | } 126 | 127 | // drawDiagonalSplit renders a diagonal split with two numbers. 128 | func drawDiagonalSplit(img *image.RGBA, incomingText, outgoingText string) { 129 | // Fill with diagonal split: red top-left, green bottom-right 130 | for py := range Size { 131 | for px := range Size { 132 | if px < Size-py { 133 | img.Set(px, py, red) 134 | } else { 135 | img.Set(px, py, green) 136 | } 137 | } 138 | } 139 | 140 | // Draw incoming number in top-left quadrant (lowered 1 pixel) 141 | drawBoldText(img, incomingText, Size/4, Size/4+1) 142 | 143 | // Draw outgoing number in bottom-right quadrant (raised 1 pixel) 144 | drawBoldText(img, outgoingText, 3*Size/4, 3*Size/4-1) 145 | } 146 | 147 | // drawBoldText renders large, professional text using Go's monospace bold font. 148 | func drawBoldText(img *image.RGBA, text string, centerX, centerY int) { 149 | // Parse Go's embedded monospace bold font 150 | face, err := opentype.Parse(gomonobold.TTF) 151 | if err != nil { 152 | return // Graceful fallback: show colored badge without text 153 | } 154 | 155 | // Create font face at large size (32 points = ~42 pixels tall) 156 | fontSize := 32.0 157 | fontFace, err := opentype.NewFace(face, &opentype.FaceOptions{ 158 | Size: fontSize, 159 | DPI: 72, 160 | }) 161 | if err != nil { 162 | return 163 | } 164 | defer fontFace.Close() //nolint:errcheck // Close error is not critical for rendering 165 | 166 | // Measure text bounds 167 | bounds, advance := font.BoundString(fontFace, text) 168 | textWidth := advance.Ceil() 169 | 170 | // Calculate baseline position to center text vertically 171 | // The visual center of text is at (bounds.Max.Y + bounds.Min.Y) / 2 above baseline 172 | // So baseline Y = centerY - visualCenter 173 | visualCenter := (bounds.Max.Y + bounds.Min.Y) / 2 174 | baselineY := fixed.I(centerY) - visualCenter 175 | 176 | // Center horizontally 177 | x := fixed.I(centerX - textWidth/2) 178 | 179 | // Draw the text 180 | drawer := &font.Drawer{ 181 | Dst: img, 182 | Src: image.NewUniform(white), 183 | Face: fontFace, 184 | Dot: fixed.Point26_6{X: x, Y: baselineY}, 185 | } 186 | drawer.DrawString(text) 187 | } 188 | 189 | // format converts a count to display text. 190 | // Shows single digits 1-9, or "+" for 10 or more. 191 | func format(n int) string { 192 | if n > 9 { 193 | return "+" 194 | } 195 | return strconv.Itoa(n) 196 | } 197 | 198 | // Cache stores generated icons to avoid redundant rendering. 199 | type Cache struct { 200 | icons map[string][]byte 201 | mu sync.RWMutex 202 | } 203 | 204 | // NewCache creates an icon cache. 205 | func NewCache() *Cache { 206 | return &Cache{icons: make(map[string][]byte)} 207 | } 208 | 209 | // Get retrieves a cached icon. 210 | func (c *Cache) Get(incoming, outgoing int) ([]byte, bool) { 211 | c.mu.RLock() 212 | defer c.mu.RUnlock() 213 | data, ok := c.icons[key(incoming, outgoing)] 214 | return data, ok 215 | } 216 | 217 | // Put stores an icon in the cache. 218 | func (c *Cache) Put(incoming, outgoing int, data []byte) { 219 | c.mu.Lock() 220 | defer c.mu.Unlock() 221 | 222 | // Simple size limit 223 | if len(c.icons) > 100 { 224 | c.icons = make(map[string][]byte) 225 | } 226 | 227 | c.icons[key(incoming, outgoing)] = data 228 | } 229 | 230 | func key(incoming, outgoing int) string { 231 | return strconv.Itoa(incoming) + ":" + strconv.Itoa(outgoing) 232 | } 233 | -------------------------------------------------------------------------------- /pkg/safebrowse/safebrowse.go: -------------------------------------------------------------------------------- 1 | // Package safebrowse provides secure URL validation and browser opening. 2 | // All validation rules apply uniformly across platforms to prevent injection attacks. 3 | package safebrowse 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | "fmt" 9 | "net/url" 10 | "os/exec" 11 | "runtime" 12 | "strings" 13 | ) 14 | 15 | const maxURLLength = 2048 16 | 17 | // Open validates and opens a URL in the system browser. 18 | func Open(ctx context.Context, rawURL string) error { 19 | if err := validate(rawURL, false); err != nil { 20 | return err 21 | } 22 | return openBrowser(ctx, rawURL) 23 | } 24 | 25 | // OpenWithParams validates and opens a URL with query parameters. 26 | func OpenWithParams(ctx context.Context, rawURL string, params map[string]string) error { 27 | if err := validate(rawURL, false); err != nil { 28 | return err 29 | } 30 | 31 | u, err := url.Parse(rawURL) 32 | if err != nil { 33 | return fmt.Errorf("parse url: %w", err) 34 | } 35 | 36 | // Validate parameters before encoding 37 | for key, value := range params { 38 | if err := validateParamString(key); err != nil { 39 | return fmt.Errorf("invalid parameter key %q: %w", key, err) 40 | } 41 | if err := validateParamString(value); err != nil { 42 | return fmt.Errorf("invalid parameter value %q: %w", value, err) 43 | } 44 | } 45 | 46 | // Build query string 47 | q := u.Query() 48 | for key, value := range params { 49 | q.Set(key, value) 50 | } 51 | u.RawQuery = q.Encode() 52 | 53 | // Validate the final URL after encoding to catch any encoding issues 54 | finalURL := u.String() 55 | if strings.Contains(finalURL, "%") { 56 | return errors.New("URL encoding produced unsafe characters") 57 | } 58 | 59 | if err := validate(finalURL, true); err != nil { 60 | return err 61 | } 62 | 63 | return openBrowser(ctx, finalURL) 64 | } 65 | 66 | // ValidateURL performs strict security validation on a URL. 67 | func ValidateURL(rawURL string) error { 68 | return validate(rawURL, false) 69 | } 70 | 71 | // ValidateGitHubPRURL validates URLs matching https://github.com/{owner}/{repo}/pull/{number}[?goose=value] 72 | func ValidateGitHubPRURL(rawURL string) error { 73 | if err := validate(rawURL, true); err != nil { 74 | return err 75 | } 76 | 77 | // Additional GitHub PR-specific checks 78 | u, err := url.Parse(rawURL) 79 | if err != nil { 80 | // Should never happen since we already validated, but check anyway 81 | return fmt.Errorf("parse url: %w", err) 82 | } 83 | if u.Host != "github.com" { 84 | return errors.New("must be github.com") 85 | } 86 | 87 | parts := strings.Split(strings.Trim(u.Path, "/"), "/") 88 | if len(parts) != 4 || parts[2] != "pull" { 89 | return errors.New("must match format: /{owner}/{repo}/pull/{number}") 90 | } 91 | 92 | // Validate PR number (must start with 1-9) 93 | if parts[3] == "" || parts[3][0] < '1' || parts[3][0] > '9' { 94 | return errors.New("PR number must start with 1-9") 95 | } 96 | for _, c := range parts[3] { 97 | if c < '0' || c > '9' { 98 | return errors.New("PR number must be digits only") 99 | } 100 | } 101 | 102 | // If query params exist, only allow ?goose= (no other params or & characters) 103 | if u.RawQuery != "" { 104 | if !strings.HasPrefix(u.RawQuery, "goose=") || strings.Contains(u.RawQuery, "&") { 105 | return errors.New("only ?goose= query parameter allowed") 106 | } 107 | } 108 | 109 | return nil 110 | } 111 | 112 | // validate performs the core validation logic. 113 | func validate(rawURL string, allowParams bool) error { 114 | if rawURL == "" { 115 | return errors.New("URL cannot be empty") 116 | } 117 | 118 | if len(rawURL) > maxURLLength { 119 | return fmt.Errorf("URL exceeds maximum length of %d", maxURLLength) 120 | } 121 | 122 | // Check every character 123 | for i, r := range rawURL { 124 | if r < 0x20 || r == 0x7F || r > 127 { 125 | return fmt.Errorf("invalid character at position %d", i) 126 | } 127 | if r == '%' { 128 | return errors.New("percent-encoding not allowed") 129 | } 130 | } 131 | 132 | u, err := url.Parse(rawURL) 133 | if err != nil { 134 | return fmt.Errorf("invalid URL: %w", err) 135 | } 136 | 137 | if u.Scheme != "https" { 138 | return errors.New("must use HTTPS") 139 | } 140 | 141 | if u.User != nil { 142 | return errors.New("user info not allowed") 143 | } 144 | 145 | if u.Fragment != "" { 146 | return errors.New("fragments (#) not allowed") 147 | } 148 | 149 | // Reject custom ports 150 | if u.Port() != "" { 151 | return errors.New("custom ports not allowed") 152 | } 153 | 154 | if !allowParams && u.RawQuery != "" { 155 | return errors.New("query parameters not allowed (use OpenWithParams)") 156 | } 157 | 158 | // Normalize host to lowercase 159 | u.Host = strings.ToLower(u.Host) 160 | 161 | // Validate host and path contain only safe characters 162 | if err := validateSafeChars(u.Host); err != nil { 163 | return fmt.Errorf("invalid host: %w", err) 164 | } 165 | 166 | if err := validateSafeChars(u.Path); err != nil { 167 | return fmt.Errorf("invalid path: %w", err) 168 | } 169 | 170 | // Check for path traversal 171 | if strings.Contains(u.Path, "..") { 172 | return errors.New("path traversal (..) not allowed") 173 | } 174 | 175 | if strings.Contains(u.Path, "//") { 176 | return errors.New("empty path segments (//) not allowed") 177 | } 178 | 179 | return nil 180 | } 181 | 182 | // validateSafeChars checks that a string contains only alphanumeric, dash, underscore, dot, slash. 183 | func validateSafeChars(s string) error { 184 | for _, r := range s { 185 | if !isSafe(r) { 186 | return fmt.Errorf("unsafe character %q", r) 187 | } 188 | } 189 | return nil 190 | } 191 | 192 | // isSafe returns true if r is an allowed character in host/path. 193 | // Specifically excludes colon to prevent port/scheme confusion. 194 | func isSafe(r rune) bool { 195 | return (r >= 'a' && r <= 'z') || 196 | (r >= 'A' && r <= 'Z') || 197 | (r >= '0' && r <= '9') || 198 | r == '-' || r == '_' || r == '.' || r == '/' 199 | } 200 | 201 | // validateParamString validates query parameter keys and values. 202 | func validateParamString(s string) error { 203 | if s == "" { 204 | return errors.New("cannot be empty") 205 | } 206 | for _, r := range s { 207 | if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '-' && r != '_' { 208 | return fmt.Errorf("contains invalid character %q", r) 209 | } 210 | } 211 | return nil 212 | } 213 | 214 | // openBrowser opens a URL in the system browser. 215 | func openBrowser(ctx context.Context, rawURL string) error { 216 | var cmd *exec.Cmd 217 | 218 | switch runtime.GOOS { 219 | case "darwin": 220 | cmd = exec.CommandContext(ctx, "/usr/bin/open", "-u", rawURL) 221 | case "windows": 222 | cmd = exec.CommandContext(ctx, "rundll32.exe", "url.dll,FileProtocolHandler", rawURL) 223 | default: 224 | xdgOpen, err := findXDGOpen() 225 | if err != nil { 226 | return err 227 | } 228 | cmd = exec.CommandContext(ctx, xdgOpen, rawURL) 229 | } 230 | 231 | return cmd.Start() 232 | } 233 | 234 | // findXDGOpen locates xdg-open on Unix systems. 235 | func findXDGOpen() (string, error) { 236 | if path, err := exec.LookPath("xdg-open"); err == nil { 237 | return path, nil 238 | } 239 | 240 | for _, path := range []string{ 241 | "/usr/local/bin/xdg-open", 242 | "/usr/bin/xdg-open", 243 | "/usr/pkg/bin/xdg-open", 244 | "/opt/local/bin/xdg-open", 245 | } { 246 | if _, err := exec.LookPath(path); err == nil { 247 | return path, nil 248 | } 249 | } 250 | 251 | return "", errors.New("xdg-open not found") 252 | } 253 | -------------------------------------------------------------------------------- /cmd/review-goose/x11tray/tray_unix.go: -------------------------------------------------------------------------------- 1 | //go:build linux || freebsd || openbsd || netbsd || dragonfly || solaris || illumos || aix 2 | 3 | // Package x11tray provides system tray functionality for Unix platforms. 4 | // It handles StatusNotifierItem integration via DBus and manages the snixembed proxy 5 | // for compatibility with legacy X11 system trays. 6 | package x11tray 7 | 8 | import ( 9 | "context" 10 | "errors" 11 | "fmt" 12 | "log/slog" 13 | "os" 14 | "os/exec" 15 | "strings" 16 | "time" 17 | 18 | "github.com/godbus/dbus/v5" 19 | ) 20 | 21 | const ( 22 | statusNotifierWatcher = "org.kde.StatusNotifierWatcher" 23 | statusNotifierWatcherPath = "/StatusNotifierWatcher" 24 | ) 25 | 26 | // HealthCheck verifies that a system tray implementation is available via D-Bus. 27 | // It checks for the KDE StatusNotifierWatcher service which is required for 28 | // system tray icons on modern Linux desktops. 29 | // 30 | // Returns nil if a tray is available, or an error describing the issue. 31 | func HealthCheck() error { 32 | conn, err := dbus.ConnectSessionBus() 33 | if err != nil { 34 | return fmt.Errorf("failed to connect to D-Bus session bus: %w", err) 35 | } 36 | defer func() { 37 | if err := conn.Close(); err != nil { 38 | slog.Debug("[X11TRAY] Failed to close DBus connection", "error", err) 39 | } 40 | }() 41 | 42 | // Check if the StatusNotifierWatcher service exists 43 | var names []string 44 | err = conn.BusObject().Call("org.freedesktop.DBus.ListNames", 0).Store(&names) 45 | if err != nil { 46 | return fmt.Errorf("failed to query D-Bus services: %w", err) 47 | } 48 | 49 | for _, name := range names { 50 | if name == statusNotifierWatcher { 51 | slog.Debug("[X11TRAY] StatusNotifierWatcher found", "service", statusNotifierWatcher) 52 | return nil 53 | } 54 | } 55 | 56 | return fmt.Errorf("no system tray found: %s service not available", statusNotifierWatcher) 57 | } 58 | 59 | // ProxyProcess represents a running snixembed background process. 60 | type ProxyProcess struct { 61 | cmd *exec.Cmd 62 | cancel context.CancelFunc 63 | } 64 | 65 | // Stop terminates the proxy process gracefully. 66 | func (p *ProxyProcess) Stop() error { 67 | if p.cancel != nil { 68 | p.cancel() 69 | } 70 | if p.cmd != nil && p.cmd.Process != nil { 71 | return p.cmd.Process.Kill() 72 | } 73 | return nil 74 | } 75 | 76 | // TryProxy attempts to start snixembed as a system tray proxy service. 77 | // snixembed bridges legacy X11 system trays to modern StatusNotifier-based trays. 78 | // 79 | // If successful, returns a ProxyProcess that should be stopped when the application exits. 80 | // Returns an error if snixembed is not found or fails to start successfully. 81 | func TryProxy(ctx context.Context) (*ProxyProcess, error) { 82 | // Check if snixembed is available 83 | snixembedPath, err := exec.LookPath("snixembed") 84 | if err != nil { 85 | return nil, errors.New( 86 | "snixembed not found in PATH: install it with your package manager " + 87 | "(e.g., 'apt install snixembed' or 'yay -S snixembed')") 88 | } 89 | 90 | slog.Info("[X11TRAY] Starting snixembed proxy", "path", snixembedPath) 91 | 92 | // Create a cancellable context for the proxy process 93 | proxyCtx, cancel := context.WithCancel(ctx) 94 | 95 | // Start snixembed in the background 96 | cmd := exec.CommandContext(proxyCtx, snixembedPath) 97 | 98 | // Capture output for debugging 99 | if err := cmd.Start(); err != nil { 100 | cancel() 101 | return nil, fmt.Errorf("failed to start snixembed: %w", err) 102 | } 103 | 104 | proxy := &ProxyProcess{ 105 | cmd: cmd, 106 | cancel: cancel, 107 | } 108 | 109 | // Give snixembed time to register with D-Bus 110 | // This is necessary because the service takes a moment to become available 111 | time.Sleep(500 * time.Millisecond) 112 | 113 | // Verify that the proxy worked by checking again 114 | if err := HealthCheck(); err != nil { 115 | // snixembed started but didn't fix the problem 116 | if stopErr := proxy.Stop(); stopErr != nil { 117 | slog.Debug("[X11TRAY] Failed to stop proxy after failed health check", "error", stopErr) 118 | } 119 | return nil, fmt.Errorf("snixembed started but system tray still unavailable: %w", err) 120 | } 121 | 122 | slog.Info("[X11TRAY] snixembed proxy started successfully") 123 | return proxy, nil 124 | } 125 | 126 | // EnsureTray checks for system tray availability and attempts to start a proxy if needed. 127 | // This is a convenience function that combines HealthCheck and TryProxy. 128 | // 129 | // Returns a ProxyProcess if one was started (caller must Stop() it on exit), or nil if 130 | // the native tray was available. Returns an error if no tray solution could be found. 131 | func EnsureTray(ctx context.Context) (*ProxyProcess, error) { 132 | // First, check if we already have a working tray 133 | if err := HealthCheck(); err == nil { 134 | slog.Debug("[X11TRAY] Native system tray available") 135 | // No proxy needed (nil) and no error (nil) - native tray is working 136 | return nil, nil //nolint:nilnil // nil proxy is valid when native tray exists 137 | } 138 | 139 | slog.Warn("[X11TRAY] No native system tray found, attempting to start proxy") 140 | 141 | // Try to start the proxy 142 | proxy, err := TryProxy(ctx) 143 | if err != nil { 144 | return nil, fmt.Errorf("system tray unavailable and proxy failed: %w", err) 145 | } 146 | 147 | return proxy, nil 148 | } 149 | 150 | // ShowContextMenu triggers the context menu via DBus on Unix platforms. 151 | // On Linux/FreeBSD with StatusNotifierItem, the menu parameter in click handlers is nil, 152 | // so we need to manually call the ContextMenu method via DBus to show the menu. 153 | func ShowContextMenu() { 154 | conn, err := dbus.ConnectSessionBus() 155 | if err != nil { 156 | slog.Warn("[X11TRAY] Failed to connect to session bus", "error", err) 157 | return 158 | } 159 | defer func() { 160 | if err := conn.Close(); err != nil { 161 | slog.Debug("[X11TRAY] Failed to close DBus connection", "error", err) 162 | } 163 | }() 164 | 165 | // Find our StatusNotifierItem service 166 | var names []string 167 | err = conn.BusObject().Call("org.freedesktop.DBus.ListNames", 0).Store(&names) 168 | if err != nil { 169 | slog.Warn("[X11TRAY] Failed to list DBus names", "error", err) 170 | return 171 | } 172 | 173 | // Find the StatusNotifierItem service for this process 174 | var serviceName string 175 | pid := os.Getpid() 176 | expectedPrefix := fmt.Sprintf("org.kde.StatusNotifierItem-%d-", pid) 177 | for _, name := range names { 178 | if strings.HasPrefix(name, expectedPrefix) { 179 | serviceName = name 180 | break 181 | } 182 | } 183 | 184 | if serviceName == "" { 185 | slog.Warn("[X11TRAY] StatusNotifierItem service not found", "pid", pid, "expectedPrefix", expectedPrefix) 186 | return 187 | } 188 | 189 | slog.Info("[X11TRAY] Attempting to trigger context menu", "service", serviceName) 190 | 191 | // Try different methods to trigger the menu display 192 | obj := conn.Object(serviceName, "/StatusNotifierItem") 193 | 194 | // First try: Call ContextMenu method (standard StatusNotifierItem) 195 | call := obj.Call("org.kde.StatusNotifierItem.ContextMenu", 0, int32(0), int32(0)) 196 | if call.Err != nil { 197 | slog.Info("[X11TRAY] ContextMenu method failed, trying SecondaryActivate", "error", call.Err) 198 | 199 | // Second try: Call SecondaryActivate (right-click equivalent) 200 | call = obj.Call("org.kde.StatusNotifierItem.SecondaryActivate", 0, int32(0), int32(0)) 201 | if call.Err != nil { 202 | slog.Warn("[X11TRAY] Both ContextMenu and SecondaryActivate failed", "contextMenuErr", call.Err) 203 | slog.Info("[X11TRAY] Note: Menu should still work with right-click via snixembed") 204 | return 205 | } 206 | slog.Info("[X11TRAY] Successfully triggered SecondaryActivate") 207 | return 208 | } 209 | 210 | slog.Info("[X11TRAY] Successfully triggered ContextMenu") 211 | } 212 | -------------------------------------------------------------------------------- /cmd/review-goose/menu_change_detection_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "slices" 5 | "sync" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | // TestMenuChangeDetection tests that the menu change detection logic works correctly 11 | // and prevents unnecessary menu rebuilds when PR data hasn't changed. 12 | func TestMenuChangeDetection(t *testing.T) { 13 | // Create app with test data 14 | app := &App{ 15 | mu: sync.RWMutex{}, 16 | stateManager: NewPRStateManager(time.Now()), 17 | hiddenOrgs: make(map[string]bool), 18 | seenOrgs: make(map[string]bool), 19 | blockedPRTimes: make(map[string]time.Time), 20 | browserRateLimiter: NewBrowserRateLimiter(startupGracePeriod, 5, defaultMaxBrowserOpensDay), 21 | systrayInterface: &MockSystray{}, 22 | incoming: []PR{ 23 | {Repository: "org1/repo1", Number: 1, Title: "Fix bug", URL: "https://github.com/org1/repo1/pull/1", NeedsReview: true, UpdatedAt: time.Now()}, 24 | {Repository: "org2/repo2", Number: 2, Title: "Add feature", URL: "https://github.com/org2/repo2/pull/2", NeedsReview: false, UpdatedAt: time.Now()}, 25 | }, 26 | outgoing: []PR{ 27 | {Repository: "org3/repo3", Number: 3, Title: "Update docs", URL: "https://github.com/org3/repo3/pull/3", IsBlocked: true, UpdatedAt: time.Now()}, 28 | }, 29 | } 30 | 31 | t.Run("same_titles_should_be_equal", func(t *testing.T) { 32 | // Generate titles twice with same data 33 | titles1 := app.generateMenuTitles() 34 | titles2 := app.generateMenuTitles() 35 | 36 | // They should be equal 37 | if !slices.Equal(titles1, titles2) { 38 | t.Errorf("Same PR data generated different titles:\nFirst: %v\nSecond: %v", titles1, titles2) 39 | } 40 | }) 41 | 42 | t.Run("different_pr_count_changes_titles", func(t *testing.T) { 43 | // Generate initial titles 44 | initialTitles := app.generateMenuTitles() 45 | 46 | // Add a new PR 47 | app.incoming = append(app.incoming, PR{ 48 | Repository: "org4/repo4", 49 | Number: 4, 50 | Title: "New PR", 51 | URL: "https://github.com/org4/repo4/pull/4", 52 | NeedsReview: true, 53 | UpdatedAt: time.Now(), 54 | }) 55 | 56 | // Generate new titles 57 | newTitles := app.generateMenuTitles() 58 | 59 | // They should be different 60 | if slices.Equal(initialTitles, newTitles) { 61 | t.Error("Adding a PR didn't change the menu titles") 62 | } 63 | 64 | // The new titles should have more items 65 | if len(newTitles) <= len(initialTitles) { 66 | t.Errorf("New titles should have more items: got %d, initial had %d", len(newTitles), len(initialTitles)) 67 | } 68 | }) 69 | 70 | t.Run("pr_repository_change_updates_menu", func(t *testing.T) { 71 | // Generate initial titles 72 | initialTitles := app.generateMenuTitles() 73 | 74 | // Change a PR repository (this would be unusual but tests the title generation) 75 | app.incoming[0].Repository = "different-org/different-repo" 76 | 77 | // Generate new titles 78 | newTitles := app.generateMenuTitles() 79 | 80 | // They should be different because menu shows "org/repo #number" 81 | if slices.Equal(initialTitles, newTitles) { 82 | t.Error("Changing a PR repository didn't change the menu titles") 83 | } 84 | }) 85 | 86 | t.Run("blocked_status_change_updates_menu", func(t *testing.T) { 87 | // Generate initial titles 88 | initialTitles := app.generateMenuTitles() 89 | 90 | // Change blocked status 91 | app.incoming[1].NeedsReview = true // Make it blocked 92 | 93 | // Generate new titles 94 | newTitles := app.generateMenuTitles() 95 | 96 | // They should be different because the title prefix changes for blocked PRs 97 | if slices.Equal(initialTitles, newTitles) { 98 | t.Error("Changing PR blocked status didn't change the menu titles") 99 | } 100 | }) 101 | } 102 | 103 | // TestFirstRunMenuRebuildBug tests the specific bug where the first scheduled update 104 | // after initial load would unnecessarily rebuild the menu. 105 | func TestFirstRunMenuRebuildBug(t *testing.T) { 106 | // Create app simulating initial state 107 | app := &App{ 108 | mu: sync.RWMutex{}, 109 | stateManager: NewPRStateManager(time.Now()), 110 | hiddenOrgs: make(map[string]bool), 111 | seenOrgs: make(map[string]bool), 112 | blockedPRTimes: make(map[string]time.Time), 113 | browserRateLimiter: NewBrowserRateLimiter(startupGracePeriod, 5, defaultMaxBrowserOpensDay), 114 | menuInitialized: false, 115 | systrayInterface: &MockSystray{}, 116 | lastMenuTitles: nil, // This is nil on first run - the bug condition 117 | incoming: []PR{ 118 | {Repository: "test/repo", Number: 1, Title: "Test PR", URL: "https://github.com/test/repo/pull/1"}, 119 | }, 120 | } 121 | 122 | // Simulate what happens during initial load 123 | // OLD BEHAVIOR: lastMenuTitles would remain nil 124 | // NEW BEHAVIOR: lastMenuTitles should be set after initial menu build 125 | 126 | // Generate initial titles (simulating what rebuildMenu would show) 127 | initialTitles := app.generateMenuTitles() 128 | 129 | // This is the fix - store titles after initial build 130 | app.mu.Lock() 131 | app.lastMenuTitles = initialTitles 132 | app.menuInitialized = true 133 | app.mu.Unlock() 134 | 135 | // Now simulate first scheduled update with same data 136 | // Generate current titles (should be same as initial) 137 | currentTitles := app.generateMenuTitles() 138 | 139 | // Get stored titles 140 | app.mu.RLock() 141 | storedTitles := app.lastMenuTitles 142 | app.mu.RUnlock() 143 | 144 | // Test 1: Stored titles should not be nil/empty 145 | if len(storedTitles) == 0 { 146 | t.Fatal("BUG: lastMenuTitles not set after initial menu build") 147 | } 148 | 149 | // Test 2: Current and stored titles should be equal (no changes) 150 | if !slices.Equal(currentTitles, storedTitles) { 151 | t.Errorf("BUG: Titles marked as different when they're the same:\nCurrent: %v\nStored: %v", 152 | currentTitles, storedTitles) 153 | } 154 | 155 | // Test 3: Verify the comparison result that updateMenu would make 156 | // In the bug, this would be comparing non-empty current titles with nil/empty stored titles 157 | // and would return false (different), triggering unnecessary rebuild 158 | shouldSkipRebuild := slices.Equal(currentTitles, storedTitles) 159 | if !shouldSkipRebuild { 160 | t.Error("BUG: Would rebuild menu even though PR data hasn't changed") 161 | } 162 | } 163 | 164 | // TestHiddenOrgChangesMenu tests that hiding/showing orgs updates menu titles 165 | func TestHiddenOrgChangesMenu(t *testing.T) { 166 | app := &App{ 167 | mu: sync.RWMutex{}, 168 | stateManager: NewPRStateManager(time.Now()), 169 | hiddenOrgs: make(map[string]bool), 170 | seenOrgs: make(map[string]bool), 171 | blockedPRTimes: make(map[string]time.Time), 172 | browserRateLimiter: NewBrowserRateLimiter(startupGracePeriod, 5, defaultMaxBrowserOpensDay), 173 | systrayInterface: &MockSystray{}, 174 | incoming: []PR{ 175 | {Repository: "org1/repo1", Number: 1, Title: "PR 1", URL: "https://github.com/org1/repo1/pull/1"}, 176 | {Repository: "org2/repo2", Number: 2, Title: "PR 2", URL: "https://github.com/org2/repo2/pull/2"}, 177 | }, 178 | } 179 | 180 | // Generate initial titles 181 | initialTitles := app.generateMenuTitles() 182 | initialCount := len(initialTitles) 183 | 184 | // Hide org1 185 | app.hiddenOrgs["org1"] = true 186 | 187 | // Generate new titles - should have fewer items 188 | newTitles := app.generateMenuTitles() 189 | 190 | // Titles should be different 191 | if slices.Equal(initialTitles, newTitles) { 192 | t.Error("Hiding an org didn't change menu titles") 193 | } 194 | 195 | // Should have fewer items (org1/repo1 should be hidden) 196 | if len(newTitles) >= initialCount { 197 | t.Errorf("Menu should have fewer items after hiding org: got %d, started with %d", 198 | len(newTitles), initialCount) 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /cmd/review-goose/pr_state.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log/slog" 5 | "maps" 6 | "sync" 7 | "time" 8 | ) 9 | 10 | // PRState tracks the complete state of a PR including blocking history. 11 | type PRState struct { 12 | FirstBlockedAt time.Time 13 | LastSeenBlocked time.Time 14 | PR PR 15 | HasNotified bool 16 | IsInitialDiscovery bool // True if this PR was discovered as already blocked during startup 17 | } 18 | 19 | // PRStateManager manages all PR states with proper synchronization. 20 | type PRStateManager struct { 21 | startTime time.Time 22 | states map[string]*PRState 23 | gracePeriodSeconds int 24 | mu sync.RWMutex 25 | } 26 | 27 | // NewPRStateManager creates a new PR state manager. 28 | func NewPRStateManager(startTime time.Time) *PRStateManager { 29 | return &PRStateManager{ 30 | states: make(map[string]*PRState), 31 | startTime: startTime, 32 | gracePeriodSeconds: 30, 33 | } 34 | } 35 | 36 | // UpdatePRs updates the state with new PR data and returns which PRs need notifications. 37 | // This function is thread-safe and handles all state transitions atomically. 38 | // isInitialDiscovery should be true only on the very first poll to prevent notifications for already-blocked PRs. 39 | func (m *PRStateManager) UpdatePRs(incoming, outgoing []PR, hiddenOrgs map[string]bool, isInitialDiscovery bool) (toNotify []PR) { 40 | m.mu.Lock() 41 | defer m.mu.Unlock() 42 | 43 | now := time.Now() 44 | inGracePeriod := time.Since(m.startTime) < time.Duration(m.gracePeriodSeconds)*time.Second 45 | 46 | slog.Debug("[STATE] UpdatePRs called", 47 | "incoming", len(incoming), "outgoing", len(outgoing), 48 | "existing_states", len(m.states), "in_grace_period", inGracePeriod, "is_initial_discovery", isInitialDiscovery) 49 | 50 | // Track which PRs are currently blocked 51 | currentlyBlocked := make(map[string]bool) 52 | 53 | // Process all PRs (both incoming and outgoing) 54 | allPRs := make([]PR, 0, len(incoming)+len(outgoing)) 55 | allPRs = append(allPRs, incoming...) 56 | allPRs = append(allPRs, outgoing...) 57 | 58 | for i := range allPRs { 59 | pr := allPRs[i] 60 | // Skip hidden orgs 61 | org := extractOrgFromRepo(pr.Repository) 62 | if org != "" && hiddenOrgs[org] { 63 | continue 64 | } 65 | 66 | // Check if PR is blocked 67 | isBlocked := pr.NeedsReview || pr.IsBlocked 68 | if !isBlocked { 69 | // PR is not blocked - remove from tracking if it was 70 | if state, exists := m.states[pr.URL]; exists && state != nil { 71 | slog.Info("[STATE] State transition: blocked -> unblocked", 72 | "repo", pr.Repository, "number", pr.Number, "url", pr.URL, 73 | "was_blocked_since", state.FirstBlockedAt.Format(time.RFC3339), 74 | "blocked_duration", time.Since(state.FirstBlockedAt).Round(time.Second)) 75 | delete(m.states, pr.URL) 76 | } 77 | continue 78 | } 79 | 80 | currentlyBlocked[pr.URL] = true 81 | 82 | // Get or create state for this PR 83 | state, exists := m.states[pr.URL] 84 | if !exists { 85 | // This PR was not in our state before 86 | if isInitialDiscovery { 87 | // Initial discovery: PR was already blocked when we started, no state transition 88 | state = &PRState{ 89 | PR: pr, 90 | FirstBlockedAt: now, 91 | LastSeenBlocked: now, 92 | HasNotified: false, // Don't consider this as notified since no actual notification was sent 93 | IsInitialDiscovery: true, // Mark as initial discovery to prevent notifications and party poppers 94 | } 95 | m.states[pr.URL] = state 96 | 97 | slog.Info("[STATE] Initial discovery: already blocked PR", 98 | "repo", pr.Repository, 99 | "number", pr.Number, 100 | "url", pr.URL, 101 | "pr_updated_at", pr.UpdatedAt.Format(time.RFC3339), 102 | "firstBlockedAt", state.FirstBlockedAt.Format(time.RFC3339)) 103 | } else { 104 | // Actual state transition: unblocked -> blocked 105 | state = &PRState{ 106 | PR: pr, 107 | FirstBlockedAt: now, 108 | LastSeenBlocked: now, 109 | HasNotified: false, 110 | IsInitialDiscovery: false, // This is a real state transition 111 | } 112 | m.states[pr.URL] = state 113 | 114 | slog.Info("[STATE] State transition: unblocked -> blocked", 115 | "repo", pr.Repository, 116 | "number", pr.Number, 117 | "url", pr.URL, 118 | "pr_updated_at", pr.UpdatedAt.Format(time.RFC3339), 119 | "firstBlockedAt", state.FirstBlockedAt.Format(time.RFC3339), 120 | "inGracePeriod", inGracePeriod) 121 | 122 | // Should we notify for actual state transitions? 123 | if !inGracePeriod && !state.HasNotified { 124 | slog.Debug("[STATE] Will notify for newly blocked PR", "repo", pr.Repository, "number", pr.Number) 125 | toNotify = append(toNotify, pr) 126 | state.HasNotified = true 127 | } else if inGracePeriod { 128 | slog.Debug("[STATE] In grace period, not notifying", "repo", pr.Repository, "number", pr.Number) 129 | } 130 | } 131 | } else { 132 | // PR was already blocked in our state - just update data, preserve FirstBlockedAt 133 | originalFirstBlocked := state.FirstBlockedAt 134 | state.LastSeenBlocked = now 135 | state.PR = pr // Update PR data 136 | 137 | slog.Debug("[STATE] State transition: blocked -> blocked (no change)", 138 | "repo", pr.Repository, "number", pr.Number, "url", pr.URL, 139 | "original_first_blocked", originalFirstBlocked.Format(time.RFC3339), 140 | "time_since_first_blocked", time.Since(originalFirstBlocked).Round(time.Second), 141 | "has_notified", state.HasNotified) 142 | 143 | // If we haven't notified yet and we're past grace period, notify now 144 | // But don't notify for initial discovery PRs 145 | if !state.HasNotified && !inGracePeriod && !state.IsInitialDiscovery { 146 | slog.Info("[STATE] Past grace period, notifying for previously blocked PR", 147 | "repo", pr.Repository, "number", pr.Number) 148 | toNotify = append(toNotify, pr) 149 | state.HasNotified = true 150 | } 151 | } 152 | } 153 | 154 | // Clean up states for PRs that are no longer in our lists 155 | // Add more conservative cleanup with logging 156 | removedCount := 0 157 | for url, state := range m.states { 158 | if !currentlyBlocked[url] { 159 | timeSinceLastSeen := time.Since(state.LastSeenBlocked) 160 | slog.Info("[STATE] Removing stale PR state (no longer blocked)", 161 | "url", url, "repo", state.PR.Repository, "number", state.PR.Number, 162 | "first_blocked_at", state.FirstBlockedAt.Format(time.RFC3339), 163 | "last_seen_blocked", state.LastSeenBlocked.Format(time.RFC3339), 164 | "time_since_last_seen", timeSinceLastSeen.Round(time.Second), 165 | "was_notified", state.HasNotified) 166 | delete(m.states, url) 167 | removedCount++ 168 | } 169 | } 170 | 171 | if removedCount > 0 { 172 | slog.Info("[STATE] State cleanup completed", "removed_states", removedCount, "remaining_states", len(m.states)) 173 | } 174 | 175 | return toNotify 176 | } 177 | 178 | // BlockedPRs returns all currently blocked PRs with their states. 179 | func (m *PRStateManager) BlockedPRs() map[string]*PRState { 180 | m.mu.RLock() 181 | defer m.mu.RUnlock() 182 | 183 | result := make(map[string]*PRState) 184 | maps.Copy(result, m.states) 185 | return result 186 | } 187 | 188 | // PRState returns the state for a specific PR. 189 | func (m *PRStateManager) PRState(url string) (*PRState, bool) { 190 | m.mu.RLock() 191 | defer m.mu.RUnlock() 192 | 193 | state, exists := m.states[url] 194 | return state, exists 195 | } 196 | 197 | // ResetNotifications resets the notification flag for all PRs (useful for testing). 198 | func (m *PRStateManager) ResetNotifications() { 199 | m.mu.Lock() 200 | defer m.mu.Unlock() 201 | 202 | for _, state := range m.states { 203 | state.HasNotified = false 204 | } 205 | slog.Info("[STATE] Reset notification flags", "prCount", len(m.states)) 206 | } 207 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | ## Golden config for golangci-lint - strict, but within the realm of what Go authors might use. 2 | # 3 | # This is tied to the version of golangci-lint listed in the Makefile, usage with other 4 | # versions of golangci-lint will yield errors and/or false positives. 5 | # 6 | # Docs: https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml 7 | # Based heavily on https://gist.github.com/maratori/47a4d00457a92aa426dbd48a18776322 8 | 9 | version: "2" 10 | 11 | issues: 12 | # Maximum issues count per one linter. Set to 0 to disable. Default is 50. 13 | max-issues-per-linter: 0 14 | # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. 15 | max-same-issues: 0 16 | 17 | formatters: 18 | enable: 19 | # - gci 20 | # - gofmt 21 | - gofumpt 22 | # - goimports 23 | # - golines 24 | - swaggo 25 | 26 | settings: 27 | golines: 28 | # Default: 100 29 | max-len: 120 30 | 31 | linters: 32 | default: all 33 | disable: 34 | # linters that give advice contrary to what the Go authors advise 35 | - decorder # checks declaration order and count of types, constants, variables and functions 36 | - dupword # [useless without config] checks for duplicate words in the source code 37 | - exhaustruct # [highly recommend to enable] checks if all structure fields are initialized 38 | - forcetypeassert # [replaced by errcheck] finds forced type assertions 39 | - ginkgolinter # [if you use ginkgo/gomega] enforces standards of using ginkgo and gomega 40 | - gochecknoglobals # checks that no global variables exist 41 | - cyclop # replaced by revive 42 | - gocyclo # replaced by revive 43 | - forbidigo # needs configuration to be useful 44 | - funlen # replaced by revive 45 | - godox # TODO's are OK 46 | - ireturn # It's OK 47 | - musttag 48 | - nonamedreturns 49 | - goconst # finds repeated strings that could be replaced by a constant 50 | - goheader # checks is file header matches to pattern 51 | - gomodguard # [use more powerful depguard] allow and block lists linter for direct Go module dependencies 52 | - gomoddirectives 53 | - err113 # bad advice about dynamic errors 54 | - lll # [replaced by golines] reports long lines 55 | - mnd # detects magic numbers, duplicated by revive 56 | - nlreturn # [too strict and mostly code is not more readable] checks for a new line before return and branch statements to increase code clarity 57 | - noinlineerr # disallows inline error handling `if err := ...; err != nil {` 58 | - prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated 59 | - tagliatelle # needs configuration 60 | - testableexamples # checks if examples are testable (have an expected output) 61 | - testpackage # makes you use a separate _test package 62 | - paralleltest # not every test should be in parallel 63 | - wrapcheck # not required 64 | - wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines 65 | - wsl_v5 # [too strict and mostly code is not more readable] add or remove empty lines 66 | - zerologlint # detects the wrong usage of zerolog that a user forgets to dispatch zerolog.Event 67 | 68 | # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml 69 | settings: 70 | depguard: 71 | rules: 72 | "deprecated": 73 | files: 74 | - "$all" 75 | deny: 76 | - pkg: github.com/golang/protobuf 77 | desc: Use google.golang.org/protobuf instead, see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules 78 | - pkg: github.com/satori/go.uuid 79 | desc: Use github.com/google/uuid instead, satori's package is not maintained 80 | - pkg: github.com/gofrs/uuid$ 81 | desc: Use github.com/gofrs/uuid/v5 or later, it was not a go module before v5 82 | "non-test files": 83 | files: 84 | - "!$test" 85 | deny: 86 | - pkg: math/rand$ 87 | desc: Use math/rand/v2 instead, see https://go.dev/blog/randv2 88 | - pkg: "github.com/sirupsen/logrus" 89 | desc: not allowed 90 | - pkg: "github.com/pkg/errors" 91 | desc: Should be replaced by standard lib errors package 92 | 93 | dupl: 94 | # token count (default: 150) 95 | threshold: 300 96 | 97 | embeddedstructfieldcheck: 98 | # Checks that sync.Mutex and sync.RWMutex are not used as embedded fields. 99 | forbid-mutex: true 100 | 101 | errcheck: 102 | # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. 103 | check-type-assertions: true 104 | check-blank: true 105 | 106 | exhaustive: 107 | # Program elements to check for exhaustiveness. 108 | # Default: [ switch ] 109 | check: 110 | - switch 111 | - map 112 | default-signifies-exhaustive: true 113 | 114 | fatcontext: 115 | # Check for potential fat contexts in struct pointers. 116 | # May generate false positives. 117 | # Default: false 118 | check-struct-pointers: true 119 | 120 | funcorder: 121 | # Checks if the exported methods of a structure are placed before the non-exported ones. 122 | struct-method: false 123 | 124 | gocognit: 125 | min-complexity: 55 126 | 127 | gocritic: 128 | enable-all: true 129 | disabled-checks: 130 | - paramTypeCombine 131 | # The list of supported checkers can be found at https://go-critic.com/overview. 132 | settings: 133 | captLocal: 134 | # Whether to restrict checker to params only. 135 | paramsOnly: false 136 | underef: 137 | # Whether to skip (*x).method() calls where x is a pointer receiver. 138 | skipRecvDeref: false 139 | hugeParam: 140 | # Default: 80 141 | sizeThreshold: 200 142 | 143 | govet: 144 | enable-all: true 145 | 146 | godot: 147 | scope: toplevel 148 | 149 | inamedparam: 150 | # Skips check for interface methods with only a single parameter. 151 | skip-single-param: true 152 | 153 | nakedret: 154 | # Default: 30 155 | max-func-lines: 7 156 | 157 | nestif: 158 | min-complexity: 15 159 | 160 | nolintlint: 161 | # Exclude following linters from requiring an explanation. 162 | # Default: [] 163 | allow-no-explanation: [funlen, gocognit, golines] 164 | # Enable to require an explanation of nonzero length after each nolint directive. 165 | require-explanation: true 166 | # Enable to require nolint directives to mention the specific linter being suppressed. 167 | require-specific: true 168 | 169 | revive: 170 | enable-all-rules: true 171 | rules: 172 | - name: add-constant 173 | severity: warning 174 | disabled: true 175 | - name: cognitive-complexity 176 | disabled: true # prefer maintidx 177 | - name: cyclomatic 178 | disabled: true # prefer maintidx 179 | - name: function-length 180 | arguments: [150, 225] 181 | - name: line-length-limit 182 | arguments: [150] 183 | - name: nested-structs 184 | disabled: true 185 | - name: max-public-structs 186 | arguments: [10] 187 | - name: flag-parameter # fixes are difficult 188 | disabled: true 189 | - name: bare-return 190 | disabled: true 191 | 192 | rowserrcheck: 193 | # database/sql is always checked. 194 | # Default: [] 195 | packages: 196 | - github.com/jmoiron/sqlx 197 | 198 | perfsprint: 199 | # optimize fmt.Sprintf("x: %s", y) into "x: " + y 200 | strconcat: false 201 | 202 | staticcheck: 203 | checks: 204 | - all 205 | 206 | usetesting: 207 | # Enable/disable `os.TempDir()` detections. 208 | # Default: false 209 | os-temp-dir: true 210 | 211 | varnamelen: 212 | max-distance: 75 213 | min-name-length: 2 214 | check-receivers: false 215 | ignore-names: 216 | - r 217 | - w 218 | - f 219 | - err 220 | 221 | exclusions: 222 | # Default: [] 223 | presets: 224 | - common-false-positives 225 | rules: 226 | # Allow "err" and "ok" vars to shadow existing declarations, otherwise we get too many false positives. 227 | - text: '^shadow: declaration of "(err|ok)" shadows declaration' 228 | linters: 229 | - govet 230 | - text: "parameter 'ctx' seems to be unused, consider removing or renaming it as _" 231 | linters: 232 | - revive 233 | - path: _test\.go 234 | linters: 235 | - dupl 236 | - gosec 237 | - godot 238 | - govet # alignment 239 | - noctx 240 | - perfsprint 241 | - revive 242 | - varnamelen 243 | -------------------------------------------------------------------------------- /cmd/review-goose/cache.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "encoding/json" 8 | "fmt" 9 | "log/slog" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | "time" 14 | 15 | "github.com/codeGROOVE-dev/goose/pkg/safebrowse" 16 | "github.com/codeGROOVE-dev/retry" 17 | "github.com/codeGROOVE-dev/turnclient/pkg/turn" 18 | ) 19 | 20 | type cacheEntry struct { 21 | Data *turn.CheckResponse `json:"data"` 22 | CachedAt time.Time `json:"cached_at"` 23 | UpdatedAt time.Time `json:"updated_at"` 24 | } 25 | 26 | // checkCache checks the cache for a PR and returns the cached data if valid. 27 | // Returns (cachedData, cacheHit, hasRunningTests). 28 | func (app *App) checkCache(cacheFile, url string, updatedAt time.Time) (cachedData *turn.CheckResponse, cacheHit bool, hasRunningTests bool) { 29 | fileData, err := os.ReadFile(cacheFile) 30 | if err != nil { 31 | if !os.IsNotExist(err) { 32 | slog.Debug("[CACHE] Cache file read error", "url", url, "error", err) 33 | } 34 | return nil, false, false 35 | } 36 | 37 | var entry cacheEntry 38 | if err := json.Unmarshal(fileData, &entry); err != nil { 39 | slog.Warn("Failed to unmarshal cache data", "url", url, "error", err) 40 | // Remove corrupted cache file 41 | if err := os.Remove(cacheFile); err != nil { 42 | slog.Error("Failed to remove corrupted cache file", "error", err) 43 | } 44 | return nil, false, false 45 | } 46 | 47 | // Determine TTL based on test state - use shorter TTL for incomplete tests 48 | testState := entry.Data.PullRequest.TestState 49 | isTestIncomplete := testState == "running" || testState == "queued" || testState == "pending" 50 | ttl := cacheTTL 51 | if isTestIncomplete { 52 | ttl = runningTestsCacheTTL 53 | } 54 | 55 | // Check if cache is expired or PR updated 56 | if time.Since(entry.CachedAt) >= ttl || !entry.UpdatedAt.Equal(updatedAt) { 57 | // Log why cache was invalid 58 | if !entry.UpdatedAt.Equal(updatedAt) { 59 | slog.Debug("[CACHE] Cache miss - PR updated", 60 | "url", url, 61 | "cached_pr_time", entry.UpdatedAt.Format(time.RFC3339), 62 | "current_pr_time", updatedAt.Format(time.RFC3339)) 63 | } else { 64 | slog.Debug("[CACHE] Cache miss - TTL expired", 65 | "url", url, 66 | "cached_at", entry.CachedAt.Format(time.RFC3339), 67 | "cache_age", time.Since(entry.CachedAt).Round(time.Second), 68 | "ttl", ttl, 69 | "test_state", testState) 70 | } 71 | return nil, false, isTestIncomplete 72 | } 73 | 74 | // Check for incomplete tests that should invalidate cache and trigger Turn API cache bypass 75 | cacheAge := time.Since(entry.CachedAt) 76 | if entry.Data != nil && isTestIncomplete && cacheAge < runningTestsCacheBypass { 77 | slog.Debug("[CACHE] Cache invalidated - tests incomplete and cache entry is fresh", 78 | "url", url, 79 | "test_state", testState, 80 | "cache_age", cacheAge.Round(time.Minute), 81 | "cached_at", entry.CachedAt.Format(time.RFC3339)) 82 | return nil, false, true 83 | } 84 | 85 | // Cache hit 86 | slog.Debug("[CACHE] Cache hit", 87 | "url", url, 88 | "cached_at", entry.CachedAt.Format(time.RFC3339), 89 | "cache_age", time.Since(entry.CachedAt).Round(time.Second), 90 | "pr_updated_at", entry.UpdatedAt.Format(time.RFC3339)) 91 | if app.healthMonitor != nil { 92 | app.healthMonitor.recordCacheAccess(true) 93 | } 94 | return entry.Data, true, false 95 | } 96 | 97 | // turnData fetches Turn API data with caching. 98 | func (app *App) turnData(ctx context.Context, url string, updatedAt time.Time) (*turn.CheckResponse, bool, error) { 99 | hasRunningTests := false 100 | // Validate URL before processing 101 | if err := safebrowse.ValidateURL(url); err != nil { 102 | return nil, false, fmt.Errorf("invalid URL: %w", err) 103 | } 104 | 105 | // Create cache key from URL and updated timestamp 106 | key := fmt.Sprintf("%s-%s", url, updatedAt.Format(time.RFC3339)) 107 | hash := sha256.Sum256([]byte(key)) 108 | cacheFile := filepath.Join(app.cacheDir, hex.EncodeToString(hash[:])[:16]+".json") 109 | 110 | // Log the cache key details 111 | slog.Debug("[CACHE] Checking cache", 112 | "url", url, 113 | "updated_at", updatedAt.Format(time.RFC3339), 114 | "cache_key", key, 115 | "cache_file", filepath.Base(cacheFile)) 116 | 117 | // Skip cache if --no-cache flag is set 118 | if !app.noCache { 119 | if cachedData, cacheHit, runningTests := app.checkCache(cacheFile, url, updatedAt); cacheHit { 120 | return cachedData, true, nil 121 | } else if runningTests { 122 | hasRunningTests = true 123 | } 124 | } 125 | 126 | // Cache miss, fetch from API 127 | if app.noCache { 128 | slog.Debug("Cache bypassed (--no-cache), fetching from Turn API", "url", url) 129 | } else { 130 | slog.Info("[CACHE] Cache miss, fetching from Turn API", 131 | "url", url, 132 | "pr_updated_at", updatedAt.Format(time.RFC3339)) 133 | if app.healthMonitor != nil { 134 | app.healthMonitor.recordCacheAccess(false) 135 | } 136 | } 137 | 138 | // Use exponential backoff with jitter for Turn API calls 139 | var data *turn.CheckResponse 140 | err := retry.Do(func() error { 141 | // Create timeout context for Turn API call 142 | turnCtx, cancel := context.WithTimeout(ctx, turnAPITimeout) 143 | defer cancel() 144 | 145 | // For PRs with running tests, send current time to bypass Turn server cache 146 | timestampToSend := updatedAt 147 | if hasRunningTests { 148 | timestampToSend = time.Now() 149 | slog.Debug("[TURN] Using current timestamp for PR with running tests to bypass Turn server cache", 150 | "url", url, 151 | "pr_updated_at", updatedAt.Format(time.RFC3339), 152 | "timestamp_sent", timestampToSend.Format(time.RFC3339)) 153 | } 154 | 155 | var err error 156 | slog.Debug("[TURN] Making API call", 157 | "url", url, 158 | "user", app.currentUser.GetLogin(), 159 | "pr_updated_at", timestampToSend.Format(time.RFC3339)) 160 | data, err = app.turnClient.Check(turnCtx, url, app.currentUser.GetLogin(), timestampToSend) 161 | if err != nil { 162 | slog.Warn("Turn API error (will retry)", "error", err) 163 | return err 164 | } 165 | slog.Debug("[TURN] API call successful", "url", url) 166 | return nil 167 | }, 168 | retry.Attempts(maxRetries), 169 | retry.DelayType(retry.CombineDelay(retry.BackOffDelay, retry.RandomDelay)), // Add jitter for better backoff distribution 170 | retry.MaxDelay(maxRetryDelay), 171 | retry.OnRetry(func(n uint, err error) { 172 | slog.Warn("[TURN] API retry", "attempt", n+1, "maxRetries", maxRetries, "url", url, "error", err) 173 | }), 174 | retry.Context(ctx), 175 | ) 176 | if err != nil { 177 | slog.Error("Turn API error after retries (will use PR without metadata)", "maxRetries", maxRetries, "error", err) 178 | if app.healthMonitor != nil { 179 | app.healthMonitor.recordAPICall(false) 180 | } 181 | return nil, false, err 182 | } 183 | 184 | if app.healthMonitor != nil { 185 | app.healthMonitor.recordAPICall(true) 186 | } 187 | 188 | // Log Turn API response for debugging 189 | if data != nil { 190 | slog.Info("[TURN] API response details", 191 | "url", url, 192 | "test_state", data.PullRequest.TestState, 193 | "state", data.PullRequest.State, 194 | "merged", data.PullRequest.Merged, 195 | "pending_checks", len(data.PullRequest.CheckSummary.Pending)) 196 | } 197 | 198 | // Save to cache (don't fail if caching fails) - skip if --no-cache is set 199 | // Cache PRs with incomplete tests using short TTL to catch completion quickly 200 | if !app.noCache && data != nil { 201 | testState := data.PullRequest.TestState 202 | isTestIncomplete := testState == "running" || testState == "queued" || testState == "pending" 203 | 204 | entry := cacheEntry{ 205 | Data: data, 206 | CachedAt: time.Now(), 207 | UpdatedAt: updatedAt, 208 | } 209 | if cacheData, err := json.Marshal(entry); err != nil { 210 | slog.Error("Failed to marshal cache data", "url", url, "error", err) 211 | } else { 212 | // Ensure cache directory exists with secure permissions 213 | if err := os.MkdirAll(filepath.Dir(cacheFile), 0o700); err != nil { 214 | slog.Error("Failed to create cache directory", "error", err) 215 | } else if err := os.WriteFile(cacheFile, cacheData, 0o600); err != nil { 216 | slog.Error("Failed to write cache file", "error", err) 217 | } else { 218 | ttl := cacheTTL 219 | if isTestIncomplete { 220 | ttl = runningTestsCacheTTL 221 | } 222 | slog.Debug("[CACHE] Saved to cache", 223 | "url", url, 224 | "cached_at", entry.CachedAt.Format(time.RFC3339), 225 | "pr_updated_at", entry.UpdatedAt.Format(time.RFC3339), 226 | "ttl", ttl, 227 | "test_state", testState, 228 | "cache_file", filepath.Base(cacheFile)) 229 | } 230 | } 231 | } 232 | 233 | return data, false, nil 234 | } 235 | 236 | // cleanupOldCache removes cache files older than the cleanup interval (15 days). 237 | func (app *App) cleanupOldCache() { 238 | entries, err := os.ReadDir(app.cacheDir) 239 | if err != nil { 240 | slog.Error("Failed to read cache directory for cleanup", "error", err) 241 | return 242 | } 243 | 244 | var cleanupCount, errorCount int 245 | for _, entry := range entries { 246 | if !strings.HasSuffix(entry.Name(), ".json") { 247 | continue 248 | } 249 | 250 | info, err := entry.Info() 251 | if err != nil { 252 | slog.Error("Failed to get file info for cache entry", "entry", entry.Name(), "error", err) 253 | errorCount++ 254 | continue 255 | } 256 | 257 | // Remove cache files older than cleanup interval (15 days) 258 | if time.Since(info.ModTime()) > cacheCleanupInterval { 259 | filePath := filepath.Join(app.cacheDir, entry.Name()) 260 | if err := os.Remove(filePath); err != nil { 261 | slog.Error("Failed to remove old cache file", "file", filePath, "error", err) 262 | errorCount++ 263 | } else { 264 | cleanupCount++ 265 | } 266 | } 267 | } 268 | 269 | if cleanupCount > 0 || errorCount > 0 { 270 | slog.Info("Cache cleanup completed", "removed", cleanupCount, "errors", errorCount) 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /cmd/review-goose/pr_state_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestPRStateManager(t *testing.T) { 9 | // Create a manager with a start time in the past (past grace period) 10 | mgr := NewPRStateManager(time.Now().Add(-60 * time.Second)) 11 | 12 | // Test 1: New blocked PR after grace period should notify 13 | pr1 := PR{ 14 | Repository: "test/repo", 15 | Number: 1, 16 | URL: "https://github.com/test/repo/pull/1", 17 | NeedsReview: true, 18 | UpdatedAt: time.Now(), // Recently updated PR 19 | } 20 | 21 | toNotify := mgr.UpdatePRs([]PR{pr1}, []PR{}, map[string]bool{}, false) 22 | if len(toNotify) != 1 { 23 | t.Errorf("Expected 1 PR to notify, got %d", len(toNotify)) 24 | } 25 | 26 | // Test 2: Same PR on next update should not notify again 27 | toNotify = mgr.UpdatePRs([]PR{pr1}, []PR{}, map[string]bool{}, false) 28 | if len(toNotify) != 0 { 29 | t.Errorf("Expected 0 PRs to notify (already notified), got %d", len(toNotify)) 30 | } 31 | 32 | // Test 3: PR becomes unblocked 33 | pr1.NeedsReview = false 34 | toNotify = mgr.UpdatePRs([]PR{pr1}, []PR{}, map[string]bool{}, false) 35 | if len(toNotify) != 0 { 36 | t.Errorf("Expected 0 PRs to notify (unblocked), got %d", len(toNotify)) 37 | } 38 | 39 | // Verify state was removed 40 | if _, exists := mgr.PRState(pr1.URL); exists { 41 | t.Error("Expected PR state to be removed when unblocked") 42 | } 43 | 44 | // Test 4: PR becomes blocked again - should notify 45 | pr1.NeedsReview = true 46 | toNotify = mgr.UpdatePRs([]PR{pr1}, []PR{}, map[string]bool{}, false) 47 | if len(toNotify) != 1 { 48 | t.Errorf("Expected 1 PR to notify (re-blocked), got %d", len(toNotify)) 49 | } 50 | } 51 | 52 | func TestPRStateManagerGracePeriod(t *testing.T) { 53 | // Create a manager with recent start time (within grace period) 54 | mgr := NewPRStateManager(time.Now().Add(-5 * time.Second)) 55 | 56 | // New blocked PR during grace period should NOT notify 57 | pr1 := PR{ 58 | Repository: "test/repo", 59 | Number: 1, 60 | URL: "https://github.com/test/repo/pull/1", 61 | NeedsReview: true, 62 | UpdatedAt: time.Now(), // Recently updated PR 63 | } 64 | 65 | toNotify := mgr.UpdatePRs([]PR{pr1}, []PR{}, map[string]bool{}, false) 66 | if len(toNotify) != 0 { 67 | t.Errorf("Expected 0 PRs to notify during grace period, got %d", len(toNotify)) 68 | } 69 | 70 | // Verify state is still tracked 71 | if _, exists := mgr.PRState(pr1.URL); !exists { 72 | t.Error("Expected PR state to be tracked even during grace period") 73 | } 74 | 75 | // Simulate time passing past grace period 76 | mgr.startTime = time.Now().Add(-60 * time.Second) 77 | 78 | // Same PR should now notify since we're past grace period and haven't notified yet 79 | toNotify = mgr.UpdatePRs([]PR{pr1}, []PR{}, map[string]bool{}, false) 80 | if len(toNotify) != 1 { 81 | t.Errorf("Expected 1 PR to notify after grace period, got %d", len(toNotify)) 82 | } 83 | } 84 | 85 | func TestPRStateManagerHiddenOrgs(t *testing.T) { 86 | mgr := NewPRStateManager(time.Now().Add(-60 * time.Second)) 87 | 88 | pr1 := PR{ 89 | Repository: "hidden-org/repo", 90 | Number: 1, 91 | URL: "https://github.com/hidden-org/repo/pull/1", 92 | IsBlocked: true, 93 | UpdatedAt: time.Now(), // Recently updated PR 94 | } 95 | 96 | pr2 := PR{ 97 | Repository: "visible-org/repo", 98 | Number: 2, 99 | URL: "https://github.com/visible-org/repo/pull/2", 100 | IsBlocked: true, 101 | UpdatedAt: time.Now(), // Recently updated PR 102 | } 103 | 104 | hiddenOrgs := map[string]bool{ 105 | "hidden-org": true, 106 | } 107 | 108 | toNotify := mgr.UpdatePRs([]PR{}, []PR{pr1, pr2}, hiddenOrgs, false) 109 | 110 | // Should only notify for visible org 111 | if len(toNotify) != 1 { 112 | t.Errorf("Expected 1 PR to notify (visible org only), got %d", len(toNotify)) 113 | } 114 | if toNotify[0].URL != pr2.URL { 115 | t.Errorf("Expected visible PR to be notified, got %s", toNotify[0].URL) 116 | } 117 | } 118 | 119 | // TestInitialDiscoveryNoNotifications tests that PRs discovered as already blocked on startup don't notify 120 | func TestInitialDiscoveryNoNotifications(t *testing.T) { 121 | mgr := NewPRStateManager(time.Now().Add(-60 * time.Second)) // Past grace period 122 | 123 | // Create some PRs that are already blocked 124 | pr1 := PR{ 125 | Repository: "test/repo1", 126 | Number: 1, 127 | URL: "https://github.com/test/repo1/pull/1", 128 | NeedsReview: true, 129 | UpdatedAt: time.Now(), 130 | } 131 | 132 | pr2 := PR{ 133 | Repository: "test/repo2", 134 | Number: 2, 135 | URL: "https://github.com/test/repo2/pull/2", 136 | IsBlocked: true, 137 | UpdatedAt: time.Now(), 138 | } 139 | 140 | // Initial discovery should NOT notify even though we're past grace period 141 | toNotify := mgr.UpdatePRs([]PR{pr1}, []PR{pr2}, map[string]bool{}, true) 142 | if len(toNotify) != 0 { 143 | t.Errorf("Expected 0 PRs to notify on initial discovery, got %d", len(toNotify)) 144 | } 145 | 146 | // Verify states were created and marked as initial discovery 147 | state1, exists1 := mgr.PRState(pr1.URL) 148 | if !exists1 { 149 | t.Error("Expected state to exist for pr1") 150 | } 151 | if !state1.IsInitialDiscovery { 152 | t.Error("Expected pr1 state to be marked as initial discovery") 153 | } 154 | 155 | state2, exists2 := mgr.PRState(pr2.URL) 156 | if !exists2 { 157 | t.Error("Expected state to exist for pr2") 158 | } 159 | if !state2.IsInitialDiscovery { 160 | t.Error("Expected pr2 state to be marked as initial discovery") 161 | } 162 | 163 | // Now a subsequent update with the same PRs should still not notify 164 | toNotify = mgr.UpdatePRs([]PR{pr1}, []PR{pr2}, map[string]bool{}, false) 165 | if len(toNotify) != 0 { 166 | t.Errorf("Expected 0 PRs to notify on subsequent update (no state change), got %d", len(toNotify)) 167 | } 168 | 169 | // But if a NEW blocked PR appears later, it should notify 170 | pr3 := PR{ 171 | Repository: "test/repo3", 172 | Number: 3, 173 | URL: "https://github.com/test/repo3/pull/3", 174 | NeedsReview: true, 175 | UpdatedAt: time.Now(), 176 | } 177 | 178 | toNotify = mgr.UpdatePRs([]PR{pr1, pr3}, []PR{pr2}, map[string]bool{}, false) 179 | if len(toNotify) != 1 { 180 | t.Errorf("Expected 1 PR to notify for newly blocked PR, got %d", len(toNotify)) 181 | } 182 | if len(toNotify) > 0 && toNotify[0].URL != pr3.URL { 183 | t.Errorf("Expected pr3 to be notified, got %s", toNotify[0].URL) 184 | } 185 | 186 | // Verify that initial discovery states are marked correctly 187 | state3, exists3 := mgr.PRState(pr3.URL) 188 | if !exists3 { 189 | t.Error("Expected state to exist for pr3") 190 | } 191 | if state3.IsInitialDiscovery { 192 | t.Error("Expected pr3 to NOT be marked as initial discovery (it was a real transition)") 193 | } 194 | } 195 | 196 | // TestPRStateManagerPreservesFirstBlockedTime tests that FirstBlockedAt is not reset 197 | // when the same blocked PR is processed on subsequent polls 198 | func TestPRStateManagerPreservesFirstBlockedTime(t *testing.T) { 199 | mgr := NewPRStateManager(time.Now().Add(-60 * time.Second)) 200 | 201 | // Create a blocked PR 202 | pr := PR{ 203 | Repository: "test/repo", 204 | Number: 1, 205 | URL: "https://github.com/test/repo/pull/1", 206 | NeedsReview: true, 207 | UpdatedAt: time.Now(), 208 | } 209 | 210 | // First call - should create state and notify (state transition: none -> blocked) 211 | toNotify := mgr.UpdatePRs([]PR{pr}, []PR{}, map[string]bool{}, false) 212 | if len(toNotify) != 1 { 213 | t.Fatalf("Expected 1 PR to notify on first call, got %d", len(toNotify)) 214 | } 215 | 216 | // Get the initial state 217 | state1, exists := mgr.PRState(pr.URL) 218 | if !exists { 219 | t.Fatal("Expected state to exist after first call") 220 | } 221 | originalFirstBlocked := state1.FirstBlockedAt 222 | 223 | // Wait a small amount to ensure timestamps would be different 224 | time.Sleep(10 * time.Millisecond) 225 | 226 | // Second call with same PR - should NOT notify and should preserve FirstBlockedAt 227 | // (no state transition: blocked -> blocked) 228 | toNotify = mgr.UpdatePRs([]PR{pr}, []PR{}, map[string]bool{}, false) 229 | if len(toNotify) != 0 { 230 | t.Errorf("Expected 0 PRs to notify on second call (no state transition), got %d", len(toNotify)) 231 | } 232 | 233 | // Get the state again 234 | state2, exists := mgr.PRState(pr.URL) 235 | if !exists { 236 | t.Fatal("Expected state to exist after second call") 237 | } 238 | 239 | // FirstBlockedAt should be exactly the same 240 | if !state2.FirstBlockedAt.Equal(originalFirstBlocked) { 241 | t.Errorf("FirstBlockedAt was changed! Original: %s, New: %s", 242 | originalFirstBlocked.Format(time.RFC3339Nano), 243 | state2.FirstBlockedAt.Format(time.RFC3339Nano)) 244 | } 245 | 246 | // HasNotified should still be true 247 | if !state2.HasNotified { 248 | t.Error("HasNotified should remain true") 249 | } 250 | 251 | t.Logf("SUCCESS: FirstBlockedAt preserved across polls: %s", originalFirstBlocked.Format(time.RFC3339)) 252 | } 253 | 254 | // TestPRStateTransitions tests the core state transition logic 255 | func TestPRStateTransitions(t *testing.T) { 256 | mgr := NewPRStateManager(time.Now().Add(-60 * time.Second)) 257 | 258 | pr := PR{ 259 | Repository: "test/repo", 260 | Number: 1, 261 | URL: "https://github.com/test/repo/pull/1", 262 | NeedsReview: true, 263 | UpdatedAt: time.Now(), 264 | } 265 | 266 | // Transition 1: none -> blocked (should notify) 267 | toNotify := mgr.UpdatePRs([]PR{pr}, []PR{}, map[string]bool{}, false) 268 | if len(toNotify) != 1 { 269 | t.Errorf("Expected notification for none->blocked transition, got %d", len(toNotify)) 270 | } 271 | 272 | // Transition 2: blocked -> unblocked (should clean up state) 273 | pr.NeedsReview = false 274 | toNotify = mgr.UpdatePRs([]PR{pr}, []PR{}, map[string]bool{}, false) 275 | if len(toNotify) != 0 { 276 | t.Errorf("Expected no notification for blocked->unblocked transition, got %d", len(toNotify)) 277 | } 278 | 279 | // Verify state was removed 280 | if _, exists := mgr.PRState(pr.URL); exists { 281 | t.Error("Expected state to be removed when PR becomes unblocked") 282 | } 283 | 284 | // Transition 3: unblocked -> blocked again (should notify again as new state) 285 | pr.NeedsReview = true 286 | toNotify = mgr.UpdatePRs([]PR{pr}, []PR{}, map[string]bool{}, false) 287 | if len(toNotify) != 1 { 288 | t.Errorf("Expected notification for unblocked->blocked transition, got %d", len(toNotify)) 289 | } 290 | 291 | // Verify new state was created 292 | state, exists := mgr.PRState(pr.URL) 293 | if !exists { 294 | t.Error("Expected new state to be created for unblocked->blocked transition") 295 | } 296 | if state.HasNotified != true { 297 | t.Error("Expected new state to be marked as notified") 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /cmd/review-goose/filtering_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | // TestCountPRsWithHiddenOrgs tests that PRs from hidden orgs are not counted 10 | func TestCountPRsWithHiddenOrgs(t *testing.T) { 11 | app := &App{ 12 | incoming: []PR{ 13 | {Repository: "org1/repo1", NeedsReview: true, UpdatedAt: time.Now()}, 14 | {Repository: "org2/repo2", NeedsReview: true, UpdatedAt: time.Now()}, 15 | {Repository: "org3/repo3", NeedsReview: true, UpdatedAt: time.Now()}, 16 | }, 17 | outgoing: []PR{ 18 | {Repository: "org1/repo4", IsBlocked: true, UpdatedAt: time.Now()}, 19 | {Repository: "org2/repo5", IsBlocked: true, UpdatedAt: time.Now()}, 20 | }, 21 | hiddenOrgs: map[string]bool{ 22 | "org2": true, // Hide org2 23 | }, 24 | hideStaleIncoming: false, 25 | systrayInterface: &MockSystray{}, // Use mock systray to avoid panics 26 | } 27 | 28 | counts := app.countPRs() 29 | 30 | // Should only count PRs from org1 and org3, not org2 31 | if counts.IncomingTotal != 2 { 32 | t.Errorf("IncomingTotal = %d, want 2 (org2 should be hidden)", counts.IncomingTotal) 33 | } 34 | if counts.IncomingBlocked != 2 { 35 | t.Errorf("IncomingBlocked = %d, want 2 (org2 should be hidden)", counts.IncomingBlocked) 36 | } 37 | if counts.OutgoingTotal != 1 { 38 | t.Errorf("OutgoingTotal = %d, want 1 (org2 should be hidden)", counts.OutgoingTotal) 39 | } 40 | if counts.OutgoingBlocked != 1 { 41 | t.Errorf("OutgoingBlocked = %d, want 1 (org2 should be hidden)", counts.OutgoingBlocked) 42 | } 43 | } 44 | 45 | // TestCountPRsWithStalePRs tests that stale PRs are not counted when hideStaleIncoming is true 46 | func TestCountPRsWithStalePRs(t *testing.T) { 47 | now := time.Now() 48 | staleTime := now.Add(-100 * 24 * time.Hour) // 100 days ago 49 | recentTime := now.Add(-1 * time.Hour) // 1 hour ago 50 | 51 | app := &App{ 52 | incoming: []PR{ 53 | {Repository: "org1/repo1", NeedsReview: true, UpdatedAt: staleTime}, 54 | {Repository: "org1/repo2", NeedsReview: true, UpdatedAt: recentTime}, 55 | {Repository: "org2/repo3", NeedsReview: false, UpdatedAt: staleTime}, 56 | }, 57 | outgoing: []PR{ 58 | {Repository: "org1/repo4", IsBlocked: true, UpdatedAt: staleTime}, 59 | {Repository: "org1/repo5", IsBlocked: true, UpdatedAt: recentTime}, 60 | }, 61 | hiddenOrgs: map[string]bool{}, 62 | hideStaleIncoming: true, // Hide stale PRs 63 | systrayInterface: &MockSystray{}, // Use mock systray to avoid panics 64 | } 65 | 66 | counts := app.countPRs() 67 | 68 | // Should only count recent PRs 69 | if counts.IncomingTotal != 1 { 70 | t.Errorf("IncomingTotal = %d, want 1 (stale PRs should be hidden)", counts.IncomingTotal) 71 | } 72 | if counts.IncomingBlocked != 1 { 73 | t.Errorf("IncomingBlocked = %d, want 1 (stale PRs should be hidden)", counts.IncomingBlocked) 74 | } 75 | if counts.OutgoingTotal != 1 { 76 | t.Errorf("OutgoingTotal = %d, want 1 (stale PRs should be hidden)", counts.OutgoingTotal) 77 | } 78 | if counts.OutgoingBlocked != 1 { 79 | t.Errorf("OutgoingBlocked = %d, want 1 (stale PRs should be hidden)", counts.OutgoingBlocked) 80 | } 81 | } 82 | 83 | // TestCountPRsWithBothFilters tests that both filters work together 84 | func TestCountPRsWithBothFilters(t *testing.T) { 85 | now := time.Now() 86 | staleTime := now.Add(-100 * 24 * time.Hour) 87 | recentTime := now.Add(-1 * time.Hour) 88 | 89 | app := &App{ 90 | incoming: []PR{ 91 | {Repository: "org1/repo1", NeedsReview: true, UpdatedAt: recentTime}, // Should be counted 92 | {Repository: "org2/repo2", NeedsReview: true, UpdatedAt: recentTime}, // Hidden org 93 | {Repository: "org3/repo3", NeedsReview: true, UpdatedAt: staleTime}, // Stale 94 | {Repository: "org1/repo4", NeedsReview: false, UpdatedAt: recentTime}, // Not blocked 95 | }, 96 | outgoing: []PR{ 97 | {Repository: "org1/repo5", IsBlocked: true, UpdatedAt: recentTime}, // Should be counted 98 | {Repository: "org2/repo6", IsBlocked: true, UpdatedAt: recentTime}, // Hidden org 99 | {Repository: "org3/repo7", IsBlocked: true, UpdatedAt: staleTime}, // Stale 100 | }, 101 | hiddenOrgs: map[string]bool{ 102 | "org2": true, 103 | }, 104 | hideStaleIncoming: true, 105 | systrayInterface: &MockSystray{}, // Use mock systray to avoid panics 106 | } 107 | 108 | counts := app.countPRs() 109 | 110 | // Should only count org1/repo1 (incoming) and org1/repo5 (outgoing) 111 | if counts.IncomingTotal != 2 { 112 | t.Errorf("IncomingTotal = %d, want 2", counts.IncomingTotal) 113 | } 114 | if counts.IncomingBlocked != 1 { 115 | t.Errorf("IncomingBlocked = %d, want 1", counts.IncomingBlocked) 116 | } 117 | if counts.OutgoingTotal != 1 { 118 | t.Errorf("OutgoingTotal = %d, want 1", counts.OutgoingTotal) 119 | } 120 | if counts.OutgoingBlocked != 1 { 121 | t.Errorf("OutgoingBlocked = %d, want 1", counts.OutgoingBlocked) 122 | } 123 | } 124 | 125 | // TestExtractOrgFromRepo tests the org extraction function 126 | func TestExtractOrgFromRepo(t *testing.T) { 127 | tests := []struct { 128 | repo string 129 | name string 130 | want string 131 | }{ 132 | { 133 | name: "standard repo path", 134 | repo: "microsoft/vscode", 135 | want: "microsoft", 136 | }, 137 | { 138 | name: "single segment", 139 | repo: "justarepo", 140 | want: "justarepo", 141 | }, 142 | { 143 | name: "empty string", 144 | repo: "", 145 | want: "", 146 | }, 147 | { 148 | name: "nested path", 149 | repo: "org/repo/subpath", 150 | want: "org", 151 | }, 152 | } 153 | 154 | for _, tt := range tests { 155 | t.Run(tt.name, func(t *testing.T) { 156 | if got := extractOrgFromRepo(tt.repo); got != tt.want { 157 | t.Errorf("extractOrgFromRepo(%q) = %q, want %q", tt.repo, got, tt.want) 158 | } 159 | }) 160 | } 161 | } 162 | 163 | // TestIsAlreadyTrackedAsBlocked tests that sprinkler correctly identifies blocked PRs 164 | func TestIsAlreadyTrackedAsBlocked(t *testing.T) { 165 | app := &App{ 166 | incoming: []PR{ 167 | {URL: "https://github.com/org1/repo1/pull/1", IsBlocked: true}, 168 | {URL: "https://github.com/org1/repo1/pull/2", IsBlocked: false}, 169 | {URL: "https://github.com/org1/repo1/pull/3", NeedsReview: true, IsBlocked: false}, // NeedsReview but not IsBlocked 170 | }, 171 | outgoing: []PR{ 172 | {URL: "https://github.com/org2/repo2/pull/10", IsBlocked: true}, 173 | {URL: "https://github.com/org2/repo2/pull/11", IsBlocked: false}, 174 | }, 175 | } 176 | 177 | sm := &sprinklerMonitor{app: app} 178 | 179 | tests := []struct { 180 | name string 181 | url string 182 | want bool 183 | }{ 184 | { 185 | name: "incoming PR is blocked", 186 | url: "https://github.com/org1/repo1/pull/1", 187 | want: true, 188 | }, 189 | { 190 | name: "incoming PR is not blocked", 191 | url: "https://github.com/org1/repo1/pull/2", 192 | want: false, 193 | }, 194 | { 195 | name: "incoming PR needs review but is not blocked", 196 | url: "https://github.com/org1/repo1/pull/3", 197 | want: false, 198 | }, 199 | { 200 | name: "outgoing PR is blocked", 201 | url: "https://github.com/org2/repo2/pull/10", 202 | want: true, 203 | }, 204 | { 205 | name: "outgoing PR is not blocked", 206 | url: "https://github.com/org2/repo2/pull/11", 207 | want: false, 208 | }, 209 | { 210 | name: "unknown PR", 211 | url: "https://github.com/org3/repo3/pull/99", 212 | want: false, 213 | }, 214 | } 215 | 216 | for _, tt := range tests { 217 | t.Run(tt.name, func(t *testing.T) { 218 | got := sm.isAlreadyTrackedAsBlocked(tt.url, "test", 1) 219 | if got != tt.want { 220 | t.Errorf("isAlreadyTrackedAsBlocked(%q) = %v, want %v", tt.url, got, tt.want) 221 | } 222 | }) 223 | } 224 | } 225 | 226 | // TestBotPRsSortedAfterHumans tests that human-authored PRs appear before bot-authored PRs 227 | func TestBotPRsSortedAfterHumans(t *testing.T) { 228 | now := time.Now() 229 | 230 | app := &App{ 231 | incoming: []PR{ 232 | {Repository: "org/repo1", Number: 1, Author: "dependabot[bot]", AuthorBot: true, NeedsReview: true, UpdatedAt: now}, 233 | {Repository: "org/repo2", Number: 2, Author: "human-dev", AuthorBot: false, NeedsReview: true, UpdatedAt: now.Add(-1 * time.Hour)}, 234 | {Repository: "org/repo3", Number: 3, Author: "renovate[bot]", AuthorBot: true, NeedsReview: true, UpdatedAt: now.Add(-2 * time.Hour)}, 235 | {Repository: "org/repo4", Number: 4, Author: "another-human", AuthorBot: false, NeedsReview: true, UpdatedAt: now.Add(-3 * time.Hour)}, 236 | }, 237 | hiddenOrgs: map[string]bool{}, 238 | stateManager: NewPRStateManager(now), 239 | systrayInterface: &MockSystray{}, 240 | } 241 | 242 | titles := app.generatePRSectionTitles(app.incoming, "Incoming", map[string]bool{}, false) 243 | 244 | if len(titles) != 4 { 245 | t.Fatalf("Expected 4 titles, got %d", len(titles)) 246 | } 247 | 248 | // Human PRs should come first (repo2 and repo4), then bot PRs (repo1 and repo3) 249 | // Within each group, sorted by UpdatedAt (most recent first) 250 | expectedOrder := []string{"repo2", "repo4", "repo1", "repo3"} 251 | for i, expected := range expectedOrder { 252 | if !strings.Contains(titles[i], expected) { 253 | t.Errorf("Title %d: expected to contain %q, got %q", i, expected, titles[i]) 254 | } 255 | } 256 | } 257 | 258 | // TestBotPRsGetSmallerIcon tests that bot PRs get a smaller dot icon instead of the block 259 | func TestBotPRsGetSmallerIcon(t *testing.T) { 260 | now := time.Now() 261 | 262 | app := &App{ 263 | incoming: []PR{ 264 | { 265 | Repository: "org/repo1", 266 | Number: 1, 267 | Author: "dependabot[bot]", 268 | AuthorBot: true, 269 | NeedsReview: true, 270 | IsBlocked: true, 271 | UpdatedAt: now, 272 | }, 273 | { 274 | Repository: "org/repo2", 275 | Number: 2, 276 | Author: "human-dev", 277 | AuthorBot: false, 278 | NeedsReview: true, 279 | IsBlocked: true, 280 | UpdatedAt: now, 281 | }, 282 | }, 283 | hiddenOrgs: map[string]bool{}, 284 | stateManager: NewPRStateManager(now), 285 | systrayInterface: &MockSystray{}, 286 | } 287 | 288 | titles := app.generatePRSectionTitles(app.incoming, "Incoming", map[string]bool{}, false) 289 | 290 | if len(titles) != 2 { 291 | t.Fatalf("Expected 2 titles, got %d", len(titles)) 292 | } 293 | 294 | // Human PR should come first with block icon 295 | humanTitle := titles[0] 296 | if !strings.Contains(humanTitle, "repo2") { 297 | t.Errorf("Expected human PR (repo2) first, got: %s", humanTitle) 298 | } 299 | if !strings.HasPrefix(humanTitle, "■") { 300 | t.Errorf("Expected human PR to have block icon (■), got: %s", humanTitle) 301 | } 302 | 303 | // Bot PR should come second with smaller dot 304 | botTitle := titles[1] 305 | if !strings.Contains(botTitle, "repo1") { 306 | t.Errorf("Expected bot PR (repo1) second, got: %s", botTitle) 307 | } 308 | if !strings.HasPrefix(botTitle, "·") { 309 | t.Errorf("Expected bot PR to have smaller dot (·), got: %s", botTitle) 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | APP_NAME = review-goose 2 | BUNDLE_NAME = Review Goose 3 | VERSION = 1.0.0 4 | BUNDLE_VERSION = 1 5 | BUNDLE_ID = dev.codegroove.r2r 6 | 7 | # Version information for builds 8 | # Try VERSION file first (for release tarballs), then fall back to git 9 | VERSION_FILE := $(shell cat cmd/review-goose/VERSION 2>/dev/null) 10 | GIT_VERSION := $(shell git describe --tags --always --dirty 2>/dev/null) 11 | BUILD_VERSION := $(or $(VERSION_FILE),$(GIT_VERSION),dev) 12 | GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") 13 | BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") 14 | LDFLAGS := -X main.version=$(BUILD_VERSION) -X main.commit=$(GIT_COMMIT) -X main.date=$(BUILD_DATE) 15 | 16 | .PHONY: all build build-all build-darwin build-linux build-windows clean deps run app-bundle app-bundle-universal install install-darwin install-unix install-windows test release help 17 | 18 | # Default target 19 | all: build 20 | 21 | # Show available make targets 22 | help: 23 | @echo "Available targets:" 24 | @echo " make build - Build for current platform" 25 | @echo " make build-all - Build for all platforms" 26 | @echo " make build-darwin - Build for macOS (amd64 and arm64)" 27 | @echo " make build-linux - Build for Linux (amd64 and arm64)" 28 | @echo " make build-windows - Build for Windows (amd64 and arm64)" 29 | @echo " make app-bundle - Create macOS .app bundle (native arch)" 30 | @echo " make app-bundle-universal - Create macOS .app bundle (universal)" 31 | @echo " make install - Install application for current platform" 32 | @echo " make test - Run tests with race detector" 33 | @echo " make lint - Run linters" 34 | @echo " make fix - Run auto-fixers" 35 | @echo " make clean - Remove build artifacts" 36 | @echo " make release VERSION=vX.Y.Z - Create and push a new release tag" 37 | 38 | test: 39 | @echo "Running tests with race detector..." 40 | @go test -race ./... 41 | 42 | # Install dependencies 43 | deps: 44 | go mod download 45 | go mod tidy 46 | 47 | # Run the application 48 | run: 49 | ifeq ($(shell uname),Darwin) 50 | @$(MAKE) install 51 | @echo "Running $(BUNDLE_NAME) from /Applications..." 52 | @open "/Applications/$(BUNDLE_NAME).app" 53 | else 54 | go run ./cmd/review-goose 55 | endif 56 | 57 | # Build for current platform 58 | build: out 59 | ifeq ($(OS),Windows_NT) 60 | @echo "Building $(APP_NAME) for Windows..." 61 | @CGO_ENABLED=1 go build -ldflags "-H=windowsgui $(LDFLAGS)" -o out/$(APP_NAME).exe ./cmd/review-goose 62 | @echo "✓ Created: out/$(APP_NAME).exe" 63 | else 64 | @echo "Building $(APP_NAME) for $(shell uname -s)/$(shell uname -m)..." 65 | @CGO_ENABLED=1 go build -ldflags "$(LDFLAGS)" -o out/$(APP_NAME) ./cmd/review-goose 66 | @echo "✓ Created: out/$(APP_NAME)" 67 | endif 68 | 69 | # Build for all platforms 70 | build-all: build-darwin build-linux build-windows 71 | 72 | # Build for macOS (both architectures) 73 | build-darwin: out 74 | @echo "Building $(APP_NAME) for darwin/amd64..." 75 | @CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o out/$(APP_NAME)-darwin-amd64 ./cmd/review-goose 76 | @echo "✓ Created: out/$(APP_NAME)-darwin-amd64" 77 | @echo "Building $(APP_NAME) for darwin/arm64..." 78 | @CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o out/$(APP_NAME)-darwin-arm64 ./cmd/review-goose 79 | @echo "✓ Created: out/$(APP_NAME)-darwin-arm64" 80 | 81 | # Build for Linux (both architectures) 82 | # Note: CGO cross-compilation requires appropriate cross-compiler toolchain 83 | build-linux: out 84 | @echo "Building $(APP_NAME) for linux/amd64..." 85 | @CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o out/$(APP_NAME)-linux-amd64 ./cmd/review-goose 86 | @echo "✓ Created: out/$(APP_NAME)-linux-amd64" 87 | @echo "Building $(APP_NAME) for linux/arm64..." 88 | @CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o out/$(APP_NAME)-linux-arm64 ./cmd/review-goose 89 | @echo "✓ Created: out/$(APP_NAME)-linux-arm64" 90 | 91 | # Build for Windows (both architectures) 92 | # Note: CGO cross-compilation requires appropriate cross-compiler toolchain 93 | build-windows: out 94 | @echo "Building $(APP_NAME) for windows/amd64..." 95 | @CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -ldflags "-H=windowsgui $(LDFLAGS)" -o out/$(APP_NAME)-windows-amd64.exe ./cmd/review-goose 96 | @echo "✓ Created: out/$(APP_NAME)-windows-amd64.exe" 97 | @echo "Building $(APP_NAME) for windows/arm64..." 98 | @CGO_ENABLED=1 GOOS=windows GOARCH=arm64 go build -ldflags "-H=windowsgui $(LDFLAGS)" -o out/$(APP_NAME)-windows-arm64.exe ./cmd/review-goose 99 | @echo "✓ Created: out/$(APP_NAME)-windows-arm64.exe" 100 | 101 | # Clean build artifacts 102 | clean: 103 | @echo "Cleaning build artifacts..." 104 | @rm -rf out/ 105 | 106 | # Create out directory 107 | out: 108 | @mkdir -p out 109 | 110 | # Install appify to out/tools directory (pinned version) 111 | APPIFY_COMMIT := 15c1e09ce9247bfd78610ac4831dd0a3cb02483c 112 | APPIFY_BIN := out/tools/appify-$(APPIFY_COMMIT) 113 | $(APPIFY_BIN): 114 | @echo "Installing appify ($(APPIFY_COMMIT))..." 115 | @mkdir -p out/tools 116 | @GOBIN=$(shell pwd)/out/tools go install github.com/machinebox/appify@$(APPIFY_COMMIT) 2>&1 | grep -v "go: finding\|go: downloading\|go: found" || true 117 | @mv out/tools/appify $@ 118 | 119 | install-appify: $(APPIFY_BIN) 120 | 121 | # Internal helper to create app bundle from a binary 122 | # Usage: make _create-app-bundle BUNDLE_BINARY=review-goose 123 | define create-app-bundle 124 | @echo "Removing old app bundle..." 125 | @rm -rf "out/$(BUNDLE_NAME).app" 126 | 127 | @echo "Creating macOS application bundle with appify..." 128 | @cp media/logo.png out/logo.png 129 | @echo "Creating menubar icon..." 130 | @sips -z 44 44 media/logo.png --out out/menubar-icon.png >/dev/null 2>&1 131 | @sips -s format png out/menubar-icon.png --out out/menubar-icon.png >/dev/null 2>&1 132 | 133 | cd out && ../$(APPIFY_BIN) -name "$(BUNDLE_NAME)" \ 134 | -icon logo.png \ 135 | -id "$(BUNDLE_ID)" \ 136 | $(1) 137 | 138 | @if [ -f "out/$(BUNDLE_NAME)-$(1).app" ]; then \ 139 | mv "out/$(BUNDLE_NAME)-$(1).app" "out/$(BUNDLE_NAME).app"; \ 140 | elif [ ! -d "out/$(BUNDLE_NAME).app" ]; then \ 141 | echo "Warning: App bundle not found in expected location"; \ 142 | fi 143 | 144 | @echo "Copying menubar icon to app bundle..." 145 | @cp out/menubar-icon.png "out/$(BUNDLE_NAME).app/Contents/Resources/menubar-icon.png" 146 | @mkdir -p "out/$(BUNDLE_NAME).app/Contents/Resources/en.lproj" 147 | 148 | @echo "Fixing executable name..." 149 | @if [ -f "out/$(BUNDLE_NAME).app/Contents/MacOS/$(BUNDLE_NAME).app" ]; then \ 150 | mv "out/$(BUNDLE_NAME).app/Contents/MacOS/$(BUNDLE_NAME).app" "out/$(BUNDLE_NAME).app/Contents/MacOS/$(BUNDLE_NAME)"; \ 151 | fi 152 | 153 | @echo "Fixing Info.plist..." 154 | @/usr/libexec/PlistBuddy -c "Set :CFBundleExecutable Review\\ Goose" "out/$(BUNDLE_NAME).app/Contents/Info.plist" 155 | @/usr/libexec/PlistBuddy -c "Add :LSUIElement bool true" "out/$(BUNDLE_NAME).app/Contents/Info.plist" 2>/dev/null || \ 156 | /usr/libexec/PlistBuddy -c "Set :LSUIElement true" "out/$(BUNDLE_NAME).app/Contents/Info.plist" 157 | @/usr/libexec/PlistBuddy -c "Add :CFBundleDevelopmentRegion string en" "out/$(BUNDLE_NAME).app/Contents/Info.plist" 2>/dev/null || \ 158 | /usr/libexec/PlistBuddy -c "Set :CFBundleDevelopmentRegion en" "out/$(BUNDLE_NAME).app/Contents/Info.plist" 159 | @/usr/libexec/PlistBuddy -c "Add :NSUserNotificationAlertStyle string alert" "out/$(BUNDLE_NAME).app/Contents/Info.plist" 2>/dev/null || \ 160 | /usr/libexec/PlistBuddy -c "Set :NSUserNotificationAlertStyle alert" "out/$(BUNDLE_NAME).app/Contents/Info.plist" 161 | @/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string $(BUILD_VERSION)" "out/$(BUNDLE_NAME).app/Contents/Info.plist" 2>/dev/null || \ 162 | /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $(BUILD_VERSION)" "out/$(BUNDLE_NAME).app/Contents/Info.plist" 163 | @/usr/libexec/PlistBuddy -c "Add :CFBundleVersion string $(BUILD_VERSION)" "out/$(BUNDLE_NAME).app/Contents/Info.plist" 2>/dev/null || \ 164 | /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $(BUILD_VERSION)" "out/$(BUNDLE_NAME).app/Contents/Info.plist" 165 | @/usr/libexec/PlistBuddy -c "Add :CFBundleGetInfoString string 'Review Goose $(BUILD_VERSION)'" "out/$(BUNDLE_NAME).app/Contents/Info.plist" 2>/dev/null || \ 166 | /usr/libexec/PlistBuddy -c "Set :CFBundleGetInfoString 'Review Goose $(BUILD_VERSION)'" "out/$(BUNDLE_NAME).app/Contents/Info.plist" 167 | 168 | @echo "Code signing the app bundle..." 169 | @xattr -cr "out/$(BUNDLE_NAME).app" 170 | @codesign --force --deep --sign - --options runtime "out/$(BUNDLE_NAME).app" >/dev/null 2>&1 171 | 172 | @echo "✓ macOS app bundle created: out/$(BUNDLE_NAME).app" 173 | endef 174 | 175 | # Build macOS application bundle using appify (native architecture only) 176 | app-bundle: out build install-appify 177 | $(call create-app-bundle,$(APP_NAME)) 178 | 179 | # Build macOS universal application bundle (both arm64 and amd64) 180 | app-bundle-universal: out build-darwin install-appify 181 | @echo "Creating universal binary..." 182 | @lipo -create out/$(APP_NAME)-darwin-amd64 out/$(APP_NAME)-darwin-arm64 \ 183 | -output out/$(APP_NAME)-universal 184 | $(call create-app-bundle,$(APP_NAME)-universal) 185 | 186 | # Install the application (detects OS automatically) 187 | install: 188 | ifeq ($(shell uname),Darwin) 189 | @$(MAKE) install-darwin 190 | else ifeq ($(OS),Windows_NT) 191 | @$(MAKE) install-windows 192 | else ifneq ($(filter $(shell uname),Linux FreeBSD OpenBSD NetBSD SunOS),) 193 | @$(MAKE) install-unix 194 | else 195 | @echo "Unsupported platform. Please install manually." 196 | @exit 1 197 | endif 198 | 199 | # Install on macOS 200 | install-darwin: app-bundle 201 | @echo "Installing on macOS..." 202 | @echo "Copying $(BUNDLE_NAME).app to /Applications..." 203 | @rm -rf "/Applications/$(BUNDLE_NAME).app" 204 | # old name 205 | @rm -rf "/Applications/Ready to Review.app" 206 | @cp -R "out/$(BUNDLE_NAME).app" "/Applications/" 207 | @echo "Installation complete! $(BUNDLE_NAME) has been installed to /Applications" 208 | 209 | # Install on Unix-like systems (Linux, BSD variants, Solaris) 210 | install-unix: build 211 | @echo "Installing on $(shell uname)..." 212 | @echo "Installing binary to /usr/local/bin..." 213 | @if command -v sudo >/dev/null 2>&1; then \ 214 | sudo install -m 755 out/$(APP_NAME) /usr/local/bin/; \ 215 | elif command -v doas >/dev/null 2>&1; then \ 216 | doas install -m 755 out/$(APP_NAME) /usr/local/bin/; \ 217 | else \ 218 | echo "Error: Neither sudo nor doas found. Please install the binary manually."; \ 219 | exit 1; \ 220 | fi 221 | @echo "Installation complete! $(APP_NAME) has been installed to /usr/local/bin" 222 | 223 | # Install on Windows 224 | install-windows: build 225 | @echo "Installing on Windows..." 226 | @echo "Creating program directory..." 227 | @if not exist "%LOCALAPPDATA%\Programs\$(APP_NAME)" mkdir "%LOCALAPPDATA%\Programs\$(APP_NAME)" 228 | @echo "Copying executable..." 229 | @copy /Y "out\$(APP_NAME).exe" "%LOCALAPPDATA%\Programs\$(APP_NAME)\" 230 | @echo "Installation complete! $(APP_NAME) has been installed to %LOCALAPPDATA%\Programs\$(APP_NAME)" 231 | @echo "You may want to add %LOCALAPPDATA%\Programs\$(APP_NAME) to your PATH environment variable." 232 | # BEGIN: lint-install . 233 | # http://github.com/codeGROOVE-dev/lint-install 234 | 235 | .PHONY: lint 236 | lint: _lint 237 | 238 | LINT_ARCH := $(shell uname -m) 239 | LINT_OS := $(shell uname) 240 | LINT_OS_LOWER := $(shell echo $(LINT_OS) | tr '[:upper:]' '[:lower:]') 241 | LINT_ROOT := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) 242 | 243 | # shellcheck and hadolint lack arm64 native binaries: rely on x86-64 emulation 244 | ifeq ($(LINT_OS),Darwin) 245 | ifeq ($(LINT_ARCH),arm64) 246 | LINT_ARCH=x86_64 247 | endif 248 | endif 249 | 250 | LINTERS := 251 | FIXERS := 252 | 253 | GOLANGCI_LINT_CONFIG := $(LINT_ROOT)/.golangci.yml 254 | GOLANGCI_LINT_VERSION ?= v2.7.2 255 | GOLANGCI_LINT_BIN := $(LINT_ROOT)/out/linters/golangci-lint-$(GOLANGCI_LINT_VERSION)-$(LINT_ARCH) 256 | $(GOLANGCI_LINT_BIN): 257 | mkdir -p $(LINT_ROOT)/out/linters 258 | rm -rf $(LINT_ROOT)/out/linters/golangci-lint-* 259 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(LINT_ROOT)/out/linters $(GOLANGCI_LINT_VERSION) 260 | mv $(LINT_ROOT)/out/linters/golangci-lint $@ 261 | 262 | LINTERS += golangci-lint-lint 263 | golangci-lint-lint: $(GOLANGCI_LINT_BIN) 264 | find . -name go.mod -execdir "$(GOLANGCI_LINT_BIN)" run -c "$(GOLANGCI_LINT_CONFIG)" \; 265 | 266 | FIXERS += golangci-lint-fix 267 | golangci-lint-fix: $(GOLANGCI_LINT_BIN) 268 | find . -name go.mod -execdir "$(GOLANGCI_LINT_BIN)" run -c "$(GOLANGCI_LINT_CONFIG)" --fix \; 269 | 270 | YAMLLINT_VERSION ?= 1.37.1 271 | YAMLLINT_ROOT := $(LINT_ROOT)/out/linters/yamllint-$(YAMLLINT_VERSION) 272 | YAMLLINT_BIN := $(YAMLLINT_ROOT)/dist/bin/yamllint 273 | $(YAMLLINT_BIN): 274 | mkdir -p $(LINT_ROOT)/out/linters 275 | rm -rf $(LINT_ROOT)/out/linters/yamllint-* 276 | curl -sSfL https://github.com/adrienverge/yamllint/archive/refs/tags/v$(YAMLLINT_VERSION).tar.gz | tar -C $(LINT_ROOT)/out/linters -zxf - 277 | cd $(YAMLLINT_ROOT) && pip3 install --target dist . || pip install --target dist . 278 | 279 | LINTERS += yamllint-lint 280 | yamllint-lint: $(YAMLLINT_BIN) 281 | PYTHONPATH=$(YAMLLINT_ROOT)/dist $(YAMLLINT_ROOT)/dist/bin/yamllint . 282 | 283 | .PHONY: _lint $(LINTERS) 284 | _lint: 285 | @exit_code=0; \ 286 | for target in $(LINTERS); do \ 287 | $(MAKE) $$target || exit_code=1; \ 288 | done; \ 289 | exit $$exit_code 290 | 291 | .PHONY: fix $(FIXERS) 292 | fix: 293 | @exit_code=0; \ 294 | for target in $(FIXERS); do \ 295 | $(MAKE) $$target || exit_code=1; \ 296 | done; \ 297 | exit $$exit_code 298 | 299 | # END: lint-install . 300 | 301 | 302 | # Release workflow - creates a new version tag 303 | # Usage: make release VERSION=v1.0.0 304 | release: 305 | @if [ -z "$(VERSION)" ]; then \ 306 | echo "Error: VERSION is required. Usage: make release VERSION=v1.0.0"; \ 307 | exit 1; \ 308 | fi 309 | @echo "Creating release $(VERSION)..." 310 | @if ! echo "$(VERSION)" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+'; then \ 311 | echo "Error: VERSION must be in format vX.Y.Z or vX.Y.Z-suffix (e.g., v1.0.0, v1.0.0-alpha)"; \ 312 | exit 1; \ 313 | fi 314 | @if git rev-parse "$(VERSION)" >/dev/null 2>&1; then \ 315 | echo "Error: Tag $(VERSION) already exists"; \ 316 | exit 1; \ 317 | fi 318 | @echo "→ Running tests..." 319 | @$(MAKE) test 320 | @echo "→ Running linters..." 321 | @$(MAKE) lint 322 | @echo "Creating VERSION file..." 323 | @echo "$(VERSION)" > cmd/review-goose/VERSION 324 | @git add cmd/review-goose/VERSION 325 | @if [ -n "$$(git diff --cached --name-only)" ]; then \ 326 | git commit -m "Release $(VERSION)"; \ 327 | fi 328 | @echo "Checking for uncommitted changes..." 329 | @if [ -n "$$(git status --porcelain)" ]; then \ 330 | echo "Error: Working directory has uncommitted changes"; \ 331 | git status --short; \ 332 | exit 1; \ 333 | fi 334 | @echo "Creating and pushing tag $(VERSION)..." 335 | @git tag "$(VERSION)" 336 | @git push origin main 337 | @git push origin "$(VERSION)" 338 | @echo "✓ Release $(VERSION) created and pushed successfully" 339 | @echo " View release at: https://github.com/codeGROOVE-dev/goose/releases/tag/$(VERSION)" 340 | -------------------------------------------------------------------------------- /pkg/safebrowse/safebrowse_test.go: -------------------------------------------------------------------------------- 1 | package safebrowse 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestValidateURL_ValidURLs(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | url string 13 | }{ 14 | {"valid GitHub PR URL", "https://github.com/owner/repo/pull/123"}, 15 | {"valid GitHub repo URL", "https://github.com/owner/repo"}, 16 | {"valid dashboard URL", "https://dash.ready-to-review.dev"}, 17 | {"valid URL with path", "https://github.com/owner/repo/pulls"}, 18 | {"valid URL with dots in domain", "https://api.github.com/repos/owner/repo"}, 19 | {"uppercase domain", "https://GITHUB.COM/owner/repo"}, 20 | {"mixed case domain", "https://GitHub.Com/owner/repo"}, 21 | } 22 | 23 | for _, tt := range tests { 24 | t.Run(tt.name, func(t *testing.T) { 25 | if err := ValidateURL(tt.url); err != nil { 26 | t.Errorf("ValidateURL() error = %v, want nil", err) 27 | } 28 | }) 29 | } 30 | } 31 | 32 | func TestValidateURL_BasicSecurity(t *testing.T) { 33 | tests := []struct { 34 | name string 35 | url string 36 | }{ 37 | {"empty string", ""}, 38 | {"HTTP instead of HTTPS", "http://github.com/owner/repo"}, 39 | {"FTP scheme", "ftp://example.com/file"}, 40 | {"no scheme", "github.com/owner/repo"}, 41 | {"URL too long", "https://github.com/" + strings.Repeat("a", 3000)}, 42 | } 43 | 44 | for _, tt := range tests { 45 | t.Run(tt.name, func(t *testing.T) { 46 | if err := ValidateURL(tt.url); err == nil { 47 | t.Errorf("ValidateURL() error = nil, want error") 48 | } 49 | }) 50 | } 51 | } 52 | 53 | func TestValidateURL_PercentEncoding(t *testing.T) { 54 | tests := []struct { 55 | name string 56 | url string 57 | }{ 58 | {"percent-encoded null byte", "https://github.com/owner/repo%00"}, 59 | {"percent-encoded newline", "https://github.com/owner/repo%0A"}, 60 | {"percent-encoded carriage return", "https://github.com/owner/repo%0D"}, 61 | {"percent-encoded space", "https://github.com/owner/repo%20"}, 62 | {"percent-encoded slash", "https://github.com/owner%2Frepo"}, 63 | {"double-encoded null", "https://github.com/owner/repo%2500"}, 64 | } 65 | 66 | for _, tt := range tests { 67 | t.Run(tt.name, func(t *testing.T) { 68 | if err := ValidateURL(tt.url); err == nil { 69 | t.Errorf("ValidateURL() error = nil, want error") 70 | } 71 | }) 72 | } 73 | } 74 | 75 | func TestValidateURL_ControlCharacters(t *testing.T) { 76 | tests := []struct { 77 | name string 78 | url string 79 | }{ 80 | {"null byte", "https://github.com/owner\x00/repo"}, 81 | {"newline character", "https://github.com/owner/repo\n"}, 82 | {"carriage return", "https://github.com/owner/repo\r"}, 83 | {"tab character", "https://github.com/owner/repo\t"}, 84 | {"vertical tab", "https://github.com/owner/repo\v"}, 85 | {"form feed", "https://github.com/owner/repo\f"}, 86 | {"bell character", "https://github.com/owner/repo\a"}, 87 | {"backspace", "https://github.com/owner/repo\b"}, 88 | {"delete character", "https://github.com/owner/repo\x7F"}, 89 | } 90 | 91 | for _, tt := range tests { 92 | t.Run(tt.name, func(t *testing.T) { 93 | if err := ValidateURL(tt.url); err == nil { 94 | t.Errorf("ValidateURL() error = nil, want error") 95 | } 96 | }) 97 | } 98 | } 99 | 100 | func TestValidateURL_ShellMetacharacters(t *testing.T) { 101 | tests := []struct { 102 | name string 103 | url string 104 | }{ 105 | {"semicolon", "https://github.com/owner/repo;ls"}, 106 | {"pipe character", "https://github.com/owner/repo|cat"}, 107 | {"ampersand", "https://github.com/owner/repo&"}, 108 | {"backtick", "https://github.com/owner/repo`whoami`"}, 109 | {"dollar sign", "https://github.com/owner/repo$PATH"}, 110 | {"command substitution", "https://github.com/owner/repo$(whoami)"}, 111 | {"parentheses", "https://github.com/owner/repo()"}, 112 | {"curly braces", "https://github.com/owner/repo{}"}, 113 | {"square brackets", "https://github.com/owner/repo[]"}, 114 | {"less than", "https://github.com/owner/repofile"}, 116 | } 117 | 118 | for _, tt := range tests { 119 | t.Run(tt.name, func(t *testing.T) { 120 | if err := ValidateURL(tt.url); err == nil { 121 | t.Errorf("ValidateURL() error = nil, want error") 122 | } 123 | }) 124 | } 125 | } 126 | 127 | func TestValidateURL_WindowsAttacks(t *testing.T) { 128 | tests := []struct { 129 | name string 130 | url string 131 | }{ 132 | {"Windows path separator backslash", "https://github.com/owner\\repo"}, 133 | {"Windows command separator", "https://github.com/owner/repo&&calc"}, 134 | {"Windows batch variable", "https://github.com/owner/%TEMP%"}, 135 | {"caret character (Windows escape)", "https://github.com/owner/repo^"}, 136 | } 137 | 138 | for _, tt := range tests { 139 | t.Run(tt.name, func(t *testing.T) { 140 | if err := ValidateURL(tt.url); err == nil { 141 | t.Errorf("ValidateURL() error = nil, want error") 142 | } 143 | }) 144 | } 145 | } 146 | 147 | func TestValidateURL_URLComponents(t *testing.T) { 148 | tests := []struct { 149 | name string 150 | url string 151 | }{ 152 | {"user info in URL", "https://user@github.com/owner/repo"}, 153 | {"password in URL", "https://user:pass@github.com/owner/repo"}, 154 | {"fragment", "https://github.com/owner/repo#section"}, 155 | {"query parameters (must use OpenWithParams)", "https://github.com/owner/repo?foo=bar"}, 156 | {"custom port 8080", "https://github.com:8080/owner/repo"}, 157 | {"SSH port 22", "https://github.com:22/owner/repo"}, 158 | {"explicit HTTPS port 443", "https://github.com:443/owner/repo"}, 159 | {"colon in path", "https://github.com/owner:repo/path"}, 160 | } 161 | 162 | for _, tt := range tests { 163 | t.Run(tt.name, func(t *testing.T) { 164 | if err := ValidateURL(tt.url); err == nil { 165 | t.Errorf("ValidateURL() error = nil, want error") 166 | } 167 | }) 168 | } 169 | } 170 | 171 | func TestValidateURL_UnicodeAttacks(t *testing.T) { 172 | tests := []struct { 173 | name string 174 | url string 175 | }{ 176 | {"Unicode character", "https://github.com/owner/repō"}, 177 | {"IDN homograph attack (Cyrillic)", "https://gіthub.com/owner/repo"}, // Cyrillic 'і' instead of 'i' 178 | {"Right-to-left override", "https://github.com/owner/repo\u202E"}, 179 | {"Zero-width space", "https://github.com/owner\u200B/repo"}, 180 | } 181 | 182 | for _, tt := range tests { 183 | t.Run(tt.name, func(t *testing.T) { 184 | if err := ValidateURL(tt.url); err == nil { 185 | t.Errorf("ValidateURL() error = nil, want error") 186 | } 187 | }) 188 | } 189 | } 190 | 191 | func TestValidateURL_PathTraversal(t *testing.T) { 192 | tests := []struct { 193 | name string 194 | url string 195 | }{ 196 | {"dot dot slash", "https://github.com/../etc/passwd"}, 197 | {"double slash in path", "https://github.com//owner/repo"}, 198 | } 199 | 200 | for _, tt := range tests { 201 | t.Run(tt.name, func(t *testing.T) { 202 | if err := ValidateURL(tt.url); err == nil { 203 | t.Errorf("ValidateURL() error = nil, want error") 204 | } 205 | }) 206 | } 207 | } 208 | 209 | func TestValidateURL_SpecialCharacters(t *testing.T) { 210 | tests := []struct { 211 | name string 212 | url string 213 | }{ 214 | {"single quote", "https://github.com/owner'/repo"}, 215 | {"double quote", "https://github.com/owner\"/repo"}, 216 | {"plus sign", "https://github.com/owner+org/repo"}, 217 | {"at sign", "https://github.com/owner@org/repo"}, 218 | {"asterisk", "https://github.com/owner*/repo"}, 219 | {"tilde", "https://github.com/~owner/repo"}, 220 | {"exclamation", "https://github.com/owner!/repo"}, 221 | } 222 | 223 | for _, tt := range tests { 224 | t.Run(tt.name, func(t *testing.T) { 225 | if err := ValidateURL(tt.url); err == nil { 226 | t.Errorf("ValidateURL() error = nil, want error") 227 | } 228 | }) 229 | } 230 | } 231 | 232 | func TestOpenWithParams(t *testing.T) { 233 | tests := []struct { 234 | name string 235 | baseURL string 236 | params map[string]string 237 | wantErr bool 238 | }{ 239 | // Valid cases 240 | { 241 | name: "valid URL with simple param", 242 | baseURL: "https://github.com/owner/repo/pull/123", 243 | params: map[string]string{"goose": "1"}, 244 | wantErr: false, 245 | }, 246 | { 247 | name: "valid URL with multiple params", 248 | baseURL: "https://github.com/owner/repo/pull/123", 249 | params: map[string]string{"goose": "review", "source": "tray"}, 250 | wantErr: false, 251 | }, 252 | { 253 | name: "valid URL with underscores in param", 254 | baseURL: "https://github.com/owner/repo/pull/123", 255 | params: map[string]string{"goose": "fix_tests"}, 256 | wantErr: false, 257 | }, 258 | { 259 | name: "valid URL with dashes in param", 260 | baseURL: "https://github.com/owner/repo/pull/123", 261 | params: map[string]string{"goose": "ready-to-review"}, 262 | wantErr: false, 263 | }, 264 | 265 | // Invalid base URLs 266 | { 267 | name: "base URL with control char", 268 | baseURL: "https://github.com/owner/repo\n", 269 | params: map[string]string{"goose": "1"}, 270 | wantErr: true, 271 | }, 272 | { 273 | name: "base URL with percent encoding", 274 | baseURL: "https://github.com/owner%20/repo", 275 | params: map[string]string{"goose": "1"}, 276 | wantErr: true, 277 | }, 278 | 279 | // Invalid parameter keys 280 | { 281 | name: "param key with space", 282 | baseURL: "https://github.com/owner/repo/pull/123", 283 | params: map[string]string{"bad key": "value"}, 284 | wantErr: true, 285 | }, 286 | { 287 | name: "param key with special char", 288 | baseURL: "https://github.com/owner/repo/pull/123", 289 | params: map[string]string{"key;": "value"}, 290 | wantErr: true, 291 | }, 292 | { 293 | name: "param key with dot", 294 | baseURL: "https://github.com/owner/repo/pull/123", 295 | params: map[string]string{"key.name": "value"}, 296 | wantErr: true, 297 | }, 298 | 299 | // Invalid parameter values 300 | { 301 | name: "param value with semicolon", 302 | baseURL: "https://github.com/owner/repo/pull/123", 303 | params: map[string]string{"goose": "value;rm -rf"}, 304 | wantErr: true, 305 | }, 306 | { 307 | name: "param value with pipe", 308 | baseURL: "https://github.com/owner/repo/pull/123", 309 | params: map[string]string{"goose": "value|cat"}, 310 | wantErr: true, 311 | }, 312 | { 313 | name: "param value with ampersand", 314 | baseURL: "https://github.com/owner/repo/pull/123", 315 | params: map[string]string{"goose": "value&other"}, 316 | wantErr: true, 317 | }, 318 | { 319 | name: "param value with space", 320 | baseURL: "https://github.com/owner/repo/pull/123", 321 | params: map[string]string{"goose": "value with space"}, 322 | wantErr: true, 323 | }, 324 | { 325 | name: "param value with quote", 326 | baseURL: "https://github.com/owner/repo/pull/123", 327 | params: map[string]string{"goose": "value\""}, 328 | wantErr: true, 329 | }, 330 | { 331 | name: "param value with backtick", 332 | baseURL: "https://github.com/owner/repo/pull/123", 333 | params: map[string]string{"goose": "`whoami`"}, 334 | wantErr: true, 335 | }, 336 | { 337 | name: "param value with dollar sign", 338 | baseURL: "https://github.com/owner/repo/pull/123", 339 | params: map[string]string{"goose": "$PATH"}, 340 | wantErr: true, 341 | }, 342 | { 343 | name: "param value with percent", 344 | baseURL: "https://github.com/owner/repo/pull/123", 345 | params: map[string]string{"goose": "value%00"}, 346 | wantErr: true, 347 | }, 348 | { 349 | name: "param value with newline", 350 | baseURL: "https://github.com/owner/repo/pull/123", 351 | params: map[string]string{"goose": "value\n"}, 352 | wantErr: true, 353 | }, 354 | } 355 | 356 | for _, tt := range tests { 357 | t.Run(tt.name, func(t *testing.T) { 358 | ctx := context.Background() 359 | err := OpenWithParams(ctx, tt.baseURL, tt.params) 360 | 361 | // We expect an error from validation or from the actual open command 362 | // If wantErr is true, we expect validation to fail 363 | // If wantErr is false, we might get an error from the actual open (which is OK for testing) 364 | if tt.wantErr { 365 | if err == nil { 366 | t.Errorf("OpenWithParams() expected error but got none") 367 | } 368 | } 369 | // For valid cases, we just check that validation passed 370 | // (the actual browser open will fail in tests, which is expected) 371 | }) 372 | } 373 | } 374 | 375 | func TestValidateGitHubPRURL(t *testing.T) { 376 | tests := []struct { 377 | name string 378 | url string 379 | wantErr bool 380 | }{ 381 | // Valid GitHub PR URLs 382 | { 383 | name: "valid PR URL", 384 | url: "https://github.com/owner/repo/pull/123", 385 | wantErr: false, 386 | }, 387 | { 388 | name: "valid PR URL with goose param", 389 | url: "https://github.com/owner/repo/pull/123?goose=review", 390 | wantErr: false, 391 | }, 392 | { 393 | name: "valid PR URL with goose underscore param", 394 | url: "https://github.com/owner/repo/pull/123?goose=fix_tests", 395 | wantErr: false, 396 | }, 397 | { 398 | name: "valid PR URL with hyphen in owner", 399 | url: "https://github.com/owner-name/repo/pull/1", 400 | wantErr: false, 401 | }, 402 | { 403 | name: "valid PR URL with dots in repo", 404 | url: "https://github.com/owner/repo.name/pull/999", 405 | wantErr: false, 406 | }, 407 | { 408 | name: "valid PR URL large number", 409 | url: "https://github.com/owner/repo/pull/9999999999", 410 | wantErr: false, 411 | }, 412 | 413 | // Invalid - wrong format 414 | { 415 | name: "not a PR URL", 416 | url: "https://github.com/owner/repo", 417 | wantErr: true, 418 | }, 419 | { 420 | name: "issue URL instead of PR", 421 | url: "https://github.com/owner/repo/issues/123", 422 | wantErr: true, 423 | }, 424 | { 425 | name: "PR URL with extra path", 426 | url: "https://github.com/owner/repo/pull/123/files", 427 | wantErr: true, 428 | }, 429 | { 430 | name: "PR number with leading zero", 431 | url: "https://github.com/owner/repo/pull/0123", 432 | wantErr: true, 433 | }, 434 | { 435 | name: "PR number zero", 436 | url: "https://github.com/owner/repo/pull/0", 437 | wantErr: true, 438 | }, 439 | { 440 | name: "wrong domain", 441 | url: "https://gitlab.com/owner/repo/pull/123", 442 | wantErr: true, 443 | }, 444 | { 445 | name: "HTTP instead of HTTPS", 446 | url: "http://github.com/owner/repo/pull/123", 447 | wantErr: true, 448 | }, 449 | 450 | // Invalid - security 451 | { 452 | name: "PR URL with wrong query param", 453 | url: "https://github.com/owner/repo/pull/123?foo=bar", 454 | wantErr: true, 455 | }, 456 | { 457 | name: "PR URL with multiple params", 458 | url: "https://github.com/owner/repo/pull/123?goose=1&other=2", 459 | wantErr: true, 460 | }, 461 | { 462 | name: "PR URL with fragment", 463 | url: "https://github.com/owner/repo/pull/123#section", 464 | wantErr: true, 465 | }, 466 | { 467 | name: "PR URL with user info", 468 | url: "https://user@github.com/owner/repo/pull/123", 469 | wantErr: true, 470 | }, 471 | { 472 | name: "PR URL with percent encoding", 473 | url: "https://github.com/owner/repo/pull/123%00", 474 | wantErr: true, 475 | }, 476 | { 477 | name: "PR URL with newline", 478 | url: "https://github.com/owner/repo/pull/123\n", 479 | wantErr: true, 480 | }, 481 | { 482 | name: "PR URL with semicolon", 483 | url: "https://github.com/owner/repo/pull/123;ls", 484 | wantErr: true, 485 | }, 486 | } 487 | 488 | for _, tt := range tests { 489 | t.Run(tt.name, func(t *testing.T) { 490 | err := ValidateGitHubPRURL(tt.url) 491 | if (err != nil) != tt.wantErr { 492 | t.Errorf("ValidateGitHubPRURL() error = %v, wantErr %v", err, tt.wantErr) 493 | } 494 | }) 495 | } 496 | } 497 | 498 | func TestValidateParamString(t *testing.T) { 499 | tests := []struct { 500 | name string 501 | input string 502 | wantErr bool 503 | }{ 504 | {"simple", "abc", false}, 505 | {"with numbers", "abc123", false}, 506 | {"with dash", "abc-def", false}, 507 | {"with underscore", "abc_def", false}, 508 | {"mixed", "Test_Value-123", false}, 509 | {"empty", "", true}, 510 | {"with space", "abc def", true}, 511 | {"with dot", "abc.def", true}, 512 | {"with special char", "abc@def", true}, 513 | {"with slash", "abc/def", true}, 514 | {"with percent", "abc%20", true}, 515 | {"with newline", "abc\n", true}, 516 | } 517 | 518 | for _, tt := range tests { 519 | t.Run(tt.name, func(t *testing.T) { 520 | err := validateParamString(tt.input) 521 | if (err != nil) != tt.wantErr { 522 | t.Errorf("validateParamString(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) 523 | } 524 | }) 525 | } 526 | } 527 | -------------------------------------------------------------------------------- /cmd/review-goose/sprinkler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "slices" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/codeGROOVE-dev/retry" 14 | "github.com/codeGROOVE-dev/sprinkler/pkg/client" 15 | "github.com/codeGROOVE-dev/turnclient/pkg/turn" 16 | "github.com/gen2brain/beeep" 17 | ) 18 | 19 | const ( 20 | eventChannelSize = 100 // Buffer size for event channel 21 | eventDedupWindow = 5 * time.Second // Time window for deduplicating events 22 | eventMapMaxSize = 1000 // Maximum entries in event dedup map 23 | eventMapCleanupAge = 1 * time.Hour // Age threshold for cleaning up old entries 24 | sprinklerMaxRetries = 3 // Max retries for Turn API calls 25 | sprinklerMaxDelay = 10 * time.Second // Max delay between retries 26 | ) 27 | 28 | // prEvent captures the essential details from a sprinkler event. 29 | type prEvent struct { 30 | timestamp time.Time 31 | url string 32 | } 33 | 34 | // sprinklerMonitor manages WebSocket event subscriptions for all user orgs. 35 | type sprinklerMonitor struct { 36 | lastConnectedAt time.Time 37 | app *App 38 | client *client.Client 39 | cancel context.CancelFunc 40 | eventChan chan prEvent 41 | lastEventMap map[string]time.Time 42 | token string 43 | orgs []string 44 | mu sync.RWMutex 45 | isRunning bool 46 | isConnected bool 47 | } 48 | 49 | // newSprinklerMonitor creates a new sprinkler monitor for real-time PR events. 50 | func newSprinklerMonitor(app *App, token string) *sprinklerMonitor { 51 | return &sprinklerMonitor{ 52 | app: app, 53 | token: token, 54 | orgs: make([]string, 0), 55 | eventChan: make(chan prEvent, eventChannelSize), 56 | lastEventMap: make(map[string]time.Time), 57 | } 58 | } 59 | 60 | // updateOrgs sets the list of organizations to monitor. 61 | func (sm *sprinklerMonitor) updateOrgs(orgs []string) { 62 | sm.mu.Lock() 63 | defer sm.mu.Unlock() 64 | 65 | if len(orgs) == 0 { 66 | slog.Debug("[SPRINKLER] No organizations provided") 67 | return 68 | } 69 | 70 | slog.Info("[SPRINKLER] Setting organizations", "orgs", orgs, "count", len(orgs)) 71 | sm.orgs = make([]string, len(orgs)) 72 | copy(sm.orgs, orgs) 73 | } 74 | 75 | // start begins monitoring for PR events across all user orgs. 76 | func (sm *sprinklerMonitor) start(ctx context.Context) error { 77 | sm.mu.Lock() 78 | defer sm.mu.Unlock() 79 | 80 | if sm.isRunning { 81 | slog.Debug("[SPRINKLER] Monitor already running") 82 | return nil // Already running 83 | } 84 | 85 | if len(sm.orgs) == 0 { 86 | slog.Debug("[SPRINKLER] No organizations to monitor, skipping start") 87 | return nil 88 | } 89 | 90 | slog.Info("[SPRINKLER] Starting event monitor", 91 | "orgs", sm.orgs, 92 | "org_count", len(sm.orgs)) 93 | 94 | // Create context with cancel for shutdown 95 | monitorCtx, cancel := context.WithCancel(ctx) 96 | sm.cancel = cancel 97 | 98 | // Create logger that discards output unless debug mode 99 | var sprinklerLogger *slog.Logger 100 | if slog.Default().Enabled(ctx, slog.LevelDebug) { 101 | sprinklerLogger = slog.Default() 102 | } else { 103 | // Use a handler that discards all logs 104 | sprinklerLogger = slog.New(slog.NewTextHandler(nil, &slog.HandlerOptions{ 105 | Level: slog.LevelError + 1, // Level higher than any log level to discard all 106 | })) 107 | } 108 | 109 | config := client.Config{ 110 | ServerURL: "wss://" + client.DefaultServerAddress + "/ws", 111 | Token: sm.token, 112 | Organization: "*", // Monitor all orgs 113 | EventTypes: []string{"pull_request"}, 114 | UserEventsOnly: false, 115 | Verbose: false, 116 | NoReconnect: false, 117 | Logger: sprinklerLogger, 118 | OnConnect: func() { 119 | sm.mu.Lock() 120 | sm.isConnected = true 121 | sm.lastConnectedAt = time.Now() 122 | sm.mu.Unlock() 123 | slog.Info("[SPRINKLER] WebSocket connected") 124 | }, 125 | OnDisconnect: func(err error) { 126 | sm.mu.Lock() 127 | sm.isConnected = false 128 | sm.mu.Unlock() 129 | if err != nil && !errors.Is(err, context.Canceled) { 130 | slog.Warn("[SPRINKLER] WebSocket disconnected", "error", err) 131 | } 132 | }, 133 | OnEvent: func(event client.Event) { 134 | sm.handleEvent(event) 135 | }, 136 | } 137 | 138 | wsClient, err := client.New(config) 139 | if err != nil { 140 | slog.Error("[SPRINKLER] Failed to create WebSocket client", "error", err) 141 | return fmt.Errorf("create sprinkler client: %w", err) 142 | } 143 | 144 | sm.client = wsClient 145 | sm.isRunning = true 146 | 147 | slog.Info("[SPRINKLER] Starting event processor goroutine") 148 | // Start event processor 149 | go sm.processEvents(monitorCtx) 150 | 151 | slog.Info("[SPRINKLER] Starting WebSocket client goroutine") 152 | // Start WebSocket client with error recovery 153 | go func() { 154 | defer func() { 155 | if r := recover(); r != nil { 156 | slog.Error("[SPRINKLER] WebSocket goroutine panic", 157 | "panic", r) 158 | sm.mu.Lock() 159 | sm.isRunning = false 160 | sm.mu.Unlock() 161 | } 162 | }() 163 | 164 | startTime := time.Now() 165 | if err := wsClient.Start(monitorCtx); err != nil && !errors.Is(err, context.Canceled) { 166 | slog.Error("[SPRINKLER] WebSocket client error", 167 | "error", err, 168 | "uptime", time.Since(startTime).Round(time.Second)) 169 | sm.mu.Lock() 170 | sm.isRunning = false 171 | sm.mu.Unlock() 172 | } else { 173 | slog.Info("[SPRINKLER] WebSocket client stopped gracefully", 174 | "uptime", time.Since(startTime).Round(time.Second)) 175 | } 176 | }() 177 | 178 | slog.Info("[SPRINKLER] Event monitor started successfully") 179 | return nil 180 | } 181 | 182 | // handleEvent processes incoming PR events. 183 | func (sm *sprinklerMonitor) handleEvent(event client.Event) { 184 | // Filter by event type 185 | if event.Type != "pull_request" { 186 | slog.Debug("[SPRINKLER] Ignoring non-PR event", "type", event.Type) 187 | return 188 | } 189 | 190 | if event.URL == "" { 191 | slog.Warn("[SPRINKLER] Received PR event with empty URL", "type", event.Type) 192 | return 193 | } 194 | 195 | // Extract org from URL (format: https://github.com/org/repo/pull/123) 196 | parts := strings.Split(event.URL, "/") 197 | const minParts = 5 198 | if len(parts) < minParts || parts[2] != "github.com" { 199 | slog.Warn("[SPRINKLER] Failed to extract org from URL", "url", event.URL) 200 | return 201 | } 202 | org := parts[3] 203 | 204 | // Check if this org is in our monitored list 205 | sm.mu.RLock() 206 | monitored := slices.Contains(sm.orgs, org) 207 | orgCount := len(sm.orgs) 208 | sm.mu.RUnlock() 209 | 210 | if !monitored { 211 | slog.Debug("[SPRINKLER] Event from unmonitored org", 212 | "org", org, 213 | "url", event.URL, 214 | "monitored_orgs", orgCount) 215 | return 216 | } 217 | 218 | // Dedupe events - only process if we haven't seen this URL recently 219 | sm.mu.Lock() 220 | lastSeen, exists := sm.lastEventMap[event.URL] 221 | now := time.Now() 222 | if exists && now.Sub(lastSeen) < eventDedupWindow { 223 | sm.mu.Unlock() 224 | slog.Debug("[SPRINKLER] Skipping duplicate event", 225 | "url", event.URL, 226 | "last_seen", now.Sub(lastSeen).Round(time.Millisecond)) 227 | return 228 | } 229 | sm.lastEventMap[event.URL] = now 230 | 231 | // Clean up old entries to prevent memory leak 232 | if len(sm.lastEventMap) > eventMapMaxSize { 233 | // Remove entries older than the cleanup age threshold 234 | cutoff := now.Add(-eventMapCleanupAge) 235 | for url, timestamp := range sm.lastEventMap { 236 | if timestamp.Before(cutoff) { 237 | delete(sm.lastEventMap, url) 238 | } 239 | } 240 | slog.Debug("[SPRINKLER] Cleaned up event map", 241 | "entries_remaining", len(sm.lastEventMap)) 242 | } 243 | sm.mu.Unlock() 244 | 245 | slog.Info("[SPRINKLER] PR event received", 246 | "url", event.URL, 247 | "org", org, 248 | "timestamp", event.Timestamp.Format(time.RFC3339)) 249 | 250 | // Send to event channel for processing (non-blocking) 251 | select { 252 | case sm.eventChan <- prEvent{timestamp: event.Timestamp, url: event.URL}: 253 | slog.Debug("[SPRINKLER] Event queued for processing", 254 | "url", event.URL, 255 | "timestamp", event.Timestamp.Format(time.RFC3339)) 256 | default: 257 | slog.Warn("[SPRINKLER] Event channel full, dropping event", 258 | "url", event.URL, 259 | "channel_size", cap(sm.eventChan)) 260 | } 261 | } 262 | 263 | // processEvents handles PR events by checking if they're blocking and notifying. 264 | func (sm *sprinklerMonitor) processEvents(ctx context.Context) { 265 | defer func() { 266 | if r := recover(); r != nil { 267 | slog.Error("[SPRINKLER] Event processor panic", "panic", r) 268 | } 269 | }() 270 | 271 | for { 272 | select { 273 | case <-ctx.Done(): 274 | return 275 | case evt := <-sm.eventChan: 276 | sm.checkAndNotify(ctx, evt) 277 | } 278 | } 279 | } 280 | 281 | // checkAndNotify checks if a PR is blocking and sends notification if needed. 282 | func (sm *sprinklerMonitor) checkAndNotify(ctx context.Context, evt prEvent) { 283 | start := time.Now() 284 | 285 | user := sm.currentUser() 286 | if user == "" { 287 | slog.Debug("[SPRINKLER] Skipping check - no user configured", "url", evt.url) 288 | return 289 | } 290 | 291 | repo, n := parseRepoAndNumberFromURL(evt.url) 292 | if repo == "" || n == 0 { 293 | slog.Warn("[SPRINKLER] Failed to parse PR URL", "url", evt.url) 294 | return 295 | } 296 | 297 | data, cached := sm.fetchTurnData(ctx, evt, repo, n, start) 298 | if data == nil { 299 | return 300 | } 301 | 302 | if sm.handleClosedPR(ctx, data, evt.url, repo, n, cached) { 303 | return 304 | } 305 | 306 | act := validateUserAction(data, user, repo, n, cached) 307 | if act == nil { 308 | return 309 | } 310 | 311 | if sm.handleNewPR(ctx, evt.url, repo, n, act) { 312 | return 313 | } 314 | 315 | if sm.isAlreadyTrackedAsBlocked(evt.url, repo, n) { 316 | return 317 | } 318 | 319 | slog.Info("[SPRINKLER] Blocking PR detected via event", 320 | "repo", repo, 321 | "number", n, 322 | "action", act.Kind, 323 | "reason", act.Reason, 324 | "event_timestamp", evt.timestamp.Format(time.RFC3339), 325 | "elapsed", time.Since(start).Round(time.Millisecond)) 326 | 327 | sm.sendNotifications(ctx, evt.url, repo, n, act) 328 | } 329 | 330 | // currentUser returns the configured user for the sprinkler monitor. 331 | func (sm *sprinklerMonitor) currentUser() string { 332 | user := "" 333 | if sm.app.currentUser != nil { 334 | user = sm.app.currentUser.GetLogin() 335 | } 336 | if sm.app.targetUser != "" { 337 | user = sm.app.targetUser 338 | } 339 | return user 340 | } 341 | 342 | // fetchTurnData retrieves PR data from Turn API with retry logic. 343 | func (sm *sprinklerMonitor) fetchTurnData(ctx context.Context, evt prEvent, repo string, n int, start time.Time) (*turn.CheckResponse, bool) { 344 | var data *turn.CheckResponse 345 | var cached bool 346 | 347 | err := retry.Do(func() error { 348 | var err error 349 | // Use event timestamp to bypass caching - this ensures we get fresh data for real-time events 350 | data, cached, err = sm.app.turnData(ctx, evt.url, evt.timestamp) 351 | if err != nil { 352 | slog.Debug("[SPRINKLER] Turn API call failed (will retry)", 353 | "repo", repo, 354 | "number", n, 355 | "event_timestamp", evt.timestamp.Format(time.RFC3339), 356 | "error", err) 357 | return err 358 | } 359 | return nil 360 | }, 361 | retry.Attempts(sprinklerMaxRetries), 362 | retry.DelayType(retry.CombineDelay(retry.BackOffDelay, retry.RandomDelay)), 363 | retry.MaxDelay(sprinklerMaxDelay), 364 | retry.OnRetry(func(attempt uint, err error) { 365 | slog.Warn("[SPRINKLER] Retrying Turn API call", 366 | "attempt", attempt+1, 367 | "repo", repo, 368 | "number", n, 369 | "error", err) 370 | }), 371 | retry.Context(ctx), 372 | ) 373 | if err != nil { 374 | slog.Warn("[SPRINKLER] Failed to get turn data after retries", 375 | "repo", repo, 376 | "number", n, 377 | "event_timestamp", evt.timestamp.Format(time.RFC3339), 378 | "elapsed", time.Since(start).Round(time.Millisecond), 379 | "error", err) 380 | return nil, false 381 | } 382 | 383 | return data, cached 384 | } 385 | 386 | // handleClosedPR processes closed or merged PRs and returns true if the PR was closed. 387 | func (sm *sprinklerMonitor) handleClosedPR( 388 | ctx context.Context, data *turn.CheckResponse, url, repo string, n int, cached bool, 389 | ) bool { 390 | state := "" 391 | merged := false 392 | if data != nil { 393 | state = data.PullRequest.State 394 | merged = data.PullRequest.Merged 395 | } 396 | 397 | slog.Info("[SPRINKLER] Turn API response", 398 | "repo", repo, 399 | "number", n, 400 | "cached", cached, 401 | "state", state, 402 | "merged", merged, 403 | "has_data", data != nil, 404 | "has_analysis", data != nil && data.Analysis.NextAction != nil) 405 | 406 | if state == "closed" || merged { 407 | sm.removeClosedPR(ctx, url, repo, n, state, merged) 408 | return true 409 | } 410 | 411 | return false 412 | } 413 | 414 | // validateUserAction checks if the user needs to take action and returns the action if critical. 415 | func validateUserAction(data *turn.CheckResponse, user, repo string, n int, cached bool) *turn.Action { 416 | if data == nil || data.Analysis.NextAction == nil { 417 | slog.Debug("[SPRINKLER] No turn data available", 418 | "repo", repo, 419 | "number", n, 420 | "cached", cached) 421 | return nil 422 | } 423 | 424 | act, exists := data.Analysis.NextAction[user] 425 | if !exists { 426 | slog.Debug("[SPRINKLER] No action required for user", 427 | "repo", repo, 428 | "number", n, 429 | "user", user, 430 | "state", data.PullRequest.State) 431 | return nil 432 | } 433 | 434 | if !act.Critical { 435 | slog.Debug("[SPRINKLER] Non-critical action, skipping notification", 436 | "repo", repo, 437 | "number", n, 438 | "action", act.Kind, 439 | "critical", act.Critical) 440 | return nil 441 | } 442 | 443 | return &act 444 | } 445 | 446 | // handleNewPR triggers a refresh for PRs not in our lists and returns true if handled. 447 | func (sm *sprinklerMonitor) handleNewPR(ctx context.Context, url, repo string, n int, act *turn.Action) bool { 448 | sm.app.mu.RLock() 449 | inIncoming := findPRInList(sm.app.incoming, url) 450 | inOutgoing := false 451 | if !inIncoming { 452 | inOutgoing = findPRInList(sm.app.outgoing, url) 453 | } 454 | sm.app.mu.RUnlock() 455 | 456 | if !inIncoming && !inOutgoing { 457 | slog.Info("[SPRINKLER] New PR detected, triggering refresh", 458 | "repo", repo, 459 | "number", n, 460 | "action", act.Kind) 461 | go sm.app.updatePRs(ctx) 462 | return true 463 | } 464 | 465 | return false 466 | } 467 | 468 | // findPRInList searches for a PR URL in the given list. 469 | func findPRInList(prs []PR, url string) bool { 470 | for i := range prs { 471 | if prs[i].URL == url { 472 | return true 473 | } 474 | } 475 | return false 476 | } 477 | 478 | // isAlreadyTrackedAsBlocked checks if the PR is already tracked as blocked. 479 | func (sm *sprinklerMonitor) isAlreadyTrackedAsBlocked(url, repo string, n int) bool { 480 | sm.app.mu.RLock() 481 | defer sm.app.mu.RUnlock() 482 | 483 | for i := range sm.app.incoming { 484 | if sm.app.incoming[i].URL == url && sm.app.incoming[i].IsBlocked { 485 | slog.Debug("[SPRINKLER] Found in incoming blocked PRs", "repo", repo, "number", n) 486 | return true 487 | } 488 | } 489 | 490 | for i := range sm.app.outgoing { 491 | if sm.app.outgoing[i].URL == url && sm.app.outgoing[i].IsBlocked { 492 | slog.Debug("[SPRINKLER] Found in outgoing blocked PRs", "repo", repo, "number", n) 493 | return true 494 | } 495 | } 496 | 497 | return false 498 | } 499 | 500 | // sendNotifications sends desktop notification, plays sound, and attempts auto-open. 501 | func (sm *sprinklerMonitor) sendNotifications(ctx context.Context, url, repo string, n int, act *turn.Action) { 502 | title := fmt.Sprintf("PR Event: #%d needs %s", n, act.Kind) 503 | msg := fmt.Sprintf("%s #%d - %s", repo, n, act.Reason) 504 | 505 | go func() { 506 | if err := beeep.Notify(title, msg, ""); err != nil { 507 | slog.Warn("[SPRINKLER] Failed to send desktop notification", 508 | "repo", repo, 509 | "number", n, 510 | "error", err) 511 | } else { 512 | slog.Info("[SPRINKLER] Sent desktop notification", 513 | "repo", repo, 514 | "number", n) 515 | } 516 | }() 517 | 518 | if sm.app.enableAudioCues && time.Since(sm.app.startTime) > startupGracePeriod { 519 | slog.Debug("[SPRINKLER] Playing notification sound", 520 | "repo", repo, 521 | "number", n, 522 | "soundType", "honk") 523 | sm.app.playSound(ctx, "honk") 524 | } 525 | 526 | if sm.app.enableAutoBrowser { 527 | slog.Debug("[SPRINKLER] Attempting auto-open", 528 | "repo", repo, 529 | "number", n) 530 | sm.app.tryAutoOpenPR(ctx, &PR{ 531 | URL: url, 532 | Repository: repo, 533 | Number: n, 534 | IsBlocked: true, 535 | ActionKind: string(act.Kind), 536 | }, sm.app.enableAutoBrowser, sm.app.startTime) 537 | } 538 | } 539 | 540 | // removeClosedPR removes a closed or merged PR from the in-memory lists. 541 | func (sm *sprinklerMonitor) removeClosedPR(ctx context.Context, url, repo string, n int, state string, merged bool) { 542 | slog.Info("[SPRINKLER] PR closed/merged, removing from lists", 543 | "repo", repo, 544 | "number", n, 545 | "state", state, 546 | "merged", merged, 547 | "url", url) 548 | 549 | // Remove from in-memory lists immediately 550 | sm.app.mu.Lock() 551 | inBefore := len(sm.app.incoming) 552 | outBefore := len(sm.app.outgoing) 553 | 554 | // Filter out this PR from incoming 555 | in := make([]PR, 0, len(sm.app.incoming)) 556 | for i := range sm.app.incoming { 557 | if sm.app.incoming[i].URL != url { 558 | in = append(in, sm.app.incoming[i]) 559 | } 560 | } 561 | sm.app.incoming = in 562 | 563 | // Filter out this PR from outgoing 564 | out := make([]PR, 0, len(sm.app.outgoing)) 565 | for i := range sm.app.outgoing { 566 | if sm.app.outgoing[i].URL != url { 567 | out = append(out, sm.app.outgoing[i]) 568 | } 569 | } 570 | sm.app.outgoing = out 571 | sm.app.mu.Unlock() 572 | 573 | slog.Info("[SPRINKLER] Removed PR from lists", 574 | "url", url, 575 | "incoming_before", inBefore, 576 | "incoming_after", len(sm.app.incoming), 577 | "outgoing_before", outBefore, 578 | "outgoing_after", len(sm.app.outgoing)) 579 | 580 | // Update UI to reflect removal 581 | sm.app.updateMenu(ctx) 582 | } 583 | 584 | // stop stops the sprinkler monitor. 585 | func (sm *sprinklerMonitor) stop() { 586 | sm.mu.Lock() 587 | defer sm.mu.Unlock() 588 | 589 | if !sm.isRunning { 590 | return 591 | } 592 | 593 | slog.Info("[SPRINKLER] Stopping event monitor") 594 | sm.cancel() 595 | sm.isRunning = false 596 | } 597 | 598 | // connectionStatus returns the current WebSocket connection status. 599 | func (sm *sprinklerMonitor) connectionStatus() (connected bool, lastConnectedAt time.Time) { 600 | sm.mu.RLock() 601 | defer sm.mu.RUnlock() 602 | return sm.isConnected, sm.lastConnectedAt 603 | } 604 | 605 | // parseRepoAndNumberFromURL extracts repo and PR number from URL. 606 | func parseRepoAndNumberFromURL(url string) (repo string, number int) { 607 | // URL format: https://github.com/org/repo/pull/123 608 | const minParts = 7 609 | parts := strings.Split(url, "/") 610 | if len(parts) < minParts || parts[2] != "github.com" { 611 | return "", 0 612 | } 613 | 614 | repo = fmt.Sprintf("%s/%s", parts[3], parts[4]) 615 | 616 | var n int 617 | _, err := fmt.Sscanf(parts[6], "%d", &n) 618 | if err != nil { 619 | return "", 0 620 | } 621 | 622 | return repo, n 623 | } 624 | --------------------------------------------------------------------------------