Merge pull request #7650 from vaindil/vaindil/rulesets

Add support for repository rulesets
This commit is contained in:
Nate Smith 2023-07-10 12:50:23 -07:00 committed by GitHub
commit 4ba2f2ffb3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 2127 additions and 0 deletions

View file

@ -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"
@ -145,6 +146,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))

View file

@ -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
}

View file

@ -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())
})
}
}

View file

@ -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
}
]

View file

@ -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="
}
}
}
}
}

View file

@ -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()
}

View file

@ -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())
})
}
}

View file

@ -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
}

View file

@ -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
}
}}}`

View file

@ -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)
}

View file

@ -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"
}
}
}
}
}

View file

@ -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": []
}

View file

@ -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"
}
]
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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())
})
}
}