github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/release/create/create.go (about)

     1  package create
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"strings"
    11  
    12  	"github.com/AlecAivazis/survey/v2"
    13  	"github.com/MakeNowJust/heredoc"
    14  	"github.com/ungtb10d/cli/v2/git"
    15  	"github.com/ungtb10d/cli/v2/internal/config"
    16  	"github.com/ungtb10d/cli/v2/internal/ghrepo"
    17  	"github.com/ungtb10d/cli/v2/internal/text"
    18  	"github.com/ungtb10d/cli/v2/pkg/cmd/release/shared"
    19  	"github.com/ungtb10d/cli/v2/pkg/cmdutil"
    20  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    21  	"github.com/ungtb10d/cli/v2/pkg/prompt"
    22  	"github.com/ungtb10d/cli/v2/pkg/surveyext"
    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  	GitClient  *git.Client
    31  	BaseRepo   func() (ghrepo.Interface, error)
    32  	Edit       func(string, string, string, io.Reader, io.Writer, io.Writer) (string, error)
    33  
    34  	TagName      string
    35  	Target       string
    36  	Name         string
    37  	Body         string
    38  	BodyProvided bool
    39  	Draft        bool
    40  	Prerelease   bool
    41  	IsLatest     *bool
    42  
    43  	Assets []*shared.AssetForUpload
    44  
    45  	// for interactive flow
    46  	SubmitAction string
    47  	// for interactive flow
    48  	ReleaseNotesAction string
    49  	// the value from the --repo flag
    50  	RepoOverride string
    51  	// maximum number of simultaneous uploads
    52  	Concurrency        int
    53  	DiscussionCategory string
    54  	GenerateNotes      bool
    55  	NotesStartTag      string
    56  }
    57  
    58  func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
    59  	opts := &CreateOptions{
    60  		IO:         f.IOStreams,
    61  		HttpClient: f.HttpClient,
    62  		GitClient:  f.GitClient,
    63  		Config:     f.Config,
    64  		Edit:       surveyext.Edit,
    65  	}
    66  
    67  	var notesFile string
    68  
    69  	cmd := &cobra.Command{
    70  		DisableFlagsInUseLine: true,
    71  
    72  		Use:   "create [<tag>] [<files>...]",
    73  		Short: "Create a new release",
    74  		Long: heredoc.Docf(`
    75  			Create a new GitHub Release for a repository.
    76  
    77  			A list of asset files may be given to upload to the new release. To define a
    78  			display label for an asset, append text starting with %[1]s#%[1]s after the file name.
    79  
    80  			If a matching git tag does not yet exist, one will automatically get created
    81  			from the latest state of the default branch. Use %[1]s--target%[1]s to override this.
    82  			To fetch the new tag locally after the release, do %[1]sgit fetch --tags origin%[1]s.
    83  
    84  			To create a release from an annotated git tag, first create one locally with
    85  			git, push the tag to GitHub, then run this command.
    86  
    87  			When using automatically generated release notes, a release title will also be automatically
    88  			generated unless a title was explicitly passed. Additional release notes can be prepended to
    89  			automatically generated notes by using the notes parameter.
    90  		`, "`"),
    91  		Example: heredoc.Doc(`
    92  			Interactively create a release
    93  			$ gh release create
    94  
    95  			Interactively create a release from specific tag
    96  			$ gh release create v1.2.3
    97  
    98  			Non-interactively create a release
    99  			$ gh release create v1.2.3 --notes "bugfix release"
   100  
   101  			Use automatically generated release notes
   102  			$ gh release create v1.2.3 --generate-notes
   103  
   104  			Use release notes from a file
   105  			$ gh release create v1.2.3 -F changelog.md
   106  
   107  			Upload all tarballs in a directory as release assets
   108  			$ gh release create v1.2.3 ./dist/*.tgz
   109  
   110  			Upload a release asset with a display label
   111  			$ gh release create v1.2.3 '/path/to/asset.zip#My display label'
   112  
   113  			Create a release and start a discussion
   114  			$ gh release create v1.2.3 --discussion-category "General"
   115  		`),
   116  		Aliases: []string{"new"},
   117  		RunE: func(cmd *cobra.Command, args []string) error {
   118  			if cmd.Flags().Changed("discussion-category") && opts.Draft {
   119  				return errors.New("discussions for draft releases not supported")
   120  			}
   121  
   122  			// support `-R, --repo` override
   123  			opts.BaseRepo = f.BaseRepo
   124  			opts.RepoOverride, _ = cmd.Flags().GetString("repo")
   125  
   126  			var err error
   127  
   128  			if len(args) > 0 {
   129  				opts.TagName = args[0]
   130  				opts.Assets, err = shared.AssetsFromArgs(args[1:])
   131  				if err != nil {
   132  					return err
   133  				}
   134  			}
   135  
   136  			if opts.TagName == "" && !opts.IO.CanPrompt() {
   137  				return cmdutil.FlagErrorf("tag required when not running interactively")
   138  			}
   139  
   140  			opts.Concurrency = 5
   141  
   142  			opts.BodyProvided = cmd.Flags().Changed("notes") || opts.GenerateNotes
   143  			if notesFile != "" {
   144  				b, err := cmdutil.ReadFile(notesFile, opts.IO.In)
   145  				if err != nil {
   146  					return err
   147  				}
   148  				opts.Body = string(b)
   149  				opts.BodyProvided = true
   150  			}
   151  
   152  			if runF != nil {
   153  				return runF(opts)
   154  			}
   155  			return createRun(opts)
   156  		},
   157  	}
   158  
   159  	cmd.Flags().BoolVarP(&opts.Draft, "draft", "d", false, "Save the release as a draft instead of publishing it")
   160  	cmd.Flags().BoolVarP(&opts.Prerelease, "prerelease", "p", false, "Mark the release as a prerelease")
   161  	cmd.Flags().StringVar(&opts.Target, "target", "", "Target `branch` or full commit SHA (default: main branch)")
   162  	cmd.Flags().StringVarP(&opts.Name, "title", "t", "", "Release title")
   163  	cmd.Flags().StringVarP(&opts.Body, "notes", "n", "", "Release notes")
   164  	cmd.Flags().StringVarP(&notesFile, "notes-file", "F", "", "Read release notes from `file` (use \"-\" to read from standard input)")
   165  	cmd.Flags().StringVarP(&opts.DiscussionCategory, "discussion-category", "", "", "Start a discussion in the specified category")
   166  	cmd.Flags().BoolVarP(&opts.GenerateNotes, "generate-notes", "", false, "Automatically generate title and notes for the release")
   167  	cmd.Flags().StringVar(&opts.NotesStartTag, "notes-start-tag", "", "Tag to use as the starting point for generating release notes")
   168  	cmdutil.NilBoolFlag(cmd, &opts.IsLatest, "latest", "", "Mark this release as \"Latest\" (default: automatic based on date and version)")
   169  
   170  	return cmd
   171  }
   172  
   173  func createRun(opts *CreateOptions) error {
   174  	httpClient, err := opts.HttpClient()
   175  	if err != nil {
   176  		return err
   177  	}
   178  
   179  	baseRepo, err := opts.BaseRepo()
   180  	if err != nil {
   181  		return err
   182  	}
   183  
   184  	var existingTag bool
   185  	if opts.TagName == "" {
   186  		tags, err := getTags(httpClient, baseRepo, 5)
   187  		if err != nil {
   188  			return err
   189  		}
   190  
   191  		if len(tags) != 0 {
   192  			options := make([]string, len(tags))
   193  			for i, tag := range tags {
   194  				options[i] = tag.Name
   195  			}
   196  			createNewTagOption := "Create a new tag"
   197  			options = append(options, createNewTagOption)
   198  			var tag string
   199  			q := &survey.Select{
   200  				Message: "Choose a tag",
   201  				Options: options,
   202  				Default: options[0],
   203  			}
   204  			//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
   205  			err := prompt.SurveyAskOne(q, &tag)
   206  			if err != nil {
   207  				return fmt.Errorf("could not prompt: %w", err)
   208  			}
   209  			if tag != createNewTagOption {
   210  				existingTag = true
   211  				opts.TagName = tag
   212  			}
   213  		}
   214  
   215  		if opts.TagName == "" {
   216  			q := &survey.Input{
   217  				Message: "Tag name",
   218  			}
   219  			//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
   220  			err := prompt.SurveyAskOne(q, &opts.TagName)
   221  			if err != nil {
   222  				return fmt.Errorf("could not prompt: %w", err)
   223  			}
   224  		}
   225  	}
   226  
   227  	var tagDescription string
   228  	if opts.RepoOverride == "" {
   229  		tagDescription, _ = gitTagInfo(opts.GitClient, opts.TagName)
   230  		// If there is a local tag with the same name as specified
   231  		// the user may not want to create a new tag on the remote
   232  		// as the local one might be annotated or signed.
   233  		// If the user specifies the target take that as explicit instruction
   234  		// to create the tag on the remote pointing to the target regardless
   235  		// of local tag status.
   236  		// If a remote tag with the same name as specified exists already
   237  		// then a new tag will not be created so ignore local tag status.
   238  		if tagDescription != "" && !existingTag && opts.Target == "" {
   239  			remoteExists, err := remoteTagExists(httpClient, baseRepo, opts.TagName)
   240  			if err != nil {
   241  				return err
   242  			}
   243  			if !remoteExists {
   244  				return fmt.Errorf("tag %s exists locally but has not been pushed to %s, please push it before continuing or specify the `--target` flag to create a new tag",
   245  					opts.TagName, ghrepo.FullName(baseRepo))
   246  			}
   247  		}
   248  	}
   249  
   250  	if !opts.BodyProvided && opts.IO.CanPrompt() {
   251  		editorCommand, err := cmdutil.DetermineEditor(opts.Config)
   252  		if err != nil {
   253  			return err
   254  		}
   255  
   256  		var generatedNotes *releaseNotes
   257  		var generatedChangelog string
   258  
   259  		generatedNotes, err = generateReleaseNotes(httpClient, baseRepo, opts.TagName, opts.Target, opts.NotesStartTag)
   260  		if err != nil && !errors.Is(err, notImplementedError) {
   261  			return err
   262  		}
   263  
   264  		if opts.RepoOverride == "" {
   265  			headRef := opts.TagName
   266  			if tagDescription == "" {
   267  				if opts.Target != "" {
   268  					// TODO: use the remote-tracking version of the branch ref
   269  					headRef = opts.Target
   270  				} else {
   271  					headRef = "HEAD"
   272  				}
   273  			}
   274  			if generatedNotes == nil {
   275  				if opts.NotesStartTag != "" {
   276  					commits, _ := changelogForRange(opts.GitClient, fmt.Sprintf("%s..%s", opts.NotesStartTag, headRef))
   277  					generatedChangelog = generateChangelog(commits)
   278  				} else if prevTag, err := detectPreviousTag(opts.GitClient, headRef); err == nil {
   279  					commits, _ := changelogForRange(opts.GitClient, fmt.Sprintf("%s..%s", prevTag, headRef))
   280  					generatedChangelog = generateChangelog(commits)
   281  				}
   282  			}
   283  		}
   284  
   285  		editorOptions := []string{"Write my own"}
   286  		if generatedNotes != nil {
   287  			editorOptions = append(editorOptions, "Write using generated notes as template")
   288  		}
   289  		if generatedChangelog != "" {
   290  			editorOptions = append(editorOptions, "Write using commit log as template")
   291  		}
   292  		if tagDescription != "" {
   293  			editorOptions = append(editorOptions, "Write using git tag message as template")
   294  		}
   295  		editorOptions = append(editorOptions, "Leave blank")
   296  
   297  		defaultName := opts.Name
   298  		if defaultName == "" && generatedNotes != nil {
   299  			defaultName = generatedNotes.Name
   300  		}
   301  		qs := []*survey.Question{
   302  			{
   303  				Name: "name",
   304  				Prompt: &survey.Input{
   305  					Message: "Title (optional)",
   306  					Default: defaultName,
   307  				},
   308  			},
   309  			{
   310  				Name: "releaseNotesAction",
   311  				Prompt: &survey.Select{
   312  					Message: "Release notes",
   313  					Options: editorOptions,
   314  				},
   315  			},
   316  		}
   317  		//nolint:staticcheck // SA1019: prompt.SurveyAsk is deprecated: use Prompter
   318  		err = prompt.SurveyAsk(qs, opts)
   319  		if err != nil {
   320  			return fmt.Errorf("could not prompt: %w", err)
   321  		}
   322  
   323  		var openEditor bool
   324  		var editorContents string
   325  
   326  		switch opts.ReleaseNotesAction {
   327  		case "Write my own":
   328  			openEditor = true
   329  		case "Write using generated notes as template":
   330  			openEditor = true
   331  			editorContents = generatedNotes.Body
   332  		case "Write using commit log as template":
   333  			openEditor = true
   334  			editorContents = generatedChangelog
   335  		case "Write using git tag message as template":
   336  			openEditor = true
   337  			editorContents = tagDescription
   338  		case "Leave blank":
   339  			openEditor = false
   340  		default:
   341  			return fmt.Errorf("invalid action: %v", opts.ReleaseNotesAction)
   342  		}
   343  
   344  		if openEditor {
   345  			text, err := opts.Edit(editorCommand, "*.md", editorContents,
   346  				opts.IO.In, opts.IO.Out, opts.IO.ErrOut)
   347  			if err != nil {
   348  				return err
   349  			}
   350  			opts.Body = text
   351  		}
   352  
   353  		saveAsDraft := "Save as draft"
   354  		publishRelease := "Publish release"
   355  		defaultSubmit := publishRelease
   356  		if opts.Draft {
   357  			defaultSubmit = saveAsDraft
   358  		}
   359  
   360  		qs = []*survey.Question{
   361  			{
   362  				Name: "prerelease",
   363  				Prompt: &survey.Confirm{
   364  					Message: "Is this a prerelease?",
   365  					Default: opts.Prerelease,
   366  				},
   367  			},
   368  			{
   369  				Name: "submitAction",
   370  				Prompt: &survey.Select{
   371  					Message: "Submit?",
   372  					Options: []string{
   373  						publishRelease,
   374  						saveAsDraft,
   375  						"Cancel",
   376  					},
   377  					Default: defaultSubmit,
   378  				},
   379  			},
   380  		}
   381  
   382  		//nolint:staticcheck // SA1019: prompt.SurveyAsk is deprecated: use Prompter
   383  		err = prompt.SurveyAsk(qs, opts)
   384  		if err != nil {
   385  			return fmt.Errorf("could not prompt: %w", err)
   386  		}
   387  
   388  		switch opts.SubmitAction {
   389  		case "Publish release":
   390  			opts.Draft = false
   391  		case "Save as draft":
   392  			opts.Draft = true
   393  		case "Cancel":
   394  			return cmdutil.CancelError
   395  		default:
   396  			return fmt.Errorf("invalid action: %v", opts.SubmitAction)
   397  		}
   398  	}
   399  
   400  	params := map[string]interface{}{
   401  		"tag_name":   opts.TagName,
   402  		"draft":      opts.Draft,
   403  		"prerelease": opts.Prerelease,
   404  	}
   405  	if opts.Name != "" {
   406  		params["name"] = opts.Name
   407  	}
   408  	if opts.Body != "" {
   409  		params["body"] = opts.Body
   410  	}
   411  	if opts.Target != "" {
   412  		params["target_commitish"] = opts.Target
   413  	}
   414  	if opts.IsLatest != nil {
   415  		// valid values: true/false/legacy
   416  		params["make_latest"] = fmt.Sprintf("%v", *opts.IsLatest)
   417  	}
   418  	if opts.DiscussionCategory != "" {
   419  		params["discussion_category_name"] = opts.DiscussionCategory
   420  	}
   421  	if opts.GenerateNotes {
   422  		if opts.NotesStartTag != "" {
   423  			generatedNotes, err := generateReleaseNotes(httpClient, baseRepo, opts.TagName, opts.Target, opts.NotesStartTag)
   424  			if err != nil && !errors.Is(err, notImplementedError) {
   425  				return err
   426  			}
   427  			if generatedNotes != nil {
   428  				if opts.Body == "" {
   429  					params["body"] = generatedNotes.Body
   430  				} else {
   431  					params["body"] = fmt.Sprintf("%s\n%s", opts.Body, generatedNotes.Body)
   432  				}
   433  				if opts.Name == "" {
   434  					params["name"] = generatedNotes.Name
   435  				}
   436  			}
   437  		} else {
   438  			params["generate_release_notes"] = true
   439  		}
   440  	}
   441  
   442  	hasAssets := len(opts.Assets) > 0
   443  
   444  	if hasAssets && !opts.Draft {
   445  		// Check for an existing release
   446  		if opts.TagName != "" {
   447  			if ok, err := publishedReleaseExists(httpClient, baseRepo, opts.TagName); err != nil {
   448  				return fmt.Errorf("error checking for existing release: %w", err)
   449  			} else if ok {
   450  				return fmt.Errorf("a release with the same tag name already exists: %s", opts.TagName)
   451  			}
   452  		}
   453  		// Save the release initially as draft and publish it after all assets have finished uploading
   454  		params["draft"] = true
   455  	}
   456  
   457  	newRelease, err := createRelease(httpClient, baseRepo, params)
   458  	if err != nil {
   459  		return err
   460  	}
   461  
   462  	if hasAssets {
   463  		uploadURL := newRelease.UploadURL
   464  		if idx := strings.IndexRune(uploadURL, '{'); idx > 0 {
   465  			uploadURL = uploadURL[:idx]
   466  		}
   467  
   468  		opts.IO.StartProgressIndicator()
   469  		err = shared.ConcurrentUpload(httpClient, uploadURL, opts.Concurrency, opts.Assets)
   470  		opts.IO.StopProgressIndicator()
   471  		if err != nil {
   472  			return err
   473  		}
   474  
   475  		if !opts.Draft {
   476  			rel, err := publishRelease(httpClient, newRelease.APIURL, opts.DiscussionCategory)
   477  			if err != nil {
   478  				return err
   479  			}
   480  			newRelease = rel
   481  		}
   482  	}
   483  
   484  	fmt.Fprintf(opts.IO.Out, "%s\n", newRelease.URL)
   485  
   486  	return nil
   487  }
   488  
   489  func gitTagInfo(client *git.Client, tagName string) (string, error) {
   490  	cmd, err := client.Command(context.Background(), "tag", "--list", tagName, "--format=%(contents:subject)%0a%0a%(contents:body)")
   491  	if err != nil {
   492  		return "", err
   493  	}
   494  	b, err := cmd.Output()
   495  	return string(b), err
   496  }
   497  
   498  func detectPreviousTag(client *git.Client, headRef string) (string, error) {
   499  	cmd, err := client.Command(context.Background(), "describe", "--tags", "--abbrev=0", fmt.Sprintf("%s^", headRef))
   500  	if err != nil {
   501  		return "", err
   502  	}
   503  	b, err := cmd.Output()
   504  	return strings.TrimSpace(string(b)), err
   505  }
   506  
   507  type logEntry struct {
   508  	Subject string
   509  	Body    string
   510  }
   511  
   512  func changelogForRange(client *git.Client, refRange string) ([]logEntry, error) {
   513  	cmd, err := client.Command(context.Background(), "-c", "log.ShowSignature=false", "log", "--first-parent", "--reverse", "--pretty=format:%B%x00", refRange)
   514  	if err != nil {
   515  		return nil, err
   516  	}
   517  	b, err := cmd.Output()
   518  	if err != nil {
   519  		return nil, err
   520  	}
   521  
   522  	var entries []logEntry
   523  	for _, cb := range bytes.Split(b, []byte{'\000'}) {
   524  		c := strings.ReplaceAll(string(cb), "\r\n", "\n")
   525  		c = strings.TrimPrefix(c, "\n")
   526  		if len(c) == 0 {
   527  			continue
   528  		}
   529  		parts := strings.SplitN(c, "\n\n", 2)
   530  		var body string
   531  		subject := strings.ReplaceAll(parts[0], "\n", " ")
   532  		if len(parts) > 1 {
   533  			body = parts[1]
   534  		}
   535  		entries = append(entries, logEntry{
   536  			Subject: subject,
   537  			Body:    body,
   538  		})
   539  	}
   540  
   541  	return entries, nil
   542  }
   543  
   544  func generateChangelog(commits []logEntry) string {
   545  	var parts []string
   546  	for _, c := range commits {
   547  		// TODO: consider rendering "Merge pull request #123 from owner/branch" differently
   548  		parts = append(parts, fmt.Sprintf("* %s", c.Subject))
   549  		if c.Body != "" {
   550  			parts = append(parts, text.Indent(c.Body, "  "))
   551  		}
   552  	}
   553  	return strings.Join(parts, "\n\n")
   554  }