github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/codespace/common.go (about)

     1  package codespace
     2  
     3  // This file defines functions common to the entire codespace command set.
     4  
     5  import (
     6  	"context"
     7  	"errors"
     8  	"fmt"
     9  	"io"
    10  	"log"
    11  	"os"
    12  	"sort"
    13  
    14  	"github.com/AlecAivazis/survey/v2"
    15  	"github.com/AlecAivazis/survey/v2/terminal"
    16  	"github.com/ungtb10d/cli/v2/internal/browser"
    17  	"github.com/ungtb10d/cli/v2/internal/codespaces"
    18  	"github.com/ungtb10d/cli/v2/internal/codespaces/api"
    19  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    20  	"github.com/ungtb10d/cli/v2/pkg/liveshare"
    21  	"github.com/spf13/cobra"
    22  	"golang.org/x/crypto/ssh"
    23  	"golang.org/x/term"
    24  )
    25  
    26  type executable interface {
    27  	Executable() string
    28  }
    29  
    30  type App struct {
    31  	io         *iostreams.IOStreams
    32  	apiClient  apiClient
    33  	errLogger  *log.Logger
    34  	executable executable
    35  	browser    browser.Browser
    36  }
    37  
    38  func NewApp(io *iostreams.IOStreams, exe executable, apiClient apiClient, browser browser.Browser) *App {
    39  	errLogger := log.New(io.ErrOut, "", 0)
    40  
    41  	return &App{
    42  		io:         io,
    43  		apiClient:  apiClient,
    44  		errLogger:  errLogger,
    45  		executable: exe,
    46  		browser:    browser,
    47  	}
    48  }
    49  
    50  // StartProgressIndicatorWithLabel starts a progress indicator with a message.
    51  func (a *App) StartProgressIndicatorWithLabel(s string) {
    52  	a.io.StartProgressIndicatorWithLabel(s)
    53  }
    54  
    55  // StopProgressIndicator stops the progress indicator.
    56  func (a *App) StopProgressIndicator() {
    57  	a.io.StopProgressIndicator()
    58  }
    59  
    60  type liveshareSession interface {
    61  	Close() error
    62  	GetSharedServers(context.Context) ([]*liveshare.Port, error)
    63  	KeepAlive(string)
    64  	OpenStreamingChannel(context.Context, liveshare.ChannelID) (ssh.Channel, error)
    65  	StartJupyterServer(context.Context) (int, string, error)
    66  	StartSharing(context.Context, string, int) (liveshare.ChannelID, error)
    67  	StartSSHServer(context.Context) (int, string, error)
    68  	StartSSHServerWithOptions(context.Context, liveshare.StartSSHServerOptions) (int, string, error)
    69  	RebuildContainer(context.Context, bool) error
    70  }
    71  
    72  // Connects to a codespace using Live Share and returns that session
    73  func startLiveShareSession(ctx context.Context, codespace *api.Codespace, a *App, debug bool, debugFile string) (session liveshareSession, err error) {
    74  	liveshareLogger := noopLogger()
    75  	if debug {
    76  		debugLogger, err := newFileLogger(debugFile)
    77  		if err != nil {
    78  			return nil, fmt.Errorf("couldn't create file logger: %w", err)
    79  		}
    80  		defer safeClose(debugLogger, &err)
    81  
    82  		liveshareLogger = debugLogger.Logger
    83  		a.errLogger.Printf("Debug file located at: %s", debugLogger.Name())
    84  	}
    85  
    86  	session, err = codespaces.ConnectToLiveshare(ctx, a, liveshareLogger, a.apiClient, codespace)
    87  	if err != nil {
    88  		return nil, fmt.Errorf("failed to connect to Live Share: %w", err)
    89  	}
    90  
    91  	return session, nil
    92  }
    93  
    94  //go:generate moq -fmt goimports -rm -skip-ensure -out mock_api.go . apiClient
    95  type apiClient interface {
    96  	GetCodespace(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error)
    97  	GetOrgMemberCodespace(ctx context.Context, orgName string, userName string, codespaceName string) (*api.Codespace, error)
    98  	ListCodespaces(ctx context.Context, opts api.ListCodespacesOptions) ([]*api.Codespace, error)
    99  	DeleteCodespace(ctx context.Context, name string, orgName string, userName string) error
   100  	StartCodespace(ctx context.Context, name string) error
   101  	StopCodespace(ctx context.Context, name string, orgName string, userName string) error
   102  	CreateCodespace(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error)
   103  	EditCodespace(ctx context.Context, codespaceName string, params *api.EditCodespaceParams) (*api.Codespace, error)
   104  	GetRepository(ctx context.Context, nwo string) (*api.Repository, error)
   105  	GetCodespacesMachines(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error)
   106  	GetCodespaceRepositoryContents(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error)
   107  	ListDevContainers(ctx context.Context, repoID int, branch string, limit int) (devcontainers []api.DevContainerEntry, err error)
   108  	GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error)
   109  	GetCodespaceBillableOwner(ctx context.Context, nwo string) (*api.User, error)
   110  }
   111  
   112  var errNoCodespaces = errors.New("you have no codespaces")
   113  
   114  func chooseCodespace(ctx context.Context, apiClient apiClient) (*api.Codespace, error) {
   115  	codespaces, err := apiClient.ListCodespaces(ctx, api.ListCodespacesOptions{})
   116  	if err != nil {
   117  		return nil, fmt.Errorf("error getting codespaces: %w", err)
   118  	}
   119  	return chooseCodespaceFromList(ctx, codespaces, false)
   120  }
   121  
   122  // chooseCodespaceFromList returns the codespace that the user has interactively selected from the list, or
   123  // an error if there are no codespaces.
   124  func chooseCodespaceFromList(ctx context.Context, codespaces []*api.Codespace, includeOwner bool) (*api.Codespace, error) {
   125  	if len(codespaces) == 0 {
   126  		return nil, errNoCodespaces
   127  	}
   128  
   129  	sortedCodespaces := codespaces
   130  	sort.Slice(sortedCodespaces, func(i, j int) bool {
   131  		return sortedCodespaces[i].CreatedAt > sortedCodespaces[j].CreatedAt
   132  	})
   133  
   134  	csSurvey := []*survey.Question{
   135  		{
   136  			Name: "codespace",
   137  			Prompt: &survey.Select{
   138  				Message: "Choose codespace:",
   139  				Options: formatCodespacesForSelect(sortedCodespaces, includeOwner),
   140  			},
   141  			Validate: survey.Required,
   142  		},
   143  	}
   144  
   145  	var answers struct {
   146  		Codespace int
   147  	}
   148  	if err := ask(csSurvey, &answers); err != nil {
   149  		return nil, fmt.Errorf("error getting answers: %w", err)
   150  	}
   151  
   152  	return sortedCodespaces[answers.Codespace], nil
   153  }
   154  
   155  func formatCodespacesForSelect(codespaces []*api.Codespace, includeOwner bool) []string {
   156  	names := make([]string, len(codespaces))
   157  
   158  	for i, apiCodespace := range codespaces {
   159  		cs := codespace{apiCodespace}
   160  		names[i] = cs.displayName(includeOwner)
   161  	}
   162  
   163  	return names
   164  }
   165  
   166  // getOrChooseCodespace prompts the user to choose a codespace if the codespaceName is empty.
   167  // It then fetches the codespace record with full connection details.
   168  // TODO(josebalius): accept a progress indicator or *App and show progress when fetching.
   169  func getOrChooseCodespace(ctx context.Context, apiClient apiClient, codespaceName string) (codespace *api.Codespace, err error) {
   170  	if codespaceName == "" {
   171  		codespace, err = chooseCodespace(ctx, apiClient)
   172  		if err != nil {
   173  			if err == errNoCodespaces {
   174  				return nil, err
   175  			}
   176  			return nil, fmt.Errorf("choosing codespace: %w", err)
   177  		}
   178  	} else {
   179  		codespace, err = apiClient.GetCodespace(ctx, codespaceName, true)
   180  		if err != nil {
   181  			return nil, fmt.Errorf("getting full codespace details: %w", err)
   182  		}
   183  	}
   184  
   185  	if codespace.PendingOperation {
   186  		return nil, fmt.Errorf(
   187  			"codespace is disabled while it has a pending operation: %s",
   188  			codespace.PendingOperationDisabledReason,
   189  		)
   190  	}
   191  
   192  	return codespace, nil
   193  }
   194  
   195  func safeClose(closer io.Closer, err *error) {
   196  	if closeErr := closer.Close(); *err == nil {
   197  		*err = closeErr
   198  	}
   199  }
   200  
   201  // hasTTY indicates whether the process connected to a terminal.
   202  // It is not portable to assume stdin/stdout are fds 0 and 1.
   203  var hasTTY = term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
   204  
   205  // ask asks survey questions on the terminal, using standard options.
   206  // It fails unless hasTTY, but ideally callers should avoid calling it in that case.
   207  func ask(qs []*survey.Question, response interface{}) error {
   208  	if !hasTTY {
   209  		return fmt.Errorf("no terminal")
   210  	}
   211  	err := survey.Ask(qs, response, survey.WithShowCursor(true))
   212  	// The survey package temporarily clears the terminal's ISIG mode bit
   213  	// (see tcsetattr(3)) so the QUIT button (Ctrl-C) is reported as
   214  	// ASCII \x03 (ETX) instead of delivering SIGINT to the application.
   215  	// So we have to serve ourselves the SIGINT.
   216  	//
   217  	// https://github.com/AlecAivazis/survey/#why-isnt-ctrl-c-working
   218  	if err == terminal.InterruptErr {
   219  		self, _ := os.FindProcess(os.Getpid())
   220  		_ = self.Signal(os.Interrupt) // assumes POSIX
   221  
   222  		// Suspend the goroutine, to avoid a race between
   223  		// return from main and async delivery of INT signal.
   224  		select {}
   225  	}
   226  	return err
   227  }
   228  
   229  var ErrTooManyArgs = errors.New("the command accepts no arguments")
   230  
   231  func noArgsConstraint(cmd *cobra.Command, args []string) error {
   232  	if len(args) > 0 {
   233  		return ErrTooManyArgs
   234  	}
   235  	return nil
   236  }
   237  
   238  func noopLogger() *log.Logger {
   239  	return log.New(io.Discard, "", 0)
   240  }
   241  
   242  type codespace struct {
   243  	*api.Codespace
   244  }
   245  
   246  // displayName formats the codespace name for the interactive selector prompt.
   247  func (c codespace) displayName(includeOwner bool) string {
   248  	branch := c.branchWithGitStatus()
   249  	displayName := c.DisplayName
   250  
   251  	if displayName == "" {
   252  		displayName = c.Name
   253  	}
   254  
   255  	description := fmt.Sprintf("%s (%s): %s", c.Repository.FullName, branch, displayName)
   256  
   257  	if includeOwner {
   258  		description = fmt.Sprintf("%-15s %s", c.Owner.Login, description)
   259  	}
   260  
   261  	return description
   262  }
   263  
   264  // gitStatusDirty represents an unsaved changes status.
   265  const gitStatusDirty = "*"
   266  
   267  // branchWithGitStatus returns the branch with a star
   268  // if the branch is currently being worked on.
   269  func (c codespace) branchWithGitStatus() string {
   270  	if c.hasUnsavedChanges() {
   271  		return c.GitStatus.Ref + gitStatusDirty
   272  	}
   273  
   274  	return c.GitStatus.Ref
   275  }
   276  
   277  // hasUnsavedChanges returns whether the environment has
   278  // unsaved changes.
   279  func (c codespace) hasUnsavedChanges() bool {
   280  	return c.GitStatus.HasUncommitedChanges || c.GitStatus.HasUnpushedChanges
   281  }
   282  
   283  // running returns whether the codespace environment is running.
   284  func (c codespace) running() bool {
   285  	return c.State == api.CodespaceStateAvailable
   286  }