package create import ( "errors" "fmt" "net/url" "os" "regexp" "strings" "github.com/AlecAivazis/survey/v2" "gitlab.com/gitlab-org/cli/commands/issue/issueutils" "gitlab.com/gitlab-org/cli/pkg/prompt" "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" "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/mr/mrutils" "gitlab.com/gitlab-org/cli/pkg/git" "gitlab.com/gitlab-org/cli/pkg/utils" ) type CreateOpts struct { Title string Description string SourceBranch string TargetBranch string TargetTrackingBranch string Labels []string Assignees []string Reviewers []string MileStone int MilestoneFlag string MRCreateTargetProject string RelatedIssue string CopyIssueLabels bool CreateSourceBranch bool RemoveSourceBranch bool AllowCollaboration bool SquashBeforeMerge bool Autofill bool FillCommitBody bool IsDraft bool IsWIP bool ShouldPush bool NoEditor bool IsInteractive bool Yes bool Web bool IO *iostreams.IOStreams Branch func() (string, error) Remotes func() (glrepo.Remotes, error) Lab func() (*gitlab.Client, error) Config func() (config.Config, error) BaseRepo func() (glrepo.Interface, error) HeadRepo func() (glrepo.Interface, error) // SourceProject is the Project we create the merge request in and where we push our branch // it is the project we have permission to push so most likely one's fork SourceProject *gitlab.Project // TargetProject is the one we query for changes between our branch and the target branch // it is the one we merge request will appear in TargetProject *gitlab.Project } func NewCmdCreate(f *cmdutils.Factory, runE func(opts *CreateOpts) error) *cobra.Command { opts := &CreateOpts{ IO: f.IO, Branch: f.Branch, Remotes: f.Remotes, Config: f.Config, HeadRepo: resolvedHeadRepo(f), } mrCreateCmd := &cobra.Command{ Use: "create", Short: `Create new merge request`, Long: ``, Aliases: []string{"new"}, Example: heredoc.Doc(` glab mr new glab mr create -a username -t "fix annoying bug" glab mr create -f --draft --label RFC glab mr create --fill --yes --web glab mr create --fill --fill-commit-body --yes `), Args: cobra.ExactArgs(0), PreRun: func(cmd *cobra.Command, args []string) { repoOverride, _ := cmd.Flags().GetString("head") if repoFromEnv := os.Getenv("GITLAB_HEAD_REPO"); repoOverride == "" && repoFromEnv != "" { repoOverride = repoFromEnv } if repoOverride != "" { _ = headRepoOverride(opts, repoOverride) } }, RunE: func(cmd *cobra.Command, args []string) 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.Lab = f.HttpClient 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 hasTitle && hasDescription && opts.Autofill { return &cmdutils.FlagError{ Err: errors.New("usage of --title and --description completely override --fill"), } } if opts.IsInteractive && !opts.IO.PromptEnabled() && !opts.Autofill { return &cmdutils.FlagError{Err: errors.New("--title or --fill required for non-interactive mode")} } if cmd.Flags().Changed("wip") && cmd.Flags().Changed("draft") { return &cmdutils.FlagError{Err: errors.New("specify either of --draft or --wip")} } if !opts.Autofill && opts.FillCommitBody { return &cmdutils.FlagError{Err: errors.New("--fill-commit-body should be used with --fill")} } // 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")} } if opts.CopyIssueLabels && opts.RelatedIssue == "" { return &cmdutils.FlagError{Err: errors.New("--copy-issue-labels can only be used with --related-issue")} } if runE != nil { return runE(opts) } return createRun(opts) }, } mrCreateCmd.Flags().BoolVarP(&opts.Autofill, "fill", "f", false, "Do not prompt for title/description and just use commit info") mrCreateCmd.Flags().BoolVarP(&opts.FillCommitBody, "fill-commit-body", "", false, "Fill description with each commit body when multiple commits. Can only be used with --fill") mrCreateCmd.Flags().BoolVarP(&opts.IsDraft, "draft", "", false, "Mark merge request as a draft") mrCreateCmd.Flags().BoolVarP(&opts.IsWIP, "wip", "", false, "Mark merge request as a work in progress. Alternative to --draft") mrCreateCmd.Flags().BoolVarP(&opts.ShouldPush, "push", "", false, "Push committed changes after creating merge request. Make sure you have committed changes") mrCreateCmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Supply a title for merge request") mrCreateCmd.Flags().StringVarP(&opts.Description, "description", "d", "", "Supply a description for merge request") mrCreateCmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", []string{}, "Add label by name. Multiple labels should be comma separated") mrCreateCmd.Flags().StringSliceVarP(&opts.Assignees, "assignee", "a", []string{}, "Assign merge request to people by their `usernames`") mrCreateCmd.Flags().StringSliceVarP(&opts.Reviewers, "reviewer", "", []string{}, "Request review from users by their `usernames`") mrCreateCmd.Flags().StringVarP(&opts.SourceBranch, "source-branch", "s", "", "The Branch you are creating the merge request. Default is the current branch.") mrCreateCmd.Flags().StringVarP(&opts.TargetBranch, "target-branch", "b", "", "The target or base branch into which you want your code merged") mrCreateCmd.Flags().BoolVarP(&opts.CreateSourceBranch, "create-source-branch", "", false, "Create source branch if it does not exist") mrCreateCmd.Flags().StringVarP(&opts.MilestoneFlag, "milestone", "m", "", "The global ID or title of a milestone to assign") mrCreateCmd.Flags().BoolVarP(&opts.AllowCollaboration, "allow-collaboration", "", false, "Allow commits from other members") mrCreateCmd.Flags().BoolVarP(&opts.RemoveSourceBranch, "remove-source-branch", "", false, "Remove Source Branch on merge") mrCreateCmd.Flags().BoolVarP(&opts.SquashBeforeMerge, "squash-before-merge", "", false, "Squash commits into a single commit when merging") mrCreateCmd.Flags().BoolVarP(&opts.NoEditor, "no-editor", "", false, "Don't open editor to enter description. If set to true, uses prompt. Default is false") mrCreateCmd.Flags().StringP("head", "H", "", "Select another head repository using the `OWNER/REPO` or `GROUP/NAMESPACE/REPO` format or the project ID or full URL") mrCreateCmd.Flags().BoolVarP(&opts.Yes, "yes", "y", false, "Skip submission confirmation prompt, with --fill it skips all optional prompts") mrCreateCmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "continue merge request creation on web browser") mrCreateCmd.Flags().BoolVarP(&opts.CopyIssueLabels, "copy-issue-labels", "", false, "Copy labels from issue to the merge request. Used with --related-issue") mrCreateCmd.Flags().StringVarP(&opts.RelatedIssue, "related-issue", "i", "", "Create merge request for an issue. The merge request title will be created from the issue if --title is not provided.") mrCreateCmd.Flags().StringVarP(&opts.MRCreateTargetProject, "target-project", "", "", "Add target project by id or OWNER/REPO or GROUP/NAMESPACE/REPO") _ = mrCreateCmd.Flags().MarkHidden("target-project") _ = mrCreateCmd.Flags().MarkDeprecated("target-project", "Use --repo instead") return mrCreateCmd } func parseIssue(apiClient *gitlab.Client, opts *CreateOpts) (*gitlab.Issue, error) { issue, _, err := issueutils.IssueFromArg(apiClient, opts.BaseRepo, opts.RelatedIssue) if err != nil { return nil, err } return issue, nil } func createRun(opts *CreateOpts) error { out := opts.IO.StdOut c := opts.IO.Color() mrCreateOpts := &gitlab.CreateMergeRequestOptions{} labClient, err := opts.Lab() if err != nil { return err } baseRepo, err := opts.BaseRepo() if err != nil { return err } headRepo, err := opts.HeadRepo() if err != nil { return err } opts.SourceProject, err = api.GetProject(labClient, headRepo.FullName()) if err != nil { return err } // if the user set the target_project, get details of the target if opts.MRCreateTargetProject != "" { opts.TargetProject, err = api.GetProject(labClient, opts.MRCreateTargetProject) if err != nil { return err } } else { // If both the baseRepo and headRepo are the same then re-use the SourceProject if baseRepo.FullName() == headRepo.FullName() { opts.TargetProject = opts.SourceProject } else { // Otherwise assume the user wants to create the merge request against the // baseRepo opts.TargetProject, err = api.GetProject(labClient, baseRepo.FullName()) if err != nil { return err } } } if !opts.TargetProject.MergeRequestsEnabled { fmt.Fprintf(opts.IO.StdErr, "Merge requests are disabled for %q\n", opts.TargetProject.PathWithNamespace) return cmdutils.SilentError } headRepoRemote, err := repoRemote(opts, headRepo, opts.SourceProject, "glab-head") if err != nil { return nil } var baseRepoRemote *glrepo.Remote // check if baseRepo is the same as the headRepo and set the remote if glrepo.IsSame(baseRepo, headRepo) { baseRepoRemote = headRepoRemote } else { baseRepoRemote, err = repoRemote(opts, baseRepo, opts.TargetProject, "glab-base") if err != nil { return nil } } if opts.MilestoneFlag != "" { opts.MileStone, err = cmdutils.ParseMilestone(labClient, baseRepo, opts.MilestoneFlag) if err != nil { return err } } if opts.CreateSourceBranch && opts.SourceBranch == "" { opts.SourceBranch = utils.ReplaceNonAlphaNumericChars(opts.Title, "-") } else if opts.SourceBranch == "" && opts.RelatedIssue == "" { opts.SourceBranch, err = opts.Branch() if err != nil { return err } } if opts.TargetBranch == "" { opts.TargetBranch = getTargetBranch(baseRepoRemote) } if opts.RelatedIssue != "" { issue, err := parseIssue(labClient, opts) if err != nil { return err } if opts.CopyIssueLabels { mrCreateOpts.Labels = &issue.Labels } opts.Description = fmt.Sprintf("Closes #%d", issue.IID) opts.Title = fmt.Sprintf("Resolve \"%s\"", issue.Title) if !opts.IsDraft && !opts.IsWIP { opts.IsDraft = true } if opts.SourceBranch == "" { sourceBranch := fmt.Sprintf("%d-%s", issue.IID, utils.ReplaceNonAlphaNumericChars(strings.ToLower(issue.Title), "-")) branchOpts := &gitlab.CreateBranchOptions{ Branch: &sourceBranch, Ref: &opts.TargetBranch, } _, err = api.CreateBranch(labClient, baseRepo.FullName(), branchOpts) if err != nil { for branchErr, branchCount := err, 1; branchErr != nil; branchCount++ { sourceBranch = fmt.Sprintf("%d-%s-%d", issue.IID, strings.ReplaceAll(strings.ToLower(issue.Title), " ", "-"), branchCount) _, branchErr = api.CreateBranch(labClient, baseRepo.FullName(), branchOpts) } } opts.SourceBranch = sourceBranch } } else { opts.TargetTrackingBranch = fmt.Sprintf("%s/%s", baseRepoRemote.Name, opts.TargetBranch) if opts.SourceBranch == opts.TargetBranch && glrepo.IsSame(baseRepo, headRepo) { fmt.Fprintf(opts.IO.StdErr, "You must be on a different branch other than %q\n", opts.TargetBranch) return cmdutils.SilentError } if opts.Autofill { if err = mrBodyAndTitle(opts); err != nil { return err } _, err = api.GetCommit(labClient, baseRepo.FullName(), opts.TargetBranch) if err != nil { return fmt.Errorf("target branch %s does not exist on remote. Specify target branch with --target-branch flag", opts.TargetBranch) } opts.ShouldPush = true } else if opts.IsInteractive { var templateName string var templateContents string 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.MergeRequestTemplate) if err != nil { return fmt.Errorf("error getting templates: %w", err) } templateNames = append(templateNames, "Open a blank merge request") 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.MergeRequestTemplate, 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 } } } } } if opts.Title == "" { return fmt.Errorf("title can't be blank") } if opts.IsDraft || opts.IsWIP { if opts.IsDraft { opts.Title = "Draft: " + opts.Title } else { opts.Title = "WIP: " + opts.Title } } mrCreateOpts.Title = &opts.Title mrCreateOpts.Description = &opts.Description mrCreateOpts.SourceBranch = &opts.SourceBranch mrCreateOpts.TargetBranch = &opts.TargetBranch if opts.AllowCollaboration { mrCreateOpts.AllowCollaboration = gitlab.Bool(true) } if opts.RemoveSourceBranch { mrCreateOpts.RemoveSourceBranch = gitlab.Bool(true) } if opts.SquashBeforeMerge { mrCreateOpts.Squash = gitlab.Bool(true) } if opts.TargetProject != nil { mrCreateOpts.TargetProjectID = &opts.TargetProject.ID } if opts.CreateSourceBranch { lb := &gitlab.CreateBranchOptions{ Branch: &opts.SourceBranch, Ref: &opts.TargetBranch, } fmt.Fprintln(opts.IO.StdErr, "\nCreating related branch...") branch, err := api.CreateBranch(labClient, headRepo.FullName(), lb) if err == nil { fmt.Fprintln(opts.IO.StdErr, "Branch created: ", branch.WebURL) } else { fmt.Fprintln(opts.IO.StdErr, "Error creating branch: ", err.Error()) } } 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) } for _, x := range metadataActions { if x == cmdutils.AddLabelAction { err = cmdutils.LabelsPrompt(&opts.Labels, labClient, baseRepoRemote) if err != nil { return err } } if x == cmdutils.AddAssigneeAction { // Use minimum permission level 30 (Maintainer) as it is the minimum level // to accept a merge request err = cmdutils.AssigneesPrompt(&opts.Assignees, labClient, baseRepoRemote, opts.IO, 30) if err != nil { return err } } if x == cmdutils.AddMilestoneAction { err = cmdutils.MilestonesPrompt(&opts.MileStone, labClient, baseRepoRemote, 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 } } // This check protects against possibly dereferencing a nil pointer. if mrCreateOpts.Labels == nil { mrCreateOpts.Labels = &gitlab.Labels{} } // These actions need to be done here, after the `Add metadata` prompt because // they are metadata that can be modified by the prompt *mrCreateOpts.Labels = append(*mrCreateOpts.Labels, opts.Labels...) if len(opts.Assignees) > 0 { users, err := api.UsersByNames(labClient, opts.Assignees) if err != nil { return err } mrCreateOpts.AssigneeIDs = cmdutils.IDsFromUsers(users) } if len(opts.Reviewers) > 0 { users, err := api.UsersByNames(labClient, opts.Reviewers) if err != nil { return err } mrCreateOpts.ReviewerIDs = cmdutils.IDsFromUsers(users) } if opts.MileStone != 0 { mrCreateOpts.MilestoneID = gitlab.Int(opts.MileStone) } if action == cmdutils.CancelAction { fmt.Fprintln(opts.IO.StdErr, "Discarded.") return nil } if err := handlePush(opts, headRepoRemote); err != nil { return err } if action == cmdutils.PreviewAction { return previewMR(opts) } if action == cmdutils.SubmitAction { message := "\nCreating merge request for %s into %s in %s\n\n" if opts.IsDraft || opts.IsWIP { message = "\nCreating draft merge request for %s into %s in %s\n\n" } fmt.Fprintf(opts.IO.StdErr, message, c.Cyan(opts.SourceBranch), c.Cyan(opts.TargetBranch), baseRepo.FullName()) // It is intentional that we create against the head repo, it is necessary // for cross-repository merge requests mr, err := api.CreateMR(labClient, headRepo.FullName(), mrCreateOpts) if err != nil { return err } fmt.Fprintln(out, mrutils.DisplayMR(c, mr, opts.IO.IsaTTY)) return nil } return errors.New("expected to cancel, preview in browser, or submit") } func mrBodyAndTitle(opts *CreateOpts) error { // TODO: detect forks commits, err := git.Commits(opts.TargetTrackingBranch, opts.SourceBranch) if err != nil { return err } if len(commits) == 1 { if opts.Title == "" { opts.Title = commits[0].Title } if opts.Description == "" { body, err := git.CommitBody(commits[0].Sha) if err != nil { return err } opts.Description = body } } else { if opts.Title == "" { opts.Title = utils.Humanize(opts.SourceBranch) } if opts.Description == "" { var body strings.Builder for i := len(commits) - 1; i >= 0; i-- { // adds 2 spaces for markdown line wrapping fmt.Fprintf(&body, "- %s \n", commits[i].Title) if opts.FillCommitBody { commitBody, err := git.CommitBody(commits[i].Sha) if err != nil { return err } re := regexp.MustCompile(`\r?\n\n`) commitBody = re.ReplaceAllString(commitBody, " \n") fmt.Fprintf(&body, "%s\n", commitBody) } } opts.Description = body.String() } } return nil } func handlePush(opts *CreateOpts, remote *glrepo.Remote) error { if opts.ShouldPush { sourceRemote := remote sourceBranch := opts.SourceBranch if sourceBranch != "" { if idx := strings.IndexRune(sourceBranch, ':'); idx >= 0 { sourceBranch = sourceBranch[idx+1:] } } if c, err := git.UncommittedChangeCount(); c != 0 { if err != nil { return err } fmt.Fprintf(opts.IO.StdErr, "\nwarning: you have %s\n", utils.Pluralize(c, "uncommitted change")) } err := git.Push(sourceRemote.Name, fmt.Sprintf("HEAD:%s", sourceBranch), opts.IO.StdOut, opts.IO.StdErr) if err == nil { branchConfig := git.ReadBranchConfig(sourceBranch) if branchConfig.RemoteName == "" && (branchConfig.MergeRef == "" || branchConfig.RemoteURL == nil) { // No remote is set so set it _ = git.SetUpstream(sourceRemote.Name, sourceBranch, opts.IO.StdOut, opts.IO.StdErr) } } return err } return nil } func previewMR(opts *CreateOpts) error { repo, err := opts.BaseRepo() if err != nil { return err } cfg, err := opts.Config() if err != nil { return err } openURL, err := generateMRCompareURL(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 generateMRCompareURL(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 len(opts.Reviewers) > 0 { // this uses the slash commands to add reviewers to the description description += fmt.Sprintf("\n/reviewer %s", strings.Join(opts.Reviewers, ", ")) } if opts.MileStone != 0 { // this uses the slash commands to add milestone to the description description += fmt.Sprintf("\n/milestone %%%d", opts.MileStone) } // The merge request **must** be opened against the head repo u, err := url.Parse(opts.SourceProject.WebURL) if err != nil { return "", err } u.Path += "/-/merge_requests/new" u.RawQuery = fmt.Sprintf( "merge_request[title]=%s&merge_request[description]=%s&merge_request[source_branch]=%s&merge_request[target_branch]=%s&merge_request[source_project_id]=%d&merge_request[target_project_id]=%d", strings.ReplaceAll(url.PathEscape(opts.Title), "+", "%2B"), strings.ReplaceAll(url.PathEscape(description), "+", "%2B"), opts.SourceBranch, opts.TargetBranch, opts.SourceProject.ID, opts.TargetProject.ID) return u.String(), nil } func resolvedHeadRepo(f *cmdutils.Factory) func() (glrepo.Interface, error) { return func() (glrepo.Interface, error) { httpClient, err := f.HttpClient() if err != nil { return nil, err } remotes, err := f.Remotes() if err != nil { return nil, err } repoContext, err := glrepo.ResolveRemotesToRepos(remotes, httpClient, "") if err != nil { return nil, err } headRepo, err := repoContext.HeadRepo(f.IO.PromptEnabled()) if err != nil { return nil, err } return headRepo, nil } } func headRepoOverride(opts *CreateOpts, repo string) error { opts.HeadRepo = func() (glrepo.Interface, error) { return glrepo.FromFullName(repo) } return nil } func repoRemote(opts *CreateOpts, repo glrepo.Interface, project *gitlab.Project, remoteName string) (*glrepo.Remote, error) { remotes, err := opts.Remotes() if err != nil { return nil, err } repoRemote, _ := remotes.FindByRepo(repo.RepoOwner(), repo.RepoName()) if repoRemote == nil { cfg, err := opts.Config() if err != nil { return nil, err } gitProtocol, _ := cfg.Get(repo.RepoHost(), "git_protocol") repoURL := glrepo.RemoteURL(project, gitProtocol) gitRemote, err := git.AddRemote(remoteName, repoURL) if err != nil { return nil, fmt.Errorf("error adding remote: %w", err) } repoRemote = &glrepo.Remote{ Remote: gitRemote, Repo: repo, } } return repoRemote, nil } func getTargetBranch(baseRepoRemote *glrepo.Remote) string { br, _ := git.GetDefaultBranch(baseRepoRemote.PushURL.String()) // we ignore the error since git.GetDefaultBranch returns master and an error // if the default branch cannot be determined return br }