Repository: cli/cli
Pull request #7650 (2023-07-01 00:02:21)

Add support for repository rulesets
[Repository rulesets](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets) are a new GitHub feature, essentially the evolution of branch protections. This PR adds support for them via a new command (`ruleset`/`rs`) with 3 subcommands.

- `rs list`: Lists rulesets for the provided repo or org
- `rs view <id>`: View info about a specific ruleset by ID, or choose interactively
- `rs check <branch>`: List the rules that apply to the given branch in a repo

<details>

<summary>Sample output</summary>

List:
```
Showing 2 of 2 rulesets in my-org/repo-name and its parents

ID    NAME            SOURCE                   STATUS  RULES
1234  My Org Ruleset  my-org (org)             active  3
5678  Test Ruleset    my-org/repo-name (repo)  active  3
```

View:
```
Test Ruleset
ID: 5678
Source: my-org/repo-name (Repository)
Enforceument: Active

Bypass List
- OrganizationAdmin (ID: 1), mode: always
- RepositoryRole (ID: 5), mode: always

Conditions
- ref_name: [exclude: []] [include: [~ALL]] 

Rules
- commit_author_email_pattern: [name: ] [negate: false] [operator: ends_with] [pattern: @example.com] 
- commit_message_pattern: [name: ] [negate: false] [operator: contains] [pattern: asdf] 
- creation
```

Check:
```
6 rules apply to branch foo in repo my-org/repo-name

- commit_author_email_pattern: [name: ] [negate: false] [operator: ends_with] [pattern: @example.com] 
  (configured in ruleset 1234 from organization my-org)

- commit_author_email_pattern: [name: ] [negate: false] [operator: ends_with] [pattern: @example.me] 
  (configured in ruleset 5678 from repository my-org/repo-name)

- commit_message_pattern: [name: ] [negate: false] [operator: starts_with] [pattern: fff] 
  (configured in ruleset 1234 from organization my-org)

- commit_message_pattern: [name: ] [negate: false] [operator: contains] [pattern: asdf] 
  (configured in ruleset 5678 from repository my-org/repo-name)

- creation
  (configured in ruleset 5678 from repository my-org/repo-name)

- required_signatures
  (configured in ruleset 1234 from organization my-org)
```

</details>

### Notes

- The list subcommand and interactive mode for the view subcommand both use GraphQL, but the others use the REST API. This is because each rule type within the GraphQL response is its own type, so if everything were to use the GraphQL API, then the CLI would have to be updated each time a new rule is added to rulesets. We don't want to tie the CLI logic to the API shape like that.
  - I could standardize both on the REST API if desired, but I already had the GraphQL logic written when I figured that out so I just left it.
- None of the commands support the `--json` flag for that same reason, as it seems to require using the GraphQL API and providing a list of supported fields, which would continue to tie them together. I'd love to add support if it's possible!


