diff --git a/git/remote.go b/git/remote.go index f9dfbc4bd..91bbb6770 100644 --- a/git/remote.go +++ b/git/remote.go @@ -140,6 +140,14 @@ func AddRemote(name, u string) (*Remote, error) { }, nil } +func UpdateRemoteURL(name, u string) error { + addCmd, err := GitCommand("remote", "set-url", name, u) + if err != nil { + return err + } + return run.PrepareCmd(addCmd).Run() +} + func SetRemoteResolution(name, resolution string) error { addCmd, err := GitCommand("config", "--add", fmt.Sprintf("remote.%s.gh-resolved", name), resolution) if err != nil { diff --git a/pkg/cmd/repo/rename/http.go b/pkg/cmd/repo/rename/http.go new file mode 100644 index 000000000..1e5d73477 --- /dev/null +++ b/pkg/cmd/repo/rename/http.go @@ -0,0 +1,61 @@ +package rename + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/ghrepo" +) + +func apiRename(client *http.Client, repo ghrepo.Interface, newRepoName string) (ghrepo.Interface, error) { + input := map[string]string{"name": newRepoName} + body, err := json.Marshal(input) + if err != nil { + return nil, err + } + + path := fmt.Sprintf("%srepos/%s", + ghinstance.RESTPrefix(repo.RepoHost()), + ghrepo.FullName(repo)) + + request, err := http.NewRequest("PATCH", path, bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + + request.Header.Set("Content-Type", "application/json; charset=utf-8") + + resp, err := client.Do(request) + if err != nil { + return nil, err + } + + if resp.StatusCode > 299 { + return nil, api.HandleHTTPError(resp) + } + + defer resp.Body.Close() + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + result := struct { + Name string + Owner struct { + Login string + } + }{} + if err := json.Unmarshal(b, &result); err != nil { + return nil, fmt.Errorf("error unmarshaling response: %w", err) + } + + newRepo := ghrepo.NewWithHost(result.Owner.Login, result.Name, repo.RepoHost()) + + return newRepo, nil +} diff --git a/pkg/cmd/repo/rename/rename.go b/pkg/cmd/repo/rename/rename.go new file mode 100644 index 000000000..d8b0e8123 --- /dev/null +++ b/pkg/cmd/repo/rename/rename.go @@ -0,0 +1,165 @@ +package rename + +import ( + "fmt" + "net/http" + + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/prompt" + "github.com/spf13/cobra" +) + +type RenameOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + Config func() (config.Config, error) + BaseRepo func() (ghrepo.Interface, error) + Remotes func() (context.Remotes, error) + DoConfirm bool + HasRepoOverride bool + newRepoSelector string +} + +func NewCmdRename(f *cmdutil.Factory, runf func(*RenameOptions) error) *cobra.Command { + opts := &RenameOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Remotes: f.Remotes, + Config: f.Config, + } + + var confirm bool + + cmd := &cobra.Command{ + Use: "rename []", + Short: "Rename a repository", + Long: heredoc.Doc(`Rename a GitHub repository + + By default, this renames the current repository; otherwise renames the specified repository.`), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.BaseRepo = f.BaseRepo + opts.HasRepoOverride = cmd.Flags().Changed("repo") + + if len(args) > 0 { + opts.newRepoSelector = args[0] + } else if !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("new name argument required when not running interactively") + } + + if len(args) == 1 && !confirm && !opts.HasRepoOverride { + if !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("--confirm required when passing a single argument") + } + opts.DoConfirm = true + } + + if runf != nil { + return runf(opts) + } + return renameRun(opts) + }, + } + + cmdutil.EnableRepoOverride(cmd, f) + cmd.Flags().BoolVarP(&confirm, "confirm", "y", false, "skip confirmation prompt") + + return cmd +} + +func renameRun(opts *RenameOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + newRepoName := opts.newRepoSelector + + currRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + if newRepoName == "" { + err = prompt.SurveyAskOne( + &survey.Input{ + Message: fmt.Sprintf("Rename %s to: ", ghrepo.FullName(currRepo)), + }, + &newRepoName, + ) + if err != nil { + return err + } + } + + if opts.DoConfirm { + var confirmed bool + p := &survey.Confirm{ + Message: fmt.Sprintf("Rename %s to %s?", ghrepo.FullName(currRepo), newRepoName), + Default: false, + } + err = prompt.SurveyAskOne(p, &confirmed) + if err != nil { + return fmt.Errorf("failed to prompt: %w", err) + } + if !confirmed { + return nil + } + } + + newRepo, err := apiRename(httpClient, currRepo, newRepoName) + if err != nil { + return err + } + + cs := opts.IO.ColorScheme() + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "%s Renamed repository %s\n", cs.SuccessIcon(), ghrepo.FullName(newRepo)) + } + + if opts.HasRepoOverride { + return nil + } + + remote, err := updateRemote(currRepo, newRepo, opts) + if err != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s Warning: unable to update remote %q: %v\n", cs.WarningIcon(), remote.Name, err) + } else if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "%s Updated the %q remote\n", cs.SuccessIcon(), remote.Name) + } + + return nil +} + +func updateRemote(repo ghrepo.Interface, renamed ghrepo.Interface, opts *RenameOptions) (*context.Remote, error) { + cfg, err := opts.Config() + if err != nil { + return nil, err + } + + protocol, err := cfg.Get(repo.RepoHost(), "git_protocol") + if err != nil { + return nil, err + } + + remotes, err := opts.Remotes() + if err != nil { + return nil, err + } + + remote, err := remotes.FindByRepo(repo.RepoOwner(), repo.RepoName()) + if err != nil { + return nil, err + } + + remoteURL := ghrepo.FormatRemoteURL(renamed, protocol) + err = git.UpdateRemoteURL(remote.Name, remoteURL) + return remote, err +} diff --git a/pkg/cmd/repo/rename/rename_test.go b/pkg/cmd/repo/rename/rename_test.go new file mode 100644 index 000000000..042579613 --- /dev/null +++ b/pkg/cmd/repo/rename/rename_test.go @@ -0,0 +1,264 @@ +package rename + +import ( + "bytes" + "net/http" + "testing" + + "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/run" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/prompt" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdRename(t *testing.T) { + testCases := []struct { + name string + input string + output RenameOptions + errMsg string + tty bool + wantErr bool + }{ + { + name: "no arguments no tty", + input: "", + errMsg: "new name argument required when not running interactively", + wantErr: true, + }, + { + name: "one argument no tty confirmed", + input: "REPO --confirm", + output: RenameOptions{ + newRepoSelector: "REPO", + }, + }, + { + name: "one argument no tty", + input: "REPO", + errMsg: "--confirm required when passing a single argument", + wantErr: true, + }, + { + name: "one argument tty confirmed", + input: "REPO --confirm", + tty: true, + output: RenameOptions{ + newRepoSelector: "REPO", + }, + }, + { + name: "one argument tty", + input: "REPO", + tty: true, + output: RenameOptions{ + newRepoSelector: "REPO", + DoConfirm: true, + }, + }, + { + name: "full flag argument", + input: "--repo OWNER/REPO NEW_REPO", + output: RenameOptions{ + newRepoSelector: "NEW_REPO", + }, + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdinTTY(tt.tty) + io.SetStdoutTTY(tt.tty) + f := &cmdutil.Factory{ + IOStreams: io, + } + + argv, err := shlex.Split(tt.input) + assert.NoError(t, err) + var gotOpts *RenameOptions + cmd := NewCmdRename(f, func(opts *RenameOptions) 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.newRepoSelector, gotOpts.newRepoSelector) + }) + } +} + +func TestRenameRun(t *testing.T) { + testCases := []struct { + name string + opts RenameOptions + httpStubs func(*httpmock.Registry) + execStubs func(*run.CommandStubber) + askStubs func(*prompt.AskStubber) + wantOut string + tty bool + }{ + { + name: "none argument", + wantOut: "✓ Renamed repository OWNER/NEW_REPO\n✓ Updated the \"origin\" remote\n", + askStubs: func(q *prompt.AskStubber) { + q.StubOne("NEW_REPO") + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("PATCH", "repos/OWNER/REPO"), + httpmock.StatusStringResponse(204, `{"name":"NEW_REPO","owner":{"login":"OWNER"}}`)) + }, + execStubs: func(cs *run.CommandStubber) { + cs.Register(`git remote set-url origin https://github.com/OWNER/NEW_REPO.git`, 0, "") + }, + tty: true, + }, + { + name: "repo override", + opts: RenameOptions{ + HasRepoOverride: true, + }, + wantOut: "✓ Renamed repository OWNER/NEW_REPO\n", + askStubs: func(q *prompt.AskStubber) { + q.StubOne("NEW_REPO") + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("PATCH", "repos/OWNER/REPO"), + httpmock.StatusStringResponse(204, `{"name":"NEW_REPO","owner":{"login":"OWNER"}}`)) + }, + tty: true, + }, + { + name: "owner repo change name argument tty", + opts: RenameOptions{ + newRepoSelector: "NEW_REPO", + }, + wantOut: "✓ Renamed repository OWNER/NEW_REPO\n✓ Updated the \"origin\" remote\n", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("PATCH", "repos/OWNER/REPO"), + httpmock.StatusStringResponse(204, `{"name":"NEW_REPO","owner":{"login":"OWNER"}}`)) + }, + execStubs: func(cs *run.CommandStubber) { + cs.Register(`git remote set-url origin https://github.com/OWNER/NEW_REPO.git`, 0, "") + }, + tty: true, + }, + { + name: "owner repo change name argument no tty", + opts: RenameOptions{ + newRepoSelector: "NEW_REPO", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("PATCH", "repos/OWNER/REPO"), + httpmock.StatusStringResponse(204, `{"name":"NEW_REPO","owner":{"login":"OWNER"}}`)) + }, + execStubs: func(cs *run.CommandStubber) { + cs.Register(`git remote set-url origin https://github.com/OWNER/NEW_REPO.git`, 0, "") + }, + }, + { + name: "confirmation with yes", + tty: true, + opts: RenameOptions{ + newRepoSelector: "NEW_REPO", + DoConfirm: true, + }, + wantOut: "✓ Renamed repository OWNER/NEW_REPO\n✓ Updated the \"origin\" remote\n", + askStubs: func(q *prompt.AskStubber) { + q.StubOne(true) + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("PATCH", "repos/OWNER/REPO"), + httpmock.StatusStringResponse(204, `{"name":"NEW_REPO","owner":{"login":"OWNER"}}`)) + }, + execStubs: func(cs *run.CommandStubber) { + cs.Register(`git remote set-url origin https://github.com/OWNER/NEW_REPO.git`, 0, "") + }, + }, + + { + name: "confirmation with no", + tty: true, + opts: RenameOptions{ + newRepoSelector: "NEW_REPO", + DoConfirm: true, + }, + askStubs: func(q *prompt.AskStubber) { + q.StubOne(false) + }, + wantOut: "", + }, + } + + for _, tt := range testCases { + q, teardown := prompt.InitAskStubber() + defer teardown() + if tt.askStubs != nil { + tt.askStubs(q) + } + + repo, _ := ghrepo.FromFullName("OWNER/REPO") + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return repo, nil + } + + tt.opts.Config = func() (config.Config, error) { + return config.NewBlankConfig(), nil + } + + tt.opts.Remotes = func() (context.Remotes, error) { + return []*context.Remote{ + { + Remote: &git.Remote{Name: "origin"}, + Repo: repo, + }, + }, nil + } + + cs, restoreRun := run.Stub() + defer restoreRun(t) + if tt.execStubs != nil { + tt.execStubs(cs) + } + + 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.SetStdinTTY(tt.tty) + io.SetStdoutTTY(tt.tty) + tt.opts.IO = io + + t.Run(tt.name, func(t *testing.T) { + defer reg.Verify(t) + err := renameRun(&tt.opts) + assert.NoError(t, err) + assert.Equal(t, tt.wantOut, stdout.String()) + }) + } +} diff --git a/pkg/cmd/repo/repo.go b/pkg/cmd/repo/repo.go index 5b2f467d9..ec16d731b 100644 --- a/pkg/cmd/repo/repo.go +++ b/pkg/cmd/repo/repo.go @@ -10,6 +10,7 @@ import ( 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" + repoRenameCmd "github.com/cli/cli/v2/pkg/cmd/repo/rename" repoSyncCmd "github.com/cli/cli/v2/pkg/cmd/repo/sync" repoViewCmd "github.com/cli/cli/v2/pkg/cmd/repo/view" "github.com/cli/cli/v2/pkg/cmdutil" @@ -44,6 +45,7 @@ func NewCmdRepo(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(repoSyncCmd.NewCmdSync(f, nil)) cmd.AddCommand(creditsCmd.NewCmdRepoCredits(f, nil)) cmd.AddCommand(gardenCmd.NewCmdGarden(f, nil)) + cmd.AddCommand(repoRenameCmd.NewCmdRename(f, nil)) cmd.AddCommand(repoDeleteCmd.NewCmdDelete(f, nil)) cmd.AddCommand(repoArchiveCmd.NewCmdArchive(f, nil))