From 47a6aff54ab28cfee05bb9124a006b9f64d213bc Mon Sep 17 00:00:00 2001 From: Nilesh Singh Date: Tue, 25 Jan 2022 23:18:24 +0530 Subject: [PATCH] Add repo deploy key commands (#4302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mislav Marohnić --- pkg/cmd/repo/deploy-key/add/add.go | 99 +++++++++++++ pkg/cmd/repo/deploy-key/add/add_test.go | 85 +++++++++++ pkg/cmd/repo/deploy-key/add/http.go | 57 ++++++++ pkg/cmd/repo/deploy-key/delete/delete.go | 67 +++++++++ pkg/cmd/repo/deploy-key/delete/delete_test.go | 40 ++++++ pkg/cmd/repo/deploy-key/delete/http.go | 39 ++++++ pkg/cmd/repo/deploy-key/deploy-key.go | 24 ++++ pkg/cmd/repo/deploy-key/list/http.go | 53 +++++++ pkg/cmd/repo/deploy-key/list/list.go | 106 ++++++++++++++ pkg/cmd/repo/deploy-key/list/list_test.go | 132 ++++++++++++++++++ pkg/cmd/repo/repo.go | 2 + 11 files changed, 704 insertions(+) create mode 100644 pkg/cmd/repo/deploy-key/add/add.go create mode 100644 pkg/cmd/repo/deploy-key/add/add_test.go create mode 100644 pkg/cmd/repo/deploy-key/add/http.go create mode 100644 pkg/cmd/repo/deploy-key/delete/delete.go create mode 100644 pkg/cmd/repo/deploy-key/delete/delete_test.go create mode 100644 pkg/cmd/repo/deploy-key/delete/http.go create mode 100644 pkg/cmd/repo/deploy-key/deploy-key.go create mode 100644 pkg/cmd/repo/deploy-key/list/http.go create mode 100644 pkg/cmd/repo/deploy-key/list/list.go create mode 100644 pkg/cmd/repo/deploy-key/list/list_test.go diff --git a/pkg/cmd/repo/deploy-key/add/add.go b/pkg/cmd/repo/deploy-key/add/add.go new file mode 100644 index 000000000..473d42498 --- /dev/null +++ b/pkg/cmd/repo/deploy-key/add/add.go @@ -0,0 +1,99 @@ +package add + +import ( + "fmt" + "io" + "net/http" + "os" + + "github.com/MakeNowJust/heredoc" + "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 AddOptions struct { + IO *iostreams.IOStreams + HTTPClient func() (*http.Client, error) + BaseRepo func() (ghrepo.Interface, error) + + KeyFile string + Title string + AllowWrite bool +} + +func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command { + opts := &AddOptions{ + HTTPClient: f.HttpClient, + IO: f.IOStreams, + } + + cmd := &cobra.Command{ + Use: "add ", + Short: "Add a deploy key to a GitHub repository", + Long: heredoc.Doc(` + Add a deploy key to a GitHub repository. + + Note that any key added by gh will be associated with the current authentication token. + If you de-authorize the GitHub CLI app or authentication token from your account, any + deploy keys added by GitHub CLI will be removed as well. + `), + Example: heredoc.Doc(` + # generate a passwordless SSH key and add it as a deploy key to a repository + $ ssh-keygen -t ed25519 -C "my description" -N "" -f ~/.ssh/gh-test + $ gh repo deploy-key add ~/.ssh/gh-test.pub + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.BaseRepo = f.BaseRepo + opts.KeyFile = args[0] + + if runF != nil { + return runF(opts) + } + return addRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Title of the new key") + cmd.Flags().BoolVarP(&opts.AllowWrite, "allow-write", "w", false, "Allow write access for the key") + return cmd +} + +func addRun(opts *AddOptions) error { + httpClient, err := opts.HTTPClient() + if err != nil { + return err + } + + var keyReader io.Reader + if opts.KeyFile == "-" { + keyReader = opts.IO.In + defer opts.IO.In.Close() + } else { + f, err := os.Open(opts.KeyFile) + if err != nil { + return err + } + defer f.Close() + keyReader = f + } + + repo, err := opts.BaseRepo() + if err != nil { + return err + } + + if err := uploadDeployKey(httpClient, repo, keyReader, opts.Title, opts.AllowWrite); err != nil { + return err + } + + if !opts.IO.IsStdoutTTY() { + return nil + } + + cs := opts.IO.ColorScheme() + _, err = fmt.Fprintf(opts.IO.Out, "%s Deploy key added to %s\n", cs.SuccessIcon(), cs.Bold(ghrepo.FullName(repo))) + return err +} diff --git a/pkg/cmd/repo/deploy-key/add/add_test.go b/pkg/cmd/repo/deploy-key/add/add_test.go new file mode 100644 index 000000000..5a0dd300a --- /dev/null +++ b/pkg/cmd/repo/deploy-key/add/add_test.go @@ -0,0 +1,85 @@ +package add + +import ( + "net/http" + "testing" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" +) + +func Test_addRun(t *testing.T) { + tests := []struct { + name string + opts AddOptions + isTTY bool + stdin string + httpStubs func(t *testing.T, reg *httpmock.Registry) + wantStdout string + wantStderr string + wantErr bool + }{ + { + name: "add from stdin", + isTTY: true, + opts: AddOptions{ + KeyFile: "-", + Title: "my sacred key", + AllowWrite: false, + }, + stdin: "PUBKEY\n", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/keys"), + httpmock.RESTPayload(200, `{}`, func(payload map[string]interface{}) { + if title := payload["title"].(string); title != "my sacred key" { + t.Errorf("POST title %q, want %q", title, "my sacred key") + } + if key := payload["key"].(string); key != "PUBKEY\n" { + t.Errorf("POST key %q, want %q", key, "PUBKEY\n") + } + if isReadOnly := payload["read_only"].(bool); !isReadOnly { + t.Errorf("POST read_only %v, want %v", isReadOnly, true) + } + })) + }, + wantStdout: "✓ Deploy key added to OWNER/REPO\n", + wantStderr: "", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, stdin, stdout, stderr := iostreams.Test() + stdin.WriteString(tt.stdin) + io.SetStdinTTY(tt.isTTY) + io.SetStdoutTTY(tt.isTTY) + io.SetStderrTTY(tt.isTTY) + + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + + opts := tt.opts + opts.IO = io + opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } + opts.HTTPClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + + err := addRun(&opts) + if (err != nil) != tt.wantErr { + t.Errorf("addRun() return error: %v", err) + return + } + + if stdout.String() != tt.wantStdout { + t.Errorf("wants stdout %q, got %q", tt.wantStdout, stdout.String()) + } + if stderr.String() != tt.wantStderr { + t.Errorf("wants stderr %q, got %q", tt.wantStderr, stderr.String()) + } + }) + } +} diff --git a/pkg/cmd/repo/deploy-key/add/http.go b/pkg/cmd/repo/deploy-key/add/http.go new file mode 100644 index 000000000..0ec670a85 --- /dev/null +++ b/pkg/cmd/repo/deploy-key/add/http.go @@ -0,0 +1,57 @@ +package add + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "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 uploadDeployKey(httpClient *http.Client, repo ghrepo.Interface, keyFile io.Reader, title string, isWritable bool) error { + path := fmt.Sprintf("repos/%s/%s/keys", repo.RepoOwner(), repo.RepoName()) + url := ghinstance.RESTPrefix(repo.RepoHost()) + path + + keyBytes, err := ioutil.ReadAll(keyFile) + if err != nil { + return err + } + + payload := map[string]interface{}{ + "title": title, + "key": string(keyBytes), + "read_only": !isWritable, + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes)) + if err != nil { + return err + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return api.HandleHTTPError(resp) + } + + _, err = io.Copy(ioutil.Discard, resp.Body) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cmd/repo/deploy-key/delete/delete.go b/pkg/cmd/repo/deploy-key/delete/delete.go new file mode 100644 index 000000000..20b4974ad --- /dev/null +++ b/pkg/cmd/repo/deploy-key/delete/delete.go @@ -0,0 +1,67 @@ +package delete + +import ( + "fmt" + "net/http" + + "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 DeleteOptions struct { + IO *iostreams.IOStreams + HTTPClient func() (*http.Client, error) + BaseRepo func() (ghrepo.Interface, error) + + KeyID string +} + +func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { + opts := &DeleteOptions{ + HTTPClient: f.HttpClient, + IO: f.IOStreams, + BaseRepo: f.BaseRepo, + } + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a deploy key from a GitHub repository", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.KeyID = args[0] + + if runF != nil { + return runF(opts) + } + return deleteRun(opts) + }, + } + + return cmd +} + +func deleteRun(opts *DeleteOptions) error { + httpClient, err := opts.HTTPClient() + if err != nil { + return err + } + + repo, err := opts.BaseRepo() + if err != nil { + return err + } + + if err := deleteDeployKey(httpClient, repo, opts.KeyID); err != nil { + return err + } + + if !opts.IO.IsStdoutTTY() { + return nil + } + + cs := opts.IO.ColorScheme() + _, err = fmt.Fprintf(opts.IO.Out, "%s Deploy key deleted from %s\n", cs.SuccessIconWithColor(cs.Red), cs.Bold(ghrepo.FullName(repo))) + return err +} diff --git a/pkg/cmd/repo/deploy-key/delete/delete_test.go b/pkg/cmd/repo/deploy-key/delete/delete_test.go new file mode 100644 index 000000000..eefa659ba --- /dev/null +++ b/pkg/cmd/repo/deploy-key/delete/delete_test.go @@ -0,0 +1,40 @@ +package delete + +import ( + "net/http" + "testing" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" +) + +func Test_deleteRun(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdinTTY(false) + io.SetStdoutTTY(true) + io.SetStderrTTY(true) + + tr := httpmock.Registry{} + defer tr.Verify(t) + + tr.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/keys/1234"), + httpmock.StringResponse(`{}`)) + + err := deleteRun(&DeleteOptions{ + IO: io, + HTTPClient: func() (*http.Client, error) { + return &http.Client{Transport: &tr}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + KeyID: "1234", + }) + assert.NoError(t, err) + + assert.Equal(t, "", stderr.String()) + assert.Equal(t, "✓ Deploy key deleted from OWNER/REPO\n", stdout.String()) +} diff --git a/pkg/cmd/repo/deploy-key/delete/http.go b/pkg/cmd/repo/deploy-key/delete/http.go new file mode 100644 index 000000000..fbbfc601a --- /dev/null +++ b/pkg/cmd/repo/deploy-key/delete/http.go @@ -0,0 +1,39 @@ +package delete + +import ( + "fmt" + "io" + "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 deleteDeployKey(httpClient *http.Client, repo ghrepo.Interface, id string) error { + path := fmt.Sprintf("repos/%s/%s/keys/%s", repo.RepoOwner(), repo.RepoName(), id) + url := ghinstance.RESTPrefix(repo.RepoHost()) + path + + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return err + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return api.HandleHTTPError(resp) + } + + _, err = io.Copy(ioutil.Discard, resp.Body) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cmd/repo/deploy-key/deploy-key.go b/pkg/cmd/repo/deploy-key/deploy-key.go new file mode 100644 index 000000000..5a2aa8be5 --- /dev/null +++ b/pkg/cmd/repo/deploy-key/deploy-key.go @@ -0,0 +1,24 @@ +package deploykey + +import ( + cmdAdd "github.com/cli/cli/v2/pkg/cmd/repo/deploy-key/add" + cmdDelete "github.com/cli/cli/v2/pkg/cmd/repo/deploy-key/delete" + cmdList "github.com/cli/cli/v2/pkg/cmd/repo/deploy-key/list" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdDeployKey(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "deploy-key ", + Short: "Manage deploy keys in a repository", + } + + cmdutil.EnableRepoOverride(cmd, f) + + cmd.AddCommand(cmdList.NewCmdList(f, nil)) + cmd.AddCommand(cmdAdd.NewCmdAdd(f, nil)) + cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil)) + + return cmd +} diff --git a/pkg/cmd/repo/deploy-key/list/http.go b/pkg/cmd/repo/deploy-key/list/http.go new file mode 100644 index 000000000..597764ae8 --- /dev/null +++ b/pkg/cmd/repo/deploy-key/list/http.go @@ -0,0 +1,53 @@ +package list + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "time" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/ghrepo" +) + +type deployKey struct { + ID int + Key string + Title string + CreatedAt time.Time `json:"created_at"` + ReadOnly bool `json:"read_only"` +} + +func repoKeys(httpClient *http.Client, repo ghrepo.Interface) ([]deployKey, error) { + path := fmt.Sprintf("repos/%s/%s/keys?per_page=100", repo.RepoOwner(), repo.RepoName()) + url := ghinstance.RESTPrefix(repo.RepoHost()) + path + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return nil, api.HandleHTTPError(resp) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var keys []deployKey + err = json.Unmarshal(b, &keys) + if err != nil { + return nil, err + } + + return keys, nil +} diff --git a/pkg/cmd/repo/deploy-key/list/list.go b/pkg/cmd/repo/deploy-key/list/list.go new file mode 100644 index 000000000..599c2e55e --- /dev/null +++ b/pkg/cmd/repo/deploy-key/list/list.go @@ -0,0 +1,106 @@ +package list + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "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/utils" + "github.com/spf13/cobra" +) + +type ListOptions struct { + IO *iostreams.IOStreams + HTTPClient func() (*http.Client, error) + BaseRepo func() (ghrepo.Interface, error) +} + +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + HTTPClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List deploy keys in a GitHub repository", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + opts.BaseRepo = f.BaseRepo + + if runF != nil { + return runF(opts) + } + return listRun(opts) + }, + } + + return cmd +} + +func listRun(opts *ListOptions) error { + apiClient, err := opts.HTTPClient() + if err != nil { + return err + } + + repo, err := opts.BaseRepo() + if err != nil { + return err + } + + deployKeys, err := repoKeys(apiClient, repo) + if err != nil { + return err + } + + if len(deployKeys) == 0 { + fmt.Fprintf(opts.IO.ErrOut, "No deploy keys found in %s\n", ghrepo.FullName(repo)) + return cmdutil.SilentError + } + + t := utils.NewTablePrinter(opts.IO) + cs := opts.IO.ColorScheme() + now := time.Now() + + for _, deployKey := range deployKeys { + sshID := strconv.Itoa(deployKey.ID) + t.AddField(sshID, nil, nil) + t.AddField(deployKey.Title, nil, nil) + + sshType := "read-only" + if !deployKey.ReadOnly { + sshType = "read-write" + } + t.AddField(sshType, nil, nil) + t.AddField(deployKey.Key, truncateMiddle, nil) + + createdAt := deployKey.CreatedAt.Format(time.RFC3339) + if t.IsTTY() { + createdAt = utils.FuzzyAgoAbbr(now, deployKey.CreatedAt) + } + t.AddField(createdAt, nil, cs.Gray) + t.EndRow() + } + + return t.Render() +} + +func truncateMiddle(maxWidth int, t string) string { + if len(t) <= maxWidth { + return t + } + + ellipsis := "..." + if maxWidth < len(ellipsis)+2 { + return t[0:maxWidth] + } + + halfWidth := (maxWidth - len(ellipsis)) / 2 + remainder := (maxWidth - len(ellipsis)) % 2 + return t[0:halfWidth+remainder] + ellipsis + t[len(t)-halfWidth:] +} diff --git a/pkg/cmd/repo/deploy-key/list/list_test.go b/pkg/cmd/repo/deploy-key/list/list_test.go new file mode 100644 index 000000000..7df3ae48b --- /dev/null +++ b/pkg/cmd/repo/deploy-key/list/list_test.go @@ -0,0 +1,132 @@ +package list + +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" +) + +func TestListRun(t *testing.T) { + tests := []struct { + name string + opts ListOptions + isTTY bool + httpStubs func(t *testing.T, reg *httpmock.Registry) + wantStdout string + wantStderr string + wantErr bool + }{ + { + name: "list tty", + isTTY: true, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + createdAt := time.Now().Add(time.Duration(-24) * time.Hour) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/keys"), + httpmock.StringResponse(fmt.Sprintf(`[ + { + "id": 1234, + "key": "ssh-rsa AAAABbBB123", + "title": "Mac", + "created_at": "%[1]s", + "read_only": true + }, + { + "id": 5678, + "key": "ssh-rsa EEEEEEEK247", + "title": "hubot@Windows", + "created_at": "%[1]s", + "read_only": false + } + ]`, createdAt.Format(time.RFC3339))), + ) + }, + wantStdout: heredoc.Doc(` + 1234 Mac read-only ssh-rsa AAAABbBB123 1d + 5678 hubot@Windows read-write ssh-rsa EEEEEEEK247 1d + `), + wantStderr: "", + }, + { + name: "list non-tty", + isTTY: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + createdAt, _ := time.Parse(time.RFC3339, "2020-08-31T15:44:24+02:00") + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/keys"), + httpmock.StringResponse(fmt.Sprintf(`[ + { + "id": 1234, + "key": "ssh-rsa AAAABbBB123", + "title": "Mac", + "created_at": "%[1]s", + "read_only": false + }, + { + "id": 5678, + "key": "ssh-rsa EEEEEEEK247", + "title": "hubot@Windows", + "created_at": "%[1]s", + "read_only": true + } + ]`, createdAt.Format(time.RFC3339))), + ) + }, + wantStdout: heredoc.Doc(` + 1234 Mac read-write ssh-rsa AAAABbBB123 2020-08-31T15:44:24+02:00 + 5678 hubot@Windows read-only ssh-rsa EEEEEEEK247 2020-08-31T15:44:24+02:00 + `), + wantStderr: "", + }, + { + name: "no keys", + isTTY: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/keys"), + httpmock.StringResponse(`[]`)) + }, + wantStdout: "", + wantStderr: "No deploy keys found in OWNER/REPO\n", + wantErr: true, + }, + } + + 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) + + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + + opts := tt.opts + opts.IO = io + opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } + opts.HTTPClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + + err := listRun(&opts) + if (err != nil) != tt.wantErr { + t.Errorf("listRun() return error: %v", err) + return + } + + if stdout.String() != tt.wantStdout { + t.Errorf("wants stdout %q, got %q", tt.wantStdout, stdout.String()) + } + if stderr.String() != tt.wantStderr { + t.Errorf("wants stderr %q, got %q", tt.wantStderr, stderr.String()) + } + }) + } +} diff --git a/pkg/cmd/repo/repo.go b/pkg/cmd/repo/repo.go index bc0909cd2..52058d868 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" + deployKeyCmd "github.com/cli/cli/v2/pkg/cmd/repo/deploy-key" 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" @@ -47,6 +48,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(deployKeyCmd.NewCmdDeployKey(f)) cmd.AddCommand(repoRenameCmd.NewCmdRename(f, nil)) cmd.AddCommand(repoDeleteCmd.NewCmdDelete(f, nil)) cmd.AddCommand(repoArchiveCmd.NewCmdArchive(f, nil))