Add release download, upload files on create, upload retrying

This commit is contained in:
Mislav Marohnić 2020-08-21 17:54:58 +02:00
parent 4e05db97e4
commit a00d927970
14 changed files with 727 additions and 309 deletions

View file

@ -190,10 +190,9 @@ type HTTPError struct {
}
func (err HTTPError) Error() string {
if err.Message != "" {
if msgs := strings.SplitN(err.Message, "\n", 2); len(msgs) > 1 {
return fmt.Sprintf("HTTP %d: %s (%s)\n%s", err.StatusCode, msgs[0], err.RequestURL, msgs[1])
}
if msgs := strings.SplitN(err.Message, "\n", 2); len(msgs) > 1 {
return fmt.Sprintf("HTTP %d: %s (%s)\n%s", err.StatusCode, msgs[0], err.RequestURL, msgs[1])
} else if err.Message != "" {
return fmt.Sprintf("HTTP %d: %s (%s)", err.StatusCode, err.Message, err.RequestURL)
}
return fmt.Sprintf("HTTP %d (%s)", err.StatusCode, err.RequestURL)

View file

@ -76,10 +76,19 @@ func TestRESTGetDelete(t *testing.T) {
}
func TestRESTError(t *testing.T) {
http := &httpmock.Registry{}
client := NewClient(ReplaceTripper(http))
fakehttp := &httpmock.Registry{}
client := NewClient(ReplaceTripper(fakehttp))
http.StubResponse(422, bytes.NewBufferString(`{"message": "OH NO"}`))
fakehttp.Register(httpmock.MatchAny, func(req *http.Request) (*http.Response, error) {
return &http.Response{
Request: req,
StatusCode: 422,
Body: ioutil.NopCloser(bytes.NewBufferString(`{"message": "OH NO"}`)),
Header: map[string][]string{
"Content-Type": {"application/json; charset=utf-8"},
},
}, nil
})
var httpErr HTTPError
err := client.REST("github.com", "DELETE", "repos/branch", nil, nil)

View file

@ -1,15 +1,13 @@
package list
package create
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strings"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/release/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/spf13/cobra"
@ -20,7 +18,18 @@ type CreateOptions struct {
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
TagName string
TagName string
Target string
Name string
Body string
BodyProvided bool
Draft bool
Prerelease bool
Assets []*shared.AssetForUpload
// maximum number of simultaneous uploads
Concurrency int
}
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
@ -29,16 +38,41 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
HttpClient: f.HttpClient,
}
var notesFile string
cmd := &cobra.Command{
Use: "create <tag>",
Use: "create <tag> [<files>...]",
Short: "Create a new release",
Args: cobra.ExactArgs(1),
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
opts.TagName = args[0]
var err error
opts.Assets, err = shared.AssetsFromArgs(args[1:])
if err != nil {
return err
}
opts.Concurrency = 5
opts.BodyProvided = cmd.Flags().Changed("notes")
if notesFile != "" {
var b []byte
if notesFile == "-" {
b, err = ioutil.ReadAll(opts.IO.In)
} else {
b, err = ioutil.ReadFile(notesFile)
}
if err != nil {
return err
}
opts.Body = string(b)
opts.BodyProvided = true
}
if runF != nil {
return runF(opts)
}
@ -46,6 +80,13 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
},
}
cmd.Flags().BoolVarP(&opts.Draft, "draft", "d", false, "Save the release as a draft instead of publishing it")
cmd.Flags().BoolVarP(&opts.Prerelease, "prerelease", "p", false, "Mark the release as a prerelease")
cmd.Flags().StringVar(&opts.Target, "target", "", "Target `branch` or commit SHA (default: main branch)")
cmd.Flags().StringVarP(&opts.Name, "title", "t", "", "Release title")
cmd.Flags().StringVarP(&opts.Body, "notes", "n", "", "Release notes")
cmd.Flags().StringVarP(&notesFile, "notes-file", "F", "", "Read release notes from `file`")
return cmd
}
@ -61,47 +102,45 @@ func createRun(opts *CreateOptions) error {
}
params := map[string]interface{}{
"tag_name": opts.TagName,
"tag_name": opts.TagName,
"draft": opts.Draft,
"prerelease": opts.Prerelease,
"name": opts.Name,
"body": opts.Body,
}
if opts.Target != "" {
params["target_commitish"] = opts.Target
}
bodyBytes, err := json.Marshal(params)
hasAssets := len(opts.Assets) > 0
if hasAssets {
params["draft"] = true
}
newRelease, err := createRelease(httpClient, baseRepo, params)
if err != nil {
return err
}
path := fmt.Sprintf("repos/%s/%s/releases", baseRepo.RepoOwner(), baseRepo.RepoName())
url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path
req, err := http.NewRequest("POST", url, bytes.NewBuffer(bodyBytes))
if err != nil {
return err
}
if hasAssets {
uploadURL := newRelease.UploadURL
if idx := strings.IndexRune(uploadURL, '{'); idx > 0 {
uploadURL = uploadURL[:idx]
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
opts.IO.StartProgressIndicator()
err = shared.ConcurrentUpload(httpClient, uploadURL, opts.Concurrency, opts.Assets)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
var newRelease struct {
HTMLURL string `json:"html_url"`
AssetsURL string `json:"assets_url"`
}
err = json.Unmarshal(b, &newRelease)
if err != nil {
return err
if !opts.Draft {
err := publishRelease(httpClient, newRelease.URL)
if err != nil {
return err
}
}
}
fmt.Fprintf(opts.IO.Out, "%s\n", newRelease.HTMLURL)

View file

@ -0,0 +1,73 @@
package create
import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/release/shared"
)
func createRelease(httpClient *http.Client, repo ghrepo.Interface, params map[string]interface{}) (*shared.Release, error) {
bodyBytes, err := json.Marshal(params)
if err != nil {
return nil, err
}
path := fmt.Sprintf("repos/%s/%s/releases", repo.RepoOwner(), repo.RepoName())
url := ghinstance.RESTPrefix(repo.RepoHost()) + path
req, err := http.NewRequest("POST", url, bytes.NewBuffer(bodyBytes))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var newRelease shared.Release
err = json.Unmarshal(b, &newRelease)
return &newRelease, err
}
func publishRelease(httpClient *http.Client, releaseURL string) error {
req, err := http.NewRequest("PATCH", releaseURL, bytes.NewBufferString(`{"draft":false}`))
if err != nil {
return err
}
req.Header.Add("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode > 299 {
return api.HandleHTTPError(resp)
}
_, err = io.Copy(ioutil.Discard, resp.Body)
return err
}

View file

@ -0,0 +1,160 @@
package download
import (
"errors"
"io"
"net/http"
"os"
"path/filepath"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/release/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/spf13/cobra"
)
type DownloadOptions struct {
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
TagName string
FilePattern string
Destination string
// maximum number of simultaneous downloads
Concurrency int
}
func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobra.Command {
opts := &DownloadOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
}
cmd := &cobra.Command{
Use: "download <tag> [<pattern>]",
Short: "Download release assets",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
opts.TagName = args[0]
if len(args) > 1 {
opts.FilePattern = args[1]
}
opts.Concurrency = 5
if runF != nil {
return runF(opts)
}
return downloadRun(opts)
},
}
cmd.Flags().StringVarP(&opts.Destination, "destination", "C", ".", "The directory to download files into")
return cmd
}
func downloadRun(opts *DownloadOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
baseRepo, err := opts.BaseRepo()
if err != nil {
return err
}
release, err := shared.FetchRelease(httpClient, baseRepo, opts.TagName)
if err != nil {
return err
}
var toDownload []shared.ReleaseAsset
for _, a := range release.Assets {
if opts.FilePattern != "" {
if isMatch, err := filepath.Match(opts.FilePattern, a.Name); err != nil || !isMatch {
continue
}
}
toDownload = append(toDownload, a)
}
if opts.Destination != "." {
err := os.MkdirAll(opts.Destination, 0755)
if err != nil {
return err
}
}
opts.IO.StartProgressIndicator()
err = downloadAssets(httpClient, toDownload, opts.Destination, opts.Concurrency)
opts.IO.StopProgressIndicator()
return err
}
func downloadAssets(httpClient *http.Client, toDownload []shared.ReleaseAsset, destDir string, numWorkers int) error {
if numWorkers == 0 {
return errors.New("the number of concurrent workers needs to be greater than 0")
}
jobs := make(chan shared.ReleaseAsset, len(toDownload))
results := make(chan error, len(toDownload))
for w := 1; w <= numWorkers; w++ {
go func() {
for a := range jobs {
results <- downloadAsset(httpClient, a.URL, filepath.Join(destDir, a.Name))
}
}()
}
for _, a := range toDownload {
jobs <- a
}
close(jobs)
var downloadError error
for i := 0; i < len(toDownload); i++ {
if err := <-results; err != nil {
downloadError = err
}
}
return downloadError
}
func downloadAsset(httpClient *http.Client, assetURL, destinationPath string) error {
req, err := http.NewRequest("GET", assetURL, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/octet-stream")
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode > 299 {
return api.HandleHTTPError(resp)
}
f, err := os.OpenFile(destinationPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, resp.Body)
return err
}

View file

@ -2,6 +2,7 @@ package release
import (
cmdCreate "github.com/cli/cli/pkg/cmd/release/create"
cmdDownload "github.com/cli/cli/pkg/cmd/release/download"
cmdList "github.com/cli/cli/pkg/cmd/release/list"
cmdUpload "github.com/cli/cli/pkg/cmd/release/upload"
cmdView "github.com/cli/cli/pkg/cmd/release/view"
@ -21,6 +22,7 @@ func NewCmdRelease(f *cmdutil.Factory) *cobra.Command {
cmdutil.EnableRepoOverride(cmd, f)
cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil))
cmd.AddCommand(cmdDownload.NewCmdDownload(f, nil))
cmd.AddCommand(cmdList.NewCmdList(f, nil))
cmd.AddCommand(cmdView.NewCmdView(f, nil))
cmd.AddCommand(cmdUpload.NewCmdUpload(f, nil))

View file

@ -0,0 +1,74 @@
package shared
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"time"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/internal/ghrepo"
)
type Release struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
IsDraft bool `json:"draft"`
IsPrerelease bool `json:"prerelease"`
PublishedAt time.Time `json:"published_at"`
URL string `json:"url"`
UploadURL string `json:"upload_url"`
HTMLURL string `json:"html_url"`
Assets []ReleaseAsset
Author struct {
Login string
}
}
type ReleaseAsset struct {
Name string
Size int64
State string
URL string
}
func FetchRelease(httpClient *http.Client, baseRepo ghrepo.Interface, tagName string) (*Release, error) {
// FIXME: this doesn't find draft releases
path := fmt.Sprintf("repos/%s/%s/releases/tags/%s", baseRepo.RepoOwner(), baseRepo.RepoName(), tagName)
url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var release Release
err = json.Unmarshal(b, &release)
if err != nil {
return nil, err
}
return &release, nil
}

