@@ -2,10 +2,13 @@ package download
22
33import (
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
3237func 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+
85118func 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
138181func 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+ }
0 commit comments