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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `prw run` now notifies once (`merged` or `closed`) and automatically stops watching a PR once it is merged or closed, instead of polling it forever. This notification always fires, regardless of `--on`/`notification_filter`.

### Added
- Review and merge events: `prw run` notifies when a PR is approved, gets changes requested, runs into merge conflicts, or becomes ready to merge after branch protection was holding it back. Shown in console, native, Slack/Discord and generic webhooks (`type` `pr_review_change` / `pr_merge_state_change`), filtered by `--on` (`fail`: changes requested and conflicts; `success`: approved and ready). `prw list` gains `REVIEW` and `MERGE` columns (`review` / `merge` in `--json` and MCP `list_watched`); `prw wait --json` and the MCP tools report `merge_state`. Disable with `prw config set pr_events false`.
- Auto-watch: `prw watch --mine` follows all your open PRs and `prw watch --review-requested` the PRs where your review is requested, across every repository. Matches are added immediately; `prw run` refreshes the searches every 5 minutes. Review-request PRs are dropped when the request goes away; PRs watched by hand are never touched by it. `prw unwatch --mine` / `--review-requested` turns it off and removes the PRs it added. `prw list` gains an `ADDED BY` column (and `source` in `--json`); `prw config show` shows `auto_watch`.
- `prw mcp`: a Model Context Protocol server over stdio for AI coding agents, with tools `get_ci_status`, `wait_for_ci` (progress notifications, cancellation, fail-fast), `list_watched`, `watch_pr` and `unwatch_pr`. Implemented without new dependencies (the official Go SDK needs Go 1.25).
- Conditional requests (ETags): repeated polls of unchanged resources are answered with `304 Not Modified`, which doesn't count against the authenticated rate limit. Measured against the real API: after the first poll, 5 further polls of a PR (15 requests) consumed 0 quota. Previously each watched PR cost 3 requests per poll (~540/hour at the default 20s interval), so about 9 PRs exhausted the 5,000/hour limit.
Expand All @@ -20,6 +21,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Integration tests against the real GitHub API (`make integration`, build tag `integration`), run nightly by a new `Integration` workflow. They fail against the old status-only logic.

### Fixed
- A check whose workflow ran more than once for the same commit (e.g. an older run cancelled by `concurrency: cancel-in-progress`) kept the PR failed even after the newer run passed. Only the newest run of each check counts now, like on GitHub.
- `prw wait` and the MCP `wait_for_ci` tool could return the final result of the previous commit when a new one was pushed during a poll. The PR head is now re-checked before returning, and waiting continues on the new commit.
- `prw wait --timeout`, the MCP `timeout_seconds` / cancellation and Ctrl-C now cancel the GitHub requests in flight; on a stalled network they used to be noticed only after the whole poll finished (up to 15 seconds per request). `prw run` also stops at once on Ctrl-C.
- PRs auto-watched with `--review-requested` that were merged or closed while `prw` wasn't running are no longer dropped as withdrawn review requests: they get their final `merged`/`closed` notification first.
- A `no_ci` PR no longer flaps to `pending` (with a "no_ci → pending" notification) when someone comments or changes a label; it stays `no_ci` until an actual check or status appears.
- The ETag cache no longer clears itself entirely when full, which made almost every request unconditional when watching many PRs; it evicts one entry instead.
- GovSlack webhook URLs (`hooks.slack-gov.com`) are detected as Slack.
- PRs without any CI are reported as `no_ci` instead of `pending` forever. Previously `prw wait` and the MCP `wait_for_ci` tool blocked until their timeout on such PRs (repositories without CI, or stacked PRs whose base branch runs none). A PR is `no_ci` once it has no checks or statuses at all and hasn't been updated for 3 minutes, so CI that is still registering after a push isn't mistaken for missing CI. `prw wait` exits `4` for it; `prw run` doesn't notify about it.
- PR owner and repository names are compared case-insensitively (as on GitHub), so `DevBlac/PRW#5` and `devblac/prw#5` are no longer two separate watch-list entries.
- Requests without a token no longer send an empty `Authorization: Bearer ` header, which GitHub rejects with 401 even for public repositories.
Expand Down
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ The tool is deliberately minimal. It does one thing well: watch PRs and tell you
- **Auto-cleanup**: merged/closed PRs are notified once and dropped from the watch list by `prw run`
- **Watch multiple PRs** from different repositories simultaneously
- **Instant notifications** when CI status changes (pending, success, failure, error)
- **Review & merge events**: approved, changes requested, merge conflicts, ready to merge
- **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)
Expand Down Expand Up @@ -345,6 +346,7 @@ prw config set webhook_url https://hooks.slack.com/services/YOUR/WEBHOOK/URL
- **`webhook_url`**: Optional HTTP endpoint for notifications
- **`webhook_format`**: Payload format: `auto` (default, detected from the URL), `slack`, `discord`, or `generic`
- **`notification_native`**: Enable native OS notifications (true/false, default: false)
- **`pr_events`**: Also notify about reviews and merge conflicts / readiness (true/false, default: true)
- **`github_token`**: GitHub Personal Access Token (prefer env var `GITHUB_TOKEN`)

