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(¬esFile, "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 }