diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 0e492dcc6..afb30e829 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -89,10 +89,6 @@ func createRun(opts *CreateOptions) error { return api.HandleHTTPError(resp) } - if resp.StatusCode == http.StatusNoContent { - return nil - } - b, err := ioutil.ReadAll(resp.Body) if err != nil { return err diff --git a/pkg/cmd/release/release.go b/pkg/cmd/release/release.go index de2613acd..dedf38afe 100644 --- a/pkg/cmd/release/release.go +++ b/pkg/cmd/release/release.go @@ -3,6 +3,7 @@ package release import ( cmdCreate "github.com/cli/cli/pkg/cmd/release/create" 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" "github.com/cli/cli/pkg/cmdutil" "github.com/spf13/cobra" @@ -22,6 +23,7 @@ func NewCmdRelease(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil)) cmd.AddCommand(cmdList.NewCmdList(f, nil)) cmd.AddCommand(cmdView.NewCmdView(f, nil)) + cmd.AddCommand(cmdUpload.NewCmdUpload(f, nil)) return cmd } diff --git a/pkg/cmd/release/upload/http.go b/pkg/cmd/release/upload/http.go new file mode 100644 index 000000000..8b66c690d --- /dev/null +++ b/pkg/cmd/release/upload/http.go @@ -0,0 +1,121 @@ +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 new file mode 100644 index 000000000..beefb55d5 --- /dev/null +++ b/pkg/cmd/release/upload/upload.go @@ -0,0 +1,186 @@ +package upload + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" +) + +type UploadOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + TagName string + Assets []*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, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "upload ...", + Short: "Upload assets to a release", + Args: cobra.MinimumNArgs(2), + 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 = assetsFromArgs(args[1:]) + if err != nil { + return err + } + + opts.Concurrency = 5 + + if runF != nil { + return runF(opts) + } + return uploadRun(opts) + }, + } + + cmd.Flags().BoolVar(&opts.OverwriteExisting, "clobber", false, "Overwrite existing assets of the same name") + + 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 { + return err + } + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + release, err := fetchRelease(httpClient, baseRepo, opts.TagName) + if err != nil { + return err + } + + uploadURL := release.UploadURL + if idx := strings.IndexRune(uploadURL, '{'); idx > 0 { + uploadURL = uploadURL[:idx] + } + + var existingNames []string + for _, a := range opts.Assets { + for _, ea := range release.Assets { + if ea.Name == a.Name { + a.ExistingURL = ea.URL + existingNames = append(existingNames, ea.Name) + break + } + } + } + + if len(existingNames) > 0 && !opts.OverwriteExisting { + return fmt.Errorf("asset under the same name already exists: %v", existingNames) + } + + opts.IO.StartProgressIndicator() + err = concurrentUpload(httpClient, uploadURL, opts.Concurrency, opts.Assets) + opts.IO.EndProgressIndicator() + 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/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index cf69ecd8c..25d447371 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -9,7 +9,9 @@ import ( "os/exec" "strconv" "strings" + "time" + "github.com/briandowns/spinner" "github.com/mattn/go-colorable" "github.com/mattn/go-isatty" "golang.org/x/crypto/ssh/terminal" @@ -22,6 +24,9 @@ type IOStreams struct { colorEnabled bool + progressIndicatorEnabled bool + progressIndicator *spinner.Spinner + stdinTTYOverride bool stdinIsTTY bool stdoutTTYOverride bool @@ -79,6 +84,23 @@ func (s *IOStreams) IsStderrTTY() bool { return false } +func (s *IOStreams) StartProgressIndicator() { + if !s.progressIndicatorEnabled { + return + } + sp := spinner.New(spinner.CharSets[11], 400*time.Millisecond, spinner.WithWriter(s.ErrOut)) + sp.Start() + s.progressIndicator = sp +} + +func (s *IOStreams) EndProgressIndicator() { + if s.progressIndicator == nil { + return + } + s.progressIndicator.Stop() + s.progressIndicator = nil +} + func (s *IOStreams) TerminalWidth() int { defaultWidth := 80 if s.stdoutTTYOverride { @@ -104,18 +126,30 @@ func (s *IOStreams) TerminalWidth() int { func System() *IOStreams { var out io.Writer = os.Stdout + stdoutIsTTY := isTerminal(os.Stdout) + stderrIsTTY := isTerminal(os.Stderr) + var colorEnabled bool - if os.Getenv("NO_COLOR") == "" && isTerminal(os.Stdout) { + if os.Getenv("NO_COLOR") == "" && stdoutIsTTY { out = colorable.NewColorable(os.Stdout) colorEnabled = true } - return &IOStreams{ + io := &IOStreams{ In: os.Stdin, Out: out, ErrOut: os.Stderr, colorEnabled: colorEnabled, } + + if stdoutIsTTY && stderrIsTTY { + io.progressIndicatorEnabled = true + } + + // prevent duplicate isTerminal queries now that we know the answer + io.SetStdoutTTY(stdoutIsTTY) + io.SetStderrTTY(stderrIsTTY) + return io } func Test() (*IOStreams, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) {