Patch:
diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go
index 2929ca720..6957ff7b6 100644
--- a/pkg/cmd/root/root.go
+++ b/pkg/cmd/root/root.go
@@ -31,6 +31,7 @@ import (
 	releaseCmd "github.com/cli/cli/v2/pkg/cmd/release"
 	repoCmd "github.com/cli/cli/v2/pkg/cmd/repo"
 	creditsCmd "github.com/cli/cli/v2/pkg/cmd/repo/credits"
+	rulesetCmd "github.com/cli/cli/v2/pkg/cmd/ruleset"
 	runCmd "github.com/cli/cli/v2/pkg/cmd/run"
 	searchCmd "github.com/cli/cli/v2/pkg/cmd/search"
 	secretCmd "github.com/cli/cli/v2/pkg/cmd/secret"
@@ -152,6 +153,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command,
 	cmd.AddCommand(issueCmd.NewCmdIssue(&repoResolvingCmdFactory))
 	cmd.AddCommand(releaseCmd.NewCmdRelease(&repoResolvingCmdFactory))
 	cmd.AddCommand(repoCmd.NewCmdRepo(&repoResolvingCmdFactory))
+	cmd.AddCommand(rulesetCmd.NewCmdRuleset(&repoResolvingCmdFactory))
 	cmd.AddCommand(runCmd.NewCmdRun(&repoResolvingCmdFactory))
 	cmd.AddCommand(workflowCmd.NewCmdWorkflow(&repoResolvingCmdFactory))
 	cmd.AddCommand(labelCmd.NewCmdLabel(&repoResolvingCmdFactory))
diff --git a/pkg/cmd/ruleset/check/check.go b/pkg/cmd/ruleset/check/check.go
new file mode 100644
index 000000000..3007fe36a
--- /dev/null
+++ b/pkg/cmd/ruleset/check/check.go
@@ -0,0 +1,163 @@
+package check
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/url"
+
+	"github.com/MakeNowJust/heredoc"
+	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/git"
+	"github.com/cli/cli/v2/internal/browser"
+	"github.com/cli/cli/v2/internal/config"
+	"github.com/cli/cli/v2/internal/ghrepo"
+	"github.com/cli/cli/v2/internal/text"
+	"github.com/cli/cli/v2/pkg/cmd/ruleset/shared"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	"github.com/spf13/cobra"
+)
+
+type CheckOptions struct {
+	HttpClient func() (*http.Client, error)
+	IO         *iostreams.IOStreams
+	Config     func() (config.Config, error)
+	BaseRepo   func() (ghrepo.Interface, error)
+	Git        *git.Client
+	Browser    browser.Browser
+
+	Branch  string
+	Default bool
+	WebMode bool
+}
+
+func NewCmdCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.Command {
+	opts := &CheckOptions{
+		IO:         f.IOStreams,
+		Config:     f.Config,
+		HttpClient: f.HttpClient,
+		Browser:    f.Browser,
+		Git:        f.GitClient,
+	}
+	cmd := &cobra.Command{
+		Use:   "check [<branch>]",
+		Short: "View rules that would apply to a given branch",
+		Long: heredoc.Doc(`
+			View information about GitHub rules that apply to a given branch.
+
+			The provided branch name does not need to exist; rules will be displayed that would apply
+			to a branch with that name. All rules are returned regardless of where they are configured.
+
+			If no branch name is provided, then the current branch will be used.
+
+			The --default flag can be used to view rules that apply to the default branch of the
+			repository.
+		`),
+		Example: heredoc.Doc(`
+			# View all rules that apply to the current branch
+			$ gh ruleset check
+
+			# View all rules that apply to a branch named "my-branch" in a different repository
+			$ gh ruleset check my-branch --repo owner/repo
+
+			# View all rules that apply to the default branch in a different repository
+			$ gh ruleset check --default --repo owner/repo
+
+			# View a ruleset configured in a different repository or any of its parents
+			$ gh ruleset view 23 --repo owner/repo
+
+			# View an organization-level ruleset
+			$ gh ruleset view 23 --org my-org
+		`),
+		Args: cobra.MaximumNArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			// support `-R, --repo` override
+			opts.BaseRepo = f.BaseRepo
+
+			if len(args) > 0 {
+				opts.Branch = args[0]
+			}
+
+			if err := cmdutil.MutuallyExclusive(
+				"specify only one of `--default` or a branch name",
+				opts.Branch != "",
+				opts.Default,
+			); err != nil {
+				return err
+			}
+
+			if runF != nil {
+				return runF(opts)
+			}
+
+			return checkRun(opts)
+		},
+	}
+
+	cmd.Flags().BoolVar(&opts.Default, "default", false, "Check rules on default branch")
+	cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the branch rules page in a web browser")
+
+	return cmd
+}
+
+func checkRun(opts *CheckOptions) error {
+	httpClient, err := opts.HttpClient()
+	if err != nil {
+		return err
+	}
+	client := api.NewClientFromHTTP(httpClient)
+
+	repoI, err := opts.BaseRepo()
+	if err != nil {
+		return fmt.Errorf("could not determine repo to use: %w", err)
+	}
+
+	git := opts.Git
+
+	if opts.Default {
+		repo, err := api.GitHubRepo(client, repoI)
+		if err != nil {
+			return fmt.Errorf("could not get repository information: %w", err)
+		}
+		opts.Branch = repo.DefaultBranchRef.Name
+	}
+
+	if opts.Branch == "" {
+		opts.Branch, err = git.CurrentBranch(context.Background())
+		if err != nil {
+			return fmt.Errorf("could not determine current branch: %w", err)
+		}
+	}
+
+	if opts.WebMode {
+		// the query string parameter may have % signs in it, so it must be carefully used with Printf functions
+		queryString := fmt.Sprintf("?ref=%s", url.QueryEscape("refs/heads/"+opts.Branch))
+		rawUrl := ghrepo.GenerateRepoURL(repoI, "rules")
+
+		if opts.IO.IsStdoutTTY() {
+			fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", text.DisplayURL(rawUrl))
+		}
+
+		return opts.Browser.Browse(rawUrl + queryString)
+	}
+
+	var rules []shared.RulesetRule
+
+	endpoint := fmt.Sprintf("repos/%s/%s/rules/branches/%s", repoI.RepoOwner(), repoI.RepoName(), url.PathEscape(opts.Branch))
+
+	if err = client.REST(repoI.RepoHost(), "GET", endpoint, nil, &rules); err != nil {
+		return fmt.Errorf("GET %s failed: %w", endpoint, err)
+	}
+
+	w := opts.IO.Out
+
+	fmt.Fprintf(w, "%d rules apply to branch %s in repo %s/%s\n", len(rules), opts.Branch, repoI.RepoOwner(), repoI.RepoName())
+
+	if len(rules) > 0 {
+		fmt.Fprint(w, "\n")
+		fmt.Fprint(w, shared.ParseRulesForDisplay(rules))
+	}
+
+	return nil
+}
diff --git a/pkg/cmd/ruleset/check/fixtures/rulesetCheck.json b/pkg/cmd/ruleset/check/fixtures/rulesetCheck.json
new file mode 100644
index 000000000..c02fddef4
--- /dev/null
+++ b/pkg/cmd/ruleset/check/fixtures/rulesetCheck.json
@@ -0,0 +1,62 @@
+[
+	{
+		"type": "commit_author_email_pattern",
+		"parameters": {
+			"name": "",
+			"negate": false,
+			"pattern": "@example.com",
+			"operator": "ends_with"
+		},
+		"ruleset_source_type": "Organization",
+		"ruleset_source": "my-org",
+		"ruleset_id": 1234
+	},
+	{
+		"type": "commit_message_pattern",
+		"parameters": {
+			"name": "",
+			"negate": false,
+			"pattern": "fff",
+			"operator": "starts_with"
+		},
+		"ruleset_source_type": "Organization",
+		"ruleset_source": "my-org",
+		"ruleset_id": 1234
+	},
+	{
+		"type": "required_signatures",
+		"ruleset_source_type": "Organization",
+		"ruleset_source": "my-org",
+		"ruleset_id": 1234
+	},
+	{
+		"type": "commit_message_pattern",
+		"parameters": {
+			"name": "",
+			"negate": false,
+			"pattern": "asdf",
+			"operator": "contains"
+		},
+		"ruleset_source_type": "Repository",
+		"ruleset_source": "my-org/repo-name",
+		"ruleset_id": 5678
+	},
+	{
+		"type": "commit_author_email_pattern",
+		"parameters": {
+			"name": "",
+			"negate": false,
+			"pattern": "@example.com",
+			"operator": "ends_with"
+		},
+		"ruleset_source_type": "Repository",
+		"ruleset_source": "my-org/repo-name",
+		"ruleset_id": 5678
+	},
+	{
+		"type": "creation",
+		"ruleset_source_type": "Repository",
+		"ruleset_source": "my-org/repo-name",
+		"ruleset_id": 5678
+	}
+]
diff --git a/pkg/cmd/ruleset/list/fixtures/rulesetList.json b/pkg/cmd/ruleset/list/fixtures/rulesetList.json
new file mode 100644
index 000000000..d7c631623
--- /dev/null
+++ b/pkg/cmd/ruleset/list/fixtures/rulesetList.json
@@ -0,0 +1,81 @@
+{
+  "data": {
+    "level": {
+      "rulesets": {
+        "totalCount": 3,
+        "nodes": [
+          {
+            "databaseId": 4,
+            "name": "test",
+            "target": "BRANCH",
+            "enforcement": "EVALUATE",
+            "source": {
+              "__typename": "Repository",
+              "owner": "OWNER/REPO"
+            },
+            "conditions": {
+              "refName": {
+                "include": [
+                  "~DEFAULT_BRANCH"
+                ],
+                "exclude": []
+              },
+              "repositoryName": null
+            },
+            "rules": {
+              "totalCount": 1
+            }
+          },
+          {
+            "databaseId": 42,
+            "name": "asdf",
+            "target": "BRANCH",
+            "enforcement": "ACTIVE",
+            "source": {
+              "__typename": "Repository",
+              "owner": "OWNER/REPO"
+            },
+            "conditions": {
+              "refName": {
+                "include": [
+                  "~DEFAULT_BRANCH"
+                ],
+                "exclude": []
+              },
+              "repositoryName": null
+            },
+            "rules": {
+              "totalCount": 2
+            }
+          },
+          {
+            "databaseId": 77,
+            "name": "foobar",
+            "target": "BRANCH",
+            "enforcement": "DISABLED",
+            "source": {
+              "__typename": "Organization",
+              "owner": "Org-Name"
+            },
+            "conditions": {
+              "refName": {
+                "include": [
+                  "~DEFAULT_BRANCH"
+                ],
+                "exclude": []
+              },
+              "repositoryName": null
+            },
+            "rules": {
+              "totalCount": 4
+            }
+          }
+        ],
+        "pageInfo": {
+          "hasNextPage": false,
+          "endCursor": "Y3Vyc29yOnYyOpHNA8E="
+        }
+      }
+    }
+  }
+}
diff --git a/pkg/cmd/ruleset/list/list.go b/pkg/cmd/ruleset/list/list.go
new file mode 100644
index 000000000..60afc69ab
--- /dev/null
+++ b/pkg/cmd/ruleset/list/list.go
@@ -0,0 +1,170 @@
+package list
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+	"strings"
+
+	"github.com/MakeNowJust/heredoc"
+	"github.com/cli/cli/v2/internal/browser"
+	"github.com/cli/cli/v2/internal/ghinstance"
+	"github.com/cli/cli/v2/internal/ghrepo"
+	"github.com/cli/cli/v2/internal/tableprinter"
+	"github.com/cli/cli/v2/internal/text"
+	"github.com/cli/cli/v2/pkg/cmd/ruleset/shared"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	ghAuth "github.com/cli/go-gh/v2/pkg/auth"
+	"github.com/spf13/cobra"
+)
+
+type ListOptions struct {
+	HttpClient func() (*http.Client, error)
+	IO         *iostreams.IOStreams
+	BaseRepo   func() (ghrepo.Interface, error)
+	Browser    browser.Browser
+
+	Limit          int
+	IncludeParents bool
+	WebMode        bool
+	Organization   string
+}
+
+func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
+	opts := &ListOptions{
+		IO:         f.IOStreams,
+		HttpClient: f.HttpClient,
+		Browser:    f.Browser,
+	}
+	cmd := &cobra.Command{
+		Use:   "list",
+		Short: "List rulesets for a repository or organization",
+		Long: heredoc.Doc(`
+			List GitHub rulesets for a repository or organization.
+
+			If no options are provided, the current repository's rulesets are listed. You can query a different
+			repository's rulesets by using the --repo flag. You can also use the --org flag to list rulesets
+			configured for the provided organization.
+
+			Use the --parents flag to control whether rulesets configured at higher levels that also apply to the provided
+			repository or organization should be returned. The default is true.
+			
+			Your access token must have the admin:org scope to use the --org flag, which can be granted by running "gh auth refresh -s admin:org".
+		`),
+		Example: heredoc.Doc(`
+			# List rulesets in the current repository
+			$ gh ruleset list
+
+			# List rulesets in a different repository, including those configured at higher levels
+			$ gh ruleset list --repo owner/repo --parents
+
+			# List rulesets in an organization
+			$ gh ruleset list --org org-name
+		`),
+		Args: cobra.ExactArgs(0),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && opts.Organization != "" {
+				return cmdutil.FlagErrorf("only one of --repo and --org may be specified")
+			}
+
+			// support `-R, --repo` override
+			opts.BaseRepo = f.BaseRepo
+
+			if opts.Limit < 1 {
+				return cmdutil.FlagErrorf("invalid limit: %v", opts.Limit)
+			}
+
+			if runF != nil {
+				return runF(opts)
+			}
+
+			return listRun(opts)
+		},
+	}
+
+	cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of rulesets to list")
+	cmd.Flags().StringVarP(&opts.Organization, "org", "o", "", "List organization-wide rulesets for the provided organization")
+	cmd.Flags().BoolVarP(&opts.IncludeParents, "parents", "p", true, "Whether to include rulesets configured at higher levels that also apply")
+	cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the list of rulesets in the web browser")
+
+	return cmd
+}
+
+func listRun(opts *ListOptions) error {
+	httpClient, err := opts.HttpClient()
+	if err != nil {
+		return err
+	}
+
+	repoI, err := opts.BaseRepo()
+	if err != nil {
+		return err
+	}
+
+	hostname, _ := ghAuth.DefaultHost()
+
+	if opts.WebMode {
+		var rulesetURL string
+		if opts.Organization != "" {
+			rulesetURL = fmt.Sprintf("%sorganizations/%s/settings/rules", ghinstance.HostPrefix(hostname), opts.Organization)
+		} else {
+			rulesetURL = ghrepo.GenerateRepoURL(repoI, "rules")
+		}
+
+		if opts.IO.IsStdoutTTY() {
+			fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", text.DisplayURL(rulesetURL))
+		}
+
+		return opts.Browser.Browse(rulesetURL)
+	}
+
+	var result *shared.RulesetList
+
+	if opts.Organization != "" {
+		result, err = shared.ListOrgRulesets(httpClient, opts.Organization, opts.Limit, hostname, opts.IncludeParents)
+	} else {
+		result, err = shared.ListRepoRulesets(httpClient, repoI, opts.Limit, opts.IncludeParents)
+	}
+
+	if err != nil {
+		return err
+	}
+
+	if result.TotalCount == 0 {
+		return shared.NoRulesetsFoundError(opts.Organization, repoI, opts.IncludeParents)
+	}
+
+	opts.IO.DetectTerminalTheme()
+	if err := opts.IO.StartPager(); err == nil {
+		defer opts.IO.StopPager()
+	} else {
+		fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err)
+	}
+
+	cs := opts.IO.ColorScheme()
+
+	if opts.IO.IsStdoutTTY() {
+		parentsMsg := ""
+		if opts.IncludeParents {
+			parentsMsg = " and its parents"
+		}
+
+		inMsg := fmt.Sprintf("%s%s", shared.EntityName(opts.Organization, repoI), parentsMsg)
+		fmt.Fprintf(opts.IO.Out, "\nShowing %d of %d rulesets in %s\n\n", len(result.Rulesets), result.TotalCount, inMsg)
+	}
+
+	tp := tableprinter.New(opts.IO)
+	tp.HeaderRow("ID", "NAME", "SOURCE", "STATUS", "RULES")
+
+	for _, rs := range result.Rulesets {
+		tp.AddField(strconv.Itoa(rs.DatabaseId), tableprinter.WithColor(cs.Cyan))
+		tp.AddField(rs.Name, tableprinter.WithColor(cs.Bold))
+		tp.AddField(shared.RulesetSource(rs))
+		tp.AddField(strings.ToLower(rs.Enforcement))
+		tp.AddField(strconv.Itoa(rs.Rules.TotalCount))
+		tp.EndRow()
+	}
+
+	return tp.Render()
+}
diff --git a/pkg/cmd/ruleset/ruleset.go b/pkg/cmd/ruleset/ruleset.go
new file mode 100644
index 000000000..86411d717
--- /dev/null
+++ b/pkg/cmd/ruleset/ruleset.go
@@ -0,0 +1,34 @@
+package ruleset
+
+import (
+	"github.com/MakeNowJust/heredoc"
+	cmdCheck "github.com/cli/cli/v2/pkg/cmd/ruleset/check"
+	cmdList "github.com/cli/cli/v2/pkg/cmd/ruleset/list"
+	cmdView "github.com/cli/cli/v2/pkg/cmd/ruleset/view"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/spf13/cobra"
+)
+
+func NewCmdRuleset(f *cmdutil.Factory) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "ruleset <command>",
+		Short: "View info about repo rulesets",
+		Long: heredoc.Doc(`
+			Repository rulesets are a way to define a set of rules that apply to a repository.
+			These commands allow you to view information about them.
+		`),
+		Aliases: []string{"rs"},
+		Example: heredoc.Doc(`
+			$ gh ruleset list
+			$ gh ruleset view --repo OWNER/REPO --web
+			$ gh ruleset check branch-name
+		`),
+	}
+
+	cmdutil.EnableRepoOverride(cmd, f)
+	cmd.AddCommand(cmdList.NewCmdList(f, nil))
+	cmd.AddCommand(cmdView.NewCmdView(f, nil))
+	cmd.AddCommand(cmdCheck.NewCmdCheck(f, nil))
+
+	return cmd
+}
diff --git a/pkg/cmd/ruleset/shared/http.go b/pkg/cmd/ruleset/shared/http.go
new file mode 100644
index 000000000..59480dadf
--- /dev/null
+++ b/pkg/cmd/ruleset/shared/http.go
@@ -0,0 +1,133 @@
+package shared
+
+import (
+	"errors"
+	"net/http"
+	"strings"
+
+	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/internal/ghrepo"
+)
+
+type RulesetResponse struct {
+	Level struct {
+		Rulesets struct {
+			TotalCount int
+			Nodes      []RulesetGraphQL
+			PageInfo   struct {
+				HasNextPage bool
+				EndCursor   string
+			}
+		}
+	}
+}
+
+type RulesetList struct {
+	TotalCount int
+	Rulesets   []RulesetGraphQL
+}
+
+func ListRepoRulesets(httpClient *http.Client, repo ghrepo.Interface, limit int, includeParents bool) (*RulesetList, error) {
+	variables := map[string]interface{}{
+		"owner":          repo.RepoOwner(),
+		"repo":           repo.RepoName(),
+		"includeParents": includeParents,
+	}
+
+	return listRulesets(httpClient, rulesetsQuery(false), variables, limit, repo.RepoHost())
+}
+
+func ListOrgRulesets(httpClient *http.Client, orgLogin string, limit int, host string, includeParents bool) (*RulesetList, error) {
+	variables := map[string]interface{}{
+		"login":          orgLogin,
+		"includeParents": includeParents,
+	}
+
+	return listRulesets(httpClient, rulesetsQuery(true), variables, limit, host)
+}
+
+func listRulesets(httpClient *http.Client, query string, variables map[string]interface{}, limit int, host string) (*RulesetList, error) {
+	pageLimit := min(limit, 100)
+
+	res := RulesetList{
+		Rulesets: []RulesetGraphQL{},
+	}
+	client := api.NewClientFromHTTP(httpClient)
+
+	for {
+		variables["limit"] = pageLimit
+		var data RulesetResponse
+		err := client.GraphQL(host, query, variables, &data)
+		if err != nil {
+			if strings.Contains(err.Error(), "requires one of the following scopes: ['admin:org']") {
+				return nil, errors.New("the 'admin:org' scope is required to view organization rulesets, try running 'gh auth refresh -s admin:org'")
+			}
+
+			return nil, err
+		}
+
+		res.TotalCount = data.Level.Rulesets.TotalCount
+		res.Rulesets = append(res.Rulesets, data.Level.Rulesets.Nodes...)
+
+		if len(res.Rulesets) >= limit {
+			break
+		}
+
+		if data.Level.Rulesets.PageInfo.HasNextPage {
+			variables["endCursor"] = data.Level.Rulesets.PageInfo.EndCursor
+			pageLimit = min(pageLimit, limit-len(res.Rulesets))
+		} else {
+			break
+		}
+	}
+
+	return &res, nil
+}
+
+func min(a, b int) int {
+	if a < b {
+		return a
+	}
+	return b
+}
+
+func rulesetsQuery(org bool) string {
+	if org {
+		return orgGraphQLHeader + sharedGraphQLBody
+	} else {
+		return repoGraphQLHeader + sharedGraphQLBody
+	}
+}
+
+const repoGraphQLHeader = `
+query RepoRulesetList($limit: Int!, $endCursor: String, $includeParents: Boolean, $owner: String!, $repo: String!) {
+	level: repository(owner: $owner, name: $repo) {
+`
+
+const orgGraphQLHeader = `
+query OrgRulesetList($limit: Int!, $endCursor: String, $includeParents: Boolean, $login: String!) {
+	level: organization(login: $login) {
+`
+
+const sharedGraphQLBody = `
+rulesets(first: $limit, after: $endCursor, includeParents: $includeParents) {
+	totalCount
+	nodes {
+		databaseId
+		name
+		target
+		enforcement
+		source {
+			__typename
+			... on Repository { owner: nameWithOwner }
+			... on Organization { owner: login }
+		}
+		rules {
+			totalCount
+		}
+	}
+	pageInfo {
+		hasNextPage
+		endCursor
+	}
+}}}`
diff --git a/pkg/cmd/ruleset/shared/shared.go b/pkg/cmd/ruleset/shared/shared.go
new file mode 100644
index 000000000..a71b20522
--- /dev/null
+++ b/pkg/cmd/ruleset/shared/shared.go
@@ -0,0 +1,127 @@
+package shared
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+
+	"github.com/cli/cli/v2/internal/ghrepo"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+)
+
+type RulesetGraphQL struct {
+	DatabaseId  int
+	Name        string
+	Target      string
+	Enforcement string
+	Source      struct {
+		TypeName string `json:"__typename"`
+		Owner    string
+	}
+	Rules struct {
+		TotalCount int
+	}
+}
+
+type RulesetREST struct {
+	Id           int
+	Name         string
+	Target       string
+	Enforcement  string
+	BypassActors []struct {
+		ActorId    int    `json:"actor_id"`
+		ActorType  string `json:"actor_type"`
+		BypassMode string `json:"bypass_mode"`
+	} `json:"bypass_actors"`
+	Conditions map[string]map[string]interface{}
+	SourceType string `json:"source_type"`
+	Source     string
+	Rules      []RulesetRule
+	Links      struct {
+		Html struct {
+			Href string
+		}
+	} `json:"_links"`
+}
+
+type RulesetRule struct {
+	Type              string
+	Parameters        map[string]interface{}
+	RulesetSourceType string `json:"ruleset_source_type"`
+	RulesetSource     string `json:"ruleset_source"`
+	RulesetId         int    `json:"ruleset_id"`
+}
+
+// Returns the source of the ruleset in the format "owner/name (repo)" or "owner (org)"
+func RulesetSource(rs RulesetGraphQL) string {
+	var level string
+	if rs.Source.TypeName == "Repository" {
+		level = "repo"
+	} else if rs.Source.TypeName == "Organization" {
+		level = "org"
+	} else {
+		level = "unknown"
+	}
+
+	return fmt.Sprintf("%s (%s)", rs.Source.Owner, level)
+}
+
+func ParseRulesForDisplay(rules []RulesetRule) string {
+	var display strings.Builder
+
+	// sort keys for consistent responses
+	sort.SliceStable(rules, func(i, j int) bool {
+		return rules[i].Type < rules[j].Type
+	})
+
+	for _, rule := range rules {
+		display.WriteString(fmt.Sprintf("- %s", rule.Type))
+
+		if rule.Parameters != nil && len(rule.Parameters) > 0 {
+			display.WriteString(": ")
+
+			// sort these keys too for consistency
+			params := make([]string, 0, len(rule.Parameters))
+			for p := range rule.Parameters {
+				params = append(params, p)
+			}
+			sort.Strings(params)
+
+			for _, n := range params {
+				display.WriteString(fmt.Sprintf("[%s: %v] ", n, rule.Parameters[n]))
+			}
+		}
+
+		// ruleset source info is only returned from the "get rules for a branch" endpoint
+		if rule.RulesetSource != "" {
+			display.WriteString(
+				fmt.Sprintf(
+					"\n  (configured in ruleset %d from %s %s)\n",
+					rule.RulesetId,
+					strings.ToLower(rule.RulesetSourceType),
+					rule.RulesetSource,
+				),
+			)
+		}
+
+		display.WriteString("\n")
+	}
+
+	return display.String()
+}
+
+func NoRulesetsFoundError(orgOption string, repoI ghrepo.Interface, includeParents bool) error {
+	entityName := EntityName(orgOption, repoI)
+	parentsMsg := ""
+	if includeParents {
+		parentsMsg = " or its parents"
+	}
+	return cmdutil.NewNoResultsError(fmt.Sprintf("no rulesets found in %s%s", entityName, parentsMsg))
+}
+
+func EntityName(orgOption string, repoI ghrepo.Interface) string {
+	if orgOption != "" {
+		return orgOption
+	}
+	return ghrepo.FullName(repoI)
+}
diff --git a/pkg/cmd/ruleset/view/fixtures/rulesetViewMultiple.json b/pkg/cmd/ruleset/view/fixtures/rulesetViewMultiple.json
new file mode 100644
index 000000000..1b0d9e2cf
--- /dev/null
+++ b/pkg/cmd/ruleset/view/fixtures/rulesetViewMultiple.json
@@ -0,0 +1,41 @@
+{
+  "data": {
+    "level": {
+      "rulesets": {
+        "totalCount": 2,
+        "nodes": [
+          {
+            "databaseId": 74,
+            "name": "My Org Ruleset",
+            "target": "BRANCH",
+            "enforcement": "EVALUATE",
+            "source": {
+              "__typename": "Organization",
+              "owner": "my-owner"
+            },
+            "rules": {
+              "totalCount": 3
+            }
+          },
+          {
+            "databaseId": 42,
+            "name": "Test Ruleset",
+            "target": "BRANCH",
+            "enforcement": "ACTIVE",
+            "source": {
+              "__typename": "Repository",
+              "owner": "my-owner/repo-name"
+            },
+            "rules": {
+              "totalCount": 3
+            }
+          }
+        ],
+        "pageInfo": {
+          "hasNextPage": false,
+          "endCursor": "Mg"
+        }
+      }
+    }
+  }
+}
diff --git a/pkg/cmd/ruleset/view/fixtures/rulesetViewOrg.json b/pkg/cmd/ruleset/view/fixtures/rulesetViewOrg.json
new file mode 100644
index 000000000..88a2bd7ee
--- /dev/null
+++ b/pkg/cmd/ruleset/view/fixtures/rulesetViewOrg.json
@@ -0,0 +1,58 @@
+{
+	"id": 74,
+	"name": "My Org Ruleset",
+	"target": "branch",
+	"source_type": "Organization",
+	"source": "my-owner",
+	"enforcement": "evaluate",
+	"conditions": {
+		"ref_name": {
+			"exclude": [],
+			"include": [
+				"~ALL"
+			]
+		},
+		"repository_name": {
+			"exclude": [],
+			"include": [
+				"~ALL"
+			],
+			"protected": true
+		}
+	},
+	"rules": [
+		{
+			"type": "commit_message_pattern",
+			"parameters": {
+				"name": "",
+				"negate": false,
+				"pattern": "asdf",
+				"operator": "contains"
+			}
+		},
+		{
+			"type": "commit_author_email_pattern",
+			"parameters": {
+				"name": "",
+				"negate": false,
+				"pattern": "@example.com",
+				"operator": "ends_with"
+			}
+		},
+		{
+			"type": "creation"
+		}
+	],
+	"node_id": "RRS_lACqUmVwb3NpdG9yec4dwx_uzSNG",
+	"_links": {
+		"self": {
+			"href": "https://api.github.com/repos/my-owner/repo-name/rulesets/74"
+		},
+		"html": {
+			"href": "https://github.com/organizations/my-owner/settings/rules/74"
+		}
+	},
+	"created_at": "2023-05-01T13:53:37.185-04:00",
+	"updated_at": "2023-06-29T17:38:03.722-04:00",
+	"bypass_actors": []
+}
diff --git a/pkg/cmd/ruleset/view/fixtures/rulesetViewRepo.json b/pkg/cmd/ruleset/view/fixtures/rulesetViewRepo.json
new file mode 100644
index 000000000..5824b341d
--- /dev/null
+++ b/pkg/cmd/ruleset/view/fixtures/rulesetViewRepo.json
@@ -0,0 +1,62 @@
+{
+	"id": 42,
+	"name": "Test Ruleset",
+	"target": "branch",
+	"source_type": "Repository",
+	"source": "my-owner/repo-name",
+	"enforcement": "active",
+	"conditions": {
+		"ref_name": {
+			"exclude": [],
+			"include": [
+				"~ALL"
+			]
+		}
+	},
+	"rules": [
+		{
+			"type": "commit_message_pattern",
+			"parameters": {
+				"name": "",
+				"negate": false,
+				"pattern": "asdf",
+				"operator": "contains"
+			}
+		},
+		{
+			"type": "commit_author_email_pattern",
+			"parameters": {
+				"name": "",
+				"negate": false,
+				"pattern": "@example.com",
+				"operator": "ends_with"
+			}
+		},
+		{
+			"type": "creation"
+		}
+	],
+	"node_id": "RRS_lACqUmVwb3NpdG9yec4dwx_uzSNG",
+	"_links": {
+		"self": {
+			"href": "https://api.github.com/repos/my-owner/repo-name/rulesets/42"
+		},
+		"html": {
+			"href": "https://github.com/my-owner/repo-name/rules/42"
+		}
+	},
+	"created_at": "2023-05-01T13:53:37.185-04:00",
+	"updated_at": "2023-06-29T17:38:03.722-04:00",
+	"bypass_actors": [
+		{
+			"actor_id": 5,
+			"actor_type": "RepositoryRole",
+			"bypass_mode": "always"
+		},
+		{
+			"actor_id": 1,
+			"actor_type": "OrganizationAdmin",
+			"bypass_mode": "always"
+		}
+	]
+}
diff --git a/pkg/cmd/ruleset/view/http.go b/pkg/cmd/ruleset/view/http.go
new file mode 100644
index 000000000..d0b26f530
--- /dev/null
+++ b/pkg/cmd/ruleset/view/http.go
@@ -0,0 +1,32 @@
+package view
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/internal/ghrepo"
+	"github.com/cli/cli/v2/pkg/cmd/ruleset/shared"
+)
+
+func viewRepoRuleset(httpClient *http.Client, repo ghrepo.Interface, databaseId string) (*shared.RulesetREST, error) {
+	path := fmt.Sprintf("repos/%s/%s/rulesets/%s", repo.RepoOwner(), repo.RepoName(), databaseId)
+	return viewRuleset(httpClient, repo.RepoHost(), path)
+}
+
+func viewOrgRuleset(httpClient *http.Client, orgLogin string, databaseId string, host string) (*shared.RulesetREST, error) {
+	path := fmt.Sprintf("orgs/%s/rulesets/%s", orgLogin, databaseId)
+	return viewRuleset(httpClient, host, path)
+}
+
+func viewRuleset(httpClient *http.Client, hostname string, path string) (*shared.RulesetREST, error) {
+	apiClient := api.NewClientFromHTTP(httpClient)
+	result := shared.RulesetREST{}
+
+	err := apiClient.REST(hostname, "GET", path, nil, &result)
+	if err != nil {
+		return nil, err
+	}
+
+	return &result, nil
+}
diff --git a/pkg/cmd/ruleset/view/view.go b/pkg/cmd/ruleset/view/view.go
new file mode 100644
index 000000000..813f72c51
--- /dev/null
+++ b/pkg/cmd/ruleset/view/view.go
@@ -0,0 +1,270 @@
+package view
+
+import (
+	"fmt"
+	"net/http"
+	"sort"
+	"strconv"
+	"strings"
+
+	"github.com/MakeNowJust/heredoc"
+	"github.com/cli/cli/v2/internal/browser"
+	"github.com/cli/cli/v2/internal/ghrepo"
+	"github.com/cli/cli/v2/internal/prompter"
+	"github.com/cli/cli/v2/internal/text"
+	"github.com/cli/cli/v2/pkg/cmd/ruleset/shared"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	ghAuth "github.com/cli/go-gh/v2/pkg/auth"
+	"github.com/spf13/cobra"
+)
+
+type ViewOptions struct {
+	HttpClient func() (*http.Client, error)
+	IO         *iostreams.IOStreams
+	BaseRepo   func() (ghrepo.Interface, error)
+	Browser    browser.Browser
+	Prompter   prompter.Prompter
+
+	ID              string
+	WebMode         bool
+	IncludeParents  bool
+	InteractiveMode bool
+	Organization    string
+}
+
+func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
+	opts := &ViewOptions{
+		IO:         f.IOStreams,
+		HttpClient: f.HttpClient,
+		Browser:    f.Browser,
+		Prompter:   f.Prompter,
+	}
+
+	cmd := &cobra.Command{
+		Use:   "view [<ruleset-id>]",
+		Short: "View information about a ruleset",
+		Long: heredoc.Doc(`
+			View information about a GitHub ruleset.
+
+			If no ID is provided, an interactive prompt will be used to choose
+			the ruleset to view.
+			
+			Use the --parents flag to control whether rulesets configured at higher
+			levels that also apply to the provided repository or organization should
+			be returned. The default is true.
+		`),
+		Example: heredoc.Doc(`
+			# Interactively choose a ruleset to view from all rulesets that apply to the current repository
+			$ gh ruleset view
+
+			# Interactively choose a ruleset to view from only rulesets configured in the current repository
+			$ gh ruleset view --no-parents
+
+			# View a ruleset configured in the current repository or any of its parents
+			$ gh ruleset view 43
+
+			# View a ruleset configured in a different repository or any of its parents
+			$ gh ruleset view 23 --repo owner/repo
+
+			# View an organization-level ruleset
+			$ gh ruleset view 23 --org my-org
+		`),
+		Args: cobra.MaximumNArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && opts.Organization != "" {
+				return cmdutil.FlagErrorf("only one of --repo and --org may be specified")
+			}
+
+			// support `-R, --repo` override
+			opts.BaseRepo = f.BaseRepo
+
+			if len(args) > 0 {
+				// a string is actually needed later on, so verify that it's numeric
+				// but use the string anyway
+				_, err := strconv.Atoi(args[0])
+				if err != nil {
+					return cmdutil.FlagErrorf("invalid value for ruleset ID: %v is not an integer", args[0])
+				}
+				opts.ID = args[0]
+			} else if !opts.IO.CanPrompt() {
+				return cmdutil.FlagErrorf("a ruleset ID must be provided when not running interactively")
+			} else {
+				opts.InteractiveMode = true
+			}
+
+			if runF != nil {
+				return runF(opts)
+			}
+			return viewRun(opts)
+		},
+	}
+
+	cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the ruleset in the browser")
+	cmd.Flags().StringVarP(&opts.Organization, "org", "o", "", "Organization name if the provided ID is an organization-level ruleset")
+	cmd.Flags().BoolVarP(&opts.IncludeParents, "parents", "p", true, "Whether to include rulesets configured at higher levels that also apply")
+
+	return cmd
+}
+
+func viewRun(opts *ViewOptions) error {
+	httpClient, err := opts.HttpClient()
+	if err != nil {
+		return err
+	}
+
+	repoI, err := opts.BaseRepo()
+	if err != nil {
+		return err
+	}
+
+	hostname, _ := ghAuth.DefaultHost()
+	cs := opts.IO.ColorScheme()
+
+	if opts.InteractiveMode {
+		var rsList *shared.RulesetList
+		limit := 30
+		if opts.Organization != "" {
+			rsList, err = shared.ListOrgRulesets(httpClient, opts.Organization, limit, hostname, opts.IncludeParents)
+		} else {
+			rsList, err = shared.ListRepoRulesets(httpClient, repoI, limit, opts.IncludeParents)
+		}
+
+		if err != nil {
+			return err
+		}
+
+		if rsList.TotalCount == 0 {
+			return shared.NoRulesetsFoundError(opts.Organization, repoI, opts.IncludeParents)
+		}
+
+		rs, err := selectRulesetID(rsList, opts.Prompter, cs)
+		if err != nil {
+			return err
+		}
+
+		if rs != nil {
+			opts.ID = strconv.Itoa(rs.DatabaseId)
+
+			// can't get a ruleset lower in the chain than what was queried, so no need to handle repos here
+			if rs.Source.TypeName == "Organization" {
+				opts.Organization = rs.Source.Owner
+			}
+		}
+	}
+
+	var rs *shared.RulesetREST
+	if opts.Organization != "" {
+		rs, err = viewOrgRuleset(httpClient, opts.Organization, opts.ID, hostname)
+	} else {
+		rs, err = viewRepoRuleset(httpClient, repoI, opts.ID)
+	}
+
+	if err != nil {
+		return err
+	}
+
+	w := opts.IO.Out
+
+	if opts.WebMode {
+		if rs != nil {
+			if opts.IO.IsStdoutTTY() {
+				fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", text.DisplayURL(rs.Links.Html.Href))
+			}
+
+			return opts.Browser.Browse(rs.Links.Html.Href)
+		} else {
+			fmt.Fprintf(w, "ruleset not found\n")
+		}
+	}
+
+	fmt.Fprintf(w, "\n%s\n", cs.Bold(rs.Name))
+	fmt.Fprintf(w, "ID: %s\n", cs.Cyan(strconv.Itoa(rs.Id)))
+	fmt.Fprintf(w, "Source: %s (%s)\n", rs.Source, rs.SourceType)
+
+	fmt.Fprint(w, "Enforcement: ")
+	switch rs.Enforcement {
+	case "disabled":
+		fmt.Fprintf(w, "%s\n", cs.Red("Disabled"))
+	case "evaluate":
+		fmt.Fprintf(w, "%s\n", cs.Yellow("Evaluate Mode (not enforced)"))
+	case "active":
+		fmt.Fprintf(w, "%s\n", cs.Green("Active"))
+	default:
+		fmt.Fprintf(w, "%s\n", rs.Enforcement)
+	}
+
+	fmt.Fprintf(w, "\n%s\n", cs.Bold("Bypass List"))
+	if len(rs.BypassActors) == 0 {
+		fmt.Fprintf(w, "This ruleset cannot be bypassed\n")
+	} else {
+		sort.Slice(rs.BypassActors, func(i, j int) bool {
+			return rs.BypassActors[i].ActorId < rs.BypassActors[j].ActorId
+		})
+
+		for _, t := range rs.BypassActors {
+			fmt.Fprintf(w, "- %s (ID: %d), mode: %s\n", t.ActorType, t.ActorId, t.BypassMode)
+		}
+	}
+
+	fmt.Fprintf(w, "\n%s\n", cs.Bold("Conditions"))
+	if len(rs.Conditions) == 0 {
+		fmt.Fprintf(w, "No conditions configured\n")
+	} else {
+		// sort keys for consistent responses
+		keys := make([]string, 0, len(rs.Conditions))
+		for key := range rs.Conditions {
+			keys = append(keys, key)
+		}
+		sort.Strings(keys)
+
+		for _, name := range keys {
+			condition := rs.Conditions[name]
+			fmt.Fprintf(w, "- %s: ", name)
+
+			// sort these keys too for consistency
+			subkeys := make([]string, 0, len(condition))
+			for subkey := range condition {
+				subkeys = append(subkeys, subkey)
+			}
+			sort.Strings(subkeys)
+
+			for _, n := range subkeys {
+				fmt.Fprintf(w, "[%s: %v] ", n, condition[n])
+			}
+
+			fmt.Fprint(w, "\n")
+		}
+	}
+
+	fmt.Fprintf(w, "\n%s\n", cs.Bold("Rules"))
+	if len(rs.Rules) == 0 {
+		fmt.Fprintf(w, "No rules configured\n")
+	} else {
+		fmt.Fprint(w, shared.ParseRulesForDisplay(rs.Rules))
+	}
+
+	return nil
+}
+
+func selectRulesetID(rsList *shared.RulesetList, p prompter.Prompter, cs *iostreams.ColorScheme) (*shared.RulesetGraphQL, error) {
+	rulesets := make([]string, len(rsList.Rulesets))
+	for i, rs := range rsList.Rulesets {
+		s := fmt.Sprintf(
+			"%s: %s | %s | contains %s | configured in %s",
+			cs.Cyan(strconv.Itoa(rs.DatabaseId)),
+			rs.Name,
+			strings.ToLower(rs.Enforcement),
+			text.Pluralize(rs.Rules.TotalCount, "rule"),
+			shared.RulesetSource(rs),
+		)
+		rulesets[i] = s
+	}
+
+	r, err := p.Select("Which ruleset would you like to view?", rulesets[0], rulesets)
+	if err != nil {
+		return nil, err
+	}
+
+	return &rsList.Rulesets[r], nil
+}


