github.com/cli/cli@v1.14.1-0.20210902173923-1af6a669e342/pkg/cmd/gist/create/create.go (about)

     1  package create
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"io/ioutil"
    10  	"net/http"
    11  	"path"
    12  	"regexp"
    13  	"sort"
    14  	"strings"
    15  
    16  	"github.com/MakeNowJust/heredoc"
    17  	"github.com/cli/cli/api"
    18  	"github.com/cli/cli/internal/config"
    19  	"github.com/cli/cli/pkg/cmd/gist/shared"
    20  	"github.com/cli/cli/pkg/cmdutil"
    21  	"github.com/cli/cli/pkg/iostreams"
    22  	"github.com/cli/cli/utils"
    23  	"github.com/spf13/cobra"
    24  )
    25  
    26  type browser interface {
    27  	Browse(string) error
    28  }
    29  
    30  type CreateOptions struct {
    31  	IO *iostreams.IOStreams
    32  
    33  	Description      string
    34  	Public           bool
    35  	Filenames        []string
    36  	FilenameOverride string
    37  	WebMode          bool
    38  
    39  	Config     func() (config.Config, error)
    40  	HttpClient func() (*http.Client, error)
    41  	Browser    browser
    42  }
    43  
    44  func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
    45  	opts := CreateOptions{
    46  		IO:         f.IOStreams,
    47  		Config:     f.Config,
    48  		HttpClient: f.HttpClient,
    49  		Browser:    f.Browser,
    50  	}
    51  
    52  	cmd := &cobra.Command{
    53  		Use:   "create [<filename>... | -]",
    54  		Short: "Create a new gist",
    55  		Long: heredoc.Doc(`
    56  			Create a new GitHub gist with given contents.
    57  
    58  			Gists can be created from one or multiple files. Alternatively, pass "-" as
    59  			file name to read from standard input.
    60  
    61  			By default, gists are secret; use '--public' to make publicly listed ones.
    62  		`),
    63  		Example: heredoc.Doc(`
    64  			# publish file 'hello.py' as a public gist
    65  			$ gh gist create --public hello.py
    66  
    67  			# create a gist with a description
    68  			$ gh gist create hello.py -d "my Hello-World program in Python"
    69  
    70  			# create a gist containing several files
    71  			$ gh gist create hello.py world.py cool.txt
    72  
    73  			# read from standard input to create a gist
    74  			$ gh gist create -
    75  
    76  			# create a gist from output piped from another command
    77  			$ cat cool.txt | gh gist create
    78  		`),
    79  		Args: func(cmd *cobra.Command, args []string) error {
    80  			if len(args) > 0 {
    81  				return nil
    82  			}
    83  			if opts.IO.IsStdinTTY() {
    84  				return &cmdutil.FlagError{Err: errors.New("no filenames passed and nothing on STDIN")}
    85  			}
    86  			return nil
    87  		},
    88  		RunE: func(c *cobra.Command, args []string) error {
    89  			opts.Filenames = args
    90  
    91  			if runF != nil {
    92  				return runF(&opts)
    93  			}
    94  			return createRun(&opts)
    95  		},
    96  	}
    97  
    98  	cmd.Flags().StringVarP(&opts.Description, "desc", "d", "", "A description for this gist")
    99  	cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the web browser with created gist")
   100  	cmd.Flags().BoolVarP(&opts.Public, "public", "p", false, "List the gist publicly (default: secret)")
   101  	cmd.Flags().StringVarP(&opts.FilenameOverride, "filename", "f", "", "Provide a filename to be used when reading from STDIN")
   102  	return cmd
   103  }
   104  
   105  func createRun(opts *CreateOptions) error {
   106  	fileArgs := opts.Filenames
   107  	if len(fileArgs) == 0 {
   108  		fileArgs = []string{"-"}
   109  	}
   110  
   111  	files, err := processFiles(opts.IO.In, opts.FilenameOverride, fileArgs)
   112  	if err != nil {
   113  		return fmt.Errorf("failed to collect files for posting: %w", err)
   114  	}
   115  
   116  	gistName := guessGistName(files)
   117  
   118  	processMessage := "Creating gist..."
   119  	completionMessage := "Created gist"
   120  	if gistName != "" {
   121  		if len(files) > 1 {
   122  			processMessage = "Creating gist with multiple files"
   123  		} else {
   124  			processMessage = fmt.Sprintf("Creating gist %s", gistName)
   125  		}
   126  		completionMessage = fmt.Sprintf("Created gist %s", gistName)
   127  	}
   128  
   129  	cs := opts.IO.ColorScheme()
   130  
   131  	errOut := opts.IO.ErrOut
   132  	fmt.Fprintf(errOut, "%s %s\n", cs.Gray("-"), processMessage)
   133  
   134  	httpClient, err := opts.HttpClient()
   135  	if err != nil {
   136  		return err
   137  	}
   138  
   139  	cfg, err := opts.Config()
   140  	if err != nil {
   141  		return err
   142  	}
   143  
   144  	host, err := cfg.DefaultHost()
   145  	if err != nil {
   146  		return err
   147  	}
   148  
   149  	gist, err := createGist(httpClient, host, opts.Description, opts.Public, files)
   150  	if err != nil {
   151  		var httpError api.HTTPError
   152  		if errors.As(err, &httpError) {
   153  			if httpError.OAuthScopes != "" && !strings.Contains(httpError.OAuthScopes, "gist") {
   154  				return fmt.Errorf("This command requires the 'gist' OAuth scope.\nPlease re-authenticate with:  gh auth refresh -h %s -s gist", host)
   155  			}
   156  			if httpError.StatusCode == http.StatusUnprocessableEntity {
   157  				if detectEmptyFiles(files) {
   158  					fmt.Fprintf(errOut, "%s Failed to create gist: %s\n", cs.FailureIcon(), "a gist file cannot be blank")
   159  					return cmdutil.SilentError
   160  				}
   161  			}
   162  		}
   163  		return fmt.Errorf("%s Failed to create gist: %w", cs.Red("X"), err)
   164  	}
   165  
   166  	fmt.Fprintf(errOut, "%s %s\n", cs.SuccessIconWithColor(cs.Green), completionMessage)
   167  
   168  	if opts.WebMode {
   169  		fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", utils.DisplayURL(gist.HTMLURL))
   170  
   171  		return opts.Browser.Browse(gist.HTMLURL)
   172  	}
   173  
   174  	fmt.Fprintln(opts.IO.Out, gist.HTMLURL)
   175  
   176  	return nil
   177  }
   178  
   179  func processFiles(stdin io.ReadCloser, filenameOverride string, filenames []string) (map[string]*shared.GistFile, error) {
   180  	fs := map[string]*shared.GistFile{}
   181  
   182  	if len(filenames) == 0 {
   183  		return nil, errors.New("no files passed")
   184  	}
   185  
   186  	for i, f := range filenames {
   187  		var filename string
   188  		var content []byte
   189  		var err error
   190  
   191  		if f == "-" {
   192  			if filenameOverride != "" {
   193  				filename = filenameOverride
   194  			} else {
   195  				filename = fmt.Sprintf("gistfile%d.txt", i)
   196  			}
   197  			content, err = ioutil.ReadAll(stdin)
   198  			if err != nil {
   199  				return fs, fmt.Errorf("failed to read from stdin: %w", err)
   200  			}
   201  			stdin.Close()
   202  
   203  			if shared.IsBinaryContents(content) {
   204  				return nil, fmt.Errorf("binary file contents not supported")
   205  			}
   206  		} else {
   207  			isBinary, err := shared.IsBinaryFile(f)
   208  			if err != nil {
   209  				return fs, fmt.Errorf("failed to read file %s: %w", f, err)
   210  			}
   211  			if isBinary {
   212  				return nil, fmt.Errorf("failed to upload %s: binary file not supported", f)
   213  			}
   214  
   215  			content, err = ioutil.ReadFile(f)
   216  			if err != nil {
   217  				return fs, fmt.Errorf("failed to read file %s: %w", f, err)
   218  			}
   219  
   220  			filename = path.Base(f)
   221  		}
   222  
   223  		fs[filename] = &shared.GistFile{
   224  			Content: string(content),
   225  		}
   226  	}
   227  
   228  	return fs, nil
   229  }
   230  
   231  func guessGistName(files map[string]*shared.GistFile) string {
   232  	filenames := make([]string, 0, len(files))
   233  	gistName := ""
   234  
   235  	re := regexp.MustCompile(`^gistfile\d+\.txt$`)
   236  	for k := range files {
   237  		if !re.MatchString(k) {
   238  			filenames = append(filenames, k)
   239  		}
   240  	}
   241  
   242  	if len(filenames) > 0 {
   243  		sort.Strings(filenames)
   244  		gistName = filenames[0]
   245  	}
   246  
   247  	return gistName
   248  }
   249  
   250  func createGist(client *http.Client, hostname, description string, public bool, files map[string]*shared.GistFile) (*shared.Gist, error) {
   251  	path := "gists"
   252  
   253  	body := &shared.Gist{
   254  		Description: description,
   255  		Public:      public,
   256  		Files:       files,
   257  	}
   258  
   259  	result := shared.Gist{}
   260  
   261  	requestByte, err := json.Marshal(body)
   262  	if err != nil {
   263  		return nil, err
   264  	}
   265  	requestBody := bytes.NewReader(requestByte)
   266  
   267  	apiClient := api.NewClientFromHTTP(client)
   268  	err = apiClient.REST(hostname, "POST", path, requestBody, &result)
   269  	if err != nil {
   270  		return nil, err
   271  	}
   272  
   273  	return &result, nil
   274  }
   275  
   276  func detectEmptyFiles(files map[string]*shared.GistFile) bool {
   277  	for _, file := range files {
   278  		if strings.TrimSpace(file.Content) == "" {
   279  			return true
   280  		}
   281  	}
   282  	return false
   283  }