diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d3d3067 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +permissions: {} + +jobs: + test: + name: Test + + runs-on: ${{ matrix.os }} + + permissions: + contents: read + + strategy: + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version: '1.24' + check-latest: 'true' + cache: 'true' + + - name: Install dependencies (Linux) + if: ${{ matrix.os == 'ubuntu-latest' }} + run: sudo apt-get update && sudo apt-get install -y gcc libgl1-mesa-dev xorg-dev + + - name: Build + run: make build + + - name: Test + run: make test diff --git a/Makefile b/Makefile index fd7c793..c219f36 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,10 @@ # BEGIN: lint-install . # http://github.com/codeGROOVE-dev/lint-install -.PHONY: lint +.PHONY: lint test +test: + go test -race ./... + lint: _lint LINT_ARCH := $(shell uname -m) diff --git a/README.md b/README.md index 8c81812..a47b101 100644 --- a/README.md +++ b/README.md @@ -44,11 +44,19 @@ prs --notify - `--blocked` - Only PRs blocking on you - `--include-stale` - Include old PRs (hidden by default) -- `--watch` - Live updates -- `--notify` - Desktop notifications -- `--bell` - Ring ASCII bell for new PRs in notify mode (default: true) -- `--org` - Filter to specific organization -- `--debug` - Show debug info +- `--watch` - Live updates with real-time WebSocket + polling +- `--exclude-orgs` - Comma-separated list of organizations to exclude +- `--verbose` - Show detailed logging + +### Color Output + +Colors are automatically adjusted based on your terminal capabilities. To disable colors entirely: + +```bash +NO_COLOR=1 prs +``` + +This respects the [NO_COLOR](https://no-color.org/) standard. ## Status Icons diff --git a/go.mod b/go.mod index 730a072..bb79175 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/ready-to-review/prs +module github.com/codeGROOVE-dev/prs go 1.23.4 @@ -6,7 +6,9 @@ require github.com/avast/retry-go/v4 v4.6.1 require ( github.com/charmbracelet/lipgloss v1.1.0 + github.com/codeGROOVE-dev/sprinkler v0.0.0-20250827124139-8fa72516d748 github.com/ready-to-review/turnclient v0.0.0-20250718014946-bb5bb107649f + golang.org/x/term v0.32.0 ) require ( @@ -15,13 +17,12 @@ require ( github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/fatih/color v1.18.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/net v0.41.0 // indirect golang.org/x/sys v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index 41dfa4b..4e5d296 100644 --- a/go.sum +++ b/go.sum @@ -2,31 +2,22 @@ github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIc github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/codeGROOVE-dev/sprinkler v0.0.0-20250827124139-8fa72516d748 h1:5RGwu9DI70NnLWvfNiM9oR/Jj0clYc7Pnfd3Sa1rcw4= +github.com/codeGROOVE-dev/sprinkler v0.0.0-20250827124139-8fa72516d748/go.mod h1:F8FIfuDdk3rkslIaCASnEt61A2lU8F+9lTPiFzFlIQ8= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= @@ -35,8 +26,6 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/ready-to-review/turnclient v0.0.0-20250716013813-67222568b615 h1:DDJ2o0vsg3Rpb2t0s1INx0UcH7ypigpqbYUTXnUBTaY= -github.com/ready-to-review/turnclient v0.0.0-20250716013813-67222568b615/go.mod h1:KVCVaRAn+IFk8QpXsHeJxjT+rxyYOsG8hhpDq5JWNlU= github.com/ready-to-review/turnclient v0.0.0-20250718014946-bb5bb107649f h1:l+hWs9J3BUQ3UkiYmgciJGSouivJl4ZAagmM936mnEg= github.com/ready-to-review/turnclient v0.0.0-20250718014946-bb5bb107649f/go.mod h1:KVCVaRAn+IFk8QpXsHeJxjT+rxyYOsG8hhpDq5JWNlU= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -46,13 +35,14 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index f369381..9574cca 100644 --- a/main.go +++ b/main.go @@ -2,7 +2,6 @@ package main import ( - "bufio" "context" "crypto/sha256" "encoding/hex" @@ -26,7 +25,9 @@ import ( "github.com/avast/retry-go/v4" "github.com/charmbracelet/lipgloss" + "github.com/codeGROOVE-dev/sprinkler/pkg/client" "github.com/ready-to-review/turnclient/pkg/turn" + "golang.org/x/term" ) // PR represents a GitHub pull request with all relevant information. @@ -73,81 +74,71 @@ type cacheEntry struct { Timestamp time.Time `json:"timestamp"` } -const ( - defaultTimeout = 30 * time.Second - defaultWatchInterval = 90 * time.Second - maxPerPage = 100 - retryAttempts = 3 - retryDelay = time.Second - retryMaxDelay = 10 * time.Second - enrichRetries = 2 - enrichDelay = 500 * time.Millisecond - enrichMaxDelay = 2 * time.Second - apiUserEndpoint = "https://api.github.com/user" - apiSearchEndpoint = "https://api.github.com/search/issues" - apiPullsEndpoint = "https://api.github.com/repos/%s/%s/pulls/%d" - defaultTurnServerURL = "https://turn.ready-to-review.dev" - maxConcurrent = 20 // Increased for better throughput - cacheTTL = 2 * time.Hour // 2 hours - maxOrgNameLength = 39 // GitHub org name limit - minTokenLength = 10 // Minimum GitHub token length - maxIdleConnsPerHost = 10 // HTTP client setting - idleConnTimeout = 90 * time.Second - minPRURLParts = 6 // Minimum parts in PR URL - repoPartIndex = 4 // Index of repo in URL parts - prTypePartIndex = 5 // Index of PR type in URL parts - numberPartIndex = 6 // Index of PR number in URL parts - daysInWeek = 7 // Days in a week - truncatedURLLength = 80 // Max URL display length - cacheFileMode = 0o644 // File permissions for cache files -) - -// Style definitions. -var ( - titleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF6B6B")). - Bold(true). - Underline(true) +// prRefreshTracker tracks the last refresh time for PRs to avoid duplicate API calls. +type prRefreshTracker struct { + lastRefresh map[string]time.Time + mu sync.RWMutex +} - headerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#4ECDC4")). - Bold(true). - Padding(0, 1) +func newPRRefreshTracker() *prRefreshTracker { + return &prRefreshTracker{ + lastRefresh: make(map[string]time.Time), + } +} - prTitleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#45B7D1")). - Bold(true) +func (t *prRefreshTracker) shouldRefresh(prURL string) bool { + t.mu.RLock() + lastTime, exists := t.lastRefresh[prURL] + t.mu.RUnlock() - ageStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#96CEB4")) + if !exists { + return true + } - urlStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FECA57")). - Underline(true) + return time.Since(lastTime) > time.Duration(prRefreshCooldownSecs)*time.Second +} - infoStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#54A0FF")) +func (t *prRefreshTracker) markRefreshed(prURL string) { + t.mu.Lock() + t.lastRefresh[prURL] = time.Now() + t.mu.Unlock() +} - successStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#5F27CD")). - Bold(true) +const ( + defaultTimeout = 30 * time.Second + defaultWatchInterval = 90 * time.Second + maxPerPage = 100 + retryAttempts = 3 + retryDelay = time.Second + retryMaxDelay = 10 * time.Second + enrichRetries = 2 + enrichDelay = 500 * time.Millisecond + enrichMaxDelay = 2 * time.Second + apiUserEndpoint = "https://api.github.com/user" + apiSearchEndpoint = "https://api.github.com/search/issues" + apiPullsEndpoint = "https://api.github.com/repos/%s/%s/pulls/%d" + defaultTurnServerURL = "https://turn.ready-to-review.dev" + defaultSprinklerURL = "wss://hook.g.robot-army.dev/ws" + maxConcurrent = 20 // Increased for better throughput + cacheTTL = 2 * time.Hour // 2 hours + prRefreshCooldownSecs = 1 // Avoid refreshing same PR within 1 second + maxOrgNameLength = 39 // GitHub org name limit + minTokenLength = 10 // Minimum GitHub token length + maxIdleConnsPerHost = 10 // HTTP client setting + idleConnTimeout = 90 * time.Second + minPRURLParts = 6 // Minimum parts in PR URL + minOrgURLParts = 4 // Minimum parts in org URL + repoPartIndex = 4 // Index of repo in URL parts + prTypePartIndex = 5 // Index of PR type in URL parts + numberPartIndex = 6 // Index of PR number in URL parts + prURLParts = 7 // Number of parts in a full PR URL + truncatedURLLength = 80 // Max URL display length + defaultTerminalWidth = 80 // Default terminal width if detection fails + titlePadding = 5 // Space between title and URL + minTitleLength = 20 // Minimum title display length + cacheFileMode = 0o644 // File permissions for cache files ) -// isValidOrgName validates GitHub organization names. -func isValidOrgName(org string) bool { - if org == "" || len(org) > maxOrgNameLength { - return false - } - for _, r := range org { - if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && - (r < '0' || r > '9') && r != '-' && r != '_' { - return false - } - } - // Cannot start or end with hyphen - return org[0] != '-' && org[len(org)-1] != '-' -} - // turnCache handles caching of Turn API responses. func turnCachePath(urlPath string, updatedAt time.Time) string { dir, err := os.UserCacheDir() @@ -196,36 +187,26 @@ func saveTurnCache(path string, response *turn.CheckResponse) { func main() { var ( - watch = flag.Bool("watch", false, "Continuously watch for PR updates") - watchInterval = flag.Duration("watch-interval", defaultWatchInterval, "Watch interval (default: 90s)") - blocked = flag.Bool("blocked", false, "Show only PRs blocking on you") - notify = flag.Bool("notify", false, "Watch for PRs and notify when they become newly blocking") - bell = flag.Bool("bell", true, "Ring ASCII bell when new PRs are found in notify mode") - turnServer = flag.String("turn-server", defaultTurnServerURL, "Turn server URL for enhanced metadata") - org = flag.String("org", "", "Filter PRs to specific organization") - includeStale = flag.Bool("include-stale", false, "Include stale PRs in the output") - debug bool + watch = flag.Bool("watch", false, "Continuously watch for PR updates") + blocked = flag.Bool("blocked", false, "Show only PRs blocking on you") + verbose = flag.Bool("verbose", false, "Show verbose logging from libraries") + excludeOrgs = flag.String("exclude-orgs", "", "Comma-separated list of orgs to exclude") + includeStale = flag.Bool("include-stale", false, "Include PRs that haven't been modified in 90 days") ) - flag.BoolVar(&debug, "debug", false, "Show debug information including API calls and turnclient data") flag.Parse() - // Validate org parameter to prevent injection - if *org != "" { - // Allow only alphanumeric, dash, and underscore (GitHub org naming rules) - if !isValidOrgName(*org) { - fmt.Fprint(os.Stderr, "error: invalid organization name\n") - os.Exit(1) - } - } - // Set up logger var logger *log.Logger - if debug { + if *verbose { logger = log.New(os.Stderr, "[github-pr-notifier] ", log.Ltime) } else { logger = log.New(io.Discard, "", 0) } + // Set up context with cancellation + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // Set up HTTP client with optimized settings httpClient := &http.Client{ Timeout: defaultTimeout, @@ -239,45 +220,40 @@ func main() { }, } - token, err := gitHubToken() + token, err := gitHubToken(ctx) if err != nil { logger.Printf("ERROR: Failed to get GitHub token: %v", err) fmt.Fprint(os.Stderr, "error: failed to authenticate with github\n") - os.Exit(1) + cancel() + return } logger.Print("INFO: Successfully retrieved GitHub token") - username, err := currentUser(token, logger, httpClient) + username, err := currentUser(ctx, token, logger, httpClient) if err != nil { logger.Printf("ERROR: Failed to get current user: %v", err) fmt.Fprint(os.Stderr, "error: failed to identify github user\n") - os.Exit(1) + cancel() + return } logger.Printf("INFO: Authenticated as user: %s", username) - // Set up turn client if turn server is specified + // Set up turn client var turnClient *turn.Client - if *turnServer != "" { - turnClient, err = turn.NewClient(*turnServer) - if err != nil { - logger.Printf("ERROR: Failed to create turn client for %s: %v", *turnServer, err) - fmt.Fprint(os.Stderr, "warning: failed to connect to turn server\n") - turnClient = nil - } else { - logger.Printf("INFO: Connected to turn server at %s", *turnServer) - if debug { - turnClient.SetLogger(logger) - } - if token != "" { - turnClient.SetAuthToken(token) - } + turnClient, err = turn.NewClient(defaultTurnServerURL) + if err != nil { + logger.Printf("ERROR: Failed to create turn client: %v", err) + turnClient = nil + } else { + logger.Print("INFO: Connected to turn server") + if *verbose { + turnClient.SetLogger(logger) + } + if token != "" { + turnClient.SetAuthToken(token) } } - // Set up context with cancellation - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - // Handle interrupts gracefully sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) @@ -287,30 +263,40 @@ func main() { cancel() }() - // If either watch or notify is set, run in watch mode - if *watch || *notify { + // Parse excluded orgs + var excludedOrgs []string + if *excludeOrgs != "" { + excludedOrgs = strings.Split(*excludeOrgs, ",") + for i := range excludedOrgs { + excludedOrgs[i] = strings.TrimSpace(excludedOrgs[i]) + } + } + + if *watch { + // Watch mode: hybrid WebSocket + polling with smart display updates cfg := &watchConfig{ token: token, username: username, blockingOnly: *blocked, - notifyMode: *notify, - bell: *bell, - interval: *watchInterval, + notifyMode: false, + bell: false, + interval: defaultWatchInterval, logger: logger, httpClient: httpClient, turnClient: turnClient, - debug: debug, - org: *org, + debug: *verbose, + org: "", includeStale: *includeStale, + excludedOrgs: excludedOrgs, } runWatchMode(ctx, cfg) } else { - // One-time display + // Default: one-time display query := &prQuery{ token: token, username: username, - org: *org, - debug: debug, + org: "", + debug: *verbose, } cls := &clients{ http: httpClient, @@ -326,12 +312,15 @@ func main() { cancel() return } - displayPRs(prs, username, *blocked, debug, *includeStale) + output := generatePRDisplay(prs, username, *blocked, *verbose, *includeStale, excludedOrgs) + if output != "" { + fmt.Print(output) + } } } -func gitHubToken() (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +func gitHubToken(ctx context.Context) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "gh", "auth", "token") @@ -356,13 +345,12 @@ func gitHubToken() (string, error) { return token, nil } -func currentUser(token string, logger *log.Logger, httpClient *http.Client) (string, error) { +func currentUser(ctx context.Context, token string, logger *log.Logger, httpClient *http.Client) (string, error) { var username string err := retry.Do( func() error { logger.Printf("Making API call to GET %s", apiUserEndpoint) - ctx := context.Background() req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiUserEndpoint, http.NoBody) if err != nil { return err @@ -580,8 +568,8 @@ func deduplicatePRs(prs []PR) []PR { return result } -func enrichPRsParallel(ctx context.Context, _ string, prs []PR, logger *log.Logger, - _ *http.Client, turnClient *turn.Client, username string, debug bool, +func enrichPRsParallel(ctx context.Context, token string, prs []PR, logger *log.Logger, + httpClient *http.Client, turnClient *turn.Client, username string, debug bool, ) { // Simple semaphore pattern - Rob Pike style sem := make(chan struct{}, maxConcurrent) @@ -591,32 +579,26 @@ func enrichPRsParallel(ctx context.Context, _ string, prs []PR, logger *log.Logg wg.Add(1) sem <- struct{}{} // acquire semaphore - go func(pr *PR) { + go func(pr *PR, githubToken string) { defer func() { <-sem // release semaphore wg.Done() }() // Ignore non-critical errors - let the app continue - if err := enrichPRData(ctx, pr, logger, turnClient, username, debug); err != nil { + if err := enrichPRData(ctx, pr, githubToken, logger, httpClient, turnClient, username, debug); err != nil { if errors.Is(err, context.Canceled) { return } logger.Printf("WARNING: Failed to enrich PR #%d: %v", pr.Number, err) } - }(&prs[i]) + }(&prs[i], token) } wg.Wait() } -func fetchPRDetails(ctx context.Context, pr *PR, logger *log.Logger, debug bool) error { - // Get token from environment - token := os.Getenv("GITHUB_TOKEN") - if token == "" { - return errors.New("GITHUB_TOKEN not set") - } - +func fetchPRDetails(ctx context.Context, pr *PR, token string, httpClient *http.Client, logger *log.Logger, debug bool) error { // Extract repository info from PR URL // URL format: https://github.com/owner/repo/pull/123 parts := strings.Split(pr.HTMLURL, "/") @@ -639,8 +621,7 @@ func fetchPRDetails(ctx context.Context, pr *PR, logger *log.Logger, debug bool) req.Header.Set("Accept", "application/vnd.github.v3+json") // Make request - client := &http.Client{Timeout: defaultTimeout} - resp, err := client.Do(req) + resp, err := httpClient.Do(req) if err != nil { return err } @@ -668,7 +649,16 @@ func fetchPRDetails(ctx context.Context, pr *PR, logger *log.Logger, debug bool) return nil } -func enrichPRData(ctx context.Context, pr *PR, logger *log.Logger, turnClient *turn.Client, username string, debug bool) error { +func enrichPRData( + ctx context.Context, + pr *PR, + token string, + logger *log.Logger, + httpClient *http.Client, + turnClient *turn.Client, + username string, + debug bool, +) error { start := time.Now() defer func() { if debug { @@ -677,7 +667,7 @@ func enrichPRData(ctx context.Context, pr *PR, logger *log.Logger, turnClient *t }() // Fetch individual PR data to get size information - if err := fetchPRDetails(ctx, pr, logger, debug); err != nil { + if err := fetchPRDetails(ctx, pr, token, httpClient, logger, debug); err != nil { logger.Printf("WARNING: Failed to fetch PR details for #%d: %v", pr.Number, err) // Continue without size info } @@ -765,47 +755,6 @@ func isBlockingOnUser(pr *PR, username string) bool { return false } -func displayPRs(prs []PR, username string, blockingOnly bool, debug bool, includeStale bool) { - // Filter out stale PRs unless includeStale is true - if !includeStale { - var filteredPRs []PR - for i := range prs { - isStale := false - if prs[i].TurnResponse != nil { - for _, tag := range prs[i].TurnResponse.PRState.Tags { - if tag == "stale" { - isStale = true - break - } - } - } - if !isStale { - filteredPRs = append(filteredPRs, prs[i]) - } - } - prs = filteredPRs - } - - incoming, outgoing := categorizePRs(prs, username) - blockingCount := countBlockingPRs(incoming, username, debug) - - displayHeader(blockingOnly, blockingCount, len(incoming), len(outgoing)) - - if len(incoming) > 0 && (!blockingOnly || blockingCount > 0) { - displayIncomingPRs(incoming, username, blockingOnly) - } - - if len(outgoing) > 0 && !blockingOnly { - displayOutgoingPRs(outgoing, username) - } - - if blockingOnly && blockingCount == 0 { - fmt.Print("\n") - fmt.Println(successStyle.Render("✨ No PRs awaiting your review - you're all caught up!")) - } - fmt.Print("\n") -} - func categorizePRs(prs []PR, username string) (incoming, outgoing []PR) { for i := range prs { if prs[i].User.Login == username { @@ -817,229 +766,6 @@ func categorizePRs(prs []PR, username string) (incoming, outgoing []PR) { return incoming, outgoing } -func countBlockingPRs(prs []PR, username string, debug bool) int { - count := 0 - for i := range prs { - if isBlockingOnUser(&prs[i], username) { - count++ - } - if debug { - debugPR(&prs[i], username) - } - } - return count -} - -func debugPR(pr *PR, username string) { - blocking := isBlockingOnUser(pr, username) - fmt.Fprintf(os.Stderr, "[DEBUG] PR #%d (%s) - blocking: %v\n", pr.Number, pr.Title, blocking) - if pr.TurnResponse != nil { - if pr.TurnResponse.PRState.UnblockAction != nil { - fmt.Fprintf(os.Stderr, " UnblockAction: %+v\n", pr.TurnResponse.PRState.UnblockAction) - } - if len(pr.TurnResponse.PRState.Tags) > 0 { - fmt.Fprintf(os.Stderr, " Tags: %v\n", pr.TurnResponse.PRState.Tags) - } - } -} - -func displayHeader(blockingOnly bool, blockingCount, incomingCount, outgoingCount int) { - fmt.Print("\n") - if blockingOnly && blockingCount > 0 { - plural := "" - if blockingCount != 1 { - plural = "s" - } - header := fmt.Sprintf("🔥 %d PR%s awaiting your review", blockingCount, plural) - fmt.Println(titleStyle.Render(header)) - } else if !blockingOnly { - fmt.Println(titleStyle.Render("📋 Pull Request Dashboard")) - - totalPRs := incomingCount + outgoingCount - summaryText := fmt.Sprintf("📊 %d total PR%s • %d incoming • %d outgoing • %d blocking you", - totalPRs, func() string { - if totalPRs == 1 { - return "" - } - return "s" - }(), incomingCount, outgoingCount, blockingCount) - fmt.Println(infoStyle.Render(summaryText)) - } -} - -func displayIncomingPRs(incoming []PR, username string, blockingOnly bool) { - // Count PRs that will actually be displayed - displayCount := 0 - for i := range incoming { - if !blockingOnly || isBlockingOnUser(&incoming[i], username) { - displayCount++ - } - } - - // Only show header if there are PRs to display - if displayCount > 0 { - fmt.Print("\n") - fmt.Println(headerStyle.Render("⬇️ Incoming PRs")) - fmt.Print("\n") - } - - for i := range incoming { - if blockingOnly && !isBlockingOnUser(&incoming[i], username) { - continue - } - displayPR(&incoming[i], username) - } -} - -func displayOutgoingPRs(outgoing []PR, username string) { - fmt.Print("\n") - fmt.Println(headerStyle.Render("⬆️ Your PRs")) - fmt.Print("\n") - for i := range outgoing { - displayPR(&outgoing[i], username) - } -} - -func displayPR(pr *PR, username string) { - // Format age - age := formatAge(pr.UpdatedAt) - - // Get PR icon based on status - icon := prIcon(pr) - - // Prepare tags display with colors - var tagsDisplay string - if pr.TurnResponse != nil && len(pr.TurnResponse.PRState.Tags) > 0 { - var coloredTags []string - for _, tag := range pr.TurnResponse.PRState.Tags { - coloredTags = append(coloredTags, coloredTag(tag)) - } - tagsDisplay = fmt.Sprintf(" %s", strings.Join(coloredTags, " ")) - } - - // Create styled components - var bulletColor string - if isBlockingOnUser(pr, username) { - bulletColor = "#FF0000" // Intense red for blocked PRs - } else { - bulletColor = "#FF79C6" // Pink for regular PRs - } - bullet := lipgloss.NewStyle().Foreground(lipgloss.Color(bulletColor)).Render("●") - prIcon := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF79C6")).Render(icon) - title := prTitleStyle.Render(truncate(pr.Title, 60)) - ageFormatted := ageStyle.Render(age) - urlFormatted := urlStyle.Render(truncateURL(pr.HTMLURL, truncatedURLLength)) - - // Create the main PR line with bullet - prLine := fmt.Sprintf(" %s %s %s", bullet, prIcon, title) - - // Add blocking indicator if user is blocked - if isBlockingOnUser(pr, username) { - blockingIndicator := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FF5555")). - Bold(true). - Render(" ⚡") - prLine += blockingIndicator - } - - // Create size display - var sizeDisplay string - if pr.Additions > 0 || pr.Deletions > 0 || pr.ChangedFiles > 0 { - sizeDisplay = fmt.Sprintf(" +%d/-%d (%d files)", pr.Additions, pr.Deletions, pr.ChangedFiles) - } - - // Create info line with indentation - infoLine := fmt.Sprintf(" %s • %s%s%s", ageFormatted, urlFormatted, sizeDisplay, tagsDisplay) - - // Print with nice spacing - fmt.Println(prLine) - fmt.Println(infoLine) - fmt.Println() -} - -func prIcon(pr *PR) string { - if pr.Draft { - return "🚧" - } - if pr.TurnResponse != nil && pr.TurnResponse.PRState.ReadyToMerge { - return "✅" - } - if pr.TurnResponse != nil { - for _, tag := range pr.TurnResponse.PRState.Tags { - switch tag { - case "has_approval": - return "👍" - case "merge_conflict": - return "💥" - case "stale": - return "⏰" - case "failing_tests": - return "❌" - default: - // Continue checking other tags - } - } - } - return "📝" -} - -func coloredTag(tag string) string { - var color string - var icon string - - switch tag { - case "draft": - color = "#FFA500" - icon = "🚧" - case "has_approval": - color = "#00FF00" - icon = "✅" - case "merge_conflict": - color = "#FF0000" - icon = "💥" - case "stale": - color = "#808080" - icon = "⏰" - case "failing_tests": - color = "#FF4444" - icon = "❌" - case "ready_to_merge": - color = "#00DD00" - icon = "🚀" - case "updated": - color = "#44AAFF" - icon = "🔄" - default: - color = "#FF9FF3" - icon = "🏷️" - } - - // Create a more subtle tag style - style := lipgloss.NewStyle(). - Foreground(lipgloss.Color(color)). - Bold(false). - Padding(0, 1). - Background(lipgloss.Color("#1a1a1a")) - - return style.Render(fmt.Sprintf("%s %s", icon, tag)) -} - -func formatAge(t time.Time) string { - d := time.Since(t) - switch { - case d < time.Hour: - return fmt.Sprintf("%dm", int(d.Minutes())) - case d < 24*time.Hour: - return fmt.Sprintf("%dh", int(d.Hours())) - case d < daysInWeek*24*time.Hour: - return fmt.Sprintf("%dd", int(d.Hours()/24)) - case d < 30*24*time.Hour: - return fmt.Sprintf("%dw", int(d.Hours()/(24*7))) - default: - return fmt.Sprintf("%dmo", int(d.Hours()/(24*30))) - } -} - func truncate(s string, n int) string { if len(s) <= n { return s @@ -1047,27 +773,20 @@ func truncate(s string, n int) string { return s[:n-3] + "..." } -func truncateURL(urlStr string, maxLen int) string { - if len(urlStr) <= maxLen { - return urlStr +func wasBlockingBefore(pr *PR, previous []PR, username string) bool { + if found, exists := findPRInList(pr, previous); exists { + return isBlockingOnUser(&found, username) } + return false +} - // Keep the important parts of GitHub URLs - parts := strings.Split(urlStr, "/") - if len(parts) >= minPRURLParts && strings.Contains(urlStr, "github.com") { - // Extract owner/repo/pull/number - owner := parts[3] - repo := parts[repoPartIndex] - prType := parts[prTypePartIndex] - number := parts[numberPartIndex] - shortened := fmt.Sprintf("github.com/%s/%s/%s/%s", owner, repo, prType, number) - if len(shortened) <= maxLen { - return shortened +func findPRInList(target *PR, prs []PR) (PR, bool) { + for i := range prs { + if prs[i].Number == target.Number && prs[i].Repository.FullName == target.Repository.FullName { + return prs[i], true } } - - // Fallback to regular truncation - return truncate(urlStr, maxLen) + return PR{}, false } // clients holds HTTP and Turn clients. @@ -1084,11 +803,27 @@ type prQuery struct { debug bool } +// displayConfig holds configuration for updateDisplay. +type displayConfig struct { + logger *log.Logger + httpClient *http.Client + turnClient *turn.Client + lastDisplayHash *string + excludedOrgs []string + token string + username string + blockingOnly bool + verbose bool + includeStale bool + force bool +} + // watchConfig holds configuration for watch mode. type watchConfig struct { logger *log.Logger httpClient *http.Client turnClient *turn.Client + excludedOrgs []string token string username string org string @@ -1101,82 +836,131 @@ type watchConfig struct { } func runWatchMode(ctx context.Context, cfg *watchConfig) { - runWatch(ctx, cfg) -} - -func runWatch(ctx context.Context, cfg *watchConfig) { - // Clear screen only if not in notify mode - if !cfg.notifyMode { - fmt.Print("\033[H\033[2J") + cfg.logger.Printf("Starting watch mode with WebSocket + polling") + + // Track last displayed output to detect changes + var lastDisplayHash string + + // Create refresh tracker to prevent duplicate API calls + refreshTracker := newPRRefreshTracker() + + const updateChanSize = 10 + // Channel to trigger display updates + updateChan := make(chan bool, updateChanSize) + + // Initial display + displayCfg := &displayConfig{ + logger: cfg.logger, + httpClient: cfg.httpClient, + turnClient: cfg.turnClient, + lastDisplayHash: &lastDisplayHash, + excludedOrgs: cfg.excludedOrgs, + token: cfg.token, + username: cfg.username, + blockingOnly: cfg.blockingOnly, + verbose: cfg.debug, + includeStale: cfg.includeStale, + force: true, + } + err := updateDisplay(ctx, displayCfg) + if err != nil { + cfg.logger.Printf("ERROR: Initial display failed: %v", err) + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + return } - var lastPRs []PR - ticker := time.NewTicker(cfg.interval) - defer ticker.Stop() + // Start WebSocket monitoring + go func() { + // Redirect standard log output to discard when not verbose + // This suppresses sprinkler library logs + if !cfg.debug { + log.SetOutput(io.Discard) + defer log.SetOutput(os.Stderr) // Restore on exit + } + + config := client.Config{ + ServerURL: defaultSprinklerURL, + Token: cfg.token, + Organization: "*", + EventTypes: []string{"*"}, + UserEventsOnly: false, + Verbose: cfg.debug, + NoReconnect: false, + OnConnect: func() { + cfg.logger.Println("✓ WebSocket connected") + }, + OnDisconnect: func(err error) { + cfg.logger.Printf("WebSocket disconnected: %v", err) + }, + OnEvent: func(event client.Event) { + if event.Type == "pull_request" && event.URL != "" { + if refreshTracker.shouldRefresh(event.URL) { + refreshTracker.markRefreshed(event.URL) + cfg.logger.Printf("WebSocket event: %s", event.URL) + select { + case updateChan <- true: + default: // Don't block if channel is full + } + } + } + }, + } + + wsClient, err := client.New(config) + if err != nil { + cfg.logger.Printf("WARNING: Failed to create WebSocket client: %v", err) + return + } - // Initial fetch and display - lastPRs = handleInitialFetch(ctx, cfg) + if err := wsClient.Start(ctx); err != nil && !errors.Is(err, context.Canceled) { + cfg.logger.Printf("WARNING: WebSocket client error: %v", err) + } + }() + + // Start polling + go func() { + ticker := time.NewTicker(defaultWatchInterval) + defer ticker.Stop() - // Set up interrupt handler - quitCh := setupQuitHandler() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + select { + case updateChan <- true: + cfg.logger.Println("Polling trigger") + default: + } + } + } + }() + // Process updates for { select { case <-ctx.Done(): fmt.Println("\nShutting down...") return - case <-ticker.C: - query := &prQuery{ - token: cfg.token, - username: cfg.username, - org: cfg.org, - debug: cfg.debug, - } - cls := &clients{ - http: cfg.httpClient, - turn: cfg.turnClient, - } - prs, err := fetchPRsWithRetry(ctx, query, cfg.logger, cls) + case <-updateChan: + displayCfg.force = false + err := updateDisplay(ctx, displayCfg) if err != nil { if !errors.Is(err, context.Canceled) { - fmt.Fprintf(os.Stderr, "Error fetching PRs: %v\n", err) + cfg.logger.Printf("ERROR: Update failed: %v", err) } - continue } - - handleUpdate(cfg, prs, lastPRs) - - lastPRs = prs - - case <-quitCh: - fmt.Println("\nExiting...") - return - } - } -} - -func wasBlockingBefore(pr *PR, previous []PR, username string) bool { - if found, exists := findPRInList(pr, previous); exists { - return isBlockingOnUser(&found, username) - } - return false -} - -func findPRInList(target *PR, prs []PR) (PR, bool) { - for i := range prs { - if prs[i].Number == target.Number && prs[i].Repository.FullName == target.Repository.FullName { - return prs[i], true } } - return PR{}, false } -func handleInitialFetch(ctx context.Context, cfg *watchConfig) []PR { +func updateDisplay(ctx context.Context, cfg *displayConfig) error { + // Fetch current PRs query := &prQuery{ token: cfg.token, username: cfg.username, - org: cfg.org, - debug: cfg.debug, + org: "", + debug: cfg.verbose, } cls := &clients{ http: cfg.httpClient, @@ -1184,86 +968,245 @@ func handleInitialFetch(ctx context.Context, cfg *watchConfig) []PR { } prs, err := fetchPRsWithRetry(ctx, query, cfg.logger, cls) if err != nil { - if !errors.Is(err, context.Canceled) { - fmt.Fprintf(os.Stderr, "Error fetching PRs: %v\n", err) - } - return nil + return err } - if !cfg.notifyMode { - header := "🔄 Live PR Dashboard - Press 'q' to quit" - fmt.Println(titleStyle.Render(header)) - fmt.Println() - displayPRs(prs, cfg.username, cfg.blockingOnly, cfg.debug, cfg.includeStale) - return prs + // Generate display output + output := generatePRDisplay(prs, cfg.username, cfg.blockingOnly, cfg.verbose, cfg.includeStale, cfg.excludedOrgs) + + // Check if display has changed + currentHash := fmt.Sprintf("%x", sha256.Sum256([]byte(output))) + if !cfg.force && currentHash == *cfg.lastDisplayHash { + cfg.logger.Println("No changes to display") + return nil } + + // Clear screen and display + fmt.Print("\033[H\033[2J") + fmt.Print(output) + *cfg.lastDisplayHash = currentHash + return nil } -func setupQuitHandler() chan bool { - quitCh := make(chan bool) - go func() { - scanner := bufio.NewScanner(os.Stdin) - for scanner.Scan() { - if scanner.Text() == "q" { - quitCh <- true - return +func generatePRDisplay(prs []PR, username string, blockingOnly, verbose, includeStale bool, excludedOrgs []string) string { + var output strings.Builder + + // Filter out excluded orgs + if len(excludedOrgs) > 0 { + var filteredPRs []PR + for i := range prs { + excluded := false + prOrg := orgFromURL(prs[i].HTMLURL) + for _, excludeOrg := range excludedOrgs { + if prOrg == excludeOrg { + excluded = true + break + } + } + if !excluded { + filteredPRs = append(filteredPRs, prs[i]) } } - }() - return quitCh -} + prs = filteredPRs + } -func handleUpdate(cfg *watchConfig, prs, lastPRs []PR) { - if !cfg.notifyMode { - displayLiveDashboard(cfg, prs) - } else { - handleNotifyMode(cfg, prs, lastPRs) + // Filter stale PRs unless includeStale is true + if !includeStale { + var filteredPRs []PR + for i := range prs { + isStale := false + if prs[i].TurnResponse != nil { + for _, tag := range prs[i].TurnResponse.PRState.Tags { + if tag == "stale" { + isStale = true + break + } + } + } + if !isStale { + filteredPRs = append(filteredPRs, prs[i]) + } + } + prs = filteredPRs + } + + // Sort PRs by most recently updated + sortPRsByUpdateTime(prs) + + // Split into incoming and outgoing + incoming, outgoing := categorizePRs(prs, username) + + // Count blocking PRs + incomingBlockingCount := 0 + for i := range incoming { + if isBlockingOnUser(&incoming[i], username) { + incomingBlockingCount++ + } + } + + outgoingBlockingCount := 0 + for i := range outgoing { + if isBlockingOnUser(&outgoing[i], username) { + outgoingBlockingCount++ + } + } + + output.WriteString("\n") + + // Incoming PRs with integrated header + if len(incoming) > 0 && (!blockingOnly || incomingBlockingCount > 0) { + // Header with counts - proper singular/plural + prText := "PR" + if len(incoming) != 1 { + prText = "PRs" + } + output.WriteString(fmt.Sprintf("incoming - %d %s", len(incoming), prText)) + if incomingBlockingCount > 0 { + output.WriteString(", ") + blockText := "blocked on YOU" + output.WriteString(lipgloss.NewStyle(). + Foreground(lipgloss.Color("#E5484D")). // Red for blocked count + Bold(true). + Render(fmt.Sprintf("%d %s", incomingBlockingCount, blockText))) + } + output.WriteString(":\n") + + for i := range incoming { + if blockingOnly && !isBlockingOnUser(&incoming[i], username) { + continue + } + output.WriteString(formatPR(&incoming[i], username)) + } + } + + // Outgoing PRs with integrated header + if len(outgoing) > 0 && !blockingOnly { + if len(incoming) > 0 { + output.WriteString("\n") + } + + // Header with counts - gray color for distinction, proper singular/plural + prText := "PR" + if len(outgoing) != 1 { + prText = "PRs" + } + output.WriteString(lipgloss.NewStyle(). + Foreground(lipgloss.Color("#8B8B8B")). // Gray for outgoing header + Render(fmt.Sprintf("outgoing - %d %s", len(outgoing), prText))) + if outgoingBlockingCount > 0 { + output.WriteString(", ") + blockText := "blocked on YOU" + output.WriteString(lipgloss.NewStyle(). + Foreground(lipgloss.Color("#E5484D")). + Bold(true). + Render(fmt.Sprintf("%d %s", outgoingBlockingCount, blockText))) + } + output.WriteString(":\n") + + for i := range outgoing { + output.WriteString(formatPR(&outgoing[i], username)) + } + } + + if blockingOnly && incomingBlockingCount == 0 { + // Show nothing when no PRs are blocking + return "" } + + output.WriteString("\n") + return output.String() } -func displayLiveDashboard(cfg *watchConfig, prs []PR) { - fmt.Print("\033[H\033[2J") - header := "🔄 Live PR Dashboard - Press 'q' to quit" - fmt.Println(titleStyle.Render(header)) - fmt.Println() - displayPRs(prs, cfg.username, cfg.blockingOnly, cfg.debug, cfg.includeStale) +// terminalWidth returns the current terminal width, defaulting to 80 if unable to detect. +func terminalWidth() int { + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || width <= 0 { + return defaultTerminalWidth + } + return width } -func handleNotifyMode(cfg *watchConfig, prs, lastPRs []PR) { - if cfg.blockingOnly { - notifyBlockingPRs(cfg, prs, lastPRs) +func formatPR(pr *PR, username string) string { + var output strings.Builder + + // Get terminal width for dynamic truncation + termWidth := terminalWidth() + + // Blocking indicator - red bullet if blocking, subtle indent otherwise + isBlocking := isBlockingOnUser(pr, username) + if isBlocking { + output.WriteString(lipgloss.NewStyle(). + Foreground(lipgloss.Color("#E5484D")). // Modern red + Bold(true). + Render("• ")) } else { - notifyAllNewPRs(cfg, prs, lastPRs) + output.WriteString(" ") // Just indent, no bullet for non-blocking } -} -func notifyBlockingPRs(cfg *watchConfig, prs, lastPRs []PR) { - newBlockingPRs := []PR{} - for i := range prs { - if isBlockingOnUser(&prs[i], cfg.username) && !wasBlockingBefore(&prs[i], lastPRs, cfg.username) { - newBlockingPRs = append(newBlockingPRs, prs[i]) - } + // Calculate space available for title + // Account for: bullet (2), space after title (1), and URL estimate + parts := strings.Split(pr.HTMLURL, "/") + urlLength := 30 // Default estimate + if len(parts) >= prURLParts { + // Actual URL will be org/repo#number + urlLength = len(fmt.Sprintf("%s/%s#%s", parts[3], parts[4], parts[6])) + } + + // Reserve space: bullet(2) + space(1) + url + some padding(5) + availableForTitle := termWidth - 2 - 1 - urlLength - titlePadding + if availableForTitle < minTitleLength { + availableForTitle = minTitleLength // Minimum title length + } + + // Title - truncated based on available space + title := pr.Title + if len(title) > availableForTitle { + title = title[:availableForTitle-3] + "..." + } + // Style title in white + whiteTitle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Render(title) + output.WriteString(whiteTitle) + output.WriteString(" ") + + // Shortened URL - just org/repo#number in blue + if len(parts) >= prURLParts { + shortURL := fmt.Sprintf("%s/%s#%s", parts[3], parts[4], parts[6]) + // Style the URL in blue and make it clickable with OSC 8 hyperlink + blueURL := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#3E63DD")). // Modern blue + Render(shortURL) + // Wrap with OSC 8 hyperlink + output.WriteString(fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", pr.HTMLURL, blueURL)) + } else { + // Fallback - still make it blue + blueURL := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#3E63DD")). + Render(pr.HTMLURL) + output.WriteString(blueURL) } - displayNewPRs(newBlockingPRs, cfg.username, cfg.bell) + + output.WriteString("\n") + return output.String() } -func notifyAllNewPRs(cfg *watchConfig, prs, lastPRs []PR) { - newPRs := []PR{} - for i := range prs { - _, exists := findPRInList(&prs[i], lastPRs) - if !exists { - newPRs = append(newPRs, prs[i]) - } +func orgFromURL(urlStr string) string { + // Extract org/owner from GitHub URL + parts := strings.Split(urlStr, "/") + if len(parts) >= minOrgURLParts && strings.Contains(urlStr, "github.com") { + return parts[3] // This is the org/owner } - displayNewPRs(newPRs, cfg.username, cfg.bell) + return "" } -func displayNewPRs(prs []PR, username string, bell bool) { - for i := range prs { - if bell { - fmt.Print("\a") // ASCII bell for attention +func sortPRsByUpdateTime(prs []PR) { + for i := 0; i < len(prs); i++ { + for j := i + 1; j < len(prs); j++ { + if prs[j].UpdatedAt.After(prs[i].UpdatedAt) { + prs[i], prs[j] = prs[j], prs[i] + } } - displayPR(&prs[i], username) } } diff --git a/main_test.go b/main_test.go index 3e5db53..59b5c9d 100644 --- a/main_test.go +++ b/main_test.go @@ -2,47 +2,8 @@ package main import ( "testing" - "time" ) -func TestFormatAge(t *testing.T) { - tests := []struct { - name string - time time.Time - expected string - }{ - { - name: "minutes", - time: time.Now().Add(-30 * time.Minute), - expected: "30m", - }, - { - name: "hours", - time: time.Now().Add(-5 * time.Hour), - expected: "5h", - }, - { - name: "days", - time: time.Now().Add(-3 * 24 * time.Hour), - expected: "3d", - }, - { - name: "weeks", - time: time.Now().Add(-14 * 24 * time.Hour), - expected: "2w", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := formatAge(tt.time) - if got != tt.expected { - t.Errorf("formatAge() = %v, want %v", got, tt.expected) - } - }) - } -} - func TestTruncate(t *testing.T) { tests := []struct { name string @@ -200,3 +161,88 @@ func TestWasBlockingBefore(t *testing.T) { }) } } + +func TestOrgFromURL(t *testing.T) { + tests := []struct { + name string + url string + expected string + }{ + { + name: "valid github PR URL", + url: "https://github.com/owner/repo/pull/123", + expected: "owner", + }, + { + name: "valid github issue URL", + url: "https://github.com/org/project/issues/456", + expected: "org", + }, + { + name: "non-github URL", + url: "https://example.com/something/else", + expected: "", + }, + { + name: "malformed URL", + url: "not-a-url", + expected: "", + }, + { + name: "github URL with too few parts", + url: "https://github.com/", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := orgFromURL(tt.url) + if got != tt.expected { + t.Errorf("orgFromURL(%q) = %q, want %q", tt.url, got, tt.expected) + } + }) + } +} + +func TestCategorizePRs(t *testing.T) { + prs := []PR{ + { + Number: 1, + User: User{Login: "alice"}, + }, + { + Number: 2, + User: User{Login: "bob"}, + }, + { + Number: 3, + User: User{Login: "alice"}, + }, + { + Number: 4, + User: User{Login: "charlie"}, + }, + } + + incoming, outgoing := categorizePRs(prs, "alice") + + if len(outgoing) != 2 { + t.Errorf("Expected 2 outgoing PRs, got %d", len(outgoing)) + } + if len(incoming) != 2 { + t.Errorf("Expected 2 incoming PRs, got %d", len(incoming)) + } + + // Verify correct categorization + for _, pr := range outgoing { + if pr.User.Login != "alice" { + t.Errorf("Outgoing PR has wrong author: %s", pr.User.Login) + } + } + for _, pr := range incoming { + if pr.User.Login == "alice" { + t.Errorf("Incoming PR has wrong author: %s", pr.User.Login) + } + } +} diff --git a/prs b/prs deleted file mode 100755 index 8e98fba..0000000 Binary files a/prs and /dev/null differ