X Tutup
Skip to content

Commit dcf5a27

Browse files
authored
Merge pull request cli#1862 from edualb/trunk
[cli#1755] SSH key management (gh ssh-key list)
2 parents 2086d13 + 4a2cc8d commit dcf5a27

File tree

7 files changed

+321
-14
lines changed

7 files changed

+321
-14
lines changed

pkg/cmd/auth/login/login_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ func Test_loginRun_Survey(t *testing.T) {
315315
_ = cfg.Set("github.com", "oauth_token", "ghi789")
316316
},
317317
httpStubs: func(reg *httpmock.Registry) {
318-
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
318+
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
319319
reg.Register(
320320
httpmock.GraphQL(`query UserCurrent\b`),
321321
httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`))
@@ -341,7 +341,7 @@ func Test_loginRun_Survey(t *testing.T) {
341341
as.StubOne(false) // cache credentials
342342
},
343343
httpStubs: func(reg *httpmock.Registry) {
344-
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
344+
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
345345
reg.Register(
346346
httpmock.GraphQL(`query UserCurrent\b`),
347347
httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`))
@@ -363,7 +363,7 @@ func Test_loginRun_Survey(t *testing.T) {
363363
as.StubOne(false) // cache credentials
364364
},
365365
httpStubs: func(reg *httpmock.Registry) {
366-
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
366+
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key"))
367367
reg.Register(
368368
httpmock.GraphQL(`query UserCurrent\b`),
369369
httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`))
@@ -436,7 +436,7 @@ func Test_loginRun_Survey(t *testing.T) {
436436
if tt.httpStubs != nil {
437437
tt.httpStubs(reg)
438438
} else {
439-
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
439+
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key"))
440440
reg.Register(
441441
httpmock.GraphQL(`query UserCurrent\b`),
442442
httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`))

pkg/cmd/auth/status/status_test.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func Test_statusRun(t *testing.T) {
9191
_ = c.Set("github.com", "oauth_token", "abc123")
9292
},
9393
httpStubs: func(reg *httpmock.Registry) {
94-
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
94+
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
9595
reg.Register(
9696
httpmock.GraphQL(`query UserCurrent\b`),
9797
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
@@ -106,8 +106,8 @@ func Test_statusRun(t *testing.T) {
106106
_ = c.Set("github.com", "oauth_token", "abc123")
107107
},
108108
httpStubs: func(reg *httpmock.Registry) {
109-
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,"))
110-
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
109+
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo"))
110+
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
111111
reg.Register(
112112
httpmock.GraphQL(`query UserCurrent\b`),
113113
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
@@ -124,7 +124,7 @@ func Test_statusRun(t *testing.T) {
124124
},
125125
httpStubs: func(reg *httpmock.Registry) {
126126
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.StatusStringResponse(400, "no bueno"))
127-
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
127+
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
128128
reg.Register(
129129
httpmock.GraphQL(`query UserCurrent\b`),
130130
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
@@ -140,8 +140,8 @@ func Test_statusRun(t *testing.T) {
140140
_ = c.Set("github.com", "oauth_token", "abc123")
141141
},
142142
httpStubs: func(reg *httpmock.Registry) {
143-
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
144-
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
143+
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
144+
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
145145
reg.Register(
146146
httpmock.GraphQL(`query UserCurrent\b`),
147147
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
@@ -159,8 +159,8 @@ func Test_statusRun(t *testing.T) {
159159
_ = c.Set("github.com", "oauth_token", "xyz456")
160160
},
161161
httpStubs: func(reg *httpmock.Registry) {
162-
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
163-
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
162+
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
163+
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
164164
reg.Register(
165165
httpmock.GraphQL(`query UserCurrent\b`),
166166
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
@@ -180,8 +180,8 @@ func Test_statusRun(t *testing.T) {
180180
_ = c.Set("github.com", "oauth_token", "xyz456")
181181
},
182182
httpStubs: func(reg *httpmock.Registry) {
183-
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
184-
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
183+
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
184+
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
185185
reg.Register(
186186
httpmock.GraphQL(`query UserCurrent\b`),
187187
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))

pkg/cmd/root/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
repoCmd "github.com/cli/cli/pkg/cmd/repo"
2121
creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits"
2222
secretCmd "github.com/cli/cli/pkg/cmd/secret"
23+
sshKeyCmd "github.com/cli/cli/pkg/cmd/ssh-key"
2324
versionCmd "github.com/cli/cli/pkg/cmd/version"
2425
"github.com/cli/cli/pkg/cmdutil"
2526
"github.com/spf13/cobra"
@@ -76,6 +77,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
7677
cmd.AddCommand(gistCmd.NewCmdGist(f))
7778
cmd.AddCommand(completionCmd.NewCmdCompletion(f.IOStreams))
7879
cmd.AddCommand(secretCmd.NewCmdSecret(f))
80+
cmd.AddCommand(sshKeyCmd.NewCmdSSHKey(f))
7981

8082
// the `api` command should not inherit any extra HTTP headers
8183
bareHTTPCmdFactory := *f

pkg/cmd/ssh-key/list/http.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package list
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"io/ioutil"
8+
"net/http"
9+
"time"
10+
11+
"github.com/cli/cli/api"
12+
"github.com/cli/cli/internal/ghinstance"
13+
)
14+
15+
var scopesError = errors.New("insufficient OAuth scopes")
16+
17+
type sshKey struct {
18+
Key string
19+
Title string
20+
CreatedAt time.Time `json:"created_at"`
21+
}
22+
23+
func userKeys(httpClient *http.Client, userHandle string) ([]sshKey, error) {
24+
resource := "user/keys"
25+
if userHandle != "" {
26+
resource = fmt.Sprintf("users/%s/keys", userHandle)
27+
}
28+
url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(ghinstance.OverridableDefault()), resource, 100)
29+
req, err := http.NewRequest("GET", url, nil)
30+
if err != nil {
31+
return nil, err
32+
}
33+
34+
resp, err := httpClient.Do(req)
35+
if err != nil {
36+
return nil, err
37+
}
38+
defer resp.Body.Close()
39+
40+
if resp.StatusCode == 404 {
41+
return nil, scopesError
42+
} else if resp.StatusCode > 299 {
43+
return nil, api.HandleHTTPError(resp)
44+
}
45+
46+
b, err := ioutil.ReadAll(resp.Body)
47+
if err != nil {
48+
return nil, err
49+
}
50+
51+
var keys []sshKey
52+
err = json.Unmarshal(b, &keys)
53+
if err != nil {
54+
return nil, err
55+
}
56+
57+
return keys, nil
58+
}

pkg/cmd/ssh-key/list/list.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package list
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net/http"
7+
"time"
8+
9+
"github.com/cli/cli/pkg/cmdutil"
10+
"github.com/cli/cli/pkg/iostreams"
11+
"github.com/cli/cli/utils"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
// ListOptions struct for list command
16+
type ListOptions struct {
17+
IO *iostreams.IOStreams
18+
HTTPClient func() (*http.Client, error)
19+
}
20+
21+
// NewCmdList creates a command for list all SSH Keys
22+
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
23+
opts := &ListOptions{
24+
HTTPClient: f.HttpClient,
25+
IO: f.IOStreams,
26+
}
27+
28+
cmd := &cobra.Command{
29+
Use: "list",
30+
Short: "Lists SSH keys in a GitHub account",
31+
Args: cobra.ExactArgs(0),
32+
RunE: func(cmd *cobra.Command, args []string) error {
33+
if runF != nil {
34+
return runF(opts)
35+
}
36+
return listRun(opts)
37+
},
38+
}
39+
40+
return cmd
41+
}
42+
43+
func listRun(opts *ListOptions) error {
44+
apiClient, err := opts.HTTPClient()
45+
if err != nil {
46+
return err
47+
}
48+
49+
sshKeys, err := userKeys(apiClient, "")
50+
if err != nil {
51+
if errors.Is(err, scopesError) {
52+
cs := opts.IO.ColorScheme()
53+
fmt.Fprint(opts.IO.ErrOut, "Error: insufficient OAuth scopes to list SSH keys\n")
54+
fmt.Fprintf(opts.IO.ErrOut, "Run the following to grant scopes: %s\n", cs.Bold("gh auth refresh -s read:public_key"))
55+
return cmdutil.SilentError
56+
}
57+
return err
58+
}
59+
60+
if len(sshKeys) == 0 {
61+
fmt.Fprintln(opts.IO.ErrOut, "No SSH keys present in GitHub account.")
62+
return cmdutil.SilentError
63+
}
64+
65+
t := utils.NewTablePrinter(opts.IO)
66+
cs := opts.IO.ColorScheme()
67+
now := time.Now()
68+
69+
for _, sshKey := range sshKeys {
70+
t.AddField(sshKey.Title, nil, nil)
71+
t.AddField(sshKey.Key, truncateMiddle, nil)
72+
73+
createdAt := sshKey.CreatedAt.Format(time.RFC3339)
74+
if t.IsTTY() {
75+
createdAt = utils.FuzzyAgoAbbr(now, sshKey.CreatedAt)
76+
}
77+
t.AddField(createdAt, nil, cs.Gray)
78+
t.EndRow()
79+
}
80+
81+
return t.Render()
82+
}
83+
84+
func truncateMiddle(maxWidth int, t string) string {
85+
if len(t) <= maxWidth {
86+
return t
87+
}
88+
89+
ellipsis := "..."
90+
if maxWidth < len(ellipsis)+2 {
91+
return t[0:maxWidth]
92+
}
93+
94+
halfWidth := (maxWidth - len(ellipsis)) / 2
95+
return t[0:halfWidth] + ellipsis + t[len(t)-halfWidth:]
96+
}

0 commit comments

Comments
 (0)
X Tutup