Skip to content

Commit 877ad22

Browse files
authored
Merge pull request cli#4384 from cli/import-codespaces
Import codespaces
2 parents c4ec0a6 + a1e72af commit 877ad22

38 files changed

+5227
-14
lines changed

cmd/ghcs/code.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package ghcs
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/url"
7+
8+
"github.com/skratchdot/open-golang/open"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
func newCodeCmd(app *App) *cobra.Command {
13+
var (
14+
codespace string
15+
useInsiders bool
16+
)
17+
18+
codeCmd := &cobra.Command{
19+
Use: "code",
20+
Short: "Open a codespace in VS Code",
21+
Args: noArgsConstraint,
22+
RunE: func(cmd *cobra.Command, args []string) error {
23+
return app.VSCode(cmd.Context(), codespace, useInsiders)
24+
},
25+
}
26+
27+
codeCmd.Flags().StringVarP(&codespace, "codespace", "c", "", "Name of the codespace")
28+
codeCmd.Flags().BoolVar(&useInsiders, "insiders", false, "Use the insiders version of VS Code")
29+
30+
return codeCmd
31+
}
32+
33+
// VSCode opens a codespace in the local VS VSCode application.
34+
func (a *App) VSCode(ctx context.Context, codespaceName string, useInsiders bool) error {
35+
user, err := a.apiClient.GetUser(ctx)
36+
if err != nil {
37+
return fmt.Errorf("error getting user: %w", err)
38+
}
39+
40+
if codespaceName == "" {
41+
codespace, err := chooseCodespace(ctx, a.apiClient, user)
42+
if err != nil {
43+
if err == errNoCodespaces {
44+
return err
45+
}
46+
return fmt.Errorf("error choosing codespace: %w", err)
47+
}
48+
codespaceName = codespace.Name
49+
}
50+
51+
url := vscodeProtocolURL(codespaceName, useInsiders)
52+
if err := open.Run(url); err != nil {
53+
return fmt.Errorf("error opening vscode URL %s: %s. (Is VS Code installed?)", url, err)
54+
}
55+
56+
return nil
57+
}
58+
59+
func vscodeProtocolURL(codespaceName string, useInsiders bool) string {
60+
application := "vscode"
61+
if useInsiders {
62+
application = "vscode-insiders"
63+
}
64+
return fmt.Sprintf("%s://github.codespaces/connect?name=%s", application, url.QueryEscape(codespaceName))
65+
}

