diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 67d1f85f1..d1617b99b 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -158,7 +158,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co cmd.Flags().StringVarP(&opts.Name, "title", "t", "", "Release title") cmd.Flags().StringVarP(&opts.Body, "notes", "n", "", "Release notes") 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 of the specified category") + 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") return cmd diff --git a/pkg/cmd/release/edit/edit.go b/pkg/cmd/release/edit/edit.go new file mode 100644 index 000000000..ca0840fa1 --- /dev/null +++ b/pkg/cmd/release/edit/edit.go @@ -0,0 +1,150 @@ +package edit + +import ( + "fmt" + "net/http" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/release/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type EditOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + BaseRepo func() (ghrepo.Interface, error) + + TagName string + Target string + Name *string + Body *string + DiscussionCategory *string + Draft *bool + Prerelease *bool +} + +func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Command { + opts := &EditOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + var notesFile string + + cmd := &cobra.Command{ + DisableFlagsInUseLine: true, + + Use: "edit ", + Short: "Edit a release", + Example: heredoc.Doc(` + Publish a release that was previously a draft + $ gh release edit v1.0 --draft=false + + Update the release notes from the content of a file + $ gh release edit v1.0 --notes-file /path/to/release_notes.md + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.BaseRepo = f.BaseRepo + + if cmd.Flags().NFlag() == 0 { + return cmdutil.FlagErrorf("use flags to specify properties to edit") + } + + if notesFile != "" { + b, err := cmdutil.ReadFile(notesFile, opts.IO.In) + if err != nil { + return err + } + body := string(b) + opts.Body = &body + } + + if runF != nil { + return runF(opts) + } + return editRun(args[0], opts) + }, + } + + cmdutil.NilBoolFlag(cmd, &opts.Draft, "draft", "", "Save the release as a draft instead of publishing it") + cmdutil.NilBoolFlag(cmd, &opts.Prerelease, "prerelease", "", "Mark the release as a prerelease") + cmdutil.NilStringFlag(cmd, &opts.Body, "notes", "n", "Release notes") + cmdutil.NilStringFlag(cmd, &opts.Name, "title", "t", "Release title") + cmdutil.NilStringFlag(cmd, &opts.DiscussionCategory, "discussion-category", "", "Start a discussion in the specified category when publishing a draft") + cmd.Flags().StringVar(&opts.Target, "target", "", "Target `branch` or full commit SHA (default: main branch)") + cmd.Flags().StringVar(&opts.TagName, "tag", "", "The name of the tag") + cmd.Flags().StringVarP(¬esFile, "notes-file", "F", "", "Read release notes from `file` (use \"-\" to read from standard input)") + + return cmd +} + +func editRun(tag string, opts *EditOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + release, err := shared.FetchRelease(httpClient, baseRepo, tag) + if err != nil { + return err + } + + params := getParams(opts) + + // If we don't provide any tag name, the API will remove the current tag from the release + if _, ok := params["tag_name"]; !ok { + params["tag_name"] = release.TagName + } + + editedRelease, err := editRelease(httpClient, baseRepo, release.DatabaseID, params) + if err != nil { + return err + } + + fmt.Fprintf(opts.IO.Out, "%s\n", editedRelease.URL) + + return nil +} + +func getParams(opts *EditOptions) map[string]interface{} { + params := map[string]interface{}{} + + if opts.Body != nil { + params["body"] = opts.Body + } + + if opts.DiscussionCategory != nil { + params["discussion_category_name"] = *opts.DiscussionCategory + } + + if opts.Draft != nil { + params["draft"] = *opts.Draft + } + + if opts.Name != nil { + params["name"] = opts.Name + } + + if opts.Prerelease != nil { + params["prerelease"] = *opts.Prerelease + } + + if opts.TagName != "" { + params["tag_name"] = opts.TagName + } + + if opts.Target != "" { + params["target_commitish"] = opts.Target + } + + return params +} diff --git a/pkg/cmd/release/edit/edit_test.go b/pkg/cmd/release/edit/edit_test.go new file mode 100644 index 000000000..410b2171e --- /dev/null +++ b/pkg/cmd/release/edit/edit_test.go @@ -0,0 +1,426 @@ +package edit + +import ( + "bytes" + "fmt" + "io/ioutil" + "net/http" + "testing" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_NewCmdEdit(t *testing.T) { + tempDir := t.TempDir() + tf, err := ioutil.TempFile(tempDir, "release-create") + require.NoError(t, err) + fmt.Fprint(tf, "MY NOTES") + tf.Close() + + tests := []struct { + name string + args string + isTTY bool + stdin string + want EditOptions + wantErr string + }{ + { + name: "no arguments notty", + args: "", + isTTY: false, + wantErr: "accepts 1 arg(s), received 0", + }, + { + name: "provide title and notes", + args: "v1.2.3 --title 'Some Title' --notes 'Some Notes'", + isTTY: false, + want: EditOptions{ + TagName: "", + Name: stringPtr("Some Title"), + Body: stringPtr("Some Notes"), + }, + }, + { + name: "provide discussion category", + args: "v1.2.3 --discussion-category some-category", + isTTY: false, + want: EditOptions{ + TagName: "", + DiscussionCategory: stringPtr("some-category"), + }, + }, + { + name: "provide tag and target commitish", + args: "v1.2.3 --tag v9.8.7 --target 97ea5e77b4d61d5d80ed08f7512847dee3ec9af5", + isTTY: false, + want: EditOptions{ + TagName: "v9.8.7", + Target: "97ea5e77b4d61d5d80ed08f7512847dee3ec9af5", + }, + }, + { + name: "provide prerelease", + args: "v1.2.3 --prerelease", + isTTY: false, + want: EditOptions{ + TagName: "", + Prerelease: boolPtr(true), + }, + }, + { + name: "provide prerelease=false", + args: "v1.2.3 --prerelease=false", + isTTY: false, + want: EditOptions{ + TagName: "", + Prerelease: boolPtr(false), + }, + }, + { + name: "provide draft", + args: "v1.2.3 --draft", + isTTY: false, + want: EditOptions{ + TagName: "", + Draft: boolPtr(true), + }, + }, + { + name: "provide draft=false", + args: "v1.2.3 --draft=false", + isTTY: false, + want: EditOptions{ + TagName: "", + Draft: boolPtr(false), + }, + }, + { + name: "provide notes from file", + args: fmt.Sprintf(`v1.2.3 -F '%s'`, tf.Name()), + isTTY: false, + want: EditOptions{ + TagName: "", + Body: stringPtr("MY NOTES"), + }, + }, + { + name: "provide notes from stdin", + args: "v1.2.3 -F -", + isTTY: false, + stdin: "MY NOTES", + want: EditOptions{ + TagName: "", + Body: stringPtr("MY NOTES"), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, stdin, _, _ := iostreams.Test() + if tt.stdin == "" { + io.SetStdinTTY(tt.isTTY) + } else { + io.SetStdinTTY(false) + fmt.Fprint(stdin, tt.stdin) + } + io.SetStdoutTTY(tt.isTTY) + io.SetStderrTTY(tt.isTTY) + + f := &cmdutil.Factory{ + IOStreams: io, + } + + var opts *EditOptions + cmd := NewCmdEdit(f, func(o *EditOptions) error { + opts = o + return nil + }) + cmd.PersistentFlags().StringP("repo", "R", "", "") + + argv, err := shlex.Split(tt.args) + require.NoError(t, err) + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.want.TagName, opts.TagName) + assert.Equal(t, tt.want.Target, opts.Target) + assert.Equal(t, tt.want.Name, opts.Name) + assert.Equal(t, tt.want.Body, opts.Body) + assert.Equal(t, tt.want.DiscussionCategory, opts.DiscussionCategory) + assert.Equal(t, tt.want.Draft, opts.Draft) + assert.Equal(t, tt.want.Prerelease, opts.Prerelease) + }) + } +} + +func Test_editRun(t *testing.T) { + tests := []struct { + name string + isTTY bool + opts EditOptions + httpStubs func(t *testing.T, reg *httpmock.Registry) + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "edit the tag name", + isTTY: true, + opts: EditOptions{ + TagName: "v1.2.4", + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockSuccessfulEditResponse(reg, func(params map[string]interface{}) { + assert.Equal(t, map[string]interface{}{ + "tag_name": "v1.2.4", + }, params) + }) + }, + wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", + wantStderr: "", + }, + { + name: "edit the target", + isTTY: true, + opts: EditOptions{ + Target: "c0ff33", + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockSuccessfulEditResponse(reg, func(params map[string]interface{}) { + assert.Equal(t, map[string]interface{}{ + "tag_name": "v1.2.3", + "target_commitish": "c0ff33", + }, params) + }) + }, + wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", + wantStderr: "", + }, + { + name: "edit the release name", + isTTY: true, + opts: EditOptions{ + Name: stringPtr("Hot Release #1"), + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockSuccessfulEditResponse(reg, func(params map[string]interface{}) { + assert.Equal(t, map[string]interface{}{ + "tag_name": "v1.2.3", + "name": "Hot Release #1", + }, params) + }) + }, + wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", + wantStderr: "", + }, + { + name: "edit the discussion category", + isTTY: true, + opts: EditOptions{ + DiscussionCategory: stringPtr("some-category"), + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockSuccessfulEditResponse(reg, func(params map[string]interface{}) { + assert.Equal(t, map[string]interface{}{ + "tag_name": "v1.2.3", + "discussion_category_name": "some-category", + }, params) + }) + }, + wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", + wantStderr: "", + }, + { + name: "edit the release name (empty)", + isTTY: true, + opts: EditOptions{ + Name: stringPtr(""), + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockSuccessfulEditResponse(reg, func(params map[string]interface{}) { + assert.Equal(t, map[string]interface{}{ + "tag_name": "v1.2.3", + "name": "", + }, params) + }) + }, + wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", + wantStderr: "", + }, + { + name: "edit the release notes", + isTTY: true, + opts: EditOptions{ + Body: stringPtr("Release Notes:\n- Fix Bug #1\n- Fix Bug #2"), + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockSuccessfulEditResponse(reg, func(params map[string]interface{}) { + assert.Equal(t, map[string]interface{}{ + "tag_name": "v1.2.3", + "body": "Release Notes:\n- Fix Bug #1\n- Fix Bug #2", + }, params) + }) + }, + wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", + wantStderr: "", + }, + { + name: "edit the release notes (empty)", + isTTY: true, + opts: EditOptions{ + Body: stringPtr(""), + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockSuccessfulEditResponse(reg, func(params map[string]interface{}) { + assert.Equal(t, map[string]interface{}{ + "tag_name": "v1.2.3", + "body": "", + }, params) + }) + }, + wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", + wantStderr: "", + }, + { + name: "edit draft (true)", + isTTY: true, + opts: EditOptions{ + Draft: boolPtr(true), + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockSuccessfulEditResponse(reg, func(params map[string]interface{}) { + assert.Equal(t, map[string]interface{}{ + "tag_name": "v1.2.3", + "draft": true, + }, params) + }) + }, + wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", + wantStderr: "", + }, + { + name: "edit draft (false)", + isTTY: true, + opts: EditOptions{ + Draft: boolPtr(false), + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockSuccessfulEditResponse(reg, func(params map[string]interface{}) { + assert.Equal(t, map[string]interface{}{ + "tag_name": "v1.2.3", + "draft": false, + }, params) + }) + }, + wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", + wantStderr: "", + }, + { + name: "edit prerelease (true)", + isTTY: true, + opts: EditOptions{ + Prerelease: boolPtr(true), + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockSuccessfulEditResponse(reg, func(params map[string]interface{}) { + assert.Equal(t, map[string]interface{}{ + "tag_name": "v1.2.3", + "prerelease": true, + }, params) + }) + }, + wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", + wantStderr: "", + }, + { + name: "edit prerelease (false)", + isTTY: true, + opts: EditOptions{ + Prerelease: boolPtr(false), + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockSuccessfulEditResponse(reg, func(params map[string]interface{}) { + assert.Equal(t, map[string]interface{}{ + "tag_name": "v1.2.3", + "prerelease": false, + }, params) + }) + }, + wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", + wantStderr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(tt.isTTY) + io.SetStdinTTY(tt.isTTY) + io.SetStderrTTY(tt.isTTY) + + fakeHTTP := &httpmock.Registry{} + fakeHTTP.Register(httpmock.REST("GET", "repos/OWNER/REPO/releases/tags/v1.2.3"), httpmock.JSONResponse(map[string]interface{}{ + "id": 12345, + "tag_name": "v1.2.3", + })) + if tt.httpStubs != nil { + tt.httpStubs(t, fakeHTTP) + } + defer fakeHTTP.Verify(t) + + tt.opts.IO = io + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: fakeHTTP}, nil + } + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + } + + err := editRun("v1.2.3", &tt.opts) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} + +func mockSuccessfulEditResponse(reg *httpmock.Registry, cb func(params map[string]interface{})) { + matcher := httpmock.REST("PATCH", "repos/OWNER/REPO/releases/12345") + responder := httpmock.RESTPayload(201, `{ + "html_url": "https://github.com/OWNER/REPO/releases/tag/v1.2.3" + }`, cb) + reg.Register(matcher, responder) +} + +func boolPtr(b bool) *bool { + return &b +} + +func stringPtr(s string) *string { + return &s +} diff --git a/pkg/cmd/release/edit/http.go b/pkg/cmd/release/edit/http.go new file mode 100644 index 000000000..d7a721080 --- /dev/null +++ b/pkg/cmd/release/edit/http.go @@ -0,0 +1,49 @@ +package edit + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/release/shared" + "io/ioutil" + "net/http" +) + +func editRelease(httpClient *http.Client, repo ghrepo.Interface, releaseID int64, params map[string]interface{}) (*shared.Release, error) { + bodyBytes, err := json.Marshal(params) + if err != nil { + return nil, err + } + + path := fmt.Sprintf("repos/%s/%s/releases/%d", repo.RepoOwner(), repo.RepoName(), releaseID) + url := ghinstance.RESTPrefix(repo.RepoHost()) + path + req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(bodyBytes)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + success := resp.StatusCode >= 200 && resp.StatusCode < 300 + if !success { + return nil, api.HandleHTTPError(resp) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var newRelease shared.Release + err = json.Unmarshal(b, &newRelease) + return &newRelease, err +} diff --git a/pkg/cmd/release/release.go b/pkg/cmd/release/release.go index c5a132cd2..861cc2d7b 100644 --- a/pkg/cmd/release/release.go +++ b/pkg/cmd/release/release.go @@ -5,6 +5,7 @@ import ( cmdDelete "github.com/cli/cli/v2/pkg/cmd/release/delete" cmdDeleteAsset "github.com/cli/cli/v2/pkg/cmd/release/delete-asset" cmdDownload "github.com/cli/cli/v2/pkg/cmd/release/download" + cmdUpdate "github.com/cli/cli/v2/pkg/cmd/release/edit" cmdList "github.com/cli/cli/v2/pkg/cmd/release/list" cmdUpload "github.com/cli/cli/v2/pkg/cmd/release/upload" cmdView "github.com/cli/cli/v2/pkg/cmd/release/view" @@ -28,6 +29,7 @@ func NewCmdRelease(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(cmdDeleteAsset.NewCmdDeleteAsset(f, nil)) cmd.AddCommand(cmdDownload.NewCmdDownload(f, nil)) cmd.AddCommand(cmdList.NewCmdList(f, nil)) + cmd.AddCommand(cmdUpdate.NewCmdEdit(f, nil)) cmd.AddCommand(cmdView.NewCmdView(f, nil)) cmd.AddCommand(cmdUpload.NewCmdUpload(f, nil)) diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index 06bc83f7a..bac7bf2b7 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -35,6 +35,7 @@ var ReleaseFields = []string{ } type Release struct { + DatabaseID int64 `json:"id"` ID string `json:"node_id"` TagName string `json:"tag_name"` Name string `json:"name"`