Address run download feedback
- With no arguments in TTY mode, prompt which artifacts to download - Change `--pattern` argument to be just `--name` and only do exact matching - For multi-archive downloads, prefix the destination path with the name of the artifact - Add tests exercising HTTP functionality - Avoid "zipslip" path injection when extracting ZIP files - Add tests for ZIP extraction
This commit is contained in:
parent
a4a41c23e6
commit
0e94de1ce6
6 changed files with 400 additions and 97 deletions
|
|
@ -5,25 +5,33 @@ import (
|
|||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/cli/cli/pkg/prompt"
|
||||
"github.com/cli/cli/pkg/set"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type DownloadOptions struct {
|
||||
IO *iostreams.IOStreams
|
||||
Platform platform
|
||||
Prompter prompter
|
||||
|
||||
DoPrompt bool
|
||||
RunID string
|
||||
DestinationDir string
|
||||
FilePatterns []string
|
||||
Names []string
|
||||
}
|
||||
|
||||
type platform interface {
|
||||
List(runID string) ([]Artifact, error)
|
||||
Download(url string, dir string) error
|
||||
}
|
||||
type prompter interface {
|
||||
Prompt(message string, options []string, result interface{}) error
|
||||
}
|
||||
|
||||
func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobra.Command {
|
||||
opts := &DownloadOptions{
|
||||
|
|
@ -33,22 +41,29 @@ func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobr
|
|||
cmd := &cobra.Command{
|
||||
Use: "download [<run-id>]",
|
||||
Short: "Download artifacts generated by a workflow run",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Long: heredoc.Doc(`
|
||||
Download artifacts generated by a GitHub Actions workflow run.
|
||||
|
||||
The contents of each artifact will be extracted under separate directories based on
|
||||
the artifact name. If only a single artifact is specified, it will be extracted into
|
||||
the current directory.
|
||||
`),
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Example: heredoc.Doc(`
|
||||
# Download all artifacts generated by a workflow run
|
||||
$ gh run download <run-id>
|
||||
|
||||
# Download a specific artifact within a run
|
||||
$ gh run download <run-id> -p <name>
|
||||
$ gh run download <run-id> -n <name>
|
||||
|
||||
# Download specific artifacts across all runs in a repository
|
||||
$ gh run download -p <name1> -p <name2>
|
||||
$ gh run download -n <name1> -n <name2>
|
||||
`),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
opts.RunID = args[0]
|
||||
} else if len(opts.FilePatterns) == 0 {
|
||||
return &cmdutil.FlagError{Err: errors.New("either run ID or `--pattern` is required")}
|
||||
} else if len(opts.Names) == 0 && opts.IO.CanPrompt() {
|
||||
opts.DoPrompt = true
|
||||
}
|
||||
|
||||
// support `-R, --repo` override
|
||||
|
|
@ -64,6 +79,7 @@ func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobr
|
|||
client: httpClient,
|
||||
repo: baseRepo,
|
||||
}
|
||||
opts.Prompter = &surveyPrompter{}
|
||||
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
|
|
@ -73,47 +89,79 @@ func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobr
|
|||
}
|
||||
|
||||
cmd.Flags().StringVarP(&opts.DestinationDir, "dir", "D", ".", "The directory to download artifacts into")
|
||||
cmd.Flags().StringArrayVarP(&opts.FilePatterns, "pattern", "p", nil, "Download only artifacts that match a glob pattern")
|
||||
cmd.Flags().StringArrayVarP(&opts.Names, "name", "n", nil, "Only download artifacts that match any of the given names")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runDownload(opts *DownloadOptions) error {
|
||||
opts.IO.StartProgressIndicator()
|
||||
defer opts.IO.StopProgressIndicator()
|
||||
|
||||
artifacts, err := opts.Platform.List(opts.RunID)
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error fetching artifacts: %w", err)
|
||||
}
|
||||
|
||||
// track downloaded artifacts and avoid re-downloading any of the same name
|
||||
downloaded := map[string]struct{}{}
|
||||
numArtifacts := 0
|
||||
|
||||
numValidArtifacts := 0
|
||||
for _, a := range artifacts {
|
||||
if a.Expired {
|
||||
continue
|
||||
}
|
||||
numArtifacts++
|
||||
if _, found := downloaded[a.Name]; found {
|
||||
numValidArtifacts++
|
||||
}
|
||||
if numValidArtifacts == 0 {
|
||||
return errors.New("no valid artifacts found to download")
|
||||
}
|
||||
|
||||
wantNames := opts.Names
|
||||
if opts.DoPrompt {
|
||||
artifactNames := set.NewStringSet()
|
||||
for _, a := range artifacts {
|
||||
if !a.Expired {
|
||||
artifactNames.Add(a.Name)
|
||||
}
|
||||
}
|
||||
options := artifactNames.ToSlice()
|
||||
if len(options) > 10 {
|
||||
options = options[:10]
|
||||
}
|
||||
err := opts.Prompter.Prompt("Select artifacts to download:", options, &wantNames)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(wantNames) == 0 {
|
||||
return errors.New("no artifacts selected")
|
||||
}
|
||||
}
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
defer opts.IO.StopProgressIndicator()
|
||||
|
||||
// track downloaded artifacts and avoid re-downloading any of the same name
|
||||
downloaded := set.NewStringSet()
|
||||
for _, a := range artifacts {
|
||||
if a.Expired {
|
||||
continue
|
||||
}
|
||||
if len(opts.FilePatterns) > 0 && !matchAny(opts.FilePatterns, a.Name) {
|
||||
if downloaded.Contains(a.Name) {
|
||||
continue
|
||||
}
|
||||
err := opts.Platform.Download(a.DownloadURL, opts.DestinationDir)
|
||||
if len(wantNames) > 0 && !matchAny(wantNames, a.Name) {
|
||||
continue
|
||||
}
|
||||
destDir := opts.DestinationDir
|
||||
if len(wantNames) != 1 {
|
||||
destDir = filepath.Join(destDir, a.Name)
|
||||
}
|
||||
err := opts.Platform.Download(a.DownloadURL, destDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error downloading %s: %w", a.Name, err)
|
||||
}
|
||||
downloaded[a.Name] = struct{}{}
|
||||
downloaded.Add(a.Name)
|
||||
}
|
||||
|
||||
if numArtifacts == 0 {
|
||||
return errors.New("no valid artifacts found to download")
|
||||
}
|
||||
if len(downloaded) == 0 {
|
||||
return errors.New("no artifact matches any of the patterns provided")
|
||||
if downloaded.Len() == 0 {
|
||||
return errors.New("no artifact matches any of the names provided")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -121,9 +169,18 @@ func runDownload(opts *DownloadOptions) error {
|
|||
|
||||
func matchAny(patterns []string, name string) bool {
|
||||
for _, p := range patterns {
|
||||
if isMatch, err := filepath.Match(p, name); err == nil && isMatch {
|
||||
if name == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type surveyPrompter struct{}
|
||||
|
||||
func (sp *surveyPrompter) Prompt(message string, options []string, result interface{}) error {
|
||||
return prompt.SurveyAskOne(&survey.MultiSelect{
|
||||
Message: message,
|
||||
Options: options,
|
||||
}, result)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue