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 }