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

### Added
- GitHub Enterprise Server and GHE.com, alongside github.com: set `github_enterprise_url` (e.g. `https://github.acme.com`) and every command, auto-watch, the dashboard and the MCP tools accept that host's PR URLs. The token comes from `github_enterprise_token`, `GH_ENTERPRISE_TOKEN` / `GITHUB_ENTERPRISE_TOKEN`, or `gh auth token --hostname <host>`. (#2)
- Auto-watch on Bitbucket Cloud: `prw watch --mine` / `--review-requested` and `prw run` also find your Bitbucket PRs (when a Bitbucket token or `bitbucket_user` is set): your open PRs in every workspace (or `bitbucket_workspaces`), and open PRs listing you as a reviewer that you haven't approved or requested changes on, in repositories active in the last 30 days (rescanned every 15 minutes). Without a GitHub token, auto-watch works on Bitbucket alone.

### Changed
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,21 @@ prw config set 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.

## GitHub Enterprise

prw also works with **GitHub Enterprise Server** and **GHE.com**, alongside github.com: tell it where your instance is, and pass its PR URLs wherever a PR URL goes.

```bash
prw config set github_enterprise_url https://github.acme.com
gh auth login --hostname github.acme.com # or set GH_ENTERPRISE_TOKEN
prw watch https://github.acme.com/team/api/pull/9
```

