diff --git a/pkg/cmd/repo/edit/edit.go b/pkg/cmd/repo/edit/edit.go new file mode 100644 index 000000000..97f76b3cf --- /dev/null +++ b/pkg/cmd/repo/edit/edit.go @@ -0,0 +1,118 @@ +package edit + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "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/spf13/cobra" +) + +type EditOptions struct { + HTTPClient *http.Client + Repository ghrepo.Interface + Edits EditRepositoryInput +} + +type EditRepositoryInput struct { + Description *string `json:"description,omitempty"` + Homepage *string `json:"homepage,omitempty"` + Visibility *string `json:"visibility,omitempty"` + EnableIssues *bool `json:"has_issues,omitempty"` + EnableProjects *bool `json:"has_projects,omitempty"` + EnableWiki *bool `json:"has_wiki,omitempty"` + IsTemplate *bool `json:"is_template,omitempty"` + DefaultBranch *string `json:"default_branch,omitempty"` + EnableSquashMerge *bool `json:"allow_squash_merge,omitempty"` + EnableMergeCommit *bool `json:"allow_merge_commit,omitempty"` + EnableRebaseMerge *bool `json:"allow_rebase_merge,omitempty"` + EnableAutoMerge *bool `json:"allow_auto_merge,omitempty"` + DeleteBranchOnMerge *bool `json:"delete_branch_on_merge,omitempty"` + AllowForking *bool `json:"allow_forking,omitempty"` +} + +func NewCmdEdit(f *cmdutil.Factory, runF func(options *EditOptions) error) *cobra.Command { + opts := &EditOptions{} + + cmd := &cobra.Command{ + Use: "edit []", + Short: "Edit repository settings", + Annotations: map[string]string{ + "help:arguments": heredoc.Doc(` + A repository can be supplied as an argument in any of the following formats: + - "OWNER/REPO" + - by URL, e.g. "https://github.com/OWNER/REPO" + `), + }, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if cmd.Flags().NFlag() == 0 { + return cmdutil.FlagErrorf("at least one flag is required") + } + + if len(args) > 0 { + var err error + opts.Repository, err = ghrepo.FromFullName(args[0]) + if err != nil { + return err + } + } else { + var err error + opts.Repository, err = f.BaseRepo() + if err != nil { + return err + } + } + + if httpClient, err := f.HttpClient(); err == nil { + opts.HTTPClient = httpClient + } else { + return err + } + + if runF != nil { + return runF(opts) + } + return editRun(opts) + }, + } + + cmdutil.NilStringFlag(cmd, &opts.Edits.Description, "description", "d", "Description of the repository") + cmdutil.NilStringFlag(cmd, &opts.Edits.Homepage, "homepage", "h", "Repository home page `URL`") + cmdutil.NilStringFlag(cmd, &opts.Edits.DefaultBranch, "default-branch", "", "Set the default branch `name` for the repository") + cmdutil.NilStringFlag(cmd, &opts.Edits.Visibility, "visibility", "", "Change the visibility of the repository to {public,private,internal}") + cmdutil.NilBoolFlag(cmd, &opts.Edits.IsTemplate, "template", "", "Make the repository available as a template repository") + cmdutil.NilBoolFlag(cmd, &opts.Edits.EnableIssues, "enable-issues", "", "Enable issues in the repository") + cmdutil.NilBoolFlag(cmd, &opts.Edits.EnableProjects, "enable-projects", "", "Enable projects in the repository") + cmdutil.NilBoolFlag(cmd, &opts.Edits.EnableWiki, "enable-wiki", "", "Enable wiki in the repository") + cmdutil.NilBoolFlag(cmd, &opts.Edits.EnableMergeCommit, "enable-merge-commit", "", "Enable merging pull requests via merge commit") + cmdutil.NilBoolFlag(cmd, &opts.Edits.EnableSquashMerge, "enable-squash-merge", "", "Enable merging pull requests via squashed commit") + cmdutil.NilBoolFlag(cmd, &opts.Edits.EnableRebaseMerge, "enable-rebase-merge", "", "Enable merging pull requests via rebase") + cmdutil.NilBoolFlag(cmd, &opts.Edits.EnableAutoMerge, "enable-auto-merge", "", "Enable auto-merge functionality") + cmdutil.NilBoolFlag(cmd, &opts.Edits.DeleteBranchOnMerge, "delete-branch-on-merge", "", "Delete head branch when pull requests are merged") + cmdutil.NilBoolFlag(cmd, &opts.Edits.AllowForking, "allow-forking", "", "Allow forking of an organization repository") + + return cmd +} + +func editRun(opts *EditOptions) error { + repo := opts.Repository + input := opts.Edits + + apiPath := fmt.Sprintf("repos/%s/%s", repo.RepoOwner(), repo.RepoName()) + + body := &bytes.Buffer{} + enc := json.NewEncoder(body) + if err := enc.Encode(input); err != nil { + return err + } + + apiClient := api.NewClientFromHTTP(opts.HTTPClient) + _, err := api.CreateRepoTransformToV4(apiClient, repo.RepoHost(), "PATCH", apiPath, body) + return err +} diff --git a/pkg/cmd/repo/edit/edit_test.go b/pkg/cmd/repo/edit/edit_test.go new file mode 100644 index 000000000..e5f2032c4 --- /dev/null +++ b/pkg/cmd/repo/edit/edit_test.go @@ -0,0 +1,140 @@ +package edit + +import ( + "bytes" + "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 TestNewCmdEdit(t *testing.T) { + tests := []struct { + name string + args string + wantOpts EditOptions + wantErr string + }{ + { + name: "no argument", + args: "", + wantErr: "at least one flag is required", + }, + { + name: "change repo description", + args: "--description hello", + wantOpts: EditOptions{ + Repository: ghrepo.NewWithHost("OWNER", "REPO", "github.com"), + Edits: EditRepositoryInput{ + Description: sp("hello"), + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + io.SetStderrTTY(true) + + f := &cmdutil.Factory{ + IOStreams: io, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + HttpClient: func() (*http.Client, error) { + return nil, nil + }, + } + + argv, err := shlex.Split(tt.args) + assert.NoError(t, err) + + var gotOpts *EditOptions + cmd := NewCmdEdit(f, func(opts *EditOptions) error { + gotOpts = opts + return nil + }) + cmd.Flags().BoolP("help", "x", false, "") + + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + return + } + require.NoError(t, err) + + assert.Equal(t, ghrepo.FullName(tt.wantOpts.Repository), ghrepo.FullName(gotOpts.Repository)) + assert.Equal(t, tt.wantOpts.Edits, gotOpts.Edits) + }) + } +} + +func Test_editRun(t *testing.T) { + tests := []struct { + name string + opts EditOptions + httpStubs func(*testing.T, *httpmock.Registry) + wantsStderr string + wantsErr string + }{ + { + name: "change name and description", + opts: EditOptions{ + Repository: ghrepo.NewWithHost("OWNER", "REPO", "github.com"), + Edits: EditRepositoryInput{ + Homepage: sp("newURL"), + Description: sp("hello world!"), + }, + }, + httpStubs: func(t *testing.T, r *httpmock.Registry) { + r.Register( + httpmock.REST("PATCH", "repos/OWNER/REPO"), + httpmock.RESTPayload(200, `{}`, func(payload map[string]interface{}) { + assert.Equal(t, 2, len(payload)) + assert.Equal(t, "newURL", payload["homepage"]) + assert.Equal(t, "hello world!", payload["description"]) + })) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + if tt.httpStubs != nil { + tt.httpStubs(t, httpReg) + } + + opts := &tt.opts + opts.HTTPClient = &http.Client{Transport: httpReg} + + err := editRun(opts) + if tt.wantsErr == "" { + require.NoError(t, err) + } else { + assert.EqualError(t, err, tt.wantsErr) + return + } + }) + } +} + +func sp(v string) *string { + return &v +} diff --git a/pkg/cmd/repo/repo.go b/pkg/cmd/repo/repo.go index ec16d731b..bc0909cd2 100644 --- a/pkg/cmd/repo/repo.go +++ b/pkg/cmd/repo/repo.go @@ -7,6 +7,7 @@ import ( repoCreateCmd "github.com/cli/cli/v2/pkg/cmd/repo/create" creditsCmd "github.com/cli/cli/v2/pkg/cmd/repo/credits" repoDeleteCmd "github.com/cli/cli/v2/pkg/cmd/repo/delete" + repoEditCmd "github.com/cli/cli/v2/pkg/cmd/repo/edit" repoForkCmd "github.com/cli/cli/v2/pkg/cmd/repo/fork" gardenCmd "github.com/cli/cli/v2/pkg/cmd/repo/garden" repoListCmd "github.com/cli/cli/v2/pkg/cmd/repo/list" @@ -41,6 +42,7 @@ func NewCmdRepo(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(repoForkCmd.NewCmdFork(f, nil)) cmd.AddCommand(repoCloneCmd.NewCmdClone(f, nil)) cmd.AddCommand(repoCreateCmd.NewCmdCreate(f, nil)) + cmd.AddCommand(repoEditCmd.NewCmdEdit(f, nil)) cmd.AddCommand(repoListCmd.NewCmdList(f, nil)) cmd.AddCommand(repoSyncCmd.NewCmdSync(f, nil)) cmd.AddCommand(creditsCmd.NewCmdRepoCredits(f, nil)) diff --git a/pkg/cmdutil/flags.go b/pkg/cmdutil/flags.go new file mode 100644 index 000000000..7472fa42c --- /dev/null +++ b/pkg/cmdutil/flags.go @@ -0,0 +1,77 @@ +package cmdutil + +import ( + "strconv" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// NilStringFlag defines a new flag with a string pointer receiver. This is useful for differentiating +// between the flag being set to a blank value and the flag not being passed at all. +func NilStringFlag(cmd *cobra.Command, p **string, name string, shorthand string, usage string) *pflag.Flag { + return cmd.Flags().VarPF(newStringValue(p), name, shorthand, usage) +} + +// NilBoolFlag defines a new flag with a bool pointer receiver. This is useful for differentiating +// between the flag being explicitly set to a false value and the flag not being passed at all. +func NilBoolFlag(cmd *cobra.Command, p **bool, name string, shorthand string, usage string) *pflag.Flag { + f := cmd.Flags().VarPF(newBoolValue(p), name, shorthand, usage) + f.NoOptDefVal = "true" + return f +} + +type stringValue struct { + string **string +} + +func newStringValue(p **string) *stringValue { + return &stringValue{p} +} + +func (s *stringValue) Set(value string) error { + *s.string = &value + return nil +} + +func (s *stringValue) String() string { + if s.string == nil || *s.string == nil { + return "" + } + return **s.string +} + +func (s *stringValue) Type() string { + return "string" +} + +type boolValue struct { + bool **bool +} + +func newBoolValue(p **bool) *boolValue { + return &boolValue{p} +} + +func (b *boolValue) Set(value string) error { + v, err := strconv.ParseBool(value) + *b.bool = &v + return err +} + +func (b *boolValue) String() string { + if b.bool == nil || *b.bool == nil { + return "false" + } else if **b.bool { + return "true" + } + return "false" +} + +func (b *boolValue) Type() string { + return "bool" +} + +func (b *boolValue) IsBoolFlag() bool { + return true +}