View file

@ -0,0 +1,187 @@
package shared
import (
"encoding/json"
"errors"
"io"
"io/ioutil"
"mime"
"net/http"
"net/url"
"os"
"path"
"strings"
"time"
"github.com/cli/cli/api"
)
type AssetForUpload struct {
Name string
Label string
Size int64
MIMEType string
Open func() (io.ReadCloser, error)
ExistingURL string
}
func AssetsFromArgs(args []string) (assets []*AssetForUpload, err error) {
for _, arg := range args {
var label string
fn := arg
if idx := strings.IndexRune(arg, '#'); idx > 0 {
fn = arg[0:idx]
label = arg[idx+1:]
}
var fi os.FileInfo
fi, err = os.Stat(fn)
if err != nil {
return
}
assets = append(assets, &AssetForUpload{
Open: func() (io.ReadCloser, error) {
return os.Open(fn)
},
Size: fi.Size(),
Name: fi.Name(),
Label: label,
MIMEType: typeForFilename(fi.Name()),
})
}
return
}
func typeForFilename(fn string) string {
ext := path.Ext(fn)
t := mime.TypeByExtension(ext)
if t == "" {
return "application/octet-stream"
}
return t
}
func ConcurrentUpload(httpClient *http.Client, uploadURL string, numWorkers int, assets []*AssetForUpload) error {
if numWorkers == 0 {
return errors.New("the number of concurrent workers needs to be greater than 0")
}
jobs := make(chan AssetForUpload, len(assets))
results := make(chan error, len(assets))
for w := 1; w <= numWorkers; w++ {
go func() {
for a := range jobs {
results <- uploadWithDelete(httpClient, uploadURL, a)
}
}()
}
for _, a := range assets {
jobs <- *a
}
close(jobs)
var uploadError error
for i := 0; i < len(assets); i++ {
if err := <-results; err != nil {
uploadError = err
}
}
return uploadError
}
const maxRetries = 3
func uploadWithDelete(httpClient *http.Client, uploadURL string, a AssetForUpload) error {
if a.ExistingURL != "" {
err := deleteAsset(httpClient, a.ExistingURL)
if err != nil {
return err
}
}
retries := 0
for {
var httpError api.HTTPError
_, err := uploadAsset(httpClient, uploadURL, a)
// retry upload several times upon receiving HTTP 5xx
if err == nil || !errors.As(err, &httpError) || httpError.StatusCode < 500 || retries < maxRetries {
return err
}
retries++
time.Sleep(time.Duration(retries) * time.Second)
}
}
func uploadAsset(httpClient *http.Client, uploadURL string, asset AssetForUpload) (*ReleaseAsset, error) {
u, err := url.Parse(uploadURL)
if err != nil {
return nil, err
}
params := u.Query()
params.Set("name", asset.Name)
params.Set("label", asset.Label)
u.RawQuery = params.Encode()
f, err := asset.Open()
if err != nil {
return nil, err
}
defer f.Close()
req, err := http.NewRequest("POST", u.String(), f)
if err != nil {
return nil, err
}
req.ContentLength = asset.Size
req.Header.Set("Content-Type", asset.MIMEType)
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var newAsset ReleaseAsset
err = json.Unmarshal(b, &newAsset)
if err != nil {
return nil, err
}
return &newAsset, nil
}
func deleteAsset(httpClient *http.Client, assetURL string) error {
req, err := http.NewRequest("DELETE", assetURL, nil)
if err != nil {
return err
}
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return api.HandleHTTPError(resp)
}
return nil
}

