Add release upload command
This commit is contained in:
parent
c4f5d6db58
commit
4e05db97e4
5 changed files with 345 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
121
pkg/cmd/release/upload/http.go
Normal file
121
pkg/cmd/release/upload/http.go
Normal 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
|
||||
}
|
||||
186
pkg/cmd/release/upload/upload.go
Normal file
186
pkg/cmd/release/upload/upload.go
Normal 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
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue