mirror of https://gitlab.com/gitlab-org/cli.git
454 lines
14 KiB
Go
454 lines
14 KiB
Go
package create
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gitlab.com/gitlab-org/cli/pkg/iostreams"
|
|
|
|
"github.com/MakeNowJust/heredoc"
|
|
"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"
|
|
"github.com/xanzy/go-gitlab"
|
|
"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/pkg/prompt"
|
|
)
|
|
|
|
type CreateOpts struct {
|
|
Title string
|
|
Description string
|
|
Labels []string
|
|
Assignees []string
|
|
|
|
Weight int
|
|
MileStone int
|
|
LinkedMR int
|
|
LinkedIssues []int
|
|
IssueLinkType string
|
|
TimeEstimate string
|
|
TimeSpent string
|
|
|
|
MilestoneFlag string
|
|
|
|
NoEditor bool
|
|
IsConfidential bool
|
|
IsInteractive bool
|
|
OpenInWeb bool
|
|
Yes bool
|
|
Web bool
|
|
|
|
IO *iostreams.IOStreams
|
|
BaseRepo func() (glrepo.Interface, error)
|
|
HTTPClient func() (*gitlab.Client, error)
|
|
Remotes func() (glrepo.Remotes, error)
|
|
Config func() (config.Config, error)
|
|
|
|
BaseProject *gitlab.Project
|
|
}
|
|
|
|
func NewCmdCreate(f *cmdutils.Factory) *cobra.Command {
|
|
opts := &CreateOpts{
|
|
IO: f.IO,
|
|
Remotes: f.Remotes,
|
|
Config: f.Config,
|
|
}
|
|
var 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
|
|
`),
|
|
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 {
|
|
fmt.Fprintf(opts.IO.StdErr, "Issues are disabled for %q\n", opts.BaseProject.PathWithNamespace)
|
|
return cmdutils.SilentError
|
|
}
|
|
|
|
return createRun(opts)
|
|
},
|
|
}
|
|
issueCreateCmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Supply a title for issue")
|
|
issueCreateCmd.Flags().StringVarP(&opts.Description, "description", "d", "", "Supply a description for issue")
|
|
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 is 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, "The weight of the issue. Valid values are greater than or equal to 0.")
|
|
issueCreateCmd.Flags().BoolVarP(&opts.NoEditor, "no-editor", "", false, "Don't open editor to enter description. If set to true, uses prompt. Default is 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")
|
|
|
|
return issueCreateCmd
|
|
}
|
|
|
|
func createRun(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.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.AssigneesPrompt(&opts.Assignees, apiClient, repoRemote, opts.IO, 20)
|
|
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.String(opts.Title)
|
|
issueCreateOpts.Labels = (*gitlab.Labels)(&opts.Labels)
|
|
issueCreateOpts.Description = &opts.Description
|
|
if opts.IsConfidential {
|
|
issueCreateOpts.Confidential = gitlab.Bool(opts.IsConfidential)
|
|
}
|
|
if opts.Weight != 0 {
|
|
issueCreateOpts.Weight = gitlab.Int(opts.Weight)
|
|
}
|
|
if opts.LinkedMR != 0 {
|
|
issueCreateOpts.MergeRequestToResolveDiscussionsOf = gitlab.Int(opts.LinkedMR)
|
|
}
|
|
if opts.MileStone != 0 {
|
|
issueCreateOpts.MilestoneID = gitlab.Int(opts.MileStone)
|
|
}
|
|
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.String(strconv.Itoa(targetIssueIID)),
|
|
LinkType: gitlab.String(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.String(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.String(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/ee/user/project/quick_actions.html
|
|
// 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"
|
|
u.RawQuery = fmt.Sprintf(
|
|
"issue[title]=%s&issue[description]=%s",
|
|
strings.ReplaceAll(url.PathEscape(opts.Title), "+", "%2B"),
|
|
strings.ReplaceAll(url.PathEscape(description), "+", "%2B"))
|
|
return u.String(), nil
|
|
}
|