commands/issue/create/issue_create.go (420 lines of code) (raw):

package create import ( "errors" "fmt" "net/url" "os" "strconv" "strings" "gitlab.com/gitlab-org/cli/pkg/iostreams" "github.com/MakeNowJust/heredoc/v2" "gitlab.com/gitlab-org/cli/internal/config" "gitlab.com/gitlab-org/cli/internal/glrepo" "gitlab.com/gitlab-org/cli/pkg/utils" "github.com/AlecAivazis/survey/v2" "github.com/spf13/cobra" gitlab "gitlab.com/gitlab-org/api/client-go" "gitlab.com/gitlab-org/cli/api" "gitlab.com/gitlab-org/cli/commands/cmdutils" "gitlab.com/gitlab-org/cli/commands/issue/issueutils" "gitlab.com/gitlab-org/cli/internal/recovery" "gitlab.com/gitlab-org/cli/pkg/prompt" ) type CreateOpts struct { Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` Labels []string `json:"labels,omitempty"` Assignees []string `json:"assignees,omitempty"` Weight int `json:"weight,omitempty"` Milestone int `json:"milestone,omitempty"` LinkedMR int `json:"linked_mr,omitempty"` LinkedIssues []int `json:"linked_issues,omitempty"` IssueLinkType string `json:"issue_link_type,omitempty"` TimeEstimate string `json:"time_estimate,omitempty"` TimeSpent string `json:"time_spent,omitempty"` EpicID int `json:"epic_id,omitempty"` DueDate string `json:"due_date,omitempty"` MilestoneFlag string `json:"milestone_flag"` NoEditor bool `json:"-"` IsConfidential bool `json:"is_confidential,omitempty"` IsInteractive bool `json:"-"` OpenInWeb bool `json:"-"` Yes bool `json:"-"` Web bool `json:"-"` Recover bool `json:"-"` IO *iostreams.IOStreams `json:"-"` BaseRepo func() (glrepo.Interface, error) `json:"-"` HTTPClient func() (*gitlab.Client, error) `json:"-"` Remotes func() (glrepo.Remotes, error) `json:"-"` Config func() (config.Config, error) `json:"-"` BaseProject *gitlab.Project `json:"-"` } func NewCmdCreate(f *cmdutils.Factory) *cobra.Command { opts := &CreateOpts{ IO: f.IO, Remotes: f.Remotes, Config: f.Config, } issueCreateCmd := &cobra.Command{ Use: "create [flags]", Short: `Create an issue.`, Long: ``, Aliases: []string{"new"}, Example: heredoc.Doc(` - glab issue create - glab issue new - glab issue create -m release-2.0.0 -t "we need this feature" --label important - glab issue new -t "Fix CVE-YYYY-XXXX" -l security --linked-mr 123 - glab issue create -m release-1.0.1 -t "security fix" --label security --web --recover `), Args: cobra.ExactArgs(0), RunE: func(cmd *cobra.Command, args []string) error { var err error // support `-R, --repo` override // // NOTE: it is important to assign the BaseRepo and HTTPClient in RunE because // they are overridden in a PersistentRun hook (when `-R, --repo` is specified) // which runs before RunE is executed opts.BaseRepo = f.BaseRepo opts.HTTPClient = f.HttpClient apiClient, err := opts.HTTPClient() if err != nil { return err } repo, err := opts.BaseRepo() if err != nil { return err } hasTitle := cmd.Flags().Changed("title") hasDescription := cmd.Flags().Changed("description") // disable interactive mode if title and description are explicitly defined opts.IsInteractive = !(hasTitle && hasDescription) if opts.IsInteractive && !opts.IO.PromptEnabled() { return &cmdutils.FlagError{Err: errors.New("'--title' and '--description' required for non-interactive mode.")} } // Remove this once --yes does more than just skip the prompts that --web happen to skip // by design if opts.Yes && opts.Web { return &cmdutils.FlagError{Err: errors.New("'--web' already skips all prompts currently skipped by '--yes'.")} } opts.BaseProject, err = api.GetProject(apiClient, repo.FullName()) if err != nil { return err } if !opts.BaseProject.IssuesEnabled { //nolint:staticcheck fmt.Fprintf(opts.IO.StdErr, "Issues are disabled for project %q or require project membership. ", opts.BaseProject.PathWithNamespace) fmt.Fprintf(opts.IO.StdErr, "Make sure issues are enabled for the %q project, and if required, you are a member of the project.\n", opts.BaseProject.PathWithNamespace) return cmdutils.SilentError } if err := createRun(opts); err != nil { // always save options to file recoverErr := createRecoverSaveFile(repo.FullName(), opts) if recoverErr != nil { fmt.Fprintf(opts.IO.StdErr, "Could not create recovery file: %v", recoverErr) } return err } return nil }, } issueCreateCmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Issue title.") issueCreateCmd.Flags().StringVarP(&opts.Description, "description", "d", "", "Issue description.") issueCreateCmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", []string{}, "Add label by name. Multiple labels should be comma-separated.") issueCreateCmd.Flags().StringSliceVarP(&opts.Assignees, "assignee", "a", []string{}, "Assign issue to people by their `usernames`.") issueCreateCmd.Flags().StringVarP(&opts.MilestoneFlag, "milestone", "m", "", "The global ID or title of a milestone to assign.") issueCreateCmd.Flags().BoolVarP(&opts.IsConfidential, "confidential", "c", false, "Set an issue to be confidential. (default false)") issueCreateCmd.Flags().IntVarP(&opts.LinkedMR, "linked-mr", "", 0, "The IID of a merge request in which to resolve all issues.") issueCreateCmd.Flags().IntVarP(&opts.Weight, "weight", "w", 0, "Issue weight. Valid values are greater than or equal to 0.") issueCreateCmd.Flags().BoolVarP(&opts.NoEditor, "no-editor", "", false, "Don't open editor to enter a description. If set to true, uses prompt. (default false)") issueCreateCmd.Flags().BoolVarP(&opts.Yes, "yes", "y", false, "Don't prompt for confirmation to submit the issue.") issueCreateCmd.Flags().BoolVar(&opts.Web, "web", false, "Continue issue creation with web interface.") issueCreateCmd.Flags().IntSliceVarP(&opts.LinkedIssues, "linked-issues", "", []int{}, "The IIDs of issues that this issue links to.") issueCreateCmd.Flags().StringVarP(&opts.IssueLinkType, "link-type", "", "relates_to", "Type for the issue link") issueCreateCmd.Flags().StringVarP(&opts.TimeEstimate, "time-estimate", "e", "", "Set time estimate for the issue.") issueCreateCmd.Flags().StringVarP(&opts.TimeSpent, "time-spent", "s", "", "Set time spent for the issue.") issueCreateCmd.Flags().BoolVar(&opts.Recover, "recover", false, "Save the options to a file if the issue fails to be created. If the file exists, the options will be loaded from the recovery file. (EXPERIMENTAL.)") issueCreateCmd.Flags().IntVarP(&opts.EpicID, "epic", "", 0, "ID of the epic to add the issue to.") issueCreateCmd.Flags().StringVarP(&opts.DueDate, "due-date", "", "", "A date in 'YYYY-MM-DD' format.") return issueCreateCmd } var createRun = func(opts *CreateOpts) error { apiClient, err := opts.HTTPClient() if err != nil { return err } repo, err := opts.BaseRepo() if err != nil { return err } var templateName string var templateContents string issueCreateOpts := &gitlab.CreateIssueOptions{} if opts.MilestoneFlag != "" { opts.Milestone, err = cmdutils.ParseMilestone(apiClient, repo, opts.MilestoneFlag) if err != nil { return err } } if opts.Recover { if err := recovery.FromFile(repo.FullName(), "issue.json", opts); err != nil { // if the file to recover doesn't exist, we can just ignore the error and move on if !errors.Is(err, os.ErrNotExist) { fmt.Fprintf(opts.IO.StdErr, "Failed to recover from file: %v", err) } } else { fmt.Fprintln(opts.IO.StdOut, "Recovered create options from file.") } } if opts.IsInteractive { if opts.Description == "" { if opts.NoEditor { err = prompt.AskMultiline(&opts.Description, "description", "Description:", "") if err != nil { return err } } else { templateResponse := struct { Index int }{} templateNames, err := cmdutils.ListGitLabTemplates(cmdutils.IssueTemplate) if err != nil { return fmt.Errorf("error getting templates: %w", err) } templateNames = append(templateNames, "Open a blank issue") selectQs := []*survey.Question{ { Name: "index", Prompt: &survey.Select{ Message: "Choose a template", Options: templateNames, }, }, } if err := prompt.Ask(selectQs, &templateResponse); err != nil { return fmt.Errorf("could not prompt: %w", err) } if templateResponse.Index != len(templateNames) { templateName = templateNames[templateResponse.Index] templateContents, err = cmdutils.LoadGitLabTemplate(cmdutils.IssueTemplate, templateName) if err != nil { return fmt.Errorf("failed to get template contents: %w", err) } } } } if opts.Title == "" { err = prompt.AskQuestionWithInput(&opts.Title, "title", "Title", "", true) if err != nil { return err } } if opts.Description == "" { if opts.NoEditor { err = prompt.AskMultiline(&opts.Description, "description", "Description:", "") if err != nil { return err } } else { editor, err := cmdutils.GetEditor(opts.Config) if err != nil { return err } err = cmdutils.EditorPrompt(&opts.Description, "Description", templateContents, editor) if err != nil { return err } } } } else if opts.Title == "" { return fmt.Errorf("title can't be blank") } var action cmdutils.Action // submit without prompting for non interactive mode if !opts.IsInteractive || opts.Yes { action = cmdutils.SubmitAction } if opts.Web { action = cmdutils.PreviewAction } if action == cmdutils.NoAction { action, err = cmdutils.ConfirmSubmission(true, true) if err != nil { return fmt.Errorf("unable to confirm: %w", err) } } if action == cmdutils.AddMetadataAction { var metadataActions []cmdutils.Action metadataActions, err = cmdutils.PickMetadata() if err != nil { return fmt.Errorf("failed to pick metadata to add: %w", err) } remotes, err := opts.Remotes() if err != nil { return err } repoRemote, err := remotes.FindByRepo(repo.RepoOwner(), repo.RepoName()) if err != nil { // when the base repo is overridden with --repo flag, it is likely it has no // remote set for the current working git dir which will error. // Init a new remote without actually adding a new git remote repoRemote = &glrepo.Remote{ Repo: repo, } } for _, x := range metadataActions { if x == cmdutils.AddLabelAction { err = cmdutils.LabelsPrompt(&opts.Labels, apiClient, repoRemote) if err != nil { return err } } if x == cmdutils.AddAssigneeAction { // Involve only reporters and up, in the future this might be expanded to `guests` // but that might hit the `100` limit for projects with large amounts of collaborators err = cmdutils.UsersPrompt(&opts.Assignees, apiClient, repoRemote, opts.IO, 20, "assignees") if err != nil { return err } } if x == cmdutils.AddMilestoneAction { err = cmdutils.MilestonesPrompt(&opts.Milestone, apiClient, repoRemote, opts.IO) if err != nil { return err } } } // Ask the user again but don't permit AddMetadata a second time action, err = cmdutils.ConfirmSubmission(true, false) if err != nil { return err } } if action == cmdutils.CancelAction { fmt.Fprintln(opts.IO.StdErr, "Discarded.") return nil } if action == cmdutils.PreviewAction { return previewIssue(opts) } if action == cmdutils.SubmitAction { issueCreateOpts.Title = gitlab.Ptr(opts.Title) issueCreateOpts.Labels = (*gitlab.LabelOptions)(&opts.Labels) issueCreateOpts.Description = &opts.Description if opts.IsConfidential { issueCreateOpts.Confidential = gitlab.Ptr(opts.IsConfidential) } if opts.Weight != 0 { issueCreateOpts.Weight = gitlab.Ptr(opts.Weight) } if opts.LinkedMR != 0 { issueCreateOpts.MergeRequestToResolveDiscussionsOf = gitlab.Ptr(opts.LinkedMR) } if opts.Milestone != 0 { issueCreateOpts.MilestoneID = gitlab.Ptr(opts.Milestone) } if opts.EpicID != 0 { issueCreateOpts.EpicID = gitlab.Ptr(opts.EpicID) } if opts.DueDate != "" { dueDate, err := gitlab.ParseISOTime(opts.DueDate) if err != nil { return err } issueCreateOpts.DueDate = gitlab.Ptr(dueDate) } if len(opts.Assignees) > 0 { users, err := api.UsersByNames(apiClient, opts.Assignees) if err != nil { return err } issueCreateOpts.AssigneeIDs = cmdutils.IDsFromUsers(users) } fmt.Fprintln(opts.IO.StdErr, "- Creating issue in", repo.FullName()) issue, err := api.CreateIssue(apiClient, repo.FullName(), issueCreateOpts) if err != nil { return err } if err := postCreateActions(apiClient, issue, opts, repo); err != nil { return err } fmt.Fprintln(opts.IO.StdOut, issueutils.DisplayIssue(opts.IO.Color(), issue, opts.IO.IsaTTY)) return nil } return errors.New("expected to cancel, preview in browser, add metadata, or submit") } func postCreateActions(apiClient *gitlab.Client, issue *gitlab.Issue, opts *CreateOpts, repo glrepo.Interface) error { if len(opts.LinkedIssues) > 0 { var err error for _, targetIssueIID := range opts.LinkedIssues { fmt.Fprintln(opts.IO.StdErr, "- Linking to issue ", targetIssueIID) issue, _, err = api.LinkIssues(apiClient, repo.FullName(), issue.IID, &gitlab.CreateIssueLinkOptions{ TargetIssueIID: gitlab.Ptr(strconv.Itoa(targetIssueIID)), LinkType: gitlab.Ptr(opts.IssueLinkType), }) if err != nil { return err } } } if opts.TimeEstimate != "" { fmt.Fprintln(opts.IO.StdErr, "- Adding time estimate ", opts.TimeEstimate) _, err := api.SetIssueTimeEstimate(apiClient, repo.FullName(), issue.IID, &gitlab.SetTimeEstimateOptions{ Duration: gitlab.Ptr(opts.TimeEstimate), }) if err != nil { return err } } if opts.TimeSpent != "" { fmt.Fprintln(opts.IO.StdErr, "- Adding time spent ", opts.TimeSpent) _, err := api.AddIssueTimeSpent(apiClient, repo.FullName(), issue.IID, &gitlab.AddSpentTimeOptions{ Duration: gitlab.Ptr(opts.TimeSpent), }) if err != nil { return err } } return nil } func previewIssue(opts *CreateOpts) error { repo, err := opts.BaseRepo() if err != nil { return err } cfg, err := opts.Config() if err != nil { return err } openURL, err := generateIssueWebURL(opts) if err != nil { return err } if opts.IO.IsOutputTTY() { fmt.Fprintf(opts.IO.StdErr, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) } browser, _ := cfg.Get(repo.RepoHost(), "browser") return utils.OpenInBrowser(openURL, browser) } func generateIssueWebURL(opts *CreateOpts) (string, error) { description := opts.Description if len(opts.Labels) > 0 { // this uses the slash commands to add labels to the description // See https://docs.gitlab.com/user/project/quick_actions/ // See also https://gitlab.com/gitlab-org/gitlab-foss/-/issues/19731#note_32550046 description += "\n/label" for _, label := range opts.Labels { description += fmt.Sprintf(" ~%q", label) } } if len(opts.Assignees) > 0 { // this uses the slash commands to add assignees to the description description += fmt.Sprintf("\n/assign %s", strings.Join(opts.Assignees, ", ")) } if opts.Milestone != 0 { // this uses the slash commands to add milestone to the description description += fmt.Sprintf("\n/milestone %%%d", opts.Milestone) } if opts.Weight != 0 { // this uses the slash commands to add weight to the description description += fmt.Sprintf("\n/weight %d", opts.Weight) } if opts.IsConfidential { // this uses the slash commands to add confidential to the description description += "\n/confidential" } u, err := url.Parse(opts.BaseProject.WebURL) if err != nil { return "", err } u.Path += "/-/issues/new" q := u.Query() q.Set("issue[title]", opts.Title) q.Add("issue[description]", description) u.RawQuery = q.Encode() return u.String(), nil } // createRecoverSaveFile will try save the issue create options to a file func createRecoverSaveFile(repoName string, opts *CreateOpts) error { recoverFile, err := recovery.CreateFile(repoName, "issue.json", opts) if err != nil { return err } fmt.Fprintf(opts.IO.StdErr, "Failed to create issue. Created recovery file: %s\nRun the command again with the '--recover' option to retry", recoverFile) return nil }