diff --git a/api/client.go b/api/client.go index e30eb18b1..3ee54a406 100644 --- a/api/client.go +++ b/api/client.go @@ -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) diff --git a/api/client_test.go b/api/client_test.go index 063d4648c..d2925bd20 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -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) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index afb30e829..879081b92 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -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 ", + Use: "create [...]", 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(¬esFile, "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) diff --git a/pkg/cmd/release/create/http.go b/pkg/cmd/release/create/http.go new file mode 100644 index 000000000..684772c12 --- /dev/null +++ b/pkg/cmd/release/create/http.go @@ -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 +} diff --git a/pkg/cmd/release/download/download.go b/pkg/cmd/release/download/download.go new file mode 100644 index 000000000..55bf8e3ce --- /dev/null +++ b/pkg/cmd/release/download/download.go @@ -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 []", + 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 +} diff --git a/pkg/cmd/release/release.go b/pkg/cmd/release/release.go index dedf38afe..06289b81b 100644 --- a/pkg/cmd/release/release.go +++ b/pkg/cmd/release/release.go @@ -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)) diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go new file mode 100644 index 000000000..3d568ab2d --- /dev/null +++ b/pkg/cmd/release/shared/fetch.go @@ -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 +} diff --git a/pkg/cmd/release/shared/upload.go b/pkg/cmd/release/shared/upload.go new file mode 100644 index 000000000..a8174f467 --- /dev/null +++ b/pkg/cmd/release/shared/upload.go @@ -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 +} diff --git a/pkg/cmd/release/upload/http.go b/pkg/cmd/release/upload/http.go deleted file mode 100644 index 8b66c690d..000000000 --- a/pkg/cmd/release/upload/http.go +++ /dev/null @@ -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 -} diff --git a/pkg/cmd/release/upload/upload.go b/pkg/cmd/release/upload/upload.go index beefb55d5..17af4584d 100644 --- a/pkg/cmd/release/upload/upload.go +++ b/pkg/cmd/release/upload/upload.go @@ -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 -} diff --git a/pkg/cmd/release/view/http.go b/pkg/cmd/release/view/http.go deleted file mode 100644 index 7422bf40c..000000000 --- a/pkg/cmd/release/view/http.go +++ /dev/null @@ -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 -} diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go index 054d0021d..9ac43a7c3 100644 --- a/pkg/cmd/release/view/view.go +++ b/pkg/cmd/release/view/view.go @@ -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) +} diff --git a/pkg/iostreams/color.go b/pkg/iostreams/color.go new file mode 100644 index 000000000..7643984a1 --- /dev/null +++ b/pkg/iostreams/color.go @@ -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) +} diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 25d447371..7084fe092 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -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)