From 9fddb07eaba3f2740fcba66e6246d83365b67d90 Mon Sep 17 00:00:00 2001 From: Bindu Date: Tue, 6 Sep 2022 12:06:18 +0000 Subject: [PATCH] Add `--notes-start-tag` flag for generating release notes in `gh release create` (#6107) --- pkg/cmd/release/create/create.go | 34 +++-- pkg/cmd/release/create/create_test.go | 200 ++++++++++++++++++++++++++ pkg/cmd/release/create/http.go | 12 +- 3 files changed, 236 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 6b692fc61..c6a3294c3 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -50,6 +50,7 @@ type CreateOptions struct { Concurrency int DiscussionCategory string GenerateNotes bool + NotesStartTag string } func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { @@ -160,6 +161,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co cmd.Flags().StringVarP(¬esFile, "notes-file", "F", "", "Read release notes from `file` (use \"-\" to read from standard input)") cmd.Flags().StringVarP(&opts.DiscussionCategory, "discussion-category", "", "", "Start a discussion in the specified category") cmd.Flags().BoolVarP(&opts.GenerateNotes, "generate-notes", "", false, "Automatically generate title and notes for the release") + cmd.Flags().StringVar(&opts.NotesStartTag, "notes-start-tag", "", "Tag to use as the starting point for generating release notes") return cmd } @@ -250,13 +252,7 @@ func createRun(opts *CreateOptions) error { var generatedNotes *releaseNotes var generatedChangelog string - params := map[string]interface{}{ - "tag_name": opts.TagName, - } - if opts.Target != "" { - params["target_commitish"] = opts.Target - } - generatedNotes, err = generateReleaseNotes(httpClient, baseRepo, params) + generatedNotes, err = generateReleaseNotes(httpClient, baseRepo, opts.TagName, opts.Target, opts.NotesStartTag) if err != nil && !errors.Is(err, notImplementedError) { return err } @@ -272,7 +268,10 @@ func createRun(opts *CreateOptions) error { } } if generatedNotes == nil { - if prevTag, err := detectPreviousTag(headRef); err == nil { + if opts.NotesStartTag != "" { + commits, _ := changelogForRange(fmt.Sprintf("%s..%s", opts.NotesStartTag, headRef)) + generatedChangelog = generateChangelog(commits) + } else if prevTag, err := detectPreviousTag(headRef); err == nil { commits, _ := changelogForRange(fmt.Sprintf("%s..%s", prevTag, headRef)) generatedChangelog = generateChangelog(commits) } @@ -412,7 +411,24 @@ func createRun(opts *CreateOptions) error { params["discussion_category_name"] = opts.DiscussionCategory } if opts.GenerateNotes { - params["generate_release_notes"] = true + if opts.NotesStartTag != "" { + generatedNotes, err := generateReleaseNotes(httpClient, baseRepo, opts.TagName, opts.Target, opts.NotesStartTag) + if err != nil && !errors.Is(err, notImplementedError) { + return err + } + if generatedNotes != nil { + if opts.Body == "" { + params["body"] = generatedNotes.Body + } else { + params["body"] = fmt.Sprintf("%s\n%s", opts.Body, generatedNotes.Body) + } + if opts.Name == "" { + params["name"] = generatedNotes.Name + } + } + } else { + params["generate_release_notes"] = true + } } hasAssets := len(opts.Assets) > 0 diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index 494d29ab5..7fb6711b2 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -221,6 +221,44 @@ func Test_NewCmdCreate(t *testing.T) { GenerateNotes: true, }, }, + { + name: "generate release notes with notes tag", + args: "v1.2.3 --generate-notes --notes-start-tag v1.1.0", + isTTY: true, + want: CreateOptions{ + TagName: "v1.2.3", + Target: "", + Name: "", + Body: "", + BodyProvided: true, + Draft: false, + Prerelease: false, + RepoOverride: "", + Concurrency: 5, + Assets: []*shared.AssetForUpload(nil), + GenerateNotes: true, + NotesStartTag: "v1.1.0", + }, + }, + { + name: "notes tag", + args: "--notes-start-tag v1.1.0", + isTTY: true, + want: CreateOptions{ + TagName: "", + Target: "", + Name: "", + Body: "", + BodyProvided: false, + Draft: false, + Prerelease: false, + RepoOverride: "", + Concurrency: 5, + Assets: []*shared.AssetForUpload(nil), + GenerateNotes: false, + NotesStartTag: "v1.1.0", + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -271,6 +309,8 @@ func Test_NewCmdCreate(t *testing.T) { assert.Equal(t, tt.want.Concurrency, opts.Concurrency) assert.Equal(t, tt.want.RepoOverride, opts.RepoOverride) assert.Equal(t, tt.want.DiscussionCategory, opts.DiscussionCategory) + assert.Equal(t, tt.want.GenerateNotes, opts.GenerateNotes) + assert.Equal(t, tt.want.NotesStartTag, opts.NotesStartTag) require.Equal(t, len(tt.want.Assets), len(opts.Assets)) for i := range tt.want.Assets { @@ -431,6 +471,86 @@ func Test_createRun(t *testing.T) { wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", wantErr: "", }, + { + name: "with generate notes and notes tag", + isTTY: true, + opts: CreateOptions{ + TagName: "v1.2.3", + Name: "", + Body: "", + Target: "", + BodyProvided: true, + GenerateNotes: true, + NotesStartTag: "v1.1.0", + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases/generate-notes"), + httpmock.RESTPayload(200, `{ + "name": "generated name", + "body": "generated body" + }`, func(params map[string]interface{}) { + assert.Equal(t, map[string]interface{}{ + "tag_name": "v1.2.3", + "previous_tag_name": "v1.1.0", + }, params) + })) + reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), httpmock.RESTPayload(201, `{ + "url": "https://api.github.com/releases/123", + "upload_url": "https://api.github.com/assets/upload", + "html_url": "https://github.com/OWNER/REPO/releases/tag/v1.2.3" + }`, func(params map[string]interface{}) { + assert.Equal(t, map[string]interface{}{ + "tag_name": "v1.2.3", + "draft": false, + "prerelease": false, + "body": "generated body", + "name": "generated name", + }, params) + })) + }, + wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", + wantErr: "", + }, + { + name: "with generate notes and notes tag and body and name", + isTTY: true, + opts: CreateOptions{ + TagName: "v1.2.3", + Name: "name", + Body: "body", + Target: "", + BodyProvided: true, + GenerateNotes: true, + NotesStartTag: "v1.1.0", + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases/generate-notes"), + httpmock.RESTPayload(200, `{ + "name": "generated name", + "body": "generated body" + }`, func(params map[string]interface{}) { + assert.Equal(t, map[string]interface{}{ + "tag_name": "v1.2.3", + "previous_tag_name": "v1.1.0", + }, params) + })) + reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), httpmock.RESTPayload(201, `{ + "url": "https://api.github.com/releases/123", + "upload_url": "https://api.github.com/assets/upload", + "html_url": "https://github.com/OWNER/REPO/releases/tag/v1.2.3" + }`, func(params map[string]interface{}) { + assert.Equal(t, map[string]interface{}{ + "tag_name": "v1.2.3", + "draft": false, + "prerelease": false, + "body": "body\ngenerated body", + "name": "name", + }, params) + })) + }, + wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", + wantErr: "", + }, { name: "publish after uploading files", isTTY: true, @@ -823,6 +943,86 @@ func Test_createRun_interactive(t *testing.T) { }, wantOut: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", }, + { + name: "create a release using generated notes with previous tag", + opts: &CreateOptions{ + TagName: "v1.2.3", + NotesStartTag: "v1.1.0", + }, + askStubs: func(as *prompt.AskStubber) { + as.StubPrompt("Title (optional)").AnswerDefault() + as.StubPrompt("Release notes"). + AssertOptions([]string{"Write my own", "Write using generated notes as template", "Leave blank"}). + AnswerWith("Write using generated notes as template") + as.StubPrompt("Is this a prerelease?").AnswerWith(false) + as.StubPrompt("Submit?").AnswerWith("Publish release") + }, + runStubs: func(rs *run.CommandStubber) { + rs.Register(`git tag --list`, 1, "") + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases/generate-notes"), + httpmock.RESTPayload(200, `{ + "name": "generated name", + "body": "generated body" + }`, func(params map[string]interface{}) { + assert.Equal(t, map[string]interface{}{ + "tag_name": "v1.2.3", + "previous_tag_name": "v1.1.0", + }, params) + })) + reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), + httpmock.StatusStringResponse(201, `{ + "url": "https://api.github.com/releases/123", + "upload_url": "https://api.github.com/assets/upload", + "html_url": "https://github.com/OWNER/REPO/releases/tag/v1.2.3" + }`)) + }, + wantParams: map[string]interface{}{ + "body": "generated body", + "draft": false, + "name": "generated name", + "prerelease": false, + "tag_name": "v1.2.3", + }, + wantOut: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", + }, + { + name: "create a release using commit log as notes with previous tag", + opts: &CreateOptions{ + TagName: "v1.2.3", + NotesStartTag: "v1.1.0", + }, + askStubs: func(as *prompt.AskStubber) { + as.StubPrompt("Title (optional)").AnswerDefault() + as.StubPrompt("Release notes"). + AssertOptions([]string{"Write my own", "Write using commit log as template", "Leave blank"}). + AnswerWith("Write using commit log as template") + as.StubPrompt("Is this a prerelease?").AnswerWith(false) + as.StubPrompt("Submit?").AnswerWith("Publish release") + }, + runStubs: func(rs *run.CommandStubber) { + rs.Register(`git tag --list`, 1, "") + rs.Register(`git .+log .+v1\.1\.0\.\.HEAD$`, 0, "commit subject\n\ncommit body\n") + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases/generate-notes"), + httpmock.StatusStringResponse(404, `{}`)) + reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), + httpmock.StatusStringResponse(201, `{ + "url": "https://api.github.com/releases/123", + "upload_url": "https://api.github.com/assets/upload", + "html_url": "https://github.com/OWNER/REPO/releases/tag/v1.2.3" + }`)) + }, + wantParams: map[string]interface{}{ + "body": "* commit subject\n\n commit body\n ", + "draft": false, + "prerelease": false, + "tag_name": "v1.2.3", + }, + wantOut: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", + }, } for _, tt := range tests { ios, _, stdout, stderr := iostreams.Test() diff --git a/pkg/cmd/release/create/http.go b/pkg/cmd/release/create/http.go index 4f89dcdc5..3d13337b1 100644 --- a/pkg/cmd/release/create/http.go +++ b/pkg/cmd/release/create/http.go @@ -76,7 +76,17 @@ func getTags(httpClient *http.Client, repo ghrepo.Interface, limit int) ([]tag, return tags, err } -func generateReleaseNotes(httpClient *http.Client, repo ghrepo.Interface, params map[string]interface{}) (*releaseNotes, error) { +func generateReleaseNotes(httpClient *http.Client, repo ghrepo.Interface, tagName, target, previousTagName string) (*releaseNotes, error) { + params := map[string]interface{}{ + "tag_name": tagName, + } + if target != "" { + params["target_commitish"] = target + } + if previousTagName != "" { + params["previous_tag_name"] = previousTagName + } + bodyBytes, err := json.Marshal(params) if err != nil { return nil, err