Skip to content

Commit c54e3c9

Browse files
committed
Add run download command for downloading workflow artifacts
1 parent a35d451 commit c54e3c9

File tree

5 files changed

+482
-0
lines changed

5 files changed

+482
-0
lines changed

pkg/cmd/run/download/download.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package download
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"path/filepath"
7+
8+
"github.com/MakeNowJust/heredoc"
9+
"github.com/cli/cli/pkg/cmdutil"
10+
"github.com/cli/cli/pkg/iostreams"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
type DownloadOptions struct {
15+
IO *iostreams.IOStreams
16+
Platform platform
17+
18+
RunID string
19+
DestinationDir string
20+
FilePatterns []string
21+
}
22+
23+
type platform interface {
24+
List(runID string) ([]Artifact, error)
25+
Download(url string, dir string) error
26+
}
27+
28+
func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobra.Command {
29+
opts := &DownloadOptions{
30+
IO: f.IOStreams,
31+
}
32+
33+
cmd := &cobra.Command{
34+
Use: "download [<run-id>]",
35+
Short: "Download artifacts generated by a workflow run",
36+
Args: cobra.MaximumNArgs(1),
37+
Example: heredoc.Doc(`
38+
# Download all artifacts generated by a workflow run
39+
$ gh run download <run-id>
40+
41+
# Download a specific artifact within a run
42+
$ gh run download <run-id> -p <name>
43+
44+
# Download specific artifacts across all runs in a repository
45+
$ gh run download -p <name1> -p <name2>
46+
`),
47+
RunE: func(cmd *cobra.Command, args []string) error {
48+
if len(args) > 0 {
49+
opts.RunID = args[0]
50+
} else if len(opts.FilePatterns) == 0 {
51+
return &cmdutil.FlagError{Err: errors.New("either run ID or `--pattern` is required")}
52+
}
53+
54+
// support `-R, --repo` override
55+
baseRepo, err := f.BaseRepo()
56+
if err != nil {
57+
return err
58+
}
59+
httpClient, err := f.HttpClient()
60+
if err != nil {
61+
return err
62+
}
63+
opts.Platform = &apiPlatform{
64+
client: httpClient,
65+
repo: baseRepo,
66+
}
67+
68+
if runF != nil {
69+
return runF(opts)
70+
}
71+
return runDownload(opts)
72+
},
73+
}
74+
75+
cmd.Flags().StringVarP(&opts.DestinationDir, "dir", "D", ".", "The directory to download artifacts into")
76+
cmd.Flags().StringArrayVarP(&opts.FilePatterns, "pattern", "p", nil, "Download only artifacts that match a glob pattern")
77+
78+
return cmd
79+
}
80+
81+
func runDownload(opts *DownloadOptions) error {
82+
opts.IO.StartProgressIndicator()
83+
defer opts.IO.StopProgressIndicator()
84+
85+
artifacts, err := opts.Platform.List(opts.RunID)
86+
if err != nil {
87+
return fmt.Errorf("error fetching artifacts: %w", err)
88+
}
89+
90+
// track downloaded artifacts and avoid re-downloading any of the same name
91+
downloaded := map[string]struct{}{}
92+
numArtifacts := 0
93+
94+
for _, a := range artifacts {
95+
if a.Expired {
96+
continue
97+
}
98+
numArtifacts++
99+
if _, found := downloaded[a.Name]; found {
100+
continue
101+
}
102+
if len(opts.FilePatterns) > 0 && !matchAny(opts.FilePatterns, a.Name) {
103+
continue
104+
}
105+
err := opts.Platform.Download(a.DownloadURL, opts.DestinationDir)
106+
if err != nil {
107+
return fmt.Errorf("error downloading %s: %w", a.Name, err)
108+
}
109+
downloaded[a.Name] = struct{}{}
110+
}
111+
112+
if numArtifacts == 0 {
113+
return errors.New("no valid artifacts found to download")
114+
}
115+
if len(downloaded) == 0 {
116+
return errors.New("no artifact matches any of the patterns provided")
117+
}
118+
119+
return nil
120+
}
121+
122+
func matchAny(patterns []string, name string) bool {
123+
for _, p := range patterns {
124+
if isMatch, err := filepath.Match(p, name); err == nil && isMatch {
125+
return true
126+
}
127+
}
128+
return false
129+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package download
2+
3+
import (
4+
"bytes"
5+
"io/ioutil"
6+
"net/http"
7+
"testing"
8+
9+
"github.com/cli/cli/internal/ghrepo"
10+
"github.com/cli/cli/pkg/cmdutil"
11+
"github.com/cli/cli/pkg/iostreams"
12+
"github.com/google/shlex"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
func Test_NewCmdDownload(t *testing.T) {
18+
tests := []struct {
19+
name string
20+
args string
21+
isTTY bool
22+
want DownloadOptions
23+
wantErr string
24+
}{
25+
{
26+
name: "empty",
27+
args: "",
28+
isTTY: true,
29+
wantErr: "either run ID or `--pattern` is required",
30+
},
31+
{
32+
name: "with run ID",
33+
args: "2345",
34+
isTTY: true,
35+
want: DownloadOptions{
36+
RunID: "2345",
37+
FilePatterns: []string(nil),
38+
DestinationDir: ".",
39+
},
40+
},
41+
{
42+
name: "to destination",
43+
args: "2345 -D tmp/dest",
44+
isTTY: true,
45+
want: DownloadOptions{
46+
RunID: "2345",
47+
FilePatterns: []string(nil),
48+
DestinationDir: "tmp/dest",
49+
},
50+
},
51+
{
52+
name: "repo level with patterns",
53+
args: "-p one -p two",
54+
isTTY: true,
55+
want: DownloadOptions{
56+
RunID: "",
57+
FilePatterns: []string{"one", "two"},
58+
DestinationDir: ".",
59+
},
60+
},
61+
}
62+
for _, tt := range tests {
63+
t.Run(tt.name, func(t *testing.T) {
64+
io, _, _, _ := iostreams.Test()
65+
io.SetStdoutTTY(tt.isTTY)
66+
io.SetStdinTTY(tt.isTTY)
67+
io.SetStderrTTY(tt.isTTY)
68+
69+
f := &cmdutil.Factory{
70+
IOStreams: io,
71+
HttpClient: func() (*http.Client, error) {
72+
return nil, nil
73+
},
74+
BaseRepo: func() (ghrepo.Interface, error) {
75+
return nil, nil
76+
},
77+
}
78+
79+
var opts *DownloadOptions
80+
cmd := NewCmdDownload(f, func(o *DownloadOptions) error {
81+
opts = o
82+
return nil
83+
})
84+
cmd.PersistentFlags().StringP("repo", "R", "", "")
85+
86+
argv, err := shlex.Split(tt.args)
87+
require.NoError(t, err)
88+
cmd.SetArgs(argv)
89+
90+
cmd.SetIn(&bytes.Buffer{})
91+
cmd.SetOut(ioutil.Discard)
92+
cmd.SetErr(ioutil.Discard)
93+
94+
_, err = cmd.ExecuteC()
95+
if tt.wantErr != "" {
96+
require.EqualError(t, err, tt.wantErr)
97+
return
98+
} else {
99+
require.NoError(t, err)
100+
}
101+
102+
assert.Equal(t, tt.want.RunID, opts.RunID)
103+
assert.Equal(t, tt.want.FilePatterns, opts.FilePatterns)
104+
assert.Equal(t, tt.want.DestinationDir, opts.DestinationDir)
105+
})
106+
}
107+
}
108+
109+
func Test_runDownload(t *testing.T) {
110+
tests := []struct {
111+
name string
112+
opts DownloadOptions
113+
artifacts []Artifact
114+
wantDownloaded []string
115+
wantErr string
116+
}{
117+
{
118+
name: "download non-expired",
119+
opts: DownloadOptions{
120+
RunID: "2345",
121+
DestinationDir: ".",
122+
FilePatterns: []string(nil),
123+
},
124+
artifacts: []Artifact{
125+
{
126+
Name: "artifact-1",
127+
DownloadURL: "http://download.com/artifact-1.zip",
128+
Expired: false,
129+
},
130+
{
131+
Name: "expired-artifact",
132+
DownloadURL: "http://download.com/expired.zip",
133+
Expired: true,
134+
},
135+
{
136+
Name: "artifact-2",
137+
DownloadURL: "http://download.com/artifact-2.zip",
138+
Expired: false,
139+
},
140+
},
141+
wantDownloaded: []string{
142+
"http://download.com/artifact-1.zip",
143+
"http://download.com/artifact-2.zip",
144+
},
145+
},
146+
}
147+
for _, tt := range tests {
148+
t.Run(tt.name, func(t *testing.T) {
149+
opts := &tt.opts
150+
io, _, stdout, stderr := iostreams.Test()
151+
opts.IO = io
152+
platform := &stubPlatform{listResults: tt.artifacts}
153+
opts.Platform = platform
154+
155+
err := runDownload(opts)
156+
if tt.wantErr != "" {
157+
require.EqualError(t, err, tt.wantErr)
158+
} else {
159+
require.NoError(t, err)
160+
}
161+
162+
assert.Equal(t, tt.wantDownloaded, platform.downloaded)
163+
assert.Equal(t, "", stdout.String())
164+
assert.Equal(t, "", stderr.String())
165+
})
166+
}
167+
}
168+
169+
type stubPlatform struct {
170+
listResults []Artifact
171+
downloaded []string
172+
}
173+
174+
func (p *stubPlatform) List(runID string) ([]Artifact, error) {
175+
return p.listResults, nil
176+
}
177+
178+
func (p *stubPlatform) Download(url string, dir string) error {
179+
p.downloaded = append(p.downloaded, url)
180+
return nil
181+
}

0 commit comments

Comments
 (0)