Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ Thumbs.db

# Local config (for testing)
.prw/
prw
17 changes: 17 additions & 0 deletions QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,23 @@ Add the output to your shell profile to enable autocomplete.
./bin/prw config set poll_interval_seconds 30
```

### Enable native OS notifications

Get desktop notifications without hosting a webhook:

```bash
# Enable via flag
./bin/prw run --notify-native

# Or persist in config
./bin/prw config set notification_native true
```

**Platform requirements:**
- **macOS**: Uses `osascript` (built-in)
- **Linux**: Requires `notify-send` (install via `libnotify-bin` package)
- **Windows**: Uses PowerShell (built-in)

### Add a webhook

Send notifications to Slack, Discord, or any HTTP endpoint:
Expand Down
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ The tool is deliberately minimal. It does one thing well: watch PRs and tell you
- **Instant notifications** when CI status changes (pending, success, failure, error)
- **Chat-ops broadcast**: one command to push current PR status to Slack/Discord (`prw broadcast`)
- **Webhook support** for Slack, Discord, or custom integrations
- **Native OS notifications** via system toasts (macOS/Linux/Windows)
- **Terminal notifications** with clear, actionable output
- **Configurable polling** interval to balance responsiveness and API usage
- **Persistent state** - remembers watched PRs between sessions
Expand Down Expand Up @@ -226,6 +227,7 @@ prw config set webhook_url https://hooks.slack.com/services/YOUR/WEBHOOK/URL
"poll_interval_seconds": 20,
"notification_filter": "change",
"webhook_url": "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX",
"notification_native": false,
"github_token": "",
"watched_prs": [
{
Expand All @@ -245,6 +247,7 @@ prw config set webhook_url https://hooks.slack.com/services/YOUR/WEBHOOK/URL

- **`poll_interval_seconds`**: How often to poll GitHub (default: 20)
- **`webhook_url`**: Optional HTTP endpoint for notifications
- **`notification_native`**: Enable native OS notifications (true/false, default: false)
- **`github_token`**: GitHub Personal Access Token (prefer env var `GITHUB_TOKEN`)

## Notifications
Expand All @@ -262,6 +265,25 @@ Status changes are always printed to stdout with clear formatting:
Time: 2025-12-06T10:32:15Z
```

### Native OS Notifications

Enable native system notifications for desktop alerts without hosting a webhook:

```bash
# Enable via flag
prw run --notify-native

# Or persist in config
prw config set notification_native true
```

**Platform requirements:**
- **macOS**: Uses `osascript` (built-in)
- **Linux**: Requires `notify-send` (usually from `libnotify-bin` package)
- **Windows**: Uses PowerShell (built-in)

If the required tool is missing, `prw` will log a warning and continue polling without crashing. Native notifications work alongside console and webhook notifications—you can enable all three simultaneously.

### Webhooks

If you configure a `webhook_url`, `prw` will POST a JSON payload on every status change:
Expand Down Expand Up @@ -368,7 +390,6 @@ make clean
These features might come in future versions:

- **GitHub App integration** for webhook-based notifications (no polling)
- **Desktop notifications** via OS-native APIs
- **Multiple notification channels** (email, Telegram, etc.)
- **Rich filtering** (watch only specific check suites, ignore draft PRs)
- **PR review status** tracking (approvals, requested changes)
Expand Down
18 changes: 17 additions & 1 deletion cmd/prw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,14 @@ func init() {
listCmd.Flags().BoolVar(&listJSON, "json", false, "output watched PRs as JSON")
runCmd.Flags().StringVar(&notifyFilter, "on", "", "notify on: change, fail, or success")
runCmd.Flags().BoolVar(&runOnce, "once", false, "check watched PRs once and exit")
runCmd.Flags().BoolVar(&notifyNative, "notify-native", false, "enable native OS notifications (macOS/Linux/Windows)")
}

var (
listJSON bool
notifyFilter string
runOnce bool
notifyNative bool
)

