Merge pull request #4450 from cli/repo-rename

gh repo rename
This commit is contained in:
Sam 2021-11-04 10:52:22 -07:00 committed by GitHub
commit 67d1a9012c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 500 additions and 0 deletions

View file

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

View file

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

View file

@ -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 [<new-name>]",
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
}

View file

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

View file

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