Skip to content

Commit

Permalink
Show changelogs for multiple outstanding releases (Closes #546) (#106)
Browse files Browse the repository at this point in the history
* compare semver; show changelogs (max 10) if they are ahead of current

* move version methods to package; fix lint issues

* logging improvements

* add basic unit test for list recent releases
  • Loading branch information
tim-codes authored Jan 20, 2025
1 parent 61379fc commit 07b8c18
Show file tree
Hide file tree
Showing 5 changed files with 221 additions and 53 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ corectl
corectl*.log
dist/
.vscode
.idea
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ require (
)

require (
github.com/Masterminds/semver/v3 v3.3.1 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/google/go-github/v59 v59.0.0 // indirect
github.com/mmcloughlin/avo v0.6.0 // indirect
Expand All @@ -52,7 +53,6 @@ require (
cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
dario.cat/mergo v1.0.1 // indirect
github.com/Masterminds/semver/v3 v3.3.1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.5 // indirect
github.com/alecthomas/chroma/v2 v2.15.0 // indirect
Expand Down
159 changes: 116 additions & 43 deletions pkg/cmd/update/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"context"
"errors"
"fmt"
"github.com/Masterminds/semver/v3"
"io"
"net/http"
"os"
Expand Down Expand Up @@ -41,6 +42,23 @@ type CoreCtlAsset struct {
Changelog string
}

func updateAvailable(githubClient *github.Client) (bool, string, error) {
release, err := getLatestCorectlRelease(githubClient)
if err != nil {
return false, "", err
}
asset, err := getReleaseCorectlAsset(release)
if err != nil {
return false, "", err
}

if version.Version == asset.Version {
return false, "", nil
} else {
return true, asset.Version, nil
}
}

// Any failures we recieve will log a warning, we don't want this to cause any command to fail, this is an optional
// check which shouldn't prevent or interrupt any command from running (especially in ci)
func CheckForUpdates(cfg *config.Config, cmd *cobra.Command) {
Expand Down Expand Up @@ -194,65 +212,89 @@ func UpdateCmd(cfg *config.Config) *cobra.Command {
return updateCmd
}

func updateAvailable(githubClient *github.Client) (bool, string, error) {
release, err := getLatestCorectlRelease(githubClient)
if err != nil {
return false, "", err
}
asset, err := getLatestCorectlAsset(release)
if err != nil {
return false, "", err
}

if version.Version == asset.Version {
return false, "", nil
} else {
return true, asset.Version, nil
}
}

func update(opts UpdateOpts) error {
if opts.targetVersion != "" {
logger.Debug().Msgf("target version set to %s", opts.targetVersion)
}

currentVersion, parseCurrentErr := version.BuildReleaseVersion(version.Version)
if parseCurrentErr != nil {
logger.Warn().Msgf("could not parse current version %s: %v, defaulting to 0.0.0", version.Version, parseCurrentErr)
if currentVersion, parseCurrentErr = version.BuildReleaseVersion("0.0.0"); parseCurrentErr != nil {
return parseCurrentErr
}
}

logger.Warn().Msg("Checking for updates")
githubClient := github.NewClient(nil)
if opts.githubToken != "" {
githubClient = githubClient.WithAuthToken(opts.githubToken)
}

var release *github.RepositoryRelease
var err error
// fetch the target release metadata
var targetRelease *github.RepositoryRelease
var getTargetReleaseErr error
if opts.targetVersion == "" {
release, err = getLatestCorectlRelease(githubClient)
targetRelease, getTargetReleaseErr = getLatestCorectlRelease(githubClient)
} else {
release, err = getCorectlReleaseByTag(githubClient, opts.targetVersion)
targetRelease, getTargetReleaseErr = getCorectlReleaseByTag(githubClient, opts.targetVersion)
}
if err != nil {
logger.Error().Msg(err.Error())
return err
if getTargetReleaseErr != nil {
logger.Error().Msg(getTargetReleaseErr.Error())
return getTargetReleaseErr
}

asset, err := getLatestCorectlAsset(release)
if err != nil {
logger.Error().Msg(err.Error())
return err
// build CoreCtlAsset from the release object
asset, getTargetAssetErr := getReleaseCorectlAsset(targetRelease)
if getTargetAssetErr != nil {
logger.Error().Msg(getTargetAssetErr.Error())
return getTargetAssetErr
}
logger.Debug().With(zap.String("current_version", version.Version), zap.String("remote_version", asset.Version)).Msg("comparing versions")
if version.Version == asset.Version {
logger.Warn().Msgf("Already running %s release (%v)", opts.targetVersion, version.Version)

// compare current/target versions
targetVersion, parseTargetVersionErr := semver.NewVersion(asset.Version)
if parseTargetVersionErr != nil {
logger.Warn().Msgf("could not parse target version: %v", parseTargetVersionErr)
return parseTargetVersionErr
}
logger.
Debug().
With(zap.String("current_version", currentVersion.String()), zap.String("remote_version", targetVersion.String())).
Msg("comparing versions")
if currentVersion.IsTargetVersionCurrent(targetVersion) {
logger.Warn().Msgf("Already running version %s", targetVersion.String())
return nil
} else {
logger.Warn().Msgf("Update available: %v", asset.Version)
}
isAhead := false
if currentVersion.IsTargetVersionBehind(targetVersion) {
logger.Warn().Msgf("Target version %s is behind the current version %s", targetVersion.String(), currentVersion.String())
} else if currentVersion.IsTargetVersionAhead(targetVersion) {
isAhead = true
logger.Warn().Msgf("Update available: v%s", targetVersion.String())
}

out, err := glamour.Render(asset.Changelog, "dark")
if err == nil {
_, _ = opts.streams.GetOutput().Write([]byte(out))
} else {
logger.Warn().With(zap.Error(err)).Msg("could not render changelog markdown, falling back to plaintext")
_, _ = opts.streams.GetOutput().Write([]byte(asset.Changelog))
// fetch and render the changelogs for each missed release (max 10 most recent)
if isAhead {
outstandingReleases, err := listOutstandingReleases(githubClient, version.Version, nil)
if err != nil {
logger.Error().Msg(err.Error())
return err
}
if len(outstandingReleases) == 10 {
logger.Warn().Msg("Showing Changelogs for the 10 most recent releases:")
} else {
logger.Warn().Msgf("Showing Changelogs for %d new releases:", len(outstandingReleases))
}
for _, release := range outstandingReleases {
var changelog = fmt.Sprintf("# Changelog for %s:\n%s", *release.TagName, *release.Body)
out, err := glamour.Render(changelog, "dark")
if err == nil {
_, _ = opts.streams.GetOutput().Write([]byte(out))
} else {
logger.Warn().With(zap.Error(err)).Msg("could not render changelog markdown, falling back to plaintext")
_, _ = opts.streams.GetOutput().Write([]byte(changelog))
}
}
}

logger.Debug().With(zap.Bool("skipConfirmation", opts.skipConfirmation)).Msg("checking params")
Expand All @@ -262,25 +304,26 @@ func update(opts UpdateOpts) error {
wizard.Info("--skip-confirmation is set, continuing with update")
} else {
if opts.streams.IsInteractive() {
confirmation, err := confirmation.GetInput(opts.streams, fmt.Sprintf("Update to %s now?", asset.Version))
confirmInput, err := confirmation.GetInput(opts.streams, fmt.Sprintf("Update to %s now?", asset.Version))
if err != nil {
return fmt.Errorf("could not get confirmation from user: %+v", err)
}

if confirmation {
if confirmInput {
wizard.Info("Update accepted")
} else {
err = fmt.Errorf("update cancelled by user")
wizard.Abort(err.Error())
return err
}
} else {
err = fmt.Errorf("non interactive terminal, cannot ask for confirmation")
err := fmt.Errorf("non interactive terminal, cannot ask for confirmation")
wizard.Abort(err.Error())
return err
}
}

var err error
wizard.SetTask(fmt.Sprintf("Downloading release %s", asset.Version), fmt.Sprintf("Downloaded release %s", asset.Version))
data, err := downloadCorectlAsset(asset)
if err != nil {
Expand Down Expand Up @@ -341,7 +384,7 @@ func update(opts UpdateOpts) error {
return nil
}

func getLatestCorectlAsset(release *github.RepositoryRelease) (*CoreCtlAsset, error) {
func getReleaseCorectlAsset(release *github.RepositoryRelease) (*CoreCtlAsset, error) {
if release.Assets == nil {
return nil, errors.New("no assets found for the latest release")
}
Expand All @@ -366,7 +409,37 @@ func getLatestCorectlAsset(release *github.RepositoryRelease) (*CoreCtlAsset, er
}

return nil, errors.New("no asset found for the current architecture and OS")
}

type ListOutstandingReleasesOpts struct {
Pages int
PageSize int
}

func listOutstandingReleases(client *github.Client, version string, opts *ListOutstandingReleasesOpts) ([]*github.RepositoryRelease, error) {
defaultOpts := ListOutstandingReleasesOpts{Pages: 1, PageSize: 10}
if opts == nil {
opts = &defaultOpts
}

var releasesSince []*github.RepositoryRelease
page := 0
for page <= opts.Pages {
page++
releases, _, err := client.Repositories.ListReleases(context.Background(), "coreeng", "corectl", &github.ListOptions{Page: page, PerPage: opts.PageSize})
if err != nil {
return nil, err
}
for _, release := range releases {
// stop listing once we hit the current version
if *release.TagName == version {
return releasesSince, nil
}
releasesSince = append(releasesSince, release)
}
}

return releasesSince, nil
}

func getLatestCorectlRelease(client *github.Client) (*github.RepositoryRelease, error) {
Expand Down
56 changes: 47 additions & 9 deletions pkg/cmd/update/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,26 +24,40 @@ func TestUpdate(t *testing.T) {

var _ = Describe("corectl update", func() {
var (
latestRelease *github.RepositoryRelease
specificRelease *github.RepositoryRelease
latestReleaseTag string
latestReleaseTag string
latestRelease *github.RepositoryRelease
getLatestReleaseCapture *httpmock.HttpCaptureHandler[github.RepositoryRelease]

specificReleaseTag string
githubClient *github.Client
githubErrorClient *github.Client
getLatestReleaseCapture *httpmock.HttpCaptureHandler[github.RepositoryRelease]
specificRelease *github.RepositoryRelease
getSpecificReleaseCapture *httpmock.HttpCaptureHandler[github.RepositoryRelease]
githubErrorString string

recentReleases []*github.RepositoryRelease
getListReleasesCapture *httpmock.HttpCaptureHandler[[]github.RepositoryRelease]

githubClient *github.Client
githubErrorClient *github.Client
githubErrorString string
)

BeforeEach(OncePerOrdered, func() {
githubErrorString = "api error"

latestReleaseTag = "v100.0.0"
specificReleaseTag = "v0.0.1"
latestRelease = &github.RepositoryRelease{TagName: github.String(latestReleaseTag)}
specificRelease = &github.RepositoryRelease{TagName: github.String(specificReleaseTag)}
getLatestReleaseCapture = httpmock.NewCaptureHandler[github.RepositoryRelease](latestRelease)

specificReleaseTag = "v0.0.1"
specificRelease = &github.RepositoryRelease{TagName: github.String(specificReleaseTag)}
getSpecificReleaseCapture = httpmock.NewCaptureHandler[github.RepositoryRelease](specificRelease)

recentReleases = []*github.RepositoryRelease{
&github.RepositoryRelease{TagName: github.String("v0.0.2")},
&github.RepositoryRelease{TagName: github.String("v0.0.3")},
latestRelease,
}
getListReleasesCapture = httpmock.NewCaptureHandler[[]github.RepositoryRelease](recentReleases)

githubClient = github.NewClient(mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetReposReleasesLatestByOwnerByRepo,
Expand All @@ -53,6 +67,10 @@ var _ = Describe("corectl update", func() {
mock.GetReposReleasesTagsByOwnerByRepoByTag,
getSpecificReleaseCapture.Func(),
),
mock.WithRequestMatchHandler(
mock.GetReposReleasesByOwnerByRepo,
getListReleasesCapture.Func(),
),
))

errorResponse := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand All @@ -72,6 +90,10 @@ var _ = Describe("corectl update", func() {
mock.GetReposReleasesTagsByOwnerByRepoByTag,
errorResponse,
),
mock.WithRequestMatchHandler(
mock.GetReposReleasesByOwnerByRepo,
errorResponse,
),
))
})

Expand Down Expand Up @@ -174,6 +196,22 @@ var _ = Describe("corectl update", func() {
Expect(err.Error()).Should(ContainSubstring("no such file or directory"))
})
})

Context("git.ListOutstandingReleases", func() {
It("returns the list of releases", func() {
releases, err := listOutstandingReleases(githubClient, latestReleaseTag, nil)
Expect(len(releases)).Should(Equal(2))
Expect(releases[0].TagName).Should(Equal(recentReleases[0].TagName))
Expect(releases[1].TagName).Should(Equal(recentReleases[1].TagName))
Expect(err).ShouldNot(HaveOccurred())
})

It("returns an error when the API call fails", func() {
_, err := listOutstandingReleases(githubErrorClient, latestReleaseTag, nil)
Expect(err).Should(HaveOccurred())
Expect(err.Error()).Should(ContainSubstring(githubErrorString))
})
})
})

func createMockTarGz(filename string, content []byte) *bytes.Buffer {
Expand Down
Loading

0 comments on commit 07b8c18

Please sign in to comment.