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