// newGitHubClient allows tests to inject a custom GitHub client.
Expand Down Expand Up @@ -209,6 +211,10 @@ Press Ctrl+C to stop.`,
if cfg.WebhookURL != "" {
notifiers = append(notifiers, notify.NewWebhookNotifier(cfg.WebhookURL))
}
// Add native notifications if enabled via flag or config
if notifyNative || cfg.NotificationNative {
notifiers = append(notifiers, notify.NewNativeNotifier())
}
notifier := notify.NewMultiNotifier(notifiers...)

filter := cfg.NotificationFilter
Expand Down Expand Up @@ -264,6 +270,7 @@ var configShowCmd = &cobra.Command{
fmt.Printf("poll_interval_seconds: %d\n", cfg.PollIntervalSeconds)
fmt.Printf("webhook_url: %s\n", cfg.WebhookURL)
fmt.Printf("notification_filter: %s\n", cfg.NotificationFilter)
fmt.Printf("notification_native: %v\n", cfg.NotificationNative)

tokenSource := "not set"
if cfg.GitHubToken != "" {
Expand All @@ -287,7 +294,8 @@ Supported keys:
- poll_interval_seconds: polling interval in seconds (default: 20)
- webhook_url: URL to POST notifications to
- github_token: GitHub personal access token
- notification_filter: change, fail, or success`,
- notification_filter: change, fail, or success
- notification_native: enable native OS notifications (true/false)`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
key := args[0]
Expand Down Expand Up @@ -315,6 +323,12 @@ Supported keys:
return fmt.Errorf("notification_filter must be one of: change, fail, success")
}
cfg.NotificationFilter = filter
case "notification_native":
enabled, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("notification_native must be true or false")
}
cfg.NotificationNative = enabled
default:
return fmt.Errorf("unknown config key: %s", key)
}
Expand Down Expand Up @@ -349,6 +363,8 @@ var configUnsetCmd = &cobra.Command{
cfg.GitHubToken = ""
case "notification_filter":
cfg.NotificationFilter = config.NotificationFilterChange
case "notification_native":
cfg.NotificationNative = false
default:
return fmt.Errorf("unknown config key: %s", key)
}
Expand Down
1 change: 1 addition & 0 deletions examples/config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"poll_interval_seconds": 20,
"notification_filter": "change",
"webhook_url": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL",
"notification_native": false,
"github_token": "ghp_example_token_here",
"watched_prs": [
{
Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type Config struct {
WebhookURL string `json:"webhook_url,omitempty"`
GitHubToken string `json:"github_token,omitempty"`
NotificationFilter string `json:"notification_filter,omitempty"`
NotificationNative bool `json:"notification_native,omitempty"`

// Watched PRs
WatchedPRs []WatchedPR `json:"watched_prs"`
Expand Down
119 changes: 119 additions & 0 deletions internal/notify/notify.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"net/http"
"os/exec"
"runtime"
"time"

"github.com/devblac/prw/internal/github"
Expand Down Expand Up @@ -142,3 +144,120 @@ func (w *WebhookNotifier) Notify(event *StatusChangeEvent) error {

return nil
}

// NativeNotifier sends notifications using OS-native notification systems.
type NativeNotifier struct {
enabled bool
}

// NewNativeNotifier creates a native notifier.
// It checks if the platform supports native notifications and if the required tools are available.
func NewNativeNotifier() *NativeNotifier {
return &NativeNotifier{
enabled: isNativeNotificationSupported(),
}
}

// Notify sends a native system notification.
func (n *NativeNotifier) Notify(event *StatusChangeEvent) error {
if !n.enabled {
// Silently skip if not supported - this is expected on unsupported platforms
return nil
}

title := fmt.Sprintf("PR Status Change: %s/%s#%d", event.Owner, event.Repo, event.Number)
message := fmt.Sprintf("%s → %s", event.PreviousState, event.CurrentState)
if event.Title != "" {
message = fmt.Sprintf("%s\n%s", event.Title, message)
}

var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
// macOS: use osascript to display notification
script := fmt.Sprintf(`display notification "%s" with title "%s"`, escapeAppleScriptString(message), escapeAppleScriptString(title))
cmd = exec.Command("osascript", "-e", script)
case "linux":
// Linux: use notify-send (requires libnotify-bin)
cmd = exec.Command("notify-send", title, message)
case "windows":
// Windows: use PowerShell to show toast notification
// Escape XML entities and PowerShell special characters
titleEscaped := escapePowerShellXMLString(title)
messageEscaped := escapePowerShellXMLString(message)
psScript := fmt.Sprintf(`[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = Windows.UI.Notifications]::CreateToastNotifier("prw").Show([Windows.UI.Notifications.ToastNotification]::new([Windows.Data.Xml.Dom.XmlDocument]::new().LoadXml("<toast><visual><binding template=\"ToastText02\"><text id=\"1">%s</text><text id=\"2">%s</text></binding></visual></toast>")))`, titleEscaped, messageEscaped)
cmd = exec.Command("powershell", "-NoProfile", "-Command", psScript)
default:
// Unsupported platform - silently skip
return nil
}

if err := cmd.Run(); err != nil {
// Log but don't fail - native notifications are optional
return fmt.Errorf("native notification failed (tool may be missing): %w", err)
}

return nil
}

// isNativeNotificationSupported checks if native notifications are supported on this platform.
func isNativeNotificationSupported() bool {
switch runtime.GOOS {
case "darwin":
// Check if osascript is available
_, err := exec.LookPath("osascript")
return err == nil
case "linux":
// Check if notify-send is available
_, err := exec.LookPath("notify-send")
return err == nil
case "windows":
// PowerShell should be available on Windows
_, err := exec.LookPath("powershell")
return err == nil
default:
return false
}
}

// escapeAppleScriptString escapes special characters for AppleScript strings.
func escapeAppleScriptString(s string) string {
// Replace quotes and backslashes
result := ""
for _, r := range s {
switch r {
case '"':
result += `\"`
case '\\':
result += `\\`
case '\n':
result += `\n`
default:
result += string(r)
}
}
return result
}

// escapePowerShellXMLString escapes special characters for XML embedded in PowerShell strings.
func escapePowerShellXMLString(s string) string {
// Escape XML entities for safe embedding in XML
result := ""
for _, r := range s {
switch r {
case '<':
result += "&lt;"
case '>':
result += "&gt;"
case '&':
result += "&amp;"
case '"':
result += "&quot;"
case '\'':
result += "&apos;"
default:
result += string(r)
}
}
return result
}
63 changes: 63 additions & 0 deletions internal/notify/notify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,3 +246,66 @@ func TestConsoleNotifierWithEmptyTitle(t *testing.T) {
t.Errorf("ConsoleNotifier.Notify failed with empty title: %v", err)
}
}

