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
3 changes: 3 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
- 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.
- Rate-limit handling: 403/429 rate-limit responses are told apart from missing-permission 403s. `prw run` stops the current round and pauses until the reset time; `prw wait` waits for the reset (still bounded by `--timeout`) instead of giving up.
Expand All @@ -19,6 +20,8 @@ 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
- 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.
- `MultiNotifier` (used whenever more than one notification channel is enabled) stopped at the first failing notifier. A down webhook silently suppressed the console and native notifications that were supposed to run after it; now every notifier always runs and their errors are combined.
- The README's "Prebuilt binaries" install snippet was pinned to `v0.2.0`, two releases behind the current `v0.3.0`. It now resolves the actual latest release tag first.
Expand Down
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ The tool is deliberately minimal. It does one thing well: watch PRs and tell you

- **`prw wait`**: block until a PR's CI finishes and exit 0/1/2 — for scripts, pipelines and AI coding agents
- **MCP server** (`prw mcp`): lets Claude Code, Claude Desktop, Cursor and other agents check and wait for PR CI as tools
- **Auto-watch**: `prw watch --mine` / `--review-requested` follow your PRs and review requests across all repositories, no URLs needed
- **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)
Expand Down Expand Up @@ -143,6 +144,15 @@ prw watch https://github.com/owner/repo/pull/123

The tool will fetch the PR details and add it to your watch list.

**Or let prw find your PRs** — no URLs to copy:

```bash
prw watch --mine # all your open PRs, in every repository
prw watch --review-requested # open PRs where your review is requested (incl. your teams)
```

These follow a GitHub search instead of a fixed list: matching PRs are added right away, and `prw run` searches again every 5 minutes, so new PRs show up by themselves. Merged or closed PRs are dropped after their final notification, and review-request PRs are also dropped once your review is no longer requested. `prw list` shows which PRs were added automatically (`ADDED BY`). Undo with `prw unwatch --mine` / `--review-requested`, which removes only the PRs that search added — PRs you watched by hand are kept.

### 3. Start the watcher

```bash
Expand Down Expand Up @@ -228,6 +238,7 @@ prw wait "$PR_URL" --timeout 15m --json
| `1` | CI failed (`failure` or `error`) |
| `2` | Timed out (`--timeout`, default 30m) while CI was still pending |
| `3` | Other error: invalid URL, API error, missing token, PR closed without merging |
| `4` | The PR has **no CI**: no checks or statuses were reported within 3 minutes of its last update (see below) |
| `130` | Interrupted (Ctrl+C) |

It fails fast — as soon as one check fails it exits `1` with the failed checks and their links, without waiting for the rest. If someone pushes to the PR meanwhile, it follows the new head commit. Transient errors (network, 5xx, rate limits) are retried up to 3 times in a row. Polls every 15s by default (`--interval`).
Expand All @@ -250,7 +261,9 @@ With `--json`, stdout is a single object:
}
```

`state` is one of `success`, `failure`, `error`, `pending` (with `timed_out` or `interrupted` set), `merged`, or `closed`.
`state` is one of `success`, `failure`, `error`, `pending` (with `timed_out` or `interrupted` set), `merged`, `closed`, or `no_ci`.

**PRs without CI.** Some PRs never get a single check — the repository has no CI, or none runs for the PR's base branch (for example a stacked PR whose base isn't `main`). Right after a push, though, every PR briefly has zero checks while CI registers them. So prw only concludes `no_ci` once the PR has had no checks or statuses at all **and** hasn't been updated for 3 minutes; until then it reports `pending`. `no_ci` exits with `4`, not `0`, so `prw wait && deploy` doesn't deploy a PR that no CI looked at. `prw run` shows `no_ci` in `prw list` but doesn't send a notification for it, and notifies normally if CI shows up later (e.g. after the PR is retargeted).

## Using prw from AI agents: `prw mcp`

Expand All @@ -276,7 +289,7 @@ Tools:

| Tool | What it does |
|------|--------------|
| `get_ci_status` | Current state of a PR (`success`, `failure`, `error`, `pending`, `merged`, `closed`), check counts, failed checks with links. Returns immediately. |
| `get_ci_status` | Current state of a PR (`success`, `failure`, `error`, `pending`, `merged`, `closed`, `no_ci`), check counts, failed checks with links. Returns immediately. |
| `wait_for_ci` | Blocks until CI finishes (`timeout_seconds` default 600, max 3600). Fails fast on the first failed check, follows new pushes, sends progress notifications. On timeout it returns `pending` with `timed_out: true` so the agent can call it again. |
| `list_watched` | PRs on the local watch list with their last known state. |
| `watch_pr` / `unwatch_pr` | Add or remove a PR from the watch list (a running `prw run` picks it up). |
Expand Down
5 changes: 4 additions & 1 deletion TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,10 @@ Legend — Impact: 1–5 · Effort: S (≤½ day), M (1–2 days), L (3+ days)
- ✅ #13 `prw wait <PR>` — done (exit codes 0/1/2/3/130, fail-fast, follows new pushes, retries transient errors, `--json`; verified live against devblac/prw#5 and real process exit codes)
- ✅ #9 API efficiency — done (ETag/If-None-Match cache; measured live: 15 steady-state requests = 0 quota; rate-limit detection + pause in `run`, wait-for-reset in `wait`). GraphQL batching not needed for now.
- ✅ #14 MCP server — done (`prw mcp`: 5 tools, progress + cancellation, protocol 2025-06-18/2025-03-26/2024-11-05; verified end-to-end over stdio against the real API with the token from `gh auth token`). Not yet tried inside a real MCP client.
- Next: #10 `prw watch --mine` / `--review-requested`
- ✅ #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

## Planned

Expand Down
268 changes: 268 additions & 0 deletions cmd/prw/autowatch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
package main

import (
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"

"github.com/devblac/prw/internal/config"
"github.com/devblac/prw/internal/github"
)

// setupAutoWatchCmd points commands at a temp config (with a token) and a fake GitHub
// whose search returns mine/reviewRequested PR numbers in owner/repo.
func setupAutoWatchCmd(t *testing.T, mine, reviewRequested []int) *int {
t.Helper()
path := filepath.Join(t.TempDir(), ".prw", "config.json")
oldPath := config.ConfigPath
config.ConfigPath = func() (string, error) { return path, nil }
t.Cleanup(func() { config.ConfigPath = oldPath })
cfg := config.DefaultConfig()
cfg.GitHubToken = "test-token"
if err := cfg.Save(); err != nil {
t.Fatal(err)
}

searches := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/search/issues":
searches++
numbers := mine
if strings.Contains(r.URL.Query().Get("q"), "review-requested:@me") {
numbers = reviewRequested
}
items := make([]string, len(numbers))
for i, n := range numbers {
items[i] = fmt.Sprintf(`{"number":%d,"title":"PR %d","html_url":"https://github.com/owner/repo/pull/%d","pull_request":{}}`, n, n, n)
}
fmt.Fprintf(w, `{"total_count":%d,"items":[%s]}`, len(items), strings.Join(items, ","))
case strings.HasPrefix(r.URL.Path, "/repos/owner/repo/pulls/"):
n := strings.TrimPrefix(r.URL.Path, "/repos/owner/repo/pulls/")
fmt.Fprintf(w, `{"number":%s,"title":"PR %s","state":"open","head":{"sha":"sha%s"}}`, n, n, n)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
oldClient := newGitHubClient
newGitHubClient = func(token string) *github.Client {
c := github.NewClient(token)
c.BaseURL = server.URL
c.HTTPClient = server.Client()
return c
}
t.Cleanup(func() { newGitHubClient = oldClient })

resetFlags := func() {
watchMine, watchReviewRequested, unwatchMine, unwatchReviewRequested = false, false, false, false
}
resetFlags()
t.Cleanup(resetFlags)
return &searches
}