View file

@ -1,121 +0,0 @@
package upload
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/internal/ghrepo"
)
type Release struct {
UploadURL string `json:"upload_url"`
Assets []ReleaseAsset
}
type ReleaseAsset struct {
Name string
State string
URL string
}
func fetchRelease(httpClient *http.Client, baseRepo ghrepo.Interface, tagName string) (*Release, error) {
path := fmt.Sprintf("repos/%s/%s/releases/tags/%s", baseRepo.RepoOwner(), baseRepo.RepoName(), tagName)
url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var release Release
err = json.Unmarshal(b, &release)
if err != nil {
return nil, err
}
return &release, nil
}
func uploadAsset(httpClient *http.Client, uploadURL string, asset AssetForUpload) (*ReleaseAsset, error) {
u, err := url.Parse(uploadURL)
if err != nil {
return nil, err
}
params := u.Query()
params.Set("name", asset.Name)
params.Set("label", asset.Label)
u.RawQuery = params.Encode()
req, err := http.NewRequest("POST", u.String(), asset.Data)
if err != nil {
return nil, err
}
req.ContentLength = asset.Size
req.Header.Set("Content-Type", asset.MIMEType)
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var newAsset ReleaseAsset
err = json.Unmarshal(b, &newAsset)
if err != nil {
return nil, err
}
return &newAsset, nil
}
func deleteAsset(httpClient *http.Client, assetURL string) error {
req, err := http.NewRequest("DELETE", assetURL, nil)
if err != nil {
return err
}
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return api.HandleHTTPError(resp)
}
return nil
}

