Repository: cli/cli
Pull request #9745 (2024-10-11 14:29:19)

Introduce testscript acceptance tests generally, and for the PR command specifically
## Description

The goal of this work is to have a set of automated tests that we can use to point `gh` at a real GitHub host in order to have some sense of blackbox behavioural validation. This PR introduces the use of `testscript` using the `gh pr` command as a motivating example.

It is a **non-goal** right now to have these running as part of our CI suite.

### Reviewer Notes

The README contains most information that I won't repeat in this description.

One big change here is that in order to avoid building `gh` and to enable code coverage, I had to move the `RealMain` that used to be in the `cmd/gh/main.go` into its own package called `ghcmd` (`main` packages can't be imported), and to export the function. I can't really see much of an issue with this, it's actually pretty idiomatic.


Patch:
diff --git a/acceptance/README.md b/acceptance/README.md
new file mode 100644
index 000000000..1a7f6a93d
--- /dev/null
+++ b/acceptance/README.md
@@ -0,0 +1,145 @@
+## Acceptance Tests
+
+The acceptance tests are blackbox* tests that are expected to interact with resources on a real GitHub instance.  They are built on top of the [`go-internal/testscript`](https://pkg.go.dev/github.com/rogpeppe/go-internal/testscript) package, which provides a framework for building tests for command line tools.
+
+*Note: they aren't strictly blackbox because `exec gh` commands delegate to a binary set up by `testscript` that calls into `ghcmd.Main`. However, since our real `func main` is an extremely thin adapter over `ghcmd.Main`, this is reasonable. This tradeoff avoids us building the binary ourselves for the tests, and allows us to get code coverage metrics.
+
+### Running the Acceptance Tests
+
+The acceptance tests have a build constraint of `//go:build acceptance`, this means that `go test ./...` will continue to work without any modifications. The `acceptance` tag must therefore be provided when running `go test`.
+
+The following environment variables are required:
+
+#### `GH_ACCEPTANCE_HOST`
+
+The GitHub host to target e.g. `github.com`
+
+#### `GH_ACCEPTANCE_ORG`
+
+The organization in which the acceptance tests can manage resources in. Consider using `gh-acceptance-testing` on `github.com`.
+
+#### `GH_ACCEPTANCE_TOKEN`
+
+The token to use for authenticatin with the `GH_ACCEPTANCE_HOST`. This must already have the necessary scopes for each test, and must have permissions to act in the `GH_ACCEPTANCE_ORG`. See [Effective Test Authoring](#effective-test-authoring) for how tests must handle tokens without sufficient scopes.
+
+It's recommended to create and use a Legacy PAT for this; Fine-Grained PATs do not offer all the necessary privileges required. You can use an OAuth token provided via `gh auth login --web` and can provide it to the acceptance tests via `GH_ACCEPTANCE_TOKEN=$(gh auth token --hostname <host>)` but this can be a bit confusing and annoying if you `gh auth login` again without `-s` and lose the required scopes.
+
+---
+
+A full example invocation can be found below:
+
+```
+GH_ACCEPTANCE_HOST=<host> GH_ACCEPTANCE_ORG=<org> GH_ACCEPTANCE_TOKEN=<token> go test -tags=acceptance ./acceptance
+```
+
+#### Code Coverage
+
+To get code coverage, `go test` can be invoked with `coverpkg` and `coverprofile` like so:
+
+```
+GH_ACCEPTANCE_HOST=<host> GH_ACCEPTANCE_ORG=<org> GH_ACCEPTANCE_TOKEN=<token> go test -tags=acceptance -coverprofile=coverage.out -coverpkg=./... ./acceptance
+```
+
+### Writing Tests
+
+This section is to be expanded over time as we write more tests and learn more.
+
+#### Environment Variables
+
+The following custom environment variables are made available to the scripts:
+ * `GH_HOST`: Set to value of the `GH_ACCEPTANCE_ORG` env var provided to `go test`
+ * `ORG`: Set to the value of the `GH_ACCEPTANCE_ORG` env var provided to `go test`
+ * `GH_TOKEN`: Set to the value of the `GH_ACCEPTANCE_TOKEN` env var provided to `go test`
+ * `RANDOM_STRING`: Set to a length 10 random string of letters to help isolate globally visible resources
+ * `SCRIPT_NAME`: Set to the name of the `testscript` currently running, without extension e.g. `pr-view`
+ * `HOME`: Set to the initial working directory. Required for `git` operations
+ * `GH_CONFIG_DIR`: Set to the initial working directory. Required for `gh` operations
+
+### Acceptance Test VS Code Support
+
+Due to the `//go:build acceptance` build constraint, some functionality is limited because `gopls` isn't being informed about the tag. To resolve this, set the following in your `settings.json`:
+
+```json
+  "gopls": {
+    "buildFlags": [
+        "-tags=acceptance"
+    ]
+  },
+```
+
+You can install the [`txtar`](https://marketplace.visualstudio.com/items?itemName=brody715.txtar) or [`vscode-testscript`](https://marketplace.visualstudio.com/items?itemName=twpayne.vscode-testscript) extensions to get syntax highlighting.
+
+### Debugging Tests
+
+When tests fail they fail like this:
+
+```
+➜ go test -tags=acceptance ./acceptance
+--- FAIL: TestPullRequests (0.00s)
+    --- FAIL: TestPullRequests/pr-merge (11.07s)
+        testscript.go:584: WORK=/private/var/folders/45/sdnm1hp10nj1s9q57dp3bc5h0000gn/T/go-test-script2778137936/script-pr-merge
+            # Use gh as a credential helper (0.693s)
+            # Create a repository with a file so it has a default branch (1.155s)
+            # Defer repo cleanup (0.000s)
+            # Clone the repo (1.551s)
+            # Prepare a branch to PR with a single file (1.168s)
+            # Create the PR (1.903s)
+            # Check that the file doesn't exist on the main branch (0.059s)
+            # Merge the PR (2.426s)
+            # Check that the state of the PR is now merged (0.571s)
+            # Pull and check the file exists on the main branch (1.074s)
+            # And check we had a merge commit (0.462s)
+            > exec git show HEAD
+            [stdout]
+            commit 85d32c1a83ace270f6754c61f3f7e14956be0a47
+            Author: William Martin <williammartin@william-github-laptop.kpn>
+            Date:   Fri Oct 11 15:23:56 2024 +0200
+
+                Add file.txt
+
+            diff --git a/file.txt b/file.txt
+            new file mode 100644
+            index 0000000..7449899
+            --- /dev/null
+            +++ b/file.txt
+            @@ -0,0 +1 @@
+            +Unimportant contents
+            > stdout 'Merge pull request #1'
+            FAIL: testdata/pr/pr-merge.txtar:42: no match for `Merge pull request #1` found in stdout
+```
+
+This is generally enough information to understand why a test has failed. However, we can get more information by providing the `-v` flag to `go test`, which turns on verbose mode and shows each command and any associated `stdio`.
+
+> [!WARNING]
+> Verbose mode dumps the `testscript` environment variables, including the `GH_TOKEN`, so be careful.
+
+By default `testscript` removes the directory in which it was running the script, and if you've been a conscientious engineer, you should be cleaning up resources using the `defer` statement. However, this can be an impediment to debugging. As such you can set `GH_ACCEPTANCE_PRESERVE_WORK_DIR=true` and `GH_ACCEPTANCE_SKIP_DEFER=true` to skip these cleanup steps.
+
+### Effective Test Authoring
+
+This section is to be expanded over time as we write more tests and learn more.
+
+#### Test Isolation
+
+The `testscript` library creates a somewhat isolated environment for each script. Each script gets a directory with limited environment variables by default. As far as reasonable, we should look to write scripts that depend on nothing more than themselves, the GitHub resources they manage, and limited additional environmental injection from our own `testscript` setup.
+
+Here are some guidelines around test isolation:
+ * Favour duplication in test setup over abstracting a new `testscript` command
+ * Favour a `testscript` owning an entire resource lifecycle over shared resource until we see a performance or rate limiting issue
+ * Use the `RANDOM_STRING` env var for globally visible resources to avoid conflicts
+
+### Debris
+
+Since these scripts are creating resources on a GitHub instance, we should try our best to cleanup after them. Use the `defer` keyword to ensure a command runs at the end of a test even in the case of failure.
+
+#### Scope Validation
+
+TODO: I believe tests should early exit if the correct scopes aren't in place to execute the entire lifecycle. It's extremely annoying if a `defer` fails to clean up resources because there's no `delete_repo` scope for example. However, I'm not sure yet whether this scope checking should be in the Go tests or in the scripts themselves. It seems very cool to understand required scopes for a script just by looking at the script itself.
+
+### Further Reading
+
+https://bitfieldconsulting.com/posts/test-scripts
+
+https://atlasgo.io/blog/2024/09/09/how-go-tests-go-test
+
+https://encore.dev/blog/testscript-hidden-testing-gem
diff --git a/cmd/gh/main.go b/cmd/gh/main.go
index 0b9af0215..e167bc6f4 100644
--- a/cmd/gh/main.go
+++ b/cmd/gh/main.go
@@ -1,276 +1,12 @@
 package main
 
 import (
-	"context"
-	"errors"
-	"fmt"
-	"io"
-	"net"
 	"os"
-	"os/exec"
-	"path/filepath"
-	"strings"
-	"time"
 
-	surveyCore "github.com/AlecAivazis/survey/v2/core"
-	"github.com/AlecAivazis/survey/v2/terminal"
-	"github.com/cli/cli/v2/api"
-	"github.com/cli/cli/v2/internal/build"
-	"github.com/cli/cli/v2/internal/config"
-	"github.com/cli/cli/v2/internal/config/migration"
-	"github.com/cli/cli/v2/internal/update"
-	"github.com/cli/cli/v2/pkg/cmd/factory"
-	"github.com/cli/cli/v2/pkg/cmd/root"
-	"github.com/cli/cli/v2/pkg/cmdutil"
-	"github.com/cli/cli/v2/pkg/iostreams"
-	"github.com/cli/cli/v2/utils"
-	"github.com/cli/safeexec"
-	"github.com/mattn/go-isatty"
-	"github.com/mgutz/ansi"
-	"github.com/spf13/cobra"
-)
-
-var updaterEnabled = ""
-
-type exitCode int
-
-const (
-	exitOK      exitCode = 0
-	exitError   exitCode = 1
-	exitCancel  exitCode = 2
-	exitAuth    exitCode = 4
-	exitPending exitCode = 8
+	"github.com/cli/cli/v2/internal/ghcmd"
 )
 
 func main() {
-	code := mainRun()
+	code := ghcmd.Main()
 	os.Exit(int(code))
 }
-
-func mainRun() exitCode {
-	buildDate := build.Date
-	buildVersion := build.Version
-	hasDebug, _ := utils.IsDebugEnabled()
-
-	cmdFactory := factory.New(buildVersion)
-	stderr := cmdFactory.IOStreams.ErrOut
-
-	ctx := context.Background()
-
-	if cfg, err := cmdFactory.Config(); err == nil {
-		var m migration.MultiAccount
-		if err := cfg.Migrate(m); err != nil {
-			fmt.Fprintln(stderr, err)
-			return exitError
-		}
-	}
-
-	updateCtx, updateCancel := context.WithCancel(ctx)
-	defer updateCancel()
-	updateMessageChan := make(chan *update.ReleaseInfo)
-	go func() {
-		rel, err := checkForUpdate(updateCtx, cmdFactory, buildVersion)
-		if err != nil && hasDebug {
-			fmt.Fprintf(stderr, "warning: checking for update failed: %v", err)
-		}
-		updateMessageChan <- rel
-	}()
-
-	if !cmdFactory.IOStreams.ColorEnabled() {
-		surveyCore.DisableColor = true
-		ansi.DisableColors(true)
-	} else {
-		// override survey's poor choice of color
-		surveyCore.TemplateFuncsWithColor["color"] = func(style string) string {
-			switch style {
-			case "white":
-				return ansi.ColorCode("default")
-			default:
-				return ansi.ColorCode(style)
-			}
-		}
-	}
-
-	// Enable running gh from Windows File Explorer's address bar. Without this, the user is told to stop and run from a
-	// terminal. With this, a user can clone a repo (or take other actions) directly from explorer.
-	if len(os.Args) > 1 && os.Args[1] != "" {
-		cobra.MousetrapHelpText = ""
-	}
-
-	rootCmd, err := root.NewCmdRoot(cmdFactory, buildVersion, buildDate)
-	if err != nil {
-		fmt.Fprintf(stderr, "failed to create root command: %s\n", err)
-		return exitError
-	}
-
-	expandedArgs := []string{}
-	if len(os.Args) > 0 {
-		expandedArgs = os.Args[1:]
-	}
-
-	// translate `gh help <command>` to `gh <command> --help` for extensions.
-	if len(expandedArgs) >= 2 && expandedArgs[0] == "help" && isExtensionCommand(rootCmd, expandedArgs[1:]) {
-		expandedArgs = expandedArgs[1:]
-		expandedArgs = append(expandedArgs, "--help")
-	}
-
-	rootCmd.SetArgs(expandedArgs)
-
-	if cmd, err := rootCmd.ExecuteContextC(ctx); err != nil {
-		var pagerPipeError *iostreams.ErrClosedPagerPipe
-		var noResultsError cmdutil.NoResultsError
-		var extError *root.ExternalCommandExitError
-		var authError *root.AuthError
-		if err == cmdutil.SilentError {
-			return exitError
-		} else if err == cmdutil.PendingError {
-			return exitPending
-		} else if cmdutil.IsUserCancellation(err) {
-			if errors.Is(err, terminal.InterruptErr) {
-				// ensure the next shell prompt will start on its own line
-				fmt.Fprint(stderr, "\n")
-			}
-			return exitCancel
-		} else if errors.As(err, &authError) {
-			return exitAuth
-		} else if errors.As(err, &pagerPipeError) {
-			// ignore the error raised when piping to a closed pager
-			return exitOK
-		} else if errors.As(err, &noResultsError) {
-			if cmdFactory.IOStreams.IsStdoutTTY() {
-				fmt.Fprintln(stderr, noResultsError.Error())
-			}
-			// no results is not a command failure
-			return exitOK
-		} else if errors.As(err, &extError) {
-			// pass on exit codes from extensions and shell aliases
-			return exitCode(extError.ExitCode())
-		}
-
-		printError(stderr, err, cmd, hasDebug)
-
-		if strings.Contains(err.Error(), "Incorrect function") {
-			fmt.Fprintln(stderr, "You appear to be running in MinTTY without pseudo terminal support.")
-			fmt.Fprintln(stderr, "To learn about workarounds for this error, run:  gh help mintty")
-			return exitError
-		}
-
-		var httpErr api.HTTPError
-		if errors.As(err, &httpErr) && httpErr.StatusCode == 401 {
-			fmt.Fprintln(stderr, "Try authenticating with:  gh auth login")
-		} else if u := factory.SSOURL(); u != "" {
-			// handles organization SAML enforcement error
-			fmt.Fprintf(stderr, "Authorize in your web browser:  %s\n", u)
-		} else if msg := httpErr.ScopesSuggestion(); msg != "" {
-			fmt.Fprintln(stderr, msg)
-		}
-
-		return exitError
-	}
-	if root.HasFailed() {
-		return exitError
-	}
-
-	updateCancel() // if the update checker hasn't completed by now, abort it
-	newRelease := <-updateMessageChan
-	if newRelease != nil {
-		isHomebrew := isUnderHomebrew(cmdFactory.Executable())
-		if isHomebrew && isRecentRelease(newRelease.PublishedAt) {
-			// do not notify Homebrew users before the version bump had a chance to get merged into homebrew-core
-			return exitOK
-		}
-		fmt.Fprintf(stderr, "\n\n%s %s → %s\n",
-			ansi.Color("A new release of gh is available:", "yellow"),
-			ansi.Color(strings.TrimPrefix(buildVersion, "v"), "cyan"),
-			ansi.Color(strings.TrimPrefix(newRelease.Version, "v"), "cyan"))
-		if isHomebrew {
-			fmt.Fprintf(stderr, "To upgrade, run: %s\n", "brew upgrade gh")
-		}
-		fmt.Fprintf(stderr, "%s\n\n",
-			ansi.Color(newRelease.URL, "yellow"))
-	}
-
-	return exitOK
-}
-
-// isExtensionCommand returns true if args resolve to an extension command.
-func isExtensionCommand(rootCmd *cobra.Command, args []string) bool {
-	c, _, err := rootCmd.Find(args)
-	return err == nil && c != nil && c.GroupID == "extension"
-}
-
-func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) {
-	var dnsError *net.DNSError
-	if errors.As(err, &dnsError) {
-		fmt.Fprintf(out, "error connecting to %s\n", dnsError.Name)
-		if debug {
-			fmt.Fprintln(out, dnsError)
-		}
-		fmt.Fprintln(out, "check your internet connection or https://githubstatus.com")
-		return
-	}
-
-	fmt.Fprintln(out, err)
-
-	var flagError *cmdutil.FlagError
-	if errors.As(err, &flagError) || strings.HasPrefix(err.Error(), "unknown command ") {
-		if !strings.HasSuffix(err.Error(), "\n") {
-			fmt.Fprintln(out)
-		}
-		fmt.Fprintln(out, cmd.UsageString())
-	}
-}
-
-func shouldCheckForUpdate() bool {
-	if os.Getenv("GH_NO_UPDATE_NOTIFIER") != "" {
-		return false
-	}
-	if os.Getenv("CODESPACES") != "" {
-		return false
-	}
-	return updaterEnabled != "" && !isCI() && isTerminal(os.Stdout) && isTerminal(os.Stderr)
-}
-
-func isTerminal(f *os.File) bool {
-	return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
-}
-
-// based on https://github.com/watson/ci-info/blob/HEAD/index.js
-func isCI() bool {
-	return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari
-		os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity
-		os.Getenv("RUN_ID") != "" // TaskCluster, dsari
-}
-
-func checkForUpdate(ctx context.Context, f *cmdutil.Factory, currentVersion string) (*update.ReleaseInfo, error) {
-	if !shouldCheckForUpdate() {
-		return nil, nil
-	}
-	httpClient, err := f.HttpClient()
-	if err != nil {
-		return nil, err
-	}
-	repo := updaterEnabled
-	stateFilePath := filepath.Join(config.StateDir(), "state.yml")
-	return update.CheckForUpdate(ctx, httpClient, stateFilePath, repo, currentVersion)
-}
-
-func isRecentRelease(publishedAt time.Time) bool {
-	return !publishedAt.IsZero() && time.Since(publishedAt) < time.Hour*24
-}
-
-// Check whether the gh binary was found under the Homebrew prefix
-func isUnderHomebrew(ghBinary string) bool {
-	brewExe, err := safeexec.LookPath("brew")
-	if err != nil {
-		return false
-	}
-
-	brewPrefixBytes, err := exec.Command(brewExe, "--prefix").Output()
-	if err != nil {
-		return false
-	}
-
-	brewBinPrefix := filepath.Join(strings.TrimSpace(string(brewPrefixBytes)), "bin") + string(filepath.Separator)
-	return strings.HasPrefix(ghBinary, brewBinPrefix)
-}
diff --git a/go.mod b/go.mod
index e638459fa..42e3c84e6 100644
--- a/go.mod
+++ b/go.mod
@@ -125,6 +125,7 @@ require (
 	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
 	github.com/rodaine/table v1.0.1 // indirect
+	github.com/rogpeppe/go-internal v1.13.1
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	github.com/sagikazarmark/locafero v0.4.0 // indirect
 	github.com/sagikazarmark/slog-shim v0.1.0 // indirect
@@ -160,6 +161,7 @@ require (
 	golang.org/x/mod v0.20.0 // indirect
 	golang.org/x/net v0.27.0 // indirect
 	golang.org/x/sys v0.26.0 // indirect
+	golang.org/x/tools v0.22.0 // indirect
 	google.golang.org/genproto/googleapis/api v0.0.0-20240520151616-dc85e6b867a5 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20240520151616-dc85e6b867a5 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
diff --git a/go.sum b/go.sum
index d1a85b60e..9a1fc23ee 100644
--- a/go.sum
+++ b/go.sum
@@ -366,8 +366,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/rodaine/table v1.0.1 h1:U/VwCnUxlVYxw8+NJiLIuCxA/xa6jL38MY3FYysVWWQ=
 github.com/rodaine/table v1.0.1/go.mod h1:UVEtfBsflpeEcD56nF4F5AocNFta0ZuolpSVdPtlmP4=
-github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
-github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
+github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
+github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
@@ -533,8 +533,8 @@ golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
-golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
+golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/api v0.172.0 h1:/1OcMZGPmW1rX2LCu2CmGUD1KXK1+pfzxotxyRUCCdk=
 google.golang.org/api v0.172.0/go.mod h1:+fJZq6QXWfa9pXhnIzsjx4yI22d4aI9ZpLb58gvXjis=
diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go
new file mode 100644
index 000000000..d5a674184
--- /dev/null
+++ b/internal/ghcmd/cmd.go
@@ -0,0 +1,271 @@
+package ghcmd
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"time"
+
+	surveyCore "github.com/AlecAivazis/survey/v2/core"
+	"github.com/AlecAivazis/survey/v2/terminal"
+	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/internal/build"
+	"github.com/cli/cli/v2/internal/config"
+	"github.com/cli/cli/v2/internal/config/migration"
+	"github.com/cli/cli/v2/internal/update"
+	"github.com/cli/cli/v2/pkg/cmd/factory"
+	"github.com/cli/cli/v2/pkg/cmd/root"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	"github.com/cli/cli/v2/utils"
+	"github.com/cli/safeexec"
+	"github.com/mattn/go-isatty"
+	"github.com/mgutz/ansi"
+	"github.com/spf13/cobra"
+)
+
+var updaterEnabled = ""
+
+type exitCode int
+
+const (
+	exitOK      exitCode = 0
+	exitError   exitCode = 1
+	exitCancel  exitCode = 2
+	exitAuth    exitCode = 4
+	exitPending exitCode = 8
+)
+
+func Main() exitCode {
+	buildDate := build.Date
+	buildVersion := build.Version
+	hasDebug, _ := utils.IsDebugEnabled()
+
+	cmdFactory := factory.New(buildVersion)
+	stderr := cmdFactory.IOStreams.ErrOut
+
+	ctx := context.Background()
+
+	if cfg, err := cmdFactory.Config(); err == nil {
+		var m migration.MultiAccount
+		if err := cfg.Migrate(m); err != nil {
+			fmt.Fprintln(stderr, err)
+			return exitError
+		}
+	}
+
+	updateCtx, updateCancel := context.WithCancel(ctx)
+	defer updateCancel()
+	updateMessageChan := make(chan *update.ReleaseInfo)
+	go func() {
+		rel, err := checkForUpdate(updateCtx, cmdFactory, buildVersion)
+		if err != nil && hasDebug {
+			fmt.Fprintf(stderr, "warning: checking for update failed: %v", err)
+		}
+		updateMessageChan <- rel
+	}()
+
+	if !cmdFactory.IOStreams.ColorEnabled() {
+		surveyCore.DisableColor = true
+		ansi.DisableColors(true)
+	} else {
+		// override survey's poor choice of color
+		surveyCore.TemplateFuncsWithColor["color"] = func(style string) string {
+			switch style {
+			case "white":
+				return ansi.ColorCode("default")
+			default:
+				return ansi.ColorCode(style)
+			}
+		}
+	}
+
+	// Enable running gh from Windows File Explorer's address bar. Without this, the user is told to stop and run from a
+	// terminal. With this, a user can clone a repo (or take other actions) directly from explorer.
+	if len(os.Args) > 1 && os.Args[1] != "" {
+		cobra.MousetrapHelpText = ""
+	}
+
+	rootCmd, err := root.NewCmdRoot(cmdFactory, buildVersion, buildDate)
+	if err != nil {
+		fmt.Fprintf(stderr, "failed to create root command: %s\n", err)
+		return exitError
+	}
+
+	expandedArgs := []string{}
+	if len(os.Args) > 0 {
+		expandedArgs = os.Args[1:]
+	}
+
+	// translate `gh help <command>` to `gh <command> --help` for extensions.
+	if len(expandedArgs) >= 2 && expandedArgs[0] == "help" && isExtensionCommand(rootCmd, expandedArgs[1:]) {
+		expandedArgs = expandedArgs[1:]
+		expandedArgs = append(expandedArgs, "--help")
+	}
+
+	rootCmd.SetArgs(expandedArgs)
+
+	if cmd, err := rootCmd.ExecuteContextC(ctx); err != nil {
+		var pagerPipeError *iostreams.ErrClosedPagerPipe
+		var noResultsError cmdutil.NoResultsError
+		var extError *root.ExternalCommandExitError
+		var authError *root.AuthError
+		if err == cmdutil.SilentError {
+			return exitError
+		} else if err == cmdutil.PendingError {
+			return exitPending
+		} else if cmdutil.IsUserCancellation(err) {
+			if errors.Is(err, terminal.InterruptErr) {
+				// ensure the next shell prompt will start on its own line
+				fmt.Fprint(stderr, "\n")
+			}
+			return exitCancel
+		} else if errors.As(err, &authError) {
+			return exitAuth
+		} else if errors.As(err, &pagerPipeError) {
+			// ignore the error raised when piping to a closed pager
+			return exitOK
+		} else if errors.As(err, &noResultsError) {
+			if cmdFactory.IOStreams.IsStdoutTTY() {
+				fmt.Fprintln(stderr, noResultsError.Error())
+			}
+			// no results is not a command failure
+			return exitOK
+		} else if errors.As(err, &extError) {
+			// pass on exit codes from extensions and shell aliases
+			return exitCode(extError.ExitCode())
+		}
+
+		printError(stderr, err, cmd, hasDebug)
+
+		if strings.Contains(err.Error(), "Incorrect function") {
+			fmt.Fprintln(stderr, "You appear to be running in MinTTY without pseudo terminal support.")
+			fmt.Fprintln(stderr, "To learn about workarounds for this error, run:  gh help mintty")
+			return exitError
+		}
+
+		var httpErr api.HTTPError
+		if errors.As(err, &httpErr) && httpErr.StatusCode == 401 {
+			fmt.Fprintln(stderr, "Try authenticating with:  gh auth login")
+		} else if u := factory.SSOURL(); u != "" {
+			// handles organization SAML enforcement error
+			fmt.Fprintf(stderr, "Authorize in your web browser:  %s\n", u)
+		} else if msg := httpErr.ScopesSuggestion(); msg != "" {
+			fmt.Fprintln(stderr, msg)
+		}
+
+		return exitError
+	}
+	if root.HasFailed() {
+		return exitError
+	}
+
+	updateCancel() // if the update checker hasn't completed by now, abort it
+	newRelease := <-updateMessageChan
+	if newRelease != nil {
+		isHomebrew := isUnderHomebrew(cmdFactory.Executable())
+		if isHomebrew && isRecentRelease(newRelease.PublishedAt) {
+			// do not notify Homebrew users before the version bump had a chance to get merged into homebrew-core
+			return exitOK
+		}
+		fmt.Fprintf(stderr, "\n\n%s %s → %s\n",
+			ansi.Color("A new release of gh is available:", "yellow"),
+			ansi.Color(strings.TrimPrefix(buildVersion, "v"), "cyan"),
+			ansi.Color(strings.TrimPrefix(newRelease.Version, "v"), "cyan"))
+		if isHomebrew {
+			fmt.Fprintf(stderr, "To upgrade, run: %s\n", "brew upgrade gh")
+		}
+		fmt.Fprintf(stderr, "%s\n\n",
+			ansi.Color(newRelease.URL, "yellow"))
+	}
+
+	return exitOK
+}
+
+// isExtensionCommand returns true if args resolve to an extension command.
+func isExtensionCommand(rootCmd *cobra.Command, args []string) bool {
+	c, _, err := rootCmd.Find(args)
+	return err == nil && c != nil && c.GroupID == "extension"
+}
+
+func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) {
+	var dnsError *net.DNSError
+	if errors.As(err, &dnsError) {
+		fmt.Fprintf(out, "error connecting to %s\n", dnsError.Name)
+		if debug {
+			fmt.Fprintln(out, dnsError)
+		}
+		fmt.Fprintln(out, "check your internet connection or https://githubstatus.com")
+		return
+	}
+
+	fmt.Fprintln(out, err)
+
+	var flagError *cmdutil.FlagError
+	if errors.As(err, &flagError) || strings.HasPrefix(err.Error(), "unknown command ") {
+		if !strings.HasSuffix(err.Error(), "\n") {
+			fmt.Fprintln(out)
+		}
+		fmt.Fprintln(out, cmd.UsageString())
+	}
+}
+
+func shouldCheckForUpdate() bool {
+	if os.Getenv("GH_NO_UPDATE_NOTIFIER") != "" {
+		return false
+	}
+	if os.Getenv("CODESPACES") != "" {
+		return false
+	}
+	return updaterEnabled != "" && !isCI() && isTerminal(os.Stdout) && isTerminal(os.Stderr)
+}
+
+func isTerminal(f *os.File) bool {
+	return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
+}
+
+// based on https://github.com/watson/ci-info/blob/HEAD/index.js
+func isCI() bool {
+	return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari
+		os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity
+		os.Getenv("RUN_ID") != "" // TaskCluster, dsari
+}
+
+func checkForUpdate(ctx context.Context, f *cmdutil.Factory, currentVersion string) (*update.ReleaseInfo, error) {
+	if !shouldCheckForUpdate() {
+		return nil, nil
+	}
+	httpClient, err := f.HttpClient()
+	if err != nil {
+		return nil, err
+	}
+	repo := updaterEnabled
+	stateFilePath := filepath.Join(config.StateDir(), "state.yml")
+	return update.CheckForUpdate(ctx, httpClient, stateFilePath, repo, currentVersion)
+}
+
+func isRecentRelease(publishedAt time.Time) bool {
+	return !publishedAt.IsZero() && time.Since(publishedAt) < time.Hour*24
+}
+
+// Check whether the gh binary was found under the Homebrew prefix
+func isUnderHomebrew(ghBinary string) bool {
+	brewExe, err := safeexec.LookPath("brew")
+	if err != nil {
+		return false
+	}
+
+	brewPrefixBytes, err := exec.Command(brewExe, "--prefix").Output()
+	if err != nil {
+		return false
+	}
+
+	brewBinPrefix := filepath.Join(strings.TrimSpace(string(brewPrefixBytes)), "bin") + string(filepath.Separator)
+	return strings.HasPrefix(ghBinary, brewBinPrefix)
+}


Test patch:
diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go
new file mode 100644
index 000000000..bcc27faca
--- /dev/null
+++ b/acceptance/acceptance_test.go
@@ -0,0 +1,171 @@
+//go:build acceptance
+
+package acceptance_test
+
+import (
+	"fmt"
+	"os"
+	"path"
+	"strings"
+	"testing"
+
+	"math/rand"
+
+	"github.com/cli/cli/v2/internal/ghcmd"
+	"github.com/rogpeppe/go-internal/testscript"
+)
+
+func ghMain() int {
+	return int(ghcmd.Main())
+}
+
+func TestMain(m *testing.M) {
+	os.Exit(testscript.RunMain(m, map[string]func() int{
+		"gh": ghMain,
+	}))
+}
+
+func TestPullRequests(t *testing.T) {
+	var tsEnv testScriptEnv
+	if err := tsEnv.fromEnv(); err != nil {
+		fmt.Fprintln(os.Stderr, err)
+		os.Exit(1)
+	}
+
+	testscript.Run(t, testScriptParamsFor(tsEnv, "pr"))
+}
+
+func testScriptParamsFor(tsEnv testScriptEnv, dir string) testscript.Params {
+	return testscript.Params{
+		Dir:                 path.Join("testdata", dir),
+		Files:               []string{},
+		Setup:               sharedSetup(tsEnv),
+		Cmds:                sharedCmds(tsEnv),
+		RequireExplicitExec: true,
+		RequireUniqueNames:  true,
+		TestWork:            tsEnv.preserveWorkDir,
+	}
+}
+
+func sharedSetup(tsEnv testScriptEnv) func(ts *testscript.Env) error {
+	return func(ts *testscript.Env) error {
+		scriptName, ok := extractScriptName(ts.Vars)
+		if !ok {
+			ts.T().Fatal("script name not found")
+		}
+		ts.Setenv("SCRIPT_NAME", scriptName)
+		ts.Setenv("HOME", ts.Cd)
+		ts.Setenv("GH_CONFIG_DIR", ts.Cd)
+
+		ts.Setenv("GH_HOST", tsEnv.host)
+		ts.Setenv("ORG", tsEnv.org)
+		ts.Setenv("GH_TOKEN", tsEnv.token)
+
+		ts.Setenv("RANDOM_STRING", randomString(10))
+		return nil
+	}
+}
+
+// sharedCmds defines a collection of custom testscript commands for our use.
+func sharedCmds(tsEnv testScriptEnv) map[string]func(ts *testscript.TestScript, neg bool, args []string) {
+	return map[string]func(ts *testscript.TestScript, neg bool, args []string){
+		"defer": func(ts *testscript.TestScript, neg bool, args []string) {
+			if neg {
+				ts.Fatalf("unsupported: ! defer")
+			}
+
+			if tsEnv.skipDefer {
+				return
+			}
+
+			ts.Defer(func() {
+				ts.Check(ts.Exec(args[0], args[1:]...))
+			})
+		},
+		"stdout2env": func(ts *testscript.TestScript, neg bool, args []string) {
+			if neg {
+				ts.Fatalf("unsupported: ! stdout2env")
+			}
+			if len(args) != 1 {
+				ts.Fatalf("usage: stdout2env name")
+			}
+
+			ts.Setenv(args[0], strings.TrimRight(ts.ReadFile("stdout"), "\n"))
+		},
+	}
+}
+
+var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
+
+func randomString(n int) string {
+	b := make([]rune, n)
+	for i := range b {
+		b[i] = letters[rand.Intn(len(letters))]
+	}
+	return string(b)
+}
+
+func extractScriptName(vars []string) (string, bool) {
+	for _, kv := range vars {
+		if strings.HasPrefix(kv, "WORK=") {
+			v := strings.Split(kv, "=")[1]
+			return strings.CutPrefix(path.Base(v), "script-")
+		}
+	}
+	return "", false
+}
+
+type missingEnvError struct {
+	missingEnvs []string
+}
+
+func (e missingEnvError) Error() string {
+	return fmt.Sprintf("environment variables %s must be set and non-empty", strings.Join(e.missingEnvs, ", "))
+}
+
+type testScriptEnv struct {
+	host  string
+	org   string
+	token string
+
+	skipDefer       bool
+	preserveWorkDir bool
+}
+
+func (e *testScriptEnv) fromEnv() error {
+	envMap := map[string]string{}
+
+	requiredEnvVars := []string{
+		"GH_ACCEPTANCE_HOST",
+		"GH_ACCEPTANCE_ORG",
+		"GH_ACCEPTANCE_TOKEN",
+	}
+
+	var missingEnvs []string
+	for _, key := range requiredEnvVars {
+		val, ok := os.LookupEnv(key)
+		if val == "" || !ok {
+			missingEnvs = append(missingEnvs, key)
+			continue
+		}
+
+		envMap[key] = val
+	}
+
+	if len(missingEnvs) > 0 {
+		return missingEnvError{missingEnvs: missingEnvs}
+	}
+
+	if envMap["GH_ACCEPTANCE_ORG"] == "github" || envMap["GH_ACCEPTANCE_ORG"] == "cli" {
+		return fmt.Errorf("GH_ACCEPTANCE_ORG cannot be 'github' or 'cli'")
+	}
+
+	e.host = envMap["GH_ACCEPTANCE_HOST"]
+	e.org = envMap["GH_ACCEPTANCE_ORG"]
+	e.token = envMap["GH_ACCEPTANCE_TOKEN"]
+
+	e.preserveWorkDir = os.Getenv("GH_ACCEPTANCE_PRESERVE_WORK_DIR") == "true"
+	e.skipDefer = os.Getenv("GH_ACCEPTANCE_SKIP_DEFER") == "true"
+
+	return nil
+}
diff --git a/acceptance/testdata/pr/pr-checkout.txtar b/acceptance/testdata/pr/pr-checkout.txtar
new file mode 100644
index 000000000..4cfe96c1a
--- /dev/null
+++ b/acceptance/testdata/pr/pr-checkout.txtar
@@ -0,0 +1,30 @@
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Create a repository with a file so it has a default branch
+exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Clone the repo
+exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Prepare a branch to PR
+cd $SCRIPT_NAME-$RANDOM_STRING
+exec git checkout -b feature-branch
+exec git commit --allow-empty -m 'Empty Commit'
+exec git push -u origin feature-branch
+
+# Create the PR
+exec gh pr create --title 'Feature Title' --body 'Feature Body'
+stdout2env PR_URL
+
+# Remove the local branch
+exec git checkout main
+exec git branch -D feature-branch
+stdout 'Deleted branch feature-branch'
+
+# Checkout the PR
+exec gh pr checkout $PR_URL
+stderr 'Switched to a new branch ''feature-branch'''
diff --git a/acceptance/testdata/pr/pr-comment.txtar b/acceptance/testdata/pr/pr-comment.txtar
new file mode 100644
index 000000000..2c4d488b5
--- /dev/null
+++ b/acceptance/testdata/pr/pr-comment.txtar
@@ -0,0 +1,28 @@
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Create a repository with a file so it has a default branch
+exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Clone the repo
+exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Prepare a branch to PR
+cd $SCRIPT_NAME-$RANDOM_STRING
+exec git checkout -b feature-branch
+exec git commit --allow-empty -m 'Empty Commit'
+exec git push -u origin feature-branch
+
+# Create the PR
+exec gh pr create --title 'Feature Title' --body 'Feature Body'
+stdout2env PR_URL
+
+# Comment on the PR
+exec gh pr comment $PR_URL --body 'Looks like a great feature!'
+
+# View the PR
+exec gh pr view $PR_URL --comments
+stdout 'Looks like a great feature!'
diff --git a/acceptance/testdata/pr/pr-create-basic.txtar b/acceptance/testdata/pr/pr-create-basic.txtar
new file mode 100644
index 000000000..98bb2faa9
--- /dev/null
+++ b/acceptance/testdata/pr/pr-create-basic.txtar
@@ -0,0 +1,24 @@
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Create a repository with a file so it has a default branch
+exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Clone the repo
+exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Prepare a branch to PR
+cd $SCRIPT_NAME-$RANDOM_STRING
+exec git checkout -b feature-branch
+exec git commit --allow-empty -m 'Empty Commit'
+exec git push -u origin feature-branch
+
+# Create the PR
+exec gh pr create --title 'Feature Title' --body 'Feature Body'
+
+# Check the PR is indeed created
+exec gh pr view
+stdout 'Feature Title'
diff --git a/acceptance/testdata/pr/pr-create-with-metadata.txtar b/acceptance/testdata/pr/pr-create-with-metadata.txtar
new file mode 100644
index 000000000..3e06b533b
--- /dev/null
+++ b/acceptance/testdata/pr/pr-create-with-metadata.txtar
@@ -0,0 +1,26 @@
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Create a repository with a file so it has a default branch
+exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Clone the repo
+exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Prepare a branch to PR
+cd $SCRIPT_NAME-$RANDOM_STRING
+exec git checkout -b feature-branch
+exec git commit --allow-empty -m 'Empty Commit'
+exec git push -u origin feature-branch
+
+# Create the PR
+exec gh pr create --title 'Feature Title' --body 'Feature Body' --assignee '@me' --label 'bug'
+stdout2env PR_URL
+
+# Check the PR is indeed created
+exec gh pr view $PR_URL
+stdout 'assignees:\t.+$'
+stdout 'labels:\tbug$'
diff --git a/acceptance/testdata/pr/pr-list.txtar b/acceptance/testdata/pr/pr-list.txtar
new file mode 100644
index 000000000..6fcd8e6b7
--- /dev/null
+++ b/acceptance/testdata/pr/pr-list.txtar
@@ -0,0 +1,24 @@
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Create a repository with a file so it has a default branch
+exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Clone the repo
+exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Prepare a branch to PR
+cd $SCRIPT_NAME-$RANDOM_STRING
+exec git checkout -b feature-branch
+exec git commit --allow-empty -m 'Empty Commit'
+exec git push -u origin feature-branch
+
+# Create the PR
+exec gh pr create --title 'Feature Title' --body 'Feature Body'
+
+# List PRs and see the new PR is in the list
+exec gh pr list
+stdout 'Feature Title\tfeature-branch\tOPEN'
diff --git a/acceptance/testdata/pr/pr-merge-merge-strategy.txtar b/acceptance/testdata/pr/pr-merge-merge-strategy.txtar
new file mode 100644
index 000000000..1d8355506
--- /dev/null
+++ b/acceptance/testdata/pr/pr-merge-merge-strategy.txtar
@@ -0,0 +1,45 @@
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Create a repository with a file so it has a default branch
+exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Clone the repo
+exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Prepare a branch to PR with a single file
+cd $SCRIPT_NAME-$RANDOM_STRING
+exec git checkout -b feature-branch
+mv ../file.txt file.txt
+exec git add .
+exec git commit -m 'Add file.txt'
+exec git push -u origin feature-branch
+
+# Create the PR
+exec gh pr create --title 'Feature Title' --body 'Feature Body'
+stdout2env PR_URL
+
+# Check that the file doesn't exist on the main branch
+exec git checkout main
+! exists file.txt
+
+# Merge the PR
+exec gh pr merge $PR_URL --merge
+
+# Check that the state of the PR is now merged
+exec gh pr view $PR_URL
+stdout 'state:\tMERGED$'
+
+# Pull and check the file exists on the main branch
+exec git pull -r
+exists file.txt
+
+# And check we had a merge commit
+exec git show HEAD
+stdout 'Merge pull request #1'
+
+-- file.txt --
+Unimportant contents
diff --git a/acceptance/testdata/pr/pr-merge-rebase-strategy.txtar b/acceptance/testdata/pr/pr-merge-rebase-strategy.txtar
new file mode 100644
index 000000000..f26338c4a
--- /dev/null
+++ b/acceptance/testdata/pr/pr-merge-rebase-strategy.txtar
@@ -0,0 +1,45 @@
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Create a repository with a file so it has a default branch
+exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Clone the repo
+exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Prepare a branch to PR with a single file
+cd $SCRIPT_NAME-$RANDOM_STRING
+exec git checkout -b feature-branch
+mv ../file.txt file.txt
+exec git add .
+exec git commit -m 'Add file.txt'
+exec git push -u origin feature-branch
+
+# Create the PR
+exec gh pr create --title 'Feature Title' --body 'Feature Body'
+stdout2env PR_URL
+
+# Check that the file doesn't exist on the main branch
+exec git checkout main
+! exists file.txt
+
+# Merge the PR
+exec gh pr merge $PR_URL --rebase
+
+# Check that the state of the PR is now merged
+exec gh pr view $PR_URL
+stdout 'state:\tMERGED$'
+
+# Pull and check the file exists on the main branch
+exec git pull -r
+exists file.txt
+
+# And check our commit was rebased
+exec git show HEAD
+stdout 'Add file.txt'
+
+-- file.txt --
+Unimportant contents
diff --git a/acceptance/testdata/pr/pr-view.txtar b/acceptance/testdata/pr/pr-view.txtar
new file mode 100644
index 000000000..6166d15ad
--- /dev/null
+++ b/acceptance/testdata/pr/pr-view.txtar
@@ -0,0 +1,25 @@
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Create a repository with a file so it has a default branch
+exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Clone the repo
+exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Prepare a branch to PR
+cd $SCRIPT_NAME-$RANDOM_STRING
+exec git checkout -b feature-branch
+exec git commit --allow-empty -m 'Empty Commit'
+exec git push -u origin feature-branch
+
+# Create the PR
+exec gh pr create --title 'Feature Title' --body 'Feature Body'
+stdout2env PR_URL
+
+# View the PR
+exec gh pr view $PR_URL
+stdout 'Feature Title'
diff --git a/cmd/gh/main_test.go b/internal/ghcmd/cmd_test.go
similarity index 99%
rename from cmd/gh/main_test.go
rename to internal/ghcmd/cmd_test.go
index 01552b2bd..08bbceb85 100644
--- a/cmd/gh/main_test.go
+++ b/internal/ghcmd/cmd_test.go
@@ -1,4 +1,4 @@
-package main
+package ghcmd
 
 import (
 	"bytes"