package download import ( "errors" "fmt" "path/filepath" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/pkg/cmd/run/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/set" "github.com/spf13/cobra" ) type DownloadOptions struct { IO *iostreams.IOStreams Platform platform Prompter iprompter DoPrompt bool RunID string DestinationDir string Names []string FilePatterns []string } type platform interface { List(runID string) ([]shared.Artifact, error) Download(url string, dir string) error } type iprompter interface { MultiSelect(string, []string, []string) ([]int, error) } func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobra.Command { opts := &DownloadOptions{ IO: f.IOStreams, Prompter: f.Prompter, } cmd := &cobra.Command{ Use: "download []", Short: "Download artifacts generated by a workflow run", 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 # Download a specific artifact within a run $ gh run download -n # Download specific artifacts across all runs in a repository $ gh run download -n -n # Select artifacts to download interactively $ gh run download `), RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { opts.RunID = args[0] } else if len(opts.Names) == 0 && len(opts.FilePatterns) == 0 && opts.IO.CanPrompt() { opts.DoPrompt = true } // support `-R, --repo` override baseRepo, err := f.BaseRepo() if err != nil { return err } httpClient, err := f.HttpClient() if err != nil { return err } opts.Platform = &apiPlatform{ client: httpClient, repo: baseRepo, } if runF != nil { return runF(opts) } return runDownload(opts) }, } cmd.Flags().StringVarP(&opts.DestinationDir, "dir", "D", ".", "The directory to download artifacts into") cmd.Flags().StringArrayVarP(&opts.Names, "name", "n", nil, "Download artifacts that match any of the given names") cmd.Flags().StringArrayVarP(&opts.FilePatterns, "pattern", "p", nil, "Download artifacts that match a glob pattern") return cmd } func runDownload(opts *DownloadOptions) error { opts.IO.StartProgressIndicator() artifacts, err := opts.Platform.List(opts.RunID) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("error fetching artifacts: %w", err) } numValidArtifacts := 0 for _, a := range artifacts { if a.Expired { continue } numValidArtifacts++ } if numValidArtifacts == 0 { return errors.New("no valid artifacts found to download") } wantPatterns := opts.FilePatterns 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] } var selected []int if selected, err = opts.Prompter.MultiSelect("Select artifacts to download:", nil, options); err != nil { return err } wantNames = []string{} for _, x := range selected { wantNames = append(wantNames, options[x]) } 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 downloaded.Contains(a.Name) { continue } if len(wantNames) > 0 || len(wantPatterns) > 0 { if !matchAnyName(wantNames, a.Name) && !matchAnyPattern(wantPatterns, a.Name) { continue } } destDir := opts.DestinationDir if len(wantPatterns) != 0 || 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.Add(a.Name) } if downloaded.Len() == 0 { return errors.New("no artifact matches any of the names or patterns provided") } return nil } func matchAnyName(names []string, name string) bool { for _, n := range names { if name == n { return true } } return false } func matchAnyPattern(patterns []string, name string) bool { for _, p := range patterns { if isMatch, err := filepath.Match(p, name); err == nil && isMatch { return true } } return false }