diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 59f576f23..995ca30c1 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" @@ -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)) 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 []", + 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/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/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/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/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 ", + 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 []", + 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 +} 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()) + }) + } +}