package commands import ( "fmt" "io" "os" "path/filepath" "strings" "time" "github.com/github/hub/github" "github.com/github/hub/ui" "github.com/github/hub/utils" ) var ( cmdRelease = &Command{ Run: listReleases, Usage: ` release [--include-drafts] [--exclude-prereleases] [-L ] [-f ] release show [-f ] release create [-dpoc] [-a ] [-m |-F ] [-t ] release edit [] release download release delete `, Long: `Manage GitHub Releases for the current repository. ## Commands: With no arguments, shows a list of existing releases. With '--include-drafts', include draft releases in the listing. With '--exclude-prereleases', exclude non-stable releases from the listing. * _show_: Show GitHub release notes for . With '--show-downloads', include the "Downloads" section. * _create_: Create a GitHub release for the specified name. If git tag does not exist, it will be created at (default: current branch). * _edit_: Edit the GitHub release for the specified name. Accepts the same options as _create_ command. Publish a draft with '--draft=false'. When or are not specified, a text editor will open pre-populated with current release title and body. To re-use existing title and body unchanged, pass '-m ""'. * _download_: Download the assets attached to release for the specified . * _delete_: Delete the release and associated assets for the specified . Note that this does **not** remove the git tag . ## Options: -L, --limit Display only the first releases. -d, --draft Create a draft release. -p, --prerelease Create a pre-release. -a, --attach Attach a file as an asset for this release. If is in the "#" format, the text after the '#' character is taken as asset label. -m, --message The text up to the first blank line in is treated as the release title, and the rest is used as release description in Markdown format. If multiple options are given, their values are concatenated as separate paragraphs. -F, --file Read the release title and description from . -e, --edit Further edit the contents of in a text editor before submitting. -o, --browse Open the new release in a web browser. -c, --copy Put the URL of the new release to clipboard instead of printing it. -t, --commitish A commit SHA or branch name to attach the release to, only used if does not already exist (default: main branch). -f, --format Pretty print releases using (default: "%T%n"). See the "PRETTY FORMATS" section of git-log(1) for some additional details on how placeholders are used in format. The available placeholders for issues are: %U: the URL of this release %uT: tarball URL %uZ: zipball URL %uA: asset upload URL %S: state (i.e. "draft", "pre-release") %sC: set color to yellow or red, depending on state %t: release name %T: release tag %b: body %as: the list of assets attached to this release %cD: created date-only (no time of day) %cr: created date, relative %ct: created date, UNIX timestamp %cI: created date, ISO 8601 format %pD: published date-only (no time of day) %pr: published date, relative %pt: published date, UNIX timestamp %pI: published date, ISO 8601 format %n: newline %%: a literal % --color[=] Enable colored output even if stdout is not a terminal. can be one of "always" (default for '--color'), "never", or "auto" (default). The git tag name for this release. ## See also: hub(1), git-tag(1) `, KnownFlags: ` -d, --include-drafts -p, --exclude-prereleases -L, --limit N -f, --format FMT --color `, } cmdShowRelease = &Command{ Key: "show", Run: showRelease, KnownFlags: ` -d, --show-downloads -f, --format FMT --color `, } cmdCreateRelease = &Command{ Key: "create", Run: createRelease, KnownFlags: ` -e, --edit -d, --draft -p, --prerelease -o, --browse -c, --copy -a, --attach FILE -m, --message MSG -F, --file FILE -t, --commitish C `, } cmdEditRelease = &Command{ Key: "edit", Run: editRelease, KnownFlags: ` -e, --edit -d, --draft -p, --prerelease -a, --attach FILE -m, --message MSG -F, --file FILE -t, --commitish C `, } cmdDownloadRelease = &Command{ Key: "download", Run: downloadRelease, } cmdDeleteRelease = &Command{ Key: "delete", Run: deleteRelease, } ) func init() { cmdRelease.Use(cmdShowRelease) cmdRelease.Use(cmdCreateRelease) cmdRelease.Use(cmdEditRelease) cmdRelease.Use(cmdDownloadRelease) cmdRelease.Use(cmdDeleteRelease) CmdRunner.Use(cmdRelease) } func listReleases(cmd *Command, args *Args) { localRepo, err := github.LocalRepo() utils.Check(err) project, err := localRepo.MainProject() utils.Check(err) gh := github.NewClient(project.Host) flagReleaseLimit := args.Flag.Int("--limit") flagReleaseIncludeDrafts := args.Flag.Bool("--include-drafts") flagReleaseExcludePrereleases := args.Flag.Bool("--exclude-prereleases") if args.Noop { ui.Printf("Would request list of releases for %s\n", project) } else { releases, err := gh.FetchReleases(project, flagReleaseLimit, func(release *github.Release) bool { return (!release.Draft || flagReleaseIncludeDrafts) && (!release.Prerelease || !flagReleaseExcludePrereleases) }) utils.Check(err) colorize := colorizeOutput(args.Flag.HasReceived("--color"), args.Flag.Value("--color")) for _, release := range releases { flagReleaseFormat := "%T%n" if args.Flag.HasReceived("--format") { flagReleaseFormat = args.Flag.Value("--format") } ui.Print(formatRelease(release, flagReleaseFormat, colorize)) } } args.NoForward() } func formatRelease(release github.Release, format string, colorize bool) string { state := "" stateColorSwitch := "" if release.Draft { state = "draft" stateColorSwitch = fmt.Sprintf("\033[%dm", 33) } else if release.Prerelease { state = "pre-release" stateColorSwitch = fmt.Sprintf("\033[%dm", 31) } var createdDate, createdAtISO8601, createdAtUnix, createdAtRelative, publishedDate, publishedAtISO8601, publishedAtUnix, publishedAtRelative string if !release.CreatedAt.IsZero() { createdDate = release.CreatedAt.Format("02 Jan 2006") createdAtISO8601 = release.CreatedAt.Format(time.RFC3339) createdAtUnix = fmt.Sprintf("%d", release.CreatedAt.Unix()) createdAtRelative = utils.TimeAgo(release.CreatedAt) } if !release.PublishedAt.IsZero() { publishedDate = release.PublishedAt.Format("02 Jan 2006") publishedAtISO8601 = release.PublishedAt.Format(time.RFC3339) publishedAtUnix = fmt.Sprintf("%d", release.PublishedAt.Unix()) publishedAtRelative = utils.TimeAgo(release.PublishedAt) } assets := make([]string, len(release.Assets)) for i, asset := range release.Assets { assets[i] = fmt.Sprintf("%s\t%s", asset.DownloadUrl, asset.Label) } placeholders := map[string]string{ "U": release.HtmlUrl, "uT": release.TarballUrl, "uZ": release.ZipballUrl, "uA": release.UploadUrl, "S": state, "sC": stateColorSwitch, "t": release.Name, "T": release.TagName, "b": release.Body, "as": strings.Join(assets, "\n"), "cD": createdDate, "cI": createdAtISO8601, "ct": createdAtUnix, "cr": createdAtRelative, "pD": publishedDate, "pI": publishedAtISO8601, "pt": publishedAtUnix, "pr": publishedAtRelative, } return ui.Expand(format, placeholders, colorize) } func showRelease(cmd *Command, args *Args) { tagName := "" if args.ParamsSize() > 0 { tagName = args.GetParam(0) } if tagName == "" { utils.Check(cmd.UsageError("")) } localRepo, err := github.LocalRepo() utils.Check(err) project, err := localRepo.MainProject() utils.Check(err) gh := github.NewClient(project.Host) args.NoForward() if args.Noop { ui.Printf("Would display information for `%s' release\n", tagName) } else { release, err := gh.FetchRelease(project, tagName) utils.Check(err) body := strings.TrimSpace(release.Body) colorize := colorizeOutput(args.Flag.HasReceived("--color"), args.Flag.Value("--color")) if flagShowReleaseFormat := args.Flag.Value("--format"); flagShowReleaseFormat != "" { ui.Print(formatRelease(*release, flagShowReleaseFormat, colorize)) return } ui.Println(release.Name) if body != "" { ui.Printf("\n%s\n", body) } if args.Flag.Bool("--show-downloads") { ui.Printf("\n## Downloads\n\n") for _, asset := range release.Assets { ui.Println(asset.DownloadUrl) } if release.ZipballUrl != "" { ui.Println(release.ZipballUrl) ui.Println(release.TarballUrl) } } } } func downloadRelease(cmd *Command, args *Args) { tagName := "" if args.ParamsSize() > 0 { tagName = args.GetParam(0) } if tagName == "" { utils.Check(cmd.UsageError("")) } localRepo, err := github.LocalRepo() utils.Check(err) project, err := localRepo.MainProject() utils.Check(err) gh := github.NewClient(project.Host) release, err := gh.FetchRelease(project, tagName) utils.Check(err) for _, asset := range release.Assets { ui.Printf("Downloading %s ...\n", asset.Name) err := downloadReleaseAsset(asset, gh) utils.Check(err) } args.NoForward() } func downloadReleaseAsset(asset github.ReleaseAsset, gh *github.Client) (err error) { assetReader, err := gh.DownloadReleaseAsset(asset.ApiUrl) if err != nil { return } defer assetReader.Close() assetFile, err := os.OpenFile(asset.Name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) if err != nil { return } defer assetFile.Close() _, err = io.Copy(assetFile, assetReader) if err != nil { return } return } func createRelease(cmd *Command, args *Args) { tagName := "" if args.ParamsSize() > 0 { tagName = args.GetParam(0) } if tagName == "" { utils.Check(cmd.UsageError("")) return } localRepo, err := github.LocalRepo() utils.Check(err) project, err := localRepo.MainProject() utils.Check(err) gh := github.NewClient(project.Host) messageBuilder := &github.MessageBuilder{ Filename: "RELEASE_EDITMSG", Title: "release", } messageBuilder.AddCommentedSection(fmt.Sprintf(`Creating release %s for %s Write a message for this release. The first block of text is the title and the rest is the description.`, tagName, project)) flagReleaseMessage := args.Flag.AllValues("--message") if len(flagReleaseMessage) > 0 { messageBuilder.Message = strings.Join(flagReleaseMessage, "\n\n") messageBuilder.Edit = args.Flag.Bool("--edit") } else if args.Flag.HasReceived("--file") { messageBuilder.Message, err = msgFromFile(args.Flag.Value("--file")) utils.Check(err) messageBuilder.Edit = args.Flag.Bool("--edit") } else { messageBuilder.Edit = true } title, body, err := messageBuilder.Extract() utils.Check(err) if title == "" { utils.Check(fmt.Errorf("Aborting release due to empty release title")) } params := &github.Release{ TagName: tagName, TargetCommitish: args.Flag.Value("--commitish"), Name: title, Body: body, Draft: args.Flag.Bool("--draft"), Prerelease: args.Flag.Bool("--prerelease"), } var release *github.Release args.NoForward() if args.Noop { ui.Printf("Would create release `%s' for %s with tag name `%s'\n", title, project, tagName) } else { release, err = gh.CreateRelease(project, params) utils.Check(err) flagReleaseBrowse := args.Flag.Bool("--browse") flagReleaseCopy := args.Flag.Bool("--copy") printBrowseOrCopy(args, release.HtmlUrl, flagReleaseBrowse, flagReleaseCopy) } messageBuilder.Cleanup() flagReleaseAssets := args.Flag.AllValues("--attach") uploadAssets(gh, release, flagReleaseAssets, args) } func editRelease(cmd *Command, args *Args) { tagName := "" if args.ParamsSize() > 0 { tagName = args.GetParam(0) } if tagName == "" { utils.Check(cmd.UsageError("")) return } localRepo, err := github.LocalRepo() utils.Check(err) project, err := localRepo.MainProject() utils.Check(err) gh := github.NewClient(project.Host) release, err := gh.FetchRelease(project, tagName) utils.Check(err) params := map[string]interface{}{} if args.Flag.HasReceived("--commitish") { params["target_commitish"] = args.Flag.Value("--commitish") } if args.Flag.HasReceived("--draft") { params["draft"] = args.Flag.Bool("--draft") } if args.Flag.HasReceived("--prerelease") { params["prerelease"] = args.Flag.Bool("--prerelease") } messageBuilder := &github.MessageBuilder{ Filename: "RELEASE_EDITMSG", Title: "release", } messageBuilder.AddCommentedSection(fmt.Sprintf(`Editing release %s for %s Write a message for this release. The first block of text is the title and the rest is the description.`, tagName, project)) flagReleaseMessage := args.Flag.AllValues("--message") if len(flagReleaseMessage) > 0 { messageBuilder.Message = strings.Join(flagReleaseMessage, "\n\n") messageBuilder.Edit = args.Flag.Bool("--edit") } else if args.Flag.HasReceived("--file") { messageBuilder.Message, err = msgFromFile(args.Flag.Value("--file")) utils.Check(err) messageBuilder.Edit = args.Flag.Bool("--edit") } else { messageBuilder.Edit = true messageBuilder.Message = fmt.Sprintf("%s\n\n%s", release.Name, release.Body) } title, body, err := messageBuilder.Extract() utils.Check(err) if title == "" && len(flagReleaseMessage) == 0 { utils.Check(fmt.Errorf("Aborting editing due to empty release title")) } if title != "" { params["name"] = title } if body != "" { params["body"] = body } if len(params) > 0 { if args.Noop { ui.Printf("Would edit release `%s'\n", tagName) } else { release, err = gh.EditRelease(release, params) utils.Check(err) } messageBuilder.Cleanup() } flagReleaseAssets := args.Flag.AllValues("--attach") uploadAssets(gh, release, flagReleaseAssets, args) args.NoForward() } func deleteRelease(cmd *Command, args *Args) { tagName := "" if args.ParamsSize() > 0 { tagName = args.GetParam(0) } if tagName == "" { utils.Check(cmd.UsageError("")) return } localRepo, err := github.LocalRepo() utils.Check(err) project, err := localRepo.MainProject() utils.Check(err) gh := github.NewClient(project.Host) release, err := gh.FetchRelease(project, tagName) utils.Check(err) if args.Noop { message := fmt.Sprintf("Deleting release related to %s...", tagName) ui.Println(message) } else { err = gh.DeleteRelease(release) utils.Check(err) } args.NoForward() } func uploadAssets(gh *github.Client, release *github.Release, assets []string, args *Args) { for _, asset := range assets { var label string parts := strings.SplitN(asset, "#", 2) asset = parts[0] if len(parts) > 1 { label = parts[1] } if args.Noop { if label == "" { ui.Errorf("Would attach release asset `%s'\n", asset) } else { ui.Errorf("Would attach release asset `%s' with label `%s'\n", asset, label) } } else { for _, existingAsset := range release.Assets { if existingAsset.Name == filepath.Base(asset) { err := gh.DeleteReleaseAsset(&existingAsset) utils.Check(err) break } } ui.Errorf("Attaching release asset `%s'...\n", asset) _, err := gh.UploadReleaseAsset(release, asset, label) utils.Check(err) } } }