Skip to content
Open
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
2 changes: 1 addition & 1 deletion cmd/gen-docs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ func (e *em) InstallLocal(_ string) error {
return nil
}

func (e *em) Upgrade(_ string, _ bool) error {
func (e *em) Upgrade(_ string, _ extensions.UpgradeOptions) error {
return nil
}

Expand Down
26 changes: 22 additions & 4 deletions pkg/cmd/extension/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
Aliases: []string{"extensions", "ext"},
}

upgradeFunc := func(name string, flagForce bool) error {
upgradeFunc := func(name string, opts extensions.UpgradeOptions) error {
cs := io.ColorScheme()
err := m.Upgrade(name, flagForce)
err := m.Upgrade(name, opts)
if err != nil {
if name != "" {
fmt.Fprintf(io.ErrOut, "%s Failed upgrading extension %s: %s\n", cs.FailureIcon(), name, err)
Expand Down Expand Up @@ -379,7 +379,7 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
if ext, err := checkValidExtension(cmd.Root(), m, repo.RepoName(), repo.RepoOwner()); err != nil {
// If an existing extension was found and --force was specified, attempt to upgrade.
if forceFlag && ext != nil {
return upgradeFunc(ext.Name(), forceFlag)
return upgradeFunc(ext.Name(), extensions.UpgradeOptions{Force: forceFlag})
}

if errors.Is(err, alreadyInstalledError) {
Expand Down Expand Up @@ -426,6 +426,9 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
var flagAll bool
var flagForce bool
var flagDryRun bool
var flagLatestPreRelease bool
var flagPin string

cmd := &cobra.Command{
Use: "upgrade {<name> | --all}",
Short: "Upgrade installed extensions",
Expand All @@ -439,6 +442,15 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
if len(args) > 1 {
return cmdutil.FlagErrorf("too many arguments")
}
if flagLatestPreRelease && flagAll {

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.

💅 These three pairwise checks should be replaced withcmdutil.MutuallyExclusive. Validation also usually lives in RunE, not Args , but that's a sin made in the past and doesn't need fixing here.

return cmdutil.FlagErrorf("cannot use `--latest-pre-release` with `--all`")
}
if flagPin != "" && flagAll {
return cmdutil.FlagErrorf("cannot use `--pin` with `--all`")
}
if flagPin != "" && flagLatestPreRelease {
return cmdutil.FlagErrorf("cannot use `--pin` with `--latest-pre-release`")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -449,12 +461,18 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
if flagDryRun {
m.EnableDryRunMode()
}
return upgradeFunc(name, flagForce)
return upgradeFunc(name, extensions.UpgradeOptions{
Force: flagForce,
LatestPreRelease: flagLatestPreRelease,
PinVersion: flagPin,
})
},
}
cmd.Flags().BoolVar(&flagAll, "all", false, "Upgrade all extensions")

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.

💅 upgrade has no Example , an oversight from the past, but we feel it here. We should add one, and at least add some examples for the new flags, but I'm sure it's nothing to have copilot generate the full proper examples.

cmd.Flags().BoolVar(&flagForce, "force", false, "Force upgrade extension")
cmd.Flags().BoolVar(&flagDryRun, "dry-run", false, "Only display upgrades")
cmd.Flags().BoolVar(&flagLatestPreRelease, "latest-pre-release", false, "Upgrade to the latest release, including pre-releases (binary extensions only)")

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.

💭 gh release uses --prerelease (create/edit) and --exclude-pre-releases (list), so either spelling has precedent.

cmd.Flags().StringVar(&flagPin, "pin", "", "Upgrade to and pin a specific release tag (binary extensions only)")
return cmd
}(),
&cobra.Command{
Expand Down
81 changes: 67 additions & 14 deletions pkg/cmd/extension/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ func TestNewCmdExtension(t *testing.T) {
name: "upgrade an extension",
args: []string{"upgrade", "hello"},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.UpgradeFunc = func(name string, force bool) error {
em.UpgradeFunc = func(name string, opts extensions.UpgradeOptions) error {
return nil
}
return func(t *testing.T) {
Expand All @@ -382,7 +382,7 @@ func TestNewCmdExtension(t *testing.T) {
args: []string{"upgrade", "hello", "--dry-run"},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.EnableDryRunModeFunc = func() {}
em.UpgradeFunc = func(name string, force bool) error {
em.UpgradeFunc = func(name string, opts extensions.UpgradeOptions) error {
return nil
}
return func(t *testing.T) {
Expand All @@ -391,7 +391,7 @@ func TestNewCmdExtension(t *testing.T) {
upgradeCalls := em.UpgradeCalls()
assert.Equal(t, 1, len(upgradeCalls))
assert.Equal(t, "hello", upgradeCalls[0].Name)
assert.False(t, upgradeCalls[0].Force)
assert.False(t, upgradeCalls[0].Opts.Force)
}
},
isTTY: true,
Expand All @@ -401,7 +401,7 @@ func TestNewCmdExtension(t *testing.T) {
name: "upgrade an extension notty",
args: []string{"upgrade", "hello"},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.UpgradeFunc = func(name string, force bool) error {
em.UpgradeFunc = func(name string, opts extensions.UpgradeOptions) error {
return nil
}
return func(t *testing.T) {
Expand All @@ -412,11 +412,64 @@ func TestNewCmdExtension(t *testing.T) {
},
isTTY: false,
},
{
name: "upgrade an extension to the latest pre-release",
args: []string{"upgrade", "hello", "--latest-pre-release"},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.UpgradeFunc = func(name string, opts extensions.UpgradeOptions) error {
return nil
}
return func(t *testing.T) {
calls := em.UpgradeCalls()
assert.Equal(t, 1, len(calls))
assert.Equal(t, "hello", calls[0].Name)
assert.True(t, calls[0].Opts.LatestPreRelease)
assert.Equal(t, "", calls[0].Opts.PinVersion)
}
},
isTTY: true,
wantStdout: "✓ Successfully checked extension upgrades\n",
},
{
name: "upgrade an extension pinned to a version",
args: []string{"upgrade", "hello", "--pin", "v1.2.3-pre"},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.UpgradeFunc = func(name string, opts extensions.UpgradeOptions) error {
return nil
}
return func(t *testing.T) {
calls := em.UpgradeCalls()
assert.Equal(t, 1, len(calls))
assert.Equal(t, "hello", calls[0].Name)
assert.Equal(t, "v1.2.3-pre", calls[0].Opts.PinVersion)
}
},
isTTY: true,
wantStdout: "✓ Successfully checked extension upgrades\n",
},
{
name: "Upgrade an extension with --latest-pre-release and --all",
args: []string{"upgrade", "--all", "--latest-pre-release"},
wantErr: true,
errMsg: "cannot use `--latest-pre-release` with `--all`",
},
{
name: "upgrade an extension with --pin and --all",
args: []string{"upgrade", "--all", "--pin", "v1.2.3"},
wantErr: true,
errMsg: "cannot use `--pin` with `--all`",
},
{
name: "upgrade an extension with --pin and --latest-pre-release",
args: []string{"upgrade", "hello", "--pin", "v1.2.3", "--latest-pre-release"},
wantErr: true,
errMsg: "cannot use `--pin` with `--latest-pre-release`",
},
{
name: "upgrade an up-to-date extension",
args: []string{"upgrade", "hello"},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.UpgradeFunc = func(name string, force bool) error {
em.UpgradeFunc = func(name string, opts extensions.UpgradeOptions) error {
// An already up to date extension returns the same response
// as an one that has been upgraded.
return nil
Expand All @@ -434,7 +487,7 @@ func TestNewCmdExtension(t *testing.T) {
name: "upgrade extension error",
args: []string{"upgrade", "hello"},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.UpgradeFunc = func(name string, force bool) error {
em.UpgradeFunc = func(name string, opts extensions.UpgradeOptions) error {
return errors.New("oh no")
}
return func(t *testing.T) {
Expand All @@ -453,7 +506,7 @@ func TestNewCmdExtension(t *testing.T) {
name: "upgrade an extension gh-prefix",
args: []string{"upgrade", "gh-hello"},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.UpgradeFunc = func(name string, force bool) error {
em.UpgradeFunc = func(name string, opts extensions.UpgradeOptions) error {
return nil
}
return func(t *testing.T) {
Expand All @@ -469,7 +522,7 @@ func TestNewCmdExtension(t *testing.T) {
name: "upgrade an extension full name",
args: []string{"upgrade", "monalisa/gh-hello"},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.UpgradeFunc = func(name string, force bool) error {
em.UpgradeFunc = func(name string, opts extensions.UpgradeOptions) error {
return nil
}
return func(t *testing.T) {
Expand All @@ -485,7 +538,7 @@ func TestNewCmdExtension(t *testing.T) {
name: "upgrade all",
args: []string{"upgrade", "--all"},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.UpgradeFunc = func(name string, force bool) error {
em.UpgradeFunc = func(name string, opts extensions.UpgradeOptions) error {
return nil
}
return func(t *testing.T) {
Expand All @@ -502,7 +555,7 @@ func TestNewCmdExtension(t *testing.T) {
args: []string{"upgrade", "--all", "--dry-run"},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.EnableDryRunModeFunc = func() {}
em.UpgradeFunc = func(name string, force bool) error {
em.UpgradeFunc = func(name string, opts extensions.UpgradeOptions) error {
return nil
}
return func(t *testing.T) {
Expand All @@ -511,7 +564,7 @@ func TestNewCmdExtension(t *testing.T) {
upgradeCalls := em.UpgradeCalls()
assert.Equal(t, 1, len(upgradeCalls))
assert.Equal(t, "", upgradeCalls[0].Name)
assert.False(t, upgradeCalls[0].Force)
assert.False(t, upgradeCalls[0].Opts.Force)
}
},
isTTY: true,
Expand All @@ -521,7 +574,7 @@ func TestNewCmdExtension(t *testing.T) {
name: "upgrade all none installed",
args: []string{"upgrade", "--all"},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.UpgradeFunc = func(name string, force bool) error {
em.UpgradeFunc = func(name string, opts extensions.UpgradeOptions) error {
return noExtensionsInstalledError
}
return func(t *testing.T) {
Expand All @@ -538,7 +591,7 @@ func TestNewCmdExtension(t *testing.T) {
name: "upgrade all notty",
args: []string{"upgrade", "--all"},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.UpgradeFunc = func(name string, force bool) error {
em.UpgradeFunc = func(name string, opts extensions.UpgradeOptions) error {
return nil
}
return func(t *testing.T) {
Expand Down Expand Up @@ -882,7 +935,7 @@ func TestNewCmdExtension(t *testing.T) {
em.InstallFunc = func(_ ghrepo.Interface, _ string) error {
return nil
}
em.UpgradeFunc = func(name string, force bool) error {
em.UpgradeFunc = func(name string, opts extensions.UpgradeOptions) error {
return nil
}
return func(t *testing.T) {
Expand Down
99 changes: 97 additions & 2 deletions pkg/cmd/extension/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ package extension
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"time"

"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/safeurl"
"github.com/hashicorp/go-version"
)

func repoExists(httpClient *http.Client, repo ghrepo.Interface) (bool, error) {
Expand Down Expand Up @@ -73,8 +76,11 @@ type releaseAsset struct {
}

type release struct {
Tag string `json:"tag_name"`
Assets []releaseAsset
Tag string `json:"tag_name"`
IsPrerelease bool `json:"prerelease"`
IsDraft bool `json:"draft"`
PublishedAt time.Time `json:"published_at"`
Assets []releaseAsset
}

// downloadAsset downloads a single asset to the given file path.
Expand Down Expand Up @@ -114,6 +120,7 @@ func downloadAsset(httpClient *http.Client, assetURL safeurl.SafeURL, destPath s
var commitNotFoundErr = errors.New("commit not found")
var releaseNotFoundErr = errors.New("release not found")
var repositoryNotFoundErr = errors.New("repository not found")
var noPrereleasesFoundErr = errors.New("no pre-releases found")

// fetchLatestRelease finds the latest published release for a repository.
func fetchLatestRelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*release, error) {
Expand Down Expand Up @@ -153,6 +160,94 @@ func fetchLatestRelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*re
return &r, nil
}

// fetchLatestPrerelease finds the highest-versioned pre-release for a
// repository. It only considers releases marked as pre-releases, selecting the
// one with the highest version. If the repository has no pre-releases it
// returns noPrereleasesFoundErr.
//
// When a stable (non-pre-release) release beats the chosen pre-release, either
// by a higher version or by a more recent publish date, it is returned as
// newerStable so the caller can warn the user that a newer stable release is
// available.
//
// Note that if the latest pre-release is not on the first page of 100, it is
// possible that this will not find it; for performance reasons in busy
// repositories it is not safe or efficient to iterate over every page of
// releases. In those cases, the user should specify a tag with --pin.
func fetchLatestPrerelease(httpClient *http.Client, baseRepo ghrepo.Interface) (prerelease *release, newerStable *release, err error) {
path := fmt.Sprintf("repos/%s/%s/releases?per_page=100", baseRepo.RepoOwner(), baseRepo.RepoName())

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.

💅 These are probably safe but it's probably best they be path encoded anyway.

url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, nil, err
}
Comment on lines +178 to +183

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.

💅I recognize this is not a pattern you introduced, but new API methods should use a cli/go-gh APIClient type, not a raw HTTP client, where it is possible. This aligns it more with newer implementations in the codebase.


resp, err := httpClient.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()

if resp.StatusCode == 404 {
return nil, nil, releaseNotFoundErr
}
if resp.StatusCode > 299 {
return nil, nil, api.HandleHTTPError(resp)
}

b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, err
}

var releases []release
if err := json.Unmarshal(b, &releases); err != nil {
return nil, nil, err
}

var bestPre *release
var bestPreVersion *version.Version
var bestStable *release
var bestStableVersion *version.Version
for i := range releases {
r := &releases[i]
if r.IsDraft {
continue
}
// Tags that are not valid semver cannot be ordered against other
// releases, so they are skipped. This means a repository whose newest
// pre-release uses an unparseable tag (e.g. v1.0.0.beta.2) may resolve
// to an older pre-release; users can reach such a release with --pin.
v, verr := version.NewVersion(r.Tag)
if verr != nil {
continue

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.

v1.0.0.beta.2 (for example, the tag from the linked issue) isn't valid semver, so version.NewVersion errors and we would continue past it.

With a mix of parseable and unparseable tags we keep the highest parseable one, which can be older than the release the user wants.

Instead, can we return an error when the newest release won't parse and point at --pin?

This is also a unit test gap.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

How do you define "newest release" in this case? Do you mean just "the first release returned in the API" because if the version won't parse then I don't know how else you'd define "newest". It's inherently not ordered as a version if it's unparseable.

I look at this and I think that this is the best of possible options; it might be reasonable to issue a warning if we can't parse a version, but I think we should only fail if we can't parse any version. Which, I think, is what's happening here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I've pushed an update with a skipped test that expresses this; feedback is welcome.

}
if r.IsPrerelease {
if bestPre == nil || v.GreaterThan(bestPreVersion) {
bestPre = r
bestPreVersion = v
}
continue
}
if bestStable == nil || v.GreaterThan(bestStableVersion) {
bestStable = r
bestStableVersion = v
}
}

if bestPre == nil {
return nil, nil, noPrereleasesFoundErr
}

if bestStable != nil {
if bestStableVersion.GreaterThan(bestPreVersion) || bestStable.PublishedAt.After(bestPre.PublishedAt) {
newerStable = bestStable
}
}

return bestPre, newerStable, nil
}

// fetchReleaseFromTag finds release by tag name for a repository
func fetchReleaseFromTag(httpClient *http.Client, baseRepo ghrepo.Interface, tagName string) (*release, error) {
url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(baseRepo.RepoHost()), "repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "releases", "tags", tagName)
Expand Down
Loading