Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions pkg/cmd/preview/preview.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package preview

import (
"github.com/MakeNowJust/heredoc"
cmdPrompter "github.com/cli/cli/v2/pkg/cmd/preview/prompter"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/spf13/cobra"
)

func NewCmdPreview(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "preview <command>",
Short: "Execute previews for gh features",
Long: heredoc.Doc(`
Preview commands are for testing, demonstrative, and development purposes only.
They should be considered unstable and can change at any time.
`),
}

cmdutil.DisableAuthCheck(cmd)

cmd.AddCommand(cmdPrompter.NewCmdPrompter(f, nil))

return cmd
}
236 changes: 236 additions & 0 deletions pkg/cmd/preview/prompter/prompter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
package prompter

import (
"fmt"

"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/internal/prompter"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
)

type prompterOptions struct {
IO *iostreams.IOStreams
Config func() (gh.Config, error)

PromptsToRun []func(prompter.Prompter, *iostreams.IOStreams) error
}

func NewCmdPrompter(f *cmdutil.Factory, runF func(*prompterOptions) error) *cobra.Command {
opts := &prompterOptions{
IO: f.IOStreams,
Config: f.Config,
}

const (
selectPrompt = "select"
multiSelectPrompt = "multi-select"
inputPrompt = "input"
passwordPrompt = "password"
confirmPrompt = "confirm"
authTokenPrompt = "auth-token"
confirmDeletionPrompt = "confirm-deletion"
inputHostnamePrompt = "input-hostname"
markdownEditorPrompt = "markdown-editor"
)

prompterTypeFuncMap := map[string]func(prompter.Prompter, *iostreams.IOStreams) error{
selectPrompt: runSelect,
multiSelectPrompt: runMultiSelect,
inputPrompt: runInput,
passwordPrompt: runPassword,
confirmPrompt: runConfirm,
authTokenPrompt: runAuthToken,
confirmDeletionPrompt: runConfirmDeletion,
inputHostnamePrompt: runInputHostname,
markdownEditorPrompt: runMarkdownEditor,
}

allPromptsOrder := []string{
selectPrompt,
multiSelectPrompt,
inputPrompt,
passwordPrompt,
confirmPrompt,
authTokenPrompt,
confirmDeletionPrompt,
inputHostnamePrompt,
markdownEditorPrompt,
}

cmd := &cobra.Command{
Use: "prompter [prompt type]",
Short: "Execute a test program to preview the prompter",
Long: heredoc.Doc(`
Execute a test program to preview the prompter.
Without an argument, all prompts will be run.

Available prompt types:
- select
- multi-select
- input
- password
- confirm
- auth-token
- confirm-deletion
- input-hostname
- markdown-editor
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}

if len(args) == 0 {
// All prompts, in a fixed order
for _, promptType := range allPromptsOrder {
f := prompterTypeFuncMap[promptType]
opts.PromptsToRun = append(opts.PromptsToRun, f)
}
} else {
// Only the one specified
for _, arg := range args {
f, ok := prompterTypeFuncMap[arg]
if !ok {
return fmt.Errorf("unknown prompter type: %q", arg)
}
opts.PromptsToRun = append(opts.PromptsToRun, f)
}
}

return prompterRun(opts)
},
}

return cmd
}

func prompterRun(opts *prompterOptions) error {
editor, err := cmdutil.DetermineEditor(opts.Config)
if err != nil {
return err
}

p := prompter.New(editor, opts.IO)

for _, f := range opts.PromptsToRun {
if err := f(p, opts.IO); err != nil {
return err
}
}

return nil
}

func runSelect(p prompter.Prompter, io *iostreams.IOStreams) error {
fmt.Fprintln(io.Out, "Demonstrating Single Select")
cuisines := []string{"Italian", "Greek", "Indian", "Japanese", "American"}
favorite, err := p.Select("Favorite cuisine?", "Italian", cuisines)
if err != nil {
return err
}
fmt.Fprintf(io.Out, "Favorite cuisine: %s\n", cuisines[favorite])
return nil
}

func runMultiSelect(p prompter.Prompter, io *iostreams.IOStreams) error {
fmt.Fprintln(io.Out, "Demonstrating Multi Select")
cuisines := []string{"Italian", "Greek", "Indian", "Japanese", "American"}
favorites, err := p.MultiSelect("Favorite cuisines?", []string{}, cuisines)
if err != nil {
return err
}
for _, f := range favorites {
fmt.Fprintf(io.Out, "Favorite cuisine: %s\n", cuisines[f])
}
return nil
}

func runInput(p prompter.Prompter, io *iostreams.IOStreams) error {
fmt.Fprintln(io.Out, "Demonstrating Text Input")
text, err := p.Input("Favorite meal?", "Breakfast")
if err != nil {
return err
}
fmt.Fprintf(io.Out, "You typed: %s\n", text)
return nil
}

func runPassword(p prompter.Prompter, io *iostreams.IOStreams) error {
fmt.Fprintln(io.Out, "Demonstrating Password Input")
safeword, err := p.Password("Safe word?")
if err != nil {
return err
}
fmt.Fprintf(io.Out, "Safe word: %s\n", safeword)
return nil
}

func runConfirm(p prompter.Prompter, io *iostreams.IOStreams) error {
fmt.Fprintln(io.Out, "Demonstrating Confirmation")
confirmation, err := p.Confirm("Are you sure?", true)
if err != nil {
return err
}
fmt.Fprintf(io.Out, "Confirmation: %t\n", confirmation)
return nil
}

func runAuthToken(p prompter.Prompter, io *iostreams.IOStreams) error {
fmt.Fprintln(io.Out, "Demonstrating Auth Token (can't be blank)")
token, err := p.AuthToken()
if err != nil {
return err
}
fmt.Fprintf(io.Out, "Auth token: %s\n", token)
return nil
}

func runConfirmDeletion(p prompter.Prompter, io *iostreams.IOStreams) error {
fmt.Fprintln(io.Out, "Demonstrating Deletion Confirmation")
err := p.ConfirmDeletion("delete-me")
if err != nil {
return err
}
fmt.Fprintln(io.Out, "Item deleted")
return nil
}

func runInputHostname(p prompter.Prompter, io *iostreams.IOStreams) error {
fmt.Fprintln(io.Out, "Demonstrating Hostname")
hostname, err := p.InputHostname()
if err != nil {
return err
}
fmt.Fprintf(io.Out, "Hostname: %s\n", hostname)
return nil
}

func runMarkdownEditor(p prompter.Prompter, io *iostreams.IOStreams) error {
defaultText := "default text value"

fmt.Fprintln(io.Out, "Demonstrating Markdown Editor with blanks allowed and default text")
editorText, err := p.MarkdownEditor("Edit your text:", defaultText, true)
if err != nil {
return err
}
fmt.Fprintf(io.Out, "Returned text: %s\n\n", editorText)

fmt.Fprintln(io.Out, "Demonstrating Markdown Editor with blanks disallowed and default text")
editorText2, err := p.MarkdownEditor("Edit your text:", defaultText, false)
if err != nil {
return err
}
fmt.Fprintf(io.Out, "Returned text: %s\n\n", editorText2)

fmt.Fprintln(io.Out, "Demonstrating Markdown Editor with blanks disallowed and no default text")
editorText3, err := p.MarkdownEditor("Edit your text:", "", false)
if err != nil {
return err
}
fmt.Fprintf(io.Out, "Returned text: %s\n", editorText3)
return nil
}
2 changes: 2 additions & 0 deletions pkg/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
labelCmd "github.com/cli/cli/v2/pkg/cmd/label"
orgCmd "github.com/cli/cli/v2/pkg/cmd/org"
prCmd "github.com/cli/cli/v2/pkg/cmd/pr"
previewCmd "github.com/cli/cli/v2/pkg/cmd/preview"
projectCmd "github.com/cli/cli/v2/pkg/cmd/project"
releaseCmd "github.com/cli/cli/v2/pkg/cmd/release"
repoCmd "github.com/cli/cli/v2/pkg/cmd/repo"
Expand Down Expand Up @@ -141,6 +142,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command,
cmd.AddCommand(statusCmd.NewCmdStatus(f, nil))
cmd.AddCommand(codespaceCmd.NewCmdCodespace(f))
cmd.AddCommand(projectCmd.NewCmdProject(f))
cmd.AddCommand(previewCmd.NewCmdPreview(f))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Should there be a separate section for previews that shows each of the commands within?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think I follow; are you talking about adding some whitespace here to separate cmd.AddCommand(previewCmd.NewCmdPreview(f)) from other commands?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the confusion and the delay; let me elaborate.

Within the root command, the command groups created are turned into separate sections of the gh --help output:

cli/pkg/cmd/root/root.go

Lines 111 to 122 in 7ceca45

cmd.AddGroup(&cobra.Group{
ID: "core",
Title: "Core commands",
})
cmd.AddGroup(&cobra.Group{
ID: "actions",
Title: "GitHub Actions commands",
})
cmd.AddGroup(&cobra.Group{
ID: "extension",
Title: "Extension commands",
})

Expand for gh --help example

$ gh --help
Work seamlessly with GitHub from the command line.

USAGE
  gh <command> <subcommand> [flags]

CORE COMMANDS
  auth:          Authenticate gh and git with GitHub
  browse:        Open repositories, issues, pull requests, and more in the browser
  codespace:     Connect to and manage codespaces
  gist:          Manage gists
  issue:         Manage issues
  org:           Manage organizations
  pr:            Manage pull requests
  project:       Work with GitHub Projects.
  release:       Manage releases
  repo:          Manage repositories

GITHUB ACTIONS COMMANDS
  cache:         Manage GitHub Actions caches
  run:           View details about workflow runs
  workflow:      View details about GitHub Actions workflows

EXTENSION COMMANDS
  XXXXXXX:       Extension xxxx
  XXXXXXXXXX:    Extension xxxx
  ...

ALIAS COMMANDS
  xxxxxx:        Alias for "xxx"
  ...

ADDITIONAL COMMANDS
  alias:         Create command shortcuts
  api:           Make an authenticated GitHub API request
  attestation:   Work with artifact attestations
  completion:    Generate shell completion scripts
  config:        Manage configuration for gh
  extension:     Manage gh extensions
  gpg-key:       Manage GPG keys
  label:         Manage labels
  ruleset:       View info about repo rulesets
  search:        Search for repositories, issues, and pull requests
  secret:        Manage GitHub secrets
  ssh-key:       Manage SSH keys
  status:        Print information about relevant issues, pull requests, and notifications across repositories
  variable:      Manage GitHub Actions variables

This is handled deep in the bowels of gh:

cli/pkg/cmd/root/help.go

Lines 136 to 151 in 7ceca45

// Statically calculated padding for non-extension commands,
// longest is `gh accessibility` with 13 characters + 1 space.
//
// Should consider novel way to calculate this in the future [AF]
namePadding := 14
for _, g := range GroupedCommands(command) {
var names []string
for _, c := range g.Commands {
names = append(names, rpad(c.Name()+":", namePadding)+c.Short)
}
helpEntries = append(helpEntries, helpEntry{
Title: strings.ToUpper(g.Title),
Body: strings.Join(names, "\n"),
})
}

So, the suggestion was whether we should have a separate command group for preview commands or not.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PREVIEW COMMANDS
  prompter:         Execute a test program to preview the prompter

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point actually. I like the separate group.

@BagToad BagToad May 10, 2025 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I got to implementing this and realized all the commands in gh --help are top level commands.

Like gh run and gh workflow, for example.

But in this case, only preview is our top-level command.

Referring back to the suggested output:

PREVIEW COMMANDS
  prompter:         Execute a test program to preview the prompter

Is this confusing that, for only the preview command group, none of the commands listed under the group are top-level commands? Are users going to understand that you have to run gh preview prompter and not gh prompter?

WDYT?

Maybe we can merge this PR without this, and then decide later how we'd want that to look?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😶 😬

PREVIEW COMMANDS
  preview prompter:         Execute a test program to preview the prompter

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is a fair point about users potentially being confused / missing these commands need to be gh preview.

I agree we should move forward with this PR as-is and revisit as we see how preview experiment proceeds.

Currently, preview will show up under ADDITIONAL COMMANDS section:

$ bin/gh help
Work seamlessly with GitHub from the command line.

USAGE
  gh <command> <subcommand> [flags]

CORE COMMANDS
  ...

GITHUB ACTIONS COMMANDS
  ...

EXTENSION COMMANDS
  ...

ALIAS COMMANDS
  ...

ADDITIONAL COMMANDS
  ...
  preview:       Execute previews for gh features


// below here at the commands that require the "intelligent" BaseRepo resolver
repoResolvingCmdFactory := *f
Expand Down