func watchListSummary(t *testing.T) string {
t.Helper()
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
var parts []string
for _, pr := range cfg.WatchedPRs {
src := pr.Source
if src == "" {
src = "manual"
}
parts = append(parts, fmt.Sprintf("%d:%s", pr.Number, src))
}
return strings.Join(parts, " ")
}

func TestWatchCmd_MineAddsSubscriptionAndCurrentPRs(t *testing.T) {
searches := setupAutoWatchCmd(t, []int{1, 2}, nil)
watchMine = true

out, err := captureStdout(func() error { return watchCmd.RunE(watchCmd, nil) })
if err != nil {
t.Fatalf("watch --mine: %v", err)
}
for _, want := range []string{"Now auto-watching your open PRs.", "+ owner/repo#1 - PR 1", "+ owner/repo#2 - PR 2", "searches again every 5 minutes"} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q:\n%s", want, out)
}
}
cfg, _ := config.Load()
if !cfg.HasAutoWatch(config.AutoWatchMine) || cfg.HasAutoWatch(config.AutoWatchReviewRequested) {
t.Errorf("AutoWatch = %v", cfg.AutoWatch)
}
if got := watchListSummary(t); got != "1:mine 2:mine" {
t.Errorf("watch list = %s", got)
}

// Running it again is idempotent.
out, err = captureStdout(func() error { return watchCmd.RunE(watchCmd, nil) })
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "Already auto-watching your open PRs.") || !strings.Contains(out, "No new PRs to add right now.") {
t.Errorf("second run output:\n%s", out)
}
if *searches != 2 {
t.Errorf("searches = %d", *searches)
}
}

func TestWatchCmd_BothSourcesAndAURL(t *testing.T) {
setupAutoWatchCmd(t, []int{1}, []int{2})
watchMine, watchReviewRequested = true, true

if _, err := captureStdout(func() error {
return watchCmd.RunE(watchCmd, []string{"https://github.com/owner/repo/pull/9"})
}); err != nil {
t.Fatal(err)
}
if got := watchListSummary(t); got != "9:manual 1:mine 2:review-requested" {
t.Errorf("watch list = %s", got)
}
}

func TestWatchCmd_ByHandClaimsAnAutoWatchedPR(t *testing.T) {
setupAutoWatchCmd(t, []int{1}, nil)
watchMine = true
captureStdout(func() error { return watchCmd.RunE(watchCmd, nil) })
watchMine = false

out, err := captureStdout(func() error { return watchCmd.RunE(watchCmd, []string{"https://github.com/owner/repo/pull/1"}) })
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "already being watched") {
t.Errorf("output: %s", out)
}
if got := watchListSummary(t); got != "1:manual" {
t.Errorf("watching by hand must claim the PR, got %s", got)
}

// ... so turning auto-watch off keeps it.
unwatchMine = true
if _, err := captureStdout(func() error { return unwatchCmd.RunE(unwatchCmd, nil) }); err != nil {
t.Fatal(err)
}
if got := watchListSummary(t); got != "1:manual" {
t.Errorf("watch list after unwatch --mine = %s", got)
}
}

func TestUnwatchCmd_MineRemovesSubscriptionAndItsPRs(t *testing.T) {
setupAutoWatchCmd(t, []int{1, 2}, []int{3})
watchMine, watchReviewRequested = true, true
captureStdout(func() error { return watchCmd.RunE(watchCmd, []string{"https://github.com/owner/repo/pull/9"}) })

unwatchMine = true
out, err := captureStdout(func() error { return unwatchCmd.RunE(unwatchCmd, nil) })
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "Stopped auto-watching your open PRs; removed 2 PR(s) it had added.") {
t.Errorf("output: %s", out)
}
cfg, _ := config.Load()
if cfg.HasAutoWatch(config.AutoWatchMine) || !cfg.HasAutoWatch(config.AutoWatchReviewRequested) {
t.Errorf("AutoWatch = %v", cfg.AutoWatch)
}
if got := watchListSummary(t); got != "9:manual 3:review-requested" {
t.Errorf("watch list = %s", got)
}

