diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 1c929482f..50aad7a40 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -76,10 +76,12 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { return t.Render() }, }, - &cobra.Command{ - Use: "install ", - Short: "Install a gh extension from a repository", - Long: heredoc.Doc(` + func() *cobra.Command { + var pinFlag string + cmd := &cobra.Command{ + Use: "install ", + Short: "Install a gh extension from a repository", + Long: heredoc.Doc(` Install a GitHub repository locally as a GitHub CLI extension. The repository argument can be specified in "owner/repo" format as well as a full URL. @@ -90,41 +92,44 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { See the list of available extensions at . `), - Example: heredoc.Doc(` + Example: heredoc.Doc(` $ gh extension install owner/gh-extension $ gh extension install https://git.example.com/owner/gh-extension $ gh extension install . `), - Args: cmdutil.MinimumArgs(1, "must specify a repository to install from"), - RunE: func(cmd *cobra.Command, args []string) error { - if args[0] == "." { - wd, err := os.Getwd() + Args: cmdutil.MinimumArgs(1, "must specify a repository to install from"), + RunE: func(cmd *cobra.Command, args []string) error { + if args[0] == "." { + wd, err := os.Getwd() + if err != nil { + return err + } + return m.InstallLocal(wd) + } + + repo, err := ghrepo.FromFullName(args[0]) if err != nil { return err } - return m.InstallLocal(wd) - } - repo, err := ghrepo.FromFullName(args[0]) - if err != nil { - return err - } + if err := checkValidExtension(cmd.Root(), m, repo.RepoName()); err != nil { + return err + } - if err := checkValidExtension(cmd.Root(), m, repo.RepoName()); err != nil { - return err - } + if err := m.Install(repo, pinFlag); err != nil { + return err + } - if err := m.Install(repo); err != nil { - return err - } - - if io.IsStdoutTTY() { - cs := io.ColorScheme() - fmt.Fprintf(io.Out, "%s Installed extension %s\n", cs.SuccessIcon(), args[0]) - } - return nil - }, - }, + if io.IsStdoutTTY() { + cs := io.ColorScheme() + fmt.Fprintf(io.Out, "%s Installed extension %s\n", cs.SuccessIcon(), args[0]) + } + return nil + }, + } + cmd.Flags().StringVar(&pinFlag, "pin", "", "pin extension to a release tag or commit sha") + return cmd + }(), func() *cobra.Command { var flagAll bool var flagForce bool diff --git a/pkg/cmd/extension/http.go b/pkg/cmd/extension/http.go index cfae2b738..d1bbe84e7 100644 --- a/pkg/cmd/extension/http.go +++ b/pkg/cmd/extension/http.go @@ -112,3 +112,37 @@ func fetchLatestRelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*re return &r, nil } + +// fetchRelease finds release by tag name for a repository +func fetchReleaseFromTag(httpClient *http.Client, baseRepo ghrepo.Interface, tagName string) (*release, error) { + fullRepoName := fmt.Sprintf("%s/%s", baseRepo.RepoOwner(), baseRepo.RepoName()) + path := fmt.Sprintf("repos/%s/releases/tags/%s", fullRepoName, tagName) + url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := httpClient.Do(req) + defer resp.Body.Close() + if err != nil { + return nil, err + } + + if resp.StatusCode > 299 { + return nil, api.HandleHTTPError(resp) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var r release + err = json.Unmarshal(b, &r) + if err != nil { + return nil, err + } + + return &r, nil +} diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index 3449092cc..41dab528d 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -320,13 +320,13 @@ type binManifest struct { Path string } -func (m *Manager) Install(repo ghrepo.Interface) error { +func (m *Manager) Install(repo ghrepo.Interface, targetCommitish string) error { isBin, err := isBinExtension(m.client, repo) if err != nil { return fmt.Errorf("could not check for binary extension: %w", err) } if isBin { - return m.installBin(repo) + return m.installBin(repo, targetCommitish) } hs, err := hasScript(m.client, repo) @@ -341,9 +341,14 @@ func (m *Manager) Install(repo ghrepo.Interface) error { return m.installGit(ghrepo.FormatRemoteURL(repo, protocol), m.io.Out, m.io.ErrOut) } -func (m *Manager) installBin(repo ghrepo.Interface) error { +func (m *Manager) installBin(repo ghrepo.Interface, targetCommitish string) error { var r *release - r, err := fetchLatestRelease(m.client, repo) + var err error + if targetCommitish == "" { + r, err = fetchLatestRelease(m.client, repo) + } else { + r, err = fetchReleaseFromTag(m.client, repo, targetCommitish) + } if err != nil { return err } @@ -498,7 +503,7 @@ func (m *Manager) upgradeExtension(ext Extension, force bool) error { if err != nil { return fmt.Errorf("failed to migrate to new precompiled extension format: %w", err) } - return m.installBin(repo) + return m.installBin(repo, "") } err = m.upgradeGitExtension(ext, force) } @@ -525,7 +530,7 @@ func (m *Manager) upgradeBinExtension(ext Extension) error { if err != nil { return fmt.Errorf("failed to parse URL %s: %w", ext.url, err) } - return m.installBin(repo) + return m.installBin(repo, "") } func (m *Manager) Remove(name string) error { diff --git a/pkg/extensions/extension.go b/pkg/extensions/extension.go index a9e120dc9..fa1959ab7 100644 --- a/pkg/extensions/extension.go +++ b/pkg/extensions/extension.go @@ -28,7 +28,7 @@ type Extension interface { //go:generate moq -rm -out manager_mock.go . ExtensionManager type ExtensionManager interface { List(includeMetadata bool) []Extension - Install(ghrepo.Interface) error + Install(ghrepo.Interface, string) error InstallLocal(dir string) error Upgrade(name string, force bool) error Remove(name string) error diff --git a/pkg/extensions/manager_mock.go b/pkg/extensions/manager_mock.go index 80e1efd57..01eac92bb 100644 --- a/pkg/extensions/manager_mock.go +++ b/pkg/extensions/manager_mock.go @@ -25,7 +25,7 @@ var _ ExtensionManager = &ExtensionManagerMock{} // DispatchFunc: func(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) (bool, error) { // panic("mock out the Dispatch method") // }, -// InstallFunc: func(interfaceMoqParam ghrepo.Interface) error { +// InstallFunc: func(interfaceMoqParam ghrepo.Interface, s string) error { // panic("mock out the Install method") // }, // InstallLocalFunc: func(dir string) error { @@ -54,7 +54,7 @@ type ExtensionManagerMock struct { DispatchFunc func(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) (bool, error) // InstallFunc mocks the Install method. - InstallFunc func(interfaceMoqParam ghrepo.Interface) error + InstallFunc func(interfaceMoqParam ghrepo.Interface, s string) error // InstallLocalFunc mocks the InstallLocal method. InstallLocalFunc func(dir string) error @@ -92,6 +92,8 @@ type ExtensionManagerMock struct { Install []struct { // InterfaceMoqParam is the interfaceMoqParam argument value. InterfaceMoqParam ghrepo.Interface + // S is the s argument value. + S string } // InstallLocal holds details about calls to the InstallLocal method. InstallLocal []struct { @@ -204,19 +206,21 @@ func (mock *ExtensionManagerMock) DispatchCalls() []struct { } // Install calls InstallFunc. -func (mock *ExtensionManagerMock) Install(interfaceMoqParam ghrepo.Interface) error { +func (mock *ExtensionManagerMock) Install(interfaceMoqParam ghrepo.Interface, s string) error { if mock.InstallFunc == nil { panic("ExtensionManagerMock.InstallFunc: method is nil but ExtensionManager.Install was just called") } callInfo := struct { InterfaceMoqParam ghrepo.Interface + S string }{ InterfaceMoqParam: interfaceMoqParam, + S: s, } mock.lockInstall.Lock() mock.calls.Install = append(mock.calls.Install, callInfo) mock.lockInstall.Unlock() - return mock.InstallFunc(interfaceMoqParam) + return mock.InstallFunc(interfaceMoqParam, s) } // InstallCalls gets all the calls that were made to Install. @@ -224,9 +228,11 @@ func (mock *ExtensionManagerMock) Install(interfaceMoqParam ghrepo.Interface) er // len(mockedExtensionManager.InstallCalls()) func (mock *ExtensionManagerMock) InstallCalls() []struct { InterfaceMoqParam ghrepo.Interface + S string } { var calls []struct { InterfaceMoqParam ghrepo.Interface + S string } mock.lockInstall.RLock() calls = mock.calls.Install