From 7f81645c785d7eaedd298fe5916b8a1ecb665542 Mon Sep 17 00:00:00 2001 From: vaindil Date: Tue, 25 Apr 2023 16:42:36 -0400 Subject: [PATCH] basic ruleset view works --- pkg/cmd/ruleset/list/http.go | 66 ++++----- pkg/cmd/ruleset/list/list.go | 9 +- pkg/cmd/ruleset/ruleset.go | 3 +- pkg/cmd/ruleset/shared/shared.go | 28 ++++ pkg/cmd/ruleset/view/http.go | 32 +++++ pkg/cmd/ruleset/view/view.go | 227 +++++++++++++++++++++++++++++++ 6 files changed, 319 insertions(+), 46 deletions(-) create mode 100644 pkg/cmd/ruleset/shared/shared.go create mode 100644 pkg/cmd/ruleset/view/http.go create mode 100644 pkg/cmd/ruleset/view/view.go diff --git a/pkg/cmd/ruleset/list/http.go b/pkg/cmd/ruleset/list/http.go index 10be1f57d..592bdeda5 100644 --- a/pkg/cmd/ruleset/list/http.go +++ b/pkg/cmd/ruleset/list/http.go @@ -1,17 +1,19 @@ package list 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" ) type RulesetResponse struct { Level struct { Rulesets struct { TotalCount int - Nodes []Ruleset + Nodes []shared.Ruleset PageInfo struct { HasNextPage bool EndCursor string @@ -20,26 +22,9 @@ type RulesetResponse struct { } } -type Ruleset struct { - DatabaseId int - Name string - Target string - Enforcement string - Conditions struct { - RefName struct { - Include []string - Exclude []string - } - RepositoryName struct { - Include []string - Exclude []string - } - } -} - type RulesetList struct { TotalCount int - Rulesets []Ruleset + Rulesets []shared.Ruleset } func listRepoRulesets(httpClient *http.Client, repo ghrepo.Interface, limit int) (*RulesetList, error) { @@ -48,7 +33,7 @@ func listRepoRulesets(httpClient *http.Client, repo ghrepo.Interface, limit int) "repo": repo.RepoName(), } - return listRulesets(httpClient, rulesetQuery(false), variables, limit, repo.RepoHost()) + return listRulesets(httpClient, rulesetsQuery(false), variables, limit, repo.RepoHost()) } func listOrgRulesets(httpClient *http.Client, orgLogin string, limit int, host string) (*RulesetList, error) { @@ -56,14 +41,14 @@ func listOrgRulesets(httpClient *http.Client, orgLogin string, limit int, host s "login": orgLogin, } - return listRulesets(httpClient, rulesetQuery(true), variables, limit, host) + 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: []Ruleset{}, + Rulesets: []shared.Ruleset{}, } client := api.NewClientFromHTTP(httpClient) @@ -93,7 +78,7 @@ func listRulesets(httpClient *http.Client, query string, variables map[string]in return &res, nil } -func rulesetQuery(org bool) string { +func rulesetsQuery(org bool) string { var args string var level string @@ -105,28 +90,28 @@ func rulesetQuery(org bool) string { level = "repository(owner: $owner, name: $repo)" } - str := "query RulesetList($limit: Int!, $endCursor: String, " + args + ") { level: " + level + " {" + str := fmt.Sprintf("query RulesetList($limit: Int!, $endCursor: String, %s) { level: %s {", args, level) - str += ` + return str + ` rulesets(first: $limit, after: $endCursor) { totalCount nodes { - databaseId + id: databaseId name target enforcement - conditions { - refName { - include - exclude - } - repositoryName { - include - exclude - protected - } - } - rules { + # conditions { + # refName { + # include + # exclude + # } + # repositoryName { + # include + # exclude + # protected + # } + # } + rulesGql: rules { totalCount } } @@ -134,9 +119,8 @@ func rulesetQuery(org bool) string { hasNextPage endCursor } - }` - - return str + "}}" + } + }}` } func min(a, b int) int { diff --git a/pkg/cmd/ruleset/list/list.go b/pkg/cmd/ruleset/list/list.go index 4b30fabe2..2c9835124 100644 --- a/pkg/cmd/ruleset/list/list.go +++ b/pkg/cmd/ruleset/list/list.go @@ -61,9 +61,9 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman }, } - cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of rules to list") - cmd.Flags().StringVarP(&opts.Organization, "org", "o", "", "List organization-wide rules") - cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "List rules in the web browser") + 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") + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "List rulesets in the web browser") return cmd } @@ -125,6 +125,7 @@ func listRun(opts *ListOptions) error { return cmdutil.NewNoResultsError(msg) } + opts.IO.DetectTerminalTheme() if err := opts.IO.StartPager(); err == nil { defer opts.IO.StopPager() } else { @@ -141,7 +142,7 @@ func listRun(opts *ListOptions) error { tp.HeaderRow("ID", "NAME", "STATUS", "TARGET") for _, rs := range result.Rulesets { - tp.AddField(strconv.Itoa(rs.DatabaseId)) + tp.AddField(strconv.Itoa(rs.Id)) tp.AddField(rs.Name, tableprinter.WithColor(cs.Bold)) tp.AddField(strings.ToLower(rs.Enforcement)) tp.AddField(strings.ToLower(rs.Target)) diff --git a/pkg/cmd/ruleset/ruleset.go b/pkg/cmd/ruleset/ruleset.go index f7d7799b6..64536e868 100644 --- a/pkg/cmd/ruleset/ruleset.go +++ b/pkg/cmd/ruleset/ruleset.go @@ -4,6 +4,7 @@ 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" ) @@ -21,7 +22,7 @@ func NewCmdRuleset(f *cmdutil.Factory) *cobra.Command { cmdutil.EnableRepoOverride(cmd, f) cmd.AddCommand(cmdList.NewCmdList(f, nil)) - // cmd.AddCommand(cmdList.NewCmdView(f, nil)) + cmd.AddCommand(cmdView.NewCmdView(f, nil)) cmd.AddCommand(cmdCheck.NewCmdCheck(f, nil)) return cmd diff --git a/pkg/cmd/ruleset/shared/shared.go b/pkg/cmd/ruleset/shared/shared.go new file mode 100644 index 000000000..d226ad18c --- /dev/null +++ b/pkg/cmd/ruleset/shared/shared.go @@ -0,0 +1,28 @@ +package shared + +type Ruleset struct { + Id int + Name string + Target string + Enforcement string + BypassMode string `json:"bypass_mode"` + BypassActors []struct { + ActorId int `json:"actor_id"` + ActorType string `json:"actor_type"` + } `json:"bypass_actors"` + Conditions map[string]map[string]interface { + // RefName struct { + // Include []string + // Exclude []string + // } `json:"ref_name"` + // RepositoryName struct { + // Include []string + // Exclude []string + // Protected bool + // } `json:"repository_name"` + } + RulesGql struct { + TotalCount int + } + Rules []interface{} +} diff --git a/pkg/cmd/ruleset/view/http.go b/pkg/cmd/ruleset/view/http.go new file mode 100644 index 000000000..bd56c3cf6 --- /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.Ruleset, 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.Ruleset, 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.Ruleset, error) { + apiClient := api.NewClientFromHTTP(httpClient) + result := shared.Ruleset{} + + 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..49c74581c --- /dev/null +++ b/pkg/cmd/ruleset/view/view.go @@ -0,0 +1,227 @@ +package view + +import ( + "fmt" + "net/http" + "reflect" + "sort" + "strconv" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/browser" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/ghinstance" + "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 ViewOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + Config func() (config.Config, error) + BaseRepo func() (ghrepo.Interface, error) + Browser browser.Browser + + ID string + WebMode 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, + Config: f.Config, + } + + 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. + `), + Example: heredoc.Doc(` + # View a ruleset in the current repository + $ gh ruleset view 43 + + # View a ruleset in a different repository + $ 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 { + // 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] + } + + 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 ID ") + + 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 + } + + cfg, err := opts.Config() + if err != nil { + return err + } + + hostname, _ := cfg.DefaultHost() + + if opts.WebMode { + // TODO need to validate ruleset's existence before opening + var rulesetURL string + if opts.Organization != "" { + rulesetURL = fmt.Sprintf("%sorganizations/%s/settings/rules/%s", ghinstance.HostPrefix(hostname), opts.Organization, opts.ID) + } else { + rulesetURL = ghrepo.GenerateRepoURL(repoI, "settings/rules/%s", opts.ID) + } + + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", text.DisplayURL(rulesetURL)) + } + + return opts.Browser.Browse(rulesetURL) + } + + var rs *shared.Ruleset + 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 + } + + cs := opts.IO.ColorScheme() + w := opts.IO.Out + + fmt.Fprintf(w, "\n%s\n", cs.Bold(rs.Name)) + fmt.Fprintf(w, "ID: %d\n", rs.Id) + + 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 being enforced)")) + case "active": + fmt.Fprintf(w, "%s\n", cs.Green("Active")) + default: + fmt.Fprintf(w, "Enforcement: %s\n", rs.Enforcement) + } + + fmt.Fprintf(w, "\n%s\n", cs.Bold("Bypassing")) + fmt.Fprintf(w, "Mode: %s\n", rs.BypassMode) + if len(rs.BypassActors) == 0 { + fmt.Fprintf(w, "No actors configured for bypass\n") + } else { + types := make(map[string]int) + for _, t := range rs.BypassActors { + val, exists := types[t.ActorType] + if exists { + types[t.ActorType] = val + 1 + } else { + types[t.ActorType] = 1 + } + } + + fmt.Fprintf(w, "Actor types allowed to bypass:\n") + for name, count := range types { + fmt.Fprintf(w, "- %s: %d actors\n", name, count) + } + } + + 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, mismatched types don't allow this to be broken + // into a separate function + 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 { + rawVal := condition[n] + + k := reflect.TypeOf(rawVal).Kind() + if rawVal == nil || + ((k == reflect.Slice || k == reflect.Map) && len(rawVal.([]interface{})) == 0) { + continue + } + + printVal := fmt.Sprint(rawVal) + + // fmt.Fprintf(w, "n: %s, type: %s\n", n, reflect.TypeOf(rawVal).String()) + + // switch val := rawVal.(type) { + // case []interface{}: + // // currently only string arrays are returned by the API at this level + // printVal = fmt.Sprint(val) + // default: + // printVal = fmt.Sprint(val) + // } + + fmt.Fprintf(w, "[%s: %s] ", n, printVal) + } + + fmt.Fprint(w, "\n") + } + } + + fmt.Fprintf(w, "\n%s\n", cs.Bold("Rules")) + fmt.Fprintf(w, "%d configured\n", reflect.ValueOf(rs.Rules).Len()) + + return nil +}