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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ 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
- `prw checks <PR_URL>`: every check run and commit status of a PR's head commit with its result, duration and link, failures first; `--json` for scripts. Superseded runs are left out. Exits `0` passed, `1` failed, `3` error, `4` no CI, `8` still running.
- 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 Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,14 @@ The tool is deliberately minimal. It does one thing well: watch PRs and tell you

## Features

- **`prw checks`**: every check of a PR with its result, duration and link
- **`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)
- **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 @@ -265,6 +267,24 @@ With `--json`, stdout is a single object:

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

## Seeing every check: `prw checks`

`prw checks` lists each check of a PR's head commit (GitHub Actions jobs, other check runs and legacy commit statuses) with its result, how long it took and a link to it — failures first, then running ones. The PR does not need to be on the watch list.

```
$ prw checks https://github.com/owner/repo/pull/123
owner/repo#123 — Add feature
abc1234 · failure · 1 fail, 1 pending, 3 pass · merge: blocked

❌ fail Test (ubuntu-latest) 4m02s https://github.com/owner/repo/actions/runs/…/job/…
⏳ pending E2E 1m10s… https://github.com/owner/repo/actions/runs/…/job/…
✅ pass Build 45s https://github.com/owner/repo/actions/runs/…/job/…
✅ pass Lint 21s https://github.com/owner/repo/actions/runs/…/job/…
✅ pass ci/jenkins https://jenkins.example.com/job/…
```

Runs superseded by a newer run of the same check (e.g. cancelled by `concurrency: cancel-in-progress`) are left out, as they don't count. `--json` prints the same as an object with a `checks` array (`name`, `bucket`, `state`, `kind`, `url`, `started_at`, `completed_at`). Exit codes match `prw wait` — `0` passed (or merged), `1` failed, `3` error or closed without merging, `4` no CI — plus `8` while checks are still running.

## 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.
Expand Down Expand Up @@ -345,6 +365,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 @@ -432,6 +453,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
5 changes: 4 additions & 1 deletion TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,10 @@ Legend — Impact: 1–5 · Effort: S (≤½ day), M (1–2 days), L (3+ days)
- 🆕 #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)
- ✅ 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
- ✅ #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)
- ✅ Codex follow-ups on #18/#19 — failed head re-check no longer accepted as "unchanged"; review-request PRs only dropped when proven withdrawn (open or 404), never on a cancelled or failed lookup; all review pages read.
- ✅ #12 `prw checks <PR>` — done (buckets fail/cancel/pending/pass/neutral/skipping, durations, links, `--json`, exit 8 while running; verified live on devblac/docker-lint#5, zephyrproject-rtos/zephyr#105183 — 35 checks, superseded run hidden — and devblac/prw#13)
- Next: #15 failed job log excerpt

## Planned

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

import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"sort"
"strings"
"text/tabwriter"
"time"

"github.com/spf13/cobra"

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

// exitChecksPending is `prw checks`' exit code while CI is still running; the other
// codes are the ones of `prw wait`. 8 is what `gh pr checks` uses for pending.
const exitChecksPending = 8

// Buckets of individual checks, in display order.
const (
checkFail = "fail"
checkCancel = "cancel"
checkPending = "pending"
checkPass = "pass"
checkNeutral = "neutral"
checkSkipping = "skipping"
)

var checksJSON bool

func init() {
rootCmd.AddCommand(checksCmd)
checksCmd.Flags().BoolVar(&checksJSON, "json", false, "print the checks as JSON")
}

var checksCmd = &cobra.Command{
Use: "checks <PR_URL>",
Short: "Show every check of a PR with its result, duration and link",
Long: `Show the individual checks (GitHub Actions jobs and other check runs, plus legacy
commit statuses) of a pull request's head commit: failures first, then running
ones, then the rest. The PR does not need to be on the watch list.

