Add release upload command

This commit is contained in:
Mislav Marohnić 2020-08-20 17:59:47 +02:00
parent c4f5d6db58
commit 4e05db97e4
5 changed files with 345 additions and 6 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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
}

View file

@ -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 <tag> <files>...",
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
}

View file

@ -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) {