- The API is `https://<host>/api/v3` for Enterprise Server and `https://api.<subdomain>.ghe.com` for GHE.com.
- The token comes from `github_enterprise_token`, `GH_ENTERPRISE_TOKEN` / `GITHUB_ENTERPRISE_TOKEN` (the GitHub CLI's variables), or `gh auth token --hostname <host>`. Your github.com token is never sent there, nor the other way round.
- `prw run`, `wait`, `checks` (with `--logs`), `list`, `broadcast`, `dashboard`, the auto-watch searches (`--mine` / `--review-requested` cover both hosts) and the MCP tools all work with it. `prw list` shows its PRs as `github.acme.com:team/api`.
- One Enterprise instance at a time; `prw flaky` is github.com only for now.

## Configuration

Configuration is stored in `~/.prw/config.json`. You can manage settings via the `config` subcommand.
Expand Down Expand Up @@ -469,6 +484,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`)
- **`github_enterprise_url`** / **`github_enterprise_token`**: your GitHub Enterprise instance and its token (env `GH_ENTERPRISE_TOKEN`; see [GitHub Enterprise](#github-enterprise))
- **`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 @@ -100,6 +100,7 @@ Legend — Impact: 1–5 · Effort: S (≤½ day), M (1–2 days), L (3+ days)
- 🆕 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.
- ✅ `prw dashboard` (owner's use case: ~25 repos × dev/rc/master cherry-picks at work) — live table grouped by task across GitHub + Bitbucket, smart CI re-reads. Bitbucket discovery endpoints verified live anonymously (workspace user PRs, merged since, recent repos, reviewer filter); `/user` and `/user/workspaces` still need a check with the owner's token. Verified live on the owner's GitHub PRs.
- 🚀 v0.5.0 released with Bitbucket Cloud and `prw dashboard` (PRs #24–#25).
- ✅ #2 GitHub Enterprise — as a third host rather than replacing github.com (one `github_enterprise_url`; API `/api/v3`, or `api.<sub>.ghe.com`; token from config, GH_ENTERPRISE_TOKEN or `gh auth token --hostname`). Routing tested against a fake server; not tried on a real instance.
- ✅ Bitbucket auto-watch (`--mine` / `--review-requested`): per-host finders, review scan every 15 min skipping PRs already reviewed, check interval scaled to the number of Bitbucket PRs (600 req/h budget). Verified live with a public Bitbucket user + the owner's GitHub account in one watch list.
- Next: #17 packaging (Homebrew/Scoop) + keychain token storage, #8 `gh` extension publishing — both need owner decisions

Expand Down
48 changes: 43 additions & 5 deletions cmd/prw/clients.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
"fmt"
"net/http"

"github.com/devblac/prw/internal/bitbucket"
Expand All @@ -27,11 +28,15 @@ var newBitbucketClient = func(email, token string) *bitbucket.Client {
}

// clientFor returns the client for PRs on host. GitHub needs a token; Bitbucket
// works anonymously for public repositories.
// works anonymously for public repositories; a GitHub Enterprise host must be the
// one in github_enterprise_url.
func clientFor(cfg *config.Config, host string) (prClient, error) {
if forge.IsBitbucket(host) {
switch {
case forge.IsBitbucket(host):
auth, _ := cfg.ResolveBitbucketAuth()
return newBitbucketClient(auth.Email, auth.Token), nil
case forge.IsEnterprise(host):
return enterpriseClient(cfg, host)
}
token := cfg.GetToken()
if token == "" {
Expand All @@ -40,13 +45,29 @@ func clientFor(cfg *config.Config, host string) (prClient, error) {
return newGitHubClient(token), nil
}

// needsGitHub reports whether the watch list or auto-watch involves GitHub.
// newEnterpriseClient allows tests to point GitHub Enterprise requests at a fake server.
var newEnterpriseClient = github.NewEnterpriseClient

// enterpriseClient returns the client of the GitHub Enterprise instance on host.
func enterpriseClient(cfg *config.Config, host string) (*github.Client, error) {
configured := cfg.EnterpriseHost()
if configured == "" || forge.NormalizeHost(host) != forge.NormalizeHost(configured) {
return nil, fmt.Errorf("%s is not a known host: for GitHub Enterprise, run 'prw config set github_enterprise_url https://%s'", host, host)
}
token, _ := cfg.ResolveEnterpriseToken()
if token == "" {
return nil, fmt.Errorf("missing GitHub Enterprise token for %s: run 'gh auth login --hostname %s', set GH_ENTERPRISE_TOKEN, or 'prw config set github_enterprise_token <token>'", host, host)
}
return newEnterpriseClient(cfg.GitHubEnterpriseURL, token)
}

// needsGitHub reports whether the watch list or auto-watch involves github.com.
func needsGitHub(cfg *config.Config) bool {
if len(cfg.AutoWatch) > 0 && !bitbucketConfigured(cfg) {
if len(cfg.AutoWatch) > 0 && !bitbucketConfigured(cfg) && cfg.EnterpriseHost() == "" {
return true // auto-watch has nothing else to search
Comment on lines +66 to 67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow Enterprise-only auto-watch with an empty watch list

When only Enterprise credentials are configured and auto-watch is enabled, an empty watch list still makes needsGitHub return true through its final return len(cfg.WatchedPRs) == 0. Thus, after all watched Enterprise PRs close—or when the initial search finds none—prw run exits with errMissingToken in cmd/prw/main.go:435–436, before registering the Enterprise finder. Account for the configured Enterprise auto-watch host in the empty-list fallback so monitoring can start without a github.com token.

Useful? React with 👍 / 👎.

}
for _, pr := range cfg.WatchedPRs {
if !forge.IsBitbucket(pr.Host) {
if forge.NormalizeHost(pr.Host) == forge.GitHub {
return true
}
}
Expand All @@ -67,12 +88,29 @@ func autoWatchFinders(cfg *config.Config, bb *bitbucket.Client) []watcher.Finder
if token := cfg.GetToken(); token != "" {
finders = append(finders, watcher.GitHubFinder{Searcher: newGitHubClient(token)})
}
if f := enterpriseFinder(cfg); f != nil {
finders = append(finders, f)
}
if f := bitbucketFinder(cfg, bb); f != nil {
finders = append(finders, f)
}
return finders
}

// enterpriseFinder returns the GitHub Enterprise auto-watch finder, or nil without
// a usable github_enterprise_url and token.
func enterpriseFinder(cfg *config.Config) watcher.Finder {
host := cfg.EnterpriseHost()
if host == "" {
return nil
}
c, err := enterpriseClient(cfg, host)
if err != nil {
return nil
}
return watcher.GitHubFinder{Searcher: c, HostName: host}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Parse Enterprise URLs in the search-result decoder

For a configured Enterprise instance, this finder calls github.Client.SearchPullRequests, but that method still decodes each result using github.ParsePRURL (internal/github/client.go:403), whose regex only accepts github.com. Consequently, results with URLs such as https://github.acme.com/team/api/pull/9 are silently skipped, so Enterprise auto-watch never discovers PRs. The new dashboard source uses the same method and likewise displays no Enterprise PRs. Update the search-result decoder to accept Enterprise URLs as well.

Useful? React with 👍 / 👎.

}

// bitbucketFinder returns the Bitbucket auto-watch finder, or nil without a Bitbucket
// account.
func bitbucketFinder(cfg *config.Config, bb *bitbucket.Client) *watcher.BitbucketFinder {
Expand Down
7 changes: 7 additions & 0 deletions cmd/prw/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@ func dashboardSources(cfg *config.Config) ([]dashboard.Source, error) {
if token := cfg.GetToken(); token != "" {
sources = append(sources, &dashboard.GitHubSource{API: newGitHubClient(token)})
}
if host := cfg.EnterpriseHost(); host != "" {
ghe, err := enterpriseClient(cfg, host)
if err != nil {
return nil, err
}
sources = append(sources, &dashboard.GitHubSource{API: ghe, Host: host})
}
if auth, _ := cfg.ResolveBitbucketAuth(); auth.Token != "" || cfg.BitbucketUser != "" {
sources = append(sources, &dashboard.BitbucketSource{
API: newBitbucketClient(auth.Email, auth.Token),
Expand Down
81 changes: 81 additions & 0 deletions cmd/prw/enterprise_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package main

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"

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

func TestClientFor_Enterprise(t *testing.T) {
var gotPath, gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotAuth = r.URL.Path, r.Header.Get("Authorization")
fmt.Fprint(w, `{"number": 9, "title": "Add SSO", "state": "open", "head": {"sha": "abc"}}`)
}))
defer srv.Close()
old := newEnterpriseClient
newEnterpriseClient = func(webURL, token string) (*github.Client, error) {
c, err := github.NewEnterpriseClient(webURL, token)
if err == nil {
c.BaseURL = srv.URL + "/api/v3"
}
return c, err
}
t.Cleanup(func() { newEnterpriseClient = old })
t.Setenv("GH_ENTERPRISE_TOKEN", "ghe-token")
t.Setenv("GITHUB_ENTERPRISE_TOKEN", "")

ref, err := forge.ParsePRURL("https://github.acme.com/team/api/pull/9")
if err != nil {
t.Fatal(err)
}
if _, err := clientFor(&config.Config{}, ref.Host); err == nil || !strings.Contains(err.Error(), "github_enterprise_url") {
t.Errorf("unconfigured host: err = %v", err)
}
if _, err := clientFor(&config.Config{GitHubEnterpriseURL: "https://other.example.com"}, ref.Host); err == nil {
t.Error("a host other than the configured one must be refused")
}

cfg := &config.Config{GitHubEnterpriseURL: "https://github.acme.com"}
client, err := clientFor(cfg, ref.Host)
if err != nil {
t.Fatal(err)
}
pr, err := client.GetPullRequest(context.Background(), ref.Owner, ref.Repo, ref.Number)
if err != nil || pr.Title != "Add SSO" {
t.Fatalf("pr = %+v, %v", pr, err)
}
if gotPath != "/api/v3/repos/team/api/pulls/9" || gotAuth != "Bearer ghe-token" {
t.Errorf("request to %s with %q", gotPath, gotAuth)
}

t.Setenv("GH_ENTERPRISE_TOKEN", "")
old2 := config.GHAuthTokenForHost
config.GHAuthTokenForHost = func(string) (string, error) { return "", fmt.Errorf("not logged in") }
t.Cleanup(func() { config.GHAuthTokenForHost = old2 })
if _, err := clientFor(cfg, ref.Host); err == nil || !strings.Contains(err.Error(), "gh auth login --hostname github.acme.com") {
t.Errorf("missing token: err = %v", err)
}
}

func TestConfigSet_EnterpriseURL(t *testing.T) {
cfg := &config.Config{}
if err := applyConfigSet(cfg, "github_enterprise_url", "https://github.acme.com/"); err != nil || cfg.GitHubEnterpriseURL != "https://github.acme.com" {
t.Errorf("set: %v, %q", err, cfg.GitHubEnterpriseURL)
}
for _, bad := range []string{"https://github.com", "bitbucket.org", "ftp://x.example.com"} {
if err := applyConfigSet(cfg, "github_enterprise_url", bad); err == nil {
t.Errorf("github_enterprise_url %q should be refused", bad)
}
}
if err := applyConfigUnset(cfg, "github_enterprise_url"); err != nil || cfg.GitHubEnterpriseURL != "" {
t.Errorf("unset: %v", err)
}
}
49 changes: 48 additions & 1 deletion cmd/prw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"os/signal"
"strconv"
Expand Down Expand Up @@ -297,8 +298,11 @@ var listCmd = &cobra.Command{

for _, pr := range cfg.WatchedPRs {
repo := fmt.Sprintf("%s/%s", pr.Owner, pr.Repo)
if forge.IsBitbucket(pr.Host) {
switch {
case forge.IsBitbucket(pr.Host):
repo = "bitbucket:" + repo
case forge.IsEnterprise(pr.Host):
repo = forge.NormalizeHost(pr.Host) + ":" + repo
}
prNum := fmt.Sprintf("#%d", pr.Number)
status := pr.LastKnownState
Expand Down Expand Up @@ -459,6 +463,15 @@ Press Ctrl+C to stop.`,
if f := bitbucketFinder(cfg, bb); f != nil {
w.AddFinder(f)
}
if host := cfg.EnterpriseHost(); host != "" {
ghe, err := enterpriseClient(cfg, host)
if err != nil {
fmt.Printf("Warning: %v\n", err)
} else {
w.SetClient(host, ghe)
w.AddFinder(watcher.GitHubFinder{Searcher: ghe, HostName: host})
}
}

// Setup signal handling
ctx, cancel := context.WithCancel(context.Background())
Expand Down Expand Up @@ -508,6 +521,11 @@ var configShowCmd = &cobra.Command{

_, tokenSource := cfg.ResolveToken()
fmt.Printf("github_token: %s\n", tokenSource)
if host := cfg.EnterpriseHost(); host != "" {
_, gheSource := cfg.ResolveEnterpriseToken()
fmt.Printf("github_enterprise_url: %s\n", cfg.GitHubEnterpriseURL)
fmt.Printf("github_enterprise_token: %s\n", gheSource)
}
bbAuth, bbSource := cfg.ResolveBitbucketAuth()
switch {
case bbAuth.Token == "":
Expand Down Expand Up @@ -539,6 +557,9 @@ Supported keys:
- webhook_url: URL to POST notifications to
- webhook_format: auto, slack, discord, or generic (default: auto, detected from the URL)
- github_token: GitHub personal access token
- github_enterprise_url: web address of your GitHub Enterprise Server or GHE.com
instance (e.g. https://github.acme.com), watched alongside github.com
- github_enterprise_token: its token (default: GH_ENTERPRISE_TOKEN, or 'gh auth token --hostname <host>')
- bitbucket_token: Bitbucket Cloud API token (with bitbucket_email), or an access token alone
- bitbucket_email: the Atlassian account email that goes with an API token
- bitbucket_workspaces: workspaces 'prw dashboard' looks in, comma-separated (default: all of yours)
Expand Down Expand Up @@ -587,6 +608,16 @@ func applyConfigSet(cfg *config.Config, key, value string) error {
cfg.WebhookFormat = format
case "github_token":
cfg.GitHubToken = value
case "github_enterprise_url":
if _, err := github.EnterpriseAPIURL(value); err != nil {
return err
}
if forge.IsBitbucket(value) || forge.NormalizeHost(hostOf(value)) == forge.GitHub {
return fmt.Errorf("github_enterprise_url is for GitHub Enterprise; github.com needs no setting")
}
cfg.GitHubEnterpriseURL = strings.TrimRight(strings.TrimSpace(value), "/")
case "github_enterprise_token":
cfg.GitHubEnterpriseToken = value
case "bitbucket_token":
cfg.BitbucketToken = value
case "bitbucket_email":
Expand Down Expand Up @@ -659,6 +690,10 @@ func applyConfigUnset(cfg *config.Config, key string) error {
cfg.WebhookFormat = ""
case "github_token":
cfg.GitHubToken = ""
case "github_enterprise_url":
cfg.GitHubEnterpriseURL = ""
case "github_enterprise_token":
cfg.GitHubEnterpriseToken = ""
case "bitbucket_token":
cfg.BitbucketToken = ""
case "bitbucket_email":
Expand All @@ -685,6 +720,18 @@ func applyConfigUnset(cfg *config.Config, key string) error {
return nil
}

// hostOf returns the host of a web address given with or without a scheme.
func hostOf(webURL string) string {
raw := strings.TrimSpace(webURL)
if !strings.Contains(raw, "://") {
raw = "https://" + raw
}
if u, err := url.Parse(raw); err == nil {
return u.Host
}
return ""
}

var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Expand Down
2 changes: 1 addition & 1 deletion cmd/prw/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ func toolOK(summary string, data interface{}) mcpToolResult {

var prURLSchema = map[string]interface{}{
"type": "string",
"description": "GitHub pull request URL, e.g. https://github.com/owner/repo/pull/123",
"description": "Pull request URL on GitHub (https://github.com/owner/repo/pull/123), the configured GitHub Enterprise host, or Bitbucket Cloud (https://bitbucket.org/workspace/repo/pull-requests/7)",
}

var mcpTools = []map[string]interface{}{
Expand Down
Loading
Loading