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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Bitbucket Cloud pull requests: `prw watch`, `run`, `wait`, `checks`, `list`, `broadcast` and the MCP tools accept `bitbucket.org/<workspace>/<repo>/pull-requests/<N>` URLs. CI comes from the commit's build statuses (Bitbucket Pipelines, Jenkins, ...), reviews from participants' approvals and change requests, the merge state from the PR's merge checks. Authenticate with an Atlassian API token (`bitbucket_email` + `bitbucket_token`, or `BITBUCKET_EMAIL` / `BITBUCKET_TOKEN`) or an access token alone; public repositories need none. To stay within Bitbucket's 1,000 requests/hour, `prw run` checks each Bitbucket PR at most once a minute, and rate limits pause only the service that hit them. Auto-watch, `--logs` and `prw flaky` are GitHub-only for now.

### Changed
- `prw config set` no longer echoes token values.
- Watch-list entries, notifications (`host` in the generic webhook payload), `prw list --json`, `prw wait --json` / `prw checks --json` and MCP results carry the PR's host; GitHub entries keep an empty one, so existing config files are unchanged.

## [0.4.0] - 2026-10-24

prw now covers the whole PR lifecycle and is built for scripts and AI agents: it detects GitHub Actions results (previously reported as `pending` forever), works with Slack and Discord webhooks, and adds `prw wait`, `prw checks`, `prw flaky`, an MCP server, auto-watching your PRs, review and merge-conflict events, and failed-job log excerpts. It reuses your `gh` login and polls with conditional requests that cost no API quota.
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ The tool is deliberately minimal. It does one thing well: watch PRs and tell you
- **`prw flaky`**: find a repository's flaky jobs (failed, then passed on re-run of the same commit)
- **`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
- **Bitbucket Cloud**: watch, wait for and list the builds of Bitbucket PRs (Pipelines, Jenkins, ...) with the same commands
- **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
Expand Down Expand Up @@ -351,6 +352,32 @@ Tools:

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.

## Bitbucket Cloud

prw also watches **Bitbucket Cloud** pull requests: pass their URL wherever a PR URL goes.

```bash
prw watch https://bitbucket.org/acme/api/pull-requests/42
prw wait https://bitbucket.org/acme/api/pull-requests/42 && ./deploy.sh
prw checks https://bitbucket.org/acme/api/pull-requests/42
```

- **CI** comes from the commit's build statuses, so it covers Bitbucket Pipelines, Jenkins and anything else that reports to Bitbucket, each with its link and duration. A stopped build counts as failed.
- **Reviews**: participants who approved or requested changes. **Merge state** comes from the PR's merge checks: `conflict`, `blocked` (e.g. missing approvals or successful builds), or `ready`.
- `prw run` notifies about Bitbucket PRs just like GitHub ones, `prw list` shows them as `bitbucket:workspace/repo`, and the MCP tools accept their URLs.
- Bitbucket Cloud allows 1,000 API requests per hour, so `prw run` checks each Bitbucket PR at most once a minute whatever the poll interval (about 8 PRs fit in the limit). A rate limit on one service doesn't pause the other.
- Not yet for Bitbucket: `--mine` / `--review-requested`, `--logs` and `prw flaky`.

**Authentication.** Public repositories work without credentials. For private ones, create an [Atlassian API token](https://id.atlassian.com/manage-profile/security/api-tokens) with Bitbucket read scopes (repositories and pull requests) and give prw your Atlassian email with it:

```bash
prw config set bitbucket_email you@example.com
prw config set bitbucket_token <api-token>
# or: export BITBUCKET_EMAIL=you@example.com BITBUCKET_TOKEN=<api-token>
```

A workspace, project or repository access token also works: set only `bitbucket_token` (it's sent as a Bearer token). No GitHub token is needed if you only watch Bitbucket PRs.

## Configuration

Configuration is stored in `~/.prw/config.json`. You can manage settings via the `config` subcommand.
Expand Down Expand Up @@ -402,6 +429,7 @@ prw config set webhook_url https://hooks.slack.com/services/YOUR/WEBHOOK/URL
- **`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`)
- **`bitbucket_token`** / **`bitbucket_email`**: Bitbucket Cloud API token and its Atlassian email, or an access token alone (env `BITBUCKET_TOKEN` / `BITBUCKET_EMAIL`)

## Notifications

Expand Down
1 change: 1 addition & 0 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ Legend — Impact: 1–5 · Effort: S (≤½ day), M (1–2 days), L (3+ days)
- ✅ #15 Failed job log excerpt — done (`--logs` on `checks`/`wait`, MCP `get_failed_logs`; parser tested on a real staticcheck failure log; verified live on cloudposse/atmos#3476, #3422 and #2199 — a commit status — and over MCP stdio)
- ✅ #16 Flakiness detection — done as `prw flaky` + MCP `get_flaky_checks` (failed-then-passed across attempts of the same run; verified live: grafana/grafana 4 flaky jobs in 300 runs, hashicorp/terraform "Unit Tests"; parallel fetching cut grafana from 24s to 9s). The "status history" half was dropped: low value for its cost.
- 🚀 v0.4.0 released with everything above (PRs #6–#22).
- 🆕 Bitbucket Cloud support (requested by the owner, who uses Bitbucket at work). Phase 1 done: PR identity carries the host; a Bitbucket client maps PRs, build statuses (Pipelines, Jenkins), participants and merge checks onto the existing types, so run / wait / checks / list / broadcast / MCP work unchanged; per-host rate-limit pauses; ≥60s between checks of a Bitbucket PR (1,000 req/h limit). Verified live on public atlassian/atlassian-connect-express PRs. Next: auto-watch (`--mine` / `--review-requested`) on Bitbucket, Pipelines step logs for `--logs`, then GitHub Enterprise (#2) on the same host abstraction.
- Next: #17 packaging (Homebrew/Scoop) + keychain token storage, #8 `gh` extension publishing — both need owner decisions

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

import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"

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

const bbPRURL = "https://bitbucket.org/acme/api/pull-requests/42"

// fakeBitbucketAPI serves PR acme/api#42 with the given state and build statuses.
type fakeBitbucketAPI struct {
state string
statuses string
auth string
}

func (f *fakeBitbucketAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.auth = r.Header.Get("Authorization")
switch p := r.URL.Path; {
case strings.HasSuffix(p, "/repositories/acme/api/pullrequests/42"):
fmt.Fprintf(w, `{"id": 42, "title": "Add login", "state": %q, "updated_on": "2026-01-01T00:00:00+00:00",
"source": {"commit": {"hash": "abc123def456"}},
"participants": [{"user": {"uuid": "{a}"}, "role": "REVIEWER", "approved": true, "state": "approved"}]}`, f.state)
case strings.HasSuffix(p, "/pullrequests/42/mergeability/checks"):
fmt.Fprint(w, `{"values": [{"type": "git_mergeability_check", "status": "PASSED"}]}`)
case strings.HasSuffix(p, "/commit/abc123def456/statuses"):
fmt.Fprintf(w, `{"values": [%s]}`, f.statuses)
default:
http.NotFound(w, r)
}
}

// setupBitbucket points Bitbucket requests at a fake API; GitHub has no token.
func setupBitbucket(t *testing.T, f *fakeBitbucketAPI, mutate func(*config.Config)) {
t.Helper()
setupWaitCommand(t, func(w http.ResponseWriter, r *http.Request) {
t.Errorf("unexpected GitHub request %s", r.URL.Path)
http.NotFound(w, r)
})
cfg, _ := config.Load()
cfg.GitHubToken = ""
if mutate != nil {
mutate(cfg)
}
if err := cfg.Save(); err != nil {
t.Fatal(err)
}
t.Setenv("GITHUB_TOKEN", "")
t.Setenv("GH_TOKEN", "")
t.Setenv("BITBUCKET_TOKEN", "")
server := httptest.NewServer(f)
t.Cleanup(server.Close)
old := newBitbucketClient
newBitbucketClient = func(email, token string) *bitbucket.Client {
c := bitbucket.NewClient(email, token)
c.SetBaseURL(server.URL)
return c
}
t.Cleanup(func() { newBitbucketClient = old })
}

func bbStatus(name, state string) string {
return fmt.Sprintf(`{"key": %q, "name": %q, "state": %q, "url": "https://bitbucket.org/acme/api/pipelines/results/7",
"created_on": "2026-01-01T00:00:00+00:00", "updated_on": "2026-01-01T00:04:00+00:00"}`, name, name, state)
}

func TestBitbucket_WatchListAndUnwatch(t *testing.T) {
f := &fakeBitbucketAPI{state: "OPEN", statuses: bbStatus("Pipeline - default", "INPROGRESS")}
setupBitbucket(t, f, func(c *config.Config) { c.BitbucketEmail, c.BitbucketToken = "dev@example.com", "api-token" })

out, err := captureStdout(func() error { return watchCmd.RunE(watchCmd, []string{bbPRURL}) })
if err != nil || !strings.Contains(out, "Now watching: acme/api#42 - Add login") {
t.Fatalf("watch: %v\n%s", err, out)
}
if !strings.HasPrefix(f.auth, "Basic ") {
t.Errorf("the API token must be sent: Authorization = %q", f.auth)
}
cfg, _ := config.Load()
if len(cfg.WatchedPRs) != 1 || cfg.WatchedPRs[0].Host != forge.Bitbucket {
t.Fatalf("watch list = %+v", cfg.WatchedPRs)
}

listJSON = false
out, _ = captureStdout(func() error { return listCmd.RunE(listCmd, nil) })
if !strings.Contains(out, "bitbucket:acme/api") {
t.Errorf("list must show the host:\n%s", out)
}

// The same owner/repo#number on GitHub is a different PR.
captureStdout(func() error { return unwatchCmd.RunE(unwatchCmd, []string{"https://github.com/acme/api/pull/42"}) })
if cfg, _ := config.Load(); len(cfg.WatchedPRs) != 1 {
t.Error("unwatching the GitHub PR must not remove the Bitbucket one")
}
captureStdout(func() error { return unwatchCmd.RunE(unwatchCmd, []string{bbPRURL}) })
if cfg, _ := config.Load(); len(cfg.WatchedPRs) != 0 {
t.Errorf("watch list = %+v", cfg.WatchedPRs)
}
}

func TestBitbucket_Wait(t *testing.T) {
f := &fakeBitbucketAPI{state: "OPEN", statuses: bbStatus("Pipeline - default", "FAILED") + "," + bbStatus("Jenkins", "SUCCESSFUL")}
setupBitbucket(t, f, nil)
waitJSON = true

out, err := captureStdout(func() error { return waitCmd.RunE(waitCmd, []string{bbPRURL}) })
if code := exitCode(err); code != exitWaitFailed {
t.Fatalf("exit code = %d (err=%v)\n%s", code, err, out)
}
var result waitResult
if err := json.Unmarshal([]byte(out), &result); err != nil {
t.Fatalf("%v\n%s", err, out)
}
if result.Host != forge.Bitbucket || result.URL != bbPRURL || result.State != "failure" || result.MergeState != "ready" ||
len(result.FailedChecks) != 1 || result.FailedChecks[0].Name != "Pipeline - default" {
t.Errorf("result = %+v", result)
}
}

func TestBitbucket_Checks(t *testing.T) {
f := &fakeBitbucketAPI{state: "OPEN", statuses: bbStatus("Pipeline - default", "SUCCESSFUL")}
setupBitbucket(t, f, nil)
checksJSON, checksLogs = false, false

out, err := captureStdout(func() error { return checksCmd.RunE(checksCmd, []string{bbPRURL}) })
if err != nil {
t.Fatalf("%v\n%s", err, out)
}
if !strings.Contains(out, "✅ pass Pipeline - default 4m00s https://bitbucket.org/acme/api/pipelines/results/7") {
t.Errorf("stdout:\n%s", out)
}
}

func TestBitbucket_RunNotifiesWithBitbucketLinks(t *testing.T) {
f := &fakeBitbucketAPI{state: "OPEN", statuses: bbStatus("Pipeline - default", "FAILED")}
setupBitbucket(t, f, func(c *config.Config) {
c.WatchedPRs = []config.WatchedPR{{Host: forge.Bitbucket, Owner: "acme", Repo: "api", Number: 42, LastKnownState: "pending"}}
})
runOnce = true
t.Cleanup(func() { runOnce = false })

out, err := captureStdout(func() error { return runCmd.RunE(runCmd, nil) })
if err != nil {
t.Fatalf("run without a GitHub token must work for Bitbucket-only watch lists: %v", err)
}
if !strings.Contains(out, "Status: pending → failure") || !strings.Contains(out, "Link: "+bbPRURL) {
t.Errorf("stdout:\n%s", out)
}
}

func TestBitbucket_PrivateRepoHint(t *testing.T) {
setupBitbucket(t, &fakeBitbucketAPI{state: "OPEN"}, nil)
_, err := captureStdout(func() error {
return watchCmd.RunE(watchCmd, []string{"https://bitbucket.org/acme/private/pull-requests/1"})
})
if err == nil || !strings.Contains(err.Error(), "prw config set bitbucket_token") {
t.Errorf("err = %v; a 404 on Bitbucket must explain how to authenticate", err)
}
}

func TestBitbucket_FlakyIsGitHubOnly(t *testing.T) {
if _, _, err := parseRepoArg("https://bitbucket.org/acme/api/pull-requests/42"); err == nil || !strings.Contains(err.Error(), "GitHub Actions") {
t.Errorf("err = %v", err)
}
}
19 changes: 13 additions & 6 deletions cmd/prw/broadcast.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"

"github.com/devblac/prw/internal/config"
"github.com/devblac/prw/internal/forge"
"github.com/devblac/prw/internal/github"
"github.com/devblac/prw/internal/notify"
)
Expand Down Expand Up @@ -41,11 +42,6 @@ var broadcastCmd = &cobra.Command{
return nil
}

token := cfg.GetToken()
if token == "" {
return errMissingToken
}

filter := strings.ToLower(strings.TrimSpace(broadcastFilter))
if filter == "" {
filter = "all"
Expand All @@ -54,7 +50,7 @@ var broadcastCmd = &cobra.Command{
return fmt.Errorf("invalid --filter value %q (expected all, changed, or failing)", broadcastFilter)
}

client := newGitHubClient(token)
clients := map[string]prClient{}

webhookURL := broadcastWebhook
if webhookURL == "" {
Expand All @@ -71,6 +67,16 @@ var broadcastCmd = &cobra.Command{
for i := range cfg.WatchedPRs {
pr := &cfg.WatchedPRs[i]

host := forge.NormalizeHost(pr.Host)
client, ok := clients[host]
if !ok {
if client, err = clientFor(cfg, host); err != nil {
fmt.Printf("Skipping %s/%s#%d: %v\n", pr.Owner, pr.Repo, pr.Number, err)
continue
}
clients[host] = client
}

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)
Expand Down Expand Up @@ -112,6 +118,7 @@ var broadcastCmd = &cobra.Command{
}

event := &notify.StatusChangeEvent{
Host: pr.Host,
Owner: pr.Owner,
Repo: pr.Repo,
Number: pr.Number,
Expand Down
21 changes: 12 additions & 9 deletions cmd/prw/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/spf13/cobra"

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

Expand Down Expand Up @@ -72,24 +73,24 @@ Exit codes:
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
owner, repo, number, err := github.ParsePRURL(args[0])
ref, err := forge.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}
client, err := clientFor(cfg, ref.Host)
if err != nil {
return &exitError{code: exitWaitError, err: err}
}
owner, repo := ref.Owner, ref.Repo

ctx := context.Background()
client := newGitHubClient(token)
report, err := fetchChecks(ctx, client, owner, repo, number, time.Now())
report, err := fetchChecks(ctx, client, ref, time.Now())
if err != nil {
return &exitError{code: exitWaitError, err: err}
return &exitError{code: exitWaitError, err: fmt.Errorf("%w%s", err, bitbucketAuthHint(ref.Host, err))}
}
if checksLogs {
report.Logs, err = fetchFailedLogs(ctx, client, owner, repo, report.SHA, checksLogLines)
Expand Down Expand Up @@ -124,6 +125,7 @@ type checkEntry struct {

// checksReport is the output of `prw checks`.
type checksReport struct {
Host string `json:"host,omitempty"`
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
Expand All @@ -139,7 +141,8 @@ type checksReport struct {

// 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) {
func fetchChecks(ctx context.Context, client ciClient, ref forge.Ref, now time.Time) (*checksReport, error) {
owner, repo, number := ref.Owner, ref.Repo, ref.Number
pr, err := client.GetPullRequest(ctx, owner, repo, number)
if err != nil {
return nil, fmt.Errorf("failed to fetch PR: %w", err)
Expand All @@ -158,7 +161,7 @@ func fetchChecks(ctx context.Context, client ciClient, owner, repo string, numbe
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),
URL: ref.URL(), Host: forge.StorageHost(ref.Host), State: ci.State, MergeState: github.MergeStatus(pr),
Checks: []checkEntry{},
}
if pr.IsClosed() {
Expand Down
5 changes: 3 additions & 2 deletions cmd/prw/checks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"testing"
"time"

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

Expand Down Expand Up @@ -47,7 +48,7 @@ func checksFixture() *scriptedClient {
}

func TestFetchChecks_BucketsAndOrder(t *testing.T) {
report, err := fetchChecks(context.Background(), checksFixture(), "o", "r", 1, checksNow)
report, err := fetchChecks(context.Background(), checksFixture(), forge.Ref{Owner: "o", Repo: "r", Number: 1}, checksNow)
if err != nil {
t.Fatal(err)
}
Expand All @@ -68,7 +69,7 @@ func TestFetchChecks_BucketsAndOrder(t *testing.T) {
}

func TestPrintChecks(t *testing.T) {
report, err := fetchChecks(context.Background(), checksFixture(), "o", "r", 1, checksNow)
report, err := fetchChecks(context.Background(), checksFixture(), forge.Ref{Owner: "o", Repo: "r", Number: 1}, checksNow)
if err != nil {
t.Fatal(err)
}
Expand Down
Loading
Loading