diff --git a/pkg/cmd/label/create.go b/pkg/cmd/label/create.go index ecad7dc44..4dfcfddfb 100644 --- a/pkg/cmd/label/create.go +++ b/pkg/cmd/label/create.go @@ -60,12 +60,13 @@ func newCmdCreate(f *cmdutil.Factory, runF func(*createOptions) error) *cobra.Co Long: heredoc.Doc(` Create a new label on GitHub. - Must specify name for the label, the description and color are optional. + Must specify name for the label. The description and color are optional. If a color isn't provided, a random one will be chosen. - Color needs to be 6 character hex value. + The label color needs to be 6 character hex value. `), Example: heredoc.Doc(` + # create new bug label $ gh label create bug --description "Something isn't working" --color E99695 `), Args: cmdutil.ExactArgs(1, "cannot create label: name argument required"), @@ -81,8 +82,8 @@ func newCmdCreate(f *cmdutil.Factory, runF func(*createOptions) error) *cobra.Co } cmd.Flags().StringVarP(&opts.Description, "description", "d", "", "Description of the label") - cmd.Flags().StringVarP(&opts.Color, "color", "c", "", "Color of the label, if not specified one will be selected at random") - cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Set the label color and description even if the name already exists.") + cmd.Flags().StringVarP(&opts.Color, "color", "c", "", "Color of the label") + cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Update the label color and description if label already exists") return cmd } @@ -144,24 +145,42 @@ func createLabel(client *http.Client, repo ghrepo.Interface, opts *createOptions } if opts.Force && errors.Is(err, errLabelAlreadyExists) { - return updateLabel(apiClient, repo, opts) + editOpts := editOptions{ + Description: opts.Description, + Color: opts.Color, + Name: opts.Name, + } + return updateLabel(apiClient, repo, &editOpts) } return err } -func updateLabel(apiClient *api.Client, repo ghrepo.Interface, opts *createOptions) error { +func updateLabel(apiClient *api.Client, repo ghrepo.Interface, opts *editOptions) error { path := fmt.Sprintf("repos/%s/%s/labels/%s", repo.RepoOwner(), repo.RepoName(), opts.Name) - requestByte, err := json.Marshal(map[string]string{ - "description": opts.Description, - "color": opts.Color, - }) + properties := map[string]string{} + if opts.Description != "" { + properties["description"] = opts.Description + } + if opts.Color != "" { + properties["color"] = opts.Color + } + if opts.NewName != "" { + properties["new_name"] = opts.NewName + } + requestByte, err := json.Marshal(properties) if err != nil { return err } requestBody := bytes.NewReader(requestByte) result := label{} - return apiClient.REST(repo.RepoHost(), "PATCH", path, requestBody, &result) + err = apiClient.REST(repo.RepoHost(), "PATCH", path, requestBody, &result) + + if httpError, ok := err.(api.HTTPError); ok && isLabelAlreadyExistsError(httpError) { + err = errLabelAlreadyExists + } + + return err } func isLabelAlreadyExistsError(err api.HTTPError) bool { diff --git a/pkg/cmd/label/edit.go b/pkg/cmd/label/edit.go new file mode 100644 index 000000000..f381b754d --- /dev/null +++ b/pkg/cmd/label/edit.go @@ -0,0 +1,104 @@ +package label + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type editOptions struct { + BaseRepo func() (ghrepo.Interface, error) + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + + Color string + Description string + Name string + NewName string +} + +func newCmdEdit(f *cmdutil.Factory, runF func(*editOptions) error) *cobra.Command { + opts := editOptions{ + HttpClient: f.HttpClient, + IO: f.IOStreams, + } + + cmd := &cobra.Command{ + Use: "edit ", + Short: "Edit a label", + Long: heredoc.Docf(` + Update a label on GitHub. + + A label can be renamed using the %[1]s--name%[1]s flag. + + The label color needs to be 6 character hex value. + `, "`"), + Example: heredoc.Doc(` + # update the color of the bug label + $ gh label edit bug --color FF0000 + + # rename and edit the description of the bug label + $ gh label edit bug --name big-bug --description "Bigger than normal bug" + `), + Args: cmdutil.ExactArgs(1, "cannot update label: name argument required"), + RunE: func(c *cobra.Command, args []string) error { + opts.BaseRepo = f.BaseRepo + opts.Name = args[0] + opts.Color = strings.TrimPrefix(opts.Color, "#") + if opts.Description == "" && + opts.Color == "" && + opts.NewName == "" { + return cmdutil.FlagErrorf("specify at least one of `--color`, `--description`, or `--name`") + } + if runF != nil { + return runF(&opts) + } + return editRun(&opts) + }, + } + + cmd.Flags().StringVarP(&opts.Description, "description", "d", "", "Description of the label") + cmd.Flags().StringVarP(&opts.Color, "color", "c", "", "Color of the label") + cmd.Flags().StringVarP(&opts.NewName, "name", "n", "", "Name of the label") + + return cmd +} + +func editRun(opts *editOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + opts.IO.StartProgressIndicator() + err = updateLabel(apiClient, baseRepo, opts) + opts.IO.StopProgressIndicator() + if err != nil { + if errors.Is(err, errLabelAlreadyExists) { + return fmt.Errorf("label with name %q already exists", opts.NewName) + } + return err + } + + if opts.IO.IsStdoutTTY() { + cs := opts.IO.ColorScheme() + successMsg := fmt.Sprintf("%s Label %q updated in %s\n", cs.SuccessIcon(), opts.Name, ghrepo.FullName(baseRepo)) + fmt.Fprint(opts.IO.Out, successMsg) + } + + return nil +} diff --git a/pkg/cmd/label/edit_test.go b/pkg/cmd/label/edit_test.go new file mode 100644 index 000000000..d5cb6104b --- /dev/null +++ b/pkg/cmd/label/edit_test.go @@ -0,0 +1,170 @@ +package label + +import ( + "bytes" + "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" +) + +func TestNewCmdEdit(t *testing.T) { + tests := []struct { + name string + input string + output editOptions + wantErr bool + errMsg string + }{ + { + name: "no argument", + input: "", + wantErr: true, + errMsg: "cannot update label: name argument required", + }, + { + name: "name argument", + input: "test", + wantErr: true, + errMsg: "specify at least one of `--color`, `--description`, or `--name`", + }, + { + name: "description flag", + input: "test --description 'some description'", + output: editOptions{Name: "test", Description: "some description"}, + }, + { + name: "color flag", + input: "test --color FFFFFF", + output: editOptions{Name: "test", Color: "FFFFFF"}, + }, + { + name: "color flag with pound sign", + input: "test --color '#AAAAAA'", + output: editOptions{Name: "test", Color: "AAAAAA"}, + }, + { + name: "name flag", + input: "test --name test1", + output: editOptions{Name: "test", NewName: "test1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: io, + } + argv, err := shlex.Split(tt.input) + assert.NoError(t, err) + var gotOpts *editOptions + cmd := newCmdEdit(f, func(opts *editOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + if tt.wantErr { + assert.EqualError(t, err, tt.errMsg) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.output.Color, gotOpts.Color) + assert.Equal(t, tt.output.Description, gotOpts.Description) + assert.Equal(t, tt.output.Name, gotOpts.Name) + assert.Equal(t, tt.output.NewName, gotOpts.NewName) + }) + } +} + +func TestEditRun(t *testing.T) { + tests := []struct { + name string + tty bool + opts *editOptions + httpStubs func(*httpmock.Registry) + wantStdout string + wantErrMsg string + }{ + { + name: "updates label", + tty: true, + opts: &editOptions{Name: "test", Description: "some description"}, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("PATCH", "repos/OWNER/REPO/labels/test"), + httpmock.StatusStringResponse(201, "{}"), + ) + }, + wantStdout: "✓ Label \"test\" updated in OWNER/REPO\n", + }, + { + name: "updates label notty", + tty: false, + opts: &editOptions{Name: "test", Description: "some description"}, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("PATCH", "repos/OWNER/REPO/labels/test"), + httpmock.StatusStringResponse(201, "{}"), + ) + }, + wantStdout: "", + }, + { + name: "updates missing label", + opts: &editOptions{Name: "invalid", Description: "some description"}, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("PATCH", "repos/OWNER/REPO/labels/invalid"), + httpmock.WithHeader( + httpmock.StatusStringResponse(404, `{"message":"Not Found"}`), + "Content-Type", + "application/json", + ), + ) + }, + wantErrMsg: "HTTP 404: Not Found (https://api.github.com/repos/OWNER/REPO/labels/invalid)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + io, _, stdout, _ := iostreams.Test() + io.SetStdoutTTY(tt.tty) + io.SetStdinTTY(tt.tty) + io.SetStderrTTY(tt.tty) + tt.opts.IO = io + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + } + defer reg.Verify(t) + err := editRun(tt.opts) + + if tt.wantErrMsg != "" { + assert.EqualError(t, err, tt.wantErrMsg) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, tt.wantStdout, stdout.String()) + }) + } +} diff --git a/pkg/cmd/label/label.go b/pkg/cmd/label/label.go index a4bfee76d..d69fd32a1 100644 --- a/pkg/cmd/label/label.go +++ b/pkg/cmd/label/label.go @@ -16,6 +16,7 @@ func NewCmdLabel(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(newCmdList(f, nil)) cmd.AddCommand(newCmdCreate(f, nil)) cmd.AddCommand(newCmdClone(f, nil)) + cmd.AddCommand(newCmdEdit(f, nil)) return cmd }