diff --git a/pkg/cmd/extension/browse/browse.go b/pkg/cmd/extension/browse/browse.go index 21d254956..3df92eae6 100644 --- a/pkg/cmd/extension/browse/browse.go +++ b/pkg/cmd/extension/browse/browse.go @@ -25,6 +25,7 @@ import ( const pagingOffset = 24 +// ExtBrowseOpts holds the options for the extension browse command. type ExtBrowseOpts struct { Cmd *cobra.Command Browser ibrowser @@ -63,6 +64,7 @@ type extEntry struct { description string } +// Title returns the display title for the extension entry including status indicators. func (e extEntry) Title() string { var installed string var official string @@ -78,6 +80,7 @@ func (e extEntry) Title() string { return fmt.Sprintf("%s%s%s", e.FullName, official, installed) } +// Description returns the extension description or a default placeholder. func (e extEntry) Description() string { if e.description == "" { return "no description provided" @@ -103,9 +106,14 @@ type wGroup interface { type fakeGroup struct{} +// Add is a no-op implementation of the wGroup interface. func (w *fakeGroup) Add(int) {} -func (w *fakeGroup) Done() {} -func (w *fakeGroup) Wait() {} + +// Done is a no-op implementation of the wGroup interface. +func (w *fakeGroup) Done() {} + +// Wait is a no-op implementation of the wGroup interface. +func (w *fakeGroup) Wait() {} func newExtList(opts ExtBrowseOpts, ui uiRegistry, extEntries []extEntry) *extList { ui.List.SetTitleColor(tcell.ColorWhite) @@ -219,10 +227,12 @@ func (el *extList) toggleSelected(verb string) { } } +// InstallSelected installs the currently highlighted extension. func (el *extList) InstallSelected() { el.toggleSelected("install") } +// RemoveSelected removes the currently highlighted extension. func (el *extList) RemoveSelected() { el.toggleSelected("remove") } @@ -233,15 +243,18 @@ func (el *extList) toggleInstalled(ix int) { el.extEntries[ix] = ee } +// Focus sets the application focus to the extension list. func (el *extList) Focus() { el.app.SetFocus(el.ui.List) } +// Refresh resets the list and reapplies the current filter. func (el *extList) Refresh() { el.Reset() el.Filter(el.filter) } +// Reset clears the list and repopulates it with all extension entries. func (el *extList) Reset() { el.ui.List.Clear() for _, ee := range el.extEntries { @@ -249,10 +262,12 @@ func (el *extList) Reset() { } } +// PageDown moves the list selection down by one page. func (el *extList) PageDown() { el.ui.List.SetCurrentItem(el.ui.List.GetCurrentItem() + pagingOffset) } +// PageUp moves the list selection up by one page. func (el *extList) PageUp() { i := el.ui.List.GetCurrentItem() - pagingOffset if i < 0 { @@ -261,10 +276,12 @@ func (el *extList) PageUp() { el.ui.List.SetCurrentItem(i) } +// ScrollDown moves the list selection down by one item. func (el *extList) ScrollDown() { el.ui.List.SetCurrentItem(el.ui.List.GetCurrentItem() + 1) } +// ScrollUp moves the list selection up by one item. func (el *extList) ScrollUp() { i := el.ui.List.GetCurrentItem() - 1 if i < 0 { @@ -273,6 +290,7 @@ func (el *extList) ScrollUp() { el.ui.List.SetCurrentItem(i) } +// FindSelected returns the currently selected extension entry and its index. func (el *extList) FindSelected() (extEntry, int) { if el.ui.List.GetItemCount() == 0 { return extEntry{}, -1 @@ -286,6 +304,7 @@ func (el *extList) FindSelected() (extEntry, int) { return extEntry{}, -1 } +// Filter narrows the displayed list to entries matching the given text. func (el *extList) Filter(text string) { el.filter = text if text == "" { @@ -377,6 +396,7 @@ func getExtensions(opts ExtBrowseOpts) ([]extEntry, error) { return extEntries, nil } +// ExtBrowse launches the interactive TUI for browsing and managing extensions. func ExtBrowse(opts ExtBrowseOpts) error { if opts.Debug { f, err := os.CreateTemp("", "extBrowse-*.txt") diff --git a/pkg/cmd/extension/browse/rg.go b/pkg/cmd/extension/browse/rg.go index 4884b1779..817befde2 100644 --- a/pkg/cmd/extension/browse/rg.go +++ b/pkg/cmd/extension/browse/rg.go @@ -20,6 +20,7 @@ func newReadmeGetter(client *http.Client, cacheTTL time.Duration) *readmeGetter } } +// Get fetches the README content for the given repository full name. func (g *readmeGetter) Get(repoFullName string) (string, error) { repo, err := ghrepo.FromFullName(repoFullName) if err != nil { diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 057f91140..d0fa21971 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -25,6 +25,7 @@ import ( var alreadyInstalledError = errors.New("alreadyInstalledError") +// NewCmdExtension creates the cobra command for managing gh extensions. func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { m := f.ExtensionManager io := f.IOStreams diff --git a/pkg/cmd/extension/extension.go b/pkg/cmd/extension/extension.go index f30bf63c1..06fcdf9aa 100644 --- a/pkg/cmd/extension/extension.go +++ b/pkg/cmd/extension/extension.go @@ -16,14 +16,19 @@ import ( const manifestName = "manifest.yml" +// ExtensionKind indicates the type of a gh CLI extension. type ExtensionKind int const ( + // GitKind represents a git-based extension. GitKind ExtensionKind = iota + // BinaryKind represents a precompiled binary extension. BinaryKind + // LocalKind represents a locally developed extension. LocalKind ) +// Extension represents an installed gh CLI extension. type Extension struct { path string kind ExtensionKind @@ -40,22 +45,27 @@ type Extension struct { owner string } +// Name returns the extension name without the "gh-" prefix. func (e *Extension) Name() string { return strings.TrimPrefix(filepath.Base(e.path), "gh-") } +// Path returns the filesystem path to the extension executable. func (e *Extension) Path() string { return e.path } +// IsLocal returns true if the extension is a locally developed extension. func (e *Extension) IsLocal() bool { return e.kind == LocalKind } +// IsBinary returns true if the extension is a precompiled binary. func (e *Extension) IsBinary() bool { return e.kind == BinaryKind } +// URL returns the repository URL of the extension. func (e *Extension) URL() string { e.mu.RLock() if e.url != "" { @@ -85,6 +95,7 @@ func (e *Extension) URL() string { return e.url } +// CurrentVersion returns the currently installed version of the extension. func (e *Extension) CurrentVersion() string { e.mu.RLock() if e.currentVersion != "" { @@ -113,6 +124,7 @@ func (e *Extension) CurrentVersion() string { return e.currentVersion } +// LatestVersion returns the latest available version of the extension. func (e *Extension) LatestVersion() string { e.mu.RLock() if e.latestVersion != "" { @@ -147,6 +159,7 @@ func (e *Extension) LatestVersion() string { return e.latestVersion } +// IsPinned returns true if the extension is pinned to a specific version. func (e *Extension) IsPinned() bool { e.mu.RLock() if e.isPinned != nil { @@ -179,6 +192,7 @@ func (e *Extension) IsPinned() bool { return *e.isPinned } +// Owner returns the GitHub owner of the extension repository. func (e *Extension) Owner() string { e.mu.RLock() if e.owner != "" { @@ -211,6 +225,7 @@ func (e *Extension) Owner() string { return e.owner } +// UpdateAvailable returns true if a newer version of the extension exists. func (e *Extension) UpdateAvailable() bool { if e.IsLocal() || e.CurrentVersion() == "" || diff --git a/pkg/cmd/extension/git.go b/pkg/cmd/extension/git.go index 58ef0ca12..0a3a7da58 100644 --- a/pkg/cmd/extension/git.go +++ b/pkg/cmd/extension/git.go @@ -21,14 +21,17 @@ type gitExecuter struct { client *git.Client } +// CheckoutBranch checks out the specified branch in the repository. func (g *gitExecuter) CheckoutBranch(branch string) error { return g.client.CheckoutBranch(context.Background(), branch) } +// Clone clones a repository from the given URL with optional arguments. func (g *gitExecuter) Clone(cloneURL string, cloneArgs []string) (string, error) { return g.client.Clone(context.Background(), cloneURL, cloneArgs) } +// CommandOutput runs a git command and returns its output. func (g *gitExecuter) CommandOutput(args []string) ([]byte, error) { cmd, err := g.client.Command(context.Background(), args...) if err != nil { @@ -37,24 +40,29 @@ func (g *gitExecuter) CommandOutput(args []string) ([]byte, error) { return cmd.Output() } +// Config retrieves a git configuration value by name. func (g *gitExecuter) Config(name string) (string, error) { return g.client.Config(context.Background(), name) } +// Fetch fetches refs from the specified remote. func (g *gitExecuter) Fetch(remote string, refspec string) error { return g.client.Fetch(context.Background(), remote, refspec) } +// ForRepo returns a new gitClient scoped to the given repository directory. func (g *gitExecuter) ForRepo(repoDir string) gitClient { gc := g.client.Copy() gc.RepoDir = repoDir return &gitExecuter{client: gc} } +// Pull pulls changes from the specified remote and branch. func (g *gitExecuter) Pull(remote, branch string) error { return g.client.Pull(context.Background(), remote, branch) } +// Remotes returns the set of configured git remotes. func (g *gitExecuter) Remotes() (git.RemoteSet, error) { return g.client.Remotes(context.Background()) } diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index b7f1f1c0c..58407c5f3 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -30,17 +30,20 @@ import ( // ErrInitialCommitFailed indicates the initial commit when making a new extension failed. var ErrInitialCommitFailed = errors.New("initial commit failed") +// ErrExtensionExecutableNotFound indicates that an installed extension has no executable. type ErrExtensionExecutableNotFound struct { Dir string Name string } +// Error returns a descriptive message about the missing executable. func (e *ErrExtensionExecutableNotFound) Error() string { return fmt.Sprintf("an extension has been installed but there is no executable: executable file named \"%s\" in %s is required to run the extension after install. Perhaps you need to build it?\n", e.Name, e.Dir) } const darwinAmd64 = "darwin-amd64" +// Manager handles installation, upgrade, and removal of gh CLI extensions. type Manager struct { dataDir func() string updateDir func() string @@ -55,6 +58,7 @@ type Manager struct { dryRunMode bool } +// NewManager creates a new extension Manager with default settings. func NewManager(ios *iostreams.IOStreams, gc *git.Client) *Manager { return &Manager{ dataDir: config.DataDir, @@ -76,18 +80,22 @@ func NewManager(ios *iostreams.IOStreams, gc *git.Client) *Manager { } } +// SetConfig sets the configuration used by the Manager. func (m *Manager) SetConfig(cfg gh.Config) { m.config = cfg } +// SetClient sets the HTTP client used by the Manager. func (m *Manager) SetClient(client *http.Client) { m.client = client } +// EnableDryRunMode enables dry-run mode so that no changes are persisted. func (m *Manager) EnableDryRunMode() { m.dryRunMode = true } +// Dispatch executes an installed extension by name with the given I/O streams. func (m *Manager) Dispatch(args []string, stdin io.Reader, stdout, stderr io.Writer) (bool, error) { if len(args) == 0 { return false, errors.New("too few arguments in list") @@ -133,6 +141,7 @@ func (m *Manager) Dispatch(args []string, stdin io.Reader, stdout, stderr io.Wri return true, externalCmd.Run() } +// List returns all installed extensions. func (m *Manager) List() []extensions.Extension { exts, _ := m.list(false) r := make([]extensions.Extension, len(exts)) @@ -205,6 +214,7 @@ func (m *Manager) populateLatestVersions(exts []*Extension) { wg.Wait() } +// InstallLocal installs a local extension from the given directory. func (m *Manager) InstallLocal(dir string) error { name := filepath.Base(dir) if err := m.cleanExtensionUpdateDir(name); err != nil { @@ -451,6 +461,7 @@ var localExtensionUpgradeError = errors.New("local extensions can not be upgrade var upToDateError = errors.New("already up to date") var noExtensionsInstalledError = errors.New("no extensions installed") +// Upgrade upgrades the named extension or all extensions if name is empty. func (m *Manager) Upgrade(name string, force bool) error { // Fetch metadata during list only when upgrading all extensions. // This is a performance improvement so that we don't make a @@ -570,6 +581,7 @@ func (m *Manager) upgradeBinExtension(ext *Extension) error { return m.installBin(repo, "") } +// Remove uninstalls the named extension and cleans up its files. func (m *Manager) Remove(name string) error { name = normalizeExtension(name) targetDir := filepath.Join(m.installDir(), name) @@ -609,6 +621,7 @@ var scriptTmpl string //go:embed ext_tmpls/buildScript.sh var buildScript []byte +// Create scaffolds a new extension project with the given name and template type. func (m *Manager) Create(name string, tmplType extensions.ExtTemplateType) error { if _, err := m.gitClient.CommandOutput([]string{"init", "--quiet", name}); err != nil { return err diff --git a/pkg/cmd/extension/mocks.go b/pkg/cmd/extension/mocks.go index 4de68cf7e..c53bf34fb 100644 --- a/pkg/cmd/extension/mocks.go +++ b/pkg/cmd/extension/mocks.go @@ -9,31 +9,37 @@ type mockGitClient struct { mock.Mock } +// CheckoutBranch mocks checking out a branch. func (g *mockGitClient) CheckoutBranch(branch string) error { args := g.Called(branch) return args.Error(0) } +// Clone mocks cloning a repository. func (g *mockGitClient) Clone(cloneURL string, cloneArgs []string) (string, error) { args := g.Called(cloneURL, cloneArgs) return args.String(0), args.Error(1) } +// CommandOutput mocks running a git command and returning its output. func (g *mockGitClient) CommandOutput(commandArgs []string) ([]byte, error) { args := g.Called(commandArgs) return []byte(args.String(0)), args.Error(1) } +// Config mocks retrieving a git configuration value. func (g *mockGitClient) Config(name string) (string, error) { args := g.Called(name) return args.String(0), args.Error(1) } +// Fetch mocks fetching refs from a remote. func (g *mockGitClient) Fetch(remote string, refspec string) error { args := g.Called(remote, refspec) return args.Error(0) } +// ForRepo mocks returning a gitClient scoped to a repository directory. func (g *mockGitClient) ForRepo(repoDir string) gitClient { args := g.Called(repoDir) if v, ok := args.Get(0).(*mockGitClient); ok { @@ -42,11 +48,13 @@ func (g *mockGitClient) ForRepo(repoDir string) gitClient { return nil } +// Pull mocks pulling changes from a remote branch. func (g *mockGitClient) Pull(remote, branch string) error { args := g.Called(remote, branch) return args.Error(0) } +// Remotes mocks returning the set of configured git remotes. func (g *mockGitClient) Remotes() (git.RemoteSet, error) { args := g.Called() return nil, args.Error(1)