Exit codes:
0 all checks passed
1 a check failed
3 error (invalid URL, API error, PR closed without merging)
4 the PR has no CI
8 checks are still running`,
Example: ` prw checks https://github.com/owner/repo/pull/123
prw checks https://github.com/owner/repo/pull/123 --json`,
SilenceUsage: true,
Args: func(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
return waitErrorf(exitWaitError, "expected exactly one PR URL, got %d argument(s)", len(args))
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
owner, repo, number, err := github.ParsePRURL(args[0])
if err != nil {
return waitErrorf(exitWaitError, "invalid PR URL: %v", err)
}
cfg, err := config.Load()
if err != nil {
return waitErrorf(exitWaitError, "failed to load config: %v", err)
}
token := cfg.GetToken()
if token == "" {
return &exitError{code: exitWaitError, err: errMissingToken}
}

report, err := fetchChecks(context.Background(), newGitHubClient(token), owner, repo, number, time.Now())
if err != nil {
return &exitError{code: exitWaitError, err: err}
}
if checksJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(report); err != nil {
return &exitError{code: exitWaitError, err: err}
}
} else {
printChecks(os.Stdout, report, time.Now())
}
return report.exitErr()
},
}

// checkEntry is one check run or commit status; it is also the --json output.
type checkEntry struct {
Name string `json:"name"`
Bucket string `json:"bucket"` // fail, cancel, pending, pass, neutral, skipping
State string `json:"state"` // GitHub's conclusion, or status while not completed
Kind string `json:"kind"` // check_run or status
URL string `json:"url,omitempty"` // job / details page
StartedAt *time.Time `json:"started_at,omitempty"` // check runs only
CompletedAt *time.Time `json:"completed_at,omitempty"`
}

// checksReport is the output of `prw checks`.
type checksReport struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
Title string `json:"title,omitempty"`
URL string `json:"url"`
SHA string `json:"sha"`
State string `json:"state"` // overall, as in `prw wait`
MergeState string `json:"merge_state,omitempty"`
Checks []checkEntry `json:"checks"`
}

// fetchChecks gets the head commit's check runs and statuses. Superseded runs of the
// same check are left out, as they don't count towards the result.
func fetchChecks(ctx context.Context, client ciClient, owner, repo string, number int, now time.Time) (*checksReport, error) {
pr, err := client.GetPullRequest(ctx, owner, repo, number)
if err != nil {
return nil, fmt.Errorf("failed to fetch PR: %w", err)
}
sha := pr.Head.SHA
status, err := client.GetCombinedStatus(ctx, owner, repo, sha)
if err != nil {
return nil, fmt.Errorf("failed to fetch commit status: %w", err)
}
runs, err := client.GetCheckRuns(ctx, owner, repo, sha)
if err != nil {
return nil, fmt.Errorf("failed to fetch check runs: %w", err)
}

ci := github.SummarizeCI(status, runs)
github.ApplyNoCI(ci, pr.UpdatedAt, now)
report := &checksReport{
Owner: owner, Repo: repo, Number: number, Title: pr.Title, SHA: sha,
URL: github.FormatPRURL(owner, repo, number), State: ci.State, MergeState: github.MergeStatus(pr),
Checks: []checkEntry{},
}
if pr.IsClosed() {
report.State = "closed"
if pr.Merged {
report.State = "merged"
}
}

for _, run := range github.LatestCheckRuns(runs) {
entry := checkEntry{Name: run.Name, Kind: "check_run", URL: run.HTMLURL, State: strings.ToLower(run.Conclusion)}
if !strings.EqualFold(run.Status, "completed") {
entry.State = strings.ToLower(run.Status)
}
entry.Bucket = checkRunBucket(run)
if !run.StartedAt.IsZero() {
t := run.StartedAt
entry.StartedAt = &t
}
if !run.CompletedAt.IsZero() {
t := run.CompletedAt
entry.CompletedAt = &t
}
report.Checks = append(report.Checks, entry)
}
for _, s := range status.Statuses {
state := github.NormalizeState(s.State)
bucket := checkPending
switch state {
case "success":
bucket = checkPass
case "failure", "error":
bucket = checkFail
}
report.Checks = append(report.Checks, checkEntry{Name: s.Context, Kind: "status", URL: s.TargetURL, State: state, Bucket: bucket})
}

order := map[string]int{checkFail: 0, checkCancel: 1, checkPending: 2, checkPass: 3, checkNeutral: 4, checkSkipping: 5}
sort.SliceStable(report.Checks, func(i, j int) bool {
a, b := report.Checks[i], report.Checks[j]
if order[a.Bucket] != order[b.Bucket] {
return order[a.Bucket] < order[b.Bucket]
}
return strings.ToLower(a.Name) < strings.ToLower(b.Name)
})
return report, nil
}

