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