Test patch:
diff --git a/pkg/cmd/ruleset/check/check_test.go b/pkg/cmd/ruleset/check/check_test.go
new file mode 100644
index 000000000..2d9dffae4
--- /dev/null
+++ b/pkg/cmd/ruleset/check/check_test.go
@@ -0,0 +1,233 @@
+package check
+
+import (
+	"bytes"
+	"io"
+	"net/http"
+	"testing"
+
+	"github.com/MakeNowJust/heredoc"
+	"github.com/cli/cli/v2/internal/browser"
+	"github.com/cli/cli/v2/internal/ghrepo"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/httpmock"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	"github.com/google/shlex"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func Test_NewCmdCheck(t *testing.T) {
+	tests := []struct {
+		name    string
+		args    string
+		isTTY   bool
+		want    CheckOptions
+		wantErr string
+	}{
+		{
+			name:  "no arguments",
+			args:  "",
+			isTTY: true,
+			want: CheckOptions{
+				Branch:  "",
+				Default: false,
+				WebMode: false,
+			},
+		},
+		{
+			name:  "branch name",
+			args:  "my-branch",
+			isTTY: true,
+			want: CheckOptions{
+				Branch:  "my-branch",
+				Default: false,
+				WebMode: false,
+			},
+		},
+		{
+			name:  "default",
+			args:  "--default=true",
+			isTTY: true,
+			want: CheckOptions{
+				Branch:  "",
+				Default: true,
+				WebMode: false,
+			},
+		},
+		{
+			name:  "web mode",
+			args:  "--web",
+			isTTY: true,
+			want: CheckOptions{
+				Branch:  "",
+				Default: false,
+				WebMode: true,
+			},
+		},
+		{
+			name:    "both --default and branch name specified",
+			args:    "--default asdf",
+			isTTY:   true,
+			wantErr: "specify only one of `--default` or a branch name",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ios, _, _, _ := iostreams.Test()
+			ios.SetStdoutTTY(tt.isTTY)
+			ios.SetStdinTTY(tt.isTTY)
+			ios.SetStderrTTY(tt.isTTY)
+
+			f := &cmdutil.Factory{
+				IOStreams: ios,
+			}
+
+			var opts *CheckOptions
+			cmd := NewCmdCheck(f, func(o *CheckOptions) error {
+				opts = o
+				return nil
+			})
+			cmd.PersistentFlags().StringP("repo", "R", "", "")
+
+			argv, err := shlex.Split(tt.args)
+			require.NoError(t, err)
+			cmd.SetArgs(argv)
+
+			cmd.SetIn(&bytes.Buffer{})
+			cmd.SetOut(io.Discard)
+			cmd.SetErr(io.Discard)
+
+			_, err = cmd.ExecuteC()
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+				return
+			} else {
+				require.NoError(t, err)
+			}
+
+			assert.Equal(t, tt.want.Branch, opts.Branch)
+			assert.Equal(t, tt.want.Default, opts.Default)
+			assert.Equal(t, tt.want.WebMode, opts.WebMode)
+		})
+	}
+}
+
+func Test_checkRun(t *testing.T) {
+	tests := []struct {
+		name       string
+		isTTY      bool
+		opts       CheckOptions
+		wantErr    string
+		wantStdout string
+		wantStderr string
+		wantBrowse string
+	}{
+		{
+			name:  "view rules for branch",
+			isTTY: true,
+			opts: CheckOptions{
+				Branch: "my-branch",
+			},
+			wantStdout: heredoc.Doc(`
+			6 rules apply to branch my-branch in repo my-org/repo-name
+
+			- commit_author_email_pattern: [name: ] [negate: false] [operator: ends_with] [pattern: @example.com] 
+			  (configured in ruleset 1234 from organization my-org)
+
+			- commit_author_email_pattern: [name: ] [negate: false] [operator: ends_with] [pattern: @example.com] 
+			  (configured in ruleset 5678 from repository my-org/repo-name)
+
+			- commit_message_pattern: [name: ] [negate: false] [operator: starts_with] [pattern: fff] 
+			  (configured in ruleset 1234 from organization my-org)
+
+			- commit_message_pattern: [name: ] [negate: false] [operator: contains] [pattern: asdf] 
+			  (configured in ruleset 5678 from repository my-org/repo-name)
+
+			- creation
+			  (configured in ruleset 5678 from repository my-org/repo-name)
+
+			- required_signatures
+			  (configured in ruleset 1234 from organization my-org)
+
+			`),
+			wantStderr: "",
+			wantBrowse: "",
+		},
+		{
+			name:  "web mode, TTY",
+			isTTY: true,
+			opts: CheckOptions{
+				Branch:  "my-branch",
+				WebMode: true,
+			},
+			wantStdout: "Opening github.com/my-org/repo-name/rules in your browser.\n",
+			wantStderr: "",
+			wantBrowse: "https://github.com/my-org/repo-name/rules?ref=refs%2Fheads%2Fmy-branch",
+		},
+		{
+			name:  "web mode, TTY, special character in branch name",
+			isTTY: true,
+			opts: CheckOptions{
+				Branch:  "my-feature/my-branch",
+				WebMode: true,
+			},
+			wantStdout: "Opening github.com/my-org/repo-name/rules in your browser.\n",
+			wantStderr: "",
+			wantBrowse: "https://github.com/my-org/repo-name/rules?ref=refs%2Fheads%2Fmy-feature%2Fmy-branch",
+		},
+		{
+			name:  "web mode, non-TTY",
+			isTTY: false,
+			opts: CheckOptions{
+				Branch:  "my-branch",
+				WebMode: true,
+			},
+			wantStdout: "",
+			wantStderr: "",
+			wantBrowse: "https://github.com/my-org/repo-name/rules?ref=refs%2Fheads%2Fmy-branch",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ios, _, stdout, stderr := iostreams.Test()
+			ios.SetStdoutTTY(tt.isTTY)
+			ios.SetStdinTTY(tt.isTTY)
+			ios.SetStderrTTY(tt.isTTY)
+
+			fakeHTTP := &httpmock.Registry{}
+			fakeHTTP.Register(
+				httpmock.REST("GET", "repos/my-org/repo-name/rules/branches/my-branch"),
+				httpmock.FileResponse("./fixtures/rulesetCheck.json"),
+			)
+
+			tt.opts.IO = ios
+			tt.opts.HttpClient = func() (*http.Client, error) {
+				return &http.Client{Transport: fakeHTTP}, nil
+			}
+			tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
+				return ghrepo.FromFullName("my-org/repo-name")
+			}
+			browser := &browser.Stub{}
+			tt.opts.Browser = browser
+
+			err := checkRun(&tt.opts)
+
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+				return
+			} else {
+				require.NoError(t, err)
+			}
+
+			if tt.wantBrowse != "" {
+				browser.Verify(t, tt.wantBrowse)
+			}
+
+			assert.Equal(t, tt.wantStdout, stdout.String())
+			assert.Equal(t, tt.wantStderr, stderr.String())
+		})
+	}
+}
diff --git a/pkg/cmd/ruleset/list/list_test.go b/pkg/cmd/ruleset/list/list_test.go
new file mode 100644
index 000000000..d075e46d6
--- /dev/null
+++ b/pkg/cmd/ruleset/list/list_test.go
@@ -0,0 +1,295 @@
+package list
+
+import (
+	"bytes"
+	"io"
+	"net/http"
+	"testing"
+
+	"github.com/MakeNowJust/heredoc"
+	"github.com/cli/cli/v2/internal/browser"
+	"github.com/cli/cli/v2/internal/ghrepo"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/httpmock"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	"github.com/google/shlex"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func Test_NewCmdList(t *testing.T) {
+	tests := []struct {
+		name    string
+		args    string
+		isTTY   bool
+		want    ListOptions
+		wantErr string
+	}{
+		{
+			name:  "no arguments",
+			args:  "",
+			isTTY: true,
+			want: ListOptions{
+				Limit:          30,
+				IncludeParents: true,
+				WebMode:        false,
+				Organization:   "",
+			},
+		},
+		{
+			name:  "limit",
+			args:  "--limit 1",
+			isTTY: true,
+			want: ListOptions{
+				Limit:          1,
+				IncludeParents: true,
+				WebMode:        false,
+				Organization:   "",
+			},
+		},
+		{
+			name:  "include parents",
+			args:  "--parents=false",
+			isTTY: true,
+			want: ListOptions{
+				Limit:          30,
+				IncludeParents: false,
+				WebMode:        false,
+				Organization:   "",
+			},
+		},
+		{
+			name:  "org",
+			args:  "--org \"my-org\"",
+			isTTY: true,
+			want: ListOptions{
+				Limit:          30,
+				IncludeParents: true,
+				WebMode:        false,
+				Organization:   "my-org",
+			},
+		},
+		{
+			name:  "web mode",
+			args:  "--web",
+			isTTY: true,
+			want: ListOptions{
+				Limit:          30,
+				IncludeParents: true,
+				WebMode:        true,
+				Organization:   "",
+			},
+		},
+		{
+			name:    "invalid limit",
+			args:    "--limit 0",
+			isTTY:   true,
+			wantErr: "invalid limit: 0",
+		},
+		{
+			name:    "repo and org specified",
+			args:    "--org \"my-org\" -R \"owner/repo\"",
+			isTTY:   true,
+			wantErr: "only one of --repo and --org may be specified",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ios, _, _, _ := iostreams.Test()
+			ios.SetStdoutTTY(tt.isTTY)
+			ios.SetStdinTTY(tt.isTTY)
+			ios.SetStderrTTY(tt.isTTY)
+
+			f := &cmdutil.Factory{
+				IOStreams: ios,
+			}
+
+			var opts *ListOptions
+			cmd := NewCmdList(f, func(o *ListOptions) error {
+				opts = o
+				return nil
+			})
+			cmd.PersistentFlags().StringP("repo", "R", "", "")
+
+			argv, err := shlex.Split(tt.args)
+			require.NoError(t, err)
+			cmd.SetArgs(argv)
+
+			cmd.SetIn(&bytes.Buffer{})
+			cmd.SetOut(io.Discard)
+			cmd.SetErr(io.Discard)
+
+			_, err = cmd.ExecuteC()
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+				return
+			} else {
+				require.NoError(t, err)
+			}
+
+			assert.Equal(t, tt.want.Limit, opts.Limit)
+			assert.Equal(t, tt.want.WebMode, opts.WebMode)
+			assert.Equal(t, tt.want.Organization, opts.Organization)
+		})
+	}
+}
+
+func Test_listRun(t *testing.T) {
+	tests := []struct {
+		name       string
+		isTTY      bool
+		opts       ListOptions
+		httpStubs  func(*httpmock.Registry)
+		wantErr    string
+		wantStdout string
+		wantStderr string
+		wantBrowse string
+	}{
+		{
+			name:  "list repo rulesets",
+			isTTY: true,
+			wantStdout: heredoc.Doc(`
+
+				Showing 3 of 3 rulesets in OWNER/REPO
+
+				ID  NAME    SOURCE             STATUS    RULES
+				4   test    OWNER/REPO (repo)  evaluate  1
+				42  asdf    OWNER/REPO (repo)  active    2
+				77  foobar  Org-Name (org)     disabled  4
+			`),
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`query RepoRulesetList\b`),
+					httpmock.FileResponse("./fixtures/rulesetList.json"),
+				)
+			},
+			wantStderr: "",
+			wantBrowse: "",
+		},
+		{
+			name:  "list org rulesets",
+			isTTY: true,
+			opts: ListOptions{
+				Organization: "my-org",
+			},
+			wantStdout: heredoc.Doc(`
+
+				Showing 3 of 3 rulesets in my-org
+
+				ID  NAME    SOURCE             STATUS    RULES
+				4   test    OWNER/REPO (repo)  evaluate  1
+				42  asdf    OWNER/REPO (repo)  active    2
+				77  foobar  Org-Name (org)     disabled  4
+			`),
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`query OrgRulesetList\b`),
+					httpmock.FileResponse("./fixtures/rulesetList.json"),
+				)
+			},
+			wantStderr: "",
+			wantBrowse: "",
+		},
+		{
+			name:  "machine-readable",
+			isTTY: false,
+			wantStdout: heredoc.Doc(`
+				4	test	OWNER/REPO (repo)	evaluate	1
+				42	asdf	OWNER/REPO (repo)	active	2
+				77	foobar	Org-Name (org)	disabled	4
+			`),
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`query RepoRulesetList\b`),
+					httpmock.FileResponse("./fixtures/rulesetList.json"),
+				)
+			},
+			wantStderr: "",
+			wantBrowse: "",
+		},
+		{
+			name:  "repo web mode, TTY",
+			isTTY: true,
+			opts: ListOptions{
+				WebMode: true,
+			},
+			wantStdout: "Opening github.com/OWNER/REPO/rules in your browser.\n",
+			wantStderr: "",
+			wantBrowse: "https://github.com/OWNER/REPO/rules",
+		},
+		{
+			name:  "org web mode, TTY",
+			isTTY: true,
+			opts: ListOptions{
+				WebMode:      true,
+				Organization: "my-org",
+			},
+			wantStdout: "Opening github.com/organizations/my-org/settings/rules in your browser.\n",
+			wantStderr: "",
+			wantBrowse: "https://github.com/organizations/my-org/settings/rules",
+		},
+		{
+			name:  "repo web mode, non-TTY",
+			isTTY: false,
+			opts: ListOptions{
+				WebMode: true,
+			},
+			wantStdout: "",
+			wantStderr: "",
+			wantBrowse: "https://github.com/OWNER/REPO/rules",
+		},
+		{
+			name:  "org web mode, non-TTY",
+			isTTY: false,
+			opts: ListOptions{
+				WebMode:      true,
+				Organization: "my-org",
+			},
+			wantStdout: "",
+			wantStderr: "",
+			wantBrowse: "https://github.com/organizations/my-org/settings/rules",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ios, _, stdout, stderr := iostreams.Test()
+			ios.SetStdoutTTY(tt.isTTY)
+			ios.SetStdinTTY(tt.isTTY)
+			ios.SetStderrTTY(tt.isTTY)
+
+			reg := &httpmock.Registry{}
+			defer reg.Verify(t)
+			if tt.httpStubs != nil {
+				tt.httpStubs(reg)
+			}
+
+			tt.opts.IO = ios
+			tt.opts.HttpClient = func() (*http.Client, error) {
+				return &http.Client{Transport: reg}, nil
+			}
+			tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
+				return ghrepo.FromFullName("OWNER/REPO")
+			}
+			browser := &browser.Stub{}
+			tt.opts.Browser = browser
+
+			err := listRun(&tt.opts)
+
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+				return
+			} else {
+				require.NoError(t, err)
+			}
+
+			if tt.wantBrowse != "" {
+				browser.Verify(t, tt.wantBrowse)
+			}
+
+			assert.Equal(t, tt.wantStdout, stdout.String())
+			assert.Equal(t, tt.wantStderr, stderr.String())
+		})
+	}
+}
diff --git a/pkg/cmd/ruleset/view/view_test.go b/pkg/cmd/ruleset/view/view_test.go
new file mode 100644
index 000000000..ce9c21db4
--- /dev/null
+++ b/pkg/cmd/ruleset/view/view_test.go
@@ -0,0 +1,364 @@
+package view
+
+import (
+	"bytes"
+	"io"
+	"net/http"
+	"testing"
+
+	"github.com/MakeNowJust/heredoc"
+	"github.com/cli/cli/v2/internal/browser"
+	"github.com/cli/cli/v2/internal/ghrepo"
+	"github.com/cli/cli/v2/internal/prompter"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/httpmock"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	"github.com/google/shlex"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func Test_NewCmdView(t *testing.T) {
+	tests := []struct {
+		name    string
+		args    string
+		isTTY   bool
+		want    ViewOptions
+		wantErr string
+	}{
+		{
+			name:  "no arguments",
+			args:  "",
+			isTTY: true,
+			want: ViewOptions{
+				ID:              "",
+				WebMode:         false,
+				IncludeParents:  true,
+				InteractiveMode: true,
+				Organization:    "",
+			},
+		},
+		{
+			name:  "only ID",
+			args:  "3",
+			isTTY: true,
+			want: ViewOptions{
+				ID:              "3",
+				WebMode:         false,
+				IncludeParents:  true,
+				InteractiveMode: false,
+				Organization:    "",
+			},
+		},
+		{
+			name:  "org",
+			args:  "--org \"my-org\"",
+			isTTY: true,
+			want: ViewOptions{
+				ID:              "",
+				WebMode:         false,
+				IncludeParents:  true,
+				InteractiveMode: true,
+				Organization:    "my-org",
+			},
+		},
+		{
+			name:  "web mode",
+			args:  "--web",
+			isTTY: true,
+			want: ViewOptions{
+				ID:              "",
+				WebMode:         true,
+				IncludeParents:  true,
+				InteractiveMode: true,
+				Organization:    "",
+			},
+		},
+		{
+			name:  "parents",
+			args:  "--parents=false",
+			isTTY: true,
+			want: ViewOptions{
+				ID:              "",
+				WebMode:         false,
+				IncludeParents:  false,
+				InteractiveMode: true,
+				Organization:    "",
+			},
+		},
+		{
+			name:    "repo and org specified",
+			args:    "--org \"my-org\" -R \"owner/repo\"",
+			isTTY:   true,
+			wantErr: "only one of --repo and --org may be specified",
+		},
+		{
+			name:    "invalid ID",
+			args:    "1.5",
+			isTTY:   true,
+			wantErr: "invalid value for ruleset ID: 1.5 is not an integer",
+		},
+		{
+			name:    "ID not provided and not TTY",
+			args:    "",
+			isTTY:   false,
+			wantErr: "a ruleset ID must be provided when not running interactively",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ios, _, _, _ := iostreams.Test()
+			ios.SetStdoutTTY(tt.isTTY)
+			ios.SetStdinTTY(tt.isTTY)
+			ios.SetStderrTTY(tt.isTTY)
+
+			f := &cmdutil.Factory{
+				IOStreams: ios,
+			}
+
+			var opts *ViewOptions
+			cmd := NewCmdView(f, func(o *ViewOptions) error {
+				opts = o
+				return nil
+			})
+			cmd.PersistentFlags().StringP("repo", "R", "", "")
+
+			argv, err := shlex.Split(tt.args)
+			require.NoError(t, err)
+			cmd.SetArgs(argv)
+
+			cmd.SetIn(&bytes.Buffer{})
+			cmd.SetOut(io.Discard)
+			cmd.SetErr(io.Discard)
+
+			_, err = cmd.ExecuteC()
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+				return
+			} else {
+				require.NoError(t, err)
+			}
+
+			assert.Equal(t, tt.want.ID, opts.ID)
+			assert.Equal(t, tt.want.WebMode, opts.WebMode)
+			assert.Equal(t, tt.want.IncludeParents, opts.IncludeParents)
+			assert.Equal(t, tt.want.InteractiveMode, opts.InteractiveMode)
+			assert.Equal(t, tt.want.Organization, opts.Organization)
+		})
+	}
+}
+
+func Test_viewRun(t *testing.T) {
+	repoRulesetStdout := heredoc.Doc(`
+
+	Test Ruleset
+	ID: 42
+	Source: my-owner/repo-name (Repository)
+	Enforcement: Active
+	
+	Bypass List
+	- OrganizationAdmin (ID: 1), mode: always
+	- RepositoryRole (ID: 5), mode: always
+	
+	Conditions
+	- ref_name: [exclude: []] [include: [~ALL]] 
+	
+	Rules
+	- commit_author_email_pattern: [name: ] [negate: false] [operator: ends_with] [pattern: @example.com] 
+	- commit_message_pattern: [name: ] [negate: false] [operator: contains] [pattern: asdf] 
+	- creation
+	`)
+
+	tests := []struct {
+		name          string
+		isTTY         bool
+		opts          ViewOptions
+		httpStubs     func(*httpmock.Registry)
+		prompterStubs func(*prompter.MockPrompter)
+		wantErr       string
+		wantStdout    string
+		wantStderr    string
+		wantBrowse    string
+	}{
+		{
+			name:  "view repo ruleset",
+			isTTY: true,
+			opts: ViewOptions{
+				ID: "42",
+			},
+			wantStdout: repoRulesetStdout,
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/my-owner/repo-name/rulesets/42"),
+					httpmock.FileResponse("./fixtures/rulesetViewRepo.json"),
+				)
+			},
+			wantStderr: "",
+			wantBrowse: "",
+		},
+		{
+			name:  "view org ruleset",
+			isTTY: true,
+			opts: ViewOptions{
+				ID:           "74",
+				Organization: "my-owner",
+			},
+			wantStdout: heredoc.Doc(`
+
+			My Org Ruleset
+			ID: 74
+			Source: my-owner (Organization)
+			Enforcement: Evaluate Mode (not enforced)
+			
+			Bypass List
+			This ruleset cannot be bypassed
+			
+			Conditions
+			- ref_name: [exclude: []] [include: [~ALL]] 
+			- repository_name: [exclude: []] [include: [~ALL]] [protected: true] 
+			
+			Rules
+			- commit_author_email_pattern: [name: ] [negate: false] [operator: ends_with] [pattern: @example.com] 
+			- commit_message_pattern: [name: ] [negate: false] [operator: contains] [pattern: asdf] 
+			- creation
+			`),
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "orgs/my-owner/rulesets/74"),
+					httpmock.FileResponse("./fixtures/rulesetViewOrg.json"),
+				)
+			},
+			wantStderr: "",
+			wantBrowse: "",
+		},
+		{
+			name:  "prompter",
+			isTTY: true,
+			opts: ViewOptions{
+				InteractiveMode: true,
+			},
+			wantStdout: repoRulesetStdout,
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`query RepoRulesetList\b`),
+					httpmock.FileResponse("./fixtures/rulesetViewMultiple.json"),
+				)
+				reg.Register(
+					httpmock.REST("GET", "repos/my-owner/repo-name/rulesets/42"),
+					httpmock.FileResponse("./fixtures/rulesetViewRepo.json"),
+				)
+			},
+			prompterStubs: func(pm *prompter.MockPrompter) {
+				const repoRuleset = "42: Test Ruleset | active | contains 3 rules | configured in my-owner/repo-name (repo)"
+				pm.RegisterSelect("Which ruleset would you like to view?",
+					[]string{
+						"74: My Org Ruleset | evaluate | contains 3 rules | configured in my-owner (org)",
+						repoRuleset,
+					},
+					func(_, _ string, opts []string) (int, error) {
+						return prompter.IndexFor(opts, repoRuleset)
+					})
+			},
+		},
+		{
+			name:  "web mode, TTY, repo",
+			isTTY: true,
+			opts: ViewOptions{
+				ID:      "42",
+				WebMode: true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/my-owner/repo-name/rulesets/42"),
+					httpmock.FileResponse("./fixtures/rulesetViewRepo.json"),
+				)
+			},
+			wantStdout: "Opening github.com/my-owner/repo-name/rules/42 in your browser.\n",
+			wantStderr: "",
+			wantBrowse: "https://github.com/my-owner/repo-name/rules/42",
+		},
+		{
+			name:  "web mode, non-TTY, repo",
+			isTTY: false,
+			opts: ViewOptions{
+				ID:      "42",
+				WebMode: true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "repos/my-owner/repo-name/rulesets/42"),
+					httpmock.FileResponse("./fixtures/rulesetViewRepo.json"),
+				)
+			},
+			wantStdout: "",
+			wantStderr: "",
+			wantBrowse: "https://github.com/my-owner/repo-name/rules/42",
+		},
+		{
+			name:  "web mode, TTY, org",
+			isTTY: true,
+			opts: ViewOptions{
+				ID:           "74",
+				Organization: "my-owner",
+				WebMode:      true,
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.REST("GET", "orgs/my-owner/rulesets/74"),
+					httpmock.FileResponse("./fixtures/rulesetViewOrg.json"),
+				)
+			},
+			wantStdout: "Opening github.com/organizations/my-owner/settings/rules/74 in your browser.\n",
+			wantStderr: "",
+			wantBrowse: "https://github.com/organizations/my-owner/settings/rules/74",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ios, _, stdout, stderr := iostreams.Test()
+			ios.SetStdoutTTY(tt.isTTY)
+			ios.SetStdinTTY(tt.isTTY)
+			ios.SetStderrTTY(tt.isTTY)
+
+			pm := prompter.NewMockPrompter(t)
+			if tt.prompterStubs != nil {
+				tt.prompterStubs(pm)
+			}
+			tt.opts.Prompter = pm
+
+			reg := &httpmock.Registry{}
+			defer reg.Verify(t)
+			if tt.httpStubs != nil {
+				tt.httpStubs(reg)
+			}
+
+			tt.opts.IO = ios
+			tt.opts.HttpClient = func() (*http.Client, error) {
+				return &http.Client{Transport: reg}, nil
+			}
+			tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
+				return ghrepo.FromFullName("my-owner/repo-name")
+			}
+			browser := &browser.Stub{}
+			tt.opts.Browser = browser
+
+			err := viewRun(&tt.opts)
+
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+				return
+			} else {
+				require.NoError(t, err)
+			}
+
+			if tt.wantBrowse != "" {
+				browser.Verify(t, tt.wantBrowse)
+			}
+
+			assert.Equal(t, tt.wantStdout, stdout.String())
+			assert.Equal(t, tt.wantStderr, stderr.String())
+		})
+	}
+}