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,13 +11,16 @@ 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.
- `prw wait <PR_URL>`: blocks until the PR's CI finishes and exits `0` (success or merged), `1` (failed), `2` (timeout), `3` (other error) or `130` (interrupted). Fails fast on the first failed check, follows new pushes, retries transient API errors, and prints a JSON result with `--json`. Progress goes to stderr.
- Zero-setup auth for GitHub CLI users: when no token is configured, `prw` uses `GH_TOKEN` or, failing that, the token from `gh auth token`. Lookup order: config file, `GITHUB_TOKEN`, `GH_TOKEN`, `gh auth token`. `prw config show` now reports the exact source.
- 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
- 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
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ The tool is deliberately minimal. It does one thing well: watch PRs and tell you
## Features

- **`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 @@ -142,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 @@ -251,6 +262,37 @@ With `--json`, stdout is a single object:

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

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

`prw mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio, so coding agents can check a PR's CI and wait for it after pushing — and read which checks failed — without screen-scraping `gh` output.

**Claude Code:**

```bash
claude mcp add prw -- prw mcp
```

**Claude Desktop, Cursor and other clients** (add to the client's `mcpServers` JSON config):

```json
{
"mcpServers": {
"prw": { "command": "prw", "args": ["mcp"] }
}
}
```

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. |
| `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). |

Results include a one-line summary for the model plus the full data as JSON (and as `structuredContent`). Authentication works like every other command — with `gh auth login` there's nothing to configure. All tools share one GitHub client, so the ETag cache keeps repeated polling free.

## Configuration

Configuration is stored in `~/.prw/config.json`. You can manage settings via the `config` subcommand.
Expand Down
5 changes: 4 additions & 1 deletion TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ Legend — Impact: 1–5 · Effort: S (≤½ day), M (1–2 days), L (3+ days)
- 🟡 #8 `gh` extension distribution — code part done (token from `GH_TOKEN` / `gh auth token`, tested with a fake gh binary and end-to-end). Remaining: publishing as `gh-prw` needs a repo named `gh-prw` (new repo or rename) + precompiled release assets named `gh-prw-<os>-<arch>` — owner decision.
- ✅ #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.
- Next: #14 MCP server (builds on `waitForCI`)
- ✅ #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.
- ✅ #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.
- Next: #18, then #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)
}
}
Loading