View file

@ -2,13 +2,11 @@ package upload
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/release/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/spf13/cobra"
@ -20,24 +18,13 @@ type UploadOptions struct {
BaseRepo func() (ghrepo.Interface, error)
TagName string
Assets []*AssetForUpload
Assets []*shared.AssetForUpload
// maximum number of simultaneous uploads
Concurrency int
OverwriteExisting bool
}
type AssetForUpload struct {
Name string
Label string
Data io.ReadCloser
Size int64
MIMEType string
ExistingURL string
}
func NewCmdUpload(f *cmdutil.Factory, runF func(*UploadOptions) error) *cobra.Command {
opts := &UploadOptions{
IO: f.IOStreams,
@ -55,7 +42,7 @@ func NewCmdUpload(f *cmdutil.Factory, runF func(*UploadOptions) error) *cobra.Co
opts.TagName = args[0]
var err error
opts.Assets, err = assetsFromArgs(args[1:])
opts.Assets, err = shared.AssetsFromArgs(args[1:])
if err != nil {
return err
}
@ -74,32 +61,6 @@ func NewCmdUpload(f *cmdutil.Factory, runF func(*UploadOptions) error) *cobra.Co
return cmd
}
func assetsFromArgs(args []string) (assets []*AssetForUpload, err error) {
for _, fn := range args {
var f *os.File
var fi os.FileInfo
f, err = os.Open(fn)
if err != nil {
return
}
fi, err = f.Stat()
if err != nil {
return
}
assets = append(assets, &AssetForUpload{
Data: f,
Size: fi.Size(),
Name: filepath.Base(fn),
Label: "",
// TODO: infer content type from file extension
MIMEType: "application/octet-stream",
})
}
return
}
func uploadRun(opts *UploadOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
@ -111,7 +72,7 @@ func uploadRun(opts *UploadOptions) error {
return err
}
release, err := fetchRelease(httpClient, baseRepo, opts.TagName)
release, err := shared.FetchRelease(httpClient, baseRepo, opts.TagName)
if err != nil {
return err
}
@ -137,50 +98,11 @@ func uploadRun(opts *UploadOptions) error {
}
opts.IO.StartProgressIndicator()
err = concurrentUpload(httpClient, uploadURL, opts.Concurrency, opts.Assets)
opts.IO.EndProgressIndicator()
err = shared.ConcurrentUpload(httpClient, uploadURL, opts.Concurrency, opts.Assets)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}
return nil
}
func concurrentUpload(httpClient *http.Client, uploadURL string, numWorkers int, assets []*AssetForUpload) error {
jobs := make(chan AssetForUpload, len(assets))
results := make(chan error, len(assets))
for w := 1; w <= numWorkers; w++ {
go func() {
for a := range jobs {
results <- uploadWithDelete(httpClient, uploadURL, a)
}
}()
}
for _, a := range assets {
jobs <- *a
}
close(jobs)
var uploadError error
for i := 0; i < len(assets); i++ {
if err := <-results; err != nil {
uploadError = err
}
}
return uploadError
}
func uploadWithDelete(httpClient *http.Client, uploadURL string, a AssetForUpload) error {
if a.ExistingURL != "" {
err := deleteAsset(httpClient, a.ExistingURL)
if err != nil {
return err
}
}
defer a.Data.Close()
_, err := uploadAsset(httpClient, uploadURL, a)
return err
}

