-
Notifications
You must be signed in to change notification settings - Fork 8.8k
Add --latest-pre-release and --pin flags to gh extension upgrade #13982
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: trunk
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
|
@@ -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) { | ||
|
|
@@ -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", | ||
|
|
@@ -439,6 +442,15 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { | |
| if len(args) > 1 { | ||
| return cmdutil.FlagErrorf("too many arguments") | ||
| } | ||
| if flagLatestPreRelease && flagAll { | ||
| 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 { | ||
|
|
@@ -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") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💅 |
||
| 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)") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💭 |
||
| cmd.Flags().StringVar(&flagPin, "pin", "", "Upgrade to and pin a specific release tag (binary extensions only)") | ||
| return cmd | ||
| }(), | ||
| &cobra.Command{ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) { | ||
|
|
@@ -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. | ||
|
|
@@ -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) { | ||
|
|
@@ -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()) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ❗ 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 This is also a unit test gap.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
|
||
There was a problem hiding this comment.
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 with
cmdutil.MutuallyExclusive.Validation also usually lives inRunE, notArgs, but that's a sin made in the past and doesn't need fixing here.