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  }