View file

@ -1,51 +0,0 @@
package view
import (
"context"
"net/http"
"time"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/internal/ghrepo"
"github.com/shurcooL/githubv4"
"github.com/shurcooL/graphql"
)
type Release struct {
TagName string
Name string
Description string
URL string
IsDraft bool
IsPrerelease bool
PublishedAt time.Time
Author struct {
Login string
}
ReleaseAssets struct {
Nodes []struct {
Name string
Size int
}
} `graphql:"releaseAssets(first: 100)"`
}
func fetchRelease(httpClient *http.Client, repo ghrepo.Interface, tagName string) (*Release, error) {
var query struct {
Repository struct {
Release *Release `graphql:"release(tagName: $tagName)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()),
"name": githubv4.String(repo.RepoName()),
"tagName": githubv4.String(tagName),
}
gql := graphql.NewClient(ghinstance.GraphQLEndpoint(repo.RepoHost()), httpClient)
err := gql.QueryNamed(context.Background(), "RepositoryReleaseByTag", &query, variables)
return query.Repository.Release, err
}

View file

@ -3,8 +3,10 @@ package view
import (
"fmt"
"net/http"
"time"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/release/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/utils"
@ -56,20 +58,61 @@ func viewRun(opts *ViewOptions) error {
return err
}
release, err := fetchRelease(httpClient, baseRepo, opts.TagName)
release, err := shared.FetchRelease(httpClient, baseRepo, opts.TagName)
if err != nil {
return err
}
fmt.Fprintf(opts.IO.Out, "%s\n", release.TagName)
iofmt := opts.IO.ColorScheme()
fmt.Fprintf(opts.IO.Out, "%s\n", iofmt.Bold(release.TagName))
if release.IsDraft {
fmt.Fprintf(opts.IO.Out, "%s • ", iofmt.Red("Draft"))
} else if release.IsPrerelease {
fmt.Fprintf(opts.IO.Out, "%s • ", iofmt.Yellow("Pre-release"))
}
fmt.Fprintf(opts.IO.Out, "%s\n", iofmt.Gray(fmt.Sprintf("%s released this %s", release.Author.Login, utils.FuzzyAgo(time.Since(release.PublishedAt)))))
renderedDescription, err := utils.RenderMarkdown(release.Description)
renderedDescription, err := utils.RenderMarkdown(release.Body)
if err != nil {
return err
}
fmt.Fprintln(opts.IO.Out, renderedDescription)
fmt.Fprintf(opts.IO.Out, "%s\n", release.URL)
if len(release.Assets) > 0 {
fmt.Fprintf(opts.IO.Out, "%s\n", iofmt.Bold("Assets"))
table := utils.NewTablePrinter(opts.IO)
for _, a := range release.Assets {
table.AddField(a.Name, nil, nil)
table.AddField(humanFileSize(a.Size), nil, nil)
table.EndRow()
}
err := table.Render()
if err != nil {
return err
}
fmt.Fprint(opts.IO.Out, "\n")
}
fmt.Fprintf(opts.IO.Out, "%s\n", iofmt.Gray(fmt.Sprintf("View on GitHub: %s", release.HTMLURL)))
return nil
}
func humanFileSize(s int64) string {
if s < 1024 {
return fmt.Sprintf("%d B", s)
}
kb := float64(s) / 1024
if kb < 1024 {
return fmt.Sprintf("%.2f KiB", kb)
}
mb := float64(kb) / 1024
if mb < 1024 {
return fmt.Sprintf("%.2f MiB", mb)
}
gb := float64(kb) / 1024
return fmt.Sprintf("%.2f GiB", gb)
}

78
pkg/iostreams/color.go Normal file
View file

@ -0,0 +1,78 @@
package iostreams
import "github.com/mgutz/ansi"
var (
magenta = ansi.ColorFunc("magenta")
cyan = ansi.ColorFunc("cyan")
red = ansi.ColorFunc("red")
yellow = ansi.ColorFunc("yellow")
blue = ansi.ColorFunc("blue")
green = ansi.ColorFunc("green")
gray = ansi.ColorFunc("black+h")
bold = ansi.ColorFunc("default+b")
)
func NewColorScheme(enabled bool) *ColorScheme {
return &ColorScheme{enabled: enabled}
}
type ColorScheme struct {
enabled bool
}
func (c *ColorScheme) Bold(t string) string {
if !c.enabled {
return t
}
return bold(t)
}
func (c *ColorScheme) Red(t string) string {
if !c.enabled {
return t
}
return red(t)
}
func (c *ColorScheme) Yellow(t string) string {
if !c.enabled {
return t
}
return yellow(t)
}
func (c *ColorScheme) Green(t string) string {
if !c.enabled {
return t
}
return green(t)
}
func (c *ColorScheme) Gray(t string) string {
if !c.enabled {
return t
}
return gray(t)
}
func (c *ColorScheme) Magenta(t string) string {
if !c.enabled {
return t
}
return magenta(t)
}
func (c *ColorScheme) Cyan(t string) string {
if !c.enabled {
return t
}
return cyan(t)
}
func (c *ColorScheme) Blue(t string) string {
if !c.enabled {
return t
}
return blue(t)
}

View file

@ -93,7 +93,7 @@ func (s *IOStreams) StartProgressIndicator() {
s.progressIndicator = sp
}
func (s *IOStreams) EndProgressIndicator() {
func (s *IOStreams) StopProgressIndicator() {
if s.progressIndicator == nil {
return
}
@ -124,6 +124,10 @@ func (s *IOStreams) TerminalWidth() int {
return defaultWidth
}
func (s *IOStreams) ColorScheme() *ColorScheme {
return NewColorScheme(s.IsStdoutTTY())
}
func System() *IOStreams {
var out io.Writer = os.Stdout
stdoutIsTTY := isTerminal(os.Stdout)