Add release edit command (#5422)

Co-authored-by: Mislav Marohnić <mislav@github.com>
This commit is contained in:
Johannes Plunien 2022-04-13 16:51:49 +02:00 committed by GitHub
parent 5f4152fbfa
commit 186e5ccfb2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 629 additions and 1 deletions

View file

@ -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(&notesFile, "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

View file

@ -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 <tag>",
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(&notesFile, "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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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))

View file

@ -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"`