github.com/cli/cli@v1.14.1-0.20210902173923-1af6a669e342/pkg/cmd/release/create/create.go (about)

     1  package create
     2  
     3  import (
     4  	"bytes"
     5  	"errors"
     6  	"fmt"
     7  	"net/http"
     8  	"os"
     9  	"strings"
    10  
    11  	"github.com/AlecAivazis/survey/v2"
    12  	"github.com/MakeNowJust/heredoc"
    13  	"github.com/cli/cli/git"
    14  	"github.com/cli/cli/internal/config"
    15  	"github.com/cli/cli/internal/ghrepo"
    16  	"github.com/cli/cli/internal/run"
    17  	"github.com/cli/cli/pkg/cmd/release/shared"
    18  	"github.com/cli/cli/pkg/cmdutil"
    19  	"github.com/cli/cli/pkg/iostreams"
    20  	"github.com/cli/cli/pkg/prompt"
    21  	"github.com/cli/cli/pkg/surveyext"
    22  	"github.com/cli/cli/pkg/text"
    23  	"github.com/spf13/cobra"
    24  )
    25  
    26  type CreateOptions struct {
    27  	IO         *iostreams.IOStreams
    28  	Config     func() (config.Config, error)
    29  	HttpClient func() (*http.Client, error)
    30  	BaseRepo   func() (ghrepo.Interface, error)
    31  
    32  	TagName      string
    33  	Target       string
    34  	Name         string
    35  	Body         string
    36  	BodyProvided bool
    37  	Draft        bool
    38  	Prerelease   bool
    39  
    40  	Assets []*shared.AssetForUpload
    41  
    42  	// for interactive flow
    43  	SubmitAction string
    44  	// for interactive flow
    45  	ReleaseNotesAction string
    46  
    47  	// the value from the --repo flag
    48  	RepoOverride string
    49  
    50  	// maximum number of simultaneous uploads
    51  	Concurrency int
    52  
    53  	DiscussionCategory string
    54  }
    55  
    56  func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
    57  	opts := &CreateOptions{
    58  		IO:         f.IOStreams,
    59  		HttpClient: f.HttpClient,
    60  		Config:     f.Config,
    61  	}
    62  
    63  	var notesFile string
    64  
    65  	cmd := &cobra.Command{
    66  		DisableFlagsInUseLine: true,
    67  
    68  		Use:   "create <tag> [<files>...]",
    69  		Short: "Create a new release",
    70  		Long: heredoc.Docf(`
    71  			Create a new GitHub Release for a repository.
    72  
    73  			A list of asset files may be given to upload to the new release. To define a
    74  			display label for an asset, append text starting with %[1]s#%[1]s after the file name.
    75  
    76  			If a matching git tag does not yet exist, one will automatically get created
    77  			from the latest state of the default branch. Use %[1]s--target%[1]s to override this.
    78  			To fetch the new tag locally after the release, do %[1]sgit fetch --tags origin%[1]s.
    79  
    80  			To create a release from an annotated git tag, first create one locally with
    81  			git, push the tag to GitHub, then run this command.
    82  		`, "`"),
    83  		Example: heredoc.Doc(`
    84  			Interactively create a release
    85  			$ gh release create v1.2.3
    86  
    87  			Non-interactively create a release
    88  			$ gh release create v1.2.3 --notes "bugfix release"
    89  
    90  			Use release notes from a file
    91  			$ gh release create v1.2.3 -F changelog.md
    92  
    93  			Upload all tarballs in a directory as release assets
    94  			$ gh release create v1.2.3 ./dist/*.tgz
    95  
    96  			Upload a release asset with a display label
    97  			$ gh release create v1.2.3 '/path/to/asset.zip#My display label'
    98  
    99  			Create a release and start a discussion
   100  			$ gh release create v1.2.3 --discussion-category "General"
   101  		`),
   102  		Args: cmdutil.MinimumArgs(1, "could not create: no tag name provided"),
   103  		RunE: func(cmd *cobra.Command, args []string) error {
   104  			if cmd.Flags().Changed("discussion-category") && opts.Draft {
   105  				return errors.New("Discussions for draft releases not supported")
   106  			}
   107  
   108  			// support `-R, --repo` override
   109  			opts.BaseRepo = f.BaseRepo
   110  			opts.RepoOverride, _ = cmd.Flags().GetString("repo")
   111  
   112  			opts.TagName = args[0]
   113  
   114  			var err error
   115  			opts.Assets, err = shared.AssetsFromArgs(args[1:])
   116  			if err != nil {
   117  				return err
   118  			}
   119  
   120  			opts.Concurrency = 5
   121  
   122  			opts.BodyProvided = cmd.Flags().Changed("notes")
   123  			if notesFile != "" {
   124  				b, err := cmdutil.ReadFile(notesFile, opts.IO.In)
   125  				if err != nil {
   126  					return err
   127  				}
   128  				opts.Body = string(b)
   129  				opts.BodyProvided = true
   130  			}
   131  
   132  			if runF != nil {
   133  				return runF(opts)
   134  			}
   135  			return createRun(opts)
   136  		},
   137  	}
   138  
   139  	cmd.Flags().BoolVarP(&opts.Draft, "draft", "d", false, "Save the release as a draft instead of publishing it")
   140  	cmd.Flags().BoolVarP(&opts.Prerelease, "prerelease", "p", false, "Mark the release as a prerelease")
   141  	cmd.Flags().StringVar(&opts.Target, "target", "", "Target `branch` or full commit SHA (default: main branch)")
   142  	cmd.Flags().StringVarP(&opts.Name, "title", "t", "", "Release title")
   143  	cmd.Flags().StringVarP(&opts.Body, "notes", "n", "", "Release notes")
   144  	cmd.Flags().StringVarP(&notesFile, "notes-file", "F", "", "Read release notes from `file`")
   145  	cmd.Flags().StringVarP(&opts.DiscussionCategory, "discussion-category", "", "", "Start a discussion of the specified category")
   146  
   147  	return cmd
   148  }
   149  
   150  func createRun(opts *CreateOptions) error {
   151  	httpClient, err := opts.HttpClient()
   152  	if err != nil {
   153  		return err
   154  	}
   155  
   156  	baseRepo, err := opts.BaseRepo()
   157  	if err != nil {
   158  		return err
   159  	}
   160  
   161  	if !opts.BodyProvided && opts.IO.CanPrompt() {
   162  		editorCommand, err := cmdutil.DetermineEditor(opts.Config)
   163  		if err != nil {
   164  			return err
   165  		}
   166  
   167  		var tagDescription string
   168  		var generatedChangelog string
   169  		if opts.RepoOverride == "" {
   170  			headRef := opts.TagName
   171  			tagDescription, _ = gitTagInfo(opts.TagName)
   172  			if tagDescription == "" {
   173  				if opts.Target != "" {
   174  					// TODO: use the remote-tracking version of the branch ref
   175  					headRef = opts.Target
   176  				} else {
   177  					headRef = "HEAD"
   178  				}
   179  			}
   180  
   181  			if prevTag, err := detectPreviousTag(headRef); err == nil {
   182  				commits, _ := changelogForRange(fmt.Sprintf("%s..%s", prevTag, headRef))
   183  				generatedChangelog = generateChangelog(commits)
   184  			}
   185  		}
   186  
   187  		editorOptions := []string{"Write my own"}
   188  		if generatedChangelog != "" {
   189  			editorOptions = append(editorOptions, "Write using commit log as template")
   190  		}
   191  		if tagDescription != "" {
   192  			editorOptions = append(editorOptions, "Write using git tag message as template")
   193  		}
   194  		editorOptions = append(editorOptions, "Leave blank")
   195  
   196  		qs := []*survey.Question{
   197  			{
   198  				Name: "name",
   199  				Prompt: &survey.Input{
   200  					Message: "Title (optional)",
   201  					Default: opts.Name,
   202  				},
   203  			},
   204  			{
   205  				Name: "releaseNotesAction",
   206  				Prompt: &survey.Select{
   207  					Message: "Release notes",
   208  					Options: editorOptions,
   209  				},
   210  			},
   211  		}
   212  		err = prompt.SurveyAsk(qs, opts)
   213  		if err != nil {
   214  			return fmt.Errorf("could not prompt: %w", err)
   215  		}
   216  
   217  		var openEditor bool
   218  		var editorContents string
   219  
   220  		switch opts.ReleaseNotesAction {
   221  		case "Write my own":
   222  			openEditor = true
   223  		case "Write using commit log as template":
   224  			openEditor = true
   225  			editorContents = generatedChangelog
   226  		case "Write using git tag message as template":
   227  			openEditor = true
   228  			editorContents = tagDescription
   229  		case "Leave blank":
   230  			openEditor = false
   231  		default:
   232  			return fmt.Errorf("invalid action: %v", opts.ReleaseNotesAction)
   233  		}
   234  
   235  		if openEditor {
   236  			// TODO: consider using iostreams here
   237  			text, err := surveyext.Edit(editorCommand, "*.md", editorContents, os.Stdin, os.Stdout, os.Stderr, nil)
   238  			if err != nil {
   239  				return err
   240  			}
   241  			opts.Body = text
   242  		}
   243  
   244  		qs = []*survey.Question{
   245  			{
   246  				Name: "prerelease",
   247  				Prompt: &survey.Confirm{
   248  					Message: "Is this a prerelease?",
   249  					Default: opts.Prerelease,
   250  				},
   251  			},
   252  			{
   253  				Name: "submitAction",
   254  				Prompt: &survey.Select{
   255  					Message: "Submit?",
   256  					Options: []string{
   257  						"Publish release",
   258  						"Save as draft",
   259  						"Cancel",
   260  					},
   261  				},
   262  			},
   263  		}
   264  
   265  		err = prompt.SurveyAsk(qs, opts)
   266  		if err != nil {
   267  			return fmt.Errorf("could not prompt: %w", err)
   268  		}
   269  
   270  		switch opts.SubmitAction {
   271  		case "Publish release":
   272  			opts.Draft = false
   273  		case "Save as draft":
   274  			opts.Draft = true
   275  		case "Cancel":
   276  			return cmdutil.CancelError
   277  		default:
   278  			return fmt.Errorf("invalid action: %v", opts.SubmitAction)
   279  		}
   280  	}
   281  
   282  	if opts.Draft && len(opts.DiscussionCategory) > 0 {
   283  		return fmt.Errorf(
   284  			"%s Discussions not supported with draft releases",
   285  			opts.IO.ColorScheme().FailureIcon(),
   286  		)
   287  	}
   288  
   289  	params := map[string]interface{}{
   290  		"tag_name":   opts.TagName,
   291  		"draft":      opts.Draft,
   292  		"prerelease": opts.Prerelease,
   293  		"name":       opts.Name,
   294  		"body":       opts.Body,
   295  	}
   296  	if opts.Target != "" {
   297  		params["target_commitish"] = opts.Target
   298  	}
   299  	if opts.DiscussionCategory != "" {
   300  		params["discussion_category_name"] = opts.DiscussionCategory
   301  	}
   302  
   303  	hasAssets := len(opts.Assets) > 0
   304  
   305  	// Avoid publishing the release until all assets have finished uploading
   306  	if hasAssets {
   307  		params["draft"] = true
   308  	}
   309  
   310  	newRelease, err := createRelease(httpClient, baseRepo, params)
   311  	if err != nil {
   312  		return err
   313  	}
   314  
   315  	if hasAssets {
   316  		uploadURL := newRelease.UploadURL
   317  		if idx := strings.IndexRune(uploadURL, '{'); idx > 0 {
   318  			uploadURL = uploadURL[:idx]
   319  		}
   320  
   321  		opts.IO.StartProgressIndicator()
   322  		err = shared.ConcurrentUpload(httpClient, uploadURL, opts.Concurrency, opts.Assets)
   323  		opts.IO.StopProgressIndicator()
   324  		if err != nil {
   325  			return err
   326  		}
   327  
   328  		if !opts.Draft {
   329  			rel, err := publishRelease(httpClient, newRelease.APIURL)
   330  			if err != nil {
   331  				return err
   332  			}
   333  			newRelease = rel
   334  		}
   335  	}
   336  
   337  	fmt.Fprintf(opts.IO.Out, "%s\n", newRelease.URL)
   338  
   339  	return nil
   340  }
   341  
   342  func gitTagInfo(tagName string) (string, error) {
   343  	cmd, err := git.GitCommand("tag", "--list", tagName, "--format=%(contents:subject)%0a%0a%(contents:body)")
   344  	if err != nil {
   345  		return "", err
   346  	}
   347  	b, err := run.PrepareCmd(cmd).Output()
   348  	return string(b), err
   349  }
   350  
   351  func detectPreviousTag(headRef string) (string, error) {
   352  	cmd, err := git.GitCommand("describe", "--tags", "--abbrev=0", fmt.Sprintf("%s^", headRef))
   353  	if err != nil {
   354  		return "", err
   355  	}
   356  	b, err := run.PrepareCmd(cmd).Output()
   357  	return strings.TrimSpace(string(b)), err
   358  }
   359  
   360  type logEntry struct {
   361  	Subject string
   362  	Body    string
   363  }
   364  
   365  func changelogForRange(refRange string) ([]logEntry, error) {
   366  	cmd, err := git.GitCommand("-c", "log.ShowSignature=false", "log", "--first-parent", "--reverse", "--pretty=format:%B%x00", refRange)
   367  	if err != nil {
   368  		return nil, err
   369  	}
   370  	b, err := run.PrepareCmd(cmd).Output()
   371  	if err != nil {
   372  		return nil, err
   373  	}
   374  
   375  	var entries []logEntry
   376  	for _, cb := range bytes.Split(b, []byte{'\000'}) {
   377  		c := strings.ReplaceAll(string(cb), "\r\n", "\n")
   378  		c = strings.TrimPrefix(c, "\n")
   379  		if len(c) == 0 {
   380  			continue
   381  		}
   382  		parts := strings.SplitN(c, "\n\n", 2)
   383  		var body string
   384  		subject := strings.ReplaceAll(parts[0], "\n", " ")
   385  		if len(parts) > 1 {
   386  			body = parts[1]
   387  		}
   388  		entries = append(entries, logEntry{
   389  			Subject: subject,
   390  			Body:    body,
   391  		})
   392  	}
   393  
   394  	return entries, nil
   395  }
   396  
   397  func generateChangelog(commits []logEntry) string {
   398  	var parts []string
   399  	for _, c := range commits {
   400  		// TODO: consider rendering "Merge pull request #123 from owner/branch" differently
   401  		parts = append(parts, fmt.Sprintf("* %s", c.Subject))
   402  		if c.Body != "" {
   403  			parts = append(parts, text.Indent(c.Body, "  "))
   404  		}
   405  	}
   406  	return strings.Join(parts, "\n\n")
   407  }