github.com/cli/cli@v1.14.1-0.20210902173923-1af6a669e342/pkg/cmd/issue/create/create.go (about) 1 package create 2 3 import ( 4 "errors" 5 "fmt" 6 "net/http" 7 8 "github.com/MakeNowJust/heredoc" 9 "github.com/cli/cli/api" 10 "github.com/cli/cli/internal/config" 11 "github.com/cli/cli/internal/ghrepo" 12 "github.com/cli/cli/pkg/cmd/pr/shared" 13 prShared "github.com/cli/cli/pkg/cmd/pr/shared" 14 "github.com/cli/cli/pkg/cmdutil" 15 "github.com/cli/cli/pkg/iostreams" 16 "github.com/cli/cli/utils" 17 "github.com/spf13/cobra" 18 ) 19 20 type browser interface { 21 Browse(string) error 22 } 23 24 type CreateOptions struct { 25 HttpClient func() (*http.Client, error) 26 Config func() (config.Config, error) 27 IO *iostreams.IOStreams 28 BaseRepo func() (ghrepo.Interface, error) 29 Browser browser 30 31 RootDirOverride string 32 33 HasRepoOverride bool 34 WebMode bool 35 RecoverFile string 36 37 Title string 38 Body string 39 Interactive bool 40 41 Assignees []string 42 Labels []string 43 Projects []string 44 Milestone string 45 } 46 47 func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { 48 opts := &CreateOptions{ 49 IO: f.IOStreams, 50 HttpClient: f.HttpClient, 51 Config: f.Config, 52 Browser: f.Browser, 53 } 54 55 var bodyFile string 56 57 cmd := &cobra.Command{ 58 Use: "create", 59 Short: "Create a new issue", 60 Example: heredoc.Doc(` 61 $ gh issue create --title "I found a bug" --body "Nothing works" 62 $ gh issue create --label "bug,help wanted" 63 $ gh issue create --label bug --label "help wanted" 64 $ gh issue create --assignee monalisa,hubot 65 $ gh issue create --assignee "@me" 66 $ gh issue create --project "Roadmap" 67 `), 68 Args: cmdutil.NoArgsQuoteReminder, 69 RunE: func(cmd *cobra.Command, args []string) error { 70 // support `-R, --repo` override 71 opts.BaseRepo = f.BaseRepo 72 opts.HasRepoOverride = cmd.Flags().Changed("repo") 73 74 titleProvided := cmd.Flags().Changed("title") 75 bodyProvided := cmd.Flags().Changed("body") 76 if bodyFile != "" { 77 b, err := cmdutil.ReadFile(bodyFile, opts.IO.In) 78 if err != nil { 79 return err 80 } 81 opts.Body = string(b) 82 bodyProvided = true 83 } 84 85 if !opts.IO.CanPrompt() && opts.RecoverFile != "" { 86 return &cmdutil.FlagError{Err: errors.New("`--recover` only supported when running interactively")} 87 } 88 89 opts.Interactive = !(titleProvided && bodyProvided) 90 91 if opts.Interactive && !opts.IO.CanPrompt() { 92 return &cmdutil.FlagError{Err: errors.New("must provide title and body when not running interactively")} 93 } 94 95 if runF != nil { 96 return runF(opts) 97 } 98 return createRun(opts) 99 }, 100 } 101 102 cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Supply a title. Will prompt for one otherwise.") 103 cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Supply a body. Will prompt for one otherwise.") 104 cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file`") 105 cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the browser to create an issue") 106 cmd.Flags().StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`. Use \"@me\" to self-assign.") 107 cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`") 108 cmd.Flags().StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the issue to projects by `name`") 109 cmd.Flags().StringVarP(&opts.Milestone, "milestone", "m", "", "Add the issue to a milestone by `name`") 110 cmd.Flags().StringVar(&opts.RecoverFile, "recover", "", "Recover input from a failed run of create") 111 112 return cmd 113 } 114 115 func createRun(opts *CreateOptions) (err error) { 116 httpClient, err := opts.HttpClient() 117 if err != nil { 118 return 119 } 120 apiClient := api.NewClientFromHTTP(httpClient) 121 122 baseRepo, err := opts.BaseRepo() 123 if err != nil { 124 return 125 } 126 127 isTerminal := opts.IO.IsStdoutTTY() 128 129 var milestones []string 130 if opts.Milestone != "" { 131 milestones = []string{opts.Milestone} 132 } 133 134 meReplacer := shared.NewMeReplacer(apiClient, baseRepo.RepoHost()) 135 assignees, err := meReplacer.ReplaceSlice(opts.Assignees) 136 if err != nil { 137 return err 138 } 139 140 tb := prShared.IssueMetadataState{ 141 Type: prShared.IssueMetadata, 142 Assignees: assignees, 143 Labels: opts.Labels, 144 Projects: opts.Projects, 145 Milestones: milestones, 146 Title: opts.Title, 147 Body: opts.Body, 148 } 149 150 if opts.RecoverFile != "" { 151 err = prShared.FillFromJSON(opts.IO, opts.RecoverFile, &tb) 152 if err != nil { 153 err = fmt.Errorf("failed to recover input: %w", err) 154 return 155 } 156 } 157 158 tpl := shared.NewTemplateManager(httpClient, baseRepo, opts.RootDirOverride, !opts.HasRepoOverride, false) 159 160 if opts.WebMode { 161 var openURL string 162 if opts.Title != "" || opts.Body != "" || tb.HasMetadata() { 163 openURL, err = generatePreviewURL(apiClient, baseRepo, tb) 164 if err != nil { 165 return 166 } 167 if !utils.ValidURL(openURL) { 168 err = fmt.Errorf("cannot open in browser: maximum URL length exceeded") 169 return 170 } 171 } else if ok, _ := tpl.HasTemplates(); ok { 172 openURL = ghrepo.GenerateRepoURL(baseRepo, "issues/new/choose") 173 } else { 174 openURL = ghrepo.GenerateRepoURL(baseRepo, "issues/new") 175 } 176 if isTerminal { 177 fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) 178 } 179 return opts.Browser.Browse(openURL) 180 } 181 182 if isTerminal { 183 fmt.Fprintf(opts.IO.ErrOut, "\nCreating issue in %s\n\n", ghrepo.FullName(baseRepo)) 184 } 185 186 repo, err := api.GitHubRepo(apiClient, baseRepo) 187 if err != nil { 188 return 189 } 190 if !repo.HasIssuesEnabled { 191 err = fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(baseRepo)) 192 return 193 } 194 195 action := prShared.SubmitAction 196 templateNameForSubmit := "" 197 var openURL string 198 199 if opts.Interactive { 200 var editorCommand string 201 editorCommand, err = cmdutil.DetermineEditor(opts.Config) 202 if err != nil { 203 return 204 } 205 206 defer prShared.PreserveInput(opts.IO, &tb, &err)() 207 208 if opts.Title == "" { 209 err = prShared.TitleSurvey(&tb) 210 if err != nil { 211 return 212 } 213 } 214 215 if opts.Body == "" { 216 templateContent := "" 217 218 if opts.RecoverFile == "" { 219 var template shared.Template 220 template, err = tpl.Choose() 221 if err != nil { 222 return 223 } 224 225 if template != nil { 226 templateContent = string(template.Body()) 227 templateNameForSubmit = template.NameForSubmit() 228 } else { 229 templateContent = string(tpl.LegacyBody()) 230 } 231 } 232 233 err = prShared.BodySurvey(&tb, templateContent, editorCommand) 234 if err != nil { 235 return 236 } 237 } 238 239 openURL, err = generatePreviewURL(apiClient, baseRepo, tb) 240 if err != nil { 241 return 242 } 243 244 allowPreview := !tb.HasMetadata() && utils.ValidURL(openURL) 245 action, err = prShared.ConfirmSubmission(allowPreview, repo.ViewerCanTriage()) 246 if err != nil { 247 err = fmt.Errorf("unable to confirm: %w", err) 248 return 249 } 250 251 if action == prShared.MetadataAction { 252 fetcher := &prShared.MetadataFetcher{ 253 IO: opts.IO, 254 APIClient: apiClient, 255 Repo: baseRepo, 256 State: &tb, 257 } 258 err = prShared.MetadataSurvey(opts.IO, baseRepo, fetcher, &tb) 259 if err != nil { 260 return 261 } 262 263 action, err = prShared.ConfirmSubmission(!tb.HasMetadata(), false) 264 if err != nil { 265 return 266 } 267 } 268 269 if action == prShared.CancelAction { 270 fmt.Fprintln(opts.IO.ErrOut, "Discarding.") 271 err = cmdutil.CancelError 272 return 273 } 274 } else { 275 if tb.Title == "" { 276 err = fmt.Errorf("title can't be blank") 277 return 278 } 279 } 280 281 if action == prShared.PreviewAction { 282 if isTerminal { 283 fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) 284 } 285 return opts.Browser.Browse(openURL) 286 } else if action == prShared.SubmitAction { 287 params := map[string]interface{}{ 288 "title": tb.Title, 289 "body": tb.Body, 290 } 291 if templateNameForSubmit != "" { 292 params["issueTemplate"] = templateNameForSubmit 293 } 294 295 err = prShared.AddMetadataToIssueParams(apiClient, baseRepo, params, &tb) 296 if err != nil { 297 return 298 } 299 300 var newIssue *api.Issue 301 newIssue, err = api.IssueCreate(apiClient, repo, params) 302 if err != nil { 303 return 304 } 305 306 fmt.Fprintln(opts.IO.Out, newIssue.URL) 307 } else { 308 panic("Unreachable state") 309 } 310 311 return 312 } 313 314 func generatePreviewURL(apiClient *api.Client, baseRepo ghrepo.Interface, tb shared.IssueMetadataState) (string, error) { 315 openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") 316 return prShared.WithPrAndIssueQueryParams(apiClient, baseRepo, openURL, tb) 317 }