## Notifications
Expand Down Expand Up @@ -387,7 +389,7 @@ If you configure a `webhook_url`, `prw` will POST to it on every status change.

| URL | Format | What you get |
|-----|--------|--------------|
| `https://hooks.slack.com/...` | `slack` | A message with the PR link, status transition and failed checks |
| `https://hooks.slack.com/...` (or GovSlack `hooks.slack-gov.com`) | `slack` | A message with the PR link, status transition and failed checks |
| `https://discord.com/api/webhooks/...` | `discord` | A colored embed (green / red / yellow) with the same details |
| anything else | `generic` | Raw JSON (below), for your own services |

Expand Down Expand Up @@ -432,6 +434,29 @@ prw run --on change

The same setting can be persisted via `prw config set notification_filter <value>`.

### Reviews and merge conflicts

Besides CI, `prw run` notifies when a PR's review decision or merge state changes:

| Event | When |
|-------|------|
| `approved` | A reviewer approved and nobody's latest review requests changes |
| `changes_requested` | A reviewer's latest review requests changes |
| `conflict` | The PR has merge conflicts with its base branch |
| `ready` | GitHub says the PR can be merged, after branch protection, conflicts or an outdated branch were holding it back |

```
🔔 Review Update!
PR: kubernetes/kubernetes#12345
Review: none → approved
```

Webhooks get the same shapes as CI changes, with `"type": "pr_review_change"` or `"pr_merge_state_change"` in the generic payload (and `Review:` / `Merge:` instead of `Status:` in Slack and Discord messages). With `--on fail` you only get `changes_requested` and `conflict`; with `--on success`, `approved` and `ready`.

A few rules keep this quiet: nothing is announced the first time a PR is checked; `ready` isn't sent together with the CI or approval notification that caused it, and not at all for repositories without branch protection (there, "CI passed" already means it); dismissed reviews and new review rounds don't notify until someone approves or requests changes. `prw list` shows the last review and merge state in the `REVIEW` and `MERGE` columns, `prw wait --json` and the MCP tools include `merge_state`.

Turn these off with `prw config set pr_events false`. They cost one extra request per PR per poll (the reviews), answered with a free `304` when nothing changed.

### Merged and closed PRs