func TestNativeNotifier(t *testing.T) {
notifier := NewNativeNotifier()

event := &StatusChangeEvent{
Owner: "owner",
Repo: "repo",
Number: 123,
Title: "Test PR",
PreviousState: "pending",
CurrentState: "success",
SHA: "abc123",
Timestamp: time.Now(),
}

// Should not panic or error even if native notifications aren't available
// (e.g., in CI environments or unsupported platforms)
err := notifier.Notify(event)
// We don't assert on error because:
// 1. The tool might not be available (e.g., notify-send on macOS CI)
// 2. The notifier should gracefully handle missing tools
// 3. This is tested more thoroughly in platform-specific tests below
_ = err
}

func TestNativeNotifierWithEmptyTitle(t *testing.T) {
notifier := NewNativeNotifier()

event := &StatusChangeEvent{
Owner: "owner",
Repo: "repo",
Number: 123,
Title: "", // Empty title
PreviousState: "pending",
CurrentState: "success",
SHA: "abc123",
Timestamp: time.Now(),
}

// Should handle empty title gracefully
err := notifier.Notify(event)
_ = err
}

func TestNativeNotifierUnsupportedPlatform(t *testing.T) {
// This test verifies that unsupported platforms don't crash
// We can't easily mock runtime.GOOS, so we just verify the notifier
// doesn't panic on any platform
notifier := NewNativeNotifier()

event := &StatusChangeEvent{
Owner: "owner",
Repo: "repo",
Number: 123,
PreviousState: "pending",
CurrentState: "success",
Timestamp: time.Now(),
}

// Should not panic
err := notifier.Notify(event)
_ = err
}