2020-09-29 05:55:34 +00:00
package create
import (
2020-11-29 22:04:41 +00:00
"errors"
2020-09-29 05:55:34 +00:00
"fmt"
2020-12-26 16:13:08 +00:00
"net/url"
2021-02-10 08:35:07 +00:00
"strconv"
2020-12-26 16:13:08 +00:00
"strings"
2022-09-19 20:23:45 +00:00
"gitlab.com/gitlab-org/cli/pkg/iostreams"
2021-01-28 22:38:32 +00:00
2021-01-08 20:37:51 +00:00
"github.com/MakeNowJust/heredoc"
2022-09-19 20:23:45 +00:00
"gitlab.com/gitlab-org/cli/internal/config"
"gitlab.com/gitlab-org/cli/internal/glrepo"
"gitlab.com/gitlab-org/cli/pkg/utils"
2020-09-29 05:55:34 +00:00
2020-11-29 22:04:41 +00:00
"github.com/AlecAivazis/survey/v2"
2020-09-29 05:55:34 +00:00
"github.com/spf13/cobra"
"github.com/xanzy/go-gitlab"
2022-09-19 20:23:45 +00:00
"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"
2020-09-29 05:55:34 +00:00
)
2020-11-29 22:04:41 +00:00
type CreateOpts struct {
Title string
Description string
2020-12-26 15:18:50 +00:00
Labels [ ] string
2020-12-09 14:48:28 +00:00
Assignees [ ] string
2020-11-29 22:04:41 +00:00
2021-02-18 06:30:07 +00:00
Weight int
MileStone int
LinkedMR int
LinkedIssues [ ] int
IssueLinkType string
2022-02-01 13:18:35 +00:00
TimeEstimate string
2022-02-01 13:27:11 +00:00
TimeSpent string
2020-11-29 22:04:41 +00:00
2020-12-30 16:51:12 +00:00
MilestoneFlag string
2020-11-29 22:04:41 +00:00
NoEditor bool
IsConfidential bool
IsInteractive bool
2020-12-26 16:13:08 +00:00
OpenInWeb bool
2020-12-27 21:50:16 +00:00
Yes bool
2021-01-13 19:48:41 +00:00
Web bool
2020-12-26 16:13:08 +00:00
2021-01-28 22:38:32 +00:00
IO * iostreams . IOStreams
2020-12-26 16:13:08 +00:00
BaseRepo func ( ) ( glrepo . Interface , error )
HTTPClient func ( ) ( * gitlab . Client , error )
Remotes func ( ) ( glrepo . Remotes , error )
Config func ( ) ( config . Config , error )
BaseProject * gitlab . Project
2020-11-29 22:04:41 +00:00
}
2020-09-29 05:55:34 +00:00
func NewCmdCreate ( f * cmdutils . Factory ) * cobra . Command {
2020-12-26 16:13:08 +00:00
opts := & CreateOpts {
2020-12-30 21:08:34 +00:00
IO : f . IO ,
Remotes : f . Remotes ,
Config : f . Config ,
2020-12-26 16:13:08 +00:00
}
2022-11-17 18:36:09 +00:00
issueCreateCmd := & cobra . Command {
2020-09-29 05:55:34 +00:00
Use : "create [flags]" ,
Short : ` Create an issue ` ,
Long : ` ` ,
Aliases : [ ] string { "new" } ,
2021-01-08 20:37:51 +00:00
Example : heredoc . Doc ( `
2022-10-21 14:38:48 +00:00
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
2021-01-08 20:37:51 +00:00
` ) ,
Args : cobra . ExactArgs ( 0 ) ,
2020-09-29 05:55:34 +00:00
RunE : func ( cmd * cobra . Command , args [ ] string ) error {
2020-12-26 16:13:08 +00:00
var err error
2020-12-30 21:08:34 +00:00
// 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
2020-12-26 16:13:08 +00:00
apiClient , err := opts . HTTPClient ( )
if err != nil {
return err
}
repo , err := opts . BaseRepo ( )
if err != nil {
return err
}
2020-11-29 22:04:41 +00:00
hasTitle := cmd . Flags ( ) . Changed ( "title" )
hasDescription := cmd . Flags ( ) . Changed ( "description" )
// disable interactive mode if title and description are explicitly defined
opts . IsInteractive = ! ( hasTitle && hasDescription )
2020-12-26 16:13:08 +00:00
if opts . IsInteractive && ! opts . IO . PromptEnabled ( ) {
2020-11-29 22:04:41 +00:00
return & cmdutils . FlagError { Err : errors . New ( "--title and --description required for non-interactive mode" ) }
}
2020-09-29 05:55:34 +00:00
2021-01-13 19:48:41 +00:00
// 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" ) }
}
2020-12-26 16:13:08 +00:00
opts . BaseProject , err = api . GetProject ( apiClient , repo . FullName ( ) )
2020-09-29 05:55:34 +00:00
if err != nil {
return err
}
2020-10-09 16:04:06 +00:00
2020-12-26 16:13:08 +00:00
if ! opts . BaseProject . IssuesEnabled {
fmt . Fprintf ( opts . IO . StdErr , "Issues are disabled for %q\n" , opts . BaseProject . PathWithNamespace )
return cmdutils . SilentError
2020-09-29 05:55:34 +00:00
}
2020-12-26 16:13:08 +00:00
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`" )
2020-12-30 16:51:12 +00:00
issueCreateCmd . Flags ( ) . StringVarP ( & opts . MilestoneFlag , "milestone" , "m" , "" , "The global ID or title of a milestone to assign" )
2020-12-26 16:13:08 +00:00
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" )
2020-12-27 21:50:16 +00:00
issueCreateCmd . Flags ( ) . BoolVarP ( & opts . Yes , "yes" , "y" , false , "Don't prompt for confirmation to submit the issue" )
2021-01-13 19:48:41 +00:00
issueCreateCmd . Flags ( ) . BoolVar ( & opts . Web , "web" , false , "continue issue creation with web interface" )
2021-02-10 08:35:07 +00:00
issueCreateCmd . Flags ( ) . IntSliceVarP ( & opts . LinkedIssues , "linked-issues" , "" , [ ] int { } , "The IIDs of issues that this issue links to" )
2021-02-18 06:30:07 +00:00
issueCreateCmd . Flags ( ) . StringVarP ( & opts . IssueLinkType , "link-type" , "" , "relates_to" , "Type for the issue link" )
2022-02-01 13:27:11 +00:00
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" )
2020-12-26 16:13:08 +00:00
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 { }
2020-12-30 16:51:12 +00:00
if opts . MilestoneFlag != "" {
2020-12-30 20:17:52 +00:00
opts . MileStone , err = cmdutils . ParseMilestone ( apiClient , repo , opts . MilestoneFlag )
2020-12-30 16:51:12 +00:00
if err != nil {
return err
}
}
2020-12-26 16:13:08 +00:00
if opts . IsInteractive {
if opts . Description == "" {
if opts . NoEditor {
2021-01-01 15:04:08 +00:00
err = prompt . AskMultiline ( & opts . Description , "description" , "Description:" , "" )
2020-12-26 16:13:08 +00:00
if err != nil {
return err
2020-11-29 17:59:41 +00:00
}
2020-12-26 16:13:08 +00:00
} else {
templateResponse := struct {
Index int
} { }
templateNames , err := cmdutils . ListGitLabTemplates ( cmdutils . IssueTemplate )
if err != nil {
return fmt . Errorf ( "error getting templates: %w" , err )
2020-09-29 05:55:34 +00:00
}
2020-12-26 16:13:08 +00:00
templateNames = append ( templateNames , "Open a blank Issue" )
selectQs := [ ] * survey . Question {
{
Name : "index" ,
Prompt : & survey . Select {
Message : "Choose a template" ,
Options : templateNames ,
} ,
} ,
2020-11-29 22:04:41 +00:00
}
2020-12-26 16:13:08 +00:00
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 )
2020-11-29 17:59:41 +00:00
if err != nil {
2020-12-26 16:13:08 +00:00
return fmt . Errorf ( "failed to get template contents: %w" , err )
2020-11-29 17:59:41 +00:00
}
2020-09-29 05:55:34 +00:00
}
}
2020-12-26 16:13:08 +00:00
}
if opts . Title == "" {
2021-01-01 15:04:08 +00:00
err = prompt . AskQuestionWithInput ( & opts . Title , "title" , "Title" , "" , true )
2020-12-26 16:13:08 +00:00
if err != nil {
return err
2020-09-29 05:55:34 +00:00
}
2020-12-26 16:13:08 +00:00
}
if opts . Description == "" {
if opts . NoEditor {
2021-01-01 15:04:08 +00:00
err = prompt . AskMultiline ( & opts . Description , "description" , "Description:" , "" )
2020-12-26 16:13:08 +00:00
if err != nil {
return err
}
} else {
editor , err := cmdutils . GetEditor ( opts . Config )
if err != nil {
return err
}
2021-04-29 18:30:45 +00:00
err = cmdutils . EditorPrompt ( & opts . Description , "Description" , templateContents , editor )
2020-12-09 14:48:28 +00:00
if err != nil {
return err
}
2020-09-29 05:55:34 +00:00
}
2020-12-26 16:13:08 +00:00
}
2020-12-26 16:19:03 +00:00
} else if opts . Title == "" {
return fmt . Errorf ( "title can't be blank" )
2020-09-29 05:55:34 +00:00
}
2020-12-26 16:13:08 +00:00
var action cmdutils . Action
// submit without prompting for non interactive mode
2020-12-27 21:50:16 +00:00
if ! opts . IsInteractive || opts . Yes {
2020-12-26 16:13:08 +00:00
action = cmdutils . SubmitAction
}
2021-01-13 19:48:41 +00:00
if opts . Web {
action = cmdutils . PreviewAction
}
2020-12-27 21:50:16 +00:00
if action == cmdutils . NoAction {
2020-12-31 02:14:07 +00:00
action , err = cmdutils . ConfirmSubmission ( true , true )
2020-12-26 16:13:08 +00:00
if err != nil {
return fmt . Errorf ( "unable to confirm: %w" , err )
}
}
2020-12-31 02:14:07 +00:00
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.
2021-01-08 13:43:07 +00:00
// Init a new remote without actually adding a new git remote
repoRemote = & glrepo . Remote {
Repo : repo ,
}
2020-12-31 02:14:07 +00:00
}
for _ , x := range metadataActions {
if x == cmdutils . AddLabelAction {
err = cmdutils . LabelsPrompt ( & opts . Labels , apiClient , repoRemote )
if err != nil {
return err
}
}
if x == cmdutils . AddAssigneeAction {
2021-01-05 23:32:10 +00:00
// 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 )
2020-12-31 02:14:07 +00:00
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
}
}
2020-12-26 16:13:08 +00:00
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 )
2022-10-04 19:29:02 +00:00
issueCreateOpts . Labels = ( * gitlab . Labels ) ( & opts . Labels )
2020-12-26 16:13:08 +00:00
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 )
}
2021-02-10 08:35:07 +00:00
fmt . Fprintln ( opts . IO . StdErr , "- Creating issue in" , repo . FullName ( ) )
2020-12-26 16:13:08 +00:00
issue , err := api . CreateIssue ( apiClient , repo . FullName ( ) , issueCreateOpts )
if err != nil {
return err
2021-02-10 08:35:07 +00:00
}
2022-02-01 13:18:35 +00:00
if err := postCreateActions ( apiClient , issue , opts , repo ) ; err != nil {
return err
2020-12-26 16:13:08 +00:00
}
2022-02-01 13:18:35 +00:00
2021-11-18 14:47:03 +00:00
fmt . Fprintln ( opts . IO . StdOut , issueutils . DisplayIssue ( opts . IO . Color ( ) , issue , opts . IO . IsaTTY ) )
2020-12-26 16:13:08 +00:00
return nil
}
2020-12-31 02:14:07 +00:00
return errors . New ( "expected to cancel, preview in browser, add metadata, or submit" )
2020-12-26 16:13:08 +00:00
}
2022-02-01 13:18:35 +00:00
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
}
}
2022-02-01 13:27:11 +00:00
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
}
}
2022-02-01 13:18:35 +00:00
return nil
}
2020-12-26 16:13:08 +00:00
func previewIssue ( opts * CreateOpts ) error {
repo , err := opts . BaseRepo ( )
if err != nil {
return err
}
cfg , err := opts . Config ( )
if err != nil {
return err
}
2021-11-18 14:39:45 +00:00
openURL , err := generateIssueWebURL ( opts )
2020-12-26 16:13:08 +00:00
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 )
}
2021-11-18 14:39:45 +00:00
func generateIssueWebURL ( opts * CreateOpts ) ( string , error ) {
2020-12-26 16:13:08 +00:00
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
2020-12-28 08:53:19 +00:00
description += "\n/label "
for _ , label := range opts . Labels {
description += fmt . Sprintf ( "~%q" , label )
}
2020-12-26 16:13:08 +00:00
}
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
2021-10-13 09:30:46 +00:00
description += fmt . Sprintf ( "\n/weight %d" , opts . Weight )
2020-12-26 16:13:08 +00:00
}
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 (
2021-11-18 14:39:45 +00:00
"issue[title]=%s&issue[description]=%s" ,
strings . ReplaceAll ( url . PathEscape ( opts . Title ) , "+" , "%2B" ) ,
strings . ReplaceAll ( url . PathEscape ( description ) , "+" , "%2B" ) )
2020-12-26 16:13:08 +00:00
return u . String ( ) , nil
2020-09-29 05:55:34 +00:00
}