X Tutup
Skip to content

Commit 14f704f

Browse files
authored
Merge pull request cli#4489 from lpessoa/lp-2167
Adding gh release download for .zip and .tar.gz
2 parents c987c57 + acc1759 commit 14f704f

File tree

3 files changed

+197
-19
lines changed

3 files changed

+197
-19
lines changed

pkg/cmd/release/download/download.go

Lines changed: 102 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ package download
22

33
import (
44
"errors"
5+
"fmt"
56
"io"
7+
"mime"
68
"net/http"
79
"os"
810
"path/filepath"
11+
"regexp"
912

1013
"github.com/MakeNowJust/heredoc"
1114
"github.com/cli/cli/v2/api"
@@ -27,6 +30,8 @@ type DownloadOptions struct {
2730

2831
// maximum number of simultaneous downloads
2932
Concurrency int
33+
34+
ArchiveType string
3035
}
3136

3237
func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobra.Command {
@@ -47,26 +52,34 @@ func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobr
4752
Example: heredoc.Doc(`
4853
# download all assets from a specific release
4954
$ gh release download v1.2.3
50-
55+
5156
# download only Debian packages for the latest release
5257
$ gh release download --pattern '*.deb'
53-
58+
5459
# specify multiple file patterns
5560
$ gh release download -p '*.deb' -p '*.rpm'
61+
62+
# download the archive of the source code for a release
63+
$ gh release download v1.2.3 --archive=zip
5664
`),
5765
Args: cobra.MaximumNArgs(1),
5866
RunE: func(cmd *cobra.Command, args []string) error {
5967
// support `-R, --repo` override
6068
opts.BaseRepo = f.BaseRepo
6169

6270
if len(args) == 0 {
63-
if len(opts.FilePatterns) == 0 {
64-
return cmdutil.FlagErrorf("the '--pattern' flag is required when downloading the latest release")
71+
if len(opts.FilePatterns) == 0 && opts.ArchiveType == "" {
72+
return cmdutil.FlagErrorf("`--pattern` or `--archive` is required when downloading the latest release")
6573
}
6674
} else {
6775
opts.TagName = args[0]
6876
}
6977

78+
// check archive type option validity
79+
if err := checkArchiveTypeOption(opts); err != nil {
80+
return err
81+
}
82+
7083
opts.Concurrency = 5
7184

7285
if runF != nil {
@@ -78,10 +91,30 @@ func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobr
7891

7992
cmd.Flags().StringVarP(&opts.Destination, "dir", "D", ".", "The directory to download files into")
8093
cmd.Flags().StringArrayVarP(&opts.FilePatterns, "pattern", "p", nil, "Download only assets that match a glob pattern")
94+
cmd.Flags().StringVarP(&opts.ArchiveType, "archive", "A", "", "Download the source code archive in the specified `format` (zip or tar.gz)")
8195

8296
return cmd
8397
}
8498

99+
func checkArchiveTypeOption(opts *DownloadOptions) error {
100+
if len(opts.ArchiveType) == 0 {
101+
return nil
102+
}
103+
104+
if err := cmdutil.MutuallyExclusive(
105+
"specify only one of '--pattern' or '--archive'",
106+
true, // ArchiveType len > 0
107+
len(opts.FilePatterns) > 0,
108+
); err != nil {
109+
return err
110+
}
111+
112+
if opts.ArchiveType != "zip" && opts.ArchiveType != "tar.gz" {
113+
return cmdutil.FlagErrorf("the value for `--archive` must be one of \"zip\" or \"tar.gz\"")
114+
}
115+
return nil
116+
}
117+
85118
func downloadRun(opts *DownloadOptions) error {
86119
httpClient, err := opts.HttpClient()
87120
if err != nil {
@@ -93,8 +126,10 @@ func downloadRun(opts *DownloadOptions) error {
93126
return err
94127
}
95128

96-
var release *shared.Release
129+
opts.IO.StartProgressIndicator()
130+
defer opts.IO.StopProgressIndicator()
97131

132+
var release *shared.Release
98133
if opts.TagName == "" {
99134
release, err = shared.FetchLatestRelease(httpClient, baseRepo)
100135
if err != nil {
@@ -108,11 +143,22 @@ func downloadRun(opts *DownloadOptions) error {
108143
}
109144

110145
var toDownload []shared.ReleaseAsset
111-
for _, a := range release.Assets {
112-
if len(opts.FilePatterns) > 0 && !matchAny(opts.FilePatterns, a.Name) {
113-
continue
146+
isArchive := false
147+
if opts.ArchiveType != "" {
148+
var archiveURL = release.ZipballURL
149+
if opts.ArchiveType == "tar.gz" {
150+
archiveURL = release.TarballURL
151+
}
152+
// create pseudo-Asset with no name and pointing to ZipBallURL or TarBallURL
153+
toDownload = append(toDownload, shared.ReleaseAsset{APIURL: archiveURL})
154+
isArchive = true
155+
} else {
156+
for _, a := range release.Assets {
157+
if len(opts.FilePatterns) > 0 && !matchAny(opts.FilePatterns, a.Name) {
158+
continue
159+
}
160+
toDownload = append(toDownload, a)
114161
}
115-
toDownload = append(toDownload, a)
116162
}
117163

118164
if len(toDownload) == 0 {
@@ -129,10 +175,7 @@ func downloadRun(opts *DownloadOptions) error {
129175
}
130176
}
131177

132-
opts.IO.StartProgressIndicator()
133-
err = downloadAssets(httpClient, toDownload, opts.Destination, opts.Concurrency)
134-
opts.IO.StopProgressIndicator()
135-
return err
178+
return downloadAssets(httpClient, toDownload, opts.Destination, opts.Concurrency, isArchive)
136179
}
137180

138181
func matchAny(patterns []string, name string) bool {
@@ -144,7 +187,7 @@ func matchAny(patterns []string, name string) bool {
144187
return false
145188
}
146189

147-
func downloadAssets(httpClient *http.Client, toDownload []shared.ReleaseAsset, destDir string, numWorkers int) error {
190+
func downloadAssets(httpClient *http.Client, toDownload []shared.ReleaseAsset, destDir string, numWorkers int, isArchive bool) error {
148191
if numWorkers == 0 {
149192
return errors.New("the number of concurrent workers needs to be greater than 0")
150193
}
@@ -159,7 +202,7 @@ func downloadAssets(httpClient *http.Client, toDownload []shared.ReleaseAsset, d
159202
for w := 1; w <= numWorkers; w++ {
160203
go func() {
161204
for a := range jobs {
162-
results <- downloadAsset(httpClient, a.APIURL, filepath.Join(destDir, a.Name))
205+
results <- downloadAsset(httpClient, a.APIURL, destDir, a.Name, isArchive)
163206
}
164207
}()
165208
}
@@ -179,13 +222,27 @@ func downloadAssets(httpClient *http.Client, toDownload []shared.ReleaseAsset, d
179222
return downloadError
180223
}
181224

182-
func downloadAsset(httpClient *http.Client, assetURL, destinationPath string) error {
225+
func downloadAsset(httpClient *http.Client, assetURL, destinationDir string, fileName string, isArchive bool) error {
183226
req, err := http.NewRequest("GET", assetURL, nil)
184227
if err != nil {
185228
return err
186229
}
187230

188231
req.Header.Set("Accept", "application/octet-stream")
232+
if isArchive {
233+
// adding application/json to Accept header due to a bug in the zipball/tarball API endpoint that makes it mandatory
234+
req.Header.Set("Accept", "application/octet-stream, application/json")
235+
236+
// override HTTP redirect logic to avoid "legacy" Codeload resources
237+
oldClient := *httpClient
238+
httpClient = &oldClient
239+
httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
240+
if len(via) == 1 {
241+
req.URL.Path = removeLegacyFromCodeloadPath(req.URL.Path)
242+
}
243+
return nil
244+
}
245+
}
189246

190247
resp, err := httpClient.Do(req)
191248
if err != nil {
@@ -197,6 +254,22 @@ func downloadAsset(httpClient *http.Client, assetURL, destinationPath string) er
197254
return api.HandleHTTPError(resp)
198255
}
199256

257+
var destinationPath = filepath.Join(destinationDir, fileName)
258+
259+
if len(fileName) == 0 {
260+
contentDisposition := resp.Header.Get("Content-Disposition")
261+
262+
_, params, err := mime.ParseMediaType(contentDisposition)
263+
if err != nil {
264+
return fmt.Errorf("unable to parse file name of archive: %w", err)
265+
}
266+
if serverFileName, ok := params["filename"]; ok {
267+
destinationPath = filepath.Join(destinationDir, serverFileName)
268+
} else {
269+
return errors.New("unable to determine file name of archive")
270+
}
271+
}
272+
200273
f, err := os.OpenFile(destinationPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
201274
if err != nil {
202275
return err
@@ -206,3 +279,16 @@ func downloadAsset(httpClient *http.Client, assetURL, destinationPath string) er
206279
_, err = io.Copy(f, resp.Body)
207280
return err
208281
}
282+
283+
var codeloadLegacyRE = regexp.MustCompile(`^(/[^/]+/[^/]+/)legacy\.`)
284+
285+
// removeLegacyFromCodeloadPath converts URLs for "legacy" Codeload archives into ones that match the format
286+
// when you choose to download "Source code (zip/tar.gz)" from a tagged release on the web. The legacy URLs
287+
// look like this:
288+
//
289+
// https://codeload.github.com/OWNER/REPO/legacy.zip/refs/tags/TAGNAME
290+
//
291+
// Removing the "legacy." part results in a valid Codeload URL for our desired archive format.
292+
func removeLegacyFromCodeloadPath(p string) string {
293+
return codeloadLegacyRE.ReplaceAllString(p, "$1")
294+
}

pkg/cmd/release/download/download_test.go

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,35 @@ func Test_NewCmdDownload(t *testing.T) {
8080
Concurrency: 5,
8181
},
8282
},
83+
{
84+
name: "download archive with valid option",
85+
args: "v1.2.3 -A zip",
86+
isTTY: true,
87+
want: DownloadOptions{
88+
TagName: "v1.2.3",
89+
FilePatterns: []string(nil),
90+
Destination: ".",
91+
ArchiveType: "zip",
92+
Concurrency: 5,
93+
},
94+
},
8395
{
8496
name: "no arguments",
8597
args: "",
8698
isTTY: true,
87-
wantErr: "the '--pattern' flag is required when downloading the latest release",
99+
wantErr: "`--pattern` or `--archive` is required when downloading the latest release",
100+
},
101+
{
102+
name: "simultaneous pattern and archive arguments",
103+
args: "-p * -A zip",
104+
isTTY: true,
105+
wantErr: "specify only one of '--pattern' or '--archive'",
106+
},
107+
{
108+
name: "invalid archive argument",
109+
args: "v1.2.3 -A abc",
110+
isTTY: true,
111+
wantErr: "the value for `--archive` must be one of \"zip\" or \"tar.gz\"",
88112
},
89113
}
90114
for _, tt := range tests {
@@ -184,6 +208,36 @@ func Test_downloadRun(t *testing.T) {
184208
wantStderr: ``,
185209
wantErr: "no assets match the file pattern",
186210
},
211+
{
212+
name: "download archive in zip format into destination directory",
213+
isTTY: true,
214+
opts: DownloadOptions{
215+
TagName: "v1.2.3",
216+
ArchiveType: "zip",
217+
Destination: "tmp/packages",
218+
Concurrency: 2,
219+
},
220+
wantStdout: ``,
221+
wantStderr: ``,
222+
wantFiles: []string{
223+
"tmp/packages/zipball.zip",
224+
},
225+
},
226+
{
227+
name: "download archive in `tar.gz` format into destination directory",
228+
isTTY: true,
229+
opts: DownloadOptions{
230+
TagName: "v1.2.3",
231+
ArchiveType: "tar.gz",
232+
Destination: "tmp/packages",
233+
Concurrency: 2,
234+
},
235+
wantStdout: ``,
236+
wantStderr: ``,
237+
wantFiles: []string{
238+
"tmp/packages/tarball.tgz",
239+
},
240+
},
187241
}
188242
for _, tt := range tests {
189243
t.Run(tt.name, func(t *testing.T) {
@@ -204,12 +258,34 @@ func Test_downloadRun(t *testing.T) {
204258
"url": "https://api.github.com/assets/3456" },
205259
{ "name": "linux.tgz", "size": 56,
206260
"url": "https://api.github.com/assets/5678" }
207-
]
261+
],
262+
"tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3",
263+
"zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3"
208264
}`))
209265
fakeHTTP.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse(`1234`))
210266
fakeHTTP.Register(httpmock.REST("GET", "assets/3456"), httpmock.StringResponse(`3456`))
211267
fakeHTTP.Register(httpmock.REST("GET", "assets/5678"), httpmock.StringResponse(`5678`))
212268

269+
fakeHTTP.Register(
270+
httpmock.REST(
271+
"GET",
272+
"repos/OWNER/REPO/tarball/v1.2.3",
273+
),
274+
httpmock.WithHeader(
275+
httpmock.StringResponse("somedata"), "content-disposition", "attachment; filename=tarball.tgz",
276+
),
277+
)
278+
279+
fakeHTTP.Register(
280+
httpmock.REST(
281+
"GET",
282+
"repos/OWNER/REPO/zipball/v1.2.3",
283+
),
284+
httpmock.WithHeader(
285+
httpmock.StringResponse("somedata"), "content-disposition", "attachment; filename=zipball.zip",
286+
),
287+
)
288+
213289
tt.opts.IO = io
214290
tt.opts.HttpClient = func() (*http.Client, error) {
215291
return &http.Client{Transport: fakeHTTP}, nil
@@ -226,7 +302,12 @@ func Test_downloadRun(t *testing.T) {
226302
require.NoError(t, err)
227303
}
228304

229-
assert.Equal(t, "application/octet-stream", fakeHTTP.Requests[1].Header.Get("Accept"))
305+
var expectedAcceptHeader = "application/octet-stream"
306+
if len(tt.opts.ArchiveType) > 0 {
307+
expectedAcceptHeader = "application/octet-stream, application/json"
308+
}
309+
310+
assert.Equal(t, expectedAcceptHeader, fakeHTTP.Requests[1].Header.Get("Accept"))
230311

231312
assert.Equal(t, tt.wantStdout, stdout.String())
232313
assert.Equal(t, tt.wantStderr, stderr.String())

pkg/httpmock/stub.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,17 @@ func StringResponse(body string) Responder {
7777
}
7878
}
7979

80+
func WithHeader(responder Responder, header string, value string) Responder {
81+
return func(req *http.Request) (*http.Response, error) {
82+
resp, _ := responder(req)
83+
if resp.Header == nil {
84+
resp.Header = make(http.Header)
85+
}
86+
resp.Header.Set(header, value)
87+
return resp, nil
88+
}
89+
}
90+
8091
func StatusStringResponse(status int, body string) Responder {
8192
return func(req *http.Request) (*http.Response, error) {
8293
return httpResponse(status, req, bytes.NewBufferString(body)), nil

0 commit comments

Comments
 (0)
X Tutup