diff --git a/pkg/cmd/ruleset/list/list.go b/pkg/cmd/ruleset/list/list.go index 6183adb6a..1b8a9cc00 100644 --- a/pkg/cmd/ruleset/list/list.go +++ b/pkg/cmd/ruleset/list/list.go @@ -137,20 +137,8 @@ func listRun(opts *ListOptions) error { return err } - var entityName string - if opts.Organization != "" { - entityName = opts.Organization - } else { - entityName = ghrepo.FullName(repoI) - } - if result.TotalCount == 0 { - parentsMsg := "" - if opts.IncludeParents { - parentsMsg = " or its parents" - } - msg := fmt.Sprintf("no rulesets found in %s%s", entityName, parentsMsg) - return cmdutil.NewNoResultsError(msg) + return shared.NoRulesetsFoundError(opts.Organization, repoI, opts.IncludeParents) } opts.IO.DetectTerminalTheme() @@ -163,7 +151,13 @@ func listRun(opts *ListOptions) error { cs := opts.IO.ColorScheme() if opts.IO.IsStdoutTTY() { - fmt.Fprintf(opts.IO.Out, "\nShowing %d of %d rulesets in %s\n\n", len(result.Rulesets), result.TotalCount, entityName) + 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) @@ -172,15 +166,7 @@ func listRun(opts *ListOptions) error { for _, rs := range result.Rulesets { tp.AddField(strconv.Itoa(rs.DatabaseId)) tp.AddField(rs.Name, tableprinter.WithColor(cs.Bold)) - var ownerString string - if rs.Source.RepoOwner != "" { - ownerString = fmt.Sprintf("%s (repo)", rs.Source.RepoOwner) - } else if rs.Source.OrgOwner != "" { - ownerString = fmt.Sprintf("%s (org)", rs.Source.OrgOwner) - } else { - ownerString = "(unknown)" - } - tp.AddField(ownerString) + tp.AddField(shared.RulesetSource(rs)) tp.AddField(strings.ToLower(rs.Enforcement)) tp.AddField(strings.ToLower(rs.Target)) tp.AddField(strconv.Itoa(rs.Rules.TotalCount)) diff --git a/pkg/cmd/ruleset/shared/http.go b/pkg/cmd/ruleset/shared/http.go index 4899369bf..05a8e3cf7 100644 --- a/pkg/cmd/ruleset/shared/http.go +++ b/pkg/cmd/ruleset/shared/http.go @@ -108,8 +108,9 @@ func rulesetsQuery(org bool) string { target enforcement source { - ... on Repository { repoOwner: nameWithOwner } - ... on Organization { orgOwner: login } + __typename + ... on Repository { owner: nameWithOwner } + ... on Organization { owner: login } } rules { totalCount diff --git a/pkg/cmd/ruleset/shared/shared.go b/pkg/cmd/ruleset/shared/shared.go index 090d3d989..682eafb38 100644 --- a/pkg/cmd/ruleset/shared/shared.go +++ b/pkg/cmd/ruleset/shared/shared.go @@ -1,13 +1,20 @@ package shared +import ( + "fmt" + + "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 { - RepoOwner string - OrgOwner string + TypeName string `json:"__typename"` + Owner string } Rules struct { TotalCount int @@ -30,3 +37,33 @@ type RulesetREST struct { Source string Rules []struct{} } + +// 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 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/view.go b/pkg/cmd/ruleset/view/view.go index fca7b17bf..0c642e838 100644 --- a/pkg/cmd/ruleset/view/view.go +++ b/pkg/cmd/ruleset/view/view.go @@ -6,12 +6,14 @@ import ( "reflect" "sort" "strconv" + "strings" "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/prompter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/ruleset/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -25,10 +27,13 @@ type ViewOptions struct { Config func() (config.Config, error) BaseRepo func() (ghrepo.Interface, error) Browser browser.Browser + Prompter prompter.Prompter - ID string - WebMode bool - Organization string + ID string + WebMode bool + IncludeParents bool + InteractiveMode bool + Organization string } func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { @@ -37,6 +42,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman HttpClient: f.HttpClient, Browser: f.Browser, Config: f.Config, + Prompter: f.Prompter, } cmd := &cobra.Command{ @@ -49,6 +55,9 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman the ruleset to view. `), Example: heredoc.Doc(` + # Interactively choose a ruleset to view + $ gh ruleset view + # View a ruleset in the current repository $ gh ruleset view 43 @@ -75,6 +84,10 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman 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 { @@ -86,6 +99,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman 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", false, "When choosing interactively, include rulesets configured at higher levels that also apply") return cmd } @@ -108,6 +122,38 @@ func viewRun(opts *ViewOptions) error { hostname, _ := cfg.DefaultHost() + 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) + 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) @@ -232,3 +278,25 @@ func viewRun(opts *ViewOptions) error { return nil } + +func selectRulesetID(rsList *shared.RulesetList, p prompter.Prompter) (*shared.RulesetGraphQL, error) { + rulesets := make([]string, len(rsList.Rulesets)) + for i, rs := range rsList.Rulesets { + s := fmt.Sprintf( + "%d: %s | %s | contains %s | configured in %s", + 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 +}