Add repo deploy key commands (#4302)

Co-authored-by: Mislav Marohnić <mislav@github.com>
This commit is contained in:
Nilesh Singh 2022-01-25 23:18:24 +05:30 committed by GitHub
parent 603502febf
commit 47a6aff54a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 704 additions and 0 deletions

View file

@ -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 <key-file>",
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
}

View file

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

View file

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

View file

@ -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 <key-id>",
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
}

View file

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

View file

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

View file

@ -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 <command>",
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
}

View file

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

View file

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

View file

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

View file

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