// checkRunBucket classifies a check run like SummarizeCI does: everything that isn't a
// pass, neutral or skip is a failure (cancelled ones are shown apart).
func checkRunBucket(run github.CheckRun) string {
if !strings.EqualFold(run.Status, "completed") {
return checkPending
}
switch strings.ToLower(run.Conclusion) {
case "success":
return checkPass
case "neutral":
return checkNeutral
case "skipped":
return checkSkipping
case "cancelled":
return checkCancel
default: // failure, timed_out, action_required, startup_failure, stale
return checkFail
}
}

func (r *checksReport) exitErr() error {
pr := fmt.Sprintf("%s/%s#%d", r.Owner, r.Repo, r.Number)
switch r.State {
case "success", "merged":
return nil
case "closed":
// Like `prw wait`: an abandoned PR must not pass `prw checks "$PR" && ...`.
return waitErrorf(exitWaitError, "%s was closed without merging", pr)
case "failure", "error":
return waitErrorf(exitWaitFailed, "CI of %s finished with %s", pr, r.State)
case github.StateNoCI:
return waitErrorf(exitWaitNoCI, "%s has no CI", pr)
default:
return &exitError{code: exitChecksPending, err: fmt.Errorf("CI of %s is still running", pr)}
}
}

var bucketIcon = map[string]string{
checkFail: "❌", checkCancel: "🚫", checkPending: "⏳", checkPass: "✅", checkNeutral: "⚪", checkSkipping: "⏭",
}

func printChecks(w io.Writer, r *checksReport, now time.Time) {
fmt.Fprintf(w, "%s/%s#%d", r.Owner, r.Repo, r.Number)
if r.Title != "" {
fmt.Fprintf(w, " — %s", r.Title)
}
fmt.Fprintln(w)

counts := map[string]int{}
for _, c := range r.Checks {
counts[c.Bucket]++
}
var parts []string
for _, b := range []string{checkFail, checkCancel, checkPending, checkPass, checkNeutral, checkSkipping} {
if counts[b] > 0 {
parts = append(parts, fmt.Sprintf("%d %s", counts[b], b))
}
}
summary := fmt.Sprintf("%s · %s", shortSHA(r.SHA), r.State)
if len(parts) > 0 {
summary += " · " + strings.Join(parts, ", ")
}
if r.MergeState != "" {
summary += " · merge: " + r.MergeState
}
fmt.Fprintln(w, summary)

if len(r.Checks) == 0 {
fmt.Fprintln(w, "\nNo checks or statuses reported for this commit.")
return
}
fmt.Fprintln(w)
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
for _, c := range r.Checks {
fmt.Fprintf(tw, "%s %s\t%s\t%s\t%s\n", bucketIcon[c.Bucket], c.Bucket, c.Name, checkDuration(c, now), c.URL)
}
tw.Flush()
}

// checkDuration is how long a check took, or has been running so far ("3m10s…").
func checkDuration(c checkEntry, now time.Time) string {
if c.StartedAt == nil {
return ""
}
if c.CompletedAt == nil {
return formatCheckDuration(now.Sub(*c.StartedAt)) + "…"
}
return formatCheckDuration(c.CompletedAt.Sub(*c.StartedAt))
}

func formatCheckDuration(d time.Duration) string {
if d < 0 {
d = 0
}
d = d.Round(time.Second)
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60)
}
return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60)
}

func shortSHA(sha string) string {
if len(sha) > 7 {
return sha[:7]
}
return sha
}
Loading