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
}