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 @@ -20,6 +20,14 @@ 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.
- `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.
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,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 @@ -260,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 @@ -286,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 Expand Up @@ -384,7 +387,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
4 changes: 3 additions & 1 deletion TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ Legend — Impact: 1–5 · Effort: S (≤½ day), M (1–2 days), L (3+ days)
- ✅ #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
- ✅ #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)
- ✅ 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.
- Next: #11 review & mergeability events

## Planned

Expand Down
6 changes: 4 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,17 +71,18 @@ 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
}
github.ApplyNoCI(ci, ghPR.UpdatedAt, time.Now())

currentState := ci.State
previousState := github.NormalizeState(pr.LastKnownState)
Expand Down
4 changes: 2 additions & 2 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
12 changes: 7 additions & 5 deletions cmd/prw/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const (

mcpInstructions = `prw reports the CI status of GitHub pull requests, aggregating GitHub Actions check runs and legacy commit statuses.
Use get_ci_status for a one-off check, and wait_for_ci after pushing to a PR to block until CI finishes (it fails fast and lists failed checks with links).
States: success, failure, error, pending, merged, closed.`
States: success, failure, error, pending, merged, closed, and no_ci (the PR has no checks or statuses at all; waiting longer will not help).`
)

func init() {
Expand Down 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), 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, and failed checks with links. 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 All @@ -440,7 +440,7 @@ var mcpTools = []map[string]interface{}{
{
"name": "wait_for_ci",
"title": "Wait for PR CI",
"description": "Block until a GitHub pull request's CI finishes, then return the result. Fails fast: returns as soon as any check fails, with the failed checks and links. Follows new pushes to the PR. If the timeout is reached first, returns state 'pending' with timed_out=true (call again to keep waiting). A merge while waiting returns state 'merged'.",
"description": "Block until a GitHub pull request's CI finishes, then return the result. Fails fast: returns as soon as any check fails, with the failed checks and links. Follows new pushes to the PR. If the timeout is reached first, returns state 'pending' with timed_out=true (call again to keep waiting). A merge while waiting returns state 'merged'; a PR with no checks or statuses at all returns 'no_ci' instead of waiting until the timeout.",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
Expand Down Expand Up @@ -545,6 +545,8 @@ func ciSummary(r *waitResult) string {
return fmt.Sprintf("CI of %s is pending: %s.", pr, pendingSummary(r))
case r.State == "success":
return fmt.Sprintf("CI of %s passed (%d check(s)).", pr, r.TotalChecks)
case r.State == github.StateNoCI:
return fmt.Sprintf("%s has no CI: no checks or statuses were reported within %s of its last update, so nothing verified it.", pr, github.NoCIGracePeriod)
}
names := make([]string, len(r.FailedChecks))
for i, c := range r.FailedChecks {
Expand All @@ -567,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 @@ -664,7 +666,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
91 changes: 91 additions & 0 deletions cmd/prw/noci_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package main

import (
"bytes"
"context"
"fmt"
"net/http"
"strings"
"testing"
"time"

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

func staleOpenPR(sha string, quietFor time.Duration) *github.PullRequest {
pr := openPR(sha)
pr.UpdatedAt = time.Now().Add(-quietFor)
return pr
}

func TestWaitForCI_NoCIStopsInsteadOfWaitingForTheTimeout(t *testing.T) {
noSleep(t)
client := &scriptedClient{steps: []pollStep{{pr: staleOpenPR("sha", time.Hour)}}}

result, err := waitForCI(context.Background(), client, "o", "r", 1, time.Second, &bytes.Buffer{})
if err != nil {
t.Fatal(err)
}
if result.State != github.StateNoCI || client.polls != 1 {
t.Errorf("state=%q polls=%d, want no_ci after one poll", result.State, client.polls)
}
if code := exitCode(result.exitErr()); code != exitWaitNoCI {
t.Errorf("exit code = %d, want 4 (no CI must not look like success)", code)
}
}

func TestWaitForCI_WaitsForCIToRegisterAfterAPush(t *testing.T) {
noSleep(t)
client := &scriptedClient{steps: []pollStep{
{pr: staleOpenPR("new", 10*time.Second)}, // just pushed, nothing registered yet
{pr: staleOpenPR("new", 20*time.Second), runs: []github.CheckRun{run("Test", "queued", "")}},
{pr: staleOpenPR("new", 30*time.Second), runs: []github.CheckRun{run("Test", "completed", "success")}},
}}

result, err := waitForCI(context.Background(), client, "o", "r", 1, time.Second, &bytes.Buffer{})
if err != nil {
t.Fatal(err)
}
if result.State != "success" || client.polls != 3 {
t.Errorf("state=%q polls=%d, want success after 3 polls", result.State, client.polls)
}
}

func TestPrintWaitResult_NoCI(t *testing.T) {
var buf bytes.Buffer
printWaitResult(&buf, &waitResult{Owner: "o", Repo: "r", Number: 1, State: github.StateNoCI, URL: "https://github.com/o/r/pull/1"}, false)
if !strings.Contains(buf.String(), "⚪ o/r#1") || !strings.Contains(buf.String(), "No CI: no checks or statuses") {
t.Errorf("output:\n%s", buf.String())
}
}

func TestCISummary_NoCI(t *testing.T) {
got := ciSummary(&waitResult{Owner: "o", Repo: "r", Number: 1, State: github.StateNoCI})
if !strings.HasPrefix(got, "o/r#1 has no CI") {
t.Errorf("summary = %q", got)
}
}

func TestWaitCmd_NoCIExitCode(t *testing.T) {
updated := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
setupWaitCommand(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/pulls/1"):
fmt.Fprintf(w, `{"number":1,"title":"Stacked","state":"open","updated_at":%q,"head":{"sha":"abc1234"}}`, updated)
case strings.HasSuffix(r.URL.Path, "/status"):
fmt.Fprint(w, `{"state":"pending","total_count":0,"statuses":[]}`)
default:
fmt.Fprint(w, `{"total_count":0,"check_runs":[]}`)
}
})

out, err := captureStdout(func() error {
return waitCmd.RunE(waitCmd, []string{"https://github.com/owner/repo/pull/1"})
})
if code := exitCode(err); code != exitWaitNoCI {
t.Errorf("exit code = %d (err=%v), want 4", code, err)
}
if !strings.Contains(out, "No CI") {
t.Errorf("stdout:\n%s", out)
}
}
Loading