Merge pull request #7650 from vaindil/vaindil/rulesets
Add support for repository rulesets
This commit is contained in:
commit
4ba2f2ffb3
16 changed files with 2127 additions and 0 deletions
|
|
@ -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))
|
||||
|
|
|
|||
163
pkg/cmd/ruleset/check/check.go
Normal file
163
pkg/cmd/ruleset/check/check.go
Normal 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
|
||||
}
|
||||
233
pkg/cmd/ruleset/check/check_test.go
Normal file
233
pkg/cmd/ruleset/check/check_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
62
pkg/cmd/ruleset/check/fixtures/rulesetCheck.json
Normal file
62
pkg/cmd/ruleset/check/fixtures/rulesetCheck.json
Normal 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
|
||||
}
|
||||
]
|
||||
81
pkg/cmd/ruleset/list/fixtures/rulesetList.json
Normal file
81
pkg/cmd/ruleset/list/fixtures/rulesetList.json
Normal 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="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
170
pkg/cmd/ruleset/list/list.go
Normal file
170
pkg/cmd/ruleset/list/list.go
Normal 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()
|
||||
}
|
||||
295
pkg/cmd/ruleset/list/list_test.go
Normal file
295
pkg/cmd/ruleset/list/list_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
34
pkg/cmd/ruleset/ruleset.go
Normal file
34
pkg/cmd/ruleset/ruleset.go
Normal 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
|
||||
}
|
||||
133
pkg/cmd/ruleset/shared/http.go
Normal file
133
pkg/cmd/ruleset/shared/http.go
Normal 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
|
||||
}
|
||||
}}}`
|
||||
127
pkg/cmd/ruleset/shared/shared.go
Normal file
127
pkg/cmd/ruleset/shared/shared.go
Normal 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)
|
||||
}
|
||||
41
pkg/cmd/ruleset/view/fixtures/rulesetViewMultiple.json
Normal file
41
pkg/cmd/ruleset/view/fixtures/rulesetViewMultiple.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
58
pkg/cmd/ruleset/view/fixtures/rulesetViewOrg.json
Normal file
58
pkg/cmd/ruleset/view/fixtures/rulesetViewOrg.json
Normal 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": []
|
||||
}
|
||||
62
pkg/cmd/ruleset/view/fixtures/rulesetViewRepo.json
Normal file
62
pkg/cmd/ruleset/view/fixtures/rulesetViewRepo.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
32
pkg/cmd/ruleset/view/http.go
Normal file
32
pkg/cmd/ruleset/view/http.go
Normal 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
|
||||
}
|
||||
270
pkg/cmd/ruleset/view/view.go
Normal file
270
pkg/cmd/ruleset/view/view.go
Normal 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
|
||||
}
|
||||
364
pkg/cmd/ruleset/view/view_test.go
Normal file
364
pkg/cmd/ruleset/view/view_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue