diff --git a/pkg/cmd/codespace/root.go b/pkg/cmd/codespace/root.go index 0a04d2fdd..662f4c2de 100644 --- a/pkg/cmd/codespace/root.go +++ b/pkg/cmd/codespace/root.go @@ -20,6 +20,7 @@ func NewRootCmd(app *App) *cobra.Command { root.AddCommand(newSSHCmd(app)) root.AddCommand(newCpCmd(app)) root.AddCommand(newStopCmd(app)) + root.AddCommand(newSelectCmd(app)) return root } diff --git a/pkg/cmd/codespace/select.go b/pkg/cmd/codespace/select.go new file mode 100644 index 000000000..d95162c12 --- /dev/null +++ b/pkg/cmd/codespace/select.go @@ -0,0 +1,75 @@ +package codespace + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +type selectOptions struct { + filePath string +} + +func newSelectCmd(app *App) *cobra.Command { + opts := selectOptions{} + + selectCmd := &cobra.Command{ + Use: "select", + Short: "Select a Codespace", + Hidden: true, + Args: noArgsConstraint, + RunE: func(cmd *cobra.Command, args []string) error { + return app.Select(cmd.Context(), "", opts) + }, + } + + selectCmd.Flags().StringVarP(&opts.filePath, "file", "f", "", "Output file path") + return selectCmd +} + +// Hidden codespace `select` command allows to reuse existing codespace selection +// dialog by external GH CLI extensions. By default output selected codespace name +// into `stdout`. Pass `--file`(`-f`) flag along with a file path to output selected +// codespace name into a file instead. +// +// ## Examples +// +// With `stdout` output: +// +// ```shell +// gh codespace select +// ``` +// +// With `into-a-file` output: +// +// ```shell +// gh codespace select --file /tmp/selected_codespace.txt +// ``` +func (a *App) Select(ctx context.Context, name string, opts selectOptions) (err error) { + codespace, err := getOrChooseCodespace(ctx, a.apiClient, name) + if err != nil { + return err + } + + if opts.filePath != "" { + f, err := os.Create(opts.filePath) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + + defer safeClose(f, &err) + + _, err = f.WriteString(codespace.Name) + if err != nil { + return fmt.Errorf("failed to write codespace name to output file: %w", err) + } + + return nil + } + + fmt.Fprintln(a.io.Out, codespace.Name) + + return nil +} diff --git a/pkg/cmd/codespace/select_test.go b/pkg/cmd/codespace/select_test.go new file mode 100644 index 000000000..b2253f1d4 --- /dev/null +++ b/pkg/cmd/codespace/select_test.go @@ -0,0 +1,107 @@ +package codespace + +import ( + "context" + "errors" + "fmt" + "io/ioutil" + "os" + "testing" + + "github.com/cli/cli/v2/internal/codespaces/api" + "github.com/cli/cli/v2/pkg/iostreams" +) + +const CODESPACE_NAME = "monalisa-cli-cli-abcdef" + +func TestApp_Select(t *testing.T) { + tests := []struct { + name string + arg string + wantErr bool + outputToFile bool + wantStdout string + wantStderr string + wantFileContents string + }{ + { + name: "Select a codespace", + arg: CODESPACE_NAME, + wantErr: false, + wantStdout: fmt.Sprintf("%s\n", CODESPACE_NAME), + }, + { + name: "Select a codespace error", + arg: "non-existent-codespace-name", + wantErr: true, + }, + { + name: "Select a codespace", + arg: CODESPACE_NAME, + wantErr: false, + wantFileContents: CODESPACE_NAME, + outputToFile: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdinTTY(true) + io.SetStdoutTTY(true) + a := NewApp(io, nil, testSelectApiMock(), nil) + + opts := selectOptions{} + if tt.outputToFile { + file, err := ioutil.TempFile("", "codespace-selection-test") + if err != nil { + t.Fatal(err) + } + + defer os.Remove(file.Name()) + + opts = selectOptions{filePath: file.Name()} + } + + if err := a.Select(context.Background(), tt.arg, opts); (err != nil) != tt.wantErr { + t.Errorf("App.Select() error = %v, wantErr %v", err, tt.wantErr) + } + + if out := stdout.String(); out != tt.wantStdout { + t.Errorf("stdout = %q, want %q", out, tt.wantStdout) + } + if out := sortLines(stderr.String()); out != tt.wantStderr { + t.Errorf("stderr = %q, want %q", out, tt.wantStderr) + } + + if tt.wantFileContents != "" { + if opts.filePath == "" { + t.Errorf("wantFileContents is set but opts.filePath is not") + } + + dat, err := os.ReadFile(opts.filePath) + if err != nil { + t.Fatal(err) + } + + if string(dat) != tt.wantFileContents { + t.Errorf("file contents = %q, want %q", string(dat), CODESPACE_NAME) + } + } + }) + } +} + +func testSelectApiMock() *apiClientMock { + testingCodespace := &api.Codespace{ + Name: CODESPACE_NAME, + } + return &apiClientMock{ + GetCodespaceFunc: func(_ context.Context, name string, includeConnection bool) (*api.Codespace, error) { + if name == CODESPACE_NAME { + return testingCodespace, nil + } + + return nil, errors.New("cannot find codespace") + }, + } +}