merge upstream + pr feedback

This commit is contained in:
Jose Garcia 2021-09-09 17:22:07 -04:00
commit fe2d5ebf37
10 changed files with 213 additions and 132 deletions

View file

@ -18,6 +18,8 @@ import (
"net/http"
"strconv"
"strings"
"github.com/opentracing/opentracing-go"
)
const githubAPI = "https://api.github.com"
@ -42,7 +44,7 @@ func (a *API) GetUser(ctx context.Context) (*User, error) {
}
a.setHeaders(req)
resp, err := a.client.Do(req)
resp, err := a.do(ctx, req, "/user")
if err != nil {
return nil, fmt.Errorf("error making request: %v", err)
}
@ -87,7 +89,7 @@ func (a *API) GetRepository(ctx context.Context, nwo string) (*Repository, error
}
a.setHeaders(req)
resp, err := a.client.Do(req)
resp, err := a.do(ctx, req, "/repos/*")
if err != nil {
return nil, fmt.Errorf("error making request: %v", err)
}
@ -156,7 +158,7 @@ func (a *API) ListCodespaces(ctx context.Context, user *User) ([]*Codespace, err
}
a.setHeaders(req)
resp, err := a.client.Do(req)
resp, err := a.do(ctx, req, "/vscs_internal/user/*/codespaces")
if err != nil {
return nil, fmt.Errorf("error making request: %v", err)
}
@ -204,7 +206,7 @@ func (a *API) GetCodespaceToken(ctx context.Context, ownerLogin, codespaceName s
}
a.setHeaders(req)
resp, err := a.client.Do(req)
resp, err := a.do(ctx, req, "/vscs_internal/user/*/codespaces/*/token")
if err != nil {
return "", fmt.Errorf("error making request: %v", err)
}
@ -238,7 +240,7 @@ func (a *API) GetCodespace(ctx context.Context, token, owner, codespace string)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := a.client.Do(req)
resp, err := a.do(ctx, req, "/vscs_internal/user/*/codespaces/*")
if err != nil {
return nil, fmt.Errorf("error making request: %v", err)
}
@ -272,7 +274,7 @@ func (a *API) StartCodespace(ctx context.Context, token string, codespace *Codes
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := a.client.Do(req)
resp, err := a.do(ctx, req, "/vscs_internal/proxy/environments/*/start")
if err != nil {
return fmt.Errorf("error making request: %v", err)
}
@ -309,7 +311,7 @@ func (a *API) GetCodespaceRegionLocation(ctx context.Context) (string, error) {
return "", fmt.Errorf("error creating request: %v", err)
}
resp, err := a.client.Do(req)
resp, err := a.do(ctx, req, req.URL.String())
if err != nil {
return "", fmt.Errorf("error making request: %v", err)
}
@ -350,7 +352,7 @@ func (a *API) GetCodespacesSKUs(ctx context.Context, user *User, repository *Rep
req.URL.RawQuery = q.Encode()
a.setHeaders(req)
resp, err := a.client.Do(req)
resp, err := a.do(ctx, req, "/vscs_internal/user/*/skus")
if err != nil {
return nil, fmt.Errorf("error making request: %v", err)
}
@ -394,7 +396,7 @@ func (a *API) CreateCodespace(ctx context.Context, user *User, repository *Repos
}
a.setHeaders(req)
resp, err := a.client.Do(req)
resp, err := a.do(ctx, req, "/vscs_internal/user/*/codespaces")
if err != nil {
return nil, fmt.Errorf("error making request: %v", err)
}
@ -424,7 +426,7 @@ func (a *API) DeleteCodespace(ctx context.Context, user *User, token, codespaceN
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := a.client.Do(req)
resp, err := a.do(ctx, req, "/vscs_internal/user/*/codespaces/*")
if err != nil {
return fmt.Errorf("error making request: %v", err)
}
@ -456,7 +458,7 @@ func (a *API) GetCodespaceRepositoryContents(ctx context.Context, codespace *Cod
req.URL.RawQuery = q.Encode()
a.setHeaders(req)
resp, err := a.client.Do(req)
resp, err := a.do(ctx, req, "/repos/*/contents/*")
if err != nil {
return nil, fmt.Errorf("error making request: %v", err)
}
@ -488,6 +490,14 @@ func (a *API) GetCodespaceRepositoryContents(ctx context.Context, codespace *Cod
return decoded, nil
}
func (a *API) do(ctx context.Context, req *http.Request, spanName string) (*http.Response, error) {
// TODO(adonovan): use NewRequestWithContext(ctx) and drop ctx parameter.
span, ctx := opentracing.StartSpanFromContext(ctx, spanName)
defer span.Finish()
req = req.WithContext(ctx)
return a.client.Do(req)
}
func (a *API) setHeaders(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+a.token)
req.Header.Set("Accept", "application/vnd.github.v3+json")

View file

@ -8,7 +8,6 @@ import (
"github.com/github/ghcs/api"
"github.com/github/ghcs/cmd/ghcs/output"
"github.com/github/ghcs/internal/codespaces"
"github.com/skratchdot/open-golang/open"
"github.com/spf13/cobra"
)
@ -54,9 +53,9 @@ func code(codespaceName string, useInsiders bool) error {
}
if codespaceName == "" {
codespace, err := codespaces.ChooseCodespace(ctx, apiClient, user)
codespace, err := chooseCodespace(ctx, apiClient, user)
if err != nil {
if err == codespaces.ErrNoCodespaces {
if err == errNoCodespaces {
return err
}
return fmt.Errorf("error choosing codespace: %v", err)

103
cmd/ghcs/common.go Normal file
View file

@ -0,0 +1,103 @@
package main
// This file defines functions common to the entire ghcs command set.
import (
"context"
"errors"
"fmt"
"sort"
"github.com/AlecAivazis/survey/v2"
"github.com/github/ghcs/api"
"golang.org/x/term"
)
var errNoCodespaces = errors.New("You have no codespaces.")
func chooseCodespace(ctx context.Context, apiClient *api.API, user *api.User) (*api.Codespace, error) {
codespaces, err := apiClient.ListCodespaces(ctx, user)
if err != nil {
return nil, fmt.Errorf("error getting codespaces: %v", err)
}
if len(codespaces) == 0 {
return nil, errNoCodespaces
}
sort.Slice(codespaces, func(i, j int) bool {
return codespaces[i].CreatedAt > codespaces[j].CreatedAt
})
codespacesByName := make(map[string]*api.Codespace)
codespacesNames := make([]string, 0, len(codespaces))
for _, codespace := range codespaces {
codespacesByName[codespace.Name] = codespace
codespacesNames = append(codespacesNames, codespace.Name)
}
sshSurvey := []*survey.Question{
{
Name: "codespace",
Prompt: &survey.Select{
Message: "Choose codespace:",
Options: codespacesNames,
Default: codespacesNames[0],
},
Validate: survey.Required,
},
}
var answers struct {
Codespace string
}
if err := ask(sshSurvey, &answers); err != nil {
return nil, fmt.Errorf("error getting answers: %v", err)
}
codespace := codespacesByName[answers.Codespace]
return codespace, nil
}
// getOrChooseCodespace prompts the user to choose a codespace if the codespaceName is empty.
// It then fetches the codespace token and the codespace record.
func getOrChooseCodespace(ctx context.Context, apiClient *api.API, user *api.User, codespaceName string) (codespace *api.Codespace, token string, err error) {
if codespaceName == "" {
codespace, err = chooseCodespace(ctx, apiClient, user)
if err != nil {
if err == errNoCodespaces {
return nil, "", err
}
return nil, "", fmt.Errorf("choosing codespace: %v", err)
}
codespaceName = codespace.Name
token, err = apiClient.GetCodespaceToken(ctx, user.Login, codespaceName)
if err != nil {
return nil, "", fmt.Errorf("getting codespace token: %v", err)
}
} else {
token, err = apiClient.GetCodespaceToken(ctx, user.Login, codespaceName)
if err != nil {
return nil, "", fmt.Errorf("getting codespace token for given codespace: %v", err)
}
codespace, err = apiClient.GetCodespace(ctx, token, user.Login, codespaceName)
if err != nil {
return nil, "", fmt.Errorf("getting full codespace details: %v", err)
}
}
return codespace, token, nil
}
var hasTTY = term.IsTerminal(0) && term.IsTerminal(1) // is process connected to a terminal?
// ask asks survey questions on the terminal, using standard options.
// It fails unless hasTTY, but ideally callers should avoid calling it in that case.
func ask(qs []*survey.Question, response interface{}) error {
if !hasTTY {
return fmt.Errorf("no terminal")
}
return survey.Ask(qs, response, survey.WithShowCursor(true))
}

View file

@ -286,8 +286,3 @@ func getMachineName(ctx context.Context, machine string, user *api.User, repo *a
return machine, nil
}
// ask asks survery questions using standard options.
func ask(qs []*survey.Question, response interface{}) error {
return survey.Ask(qs, response, survey.WithShowCursor(true))
}

View file

@ -8,7 +8,6 @@ import (
"github.com/github/ghcs/api"
"github.com/github/ghcs/cmd/ghcs/output"
"github.com/github/ghcs/internal/codespaces"
"github.com/spf13/cobra"
)
@ -63,7 +62,7 @@ func delete_(codespaceName string) error {
return fmt.Errorf("error getting user: %v", err)
}
codespace, token, err := codespaces.GetOrChooseCodespace(ctx, apiClient, user, codespaceName)
codespace, token, err := getOrChooseCodespace(ctx, apiClient, user, codespaceName)
if err != nil {
return fmt.Errorf("get or choose codespace: %v", err)
}

View file

@ -62,7 +62,7 @@ func logs(ctx context.Context, log *output.Logger, codespaceName string, follow
return fmt.Errorf("getting user: %v", err)
}
codespace, token, err := codespaces.GetOrChooseCodespace(ctx, apiClient, user, codespaceName)
codespace, token, err := getOrChooseCodespace(ctx, apiClient, user, codespaceName)
if err != nil {
return fmt.Errorf("get or choose codespace: %v", err)
}

View file

@ -4,8 +4,13 @@ import (
"errors"
"fmt"
"io"
"log"
"os"
"strconv"
"strings"
"github.com/lightstep/lightstep-tracer-go"
"github.com/opentracing/opentracing-go"
"github.com/spf13/cobra"
)
@ -18,22 +23,32 @@ func main() {
var version = "DEV"
var rootCmd = &cobra.Command{
Use: "ghcs",
SilenceUsage: true, // don't print usage message after each error (see #80)
SilenceErrors: false, // print errors automatically so that main need not
Long: `Unofficial CLI tool to manage GitHub Codespaces.
var rootCmd = newRootCmd()
func newRootCmd() *cobra.Command {
var lightstep string
root := &cobra.Command{
Use: "ghcs",
SilenceUsage: true, // don't print usage message after each error (see #80)
SilenceErrors: false, // print errors automatically so that main need not
Long: `Unofficial CLI tool to manage GitHub Codespaces.
Running commands requires the GITHUB_TOKEN environment variable to be set to a
token to access the GitHub API with.`,
Version: version,
Version: version,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if os.Getenv("GITHUB_TOKEN") == "" {
return tokenError
}
return nil
},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if os.Getenv("GITHUB_TOKEN") == "" {
return tokenError
}
return initLightstep(lightstep)
},
}
root.PersistentFlags().StringVar(&lightstep, "lightstep", "", "Lightstep tracing endpoint (service:token@host:port)")
return root
}
var tokenError = errors.New("GITHUB_TOKEN is missing")
@ -45,3 +60,51 @@ func explainError(w io.Writer, err error) {
return
}
}
// initLightstep parses the --lightstep=service:token@host:port flag and
// enables tracing if non-empty.
func initLightstep(config string) error {
if config == "" {
return nil
}
cut := func(s, sep string) (pre, post string) {
if i := strings.Index(s, sep); i >= 0 {
return s[:i], s[i+len(sep):]
}
return s, ""
}
// Parse service:token@host:port.
serviceToken, hostPort := cut(config, "@")
service, token := cut(serviceToken, ":")
host, port := cut(hostPort, ":")
portI, err := strconv.Atoi(port)
if err != nil {
return fmt.Errorf("invalid Lightstep configuration: %s", config)
}
opentracing.SetGlobalTracer(lightstep.NewTracer(lightstep.Options{
AccessToken: token,
Collector: lightstep.Endpoint{
Host: host,
Port: portI,
Plaintext: false,
},
Tags: opentracing.Tags{
lightstep.ComponentNameKey: service,
},
}))
// Report failure to record traces.
lightstep.SetGlobalEventHandler(func(ev lightstep.Event) {
switch ev := ev.(type) {
case lightstep.EventStatusReport, lightstep.MetricEventStatusReport:
// ignore
default:
log.Printf("[trace] %s", ev)
}
})
return nil
}

View file

@ -61,9 +61,9 @@ func ports(codespaceName string, asJSON bool) error {
return fmt.Errorf("error getting user: %v", err)
}
codespace, token, err := codespaces.GetOrChooseCodespace(ctx, apiClient, user, codespaceName)
codespace, token, err := getOrChooseCodespace(ctx, apiClient, user, codespaceName)
if err != nil {
if err == codespaces.ErrNoCodespaces {
if err == errNoCodespaces {
return err
}
return fmt.Errorf("error choosing codespace: %v", err)
@ -157,7 +157,7 @@ func getDevContainer(ctx context.Context, apiClient *api.API, codespace *api.Cod
// newPortsPublicCmd returns a Cobra "ports public" subcommand, which makes a given port public.
func newPortsPublicCmd() *cobra.Command {
newPortsPublicCmd := &cobra.Command{
return &cobra.Command{
Use: "public <port>",
Short: "Mark port as public",
Args: cobra.MinimumNArgs(1),
@ -181,13 +181,11 @@ func newPortsPublicCmd() *cobra.Command {
return updatePortVisibility(log, codespace, port, true)
},
}
return newPortsPublicCmd
}
// newPortsPrivateCmd returns a Cobra "ports private" subcommand, which makes a given port private.
func newPortsPrivateCmd() *cobra.Command {
newPortsPrivateCmd := &cobra.Command{
return &cobra.Command{
Use: "private <port>",
Short: "Mark port as private",
Args: cobra.MinimumNArgs(1),
@ -211,8 +209,6 @@ func newPortsPrivateCmd() *cobra.Command {
return updatePortVisibility(log, codespace, port, false)
},
}
return newPortsPrivateCmd
}
func updatePortVisibility(log *output.Logger, codespaceName, sourcePort string, public bool) error {
@ -224,9 +220,9 @@ func updatePortVisibility(log *output.Logger, codespaceName, sourcePort string,
return fmt.Errorf("error getting user: %v", err)
}
codespace, token, err := codespaces.GetOrChooseCodespace(ctx, apiClient, user, codespaceName)
codespace, token, err := getOrChooseCodespace(ctx, apiClient, user, codespaceName)
if err != nil {
if err == codespaces.ErrNoCodespaces {
if err == errNoCodespaces {
return err
}
return fmt.Errorf("error getting codespace: %v", err)
@ -258,7 +254,7 @@ func updatePortVisibility(log *output.Logger, codespaceName, sourcePort string,
// NewPortsForwardCmd returns a Cobra "ports forward" subcommand, which forwards a set of
// port pairs from the codespace to localhost.
func newPortsForwardCmd() *cobra.Command {
newPortsForwardCmd := &cobra.Command{
return &cobra.Command{
Use: "forward <remote-port>:<local-port>...",
Short: "Forward ports",
Args: cobra.MinimumNArgs(1),
@ -284,8 +280,6 @@ func newPortsForwardCmd() *cobra.Command {
return forwardPorts(log, codespace, ports)
},
}
return newPortsForwardCmd
}
func forwardPorts(log *output.Logger, codespaceName string, ports []string) error {
@ -302,9 +296,9 @@ func forwardPorts(log *output.Logger, codespaceName string, ports []string) erro
return fmt.Errorf("error getting user: %v", err)
}
codespace, token, err := codespaces.GetOrChooseCodespace(ctx, apiClient, user, codespaceName)
codespace, token, err := getOrChooseCodespace(ctx, apiClient, user, codespaceName)
if err != nil {
if err == codespaces.ErrNoCodespaces {
if err == errNoCodespaces {
return err
}
return fmt.Errorf("error getting codespace: %v", err)

View file

@ -52,7 +52,7 @@ func ssh(ctx context.Context, sshProfile, codespaceName string, localSSHServerPo
return fmt.Errorf("error getting user: %v", err)
}
codespace, token, err := codespaces.GetOrChooseCodespace(ctx, apiClient, user, codespaceName)
codespace, token, err := getOrChooseCodespace(ctx, apiClient, user, codespaceName)
if err != nil {
return fmt.Errorf("get or choose codespace: %v", err)
}

View file

@ -4,62 +4,12 @@ import (
"context"
"errors"
"fmt"
"sort"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/github/ghcs/api"
"github.com/github/go-liveshare"
)
var (
ErrNoCodespaces = errors.New("You have no codespaces.")
)
func ChooseCodespace(ctx context.Context, apiClient *api.API, user *api.User) (*api.Codespace, error) {
codespaces, err := apiClient.ListCodespaces(ctx, user)
if err != nil {
return nil, fmt.Errorf("error getting codespaces: %v", err)
}
if len(codespaces) == 0 {
return nil, ErrNoCodespaces
}
sort.Slice(codespaces, func(i, j int) bool {
return codespaces[i].CreatedAt > codespaces[j].CreatedAt
})
codespacesByName := make(map[string]*api.Codespace)
codespacesNames := make([]string, 0, len(codespaces))
for _, codespace := range codespaces {
codespacesByName[codespace.Name] = codespace
codespacesNames = append(codespacesNames, codespace.Name)
}
sshSurvey := []*survey.Question{
{
Name: "codespace",
Prompt: &survey.Select{
Message: "Choose codespace:",
Options: codespacesNames,
Default: codespacesNames[0],
},
Validate: survey.Required,
},
}
answers := struct {
Codespace string
}{}
if err := survey.Ask(sshSurvey, &answers); err != nil {
return nil, fmt.Errorf("error getting answers: %v", err)
}
codespace := codespacesByName[answers.Codespace]
return codespace, nil
}
type logger interface {
Print(v ...interface{}) (int, error)
Println(v ...interface{}) (int, error)
@ -123,35 +73,3 @@ func ConnectToLiveshare(ctx context.Context, log logger, apiClient *api.API, use
return lsclient.JoinWorkspace(ctx)
}
// GetOrChooseCodespace prompts the user to choose a codespace if the codespaceName is empty.
// It then fetches the codespace token and the codespace record.
func GetOrChooseCodespace(ctx context.Context, apiClient *api.API, user *api.User, codespaceName string) (codespace *api.Codespace, token string, err error) {
if codespaceName == "" {
codespace, err = ChooseCodespace(ctx, apiClient, user)
if err != nil {
if err == ErrNoCodespaces {
return nil, "", err
}
return nil, "", fmt.Errorf("choosing codespace: %v", err)
}
codespaceName = codespace.Name
token, err = apiClient.GetCodespaceToken(ctx, user.Login, codespaceName)
if err != nil {
return nil, "", fmt.Errorf("getting codespace token: %v", err)
}
} else {
token, err = apiClient.GetCodespaceToken(ctx, user.Login, codespaceName)
if err != nil {
return nil, "", fmt.Errorf("getting codespace token for given codespace: %v", err)
}
codespace, err = apiClient.GetCodespace(ctx, token, user.Login, codespaceName)
if err != nil {
return nil, "", fmt.Errorf("getting full codespace details: %v", err)
}
}
return codespace, token, nil
}