out, _ = captureStdout(func() error { return unwatchCmd.RunE(unwatchCmd, nil) })
if !strings.Contains(out, "Was not auto-watching your open PRs.") {
t.Errorf("second unwatch --mine output: %s", out)
}
}

func TestWatchCmd_Args(t *testing.T) {
setupAutoWatchCmd(t, nil, nil)

if err := watchCmd.Args(watchCmd, nil); err == nil || !strings.Contains(err.Error(), "--mine") {
t.Errorf("no URL and no flag must fail with a hint, got %v", err)
}
if err := watchCmd.Args(watchCmd, []string{"a", "b"}); err == nil {
t.Error("two URLs must fail")
}
if err := watchCmd.Args(watchCmd, []string{"https://github.com/o/r/pull/1"}); err != nil {
t.Errorf("one URL: %v", err)
}
watchMine = true
if err := watchCmd.Args(watchCmd, nil); err != nil {
t.Errorf("--mine without URL: %v", err)
}
if err := watchCmd.Args(watchCmd, []string{"a", "b"}); err == nil {
t.Error("--mine with two URLs must fail")
}

if err := unwatchCmd.Args(unwatchCmd, nil); err == nil {
t.Error("unwatch with nothing must fail")
}
unwatchReviewRequested = true
if err := unwatchCmd.Args(unwatchCmd, nil); err != nil {
t.Errorf("unwatch --review-requested: %v", err)
}
}

func TestWatchCmd_MineNeedsToken(t *testing.T) {
setupAutoWatchCmd(t, nil, nil)
config.Update(func(c *config.Config) error { c.GitHubToken = ""; return nil })
t.Setenv("GITHUB_TOKEN", "")
watchMine = true

if err := watchCmd.RunE(watchCmd, nil); err != errMissingToken {
t.Errorf("err = %v, want errMissingToken", err)
}
if cfg, _ := config.Load(); len(cfg.AutoWatch) != 0 {
t.Error("auto-watch must not be enabled without a token")
}
}

func TestListAndConfigShow_ShowAutoWatch(t *testing.T) {
setupAutoWatchCmd(t, []int{1}, nil)
watchMine = true
captureStdout(func() error { return watchCmd.RunE(watchCmd, []string{"https://github.com/owner/repo/pull/9"}) })

out, err := captureStdout(func() error { return listCmd.RunE(listCmd, nil) })
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "Auto-watching your open PRs (--mine).") || !strings.Contains(out, "ADDED BY") {
t.Errorf("list output:\n%s", out)
}
lines := strings.Split(out, "\n")
var row1, row9 string
for _, l := range lines {
if strings.Contains(l, "#1 ") {
row1 = l
}
if strings.Contains(l, "#9 ") {
row9 = l
}
}
if !strings.Contains(row1, "--mine") || !strings.Contains(row9, "you") {
t.Errorf("ADDED BY column wrong:\n%s", out)
}

oldJSON := listJSON
listJSON = true
defer func() { listJSON = oldJSON }()
out, _ = captureStdout(func() error { return listCmd.RunE(listCmd, nil) })
if !strings.Contains(out, `"source": "mine"`) {
t.Errorf("JSON list should include the source:\n%s", out)
}

out, _ = captureStdout(func() error { return configShowCmd.RunE(configShowCmd, nil) })
if !strings.Contains(out, "auto_watch: mine") {
t.Errorf("config show output:\n%s", out)
}
}
1 change: 1 addition & 0 deletions cmd/prw/broadcast.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ var broadcastCmd = &cobra.Command{
fmt.Printf("Error fetching status for %s/%s#%d: %v\n", pr.Owner, pr.Repo, pr.Number, err)
continue
}
github.ApplyNoCI(ci, ghPR.UpdatedAt, time.Now())

currentState := ci.State
previousState := github.NormalizeState(pr.LastKnownState)
Expand Down
Loading