Once `prw run` sees that a watched PR was merged or closed, it sends one final notification (`CurrentState` is `merged` or `closed`, so you'll see e.g. `success → merged`) and removes the PR from the watch list — regardless of `--on`/`notification_filter`, since a merge is a lifecycle event, not a CI result. This keeps the watch list from growing forever. `prw list` and `prw broadcast` are unaffected: a PR only drops out once `prw run` has actually polled it.
Expand Down
4 changes: 3 additions & 1 deletion TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,9 @@ Legend — Impact: 1–5 · Effort: S (≤½ day), M (1–2 days), L (3+ days)
- ✅ #10 `prw watch --mine` / `--review-requested` — done (search-backed subscriptions refreshed every 5 min by `prw run`; verified live: 18 open PRs across 7 repos found, CI checked, cleanly removed by `unwatch --mine`)
- 🆕 #18 **Report "no CI" instead of `pending` forever** (found while testing #10: 14 of 18 real PRs have zero checks — repos without CI, or stacked PRs whose base isn't `main`). Today `prw wait` / `wait_for_ci` would wait until the timeout on them. Needs a grace period after the last push (CI takes a moment to register checks) before concluding "no CI". Impact 4, effort S–M.
- ✅ #18 `no_ci` state — done (0 checks + PR not updated for 3 min → `no_ci`; `prw wait` exits 4; no notification; verified live: 11 real PRs went from `pending` to `no_ci`, `prw wait` on one returns in 2s instead of the 30-min timeout)
- Next: #11 review & mergeability events
- ✅ Codex review feedback on #6, #7, #13–#17 — done in `fix/codex-review`: superseded check runs (verified live on zephyrproject-rtos/zephyr#105183: was `failure`, now `success`), head re-check in `prw wait`, context propagation into every API request, closed review-request PRs keep their notification, no `no_ci` flapping, ETag cache eviction, GovSlack.
- ✅ #11 Review & mergeability events — done (approved / changes_requested / conflict / ready; verified live: devblac/prw#12 `ready`, cli/cli#14727 `blocked`, a simulated blocked → ready notified once and ready → blocked stayed silent)
- Next: #12 `prw checks <PR>`

## Planned

Expand Down
5 changes: 3 additions & 2 deletions cmd/prw/broadcast.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"context"
"fmt"
"strings"
"time"
Expand Down Expand Up @@ -70,13 +71,13 @@ var broadcastCmd = &cobra.Command{
for i := range cfg.WatchedPRs {
pr := &cfg.WatchedPRs[i]

ghPR, err := client.GetPullRequest(pr.Owner, pr.Repo, pr.Number)
ghPR, err := client.GetPullRequest(context.Background(), pr.Owner, pr.Repo, pr.Number)
if err != nil {
fmt.Printf("Error fetching PR %s/%s#%d: %v\n", pr.Owner, pr.Repo, pr.Number, err)
continue
}

ci, err := github.FetchCIStatus(client, pr.Owner, pr.Repo, ghPR.Head.SHA)
ci, err := github.FetchCIStatus(context.Background(), client, pr.Owner, pr.Repo, ghPR.Head.SHA)
if err != nil {
fmt.Printf("Error fetching status for %s/%s#%d: %v\n", pr.Owner, pr.Repo, pr.Number, err)
continue
Expand Down
34 changes: 28 additions & 6 deletions cmd/prw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ func watchSinglePR(prURL string) error {

// Try to fetch the PR to validate it exists and get title
client := newGitHubClient(token)
pr, err := client.GetPullRequest(owner, repo, number)
pr, err := client.GetPullRequest(context.Background(), owner, repo, number)
if err != nil {
return fmt.Errorf("failed to fetch PR: %w", err)
}
Expand Down Expand Up @@ -229,7 +229,7 @@ func enableAutoWatch(sources []string) error {
}

// Run the searches now so the user sees the result immediately.
res, err := watcher.SyncAutoWatch(newGitHubClient(token), sources)
res, err := watcher.SyncAutoWatch(context.Background(), newGitHubClient(token), sources)
for _, warning := range res.Warnings {
fmt.Printf("Warning: %s\n", warning)
}
Expand Down Expand Up @@ -283,8 +283,8 @@ var listCmd = &cobra.Command{
}

w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "REPO\tPR\tSTATUS\tLAST CHECKED\tADDED BY\tTITLE")
fmt.Fprintln(w, "----\t--\t------\t------------\t--------\t-----")
fmt.Fprintln(w, "REPO\tPR\tSTATUS\tREVIEW\tMERGE\tLAST CHECKED\tADDED BY\tTITLE")
fmt.Fprintln(w, "----\t--\t------\t------\t-----\t------------\t--------\t-----")

for _, pr := range cfg.WatchedPRs {
repo := fmt.Sprintf("%s/%s", pr.Owner, pr.Repo)
Expand All @@ -305,7 +305,7 @@ var listCmd = &cobra.Command{
if pr.Source != "" {
addedBy = "--" + pr.Source
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", repo, prNum, status, lastChecked, addedBy, title)
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", repo, prNum, status, orDash(pr.LastReviewState), orDash(pr.LastMergeState), lastChecked, addedBy, title)
}

w.Flush()
Expand Down Expand Up @@ -484,6 +484,7 @@ var configShowCmd = &cobra.Command{
fmt.Printf("webhook_format: %s\n", notify.NormalizeWebhookFormat(cfg.WebhookFormat))
fmt.Printf("notification_filter: %s\n", cfg.NotificationFilter)
fmt.Printf("notification_native: %v\n", cfg.NotificationNative)
fmt.Printf("pr_events: %v\n", !cfg.DisablePREvents)

_, tokenSource := cfg.ResolveToken()
fmt.Printf("github_token: %s\n", tokenSource)
Expand All @@ -510,7 +511,8 @@ Supported keys:
- webhook_format: auto, slack, discord, or generic (default: auto, detected from the URL)
- github_token: GitHub personal access token
- notification_filter: change, fail, or success
- notification_native: enable native OS notifications (true/false)`,
- notification_native: enable native OS notifications (true/false)
- pr_events: notify about reviews and merge conflicts / readiness, not just CI (true/false, default: true)`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
key := args[0]
Expand Down Expand Up @@ -557,6 +559,12 @@ func applyConfigSet(cfg *config.Config, key, value string) error {
return fmt.Errorf("notification_native must be true or false")
}
cfg.NotificationNative = enabled
case "pr_events":
enabled, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("pr_events must be true or false")
}
cfg.DisablePREvents = !enabled
default:
return fmt.Errorf("unknown config key: %s", key)
}
Expand Down Expand Up @@ -595,6 +603,8 @@ func applyConfigUnset(cfg *config.Config, key string) error {
cfg.NotificationFilter = config.NotificationFilterChange
case "notification_native":
cfg.NotificationNative = false
case "pr_events":
cfg.DisablePREvents = false
default:
return fmt.Errorf("unknown config key: %s", key)
}
Expand Down Expand Up @@ -623,6 +633,16 @@ type listPROutput struct {
LastChecked *time.Time `json:"last_checked,omitempty"`
Title string `json:"title,omitempty"`
Source string `json:"source,omitempty"` // auto-watch source that added it; empty if added by hand
Review string `json:"review,omitempty"` // approved, changes_requested or none; empty until checked
Merge string `json:"merge,omitempty"` // ready, conflict, blocked, behind, unstable or draft
}

// orDash shows a state that hasn't been observed yet as "-".
func orDash(s string) string {
if s == "" {
return "-"
}
return s
}

func outputJSONList(prs []config.WatchedPR) error {
Expand All @@ -647,6 +667,8 @@ func outputJSONList(prs []config.WatchedPR) error {
LastChecked: lastChecked,
Title: pr.Title,
Source: pr.Source,
Review: pr.LastReviewState,
Merge: pr.LastMergeState,
})
}

Expand Down
10 changes: 6 additions & 4 deletions cmd/prw/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ var mcpTools = []map[string]interface{}{
{
"name": "get_ci_status",
"title": "Get PR CI status",
"description": "Get the current CI status of a GitHub pull request: overall state (success, failure, error, pending, merged, closed, or no_ci when the PR has no checks at all), check counts, and failed checks with links. Aggregates GitHub Actions check runs and legacy commit statuses. Returns immediately.",
"description": "Get the current CI status of a GitHub pull request: overall state (success, failure, error, pending, merged, closed, or no_ci when the PR has no checks at all), check counts, failed checks with links, and merge_state (ready, conflict, blocked, behind, unstable, draft) telling whether the PR can be merged. Aggregates GitHub Actions check runs and legacy commit statuses. Returns immediately.",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{"pr_url": prURLSchema},
Expand Down Expand Up @@ -569,7 +569,7 @@ func toolGetCIStatus(ctx context.Context, s *mcpServer, args json.RawMessage, _
return toolError("%v", err)
}
result := &waitResult{Owner: owner, Repo: repo, Number: number, URL: github.FormatPRURL(owner, repo, number)}
if _, err := pollOnce(client, result); err != nil {
if _, err := pollOnce(ctx, client, result); err != nil {
return toolError("failed to get CI status of %s/%s#%d: %v", owner, repo, number, err)
}
return toolOK(ciSummary(result), result)
Expand Down Expand Up @@ -626,6 +626,8 @@ type watchedPROutput struct {
Number int `json:"number"`
Title string `json:"title,omitempty"`
LastState string `json:"last_known_state"`
Review string `json:"review,omitempty"` // approved, changes_requested, none
Merge string `json:"merge,omitempty"` // ready, conflict, blocked, behind, unstable, draft
LastChecked *time.Time `json:"last_checked,omitempty"`
}

Expand All @@ -642,7 +644,7 @@ func toolListWatched(ctx context.Context, s *mcpServer, _ json.RawMessage, _ io.
}
out := watchedPROutput{
URL: github.FormatPRURL(pr.Owner, pr.Repo, pr.Number), Owner: pr.Owner, Repo: pr.Repo,
Number: pr.Number, Title: pr.Title, LastState: state,
Number: pr.Number, Title: pr.Title, LastState: state, Review: pr.LastReviewState, Merge: pr.LastMergeState,
}
if !pr.LastChecked.IsZero() {
t := pr.LastChecked
Expand All @@ -666,7 +668,7 @@ func toolWatchPR(ctx context.Context, s *mcpServer, args json.RawMessage, _ io.W
if err != nil {
return toolError("%v", err)
}
pr, err := client.GetPullRequest(owner, repo, number)
pr, err := client.GetPullRequest(ctx, owner, repo, number)
if err != nil {
return toolError("failed to fetch %s/%s#%d: %v", owner, repo, number, err)
}
Expand Down
Loading