From 7ad1d7c0a16be0585f018e4bfa170336d61b8c8e Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 16 Apr 2026 13:14:10 +0200 Subject: [PATCH 1/3] Suggest and install official extensions via stub commands Register hidden stub commands for official GitHub extensions (gh-aw, gh-stack) that offer to install the extension when invoked. This replaces the error-string-matching approach from the original PR with proper cobra commands that: - Avoid false-positive matches on flag values or post-'--' args - Eliminate conflicting cobra 'Did you mean?' suggestions - Properly propagate prompt/install errors for correct exit codes - Are hidden from help output and shell completions - Use GroupID "extension" so checkValidExtension allows installing over them - Are registered after extensions and aliases so both take priority Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/ghcmd/cmd.go | 53 -------- pkg/cmd/root/official_extension.go | 76 +++++++++++ pkg/cmd/root/official_extension_test.go | 168 ++++++++++++++++++++++++ pkg/cmd/root/root.go | 12 ++ pkg/extensions/official.go | 24 +--- pkg/extensions/official_test.go | 26 ---- 6 files changed, 261 insertions(+), 98 deletions(-) create mode 100644 pkg/cmd/root/official_extension.go create mode 100644 pkg/cmd/root/official_extension_test.go diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 3bf0f5f4a..8690078c6 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -14,18 +14,15 @@ import ( surveyCore "github.com/AlecAivazis/survey/v2/core" "github.com/AlecAivazis/survey/v2/terminal" - "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/agents" "github.com/cli/cli/v2/internal/build" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/config/migration" - "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/update" "github.com/cli/cli/v2/pkg/cmd/factory" "github.com/cli/cli/v2/pkg/cmd/root" "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/utils" "github.com/cli/safeexec" @@ -143,18 +140,6 @@ func Main() exitCode { return exitCode(extError.ExitCode()) } - // Check if any of the provided args match a known official extension. - // We scan all args rather than just the first because global flags - // (e.g. --repo) may precede the unknown command name. - if strings.HasPrefix(err.Error(), "unknown command ") { - for _, arg := range expandedArgs { - if ext := extensions.FindOfficialExtension(arg); ext != nil { - handleOfficialExtension(cmdFactory.IOStreams, cmdFactory.Prompter, cmdFactory.ExtensionManager, ext, err) - return exitError - } - } - } - printError(stderr, err, cmd, hasDebug) if strings.Contains(err.Error(), "Incorrect function") { @@ -260,41 +245,3 @@ func isUnderHomebrew(ghBinary string) bool { brewBinPrefix := filepath.Join(strings.TrimSpace(string(brewPrefixBytes)), "bin") + string(filepath.Separator) return strings.HasPrefix(ghBinary, brewBinPrefix) } - -// handleOfficialExtension prints a suggestion for the matched official extension -// and, in interactive TTY sessions, prompts the user to install it. -func handleOfficialExtension(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension, err error) { - stderr := io.ErrOut - - fmt.Fprintln(stderr, err) - - if !io.CanPrompt() { - fmt.Fprint(stderr, heredoc.Docf(` - %q is also available as an official extension. - To install it, run: - gh extension install github.com/%s/%s - `, fmt.Sprintf("gh %s", ext.Name), ext.Owner, ext.Repo)) - return - } - - prompt := heredoc.Docf(` - %q is also available as an official extension. - Would you like to install it now? - `, fmt.Sprintf("gh %s", ext.Name)) - confirmed, promptErr := p.Confirm(prompt, true) - if promptErr != nil || !confirmed { - return - } - - repo := ext.Repository() - io.StartProgressIndicatorWithLabel(fmt.Sprintf("Installing %s/%s...", ext.Owner, ext.Repo)) - defer io.StopProgressIndicator() - installErr := em.Install(repo, "") - io.StopProgressIndicator() - if installErr != nil { - fmt.Fprintf(stderr, "Failed to install extension: %s\n", installErr) - return - } - - fmt.Fprintf(stderr, "Successfully installed %s/%s\n", ext.Owner, ext.Repo) -} diff --git a/pkg/cmd/root/official_extension.go b/pkg/cmd/root/official_extension.go new file mode 100644 index 000000000..cde6bb6da --- /dev/null +++ b/pkg/cmd/root/official_extension.go @@ -0,0 +1,76 @@ +package root + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/extensions" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +// NewCmdOfficialExtension creates a hidden stub command for an official +// extension that has not yet been installed. When invoked, it suggests +// installing the extension and, in interactive sessions, offers to do so +// immediately. After a successful install, the extension is dispatched with +// the original arguments. +func NewCmdOfficialExtension(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension) *cobra.Command { + return &cobra.Command{ + Use: ext.Name, + Short: fmt.Sprintf("Install the official %s extension", ext.Name), + Hidden: true, + GroupID: "extension", + Annotations: map[string]string{ + "skipAuthCheck": "true", + }, + // Accept any args/flags the user may have passed so we don't get + // cobra validation errors before reaching RunE. + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + return officialExtensionRun(io, p, em, ext, args) + }, + } +} + +func officialExtensionRun(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension, args []string) error { + stderr := io.ErrOut + + if !io.CanPrompt() { + fmt.Fprint(stderr, heredoc.Docf(` + %[1]s is available as an official extension. + To install it, run: + gh extension install %[2]s/%[3]s + `, fmt.Sprintf("gh %s", ext.Name), ext.Owner, ext.Repo)) + return nil + } + + prompt := heredoc.Docf(` + %[1]s is available as an official extension. + Would you like to install it now? + `, fmt.Sprintf("gh %s", ext.Name)) + confirmed, err := p.Confirm(prompt, true) + if err != nil { + return err + } + if !confirmed { + return nil + } + + repo := ext.Repository() + io.StartProgressIndicatorWithLabel(fmt.Sprintf("Installing %s/%s...", ext.Owner, ext.Repo)) + installErr := em.Install(repo, "") + io.StopProgressIndicator() + if installErr != nil { + return fmt.Errorf("failed to install extension: %w", installErr) + } + + fmt.Fprintf(stderr, "Successfully installed %s/%s\n", ext.Owner, ext.Repo) + + // Dispatch the newly installed extension with the original arguments. + dispatchArgs := append([]string{ext.Name}, args...) + if _, dispatchErr := em.Dispatch(dispatchArgs, io.In, io.Out, stderr); dispatchErr != nil { + return dispatchErr + } + return nil +} diff --git a/pkg/cmd/root/official_extension_test.go b/pkg/cmd/root/official_extension_test.go new file mode 100644 index 000000000..8b6aaa3ae --- /dev/null +++ b/pkg/cmd/root/official_extension_test.go @@ -0,0 +1,168 @@ +package root + +import ( + "fmt" + "io" + "testing" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/extensions" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOfficialExtensionRun_NonTTY(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + // non-TTY by default + + ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} + em := &extensions.ExtensionManagerMock{} + p := &prompter.PrompterMock{} + + err := officialExtensionRun(ios, p, em, ext, nil) + require.NoError(t, err) + + assert.Contains(t, stderr.String(), "gh stack") + assert.Contains(t, stderr.String(), "gh extension install github/gh-stack") +} + +func TestOfficialExtensionRun_TTY_Confirmed(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} + var installedRepo ghrepo.Interface + var dispatchedArgs []string + em := &extensions.ExtensionManagerMock{ + InstallFunc: func(repo ghrepo.Interface, pin string) error { + installedRepo = repo + return nil + }, + DispatchFunc: func(args []string, stdin io.Reader, stdout, stderr io.Writer) (bool, error) { + dispatchedArgs = args + return true, nil + }, + } + p := &prompter.PrompterMock{ + ConfirmFunc: func(_ string, _ bool) (bool, error) { + return true, nil + }, + } + + err := officialExtensionRun(ios, p, em, ext, []string{"--help"}) + require.NoError(t, err) + + require.NotNil(t, installedRepo) + assert.Equal(t, "github", installedRepo.RepoOwner()) + assert.Equal(t, "gh-stack", installedRepo.RepoName()) + assert.Equal(t, "github.com", installedRepo.RepoHost()) + assert.Contains(t, stderr.String(), "Successfully installed github/gh-stack") + assert.Equal(t, []string{"stack", "--help"}, dispatchedArgs) +} + +func TestOfficialExtensionRun_TTY_Declined(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} + em := &extensions.ExtensionManagerMock{} + p := &prompter.PrompterMock{ + ConfirmFunc: func(_ string, _ bool) (bool, error) { + return false, nil + }, + } + + err := officialExtensionRun(ios, p, em, ext, nil) + require.NoError(t, err) + + assert.Empty(t, em.InstallCalls()) +} + +func TestOfficialExtensionRun_TTY_PromptError(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} + em := &extensions.ExtensionManagerMock{} + p := &prompter.PrompterMock{ + ConfirmFunc: func(_ string, _ bool) (bool, error) { + return false, fmt.Errorf("prompt interrupted") + }, + } + + err := officialExtensionRun(ios, p, em, ext, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "prompt interrupted") +} + +func TestOfficialExtensionRun_TTY_InstallError(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} + em := &extensions.ExtensionManagerMock{ + InstallFunc: func(_ ghrepo.Interface, _ string) error { + return fmt.Errorf("network error") + }, + } + p := &prompter.PrompterMock{ + ConfirmFunc: func(_ string, _ bool) (bool, error) { + return true, nil + }, + } + + err := officialExtensionRun(ios, p, em, ext, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "network error") +} + +func TestOfficialExtensionRun_TTY_DispatchError(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} + em := &extensions.ExtensionManagerMock{ + InstallFunc: func(_ ghrepo.Interface, _ string) error { + return nil + }, + DispatchFunc: func(_ []string, _ io.Reader, _, _ io.Writer) (bool, error) { + return false, fmt.Errorf("dispatch failed") + }, + } + p := &prompter.PrompterMock{ + ConfirmFunc: func(_ string, _ bool) (bool, error) { + return true, nil + }, + } + + err := officialExtensionRun(ios, p, em, ext, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "dispatch failed") +} + +func TestNewCmdOfficialExtension_Properties(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} + em := &extensions.ExtensionManagerMock{} + p := &prompter.PrompterMock{} + + cmd := NewCmdOfficialExtension(ios, p, em, ext) + + assert.Equal(t, "stack", cmd.Use) + assert.True(t, cmd.Hidden) + assert.Equal(t, "extension", cmd.GroupID) + assert.True(t, cmd.DisableFlagParsing) + assert.Equal(t, "true", cmd.Annotations["skipAuthCheck"]) +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index ed33f568e..793b3bc83 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -44,6 +44,7 @@ import ( versionCmd "github.com/cli/cli/v2/pkg/cmd/version" workflowCmd "github.com/cli/cli/v2/pkg/cmd/workflow" "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/extensions" "github.com/google/shlex" "github.com/spf13/cobra" ) @@ -229,6 +230,17 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, } } + // Official extension stubs — hidden commands that suggest installing + // GitHub-owned extensions when invoked. Registered after real extensions + // and aliases so that both take priority over stubs. + for i := range extensions.OfficialExtensions { + ext := &extensions.OfficialExtensions[i] + if _, _, err := cmd.Find([]string{ext.Name}); err == nil { + continue + } + cmd.AddCommand(NewCmdOfficialExtension(io, f.Prompter, em, ext)) + } + cmdutil.DisableAuthCheck(cmd) // The reference command produces paged output that displays information on every other command. diff --git a/pkg/extensions/official.go b/pkg/extensions/official.go index a1e6996db..a07c426df 100644 --- a/pkg/extensions/official.go +++ b/pkg/extensions/official.go @@ -12,29 +12,15 @@ type OfficialExtension struct { Repo string } -// Repository returns a ghrepo.Interface pinned to github.com for use with -// ExtensionManager.Install. +// Repository returns a ghrepo.Interface pinned to github.com so that GHES +// users install from github.com rather than their enterprise host. func (e *OfficialExtension) Repository() ghrepo.Interface { return ghrepo.NewWithHost(e.Owner, e.Repo, "github.com") } -// officialExtensions is the hard-coded registry of GitHub-owned extensions -// that gh will suggest installing when the user invokes an unknown command -// matching one of their names. -// Install suggestions include the "github.com/" host prefix so that GHES users -// install from github.com rather than their enterprise host. -var officialExtensions = []OfficialExtension{ +// OfficialExtensions is the registry of GitHub-owned extensions that gh will +// offer to install when the user invokes the corresponding command name. +var OfficialExtensions = []OfficialExtension{ {Name: "aw", Owner: "github", Repo: "gh-aw"}, {Name: "stack", Owner: "github", Repo: "gh-stack"}, } - -// FindOfficialExtension returns the matching official extension for -// commandName, or nil if none matches. -func FindOfficialExtension(commandName string) *OfficialExtension { - for _, ext := range officialExtensions { - if ext.Name == commandName { - return &ext - } - } - return nil -} diff --git a/pkg/extensions/official_test.go b/pkg/extensions/official_test.go index 0a0b5ec52..047af580a 100644 --- a/pkg/extensions/official_test.go +++ b/pkg/extensions/official_test.go @@ -4,34 +4,8 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestFindOfficialExtension(t *testing.T) { - tests := []struct { - name string - commandName string - wantNil bool - wantRepo string - }{ - {name: "found", commandName: "stack", wantNil: false, wantRepo: "gh-stack"}, - {name: "not found", commandName: "xyzzy", wantNil: true}, - {name: "empty", commandName: "", wantNil: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ext := FindOfficialExtension(tt.commandName) - if tt.wantNil { - assert.Nil(t, ext) - } else { - require.NotNil(t, ext) - assert.Equal(t, tt.wantRepo, ext.Repo) - } - }) - } -} - func TestOfficialExtension_Repository(t *testing.T) { ext := &OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} repo := ext.Repository() From 9596f99e565fa76a087f00c605bf93963ffb27d6 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 16 Apr 2026 18:09:16 +0200 Subject: [PATCH 2/3] Add no em dash rule to AGENTS.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index b04e6b775..a9e3ab109 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,6 +135,7 @@ for _, tt := range tests { - Add godoc comments to all exported functions, types, and constants - Avoid unnecessary code comments — only comment when the *why* isn't obvious from the code - Do not comment just to restate what the code does +- Never use em dashes (—) in code, comments, or documentation; use regular dashes (-) or rewrite the sentence instead ## Error Handling From abc2a50b4c00ea1ef93907214b909f16306c9308 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 16 Apr 2026 18:11:28 +0200 Subject: [PATCH 3/3] Address PR review feedback - Use cmdutil.DisableAuthCheck instead of raw annotation map - Convert tests to table-driven format - Use generic extension name in tests (gh-cool) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/root/official_extension.go | 10 +- pkg/cmd/root/official_extension_test.go | 241 +++++++++++------------- 2 files changed, 117 insertions(+), 134 deletions(-) diff --git a/pkg/cmd/root/official_extension.go b/pkg/cmd/root/official_extension.go index cde6bb6da..bc1f21893 100644 --- a/pkg/cmd/root/official_extension.go +++ b/pkg/cmd/root/official_extension.go @@ -5,6 +5,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -16,14 +17,11 @@ import ( // immediately. After a successful install, the extension is dispatched with // the original arguments. func NewCmdOfficialExtension(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension) *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: ext.Name, Short: fmt.Sprintf("Install the official %s extension", ext.Name), Hidden: true, GroupID: "extension", - Annotations: map[string]string{ - "skipAuthCheck": "true", - }, // Accept any args/flags the user may have passed so we don't get // cobra validation errors before reaching RunE. DisableFlagParsing: true, @@ -31,6 +29,10 @@ func NewCmdOfficialExtension(io *iostreams.IOStreams, p prompter.Prompter, em ex return officialExtensionRun(io, p, em, ext, args) }, } + + cmdutil.DisableAuthCheck(cmd) + + return cmd } func officialExtensionRun(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension, args []string) error { diff --git a/pkg/cmd/root/official_extension_test.go b/pkg/cmd/root/official_extension_test.go index 8b6aaa3ae..6986c4cb5 100644 --- a/pkg/cmd/root/official_extension_test.go +++ b/pkg/cmd/root/official_extension_test.go @@ -13,156 +13,137 @@ import ( "github.com/stretchr/testify/require" ) -func TestOfficialExtensionRun_NonTTY(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - // non-TTY by default +func TestOfficialExtensionRun(t *testing.T) { + ext := &extensions.OfficialExtension{Name: "cool", Owner: "github", Repo: "gh-cool"} - ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} - em := &extensions.ExtensionManagerMock{} - p := &prompter.PrompterMock{} - - err := officialExtensionRun(ios, p, em, ext, nil) - require.NoError(t, err) - - assert.Contains(t, stderr.String(), "gh stack") - assert.Contains(t, stderr.String(), "gh extension install github/gh-stack") -} - -func TestOfficialExtensionRun_TTY_Confirmed(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - ios.SetStdinTTY(true) - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - - ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} - var installedRepo ghrepo.Interface - var dispatchedArgs []string - em := &extensions.ExtensionManagerMock{ - InstallFunc: func(repo ghrepo.Interface, pin string) error { - installedRepo = repo - return nil + tests := []struct { + name string + isTTY bool + confirmResult bool + confirmErr error + installErr error + dispatchErr error + args []string + wantErr string + wantStderr string + wantInstalled bool + wantDispatched bool + wantDispArgs []string + }{ + { + name: "non-TTY prints install instructions", + isTTY: false, + wantStderr: "gh extension install github/gh-cool", }, - DispatchFunc: func(args []string, stdin io.Reader, stdout, stderr io.Writer) (bool, error) { - dispatchedArgs = args - return true, nil + { + name: "TTY confirmed installs and dispatches", + isTTY: true, + confirmResult: true, + args: []string{"--help"}, + wantStderr: "Successfully installed github/gh-cool", + wantInstalled: true, + wantDispatched: true, + wantDispArgs: []string{"cool", "--help"}, }, - } - p := &prompter.PrompterMock{ - ConfirmFunc: func(_ string, _ bool) (bool, error) { - return true, nil + { + name: "TTY declined does not install", + isTTY: true, + confirmResult: false, + }, + { + name: "TTY prompt error is propagated", + isTTY: true, + confirmErr: fmt.Errorf("prompt interrupted"), + wantErr: "prompt interrupted", + }, + { + name: "TTY install error is propagated", + isTTY: true, + confirmResult: true, + installErr: fmt.Errorf("network error"), + wantErr: "network error", + wantInstalled: true, + }, + { + name: "TTY dispatch error is propagated", + isTTY: true, + confirmResult: true, + dispatchErr: fmt.Errorf("dispatch failed"), + wantErr: "dispatch failed", + wantInstalled: true, + wantDispatched: true, }, } - err := officialExtensionRun(ios, p, em, ext, []string{"--help"}) - require.NoError(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + if tt.isTTY { + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + } - require.NotNil(t, installedRepo) - assert.Equal(t, "github", installedRepo.RepoOwner()) - assert.Equal(t, "gh-stack", installedRepo.RepoName()) - assert.Equal(t, "github.com", installedRepo.RepoHost()) - assert.Contains(t, stderr.String(), "Successfully installed github/gh-stack") - assert.Equal(t, []string{"stack", "--help"}, dispatchedArgs) -} + em := &extensions.ExtensionManagerMock{ + InstallFunc: func(_ ghrepo.Interface, _ string) error { + return tt.installErr + }, + DispatchFunc: func(_ []string, _ io.Reader, _, _ io.Writer) (bool, error) { + if tt.dispatchErr != nil { + return false, tt.dispatchErr + } + return true, nil + }, + } + p := &prompter.PrompterMock{ + ConfirmFunc: func(_ string, _ bool) (bool, error) { + return tt.confirmResult, tt.confirmErr + }, + } -func TestOfficialExtensionRun_TTY_Declined(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdinTTY(true) - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) + err := officialExtensionRun(ios, p, em, ext, tt.args) - ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} - em := &extensions.ExtensionManagerMock{} - p := &prompter.PrompterMock{ - ConfirmFunc: func(_ string, _ bool) (bool, error) { - return false, nil - }, + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + + if tt.wantInstalled { + require.NotEmpty(t, em.InstallCalls()) + repo := em.InstallCalls()[0].InterfaceMoqParam + assert.Equal(t, "github", repo.RepoOwner()) + assert.Equal(t, "gh-cool", repo.RepoName()) + assert.Equal(t, "github.com", repo.RepoHost()) + } else if tt.isTTY && !tt.confirmResult && tt.confirmErr == nil { + assert.Empty(t, em.InstallCalls()) + } + + if tt.wantDispatched { + require.NotEmpty(t, em.DispatchCalls()) + if tt.wantDispArgs != nil { + assert.Equal(t, tt.wantDispArgs, em.DispatchCalls()[0].Args) + } + } + }) } - - err := officialExtensionRun(ios, p, em, ext, nil) - require.NoError(t, err) - - assert.Empty(t, em.InstallCalls()) -} - -func TestOfficialExtensionRun_TTY_PromptError(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdinTTY(true) - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - - ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} - em := &extensions.ExtensionManagerMock{} - p := &prompter.PrompterMock{ - ConfirmFunc: func(_ string, _ bool) (bool, error) { - return false, fmt.Errorf("prompt interrupted") - }, - } - - err := officialExtensionRun(ios, p, em, ext, nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "prompt interrupted") -} - -func TestOfficialExtensionRun_TTY_InstallError(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdinTTY(true) - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - - ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} - em := &extensions.ExtensionManagerMock{ - InstallFunc: func(_ ghrepo.Interface, _ string) error { - return fmt.Errorf("network error") - }, - } - p := &prompter.PrompterMock{ - ConfirmFunc: func(_ string, _ bool) (bool, error) { - return true, nil - }, - } - - err := officialExtensionRun(ios, p, em, ext, nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "network error") -} - -func TestOfficialExtensionRun_TTY_DispatchError(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdinTTY(true) - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - - ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} - em := &extensions.ExtensionManagerMock{ - InstallFunc: func(_ ghrepo.Interface, _ string) error { - return nil - }, - DispatchFunc: func(_ []string, _ io.Reader, _, _ io.Writer) (bool, error) { - return false, fmt.Errorf("dispatch failed") - }, - } - p := &prompter.PrompterMock{ - ConfirmFunc: func(_ string, _ bool) (bool, error) { - return true, nil - }, - } - - err := officialExtensionRun(ios, p, em, ext, nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "dispatch failed") } func TestNewCmdOfficialExtension_Properties(t *testing.T) { ios, _, _, _ := iostreams.Test() - ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} + ext := &extensions.OfficialExtension{Name: "cool", Owner: "github", Repo: "gh-cool"} em := &extensions.ExtensionManagerMock{} p := &prompter.PrompterMock{} cmd := NewCmdOfficialExtension(ios, p, em, ext) - assert.Equal(t, "stack", cmd.Use) + assert.Equal(t, "cool", cmd.Use) assert.True(t, cmd.Hidden) assert.Equal(t, "extension", cmd.GroupID) assert.True(t, cmd.DisableFlagParsing) - assert.Equal(t, "true", cmd.Annotations["skipAuthCheck"]) }