X Tutup
Skip to content

Commit 47a6aff

Browse files
n1leshmislav
andauthored
Add repo deploy key commands (cli#4302)
Co-authored-by: Mislav Marohnić <mislav@github.com>
1 parent 603502f commit 47a6aff

File tree

11 files changed

+704
-0
lines changed

11 files changed

+704
-0
lines changed

pkg/cmd/repo/deploy-key/add/add.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package add
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"net/http"
7+
"os"
8+
9+
"github.com/MakeNowJust/heredoc"
10+
"github.com/cli/cli/v2/internal/ghrepo"
11+
"github.com/cli/cli/v2/pkg/cmdutil"
12+
"github.com/cli/cli/v2/pkg/iostreams"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
type AddOptions struct {
17+
IO *iostreams.IOStreams
18+
HTTPClient func() (*http.Client, error)
19+
BaseRepo func() (ghrepo.Interface, error)
20+
21+
KeyFile string
22+
Title string
23+
AllowWrite bool
24+
}
25+
26+
func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command {
27+
opts := &AddOptions{
28+
HTTPClient: f.HttpClient,
29+
IO: f.IOStreams,
30+
}
31+
32+
cmd := &cobra.Command{
33+
Use: "add <key-file>",
34+
Short: "Add a deploy key to a GitHub repository",
35+
Long: heredoc.Doc(`
36+
Add a deploy key to a GitHub repository.
37+
38+
Note that any key added by gh will be associated with the current authentication token.
39+
If you de-authorize the GitHub CLI app or authentication token from your account, any
40+
deploy keys added by GitHub CLI will be removed as well.
41+
`),
42+
Example: heredoc.Doc(`
43+
# generate a passwordless SSH key and add it as a deploy key to a repository
44+
$ ssh-keygen -t ed25519 -C "my description" -N "" -f ~/.ssh/gh-test
45+
$ gh repo deploy-key add ~/.ssh/gh-test.pub
46+
`),
47+
Args: cobra.ExactArgs(1),
48+
RunE: func(cmd *cobra.Command, args []string) error {
49+
opts.BaseRepo = f.BaseRepo
50+
opts.KeyFile = args[0]
51+
52+
if runF != nil {
53+
return runF(opts)
54+
}
55+
return addRun(opts)
56+
},
57+
}
58+
59+
cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Title of the new key")
60+
cmd.Flags().BoolVarP(&opts.AllowWrite, "allow-write", "w", false, "Allow write access for the key")
61+
return cmd
62+
}
63+
64+
func addRun(opts *AddOptions) error {
65+
httpClient, err := opts.HTTPClient()
66+
if err != nil {
67+
return err
68+
}
69+
70+
var keyReader io.Reader
71+
if opts.KeyFile == "-" {
72+
keyReader = opts.IO.In
73+
defer opts.IO.In.Close()
74+
} else {
75+
f, err := os.Open(opts.KeyFile)
76+
if err != nil {
77+
return err
78+
}
79+
defer f.Close()
80+
keyReader = f
81+
}
82+
83+
repo, err := opts.BaseRepo()
84+
if err != nil {
85+
return err
86+
}
87+
88+
if err := uploadDeployKey(httpClient, repo, keyReader, opts.Title, opts.AllowWrite); err != nil {
89+
return err
90+
}
91+
92+
if !opts.IO.IsStdoutTTY() {
93+
return nil
94+
}
95+
96+
cs := opts.IO.ColorScheme()
97+
_, err = fmt.Fprintf(opts.IO.Out, "%s Deploy key added to %s\n", cs.SuccessIcon(), cs.Bold(ghrepo.FullName(repo)))
98+
return err
99+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package add
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
7+
"github.com/cli/cli/v2/internal/ghrepo"
8+
"github.com/cli/cli/v2/pkg/httpmock"
9+
"github.com/cli/cli/v2/pkg/iostreams"
10+
)
11+
12+
func Test_addRun(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
opts AddOptions
16+
isTTY bool
17+
stdin string
18+
httpStubs func(t *testing.T, reg *httpmock.Registry)
19+
wantStdout string
20+
wantStderr string
21+
wantErr bool
22+
}{
23+
{
24+
name: "add from stdin",
25+
isTTY: true,
26+
opts: AddOptions{
27+
KeyFile: "-",
28+
Title: "my sacred key",
29+
AllowWrite: false,
30+
},
31+
stdin: "PUBKEY\n",
32+
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
33+
reg.Register(
34+
httpmock.REST("POST", "repos/OWNER/REPO/keys"),
35+
httpmock.RESTPayload(200, `{}`, func(payload map[string]interface{}) {
36+
if title := payload["title"].(string); title != "my sacred key" {
37+
t.Errorf("POST title %q, want %q", title, "my sacred key")
38+
}
39+
if key := payload["key"].(string); key != "PUBKEY\n" {
40+
t.Errorf("POST key %q, want %q", key, "PUBKEY\n")
41+
}
42+
if isReadOnly := payload["read_only"].(bool); !isReadOnly {
43+
t.Errorf("POST read_only %v, want %v", isReadOnly, true)
44+
}
45+
}))
46+
},
47+
wantStdout: "✓ Deploy key added to OWNER/REPO\n",
48+
wantStderr: "",
49+
wantErr: false,
50+
},
51+
}
52+
53+
for _, tt := range tests {
54+
t.Run(tt.name, func(t *testing.T) {
55+
io, stdin, stdout, stderr := iostreams.Test()
56+
stdin.WriteString(tt.stdin)
57+
io.SetStdinTTY(tt.isTTY)
58+
io.SetStdoutTTY(tt.isTTY)
59+
io.SetStderrTTY(tt.isTTY)
60+
61+
reg := &httpmock.Registry{}
62+
if tt.httpStubs != nil {
63+
tt.httpStubs(t, reg)
64+
}
65+
66+
opts := tt.opts
67+
opts.IO = io
68+
opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }
69+
opts.HTTPClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }
70+
71+
err := addRun(&opts)
72+
if (err != nil) != tt.wantErr {
73+
t.Errorf("addRun() return error: %v", err)
74+
return
75+
}
76+
77+
if stdout.String() != tt.wantStdout {
78+
t.Errorf("wants stdout %q, got %q", tt.wantStdout, stdout.String())
79+
}
80+
if stderr.String() != tt.wantStderr {
81+
t.Errorf("wants stderr %q, got %q", tt.wantStderr, stderr.String())
82+
}
83+
})
84+
}
85+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package add
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"io/ioutil"
9+
"net/http"
10+
11+
"github.com/cli/cli/v2/api"
12+
"github.com/cli/cli/v2/internal/ghinstance"
13+
"github.com/cli/cli/v2/internal/ghrepo"
14+
)
15+
16+
func uploadDeployKey(httpClient *http.Client, repo ghrepo.Interface, keyFile io.Reader, title string, isWritable bool) error {
17+
path := fmt.Sprintf("repos/%s/%s/keys", repo.RepoOwner(), repo.RepoName())
18+
url := ghinstance.RESTPrefix(repo.RepoHost()) + path
19+
20+
keyBytes, err := ioutil.ReadAll(keyFile)
21+
if err != nil {
22+
return err
23+
}
24+
25+
payload := map[string]interface{}{
26+
"title": title,
27+
"key": string(keyBytes),
28+
"read_only": !isWritable,
29+
}
30+
31+
payloadBytes, err := json.Marshal(payload)
32+
if err != nil {
33+
return err
34+
}
35+
36+
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes))
37+
if err != nil {
38+
return err
39+
}
40+
41+
resp, err := httpClient.Do(req)
42+
if err != nil {
43+
return err
44+
}
45+
defer resp.Body.Close()
46+
47+
if resp.StatusCode > 299 {
48+
return api.HandleHTTPError(resp)
49+
}
50+
51+
_, err = io.Copy(ioutil.Discard, resp.Body)
52+
if err != nil {
53+
return err
54+
}
55+
56+
return nil
57+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package delete
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
7+
"github.com/cli/cli/v2/internal/ghrepo"
8+
"github.com/cli/cli/v2/pkg/cmdutil"
9+
"github.com/cli/cli/v2/pkg/iostreams"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
type DeleteOptions struct {
14+
IO *iostreams.IOStreams
15+
HTTPClient func() (*http.Client, error)
16+
BaseRepo func() (ghrepo.Interface, error)
17+
18+
KeyID string
19+
}
20+
21+
func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command {
22+
opts := &DeleteOptions{
23+
HTTPClient: f.HttpClient,
24+
IO: f.IOStreams,
25+
BaseRepo: f.BaseRepo,
26+
}
27+
28+
cmd := &cobra.Command{
29+
Use: "delete <key-id>",
30+
Short: "Delete a deploy key from a GitHub repository",
31+
Args: cobra.ExactArgs(1),
32+
RunE: func(cmd *cobra.Command, args []string) error {
33+
opts.KeyID = args[0]
34+
35+
if runF != nil {
36+
return runF(opts)
37+
}
38+
return deleteRun(opts)
39+
},
40+
}
41+
42+
return cmd
43+
}
44+
45+
func deleteRun(opts *DeleteOptions) error {
46+
httpClient, err := opts.HTTPClient()
47+
if err != nil {
48+
return err
49+
}
50+
51+
repo, err := opts.BaseRepo()
52+
if err != nil {
53+
return err
54+
}
55+
56+
if err := deleteDeployKey(httpClient, repo, opts.KeyID); err != nil {
57+
return err
58+
}
59+
60+
if !opts.IO.IsStdoutTTY() {
61+
return nil
62+
}
63+
64+
cs := opts.IO.ColorScheme()
65+
_, err = fmt.Fprintf(opts.IO.Out, "%s Deploy key deleted from %s\n", cs.SuccessIconWithColor(cs.Red), cs.Bold(ghrepo.FullName(repo)))
66+
return err
67+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package delete
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
7+
"github.com/cli/cli/v2/internal/ghrepo"
8+
"github.com/cli/cli/v2/pkg/httpmock"
9+
"github.com/cli/cli/v2/pkg/iostreams"
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func Test_deleteRun(t *testing.T) {
14+
io, _, stdout, stderr := iostreams.Test()
15+
io.SetStdinTTY(false)
16+
io.SetStdoutTTY(true)
17+
io.SetStderrTTY(true)
18+
19+
tr := httpmock.Registry{}
20+
defer tr.Verify(t)
21+
22+
tr.Register(
23+
httpmock.REST("DELETE", "repos/OWNER/REPO/keys/1234"),
24+
httpmock.StringResponse(`{}`))
25+
26+
err := deleteRun(&DeleteOptions{
27+
IO: io,
28+
HTTPClient: func() (*http.Client, error) {
29+
return &http.Client{Transport: &tr}, nil
30+
},
31+
BaseRepo: func() (ghrepo.Interface, error) {
32+
return ghrepo.New("OWNER", "REPO"), nil
33+
},
34+
KeyID: "1234",
35+
})
36+
assert.NoError(t, err)
37+
38+
assert.Equal(t, "", stderr.String())
39+
assert.Equal(t, "✓ Deploy key deleted from OWNER/REPO\n", stdout.String())
40+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package delete
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"io/ioutil"
7+
"net/http"
8+
9+
"github.com/cli/cli/v2/api"
10+
"github.com/cli/cli/v2/internal/ghinstance"
11+
"github.com/cli/cli/v2/internal/ghrepo"
12+
)
13+
14+
func deleteDeployKey(httpClient *http.Client, repo ghrepo.Interface, id string) error {
15+
path := fmt.Sprintf("repos/%s/%s/keys/%s", repo.RepoOwner(), repo.RepoName(), id)
16+
url := ghinstance.RESTPrefix(repo.RepoHost()) + path
17+
18+
req, err := http.NewRequest("DELETE", url, nil)
19+
if err != nil {
20+
return err
21+
}
22+
23+
resp, err := httpClient.Do(req)
24+
if err != nil {
25+
return err
26+
}
27+
defer resp.Body.Close()
28+
29+
if resp.StatusCode > 299 {
30+
return api.HandleHTTPError(resp)
31+
}
32+
33+
_, err = io.Copy(ioutil.Discard, resp.Body)
34+
if err != nil {
35+
return err
36+
}
37+
38+
return nil
39+
}

0 commit comments

Comments
 (0)
X Tutup