diff --git a/CHANGELOG.md b/CHANGELOG.md index 240e5a1..f8b86a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `. (#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 diff --git a/README.md b/README.md index 81fd6ed..0fde57f 100644 --- a/README.md +++ b/README.md @@ -418,6 +418,21 @@ prw config set bitbucket_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:///api/v3` for Enterprise Server and `https://api..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 `. 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. @@ -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 diff --git a/TASKS.md b/TASKS.md index 821023b..57f61d6 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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..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 diff --git a/cmd/prw/clients.go b/cmd/prw/clients.go index 6c5a405..349feb5 100644 --- a/cmd/prw/clients.go +++ b/cmd/prw/clients.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "fmt" "net/http" "github.com/devblac/prw/internal/bitbucket" @@ -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 == "" { @@ -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 '", 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 } for _, pr := range cfg.WatchedPRs { - if !forge.IsBitbucket(pr.Host) { + if forge.NormalizeHost(pr.Host) == forge.GitHub { return true } } @@ -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} +} + // bitbucketFinder returns the Bitbucket auto-watch finder, or nil without a Bitbucket // account. func bitbucketFinder(cfg *config.Config, bb *bitbucket.Client) *watcher.BitbucketFinder { diff --git a/cmd/prw/dashboard.go b/cmd/prw/dashboard.go index 3126fa6..1ba115a 100644 --- a/cmd/prw/dashboard.go +++ b/cmd/prw/dashboard.go @@ -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), diff --git a/cmd/prw/enterprise_test.go b/cmd/prw/enterprise_test.go new file mode 100644 index 0000000..a01ee5f --- /dev/null +++ b/cmd/prw/enterprise_test.go @@ -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) + } +} diff --git a/cmd/prw/main.go b/cmd/prw/main.go index 25f6246..4f71fea 100644 --- a/cmd/prw/main.go +++ b/cmd/prw/main.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "os" "os/signal" "strconv" @@ -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 @@ -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()) @@ -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 == "": @@ -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 ') - 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) @@ -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": @@ -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": @@ -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", diff --git a/cmd/prw/mcp.go b/cmd/prw/mcp.go index 92078b8..a17fd02 100644 --- a/cmd/prw/mcp.go +++ b/cmd/prw/mcp.go @@ -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{}{ diff --git a/internal/config/config.go b/internal/config/config.go index 8e63717..7f9e110 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "os" "os/exec" "path/filepath" @@ -27,6 +28,11 @@ type Config struct { WebhookURL string `json:"webhook_url,omitempty"` WebhookFormat string `json:"webhook_format,omitempty"` GitHubToken string `json:"github_token,omitempty"` + // GitHubEnterpriseURL is the web address of a GitHub Enterprise Server or GHE.com + // instance (e.g. https://github.acme.com) whose PRs prw watches alongside + // github.com; GitHubEnterpriseToken is its token. + GitHubEnterpriseURL string `json:"github_enterprise_url,omitempty"` + GitHubEnterpriseToken string `json:"github_enterprise_token,omitempty"` // BitbucketToken and BitbucketEmail authenticate against Bitbucket Cloud: an // Atlassian API token with the account's email (Basic auth), or a workspace / // project / repository access token alone (Bearer). See ResolveBitbucketAuth. @@ -421,19 +427,24 @@ const ( ghAuthTokenGitHubHost = "github.com" ) -// GHAuthToken asks the GitHub CLI for its token, so anyone logged in with +// GHAuthToken asks the GitHub CLI for its github.com token, so anyone logged in with // `gh auth login` (including users running prw as a gh extension) needs no extra // setup. It is a variable so tests can replace it and never run the real gh. var GHAuthToken = func() (string, error) { + // Ask for github.com's token explicitly rather than whatever GH_HOST points to. + return GHAuthTokenForHost(ghAuthTokenGitHubHost) +} + +// GHAuthTokenForHost asks the GitHub CLI for its token for host (e.g. a GitHub +// Enterprise host logged in with `gh auth login --hostname`). Tests replace it. +var GHAuthTokenForHost = func(host string) (string, error) { path, err := exec.LookPath("gh") if err != nil { return "", err } ctx, cancel := context.WithTimeout(context.Background(), ghAuthTokenTimeout) defer cancel() - // prw only talks to api.github.com, so ask for that host's token explicitly - // rather than whatever GH_HOST points to. - out, err := exec.CommandContext(ctx, path, "auth", "token", "--hostname", ghAuthTokenGitHubHost).Output() + out, err := exec.CommandContext(ctx, path, "auth", "token", "--hostname", host).Output() if err != nil { return "", err } @@ -464,6 +475,53 @@ func (c *Config) GetToken() string { return token } +// Where ResolveEnterpriseToken found the GitHub Enterprise token. +const ( + TokenSourceGHEEnv = "environment variable (GH_ENTERPRISE_TOKEN)" + TokenSourceGHEEnvAlt = "environment variable (GITHUB_ENTERPRISE_TOKEN)" + TokenSourceGHECLIHint = "GitHub CLI (gh auth token --hostname %s)" +) + +// EnterpriseHost returns the host name of github_enterprise_url (e.g. +// "github.acme.com"), or "" if it isn't set or isn't a URL. +func (c *Config) EnterpriseHost() string { + raw := strings.TrimSpace(c.GitHubEnterpriseURL) + if raw == "" { + return "" + } + if !strings.Contains(raw, "://") { + raw = "https://" + raw + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return "" + } + return strings.ToLower(u.Host) +} + +// ResolveEnterpriseToken returns the GitHub Enterprise token and where it came from, +// checking in order: the config file, GH_ENTERPRISE_TOKEN, GITHUB_ENTERPRISE_TOKEN (the +// GitHub CLI's variables), and `gh auth token --hostname `. +func (c *Config) ResolveEnterpriseToken() (token, source string) { + host := c.EnterpriseHost() + if host == "" { + return "", TokenSourceNone + } + if c.GitHubEnterpriseToken != "" { + return c.GitHubEnterpriseToken, TokenSourceConfig + } + if t := os.Getenv("GH_ENTERPRISE_TOKEN"); t != "" { + return t, TokenSourceGHEEnv + } + if t := os.Getenv("GITHUB_ENTERPRISE_TOKEN"); t != "" { + return t, TokenSourceGHEEnvAlt + } + if t, err := GHAuthTokenForHost(host); err == nil && t != "" { + return t, fmt.Sprintf(TokenSourceGHECLIHint, host) + } + return "", TokenSourceNone +} + // BitbucketAuth is how to authenticate against Bitbucket Cloud. With an Email, Token // is an Atlassian API token used with Basic auth; without one, Token is an access // token sent as Bearer. A zero BitbucketAuth means anonymous (public repositories). diff --git a/internal/config/enterprise_test.go b/internal/config/enterprise_test.go new file mode 100644 index 0000000..a093fb7 --- /dev/null +++ b/internal/config/enterprise_test.go @@ -0,0 +1,54 @@ +package config + +import ( + "errors" + "testing" +) + +func TestEnterpriseHost(t *testing.T) { + for in, want := range map[string]string{"": "", "https://GitHub.Acme.com/": "github.acme.com", "github.acme.com": "github.acme.com", "http://ghe.local:8080": "ghe.local:8080"} { + if got := (&Config{GitHubEnterpriseURL: in}).EnterpriseHost(); got != want { + t.Errorf("EnterpriseHost(%q) = %q, want %q", in, got, want) + } + } +} + +func TestResolveEnterpriseToken(t *testing.T) { + var askedHost string + old := GHAuthTokenForHost + GHAuthTokenForHost = func(host string) (string, error) { + askedHost = host + if host == "github.acme.com" { + return "cli", nil + } + return "", errors.New("not logged in") + } + t.Cleanup(func() { GHAuthTokenForHost = old }) + + cfg := &Config{GitHubEnterpriseURL: "https://github.acme.com", GitHubEnterpriseToken: "cfg"} + t.Setenv("GH_ENTERPRISE_TOKEN", "env") + t.Setenv("GITHUB_ENTERPRISE_TOKEN", "env2") + if tok, src := cfg.ResolveEnterpriseToken(); tok != "cfg" || src != TokenSourceConfig { + t.Errorf("config: %s %s", tok, src) + } + cfg.GitHubEnterpriseToken = "" + if tok, _ := cfg.ResolveEnterpriseToken(); tok != "env" { + t.Errorf("GH_ENTERPRISE_TOKEN: %s", tok) + } + t.Setenv("GH_ENTERPRISE_TOKEN", "") + if tok, _ := cfg.ResolveEnterpriseToken(); tok != "env2" { + t.Errorf("GITHUB_ENTERPRISE_TOKEN: %s", tok) + } + t.Setenv("GITHUB_ENTERPRISE_TOKEN", "") + if tok, _ := cfg.ResolveEnterpriseToken(); tok != "cli" || askedHost != "github.acme.com" { + t.Errorf("gh auth token: %s (asked for %s)", tok, askedHost) + } + if tok, src := (&Config{}).ResolveEnterpriseToken(); tok != "" || src != TokenSourceNone { + t.Errorf("unset: %s %s", tok, src) + } + // GitHub.com's token is never used for the enterprise host, nor the other way round. + t.Setenv("GITHUB_TOKEN", "dotcom") + if tok, _ := (&Config{GitHubEnterpriseURL: "https://other.example.com"}).ResolveEnterpriseToken(); tok != "" { + t.Errorf("must not fall back to github.com's token: %s", tok) + } +} diff --git a/internal/dashboard/sources.go b/internal/dashboard/sources.go index 7a15887..5a85480 100644 --- a/internal/dashboard/sources.go +++ b/internal/dashboard/sources.go @@ -44,9 +44,11 @@ type GitHubAPI interface { // GitHubSource finds PRs with GitHub's search, across all repositories. type GitHubSource struct { API GitHubAPI + // Host is a GitHub Enterprise host; empty means github.com. + Host string } -func (s *GitHubSource) Name() string { return "GitHub" } +func (s *GitHubSource) Name() string { return forge.ServiceName(s.Host) } func (s *GitHubSource) RequestsInLastHour() int { return s.API.RequestsInLastHour() } // Discover runs one search per selected filter and reads each PR (branches, head, @@ -89,9 +91,10 @@ func (s *GitHubSource) Discover(ctx context.Context, f Filter) ([]Item, []string errs[i] = err return } - ref := forge.Ref{Host: forge.GitHub, Owner: r.Owner, Repo: r.Repo, Number: r.Number} + host := forge.NormalizeHost(s.Host) + ref := forge.Ref{Host: host, Owner: r.Owner, Repo: r.Repo, Number: r.Number} found[i] = Item{ - Ref: ref, URL: ref.URL(), Host: forge.GitHub, Repo: r.Owner + "/" + r.Repo, Number: r.Number, + Ref: ref, URL: ref.URL(), Host: host, Repo: r.Owner + "/" + r.Repo, Number: r.Number, Title: pr.Title, Source: pr.Head.Ref, Target: pr.Base.Ref, State: q.state, Role: q.role, UpdatedAt: pr.UpdatedAt, HeadSHA: pr.Head.SHA, Merge: github.MergeStatus(pr), } diff --git a/internal/forge/forge.go b/internal/forge/forge.go index 26458ca..5c15c09 100644 --- a/internal/forge/forge.go +++ b/internal/forge/forge.go @@ -1,5 +1,6 @@ // Package forge identifies pull requests across the code hosting services prw -// supports (GitHub and Bitbucket Cloud): parsing their URLs and building links. +// supports (GitHub, GitHub Enterprise and Bitbucket Cloud): parsing their URLs and +// building links. package forge import ( @@ -15,8 +16,8 @@ const ( Bitbucket = "bitbucket.org" ) -// Ref identifies a pull request. Host is GitHub or Bitbucket; Owner is the GitHub -// owner or the Bitbucket workspace. +// Ref identifies a pull request. Host is GitHub, Bitbucket, or a GitHub Enterprise +// Server / GHE.com host name; Owner is the GitHub owner or the Bitbucket workspace. type Ref struct { Host string Owner string @@ -25,33 +26,47 @@ type Ref struct { } var ( - githubPRURL = regexp.MustCompile(`^(?:https?://)?(?:www\.)?github\.com/([^/]+)/([^/]+)/pull/(\d+)`) bitbucketPRURL = regexp.MustCompile(`^(?:https?://)?(?:www\.)?bitbucket\.org/([^/]+)/([^/]+)/pull-requests/(\d+)`) + // githubPRURL matches github.com and GitHub Enterprise pull request URLs. + githubPRURL = regexp.MustCompile(`^(?:https?://)?([^/\s]+)/([^/]+)/([^/]+)/pull/(\d+)`) ) -// ParsePRURL parses a GitHub (github.com/owner/repo/pull/N) or Bitbucket Cloud -// (bitbucket.org/workspace/repo/pull-requests/N) pull request URL. +// ParsePRURL parses a GitHub (github.com/owner/repo/pull/N), GitHub Enterprise +// (/owner/repo/pull/N) or Bitbucket Cloud (bitbucket.org/workspace/repo/pull-requests/N) +// pull request URL. Whether prw can reach a GitHub Enterprise host is up to the +// caller (it needs github_enterprise_url). func ParsePRURL(url string) (Ref, error) { url = strings.TrimSpace(url) - for host, re := range map[string]*regexp.Regexp{GitHub: githubPRURL, Bitbucket: bitbucketPRURL} { - if m := re.FindStringSubmatch(url); m != nil { - n, err := strconv.Atoi(m[3]) - if err != nil { - return Ref{}, fmt.Errorf("invalid PR number: %w", err) - } - return Ref{Host: host, Owner: m[1], Repo: m[2], Number: n}, nil - } + if m := bitbucketPRURL.FindStringSubmatch(url); m != nil { + return ref(Bitbucket, m[1], m[2], m[3]) } - return Ref{}, fmt.Errorf("not a GitHub (github.com/owner/repo/pull/N) or Bitbucket (bitbucket.org/workspace/repo/pull-requests/N) pull request URL") + if m := githubPRURL.FindStringSubmatch(url); m != nil && !IsBitbucket(m[1]) { + return ref(NormalizeHost(m[1]), m[2], m[3], m[4]) + } + return Ref{}, fmt.Errorf("not a GitHub (github.com/owner/repo/pull/N, or /owner/repo/pull/N) or Bitbucket (bitbucket.org/workspace/repo/pull-requests/N) pull request URL") +} + +func ref(host, owner, repo, number string) (Ref, error) { + n, err := strconv.Atoi(number) + if err != nil { + return Ref{}, fmt.Errorf("invalid PR number: %w", err) + } + return Ref{Host: host, Owner: owner, Repo: repo, Number: n}, nil } -// NormalizeHost maps the empty host of watch-list entries created before Bitbucket -// support (and any casing) to a known host. +// NormalizeHost maps a host to its canonical name: the empty host of watch-list +// entries created before Bitbucket support means GitHub; casing, a "www." prefix +// and a port-less trailing dot are dropped. Other hosts are GitHub Enterprise. func NormalizeHost(host string) string { - if strings.EqualFold(strings.TrimSpace(host), Bitbucket) { + h := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") + h = strings.TrimPrefix(h, "www.") + switch h { + case "", GitHub: + return GitHub + case Bitbucket: return Bitbucket } - return GitHub + return h } // IsBitbucket reports whether host is Bitbucket Cloud. @@ -59,12 +74,44 @@ func IsBitbucket(host string) bool { return NormalizeHost(host) == Bitbucket } +// IsEnterprise reports whether host is a GitHub Enterprise host (neither github.com +// nor Bitbucket). +func IsEnterprise(host string) bool { + h := NormalizeHost(host) + return h != GitHub && h != Bitbucket +} + // PRURL is the web URL of a pull request. func PRURL(host, owner, repo string, number int) string { if IsBitbucket(host) { return fmt.Sprintf("https://bitbucket.org/%s/%s/pull-requests/%d", owner, repo, number) } - return fmt.Sprintf("https://github.com/%s/%s/pull/%d", owner, repo, number) + return fmt.Sprintf("https://%s/%s/%s/pull/%d", NormalizeHost(host), owner, repo, number) +} + +// Display is the short form of a PR for lists: owner/repo#N, prefixed with +// "bitbucket:" on Bitbucket and with the host on GitHub Enterprise. +func Display(host, owner, repo string, number int) string { + name := fmt.Sprintf("%s/%s#%d", owner, repo, number) + switch h := NormalizeHost(host); { + case h == Bitbucket: + return "bitbucket:" + name + case h != GitHub: + return h + ":" + name + } + return name +} + +// ServiceName names the service of host in messages: GitHub, GitHub Enterprise or +// Bitbucket. +func ServiceName(host string) string { + switch { + case IsBitbucket(host): + return "Bitbucket" + case IsEnterprise(host): + return "GitHub Enterprise" + } + return "GitHub" } // URL is the web URL of the pull request. @@ -76,8 +123,8 @@ func (r Ref) String() string { return fmt.Sprintf("%s/%s#%d", r.Owner, r.Repo, r // StorageHost is how a host is stored in the watch list: empty for GitHub, so that // existing config files (which predate the field) keep meaning GitHub. func StorageHost(host string) string { - if IsBitbucket(host) { - return Bitbucket + if h := NormalizeHost(host); h != GitHub { + return h } return "" } diff --git a/internal/forge/forge_test.go b/internal/forge/forge_test.go index 81c49cc..55963c1 100644 --- a/internal/forge/forge_test.go +++ b/internal/forge/forge_test.go @@ -12,6 +12,9 @@ func TestParsePRURL(t *testing.T) { {"https://bitbucket.org/acme/api/pull-requests/42", Ref{Bitbucket, "acme", "api", 42}}, {"https://bitbucket.org/acme/api/pull-requests/42/diff", Ref{Bitbucket, "acme", "api", 42}}, {"bitbucket.org/acme/web-app/pull-requests/1", Ref{Bitbucket, "acme", "web-app", 1}}, + {"https://GitHub.Acme.com/team/api/pull/9", Ref{"github.acme.com", "team", "api", 9}}, + {"https://ghe.local:8443/team/api/pull/9/checks", Ref{"ghe.local:8443", "team", "api", 9}}, + {"https://www.github.com/owner/repo/pull/1", Ref{GitHub, "owner", "repo", 1}}, } for _, tt := range tests { got, err := ParsePRURL(tt.url) @@ -36,4 +39,20 @@ func TestPRURLAndHosts(t *testing.T) { if StorageHost(GitHub) != "" || StorageHost("Bitbucket.org") != Bitbucket || !IsBitbucket("BITBUCKET.ORG") || IsBitbucket("") { t.Error("host normalization") } + if StorageHost("GitHub.Acme.com") != "github.acme.com" || !IsEnterprise("github.acme.com") || IsEnterprise("") || IsEnterprise(Bitbucket) { + t.Error("enterprise host normalization") + } + if got := PRURL("github.acme.com", "team", "api", 9); got != "https://github.acme.com/team/api/pull/9" { + t.Errorf("enterprise URL = %s", got) + } + for host, want := range map[string]string{"": "o/r#1", Bitbucket: "bitbucket:o/r#1", "github.acme.com": "github.acme.com:o/r#1"} { + if got := Display(host, "o", "r", 1); got != want { + t.Errorf("Display(%q) = %s, want %s", host, got, want) + } + } + for host, want := range map[string]string{"": "GitHub", Bitbucket: "Bitbucket", "github.acme.com": "GitHub Enterprise"} { + if got := ServiceName(host); got != want { + t.Errorf("ServiceName(%q) = %s", host, got) + } + } } diff --git a/internal/github/client.go b/internal/github/client.go index b1b5f87..212deb5 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -107,6 +107,37 @@ func NewClient(token string) *Client { } } +// EnterpriseAPIURL returns the REST API base URL of a GitHub Enterprise instance from +// its web address: https://api. for GHE.com (data residency) hosts, and +// :///api/v3 for GitHub Enterprise Server. +func EnterpriseAPIURL(webURL string) (string, error) { + raw := strings.TrimSpace(webURL) + if !strings.Contains(raw, "://") { + raw = "https://" + raw + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") { + return "", fmt.Errorf("github_enterprise_url %q is not a web address like https://github.example.com", webURL) + } + host := strings.ToLower(u.Host) + if strings.HasSuffix(host, ".ghe.com") { + return "https://api." + host, nil + } + return u.Scheme + "://" + host + "/api/v3", nil +} + +// NewEnterpriseClient creates a client for the GitHub Enterprise instance at webURL. +func NewEnterpriseClient(webURL, token string) (*Client, error) { + base, err := EnterpriseAPIURL(webURL) + if err != nil { + return nil, err + } + c := NewClient(token) + c.BaseURL = base + c.Service = "GitHub Enterprise" + return c, nil +} + // APIError is returned when GitHub responds with a non-200 status code. type APIError struct { StatusCode int diff --git a/internal/github/enterprise_test.go b/internal/github/enterprise_test.go new file mode 100644 index 0000000..4172bf1 --- /dev/null +++ b/internal/github/enterprise_test.go @@ -0,0 +1,26 @@ +package github + +import "testing" + +func TestEnterpriseAPIURL(t *testing.T) { + for in, want := range map[string]string{ + "https://github.acme.com": "https://github.acme.com/api/v3", + "github.acme.com/": "https://github.acme.com/api/v3", + "http://ghe.local:8080": "http://ghe.local:8080/api/v3", + "https://Octocorp.GHE.com": "https://api.octocorp.ghe.com", + " https://github.acme.com/x/y ": "https://github.acme.com/api/v3", + } { + if got, err := EnterpriseAPIURL(in); err != nil || got != want { + t.Errorf("EnterpriseAPIURL(%q) = %q, %v; want %q", in, got, err, want) + } + } + for _, bad := range []string{"", "ftp://github.acme.com", "https://"} { + if _, err := EnterpriseAPIURL(bad); err == nil { + t.Errorf("EnterpriseAPIURL(%q) should fail", bad) + } + } + c, err := NewEnterpriseClient("https://github.acme.com", "t") + if err != nil || c.BaseURL != "https://github.acme.com/api/v3" || c.Token != "t" || c.Service != "GitHub Enterprise" { + t.Errorf("client = %+v, %v", c, err) + } +} diff --git a/internal/watcher/autowatch.go b/internal/watcher/autowatch.go index e87b54a..5a644ad 100644 --- a/internal/watcher/autowatch.go +++ b/internal/watcher/autowatch.go @@ -47,17 +47,19 @@ type Found struct { // Finder finds the open PRs of an auto-watch source on one code host. type Finder interface { - Host() string // forge.GitHub, forge.Bitbucket + Host() string // forge.GitHub, forge.Bitbucket, or a GitHub Enterprise host Find(ctx context.Context, source string) (*Found, error) GetPullRequest(ctx context.Context, owner, repo string, number int) (*github.PullRequest, error) } -// GitHubFinder finds PRs with GitHub's search. +// GitHubFinder finds PRs with GitHub's search, on github.com or, with HostName, on a +// GitHub Enterprise host. type GitHubFinder struct { Searcher + HostName string } -func (GitHubFinder) Host() string { return forge.GitHub } +func (f GitHubFinder) Host() string { return forge.NormalizeHost(f.HostName) } func (f GitHubFinder) Find(ctx context.Context, source string) (*Found, error) { query, ok := autoWatchQueries[source] @@ -86,7 +88,7 @@ type SyncResult struct { // SyncAutoWatch runs GitHub's search for every source in sources and updates the watch // list; see Sync. func SyncAutoWatch(ctx context.Context, client Searcher, sources []string) (*SyncResult, error) { - return Sync(ctx, []Finder{GitHubFinder{client}}, sources) + return Sync(ctx, []Finder{GitHubFinder{Searcher: client}}, sources) } // Sync runs every source in sources on every finder's host and updates the watch list: @@ -231,19 +233,15 @@ func (w *Watcher) AddFinder(f Finder) { func (w *Watcher) autoWatchFinders() []Finder { var finders []Finder if s, ok := w.client.(Searcher); ok && !isNilClient(w.client) { - finders = append(finders, GitHubFinder{s}) + finders = append(finders, GitHubFinder{Searcher: s}) } return append(finders, w.finders...) } -// DisplayPR names a watched PR: owner/repo#N, prefixed with "bitbucket:" for -// Bitbucket. +// DisplayPR names a watched PR: owner/repo#N, prefixed with "bitbucket:" on +// Bitbucket and with the host on GitHub Enterprise. func DisplayPR(pr config.WatchedPR) string { - name := fmt.Sprintf("%s/%s#%d", pr.Owner, pr.Repo, pr.Number) - if forge.IsBitbucket(pr.Host) { - name = "bitbucket:" + name - } - return name + return forge.Display(pr.Host, pr.Owner, pr.Repo, pr.Number) } // syncAutoWatch refreshes the auto-watch searches during `prw run` and reports changes. diff --git a/internal/watcher/bitbucket_finder_test.go b/internal/watcher/bitbucket_finder_test.go index e587263..7b36876 100644 --- a/internal/watcher/bitbucket_finder_test.go +++ b/internal/watcher/bitbucket_finder_test.go @@ -166,7 +166,7 @@ func TestSync_TwoHostsKeepTheirPRsApart(t *testing.T) { bbAPI.prs = map[string]*github.PullRequest{"acme/api/7": openReviewPR(7)} bb := &BitbucketFinder{API: bbAPI, User: me, Workspaces: []string{"acme"}} - res, err := Sync(context.Background(), []Finder{GitHubFinder{gh}, bb}, []string{config.AutoWatchMine, config.AutoWatchReviewRequested}) + res, err := Sync(context.Background(), []Finder{GitHubFinder{Searcher: gh}, bb}, []string{config.AutoWatchMine, config.AutoWatchReviewRequested}) if err != nil { t.Fatal(err) } diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index b130193..d16fcc9 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -87,7 +87,8 @@ func New(client GitHubClient, cfg *config.Config, notifier notify.Notifier) *Wat } } -// SetClient sets the client used for PRs on host (forge.Bitbucket). +// SetClient sets the client used for PRs on host (forge.Bitbucket, or a GitHub +// Enterprise host). func (w *Watcher) SetClient(host string, client GitHubClient) { w.clients[forge.NormalizeHost(host)] = client } @@ -267,12 +268,7 @@ func (w *Watcher) pauseForRateLimit(host string, resetAt time.Time) { serviceName(host), resetAt.Local().Format("15:04:05"), resetAt.Sub(now).Round(time.Second)) } -func serviceName(host string) string { - if forge.IsBitbucket(host) { - return "Bitbucket" - } - return "GitHub" -} +func serviceName(host string) string { return forge.ServiceName(host) } // errNoClient is returned for PRs on a host prw has no client for (no token). var errNoClient = errors.New("no client for this host")