cmd/ghcs/common.go

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package ghcs
2+
3+
// This file defines functions common to the entire ghcs command set.
4+
5+
import (
6+
"context"
7+
"errors"
8+
"fmt"
9+
"io"
10+
"os"
11+
"sort"
12+
13+
"github.com/AlecAivazis/survey/v2"
14+
"github.com/AlecAivazis/survey/v2/terminal"
15+
"github.com/cli/cli/v2/cmd/ghcs/output"
16+
"github.com/cli/cli/v2/internal/api"
17+
"github.com/spf13/cobra"
18+
"golang.org/x/term"
19+
)
20+
21+
type App struct {
22+
apiClient apiClient
23+
logger *output.Logger
24+
}
25+
26+
func NewApp(logger *output.Logger, apiClient apiClient) *App {
27+
return &App{
28+
apiClient: apiClient,
29+
logger: logger,
30+
}
31+
}
32+
33+
//go:generate moq -fmt goimports -rm -skip-ensure -out mock_api.go . apiClient
34+
type apiClient interface {
35+
GetUser(ctx context.Context) (*api.User, error)
36+
GetCodespaceToken(ctx context.Context, user, name string) (string, error)
37+
GetCodespace(ctx context.Context, token, user, name string) (*api.Codespace, error)
38+
ListCodespaces(ctx context.Context, user string) ([]*api.Codespace, error)
39+
DeleteCodespace(ctx context.Context, user, name string) error
40+
StartCodespace(ctx context.Context, token string, codespace *api.Codespace) error
41+
CreateCodespace(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error)
42+
GetRepository(ctx context.Context, nwo string) (*api.Repository, error)
43+
AuthorizedKeys(ctx context.Context, user string) ([]byte, error)
44+
GetCodespaceRegionLocation(ctx context.Context) (string, error)
45+
GetCodespacesSKUs(ctx context.Context, user *api.User, repository *api.Repository, branch, location string) ([]*api.SKU, error)
46+
GetCodespaceRepositoryContents(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error)
47+
}
48+
49+
var errNoCodespaces = errors.New("you have no codespaces")
50+
51+
func chooseCodespace(ctx context.Context, apiClient apiClient, user *api.User) (*api.Codespace, error) {
52+
codespaces, err := apiClient.ListCodespaces(ctx, user.Login)
53+
if err != nil {
54+
return nil, fmt.Errorf("error getting codespaces: %w", err)
55+
}
56+
return chooseCodespaceFromList(ctx, codespaces)
57+
}
58+
59+
func chooseCodespaceFromList(ctx context.Context, codespaces []*api.Codespace) (*api.Codespace, error) {
60+
if len(codespaces) == 0 {
61+
return nil, errNoCodespaces
62+
}
63+
64+
sort.Slice(codespaces, func(i, j int) bool {
65+
return codespaces[i].CreatedAt > codespaces[j].CreatedAt
66+
})
67+
68+
codespacesByName := make(map[string]*api.Codespace)
69+
codespacesNames := make([]string, 0, len(codespaces))
70+
for _, codespace := range codespaces {
71+
codespacesByName[codespace.Name] = codespace
72+
codespacesNames = append(codespacesNames, codespace.Name)
73+
}
74+
75+
sshSurvey := []*survey.Question{
76+
{
77+
Name: "codespace",
78+
Prompt: &survey.Select{
79+
Message: "Choose codespace:",
80+
Options: codespacesNames,
81+
Default: codespacesNames[0],
82+
},
83+
Validate: survey.Required,
84+
},
85+
}
86+
87+
var answers struct {
88+
Codespace string
89+
}
90+
if err := ask(sshSurvey, &answers); err != nil {
91+
return nil, fmt.Errorf("error getting answers: %w", err)
92+
}
93+
94+
codespace := codespacesByName[answers.Codespace]
95+
return codespace, nil
96+
}
97+
98+
// getOrChooseCodespace prompts the user to choose a codespace if the codespaceName is empty.
99+
// It then fetches the codespace token and the codespace record.
100+
func getOrChooseCodespace(ctx context.Context, apiClient apiClient, user *api.User, codespaceName string) (codespace *api.Codespace, token string, err error) {
101+
if codespaceName == "" {
102+
codespace, err = chooseCodespace(ctx, apiClient, user)
103+
if err != nil {
104+
if err == errNoCodespaces {
105+
return nil, "", err
106+
}
107+
return nil, "", fmt.Errorf("choosing codespace: %w", err)
108+
}
109+
codespaceName = codespace.Name
110+
111+
token, err = apiClient.GetCodespaceToken(ctx, user.Login, codespaceName)
112+
if err != nil {
113+
return nil, "", fmt.Errorf("getting codespace token: %w", err)
114+
}
115+
} else {
116+
token, err = apiClient.GetCodespaceToken(ctx, user.Login, codespaceName)
117+
if err != nil {
118+
return nil, "", fmt.Errorf("getting codespace token for given codespace: %w", err)
119+
}
120+
121+
codespace, err = apiClient.GetCodespace(ctx, token, user.Login, codespaceName)
122+
if err != nil {
123+
return nil, "", fmt.Errorf("getting full codespace details: %w", err)
124+
}
125+
}
126+
127+
return codespace, token, nil
128+
}
129+
130+
func safeClose(closer io.Closer, err *error) {
131+
if closeErr := closer.Close(); *err == nil {
132+
*err = closeErr
133+
}
134+
}
135+
136+
// hasTTY indicates whether the process connected to a terminal.
137+
// It is not portable to assume stdin/stdout are fds 0 and 1.
138+
var hasTTY = term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
139+
140+
// ask asks survey questions on the terminal, using standard options.
141+
// It fails unless hasTTY, but ideally callers should avoid calling it in that case.
142+
func ask(qs []*survey.Question, response interface{}) error {
143+
if !hasTTY {
144+
return fmt.Errorf("no terminal")
145+
}
146+
err := survey.Ask(qs, response, survey.WithShowCursor(true))
147+
// The survey package temporarily clears the terminal's ISIG mode bit
148+
// (see tcsetattr(3)) so the QUIT button (Ctrl-C) is reported as
149+
// ASCII \x03 (ETX) instead of delivering SIGINT to the application.
150+
// So we have to serve ourselves the SIGINT.
151+
//
152+
// https://github.com/AlecAivazis/survey/#why-isnt-ctrl-c-working
153+
if err == terminal.InterruptErr {
154+
self, _ := os.FindProcess(os.Getpid())
155+
_ = self.Signal(os.Interrupt) // assumes POSIX
156+
157+
// Suspend the goroutine, to avoid a race between
158+
// return from main and async delivery of INT signal.
159+
select {}
160+
}
161+
return err
162+
}
163+
164+
// checkAuthorizedKeys reports an error if the user has not registered any SSH keys;
165+
// see https://github.com/cli/cli/v2/issues/166#issuecomment-921769703.
166+
// The check is not required for security but it improves the error message.
167+
func checkAuthorizedKeys(ctx context.Context, client apiClient, user string) error {
168+
keys, err := client.AuthorizedKeys(ctx, user)
169+
if err != nil {
170+
return fmt.Errorf("failed to read GitHub-authorized SSH keys for %s: %w", user, err)
171+
}
172+
if len(keys) == 0 {
173+
return fmt.Errorf("user %s has no GitHub-authorized SSH keys", user)
174+
}
175+
return nil // success
176+
}
177+
178+
var ErrTooManyArgs = errors.New("the command accepts no arguments")
179+
180+
func noArgsConstraint(cmd *cobra.Command, args []string) error {
181+
if len(args) > 0 {
182+
return ErrTooManyArgs
183+
}
184+
return nil
185+
}

0 commit comments

Comments
 (0)