Add release download, upload files on create, upload retrying
This commit is contained in:
parent
4e05db97e4
commit
a00d927970
14 changed files with 727 additions and 309 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(¬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)
|
||||
|
|
|
|||
73
pkg/cmd/release/create/http.go
Normal file
73
pkg/cmd/release/create/http.go
Normal 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
|
||||
}
|
||||
160
pkg/cmd/release/download/download.go
Normal file
160
pkg/cmd/release/download/download.go
Normal 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
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
74
pkg/cmd/release/shared/fetch.go
Normal file
74
pkg/cmd/release/shared/fetch.go
Normal 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
|
||||
}
|
||||
187
pkg/cmd/release/shared/upload.go
Normal file
187
pkg/cmd/release/shared/upload.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
78
pkg/iostreams/color.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue